ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter+OpenHarmony零依赖实现临时记事本内存CRUD

Flutter+OpenHarmony零依赖实现临时记事本内存CRUD 做记事本应用最容易翻车的地方往往不在 UI而在数据层。别笑我见过不少 Flutter 练手项目开局先选状态管理库、再搞数据库同步结果连最基本的“新增一条记录再删掉”都写得糊里糊涂。这次我的目标是明确的用 Flutter OpenHarmony 做一个鸿蒙风格的临时记事本并且把“零依赖”作为底线——不引第三方状态库、不引数据库第一篇文章只把内存 CRUD 砸实。临时记事本的核心使用场景就是随手记、随手扔应用重启后数据丢了也不心疼所以先把内存层做透反而是最合理的第一步。这个系列适合已经能把 Flutter 工程跑起来、但对 OpenHarmony 适配不熟的读者。你不用先懂复杂的鸿蒙原生开发只需要明白 Flutter 的 UI 层照样可以用在 OpenHarmony 设备上数据层用 Dart 写在内存里而增删改查这四个动作我们要做到既直观、又可扩展。下面所有讨论都围绕临时记事本的实际场景展开创建笔记、列出笔记、修改标题或内容、删除记录。这 4 件事做完一个记事本的数据核心就有了。1. OpenHarmony Flutter 项目起步不能只停留在“能跑”1.1 为什么选 Flutter 来做 OpenHarmony 上的记事本先说一个容易被人忽略的事实OpenHarmony 是一个完整的操作系统生态但它的应用形态这几年才逐步完善。直接用原生 ArkTS 写界面当然可以不过如果你已经积累了 Flutter/Dart 的开发经验用 Flutter 的适配版引擎来构建 OpenHarmony 应用学习成本会低非常多。Flutter 的渲染层抽象得比较彻底UI 代码在 Android、iOS、OpenHarmony 之间能保持很高复用度这对“一套代码多端复用”的小工具类项目来说相当划算。临时记事本这种项目数据量小、页面结构简单、交互路径短用 Flutter 写并不会比原生重多少。更妙的是Dart 的语言特性很适合做内存 CRUD集合类型丰富、异步模型简单、没有复杂的头文件依赖。你甚至可以完全不用引入 Rx 或者响应式框架靠着 Flutter 自带的 ChangeNotifier 和 ListenableBuilder就能把内存数据和界面状态绑起来。这就是“零依赖”能做到的真正原因引擎给的已经够用缺的是对 CRUD 生命周期的理解。1.2 先把“零依赖”的边界画清楚很多文章把“零依赖”挂在嘴边但实际写代码时又说“我用了 shared_preferences不就是个本地键值存储吗”。这里我给自己定的规则非常明确工程里 pubspec.yaml 的 dependencies 部分除了 Flutter SDK 自身不新增任何第三方包。像状态管理、数据持久化、路由、网络请求这些统统先不用所有数据读写都靠 Dart 内置集合和 Flutter 内置的通知机制完成。“零依赖”不等于“零框架”要知道 Flutter 本身就是一个框架OpenHarmony 的适配引擎也算在运行环境里。我这里说的零依赖是零外部 pub 包依赖目标有三点让首篇文章只聚焦 CRUD 的核心逻辑不被第三方库的 API 干扰让后续持久化扩展有清晰边界想加数据库时只替换存储实现不推翻 UI 层让项目在任何网络环境下都能顺利构建不会因为某个包版本解析失败而卡住。在这个约束下我们的临时记事本工程结构会非常清爽。核心数据层放在一个独立的目录里不 import 任何 Flutter 组件纯 Dart 类。这样即使以后把这套 CRUD 逻辑单元测试出来也不用启动模拟器或设备。1.3 初始化工程时最容易踩的三个坑说到初始化工程Flutter OpenHarmony 的场景和普通 Flutter 工程有一点差别这部分网上资料很零散我先挑最典型的三个问题说免得你在环境上浪费一整天。第一个是工具链报错。很多人在 VS Code 里创建 Flutter 工程后直接跑默认模板结果终端提示 unable to find suitable visual studio toolc。这个报错和 OpenHarmony 本身无关而是 Flutter 默认探测了 Windows 桌面构建工具链。如果你只打算跑 OpenHarmony 设备或模拟器可以忽略或关闭不需要的桌面构建目标不需要真的安装一整套 Visual Studio C 工具链。解决办法是在创建工程时明确设备类型或者检查 flutter doctor 的输出看是否有哪些开发目标是你根本用不到的。第二个是 Gradle 插件重复应用问题。构建 OpenHarmony 设备包时如果模板里已经通过 settings.gradle 插件方式管理 Flutter Gradle 插件你又手动在 app 模块里用 apply script 的方式应用了一遍就会遇到 you are applying Flutters main Gradle plugin imperatively using the apply script 这种错误。这个问题的本质是同一份插件发生了两条应用路径属于典型的模板迁移旧代码造成的冲突。我的建议很直接新建工程尽量用当前适配版生成的标准目录结构不要从旧项目复制 Gradle 文件。第三个是 OpenHarmony SDK 路径配置。Flutter 的 OpenHarmony 适配分支通常需要在 local.properties 或环境变量里显式指定 OpenHarmony SDK 位置否则 flutter doctor 一直提示找不到。很多人卡在这里是因为 SDK 路径写错了层级应该指到包含 oh-uni-package.json 的那一层不是随便一个父目录。我列一个常见的现象对照表方便排查报错现象最可能原因处理思路unable to find suitable visual studio toolc默认探测桌面构建工具链关掉不需要的桌面目标不装 VS C 也能跑Flutter main Gradle plugin imperatively插件被 apply 路径重复加载保留模板默认方案删除旧式 apply 脚本找不到 OpenHarmony SDKlocal.properties 路径层级错误指到包含 SDK 清单文件的具体目录构建产物不是 .haptarget 未切换到 ohos构建时明确选择 OpenHarmony 目标设备环境问题解决后别急着写界面先回到数据层。临时记事本的内存 CRUD 需要一套干净的数据模型这是后面一切功能的地基。2. 把临时笔记的业务边界用数据模型钉死2.1 TempNote 类字段越少越不容易乱记事本里的数据长什么样最简单也最不容易出错的做法是把一条笔记建模成“标题 内容 时间标记”三个核心字段。别一上来就扩展一堆 tags、color、category临时记事本的核心诉求是快速字段越多内存 CRUD 的边界就越模糊。我用 Dart 定义 TempNote不依赖任何外部库class TempNote { TempNote({ required this.id, required this.title, String? content, }) : content content ?? , createdAt DateTime.now(), updatedAt DateTime.now(); final String id; final String title; final String content; final DateTime createdAt; final DateTime updatedAt; TempNote copyWith({ String? title, String? content, }) { return TempNote( id: id, title: title ?? this.title, content: content ?? this.content, ); } }id 是主键我用字符串而不是 int原因后面讲持久化后路时会说到先用字符串最保险。title 是必填字段content 允许为空。createdAt 和 updatedAt 在构造函数里自动写入当前时间这样创建笔记时少传两个参数UI 层调用起来更简洁。也许你会问Dart 里不是有 final 字段不可变吗为什么还要写 copyWith答案是为了防止内存 CRUD 中最隐蔽的坑。如果 UI 层拿到一个笔记对象后直接改它的 titlenote.title 新标题; // 如果 title 可变那内存 Store 里的对象也会同步变化你根本分不清是哪个环节改了数据。一旦出现 bug排查范围会很大。TempNote 设计成不可变每次更新都生成新对象这样数据流向一目了然只有 Store 类里的 add、update、remove 方法能改变集合内容业务层拿到的都是快照。2.2 id 的生成策略零依赖不等于随便拼字符串id 虽然是字符串但生成策略要讲究。临时记事本不接 UUID 库我采用“时间戳 自增序号”的方式int _idCounter 0; String generateNoteId() { final timestamp DateTime.now().microsecondsSinceEpoch; _idCounter; return ${timestamp}_$_idCounter; }时间戳本身已经足够区分大多数场景加上自增序号是为了防止同一微秒内快速创建多条笔记。这样生成的 id 在单机单进程里几乎不会重复而且不需要引入任何第三方 ID 生成器。2.3 Map 还是 List内存数据结构选择临时记事本的数据量通常不会大几十条到几百条最多了。这种情况下我强烈建议用ListTempNote而不是MapString, TempNote。原因有三个记事本在 UI 上要按时间顺序展示List 天然保留插入顺序内存量小用 Map 做索引带来的性能收益几乎感知不到List 的 removeWhere、where、firstWhere 操作读起来非常直观后续维护成本低。如果你担心按 id 查找太慢完全可以在 MemoryNoteStore 内部临时遍历几千条以内都没压力。真正需要 Map 做索引的时候是数据量大到上万条的场景但那时候你就该考虑持久化数据库了不是依靠内存 CRUD 了。3. 在 Store 层实现内存 CRUD每一行代码都要能解释清楚3.1 先抽象接口再写实现数据模型定好了接着写数据访问层。我不建议直接在 Widget 里操作ListTempNote而是建一个仓储接口把 CRUD 的语义固化下来abstract class INoteStore { ListTempNote getAll(); TempNote? getById(String id); TempNote add(TempNote note); TempNote updateNote({ required String id, required String title, required String content, }); bool remove(String id); }接口列表不长但是含义很清楚。add 返回创建后的 TempNoteupdate 返回更新后的新对象remove 返回布尔值表示是否真正删除了。这样设计的好处是UI 层不需要知道内部是 List 还是 Map也不需要知道数据是内存还是数据库。MemoryNoteStore 实现这个接口并混入 Flutter 的 ChangeNotifierclass MemoryNoteStore extends ChangeNotifier implements INoteStore { final ListTempNote _notes TempNote[]; override ListTempNote getAll() List.unmodifiable(_notes); override TempNote? getById(String id) { for (final note in _notes) { if (note.id id) { return note; } } return null; } override TempNote add(TempNote note) { _notes.add(note); notifyListeners(); return note; } override TempNote updateNote({ required String id, required String title, required String content, }) { final index _notes.indexWhere((note) note.id id); if (index 0) { throw StateError(Note not found: $id); } final updated _notes[index].copyWith(title: title, content: content); _notes[index] updated; notifyListeners(); return updated; } override bool remove(String id) { final oldLength _notes.length; _notes.removeWhere((note) note.id id); final removed _notes.length ! oldLength; if (removed) { notifyListeners(); } return removed; } }这里有一个细节值得展开getAll 返回List.unmodifiable(_notes)。为什么不是直接返回_notes因为直接返回内部列表UI 或其他调用方就能随意removeAt(0)或clear()从而绕过 notifyListeners数据变化了界面却不更新。unmodifiable 只是禁止修改列表结构但列表里的 TempNote 本身已经是不可变对象所以这是双层防线非常稳妥。update 方法我选择了先 copyWith 生成新对象再替换列表对应位置。这个操作把“数据变化”变成显式事件每次更新都是一次提交而不是原地修改。update 过程中如果找不到 id直接抛 StateError比返回 null 更明确。因为调用方既然传了一个 id 进来就说明它心里预期这条笔记存在静默失败反而会把错误留到更远的地方。3.2 修改标题时最容易被忽略的“脏数据”问题内存 CRUD 新手最容易犯的一个错误是在列表项 Widget 里直接拿到 TempNote然后试图修改它。比如你在 ListTile 的 onTap 里写了notes[index].title 改了;如果 TempNote 的字段是可变的话界面上的文字确实可能瞬间变化因为同一个对象被 ListView 持有。但这会带来两个隐患一是这个变化不会触发任何监听器和 Store 之间的状态同步断裂二是如果列表做过排序或过滤你改完对象后列表顺序不会自动调整UI 显示出来可能就是“文本变了但顺序不对”的诡异状态。正确做法是让 UI 只负责“表达意图”把改动动作交给 Store。列表项想要修改标题就调用 store.updateNote接收返回值再重建列表。这能确保每一次数据变化都经过同一套通知机制也方便以后接入单元测试。3.3 删除操作返回值的意义不要盲目调 notifyListenersremove 方法里我特意先记录删除前的长度再 removeWhere最后比较长度。这么做的目的只有一个如果删除的 id 不存在就不触发 notifyListeners。别小看这个细节在 Flutter 里频繁无意义的 notifyListeners 会触发不必要的重建性能问题往往就是在这种看似无害的小地方积累出来的。如果你用的是_notes.remove(note)这种按对象删除的方式还得注意 TempNote 没有重写 equals所以默认比较的是对象引用。你在列表中拿到的对象和 Store 内部存储的对象如果是同一个引用当然没问题但如果你从 getAll 得到的列表里取了一个元素然后试图用另一个“内容相同但引用不同”的对象去 remove就会失败。这就是为什么我推荐用 id 作为删除依据字符串比较比对象引用可靠得多。4. 让 CRUD 结果可感知在零依赖前提下做事件通知与生命周期管理4.1 ChangeNotifier ListenableBuilder 是最轻的绑定方案内存 CRUD 完成以后数据存在于 Store 里但 UI 不会自动知道。零依赖状态下Flutter 内置的 ChangeNotifier 就是最合适的消息机制。它本质上是一个“可以被订阅的通知源”谁关心数据变化谁就注册监听数据变化时逐个通知。在 Widget 侧我不会用 AnimatedBuilder 或旧式的setState手动刷新而是用 Flutter 3.x 里原生的 ListenableBuilderListenableBuilder( listenable: store, builder: (context, child) { final notes store.getAll(); return ListView.builder( itemCount: notes.length, itemBuilder: (context, index) { final note notes[index]; return ListTile( title: Text(note.title), subtitle: Text(note.content), onTap: () { store.updateNote( id: note.id, title: ${note.title}已编辑, content: note.content, ); }, trailing: IconButton( icon: const Icon(Icons.delete_outline), onPressed: () store.remove(note.id), ), ); }, ); }, )ListenableBuilder 会在监听对象触发 notifyListeners 时自动重建 builder。整个过程没有引入 provider没有引入 bloc没有任何继承关系只是一个简单的监听模式。很多人听到“状态管理”就紧张其实你用 ChangeNotifier 加 ListenableBuilder 已经能给中小型项目提供非常可靠的状态同步方案。4.2 生命周期管理Store 的释放和监听器清理memory CRUD 最需要提防的是内存泄漏。ChangeNotifier 最典型的泄漏路径是Store 作为页面级对象被创建但页面销毁时没有释放仍被某个全局单例或父级对象持有着。临时记事本如果只在某一个页面用我建议把 Store 的生命周期控制在页面级用 State 持有并在 dispose 时调用 store.disposeclass _HomePageState extends StateHomePage { late final MemoryNoteStore _store; override void initState() { super.initState(); _store MemoryNoteStore(); } override void dispose() { _store.dispose(); super.dispose(); } }这里有个容易踩的坑ChangeNotifier 的 dispose 不等于清空内部列表。dispose 只是把监听者们移除让对象不再被通知机制持有。如果你想在页面销毁时连数据也清掉必须自己写 clear 方法。对临时记事本来说数据要不要清空取决于产品定义但监听器一定要释放这是底线。4.3 别随便为内存 CRUD 起 isolate热门搜索词里经常出现 Flutter isolate、内存优化这里多说一句。Dart 的 isolate 有独立的内存堆和事件循环理论上你可以把 CRUD 放到后台 isolate 里执行避免大数据量计算卡 UI。但临时记事本的数据量在内存里遍历几千条都是微秒级操作根本不到需要 isolate 的程度。强行 isolate 反而会引入跨 isolate 通信的序列化成本以及 Store 对象不能直接共享的问题完全是得不偿失。真正该考虑内存优化的是另外一种情况你从外界粘贴了大段文本、或者一次导入几百条记录时尽量不要在 build 方法里做耗时操作。比如你更新了一条笔记的 content不要在频发的 setState 里对整段文本做正则处理或 Markdown 解析那才是 UI 卡顿的来源。CRUD 本身的 List 操作在单线程里非常快不必过度设计。5. 给临时数据留一条通往持久化的后路5.1 接口隔离现在省事后面不推倒很多人看到“内存 CRUD”会觉得反正数据重启就没了随便写写得了。但正因为是临时反而更要留后路。我把数据访问抽象成 INoteStore 接口就是为了将来某一天你决定把笔记存到本地文件、SQLite、或者 OpenHarmony 的 RelationalStore 时只需要新写一个 PersistNoteStore 实现同一个接口UI 层一行都不用改。接口隔离的价值在 Flutter 里特别明显。Flutter 的 UI 代码天然倾向于从宽泛的数据源读取数据如果一开始就直接在 Widget 里操作ListTempNote后续换数据库时所有用到.add、.remove的地方都要改。现在所有修改都收敛到 store.add、store.remove、store.updateNote替换成本很低。5.2 id 和时间的持久化价值坚持用字符串 id、保存 createdAt 和 updatedAt都是给持久化铺路。将来迁移到本地数据库时字符串 id 可以直接当主键DateTime 类型可以转成整数时间戳存储不会遇到类型转换的尴尬。尤其是 id 里带有时间戳信息你甚至能通过 id 排序恢复创建顺序即使漏了 createdAt 字段也不至于完全乱套。5.3 什么时候该打破“零依赖”我前面一直强调零依赖但“零依赖”不是永远正确。当临时记事本要升级成真正的本地记事本时需要长期保存数据徒手写文件同步又容易出错这时候引入 sqflite 或其 OpenHarmony 适配、或者用 drift 自动生成数据库代码都是合理的。到那时你会发现前面建的 INoteStore 抽象正好用得上数据库实现类只需要把 add、update、remove 映射成 SQL 语句接口签名不变UI 层永远只认那 4 个方法。所以零依赖不是目的控制复杂度才是目的。第一篇文章先用内在数据把 CRUD 逻辑、通知机制、生命周期管理全部跑通后续再按需引入存储依赖每一步都能稳扎稳打。6. 组装一个最简页面让 CRUD 在 OpenHarmony 设备上真正跑起来6.1 一个能跑通的最小例子前面讲了这么多最后我们组装一个最小可运行页面。这个页面不做复杂设计就是鸿蒙风格的浅色底、卡片列表、底部新增按钮功能上包含“列出所有笔记”和“删除笔记”两个动作再加上点击列表项就追加已编辑标记的更新动作。整个页面加上 Store 总共不到 150 行。import package:flutter/material.dart; void main() runApp(const NoteApp()); class NoteApp extends StatelessWidget { const NoteApp({super.key}); override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, home: HomePage(store: MemoryNoteStore()), ); } } class HomePage extends StatefulWidget { const HomePage({super.key, required this.store}); final MemoryNoteStore store; override StateHomePage createState() _HomePageState(); } class _HomePageState extends StateHomePage { MemoryNoteStore get _store widget.store; void _addNote() { _store.add( TempNote( id: generateNoteId(), title: 临时笔记 ${_store.getAll().length 1}, content: 这是内存里的临时内容, ), ); } override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(临时记事本)), body: ListenableBuilder( listenable: _store, builder: (context, child) { final notes _store.getAll(); if (notes.isEmpty) { return const Center(child: Text(还没有笔记点右下角新建)); } return ListView.builder( itemCount: notes.length, itemBuilder: (context, index) { final note notes[index]; return Card( child: ListTile( title: Text(note.title), subtitle: Text(note.content), onTap: () { _store.updateNote( id: note.id, title: ${note.title}已编辑, content: note.content, ); }, trailing: IconButton( icon: const Icon(Icons.delete_outline), onPressed: () _store.remove(note.id), ), ), ); }, ); }, ), floatingActionButton: FloatingActionButton( onPressed: _addNote, child: const Icon(Icons.add), ), ); } }这段代码把之前讲的每一点都用上了Store 实现 INoteStore 并混入 ChangeNotifier、getAll 返回不可变列表、update 走 copyWith、remove 按 id 删除。你在 OpenHarmony 设备或模拟器上运行后点加号新增笔记点列表项更新标题点删除按钮移除笔记整个过程完全不依赖任何第三方包。6.2 验证内存 CRUD 是否健康的三个检查点运行起来不算完我再分享三个实际验证时用的小方法帮你判断这块内存数据层是否真的健康第一打开 Flutter DevTools 的内存工具页疯狂新增和删除笔记几十次观察内存是不是能回到差不多的水平。如果只增不删之后内存曲线一直往上走且没有回落说明可能有对象被长时间持有多半是 Store 泄漏到了某个全局位置。第二在 update 方法里临时加一个日志打印更新前后的 id 和 title确认每次更新都生成新对象。如果日志里出现两次相同 id 且 title 相同的情况就要检查是不是 copyWith 里漏传了某个字段。第三做一次热重启而不是热重载因为内存 Store 的数据在热重载后可能还在但冷启动必然清空。临时记事本如果冷启动后还有旧笔记说明数据被写到了某处持久化存储里那你已经不是在跑单纯的内存 CRUD 了。前三篇的路线大概就有了第一篇把内存 CRUD 做扎实第二篇可以补上页面润色和鸿蒙风格的组件细节第三篇再决定怎么推进持久化。至少现在你已经有一块数据层可以放心往上继续盖东西了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进