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++
  }
}

三个关键操作:

  1. _nextId++——自增 id,保证每篇笔记的 id 唯一。初始值 100,和 mock 数据的 id 1-12 不冲突。
  2. _notes = n——创建新数组并替换引用,触发 @State 刷新。
  3. 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 一个方法就够了? 因为 aboutToAppearbuild() 之前执行,保证首次渲染时数据已加载。如果只写 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 一处。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐