跳到主要内容

第十二章:综合实战:待办事项应用

本章配套代码:code/Chapter12(完整待办事项应用) 前置要求:已学完状态管理、列表、路由、数据持久化全部核心章节

12.1 学习目标

  • 综合运用 V2 状态管理、列表渲染、数据持久化
  • 掌握 @ObservedV2/@Trace 模型设计
  • 掌握 @Computed 派生数据(筛选/统计)
  • 掌握 Preferences 持久化完整流程
  • 体验"从需求到实现"的完整开发流程

12.2 需求分析

待办事项应用功能清单:

功能说明技术点
添加待办输入标题添加@Local + 数组重赋值
勾选完成打勾切换状态@ObservedV2/@Trace
删除待办移除条目数组 filter
筛选视图全部/进行中/已完成@Computed
统计信息总计/完成/待办@Computed
数据持久化重启保留Preferences

UI 结构设计:

┌──────────────────────────────┐
│ 待办事项 标题 │
│ ┌────────────────────────┐ │
│ │ [输入框] [添加] │ │
│ └────────────────────────┘ │
│ [全部✓] [进行中] [已完成] │
│ 总计: 3 已完成: 1 待: 2 │
│ ┌────────────────────────┐ │
│ │ ☑ 学习状态管理V2 [删除]│ │
│ │ ☐ 编写待办应用 [删除]│ │
│ └────────────────────────┘ │
└──────────────────────────────┘

12.3 数据模型设计

使用 @ObservedV2 类描述待办项,实现属性级观测:

@ObservedV2
class TodoItem {
@Trace id: number
@Trace title: string
@Trace completed: boolean

constructor(id: number, title: string) {
this.id = id
this.title = title
this.completed = false
}
}

🔑 为什么用 @ObservedV2?因为切换完成状态修改的是 todo.completed(属性),需要属性级观测触发 UI 刷新。

12.4 状态与派生数据

12.4.1 状态声明

@Entry
@ComponentV2
struct Index {
@Local todos: TodoItem[] = [
new TodoItem(1, '学习状态管理V2'),
new TodoItem(2, '编写待办应用'),
new TodoItem(3, '部署到模拟器')
]
@Local newTodo: string = ''
@Local filter: string = 'all' // all / active / completed
}

12.4.2 @Computed 派生数据

筛选与统计使用 @Computed,依赖 todosfilter 自动重算:

@Computed
get filteredTodos(): TodoItem[] {
switch (this.filter) {
case 'active':
return this.todos.filter(t => !t.completed)
case 'completed':
return this.todos.filter(t => t.completed)
default:
return this.todos
}
}

@Computed
get completedCount(): number {
return this.todos.filter(t => t.completed).length
}

@Computed
get activeCount(): number {
return this.todos.filter(t => !t.completed).length
}

🔑 @Computed 自动收集依赖(this.todosthis.filter),任一变化即重算缓存值。

12.5 业务操作

async addTodo(): Promise<void> {
if (this.newTodo) {
this.todos = [...this.todos, new TodoItem(Date.now(), this.newTodo)] // 重新赋值
this.newTodo = ''
await this.saveData()
}
}

async toggleTodo(id: number): Promise<void> {
this.todos = this.todos.map(t =>
t.id === id ? Object.assign(new TodoItem(t.id, t.title), { completed: !t.completed }) : t
)
await this.saveData()
}

async deleteTodo(id: number): Promise<void> {
this.todos = this.todos.filter(t => t.id !== id)
await this.saveData()
}

⚠️ 再次强调:数组修改必须重新赋值[...]/map/filter),这是 V2 触发刷新的关键。

12.6 数据持久化

12.6.1 加载数据

async loadData(): Promise<void> {
const context = this.getUIContext().getHostContext()
this.pref = await preferences.getPreferences(context, 'todo_store')
const saved = await this.pref?.get('todos', '') as string
if (saved) {
const raw = JSON.parse(saved) as { id: number, title: string, completed: boolean }[]
this.todos = raw.map(t => {
const item = new TodoItem(t.id, t.title)
item.completed = t.completed
return item
})
}
}

12.6.2 保存数据

async saveData(): Promise<void> {
const raw = this.todos.map(t => ({ id: t.id, title: t.title, completed: t.completed }))
await this.pref?.put('todos', JSON.stringify(raw))
await this.pref?.flush()
}

💡 序列化时只存普通对象(id/title/completed),反序列化时重建 TodoItem 实例(恢复 @ObservedV2 观测能力)。

12.7 完整代码

对应 code/Chapter12Index.ets(完整可运行版本,含筛选高亮与持久化提示):

import { preferences } from '@kit.ArkData'

@ObservedV2
class TodoItem {
@Trace id: number
@Trace title: string
@Trace completed: boolean

constructor(id: number, title: string) {
this.id = id
this.title = title
this.completed = false
}
}

@Entry
@ComponentV2
struct Index {
@Local todos: TodoItem[] = []
@Local newTodo: string = ''
@Local filter: string = 'all'

private pref?: preferences.Preferences

@Computed
get filteredTodos(): TodoItem[] {
if (this.filter === 'active') {
return this.todos.filter(t => !t.completed)
} else if (this.filter === 'completed') {
return this.todos.filter(t => t.completed)
}
return this.todos
}

@Computed
get completedCount(): number {
return this.todos.filter(t => t.completed).length
}

@Computed
get activeCount(): number {
return this.todos.length - this.completedCount
}

async aboutToAppear(): Promise<void> {
const context = this.getUIContext().getHostContext()
this.pref = await preferences.getPreferences(context, 'todo_store')
await this.loadData()
}

async loadData(): Promise<void> {
const raw = await this.pref?.get('todos', '[]') as string
const list = JSON.parse(raw) as { id: number, title: string, completed: boolean }[]
this.todos = list.map(t => {
const item = new TodoItem(t.id, t.title)
item.completed = t.completed
return item
})
}

async saveData(): Promise<void> {
const raw = this.todos.map((t: TodoItem): TodoItem => {
const item = new TodoItem(t.id, t.title)
item.completed = t.completed
return item
})
await this.pref?.put('todos', JSON.stringify(raw))
await this.pref?.flush()
}

async addTodo(): Promise<void> {
if (!this.newTodo) return
this.todos = [...this.todos, new TodoItem(Date.now(), this.newTodo)]
this.newTodo = ''
await this.saveData()
}

async toggleTodo(id: number): Promise<void> {
this.todos = this.todos.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
)
await this.saveData()
}

async deleteTodo(id: number): Promise<void> {
this.todos = this.todos.filter(t => t.id !== id)
await this.saveData()
}

build() {
Column({ space: 16 }) {
// ① 标题
Text('Chapter 12: Todo 综合实战')
.fontSize(24)
.fontWeight(FontWeight.Bold)

// ② 输入行
Row() {
TextInput({ placeholder: '添加新待办', text: this.newTodo })
.layoutWeight(1)
.onChange((v: string) => { this.newTodo = v })
.onSubmit(() => this.addTodo())
Button('添加')
.onClick(() => this.addTodo())
}
.width('100%')

// ③ 筛选按钮(当前选中高亮 ✓)
Row({ space: 8 }) {
Button(this.filter === 'all' ? '全部 ✓' : '全部')
.backgroundColor(this.filter === 'all' ? '#007DFF' : '#e0e0e0')
.onClick(() => { this.filter = 'all' })
Button(this.filter === 'active' ? '进行中 ✓' : '进行中')
.backgroundColor(this.filter === 'active' ? '#007DFF' : '#e0e0e0')
.onClick(() => { this.filter = 'active' })
Button(this.filter === 'completed' ? '已完成 ✓' : '已完成')
.backgroundColor(this.filter === 'completed' ? '#007DFF' : '#e0e0e0')
.onClick(() => { this.filter = 'completed' })
}

// ④ 统计
Row({ space: 12 }) {
Text(`总计: ${this.todos.length}`).fontSize(13)
Text(`已完成: ${this.completedCount}`).fontSize(13).fontColor('#00A86B')
Text(`待完成: ${this.activeCount}`).fontSize(13).fontColor('#FF6B6B')
}

// ⑤ 列表
List({ space: 8 }) {
ForEach(this.filteredTodos, (todo: TodoItem) => {
ListItem() {
Row() {
Checkbox()
.select(todo.completed)
.onChange(() => this.toggleTodo(todo.id))
Text(todo.title)
.fontSize(16)
.decoration({
type: todo.completed ? TextDecorationType.LineThrough : TextDecorationType.None
})
.fontColor(todo.completed ? '#999999' : '#333333')
.layoutWeight(1)
Button('删除')
.fontSize(12)
.backgroundColor('#FF6B6B')
.onClick(() => this.deleteTodo(todo.id))
}
.width('100%')
.padding(12)
.backgroundColor(Color.White)
.borderRadius(8)
}
}, (todo: TodoItem) => todo.id.toString())
}
.layoutWeight(1)
}
.width('100%')
.height('100%')
.padding(16)
.backgroundColor('#f5f5f5')
}
}

运行效果:

  • 添加:输入标题 → 回车或点"添加",列表实时新增
  • 勾选:打勾后划线+变灰,统计即时更新
  • 删除:移除条目并持久化
  • 筛选:三个 tab 切换视图,当前 tab 高亮
  • 持久化:杀进程重启,数据完整保留

运行效果截图:

Chapter12 待办应用总览

待办列表(勾选完成状态)

待办条目 2

待办条目 3

12.8 设计模式总结

本实战贯穿了本课程的核心设计模式:

模式应用
模型层@ObservedV2 类描述业务数据
状态层@Local 组件状态
派生层@Computed 计算筛选/统计
交互层事件回调修改状态
持久化层Preferences 存取

数据流:

UI 操作 → 修改 @Local → @Computed 重算 → UI 刷新 → saveData 持久化

12.9 常见问题

Q:勾选后 UI 不刷新? 检查 TodoItem 是否有 @ObservedV2 + @Trace,以及是否重新赋值数组。

Q:重启后数据丢失? 检查 saveData 是否在每次增删改后调用,且执行了 flush()

Q:筛选 tab 如何判断当前选中? 比较 this.filter === 'all' 等,动态设置按钮文字与背景色。

Q:Date.now() 做 id 会不会冲突? 快速连续添加可能冲突(同一毫秒)。生产可用自增计数器或 UUID。

12.10 本章小结

收获说明
模型设计@ObservedV2/@Trace
状态管理@Local + 数组重赋值
派生数据@Computed 筛选/统计
交互Checkbox/Button 事件
持久化Preferences 序列化存取

12.11 课后练习(扩展)

  1. 增加"编辑待办"功能(点击进入编辑页,支持修改标题)
  2. 增加"清空已完成"按钮
  3. 增加搜索功能(关键词过滤)
  4. 使用 @Monitor 监听数据变化自动持久化
  5. 将应用改为多页面(列表页 + 详情页 + 路由跳转)

12.12 参考资料

评论

加载中…
加载中...