☰
Flutter for OpenHarmony实战:SharedPreferences与提醒逻辑封装
2026/10/7 16:49:15 网站建设 项目流程

最早接触“Flutter for OpenHarmony”这个方向,是因为手头一个视力保护提醒App要移植到鸿蒙生态的设备上。说来也巧,这个App本身功能并不复杂:记录用眼时长、到点提醒“该看看远处了”、把用户自定义的提醒间隔和每天累计时长存下来。可真正动手之后我才发现,越是一套看起来简单的“小功能”,越能炸出一堆平台差异、插件适配和存储时序的坑。

这个项目最核心的落点就是两个:一是Flutter如何跑在OpenHarmony上,二是SharedPreferences本地存储怎么在跨端场景里用得干净、用得稳。这篇文章我会从项目拆解开始,把环境搭建、存储封装、提醒逻辑和状态联动完整过一遍,重点讲SharedPreferences的读写封装、跨页面同步,以及OpenHarmony适配时容易踩的几个典型问题。适合刚接触Flutter跨端开发、或者准备把自己的App适配到OpenHarmony的开发者,里面有大量可以直接抄作业的代码和配置。

1. 项目拆解:视力保护提醒App到底在做什么

1.1 核心功能与用户场景

视力保护提醒App的典型场景,其实就是围绕“20-20-20”护眼法则来的:每用眼20分钟,把视线移开看向20英尺(约6米)外的物体,保持20秒。放在App里,就对应三个必须有的功能模块:

  • 用眼计时:从用户点击“开始专注”起,记录连续用眼时长,屏幕亮着只是表现,真正要算的是时间。
  • 定时提醒:累计时长达到用户设定的间隔(默认20分钟),弹出全屏或对话框提醒,让用户休息20秒。
  • 数据与设置:提醒间隔、休息时长、每日目标、是否振动,这些是设置项;今天累计用眼多少分钟、完成了几个休息目标,这些是统计项。设置和统计数据都需要在App重启后保留。

如果你把它看成一个纯前端页面,那确实没什么好讲的。但它真正的价值在“本地存储”这一层:用户每次设置完提醒间隔,如果杀进程之后又变回默认值,这个App等于白做。而统计数据如果只在内存里算,哪天被系统回收了,今天护眼10次的记录就全没了。所以SharedPreferences在这里承担的不是锦上添花的角色,而是整个App状态闭环里不可缺少的持久化底座。

1.2 为什么用Flutter来做OpenHarmony适配

过去OpenHarmony上的应用开发,主流是ArkTS + ArkUI,生态相对独立。如果只针对OpenHarmony做一款App,其实用ArkTS更省事。但问题是,我手头这个视力保护App原本有Android和iOS版本,团队不可能为一个新系统重新维护一套代码,Flutter这种“一套Dart代码多处编译”的方案,就成了最现实的选择。

Flutter在OpenHarmony上的支持,主要由OpenHarmony SIG(特别兴趣小组)维护。他们有对应的flutter_flutter适配分支和一批常用插件的ohos实现,比如shared_preferences、path_provider、sqflite等都有社区适配版本。实际工程里我采用的是“Flutter工程为主,DevEco Studio负责编译和运行”的方式,而不是把Flutter打包成har/aar再嵌入原生工程。前者对纯Flutter团队更友好,调试路径最短。

不过要提醒一句:Flutter for OpenHarmony的版本节奏不一定和官方Flutter同步。选择分支时,先去看看维护仓库的说明,确定它当前对应的Flutter版本和OpenHarmony SDK版本。我自己的经验是不要追新,选一个已经发布了一段时间的稳定适配分支,社区踩坑的人多,你踩坑时能搜到答案的概率也大。

2. 工程准备:Flutter for OpenHarmony环境搭建与项目初始化

2.1 环境侧的准备:SDK、IDE与Flutter分支

在OpenHarmony上跑Flutter,环境比普通Flutter项目多几步。首先你需要一套OpenHarmony SDK,通常随DevEco Studio一起安装;其次需要把Flutter SDK替换成适配OpenHarmony的分支;最后用命令行工具和DevEco Studio配合完成构建。

我当时的环境是这样的:

组件建议方案
操作系统Windows 10/11 或 Ubuntu 20.04+,内存建议16G以上
开发IDEDevEco Studio 4.0+,用于配置OpenHarmony SDK与运行调试
OpenHarmony SDK根据适配分支要求安装对应API版本
Flutter SDK使用OpenHarmony SIG维护的flutter_flutter适配分支
调试设备OpenHarmony开发板或真机,需要开启开发者模式

环境变量方面,除了配置Flutter的bin目录,还需要让Flutter工具链能找到OpenHarmony SDK。这个具体路径不少教程都会写,但更省事的做法是用DevEco Studio里自带的SDK路径,然后通过命令行参数指定。我第一次搭环境时就是在这里卡了半天,后来把所有路径写进了一个脚本里,每次同步完环境直接跑脚本,比手动敲省心很多。

另外,OpenHarmony的构建工具是hvigor而不是Gradle,和Android工程完全不同。如果你之前用习惯了Android那套构建体系,刚开始接触时很容易下意识去找build.gradle,实际上在Flutter for OpenHarmony工程里,你要看的是ohos目录下的工程配置,配合DevEco Studio完成签名、应用打包和真机安装。

2.2 创建项目与添加依赖

环境OK之后,创建项目还是标准的Flutter命令:

flutter create eye_guard_app

不过创建出来的工程需要做一些调整。最明显的一点是:要确认当前Flutter分支能识别ohos这个平台目录。有些版本需要在项目里手动补充ohos平台的适配层,具体看SDK分支的说明文档。

然后是依赖。这个项目用到两个关键包:shared_preferences做本地存储,provider做状态管理。OpenHarmony下不能只加官方pub.dev的包,还需要额外引入对应的ohos适配实现:

dependencies: flutter: sdk: flutter shared_preferences: ^2.1.0 shared_preferences_ohos: ^2.1.0 provider: ^6.0.5

shared_preferences_ohos就是OpenHarmony平台的端实现。它的接口和官方shared_preferences保持一致,这样业务层代码完全不用区分平台,Dart侧只需要在入口处完成一次平台通道注册。要注意的是,适配包的版本需要和主包、Flutter分支匹配,我自己就遇到过版本对不上导致运行时找不到符号的情况。

主入口的注册逻辑一般是这样的:

void main() async { WidgetsFlutterBinding.ensureInitialized(); // OpenHarmony 平台通道注册 SharedPreferencesOhos.registerWith(); runApp(const EyeGuardApp()); }

不要小看这行registerWith调用。没有它,真机上所有SharedPreferences调用都会卡在平台通道上,报错信息还不一定直观。

3. SharedPreferences本地存储的设计与封装

3.1 SharedPreferences的原理与适用边界

SharedPreferences本质上是一个轻量级的键值对存储,Android端底层是XML文件,iOS端用NSUserDefaults,OpenHarmony上同样有对应的持久化实现。它适合存设置项、用户偏好、简单的统计数据,读取时会一次性加载到内存,后续读取速度极快,写入时会异步落盘。

这款视力保护App需要存的数据非常契合SharedPreferences的能力范围:

  • 提醒间隔:整数,比如20
  • 休息时长秒数:整数,比如20
  • 是否开启振动:布尔值
  • 每日累计用眼秒数:整数
  • 上次提醒日期:字符串,比如“2024-06-01”,用来判断是否跨天

这些数据都是小而简单的标量。如果用数据库去存,不仅增加依赖,还显得大材小用。反过来说,如果以后要做多设备数据同步、离线大表查询,那就该上数据库或者文件存储了,SharedPreferences不是万能的,它的定位就是“存少量偏好数据”的轻量级方案。

从性能上看,SharedPreferences适合低频写入。视力保护App中,提醒间隔这类设置项一天改不了几次,统计数据也只需要在每次休息完成、跨天重置时写一次。频率很低,不会有性能压力。但如果谁把SharedPreferences当成高频埋点存储来用,每秒写几次,那掉队、卡顿甚至ANR都可能找上门。

3.2 封装一个可复用的存储工具类

直接散落调用SharedPreferences的API不是不能写,但写多了会发现到处是重复代码,而且Key字符串满天飞,一个拼写错误就能让数据“读不到”。我在这个项目里做的第一件事就是封装一个Prefs工具类,把Key统一收敛,所有读写都走这一个入口。

先定义Key常量:

class SpKeys { SpKeys._(); static const String remindIntervalMinutes = 'remind_interval_minutes'; static const String breakDurationSeconds = 'break_duration_seconds'; static const String vibrateEnabled = 'vibrate_enabled'; static const String dailyTargetCount = 'daily_target_count'; static const String todayFocusSeconds = 'today_focus_seconds'; static const String lastRemindDate = 'last_remind_date'; }

再封装通用的读写方法:

class Prefs { Prefs._(); static SharedPreferences? _sp; static Future<SharedPreferences> get _instance async { _sp ??= await SharedPreferences.getInstance(); return _sp!; } // int 读取与写入 static Future<int> getInt(String key, [int defaultValue = 0]) async { final sp = await _instance; return sp.getInt(key) ?? defaultValue; } static Future<void> setInt(String key, int value) async { final sp = await _instance; await sp.setInt(key, value); } // String 读取与写入 static Future<String> getString(String key, [String defaultValue = '']) async { final sp = await _instance; return sp.getString(key) ?? defaultValue; } static Future<void> setString(String key, String value) async { final sp = await _instance; await sp.setString(key, value); } // bool 读取与写入 static Future<bool> getBool(String key, [bool defaultValue = false]) async { final sp = await _instance; return sp.getBool(key) ?? defaultValue; } static Future<void> setBool(String key, bool value) async { final sp = await _instance; await sp.setBool(key, value); } // 删除 static Future<void> remove(String key) async { final sp = await _instance; await sp.remove(key); } }

这个工具类的核心思路:懒加载单例持有SharedPreferences实例,所有调用方不需要关心实例初始化过程。三层方法签名也很直观:getInt、setInt、getString、setBool,覆盖面足够日常使用。如果你项目中需要存字符串列表,可以照着加getStringList和setStringList两个方法。

3.3 复杂数据与JSON序列化

视力保护App里有一块数据是“最近7天用眼统计”,如果用7个单独的Key存,代码会很啰嗦。更优雅的做法是用JSON把整组数据序列化成一个字符串存起来。

我建了一个简单的统计模型:

import 'dart:convert'; class WeekStat { final Map<String, int> dailySeconds; WeekStat(this.dailySeconds); Map<String, dynamic> toJson() => {'dailySeconds': dailySeconds}; factory WeekStat.fromJson(Map<String, dynamic> json) { return WeekStat(Map<String, int>.from(json['dailySeconds'] as Map)); } }

存储侧只需要一个Key:

// 保存 final weekStat = WeekStat({'2024-06-01': 1200, '2024-06-02': 800}); await Prefs.setString(SpKeys.weekStat, jsonEncode(weekStat.toJson())); // 读取 final raw = await Prefs.getString(SpKeys.weekStat); final stat = raw.isEmpty ? WeekStat({}) : WeekStat.fromJson(jsonDecode(raw) as Map<String, dynamic>);

这里有几个实操上的坑。第一,jsonDecode返回的类型是dynamic,强转前一定要确认结构,否则很容易抛类型转换异常。第二,空字符串要单独处理,因为首次启动时还没有写入过数据。第三,如果模型字段经常变更,最好在fromJson里写默认值,避免老版本存储的数据缺字段时直接崩。

4. 核心功能实现:提醒逻辑与设置持久化联动

4.1 计时器与用眼状态机

视力保护App的提醒逻辑,我最初想法很简单:用一个Timer每秒累加,够了就弹提醒。但很快发现问题——App一旦切到后台,Flutter引擎的Timer会被挂起,回来之后计时就不准了。

把计时改成基于时间戳之后就稳定多了。用眼开始的时候记录一个focusStartTime,每次“嘀嗒”计算now与focusStartTime的差值,而不是自己维护一个不断累加的秒数。这样即使用户中途切走应用再回来,根据系统时间差也能计算真实用眼时长。

状态机我用了一个枚举:

enum EyeStatus { focusing, breaking, paused }

状态流转是这样的:

  • 用户点击“开始专注”,状态变成focusing,记录focusStartTime。
  • 累计时长达到间隔阈值,状态切换为breaking,弹出休息提醒。
  • 用户点击“休息完成”,状态回到focusing,重新记录focusStartTime。
  • 用户手动暂停,状态变成paused,清除当前计时上下文。
class EyeGuardLogic { DateTime? _focusStart; int remindIntervalMinutes = 20; EyeStatus status = EyeStatus.paused; void startFocus() { _focusStart = DateTime.now(); status = EyeStatus.focusing; } int getElapsedSeconds() { if (_focusStart == null) return 0; return DateTime.now().difference(_focusStart!).inSeconds; } bool shouldRemind() { return getElapsedSeconds() >= remindIntervalMinutes * 60; } }

用时间戳还有一个隐藏好处:用户可以跨天计时。如果昨天23:50开始用眼,今天00:10还在用,时间差依然正确,不会被零点重置逻辑误伤。

4.2 设置与统计数据的读写闭环

SharedPreferences在设置页面的作用很直接。用户修改提醒间隔后,先更新内存中的值,再调用Prefs保存。关键点在于:修改设置后,正在进行的计时器也需要立刻使用新间隔。

设置项写好后,我会统一走这样一个方法:

Future<void> updateRemindInterval(int minutes) async { remindIntervalMinutes = minutes; await Prefs.setInt(SpKeys.remindIntervalMinutes, minutes); }

这样做的用意是把“写内存”和“持久化”绑在一起。界面层调用updateRemindInterval后,UI立即从内存变量刷新,存储异步完成也不会阻塞交互。

统计数据方面,每天用眼秒数我存成todayFocusSeconds。每次“嘀嗒”时不落盘,只在连续休息完成后追加一次,或者在App进入后台时批量保存。相比每秒写一次,这种策略大幅减少了SharedPreferences的写入次数,也避免无谓的性能损耗。

跨天重置的逻辑:

Future<void> _checkDayRollover() async { final today = DateFormat('yyyy-MM-dd').format(DateTime.now()); final lastDate = await Prefs.getString(SpKeys.lastRemindDate); if (lastDate != today) { await Prefs.setInt(SpKeys.todayFocusSeconds, 0); await Prefs.setString(SpKeys.lastRemindDate, today); } }

这个检查放在App启动和每次提醒完成时各跑一次,保证用户隔几天打开App看到的今日数据是当天重新计的。

4.3 页面状态同步:Provider与SharedPreferences的协作

SharedPreferences本身不提供内存中的响应式通知机制。如果首页改了设置,设置页必须立即反映,就需要一个状态管理方案串起来。我用的是Provider,这也是Flutter社区里最简单直白的一种组件通信方式。

整个App的状态宿主是SettingsModel,它继承自ChangeNotifier,内部维护所有和设置、统计相关的字段:

class SettingsModel extends ChangeNotifier { int remindIntervalMinutes = 20; int breakDurationSeconds = 20; bool vibrateEnabled = true; int todayFocusSeconds = 0; Future<void> load() async { remindIntervalMinutes = await Prefs.getInt(SpKeys.remindIntervalMinutes, 20); breakDurationSeconds = await Prefs.getInt(SpKeys.breakDurationSeconds, 20); vibrateEnabled = await Prefs.getBool(SpKeys.vibrateEnabled, true); todayFocusSeconds = await Prefs.getInt(SpKeys.todayFocusSeconds, 0); notifyListeners(); } Future<void> setRemindIntervalMinutes(int value) async { remindIntervalMinutes = value; notifyListeners(); await Prefs.setInt(SpKeys.remindIntervalMinutes, value); } }

入口处的装配:

void main() async { WidgetsFlutterBinding.ensureInitialized(); SharedPreferencesOhos.registerWith(); runApp( ChangeNotifierProvider( create: (_) => SettingsModel()..load(), child: const EyeGuardApp(), ), ); }

这样首页、设置页、统计页都能通过context.watch ()拿到最新数据,修改时调用model里的方法,界面自动刷新,数据自动落盘。组件之间的通信问题,通过Provider这一层就全部解决了。SharedPreferences在这里起到的是“冷启动恢复现场”的作用:App被杀掉再打开,load()从本地把数据接回来,界面状态和用户最后操作时保持一致。

5. 进阶优化与存储边界

5.1 敏感数据与AES256的取舍

视力保护App的本地数据本身不涉及账号密码,SharedPreferences直存完全没问题。但如果哪一天你在类似项目里需要存用户token、隐私数据,那就得提高警惕了。

SharedPreferences本质是明文存储。OpenHarmony上它的落地实现同样不具备加密能力。APK改个后缀解包就能看到里面的XML或对应存储文件,普通文本一览无余。想真正防住本地文件被读取后直接拿到明文,要在业务层做加密。

常用方案就是AES256对称加密:先用一个安全随机数生成密钥,密钥通过系统级安全存储保管,然后用AES256对敏感字段加密后,再把密文存进SharedPreferences。读取时先解密再使用。这个思路和本地音频文件加密场景是相通的——凡是静态存储在设备上的数据,只要不想被直接扒出来,要么靠系统沙箱,要么靠应用层加密。

需要注意:AES256的密钥管理比算法本身更棘手。最简单的教训是不要把密钥硬编码在Dart代码里,那样等于没加密。理想做法是通过OpenHarmony的Ability机制或安全组件,把密钥放到系统级存储里,然后业务侧只保存一个指向密钥的索引。

顺带提一句,如果你的需求只是防君子不防小人,那做一层基础混淆和权限控制就够了,不必为了加密而加密。加密会带来性能开销和实现复杂度,务必权衡。

5.2 减少写入频率与提升体验

SharedPreferences虽然轻量,但也不能当高频写入通道来用。我分享几条经验:

  • 只在用户完成一次“休息闭环”后写统计数据,而不是每秒写一次。
  • 设置项修改后立即写入没问题,因为频率本身低。
  • 用时间戳判断跨天和用眼时长,比用累加变量可靠得多,也能减少不必要的状态存储。
  • 大量数据不要塞进SharedPreferences,可以考虑写成文件或者用数据库。

另外,读取侧也有一点值得注意:SharedPreferences.getInstance()在首次调用时有IO开销,如果放在启动页的同步加载路径上,可能会让首帧出现短暂的卡顿。我的做法是在main()里提前预读一次,让初始化并行进行,然后再跑runApp。实际体验下来,启动速度会顺滑不少。

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

6.1 读取返回null与类型强转

这是最容易被新手踩到的问题。SharedPreferences读取不存在的Key时,getInt返回的是null,不是0。如果你直接做int赋值,会得到一个类型错误。

正确做法是永远给默认值:

final value = prefs.getInt('remind_interval_minutes') ?? 20;

我封装的Prefs工具类里已经默认了返回默认值,所以业务层不容易踩雷。另一个坑是jsonDecode出来的Map类型,强转时一定要用as Map<String, dynamic>,不要用as Map,Dart泛型在运行时是有类型检查的,写不对会直接抛错。

6.2 平台通道报错与插件适配

OpenHarmony真机上跑Flutter,如果漏了SharedPreferencesOhos.registerWith(),日志里会出现平台通道未实现的异常。更隐蔽的是,有些适配包版本较老,插件注册机制和最新Flutter SDK不匹配,即使写了registerWith也会失败。

排查路径一般是:先确认日志里是否能找到MissingPluginException,如果有,优先检查入口处的注册是否执行;然后检查shared_preferences和shared_preferences_ohos的版本组合,去仓库Issues里搜一下有没有人遇到过相同组合的问题;最后看ohos目录下的模块配置,确保动态库和插件har包都被正确打包进应用。

很多Flutter报错,比如运行日志里出现E/flutter开头,跟着一堆Dart堆栈,基本都能往“插件没注册”或“版本不匹配”这两个方向查。多看几行堆栈,别被第一行吓到。

6.3 后台计时失效与Timer挂起

Dart的Timer在App进入后台后并不会稳定执行。这不是OpenHarmony独有,Android和iOS上也有类似限制。如果你只靠Flutter的Timer做提醒,用户把App切到后台超过阈值时,弹窗不会准时出现。

要解决真正的后台提醒,需要依赖平台能力,比如OpenHarmony的后台任务机制或者系统级闹钟。那部分已经不是Flutter层能简单搞定的了。我这边实际的做法是:App在前台时用时间戳做精确计时;切到后台后,允许记录“上次活跃时间”,回前台时再补算这段时间是否超过提醒间隔,如果超过了就立即补弹一次提醒。

别把后台运行想得太简单。普通应用在系统资源紧张时,后台进程随时可能被冻结或回收,这时进程内的任何计时都不可信。靠谱的做法是降低期望值:前台体验做到精确,后台提醒做到“回来时补偿”。

6.4 多页面数据不同步

如果你在首页写了一个值,然后到设置页发现读的还是旧值,大概率是没走状态管理,直接在页面里各自调用了SharedPreferences。

SharedPreferences本身没有监听机制,页面A改了不一定能通知页面B。解决办法就是统一走Provider或类似的响应式状态。SettingsModel是唯一数据源,所有页面都从它那里读,修改也通过它的方法写,天然同步。不要在Widget的build方法里直接调Prefs读数据,那样一来每次rebuild都会产生IO调用,二来状态没法统一。

6.5 首次启动黑白屏与初始化失败

我遇到过真机安装后首次启动直接卡在启动页的情况,日志里没有明显报错,只有一行Unhandled Exception指向null。排查到最后发现,问题出在初始化顺序上:SettingsModel的构造函数里触发了SharedPreferences读取,但那时runApp还没执行,平台通道尚未准备就绪。

对策很简单,在runApp之前先保证通道就绪:

void main() async { WidgetsFlutterBinding.ensureInitialized(); SharedPreferencesOhos.registerWith(); runApp(...); }

这个顺序务必固定。WidgetsFlutterBinding.ensureInitialized()负责初始化Flutter引擎和绑定平台消息通道,没有这一步,任何插件调用都可能拿到空响应。这个教训我后来记在项目ReadMe的开头,每次新建Flutter for OpenHarmony工程都会先检查入口顺序。

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

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

立即咨询