☰
Flutter for OpenHarmony设置页开发实践:从数据建模到持久化与联动
2026/10/20 12:54:08 网站建设 项目流程

Flutter for OpenHarmony 的跨端方案逐渐成熟后,越来越多宠物、家居、工具类 App 开始把界面层往这套体系迁移。我最近在一个宠物管理类项目里做设置模块,设备端跑 OpenHarmony,UI 层用 Flutter 拉起,项目代号就叫猫咪管家。真正做进去才发现,设置页听着简单,实际比业务页更考验收敛能力:通知开关、主题模式、语言切换、缓存清理、版本更新,每一项都牵扯数据模型、本地持久化、平台通道和状态刷新。Android 上几句代码搞定的 SharedPreferences,在这里要重新封装;ThemeMode.system 想跟系统走,也要额外处理。这篇就把设置页从建模到落地的完整过程拆开写,重点讲 Flutter for OpenHarmony 和标准 Flutter 工程之间的差异,以及我踩过的坑和补救方案。

这套内容适合三类人:正在把 Flutter 工程往 OpenHarmony 上迁移的开发者、做跨端业务但不太熟悉设置页内部结构的同学,以及想了解“设置项数据模型 + 本地存储 + UI 联动”完整链路的人。每一步我都会给出代码和设计理由,不绕弯子,直接说结论。

1. 整体思路:为什么设置页比业务页更吃设计

1.1 从猫咪管家 App 的设置需求说起

猫咪管家 App 是一个面向养宠用户的管理工具,核心功能包括猫咪档案、喂食提醒、疫苗日历和健康记录。设置模块在“我的”页底部,产品提的需求乍一看不复杂:通知开关、提醒铃声、主题模式、语言切换、缓存清理、检查更新、关于页面。

但把这些需求翻译成工程语言,立刻暴露出一堆隐藏问题:每个开关都要独立持久化,刷新页面不能回跳;主题切换要即时作用于全 App,不能只改当前页;语言包切换后整个 Widget 树都要重建,部分页面还要保持状态;缓存清理要区分“临时文件可清”和“用户离线数据不可清”,不能一刀切。

这些需求全部集中在同一个页面里,如果直接在 State 里堆逻辑,代码会迅速膨胀。我当时的决策很简单:设置页需要一个独立的数据层,UI 只负责渲染和转发用户操作,所有值的变化都要走统一入口。

1.2 为什么在 OpenHarmony 上选 Flutter

团队选择 Flutter for OpenHarmony 的原因主要有三个。一是业务本身已经是 Flutter 写的,迁移成本在于适配系统能力而不是重写 UI;二是设置页这类界面以表单为主,Flutter 的 ListTile、Switch、Slider 组件开箱即用,比在原生体系里手搭效率高很多;三是 Flutter 的响应式刷新模型天然适合“开关状态变化 -> UI 跟随变化”这类交互。

不过打开工程后第一个要认清的现实是:OpenHarmony 的 Flutter 适配版不是把 Android 的 embedding 原样搬过来,而是把 Flutter 引擎桥接到系统自己的图形栈和能力接口上。这意味着插件生态和标准 Flutter 有差异,越是依赖原生能力的插件越要提前验证。设置页刚好是插件依赖的重灾区:SharedPreferences 需要键值存储能力,path_provider 需要获取缓存目录,通知开关需要调用系统通知能力,这些都不能想当然地照搬安卓写法。

1.3 目录结构与状态管理选型

工程里我采用按功能模块拆分的方式,设置功能独立成一个 feature 包,不和其他业务代码混在一起。

lib/ app/ app.dart core/ storage/ setting_storage.dart theme/ theme_controller.dart locale/ locale_controller.dart features/ settings/ models/ setting_item.dart controllers/ setting_controller.dart pages/ settings_page.dart widgets/ setting_tile.dart

状态管理用的是 ChangeNotifier + Provider 的轻量组合,没有上 Bloc 或 Riverpod。理由很直接:设置页的数据流是“单点修改,多点响应”,ChangeNotifier 的 notifyListeners 机制足够覆盖,代码量也最小。Provider 主要负责把控制器挂到组件树上,让页面在打开时自动读取持久化数据,并监听变化重建局部 UI。如果为了设置页引入整套状态管理框架,反而会把简单问题复杂化。

2. 设置项数据模型与状态控制器设计

2.1 用统一的 SettingItem 描述所有设置行

设置页的每一行,本质上都是“标识 ID + 标题 + 副标题 + 控件类型 + 当前值”的组合。我定义了一个SettingItem来统一描述:

enum SettingType { switchValue, choice, action, } class SettingItem { final String id; final String title; final String? subtitle; final SettingType type; final Object? value; const SettingItem({ required this.id, required this.title, this.subtitle, required this.type, this.value, }); }

这个模型看起来简单,实际帮了大忙。设置页右侧的控件是 Switch 还是 ListTile 还是普通按钮,完全由type决定;保存和回显时根据id找到对应键;UI 上任何一行都不需要单独写状态字段,直接从统一的 value 取值。后续产品要加“提醒方式选择”这种新行,只需要新增一个SettingItem实例,页面代码几乎不用动。

2.2 控制器:状态先行、持久化兜底、失败回滚

设置项的值不能直接存在 Widget 里。我建了一个SettingController,继承 ChangeNotifier,负责两件事:维护内存中的设置值,并在每次变更后异步写持久化。

class SettingController extends ChangeNotifier { final SettingStorage _storage; final Map<String, dynamic> _values = {}; SettingController(this._storage); dynamic getValue(String key) => _values[key]; Future<void> setBool(String key, bool value) async { final oldValue = _values[key]; _values[key] = value; notifyListeners(); try { await _storage.saveBool(key, value); } catch (e) { _values[key] = oldValue; notifyListeners(); rethrow; } } }

这段代码最核心的是“失败回滚”。用户拨动开关的瞬间 UI 立刻响应,但如果系统写入失败,要把内存值还原,避免出现“界面显示开了、重启又变回关”这种离谱问题。早期版本我没做回滚,在 OpenHarmony 上碰到偶发写失败时,用户反馈“开关拨过去又弹回来”,排查了半天才意识到是持久化异常。所以这个回滚逻辑必须写在控制器的统一修改入口里,UI 只负责调用和捕获异常提示。

首次进入设置页时,控制器要从存储里把所有设置项读出来。这里要注意读操作需要异步等待,建议在页面加载前完成初始化,而不是在 build 里处理 Future。我用 Provider 的FutureProvider做一次性初始化,页面拿到控制器实例后再渲染列表,整个过程最多几十毫秒,用户无感。

2.3 为什么值要与 UI 解耦

很多新手写开关会直接写Switch(value: isOpen, onChanged: ...),把状态放在页面 State 里。设置页一旦超过 5 个开关,这种写法就会裂开:主题切换要同时改多个页面,App 内语言切换要触发 MaterialApp 重建,通知开关要联动业务模块的监听器。如果把状态放在 UI 层,这些跨模块联动根本做不干净。

解耦之后的好处是:任何模块都可以通过settingController.getValue('themeMode')读到当前主题,通过addListener收到变化通知,不依赖页面节点顺序和生命周期。设置项的数据流向始终是单向的:用户操作 -> 控制器改值 -> 持久化 + 通知 -> UI 刷新。这套模式越早建立,后面加设置项越省事。

3. 本地持久化:封装上层接口,隔离平台差异

3.1 SharedPreferences 在 OpenHarmony 上的真实体验

标准 Flutter 里做键值持久化,第一反应是shared_preferences插件。但在 Flutter for OpenHarmony 的工程里,插件不是全部可用,依赖原生实现的插件需要逐一验证。我当时遇到的情况是:插件能编译,运行时却拿不到对应目录,写入后进程内可读,重启进程后数据消失。

排查后确认是插件底层的目录获取和系统侧弱绑定。Flutter 侧的 Dart 代码提到SharedPreferences.getInstance(),最终要走平台通道调用原生实现,而 OpenHarmony 上的原生实现并没有完全对齐 Android 的文件路径和权限规则。这种问题最麻烦,因为它不报编译错,只在特定场景复现。

注意:在 Flutter for OpenHarmony 上使用任何依赖原生能力的插件之前,先做“写入 -> 杀掉进程 -> 重启读取”的验证流程,别等功能做完了才发现数据存不住。

3.2 自封装 SettingStorage 接口

为了让业务层不依赖具体插件实现,我定义了一个SettingStorage抽象接口,把存储方式完全隔离出来:

abstract class SettingStorage { Future<bool?> getBool(String key); Future<void> saveBool(String key, bool value); Future<String?> getString(String key); Future<void> saveString(String key, String value); Future<int?> getInt(String key); Future<void> saveInt(String key, int value); }

接口的四个方法涵盖设置页所有数据类型:开关用 bool,主题模式用 String,语言选项用 String,后续如果加铃声强度还可以用 int。线上实现类内部到底用 SharedPreferences 还是系统偏好接口,或者干脆自己写 JSON 文件,上层完全不关心。

这个抽象层的价值在 OpenHarmony 上体现得尤其明显。当发现某个插件实现不稳定时,我只改实现类,业务代码零改动。后来我把实现切到了平台通道,在原生侧用系统自带的轻量偏好存储接口,覆盖了getBool/saveBool这类基础操作,稳定性一下子上来。整个切换过程不到半天,如果没做接口隔离,得在设置页每个开关回调里跑来跑去改,很难收干净。

3.3 不要把所有设置项塞进一个 JSON

当时为了省事,我用一个 JSON 字符串保存所有设置项,比如{"notify":true,"theme":"dark","language":"zh"},一个 key 解决全部。初期很香,后面出了两个问题。

第一是并发写冲突。两个设置项几乎同时修改时,后写的 JSON 会覆盖先写的,导致先改的开关丢失。第二是类型扩张困难。版本迭代后某个字段改成新结构,老数据解析直接崩,还得写迁移逻辑。这两次折腾之后,我彻底改成“每个设置项单独一个 key 存储”,宁可多写几条存储记录,也要避免单点更新冲突。

提示:设置项的持久化,用“一字段一 key”,不要用“一 JSON 全包”。这个原则在 OpenHarmony 和 Android 上同样适用,省下的维护成本远大于多几条记录的开销。

4. 设置页 UI 实现与交互细节

4.1 分组列表的结构设计

设置页的 UI 结构分三块:账号与档案入口、通用设置区、数据与关于区。通用设置区集中在主题语言通知,数据区负责缓存和版本,中间用一个简单的分组头分隔。

ListView( children: [ _buildAccountSection(), _buildGeneralSection(), _buildDataSection(), _buildAboutSection(), ], )

每组内部用SettingTile复用,左侧是标题和可选的副标题,右侧根据设置类型渲染不同控件。列表整体固定在一屏以内,用户不需要频繁滑动,这也是设置页体验的基本要求。真正需要滑动的内容不要超过两屏,超出就说明设置项分组没做好,该合并的合并,该收进二级页的收进二级页。

4.2 开关切换的即时反馈与失败提示

设置页最受欢迎的交互是“拨动即生效”。用户拨通知开关,App 右上角立刻出现全局的通知监听状态变化,不需要额外点保存。实现上靠的是控制器notifyListeners之后,所有监听者同时刷新。

“即生效”的代价是要处理好失败场景。我在SettingTile里把 Switch 的onChanged包了一层 async 处理:

Future<void> _handleSwitchChanged(String key, bool value) async { try { await controller.setBool(key, value); } catch (_) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('设置保存失败,请重试')), ); } }

这里有几个细节:Switch 本身不要包 FutureBuilder,否则拨一下会闪白;失败提示用 SnackBar 轻提示,不用弹窗,避免打断用户连续操作;回滚由控制器完成,UI 层不做二次赋值。实测下来这种交互最稳定,不会出现开关“拨过去又弹回来”的视觉抖动。

4.3 列表行点击与右侧控件的分工

有些设置行是“整行可点”,比如“清理缓存”点击整行弹出确认框;有些是“仅右侧可操作”,比如开关行点击空白区域不应触发。为了避免手势冲突,SettingTile内部做了严格分工:type == SettingType.action时整行包 InkWell 响应点击,此时右侧不渲染控件而是渲染一个箭头;type == SettingType.switchValue时右侧渲染 Switch,整行不响应点击。

这个细节在安卓上无所谓,但在 OpenHarmony 上如果处理不好,会出现“点击一行同时触发了两个控件”的诡异问题。原因和系统的手势事件分发顺序有关,我的解决方式是在 Switch 外包一层GestureDetector并把行为设为 opaque,拦截掉下层的点击。这个坑虽然小,但是排查起来非常费时间。

5. 主题切换、多语言联动与全局重建

5.1 ThemeMode 的状态管理与三态切换

猫咪管家支持浅色、深色、跟随系统三种主题模式,设置页用一个小弹窗做选择。控制器的核心逻辑:

enum AppThemeMode { light, dark, system } class ThemeController extends ChangeNotifier { AppThemeMode _mode = AppThemeMode.system; void setThemeMode(AppThemeMode mode) { _mode = mode; _persist(); notifyListeners(); } }

MaterialApp 的 themeMode 属性直接映射到AppThemeMode,浅色和深色主题通过ThemeData分别构造。这里要格外注意一个 OpenHarmony 上的差异:跟随系统模式很可能拿不到设备当前的实际亮度模式,或者拿到的状态和系统设置不同步。原因在于壳层对系统设置的读取频率低,Flutter 侧监听不到变化事件。

我的补救方案是在 App 启动时主动读取一次系统亮度模式,并注册一个信号监听。切换回亮色暗色时,如果发现和内存模式不一致,立刻刷新。如果你在 OpenHarmony 上发现跟随系统不生效,先别怀疑主题代码,大概率是系统亮暗变化的回调没有传给 Flutter。

5.2 语言切换如何触发全局刷新

多语言功能用flutter_localizations和intl组合实现。语言包放在 assets/lang 下,分别是 zh.json 和 en.json,初始化时一次性读入内存,用一个Map<String, String>维护。

class LocaleController extends ChangeNotifier { Locale _locale = const Locale('zh'); Map<String, String> _strings = {}; void setLocale(Locale locale) { _locale = locale; _persist(); notifyListeners(); } }

设置页切语言时,调用setLocale,MaterialApp 收到新的 locale 后,localizationsDelegates会重新解析语言包,整个组件树随之重建,所有文本即时换成新语言。这里踩过的一个坑是:切语言后页面回到第一屏,用户原本在设置页中间位置,突然被顶回顶部。原因是 MaterialApp 重建时 didChangeDependencies 里的 context 被重新拉取,ListView 的滚动位置没有保留。

解决方式是给设置页的 ListView 加上PageStorageKey,再在外层用AutomaticKeepAliveClientMixin保持页面存活。Flutter 的 keepalive 机制在语言切换这种全局刷新场景里能兜住滚动位置,实测有效。不过要注意 OpenHarmony 上页面存活的底层实现更依赖壳层,建议关键页面都套上 keepalive,能省掉很多奇奇怪怪的跳变问题。

6. 缓存清理与升级检查的工程实现

6.1 缓存目录遍历与分类清理

缓存清理的逻辑不复杂,但“能清理什么”和“不能清理什么”必须想清楚。猫咪管家的缓存分三类:内存图片缓存、临时下载文件、离线卡包数据。前两类可以清,卡包数据涉及用户欠费等核心业务,绝不能动。

实现上先算总大小,再按分类清理:

Future<int> calculateCacheSize() async { final dir = await getTemporaryDirectory(); int size = 0; await for (final entity in dir.list(recursive: true)) { if (entity is File) { size += await entity.length(); } } return size; } Future<void> clearCache() async { final dir = await getTemporaryDirectory(); await for (final entity in dir.list(recursive: true)) { if (entity is File) { await entity.delete(); } } }

path_provider 在 OpenHarmony 上的适配版本可用,但获取到的目录路径和 Android 完全不同,需要打印日志确认。我当时第一次运行一直提示找不到目录,排查后才发现单独使用 getTemporaryDirectory 在部分版本上返回的是临时目录的父级,导致遍历直接失败。后来在原生侧把不同用途的目录分开注册,Flutter 侧按用途读取,问题才算彻底解决。

注意:清理缓存前一定要确认文件被占用情况,部分系统文件被进程打开时 delete 会抛异常,建议逐个文件 try-catch,跳过失败项而不是中断整个清理流程。

6.2 检查更新与版本号比较

检查更新在设置页里是低频操作,但服务端逻辑做起来不简单。服务端下发一个 JSON,包含 versionCode、versionName、downloadUrl、note 四个字段,客户端启动时不主动检查,用户点“检查更新”才请求。

版本号比较不能直接 double 转换,因为版本格式可能是1.10.0,字符串转 double 会变成 1.1,直接误判。我写了一个分段比较器:

List<int> parseVersion(String version) { return version.split('.').map((e) => int.parse(e)).toList(); } bool isNewer(String remote, String local) { final r = parseVersion(remote); final l = parseVersion(local); final maxLength = r.length > l.length ? r.length : l.length; for (var i = 0; i < maxLength; i++) { final rv = i < r.length ? r[i] : 0; final lv = i < l.length ? l[i] : 0; if (rv != lv) return rv > lv; } return false; }

判断出有新版本后,弹窗分成两个按钮:暂不更新和立即更新。立即更新走下载流程,下载进度用 LinearProgressIndicator 展示。更新包里如果要求重启 App,在网络请求和页面重建时要保留下载状态,这又是状态管理发挥作用的地方。前期版本直接用 setState 存了个 int 进度,切页面回来进度归零,后来挪到全局代理对象才解决。

7. 常见问题与排查技巧实录

7.1 高频问题的症状与对策

开发过程中整理了几个高频问题,直接做成速查表,团队新成员上手时照着排查能省一半时间。

症状排查方向解决参考
设置页首帧白屏卡住插件初始化触发平台通道,权限未申请延迟加载非必要插件,启动后异步初始化
开关拨过去又弹回来持久化写入失败,未做回滚控制器统一修改入口,捕获异常后回滚内存值
切主题后部分页面颜色不变页面内硬编码了颜色,没走 ThemeData全局替换为 Theme.of(context) 读取
切语言后文本部分未变化页面 state 未重建,KeepAlive 缓存重新拉取语言包并触发 notifyListeners
清缓存后 App 卡顿删除文件未确认占用,异常中断逐文件 try-catch,跳过删除失败的文件
跟随系统模式无效系统亮度变化事件未传递给 Flutter壳层主动监听并转发信号到 Flutter 侧

7.2 设置页自检清单

功能完成后我自己过了一遍清单,每项都实测过才敢交付。这里直接分享出来:

  • 所有开关杀进程后重启,状态保持正确
  • 主题切换后返回业务页,颜色即时更新且无闪烁
  • 语言切换后文本全部更新,设置页滚动位置不跳变
  • 缓存清理前后显示高度一致,清理中无卡死
  • 检查更新在无网和弱网下给出明确提示,不白屏
  • 快速连续点击开关,不发生状态错乱
  • 列表在关闭动画的情况下仍保持流畅跟手

第 6 条是重点。在 OpenHarmony 上快速连点同样一个开关,容易出现两次写入互相覆盖。我的解决方式是控制器里加了操作序列号,后一次操作会等待前一次完成再执行,丢弃过期回调。这个机制加上去后,连点测试稳定通过。

7.3 关于调试工具的一个心得

Flutter for OpenHarmony 的调试工具链和标准 Flutter 有区别,Widget 检查器有时会报连接失败。我的经验是不要依赖它,直接多用日志定位。数据层打印出入参数,UI 层打印通知次数,配合调试日志能排查 80% 的问题。设置页这种纯 UI 业务,日志定位效率往往比可视化调试更高。

我个人在实际操作中还有一个体会是:设置页是最适合“先定数据模型、再定存储接口、最后写 UI”顺序的模块。数据模型定清楚,设置项的类型、取值、回显逻辑全部明确,后面 UI 怎么写都不容易乱;存储接口先封好,后续换平台或换实现方式不需要动页面代码。先把这个顺序走顺,写出来的代码自己维护起来也轻松得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询