去年我们团队接到一个比较特别的需求:做一款面向三国杀玩家的攻略工具App,目标平台之一是国产开源的OpenHarmony系统。技术栈没有纠结太久,直接选了Flutter。这个项目后来被我当成Flutter for OpenHarmony的实战样板,尤其是设置功能这块,踩的坑和沉淀的经验都够写一篇长文了。这篇就围绕“Flutter for OpenHarmony三国杀攻略App实战——设置功能实现”展开,把从选型思考、环境搭建、需求拆分、UI实现到数据持久化、全局联动、真机排错的全过程捋一遍,给后面想在这个方向动手的同学一些可复现的参考。
1. 从一个大需求说起:为什么三国杀攻略App选Flutter跑在OpenHarmony上
1.1 攻略App的真实使用场景
三国杀这个游戏有个特点:武将多、技能复杂、牌堆规则细、身份局和国战模式差异又大。很多玩家打开攻略App,不是去看新闻,而是快速查“当前武将的技能是什么”“这张牌该怎么结算”“主公被集火时怎么出桃”。所以App的核心价值是信息密度要高、检索要快、阅读要舒服,这直接决定了设置功能不能只是摆设。
我们的目标用户画得很清楚:线下桌游玩家和线上对局玩家都有,碎片时间多,经常在牌桌上临时翻手机。这类用户对App有几个硬性需求:
- 武将攻略支持按势力、按强度排序,还要能记住自己的常用武将。
- 牌局规则速查要能快速定位到“结算流程”和“FAQ”两个入口。
- 字体要能放大,很多老玩家在牌桌上对着一堆文字,字号小了是真看不清。
- 晚上打牌多,深色模式必须做好,不能白花花一片闪眼睛。
- 音效和震动提示在安静场合要能一键全关。
围绕这些,我们把设置功能从“系统默认选项”提升到了“影响阅读体验的核心模块”这个定位,后面整个技术方案都是为了服务这个定位。
1.2 Flutter与ArkUI的选型权衡
最开始摆在面前的其实有三条路:ArkUI原生开发、Flutter跨平台、以及Web套壳。逐一说一下当时的权衡。
ArkUI原生开发确实是OpenHarmony亲儿子,性能潜力最强,和系统能力结合也最顺。但问题在于:我们团队已经有了一套成熟的Flutter组件库和历史代码资产,Android和iOS端的攻略App已经跑了大半年,如果只为OpenHarmony重新用ArkUI写一遍,相当于业务逻辑和UI全部重做,成本会翻倍。而且ArkUI的生态成熟度还在爬坡,很多三方库要自己封装。
Web套壳开发效率极高,但三国杀攻略里有大量列表滑动、图片懒加载、主题切换这类交互,WebView的性能和原生体验差距明显,尤其在小内存设备上能直接感受到掉帧。既然要做长期运营的产品,我们不想在这个维度妥协。
Flutter的优势就很明显了:渲染引擎自绘,UI在不同平台上的一致性非常强;Dart语言的空安全和组件化模型能复用安卓/iOS的全部业务代码;而且Flutter针对OpenHarmony的适配在当时已经能做基础交叉编译。代价是插件生态需要逐个确认OpenHarmony支持度,部分平台通道要自己补。这个代价我们觉得可控,毕竟App的核心功能是图文展示,不重度依赖摄像头、蓝牙这类系统能力。
1.3 整个项目的模块结构
项目不是单页面Demo,而是按业务域拆的:
- 首页:推荐内容聚合,包含今日武将、热门牌局问答。
- 武将图鉴:亿级数据量没有,但几千条武将条目加上头像、技能文本、关联FAQ,列表渲染压力不小。
- 规则速查:树状结构,支持搜索和收藏。
- 个人信息中心:登录态、奖励、设置入口。
设置功能放在“个人中心>设置”二级页面,但它影响的范围覆盖整个App。所以我们在设计初期没有把设置当成一个孤立的页面来看,而是当成一个“全局配置中心”来做,这个定位直接决定了后续的数据层设计和状态管理方案。
2. 环境搭建与工程初始化:Flutter与OpenHarmony的工具链对齐
2.1 版本选型与对应关系
Flutter开发Android时,版本对应关系很清晰;到了Flutter for OpenHarmony,版本对应就成了第一道坎。我们当时选配的原则是“能用稳定版就绝不追新”,因为OpenHarmony适配往往滞后于Flutter官方版本。
关键对应关系大概是这样:
| 组件 | 版本选择原则 | 说明 |
|---|---|---|
| Flutter SDK | 使用社区已适配OpenHarmony的稳定分支 | 需要确认对应分支包含OpenHarmony构建目标 |
| Dart SDK | 随Flutter SDK绑定 | 不要单独升级,避免ABI不匹配 |
| OpenHarmony SDK | 按目标设备支持的最低API版本选择 | 我们选的是能覆盖主推机型的API级别 |
| 官方IDE | 使用当时API级别的配套版本 | 高版本IDE有时强制要求更高API,反而麻烦 |
这个表看起来简单,实际操作里最容易翻车的是“Flutter SDK分支”和“OpenHarmony SDK API版本”的配对。我见过不少同学把Flutter稳定版直接执行构建命令,结果发现根本没有OpenHarmony编译目标,折腾半天。正确的做法是先查适配分支的说明文档,确认支持的最低API版本,再对照目标设备的系统版本。
2.2 初始化Flutter工程并挂载OpenHarmony工程
初始化流程本身不复杂,但有个点需要特别注意:Flutter工程和OpenHarmony工程不是“二选一”,而是“两个工程并存、通过构建命令衔接”。
我们的标准操作流程是这样的:
- 执行
flutter create创建标准Flutter工程,这会生成lib/、pubspec.yaml等目录。 - 在工程根目录下手动添加OpenHarmony侧工程目录。这一步不能用Flutter自动生成,因为适配分支的模板里已经包含了
ohos目录约定。 - 打开官方IDE,导入OpenHarmony侧目录,让它负责工程的编译、签名和安装。
- 在Flutter侧执行构建命令,产物会输出到OpenHarmony工程指定目录,再由IDE统一打包。
- 首次联调时,先不写任何业务代码,用默认页面验证整条链路能跑通。
这里有个操作细节值得强调:OpenHarmony侧的工程文件不要用IDE的向导从零创建,而是直接复制适配分支模板里的示例。因为模板里已经把权限声明、引擎初始化、资源路径这些关键项都配好了,自己从零配置很容易漏掉某个权限或者模块。我们工程初期用模板改,一小时就通了;团队里有同学想从零配,折腾了两天才跑出第一帧。
2.3 跑通第一帧的完整验证步骤
很多人以为“能编译”就代表“能跑”,但Flutter渲染引擎在OpenHarmony上的第一帧比想象中容易出现黑屏或白屏。我们当时设计了一套小小的验证清单,后面每次环境变更都会重跑一遍:
- 默认计数器Demo能编译通过。
- 安装到真机后,点击图标能进入首页,计数器按钮响应正常。
- 修改
main.dart里的颜色值,执行热重载或热重启,页面颜色变化能实时反映。 - 打开开发者工具查看日志,没有出现渲染器和平台通道相关的异常输出。
- 手动把设备切到飞行模式再退出,确认App不会崩溃。
这套验证里最容易出问题的不是编译,而是“热重载后页面没变化”。这不是代码逻辑问题,而是Flutter在OpenHarmony上的热重载和原生平台不完全一致,偶尔需要手动触发完整重启。这个我在第6节会详细说,它真的会坑到你怀疑是不是写错了代码。
3. 设置功能的需求拆解与数据层设计
3.1 设置项清单:玩家真正需要的六类配置
三国杀攻略App的设置项,不能照搬通用App那一套“通用>通知>权限”的结构。我们的功能清单是在玩家群和内部测试中反复迭代出来的,最终收敛成六类:
- 深色模式:支持系统默认、强制浅色、强制深色三种状态。
- 主题色:给不同阵营做了配色,比如主公可选暗红、内奸可选暗紫,偏好性强。
- 字体缩放:提供从标准到特大四档,主要影响武将技能详情和规则速查长文。
- 音效开关:牌局提示音、点击音、技能释放音分别控制。
- 震动反馈:出牌震动和提醒震动,震动强度分弱、中、强三档。
- 缓存清理:显示当前缓存占用,一键清除图片和临时文件。
这六类配置的共性问题是:它们都不是单一维度的布尔值。深色模式是三态枚举,字体缩放是数值档位,震动强度是三档枚举,缓存清理是异步操作加状态反馈。所以数据模型设计上不能偷懒全用bool,得用枚举和数值类型,并且在UI层做合理映射。
3.2 配置持久化方案:从原生偏好存储到轻量数据库
Flutter默认的配置持久化方案是shared_preferences,它本质是键值对存储,适合轻量配置。我们原本计划直接用它的OpenHarmony实现,结果发现适配进度不一致,有的版本没有OpenHarmony平台实现,一调就崩。
我们实际用的是一套分层方案:
- 配置类数据(深色模式、主题色、字体档位、开关状态)走键值对存储。
- 缓存大小的统计和历史记录走文件目录计算,不依赖数据库。
- 后续如果要做“用户自定义武将标签”这类结构化数据,再考虑引入轻量级数据库。
这个分层的逻辑很简单:设置功能要的是“读得快、写得快、改了立刻生效”,不需要复杂查询;而缓存清理要的是“能准确统计目录大小”,这属于文件系统操作,不归存储框架管。
存储封装的接口设计上,我们做了一层SettingsRepository抽象,上层业务不直接调用插件,而是通过它读取。这样做的直接好处是:后来发现某个插件在OpenHarmony上不兼容,只需要替换这一层的实现,所有调用方不用跟着改。
3.3 配置模型与默认值管理
配置模型如果只用Map<String, Object>散着存,后续维护会非常痛苦。我们定义了一个带类型的配置模型类,把所有设置项收敛成一个不可变对象:
- 每个字段对应一个设置项,类型明确,枚举用Dart的
enum定义。 - 默认值集中在一个常量类里,保证任何情况下都有兜底值。
- 读取时如果某个键不存在,统一走默认值,不向上抛异常。
- 写入时先构建新对象再整体保存,避免半写入状态。
这里值得展开说一下默认值管理。很多新手会把默认值写死在页面组件里,比如themeMode ?? ThemeMode.system,看起来没错,但一旦有多个页面都要读取同一个配置,每个页面都要写一遍兜底逻辑,万一某个页面忘了写,就会出现“第一个页面是深色,第二个页面是浅色”的诡异现象。我们把默认值收敛到数据层之后,这个问题从根上化解了。
4. 设置页UI的实现:分组列表、开关、滑杆与深色模式
4.1 分组列表骨架与设置项驱动模型
设置页的UI结构是典型的分组列表:顶部是外观设置组、中间是声音与震动组、底部是存储与关于组。Flutter里实现分组列表最直接的方式是ListView配合多个Column或Section组件。但为了避免写出一大堆重复的ListTile,我们做了一层“设置项模型驱动”的结构。
每个设置项对应一个配置描述对象,包含:
- 标题和副标题文本。
- 当前值读取方式。
- 变更回调。
- 右侧控件类型(开关、滑杆、占位符、跳转箭头)。
UI层根据模型类型自动渲染对应控件。这样新增一个设置项时,只需要在模型列表里加一条配置,页面代码几乎不用动。这个模式在后续迭代里帮了大忙,比如后来加“震动强度选择”时,我们只加了模型和对应控件类型,页面骨架零改动。
4.2 深色模式与三国杀主题配色的动态切换
深色模式是设置功能里最折磨人也最出效果的一块。实现上核心是MaterialApp的themeMode和ThemeData的切换,但真正复杂的是三国杀主题色怎么在不同模式下保持协调。
我们的做法是预置了一套完整的ColorScheme:浅色模式下背景是暖白色,主色是深红和暗金;深色模式下背景是墨蓝黑,主色换成暗红和浅金。每个模式都定义完整的ColorScheme,包括背景、卡片、文字主次、分隔线、按钮状态等。如果只切换themeMode但主题色不换,深色模式会显得很闷,这是很多App的通病。
实现上有一个关键点:设置页里的深色模式切换,要能立刻让整个App的页面刷新。这不是简单的setState能完成的,而是要让全局配置变化通知到所有当前存活的页面。这个我在第5节会展开。
另一个值得提的小细节是:三国杀主题色里用了大量低饱和度的红色和金色,在浅色和深色背景下对文字的对比度差异很大。我们专门用对比度计算工具检查过每一组前景色和背景色的组合,确保在四档字号下的可读性都过关。这块是纯视觉细节,但它直接决定了玩家愿不愿意长时间在App里翻攻略。
4.3 字体缩放滑杆与音效/震动开关
字体缩放实现用的是Slider控件,档位是标准、较大、大、特大四档,对应一个缩放系数数组。难点不在滑杆本身,而在如何让缩放“所见即所得”,让玩家在设置页调字号时能立刻看到预览文字和真实阅读页面的效果。
我们的实现逻辑是:
Slider的值映射到TextScaler的缩放系数。- 设置页顶部放一段预览文本,内容选的是三国杀里一段技能描述,比如“出牌阶段限一次,你可以令一名其他角色选择一项……”
- 预览文本外层套一个通过配置模型读取缩放系数的构建器,滑杆变化时预览文本同步更新。
- 全局的
MaterialApp通过 builder 注入同样的文本缩放系数,这样阅读页、列表页、弹窗全都跟着变。
这个“全局注入”方案比在每一个页面手动加TextStyle要干净得多,但代价是全局builder的构建逻辑不能写得重,否则每次配置变化整个组件树都要重建,性能会明显下降。我们的优化是:只把缩放系数和主题色这两个高频变化的值放进全局通知,其他低频配置不变更时不触发builder重建。
音效和震动开关相对简单,核心是Switch控件绑定布尔值。但要注意“音效开关”和“震动开关”是总开关,设置项里还可以分别控制“点击音、技能音、提示音”和“震动强弱”。总开关关闭时,所有子开关全部禁用,UI上用灰色态呈现。
4.4 缓存清理与关于页面的实现细节
缓存清理在设置功能里属于“非典型选项”,它有异步操作和结果反馈,不能像普通开关那样即时保存。我们的实现分三步:
- 进入设置页时,异步计算缓存目录总大小,计算完回传显示在列表副标题上。
- 点击“清理缓存”时弹出确认对话框,明确提示将清除图片缓存和临时文件。
- 清理过程中按钮置灰并显示进度,清理完成后用轻提示反馈结果,同时刷新缓存大小显示。
这里有个小坑:计算缓存目录大小和删除缓存文件都涉及文件系统操作,在OpenHarmony上的目录路径不能写死,必须通过路径工具去取 App 的缓存目录。我们一开始写死了相对路径,在部分设备上清理逻辑直接失效,改成工具类获取后问题消失。
关于页面的实现反而是配置项里最不费劲的,放一个Logo、版本号、开源协议入口就够了。但有一点值得做:版本号不要硬编码,要从构建环境读取,否则每次发版都要进代码里改。我们通过一个简单的常量加构建标记解决,省去后续维护成本。
5. 设置与全局状态的联动:让配置改完立刻生效
5.1 轻量级状态方案:ValueNotifier替代重状态管理框架
设置功能最核心的诉求是“改完立刻生效”,这对全局状态管理提出了要求。业内常见的方案是直接上一个状态管理框架,但我们的项目体量不大,纯粹为了设置功能引入完整的状态管理框架,反而会增加学习成本和构建复杂度。
我们用的是Flutter生态里非常基础的ValueNotifier加ValueListenableBuilder组合。设置数据模型作为全局的单例,内部持有一个ValueNotifier,每次配置变更就通知所有监听者,UI层被ValueListenableBuilder包裹的页面自动重建。
这个方案的优点很突出:
- 不需要额外依赖,代码量极少。
- 数据流清晰:配置模型是唯一数据源,UI只是它的映射。
- 对性能友好:只有真正依赖某几项配置的组件才会重建,而不是整个页面全量重建。
同样,它也有缺点:如果项目持续膨胀,设置项数量翻倍,依赖关系会变复杂,届时要考虑引入更结构化的方案。就目前的攻略App体量来说,ValueNotifier完全够用。
5.2 攻略列表与阅读页如何响应配置变化
配置变化影响的页面有两类:一类是依赖主题色的页面,另一类是依赖字体缩放的页面。
攻略列表页依赖的是主题色和深色模式。当玩家在设置页切了深色模式,返回列表页时要看到背景和卡片颜色立刻变化。我们用ValueListenableBuilder包住列表页的根组件,监听配置模型的变化,变化后重新读取主题构建列表。这样切模式没有闪屏,也没有先浅后深的分帧感。
阅读页依赖的是字体缩放。我们在阅读页的滚动内容外层套了同样的ValueListenableBuilder,但优化方式是:缩放系数变化时只重建文本区块,不重建列表头和底部操作栏。这个细粒度控制让阅读页在调节字号时保持流畅,不会出现整页闪烁。
需要特别指出的是:设置页本身可以实时预览,玩家在滑动字号滑杆时,预览文本和真实阅读页用的是同一个缩放系数来源,这就保证了预览内容的准确性。我们在真机上验证过,从设置页返回阅读页,字号完全一致,没有出现“预览是特大,实际是标准”的错位。
5.3 音效资源的延迟加载与缓存清理的并发保护
音效和震动的联动有一个容易忽略的点:开关切换时,已经加载的音效资源要不要立即释放?如果释放,切回去时要不要重新加载?
我们的做法是:音效资源不是常驻内存的,而是按需加载的。进入牌局提示场景时才加载对应音频,设置项关闭时不加载新资源,已经在播放的音频立即停止并释放。这样做的好处是开关切换的响应速度不受资源加载影响,坏处是第一次播放提示音时有轻微的加载延迟。我们接受这个延迟,因为在真机上感知不到明显卡顿。
缓存清理的并发保护是另一个很容易写崩的点。清理缓存是异步操作,如果在清理过程中玩家又点了清理按钮,或者进入了需要读取缓存文件的页面,会出现双重清理或者读取到半删除文件的问题。我们用一个布尔锁变量控制:清理期间按钮禁用、入口置灰,清理事务完成后才解锁。这个看似简单的问题,上线前就帮我们堵住了两个潜在的崩溃场景。
6. 在OpenHarmony实机上踩过的坑与性能观察
6.1 偏好存储插件在OpenHarmony上的MissingPluginException
这是我们在Flutter for OpenHarmony上遇到的第一个硬伤。当时直接引入shared_preferences,跑在OpenHarmony真机上,只要页面调用了读取配置的接口,日志里就抛MissingPluginException,设置页空白。
排查路径是很经典的“是不是插件没适配”思路:
- 查看插件包的目录结构,确认是否有
ohos平台目录。 - 去适配分支的插件列表里查支持状态。
- 确认
pubspec.yaml里用的版本是否包含OpenHarmony实现。
结论是:当前依赖的版本确实还没跟上OpenHarmony适配。我们的处理方案是把偏好存储这一层替换成基于文件系统的轻量实现,用JSON格式把所有设置项持久化到应用私有目录,再包一层异步接口对外提供读写能力。这样做虽然少了一些键值对存储的便利性,但完全绕开了插件兼容问题,读写速度也够用。
这个经历给我们的教训是:在OpenHarmony上使用任何Flutter插件之前,第一优先级是确认它的OpenHarmony适配状态,不要想当然认为能用在Android上就能用在OpenHarmony上。
6.2 生命周期回调差异:切后台再回前台的状态恢复
Flutter标准生命周期里有个AppLifecycleState,指示应用处于前台、后台、暂停还是挂起状态。在Android/iOS上这套状态已经比较成熟,但在OpenHarmony上,生命周期回调的触发时机和原生系统事件存在一些错位。
我们遇到的实际情况是:三国杀攻略App在真机上切后台再回前台时,音效开关的状态显示是正常的,但实际音效模块没有重新初始化,导致关闭状态下的震动还是偶尔会触发。后来排查确定是生命周期回调没有被正确映射到音效模块的复位逻辑上。
解决方案是在音效模块的初始化代码里增加“前台恢复”强感知逻辑:每次状态切换为前台时,重新读取配置模型里的音效开关值,按最新值重新决定是否启用音频播放。不能依赖模块内部缓存的旧状态,必须以配置中心的最新值为准。
这也算是OpenHarmony适配的通用经验:平台差异不只体现在编译和插件上,业务代码里凡是依赖系统生命周期的地方,都要预留“重新同步配置”的入口。
6.3 热重载对设置状态的破坏
用Flutter开发时,热重载是一个非常依赖的习惯,改个UI代码几秒钟就能看到效果。但到了OpenHarmony上,热重载并不总是可靠。
我们遇到的具体情况是:配置模型里已经写好的值,在热重载后居然回退了。看代码,逻辑没问题;重新执行一次热重启,又恢复了。后来确认是适配层在热重载时没有保留Dart堆中的全局单例状态,导致内存中的配置模型被重新初始化。这不是我们代码的问题,是平台的局限。
所以我们的工作流也随之调整:涉及配置模型和全局状态的部分,改完代码后不要依赖热重载,直接热重启;只改纯UI表现时再放心用热重载。一开始不适应,总想图快,结果被假状态误导了几次之后,团队就统一用这个规矩了。
6.4 包体积、启动耗时与主题切换流畅度实测
性能数据这块,我们没有在模拟器上测,全部以真机数据为准。选择的测试设备是主流的入门级低内存机器,理由是攻略App的使用场景里有不少老型号手机,跑得动入门级设备才算及格。
观察结果有几个参考值:
- 包体积相比纯Flutter工程会有一定程度的增加,主要是OpenHarmony引擎和平台库的引入,具体数值因Flutter版本和适配分支而异,建议以实际构建产物为准。
- 冷启动到首页渲染完成的时间,在入门级设备上体验尚可,比预期略慢但可接受。
- 主题切换的流畅度没有出现明显掉帧,但深色模式下列表长页的滚动性能略低于浅色模式,猜测与绘制复杂的深色背景有关。
针对滚动性能,我们做了一件事:在深色模式下对列表页关闭部分不必要的阴影和半透明效果。三国杀主题里很多卡片用了半透明层叠加,浅色模式下感知不强,深色模式下绘制开销就上来了。牺牲掉一层装饰效果,换来了滚动帧率的稳定,这个取舍值得。
结语与后续扩展
Flutter for OpenHarmony这个组合,目前给我的整体感觉是“能跑、能上生产,但插件生态还需要时间补齐”。设置功能作为整个App里最吃“全局联动”的模块,恰好把Flutter跨端能力和OpenHarmony平台差异都暴露了个遍。从选型到落地,我们收获最大的不是写了多少代码,而是建立了一套在OpenHarmony上验证Flutter插件兼容、处理生命周期差异、规避热重载陷阱的流程。后面再在这个平台做新项目,我们心里会有底很多。
如果要说一个最值得留住的实操建议:做OpenHarmony的Flutter应用时,一定要在项目第一天就区分清楚“纯Flutter代码”和“依赖平台能力的代码”,把后者全部隔离到独立模块里。设置功能里配置存储、音效资源、缓存目录都属于平台能力,我们正是因为一开始就做了隔离,才能在几个插件不兼容的情况下,快速换成文件系统实现而不影响业务层。这个设计决策,帮我省掉了至少两天的重构时间,也希望它能帮到你。