做轻量级记事本应用最容易被忽略的往往不是富文本、手写识别或者云同步而是“文件夹详情”这一类看起来不起眼的页面。很多团队把大量精力砸在编辑器体验上结果文件夹列表一进去就卡顿、数据对不上、删除笔记后数量统计不刷新用户没到写笔记那一步就先把 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_idTEXT主键使用 UUIDparent_idTEXT父文件夹 ID根节点为空nameTEXT文件夹名称sort_orderINTEGER同级排序权重created_atINTEGER创建时间戳updated_atINTEGER更新时间戳笔记表关键字段字段类型说明note_idTEXT主键folder_idTEXT直属文件夹外键titleTEXT标题冗余避免全文解析contentTEXTMarkdown 正文is_deletedINTEGER软删除标记pinnedINTEGER是否置顶updated_atINTEGER更新时间排序主力字段folder_pathTEXT冗余路径用于子范围查询索引方面folder_id is_deleted建联合索引updated_at单独建倒序索引。文件夹详情页默认排序是sort by pinned desc, updated_at desc这个索引足够覆盖大部分查询场景。搜索的时候走title like ?加is_deleted0数据量在几万条以内完全没问题。3.3 Repository 与数据流Repository 层对外暴露的方法是单向数据流。页面状态永远是由数据库查询结果驱动任何修改操作都先写库再通过监听器通知 UI 刷新不做内存缓存里的“花活”更新。核心接口长这样abstract class FolderRepository { StreamFolderDetailData watchFolderDetail(String folderId); FutureListNoteEntity getNotesByFolder( String folderId, { required NoteQuery query, }); Futurevoid moveNotesToFolder( ListString noteIds, String targetFolderId, ); Futurevoid softDeleteNotes(ListString 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 的触摸滚动频率上这个阈值可以稍微调大一点因为部分设备的老化或性能差异会让惯性滚动比较猛阈值太小会频繁触发加载。翻页读取的代码大致如下Futurevoid _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 为什么选 ChangeNotifierFlutter 的状态管理方案很多有 Bloc、Riverpod、GetX。这个项目规模不大我用的是ChangeNotifier加Provider理由很直接详情页是一个中等复杂度的页面用ValueNotifier处理单个状态用ChangeNotifier处理聚合状态已经足够清晰不需要引入代码生成和额外学习成本。文件夹详情这种页面状态很典型一组排序参数、一个分页游标、一组已加载笔记、一组选中的笔记 ID、若干 UI 开关。把这些状态收进一个FolderDetailController页面只负责监听AnimatedBuilder或者Consumer业务逻辑全部集中在 Controller 里方便单元测试。5.2 Controller 如何拆解我建了一个FolderDetailController extends ChangeNotifier属性分成三类数据状态ListNoteEntity notes、int totalCount、int subFolderCount查询状态String keyword、int page、bool hasMore、bool isLoading交互状态bool selectionMode、SetString 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。批量删除的实现必须包在一个事务里。我写的是Futurevoid softDeleteNotes(ListString 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_modeWAL合并多次写入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 的应用希望这些经验能帮你少走几趟弯路尤其是路径匹配和数据同步那两处妥妥是隐藏版本的大坑。
阅读完成 · 觉得有帮助?