ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter×OpenHarmony视力提醒App:SharedPreferences持久化实战复盘

Flutter×OpenHarmony视力提醒App:SharedPreferences持久化实战复盘 用 Flutter 给 OpenHarmony 写个视力保护提醒我发现 SharedPreferences 才是那个隐藏主角起因很简单我手上有一台 OpenHarmony 开发板想着不能让它吃灰就琢磨做点实用的东西。手机用多了眼睛确实容易干就想干脆写一个“视力保护提醒 App”放在上面定时提醒休息、记录每天用眼时长还能设个目标。选型上没纠结太多直接用 Flutter。倒不是因为它天下无敌而是我实在太熟了能从调研直接跳到写代码。真正写下来才发现这个项目的核心难点根本不在 UI 有多炫、动画有多丝滑而是数据怎么持久化。说白了设置项、用眼记录、休息时间、每日目标这些数据如果每次打开 App 都丢那这 App 就没有存在价值。而这个需求落到 Flutter 上最直接、最实用的方案就是 SharedPreferences。这篇文章就完整复盘一下我的实现过程从需求设计、环境配置、存储层封装到实际踩坑全部摊开来讲。这篇实战内容适合谁给刚接触 OpenHarmony Flutter 的开发者给准备用 Flutter 在非标准 Android 系统上做工具类应用的开发者也给那些把 SharedPreferences 当成“存个字符串”而忽略它设计细节的人。看完你应该能少走很多弯路至少不用再去翻半天文档查“OpenHarmony 到底能不能跑 Flutter 插件”这种问题。1. 项目整体设计与需求拆解1.1 视力保护提醒 App 到底需要哪些功能在做任何代码之前我习惯先把“这个产品到底要解决什么问题”列清楚。视力保护这个话题说起来很宽但落到实际功能上无非三件事第一记录我是怎么用眼的。也就是统计今天一共看了多长时间屏幕比如连续看了多少分钟今天累计看了多少分钟。这个指标是整个 App 的核心数据源后面所有提醒逻辑都依赖它。第二提醒我休息。这是核心交互比如我连续用了 45 分钟屏幕它就提醒我起来远眺一会儿。提醒间隔不能写死得能让用户自己设置这就意味着必须有一个存储用户偏好设置的地方。第三给我一点轻量的统计反馈。比如每天达标了没今天累计时长是多少。统计结果不追求复杂能简单展示即可毕竟这不是健康监测设备而是一个轻量工具。这三个需求拆完之后技术点就浮现出来了计时逻辑需要一个常驻的计时器持续累加用眼时长提醒逻辑根据计时结果和用户设置的间隔触发提醒数据持久化保存用户设置、每日累计时长、最后休息时间、达标状态等在这三个技术点里数据持久化看起来最不起眼但却是支撑前两个逻辑的基础。如果提醒间隔设置不能保存那么用户每次打开 App 都要重新设置这是个灾难级的体验。如果每日累计时长不能保存那么 App 一重启统计就归零用户根本看不到自己的用眼趋势。所以项目刚开始我就确定了 SharedPreferences 作为数据持久化方案的基调。1.2 为什么存储方案偏偏选了 SharedPreferences有人可能问为啥不用数据库SQLite 也不是不行还有 hive、sqflite 这些方案可以用。但我认真权衡过针对这个项目SharedPreferences 有不可替代的优势。SharedPreferences 的核心特点是“轻”。它本质上是把数据以键值对的形式存到一个 XML/JSON 文件里读取时直接加载到内存写的时候直接落盘。对于配置类数据、用户偏好类数据这种存储模型非常合适。而视力保护 App 里存的恰好就是这种数据设置项、计数、时间戳都是零散的键值对不是结构化的关系数据。相比 SQLiteSharedPreferences 省掉了建表、写 SQL、事务管理的复杂度。相比 hiveSharedPreferences 在 Flutter 生态里的兼容性最广尤其对 OpenHarmony 这种非 Android 系统官方适配做得更成熟。毕竟我们在 OpenHarmony 上跑 Flutter本身就有一些兼容性差异要处理存储层尽量选平台适配完善度高的方案能省掉大量排查时间。另外一个重要原因是这个 App 的数据量极其有限。每日用眼时长数据按天存就是一天一条一年才三百多条。这种量级的数据用 SQLite 就是高射炮打蚊子反而是 SharedPreferences 这种简单键值对的形式读起来更流畅。不过这里也要说清楚 SharedPreferences 的边界。它不适合存大量结构化数据也不适合存需要频繁查询、条件筛选的数据。如果以后这个 App 要把每天的用眼记录做成曲线图并且要查某个月的数据那就要考虑引入数据库。但就当前项目来说SharedPreferences 完全够用甚至是更合理的方案。1.3 OpenHarmony 上的 Flutter 差异要先心里有数这个项目还有一个特殊性就是运行目标是 OpenHarmony不是标准的 Android 系统。很多人一上来就踩坑是因为拿纯 Flutter 的方式在 OpenHarmony 上搞发现要么构建不过要么运行时崩溃。OpenHarmony 和 Android 的兼容性关系一句话说清楚OpenHarmony 不直接兼容 Android APK它有自己的应用打包格式 HAP以及自己的系统 API 体系。Flutter 官方提供的是 OpenHarmony 的支持分支也就是 Flutter 的 OpenHarmony SDK 版本需要单独下载配置。构建工具链也不是 Gradle AAR而是 hvigor HARHarmony Archive。我一开始没注意这个区别按照标准 Flutter 流程去装结果构建的时候直接报错折腾了几个小时才发现问题出在 SDK 版本上。具体来说OpenHarmony 上跑 Flutter 需要配置两套东西一套是 OpenHarmony 的 SDK包含 API 和编译工具另一套是 Flutter 的 OpenHarmony 分支 SDK不是 Google 官方那个这两者缺一不可。这个项目让我最深的一个体会是跨端开发最花时间的往往不是业务代码本身而是“让代码跑起来”的过程。如果你也想做类似的 OpenHarmony Flutter 项目先别急着写业务逻辑先把环境跑通用最简单的“Hello World”验证整条链路再开始业务开发。2. SharedPreferences 本地存储接入与数据模型设计2.1 加入 Flutter 插件与工程配置在 Flutter 工程里接入 SharedPreferences正常情况下只需要在pubspec.yaml里加依赖dependencies: flutter: sdk: flutter shared_preferences: ^2.2.2然后运行flutter pub get导入包就行import package:shared_preferences/shared_preferences.dart;但在 OpenHarmony 上这件事没有那么“自动”。OpenHarmony 的 Flutter SDK 目前对插件体系的处理机制跟 Android 不太一样插件往往需要以源码方式或者以 HARHarmony Archive方式集成而不是像 Android 那样直接引用 AAR 自动完成编译链接。我实际踩的坑是这样的加了依赖后在 OpenHarmony 的构建环境里跑flutter pub get依赖能拉下来但是构建 HAP 的时候报找不到SharedPreferencesPlugin这个符号。排查了一圈最后发现是需要在 OpenHarmony 工程侧也就是ohos目录下的模块手动声明并链接这个插件的原生实现或者在构建配置里指定插件源码路径。不同版本的 Flutter for OpenHarmony 适配方式会有差异如果你也遇到类似问题优先去插件源码里看它有没有提供ohos目录的插件注册文件。提示不要在 Windows 上直接跑 OpenHarmony 的 Flutter 构建我试过各种路径和权限问题能把人逼疯Linux 或者 Mac 环境省心太多。2.2 数据模型设计先把要存哪些键想明白写任何存储代码之前第一件事是设计键Key。我见过太多人拿到 SharedPreferences 就开始setString、setInt写到哪里算哪里最后项目一半键名混乱类型还不一致自己都不记得某字段到底是字符串还是整数。在设计键的时候我遵循了三个原则第一前缀分组。所有键用模块前缀来区分比如设置项用setting.前缀统计数据用stat.前缀这样在调试时看着键名就能猜出它的用途也方便统一清理。第二类型稳定。一个键对应一种数据类型不能一会儿存 int一会儿存 StringSharedPreferences 虽然允许你存不同类型的值但取的时候类型不对会引发运行时异常。第三最小化存储。能用短字符串表示的数据就不存完整对象能用时间戳表示的就不存格式化字符串。比如每天统计的日期我直接用String存yyyy-MM-dd格式而不是存完整的DateTime对象序列化结果。结合视力保护 App 的需求我最终设计的键值表如下键名类型默认值用途说明setting.remindIntervalMinutesint45提醒间隔单位分钟setting.dailyGoalMinutesint120每日目标时长单位分钟setting.autoResetbooltrue是否自动跨天清零stat.lastRestTimestampint0上次休息时间戳stat.todayUsageSecondsint0今日累计用眼秒数stat.todayUsageGuidString统计内容的唯一标识stat.lastDateString上次记录数据的日期字符串system.firstLaunchTimeint0首次启动时间戳用于后续扩展这个表看起来不起眼但它是整个 App 数据层的“宪法”。后面所有逻辑代码都按这个表来读写。你可能会问stat.todayUsageGuid这个 Field 看起来有点多余其实是留给之后的同步扩展用的先预留可以省得以后大改。2.3 封装一个全局存取工具类而不是直接到处 new如果你在代码里到处写SharedPreferences.getInstance()再 get/set短期能用但后期会非常痛苦。因为如果哪天你想把存储方案从 SharedPreferences 换掉或者加上一层缓存逻辑就要改十几个文件。我在这个项目里做了一层简单的封装起名LocalStorageHelper统一管理所有键值对读写。设计思路是这样的class LocalStorageHelper { static LocalStorageHelper? _instance; static SharedPreferences? _prefs; static FutureLocalStorageHelper getInstance() async { if (_instance null) { _prefs await SharedPreferences.getInstance(); _instance LocalStorageHelper._(); } return _instance!; } int getInt(String key, {int defaultValue 0}) _prefs?.getInt(key) ?? defaultValue; String getString(String key, {String defaultValue }) _prefs?.getString(key) ?? defaultValue; bool getBool(String key, {bool defaultValue false}) _prefs?.getBool(key) ?? defaultValue; Futurevoid setInt(String key, int value) async { await _prefs?.setInt(key, value); } Futurevoid setString(String key, String value) async { await _prefs?.setString(key, value); } Futurevoid setBool(String key, bool value) async { await _prefs?.setBool(key, value); } }这个封装结构很简单但很实用。让我说几个设计细节首先这是一个单例。SharedPreferences 本身设计上也是全局单例getInstance()的开销在于第一次读取文件后续都是走内存缓存。如果每次 set 之前都重新getInstance()文件会被重复加载虽然影响不大但在低性能设备上还是能感知到延迟的。其次所有 getter 都有默认值兜底。这样即使某个键因为版本升级或者数据迁移没有初始化也不会导致空指针或者类型转换异常。这算是我写了很多 Flutter 存储代码后最大的一个心得永远别要求某个键一定存在。因为在极端情况下比如用户清除了 App 数据存储文件会被重置这时候你不做兜底App 就会崩。还有一点这个封装里没有处理线程并发。SharedPreferences 的写入是异步的如果你的业务逻辑里存在多线程同时写一个键的情况要注意最后的写入者覆盖问题。在视力保护 App 里主要写入都是单线程触发所以暂时不涉及但如果后期做多任务后台同步就要考虑加了。3. 核心功能实现从用眼计时到休息提醒3.1 用眼时长统计的完整逻辑链用眼时长统计是整个 App 的最底层功能它的任务是记录“今天已经用了多久屏幕”。这个功能如果不做持久化那每隔一段时间就要重新计算“今天”这个概念就毫无意义。整个逻辑链如下起点是 App 冷启动或从后台恢复。这时候我首先读取stat.lastDate判断是不是同一天。如果不是同一天说明跨天了那就需要重置stat.todayUsageSeconds为 0并把stat.lastDate更新为今天的日期字符串。如果用户设置了setting.autoReset为 false那就不能重置而要继续累计。这个判断必须在任何使用时长数据之前执行不然会出现一个 bug凌晨 12 点后 App 还在运行过了 0 点没有重置用户看到的就是前一天的数据延续到了今天很困惑。接下来是计时的核心逻辑。我用一个Timer.periodic来实现每 1 分钟累加 60 秒这个精度对于视力保护场景足够了。如果不要求精度特别高甚至可以用“只在 App 可见时计时”的方式用 App 生命周期回调来驱动计时但那样逻辑会复杂很多后台切回还要补算时间差。最后我选了最简单可靠的方案前台定时器累加。Timer.periodic(const Duration(minutes: 1), (timer) async { final storage await LocalStorageHelper.getInstance(); final current storage.getInt(stat.todayUsageSeconds, defaultValue: 0); await storage.setInt(stat.todayUsageSeconds, current 60); });这里要注意一个细节我把setInt写在了 timer 回调里每 1 分钟就写一次盘。有人可能会觉得频繁写盘会不会耗电或者磨损存储实测下来SharedPreferences 的写盘频率很低一分钟一次完全在可接受范围内。而且就算你在计时过程中关闭了 App因为数据最多丢 1 分钟下次启动后补上就行用户体验上无感知。3.2 休息提醒的触发机制与状态存储休息提醒功能是用户最容易感知到的部分它的触发机制不复杂但状态管理要想清楚。设计逻辑是每次启动计时器之前读取stat.lastRestTimestamp计算当前时间 - 上次休息时间 提醒间隔时就触发提醒。提醒触发后立刻把stat.lastRestTimestamp更新为当前时间戳这样不会重复提醒形成“休息-计时-再提醒”的完整周期。Futurevoid checkAndNotifyRemind() async { final storage await LocalStorageHelper.getInstance(); final lastRest storage.getInt(stat.lastRestTimestamp, defaultValue: 0); final intervalMinutes storage.getInt(setting.remindIntervalMinutes, defaultValue: 45); if (lastRest 0) { await storage.setInt(stat.lastRestTimestamp, DateTime.now().millisecondsSinceEpoch); return; } final diffMinutes DateTime.now() .difference(DateTime.fromMillisecondsSinceEpoch(lastRest)) .inMinutes; if (diffMinutes intervalMinutes) { _triggerRestRemind(); await storage.setInt( stat.lastRestTimestamp, DateTime.now().millisecondsSinceEpoch); } }这里我想强调一个容易犯错的地方——把时间戳存成字符串。我见过不少项目为了“可读性”把时间戳转成了2024-06-15 23:00:00这样的字符串存到 SharedPreferences。这个方案的问题在于比较时间时还得再DateTime.parse()转回来而且如果你存的是yyyy-MM-dd HH:mm:ss当系统设置时区变化时解析会有偏移调试起来非常痛苦。正确做法就是直接存millisecondsSinceEpoch读取后用DateTime.fromMillisecondsSinceEpoch转换干净利落。至于提醒的形式我用的是 Flutter 层的对话框 本地通知的组合。OpenHarmony 上目前对 flutter_local_notifications 插件的适配情况不如 Android 成熟为了保证兼容性我的做法是用系统 Toast 和弹窗这种基础形式来实现提醒等通知插件适配成熟后再升级。这属于典型的多端适配思维——用最小公共能力先跑通功能再用平台特性做增强。3.3 设置页面与 Provider 状态管理联动视力保护 App 必然要有一个设置页用户能调整提醒间隔、每日目标等。这些设置项被修改后要立刻保存到 SharedPreferences同时要通知其他页面刷新状态。这里就涉及到热词里经常有人问的flutter provider 怎么用正好在这个项目里实践了一下。设置页的核心逻辑是进入设置页时从 SharedPreferences 读取当前值显示到表单控件用户修改后先写入 SharedPreferences然后通过 Provider 更新状态触发其他页面更新。我用的状态管理方案是provider因为它足够轻、足够稳定而且社区生态大。具体做法先定义一个SettingsProvider继承 ChangeNotifierclass SettingsProvider extends ChangeNotifier { int _remindIntervalMinutes 45; int _dailyGoalMinutes 120; int get remindIntervalMinutes _remindIntervalMinutes; int get dailyGoalMinutes _dailyGoalMinutes; Futurevoid load() async { final storage await LocalStorageHelper.getInstance(); _remindIntervalMinutes storage.getInt(setting.remindIntervalMinutes, defaultValue: 45); _dailyGoalMinutes storage.getInt(setting.dailyGoalMinutes, defaultValue: 120); notifyListeners(); } Futurevoid setRemindInterval(int minutes) async { _remindIntervalMinutes minutes; notifyListeners(); final storage await LocalStorageHelper.getInstance(); await storage.setInt(setting.remindIntervalMinutes, minutes); } }这里有个顺序要注意先更新内存里的状态并 notifyListeners再写 SharedPreferences。这样 UI 能立刻响应不会让用户觉得点了没反应。而持久化写入即使稍慢一点用户也无感知。Provider 和 SharedPreferences 联动就实现了“设置一次永远生效”的效果。而且load()方法统一在 App 启动时调用一次之后整个运行期间所有页面都从同一个 Provider 读取状态避免到处重复读存储。从组件通信的角度看Provider 解决的是“跨页面、跨组件共享状态”的问题。在这个 App 里设置页改了提醒间隔主页面的倒计时标签要立刻更新如果不引入状态管理就得用回调一层层传值代码会很难看。Provider 则把这种跨组件联动变得非常直观。3.4 跨天清零与统计持续性策略跨天清零这件事情表面上看 App 只在用户打开时才记录时长跨天判断只要在启动时做一次就行了但实际使用场景要复杂得多。比如我午休时退出 App下午再打开此时已经是第二天了不一定。你要根据stat.lastDate和当前日期比较而不是根据系统时间是否跨越 0 点来判断。这里的逻辑看起来简单但有个边界情况如果用户一直开着 App跨过 0 点了那么你的 timer 还在跑每分钟往stat.todayUsageSeconds里加值但此时已经不是同一天了。如果你只在 App 启动时做跨天判断就会导致这一整天不重置。我的解法是把跨天检查也放到定时器回调里每执行一次检查一次日期。这样即使 App 一直开着过了 0 点后第一次定时器触发就会检测到跨天从而执行重置。Timer.periodic(const Duration(minutes: 1), (timer) async { final storage await LocalStorageHelper.getInstance(); final today DateUtils.dateOnlyString(DateTime.now()); final lastDate storage.getString(stat.lastDate, defaultValue: ); if (lastDate ! today) { await storage.setInt(stat.todayUsageSeconds, 0); await storage.setString(stat.lastDate, today); await storage.setInt(stat.lastRestTimestamp, DateTime.now().millisecondsSinceEpoch); } final current storage.getInt(stat.todayUsageSeconds, defaultValue: 0); await storage.setInt(stat.todayUsageSeconds, current 60); });千万不要小看这个“跨天重置”的逻辑。我一个朋友做类似的应用当时就是偷懒只在onCreate里做一天判断结果测试时发现开了一宿没关 App第二天数据居然变成负数因为逻辑里还减去了超出部分之类。虽然我上面代码不会导致负数但如果不做定时器内的跨天检查用户体验会错乱。此外关于“每日目标达成度”我是在做统计展示时计算的。每日目标数存在setting.dailyGoalMinutes当前用眼时长存在stat.todayUsageSeconds两者相除就能得到进度条显示的百分比。这个计算不涉及持久化纯内存计算就行所以没单独设计存储。4. 常见问题与排查技巧实录4.1 构建期报错Gradle Plugin 的 “apply” 问题做 OpenHarmony Flutter 项目时一个非常典型的构建报错对应的原生错误信息长得就像这个样子You are applying Flutters main Gradle plugin imperatively using the apply script method, which is not supported. Apply the Flutter Gradle plugin instead.这个报错的意思简单说就是构建系统要求你使用声明式的plugins DSL方式引入 Flutter Gradle 插件而不是用旧式apply方法。这在纯 Android 的 Flutter 工程里也会出现但 OpenHarmony 的工程因为同时存在 HAP 构建和 Android 构建的兼容层更容易触发。解决的思路分两条路走第一种在settings.gradle里声明插件路径然后用plugins {}引用。 第二种如果工程中确实有历史遗留代码用了apply就手动改成插件 DSL 方式。具体到 OpenHarmony 工程因为 hvigor 是主要构建工具Gradle 配置更像是一个兼容性外壳。我最终的解决办法是检查ohos模块下的构建配置文件确保 Flutter 插件的应用方式与 OpenHarmony 的 hvigor 兼容版本匹配。这里有句话要送给准备入坑的人多注意你的 Flutter for OpenHarmony SDK 的版本说明它会在文档里明确告诉你它期望的构建脚本格式。还碰到过直接把 Android 工程里的build.gradle拷贝到 OpenHarmony 工程里的做法这基本必炸。两者的模块结构差异不小照搬不仅不能构建还会让你排查个半死。老老实实按 OpenHarmony 模板工程来再逐步加配置。4.2 运行时崩溃Dart VM 初始化错误在你绕过构建问题之后下一个高频坑就是运行时崩溃尤其是下面这个E/flutter (31173): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: ...这个错误信息本身非常笼统就是 Dart 层某个未捕获的异常被打印出来了。如果你刚好在初始化 SharedPreferences 之后跑某些代码问题就出在getInstance()没有正确完成或者某处没有加await。在我这个项目里这个报错出现过一次原因是这样我在main()里调用SharedPreferences.getInstance()时忘记在WidgetsFlutterBinding.ensureInitialized()之后执行。Flutter 的插件系统在初始化前调用原生通道会导致一个“channel hasnt been set up”的异常最终就体现为未捕获异常。正确顺序应当是这样的void main() async { WidgetsFlutterBinding.ensureInitialized(); final storage await LocalStorageHelper.getInstance(); await storage.init(); runApp(MyApp(storage: storage)); }WidgetsFlutterBinding.ensureInitialized()这行相当于告诉 Flutter 引擎“我要开始用平台通道了”不调用它任何插件相关的操作都会出问题。这是 Flutter 新手最容易踩的坑也是很多“我明明按文档写的为什么报错”的根源。4.3 SharedPreferences 数据读不到或写入失败有一个我印象非常深的问题发生在真机调试时App 运行正常数据写进去了但第二次打开时读出来的全是默认值。查了半天发现问题出在设备上残留了旧版本的 App 数据。在 OpenHarmony 上调试时如果你之前装过旧版本特别是没有清理数据直接覆盖安装SharedPreferences 的文件可能被旧版本占着或者新旧版本的包名不一致导致新版本读到的存储目录不是同一个位置。解决办法很简单在设备上把旧 App 卸载干净重新安装就好。如果你是靠持续调试迭代这一步相当重要。另一个情况是写入失败。SharedPreferences 的setInt返回一个Futurebool有时候会返回 false但不会抛异常。这意味着写盘失败了。原因通常是存储空间不足、文件被锁定或者跨进程并发访问。在正规开发流程里你应该把返回值接住写上错误日志final result await storage.setInt(stat.todayUsageSeconds, newValue); if (!result) { // 写入失败记录日志辅助排查 }我见过很多代码只写了await prefs.setInt(...)完全不管返回值等到生产环境出问题了毫无头绪。把返回值玩起来不麻烦但对排查问题帮助巨大。4.4 常见问题速查表现象可能原因解决办法构建报 Gradle plugin apply 错误构建脚本用了旧式 apply 方式改为 plugins DSL或用 OpenHarmony 模板工程运行时 Dart VM 初始化异常插件初始化前没 ensureInitializedmain 方法第一行加 WidgetsFlutterBinding.ensureInitializedSharedPreferences 读取都是默认值包名不一致或旧数据残留卸载重装确认包名一致setInt 返回 false写盘失败存储空间或文件锁检查设备存储空间捕获返回值跨天数据没有重置timer 中没有做跨天检查在 timer 回调中加入日期比对逻辑设置项在重启后丢失写入前没有 await 完成确保 await prefs.setXXX() 完成后再离开4.5 分步排查技巧遇到怪问题这样定位说起来这可能是我这一篇最想分享的一个通用经验任何平台、任何框架的“怪问题”都要学会分级排查。我总结出的思路大概是这样先分前端还是后端。在 Flutter 里就是 Dart 层还是原生层。Dart 层的问题往往有清晰的堆栈运行日志就能定位。如果是原生层的问题你就要考虑是不是插件不兼容、构建配置有误或者 SDK 版本不对。再分构建期还是运行期。构建期错误给的信息一般比较明确路径问题、依赖问题、语法问题看控制台输出基本能定位。运行期错误信息模糊就要加日志、设置断点、然后最小化复现。比如 SharedPreferences 写入异常我就写过一个小测试页只加载存储模块手动往所有键写一遍数据再重启读取对照排查。最后分真机还是模拟器。我之前在 OpenHarmony 模拟器上跑得好好的一上真机就偶尔丢数据排查下来发现是模拟器和真机的文件系统调度策略不同。所以如果你在模拟器上一切正常、真机上出问题不要怀疑代码逻辑先往系统差异上找。另外一个帮助巨大的习惯是每次迭代都记录“当前版本号 当前环境 修改点”尤其是配置文件和构建脚本的改动任何一条看似无关的环境变量变化都可能导致后续诡异的 Bug。我有一次排查了整整两小时最后发现是设备系统时间不对导致跨天判断出了问题而系统时间不对的原因仅仅是因为前一天晚上我把设备时间手动调过。这种问题如果不记录环境变化真的查到我怀疑人生。5. 测试与真机联调的一些实战经验5.1 在 OpenHarmony 设备上跑 Flutter 的调试流程如果你在 OpenHarmony 真机上跑 Flutter 应用最理想的调试方式不是直接跑flutter run而是分阶段验证。先说简单的验证方式flutter build hap --debug。构建产物可以手动安装到设备上。这种方式的优点是能确认编译阶段没问题缺点是安装到设备上后不能直接看日志要配合hdc工具查看窗口日志。真正的联调建议用 DevEco Studio 的调试通道配合flutter attach的模式。你需要先在设备上启动 App然后命令行执行flutter attach就能进入热重载、断点调试的流程。我个人的使用体验是这种模式下调试效率比纯日志高很多。还有一个容易被忽视的点OpenHarmony 设备的内存和性能比常规手机要弱。在真机上运行 Flutter 的首次启动速度会比较慢不要因为这个就认为 App 卡死了。等它几秒钟就会发现页面加载完成。这种“慢启动”现象在 debug 模式下特别突出如果打 release 包就会好很多。做产品验证时建议优先用 release 包来评估真实性能。5.2 用日志验证存储层是否正确工作这里提供一个非常有效的调试方法在SharedPreferences 读写逻辑里加日志。别嫌日志“不优雅”真要排查问题的时候日志就是唯一的真相来源。比如在设置页面修改提醒间隔时你打印两行日志一行是“即将写入的键值”一行是“写入结果”。在页面重新加载时再打印一行“读取到键值”。对比日志就能快速判断到底是写入环节出问题还是读取环节出问题。我习惯在所有 SharedPreferences 的读写处都加一行简短的日志日志格式类似debugPrint(SP WRITE keysetting.remindIntervalMinutes value45 resulttrue); debugPrint(SP READ keysetting.remindIntervalMinutes value45);这种日志在正式发布前可以统一用kReleaseMode条件编译关掉避免刷屏和性能损耗但调试期必须保留。有问题不敢说才是大问题。6. 最后再分享一些真实体会也算踩坑后的经验总结这个项目做下来前后花了一周多的时间。不算长但信息密度很高尤其是和 OpenHarmony 相关的部分踩的每一个坑都让我想摔键盘。但反过来想正是这些坑让我对这个技术组合有了更深的认知。第一点体会Flutter 在 OpenHarmony 上跑本质上是“跨了两个平台”——Web 前端跨到 Flutter原生的 Android 跨到 OpenHarmony。每一次跨越都意味着适配成本你在设计阶段就要留出排查时间。如果你是一个“照着教程抄一遍”的开发模式遇到 OpenHarmony 这种非标准环境大概率会卡住。所以我的建议是不要只抄代码要理解它背后的运行机制比如 Flutter 插件在 OpenHarmony 上是怎么和原生层通信的SharedPreferences 底层是怎么落盘的。理解了这些遇到问题才知道从哪儿入手。第二点体会SharedPreferences 在这个项目里承担的职责比我预想的重要得多。最初我以为它只是“存设置”的小工具后来发现它从用眼计时到休息提醒从跨天重置到设置联动全都是靠这一层薄薄的键值对封装在支撑。这让我重新理解了 SharedPreferences 的应用边界——它不是一个“low-level”的存储方案而是一个只要用对场景、用对模式就能发挥大价值的基础设施。第三点体会当你开始做一个 App 时先把存储层设计好比先堆界面更值钱。界面不好看可以后期美化但数据设计如果一开始就拍脑袋后面改起来就是牵一发动全身尤其是持久化格式的变更还得考虑老用户的升级兼容问题。我这次的键值表和封装工具类虽然写起来也就几十行但它给后续开发省下来的时间远远超过了写它花掉的时间。如果你也想照着这个思路做一个类似的工具类 App我的建议是先在自己的标准 Flutter 环境里把逻辑跑通再迁移到 OpenHarmony 上做适配调试。这样可以把“逻辑开发”和“适配开发”两个阶段分开排查问题时的变量少一半思路清晰得多。祝你少踩坑多下班。
返回列表