到家刚换上拖鞋,手机里那个家具购买记录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标准做法:一个Scaffold,AppBar显示“设置”,body是一个ListView,内部通过自定义的Widget来组织分组。这里我特意不用TableList这种重组件,就是为了在OpenHarmony的Flutter渲染引擎上保持轻量。
2.1 分组控件设计
每个分组我抽象了一个SettingsSection,内部包含一个标题和若干SettingsTile。SettingsTile支持三种形态:开关类、跳转类、点击类。这样通过一个统一的入口,就能快速扩展不同的设置项。核心的UI骨架如下:
class SettingsSection extends StatelessWidget { final String title; final List<Widget> 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 ValueChanged<bool> 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; Future<void> 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就行。
Future<File> 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.read<SettingsModel>(); 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当成自己家当的电子账本。