
直接做一个能用的 OpenHarmony 待办清单是我最近琢磨状态驱动这件事最顺手的切入方式。之前写过不少鸿蒙基础组件的小例子但大多是单一交互这次干脆完整跑一个“能添加、能勾选、能删除、重启还在”的最小任务管理应用。标题里那句“用状态驱动实现最小可行任务管理”不是空话这个小项目背后真正想说的是在 OpenHarmony 的 ArkUI 声明式开发范式里状态管理到底怎么理解事件、数据、界面三者是怎么联动起来的以及一个新人从搭环境到调通功能会遇到哪些坑。这篇内容适合两类人一类是刚把 OpenHarmony 开发环境跑起来、正愁不知道写什么练手项目的开发者另一类是写过一些 ArkTS 页面但总觉得“状态刷新”这件事玄乎改了变量界面不更新或者列表一操作就错乱的半新手。我会把整个项目从需求拆解、环境搭建、核心代码到问题排查一步步讲清楚代码可以直接复制到你的工程里跑。1. 这个项目到底在做什么1.1 一个“不小”的小项目待办事项清单在移动开发里基本是“Hello World 进阶版”但恰恰因为它功能简单才适合用来把状态管理这个核心问题看透。这个项目实现的范围很克制一个文本输入框一个添加按钮一个任务列表列表项可以勾选完成、可以删除右上角有全部/待办/已完成三个筛选入口最后把任务数据持久化到本地杀掉应用再打开数据还在。这个范围对应的是“最小可行任务管理”意思是不贪多、不做账号系统、不做分类标签、不做提醒只把任务数据的基本生命周期走通创建、读取、更新状态、删除、存储。功能越少状态管理的逻辑就越清晰每个状态变化都能被直观地看到。之所以选择这个方向是因为在 OpenHarmony 应用开发里传统命令式的那种“找到控件、设置属性”的写法已经不再主流ArkUI 走的是声明式路线。声明式的核心就是界面是状态的函数你只管改数据UI 自动跟着变。待办清单这类 CRUD 应用恰好是学习“数据驱动 UI”最好的试验场。1.2 为什么核心是“状态驱动”很多教程会说“用 State 修饰变量”但很少解释“为什么界面会自动刷新”。这里要先把机制讲清楚。在 ArkUI 的响应式系统里用装饰器修饰的变量会被框架建立观测关系。所谓观测就是框架在运行时会跟踪这个变量的读取和修改当你渲染一个组件时读取了某个状态框架就记下“这个组件依赖这个状态”当你修改这个状态时框架立刻找到依赖它的组件触发重新渲染。你不需要手动去调用类似this.$element(listItem).refresh()这样的方法也不需要纠结数据变化后该更新哪个控件。这个思路放到待办应用里就是你把tasks数组当作唯一的“事实来源”。所有 UI 都从tasks派生列表长什么样取决于tasks里有什么勾选状态对不对取决于对应任务的done字段筛选结果是对tasks做了一次过滤计算。用户操作的每一步本质都在做同一件事修改tasks。添加任务就是往数组里塞一条切换完成就是翻转某条的done删除就是从数组里移除一条。理解这件事最大的好处是你的代码结构不再以“页面控件”为中心而是以“数据流转”为中心。项目很小的时候这个区别不明显但一旦功能变多比如加入搜索、排序、多列表同步状态驱动的优势就体现出来了。后面我写的每个功能都会围绕这个原则展开。2. 环境准备与工程骨架2.1 官方 IDE 与 SDK 配置OpenHarmony 应用开发官方推荐的 IDE 是 DevEco Studio它基于 IntelliJ 平台用过 Android Studio 的人上手很快。这里有几个关键的配置点都是我自己踩过的SDK 版本建议选 API 10 或 API 11。API 9 太老部分接口和装饰器行为跟新版有差异API 12 开始引入了新的状态管理 V2 体系对新手来说容易混。API 10 或 11 配合 V1 的State、Prop、Link这套装饰器学习资料多、行为稳定最适合入门。创建工程时选择Empty Ability模板语言会自动选 ArkTS这就是 OpenHarmony 应用开发的主语言。ArkTS 是基于 TypeScript 的但为了运行时性能做了严格限制比如不能用any、不能用解构赋值里的某些写法、对象字面量必须对应明确的类型。大部分 TS 语法习惯可以沿用但偶尔会碰到编译报错说“这个写法不允许”不用紧张按提示改就行。工程目录里主要关心这几个地方entry/src/main/ets/pages/Index.ets是入口页面entry/src/main/resources放资源和配置entry/src/main/module.json5是模块配置。我们的核心代码基本都在Index.ets里就能写完。2.2 最小可运行的首屏 UI先不着急写逻辑我把首屏静态界面搭出来确保能在模拟器上跑起来。这一步的目的是先建立“界面骨架”的直观感受后面再往上挂状态。Entry Component struct Index { build() { Column({ space: 16 }) { Text(待办清单) .fontSize(24) .fontWeight(FontWeight.Bold) .width(100%) .padding({ top: 16, bottom: 8 }) Row({ space: 8 }) { TextInput({ placeholder: 输入新的待办事项 }) .layoutWeight(1) Button(添加) .height(40) } .width(100%) List() { ForEach([], (item: string) { ListItem() { Text(item) } }, (item: string) item) } .width(100%) .layoutWeight(1) } .padding(16) .width(100%) .height(100%) } }能看到一个标题、一个输入行和一个空列表说明工程没问题。接下来要做的就是把这个静态骨架一步步变成由状态驱动的真实应用。3. 核心实现状态驱动下的任务全流程3.1 数据模型与全局状态先定义任务的数据结构。最小可行只需要四个字段唯一标识、内容、完成状态、创建时间。用interface定义比用class简单直接interface Task { id: number content: string done: boolean createTime: number }为什么id不用数组下标因为删除和排序会导致下标变化用下标作为标识会出现列表渲染错乱、选中状态串位的问题。等会儿写 ForEach 的 key 生成函数时你就会看到id的重要性。然后在组件里声明状态State tasks: Task[] []State是 ArkUI V1 体系里最基础的装饰器。被它修饰的变量值变了会触发依赖它的 UI 刷新。注意这里的tasks是数组数组的增删改要特别注意刷新规则后面单独讲。另外还需要两个辅助状态一个是输入框当前内容inputText一个是筛选条件currentFilter。前者是典型的“受控组件”思路用户打字只是修改状态输入框显示什么由状态决定。后者用于切换全部/待办/已完成。State inputText: string State currentFilter: string all到这里应用的“唯一事实来源”已经建立tasks决定列表数据inputText决定输入框内容currentFilter决定列表过滤规则。3.2 添加任务事件 → 状态 → 界面刷新添加任务的事件处理函数如下addTask() { const content this.inputText.trim() if (content ) { return } const task: Task { id: Date.now(), content: content, done: false, createTime: Date.now() } this.tasks [...this.tasks, task] this.inputText }这里有一个关键写法this.tasks [...this.tasks, task]而不是this.tasks.push(task)。这是 ArkUI State 数组最核心的坑。State对数组的“整体替换”有完整观测能力但对数组内部的“就地修改”观测非常有限。push是就地修改在某些场景下界面不会更新展开运算符创建了一个新数组框架能明确感知到引用变化从而刷新列表。作为一个经验法则操作 State 数组时尽量让变量指向一个新数组不要直接调用 push、splice 这类方法。在 UI 上把 TextInput 的值绑定到inputText用onChange事件同步输入TextInput({ placeholder: 输入新的待办事项, text: this.inputText }) .onChange((value: string) { this.inputText value })这里也是状态驱动的体现TextInput 自身不维护“当前文本”这份数据它只是一个展示组件真正存值的是inputText组件通过text参数和onChange回调与状态单向同步。好处是当你在其他地方清了inputText输入框会自动清空不需要额外调用任何清空方法。3.3 完成状态切换为什么不能直接改对象属性列表渲染和勾选操作是这个项目的核心交互。列表主体用List和ForEach实现List({ space: 12 }) { ForEach( this.visibleTasks, (task: Task) { ListItem() { Row({ space: 12 }) { Checkbox() .select(task.done) .onChange((checked: boolean) { this.toggleTask(task.id) }) Text(task.content) .fontSize(16) .decoration({ type: task.done ? TextDecorationType.LineThrough : TextDecorationType.None }) .fontColor(task.done ? #999999 : #333333) } .width(100%) .padding(12) .backgroundColor(#F5F5F5) .borderRadius(8) } }, (task: Task) task.id.toString() ) }注意 ForEach 的第三个参数它叫 key 生成函数框架靠它区分哪些列表项是新增的、哪些是复用的。用task.id保证 key 唯一稳定绝对不要用数组下标。现在看toggleTask的实现这是展示“为什么状态驱动不意味着随便改”的绝佳例子toggleTask(id: number) { this.tasks this.tasks.map((task: Task): Task { if (task.id id) { return { ...task, done: !task.done } } return task }) }理论上如果我能拿到task对象直接写task.done !task.done更自然。但问题来了State在 V1 体系里对“对象内部属性变化”的侦测能力很弱。当你直接改对象属性时框架通常不知道“这个对象变了”界面自然不刷新。解决办法就是“返回一个新对象”{ ...task, done: !task.done }创建了一个内容相同但引用不同的新对象然后整体放入新数组。这样从数组引用到对象引用都变了框架能明确感知变化。有些教程会让 Task 类继承Observed然后子组件用ObjectLink接收这样就能直接改属性。那是另一种可行方案但会引入更多概念。在这个 MVP 项目里用不可变更新的写法最简洁、最好懂也足够覆盖从简单到中等复杂的所有场景。3.4 删除与筛选让列表跟着状态走删除功能非常简单本质和添加一样过滤出一个不包含目标 id 的新数组然后赋值回去。deleteTask(id: number) { this.tasks this.tasks.filter((task: Task) task.id ! id) }如果你想做得更顺手一点可以给列表项加一个“长按删除”或“滑动删除”。滑动删除用的是SwipeAction组件需要包一层ListItem的swipeAction属性。这个功能写起来会多一点代码但核心逻辑不会变无论手势怎么触发最终都是把 id 传入deleteTask。筛选区的实现也不复杂。三个按钮用currentFilter记录当前选中的筛选项Row({ space: 8 }) { ForEach([all, active, completed], (filter: string) { Button(this.filterLabel(filter)) .fontSize(14) .height(32) .backgroundColor(this.currentFilter filter ? #007DFF : #E0E0E0) .fontColor(this.currentFilter filter ? #FFFFFF : #666666) .onClick(() { this.currentFilter filter }) }, (filter: string) filter) }这里的核心是计算属性visibleTasks它不是状态而是从tasks和currentFilter派生出来的get visibleTasks(): Task[] { if (this.currentFilter all) { return this.tasks } const done this.currentFilter completed return this.tasks.filter((task: Task) task.done done) }看到没有整个应用没有任何一个“专门更新 UI”的代码。点击筛选按钮只是改了currentFiltervisibleTasks重新计算ForEach 自动用新数据重新渲染。这就是状态驱动最让人舒服的地方所有逻辑都在处理数据所有 UI 变化都是数据变化的结果。4. 数据持久化重启不丢数据4.1 PersistentStorage 还是首选项待办应用最烦的就是杀进程数据全没。OpenHarmony 里做本地持久化有几种方案PersistentStorage、ohos.data.preferences首选项、关系型数据库RelationalStore、分布式数据服务。MVP 阶段我用的是PersistentStorage它最贴合状态驱动模型。简单说PersistentStorage可以把某些状态自动同步到磁盘。第一次赋值时写入持久化文件App 下次启动时自动读取并恢复。用法如下PersistentStorage.persistProp(tasks, [] as Task[])然后在组件里用StorageLink与这个持久化属性建立双向绑定StorageLink(tasks) State tasks: Task[] []StorageLink和State的区别在于StorageLink不仅能触发 UI 更新还能把新值写回PersistentStorage对应的键。相当于给State加了一个“自动存档”的外挂。如果你不想依赖PersistentStorage也可以用首选项手动存储。首选项是 OpenHarmony 标准的键值对存储接口需要拿Context获取实例然后put和get操作完要flush。复用它在代码上更繁琐但它更通用跨模块调用也容易。我的建议是如果不介意多写几行代码用首选项因为PersistentStorage对复杂数据类型的序列化支持比较有限后面会详细说明坑在哪。首选项的典型写法import dataPreferences from ohos.data.preferences import common from ohos.app.ability.common let context getContext(this) as common.UIAbilityContext let preferences dataPreferences.getPreferencesSync(context, { name: todo_store }) // 存 preferences.putSync(tasks, JSON.stringify(this.tasks)) preferences.flush() // 取 let raw preferences.getSync(tasks, []) as string this.tasks JSON.parse(raw) as Task[]用首选项的一个额外好处是你可以在应用启动时通过一个loadTasks()方法把数据读出来再赋给State。数据从磁盘到内存这个过程变成了显式代码控制感更强。4.2 存储、恢复与类型还原的三个坑第一个坑JSON.parse 回来的数组类型上说是Task[]但运行时对象其实没有经过接口校验。最常见的现场是你给task增加了一个新字段比如优先级但本地存储的还是旧数据旧数据没有这个字段然后代码里读取task.priority得到 undefined界面显示异常。解决办法是加一个数据迁移函数在加载时逐条检查字段缺失的给默认值const plainTasks JSON.parse(raw) as Task[] this.tasks plainTasks.map((item: Task) ({ id: item.id, content: item.content ?? , done: item.done ?? false, createTime: item.createTime ?? Date.now() }))第二个坑PersistentStorage在处理数组时刷新机制没有State那么灵敏。我在调试中就遇到过添加任务后界面刷新了但重启后任务没有全部恢复。排查后发现PersistentStorage对“整体赋值新数组”响应正常但对“只有个别元素变化”的同步并不可靠。所以只要你坚持用this.tasks [...this.tasks, task]这种整体替换的方式问题就不大。这再次印证了不可变更新不仅是 UI 刷新的要求也是持久化可靠性的要求。第三个坑筛选状态下删除任务的诡异现场。如果你在“已完成”筛选中删除一条任务看起来列表少了一条。但如果这条任务被删后tasks里还有剩余已完成任务那没问题可如果你删的是最后一条已完成任务currentFilter还停在completed列表会变成空白页。这个小场景很典型状态之间是有依赖关系的删除数据的逻辑要考虑筛选条件是否还成立。我的处理方式是加一个兜底判断if (this.currentFilter completed this.tasks.every((task: Task) !task.done)) { this.currentFilter all }这样用户不会面对一个“空筛选结果”的发呆界面。5. 踩坑实录与质量复盘5.1 调试环境三个老大难模拟器与预览器不一致。DevEco Studio 里有两个运行通道Previewer 预览器启动快但它对系统能力和底层接口的支持不全比如PersistentStorage在某些预览器版本上不生效界面能看到数据持久化却不工作。我一度以为代码写错了切到模拟器才发现功能正常。所以涉及系统能力的功能务必在模拟器或真机上验证。模拟器启动慢且占内存。OpenHarmony 模拟器在低配机器上启动可能要两三分钟时间长了还会卡。我的经验是先把 UI 静态部分用 Previewer 快速迭代确认布局没大问题逻辑部分用 Previewer 也能跑但涉及持久化和设备能力时再切模拟器。这样可以减少无效等待。键盘遮挡输入框。在模拟器里点击 TextInput 弹起键盘后输入框和列表下方的按钮可能被遮挡。简单处理是用全局expandSafeArea和avoidArea属性更通用的做法是把主界面放进Scroll里让键盘顶起时页面可以滚动。这个场景下最省事的是给输入行设置为固定在顶部区域列表区域自动压缩实现代码是Column({ space: 12 }) { // 输入行 // 列表 } .keyboardAvoidMode(KeyboardAvoidMode.RESIZE)5.2 状态相关的典型问题速查表写这个小项目过程中遇到了不少典型的、新人也最容易碰到的状态问题整理成一张速查表方便你对照自查。症状根本原因解决办法调用了push但列表不刷新State对数组就地修改观测能力弱用展开运算符创建新数组赋值修改了task.done界面没变化直接改了对象属性框架感知不到返回新对象{ ...task, done: !task.done }列表删除后错位、残留ForEach 的 key 用了数组下标改用业务唯一 id 作为 key输入中文时值丢了一半键盘联想导致 onChange 频繁触发输入内容绑定状态以最后一次值为准筛选到空列表界面空白状态之间缺少联动兜底判断删除后筛选结果为空重置筛选条件杀掉应用后数据丢失没有接入持久化或 PersistentStorage 没生效用首选项或整体替换方式持久化类型检查通过但运行时报 undefinedJSON 恢复的数据缺少新字段加载时逐条补默认值做迁移5.3 从 MVP 到生产级后续还能怎么扩展这个项目做到这里算是一个完整的闭环了但如果你想让它在架构和功能上再往前走一步有几个方向和思路值得接着折腾。功能层面可以加优先级分级。也就是给 Task 增加priority字段支持高/中/低三档列表按优先级排序。不要小看这个改动它考验的是排序逻辑如何与筛选、状态更新共存。也可以加编辑能力点击任务内容进入可编辑状态保存时更新对应任务。状态层面当tasks不只在一个页面使用时就要考虑把状态提升到应用级。用AppStorage或StorageLink就能实现跨页面共享。页面多了之后建议抽出独立的业务逻辑层像一个TaskStore类专门封装addTask、toggleTask、deleteTask、loadTasks页面只负责调用和渲染。这其实就是轻量级的单向数据流架构也是状态驱动思想走向生产级的自然延伸。数据层面如果任务量大了比如上千条继续用首选项存全量 JSON 会有性能问题。届时可以换成RelationalStore按 id 建表增删改查走 SQL启动加载和状态恢复都更快。如果想做多端协同还能用分布式数据服务让手机和平板之间实时同步任务。那些就离“最小可行”很远了但思考路径是一脉相承的始终让数据处于核心位置界面只是数据的一种投影。我个人做完这个小项目的感受是待办清单虽然简单但它是理解 ArkUI 状态管理机制的绝佳载体。写其他页面时你可能会疑惑“这个状态该放组件里还是应用里”在待办清单里你被逼着拆解每个交互背后的数据流转想清楚每一次点击到底改变了什么。这种训练比背十个装饰器的定义都管用。最后再分享一个小技巧调试状态类问题时不要光看界面打开 DevEco Studio 的调试器在addTask、toggleTask这些函数里打上断点观察this.tasks的引用是否变化。引用变了界面没刷那是框架问题引用没变界面没刷那是你的更新方式不对就去按上面的表排查。抓住“引用变化”这个核心状态管理的各种坑都能迎刃而解。