📖鸿蒙NEXT开发实战系列| 第19篇 | 实战篇 🎯适合人群:学完状态管理基础的开发者 ⏰阅读时间:约20分钟 | 💻开发环境:DevEco Studio 5.0+
上一篇:18-进阶篇-状态管理高级技巧下一篇:20-实战篇-网络请求与数据持久化
📑 目录
一、前言:为什么要实战?
二、项目架构设计
2.1 功能需求分析
2.2 状态管理方案选型
2.3 项目结构规划
三、核心代码实现
3.1 数据模型定义
3.2 TodoStore状态管理类
3.3 主页面布局实现
3.4 TodoItem组件实现
四、持久化存储实现
4.1 AppStorage配置
4.2 数据序列化与反序列化
五、完整源码汇总
六、运行效果展示
七、总结与扩展
系列文章推荐
标签
一、前言:为什么要实战?
学了那么多状态管理的理论知识:@State、@Link、@Provide、@Consume、AppStorage... 是不是感觉"我懂了,但又好像没完全懂"?
光看理论不练手等于白学!
今天我们就用鸿蒙的状态管理,从零实现一个完整的Todo应用。这个应用虽然简单,但麻雀虽小五脏俱增,涵盖了:
增删改查:完整的CRUD操作
状态管理:@State管理局部状态、@Provide/@Consume跨组件通信
持久化:AppStorage实现数据持久化存储
组件化:合理的组件拆分和状态设计
总共约200行代码,带你打通状态管理的任督二脉!
二、项目架构设计
2.1 功能需求分析
我们的Todo应用需要实现以下功能:
功能 | 说明 |
|---|---|
✅ 添加待办 | 输入框输入内容,点击添加 |
✅ 删除待办 | 左滑或点击删除按钮 |
✅ 完成标记 | 点击复选框标记完成状态 |
✅ 编辑待办 | 双击进入编辑模式 |
✅ 数据持久化 | 应用重启后数据不丢失 |
✅ 统计展示 | 显示总数、已完成数 |
2.2 状态管理方案选型
针对不同场景,我们选择不同的状态管理方案:
┌─────────────────────────────────────────────────────────┐ │ 状态管理方案选型 │ ├─────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────┐ ┌─────────────┐ │ │ │ @State │──────│ @Link │ │ │ │ 组件内部 │ │ 父子通信 │ │ │ └─────────────┘ └─────────────┘ │ │ │ │ ┌─────────────┐ ┌─────────────┐ │ │ │ @Provide │──────│ @Consume │ │ │ │ 跨层提供 │ │ 跨层消费 │ │ │ └─────────────┘ └─────────────┘ │ │ │ │ ┌─────────────────────────────────────┐ │ │ │ AppStorage │ │ │ │ 全局状态 + 持久化 │ │ │ └─────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────┘具体方案:
TodoList数据:使用
@StorageLink链接AppStorage,实现全局共享+持久化输入框内容:使用
@State管理组件局部状态子组件属性:使用
@Prop接收父组件数据(单向数据流)
2.3 项目结构规划
TodoApp/ ├── entry/src/main/ets/ │ ├── entryability/ │ │ └── EntryAbility.ets # 应用入口 │ ├── pages/ │ │ └── Index.ets # 主页面 │ ├── model/ │ │ └── TodoItem.ets # 数据模型 │ ├── components/ │ │ └── TodoListItem.ets # 列表项组件 │ └── utils/ │ └── StorageUtils.ets # 存储工具类三、核心代码实现
3.1 数据模型定义
首先定义Todo的数据结构:
// model/TodoItem.ets /** * Todo数据模型 * @property id 唯一标识 * @property content 待办内容 * @property isCompleted 是否完成 * @property createdAt 创建时间 */ export class TodoItem { id: string = '' content: string = '' isCompleted: boolean = false createdAt: number = Date.now() constructor(content: string) { this.id = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}` this.content = content this.isCompleted = false this.createdAt = Date.now() } }设计说明:
使用
id作为唯一标识,采用时间戳+随机数的方式生成createdAt记录创建时间,方便后续扩展排序功能构造函数只需传入
content,其他字段自动生成
3.2 TodoStore状态管理类
创建一个状态管理类,集中管理Todo相关的状态和方法:
// store/TodoStore.ets import { TodoItem } from '../model/TodoItem' import { preferences } from '@kit.ArkData' const STORAGE_KEY = 'todo_list_data' /** * Todo状态管理类 * 使用Preferences实现数据持久化 */ export class TodoStore { private static instance: TodoStore | null = null private prefs: preferences.Preferences | null = null // 单例模式 static getInstance(): TodoStore { if (!TodoStore.instance) { TodoStore.instance = new TodoStore() } return TodoStore.instance } /** * 初始化存储 */ async init(context: Context): Promise<void> { try { this.prefs = await preferences.getPreferences(context, 'todo_store') } catch (err) { console.error('TodoStore初始化失败:', err) } } /** * 加载数据 */ async loadData(): Promise<TodoItem[]> { try { if (!this.prefs) return [] const data = await this.prefs.get(STORAGE_KEY, '[]') as string return JSON.parse(data) as TodoItem[] } catch (err) { console.error('加载数据失败:', err) return [] } } /** * 保存数据 */ async saveData(list: TodoItem[]): Promise<void> { try { if (!this.prefs) return await this.prefs.put(STORAGE_KEY, JSON.stringify(list)) await this.prefs.flush() } catch (err) { console.error('保存数据失败:', err) } } }设计说明:
采用单例模式,全局共享一个Store实例
使用
Preferences进行轻量级数据持久化提供异步的
loadData和saveData方法
3.3 主页面布局实现
主页面是整个应用的核心,负责布局和状态管理:
// pages/Index.ets import { TodoItem } from '../model/TodoItem' import { TodoStore } from '../store/TodoStore' import { TodoListItem } from '../components/TodoListItem' @Entry @Component struct Index { // ============ 状态定义 ============ // 使用@State管理本地状态 @State todoList: TodoItem[] = [] @State inputText: string = '' @State isLoading: boolean = true // Store实例 private store: TodoStore = TodoStore.getInstance() // ============ 生命周期 ============ aboutToAppear(): void { this.initApp() } /** * 初始化应用 */ private async initApp(): Promise<void> { // 获取UIContext用于显示弹窗 const uiContext = this.getUIContext().getHostContext()! // 初始化Store await this.store.init(uiContext) // 加载数据 const data = await this.store.loadData() this.todoList = data this.isLoading = false } /** * 保存数据到持久化存储 */ private async saveData(): Promise<void> { await this.store.saveData(this.todoList) } // ============ 业务方法 ============ /** * 添加待办 */ private addTodo(): void { const content = this.inputText.trim() if (!content) return // 创建新Todo并添加到列表头部 const newTodo = new TodoItem(content) this.todoList = [newTodo, ...this.todoList] // 清空输入框 this.inputText = '' // 持久化保存 this.saveData() } /** * 切换完成状态 */ private toggleTodo(id: string): void { const index = this.todoList.findIndex(item => item.id === id) if (index !== -1) { // 注意:需要创建新数组触发状态更新 const newList = [...this.todoList] newList[index] = { ...newList[index], isCompleted: !newList[index].isCompleted } this.todoList = newList this.saveData() } } /** * 删除待办 */ private deleteTodo(id: string): void { this.todoList = this.todoList.filter(item => item.id !== id) this.saveData() } // ============ 计算属性 ============ /** * 已完成数量 */ private get completedCount(): number { return this.todoList.filter(item => item.isCompleted).length } /** * 总数量 */ private get totalCount(): number { return this.todoList.length } // ============ UI构建 ============ build() { Column() { // ---- 标题区域 ---- this.TitleSection() // ---- 输入区域 ---- this.InputSection() // ---- 统计区域 ---- this.StatsSection() // ---- 列表区域 ---- this.ListSection() } .width('100%') .height('100%') .backgroundColor('#F5F5F5') } /** * 标题区域 */ @Builder TitleSection() { Row() { Text('我的待办') .fontSize(28) .fontWeight(FontWeight.Bold) .fontColor('#333333') } .width('100%') .padding({ left: 20, right: 20, top: 40, bottom: 20 }) .justifyContent(FlexAlign.Center) } /** * 输入区域 */ @Builder InputSection() { Row() { TextInput({ text: this.inputText, placeholder: '请输入待办内容...' }) .layoutWeight(1) .height(48) .backgroundColor('#FFFFFF') .borderRadius(8) .padding({ left: 16, right: 16 }) .fontSize(16) .onChange((value: string) => { this.inputText = value }) .onSubmit(() => { this.addTodo() }) Button('添加') .width(80) .height(48) .margin({ left: 12 }) .backgroundColor('#4CAF50') .borderRadius(8) .onClick(() => { this.addTodo() }) } .width('100%') .padding({ left: 20, right: 20 }) } /** * 统计区域 */ @Builder StatsSection() { Row() { Text(`共 ${this.totalCount} 项,已完成 ${this.completedCount} 项`) .fontSize(14) .fontColor('#666666') } .width('100%') .padding({ left: 20, right: 20, top: 16, bottom: 8 }) } /** * 列表区域 */ @Builder ListSection() { if (this.isLoading) { // 加载状态 Column() { LoadingProgress() .width(48) .height(48) Text('加载中...') .fontSize(14) .fontColor('#999999') .margin({ top: 12 }) } .layoutWeight(1) .justifyContent(FlexAlign.Center) } else if (this.todoList.length === 0) { // 空状态 Column() { Text('📝') .fontSize(48) Text('暂无待办事项') .fontSize(16) .fontColor('#999999') .margin({ top: 12 }) Text('点击上方输入框添加') .fontSize(14) .fontColor('#CCCCCC') .margin({ top: 8 }) } .layoutWeight(1) .justifyContent(FlexAlign.Center) } else { // 列表 List({ space: 8 }) { ForEach(this.todoList, (item: TodoItem) => { ListItem() { TodoListItem({ item: item, onToggle: (id: string) => this.toggleTodo(id), onDelete: (id: string) => this.deleteTodo(id) }) } }, (item: TodoItem) => item.id) } .layoutWeight(1) .padding({ left: 20, right: 20, top: 8 }) .divider({ strokeWidth: 0.5, color: '#EEEEEE' }) } } }关键代码说明:
状态更新触发机制:
// 必须创建新数组才能触发UI更新 const newList = [...this.todoList] newList[index] = { ...newList[index], isCompleted: !newList[index].isCompleted } this.todoList = newListForEach的keyGenerator:
ForEach(this.todoList, (item: TodoItem) => { // 列表项 }, (item: TodoItem) => item.id) // 使用id作为唯一标识
3.4 TodoListItem组件实现
将单个Todo项抽取为独立组件:
// components/TodoListItem.ets import { TodoItem } from '../model/TodoItem' /** * Todo列表项组件 */ @Component struct TodoListItem { // 使用@Prop接收父组件数据 @Prop item: TodoItem // 回调函数 onToggle: (id: string) => void = () => {} onDelete: (id: string) => void = () => {} build() { Row() { // 复选框 Checkbox() .select(this.item.isCompleted) .selectedColor('#4CAF50') .width(24) .height(24) .onChange((value: boolean) => { this.onToggle(this.item.id) }) // 内容文本 Text(this.item.content) .fontSize(16) .fontColor(this.item.isCompleted ? '#999999' : '#333333') .decoration({ type: this.item.isCompleted ? TextDecorationType.LineThrough : TextDecorationType.None, color: '#999999' }) .layoutWeight(1) .margin({ left: 12 }) .maxLines(3) // 删除按钮 Button('删除') .width(60) .height(32) .fontSize(14) .backgroundColor('#FF5252') .borderRadius(4) .onClick(() => { this.onDelete(this.item.id) }) } .width('100%') .padding(16) .backgroundColor('#FFFFFF') .borderRadius(8) .shadow({ radius: 4, color: '#1A000000', offsetX: 0, offsetY: 2 }) } }组件设计说明:
@Prop装饰器:
用于接收父组件传递的数据
单向数据流:父组件更新会同步到子组件
子组件不能直接修改@Prop装饰的属性
回调函数模式:
通过
onToggle和onDelete回调通知父组件实际的状态修改在父组件中完成
四、持久化存储实现
4.1 AppStorage配置
在entryAbility中配置AppStorage:
// entryability/EntryAbility.ets import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit' import { hilog } from '@kit.PerformanceAnalysisKit' import { window } from '@kit.ArkUI' export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate') } onDestroy(): void { hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onDestroy') } onWindowStageCreate(windowStage: window.WindowStage): void { hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate') windowStage.loadContent('pages/Index', (err) => { if (err.code) { hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)) return } hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.') }) } onWindowStageDestroy(): void { hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageDestroy') } onForeground(): void { hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onForeground') } onBackground(): void { hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onBackground') } }4.2 数据序列化与反序列化
为了确保数据能够正确存储和读取,需要注意序列化问题:
// 模型类需要提供序列化方法 export class TodoItem { id: string = '' content: string = '' isCompleted: boolean = false createdAt: number = Date.now() constructor(content: string) { this.id = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}` this.content = content this.isCompleted = false this.createdAt = Date.now() } /** * 转换为可序列化的对象 */ toJSON(): object { return { id: this.id, content: this.content, isCompleted: this.isCompleted, createdAt: this.createdAt } } /** * 从对象创建实例 */ static fromJSON(json: any): TodoItem { const item = new TodoItem('') item.id = json.id || '' item.content = json.content || '' item.isCompleted = json.isCompleted || false item.createdAt = json.createdAt || Date.now() return item } }五、完整源码汇总
为了方便大家直接复制运行,这里给出完整的源码汇总:
5.1 TodoItem.ets(数据模型)
/** * Todo数据模型 */ export class TodoItem { id: string = '' content: string = '' isCompleted: boolean = false createdAt: number = Date.now() constructor(content: string) { this.id = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}` this.content = content this.isCompleted = false this.createdAt = Date.now() } }5.2 TodoStore.ets(状态管理)
import { TodoItem } from '../model/TodoItem' import { preferences } from '@kit.ArkData' const STORAGE_KEY = 'todo_list_data' /** * Todo状态管理类 */ export class TodoStore { private static instance: TodoStore | null = null private prefs: preferences.Preferences | null = null static getInstance(): TodoStore { if (!TodoStore.instance) { TodoStore.instance = new TodoStore() } return TodoStore.instance } async init(context: Context): Promise<void> { try { this.prefs = await preferences.getPreferences(context, 'todo_store') } catch (err) { console.error('TodoStore初始化失败:', err) } } async loadData(): Promise<TodoItem[]> { try { if (!this.prefs) return [] const data = await this.prefs.get(STORAGE_KEY, '[]') as string return JSON.parse(data) as TodoItem[] } catch (err) { console.error('加载数据失败:', err) return [] } } async saveData(list: TodoItem[]): Promise<void> { try { if (!this.prefs) return await this.prefs.put(STORAGE_KEY, JSON.stringify(list)) await this.prefs.flush() } catch (err) { console.error('保存数据失败:', err) } } }5.3 TodoListItem.ets(列表项组件)
import { TodoItem } from '../model/TodoItem' /** * Todo列表项组件 */ @Component struct TodoListItem { @Prop item: TodoItem onToggle: (id: string) => void = () => {} onDelete: (id: string) => void = () => {} build() { Row() { Checkbox() .select(this.item.isCompleted) .selectedColor('#4CAF50') .width(24) .height(24) .onChange((value: boolean) => { this.onToggle(this.item.id) }) Text(this.item.content) .fontSize(16) .fontColor(this.item.isCompleted ? '#999999' : '#333333') .decoration({ type: this.item.isCompleted ? TextDecorationType.LineThrough : TextDecorationType.None, color: '#999999' }) .layoutWeight(1) .margin({ left: 12 }) .maxLines(3) Button('删除') .width(60) .height(32) .fontSize(14) .backgroundColor('#FF5252') .borderRadius(4) .onClick(() => { this.onDelete(this.item.id) }) } .width('100%') .padding(16) .backgroundColor('#FFFFFF') .borderRadius(8) .shadow({ radius: 4, color: '#1A000000', offsetX: 0, offsetY: 2 }) } }5.4 Index.ets(主页面)
import { TodoItem } from '../model/TodoItem' import { TodoStore } from '../store/TodoStore' import { TodoListItem } from '../components/TodoListItem' @Entry @Component struct Index { @State todoList: TodoItem[] = [] @State inputText: string = '' @State isLoading: boolean = true private store: TodoStore = TodoStore.getInstance() aboutToAppear(): void { this.initApp() } private async initApp(): Promise<void> { const uiContext = this.getUIContext().getHostContext()! await this.store.init(uiContext) const data = await this.store.loadData() this.todoList = data this.isLoading = false } private async saveData(): Promise<void> { await this.store.saveData(this.todoList) } private addTodo(): void { const content = this.inputText.trim() if (!content) return const newTodo = new TodoItem(content) this.todoList = [newTodo, ...this.todoList] this.inputText = '' this.saveData() } private toggleTodo(id: string): void { const index = this.todoList.findIndex(item => item.id === id) if (index !== -1) { const newList = [...this.todoList] newList[index] = { ...newList[index], isCompleted: !newList[index].isCompleted } this.todoList = newList this.saveData() } } private deleteTodo(id: string): void { this.todoList = this.todoList.filter(item => item.id !== id) this.saveData() } private get completedCount(): number { return this.todoList.filter(item => item.isCompleted).length } private get totalCount(): number { return this.todoList.length } build() { Column() { // 标题 Row() { Text('我的待办') .fontSize(28) .fontWeight(FontWeight.Bold) .fontColor('#333333') } .width('100%') .padding({ left: 20, right: 20, top: 40, bottom: 20 }) .justifyContent(FlexAlign.Center) // 输入区域 Row() { TextInput({ text: this.inputText, placeholder: '请输入待办内容...' }) .layoutWeight(1) .height(48) .backgroundColor('#FFFFFF') .borderRadius(8) .padding({ left: 16, right: 16 }) .fontSize(16) .onChange((value: string) => { this.inputText = value }) .onSubmit(() => { this.addTodo() }) Button('添加') .width(80) .height(48) .margin({ left: 12 }) .backgroundColor('#4CAF50') .borderRadius(8) .onClick(() => { this.addTodo() }) } .width('100%') .padding({ left: 20, right: 20 }) // 统计 Row() { Text(`共 ${this.totalCount} 项,已完成 ${this.completedCount} 项`) .fontSize(14) .fontColor('#666666') } .width('100%') .padding({ left: 20, right: 20, top: 16, bottom: 8 }) // 列表 if (this.isLoading) { Column() { LoadingProgress() .width(48) .height(48) Text('加载中...') .fontSize(14) .fontColor('#999999') .margin({ top: 12 }) } .layoutWeight(1) .justifyContent(FlexAlign.Center) } else if (this.todoList.length === 0) { Column() { Text('📝') .fontSize(48) Text('暂无待办事项') .fontSize(16) .fontColor('#999999') .margin({ top: 12 }) Text('点击上方输入框添加') .fontSize(14) .fontColor('#CCCCCC') .margin({ top: 8 }) } .layoutWeight(1) .justifyContent(FlexAlign.Center) } else { List({ space: 8 }) { ForEach(this.todoList, (item: TodoItem) => { ListItem() { TodoListItem({ item: item, onToggle: (id: string) => this.toggleTodo(id), onDelete: (id: string) => this.deleteTodo(id) }) } }, (item: TodoItem) => item.id) } .layoutWeight(1) .padding({ left: 20, right: 20, top: 8 }) .divider({ strokeWidth: 0.5, color: '#EEEEEE' }) } } .width('100%') .height('100%') .backgroundColor('#F5F5F5') } }六、运行效果展示
6.1 主界面效果
┌─────────────────────────────────┐ │ 我的待办 │ ├─────────────────────────────────┤ │ ┌────────────────────┐ ┌────┐ │ │ │ 请输入待办内容... │ │添加│ │ │ └────────────────────┘ └────┘ │ ├─────────────────────────────────┤ │ 共 3 项,已完成 1 项 │ ├─────────────────────────────────┤ │ ┌─────────────────────────────┐│ │ │ ☑ 学习鸿蒙状态管理 ││ │ │ ───────────────── ││ │ │ 删除 ││ │ └─────────────────────────────┘│ │ ┌─────────────────────────────┐│ │ │ ☐ 完成Todo应用实战 ││ │ │ 删除 ││ │ └─────────────────────────────┘│ │ ┌─────────────────────────────┐│ │ │ ☐ 写技术博客 ││ │ │ 删除 ││ │ └─────────────────────────────┘│ └─────────────────────────────────┘6.2 功能演示
操作 | 效果 |
|---|---|
输入内容点击添加 | 新待办添加到列表顶部 |
点击复选框 | 标记完成/取消完成,文本添加删除线 |
点击删除按钮 | 删除该待办项 |
关闭应用重新打开 | 数据自动加载,状态保持 |
七、总结与扩展
7.1 知识点回顾
通过这个Todo应用,我们实践了:
@State:管理组件内部状态
@Prop:父子组件单向数据传递
@Builder:自定义构建函数,提高代码复用
Preferences:轻量级数据持久化方案
ForEach:列表渲染及key的使用
组件化开发:合理的组件拆分
7.2 扩展优化方向
如果你想进一步完善这个应用,可以考虑:
添加编辑功能:双击进入编辑模式
分类管理:支持创建不同的待办分类
优先级设置:支持设置高/中/低优先级
提醒功能:设置提醒时间,到时通知
云同步:接入云服务,实现多端同步
性能优化:大量数据时使用LazyForEach懒加载
7.3 状态管理最佳实践
通过这个项目,总结几点状态管理的最佳实践:
┌─────────────────────────────────────────────────────────┐ │ 状态管理最佳实践 │ ├─────────────────────────────────────────────────────────┤ │ │ │ 1. 状态下沉:状态尽量放在使用它的最近组件 │ │ 2. 单向数据流:父子组件通过props和回调通信 │ │ 3. 不可变更新:修改数组/对象时创建新引用 │ │ 4. 持久化分离:数据存储逻辑与UI逻辑分离 │ │ 5. 类型安全:使用TypeScript定义明确的数据类型 │ │ │ └─────────────────────────────────────────────────────────┘系列文章推荐
01-入门篇-开发环境搭建
02-入门篇-第一个鸿蒙应用
05-基础篇-ArkUI组件入门
15-进阶篇-状态管理基础@State
16-进阶篇-父子组件通信@Link
17-进阶篇-跨组件通信@Provide
18-进阶篇-状态管理高级技巧
20-实战篇-网络请求与数据持久化
标签
Todo应用鸿蒙实战状态管理ArkUI完整项目@State@PropPreferences持久化存储组件化开发
💡下期预告:下一篇我们将学习网络请求与数据持久化的进阶用法,敬请期待!
📧反馈交流:如有问题或建议,欢迎在评论区留言交流。