标签 HarmonyOS 6 下的文章

前言

在上一篇文章中,我们探讨了组件内部以及深层嵌套对象的状态管理。但当我们把视角拉高,俯瞰整个应用时,会发现一个新的挑战:数据孤岛。

用户在“我的”页面修改了头像,首页的左上角是不是也得跟着变?用户在“设置”里开启了夜间模式,是不是所有的页面都要瞬间切换颜色?如果只靠组件之间的父子传递(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 哥,关注我,一起探索新技术的魅力海洋。

前言

在一个优秀的应用设计中,界面不仅仅是平铺直叙的展示,更需要有层级感。当用户点击删除按钮时,我们需要一个确认框来防止误触;当后台数据加载完成时,我们需要一个轻量的提示告诉用户 好了 ;当用户对某个晦涩的功能图标感到困惑时,我们需要一个气泡弹窗来解释它的含义。这些浮在主界面之上的交互层,我们统称为 覆盖物(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 获得了对画布的完全掌控权,让弹窗不再只是功能的载体,更是视觉设计的延伸。切记,不要滥用弹窗,每一次遮罩的出现都是对用户注意力的强行掠夺。

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

前言

在鸿蒙应用的开发历程中,页面跳转一直是大家最先接触的功能之一。很长一段时间里,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 架构,是提升应用质量的关键一步。