最近在整理 NoteStar 这个项目时,我把整套“便笺服务类”的设计思路和落地方案重新梳理了一遍。项目的定位非常明确:用 Flutter 在 HarmonyOS 6.0 上实现一个本地优先的便笺工具,能力范围涵盖笔记的创建、编辑、删除、归档、置顶、搜索和本地持久化。标题里那个“便单服务类”,在工程里我落地成了一套可复用的便笺服务层,页面不直接操作数据库,所有数据能力都通过 Service 接口暴露。这篇博文会把这套设计的选型、建模、实现、适配和踩坑经历完整记录下来,给正在做 Flutter 本地存储或 HarmonyOS 适配的人提供一个可直接参考的样本。
在往下写之前,先说明一个容易混淆的点:标题里的“便单服务类”不是“账单服务”,而是“便笺管理服务”的意思。这类应用的功能看起来很少,但真正动手做的时候你会发现,所有复杂度都藏在数据完整性里。用户记一条笔记只需要几秒钟,但这条笔记要经历创建、编辑、归档、恢复、搜索、备份这一整条链路,任何一环出了问题,用户都不会给你第二次机会。这也是我为什么要专门围绕“持久化存储和管理”来写这篇实践总结。
1. 项目概述:NoteStar 要解决什么问题
严格来讲,NoteStar 不是一个从零想象的“创新项目”,它更像是对经典场景的再一次认真表达:在手机上随手记录、管理、归档便笺。市面上的笔记类应用已经非常多,但真正能满足“离线优先 + 跨端一致体验 + 存储可控”这三个条件的,其实没有多少。很多产品要么把同步逻辑做得太重,要么把数据模型设计得过度抽象,根本撑不起一个“随手记”的轻量场景。NoteStar 想做的就是把这个场景的存储底座做扎实。
项目功能边界一开始就划得很清楚:
- 快速创建便笺,支持标题、正文、标签;
- 对已有便笺进行编辑、删除、置顶、归档和取消归档;
- 通过关键词搜索标题、正文和标签;
- 所有数据本地持久化,App 重启后数据不丢;
- 提供本地备份与恢复能力,防止误删导致的数据不可逆。
这条功能清单看起来非常简单,但落到技术上,每一步都要做取舍。比如主键到底用自增 ID 还是 UUID?时间字段存字符串还是时间戳?标签是单独建表还是冗余存储?归档和置顶要不要应用在同一条排序规则里?这些细节决定了应用的长期可维护性,也决定了用户数据的可靠程度。
我在动手写代码前做的第一件事,不是创建 Flutter 工程,而是把上面这些问题先逼问一遍。这个习惯来源于一次教训:早年间我做小工具应用,习惯边写边想数据结构,结果功能上线三个月后想加一个“归档”能力,发现数据库表结构完全撑不住,推倒重来了一次。这次做 NoteStar,我宁可前期慢一点,也要先把模型和服务边界定义清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体设计与技术选型:为什么把服务层单独抽出来
2.1 从页面直连数据库到 Service 层
很多 Flutter 新人写便笺应用,最常见的写法是在页面 State 里直接调用数据库方法,比如在 _onSave 回调里执行 db.insert()。这个写法在功能少于 10 个的时候非常爽,代码看起来直截了当。但一旦功能超过 20 个,你会发现自己陷入重复代码的泥潭:每个页面都要处理数据库初始化、事务、异常回滚;数据库模型一旦改动,所有页面同步修改;想在页面上加一个统一的“保存成功”提示,要到处复制粘贴。
NoteStar 从第一天起就切成了三层结构:
- UI 层:只负责展示和交互,页面里看不到任何 SQL 语句;
- Service 层:也就是便笺服务类,对外暴露
createNote、updateNote、deleteNote、searchNotes、archiveNote这类语义化方法; - Store 层:实现真正的持久化操作,包括数据库建表、CRUD、文件备份等。
这么设计的理由很直接。便笺应用的核心资产是数据,数据访问逻辑需要高度稳定;而 UI 恰恰是改动最频繁的部分。把这两者切开,UI 改版不影响存储,存储迁移不影响 UI,后续哪怕要把 SQLite 换成本地 JSON 文件,也只需要改 Store 层的内部实现,Service 的接口签名完全不用动。
有读者可能会问,功能这么少,有必要做三层吗?我的回答是:有必要。因为持久化存储和 UI 是完全不同的关注点,存储关注数据一致性,UI 关注交互反馈。如果把它们耦合在一起,你每次改 UI 都要担心会不会破坏数据库逻辑,每次调数据库又不敢动页面。这种隐性成本在项目前期几乎看不到,但到后期债务积累起来,速度会非常快。
2.2 数据模型设计:别把便笺设计成一张“死表”
便笺模型在设计时要同时兼顾两类诉求:列表页展示要快,编辑页读写要准。我最终定义的 notes 表结构如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | TEXT | 主键,用 UUID 字符串,避免自增 ID 在数据合并时冲突 |
| title | TEXT | 便笺标题 |
| content | TEXT | 正文内容,支持纯文本或轻量 HTML |
| tags | TEXT | 标签列表,用 JSON 数组字符串保存 |
| is_pinned | INTEGER | 是否置顶,0/1 |
| is_archived | INTEGER | 是否归档,0/1 |
| created_at | INTEGER | 创建时间,Unix 毫秒时间戳 |
| updated_at | INTEGER | 最后修改时间 |
| reminder_at | INTEGER | 提醒时间,可为空 |
很多人会把主键直接定义成 INTEGER AUTOINCREMENT,但我在这里特意用了 UUID。原因是,如果后期考虑多设备或数据合并,自增 ID 几乎必然冲突。在 NoteStar 这种本地优先的架构里,单机 UUID 的开销可以忽略不计,但换来的是以后接云同步时不用改表结构。
时间字段我统一用毫秒时间戳,而不是 ISO 8601 字符串。这个决策基于一个很简单的理由:存储层永远存机器友好型数据,人看的格式化逻辑放在 UI 层处理。时间戳在排序、范围查询、时间计算上比字符串快得多,也不会因为时区不同产生排序错乱。
标签字段用 JSON 数组字符串保存,而不是单独建一张标签表,也是基于场景做的取舍。NoteStar 的单条笔记标签数量极少,单独建表意味着每次查询都要 JOIN,带来的收益远小于复杂度。如果你的应用要做“标签云”或“按标签批量筛选”这种高频操作,再考虑拆表也不迟。
2.3 状态管理:我选择了最朴素的方式
状态管理这块,Flutter 生态里 provider、riverpod、bloc 各有拥趸。NoteStar 没有上很重的框架,而是用 Provider + ChangeNotifier 的组合。理由很简单:我希望项目的主线尽量集中在“存储”这个主题上,而不是让读者被状态管理的概念淹没。ChangeNotifier 天然适合便笺这种以列表为枢纽的场景:一个 NoteListViewModel 持有 List<Note>,所有变更操作通过 Service 层完成后再调用 notifyListeners,列表页就能自动刷新。
如果你的项目已经用了 riverpod 或 bloc,也没有关系。这篇博文里的 Service 层和 Store 层设计与具体状态管理框架是解耦的,你完全可以把同样的接口平移到自己的项目里。我唯一要强调的原则是:状态管理器只负责 UI 状态,不负责业务数据。数据库里的数据永远以存储层为准,ViewModel 只是它的一个投影。
3. 持久化存储:选型、适配与迁移
3.1 存储方案对比:为什么选择 SQLite 而不是 Hive
做 Flutter 本地存储,绕不开几个选项:SharedPreferences、Hive、sqflite。
SharedPreferences 适合存配置项,比如主题颜色、是否开启提醒。它本质上是 key-value,不适合存结构化列表。Hive 是纯 Dart 实现的 KV 存储,速度快,API 简单,但如果要做按内容模糊搜索、按时间范围排序、事务回滚,Hive 需要你手工维护索引,复杂度反而更高。SQLite 是关系型存储,对结构化数据、复杂查询、事务的支持非常成熟,而且 sqflite 是 Flutter 生态里历史最久的插件之一,稳定性和社区资料都比较充分。
NoteStar 的最终方案是:主数据用 SQLite 存,轻量配置用 SharedPreferences 存。SQLite 负责 notes 表的 CRUD 和模糊搜索,SharedPreferences 负责记录“上次打开时间”“主题模式”这类小开关。这是非常传统但非常稳的搭配。
如果你要处理超大体积的富文本便笺,可以考虑在 sqflite 之外再叠一层文件存储,正文放文件、元数据放表里,这算是 NoteStar 留出的一个扩展点。我之所以不做,是因为当前场景下正文都在 10KB 以内,直接在 SQLite 里存字符串完全没有问题。
3.2 HarmonyOS 6.0 上的存储适配:不碰平台代码的桥接方案
在 HarmonyOS 上跑 Flutter,核心问题永远是插件兼容性。sqflite 这类主流插件在 Android/iOS 上开箱即用,但在 HarmonyOS 环境上,你需要确认它是否走通了 ohos 目录的桥接。我的做法是:在项目里增加一个存储桥接接口,定义成抽象类,具体实现再根据平台选择 sqflite 或 HarmonyOS 原生的关系型数据库接口。
dart复制abstract class NoteStore {
Future<void> init();
Future<List<Note>> getAllNotes();
Future<void> insertNote(Note note);
Future<void> updateNote(Note note);
Future<void> deleteNote(String id);
Future<List<Note>> searchNotes(String keyword);
}
实现类在 Android/iOS 上用 sqflite,在 HarmonyOS 上如果插件栈还不够成熟,就退回本地 JSON 文件存储,或者通过 MethodChannel 调用 ArkTS 侧的关系型数据库接口。这个桥接层带来的好处是:你永远不需要在 UI 层判断 Platform.isAndroid,存储的切换被隔离在 Store 层内部。
需要特别注意,HarmonyOS 上获取应用私有目录的方式与 Android 略有差异。强烈建议不要硬编码路径,而是通过 path_provider 插件或原生桥接层动态获取。我在项目一开始就踩过路径硬编码的坑,导致升级版本后应用找不到老数据,后来统一改为动态获取目录,问题才彻底解决。
3.3 数据库升级与迁移策略
便笺这种应用,数据模型一定会演进。比如第一版没有 reminder_at 字段,第二版要加。这时候如果你直接删除重装,用户的笔记就全丢了。我建议在 Store 实现里带上数据库版本号和 onUpgrade 回调。
设计思路如下:
- 首次初始化时创建 notes 表,版本号设为 1;
- 后续模型变更时,版本号加 1,在
onUpgrade里分段写ALTER TABLE语句; - 提供建表语句的集中管理,方便测试和重建。
迁移算法并不复杂,但必须在发布前充分测试“老版本数据升级到新版本”的完整流程。我专门写了一个迁移测试脚本,用不同版本的历史数据库文件去触发升级,验证旧数据是否完整保留。这个习惯帮我提前挡掉了三次数据丢失事故。
4. 实操:把 NoteStar 的持久化链路完整跑通
4.1 工程初始化与依赖配置
先用 Android Studio 创建一个 Flutter 工程。如果你要同时支持 HarmonyOS,请在工程根目录确认 ohos 目录存在,并且把 Flutter 版本与 HarmonyOS 适配分支的版本对齐。这里有个非常典型的坑:Flutter 版本不一致会导致依赖下载失败,尤其是 flutter-plugin-loader 的版本冲突。遇到这种情况,去 pubspec.lock 里看 metadata 版本,把 flutter 和 dart 版本对齐后重新 pub get,通常能解决。
依赖配置如下:
yaml复制dependencies:
flutter:
sdk: flutter
provider: ^6.1.1
sqflite: ^2.3.2
path: ^1.9.0
path_provider: ^2.1.2
shared_preferences: ^2.2.2
uuid: ^4.3.3
intl: ^0.19.0
sqflite 和 path_provider 是存储链路的核心,uuid 用来生成笔记主键,intl 用来做时间格式化。字段我这里看起来像是从旧项目复制过来的版本号,实际使用时建议以 pub.dev 上当前可用的最新稳定版本为准。
4.2 编写 Note 模型与 JSON 序列化
Note 模型我建议做成不可变类,所有字段 final,修改操作返回新实例。这样可以避免多个页面同时引用同一个对象时出现“幽灵修改”。模型里提供 toMap 和 fromMap 方法,用于 SQLite 行数据的双向转换。
dart复制class Note {
final String id;
final String title;
final String content;
final List<String> tags;
final bool isPinned;
final bool isArchived;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? reminderAt;
Note({
required this.id,
required this.title,
this.content = '',
this.tags = const [],
this.isPinned = false,
this.isArchived = false,
required this.createdAt,
required this.updatedAt,
this.reminderAt,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'title': title,
'content': content,
'tags': jsonEncode(tags),
'is_pinned': isPinned ? 1 : 0,
'is_archived': isArchived ? 1 : 0,
'created_at': createdAt.millisecondsSinceEpoch,
'updated_at': updatedAt.millisecondsSinceEpoch,
'reminder_at': reminderAt?.millisecondsSinceEpoch,
};
}
factory Note.fromMap(Map<String, dynamic> map) {
return Note(
id: map['id'] as String,
title: map['title'] as String? ?? '',
content: map['content'] as String? ?? '',
tags: (jsonDecode(map['tags'] as String? ?? '[]') as List).cast<String>(),
isPinned: (map['is_pinned'] as int) == 1,
isArchived: (map['is_archived'] as int) == 1,
createdAt: DateTime.fromMillisecondsSinceEpoch(map['created_at'] as int),
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int),
reminderAt: map['reminder_at'] == null
? null
: DateTime.fromMillisecondsSinceEpoch(map['reminder_at'] as int),
);
}
}
这里有个小坑:从 JSON 解析 List<String> 时,直接 map<String> 的话泛型信息会丢失,一定要先 cast。我早期写错过一次,运行期才爆出类型错误,后来养成了在模型层就做完整类型转换的习惯。
4.3 数据库初始化与建表
数据库初始化我封装在一个 DatabaseHelper 单例里,核心逻辑如下:
dart复制class DatabaseHelper {
static final DatabaseHelper _instance = DatabaseHelper._();
DatabaseHelper._();
static Database? _db;
Future<Database> get database async {
_db ??= await _initDb();
return _db!;
}
Future<Database> _initDb() async {
final dir = await getApplicationDocumentsDirectory();
final path = p.join(dir.path, 'notestar.db');
return openDatabase(
path,
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE notes(
id TEXT PRIMARY KEY,
title TEXT,
content TEXT,
tags TEXT,
is_pinned INTEGER,
is_archived INTEGER,
created_at INTEGER,
updated_at INTEGER,
reminder_at INTEGER
)
''');
await db.execute(
'CREATE INDEX idx_notes_updated ON notes(updated_at DESC)',
);
},
);
}
}
数据库路径必须用 getApplicationDocumentsDirectory() 动态获取,不要自己拼一个看起来合理的路径。在 HarmonyOS 上,应用沙箱路径如果写死,后续系统权限收紧或版本升级都会出问题。
索引这一步容易被忽略。对 updated_at 建索引,能让列表按更新时间倒序的查询从全表扫描变成索引扫描,数据量到几千条以后差距非常明显。搜索用的 title/content 字段,我建议后续再接 FTS4/FTS5,普通 LIKE 查询在小数据量下没问题,数据量上来后性能下降会很明显。
4.4 Service 层封装:一个可以复用的 NoteService
NoteService 是面对 UI 的入口,也是这个“服务类”的关键。它内部持有 NoteStore 接口,对外暴露的都是语义化方法。这样 UI 层既不需要知道数据库,也不需要关心数据是从 SQLite 还是 JSON 文件里读出来的。
dart复制class NoteService {
final NoteStore _store;
NoteService(this._store);
Future<List<Note>> fetchAll() {
return _store.getAllNotes();
}
Future<void> createNote(
String title,
String content, {
List<String> tags = const [],
}) {
final note = Note(
id: const Uuid().v4(),
title: title,
content: content,
tags: tags,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
return _store.insertNote(note);
}
Future<void> updateNote(
String id, {
String? title,
String? content,
List<String>? tags,
}) async {
final old = await _store.getNoteById(id);
if (old == null) return;
final updated = old.copyWith(
title: title ?? old.title,
content: content ?? old.content,
tags: tags ?? old.tags,
updatedAt: DateTime.now(),
);
await _store.updateNote(updated);
}
Future<void> archiveNote(String id, bool value) async {
final old = await _store.getNoteById(id);
if (old == null) return;
await _store.updateNote(old.copyWith(isArchived: value, updatedAt: DateTime.now()));
}
Future<void> deleteNote(String id) {
return _store.deleteNote(id);
}
Future<List<Note>> search(String keyword) {
return _store.searchNotes(keyword);
}
}
如果你把上面的代码读一遍,会发现一个很明显的特征:所有业务动作都落到了“先取旧数据、创建新实例、再写回存储”这个模式上。这种不可变模型有几个好处:一是不容易出现引用共享导致的状态污染;二是 copyWith 让局部更新变得语义清晰;三是测试时可以非常方便地构造不同状态的对象。
4.5 搜索与列表整合:一个 ViewModel 复用两套查询
NoteStar 的搜索逻辑实现为“标题 + 内容 + 标签”的模糊匹配:
dart复制Future<List<Note>> searchNotes(String keyword) async {
final db = await database;
final like = '%$keyword%';
final result = await db.query(
'notes',
where: 'title LIKE ? OR content LIKE ? OR tags LIKE ?',
whereArgs: [like, like, like],
orderBy: 'is_pinned DESC, updated_at DESC',
);
return result.map(Note.fromMap).toList();
}
搜索页和列表页共用同一个 NoteListViewModel,只是给它传入不同的查询条件。这个设计省掉了两套列表逻辑,也让搜索结果排序、置顶规则与列表完全一致。你在切换到搜索模式时,只需要把 ViewModel 的数据源从“全部笔记”替换成“搜索结果”即可。
5. 常见问题与排查技巧实录
5.1 问题速查表
下面这些是我在开发 NoteStar 时真实遇到且走过弯路的问题,整理成表供大家对照:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| Flutter 工程在 HarmonyOS 上打不开 | 缺少 ohos 适配目录或插件版本不匹配 | 确认 Flutter SDK 与 HarmonyOS 适配分支版本一致;检查 pubspec 中插件是否存在 ohos 实现 |
| sqflite 在真机上找不到数据库文件 | 数据库路径硬编码 | 改用 getApplicationDocumentsDirectory() 动态获取路径 |
| 升级版本后旧数据消失 | 未配置 onUpgrade 或误用了删除重建 |
增加数据库版本号与迁移脚本,测试老库升级流程 |
| 列表刷新后新便笺不显示 | ViewModel 未在保存后调用 notifyListeners |
在 Service 返回结果后统一回调,或使用 ValueNotifier 自动通知 |
| 搜索结果顺序混乱 | 未指定 orderBy |
统一用 'is_pinned DESC, updated_at DESC' |
| pub get 依赖下载失败 | Flutter 或依赖版本不一致 | 对齐锁文件版本,清理缓存后重试 |
| 热重载后列表不更新 | ViewModel 实例在热重载后未重建 | 检查状态管理初始化位置,必要时冷启动验证 |
| 底部弹窗内有 TextField 时键盘遮挡输入 | 未处理 viewInsets | showModalBottomSheet 设置 isScrollControlled: true,并动态调整内边距 |
5.2 几个值得展开的调试细节
第一,热重载后列表页不更新。这个问题在很多 Flutter 项目里都出现过,NoteStar 也遇到过。大多数情况下不是代码逻辑错了,而是 ViewModel 实例在热重载后没有被重建,页面持有的还是旧的数据快照。解决方案是给 ViewModel 增加 debug 标记,或者在热重载后手动触发一次冷启动来验证数据链路。不用为这个问题过度设计,但要理解热重载和完整冷启动之间的区别。
第二,底部弹窗里有 TextField,键盘弹起后布局被挤压变形。这是我在做新建便笺的交互层时碰到的典型 UI 问题。解决办法是给底部弹窗组件设置 viewInsets 监听,动态调整内边距;如果用了 showModalBottomSheet,必须把 isScrollControlled 设为 true,否则键盘会出现遮挡。这个教训让我意识到,即便主题是存储,UI 细节依然是项目能否落地的重要部分。
第三,富文本渲染。NoteStar 的正文一开始设计成纯文本,后来用户反馈希望粘贴内容时能保留基本格式,于是引入了轻量 HTML 渲染方案。在 Flutter 里渲染富文本,常见的是 flutter_html 包,但要注意它和部分版本 sqflite 同时使用时,可能因为原生依赖冲突导致编译失败。我的处理是:存储层存 HTML 字符串,展示层在需要时才渲染,避免让富文本渲染组件进入存储链路。
5.3 一个关于插件冲突和构建失败的真实案例
开发到中期,我遇到过 flutter error resolving plugin 的报错,具体内容指向 plugin-loader 的版本问题。排查过程是这样的:先看 pubspec.lock 里 flutter 和 dart 的版本号,再用 flutter clean 清掉所有缓存,最后把与插件强相关的最新版本重新拉取,问题才解决。这个案例提醒我,在多平台 Flutter 项目里,插件版本管理必须非常谨慎,不要用 flutter pub upgrade 无脑升级,尤其是在 HarmonyOS 适配还不完善的阶段。
另外还有一个构建层面的问题:在某些 Windows 环境下,Flutter 构建会报出 CMake 相关的错误,比如 generator Visual Studio 版本不一致。这类问题通常不是项目代码引起的,而是本地 NDK、CMake 或工具链版本错位。我的做法是统一用同一套命令行工具链版本,并且每次切换 Flutter 版本后都执行一次干净构建。
6. 项目沉淀与后续扩展方向
6.1 三个让我收益最大的设计决策
第一个决策是存储层抽象。NoteStar 能做到在 UI 层完全无痛切换存储实现,就是因为一开始就把接口边界划清楚了。即使你只是自己写一个小应用,也建议至少把数据库操作包在一个 Repository 类里,而不是散落在页面里。这个习惯在项目越做越大的时候价值越明显。
第二个决策是模型面向“未来可能的迁移”。主键用 UUID、时间用毫秒时间戳、枚举状态用整数 0/1,这些看似微小的选择,在之后做备份恢复、数据合并、多端同步评估时节省了大量时间。你永远不知道产品三个月后会变成什么样,基础模型设计得多费一点心,后面就能少受很多苦。
第三个决策是不要低估平台差异。HarmonyOS 和 Android 在目录权限、插件兼容性上的差异,真的会在不设防的时候给你一击。跨平台开发不是把 UI 跑起来就完事了,存储、文件、通知、剪贴板这些系统能力都需要做平台抽象。
6.2 后续扩展方向建议
如果你想把 NoteStar 继续往前推,我建议按以下优先级排期:
- 引入 FTS5 做全文索引,解决大规模便笺搜索性能瓶颈;
- 增加本地文件的加密备份与恢复机制,通过数据库导出/导入保护用户数据;
- 设计一个可插拔的 SyncProvider 接口,为未来接入云同步预留扩展点;
- 针对 HarmonyOS 的原子化服务能力做一次服务卡片尝试,让便笺可以在桌面上直接预览最近记录。
我个人在实际操作中最深的体会是:便笺类应用的代码量不大,但极其考验对“数据完整性”的设计敏感度。用户可能不在意你的架构有多漂亮,但一定在意删除重建之后笔记还在不在、搜索能不能搜到半年前写的一句话。NoteStar 这套方案的大部分价值,其实就落在“让这些底层能力稳定到可以被忽略”这件事上。希望这篇记录能让你在自己的项目里少折腾一些弯路。
