到家刚换上拖鞋手机里那个家具购买记录App弹了个提醒——沙发的保修期还有三十天到期。这个功能就是我在某天的夜里加进去的说来也不复杂就是设置模块里多了一个“保修到期提醒”开关配合本地存储和系统通知效果实测下来很稳。这篇文章就把这个App的设置功能实现过程完整拆开揉碎讲清楚。项目适配的是Flutter for OpenHarmony也就是让同一套Flutter代码跑在国产开源系统上开发者不用专门学新的UI框架只需要关注业务本身。先交代一下背景。这个家具购买记录App的核心功能很简单记录家里每件大家电、家具的购买时间、价格、保修期、商家联系方式。但越是简单的App设置模块越不能含糊因为设置项直接决定了用户怎么管理自己的数据、怎么接收提醒、怎么和系统交互。我最初设计设置模块时列出了用户最关心的几个点主题外观、数据备份与导出、通知提醒开关、关于页面。这套功能完成后App才算真正“能用”而不只是“能看”。1. 为什么设置模块是一个项目的试金石说实话单独把设置模块拎出来写一篇实战文章不是因为设置页面代码量大而是因为设置模块几乎串联了一个App的全部底层能力。做这个模块时你至少要处理本地持久化存储用户改了开关和选项要记住、消息通知授权开关控制系统级提醒、文件读写数据导出、UI状态管理当前主题、语言等全局状态、甚至还有平台通道调系统的深色模式、打开文件管理器等。在OpenHarmony上这些能力更考验适配能力。Flutter for OpenHarmony目前在API层面已经覆盖了大部分常用插件但有些插件需要手动确认版本。我的经验是凡是涉及shared_preferences、path_provider、url_launcher这些基础能力的基本无缝但涉及系统设置、复杂文件选择器的就要多做一步适配甚至要写平台侧的ohos方法通道。1.1 设置页面的目标拆解开始编码之前先把设置模块的需求拆清楚。我采用的是“单页多组”的结构这也是移动端最通用的设置页设计模式。整页一个滚动列表内部用不同的Section分组每组显示相关的设置项。具体到我们的家具记录App最终确定6组外观深色模式跟随、字体大小调节数据管理备份到本地、导出CSV、清空记录通知保修到期提醒、优惠信息推送默认关闭关于版本号、开源许可、反馈入口存储显示当前数据库占用、缓存清理通用语言切换简体/繁体、列表排序方式这个结构前期想清楚后面写代码几乎就是按图索骥。每一组对应一个Model、一组设置项、一套持久化键值。1.2 技术选型背后的取舍Flutter for OpenHarmony项目里状态管理这块我最终选了Provider而不是Riverpod或者Bloc原因很实际设置模块的全局状态不多无非是主题、语言、通知开关这几个Provider的ChangeNotifier加一个全局的SettingsModel完全够用而且调试的时候用Provider的context.read和context.watch非常直观代码量比Riverpod少。持久化我一开始想直接上数据库比如Hive或者Sqlite后来评估了一下场景设置项都是键值对用shared_preferences这种KV存储最合适读写简单、性能好、不需要建表。而家具购买记录本身的数据量也不大普通家庭十年也就几十条所以记录数据我也用的JSON序列化后存文件配合path_provider放到应用文档目录。这里明确一点调用shared_preferences在OpenHarmony上走的是ohos适配层的实现用户的偏好最终会存到应用私有目录卸载应用时会被系统清掉这是符合预期行为的。2. 设置页面的整体UI架构与交互设计设置页的UI结构我采用了Flutter标准做法一个ScaffoldAppBar显示“设置”body是一个ListView内部通过自定义的Widget来组织分组。这里我特意不用TableList这种重组件就是为了在OpenHarmony的Flutter渲染引擎上保持轻量。2.1 分组控件设计每个分组我抽象了一个SettingsSection内部包含一个标题和若干SettingsTile。SettingsTile支持三种形态开关类、跳转类、点击类。这样通过一个统一的入口就能快速扩展不同的设置项。核心的UI骨架如下class SettingsSection extends StatelessWidget { final String title; final ListWidget children; const SettingsSection({ Key? key, required this.title, required this.children, }) : super(key: key); override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 20, 16, 8), child: Text( title, style: Theme.of(context).textTheme.labelLarge, ), ), Card( margin: const EdgeInsets.symmetric(horizontal: 12), child: Column( children: children, ), ), ], ); } }2.2 开关类设置项的实现开关项是整个设置模块最常用的交互比如“保修到期提醒”“深色模式跟随”这类。开关项需要处理好三件事当前值的初始化、用户切换时的状态变更、状态变更后的副作用比如打开通知开关要先申请系统权限。实际的构建代码如下class SwitchSettingsTile extends StatelessWidget { final String title; final String subtitle; final bool value; final ValueChangedbool onChanged; const SwitchSettingsTile({ Key? key, required this.title, this.subtitle , required this.value, required this.onChanged, }) : super(key: key); override Widget build(BuildContext context) { return ListTile( title: Text(title), subtitle: subtitle.isNotEmpty ? Text(subtitle) : null, trailing: Switch( value: value, onChanged: onChanged, ), onTap: () onChanged(!value), ); } }一个小细节ListTile的onTap里我也调用了onChanged(!value)这样用户点整行也能切换开关不一定要精确点Switch实测在平板上这个体验提升非常明显因为手指点按的目标区域变大了。2.3 主题切换的联动实现深色模式跟随这个功能单纯切换MaterialApp的themeMode还不够难点在于切换后整个页面要立即刷新。我的处理方式是在全局的SettingsModel里保存一个themeModeState然后把MaterialApp包在ChangeNotifierProvider里主题切换后所有依赖该状态的Widget自动重建。void updateThemeMode(ThemeMode mode) { _themeMode mode; _prefs.setString(theme_mode, mode.name); notifyListeners(); }这里踩过一个坑在OpenHarmony的Flutter侧ThemeMode.system是跟随系统的但OpenHarmony某些版本的深色模式参数和Android上报给Flutter的有所不同导致system模式下偶尔出现切换不跟手的情况。后来我的处理是在App启动时读取系统亮度模式并强制设置一次ThemeMode.light或ThemeMode.dark把这个值存进Provider就不再依赖system的实时上报。这个调整换来的是稳定性牺牲的只是一点点的“实时跟随”。3. 数据层设计设置项与购买记录的存储设置模块不能只做UI背后必须有可靠的数据层支撑。这里要分两块讲设置项本身的存储以及用户核心数据家具购买记录的导入导出。3.1 基于shared_preferences的设置持久化初始化时我需要从本地读所有设置项到SettingsModel代码逻辑是全局唯一的获取方法方便在任何页面安全访问。以下是我的核心代码class SettingsModel extends ChangeNotifier { static const _prefsKeyTheme theme_mode; static const _prefsKeyNotify notify_warranty; static const _prefsKeyLanguage language_code; static const _prefsKeySort sort_order; ThemeMode _themeMode ThemeMode.light; bool _notifyWarranty true; String _languageCode zh_CN; String _sortOrder recent; ThemeMode get themeMode _themeMode; bool get notifyWarranty _notifyWarranty; String get languageCode _languageCode; String get sortOrder _sortOrder; Futurevoid loadSettings() async { final prefs await SharedPreferences.getInstance(); _themeMode ThemeMode.values.firstWhere( (e) e.name (prefs.getString(_prefsKeyTheme) ?? light), orElse: () ThemeMode.light, ); _notifyWarranty prefs.getBool(_prefsKeyNotify) ?? true; _languageCode prefs.getString(_prefsKeyLanguage) ?? zh_CN; _sortOrder prefs.getString(_prefsKeySort) ?? recent; notifyListeners(); } void setThemeMode(ThemeMode mode) { _themeMode mode; SharedPreferences.getInstance().then((prefs) { prefs.setString(_prefsKeyTheme, mode.name); }); notifyListeners(); } }补充说明一下SharedPreferences在OpenHarmony的Flutter实现里底层是用了Preferences这个轻量KV库去持久化数据最终会落在应用沙箱目录的shared_preferences.xml文件里。对于设置项这种小型数据这个方案完全够用但我建议避免在里存超过500条的大数据量因为每次getInstance都会把整个文件加载进内存数据多了会白白增加启动耗电。3.2 购买记录导出功能的实现导出CSV是家具记录App的刚需。用户买了沙发、冰箱、空调保修期一年两年纸质单据早丢了App里存着电子记录如果能一键导出成CSV表格既能归档又能打印安全感直接拉满。核心思路从本地JSON文件读取记录列表遍历并拼接成CSV格式的字符串然后通过path_provider拿到应用外部文档目录Downloads用dart:io写文件。OpenHarmony平台侧对应用写公共目录有权限管控好在path_provider适配时已经把Download目录的路径暴露出来了直接writeAsString就行。FutureFile exportToCsv() async { final records await _recordRepository.getAllRecords(); if (records.isEmpty) { throw Exception(暂无购买记录可导出); } final buffer StringBuffer(); buffer.writeln(名称,品类,购买日期,价格,保修到期日,商家,备注); for (final record in records) { buffer.writeln( ${record.name}, ${record.category}, ${record.purchaseDate}, ${record.price}, ${record.warrantyEndDate}, ${record.merchant}, ${record.remark.replaceAll(,, )} ); } final directory await getExternalStorageDirectory(); final file File(${directory.path}/purchase_records_${DateTime.now().millisecondsSinceEpoch}.csv); await file.writeAsString(buffer.toString(), flush: true); return file; }这里有一个非常关键的CSV转义问题如果备注里含逗号、引号、换行直接拼字符串会导致CSV列错乱。我在项目里写了一个简单的转义函数规则是字段内含逗号、双引号或换行时用双引号包裹并把内部的双引号替换成两个双引号。这个规则对应的是标准RFC4180虽然兼容性差点但胜在Excel和WPS都能正确识别。导出成功之后通常还要提示用户文件在哪。OpenHarmony的Flutter框架里没有直接的“打开文件管理器选中文件”的API所以我的方案是用SnackBar告诉用户路径另外提供一个“分享文件”按钮通过url_launcher去拉起系统文件分享面板。实测这个行为在OpenHarmony上有部分版本的文件分享面板支持度不consistent某些设备只能分享图片不能分享CSV所以我在代码里做了降级处理如果没有可用的分享目标就显示完整路径让用户自己手动去文件管理器查看。4. 通知开关与系统权限的协同处理这一个环节我要特别提醒很多人做通知开关只存一个布尔值开关拨了之后就完事了这是不对的。真正的通知开关除了改变App的UI状态还需要调用系统去申请/关闭通知权限。尤其在OpenHarmony上通知权限是一个动态权限和Android 13之后的模型类似必须在运行时向用户申请。4.1 初始化时的权限申请逻辑在设置页首次构建时如果“保修到期提醒”开关默认是打开的我会立刻检查通知权限状态。如果之前没有授权过就弹出系统授权框。这个逻辑放在initState里不合适因为initState里拿不到安全的context。正确的姿势是放在didChangeDependencies里做一次性检查override void didChangeDependencies() { super.didChangeDependencies(); if (_permissionChecked) return; _permissionChecked true; Future.microtask(() async { final notifySetting context.readSettingsModel(); if (notifySetting.notifyWarranty) { final status await NotificationService.getPermissionStatus(); if (status ! NotificationPermission.granted) { NotificationService.requestPermission(); } } }); }这里用Future.microtask做了一个延迟确保是在本帧构建完成后再去申请权限避免出现“在build期间弹系统对话框”的报错。4.2 保修到期提醒的后台调度通知开关控制的后台调度逻辑我用了一个很简单的定时扫描方案而不是引入工作管理器。因为家具记录App的场景特性是——用户打开App的频率不高但每次打开都是记录或查看那我就在首页加载的时候去扫描一次看最近7天内有没有保修到期的记录如果有就发一条本地通知。回调触发后通过flutter_local_notifications插件发送通知。这里要注意发送通知时需要先初始化插件参数特别是Android和OpenHarmony对通知渠道channel的处理。OpenHarmony上这个插件的适配版本支持创建渠道渠道ID固定为warranty名称是保修提醒重要性IMPORTANCE_HIGH保证通知能正常弹出。final AndroidNotificationDetails androidDetails AndroidNotificationDetails( warranty, 保修提醒, channelDescription: 家具/家电保修到期提醒通知, importance: Importance.high, priority: Priority.high, ); final NotificationDetails details NotificationDetails( android: androidDetails, iOS: const DarwinNotificationDetails(), ); await flutterLocalNotificationsPlugin.show( record.id, 保修提醒, ${record.name}的保修期还剩${days}天到期, details, );在OpenHarmony上插件内部会把这套参数映射到本地通知代理上。我实测下来只要渠道号一致多次调用不会重复创建渠道不会有弹通知权限的二次请求。4.3 开关关闭时的联动清理用户关掉“保修到期提醒”开关后不能只改布尔值还要把之前可能已经排好的定时通知取消掉。我的做法是在setNotifyWarranty(false)之后立刻调用flutterLocalNotificationsPlugin.cancelAll()避免出现“设置里明明关了结果第二天还是弹提醒”这种让人恼火的Bug。另外我还做了一个小优化开关关闭后把已经存在的未读提醒也一并标记为已读。这个逻辑不在系统通知API里而是在App自己的记录表里维护一个reminder_read字段。打开App首页时如果reminder_read为false会展示一个横幅引导用户去设置页开启提醒。这个设计纯粹是用户体验角度的补丁实际用户反馈比预想好很多。5. 常见问题与排查技巧实录整个设置模块开发下来遇到的技术问题还真不少尤其集中在OpenHarmony适配层。整理出几个典型的问题每个都是我实际踩过并用几个小时定位的。5.1 SharedPreferences实例竞争导致的值覆盖现象设置页修改深色模式后回到首页再进设置页发现开关状态跳回旧值。排查发现写入和读取时如果同时存在多个SharedPreferences实例可能存在内存缓存竞争。处理方案全局统一使用一个单例来管理SharedPreferences并且所有读写操作都用async/await串行化不要用遗留的then写法。我在SettingsModel里把getInstance的结果缓存成PreferenceCache成员变量后续读写全部走这个成员就不再出现覆盖问题。5.2 OpenHarmony上ThemeMode.system的适配异常前面提过这里再展开说一遍。某些OpenHarmony版本的平台通道上报亮色/暗色模式的状态不够及时导致Flutter拿到的永远是亮色所以设置了ThemeMode.system之后App不会跟随系统切暗。排查思路先打印MediaQuery.platformBrightnessOf(context)看看当前值是否变化如果系统已切暗但Flutter侧始终返回light那基本可以断定是平台通道的同步问题。最终我采用的方案是增加一个设置项“跟随系统亮度”用户开启后每次App从后台恢复AppLifecycleState.resumed时手动读取一次系统亮度并应用到MaterialApp。这个方案在代码注释里写清楚了原因即使后续框架修复了也保留原逻辑。5.3 导出CSV后中文乱码在Windows上用Excel打开导出的CSV中文全部乱码。原因是CSV文件没有带UTF-8 BOM头Excel默认用ANSI解码。解决办法很暴力但有效在写文件之前先写入一个BOM标记0xEF, 0xBB, 0xBF再写入CSV内容。这个改动很小但对Windows用户极其重要因为买家具的很多人就是在Windows电脑上打开表格。final bytes utf8.encode(\uFEFF$csvContent); await file.writeAsBytes(bytes, flush: true);顺带提醒一下如果是给macOS用户用的BOM头不影响正常打开所以直接统一加BOM没有问题。5.4 本地通知在设置页创建后不立即弹出用户新录入一条保修记录提示“保存成功”点击“测试提醒”却收不到通知。原因其实是OpenHarmony对通知的发送频率有限制同一秒内连续发送多条会被系统静默丢弃。解决方案在测试入口做一个防抖点击后先清除已有通知间隔3秒再发送新测试通知。这样不会触发系统的频率限制也方便用户验证效果。6. 设置模块复盘哪些做对了哪些下次会改整个设置模块实现完后我在自己常用的真机上从头到尾跑了一遍使用流程突破点都在预期内。做对了的部分我觉得首要的是分组设置项的设计。得益于统一的SettingsSection和SettingsTile抽象后续加了“语言切换”“缓存清理”这两个功能时几乎没有改动已有代码新功能就是新增几个Tile塞进去开发效率很高。还有一个值得分享的细节设置页里的每一个开关状态都带有持久化痕迹也就是说每次用户切任何一项我都立即写盘不设“保存”按钮。这种交互更符合移动端的直觉用户切完就走不用担心忘记保存丢失设置。代价是一点点存储频次但设置项数据量小这个代价可以忽略。下次如果再做一个跨平台项目我会把“跟随系统亮度”这类功能做成默认开启并在首次启动引导里让用户选择一次主题偏好而不是让用户翻好几层菜单去发现自己可以设置深色模式。这个结论来自用户反馈有两位测试用户告诉我他们完全没注意到设置页里有深色模式选项因为根本不会主动进设置页。所以这类全局外观设置最好在首启动时暴露一次而不是只藏在设置里。最后再分享一个小技巧在OpenHarmony的Flutter开发中尽量把平台相关的操作权限判断、系统通知、文件路径封装成独立的Service层不要让这些逻辑散落在各个页面里。这样就算后续Flutter for OpenHarmony的API有变动你也只需要修改Service层的一个文件而不是全项目搜索替换。我的NotificationService就是这样一个抽象现在整套代码从OpenHarmony切回Android只需要替换Service内部实现UI层一行没动。以上就是这个家具购买记录App设置模块的实战记录。整个过程有踩坑也有收获分享出来希望能给同样在搞Flutter for OpenHarmony的朋友一点参照。设置功能看着不起眼但真正做扎实了它就是整个App的定心丸。数据存得住、权限要得到、通知发得出、文件导得出用户才会放心把这个App当成自己家当的电子账本。
阅读完成 · 觉得有帮助?