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, isFavgetNotes, getNotesByNotebook, getFavorites, searchNotes, getNoteById, getAllTags
修改类toggleFav, addToShop, clearShop, removeFromShopaddNote, 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 的职责:

变量数据来源用途
notesgetNotes()渲染最近笔记列表、显示总笔记数
favCountgetFavorites().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跳转目标传递参数
首页不跳转(当前页)
搜索NoteListnotebookId=-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、测试、元服务和应用上架分发等。

更多推荐