夜雨聆风学习资料网

ARTICLE · 1316916

实战一:手写一个待办清单 App(界面 + 状态 + 列表 + 存储)

实战一:手写一个待办清单 App(界面 + 状态 + 列表 + 存储)

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

前面九篇,我们分别讲清了定位、环境、ArkTS、声明式、布局、状态、列表、性能。每一篇都能跑,但它们是零件。

这一篇开始造机器:用一个完整的待办清单 App,把这些零件全部串起来。需求不难,但它刚好把每个知识点都用上了——一个 Column 布局、一个 @State 数组、一个 List + ForEach、一次本地存储。

写完这个项目,你会第一次体会到:ArkUI 的开发体验,本质上是「状态驱动」的复利。

一、先看最终要做成什么样

先把界面定下来,后面所有代码都围绕它写。界面分四个区:

待办清单 App 的四个界面分区与对应知识点

示意图 —— 界面为结构示意,实际样式可按个人喜好调整

分区
功能
用到的知识点
对应篇目
标题栏
显示总数与剩余数
@State + getter 派生统计
第 7 篇
筛选 Tab
全部 / 未完成 / 已完成
@State 存筛选条件
第 7 篇
列表区
展示待办项、勾选、删除
List + ForEach + key
第 8 篇
添加栏
输入并新增待办
@State + 事件回调
第 5 篇

功能清单也很简单:新增、勾选完成、删除、按状态筛选、数据本地保存。

●新增:输入文字,点添加,出现在列表顶部。

●勾选:点圆框切换完成状态,文字变灰划掉。

●删除:点右侧删除,列表实时移除该项。

●筛选:切换 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() 补上。

九、这个项目用到的全部知识点

回头盘一遍,你会发现这九篇几乎每一篇都用上了:

篇目
在这个项目里的落点
第 5 篇 声明式
@Component + build(),界面只描述不指挥
第 6 篇 布局
Column 竖向分区 + Row 横向排列
第 7 篇 状态管理
@State 数组、@Prop 传参、getter 派生统计
第 8 篇 列表渲染
List + ForEach + key 用 item.id
第 9 篇 性能
整体替换而非原地修改;List 懒加载;避免深层嵌套

这才是学框架的正确方式:不是把每个 API 背下来,而是知道每个 API 在真实项目里解决什么问题。

小结

这个项目很小,但它把完整的开发链路走通了一遍:

●需求 → 分层 → 数据模型 → 状态 → 存储 → 界面,顺序不能乱。

●状态只有一份,统计和筛选都是派生出来的,不做冗余存储。

●所有数据修改集中在 viewmodel,子组件只显示和抛事件。

●key 用 id,这是列表不出错的关键。

下一篇我们在这个项目上继续加功能:编辑待办、按优先级排序、分类标签,你会看到当需求变复杂时,前面那套分层结构是如何保护你的。

— — —

你的待办清单想加什么功能?留言说说,我挑有意思的写进下一篇。

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料