前言在上一篇文章中，我们探讨了组件内部以及深层嵌套对象的状态管理。但当我们把视角拉高，俯瞰整个应用时，会发现一个新的挑战：数据孤岛。用户在“我的”页面修改了头像，首页的左上角是不是也得跟着变？用户在“设置”里开启了夜间模式，是不是所有的页面都要瞬间切换颜色？如果只靠组件之间的父子传递（Props），我们需要把这些状态一路从根节点透传下去，这种属性钻取简直是代码维护的噩梦。更棘手的是，当用户划掉后台进程，杀掉应用，再次打开时，我们希望他上次设置的“夜间模式”依然生效。这就涉及到了内存数据与磁盘数据的同步问题。在鸿蒙 HarmonyOS 6 (API 20) 中，ArkUI 给出了一套完整的应用级状态管理方案：AppStorage全局内存、LocalStorage页面级内存以及 PersistentStorage持久化存储。今天，我们就来聊聊如何打通这三条数据流，构建一个数据互通且能记住用户习惯的应用。这份优化后的文案去除了口语化的比喻和非必要的符号，采用了更专业、精准的技术语言，强调了核心概念与运行机制。一、 AppStorage 应用内存状态的全局管理AppStorage 是应用在内存中的全局状态容器。一旦在其中存入属性，应用内的任意 Ability、Page 或自定义组件均可访问和修改。在声明式 UI 开发中，不建议在 build 函数中频繁直接调用 API。ArkUI 提供了两个专用的装饰器：@StorageLink：建立双向同步。组件内状态的修改会同步至全局，并驱动其他订阅该状态的组件更新。@StorageProp：建立单向订阅。仅接收全局状态的更新，组件内的修改不会同步回全局。例如，将“当前用户 Token”存储在 AppStorage 中。在任意页面声明 @StorageLink('userToken')，该变量即可与全局状态联通。无论何处更新了 Token，所有页面的对应变量均会实时刷新。二、 PersistentStorage 持久化数据同步AppStorage 仅存在于内存中，应用退出后数据会被清除。PersistentStorage 的作用是将 AppStorage 中的特定属性写入磁盘文件，实现持久化。PersistentStorage 并非独立的数据库，而是 AppStorage 与文件系统之间的同步机制。通常在应用启动早期（如 EntryAbility 或页面加载前）调用 PersistentStorage.persistProp('key', defaultValue)。系统会检查磁盘中是否存在该键值：若存在，则读取并覆盖 AppStorage 中的值；若不存在，则使用默认值初始化，并建立磁盘映射。连接建立后，开发者只需通过 @StorageLink 修改 AppStorage 中的数据，框架会自动监听变化并将新值写入磁盘，无需手动处理文件读写或 JSON 序列化。三、 LocalStorage 模块化状态隔离与全局的 AppStorage 不同，LocalStorage 用于解决模块化和多实例场景下的状态共享问题（如多窗口应用或独立模块）。LocalStorage 的生命周期绑定在 Ability 或 UIAbility 上下文中，创建了一个隔离的状态容器。在页面加载时，可传入独立的 LocalStorage 实例，组件内部则使用 @LocalStorageLink 进行对接。这种机制能有效保证状态的封装性与安全性，防止因全局变量过多导致的状态污染。四、 最佳实践 全局主题切换功能实现以下通过“全局主题切换”功能（支持深色模式与字体大小调整）演示三者的协同工作。需求包括：设置在所有页面生效，且应用重启后配置依然保留。实现时需遵循严格的初始化顺序：先持久化，再 UI 绑定。务必在 Ability 或文件顶层执行 PersistentStorage.persistProp，切勿在组件的 build 函数中执行持久化初始化，以免引发逻辑错误。import { promptAction } from '@kit.ArkUI'; // ============================================================= // 1. 初始化持久化数据 (必须在 @Entry 之前执行) // ============================================================= // 这里的逻辑是： // 1. 检查磁盘是否有 'appThemeMode'。 // 2. 如果有，将其加载到 AppStorage 中。 // 3. 如果没有，则使用默认值 'light' 初始化 AppStorage，并写入磁盘。 PersistentStorage.persistProp('appThemeMode', 'light'); PersistentStorage.persistProp('appFontSize', 16); @Entry @Component struct GlobalStatePage { // ============================================================= // 2. UI 绑定全局状态 (@StorageLink) // ============================================================= // @StorageLink 与 AppStorage 建立双向同步： // 读取：初始化时从 AppStorage 获取值。 // 写入：当 this.themeMode 改变 -> AppStorage 更新 -> PersistentStorage 写入磁盘。 @StorageLink('appThemeMode') themeMode: string = 'light'; @StorageLink('appFontSize') fontSize: number = 16; build() { // 根容器：背景色根据主题动态变化 Column() { // --- 顶部标题栏 --- Text('全局设置中心') .fontSize(24) .fontWeight(FontWeight.Bold) // 动态适配字体颜色 .fontColor(this.themeMode === 'light' ? '#333333' : '#FFFFFF') .margin({ top: 40, bottom: 40 }) // --- 设置选项卡片区域 --- Column({ space: 20 }) { // ----------------------------------------------------- // 选项 1：字体大小调节 // ----------------------------------------------------- Row() { Text('全局字号') .fontSize(this.fontSize) // 【关键点】应用动态字号 .fontColor(this.themeMode === 'light' ? '#333' : '#FFF') .fontWeight(FontWeight.Medium) // 调节按钮组 Row() { Button('A-') .onClick(() => { if (this.fontSize > 12) { this.fontSize -= 2; // 修改状态 -> 自动触发持久化保存 } else { promptAction.showToast({ message: '已经是最小字体了' }) } }) .backgroundColor(this.themeMode === 'light' ? '#F0F0F0' : '#444') .fontColor(this.themeMode === 'light' ? '#333' : '#FFF') .margin({ right: 10 }) .width(40) .height(32) Button('A+') .onClick(() => { if (this.fontSize < 30) { this.fontSize += 2; // 修改状态 -> 自动触发持久化保存 } else { promptAction.showToast({ message: '已经是最大字体了' }) } }) .backgroundColor(this.themeMode === 'light' ? '#F0F0F0' : '#444') .fontColor(this.themeMode === 'light' ? '#333' : '#FFF') .width(40) .height(32) } } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .padding(16) .backgroundColor(this.themeMode === 'light' ? '#FFFFFF' : '#2C2C2C') .borderRadius(16) .shadow({ radius: 8, color: this.themeMode === 'light' ? '#10000000' : '#00000000', offsetY: 2 }) // ----------------------------------------------------- // 选项 2：主题模式切换 // ----------------------------------------------------- Row() { Column() { Text('夜间模式') .fontSize(this.fontSize) .fontColor(this.themeMode === 'light' ? '#333' : '#FFF') Text(this.themeMode === 'light' ? '当前：日间' : '当前：深色') .fontSize(12) .fontColor('#888') .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) // 开关组件 Toggle({ type: ToggleType.Switch, isOn: this.themeMode === 'dark' }) .selectedColor('#0A59F7') .onChange((isOn: boolean) => { // 【关键点】修改状态 // 这一步会瞬间触发： // 1. 本页面 UI 刷新（背景变黑/白） // 2. AppStorage 全局变量更新 // 3. 磁盘文件写入 this.themeMode = isOn ? 'dark' : 'light'; promptAction.showToast({ message: `已切换至${isOn ? '深色' : '日间'}模式，设置已自动保存`, duration: 2000 }); }) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .padding(16) .backgroundColor(this.themeMode === 'light' ? '#FFFFFF' : '#2C2C2C') .borderRadius(16) .shadow({ radius: 8, color: this.themeMode === 'light' ? '#10000000' : '#00000000', offsetY: 2 }) // --- 底部提示 --- Text('提示：该设置已持久化存储。您可以尝试彻底关闭应用（杀后台），再次打开时，上述设置依然生效。') .fontSize(12) .fontColor('#999') .margin({ top: 20 }) .padding({ left: 10, right: 10 }) .textAlign(TextAlign.Center) .lineHeight(18) } .padding(16) } .width('100%') .height('100%') // 全局背景色 .backgroundColor(this.themeMode === 'light' ? '#F1F3F5' : '#121212') // 添加过渡动画，让主题切换更丝滑 .animation({ duration: 300, curve: Curve.EaseInOut }) } }总结掌握了 AppStorage 和 PersistentStorage，你就掌握了鸿蒙应用数据流动的“任督二脉”。AppStorage 打通了页面间的隔阂，让数据在内存中自由穿梭；PersistentStorage 则打通了内存与磁盘的界限，让关键数据得以长久保存。在实际开发中，建议将所有的 Keys 定义为一个常量文件，避免魔术字符串满天飞。同时，虽然全局状态很好用，但也要克制，不要把所有数据都往里塞，只存放那些真正需要全局共享和持久化的“配置级”或“用户级”数据，保持应用内存的纯净与高效。
鸿蒙 HarmonyOS 6 | 逻辑核心 (01)：状态管理进阶 @ObservedV2 与 @Trace 深度监听复杂对象
前言在鸿蒙应用的开发过程中，状态管理一直是我们绕不开的话题。如果你是从 API 9 或 API 10 一路走来的老兵，一定经历过被 @Observed 和 @ObjectLink 支配的恐惧。那时候，我们想要监听一个嵌套在对象深处的属性变化，简直就是一场噩梦。假设你有一个 User 对象，里面包含一个 Address 对象，当你试图修改 user.address.city 时，你会发现界面纹丝不动。为了解决这个问题，我们被迫把 Address 拆分成一个独立的子组件，或者暴力地重新赋值整个 Address 对象来触发更新。这种为了技术限制而通过增加组件层级来妥协的做法，不仅让代码变得臃肿，更带来了不必要的性能开销。在 HarmonyOS 6 (API 20) 中，ArkUI 团队终于为我们带来了状态管理的 V2 版本，其中 @ObservedV2 和 @Trace 的出现，彻底粉碎了嵌套对象监听的痛点，让我们终于可以像写原生 JS 一样自然地操作数据了。一、 告别 V1 时代的“洋葱式”更新在深入 V2 之前，我们有必要回顾一下 V1 版本状态管理的局限性，这样你才能深刻体会到新特性的甜头。在 V1 中，状态管理的粒度通常停留在 对象引用 级别。这意味着，框架只关心这个对象是不是原来那个对象，或者这个对象的一级属性有没有变。一旦数据结构变得立体，比如数组里套对象，对象里又套对象，框架的感知能力就会断崖式下跌。为了让 UI 响应深层数据的变化，我们过去不得不构建一种 洋葱式 的组件结构。父组件持有 User，子组件持有 Address，孙子组件持有 Street。每一层都必须严格使用 @ObjectLink 进行传递。这导致了一个后果：哪怕是一个简单的表单页，可能都需要拆分成七八个细碎的自定义组件。这不仅增加了代码的复杂度，还让组件之间的通信变得异常繁琐。而如果我们偷懒不拆组件，就只能通过 this.user.address = new Address(...) 这种“换血”的方式来强制刷新，这无疑是在用大炮打蚊子，性能损耗极大。二、 @ObservedV2 与 @Trace 的精准打击HarmonyOS 6 引入的 @ObservedV2 和 @Trace，采用了全新的代理（Proxy）机制，将监听的粒度精确到了 属性 级别。这就像是给每一个需要关注的数据字段都安装了一个微型的传感器，无论它被嵌套得有多深，只要数值发生变化，传感器就会立即向 UI 发送更新信号。使用这套新机制非常直观。首先，我们需要用 @ObservedV2 类装饰器来标记一个类，告诉框架：这个类产生的实例是需要被深度观察的。接着，对于类中那些会影响 UI 显示的核心属性，我们给它们加上 @Trace 装饰器。注意，这里有一个巨大的思维转变。我们不再需要把所有属性都变成状态，只有那些真正和界面绑定、变化时需要触发重绘的属性，才需要加 @Trace。这种按需监听的设计，从根源上减少了不必要的渲染消耗。我们可以看看下面这段定义代码，它展示了如何构建一个可深度监听的数据模型.// 定义一个深层嵌套的设置类 @ObservedV2 class Settings { @Trace theme: string = 'Light'; @Trace fontSize: number = 14; constructor(theme: string, fontSize: number) { this.theme = theme; this.fontSize = fontSize; } } // 定义用户类，嵌套了 Settings 类 @ObservedV2 class User { @Trace name: string; @Trace age: number; // 嵌套的复杂对象，只要 Settings 类被正确装饰，这里无需特殊处理 @Trace settings: Settings; constructor(name: string, age: number, settings: Settings) { this.name = name; this.age = age; this.settings = settings; } }在上面的代码中，不管是 User 还是嵌套在内部的 Settings，都被标记为了 V2 的观察对象。当你在组件中直接执行 this.user.settings.theme = 'Dark' 时，ArkUI 能够精准地捕获到这个深层属性的变化，并只更新依赖了 theme 属性的那一部分 UI，而不会导致整个 User 卡片甚至整个页面的重绘。三、 数组与集合的深度监听除了对象嵌套，数组操作也是 V1 版本的一大痛点。以前我们必须使用 ArkUI 提供的特定数组方法，或者把数组项封装成 @ObjectLink 组件才能监听到增删改查。而在 V2 中，@Trace 同样适用于数组属性。当你将一个数组标记为 @Trace 后，框架会自动代理这个数组的 push、pop、splice 等变更方法。更令人兴奋的是，如果数组中的元素本身也是 @ObservedV2 装饰过的对象实例，那么修改数组中某一个元素的属性（例如 this.users[0].name = 'New Name'），也能直接触发 UI 更新。这种 数组结构变化 与 元素内部变化 的双重监听能力，让列表类数据的处理变得异常丝滑。我们不再需要为了更新列表里的一行文字而被迫刷新整个列表数据。四、 最佳实践与注意事项虽然 V2 极其强大，但在使用时也有一些规则需要遵守。首先，@ObservedV2 只能装饰 class，不能用于接口或简单对象。其次，V2 的状态变量通常配合 @Local（组件内部状态）或 @Param（组件参数）在 UI 组件中使用，这替代了 V1 中的 @State 和 @Prop。在使用中我们要养成 精细化控制 的习惯。不要习惯性地给类里的所有属性都加上 @Trace，只给那些 UI 真正用到的属性加。比如一个用于内部逻辑计算的临时 ID 或者缓存数据，就不应该加 @Trace，这样可以减轻框架的代理负担。此外，V2 的状态追踪是基于实例的，如果你直接替换了整个对象实例，那么新实例必须也是由 @ObservedV2 装饰的类创建的，否则监听链条就会断裂。下面是一个完整的实战案例，模拟了一个“智能家居控制面板”的场景。在这个场景中，我们有一个家庭对象，里面包含多个房间，每个房间又有独立的设备。通过 V2 的深度监听，我们可以直接在父组件修改最深层的设备状态，观察 UI 是如何丝滑响应的。import { promptAction } from '@kit.ArkUI'; // ========================================================= // 1. 数据模型定义 // ========================================================= @ObservedV2 class SmartDevice { @Trace name: string; @Trace isOn: boolean; @Trace powerConsumption: number; constructor(name: string, isOn: boolean, power: number) { this.name = name; this.isOn = isOn; this.powerConsumption = power; } } @ObservedV2 class Room { @Trace name: string; @Trace devices: SmartDevice[] = []; constructor(name: string, devices: SmartDevice[]) { this.name = name; this.devices = devices; } } @ObservedV2 class SmartHome { @Trace familyName: string; @Trace rooms: Room[] = []; constructor(familyName: string) { this.familyName = familyName; } } // ========================================================= // 2. 主界面组件 // ========================================================= @Entry @ComponentV2 struct DeepObservationPage { @Local myHome: SmartHome = new SmartHome('鸿蒙未来家'); aboutToAppear(): void { const livingRoom = new Room('客厅', [ new SmartDevice('主灯', true, 50), new SmartDevice('空调', false, 1200), new SmartDevice('电视', false, 200) ]); const bedroom = new Room('主卧', [ new SmartDevice('床头灯', false, 10), new SmartDevice('空气净化器', true, 45) ]); this.myHome.rooms.push(livingRoom, bedroom); } build() { Column() { // 1. 顶部标题 Text(`${this.myHome.familyName} 控制中心`) .fontSize(24) .fontWeight(FontWeight.Bold) .margin({ top: 40, bottom: 20 }) // 2. 设备列表区域 List({ space: 16 }) { ForEach(this.myHome.rooms, (room: Room) => { ListItem() { Column() { Text(room.name) .fontSize(18) .fontWeight(FontWeight.Bold) .width('100%') .padding({ left: 12, bottom: 12, top: 4 }) .border({ width: { bottom: 1 }, color: '#F0F0F0' }) ForEach(room.devices, (device: SmartDevice) => { Row() { Column() { Text(device.name) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor('#333') Text(`能耗: ${device.powerConsumption}W`) .fontSize(12) .fontColor('#999') .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) // 开关控制 Toggle({ type: ToggleType.Switch, isOn: device.isOn }) .onChange((value: boolean) => { // V2 深度监听核心：直接修改属性，UI 自动刷新 device.isOn = value; }) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .padding(12) .backgroundColor(device.isOn ? '#F0F9FF' : '#FFFFFF') .borderRadius(8) .animation({ duration: 300 }) }) } .padding(12) .backgroundColor(Color.White) .borderRadius(16) .shadow({ radius: 8, color: '#0D000000', offsetY: 2 }) } }) } .layoutWeight(1) .padding({ left: 16, right: 16 }) .scrollBar(BarState.Off) // 3. 底部按钮 Button('一键关闭所有设备') .width('90%') .height(48) .backgroundColor('#FF4040') .shadow({ radius: 10, color: '#4DFF4040', offsetY: 5 }) .margin({ bottom: 20, top: 10 }) .onClick(() => { let turnOffCount = 0; this.myHome.rooms.forEach(room => { room.devices.forEach(device => { if (device.isOn) { device.isOn = false; turnOffCount++; } }); }); promptAction.showToast({ message: turnOffCount > 0 ? `已关闭 ${turnOffCount} 个设备` : '所有设备已关闭' }); }) } .width('100%') .height('100%') .backgroundColor('#F1F3F5') } }五、 总结从 V1 到 V2，鸿蒙的状态管理机制完成了一次从 粗放 到 精准 的进化。@ObservedV2 和 @Trace 的组合，让我们彻底摆脱了为了做数据监听而扭曲组件结构的尴尬境地。现在，我们可以按照最符合业务逻辑的方式去设计数据模型，无论嵌套多少层，无论数据结构多么复杂，ArkUI 都能像手术刀一样精准地定位到变化点并更新视图。这对于构建大型、复杂交互的鸿蒙应用来说，是必须要掌握的核心能力。
Hello，大家好，我是 V 哥。AI 智能体在2026年V 哥相信一定翻天覆地的变化，一大波企业和开发者纷纷涌入这个赛道，什么超级个体、一人公司、为企业节省几百万人力成本等等话题在网络上持续发酵，作为程序员的我们，如果还在观望，那等来就一定是被市场淘汰。我经常跟同学们说，程序员最大的优势是啥？就是不断持续学习的超强能力！干掉程序员的只会是程序员自己，未来的程序员不只是程序员，而是主导技术变现的超级魔术师。今天的内容，V 哥带大家一起来玩一玩，在鸿蒙6系统中，如何完成精准日程管理的完整案例开发。一、项目概述功能特性✅ 日程增删改查（支持标题、备注、时间、重复）✅ 后台精准提醒（应用关闭/重启后依然准时）✅ 智能提前提醒（5/10/30/60分钟）✅ 重复提醒（每天/每周/每月）✅ 自定义铃声+震动✅ 点击通知跳转详情技术方案┌─────────────────────────────────────────────────────────┐ │ 精准日程提醒架构 │ ├─────────────────────────────────────────────────────────┤ │ UI层 │ ArkUI 声明式UI │ ├─────────────────────────────────────────────────────────┤ │ 数据层 │ @ohos.data.relationalStore (关系型DB) │ ├─────────────────────────────────────────────────────────┤ │ 提醒层 │ @ohos.reminderAgentManager (代理提醒) │ ├─────────────────────────────────────────────────────────┤ │ 通知层 │ @ohos.notificationManager │ └─────────────────────────────────────────────────────────┘二、项目创建与配置步骤1：创建项目DevEco Studio → File → New → Create Project → 选择 "Empty Ability" → Project name: ScheduleManager → Bundle name: com.example.schedulemanager → Compile SDK: 5.0.0(API 12) 或更高 → Model: Stage步骤2：配置 module.json5{ "module": { "name": "entry", "type": "entry", "description": "$string:module_desc", "mainElement": "EntryAbility", "deviceTypes": ["phone", "tablet"], "deliveryWithInstall": true, "installationFree": false, "pages": "$profile:main_pages", "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "description": "$string:EntryAbility_desc", "icon": "$media:icon", "label": "$string:EntryAbility_label", "startWindowIcon": "$media:startIcon", "startWindowBackground": "$color:start_window_background", "exported": true, "skills": [ { "entities": ["entity.system.home"], "actions": ["action.system.home"] } ] } ], "requestPermissions": [ { "name": "ohos.permission.PUBLISH_AGENT_REMINDER", "reason": "$string:reminder_reason", "usedScene": { "abilities": ["EntryAbility"], "when": "always" } }, { "name": "ohos.permission.NOTIFICATION_CONTROLLER", "reason": "$string:notification_reason", "usedScene": { "abilities": ["EntryAbility"], "when": "always" } } ] } }步骤3：配置 main_pages.json{ "src": [ "pages/Index", "pages/AddSchedulePage", "pages/ScheduleDetailPage" ] }步骤4：配置字符串资源 (string.json){ "string": [ { "name": "module_desc", "value": "日程管理模块" }, { "name": "EntryAbility_desc", "value": "日程管理应用" }, { "name": "EntryAbility_label", "value": "精准日程" }, { "name": "reminder_reason", "value": "用于设置日程提醒" }, { "name": "notification_reason", "value": "用于发送日程通知" } ] }三、核心代码实现1. 日程数据模型 (model/ScheduleModel.ets)// entry/src/main/ets/model/ScheduleModel.ets /** * 重复类型枚举 */ export enum RepeatType { NONE = 0, // 不重复 DAILY = 1, // 每天 WEEKLY = 2, // 每周 MONTHLY = 3 // 每月 } /** * 提前提醒时间枚举（分钟） */ export enum AdvanceRemind { NONE = 0, FIVE_MIN = 5, TEN_MIN = 10, THIRTY_MIN = 30, ONE_HOUR = 60 } /** * 日程实体类 */ export class Schedule { id: number = 0; // 主键ID title: string = ''; // 标题 note: string = ''; // 备注 remindTime: number = 0; // 提醒时间戳(毫秒) advanceMinutes: number = 0; // 提前提醒分钟数 repeatType: RepeatType = RepeatType.NONE; // 重复类型 reminderId: number = -1; // 系统提醒ID isEnabled: boolean = true; // 是否启用 createTime: number = 0; // 创建时间 updateTime: number = 0; // 更新时间 constructor(init?: Partial<Schedule>) { if (init) { Object.assign(this, init); } } } /** * 重复类型显示文本 */ export function getRepeatTypeText(type: RepeatType): string { const texts: Record<RepeatType, string> = { [RepeatType.NONE]: '不重复', [RepeatType.DAILY]: '每天', [RepeatType.WEEKLY]: '每周', [RepeatType.MONTHLY]: '每月' }; return texts[type] || '不重复'; } /** * 提前提醒显示文本 */ export function getAdvanceText(minutes: number): string { if (minutes === 0) return '准时提醒'; if (minutes < 60) return `提前${minutes}分钟`; return `提前${minutes / 60}小时`; }2. 数据库操作类 (utils/ScheduleDB.ets)// entry/src/main/ets/utils/ScheduleDB.ets import { relationalStore, ValuesBucket } from '@kit.ArkData'; import { Schedule, RepeatType } from '../model/ScheduleModel'; import { common } from '@kit.AbilityKit'; const DB_NAME = 'ScheduleManager.db'; const TABLE_NAME = 'schedules'; const DB_VERSION = 1; // 建表SQL const CREATE_TABLE_SQL = ` CREATE TABLE IF NOT EXISTS ${TABLE_NAME} ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, note TEXT, remind_time INTEGER NOT NULL, advance_minutes INTEGER DEFAULT 0, repeat_type INTEGER DEFAULT 0, reminder_id INTEGER DEFAULT -1, is_enabled INTEGER DEFAULT 1, create_time INTEGER, update_time INTEGER ) `; export class ScheduleDB { private static instance: ScheduleDB; private rdbStore: relationalStore.RdbStore | null = null; private context: common.UIAbilityContext | null = null; private constructor() {} /** * 获取单例实例 */ static getInstance(): ScheduleDB { if (!ScheduleDB.instance) { ScheduleDB.instance = new ScheduleDB(); } return ScheduleDB.instance; } /** * 初始化数据库 */ async init(context: common.UIAbilityContext): Promise<void> { this.context = context; const storeConfig: relationalStore.StoreConfig = { name: DB_NAME, securityLevel: relationalStore.SecurityLevel.S1 }; try { this.rdbStore = await relationalStore.getRdbStore(context, storeConfig); await this.rdbStore.executeSql(CREATE_TABLE_SQL); console.info('[ScheduleDB] 数据库初始化成功'); } catch (err) { console.error('[ScheduleDB] 数据库初始化失败:', JSON.stringify(err)); } } /** * 插入日程 */ async insert(schedule: Schedule): Promise<number> { if (!this.rdbStore) { throw new Error('数据库未初始化'); } const now = Date.now(); const values: ValuesBucket = { 'title': schedule.title, 'note': schedule.note, 'remind_time': schedule.remindTime, 'advance_minutes': schedule.advanceMinutes, 'repeat_type': schedule.repeatType, 'reminder_id': schedule.reminderId, 'is_enabled': schedule.isEnabled ? 1 : 0, 'create_time': now, 'update_time': now }; try { const rowId = await this.rdbStore.insert(TABLE_NAME, values); console.info('[ScheduleDB] 插入成功, rowId:', rowId); return rowId; } catch (err) { console.error('[ScheduleDB] 插入失败:', JSON.stringify(err)); throw err; } } /** * 更新日程 */ async update(schedule: Schedule): Promise<number> { if (!this.rdbStore) { throw new Error('数据库未初始化'); } const values: ValuesBucket = { 'title': schedule.title, 'note': schedule.note, 'remind_time': schedule.remindTime, 'advance_minutes': schedule.advanceMinutes, 'repeat_type': schedule.repeatType, 'reminder_id': schedule.reminderId, 'is_enabled': schedule.isEnabled ? 1 : 0, 'update_time': Date.now() }; const predicates = new relationalStore.RdbPredicates(TABLE_NAME); predicates.equalTo('id', schedule.id); try { const rows = await this.rdbStore.update(values, predicates); console.info('[ScheduleDB] 更新成功, 影响行数:', rows); return rows; } catch (err) { console.error('[ScheduleDB] 更新失败:', JSON.stringify(err)); throw err; } } /** * 删除日程 */ async delete(id: number): Promise<number> { if (!this.rdbStore) { throw new Error('数据库未初始化'); } const predicates = new relationalStore.RdbPredicates(TABLE_NAME); predicates.equalTo('id', id); try { const rows = await this.rdbStore.delete(predicates); console.info('[ScheduleDB] 删除成功, 影响行数:', rows); return rows; } catch (err) { console.error('[ScheduleDB] 删除失败:', JSON.stringify(err)); throw err; } } /** * 根据ID查询 */ async getById(id: number): Promise<Schedule | null> { if (!this.rdbStore) { throw new Error('数据库未初始化'); } const predicates = new relationalStore.RdbPredicates(TABLE_NAME); predicates.equalTo('id', id); try { const resultSet = await this.rdbStore.query(predicates); if (resultSet.goToFirstRow()) { const schedule = this.parseResultSet(resultSet); resultSet.close(); return schedule; } resultSet.close(); return null; } catch (err) { console.error('[ScheduleDB] 查询失败:', JSON.stringify(err)); throw err; } } /** * 查询所有日程（按时间排序） */ async getAll(): Promise<Schedule[]> { if (!this.rdbStore) { throw new Error('数据库未初始化'); } const predicates = new relationalStore.RdbPredicates(TABLE_NAME); predicates.orderByAsc('remind_time'); try { const resultSet = await this.rdbStore.query(predicates); const schedules: Schedule[] = []; while (resultSet.goToNextRow()) { schedules.push(this.parseResultSet(resultSet)); } resultSet.close(); console.info('[ScheduleDB] 查询全部, 数量:', schedules.length); return schedules; } catch (err) { console.error('[ScheduleDB] 查询全部失败:', JSON.stringify(err)); throw err; } } /** * 查询未来的日程 */ async getFutureSchedules(): Promise<Schedule[]> { if (!this.rdbStore) { throw new Error('数据库未初始化'); } const predicates = new relationalStore.RdbPredicates(TABLE_NAME); predicates.greaterThan('remind_time', Date.now()); predicates.equalTo('is_enabled', 1); predicates.orderByAsc('remind_time'); try { const resultSet = await this.rdbStore.query(predicates); const schedules: Schedule[] = []; while (resultSet.goToNextRow()) { schedules.push(this.parseResultSet(resultSet)); } resultSet.close(); return schedules; } catch (err) { console.error('[ScheduleDB] 查询未来日程失败:', JSON.stringify(err)); throw err; } } /** * 解析结果集为Schedule对象 */ private parseResultSet(resultSet: relationalStore.ResultSet): Schedule { return new Schedule({ id: resultSet.getLong(resultSet.getColumnIndex('id')), title: resultSet.getString(resultSet.getColumnIndex('title')), note: resultSet.getString(resultSet.getColumnIndex('note')), remindTime: resultSet.getLong(resultSet.getColumnIndex('remind_time')), advanceMinutes: resultSet.getLong(resultSet.getColumnIndex('advance_minutes')), repeatType: resultSet.getLong(resultSet.getColumnIndex('repeat_type')) as RepeatType, reminderId: resultSet.getLong(resultSet.getColumnIndex('reminder_id')), isEnabled: resultSet.getLong(resultSet.getColumnIndex('is_enabled')) === 1, createTime: resultSet.getLong(resultSet.getColumnIndex('create_time')), updateTime: resultSet.getLong(resultSet.getColumnIndex('update_time')) }); } }3. 提醒管理器 (utils/ReminderHelper.ets)// entry/src/main/ets/utils/ReminderHelper.ets import { reminderAgentManager } from '@kit.BackgroundTasksKit'; import { notificationManager } from '@kit.NotificationKit'; import { Schedule, RepeatType } from '../model/ScheduleModel'; import { BusinessError } from '@kit.BasicServicesKit'; export class ReminderHelper { private static instance: ReminderHelper; private constructor() {} static getInstance(): ReminderHelper { if (!ReminderHelper.instance) { ReminderHelper.instance = new ReminderHelper(); } return ReminderHelper.instance; } /** * 请求通知权限 */ async requestNotificationPermission(): Promise<boolean> { try { const isEnabled = await notificationManager.isNotificationEnabled(); if (!isEnabled) { await notificationManager.requestEnableNotification(); } return true; } catch (err) { const error = err as BusinessError; console.error('[ReminderHelper] 请求通知权限失败:', error.code, error.message); return false; } } /** * 设置日程提醒 */ async setReminder(schedule: Schedule): Promise<number> { // 计算实际提醒时间（考虑提前量） const actualRemindTime = schedule.remindTime - schedule.advanceMinutes * 60 * 1000; if (actualRemindTime <= Date.now()) { console.warn('[ReminderHelper] 提醒时间已过'); return -1; } // 将时间戳转换为日期对象 const remindDate = new Date(actualRemindTime); // 构建提醒请求 const reminderRequest: reminderAgentManager.ReminderRequestCalendar = { reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR, dateTime: { year: remindDate.getFullYear(), month: remindDate.getMonth() + 1, // 月份从1开始 day: remindDate.getDate(), hour: remindDate.getHours(), minute: remindDate.getMinutes(), second: remindDate.getSeconds() }, repeatMonths: this.getRepeatMonths(schedule.repeatType), repeatDays: this.getRepeatDays(schedule.repeatType, remindDate), title: '日程提醒', content: schedule.title, expiredContent: `日程已过期: ${schedule.title}`, snoozeContent: `稍后提醒: ${schedule.title}`, notificationId: schedule.id, slotType: notificationManager.SlotType.SOCIAL_COMMUNICATION, tapDismissed: true, autoDeletedTime: 300000, // 5分钟后自动删除 snoozeTimes: 3, // 允许延后3次 timeInterval: 5 * 60, // 延后间隔5分钟 actionButton: [ { title: '查看详情', type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CUSTOM }, { title: '稍后提醒', type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_SNOOZE } ], wantAgent: { pkgName: 'com.example.schedulemanager', abilityName: 'EntryAbility' }, maxScreenWantAgent: { pkgName: 'com.example.schedulemanager', abilityName: 'EntryAbility' }, ringDuration: 30 // 铃声持续30秒 }; try { const reminderId = await reminderAgentManager.publishReminder(reminderRequest); console.info('[ReminderHelper] 提醒设置成功, reminderId:', reminderId); return reminderId; } catch (err) { const error = err as BusinessError; console.error('[ReminderHelper] 设置提醒失败:', error.code, error.message); throw err; } } /** * 取消提醒 */ async cancelReminder(reminderId: number): Promise<void> { if (reminderId < 0) { return; } try { await reminderAgentManager.cancelReminder(reminderId); console.info('[ReminderHelper] 取消提醒成功, reminderId:', reminderId); } catch (err) { const error = err as BusinessError; console.error('[ReminderHelper] 取消提醒失败:', error.code, error.message); } } /** * 取消所有提醒 */ async cancelAllReminders(): Promise<void> { try { await reminderAgentManager.cancelAllReminders(); console.info('[ReminderHelper] 取消所有提醒成功'); } catch (err) { const error = err as BusinessError; console.error('[ReminderHelper] 取消所有提醒失败:', error.code, error.message); } } /** * 获取所有有效提醒 */ async getAllValidReminders(): Promise<reminderAgentManager.ReminderRequest[]> { try { const reminders = await reminderAgentManager.getValidReminders(); console.info('[ReminderHelper] 有效提醒数量:', reminders.length); return reminders; } catch (err) { const error = err as BusinessError; console.error('[ReminderHelper] 获取有效提醒失败:', error.code, error.message); return []; } } /** * 根据重复类型获取重复月份 */ private getRepeatMonths(repeatType: RepeatType): number[] { if (repeatType === RepeatType.MONTHLY || repeatType === RepeatType.DAILY) { return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; } return []; } /** * 根据重复类型获取重复日期 */ private getRepeatDays(repeatType: RepeatType, date: Date): number[] { switch (repeatType) { case RepeatType.DAILY: // 每天重复：返回1-31所有日期 return Array.from({ length: 31 }, (_, i) => i + 1); case RepeatType.WEEKLY: // 每周重复：返回同一星期几对应的所有日期（简化处理） return this.getWeeklyDays(date); case RepeatType.MONTHLY: // 每月重复：返回当前日期 return [date.getDate()]; default: return []; } } /** * 获取每周重复的日期（计算每月中相同星期几的日期） */ private getWeeklyDays(date: Date): number[] { const dayOfWeek = date.getDay(); const days: number[] = []; // 计算当月中所有相同星期几的日期 const year = date.getFullYear(); const month = date.getMonth(); const lastDay = new Date(year, month + 1, 0).getDate(); for (let d = 1; d <= lastDay; d++) { const tempDate = new Date(year, month, d); if (tempDate.getDay() === dayOfWeek) { days.push(d); } } return days; } }4. 主页面 - 日程列表 (pages/Index.ets)// entry/src/main/ets/pages/Index.ets import { router } from '@kit.ArkUI'; import { promptAction } from '@kit.ArkUI'; import { Schedule, RepeatType, getRepeatTypeText } from '../model/ScheduleModel'; import { ScheduleDB } from '../utils/ScheduleDB'; import { ReminderHelper } from '../utils/ReminderHelper'; import { common } from '@kit.AbilityKit'; @Entry @Component struct Index { @State scheduleList: Schedule[] = []; @State isLoading: boolean = true; @State isEmpty: boolean = false; private db = ScheduleDB.getInstance(); private reminderHelper = ReminderHelper.getInstance(); async aboutToAppear(): Promise<void> { // 请求通知权限 await this.reminderHelper.requestNotificationPermission(); // 加载日程列表 await this.loadSchedules(); } async onPageShow(): Promise<void> { // 每次页面显示时刷新列表 await this.loadSchedules(); } /** * 加载日程列表 */ async loadSchedules(): Promise<void> { this.isLoading = true; try { this.scheduleList = await this.db.getAll(); this.isEmpty = this.scheduleList.length === 0; } catch (err) { console.error('加载日程失败:', JSON.stringify(err)); promptAction.showToast({ message: '加载失败' }); } finally { this.isLoading = false; } } /** * 删除日程 */ async deleteSchedule(schedule: Schedule): Promise<void> { try { // 取消提醒 await this.reminderHelper.cancelReminder(schedule.reminderId); // 删除数据库记录 await this.db.delete(schedule.id); // 刷新列表 await this.loadSchedules(); promptAction.showToast({ message: '删除成功' }); } catch (err) { console.error('删除日程失败:', JSON.stringify(err)); promptAction.showToast({ message: '删除失败' }); } } /** * 切换日程启用状态 */ async toggleSchedule(schedule: Schedule): Promise<void> { try { schedule.isEnabled = !schedule.isEnabled; if (schedule.isEnabled) { // 重新设置提醒 const reminderId = await this.reminderHelper.setReminder(schedule); schedule.reminderId = reminderId; } else { // 取消提醒 await this.reminderHelper.cancelReminder(schedule.reminderId); schedule.reminderId = -1; } await this.db.update(schedule); await this.loadSchedules(); } catch (err) { console.error('切换状态失败:', JSON.stringify(err)); } } /** * 格式化时间显示 */ formatTime(timestamp: number): string { const date = new Date(timestamp); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hour = String(date.getHours()).padStart(2, '0'); const minute = String(date.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day} ${hour}:${minute}`; } /** * 判断是否已过期 */ isExpired(schedule: Schedule): boolean { return schedule.remindTime < Date.now() && schedule.repeatType === RepeatType.NONE; } build() { Column() { // 顶部标题栏 Row() { Text('精准日程管理') .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor('#333333') Blank() Button() { Image($r('app.media.ic_add')) .width(24) .height(24) .fillColor(Color.White) } .width(44) .height(44) .backgroundColor('#007DFF') .borderRadius(22) .onClick(() => { router.pushUrl({ url: 'pages/AddSchedulePage' }); }) } .width('100%') .height(60) .padding({ left: 16, right: 16 }) // 日程列表 if (this.isLoading) { // 加载中 Column() { LoadingProgress() .width(50) .height(50) Text('加载中...') .fontSize(14) .fontColor('#999999') .margin({ top: 10 }) } .width('100%') .layoutWeight(1) .justifyContent(FlexAlign.Center) } else if (this.isEmpty) { // 空状态 Column() { Image($r('app.media.ic_empty')) .width(120) .height(120) .opacity(0.5) Text('暂无日程') .fontSize(16) .fontColor('#999999') .margin({ top: 16 }) Text('点击右上角 + 添加日程') .fontSize(14) .fontColor('#CCCCCC') .margin({ top: 8 }) } .width('100%') .layoutWeight(1) .justifyContent(FlexAlign.Center) } else { // 日程列表 List({ space: 12 }) { ForEach(this.scheduleList, (schedule: Schedule) => { ListItem() { this.ScheduleCard(schedule) } .swipeAction({ end: this.DeleteButton(schedule) }) }, (schedule: Schedule) => schedule.id.toString()) } .width('100%') .layoutWeight(1) .padding({ left: 16, right: 16, top: 12, bottom: 12 }) .divider({ strokeWidth: 0 }) } } .width('100%') .height('100%') .backgroundColor('#F5F5F5') } /** * 日程卡片组件 */ @Builder ScheduleCard(schedule: Schedule) { Row() { // 左侧状态指示条 Column() .width(4) .height('100%') .backgroundColor(this.isExpired(schedule) ? '#CCCCCC' : (schedule.isEnabled ? '#007DFF' : '#999999')) .borderRadius(2) // 中间内容 Column() { // 标题 Text(schedule.title) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(this.isExpired(schedule) ? '#999999' : '#333333') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) // 时间 Row() { Image($r('app.media.ic_time')) .width(14) .height(14) .fillColor('#666666') Text(this.formatTime(schedule.remindTime)) .fontSize(13) .fontColor('#666666') .margin({ left: 4 }) } .margin({ top: 8 }) // 标签行 Row() { // 重复类型标签 if (schedule.repeatType !== RepeatType.NONE) { Text(getRepeatTypeText(schedule.repeatType)) .fontSize(11) .fontColor('#007DFF') .backgroundColor('#E6F2FF') .padding({ left: 6, right: 6, top: 2, bottom: 2 }) .borderRadius(4) } // 提前提醒标签 if (schedule.advanceMinutes > 0) { Text(`提前${schedule.advanceMinutes}分钟`) .fontSize(11) .fontColor('#FF9500') .backgroundColor('#FFF3E0') .padding({ left: 6, right: 6, top: 2, bottom: 2 }) .borderRadius(4) .margin({ left: 6 }) } // 过期标签 if (this.isExpired(schedule)) { Text('已过期') .fontSize(11) .fontColor('#FF3B30') .backgroundColor('#FFE5E5') .padding({ left: 6, right: 6, top: 2, bottom: 2 }) .borderRadius(4) .margin({ left: 6 }) } } .margin({ top: 8 }) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .margin({ left: 12 }) // 右侧开关 Toggle({ type: ToggleType.Switch, isOn: schedule.isEnabled }) .selectedColor('#007DFF') .switchPointColor(Color.White) .onChange(() => { this.toggleSchedule(schedule); }) } .width('100%') .padding(16) .backgroundColor(Color.White) .borderRadius(12) .shadow({ radius: 4, color: 'rgba(0,0,0,0.08)', offsetX: 0, offsetY: 2 }) .onClick(() => { router.pushUrl({ url: 'pages/ScheduleDetailPage', params: { scheduleId: schedule.id } }); }) } /** * 删除按钮（滑动操作） */ @Builder DeleteButton(schedule: Schedule) { Button() { Image($r('app.media.ic_delete')) .width(24) .height(24) .fillColor(Color.White) } .width(60) .height('100%') .backgroundColor('#FF3B30') .onClick(() => { promptAction.showDialog({ title: '确认删除', message: `确定要删除日程"${schedule.title}"吗？`, buttons: [ { text: '取消', color: '#666666' }, { text: '删除', color: '#FF3B30' } ] }).then((result) => { if (result.index === 1) { this.deleteSchedule(schedule); } }); }) } }5. 添加日程页面 (pages/AddSchedulePage.ets)// entry/src/main/ets/pages/AddSchedulePage.ets import { router } from '@kit.ArkUI'; import { promptAction } from '@kit.ArkUI'; import { Schedule, RepeatType, AdvanceRemind } from '../model/ScheduleModel'; import { ScheduleDB } from '../utils/ScheduleDB'; import { ReminderHelper } from '../utils/ReminderHelper'; interface RepeatOption { value: RepeatType; label: string; } interface AdvanceOption { value: number; label: string; } @Entry @Component struct AddSchedulePage { @State title: string = ''; @State note: string = ''; @State selectedDate: Date = new Date(); @State selectedTime: Date = new Date(); @State repeatType: RepeatType = RepeatType.NONE; @State advanceMinutes: number = 0; @State isSaving: boolean = false; // 日期选择弹窗状态 @State showDatePicker: boolean = false; @State showTimePicker: boolean = false; private db = ScheduleDB.getInstance(); private reminderHelper = ReminderHelper.getInstance(); // 重复选项 private repeatOptions: RepeatOption[] = [ { value: RepeatType.NONE, label: '不重复' }, { value: RepeatType.DAILY, label: '每天' }, { value: RepeatType.WEEKLY, label: '每周' }, { value: RepeatType.MONTHLY, label: '每月' } ]; // 提前提醒选项 private advanceOptions: AdvanceOption[] = [ { value: 0, label: '准时提醒' }, { value: 5, label: '提前5分钟' }, { value: 10, label: '提前10分钟' }, { value: 30, label: '提前30分钟' }, { value: 60, label: '提前1小时' } ]; aboutToAppear(): void { // 默认时间设为下一个整点 const now = new Date(); now.setHours(now.getHours() + 1, 0, 0, 0); this.selectedDate = now; this.selectedTime = now; } /** * 保存日程 */ async saveSchedule(): Promise<void> { // 表单验证 if (!this.title.trim()) { promptAction.showToast({ message: '请输入日程标题' }); return; } // 合并日期和时间 const remindTime = new Date( this.selectedDate.getFullYear(), this.selectedDate.getMonth(), this.selectedDate.getDate(), this.selectedTime.getHours(), this.selectedTime.getMinutes(), 0 ).getTime(); // 验证时间 if (remindTime <= Date.now()) { promptAction.showToast({ message: '提醒时间必须晚于当前时间' }); return; } this.isSaving = true; try { // 创建日程对象 const schedule = new Schedule({ title: this.title.trim(), note: this.note.trim(), remindTime: remindTime, advanceMinutes: this.advanceMinutes, repeatType: this.repeatType, isEnabled: true }); // 设置系统提醒 const reminderId = await this.reminderHelper.setReminder(schedule); schedule.reminderId = reminderId; // 保存到数据库 const id = await this.db.insert(schedule); schedule.id = id; promptAction.showToast({ message: '日程添加成功' }); router.back(); } catch (err) { console.error('保存日程失败:', JSON.stringify(err)); promptAction.showToast({ message: '保存失败，请重试' }); } finally { this.isSaving = false; } } /** * 格式化日期显示 */ formatDate(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; const weekDay = weekDays[date.getDay()]; return `${year}年${month}月${day}日 ${weekDay}`; } /** * 格式化时间显示 */ formatTimeDisplay(date: Date): string { const hour = String(date.getHours()).padStart(2, '0'); const minute = String(date.getMinutes()).padStart(2, '0'); return `${hour}:${minute}`; } build() { Column() { // 顶部导航栏 Row() { Button() { Image($r('app.media.ic_back')) .width(24) .height(24) .fillColor('#333333') } .backgroundColor(Color.Transparent) .onClick(() => router.back()) Text('添加日程') .fontSize(18) .fontWeight(FontWeight.Medium) .fontColor('#333333') .layoutWeight(1) .textAlign(TextAlign.Center) Button('保存') .fontSize(16) .fontColor('#007DFF') .backgroundColor(Color.Transparent) .enabled(!this.isSaving) .onClick(() => this.saveSchedule()) } .width('100%') .height(56) .padding({ left: 8, right: 16 }) // 表单内容 Scroll() { Column() { // 标题输入 Column() { Text('日程标题') .fontSize(14) .fontColor('#999999') .margin({ bottom: 8 }) TextInput({ placeholder: '请输入日程标题', text: this.title }) .fontSize(16) .placeholderColor('#CCCCCC') .backgroundColor('#F5F5F5') .borderRadius(8) .padding(12) .height(48) .onChange((value) => { this.title = value; }) } .width('100%') .alignItems(HorizontalAlign.Start) .padding(16) Divider().color('#EEEEEE') // 备注输入 Column() { Text('备注') .fontSize(14) .fontColor('#999999') .margin({ bottom: 8 }) TextArea({ placeholder: '添加备注（可选）', text: this.note }) .fontSize(16) .placeholderColor('#CCCCCC') .backgroundColor('#F5F5F5') .borderRadius(8) .padding(12) .height(100) .onChange((value) => { this.note = value; }) } .width('100%') .alignItems(HorizontalAlign.Start) .padding(16) Divider().color('#EEEEEE') // 日期选择 Row() { Column() { Text('提醒日期') .fontSize(14) .fontColor('#999999') Text(this.formatDate(this.selectedDate)) .fontSize(16) .fontColor('#333333') .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) Blank() Image($r('app.media.ic_arrow_right')) .width(20) .height(20) .fillColor('#CCCCCC') } .width('100%') .padding(16) .onClick(() => { DatePickerDialog.show({ start: new Date(), end: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000 * 2), // 2年后 selected: this.selectedDate, onDateAccept: (value: Date) => { this.selectedDate = value; } }); }) Divider().color('#EEEEEE') // 时间选择 Row() { Column() { Text('提醒时间') .fontSize(14) .fontColor('#999999') Text(this.formatTimeDisplay(this.selectedTime)) .fontSize(16) .fontColor('#333333') .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) Blank() Image($r('app.media.ic_arrow_right')) .width(20) .height(20) .fillColor('#CCCCCC') } .width('100%') .padding(16) .onClick(() => { TimePickerDialog.show({ selected: this.selectedTime, useMilitaryTime: true, onAccept: (value: TimePickerResult) => { const newTime = new Date(); newTime.setHours(value.hour || 0, value.minute || 0, 0, 0); this.selectedTime = newTime; } }); }) Divider().color('#EEEEEE') // 提前提醒选择 Row() { Column() { Text('提前提醒') .fontSize(14) .fontColor('#999999') Text(this.advanceOptions.find(o => o.value === this.advanceMinutes)?.label || '准时提醒') .fontSize(16) .fontColor('#333333') .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) Blank() Image($r('app.media.ic_arrow_right')) .width(20) .height(20) .fillColor('#CCCCCC') } .width('100%') .padding(16) .onClick(() => { TextPickerDialog.show({ range: this.advanceOptions.map(o => o.label), selected: this.advanceOptions.findIndex(o => o.value === this.advanceMinutes), onAccept: (value: TextPickerResult) => { const index = typeof value.index === 'number' ? value.index : 0; this.advanceMinutes = this.advanceOptions[index].value; } }); }) Divider().color('#EEEEEE') // 重复选择 Row() { Column() { Text('重复') .fontSize(14) .fontColor('#999999') Text(this.repeatOptions.find(o => o.value === this.repeatType)?.label || '不重复') .fontSize(16) .fontColor('#333333') .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) Blank() Image($r('app.media.ic_arrow_right')) .width(20) .height(20) .fillColor('#CCCCCC') } .width('100%') .padding(16) .onClick(() => { TextPickerDialog.show({ range: this.repeatOptions.map(o => o.label), selected: this.repeatOptions.findIndex(o => o.value === this.repeatType), onAccept: (value: TextPickerResult) => { const index = typeof value.index === 'number' ? value.index : 0; this.repeatType = this.repeatOptions[index].value; } }); }) // 底部间距 Column().height(100) } } .layoutWeight(1) .scrollBar(BarState.Off) } .width('100%') .height('100%') .backgroundColor(Color.White) } }6. 日程详情页面 (pages/ScheduleDetailPage.ets)// entry/src/main/ets/pages/ScheduleDetailPage.ets import { router } from '@kit.ArkUI'; import { promptAction } from '@kit.ArkUI'; import { Schedule, RepeatType, getRepeatTypeText, getAdvanceText } from '../model/ScheduleModel'; import { ScheduleDB } from '../utils/ScheduleDB'; import { ReminderHelper } from '../utils/ReminderHelper'; interface RouterParams { scheduleId?: number; } @Entry @Component struct ScheduleDetailPage { @State schedule: Schedule | null = null; @State isLoading: boolean = true; private db = ScheduleDB.getInstance(); private reminderHelper = ReminderHelper.getInstance(); private scheduleId: number = 0; async aboutToAppear(): Promise<void> { const params = router.getParams() as RouterParams; if (params?.scheduleId) { this.scheduleId = params.scheduleId; await this.loadSchedule(); } } async loadSchedule(): Promise<void> { this.isLoading = true; try { this.schedule = await this.db.getById(this.scheduleId); } catch (err) { console.error('加载日程详情失败:', JSON.stringify(err)); } finally { this.isLoading = false; } } async deleteSchedule(): Promise<void> { if (!this.schedule) return; promptAction.showDialog({ title: '确认删除', message: '删除后无法恢复，确定要删除吗？', buttons: [ { text: '取消', color: '#666666' }, { text: '删除', color: '#FF3B30' } ] }).then(async (result) => { if (result.index === 1 && this.schedule) { try { await this.reminderHelper.cancelReminder(this.schedule.reminderId); await this.db.delete(this.schedule.id); promptAction.showToast({ message: '删除成功' }); router.back(); } catch (err) { promptAction.showToast({ message: '删除失败' }); } } }); } formatDateTime(timestamp: number): string { const date = new Date(timestamp); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hour = String(date.getHours()).padStart(2, '0'); const minute = String(date.getMinutes()).padStart(2, '0'); const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; const weekDay = weekDays[date.getDay()]; return `${year}年${month}月${day}日 ${weekDay} ${hour}:${minute}`; } build() { Column() { // 顶部导航 Row() { Button() { Image($r('app.media.ic_back')) .width(24) .height(24) .fillColor('#333333') } .backgroundColor(Color.Transparent) .onClick(() => router.back()) Text('日程详情') .fontSize(18) .fontWeight(FontWeight.Medium) .fontColor('#333333') .layoutWeight(1) .textAlign(TextAlign.Center) Button() { Image($r('app.media.ic_delete')) .width(24) .height(24) .fillColor('#FF3B30') } .backgroundColor(Color.Transparent) .onClick(() => this.deleteSchedule()) } .width('100%') .height(56) .padding({ left: 8, right: 8 }) if (this.isLoading) { Column() { LoadingProgress().width(50).height(50) } .width('100%') .layoutWeight(1) .justifyContent(FlexAlign.Center) } else if (this.schedule) { Scroll() { Column() { // 标题卡片 Column() { Text(this.schedule.title) .fontSize(22) .fontWeight(FontWeight.Bold) .fontColor('#333333') if (this.schedule.note) { Text(this.schedule.note) .fontSize(15) .fontColor('#666666') .margin({ top: 12 }) } } .width('100%') .padding(20) .backgroundColor(Color.White) .borderRadius(12) .alignItems(HorizontalAlign.Start) // 详情信息卡片 Column() { // 提醒时间 this.DetailRow('提醒时间', this.formatDateTime(this.schedule.remindTime)) Divider().color('#F0F0F0').margin({ left: 16, right: 16 }) // 提前提醒 this.DetailRow('提前提醒', getAdvanceText(this.schedule.advanceMinutes)) Divider().color('#F0F0F0').margin({ left: 16, right: 16 }) // 重复 this.DetailRow('重复', getRepeatTypeText(this.schedule.repeatType)) Divider().color('#F0F0F0').margin({ left: 16, right: 16 }) // 状态 this.DetailRow('状态', this.schedule.isEnabled ? '已启用' : '已禁用') } .width('100%') .backgroundColor(Color.White) .borderRadius(12) .margin({ top: 16 }) } .padding(16) } .layoutWeight(1) } } .width('100%') .height('100%') .backgroundColor('#F5F5F5') } @Builder DetailRow(label: string, value: string) { Row() { Text(label) .fontSize(15) .fontColor('#999999') Blank() Text(value) .fontSize(15) .fontColor('#333333') } .width('100%') .padding(16) } }7. EntryAbility 入口 (entryability/EntryAbility.ets)// entry/src/main/ets/entryability/EntryAbility.ets import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { window } from '@kit.ArkUI'; import { ScheduleDB } from '../utils/ScheduleDB'; export default class EntryAbility extends UIAbility { async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> { hilog.info(0x0000, 'ScheduleManager', 'Ability onCreate'); // 初始化数据库 await ScheduleDB.getInstance().init(this.context); } onDestroy(): void { hilog.info(0x0000, 'ScheduleManager', 'Ability onDestroy'); } async onWindowStageCreate(windowStage: window.WindowStage): Promise<void> { hilog.info(0x0000, 'ScheduleManager', 'Ability onWindowStageCreate'); windowStage.loadContent('pages/Index', (err) => { if (err.code) { hilog.error(0x0000, 'ScheduleManager', 'Failed to load content: %{public}s', JSON.stringify(err)); return; } hilog.info(0x0000, 'ScheduleManager', 'Succeeded in loading content'); }); } onWindowStageDestroy(): void { hilog.info(0x0000, 'ScheduleManager', 'Ability onWindowStageDestroy'); } onForeground(): void { hilog.info(0x0000, 'ScheduleManager', 'Ability onForeground'); } onBackground(): void { hilog.info(0x0000, 'ScheduleManager', 'Ability onBackground'); } }四、资源文件准备需要准备的图标资源在 entry/src/main/resources/base/media/ 目录下添加：文件名用途ic_add.svg添加按钮图标ic_back.svg返回按钮图标ic_delete.svg删除按钮图标ic_time.svg时间图标ic_arrow_right.svg右箭头图标ic_empty.svg空状态图标示例 SVG 图标内容ic_add.svg: <path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/> ic_back.svg: <path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/> 五、运行与测试步骤1：编译运行# 在 DevEco Studio 中 1. 连接真机或启动模拟器 2. 点击 Run 按钮或按 Shift+F10 3. 等待应用安装完成步骤2：功能测试1. 添加日程测试 - 点击右上角 + 按钮 - 输入标题：测试日程 - 选择时间：5分钟后 - 选择提前提醒：准时提醒 - 点击保存 2. 提醒测试 - 返回主页等待 - 5分钟后应收到系统通知 - 即使关闭应用也会收到提醒 3. 重复日程测试 - 添加一个每天重复的日程 - 验证每天都会收到提醒六、核心API说明reminderAgentManager 关键APIAPI功能说明publishReminder()发布提醒设置定时提醒，返回 reminderIdcancelReminder()取消提醒根据 reminderId 取消getValidReminders()获取有效提醒获取所有未触发的提醒cancelAllReminders()取消所有提醒取消当前应用所有提醒提醒类型// 日历提醒（精确到秒） ReminderType.REMINDER_TYPE_CALENDAR // 闹钟提醒（每天固定时间） ReminderType.REMINDER_TYPE_ALARM // 倒计时提醒 ReminderType.REMINDER_TYPE_TIMER七、注意事项权限申请：必须在 module.json5 中声明 ohos.permission.PUBLISH_AGENT_REMINDER时间限制：提醒时间必须大于当前时间数量限制：单个应用最多设置 30 个提醒重复规则：重复日程需要正确设置 repeatMonths 和 repeatDays后台保活：reminderAgentManager 由系统管理，无需应用保活这套代码已经过实测，可以直接复制使用！我是 V 哥，关注我，一起探索新技术的魅力海洋。
鸿蒙 HarmonyOS 6 | ArkUI (08)：弹窗与覆盖 CustomDialog、Toast 与 Popup
前言在一个优秀的应用设计中，界面不仅仅是平铺直叙的展示，更需要有层级感。当用户点击删除按钮时，我们需要一个确认框来防止误触；当后台数据加载完成时，我们需要一个轻量的提示告诉用户 好了 ；当用户对某个晦涩的功能图标感到困惑时，我们需要一个气泡弹窗来解释它的含义。这些浮在主界面之上的交互层，我们统称为 覆盖物（Overlays）。在早期的开发中，很多工程师习惯直接使用系统原生的 AlertDialog，那种灰底黑字的弹窗虽然功能健全，但在如今这个颜值为王的时代，它打断了用户的情绪流，也破坏了应用的整体设计语言。在鸿蒙 HarmonyOS 6 中，ArkUI 为我们提供了极其强大的弹窗定制能力。无论是转瞬即逝的 Toast，还是完全自定义的 CustomDialog，亦或是指向性明确的 Popup 气泡，我们都可以像搭积木一样，用声明式的代码构建出既美观又灵动的交互体验。一、 轻量级反馈与上下文气泡在进入复杂的弹窗之前，我们先解决最基础的反馈需求。当用户复制了一段文本，或者刷新列表成功时，我们不需要让用户进行任何操作，只需要给出一个朕已阅的信号。这就是 Toast。在 API 20 中，系统将这类交互统一收敛到了 promptAction 模块下。我们不再像以前那样去寻找 Window 实例，而是直接调用 promptAction.showToast。这个 API 非常纯粹，它接受一个显示时长、一条消息文本，以及一个可选的位置参数。但在实战中，建议尽量保持 Toast 的简洁，不要试图在里面塞入过多的文字。它应该像一阵风，来过，被看到，然后消失。如果说 Toast 是全局的广播，那么 Popup 气泡就是点对点的悄悄话。CustomDialog 是一种模态交互，它会给背景加上遮罩，强迫用户聚焦。但有时候，我们并不想打断用户的操作流，只是想对界面上的某个元素做一点补充说明。比如一个帮助的小问号图标，或者一个“新功能”的引导提示。这时候，ArkUI 提供的 bindPopup 属性是最优雅的选择。这意味着任何组件——一个按钮、一张图片甚至一段文字，都可以绑定一个气泡。系统会自动计算目标组件在屏幕上的位置，然后决定气泡是出现在上方、下方还是侧边，并自动生成一个小箭头指向目标。我们作为开发者，几乎不需要关心坐标计算的问题，只需要关注气泡里的内容构建即可。@Entry @Component struct PopupExample { // 控制气泡显示的开关状态 @State showPopup: boolean = false; // 定义气泡内部的 UI 结构 @Builder PopupContent() { Column() { Text('功能说明') .fontSize(14) .fontWeight(FontWeight.Bold) .fontColor(Color.White) .margin({ bottom: 4 }) Text('这里是详细的补充文案，系统会自动根据位置计算箭头指向。') .fontSize(12) .fontColor('#E6E6E6') } .padding(12) .backgroundColor('#4D4D4D') // 气泡背景通常与文字反色 .borderRadius(8) } build() { Column() { // 任何组件都可以绑定气泡，这里以一个问号图标为例 SymbolGlyph($r('sys.symbol.questionmark_circle')) .fontSize(24) .fontColor($r('sys.color.ohos_id_color_text_secondary')) // 1. 点击切换状态 .onClick(() => { this.showPopup = !this.showPopup; }) // 2. 绑定气泡属性 .bindPopup(this.showPopup, { builder: this.PopupContent, // 指向内容构建器 placement: Placement.Bottom, // 优先显示位置（系统会自动调整） mask: false, // false 表示非模态，不阻断用户操作其他区域 enableArrow: true, // 显示指向目标的小箭头 popupColor: '#4D4D4D', // 气泡背景色（需与 Builder 背景一致或透明） onStateChange: (e) => { // 3. 状态同步：当点击空白处气泡消失时，同步更新 boolean 变量 if (!e.isVisible) { this.showPopup = false; } } }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } }二、 定制化核心：CustomDialog 与控制器模式当业务逻辑变得复杂，比如需要用户领取优惠券、签署隐私协议或者选择复杂的筛选条件时，系统的标准弹窗就捉襟见肘了。这时候，CustomDialog（自定义弹窗）就是我们的救星。它的设计哲学非常有趣，采用了一种 控制器（Controller） 模式。我们需要定义两个部分：一个是弹窗本身的 UI 结构，另一个是控制它打开和关闭的遥控器。首先，我们需要定义一个被 @CustomDialog 装饰器修饰的结构体。在这个结构体里，你可以使用任何 ArkUI 组件：Column、Row、Image 甚至 List。这意味你可以把弹窗做得像普通页面一样丰富多彩。紧接着，在父组件中，我们需要实例化一个 CustomDialogController。这个控制器是连接父子组件的纽带。在实例化时，我们需要传入 builder 参数，指向我们刚才定义的弹窗组件。@Entry @Component struct HomePage { // 1. 实例化控制器：连接父组件与弹窗组件 // 必须在 @Component 中作为成员变量定义 dialogController: CustomDialogController | null = new CustomDialogController({ builder: PrivacyAgreementDialog(), // 引用外部定义的 @CustomDialog 组件 autoCancel: false, // 点击遮罩是否允许关闭（强制交互场景通常设为 false） alignment: DialogAlignment.Center, // 弹窗在屏幕中的对齐方式 customStyle: true, // 是否完全自定义样式（去除系统默认的白色背景和圆角） offset: { dx: 0, dy: 0 }, // 相对对齐位置的偏移量 maskColor: '#33000000', // 自定义遮罩层颜色 }); // 推荐：在组件销毁时清理控制器，防止内存泄漏 aboutToDisappear() { this.dialogController = null; } build() { Column() { Button('打开隐私协议') .fontSize(16) .onClick(() => { // 2. 通过控制器打开弹窗 if (this.dialogController != null) { this.dialogController.open(); } }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } }这里有一个初学者常犯的错误，就是试图通过 @Prop 或 @Link 来直接同步父子组件的数据。虽然 CustomDialog 支持这些装饰器，但由于弹窗并不在常规的组件渲染树中，数据的响应式更新有时会存在滞后。最佳的实践是：在打开弹窗时传入初始数据，在关闭弹窗时通过回调函数返回结果。比如做一个“领取优惠券”的弹窗，我们在构建 CustomDialog 时定义一个 confirm 回调函数。当用户点击弹窗里的“立即领取”按钮时，我们调用这个回调，把结果传回给父组件，然后关闭弹窗。这种 事件驱动 的数据流向，比复杂的双向绑定更加稳健且易于追踪。做出来和做得好看是两码事。默认的 CustomDialog 往往带有系统默认的圆角和白色背景，有时甚至会有默认的内边距。为了实现设计师眼中那种“全屏半透明”或者“底部异形弹窗”的效果，我们一定要善用 customStyle: true 这个配置项。一旦设置为 true，系统就会移除所有默认的弹窗样式，给你一张完全空白的画布。这时候，你需要在你的 @CustomDialog 组件内部，自己定义背景色、圆角和阴影。虽然麻烦了一点，但它赋予了你像素级的控制权。三、 综合实战：构建营销活动弹窗体系为了将上述知识点融会贯通，我们来构建一个真实的电商营销场景。这个页面包含一个模拟的“会员中心”，右上角有一个绑定了 bindPopup 的帮助图标，点击会展示活动规则；而在页面中心，有一个“领取大礼包”的按钮，点击会唤起一个完全自定义样式的 CustomDialog 优惠券弹窗。在这个代码中，请仔细观察 CouponDialog 的定义，它是如何通过 controller 关闭自己的，以及父组件是如何通过 CustomDialogController 配置 customStyle: true 来移除系统默认背景的。这就是构建高颜值弹窗的标准模板。TypeScriptimport { promptAction } from '@kit.ArkUI'; @CustomDialog struct CouponDialog { controller?: CustomDialogController; couponAmount: number = 0; onConfirm: () => void = () => {}; build() { Column() { // 顶部装饰 Stack({ alignContent: Alignment.Bottom }) { Column() .width('100%') .height('100%') .backgroundColor('#FF4040') .borderRadius({ topLeft: 16, topRight: 16 }) Text(`¥${this.couponAmount}`) .fontSize(40) .fontWeight(FontWeight.Bold) .fontColor(Color.White) .margin({ bottom: 20 }) } .width('100%') .height(120) // 内容 Column({ space: 12 }) { Text('恭喜获得新人优惠券') .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor('#333') Text('全场通用，无门槛立减。有效期至 2026-12-31') .fontSize(14) .fontColor('#999') .textAlign(TextAlign.Center) .padding({ left: 20, right: 20 }) } .padding({ top: 20, bottom: 20 }) // 按钮 Row() { Button('残忍拒绝') .backgroundColor('#F5F5F5') .fontColor('#666') .layoutWeight(1) .margin({ right: 10 }) .onClick(() => { // 【修复点 2】调用时加上 '?' (可选链)，防止空指针报错 this.controller?.close(); }) Button('立即领取') .backgroundColor('#FF4040') .fontColor(Color.White) .layoutWeight(1) .onClick(() => { this.onConfirm(); // 【修复点 3】同理，加上 '?' this.controller?.close(); }) } .width('100%') .padding({ left: 20, right: 20, bottom: 20 }) } .width(300) .backgroundColor(Color.White) .borderRadius(16) .shadow({ radius: 10, color: '#33000000', offsetY: 5 }) } } @Entry @Component struct DialogAndPopupPage { // 状态变量：控制气泡 (Popup) 的显示与隐藏 @State isHelpPopupVisible: boolean = false; // 【核心】定义弹窗控制器 // 必须在 build() 之外实例化 // builder 参数指向上面定义的 @CustomDialog 组件 private dialogController: CustomDialogController = new CustomDialogController({ builder: CouponDialog({ couponAmount: 100, // 向弹窗传递数据 onConfirm: () => { // 定义弹窗确认后的逻辑 this.handleCouponReceived(); } }), autoCancel: true, // 允许点击遮罩关闭 customStyle: true, // 使用完全自定义样式（去除系统默认白底圆角） alignment: DialogAlignment.Center // 居中显示 }); // 模拟业务逻辑：领取成功后的 Toast 反馈 handleCouponReceived() { promptAction.showToast({ message: '领取成功！已存入卡包', duration: 2000, bottom: 100 }); } // 定义 Popup (气泡) 的内容构建器 @Builder PopupBuilder() { Column() { Text('活动规则说明') .fontSize(14) .fontWeight(FontWeight.Bold) .fontColor(Color.White) .margin({ bottom: 8 }) Text('1. 仅限新用户领取\n2. 每日限领一张\n3. 不可与其他活动叠加') .fontSize(12) .fontColor(Color.White) .lineHeight(18) } .padding(12) .width(200) } build() { Column() { // --- 顶部导航栏 --- Row() { Text('会员中心') .fontSize(20) .fontWeight(FontWeight.Bold) Blank() // 撑开中间空间 // 帮助图标 (绑定 Popup) Text('?') .fontSize(18) .fontColor(Color.White) .backgroundColor('#CCCCCC') .width(24) .height(24) .textAlign(TextAlign.Center) .borderRadius(12) // 【核心】绑定气泡 .bindPopup(this.isHelpPopupVisible, { builder: this.PopupBuilder(), // 指向 Builder placement: Placement.BottomRight, // 气泡位置 popupColor: '#4C4C4C', // 气泡深色背景 enableArrow: true, // 显示箭头 mask: false, // 非模态，不遮挡背景 onStateChange: (e) => { // 状态同步：处理点击外部自动消失的情况 if (!e.isVisible) { this.isHelpPopupVisible = false; } } }) .onClick(() => { // 点击切换显示状态 this.isHelpPopupVisible = !this.isHelpPopupVisible; }) } .width('100%') .padding(20) // --- 页面主体内容 --- Column({ space: 30 }) { // 模拟大图占位 Column() .width(200) .height(200) .backgroundColor('#E0E0E0') .borderRadius(100) .margin({ top: 50 }) Text('超级会员大礼包') .fontSize(24) .fontWeight(FontWeight.Bold) Text('包含 100 元无门槛优惠券') .fontSize(16) .fontColor('#666') // 【核心】触发弹窗的按钮 Button('立即领取') .width('80%') .height(50) .fontSize(18) .fontWeight(FontWeight.Bold) .backgroundColor('#FF4040') .shadow({ radius: 10, color: '#4DFF4040', offsetY: 5 }) .onClick(() => { // 打开自定义弹窗 if (this.dialogController) { this.dialogController.open(); } }) } .width('100%') .layoutWeight(1) // 占据剩余高度 } .width('100%') .height('100%') .backgroundColor('#F8F8F8') } }总结弹窗和覆盖物是应用与用户沟通的第二语言。Toast 是轻声的耳语，CustomDialog 是正式的对话，而 Popup 则是贴心的便签。在鸿蒙 HarmonyOS 6 开发中，掌握 @CustomDialog 和 bindPopup 是构建高级 UI 的必修课。我们抛弃了系统的默认样式，通过 customStyle 获得了对画布的完全掌控权，让弹窗不再只是功能的载体，更是视觉设计的延伸。切记，不要滥用弹窗，每一次遮罩的出现都是对用户注意力的强行掠夺。好的交互应该是克制的，只在真正需要的时候才优雅地浮现。
鸿蒙 HarmonyOS 6 | ArkUI (07)：导航架构 Navigation 组件 (V2) 与路由栈管理最佳实践
前言在鸿蒙应用的开发历程中，页面跳转一直是大家最先接触的功能之一。很长一段时间里，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 都提供了 title、menus 和 toolBar 属性。如果设计风格符合系统规范，直接传入资源配置即可，系统会自动适配深色模式和折叠屏布局。但如果设计差异巨大，我们可以通过 .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 架构，是提升应用质量的关键一步。