1. 内容整体设计与适配思路
1.1 先搞清楚 memory_cache 到底是干什么的
我把话放前面:在鸿蒙端写 Flutter 应用,最容易被忽略的短板不是 UI 渲染,也不是路由管理,而是"临时数据该放哪"。
很多开发者一遇到跨页面传参、组件间共享状态,第一反应就是上全局单例或者派发事件。但全局单例写入没有生命周期管理,时间一长内存里堆积的全是脏数据;事件派发虽然解耦,可一旦页面销毁、监听者未注销,轻则空指针,重则内存泄漏。这时候你需要的其实就是一个"带过期时间的轻量 KV 存储"。
memory_cache 就是干这个的。它本质上是一个纯 Dart 实现的、运行在内存里的键值对缓存库。API 极简,核心能力就几件事:写入、读取、删除、判断是否存在、设置过期时间、容量超限后按策略淘汰。大部分操作在 O(1) 复杂度内完成,没有磁盘 IO,没有网络请求,纯粹是内存里的一个有序哈希结构。
我在某跨平台管理项目里做鸿蒙端侧适配时,第一轮改造遇到的最大痛点就是:底部导航四个 Tab 之间共享一份筛选条件,每个 Tab 都自己存了一份副本,用户切回上一个 Tab 时数据已经过期。后续切换成 memory_cache 做统一暂存层,把筛选条件、分页游标、临时草稿全部塞进去,组件间同步问题基本消失。
1.2 鸿蒙化适配的三个层次
很多刚接触鸿蒙开发的人一听到"适配"两个字,脑子里浮现的第一反应是:源码能不能编译过、原生代码要怎么重写。实际上鸿蒙侧跑 Flutter,适配工作可以分成三个层次来理解。
第一个层次是纯 Dart 包的适配。memory_cache 没有原生的 Android/iOS 代码,完全构建在 Dart 标准库之上。鸿蒙的 Flutter 环境由 OpenHarmony 的 Flutter 引擎适配层承载,Dart 语言运行时是完整保留的。所以对于这一层来说,所谓"鸿蒙化"并不是重新开发,而是验证 Dart 代码在鸿蒙引擎下的行为一致性,以及处理依赖树里那些隐藏的原生插件引用。
第二个层次是插件依赖的适配。你在 pubspec.yaml 里引用的包,它本身是纯 Dart 的,但它的传递依赖未必是。比如有些缓存库依赖 path_provider 写入临时文件,而 path_provider 在鸿蒙上需要原生侧实现,老版本会直接编译失败。memory_cache 的聪明之处是它不碰文件系统、不碰平台通道,这使得它天然具备跨端兼容性。
第三个层次是运行时环境适配。具体来说就是内存管理、异步调度、时间精度。鸿蒙上 Flutter 引擎跑在 ArkTS 运行时之上,Dart 的 Timer、ScheduleMicrotask、DateTime 这些基础能力都会有对应实现。实际适配中,我用一套我写的压测脚本在 Android 和鸿蒙上跑几万次读写操作,结果除了可忽略的时钟精度差异外,行为完全一致。
所以结论先放在这:memory_cache 的鸿蒙化适配,难点不在"能不能跑",而在"跑完之后如何封装得更好用、如何在业务层替换掉过去的痛点代码"。
1.3 为什么不用 SharedPreferences 和全局变量
项目讨论适配方案时,有同事问过一个很实质的问题:鸿蒙侧有首选项 PersistentStorage,也有全局单例,为什么还要额外引入一个内存缓存库?
我把当时会议上的对比梳理一下,照着这个逻辑走就不会踩坑。
首选项(无论是 Flutter 的 shared_preferences 还是鸿蒙原生首选项)都是持久化存储,写入要经过序列化、文件 IO、落盘,性能至少比内存操作慢一到两个数量级。如果你只是想把 A 页面一个临时筛选条件传给 B 页面,用持久化存储属于大炮打蚊子。更麻烦的是,首选项读出来全是字符串,还要自己二次序列化为 JSON 或对象,类型体操做多了容易出低级错误。
全局单例在小型项目看起来最简洁,但问题出在生命周期管理上。一个静态 Map 塞进去的数据,永远不会自己消失。登录态失效了旧数据还在,页面销毁了缓存还在,你的内存被一堆没人用的临时值占着,还很难定位是谁写的。memory_cache 引入 TTL(过期时间)机制之后,数据到点就失效,读取时自动触发淘汰,从机制上规避了这种问题。
memory_cache 解决的是"临时数据暂存区"这个场景,不解决持久化,不解决跨进程通信,不解决磁盘缓存。想清楚这一点,方案选型就不可能跑偏。
2. 核心原理与关键技术点拆解
2.1 缓存模型设计:一个带时间戳的哈希表
memory_cache 的内在结构并不神秘,核心就是一个 Map + 时间戳标记。
底层数据结构可以近似理解为:
class CacheEntry { final dynamic value; final DateTime expiresAt; DateTime lastAccessAt; int accessCount; CacheEntry({ required this.value, required this.expiresAt, required this.lastAccessAt, this.accessCount = 0, }); } class MemoryCache { final Map<String, CacheEntry> _store = {}; // 其余方法... }每写一个 key,就往 Map 里塞一个 CacheEntry。value 存储业务数据本体,expiresAt 标记此条数据何时过期,lastAccessAt 和 accessCount 配合实现 LRU 淘汰策略。
这个设计最核心的决策是"不缓存数据以外的任何状态"。比如它不会因为你要存一个 List 就帮你深拷贝一份,也不会自动把对象序列化成字符串。它把内存持有和生命周期管理做好,数据怎么加工完全是使用者自己的事。这层不做多余动作的好处是性能损耗极低、语义简单,坏处是如果你往里面存了一个大对象,那这个大对象的强引用会一直存在,直到过期或被淘汰。
内存缓存的底层模型一定要想清楚,否则后面做适配封装时你会不断怀疑"这个值是引用传递还是值拷贝"。
2.2 TTL 过期机制:懒淘汰和主动清理
memory_cache 的过期机制包含两条路径,理解这两条路径是正确使用的关键。
第一条是懒淘汰。每次执行 get 操作时,先检查当前时间是否超过 expiresAt。如果超过了,直接判定为 miss,同时把这个 key 从 Map 中移除。这样做的好处是干净利落,不用额外启动定时器,读操作自己顺手就把脏数据收拾了。缺点很明显:如果某个 key 写入后再也没有人读,它会一直躺在内存里占地方,直到下次相同 key 写入覆盖它。
第二条是主动清理。memory_cache 允许你设置一个清理间隔,内部用 Timer.periodic 定期扫描全表,把已经过期的条目成批移除。这个机制弥补了懒淘汰的盲区,但要注意清理间隔不合适会带来额外开销。间隔太短,全表扫描次数变多,数据量大时浪费 CPU;间隔太长,过期数据存留过久,又白白浪费内存。
我建议按业务波动频率来设置。列表类页面缓存清理间隔 30 秒到 1 分钟比较合适,表单草稿这种低频写入场景直接 5 分钟也没问题。不要为了"看起来安全"把清理间隔设成 100 毫秒,我实测过,百万级缓存条目下 100 毫秒全表扫描会把 UI 帧率打到明显掉帧。
2.3 容量限制与 LRU 淘汰策略
缓存库没有容量上限就是耍流氓。手机端内存本来就紧张,一个缓存库把几百兆内存吃掉,再快的命中率也没有意义。memory_cache 对容量限额的实现思路是:写入时检查当前条目数,一旦超过 maxCapacity,就按预设淘汰策略驱逐一部分条目。
这里不得不说 LRU 策略的实现细节。严格意义上的 LRU 要求每次访问都更新数据项的"最近使用时间"。在 Dart 里要按照最近访问时间排序,最朴素的实现是每次读取后调 sort,但 O(n log n) 的排序在容量大的时候会拖慢写入路径。我拆解 memory_cache 的实现后发现,它采用的是近似 LRU:通过记录 lastAccessAt 字段,在容量超限时遍历一次全表找出最旧的一批并淘汰。这个方案在中等容量下表现良好,不是绝对精确的 LRU,但收益比极高。
实际适配中我遇到过这么个一个边界场景:容量设置为 1000,写入 1000 条后并不会立刻触发淘汰,而是第 1001 条写入时才意识到超限,淘汰掉若干条后再写入。这意味着瞬时内存峰值能到 1001 条左右,虽然只差一条,但如果你依赖"最多 1000 条"这个约束来做功能逻辑,就要小心这个窗口期。
2.4 组件间状态同步:暂存层 + 消息通知
标题里提到了"优化鸿蒙端侧组件间状态同步",我多说几句这一块的落地思路。memory_cache 本身不是状态管理框架,它并不提供监听回调或者响应式更新。但把它作为暂存层,配合极轻量的通知机制,就能平替一部分全局事件总线的工作。
我的做法是这样:封装一个 Repository 层,数据读写走 memory_cache,每一次写操作之后,对外发一个同 key 的消息。页面或组件只要关心自己读的 key,收到通知后重新从缓存里 get 一次即可。
这套方案比直接派发完整数据更省内存,也比"每个 Tab 自己存副本"更容易保证一致性。关键在于"暂存层永远只有一份数据来源",组件不缓存本地副本,全部通过 key 获取。谁写谁通知,谁关心谁监听,数据流向非常清晰。
我在某项目的实际操作是:A 页面搜索条件变化后写缓存,然后通知筛选栏、列表页、详情页三个关注方,它们各自读取最新值渲染。整个链路没有引入额外状态管理库,十几个页面的联动逻辑一下子清晰了。
3. 实操过程与完整适配步骤
3.1 环境准备:鸿蒙 Flutter 工程的基础约束
鸿蒙化适配不是从改代码开始,而是从工程形态开始。当前阶段,鸿蒙侧跑 Flutter 的标准方式是:在鸿蒙工程中加载 Flutter 模块,或者以混合工程的方式嵌入 Flutter 页面。无论哪种方式,你的 Flutter 侧代码都会被打成产物包,由鸿蒙侧统一加载。
实操上分两步走。第一步,确认 Flutter SDK 版本支持鸿蒙目标平台,把flutter doctor跑通;第二步,在鸿蒙工程目录下配置 Flutter 模块依赖,保证 Debug/Release 包能正确打出 har 或 hap 产物。
特别提醒一点:鸿蒙构建环境下,pub 仓库的网络访问策略做了严格限制。部分开发者在拉取依赖时会长时间卡在Resolving dependencies...。不是代码问题,是仓库地址不在白名单里。遇到这种情况,把 pub 镜像源切到国内可访问的地址,再重新执行 pub get 就能解决。
这一步没有任何玄学,环境通了再谈代码。否则后面所有报错都会混在一起,你会分不清是编译器问题还是业务代码问题。
3.2 依赖引入:pubspec.yaml 的配置策略
memory_cache 的依赖引入非常简单,在 pubspec.yaml 里加一行:
dependencies: flutter: sdk: flutter memory_cache: ^2.0.0然后执行:
flutter pub get这里我要单独说一下版本锁定的重要性。不要手滑写成any,也不要直接拉最新版本不锁版本号。因为鸿蒙端的 Flutter 引擎通常是跟随 OpenHarmony 版本走的,Dart SDK 版本相对固定,第三方包的某个小版本可能隐式依赖更高版本 Dart 语法特性,导致编译失败。
我在适配初期就吃过这个亏。某个中间版本内部用了较新的空安全语法糖,鸿蒙侧 Dart 编译器不支持,折腾了快两个小时才发现是依赖版本问题。后续我形成了固定习惯:pubspec.lock 文件纳入版本管理,每次升级依赖单独提交,发现问题直接回滚。
3.3 核心封装:内存缓存管理类
拿到库之后,我不会直接裸用,会先包一层自己的 CacheManager。裸用 memory_cache 写业务代码的问题在于调用散落各处,后续想调整 TTL 策略、统一加埋点、加 key 前缀都无从下手。
我当时的封装长这样:
class AppCache { AppCache._internal() { _cache = MemoryCache( expiry: const Duration(minutes: 30), cleanupInterval: const Duration(seconds: 30), maxCapacity: 500, ); } static final AppCache instance = AppCache._internal(); late final MemoryCache _cache; static const _prefix = 'app:'; static const _accountKey = '${_prefix}account_info'; // 写入,带业务默认过期时间 Future<void> setJSON(String key, Map<String, dynamic> json) async { await _cache.set(key, json); } // 读取泛型数据,miss 返回 null Future<T?> getJSON<T>(String key) async { final value = await _cache.get(key); if (value is Map<String, dynamic>) return _decode<T>(value); return null; } // 针对高频筛选条件的快捷读写 Future<void> setFilter(FilterModel filter) async { await _cache.set(_filterKey, filter.toJson()); eventBus.emit(_filterEvent, filter); } Future<FilterModel?> getFilter() async { final raw = await _cache.get(_filterKey); if (raw == null) return null; return FilterModel.fromJson(raw as Map<String, dynamic>); } }几个封装要点说明下。
第一,key 统一加前缀。用意很明显:避免跟其他模块的 key 冲突,后续如果接入多个缓存库,也能直接区分来源。前缀分隔符我用冒号,可视性和一致性都很好。
第二,业务模块不直接感知 MemoryCache 实例,只通过 AppCache 暴露的语义化方法访问。这样后续更换底层缓存库时,业务代码完全不用动。
第三,写操作成功后同步发一个事件。这部分就是我在前面说的"暂存层 + 通知"的核心逻辑。组件不会自己去轮询缓存有没有变化,而是等事件来了再拉取,保证响应及时又不至于每帧都查。
3.4 业务层替换改造
业务层改造是整个适配过程中最繁琐但也最给人成就感的一步。我按照"数据特性"把原来的存储调用分成三类,分类处理。
第一类:临时页面状态。比如表单输入、列表滚动位置、分页游标。这些数据生命周期短,丢了不可惜,但是切换页面时希望立刻恢复。我用默认 TTL 30 分钟、容量限制 500 条的配置承载。
第二类:跨组件共享数据。比如登录态、用户配置、全局筛选条件。这类数据需要被多个页面消费,同时更新后要第一时间同步到 UI。我用固定 key + 事件通知的方式处理。
第三类:低频业务数据。比如用户拉取过一次的静态配置,如果不考虑服务端更新,就可以缓存 24 小时,减少重复网络请求。
改造时有一个原则要反复强调:缓存只是提升体验的手段,不是数据可靠性的来源。任何 key 读取为 null 时,业务层都必须有兜底逻辑。比如回源网络请求、使用默认值、或者跳转重新登录。我在代码审查时严格要求同事遵守这条,因为缓存 miss 时如果直接崩了,就说明一开始设计就有问题。
3.5 内存缓存调优的三个参数
适配做完之后,真正考验水平的是调参。我总结三个核心参数以及影响范围。
第一个是 TTL 时长。TTL 太长会导致用户看到过期信息,比如切换账号后旧列表还在;太短则缓存命中率低,起不到暂存效果。我的经验是:优先按业务语义设过期,而不是统一设一个值。筛选条件 10 到 30 分钟,账号信息跟随登录态,分页游标 5 分钟足够。
第二个是清理间隔 cleanupInterval。这个参数决定了"没人读的过期数据"能在内存里活多久。间隔越短,内存越干净,但 CPU 消耗越高。按照条目数量来算,每次全表扫描是 O(n) 的,几十条毫无压力,上万条就要谨慎。
第三个是容量上限 maxCapacity。容量不能只看条目数,得看单条数据的体量。一条缓存 10KB 的 JSON 和一条缓存 1MB 的位图,对应的内存压力天差地别。我的建议是按单条数据体积预估内存,然后设定上限。
表格直观列一下:
| 参数 | 影响面 | 推荐策略 |
|---|---|---|
| TTL 时长 | 数据新鲜度与命中率 | 按业务场景差异化设置 |
| 清理间隔 | 内存回收速度与 CPU 开销 | 中低频扫描,30 秒起 |
| 容量上限 | 整体内存水位 | 按单条体积估算后设置 |
4. 常见问题与排查技巧实录
4.1 编译失败:运行不起来先查这三处
适配鸿蒙时遇到的编译问题,大多数不是 memory_cache 本身的问题,而是工程环境问题。我把排查顺序固定了:先看 Flutter SDK 版本和鸿蒙 SDK 版本的兼容矩阵,再看 pub 依赖解析是否成功,最后才是业务代码编译日志。
有一个高频报错是类找不到。这通常发生在混用不同版本的空安全 SDK 时,某个传递依赖没有正确解析到鸿蒙平台。解法是把依赖精简到最少,逐个flutter build验证。别嫌麻烦,依赖树里每多一个包,鸿蒙侧跑不动的风险就会增大一分。
4.2 数据过期后读到了旧值
这是我接到反馈最多的问题。业务场景是这样:A 模块写入一个 key 之后,B 模块读取,在 TTL 尚未过期的时候,用户手动触发了一次刷新,期望拿到新数据,但旧值还在。
排查后发现,这不是 memory_cache 的 Bug,而是写入失败被静默吞掉。调用 set 方法时返回的 Future 没有 await,后续网络请求还没回来,页面已经按旧值渲染了。解决办法很简单:所有写操作都 await,或者在写完后主动派发通知事件。
另一个容易忽略的坑是 key 冲突。不同模块如果用了相同的 key,后写的数据会覆盖先写的数据,但业务上互不知情。我在封装里已经加了模块前缀,核心目的就是规避这种"幽灵覆盖"。
4.3 大对象缓存导致内存暴涨
有段时间测试反馈应用内存异常,机型越老越明显。定位后发现缓存里塞了图片字节数组,单条高达几十 MB。内存缓存不是不能存图片,是要用对方式。正确的做法是存图片的缩略图或者解码后的尺寸描述,全尺寸大图让图片缓存库去管理。
如果确实需要在缓存里存字节数据,我给两个底限建议:一是严格控制容量,二是宁可 TTL 短一些,也不要把大对象的过期时间拉长。这两条守住了,内存水位基本可控。
4.4 排查清单速查
| 现象 | 可能原因 | 解决动作 |
|---|---|---|
| 排除依赖后编译失败 | 传递依赖或 SDK 版本过旧 | 更新到支持鸿蒙引擎的版本 |
| 数据不更新 | 写入未 await / 未发通知 | 等写入完成,同步派发事件 |
| 数据被莫名覆盖 | key 冲突 | 统一加模块前缀 |
| 内存异常增长 | 大对象缓存过多 | 压缩数据、缩短 TTL、收紧容量 |
| Tab 切换后状态丢失 | TTL 过短或未使用暂存层 | 单独加长关键 key 的过期时间 |
5. 适配后的性能表现与使用边界
5.1 压测数据:读写耗时与命中率
适配完成后,我在鸿蒙真机上用 10 万条数据做了压测。单条 JSON 数据大小约 2KB,连续执行 5 万次写入、5 万次读取。
结果表明:写入耗时大约在微秒到百微秒级别,受 GC 影响有小幅波动;读取命中耗时更稳定,基本在微秒级。对于 UI 侧高频读取的场景,这个性能足够保证流畅。
容量淘汰表现上,当写入量超过 maxCapacity 后,每次写入会触发淘汰动作,耗时略有上升,但整体没有出现明显毛刺。
我还同时压了 SharedPreferences 做对比。同样的读写场景,SharedPreferences 的耗时是内存缓存的几十倍到上百倍,且线程模型更复杂。这不是说 SharedPreferences 没用,只是它不该用在"高频临时状态暂存"这个场景。
5.2 边界条件:什么时候不应该用内存缓存
凡是工具,都有边界。我明确说一下什么时候你不应该选 memory_cache。
第一,需要进程重启后仍然保留数据的场景。内存缓存一断电就没了,不要拿它当持久化存储。账号、本地设置、用户协议同意结果,这些必须走磁盘。
第二,跨设备、多端同步的场景。内存缓存只存在于一个运行实例内,跟服务端同步无关。
第三,超大体积数据的存储。一个虚拟机、一个大型离线包,这种 GB 级数据进内存缓存是灾难。
第四,需要事务性保证的场景。内存缓存没有事务概念,写入一半被中断,数据就是脏的。
明确边界之后,方案才经得起推敲。这个适配项目里,我坚持在代码注释里写清了"本模块只做临时暂存,不做数据持久化"这句说明,后续接手的人也不会再误用。
5.3 鸿蒙侧端到端的应用架构参考
以我实际交付的架构为参考,整个链路长这样:Flutter 业务层调用封装好的 Repository,Repository 内部查内存缓存。命中直接返回;未命中则走网络请求,拉取成功后回填缓存,再返回给 UI。UI 层不做任何缓存判断,只管渲染。
这个架构的价值在于职责单向且清晰。UI 不碰缓存,缓存不碰网络,网络不碰 UI。每一层都能单独测试,出了问题也只在某一层内部排查。
鸿蒙端侧组件间同步的场景,核心就是"数据在内存里只放一份、被多个组件按 key 引用"。谁写谁负责通知,谁读谁负责兜底。这个模式在 Flutter 生态里能跑,在鸿蒙上同样能跑,因为依赖的只是 Dart 运行时基础能力。
6. 踩坑后的适配体会
如果只让我留一条经验,我会说:鸿蒙化适配 Flutter 三方包时,优先选择纯 Dart 实现、不依赖平台通道的库。这类库的适配成本极低,核心工作不在代码层面,而在封装设计层面。你能不能在业务里把它用好,比你能不能编译过它更关键。
另一个实际体会是,内存缓存虽然是临时数据暂存区,但它直接影响用户体验的连贯性。以前页面切换重新拉数据,用户能明显感觉到加载等待;接入 memory_cache 后,大部分场景秒开,卡顿反馈基本消失。这个收益立竿见影,比做一堆动画优化实在得多。
最后再分享一个小技巧:不要在你的业务代码里直接用魔法字符串作为 key。统一维护一个 key 常量类,每个 key 都写清楚用途和数据格式。排查线上问题时你会感谢自己这个决定。缓存的问题往往不是技术问题,而是"谁在什么时候写了什么"的追溯问题,key 语义清晰,麻烦能少一大半。
后续如果业务数据量继续膨胀,可以考虑扩展方向:将 memory_cache 升级为二级缓存架构,内存只存放热数据,温数据落到本地文件缓存。这个升级对上层业务完全透明,因为我把访问入口都收拢在 AppCache 里了。到时候只需要替换内部实现,业务层一行都不用动。