标签 ArkUI 下的文章

在 ArkUI 里,单个手势(点击、长按、滑动、缩放…)已经够好用,但一旦你要做这种交互:

  • 长按后才能拖动
  • 同一区域支持 单击 / 双击 且行为不同
  • 两个手势要 同时识别 或互斥

就会发现仅靠 TapGesture / PanGesture 这些基础手势不太好管理——这时候就轮到主角 GestureGroup 登场了。

本文定位就是一篇可以直接发社区的实战向自学笔记,按这几个问题展开:

  1. GestureGroup 是什么?解决什么问题?
  2. 三种 GestureMode 到底怎么选?
  3. 如何正确组合单击 / 双击、长按 + 拖动?
  4. onCancel 在真实项目中有什么用?

一、GestureGroup 是什么?

官方一句话定义:

GestureGroup 用来把多个基础手势组合在一起,根据指定的识别模式统一管理。
  • API Version 7 开始支持
  • 元服务 从 API 11 开始支持
  • 系统能力:SystemCapability.ArkUI.ArkUI.Full

核心接口只有一个:

GestureGroup(mode: GestureMode, ...gesture: GestureType[])

参数说明:

  • mode: GestureMode(必填)
    组合手势的“识别策略”,即三种模式:Sequence / Parallel / Exclusive
  • ...gesture: GestureType[](可选)

    • 一个或多个基础手势实例(TapGestureLongPressGesturePanGesture 等)
    • 如果这里不填,那这个 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 关键点解读

  1. 必须用 Sequence 模式

    GestureGroup(GestureMode.Sequence, LongPressGesture(...), PanGesture())

    想要“长按 → 再拖动”这样的链式交互,最自然就是顺序识别。

  2. 位移计算通过“起始位置 + 偏移量”完成

    this.offsetX = this.positionX + event.offsetX
    this.offsetY = this.positionY + event.offsetY
    • positionX / positionY 记录上一次拖动结束的位置;
    • event.offsetX / offsetY 是当前手势中的增量;
    • 松手时把当前 offset 写回 position,即新起点。
  3. 只在 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 使用小结 & 常见坑

最后快速帮你盘一遍重点:

  1. 基本语法

    .gesture(
      GestureGroup(GestureMode.Sequence | Parallel | Exclusive, 手势1, 手势2, ...)
        .onCancel(() => { ... })
    )
  2. mode 选型建议

    • 顺序链条(长按 → 拖动、双击优先于单击):Sequence
    • 多手势同时有效(缩放 + 旋转):Parallel
    • 多手势竞争,一个成功其他失败(滑动 vs 点击):Exclusive
  3. Tap + 双击 必须注意顺序

    • 双击手势写前面,单击写后面;
    • 否则单击会先被识别,导致双击识别不到。
  4. Sequence 模式 only 最后一个 onActionEnd 生效

    • 需要在“最后一个手势”的 onActionEnd 里做收尾逻辑;
    • 上层流程性操作,尽量放在最后一个手势里处理。
  5. 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 })
  }
}

总结

掌握了 AppStoragePersistentStorage,你就掌握了鸿蒙应用数据流动的“任督二脉”。AppStorage 打通了页面间的隔阂,让数据在内存中自由穿梭;PersistentStorage 则打通了内存与磁盘的界限,让关键数据得以长久保存。
在实际开发中,建议将所有的 Keys 定义为一个常量文件,避免魔术字符串满天飞。同时,虽然全局状态很好用,但也要克制,不要把所有数据都往里塞,只存放那些真正需要全局共享和持久化的“配置级”或“用户级”数据,保持应用内存的纯净与高效。

前言

在鸿蒙应用的开发过程中,状态管理一直是我们绕不开的话题。如果你是从 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:

<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
  <path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>

ic_back.svg:

<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
  <path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
</svg>

五、运行与测试

步骤1:编译运行

# 在 DevEco Studio 中
1. 连接真机或启动模拟器
2. 点击 Run 按钮或按 Shift+F10
3. 等待应用安装完成

步骤2:功能测试

1. 添加日程测试
   - 点击右上角 + 按钮
   - 输入标题:测试日程
   - 选择时间:5分钟后
   - 选择提前提醒:准时提醒
   - 点击保存

2. 提醒测试
   - 返回主页等待
   - 5分钟后应收到系统通知
   - 即使关闭应用也会收到提醒

3. 重复日程测试
   - 添加一个每天重复的日程
   - 验证每天都会收到提醒

六、核心API说明

reminderAgentManager 关键API

API功能说明
publishReminder()发布提醒设置定时提醒,返回 reminderId
cancelReminder()取消提醒根据 reminderId 取消
getValidReminders()获取有效提醒获取所有未触发的提醒
cancelAllReminders()取消所有提醒取消当前应用所有提醒

提醒类型

// 日历提醒(精确到秒)
ReminderType.REMINDER_TYPE_CALENDAR

// 闹钟提醒(每天固定时间)
ReminderType.REMINDER_TYPE_ALARM

// 倒计时提醒
ReminderType.REMINDER_TYPE_TIMER

七、注意事项

  1. 权限申请:必须在 module.json5 中声明 ohos.permission.PUBLISH_AGENT_REMINDER
  2. 时间限制:提醒时间必须大于当前时间
  3. 数量限制:单个应用最多设置 30 个提醒
  4. 重复规则:重复日程需要正确设置 repeatMonthsrepeatDays
  5. 后台保活reminderAgentManager 由系统管理,无需应用保活

这套代码已经过实测,可以直接复制使用!我是 V 哥,关注我,一起探索新技术的魅力海洋。

在 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 做自适应布局
  }
}

这里有三件事:

  1. 使用 @Env(...) 装饰组件字段;
  2. 参数是一个 SystemProperties 枚举值(环境变量的“key”);
  3. 装饰后的字段类型由这个环境变量决定,比如 BREAK_POINT 对应 WindowSizeLayoutBreakpointInfo
✅ 重点:当 @Env 写在 @Component / @ComponentV2 内部字段上时,它能拿到当前窗口的一些环境信息,而不是全局单例。

二、核心类型:EnvDecorator & SystemProperties

2.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;

排查建议:

  1. 一定要使用 SystemProperties 枚举,不要手写字符串:

    @Env(SystemProperties.BREAK_POINT)
    breakpoint: uiObserver.WindowSizeLayoutBreakpointInfo;
  2. 确认当前 SDK / API Level 是否已经 ≥ 22
  3. 检查是不是写错了导入,或自定义了同名枚举覆盖了系统的 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 思路设计

  1. @Env(SystemProperties.BREAK_POINT) 拿到断点信息;
  2. 根据断点信息判断当前属于 COMPACT / MEDIUM / EXPANDED 之类的类别(具体枚举以 SDK 为准);
  3. 用一个 getter 或方法,将断点映射到“列数”、“间距”等 UI 参数;
  4. 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?

典型场景包括:

  1. 响应式布局:

    • 不同断点展示不同列数、不同导航结构;
    • 小屏用 Tab,大屏用侧栏 + 内容区域。
  2. 窗口多实例 / 多窗口:

    • 同一个组件被复用到不同窗口中,需要根据各自窗口环境分别调整。
  3. 元服务 / 卡片场景:

    • 某些运行形态下环境信息不同,通过 @Env 拿到差异,裁剪 UI。

八、总结

@Env 看起来只是一个小小的装饰器,但定位其实很清晰:

  • @State / @Prop 管组件内部/外部数据;
  • @Env 管组件所处的“环境维度”的信息。

掌握它之后,你可以把「环境感知」这件事,从零散的 getWindowRect、全局单例逻辑中抽离出来,用更声明式、更 ArkUI 风格的写法来组织代码。

在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分钟内排查是否存在性能问题、明确问题类型与触发场景,不深入底层细节。

实操步骤(零基础可照做):

  1. 环境准备:USB连接真机(不支持模拟器),开启开发者模式与USB调试;确保macOS 12+,DevEco Studio版本匹配(建议5.1.0+)。
  2. 启动工具与选目标:通过菜单栏(View→Tool Windows→Profiler)、底部工具栏“Profiler”或搜索启动工具,在左侧会话区依次选择“设备—应用—进程”。
  3. 复现场景并监控:会话列表默认加载Realtime Monitor,操作应用复现核心场景(冷启动、列表滑动、动画播放等),观察数据区泳道的CPU、内存、帧率、GPU数据。
  4. 标记异常并定界:用快捷键M标记异常时间点,记录核心信息——如“列表滑动时帧率降至40fps(卡顿)”“内存多次操作后只增不减(泄漏)”,明确问题类型与场景。

干货技巧:实时监控仅用于“筛问题”,无需长时间录制;重点关注帧率、CPU占用两大指标,可快速锁定80%的表层性能问题。

步骤2:深度录制定位——精准找到代码根因

核心目标:针对定界的问题,用场景化模板录制精细化数据,从宏观指标拆解至具体代码行,找到根本原因。

实操核心步骤:

  1. 选对场景化模板(关键!):模板选错会导致数据无效,匹配关系如下:

问题类型

推荐模板

核心分析维度

页面滑动/动画卡顿

Frame/ArkUI

帧率丢帧、组件绘制、状态更新

应用启动慢

Launch

启动各阶段耗时、热点函数

ArkTS层内存泄漏

Snapshot

对象持有关系、内存分配节点

Native层问题

Allocation/CPU

Native内存分配、CPU热点函数

  1. 深度录制场景:选中模板后点击“Create Session”,点击录制按钮(▶),完整复现异常场景(如滑动卡顿需滑动3次以上),结束录制后等待数据解析。
  2. 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 实操分析流程(卡顿场景)

  1. 框选卡顿时段,查看RS Frame/App Frame泳道,判断卡顿来自App侧还是Render侧;
  2. 若为App侧卡顿:切换至ArkTS Callstack泳道,定位耗时最长的组件绘制或状态更新函数;
  3. 若为Render侧卡顿:查看GPU使用率,排查是否因硬件合成渲染过载;
  4. 通过“Statistics”区域统计卡顿率、次数,验证优化后的数据改善情况。

3.3 快捷键高效操作(提升50%效率)

  • 时间轴:W/S放大/缩小,A/D左右移动(需激活泳道区);
  • 标记:M添加单点标记,Shift+M添加时间段标记;
  • 标记切换:Ctrl+,/Ctrl+. 前后切换单点标记,Ctrl+[/Ctrl+] 切换时间段标记。

四、专项分析:ArkUI组件与状态卡顿定位

ArkUI层卡顿多源于组件布局、状态管理不当,通过ArkUI模板的专属泳道,可精准定位这类上层问题。

4.1 典型问题场景(高频踩坑点)

  1. 布局嵌套过多:组件层级超过5层,导致绘制链路冗长;
  2. 冗余刷新:更新大型Object部分属性,触发全对象刷新;
  3. 状态绑定异常:子组件重复绑定同一状态变量,更新时多次刷新;
  4. 装饰器误用:@Prop传递大型对象,引发不必要的深度拷贝。

4.2 核心泳道实操

4.2.1 ArkUI Component泳道(组件绘制分析)

  1. 框选时段后,“Summary”列表展示组件绘制统计(次数、总耗时、最大耗时),快速锁定绘制耗时最长的组件;
  2. 点选泳道条块,“More”区域展示组件树,直观查看布局嵌套层级,优化冗余组件。

4.2.2 ArkUI State泳道(状态更新分析)

  1. 录制状态更新场景(如点击按钮更新数据),“Summary”区域展示状态变量的变化次数、所属组件;
  2. 选中状态变量变化记录,开启“Delivery Chain”开关,图形化查看状态影响的组件链路,定位冗余刷新组件;
  3. 关联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 对多语言、多地区资源的天然支持

在真实项目中,大多数地区定制需求并不复杂,核心思路其实只有一句话:

先交给系统做资源适配,实在不行再写判断逻辑。

下面我们一步一步来看具体实现方式。

鸿蒙地区特定内容的整体实现思路

在鸿蒙系统中,地区定制通常可以拆分为三个层次:

  1. 系统层:获取当前设备的语言和地区信息
  2. 资源层:根据地区自动加载不同资源
  3. 业务层:在运行时根据地区控制功能和内容

这三层并不是互斥的,而是经常组合使用。

通过系统语言和地区识别用户环境

获取系统地区信息

鸿蒙提供了 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'))

不同地区加载不同内容,避免代码层面处理复杂文本。

常见问题 QA

Q1:可以只用代码判断不做资源适配吗?

可以,但不推荐。
代码判断适合控制功能,不适合承载大量文案。

Q2:地区和语言一定是一一对应的吗?

不一定。
比如香港地区可能使用中文或英文,建议优先按语言,再结合地区判断。

Q3:地区变化时需要重启应用吗?

一般不需要,重新加载页面即可。
资源匹配通常在页面创建时生效。

总结

在鸿蒙系统中实现地区特定内容,其实并不复杂,关键在于合理分层

  • 文案和静态内容优先使用资源适配
  • 功能和业务规则使用少量逻辑判断
  • 活动和运营内容交给服务端

一句话概括就是:

资源适配解决大部分问题,代码只处理真正的差异逻辑。

前言

在一个优秀的应用设计中,界面不仅仅是平铺直叙的展示,更需要有层级感。当用户点击删除按钮时,我们需要一个确认框来防止误触;当后台数据加载完成时,我们需要一个轻量的提示告诉用户 好了 ;当用户对某个晦涩的功能图标感到困惑时,我们需要一个气泡弹窗来解释它的含义。这些浮在主界面之上的交互层,我们统称为 覆盖物(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 来移除系统默认背景的。这就是构建高颜值弹窗的标准模板。

TypeScript

import { 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 开发中,掌握 @CustomDialogbindPopup 是构建高级 UI 的必修课。我们抛弃了系统的默认样式,通过 customStyle 获得了对画布的完全掌控权,让弹窗不再只是功能的载体,更是视觉设计的延伸。切记,不要滥用弹窗,每一次遮罩的出现都是对用户注意力的强行掠夺。

好的交互应该是克制的,只在真正需要的时候才优雅地浮现。

在这里插入图片描述

摘要

随着鸿蒙系统在手机、平板、穿戴设备以及多终端场景中的应用越来越多,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 会直接爆炸。

正确姿势:LazyForEach

LazyForEach(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
  • 列表一定懒加载

这些优化手段单独看都不复杂,但一旦组合起来,页面流畅度会有非常明显的提升。

在这里插入图片描述

摘要

在 HarmonyOS 的 ArkUI 开发中,经常会遇到这样一种交互需求:
用户按下某个组件,拖动它,然后在松手的一瞬间触发一个“释放”动作,比如飞出去、回弹、投放到某个区域,或者触发业务逻辑。

很多同学在一开始都会问一个问题:
ArkUI 里有没有现成的“施放 API”?

答案是:没有。
但 ArkUI 提供的 手势系统、状态管理和动画能力,已经足够我们组合出各种“施放效果”。

这篇文章就从一个最基础的拖拽开始,一步一步讲清楚:
ArkUI 中的“施放功能”到底是怎么实现的,以及在真实项目中该怎么用。

引言

随着 HarmonyOS 应用交互越来越偏向“自然操作”,像拖拽、投放、抛出这类交互,在实际项目中出现得非常多,比如:

  • 卡片拖到指定区域触发操作
  • 图标长按后丢进回收区
  • 功能模块拖拽排序
  • 智能设备管理中,把设备“丢”进分组

在 ArkUI 里,这些效果并不是某一个组件单独完成的,而是多种能力的组合
理解这一点之后,你会发现实现起来并不复杂,而且扩展性非常强。

ArkUI 中“施放”的本质是什么

从技术角度来看,所谓“施放”,本质就是三步:

  1. 用手势感知用户操作
  2. 用状态驱动组件位置变化
  3. 在松手时,通过动画完成“释放效果”

换句话说就是:
手势负责输入,状态负责位置,动画负责感觉。

最基础的施放实现:拖拽 + 松手回弹

实现思路

这个 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,而是一种交互设计模式

  • 手势负责感知用户行为
  • 状态决定组件位置
  • 动画塑造最终体验

只要你理解了这个组合思路,就可以根据项目需求,灵活实现各种拖拽、投放、释放效果,而且代码非常干净、可维护性也很好。

前言

在鸿蒙应用的开发历程中,页面跳转一直是大家最先接触的功能之一。很长一段时间里,Router 模块都是我们手中的标配武器,那句 router.pushUrl 相信每一位开发者都烂熟于心。但在构建大型应用,尤其是面对平板、折叠屏这些复杂设备时,老旧的 Router 逐渐显露出了疲态。它是一个页面级别的全局单例,难以处理分屏、弹窗嵌套路由以及模块化的动态加载。这就像是用一把瑞士军刀去砍伐整片森林,虽然能用,但效率极低且手感生涩。

在 HarmonyOS 6 的时代,官方明确推荐我们全面拥抱 Navigation 组件。这不仅仅是一个组件的更替,更是一次架构思维的升级。Navigation 不再是一个简单的 API 调用,它是一个容器,一个能够容纳完整路由栈、标题栏和工具栏的超级容器。它将路由的管理权从系统底层交还到了开发者手中,让我们能够像操作数组一样精准地控制页面的进出栈。

今天,我们就把那个陈旧的 Router 放在一边,深入探讨如何利用 Navigation V2 架构和 NavPathStack 构建一个现代化、健壮的应用导航体系。

一、 从 Router 到 Navigation:架构的范式转移

要理解 Navigation 的强大,我们先得明白它解决了什么痛点。传统的 Router 是基于 Page(页面)的,每一个页面都是一个独立的 Ability 或者窗口层级。当我们想要在一个弹窗里再做一套局部导航,或者在平板的左侧菜单里嵌入一个独立的路由栈时,Router 就束手无策了。

Navigation 组件的出现彻底改变了这一局面。它本质上是一个 UI 组件,这意味着它可以被放置在界面的任何位置。你可以把它放在根节点作为全屏导航,也可以把它放在一个 Dialog 内部,甚至可以嵌套使用。

在 API 20 中,Navigation 采用了 组件级路由 的概念。每一个“页面”不再是 @Entry 修饰的独立文件,而是被 NavDestination 包裹的自定义组件。这种设计让页面变得极其轻量,页面的切换本质上就是组件的挂载与卸载,性能得到了巨大的提升。更重要的是,它配合 NavPathStack 实现了路由栈的可编程化,我们终于可以像操作数据一样去操作界面了。

二、 核心大脑:NavPathStack 路由栈管理

如果说 Navigation 是躯壳,那么 NavPathStack 就是它的灵魂。在 V2 版本中,我们不再直接调用组件的方法来跳转,而是创建一个 NavPathStack 的实例,并将其绑定到 Navigation 组件的 pathStack 属性上。这个栈对象就是我们操控界面的遥控器。

你需要实现一个复杂的登录流程:用户点击购买 -> 跳转登录 -> 跳转注册 -> 注册成功 -> 直接返回购买页(跳过登录页)。在旧的 Router 模式下,你需要计算 delta 索引或者使用 replace 模式小心翼翼地堆叠。而在 NavPathStack 中,就方便多了。你可以随时调用 popToName 直接回到指定的路由锚点,或者操作栈数组,精准地移除中间的某几个页面。

数据的传递也变得优雅。当我们调用 pushPath 时,可以直接传入一个 param 对象。而在目标页面中,我们不需要再写繁琐的 router.getParams(),而是直接在 NavDestination 的 onShown 生命周期或者组件初始化时,从栈中获取参数。这种参数传递是类型安全的,且完全受控。此外,NavPathStack 还提供了强大的拦截器机制(Interception),让我们可以在路由跳转发生前进行鉴权拦截,比如用户未登录时直接重定向到登录页,这一切都在路由层面被优雅地拦截处理了。

三、 页面构造:NavDestination 与路由表设计

在 Navigation 架构下,我们的一级页面(根页面)通常直接写在 Navigation 的闭包里,而二级、三级页面则通过 NavDestination 来定义。这里有一个关键的概念转变:我们需要构建一个 路由映射表

我们不再是通过文件路径去跳转,而是通过 路由名称(Name)。我们需要在 Navigation 组件中配置 navDestination 属性,它接收一个 @Builder 构建函数。当 NavPathStack 请求跳转到 "DetailPage" 时,这个构建函数就会被触发,我们需要在这个函数里根据传入的 name 返回对应的 NavDestination 包裹的组件。

这种设计模式天然支持模块化开发。我们可以把不同模块的路由表分散在各自的 HAR 包中,最后在主工程中进行聚合。每个 NavDestination 都是一个独立的沙箱,它拥有自己的标题栏、菜单栏和生命周期(onShown, onHidden)。这对于开发者来说非常友好,我们可以在 onWillAppear 中发起网络请求,在 onWillDisappear 中保存草稿,页面的生命周期完全掌握在自己手中。

四、 界面定制:摆脱默认样式的束缚

Navigation 自带了标准的标题栏(TitleBar)和工具栏(ToolBar),这在快速开发原型时非常方便。但在实际的商业项目中,设计师往往会给出天马行空的顶部导航设计,比如透明渐变背景、复杂的搜索框或者异形的返回按钮。

很多初学者会困惑:我是该用系统自带的,还是自己画?我的建议是按需定制。Navigation 和 NavDestination 都提供了 titlemenustoolBar 属性。如果设计风格符合系统规范,直接传入资源配置即可,系统会自动适配深色模式和折叠屏布局。但如果设计差异巨大,我们可以通过 .hideTitleBar(true) 彻底隐藏系统标题栏,然后在内容区域(Content)的顶部放置我们自定义的 NavBar 组件。

这里有一个细节需要注意,当我们隐藏了系统标题栏后,原本的滑动返回手势依然有效,但左上角的返回箭头没了。我们需要自己实现一个返回按钮,并调用 this.pageStack.pop() 来手动触发返回。这种灵活性让我们既能享受系统手势的便利,又能完全掌控视觉呈现。

import { promptAction } from '@kit.ArkUI';

// 1. 定义路由参数模型
interface ContactParams {
  id: string;
  name: string;
  phone: string;
}

@Entry
@Component
struct NavigationBestPracticePage {
  // 核心修正:使用 @Provide 而不是 @State
  // 这样后代组件 (DetailPage) 才能通过 @Consume 直接获取该对象
  @Provide('pageStack') pageStack: NavPathStack = new NavPathStack();

  // 模拟的首页数据
  @State contacts: ContactParams[] = [
    { id: '1', name: '张三', phone: '13800138000' },
    { id: '2', name: '李四', phone: '13900139000' },
    { id: '3', name: '王五', phone: '15000150000' }
  ];

  // -------------------------------------------------------
  // 路由工厂:根据路由名称动态构建页面
  // -------------------------------------------------------
  @Builder
  PagesMap(name: string, param: Object) {
    if (name === 'DetailPage') {
      // 跳转到详情页
      DetailPage({
        contactInfo: param as ContactParams
      })
    } else if (name === 'EditPage') {
      // 跳转到编辑页
      EditPage({
        contactInfo: param as ContactParams
      })
    }
  }

  build() {
    // 根容器:Navigation
    Navigation(this.pageStack) {
      // 首页内容区域
      Column() {
        Text('通讯录 (V2)')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 20, bottom: 20 })
          .width('100%')
          .padding({ left: 16 })

        List() {
          ForEach(this.contacts, (item: ContactParams) => {
            ListItem() {
              Row() {
                // 这里使用系统图标模拟头像,实际请替换为 app.media.xxx
                Image($r('app.media.startIcon'))
                  .width(40)
                  .height(40)
                  .borderRadius(20)
                  .margin({ right: 12 })
                  .backgroundColor('#E0E0E0') // 兜底背景色

                Column() {
                  Text(item.name).fontSize(16).fontWeight(FontWeight.Medium)
                  Text(item.phone).fontSize(14).fontColor('#999')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                // 跳转按钮
                Button('查看')
                  .fontSize(12)
                  .height(28)
                  .onClick(() => {
                    // 核心动作:压栈跳转
                    this.pageStack.pushPathByName('DetailPage', item, true);
                  })
              }
              .width('100%')
              .padding(12)
              .backgroundColor(Color.White)
              .borderRadius(12)
              .margin({ bottom: 8 })
            }
          })
        }
        .padding(16)
        .layoutWeight(1)
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#F1F3F5')
    }
    // 绑定路由映射构建器
    .navDestination(this.PagesMap)
    // 首页的标题模式
    .titleMode(NavigationTitleMode.Mini)
    .hideTitleBar(true) // 首页隐藏系统标题栏,使用自定义内容
    .mode(NavigationMode.Stack) // 强制使用堆叠模式
  }
}

// -------------------------------------------------------
// 子页面 1:详情页 (使用 @Consume 获取 Stack)
// -------------------------------------------------------
@Component
struct DetailPage {
  // 接收参数
  contactInfo: ContactParams = { id: '', name: '', phone: '' };

  // 获取当前的路由栈 (对应父组件的 @Provide)
  @Consume('pageStack') pageStack: NavPathStack;

  build() {
    NavDestination() {
      Column({ space: 20 }) {
        Image($r('app.media.startIcon'))
          .width(80)
          .height(80)
          .borderRadius(40)
          .margin({ top: 40 })
          .backgroundColor('#E0E0E0')

        Text(this.contactInfo.name)
          .fontSize(24)
          .fontWeight(FontWeight.Bold)

        Text(this.contactInfo.phone)
          .fontSize(18)
          .fontColor('#666')

        Button('编辑资料')
          .width('80%')
          .margin({ top: 40 })
          .onClick(() => {
            // 继续压栈,跳转到编辑页
            this.pageStack.pushPathByName('EditPage', this.contactInfo);
          })
      }
      .width('100%')
      .height('100%')
    }
    .title('联系人详情') // 设置系统标题
  }
}

// -------------------------------------------------------
// 子页面 2:编辑页 (使用 onReady 获取 Stack)
// -------------------------------------------------------
@Component
struct EditPage {
  @State contactInfo: ContactParams = { id: '', name: '', phone: '' };
  @State newName: string = '';

  // 独立维护 Stack 引用,不依赖 @Consume,解耦性更好
  private stack: NavPathStack | null = null;

  aboutToAppear(): void {
    this.newName = this.contactInfo.name;
  }

  build() {
    NavDestination() {
      Column({ space: 16 }) {
        Text('修改姓名:')
          .fontSize(14)
          .fontColor('#666')
          .width('90%')
          .margin({ top: 20 })

        TextInput({ text: $$this.newName, placeholder: '请输入新名字' })
          .backgroundColor(Color.White)
          .width('90%')
          .height(50)
          .borderRadius(10)

        Button('保存并返回')
          .width('90%')
          .margin({ top: 20 })
          .onClick(() => {
            // 模拟保存操作
            if (this.stack) {
              this.stack.pop(true); // 出栈
              promptAction.showToast({ message: `保存成功: ${this.newName}` });
            }
          })
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#F1F3F5')
    }
    .title('编辑')
    .onReady((context: NavDestinationContext) => {
      // 最佳实践:在 onReady 中获取当前页面的 stack
      // 这种方式不需要父组件必须使用 @Provide,适用性更广
      this.stack = context.pathStack;
    })
  }
}

五、 总结与实战

Navigation 组件配合 NavPathStack,标志着鸿蒙应用开发进入了 单窗口多组件(Single Window, Multi-Component) 的架构时代。它解决了 Router 时代的诸多顽疾,提供了更灵活的嵌套能力、更强大的路由栈控制以及更轻量的页面切换开销。

对于任何一个立志于构建专业级鸿蒙应用的开发者来说,尽早重构代码,迁移到 Navigation 架构,是提升应用质量的关键一步。

在 ArkUI 里,做主题和平时做样式是两件事:

  • 样式:某个组件单独改 fontColorbackgroundColor
  • 主题:一整块区域里的组件,整体按一套规则变色

API Version 12 开始,ArkUI 提供了一个专门做「局部主题」的组件:WithTheme
它不负责画 UI,只负责一件事:给作用域里的组件套一层主题/深浅色规则

这篇文章就是一份可以直接上手的 WithTheme 自学指南,适合发社区、做笔记或带项目里落地。


一、WithTheme 是什么?

官方定义很简单:

  • WithTheme 是一个主题作用域容器
  • 只接受一个子组件(可以是 Column / Row / 自定义组件);
  • 只负责两件事:

    • 配置这一块区域用哪套 自定义主题颜色theme);
    • 控制这一块区域的 深色 / 浅色模式colorMode)。

基础信息:

  • 支持版本:从 API Version 12 开始;
  • 系统能力SystemCapability.ArkUI.ArkUI.Full
  • 元服务:从 API 12 开始支持元服务 API;
  • 不支持通用属性、不支持通用事件(它只是“包裹容器”,样式写在子组件上)。

二、WithTheme 能影响哪些组件?

不是所有组件都会响应 WithTheme,这点很关键。当前支持的系统组件包括:

  • 输入类:TextInputSearch
  • 按钮 & 徽标:ButtonBadgeCounter
  • 轮播 & 选择类:SwiperSelectMenu
  • 文本类:Text
  • 选择器类:

    • TimePickerDatePickerTextPicker
    • CheckboxCheckboxGroupRadio
    • Slider
  • 状态展示类:

    • ProgressTogglePatternLockQRCode
  • 分隔类:Divider
简单记:表单控件 + 按钮 + 文本 + 分隔线,大部分能跟着 WithTheme 一起变。

三、核心接口与配置项

3.1 WithTheme 基本接口

WithTheme(options: WithThemeOptions) {
  // 只能有一个子组件
  // 这个子组件里面可以再写 Column/Row/自定义组件
}
注意:WithTheme 不支持通用属性和通用事件,需要把布局、点击等逻辑写在内部组件上。

3.2 WithThemeOptions 结构

interface WithThemeOptions {
  theme?: CustomTheme        // 自定义主题配色
  colorMode?: ThemeColorMode // 深浅色模式
}
  • theme?: CustomTheme

    • 用于指定 WithTheme 作用域内组件的缺省配色
    • 默认:undefined,表示跟随系统 token 默认样式。
  • colorMode?: ThemeColorMode

    • 控制作用域内组件的深色/浅色模式
    • 默认:ThemeColorMode.SYSTEM(跟随系统)。

3.3 CustomTheme 类型

type CustomTheme = CustomTheme
  • CustomTheme 实际上是一个接口;
  • 搭配 CustomColors 一起使用,用来描述一整套颜色体系(比如一套绿色主题、一套红色主题)。

四、局部深浅色:colorMode 实战

很多页面希望做到:

  • 整体跟随系统;
  • 但某一块区域 强制深色(比如顶部 Banner)或 强制浅色(比如活动卡片)。

这时可以用 WithTheme 搭配 colorMode

4.1 深浅色资源准备:dark.json

image.png

要让深浅色生效,先准备深色资源文件 dark.json,例如:

{
  "color": [
    {
      "name": "start_window_background",
      "value": "#000000"
    }
  ]
}

4.2 示例:同一页面展示默认、Dark、Light 三种区域

image.png

@Entry
@Component
struct Index {
  build() {
    Column() {
      // ① 系统默认区域
      Column() {
        Text('无WithTheme')
          .fontSize(40)
          .fontWeight(FontWeight.Bold)
      }
      .justifyContent(FlexAlign.Center)
      .width('100%')
      .height('33%')
      .backgroundColor($r('app.color.start_window_background'))

      // ② 局部强制深色模式
      WithTheme({ colorMode: ThemeColorMode.DARK }) {
        Column() {
          Text('WithTheme')
            .fontSize(40)
            .fontWeight(FontWeight.Bold)
          Text('DARK')
            .fontSize(40)
            .fontWeight(FontWeight.Bold)
        }
        .justifyContent(FlexAlign.Center)
        .width('100%')
        .height('33%')
        .backgroundColor($r('sys.color.background_primary'))
      }

      // ③ 局部强制浅色模式
      WithTheme({ colorMode: ThemeColorMode.LIGHT }) {
        Column() {
          Text('WithTheme')
            .fontSize(40)
            .fontWeight(FontWeight.Bold)
          Text('LIGHT')
            .fontSize(40)
            .fontWeight(FontWeight.Bold)
        }
        .justifyContent(FlexAlign.Center)
        .width('100%')
        .height('33%')
        .backgroundColor($r('sys.color.background_primary'))
      }
    }
    .height('100%')
    .expandSafeArea(
      [SafeAreaType.SYSTEM],
      [SafeAreaEdge.TOP, SafeAreaEdge.END, SafeAreaEdge.BOTTOM, SafeAreaEdge.START]
    )
  }
}

使用建议:

  • 想让某个模块始终深色:WithTheme({ colorMode: ThemeColorMode.DARK })
  • 想让底部工具条固定浅色:ThemeColorMode.LIGHT
  • 根节点跟系统,局部区域用 WithTheme 做反色/特殊效果,是比较推荐的实践。

五、自定义主题:CustomTheme + CustomColors 实战

除了深浅色,有时我们希望整块区域用一套品牌色,比如「绿色主题卡片」vs「红色活动卡片」。

这时用 CustomTheme 来定义一套颜色,然后交给 WithTheme

5.1 定义颜色集合 CustomColors

import { CustomTheme, CustomColors } from '@kit.ArkUI';

class GreenColors implements CustomColors {
  fontPrimary = '#ff049404';
  fontEmphasize = '#FF00541F';
  fontOnPrimary = '#FFFFFFFF';
  compBackgroundTertiary = '#1111FF11';
  backgroundEmphasize = '#FF00541F';
  compEmphasizeSecondary = '#3322FF22';
}

class RedColors implements CustomColors {
  fontPrimary = '#fff32b3c';
  fontEmphasize = '#FFD53032';
  fontOnPrimary = '#FFFFFFFF';
  compBackgroundTertiary = '#44FF2222';
  backgroundEmphasize = '#FFD00000';
  compEmphasizeSecondary = '#33FF1111';
}
实际项目里可以按照设计给的 token 表来映射,保持命名和 UI 视觉规范一致。

5.2 封装成 CustomTheme

class PageCustomTheme implements CustomTheme {
  colors?: CustomColors

  constructor(colors: CustomColors) {
    this.colors = colors
  }
}

5.3 使用 WithTheme 控制局部主题

下面这个例子展示了一个典型的用法:
上半部分使用系统默认按钮配色
下半部分被 WithTheme 包裹,使用可切换的自定义主题

@Entry
@Component
struct IndexPage {
  static readonly themeCount = 3;

  themeNames: string[] = ['System', 'Custom (green)', 'Custom (red)'];

  themeArray: (CustomTheme | undefined)[] = [
    undefined,                              // 系统默认主题
    new PageCustomTheme(new GreenColors()), // 绿色主题
    new PageCustomTheme(new RedColors())    // 红色主题
  ]

  @State themeIndex: number = 0;

  build() {
    Column() {
      // 区域一:未使用 WithTheme,系统默认配色
      Column({ space: '8vp' }) {
        Text('未使用WithTheme')

        // 点击切换下方 WithTheme 的配色
        Button(`切换theme配色:${this.themeNames[this.themeIndex]}`)
          .onClick(() => {
            this.themeIndex = (this.themeIndex + 1) % IndexPage.themeCount;
          })

        // 系统默认按钮配色
        Button('Button.style(NORMAL) with System Theme')
          .buttonStyle(ButtonStyleMode.NORMAL)
        Button('Button.style(EMP..ED) with System Theme')
          .buttonStyle(ButtonStyleMode.EMPHASIZED)
        Button('Button.style(TEXTUAL) with System Theme')
          .buttonStyle(ButtonStyleMode.TEXTUAL)
      }
      .margin({ top: '50vp' })

      // 区域二:使用 WithTheme,局部换肤
      WithTheme({ theme: this.themeArray[this.themeIndex] }) {
        Column({ space: '8vp' }) {
          Text('使用WithTheme')
          Button('Button.style(NORMAL) with Custom Theme')
            .buttonStyle(ButtonStyleMode.NORMAL)
          Button('Button.style(EMP..ED) with Custom Theme')
            .buttonStyle(ButtonStyleMode.EMPHASIZED)
          Button('Button.style(TEXTUAL) with Custom Theme')
            .buttonStyle(ButtonStyleMode.TEXTUAL)
        }
        .width('100%')
      }
    }
  }
}

效果:

  • 上半部分:始终采用系统默认主题;
  • 下半部分:随着按钮点击,在 System / Green / Red 三种主题间切换;
  • 完全局部生效,不影响其他页面和组件。

六、常见使用场景

结合上面的能力,WithTheme 很适合这些场景:

  1. 局部夜间模式

    • 例如:播放器底部控制条、评论区、侧边栏等;
    • 根页面跟系统,某个区域用深色:
    WithTheme({ colorMode: ThemeColorMode.DARK }) {
      // 播放控制区 / 评论列表
    }
  2. 卡片级换肤 / 品牌卡片

    • 营销活动卡片、会员卡片、小程序入口等:
    WithTheme({ theme: new PageCustomTheme(new GreenColors()) }) {
      // 活动卡片 / 会员卡片布局
    }
  3. 表单区域统一风格

    • 一个复杂表单里用到 Button / TextInput / Checkbox / Slider 等:
    • 全部丢在 WithTheme 里,做一套专门的表单主题。
  4. 多主题 Demo / 设置页

    • 设置页里提供「主题预览」;
    • 上方一个切换按钮,下面用了多个 WithTheme 区块分别展示效果。

七、容易踩的点 & 调试建议

  1. 子组件只能一个

    • WithTheme 的子节点只能是一个组件;
    • 如果有多个,请用 Column/Row/自定义组件包一层。
  2. 不是所有组件都响应主题

    • 自绘组件(Canvas、Shape 等)不会自动跟主题;
    • 自定义组件如果内部没用系统控件,也看不到效果。
  3. 内部写死颜色会覆盖部分主题

    • 比如你在 Button 上手动设置了 backgroundColor('#FF0000')
    • 这可能会盖住主题里本来给它配置的一些颜色表现;
    • 建议:尽量用 buttonStylefontColor + 主题,让主题主导,而不是全部手写 Hex。
  4. 深浅色看起来没变化?

    • 检查是否已经配置 dark.json 等资源;
    • 检查是不是本身背景就接近黑/白,导致肉眼不明显;
    • 可以临时多放一些 Text / Button 观察效果。

八、总结

WithTheme 的定位可以一句话概括:

内外解耦:全局主题搞整体,WithTheme 专门做“局部换肤 + 局部深浅色”。

掌握它之后,你可以在 ArkUI 里轻松实现:

  • 某一块区域固定深色 / 浅色;
  • 某类卡片、一段区域统一走品牌主题色;
  • 在一个页面里同时展示多套主题效果,而不影响全局。