一、前言

在前几篇文章中,我们先后完成了鸿蒙计算器和待办事项应用的开发,掌握了 ArkUI 的页面布局、状态管理、列表渲染和数据持久化等核心技能。今天我们将这些知识综合运用,开发一个功能更完整的项目——笔记应用(Notes App)

笔记应用是移动端最典型的工具类应用之一。无论是系统自带的备忘录,还是第三方的 Notion、有道云笔记,其核心功能都是新建、编辑、保存和删除笔记。和单纯的待办应用相比,笔记应用需要处理更复杂的场景:多页面之间的跳转与参数传递、新建与编辑复用同一个页面、跨页面的数据同步、以及更丰富的内容输入方式。这些恰恰是实际鸿蒙应用开发中最常见、最核心的需求模式。

本文将从项目创建开始,带你一步步实现一个功能完整的鸿蒙笔记应用。与之前文章的"一步到位"风格不同,本文将更注重设计思路的讲解——为什么要这样组织代码?为什么需要两个生命周期函数配合?跨页面数据是怎么同步的?理解了这些设计决策,你才能在遇到类似需求时举一反三。


二、项目准备

2.1 开发环境要求

  • IDE:DevEco Studio(最新版本,可从华为开发者官网下载)
  • SDK:API 23 或以上,通过 SDK Manager 确认已安装
  • 语言/框架:ArkTS + ArkUI
  • 模拟器:Phone_API23(1080 × 1920 分辨率)

2.2 新建项目

打开 DevEco Studio,点击 File → New → Create Project,选择 Empty Ability 模板,填写以下项目信息:

配置项 推荐值 说明
项目名称 NotesApp 使用英文,不要出现中文
包名 com.example.notesapp 应用唯一标识,反向域名格式
保存路径 D:\Projects\NotesApp 必须为纯英文路径
Compile SDK API 23 根据已安装的 SDK 版本选择
模型 Stage 官方推荐的应用开发模型
模块名称 entry 应用入口模块,保持默认

点击 Finish 完成创建。IDE 会自动同步项目配置,首次同步通常需要 1-3 分钟。同步完成后,可以看到标准的 HarmonyOS 项目目录结构。


三、功能设计

3.1 双页面架构

笔记应用采用双页面架构:

  • 列表页(Index.ets):应用首页,以卡片列表形式展示所有笔记。每张卡片显示标题、内容摘要和修改时间。顶部有新建按钮。
  • 详情页(NoteDetail.ets):笔记的新建和编辑都在此页面完成。通过路由参数区分新建(-1)和编辑(实际 ID)两种模式。

两个页面通过路由跳转连接,通过全局存储 @StorageLink 共享数据。

3.2 界面布局

列表页布局:

┌─────────────────────────────┐
│  📝 我的笔记       ➕ 新建   │  ← 顶部标题栏
├─────────────────────────────┤
│  ┌─────────────────────┐    │
│  │ 会议记录        🗑️  │    │  ← 笔记卡片(标题 + 摘要 + 时间)
│  │ 今天下午三点讨...     │    │
│  │ 05-31 14:30          │    │
│  ├─────────────────────┤    │
│  │ 购物清单        🗑️  │    │
│  │ 牛奶、鸡蛋、...       │    │
│  │ 05-30 10:15          │    │
│  └─────────────────────┘    │
└─────────────────────────────┘

详情页布局:

┌─────────────────────────────┐
│  ← 返回            💾 保存  │  ← 顶部导航栏
├─────────────────────────────┤
│  输入标题...                │  ← 单行标题输入
├─────────────────────────────┤
│                             │
│  开始记录...                │  ← 多行内容输入
│                             │
│                             │
└─────────────────────────────┘

3.3 数据模型

笔记的数据结构如下:

interface Note {
  id: number       // 唯一标识,用于精确查找和删除
  title: string    // 笔记标题,可为空字符串
  content: string  // 笔记正文内容
  timestamp: number // 创建或最后修改时间的时间戳
}

四个字段各司其职:id 是数据库层面的主键,用于区分和定位每一条笔记;titlecontent 是用户数据的主体;timestamp 用于列表按时间排序,并在卡片上显示可读的日期时间。

3.4 功能清单

功能 详细说明 实现位置
查看笔记列表 按时间倒序显示所有笔记卡片 Index.ets → build()
内容摘要 显示内容的前 40 个字符 Index.ets → getPreview()
时间格式化 将时间戳转为 MM-DD HH:MM 格式 Index.ets → formatDate()
新建笔记 跳转到详情页,传入 ID = -1 Index.ets → openNewNote()
编辑笔记 点击卡片跳转,传入实际 ID Index.ets → openNote()
保存笔记 新建或修改后写回存储并返回 NoteDetail.ets → saveNote()
删除笔记 点击 🗑️ 按钮移除 Index.ets → deleteNote()
数据持久化 @StorageLink 自动保存到本地 两个页面共享
列表自动刷新 返回时通过 onPageShow 重新加载 Index.ets → onPageShow()
空状态提示 无笔记时显示引导文字 Index.ets → build()

四、完整代码实现

在开始写代码之前,先明确一下 ArkTS 的一些语法约束。与标准 TypeScript 不同,ArkTS 为了运行时性能和安全考虑,做了一些语法限制:不允许使用数组展开运算符(...arr),不允许使用 mapfilterreduce 等高阶函数,不允许使用对象展开({...obj})。我们在笔记应用中将严格遵守这些约束,所有数组操作都使用 concatfor 循环实现。

五、核心技术实现

笔记应用涉及两个页面之间的协同工作,核心逻辑集中在三个方面:页面路由与参数传递、数据持久化的跨页面同步、以及笔记的新建与编辑复用。

4.1 页面路由配置

笔记应用包含两个页面,需要在路由配置文件中注册。打开 entry/src/main/resources/base/profile/main_pages.json

{
  "src": [
    "pages/Index",
    "pages/NoteDetail"
  ]
}

这是多页面开发中最容易被忽略的一步。如果只写了 Index 而没有注册 NoteDetail,IDE 在编译时不会报错,但运行时跳转会失败并显示白屏。每次新增页面,都必须在这里注册。

4.2 状态管理与持久化

笔记应用使用两种装饰器管理数据:

@State:驱动 UI 渲染。notes 数组是列表页的核心数据源,任何增删改操作后重新赋值都会触发 UI 自动更新。

@StorageLink:实现数据持久化。它绑定到键名 'notes_data',变量赋值时自动写入设备本地存储,应用启动时自动读取恢复。

两个页面的配合方式:

// Index.ets
@State notes: Note[] = []
@StorageLink('notes_data') savedNotes: string = '[]'

// NoteDetail.ets
@StorageLink('notes_data') savedNotes: string = '[]'

关键设计要点是两个页面的 @StorageLink 使用了完全相同的键名 'notes_data'。这意味着它们操作的是同一个底层存储——NoteDetail 写入数据后,Index 能立即通过 loadNotes() 读取到最新内容。

4.3 页面跳转与参数传递

从列表页到详情页的跳转使用 router.pushUrl

private openNewNote(): void {
  router.pushUrl({
    url: 'pages/NoteDetail',
    params: { noteId: -1 }  // -1 表示新建
  })
}

private openNote(id: number): void {
  router.pushUrl({
    url: 'pages/NoteDetail',
    params: { noteId: id }  // 实际 ID 表示编辑
  })
}

在详情页中接收参数:

aboutToAppear(): void {
  const params = router.getParams() as Record<string, Object>
  if (params) {
    this.noteId = params['noteId'] as number
  }
  if (this.noteId !== -1) {
    this.loadNote()  // 编辑模式:加载已有内容到输入框
  }
  // ID 为 -1 时保持空白输入框,等待用户输入
}

通过 noteId = -1 这个约定值来区分新建和编辑,避免了为这两种模式分别编写两个页面。当 noteId 为 -1 时详情页保持空白,等待用户输入;当 noteId 为有效值时,自动从存储中查找并加载对应的笔记内容到输入框。

4.4 笔记保存流程

保存是笔记应用最核心的操作,涉及数据读取、新建/编辑判断、写入存储和页面返回四个步骤:

private saveNote(): void {
  // 第一步:从 @StorageLink 读取已有数据
  let notes: Note[] = []
  if (this.savedNotes && this.savedNotes !== '[]') {
    notes = JSON.parse(this.savedNotes)
  }

  // 第二步:判断是新建还是编辑
  if (this.noteId === -1) {
    // 新建:生成新 ID
    let maxId = 0
    for (let i = 0; i < notes.length; i++) {
      if (notes[i].id > maxId) maxId = notes[i].id
    }
    const newNote: Note = {
      id: maxId + 1,
      title: this.titleText,
      content: this.contentText,
      timestamp: Date.now()
    }
    notes = [newNote].concat(notes)
  } else {
    // 编辑:遍历查找并替换
    const newNotes: Note[] = []
    for (let i = 0; i < notes.length; i++) {
      if (notes[i].id === this.noteId) {
        newNotes.push({
          id: notes[i].id,
          title: this.titleText,
          content: this.contentText,
          timestamp: Date.now()
        })
      } else {
        newNotes.push(notes[i])
      }
    }
    notes = newNotes
  }

  // 第三步:写回持久化存储
  this.savedNotes = JSON.stringify(notes)
  // 第四步:返回列表页
  router.back()
}

保存完成后 router.back() 返回列表页。此时列表页的 onPageShow() 生命周期触发,从 @StorageLink 重新加载数据,界面自动刷新。

完整的保存数据流如下:

用户填写内容 → 点击保存
  → 解析 @StorageLink 中的 JSON 字符串为数组
  → 如果是新建:生成自增 ID,插入数组最前面
  → 如果是编辑:遍历数组,替换匹配 ID 的笔记
  → 将数组序列化为 JSON 写回 @StorageLink
  → router.back() 返回列表页
  → 列表页 onPageShow() 触发
  → loadNotes() 从 @StorageLink 读取最新数据
  → 赋值给 @State notes → UI 自动更新

4.5 两个生命周期函数的配合

列表页使用了两个生命周期函数:

aboutToAppear(): void {
  this.loadNotes()  // 页面首次创建时加载
}

onPageShow(): void {
  this.loadNotes()  // 每次页面变为可见时加载
}

两者的区别在于触发时机:aboutToAppear 只在页面实例化时执行一次,适合做一次性初始化;onPageShow 每次页面显示到前台时都会执行,包括从其他页面返回时。如果没有 onPageShow,用户从详情页保存返回后,列表页不会刷新,新添加的笔记不会显示。

4.6 UI 构建详解

列表页使用 List + ListItem 实现高效的滚动列表:

List({ space: 10 }) {
  ForEach(this.sortedNotes, (note: Note) => {
    ListItem() {
      Row() {
        Column() {
          Text(note.title.length > 0 ? note.title : '无标题')
            .fontSize(18).fontWeight(FontWeight.Medium).fontColor(Color.White)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })

          Text(this.getPreview(note.content))
            .fontSize(14).fontColor('#8E8E93')
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })

          Text(this.formatDate(note.timestamp))
            .fontSize(12).fontColor('#555555')
        }
        .layoutWeight(1)

        Button('🗑️')
          .fontSize(18).width(40).height(40)
          .backgroundColor(Color.Transparent)
          .onClick(() => { this.deleteNote(note.id) })
      }
      .backgroundColor('#1C1C1E').borderRadius(12)
      .onClick(() => { this.openNote(note.id) })
    }
  }, (note: Note) => note.id.toString())
}

每张卡片显示三个信息层级:标题(18 号加粗白色)、内容摘要(14 号灰色,限制 40 字)、时间戳(12 号浅灰色),形成清晰的视觉层次。textOverflow 配合 maxLines(1) 实现单行省略号效果,防止长文本破坏布局。

详情页使用 TextInputTextArea 分别处理标题和正文:

TextInput({ placeholder: '输入标题...', text: this.titleText })
  .fontSize(22).fontWeight(FontWeight.Bold)
  .fontColor(Color.White).placeholderColor('#555555')
  .backgroundColor(Color.Transparent).height(56)
  .onChange((value: string) => { this.titleText = value })

Divider().height(1).color('#333333').width('92%')

TextArea({ placeholder: '开始记录...', text: this.contentText })
  .fontSize(16).fontColor(Color.White).placeholderColor('#555555')
  .backgroundColor(Color.Transparent).layoutWeight(1)
  .onChange((value: string) => { this.contentText = value })

TextInput 适合单行输入(标题),TextArea 支持多行文本且通过 layoutWeight(1) 自动占满剩余空间,适合笔记正文的书写。

4.7 删除与排序

删除操作和待办应用一样,使用不可变更新模式:

private deleteNote(id: number): void {
  const newNotes: Note[] = []
  for (let i = 0; i < this.notes.length; i++) {
    if (this.notes[i].id !== id) {
      newNotes.push(this.notes[i])
    }
  }
  this.notes = newNotes
  this.saveNotes()
}

列表排序通过 getter 属性实现按时间倒序:

get sortedNotes(): Note[] {
  const result: Note[] = []
  for (let i = this.notes.length - 1; i >= 0; i--) {
    result.push(this.notes[i])
  }
  return result
}

五、运行与测试

5.1 文件操作

在 DevEco Studio 中完成以下三步:

  1. 替换 Index.ets:打开 entry/src/main/ets/pages/Index.ets,全选替换为列表页代码
  2. 新建 NoteDetail.ets:在 pages 目录上右键 → New → File,命名为 NoteDetail.ets,粘贴详情页代码
  3. 更新路由:打开 resources/base/profile/main_pages.json,确保注册了两个页面

5.2 功能测试

编号 测试操作 预期结果
1 首次启动应用 显示空状态引导:“还没有笔记,点右上角新建”
2 点击右上角"新建" 跳转到详情页,标题和内容均为空
3 输入标题"会议记录"和内容 → 点保存 返回列表,显示新笔记卡片
4 点击笔记卡片 跳转到详情页,标题和内容已填充
5 修改内容 → 点保存 返回列表,内容已更新
6 点 🗑️ 删除按钮 笔记从列表中消失
7 连续添加 5 条笔记 按时间倒序排列,最新在最上方
8 退出应用重新打开 所有笔记数据依然存在

5.3 常见编译问题

在编译笔记应用时,有以下几个容易踩坑的地方:

问题 1:找不到页面
如果运行时提示页面不存在,检查 main_pages.json 是否注册了 NoteDetail。这是多页面开发最常见的错误——IDE 不会在编译时检查页面是否存在,只有在运行时跳转才会报错。

问题 2:数据不刷新
如果保存后返回列表页发现数据没有更新,检查 Index.ets 中是否有 onPageShow() 方法。如果只用了 aboutToAppear(),返回时不会触发数据重新加载。

问题 3:@StorageLink 键名不一致
两个页面使用 @StorageLink 时必须使用完全相同的键名。一个写 'notes_data',另一个写 'notesData',会导致数据无法同步。建议将键名定义为常量字符串,两处保持一致。

六、运行效果

在这里插入图片描述
在这里插入图片描述


七、与其他项目的对比

将笔记应用和之前开发的计算器、待办应用做横向对比,可以看出鸿蒙开发技能的递进关系:

对比维度 计算器 待办应用 笔记应用
页面数量 1 个 1 个 2 个
路由跳转 pushUrl + back
参数传递 router.getParams
跨页面数据 @StorageLink 共享
生命周期 无特殊需求 aboutToAppear aboutToAppear + onPageShow
输入组件 Button 点击 TextInput TextInput + TextArea
核心复杂度 运算逻辑 增删改查 多页面协作

从表格可以清楚看到,每一个项目都在前一项目的基础上引入了新的知识点。如果你顺序完成了这三个项目,你接触到的 ArkUI 知识已经覆盖了:

  • 基础组件:Text、Button、TextInput、TextArea、List、Divider
  • 状态管理:@State、@StorageLink
  • 生命周期:aboutToAppear、onPageShow、aboutToDisappear
  • 路由:pushUrl、back、getParams
  • 数据处理:JSON 序列化、不可变数组更新、时间格式化

这些知识足够支撑你开发大部分工具类鸿蒙应用。

八、扩展思路

  1. 搜索功能:在列表页顶部增加搜索框,使用 includes 方法根据标题或内容实时筛选笔记
  2. 富文本编辑:引入 RichEditor 组件,支持加粗、列表、图片插入等富文本格式
  3. 分类标签:为笔记增加 category 字段,支持按标签分类筛选
  4. 深浅主题:参考计算器扩展篇,增加深色/浅色主题切换功能
  5. 回收站机制:删除的笔记先标记为 deleted 而非直接移除,支持 30 天内恢复

九、总结

本文从零开始完成了鸿蒙笔记应用的开发,这是继计算器和待办应用之后的第三个完整项目。与之前两个项目不同,笔记应用的核心挑战不在于复杂的运算逻辑或精细的 UI 设计,而在于多页面之间的数据协作。两个页面如何共享数据?返回时如何自动刷新?新建和编辑如何复用同一个页面?这些问题的解决方案,构成了本文最值得关注的内容。

核心知识点对照如下:

知识点 具体应用 与待办 App 的差异
多页面路由 router.pushUrl 跳转 + router.back 返回 待办 App 只有单页面
参数传递 params 传递 noteId,区分新建与编辑 新增知识点
跨页面数据同步 两个页面共享同一个 @StorageLink 键名 新增知识点
双生命周期 aboutToAppear + onPageShow 配合 新增 onPageShow
文本输入 TextInput 单行 + TextArea 多行 待办只有 TextInput
不可变更新 concat + for 循环构建新数组 与待办一致

从计算器到待办应用,再到笔记应用,ArkUI 的知识体系逐步深入。计算器侧重单一页面的交互逻辑,待办应用引入列表和数据持久化,笔记应用则进一步扩展到多页面协作、路由跳转和参数传递。掌握了这三个项目,你已经具备了独立开发鸿蒙工具类应用的基础能力。后续可以尝试挑战更复杂的项目——结合网络请求的天气应用、使用 Canvas 绘图的绘图板、或接入华为帐号服务的登录应用。鸿蒙生态的发展空间非常广阔,期待你做出更多有趣的应用。

下一步可以挑战更复杂的项目——结合网络请求的天气应用、使用 Canvas 绘图的白板应用、或者接入华为帐号服务的登录应用。鸿蒙生态的开发空间非常广阔。

Logo

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

更多推荐