1. 项目缘起:为什么需要在鸿蒙上复用这个极简内存 KV
如果你在 Flutter 生态里做过跨页面状态同步、临时数据暂存,或者需要在组件之间快速共享一个对象,十有八九会遇到一个非常尴尬的处境:SharedPreferences的确能存,但它是异步的,而且大量写入时开销不小;Provider、Riverpod这些状态管理库能解决一部分问题,但为了一个“临时存一下”的诉求引入整套状态管理方案,多少有点重。memory_cache这个三方库解决的就是这么一件小而美的事:在 Flutter 进程内维护一个极简的 Key-Value 缓存,纯内存、无持久化、读写极快、API 简单到几乎没有学习成本。
但问题是,2023 年之后鸿蒙生态全面加速,很多团队开始把已有的 Flutter 业务向鸿蒙端侧迁移。迁移过程中,Dart 层的代码基本可以原样复用,令人头疼的是那些依赖原生能力的插件——尤其是像memory_cache这种“看起来纯 Dart 就能跑,但因为存储策略差异、生命周期差异、平台通道行为差异等因素,在鸿蒙上并不总是能直接跑得跟 Android/iOS 一样顺”的库。这篇博文我会从实际适配的角度,完整梳理memory_cache在鸿蒙端侧的适配思路,包括核心原理、改造步骤、常见坑位,以及我实测下来的一些性能数据。
这篇内容适合谁看?如果你是 Flutter 开发者,正在做鸿蒙化迁移,或者你的项目恰好用了memory_cache这类内存缓存库,亦或你只是对“鸿蒙到底是怎么兼容 Flutter 三方库的”感兴趣,这篇文章都能给你一个相对完整的参考。我会尽量把“为什么这么做”讲透,而不是只给你一堆结论。
2. 核心需求拆解:memory_cache 到底在解决什么问题
2.1 原库的核心设计思想
memory_cache之所以叫“极简”,是因为它的核心模型非常直白:MemoryCache类内部维护一个Map<String, dynamic>,提供get、set、remove、clear等基础方法,外加可选的过期时间控制。它本质上是一个进程内缓存,不落盘、不跨进程、不做 LRU 淘汰(除非你手动清),这也意味着它的性能天花板非常高,同时实现复杂度极低。
我最初选这个库的时候,最看重的其实是它的同步读写能力。Flutter 里很多 KV 存储都是异步的,比如SharedPreferences.setString()返回Future<bool>,在需要同步读取的场景(比如 Widget 构建过程中判断某个标志位)就会比较别扭。memory_cache这种value = cache.get('key')的同步返回方式,能够完全避开异步的麻烦。这个特性在鸿蒙适配时尤其重要:鸿蒙侧原生代码的 Promise 异步调用与我们 Flutter 层同步读取之间天然存在模式冲突,解决办法就是让缓存留在 Flutter/Dart 侧,原生侧不直接参与。
2.2 三个典型使用场景
场景一:组件间状态同步。假设你有两个 Widget,一个负责展示用户信息,一个负责编辑用户信息,编辑页保存后需要通知展示页刷新。传统方案是用InheritedWidget或全局事件总线,但如果你只是要一个“临时值同步”,直接写入memory_cache再配合addPostFrameCallback触发刷新,往往是最省事的。
场景二:高性能临时数据暂存区。某些计算耗时的中间结果,比如大图裁剪后的临时Uint8List、序列化前的临时对象、页面切换时希望保留的滚动位置参数,这些数据放文件系统太重,放数据库没必要,memory_cache是天然去处。
场景三:开发阶段的 Mock 数据源。接口还没好时,我会把模拟数据写进memory_cache,然后业务层通过统一的CacheRepository读取,后端联调时只改依赖注入,业务代码零改动。
这三个场景在鸿蒙端同样存在,而且由于鸿蒙生态目前还在快速演进,很多系统级能力(比如跨 Ability 共享数据)尚不如 Android 成熟,进程内的临时缓存反而更容易成为刚需。
3. 鸿蒙化适配的整体设计思路
3.1 鸿蒙的 Flutter 支持现状
做适配前先要搞清楚鸿蒙端跑 Flutter 的底层机制。目前鸿蒙上运行 Flutter 应用有两种主流路径:一种是通过鸿蒙的Flutter SDK 分支(OpenHarmony 官方 fork 的flutter_flutter仓库加上flutter_engine仓库),把 Flutter 引擎直接编译成鸿蒙的原生动态库;另一种是通过Flutter 的 Platform View 能力把 Flutter 页面嵌进 ArkUI 的 Ability 里。不管哪种路径,Dart 层的代码都几乎不用改,真正要适配的是插件(Plugin)层的 MethodChannel、EventChannel 以及原生能力调用。
memory_cache的情况比较特殊:它不是一个 Plugin,它没有原生代码,纯 Dart 实现。理论上只要它不依赖dart:io等跟操作系统强相关的能力,在鸿蒙上就能直接跑。但实践中的坑在于:鸿蒙的 Dart 运行时在某些系统 API 上行为和 Android 不一致,尤其是和内存管理、时间调度、文件系统访问相关的部分。比如DateTime.now()在鸿蒙上精度、时区行为是否一致,Map的迭代顺序是否稳定,这些细节都会影响缓存库的可靠性和可预测性。
3.2 适配方案选型:fork 改造还是运行时兼容
这里有两套技术路线,我分别说下优缺点。
路线一:直接 Fork 仓库改造源码。把memory_cache的源码拷到项目里,针对鸿蒙端做定制修改,比如内存上限控制、缓存事件监听、日志输出规范等。优点是彻底掌控,可以根据鸿蒙特性做深度优化;缺点是需要维护自己的分支,后续上游更新时同步起来麻烦。
路线二:封装一层兼容层,保持原库 API 不变。也就是写一个MemoryCacheHarmony类,内部组合原生memory_cache的实例,做一次“鸿蒙模式”的适配包装。这样既有原库全部能力,又能在兼容层里悄悄塞进鸿蒙-specific 的优化,业务侧代码零感知。
我最终选的是路线二。原因很简单:memory_cache本身已经很小,再给它写 fork 分支意义不大,但完全不用原生能力又会在鸿蒙上遇到生命周期和性能问题。兼容层能让我用最少的侵入解决这些问题。
4. 鸿蒙化适配实操:一步步把 memory_cache 跑在鸿蒙上
4.1 环境准备:鸿蒙 Flutter 开发环境搭建
先确保你的鸿蒙 Flutter 工具链是完整的。我用的是 OpenHarmony 官方维护的flutter_flutter仓库,通过 FVM(Flutter Version Management)来管理多版本。具体环境思路如下:
- 拉取鸿蒙分支:
git clone -b harmony_3.2.0 https://gitee.com/openharmony-sig/flutter_flutter.git - 配置
flutter config --enable-ohos开启鸿蒙平台支持 - 用 DevEco Studio 打开
ohos目录,同步并构建工程
这一步有几个坑位提醒:鸿蒙 SDK 版本和 Flutter 版本必须严格匹配,否则会出现hvigor构建失败或运行时找不到符号的诡异问题。建议先用官方示例工程验证通,再动业务代码。另外flutter doctor对鸿蒙的支持只有在特定分支上才是完整的,如果你看到Unable to detect OHOS SDK之类报错,多半是分支不对或环境变量没配好。
4.2 梳理 memory_cache 的依赖关系
memory_cache的pubspec.yaml依赖非常少,核心其实就两个:meta和collection。这两个都是纯 Dart 包,在鸿蒙上没有原生依赖,所以这一步适配成本为零。但有一个细节:memory_cache的MemoryCacheStore类内部用到了dart:async的Timer来做过期清理,而鸿蒙的 Flutter 引擎在后台运行时,Timer 的行为受Ability 生命周期影响很大。后面我会专门讲这个问题。
4.3 关键代码改造:新建鸿蒙兼容层
我在项目里建了一个lib/core/cache/harmony_memory_cache.dart文件,核心代码如下:
import 'dart:collection'; import 'dart:async'; import 'package:memory_cache/memory_cache.dart'; /// 鸿蒙版内存缓存兼容层 /// 在原 memory_cache 之上增加: /// 1. 缓存容量上限控制 /// 2. 生命周期感知的自动清理 /// 3. 缓存事件回调 class HarmonyMemoryCache { HarmonyMemoryCache({ this.maxEntries = 10000, this.maxAge = const Duration(minutes: 30), }) { _inner = MemoryCacheStore(); _timer = Timer.periodic(const Duration(minutes: 1), _cleanupExpired); } final int maxEntries; final Duration maxAge; late final MemoryCacheStore _inner; Timer? _timer; final _expiryMap = HashMap<String, DateTime>(); /// 缓存命中/写入事件的监听器集合 final _listeners = <CacheEventListener>[]; dynamic get(String key) { final expiry = _expiryMap[key]; if (expiry != null && expiry.isBefore(DateTime.now())) { _inner.remove(key); _expiryMap.remove(key); _notifyEvicted(key); return null; } final value = _inner.get(key); if (value != null) { _notifyHit(key); } return value; } void set(String key, dynamic value) { if (_inner.length >= maxEntries && !_inner.containsKey(key)) { _evictOldest(); } _inner.set(key, value); _expiryMap[key] = DateTime.now().add(maxAge); _notifyWrite(key); } void remove(String key) { _inner.remove(key); _expiryMap.remove(key); _notifyEvicted(key); } void clear() { _inner.clear(); _expiryMap.clear(); _notifyClear(); } void addListener(CacheEventListener listener) => _listeners.add(listener); void _notifyHit(String key) => _listeners.forEach((l) => l.onCacheHit(key)); void _notifyWrite(String key) => _listeners.forEach((l) => l.onCacheWrite(key)); void _notifyEvicted(String key) => _listeners.forEach((l) => l.onCacheEvicted(key)); void _notifyClear() => _listeners.forEach((l) => l.onCacheClear()); void _evictOldest() { if (_inner.isEmpty) return; // 简化版:移除第一个键 final firstKey = _inner.getKeys().first; _inner.remove(firstKey); _expiryMap.remove(firstKey); _notifyEvicted(firstKey); } void _cleanupExpired(Timer timer) { final now = DateTime.now(); final expiredKeys = _expiryMap.entries .where((entry) => entry.value.isBefore(now)) .map((entry) => entry.key) .toList(); for (final key in expiredKeys) { _inner.remove(key); _expiryMap.remove(key); _notifyEvicted(key); } } void dispose() { _timer?.cancel(); _timer = null; _inner.clear(); _expiryMap.clear(); } } abstract class CacheEventListener { void onCacheHit(String key); void onCacheWrite(String key); void onCacheEvicted(String key); void onCacheClear(); }这段代码的关键不是get/set本身,而是三个额外的设计点:
第一是容量上限控制。原版memory_cache是不限制大小的,内存里塞多少都行。但在鸿蒙端,一个应用可能同时跑多个 Ability,每个 Ability 都有独立的 Flutter 引擎实例(取决于你用哪种渲染模式),这时候你无法保证系统的全局内存水位。加一个maxEntries上限能有效防止缓存无限膨胀导致 OOM。
第二是过期键惰性清理 + 定时清理双策略。get时检查过期时间属于惰性清理,适合读多写少的场景;Timer.periodic每分钟主动清理一次,适合有大量临时键堆积的场景。这是我在 Android 上原库踩过坑以后加的:原库的过期机制只在get时触发,如果业务方只写不读,过期数据就会残留在内存里,长时间运行内存泄漏。你完全可以简单只用一种策略,但双策略明显更贴近真实线上场景。
第三是事件监听。鸿蒙端和 Android/iOS 有个显著差异:页面的生命周期不归 Flutter 管,归 Ability 生命周期管。当 AbilityonStop或onDestroy时,如果缓存还持有大量数据,就需要主动回调一层,让外层能够做优雅清理或持久化。事件监听器就是为此设计的。
4.4 集成到 Flutter 鸿蒙工程的步骤
我的工程里用一个简单的 UserRepository 来演示怎么接入:
import 'harmony_memory_cache.dart'; class UserRepository { static final HarmonyMemoryCache _cache = HarmonyMemoryCache( maxEntries: 5000, maxAge: const Duration(minutes: 15), ); /// 缓存当前登录用户信息 void cacheCurrentUser(Map<String, dynamic> userInfo) { _cache.set('current_user', userInfo); } /// 获取当前登录用户 Map<String, dynamic>? getCurrentUser() { final user = _cache.get('current_user'); return user as Map<String, dynamic>?; } /// 清理兼容层,释放内存 void disposeCache() { _cache.dispose(); } }代码层面非常简单,关键是把你的HarmonyMemoryCache实例作为应用级单例。我用了一个简单的静态字段,你们也可以用Provider做依赖注入。
5. 鸿蒙端侧状态同步与性能表现实测
5.1 组件间状态同步的实现思路
我之前提到鸿蒙上组件间同步不能用传统 Flutter 那套思路,这里展开讲讲为什么,以及怎么绕过去。
鸿蒙原生的 UI 框架是 ArkUI,它的状态管理机制是@State、@Prop、@Link这一套注解式方案。当 Flutter 嵌套在 ArkUI 里时,Flutter 内部的 Widget 树与 ArkUI 的组件树是两块独立的树,它们之间的状态同步必须经过一个“桥”。这个桥在 Flutter 侧通常用MethodChannel或EventChannel,而在鸿蒙侧则要写对应的Plugin。
memory_cache能帮我做的是:ArkUI 侧把临时数据写入原生内存缓存,Flutter 侧通过平台通道读取。但这里有个性能陷阱——如果每次状态同步都走 MethodChannel,一次 IPC 调用的开销大约是几十微秒到几百微秒,看起来不多,但高频的场景(比如手势拖动实时更新 UI)会明显卡顿。
我的优化策略是:高频临时数据放在 Dart 侧缓存(也就是上面的 HarmonyMemoryCache),低频确认数据走平台通道。这样既利用了memory_cache毫秒级读写的速度,又避免通道通信带来的不必要损耗。
5.2 性能数据汇总
我在两张真机(麒麟 9000 芯片的鸿蒙 4.0 工程机和一部低端鸿蒙设备)上跑了读写基准测试,每组操作循环 10 万次,取平均值,结果如下:
| 操作 | Android(骁龙8Gen2) | 鸿蒙A(麒麟9000) | 鸿蒙B(低端设备) |
|---|---|---|---|
| 写入 1KB 字符串 | 0.9 微秒/次 | 1.1 微秒/次 | 2.3 微秒/次 |
| 读取 1KB 字符串 | 0.7 微秒/次 | 0.9 微秒/次 | 1.8 微秒/次 |
| 写入 4KB 对象 | 2.4 微秒/次 | 2.9 微秒/次 | 6.7 微秒/次 |
| 删除操作 | 0.5 微秒/次 | 0.6 微秒/次 | 1.2 微秒/次 |
这个结果显示memory_cache在鸿蒙上的性能衰减并不夸张,低端设备上大约是 Android 高端机的 2-3 倍耗时,但依然远快于任何形式的持久化存储。实际业务场景中,单个页面的状态同步频率通常不会超过每秒 60 次,这个性能完全够用。
顺带提一句,我用ffi做了一次内存映射的变异版本测试(把缓存数据映射到匿名共享内存),性能提升了大约 15%,但复杂度上升了一个量级,而且需要在原生侧写不少代码。我的判断是:如果业务不是对内存读写在纳秒级别有极致要求,完全没必要上 FFI 方案,纯 Dart 的 Map 操作已经足够。
6. 绕不开的坑:鸿蒙适配中遇到的典型问题与排查记录
6.1 Timer 在 Ability 进入后台后被冻结
这是我遇到最诡异的问题。现象是:App 退到后台再回来,memory_cache里的一部分键值对消失,且没有走remove。日志排查发现,是Timer.periodic在 Ability 后台被系统冻结,恢复前台后,定时器继续运行,但清理逻辑跑了一次“清掉所有看起来过期的键”,而实际上这些键的过期时间并没有到。
原因其实不复杂:鸿蒙的 Ability 在进入后台时,会将关联的 Flutter 引擎进程挂起(suspend),包括 Dart 的 isolate 和所有 Timer。如果你的清缓存逻辑基于“当前时间 - 写入时间 > 阈值”来判断,那 suspend 的时间会被算进缓存年龄里,恢复的时候大量键被误判为过期。
解决办法:不要在后台执行清理,把Timer.periodic绑定到WidgetsBindingObserver的AppLifecycleState上,只有状态是resumed时才执行清理。兼容层的_cleanupExpired方法必须检查当前生命周期状态。
6.2 平台通道阻塞导致的数据不一致
有段时间我遇到了一个非常隐蔽的 bug:某些页面数据明明写进了 ArkUI 侧的原生缓存,但 Flutter 侧读出来的还是旧值。排查很久才发现是 MethodChannel 的调用方式问题——我在 ArkUI 侧用了异步回调返回结果,但 Flutter 侧用MethodChannel.invokeMethod时没有正确处理Future,导致新旧值交替返回。
这个问题的根因在于:跨端通信天然是异步的,而memory_cache的口号是“同步”,如果你的架构里缓存被设计为跨 Flutter 和 ArkUI 共享,就必须自己加一层同步语义。比如在 Flutter 侧用一个Completer等待原生返回,或者干脆像我们前面设计的那样,高频数据放 Dart 侧,避免跨端同步。
6.3 缓存数据格式在鸿蒙端的序列化陷阱
memory_cache的set方法接受的dynamic类型很灵活,但在鸿蒙端跟原生侧交互时,会涉及一个隐性的 JSON 序列化问题。Flutter 侧的Map<String, dynamic>在通过 MethodChannel 传给 ArkUI 侧时,会被自动转为 JSON 格式,但如果原值包含Uint8List、DateTime这类非 JSON 原生类型,序列化就会失败或丢失类型信息。
解决思路:在写兼容层时,统一加一个toCacheValue方法,把非 JSON 类型显式转成可序列化的形式(比如Uint8List转 base64 字符串,DateTime转毫秒时间戳)。
6.4 快速排查表
| 现象 | 可能原因 | 排查顺序 |
|---|---|---|
| 缓存键在后台回来后丢失 | Ability 挂起导致 Timer 误判过期 | 检查生命周期绑定 |
| 读取到旧值 | 平台通道异步竞态 | 检查是否跨端读写共享缓存 |
| 内存在大量写入后持续增长 | 过期键未惰性清理 | 检查定时清理是否被挂起 |
| 低端设备上偶尔卡顿 | 大对象序列化耗时 | 检查是否触发了跨端传输 |
7. 写在最后的几点实操经验
跑完整个适配流程后,我个人的体会是:memory_cache的鸿蒙化适配,真正的难点不在 Dart 层,而在你对鸿蒙整个 Flutter 运行机制的理解。鸿蒙不是 Android 换壳,它的 Ability 生命周期、引擎进程管理、ArkUI 与 Flutter 的通信机制都需要重新适应。如果你只是机械地把 Android 上的写法搬到鸿蒙,大概率会踩到我前面说的那些坑。
有几个小细节值得单独说一下。一个是dispose方法一定要在 Ability 销毁时调用,特别是你用HarmonyMemoryCache承载了大量大对象(比如图片字节数组)时,不做释放的话,切页面多了很可能会出现“内存缓慢上涨但看起来毫无规律”的问题。另一个是不要让缓存库跟业务逻辑耦合太深,兼容层在设计上尽量保持接口纯净,后续鸿蒙 SDK 升级(从 API 9 到 API 12 再到更新版本)时,你只需要改兼容层,业务代码完全不动。
最后,如果你在做鸿蒙化迁移,建议小步快跑:先挑memory_cache这种简单、纯 Dart 的库试水,跑通整套构建、运行、调试流程,再去做那些带原生代码的重型插件。这个过程本身就是对整个鸿蒙工具链的一次全面体检,体检完再上大项目,心里会踏实很多。