HarmonyOS知识库——开发实战总结与设计决策复盘
知识库 App 的 6 个页面和 9 个操作函数已经全部实现。这篇文章不重复讲解代码细节,而是从"开发者做了什么选择、为什么这样选、有没有更好的方案"三个角度做整体复盘。
完整效果
一、开发顺序与页面依赖
1. NoteService.ets 数据层:接口 + mock 数据 + 操作函数
2. Index.ets 首页:仪表盘 + 底部导航
3. NoteEditor.ets 编辑器:新建/编辑双模式 + 标签管理
4. NoteList.ets 列表页:多源数据 + 搜索过滤
5. TagFilterPage.ets 标签筛选:双状态切换
6. ProfilePage.ets 个人中心:标签云 + 统计
7. FavoritesPage.ets 收藏页:收藏列表
为什么先做数据层
NoteService 是所有页面的基础——没有接口定义和 mock 数据,页面无法渲染任何内容。先做数据层有两个好处:
第一,接口定义决定了页面的 UI 结构。 Note 的 8 个字段直接决定了编辑器要输入什么、列表页要显示什么、详情页要展示什么。先写接口再做页面,能避免"页面写好了发现数据结构不够用"的返工。
第二,操作函数的返回值设计影响页面的状态管理。 toggleFav 返回 boolean,addNote 返回 void——这两个设计决策在数据层就确定了,页面层直接沿用。
为什么 NoteEditor 第二做
NoteEditor 是唯一的数据"写入"页面——新建和编辑都通过它完成。先做 NoteEditor 能验证 addNote/deleteNote/toggleFav 三个修改函数是否正确。如果先做列表页,列表永远只显示 mock 数据,无法测试"新建/编辑后列表是否更新"。
为什么 FavoritesPage 最后做
FavoritesPage 只读取 getFavorites()——不需要任何新操作函数。它的实现依赖 toggleFav 在 NoteEditor 里正常工作。如果先做 FavoritesPage,收藏列表永远是空的(没有地方切换收藏状态)。
二、数据层的六个设计决策
决策一:Note 接口 favorited 字段直接存笔记上
// 知识库:favorited 存在 Note 上
interface Note { favorited: boolean }
// 替代方案:独立收藏数组
let _favs: number[] = []
选择理由: 12 篇笔记的规模下,直接存在 Note 上更直观——看到一篇笔记就知道它是否被收藏。查询收藏只需要 getFavorites() 单层遍历,不需要交叉匹配。
适用场景: 数据量小(< 100 条)、收藏是笔记固有属性的场景。如果收藏需要跨多个数据类型共享(比如同时收藏笔记和笔记本),独立数组更合适。
决策二:Notebook 保留 noteCount 冗余字段
interface Notebook { noteCount: number }
选择理由: noteCount 可以从 _notes 里实时计算,但每次显示笔记本列表都遍历 12 篇笔记统计数量效率不高。保留 noteCount 是"空间换时间"——多存一个数字,避免每次查询都遍历。
代价: addNote 和 deleteNote 必须同步更新 noteCount。忘了更新就会数据不一致。
踩坑记录: 最初 deleteNote 忘了 NOTEBOOKS[i].noteCount--,删除笔记后笔记本卡片的笔记数量没变。加了同步逻辑后解决。
决策三:getAllTags 用三重循环统计
for (let i = 0; i < _notes.length; i++) { // 遍历笔记
for (let j = 0; j < _notes[i].tags.length; j++) { // 遍历标签
for (let k = 0; k < result.length; k++) { // 检查是否已统计
选择理由: 三重循环是最直观的频率统计实现——不需要引入 Map 或 Set 等额外数据结构。在 12 篇笔记、约 15 个不重复标签的规模下,总执行次数约 540 次,性能完全不是问题。
替代方案: 用 Map<string, number> 做计数器,时间复杂度降到 O(n×m)。但 ArkTS 对 Map 的支持不如 TypeScript 完善,用 for 循环更可靠。
决策四:addNote 新笔记放数组最前面
const n: Note[] = [{ id: _nextId, /* 新笔记 */ }]
for (let i = 0; i < _notes.length; i++) { n.push(_notes[i]) }
_notes = n
选择理由: 新建笔记后用户期望立即在列表顶部看到。如果 push 到末尾,用户需要滚动到最后才能看到新笔记。
和 deleteNote 的对比: deleteNote 是"过滤重建"(跳过要删除的项),addNote 是"头插重建"(新项放最前面)。两种操作都创建新数组并替换引用。
决策五:searchNotes 用 indexOf 不用 includes
if (_notes[i].title.toLowerCase().indexOf(q) >= 0 || ...)
选择理由: indexOf 在 ArkTS 里的兼容性更好。includes 是 ES6 方法,某些 ArkTS 版本可能不支持。indexOf 是 ES1 方法,所有版本都支持。
代价: indexOf(q) >= 0 比 includes(q) 稍微啰嗦。但兼容性比代码简洁更重要。
决策六:_nextId 从 100 开始
let _nextId: number = 100
选择理由: mock 数据的 id 是 1-12,从 100 开始保证新增笔记的 id 不和 mock 数据冲突。如果从 13 开始,id 连续性更好,但 100 是一个更明显的"分界线"——id < 100 是 mock 数据,id >= 100 是用户新建的数据。
三、页面层的四个关键技术
技术一:NoteEditor 的"编辑副本"模式
@State title: string = '' // 编辑副本
@State note: Note | undefined // 原始数据
// 编辑时修改副本
.onChange((v: string) => { this.title = v })
// 保存时写回原始数据
this.note.title = this.title.trim()

为什么需要编辑副本? 用户可能修改了但没保存——点返回取消编辑。如果直接修改 note.title,取消编辑后原始数据也被改了。用独立的 title/content 存编辑副本,只有点"保存"才写回 note。
只在编辑模式需要复制 tags 数组。 title/content 是基本类型,赋值时复制值。tags 是数组,赋值时复制引用——直接 this.tags = this.note.tags 会导致编辑器里添加/删除标签影响原始数据。
技术二:NoteList 的多源数据切换
this.notes = id < 0 ? getNotes() : getNotesByNotebook(id)
一行代码处理两种数据源。 notebookId=-1 显示全部笔记,其他值显示该笔记本的笔记。搜索时调用 searchNotes,清空搜索时调用 getNotes 恢复。
三种数据源最终走到同一个 ForEach。 不管数据从哪来,渲染逻辑完全一样。这是"数据源和渲染分离"的架构优势。
技术三:TagFilterPage 的双状态切换
if (this.selectedTag.length === 0) {
// 标签网格
} else {
// 筛选笔记列表
}

if-else 控制两种完全不同的 UI 结构。 选中标签前显示 Flex 标签网格,选中后显示 Column 笔记列表。状态切换不需要页面跳转——同页面内完成。
和显示/隐藏的区别: 显示/隐藏用 visibility 或 opacity,两种 UI 同时存在但只显示一种。条件渲染是"只渲染当前需要的 UI",性能更好。
技术四:build 里直接调查询函数
Text('累计 ' + getNotes().length + ' 篇笔记')
ProfilePage 在 build 里直接调用 getNotes().length——不缓存到 @State。 因为 ProfilePage 没有其他 @State 变化,每次 build 重新查询的性能开销可以忽略。
和首页的对比: 首页用 @State notes 缓存笔记列表,因为首页有搜索等其他 @State 变化会触发 build。ProfilePage 没有这种需求,直接查询更简洁。
四、状态管理的三种模式
模式一:直接查询赋值
// ProfilePage
this.tags = getAllTags()
// NoteList
this.notes = getNotesByNotebook(id)
适用于"查询一次、渲染多次"的场景。查询结果赋值给 @State,UI 根据 @State 渲染。
模式二:操作函数 + 重新查询
// NoteEditor 保存后
addNote(this.title, this.content, this.selectedNb, this.tags)
router.back() // 返回后 onPageShow 重新查询
适用于"先修改数据、再刷新页面"的场景。操作函数修改 _notes,返回后 onPageShow 重新查询最新数据。
模式三:操作函数返回值赋值
// NoteEditor 收藏切换
this.fav = toggleFav(this.note.id)
适用于"操作后只需要一个状态值"的场景。toggleFav 返回 boolean,直接赋值给 @State。
三种模式的选择标准
| 场景 | 模式 | 例子 |
|---|---|---|
| 初始加载数据 | 直接查询赋值 | aboutToAppear 里调 getNotes() |
| 修改数据后刷新列表 | 操作函数+重新查询 | addNote 后 router.back 触发 onPageShow |
| 切换单个状态 | 返回值赋值 | toggleFav 返回 boolean |
五、辅助函数的重复与抽取
nbName/nbIcon/nbColor 三个辅助函数在 NoteList、FavoritesPage、TagFilterPage 三个页面重复出现。
当前状态:各自定义
三个页面各写了一份,代码有重复但每个页面自包含。
可选方案:抽取到 NoteService
export function getNotebookName(id: number): string { ... }
export function getNotebookIcon(id: number): string { ... }
export function getNotebookColor(id: number): string { ... }
三个页面直接导入调用。
为什么当前没抽取
| 因素 | 分析 |
|---|---|
| 重复量 | 3 个函数 × 3 个页面 = 9 处重复 |
| 函数复杂度 | 每个函数只有 1 行逻辑 |
| 页面数量 | 只有 3 个页面用到 |
| 维护成本 | 改一个函数需要同步改 3 个文件 |
在 3 个页面的规模下,重复代码的维护成本可以接受。如果增加到 5+ 页面都用到,再抽取也不迟。
六、已知问题与优化方向
| 问题 | 位置 | 影响 | 优化方案 |
|---|---|---|---|
| 删除笔记无确认弹窗 | NoteEditor | 误触删除无法恢复 | 加 AlertDialog 确认 |
| 标题为空保存无提示 | NoteEditor | 用户不知道保存失败 | 加 Toast 提示 |
| 搜索无防抖 | NoteList | 快速输入时频繁查询 | 加 300ms 防抖 |
| noteCount 可能不一致 | NoteService | 删除后数量没更新 | 已修复,但需测试 |
| 数据不持久化 | NoteService | 重启后数据清空 | 引入 Preferences 存储 |
| 标签无删除功能 | NoteEditor | 只能添加不能删除标签 | 已实现删除(点击标签 ×) |
优先级排序: 数据持久化 > 删除确认弹窗 > 搜索防抖 > 标题为空提示。数据持久化影响用户体验最直接——重启后所有笔记丢失。
七、项目复杂度评估
| 维度 | 数据 |
|---|---|
| 页面数量 | 6 个 |
| 接口数量 | 3 个(Note, Notebook, TagCount) |
| 操作函数 | 9 个(6 查询 + 3 修改) |
| @State 变量 | 最多 8 个(NoteEditor) |
| Builder | 2 个(Stat, Nav) |
| 路由路径 | 11 条 |
| 辅助函数 | 3 个(nbName/nbIcon/nbColor),3 页面重复 |
复杂度集中在数据层和路由层。 数据层有 9 个操作函数管理静态查询和动态修改;路由层有 11 条路径、4 种参数类型。页面层相对简单——大多数页面是"导航栏 + 列表"的标准结构。
八、知识库 App 的核心设计模式
模式一:多入口单出口
所有页面最终都汇聚到 NoteEditor——它是唯一的"写入"页面。其他页面都是"读取"或"筛选"。这种设计让数据写入集中管理,降低了数据不一致的风险。
模式二:参数驱动页面状态
路由参数决定目标页面的行为——noteId 决定新建/编辑,notebookId 决定筛选范围,tag 决定标签筛选。同一个页面靠参数呈现不同状态,减少了页面数量。
模式三:查询函数即数据源
页面不自己管理数据——所有数据来自 NoteService 的查询函数。getNotes()、getFavorites()、searchNotes() 是三个独立的数据源,页面按需调用。数据层和页面层的职责清晰分离。
模式四:@State 刷新的统一模式
所有 @State 刷新都遵循"创建新数组 + 替换引用"的模式。addNote、deleteNote、removeTag 都是创建新数组后赋值给 @State。toggleFav 是唯一用"修改属性 + 返回值赋值"的例外。
更多推荐


所有评论(0)