HarmonyOS知识库——首页仪表盘的架构设计与数据流
HarmonyOS知识库——首页仪表盘的架构设计与数据流
知识库 App 的首页是一个典型的仪表盘——统计卡片、笔记本导航、最近笔记列表,三个区块各司其职。这种"一屏展示多维度信息"的布局在工具类 App 里很常见,但要做到信息密度高而不杂乱,需要在布局嵌套和数据流设计上下功夫。本文从首页的四个区块出发,拆解每个区块的布局策略和背后的设计决策。
完整效果
一、页面概览:四个功能区块
从截图看,首页从上到下依次是:
| 区块 | 内容 | 交互 |
|---|---|---|
| Header | 标题 + 笔记总数 + 新建按钮 | 点击新建跳转 NoteEditor |
| 快捷统计 | 总笔记 / 收藏 / 笔记本 三卡片 | 纯展示 |
| 笔记本 | 4 个笔记本卡片横滑 | 点击跳转 NoteList |
| 最近笔记 | 笔记列表(含标签、笔记本、日期) | 点击跳转 NoteEditor |
底部导航栏 4 个 tab:首页 / 搜索 / 收藏 / 我的。
二、数据层设计:NoteService 的完整结构
三个接口
export interface Note {
id: number; title: string; content: string; notebookId: number
tags: string[]; favorited: boolean; createdAt: string; updatedAt: string
}
export interface Notebook {
id: number; name: string; icon: string; color: string; noteCount: number
}
export interface TagCount { name: string; count: number }

Note 接口的 8 个字段分三组:
| 分组 | 字段 | 设计理由 |
|---|---|---|
| 基本信息 | id, title, content | 笔记的核心数据 |
| 分类信息 | notebookId, tags | 用于筛选和检索 |
| 状态信息 | favorited, createdAt, updatedAt | 用于排序和展示 |
notebookId 用数字不用字符串。 和菜谱 App 的 category 字符串匹配不同,笔记本有独立的 Notebook 接口,用 id 关联更高效——查询时直接比较数字,不需要字符串匹配。
tags 是字符串数组。 一篇笔记可以有多个标签(如"HarmonyOS"“编程”),用数组存储。标签系统是知识库的核心——用户通过标签发现相关笔记。
favorited 是布尔值。 和菜谱 App 的收藏设计一致——直接在 Note 对象上标记,不需要独立的收藏数组。
createdAt 和 updatedAt 用字符串不用日期对象。 mock 阶段用 “2026-07-10” 格式最简单。真实 App 应该用 timestamp,方便排序和计算时间差。
8 个操作函数
export function getNotes(): Note[] // 获取全部笔记
export function getNotesByNotebook(nbId: number): Note[] // 按笔记本筛选
export function getFavorites(): Note[] // 获取收藏笔记
export function searchNotes(kw: string): Note[] // 搜索笔记
export function getNoteById(id: number): Note | undefined // 按 id 查询
export function addNote(title, content, nbId, tags): void // 新增笔记
export function deleteNote(id: number): void // 删除笔记
export function toggleFav(id: number): boolean // 切换收藏
export function getAllTags(): TagCount[] // 获取所有标签及计数

和菜谱 App 的操作函数对比:
| 函数类型 | 菜谱 App | 知识库 App |
|---|---|---|
| 查询类 | getRecipeById, getRecipesByCat, getFavs, isFav | getNotes, getNotesByNotebook, getFavorites, searchNotes, getNoteById, getAllTags |
| 修改类 | toggleFav, addToShop, clearShop, removeFromShop | addNote, deleteNote, toggleFav |
知识库的查询函数更多——因为有搜索、按笔记本筛选、按标签统计三种查询维度。菜谱 App 只有按分类筛选一种。
addNote 的实现细节
export function addNote(title: string, content: string, nbId: number, tags: string[]): void {
_nextId++
const d: string = '2026-07-15'
const n: Note[] = [{ id: _nextId, title: title, content: content, notebookId: nbId,
tags: tags, favorited: false, createdAt: d, updatedAt: d }]
for (let i: number = 0; i < _notes.length; i++) { n.push(_notes[i]) }
_notes = n
for (let i: number = 0; i < NOTEBOOKS.length; i++) {
if (NOTEBOOKS[i].id === nbId) NOTEBOOKS[i].noteCount++
}
}
三个关键操作:
_nextId++——自增 id,保证每篇笔记的 id 唯一。初始值 100,和 mock 数据的 id 1-12 不冲突。_notes = n——创建新数组并替换引用,触发 @State 刷新。NOTEBOOKS[i].noteCount++——同步更新笔记本的笔记计数。
踩坑记录: 最初 addNote 忘了更新 noteCount,新建笔记后首页的笔记本卡片显示的笔记数量没变。加了 NOTEBOOKS[i].noteCount++ 后解决。这提醒我们:修改数据时要考虑所有读取该数据的地方是否需要同步更新。
getAllTags 的三重嵌套
export function getAllTags(): TagCount[] {
const result: TagCount[] = []
for (let i: number = 0; i < _notes.length; i++) {
for (let j: number = 0; j < _notes[i].tags.length; j++) {
const t: string = _notes[i].tags[j]
let found: boolean = false
for (let k: number = 0; k < result.length; k++) {
if (result[k].name === t) { result[k].count++; found = true; break }
}
if (!found) { result.push({ name: t, count: 1 }) }
}
}
return result
}

三重循环的逻辑: 外层遍历笔记,中层遍历每篇笔记的标签,内层检查标签是否已存在于结果中。已存在则 count+1,不存在则新增。
为什么用三重 for 循环而不是高阶函数? flatMap + reduce 能更简洁地实现同样逻辑,但 ArkTS 对高阶函数的支持不如 TypeScript 完善。用 for 循环最可靠。
三、首页的数据流
@State notes: Note[] = []
@State favCount: number = 0
onPageShow(): void { this.refresh() }
aboutToAppear(): void { this.refresh() }
private refresh(): void {
this.notes = getNotes()
this.favCount = getFavorites().length
}
两个 @State 的职责:
| 变量 | 数据来源 | 用途 |
|---|---|---|
| notes | getNotes() |
渲染最近笔记列表、显示总笔记数 |
| favCount | getFavorites().length |
显示收藏数量统计卡片 |
refresh 只在两个生命周期调用。 aboutToAppear 在页面首次创建时调用,onPageShow 在页面每次显示时调用。用户从其他页面返回首页时,onPageShow 重新查询最新数据——比如在 NoteEditor 新建了笔记,返回首页后列表自动更新。
为什么不用 onPageShow 一个方法就够了? 因为 aboutToAppear 在 build() 之前执行,保证首次渲染时数据已加载。如果只写 onPageShow,首次渲染时 notes 是空数组,列表会先显示空白再刷新——用户会看到闪烁。
数据流图
NoteService._notes(模块级变量)
↓ getNotes()
Index.notes(@State)→ ForEach 渲染最近笔记列表
↓
├→ 标题区:this.notes.length + ' 篇笔记'
├→ 统计卡片:this.notes.length / this.favCount / NOTEBOOKS.length
└→ 笔记列表:ForEach(this.notes, ...)
所有数据都从 NoteService 直接读取。 首页没有中间数据层——getNotes() 返回的就是 _notes 的引用,不需要额外处理。这比菜谱 App 简单——菜谱的收藏需要 getFavs() 做双重遍历匹配。
四、四个辅助函数的作用
private nbName(id: number): string {
for (let i: number = 0; i < NOTEBOOKS.length; i++) {
if (NOTEBOOKS[i].id === id) return NOTEBOOKS[i].name
}
return ''
}
private nbIcon(id: number): string { /* 同上,返回 icon */ }
private nbColor(id: number): string { /* 同上,返回 color */ }
private previewContent(c: string): string {
const r: string[] = []
for (let i: number = 0; i < c.length && i < 50; i++) {
const ch: string = c[i]
r.push(ch === '\n' ? ' ' : ch)
}
return r.join('')
}
为什么需要 nbName/nbIcon/nbColor
笔记数据只存了 notebookId(数字),没有存笔记本的名称、图标、颜色。展示时需要通过 notebookId 查找笔记本信息——这就是三个辅助函数的作用。
为什么不直接在 Note 里存笔记本信息? 因为数据冗余——如果笔记本改名,所有关联笔记的 notebookName 都要更新。存 id 更安全,查询时实时获取最新信息。
三个函数结构完全一样——只返回值不同。 这是抽取公共函数的典型场景。但因为只有首页用到,在 6 个页面的规模下,各自写更清晰。
previewContent 的内容预览
private previewContent(c: string): string {
const r: string[] = []
for (let i: number = 0; i < c.length && i < 50; i++) {
const ch: string = c[i]
r.push(ch === '\n' ? ' ' : ch)
}
return r.join('')
}
截取前 50 个字符作为预览。 c.length && i < 50 保证不越界。ch === '\n' ? ' ' : ch 把换行符替换成空格——笔记内容里有很多换行(步骤分隔),预览时换成空格更整洁。
为什么用 for 循环逐字符拼接而不是 c.substring(0, 50)? 因为需要同时做"截取"和"替换换行"两个操作。substring 只能截取,替换还需要额外处理。for 循环一步到位。
五、快捷统计卡片的 @Builder
@Builder Stat(icon: string, value: string, label: string) {
Column() {
Text(icon).fontSize(20).margin({ bottom: 4 })
Text(value).fontSize(18).fontWeight(FontWeight.Bold).fontColor(A)
Text(label).fontSize(10).fontColor(T3)
}.layoutWeight(1).padding(14).backgroundColor(CW).borderRadius(14)
}
三个参数的固定语义: icon 是 emoji 图标,value 是数字字符串,label 是说明文字。每次调用传入不同值就能渲染不同的统计卡片。
layoutWeight(1) 让三个卡片均分宽度。 外层 Row 用了 space: 10,减去 20 的间距后,剩余宽度三等分。如果不用 layoutWeight,卡片宽度由内容决定——数字大的卡片会更宽,视觉上不整齐。
和菜谱 App 的 NavBtn Builder 对比: 两个 Builder 都是"接收参数 → 渲染固定结构"的模式。Stat 的参数更简单(三个字符串),NavBtn 需要处理三元表达式选择图标。Stat 的复用次数更多(3 次),NavBtn 只在首页用。
六、笔记本横滑区域
Row({ space: 10 }) {
ForEach(NOTEBOOKS, (nb: Notebook) => {
Column() {
Text(nb.icon).fontSize(32).margin({ bottom: 6 })
Text(nb.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(T1)
Text(nb.noteCount + ' 篇').fontSize(10).fontColor(T3)
}.layoutWeight(1).padding({ top: 16, bottom: 16 })
.backgroundColor(nb.color + '10').borderRadius(14)
.onClick(() => {
router.pushUrl({ url: 'pages/NoteList',
params: { 'notebookId': nb.id, 'notebookName': nb.name } as Record<string,Object> })
})
})
}
为什么不用 Scroll 横向滚动
当前 NOTEBOOKS 只有 4 个,用 Row({ space: 10 }) + layoutWeight(1) 均分宽度刚好放下。如果笔记本增加到 6-8 个,就需要加 Scroll 实现横向滚动:
// 笔记本增多时的改造方案
Scroll() {
Row({ space: 10 }) {
ForEach(NOTEBOOKS, (nb: Notebook) => {
Column() { /* ... */ }
.width(100) // 固定宽度,不再用 layoutWeight
})
}.padding({ left: 16, right: 16 })
}
当前不需要横向滚动——4 个笔记本在一屏内能放下,加 Scroll 反而增加交互成本(用户需要滑动才能看到全部)。
路由传参的双参数设计
params: { 'notebookId': nb.id, 'notebookName': nb.name } as Record<string,Object>
传两个参数而不是一个。 notebookId 用于查询笔记列表(getNotesByNotebook(nbId)),notebookName 用于 NoteList 页面的标题显示。如果不传 notebookName,NoteList 需要自己查 NOTEBOOKS 获取名称——多一次查询。
踩坑记录: 最初只传了 notebookId,NoteList 页面标题显示为空。查了原因发现忘了传 notebookName,NoteList 的 router.getParams() 取不到名字。加了双参数后解决。
七、最近笔记卡片的信息密度
Row() {
Column() {
Text(this.nbIcon(n.notebookId)).fontSize(20).width(44).height(44)
.borderRadius(12).backgroundColor(this.nbColor(n.notebookId) + '12')
.textAlign(TextAlign.Center)
}
Column() {
Row() {
Text(n.title).fontSize(14).fontWeight(FontWeight.Bold).fontColor(T1)
.maxLines(1).layoutWeight(1)
if (n.favorited) Text('❤️').fontSize(12)
}
Text(this.previewContent(n.content)).fontSize(11).fontColor(T3)
.maxLines(1).margin({ top: 2 })
Row() {
Text(this.nbName(n.notebookId)).fontSize(10).fontColor(this.nbColor(n.notebookId))
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
.backgroundColor(this.nbColor(n.notebookId) + '12').borderRadius(4)
Text(' · ' + n.createdAt).fontSize(10).fontColor(T3)
ForEach(n.tags, (t: string) => {
Text(' #' + t).fontSize(10).fontColor(A)
.onClick(() => {
router.pushUrl({ url: 'pages/TagFilterPage',
params: { 'tag': t } as Record<string,Object> })
})
})
}.margin({ top: 3 })
}.alignItems(HorizontalAlign.Start).margin({ left: 10 }).layoutWeight(1)
}

卡片的三行信息结构
| 行 | 内容 | 样式 |
|---|---|---|
| 第一行 | 笔记标题 + 收藏图标 | 14px 粗体 + maxLines(1) |
| 第二行 | 内容预览(前 50 字) | 11px 浅灰 + maxLines(1) |
| 第三行 | 笔记本标签 + 日期 + 标签 | 10px 彩色标签 + 灰色日期 |
信息密度很高——一个小卡片里塞了 4 类信息。 这是"仪表盘"页面的特点:用户需要在短时间内浏览大量信息,高密度设计减少了滚动次数。
笔记本标签的样式
Text(this.nbName(n.notebookId)).fontSize(10).fontColor(this.nbColor(n.notebookId))
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
.backgroundColor(this.nbColor(n.notebookId) + '12').borderRadius(4)
笔记本名称用对应颜色显示——背景色是颜色的 6% 透明度,文字是颜色本身。 这种"浅色背景 + 深色文字"的标签样式让用户一眼看到笔记属于哪个笔记本。
标签的点击跳转
ForEach(n.tags, (t: string) => {
Text(' #' + t).fontSize(10).fontColor(A)
.onClick(() => {
router.pushUrl({ url: 'pages/TagFilterPage',
params: { 'tag': t } as Record<string,Object> })
})
})
标签是可点击的——点击后跳转到 TagFilterPage,显示同一标签的所有笔记。 这是知识库的核心交互:通过标签发现相关笔记。# 前缀让标签看起来像社交媒体的 hashtag。
八、底部导航栏的路由设计
@Builder Nav(label: string, active: boolean) {
Column({ space: 2 }) {
Text(label === '首页' ? '🏠' : (label === '搜索' ? '🔍' :
(label === '收藏' ? '❤️' : '👤'))).fontSize(18)
Text(label).fontSize(9).fontColor(active ? A : '#C0BFC6')
}.onClick(() => {
if (label === '搜索') router.pushUrl({ url: 'pages/NoteList',
params: { 'notebookId': -1, 'notebookName': '全部笔记' } as Record<string,Object> })
else if (label === '收藏') router.pushUrl({ url: 'pages/FavoritesPage' })
else if (label === '我的') router.pushUrl({ url: 'pages/ProfilePage' })
})
}
“搜索” tab 传 notebookId: -1
notebookId: -1 是一个约定值——表示"不按笔记本筛选,显示全部笔记"。 NoteList 页面收到 -1 后调用 getNotes() 而不是 getNotesByNotebook(-1)。这种"特殊值表示特殊含义"的模式在原型阶段很常见,但真实 App 应该用更明确的方式(比如不传 notebookId 参数)。
踩坑记录: 最初传了 notebookId: 0,但 NOTEBOOKS 里没有 id=0 的笔记本,NoteList 页面查不到笔记显示空白。改成 -1 后在 NoteList 里加了判断:if (nbId === -1) return getNotes()。
四个 tab 的跳转目标
| Tab | 跳转目标 | 传递参数 |
|---|---|---|
| 首页 | 不跳转(当前页) | 无 |
| 搜索 | NoteList | notebookId=-1, notebookName=‘全部笔记’ |
| 收藏 | FavoritesPage | 无 |
| 我的 | ProfilePage | 无 |
九、整体嵌套结构
Stack(Alignment.Bottom) 页面容器
├→ Scroll 可滚动内容
│ └→ Column 垂直排列
│ ├→ Row (Header) 标题 + 新建按钮
│ ├→ Row + @Builder Stat × 3 快捷统计
│ ├→ Row + ForEach(NOTEBOOKS) 笔记本横排
│ ├→ Column + ForEach(this.notes) 最近笔记列表
│ └→ Blank(80) 底部导航栏预留
└→ Row + @Builder Nav × 4 底部导航栏
嵌套深度 4 层:Stack → Scroll → Column → Row/Column。 和菜谱 App 首页的嵌套深度一致。Stack 固定底部导航栏,Scroll 保证内容可滚动,Column 垂直排列各区块。
Blank().height(80) 预留导航栏高度。 导航栏 60px + 上方留白 20px = 80px。没有这个空白,最后一个笔记卡片会被导航栏遮挡。
十、数据模型设计对页面代码量的影响
首页有 4 个辅助函数(nbName/nbIcon/nbColor/previewContent),原因是 Note 接口只存了 notebookId(数字),没有存笔记本的名称、图标、颜色。展示时需要通过 id 查找笔记本信息。
存 id 还是存完整信息,是一个经典的权衡:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 存 id | 数据不冗余,改笔记本名只需改 NOTEBOOKS 一处 | 页面需要辅助函数查找信息 |
| 存完整信息 | 页面直接读取,代码更简单 | 数据冗余,改名需同步更新多处 |
在 6 个页面的规模下,两种方式差异不大。但如果页面增多(比如 20 个页面都需要显示笔记本信息),存 id 的优势就体现出来——修改笔记本名只需改 NOTEBOOKS 一处。
更多推荐


所有评论(0)