做轻量级记事本应用,最容易被忽略的往往不是富文本、手写识别或者云同步,而是“文件夹详情”这一类看起来不起眼的页面。很多团队把大量精力砸在编辑器体验上,结果文件夹列表一进去就卡顿、数据对不上、删除笔记后数量统计不刷新,用户没到写笔记那一步就先把 App 卸了。这次我用 Flutter 在 OpenHarmony 生态里做了一个轻量级开源记事本 App,核心场景就是文件夹维度下的笔记聚合展示与操作。这篇文章我会把“文件夹详情”这个功能从需求拆解、数据设计、页面实现到性能优化完整展开,适合正在做 Flutter 跨端应用、或者想把手头 Flutter 工程适配到 OpenHarmony 设备上的开发者参考。
先交代一下项目背景。这套应用不追求大而全,整体定位是“打开快、记完就走”的本地优先记事本,支持多级文件夹、Markdown 简单渲染和全文检索。文件名就叫“开源记事本”,代码全部开放,任何开发者拿去可以二次开发。文件夹详情是整个信息架构里承上启下的部分:上一级是文件夹列表,下一级是单篇笔记编辑器,详情页既要承载聚合信息,也要承担笔记管理入口的角色。所以它是不是好看没有想象中重要,关键是数据链路要短、交互反馈要即时、边界情况要考虑完整。
1. 项目全景与需求拆解
1.1 轻量记事本的核心边界
做记事本第一件事不是画界面,而是定边界。常见误区是功能越加越多,最后变成一个四不像的文档系统。我定的边界很明确:
- 本地存储为主,不做复杂账号体系;
- 笔记数据格式是 Markdown 文本,不锁定私有二进制;
- 文件夹最多支持三级嵌套;
- 详情页只负责查看、搜索、排序、移动、删除等管理操作,不做内容编辑。
这个边界的好处在于,后续不管是迁移到新的设备,还是把数据暴露给用户自行备份,都会非常方便。Markdown 纯文本加文件索引,比什么都用数据库存 blob 要透明得多。在 OpenHarmony 设备上,权限管理和文件访问策略比 Android 严格一些,本地优先加明文存储反而更省事。
1.2 文件夹详情页的准确定位
文件夹详情页不是简单把笔记列表堆出来。它需要承担几个职责:
- 展示当前文件夹的元信息,包括名称、路径、笔记总数、子文件夹贡献的笔记数;
- 按用户选择的排序维度展示笔记条目,默认按更新时间倒序;
- 支持进入笔记的“快速查看模式”和编辑模式;
- 提供批量操作入口,比如批量移动、批量删除;
- 在列表层面完成关键词搜索和结果高亮。
我一开始把搜索框放在顶部,但后来发现文件夹详情里的搜索范围就应该限定在当前文件夹及其子文件夹。这个“范围感”很重要,否则详情页和全局搜索页就重叠了。最终实现是搜索时自动带上folderId作为过滤条件。
1.3 技术选型:Flutter 跨端与 OpenHarmony 适配
选 Flutter 的原因很简单:团队已经有 Dart 的开发积累,UI 组件生态成熟,一套代码可以覆盖手机、平板和 OpenHarmony 设备。OpenHarmony 上跑 Flutter 依赖的是开放的适配层,需要把 Flutter 引擎和 OpenHarmony 的框架能力打通。这个过程会出现不少原生桥接问题,后面我会专门讲踩坑。
比选型更重要的是一条原则:业务层绝不直接依赖平台通道。所有平台相关能力,比如读取系统主题色、获取设备存储路径、震动反馈,都收口到一个PlatformService抽象接口里。在 Android 和 OpenHarmony 上分别提供实现,这样上层业务只需要关心接口,不需要关心当前跑在哪个平台。
2. 工程落地:环境配置与项目结构
2.1 适配 OpenHarmony 的 Flutter 开发环境
如果你之前在 Android 上跑过 Flutter,转到 OpenHarmony 时主要多干三件事:
- 安装 OpenHarmony 的 SDK 和 IDE,创建或导入一个标准的 OpenHarmony 工程;
- 下载适配后的 Flutter SDK,它提供
ohos目录和相关构建脚本; - 把 Flutter 模块作为依赖合入 OpenHarmony 工程,由 IDE 统一打包安装。
实操中要注意 Flutter SDK 的版本一致性。如果团队其他人还在用老的适配版本,而你升级了 Flutter,很可能会出现引擎和 OHOS 壳工程之间方法签名对不上的问题。我的建议是锁一个经过验证的版本组合,把版本号写进pubspec.yaml的注释里,并在 CI 流程里固定 Flutter 的下载地址。
2.2 模块化目录结构
工程按功能模块而不是按页面切分,目录结构大致如下:
lib/ main.dart app/ routes/ theme/ di/ data/ local/ repository/ model/ features/ folder/ folder_list/ folder_detail/ note/ note_editor/ note_viewer/ search/ settings/ core/ platform/ utils/ widgets/features/folder/folder_detail下面再细分为widgets、controller、model。这样做的好处是,文件夹详情相关的状态逻辑和 UI 组件可以独立替换,不会影响全局路由表。如果你做的是小项目,可能用不到这么细的拆分,但我建议至少把data和features分开,不然业务一旦多起来,page目录会变成无底洞。
2.3 依赖管理思路
项目只引入少量核心依赖:path(路径拼接)、sqflite或sqlite3(本地数据库)、provider(状态管理)、intl(时间格式化)、dart:io相关。图标和主题全部走 Flutter 内置能力,不引入重量级 UI 库。
有段时间我为了省事引入了某 Material 扩展库,结果它底层依赖了 Flutter 高版本 API,在 OpenHarmony 适配链上编译不过去。后来还是退回纯手写组件。这算是一个提醒:跨端项目能少引入依赖就少引入,尤其涉及原生插件,一定要先确认对方是否承诺适配 OpenHarmony,否则最后难受的是自己。
3. 数据层:文件夹与笔记的建模
3.1 实体关系如何设计
文件夹和笔记是典型的一对多。但如果只是简单的一对多,做详情页时会发现一个问题:统计子文件夹数量非常麻烦。比如你有一个“工作”文件夹,里面套了“项目A”“项目B”,那么“工作”详情页的笔记总数应该是当前文件夹直属笔记加上所有子文件夹的笔记总和。
我给出的方案是在notes表里冗余一个folder_path字段,存储从根节点到当前文件夹的完整路径字符串。比如工作/项目A/需求,这样子文件夹统计只需要folder_path前缀匹配,不需要递归查询。冗余字段换来的是查询效率和代码简单度,代价是移动文件夹时要批量更新路径前缀,我在 Repository 层用一个事务完成,不会出现中间态。
3.2 表结构与索引
文件夹表字段设计如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| folder_id | TEXT | 主键,使用 UUID |
| parent_id | TEXT | 父文件夹 ID,根节点为空 |
| name | TEXT | 文件夹名称 |
| sort_order | INTEGER | 同级排序权重 |
| created_at | INTEGER | 创建时间戳 |
| updated_at | INTEGER | 更新时间戳 |
笔记表关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| note_id | TEXT | 主键 |
| folder_id | TEXT | 直属文件夹外键 |
| title | TEXT | 标题(冗余,避免全文解析) |
| content | TEXT | Markdown 正文 |
| is_deleted | INTEGER | 软删除标记 |
| pinned | INTEGER | 是否置顶 |
| updated_at | INTEGER | 更新时间,排序主力字段 |
| folder_path | TEXT | 冗余路径,用于子范围查询 |
索引方面,folder_id + is_deleted建联合索引,updated_at单独建倒序索引。文件夹详情页默认排序是sort by pinned desc, updated_at desc,这个索引足够覆盖大部分查询场景。搜索的时候走title like ?加is_deleted=0,数据量在几万条以内完全没问题。
3.3 Repository 与数据流
Repository 层对外暴露的方法是单向数据流。页面状态永远是由数据库查询结果驱动,任何修改操作都先写库,再通过监听器通知 UI 刷新,不做内存缓存里的“花活”更新。
核心接口长这样:
abstract class FolderRepository { Stream<FolderDetailData> watchFolderDetail(String folderId); Future<List<NoteEntity>> getNotesByFolder( String folderId, { required NoteQuery query, }); Future<void> moveNotesToFolder( List<String> noteIds, String targetFolderId, ); Future<void> softDeleteNotes(List<String> noteIds); }watchFolderDetail返回的是一个Stream,底层由数据库的IncrementalQuery机制驱动。为什么不用一次性查询加手动刷新?因为详情页还需要感知子文件夹的增删。比如你在文件夹“工作”详情页,这时另一个页面在“工作/项目A”下新建了一条笔记,详情页的总数要自动 +1。用 Stream 监听这种变化是最省心的,不用到处发事件总线。
4. 文件夹详情页的核心实现
4.1 页面骨架与可复用组件
文件夹详情页我用了一个常见的三段式骨架:
- 顶部:返回按钮、文件夹标题、笔记统计数、搜索入口、更多菜单;
- 中间:排序条和批量操作栏;
- 下部:笔记列表。
这里有一个值得注意的设计:统计数字是独立的组件,叫FolderCounterBadge,它不依赖页面传入的静态数字,而是从FolderDetailController中订阅。这样页面任何时候重建,数字都不会闪烁或者与列表不一致。
底部列表不是直接塞ListView,而是用CustomScrollView加SliverList。当笔记数量很多时,SliverList支持按需构建,内存占用更可控。列表项组件拆成NoteListTile,它的职责仅仅是展示一条笔记的标题、摘要、时间和置顶状态,不做任何业务逻辑。
4.2 笔记列表的动态加载
数据分页是一个容易被人忽略但绝不能省的点。详情页不是一次把所有笔记加载进内存,而是按 30 条一页加载。滚动到底部自动加载下一页。
实现风格上,我建议用ScrollController监听滚动位置,当position.pixels >= maxScrollExtent - 200时触发loadMore。注意在 OpenHarmony 的触摸滚动频率上,这个阈值可以稍微调大一点,因为部分设备的老化或性能差异会让惯性滚动比较猛,阈值太小会频繁触发加载。
翻页读取的代码大致如下:
Future<void> _loadMore() async { if (_isLoading || !_hasMore) return; _isLoading = true; final nextNotes = await _repository.getNotesByFolder( _folderId, query: NoteQuery( page: _page + 1, pageSize: 30, sortField: _sortField, order: _sortDesc ? SortOrder.desc : SortOrder.asc, keyword: _keyword.isEmpty ? null : _keyword, ), ); if (nextNotes.isNotEmpty) { _page++; _notes.addAll(nextNotes); } else { _hasMore = false; } _isLoading = false; notifyListeners(); }注意_hasMore的判断,最好放在数据库查询层,而不是靠“返回条数小于 pageSize”来猜。因为搜索到刚好最后一页正好 30 条时,这种做法会误判成还有下一页,白请求一次。我在查询里用limit pageSize + 1的方式,如果返回值多出一条,说明还有下一页,再取前pageSize条展示。
4.3 排序、筛选与批量操作
详情页的排序维度有三个:更新时间、创建时间、标题字母序。默认是更新时间倒序,这个最符合“最近在写什么”的真实使用习惯。
排序条做成横向滚动的Chip组,点一个切一次。切换排序时,不能直接对已加载的列表做内存排序,一定要重新从数据库查询第一页。原因很简单:内存排序会打乱分页游标,之后加载下一页会重复数据。我踩过一次这个坑,排查了半天,最后发现是内存List.sort后没有重置页码导致的。
批量操作栏在非编辑模式下是隐藏的。长按某个条目会进入多选模式,选中条目数量实时显示,底部出现“移动”“删除”“取消”三个按钮。移动功能调用的是moveNotesToFolder,弹出一个自绘的文件夹选择器,选中目标后执行事务更新。删除走的是软删除,is_deleted置为 1,列表立即移出对应条目,但在回收站页面还能恢复。
4.4 手势交互与空状态设计
移动端应用的手势体验决定了详情页的“高级感”。我做了两个轻量的手势交互:
一是左滑单条笔记出现快捷操作,左滑到底直接软删除。这个用Dismissible组件实现,但 Dismissible 有个问题:滑动过程中列表刷新,方向键会被误判。我建议在滑动前先让 provider 暂停 stream 通知,释放后再恢复。
二是下拉刷新。详情页顶部用RefreshIndicator包裹 CustomScrollView,刷新动作会把当前文件夹的统计数据重新拉取,并清掉内存列表重新加载第一页。
空状态我单独做了一个EmptyFolderView,组件里包含图标、一行文案“没有笔记,随手记点什么吧”、一个新建笔记的按钮。这个按钮的跳转路径是NoteEditorPage,初始化参数来自当前文件夹 ID。空状态文案不能太干巴,但也不能太跳脱,要符合记事本工具的调性。
5. 状态管理与实践:Controller 设计
5.1 为什么选 ChangeNotifier
Flutter 的状态管理方案很多,有 Bloc、Riverpod、GetX。这个项目规模不大,我用的是ChangeNotifier加Provider,理由很直接:详情页是一个中等复杂度的页面,用ValueNotifier处理单个状态,用ChangeNotifier处理聚合状态,已经足够清晰,不需要引入代码生成和额外学习成本。
文件夹详情这种页面,状态很典型:一组排序参数、一个分页游标、一组已加载笔记、一组选中的笔记 ID、若干 UI 开关。把这些状态收进一个FolderDetailController,页面只负责监听AnimatedBuilder或者Consumer,业务逻辑全部集中在 Controller 里,方便单元测试。
5.2 Controller 如何拆解
我建了一个FolderDetailController extends ChangeNotifier,属性分成三类:
- 数据状态:
List<NoteEntity> notes、int totalCount、int subFolderCount; - 查询状态:
String keyword、int page、bool hasMore、bool isLoading; - 交互状态:
bool selectionMode、Set<String> selectedIds、SortField sortField。
对外暴露的方法包括loadFirstPage()、loadMore()、refresh()、toggleSelection(String id)、selectAll()、applySort(SortField)、search(String keyword)等。
一开始我有一个误区,把selectedIds直接放在页面的 State 里。结果批量删除一半时切换 Tab,页面 State 被重建,选中状态全丢了。后来全部挪到 Controller,这个问题迎刃而解。经验就是一句话:凡是切换 tab、切后台回来还要保留的 UI 状态,尽量不要只放在 Widget State 里。
5.3 跨页面同步的关键技巧
文件夹详情的另一个难点是跨页面状态同步。比如从详情页进入笔记编辑器,改完标题后返回,列表中对应条目要立刻刷新,不需要用户手动下拉。
实现方式是在 Controller 初始化时,监听一个全局的NoteChangeNotifier,当有NoteUpdated事件发生时,只更新本地列表中匹配的那一条,不重新分页加载。这个策略能避免更新时间排序里位置的跳动,也避免破坏用户已滚动到的位置。
排序位置更新的逻辑单独钩在NoteEntity.updatedAt变更事件里,如果当前排序是“更新时间”,则需要把刚更新的那条移到列表顶部;如果是“创建时间”,则保持原位不动,只更新摘要文本。
6. 性能优化与踩坑记录
6.1 列表性能的四个关键点
OpenHarmony 设备上 Flutter 列表性能瓶颈主要不在引擎渲染,而在数据构建和平台通道往返。我做了一组实测,优化前后目录页从加载 500 条笔记到滚动跟手,效果差别巨大。关键点有四个:
- 列表项使用
const构造,减少 Widget 重建; - 条目缩略图图标用静态 IconData,不动态加载;
- 日期格式化提前算好,不要在 build 里重复创建
DateFormat; - 摘要预览只取
content前 80 个字符,避免大字符串截断进入列表项。
代码层面,NoteListTile的build方法里不创建任何新对象,所有经过计算的文本都缓存。实际测试中,Flutter 在 OpenHarmony 上每秒构建帧数是稳定的,没有出现掉帧卡顿。
6.2 状态更新与 Build 风暴控制
详情页最容易出现“一次查询导致十次 rebuild”的问题。比如loadFirstPage成功后,我同时更新了notes、totalCount、subFolderCount、isLoading,如果每改一个属性都notifyListeners(),页面就会闪烁多次。
我的做法是在一个方法内部先更新所有业务字段,最后只调用一次notifyListeners()。具体到代码里,就是把傅里叶展开式属性赋值全部写完,最后再加一行通知。
另外,我在 Controller 里加了一个简单的防抖函数。搜索框每次输入字符时,等 300 毫秒没有新输入才触发查询。这个机制对全量性能影响非常大,因为 OpenHarmony 原生输入通道本来就有一定延迟,每个字符都触发数据库查询会让页面打字都卡。
6.3 平台通道与文件路径的适配
在 OpenHarmony 上,获取应用私有目录和 Android 上完全不一样。Android 上我们通常用path_provider,但在 OpenHarmony 平台要自己实现一个PlatformService.getAppStoragePath()的通道方法,把原生路径字符串传回 Dart 层。
这块踩的坑是路径分隔符。OpenHarmony 某些版本返回的文件路径是file://开头,Dart 层如果不处理,拼接到 SQLite 的路径参数就会报错。我统一封装了一个normalizePath工具,把所有file://前缀去掉,把\替换成/。起因就是测试低版本设备时发现数据库文件打不开。
String normalizePath(String raw) { var clean = raw.replaceFirst(RegExp(r'^file://'), ''); clean = clean.replaceAll('\\', '/'); if (!clean.startsWith('/')) clean = '/$clean'; return clean; }6.4 数据库连接与事务管理
在 OpenHarmony 上 SQLite 和 Android 的默认配置有一些差异。我这里用的是系统提供的sqlite3插件,开启WAL模式,设busy_timeout为 5 秒。这个配置对文件系统性能较弱的设备尤其重要,否则大批量删除时会报database is locked。
批量删除的实现必须包在一个事务里。我写的是:
Future<void> softDeleteNotes(List<String> noteIds) async { await db.transaction((txn) async { for (final id in noteIds) { await txn.update( 'notes', {'is_deleted': 1, 'updated_at': now}, where: 'note_id = ?', whereArgs: [id], ); } }); }事务提交之后,再去更新文件夹的updated_at,保证文件夹列表页的排序也是新的。顺序千万不能反,如果先更新了文件夹再删笔记,一旦删除失败就会产生脏的“最近修改时间”。
7. 常见问题与经验教训速查
7.1 实操中反复踩到的五个问题
我把这个项目过程中积累的问题整理成一个速查表,方便后续开发者直接对号入座。
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| 详情页进入时白屏闪烁 | 初始加载异步状态未区分 | Controller 增加isInitialized,未完成前用轻量骨架屏 |
| 下拉刷新后列表跳回顶部 | 刷新逻辑里直接清空再notifyListeners | 使用RefreshIndicator.onRefresh先等待数据加载,再一次性重建 |
| 删除一条笔记后 totalCount 不变 | 只更新了列表,没有更新统计字段 | 统一在 Controller 的_applyDelete里同步修改totalCount |
| 批量选择时列表滚动位置错乱 | ListView无 key 导致复用组件 | 给NoteListTile设置ValueKey(noteId) |
| 在 OpenHarmony 设备上数据库很慢 | 没有开启 WAL,事务频繁 | 初始化时PRAGMA journal_mode=WAL,合并多次写入 |
7.2 一个印象深刻的定位过程
有一次测试人员在多级文件夹里新建笔记后,正处在二级文件夹详情页时,数据统计一直不更新。由于使用了 Stream 监听,我以为会自动刷新,结果发现子文件夹新建笔记的事件根本不会触发父文件夹的监听。
最终定位是:Stream 查询的folder_path LIKE ?参数没有包含尾部斜杠。工作/项目A这个路径去匹配工作/项目A/新笔记确实能匹配上,但我通配符写的是work/projectA/%,因为%默认不匹配空字符串,所以当新笔记直接建在文件夹工作/项目A下面时记录被漏掉了。
修复非常小:查询参数改为folder_path = ? OR folder_path LIKE ? || '/%',同时在建表时用COLLATE和索引来保证大小写路径一致。这个坑让我意识到,凡是涉及层级路径的匹配,都要把“当前节点自身”和“子孙节点”两条路径放进查询条件里,缺一不可。
7.3 日志与调试经验
OpenHarmony 上跑 Flutter 调试有一个不太顺的点:print日志有时候会吞掉,println 在发布版里直接被优化掉。我的做法是用一个统一的AppLogger封装,内部调用dart:developer的log,并强制在 debug 模式下输出到控制台。
定位文件夹详情页数据链路问题时,我习惯在 Repository 层加一条日志,打印 SQL 语句和绑定参数。这样页面出问题,能很快判断是 SQL 写错还是页面状态更新逻辑写错,而不是两边互相猜。
结语:一个“小页面”背后的工程深水区
文件夹详情在外人看起来就是一个列表加几个按钮,但真正动手实现以后,我发现它几乎覆盖了 App 开发从数据到交互的全链路细节。最让我觉得值得记录的不是某个单一技巧,而是“列表页不能只考虑列表”的思维方式。你要时刻想着数据的一致性和用户操作后的反馈是否闭环,还要考虑不同设备的滚动性能和平台的存储差异。
在这个项目里,我们最后把文件夹详情页单独抽成了一个可复用的模块,后续接手的人只需要给出一个folderId,其他所有统计、分页、手势、批量操作全部自动到位。这也是我给出的一个建议:做这类轻量级工具型应用,把一个中高复杂度页面打磨成独立模块,远比堆更多新功能更值得投入。如果你正在做 Flutter 跨 OpenHarmony 的应用,希望这些经验能帮你少走几趟弯路,尤其是路径匹配和数据同步那两处,妥妥是隐藏版本的大坑。