GestureGroup 自学指南：一次搞懂组合手势（三种模式全解析）
在 ArkUI 里，单个手势（点击、长按、滑动、缩放…）已经够好用，但一旦你要做这种交互：长按后才能拖动同一区域支持 单击 / 双击 且行为不同两个手势要 同时识别 或互斥就会发现仅靠 TapGesture / PanGesture 这些基础手势不太好管理——这时候就轮到主角 GestureGroup 登场了。本文定位就是一篇可以直接发社区的实战向自学笔记，按这几个问题展开：GestureGroup 是什么？解决什么问题？三种 GestureMode 到底怎么选？如何正确组合单击 / 双击、长按 + 拖动？onCancel 在真实项目中有什么用？一、GestureGroup 是什么？官方一句话定义：GestureGroup 用来把多个基础手势组合在一起，根据指定的识别模式统一管理。从 API Version 7 开始支持元服务 从 API 11 开始支持系统能力：SystemCapability.ArkUI.ArkUI.Full核心接口只有一个：GestureGroup(mode: GestureMode, ...gesture: GestureType[])参数说明：mode: GestureMode（必填）组合手势的“识别策略”，即三种模式：Sequence / Parallel / Exclusive...gesture: GestureType[]（可选）一个或多个基础手势实例（TapGesture、LongPressGesture、PanGesture 等）如果这里不填，那这个 GestureGroup 相当于白写，组合识别不生效⚠️ 官方特别说明：当一个组件要同时支持 单击 + 双击 时，必须把双击放前面，单击放后面，才能正确识别。二、GestureMode 三种模式，搞清区别就成功一半GestureMode 枚举定义了组合手势的识别方式：enum GestureMode { Sequence, // 顺序识别 Parallel, // 并发识别 Exclusive // 互斥识别 }2.1 Sequence：顺序识别（默认值）按照注册顺序，一个一个识别。前面的失败，后面的都不会触发。特点：只有当 前一个手势识别完成，才会进入下一个手势识别；任意一个中途失败，后面的通通不再识别；在顺序识别模式下，只有最后一个手势能触发 onActionEnd 事件。典型场景：长按后才允许拖动（长按没触发，就不让拖）双击成功则不再触发单击回调复杂手势链：长按 → 拖动 → 抬手触发某种状态收束2.2 Parallel：并发识别所有手势同时识别，互不干扰。特点：注册的所有手势“并行”识别；各自成功或失败 互不影响；适合“多个手势可以同时成立”的场景。典型场景：同一组件既要识别 PinchGesture（缩放）又要识别 RotateGesture（旋转）；类似“边拖动边缩放”的复杂交互。2.3 Exclusive：互斥识别所有手势一起识别，谁先成功，就“赢”，其余都视为失败。特点：有点像“抢占式”的识别模式；一旦其中一个手势识别成功：其他手势立即失败；结束整个组合手势识别。典型场景：同区域要么触发“滑动删除”，要么触发“点击打开”，不能两者都触发；导航区域：水平滑动切换 Tab vs 垂直滑动滚动列表，二选一。三、事件：onCancel 什么时候触发？GestureGroup 自己只有一个事件：onCancel(event: () => void)含义：手势识别成功后，如果收到触摸取消事件，会触发这个回调；常见情况：系统打断（来电、系统弹窗）父组件拦截或其它手势优先级更高触摸被提前终止在实际项目里，onCancel 通常用来做：恢复 UI 状态（比如把变成虚线的边框改回实线）；取消动画、清理资源；重置一些临时的状态变量，避免后续交互异常。四、官方示例拆解：长按 + 拖动（顺序识别）先看一下官方示例的完整版，然后逐块拆解思路。// xxx.ets @Entry @Component struct GestureGroupExample { @State count: number = 0; @State offsetX: number = 0; @State offsetY: number = 0; @State positionX: number = 0; @State positionY: number = 0; @State borderStyles: BorderStyle = BorderStyle.Solid; build() { Column() { Text('sequence gesture\n' + 'LongPress onAction:' + this.count + '\n' + 'PanGesture offset:\nX: ' + this.offsetX + '\n' + 'Y: ' + this.offsetY) .fontSize(15) } .translate({ x: this.offsetX, y: this.offsetY, z: 0 }) .height(150) .width(200) .padding(20) .margin(20) .border({ width: 3, style: this.borderStyles }) .gesture( // 顺序识别：长按成功后，才会识别拖动 GestureGroup(GestureMode.Sequence, LongPressGesture({ repeat: true }) .onAction((event?: GestureEvent) => { if (event && event.repeat) { this.count++ } console.info('LongPress onAction') }), PanGesture() .onActionStart(() => { this.borderStyles = BorderStyle.Dashed console.info('pan start') }) .onActionUpdate((event?: GestureEvent) => { if (event) { this.offsetX = this.positionX + event.offsetX this.offsetY = this.positionY + event.offsetY } console.info('pan update') }) .onActionEnd(() => { this.positionX = this.offsetX this.positionY = this.offsetY this.borderStyles = BorderStyle.Solid console.info('pan end') }) ) .onCancel(() => { console.info('sequence gesture canceled') }) ) } }4.1 交互效果总结用户先长按卡片：长按过程中，count 会累加；长按识别完成后，才会开始识别拖动：拖动时卡片跟着移动（offsetX / offsetY 更新）；边框样式变成虚线，松手恢复实线；如果中途被取消，走 onCancel。4.2 关键点解读必须用 Sequence 模式GestureGroup(GestureMode.Sequence, LongPressGesture(...), PanGesture())想要“长按 → 再拖动”这样的链式交互，最自然就是顺序识别。位移计算通过“起始位置 + 偏移量”完成this.offsetX = this.positionX + event.offsetX this.offsetY = this.positionY + event.offsetYpositionX / positionY 记录上一次拖动结束的位置；event.offsetX / offsetY 是当前手势中的增量；松手时把当前 offset 写回 position，即新起点。只在 PanGesture 的 onActionEnd 收尾因为 Sequence 模式下只有最后一个手势能触发 onActionEnd；恰好我们希望拖动结束时写入最终位置、恢复边框样式。五、经典场景：单击 + 双击共存怎么写？这是 GestureGroup 出现频率最高的需求之一。5.1 思路用 TapGesture 写两个手势：一个 count: 2 表示双击；一个 count: 1 表示单击；使用 GestureGroup(GestureMode.Sequence, 双击, 单击)；双击优先识别，成功后单击不会再触发。5.2 示例代码@Entry @Component struct TapGestureGroupDemo { @State singleCount: number = 0; @State doubleCount: number = 0; build() { Column() { Text(`单击次数：${this.singleCount}`) .fontSize(16) Text(`双击次数：${this.doubleCount}`) .fontSize(16) .margin({ bottom: 12 }) Text('点击这个区域测试单击/双击') .fontSize(18) .padding(20) .backgroundColor('#EEEEEE') .borderRadius(12) .gesture( GestureGroup( GestureMode.Sequence, // 一定要把双击放前面！ TapGesture({ count: 2 }) .onAction(() => { this.doubleCount++; console.info('double tap'); }), TapGesture({ count: 1 }) .onAction(() => { this.singleCount++; console.info('single tap'); }) ) ) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) } }✅ 小结：双击写在前面 → 既能识别双击，又不会误触单击；用 Sequence 模式就够了，不需要 Parallel / Exclusive。六、Parallel / Exclusive 模式实战思路示例这里给两个思路示例，你可以按需带入自己项目。6.1 Parallel：缩放 + 旋转同时识别伪代码示意：Shape() .width(200) .height(200) .gesture( GestureGroup(GestureMode.Parallel, PinchGesture() .onActionUpdate(e => { // 根据 e.scale 处理缩放 }), RotationGesture() .onActionUpdate(e => { // 根据 e.angle 处理旋转 }) ) )两个手势同时识别，互不阻塞；更适合“画布类”、“图片编辑器”等交互。6.2 Exclusive：滑动删除 vs 点击打开二选一思路：给同一个 Item 区域同时注册：一个 PanGesture（水平滑动触发删除）；一个 TapGesture（点击进入详情）；用 GestureGroup(GestureMode.Exclusive, PanGesture, TapGesture)；用户如果滑动成功，就进入删除逻辑，不再触发点击。伪代码示意：Row() .width('100%') .height(60) .gesture( GestureGroup(GestureMode.Exclusive, PanGesture({ direction: PanDirection.Horizontal }) .onActionEnd(e => { // 滑到一定距离后，触发删除 }), TapGesture({ count: 1 }) .onAction(() => { // 打开详情页 }) ) )七、GestureGroup 使用小结 & 常见坑最后快速帮你盘一遍重点：基本语法.gesture( GestureGroup(GestureMode.Sequence | Parallel | Exclusive, 手势1, 手势2, ...) .onCancel(() => { ... }) )mode 选型建议顺序链条（长按 → 拖动、双击优先于单击）：Sequence多手势同时有效（缩放 + 旋转）：Parallel多手势竞争，一个成功其他失败（滑动 vs 点击）：ExclusiveTap + 双击 必须注意顺序双击手势写前面，单击写后面；否则单击会先被识别，导致双击识别不到。Sequence 模式 only 最后一个 onActionEnd 生效需要在“最后一个手势”的 onActionEnd 里做收尾逻辑；上层流程性操作，尽量放在最后一个手势里处理。onCancel 用来兜底清理状态和 onActionEnd 不同：onCancel 是“被打断”的收尾；避免 UI 卡在“选中态 / 虚线边框 / 半透明”等中间状态。到这里，GestureGroup 的核心思路和常见用法基本都过了一遍。建议你：先把官方的长按 + 拖动例子跑起来；再自己写一个 单击 + 双击共存 的小 Demo；然后根据项目需求，尝试用 Parallel / Exclusive 把原来复杂的 if/else 手势逻辑慢慢收敛到 GestureGroup 上。用熟之后，你会发现：组合手势本身没那么难，难的是想清楚交互规则，而 GestureGroup 正好帮你把“规则”变成清晰的代码结构。
前言在上一篇文章中，我们探讨了组件内部以及深层嵌套对象的状态管理。但当我们把视角拉高，俯瞰整个应用时，会发现一个新的挑战：数据孤岛。用户在“我的”页面修改了头像，首页的左上角是不是也得跟着变？用户在“设置”里开启了夜间模式，是不是所有的页面都要瞬间切换颜色？如果只靠组件之间的父子传递（Props），我们需要把这些状态一路从根节点透传下去，这种属性钻取简直是代码维护的噩梦。更棘手的是，当用户划掉后台进程，杀掉应用，再次打开时，我们希望他上次设置的“夜间模式”依然生效。这就涉及到了内存数据与磁盘数据的同步问题。在鸿蒙 HarmonyOS 6 (API 20) 中，ArkUI 给出了一套完整的应用级状态管理方案：AppStorage全局内存、LocalStorage页面级内存以及 PersistentStorage持久化存储。今天，我们就来聊聊如何打通这三条数据流，构建一个数据互通且能记住用户习惯的应用。这份优化后的文案去除了口语化的比喻和非必要的符号，采用了更专业、精准的技术语言，强调了核心概念与运行机制。一、 AppStorage 应用内存状态的全局管理AppStorage 是应用在内存中的全局状态容器。一旦在其中存入属性，应用内的任意 Ability、Page 或自定义组件均可访问和修改。在声明式 UI 开发中，不建议在 build 函数中频繁直接调用 API。ArkUI 提供了两个专用的装饰器：@StorageLink：建立双向同步。组件内状态的修改会同步至全局，并驱动其他订阅该状态的组件更新。@StorageProp：建立单向订阅。仅接收全局状态的更新，组件内的修改不会同步回全局。例如，将“当前用户 Token”存储在 AppStorage 中。在任意页面声明 @StorageLink('userToken')，该变量即可与全局状态联通。无论何处更新了 Token，所有页面的对应变量均会实时刷新。二、 PersistentStorage 持久化数据同步AppStorage 仅存在于内存中，应用退出后数据会被清除。PersistentStorage 的作用是将 AppStorage 中的特定属性写入磁盘文件，实现持久化。PersistentStorage 并非独立的数据库，而是 AppStorage 与文件系统之间的同步机制。通常在应用启动早期（如 EntryAbility 或页面加载前）调用 PersistentStorage.persistProp('key', defaultValue)。系统会检查磁盘中是否存在该键值：若存在，则读取并覆盖 AppStorage 中的值；若不存在，则使用默认值初始化，并建立磁盘映射。连接建立后，开发者只需通过 @StorageLink 修改 AppStorage 中的数据，框架会自动监听变化并将新值写入磁盘，无需手动处理文件读写或 JSON 序列化。三、 LocalStorage 模块化状态隔离与全局的 AppStorage 不同，LocalStorage 用于解决模块化和多实例场景下的状态共享问题（如多窗口应用或独立模块）。LocalStorage 的生命周期绑定在 Ability 或 UIAbility 上下文中，创建了一个隔离的状态容器。在页面加载时，可传入独立的 LocalStorage 实例，组件内部则使用 @LocalStorageLink 进行对接。这种机制能有效保证状态的封装性与安全性，防止因全局变量过多导致的状态污染。四、 最佳实践 全局主题切换功能实现以下通过“全局主题切换”功能（支持深色模式与字体大小调整）演示三者的协同工作。需求包括：设置在所有页面生效，且应用重启后配置依然保留。实现时需遵循严格的初始化顺序：先持久化，再 UI 绑定。务必在 Ability 或文件顶层执行 PersistentStorage.persistProp，切勿在组件的 build 函数中执行持久化初始化，以免引发逻辑错误。import { promptAction } from '@kit.ArkUI'; // ============================================================= // 1. 初始化持久化数据 (必须在 @Entry 之前执行) // ============================================================= // 这里的逻辑是： // 1. 检查磁盘是否有 'appThemeMode'。 // 2. 如果有，将其加载到 AppStorage 中。 // 3. 如果没有，则使用默认值 'light' 初始化 AppStorage，并写入磁盘。 PersistentStorage.persistProp('appThemeMode', 'light'); PersistentStorage.persistProp('appFontSize', 16); @Entry @Component struct GlobalStatePage { // ============================================================= // 2. UI 绑定全局状态 (@StorageLink) // ============================================================= // @StorageLink 与 AppStorage 建立双向同步： // 读取：初始化时从 AppStorage 获取值。 // 写入：当 this.themeMode 改变 -> AppStorage 更新 -> PersistentStorage 写入磁盘。 @StorageLink('appThemeMode') themeMode: string = 'light'; @StorageLink('appFontSize') fontSize: number = 16; build() { // 根容器：背景色根据主题动态变化 Column() { // --- 顶部标题栏 --- Text('全局设置中心') .fontSize(24) .fontWeight(FontWeight.Bold) // 动态适配字体颜色 .fontColor(this.themeMode === 'light' ? '#333333' : '#FFFFFF') .margin({ top: 40, bottom: 40 }) // --- 设置选项卡片区域 --- Column({ space: 20 }) { // ----------------------------------------------------- // 选项 1：字体大小调节 // ----------------------------------------------------- Row() { Text('全局字号') .fontSize(this.fontSize) // 【关键点】应用动态字号 .fontColor(this.themeMode === 'light' ? '#333' : '#FFF') .fontWeight(FontWeight.Medium) // 调节按钮组 Row() { Button('A-') .onClick(() => { if (this.fontSize > 12) { this.fontSize -= 2; // 修改状态 -> 自动触发持久化保存 } else { promptAction.showToast({ message: '已经是最小字体了' }) } }) .backgroundColor(this.themeMode === 'light' ? '#F0F0F0' : '#444') .fontColor(this.themeMode === 'light' ? '#333' : '#FFF') .margin({ right: 10 }) .width(40) .height(32) Button('A+') .onClick(() => { if (this.fontSize < 30) { this.fontSize += 2; // 修改状态 -> 自动触发持久化保存 } else { promptAction.showToast({ message: '已经是最大字体了' }) } }) .backgroundColor(this.themeMode === 'light' ? '#F0F0F0' : '#444') .fontColor(this.themeMode === 'light' ? '#333' : '#FFF') .width(40) .height(32) } } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .padding(16) .backgroundColor(this.themeMode === 'light' ? '#FFFFFF' : '#2C2C2C') .borderRadius(16) .shadow({ radius: 8, color: this.themeMode === 'light' ? '#10000000' : '#00000000', offsetY: 2 }) // ----------------------------------------------------- // 选项 2：主题模式切换 // ----------------------------------------------------- Row() { Column() { Text('夜间模式') .fontSize(this.fontSize) .fontColor(this.themeMode === 'light' ? '#333' : '#FFF') Text(this.themeMode === 'light' ? '当前：日间' : '当前：深色') .fontSize(12) .fontColor('#888') .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) // 开关组件 Toggle({ type: ToggleType.Switch, isOn: this.themeMode === 'dark' }) .selectedColor('#0A59F7') .onChange((isOn: boolean) => { // 【关键点】修改状态 // 这一步会瞬间触发： // 1. 本页面 UI 刷新（背景变黑/白） // 2. AppStorage 全局变量更新 // 3. 磁盘文件写入 this.themeMode = isOn ? 'dark' : 'light'; promptAction.showToast({ message: `已切换至${isOn ? '深色' : '日间'}模式，设置已自动保存`, duration: 2000 }); }) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .padding(16) .backgroundColor(this.themeMode === 'light' ? '#FFFFFF' : '#2C2C2C') .borderRadius(16) .shadow({ radius: 8, color: this.themeMode === 'light' ? '#10000000' : '#00000000', offsetY: 2 }) // --- 底部提示 --- Text('提示：该设置已持久化存储。您可以尝试彻底关闭应用（杀后台），再次打开时，上述设置依然生效。') .fontSize(12) .fontColor('#999') .margin({ top: 20 }) .padding({ left: 10, right: 10 }) .textAlign(TextAlign.Center) .lineHeight(18) } .padding(16) } .width('100%') .height('100%') // 全局背景色 .backgroundColor(this.themeMode === 'light' ? '#F1F3F5' : '#121212') // 添加过渡动画，让主题切换更丝滑 .animation({ duration: 300, curve: Curve.EaseInOut }) } }总结掌握了 AppStorage 和 PersistentStorage，你就掌握了鸿蒙应用数据流动的“任督二脉”。AppStorage 打通了页面间的隔阂，让数据在内存中自由穿梭；PersistentStorage 则打通了内存与磁盘的界限，让关键数据得以长久保存。在实际开发中，建议将所有的 Keys 定义为一个常量文件，避免魔术字符串满天飞。同时，虽然全局状态很好用，但也要克制，不要把所有数据都往里塞，只存放那些真正需要全局共享和持久化的“配置级”或“用户级”数据，保持应用内存的纯净与高效。
鸿蒙 HarmonyOS 6 | 逻辑核心 (01)：状态管理进阶 @ObservedV2 与 @Trace 深度监听复杂对象
前言在鸿蒙应用的开发过程中，状态管理一直是我们绕不开的话题。如果你是从 API 9 或 API 10 一路走来的老兵，一定经历过被 @Observed 和 @ObjectLink 支配的恐惧。那时候，我们想要监听一个嵌套在对象深处的属性变化，简直就是一场噩梦。假设你有一个 User 对象，里面包含一个 Address 对象，当你试图修改 user.address.city 时，你会发现界面纹丝不动。为了解决这个问题，我们被迫把 Address 拆分成一个独立的子组件，或者暴力地重新赋值整个 Address 对象来触发更新。这种为了技术限制而通过增加组件层级来妥协的做法，不仅让代码变得臃肿，更带来了不必要的性能开销。在 HarmonyOS 6 (API 20) 中，ArkUI 团队终于为我们带来了状态管理的 V2 版本，其中 @ObservedV2 和 @Trace 的出现，彻底粉碎了嵌套对象监听的痛点，让我们终于可以像写原生 JS 一样自然地操作数据了。一、 告别 V1 时代的“洋葱式”更新在深入 V2 之前，我们有必要回顾一下 V1 版本状态管理的局限性，这样你才能深刻体会到新特性的甜头。在 V1 中，状态管理的粒度通常停留在 对象引用 级别。这意味着，框架只关心这个对象是不是原来那个对象，或者这个对象的一级属性有没有变。一旦数据结构变得立体，比如数组里套对象，对象里又套对象，框架的感知能力就会断崖式下跌。为了让 UI 响应深层数据的变化，我们过去不得不构建一种 洋葱式 的组件结构。父组件持有 User，子组件持有 Address，孙子组件持有 Street。每一层都必须严格使用 @ObjectLink 进行传递。这导致了一个后果：哪怕是一个简单的表单页，可能都需要拆分成七八个细碎的自定义组件。这不仅增加了代码的复杂度，还让组件之间的通信变得异常繁琐。而如果我们偷懒不拆组件，就只能通过 this.user.address = new Address(...) 这种“换血”的方式来强制刷新，这无疑是在用大炮打蚊子，性能损耗极大。二、 @ObservedV2 与 @Trace 的精准打击HarmonyOS 6 引入的 @ObservedV2 和 @Trace，采用了全新的代理（Proxy）机制，将监听的粒度精确到了 属性 级别。这就像是给每一个需要关注的数据字段都安装了一个微型的传感器，无论它被嵌套得有多深，只要数值发生变化，传感器就会立即向 UI 发送更新信号。使用这套新机制非常直观。首先，我们需要用 @ObservedV2 类装饰器来标记一个类，告诉框架：这个类产生的实例是需要被深度观察的。接着，对于类中那些会影响 UI 显示的核心属性，我们给它们加上 @Trace 装饰器。注意，这里有一个巨大的思维转变。我们不再需要把所有属性都变成状态，只有那些真正和界面绑定、变化时需要触发重绘的属性，才需要加 @Trace。这种按需监听的设计，从根源上减少了不必要的渲染消耗。我们可以看看下面这段定义代码，它展示了如何构建一个可深度监听的数据模型.// 定义一个深层嵌套的设置类 @ObservedV2 class Settings { @Trace theme: string = 'Light'; @Trace fontSize: number = 14; constructor(theme: string, fontSize: number) { this.theme = theme; this.fontSize = fontSize; } } // 定义用户类，嵌套了 Settings 类 @ObservedV2 class User { @Trace name: string; @Trace age: number; // 嵌套的复杂对象，只要 Settings 类被正确装饰，这里无需特殊处理 @Trace settings: Settings; constructor(name: string, age: number, settings: Settings) { this.name = name; this.age = age; this.settings = settings; } }在上面的代码中，不管是 User 还是嵌套在内部的 Settings，都被标记为了 V2 的观察对象。当你在组件中直接执行 this.user.settings.theme = 'Dark' 时，ArkUI 能够精准地捕获到这个深层属性的变化，并只更新依赖了 theme 属性的那一部分 UI，而不会导致整个 User 卡片甚至整个页面的重绘。三、 数组与集合的深度监听除了对象嵌套，数组操作也是 V1 版本的一大痛点。以前我们必须使用 ArkUI 提供的特定数组方法，或者把数组项封装成 @ObjectLink 组件才能监听到增删改查。而在 V2 中，@Trace 同样适用于数组属性。当你将一个数组标记为 @Trace 后，框架会自动代理这个数组的 push、pop、splice 等变更方法。更令人兴奋的是，如果数组中的元素本身也是 @ObservedV2 装饰过的对象实例，那么修改数组中某一个元素的属性（例如 this.users[0].name = 'New Name'），也能直接触发 UI 更新。这种 数组结构变化 与 元素内部变化 的双重监听能力，让列表类数据的处理变得异常丝滑。我们不再需要为了更新列表里的一行文字而被迫刷新整个列表数据。四、 最佳实践与注意事项虽然 V2 极其强大，但在使用时也有一些规则需要遵守。首先，@ObservedV2 只能装饰 class，不能用于接口或简单对象。其次，V2 的状态变量通常配合 @Local（组件内部状态）或 @Param（组件参数）在 UI 组件中使用，这替代了 V1 中的 @State 和 @Prop。在使用中我们要养成 精细化控制 的习惯。不要习惯性地给类里的所有属性都加上 @Trace，只给那些 UI 真正用到的属性加。比如一个用于内部逻辑计算的临时 ID 或者缓存数据，就不应该加 @Trace，这样可以减轻框架的代理负担。此外，V2 的状态追踪是基于实例的，如果你直接替换了整个对象实例，那么新实例必须也是由 @ObservedV2 装饰的类创建的，否则监听链条就会断裂。下面是一个完整的实战案例，模拟了一个“智能家居控制面板”的场景。在这个场景中，我们有一个家庭对象，里面包含多个房间，每个房间又有独立的设备。通过 V2 的深度监听，我们可以直接在父组件修改最深层的设备状态，观察 UI 是如何丝滑响应的。import { promptAction } from '@kit.ArkUI'; // ========================================================= // 1. 数据模型定义 // ========================================================= @ObservedV2 class SmartDevice { @Trace name: string; @Trace isOn: boolean; @Trace powerConsumption: number; constructor(name: string, isOn: boolean, power: number) { this.name = name; this.isOn = isOn; this.powerConsumption = power; } } @ObservedV2 class Room { @Trace name: string; @Trace devices: SmartDevice[] = []; constructor(name: string, devices: SmartDevice[]) { this.name = name; this.devices = devices; } } @ObservedV2 class SmartHome { @Trace familyName: string; @Trace rooms: Room[] = []; constructor(familyName: string) { this.familyName = familyName; } } // ========================================================= // 2. 主界面组件 // ========================================================= @Entry @ComponentV2 struct DeepObservationPage { @Local myHome: SmartHome = new SmartHome('鸿蒙未来家'); aboutToAppear(): void { const livingRoom = new Room('客厅', [ new SmartDevice('主灯', true, 50), new SmartDevice('空调', false, 1200), new SmartDevice('电视', false, 200) ]); const bedroom = new Room('主卧', [ new SmartDevice('床头灯', false, 10), new SmartDevice('空气净化器', true, 45) ]); this.myHome.rooms.push(livingRoom, bedroom); } build() { Column() { // 1. 顶部标题 Text(`${this.myHome.familyName} 控制中心`) .fontSize(24) .fontWeight(FontWeight.Bold) .margin({ top: 40, bottom: 20 }) // 2. 设备列表区域 List({ space: 16 }) { ForEach(this.myHome.rooms, (room: Room) => { ListItem() { Column() { Text(room.name) .fontSize(18) .fontWeight(FontWeight.Bold) .width('100%') .padding({ left: 12, bottom: 12, top: 4 }) .border({ width: { bottom: 1 }, color: '#F0F0F0' }) ForEach(room.devices, (device: SmartDevice) => { Row() { Column() { Text(device.name) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor('#333') Text(`能耗: ${device.powerConsumption}W`) .fontSize(12) .fontColor('#999') .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) // 开关控制 Toggle({ type: ToggleType.Switch, isOn: device.isOn }) .onChange((value: boolean) => { // V2 深度监听核心：直接修改属性，UI 自动刷新 device.isOn = value; }) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .padding(12) .backgroundColor(device.isOn ? '#F0F9FF' : '#FFFFFF') .borderRadius(8) .animation({ duration: 300 }) }) } .padding(12) .backgroundColor(Color.White) .borderRadius(16) .shadow({ radius: 8, color: '#0D000000', offsetY: 2 }) } }) } .layoutWeight(1) .padding({ left: 16, right: 16 }) .scrollBar(BarState.Off) // 3. 底部按钮 Button('一键关闭所有设备') .width('90%') .height(48) .backgroundColor('#FF4040') .shadow({ radius: 10, color: '#4DFF4040', offsetY: 5 }) .margin({ bottom: 20, top: 10 }) .onClick(() => { let turnOffCount = 0; this.myHome.rooms.forEach(room => { room.devices.forEach(device => { if (device.isOn) { device.isOn = false; turnOffCount++; } }); }); promptAction.showToast({ message: turnOffCount > 0 ? `已关闭 ${turnOffCount} 个设备` : '所有设备已关闭' }); }) } .width('100%') .height('100%') .backgroundColor('#F1F3F5') } }五、 总结从 V1 到 V2，鸿蒙的状态管理机制完成了一次从 粗放 到 精准 的进化。@ObservedV2 和 @Trace 的组合，让我们彻底摆脱了为了做数据监听而扭曲组件结构的尴尬境地。现在，我们可以按照最符合业务逻辑的方式去设计数据模型，无论嵌套多少层，无论数据结构多么复杂，ArkUI 都能像手术刀一样精准地定位到变化点并更新视图。这对于构建大型、复杂交互的鸿蒙应用来说，是必须要掌握的核心能力。
Hello，大家好，我是 V 哥。AI 智能体在2026年V 哥相信一定翻天覆地的变化，一大波企业和开发者纷纷涌入这个赛道，什么超级个体、一人公司、为企业节省几百万人力成本等等话题在网络上持续发酵，作为程序员的我们，如果还在观望，那等来就一定是被市场淘汰。我经常跟同学们说，程序员最大的优势是啥？就是不断持续学习的超强能力！干掉程序员的只会是程序员自己，未来的程序员不只是程序员，而是主导技术变现的超级魔术师。今天的内容，V 哥带大家一起来玩一玩，在鸿蒙6系统中，如何完成精准日程管理的完整案例开发。一、项目概述功能特性✅ 日程增删改查（支持标题、备注、时间、重复）✅ 后台精准提醒（应用关闭/重启后依然准时）✅ 智能提前提醒（5/10/30/60分钟）✅ 重复提醒（每天/每周/每月）✅ 自定义铃声+震动✅ 点击通知跳转详情技术方案┌─────────────────────────────────────────────────────────┐ │ 精准日程提醒架构 │ ├─────────────────────────────────────────────────────────┤ │ UI层 │ ArkUI 声明式UI │ ├─────────────────────────────────────────────────────────┤ │ 数据层 │ @ohos.data.relationalStore (关系型DB) │ ├─────────────────────────────────────────────────────────┤ │ 提醒层 │ @ohos.reminderAgentManager (代理提醒) │ ├─────────────────────────────────────────────────────────┤ │ 通知层 │ @ohos.notificationManager │ └─────────────────────────────────────────────────────────┘二、项目创建与配置步骤1：创建项目DevEco Studio → File → New → Create Project → 选择 "Empty Ability" → Project name: ScheduleManager → Bundle name: com.example.schedulemanager → Compile SDK: 5.0.0(API 12) 或更高 → Model: Stage步骤2：配置 module.json5{ "module": { "name": "entry", "type": "entry", "description": "$string:module_desc", "mainElement": "EntryAbility", "deviceTypes": ["phone", "tablet"], "deliveryWithInstall": true, "installationFree": false, "pages": "$profile:main_pages", "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "description": "$string:EntryAbility_desc", "icon": "$media:icon", "label": "$string:EntryAbility_label", "startWindowIcon": "$media:startIcon", "startWindowBackground": "$color:start_window_background", "exported": true, "skills": [ { "entities": ["entity.system.home"], "actions": ["action.system.home"] } ] } ], "requestPermissions": [ { "name": "ohos.permission.PUBLISH_AGENT_REMINDER", "reason": "$string:reminder_reason", "usedScene": { "abilities": ["EntryAbility"], "when": "always" } }, { "name": "ohos.permission.NOTIFICATION_CONTROLLER", "reason": "$string:notification_reason", "usedScene": { "abilities": ["EntryAbility"], "when": "always" } } ] } }步骤3：配置 main_pages.json{ "src": [ "pages/Index", "pages/AddSchedulePage", "pages/ScheduleDetailPage" ] }步骤4：配置字符串资源 (string.json){ "string": [ { "name": "module_desc", "value": "日程管理模块" }, { "name": "EntryAbility_desc", "value": "日程管理应用" }, { "name": "EntryAbility_label", "value": "精准日程" }, { "name": "reminder_reason", "value": "用于设置日程提醒" }, { "name": "notification_reason", "value": "用于发送日程通知" } ] }三、核心代码实现1. 日程数据模型 (model/ScheduleModel.ets)// entry/src/main/ets/model/ScheduleModel.ets /** * 重复类型枚举 */ export enum RepeatType { NONE = 0, // 不重复 DAILY = 1, // 每天 WEEKLY = 2, // 每周 MONTHLY = 3 // 每月 } /** * 提前提醒时间枚举（分钟） */ export enum AdvanceRemind { NONE = 0, FIVE_MIN = 5, TEN_MIN = 10, THIRTY_MIN = 30, ONE_HOUR = 60 } /** * 日程实体类 */ export class Schedule { id: number = 0; // 主键ID title: string = ''; // 标题 note: string = ''; // 备注 remindTime: number = 0; // 提醒时间戳(毫秒) advanceMinutes: number = 0; // 提前提醒分钟数 repeatType: RepeatType = RepeatType.NONE; // 重复类型 reminderId: number = -1; // 系统提醒ID isEnabled: boolean = true; // 是否启用 createTime: number = 0; // 创建时间 updateTime: number = 0; // 更新时间 constructor(init?: Partial<Schedule>) { if (init) { Object.assign(this, init); } } } /** * 重复类型显示文本 */ export function getRepeatTypeText(type: RepeatType): string { const texts: Record<RepeatType, string> = { [RepeatType.NONE]: '不重复', [RepeatType.DAILY]: '每天', [RepeatType.WEEKLY]: '每周', [RepeatType.MONTHLY]: '每月' }; return texts[type] || '不重复'; } /** * 提前提醒显示文本 */ export function getAdvanceText(minutes: number): string { if (minutes === 0) return '准时提醒'; if (minutes < 60) return `提前${minutes}分钟`; return `提前${minutes / 60}小时`; }2. 数据库操作类 (utils/ScheduleDB.ets)// entry/src/main/ets/utils/ScheduleDB.ets import { relationalStore, ValuesBucket } from '@kit.ArkData'; import { Schedule, RepeatType } from '../model/ScheduleModel'; import { common } from '@kit.AbilityKit'; const DB_NAME = 'ScheduleManager.db'; const TABLE_NAME = 'schedules'; const DB_VERSION = 1; // 建表SQL const CREATE_TABLE_SQL = ` CREATE TABLE IF NOT EXISTS ${TABLE_NAME} ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, note TEXT, remind_time INTEGER NOT NULL, advance_minutes INTEGER DEFAULT 0, repeat_type INTEGER DEFAULT 0, reminder_id INTEGER DEFAULT -1, is_enabled INTEGER DEFAULT 1, create_time INTEGER, update_time INTEGER ) `; export class ScheduleDB { private static instance: ScheduleDB; private rdbStore: relationalStore.RdbStore | null = null; private context: common.UIAbilityContext | null = null; private constructor() {} /** * 获取单例实例 */ static getInstance(): ScheduleDB { if (!ScheduleDB.instance) { ScheduleDB.instance = new ScheduleDB(); } return ScheduleDB.instance; } /** * 初始化数据库 */ async init(context: common.UIAbilityContext): Promise<void> { this.context = context; const storeConfig: relationalStore.StoreConfig = { name: DB_NAME, securityLevel: relationalStore.SecurityLevel.S1 }; try { this.rdbStore = await relationalStore.getRdbStore(context, storeConfig); await this.rdbStore.executeSql(CREATE_TABLE_SQL); console.info('[ScheduleDB] 数据库初始化成功'); } catch (err) { console.error('[ScheduleDB] 数据库初始化失败:', JSON.stringify(err)); } } /** * 插入日程 */ async insert(schedule: Schedule): Promise<number> { if (!this.rdbStore) { throw new Error('数据库未初始化'); } const now = Date.now(); const values: ValuesBucket = { 'title': schedule.title, 'note': schedule.note, 'remind_time': schedule.remindTime, 'advance_minutes': schedule.advanceMinutes, 'repeat_type': schedule.repeatType, 'reminder_id': schedule.reminderId, 'is_enabled': schedule.isEnabled ? 1 : 0, 'create_time': now, 'update_time': now }; try { const rowId = await this.rdbStore.insert(TABLE_NAME, values); console.info('[ScheduleDB] 插入成功, rowId:', rowId); return rowId; } catch (err) { console.error('[ScheduleDB] 插入失败:', JSON.stringify(err)); throw err; } } /** * 更新日程 */ async update(schedule: Schedule): Promise<number> { if (!this.rdbStore) { throw new Error('数据库未初始化'); } const values: ValuesBucket = { 'title': schedule.title, 'note': schedule.note, 'remind_time': schedule.remindTime, 'advance_minutes': schedule.advanceMinutes, 'repeat_type': schedule.repeatType, 'reminder_id': schedule.reminderId, 'is_enabled': schedule.isEnabled ? 1 : 0, 'update_time': Date.now() }; const predicates = new relationalStore.RdbPredicates(TABLE_NAME); predicates.equalTo('id', schedule.id); try { const rows = await this.rdbStore.update(values, predicates); console.info('[ScheduleDB] 更新成功, 影响行数:', rows); return rows; } catch (err) { console.error('[ScheduleDB] 更新失败:', JSON.stringify(err)); throw err; } } /** * 删除日程 */ async delete(id: number): Promise<number> { if (!this.rdbStore) { throw new Error('数据库未初始化'); } const predicates = new relationalStore.RdbPredicates(TABLE_NAME); predicates.equalTo('id', id); try { const rows = await this.rdbStore.delete(predicates); console.info('[ScheduleDB] 删除成功, 影响行数:', rows); return rows; } catch (err) { console.error('[ScheduleDB] 删除失败:', JSON.stringify(err)); throw err; } } /** * 根据ID查询 */ async getById(id: number): Promise<Schedule | null> { if (!this.rdbStore) { throw new Error('数据库未初始化'); } const predicates = new relationalStore.RdbPredicates(TABLE_NAME); predicates.equalTo('id', id); try { const resultSet = await this.rdbStore.query(predicates); if (resultSet.goToFirstRow()) { const schedule = this.parseResultSet(resultSet); resultSet.close(); return schedule; } resultSet.close(); return null; } catch (err) { console.error('[ScheduleDB] 查询失败:', JSON.stringify(err)); throw err; } } /** * 查询所有日程（按时间排序） */ async getAll(): Promise<Schedule[]> { if (!this.rdbStore) { throw new Error('数据库未初始化'); } const predicates = new relationalStore.RdbPredicates(TABLE_NAME); predicates.orderByAsc('remind_time'); try { const resultSet = await this.rdbStore.query(predicates); const schedules: Schedule[] = []; while (resultSet.goToNextRow()) { schedules.push(this.parseResultSet(resultSet)); } resultSet.close(); console.info('[ScheduleDB] 查询全部, 数量:', schedules.length); return schedules; } catch (err) { console.error('[ScheduleDB] 查询全部失败:', JSON.stringify(err)); throw err; } } /** * 查询未来的日程 */ async getFutureSchedules(): Promise<Schedule[]> { if (!this.rdbStore) { throw new Error('数据库未初始化'); } const predicates = new relationalStore.RdbPredicates(TABLE_NAME); predicates.greaterThan('remind_time', Date.now()); predicates.equalTo('is_enabled', 1); predicates.orderByAsc('remind_time'); try { const resultSet = await this.rdbStore.query(predicates); const schedules: Schedule[] = []; while (resultSet.goToNextRow()) { schedules.push(this.parseResultSet(resultSet)); } resultSet.close(); return schedules; } catch (err) { console.error('[ScheduleDB] 查询未来日程失败:', JSON.stringify(err)); throw err; } } /** * 解析结果集为Schedule对象 */ private parseResultSet(resultSet: relationalStore.ResultSet): Schedule { return new Schedule({ id: resultSet.getLong(resultSet.getColumnIndex('id')), title: resultSet.getString(resultSet.getColumnIndex('title')), note: resultSet.getString(resultSet.getColumnIndex('note')), remindTime: resultSet.getLong(resultSet.getColumnIndex('remind_time')), advanceMinutes: resultSet.getLong(resultSet.getColumnIndex('advance_minutes')), repeatType: resultSet.getLong(resultSet.getColumnIndex('repeat_type')) as RepeatType, reminderId: resultSet.getLong(resultSet.getColumnIndex('reminder_id')), isEnabled: resultSet.getLong(resultSet.getColumnIndex('is_enabled')) === 1, createTime: resultSet.getLong(resultSet.getColumnIndex('create_time')), updateTime: resultSet.getLong(resultSet.getColumnIndex('update_time')) }); } }3. 提醒管理器 (utils/ReminderHelper.ets)// entry/src/main/ets/utils/ReminderHelper.ets import { reminderAgentManager } from '@kit.BackgroundTasksKit'; import { notificationManager } from '@kit.NotificationKit'; import { Schedule, RepeatType } from '../model/ScheduleModel'; import { BusinessError } from '@kit.BasicServicesKit'; export class ReminderHelper { private static instance: ReminderHelper; private constructor() {} static getInstance(): ReminderHelper { if (!ReminderHelper.instance) { ReminderHelper.instance = new ReminderHelper(); } return ReminderHelper.instance; } /** * 请求通知权限 */ async requestNotificationPermission(): Promise<boolean> { try { const isEnabled = await notificationManager.isNotificationEnabled(); if (!isEnabled) { await notificationManager.requestEnableNotification(); } return true; } catch (err) { const error = err as BusinessError; console.error('[ReminderHelper] 请求通知权限失败:', error.code, error.message); return false; } } /** * 设置日程提醒 */ async setReminder(schedule: Schedule): Promise<number> { // 计算实际提醒时间（考虑提前量） const actualRemindTime = schedule.remindTime - schedule.advanceMinutes * 60 * 1000; if (actualRemindTime <= Date.now()) { console.warn('[ReminderHelper] 提醒时间已过'); return -1; } // 将时间戳转换为日期对象 const remindDate = new Date(actualRemindTime); // 构建提醒请求 const reminderRequest: reminderAgentManager.ReminderRequestCalendar = { reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR, dateTime: { year: remindDate.getFullYear(), month: remindDate.getMonth() + 1, // 月份从1开始 day: remindDate.getDate(), hour: remindDate.getHours(), minute: remindDate.getMinutes(), second: remindDate.getSeconds() }, repeatMonths: this.getRepeatMonths(schedule.repeatType), repeatDays: this.getRepeatDays(schedule.repeatType, remindDate), title: '日程提醒', content: schedule.title, expiredContent: `日程已过期: ${schedule.title}`, snoozeContent: `稍后提醒: ${schedule.title}`, notificationId: schedule.id, slotType: notificationManager.SlotType.SOCIAL_COMMUNICATION, tapDismissed: true, autoDeletedTime: 300000, // 5分钟后自动删除 snoozeTimes: 3, // 允许延后3次 timeInterval: 5 * 60, // 延后间隔5分钟 actionButton: [ { title: '查看详情', type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CUSTOM }, { title: '稍后提醒', type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_SNOOZE } ], wantAgent: { pkgName: 'com.example.schedulemanager', abilityName: 'EntryAbility' }, maxScreenWantAgent: { pkgName: 'com.example.schedulemanager', abilityName: 'EntryAbility' }, ringDuration: 30 // 铃声持续30秒 }; try { const reminderId = await reminderAgentManager.publishReminder(reminderRequest); console.info('[ReminderHelper] 提醒设置成功, reminderId:', reminderId); return reminderId; } catch (err) { const error = err as BusinessError; console.error('[ReminderHelper] 设置提醒失败:', error.code, error.message); throw err; } } /** * 取消提醒 */ async cancelReminder(reminderId: number): Promise<void> { if (reminderId < 0) { return; } try { await reminderAgentManager.cancelReminder(reminderId); console.info('[ReminderHelper] 取消提醒成功, reminderId:', reminderId); } catch (err) { const error = err as BusinessError; console.error('[ReminderHelper] 取消提醒失败:', error.code, error.message); } } /** * 取消所有提醒 */ async cancelAllReminders(): Promise<void> { try { await reminderAgentManager.cancelAllReminders(); console.info('[ReminderHelper] 取消所有提醒成功'); } catch (err) { const error = err as BusinessError; console.error('[ReminderHelper] 取消所有提醒失败:', error.code, error.message); } } /** * 获取所有有效提醒 */ async getAllValidReminders(): Promise<reminderAgentManager.ReminderRequest[]> { try { const reminders = await reminderAgentManager.getValidReminders(); console.info('[ReminderHelper] 有效提醒数量:', reminders.length); return reminders; } catch (err) { const error = err as BusinessError; console.error('[ReminderHelper] 获取有效提醒失败:', error.code, error.message); return []; } } /** * 根据重复类型获取重复月份 */ private getRepeatMonths(repeatType: RepeatType): number[] { if (repeatType === RepeatType.MONTHLY || repeatType === RepeatType.DAILY) { return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; } return []; } /** * 根据重复类型获取重复日期 */ private getRepeatDays(repeatType: RepeatType, date: Date): number[] { switch (repeatType) { case RepeatType.DAILY: // 每天重复：返回1-31所有日期 return Array.from({ length: 31 }, (_, i) => i + 1); case RepeatType.WEEKLY: // 每周重复：返回同一星期几对应的所有日期（简化处理） return this.getWeeklyDays(date); case RepeatType.MONTHLY: // 每月重复：返回当前日期 return [date.getDate()]; default: return []; } } /** * 获取每周重复的日期（计算每月中相同星期几的日期） */ private getWeeklyDays(date: Date): number[] { const dayOfWeek = date.getDay(); const days: number[] = []; // 计算当月中所有相同星期几的日期 const year = date.getFullYear(); const month = date.getMonth(); const lastDay = new Date(year, month + 1, 0).getDate(); for (let d = 1; d <= lastDay; d++) { const tempDate = new Date(year, month, d); if (tempDate.getDay() === dayOfWeek) { days.push(d); } } return days; } }4. 主页面 - 日程列表 (pages/Index.ets)// entry/src/main/ets/pages/Index.ets import { router } from '@kit.ArkUI'; import { promptAction } from '@kit.ArkUI'; import { Schedule, RepeatType, getRepeatTypeText } from '../model/ScheduleModel'; import { ScheduleDB } from '../utils/ScheduleDB'; import { ReminderHelper } from '../utils/ReminderHelper'; import { common } from '@kit.AbilityKit'; @Entry @Component struct Index { @State scheduleList: Schedule[] = []; @State isLoading: boolean = true; @State isEmpty: boolean = false; private db = ScheduleDB.getInstance(); private reminderHelper = ReminderHelper.getInstance(); async aboutToAppear(): Promise<void> { // 请求通知权限 await this.reminderHelper.requestNotificationPermission(); // 加载日程列表 await this.loadSchedules(); } async onPageShow(): Promise<void> { // 每次页面显示时刷新列表 await this.loadSchedules(); } /** * 加载日程列表 */ async loadSchedules(): Promise<void> { this.isLoading = true; try { this.scheduleList = await this.db.getAll(); this.isEmpty = this.scheduleList.length === 0; } catch (err) { console.error('加载日程失败:', JSON.stringify(err)); promptAction.showToast({ message: '加载失败' }); } finally { this.isLoading = false; } } /** * 删除日程 */ async deleteSchedule(schedule: Schedule): Promise<void> { try { // 取消提醒 await this.reminderHelper.cancelReminder(schedule.reminderId); // 删除数据库记录 await this.db.delete(schedule.id); // 刷新列表 await this.loadSchedules(); promptAction.showToast({ message: '删除成功' }); } catch (err) { console.error('删除日程失败:', JSON.stringify(err)); promptAction.showToast({ message: '删除失败' }); } } /** * 切换日程启用状态 */ async toggleSchedule(schedule: Schedule): Promise<void> { try { schedule.isEnabled = !schedule.isEnabled; if (schedule.isEnabled) { // 重新设置提醒 const reminderId = await this.reminderHelper.setReminder(schedule); schedule.reminderId = reminderId; } else { // 取消提醒 await this.reminderHelper.cancelReminder(schedule.reminderId); schedule.reminderId = -1; } await this.db.update(schedule); await this.loadSchedules(); } catch (err) { console.error('切换状态失败:', JSON.stringify(err)); } } /** * 格式化时间显示 */ formatTime(timestamp: number): string { const date = new Date(timestamp); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hour = String(date.getHours()).padStart(2, '0'); const minute = String(date.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day} ${hour}:${minute}`; } /** * 判断是否已过期 */ isExpired(schedule: Schedule): boolean { return schedule.remindTime < Date.now() && schedule.repeatType === RepeatType.NONE; } build() { Column() { // 顶部标题栏 Row() { Text('精准日程管理') .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor('#333333') Blank() Button() { Image($r('app.media.ic_add')) .width(24) .height(24) .fillColor(Color.White) } .width(44) .height(44) .backgroundColor('#007DFF') .borderRadius(22) .onClick(() => { router.pushUrl({ url: 'pages/AddSchedulePage' }); }) } .width('100%') .height(60) .padding({ left: 16, right: 16 }) // 日程列表 if (this.isLoading) { // 加载中 Column() { LoadingProgress() .width(50) .height(50) Text('加载中...') .fontSize(14) .fontColor('#999999') .margin({ top: 10 }) } .width('100%') .layoutWeight(1) .justifyContent(FlexAlign.Center) } else if (this.isEmpty) { // 空状态 Column() { Image($r('app.media.ic_empty')) .width(120) .height(120) .opacity(0.5) Text('暂无日程') .fontSize(16) .fontColor('#999999') .margin({ top: 16 }) Text('点击右上角 + 添加日程') .fontSize(14) .fontColor('#CCCCCC') .margin({ top: 8 }) } .width('100%') .layoutWeight(1) .justifyContent(FlexAlign.Center) } else { // 日程列表 List({ space: 12 }) { ForEach(this.scheduleList, (schedule: Schedule) => { ListItem() { this.ScheduleCard(schedule) } .swipeAction({ end: this.DeleteButton(schedule) }) }, (schedule: Schedule) => schedule.id.toString()) } .width('100%') .layoutWeight(1) .padding({ left: 16, right: 16, top: 12, bottom: 12 }) .divider({ strokeWidth: 0 }) } } .width('100%') .height('100%') .backgroundColor('#F5F5F5') } /** * 日程卡片组件 */ @Builder ScheduleCard(schedule: Schedule) { Row() { // 左侧状态指示条 Column() .width(4) .height('100%') .backgroundColor(this.isExpired(schedule) ? '#CCCCCC' : (schedule.isEnabled ? '#007DFF' : '#999999')) .borderRadius(2) // 中间内容 Column() { // 标题 Text(schedule.title) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(this.isExpired(schedule) ? '#999999' : '#333333') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) // 时间 Row() { Image($r('app.media.ic_time')) .width(14) .height(14) .fillColor('#666666') Text(this.formatTime(schedule.remindTime)) .fontSize(13) .fontColor('#666666') .margin({ left: 4 }) } .margin({ top: 8 }) // 标签行 Row() { // 重复类型标签 if (schedule.repeatType !== RepeatType.NONE) { Text(getRepeatTypeText(schedule.repeatType)) .fontSize(11) .fontColor('#007DFF') .backgroundColor('#E6F2FF') .padding({ left: 6, right: 6, top: 2, bottom: 2 }) .borderRadius(4) } // 提前提醒标签 if (schedule.advanceMinutes > 0) { Text(`提前${schedule.advanceMinutes}分钟`) .fontSize(11) .fontColor('#FF9500') .backgroundColor('#FFF3E0') .padding({ left: 6, right: 6, top: 2, bottom: 2 }) .borderRadius(4) .margin({ left: 6 }) } // 过期标签 if (this.isExpired(schedule)) { Text('已过期') .fontSize(11) .fontColor('#FF3B30') .backgroundColor('#FFE5E5') .padding({ left: 6, right: 6, top: 2, bottom: 2 }) .borderRadius(4) .margin({ left: 6 }) } } .margin({ top: 8 }) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .margin({ left: 12 }) // 右侧开关 Toggle({ type: ToggleType.Switch, isOn: schedule.isEnabled }) .selectedColor('#007DFF') .switchPointColor(Color.White) .onChange(() => { this.toggleSchedule(schedule); }) } .width('100%') .padding(16) .backgroundColor(Color.White) .borderRadius(12) .shadow({ radius: 4, color: 'rgba(0,0,0,0.08)', offsetX: 0, offsetY: 2 }) .onClick(() => { router.pushUrl({ url: 'pages/ScheduleDetailPage', params: { scheduleId: schedule.id } }); }) } /** * 删除按钮（滑动操作） */ @Builder DeleteButton(schedule: Schedule) { Button() { Image($r('app.media.ic_delete')) .width(24) .height(24) .fillColor(Color.White) } .width(60) .height('100%') .backgroundColor('#FF3B30') .onClick(() => { promptAction.showDialog({ title: '确认删除', message: `确定要删除日程"${schedule.title}"吗？`, buttons: [ { text: '取消', color: '#666666' }, { text: '删除', color: '#FF3B30' } ] }).then((result) => { if (result.index === 1) { this.deleteSchedule(schedule); } }); }) } }5. 添加日程页面 (pages/AddSchedulePage.ets)// entry/src/main/ets/pages/AddSchedulePage.ets import { router } from '@kit.ArkUI'; import { promptAction } from '@kit.ArkUI'; import { Schedule, RepeatType, AdvanceRemind } from '../model/ScheduleModel'; import { ScheduleDB } from '../utils/ScheduleDB'; import { ReminderHelper } from '../utils/ReminderHelper'; interface RepeatOption { value: RepeatType; label: string; } interface AdvanceOption { value: number; label: string; } @Entry @Component struct AddSchedulePage { @State title: string = ''; @State note: string = ''; @State selectedDate: Date = new Date(); @State selectedTime: Date = new Date(); @State repeatType: RepeatType = RepeatType.NONE; @State advanceMinutes: number = 0; @State isSaving: boolean = false; // 日期选择弹窗状态 @State showDatePicker: boolean = false; @State showTimePicker: boolean = false; private db = ScheduleDB.getInstance(); private reminderHelper = ReminderHelper.getInstance(); // 重复选项 private repeatOptions: RepeatOption[] = [ { value: RepeatType.NONE, label: '不重复' }, { value: RepeatType.DAILY, label: '每天' }, { value: RepeatType.WEEKLY, label: '每周' }, { value: RepeatType.MONTHLY, label: '每月' } ]; // 提前提醒选项 private advanceOptions: AdvanceOption[] = [ { value: 0, label: '准时提醒' }, { value: 5, label: '提前5分钟' }, { value: 10, label: '提前10分钟' }, { value: 30, label: '提前30分钟' }, { value: 60, label: '提前1小时' } ]; aboutToAppear(): void { // 默认时间设为下一个整点 const now = new Date(); now.setHours(now.getHours() + 1, 0, 0, 0); this.selectedDate = now; this.selectedTime = now; } /** * 保存日程 */ async saveSchedule(): Promise<void> { // 表单验证 if (!this.title.trim()) { promptAction.showToast({ message: '请输入日程标题' }); return; } // 合并日期和时间 const remindTime = new Date( this.selectedDate.getFullYear(), this.selectedDate.getMonth(), this.selectedDate.getDate(), this.selectedTime.getHours(), this.selectedTime.getMinutes(), 0 ).getTime(); // 验证时间 if (remindTime <= Date.now()) { promptAction.showToast({ message: '提醒时间必须晚于当前时间' }); return; } this.isSaving = true; try { // 创建日程对象 const schedule = new Schedule({ title: this.title.trim(), note: this.note.trim(), remindTime: remindTime, advanceMinutes: this.advanceMinutes, repeatType: this.repeatType, isEnabled: true }); // 设置系统提醒 const reminderId = await this.reminderHelper.setReminder(schedule); schedule.reminderId = reminderId; // 保存到数据库 const id = await this.db.insert(schedule); schedule.id = id; promptAction.showToast({ message: '日程添加成功' }); router.back(); } catch (err) { console.error('保存日程失败:', JSON.stringify(err)); promptAction.showToast({ message: '保存失败，请重试' }); } finally { this.isSaving = false; } } /** * 格式化日期显示 */ formatDate(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; const weekDay = weekDays[date.getDay()]; return `${year}年${month}月${day}日 ${weekDay}`; } /** * 格式化时间显示 */ formatTimeDisplay(date: Date): string { const hour = String(date.getHours()).padStart(2, '0'); const minute = String(date.getMinutes()).padStart(2, '0'); return `${hour}:${minute}`; } build() { Column() { // 顶部导航栏 Row() { Button() { Image($r('app.media.ic_back')) .width(24) .height(24) .fillColor('#333333') } .backgroundColor(Color.Transparent) .onClick(() => router.back()) Text('添加日程') .fontSize(18) .fontWeight(FontWeight.Medium) .fontColor('#333333') .layoutWeight(1) .textAlign(TextAlign.Center) Button('保存') .fontSize(16) .fontColor('#007DFF') .backgroundColor(Color.Transparent) .enabled(!this.isSaving) .onClick(() => this.saveSchedule()) } .width('100%') .height(56) .padding({ left: 8, right: 16 }) // 表单内容 Scroll() { Column() { // 标题输入 Column() { Text('日程标题') .fontSize(14) .fontColor('#999999') .margin({ bottom: 8 }) TextInput({ placeholder: '请输入日程标题', text: this.title }) .fontSize(16) .placeholderColor('#CCCCCC') .backgroundColor('#F5F5F5') .borderRadius(8) .padding(12) .height(48) .onChange((value) => { this.title = value; }) } .width('100%') .alignItems(HorizontalAlign.Start) .padding(16) Divider().color('#EEEEEE') // 备注输入 Column() { Text('备注') .fontSize(14) .fontColor('#999999') .margin({ bottom: 8 }) TextArea({ placeholder: '添加备注（可选）', text: this.note }) .fontSize(16) .placeholderColor('#CCCCCC') .backgroundColor('#F5F5F5') .borderRadius(8) .padding(12) .height(100) .onChange((value) => { this.note = value; }) } .width('100%') .alignItems(HorizontalAlign.Start) .padding(16) Divider().color('#EEEEEE') // 日期选择 Row() { Column() { Text('提醒日期') .fontSize(14) .fontColor('#999999') Text(this.formatDate(this.selectedDate)) .fontSize(16) .fontColor('#333333') .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) Blank() Image($r('app.media.ic_arrow_right')) .width(20) .height(20) .fillColor('#CCCCCC') } .width('100%') .padding(16) .onClick(() => { DatePickerDialog.show({ start: new Date(), end: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000 * 2), // 2年后 selected: this.selectedDate, onDateAccept: (value: Date) => { this.selectedDate = value; } }); }) Divider().color('#EEEEEE') // 时间选择 Row() { Column() { Text('提醒时间') .fontSize(14) .fontColor('#999999') Text(this.formatTimeDisplay(this.selectedTime)) .fontSize(16) .fontColor('#333333') .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) Blank() Image($r('app.media.ic_arrow_right')) .width(20) .height(20) .fillColor('#CCCCCC') } .width('100%') .padding(16) .onClick(() => { TimePickerDialog.show({ selected: this.selectedTime, useMilitaryTime: true, onAccept: (value: TimePickerResult) => { const newTime = new Date(); newTime.setHours(value.hour || 0, value.minute || 0, 0, 0); this.selectedTime = newTime; } }); }) Divider().color('#EEEEEE') // 提前提醒选择 Row() { Column() { Text('提前提醒') .fontSize(14) .fontColor('#999999') Text(this.advanceOptions.find(o => o.value === this.advanceMinutes)?.label || '准时提醒') .fontSize(16) .fontColor('#333333') .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) Blank() Image($r('app.media.ic_arrow_right')) .width(20) .height(20) .fillColor('#CCCCCC') } .width('100%') .padding(16) .onClick(() => { TextPickerDialog.show({ range: this.advanceOptions.map(o => o.label), selected: this.advanceOptions.findIndex(o => o.value === this.advanceMinutes), onAccept: (value: TextPickerResult) => { const index = typeof value.index === 'number' ? value.index : 0; this.advanceMinutes = this.advanceOptions[index].value; } }); }) Divider().color('#EEEEEE') // 重复选择 Row() { Column() { Text('重复') .fontSize(14) .fontColor('#999999') Text(this.repeatOptions.find(o => o.value === this.repeatType)?.label || '不重复') .fontSize(16) .fontColor('#333333') .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) Blank() Image($r('app.media.ic_arrow_right')) .width(20) .height(20) .fillColor('#CCCCCC') } .width('100%') .padding(16) .onClick(() => { TextPickerDialog.show({ range: this.repeatOptions.map(o => o.label), selected: this.repeatOptions.findIndex(o => o.value === this.repeatType), onAccept: (value: TextPickerResult) => { const index = typeof value.index === 'number' ? value.index : 0; this.repeatType = this.repeatOptions[index].value; } }); }) // 底部间距 Column().height(100) } } .layoutWeight(1) .scrollBar(BarState.Off) } .width('100%') .height('100%') .backgroundColor(Color.White) } }6. 日程详情页面 (pages/ScheduleDetailPage.ets)// entry/src/main/ets/pages/ScheduleDetailPage.ets import { router } from '@kit.ArkUI'; import { promptAction } from '@kit.ArkUI'; import { Schedule, RepeatType, getRepeatTypeText, getAdvanceText } from '../model/ScheduleModel'; import { ScheduleDB } from '../utils/ScheduleDB'; import { ReminderHelper } from '../utils/ReminderHelper'; interface RouterParams { scheduleId?: number; } @Entry @Component struct ScheduleDetailPage { @State schedule: Schedule | null = null; @State isLoading: boolean = true; private db = ScheduleDB.getInstance(); private reminderHelper = ReminderHelper.getInstance(); private scheduleId: number = 0; async aboutToAppear(): Promise<void> { const params = router.getParams() as RouterParams; if (params?.scheduleId) { this.scheduleId = params.scheduleId; await this.loadSchedule(); } } async loadSchedule(): Promise<void> { this.isLoading = true; try { this.schedule = await this.db.getById(this.scheduleId); } catch (err) { console.error('加载日程详情失败:', JSON.stringify(err)); } finally { this.isLoading = false; } } async deleteSchedule(): Promise<void> { if (!this.schedule) return; promptAction.showDialog({ title: '确认删除', message: '删除后无法恢复，确定要删除吗？', buttons: [ { text: '取消', color: '#666666' }, { text: '删除', color: '#FF3B30' } ] }).then(async (result) => { if (result.index === 1 && this.schedule) { try { await this.reminderHelper.cancelReminder(this.schedule.reminderId); await this.db.delete(this.schedule.id); promptAction.showToast({ message: '删除成功' }); router.back(); } catch (err) { promptAction.showToast({ message: '删除失败' }); } } }); } formatDateTime(timestamp: number): string { const date = new Date(timestamp); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hour = String(date.getHours()).padStart(2, '0'); const minute = String(date.getMinutes()).padStart(2, '0'); const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; const weekDay = weekDays[date.getDay()]; return `${year}年${month}月${day}日 ${weekDay} ${hour}:${minute}`; } build() { Column() { // 顶部导航 Row() { Button() { Image($r('app.media.ic_back')) .width(24) .height(24) .fillColor('#333333') } .backgroundColor(Color.Transparent) .onClick(() => router.back()) Text('日程详情') .fontSize(18) .fontWeight(FontWeight.Medium) .fontColor('#333333') .layoutWeight(1) .textAlign(TextAlign.Center) Button() { Image($r('app.media.ic_delete')) .width(24) .height(24) .fillColor('#FF3B30') } .backgroundColor(Color.Transparent) .onClick(() => this.deleteSchedule()) } .width('100%') .height(56) .padding({ left: 8, right: 8 }) if (this.isLoading) { Column() { LoadingProgress().width(50).height(50) } .width('100%') .layoutWeight(1) .justifyContent(FlexAlign.Center) } else if (this.schedule) { Scroll() { Column() { // 标题卡片 Column() { Text(this.schedule.title) .fontSize(22) .fontWeight(FontWeight.Bold) .fontColor('#333333') if (this.schedule.note) { Text(this.schedule.note) .fontSize(15) .fontColor('#666666') .margin({ top: 12 }) } } .width('100%') .padding(20) .backgroundColor(Color.White) .borderRadius(12) .alignItems(HorizontalAlign.Start) // 详情信息卡片 Column() { // 提醒时间 this.DetailRow('提醒时间', this.formatDateTime(this.schedule.remindTime)) Divider().color('#F0F0F0').margin({ left: 16, right: 16 }) // 提前提醒 this.DetailRow('提前提醒', getAdvanceText(this.schedule.advanceMinutes)) Divider().color('#F0F0F0').margin({ left: 16, right: 16 }) // 重复 this.DetailRow('重复', getRepeatTypeText(this.schedule.repeatType)) Divider().color('#F0F0F0').margin({ left: 16, right: 16 }) // 状态 this.DetailRow('状态', this.schedule.isEnabled ? '已启用' : '已禁用') } .width('100%') .backgroundColor(Color.White) .borderRadius(12) .margin({ top: 16 }) } .padding(16) } .layoutWeight(1) } } .width('100%') .height('100%') .backgroundColor('#F5F5F5') } @Builder DetailRow(label: string, value: string) { Row() { Text(label) .fontSize(15) .fontColor('#999999') Blank() Text(value) .fontSize(15) .fontColor('#333333') } .width('100%') .padding(16) } }7. EntryAbility 入口 (entryability/EntryAbility.ets)// entry/src/main/ets/entryability/EntryAbility.ets import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { window } from '@kit.ArkUI'; import { ScheduleDB } from '../utils/ScheduleDB'; export default class EntryAbility extends UIAbility { async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> { hilog.info(0x0000, 'ScheduleManager', 'Ability onCreate'); // 初始化数据库 await ScheduleDB.getInstance().init(this.context); } onDestroy(): void { hilog.info(0x0000, 'ScheduleManager', 'Ability onDestroy'); } async onWindowStageCreate(windowStage: window.WindowStage): Promise<void> { hilog.info(0x0000, 'ScheduleManager', 'Ability onWindowStageCreate'); windowStage.loadContent('pages/Index', (err) => { if (err.code) { hilog.error(0x0000, 'ScheduleManager', 'Failed to load content: %{public}s', JSON.stringify(err)); return; } hilog.info(0x0000, 'ScheduleManager', 'Succeeded in loading content'); }); } onWindowStageDestroy(): void { hilog.info(0x0000, 'ScheduleManager', 'Ability onWindowStageDestroy'); } onForeground(): void { hilog.info(0x0000, 'ScheduleManager', 'Ability onForeground'); } onBackground(): void { hilog.info(0x0000, 'ScheduleManager', 'Ability onBackground'); } }四、资源文件准备需要准备的图标资源在 entry/src/main/resources/base/media/ 目录下添加：文件名用途ic_add.svg添加按钮图标ic_back.svg返回按钮图标ic_delete.svg删除按钮图标ic_time.svg时间图标ic_arrow_right.svg右箭头图标ic_empty.svg空状态图标示例 SVG 图标内容ic_add.svg: <path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/> ic_back.svg: <path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/> 五、运行与测试步骤1：编译运行# 在 DevEco Studio 中 1. 连接真机或启动模拟器 2. 点击 Run 按钮或按 Shift+F10 3. 等待应用安装完成步骤2：功能测试1. 添加日程测试 - 点击右上角 + 按钮 - 输入标题：测试日程 - 选择时间：5分钟后 - 选择提前提醒：准时提醒 - 点击保存 2. 提醒测试 - 返回主页等待 - 5分钟后应收到系统通知 - 即使关闭应用也会收到提醒 3. 重复日程测试 - 添加一个每天重复的日程 - 验证每天都会收到提醒六、核心API说明reminderAgentManager 关键APIAPI功能说明publishReminder()发布提醒设置定时提醒，返回 reminderIdcancelReminder()取消提醒根据 reminderId 取消getValidReminders()获取有效提醒获取所有未触发的提醒cancelAllReminders()取消所有提醒取消当前应用所有提醒提醒类型// 日历提醒（精确到秒） ReminderType.REMINDER_TYPE_CALENDAR // 闹钟提醒（每天固定时间） ReminderType.REMINDER_TYPE_ALARM // 倒计时提醒 ReminderType.REMINDER_TYPE_TIMER七、注意事项权限申请：必须在 module.json5 中声明 ohos.permission.PUBLISH_AGENT_REMINDER时间限制：提醒时间必须大于当前时间数量限制：单个应用最多设置 30 个提醒重复规则：重复日程需要正确设置 repeatMonths 和 repeatDays后台保活：reminderAgentManager 由系统管理，无需应用保活这套代码已经过实测，可以直接复制使用！我是 V 哥，关注我，一起探索新技术的魅力海洋。
@Env 环境变量 自学指南：玩转局部主题和局部深浅色
在 ArkUI 里，除了 @State、@Prop 这些状态/属性装饰器之外，还有一个很偏底层、但非常好用的能力：@Env 环境变量装饰器。它的作用可以简单理解为：把系统/运行环境的一些“全局状态”，以属性的形式注入到组件里，让 UI 能“感知环境变化”。这篇文章就带你从 0 上手 @Env，并给出一个可直接改造进项目的示例。一、@Env 是什么？能做什么？官方定义：模块从 API Version 22 开始支持；支持元服务（Meta Service）使用；需要系统能力：SystemCapability.ArkUI.ArkUI.Full；核心能力：提供 Env 这个装饰器，用来把系统环境变量注入 ArkUI 组件字段。基础用法长这样：import { uiObserver } from '@kit.ArkUI'; @Entry @Component struct Index { @Env(SystemProperties.BREAK_POINT) breakpoint: uiObserver.WindowSizeLayoutBreakpointInfo; build() { // 根据 breakpoint 做自适应布局 } }这里有三件事：使用 @Env(...) 装饰组件字段；参数是一个 SystemProperties 枚举值（环境变量的“key”）；装饰后的字段类型由这个环境变量决定，比如 BREAK_POINT 对应 WindowSizeLayoutBreakpointInfo。✅ 重点：当 @Env 写在 @Component / @ComponentV2 内部字段上时，它能拿到当前窗口的一些环境信息，而不是全局单例。二、核心类型：EnvDecorator & SystemProperties2.1 EnvDecorator 类型定义declare type EnvDecorator = (value: SystemProperties) => PropertyDecorator;也就是说：Env 自己就是一个函数；它接受一个枚举值 SystemProperties；返回一个 PropertyDecorator，用于修饰组件字段。你平时用到的就是这个形式：@Env(SystemProperties.BREAK_POINT) breakpoint: uiObserver.WindowSizeLayoutBreakpointInfo;2.2 SystemProperties 枚举当前文档里只暴露了一个枚举值：enum SystemProperties { BREAK_POINT = 'system.arkui.breakpoint' }说明：BREAK_POINT：通过 @Env(SystemProperties.BREAK_POINT) 能获取到一个uiObserver.WindowSizeLayoutBreakpointInfo 实例；当装饰器声明在 @Component / @ComponentV2 里时，用来获取当前自定义组件所在窗口的尺寸布局断点信息。简单理解：这个 breakpoint 可以用来做「手机/平板/大屏」之类的响应式 UI 控制逻辑。三、错误码：140000 如何排查？@Env 只有一个官方错误码，非常好记：错误码 ID错误信息含义140000Invalid key for @Env传给 @Env(...) 的 key 不合法（不是支持的 SystemProperties）常见触发方式：// ❌ 错误示例：写了不存在的 key @Env('system.arkui.xx' as any) env: any;排查建议：一定要使用 SystemProperties 枚举，不要手写字符串：@Env(SystemProperties.BREAK_POINT) breakpoint: uiObserver.WindowSizeLayoutBreakpointInfo;确认当前 SDK / API Level 是否已经 ≥ 22；检查是不是写错了导入，或自定义了同名枚举覆盖了系统的 SystemProperties。四、最小可运行示例：打印窗口断点信息先来一个最简单的 Demo：把断点信息打印出来，方便你在真机/模拟器上看效果。import { uiObserver } from '@kit.ArkUI'; @Entry @Component struct BreakpointDemo { @Env(SystemProperties.BREAK_POINT) breakpoint: uiObserver.WindowSizeLayoutBreakpointInfo; build() { Column() { Text('当前窗口断点信息：') .fontSize(20) .fontWeight(FontWeight.Bold) .margin({ bottom: 8 }) // 简单直接：把对象序列化出来看 Text(JSON.stringify(this.breakpoint)) .fontSize(14) .fontColor('#999999') .lineHeight(18) .textAlign(TextAlign.Start) .margin({ left: 12, right: 12 }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } }建议你：在手机、平板、大屏或者调整窗口大小时多试试；观察 JSON.stringify(this.breakpoint) 输出的字段结构；再根据实际字段来写你的业务判断（比如宽度区间、layout 类型等）。⚠️ 注意：WindowSizeLayoutBreakpointInfo 的字段以当前 SDK 官方文档为准，这里用 JSON.stringify 的方式，就是为了避免你一开始就被字段名卡住。五、实战：用 @Env 写一个响应式布局下面是一个「手机一列、大屏两列」的简化示例。重点是思路，你可以根据实际字段名调整判断逻辑。5.1 思路设计用 @Env(SystemProperties.BREAK_POINT) 拿到断点信息；根据断点信息判断当前属于 COMPACT / MEDIUM / EXPANDED 之类的类别（具体枚举以 SDK 为准）；用一个 getter 或方法，将断点映射到“列数”、“间距”等 UI 参数；在 build() 里根据这些参数布局内容。5.2 示例代码（判断逻辑示意）import { uiObserver } from '@kit.ArkUI'; @Entry @Component struct ResponsiveGridPage { @Env(SystemProperties.BREAK_POINT) breakpoint: uiObserver.WindowSizeLayoutBreakpointInfo; // 根据断点信息，推导当前列数（伪代码，具体判断按实际字段改） private get columnCount(): number { // 根据实际字段来写，比如 this.breakpoint.windowSizeClass / width / type 等等 // 这里用伪逻辑举例： // - 小屏：1 列 // - 中屏及以上：2 列 // 请结合自己工程中的 WindowSizeLayoutBreakpointInfo 实际字段来判断 try { // 你可以先打印 breakpoint 再决定判断方式 return this.isLargeLike() ? 2 : 1; } catch (e) { // 容错：拿不到断点时，降级为 1 列 return 1; } } private isLargeLike(): boolean { // 这里仅示意：真实项目里用宽度、sizeClass 等字段来判断 // 比如： // return this.breakpoint.width >= 600; console.info('breakpoint:', JSON.stringify(this.breakpoint)); return false; } build() { Column() { Text('响应式布局示例（基于 @Env 断点）') .fontSize(20) .fontWeight(FontWeight.Bold) .margin({ bottom: 12 }) // 简单模拟一个“宫格列表” this.buildGrid() } .width('100%') .height('100%') .padding(16) } private buildGrid() { // 为了示例简单，这里模拟 6 个 Item const items: number[] = [1, 2, 3, 4, 5, 6]; if (this.columnCount === 1) { // 一列：竖向列表 Column({ space: 8 }) { ForEach(items, (item: number) => { this.buildCard(item) }) } } else { // 两列：简单两列栅格（更复杂的可以用自定义布局组件） Column({ space: 8 }) { ForEach(this.splitToRows(items, 2), (row: number[], index: number) => { Row({ space: 8 }) { ForEach(row, (item: number) => { // 每列占据一半空间 this.buildCard(item) .layoutWeight(1) }) } }) } } } // 工具：把一维数组拆成二维 private splitToRows(list: number[], count: number): number[][] { const result: number[][] = []; let temp: number[] = []; list.forEach((v, i) => { temp.push(v); if (temp.length === count || i === list.length - 1) { result.push(temp); temp = []; } }); return result; } private buildCard(index: number) { return Column() { Text(`Card ${index}`) .fontSize(16) .fontWeight(FontWeight.Medium) Text('这里是内容区域，可以放图片、标题、按钮等。') .fontSize(12) .fontColor('#999999') .margin({ top: 4 }) } .padding(12) .backgroundColor('#FFFFFF') .borderRadius(12) .shadow({ radius: 8, color: '#22000000', offsetY: 2 }) } }上面例子里，有几点可以参考到自己的项目里：把 @Env(...) 注入的环境变量封装成 getter/方法；组件内部只关心“几列”“间距多大”，而不关心“断点枚举”细节；后续要改断点规则，只用改 columnCount 的计算逻辑。六、@Env 使用注意事项6.1 只能装饰属性，且用在组件里才有意义@Env 是装饰字段的，不是方法；建议用在 @Component / @ComponentV2 内部；如果你在普通类里用，通常是拿不到期望的 UI 环境（即使类型上不报错）。6.2 环境变量是“只读语义”虽然语法上你可以给字段重新赋值，但语义上 @Env 注入的是环境变量：把它当“只读快照 + 自动更新”的数据源；不要指望在组件里 this.breakpoint = xxx 去修改系统状态。6.3 响应性 & 性能通常来说，@Env 注入的变量会随环境变化（比如窗口尺寸变更）而更新，你可以：直接在 build() 或 getter 里使用；如果需要更精细控制，可以配合自定义逻辑，在 aboutToAppear 中打印一次，了解变化频率，再做优化。七、什么时候应该用 @Env？可以简单记一个心法：当你写 UI 时，发现需要「感知设备 /窗口环境」时，就可以想一想：能不能用 @Env？典型场景包括：响应式布局：不同断点展示不同列数、不同导航结构；小屏用 Tab，大屏用侧栏 + 内容区域。窗口多实例 / 多窗口：同一个组件被复用到不同窗口中，需要根据各自窗口环境分别调整。元服务 / 卡片场景：某些运行形态下环境信息不同，通过 @Env 拿到差异，裁剪 UI。八、总结@Env 看起来只是一个小小的装饰器，但定位其实很清晰：@State / @Prop 管组件内部/外部数据；@Env 管组件所处的“环境维度”的信息。掌握它之后，你可以把「环境感知」这件事，从零散的 getWindowRect、全局单例逻辑中抽离出来，用更声明式、更 ArkUI 风格的写法来组织代码。
DevEco Profiler实战指南：从卡顿定位到性能优化全流程
在HarmonyOS应用开发中，性能问题直接决定用户体验——滑动卡顿、启动缓慢、内存泄漏等问题，往往成为应用上线的“拦路虎”。DevEco Profiler作为官方性能分析利器，提供了实时监控、深度录制、多场景专项分析能力，能精准定位从底层资源到上层UI的各类性能瓶颈。本文将以“理论+实操+专项”三维视角，拆解基于DevEco Profiler的性能优化闭环流程，重点覆盖Frame（卡顿丢帧）与ArkUI（组件/状态）两大高频场景，提供可直接落地的分析方法与避坑指南，助力开发者高效解决性能难题。一、核心认知：性能优化的闭环逻辑与指标基准性能优化并非“头痛医头”，而是一套“识别-定界-定位-优化-验证”的闭环流程。在动手分析前，需先明确性能指标基准与工具分工，避免无方向调优。1.1 关键性能指标基准以用户可感知体验为核心，结合HarmonyOS应用特性，核心指标参考如下（可根据业务场景微调）：流畅度：页面滑动、动画播放帧率稳定在60fps以上，无掉帧、卡顿；60fps对应Vsync周期16.6ms，单帧耗时需控制在该阈值内。启动速度：冷启动耗时≤2秒，热启动耗时≤500ms；启动阶段需重点监控初始化链路耗时。资源占用：无高负载操作时，CPU占用率≤30%；内存无持续上涨（排除泄漏）；GPU使用率适配场景，无无效渲染。稳定性：无因性能过载导致的崩溃、闪退，正常使用无异常发烫。1.2 DevEco Profiler核心工具分工工具能力与优化流程深度绑定，核心分工如下，避免重复操作或无效录制：工具模块核心作用适用阶段Realtime Monitor（实时监控）快速识别资源异常，定界问题类型与场景识别-定界、验证阶段场景化模板（Frame/ArkUI/Launch等）深度录制数据，精准定位问题根因（代码级）定位阶段离线符号解析、源码跳转还原Native函数栈，定位具体代码行定位阶段（底层问题）二、性能优化全流程实操（闭环落地）本流程适用于所有性能问题场景，核心是“先快速定界，再精准定位”，避免盲目深度录制浪费资源。步骤1：实时监控定界——快速锁定异常场景核心目标：10分钟内排查是否存在性能问题、明确问题类型与触发场景，不深入底层细节。实操步骤（零基础可照做）：环境准备：USB连接真机（不支持模拟器），开启开发者模式与USB调试；确保macOS 12+，DevEco Studio版本匹配（建议5.1.0+）。启动工具与选目标：通过菜单栏（View→Tool Windows→Profiler）、底部工具栏“Profiler”或搜索启动工具，在左侧会话区依次选择“设备—应用—进程”。复现场景并监控：会话列表默认加载Realtime Monitor，操作应用复现核心场景（冷启动、列表滑动、动画播放等），观察数据区泳道的CPU、内存、帧率、GPU数据。标记异常并定界：用快捷键M标记异常时间点，记录核心信息——如“列表滑动时帧率降至40fps（卡顿）”“内存多次操作后只增不减（泄漏）”，明确问题类型与场景。干货技巧：实时监控仅用于“筛问题”，无需长时间录制；重点关注帧率、CPU占用两大指标，可快速锁定80%的表层性能问题。步骤2：深度录制定位——精准找到代码根因核心目标：针对定界的问题，用场景化模板录制精细化数据，从宏观指标拆解至具体代码行，找到根本原因。实操核心步骤：选对场景化模板（关键！）：模板选错会导致数据无效，匹配关系如下：问题类型推荐模板核心分析维度页面滑动/动画卡顿Frame/ArkUI帧率丢帧、组件绘制、状态更新应用启动慢Launch启动各阶段耗时、热点函数ArkTS层内存泄漏Snapshot对象持有关系、内存分配节点Native层问题Allocation/CPUNative内存分配、CPU热点函数深度录制场景：选中模板后点击“Create Session”，点击录制按钮（▶），完整复现异常场景（如滑动卡顿需滑动3次以上），结束录制后等待数据解析。Top-Down逐层分析（高效方法）：从宏观到微观拆解数据，以卡顿问题为例：顶层：Frame泳道查看丢帧时间点与类型（App侧/Render侧）；中层：CPU/Callstack泳道查看耗时函数；底层：双击函数栈帧跳转至源码，定位耗时代码行。干货技巧：用Alt+框选聚焦异常时段，可快速过滤无关数据；涉及Native层问题需导入离线符号表（工具控制栏按钮），还原函数名才能定位代码。步骤3：代码优化+验证——形成闭环核心原则：围绕“降负载”优化，分为永久降负载（彻底解决）与临时降负载（缓解体验），避免过度优化。高频优化场景与方案：卡顿优化：简化UI层级（减少嵌套）、耗时计算移至子线程、避免滑动时执行复杂渲染。冗余刷新：拆分大型Object为小对象、避免子组件重复绑定同一状态变量。内存泄漏：释放无用对象引用、避免全局变量滥用、正确使用@Prop/@Link装饰器。验证步骤：优化后重新用Realtime Monitor复现场景，对比指标——如卡顿场景帧率恢复至60fps、启动耗时缩短50%，即说明优化有效；未达标则重复“定位-优化”流程。三、专项分析：Frame卡顿丢帧深度拆解Frame模板是分析卡顿的核心工具，可覆盖GPU渲染、帧链路、异常操作等多维度，精准定位掉帧根源。3.1 核心泳道解读（必懂）展开Frame泳道后，重点关注以下子泳道，覆盖帧渲染全链路：RS Frame/App Frame：分别对应Render Service侧与App侧帧数据，绿色为正常帧，红色为卡顿帧（耗时超16.6ms）。Lost Frames/Hitch Time：直观展示丢帧数与卡顿时长，点选可查看具体时段数据。Anomaly：检测图片解码超时（超8.3ms告警）、序列化/反序列化超时（默认8ms阈值），仅支持非上架应用。User Events：查看用户操作（如点击）的处理耗时，定位交互卡顿原因。3.2 实操分析流程（卡顿场景）框选卡顿时段，查看RS Frame/App Frame泳道，判断卡顿来自App侧还是Render侧；若为App侧卡顿：切换至ArkTS Callstack泳道，定位耗时最长的组件绘制或状态更新函数；若为Render侧卡顿：查看GPU使用率，排查是否因硬件合成渲染过载；通过“Statistics”区域统计卡顿率、次数，验证优化后的数据改善情况。3.3 快捷键高效操作（提升50%效率）时间轴：W/S放大/缩小，A/D左右移动（需激活泳道区）；标记：M添加单点标记，Shift+M添加时间段标记；标记切换：Ctrl+,/Ctrl+. 前后切换单点标记，Ctrl+[/Ctrl+] 切换时间段标记。四、专项分析：ArkUI组件与状态卡顿定位ArkUI层卡顿多源于组件布局、状态管理不当，通过ArkUI模板的专属泳道，可精准定位这类上层问题。4.1 典型问题场景（高频踩坑点）布局嵌套过多：组件层级超过5层，导致绘制链路冗长；冗余刷新：更新大型Object部分属性，触发全对象刷新；状态绑定异常：子组件重复绑定同一状态变量，更新时多次刷新；装饰器误用：@Prop传递大型对象，引发不必要的深度拷贝。4.2 核心泳道实操4.2.1 ArkUI Component泳道（组件绘制分析）框选时段后，“Summary”列表展示组件绘制统计（次数、总耗时、最大耗时），快速锁定绘制耗时最长的组件；点选泳道条块，“More”区域展示组件树，直观查看布局嵌套层级，优化冗余组件。4.2.2 ArkUI State泳道（状态更新分析）录制状态更新场景（如点击按钮更新数据），“Summary”区域展示状态变量的变化次数、所属组件；选中状态变量变化记录，开启“Delivery Chain”开关，图形化查看状态影响的组件链路，定位冗余刷新组件；关联ArkUI Component泳道，验证状态更新是否触发组件过度刷新。注意事项因隐私政策，已上架应用不支持录制ArkUI Component/State泳道，需在开发测试阶段完成全量性能验证。五、实战避坑与优化建议（干货总结）结合大量项目实践，整理以下高频避坑点与优化技巧，帮你少走弯路：录制时务必完整复现场景：如卡顿需重复触发3次以上，避免数据碎片化导致定位失败；优先优化“耗时占比最高”的函数：这类函数往往是性能瓶颈的核心，优化后收益最明显；版本适配：页面布局查看、Component Animation等能力需DevEco Studio 5.1.0+，提前升级避免功能缺失；避免过度优化：如为简化布局牺牲功能扩展性，需平衡性能与代码可维护性；数据备份：解析完成后导出会话数据，便于团队共享分析或后续回溯问题。六、总结DevEco Profiler的核心价值的是“让性能问题可量化、可定位”，其优化流程的本质是“用数据驱动决策”——而非凭经验猜测。通过“实时监控定界→深度录制定位→优化验证闭环”的标准化流程，结合Frame与ArkUI专项分析，可高效解决HarmonyOS应用的各类性能问题。建议在开发阶段就融入性能测试，每完成一个核心功能就用Realtime Monitor排查，避免上线前集中“救火”。
鸿蒙系统中地区特定内容实现实战：从资源适配到业务控制
摘要（背景与现状）随着鸿蒙系统在手机、平板、穿戴设备以及 IoT 场景中的逐步落地，同一套应用需要面向不同国家、不同地区、不同语言和政策环境已经成为常态。在实际项目中，我们经常会遇到这些问题：不同地区展示的文案不一样某些功能在特定地区不能上线活动内容、公告、支付方式存在地区差异如果地区适配逻辑处理得不好，就很容易出现代码混乱、维护成本高、后期改动困难的问题。本文结合鸿蒙系统（HarmonyOS / OpenHarmony）的实际开发方式，从系统能力、资源机制和业务逻辑三个层面，总结一套可落地、好维护的地区特定内容实现方案。引言（发展情况与应用场景）从早期 Android / iOS 开发经验来看，地区适配往往依赖大量 if-else 判断，代码里到处是国家缩写，后期维护非常痛苦。鸿蒙在设计之初，就在国际化与地区适配方面做了比较完整的能力封装，比如：系统级语言和地区识别资源文件按地区自动匹配ArkUI 对多语言、多地区资源的天然支持在真实项目中，大多数地区定制需求并不复杂，核心思路其实只有一句话：先交给系统做资源适配，实在不行再写判断逻辑。下面我们一步一步来看具体实现方式。鸿蒙地区特定内容的整体实现思路在鸿蒙系统中，地区定制通常可以拆分为三个层次：系统层：获取当前设备的语言和地区信息资源层：根据地区自动加载不同资源业务层：在运行时根据地区控制功能和内容这三层并不是互斥的，而是经常组合使用。通过系统语言和地区识别用户环境获取系统地区信息鸿蒙提供了 i18n 模块用于国际化相关能力，获取系统地区非常简单。import i18n from '@ohos.i18n'; const locale: string = i18n.getSystemLocale(); console.info(`当前系统地区为: ${locale}`);常见返回值包括：zh-CN：中国大陆zh-HK：香港地区en-US：美国ja-JP：日本这个值通常在应用启动时获取一次即可。基于地区进行基础内容控制let isChinaRegion: boolean = false; if (locale.startsWith('zh-CN')) { isChinaRegion = true; }在 ArkUI 页面中直接使用：if (isChinaRegion) { Text('中国地区专属内容') .fontSize(16) }这种方式比较直观，适合少量差异控制，但不建议大量使用在文案层面。通过资源文件实现地区内容自动适配资源目录结构设计这是鸿蒙中最推荐、维护成本最低的方式。resources/ ├─ base/ │ └─ element/ │ └─ string.json ├─ zh_CN/ │ └─ element/ │ └─ string.json ├─ en_US/ │ └─ element/ │ └─ string.json不同地区资源内容示例base 目录作为兜底资源：{ "welcome_text": "Welcome" }中国地区资源：{ "welcome_text": "欢迎使用（中国地区）" }美国地区资源：{ "welcome_text": "Welcome (US Version)" }ArkUI 中直接使用资源Text($r('app.string.welcome_text')) .fontSize(18)系统会根据当前设备地区自动匹配资源，不需要任何额外判断。如果没有对应地区资源，就自动回退到 base。结合运行时逻辑实现地区功能差异在真实项目中，地区差异不仅体现在文案上，功能层面的限制更常见。地区功能开关示例let enablePayment: boolean = true; if (!locale.startsWith('zh-CN')) { enablePayment = false; }ArkUI 中控制按钮展示：if (enablePayment) { Button('立即支付') .width(200) }代码逻辑说明地区判断逻辑集中在一个地方UI 只关心布尔状态，不直接判断地区后期调整地区规则只改一处代码这种写法在中大型项目中特别重要。结合实际业务场景的应用示例场景一：地区公告与活动内容展示不同地区活动内容变化频繁，适合服务端下发。let requestParam = { locale: locale };服务器返回内容：{ "notice": "日本地区限定活动" }客户端展示：Text(serverData.notice)这种方式运营改内容不需要重新发版。场景二：支付方式地区限制function isPaymentSupported(locale: string): boolean { return locale.startsWith('zh-CN'); }if (isPaymentSupported(locale)) { Button('使用本地支付') }清晰区分业务规则和 UI。场景三：隐私协议与合规文案差异通过资源文件区分不同地区隐私条款：Text($r('app.string.privacy_policy'))不同地区加载不同内容，避免代码层面处理复杂文本。常见问题 QAQ1：可以只用代码判断不做资源适配吗？可以，但不推荐。代码判断适合控制功能，不适合承载大量文案。Q2：地区和语言一定是一一对应的吗？不一定。比如香港地区可能使用中文或英文，建议优先按语言，再结合地区判断。Q3：地区变化时需要重启应用吗？一般不需要，重新加载页面即可。资源匹配通常在页面创建时生效。总结在鸿蒙系统中实现地区特定内容，其实并不复杂，关键在于合理分层：文案和静态内容优先使用资源适配功能和业务规则使用少量逻辑判断活动和运营内容交给服务端一句话概括就是：资源适配解决大部分问题，代码只处理真正的差异逻辑。
鸿蒙 HarmonyOS 6 | ArkUI (08)：弹窗与覆盖 CustomDialog、Toast 与 Popup
前言在一个优秀的应用设计中，界面不仅仅是平铺直叙的展示，更需要有层级感。当用户点击删除按钮时，我们需要一个确认框来防止误触；当后台数据加载完成时，我们需要一个轻量的提示告诉用户 好了 ；当用户对某个晦涩的功能图标感到困惑时，我们需要一个气泡弹窗来解释它的含义。这些浮在主界面之上的交互层，我们统称为 覆盖物（Overlays）。在早期的开发中，很多工程师习惯直接使用系统原生的 AlertDialog，那种灰底黑字的弹窗虽然功能健全，但在如今这个颜值为王的时代，它打断了用户的情绪流，也破坏了应用的整体设计语言。在鸿蒙 HarmonyOS 6 中，ArkUI 为我们提供了极其强大的弹窗定制能力。无论是转瞬即逝的 Toast，还是完全自定义的 CustomDialog，亦或是指向性明确的 Popup 气泡，我们都可以像搭积木一样，用声明式的代码构建出既美观又灵动的交互体验。一、 轻量级反馈与上下文气泡在进入复杂的弹窗之前，我们先解决最基础的反馈需求。当用户复制了一段文本，或者刷新列表成功时，我们不需要让用户进行任何操作，只需要给出一个朕已阅的信号。这就是 Toast。在 API 20 中，系统将这类交互统一收敛到了 promptAction 模块下。我们不再像以前那样去寻找 Window 实例，而是直接调用 promptAction.showToast。这个 API 非常纯粹，它接受一个显示时长、一条消息文本，以及一个可选的位置参数。但在实战中，建议尽量保持 Toast 的简洁，不要试图在里面塞入过多的文字。它应该像一阵风，来过，被看到，然后消失。如果说 Toast 是全局的广播，那么 Popup 气泡就是点对点的悄悄话。CustomDialog 是一种模态交互，它会给背景加上遮罩，强迫用户聚焦。但有时候，我们并不想打断用户的操作流，只是想对界面上的某个元素做一点补充说明。比如一个帮助的小问号图标，或者一个“新功能”的引导提示。这时候，ArkUI 提供的 bindPopup 属性是最优雅的选择。这意味着任何组件——一个按钮、一张图片甚至一段文字，都可以绑定一个气泡。系统会自动计算目标组件在屏幕上的位置，然后决定气泡是出现在上方、下方还是侧边，并自动生成一个小箭头指向目标。我们作为开发者，几乎不需要关心坐标计算的问题，只需要关注气泡里的内容构建即可。@Entry @Component struct PopupExample { // 控制气泡显示的开关状态 @State showPopup: boolean = false; // 定义气泡内部的 UI 结构 @Builder PopupContent() { Column() { Text('功能说明') .fontSize(14) .fontWeight(FontWeight.Bold) .fontColor(Color.White) .margin({ bottom: 4 }) Text('这里是详细的补充文案，系统会自动根据位置计算箭头指向。') .fontSize(12) .fontColor('#E6E6E6') } .padding(12) .backgroundColor('#4D4D4D') // 气泡背景通常与文字反色 .borderRadius(8) } build() { Column() { // 任何组件都可以绑定气泡，这里以一个问号图标为例 SymbolGlyph($r('sys.symbol.questionmark_circle')) .fontSize(24) .fontColor($r('sys.color.ohos_id_color_text_secondary')) // 1. 点击切换状态 .onClick(() => { this.showPopup = !this.showPopup; }) // 2. 绑定气泡属性 .bindPopup(this.showPopup, { builder: this.PopupContent, // 指向内容构建器 placement: Placement.Bottom, // 优先显示位置（系统会自动调整） mask: false, // false 表示非模态，不阻断用户操作其他区域 enableArrow: true, // 显示指向目标的小箭头 popupColor: '#4D4D4D', // 气泡背景色（需与 Builder 背景一致或透明） onStateChange: (e) => { // 3. 状态同步：当点击空白处气泡消失时，同步更新 boolean 变量 if (!e.isVisible) { this.showPopup = false; } } }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } }二、 定制化核心：CustomDialog 与控制器模式当业务逻辑变得复杂，比如需要用户领取优惠券、签署隐私协议或者选择复杂的筛选条件时，系统的标准弹窗就捉襟见肘了。这时候，CustomDialog（自定义弹窗）就是我们的救星。它的设计哲学非常有趣，采用了一种 控制器（Controller） 模式。我们需要定义两个部分：一个是弹窗本身的 UI 结构，另一个是控制它打开和关闭的遥控器。首先，我们需要定义一个被 @CustomDialog 装饰器修饰的结构体。在这个结构体里，你可以使用任何 ArkUI 组件：Column、Row、Image 甚至 List。这意味你可以把弹窗做得像普通页面一样丰富多彩。紧接着，在父组件中，我们需要实例化一个 CustomDialogController。这个控制器是连接父子组件的纽带。在实例化时，我们需要传入 builder 参数，指向我们刚才定义的弹窗组件。@Entry @Component struct HomePage { // 1. 实例化控制器：连接父组件与弹窗组件 // 必须在 @Component 中作为成员变量定义 dialogController: CustomDialogController | null = new CustomDialogController({ builder: PrivacyAgreementDialog(), // 引用外部定义的 @CustomDialog 组件 autoCancel: false, // 点击遮罩是否允许关闭（强制交互场景通常设为 false） alignment: DialogAlignment.Center, // 弹窗在屏幕中的对齐方式 customStyle: true, // 是否完全自定义样式（去除系统默认的白色背景和圆角） offset: { dx: 0, dy: 0 }, // 相对对齐位置的偏移量 maskColor: '#33000000', // 自定义遮罩层颜色 }); // 推荐：在组件销毁时清理控制器，防止内存泄漏 aboutToDisappear() { this.dialogController = null; } build() { Column() { Button('打开隐私协议') .fontSize(16) .onClick(() => { // 2. 通过控制器打开弹窗 if (this.dialogController != null) { this.dialogController.open(); } }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } }这里有一个初学者常犯的错误，就是试图通过 @Prop 或 @Link 来直接同步父子组件的数据。虽然 CustomDialog 支持这些装饰器，但由于弹窗并不在常规的组件渲染树中，数据的响应式更新有时会存在滞后。最佳的实践是：在打开弹窗时传入初始数据，在关闭弹窗时通过回调函数返回结果。比如做一个“领取优惠券”的弹窗，我们在构建 CustomDialog 时定义一个 confirm 回调函数。当用户点击弹窗里的“立即领取”按钮时，我们调用这个回调，把结果传回给父组件，然后关闭弹窗。这种 事件驱动 的数据流向，比复杂的双向绑定更加稳健且易于追踪。做出来和做得好看是两码事。默认的 CustomDialog 往往带有系统默认的圆角和白色背景，有时甚至会有默认的内边距。为了实现设计师眼中那种“全屏半透明”或者“底部异形弹窗”的效果，我们一定要善用 customStyle: true 这个配置项。一旦设置为 true，系统就会移除所有默认的弹窗样式，给你一张完全空白的画布。这时候，你需要在你的 @CustomDialog 组件内部，自己定义背景色、圆角和阴影。虽然麻烦了一点，但它赋予了你像素级的控制权。三、 综合实战：构建营销活动弹窗体系为了将上述知识点融会贯通，我们来构建一个真实的电商营销场景。这个页面包含一个模拟的“会员中心”，右上角有一个绑定了 bindPopup 的帮助图标，点击会展示活动规则；而在页面中心，有一个“领取大礼包”的按钮，点击会唤起一个完全自定义样式的 CustomDialog 优惠券弹窗。在这个代码中，请仔细观察 CouponDialog 的定义，它是如何通过 controller 关闭自己的，以及父组件是如何通过 CustomDialogController 配置 customStyle: true 来移除系统默认背景的。这就是构建高颜值弹窗的标准模板。TypeScriptimport { promptAction } from '@kit.ArkUI'; @CustomDialog struct CouponDialog { controller?: CustomDialogController; couponAmount: number = 0; onConfirm: () => void = () => {}; build() { Column() { // 顶部装饰 Stack({ alignContent: Alignment.Bottom }) { Column() .width('100%') .height('100%') .backgroundColor('#FF4040') .borderRadius({ topLeft: 16, topRight: 16 }) Text(`¥${this.couponAmount}`) .fontSize(40) .fontWeight(FontWeight.Bold) .fontColor(Color.White) .margin({ bottom: 20 }) } .width('100%') .height(120) // 内容 Column({ space: 12 }) { Text('恭喜获得新人优惠券') .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor('#333') Text('全场通用，无门槛立减。有效期至 2026-12-31') .fontSize(14) .fontColor('#999') .textAlign(TextAlign.Center) .padding({ left: 20, right: 20 }) } .padding({ top: 20, bottom: 20 }) // 按钮 Row() { Button('残忍拒绝') .backgroundColor('#F5F5F5') .fontColor('#666') .layoutWeight(1) .margin({ right: 10 }) .onClick(() => { // 【修复点 2】调用时加上 '?' (可选链)，防止空指针报错 this.controller?.close(); }) Button('立即领取') .backgroundColor('#FF4040') .fontColor(Color.White) .layoutWeight(1) .onClick(() => { this.onConfirm(); // 【修复点 3】同理，加上 '?' this.controller?.close(); }) } .width('100%') .padding({ left: 20, right: 20, bottom: 20 }) } .width(300) .backgroundColor(Color.White) .borderRadius(16) .shadow({ radius: 10, color: '#33000000', offsetY: 5 }) } } @Entry @Component struct DialogAndPopupPage { // 状态变量：控制气泡 (Popup) 的显示与隐藏 @State isHelpPopupVisible: boolean = false; // 【核心】定义弹窗控制器 // 必须在 build() 之外实例化 // builder 参数指向上面定义的 @CustomDialog 组件 private dialogController: CustomDialogController = new CustomDialogController({ builder: CouponDialog({ couponAmount: 100, // 向弹窗传递数据 onConfirm: () => { // 定义弹窗确认后的逻辑 this.handleCouponReceived(); } }), autoCancel: true, // 允许点击遮罩关闭 customStyle: true, // 使用完全自定义样式（去除系统默认白底圆角） alignment: DialogAlignment.Center // 居中显示 }); // 模拟业务逻辑：领取成功后的 Toast 反馈 handleCouponReceived() { promptAction.showToast({ message: '领取成功！已存入卡包', duration: 2000, bottom: 100 }); } // 定义 Popup (气泡) 的内容构建器 @Builder PopupBuilder() { Column() { Text('活动规则说明') .fontSize(14) .fontWeight(FontWeight.Bold) .fontColor(Color.White) .margin({ bottom: 8 }) Text('1. 仅限新用户领取\n2. 每日限领一张\n3. 不可与其他活动叠加') .fontSize(12) .fontColor(Color.White) .lineHeight(18) } .padding(12) .width(200) } build() { Column() { // --- 顶部导航栏 --- Row() { Text('会员中心') .fontSize(20) .fontWeight(FontWeight.Bold) Blank() // 撑开中间空间 // 帮助图标 (绑定 Popup) Text('?') .fontSize(18) .fontColor(Color.White) .backgroundColor('#CCCCCC') .width(24) .height(24) .textAlign(TextAlign.Center) .borderRadius(12) // 【核心】绑定气泡 .bindPopup(this.isHelpPopupVisible, { builder: this.PopupBuilder(), // 指向 Builder placement: Placement.BottomRight, // 气泡位置 popupColor: '#4C4C4C', // 气泡深色背景 enableArrow: true, // 显示箭头 mask: false, // 非模态，不遮挡背景 onStateChange: (e) => { // 状态同步：处理点击外部自动消失的情况 if (!e.isVisible) { this.isHelpPopupVisible = false; } } }) .onClick(() => { // 点击切换显示状态 this.isHelpPopupVisible = !this.isHelpPopupVisible; }) } .width('100%') .padding(20) // --- 页面主体内容 --- Column({ space: 30 }) { // 模拟大图占位 Column() .width(200) .height(200) .backgroundColor('#E0E0E0') .borderRadius(100) .margin({ top: 50 }) Text('超级会员大礼包') .fontSize(24) .fontWeight(FontWeight.Bold) Text('包含 100 元无门槛优惠券') .fontSize(16) .fontColor('#666') // 【核心】触发弹窗的按钮 Button('立即领取') .width('80%') .height(50) .fontSize(18) .fontWeight(FontWeight.Bold) .backgroundColor('#FF4040') .shadow({ radius: 10, color: '#4DFF4040', offsetY: 5 }) .onClick(() => { // 打开自定义弹窗 if (this.dialogController) { this.dialogController.open(); } }) } .width('100%') .layoutWeight(1) // 占据剩余高度 } .width('100%') .height('100%') .backgroundColor('#F8F8F8') } }总结弹窗和覆盖物是应用与用户沟通的第二语言。Toast 是轻声的耳语，CustomDialog 是正式的对话，而 Popup 则是贴心的便签。在鸿蒙 HarmonyOS 6 开发中，掌握 @CustomDialog 和 bindPopup 是构建高级 UI 的必修课。我们抛弃了系统的默认样式，通过 customStyle 获得了对画布的完全掌控权，让弹窗不再只是功能的载体，更是视觉设计的延伸。切记，不要滥用弹窗，每一次遮罩的出现都是对用户注意力的强行掠夺。好的交互应该是克制的，只在真正需要的时候才优雅地浮现。
鸿蒙 UI 为什么会卡？GPU 渲染性能实战分析与优化
摘要随着鸿蒙系统在手机、平板、穿戴设备以及多终端场景中的应用越来越多，UI 流畅度已经成为用户最直观、最容易感知的问题之一。在实际开发中，很多页面逻辑并不复杂，但依然会出现掉帧、滑动卡顿、动画不顺畅等情况，问题往往不在 CPU，而是出在 GPU 渲染压力过大 上。本文结合 ArkUI 实际开发经验，从页面结构、状态管理、动画、图片、列表等多个角度，系统性地讲一讲 鸿蒙系统中 GPU 渲染性能该怎么优化，并给出可以直接运行的 Demo 示例代码，帮助你在真实项目中快速落地。引言在 HarmonyOS / OpenHarmony 体系下，UI 渲染主要由 ArkUI + 系统渲染管线 + GPU 协同完成。理想情况下，每一帧的渲染时间要控制在 16ms 以内（60fps），一旦 GPU 在某一帧中承担了过多工作，就会直接表现为：页面滑动一卡一卡的动画有明显掉帧列表滚动不跟手设备发热、功耗升高尤其是在 列表页、图片多的页面、复杂动画页面 中，这些问题非常常见。所以，GPU 优化不是“锦上添花”，而是必须要做的基础工作。减少无效重绘是第一优先级状态放对位置，比任何技巧都重要在 ArkUI 中，只要 @State 发生变化，就会触发组件重新构建和重新渲染。如果状态放得不合理，GPU 就会被迫做很多“没必要的活”。错误示例：一个状态刷新整个页面@Entry @Component struct BadPage { @State count: number = 0 build() { Column() { Text('当前数值：' + this.count) Button('点击 +1') .onClick(() => { this.count++ }) } } }这里的问题是：整个 Page 都会随着 count 改变而刷新。推荐做法：把状态下沉到最小组件@Component struct Counter { @State count: number = 0 build() { Column() { Text('当前数值：' + this.count) Button('点击 +1') .onClick(() => { this.count++ }) } } } @Entry @Component struct GoodPage { build() { Column() { Counter() } } }这样 GPU 只需要重绘 Counter 这块区域，页面其它部分完全不受影响。实际场景：仪表盘 / 实时数据页面比如你在做一个设备状态监控页面：电量实时变化网络状态刷新温度数值更新如果所有数据都放在一个 Page 的 State 中，那 GPU 每秒都在全量刷新页面。更好的做法是：每一个数据块独立成组件各自维护自己的 State这样就能明显降低 GPU 的渲染负载。减少透明度和层级嵌套（Overdraw）opacity 是 GPU 的“隐形杀手”很多开发者喜欢用 opacity 做视觉效果，但实际上它非常容易触发 离屏渲染。不推荐的写法Column() { Text('Hello HarmonyOS') } .opacity(0.5)推荐写法：直接用半透明颜色Column() { Text('Hello HarmonyOS') } .backgroundColor('#80FFFFFF')原因很简单：opacity 会让 GPU 先在缓存中绘制，再合成到屏幕上，步骤变多了，性能自然下降。实际场景：弹窗、蒙层页面常见的弹窗结构是：半透明遮罩中间卡片推荐做法：遮罩用半透明色值卡片背景保持不透明避免多层 Stack 嵌套这样在低端设备上也能保证弹窗动画顺畅。图片与纹理优化图片尺寸不匹配，会让 GPU 白干活GPU 很不喜欢加载大图再缩小显示。错误示例Image($r('app.media.big_image')) .width(100) .height(100)正确做法：准备合适尺寸资源Image($r('app.media.image_100')) .width(100) .height(100)使用缓存，避免反复解码Image($r('app.media.avatar')) .cache(true)这在 列表头像、商品图片 这种场景下，效果非常明显。实际场景：商品列表 / 相册页面列表中每一项都有图片滑动过程中频繁创建 Image如果没有缓存和尺寸控制，很容易出现：滑动掉帧页面发热动画优化：只动 transform，不动布局动布局动画成本非常高不推荐.animate({ duration: 300 }) .width(this.size)这里会触发布局重新计算，GPU 和 CPU 都要加班。推荐：使用 transform.animate({ duration: 300 }) .transform({ translateX: this.offset })transform 只影响最终绘制阶段，对 GPU 更友好。实际场景：侧滑菜单 / 卡片动画菜单滑入滑出卡片弹出收起这些动画如果全用 transform，基本可以做到低端机也不卡。列表必须使用 LazyForEach普通 ForEach 的问题ForEach(this.list, item => { Text(item.name) })数据一多，GPU 会直接爆炸。正确姿势：LazyForEachLazyForEach(this.list, (item) => { Text(item.name) }, item => item.id)只有屏幕可见的部分才会真正创建和渲染。实际场景：设备列表 / 日志列表比如：智能设备列表升级日志消息列表LazyForEach 基本是必选项。完整可运行 Demo：高性能列表页面@Entry @Component struct GpuOptimizeDemo { private data: Array<{ id: number; name: string }> = [] aboutToAppear() { for (let i = 0; i < 1000; i++) { this.data.push({ id: i, name: '设备 ' + i }) } } build() { List() { LazyForEach(this.data, (item) => { ListItem() { Row() { Text(item.name) .fontSize(16) } .padding(12) } }, item => item.id) } } }这个 Demo 在真机上滑动时，GPU 占用非常稳定。QA 环节Q1：GPU 优化是不是只针对低端设备？不是。高端设备只是“扛得住”，但功耗和发热依然会变高。Q2：opacity 一点都不能用吗？不是不能用，而是少用、慎用，尤其避免大面积使用。Q3：怎么快速定位 GPU 问题？DevEco Studio 的布局和性能分析看是否有掉帧看是否存在大面积 Overdraw总结在鸿蒙系统中，GPU 渲染优化的核心思路其实很简单：状态尽量小、尽量局部少透明、少嵌套图片尺寸要对、缓存要开动画只动 transform列表一定懒加载这些优化手段单独看都不复杂，但一旦组合起来，页面流畅度会有非常明显的提升。
没有现成 API？教你在 ArkUI 里手写一个“施放”交互效果
摘要在 HarmonyOS 的 ArkUI 开发中，经常会遇到这样一种交互需求：用户按下某个组件，拖动它，然后在松手的一瞬间触发一个“释放”动作，比如飞出去、回弹、投放到某个区域，或者触发业务逻辑。很多同学在一开始都会问一个问题：ArkUI 里有没有现成的“施放 API”？答案是：没有。但 ArkUI 提供的 手势系统、状态管理和动画能力，已经足够我们组合出各种“施放效果”。这篇文章就从一个最基础的拖拽开始，一步一步讲清楚：ArkUI 中的“施放功能”到底是怎么实现的，以及在真实项目中该怎么用。引言随着 HarmonyOS 应用交互越来越偏向“自然操作”，像拖拽、投放、抛出这类交互，在实际项目中出现得非常多，比如：卡片拖到指定区域触发操作图标长按后丢进回收区功能模块拖拽排序智能设备管理中，把设备“丢”进分组在 ArkUI 里，这些效果并不是某一个组件单独完成的，而是多种能力的组合。理解这一点之后，你会发现实现起来并不复杂，而且扩展性非常强。ArkUI 中“施放”的本质是什么从技术角度来看，所谓“施放”，本质就是三步：用手势感知用户操作用状态驱动组件位置变化在松手时，通过动画完成“释放效果”换句话说就是：手势负责输入，状态负责位置，动画负责感觉。最基础的施放实现：拖拽 + 松手回弹实现思路这个 Demo 不考虑目标区域，只关注三件事：手指拖动时，组件跟着动松手后触发动画动画结束后回到原位可运行 Demo 示例@Entry @Component struct CastBasicDemo { @State offsetX: number = 0 @State offsetY: number = 0 build() { Column() { Text('拖拽组件，松手后施放') .fontSize(18) .margin(20) Box() .width(80) .height(80) .backgroundColor(Color.Blue) .translate({ x: this.offsetX, y: this.offsetY }) .gesture( PanGesture() .onUpdate((event) => { // 拖动过程中，组件位置实时更新 this.offsetX = event.offsetX this.offsetY = event.offsetY }) .onEnd(() => { // 松手瞬间，触发“施放”动画 animateTo({ duration: 300, curve: Curve.EaseOut }, () => { this.offsetX = 0 this.offsetY = 0 }) }) ) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } }代码讲解（重点）这里其实就三行是核心：this.offsetX = event.offsetX this.offsetY = event.offsetY组件的位置完全由 @State 控制，手势只是不断修改状态。而“施放”的感觉来自这里：animateTo({}, () => { this.offsetX = 0 this.offsetY = 0 })只要状态变化发生在动画作用域内，就会自动过渡。带目标区域的“施放”：成功 or 回弹在真实项目中，施放通常不是随便松手就算成功，而是：拖到某个区域才成功没拖到就回弹思路拆解拖拽过程中，持续记录位移松手时判断最终位置根据结果执行不同动画示例代码@Entry @Component struct CastTargetDemo { @State offsetX: number = 0 @State offsetY: number = 0 build() { Stack() { // 目标区域 Box() .width(120) .height(120) .backgroundColor(Color.Grey) .position({ x: 200, y: 300 }) // 可施放组件 Box() .width(80) .height(80) .backgroundColor(Color.Green) .translate({ x: this.offsetX, y: this.offsetY }) .gesture( PanGesture() .onUpdate((event) => { this.offsetX = event.offsetX this.offsetY = event.offsetY }) .onEnd(() => { if (this.offsetX > 150 && this.offsetY > 250) { // 施放成功，吸附到目标 animateTo({ duration: 200 }, () => { this.offsetX = 200 this.offsetY = 300 }) } else { // 失败，回弹 animateTo({ duration: 300 }, () => { this.offsetX = 0 this.offsetY = 0 }) } }) ) } .width('100%') .height('100%') } }这里在做什么判断if (this.offsetX > 150 && this.offsetY > 250)这本质上是一个区域命中判断。在正式项目中，你可以：根据组件尺寸动态计算封装成工具函数甚至引入碰撞检测逻辑真实应用场景示例场景一：卡片拖拽投放到功能区典型应用：首页卡片管理、模块编辑模式。示例核心代码.onEnd(() => { if (this.offsetX > 180) { animateTo({ duration: 200 }, () => { this.offsetX = 220 this.offsetY = 0 }) // 这里可以触发业务逻辑，比如加入列表 } else { animateTo({ duration: 300 }, () => { this.offsetX = 0 this.offsetY = 0 }) } })逻辑上非常清晰：UI 动画和业务逻辑是分开的，不会互相影响。场景二：图标拖进回收站这种交互非常常见，关键点是：松手瞬间让组件消失而不是回弹.onEnd(() => { if (this.offsetY > 400) { animateTo({ duration: 200 }, () => { this.offsetY = 600 }) } else { animateTo({ duration: 300 }, () => { this.offsetX = 0 this.offsetY = 0 }) } })你也可以配合透明度一起做：.opacity(this.isRemoved ? 0 : 1)场景三：设备管理中的“拖拽分组”结合你后续可能做的鸿蒙设备管理场景：左侧设备列表右侧分组区域拖拽设备到分组完成绑定这时就可以升级到 Drag & Drop，实现跨组件投放。Box() .draggable(true) .onDragStart(() => { return { data: 'device-id-001' } })目标区域：Column() .onDrop((event) => { console.log('接收到设备：', event.data) })这种方式更适合复杂业务。QA 常见问题Q1：为什么不用绝对定位？绝对定位是死的，而 translate 是基于状态的，动画过渡更自然，也更安全。Q2：施放动画卡顿怎么办？确保只操作必要的状态避免在 onUpdate 里写复杂逻辑动画时间不要太长Q3：PanGesture 和 Drag 怎么选？单组件内部效果：PanGesture跨组件、跨区域：Drag & Drop总结在 ArkUI 中，“施放功能”并不是某一个 API，而是一种交互设计模式：手势负责感知用户行为状态决定组件位置动画塑造最终体验只要你理解了这个组合思路，就可以根据项目需求，灵活实现各种拖拽、投放、释放效果，而且代码非常干净、可维护性也很好。