OpenHarmony应用开发实战:用ArkTS状态驱动构建待办清单
最近一直在折腾 OpenHarmony 应用开发总感觉学一个新框架最有效的方式不是照着官方 Demo 敲一遍而是用一个真正能跑、能解决实际需求的小项目把它的核心机制用透。待办事项清单就是这样一个经典练手项目看起来简单却几乎覆盖了声明式 UI 开发的所有关键环节——状态管理、数据绑定、列表渲染、数据持久化。这篇文章就是我从零构建一个 OpenHarmony 简易待办事项清单的完整记录核心思路可以浓缩成八个字状态驱动最小可行。如果你刚接触 ArkTS 和 ArkUI或者想做一个小工具落地 OpenHarmony 设备又或者单纯想搞明白“状态驱动”这四个字到底意味着什么这篇内容都值得你花二十分钟看完。我会把工程怎么搭、状态怎么设计、代码怎么组织、数据怎么落地以及我踩过的几个坑全部摊开来讲。你可以直接照着复现也可以把它当成自己项目的起点。1. 项目定位为什么是待办清单为什么是状态驱动1.1 先砍到一个“刚刚好”的 MVP很多人在入门一个新平台时容易犯一个毛病——项目一开始就想要“大而全”。我见过有人拿 OpenHarmony 练手直接做 IM 工具的也见过第一版就想上多端同步的。我的建议是第一个项目一定要砍到只剩必需功能也就是所谓的最小可行产品MVP。待办清单这个场景天然适合做 MVP。对一个任务管理工具来说最核心的动作无非就四件事添加一条待办标记一条待办为已完成删除一条待办以及把数据保存下来等下次打开还在。至于分类、标签、提醒、拖拽排序这些都属于“锦上添花”第一版完全可以不碰。我最终确定的功能边界非常清晰功能优先级说明添加待办必须有输入文字后回车或点击按钮插入列表顶部或底部标记完成/未完成必须有点击左侧圆圈切换已完成项显示删除线删除待办必须有每条右侧提供删除入口已完成数量统计要有但可精简底部一行文字显示总数和完成数数据持久化必须有杀掉应用重开列表不丢砍掉那些花哨功能之后整个项目就只有三个层面数据层待办数据结构、状态层被 State 等装饰器管理的变量、视图层ArkUI 组件树。这种分层足够简单但又不至于简单到像官方 Demo 那样只展示一个按钮恰好能把状态驱动的核心机制完整过一遍。1.2 状态驱动和传统命令式到底差在哪先解决一个问题什么叫“状态驱动”我见过太多初学者在 ArkUI 里写代码脑子里还带着传统命令式 UI 的思维结果处处碰壁。打个比方。你用 Excel 做了两张表一张是原始数据一张是根据数据生成的图表。你在数据表里改一个数字图表立刻跟着变你不需要手动去“重画”图表。这就是状态驱动的本质数据是唯一可信源界面只是数据状态的一种投影。在命令式 UI 时代比如传统的 Java 界面或者早期的 JS 编程你要更新界面得“找到那个控件手动改它的属性”。比如拿到一个 TextView 实例调用setText()。这种方式的缺点是当界面复杂到一定程度你需要记住“哪个控件、什么时候、被谁改了”状态管理会变得极其痛苦。而 ArkUI 这套声明式框架不一样。你只需要声明“界面长什么样”至于界面和数据如何联动交给框架内部的观察-通知机制去处理。当你在代码里改了被 State 修饰的变量框架会自动重新渲染依赖它的组件。数据变了界面自己就跟着变了。这就是“状态驱动”的含义。对 OpenHarmony 应用开发来说ArkTS 是官方主推的应用层语言UI 描述用的是 ArkUI 声明式语法。你可能会好奇OpenHarmony 整个系统是用什么语言写的其实系统底层主要是 C/C负责内核、图形渲染、硬件抽象这些重活而应用层则支持 ArkTS / TypeScript / JavaScript让普通开发者能用更友好的方式写应用。我们这篇文章里所有代码都是标准的 ArkTS 代码。1.3 为什么这个项目适合作为状态驱动入门顺着上面的思路待办清单项目的好处就非常明显了它状态少但关系清楚。整个应用只需要一个核心状态——待办列表。围绕这个状态衍生出输入框内容、已完成数量这些次要状态。你可以在有限的代码量里完整看到“状态定义-状态变更-界面刷新”的闭环并且能直观感受到“改数据比改控件”爽在哪里。与此同时OpenHarmony 应用层提供了一整套状态管理装饰器比如 State、Prop、Link、Observed、Provide 等。待办清单这种单页面小项目用 State、Prop 这几个就足够搞定一切不用引入路由、不用搞网络请求、不用碰数据库。做完之后你会发现你对这些装饰器的理解比看十遍官方文档都扎实。2. 环境准备与工程搭建2.1 选对 IDE 和 SDK 版本先把开发环境搞定。OpenHarmony 应用开发目前最顺手的工具是 DevEco Studio这是官方基于 IntelliJ 的 IDE界面和 Android Studio 很像用过 JetBrains 系 IDE 的基本零成本上手。版本选择上我的建议是不要追太新的 Preview 版用已经稳定发布、社区反馈多的正式版。我实际使用的是 DevEco Studio 4.0 系列配合 OpenHarmony SDK API 9/10真机用的是 OpenHarmony 开发板。如果你的目标设备是 HarmonyOS 手机SDK 选 HarmonyOS 套件即可ArkTS/ArkUI 的写法是同一套代码基本可以无缝迁移。模拟器方面DevEco Studio 自带的 Local Emulator 可以跑但我体验下来OpenHarmony 模拟器启动速度不算快。如果手头有开发板或支持鸿蒙系统的真机直接真机调试会更接近实际场景。有一点提前提醒你第一次把应用跑到真机上需要在工程里配置好签名和证书。DevEco Studio 有自动签名工具跟着向导走一遍一般十分钟能搞定。工程创建不要选那些复杂的模板直接选Empty Ability就行。一个空工程会生成标准的 ability 入口和 pages 目录足够我们开始写代码。2.2 工程结构需要知道的那几件事创建完工程后你会看到一个类似这样的目录结构entry/ src/main/ ets/ entryability/ EntryAbility.ets pages/ Index.ets common/ TodoModel.ets TodoStorage.ets TodoRow.ets resources/ base/element/string.json base/profile/main_pages.json module.json5这个结构其实已经很直白EntryAbility.ets是应用入口相当于 Activity/入口页的生命周期管理一般不需要大改。pages/Index.ets是首页 UI。我们大部分界面代码会写在这里。common/目录是我自己建立的放数据模型、工具类、自定义组件。这个目录不是强制要求但建议把不同职责的代码拆开别全堆在一个页面文件里。module.json5是模块配置权限、窗口模式、页面路由都在里面。resources/存放字符串、颜色、图标等资源另外新建资源文件也会放在这里。初学阶段你甚至可以只改pages/Index.ets一个文件完成整个应用但那样代码会变得很长而且不利于理解组件化。我的做法是数据模型和持久化工具放common/自定义列表项组件放common/TodoRow.ets页面本身只负责状态管理和布局组合。这样一个文件解决一个问题后面排查 bug 时定位特别快。2.3 ArkTS 上手前要建立的两个认知在写代码之前有两个关于 ArkTS 的认知值得先建立起来。第一ArkTS 是 TypeScript 的超集强化版但又比标准 TypeScript 更严格。它禁止使用any类型强制要求变量初始化对对象字面量也有类型要求。这看起来增加了约束但对工程化其实是件好事——很多低级错误在编译期就暴露了不会留到运行时才爆炸。我在写代码时习惯了把所有数据结构都定义成 class所有方法参数都标注类型整体代码反而变得特别好读。第二ArkTS 里不能像写传统 TS 那样随意动态加属性。对象是什么形状在创建时就要定下来。比如定义一个待办数据类字段就那几个类外不能临时给它挂一个新属性。这种“强制确定性”恰恰符合状态驱动的思路数据结构稳定状态变化就有迹可循。小建议如果你之前的开发经验主要在 Java、PHP、Python 这类语言刚接触 ArkTS 时把“类型安全”当成朋友而不是枷锁。你会发现编译器的报错提示往往能帮你提前发现很多逻辑 bug。3. 数据模型与状态层设计3.1 先定义一个干净的数据模型整个应用最核心的数据结构就是一条待办事项。我先建了一个TodoItemData类字段如下export class TodoItemData { id: number // 唯一标识 title: string // 内容文字 done: boolean // 是否已完成 createdAt: number // 创建时间戳 constructor(title: string ) { this.id -1 this.title title this.done false this.createdAt Date.now() } }你可能会问一个待办事项为什么需要id和createdAt这两个字段不是浪费它们对状态驱动的稳定性影响很大。id是列表渲染的唯一 key。待办事项内容可能是重复的但 id 不会重复。后面我们用 ForEach 渲染列表时key 必须稳定且唯一否则会出现界面错乱。createdAt虽然第一版用不上但它是一个天然的排序依据和调试信息以后做“按时间分组”时不用改模型。为什么不直接用数组下标做 key因为列表一旦发生删除、插入操作下标就会变。比如删掉第 0 项原来的第 1 项变成新的第 0 项。如果 key 是下标那么同一个 key 对应对不同内容的项界面可能复用错乱。id 是稳定的删除第 0 项剩余项的 id 都不会变渲染就不会出问题。3.2 State、Prop、Link 怎么选OpenHarmony 的状态管理装饰器有好几个初学最容易混淆的就是 State、Prop、Link 三者。我用一个对比表帮你理清关系装饰器同步方向典型使用场景注意事项State自身状态驱动自身 UI 刷新页面级变量如待办列表、输入框内容只在本组件内生效Prop父传子单向同步子组件展示不可修改或只读的数据子组件内修改不会同步回父组件Link父子双向同步子组件需要修改同一个数据源修改会直接改到父组件的源数据在这个待办项目里我的设计是在Index.ets页面组件中用State todoList: TodoItemData[] []管整个列表。用State inputText: string 管输入框内容。在TodoRow子组件中用Prop item: TodoItemData接收单条待办数据只负责展示不直接修改数据。为什么不把todoList直接传给子组件让每个子组件自己修改自己的那条数据因为这会引入双向绑定的复杂度。我更倾向于“数据统一管理动作向上传递”的模式子组件点击触发一个回调函数由父组件修改数组再通过 Prop 把新的数据传回来。这样数据的唯一入口在父组件任何修改都经过同一套逻辑排查问题的成本大大降低。3.3 数组更新为什么我坚持“换新”而不是“改旧”接下来这部分是我在 OpenHarmony 开发中踩过的最重要的坑之一值得单独拿出来讲清楚。很多人在 State 数组上直接调用push、splice这类“原地修改”的方法然后发现界面没刷新或者刷新得乱七八糟。原因很简单State 对数组的“移动监听”在不同版本上表现并不一致尤其当你修改的是数组中某个对象的属性时框架不一定能感知到对象内部的变化。我采用的办法是“不可变更新”风格每次修改数组都生成一个新数组整体赋值给 State 变量。不修改原数组不依赖框架对深层对象变化的监测跨版本稳定性最好。下面这段代码就是核心的添加逻辑addTodo(): void { const title this.inputText.trim() if (title ) { return } const item new TodoItemData(title) item.id this.nextId() this.todoList this.todoList.concat(item) // 生成新数组 this.inputText this.saveData() }concat不会改变原数组而是返回一个新数组。赋值给this.todoList时State 明确知道“整个数组引用变了”于是必然触发重新渲染。这个做法比push多了一点内存开销但一个待办列表撑死几百条数据这点开销可以忽略不计。换回的是确定性这个交易非常划算。同样的思路切换完成状态时会完整复制对象并修改副本而不是原地改属性toggleTodo(id: number): void { const newList: TodoItemData[] [] this.todoList.forEach((item: TodoItemData) { const copy new TodoItemData(item.title) copy.id item.id copy.done item.done copy.createdAt item.createdAt if (item.id id) { copy.done !copy.done } newList.push(copy) }) this.todoList newList this.saveData() }这样既保证了 State 变量被整体替换又保证了每一个列表项对象都是新对象Prop 传下去的数据引用也变了UI 一定会刷新。4. 界面实现从一个空页面到一个可用的清单4.1 页面骨架先立起来界面布局我用的是 ArkUI 最基础的Column和Row。整体结构分成三块顶部的标题栏、中部的输入区和列表区、底部的统计栏。代码如下Entry Component struct Index { State todoList: TodoItemData[] [] State inputText: string build() { Column() { // 标题栏 Row() { Text(待办清单) .fontSize(24) .fontWeight(FontWeight.Bold) } .width(100%) .padding(16) // 输入区 Row({ space: 8 }) { TextInput({ placeholder: 输入待办内容, text: this.inputText }) .layoutWeight(1) .height(44) .onChange((value: string) { this.inputText value }) .onSubmit(() { this.addTodo() }) Button(添加) .height(44) .onClick(() { this.addTodo() }) } .width(100%) .padding({ left: 16, right: 16, bottom: 12 }) // 列表区 if (this.todoList.length 0) { Blank() Text(暂无待办输入第一条吧) .fontColor(#999999) Blank() } else { List() { ForEach(this.todoList, (item: TodoItemData) { ListItem() { TodoRow({ item: item, onToggle: (id: number): void this.toggleTodo(id), onDelete: (id: number): void this.removeTodo(id) }) } }, (item: TodoItemData) item.id.toString()) } .layoutWeight(1) } // 底部统计 Row() { Text(共 ${this.todoList.length} 项已完成 ${this.completedCount()} 项) .fontSize(14) .fontColor(#666666) } .width(100%) .padding(16) } .width(100%) .height(100%) } }一个页面组件基本就是这个骨架。有几个细节说明一下.layoutWeight(1)是让输入框占满剩余宽度让 List 区占满屏幕剩余高度这样底部统计栏始终在最下端。列表为空时我用if分支展示一个空状态提示。这也是状态驱动的好处不需要手动控制“显示空提示”还是“显示列表”完全由todoList.length 0这个状态决定。Blank()是一个可伸缩的空白填充组件用来把空提示推到垂直居中位置。4.2 添加交互输入、回车、校验添加待办的入口有两个点击“添加”按钮或者软键盘上的回车键。两个入口最终都走同一个addTodo()方法。这里我特别关注三个坑第一个坑是空字符串。如果用户输入了纯空格或者什么都不输直接回车待办列表里不该出现空项。所以我在addTodo()开头用trim()去掉首尾空格如果结果为空就直接返回。第二个坑是输入框状态。点击添加之后输入框里应该清空否则用户要手动删除上一个输入内容体验很差。所以添加成功后我立刻把this.inputText 同时 TextInput 绑定了text: this.inputText界面会自动清空不用手动设置组件内容。第三个坑是重复提交。如果用户连点两下“添加”按钮可能添加两条同样的待办。这里可以在addTodo()里加一个防止重入的开关或者把按钮设置成点击后暂时禁用。我的做法比较轻量在添加逻辑执行过程中如果当前输入内容还是上一次的内容就直接忽略第二次点击。这属于细节打磨但对体验提升很实在。4.3 列表渲染ForEach 的第三个参数必填列表是待办清单的核心。ArkUI 渲染列表用的是 ForEach它的基本语法是三个参数数据源、builder 函数、key 生成器。很多人会漏掉第三个参数或者图省事不写结果列表一增删就各种诡异。ForEach( this.todoList, (item: TodoItemData) { ListItem() { TodoRow({ ... }) } }, (item: TodoItemData) item.id.toString() )第三个参数就是 key 生成器。我返回的是item.id.toString()。这个 key 必须满足两个条件唯一且稳定。唯一好理解id 每个都不重复稳定指的是在列表增删过程中未被删除的项 key 不能变。用 id 恰好满足要求。如果你用数组下标当 key删除第 0 个元素后原来第 1 个元素的下标变成 0key 就变了。框架会认为这是一个新项的插入和另一个项的删除可能错误地复用或重排组件轻则动画错乱重则内容错位。这个坑我踩过一次排查了很久才发现是 key 的问题。4.4 每个列表项内部长什么样自定义列表项组件TodoRow是界面里的重点。它不是页面组件而是一个可复用的Component组件。它接收一条待办数据以及两个回调函数。这里用到一个设计原则子组件不直接修改数据只把“发生了什么事”告诉父组件。Component export struct TodoRow { Prop item: TodoItemData new TodoItemData() onToggle: (id: number) void () {} onDelete: (id: number) void () {} build() { Row({ space: 12 }) { // 左侧圆形勾选按钮 Circle({ width: 22, height: 22 }) .fill(this.item.done ? #317AF7 : #FFFFFF) .stroke(this.item.done ? #317AF7 : #CCCCCC, 2) .onClick(() { this.onToggle(this.item.id) }) // 待办文字 Text(this.item.title) .fontSize(16) .fontColor(this.item.done ? #999999 : #222222) .decoration({ type: this.item.done ? TextDecorationType.LineThrough : TextDecorationType.None }) .layoutWeight(1) // 删除 Text(删除) .fontSize(14) .fontColor(#E84026) .onClick(() { this.onDelete(this.item.id) }) } .width(100%) .padding({ left: 16, right: 16, top: 12, bottom: 12 }) .borderRadius(12) .backgroundColor(#F5F5F5) } }我没有用官方的 Checkbox 组件而是用Circle画了一个圆圈通过填充颜色和描边颜色区分完成状态。原因有两个一是待办清单的完成态通常是一个圆圈对勾用 Circle 视觉效果更干净二是 Circle 本身就可以响应点击事件写起来和 Checkbox 一样简单但对展示样式的控制更强。文字部分在完成态下加了删除线。这里再次体现了状态驱动的妙处done字段一变文字样式自动跟着变我们不用写任何“更新文字样式”的语句。你只要把done当成唯一可信的状态源界面的一切表现都是它派生出来的。底部统计栏的数字也是同理private completedCount(): number { return this.todoList.filter((it: TodoItemData) it.done).length }这个函数在 build 时会被调用。每当todoList变化统计数自动更新。整个过程没有一行命令式代码。5. 数据持久化让待办在重启后依然存在5.1 不持久化的后果如果你把上面的代码跑起来你会发现功能完全正常可以添加、勾选、删除。但一旦杀掉应用重新打开列表空空如也。这其实不难理解State todoList是内存数据生命周期只存在于应用运行期间。对于任务管理类应用来说这是不可接受的行为。用户记下的待办重启设备后不应该消失。所以我们必须把列表数据写到某个持久化存储里。OpenHarmony 应用层可用的持久化方案有好几种首选项、关系型数据库、分布式数据服务等。这个项目用不上数据库数据量小、结构简单用用户首选项Preferences就足够了。Preferences 本质上是键值对存储适合轻量数据读写效率高实现也简单。5.2 Preferences 的完整实现方案我封装了一个TodoStorage类负责加载和保存待办列表。为了让页面代码保持干净所有关于文件、路径、序列化的细节都藏在这个类里。import dataPreferences from ohos.data.preferences import { common } from ohos.app.ability.common import { TodoItemData } from ./TodoModel const PREF_NAME todo_app const KEY todo_items export class TodoStorage { static async load(context: common.UIAbilityContext): PromiseTodoItemData[] { try { const pref await dataPreferences.getPreferences(context, PREF_NAME) const raw await pref.get(KEY, []) as string const arr: TodoItemData[] JSON.parse(raw) as TodoItemData[] return arr.map((it: TodoItemData) { const item new TodoItemData(it.title) item.id it.id item.done it.done item.createdAt it.createdAt return item }) } catch (err) { return [] } } static async save(list: TodoItemData[], context: common.UIAbilityContext): Promisevoid { const pref await dataPreferences.getPreferences(context, PREF_NAME) await pref.put(KEY, JSON.stringify(list)) await pref.flush() } }解释一下几个关键点。getPreferences(context, PREF_NAME)返回一个 Preferences 实例PREF_NAME是这个存储文件的逻辑名。Preferences 存储的值必须是可序列化的基础类型或对象所以待办数组在写入前要用JSON.stringify(list)转成字符串读取时再用JSON.parse(raw)解析回来。这就是我的“JSON 序列化方案”。为什么需要context因为 Preferences 存储的位置和应用沙箱相关必须拿到当前 UIAbility 的 Context 才能正确访问。在Index.ets页面组件里我用getContext(this)拿到上下文aboutToAppear(): void { const context getContext(this) as common.UIAbilityContext TodoStorage.load(context).then((list: TodoItemData[]) { if (list.length 0) { this.todoList list } }) }aboutToAppear是页面组件即将出现时触发的生命周期回调在这里加载持久化的数据时机刚刚好。加载完成后也会用一个整体赋值设置State todoList同样走“换新”的路线确保界面刷新。保存的时机我放在每次变更完成后也就是addTodo、toggleTodo、removeTodo三个方法各自在最后调用saveData()。这样实现最简单也不用考虑防抖优化。如果你以后想做复杂项目可以改成定时批量保存或者用flush延迟策略但现阶段每次保存都是十几行数据性能完全没问题。5.3 另一个选项AppStorage PersistentStorage 的取舍除了 Preferences我在调试过程中也试过 OpenHarmony 提供的官方“应用存储”方案AppStorage和PersistentStorage。这套方案和 UI 状态结合更紧密使用起来相当“声明式”PersistentStorage.persistProp(todoJson, [])然后页面里用StorageLink(todoJson)绑定一个同名变量这个变量就会自动同步到持久化存储。听起来很完美但实际项目中我遇到一个很别扭的问题StorageLink对复杂对象的自动序列化支持并不稳定重启后经常出现“读到了初值”的情况。后来我把数据统一转换成 JSON 字符串再存入 AppStorage问题才解决。所以我的经验是如果你的数据结构很简单比如一个数字、一个开关状态用 AppStorage PersistentStorage 会很舒服代码量少和 UI 联动也好。但像待办列表这种“数组套对象”的结构我建议直接用 Preferences 手动序列化把控制权握在自己手里不要依赖框架的隐式转换。对比项Preferences JSON 序列化AppStorage PersistentStorage代码量稍多需要手动序列化少装饰器自动同步复杂数据结构支持完全可控对象数组跨版本稳定性差和 UI 状态联动需要手动加载赋值天然联动推荐场景数组、对象等复杂结构简单标量状态两者的选择并不绝对关键是你对结构的控制需求。做工程最忌讳“看着能用就行”底层数据的读写逻辑一定要做到心里有数。6. 避坑与调试实录6.1 列表不刷新最经典的坑你写完第一版信心满满点下“添加”列表没反应。先别怀疑人生大概率是状态更新方式出了问题。我复盘一下几种可能性第一种直接用push修改数组而当前版本的 State 没有感知到变化。这个我在前面已经反复强调过用concat或展开运算符生成新数组赋值即可解决。第二种修改的是数组里某个对象的属性例如this.todoList[0].done true。这个问题更隐蔽因为有时看起来生效了有时又没生效。根本原因在于 State 对嵌套对象的观测深度有限。我的建议是不要依赖深层观测把整个对象复制一份再整体替换一劳永逸。第三种ForEach 的 key 生成器有问题导致组件复用错乱看起来像“没刷新”。这种情况其实界面变了只是变在错误的位置上。检查 key 是否唯一且稳定特别是删除后 key 是否保持不变。注意新版 SDK 和旧版 SDK 对 State 数组的观测能力有所差异。不要指望“我换个版本就正常”统一使用不可变更新的写法才是跨版本最稳妥的做法。6.2 Prop 数据不同步为什么子组件还是旧数据有一次我把待办勾选状态改完列表大部分正常但个别行的删除线没出来。排查了半天发现不是渲染问题而是我给 TodoRow 传的是同一个对象引用Prop 的同步依赖的是“赋值一个新的对象”这种触发机制。如果你直接改原对象的属性Prop 可能不会收到通知。这也是为什么我在toggleTodo里坚持创建新的 TodoItemData 副本而不是修改原对象后塞回原数组。复制一份再整体替换既保证 State 感知数组变化又保证 Prop 感知对象变化。两个触发条件同时满足界面才稳定。如果你的子组件里有“自己维护一份副本”的需求记得明确这一份副本的更新策略是从 Prop 同步还是本地独立状态。这两者混用基本都会踩到数据不同步的坑。6.3 初始化时序为什么加载完数据又变空了还有一个非常容易踩的时序坑aboutToAppear里做异步加载但加载完成前用户已经点了添加按钮然后加载结果回来直接覆盖了用户新加的数据。我在第一次实现时就是这么写的aboutToAppear里TodoStorage.load().then(list { this.todoList list })看起来没毛病。但如果加载不及时用户手快已经加了一条新待办加载结果一回来整表被旧数据覆盖新待办消失了。解决办法有两种。一种简单粗暴加载完成后再允许操作界面上加个 loading 状态。另一种是合并逻辑加载回来时不是直接覆盖而是把当前列表和加载结果合并。对 MVP 来说我建议用 loading 状态最简单State isLoading: boolean true aboutToAppear(): void { // 加载完成后置 false }列表区根据isLoading显示加载中提示还是内容。这个状态也是一个典型的“状态驱动”案例界面显示什么完全由加载状态决定。6.4 输入框被软键盘遮挡真机上另一个常见问题是当软键盘弹出来时输入框被键盘挡住看不到正在输入的内容。这个问题的根源在于窗口默认的键盘避让模式。OpenHarmony 的窗口支持键盘避让场景设置。我处理的方法是在EntryAbility的窗口创建逻辑里获取主窗口后设置合适的键盘避让模式让窗口主要内容自动上移或者使用全屏模式配合avoidArea调整布局。不同 SDK 版本的具体 API 名称略有差异建议直接搜索你当前 SDK 版本对应的窗口避让接口名。配套的细节是输入框组件最好放在页面中偏上的位置或者使用.layoutWeight(1)让列表区占据大部分高度这样即使用键盘避让不理想输入框不被遮挡的概率也更高一些。6.5 别忘了兼容性视角如果你做的应用以后要发布到应用市场或者要过 OpenHarmony 生态的兼容性测试有一点值得提前养成习惯持久化、文件读写这类系统能力一律走官方标准接口不要自己写死路径。比如 Preferences 的存储位置永远通过 Context 获取不要硬编码/data/...这样依赖具体系统的路径。我这里说这些并不是为了什么“认证”而过度设计而是同一套代码在多设备、多版本上运行的稳妥之道。7. 扩展方向与个人体会做完这个 MVP 之后我最大的感受是OpenHarmony 应用开发的状态驱动范式一旦建立起“数据是唯一可信源”的思维写界面会非常顺畅。以前写界面要先想“哪个控件需要更新”现在只需要想“数据怎么变”剩下的交给框架。这种思维转变带来的效率提升是几何级的。如果你想把这个小项目继续往下做我根据个人经验给你几个方向参考一是给待办分组。可以在 TodoItemData 里加一个groupId字段或者引入“分类”这个概念再用Observed和ObjectLink处理嵌套对象的状态观测。这就比现在单层数组要复杂一些但很有代表性。二是加入优先级排序。给每条待办加priority字段列表展示时根据优先级排序底部加一些筛选按钮。这可以帮你练习“从同一状态派生出多个视图”的状态派生技巧。三是做数据同步。如果你有多个 OpenHarmony 设备可以考虑用分布式数据服务把待办列表同步到另一台设备上。这是 OpenHarmony 相对独特的优势也是从本地单机走向跨设备协同的绝佳练习。最后分享一个我在实际开发中的小习惯状态命名尽量以名词为主比如todoList、inputText、isLoading不要用data1、flag这类模棱两可的名字。当你做状态驱动的 UI 时状态名称就是你在代码里反复念叨的词起好名字调试时能省下大量脑力。这个项目虽小但在状态管理、组件化和持久化上的处理思路完全可以平移到更复杂的 OpenHarmony 应用中希望这篇记录能帮你少踩几个坑。