ARTICLE · 1316916
实战一:手写一个待办清单 App(界面 + 状态 + 列表 + 存储)
前面九篇讲的都是零件,这一篇把它们装成一台机器。本文从需求拆解开始,用完整代码实现一个待办清单 App:数据模型、状态管理、列表渲染、筛选交互、Preferences 持久化,逐层给出代码与取舍理由。

前面九篇,我们分别讲清了定位、环境、ArkTS、声明式、布局、状态、列表、性能。每一篇都能跑,但它们是零件。
这一篇开始造机器:用一个完整的待办清单 App,把这些零件全部串起来。需求不难,但它刚好把每个知识点都用上了——一个 Column 布局、一个 @State 数组、一个 List + ForEach、一次本地存储。
写完这个项目,你会第一次体会到:ArkUI 的开发体验,本质上是「状态驱动」的复利。
一、先看最终要做成什么样
先把界面定下来,后面所有代码都围绕它写。界面分四个区:

待办清单 App 的四个界面分区与对应知识点
示意图 —— 界面为结构示意,实际样式可按个人喜好调整
功能清单也很简单:新增、勾选完成、删除、按状态筛选、数据本地保存。
●新增:输入文字,点添加,出现在列表顶部。
●勾选:点圆框切换完成状态,文字变灰划掉。
●删除:点右侧删除,列表实时移除该项。
●筛选:切换 Tab,列表只显示符合条件的项。
●持久化:杀掉应用再打开,数据还在。
二、代码该怎么分文件
第一个要做的决定是:代码放几个文件里?新手常犯的错是一股脑全塞进 Index.ets,写到 800 行就再也改不动了。

项目的目录结构与各文件职责
示意图 —— 目录为建议结构,可按个人习惯调整
对这样一个小项目,我建议分五个文件。分层的判断标准只有一条:每一层只做一件事,且不依赖上一层的具体实现。
三、数据模型:先把一条待办定义清楚
第一步是定义数据。一条待办需要什么?id、标题、是否完成、创建时间。就这些。
model/TodoItem.ets
// model/TodoItem.etsexport interface TodoItem { id: number // 唯一标识,用作 ForEach 的 key title: string // 待办内容 done: boolean // 是否已完成 createdAt: number // 创建时间戳}为什么 id 要放在最前面、而且是数字?因为它要做两件事:一是给 ForEach 当 key(上一讲讲过,key 必须唯一且稳定);二是给「切换完成状态」当定位依据——我们改的是某一条,而不是「数组里的第几个」。
key 用 id,不只是为了让 ForEach 正确,更是为了让「找到并修改某一条」这件事变得可靠。
四、状态层:所有数据的唯一来源
接下来是核心。前面九篇的知识点在这里汇合。
第一步:定义状态。整个 App 的待办数据只存在一个地方:
viewmodel/TodoViewModel.ets · 状态定义
// viewmodel/TodoViewModel.ets@Observedexport class TodoViewModel { @State todos: TodoItem[] = [] // 全部数据 @State filter: FilterType = FilterType.All // 当前筛选 @State input: string = '' // 输入框文本 // 统计值用 getter 派生,不单独存一份状态 get total(): number { return this.todos.length } get remaining(): number { return this.todos.filter(t => !t.done).length } // 当前应该显示在列表里的数据 get visibleTodos(): TodoItem[] { switch (this.filter) { case FilterType.Undone: return this.todos.filter(t => !t.done) case FilterType.Done: return this.todos.filter(t => t.done) default: return this.todos } }}这里有三个设计决策值得单独说:
1 统计值用 getter,不存状态「剩余几项」是从 todos 算出来的,不是另存一份。多存一份就多一处可能不同步的地方——这正是第 9 篇说的「状态粒度」。
2 筛选后的列表也用 gettervisibleTodos 每次访问时重新过滤。这样无论 todos 怎么变、筛选条件怎么变,列表永远是当前真相。
3 筛选条件是一个状态filter 放在 viewmodel 里,界面只负责传值和展示,筛选逻辑不写在界面代码中。
第二步:写操作方法。所有对数据的修改都收敛到这几个方法里:
viewmodel/TodoViewModel.ets · 操作方法
// 新增 addTodo(title: string): void { const trimmed = title.trim() if (trimmed.length === 0) { return } const item: TodoItem = { id: this.nextId(), title: trimmed, done: false, createdAt: Date.now() } // 整体替换,触发 UI 更新(第 7 篇讲过) this.todos = [item, ...this.todos] } // 切换完成状态:按 id 定位,只改这一条 toggleTodo(id: number): void { this.todos = this.todos.map(t => { if (t.id === id) { return { ...t, done: !t.done } } return t }) } // 删除:同样按 id 定位 removeTodo(id: number): void { this.todos = this.todos.filter(t => t.id !== id) } private nextId(): number { return this.todos.reduce((max, t) => Math.max(max, t.id), 0) + 1 }注意 toggleTodo 的写法:用 map 遍历出新数组、只替换命中的那一条,其余原样返回。这样既满足「整体替换」的更新要求,又不会误伤其他项。
为什么删除用 filter、修改用 map?因为删除是「不要这一条」,修改是「这一条换个样子」。语义不同,方法也不同,别硬用一种写法套所有场景。
五、持久化:让数据活过应用被杀
内存里的数据一退出就没了。要实现「杀掉应用再打开数据还在」,需要把数据写到本地存储。鸿蒙提供了 Preferences,适合存这种小而简单的数据。
store/TodoStore.ets
// store/TodoStore.etsimport { preferences } from '@kit.ArkData'import { common } from '@kit.AbilityKit'const STORE_NAME = 'todo_store'const KEY_TODOS = 'todos_json'export class TodoStore { // 读出全部待办 static async load(): Promise<TodoItem[]> { try { const ctx = getContext(this) as common.UIAbilityContext const store = await preferences.getPreferences(ctx, { name: STORE_NAME }) const raw = await store.get(KEY_TODOS, '[]') as string return JSON.parse(raw) as TodoItem[] } catch (e) { // 读失败就当空列表,不要让应用崩掉 return [] } } // 写回全部待办 static async save(todos: TodoItem[]): Promise<void> { try { const ctx = getContext(this) as common.UIAbilityContext const store = await preferences.getPreferences(ctx, { name: STORE_NAME }) await store.put(KEY_TODOS, JSON.stringify(todos)) await store.flush() // 必须 flush 才真正落盘 } catch (e) { // 存失败不阻断用户操作,下次还能在内存里用 } }}三个必须注意的细节:
●必须 await flush()。put() 只是写进内存缓冲,flush() 才落到磁盘。少了这一行,重启应用数据就没了。
●整个对象存成一个字符串。用 JSON.stringify 转成字符串再存,取出来再 parse。这是 Preferences 存复杂结构的标准做法。
●读写都要包 try/catch。存储失败不该让整个应用崩掉——读失败就当空列表启动,存失败就继续用内存里的数据。
那什么时候触发保存?最简单可靠的做法是:每次数据变更后都存一次。
变更后落盘
// 在 viewmodel 里,操作完成后统一落盘 private async persist(): Promise<void> { await TodoStore.save(this.todos) } async addTodo(title: string): Promise<void> { // ...修改 this.todos await this.persist() }待办数据量很小,每次变更都全量保存完全没问题。不要为了「省 IO」去设计增量存储——那是过度设计。
六、界面层:把状态画出来
最后是界面。这里没有新知识,全是前面几篇的组合。
pages/Index.ets · 页面组装
// pages/Index.ets@Entry@Componentstruct Index { @State vm: TodoViewModel = new TodoViewModel() aboutToAppear(): void { this.loadFromDisk() } private async loadFromDisk(): Promise<void> { const todos = await TodoStore.load() this.vm.todos = todos } build() { Column() { // ① 标题栏 this.TitleBar() // ② 筛选 Tab this.FilterTabs() // ③ 列表 List() { ForEach(this.vm.visibleTodos, (item: TodoItem) => { ListItem() { TodoItemRow({ item: item, onToggle: () => { this.vm.toggleTodo(item.id) }, onDelete: () => { this.vm.removeTodo(item.id) } }) } }, (item: TodoItem) => item.id.toString()) } .layoutWeight(1) .width('100%') // ④ 添加栏 this.AddBar() } .width('100%') .height('100%') .backgroundColor('#f5f6f7') }}这段代码里有三个关键点:
●key 用 item.id.toString()。第 8 篇讲过——绝不能用下标,否则删除一项后列表会串行。
●列表数据来自 getter。this.vm.visibleTodos 会自动跟着 filter 变化,不用手写三种列表。
●回调用箭头函数包一层。() => { this.vm.toggleTodo(item.id) } 把 id 固定住,避免闭包拿到循环中的错误引用。
子组件要能反过来改数据,就要用 @Link(第 7 篇):
components/TodoItemRow.ets · 列表项组件
// components/TodoItemRow.ets@Componentexport struct TodoItemRow { @Prop item: TodoItem // 只读展示用 @Prop @Prop onToggle: () => void // 事件回调用 @Prop 传函数 @Prop onDelete: () => void build() { Row({ space: 12 }) { // 勾选框 Checkbox() .select(this.item.done) .onChange(() => { this.onToggle() }) // 标题:已完成则置灰 Text(this.item.title) .fontSize(16) .fontColor(this.item.done ? '#a8aeb8' : '#1f2329') .decoration({ type: this.item.done ? TextDecorationType.LineThrough : TextDecorationType.None }) .layoutWeight(1) // 删除按钮 Text('删除') .fontSize(13) .fontColor('#d63f3f') .onClick(() => { this.onDelete() }) } .width('100%') .padding(16) .backgroundColor('#ffffff') .borderRadius(8) }}这里有个容易踩的坑:把 item 直接传给子组件时,如果传的是对象,修改需要整体替换。所以子组件只负责显示和抛事件,真正的数据修改一律回到 viewmodel 里做——子组件不碰数据源,就不会出现「改了一处别处没同步」的问题。
七、筛选 Tab 的实现
筛选 Tab 本质上是三个按钮 + 一个状态,代码很短:
筛选 Tab
@Builder FilterTabs() { Row({ space: 8 }) { ForEach(this.filterOptions, (opt: FilterOption) => { Text(opt.label) .fontSize(13) .fontColor(this.vm.filter === opt.type ? '#07C160' : '#6b7280') .padding({ left: 14, right: 14, top: 7, bottom: 7 }) .backgroundColor(this.vm.filter === opt.type ? '#e8f9ef' : '#f2f3f5') .borderRadius(15) .onClick(() => { this.vm.filter = opt.type }) }, (opt: FilterOption) => opt.type.toString()) } .width('100%') .padding({ left: 16, right: 16, top: 12, bottom: 12 }) }注意这里用了 @Builder。它是 ArkUI 里把界面片段抽出来的语法,相当于把一整块 UI 写成方法,这样 Index.ets 里只剩「组装」,不会变成一堆重复代码。
八、完整跑起来的检查清单
写完之后,按这个顺序自测一遍:
1新建一条待办输入文字点添加,列表顶部立刻出现,且清空输入框。
2勾选完成圆框变绿、文字变灰划掉,顶部「剩余」数字减一。
3切换筛选点「未完成」,只显示未完成项;切到「已完成」,只显示已完成项。
4删除一条点删除,列表移除该项,统计数字同步变化。
5杀掉应用重开数据还在,且勾选状态、筛选条件都正确恢复。
如果第 5 步失败,99% 是漏了 flush(),或者没有在 aboutToAppear 里把数据读回来。
如果第 4 步出现串行,那是 key 用错了——回看第 8 篇,把 item.id.toString() 补上。
九、这个项目用到的全部知识点
回头盘一遍,你会发现这九篇几乎每一篇都用上了:
这才是学框架的正确方式:不是把每个 API 背下来,而是知道每个 API 在真实项目里解决什么问题。
小结
这个项目很小,但它把完整的开发链路走通了一遍:
●需求 → 分层 → 数据模型 → 状态 → 存储 → 界面,顺序不能乱。
●状态只有一份,统计和筛选都是派生出来的,不做冗余存储。
●所有数据修改集中在 viewmodel,子组件只显示和抛事件。
●key 用 id,这是列表不出错的关键。
下一篇我们在这个项目上继续加功能:编辑待办、按优先级排序、分类标签,你会看到当需求变复杂时,前面那套分层结构是如何保护你的。
— — —
你的待办清单想加什么功能?留言说说,我挑有意思的写进下一篇。















































































