一、技术前言

在这里插入图片描述

在移动互联网深入日常生活的今天,情感社交应用已经成为连接人与人之间情感纽带的重要数字载体。从情侣打卡到恋爱日记,从纪念日提醒到甜蜜铃声,这类应用的核心使命是将抽象的"爱"转化为可感知、可记录、可回味的具象体验。然而,一款优秀的情侣恋爱日记应用绝不仅仅是"表单 + 列表"的简单堆砌——它需要承载心情记录、纪念日管理、铃声定制、恋爱统计等多个业务模块,同时还要在视觉层面传递温暖、甜蜜、亲密的情感氛围,在交互层面提供流畅、细腻、富有呼吸感的操作体验。

在这里插入图片描述
本文将深入剖析一款基于 HarmonyOS ArkUI 声明式 UI 框架开发的情侣恋爱日记应用——“蜜语”,从架构设计、色彩体系、数据建模、状态管理、Notification Kit 自定义铃声、Canvas 雷达图绘制、弹窗系统等多个维度,全面解读其技术实现方案。

在这里插入图片描述
ArkUI 是华为 HarmonyOS 生态下的核心 UI 开发框架,其声明式编程范式与 React、Flutter、SwiftUI 等现代框架一脉相承,但同时又具备自身独特的组件化机制和状态管理能力。ArkUI 通过 @Entry@Component@Builder@State@Observed 等装饰器,为开发者提供了强大的响应式编程能力。在"蜜语"这款应用中,开发者充分利用了这些特性,将复杂的多 Tab 应用拆分为多个独立且可复用的 @Builder 构建函数,实现了代码的高内聚、低耦合。

在这里插入图片描述
Notification Kit 是 HarmonyOS 提供的通知能力核心套件,它允许应用向用户推送系统级通知,支持基础文本、长文本、多行文本、图片等多种内容类型。而 HarmonyOS 6.1.1 版本带来了一个重要新特性——应用沙箱文件可作为通知的自定义铃声。这一特性的核心在于 NotificationRequest.sound 字段,其取值格式为 'uri::' + fileUri.getUriFromPath(沙箱音频路径),即通过 uri:: 前缀将沙箱内的音频文件路径转换为通知系统可识别的 URI。这意味着应用可以动态生成音频文件(如 WAV 格式的正弦波铃声),将其写入应用沙箱的 EL1 区域 files 目录,然后在发布通知时指定该音频作为铃声——实现了完全由应用控制的通知声音定制能力。这对于情侣恋爱日记这类情感社交应用而言,意味着纪念日提醒不再是千篇一律的系统默认铃声,而是可以为每个纪念日配上"心动提示"“悄悄话铃”"晚安曲铃"等专属语义铃声,极大地增强了情感表达的个性化和仪式感。

在这里插入图片描述
Canvas 绘图能力是 ArkUI 框架中处理自定义图形渲染的重要手段。ArkUI 提供了 Canvas 组件,开发者可通过 CanvasRenderingContext2D 上下文进行 2D 图形绘制,支持路径、填充、描边、文本、渐变等全套 Canvas 2D API。在本应用中,Canvas 被用于绘制"恋爱六维雷达图"——一种多维度数据可视化图表,从亲密、沟通、陪伴、浪漫、信任、成长六个维度量化评估恋爱关系状态。雷达图通过三层同心网格、数值多边形填充、维度标签标注等元素构成,并且与呼吸动画联动——每秒重绘一次,节点圆半径在 3.5px 和 4px 之间交替变化,产生微妙的脉动效果。这种将 Canvas 绘制与定时器驱动的状态变化相结合的方案,实现了静态图表的动态化表达。

在这里插入图片描述
情感社交应用场景有其独特的设计哲学。与工具类、资讯类应用不同,情感社交应用的用户打开应用时的心理状态是"寻找温暖"而非"高效完成任务"。因此,这类应用在视觉设计上需要刻意降低信息密度、提升色彩温度、增加动画细节,让用户在每一次交互中都能感受到"被用心对待"的体验。"蜜语"应用正是基于这一设计哲学,选择了蜜桃粉(#F472B6)作为主色、薰衣草紫(#A78BFA)作为辅色、浅粉背景(#FFF5F7)作为底色的配色方案——这种"甜蜜系"配色不仅传达了恋爱主题,还在长时间使用中不会产生视觉疲劳,因为浅色背景和低饱和度功能色的组合天然具有柔和、舒适的视觉特质。

在这里插入图片描述
从技术选型的角度来看,这款应用还采用了以下关键技术决策:第一,4 个 Tab 各自完全不同的布局风格。 心情 Tab 采用 Canvas 雷达图 + 心情选择条 + 图例卡片的三段式布局;纪念 Tab 采用竖向时间轴布局(固定行高 72px);铃音 Tab 采用生成器 + 当前铃声 + 铃声库 + 发布入口的四卡纵向布局;我的 Tab 采用渐变大卡 + 统计清单的布局。第二,WAV 音频字节级生成。 应用通过 DataViewArrayBuffer 在 JavaScript 层面直接构造符合 RIFF/WAVE 规范的音频字节,模拟用户生成或网络下载的音频文件,然后写入沙箱——这种字节级操作展示了 ArkUI 对底层二进制数据的处理能力。第三,呼吸动画驱动全局视觉反馈。 一个 breath 布尔状态变量通过 setInterval 每秒翻转,驱动通知授权状态胶囊的闪烁、雷达图节点的脉动、柱状图末柱的放大——一个状态变量联动三处视觉反馈,体现了响应式编程的优雅。第四,三层弹窗系统。 build()Stack 包裹主内容与三层弹窗(新增纪念日 / 编辑纪念日 / 删除铃声),通过 addModaleditModaldelModal 三个布尔状态控制显隐,实现了统一的弹窗交互体验。

下面,我们将从代码的第一行开始,逐段深入分析这个情侣恋爱日记应用的完整实现。


二、整体架构流程图

为了更好地理解"蜜语"应用的整体架构和各组件之间的关系,我们使用以下 Mermaid 流程图来展示从模块导入到 UI 渲染的完整数据流向:

UI 渲染层

主组件 Page1102

数据模型层

辅助函数层

常量与数据层

模块导入层

@kit.NotificationKit
notificationManager

@kit.CoreFileKit
fileIo + fileUri

@kit.AbilityKit
contextConstant + common

@kit.BasicServicesKit
BusinessError

ColorPalette 接口
22 个颜色字段

COLORS 色板常量
蜜桃粉 + 薰衣草紫

TAB_LIST 4 Tab 元数据

RADAR_LABELS / RADAR_VALUES
六维标签与数值

MOOD_OPTIONS / MONTH 数据

FORM_LABELS 表单标签

moodColor
心情色映射

memoColor
状态色映射

buildWavBytes
WAV 音频字节生成

MemoItem @Observed
纪念日条目

RingItem @Observed
铃声条目

StatItem @Observed
统计条目

MEMO_LIST / RING_LIST / STAT_LIST
Mock 数据

@State 状态群
20+ 状态变量

通知授权 requestAuth

沙箱写入 saveRingToSandbox

铃声导入 importRingToSandbox

创建铃声 createRingByGen

设为默认 setCurrentRing

sound 构建 getSoundValue

发布通知 publishNotice

删除铃声 delRing

增删纪念日 saveMemo / updateMemo

生命周期 aboutToAppear / aboutToDisappear

雷达图绘制 drawRadarChart

build 主入口
Stack 包裹

headerMain 头部
授权胶囊 + 数据小卡

tabMood 心情 Tab
Canvas 雷达图

tabMemo 纪念 Tab
竖向时间轴

tabRing 铃音 Tab
生成器 + 铃声库

tabMine 我的 Tab
渐变大卡 + 统计

chartCard 柱状图
月度日记记录

tabBar 底部导航
4 Tab 单排

panelAdd 新增弹窗

panelEdit 编辑弹窗

panelDel 删除弹窗

modalOverlay 遮罩层

上图展示了从底层 Kit 导入到最终 UI 渲染的完整数据流。可以看到,Notification KitCoreFileKitAbilityKit 三个 Kit 共同支撑了通知铃声的核心链路:buildWavBytes 生成音频字节 → saveRingToSandbox 写入沙箱 → getSoundValue 构建 uri:: 前缀 → publishNotice 发布携带自定义铃声的通知。而 Canvas 绘图链路则由 drawRadarChart 方法独立完成,通过呼吸动画定时器驱动每秒重绘。


三、模块导入与依赖声明

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

这段代码是整个应用的依赖根基,四个 Kit 分别承担不同的底层能力支撑。

@kit.NotificationKit 导入了 notificationManager 模块,这是 HarmonyOS 通知能力的核心管理器。在本应用中,它负责四项关键任务:第一,通过 requestEnableNotification() 请求通知授权;第二,通过 isNotificationEnabled() 查询当前授权状态;第三,通过 openNotificationSettings() 在用户曾拒绝授权时拉起系统通知设置页进行二次引导授权;第四,通过 publish() 方法发布携带自定义铃声的通知。这个模块是"蜜语"应用纪念日提醒功能的基石——没有通知能力,恋爱纪念日的甜蜜提醒就无从送达。

@kit.CoreFileKit 导入了 fileIo(别名 fs)和 fileUri 两个子模块。fileIo 提供了文件同步操作 API,包括 openSync(创建/打开文件)、writeSync(写入数据)、closeSync(关闭文件描述符)和 unlinkSync(删除文件)。在本应用中,这些 API 用于将 buildWavBytes 生成的 WAV 音频字节写入沙箱 EL1 区域的 files 目录,以及删除铃声时同步清理沙箱文件。fileUri 提供了 getUriFromPath() 方法,将沙箱文件路径转换为 file:// 格式的 URI 字符串——这是构建 NotificationRequest.sound 字段值的关键一环。

@kit.AbilityKit 导入了 contextConstantcommoncontextConstant 提供了 AreaMode 枚举(EL1 / EL2),用于指定应用沙箱的加密存储区域。common 提供了 UIAbilityContext 类型,用于获取 getApplicationContext()filesDir 等应用上下文信息。在本应用中,通过 this.getUIContext().getHostContext() as common.UIAbilityContext 获取宿主上下文,进而访问应用沙箱路径和通知授权入口。

@kit.BasicServicesKit 导入了 BusinessError 类型,用于类型化地处理 Promise 的 catch 回调中的错误对象。这是 HarmonyOS 推荐的错误处理范式——所有 Promise rejection 都返回 BusinessError 类型的对象,包含 code(错误码)和 message(错误消息)两个字段。在本应用中,通知授权失败(错误码 1600004 表示用户未授权)和通知发布失败都会被 catch 捕获,但应用选择了静默处理策略,不向用户暴露错误信息。


四、颜色系统设计

4.1 ColorPalette 接口

/** 主题色板接口:集中声明页面所有颜色字段 */
interface ColorPalette {
  bg: string;
  card: string;
  chip: string;
  title: string;
  sub: string;
  text3: string;
  white: string;
  pink: string;
  pinkD: string;
  pinkL: string;
  purple: string;
  purpleL: string;
  gold: string;
  goldL: string;
  green: string;
  greenL: string;
  red: string;
  redL: string;
  blue: string;
  blueL: string;
  line: string;
  tabOn: string;
  mask: string;
}

ColorPalette 接口是整个应用色彩管理的类型契约。通过 TypeScript 接口定义所有颜色字段,确保了颜色使用的类型安全和命名统一。

这个接口定义了 23 个颜色字段,可以归纳为六大色族:

基础色族bg / card / chip / white / line / mask):bg 是页面背景色,card 是卡片背景色,chip 是芯片/标签背景色,white 是纯白,line 是分隔线色,mask 是弹窗遮罩色。这六个颜色构成了页面的骨架视觉层。

文字色族title / sub / text3):title 是主标题色(深色),sub 是副文本色(中等),text3 是三级文本色(浅色)。三级文字色形成了从深到浅的视觉层级,对应不同重要程度的信息。

蜜桃粉色族pink / pinkD / pinkL):pink 是主品牌色,pinkD 是深粉(用于渐变起始色),pinkL 是浅粉(用于标签背景色)。这是整个应用的品牌核心色族,用于按钮、高亮、选中态等关键视觉元素。

薰衣草紫色族purple / purpleL):作为蜜桃粉的互补色,紫色用于第二维度的强调,如柱状图标记、特定数据展示等。

功能色族gold / goldL / green / greenL / red / redL / blue / blueL):金色用于"已纪念"状态和"未导入"标签,绿色用于"已授权"和"沙箱中"状态,红色用于"未授权"和"删除"操作,蓝色用于"委屈"心情色和"编辑"按钮。每个功能色都有主色和浅色配对,主色用于文字,浅色用于背景。

4.2 浅色主题色板常量

/** 浅色主题色板常量(蜜语 · 蜜桃粉 + 薰衣草紫) */
const COLORS: ColorPalette = {
  bg: '#FFF5F7',
  card: '#FFFFFF',
  chip: '#FDE8EF',
  title: '#432840',
  sub: '#B07A94',
  text3: '#D4A5BB',
  white: '#FFFFFF',
  pink: '#F472B6',
  pinkD: '#EC4899',
  pinkL: '#FDE8EF',
  purple: '#A78BFA',
  purpleL: '#F0EBFB',
  gold: '#F5C451',
  goldL: '#FDF3DC',
  green: '#34D399',
  greenL: '#DCFCEA',
  red: '#FB7185',
  redL: '#FFE9ED',
  blue: '#60A5FA',
  blueL: '#E3EFFE',
  line: '#F7DCE6',
  tabOn: '#F472B6',
  mask: 'rgba(67,40,64,0.5)'
};

COLORS 常量是 ColorPalette 接口的具体实现,定义了"蜜语"应用的完整色板。

背景色 #FFF5F7 是一种极浅的粉色,几乎接近白色但带有一丝暖色调。这种"几乎白色但不完全白色"的背景色是情感社交应用的经典选择——纯白背景过于冰冷,而带有一丝粉色的背景能在视觉上传递温暖感,同时又不会影响内容的可读性。

标题色 #432840 是一种深紫粉色,饱和度适中,明度较低。这种颜色避免了纯黑色的生硬感,同时保持了足够的对比度(与 #FFF5F7 背景的对比度约为 12:1),确保了标题文字的清晰可读。将深紫粉色而非纯黑作为标题色,是情感类应用色彩设计中的一个微妙而重要的决策。

蜜桃粉 #F472B6 是整个应用的品牌核心色。这是一个中等明度、中等饱和度的粉色,在 #FFF5F7 背景上具有良好的可见性。它用于按钮背景、选中态高亮、品牌数据展示等关键位置。深粉 #EC4899 是品牌色的加深版本,主要用于渐变色的起始位置(如"我的"Tab 的大卡渐变),与薰衣草紫 #A78BFA 配合形成 135 度对角渐变。

遮罩色 rgba(67,40,64,0.5) 使用了与标题色相同的深紫粉色,但透明度设为 50%。这种设计确保了遮罩层的色调与整体色板一致——不是常见的纯黑半透明遮罩,而是带有品牌色调的温暖遮罩,使得弹窗的视觉体验与整体应用保持统一。


五、常量定义

5.1 Tab 导航元数据

/** Tab 元数据接口:底部导航图标 + 标签 */
interface TabMeta {
  icon: string;
  label: string;
}

/** 底部导航 Tab 常量列表(4 Tab 单排) */
const TAB_LIST: TabMeta[] = [
  { icon: '💖', label: '心情' },
  { icon: '🎂', label: '纪念' },
  { icon: '🔔', label: '铃音' },
  { icon: '👤', label: '我的' }
];

底部导航采用 4 个 Tab 单排布局,每个 Tab 由 Emoji 图标和文字标签组成。

TabMeta 接口定义了 Tab 的数据结构——icon 是 Emoji 字符串,label 是中文标签。使用 Emoji 而非图标字体或 SVG 图标是一个有趣的设计选择:Emoji 天然具有色彩和情感属性(💖是粉色爱心、🎂是生日蛋糕、🔔是铃铛、👤是人像),与"蜜语"的恋爱主题高度契合,且无需额外导入图标资源。

4 个 Tab 的命名和图标选择也体现了精心设计:心情(💖)对应恋爱关系中的情感状态可视化;纪念(🎂)对应恋爱中的特殊日期管理;铃音(🔔)对应通知铃声定制——这是本应用最具技术深度的功能模块;我的(👤)对应个人恋爱数据统计。这四个维度覆盖了情侣恋爱日记应用的核心功能场景。

5.2 雷达图维度与数值

/** 恋爱六维雷达图维度标签 */
const RADAR_LABELS: string[] = ['亲密', '沟通', '陪伴', '浪漫', '信任', '成长'];
/** 恋爱六维雷达图数值(0~1) */
const RADAR_VALUES: number[] = [0.9, 0.8, 0.75, 0.85, 0.95, 0.7];

雷达图数据定义了六个维度和对应的数值。这六个维度的选择体现了对恋爱关系的深度理解:

亲密(0.9)代表身体和情感上的亲近程度;沟通(0.8)代表交流的频率和质量;陪伴(0.75)代表共处时间;浪漫(0.85)代表仪式感和惊喜;信任(0.95)代表彼此的信赖和安全感——这是六维中得分最高的一项,暗示了这段恋爱关系的稳固基础;成长(0.7)代表共同进步的动力——这是六维中得分最低的一项,暗示了未来可以提升的空间。

数值范围 0~1 的设计是为了与雷达图绘制时的半径比例直接对应——r * val 即为该维度在雷达图上的实际半径。这种"数据即坐标"的映射方式简化了绘制逻辑,避免了额外的比例转换。

5.3 心情选项与月度数据

/** 今日心情可选项数组 */
const MOOD_OPTIONS: string[] = ['开心', '平静', '委屈', '生气'];

/** 月度日记柱状图月份索引 */
const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
/** 月度柱状图月份名称 */
const MONTH_NAME: string[] = ['3月', '4月', '5月', '6月', '7月', '8月'];
/** 月度日记篇数 */
const MONTH_VAL: number[] = [12, 15, 9, 18, 14, 16];
/** 月度柱状图最大值 */
const MONTH_MAX: number = 20;

心情选项定义了四种心情状态:开心、平静、委屈、生气。这四种心情覆盖了情侣日常情感的正向(开心)、中性(平静)、负向(委屈、生气)三个区间,并且每种心情在 moodColor 函数中都有对应的颜色映射——开心对应粉色、平静对应紫色、委屈对应蓝色、生气对应红色——颜色从暖到冷再到警示,形成了情感温度的视觉编码。

月度柱状图数据展示了最近六个月的日记篇数。最大值 MONTH_MAX = 20 用于计算每根柱子的相对高度——MONTH_VAL[i] / MONTH_MAX * 110 即为柱子的像素高度(基础高度 110px)。数据中 6 月(18 篇)是峰值,5 月(9 篇)是低谷,模拟了真实使用中的波动趋势。

5.4 表单标签

/** 新增/编辑纪念日表单标签数组 */
const FORM_LABELS: string[] = ['纪念日日期', '纪念日名称', '纪念天数'];

FORM_LABELS 定义了纪念日表单的三个字段标签。将表单标签提取为常量数组有两个好处:一是确保新增弹窗和编辑弹窗使用完全一致的标签文案;二是如果需要国际化或修改文案,只需修改一处常量定义即可,符合 DRY(Don’t Repeat Yourself)原则。


六、辅助函数

6.1 心情色映射 moodColor

/** 心情色映射:开心粉 / 平静紫 / 委屈蓝 / 生气红 */
function moodColor(m: string): string {
  if (m === '开心') {
    return COLORS.pink;
  } else if (m === '平静') {
    return COLORS.purple;
  } else if (m === '委屈') {
    return COLORS.blue;
  } else if (m === '生气') {
    return COLORS.red;
  }
  return COLORS.text3;
}

moodColor 函数将心情字符串映射为颜色值,是一个纯函数——输入相同的心情字符串,输出始终是同一个颜色,不依赖任何外部状态。

映射策略体现了情感色彩心理学:开心映射为蜜桃粉(COLORS.pink),粉色是温暖、甜蜜、愉悦的颜色,与开心的情感温度一致;平静映射为薰衣草紫(COLORS.purple),紫色是沉稳、内敛、宁静的颜色,与平静的情感状态匹配;委屈映射为蓝色(COLORS.blue),蓝色是忧郁、冷感、低落的颜色,传达了委屈时的情绪低沉;生气映射为红色(COLORS.red),红色是激烈、警示、冲突的颜色,与生气的情感强度对应。

默认返回值 COLORS.text3(三级文本色,浅粉灰)确保了当传入未知心情字符串时不会报错,返回一个中性色彩。这种防御性编程保证了函数的健壮性。

6.2 纪念日状态色映射 memoColor

/** 纪念日状态色映射:已纪念金 / 待纪念粉 / 筹备中紫 */
function memoColor(s: string): string {
  if (s === '已纪念') {
    return COLORS.gold;
  } else if (s === '待纪念') {
    return COLORS.pink;
  } else if (s === '筹备中') {
    return COLORS.purple;
  }
  return COLORS.text3;
}

memoColor 函数将纪念日状态映射为颜色值,用于时间轴中圆点、状态文字和连接线的颜色。

映射策略遵循了"时间温度"逻辑:已纪念(金色 COLORS.gold)代表已经过去的纪念日,金色传达了"珍贵回忆"的含义——就像金色的老照片,带有时间的沉淀感;待纪念(蜜桃粉 COLORS.pink)代表即将到来的纪念日,粉色传达了"期待甜蜜"的含义——这是品牌核心色,强调即将到来的重要性;筹备中(薰衣草紫 COLORS.purple)代表正在准备的纪念日,紫色传达了"计划中、未完成"的含义——紫色在色彩心理学中常与规划、想象关联。

6.3 WAV 音频字节生成 buildWavBytes

/** 生成正弦波 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;
}

buildWavBytes 是整个应用中技术含量最高的函数之一。它在 JavaScript 层面直接构造符合 RIFF/WAVE 规范的音频二进制数据,无需依赖任何音频编解码库。

WAV 文件格式解析:WAV 文件采用 RIFF(Resource Interchange File Format)容器格式,由"文件头 + 音频数据"两部分组成。文件头共 44 字节,包含以下字段:

  • RIFF(偏移 0,4 字节):RIFF 容器标识
  • 文件大小(偏移 4,4 字节,小端序):36 + dataSize,即文件总大小减去前 8 字节的 RIFF 头
  • WAVE(偏移 8,4 字节):WAVE 格式标识
  • fmt (偏移 12,4 字节):格式块标识(注意末尾有一个空格)
  • fmt 块大小(偏移 16,4 字节):固定为 16(PCM 格式)
  • 音频格式(偏移 20,2 字节):1 = PCM(无压缩)
  • 声道数(偏移 22,2 字节):1 = 单声道
  • 采样率(偏移 24,4 字节):44100 Hz
  • 字节率(偏移 28,4 字节):sampleRate * 2(采样率 × 声道数 × 每样本字节数)
  • 块对齐(偏移 32,2 字节):2(声道数 × 每样本字节数)
  • 每样本位数(偏移 34,2 字节):16 bit
  • data(偏移 36,4 字节):数据块标识
  • 数据大小(偏移 40,4 字节):dataSize

音频数据生成逻辑是函数的核心。对于每个采样点,计算步骤如下:

  1. 时间 ti / sampleRate,将采样索引转换为实际时间(秒)
  2. 包络 envMath.min(1, i / (sampleRate * 0.02)),前 20ms 的淡入包络,避免开头产生爆音
  3. 衰减 decayMath.max(0, 1 - t / (durationMs / 1000)),线性衰减,到结束时归零
  4. 波形值 vMath.sin(2 * Math.PI * freq * t) * 0.5 * env * decay,正弦波 × 0.5 振幅 × 包络 × 衰减
  5. 16bit 量化Math.round(v * 32767),将 -1~1 的浮点值映射到 -32768~32767 的 16bit 整数范围

DataView 的使用是本函数的技术亮点。ArrayBuffer 是一块固定大小的原始二进制缓冲区,不能直接读写;DataView 提供了一组类型化读写方法(setUint8setUint16setUint32setInt16),可以在指定的字节偏移处写入不同类型的数据。true 参数表示使用小端序(Little-Endian),这是 WAV 文件的标准字节序。writeStr 辅助函数通过逐字符写入 ASCII 码值来写入 4 字节的格式标识字符串。

这个函数的意义在于:它展示了在 ArkUI/ArkTS 环境中,开发者可以完全不依赖原生模块或第三方库,纯靠 JavaScript 的 ArrayBuffer + DataView 来生成符合国际标准的二进制文件。这对于需要动态生成音频、图像等二进制资源的 HarmonyOS 应用而言,是一种非常实用的技术手段。


七、数据模型与 Mock 数据

7.1 MemoItem 纪念日模型

/** 纪念日条目(纪念 Tab 竖向时间轴) */
@Observed export class MemoItem {
  time: string;
  title: string;
  days: string;
  status: string;

  constructor(time: string, title: string, days: string, status: string) {
    this.time = time;
    this.title = title;
    this.days = days;
    this.status = status;
  }
}

MemoItem 是纪念日条目的数据模型,使用 @Observed 装饰器标注。@Observed 是 ArkUI 的可观察对象装饰器,它使得被装饰的类的属性变化能够被 @State@Prop@Link 等状态装饰器感知——当 MemoItem 实例的属性被修改时(如 updateMemo 方法中的赋值操作),引用该实例的 UI 组件会自动重新渲染。

四个字段的含义分别是:time(纪念日日期,如 '01-14')、title(纪念日名称,如 '恋爱纪念日')、days(纪念天数,如 '586')、status(状态,取值为 '已纪念' / '待纪念' / '筹备中')。值得注意的是 days 字段使用了 string 类型而非 number 类型,这是因为显示场景中需要直接拼接字符串(如 '第 ' + item.days + ' 天'),使用字符串避免了不必要的类型转换。

7.2 RingItem 铃声模型

/** 铃声条目(铃音 Tab:沙箱自定义铃声) */
@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 是铃声条目的数据模型,同样使用 @Observed 装饰。六个字段覆盖了铃声的完整元数据:

name 是铃声的显示名称(如 '心动提示'),使用了语义化命名而非技术化命名——“心动提示"而非"880Hz 正弦波”,体现了情感社交应用对用户友好性的追求。file 是沙箱中的文件名(如 'ring_880.wav'),频率值编码在文件名中,便于调试。freq 是生成频率(Hz),范围 220~1760,覆盖了从低沉到尖锐的音频范围。duration 是时长(毫秒),范围 600~2400。size 是文件大小(如 '—''85 KB'),初始为 '—'(占位符),导入沙箱后计算实际大小。inSandbox 是布尔标志,表示该铃声是否已写入沙箱——这是控制"导入沙箱"和"设为默认"操作可见性的关键状态。

7.3 StatItem 统计模型

/** 统计条目(我的 Tab 恋爱统计清单) */
@Observed export class StatItem {
  icon: string;
  name: string;
  val: string;
  tag: string;

  constructor(icon: string, name: string, val: string, tag: string) {
    this.icon = icon;
    this.name = name;
    this.val = val;
    this.tag = tag;
  }
}

StatItem 是恋爱统计条目的数据模型。四个字段分别对应:icon(Emoji 图标,如 '📔')、name(统计项名称,如 '共同日记')、val(统计值,如 '218 篇')、tag(附加标签,如 '本月 +12 篇')。valtag 都使用字符串类型,允许包含单位(“篇”、“张”、“个”、“句”、“枚”)和描述性文字,提供了灵活的展示能力。

7.4 Mock 数据

/** 纪念日 Mock 数据(6 条) */
const MEMO_LIST: Array<MemoItem> = [
  new MemoItem('01-14', '恋爱纪念日', '586', '已纪念'),
  new MemoItem('02-29', '第一次牵手', '541', '已纪念'),
  new MemoItem('05-20', '告白纪念日', '461', '已纪念'),
  new MemoItem('09-03', 'TA 的生日', '9', '待纪念'),
  new MemoItem('10-01', '一起旅行纪念日', '37', '筹备中'),
  new MemoItem('12-24', '第一个平安夜', '121', '筹备中')
];

纪念日 Mock 数据精心设计了 6 条数据,覆盖了三种状态和全年时间线。前 3 条是"已纪念"状态——恋爱纪念日(1 月 14 日)、第一次牵手(2 月 29 日,闰日增添特殊感)、告白纪念日(5 月 20 日,“520"谐音"我爱你”)。后 3 条分别对应"待纪念"(TA 的生日,9 天后)和"筹备中"(一起旅行纪念日、第一个平安夜)。数据从年初到年末排列,模拟了真实的恋爱时间线。

/** 铃声库 Mock 数据(6 条:心动/纪念语义铃声) */
const RING_LIST: Array<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)
];

铃声库 Mock 数据同样包含 6 条,频率从 220Hz(低沉"恋爱警报")到 1760Hz(高亢"晚安曲铃")覆盖了 8 个倍频程。每条铃声都有语义化命名——"心动提示"对应中频 880Hz(A5 音,明亮但不刺耳)、"纪念日钟声"对应 660Hz(E5 音,沉稳庄严)、"悄悄话铃"对应 440Hz(A4 音,标准音高,柔和低语)、"早安吻铃"对应 1320Hz(E6 音,清脆唤醒)、"晚安曲铃"对应 1760Hz(A6 音,高远空灵)、"恋爱警报"对应 220Hz(A3 音,低沉警示)。

所有铃声的 inSandbox 初始为 falsesize 初始为 '—',表示尚未导入沙箱。用户需要在铃音 Tab 中手动"导入沙箱"后才能将其设为通知铃声。这种"先导入、再使用"的两步操作流程,模拟了真实应用中从网络下载或本地选择铃声文件后导入应用沙箱的使用场景。

/** 恋爱统计 Mock 数据(5 条) */
const STAT_LIST: Array<StatItem> = [
  new StatItem('📔', '共同日记', '218 篇', '本月 +12 篇'),
  new StatItem('📷', '甜蜜相册', '526 张', '精选 36 张'),
  new StatItem('🎂', '纪念日', '12 个', '下个在 9 天后'),
  new StatItem('💌', '情话交换', '1,024 句', '今日 8 句'),
  new StatItem('🏆', '恋爱成就', '28 枚', '新解锁 2 枚')
];

恋爱统计 Mock 数据涵盖 5 个维度——日记、相册、纪念日、情话、成就——全面量化了情侣关系的数字足迹。每条数据都包含一个附加标签(如"本月 +12 篇"、“今日 8 句”),传达了"持续积累、动态变化"的数据活力感。


八、组件主体与状态声明

8.1 @Entry @Component 声明

/** 1102 蜜语 · 情侣恋爱日记主页面 */
@Entry
@Component
struct Page1102 {

@Entry 装饰器标识该组件为页面入口组件——一个 ArkTS 文件中只能有一个 @Entry 组件,它是应用渲染的根节点。@Component 装饰器声明 Page1102 为一个自定义组件,拥有独立的状态管理和生命周期。struct 关键字定义了一个结构体——ArkUI 中的组件使用 struct 而非 class,因为 struct 是值类型且不支持继承,更符合 UI 组件"组合优于继承"的设计哲学。

8.2 @State 状态变量

  /** 当前选中 Tab 索引 */
  @State currentTab: number = 0;
  /** 新增纪念日弹窗开关 */
  @State addModal: boolean = false;
  /** 编辑纪念日弹窗开关 */
  @State editModal: boolean = false;
  /** 删除铃声确认弹窗开关 */
  @State delModal: boolean = false;
  /** 当前编辑的纪念日索引 */
  @State editIdx: number = 0;
  /** 当前删除的铃声索引 */
  @State delIdx: number = 0;
  /** 呼吸动画开关(每秒翻转) */
  @State breath: boolean = false;
  /** 通知授权状态 */
  @State granted: boolean = false;
  /** 通知 ID(发布成功后自增) */
  @State notifyId: number = 100;
  /** 当前默认铃声索引 */
  @State currentRingIdx: number = 0;
  /** 已导入沙箱的铃声数量 */
  @State sandboxCount: number = 0;
  /** 生成器频率(Hz) */
  @State genFreq: number = 880;
  /** 生成器时长(ms) */
  @State genDuration: number = 1200;
  /** 雷达图 Canvas 就绪标志 */
  @State canvasReady: boolean = false;
  /** 已发布纪念日提醒条数 */
  @State sentCount: number = 0;
  /** 今日心情选中索引 */
  @State moodIdx: number = 0;
  /** 呼吸动画定时器句柄 */
  private timer: number = -1;
  /** 纪念日列表(时间轴数据源) */
  @State memoList: Array<MemoItem> = MEMO_LIST;
  /** 铃声库列表 */
  @State ringList: Array<RingItem> = RING_LIST;
  /** 恋爱统计列表 */
  @State statList: Array<StatItem> = STAT_LIST;
  /** 新增表单:纪念日日期 */
  @State formDate: string = '';
  /** 新增表单:纪念日名称 */
  @State formTitle: string = '';
  /** 新增表单:纪念天数 */
  @State formDays: string = '';
  /** 编辑表单:纪念日日期 */
  @State editDate: string = '';
  /** 编辑表单:纪念日名称 */
  @State editTitle: string = '';
  /** 编辑表单:纪念天数 */
  @State editDays: string = '';
  /** 雷达图 Canvas 上下文 */
  private radarCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));

这是整个应用最核心的状态声明区域,共定义了 25 个状态变量(22 个 @State + 3 个 private)。可以将它们归纳为五大类:

第一类:导航与弹窗状态currentTabaddModaleditModaldelModaleditIdxdelIdx)。currentTab 控制底部 4 个 Tab 的切换,4 个弹窗布尔值控制三层弹窗的显隐,editIdxdelIdx 记录当前操作的数据索引。这组状态是用户交互的"控制面板"。

第二类:通知与铃声状态grantednotifyIdcurrentRingIdxsandboxCountsentCount)。granted 是通知授权状态,notifyId 是自增的通知 ID(从 100 开始),currentRingIdx 是当前默认铃声索引,sandboxCount 是已导入沙箱的铃声计数,sentCount 是已发送提醒条数。这组状态构成了通知铃声功能的完整状态机。

第三类:铃声生成器状态genFreqgenDuration)。这两个变量绑定到铃音 Tab 的两个 Slider 控件,用户拖动滑块时实时更新,点击"生成铃声到沙箱"时传入 buildWavBytes 生成音频。

第四类:动画与 Canvas 状态breathcanvasReadytimer)。breath 是全局呼吸动画开关,canvasReady 标记 Canvas 组件是否已触发 onReady 回调,timersetInterval 返回的定时器句柄。timer 使用 private 而非 @State,因为它不参与 UI 渲染,仅用于生命周期管理。

第五类:表单与列表数据memoListringListstatListformDateformTitleformDayseditDateeditTitleeditDays)。三个列表数据源初始化为 Mock 常量,六个表单字段初始化为空字符串。

radarCtxCanvasRenderingContext2D 实例,用于雷达图绘制。new RenderingContextSettings(true) 中的 true 表示启用抗锯齿(antialias),确保雷达图的线条和填充边缘平滑。radarCtx 使用 private 装饰而非 @State,因为 Canvas 上下文本身不参与响应式更新——绘制操作通过显式调用 drawRadarChart() 方法触发,而非通过状态变化自动触发。


九、Notification Kit 通知授权与铃声发布

9.1 通知授权请求 requestAuth

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

requestAuth 方法实现了 HarmonyOS 通知授权的两段式策略。

第一段:主动请求授权。 notificationManager.requestEnableNotification(hostCtx) 是 HarmonyOS 的通知授权 API。当应用首次调用此方法时,系统会弹出标准的通知授权弹窗,询问用户是否允许该应用发送通知。如果用户点击"允许",Promise resolve,this.granted = true 更新授权状态,UI 上的授权胶囊从红色"未授权"变为绿色"已授权"。如果用户点击"拒绝",Promise reject,进入 catch 分支。

第二段:引导二次授权。 当用户曾经拒绝过授权请求后,requestEnableNotification 不会再弹窗(HarmonyOS 的授权弹窗只出现一次),而是直接 reject。此时进入 catch 分支,调用 notificationManager.openNotificationSettings(hostCtx)——这个方法会拉起系统的通知设置页面,引导用户手动找到该应用并开启通知权限。这是一种"二次引导"策略:当系统弹窗无法再次出现时,通过引导用户到系统设置页来完成授权。

上下文获取 this.getUIContext().getHostContext() as common.UIAbilityContext 是获取宿主 UIAbility 上下文的标准方式。getUIContext() 返回当前 UI 上下文,getHostContext() 返回宿主上下文,类型断言为 common.UIAbilityContext 后即可调用 getApplicationContext() 等方法。空值检查 if (!hostCtx) return 确保在上下文不可用时安全退出。

9.2 沙箱音频写入 saveRingToSandbox

  /** 将生成的音频写入沙箱 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;
    const dir = appCtx.filesDir;
    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 方法是将音频数据持久化到应用沙箱的关键方法,它串联了 buildWavBytes(生成字节)和文件 I/O(写入字节)两个环节。

EL1 加密区域 是 HarmonyOS 沙箱存储的分层策略之一。HarmonyOS 将应用数据分为 EL1(Device Encrypt Level 1,设备级加密)和 EL2(Device Encrypt Level 2,用户级加密)。EL1 在设备启动后即可访问,适合存储不需要用户登录即可使用的数据。在本应用中,通知铃声的音频文件需要在没有用户登录态的情况下被通知系统读取,因此选择 EL1 区域。appCtx.area = contextConstant.AreaMode.EL1 设置后续 filesDir 的读取区域为 EL1,然后 appCtx.filesDir 返回 EL1 区域下的 files 目录路径。

文件操作三步曲openSync 创建/打开文件(CREATE 表示不存在则创建,WRITE_ONLY 表示只写模式,TRUNC 表示如果文件已存在则截断为空),writeSyncArrayBuffer 数据写入文件描述符 file.fdcloseSync 关闭文件描述符释放系统资源。这三步是同步操作,在调用方看来是"瞬间完成"的。

异常处理 使用 try/catch 包裹文件操作。如果沙箱写入失败(如磁盘空间不足、权限问题),异常被静默捕获,方法返回构建的路径字符串(即使文件实际上未写入成功)。这种"容错但继续"的策略确保了即使沙箱写入失败,应用也不会崩溃——后续的 getSoundValue 会返回一个路径 URI,通知发布时如果找不到该文件则使用默认铃声。

9.3 铃声导入与创建

  /** 铃声库条目导入沙箱(更新大小与状态) */
  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 方法处理铃声库中已有条目的沙箱导入。方法逻辑分四步:首先取出铃声条目引用 r;然后调用 saveRingToSandbox 将音频写入沙箱;接着更新 r.inSandbox = truer.size(计算公式为 (44 字节文件头 + 采样数 × 2 字节) / 1024,四舍五入为 KB);最后递增沙箱计数 sandboxCount

由于 RingItem 使用了 @Observed 装饰,r.inSandbox = truer.size = kb + ' KB' 的赋值会触发引用该数据的 UI 组件重新渲染——铃声库列表中该条目的状态标签从金色"未导入"变为绿色"沙箱中","导入沙箱"按钮消失,文件大小从 '—' 变为实际值。这就是 @Observed + @State 响应式数据驱动的威力。

  /** 用生成器参数新建铃声并写入沙箱 */
  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 方法处理通过铃声生成器创建新铃声的流程。它以 ringList.length + 1 作为序号,创建一个新的 RingItem(名称"自定义铃声 N"、文件名 ring_custom_N.wav、频率和时长取自生成器的 genFreqgenDuration),推入 ringList 数组尾部,然后立即调用 importRingToSandbox 将其导入沙箱。

由于 ringList@State 装饰的数组,push 操作会触发 ForEach 重新渲染,铃声库列表底部会出现一个新的铃声条目——这种"即点即生成即导入"的流畅体验,展示了 ArkUI 响应式列表的实时更新能力。

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

setCurrentRing 方法实现了"设为默认"操作。它包含一个重要的前置检查:如果目标铃声尚未导入沙箱,先自动导入——这确保了设为默认的铃声一定在沙箱中可用,避免了"设为默认但文件不存在"的异常状态。然后更新 currentRingIdx,触发铃音 Tab 中"当前默认铃声"卡片的重新渲染。

9.4 sound 字段构建 getSoundValue

  /** 当前通知请求 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 自定义铃声特性的核心——它构建 NotificationRequest.sound 字段的值。

uri:: 前缀 是 HarmonyOS 6.1.1 新增的通知铃声格式。在旧版本中,通知铃声只能使用系统预设的声音或 rawfile 资源;而 6.1.1 版本允许通过 uri:: + 文件 URI 的格式指定应用沙箱内的音频文件作为通知铃声。这种设计赋予了应用完全的通知声音定制能力——应用可以动态生成、下载或用户录制音频文件,写入沙箱后直接用作通知铃声。

路径构建逻辑分三步:首先从 ringList 中取出当前默认铃声条目 ring;然后获取应用上下文 appCtx 和沙箱 files 目录 appCtx.filesDir;最后拼接出完整路径 path = filesDir + '/' + ring.file,并通过 fileUri.getUriFromPath(path) 将路径转换为 file:// 格式的 URI 字符串。

容错处理:如果 hostCtx 为空(上下文不可用),返回 'uri::'(空前缀),通知系统会回退到默认铃声。这种防御性设计确保了即使上下文获取失败,通知发布也不会因 sound 字段格式错误而崩溃。

9.5 发布自定义铃声通知 publishNotice

  /** 发布携带沙箱自定义铃声的纪念日提醒(核心:sound 字段填沙箱 uri) */
  publishNotice() {
    if (this.currentRingIdx < 0 || this.currentRingIdx >= this.ringList.length) {
      return;
    }
    const ring = this.ringList[this.currentRingIdx];
    if (!ring.inSandbox) {
      this.importRingToSandbox(this.currentRingIdx);
    }
    const soundVal = this.getSoundValue();
    const ringName = ring.name;
    const request: notificationManager.NotificationRequest = {
      id: this.notifyId,
      notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,
      content: {
        notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
        normal: {
          title: '蜜语 · 纪念日提醒',
          text: '甜甜的纪念日就要到啦,快为 TA 准备一份小惊喜吧~',
          additionalText: '自定义铃声:' + ringName
        }
      },
      sound: soundVal  // HarmonyOS 6.1.1:支持应用沙箱内的音频路径
    };
    notificationManager.publish(request).then(() => {
      this.notifyId++;
      this.sentCount++;
    }).catch((err: BusinessError) => {
      // 失败:未授权 1600004
    });
  }

publishNotice 方法是整个通知铃声链路的终点——它将前述所有环节(音频生成、沙箱写入、URI 构建)汇总为一条完整的通知请求并发布到系统通知中心。

边界检查:首先检查 currentRingIdx 是否在有效范围内,防止索引越界。然后检查当前铃声是否已导入沙箱——如果未导入,自动调用 importRingToSandbox 进行导入。这两层检查确保了通知发布前置条件的完整性。

NotificationRequest 构建是方法的核心。id 使用自增的 notifyId(初始值 100),每次发布成功后递增,确保每条通知有唯一 ID。notificationSlotType 设为 SOCIAL_COMMUNICATION(社交通信类型),这种 Slot 类型在系统中具有较高优先级,通知时会以更显眼的方式呈现。content 使用 NOTIFICATION_CONTENT_BASIC_TEXT(基础文本类型),包含三个文本字段:title(标题"蜜语 · 纪念日提醒")、text(正文"甜甜的纪念日就要到啦,快为 TA 准备一份小惊喜吧~")、additionalText(附加文本"自定义铃声:心动提示"——将当前铃声名称展示在通知中,让用户知道这条通知使用了哪个铃声)。

sound: soundVal 是整段代码的技术制高点。soundValgetSoundValue() 返回的 'uri::' + fileUri 字符串。当通知被发布后,系统通知中心在展示该通知时,会读取 sound 字段,解析 uri:: 前缀后的 URI,定位到沙箱中的 WAV 文件,并播放该音频作为通知铃声——这就是"蜜语"应用"为每个纪念日配上专属铃声"的技术实现。

发布结果处理then 回调中递增 notifyIdsentCount,后者会触发头部"已发提醒"数据小卡的更新。catch 回调静默处理错误(如未授权错误码 1600004),不向用户展示错误信息——这在 Demo 应用中是可接受的策略,但在生产环境中应增加错误提示和引导。

9.6 删除铃声 delRing

  /** 删除铃声(同步清理沙箱文件) */
  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 方法处理铃声的删除操作,它同时清理内存数据和沙箱文件,体现了"数据一致性"的设计意识。

沙箱文件清理:如果被删除的铃声已导入沙箱(r.inSandboxtrue),首先递减 sandboxCount,然后设置 EL1 区域,通过 fs.unlinkSync 删除沙箱中的 WAV 文件。try/catch 包裹确保文件不存在时不会崩溃。

列表数据清理this.ringList.splice(idx, 1) 从数组中移除该条目,触发 ForEach 重新渲染,铃声库列表中该条目消失。如果被删除的是当前默认铃声(或在其之后的条目导致索引偏移),currentRingIdx 会被修正为新的列表末尾索引,避免"默认铃声指向不存在的条目"的异常状态。

弹窗关闭:最后将 delModal 设为 false,关闭删除确认弹窗。这种"先操作数据、后关闭弹窗"的顺序确保了用户看到弹窗关闭时数据已经更新完毕。


十、纪念日增删逻辑

  /** 保存新增纪念日(新增弹窗确认按钮) */
  saveMemo() {
    if (this.formDate === '' || this.formTitle === '' || this.formDays === '') {
      return;
    }
    this.memoList.push(new MemoItem(this.formDate, this.formTitle, this.formDays, '待纪念'));
    this.formDate = '';
    this.formTitle = '';
    this.formDays = '';
    this.addModal = false;
  }

saveMemo 方法处理新增纪念日表单的提交逻辑。

空值校验:三个表单字段(日期、名称、天数)任一为空则直接返回,不执行保存操作。这种"前端校验"防止了空数据进入列表。值得注意的是,校验逻辑没有提示用户哪个字段为空——这在 Demo 中可接受,但在生产环境中应增加具体的错误提示。

数据创建:使用表单中的三个字段值创建新的 MemoItem,状态固定为 '待纪念'(新增的纪念日默认为"待纪念"状态),推入 memoList 数组。由于 memoList@State 装饰且 MemoItem@Observed 装饰,push 操作触发列表重新渲染,时间轴底部出现新的纪念日条目。

表单清空与弹窗关闭:三个表单状态变量重置为空字符串(为下次新增做准备),addModal 设为 false 关闭弹窗。

  /** 保存编辑纪念日(编辑弹窗确认按钮) */
  updateMemo() {
    if (this.editIdx >= 0 && this.editIdx < this.memoList.length) {
      this.memoList[this.editIdx].time = this.editDate;
      this.memoList[this.editIdx].title = this.editTitle;
      this.memoList[this.editIdx].days = this.editDays;
    }
    this.editModal = false;
  }

updateMemo 方法处理编辑纪念日表单的提交逻辑。

索引校验:检查 editIdx 是否在有效范围内,防止越界访问。编辑操作只修改 timetitledays 三个字段,不修改 status——这意味着编辑不会改变纪念日的状态(“已纪念"的纪念日编辑后仍然是"已纪念”)。

原地修改:与新增操作不同,编辑是原地修改已有条目的属性值。由于 MemoItem 使用了 @Observed 装饰,属性赋值会触发引用该条目的 UI 组件重新渲染,时间轴中对应条目的日期、名称、天数会立即更新为新值。


十一、生命周期与呼吸动画

  /** 生命周期:查询授权状态并启动呼吸动画定时器 */
  aboutToAppear() {
    notificationManager.isNotificationEnabled().then((enabled: boolean) => {
      this.granted = enabled;
    }).catch(() => {
      this.granted = false;
    });
    this.timer = setInterval(() => {
      this.breath = !this.breath;
      if (this.canvasReady) {
        this.drawRadarChart();
      }
    }, 1000);
  }

aboutToAppear 是 ArkUI 组件的生命周期回调,在组件创建后、build() 执行前调用。它承担了两项初始化任务。

通知授权状态查询notificationManager.isNotificationEnabled() 异步查询当前应用的通知授权状态。如果已授权,enabledtruethis.granted 更新为 true,头部授权胶囊显示为绿色"已授权"。如果查询失败或未授权,this.granted 保持或更新为 false,胶囊显示为红色"未授权·点击授权"。这个查询确保了应用每次进入页面时都能同步最新的授权状态——即使用户在系统设置中修改了通知权限,重新进入应用后状态也会正确反映。

呼吸动画定时器setInterval 每秒执行一次回调,回调中做两件事:第一,翻转 breath 布尔值(true → false → true → ...),这个翻转驱动了三处视觉反馈——头部授权胶囊的闪烁透明度(opacity(this.breath ? 1 : 0.35))、雷达图节点圆的半径脉动(this.breath ? 4 : 3.5)、柱状图末柱的高度放大(+4 偏移)。第二,如果 Canvas 已就绪(canvasReadytrue),调用 drawRadarChart() 重绘雷达图——这是雷达图实现"节点脉动"效果的方式:每秒重绘一次,节点半径随 breath 值在 3.5px 和 4px 之间交替。

  /** 生命周期:销毁时清理定时器 */
  aboutToDisappear() {
    clearInterval(this.timer);
  }

aboutToDisappear 是组件销毁时的生命周期回调。它调用 clearInterval(this.timer) 清理 aboutToAppear 中创建的定时器——这是防止内存泄漏的关键步骤。如果不清理定时器,组件销毁后定时器仍在运行,每秒尝试翻转 breath 和重绘 Canvas,但此时组件已不存在,可能导致空引用异常或无效操作。这种"创建-清理"的对称设计是前端生命周期管理的标准范式。


十二、Canvas 雷达图绘制 drawRadarChart

  /** 绘制恋爱六维雷达图(三层网格 + 半透明填充 + 维度标签) */
  drawRadarChart() {
    const ctx = this.radarCtx;
    const cx = 160;
    const cy = 95;
    const r = 62;
    const n = RADAR_LABELS.length;
    ctx.clearRect(0, 0, 320, 190);

drawRadarChart 是整个应用中最具视觉表现力的方法。它使用 Canvas 2D API 绘制恋爱六维雷达图,包含五层绘制逻辑。

初始化参数cx = 160cy = 95 是雷达图的中心坐标(Canvas 宽 320、高 190,中心位于水平居中、垂直略偏上的位置)。r = 62 是雷达图的最大半径。n = RADAR_LABELS.length(值为 6)是维度数量。ctx.clearRect(0, 0, 320, 190) 清除整个 Canvas 画布——这是每秒重绘的必要前置步骤,否则新旧帧会叠加在一起。

12.1 三层同心网格

    // 三层同心网格
    for (let layer = 1; layer <= 3; layer++) {
      const lr = r * layer / 3;
      ctx.beginPath();
      for (let i = 0; i < n; i++) {
        const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
        const x = cx + Math.cos(angle) * lr;
        const y = cy + Math.sin(angle) * lr;
        if (i === 0) {
          ctx.moveTo(x, y);
        } else {
          ctx.lineTo(x, y);
        }
      }
      ctx.closePath();
      ctx.strokeStyle = COLORS.line;
      ctx.lineWidth = 1;
      ctx.stroke();
    }

三层同心六边形网格是雷达图的"参考标尺"。外层循环 layer 从 1 到 3,对应半径为 r/32r/3r 的三个六边形。

角度计算 angle = -Math.PI / 2 + (i / n) * Math.PI * 2-Math.PI / 2(即 -90 度)将起始角度设为正上方,(i / n) * Math.PI * 2 计算每个维度的角度偏移(360 度均分 6 份,每份 60 度)。从正上方开始顺时针计算,六个顶点的角度分别为 -90°、-30°、30°、90°、150°、210°。

路径构建moveTo 将画笔移动到第一个顶点,lineTo 依次连线到后续顶点,closePath 闭合路径回到起点。strokeStyle = COLORS.line(浅粉线条色 #F7DCE6)和 lineWidth = 1 使网格线纤细而柔和,不会喧宾夺主。

12.2 轴线绘制

    // 轴线
    for (let i = 0; i < n; i++) {
      const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
      ctx.beginPath();
      ctx.moveTo(cx, cy);
      ctx.lineTo(cx + Math.cos(angle) * r, cy + Math.sin(angle) * r);
      ctx.strokeStyle = COLORS.line;
      ctx.stroke();
    }

六条轴线从中心 (cx, cy) 向外延伸到最大半径 r 处,连接中心点与外层六边形的六个顶点。轴线使用与网格相同的线条色和线宽,视觉上与网格融为一体,构成了雷达图的"骨架"。

12.3 数值多边形填充

    // 数值多边形填充
    ctx.beginPath();
    for (let i = 0; i < n; i++) {
      const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
      const val = RADAR_VALUES[i];
      const x = cx + Math.cos(angle) * r * val;
      const y = cy + Math.sin(angle) * r * val;
      if (i === 0) {
        ctx.moveTo(x, y);
      } else {
        ctx.lineTo(x, y);
      }
    }
    ctx.closePath();
    ctx.fillStyle = COLORS.pink;
    ctx.globalAlpha = 0.3;
    ctx.fill();
    ctx.globalAlpha = 1;
    ctx.strokeStyle = COLORS.pink;
    ctx.lineWidth = 2;
    ctx.stroke();

数值多边形是雷达图的"数据形状"。与网格六边形不同,每个顶点的半径不再是固定的 lr,而是 r * val——val 是 0~1 的数值,决定了该维度顶点距离中心的实际距离。

填充与描边分两步:首先 fillStyle = COLORS.pink(蜜桃粉)和 globalAlpha = 0.3(30% 透明度)填充多边形内部,产生半透明的粉色区域——这个区域的大小和形状直观地展示了六维得分的分布。然后 globalAlpha = 1 恢复完全不透明,strokeStyle = COLORS.pinklineWidth = 2 描边多边形边缘,形成 2px 粗的粉色轮廓线。

透明度恢复是一个重要的细节——globalAlpha 是 Canvas 上下文的全局状态,设置后会影响后续所有绘制操作。填充后将 globalAlpha 恢复为 1,确保后续的节点绘制和标签绘制不受透明度影响。

12.4 数值节点

    // 数值节点
    for (let i = 0; i < n; i++) {
      const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
      const val = RADAR_VALUES[i];
      const x = cx + Math.cos(angle) * r * val;
      const y = cy + Math.sin(angle) * r * val;
      ctx.beginPath();
      ctx.arc(x, y, this.breath ? 4 : 3.5, 0, Math.PI * 2);
      ctx.fillStyle = COLORS.pink;
      ctx.fill();
    }

六个数值节点是多边形顶点上的实心圆。ctx.arc(x, y, radius, 0, Math.PI * 2) 绘制以 (x, y) 为圆心、radius 为半径的完整圆。半径 this.breath ? 4 : 3.5 是呼吸动画的体现——每秒在 4px 和 3.5px 之间交替,产生微妙的脉动效果。

这个 0.5px 的半径差异看似微小,但在 320×190 的小尺寸 Canvas 上,这种脉动足以让用户感知到"雷达图是活的"——它不是一张静态图片,而是一个随时间呼吸的数据可视化组件。这种将数据可视化与微交互动画结合的设计,是情感社交应用"传递活力"的典型手段。

12.5 维度标签

    // 维度标签
    ctx.font = '10px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillStyle = COLORS.sub;
    for (let i = 0; i < n; i++) {
      const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
      const x = cx + Math.cos(angle) * (r + 16);
      const y = cy + Math.sin(angle) * (r + 16) + 3;
      ctx.fillText(RADAR_LABELS[i], x, y);
    }
  }

维度标签是雷达图的可读性保障。六个标签(亲密、沟通、陪伴、浪漫、信任、成长)绘制在各自轴线的外延位置(r + 16,即超出最大半径 16px 处),使用 10px 无衬线字体、副文本色 COLORS.sub(中等深度粉色)、居中对齐。y + 3 的偏移是为了垂直居中文本——Canvas 的 fillText 以文本基线为 y 坐标,向下偏移 3px 使文本视觉居中于轴线端点。

标签位置在六边形外侧,与数值多边形之间留有 16px 的间距,确保标签不会与多边形或节点重叠。六个标签均匀分布在六边形外围,形成了完整的"维度环"。


十三、build() 主构建方法

  /** 页面主构建:Stack 包裹主内容与三层弹窗 */
  build() {
    Stack() {
      Column() {
        this.headerMain()
        Divider().strokeWidth(1).color(COLORS.line)
        Scroll() {
          Column() {
            if (this.currentTab === 0) {
              this.tabMood()
            } else if (this.currentTab === 1) {
              this.tabMemo()
            } else if (this.currentTab === 2) {
              this.tabRing()
            } else {
              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() 方法是 ArkUI 组件的渲染入口,定义了整个页面的 UI 结构和组件层级。

Stack 层叠布局是整个页面的骨架。Stack 允许子组件在 Z 轴方向层叠排列——先声明的子组件在底层,后声明的在顶层。在这个应用中,Stack 包含两层:

  • 底层Column 主内容区域,包含头部(headerMain)、分隔线(Divider)、滚动内容区(Scroll)、底部导航(tabBar)。
  • 顶层:三个条件渲染的弹窗面板(panelAddpanelEditpanelDel),通过 if 条件控制显隐。

主内容区的垂直布局Column 从上到下依次排列头部、分隔线、滚动区和底部导航。Divider 使用浅粉线条色(COLORS.line),在头部和内容区之间形成柔和的分隔。Scroll 使用 layoutWeight(1) 占据除头部和底部导航之外的剩余空间,scrollBar(BarState.Off) 隐藏滚动条保持界面简洁。

条件渲染核心if / else if 条件链根据 currentTab 的值决定渲染哪个 Tab 的 @Builder 函数——0 为心情、1 为纪念、2 为铃音、3 为我的。这种设计确保同一时间只有一个 Tab 的内容被渲染,节省了内存和渲染资源。chartCard 始终显示在所有 Tab 内容的下方,作为通用的月度日记柱状图。

弹窗的 Stack 层叠:三个弹窗面板各自包裹在独立的 if 语句中,通过对应的布尔状态控制显隐。每个弹窗面板接收一个 onClose 回调函数(() => { this.xxxModal = false }),用于点击遮罩或取消按钮时关闭弹窗。三个弹窗使用独立的 if 而非 if/else if,虽然同一时间最多只有一个弹窗打开,但独立 if 在代码结构上保持了灵活性。

整体背景色 COLORS.bg#FFF5F7 浅粉背景)设置在最外层 Stack 上,贯穿整个页面。


十四、头部构建 headerMain

  /** 头部:应用名 + 授权呼吸胶囊 + 3 个恋爱数据小卡 */
  @Builder
  headerMain() {
    Column({ space: 10 }) {
      Row() {
        Column({ space: 3 }) {
          Text('蜜语').fontSize(17).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Text('情侣恋爱日记 · 双人甜蜜空间').fontSize(9).fontColor(COLORS.sub)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        // 通知授权状态胶囊(呼吸灯闪烁提示未授权)
        Row({ space: 5 }) {
          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: 8, right: 8, top: 4, bottom: 4 })
        .backgroundColor(this.granted ? COLORS.greenL : COLORS.redL)
        .borderRadius(10)
        .onClick(() => {
          if (!this.granted) {
            this.requestAuth();
          }
        })
      }
      .width('100%')

头部区域是用户进入应用后首先看到的视觉区域,承担了品牌展示和授权引导两项职责。

品牌标识区:左侧是应用名"蜜语"(17px 粗体深色)和副标题"情侣恋爱日记 · 双人甜蜜空间"(9px 副文本色)。layoutWeight(1) 使品牌区占据除授权胶囊外的全部水平空间,alignItems(HorizontalAlign.Start) 确保文字左对齐。

通知授权胶囊是头部最精巧的设计。它是一个 Row 容器,内含一个 6px 圆形指示灯和文字标签。指示灯颜色和文字内容都由 granted 状态决定——已授权时绿色圆点 + "已授权"绿色文字 + 浅绿背景;未授权时红色圆点 + "未授权·点击授权"红色文字 + 浅红背景。

呼吸闪烁效果opacity(this.breath ? 1 : 0.35) 使指示灯的透明度每秒在 100% 和 35% 之间交替——这就是"呼吸灯"效果,它在视觉上传达了"这个状态需要你关注"的信号。已授权状态下指示灯也是呼吸的,但绿色呼吸传达"正常运行"的安心感;未授权状态下红色呼吸传达"需要操作"的紧迫感。

点击交互onClick 回调中检查 if (!this.granted) ——只有未授权时才触发 requestAuth()。已授权状态下点击不产生任何操作,避免了不必要的授权请求。

      // 头部下排 3 个数据小卡
      Row({ space: 10 }) {
        Column({ space: 3 }) {
          Text('恋爱天数').fontSize(9).fontColor(COLORS.text3)
          Text('586 天').fontSize(11).fontColor(COLORS.pink).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
        .padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.card).borderRadius(10)

        Column({ space: 3 }) {
          Text('甜蜜日记').fontSize(9).fontColor(COLORS.text3)
          Text('218 篇').fontSize(11).fontColor(COLORS.purple).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
        .padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.card).borderRadius(10)

        Column({ space: 3 }) {
          Text('已发提醒').fontSize(9).fontColor(COLORS.text3)
          Text(this.sentCount.toString() + ' 条').fontSize(11).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
        .padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.card).borderRadius(10)
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 12, bottom: 10 })
  }

头部下排的 3 个数据小卡是"关键指标一览"设计,让用户在打开应用的第一秒就能看到最重要的三项恋爱数据。

三卡等宽布局:三个 Column 各自使用 layoutWeight(1) 等分水平空间,间距 10px。每张卡片包含一个小号标签(9px 三级文本色)和一个稍大的数值(11px 粗体),数值使用不同的品牌色——恋爱天数用蜜桃粉、甜蜜日记用薰衣草紫、已发提醒用金色——三色对应三个不同的数据维度,避免了视觉单调。

动态数据:第三张卡片的"已发提醒"数值使用 this.sentCount.toString() + ' 条'——这是动态绑定,每次发布通知成功后 sentCount 递增,该卡片自动更新。这种"头部展示动态数据"的设计让用户每次发布通知后都能在头部看到计数变化,提供了操作反馈。


十五、心情 Tab 构建 tabMood

  /** 心情 Tab:Canvas 六维雷达图 + 今日心情选择 + 六维图例 */
  @Builder
  tabMood() {
    Column({ space: 12 }) {
      // 雷达图卡
      Column({ space: 10 }) {
        Row() {
          Text('💖 恋爱六维雷达').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('本月评估').fontSize(9).fontColor(COLORS.text3)
        }
        .width('100%')

        Canvas(this.radarCtx).width('100%').height(190)
          .onReady(() => {
            this.canvasReady = true;
            this.drawRadarChart();
          })
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

心情 Tab 的第一段是雷达图卡片。

标题行:左侧"💖 恋爱六维雷达"(13px 粗体标题色),中间用 Column().layoutWeight(1) 推开,右侧"本月评估"(9px 三级文本色)作为时间范围标注。

Canvas 组件Canvas(this.radarCtx) 将前面声明的 radarCtx 绑定到 Canvas 组件,width('100%')height(190) 设置画布尺寸。onReady 回调在 Canvas 组件完成初始化后触发——此时将 canvasReady 设为 true(使呼吸定时器可以开始驱动重绘),并立即调用 drawRadarChart() 绘制第一帧雷达图。这是 Canvas 绘制的标准启动模式:等待 onReady 后才能开始绘制,否则 Canvas 上下文可能尚未准备好。

      // 今日心情选择条
      Column({ space: 8 }) {
        Text('🌤️ 今日心情').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Row({ space: 8 }) {
          ForEach(MOOD_OPTIONS, (m: string, idx: number) => {
            Text(m).fontSize(11)
              .fontColor(this.moodIdx === idx ? COLORS.white : moodColor(m))
              .fontWeight(this.moodIdx === idx ? FontWeight.Bold : FontWeight.Normal)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 8, bottom: 8 })
              .backgroundColor(this.moodIdx === idx ? moodColor(m) : COLORS.chip)
              .borderRadius(10)
              .onClick(() => {
                this.moodIdx = idx;
              })
          }, (m: string) => m)
        }
        .width('100%')
        Text('记录心情,让 TA 第一时间懂你~')
          .fontSize(9).fontColor(COLORS.sub)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

心情 Tab 的第二段是今日心情选择条。

四选项等宽布局ForEach 遍历 MOOD_OPTIONS(开心、平静、委屈、生气),每个选项使用 layoutWeight(1) 等分水平空间。选中态和未选中态的视觉对比通过三组属性实现:fontColor(选中白色、未选中心情色)、fontWeight(选中粗体、未选中常规)、backgroundColor(选中心情色背景、未选中芯片色背景)。

moodColor 函数的应用:每个心情选项的颜色由 moodColor(m) 函数返回——开心粉、平静紫、委屈蓝、生气红。选中时整个选项变为对应颜色背景 + 白色文字,未选中时为浅色背景 + 对应颜色文字。这种"颜色编码"设计让用户可以通过颜色快速识别心情类型。

底部提示文字:"记录心情,让 TA 第一时间懂你~"使用 9px 副文本色,maxLines(1)textOverflow: Ellipsis 确保文字过长时省略而非换行,保持卡片高度稳定。

      // 六维图例(2 列 3 行小卡)
      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
        ForEach(RADAR_LABELS, (label: string, idx: number) => {
          Row({ space: 8 }) {
            Circle().width(8).height(8).fill(COLORS.pink)
            Column({ space: 2 }) {
              Text(label).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Text('得分 ' + Math.round(RADAR_VALUES[idx] * 100).toString() + ' 分')
                .fontSize(8).fontColor(COLORS.text3)
            }
            .alignItems(HorizontalAlign.Start).layoutWeight(1)
            Text(Math.round(RADAR_VALUES[idx] * 100).toString() + '%')
              .fontSize(12).fontColor(COLORS.pink).fontWeight(FontWeight.Bold)
          }
          .width('48%').padding(10)
          .backgroundColor(COLORS.card).borderRadius(10).margin({ bottom: 10 })
        }, (label: string) => label)
      }
      .width('100%')
    }
    .width('100%')
  }

心情 Tab 的第三段是六维图例卡片网格。

Flex 换行布局Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) 创建了一个可换行的弹性容器。每个图例卡片宽度为 48%(不到 50%,留出间距),SpaceBetween 对齐使两张卡片之间自动产生间距。6 个图例卡片形成 3 行 2 列的网格。

图例卡片内容:每个卡片左侧是 8px 粉色圆点(与雷达图数值多边形颜色一致),中间是维度名称(11px 粗体标题色)和"得分 XX 分"(8px 三级文本色),右侧是百分比数值(12px 粗体蜜桃粉)。Math.round(RADAR_VALUES[idx] * 100) 将 0~1 的数值转换为 0~100 的整数百分比——0.9 变为"90 分"和"90%“,0.7 变为"70 分"和"70%”。

图例与雷达图的联动:图例中的百分比数值与雷达图中多边形顶点的位置直接对应——90% 的维度在雷达图上更接近外圈,70% 的维度更接近中心。用户可以通过图例快速定位雷达图上各维度的具体数值。


十六、纪念 Tab 构建 tabMemo

  /** 纪念 Tab:竖向时间轴(固定行高 72,纪念提醒按钮 + 编辑入口) */
  @Builder
  tabMemo() {
    Column({ space: 10 }) {
      // 标题行 + 新增纪念日入口
      Row() {
        Text('🎂 纪念日时间轴').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('共 ' + this.memoList.length.toString() + ' 个').fontSize(9).fontColor(COLORS.text3)
        Text('+ 新增纪念日').fontSize(9).fontColor(COLORS.pink)
          .padding({ left: 8, right: 8, top: 4, bottom: 4 })
          .backgroundColor(COLORS.pinkL).borderRadius(8)
          .onClick(() => {
            this.addModal = true;
          })
      }
      .width('100%')

纪念 Tab 的标题行展示了时间轴的概要信息和新增入口。

标题行布局:左侧标题"🎂 纪念日时间轴",中间 Column().layoutWeight(1) 推开,右侧依次是总数统计"共 N 个"和新增按钮"+ 新增纪念日"。总数使用 this.memoList.length.toString() 动态绑定——当用户新增或删除纪念日时,该数字自动更新。新增按钮使用蜜桃粉文字 + 浅粉背景,点击时 this.addModal = true 打开新增弹窗。

      // 竖向时间轴(每行固定高 72)
      ForEach(this.memoList, (item: MemoItem, idx: number) => {
        Row({ space: 10 }) {
          // 左侧日期 + 状态
          Column({ space: 3 }) {
            Text(item.time).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text(item.status).fontSize(8).fontColor(memoColor(item.status))
          }
          .width(44).height('100%').alignItems(HorizontalAlign.Start).padding({ top: 12 })

          // 中间圆点 + 连接竖线
          Column() {
            Circle().width(8).height(8).fill(memoColor(item.status))
            if (idx < this.memoList.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.days + ' 天').fontSize(9).fontColor(COLORS.sub)
            }
            .alignItems(HorizontalAlign.Start).layoutWeight(1)

            // 操作按钮组:纪念提醒 + 编辑
            Column({ space: 4 }) {
              Text('纪念提醒').fontSize(9).fontColor(COLORS.pink)
                .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                .backgroundColor(COLORS.pinkL).borderRadius(8)
                .onClick(() => {
                  this.publishNotice();
                })
              Text('编辑').fontSize(9).fontColor(COLORS.text3)
                .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                .backgroundColor(COLORS.chip).borderRadius(8)
                .onClick(() => {
                  this.editIdx = idx;
                  this.editDate = item.time;
                  this.editTitle = item.title;
                  this.editDays = item.days;
                  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: MemoItem) => item.time + item.title)
    }
    .width('100%')
  }

竖向时间轴是纪念 Tab 的核心视觉元素,它通过三列布局实现了"日期—轴线—卡片"的经典时间轴结构。

三列布局:每行 Row 包含三列——左侧日期列(宽 44px)、中间轴线列(宽 10px)、右侧纪念日卡片列(layoutWeight(1) 占剩余空间)。固定行高 height(72) 确保所有行视觉一致,alignItems(VerticalAlign.Top) 使三列从顶部对齐。

左侧日期列:显示日期(如"01-14",11px 粗体标题色)和状态(如"已纪念",8px 状态色)。memoColor(item.status) 为状态文字着色——金色、粉色或紫色,与中间圆点颜色一致。

中间轴线列:顶部是 8px 圆点(状态色),圆点下方是 2px 宽的连接竖线(浅粉线条色)。连接线使用 Column().layoutWeight(1) 占据圆点下方的全部剩余空间,形成"圆点—竖线—下一行圆点"的连续轴线。最后一个条目(idx < this.memoList.length - 1false)不显示连接线,表示时间轴的末端。

右侧纪念日卡片:左侧是纪念日名称(12px 粗体,maxLines(1) + 省略号防止换行)和天数(9px 副文本色,格式"第 N 天")。右侧是两个操作按钮——“纪念提醒”(蜜桃粉文字 + 浅粉背景,点击调用 publishNotice() 发布通知)和"编辑"(三级文本色文字 + 芯片色背景,点击时将当前条目的数据填充到编辑表单状态变量中,然后打开编辑弹窗)。

ForEach 键值函数 (item: MemoItem) => item.time + item.title:使用日期+名称的组合字符串作为列表项的唯一键,确保列表更新时能正确识别哪些项发生了变化、哪些项被增删。这种组合键比单一字段键更可靠,因为不同的纪念日可能有相同的日期或名称,但不会同时相同。


十七、铃音 Tab 构建 tabRing

铃音 Tab 是整个应用技术含量最高的页面,包含四个功能卡片:铃声生成器、当前默认铃声、铃声库列表、发布通知入口。

17.1 沙箱铃声生成器

      // 1. 沙箱铃声生成器卡
      Column({ space: 10 }) {
        Text('🎛️ 沙箱铃声生成器').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

        Row({ space: 8 }) {
          Text('频率').fontSize(10).fontColor(COLORS.sub)
          Text(this.genFreq.toString() + ' Hz').fontSize(10).fontColor(COLORS.pink).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('220 ~ 1760 Hz').fontSize(8).fontColor(COLORS.text3)
        }
        .width('100%')

        Slider({ value: this.genFreq, min: 220, max: 1760, step: 20, style: SliderStyle.OutSet })
          .selectedColor(COLORS.pink).trackColor(COLORS.chip).blockColor(COLORS.pink)
          .width('100%')
          .onChange((value: number) => {
            this.genFreq = value;
          })

        Row({ space: 8 }) {
          Text('时长').fontSize(10).fontColor(COLORS.sub)
          Text(this.genDuration.toString() + ' ms').fontSize(10).fontColor(COLORS.purple).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('600 ~ 2400 ms').fontSize(8).fontColor(COLORS.text3)
        }
        .width('100%')

        Slider({ value: this.genDuration, min: 600, max: 2400, step: 100, style: SliderStyle.OutSet })
          .selectedColor(COLORS.purple).trackColor(COLORS.chip).blockColor(COLORS.purple)
          .width('100%')
          .onChange((value: number) => {
            this.genDuration = value;
          })

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

铃声生成器卡片提供了频率和时长两个可调参数,用户通过拖动滑块自定义铃声参数。

双滑块设计:频率滑块(220~1760 Hz,步长 20,蜜桃粉主题色)和时长滑块(600~2400 ms,步长 100,薰衣草紫主题色)。每个滑块上方有参数标签行——左侧参数名"频率"/“时长”,中间当前值(粉色/紫色粗体),右侧取值范围提示。SliderStyle.OutSet 使滑块手柄在轨道外侧,更易于触摸操作。

实时绑定:滑块的 onChange 回调将当前值赋给 genFreq / genDuration 状态变量。由于状态变量是 @State 装饰的,赋值后滑块上方的数值显示会立即更新——用户拖动滑块时数值实时变化,提供了即时的参数反馈。

生成按钮:蜜桃粉背景 + 白色粗体文字"生成铃声到沙箱",点击调用 createRingByGen()——该方法使用当前 genFreqgenDuration 生成 WAV 音频字节,写入沙箱,并在铃声库列表底部添加新条目。整个流程"一键完成":参数调整 → 点击生成 → 字节构建 → 沙箱写入 → 列表更新。

17.2 当前默认铃声卡

      // 2. 当前默认铃声卡(展示 sound 字段实际取值)
      Column({ space: 8 }) {
        Row() {
          Text('🎵 当前默认铃声').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('沙箱文件 ' + this.sandboxCount.toString() + ' 个').fontSize(9).fontColor(COLORS.green)
        }
        .width('100%')

        Text(this.currentRingIdx >= 0 && this.currentRingIdx < this.ringList.length ?
          this.ringList[this.currentRingIdx].name : '暂无铃声')
          .fontSize(15).fontColor(COLORS.pink).fontWeight(FontWeight.Bold)

        Text(this.getSoundValue()).fontSize(8).maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .fontColor(COLORS.sub).width('100%').padding(8)
          .backgroundColor(COLORS.chip).borderRadius(8)
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

当前默认铃声卡展示了通知铃声的核心配置状态。

标题行:左侧"🎵 当前默认铃声",右侧"沙箱文件 N 个"(绿色文字,动态绑定 sandboxCount)。沙箱文件计数让用户了解当前有多少铃声已导入沙箱。

铃声名称:15px 蜜桃粉粗体显示当前默认铃声的名称(如"心动提示")。边界检查 currentRingIdx >= 0 && currentRingIdx < ringList.length 确保索引有效,无效时显示"暂无铃声"。

sound 字段值展示:这是整个卡片最技术化的部分——this.getSoundValue() 的返回值(如 uri::file:///data/.../ring_880.wav)以 8px 字号、2 行省略的方式展示在芯片色背景区域中。这个看似"调试信息"的展示实际上具有教学意义:它让用户(和技术学习者)直观看到 NotificationRequest.sound 字段的实际取值格式,理解 uri:: 前缀如何将沙箱路径转换为通知系统可识别的 URI。maxLines(2) 和省略号确保 URI 过长时不会破坏布局。

17.3 铃声库列表

      // 3. 铃声库列表
      Column({ space: 8 }) {
        Text('📁 铃声库(导入沙箱后即可设为通知铃声)')
          .fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

        ForEach(this.ringList, (item: RingItem, idx: number) => {
          Column({ space: 8 }) {
            Row({ space: 8 }) {
              Text('🎵').fontSize(14)
              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.white)
                      .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                      .backgroundColor(COLORS.pink).borderRadius(5)
                  }
                }
                Text(item.freq.toString() + ' Hz · ' + item.duration.toString() + ' ms · ' + item.size)
                  .fontSize(8).fontColor(COLORS.text3)
              }
              .alignItems(HorizontalAlign.Start).layoutWeight(1)

              Text(item.inSandbox ? '沙箱中' : '未导入').fontSize(8)
                .fontColor(item.inSandbox ? COLORS.green : COLORS.gold)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .backgroundColor(item.inSandbox ? COLORS.greenL : COLORS.goldL)
                .borderRadius(6)
            }
            .width('100%')

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

铃声库列表是铃音 Tab 中最复杂的 UI 组件,它以卡片形式展示每个铃声条目,并提供导入、设为默认、删除三个操作。

每条铃声卡片的上方信息行:左侧🎵图标,中间是铃声名称(12px 粗体标题色)+ 频率/时长/大小描述(8px 三级文本色,格式"880 Hz · 1200 ms · 85 KB"),右侧是状态标签——“沙箱中”(绿色文字 + 浅绿背景)或"未导入"(金色文字 + 浅金背景)。如果该条目是当前默认铃声,名称旁边还会显示一个蜜桃粉"默认"小标签。

条件渲染的导入按钮if (!item.inSandbox) 只在铃声未导入沙箱时显示"导入沙箱"按钮(金色主题)。已导入后该按钮消失,操作行从三按钮变为两按钮(设为默认 + 删除)。这种"状态驱动按钮显隐"的设计避免了用户对已导入铃声重复执行导入操作。

三色操作按钮:导入沙箱(金色)、设为默认(蜜桃粉)、删除(红色)——三色对应三种不同性质的操作:金色表示"准备性操作"(导入是使用的前提)、粉色表示"主操作"(设为默认是核心功能)、红色表示"危险操作"(删除不可逆)。颜色语义与操作性质的一致性,是优秀的交互设计。

删除的安全防护:删除操作不会直接执行,而是 this.delIdx = idx; this.delModal = true ——先记录待删除索引,然后弹出确认弹窗。这种"二次确认"机制防止了用户误触删除。

17.4 发布通知入口

      // 4. 行业发布通知卡
      Column({ space: 8 }) {
        Text('📣 发布纪念日提醒').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Text('为 TA 推送携带沙箱自定义铃声的纪念日提醒,甜蜜时刻不错过。')
          .fontSize(9).fontColor(COLORS.sub)
          .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text('立即发布提醒').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
          .width('100%').textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.pink).borderRadius(10)
          .onClick(() => {
            this.publishNotice();
          })
        Row() {
          Text('通知 ID:' + this.notifyId.toString()).fontSize(8).fontColor(COLORS.text3)
          Column().layoutWeight(1)
          Text('已发送:' + this.sentCount.toString() + ' 条').fontSize(8).fontColor(COLORS.text3)
        }
        .width('100%')
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

发布通知卡是铃音 Tab 的"终极操作入口"——将前面的铃声选择和沙箱导入汇总为一条通知发布到系统通知中心。

功能说明文字:“为 TA 推送携带沙箱自定义铃声的纪念日提醒,甜蜜时刻不错过。”——这句话点明了整个铃音 Tab 的核心价值:不是简单的通知推送,而是"携带自定义铃声"的通知推送。9px 副文本色 + 2 行省略确保文字在有限空间内完整展示。

发布按钮:蜜桃粉全宽按钮 + 白色粗体文字"立即发布提醒",点击调用 publishNotice()。这是整个通知铃声链路的终点操作。

底部状态行:左侧"通知 ID:N"(动态绑定 notifyId,展示下一条通知的 ID),右侧"已发送:N 条"(动态绑定 sentCount,展示已发布数量)。这两个数字为用户提供了操作历史的即时反馈——每次发布成功后,ID 递增、已发送计数递增,用户可以直观地看到累计操作量。


十八、我的 Tab 构建 tabMine

  /** 我的 Tab:恋爱天数渐变大卡 + 恋爱统计清单 */
  @Builder
  tabMine() {
    Column({ space: 12 }) {
      // 恋爱天数渐变大卡
      Column({ space: 8 }) {
        Text('💕 我们在一起').fontSize(11).fontColor('rgba(255,255,255,0.7)')
        Text('586 天').fontSize(34).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
        Text('始于 2024-01-14 的那场心动')
          .fontSize(9).fontColor('rgba(255,255,255,0.7)')
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Row({ space: 10 }) {
          Column({ space: 3 }) {
            Text('甜蜜指数').fontSize(8).fontColor('rgba(255,255,255,0.7)')
            Text('96').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column({ space: 3 }) {
            Text('心动次数').fontSize(8).fontColor('rgba(255,255,255,0.7)')
            Text('3,214').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column({ space: 3 }) {
            Text('下个纪念日').fontSize(8).fontColor('rgba(255,255,255,0.7)')
            Text('9 天后').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)
        }
        .width('100%').margin({ top: 6 })
      }
      .width('100%').padding(18).borderRadius(14)
      .linearGradient({ angle: 135, colors: [[COLORS.pinkD, 0], [COLORS.purple, 1]] })

我的 Tab 的第一段是渐变大卡——整个应用视觉冲击力最强的组件。

线性渐变背景.linearGradient({ angle: 135, colors: [[COLORS.pinkD, 0], [COLORS.purple, 1]] }) 创建了一个 135 度对角渐变——从深粉(#EC4899)到薰衣草紫(#A78BFA),起始位置 0%(左上角),结束位置 100%(右下角)。这种"粉紫渐变"是"蜜语"应用的标志性视觉元素,将两种品牌色融合在同一个表面上,传达了"甜蜜 + 神秘"的双重情感。

白色文字 + 半透明:在渐变背景上,所有文字都使用白色(COLORS.white)或半透明白色(rgba(255,255,255,0.7))。主数字"586 天"使用 34px 粗体——这是整个应用中最大的字号,强调了"恋爱天数"作为核心数据的重要性。标签文字(“我们在一起”、“始于…”、"甜蜜指数"等)使用 70% 透明度的白色,与数值文字形成"弱-强"的视觉层级。

三指标行:甜蜜指数(96)、心动次数(3,214)、下个纪念日(9 天后)——三个指标等宽排列,每项包含 8px 标签和 12px 数值。这三个指标从不同维度量化了恋爱关系——甜蜜指数是质量评估、心动次数是情感累积、下个纪念日是未来期待。

      // 恋爱统计清单
      ForEach(this.statList, (item: StatItem) => {
        Row({ space: 10 }) {
          Text(item.icon).fontSize(16)
          Column({ space: 3 }) {
            Text(item.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text(item.tag).fontSize(8).fontColor(COLORS.text3)
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1)
          Text(item.val).fontSize(13).fontColor(COLORS.pink).fontWeight(FontWeight.Bold)
        }
        .width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(10)
      }, (item: StatItem) => item.name)

恋爱统计清单以 5 行列表形式展示恋爱数据足迹。每行包含:左侧 Emoji 图标(16px)、中间统计项名称(11px 粗体标题色)+ 附加标签(8px 三级文本色)、右侧统计值(13px 粗体蜜桃粉)。这种"图标-名称-数值"的三列布局是数据清单的经典设计模式——左侧图标提供视觉识别,中间名称和标签提供上下文,右侧数值是用户最关心的"答案"。

      // 恋人信息卡
      Column({ space: 6 }) {
        Row() {
          Text('💑 我们的约定').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('进行中').fontSize(9).fontColor(COLORS.green)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor(COLORS.greenL).borderRadius(8)
        }
        .width('100%')
        Text('每周一部电影 · 每月一次短途旅行 · 吵架不过夜')
          .fontSize(9).fontColor(COLORS.sub)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(10)
    }
    .width('100%')
  }

恋人信息卡是"我的"Tab 的收尾元素,展示了情侣间的"约定"——“每周一部电影 · 每月一次短途旅行 · 吵架不过夜”。标题行右侧有绿色"进行中"状态标签,传达了"约定正在持续执行"的动态感。maxLines(1) + 省略号确保约定文字过长时不会破坏卡片布局。


十九、柱状图卡片 chartCard

  /** 通用图表卡:恋爱日记月度篇数柱状图(末柱呼吸放大) */
  @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, MONTH_VAL[i] / MONTH_MAX * 110 + 4) :
                Math.max(20, MONTH_VAL[i] / MONTH_MAX * 110))
              .borderRadius(4).backgroundColor(COLORS.pink)
            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)
  }

柱状图卡片是一个通用组件,显示在所有 Tab 内容的下方(chartCardbuild() 的条件链之外)。

纯 ArkUI 实现的柱状图:这个柱状图没有使用 Canvas,而是完全用 ArkUI 的 Column 组件堆叠实现——每根柱子是一个 Column 组件,宽度固定 16px,高度通过 MONTH_VAL[i] / MONTH_MAX * 110 动态计算(最大 110px),背景色为蜜桃粉,圆角 4px。柱子下方是月份标签(8px 三级文本色)。

末柱呼吸放大是柱状图的动画亮点。this.breath && i === MONTH_IDX.length - 1 判断是否为最后一根柱子(8 月),如果是且 breathtrue,高度增加 4px(+ 4)。这种"末柱呼吸"效果让最新月份的数据在视觉上"跳动",引导用户关注最新数据。Math.max(20, ...) 确保柱子最小高度为 20px,即使数据为 0 也有最小可见高度。

alignItems(VerticalAlign.Bottom) 使柱子从底部对齐——这是柱状图的标准视觉规范,所有柱子底部在同一水平线上,顶部参差不齐反映数据差异。height(150) 为整个柱状图行提供固定高度,确保布局稳定。


二十、底部导航 tabBar

  /** 底部导航:4 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 })
  }

底部导航栏采用 4 Tab 单排布局。

选中态视觉区分通过三组属性实现:图标 fontSize(选中 20px、未选中 17px)、图标 opacity(选中 100%、未选中 65%)、标签 fontColor(选中蜜桃粉 COLORS.tabOn、未选中三级文本色)、标签 fontWeight(选中粗体、未选中常规)。四组属性共同作用,使选中态在大小、透明度、颜色、粗细四个维度上都与未选中态形成明显对比。

顶部边框 .border({ width: { top: 1 }, color: COLORS.line }) 在导航栏顶部添加 1px 浅粉线条,与上方内容区形成柔和的分隔。width: { top: 1 } 只设置上边框宽度,避免四周边框造成的视觉干扰。

layoutWeight(1) 等宽布局:四个 Tab 各自使用 layoutWeight(1) 等分水平空间,无论屏幕宽度如何变化,四个 Tab 始终等宽排列。


二十一、弹窗系统

21.1 遮罩层 modalOverlay

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

遮罩层是所有弹窗的公共组件。它是一个全屏 Stack,内含一个半透明遮罩 ColumnCOLORS.maskrgba(67,40,64,0.5) 深粉紫 50% 透明度)。alignContent(Alignment.Center) 使后续弹窗面板在遮罩层中居中显示。onClick(() => onClose()) 使点击遮罩区域时关闭弹窗——这是移动端弹窗的标准交互模式。

21.2 新增纪念日弹窗 panelAdd

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

        Column({ space: 6 }) {
          Text(FORM_LABELS[0]).fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.formDate, placeholder: '如 09-03' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.formDate = value;
            })
        }
        .width('100%').alignItems(HorizontalAlign.Start)

        Column({ space: 6 }) {
          Text(FORM_LABELS[1]).fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.formTitle, placeholder: '如 一起旅行纪念日' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.formTitle = value;
            })
        }
        .width('100%').alignItems(HorizontalAlign.Start)

        Column({ space: 6 }) {
          Text(FORM_LABELS[2]).fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.formDays, placeholder: '如 586' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.formDays = value;
            })
        }
        .width('100%').alignItems(HorizontalAlign.Start)

        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.pink).borderRadius(9)
            .onClick(() => {
              this.saveMemo();
            })
        }
        .width('100%')
      }
      .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }

新增纪念日弹窗采用"遮罩 + 面板"的双层结构。

Stack 双层结构:底层是 modalOverlay(onClose) 提供遮罩,上层是 Column 面板内容。面板宽度 78%(不全屏,留出边距使遮罩可见),白色背景,14px 圆角,16px 内边距。alignContent(Alignment.Center) 使面板在 Stack 中居中。

三字段表单:每个字段由 Column 包裹——上方是字段标签(FORM_LABELS 中的"纪念日日期"/“纪念日名称”/“纪念天数”,9px 副文本色),下方是 TextInput 组件(11px 标题色,芯片色背景,8px 圆角)。TextInputtext 参数绑定到对应的 @State 变量(formDateformTitleformDays),onChange 回调将输入值同步到状态变量。placeholder 提供输入示例。

双按钮底部:取消(芯片色背景 + 副文本色文字)和保存(蜜桃粉背景 + 白色粗体文字),两个按钮 layoutWeight(1) 等宽排列。取消按钮调用 onClose() 关闭弹窗,保存按钮调用 saveMemo() 执行保存逻辑(内部会清空表单并关闭弹窗)。

21.3 编辑纪念日弹窗 panelEdit

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

        Column({ space: 6 }) {
          Text(FORM_LABELS[0]).fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.editDate, placeholder: '纪念日日期' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.editDate = value;
            })
        }
        .width('100%').alignItems(HorizontalAlign.Start)

        Column({ space: 6 }) {
          Text(FORM_LABELS[1]).fontSize(9).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%').alignItems(HorizontalAlign.Start)

        Column({ space: 6 }) {
          Text(FORM_LABELS[2]).fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.editDays, placeholder: '纪念天数' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.editDays = value;
            })
        }
        .width('100%').alignItems(HorizontalAlign.Start)

        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.pink).borderRadius(9)
            .onClick(() => {
              this.updateMemo();
            })
        }
        .width('100%')
      }
      .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }

编辑弹窗的结构与新增弹窗几乎完全一致——同样三个字段、同样双按钮布局。区别在于:标题为"编辑纪念日",TextInput 绑定的状态变量是 editDate / editTitle / editDays(而非 formDate / formTitle / formDays),保存按钮调用 updateMemo() 而非 saveMemo()

预填数据是编辑弹窗的关键特性。当用户在时间轴中点击"编辑"按钮时,回调中会将当前条目的数据填充到编辑表单状态变量中:

this.editIdx = idx;
this.editDate = item.time;
this.editTitle = item.title;
this.editDays = item.days;
this.editModal = true;

这样当编辑弹窗打开时,三个 TextInput 中已经预填了当前条目的值,用户可以在此基础上修改。这种"预填 + 修改"的交互模式比"清空 + 重新输入"更加友好。

21.4 删除铃声确认弹窗 panelDel

  /** 删除铃声确认弹窗面板 */
  @Builder
  panelDel(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 12 }) {
        Text('删除铃声').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Text('确认删除该铃声吗?删除后将同步清理沙箱中的音频文件,且不可恢复。')
          .fontSize(10).fontColor(COLORS.sub)
          .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
        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)
  }

删除铃声确认弹窗是"危险操作二次确认"的典型实现。

没有表单字段:与新增和编辑弹窗不同,删除弹窗没有 TextInput 字段——它只需要用户确认"是"或"否",不需要输入任何数据。弹窗内容只有标题和说明文字。

警示性说明文字:“确认删除该铃声吗?删除后将同步清理沙箱中的音频文件,且不可恢复。”——这句话明确告知用户删除的后果:不仅列表中的铃声会被移除,沙箱中的音频文件也会被删除,且操作不可逆。maxLines(2) + 省略号确保文字在弹窗内完整展示。

红色确认按钮:“确认删除"按钮使用红色背景(COLORS.red)而非蜜桃粉——红色是"危险操作"的通用颜色,警告用户这是不可逆操作。取消按钮使用芯片色背景 + 副文本色文字,视觉上弱于确认按钮,引导用户优先选择"取消”。确认按钮调用 delRing() 执行删除(内部会清理沙箱文件、移除列表条目、修正索引、关闭弹窗)。


二十二、4 种 Tab 布局对比表

维度心情 tabMood纪念 tabMemo铃音 tabRing我的 tabMine
布局风格Canvas 雷达图 + 选择条 + 图例网格竖向时间轴(固定行高 72)生成器 + 当前铃声 + 铃声库 + 发布入口渐变大卡 + 统计清单 + 约定卡
核心视觉元素Canvas 雷达图 + 半透明粉色填充圆点 + 连接竖线 + 状态色双滑块 + sound URI 展示135 度粉紫渐变 + 大号天数
数据模型RADAR_LABELS / RADAR_VALUESMemoItem (4 字段)RingItem (6 字段)StatItem (4 字段)
动画效果雷达图节点呼吸脉动无(静态时间轴)滑块拖动实时反馈渐变背景(静态)
颜色策略蜜桃粉主色 + 心情四色映射状态三色(金/粉/紫)操作三色(金/粉/红)粉紫渐变 + 白色文字
交互设计心情选择 + Canvas 联动新增 + 编辑 + 纪念提醒生成 + 导入 + 设默认 + 删除 + 发布查看统计 + 约定状态
信息密度中(3 段卡片)中(6 行时间轴)高(4 段卡片 + 列表)中(大卡 + 5 行清单)
技术亮点Canvas 2D API 绘图ForEach + 组合键 + 固定行高Notification Kit + 沙箱 I/OlinearGradient 渐变
适用场景恋爱关系可视化纪念日管理与提醒通知铃声全生命周期个人数据总览

二十三、技术要点总结表

技术维度实现方式关键代码/机制
状态管理@State 装饰器 + 响应式更新22 个 @State 变量 + 3 个 private 变量
可观察对象@Observed 装饰器MemoItem / RingItem / StatItem 三个模型类
组件化@Builder 构建函数4 个 Tab + 3 个弹窗 + 头部 + 导航 + 图表 + 遮罩
条件渲染if / else if 条件链根据 currentTab 值切换 4 个 Tab 内容
通知授权Notification Kit 两段式策略requestEnableNotification + openNotificationSettings
自定义铃声沙箱音频 + uri:: 前缀sound = ‘uri::’ + fileUri.getUriFromPath(沙箱路径)
沙箱文件 I/OfileIo 同步操作openSync / writeSync / closeSync / unlinkSync
EL1 加密区域contextConstant.AreaMode.EL1通知铃声音频存储在设备级加密区
音频生成ArrayBuffer + DataViewbuildWavBytes 构造 RIFF/WAVE 44 字节头 + PCM 数据
Canvas 绘图CanvasRenderingContext2DdrawRadarChart 三层网格 + 数值多边形 + 标签
呼吸动画setInterval 定时器 + breath 状态1 秒翻转驱动胶囊闪烁 + 雷达图脉动 + 末柱放大
弹窗系统Stack 层叠 + 条件渲染modalOverlay + panelAdd / panelEdit / panelDel
色彩管理ColorPalette 接口 + 常量23 个颜色字段,蜜桃粉 + 薰衣草紫主题
数据映射纯函数映射moodColor / memoColor 状态色映射
列表渲染ForEach + 键值生成函数每个列表使用唯一组合键策略
布局技术Stack / Column / Row / Scroll / Flex层叠 + 弹性 + 滚动 + 换行多种布局组合
线性渐变linearGradient 属性135 度粉紫渐变用于"我的"Tab 大卡
生命周期aboutToAppear / aboutToDisappear授权查询 + 定时器创建与清理

二十四、总结

"蜜语"情侣恋爱日记作为一款基于 HarmonyOS ArkUI 框架开发的情感社交应用 Demo,在多 Tab 架构设计、Notification Kit 自定义铃声、Canvas 雷达图绘制、色彩体系构建、弹窗系统管理等多个技术维度上展现了丰富的实践价值,值得深入研究和学习。

架构设计方面,应用采用了"单页面 + 条件渲染 + Stack 层叠"的三重架构模式。4 个 Tab 共享同一个页面实例,通过 currentTab 状态变量控制内容区域的渲染方向,避免了多页面实例管理的复杂性。Stack 层叠布局使三层弹窗系统能够自然覆盖在主内容之上,实现了复杂的交互层级而不干扰底层内容的结构。aboutToAppearaboutToDisappear 生命周期的对称设计——前者创建定时器和查询授权状态,后者清理定时器——体现了规范的资源管理意识。整个应用的 25 个状态变量覆盖了导航、弹窗、通知、铃声、动画、Canvas、表单、列表八大领域,构成了一个完整而自洽的状态机。

Notification Kit 自定义铃声方面,这是本应用最具技术深度的功能链路。完整链路为:buildWavBytes 生成 WAV 音频字节 → saveRingToSandbox 写入 EL1 沙箱 files 目录 → getSoundValue 构建 'uri::' + fileUri 格式的 sound 字段值 → publishNotice 发布携带自定义铃声的通知。这条链路展示了 HarmonyOS 6.1.1 新特性——应用沙箱文件作为通知铃声——的完整工程实践。从 ArrayBuffer + DataView 字节级构造 RIFF/WAVE 文件头,到 fileIo 同步写入 EL1 加密区域,再到 fileUri.getUriFromPath 路径转 URI,每一个环节都体现了对 HarmonyOS 系统 API 的深入理解和精准运用。两段式授权策略(requestEnableNotification 首次弹窗 + openNotificationSettings 二次引导)确保了授权流程的用户友好性。

Canvas 雷达图绘制方面drawRadarChart 方法是数据可视化在 ArkUI 中的精巧实践。五层绘制逻辑——三层同心网格、六条轴线、数值多边形填充与描边、呼吸节点、维度标签——层层递进,从骨架到数据到标签,构建了一个完整的多维度可视化图表。与呼吸动画的联动设计(每秒重绘、节点半径 3.5px↔4px 交替)使静态图表获得了"生命感"。RenderingContextSettings(true) 开启抗锯齿确保了线条和填充的视觉质量。Canvas 的 clearRectbeginPathmoveTo / lineToclosePathfill / stroke 绘制流程,是 Canvas 2D API 的标准范式,为开发者提供了自定义图形渲染的完整参考。

色彩体系方面,“蜜桃粉 + 薰衣草紫 + 浅粉背景"的配色方案是这款应用最具辨识度的设计特征。ColorPalette 接口定义了 23 个颜色常量,覆盖了背景、卡片、芯片、三级文字、品牌色族(粉/紫/金/绿/红/蓝各含主色+浅色)、线条、遮罩等全场景需求。每个颜色族采用"主色 + 深色 + 浅色"的三级体系。蜜桃粉背景(#FFF5F7)模拟了温暖柔和的氛围,深紫粉标题色(#432840)避免了纯黑的生硬感,粉紫渐变(#EC4899#A78BFA)创造了"甜蜜 + 神秘"的视觉冲击。功能色的语义化使用——金色对应"已纪念/未导入”、绿色对应"已授权/沙箱中"、红色对应"未授权/删除"、蓝色对应"委屈"心情——形成了颜色与操作性质的一致性编码。

组件化方面,应用使用了 11 个 @Builder 构建函数,将页面拆分为头部、4 个 Tab 内容、柱状图、底部导航、遮罩层和 3 个弹窗面板。每个 @Builder 函数职责单一、逻辑清晰。三个数据模型类(MemoItemRingItemStatItem)均使用 @Observed 装饰,确保了属性级修改的响应式更新——编辑纪念日时只修改对应条目的属性值,@Observed 机制自动触发引用该条目的 UI 组件重新渲染,无需手动刷新整个列表。两个纯函数映射(moodColormemoColor)将业务状态与颜色解耦,新增状态类型时只需修改函数内部逻辑。

动画编排方面,应用采用"一个状态变量驱动多处视觉反馈"的优雅设计。breath 布尔值通过 setInterval 每秒翻转,联动了三处视觉反馈:通知授权胶囊的透明度交替(1 ↔ 0.35,呼吸灯效果)、雷达图节点半径脉动(4 ↔ 3.5px,数据脉动)、柱状图末柱高度放大(+4px,数据跳动)。这种"一状态多反馈"的设计比"每处动画各自维护独立定时器"更加高效和一致。纯 ArkUI 实现的柱状图(Column 高度动态计算)则展示了不依赖 Canvas 也能实现数据可视化的替代方案。

数据一致性方面,删除铃声操作(delRing)体现了对数据一致性的重视——它同时清理了内存数据(ringList.splice)和持久化文件(fs.unlinkSync),确保了"列表中不存在的铃声在沙箱中也不存在"。设为默认操作(setCurrentRing)的前置检查(未导入则自动导入)确保了"默认铃声一定在沙箱中可用"。通知发布操作(publishNotice)的前置检查(未导入则自动导入)确保了"发布的通知一定有可播放的铃声"。这些"前置保证"设计避免了运行时异常,提升了应用的健壮性。

改进方向方面,虽然 Demo 应用已经展现了很高的完成度,但在实际生产环境中仍有以下改进空间:使用 LazyForEach 替代 ForEach 以优化长列表(如铃声库超过 100 条时)的渲染性能;引入路由管理处理纪念日详情页等深层页面跳转;将数据模型和 Mock 数据抽取到独立模块文件中实现关注点分离;增加网络请求层实现真实铃声文件下载和云同步;添加通知发布失败的错误提示和引导授权流程;使用 @StorageLink@Provide/@Consume 实现跨组件状态共享;引入 @Watch 监听授权状态变化以触发更精细的 UI 更新;将 WAV 音频生成替换为更丰富的音频合成方案(如多频率叠加、包络曲线优化);考虑使用 requestPermissionsFromUser 统一处理运行时权限请求。

总的来说,"蜜语"通过蜜桃粉主题的甜蜜配色、Canvas 雷达图的数据可视化、Notification Kit 沙箱自定义铃声的技术突破、4 种差异化 Tab 布局的视觉多样性、呼吸动画的细腻微交互,以及三层弹窗系统的完整交互闭环,成功地构建了一个功能完整、视觉优雅、技术扎实、体验流畅的情侣恋爱日记应用 Demo。它不仅展示了 ArkUI 框架在情感社交应用场景中的强大表现力,更展示了 HarmonyOS 6.1.1 通知铃声新特性的工程实践路径——为情感社交类应用的设计与技术实现提供了有价值的参考范例。

附录: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.1Release✅ 已安装

界面顶部提示:“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 246.1.1.100Release✅ 已安装
API Version 236.1.0.28Beta1未安装
API Version 226.0.2.112Release未安装

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

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

在这里插入图片描述


三、小结

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

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


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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐