深色活力橙主题的HarmonyOS运动健身App:Notification Kit沙箱铃声与Canvas数据可视化双引擎实战
一、技术前言

HarmonyOS的ArkUI框架是华为为鸿蒙生态打造的声明式UI开发范式,它以ArkTS语言为基础,融合了TypeScript的类型安全与声明式UI的简洁表达力。在ArkUI中,开发者通过@Entry、@Component、@State、@Builder等装饰器构建页面结构,状态变化自动驱动UI刷新,无需手动操作DOM。这种模式大幅降低了复杂界面的开发成本,尤其在运动健身这类需要频繁数据更新和交互重绘的场景中,声明式UI的优势更加显著——心率、消耗、时长等实时数据只需更新状态变量,界面便会自动同步,开发者可以将精力集中在业务逻辑而非界面同步上。

Notification Kit是HarmonyOS 6.1.1版本引入的重要能力升级,它允许开发者在通知请求中通过sound字段指定自定义铃声。这一特性的核心突破在于:通知不再局限于系统默认提示音,而是可以引用应用沙箱目录中的音频文件。具体实现方式是将沙箱路径通过fileUri.getUriFromPath()转换为URI,再以uri::为前缀拼接到NotificationRequest.sound字段中。这意味着运动健身App可以为不同类型的训练提醒(晨间有氧、力量训练、瑜伽课程等)配置不同的语义铃声,让用户在听到通知的第一时间就能辨别训练类型,极大提升了运动场景下的通知体验。

Canvas绘图是ArkUI中实现自定义数据可视化的核心手段。通过CanvasRenderingContext2D提供的2D绘图API,开发者可以在Canvas组件上绘制折线图、柱状图、饼图等各类图表。在运动健身场景中,数据可视化是用户感知训练效果的重要窗口——近7日运动时长的折线图能帮助用户直观看到训练趋势,月度卡路里消耗的柱状图能让用户感受长期坚持的成果。Canvas绘图配合ArkUI的动画系统(如setInterval驱动的呼吸动画),可以实现数据点动态放大、柱体实时呼吸等生动效果,让静态图表变为有温度的数据表达。

运动健身行业正经历从线下场馆向数字化平台的深度转型。现代运动健身App不再只是简单的计步器或计时器,而是集训练计划管理、私教课程预约、体测数据跟踪、社区互动于一体的综合平台。在HarmonyOS生态中,运动健身App可以充分利用系统级通知能力实现训练提醒、利用Canvas实现专业的数据可视化、利用沙箱文件系统管理用户生成的个性化资源(如自定义铃声),从而构建出原生级别的健身体验。

本篇分析的"燃力·运动健身平台"正是这样一款融合了多重技术能力的实践型应用。它采用深色活力橙+能量绿+炭黑底的配色方案,以4个布局完全不同的Tab页面(运动、计划、铃音、我的)构建完整业务闭环。运动页通过Canvas折线图展示周训练趋势,计划页以竖向时间轴组织训练日程,铃音页深度实践Notification Kit的沙箱自定义铃声特性,我的页通过柱状图和渐变大卡呈现体测与月度数据。整个应用不仅是一个技术演示,更是一套可落地的运动健身产品原型。

从设计理念来看,这款应用追求的是"技术深度与业务温度的平衡"。一方面,它深入到HarmonyOS底层文件系统、通知机制、Canvas绘图的细节,展示了硬核的技术功底;另一方面,它将这些技术能力包裹在运动健身这一充满活力的业务场景中,通过呼吸动画、渐变色卡、语义铃声等细节让冰冷的代码产生温度。这种理念使得应用既适合作为技术学习的范本,也适合作为产品设计的参考。

二、整体架构流程图
从架构图可以看出,整个应用以Stack为根容器,自上而下组织头部、内容区和导航栏三个层次。内容区通过currentTab状态变量实现四个页面的条件渲染切换,每个页面拥有独立的布局结构和业务职责。Notification Kit体系贯穿计划页和铃音页,形成从授权到沙箱文件操作再到通知发布的完整链路。Canvas体系由呼吸动画定时器驱动,每秒翻转breath状态并联动重绘折线图和柱状图,实现数据点呼吸放大效果。弹窗系统通过三个布尔状态控制三种弹窗的条件渲染,统一使用全屏遮罩加居中面板的模式。
三、颜色系统与常量定义
3.1 主题色板接口设计
/** 主题色板接口:集中声明页面所有颜色字段 */
interface ColorPalette {
bg: string;
card: string;
chip: string;
title: string;
sub: string;
text3: string;
white: string;
orange: string;
orangeD: string;
orangeL: string;
green: string;
greenL: string;
blue: string;
blueL: string;
red: string;
redL: string;
gold: string;
goldL: string;
purple: string;
purpleL: string;
line: string;
tabOn: string;
mask: string;
}
这段代码定义了一个名为ColorPalette的接口,它是整个应用颜色系统的类型契约。在ArkTS中,接口(interface)用于声明对象的结构形状,这里将所有页面用到的颜色字段集中到一个接口中,体现了"单一职责"的设计思想——颜色配置不散落在各处,而是统一管理。接口中包含了23个颜色字段,覆盖了背景色(bg)、卡片色(card)、标签色(chip)、多层级文本色(title/sub/text3)、主色及其深浅变体(orange/orangeD/orangeL、green/greenL等)、分割线色(line)、选中Tab色(tabOn)和遮罩色(mask),构成了一个完整的语义化色板体系。
值得注意的是,每种主色都搭配了深色变体(以D结尾)和浅色变体(以L结尾)。这种设计在深色主题中尤为常见:深色变体用于渐变起始或强调元素,浅色变体用于按钮背景或标签底色。例如orangeD用于渐变大卡的起始色,orangeL用于按钮的半透明背景。通过接口约束,任何使用颜色的地方都有类型提示,避免了字符串硬编码带来的拼写错误和维护困难。
3.2 深色主题色板常量
/** 深色主题色板常量(燃力 · 活力橙 + 能量绿 + 炭黑底) */
const COLORS: ColorPalette = {
bg: '#101014',
card: '#1A1B22',
chip: '#232530',
title: '#F2F4F8',
sub: '#9CA3B5',
text3: '#5B6472',
white: '#FFFFFF',
orange: '#FF6B35',
orangeD: '#E5511E',
orangeL: '#2E1B10',
green: '#4ADE80',
greenL: '#12291A',
blue: '#38BDF8',
blueL: '#0E2537',
red: '#F87171',
redL: '#331717',
gold: '#FBBF24',
goldL: '#332711',
purple: '#A78BFA',
purpleL: '#241F3D',
line: '#232530',
tabOn: '#FF6B35',
purpleL: '#241F3D',
line: '#232530',
tabOn: '#FF6B35',
mask: 'rgba(0,0,0,0.6)'
};
这里定义了COLORS常量,它是ColorPalette接口的具体实现。背景色#101014是接近纯黑的炭黑色,RGB值为16/16/20,略带冷色调,比纯黑#000000更加柔和,长时间观看不会产生强烈的视觉割裂感。卡片色#1A1B22在背景色基础上提亮了一档,RGB值为26/27/34,用于区分内容容器与背景层次。标签色#232530进一步提升亮度,用于输入框背景或Chip标签底色。
主题色活力橙#FF6B35是整个应用的视觉灵魂,它是一个高饱和度的暖橙色,RGB值为255/107/53,在深色背景上具有极强的视觉穿透力,用于关键数据、按钮、选中状态等需要强调的元素。能量绿#4ADE80作为辅助色,RGB值为74/222/128,用于成功状态、增长指标、已完成标记等正向反馈。浅色变体如orangeL: '#2E1B10'实际上是非常暗的橙色调,在深色背景上作为按钮背景时能产生"橙光透出"的微妙效果。遮罩色使用rgba(0,0,0,0.6)的半透明黑色,用于弹窗时遮盖底层内容。
3.3 Tab元数据与数据常量
/** Tab 元数据接口:底部导航图标 + 标签 */
interface TabMeta {
icon: string;
label: string;
}
/** 底部导航 Tab 常量列表(4 Tab 单排) */
const TAB_LIST: TabMeta[] = [
{ icon: '🏃', label: '运动' },
{ icon: '📅', label: '计划' },
{ icon: '🔔', label: '铃音' },
{ icon: '👤', label: '我的' }
];
/** 今日训练部位标签(运动页横滚 Chip 行) */
const BODY_TAGS: string[] = ['🔥 胸肩', '💪 背部', '🦵 腿臀', '🎯 核心', '🏃 有氧', '🧘 柔韧'];
/** 近 7 日运动时长(分钟,Canvas 折线图数据) */
const WEEK_VALUES: number[] = [46, 58, 72, 38, 65, 84, 52];
/** 折线图纵轴最大值(分钟) */
const WEEK_MAX: number = 90;
/** 折线图横轴星期标签 */
const WEEK_LABELS: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
/** 月度消耗柱状图月份标签(近 6 个月) */
const MONTH_NAME: string[] = ['03月', '04月', '05月', '06月', '07月', '08月'];
/** 月度消耗数值(kcal,Canvas 柱状图数据) */
const MONTH_VAL: number[] = [8600, 9200, 10400, 11800, 12600, 13200];
/** 月度消耗柱状图纵轴最大值(kcal) */
const MONTH_MAX: number = 14000;
TabMeta接口定义了底部导航每个Tab的图标和标签两个字段,图标使用Emoji字符而非图片资源,这是轻量化应用中常见的做法——Emoji在不同设备上有统一的渲染标准,且不需要额外管理图片资源文件。TAB_LIST数组定义了4个Tab的顺序和内容:运动、计划、铃音、我的,这个顺序遵循了"核心功能优先"的信息架构原则,将用户最高频使用的运动训练放在第一位。
BODY_TAGS定义了运动页横向滚动的训练部位Chip行,包含胸肩、背部、腿臀、核心、有氧、柔韧六大训练部位,每个标签前附带Emoji以增强视觉辨识。WEEK_VALUES数组存储了近7日的运动时长数据(单位分钟),数值为46到84不等,周六84分钟为峰值,与运动健身人群周末训练时间充裕的实际情况吻合。WEEK_MAX设为90分钟作为纵轴上限,略大于最大值84,确保折线图的最高点不会顶到图表边缘。MONTH_VAL数组记录了近6个月的卡路里消耗,从3月的8600 kcal递增到8月的13200 kcal,呈现稳步上升的健身趋势,这种数据设计让柱状图呈现出积极向上的视觉效果,激励用户坚持训练。
四、辅助函数与音频生成
4.1 训练状态色映射与时间格式化
/** 训练计划状态色映射:已完成绿 / 进行中蓝 / 待开练橙 / 其余弱化 */
function planColor(s: string): string {
if (s === '已完成') {
return COLORS.green;
} else if (s === '进行中') {
return COLORS.blue;
} else if (s === '待开练') {
return COLORS.orange;
}
return COLORS.text3;
}
/** 获取当前时刻 HH:mm 格式字符串(通知历史展示用) */
function nowTime(): string {
const d = new Date();
const hRaw: number = d.getHours();
const mRaw: number = d.getMinutes();
const hh: string = hRaw < 10 ? '0' + hRaw.toString() : hRaw.toString();
const mm: string = mRaw < 10 ? '0' + mRaw.toString() : mRaw.toString();
return hh + ':' + mm;
}
planColor函数是计划页时间轴的状态色映射器,它接收状态字符串参数,返回对应的主题色。这个函数虽然简单,但在整个应用中起到了关键的视觉编码作用——训练计划的每个状态都有对应的颜色语义:绿色代表已完成(成就感)、蓝色代表进行中(专注感)、橙色代表待开练(期待感)、灰色代表其他状态。这种状态色映射使用户在浏览时间轴时能通过颜色快速识别训练进度,无需逐条阅读文字。
nowTime函数用于生成当前时间的HH:mm格式字符串,在通知发送历史中记录发送时刻。函数通过new Date()获取当前时间,分别提取小时和分钟,对小于10的值补零前缀,最终拼接为"08:30"这样的标准格式。这种手动格式化方式虽然朴素,但不依赖任何第三方库,在ArkTS环境中运行可靠。
4.2 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;
}
这是整个应用中最具技术深度的辅助函数之一。buildWavBytes函数在内存中直接构造一个符合WAV文件格式规范的音频字节流,不需要任何音频录制设备或网络下载,纯代码生成。函数接收频率(Hz)和时长(毫秒)两个参数,返回一个ArrayBuffer,这个ArrayBuffer可以直接写入沙箱文件成为可播放的.wav音频。
WAV文件格式由文件头(44字节)和音频数据两部分组成。文件头中的关键参数包括:采样率44100Hz(CD音质标准)、声道数1(单声道)、位深度16bit(每个采样点用2字节表示)。代码中writeStr辅助函数用于将字符串(如'RIFF'、'WAVE'、'fmt '、'data')逐字节写入指定偏移位置,这些字符串是WAV格式的标识符。DataView的setUint32和setUint16方法以小端序(参数true)写入数值字段,如采样率、数据大小等。
音频数据生成部分使用了正弦波合成:Math.sin(2 * Math.PI * freq * t)生成频率为freq的正弦波。为了使声音更自然,函数引入了两个包络参数——env是起音包络,在前20毫秒内从0渐变到1,模拟声音的渐入效果,避免起始时的爆音;decay是衰减包络,从1线性衰减到0,模拟声音的渐弱。最终采样值v乘以0.5降低振幅,再乘以32767并取整转为16位有符号整数,通过setInt16写入。这种纯代码生成WAV音频的方式,在运动健身场景中可以快速为不同训练类型生成不同频率的提示音——低频440Hz适合舒缓的拉伸提醒,高频1760Hz适合冲刺训练提醒。
五、数据模型与Mock数据
5.1 可观察数据模型定义
/** 训练计划条目(计划 Tab 竖向时间轴) */
@Observed export class PlanItem {
time: string;
title: string;
status: string;
note: string;
constructor(time: string, title: string, status: string, note: string) {
this.time = time;
this.title = title;
this.status = status;
this.note = note;
}
}
/** 铃声条目(铃音 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;
}
}
这里定义了两个使用@Observed装饰器修饰的数据模型类。@Observed是ArkUI框架中的可观察对象装饰器,它使得被装饰的类的实例属性在被修改时能够触发与其绑定的UI组件刷新。这与@State的区别在于:@State用于组件内部的状态管理,而@Observed用于跨组件共享的数据模型,当多个组件引用同一个@Observed对象时,修改该对象的属性会让所有引用它的组件自动更新。
PlanItem类封装了训练计划的四个字段:时间(如"07:00")、标题(如"晨间有氧 · 3km 慢跑")、状态(如"已完成")、备注(如训练详情描述)。RingItem类封装了铃声的六个字段:名称、文件名、频率、时长、大小、是否已导入沙箱。其中inSandbox字段是一个关键的状态标记,它决定了铃声是否可以被设置为通知铃声——只有在沙箱中的音频文件才能通过uri::前缀被Notification Kit引用。RingItem类的属性修改(如inSandbox从false变为true,size从"—"变为"152 KB")会直接驱动UI上铃声列表的刷新,这就是@Observed装饰器的价值所在。
5.2 通知历史与统计模型
/** 通知发送历史条目(铃音 Tab 通知历史列表) */
@Observed export class NoticeLog {
time: string;
title: string;
ok: boolean;
constructor(time: string, title: string, ok: boolean) {
this.time = time;
this.title = title;
this.ok = ok;
}
}
/** 运动统计条目(我的 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;
}
}
NoticeLog类用于记录通知发送的历史,包含发送时间、通知标题和发送是否成功三个字段。ok布尔字段决定了历史列表中状态点的颜色——成功为绿色,失败为红色。当用户在铃音页点击"发布试听"按钮时,无论通知发送成功还是失败,都会通过addNoticeLog方法生成一条NoticeLog实例并插入到历史列表的最前面,最多保留6条。
StatItem类用于"我的"页面的运动统计清单,每条记录包含图标、名称、数值和标签四个字段。例如{ icon: '🏋️', name: '累计训练', val: '268 次', tag: '本周 +6 次' },这种结构设计让每条统计既展示绝对数值(268次),又展示相对变化(本周+6次),信息密度高但层次清晰。使用@Observed装饰器意味着当统计数据更新时(如用户完成一次新训练后累计次数+1),UI会自动刷新。
5.3 Mock数据填充
/** 今日训练计划 Mock 数据(9 条:力量/有氧/瑜伽/私教/拉伸等训练安排) */
const PLAN_LIST: Array<PlanItem> = [
new PlanItem('07:00', '晨间有氧 · 3km 慢跑', '已完成', '配速 6\'30"/km · 消耗 286 kcal'),
new PlanItem('09:30', '力量训练 · 胸肩塑形', '已完成', '杠铃卧推 4 组 × 12 · 哑铃飞鸟 3 组 × 15'),
new PlanItem('12:30', '午间核心激活', '进行中', '平板支撑 3 组 × 60s · 俄罗斯转体 3 组 × 20'),
new PlanItem('15:00', '私教课 · 体态矫正', '进行中', '陈教练 · 60 分钟 1v1 · 教室 A'),
new PlanItem('17:30', '瑜伽课 · 流瑜伽', '待开练', '教室 B · 75 分钟 · 剩余 3 席'),
new PlanItem('19:00', '力量训练 · 下肢爆发', '待开练', '深蹲 5 组 × 8 · 箭步蹲 3 组 × 12'),
new PlanItem('20:00', '有氧跑 · 5km 间歇', '待开练', '快慢交替 400m × 8 组 · 消耗约 420 kcal'),
new PlanItem('21:00', '拉伸恢复 · 筋膜放松', '待开练', '泡沫轴 15 分钟 · 静态拉伸 10 分钟'),
new PlanItem('21:45', '睡前呼吸冥想', '待开练', '4-7-8 呼吸法 · 10 分钟助眠')
];
/** 铃声库 Mock 数据(5 条:训练场景语义铃声,初始未导入沙箱) */
const RING_LIST: Array<RingItem> = [
new RingItem('燃力战鼓', 'ring_660.wav', 660, 1500, '—', false),
new RingItem('冲刺号角', 'ring_1320.wav', 1320, 1000, '—', false),
new RingItem('能量脉冲', 'ring_880.wav', 880, 1200, '—', false),
new RingItem('深缓呼吸', 'ring_440.wav', 440, 2000, '—', false),
new RingItem('极限高频', 'ring_1760.wav', 1760, 800, '—', false)
];
/** 运动统计 Mock 数据(5 条) */
const STAT_LIST: Array<StatItem> = [
new StatItem('🏋️', '累计训练', '268 次', '本周 +6 次'),
new StatItem('🔥', '累计消耗', '128,600 kcal', '相当于 14.8 kg 脂肪'),
new StatItem('✅', '课程完成率', '92%', '私教课 48/52 节'),
new StatItem('🏅', '成就徽章', '18 枚', '最新:百公里跑者'),
new StatItem('📅', '连续周练', '14 周', '最长纪录 16 周')
];
这三组Mock数据是整个应用的业务数据基座。PLAN_LIST包含9条训练计划,从早晨7:00的晨间有氧到晚上21:45的睡前冥想,覆盖了一天中力量训练、有氧运动、瑜伽课程、私教课程、拉伸恢复、冥想等多种训练类型,状态分布为2条已完成、2条进行中、5条待开练,模拟了一个真实训练日的动态过程。
RING_LIST包含5条铃声数据,每条对应不同的训练场景和频率:660Hz的"燃力战鼓"适合常规训练提醒,1320Hz的"冲刺号角"适合高强度间歇训练,880Hz的"能量脉冲"适合力量训练,440Hz的"深缓呼吸"适合瑜伽冥想,1760Hz的"极限高频"适合冲刺挑战。所有铃声初始inSandbox为false、size为"—",需要用户主动导入沙箱后才能获取文件大小并设为通知铃声。这种设计模拟了真实场景中用户从网络下载或本地生成铃声文件后导入应用的过程。
STAT_LIST包含5条运动统计数据,从累计训练次数到成就徽章,每条都有绝对数值和相对标签两个维度的信息。值得注意的是"累计消耗 128,600 kcal · 相当于 14.8 kg 脂肪"这一条,将抽象的卡路里数值转化为用户可感知的脂肪重量,是运动健身产品中常见的数据可视化策略——通过等价换算让数字更有温度。
六、组件主体与状态管理
6.1 组件声明与状态变量
/** 1106 燃力 · 运动健身平台主页面 */
@Entry
@Component
struct Page1106 {
/** 当前选中 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;
/** 柱状图 Canvas 就绪标志 */
@State barReady: boolean = false;
/** 已发布通知条数 */
@State sentCount: number = 0;
/** 呼吸动画定时器句柄 */
private timer: number = -1;
@Entry装饰器标记Page1106为应用入口页面,@Component声明它是一个ArkUI组件。组件内部使用大量@State装饰的状态变量来管理UI状态。这些状态变量可以分为四类:页面导航状态(currentTab)、弹窗开关状态(addModal/editModal/delModal)、业务数据状态(granted/notifyId/currentRingIdx/sandboxCount/sentCount)、动画与图表状态(breath/canvasReady/barReady)以及生成器参数(genFreq/genDuration)。
breath是一个核心动画状态变量,它每秒在true和false之间翻转,驱动多个UI元素的呼吸效果——头部授权胶囊的透明度和缩放、折线图末端数据点的放大、柱状图末柱的高度变化。canvasReady和barReady是Canvas就绪标志,它们在Canvas组件的onReady回调中被置为true,确保只有在Canvas准备完毕后才调用绘图方法,避免在Canvas未初始化时绘图导致的空指针异常。notifyId从100开始自增,每发布一条通知递增1,确保每条通知有唯一ID,不会被系统覆盖。
6.2 数据源与Canvas上下文
/** 训练计划列表(时间轴数据源) */
@State planList: Array<PlanItem> = PLAN_LIST;
/** 铃声库列表 */
@State ringList: Array<RingItem> = RING_LIST;
/** 通知发送历史列表(发布通知后记录) */
@State noticeLogs: Array<NoticeLog> = [];
/** 运动统计列表 */
@State statList: Array<StatItem> = STAT_LIST;
/** 新增表单字段:时间 / 名称 / 备注 */
@State formTime: string = '';
@State formTitle: string = '';
@State formNote: string = '';
/** 编辑表单字段:时间 / 名称 / 备注 */
@State editTime: string = '';
@State editTitle: string = '';
@State editNote: string = '';
/** 折线图 Canvas 上下文(近 7 日运动时长) */
private lineCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));
/** 柱状图 Canvas 上下文(月度消耗) */
private barCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));
数据源部分将Mock数据赋值给@State装饰的数组变量,使它们成为可观察的数据源。planList和ringList分别初始化为PLAN_LIST和RING_LIST常量,noticeLogs初始化为空数组,运行时会随通知发送动态填充。表单字段(formTime/formTitle/formNote和editTime/editTitle/editNote)用于弹窗中的输入框双向绑定。
Canvas上下文是Canvas绘图的关键基础设施。lineCtx和barCtx都是CanvasRenderingContext2D实例,通过new CanvasRenderingContext2D(new RenderingContextSettings(true))创建。RenderingContextSettings的参数true表示开启抗锯齿,让绘制的线条和图形边缘更加平滑。这两个上下文实例分别绑定到运动页和"我的"页的Canvas组件上,在onReady回调后通过自定义的绘图方法(drawLineChart和drawBarChart)进行绘制。需要注意的是,Canvas上下文声明为private而非@State,因为它不需要驱动UI刷新——Canvas的刷新是通过主动调用绘图方法实现的,而非状态绑定。
七、Notification Kit核心方法
7.1 通知授权请求
/** 请求通知授权(首次调用弹系统授权框;曾被拒绝则拉起通知设置页二次授权) */
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方法实现了HarmonyOS通知授权的完整流程。首先通过this.getUIContext().getHostContext()获取宿主上下文(UIAbilityContext),这是ArkTS中获取应用上下文的推荐方式(旧的getContext(this)已废弃)。获取到上下文后,调用notificationManager.requestEnableNotification()请求通知授权——如果是首次调用,系统会弹出授权对话框;如果用户曾经授权过,则直接返回成功。
方法的精妙之处在于catch分支的二次授权处理。当用户曾经拒绝授权时,requestEnableNotification会返回错误码1600004,此时不会直接放弃,而是调用notificationManager.openNotificationSettings()拉起系统通知设置页面,引导用户手动开启通知权限。这种"先请求、失败再引导手动开启"的两段式授权策略,是HarmonyOS通知权限管理的最佳实践,确保了即使用户曾误触拒绝,仍有清晰的路径重新开启。授权成功后this.granted = true会驱动头部授权胶囊从红色"去开启"变为绿色"通知已开启"。
7.2 沙箱音频文件写入
/** 将生成的音频写入沙箱 EL1 的 files 目录,返回沙箱路径 */
saveRingToSandbox(fileName: string, freq: number, durationMs: number): string {
// getContext(this) 已废弃,须用 getUIContext().getHostContext()
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方法是将音频写入应用沙箱的核心实现。方法首先获取宿主上下文,然后通过getApplicationContext()获取应用上下文。关键的一步是appCtx.area = contextConstant.AreaMode.EL1——这行代码将文件操作区域设置为EL1(设备级加密区域)。HarmonyOS的沙箱文件系统分为多个安全级别(EL1/EL2/EL3/EL4),Notification Kit要求自定义铃声文件必须位于EL1区域的filesDir目录下,这是一个安全约束:EL1区域的文件在设备解锁后可被系统服务(包括通知服务)访问,但应用间相互隔离。
文件写入使用同步API:fs.openSync以创建+只写+截断模式打开文件,fs.writeSync将buildWavBytes生成的ArrayBuffer写入文件描述符,fs.closeSync关闭文件。整个操作包裹在try-catch中,写入失败时静默忽略,方法返回空字符串。调用方根据返回值判断是否写入成功,写入失败时通知发布会回退到系统默认铃声。这种容错设计保证了沙箱写入失败不会导致应用崩溃或通知发布流程中断。
7.3 铃声导入与生成
/** 铃声库条目导入沙箱(更新文件大小与导入状态) */
importRingToSandbox(idx: number) {
if (idx < 0 || idx >= this.ringList.length) {
return;
}
const r = this.ringList[idx];
const path = this.saveRingToSandbox(r.file, r.freq, r.duration);
if (path === '') {
return;
}
r.inSandbox = true;
const kb = Math.round((44 + Math.floor(44100 * r.duration / 1000) * 2) / 1024);
r.size = kb.toString() + ' KB';
this.sandboxCount++;
}
/** 用生成器参数新建铃声并写入沙箱(写入成功则标记已导入) */
genRing() {
const seq = this.ringList.length + 1;
const ring = new RingItem('能量铃声' + seq.toString(), 'ring_gen_' + seq.toString() + '.wav',
this.genFreq, this.genDuration, '—', false);
this.ringList.push(ring);
const path = this.saveRingToSandbox(ring.file, ring.freq, ring.duration);
if (path !== '') {
ring.inSandbox = true;
const kb = Math.round((44 + Math.floor(44100 * ring.duration / 1000) * 2) / 1024);
ring.size = kb.toString() + ' KB';
this.sandboxCount++;
}
}
importRingToSandbox方法处理铃声库中已有条目导入沙箱的逻辑。方法首先做边界检查(索引合法性验证),然后调用saveRingToSandbox将铃声条目对应的音频写入沙箱。写入成功后,更新铃声条目的两个关键属性:inSandbox置为true,size更新为实际文件大小。文件大小的计算公式44 + Math.floor(44100 * r.duration / 1000) * 2中,44是WAV文件头大小,44100 * duration / 1000是采样点数,每个采样点2字节(16bit),除以1024转为KB。sandboxCount自增1,驱动铃音页"沙箱文件 N 个"的显示更新。
genRing方法使用铃声生成器的频率和时长参数创建一条全新的铃声。方法生成序号、构造RingItem实例、推入铃声列表,然后立即写入沙箱。与导入不同,生成是"创建即写入"的一步到位流程,而导入是"先存在条目、后写入沙箱"的两步流程。这两种方式共同体现了应用铃声管理的灵活性——既有预设铃声可选,也支持用户自定义参数生成。
7.4 设为默认与sound字段值生成
/** 设为默认通知铃声(未导入沙箱时自动导入) */
setCurrentRing(idx: number) {
if (idx < 0 || idx >= this.ringList.length) {
return;
}
if (!this.ringList[idx].inSandbox) {
this.importRingToSandbox(idx);
}
this.currentRingIdx = idx;
}
/** 当前通知请求 sound 字段值(6.1.1 新特性:沙箱路径转 uri:: 前缀) */
getSoundValue(): string {
if (this.currentRingIdx < 0 || this.currentRingIdx >= this.ringList.length) {
return 'uri::';
}
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);
}
setCurrentRing方法体现了"懒导入"的设计理念——当用户将一条铃声设为默认时,如果该铃声尚未导入沙箱,方法会自动调用importRingToSandbox先完成导入,再设置currentRingIdx。这确保了设为默认的铃声一定存在于沙箱中,后续发布通知时不会因文件缺失而失败。这种自动导入的体验设计让用户无需关心"先导入后设置"的操作顺序,降低了使用门槛。
getSoundValue方法是整个Notification Kit沙箱铃声特性的核心实现。它构造通知请求中sound字段的完整值:首先获取当前默认铃声的沙箱路径(appCtx.filesDir + '/' + ring.file),然后通过fileUri.getUriFromPath()将沙箱路径转换为URI格式,最后以'uri::'为前缀拼接成最终的sound值。这个uri::前缀是HarmonyOS 6.1.1引入的语法,它告诉通知系统"后面跟随的是一个URI引用的音频文件",而非系统内置铃声标识。这个方法在铃音页有实时代码预览展示,用户可以实时看到sound字段的变化。
7.5 通知发布与历史记录
/** 记录一条通知发送历史(最新在前,最多保留 6 条) */
addNoticeLog(title: string, ok: boolean) {
this.noticeLogs.unshift(new NoticeLog(nowTime(), title, ok));
if (this.noticeLogs.length > 6) {
this.noticeLogs.pop();
}
}
/** 发布携带沙箱自定义铃声的训练提醒通知(核心:sound 字段填沙箱 uri) */
publishNotice(title: string, text: string) {
if (this.currentRingIdx < 0 || this.currentRingIdx >= this.ringList.length) {
return;
}
const ring = this.ringList[this.currentRingIdx];
if (!ring.inSandbox) {
this.importRingToSandbox(this.currentRingIdx);
}
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) {
return;
}
const appCtx = hostCtx.getApplicationContext();
appCtx.area = contextConstant.AreaMode.EL1;
const sandboxPath = appCtx.filesDir + '/' + ring.file;
// ★ 6.1.1 新特性:沙箱路径 → fileUri → 'uri::' 前缀
const uri = fileUri.getUriFromPath(sandboxPath);
const soundVal = 'uri::' + uri;
const request: notificationManager.NotificationRequest = {
id: this.notifyId++,
notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,
content: {
notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: {
title: title,
text: text,
additionalText: '铃声:' + ring.name
}
},
sound: soundVal
};
notificationManager.publish(request).then(() => {
this.sentCount++;
this.addNoticeLog(title, true);
}).catch((err: BusinessError) => {
// 失败:常见为未授权(1600004)
this.addNoticeLog(title, false);
});
}
addNoticeLog方法管理通知历史的维护逻辑,使用unshift将最新记录插入数组头部,保证最近发送的通知排在最前面。当历史超过6条时,通过pop移除末尾最旧的记录,控制内存占用和列表长度。这种"最新在前、限量保留"的日志策略在UI上形成一个倒序的时间线,用户打开铃音页就能第一时间看到最近的通知发送结果。
publishNotice方法是整个通知系统的集大成者。方法首先确保当前默认铃声已导入沙箱(同样的懒导入逻辑),然后构造沙箱路径并通过fileUri.getUriFromPath转换为URI。NotificationRequest对象的构造是核心:id使用自增的notifyId确保唯一性;notificationSlotType设为SOCIAL_COMMUNICATION(社交通信类型,优先级较高,适合训练提醒这种需要及时查看的通知);content使用NOTIFICATION_CONTENT_BASIC_TEXT基础文本类型,包含标题、正文和附加文本(附加文本显示铃声名称,让用户在通知栏就能看到使用的是哪个铃声);sound字段填入'uri::' + uri。
通知发布成功后,sentCount自增驱动头部"已发通知 N 条"的更新,同时记录一条成功的历史日志。如果发布失败(常见原因是未授权,错误码1600004),记录一条失败日志,UI上显示红色失败标记。这种成功/失败的双向反馈让用户清楚知道每一条通知的发送结果。
7.6 铃声删除与沙箱清理
/** 删除铃声(同步清理沙箱文件,unlinkSync 包 try-catch) */
deleteRing(idx: number) {
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;
}
deleteRing方法处理铃声删除的完整流程,包括沙箱文件清理和数据列表更新两个层面。方法首先检查索引合法性,然后判断被删除的铃声是否已导入沙箱——如果已导入,需要同步删除沙箱中的物理文件:通过fs.unlinkSync删除文件,操作包裹在try-catch中以防文件不存在的情况(例如用户在其他途径已删除了沙箱文件)。sandboxCount递减反映沙箱文件数量的变化。
从列表中删除铃声条目使用splice(idx, 1),这会触发@Observed的响应式更新,使铃声列表UI自动刷新。删除后还有一个重要的边界处理:如果被删除的铃声正好是当前默认铃声(currentRingIdx >= ringList.length),则将默认铃声索引重置为列表末尾,避免索引越界。最后关闭删除确认弹窗(this.delModal = false)。这种"先清理文件、再更新列表、最后处理边界"的删除流程,确保了数据一致性。
八、训练计划与生命周期管理
8.1 计划增删改方法
/** 保存新增训练计划(新增弹窗确认按钮) */
savePlan() {
if (this.formTime === '' || this.formTitle === '' || this.formNote === '') {
return;
}
this.planList.push(new PlanItem(this.formTime, this.formTitle, '待开练', this.formNote));
this.formTime = '';
this.formTitle = '';
this.formNote = '';
this.addModal = false;
}
/** 保存编辑训练计划(编辑弹窗确认按钮) */
updatePlan() {
if (this.editIdx >= 0 && this.editIdx < this.planList.length) {
this.planList[this.editIdx].time = this.editTime;
this.planList[this.editIdx].title = this.editTitle;
this.planList[this.editIdx].note = this.editNote;
}
this.editModal = false;
}
savePlan方法处理新增训练计划的保存逻辑。方法首先做了表单非空验证——三个字段(时间、名称、备注)中任何一个为空都直接返回不保存,这是一种轻量级的输入校验,避免用户提交不完整的计划。验证通过后,使用表单数据构造一个新的PlanItem实例,状态默认设为"待开练",推入计划列表。保存后清空表单字段并关闭弹窗。由于planList是@State装饰的数组,push操作会触发列表的响应式更新,时间轴UI自动新增一行。
updatePlan方法处理编辑计划的保存。与新增不同,编辑是通过索引定位到现有条目,然后逐字段更新其属性。由于PlanItem是@Observed类,属性赋值会自动触发UI刷新,时间轴上对应行的内容会实时更新。编辑保存后关闭编辑弹窗。两种方法的配合实现了训练计划的CRUD(增删改查)中的"增"和"改",为用户提供了灵活的计划管理能力。
8.2 生命周期与呼吸动画
/** 生命周期:查询通知授权状态并启动呼吸动画定时器(联动 Canvas 重绘) */
aboutToAppear() {
notificationManager.isNotificationEnabled().then((enabled: boolean) => {
this.granted = enabled;
}).catch(() => {
this.granted = false;
});
this.timer = setInterval(() => {
this.breath = !this.breath;
if (this.canvasReady) {
this.drawLineChart();
}
if (this.barReady) {
this.drawBarChart();
}
}, 1000);
}
/** 生命周期:销毁时清理定时器 */
aboutToDisappear() {
clearInterval(this.timer);
}
aboutToAppear是ArkUI组件的生命周期钩子,在组件即将出现时调用。方法做了两件事:查询通知授权状态和启动呼吸动画定时器。授权状态查询使用notificationManager.isNotificationEnabled()异步方法,返回布尔值表示当前是否已授权,赋值给granted状态变量后驱动头部授权胶囊的初始显示。如果查询失败则默认设为未授权。
呼吸动画定时器使用setInterval每1000毫秒(1秒)执行一次回调。回调中做三件事:翻转breath状态(true变false、false变true),如果折线图Canvas已就绪则重绘折线图,如果柱状图Canvas已就绪则重绘柱状图。breath状态的翻转驱动了三类UI效果:头部授权胶囊的透明度(breath ? 1 : 0.35)和缩放(breath ? 1.04 : 1)、折线图末端数据点的半径(breath ? 5 : 3.5)、柱状图末柱的高度(breath ? barH + 5 : barH)。Canvas的重绘则通过主动调用绘图方法实现,每秒重绘一次让数据点和柱体产生"呼吸"的脉动效果。aboutToDisappear中clearInterval(this.timer)清理定时器,避免组件销毁后定时器继续执行造成内存泄漏。
九、Canvas图表绘制
9.1 折线图绘制方法
/** 绘制近 7 日运动时长折线图(网格线+折线+数据点+数值标签+星期标签) */
drawLineChart() {
const ctx = this.lineCtx;
const w = 320;
const h = 180;
const pad = 30;
const stepX = (w - pad * 2) / (WEEK_VALUES.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();
}
// 主折线(主色,线宽 2.5)
ctx.beginPath();
for (let i = 0; i < WEEK_VALUES.length; i++) {
const x = pad + i * stepX;
const y = h - pad - (WEEK_VALUES[i] / WEEK_MAX) * (h - pad * 2);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.strokeStyle = COLORS.orange;
ctx.lineWidth = 2.5;
ctx.stroke();
drawLineChart方法绘制近7日运动时长折线图,这是运动页的核心可视化组件。方法首先设定画布尺寸(宽320高180)和内边距(pad=30),计算每个数据点之间的水平间距stepX = (w - pad * 2) / (WEEK_VALUES.length - 1)——用画布有效宽度除以数据点间隔数,得到等间距分布。clearRect清空画布,确保每次重绘不会叠加之前的内容。
网格线部分绘制4条横向参考线(i从0到3),将图表区域纵向分为4个区间,帮助用户估算数据值的量级。网格线使用COLORS.line的深灰色,lineWidth为1,视觉上轻盈不干扰主折线。主折线绘制部分先通过一个循环计算每个数据点的坐标x和y,其中y = h - pad - (WEEK_VALUES[i] / WEEK_MAX) * (h - pad * 2)将数据值映射到画布坐标系——值越大y越小(越靠上),值越小y越大(越靠下)。第一个点用moveTo定位,后续点用lineTo连接,最后设置橙色(COLORS.orange)描边和2.5线宽,调用stroke绘制折线。
// 数据点(末端呼吸放大)+ 顶部数值标签
for (let i = 0; i < WEEK_VALUES.length; i++) {
const x = pad + i * stepX;
const y = h - pad - (WEEK_VALUES[i] / WEEK_MAX) * (h - pad * 2);
ctx.beginPath();
ctx.arc(x, y, i === WEEK_VALUES.length - 1 ? (this.breath ? 5 : 3.5) : 3, 0, Math.PI * 2);
ctx.fillStyle = COLORS.card;
ctx.fill();
ctx.strokeStyle = COLORS.green;
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.font = 'bold 8px sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = COLORS.orange;
ctx.fillText(WEEK_VALUES[i].toString(), x, y - 8);
}
// 底部星期标签
ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = COLORS.sub;
for (let i = 0; i < WEEK_LABELS.length; i++) {
ctx.fillText(WEEK_LABELS[i], pad + i * stepX, h - pad + 14);
}
}
折线图的数据点绘制部分是一个亮点。每个数据点是一个小圆:先用COLORS.card填充内圆(与卡片背景同色,形成"镂空"效果),再用COLORS.green描边外圆。最后一个数据点(周六,84分钟)的半径会根据breath状态在3.5和5之间变化,配合每秒一次的定时器重绘,产生呼吸放大的脉动效果,突出最新一天的数据。每个数据点上方还有橙色数值标签,使用bold 8px sans-serif字体居中绘制。
底部星期标签使用9px sans-serif字体,灰色(COLORS.sub),在每个数据点的x坐标位置绘制对应的星期文本。标签位于画布底部h - pad + 14的位置,与折线区域之间留有间距。整个折线图包含了网格线、主折线、数据点、数值标签、星期标签五个层次,信息完整且层次分明,是一个标准的运动数据趋势可视化方案。
9.2 柱状图绘制方法
/** 绘制近 6 个月消耗柱状图(圆角柱+网格线+数值标签+月份标签,末柱呼吸放大) */
drawBarChart() {
const ctx = this.barCtx;
const w = 320;
const h = 170;
const pad = 26;
const barW = 20;
const stepX = (w - pad * 2) / MONTH_VAL.length;
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();
}
// 柱体(末柱呼吸放大)
for (let i = 0; i < MONTH_VAL.length; i++) {
const cx = pad + stepX * i + stepX / 2;
let barH = Math.max(12, MONTH_VAL[i] / MONTH_MAX * (h - pad * 2));
if (i === MONTH_VAL.length - 1 && this.breath) {
barH += 5;
}
const top = h - pad - barH;
ctx.fillStyle = i === MONTH_VAL.length - 1 ? COLORS.orange : COLORS.orangeD;
// 柱身 + 柱顶圆角(上半圆)
ctx.fillRect(cx - barW / 2, top + barW / 2, barW, barH - barW / 2);
ctx.beginPath();
ctx.arc(cx, top + barW / 2, barW / 2, Math.PI, 0);
ctx.fill();
drawBarChart方法绘制近6个月消耗柱状图,位于"我的"页面。与折线图类似,方法先设定画布尺寸和内边距,清空画布,绘制4条横向网格线。柱状图的参数有所不同:barW = 20定义了每个柱体的宽度,stepX计算每个柱体的水平间距。
柱体绘制部分有几个技术要点。首先是柱高的计算:barH = Math.max(12, MONTH_VAL[i] / MONTH_MAX * (h - pad * 2)),将月度消耗值映射到画布高度,最小高度12确保即使数据较小也能看到柱体。最后一个月(8月,13200 kcal)的柱高会根据breath状态额外增加5像素,产生呼吸放大效果。柱体颜色的设计:最后一个月使用亮橙色(COLORS.orange)强调最新数据,其余月份使用深橙色(COLORS.orangeD),形成主次分明的视觉层次。
柱顶圆角的实现是一个巧妙的Canvas技巧。柱身使用fillRect绘制矩形(从top + barW / 2开始,留出顶部圆角的空间),然后用arc绘制一个从Math.PI到0的半圆(即上半圆)填充柱顶。这种"矩形+半圆"的组合形成了顶部圆角、底部直角的柱体形状,在数据可视化中比纯方角柱体更加柔和美观。
// 顶部数值标签
ctx.font = 'bold 8px sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = COLORS.sub;
ctx.fillText(MONTH_VAL[i].toString(), cx, top - 6);
// 底部月份标签
ctx.font = '8px sans-serif';
ctx.fillStyle = COLORS.text3;
ctx.fillText(MONTH_NAME[i], cx, h - pad + 14);
}
}
柱状图的数值标签和月份标签的绘制方式与折线图相似。每个柱体上方top - 6的位置绘制灰色数值标签(如"13200"),柱体下方h - pad + 14的位置绘制更暗的月份标签(如"08月")。数值标签使用bold加粗字体强调数据,月份标签使用常规字重作为辅助信息。整个柱状图同样包含网格线、柱体、数值标签、月份标签四个层次,与折线图在风格上保持统一。
十、页面构建与布局系统
10.1 主构建方法与Stack布局
/** 页面主构建:Stack 包裹主内容与三层弹窗 */
build() {
Stack() {
Column() {
this.headerBar()
Divider().strokeWidth(1).color(COLORS.line)
Scroll() {
Column() {
if (this.currentTab === 0) {
this.tabSport()
} else if (this.currentTab === 1) {
this.tabPlan()
} else if (this.currentTab === 2) {
this.tabRing()
} else {
this.tabMine()
}
}
.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组件的入口构建方法,它定义了页面的整体布局结构。最外层使用Stack作为根容器,这是为了实现弹窗的层叠效果——Stack的子组件按声明顺序从底到顶叠加,主内容在底层,弹窗在上层。Stack内部的Column包含三个部分:头部(headerBar)、分割线(Divider)、可滚动内容区(Scroll),以及底部的Tab栏(tabBar)。
内容区的Scroll组件使用if-else条件渲染根据currentTab的值切换四个Tab页面。layoutWeight(1)让滚动区占据头部和Tab栏之间的所有剩余空间。scrollBar(BarState.Off)隐藏滚动条,保持深色主题的视觉纯净。弹窗部分通过三个if条件语句分别控制三种弹窗的渲染——当对应的状态变量为true时,弹窗组件被构建并叠加在Stack顶层;为false时弹窗不渲染。这种条件渲染方式比visibility隐藏更高效,因为弹窗关闭时其组件树会完全销毁,不占用内存和渲染资源。
10.2 头部区域构建
/** 头部:应用名 + 授权呼吸胶囊 + 3 个运动数据小卡 */
@Builder
headerBar() {
Column({ space: 10 }) {
Row() {
Column({ space: 3 }) {
Text('燃力').fontSize(20).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('运动健身 · 私教课程平台').fontSize(12).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 通知授权状态胶囊(呼吸灯闪烁 + scale 微变提示未授权)
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: 9, right: 9, top: 5, bottom: 5 })
.backgroundColor(this.granted ? COLORS.greenL : COLORS.redL)
.borderRadius(11)
.scale(this.breath ? { x: 1.04, y: 1.04 } : { x: 1, y: 1 })
.onClick(() => {
if (!this.granted) {
this.requestAuth();
}
})
}
.width('100%')
headerBar构建器定义了页面头部区域,使用Column垂直排列两行内容。第一行是应用名称和通知授权胶囊的横向排列。应用名称"燃力"使用20px大字加粗白色显示,下方副标题"运动健身 · 私教课程平台"使用12px灰色小字,形成主次分明的品牌信息。应用名称占据layoutWeight(1)的弹性宽度,将授权胶囊推到右侧。
授权胶囊是头部最具交互性的元素。它是一个Row容器,内部包含一个6px的小圆点和一段文字。圆点颜色根据授权状态变化:已授权为绿色,未授权为红色。当未授权时,胶囊通过opacity(this.breath ? 1 : 0.35)实现呼吸闪烁效果——每秒在完全不透明和35%透明度之间切换,同时scale在1.0和1.04之间微缩放,形成视觉上"呼吸"的脉动感,吸引用户注意并引导点击。已授权后,胶囊变为绿色背景的稳定状态,不再呼吸。点击胶囊时如果未授权则调用requestAuth请求授权。
// 头部下排 3 个运动数据小卡
Row({ space: 10 }) {
Column({ space: 3 }) {
Text('今日消耗').fontSize(9).fontColor(COLORS.text3)
Text('486 kcal').fontSize(11).fontColor(COLORS.orange).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.green).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('12 天').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 })
}
头部第二行是三个运动数据小卡,水平等宽排列(layoutWeight(1))。每张小卡包含一个9px灰色标签和一个11px彩色数值,数值颜色根据数据语义选择:今日消耗用橙色(与主题色呼应)、已发通知用绿色(与通知能力关联)、连续打卡用金色(与成就激励关联)。三张小卡的卡片背景色统一使用COLORS.card,圆角10。值得注意的是"已发通知"小卡的数值是动态的——this.sentCount.toString() + ' 条',每次发布通知后sentCount自增,头部小卡实时更新,形成了从铃音页发布通知到头部数据变化的完整数据流闭环。
十一、运动Tab页面详解
11.1 今日训练大卡
/** 运动 Tab:今日训练大卡 + 3 数据小卡 + 部位横滚 Chip + Canvas 折线图 + 本周小结行 */
@Builder
tabSport() {
Column({ space: 12 }) {
// 1. 今日训练大卡(动作名 + 组数 + 时长 + 消耗 kcal 大数字)
Column({ space: 10 }) {
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%')
Row({ space: 12 }) {
// 消耗 kcal 大数字
Column({ space: 2 }) {
Row({ space: 4 }) {
Text('486').fontSize(38).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('kcal').fontSize(11).fontColor(COLORS.sub).padding({ top: 14 })
}
Text('预计消耗热量').fontSize(8).fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
// 动作 / 组数 / 时长信息列
Column({ space: 5 }) {
Text('动作 · 全身循环 8 式').fontSize(9).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('组数 · 6 组 × 12 次').fontSize(9).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('时长 · 48 分钟').fontSize(9).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
}
.alignItems(HorizontalAlign.End)
}
.width('100%').alignItems(VerticalAlign.Top)
Text('已完成 32 / 48 分钟 · 剩余 2 个动作').fontSize(8).fontColor(COLORS.text3)
}
.width('100%').padding(16).backgroundColor(COLORS.card).borderRadius(12)
运动Tab的第一块内容是今日训练大卡,这是整个运动页的视觉焦点。卡片顶部一行包含训练名称"今日训练 · 全身力量循环"和右侧的"进行中"状态标签,状态标签使用绿色背景的胶囊形状,传达积极的训练状态。
卡片的核心区域采用左右分栏布局:左侧是38px超大字号的消耗热量数字"486",使用主题橙色加粗,视觉冲击力极强,旁边小字"kcal"靠上对齐(padding({ top: 14 })),下方是"预计消耗热量"说明。右侧是动作、组数、时长三行训练参数,右对齐排列。这种"大数据+小参数"的布局是运动健身类应用展示训练摘要的经典模式,让用户一眼就能看到最关心的热量消耗,同时不遗漏训练细节。卡片底部还有一行进度提示"已完成 32 / 48 分钟 · 剩余 2 个动作",传达训练的进行状态。
11.2 数据小卡与部位Chip
// 2. 3 个训练数据小卡(今日消耗/训练时长/连续打卡)
Row({ space: 10 }) {
Column({ space: 4 }) {
Text('🔥').fontSize(15)
Text('486').fontSize(14).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('今日消耗 kcal').fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.card).borderRadius(10)
Column({ space: 4 }) {
Text('⏱️').fontSize(15)
Text('48').fontSize(14).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
Text('训练时长 min').fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.card).borderRadius(10)
Column({ space: 4 }) {
Text('📅').fontSize(15)
Text('12').fontSize(14).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('连续打卡 天').fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.card).borderRadius(10)
}
.width('100%')
// 3. 今日训练部位横滚 Chip 行
Scroll() {
Row({ space: 8 }) {
ForEach(BODY_TAGS, (tag: string, idx: number) => {
Text(tag).fontSize(10)
.fontColor(idx === 0 ? COLORS.orange : COLORS.sub)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(idx === 0 ? COLORS.orangeL : COLORS.chip)
.borderRadius(12)
}, (tag: string) => tag)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.align(Alignment.Start)
三张训练数据小卡与头部数据小卡类似,但增加了Emoji图标在顶部,形成"图标+数值+标签"的三行结构。今日消耗用橙色(热量关联)、训练时长用绿色(时间关联)、连续打卡用金色(激励关联),色彩语义与头部保持一致。
训练部位Chip行使用横向滚动的Scroll组件包裹一行ForEach生成的标签。每个Chip是一个Text组件,背景色和文字色根据索引区分——第一个Chip(胸肩)使用橙色系强调当前选中状态,其余使用灰色系表示未选中。横向滚动通过ScrollDirection.Horizontal指定,隐藏滚动条保持视觉整洁。align(Alignment.Start)让内容靠左对齐,模拟从左到右浏览训练部位的真实体验。Chip圆角12px的胶囊形状是运动健身类应用中常见的标签设计语言。
11.3 Canvas折线图与本周小结
// 4. Canvas 近 7 日运动时长折线图卡
Column({ space: 10 }) {
Row() {
Text('📈 近 7 日运动时长').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('单位:分钟').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
Canvas(this.lineCtx).width('100%').height(180)
.onReady(() => {
this.canvasReady = true;
this.drawLineChart();
})
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
// 5. 本周小结行
Row({ space: 8 }) {
Column({ space: 3 }) {
Text('415').fontSize(14).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('本周合计(分钟)').fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.card).borderRadius(10)
Column({ space: 3 }) {
Text('87%').fontSize(14).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
Text('周目标完成').fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.card).borderRadius(10)
Column({ space: 3 }) {
Text('+9.6%').fontSize(14).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
Text('较上周').fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.card).borderRadius(10)
}
.width('100%')
}
.width('100%')
}
Canvas折线图卡是运动页的技术核心。卡片顶部是标题行"📈 近 7 日运动时长"和右侧的"单位:分钟"标注。Canvas(this.lineCtx)组件绑定了组件主体中创建的lineCtx上下文,宽度100%高度180。onReady回调在Canvas准备完毕后触发,将canvasReady置为true并调用drawLineChart()进行首次绘制。这个就绪标志非常重要——呼吸动画定时器每秒检查canvasReady,只有为true时才重绘,避免了Canvas未初始化时调绘图的异常。
本周小结行是运动页的收尾区域,三张小卡分别展示本周合计运动时长(415分钟,橙色)、周目标完成率(87%,绿色)、较上周增长率(+9.6%,蓝色)。三张卡片的色彩选择遵循"总量用主色、成就用绿、增长用蓝"的语义规则,让用户通过颜色就能快速理解数据的含义。这种"折线图+数字摘要"的组合是数据可视化的经典范式——图形展示趋势,数字传达精确值,两者互补构成完整的数据表达。
十二、计划Tab页面详解
12.1 标题行与新增入口
/** 计划 Tab:标题行 + 竖向时间轴(固定行高 72,开练提醒按钮联动通知授权) */
@Builder
tabPlan() {
Column({ space: 10 }) {
// 标题行 + 新增入口
Row() {
Text('📅 今日训练计划').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('共 ' + this.planList.length.toString() + ' 项').fontSize(9).fontColor(COLORS.text3)
Text('+ 新增计划').fontSize(9).fontColor(COLORS.orange)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(COLORS.orangeL).borderRadius(8)
.onClick(() => {
this.addModal = true;
})
}
.width('100%')
计划Tab的标题行使用横向布局,左侧是标题"📅 今日训练计划",中间用Column().layoutWeight(1)占位将后续元素推到右侧,然后是计划总数"共 9 项"和新增入口"+ 新增计划"。计划总数this.planList.length.toString()是动态的,当用户新增或删除计划时会自动更新。新增入口使用橙色胶囊按钮,点击后设置addModal = true打开新增弹窗。这种标题行设计在信息展示和操作入口之间取得了平衡——用户既能看到当前数据概况,也能快速执行新增操作。
12.2 竖向时间轴构建
// 竖向时间轴(每行固定高 72)
ForEach(this.planList, (item: PlanItem, idx: number) => {
Row({ space: 10 }) {
// 左侧时间 + 状态
Column({ space: 3 }) {
Text(item.time).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(item.status).fontSize(10).fontColor(planColor(item.status))
}
.width(44).height('100%').alignItems(HorizontalAlign.Start).padding({ top: 12 })
// 中间圆点 + 连接竖线(最后一行不画竖线)
Column() {
Circle().width(8).height(8).fill(planColor(item.status))
if (idx < this.planList.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.note).fontSize(9).fontColor(COLORS.sub)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
// 开练提醒按钮:已授权发通知,未授权先请求授权
Text('开练提醒').fontSize(9).fontColor(COLORS.orange)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(COLORS.orangeL).borderRadius(8)
.onClick(() => {
if (!this.granted) {
this.requestAuth();
} else {
this.publishNotice('训练提醒', item.title + ' 即将开始,记得提前热身!');
}
})
}
.layoutWeight(1).height('100%').padding(10)
.backgroundColor(COLORS.card).borderRadius(10)
.onClick(() => {
this.editIdx = idx;
this.editTime = item.time;
this.editTitle = item.title;
this.editNote = item.note;
this.editModal = true;
})
}
.width('100%')
.height(72)
.alignItems(VerticalAlign.Top)
.margin({ bottom: 6 })
}, (item: PlanItem) => item.time + item.title)
}
.width('100%')
}
竖向时间轴是计划Tab的核心布局,使用ForEach遍历planList数组,每行高度固定72px。每行分为三列:左侧时间列(宽44)、中间圆点竖线列(宽10)、右侧训练卡(layoutWeight(1)占据剩余空间)。
时间列显示训练时间和状态,状态文字颜色通过planColor函数映射。中间列是时间轴的视觉核心——顶部一个8px的圆点,颜色也通过planColor映射状态;圆点下方是一条2px宽的竖线(Column().width(2).layoutWeight(1)),连接到下一行的圆点。最后一行不画竖线(if (idx < this.planList.length - 1)),形成完整的时间轴视觉。
右侧训练卡显示训练名称和备注,都使用maxLines(1)和TextOverflow.Ellipsis做单行省略处理,确保固定行高内内容不会溢出。卡片右侧的"开练提醒"按钮是通知能力与训练计划的交汇点:点击时如果未授权则请求授权,已授权则调用publishNotice发布一条携带自定义铃声的训练提醒通知,通知内容为计划标题+“即将开始,记得提前热身!”。整个卡片可点击,点击后打开编辑弹窗,预先填充当前计划的字段值。ForEach的键函数(item: PlanItem) => item.time + item.title使用时间+标题的组合作为唯一标识,帮助框架高效diff列表变化。
十三、铃音Tab页面详解
13.1 沙箱铃声生成器
/** 铃音 Tab:生成器 + 当前默认铃声 + sound 预览 + 铃声库 + 通知试听与历史 */
@Builder
tabRing() {
Column({ space: 12 }) {
// 1. 沙箱铃声生成器卡(频率/时长双 Slider)
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.orange).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('440 ~ 1760 Hz').fontSize(8).fontColor(COLORS.text3)
}
.width('100%')
Slider({ value: this.genFreq, min: 440, max: 1760, step: 20, style: SliderStyle.OutSet })
.selectedColor(COLORS.orange).trackColor(COLORS.chip).blockColor(COLORS.orange)
.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.blue).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.blue).trackColor(COLORS.chip).blockColor(COLORS.blue)
.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.orange).borderRadius(10)
.onClick(() => {
this.genRing();
})
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
铃音Tab的第一块内容是沙箱铃声生成器,这是整个应用中交互最丰富的区域。卡片包含两个Slider滑块——频率滑块(440~1760 Hz,步进20)和时长滑块(600~2400 ms,步进100),分别使用橙色和蓝色作为选中色,形成视觉区分。每个滑块上方有一行参数显示,左侧是参数名(频率/时长),中间是当前值(动态更新),右侧是取值范围提示。滑块拖动时通过onChange回调实时更新genFreq或genDuration状态变量,上方的数值显示同步刷新。
底部的"生成到沙箱"按钮使用橙色填充背景、白色加粗文字,点击后调用genRing()方法。这个方法会使用当前滑块设定的频率和时长参数,通过buildWavBytes在内存中生成WAV音频,写入沙箱EL1目录,然后将新铃声添加到铃声列表并标记为已导入。整个生成器让用户可以自由定制铃声的音高和持续时间,不同频率的铃声对应不同的训练场景——低频深沉适合放松提醒,高频尖锐适合冲刺提醒。
13.2 当前铃声与sound预览
// 2. 当前默认铃声状态卡
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.orange).fontWeight(FontWeight.Bold)
Text(this.currentRingIdx >= 0 && this.currentRingIdx < this.ringList.length ?
this.ringList[this.currentRingIdx].freq.toString() + ' Hz · ' +
this.ringList[this.currentRingIdx].duration.toString() + ' ms · ' +
this.ringList[this.currentRingIdx].size : '请在铃声库中选择')
.fontSize(9).fontColor(COLORS.text3)
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
// 3. sound 字段实时代码预览卡(等宽字体展示完整 uri:: 值)
Column({ space: 8 }) {
Row() {
Text('⌨️ sound 字段实时预览').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('HarmonyOS 6.1.1').fontSize(8).fontColor(COLORS.purple)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.purpleL).borderRadius(6)
}
.width('100%')
Text("sound: '" + this.getSoundValue() + "'")
.fontSize(8).fontFamily('monospace').fontColor(COLORS.green)
.maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%').padding(10).backgroundColor(COLORS.chip).borderRadius(8)
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
当前默认铃声状态卡展示用户选择的默认铃声信息,包括铃声名称(15px橙色加粗)、频率/时长/大小参数(9px灰色)。右上角还有"沙箱文件 N 个"的统计,sandboxCount是动态的,每次导入或删除铃声都会更新。当没有选择铃声时,显示"暂无铃声"和"请在铃声库中选择"。
sound字段预览卡是一个极具技术展示价值的设计。它使用等宽字体(monospace)以代码形式实时展示通知请求中sound字段的完整值:sound: 'uri::...'。这个值由getSoundValue()方法动态计算,当用户切换默认铃声时,预览内容会实时变化,让用户直观看到Notification Kit 6.1.1新特性中uri::前缀的完整URI格式。卡片右上角的"HarmonyOS 6.1.1"紫色标签点明了这是特定版本的特性。预览文本使用maxLines(3)和省略号溢出处理,防止URI过长导致布局溢出。这种"代码级预览"的设计在技术类应用中非常实用,它将底层数据结构直接呈现给用户,兼具教育性和透明度。
13.3 铃声库列表
// 4. 铃声库列表(导入沙箱 / 设默认 / 删除)
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.orange).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.orange)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 5, bottom: 5 }).backgroundColor(COLORS.orangeL).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)
铃声库列表使用ForEach遍历ringList,每条铃声是一个独立的卡片。卡片顶部行展示铃声信息:左侧是音符Emoji,中间是铃声名称和参数(频率/时长/大小),如果当前铃声是默认铃声则显示橙色"默认"标签。右侧是导入状态标签——已导入为绿色"沙箱中",未导入为金色"未导入"。
卡片底部是一行操作按钮,根据导入状态有三种或两种按钮。未导入时显示"导入沙箱"(金色)、“设为默认”(橙色)、“删除”(红色)三个按钮;已导入时不显示导入按钮,只有"设为默认"和"删除"两个按钮。三个按钮等宽排列(layoutWeight(1)),使用各自的浅色变体作为背景色,形成色彩编码的操作组。"导入沙箱"调用importRingToSandbox,"设为默认"调用setCurrentRing(含自动导入),"删除"设置delIdx并打开删除确认弹窗。这种"信息+操作"的卡片布局让用户在浏览铃声列表时就能执行所有管理操作,无需进入二级页面。
13.4 通知试听与历史
// 5. 发布一条通知试听卡(含最近发送历史)
Column({ space: 8 }) {
Text('📣 发布一条通知试听').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('携带当前默认铃声发布一条训练通知,收到提醒时注意聆听自定义提示音。')
.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.orange).borderRadius(10)
.onClick(() => {
if (!this.granted) {
this.requestAuth();
} else {
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%')
// 最近发送历史(成功绿点 / 失败红点)
ForEach(this.noticeLogs, (log: NoticeLog) => {
Row({ space: 8 }) {
Circle().width(6).height(6).fill(log.ok ? COLORS.green : COLORS.red)
Text(log.title).fontSize(10).fontColor(COLORS.title)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
Text(log.time + (log.ok ? ' · 已送达' : ' · 失败')).fontSize(8)
.fontColor(log.ok ? COLORS.green : COLORS.red)
}
.width('100%').padding({ top: 4, bottom: 4 })
.backgroundColor(COLORS.chip).borderRadius(7)
}, (log: NoticeLog) => log.time + log.title)
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
}
.width('100%')
}
通知试听卡是铃音Tab的收尾区域,也是整个通知系统的"出口"。卡片包含说明文字、"发布试听"按钮、通知ID和已发送统计、以及最近发送历史列表。“发布试听"按钮的点击逻辑与计划页的"开练提醒"类似:未授权先请求授权,已授权则调用publishNotice发布一条试听通知。通知内容为"通知试听:正在播放当前默认铃声,请留意提示音。”,携带当前默认铃声的sound字段。
通知ID和已发送统计行显示当前notifyId(下一次发布将使用的ID)和sentCount(已成功发送的条数),让用户了解通知的运行状态。最近发送历史使用ForEach遍历noticeLogs数组,每条记录显示一个状态圆点(成功绿/失败红)、通知标题、发送时间和送达状态。历史记录最多6条,按时间倒序排列,最新的在最上方。这个历史列表让用户可以回顾通知发送的结果,特别是失败记录能帮助用户排查授权问题。
十四、我的Tab页面详解
14.1 体测成绩渐变大卡
/** 我的 Tab:体测成绩渐变大卡 + 统计清单 + Canvas 月度消耗柱状图 + 特性说明卡 */
@Builder
tabMine() {
Column({ space: 12 }) {
// 1. 体测成绩渐变大卡(体重/体脂大数字 + 较上月变化)
Column({ space: 10 }) {
Text('⚖️ 本月体测成绩').fontSize(11).fontColor('rgba(255,255,255,0.75)')
Row({ space: 16 }) {
Column({ space: 4 }) {
Text('体重').fontSize(9).fontColor('rgba(255,255,255,0.7)')
Text('68.5').fontSize(28).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
Text('kg · 较上月 -1.2').fontSize(8).fontColor(COLORS.green)
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column().width(1).height(52).backgroundColor('rgba(255,255,255,0.25)')
Column({ space: 4 }) {
Text('体脂率').fontSize(9).fontColor('rgba(255,255,255,0.7)')
Text('16.8').fontSize(28).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
Text('% · 较上月 -0.9').fontSize(8).fontColor(COLORS.green)
}
.alignItems(HorizontalAlign.End).layoutWeight(1)
}
.width('100%')
}
.width('100%').padding(18).borderRadius(14)
.linearGradient({ angle: 135, colors: [[COLORS.orangeD, 0], [COLORS.purple, 1]] })
"我的"Tab的第一块内容是体测成绩渐变大卡,这是整个应用中唯一使用渐变背景的元素。卡片通过linearGradient设置了135度角的线性渐变,从orangeD(深橙#E5511E)渐变到purple(紫色#A78BFA),跨度从暖色到冷色,形成视觉上的丰富层次。卡片内所有文字使用白色或半透明白色(rgba(255,255,255,0.7)),确保在渐变背景上的可读性。
卡片内容分为左右两栏:左侧体重(68.5 kg,较上月-1.2),右侧体脂率(16.8%,较上月-0.9)。两栏之间有一条1px宽、52px高的半透明白色竖线分隔。每个数据项都有28px的大字号白色数值和绿色变化标签(“-1.2”、“-0.9”),绿色代表下降在体重和体脂场景中是正向的(减重减脂是好事)。这种将体测核心数据放在渐变大卡中的设计,是运动健身类应用"我的"页面的经典模式,让用户打开页面就能第一时间看到最重要的体测结果。
14.2 统计清单与柱状图
// 2. 运动统计清单
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.orange).fontWeight(FontWeight.Bold)
}
.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(10)
}, (item: StatItem) => item.name)
// 3. Canvas 月度消耗柱状图卡
this.chartCard()
运动统计清单使用ForEach遍历statList,每条统计是一个独立的卡片,内部使用Row横向布局:左侧是Emoji图标(16px),中间是统计名称和标签(名称11px白色加粗,标签8px灰色),右侧是数值(13px橙色加粗)。这种"图标+信息+数值"的三段式布局在统计展示中非常高效,用户扫一眼就能获取关键数据。ForEach的键函数使用item.name作为唯一标识。
月度消耗柱状图通过调用this.chartCard()构建器引入,这是代码复用的体现——chartCard是一个独立的@Builder方法,封装了柱状图卡片的标题行和Canvas组件,使"我的"页面的tabMine构建方法更加简洁。chartCard内部的Canvas绑定了barCtx上下文,onReady后设置barReady = true并调用drawBarChart()首次绘制。
14.3 特性说明卡
// 4. 特性说明卡(两行说明 Notification Kit 沙箱铃声与 Canvas 特性)
Column({ space: 6 }) {
Text('🧩 特性说明').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('Notification Kit:通知 sound 字段支持应用沙箱 EL1 files 目录音频,以 uri:: 前缀引用,训练提醒可使用自定义铃声。')
.fontSize(8).fontColor(COLORS.sub)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
Text('Canvas 图表:折线图与柱状图由 CanvasRenderingContext2D 绘制,呼吸动画每秒联动重绘数据点与柱高。')
.fontSize(8).fontColor(COLORS.sub)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(10)
}
.width('100%')
}
特性说明卡是"我的"页面的收尾区域,也是整个应用的技术说明文档。卡片包含两行说明文字,分别介绍Notification Kit的沙箱铃声特性和Canvas图表的绘制原理。第一行说明通知sound字段支持沙箱EL1目录音频,以uri::前缀引用,让训练提醒可使用自定义铃声;第二行说明折线图和柱状图由CanvasRenderingContext2D绘制,呼吸动画每秒联动重绘数据点与柱高。两行文字都使用8px小字灰色,maxLines(2)限制为两行,超出部分省略。这种在应用内嵌入技术说明的设计,既是产品文档的体现,也帮助理解代码背后的技术原理。
十五、底部导航与弹窗系统
15.1 底部Tab栏
/** 底部导航: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 })
}
底部Tab栏使用ForEach遍历TAB_LIST,4个Tab等宽排列(layoutWeight(1))。每个Tab是一个Column,上方是Emoji图标,下方是文字标签。选中态和非选中态通过三个维度区分:图标字号(选中20px,非选中17px)、图标透明度(选中100%,非选中65%)、标签颜色和字重(选中橙色加粗,非选中灰色常规)。这种多维度区分使选中状态一目了然。
Tab栏整体背景色使用COLORS.card,顶部有1px的分割线(border({ width: { top: 1 }, color: COLORS.line }))与内容区分隔。点击Tab时设置currentTab = idx,驱动内容区的条件渲染切换。ForEach的键函数使用t.label作为唯一标识。整个Tab栏设计简洁实用,4个Tab单排排列,适合手机屏幕的拇指操作范围。
15.2 全屏遮罩与新增弹窗
/** 弹窗全屏遮罩(点击遮罩关闭弹窗) */
@Builder
modalOverlay(onClose: () => void) {
Stack() {
Column().width('100%').height('100%').backgroundColor(COLORS.mask)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
.onClick(() => onClose())
}
/** 新增训练计划弹窗面板(时间/名称/备注三个 TextInput) */
@Builder
panelAdd(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('新增训练计划').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
TextInput({ text: this.formTime, placeholder: '训练时间,如 08:00' })
.fontSize(11).fontColor(COLORS.title)
.backgroundColor(COLORS.chip).borderRadius(8)
.onChange((value: string) => {
this.formTime = value;
})
TextInput({ text: this.formTitle, placeholder: '训练名称,如 力量训练 · 背部划船' })
.fontSize(11).fontColor(COLORS.title)
.backgroundColor(COLORS.chip).borderRadius(8)
.onChange((value: string) => {
this.formTitle = value;
})
TextInput({ text: this.formNote, placeholder: '训练备注,如 高位下拉 4 组 × 12' })
.fontSize(11).fontColor(COLORS.title)
.backgroundColor(COLORS.chip).borderRadius(8)
.onChange((value: string) => {
this.formNote = value;
})
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.orange).borderRadius(9)
.onClick(() => {
this.savePlan();
})
}
.width('100%')
}
.width('86%').padding(16).backgroundColor(COLORS.card).borderRadius(16)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
}
弹窗系统采用"遮罩+面板"的两层结构。modalOverlay是一个全屏遮罩构建器,使用半透明黑色(COLORS.mask = rgba(0,0,0,0.6))覆盖整个屏幕,点击遮罩触发onClose回调关闭弹窗。遮罩使用Stack包裹,alignContent(Alignment.Center)让后续叠加的面板居中显示。这种将遮罩抽离为独立@Builder的方式实现了代码复用——三种弹窗都复用同一个遮罩。
panelAdd是新增训练计划弹窗,在遮罩之上叠加一个宽度86%的Column面板。面板包含标题、三个TextInput输入框(时间、名称、备注)和取消/保存按钮行。TextInput使用text参数绑定状态变量实现双向绑定,onChange回调更新对应的状态。输入框背景使用COLORS.chip深灰色,圆角8,与深色主题协调。按钮行中"取消"使用灰色背景和灰色文字,"保存"使用橙色背景和白色加粗文字,通过颜色对比引导用户选择保存。保存按钮调用savePlan()方法,该方法会验证输入非空后创建新的PlanItem并关闭弹窗。
15.3 编辑与删除弹窗
/** 编辑训练计划弹窗面板 */
@Builder
panelEdit(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('编辑训练计划').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
TextInput({ text: this.editTime, placeholder: '训练时间' })
.fontSize(11).fontColor(COLORS.title)
.backgroundColor(COLORS.chip).borderRadius(8)
.onChange((value: string) => {
this.editTime = value;
})
TextInput({ text: this.editTitle, placeholder: '训练名称' })
.fontSize(11).fontColor(COLORS.title)
.backgroundColor(COLORS.chip).borderRadius(8)
.onChange((value: string) => {
this.editTitle = value;
})
TextInput({ text: this.editNote, placeholder: '训练备注' })
.fontSize(11).fontColor(COLORS.title)
.backgroundColor(COLORS.chip).borderRadius(8)
.onChange((value: string) => {
this.editNote = value;
})
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.orange).borderRadius(9)
.onClick(() => {
this.updatePlan();
})
}
.width('100%')
}
.width('86%').padding(16).backgroundColor(COLORS.card).borderRadius(16)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
}
/** 删除铃声确认弹窗面板(显示铃声名) */
@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(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.deleteRing(this.delIdx);
})
}
.width('100%')
}
.width('86%').padding(16).backgroundColor(COLORS.card).borderRadius(16)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
}
}
编辑弹窗panelEdit与新增弹窗结构几乎一致,区别在于绑定的状态变量(editTime/editTitle/editNote而非formTime/formTitle/formNote)和保存按钮调用的方法(updatePlan()而非savePlan())。编辑弹窗在打开前,会预先将当前计划项的字段值赋给编辑状态变量(在计划时间轴卡片的onClick中完成),实现"编辑预填充"的体验。
删除弹窗panelDel结构更简单——只有标题、确认说明和取消/确认按钮。确认说明文字动态拼接被删除铃声的名称:“确认删除铃声「燃力战鼓」吗?删除后将同步清理沙箱中的音频文件,且不可恢复。”,明确告知用户删除的后果。"确认删除"按钮使用红色背景,与删除操作的危险语义一致。点击后调用deleteRing(this.delIdx)执行删除。三种弹窗的统一设计模式(遮罩+居中面板+按钮行)保证了UI的一致性,用户学会操作一种弹窗就能无缝操作其他弹窗。
十六、技术特性对比
| 技术维度 | 本应用实现方案 | 传统方案对比 | 技术优势 |
|---|---|---|---|
| 通知铃声 | Notification Kit沙箱EL1音频 + uri::前缀 | 系统默认铃声,无法自定义 | 通知可携带应用生成的自定义音频,语义化铃声提升辨识度 |
| 音频生成 | buildWavBytes纯代码构造WAV字节流 | 需预置音频文件或网络下载 | 零资源依赖,运行时按需生成任意频率/时长音频 |
| 文件操作 | fileIo同步API + EL1区域 + try-catch容错 | 异步API或无区域控制 | 同步写入简洁可靠,EL1满足通知安全约束 |
| 数据可视化 | CanvasRenderingContext2D手动绘制折线图/柱状图 | 三方图表库或图片 | 零依赖、高度可控、呼吸动画联动重绘 |
| 动画系统 | setInterval每秒翻转breath + Canvas重绘 | Animation属性动画或Lottie | 跨UI元素和Canvas的统一脉动节奏,轻量高效 |
| 状态管理 | @State + @Observed双层响应式 | 全@State或全局Store | @Observed模型类支持跨组件共享与自动刷新 |
| 授权流程 | requestEnableNotification + openNotificationSettings两段式 | 单次请求或直接跳设置 | 首次弹框引导、拒绝后跳设置页,授权路径完整 |
| 弹窗系统 | Stack层叠 + 条件渲染 + 复用modalOverlay | 全屏Dialog或路由跳转 | 声明式条件渲染无需路由开销,遮罩复用降低代码量 |
| 布局策略 | 4 Tab各异布局 + ForEach列表渲染 | 统一列表或网格 | 每页布局匹配业务语义,信息架构最优 |
| 主题管理 | interface约束 + 常量集中声明 | 散落硬编码或简单常量 | 类型安全、集中维护、深浅变体成体系 |
十七、总结
回顾整个"燃力·运动健身平台"的技术实现,我们可以清晰地看到HarmonyOS ArkUI框架在构建复杂业务应用时的强大表达能力。这款应用没有使用任何三方库,纯粹依靠ArkTS声明式UI、Notification Kit、CoreFileKit和Canvas API构建了包含4个布局完全不同的Tab页面、3种弹窗、2种Canvas图表、1套通知铃声生成与发布系统的完整应用。这种"零依赖"的实现方式不仅体现了框架原生能力的丰富性,也保证了应用的轻量和安全。
在Notification Kit的实践方面,应用完整实现了从授权请求到沙箱文件写入、从URI转换到通知发布的全链路。buildWavBytes函数纯代码生成WAV音频的设计尤其值得关注——它不依赖任何预置音频文件或网络下载,通过正弦波合成在运行时按需生成任意频率和时长的音频,写入选定EL1区域的沙箱files目录后,通过fileUri.getUriFromPath转为URI并以uri::前缀拼入通知请求的sound字段。这套流程完整演示了HarmonyOS 6.1.1自定义通知铃声特性的使用方法,是通知能力深度实践的优秀范例。两段式授权设计(requestEnableNotification + openNotificationSettings)确保了用户在任何场景下都有清晰的授权路径,不会因曾经拒绝而永久失去通知功能。
Canvas数据可视化方面,应用通过CanvasRenderingContext2D手动绘制了折线图和柱状图两种图表,不依赖任何图表库。折线图包含网格线、主折线、数据点(末端呼吸放大)、数值标签和星期标签五个层次;柱状图包含网格线、圆角柱体(末柱呼吸放大)、数值标签和月份标签四个层次。两种图表都通过setInterval每秒翻转breath状态实现呼吸动画联动重绘,让静态图表产生脉动的生命力。Canvas就绪标志(canvasReady/barReady)确保了绘图方法只在Canvas初始化完成后调用,避免了空指针异常。onReady回调驱动的首次绘制加上定时器驱动的重复绘制,构成了稳定的Canvas渲染管线。
在状态管理方面,@State和@Observed的双层响应式设计值得称道。@State管理组件内部状态(弹窗开关、当前Tab、表单字段等),@Observed管理可跨组件共享的数据模型(PlanItem、RingItem、NoticeLog、StatItem)。当用户在铃音页导入铃声时,RingItem的inSandbox和size属性变化会自动驱动铃声列表UI刷新;当用户在计划页新增训练时,planList的push操作会自动驱动时间轴新增一行。这种响应式设计让开发者无需手动操作DOM或调用invalidate,只需更新数据模型,UI自动同步。
布局设计方面,四个Tab页面每个都有独特的布局结构——运动页是"大卡+小卡+Chip+折线图+小卡"的混合垂直流,计划页是"左侧时间+中间圆点竖线+右侧训练卡"的竖向时间轴,铃音页是"生成器+状态卡+预览+列表+历史"的功能垂直流,我的页是"渐变大卡+清单+柱状图+说明"的数据展示流。这种"一页一景"的布局策略让每个Tab都有独特的视觉节奏和操作体验,避免了千篇一律的列表式布局。弹窗系统采用Stack层叠+条件渲染+复用遮罩的模式,三种弹窗(新增计划、编辑计划、删除铃声)共享统一的设计语言和交互模式,保证了UI的一致性。
从产品设计的角度,这款应用将硬核的技术能力(WAV音频合成、沙箱文件操作、Canvas绘图、通知发布)包裹在运动健身这一充满活力的业务场景中。活力橙+能量绿+炭黑底的配色方案、呼吸动画的脉动效果、渐变体测大卡的视觉冲击、语义铃声的训练场景关联,这些细节让冰冷的代码产生了温度。它不仅是一个技术范本,更是一套可落地的运动健身产品原型——用户可以管理训练计划、生成自定义铃声、发布训练提醒通知、查看运动数据趋势,形成完整的运动管理闭环。对于希望在HarmonyOS生态中开发运动健身类应用的开发者来说,这款应用的技术方案和设计理念都具有很高的参考价值。
附录: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 将自动执行以下操作:
- 生成项目骨架(Stage 模型目录结构)
- 执行
ohpm install安装依赖 - 运行 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 版本编写,不同版本界面可能存在细微差异。
更多推荐


所有评论(0)