📋 项目信息

项目信息 内容
项目名称 待办事项应用(TodoList)
开发工具 DevEco Studio 5.0
开发语言 ArkTS
API版本 23
项目类型 Application
包名 com.example.todolist
开发时间 约6小时(包含调试)

第一部分:需求分析与技术选型

1.1 需求分析

1.1.1 核心功能

在我开始写代码之前,我先明确了这个待办事项应用需要哪些功能:

基础功能(必须有)

  • ✅ 添加新的待办事项
  • ✅ 标记待办事项为已完成
  • ✅ 删除待办事项
  • ✅ 显示待办事项列表

进阶功能(可以有)

  • ✅ 统计已完成/总数
  • ✅ 一键清除已完成事项
  • ✅ 数据持久化(应用关闭后数据不丢失)

可选功能(暂时不做)

  • ❌ 待办事项分类(工作/生活/学习)
  • ❌ 设置提醒时间
  • ❌ 云同步
  • ❌ 语音输入
1.1.2 用户体验设计

我参考了iOS的"提醒事项"应用和Android的"Google Tasks"应用,确定了以下设计原则:

  1. 简洁直观:界面不能太复杂,一眼就能看懂
  2. 操作流畅:添加、完成、删除操作不能超过2次点击
  3. 视觉反馈:完成状态要有明确的视觉区分(比如删除线)
  4. 数据持久化:关闭应用后再打开,数据还在

1.2 技术选型

1.2.1 数据存储方案对比

在HarmonyOS中,有多种数据存储方案可以选择:

存储方案 优点 缺点 适用场景 我的选择
@StorageLink 1. API简单
2. 自动持久化
3. 支持复杂对象
1. 性能一般
2. 不适合大量数据
✅ 小型应用
✅ 配置数据
选择这个
Preferences 1. 轻量级
2. 性能好
1. 只支持基本类型
2. 不支持复杂对象
✅ 用户设置
❌ 不适合待办事项
❌ 不选择
关系型数据库 1. 支持复杂查询
2. 性能好
1. API复杂
2. 需要建表
❌ 大型应用
❌ 不适合简单待办
❌ 不选择
分布式数据管理 1. 支持跨设备同步 1. 非常复杂
2. 需要华为账号
❌ 云同步场景 ❌ 不选择

最终选择@StorageLink

理由

  1. API非常简单(类似LocalStorage)
  2. 自动持久化(不需要手动调用保存方法)
  3. 支持存储复杂对象(数组、对象)
  4. 对于待办事项这种小型应用,性能完全够用
1.2.2 UI框架选择

HarmonyOS提供了多种UI开发框架:

UI框架 特点 我的选择
ArkUI (Declarative) 1. 声明式UI
2. 响应式数据绑定
3. 组件化
选择这个
ArkUI (Classic) 1. 命令式UI
2. 类似Android View系统
❌ 不选择
Java UI 1. 传统命令式UI
2. 已被废弃
❌ 不选择

最终选择ArkUI (Declarative)

理由

  1. 声明式UI开发效率更高
  2. 响应式数据绑定,代码更简洁
  3. 官方主推,未来会得到更好支持

第二部分:项目搭建与基础结构设计

2.1 创建项目

步骤1:打开DevEco Studio

(假设你已经安装好了DevEco Studio,这里不再赘述环境搭建过程)

步骤2:新建项目
  1. 点击 “New Project”
  2. 选择 “Empty Ability” 模板
  3. 点击 “Next”
步骤3:配置项目信息
Project Name: TodoList
Bundle Name: com.example.todolist
Save Location: E:\HMproject\Project\TodoList
Compile SDK: API 23

点击 “Finish”,等待项目创建完成。

2.2 定义数据结构

entry/src/main/ets/pages/Index.ets 文件中,我首先定义了待办事项的数据结构:

interface TodoItem {
  id: number      // 唯一标识符
  text: string    // 待办事项内容
  done: boolean   // 是否已完成
}

为什么需要 id 字段?

一开始我其实没有设计 id 字段,而是用数组索引来标识每一个待办事项。但是后来发现了一个问题:

问题场景

待办事项列表:
索引0: { text: "学习HarmonyOS", done: false }
索引1: { text: "写博客", done: false }
索引2: { text: "跑步", done: false }

如果我删除索引1的待办事项("写博客"),
那么原来的索引2("跑步")会变成新的索引1。

这时候如果用户快速点击删除按钮,可能会删错 item!

解决方案:给每个待办事项分配一个唯一的 id,用 id 来标识待办事项,而不是用数组索引。

id 的生成策略

我使用了一个简单的自增ID策略:

  • 每新增一个待办事项,nextId 加1
  • nextId 从1开始
private nextId: number = 1

private addTodo(): void {
  const newItem: TodoItem = {
    id: this.nextId++,  // 使用当前nextId,然后自增
    text: text,
    done: false
  }
  // ...
}

为什么不用时间戳作为 id

我考虑过用 Date.now() 作为 id,但是有以下问题:

  1. 不够直观:调试时看到 id: 1710746132000,不知道是第几个待办事项
  2. 可能重复:如果在同一毫秒内添加两个待办事项(虽然概率很低),会 id 冲突

所以最终选择了自增ID策略。

2.3 设计状态管理

在ArkUI中,状态管理是非常核心的概念。我需要管理以下状态:

@Entry
@Component
struct Index {
  @State todos: TodoItem[] = []           // 待办事项列表
  @State inputText: string = ''            // 输入框的文本
  @StorageLink('todos_data') savedTodos: string = '[]'  // 持久化存储
  private nextId: number = 1              // 下一个待办事项的ID
  
  // ...
}

状态变量详解

状态变量 装饰器 作用 为什么用这个装饰器
todos @State 待办事项列表 UI需要响应这个状态的变化(添加/删除/修改待办事项时,UI要更新)
inputText @State 输入框的文本 UI需要响应这个状态的变化(用户输入时,输入框要实时显示)
savedTodos @StorageLink 持久化存储 需要将数据持久化到本地,并且数据变化时自动保存
nextId 无装饰器 下一个待办事项的ID 这个值变化不需要驱动UI更新,所以不需要装饰器

@State vs @StorageLink

装饰器 作用 数据持久化 适用场景
@State 组件内部状态,变化时会驱动UI更新 ❌ 不持久化 ✅ 临时状态
✅ UI相关的状态
@StorageLink 与持久化存储双向绑定,变化时会驱动UI更新 ✅ 自动持久化 ✅ 需要持久化的状态
✅ 跨页面共享的状态

我的用法

  • todos@State:因为 todos 的变化需要立即驱动UI更新
  • savedTodos@StorageLink:因为需要将 todos 序列化后存储到本地

两者配合工作:

  1. 当用户添加/删除/修改待办事项时,todos 变化,UI更新
  2. 同时,我手动将 todos 序列化成JSON字符串,保存到 savedTodos
  3. savedTodos 会自动持久化到本地存储
  4. 应用下次启动时,从 savedTodos 读取数据,反序列化成 todos

第三部分:核心功能实现(附完整代码与详解)

3.1 添加待办事项

3.1.1 功能描述

用户可以在输入框中输入待办事项的内容,然后点击"➕"按钮或按回车键,将待办事项添加到列表中。

交互细节

  1. 输入框为空时,点击"➕"按钮无反应
  2. 输入框有内容时,点击"➕"按钮或按回车键,将待办事项添加到列表顶部
  3. 添加成功后,清空输入框
3.1.2 代码实现
private addTodo(): void {
  const text = this.inputText.trim()
  if (text === '') return

  const newItem: TodoItem = {
    id: this.nextId++,
    text: text,
    done: false
  }
  this.todos = [newItem].concat(this.todos)
  this.inputText = ''
  this.saveTodos()
}
3.1.3 代码详解

第1-3行:输入校验

const text = this.inputText.trim()
if (text === '') return
  • this.inputText.trim()

    • 去掉字符串首尾的空格
    • 示例:" 学习HarmonyOS ""学习HarmonyOS"
    • 为什么要 trim()
      • 如果用户输入了全空格(比如误触),不应该添加为空待办事项
      • 如果用户输入了前后空格,应该自动去掉
  • if (text === '') return

    • 如果去掉空格后为空字符串,直接返回,不添加待办事项

第5-9行:创建新的待办事项

const newItem: TodoItem = {
  id: this.nextId++,
  text: text,
  done: false
}
  • const newItem: TodoItem

    • 创建一个 TodoItem 对象
    • const 表示这个引用不能被修改(但是对象的属性可以被修改)
  • id: this.nextId++

    • 使用当前的 nextId 作为 id
    • 然后 nextId 自增1(因为用了 ++ 后缀运算符)
    • 示例:
      • 如果 nextId1,那么 id1,然后 nextId 变成 2
      • 如果 nextId2,那么 id2,然后 nextId 变成 3
  • done: false

    • 新添加的待办事项,默认是未完成状态

第11行:将新待办事项添加到列表顶部

this.todos = [newItem].concat(this.todos)
  • [newItem]

    • 创建一个只包含 newItem 的数组
  • .concat(this.todos)

    • 将原来的 this.todos 数组拼接到后面
    • 返回一个新的数组
  • 为什么不用 this.todos.unshift(newItem)

    • 因为 this.todos 是用 @State 装饰的
    • @State 装饰的数组,必须通过重新赋值来触发UI更新
    • 如果直接调用 unshift(),不会触发UI更新
  • 为什么不这样写?

    this.todos.unshift(newItem)  // ❌ 错误!不会触发UI更新
    
  • 正确的写法

    this.todos = [newItem].concat(this.todos)  // ✅ 正确!会触发UI更新
    

第13行:清空输入框

this.inputText = ''
  • 添加成功后,清空输入框
  • 因为 inputText 是用 @State 装饰的,所以UI会自动更新(输入框会显示为空)

第14行:保存待办事项到本地存储

this.saveTodos()
  • 调用 saveTodos() 方法,将 todos 序列化后保存到 savedTodos
  • 具体实现见后面的 saveTodos() 方法详解
3.1.4 输入框的事件绑定

为了提升用户体验,我不仅绑定了按钮的点击事件,还绑定了输入框的 Submit 事件(用户按回车键时触发):

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() })  // 绑定回车事件

.onChange() 事件

.onChange((value: string) => { this.inputText = value })
  • 当输入框的内容变化时,会触发这个回调
  • value 是最新的输入内容
  • 我将其保存到 this.inputText 中,这样就可以在 addTodo() 方法中获取输入内容

.onSubmit() 事件

.onSubmit(() => { this.addTodo() })
  • 当用户在输入框中按回车键时,会触发这个回调
  • 我调用 this.addTodo() 方法,添加待办事项
  • 这提升了用户体验:用户不需要点击"➕"按钮,直接按回车就可以了

3.2 标记待办事项为已完成

3.2.1 功能描述

用户可以点击待办事项前面的图标(⭕或✅),来切换待办事项的完成状态。

交互细节

  1. 未完成的待办事项,前面显示 ⭕
  2. 已完成的待办事项,前面显示 ✅
  3. 点击图标后,切换完成状态
  4. 已完成的待办事项,文字会有删除线,并且颜色变灰
3.2.2 代码实现
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) {
      newTodos.push({ id: item.id, text: item.text, done: !item.done })
    } else {
      newTodos.push(item)
    }
  }
  this.todos = newTodos
  this.saveTodos()
}
3.2.3 代码详解

为什么不用 Array.map()

其实我一开始是用 Array.map() 来实现的:

// 第一版实现(有bug)
private toggleTodo(id: number): void {
  this.todos = this.todos.map(item => {
    if (item.id === id) {
      return { id: item.id, text: item.text, done: !item.done }
    }
    return item
  })
  this.saveTodos()
}

但是后来发现了一个问题:ESLint报错

error  Insert `⏎`  prettier/prettier

这个错误是因为 Prettier 代码格式化工具要求箭头函数的函数体如果超过一定长度,必须换行。

解决方案:改用 for 循环(也更易于调试)。

第2行:创建新的数组

const newTodos: TodoItem[] = []
  • 我创建了一个新的数组 newTodos
  • 为什么要创建新的数组?
    • 因为 this.todos 是用 @State 装饰的
    • 必须通过重新赋值来触发UI更新
    • 如果直接修改数组中的对象,不会触发UI更新

第3-11行:遍历原数组,找到目标待办事项

for (let i = 0; i < this.todos.length; i++) {
  const item = this.todos[i]
  if (item.id === id) {
    newTodos.push({ id: item.id, text: item.text, done: !item.done })
  } else {
    newTodos.push(item)
  }
}
  • 遍历原数组

    • for 循环遍历 this.todos 数组
  • 判断是否是目标待办事项

    • 如果 item.id === id,说明这就是用户点击的那个待办事项
    • 需要切换它的 done 状态
  • 切换 done 状态

    done: !item.done
    
    • !item.done 表示取反
    • 如果原来是 false(未完成),变成 true(已完成)
    • 如果原来是 true(已完成),变成 false(未完成)
  • 推入新数组

    • 无论是修改了的待办事项,还是未修改的待办事项,都推入 newTodos 数组

第13行:重新赋值给 this.todos

this.todos = newTodos
  • 将新数组赋值给 this.todos
  • 因为 this.todos 是用 @State 装饰的,所以会触发UI更新

第14行:保存待办事项到本地存储

this.saveTodos()
  • 调用 saveTodos() 方法,保存数据
3.2.4 UI中的绑定

在UI中,我是这样绑定 toggleTodo() 方法的:

Button(item.done ? '✅' : '⭕')
  .fontSize(18).width(36).height(36)
  .backgroundColor(Color.Transparent)
  .onClick(() => { this.toggleTodo(item.id) })
  • item.done ? '✅' : '⭕'

    • 根据 item.done 的值,决定显示哪个图标
    • 如果 donetrue,显示 ✅
    • 如果 donefalse,显示 ⭕
  • .onClick(() => { this.toggleTodo(item.id) })

    • 绑定点击事件
    • 点击时,调用 this.toggleTodo(item.id),并传入待办事项的 id

3.3 删除待办事项

3.3.1 功能描述

用户可以点击待办事项右边的删除按钮(🗑️),来删除待办事项。

交互细节

  1. 点击删除按钮后,待办事项立即消失
  2. 不需要确认对话框(提升操作效率)
3.3.2 代码实现
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
  this.saveTodos()
}
3.3.3 代码详解

核心思路:遍历原数组,将不是目标待办事项的元素推入新数组。

第2行:创建新的数组

const newTodos: TodoItem[] = []
  • 创建一个新的数组 newTodos

第3-7行:遍历原数组,过滤掉目标待办事项

for (let i = 0; i < this.todos.length; i++) {
  if (this.todos[i].id !== id) {
    newTodos.push(this.todos[i])
  }
}
  • 遍历原数组

    • for 循环遍历 this.todos 数组
  • 判断是否是目标待办事项

    • 如果 this.todos[i].id !== id,说明这不是用户要删除的待办事项
    • 将其推入 newTodos 数组
  • 如果 this.todos[i].id === id

    • 说明这就是用户要删除的待办事项
    • 不推入 newTodos 数组(相当于过滤掉了)

第9行:重新赋值给 this.todos

this.todos = newTodos
  • 将新数组赋值给 this.todos
  • 触发UI更新

第10行:保存待办事项到本地存储

this.saveTodos()
  • 调用 saveTodos() 方法,保存数据
3.3.4 UI中的绑定

在UI中,我是这样绑定 deleteTodo() 方法的:

Button('🗑️')
  .fontSize(16).backgroundColor(Color.Transparent)
  .onClick(() => { this.deleteTodo(item.id) })
  • .onClick(() => { this.deleteTodo(item.id) })
    • 绑定点击事件
    • 点击时,调用 this.deleteTodo(item.id),并传入待办事项的 id

3.4 清除所有已完成的待办事项

3.4.1 功能描述

用户可以点击底部的"清除已完成"按钮,一键删除所有已完成的待办事项。

交互细节

  1. 只有当有已完成的待办事项时,"清除已完成"按钮才显示
  2. 点击后,所有已完成的待办事项被删除
  3. 未完成的待办事项不受影响
3.4.2 代码实现
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
  this.saveTodos()
}
3.4.3 代码详解

核心思路:遍历原数组,将未完成的待办事项推入新数组。

第2行:创建新的数组

const newTodos: TodoItem[] = []
  • 创建一个新的数组 newTodos

第3-7行:遍历原数组,过滤掉已完成的待办事项

for (let i = 0; i < this.todos.length; i++) {
  if (!this.todos[i].done) {
    newTodos.push(this.todos[i])
  }
}
  • 遍历原数组

    • for 循环遍历 this.todos 数组
  • 判断是否是未完成的待办事项

    • 如果 !this.todos[i].done(即 donefalse),说明这是未完成的待办事项
    • 将其推入 newTodos 数组
  • 如果 this.todos[i].donetrue

    • 说明这是已完成的待办事项
    • 不推入 newTodos 数组(相当于过滤掉了)

第9行:重新赋值给 this.todos

this.todos = newTodos
  • 将新数组赋值给 this.todos
  • 触发UI更新

第10行:保存待办事项到本地存储

this.saveTodos()
  • 调用 saveTodos() 方法,保存数据
3.4.4 UI中的绑定

在UI中,我是这样绑定 clearDone() 方法的:

if (this.hasCompleted) {
  Text('清除已完成')
    .fontSize(14).fontColor('#FF9F0A')
    .onClick(() => { this.clearDone() })
}
  • if (this.hasCompleted)

    • 只有当有已完成的待办事项时,才显示"清除已完成"按钮
    • this.hasCompleted 是一个计算属性(后面会详解)
  • .onClick(() => { this.clearDone() })

    • 绑定点击事件
    • 点击时,调用 this.clearDone() 方法

3.5 数据持久化

3.5.1 功能描述

应用需要将待办事项数据持久化到本地存储,这样用户关闭应用后再打开,数据还在。

交互细节

  1. 每次添加/删除/修改待办事项时,自动保存到本地存储
  2. 应用启动时,自动从本地存储读取数据
3.5.2 保存数据
private saveTodos(): void {
  this.savedTodos = JSON.stringify(this.todos)
}

代码详解

  • JSON.stringify(this.todos)

    • this.todos 数组序列化成JSON字符串
    • 示例:
      this.todos = [
        { id: 1, text: '学习HarmonyOS', done: false },
        { id: 2, text: '写博客', done: true }
      ]
      
      JSON.stringify(this.todos)
      // 返回:
      // '[{"id":1,"text":"学习HarmonyOS","done":false},{"id":2,"text":"写博客","done":true}]'
      
  • this.savedTodos = ...

    • 将JSON字符串赋值给 this.savedTodos
    • 因为 savedTodos 是用 @StorageLink('todos_data') 装饰的
    • 所以会自动持久化到本地存储(key是 'todos_data'
3.5.3 读取数据
private loadTodos(): void {
  if (this.savedTodos && this.savedTodos !== '[]') {
    this.todos = JSON.parse(this.savedTodos)
    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
  }
}

代码详解

第2-3行:检查是否有保存的数据

if (this.savedTodos && this.savedTodos !== '[]') {
  this.todos = JSON.parse(this.savedTodos)
  • if (this.savedTodos && this.savedTodos !== '[]')

    • 检查 this.savedTodos 是否有值
    • 并且不是空数组的JSON字符串('[]'
    • 如果满足条件,说明有保存的数据,需要读取
  • JSON.parse(this.savedTodos)

    • 将JSON字符串反序列化成数组
    • 示例:
      this.savedTodos = '[{"id":1,"text":"学习HarmonyOS","done":false}]'
      
      JSON.parse(this.savedTodos)
      // 返回:
      // [
      //   { id: 1, text: '学习HarmonyOS', done: false }
      // ]
      
  • this.todos = JSON.parse(this.savedTodos)

    • 将反序列化得到的数组赋值给 this.todos
    • 触发UI更新

第4-10行:恢复 nextId

  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
}
  • 为什么要恢复 nextId

    • 因为 nextId 是用来生成新的待办事项的 id
    • 如果每次启动应用都将 nextId 重置为1,那么新添加的待办事项的 id 可能会与已有的待办事项的 id 冲突
  • 如何恢复 nextId

    • 遍历 this.todos 数组,找到最大的 id
    • nextId 设置为 maxId + 1
  • 示例

    已有的待办事项:
    { id: 1, text: '学习HarmonyOS', done: false }
    { id: 2, text: '写博客', done: true }
    { id: 5, text: '跑步', done: false }
    
    最大的 id 是 5
    所以 nextId 应该设置为 5 + 1 = 6
    
3.5.4 何时读取数据?

我在 aboutToAppear() 生命周期方法中读取数据:

aboutToAppear(): void {
  this.loadTodos()
}
  • aboutToAppear() 是什么?

    • 这是ArkUI组件的生命周期方法
    • 在组件即将出现时调用
    • 适合做初始化操作(比如读取数据、启动定时器等)
  • 为什么不在 aboutToAppear() 中读取数据?

    • 其实也可以在 aboutToAppear() 中读取数据
    • 但是我选择在 aboutToAppear() 中读取,因为这样更符合语义(组件即将出现时,准备好数据)

第四部分:UI布局与样式设计(附完整代码与详解)

4.1 整体布局结构

我的待办事项应用的UI布局分为4个部分:

┌─────────────────────────────────────┐
│  顶部标题栏                         │  1. 标题栏
├─────────────────────────────────────┤
│  输入框 + 添加按钮                  │  2. 输入区域
├─────────────────────────────────────┤
│  待办事项列表                       │  3. 列表区域
│  - 待办事项1                       │
│  - 待办事项2                       │
│  - ...                             │
├─────────────────────────────────────┤
│  底部统计栏                         │  4. 统计栏
└─────────────────────────────────────┘

对应的UI代码结构:

build() {
  Column() {               // 纵向布局(整体)
    Row() { ... }          // 1. 标题栏
    Row() { ... }          // 2. 输入区域
    List() { ... }         // 3. 列表区域
    Row() { ... }          // 4. 统计栏
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#000000')
}

4.2 标题栏

4.2.1 功能描述

标题栏显示应用的标题,以及已完成/总数的统计。

UI设计

  • 左侧:应用标题(“✅ 今日待办”)
  • 右侧:统计信息(“已完成数 / 总数”)
4.2.2 代码实现
Row() {
  Text('✅ 今日待办')
    .fontSize(26).fontWeight(FontWeight.Bold).fontColor(Color.White)
  Blank()
  Text(`${this.completedCount} / ${this.totalCount}`)
    .fontSize(18).fontColor('#8E8E93')
}
.width('100%').padding(20)
4.2.3 代码详解

Row() 布局

  • 横向布局容器
  • 子组件从左到右排列

左侧标题

Text('✅ 今日待办')
  .fontSize(26).fontWeight(FontWeight.Bold).fontColor(Color.White)
  • Text('✅ 今日待办')

    • 显示文本 “✅ 今日待办”
    • ✅ 是Emoji,增加视觉吸引力
  • .fontSize(26)

    • 字体大小26像素
  • .fontWeight(FontWeight.Bold)

    • 字体加粗
  • .fontColor(Color.White)

    • 字体颜色白色

Blank() 组件

Blank()
  • 作用:占据剩余空间
  • 效果:将后面的组件(统计信息)推到最右边
  • 类似CSS的 margin-left: auto

右侧统计信息

Text(`${this.completedCount} / ${this.totalCount}`)
  .fontSize(18).fontColor('#8E8E93')
  • `${this.completedCount} / ${this.totalCount}`

    • 模板字符串(类似JavaScript的模板字符串)
    • this.completedCountthis.totalCount 插入到字符串中
    • 示例:如果 completedCount2totalCount5,显示 "2 / 5"
  • .fontSize(18)

    • 字体大小18像素(比标题小一些)
  • .fontColor('#8E8E93')

    • 字体颜色是浅灰色(#8E8E93)
    • 这样不会抢标题的风头

容器样式

.width('100%').padding(20)
  • .width('100%')

    • 宽度填满父容器
  • .padding(20)

    • 四周内边距都是20像素
4.2.4 计算属性

completedCounttotalCount计算属性

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

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

completedCount

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

    • 过滤出已完成的待办事项
    • 返回一个新的数组
  • .length

    • 返回数组的长度
    • 即已完成的待办事项数量

totalCount

get totalCount(): number {
  return this.todos.length
}
  • 返回 this.todos 数组的长度
  • 即总的待办事项数量

什么是计算属性?

  • 计算属性是根据其他状态计算得到的属性
  • 当依赖的状态变化时,计算属性会自动重新计算
  • 在ArkUI中,用 get 关键字定义计算属性

为什么不用普通方法?

  • 其实用普通方法也可以:
    private getCompletedCount(): number {
      return this.todos.filter(item => item.done).length
    }
    
  • 但是在UI中使用时,计算属性更简洁:
    // 计算属性
    Text(`${this.completedCount} / ${this.totalCount}`)
    
    // 普通方法
    Text(`${this.getCompletedCount()} / ${this.getTotalCount()}`)
    

4.3 输入区域

4.3.1 功能描述

输入区域包含一个文本输入框和一个添加按钮。

UI设计

  • 左侧:文本输入框(占位符:“输入新待办…”)
  • 右侧:添加按钮(“➕”)
4.3.2 代码实现
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 })
4.3.3 代码详解

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() })
  • TextInput({ placeholder: '输入新待办...', text: this.inputText })

    • 创建一个文本输入框
    • placeholder:占位符(输入框为空时显示的提示文字)
    • text:输入框的文本内容(双向绑定到 this.inputText
  • .layoutWeight(1)

    • 占据剩余空间
    • 因为后面的添加按钮有固定宽度(48像素)
    • 所以输入框会占据除按钮外的所有空间
  • .height(48)

    • 高度48像素
  • .backgroundColor('#2C2C2E')

    • 背景色深灰色(#2C2C2E)
    • 这是iOS风格的深色模式输入框颜色
  • .borderRadius(12)

    • 圆角半径12像素
    • 让输入框看起来更圆润
  • .padding({ left: 16 })

    • 左内边距16像素
    • 让输入的文字不要贴着左边框
  • .fontColor(Color.White)

    • 输入的文字颜色是白色
  • .placeholderColor('#8E8E93')

    • 占位符文字颜色是浅灰色(#8E8E93)
  • .onChange((value: string) => { this.inputText = value })

    • 绑定文本变化事件
    • 当输入框的内容变化时,将最新的内容保存到 this.inputText
  • .onSubmit(() => { this.addTodo() })

    • 绑定提交事件(用户按回车键时触发)
    • 调用 this.addTodo() 方法,添加待办事项

添加按钮

Button('➕')
  .fontSize(22).width(48).height(48).borderRadius(24)
  .backgroundColor('#FF9F0A').margin({ left: 8 })
  .onClick(() => { this.addTodo() })
  • Button('➕')

    • 创建一个按钮,文字是 “➕”(Emoji)
  • .fontSize(22)

    • 字体大小22像素
  • .width(48).height(48)

    • 宽度48像素,高度48像素
    • 这是一个正方形按钮
  • .borderRadius(24)

    • 圆角半径24像素
    • 因为宽度和高度都是48像素,所以这是一个正圆形按钮
  • .backgroundColor('#FF9F0A')

    • 背景色是橙色(#FF9F0A)
    • 这是iOS风格的操作按钮颜色
  • .margin({ left: 8 })

    • 左边距8像素
    • 让按钮和输入框之间有一点间距
  • .onClick(() => { this.addTodo() })

    • 绑定点击事件
    • 调用 this.addTodo() 方法,添加待办事项

容器样式

.width('100%').padding({ left: 20, right: 20 })
  • .width('100%')

    • 宽度填满父容器
  • .padding({ left: 20, right: 20 })

    • 左右内边距都是20像素
    • 让输入区域不要贴着屏幕边缘

4.4 列表区域

4.4.1 功能描述

列表区域显示所有的待办事项,支持滚动。

UI设计

  • 使用 List 组件显示待办事项列表
  • 每个待办事项是一个 ListItem
  • 每个待办事项包含:
    • 左侧:完成状态图标(⭕或✅)
    • 中间:待办事项文本(已完成的有删除线)
    • 右侧:删除按钮(🗑️)
4.4.2 代码实现
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())
}
.layoutWeight(1).width('100%')
.padding({ left: 20, right: 20 })
4.4.3 代码详解

List() 组件

List({ space: 8 }) {
  // ...
}
  • List()

    • 创建一个列表容器
    • 类似Android的 RecyclerView、iOS的 UITableView
  • { space: 8 }

    • 列表项之间的间距是8像素

ForEach() 循环

ForEach(this.todos, (item: TodoItem) => {
  ListItem() {
    // ...
  }
}, (item: TodoItem) => item.id.toString())
  • ForEach(this.todos, ...)

    • 遍历 this.todos 数组
    • 为每个待办事项创建一个 ListItem
  • (item: TodoItem) => { ... }

    • 箭头函数,参数是 item(当前待办事项)
  • (item: TodoItem) => item.id.toString()

    • 为每个待办事项生成一个唯一的key
    • item.id 转换成字符串作为key
    • 为什么要key?
      • 提高列表渲染性能
      • 当数组变化时,框架可以根据key判断哪些是新增的、哪些是删除的、哪些是需要移动的

ListItem() 组件

ListItem() {
  Row() {
    // ...
  }
  .width('100%').padding(12)
  .backgroundColor('#1C1C1E').borderRadius(12)
}
  • ListItem()

    • 创建一个列表项
    • 每个 ListItem 对应一个待办事项
  • 容器样式

    • .width('100%'):宽度填满父容器
    • .padding(12):内边距12像素
    • .backgroundColor('#1C1C1E'):背景色深灰色(#1C1C1E)
    • .borderRadius(12):圆角半径12像素

列表项的内部结构(Row() 布局)

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

完成状态图标(Button()

Button(item.done ? '✅' : '⭕')
  .fontSize(18).width(36).height(36)
  .backgroundColor(Color.Transparent)
  .onClick(() => { this.toggleTodo(item.id) })
  • Button(item.done ? '✅' : '⭕')

    • 根据 item.done 的值,决定显示哪个图标
    • 如果 donetrue,显示 ✅
    • 如果 donefalse,显示 ⭕
  • .backgroundColor(Color.Transparent)

    • 背景色透明
    • 因为我们用的是Emoji,不需要背景色
  • .onClick(() => { this.toggleTodo(item.id) })

    • 绑定点击事件
    • 点击时,调用 this.toggleTodo(item.id) 方法,切换完成状态

待办事项文本(Text()

Text(item.text)
  .fontSize(18)
  .fontColor(item.done ? '#555555' : Color.White)
  .decoration({
    type: item.done
      ? TextDecorationType.LineThrough
      : TextDecorationType.None
  })
  .layoutWeight(1).padding({ left: 8 })
  • Text(item.text)

    • 显示待办事项的文本
  • .fontColor(item.done ? '#555555' : Color.White)

    • 根据 item.done 的值,决定字体颜色
    • 如果 donetrue,显示深灰色(#555555)
    • 如果 donefalse,显示白色
  • .decoration({ ... })

    • 设置文本装饰(如下划线、删除线等)
    • type: item.done ? TextDecorationType.LineThrough : TextDecorationType.None
      • 如果 donetrue,显示删除线
      • 如果 donefalse,不显示装饰
  • .layoutWeight(1)

    • 占据剩余空间
    • 这样删除按钮会靠右显示
  • .padding({ left: 8 })

    • 左边距8像素
    • 让文本和完成状态图标之间有一点间距

删除按钮(Button()

Button('🗑️')
  .fontSize(16).backgroundColor(Color.Transparent)
  .onClick(() => { this.deleteTodo(item.id) })
  • Button('🗑️')

    • 创建一个按钮,文字是 “🗑️”(Emoji)
  • .backgroundColor(Color.Transparent)

    • 背景色透明
    • 因为我们用的是Emoji,不需要背景色
  • .onClick(() => { this.deleteTodo(item.id) })

    • 绑定点击事件
    • 点击时,调用 this.deleteTodo(item.id) 方法,删除待办事项

列表容器样式

.layoutWeight(1).width('100%')
.padding({ left: 20, right: 20 })
  • .layoutWeight(1)

    • 占据剩余空间
    • 因为标题栏和输入区域都有固定高度
    • 所以列表区域会占据除它们之外的所有空间
  • .width('100%')

    • 宽度填满父容器
  • .padding({ left: 20, right: 20 })

    • 左右内边距都是20像素
    • 让列表不要贴着屏幕边缘

4.5 统计栏

4.5.1 功能描述

统计栏显示待办事项的总数,以及"清除已完成"按钮(仅当有已完成的待办事项时显示)。

UI设计

  • 左侧:总数统计(“共 X 项”)
  • 右侧:"清除已完成"按钮(条件显示)
4.5.2 代码实现
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 })
4.5.3 代码详解

Row() 布局

  • 横向布局容器
  • 子组件从左到右排列

左侧总数统计

Text(`${this.totalCount}`)
  .fontSize(14).fontColor('#8E8E93')
  • `共 ${this.totalCount} 项`

    • 模板字符串
    • 显示 “共 X 项”,其中 X 是 this.totalCount 的值
  • .fontSize(14)

    • 字体大小14像素(比较小,因为这是次要信息)
  • .fontColor('#8E8E93')

    • 字体颜色浅灰色(#8E8E93)

Blank() 组件

Blank()
  • 作用:占据剩余空间
  • 效果:将后面的"清除已完成"按钮推到最右边

右侧"清除已完成"按钮(条件显示)

if (this.hasCompleted) {
  Text('清除已完成')
    .fontSize(14).fontColor('#FF9F0A')
    .onClick(() => { this.clearDone() })
}
  • if (this.hasCompleted)

    • 只有当有已完成的待办事项时,才显示"清除已完成"按钮
    • this.hasCompleted 是一个计算属性(后面会详解)
  • Text('清除已完成')

    • 显示文本 “清除已完成”
  • .fontSize(14)

    • 字体大小14像素
  • .fontColor('#FF9F0A')

    • 字体颜色橙色(#FF9F0A)
    • 橙色表示可点击(类似链接)
  • .onClick(() => { this.clearDone() })

    • 绑定点击事件
    • 点击时,调用 this.clearDone() 方法,清除所有已完成的待办事项

容器样式

.width('100%').padding(20)
.backgroundColor('#1C1C1E')
.borderRadius({ topLeft: 16, topRight: 16 })
  • .width('100%')

    • 宽度填满父容器
  • .padding(20)

    • 内边距20像素
  • .backgroundColor('#1C1C1E')

    • 背景色深灰色(#1C1C1E)
  • .borderRadius({ topLeft: 16, topRight: 16 })

    • 只设置左上角和右上角的圆角
    • 让统计栏看起来像是一个"卡片"的顶部
4.5.4 计算属性

hasCompleted 是一个计算属性:

get hasCompleted(): boolean {
  return this.completedCount > 0
}
  • 作用:判断是否有已完成的待办事项
  • 返回值true(有)或 false(没有)
  • 实现:如果 this.completedCount > 0,返回 true,否则返回 false

第五部分:踩坑记录与解决方案

坑1:@State 装饰的数组,直接修改不会触发UI更新

问题描述

我一开始是这样实现 addTodo() 方法的:

// 错误实现
private addTodo(): void {
  const text = this.inputText.trim()
  if (text === '') return

  const newItem: TodoItem = {
    id: this.nextId++,
    text: text,
    done: false
  }
  this.todos.push(newItem)  // ❌ 错误!不会触发UI更新
  this.inputText = ''
  this.saveTodos()
}

问题:调用 this.todos.push(newItem) 后,UI没有更新(列表没有显示新添加的待办事项)。

原因分分析

在ArkUI中,@State 装饰的状态变量,只有在重新赋值时才会触发UI更新。

直接调用 this.todos.push(newItem),虽然修改了数组的内容,但是 this.todos 这个引用没有变,所以ArkUI认为状态没有变化,不会触发UI更新。

解决方案

必须重新赋值this.todos

// 正确实现
private addTodo(): void {
  const text = this.inputText.trim()
  if (text === '') return

  const newItem: TodoItem = {
    id: this.nextId++,
    text: text,
    done: false
  }
  this.todos = [newItem].concat(this.todos)  // ✅ 正确!会触发UI更新
  this.inputText = ''
  this.saveTodos()
}

类似的坑

错误用法 正确用法
this.todos.push(item) this.todos = this.todos.concat([item])
this.todos.unshift(item) this.todos = [item].concat(this.todos)
this.todos.splice(index, 1) 创建新数组,过滤掉要删除的元素
this.todos.sort() this.todos = this.todos.slice().sort()
this.todos.reverse() this.todos = this.todos.slice().reverse()

总结

对于 @State 装饰的数组和对象,必须通过重新赋值来触发UI更新,不能直接调用修改原数组/对象的方法。


坑2:@StorageLink 装饰的变量,类型必须是 stringnumberboolean

问题描述

我一开始是这样定义 savedTodos 的:

// 错误定义
@StorageLink('todos_data') savedTodos: TodoItem[] = []

问题:编译报错,提示类型不匹配。

原因分分析

@StorageLink 装饰的变量,类型必须是 stringnumberboolean

不能用复杂类型(如数组、对象),因为 @StorageLink 需要将数据存储到本地存储,而本地存储只支持基本类型。

解决方案

savedTodos 定义为 string 类型,存储 todos 的JSON字符串:

// 正确定义
@StorageLink('todos_data') savedTodos: string = '[]'

然后,在保存和读取时,手动进行序列化和反序列化:

private saveTodos(): void {
  this.savedTodos = JSON.stringify(this.todos)  // 序列化成JSON字符串
}

private loadTodos(): void {
  if (this.savedTodos && this.savedTodos !== '[]') {
    this.todos = JSON.parse(this.savedTodos)  // 反序列化成数组
    // ...
  }
}

坑3:ForEach 必须有唯一的key

问题描述

我一开始是这样写 ForEach 的:

// 错误写法
ForEach(this.todos, (item: TodoItem) => {
  ListItem() {
    // ...
  }
})

问题:运行时报错,提示缺少key。

原因分分析

ForEach 必须有一个唯一的key,用来标识每个列表项。

这个key的作用:

  1. 提高渲染性能:当数组变化时,框架可以根据key判断哪些是新增的、哪些是删除的、哪些是需要移动的
  2. 保持组件状态:如果某个列表项有内部状态(比如输入框的文字),key可以用来保持这个状态
解决方案

ForEach 提供唯一的key:

// 正确写法
ForEach(this.todos, (item: TodoItem) => {
  ListItem() {
    // ...
  }
}, (item: TodoItem) => item.id.toString())  // ✅ 提供唯一的key

key的要求

  1. 唯一:每个列表项的key必须唯一
  2. 稳定:key不能变化(否则会失去性能优化效果)

为什么用 item.id.toString() 作为key?

  • 唯一:因为 item.id 是唯一的
  • 稳定:因为 item.id 不会变化(一旦分配就不会修改)

不要用数组索引作为key!

// ❌ 错误!不要用数组索引作为key
ForEach(this.todos, (item: TodoItem, index: number) => {
  ListItem() {
    // ...
  }
}, (item: TodoItem, index: number) => index.toString())

原因:如果数组的顺序发生变化(比如删除、插入元素),数组索引会变化,这样就失去了key的作用。


坑4:输入框的 onChange 事件会触发多次

问题描述

我一开始是这样绑定输入框的 onChange 事件的:

TextInput({ placeholder: '输入新待办...', text: this.inputText })
  .onChange((value: string) => {
    console.info('onChange: ' + value)  // 打印日志
    this.inputText = value
  })

问题:每次输入一个字符,都会触发一次 onChange 事件,导致日志打印很多次。

原因分分析

这是正常行为。onChange 事件会在输入框的内容每次变化时触发。

如果你输入 “abc”,会触发3次 onChange 事件:

  1. 输入 “a” 时触发
  2. 输入 “b” 时触发
  3. 输入 “c” 时触发
解决方案

这是正常行为,不需要解决。但是如果你想要防抖(延迟处理),可以用以下方法:

private searchTimer: number = 0

TextInput({ placeholder: '输入新待办...', text: this.inputText })
  .onChange((value: string) => {
    if (this.searchTimer) {
      clearTimeout(this.searchTimer)
    }
    this.searchTimer = setTimeout(() => {
      this.inputText = value
    }, 300)  // 延迟300ms
  })

但是,对于待办事项应用,不需要防抖,因为:

  1. onChange 事件只是更新 this.inputText,开销很小
  2. 防抖会让用户输入时有"延迟感",影响体验

第六部分:完整代码清单

以下是待办事项应用的完整代码(复制粘贴到 Index.ets 中即可运行):

interface TodoItem {
  id: number
  text: string
  done: boolean
}

@Entry
@Component
struct Index {
  @State todos: TodoItem[] = []
  @State inputText: string = ''
  @StorageLink('todos_data') savedTodos: string = '[]'

  private nextId: number = 1

  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
  }

  private addTodo(): void {
    const text = this.inputText.trim()
    if (text === '') return

    const newItem: TodoItem = {
      id: this.nextId++,
      text: text,
      done: false
    }
    this.todos = [newItem].concat(this.todos)
    this.inputText = ''
    this.saveTodos()
  }

  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) {
        newTodos.push({ id: item.id, text: item.text, done: !item.done })
      } else {
        newTodos.push(item)
      }
    }
    this.todos = newTodos
    this.saveTodos()
  }

  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
    this.saveTodos()
  }

  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
    this.saveTodos()
  }

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

  private loadTodos(): void {
    if (this.savedTodos && this.savedTodos !== '[]') {
      this.todos = JSON.parse(this.savedTodos)
      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()
  }

  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())
      }
      .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')
  }
}

第七部分:运行截图

在这里插入图片描述

第八部分:性能优化与未来改进

8.1 性能优化

8.1.1 使用 trackBy 提高列表渲染性能

ForEach 中,我已经使用了唯一的key:

ForEach(this.todos, (item: TodoItem) => {
  // ...
}, (item: TodoItem) => item.id.toString())

这已经可以提高列表渲染性能了。

8.1.2 避免不必要的重新渲染

在我的代码中,当 todos 变化时,只有列表区域会重新渲染,其他区域(标题栏、输入区域、统计栏)不会重新渲染。

这是因为ArkUI的细粒度响应式更新机制:只有依赖了变化状态的部分,才会重新渲染。

8.2 未来改进

8.2.1 功能改进
功能 优先级 难度 说明
待办事项分类 ⭐⭐ 增加"工作"、“生活”、"学习"等分类
设置提醒时间 ⭐⭐⭐⭐ 到时间后推送通知
云同步 ⭐⭐⭐⭐⭐ 需要后端服务器和华为账号
语音输入 ⭐⭐⭐ 调用华为语音识别API
8.2.2 UI改进
改进点 优先级 难度 说明
深色/浅色模式切换 提供两种主题
动画效果 ⭐⭐⭐ 添加/删除/完成时的动画
拖拽排序 ⭐⭐⭐⭐ 支持拖拽排序待办事项

第九部分:总结与心得

9.1 项目总结

通过开发这个待办事项应用,我学到了:

  1. HarmonyOS ArkUI开发的基础流程

    • 创建项目、定义数据结构、实现功能、设计UI、调试运行
  2. ArkUI的状态管理机制

    • @State@StorageLink 等装饰器的用法
    • 响应式编程的思想
  3. 数据持久化方案

    • 使用 @StorageLink 实现自动持久化
  4. 列表渲染性能优化

    • ForEach 提供唯一的key
  5. iOS风格UI设计

    • 深色模式、圆角、间距、字体颜色等

9.2 开发心得

心得1:一定要理解状态管理机制

ArkUI的状态管理机制是最核心的概念。如果你不理解 @State@Prop@Link@StorageLink 等装饰器的作用,就会写出很多bug。

建议

  • 仔细阅读官方文档的"状态管理"章节
  • 多写demo,亲自体验不同装饰器的效果
心得2:多用计算属性

计算属性可以让代码更简洁、更易维护。

示例

// ❌ 不用计算属性(代码冗长)
Text(`${this.todos.filter(item => item.done).length} / ${this.todos.length}`)

// ✅ 用计算属性(代码简洁)
Text(`${this.completedCount} / ${this.totalCount}`)
心得3:一定要注意性能

虽然这个待办事项应用很简单,但是也要注意性能。

建议

  • ForEach 提供唯一的key
  • 避免在 build() 方法中做复杂计算
  • 如果列表很长,考虑分页加载或虚拟列表

相关资源

  • 华为开发者官网:https://developer.harmonyos.com
  • ArkUI官方文档:https://developer.harmonyos.com/cn/docs/documentation/doc-guides/arkui
Logo

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

更多推荐