一、技术前言

在这里插入图片描述

在移动端通知系统的演进历程中,自定义铃声一直是一个被开发者高度关注但实现门槛颇高的能力。传统移动操作系统中,通知铃声通常被限制为系统预置音频或应用内 resources/rawfile 目录下的静态资源文件,开发者无法在运行时动态指定用户生成或网络下载的音频作为通知铃声。HarmonyOS 6.1.1 版本的 Notification Kit 引入了一项重要的新特性——支持将应用沙箱内的音频文件作为通知的自定义铃声。这一突破意味着应用可以在运行时动态生成、下载或用户自定义音频,并直接将其绑定到通知请求的 sound 字段上,极大拓展了通知铃声的灵活性和个性化空间。

在这里插入图片描述
HarmonyOS 的 ArkUI 框架是华为自研的声明式 UI 开发范式,其核心设计理念是通过 @Entry@Component@State@Builder@Observed 等装饰器,让开发者以接近自然描述的方式声明界面结构,由框架负责高效的差分渲染和状态同步。在 ArkTS 语言体系中,类型安全被提到了前所未有的高度——所有变量、参数、返回值都需要明确的类型标注,接口(interface)被广泛用于定义数据结构契约,这使得编译期就能捕获大量潜在错误。在本文分析的应用中,ArkUI 的这些特性被充分发挥:6 个 Tab 页面通过 @Builder 函数独立封装,状态变量通过 @State 管理并驱动响应式更新,数据模型通过 @Observed 实现深度观察,弹窗系统通过条件渲染按需挂载。

在这里插入图片描述
Notification Kit 是 HarmonyOS 提供的系统能力套件之一,它封装了通知发布、通知渠道管理、通知授权、通知设置等完整的通知生命周期管理能力。在本应用中,Notification Kit 的几个核心 API 被深度使用:notificationManager.isNotificationEnabled() 用于查询当前授权状态,notificationManager.requestEnableNotification() 用于请求系统授权弹框,notificationManager.openNotificationSettings() 用于在被用户拒绝后拉起通知设置页进行二次引导,notificationManager.publish() 用于发布携带自定义铃声的 NotificationRequest。这些 API 的组合使用构成了一个完整的"授权检查→申请授权→降级引导→发布通知"的闭环流程。

在这里插入图片描述
在数据可视化方面,ArkUI 提供了 Canvas 组件和 CanvasRenderingContext2D 上下文,支持开发者使用标准的 2D Canvas API 进行自由绘制。这与 Web 平台的 Canvas 2D Context 几乎完全兼容,包括 beginPathmoveTolineToarcfillstrokecreateLinearGradient 等方法。本应用在统计页使用 Canvas 绘制了一幅近 7 天通知量的折线图,包含背景网格、渐变填充区域、折线本体、数据点和 x 轴标签五个层次,并通过定时器驱动的呼吸动画实现最后一个数据点(今日)的半径脉动效果。这种纯代码绘制方式无需依赖任何第三方图表库,既控制了包体积,又保证了绘制的灵活性和精确度。

在这里插入图片描述
在文件系统层面,HarmonyOS 的应用沙箱采用了分级加密(Encryption Level)机制,分为 EL1~EL4 四个安全等级。本应用将生成的音频文件写入 EL1 区域的 filesDir 目录,这是 Notification Kit 在 6.1.1 版本中对自定义铃声路径的基本要求。通过 contextConstant.AreaMode.EL1 设置应用上下文的加密区域,再使用 @kit.CoreFileKit 中的 fileIo 模块进行文件的创建、写入和删除操作,以及 fileUri.getUriFromPath() 将沙箱路径转换为 uri:: 前缀的 URI 格式,最终填入 NotificationRequest 的 sound 字段。这一系列操作涉及应用上下文获取、沙箱区域切换、二进制音频数据构造、文件 I/O 和 URI 转换等多个技术环节。

在这里插入图片描述
从业务场景来看,"铃语·智能提醒助手"定位为效率工具类应用,其核心价值在于让用户能够为不同类型的提醒事项配置个性化通知铃声。应用设计了 6 个功能维度:提醒页以竖向时间轴呈现当日提醒事项,铃声坊提供音频生成器和铃声库管理,通知台支持通知构造和发布测试,渠道页管理通知渠道开关,统计页通过 Canvas 折线图和柱状图展示通知数据趋势,我的页提供授权管理和特性说明。整体设计采用深色主题(#0B1026 深蓝底色),搭配通知蓝(#6C8CFF)和铃音金(#F5C451)作为双主色调,营造出科技感与温暖感并存的视觉氛围。

在这里插入图片描述
下面,我们将从代码的第一行开始,逐段、逐块地深入分析这个智能提醒助手的完整技术实现。


二、整体架构流程图

弹窗系统

底部导航

内容区域_Scroll

头部区域

核心业务方法

数据层

状态管理层

入口组件

Page1100
@Entry @Component

currentTab: number
当前激活 Tab

addModal / editModal / delModal
弹窗开关

breath: boolean
呼吸动画状态

granted / notifyId / currentRingIdx
sandboxCount / sentToday
Notification 核心状态

genFreq / genDuration
铃声生成器参数

ntTitle / ntText
通知台表单

canvasReady: boolean
Canvas 就绪标志

ColorPalette 色彩体系
24 个颜色常量

5 个 @Observed 数据模型
RemindItem / RingItem / NoticeLog
ChannelItem / FuncItem

5 组模拟数据数组
REMIND_LIST / RING_LIST / NOTICE_LOG
CHANNEL_LIST / FUNC_LIST

3 个工具函数
remindColor / logColor / buildWavBytes

requestAuth
通知授权请求

saveRingToSandbox
音频写入沙箱

getSoundValue
sound 字段值生成

publishNotice
发布自定义铃声通知

publishSystemNotice
发布系统铃声通知

drawLineChart
Canvas 折线图绘制

headerNotify
授权状态条 + 铃声/沙箱/已发数据卡

tabRemind
Tab0 竖向时间轴

tabRing
Tab1 铃声生成器 + 铃声库

tabNotify
Tab2 通知构造器 + 发布历史

tabChannel
Tab3 渠道开关列表

tabStat
Tab4 Canvas 折线图 + 数据小卡

tabMine
Tab5 授权大卡 + 功能清单

chartCard
月度柱状图通用卡

tabBar
单排 6 Tab

panelAdd
新增提醒弹窗

panelEdit
编辑提醒弹窗

panelDel
删除铃声确认弹窗

从上述架构流程图可以清晰地看到,整个应用以 Page1100 组件为核心枢纽,向下连接了状态管理层、数据层、核心业务方法、头部区域、内容区域、底部导航和弹窗系统七大子系统。状态管理通过 @State 装饰器实现响应式绑定,数据层通过 @Observed 装饰器提供可观察的数据模型,核心业务方法封装了 Notification Kit 的授权、沙箱文件操作和通知发布逻辑。6 个 Tab 的内容区域通过 if/else if 条件链根据 currentTab 的值动态切换,弹窗系统则通过三个布尔状态变量按需挂载到 Stack 容器中。


三、文件头部注释与设计意图

// ============================================================
// 铃语 · 智能提醒助手(现代行业:效率工具)
// 头部样式:通知授权状态条(应用名+授权呼吸灯+当前铃声+沙箱文件数)无动画
// 布局风格:6 个 tab 每个布局完全不同
//   提醒=竖向时间轴(时间+圆点竖线+提醒卡) / 铃声坊=生成器参数卡+铃声库行列表
//   通知台=通知构造表单+sound代码预览+发布历史 / 渠道=左色条开关列表行
//   统计=Canvas折线图+数据小卡 / 我的=授权渐变大卡+功能清单+特性说明卡
// Notification 特性(HarmonyOS 6.1.1 新特性):应用沙箱文件作为通知自定义铃声
//   NotificationRequest.sound = 'uri::' + fileUri.getUriFromPath(沙箱音频路径)
//   音频须放沙箱 EL1 区域 files 目录(模拟网络下载/用户生成的音频)
// Canvas 图表:统计页近 7 天通知量折线图 drawLine()(呼吸动画联动重绘)
// 弹窗系统:build() 用 Stack() 包裹;panelAdd 新增提醒 / panelEdit 编辑提醒 / panelDel 删除铃声
// 底部 6 tab 单排,主题:通知蓝(#6C8CFF) + 铃音金(#F5C451) + 深色背景(#0B1026)
// ============================================================

这段文件头部注释扮演着"架构设计文档"的角色,在团队协作中为后续开发者提供了快速理解整个文件设计意图的入口。我们来逐段解读:

“铃语 · 智能提醒助手(现代行业:效率工具)”:第一行直接给出了应用的名称、所属行业领域和定位。效率工具类应用的核心价值在于帮助用户更高效地管理时间和任务,而通知铃声个性化正是这一价值链中的重要一环——不同的铃声可以帮助用户在不看手机的情况下区分通知类型和优先级。

头部样式描述:明确了头部区域包含四个信息维度——应用名、授权状态呼吸灯、当前铃声名称和沙箱文件数。值得注意的是"无动画"的标注,这表示头部区域本身采用静态布局,但授权状态灯的呼吸效果由全局 breath 状态驱动,是一种"被动联动"而非"主动动画"的设计策略。

6 个 Tab 的布局风格:这是整个应用最核心的设计理念——每个 Tab 都采用完全不同的布局方式。提醒页采用竖向时间轴,适合按时间排列提醒事项;铃声坊采用生成器参数卡加铃声库列表的组合,兼顾参数调节和列表管理两个场景;通知台采用表单加代码预览加历史记录的三段式,让开发者能直观看到 sound 字段的最终值;渠道页采用左色条开关列表行,通过色条的亮灭快速传达渠道启停状态;统计页采用 Canvas 折线图加数据小卡,是唯一使用 Canvas 绘制的页面;我的页采用渐变大卡加功能清单,突出品牌感和信息密度。

Notification 特性描述:这段注释精确指出了 HarmonyOS 6.1.1 的关键新特性——NotificationRequest.sound 字段支持 uri:: 前缀加沙箱路径的格式。同时强调了音频必须放在 EL1 区域的 files 目录,这是系统安全策略的硬性要求,不符合此路径规范的音频将无法作为通知铃声播放。

Canvas 图表描述:指明统计页使用 drawLine() 方法绘制折线图,并与呼吸动画联动重绘。这意味着折线图不是静态的——每秒钟定时器触发时,Canvas 会被重新绘制,最后一个数据点(今日)的半径会在 3px 和 5px 之间脉动,形成"今日数据点在呼吸"的视觉效果。

弹窗系统描述:明确 build() 方法使用 Stack() 作为最外层容器,这是因为弹窗需要浮在页面内容之上。三个弹窗分别对应新增提醒、编辑提醒和删除铃声三个核心操作。

底部 Tab 和主题描述:底部采用单排 6 Tab 的设计(不像某些应用使用双行 4+4 布局),这在 6 个 Tab 数量下是合理的——单排可以保证每个 Tab 有足够的触控区域。主题色方面,通知蓝代表系统通知能力,铃音金代表音频个性化,两者在深蓝底色上形成强烈的冷暖对比。


四、模块导入与 Kit 依赖

import { notificationManager } from '@kit.NotificationKit';
import { fileIo as fs, fileUri } from '@kit.CoreFileKit';
import { contextConstant, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

这四行导入语句揭示了应用所依赖的全部系统能力套件,每一行都承载着特定的技术职责。

4.1 NotificationKit 导入

第一行从 @kit.NotificationKit 导入了 notificationManager 模块。这是整个应用的核心依赖——所有与通知发布、授权检查、设置跳转相关的功能都由这个模块提供。notificationManager 是一个命名空间对象,内部包含了 isNotificationEnabledrequestEnableNotificationopenNotificationSettingspublish 等方法,以及 NotificationRequestSlotTypeContentType 等类型定义。在本应用中,NotificationRequest 接口被用于构造通知请求,其 sound 字段是 HarmonyOS 6.1.1 新特性的核心载体。

4.2 CoreFileKit 导入

第二行从 @kit.CoreFileKit 导入了 fileIo(别名 fs)和 fileUri 两个模块。fileIo 提供文件系统的同步和异步操作能力,包括 openSyncwriteSynccloseSyncunlinkSync 等方法,在本应用中用于将生成的 WAV 音频字节写入沙箱文件以及在删除铃声时清理沙箱文件。fileUri 模块提供了 getUriFromPath() 方法,用于将沙箱文件路径转换为 uri:: 前缀的 URI 格式——这是 NotificationRequest 的 sound 字段要求的特定格式。

4.3 AbilityKit 导入

第三行从 @kit.AbilityKit 导入了 contextConstantcommon 两个模块。contextConstant 包含 AreaMode 枚举,用于设置应用上下文的加密区域等级(EL1~EL4)。在本应用中,写入音频文件前必须将 appCtx.area 设置为 contextConstant.AreaMode.EL1,否则文件不会写入 EL1 沙箱区域,导致后续通知铃声路径不满足系统要求。common 模块提供了 UIAbilityContext 类型,用于获取宿主上下文和应用程序上下文。

4.4 BasicServicesKit 导入

第四行从 @kit.BasicServicesKit 导入了 BusinessError 类型。这是 HarmonyOS 的统一错误类型,所有异步 API 的 catch 回调都接收此类型的参数。在本应用中,requestEnableNotificationpublish 方法的 catch 回调都使用了 BusinessError 类型标注,其中 requestEnableNotification 的错误码 1600004 表示用户曾拒绝授权,需要降级处理为拉起通知设置页。


五、色彩体系设计(ColorPalette 接口与 COLORS 常量)

5.1 色彩接口定义

interface ColorPalette {
  bg: string;
  card: string;
  chip: string;
  title: string;
  sub: string;
  text3: string;
  white: string;
  blue: string;
  blueD: string;
  blueL: string;
  gold: string;
  goldD: string;
  goldL: string;
  green: string;
  greenL: string;
  red: string;
  redL: string;
  purple: string;
  purpleL: string;
  orange: string;
  line: string;
  tabOn: string;
  mask: string;
}

ColorPalette 接口定义了应用所需的全部 24 个颜色变量,涵盖了背景色、卡片色、芯片色、三级文本色、白色、蓝色系(浅/标准/深)、金色系(浅/标准/深)、绿色系(浅/标准)、红色系(浅/标准)、紫色系(浅/标准)、橙色、线条色、Tab 选中色和遮罩色。这种"一份接口约束全部颜色"的设计方式在大型项目中尤为重要——它确保了任何新增的颜色引用都必须符合接口契约,TypeScript 编译器会在编译期检查类型完整性。

色彩语义的划分值得注意:title 代表主标题色,sub 代表副文本色,text3 代表三级文本色——这三级文本色构成了一套从主到次的视觉层次。蓝色系有 blue(标准)、blueD(深色,用于渐变起点)和 blueL(浅色背景,用于标签底色)三个层级。金色系同样如此。这种"标准色 + 深色变体 + 浅色背景变体"的三元组设计模式,让色彩在"实心按钮"、"渐变背景"和"浅色标签底色"三种使用场景中都能保持统一的色系归属。

5.2 色彩常量实例化

const COLORS: ColorPalette = {
  bg: '#0B1026',
  card: '#141B38',
  chip: '#1B2445',
  title: '#E8ECFF',
  sub: '#8E9BC8',
  text3: '#55608F',
  white: '#FFFFFF',
  blue: '#6C8CFF',
  blueD: '#4C6AE8',
  blueL: '#141E44',
  gold: '#F5C451',
  goldD: '#D9A631',
  goldL: '#2E2612',
  green: '#4ADE80',
  greenL: '#0A2E1A',
  red: '#F87171',
  redL: '#2E1212',
  purple: '#A78BFA',
  purpleL: '#1E1B38',
  orange: '#FB923C',
  line: '#1B2445',
  tabOn: '#6C8CFF',
  mask: 'rgba(0,0,0,0.55)'
};

COLORS 常量是 ColorPalette 接口的具体实例,所有颜色值在此集中定义。我们来分析其色彩策略:

深色背景体系bg#0B1026)是接近纯黑的深蓝底色,card#141B38)是卡片背景色,chip#1B2445)是芯片/标签背景色。这三个颜色从深到浅递进,构成了深色主题的基础层次。#0B1026 的 RGB 值为 (11, 16, 38),蓝色分量显著高于红绿分量,营造出冷峻的科技感。

通知蓝主色系blue#6C8CFF)是品牌主色,RGB 值为 (108, 140, 255),是一种柔和而明确的蓝紫色。blueD#4C6AE8)是深色变体,用于渐变背景的起点。blueL#141E44)是浅色背景变体,用于蓝色标签的底色——它的明度很低(接近背景色),但蓝色色相与主色一致,确保了"浅底蓝字"的标签配色和谐。

铃音金辅色系gold#F5C451)是辅色,RGB 值为 (245, 196, 81),是一种温暖的琥珀金色。goldD#D9A631)是深色变体,用于边框。goldL#2E2612)是浅色背景变体,RGB 值中红色分量最高,呈现出暖棕色调。金色与蓝色的冷暖对比构成了应用的双主色视觉语言。

功能色系green#4ADE80)表示已送达/已响等积极状态,red#F87171)表示未授权/删除等警示状态,orange#FB923C)表示未导入/静默等中间状态,purple#A78BFA)表示今日已发等辅助数据。每个功能色都有对应的 L 后缀浅色背景变体。

特殊色maskrgba(0,0,0,0.55))是弹窗遮罩色,使用 rgba 格式定义半透明黑色;line#1B2445)与 chip 相同,用于分割线和图表网格线;tabOnblue 相同,语义化地表示 Tab 选中色。


六、常量定义与数据配置

6.1 Tab 元数据定义

interface TabMeta {
  icon: string;
  label: string;
}

const TAB_LIST: TabMeta[] = [
  { icon: '⏰', label: '提醒' },
  { icon: '🎵', label: '铃声坊' },
  { icon: '📣', label: '通知台' },
  { icon: '📡', label: '渠道' },
  { icon: '📊', label: '统计' },
  { icon: '👤', label: '我的' }
];

TabMeta 接口定义了 Tab 的元数据结构,包含 icon(图标)和 label(标签文字)两个字段。TAB_LIST 数组定义了底部导航的 6 个 Tab,每个 Tab 使用一个 Emoji 图标加文字标签的方式呈现。使用 Emoji 作为图标是一种轻量化的设计选择——无需准备图标资源文件,且 Emoji 在所有设备上都有一致的渲染效果。6 个 Tab 分别对应提醒、铃声坊、通知台、渠道、统计和我的,覆盖了从日常使用到高级配置的完整功能链路。

6.2 提醒标签常量

const REMIND_TAGS: string[] = ['生活', '工作', '健康', '运动'];

REMIND_TAGS 定义了提醒事项的 4 个标签分类,用于新增提醒弹窗中的标签选择器。这 4 个标签覆盖了用户日常提醒的主要场景:生活类(如亲子通话)、工作类(如部门周会)、健康类(如服药提醒)和运动类(如健身课程)。

6.3 统计页数据常量

const NOTIFY_WEEK: number[] = [12, 18, 9, 22, 15, 26, 20];
const WDAY_LABELS: string[] = ['一', '二', '三', '四', '五', '六', '日'];
const NOTIFY_WEEK_MAX: number = 30;

这三行定义了统计页折线图所需的数据。NOTIFY_WEEK 是近 7 天的通知发送量数组,从周一到周日分别为 12、18、9、22、15、26、20 条。WDAY_LABELS 是 x 轴标签,使用中文星期缩写。NOTIFY_WEEK_MAX 是 Y 轴最大值,设为 30(略大于数据峰值 26),确保折线不会触及图表顶部。这种"最大值略大于数据峰值"的设计保证了数据可视化的最佳比例——折线充满图表空间但又留有呼吸余地。

6.4 月度柱状图数据常量

const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const NOTICE_VAL: number[] = [320, 356, 289, 402, 378, 415];
const NOTICE_MAX: number = 450;

这四行定义了月度通知量柱状图的数据。MONTH_IDX 是索引数组(0~5),MONTH_NAME 是 6 个月的标签(3 月到 8 月),NOTICE_VAL 是对应月份的通知发送量(320~415 条),NOTICE_MAX 是 Y 轴最大值 450。月度数据整体呈上升趋势(从 320 增长到 415),这与应用功能逐步丰富、用户使用频次逐渐增加的场景一致。


七、辅助函数设计

7.1 提醒状态颜色映射函数

/** 提醒状态→颜色映射 */
function remindColor(s: string): string {
  if (s === '待响') return COLORS.blue;
  if (s === '已响') return COLORS.green;
  return COLORS.text3;
}

remindColor 函数接收提醒状态字符串,返回对应的颜色值。状态分为三种:待响 返回通知蓝(表示即将触发的提醒),已响 返回绿色(表示已经完成的提醒),其他状态(如"静默")返回三级文本色(灰色,表示不活跃)。

这种"状态→颜色"的映射函数是 UI 层常用的设计模式,其优势在于将业务语义与视觉表现解耦——如果未来需要调整颜色方案,只需修改这个函数,所有引用处自动生效。函数使用简单的 if 链而非 switch 或查找表,是因为只有三种状态,if 链的可读性最高。

7.2 通知发布状态颜色映射函数

/** 通知发布状态→颜色映射 */
function logColor(s: string): string {
  if (s === '已送达') return COLORS.green;
  if (s === '未授权') return COLORS.red;
  return COLORS.orange;
}

logColor 函数与 remindColor 结构相同,但映射的是通知发布状态:已送达 返回绿色(成功),未授权 返回红色(失败),其他状态返回橙色(中间态)。在通知发布历史列表中,每条记录的状态标签使用此函数着色,让用户能一眼区分成功和失败的通知。

7.3 WAV 音频字节生成函数

/** 生成正弦波 WAV 音频字节(16bit 单声道 PCM,模拟用户生成/网络下载的音频文件) */
function buildWavBytes(freq: number, durationMs: number): ArrayBuffer {
  const sampleRate = 44100;
  const numSamples = Math.floor(sampleRate * durationMs / 1000);
  const dataSize = numSamples * 2;
  const buf = new ArrayBuffer(44 + dataSize);
  const view = new DataView(buf);
  const writeStr = (offset: number, s: string) => {
    for (let i = 0; i < s.length; i++) {
      view.setUint8(offset + i, s.charCodeAt(i));
    }
  };
  writeStr(0, 'RIFF');
  view.setUint32(4, 36 + dataSize, true);
  writeStr(8, 'WAVE');
  writeStr(12, 'fmt ');
  view.setUint32(16, 16, true);
  view.setUint16(20, 1, true);
  view.setUint16(22, 1, true);
  view.setUint32(24, sampleRate, true);
  view.setUint32(28, sampleRate * 2, true);
  view.setUint16(32, 2, true);
  view.setUint16(34, 16, true);
  writeStr(36, 'data');
  view.setUint32(40, dataSize, true);
  for (let i = 0; i < numSamples; i++) {
    const t = i / sampleRate;
    const env = Math.min(1, i / (sampleRate * 0.02));
    const decay = Math.max(0, 1 - t / (durationMs / 1000));
    const v = Math.sin(2 * Math.PI * freq * t) * 0.5 * env * decay;
    view.setInt16(44 + i * 2, Math.round(v * 32767), true);
  }
  return buf;
}

这是整个应用中最具技术含量的函数之一。它接收频率(Hz)和时长(毫秒)两个参数,生成一个标准的 16bit 单声道 PCM WAV 文件的二进制数据(ArrayBuffer)。这个函数的核心目的是模拟"用户生成的音频文件"——在实际应用中,音频可能来自网络下载或用户录制,这里通过代码合成正弦波来模拟这一场景。

我们来逐段分析这个函数的实现:

采样率与数据量计算sampleRate = 44100 是 CD 音质的采样率。numSamples = Math.floor(sampleRate * durationMs / 1000) 计算采样点数。dataSize = numSamples * 2 计算音频数据字节数(每个采样点 2 字节,16bit)。

缓冲区与视图创建buf = new ArrayBuffer(44 + dataSize) 创建缓冲区,44 字节是 WAV 文件头长度,dataSize 是音频数据长度。view = new DataView(buf) 创建 DataView 视图,用于以小端序读写二进制数据。

WAV 文件头写入:使用 writeStr 辅助函数写入 ASCII 字符串标识(RIFF、WAVE、fmt 、data),使用 setUint32setUint16 写入数值字段。文件头各字段含义如下:偏移 0 的"RIFF"是文件标识,偏移 4 是文件大小减 8(36 + dataSize),偏移 8 的"WAVE"是格式标识,偏移 12 的"fmt "是格式块标识,偏移 16 是格式块大小(16 字节),偏移 20 是音频格式(1 = PCM),偏移 22 是声道数(1 = 单声道),偏移 24 是采样率(44100),偏移 28 是字节率(采样率 × 声道数 × 位深/8 = 88200),偏移 32 是块对齐(2),偏移 34 是位深(16bit),偏移 36 的"data"是数据块标识,偏移 40 是数据块大小。

音频数据生成:循环遍历每个采样点,计算三个参数:t 是当前时间(秒),env 是起始包络(前 20ms 淡入,避免点击声),decay 是衰减包络(线性衰减至 0)。最终振幅 v = Math.sin(2 * Math.PI * freq * t) * 0.5 * env * decay,其中 0.5 是基础音量(避免满幅削波)。使用 setInt16 将采样值写入缓冲区(范围 -32768~32767)。

这个函数的设计体现了几个重要的工程考量:第一,使用 DataView 而非 Uint8Array 来操作缓冲区,因为 WAV 文件需要混合写入字符串、32 位整数、16 位整数等不同类型的数据,DataView 提供了灵活的类型化读写能力。第二,所有数值字段的 littleEndian 参数设为 true(小端序),这是 WAV 文件格式的标准要求。第三,包络和衰减的设计使得生成的音频在播放时不会有突兀的起始点击声和尾部截断声,提升了铃声的听觉体验。


八、数据模型设计(@Observed 数据类)

8.1 提醒条目模型

/** 提醒条目(提醒 Tab 时间轴) */
@Observed export class RemindItem {
  time: string;
  title: string;
  tag: string;
  status: string;
  constructor(time: string, title: string, tag: string, status: string) {
    this.time = time;
    this.title = title;
    this.tag = tag;
    this.status = status;
  }
}

RemindItem 类使用 @Observed 装饰器标记,表示该类的实例属性变化可以被 ArkUI 框架深度观察。@Observed@State 的区别在于:@State 是组件内部的状态管理,当 @State 变量引用的对象的引用发生变化时会触发重渲染;但如果只是对象内部的属性发生变化(如修改 item.status),@State 无法感知。@Observed 装饰的类则不同,框架会代理其属性访问器,使得属性级别的变化也能触发 UI 更新。

RemindItem 包含四个字段:time(时间,如"07:30")、title(提醒标题,如"晨间打卡")、tag(标签分类,如"健康")和 status(状态,如"待响"/“已响”/“静默”)。构造函数接收所有参数进行初始化。在提醒页的时间轴中,每个提醒条目的状态颜色由 remindColor(item.status) 动态计算,当用户编辑提醒或时间到达后状态变化时,时间轴上的圆点颜色会自动更新。

8.2 铃声条目模型

/** 铃声条目(铃声坊:沙箱自定义铃声) */
@Observed export class RingItem {
  name: string;
  file: string;
  freq: number;
  duration: number;
  size: string;
  inSandbox: boolean;
  constructor(name: string, file: string, freq: number,
    duration: number, size: string, inSandbox: boolean) {
    this.name = name;
    this.file = file;
    this.freq = freq;
    this.duration = duration;
    this.size = size;
    this.inSandbox = inSandbox;
  }
}

RingItem 类表示铃声库中的一个铃声条目,包含六个字段:name(铃声名称,如"清泉叮咚")、file(沙箱文件名,如"ring_880.wav")、freq(音频频率 Hz)、duration(时长 ms)、size(文件大小,初始为"—"表示未导入)、inSandbox(是否已导入沙箱)。

这个模型的设计亮点在于 inSandbox 状态字段和 size 字段的联动:铃声初始状态 inSandboxfalsesize 为"—“;当调用 importRingToSandbox() 导入沙箱后,inSandbox 变为 truesize 被更新为实际文件大小(如"75 KB”)。由于 RingItem@Observed 标记,这些属性变化会自动反映到铃声库列表的 UI 上——状态标签从"未导入"变为"沙箱中",文件大小从"—"变为具体数值。

8.3 通知发布历史模型

/** 通知发布历史(通知台) */
@Observed export class NoticeLog {
  title: string;
  text: string;
  ring: string;
  time: string;
  status: string;
  constructor(title: string, text: string, ring: string, time: string, status: string) {
    this.title = title;
    this.text = text;
    this.ring = ring;
    this.time = time;
    this.status = status;
  }
}

NoticeLog 类表示通知台中的发布历史记录,包含五个字段:title(通知标题)、text(通知内容)、ring(使用的铃声名称)、time(发布时间)、status(发布状态,“已送达"或"未授权”)。每次调用 publishNotice()publishSystemNotice() 时,都会创建一个新的 NoticeLog 实例并 unshift 到历史列表头部,确保最新的记录显示在最上方。

8.4 通知渠道模型

/** 通知渠道(渠道 Tab) */
@Observed export class ChannelItem {
  icon: string;
  name: string;
  desc: string;
  slot: string;
  on: boolean;
  count: number;
  constructor(icon: string, name: string, desc: string, slot: string, on: boolean, count: number) {
    this.icon = icon;
    this.name = name;
    this.desc = desc;
    this.slot = slot;
    this.on = on;
    this.count = count;
  }
}

ChannelItem 类表示通知渠道管理页的一个渠道条目,包含六个字段:icon(Emoji 图标)、name(渠道名称)、desc(渠道描述)、slot(槽位类型,对应 notificationManager.SlotType 枚举值)、on(是否开启)、count(今日通知数)。

slot 字段的值如 SOCIAL_COMMUNICATIONSERVICE_INFORMATIONNEWS_INFORMATIONOTHER_TYPES 都是 HarmonyOS Notification Kit 中 SlotType 枚举的实际成员名。SlotType 决定了通知的行为特征(如响铃方式、振动方式、重要性级别等)。on 字段驱动渠道页的 Toggle 开关状态,当用户切换开关时,item.on 更新,左色条颜色和渠道名称颜色随之变化。

8.5 功能清单模型

/** 功能清单条目(我的 Tab) */
@Observed export class FuncItem {
  icon: string;
  name: string;
  desc: string;
  tag: string;
  constructor(icon: string, name: string, desc: string, tag: string) {
    this.icon = icon;
    this.name = name;
    this.desc = desc;
    this.tag = tag;
  }
}

FuncItem 类表示我的页功能清单的一个条目,包含四个字段:icon(Emoji 图标)、name(功能名称)、desc(功能描述)、tag(标签,如"可关闭"、“6.1.1”、“安全”、“校验”、“图书”)。这些条目用于向用户展示应用的核心功能和技术特性,其中"沙箱铃声"条目的 tag 为"6.1.1",明确标注了该功能依赖的 HarmonyOS 版本。


九、模拟数据初始化

9.1 提醒列表数据

const REMIND_LIST: RemindItem[] = [
  new RemindItem('07:30', '晨间打卡', '健康', '已响'),
  new RemindItem('09:00', '部门周会', '工作', '已响'),
  new RemindItem('11:30', '午间服药', '健康', '待响'),
  new RemindItem('14:00', '方案评审', '工作', '待响'),
  new RemindItem('16:30', '健身课程', '运动', '静默'),
  new RemindItem('19:00', '亲子通话', '生活', '待响'),
  new RemindItem('22:30', '睡眠提醒', '健康', '待响')
];

REMIND_LIST 定义了 7 条提醒数据,覆盖了一天从早到晚的完整时间线。数据设计的特点是:状态分布合理(2 条已响、4 条待响、1 条静默),标签分布均匀(健康 3 条、工作 2 条、运动 1 条、生活 1 条),时间间隔从 1.5 小时到 3.5 小时不等,模拟了真实用户的日常提醒节奏。这些数据将在提醒页以竖向时间轴的形式呈现,每条提醒对应时间轴上的一个节点。

9.2 铃声库数据

const RING_LIST: RingItem[] = [
  new RingItem('清泉叮咚', 'ring_880.wav', 880, 1200, '—', false),
  new RingItem('夜风铃', 'ring_660.wav', 660, 1500, '—', false),
  new RingItem('木鱼短敲', 'ring_440.wav', 440, 600, '—', false),
  new RingItem('玻璃风铃', 'ring_1320.wav', 1320, 1000, '—', false),
  new RingItem('蜂鸟振翅', 'ring_1760.wav', 1760, 800, '—', false),
  new RingItem('钟楼远鸣', 'ring_220.wav', 220, 2000, '—', false)
];

RING_LIST 定义了 6 条铃声数据,每条铃声的频率和时长都不同,对应不同的音色特征。频率从 220Hz(钟楼远鸣,低沉浑厚)到 1760Hz(蜂鸟振翅,尖锐高亢)跨度极大,覆盖了人耳可感知的主要频段。铃声名称富有画面感(“清泉叮咚”、“夜风铃"等),帮助用户从名称上就能感知音色风格。所有铃声初始 inSandboxfalsesize 为”—",需要用户手动导入沙箱后才可设为通知铃声。

9.3 通知发布历史数据

const NOTICE_LOG: NoticeLog[] = [
  new NoticeLog('测试通知', '您有一条新的系统通知', '清泉叮咚', '09:12', '已送达'),
  new NoticeLog('版本更新', '铃声坊上线自定义铃声功能', '夜风铃', '08:30', '已送达')
];

NOTICE_LOG 定义了 2 条初始历史记录,模拟应用此前已经发送过的通知。这两条记录的状态都是"已送达",使用不同铃声(清泉叮咚、夜风铃),发布时间分别在 09:12 和 08:30。用户在通知台发布新通知后,新记录会通过 unshift 插入到数组头部,形成按时间倒序的发布历史列表。

9.4 通知渠道数据

const CHANNEL_LIST: ChannelItem[] = [
  new ChannelItem('💬', '社交通信', '好友消息、群聊提及', 'SOCIAL_COMMUNICATION', true, 18),
  new ChannelItem('📦', '服务提醒', '订单状态、物流更新', 'SERVICE_INFORMATION', true, 12),
  new ChannelItem('📰', '资讯通知', '热点推送、订阅更新', 'NEWS_INFORMATION', false, 6),
  new ChannelItem('❤️', '内容互动', '点赞评论、粉丝动态', 'OTHER_TYPES', true, 9)
];

CHANNEL_LIST 定义了 4 条通知渠道数据,分别对应 Notification Kit 的 4 种 SlotType。社交通信和服务提醒渠道默认开启(on: true),资讯通知渠道默认关闭(on: false),内容互动渠道默认开启。今日通知数从 6 到 18 不等,模拟了不同渠道的通知频率差异。这 4 个渠道的 slot 字段值与 notificationManager.SlotType 枚举完全对应,确保了数据与系统能力的一致性。

9.5 功能清单数据

const FUNC_LIST: FuncItem[] = [
  new FuncItem('🔔', '通知授权', '管理系统通知开关权限', '可关闭'),
  new FuncItem('🎵', '沙箱铃声', '用户生成的音频作为通知铃声', '6.1.1'),
  new FuncItem('📁', '沙箱目录', '音频存放于 EL1 区域 files 目录', '安全'),
  new FuncItem('🛡️', '路径安全', 'sound 路径不含 ../ 与 /..', '校验'),
  new FuncItem('📖', '延伸阅读', '《鸿蒙HarmonyOS 6应用开发》9.2.1 简单消息', '图书')
];

FUNC_LIST 定义了 5 条功能说明数据,每条对应一个核心功能点。其中"沙箱铃声"条目的 tag 标注为"6.1.1",明确标识这是 HarmonyOS 6.1.1 版本的新特性;"路径安全"条目提到 sound 路径不能包含 ..//..,这是 Notification Kit 对铃声路径的安全校验规则,防止路径遍历攻击;"延伸阅读"条目引用了《鸿蒙HarmonyOS 6应用开发》一书的 9.2.1 章节,为开发者提供了进一步学习的线索。


十、组件主体与状态管理

10.1 组件声明与 Tab 状态

@Entry
@Component
struct Page1100 {
  // --- Tab 状态 ---
  @State currentTab: number = 0;

  // --- 弹窗状态 ---
  @State addModal: boolean = false;
  @State editModal: boolean = false;
  @State delModal: boolean = false;
  @State editIdx: number = -1;
  @State delIdx: number = -1;

Page1100 是应用的入口组件,使用 @Entry@Component 装饰器标记。@Entry 表示这是页面的根组件,@Component 表示这是一个自定义组件。

状态变量分为两组。第一组是 Tab 状态:currentTab 记录当前激活的 Tab 索引(0~5),初始值为 0(提醒页)。当用户点击底部 Tab 时,currentTab 更新,build() 中的 if/else if 链会渲染对应 Tab 的 @Builder 函数。

第二组是弹窗状态:三个布尔变量 addModaleditModaldelModal 分别控制三个弹窗的显示/隐藏。editIdxdelIdx 分别记录当前编辑或删除的条目索引,初始值为 -1 表示无选中。弹窗的显示采用"条件渲染"策略——只有当 addModaltrue 时,panelAdd 才会被渲染到 Stack 容器中,关闭时直接从组件树中移除。这种设计比"始终渲染但控制可见性"更高效,因为未显示的弹窗不参与布局计算和绘制。

10.2 呼吸动画与定时器状态

  // --- 呼吸动画 ---
  @State breath: boolean = false;
  timer: number = -1;

breath 是一个布尔型状态变量,每秒在 truefalse 之间切换,驱动整个应用中所有需要呼吸效果的视觉元素。timer 是定时器 ID,使用普通变量(非 @State)声明,因为它不需要触发 UI 更新——它只是一个 setInterval 的返回值引用。

这个设计策略值得深入分析:使用单一全局布尔变量驱动所有呼吸效果,意味着全应用的呼吸节奏完全同步。当 breathtrue 时,所有呼吸元素同时处于"亮"态;当 breathfalse 时,同时处于"暗"态。这种同步呼吸的效果在视觉上比异步呼吸更协调,但也带来了一个性能考量——breath 的每次变化会触发所有引用了它的组件重新渲染。在本应用中,由于引用 breath 的组件数量有限且分散在不同 Tab(只有当前 Tab 的组件参与渲染),性能开销可以接受。

10.3 Notification 核心状态

  // --- Notification 状态 ---
  @State granted: boolean = false;       // 通知授权状态
  @State notifyId: number = 100;         // 通知 id(自增防覆盖)
  @State currentRingIdx: number = 0;     // 当前默认铃声索引
  @State sandboxCount: number = 0;       // 已写入沙箱的铃声数
  @State sentToday: number = 2;          // 今日已发通知数

这五个状态变量构成了通知功能的核心状态机:

granted 记录通知授权状态,初始为 false,在 aboutToAppear 中通过 isNotificationEnabled() 查询后更新。授权状态决定了头部呼吸灯颜色(绿色/红色)、通知台发送按钮是否可用、我的页授权大卡的显示文案等多个视觉表现。

notifyId 是通知 ID 自增计数器,初始值为 100。每次成功发布通知后递增,确保每条通知的 ID 唯一,避免新通知覆盖旧通知。Notification Kit 要求每条通知有唯一 ID,相同 ID 的通知会更新而非新建。

currentRingIdx 记录当前默认铃声在 ringList 中的索引,初始值为 0(“清泉叮咚”)。用户在铃声坊或通知台切换铃声时更新此值,头部状态条和通知台 sound 预览会实时反映当前选中铃声。

sandboxCount 记录已写入沙箱的铃声数量,初始为 0。每次导入铃声到沙箱时递增,删除铃声时递减。此值在头部状态条、统计页数据小卡和我的页授权大卡中都有展示。

sentToday 记录今日已发通知数,初始为 2(对应 NOTICE_LOG 的两条初始记录)。每次发布通知后递增,在头部状态条、统计页数据小卡和我的页授权大卡中展示。

10.4 铃声生成器与通知台表单状态

  // --- 铃声坊生成器参数 ---
  @State genFreq: number = 880;          // 生成频率 Hz
  @State genDuration: number = 1200;     // 生成时长 ms

  // --- 通知台表单 ---
  @State ntTitle: string = '铃语提醒';
  @State ntText: string = '您有一条新的待办提醒,请及时处理';

  // --- 弹窗表单 ---
  @State addTime: string = '';
  @State addTitle: string = '';
  @State addTag: string = '生活';
  @State editTime: string = '';
  @State editTitle: string = '';

铃声生成器参数 genFreqgenDuration 分别控制生成铃声的频率和时长,通过 Slider 组件调节,范围分别为 220~1760Hz(步进 20Hz)和 600~2400ms(步进 100ms)。

通知台表单 ntTitlentText 是用户在通知台输入的通知标题和内容,有默认值,用户可通过 TextInput 修改。

弹窗表单变量分为新增和编辑两组:新增组包含 addTimeaddTitleaddTag,编辑组包含 editTimeeditTitle。打开弹窗前会预填这些变量,保存时读取这些变量的值创建或更新数据。

10.5 Canvas 与数据数组状态

  // --- Canvas(统计页折线图) ---
  private statCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));
  @State canvasReady: boolean = false;

  // --- 数据数组 ---
  @State remindList: RemindItem[] = REMIND_LIST;
  @State ringList: RingItem[] = RING_LIST;
  @State logList: NoticeLog[] = NOTICE_LOG;
  @State channelList: ChannelItem[] = CHANNEL_LIST;

Canvas 相关状态包含两个变量:statCtxCanvasRenderingContext2D 实例,使用 private 声明(不需要触发 UI 更新),通过 new CanvasRenderingContext2D(new RenderingContextSettings(true)) 创建,其中 true 参数开启抗锯齿。canvasReady 是一个布尔标志,表示 Canvas 组件是否已完成初始化(在 onReady 回调中设为 true),用于在定时器中判断是否可以安全调用 drawLineChart() 进行重绘。

数据数组状态包含四个 @State 变量,分别对应四个 Tab 的列表数据源。这些变量引用的数组在初始化时赋值为之前定义的常量数组(REMIND_LIST 等),在用户操作(新增提醒、导入铃声、发布通知等)时会被修改(push/unshift/splice),由于 @State 装饰器的响应式机制,修改后的数组会自动触发列表 UI 重新渲染。


十一、生命周期方法

11.1 aboutToAppear 生命周期

  aboutToAppear() {
    // 查询通知授权状态
    notificationManager.isNotificationEnabled().then((enabled: boolean) => {
      this.granted = enabled;
    }).catch(() => {
      this.granted = false;
    });
    // 呼吸动画 + Canvas 联动重绘
    this.timer = setInterval(() => {
      this.breath = !this.breath;
      if (this.canvasReady) {
        this.drawLineChart();
      }
    }, 1000);
  }

aboutToAppear 是组件生命周期方法,在组件创建后、UI 渲染前调用。此方法完成了两项关键初始化:

第一,通知授权状态查询。调用 notificationManager.isNotificationEnabled() 异步查询当前应用是否已获得通知授权。这是一个返回 Promise 的异步方法,then 回调接收布尔结果并更新 granted 状态,catch 回调在查询失败时将 granted 设为 false。这个查询的目的是在应用启动时就知道授权状态,避免用户在未授权状态下发送通知导致失败。授权状态更新后,头部呼吸灯颜色、通知台提示文案等 UI 元素会自动响应。

第二,呼吸动画定时器。使用 setInterval 创建一个每 1000 毫秒执行一次的定时器,回调函数做两件事:翻转 breath 布尔值,以及如果 Canvas 已就绪则调用 drawLineChart() 重绘折线图。这个定时器是整个应用动画系统的"心脏"——所有呼吸效果(头部授权灯、统计页今日数据点、我的页音符图标、月度柱状图最新柱条)都由 breath 驱动,而 Canvas 折线图的今日数据点半径脉动则由 drawLineChart() 重绘实现。

定时器 ID 被保存到 this.timer 变量中,供 aboutToDisappear 清理使用。这是一个重要的内存管理实践——如果不清理定时器,组件销毁后定时器仍会执行,访问已销毁组件的属性会导致异常。

11.2 aboutToDisappear 生命周期

  aboutToDisappear() {
    clearInterval(this.timer);
  }

aboutToDisappear 在组件销毁前调用,这里使用 clearInterval(this.timer) 清理定时器。这是防止内存泄漏的标准做法——setInterval 创建的定时器不会自动销毁,即使组件已经从组件树中移除,定时器回调仍会继续执行。通过在 aboutToDisappear 中清理,确保了组件生命周期的完整性。


十二、通知授权请求方法

  /** 请求通知授权(首次调用弹系统授权框;曾被拒绝则拉起通知设置页二次授权) */
  requestAuth() {
    const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    if (!hostCtx) {
      return;
    }
    notificationManager.requestEnableNotification(hostCtx).then(() => {
      this.granted = true;
    }).catch((err: BusinessError) => {
      // 曾拒绝时返回 1600004,拉起通知设置页引导用户手动开启
      notificationManager.openNotificationSettings(hostCtx).then(() => {
      }).catch(() => {
        this.granted = false;
      });
    });
  }

requestAuth 方法封装了通知授权的完整流程,采用"两级降级策略":

第一级:获取宿主上下文。通过 this.getUIContext().getHostContext() 获取当前 UI 的宿主上下文,并使用 as common.UIAbilityContext 进行类型断言。如果 hostCtx 为空(极端情况),直接返回,不执行后续逻辑。宿主上下文是调用 Notification Kit API 的必要参数——它提供了应用上下文(ApplicationContext)的获取入口。

第二级:请求授权弹框。调用 notificationManager.requestEnableNotification(hostCtx),如果用户从未被询问过授权,系统会弹出标准的授权对话框。用户同意后,then 回调将 granted 设为 true

降级:拉起通知设置页。如果用户此前曾拒绝授权(系统返回错误码 1600004),catch 回调会调用 notificationManager.openNotificationSettings(hostCtx) 拉起系统的通知设置页面,引导用户手动找到本应用并开启通知权限。这是 HarmonyOS 通知授权的标准降级方案——一旦用户拒绝过,系统不会再弹授权框,只能引导用户到设置页操作。

这种两级降级策略确保了无论用户是否曾拒绝授权,都能获得合理的引导路径,而不是直接报错失败。


十三、沙箱文件操作方法

13.1 音频写入沙箱

  /** 将生成的音频写入沙箱 EL1 的 files 目录,返回沙箱路径 */
  saveRingToSandbox(fileName: string, freq: number, durationMs: number): string {
    const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    if (!hostCtx) {
      return '';
    }
    const appCtx = hostCtx.getApplicationContext();
    appCtx.area = contextConstant.AreaMode.EL1;  // 必须在 EL1 沙箱下
    const dir = appCtx.filesDir;                 // EL1 区域 files 目录
    const path = dir + '/' + fileName;
    try {
      const data = buildWavBytes(freq, durationMs);
      const file = fs.openSync(path, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC);
      fs.writeSync(file.fd, data);
      fs.closeSync(file);
    } catch (e) {
      // 沙箱写入失败时忽略,发布通知时回退系统铃声
    }
    return path;
  }

saveRingToSandbox 方法是 Notification Kit 6.1.1 自定义铃声特性的核心实现之一。它将指定频率和时长的 WAV 音频写入沙箱 EL1 区域的 files 目录,返回完整的沙箱路径供后续使用。

方法执行流程分为五个步骤:

第一步:获取应用上下文。通过 hostCtx.getApplicationContext() 获取应用上下文对象 appCtx。应用上下文提供了 filesDir(文件目录路径)和 area(加密区域)两个关键属性。

第二步:设置加密区域appCtx.area = contextConstant.AreaMode.EL1 将应用上下文的加密区域设为 EL1。这一步至关重要——HarmonyOS 的文件系统按加密等级分区,只有在 EL1 区域的 files 目录下创建的文件才能被 Notification Kit 识别为合法的铃声路径。如果不设置或设置为其他等级,文件虽然可以写入,但通知铃声将无法播放。

第三步:构建文件路径dir = appCtx.filesDir 获取 EL1 区域的 files 目录路径,path = dir + '/' + fileName 拼接完整的文件路径。

第四步:生成并写入音频数据。调用 buildWavBytes(freq, durationMs) 生成 WAV 音频的 ArrayBuffer,然后使用 fs.openSync() 以"创建+只写+截断"模式打开文件,fs.writeSync() 写入二进制数据,fs.closeSync() 关闭文件描述符。这三个同步 I/O 方法确保数据完整写入后才返回。

第五步:异常处理try/catch 捕获所有 I/O 异常,失败时静默忽略并返回路径。这种"容错降级"设计确保了即使沙箱写入失败,应用也不会崩溃——后续发布通知时如果发现铃声未在沙箱中,会回退为系统铃声。

13.2 铃声导入沙箱

  /** 铃声库条目导入沙箱(更新大小与状态) */
  importRingToSandbox(idx: number) {
    const r = this.ringList[idx];
    this.saveRingToSandbox(r.file, r.freq, r.duration);
    r.inSandbox = true;
    const kb = Math.round((44 + Math.floor(44100 * r.duration / 1000) * 2) / 1024);
    r.size = kb + ' KB';
    this.sandboxCount++;
  }

importRingToSandbox 方法是对 saveRingToSandbox 的业务封装,它在写入沙箱文件后还完成了三项状态更新:

更新导入状态r.inSandbox = true 将铃声条目的 inSandbox 属性设为 true,由于 RingItem@Observed 标记,铃声库列表中的状态标签会自动从"未导入"变为"沙箱中"。

计算文件大小kb = Math.round((44 + Math.floor(44100 * r.duration / 1000) * 2) / 1024) 重新计算 WAV 文件大小——44 字节文件头加采样点数乘以 2(16bit)再除以 1024 转 KB。计算结果赋值给 r.size,更新铃声库中的文件大小显示。

递增沙箱计数this.sandboxCount++ 递增全局沙箱铃声计数器,头部状态条和统计页数据小卡会自动更新。

13.3 生成器创建铃声

  /** 用生成器参数新建铃声并写入沙箱 */
  createRingByGen() {
    const seq = this.ringList.length + 1;
    const ring = new RingItem('自定义铃声' + seq, 'ring_custom_' + seq + '.wav',
      this.genFreq, this.genDuration, '—', false);
    this.ringList.push(ring);
    this.importRingToSandbox(this.ringList.length - 1);
  }

createRingByGen 方法使用铃声坊生成器的当前参数(genFreqgenDuration)创建一个新的铃声条目。序列号 seq 基于当前铃声库长度加 1,确保命名唯一性。新铃声的名称为"自定义铃声N",文件名为"ring_custom_N.wav"。创建后通过 push 添加到 ringList 末尾,再调用 importRingToSandbox 写入沙箱。由于 ringList@State 变量,push 操作会触发铃声库列表自动追加新的铃声卡片。

13.4 设为默认铃声

  /** 设为默认通知铃声(未导入沙箱时自动导入) */
  setCurrentRing(idx: number) {
    if (!this.ringList[idx].inSandbox) {
      this.importRingToSandbox(idx);
    }
    this.currentRingIdx = idx;
  }

setCurrentRing 方法将指定索引的铃声设为默认通知铃声。方法的亮点在于"自动导入"逻辑——如果目标铃声尚未导入沙箱,会先调用 importRingToSandbox 导入,确保设为默认铃声的音频文件一定存在于沙箱中。这避免了发布通知时发现铃声不在沙箱而需要临时导入的尴尬,提升了用户体验的连贯性。


十四、sound 字段值生成与通知发布方法

14.1 sound 字段值生成

  /** 当前通知请求 sound 字段值(6.1.1 新特性:沙箱路径转 uri:: 前缀) */
  getSoundValue(): string {
    const ring = this.ringList[this.currentRingIdx];
    const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    if (!hostCtx) {
      return 'uri::';
    }
    const appCtx = hostCtx.getApplicationContext();
    const path = appCtx.filesDir + '/' + ring.file;
    return 'uri::' + fileUri.getUriFromPath(path);
  }

getSoundValue 方法是 Notification Kit 6.1.1 新特性的核心体现。它将当前默认铃声的沙箱路径转换为 uri:: 前缀的 URI 格式字符串,作为 NotificationRequest.sound 字段的值。

方法执行流程:首先获取当前铃声对象和宿主上下文,然后获取应用上下文的 filesDir 路径,拼接铃声文件名得到完整的沙箱路径,最后通过 fileUri.getUriFromPath(path) 将路径转换为 URI,并加上 uri:: 前缀。

uri:: 前缀是 Notification Kit 6.1.1 对自定义铃声路径的格式要求。在 6.1.1 之前,sound 字段只能使用 resource:// 前缀指向 resources/rawfile 中的预置音频。升级后,uri:: 前缀允许指向应用沙箱内的任意合法路径,极大扩展了铃声来源的灵活性。这个方法在通知台的 sound 代码预览卡中被实时调用展示,让开发者能直观看到最终传入 NotificationRequest 的 sound 值。

14.2 发布自定义铃声通知

  /** 发布携带沙箱自定义铃声的通知(核心:sound 字段填沙箱 uri) */
  publishNotice() {
    const ring = this.ringList[this.currentRingIdx];
    if (!ring.inSandbox) {
      this.importRingToSandbox(this.currentRingIdx);
    }
    const soundVal = this.getSoundValue();
    const ringName = ring.name;
    const timeNow = this.nowTime();
    const request: notificationManager.NotificationRequest = {
      id: this.notifyId,
      notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,
      content: {
        notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
        normal: {
          title: this.ntTitle,
          text: this.ntText,
          additionalText: '自定义铃声:' + ringName
        }
      },
      sound: soundVal  // HarmonyOS 6.1.1:支持应用沙箱内的音频路径
    };
    notificationManager.publish(request).then(() => {
      this.notifyId++;
      this.sentToday++;
      this.logList.unshift(new NoticeLog(this.ntTitle, this.ntText, ringName, timeNow, '已送达'));
    }).catch((err: BusinessError) => {
      this.logList.unshift(new NoticeLog(this.ntTitle, this.ntText, ringName, timeNow, '未授权'));
    });
  }

publishNotice 方法是整个应用的核心业务方法——发布一条携带沙箱自定义铃声的系统通知。我们来逐段分析:

前置保障:首先获取当前铃声对象,如果铃声尚未导入沙箱则自动导入。这确保了无论用户何时点击发送按钮,目标铃声一定已在沙箱中可用。

构造通知请求:创建一个 NotificationRequest 对象,包含以下字段:

  • id:通知唯一标识,使用自增的 notifyId
  • notificationSlotType:通知渠道类型,设为 SOCIAL_COMMUNICATION(社交通信),这是重要性最高的渠道类型,会默认响铃
  • content:通知内容,使用 NOTIFICATION_CONTENT_BASIC_TEXT(基础文本类型),包含 title(标题)、text(正文)和 additionalText(附加文本,显示"自定义铃声:铃声名称")
  • sound:自定义铃声路径,值为 getSoundValue() 返回的 uri:: 前缀 URI

发布与状态更新:调用 notificationManager.publish(request) 发布通知。成功时递增 notifyId(防覆盖)、递增 sentToday(今日已发数),并向发布历史列表头部插入一条"已送达"记录。失败时(通常是未授权)向历史列表插入一条"未授权"记录。

这个方法的关键技术点在于 sound 字段——它是 HarmonyOS 6.1.1 的核心新特性。在 6.1.1 之前,如果要自定义通知铃声,只能将音频文件预置在应用的 resources/rawfile 目录中,无法在运行时动态指定。6.1.1 版本解除了这一限制,允许应用将沙箱内的任意音频文件作为通知铃声,实现了真正的"运行时动态铃声"。

14.3 发布系统铃声通知

  /** 发布系统铃声通知(不携带 sound 字段,用于对比效果) */
  publishSystemNotice() {
    const timeNow = this.nowTime();
    const request: notificationManager.NotificationRequest = {
      id: this.notifyId,
      notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,
      content: {
        notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
        normal: {
          title: this.ntTitle,
          text: this.ntText,
          additionalText: '系统默认铃声'
        }
      }
    };
    notificationManager.publish(request).then(() => {
      this.notifyId++;
      this.sentToday++;
      this.logList.unshift(new NoticeLog(this.ntTitle, this.ntText, '系统默认', timeNow, '已送达'));
    }).catch((err: BusinessError) => {
      this.logList.unshift(new NoticeLog(this.ntTitle, this.ntText, '系统默认', timeNow, '未授权'));
    });
  }

publishSystemNotice 方法与 publishNotice 结构几乎相同,唯一的区别是 NotificationRequest 不包含 sound 字段。不指定 sound 时,系统会使用 SlotType 对应的默认铃声(SOCIAL_COMMUNICATION 渠道使用系统默认通知铃声)。这个方法的设计目的是让用户对比"自定义铃声"和"系统铃声"的效果差异——通过同一通知内容但不同铃声配置的 A/B 对比,直观展现 6.1.1 新特性的价值。

14.4 当前时间获取

  /** 当前时间 HH:mm */
  nowTime(): string {
    const d = new Date();
    const h = d.getHours();
    const m = d.getMinutes();
    const hh = h < 10 ? '0' + h : '' + h;
    const mm = m < 10 ? '0' + m : '' + m;
    return hh + ':' + mm;
  }

nowTime 方法返回当前时间的 HH:mm 格式字符串。使用 new Date() 获取当前时间,分别提取小时和分钟,通过三元运算符补零(小于 10 时前缀"0"),拼接为"08:30"这样的标准格式。这个时间字符串用于发布历史记录的时间戳。


十五、铃声删除与提醒编辑方法

15.1 删除铃声(同步清理沙箱文件)

  /** 删除铃声(同步清理沙箱文件) */
  delRing() {
    const idx = this.delIdx;
    if (idx >= 0 && idx < this.ringList.length) {
      const r = this.ringList[idx];
      if (r.inSandbox) {
        this.sandboxCount--;
        try {
          const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
          if (hostCtx) {
            const appCtx = hostCtx.getApplicationContext();
            appCtx.area = contextConstant.AreaMode.EL1;
            fs.unlinkSync(appCtx.filesDir + '/' + r.file);
          }
        } catch (e) {
          // 沙箱文件不存在时忽略
        }
      }
      this.ringList.splice(idx, 1);
      if (this.currentRingIdx >= this.ringList.length) {
        this.currentRingIdx = this.ringList.length - 1;
    }
    this.delModal = false;
  }

delRing 方法处理铃声删除的完整流程,包含三项关键操作:

沙箱文件清理:如果被删除的铃声已导入沙箱,需要同步删除沙箱中的物理文件。方法重新获取应用上下文,设置 EL1 区域,调用 fs.unlinkSync() 删除文件。异常处理使用静默忽略策略——如果文件不存在(可能已被其他途径删除),不报错继续执行。

列表更新this.ringList.splice(idx, 1) 从数组中移除被删除的铃声条目。由于 ringList@State 变量,splice 操作会触发铃声库列表自动移除对应卡片。

默认铃声索引修正:如果被删除的铃声正好是当前默认铃声(currentRingIdx),或者删除后 currentRingIdx 超出了新数组范围,将其修正为最后一个元素的索引,避免后续访问越界。

15.2 保存新增提醒

  /** 保存新增提醒 */
  saveRemind() {
    if (this.addTime === '' || this.addTitle === '') {
      return;
    }
    this.remindList.push(new RemindItem(this.addTime, this.addTitle, this.addTag, '待响'));
    this.addModal = false;
  }

saveRemind 方法在新增提醒弹窗中点击"保存"时调用。方法首先校验时间和标题是否为空(空值时不执行任何操作,保持弹窗打开),然后使用表单数据创建新的 RemindItem(状态为"待响"),push 到提醒列表,最后关闭弹窗。

15.3 保存编辑提醒

  /** 保存编辑提醒 */
  saveEditRemind() {
    if (this.editIdx >= 0 && this.editIdx < this.remindList.length) {
      const r = this.remindList[this.editIdx];
      if (this.editTime !== '') {
        r.time = this.editTime;
      }
      if (this.editTitle !== '') {
        r.title = this.editTitle;
      }
    }
    this.editModal = false;
  }

saveEditRemind 方法在编辑提醒弹窗中点击"保存"时调用。方法首先校验编辑索引的有效性,然后获取目标提醒对象,分别更新时间和标题(空值不更新,保留原值)。由于 RemindItem@Observed 标记,属性变化会自动反映到时间轴 UI 上。最后关闭弹窗。


十六、build 方法与整体布局结构

  build() {
    Stack() {
      Column() {
        this.headerNotify()
        Divider().strokeWidth(1).color(COLORS.line)
        Scroll() {
          Column() {
            if (this.currentTab === 0) {
              this.tabRemind()
            } else if (this.currentTab === 1) {
              this.tabRing()
            } else if (this.currentTab === 2) {
              this.tabNotify()
            } else if (this.currentTab === 3) {
              this.tabChannel()
            } else if (this.currentTab === 4) {
              this.tabStat()
            } else if (this.currentTab === 5) {
              this.tabMine()
            }
            this.chartCard()
          }
          .padding({ left: 14, right: 14, top: 12, bottom: 12 })
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)

        this.tabBar()
      }
      .width('100%')
      .height('100%')

      if (this.addModal) {
        this.panelAdd(() => {
          this.addModal = false;
        })
      }
      if (this.editModal) {
        this.panelEdit(() => {
          this.editModal = false;
        })
      }
      if (this.delModal) {
        this.panelDel(() => {
          this.delModal = false;
        })
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }

build 方法是组件的核心,定义了整个页面的 UI 结构。整体布局采用 Stack(堆叠容器)作为最外层,内部包含两层:

第一层:页面主体(Column)。从上到下依次是:头部通知状态条(headerNotify)、分割线(Divider)、可滚动内容区(Scroll)和底部 Tab 栏(tabBar)。内容区使用 layoutWeight(1) 占满中间剩余空间,scrollBar(BarState.Off) 隐藏滚动条保持视觉简洁。

内容区内部的 Column 通过 if/else if 链根据 currentTab 渲染对应 Tab 的 @Builder 函数。值得注意的是,无论当前在哪个 Tab,chartCard()(月度柱状图)都会在内容底部渲染——这是一个全局通用的图表卡片,为所有 Tab 提供持续的数据展示。内容区使用 14px 左右内边距和 12px 上下内边距,确保内容与屏幕边缘有适当的呼吸距离。

第二层:弹窗层。三个 if 条件分别控制三个弹窗的渲染。每个弹窗都接收一个关闭回调函数,点击遮罩或取消按钮时调用回调将对应的布尔状态设为 false,弹窗从 Stack 中移除。

Stack 容器的关键作用是让弹窗层覆盖在页面主体之上——Stack 的子元素按声明顺序堆叠,后声明的元素覆盖在前面的元素之上。弹窗使用 width('100%')height('100%') 占满整个屏幕,配合 alignContent(Alignment.Center) 实现弹窗内容的居中显示。StackbackgroundColor 设为 COLORS.bg(深色背景),确保整个页面的背景统一。


十七、头部通知状态条详解

  /** 通知授权头部 */
  @Builder
  headerNotify() {
    Column({ space: 10 }) {
      Row() {
        Column({ space: 2 }) {
          Text('🔔 铃语').fontSize(18).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Text('智能提醒助手 · Notification Kit').fontSize(9).fontColor(COLORS.text3)
        }
        Column().layoutWeight(1)
        Row({ space: 6 }) {
          Circle().width(6).height(6)
            .fill(this.granted ? COLORS.green : COLORS.red)
            .opacity(this.breath ? 1 : 0.35)
          Text(this.granted ? '已授权' : '未授权·点击授权').fontSize(10)
            .fontColor(this.granted ? COLORS.green : COLORS.red)
        }
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .backgroundColor(this.granted ? COLORS.greenL : COLORS.redL)
        .borderRadius(10)
        .onClick(() => {
          if (!this.granted) {
            this.requestAuth();
          }
        })
      }
      .width('100%')

      Row({ space: 8 }) {
        Column({ space: 3 }) {
          Text('当前铃声').fontSize(9).fontColor(COLORS.text3)
          Text(this.ringList[this.currentRingIdx].name).fontSize(11)
            .fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1)
        .padding({ top: 8, bottom: 8 })
        .backgroundColor(COLORS.goldL)
        .borderRadius(8)
        .alignItems(HorizontalAlign.Center)

        Column({ space: 3 }) {
          Text('沙箱铃声').fontSize(9).fontColor(COLORS.text3)
          Text(this.sandboxCount + ' 个').fontSize(11)
            .fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1)
        .padding({ top: 8, bottom: 8 })
        .backgroundColor(COLORS.blueL)
        .borderRadius(8)
        .alignItems(HorizontalAlign.Center)

        Column({ space: 3 }) {
          Text('今日已发').fontSize(9).fontColor(COLORS.text3)
          Text(this.sentToday + ' 条').fontSize(11)
            .fontColor(COLORS.purple).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1)
        .padding({ top: 8, bottom: 8 })
        .backgroundColor(COLORS.purpleL)
        .borderRadius(8)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 12, bottom: 12 })
    .backgroundColor(COLORS.bg)
  }

头部状态条分为上下两行,我们先分析第一行——应用标识与授权状态:

应用标识区:左侧使用 Column 纵向排列应用名"铃语"(18px 粗体标题色)和副标题"智能提醒助手 · Notification Kit"(9px 三级文本色),建立了品牌认知和技术标识的双重信息。

授权状态区:右侧的授权状态标签由一个 Circle 圆点和文字组成。圆点的 fill 颜色根据 granted 在绿色(已授权)和红色(未授权)之间切换,opacity 根据 breath 在 1(亮)和 0.35(暗)之间切换,形成"呼吸灯"效果。文字同步显示"已授权"或"未授权·点击授权"。整个标签的背景色也随 granted 变化——绿色浅底或红色浅底。点击事件在未授权时触发 requestAuth() 请求授权。

中间间隔Column().layoutWeight(1) 充当弹性间隔,将左右两部分推开。

第二行是三个数据小卡,水平排列,各占 layoutWeight(1) 等宽:

当前铃声卡:金色文字显示当前默认铃声名称,金色浅底背景(goldL),让铃声信息一目了然。

沙箱铃声卡:蓝色文字显示沙箱铃声数量,蓝色浅底背景(blueL),表示系统通知能力。

今日已发卡:紫色文字显示今日已发通知数,紫色浅底背景(purpleL),表示数据统计。

三个小卡使用不同的颜色主题(金/蓝/紫),既区分了信息类别,又丰富了视觉层次。每个卡都使用 alignItems(HorizontalAlign.Center) 居中对齐内容,borderRadius(8) 统一圆角风格。


十八、Tab0:今日提醒(竖向时间轴)详解

  /** Tab 0:今日提醒(竖向时间轴) */
  @Builder
  tabRemind() {
    Column({ space: 0 }) {
      Row() {
        Text('⏰ 今日提醒').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('新增').fontSize(10).fontColor(COLORS.blue)
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .backgroundColor(COLORS.blueL)
          .borderRadius(9)
          .onClick(() => {
            this.addTime = '';
            this.addTitle = '';
            this.addTag = '生活';
            this.addModal = true;
          })
      }
      .width('100%')
      .margin({ bottom: 12 })

      // 新版时间轴:每行固定高度 72,竖线在固定行高内用 layoutWeight 填满剩余空间,
      // 测量链路稳定,7 条提醒全部按序渲染;时间列/圆点列/卡片统一高度
      ForEach(this.remindList, (item: RemindItem, idx: number) => {
        Row({ space: 10 }) {
          // 左侧时间列(固定宽,顶部对齐,与卡片标题行齐平)
          Column({ space: 3 }) {
            Text(item.time).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text(item.tag).fontSize(8).fontColor(remindColor(item.status))
          }
          .width(44)
          .height('100%')
          .alignItems(HorizontalAlign.Start)
          .padding({ top: 12 })

          // 中间圆点+竖线(行高固定后竖线测量稳定,连续延伸到下一行)
          Column() {
            Circle().width(8).height(8).fill(remindColor(item.status))
            if (idx < this.remindList.length - 1) {
              Column().width(2).layoutWeight(1).backgroundColor(COLORS.line).margin({ top: 2 })
            }
          }
          .width(10)
          .height('100%')
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 14 })

          // 右侧提醒卡(占满整行高度,各卡视觉等高)
          Row() {
            Column({ space: 4 }) {
              Text(item.title).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(item.status === '静默' ? '该时段已静默' : '将播放沙箱自定义铃声')
                .fontSize(9).fontColor(COLORS.text3)
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('编辑').fontSize(9).fontColor(COLORS.sub)
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor(COLORS.chip)
              .borderRadius(8)
              .onClick(() => {
                this.editIdx = idx;
                this.editTime = item.time;
                this.editTitle = item.title;
                this.editModal = true;
              })
          }
          .layoutWeight(1)
          .height('100%')
          .padding(10)
          .backgroundColor(COLORS.card)
          .borderRadius(10)
        }
        .width('100%')
        .height(72)
        .alignItems(VerticalAlign.Top)
        .margin({ bottom: 6 })
      }, (item: RemindItem) => item.time + item.title)
    }
    .width('100%')
  }

提醒页采用竖向时间轴布局,这是整个应用中最具设计巧思的 Tab 之一。代码中保留了一段旧版时间轴的注释,记录了从"布局歧义"到"固定行高"的优化过程。我们来详细分析新版实现:

18.1 旧版时间轴的测量歧义问题

代码注释中详细记录了旧版实现的问题:旧版外层使用 Column({ space: 10 }),行间空白由 space 提供。中间竖线使用 Column().width(2).layoutWeight(1) 来"填满行高",但行高本身又依赖子项内容的高度——两者互相依赖产生了测量歧义。结果是首行被撑满整个可视区域,后续 6 条提醒全部被挤出屏幕,下方出现大面积空白。

这是一个典型的"测量循环依赖"问题:ArkUI 的布局系统需要先知道子项高度才能计算父容器高度,但子项中的竖线又试图通过 layoutWeight 填满父容器的高度。当这种循环依赖存在时,布局引擎的行为变得不可预测。

18.2 固定行高解决方案

新版的核心改进是给每行设置固定高度 height(72),彻底消除了测量歧义。在固定行高下,竖线的 layoutWeight(1) 有了明确的"剩余空间"可以填满——72px 行高减去圆点 8px 和 padding 14px,剩余约 50px 由竖线填充,测量链路完全稳定。

18.3 三列结构设计

每行分为三列:

左侧时间列(宽 44px):显示时间和标签,使用 alignItems(HorizontalAlign.Start) 左对齐,padding({ top: 12 }) 顶部留白与右侧卡片标题行齐平。height('100%') 确保占满整行高度。

中间圆点竖线列(宽 10px):顶部是一个 8x8 的圆点,颜色由 remindColor(item.status) 决定。圆点下方是竖线(Column 宽 2px),使用 layoutWeight(1) 填满剩余高度。最后一条提醒不显示竖线(if (idx < this.remindList.length - 1) 条件判断),避免时间轴尾部出现多余的竖线。

右侧提醒卡layoutWeight(1) 占满剩余宽度):卡片内左侧纵向排列标题和副标题,副标题根据状态显示不同文案——静默状态显示"该时段已静默",其他状态显示"将播放沙箱自定义铃声"。右侧是"编辑"按钮,点击时预填编辑表单并打开编辑弹窗。标题使用 maxLines(1)textOverflow({ overflow: TextOverflow.Ellipsis }) 确保超长文本省略号截断。

18.4 ForEach 键值生成

}, (item: RemindItem) => item.time + item.title)

ForEach 的第三个参数是键值生成函数,使用 item.time + item.title 拼接作为唯一键。这意味着如果两条提醒的时间和标题完全相同,键值会冲突。在实际应用中,更好的做法是加入 idx 或使用唯一 ID,但在这个数据量有限(7 条)的场景下,时间和标题的组合已经足够区分。


十九、Tab1:铃声坊(生成器 + 铃声库)详解

  /** Tab 1:铃声坊(生成器 + 铃声库) */
  @Builder
  tabRing() {
    Column({ space: 10 }) {
      // 生成器参数卡
      Column({ space: 10 }) {
        Row() {
          Text('🎵 铃声生成器').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('写入沙箱').fontSize(9).fontColor(COLORS.gold)
        }
        .width('100%')

        Column({ space: 4 }) {
          Row() {
            Text('音频频率').fontSize(10).fontColor(COLORS.sub)
            Column().layoutWeight(1)
            Text(this.genFreq + ' Hz').fontSize(10).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
          }
          .width('100%')
          Slider({ value: this.genFreq, min: 220, max: 1760, step: 20, style: SliderStyle.OutSet })
            .selectedColor(COLORS.gold)
            .trackColor(COLORS.chip)
            .blockColor(COLORS.gold)
            .onChange((value: number) => {
              this.genFreq = value;
            })
        }
        .width('100%')

        Column({ space: 4 }) {
          Row() {
            Text('铃声时长').fontSize(10).fontColor(COLORS.sub)
            Column().layoutWeight(1)
            Text(this.genDuration + ' ms').fontSize(10).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
          }
          .width('100%')
          Slider({ value: this.genDuration, min: 600, max: 2400, step: 100, style: SliderStyle.OutSet })
            .selectedColor(COLORS.blue)
            .trackColor(COLORS.chip)
            .blockColor(COLORS.blue)
            .onChange((value: number) => {
              this.genDuration = value;
            })
        }
        .width('100%')

        Text('生成铃声到沙箱').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          .width('100%')
          .textAlign(TextAlign.Center)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(COLORS.gold)
          .borderRadius(9)
          .onClick(() => {
            this.createRingByGen();
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.card)
      .borderRadius(12)

      // 当前默认铃声卡
      Column({ space: 6 }) {
        Row() {
          Text('🏅 当前默认铃声').fontSize(11).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('sound 字段').fontSize(9).fontColor(COLORS.text3)
        }
        .width('100%')
        Text(this.getSoundValue()).fontSize(8).fontColor(COLORS.sub)
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .width('100%')
          .padding({ left: 8, right: 8, top: 6, bottom: 6 })
          .backgroundColor(COLORS.blueL)
          .borderRadius(6)
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.card)
      .borderRadius(12)
      .border({ width: 1, color: COLORS.goldD })

铃声坊页分为三大部分:生成器参数卡、当前默认铃声卡和铃声库列表。我们来逐一分析:

19.1 铃声生成器参数卡

生成器卡包含两个 Slider 滑块和一个生成按钮:

频率滑块:范围 220~1760Hz,步进 20Hz。滑块轨道使用 trackColor(COLORS.chip)(深色),已选部分使用 selectedColor(COLORS.gold)(金色),滑块手柄使用 blockColor(COLORS.gold)。滑块上方显示当前频率值(金色粗体),实时随滑块拖动更新。onChange 回调将滑动值同步到 genFreq 状态变量。

时长滑块:范围 600~2400ms,步进 100ms。配色方案与频率滑块相同但使用蓝色主题,以视觉上区分两个参数。上方显示当前时长值(蓝色粗体)。

生成按钮:全宽金色实心按钮,文字"生成铃声到沙箱",居中对齐。点击调用 createRingByGen(),使用当前 genFreqgenDuration 参数创建新铃声并写入沙箱。

这个生成器的设计让用户可以直观地通过滑块调节音频参数,并实时看到参数值的变化——频率越高铃声越尖锐,时长越长铃声越持久。这种"参数可视化调节"的交互方式比传统的输入框更直观,也更符合移动端的触控交互习惯。

19.2 当前默认铃声卡

这个卡片以金色边框(border({ width: 1, color: COLORS.goldD }))突出显示,包含两行内容:

第一行是标题"当前默认铃声"和右侧的"sound 字段"标签,让用户知道下方显示的是 NotificationRequest.sound 字段的实际值。

第二行是 getSoundValue() 的返回值,使用 8px 等宽字体(monospace 风格通过 fontSize 和 fontColor 实现),蓝色浅底背景。文本使用 maxLines(2) 限制为两行,超长时省略号截断。这个预览让开发者能直接看到最终传入 NotificationRequest 的 sound URI 值,对于调试和理解 6.1.1 新特性非常有帮助。

19.3 铃声库列表

      // 铃声库
      Row() {
        Text('📚 沙箱铃声库').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text(this.ringList.length + ' 个').fontSize(10).fontColor(COLORS.text3)
      }
      .width('100%')

      ForEach(this.ringList, (item: RingItem, idx: number) => {
        Column({ space: 8 }) {
          Row({ space: 10 }) {
            Text('🎵').fontSize(18)
            Column({ space: 3 }) {
              Row({ space: 6 }) {
                Text(item.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                if (idx === this.currentRingIdx) {
                  Text('默认').fontSize(8).fontColor(COLORS.gold)
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .backgroundColor(COLORS.goldL)
                    .borderRadius(6)
                }
              }
              Text(item.freq + 'Hz · ' + (item.duration / 1000).toFixed(1) + 's · ' + item.size)
                .fontSize(9).fontColor(COLORS.text3)
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text(item.inSandbox ? '沙箱中' : '未导入').fontSize(9)
              .fontColor(item.inSandbox ? COLORS.green : COLORS.orange)
              .padding({ left: 6, right: 6, top: 3, bottom: 3 })
              .backgroundColor(item.inSandbox ? COLORS.greenL : COLORS.chip)
              .borderRadius(7)
          }
          .width('100%')

          Row({ space: 8 }) {
            if (!item.inSandbox) {
              Text('导入沙箱').fontSize(9).fontColor(COLORS.gold)
                .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                .backgroundColor(COLORS.goldL)
                .borderRadius(8)
                .onClick(() => {
                  this.importRingToSandbox(idx);
                })
            }
            Text('设为默认').fontSize(9).fontColor(COLORS.blue)
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .backgroundColor(COLORS.blueL)
              .borderRadius(8)
              .onClick(() => {
                this.setCurrentRing(idx);
              })
            Column().layoutWeight(1)
            Text('删除').fontSize(9).fontColor(COLORS.red)
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .backgroundColor(COLORS.redL)
              .borderRadius(8)
              .onClick(() => {
                this.delIdx = idx;
                this.delModal = true;
              })
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
      }, (item: RingItem) => item.file)

铃声库列表通过 ForEach 渲染所有铃声条目,每个铃声卡片包含两行:

第一行(信息行):左侧是音符 Emoji 图标,中间是铃声名称(粗体标题色)和参数详情(频率、时长、文件大小,三级文本色)。如果当前铃声是默认铃声,名称旁会显示金色"默认"标签。右侧是状态标签——已导入沙箱显示绿色"沙箱中",未导入显示橙色"未导入"。

第二行(操作行):包含三个操作按钮(根据状态可能有四个)。如果铃声未导入沙箱,显示"导入沙箱"按钮(金色浅底),点击调用 importRingToSandbox。"设为默认"按钮(蓝色浅底)始终显示,点击调用 setCurrentRing。"删除"按钮(红色浅底)在最右侧,点击设置 delIdx 并打开删除确认弹窗。三个按钮之间使用 Column().layoutWeight(1) 弹性间隔将删除按钮推到最右。

铃声库列表的设计体现了"状态驱动 UI"的理念——按钮的有无和状态标签的颜色完全由 item.inSandbox 决定,当用户点击"导入沙箱"后,inSandbox 变为 true,“导入沙箱"按钮消失,状态标签变为绿色"沙箱中”,文件大小从"—"变为实际数值,所有变化由 @Observed 自动驱动。


二十、Tab2:通知台(构造通知 + 发布历史)详解

  /** Tab 2:通知台(构造通知 + 发布历史) */
  @Builder
  tabNotify() {
    Column({ space: 10 }) {
      // 通知构造卡
      Column({ space: 10 }) {
        Text('📣 通知构造器').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          .width('100%')

        Column({ space: 4 }) {
          Text('通知标题').fontSize(10).fontColor(COLORS.sub)
          TextInput({ text: this.ntTitle, placeholder: '请输入通知标题' })
            .fontSize(11)
            .fontColor(COLORS.title)
            .backgroundColor(COLORS.chip)
            .borderRadius(8)
            .onChange((value: string) => {
              this.ntTitle = value;
            })
        }
        .width('100%')

        Column({ space: 4 }) {
          Text('通知内容').fontSize(10).fontColor(COLORS.sub)
          TextInput({ text: this.ntText, placeholder: '请输入通知内容' })
            .fontSize(11)
            .fontColor(COLORS.title)
            .backgroundColor(COLORS.chip)
            .borderRadius(8)
            .onChange((value: string) => {
              this.ntText = value;
            })
        }
        .width('100%')

        // 铃声选择横滚
        Column({ space: 6 }) {
          Text('选择铃声(未导入沙箱将自动生成)').fontSize(10).fontColor(COLORS.sub)
          Scroll() {
            Row({ space: 8 }) {
              ForEach(this.ringList, (item: RingItem, idx: number) => {
                Text(item.name).fontSize(10)
                  .fontColor(idx === this.currentRingIdx ? COLORS.title : COLORS.sub)
                  .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                  .backgroundColor(idx === this.currentRingIdx ? COLORS.gold : COLORS.chip)
                  .borderRadius(14)
                  .onClick(() => {
                    this.setCurrentRing(idx);
                  })
              }, (item: RingItem) => item.name)
            }
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .width('100%')
        }
        .width('100%')

        // sound 字段代码预览卡
        Column({ space: 4 }) {
          Text("NotificationRequest.sound 字段(HarmonyOS 6.1.1 沙箱铃声)")
            .fontSize(9).fontColor(COLORS.text3)
          Text("sound: '" + this.getSoundValue() + "'")
            .fontSize(8)
            .fontColor(COLORS.gold)
            .fontFamily('monospace')
            .maxLines(3)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
            .width('100%')
            .padding(8)
            .backgroundColor(COLORS.blueL)
            .borderRadius(6)
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)

通知台页分为通知构造器和发布历史两部分。我们先分析通知构造器:

20.1 通知标题与内容输入

通知构造器使用两个 TextInput 组件接收用户输入的通知标题和内容。TextInputtext 参数绑定到 ntTitlentText 状态变量,onChange 回调将输入值同步回状态变量。这种双向绑定确保了用户输入实时反映到状态,发送通知时读取的是最新值。两个输入框都使用 COLORS.chip(深色芯片色)作为背景,borderRadius(8) 统一圆角。

20.2 铃声选择横滚列表

铃声选择使用水平滚动的标签列表实现。Scroll 容器设置 scrollable(ScrollDirection.Horizontal) 实现水平滚动,scrollBar(BarState.Off) 隐藏滚动条。内部使用 ForEach 渲染所有铃声名称标签,选中铃声的标签使用金色背景和标题色文字,未选中使用芯片色背景和副文本色文字。点击标签调用 setCurrentRing(idx) 切换默认铃声。

这种横滚标签的设计比下拉选择器更直观——用户可以滑动浏览所有可选铃声,点击即选中,视觉反馈即时。标签使用 borderRadius(14) 形成胶囊形,符合移动端标签的设计惯例。

20.3 sound 字段代码预览卡

这是一个技术亮点——在 UI 中直接展示 NotificationRequest.sound 字段的最终值。预览卡使用蓝色浅底背景,金色等宽字体(fontFamily('monospace')),显示格式为 sound: 'uri::...'。这个预览让开发者能直观理解 6.1.1 新特性的 URI 格式,当用户切换铃声时预览内容自动更新。

20.4 发送按钮与授权提示

        Row({ space: 8 }) {
          Text('发送沙箱铃声通知').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 })
            .backgroundColor(COLORS.blue)
            .borderRadius(9)
            .onClick(() => {
              this.publishNotice();
            })
          Text('系统铃声对比').fontSize(11).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 })
            .backgroundColor(COLORS.goldL)
            .borderRadius(9)
            .onClick(() => {
              this.publishSystemNotice();
            })
        }
        .width('100%')

        if (!this.granted) {
          Text('⚠️ 通知未授权,发送将失败,点击头部"未授权"先申请授权')
            .fontSize(9).fontColor(COLORS.red).width('100%')
        }
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.card)
      .borderRadius(12)

两个并排按钮:左侧蓝色实心按钮"发送沙箱铃声通知"调用 publishNotice()(携带自定义 sound 字段),右侧金色浅底按钮"系统铃声对比"调用 publishSystemNotice()(不携带 sound 字段)。两个按钮各占 layoutWeight(1) 等宽,形成 A/B 对比测试的交互入口。

grantedfalse(未授权)时,按钮下方显示红色警告文字,提示用户先到头部申请授权。这种"前置校验+引导"的设计避免了用户在未授权状态下点击发送后才发现失败的糟糕体验。

20.5 发布历史列表

      // 发布历史
      Row() {
        Text('📜 发布历史').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text(this.logList.length + ' 条').fontSize(10).fontColor(COLORS.text3)
      }
      .width('100%')

      ForEach(this.logList, (item: NoticeLog, idx: number) => {
        Row({ space: 10 }) {
          Column({ space: 3 }) {
            Text(item.time).fontSize(10).fontColor(COLORS.text3)
            Text(item.status).fontSize(9).fontColor(logColor(item.status))
          }
          .width(44)
          .alignItems(HorizontalAlign.Center)

          Column({ space: 3 }) {
            Row({ space: 6 }) {
              Text(item.title).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text('🎵 ' + item.ring).fontSize(8).fontColor(COLORS.gold)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .width('100%')
            Text(item.text).fontSize(9).fontColor(COLORS.sub)
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.card)
        .borderRadius(10)
      }, (item: NoticeLog, idx: number) => item.time + item.title + idx)

发布历史列表通过 ForEach 渲染所有通知发布记录。每条记录分为左右两部分:

左侧信息列(宽 44px):显示发布时间和状态标签。状态颜色由 logColor(item.status) 决定——"已送达"为绿色,"未授权"为红色。

右侧内容区layoutWeight(1)):第一行是通知标题(粗体)和铃声名称(金色小标签),第二行是通知正文。所有文本都使用 maxLines(1)textOverflow({ overflow: TextOverflow.Ellipsis }) 确保单行显示,超长省略号截断。

ForEach 的键值生成函数使用 item.time + item.title + idx 拼接,加入了 idx 确保即使时间和标题相同的记录也有唯一键值。这是对提醒页键值生成策略的改进——发布历史可能存在标题和时间相同的不同记录,加入索引避免了键值冲突。


二十一、Tab3:通知渠道(左色条开关列表)详解

  /** Tab 3:通知渠道(左色条开关列表) */
  @Builder
  tabChannel() {
    Column({ space: 10 }) {
      Row() {
        Text('📡 通知渠道管理').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('SlotType').fontSize(10).fontColor(COLORS.text3)
      }
      .width('100%')

      ForEach(this.channelList, (item: ChannelItem, idx: number) => {
        Row({ space: 10 }) {
          // 左色条
          Column()
            .width(3)
            .height(44)
            .borderRadius(2)
            .backgroundColor(item.on ? COLORS.blue : COLORS.text3)

          Text(item.icon).fontSize(18)

          Column({ space: 3 }) {
            Text(item.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text(item.desc).fontSize(9).fontColor(COLORS.text3)
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
            Text('槽位类型:' + item.slot).fontSize(8).fontColor(COLORS.sub)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Column({ space: 5 }) {
            Toggle({ type: ToggleType.Switch, isOn: item.on })
              .selectedColor(COLORS.blue)
              .onChange((isOn: boolean) => {
                item.on = isOn;
              })
            Text('今日 ' + item.count + ' 条').fontSize(8).fontColor(COLORS.text3)
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.card)
        .borderRadius(10)
      }, (item: ChannelItem) => item.slot)
    }
    .width('100%')
  }

通知渠道页采用"左色条+开关列表行"的布局方式,这是一种在移动端设置页中常见的布局模式。每个渠道条目从左到右包含四个元素:

左色条:宽 3px、高 44px 的竖条,颜色由 item.on 决定——开启时为通知蓝,关闭时为三级文本灰色。色条的作用是提供"一眼可辨"的状态视觉线索——用户快速扫过列表时,蓝色色条代表开启的渠道,灰色色条代表关闭的渠道,无需逐行阅读文字。

Emoji 图标:18px 的 Emoji 图标(💬社交通信、📦服务提醒、📰资讯通知、❤️内容互动),为每个渠道提供视觉标识。

渠道信息区:三行文字——渠道名称(粗体)、描述(三级文本色,单行省略)、槽位类型(副文本色,显示 SlotType 枚举值)。显示槽位类型的技术值是一个面向开发者的设计——它让开发者能直观看到每种渠道对应的 notificationManager.SlotType 枚举名,方便开发调试。

开关与计数:右侧是 Toggle 开关组件(Switch 样式,选中蓝色),isOn 绑定 item.ononChange 回调更新 item.on。开关下方显示今日该渠道的通知数。

ForEach 的键值使用 item.slot(SlotType 枚举值),由于每个渠道的 slot 值唯一,这是合理的键值选择。当用户切换 Toggle 时,item.on 更新,由于 ChannelItem@Observed 标记,左色条颜色和开关状态会自动同步更新。


二十二、Tab4:通知统计(Canvas 折线图 + 数据小卡)详解

  /** Tab 4:通知统计(Canvas 折线图 + 数据小卡) */
  @Builder
  tabStat() {
    Column({ space: 10 }) {
      Column({ space: 8 }) {
        Row() {
          Text('📈 近 7 天通知量').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('峰值 26 条').fontSize(9).fontColor(COLORS.text3)
        }
        .width('100%')
        Canvas(this.statCtx)
          .width('100%')
          .height(180)
          .onReady(() => {
            this.canvasReady = true;
            this.drawLineChart();
          })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.card)
      .borderRadius(12)

      Row({ space: 8 }) {
        Column({ space: 3 }) {
          Text('今日已发').fontSize(9).fontColor(COLORS.text3)
          Text(this.sentToday + ' 条').fontSize(13).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor(COLORS.card)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)

        Column({ space: 3 }) {
          Text('周累计').fontSize(9).fontColor(COLORS.text3)
          Text('122 条').fontSize(13).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor(COLORS.card)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)

        Column({ space: 3 }) {
          Text('沙箱铃声').fontSize(9).fontColor(COLORS.text3)
          Text(this.sandboxCount + ' 个').fontSize(13).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor(COLORS.card)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)

        Column({ space: 3 }) {
          Text('送达率').fontSize(9).fontColor(COLORS.text3)
          Text('98%').fontSize(13).fontColor(COLORS.purple).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor(COLORS.card)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
    }
    .width('100%')
  }

统计页分为 Canvas 折线图和四宫格数据小卡两部分:

22.1 Canvas 折线图容器

折线图使用 Canvas(this.statCtx) 组件,传入之前创建的 CanvasRenderingContext2D 实例。Canvas 宽度为 100%(充满父容器),高度固定 180px。onReady 回调在 Canvas 初始化完成后触发,将 canvasReady 设为 true 并立即调用 drawLineChart() 进行首次绘制。

onReady 回调是 Canvas 组件的关键生命周期——在 onReady 之前,Canvas 的绘制上下文尚未就绪,任何绘制操作都会失败。因此 canvasReady 标志在定时器中作为"是否可以安全重绘"的判断条件,确保不会在 Canvas 未就绪时调用 drawLineChart()

折线图上方显示标题"近 7 天通知量"和"峰值 26 条"的标注,让用户在查看图表前就了解数据概要。

22.2 四宫格数据小卡

折线图下方是四个等宽的数据小卡(各占 layoutWeight(1)):

  • 今日已发:蓝色数字,值来自 this.sentToday 状态变量(动态更新)
  • 周累计:绿色数字,固定值 122 条
  • 沙箱铃声:金色数字,值来自 this.sandboxCount 状态变量(动态更新)
  • 送达率:紫色数字,固定值 98%

四个小卡使用不同的颜色主题(蓝/绿/金/紫),与头部状态条的三个数据卡形成呼应。固定值和动态值混合展示——动态值让用户看到实时数据,固定值提供基准参考(如送达率 98% 表示系统稳定性良好)。


二十三、Canvas 折线图绘制方法详解

  /** Canvas 折线图绘制(近 7 天通知量,呼吸动画联动) */
  drawLineChart() {
    const ctx = this.statCtx;
    const w = 320;
    const h = 180;
    const pad = 30;
    const stepX = (w - pad * 2) / (NOTIFY_WEEK.length - 1);
    ctx.clearRect(0, 0, w, h);

    // 背景网格
    ctx.strokeStyle = COLORS.line;
    ctx.lineWidth = 1;
    for (let i = 0; i <= 3; i++) {
      const y = pad + (h - pad * 2) * i / 3;
      ctx.beginPath();
      ctx.moveTo(pad, y);
      ctx.lineTo(w - pad, y);
      ctx.stroke();
    }

    // 渐变填充区域
    const grad = ctx.createLinearGradient(0, pad, 0, h - pad);
    grad.addColorStop(0, COLORS.blue);
    grad.addColorStop(1, 'rgba(108,140,255,0.05)');
    ctx.beginPath();
    ctx.moveTo(pad, h - pad);
    for (let i = 0; i < NOTIFY_WEEK.length; i++) {
      const x = pad + i * stepX;
      const y = h - pad - (NOTIFY_WEEK[i] / NOTIFY_WEEK_MAX) * (h - pad * 2);
      ctx.lineTo(x, y);
    }
    ctx.lineTo(w - pad, h - pad);
    ctx.closePath();
    ctx.fillStyle = grad;
    ctx.fill();

    // 折线
    ctx.beginPath();
    for (let i = 0; i < NOTIFY_WEEK.length; i++) {
      const x = pad + i * stepX;
      const y = h - pad - (NOTIFY_WEEK[i] / NOTIFY_WEEK_MAX) * (h - pad * 2);
      if (i === 0) {
        ctx.moveTo(x, y);
      } else {
        ctx.lineTo(x, y);
      }
    }
    ctx.strokeStyle = COLORS.blue;
    ctx.lineWidth = 2;
    ctx.stroke();

    // 数据点(今日点呼吸动画)
    for (let i = 0; i < NOTIFY_WEEK.length; i++) {
      const x = pad + i * stepX;
      const y = h - pad - (NOTIFY_WEEK[i] / NOTIFY_WEEK_MAX) * (h - pad * 2);
      ctx.beginPath();
      ctx.arc(x, y, i === NOTIFY_WEEK.length - 1 ? (this.breath ? 5 : 3) : 3, 0, Math.PI * 2);
      ctx.fillStyle = COLORS.card;
      ctx.fill();
      ctx.strokeStyle = COLORS.gold;
      ctx.lineWidth = 1.5;
      ctx.stroke();
    }

    // x 轴标签
    ctx.font = '9px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillStyle = COLORS.sub;
    for (let i = 0; i < WDAY_LABELS.length; i++) {
      ctx.fillText(WDAY_LABELS[i], pad + i * stepX, h - pad + 14);
    }
  }

drawLineChart 方法是整个应用中 Canvas 绘图能力的集中展示。它使用标准的 Canvas 2D API 绘制一幅包含五个视觉层次的折线图。我们来逐层分析:

23.1 坐标系统与清屏

const ctx = this.statCtx;
const w = 320;
const h = 180;
const pad = 30;
const stepX = (w - pad * 2) / (NOTIFY_WEEK.length - 1);
ctx.clearRect(0, 0, w, h);

首先定义画布尺寸(320×180)和内边距(30px)。stepX 是相邻数据点之间的水平间距,计算公式为 (画布宽度 - 左右内边距) / (数据点数 - 1),确保 7 个数据点均匀分布在画布有效区域内。

ctx.clearRect(0, 0, w, h) 清除整个画布——这是每次重绘前的必要操作。由于定时器每秒调用 drawLineChart(),如果不先清屏,新的绘制会叠加在旧的绘制之上,导致画面混乱。

23.2 背景网格绘制

ctx.strokeStyle = COLORS.line;
ctx.lineWidth = 1;
for (let i = 0; i <= 3; i++) {
  const y = pad + (h - pad * 2) * i / 3;
  ctx.beginPath();
  ctx.moveTo(pad, y);
  ctx.lineTo(w - pad, y);
  ctx.stroke();
}

背景网格由 4 条水平线组成,将图表区域分为 3 等份。每条线的 Y 坐标为 pad + (h - pad * 2) * i / 3,从顶部内边距到底部内边距均匀分布。线条颜色使用 COLORS.line(深色),宽度 1px,在深色背景下形成若隐若现的网格参考线。

每次绘制线条时都调用 ctx.beginPath() 开始新路径,然后 moveTo 设置起点、lineTo 设置终点、stroke 描边。这是 Canvas 2D 的标准路径绘制流程——beginPath 确保每次绘制的是独立的线段而非连接到前一路径。

23.3 渐变填充区域

const grad = ctx.createLinearGradient(0, pad, 0, h - pad);
grad.addColorStop(0, COLORS.blue);
grad.addColorStop(1, 'rgba(108,140,255,0.05)');
ctx.beginPath();
ctx.moveTo(pad, h - pad);
for (let i = 0; i < NOTIFY_WEEK.length; i++) {
  const x = pad + i * stepX;
  const y = h - pad - (NOTIFY_WEEK[i] / NOTIFY_WEEK_MAX) * (h - pad * 2);
  ctx.lineTo(x, y);
}
ctx.lineTo(w - pad, h - pad);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();

渐变填充区域是折线下方的半透明色块,从顶部的蓝色渐变到底部的近透明蓝色。createLinearGradient(0, pad, 0, h - pad) 创建一个从图表顶部到底部的垂直线性渐变,addColorStop(0, COLORS.blue) 设置顶部为通知蓝,addColorStop(1, 'rgba(108,140,255,0.05)') 设置底部为几乎透明的蓝色。

绘制路径从左下角开始,沿折线数据点行进,到右下角结束,形成封闭的多边形区域。每个数据点的 Y 坐标计算公式为 h - pad - (数据值 / 最大值) * (图表高度),将数据值映射为画布坐标——值越大 Y 越小(越靠上),值越小 Y 越大(越靠下)。

ctx.fill() 使用渐变填充这个封闭路径。渐变填充区域让折线图不再单薄,增加了视觉层次感和数据面积的直观感知。

23.4 折线本体绘制

ctx.beginPath();
for (let i = 0; i < NOTIFY_WEEK.length; i++) {
  const x = pad + i * stepX;
  const y = h - pad - (NOTIFY_WEEK[i] / NOTIFY_WEEK_MAX) * (h - pad * 2);
  if (i === 0) {
    ctx.moveTo(x, y);
  } else {
    ctx.lineTo(x, y);
  }
}
ctx.strokeStyle = COLORS.blue;
ctx.lineWidth = 2;
ctx.stroke();

折线本体的绘制与填充区域的路径类似,但不闭合也不填充。第一个数据点使用 moveTo 设置路径起点,后续数据点使用 lineTo 连接。最后使用 COLORS.blue(通知蓝)描边,线宽 2px。

折线是图表的核心信息载体——它直接展示了数据的变化趋势。从数据 [12, 18, 9, 22, 15, 26, 20] 可以看出,折线呈现波动上升趋势,周三(9 条)是低谷,周六(26 条)是峰值。

23.5 数据点与呼吸动画

for (let i = 0; i < NOTIFY_WEEK.length; i++) {
  const x = pad + i * stepX;
  const y = h - pad - (NOTIFY_WEEK[i] / NOTIFY_WEEK_MAX) * (h - pad * 2);
  ctx.beginPath();
  ctx.arc(x, y, i === NOTIFY_WEEK.length - 1 ? (this.breath ? 5 : 3) : 3, 0, Math.PI * 2);
  ctx.fillStyle = COLORS.card;
  ctx.fill();
  ctx.strokeStyle = COLORS.gold;
  ctx.lineWidth = 1.5;
  ctx.stroke();
}

每个数据点绘制为一个圆形——内部填充 COLORS.card(卡片色,与背景一致),外部描边 COLORS.gold(金色),线宽 1.5px。这种"空心圆"设计让数据点在折线上清晰可见,金色描边与蓝色折线形成色彩对比。

最后一个数据点(今日)的半径根据 this.breath 在 5px 和 3px 之间切换——当 breathtrue 时半径 5px(放大),为 false 时半径 3px(缩小)。这就是"呼吸动画联动重绘"的实现——每秒钟 breath 翻转一次,drawLineChart() 被重新调用,今日数据点的半径在大小之间脉动,形成"今日数据在呼吸"的视觉效果,吸引用户关注最新数据。

23.6 x 轴标签绘制

ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = COLORS.sub;
for (let i = 0; i < WDAY_LABELS.length; i++) {
  ctx.fillText(WDAY_LABELS[i], pad + i * stepX, h - pad + 14);
}

最后绘制 x 轴标签——周一到周日的中文缩写。字体设为 9px sans-serif,对齐方式为居中(textAlign = 'center'),颜色为副文本色。每个标签的 X 坐标与对应数据点的 X 坐标一致,Y 坐标在图表底部下方 14px 处。

textAlign = 'center' 是关键设置——它让文本以给定的 X 坐标为中心点居中对齐,确保标签与数据点垂直对齐。如果不设置,默认的左对齐会导致标签偏移到数据点右侧。


二十四、Tab5:我的(授权大卡 + 功能清单 + 特性说明)详解

24.1 授权渐变大卡

  /** Tab 5:我的(授权大卡 + 功能清单 + 特性说明) */
  @Builder
  tabMine() {
    Column({ space: 10 }) {
      // 授权渐变大卡
      Column({ space: 8 }) {
        Row() {
          Column({ space: 2 }) {
            Text('🔔 铃语会员').fontSize(16).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
            Text('智能提醒助手 · 已陪伴 128 天').fontSize(9).fontColor('rgba(255,255,255,0.75)')
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text('🎵').fontSize(26).opacity(this.breath ? 1 : 0.6)
        }
        .width('100%')

        Divider().strokeWidth(1).color('rgba(255,255,255,0.18)')

        Row() {
          Column({ space: 3 }) {
            Text(this.granted ? '已授权' : '未授权').fontSize(11)
              .fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
            Text('通知权限').fontSize(8).fontColor('rgba(255,255,255,0.6)')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column({ space: 3 }) {
            Text(this.sentToday + ' 条').fontSize(11)
              .fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
            Text('今日已发').fontSize(8).fontColor('rgba(255,255,255,0.6)')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column({ space: 3 }) {
            Text(this.sandboxCount + ' 个').fontSize(11)
              .fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
            Text('沙箱铃声').fontSize(8).fontColor('rgba(255,255,255,0.6)')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column({ space: 3 }) {
            Text(this.ringList.length + ' 个').fontSize(11)
              .fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
            Text('铃声库').fontSize(8).fontColor('rgba(255,255,255,0.6)')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%')

        if (!this.granted) {
          Text('开启通知授权,体验沙箱自定义铃声')
            .fontSize(10).fontColor(COLORS.white)
            .width('100%')
            .textAlign(TextAlign.Center)
            .padding({ top: 7, bottom: 7 })
            .backgroundColor('rgba(245,196,81,0.9)')
            .borderRadius(9)
            .onClick(() => {
              this.requestAuth();
            })
        }
      }
      .width('100%')
      .padding(14)
      .borderRadius(14)
      .linearGradient({
        angle: 135,
        colors: [[COLORS.blueD, 0], [COLORS.purple, 1]]
      })

我的页的授权渐变大卡是整个应用视觉设计的"高光时刻"。卡片使用 linearGradient 实现从蓝色(blueD)到紫色(purple)的 135 度角对角渐变背景,在深色页面中形成醒目的视觉焦点。

卡片内容分为三层:

第一层(品牌信息):左侧是应用名"铃语会员"(白色粗体)和副标题"智能提醒助手 · 已陪伴 128 天"(半透明白色),右侧是一个 26px 的音符 Emoji,透明度随 breath 在 1 和 0.6 之间切换,形成呼吸效果。

分割线:使用半透明白色(rgba(255,255,255,0.18))的 Divider,在渐变背景上形成柔和的分隔。

第二层(四宫格数据):四个等宽列分别显示通知权限状态、今日已发数、沙箱铃声数和铃声库总数。所有数字使用金色粗体,标签使用半透明白色,在蓝紫渐变背景上形成统一的视觉风格。

第三层(授权引导按钮):当未授权时显示金色半透明背景的引导按钮,点击调用 requestAuth()。这个按钮在授权成功后自动消失(条件渲染),提供了"未授权→引导授权→授权成功"的完整闭环。

24.2 功能清单

      // 功能清单
      ForEach(FUNC_LIST, (item: FuncItem) => {
        Row({ space: 10 }) {
          Text(item.icon).fontSize(16)
          Column({ space: 3 }) {
            Text(item.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text(item.desc).fontSize(9).fontColor(COLORS.text3)
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text(item.tag).fontSize(8).fontColor(COLORS.sub)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .backgroundColor(COLORS.chip)
            .borderRadius(6)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.card)
        .borderRadius(10)
      }, (item: FuncItem) => item.name)

功能清单使用 ForEach 渲染 FUNC_LIST 中的 5 个功能条目。每行包含 Emoji 图标、功能名称、功能描述和右侧的标签(如"6.1.1"、“安全”、“校验"等)。标签使用芯片色背景和小圆角,视觉上类似"版本徽章"或"状态标签”。

24.3 特性说明卡

      // 特性说明卡
      Column({ space: 6 }) {
        Text('📖 HarmonyOS 6.1.1 新特性说明').fontSize(12)
          .fontColor(COLORS.gold).fontWeight(FontWeight.Bold).width('100%')
        Text('NotificationRequest 的 sound 字段在 6.1.1 前仅支持 resources/rawfile 预置音频;'
          + '升级后支持应用沙箱内的音频文件(网络下载或用户生成),'
          + "须放沙箱 EL1 区域 files 目录,并以 'uri::' + fileUri.getUriFromPath(路径) 格式传入。")
          .fontSize(9).fontColor(COLORS.sub).lineHeight(14).width('100%')
        Text('对应《鸿蒙HarmonyOS 6应用开发:从零基础到App上线》'
          + '9.2.1 简单消息小节:构造通知请求时增加填写 sound 字段并传入沙箱音频路径,'
          + '发送通知即可验证自定义铃声。')
          .fontSize(9).fontColor(COLORS.sub).lineHeight(14).width('100%')
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.goldL)
      .borderRadius(12)
      .alignItems(HorizontalAlign.Start)

特性说明卡使用金色浅底背景(goldL),包含三段文字:标题、特性说明和技术参考。第一段详细解释了 6.1.1 新特性的前后对比——之前仅支持 resources/rawfile 预置音频,之后支持沙箱内动态音频。第二段引用了《鸿蒙HarmonyOS 6应用开发》一书的 9.2.1 章节,为读者提供了延伸学习路径。卡片使用 alignItems(HorizontalAlign.Start) 确保文本左对齐,lineHeight(14) 设置行高提升可读性。


二十五、月度柱状图通用卡详解

  /** 月度通知量柱状图(通用图表卡) */
  @Builder
  chartCard() {
    Column({ space: 10 }) {
      Row() {
        Text('📊 月度通知发送量').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('单位:条').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      Row({ space: 8 }) {
        ForEach(MONTH_IDX, (i: number) => {
          Column({ space: 5 }) {
            Column()
              .width(16)
              .height(this.breath && i === MONTH_IDX.length - 1 ?
                Math.max(20, NOTICE_VAL[i] / NOTICE_MAX * 110 + 4) :
                Math.max(20, NOTICE_VAL[i] / NOTICE_MAX * 110))
              .borderRadius(4)
              .backgroundColor(COLORS.blue)
            Text(MONTH_NAME[i]).fontSize(8).fontColor(COLORS.text3)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }, (i: number) => 'm' + i)
      }
      .width('100%')
      .alignItems(VerticalAlign.Bottom)
      .height(150)
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS.card)
    .borderRadius(12)
  }

chartCard 是一个通用的月度通知量柱状图卡片,在所有 Tab 的内容底部都会渲染。与统计页的 Canvas 折线图不同,这个柱状图完全使用 ArkUI 的布局组件(Column 的高度属性)实现,无需 Canvas 绘制。

每个柱子由 Column 组件的高度来表示数据值,计算公式为 Math.max(20, NOTICE_VAL[i] / NOTICE_MAX * 110)Math.max(20, ...) 确保最小高度为 20px,避免数据值过小时柱子不可见。最大值 NOTICE_MAX = 450 对应 110px 高度,因此柱子高度范围为 20~110px。

最后一个柱子(8 月,最新数据)有呼吸动画效果——当 breathtrue 时高度增加 4px(NOTICE_VAL[i] / NOTICE_MAX * 110 + 4),形成轻微的脉动效果。这种"最新数据呼吸"的设计与折线图的"今日点呼吸"一脉相承,让用户关注最新数据。

外层 Row 设置 alignItems(VerticalAlign.Bottom) 确保所有柱子从底部对齐,高度差异在顶部体现。height(150) 为整个柱状图区域提供了固定的布局空间。每个柱子使用 layoutWeight(1) 等宽分布,下方显示月份标签。

这种纯 CSS(布局组件)实现的柱状图虽然不如 Canvas 灵活,但代码更简洁,且柱子可以直接绑定 @State 变量实现响应式更新。对于简单的柱状图场景,这是一种比 Canvas 更轻量的选择。


二十六、底部 Tab 栏详解

  /** 底部 Tab 栏 */
  @Builder
  tabBar() {
    Row() {
      ForEach(TAB_LIST, (t: TabMeta, idx: number) => {
        Column({ space: 3 }) {
          Text(t.icon)
            .fontSize(this.currentTab === idx ? 20 : 17)
            .opacity(this.currentTab === idx ? 1 : 0.65)
          Text(t.label)
            .fontSize(9)
            .fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
            .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 7, bottom: 7 })
        .onClick(() => {
          this.currentTab = idx;
        })
      }, (t: TabMeta) => t.label)
    }
    .width('100%')
    .backgroundColor(COLORS.card)
    .border({ width: { top: 1 }, color: COLORS.line })
  }

底部 Tab 栏使用单排 6 个 Tab 的布局,每个 Tab 通过 layoutWeight(1) 等宽分布。选中态和非选中态的视觉差异通过三个维度体现:

图标大小:选中 Tab 的 Emoji 图标字号为 20px,非选中为 17px,通过 3px 的字号差异提供"放大选中"的视觉反馈。

图标透明度:选中 Tab 的透明度为 1(完全不透明),非选中为 0.65(半透明),通过透明度差异降低非选中 Tab 的视觉权重。

文字样式:选中 Tab 的文字使用 COLORS.tabOn(通知蓝)和粗体,非选中使用 COLORS.text3(三级文本灰色)和常规字重。颜色和字重的双重差异确保了选中态的辨识度。

点击 Tab 时只需将 currentTab 设为对应索引,由于 currentTab@State 变量,变化会自动触发 build() 中的条件渲染链切换 Tab 内容,同时 Tab 栏自身的样式也会更新。这种"单一状态驱动全局切换"的设计简洁高效。

Tab 栏底部使用 border({ width: { top: 1 }, color: COLORS.line }) 添加一条顶部边框线,在视觉上将 Tab 栏与内容区域分隔。背景色使用 COLORS.card(卡片色),与内容区域的深色背景形成轻微的层次对比。


二十七、弹窗系统详解

27.1 通用遮罩层

  /** 通用弹窗遮罩 */
  @Builder
  modalOverlay(onClose: () => void) {
    Stack() {
      Column().width('100%').height('100%').backgroundColor(COLORS.mask)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
    .onClick(() => onClose())
  }

modalOverlay 是一个可复用的遮罩层 @Builder 函数,接收一个 onClose 回调参数。它渲染一个全屏的半透明黑色背景(COLORS.mask = rgba(0,0,0,0.55)),点击任意位置触发 onClose 回调关闭弹窗。

这种"遮罩+回调"的设计模式将遮罩的关闭逻辑与弹窗的具体内容解耦——任何弹窗都可以复用这个遮罩,只需传入自己的关闭逻辑。Stack 容器配合 alignContent(Alignment.Center) 确保后续弹窗内容居中显示。

27.2 新增提醒弹窗

  /** 新增提醒弹窗 */
  @Builder
  panelAdd(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 12 }) {
        Text('新增提醒').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

        Column({ space: 4 }) {
          Text('时间(如 08:00)').fontSize(10).fontColor(COLORS.sub)
          TextInput({ text: this.addTime, placeholder: '08:00' })
            .fontSize(11)
            .fontColor(COLORS.title)
            .backgroundColor(COLORS.chip)
            .borderRadius(8)
            .onChange((value: string) => {
              this.addTime = value;
            })
        }
        .width('100%')

        Column({ space: 4 }) {
          Text('提醒标题').fontSize(10).fontColor(COLORS.sub)
          TextInput({ text: this.addTitle, placeholder: '如:晨间打卡' })
            .fontSize(11)
            .fontColor(COLORS.title)
            .backgroundColor(COLORS.chip)
            .borderRadius(8)
            .onChange((value: string) => {
              this.addTitle = value;
            })
        }
        .width('100%')

        Column({ space: 6 }) {
          Text('标签').fontSize(10).fontColor(COLORS.sub)
          Row({ space: 8 }) {
            ForEach(REMIND_TAGS, (tag: string) => {
              Text(tag).fontSize(10)
                .fontColor(this.addTag === tag ? COLORS.title : COLORS.sub)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                .backgroundColor(this.addTag === tag ? COLORS.blue : COLORS.chip)
                .borderRadius(13)
                .onClick(() => {
                  this.addTag = tag;
                })
            }, (tag: string) => tag)
          }
          .width('100%')
        }
        .width('100%')

        Row({ space: 10 }) {
          Text('取消').fontSize(12).fontColor(COLORS.sub)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 })
            .backgroundColor(COLORS.chip)
            .borderRadius(9)
            .onClick(() => onClose())
          Text('保存').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 })
            .backgroundColor(COLORS.blue)
            .borderRadius(9)
            .onClick(() => {
              this.saveRemind();
            })
        }
        .width('100%')
      }
      .width('78%')
      .padding(16)
      .backgroundColor(COLORS.card)
      .borderRadius(14)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }

新增提醒弹窗是三个弹窗中功能最丰富的一个,包含三个表单项和两个操作按钮:

时间输入TextInput 绑定 addTime 状态变量,placeholder 提示"08:00"格式。

标题输入TextInput 绑定 addTitle 状态变量,placeholder 提示"如:晨间打卡"。

标签选择器:使用 ForEach 渲染 REMIND_TAGS(生活/工作/健康/运动)四个标签胶囊,选中标签使用蓝色背景和标题色文字,未选中使用芯片色背景和副文本色文字。点击标签更新 addTag 状态变量。这种"胶囊选择器"比下拉选择更直观,用户可以一眼看到所有选项。

底部按钮:左侧"取消"按钮(芯片色背景)调用 onClose 关闭弹窗,右侧"保存"按钮(蓝色实心背景)调用 saveRemind() 保存提醒。两个按钮各占 layoutWeight(1) 等宽,形成"主操作+次操作"的标准布局。

弹窗内容容器宽度为 78%,使用 padding(16) 内边距和 borderRadius(14) 圆角,背景色为 COLORS.card(卡片色)。整个 Stack 占满屏幕(width('100%')height('100%')),配合 alignContent(Alignment.Center) 实现弹窗内容居中。

27.3 编辑提醒弹窗

  /** 编辑提醒弹窗 */
  @Builder
  panelEdit(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 12 }) {
        Text('编辑提醒').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

        Column({ space: 4 }) {
          Text('时间').fontSize(10).fontColor(COLORS.sub)
          TextInput({ text: this.editTime, placeholder: '08:00' })
            .fontSize(11)
            .fontColor(COLORS.title)
            .backgroundColor(COLORS.chip)
            .borderRadius(8)
            .onChange((value: string) => {
              this.editTime = value;
            })
        }
        .width('100%')

        Column({ space: 4 }) {
          Text('提醒标题').fontSize(10).fontColor(COLORS.sub)
          TextInput({ text: this.editTitle, placeholder: '如:晨间打卡' })
            .fontSize(11)
            .fontColor(COLORS.title)
            .backgroundColor(COLORS.chip)
            .borderRadius(8)
            .onChange((value: string) => {
              this.editTitle = value;
            })
        }
        .width('100%')

        Row({ space: 10 }) {
          Text('取消').fontSize(12).fontColor(COLORS.sub)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 })
            .backgroundColor(COLORS.chip)
            .borderRadius(9)
            .onClick(() => onClose())
          Text('保存').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 })
            .backgroundColor(COLORS.blue)
            .borderRadius(9)
            .onClick(() => {
              this.saveEditRemind();
            })
        }
        .width('100%')
      }
      .width('78%')
      .padding(16)
      .backgroundColor(COLORS.card)
      .borderRadius(14)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }

编辑提醒弹窗与新增弹窗结构相似,但去掉了标签选择器(编辑时不修改标签),只保留时间和标题两个输入项。打开弹窗前会预填 editTimeeditTitle(在提醒页点击"编辑"时设置),让用户看到当前值并在此基础上修改。保存时调用 saveEditRemind(),该方法只更新非空字段,保留了用户未修改的原始值。

27.4 删除铃声确认弹窗

  /** 删除铃声确认弹窗 */
  @Builder
  panelDel(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 12 }) {
        Text('删除铃声').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Text(this.delIdx >= 0 && this.delIdx < this.ringList.length ?
          '确认删除「' + this.ringList[this.delIdx].name + '」吗?将同步清理沙箱文件。' :
          '确认删除该铃声吗?')
          .fontSize(11).fontColor(COLORS.sub).lineHeight(16)
        Row({ space: 10 }) {
          Text('取消').fontSize(12).fontColor(COLORS.sub)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 })
            .backgroundColor(COLORS.chip)
            .borderRadius(9)
            .onClick(() => onClose())
          Text('删除').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 })
            .backgroundColor(COLORS.red)
            .borderRadius(9)
            .onClick(() => {
              this.delRing();
            })
        }
        .width('100%')
      }
      .width('78%')
      .padding(16)
      .backgroundColor(COLORS.card)
      .borderRadius(14)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }

删除铃声确认弹窗是一个"危险操作确认"弹窗,与新增和编辑弹窗的设计差异在于:

提示文案:弹窗显示一条确认信息,包含被删除铃声的名称和"将同步清理沙箱文件"的警示文案。信息文案使用三元条件判断安全地访问 ringList[delIdx],当 delIdx 无效时显示通用提示。

删除按钮配色:删除按钮使用红色实心背景(COLORS.red)和白色文字,与新增/编辑弹窗的蓝色保存按钮形成对比。红色背景传达"危险操作"的语义警示,符合移动端设计规范中"破坏性操作使用红色"的原则。

确认机制:删除操作需要用户主动点击"删除"按钮才会执行,点击遮罩或"取消"按钮只关闭弹窗不执行删除。这种"二次确认"机制防止了用户误触导致的意外数据丢失。


二十八、技术亮点总结

28.1 架构设计层面

  1. 组件化拆分:6 个 Tab 的内容各自封装为独立的 @Builder 函数(tabRemindtabRingtabNotifytabChanneltabStattabMine),弹窗系统也独立封装为三个 @Builder 函数。这种高内聚低耦合的架构使得每个业务模块可以独立开发、测试和迭代。

  2. 声明式条件渲染:通过 if/else if 链和 @State currentTab 实现 Tab 切换,代码逻辑清晰,无需手动管理页面的显示/隐藏。每次只渲染当前激活的 Tab 内容,实现了按需渲染的性能优化。

  3. 单一数据源:所有状态通过 @State 变量管理,UI 完全由状态驱动。通知授权状态、铃声索引、沙箱计数、今日已发数等核心状态在任何 Tab 中变化时,所有引用它们的组件都会自动更新。

28.2 Notification Kit 集成层面

  1. 完整的授权流程:实现了"查询授权→请求授权→拒绝后拉起设置页"的完整两级降级授权流程,覆盖了用户从首次使用到已拒绝授权的所有场景。

  2. 沙箱铃声核心特性:完整实现了 HarmonyOS 6.1.1 的 NotificationRequest.sound 字段沙箱铃声特性——从 WAV 音频生成、EL1 沙箱文件写入、URI 路径转换到通知发布,构成了端到端的技术闭环。

  3. A/B 对比设计:通知台提供"发送沙箱铃声通知"和"系统铃声对比"两个并排按钮,让用户能直观对比自定义铃声和系统铃声的效果差异,体现了 Demo 应用的教学价值。

28.3 Canvas 数据可视化层面

  1. 五层折线图:统计页的 Canvas 折线图包含背景网格、渐变填充区域、折线本体、数据点和 x 轴标签五个视觉层次,通过纯代码绘制实现了接近专业图表库的视觉效果。

  2. 呼吸动画联动重绘:利用定时器每秒翻转 breath 状态并重新调用 drawLineChart(),实现今日数据点半径的脉动效果。这种"状态驱动 Canvas 重绘"的模式是 ArkUI 中 Canvas 动画的经典实现。

  3. 双图表策略:统计页同时使用 Canvas 折线图(精细绘制)和布局组件柱状图(轻量实现),展示了两种数据可视化路径的技术选择。

28.4 视觉设计层面

  1. 深色主题+冷暖对比:整体采用 #0B1026 深蓝底色,搭配通知蓝(#6C8CFF)和铃音金(#F5C451)作为双主色,冷色代表系统通知能力,暖色代表音频个性化,冷暖对比鲜明。

  2. 渐变大卡:我的页的授权渐变大卡使用蓝紫对角渐变,是整个深色页面中的视觉焦点,配合呼吸音符图标形成生动的品牌展示。

  3. 色彩语义化:绿色表示成功/已送达,红色表示失败/删除,橙色表示中间态/未导入,蓝色表示系统通知,金色表示铃声/默认,紫色表示统计数据——每种颜色承载明确的语义。

28.5 工程实践层面

  1. 定时器清理:在 aboutToDisappear 生命周期中调用 clearInterval 清理定时器,防止组件销毁后的内存泄漏。

  2. Canvas 就绪标志:使用 canvasReady 布尔标志在定时器中判断 Canvas 是否已初始化,避免在 Canvas 未就绪时调用绘制方法导致异常。

  3. 容错降级策略:沙箱文件写入使用 try/catch 静默处理异常,授权请求使用 catch 降级为拉起设置页——所有可能失败的操作都有降级方案,应用不会因异常崩溃。

  4. 固定行高消除测量歧义:提醒页时间轴通过给每行设置固定高度 72px,彻底解决了 layoutWeight 与行高互相依赖导致的测量循环问题,代码注释保留了旧版问题分析供后续开发者参考。


二十九、页面功能与技术特性对比总览表

维度 提醒 Tab 铃声坊 Tab 通知台 Tab 渠道 Tab 统计 Tab 我的 Tab
布局方式 竖向时间轴 生成器卡+铃声库列表 表单+代码预览+历史 左色条开关列表 Canvas折线图+数据小卡 渐变大卡+功能清单+说明卡
数据模型 RemindItem RingItem NoticeLog ChannelItem 固定常量 FuncItem
核心操作 新增/编辑提醒 生成/导入/设默认/删除铃声 构造/发布通知 切换渠道开关 查看图表 授权/查看功能
动画效果 今日点呼吸+最新柱呼吸 音符呼吸
颜色主题 蓝/绿/灰状态 金/蓝/绿/橙状态 蓝/金操作 蓝色条/灰条 蓝/绿/金/紫数据 蓝紫渐变+金色文字
数据量 7 条 6 个 2 条历史 4 个渠道 7 天+6 月 5 个功能
特殊组件 Circle圆点/竖线layoutWeight Slider滑块/横滚标签 TextInput/Scroll横滚 Toggle开关 Canvas折线图/Canvas柱状图 linearGradient渐变
Notification能力 沙箱文件写入 通知发布(sound字段) SlotType展示 通知数据统计 授权管理
技术亮点 固定行高消除测量歧义 buildWavBytes音频生成 sound字段uri::格式 SlotType枚举映射 Canvas五层绘制+呼吸重绘 渐变背景+条件渲染授权引导

三十、总结与展望

通过对"铃语·智能提醒助手"源码的逐段深入分析,我们可以看到这款应用在 HarmonyOS ArkUI 框架下展现了多项核心技术能力,其中最突出的当属 Notification Kit 6.1.1 沙箱自定义铃声特性的完整实现。

首先,Notification Kit 的深度集成体现了系统能力的完整闭环。 从应用启动时的授权状态查询(isNotificationEnabled),到用户主动授权请求(requestEnableNotification),再到拒绝后的设置页降级引导(openNotificationSettings),最后到携带沙箱铃声的通知发布(publish + sound 字段),整个通知生命周期被完整覆盖。特别是 sound 字段使用 'uri::' + fileUri.getUriFromPath(沙箱路径) 的格式,这是 HarmonyOS 6.1.1 的核心新特性——在此之前,通知铃声只能使用 resources/rawfile 中的预置音频,无法在运行时动态指定。这一突破为应用的铃声个性化能力打开了全新的空间。

其次,沙箱文件系统的操作体现了 HarmonyOS 安全架构的严谨性。 应用通过 contextConstant.AreaMode.EL1 设置加密区域,使用 fileIo 模块的同步 I/O 方法(openSync/writeSync/closeSync/unlinkSync)管理音频文件,通过 fileUri.getUriFromPath 完成路径到 URI 的转换。整个过程严格遵循"EL1 区域→files 目录→uri::前缀"的路径规范,任何一步不符合规范都会导致铃声无法播放。这种分级加密的安全设计既保证了应用数据的隔离性,又为系统能力(如通知铃声)提供了可信赖的文件来源。

再次,Canvas 2D 绘图能力展现了 ArkUI 的数据可视化潜力。 统计页的折线图通过 CanvasRenderingContext2D 实现了五个视觉层次——背景网格、渐变填充、折线本体、数据点和 x 轴标签——全部使用标准 Canvas 2D API 手动绘制。更巧妙的是,通过定时器驱动的 breath 状态联动 drawLineChart() 重绘,实现了今日数据点半径的呼吸脉动效果。这种"状态驱动 Canvas 重绘"的模式为 ArkUI 中的动态图表提供了可复用的实现范式。

然后,WAV 音频字节生成函数展现了底层编程能力。 buildWavBytes 函数使用 ArrayBufferDataView 从零构造了标准 WAV 文件格式的二进制数据,包含完整的 RIFF/WAVE 文件头(44 字节)和 16bit 单声道 PCM 音频数据。起始包络(前 20ms 淡入)和线性衰减包络的设计确保了生成的铃声在播放时不会有点击声和截断声。这种在应用层直接生成音频文件的能力,使得应用无需依赖任何音频资源文件或第三方库,完全自包含。

最后,布局工程实践体现了对 ArkUI 测量机制的深入理解。 提醒页时间轴从旧版的"测量循环依赖"问题到新版的"固定行高"解决方案,是一个极具教学意义的布局优化案例。旧版中竖线使用 layoutWeight(1) 试图填满行高,而行高又依赖子项内容——两者互相依赖导致测量歧义,首行被撑满整个可视区域。新版通过固定行高 72px 彻底消除了歧义,竖线的 layoutWeight(1) 有了明确的剩余空间可以填充。代码注释保留了旧版的问题分析和注释代码,为后续开发者提供了宝贵的学习参考。

从工程优化的角度来看,这款应用还有一些可以进一步改进的方向:

  1. 数据持久化:当前的模拟数据(提醒列表、铃声库、发布历史等)在应用重启后会重置。实际应用中需要接入 @kit.ArkData 的 Preferences 或关系型数据库实现数据持久化,特别是沙箱铃声文件的管理状态需要持久记录。

  2. 真实音频文件支持buildWavBytes 函数生成的是简单的正弦波音频,音色较为单一。实际应用中可以支持从网络下载真实音频文件或让用户录制音频,通过 @kit.CoreFileKit 的网络下载能力或 @kit.AudioKit 的录音能力实现。

  3. 通知触发时机:当前应用是手动点击按钮发送通知,实际应用中应该结合 @kit.BackgroundTasksKit 的后台提醒能力,在用户设定的时间自动触发通知,实现真正的"智能提醒"。

  4. 长列表性能优化:铃声库和发布历史可能随着使用积累变长,对于超过一定长度的列表,建议使用 LazyForEach 替代 ForEach 实现懒加载,减少首屏渲染开销。

  5. Canvas 尺寸自适应:当前折线图的宽度固定为 320px,在不同屏幕尺寸的设备上可能无法充满 Canvas 组件。可以通过 onAreaChange 回调获取实际渲染尺寸,动态调整画布宽度和数据点间距,实现真正的响应式 Canvas 图表。

  6. 弹窗状态统一管理:三个弹窗状态变量(addModaleditModaldelModal)可以统一为一个枚举状态(如 activeModal: 'add' | 'edit' | 'del' | null),减少变量数量,也避免多个弹窗同时显示的逻辑冲突。

总而言之,"铃语·智能提醒助手"作为一款基于 HarmonyOS ArkUI 的效率工具 Demo,不仅完整展示了 Notification Kit 6.1.1 沙箱自定义铃声这一核心新特性的端到端实现,还涵盖了 Canvas 数据可视化、沙箱文件系统操作、WAV 音频生成、多 Tab 布局设计、呼吸动画编排、弹窗交互系统等多项技术能力。其代码结构清晰、注释详尽、设计理念明确,是学习 HarmonyOS 应用开发,特别是通知能力和 Canvas 绘图的优秀参考案例。

附录:DevEco Studio 创建新项目与查看 SDK 版本

本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。


一、创建新项目

1.1 进入欢迎界面

启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:

  • 新建项目:从头创建新项目
  • 打开项目:打开本地已有项目
  • 克隆仓库:从 Git 等版本控制拉取代码

点击 “新建项目” 按钮,进入项目创建向导。

在这里插入图片描述

1.2 选择项目模板

在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:

类型 说明
应用(Application) 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期
元服务(Atomic Service) 开发轻量级的原子化服务,无需安装即可使用

选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

在这里插入图片描述

1.3 配置项目信息

点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:

配置项 示例值 说明
项目名称(Project name) rollboat 应用的项目名称,建议使用英文命名
包名(Bundle name) com.rollboat.myapplication 应用唯一标识,采用反向域名格式
保存路径(Save location) D:\CodeFactory\rollboat 项目本地存储路径,避免使用中文和空格
兼容 SDK(Compatible SDK) 6.1.1(24) 目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异
模块名称(Module name) entry 主模块名称,默认 entry 为应用入口模块
设备类型(Device types) ☑ Phone 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV

右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

在这里插入图片描述

1.4 完成创建

确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:

  1. 生成项目骨架(Stage 模型目录结构)
  2. 执行 ohpm install 安装依赖
  3. 运行 Hvigor 构建初始化(Build Init

构建日志中显示 “退出代码为 0” 表示项目初始化成功。

在这里插入图片描述

1.5 项目结构概览

创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:

rollboat/
├── .hvigor/                   # Hvigor 构建工具缓存
├── .idea/                     # IDE 配置文件
├── AppScope/                  # 应用级全局配置
│   └── app.json5
├── entry/                     # 主模块(入口模块)
│   ├── src/main/ets/
│   │   ├── entryability/      # Ability 生命周期管理
│   │   │   └── EntryAbility.ets
│   │   └── pages/             # UI 页面
│   │       └── Index.ets      # 首页(默认 Hello World)
│   ├── src/main/resources/    # 资源文件
│   ├── module.json5           # 模块配置
│   └── build-profile.json5    # 构建配置
├── oh_modules/                # OHPM 依赖包
├── build-profile.json5        # 工程构建配置
├── hvigorfile.ts              # Hvigor 构建脚本
└── oh-package.json5           # 包管理配置

核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:

@Entry
@Component
struct Index {
  @State message: string = 'Hello World';

  build() {
    RelativeContainer() {
      Text(this.message)
        .id('HelloWorld')
        .fontSize($r('app.float.page_text_font_size'))
        .fontWeight(FontWeight.Bold)
        .alignRules({
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .onClick(() => {
          this.message = 'Welcome';
        })
    }
    .height('100%')
    .width('100%')
  }
}
关键语法 作用
@Entry 标记为页面入口,可用于路由跳转
@Component 声明为自定义组件
@State 状态变量,数据变更时自动触发 UI 刷新
RelativeContainer 相对布局容器,替代传统线性布局
.onClick() 点击事件,此处点击后文本变为 “Welcome”

打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:

文件 → 设置 → HarmonyOS SDK(或快捷键 Ctrl + Alt + S 搜索 “HarmonyOS SDK”)

在设置面板中,可以看到当前已安装的 SDK 版本信息:

名称 阶段 状态
HarmonyOS 6.1.1 Release ✅ 已安装

界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

在这里插入图片描述

2.2 查看 ArkUI-X SDK(跨平台扩展)

如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:

文件 → 设置 → 语言和框架 → ArkUI-X

在这里可以查看已安装和可选的 ArkUI-X SDK 版本:

版本 SDK 版本号 阶段 状态
API Version 24 6.1.1.100 Release ✅ 已安装
API Version 23 6.1.0.28 Beta1 未安装
API Version 22 6.0.2.112 Release 未安装

安装路径示例:D:\DevTools\ArkUI-X\sdk

说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

在这里插入图片描述


三、小结

步骤 操作 关键点
创建项目 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 使用 Stage 模型 + ArkTS 语言
查看 SDK 设置 → HarmonyOS SDK SDK 已内置,无需手动安装
跨平台扩展 设置 → ArkUI-X 根据需要安装对应 API 版本

至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。


本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐