一、前言

在前两篇文章中,我们完成了鸿蒙计算器的完整开发,已经掌握了 ArkUI 页面布局、状态管理、按钮事件处理等核心技能。今天我们将这些技能应用到另一个经典项目——待办事项应用(Todo List)

待办事项 App 和计算器一样,是移动端最经典的入门项目之一。但相比于计算器,它覆盖了 ArkUI 开发中更多重要的知识点:输入框组件、列表渲染、增删改查操作、以及数据持久化。如果说计算器让你学会了"怎么处理用户点击",那么待办 App 将让你学会"怎么管理用户数据"。

为什么选择待办 App 作为第二个项目?因为它是绝大多数人手机上都会安装的工具类应用,功能明确、需求清晰,而且增删改查是几乎所有应用(通讯录、备忘录、购物清单)的基础模式。做完这个项目,你可以轻松举一反三,开发出各种列表型应用。


二、项目准备

2.1 开发环境要求

  • IDE:DevEco Studio(推荐最新版本)
  • SDK:API 23 或以上,在 SDK Manager 中确认已安装
  • 语言/框架:ArkTS + ArkUI

2.2 新建项目

打开 DevEco Studio,按以下步骤操作:

  1. 点击 File → New → Create Project
  2. 选择 Empty Ability 模板
  3. 填写项目信息:
配置项 推荐值
项目名称 TodoList
包名 com.example.todolist
Compile SDK API 23
模块名称 entry
  1. 点击 Finish 完成创建

创建完成后等待 IDE 自动同步。同步成功后,可以看到标准的 HarmonyOS 项目结构,我们只需要修改 entry/src/main/ets/pages/Index.ets 这一个文件。


三、功能设计

3.1 界面布局

待办事项 App 采用经典的分层结构,从上到下依次是:标题栏、输入区、列表区、底部统计栏:

┌─────────────────────────────┐
│                             │
│   ✅ 今日待办 (3/5)          │  ← 顶部标题 + 实时进度
│                             │
├─────────────────────────────┤
│  ┌───────────────────────┐  │
│  │ 输入新待办...     ➕    │  │  ← TextInput + 添加按钮
│  └───────────────────────┘  │
├─────────────────────────────┤
│  ☐ 买牛奶                   │  ← 未完成(空心圆)
│  ☑ 交电费                   │  ← 已完成(✅ + 删除线)
│  ☐ 写周报                   │
│  ☐ 健身                     │
│                             │
├─────────────────────────────┤
│     共 4 项    清除已完成    │  ← 底部统计 + 操作
└─────────────────────────────┘

3.2 功能清单

功能 详细说明
添加待办 在输入框输入文本,点击 ➕ 按钮或按回车添加
标记完成 点击左侧 ○/✅ 切换待办的完成状态
删除待办 点击右侧 🗑️ 按钮移除指定待办
实时统计 顶部显示"已完成数 / 总数",底部显示"共 N 项"
清除已完成 一键删除所有已完成项,只保留未完成的
空输入保护 输入为空或只有空格时不添加
数据持久化 关闭 App 后数据不会丢失,下次打开自动恢复

四、完整代码实现

4.1 数据模型

首先定义待办事项的数据结构:

interface TodoItem {
  id: number       // 唯一标识,用于区分和操作每条待办
  text: string     // 待办文本内容
  done: boolean    // 完成状态:false=未完成,true=已完成
}

在 ArkTS 中使用 interface 定义数据类型,可以确保后续操作中字段名的一致性,避免拼写错误。

4.2 状态变量

@State todos: TodoItem[] = []          // 全部待办列表(响应式)
@State inputText: string = ''           // 输入框文本(响应式)
@StorageLink('todos_data') savedTodos: string = '[]'  // 持久化

private nextId: number = 1              // 下一个可用的 ID(非 UI 状态)

这里我们用到了一种新的装饰器:@StorageLink。它的作用是将变量与设备的本地存储自动同步——变量赋值时自动写入存储,App 启动时自动读取恢复。字符串参数 'todos_data' 是存储键名,需要保证唯一性。

对比一下 @State@StorageLink 的区别:

装饰器 生命周期 用途
@State 页面存续期间 临时 UI 状态,刷新即失
@StorageLink 设备存续期间 用户数据,关闭 App 还在

4.3 添加待办

private addTodo(): void {
  const text = this.inputText.trim()
  if (text === '') return  // 空内容不添加

  const newItem: TodoItem = {
    id: this.nextId++,      // 自增 ID,保证唯一
    text: text,
    done: false
  }
  this.todos = [newItem].concat(this.todos)  // 新项插入最前面
  this.inputText = ''                        // 清空输入框
}

为什么不用 this.todos.unshift(newItem) 呢?因为 @State 检测的是变量引用是否变化——unshift 修改的是原数组,引用不变,ArkUI 不会触发渲染。而 concat 返回新数组,引用变了,UI 自动更新。

注意这里没用 [newItem, ...this.todos] 的展开运算符写法,因为 ArkTS 编译器禁止对数组使用展开语法(规则 arkts-no-spread)。改用 concat 达到同样效果。

4.4 切换完成状态

private toggleTodo(id: number): void {
  const newTodos: TodoItem[] = []
  for (let i = 0; i < this.todos.length; i++) {
    const item = this.todos[i]
    if (item.id === id) {
      // 创建新对象,改变 done 状态
      newTodos.push({ id: item.id, text: item.text, done: !item.done })
    } else {
      newTodos.push(item)
    }
  }
  this.todos = newTodos
}

ArkTS 不支持 map 和数组展开运算符 {...item},所以这里用 for 循环手动构建新数组。需要修改的那一项创建一个新的对象,其他项直接引用原对象,既保证了不可变性,又避免了不必要的对象创建。

4.5 删除待办

private deleteTodo(id: number): void {
  const newTodos: TodoItem[] = []
  for (let i = 0; i < this.todos.length; i++) {
    if (this.todos[i].id !== id) {
      newTodos.push(this.todos[i])
    }
  }
  this.todos = newTodos
}

ArkTS 不支持 filter,用 for 循环 + if 跳过要删除的项,实现同样的过滤效果。

4.6 清除已完成

private clearDone(): void {
  const newTodos: TodoItem[] = []
  for (let i = 0; i < this.todos.length; i++) {
    if (!this.todos[i].done) {
      newTodos.push(this.todos[i])
    }
  }
  this.todos = newTodos
}

4.7 数据持久化

使用 @StorageLink 后,持久化的读写变得非常简单:

private saveTodos(): void {
  this.savedTodos = JSON.stringify(this.todos)
}

private loadTodos(): void {
  if (this.savedTodos && this.savedTodos !== '[]') {
    this.todos = JSON.parse(this.savedTodos)
    // 恢复 nextId:取最大 ID + 1
    let maxId = 0
    for (let i = 0; i < this.todos.length; i++) {
      if (this.todos[i].id > maxId) {
        maxId = this.todos[i].id
      }
    }
    this.nextId = maxId + 1
  }
}

aboutToAppear(): void {
  this.loadTodos()
}

aboutToAppear 是 ArkUI 的生命周期回调函数,在页面初始化完成后、UI 渲染之前自动调用。我们在这里读取之前保存的数据,恢复到 todos 数组中。

数据恢复后还要同步 nextId。假如用户之前添加了 5 条待办(ID 1-5),删除 3 条后剩下 ID 为 1、3、5 的三条。如果不恢复 nextId,下次添加时会从 1 开始,和已有的 ID 冲突。所以我们遍历现有数据找到最大 ID,加 1 作为下一个可用 ID。

4.8 计算属性

get completedCount(): number {
  return this.todos.filter(item => item.done).length
}

get totalCount(): number {
  return this.todos.length
}

get hasCompleted(): boolean {
  return this.completedCount > 0
}

计算属性(getter)是 ArkTS 中非常实用的功能,它们像普通属性一样使用(this.completedCount),但实际上每次访问都会重新计算。因为 todos@State 变量,当它变化时,所有依赖它的 getter 都会自动重新求值,UI 随之刷新。

4.9 完整 UI 构建

build() {
  Column() {
    // ═══ 顶部标题栏 ═══
    Row() {
      Text('✅ 今日待办')
        .fontSize(26).fontWeight(FontWeight.Bold).fontColor(Color.White)
      Blank()
      Text(`${this.completedCount} / ${this.totalCount}`)
        .fontSize(18).fontColor('#8E8E93')
    }
    .width('100%').padding(20)

    // ═══ 输入区域 ═══
    Row() {
      TextInput({ placeholder: '输入新待办...', text: this.inputText })
        .layoutWeight(1)
        .height(48).backgroundColor('#2C2C2E').borderRadius(12)
        .padding({ left: 16 }).fontColor(Color.White)
        .placeholderColor('#8E8E93')
        .onChange((value: string) => { this.inputText = value })
        .onSubmit(() => { this.addTodo() })  // 回车提交

      Button('➕')
        .fontSize(22).width(48).height(48).borderRadius(24)
        .backgroundColor('#FF9F0A').margin({ left: 8 })
        .onClick(() => { this.addTodo() })
    }
    .width('100%').padding({ left: 20, right: 20 })

    // ═══ 待办列表 ═══
    List({ space: 8 }) {
      ForEach(this.todos, (item: TodoItem) => {
        ListItem() {
          Row() {
            // 勾选按钮:未完成显示 ⭕,已完成显示 ✅
            Button(item.done ? '✅' : '⭕')
              .fontSize(18).width(36).height(36)
              .backgroundColor(Color.Transparent)
              .onClick(() => { this.toggleTodo(item.id) })

            // 待办文本:已完成加删除线
            Text(item.text)
              .fontSize(18)
              .fontColor(item.done ? '#555555' : Color.White)
              .decoration({
                type: item.done
                  ? TextDecorationType.LineThrough
                  : TextDecorationType.None
              })
              .layoutWeight(1).padding({ left: 8 })

            // 删除按钮
            Button('🗑️')
              .fontSize(16).backgroundColor(Color.Transparent)
              .onClick(() => { this.deleteTodo(item.id) })
          }
          .width('100%').padding(12)
          .backgroundColor('#1C1C1E').borderRadius(12)
        }
      }, (item: TodoItem) => item.id.toString())  // 唯一 key
    }
    .layoutWeight(1).width('100%')
    .padding({ left: 20, right: 20 })

    // ═══ 底部统计栏 ═══
    Row() {
      Text(`${this.totalCount}`)
        .fontSize(14).fontColor('#8E8E93')
      Blank()
      if (this.hasCompleted) {
        Text('清除已完成')
          .fontSize(14).fontColor('#FF9F0A')
          .onClick(() => { this.clearDone() })
      }
    }
    .width('100%').padding(20)
    .backgroundColor('#1C1C1E')
    .borderRadius({ topLeft: 16, topRight: 16 })
  }
  .width('100%').height('100%').backgroundColor('#000000')
}

UI 中的几个关键组件说明:

  • TextInput:ArkUI 的文本输入框组件。placeholder 设置占位提示文字,onChange 监听输入变化,onSubmit 监听键盘回车事件
  • List + ListItem:高效的虚拟滚动列表,只渲染可见区域的列表项,数据量大时也不会卡顿
  • ForEach 的 key(item: TodoItem) => item.id.toString() 为每个列表项提供唯一标识,帮助 ArkUI 精确追踪组件变化
  • decoration:Text 组件的装饰属性,这里用 LineThrough 实现已完成事项的删除线效果
  • Blank():弹性占位组件,将左右两侧的元素推开

4.10 为什么每次都要创建新数组

初学 ArkTS 的开发者可能会问:为什么增删改都要创建新数组?直接用 this.todos.push()this.todos.splice() 不是更简单吗?

原因在于 ArkUI 的变更检测机制。@State 装饰器通过引用比较来判断一个变量是否发生了变化:

// ❌ 不触发 UI 更新
this.todos.push(newItem)      // 数组引用没变,UI 不变

// ✅ 触发 UI 更新
this.todos = [newItem].concat(this.todos)  // 新数组,新引用,UI 刷新

这就是为什么我们的增删改操作都创建新数组:concat 连接、for 循环构建新数组。这个模式叫做不可变更新(Immutable Update),在 React、Vue 等现代前端框架中也非常常见。

4.11 保存时机优化

每次增删改后立即保存可能会频繁写入存储,但 @StorageLink 底层做了异步批量处理,对性能影响很小。如果要进一步优化,可以在 aboutToDisappear 时一次性保存:

aboutToDisappear(): void {
  this.savedTodos = JSON.stringify(this.todos)
}

这样只有离开页面时才写入一次。但缺点是如果 App 被系统强杀(比如耗完电池自动关机),最后的状态可能来不及保存。权衡之下,每次操作后立即保存是更稳妥的做法。


五、运行与测试

5.1 替换代码

打开 entry/src/main/ets/pages/Index.ets,全选替换为上面提供的完整代码。IDE 会自动编译,等待几秒即可。

5.2 启动模拟器

点击右侧 Device Manager,创建并启动手机模拟器。首次启动可能需要 2-5 分钟。

5.3 功能测试

# 测试操作 预期结果
1 输入"买牛奶" → 点 ➕ 列表显示"买牛奶",顶部变为 0/1
2 直接点 ➕(不输入) 无反应,空输入保护生效
3 输入" "(空格) → 点 ➕ 无反应,trim() 后为空
4 点 ⭕ 切换完成 变为 ✅,文字加删除线,顶部 1/1
5 再点 ✅ 恢复为 ⭕,删除线消失,顶部 0/1
6 添加多条,点 🗑️ 删除 该条消失,统计数字减少
7 标记 2 条完成 → 清除已完成 已完成消失,未完成保留
8 添加 5 条,退出 App 重开 所有数据还在
9 键盘回车添加 和点 ➕ 效果一样
10 连续快速添加 每条都正常显示,ID 不重复

六、运行效果

在这里插入图片描述


通过以上完整的开发过程可以发现,一个功能全面的待办 App 其实只需要不到 200 行代码就能实现。这得益于 ArkUI 声明式框架的高效表达力——用最少的代码描述最多的界面和交互逻辑。

七、扩展思路

一个基础版待办 App 做完后,可以通过以下方向继续深化:

  1. 分类标签:增加"工作 💼 / 生活 🏠 / 学习 📚"分类,按标签筛选显示
  2. 优先级:高/中/低三级,高优先级用红色标记
  3. 截止日期:集成 DatePicker 选择截止日期,超时自动变红
  4. 编辑功能:点击待办文本弹出编辑框,支持修改内容
  5. 排序拖拽:长按拖拽调整待办顺序
  6. 搜索功能:增加搜索框,实时过滤待办列表

每个扩展方向都对应了 ArkUI 的一个新知识点,比如 DatePicker 日期组件、动画 API、搜索过滤等。


八、总结

本文从零开始完成了鸿蒙待办事项 App 的开发,核心知识点回顾:

  • 数据模型:使用 interface 定义清楚的 TodoItem 结构
  • 状态管理@State 驱动 UI,@StorageLink 持久化保存
  • 数组不可变性:用 concatfor 循环创建新数组而非直接修改原数组
  • 列表渲染List + ListItem + ForEach 实现高效列表
  • 生命周期aboutToAppear 初始化数据,aboutToDisappear 善后
  • getter 计算属性completedCounttotalCount 实时计算
  • 条件渲染:只有有完成项时才显示"清除已完成"按钮
  • 条件样式:已完成文字加删除线,颜色变灰

待办 App 是鸿蒙开发入门阶段非常值得动手实践的项目。它比计算器更贴近实际应用场景,代码量适中,但覆盖的知识面更广。掌握了计算器和待办这两个项目,你已经具备了独立开发简单工具类鸿蒙应用的能力。

Logo

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

更多推荐