HarmonyOS ArkUI 实战:当 Notification Kit 自定义铃声遇见 Canvas 环形饼图,生鲜电商配送平台如何用单文件页面讲好“送达“这件事
一、技术前言:从框架能力到业务场景的深度融合

HarmonyOS ArkUI 框架是华为为鸿蒙生态打造的核心 UI 开发框架,它采用声明式编程范式(Declarative UI),开发者只需描述界面"是什么样子",框架自动完成状态驱动、差异更新和高效渲染。ArkUI 的核心由 ArkTS 语言和一系列内置组件构成,ArkTS 在 TypeScript 基础上扩展了 @Entry、@Component、@State、@Builder、@Observed 等装饰器,使得状态管理与视图绑定变得极其简洁。在一个结构体(struct)中声明状态变量,当状态变化时,引用该状态的 UI 片段会自动重新渲染,无需手动调用 setData 或 invalidate。这种模式特别适合数据频繁变化的即时配送场景——订单节点不断追加、配送状态实时流转、通知计数持续递增,开发者只需专注业务逻辑,渲染层交给框架。

Notification Kit 是 HarmonyOS 提供的通知能力套件,它允许应用向系统通知栏发布通知、管理通知渠道、请求通知授权。在传统移动开发中,通知铃声通常只能使用系统预设音效或资源目录下的固定音频,应用很难为不同业务场景(到货提醒、满减推送、上新通知)定制差异化铃声。而 HarmonyOS 6.1.1 版本带来了一个关键能力——sound 字段支持应用沙箱内的音频路径。这意味着应用可以在运行时动态生成音频文件、写入沙箱目录、将沙箱路径转换为 uri:: 前缀的 URI 字符串,再填入 NotificationRequest.sound 字段,从而让每一条通知携带独一无二的铃声。对于生鲜电商配送场景而言,“门铃叮咚”“到货号角”"开餐锣"这类拟声铃声可以让用户在不看屏幕的情况下,仅凭听觉就能辨识通知的业务含义,大幅提升配送环节的用户体验。

Canvas 绘制能力是 ArkUI 提供的底层图形接口,它暴露了 CanvasRenderingContext2D 上下文,支持 arc、fillRect、moveTo、fillText 等一整套 Canvas 2D API。在生鲜电商数据可视化场景中,环形饼图(Doughnut Chart)是展示"订单品类分布"的最佳选择之一——它以扇形面积直观反映各品类占比,中心镂空区域又可承载汇总文案,比传统柱状图更紧凑、更美观。通过 Canvas 组件的 onReady 回调获取就绪时机,再调用自定义绘制函数,可以精确控制起止角度、配色、中心镂空半径和文字位置。配合定时器驱动的"呼吸动画",还能让中心文案在"品类分布"与"ORDER"之间切换,形成动态视觉焦点。

生鲜电商与即时配送是一个对"时效感"和"信任感"极度敏感的行业。用户购买蔬菜水果、肉禽水产、乳制品等高保鲜度商品,最关心的不是价格而是"何时送达"“是否全程冷链”“到货时谁来通知我”。一个优秀的配送平台应用,必须把配送时间轴、品类分布、通知铃声三件事做到极致。本应用以"鲜达"为品牌名,采用浅色生鲜绿主题色(主色 #16A34A),通过头部数据小卡(待收订单、配送节点、已发通知)让用户一屏掌握全局状态;首页用横滑时令专场大卡承载营销活动、用环形饼图呈现消费结构;订单 Tab 用竖向时间轴串联配送节点、每个节点可触发携带自定义铃声的配送提醒通知;铃音 Tab 提供铃声生成器(正弦波合成 WAV)和铃声库管理;我的 Tab 用渐变大卡展示会员权益和订单统计。整个应用以单文件页面的形式,将 Notification Kit 的沙箱铃声、Canvas 的环形饼图、@Observed 的数据驱动、@Builder 的视图复用、modalOverlay 的弹窗体系等技术能力有机融合,形成了一个可读、可维护、可扩展的工程范例。

从设计理念上看,本应用遵循"状态即真理"的 ArkUI 哲学:所有可变数据都以 @State 或 @Observed 类实例承载,UI 通过 ForEach 绑定数据源,任何数据的增删改都会自动反映到视图。弹窗系统采用"遮罩 + 居中内容"的经典模式,通过 addModal、editModal、delModal 三个布尔状态控制三态弹窗的显示与隐藏,delKind 字段则统一区分删除目标是铃声还是配送节点,避免重复造弹窗。呼吸动画通过 setInterval 每秒翻转 breath 布尔值,联动头部授权胶囊的透明度、饼图中心文案和图表重绘,以极低的实现成本营造出"页面有呼吸感"的高级体验。这种将多个看似独立的能力(通知、Canvas、动画、弹窗、列表)编织成一个连贯业务叙事的能力,正是 ArkUI 框架在业务场景落地时的真正价值所在。
二、整体架构流程图
这张架构图揭示了应用从入口到渲染的完整数据流。aboutToAppear 生命周期负责初始化授权状态并启动呼吸定时器,它是整个页面"活起来"的起点。数据层的常量数组与 @Observed 类配合,为视图层提供稳定的数据源。业务逻辑层的六个核心方法分别覆盖通知授权、沙箱文件操作、WAV 合成、通知发布、饼图绘制和增删改查,是连接状态与视图的桥梁。视图层的 Builder 按头部、四 Tab、底部栏、弹窗四组组织,通过 currentTab 这一单一状态切换可见内容,形成清晰的"单页面多视图"结构。
三、模块依赖引入与主题色板设计
3.1 三大 Kit 的 import 策略
import { notificationManager } from '@kit.NotificationKit';
import { fileIo as fs, fileUri } from '@kit.CoreFileKit';
import { contextConstant, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
本段是整个应用的依赖声明,四行 import 各有明确分工。第一行从 @kit.NotificationKit 引入 notificationManager,它是通知能力的统一入口,后续的 requestEnableNotification、openNotificationSettings、isNotificationEnabled、publish 等方法都来自这个对象。第二行从 @kit.CoreFileKit 引入 fileIo(别名 fs)和 fileUri,前者用于在沙箱目录创建、写入、关闭、删除音频文件,后者用于将沙箱文件路径转换为系统能识别的 URI 字符串——这是让 Notification Kit 的 sound 字段生效的关键一环。第三行从 @kit.AbilityKit 引入 contextConstant 和 common,contextConstant.AreaMode.EL1 用于指定沙箱存储区等级,common.UIAbilityContext 则是获取 applicationContext、filesDir 等运行时上下文所必需的类型。第四行从 @kit.BasicServicesKit 引入 BusinessError,它是鸿蒙异步 API 的标准错误类型,包含 code 和 message 字段,便于在 catch 中按错误码分支处理(例如 1600004 表示通知未授权)。
这种按 Kit 拆分 import 的写法体现了鸿蒙"按需引入"的设计哲学——每个 Kit 都对应一组明确的能力域,开发者只引入实际用到的模块,减少打包体积的同时也避免了命名冲突。值得注意的是 fileIo as fs 这种别名写法,它让后续的 fs.openSync、fs.writeSync 调用更简洁,符合 Node.js 社区的习惯命名。
3.2 ColorPalette 接口与生鲜绿主题常量
/** 主题色板接口(浅色生鲜绿风格) */
interface ColorPalette {
bg: string;
card: string;
chip: string;
title: string;
sub: string;
text3: string;
white: string;
green: string;
greenD: string;
greenL: string;
orange: string;
orangeD: string;
orangeL: string;
blue: string;
blueL: string;
red: string;
redL: string;
purple: string;
purpleL: string;
gold: string;
goldL: string;
line: string;
tabOn: string;
mask: string;
}
这段定义了一个名为 ColorPalette 的接口,它声明了应用主题色板的全部字段。把颜色集中管理是工程化前端开发的最佳实践——它避免了硬编码十六进制色值散落各处导致的"改一处要找十处"的维护噩梦。接口字段命名遵循"语义优先"原则:bg 是页面背景,card 是卡片白底,chip 是浅色标签底,title/sub/text3 是三级文字色,其余按"主色 + D(深)/ L(浅)"的命名约定,每种主色都提供三个梯度方便在不同场景下取用。line 是分割线,tabOn 是底部 Tab 选中色,mask 是弹窗遮罩色。这种"三梯度色系 + 语义化命名"的设计让 UI 配色既有层次感又便于统一调整。
/** 主题色常量(生鲜绿配色体系) */
const COLORS: ColorPalette = {
bg: '#F4FBF4',
card: '#FFFFFF',
chip: '#E6F4E6',
title: '#14352A',
sub: '#5E8A6E',
text3: '#A8C9B0',
white: '#FFFFFF',
green: '#16A34A',
greenD: '#12813C',
greenL: '#DCFCE7',
orange: '#F97316',
orangeD: '#DD5F0A',
orangeL: '#FFEDD5',
blue: '#3B82F6',
blueL: '#DBEAFE',
red: '#EF4444',
redL: '#FEE2E2',
purple: '#A78BFA',
purpleL: '#EDE9FE',
gold: '#F5C451',
goldL: '#FDF3DC',
line: '#D8EED8',
tabOn: '#16A34A',
mask: 'rgba(20,53,42,0.5)'
};
COLORS 常量是接口的具体实现,它定义了"生鲜绿"这一视觉主题的全部色值。背景 #F4FBF4 是一种极浅的绿白色,让页面看起来干净又有生机;主色 #16A34A 是 Tailwind green-600 的同款鲜绿,既有自然感又不至于太"土";标题文字 #14352A 是深森林绿,比纯黑更柔和,与浅绿背景对比度足够又不刺眼。副标题 #5E8A6E 和三级文字 #A8C9B0 形成两级灰绿过渡,保证信息层级清晰。配色体系中还预留了橙、蓝、红、紫、金五个辅色,分别用于配送中、待发货、删除按钮、乳品品类饼图扇区、会员渐变大卡等差异化场景。mask 使用 rgba(20,53,42,0.5) 半透明深绿,让弹窗遮罩与整体色调保持一致而非生硬的纯黑遮罩。整套配色体系强调"在统一中制造差异"——主色统领品牌识别,辅色服务功能区分。
四、底部 Tab 元信息与业务数据模型
4.1 底部 Tab 列表定义
/** 底部 Tab 元信息 */
interface TabMeta {
icon: string;
label: string;
}
/** 底部 Tab 列表(单排 4 个) */
const TAB_LIST: TabMeta[] = [
{ icon: '🥬', label: '首页' },
{ icon: '📦', label: '订单' },
{ icon: '🔔', label: '铃音' },
{ icon: '👤', label: '我的' }
];
TabMeta 接口定义底部 Tab 项的两个字段:icon 是 Emoji 图标,label 是文字标签。TAB_LIST 是一个四元素的常量数组,分别对应首页(蔬菜 Emoji)、订单(包裹 Emoji)、铃音(铃铛 Emoji)、我的(人头 Emoji)。使用 Emoji 作为图标是一种轻量化的设计选择——它无需引入图标字体或 SVG 资源,跨平台兼容性好,色彩丰富自带情感色彩,非常适合生鲜电商这种"生活化"的 To C 应用。底部 Tab 在视图层通过 ForEach 渲染,配合 currentTab 状态变量判断选中态,选中时图标放大、文字加粗变绿,未选中时缩小并降低透明度,形成清晰的视觉反馈。
4.2 饼图数据与配色一一对应
/** 饼图数据项(对象数组先定义接口) */
interface PieData {
val: number;
label: string;
}
/** 订单品类占比数据 */
const PIE_DATA: PieData[] = [
{ val: 30, label: '蔬菜' },
{ val: 25, label: '水果' },
{ val: 20, label: '肉禽' },
{ val: 15, label: '水产' },
{ val: 10, label: '乳品' }
];
/** 饼图配色(与 PIE_DATA 顺序一一对应) */
const PIE_COLORS: string[] = [
COLORS.green,
COLORS.orange,
COLORS.red,
COLORS.blue,
COLORS.purple
];
PieData 接口只声明两个字段:val 是占比数值(百分比),label 是品类名称。PIE_DATA 数组按业务重要度降序排列五个生鲜品类:蔬菜占 30%、水果占 25%、肉禽占 20%、水产占 15%、乳品占 10%,总和 100%。这种降序排列让饼图扇区从 12 点方向顺时针展开时,最大扇区始终在视觉起点,符合用户"从大到小"的阅读习惯。
PIE_COLORS 是一个与 PIE_DATA 严格一一对应的色值数组,按索引取色保证每个扇区颜色稳定。这里巧妙复用了 COLORS 主题色板:蔬菜用主色绿、水果用橙、肉禽用红、水产用蓝、乳品用紫,五种颜色都来自已定义的辅色,无需新增色值即可满足饼图需求。这种"数据数组与色值数组平行维护"的模式比"把颜色塞进数据对象"更灵活——当需要切换主题(如夜间模式)时,只需替换 PIE_COLORS 的取值来源,而 PIE_DATA 本身完全不需要改动。
4.3 时令专场背景轮换色
/** 首页时令专场大卡背景轮换色 */
const SEASON_BG: string[] = [
COLORS.greenL,
COLORS.orangeL,
COLORS.blueL,
COLORS.purpleL
];
SEASON_BG 是首页横滑时令专场大卡的背景色数组,按索引取浅色版本的主题色(greenL、orangeL、blueL、purpleL)。四张大卡各取一种浅色,使横滑时色彩在绿、橙、蓝、紫之间轮换,避免四张同色卡片的单调感。使用浅色版(L 后缀)而非主色,是为了让大卡上的文字(深色 title)保持足够对比度——浅色背景配深色文字是生鲜电商这类需要传递清晰信息的场景的安全选择。
4.4 配送节点状态着色映射函数
/** 配送节点状态 → 主题色映射(时间轴左侧与圆点着色) */
function traceColor(s: string): string {
if (s === '已完成') {
return COLORS.green;
}
if (s === '配送中') {
return COLORS.orange;
}
if (s === '待发货') {
return COLORS.blue;
}
return COLORS.text3;
}
traceColor 是一个独立的工具函数,接收状态字符串返回对应的主题色。"已完成"返回主色绿(代表成功送达)、"配送中"返回橙色(代表进行中、活跃)、"待发货"返回蓝色(代表等待、未启动),其余未知状态返回三级灰绿作为兜底。这种"状态到颜色"的映射函数把语义判断集中到一处,避免在 ForEach 模板里写一堆三元运算符。函数被订单 Tab 的时间轴圆点、状态文字同时调用,保证同一状态在页面上视觉一致。颜色选择遵循通用约定:绿橙红蓝分别对应完成、进行、警告、等待,符合用户对交通信号灯和仪表盘色的既有认知,降低学习成本。
五、WAV 音频字节合成:让通知铃声可编程
5.1 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);
buildWavBytes 是本应用最具技术含量的函数之一:它在内存中合成一段合法的 WAV 音频字节,无需任何音频素材文件即可生成铃声。函数接收频率 freq(赫兹)和时长 durationMs(毫秒)两个参数,返回一个 ArrayBuffer。sampleRate = 44100 是 CD 音质标准采样率,每秒采集 44100 个样本点;numSamples 根据时长计算总样本数;dataSize = numSamples * 2 因为 16bit 采样每个样本占 2 字节;buf = new ArrayBuffer(44 + dataSize) 中前 44 字节是 WAV 文件头,后面是 PCM 音频数据。DataView 提供了按字节读写 ArrayBuffer 的能力,是手动构造二进制格式的标准工具。
5.2 WAV 文件头的逐字段写入
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);
WAV 文件格式遵循 RIFF 容器规范,44 字节的文件头包含一系列固定字段。writeStr 是内部辅助函数,将字符串按字符逐字节写入指定偏移——用于写入 RIFF、WAVE、fmt 、data 这四个四字节标识符。其余字段按小端序(true 参数表示 little-endian)写入:偏移 4 是文件大小(不含前 8 字节的 RIFF 头),偏移 16 是 fmt 块大小(PCM 格式固定 16),偏移 20 是音频格式(1 = PCM),偏移 22 是声道数(1 = 单声道),偏移 24 是采样率,偏移 28 是字节率(采样率 × 声道 × 位深/8),偏移 32 是块对齐(声道 × 位深/8),偏移 34 是位深(16 bit),偏移 40 是数据段大小。这些字段的精确写入是 Notification Kit 能够正确解析音频文件的前提——任何一个字段错误都会导致通知静默或解析失败。
5.3 PCM 音频数据的包络与衰减合成
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;
}
这一段是音频数据的真正合成逻辑。循环遍历每个样本点,t = i / sampleRate 计算当前时间(秒)。env 是攻击包络(Attack Envelope),前 20ms 内从 0 线性升到 1,避免正弦波瞬间从零跳到峰值产生"咔哒"爆破音。decay 是衰减包络,随时间线性衰减到 0,使铃声有自然的消逝感而非戛然而止。v = Math.sin(2 * Math.PI * freq * t) * 0.5 * env * decay 是最终样本值——纯正弦波乘以 0.5 振幅、再乘包络和衰减,最后 Math.round(v * 32767) 把浮点振幅映射到 16bit 有符号整数范围(-32768 到 32767),通过 setInt16 写入。0.5 的振幅系数是为了避免削波失真——超过 1.0 的振幅在 16bit 量化时会溢出。这套合成策略让生成的铃声既听起来"叮咚"自然,又是完全可程序化控制的,为铃声生成器功能提供了底层引擎。
六、@Observed 数据类与种子数据
6.1 SeasonItem 时令专场条目
/** 时令专场条目(首页横滑大卡) */
@Observed export class SeasonItem {
name: string;
tag: string;
emoji: string;
constructor(name: string, tag: string, emoji: string) {
this.name = name;
this.tag = tag;
this.emoji = emoji;
}
}
/** 时令专场数据(4 条) */
const SEASON_LIST: SeasonItem[] = [
new SeasonItem('春日时令蔬菜专场', '低至 5 折', '🥬'),
new SeasonItem('有机水果尝鲜季', '满 99 减 20', '🍓'),
new SeasonItem('深海鲜捕直达', '冷链 48 小时', '🦐'),
new SeasonItem('草原乳品狂欢周', '第 2 件半价', '🥛')
];
@Observed 装饰器是 ArkUI 提供的可观察对象装饰器,它让一个类的实例属性变化能够被 @State 或 @Prop 状态变量感知。SeasonItem 包含三个属性:name 是专场名称、tag 是促销标签(如"低至 5 折")、emoji 是品类图标。SEASON_LIST 是四个种子数据条目,覆盖蔬菜、水果、海鲜、乳品四大生鲜品类,每条都带差异化的营销文案。export 关键字使得这个类可以在其他文件复用,体现模块化设计。这种"接口/类定义 + 种子数据常量"的模式贯穿整个数据层,让数据结构与数据内容分离,便于后续接入真实接口时只替换数据来源而不动模型。
6.2 OrderTraceItem 订单配送节点条目
/** 订单配送节点条目(订单 Tab 时间轴) */
@Observed export class OrderTraceItem {
time: string;
title: string;
status: string;
constructor(time: string, title: string, status: string) {
this.time = time;
this.title = title;
this.status = status;
}
}
/** 订单配送节点数据(7 条) */
const TRACE_LIST: OrderTraceItem[] = [
new OrderTraceItem('09:12', '订单已支付,拣货中', '待发货'),
new OrderTraceItem('09:30', '分拣完成,等待骑手', '待发货'),
new OrderTraceItem('09:48', '骑手已取货出发', '配送中'),
new OrderTraceItem('10:05', '途经冷链中转站', '配送中'),
new OrderTraceItem('10:22', '距离您约 1.2km', '配送中'),
new OrderTraceItem('10:36', '已送达小区驿站', '已完成'),
new OrderTraceItem('10:40', '签收完成,感谢信任', '已完成')
];
OrderTraceItem 是订单时间轴的核心数据单元,包含时间、标题、状态三个字段。TRACE_LIST 是七条种子数据,按时间顺序串联起一笔订单从"09:12 支付拣货"到"10:40 签收完成"的完整配送旅程,跨越"待发货 → 配送中 → 已完成"三个状态阶段。这种密集的节点设计让时间轴看起来有"全过程可视"的厚重感,符合生鲜配送"全程冷链、节点透明"的行业诉求。每条数据都可以通过页面的"编辑""删除"按钮进行增删改,由于 @Observed 装饰器的存在,任何修改都会自动反映到 UI,无需手动刷新列表。
6.3 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;
}
}
/** 铃声库数据(6 条) */
const RING_LIST: RingItem[] = [
new RingItem('门铃叮咚', 'ring_880.wav', 880, 1200, '—', false),
new RingItem('到货号角', 'ring_660.wav', 660, 1500, '—', false),
new RingItem('开餐锣', 'ring_440.wav', 440, 600, '—', false),
new RingItem('满减提醒铃', 'ring_1320.wav', 1320, 1000, '—', false),
new RingItem('清晨上新铃', 'ring_1760.wav', 1760, 800, '—', false),
new RingItem('晚安轻音', 'ring_220.wav', 220, 2000, '—', false)
];
RingItem 是铃声库的核心数据模型,包含六个属性:name 是铃声名称(拟声化命名如"门铃叮咚")、file 是沙箱文件名(如 ring_880.wav,频率编码进文件名便于辨识)、freq 是合成频率(赫兹)、duration 是合成时长(毫秒)、size 是文件大小(初始为"—"占位,导入沙箱后更新为实际 KB 数)、inSandbox 是是否已写入沙箱的布尔标志。RING_LIST 六条种子数据覆盖了从低频 220Hz"晚安轻音"到高频 1760Hz"清晨上新铃"的频段范围,每条铃声的频率和时长都经过语义化设计——低频沉稳适合夜晚、高频明亮适合早晨、660Hz 接近自然号角声。"门铃叮咚"880Hz 接近标准电话铃频率,是用户最熟悉的"该开门了"的声音信号。
6.4 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;
}
}
/** 订单统计数据(5 条) */
const STAT_LIST: StatItem[] = [
new StatItem('📦', '本月订单', '18', '单'),
new StatItem('💰', '累计节省', '126', '元'),
new StatItem('🥬', '常买品类', '蔬菜', '类'),
new StatItem('⭐', '会员积分', '2680', '分'),
new StatItem('🎫', '可用优惠券', '5', '张')
];
StatItem 是"我的"Tab 订单统计列表的数据模型,四个属性分别是图标 Emoji、统计项名称、数值和单位。STAT_LIST 五条种子数据覆盖了订单数、节省金额、常买品类、会员积分、优惠券数五个维度,构成一个紧凑的会员经营仪表盘。val 字段定义为 string 而非 number 是因为"蔬菜"这种品类值本身就是字符串,统一类型简化模板渲染。每条数据通过 ForEach 渲染为一行横向布局——图标 + 名称 + 数值 + 单位,数据维度多而不乱。
七、页面状态变量与生命周期
7.1 @Entry @Component 与 @State 状态群
/** 页面入口:鲜达主页面(通知铃声 + 品类饼图 + 四 Tab) */
@Entry
@Component
struct Page1110 {
/** 当前选中 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;
/** 生成器频率 */
@State genFreq: number = 880;
/** 生成器时长 */
@State genDuration: number = 1200;
/** Canvas 就绪标志 */
@State canvasReady: boolean = false;
@Entry 装饰器将 Page1110 标记为应用入口页面,@Component 声明它是一个 ArkUI 组件。这一段集中定义了页面所有的可变状态,按职责可分为四组:第一组是 Tab 与弹窗控制(currentTab、addModal、editModal、delModal),决定当前可见的视图和弹窗;第二组是索引定位(editIdx、delIdx、currentRingIdx),记录当前操作的数据条目;第三组是动画与授权(breath、granted、canvasReady),驱动呼吸效果与通知能力;第四组是业务计数与生成器参数(notifyId、sandboxCount、genFreq、genDuration),支撑通知发布与铃声生成。@State 装饰器让这些变量成为可观察状态——任何一处修改,引用该状态的视图片段都会自动重新渲染,这是 ArkUI"状态驱动 UI"的核心机制。
7.2 私有属性与 @Observed 列表绑定
/** 呼吸定时器句柄 */
private timer: number = -1;
/** 删除目标类型(ring=铃声 / trace=配送节点) */
private delKind: string = 'ring';
/** 时令专场列表 */
@State seasonList: SeasonItem[] = SEASON_LIST;
/** 订单配送节点列表 */
@State traceList: OrderTraceItem[] = TRACE_LIST;
/** 铃声库列表 */
@State ringList: RingItem[] = RING_LIST;
/** 订单统计列表 */
@State statList: StatItem[] = STAT_LIST;
/** 已发布配送通知次数 */
@State noticeCount: number = 0;
/** 新增表单:节点时间 */
@State formTime: string = '';
/** 新增表单:节点内容 */
@State formTitle: string = '';
/** 新增表单:节点状态 */
@State formStatus: string = '';
/** 饼图绘制上下文(订单品类分布) */
private pieCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));
这一段继续定义状态与私有属性。timer 是 setInterval 返回的句柄,用于在 aboutToDisappear 时清理定时器,防止内存泄漏。delKind 是一个枚举字符串('ring' 或 'trace'),让删除弹窗能够区分删除目标是铃声还是配送节点——这是用一个弹窗服务多种删除场景的设计技巧。四个 @State 列表(seasonList、traceList、ringList、statList)将前面定义的种子数据赋初值,作为 ForEach 的数据源。formTime/formTitle/formStatus 是新增/编辑表单的临时字段,初始为空字符串。pieCtx 是 Canvas 绘制上下文,通过 new CanvasRenderingContext2D(new RenderingContextSettings(true)) 创建,true 参数开启抗锯齿,保证饼图扇区边缘平滑。注意它是 private 而非 @State,因为它本身引用不变,变化的是内部状态,不需要触发组件级重渲染。
7.3 aboutToAppear 与 aboutToDisappear 生命周期
/** 生命周期:初始化授权状态并启动呼吸定时器 */
aboutToAppear() {
notificationManager.isNotificationEnabled().then((enabled: boolean) => {
this.granted = enabled;
}).catch(() => {
this.granted = false;
});
this.timer = setInterval(() => {
this.breath = !this.breath;
if (this.canvasReady) {
this.drawPieChart();
}
}, 1000);
}
/** 生命周期:清理呼吸定时器 */
aboutToDisappear() {
clearInterval(this.timer);
}
aboutToAppear 是组件创建后、build 执行前的生命周期钩子,适合做异步初始化。这里做两件事:第一,调用 notificationManager.isNotificationEnabled() 异步查询通知授权状态,结果赋给 granted 状态——头部授权胶囊会根据这个状态显示"已授权"或"未授权·点击授权"。第二,启动一个 1000ms 间隔的定时器,每秒翻转 breath 布尔值;如果 Canvas 已就绪(canvasReady 为 true),还会同步触发饼图重绘,实现中心文案在"品类分布"和"ORDER"之间的呼吸切换。aboutToDisappear 是组件销毁前的钩子,调用 clearInterval(this.timer) 清理定时器,避免组件销毁后定时器还在空转造成的内存泄漏——这是鸿蒙开发中保证页面生命周期健康的关键实践。
八、Notification Kit 通知授权与沙箱铃声
8.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 方法实现了鸿蒙通知授权的"两段式"策略。首先通过 this.getUIContext().getHostContext() 获取宿主 UIAbilityContext——这是所有 Notification Kit 调用的入口上下文,类型断言为 common.UIAbilityContext。如果上下文不存在直接 return,避免后续调用空指针。接着调用 notificationManager.requestEnableNotification(hostCtx):如果用户从未授权过,系统会弹出原生授权对话框;用户同意后 Promise resolve,granted 设为 true。如果用户曾经拒绝过授权(或被系统判定不可再次弹框),Promise 会 reject 进入 catch 分支——这里不直接放弃,而是调用 notificationManager.openNotificationSettings(hostCtx) 拉起系统通知设置页,引导用户手动开启通知权限,这就是"二次授权"策略。即便用户在设置页也拒绝,最终 catch 内层把 granted 设为 false。这套策略最大限度保证了应用能拿到通知权限,因为配送通知是生鲜电商应用的核心能力。
8.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 是连接"音频合成"与"通知播放"的关键方法。先获取宿主上下文,再通过 getApplicationContext() 拿到应用级上下文。appCtx.area = contextConstant.AreaMode.EL1 这一行非常关键——它把存储区设置为 EL1(Encryption Level 1,设备级加密沙箱),这是 Notification Kit 的 sound 字段能识别的沙箱区域。appCtx.filesDir 返回该沙箱下的 files 目录绝对路径,拼接文件名得到完整路径。try 块中先调用 buildWavBytes 合成音频字节,再用 fs.openSync 以"创建 + 只写 + 截断"模式打开文件,fs.writeSync 写入字节,fs.closeSync 关闭文件句柄。任何异常都进入 catch 静默忽略,保证页面不会因沙箱写入失败而崩溃。函数最终返回沙箱路径,供下一步转 URI 使用。这种"合成 → 写入 → 返回路径"的封装让上层调用者无需关心二进制细节。
8.3 importRingToSandbox 与 createRingByGen
/** 铃声库条目导入沙箱(更新大小与状态) */
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++;
}
/** 用生成器参数新建铃声并写入沙箱 */
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);
}
importRingToSandbox 是铃声条目级导入方法。先取出指定索引的 RingItem,调用 saveRingToSandbox 写入沙箱,然后把 inSandbox 设为 true、根据公式计算文件大小(KB 数)并写入 size 字段——44 + 采样数 × 2 是 WAV 文件总字节数,除以 1024 转 KB 并四舍五入。sandboxCount 自增用于头部和铃音 Tab 显示已导入数量。createRingByGen 是铃声生成器的提交逻辑:以 ringList.length + 1 作为序号命名(“自定义铃声1"“自定义铃声2”),用当前 genFreq 和 genDuration 两个状态值构造新的 RingItem,push 到列表尾部后立即调用 importRingToSandbox 导入沙箱。这样用户在生成器调整滑块后点"生成铃声到沙箱”,就能立刻在铃声库看到新条目且已导入沙箱,体验流畅闭环。
8.4 setCurrentRing 与 getSoundValue
/** 设为默认通知铃声(未导入沙箱时自动导入) */
setCurrentRing(idx: number) {
if (!this.ringList[idx].inSandbox) {
this.importRingToSandbox(idx);
}
this.currentRingIdx = idx;
}
/** 当前通知请求 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);
}
setCurrentRing 方法在设为默认前先检查是否已导入沙箱,未导入则自动导入,再赋值 currentRingIdx——这种"惰性导入"避免用户必须显式点"导入沙箱"才能设为默认,简化操作流程。getSoundValue 是构建 Notification Kit 的 sound 字段值的核心方法:取出当前默认铃声条目,获取宿主上下文,拼接沙箱路径,最后通过 fileUri.getUriFromPath(path) 把路径转成 URI,再加 uri:: 前缀返回。uri:: 前缀是 HarmonyOS 6.1.1 通知 sound 字段识别沙箱路径的约定格式——系统能根据这个前缀解析出沙箱文件并播放。这个方法是整个"自定义铃声"能力的最后一公里。
8.5 publishNotice 发布配送提醒通知
/** 发布携带沙箱自定义铃声的配送提醒通知(核心:sound 字段填沙箱 uri) */
publishNotice() {
const ring = this.ringList[this.currentRingIdx];
if (!ring.inSandbox) {
this.importRingToSandbox(this.currentRingIdx);
}
const soundVal = this.getSoundValue();
const ringName = ring.name;
const request: notificationManager.NotificationRequest = {
id: this.notifyId,
notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,
content: {
notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: {
title: '配送提醒',
text: '您的生鲜订单正在配送',
additionalText: '自定义铃声:' + ringName
}
},
sound: soundVal // HarmonyOS 6.1.1:支持应用沙箱内的音频路径
};
notificationManager.publish(request).then(() => {
this.notifyId++;
this.noticeCount++;
}).catch((err: BusinessError) => {
if (err.code === 1600004) {
this.requestAuth();
}
});
}
publishNotice 是 Notification Kit 能力的集大成方法。先确保当前默认铃声已导入沙箱,再调用 getSoundValue() 拿到 uri:: 前缀的 URI。构造 NotificationRequest 对象时,id 用自增的 notifyId 保证每条通知独立;notificationSlotType 设为 SOCIAL_COMMUNICATION(社交通讯类型,重要级别高,会强制响铃);content 使用 NOTIFICATION_CONTENT_BASIC_TEXT 基础文本类型,包含 title(配送提醒)、text(您的生鲜订单正在配送)、additionalText(自定义铃声:门铃叮咚)三个字段;最关键的是 sound: soundVal——这一行把沙箱自定义铃声 URI 绑定到通知,发布时系统会播放这段音频而非默认铃声。调用 notificationManager.publish(request) 发布,成功后 notifyId 和 noticeCount 都自增,头部"已发通知"小卡会实时刷新。catch 分支检查错误码 1600004(通知未授权),如果是则触发 requestAuth 重新引导授权。这个方法把授权检查、沙箱导入、URI 生成、通知发布、错误恢复五步串联,是整个应用最具业务价值的代码段。
九、铃声与配送节点的增删改逻辑
9.1 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 处理铃声删除的全过程。先做边界检查(idx 在合法范围),再判断该铃声是否在沙箱——如果在,先把 sandboxCount 减一,然后通过 fs.unlinkSync 同步删除沙箱文件(注意要重新设置 area = EL1,因为可能被其他操作改过)。删除文件失败静默忽略,因为可能文件本来就不存在。然后 splice 把条目从列表移除,如果被删除的恰好是当前默认铃声之后的索引越界,则把 currentRingIdx 收敛到列表末尾。最后 delModal = false 关闭弹窗。这段代码体现了"数据一致性"思维:删除内存数据的同时清理磁盘文件,避免沙箱累积无用音频。
9.2 saveTrace 新增配送节点
/** 保存新增配送节点(空字段用默认值兜底) */
saveTrace() {
const time = this.formTime === '' ? '刚刚' : this.formTime;
const title = this.formTitle === '' ? '订单状态更新' : this.formTitle;
const status = this.formStatus === '' ? '配送中' : this.formStatus;
this.traceList.push(new OrderTraceItem(time, title, status));
this.formTime = '';
this.formTitle = '';
this.formStatus = '';
this.addModal = false;
}
saveTrace 是新增配送节点的保存逻辑。三个三元运算符为空字段提供默认值——时间为空填"刚刚"、标题为空填"订单状态更新"、状态为空填"配送中",这种兜底机制让用户即使不填任何字段直接点保存,也能产生合法的节点数据。用表单值构造 OrderTraceItem 后 push 到 traceList,由于 traceList 是 @State 数组且元素是 @Observed 类,列表会自动追加新行。然后清空三个表单字段为下次输入做准备,最后关闭新增弹窗。短短几行代码就完成了"输入校验 → 数据构造 → 列表追加 → 表单重置 → 弹窗关闭"的完整保存流程。
9.3 openEditTrace 与 editTrace
/** 打开编辑弹窗(回填配送节点当前值) */
openEditTrace(idx: number) {
this.editIdx = idx;
this.formTime = this.traceList[idx].time;
this.formTitle = this.traceList[idx].title;
this.formStatus = this.traceList[idx].status;
this.editModal = true;
}
/** 保存编辑后的配送节点(整体替换数组引用以刷新列表) */
editTrace() {
const t = this.traceList[this.editIdx];
t.time = this.formTime === '' ? t.time : this.formTime;
t.title = this.formTitle === '' ? t.title : this.formTitle;
t.status = this.formStatus === '' ? t.status : this.formStatus;
this.traceList = this.traceList.slice();
this.editModal = false;
}
openEditTrace 在打开编辑弹窗前,先把目标节点的当前值回填到 formTime/formTitle/formStatus 三个表单字段,这样弹窗的 TextInput 会显示当前值,用户基于现状修改而非从零输入——这是编辑场景的标准做法。editTrace 保存修改时,空字段保留原值(与新增的兜底默认值不同,编辑场景下空表示"不修改"),直接修改 @Observed 类实例的属性。关键点是 this.traceList = this.traceList.slice()——通过 slice() 创建一个新数组引用替换原引用,强制触发 @State 数组级别重渲染。这是因为修改 @Observed 实例属性只触发属性级更新,某些场景下需要数组级刷新才能保证 ForEach 重新求值,slice() 是个轻量且语义清晰的技巧。
9.4 removeTrace 删除配送节点
/** 删除配送节点 */
removeTrace() {
if (this.delIdx >= 0 && this.delIdx < this.traceList.length) {
this.traceList.splice(this.delIdx, 1);
}
this.delModal = false;
}
removeTrace 比删除铃声简单——配送节点没有沙箱文件需要清理,只需 splice 移除数组元素即可。边界检查保证索引合法,最后关闭删除弹窗。删除后 @State traceList 数组变化,ForEach 自动重新渲染时间轴,被删除的节点消失,后续节点的连接线也会自动重新绘制。
十、Canvas 环形饼图绘制
10.1 drawPieChart 函数全貌
/** 绘制订单品类环形饼图(中心镂空 + 呼吸切换文案) */
drawPieChart() {
const ctx = this.pieCtx;
const cx = 160;
const cy = 100;
const r = 66;
ctx.clearRect(0, 0, 320, 200);
let start = -Math.PI / 2;
for (let i = 0; i < PIE_DATA.length; i++) {
const val = PIE_DATA[i].val;
const angle = (val / 100) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, r, start, start + angle);
ctx.fillStyle = PIE_COLORS[i];
ctx.fill();
start += angle;
}
ctx.beginPath();
ctx.arc(cx, cy, r * 0.55, 0, Math.PI * 2);
ctx.fillStyle = COLORS.card;
ctx.fill();
ctx.fillStyle = COLORS.title;
ctx.font = 'bold 15px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(this.breath ? '品类分布' : 'ORDER', cx, cy + 5);
}
drawPieChart 是 Canvas 绘制的核心方法。首先取出 pieCtx 上下文,定义圆心 (160, 100)、半径 66——这组数值与 Canvas 组件 320×200 的尺寸匹配,保证饼图居中且留有边距。clearRect(0, 0, 320, 200) 清空画布,避免呼吸重绘时残留旧像素。start = -Math.PI / 2 把起始角度设为 12 点方向(数学坐标 0 弧度是 3 点方向,负 90 度即 12 点),符合饼图"从顶部开始顺时针"的视觉惯例。
进入循环遍历 PIE_DATA,每项计算占比角度 angle = (val / 100) * Math.PI * 2(百分比转弧度),beginPath 开启新路径、moveTo 移到圆心、arc 画扇形弧线、fillStyle 设对应色、fill 填充——一个扇形就完成了。start += angle 累加起始角度,准备画下一个扇形。五个扇形画完后,整圆被分成蔬菜 30%、水果 25%、肉禽 20%、水产 15%、乳品 10% 五份。
接着是环形饼图的关键——中心镂空。beginPath + arc(cx, cy, r * 0.55, 0, Math.PI * 2) 画一个半径 36.3 的完整小圆,fillStyle = COLORS.card(白色)填充,覆盖掉扇形中心部分,形成"环形"效果。这个镂空区域承载中心文案:fillStyle = COLORS.title、font = 'bold 15px sans-serif'、textAlign = 'center',最后 fillText(this.breath ? '品类分布' : 'ORDER', cx, cy + 5) 根据 breath 状态切换文案。cy + 5 是文字垂直微调——Canvas 文字基线默认在底部,加 5 像素让文字视觉居中。配合 aboutToAppear 启动的 1 秒定时器,每秒 breath 翻转一次,中心文案就在中文"品类分布"和英文"ORDER"之间切换,形成"呼吸"效果。
十一、页面骨架 build 与四 Tab 切换
11.1 build 主结构
/** 页面骨架:头部 + 滚动内容(四 Tab)+ 底部 Tab + 弹窗层 */
build() {
Stack() {
Column() {
this.headerMain()
Divider().strokeWidth(1).color(COLORS.line)
Scroll() {
Column() {
if (this.currentTab === 0) {
this.tabHome()
} else if (this.currentTab === 1) {
this.tabOrder()
} else if (this.currentTab === 2) {
this.tabRing()
} else if (this.currentTab === 3) {
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 是组件的渲染入口。最外层是 Stack(堆叠容器),它允许子元素在同一区域层叠,这是实现弹窗覆盖主内容的关键。Stack 内第一层是一个 Column,从上到下依次是 headerMain()(头部)、Divider(分割线)、Scroll(可滚动内容区)、tabBar()(底部 Tab 栏)。Scroll 内是一个 Column,根据 currentTab 的值通过 if-else 链选择渲染 tabHome、tabOrder、tabRing、tabMine 四个 Builder 之一——这种"条件渲染"比一次性渲染全部 Tab 更节省内存。Scroll 设置 layoutWeight(1) 占满中间剩余空间、scrollBar(BarState.Off) 隐藏滚动条。Stack 的第二、三、四层是三个弹窗,分别由 addModal、editModal、delModal 三个布尔状态控制显示——状态为 true 时弹窗渲染覆盖在主内容之上,关闭时立即从渲染树移除。整个 build 结构清晰、职责分明,是单页面多视图应用的经典骨架。
十二、头部 Builder:数据小卡与授权胶囊
12.1 headerMain 头部主结构
/** 应用头部:标题副题 + 授权胶囊 + 三数据小卡 */
@Builder
headerMain() {
Column({ space: 10 }) {
Row() {
Column({ space: 2 }) {
Text('鲜达')
.fontSize(19)
.fontColor(COLORS.title)
.fontWeight(FontWeight.Bold)
Text('送达地址 科技园 3 号楼 · 配送中')
.fontSize(10)
.fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
Column().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: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.granted ? COLORS.greenL : COLORS.redL)
.borderRadius(10)
.onClick(() => {
if (!this.granted) {
this.requestAuth();
}
})
}
.width('100%')
headerMain 是 @Builder 装饰的视图构造器,专门负责头部渲染。外层 Column({ space: 10 }) 上下两部分:第一部分是 Row 横向布局,左侧 Column 放品牌名"鲜达"(19px 粗体深绿色)和送达地址副标题(10px 灰绿色"送达地址 科技园 3 号楼 · 配送中")。中间用 Column().layoutWeight(1) 作为弹性占位,把右侧授权胶囊推到最右。右侧胶囊是一个 Row,包含一个 Circle 圆点(直径 6px)和状态文字。圆点的 fill 根据 granted 状态在绿/红之间切换,opacity 根据 breath 状态在 1/0.35 之间切换——这就是"呼吸效果"在头部的体现,未授权时红灯闪烁、已授权时绿灯闪烁,让用户一眼注意到授权状态。整个胶囊的背景色也随状态切换浅绿或浅红,圆角 10 形成药丸状。点击时如果未授权则触发 requestAuth,已授权则不做任何操作——避免重复弹框打扰用户。
12.2 三数据小卡
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('2').fontSize(15).fontColor(COLORS.orangeD).fontWeight(FontWeight.Bold)
Text('待收订单').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 8 })
.backgroundColor(COLORS.card)
.borderRadius(10)
Column({ space: 2 }) {
Text(this.traceList.length.toString()).fontSize(15).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
Text('配送节点').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 8 })
.backgroundColor(COLORS.card)
.borderRadius(10)
Column({ space: 2 }) {
Text(this.noticeCount.toString()).fontSize(15).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
Text('已发通知').fontSize(9).fontColor(COLORS.sub)
}
.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 })
.backgroundColor(COLORS.bg)
}
头部的第二部分是三数据小卡,横向排列在 Row({ space: 8 }) 内。每张卡片是一个 Column,包含数值(15px 粗体)和说明文字(9px 灰绿色)。第一张"待收订单"数值为固定 2(橙色),第二张"配送节点"数值动态取自 this.traceList.length.toString()(绿色,配送节点越多数字越大),第三张"已发通知"数值取自 this.noticeCount.toString()(蓝色,每发布一条配送通知就自增)。三张卡片各用一种主题色区分,配 layoutWeight(1) 等宽分布、白底圆角 10,形成简洁的数据仪表盘。由于 traceList 和 noticeCount 都是 @State,新增节点或发布通知后,对应数字会自动刷新,无需手动调用 setText。这种"数据即视图"的设计让头部始终保持最新状态,是 ArkUI 状态驱动 UI 的典型范例。
十三、首页 Tab:横滑大卡与品类饼图
13.1 tabHome 标题行与横滑专场
/** 首页 Tab:横滑时令专场大卡 + 品类饼图 + 图例行 */
@Builder
tabHome() {
Column({ space: 10 }) {
Row() {
Text('时令专场')
.fontSize(14)
.fontColor(COLORS.title)
.fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('横滑查看全部')
.fontSize(9)
.fontColor(COLORS.text3)
}
.width('100%')
Scroll() {
Row({ space: 12 }) {
ForEach(this.seasonList, (item: SeasonItem, idx: number) => {
Stack({ alignContent: Alignment.BottomStart }) {
Column().width(250).height(140)
.backgroundColor(SEASON_BG[idx])
.borderRadius(14)
Column({ space: 5 }) {
Text(item.emoji).fontSize(26)
Text(item.name)
.fontSize(14)
.fontColor(COLORS.title)
.fontWeight(FontWeight.Bold)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.tag).fontSize(9).fontColor(COLORS.greenD)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.white).borderRadius(8)
}
.padding(12)
.alignItems(HorizontalAlign.Start)
}
.width(250)
.height(140)
}, (item: SeasonItem) => item.name)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
tabHome 是首页 Tab 的视图构造器。第一部分是标题行——"时令专场"粗体标题 + 弹性占位 + "横滑查看全部"提示文字,引导用户横滑。第二部分是横滑专场,外层 Scroll 设置 scrollable(ScrollDirection.Horizontal) 启用横向滚动、隐藏滚动条;内层 Row({ space: 12 }) 横向排列所有大卡。每张大卡用 Stack({ alignContent: Alignment.BottomStart }) 实现内容底左对齐——背景色 Column(250×140 圆角 14)+ 内容 Column(Emoji + 名称 + 标签)。SEASON_BG[idx] 让四张卡片各取一种浅色背景,色彩在绿橙蓝紫间轮换。maxLines(1) + textOverflow({ overflow: TextOverflow.Ellipsis }) 保证名称超长时省略号截断而非换行破坏布局。促销标签 item.tag 用白底圆角小药丸承载,与浅色背景形成对比。ForEach 的键函数 (item: SeasonItem) => item.name 用名称作为唯一键,保证数据变化时正确 diff。
13.2 品类饼图与图例
Row() {
Text('订单品类分布')
.fontSize(14)
.fontColor(COLORS.title)
.fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('近 30 天')
.fontSize(9)
.fontColor(COLORS.text3)
}
.width('100%')
Column({ space: 12 }) {
Canvas(this.pieCtx)
.width('100%')
.height(200)
.onReady(() => {
this.canvasReady = true;
this.drawPieChart();
})
ForEach(PIE_DATA, (d: PieData, i: number) => {
Row({ space: 6 }) {
Column().width(10).height(10)
.borderRadius(3)
.backgroundColor(PIE_COLORS[i])
Text(d.label + ' ' + d.val + '%')
.fontSize(10)
.fontColor(COLORS.sub)
}
.margin({ bottom: 6 })
}, (d: PieData) => d.label)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(12)
}
.width('100%')
}
首页的第二大块是订单品类饼图区。标题行同样用"标题 + 占位 + 时间窗"模式,"近 30 天"提示数据时间范围。主体是一个白底圆角 12 的卡片,内含 Canvas 组件和图例列表。Canvas(this.pieCtx) 把前面创建的 pieCtx 绑定到组件,width('100%') 撑满卡片、height(200) 固定高度。onReady 回调在 Canvas 初始化完成时触发,把 canvasReady 设为 true(这样定时器才会触发重绘),并立即调用 drawPieChart() 首次绘制。图例部分用 ForEach 遍历 PIE_DATA,每行是一个 10×10 色块(PIE_COLORS[i])+ 品类名称和百分比文字,色块颜色与饼图扇区严格对应。这样饼图与图例在视觉上形成"扇区-色块"的明确关联,用户能快速读出每个色块对应的品类占比。
十四、订单 Tab:竖向时间轴
14.1 tabOrder 标题行与新增按钮
/** 订单 Tab:竖向时间轴(配送节点 + 配送通知按钮) */
@Builder
tabOrder() {
Column({ space: 10 }) {
Row() {
Text('配送进度 · 订单 20260825001')
.fontSize(14)
.fontColor(COLORS.title)
.fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('+ 新增节点')
.fontSize(10)
.fontColor(COLORS.white)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(COLORS.green)
.borderRadius(10)
.onClick(() => {
this.addModal = true;
})
}
.width('100%')
tabOrder 是订单 Tab 的视图构造器。标题行显示"配送进度 · 订单 20260825001"——订单号是 2026 年 8 月 25 日第 001 单的语义化编码,让用户对订单归属有具象感。右侧"+ 新增节点"按钮是绿底白字圆角药丸,点击后 addModal = true 弹出新增节点弹窗。这种把高频操作放在标题行右侧的布局,符合"内容+操作"双栏 header 的常见设计模式。
14.2 时间轴节点渲染
ForEach(this.traceList, (item: OrderTraceItem, 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(traceColor(item.status))
}
.width(44)
.height('100%')
.alignItems(HorizontalAlign.Start)
.padding({ top: 12 })
Column() {
Circle().width(8).height(8)
.fill(traceColor(item.status))
if (idx < this.traceList.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('全程冷链 · 温度实时监控')
.fontSize(9)
.fontColor(COLORS.text3)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 3 }) {
Text('配送通知').fontSize(8).fontColor(COLORS.sub)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.backgroundColor(COLORS.chip).borderRadius(6)
.onClick(() => {
this.publishNotice();
})
Text('编辑').fontSize(8).fontColor(COLORS.blue)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.backgroundColor(COLORS.blueL).borderRadius(6)
.onClick(() => {
this.openEditTrace(idx);
})
Text('删除').fontSize(8).fontColor(COLORS.red)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.backgroundColor(COLORS.redL).borderRadius(6)
.onClick(() => {
this.delKind = 'trace';
this.delIdx = idx;
this.delModal = true;
})
}
}
.layoutWeight(1)
.height('100%')
.padding(10)
.backgroundColor(COLORS.card)
.borderRadius(10)
}
.width('100%')
.height(72)
.alignItems(VerticalAlign.Top)
.margin({ bottom: 6 })
}, (item: OrderTraceItem) => item.time + item.title)
}
.width('100%')
}
这一段是时间轴的核心渲染逻辑。ForEach 遍历 traceList,每个节点渲染为一行高 72 的 Row,包含三列:第一列是时间+状态文字(左对齐,44 宽),状态文字颜色由 traceColor 函数动态着色——“已完成"绿、“配送中"橙、“待发货"蓝。第二列是时间轴的"轴”,顶部一个 8×8 圆点(颜色同状态),下面用 if (idx < this.traceList.length - 1) 判断:如果不是最后一个节点,就渲染一根 2 宽的灰色竖线连接到下一个节点,形成完整的"时间轴"视觉链。第三列是节点内容卡片(白底圆角 10),左侧是节点标题(如"骑手已取货出发”)和副标题"全程冷链 · 温度实时监控”,右侧是三个操作按钮竖向排列:“配送通知”(点击触发 publishNotice 发布带铃声的通知)、“编辑”(点击 openEditTrace)、“删除”(点击设 delKind='trace'、delIdx=idx、delModal=true 弹出删除确认弹窗)。
每个按钮用浅色背景 + 对应色文字的小药丸样式,密集但清晰。ForEach 的键函数 (item: OrderTraceItem) => item.time + item.title 用时间+标题作为唯一键,保证节点增删改时正确 diff。整个时间轴把"配送全过程"以"时间-状态-内容-操作"四维结构呈现,是生鲜电商配送透明度的核心体现。
十五、铃音 Tab:生成器与铃声库
15.1 铃声生成器卡片
/** 铃音 Tab:生成器 + 默认铃声 + 铃声库 */
@Builder
tabRing() {
Column({ space: 10 }) {
Column({ space: 12 }) {
Row() {
Text('🎛️ 铃声生成器')
.fontSize(13)
.fontColor(COLORS.title)
.fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('正弦波合成')
.fontSize(9)
.fontColor(COLORS.text3)
}
.width('100%')
Row({ space: 8 }) {
Text('频率').fontSize(10).fontColor(COLORS.sub)
Text(this.genFreq.toString() + ' Hz').fontSize(10)
.fontColor(COLORS.green).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('时长 ' + this.genDuration.toString() + ' ms').fontSize(10)
.fontColor(COLORS.orangeD).fontWeight(FontWeight.Bold)
}
.width('100%')
Slider({ value: this.genFreq, min: 220, max: 1760, step: 20, style: SliderStyle.OutSet })
.selectedColor(COLORS.green)
.trackColor(COLORS.chip)
.blockColor(COLORS.green)
.onChange((value: number) => {
this.genFreq = value;
})
Slider({ value: this.genDuration, min: 600, max: 2400, step: 100, style: SliderStyle.OutSet })
.selectedColor(COLORS.green)
.trackColor(COLORS.chip)
.blockColor(COLORS.green)
.onChange((value: number) => {
this.genDuration = value;
})
Text('生成铃声到沙箱')
.fontSize(12)
.fontColor(COLORS.white)
.fontWeight(FontWeight.Bold)
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 })
.backgroundColor(COLORS.green)
.borderRadius(10)
.onClick(() => {
this.createRingByGen();
})
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(12)
tabRing 是铃音 Tab 的视图构造器,第一块是铃声生成器。标题行"🎛️ 铃声生成器"和"正弦波合成"副标题点明这是一个程序化合成工具。下面一行显示当前频率(绿色加粗,220-1760 Hz)和时长(橙色加粗,600-2400 ms)两个关键参数,颜色与下方 Slider 的 selectedColor 对应形成视觉关联。两个 Slider 滑块分别控制频率和时长,onChange 把滑块值同步到 genFreq、genDuration 状态。底部"生成铃声到沙箱"绿底白字按钮,点击触发 createRingByGen 把当前参数合成为 WAV 写入沙箱并追加到铃声库。这个生成器把音频合成的"黑盒"变成了用户可玩的"调音台",体现了应用"可编程铃声"的产品差异化。
15.2 当前默认铃声卡片
Column({ space: 6 }) {
Row() {
Text('⭐ 当前默认铃声')
.fontSize(13)
.fontColor(COLORS.title)
.fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('沙箱 ' + this.sandboxCount + ' 首')
.fontSize(9)
.fontColor(COLORS.sub)
}
.width('100%')
Row({ space: 8 }) {
Text('🎵')
.fontSize(16)
Text(this.ringList[this.currentRingIdx].name)
.fontSize(12)
.fontColor(COLORS.greenD)
.fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('通知 sound 字段')
.fontSize(9)
.fontColor(COLORS.text3)
}
.width('100%')
Text(this.getSoundValue())
.fontSize(8)
.fontColor(COLORS.sub)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.padding(8)
.backgroundColor(COLORS.chip)
.borderRadius(8)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(12)
第二块是"当前默认铃声"信息卡片。标题行显示已导入沙箱数量"沙箱 N 首",让用户对沙箱使用情况一目了然。下方一行显示当前默认铃声名称(绿色加粗)和"通知 sound 字段"提示。最关键的是底部一个浅绿底圆角 8 的文本框,里面显示 this.getSoundValue() 的实际返回值——这是一个 uri:: 开头的完整沙箱 URI 字符串,长度通常较长,所以 fontSize(8) + maxLines(2) + 省略号截断。把这个 URI 直接展示给用户是一个"技术透明化"的设计:用户可以直观看到通知 sound 字段填的是什么,理解铃声是如何绑定到通知的,这对于开发者和高级用户调试非常有价值。
15.3 铃声库列表
Column({ space: 8 }) {
Row() {
Text('🎶 铃声库')
.fontSize(13)
.fontColor(COLORS.title)
.fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(this.ringList.length + ' 首')
.fontSize(9)
.fontColor(COLORS.text3)
}
.width('100%')
ForEach(this.ringList, (item: RingItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text('🎵')
.fontSize(14)
Text(item.name)
.fontSize(12)
.fontColor(COLORS.title)
.fontWeight(FontWeight.Bold)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (idx === this.currentRingIdx) {
Text('默认')
.fontSize(8)
.fontColor(COLORS.white)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.green)
.borderRadius(6)
}
Column().layoutWeight(1)
Text(item.freq + 'Hz · ' + item.duration + 'ms · ' + item.size)
.fontSize(9)
.fontColor(COLORS.text3)
}
.width('100%')
Row({ space: 8 }) {
Text(item.inSandbox ? '沙箱中' : '未导入')
.fontSize(8)
.fontColor(item.inSandbox ? COLORS.green : COLORS.orange)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(item.inSandbox ? COLORS.greenL : COLORS.orangeL)
.borderRadius(6)
Column().layoutWeight(1)
if (!item.inSandbox) {
Text('导入沙箱')
.fontSize(9)
.fontColor(COLORS.orangeD)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(COLORS.orangeL)
.borderRadius(8)
.onClick(() => {
this.importRingToSandbox(idx);
})
}
Text('设为默认')
.fontSize(9)
.fontColor(COLORS.greenD)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(COLORS.greenL)
.borderRadius(8)
.onClick(() => {
this.setCurrentRing(idx);
})
Text('删除')
.fontSize(9)
.fontColor(COLORS.red)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(COLORS.redL)
.borderRadius(8)
.onClick(() => {
this.delKind = 'ring';
this.delIdx = idx;
this.delModal = true;
})
}
.width('100%')
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.chip)
.borderRadius(10)
}, (item: RingItem) => item.file)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(12)
}
.width('100%')
}
第三块是铃声库列表。ForEach 遍历 ringList,每个条目渲染为一个浅绿底圆角 10 的卡片,分上下两行:上行是铃声名称、(如果是当前默认)"默认"绿标、右侧频率/时长/大小参数;下行是"沙箱中/未导入"状态标、(未导入时)"导入沙箱"按钮、"设为默认"按钮、"删除"按钮。状态标颜色随 inSandbox 在绿橙间切换,按钮颜色统一遵循"导入橙、设为默认绿、删除红"的语义化约定。删除按钮点击时设 delKind='ring'、delIdx=idx、delModal=true,与配送节点的删除共用一个弹窗但通过 delKind 区分操作对象。ForEach 的键函数 (item: RingItem) => item.file 用文件名作为唯一键,因为文件名是铃声的唯一标识。
十六、我的 Tab 与底部 Tab 栏
16.1 tabMine 会员渐变大卡与统计列表
/** 我的 Tab:会员渐变大卡 + 订单统计列表 */
@Builder
tabMine() {
Column({ space: 10 }) {
Column({ space: 6 }) {
Row() {
Column({ space: 3 }) {
Text('鲜达绿卡 · 黄金会员').fontSize(11).fontColor('rgba(255,255,255,0.7)')
Text('2680 积分').fontSize(24).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
}
.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Text('🥬').fontSize(34)
}
.width('100%')
Text('会员日全场 95 折 · 每月 4 张免配送费券 · 专属客服')
.fontSize(9).fontColor('rgba(255,255,255,0.7)').width('100%')
}
.width('100%')
.padding(14)
.borderRadius(14)
.linearGradient({ angle: 135, colors: [[COLORS.greenD, 0], [COLORS.gold, 1]] })
ForEach(this.statList, (item: StatItem) => {
Row({ space: 10 }) {
Text(item.icon).fontSize(16)
Text(item.name).fontSize(12).fontColor(COLORS.title)
Column().layoutWeight(1)
Text(item.val).fontSize(13).fontColor(COLORS.greenD).fontWeight(FontWeight.Bold)
Text(item.tag).fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.card)
.borderRadius(10)
.margin({ bottom: 8 })
}, (item: StatItem) => item.name)
}
.width('100%')
}
tabMine 是"我的"Tab 视图。顶部是一个 linearGradient 渐变大卡——angle: 135 从左上到右下,颜色从 COLORS.greenD(深绿)过渡到 COLORS.gold(金黄),呼应"鲜达绿卡 · 黄金会员"的双色命名。卡片内左侧是会员身份和积分(24px 大字白色加粗),右侧是蔬菜 Emoji(34px),下方一行会员权益描述"会员日全场 95 折 · 每月 4 张免配送费券 · 专属客服"。所有文字用 rgba(255,255,255,0.7) 半透明白色,在深色渐变背景上既有层次又不抢眼。
下方是 ForEach 渲染的统计列表,每行白底圆角 10——Emoji 图标 + 统计项名称 + 弹性占位 + 数值(绿色加粗) + 单位(灰绿色)。五行统计覆盖订单、节省、品类、积分、优惠券,构成会员经营仪表盘。ForEach 键函数 (item: StatItem) => item.name 用名称作为键。
16.2 tabBar 底部 Tab 栏
/** 底部 Tab 栏(单排 4 个) */
@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 })
}
tabBar 是底部 Tab 栏。ForEach 遍历 TAB_LIST,每个 Tab 渲染为图标+文字纵向排列的 Column,layoutWeight(1) 等宽分布。选中态由 currentTab === idx 判断:选中时图标 20px、不透明度 1、文字加粗绿色 tabOn;未选中时图标 17px、透明度 0.65、文字常规灰绿。点击 onClick 把 currentTab 设为当前索引,触发 build 内的 if-else 链重新渲染对应 Tab 内容。底部 Tab 栏背景白色,顶部 1px 灰绿色分割线,与浅绿主题协调。整套底部 Tab 的交互简洁直接——点击即切换,状态视觉反馈即时清晰,是 ArkUI 状态驱动 UI 的最直观体现。
十七、三态弹窗系统
17.1 modalOverlay 全屏遮罩
/** 弹窗全屏遮罩(点击关闭) */
@Builder
modalOverlay(onClose: () => void) {
Stack() {
Column().width('100%').height('100%')
.backgroundColor(COLORS.mask)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
.onClick(() => onClose())
}
modalOverlay 是弹窗的共享遮罩层,接收一个 onClose 回调。外层 Stack 占满全屏并设置内容居中对齐(Alignment.Center),内层是一个全屏的半透明 Column,背景色 COLORS.mask(rgba(20,53,42,0.5) 深绿半透明)。整个 Stack 的 onClick 触发 onClose 回调——这是"点击遮罩关闭弹窗"的标准实现。把遮罩抽成独立 Builder 复用,避免每个弹窗重复写一遍遮罩代码。alignContent(Alignment.Center) 让弹窗主体(外层 Stack 的子元素)自动垂直水平居中。
17.2 panelAdd 新增配送节点弹窗
/** 新增配送节点弹窗 */
@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: '节点时间(如:10:50)' })
.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.formStatus, placeholder: '节点状态(待发货/配送中/已完成)' })
.fontSize(11)
.fontColor(COLORS.title)
.backgroundColor(COLORS.chip)
.borderRadius(8)
.onChange((value: string) => {
this.formStatus = 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.green)
.borderRadius(9)
.onClick(() => {
this.saveTrace();
})
}
.width('100%')
}
.width('78%')
.padding(16)
.backgroundColor(COLORS.card)
.borderRadius(14)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
}
panelAdd 是新增配送节点弹窗。结构上是 Stack 内层叠 modalOverlay(遮罩)和主体 Column(白底圆角 14,宽 78%)。主体内从上到下:标题"新增配送节点"、三个 TextInput(时间、内容、状态),每个输入框 text 绑定 formTime/formTitle/formStatus 状态,onChange 把输入值同步回状态。底部 Row 是"取消"+"保存"两个等宽按钮——取消灰底,点击 onClose 关闭弹窗;保存绿底白字加粗,点击触发 saveTrace 提交新增。TextInput 的 text 参数支持双向绑定——状态变化输入框内容跟着变,输入框输入状态也跟着变,这是 ArkUI 表单组件的核心特性。弹窗宽度 78% 是经验值,既不会太窄挤内容,也不会太宽挡住主内容。
17.3 panelEdit 编辑配送节点弹窗
/** 编辑配送节点弹窗 */
@Builder
panelEdit(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('编辑配送节点')
.fontSize(15)
.fontColor(COLORS.title)
.fontWeight(FontWeight.Bold)
TextInput({ text: this.formTime, placeholder: '节点时间' })
.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.formStatus, placeholder: '节点状态' })
.fontSize(11)
.fontColor(COLORS.title)
.backgroundColor(COLORS.chip)
.borderRadius(8)
.onChange((value: string) => {
this.formStatus = 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.orangeD)
.borderRadius(9)
.onClick(() => {
this.editTrace();
})
}
.width('100%')
}
.width('78%')
.padding(16)
.backgroundColor(COLORS.card)
.borderRadius(14)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
}
panelEdit 与 panelAdd 结构高度相似,只有两处关键差异:标题是"编辑配送节点",保存按钮文字是"保存修改"、背景色是橙色 COLORS.orangeD 而非绿色。颜色差异是有意为之——新增是"创造"用绿色,编辑是"修改"用橙色,让用户在视觉上区分两种操作。点击保存触发 editTrace 而非 saveTrace。三个 TextInput 的 text 同样绑定 formTime/formTitle/formStatus,但因为 openEditTrace 在打开弹窗前已经把目标节点的当前值回填到这三个状态,所以弹窗显示的是"已填充当前值"的输入框,用户基于现状修改。这种"状态共享 + 上下文区分"的弹窗复用模式,比每个场景写一个独立弹窗更省代码。
17.4 panelDel 删除确认弹窗
/** 删除确认弹窗(铃声 / 配送节点双目标) */
@Builder
panelDel(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('删除确认')
.fontSize(15)
.fontColor(COLORS.title)
.fontWeight(FontWeight.Bold)
Text(this.delKind === 'ring'
? '将删除该铃声条目,并同步清理沙箱中的音频文件。'
: '将从配送时间轴中移除该节点,删除后不可恢复。')
.fontSize(10)
.fontColor(COLORS.sub)
.width('100%')
Row({ space: 10 }) {
Text('取消')
.fontSize(12)
.fontColor(COLORS.sub)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 })
.backgroundColor(COLORS.chip)
.borderRadius(9)
.onClick(() => onClose())
Text('确认删除')
.fontSize(12)
.fontColor(COLORS.white)
.fontWeight(FontWeight.Bold)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 })
.backgroundColor(COLORS.red)
.borderRadius(9)
.onClick(() => {
if (this.delKind === 'ring') {
this.delRing();
} else {
this.removeTrace();
}
})
}
.width('100%')
}
.width('78%')
.padding(16)
.backgroundColor(COLORS.card)
.borderRadius(14)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
}
panelDel 是删除确认弹窗,最巧妙的设计是用 delKind 一个状态变量服务两种删除场景。提示文案根据 delKind === 'ring' 在"将删除该铃声条目,并同步清理沙箱中的音频文件"和"将从配送时间轴中移除该节点,删除后不可恢复"之间切换——文案的差异帮助用户理解删除的后果。点击"确认删除"时再次检查 delKind:如果是 'ring' 调用 delRing,否则调用 removeTrace。删除按钮是红色 COLORS.red,符合"危险操作"的颜色约定。一个弹窗服务两种删除场景,比写两个独立弹窗节省了一半的代码,这种"状态参数化弹窗"是工程化的高阶技巧。
十八、技术特性对比总结
| 特性维度 | 本应用实现方案 | 传统移动端方案 | 差异化价值 |
|---|---|---|---|
| 通知铃声定制 | Notification Kit 的 sound 字段填 uri:: 前缀的沙箱路径,运行时动态生成 WAV 写入 EL1 沙箱 |
使用系统预设音效或打包到资源目录的固定音频 | 每条通知可携带不同铃声,铃声可程序化生成、按需更新,无需发版 |
| 音频合成 | buildWavBytes 函数用 DataView 手动构造 44 字节 WAV 头 + 16bit 单声道 PCM 数据,支持攻击包络和衰减包络 |
引入第三方音频库或预生成音频素材 | 零依赖、可程序化、体积小,频率和时长任意组合 |
| 沙箱存储 | contextConstant.AreaMode.EL1 设备级加密沙箱,fs.openSync 同步写入 |
应用缓存目录或外部存储 | 加密沙箱安全可控,Notification Kit 可直接读取 |
| 数据可视化 | Canvas + CanvasRenderingContext2D,drawPieChart 手动绘制扇形 + 中心镂空 + 呼吸文案 |
引入图表库(如 MPAndroidChart)或 SVG | 零依赖、可控性高、性能好,呼吸动画自然 |
| 列表渲染 | ForEach + @Observed 类 + @State 数组,数据增删改自动刷新 |
RecyclerView/ListView + Adapter 手动通知 |
声明式自动 diff,代码量大幅减少 |
| 弹窗系统 | modalOverlay 共享遮罩 + panelAdd/panelEdit/panelDel 三态弹窗,delKind 参数化删除目标 |
每种弹窗独立实现,或引入第三方 Dialog 库 | 共享遮罩复用,状态参数化弹窗,代码量减半 |
| 状态管理 | @State 单组件状态 + @Observed 可观察类 + aboutToAppear/aboutToDisappear 生命周期 |
ViewModel + LiveData 或 MVP/MVC | 状态与视图自动绑定,无需手动刷新 |
| 呼吸动画 | setInterval 1 秒翻转 breath 布尔值,联动头部透明度 + 饼图中心文案 + Canvas 重绘 |
ValueAnimator 或 Timer + 手动 invalidate |
单一状态驱动多处视觉,极低实现成本 |
| 通知授权 | requestEnableNotification + openNotificationSettings 二次授权策略 |
单次 requestPermissions |
拒绝过也能拉起设置页二次引导,最大化授权率 |
| 主题色管理 | ColorPalette 接口 + COLORS 常量,三梯度色系(主色 + 深浅)+ 语义化命名 |
XML 资源文件或硬编码色值 | 集中管理、类型安全、易切换主题 |
| 数据模型 | @Observed export class + 构造函数 + 种子数据常量数组 |
data class + 初始化函数 | 模型与数据分离,便于接入真实接口 |
| 底部 Tab | 单排 4 个 Emoji 图标 + 文字,currentTab 状态驱动条件渲染 |
BottomNavigationView + Menu 资源 |
零资源依赖,Emoji 自带情感色彩 |
十九、总结与延伸思考

19.1 单文件页面的工程价值
本应用以一个结构体 Page1110 承载了完整的生鲜电商配送平台功能——四个 Tab、通知铃声、Canvas 饼图、三态弹窗、增删改查、呼吸动画——所有逻辑都收拢在单一文件内。这种"单文件页面"的写法在鸿蒙 ArkUI 中并非工程规范的强制要求,但它对于技术演示、教学分享、能力验证场景具有独特价值:开发者可以一次性读懂从状态声明到视图渲染的全链路,无需在多文件间跳转。对于真实业务项目,建议把数据模型、工具函数、Builder 视图、业务方法拆分到不同文件,通过 export/import 组合,但本应用提供的"单文件心智模型"是理解 ArkUI 工作原理的最佳起点。
19.2 Notification Kit 沙箱铃声的行业意义
sound 字段支持沙箱路径是 HarmonyOS 6.1.1 的重要特性,它把通知铃声从"系统预设"时代带入"应用可编程"时代。对于生鲜电商这种"通知即业务"的场景,意义尤为重大——门铃叮咚代表到货、号角代表满减、锣声代表开餐,用户无需看屏幕就能辨识通知类型。这种"听觉差异化"在嘈杂环境(厨房、户外、通勤)中比视觉通知更有效。结合 buildWavBytes 的程序化合成能力,应用甚至可以根据订单金额、配送距离、商品品类动态生成专属铃声,让通知成为品牌识别的一部分。这是传统移动平台很难实现的能力,鸿蒙在这方面的开放度值得称道。
19.3 Canvas 绘制的可控性优势
drawPieChart 用不到 30 行代码实现了环形饼图 + 中心镂空 + 呼吸文案切换,没有引入任何图表库。这种"纯 Canvas 手绘"的优势在于:第一,零依赖,包体积不增加;第二,完全可控,每个扇区的角度、颜色、半径都可以精确调整;第三,性能优秀,Canvas 直接调用底层图形 API,没有图表库的中间层开销;第四,可动画化,配合定时器重绘即可实现呼吸、过渡、生长等动效。劣势是开发成本相对较高——但对于这种"5 个扇区 + 中心文案"的简单场景,手绘的投入产出比远超引入图表库。这种"按需选择技术方案"的判断力,是资深开发者与初学者的分水岭。
19.4 状态驱动 UI 的哲学
本应用最值得体会的设计哲学是"状态即真理"。所有可变数据都以 @State 或 @Observed 承载,UI 通过 ForEach 和条件渲染绑定状态。开发者只需关心"状态如何变化",框架自动完成"UI 如何更新"——新增节点 push 到 traceList,时间轴自动追加行;发布通知 noticeCount++,头部小卡自动刷新;切换 Tab currentTab = idx,对应 Tab 内容自动渲染。这种模式把开发者从命令式的 setText、notifyDataSetChanged、invalidate 中解放出来,代码量大幅减少,bug 率显著降低。鸿蒙 ArkUI 的这套状态管理机制,与现代前端框架(React Hooks、Vue Composition API、SwiftUI)的思路一脉相承,是移动端 UI 开发的演进方向。
19.5 工程化建议与延伸方向
如果把本应用作为起点继续延伸,可以从以下几个方向扩展:第一,把 SEASON_LIST、TRACE_LIST 等种子数据替换为后端接口返回,引入 @kit.NetworkKit 发起 HTTP 请求,配合 @kit.DataLoadingKit 做缓存;第二,把 buildWavBytes 升级为支持多频率叠加(和弦)、包络曲线可配置(ADSR)的更复杂合成器,甚至引入 @kit.AudioKit 实时播放预览;第三,把单 Canvas 饼图扩展为多图表仪表盘,增加柱状图、折线图、面积图,引入手势交互(点击扇区高亮、双指缩放);第四,把弹窗系统升级为 @kit.ModalKit 的全屏模态页或半屏 Sheet,承载更复杂的表单;第五,引入 @kit.WearableKit 把配送通知推送到手表,让骑手和用户在手腕上即可接收铃声提醒。生鲜电商配送是一个足够大的业务舞台,ArkUI 与鸿蒙 Kit 提供的能力足够丰富,本应用演示的只是冰山一角,期待开发者们在这个基础上生长出更多创新。
19.6 结语

技术能力的真正价值不在能力本身,而在它如何服务于具体的业务场景与人。本应用把 Notification Kit 的沙箱铃声、Canvas 的环形饼图、ArkUI 的状态驱动、@Observed 的数据可观察这些"分散的能力点"编织成"鲜达"这样一个具体的生鲜配送平台,让每一项技术都能回答一个用户问题:铃声可编程让用户"听得懂"通知、饼图让用户"看得见"消费结构、时间轴让用户"跟得上"配送进度、呼吸动画让用户"感觉到"页面是活的。这种从"技术清单"到"业务叙事"的转化能力,才是工程师真正应该追求的境界。希望这篇详尽的代码剖析,能帮助你不仅读懂"怎么写",更读懂"为什么这么写",进而在自己的业务场景中,写出有灵魂的鸿蒙原生应用。
附录: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)