移动端音效/BGM 素材市场正经历从"野蛮下载"到"合规溯源"的关键转型。游戏开发者、影视后期团队、独立创作者在采购音效素材时,面临的最大痛点是:下载了一个 wav 音效包,却说不清它来自哪个素材站的哪个详情页——商用授权审计时无从追溯,版权纠纷一旦发生便缺乏证据链。HarmonyOS 6.1.1 在 ArkWeb 框架的 WebDownloadDelegate 下载代理中新增了 getOriginalUrl()getReferrerUrl() 双 URL 接口,为这一行业痛点提供了系统级解决方案。
在这里插入图片描述

HarmonyOS ArkUI 是华为为鸿蒙生态打造的声明式 UI 开发框架,基于 ArkTS 语言(TypeScript 的超集),采用 @Entry@Component@State@Builder 等装饰器构建组件化页面结构。ArkUI 的核心优势在于声明式渲染——开发者只需描述 UI 的"当前状态",框架自动处理状态变化后的视图更新,无需手动操作 DOM 或等价节点。在本项目中,4 个 Tab 页面的切换、下载进度的实时刷新、柱状图的呼吸动画,全部依赖 ArkUI 的状态驱动机制实现,代码量远低于命令式框架的同类实现。

在这里插入图片描述
ArkWeb 是 HarmonyOS 内置的 Web 组件引擎,底层基于 Chromium 内核,提供 Web 组件嵌入网页、WebviewController 控制页面加载/导航、WebDownloadDelegate 代理下载行为三大核心能力。WebDownloadDelegate 定义了四个回调接口:onBeforeDownload(下载开始前,必须调用 item.start() 提供沙箱保存路径)、onDownloadUpdated(下载进行中,可获取进度百分比)、onDownloadFailed(下载失败)、onDownloadFinish(下载完成)。在 HarmonyOS 6.1.1 之前,下载完成回调仅能拿到文件名、文件大小等基本信息,无法追溯下载来源;6.1.1 版本在 onDownloadFinish 回调的 WebDownloadItem 对象上新增了 getOriginalUrl()getReferrerUrl() 两个方法,分别返回下载项的原始 URL 地址(素材站 CDN 直链)和引用页 URL 地址(触发下载的音效包详情页),由此构建出完整的下载溯源链路。

在这里插入图片描述
Canvas 绘图能力是 ArkUI 的另一项重要特性。通过 Canvas 组件绑定 CanvasRenderingContext2D 上下文,开发者可以使用 fillRectmoveTolineTostrokefillText 等 2D Canvas API 在应用内绘制自定义图表。本项目利用 Canvas 绘制音效分类下载分布柱状图,并通过 setInterval 定时器驱动呼吸动画——每秒翻转 breath 布尔状态,柱高在 ±5% 区间波动重绘,营造出"音效在跳动"的视觉隐喻,与录音棚主题高度契合。

在这里插入图片描述
在音效素材市场的业务场景下,本应用设计了 4 个功能 Tab:音效 Tab 展示今日推荐音效包、热门音效榜和分类下载柱状图;网页 Tab 内嵌 ArkWeb 组件浏览真实音效素材站(freesound.org、pixabay 等),支持地址栏输入、快捷站点跳转和应用侧主动下载;下载 Tab 展示下载进度、完成回调代码预览和历史下载记录的双 URL 溯源信息;我的 Tab 展示声音设计师资料卡和功能清单。深色录音棚主题(#121212 黑 + #3DDC84 声波绿 + #FFB300 琥珀橙)贯穿全局,弹窗系统采用全屏遮罩 + 居中面板的模式实现新建下载、编辑备注、删除确认三种交互。

在这里插入图片描述
本文将从色彩系统、常量定义、辅助函数、数据模型、组件主体、ArkWeb 下载代理、Canvas 柱状图、UI 构建、弹窗系统等维度逐段剖析完整源码,帮助开发者深入理解 HarmonyOS ArkUI 的状态管理、ArkWeb 下载溯源机制和 Canvas 图表绘制的工程实践。

在这里插入图片描述

二、整体架构流程图

0

1

2

3

应用入口 @Entry Page1126

aboutToAppear 生命周期

setupDownloadDelegate 注册下载代理

onBeforeDownload 调用 start 提供沙箱路径

onDownloadUpdated 刷新进度条

onDownloadFailed 置失败状态

onDownloadFinish ★6.1.1双URL溯源

getOriginalUrl 原始URL直链

getReferrerUrl 引用页URL

setInterval 启动呼吸动画定时器

breath 状态翻转 → drawBarChart 重绘

build 主构建 Stack

headerMain 头部

声波绿渐变 Banner

搜索条 + 新建下载按钮

分类 chips 横滑

Scroll 内容区

currentTab 判断

tabSound 音效Tab

recBanner 今日推荐音效包

rankCard 热门音效榜

chartCard Canvas柱状图

tabWeb 网页Tab

地址栏 TextInput + 前往

快捷站点横滑

Web组件 嵌入网页

主动下载演示行

tabDownload 下载Tab

进行中任务卡 Progress

onDownloadFinish 代码预览

历史记录双URL溯源列表

tabMine 我的Tab

声音设计师渐变大卡

功能清单行

tabBar 底部4Tab导航

弹窗层 Stack叠加

panelAdd 新建下载

panelEdit 编辑备注

panelDel 删除确认


三、色彩系统与常量定义

在这里插入图片描述

3.1 深色主题色板接口与常量

音效屋应用采用录音棚风格的深色主题,所有颜色集中管理在一个接口和一个常量对象中,确保主题的一致性和可维护性。

/** 主题色板接口:集中声明页面所有颜色字段(录音棚黑+声波绿+琥珀橙深色系) */
interface ColorPalette {
  bg: string;
  card: string;
  chip: string;
  title: string;
  sub: string;
  text3: string;
  green: string;
  greenD: string;
  amber: string;
  red: string;
  line: string;
  tabOn: string;
  mask: string;
  codeBg: string;
}

/** 深色主题色板常量(音效屋 · 录音棚黑 + 声波绿 + 琥珀橙) */
const COLORS: ColorPalette = {
  bg: '#121212',
  card: '#1E1E1E',
  chip: '#2A2A2A',
  title: '#F5F5F5',
  sub: '#B8B8B8',
  text3: '#8A8A8A',
  green: '#3DDC84',
  greenD: '#0E7A44',
  amber: '#FFB300',
  red: '#FF6B7E',
  line: '#333333',
  tabOn: '#3DDC84',
  mask: 'rgba(0,0,0,0.68)',
  codeBg: '#0A140E'
};

首先定义了 ColorPalette 接口,它声明了页面中所有可能用到的颜色字段。将颜色集中到接口中是一种防御性设计——任何遗漏的颜色字段在编译期就会被 TypeScript 类型检查捕获,避免在代码中散落硬编码的色值字符串。接口包含了 14 个字段:背景色 bg、卡片背景 card、芯片背景 chip、标题文字 title、副标题 sub、三级文字 text3、声波绿 green 及其深色变体 greenD、琥珀橙 amber、警告红 red、分割线 line、Tab 选中色 tabOn、遮罩色 mask 和代码块背景 codeBg

随后用 const COLORS: ColorPalette 实例化了这个接口。值得注意的是 #121212 是 Material Design 推荐的深色主题背景色,纯黑(#000000)在 OLED 屏幕上会因对比度过强导致视觉疲劳,而 #121212 保留了极轻微的灰度,在保持暗色氛围的同时降低了眼睛负担。#3DDC84 是 Android 12 Material You 的绿色强调色,饱和度适中且在深色背景上具有高对比度;#FFB300 琥珀橙作为辅助强调色,与绿色形成互补色关系,在柱状图、渐变 Banner 等场景中产生视觉张力。codeBg 使用了 #0A140E——一种极深的暗绿色,暗示代码与"声波绿"主题的关联。

3.2 Tab 元数据与分类常量

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

/** 底部导航 Tab 常量列表(4 Tab 单排) */
const TAB_LIST: TabMeta[] = [
  { icon: '🎵', label: '音效' },
  { icon: '🌐', label: '网页' },
  { icon: '⬇', label: '下载' },
  { icon: '👤', label: '我的' }
];

/** 头部横滑分类 chips 文案(音效分类 8 类) */
const CATE_TAGS: string[] = ['转场', '按钮', '提示', '环境', '脚步', '战斗', '水声', '鸟鸣'];

/** 快捷站点常量(网页 Tab 横滑入口,真实可访问的音效/BGM 素材站点) */
const QUICK_SITES: string[] = [
  'https://freesound.org',
  'https://pixabay.com/sound-effects/',
  'https://incompetech.com',
  'https://www.bensound.com'
];

TabMeta 接口定义了底部导航 Tab 的图标和标签两个字段。TAB_LIST 常量数组包含 4 个 Tab 条目:音效(🎵)、网页(🌐)、下载(⬇)、我的(👤)。使用 emoji 作为图标是一种轻量级方案——无需引入图标字体库或 SVG 资源,且在所有设备上渲染一致。CATE_TAGS 定义了音效分类的 8 个类别标签,用于头部横滑 chips 区域,涵盖了游戏开发和影视后期常用的音效类型。

QUICK_SITES 数组列出了 4 个真实可访问的音效/BGM 素材站点。freesound.org 是全球最大的协作音效库,pixabay 提供 CC0 免费音效,incompetech 以 Kevin MacLeod 的 BGM 闻名,bensound 则是广受欢迎的免费背景音乐站。这些真实 URL 确保了 ArkWeb 组件加载的页面是可访问的,开发者运行 Demo 时能直接看到 Web 组件的真实渲染效果。

3.3 柱状图数据模型

/** 柱状图分类数据点接口(Canvas 音效分类下载分布图) */
interface BarPoint {
  val: number;    // 下载量(万次)
  color: string;  // 柱色(声波绿/琥珀橙交替,引用 COLORS)
  label: string;  // 分类名
}

/** 音效分类下载分布 Mock 数据(8 类,柱色按分类交替声波绿/琥珀橙) */
const BAR_DATA: BarPoint[] = [
  { val: 32, color: COLORS.green, label: '转场' },
  { val: 26, color: COLORS.amber, label: '按钮' },
  { val: 18, color: COLORS.green, label: '提示' },
  { val: 24, color: COLORS.amber, label: '环境' },
  { val: 15, color: COLORS.green, label: '脚步' },
  { val: 28, color: COLORS.amber, label: '战斗' },
  { val: 20, color: COLORS.green, label: '水声' },
  { val: 12, color: COLORS.amber, label: '鸟鸣' }
];

/** 柱状图顶部汇总文案 */
const BAR_TITLE: string = '分类下载分布';

BarPoint 接口定义了柱状图每个数据点的三个属性:数值 val(下载量,单位万次)、颜色 color、标签 labelBAR_DATA 数组包含 8 个数据点,与 CATE_TAGS 的 8 个分类一一对应。柱色采用声波绿和琥珀橙交替排列——奇数索引用绿色,偶数索引用橙色,这种交替配色在柱状图中既能区分相邻柱子,又保持了主题色的统一性。color 字段直接引用 COLORS.greenCOLORS.amber 常量,确保与全局主题一致。BAR_TITLE 定义了图表卡片的标题文案。


四、辅助函数

/** 榜单趋势颜色映射:上升红 / 下降绿 / 持平弱化 */
function trendColor(t: string): string {
  if (t === 'up') { return COLORS.red; }
  if (t === 'down') { return COLORS.green; }
  return COLORS.text3;
}

/** 榜单趋势图标文案:上升 / 下降 / 持平 */
function trendIcon(t: string): string {
  if (t === 'up') { return '↑ 上升'; }
  if (t === 'down') { return '↓ 下降'; }
  return '— 持平';
}

/** 站点 URL 转展示域名(去掉协议前缀,地址栏/快捷站点卡用) */
function siteHost(url: string): string {
  const head = 'https://';
  if (url.startsWith(head)) {
    return url.slice(head.length);
  }
  return url;
}

三个辅助函数分别处理不同的展示需求。trendColor 函数将趋势字符串映射为颜色:上升用红色 COLORS.red 表示"热度攀升",下降用绿色 COLORS.green 表示"关注降温"——注意这里的颜色语义与直觉相反,在音效下载榜的语境中,"下降"意味着该音效包热度在减退,用冷色系绿色表示降温是合理的。

trendIcon 函数将趋势字符串映射为带箭头的文案标签,在热门音效榜中与颜色配合使用,提供文字+符号的双重视觉指示。

siteHost 函数用于在快捷站点卡片中展示去掉 https:// 前缀的域名,让卡片文案更简洁。例如 https://freesound.org 会显示为 freesound.org。函数仅处理 https:// 前缀(因为 QUICK_SITES 中所有 URL 都使用 https),对于不含该前缀的 URL 则原样返回。这种简单的字符串处理避免了引入 URL 解析库的开销。


五、数据模型层

5.1 音效榜单条目 SoundItem

/** 音效榜单条目(音效 Tab 大编号热门音效榜) */
@Observed export class SoundItem {
  rank: string;    // 名次字符串
  name: string;    // 音效包名
  author: string;  // 声音设计师 / 音频工作室
  fmt: string;     // 音频格式(WAV/MP3)
  dls: string;     // 本周下载量文本
  trend: string;   // 趋势:'up' / 'down' / 'flat'

  constructor(rank: string, name: string, author: string, fmt: string, dls: string, trend: string) {
    this.rank = rank;
    this.name = name;
    this.author = name;
    this.fmt = fmt;
    this.dls = dls;
    this.trend = trend;
  }
}

/** 热门音效榜 Mock 数据(8 条,含格式/下载量/趋势) */
const SOUND_LIST: Array<SoundItem> = [
  new SoundItem('1', '史诗战斗配乐合集', '雷霆音频', 'WAV', '8.2万', 'up'),
  new SoundItem('2', '电影级转场音效包', '幻听工作室', 'WAV', '7.6万', 'up'),
  new SoundItem('3', '复古街机按钮音', '像素声库', 'MP3', '6.9万', 'flat'),
  new SoundItem('4', '清晨森林环境声', '自然采样局', 'WAV', '6.1万', 'up'),
  new SoundItem('5', '石板路脚步声', '踏浪文化', 'WAV', '5.4万', 'down'),
  new SoundItem('6', '深海水声纹理', '潜音社', 'WAV', '4.8万', 'up'),
  new SoundItem('7', '丛林鸟鸣精选', '山雀录音棚', 'MP3', '4.2万', 'down'),
  new SoundItem('8', '消息提示音合集', '叮咚实验室', 'MP3', '3.9万', 'flat')
];

SoundItem 类使用了 @Observed 装饰器,这是 ArkUI 状态管理框架的关键装饰器之一。@Observed 修饰的类,其实例在作为 @State@Prop@ObjectLink 等状态变量使用时,属性变化会被框架自动追踪并触发视图更新。SoundItem 包含 6 个字段:排名 rank(字符串类型,便于前 3 名加粗显示)、音效包名 name、作者 author、音频格式 fmt(WAV 或 MP3,反映音质等级)、下载量文本 dls、趋势 trend

SOUND_LIST 常量数组提供了 8 条 Mock 数据,模拟了一个真实的音效下载排行榜。数据涵盖了游戏、影视、UI 交互等多种音效类型,每条数据都有不同的趋势状态(up/down/flat),便于在 UI 中展示趋势变化。WAV 格式代表无损高采样率音效,MP3 代表压缩格式,这种区分让榜单信息更有层次。

5.2 音效包合集条目 PackItem

/** 音效包合集条目(音效 Tab 今日推荐音效包卡) */
@Observed export class PackItem {
  icon: string;     // 音效包 emoji 图标
  name: string;     // 音效包名
  count: string;    // 音效条数文本(如"40 条")
  duration: string; // 合集总时长(如 12:36)
  spec: string;     // 采样率规格(如 48kHz/24bit)

  constructor(icon: string, name: string, count: string, duration: string, spec: string) {
    this.icon = icon;
    this.name = name;
    this.count = count;
    this.duration = duration;
    this.spec = spec;
  }
}

/** 今日推荐音效包 Mock 数据(3 条:包名+条数+时长+采样率) */
const TODAY_PACKS: Array<PackItem> = [
  new PackItem('🎬', '电影级转场音效', '40 条', '12:36', '48kHz/24bit WAV'),
  new PackItem('🕹', '复古街机按钮音', '24 条', '03:48', '44.1kHz/16bit WAV'),
  new PackItem('🌅', '清晨森林环境声', '36 条', '52:10', '48kHz/24bit WAV')
];

PackItem 同样使用 @Observed 装饰器,代表"今日推荐音效包"的数据模型。5 个字段中,icon 使用 emoji 表示音效包的类型(电影转场用 🎬、街机按钮用 🕹、森林环境用 🌅),count 表示包内音效条数,duration 表示合集总时长,spec 表示采样率规格——48kHz/24bit 是专业录音棚标准,44.1kHz/16bit 是 CD 标准,这种标注让专业用户一眼判断音效品质。

TODAY_PACKS 提供了 3 条推荐数据,每条都包含了完整的音频技术规格信息,体现了音效素材市场对专业音频参数的重视。

5.3 下载记录条目 DownloadRecord(核心数据载体)

/** 下载记录条目(下载 Tab 双 URL 溯源卡:ArkWeb 6.1.1 特性数据载体) */
@Observed export class DownloadRecord {
  fileName: string;     // 文件名(wav/zip 音效素材)
  fileSize: string;     // 大小文本
  finishTime: string;   // 完成时间
  originalUrl: string;  // ★ getOriginalUrl() 结果:下载项原始 URL(素材站直链)
  referrerUrl: string;  // ★ getReferrerUrl() 结果:引用页 URL(音效包详情页)
  note: string;         // 用户备注(可编辑)

  constructor(fileName: string, fileSize: string, finishTime: string,
    originalUrl: string, referrerUrl: string, note: string) {
    this.fileName = fileName;
    this.fileSize = fileSize;
    this.finishTime = finishTime;
    this.originalUrl = originalUrl;
    this.referrerUrl = referrerUrl;
    this.note = note;
  }
}

/** 历史下载记录 Mock 数据(6 条,双 URL 均为带域名/路径/参数的完整地址) */
const DOWNLOAD_RECORDS: Array<DownloadRecord> = [
  new DownloadRecord('ForestAmbience_48k24b.wav', '38.6 MB', '今天 09:42',
    'https://cdn.yinxiaowu.cn/sfx/env/ForestAmbience_48k24b.wav',
    'https://www.yinxiaowu.cn/pack/detail?id=2016&tab=env', '自然采样局直链'),
  new DownloadRecord('ArcadeUI_Buttons_v2.zip', '12.4 MB', '今天 08:15',
    'https://cdn.yinxiaowu.cn/sfx/ui/ArcadeUI_Buttons_v2.zip',
    'https://www.yinxiaowu.cn/rank/week?tab=ui', '编辑精选推荐'),
  new DownloadRecord('Battle_Epic_v3.wav', '96.2 MB', '昨天 21:36',
    'https://mirror.leiting-audio.cn/battle/Battle_Epic_v3_stems.wav',
    'https://leiting-audio.cn/battle/detail?id=88&ref=share', '战斗场景配乐'),
  new DownloadRecord('Transition_Cinema_Pro.zip', '58.0 MB', '昨天 19:04',
    'https://cdn.huanting.studio/trans/Transition_Cinema_Pro.zip',
    'https://huanting.studio/search?q=电影转场', '搜索下载'),
  new DownloadRecord('BirdCalls_Jungle.mp3', '26.7 MB', '昨天 12:48',
    'https://dl.shanque.cn/bird/BirdCalls_Jungle_320k.mp3',
    'https://www.shanque.cn/free2026/bird', '限时免费专区'),
  new DownloadRecord('WaterDeep_Texture.wav', '44.3 MB', '08-23 10:22',
    'https://down.qianyin.cn/water/WaterDeep_Texture_48k.wav',
    'https://qianyin.cn/water/detail?id=512&ref=rank', '水声纹理素材')
];

DownloadRecord 是整个应用最核心的数据模型,它承载了 HarmonyOS 6.1.1 ArkWeb 双 URL 溯源特性的完整信息。6 个字段中,fileNamefileSizefinishTime 是常规下载信息,而 originalUrlreferrerUrl 则是 6.1.1 新增双接口的直接映射——originalUrl 存储 getOriginalUrl() 的返回值(下载项的原始 URL,即素材站 CDN 直链),referrerUrl 存储 getReferrerUrl() 的返回值(引用页 URL,即触发下载的音效包详情页地址)。note 字段允许用户手动编辑备注,记录下载用途和授权状态。

DOWNLOAD_RECORDS Mock 数据提供了 6 条历史记录,每条都包含了带域名、路径、查询参数的完整 URL。这些 URL 模拟了真实的下载场景:originalUrl 指向 CDN 直链(如 cdn.yinxiaowu.cnmirror.leiting-audio.cn),referrerUrl 指向详情页或搜索页(如 www.yinxiaowu.cn/pack/detail?id=2016&tab=env)。注意 referrer URL 中的查询参数(?id=2016&tab=env?ref=share?ref=rank)携带了重要的溯源信息——通过这些参数可以精确还原用户是从哪个详情页、哪个排行榜、哪次分享触发的下载,这正是商用授权审计的核心证据链。

5.4 用户功能清单条目 UserStat

/** 我的页功能清单条目 */
@Observed export class UserStat {
  icon: string;    // 功能图标
  label: string;   // 功能名
  value: string;   // 状态/数值文本
  arrow: boolean;  // 是否显示右箭头

  constructor(icon: string, label: string, value: string, arrow: boolean) {
    this.icon = icon;
    this.label = label;
    this.value = value;
    this.arrow = arrow;
  }
}

/** 我的页功能清单 Mock 数据(8 条) */
const STAT_LIST: Array<UserStat> = [
  new UserStat('⬇', '累计下载', '186 个音效包', true),
  new UserStat('🔗', '来源审计', '双 URL 溯源已开启', true),
  new UserStat('📁', '素材目录', '/data/storage/files', true),
  new UserStat('🎚', '采样率偏好', '48kHz / 24bit', true),
  new UserStat('🕘', '下载历史', '近 30 天 42 条', true),
  new UserStat('🔔', '音效包更新提醒', '每周五 10:00', true),
  new UserStat('🛡', '商用授权检查', '已确认 12 个商用包', true),
  new UserStat('⚙', '下载偏好设置', '仅 Wi-Fi 下载', true)
];

UserStat 是"我的"页面功能清单的数据模型,4 个字段中 arrow 控制是否在行尾显示右箭头 ,表示该行可点击进入。STAT_LIST 提供了 8 条功能数据,其中"来源审计:双 URL 溯源已开启"和"商用授权检查:已确认 12 个商用包"两条直接呼应了应用的核心溯源主题,让用户在"我的"页面也能感知到下载溯源功能的存在和工作状态。


六、组件主体与状态管理

6.1 组件声明与 State 变量

/** 1126 音效屋 · 音效素材库主页面 */
@Entry
@Component
struct Page1126 {
  /** 当前选中 Tab 索引 */
  @State currentTab: number = 0;
  /** 呼吸动画开关(每秒翻转,联动柱状图柱高 ±5% 波动) */
  @State breath: boolean = false;
  /** 呼吸动画定时器句柄 */
  @State timer: number = -1;
  /** 头部分类 chips 选中索引 */
  @State cateIdx: number = 0;
  /** 新建下载任务弹窗开关 */
  @State addModal: boolean = false;
  /** 编辑备注弹窗开关 */
  @State editModal: boolean = false;
  /** 删除记录确认弹窗开关 */
  @State delModal: boolean = false;
  /** 当前编辑的记录索引 */
  @State editIdx: number = 0;
  /** 当前删除的记录索引 */
  @State delIdx: number = 0;
  /** 热门音效榜数据 */
  @State soundList: Array<SoundItem> = SOUND_LIST;
  /** 今日推荐音效包数据 */
  @State packList: Array<PackItem> = TODAY_PACKS;
  /** 下载记录列表数据 */
  @State downloadRecords: Array<DownloadRecord> = DOWNLOAD_RECORDS;
  /** 我的页功能清单数据 */
  @State statList: Array<UserStat> = STAT_LIST;
  /** 新建表单:音效包下载链接 URL */
  @State formUrl: string = '';
  /** 新建表单:备注 */
  @State formNote: string = '';
  /** 编辑表单:记录备注 */
  @State editNote: string = '';

@Entry 装饰器标记 Page1126 为页面入口组件,@Component 声明它是一个自定义组件。struct 关键字定义了组件结构体——ArkUI 使用 struct 而非 class 来定义组件,这是 ArkTS 的语言特性。

@State 装饰器是 ArkUI 状态管理的核心。被 @State 修饰的变量,其值变化会自动触发组件的 build() 方法重新执行,从而更新视图。这里有 18 个 @State 变量,可分为几类:currentTab 控制 Tab 切换(0-3 对应音效/网页/下载/我的);breathtimer 服务于柱状图呼吸动画;cateIdx 控制分类 chips 选中态;addModaleditModaldelModal 三个布尔值控制三种弹窗的显示/隐藏;editIdxdelIdx 记录当前操作的记录索引;soundListpackListdownloadRecordsstatList 是四个列表数据源,直接赋值为前面定义的 Mock 常量;formUrlformNoteeditNote 是表单输入的受控状态。

值得注意的是,将 Mock 常量直接赋值给 @State 变量是一种常见模式——初始渲染时使用 Mock 数据,后续可通过状态修改来更新列表。@State 修饰的数组变量,当数组引用发生变化时(如 spliceslice 返回新数组)会触发视图更新;但如果只是修改数组元素的属性,需要借助 @Observed + @ObjectLink 或整体替换数组引用才能确保刷新。

6.2 Canvas 与 ArkWeb 状态

  // --- Canvas 状态(音效分类下载分布柱状图) ---
  /** 柱状图 Canvas 就绪标志 */
  @State canvasReady: boolean = false;
  /** 柱状图 Canvas 上下文(private,不用 @State) */
  private barCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));

  // --- ArkWeb 状态(6.1.1 特性:双 URL 溯源) ---
  /** Web 控制器(加载页面 + 绑定下载代理 + 主动发起下载) */
  private webController: webview.WebviewController = new webview.WebviewController();
  /** 下载代理(四个回调:开始前/进行中/失败/完成) */
  private downloadDelegate: webview.WebDownloadDelegate = new webview.WebDownloadDelegate();
  /** 地址栏输入值(敲字不等于加载) */
  @State urlInput: string = QUICK_SITES[0];
  /** Web 组件实际加载值(点"前往"后才更新) */
  @State webUrl: string = QUICK_SITES[0];
  /** 当前下载文件名 */
  @State dlName: string = '';
  /** 当前下载进度(0~100) */
  @State dlPercent: number = 0;
  /** 下载状态文案 */
  @State dlState: string = '空闲';

Canvas 相关状态中,canvasReady 标志位标记 Canvas 组件是否已准备就绪——只有在 Canvas.onReady 回调触发后才会设为 true,避免在 Canvas 未就绪时调用 drawBarChart() 导致空指针。barCtxCanvasRenderingContext2D 实例,使用 private 修饰而非 @State——因为 Canvas 上下文对象本身不参与视图渲染的状态驱动,它只是 Canvas 绑定的绘图引擎,赋值后引用固定不变。

ArkWeb 相关状态是本应用的技术核心。webControllerwebview.WebviewController 实例,负责控制 Web 组件的页面加载、导航和下载行为。downloadDelegatewebview.WebDownloadDelegate 实例,用于注册下载回调。两者都用 private 修饰,不参与状态驱动。

urlInputwebUrl 的分离设计值得注意——urlInput 绑定地址栏的 TextInput,用户输入时实时更新但不会触发网页加载;webUrl 绑定 Web 组件的 src 属性,只有在用户点击"前往"按钮时才更新。这种双状态分离避免了用户每输入一个字符就重新加载网页的问题,是 Web 浏览器地址栏的标准设计模式。dlNamedlPercentdlState 三个变量实时反映当前下载任务的状态,通过 @State 修饰确保下载进度条和状态文案实时刷新。

6.3 下载代理注册 setupDownloadDelegate(核心方法)

  /** 注册下载代理:四回调齐全,完成回调中调用 6.1.1 新增双接口 */
  setupDownloadDelegate() {
    // 下载开始前:必须调用 start() 提供沙箱路径,否则任务停在 PENDING
    this.downloadDelegate.onBeforeDownload((item: webview.WebDownloadItem) => {
      const hostCtx = this.getUIContext().getHostContext();
      const dir = hostCtx ? hostCtx.filesDir : '';
      item.start(dir + '/' + item.getSuggestedFileName());
    });
    // 下载进行中:刷新进度条与状态文案
    this.downloadDelegate.onDownloadUpdated((item: webview.WebDownloadItem) => {
      this.dlPercent = item.getPercentComplete();
      this.dlState = '正在下载 ' + item.getPercentComplete() + '%';
    });
    // 下载失败:置失败文案(进度清零)
    this.downloadDelegate.onDownloadFailed((item: webview.WebDownloadItem) => {
      this.dlState = '下载失败 · ' + item.getGuid();
      this.dlPercent = 0;
    });
    // 下载完成:★ 6.1.1 新特性——getOriginalUrl + getReferrerUrl 双溯源
    this.downloadDelegate.onDownloadFinish((item: webview.WebDownloadItem) => {
      const originalUrl: string = item.getOriginalUrl();   // 原始 URL 地址(素材站直链)
      const referrerUrl: string = item.getReferrerUrl();   // 引用页 URL 地址(音效包详情页)
      this.downloadRecords.unshift(new DownloadRecord(
        item.getSuggestedFileName(), Math.round(item.getTotalBytes() / 1048576) + ' MB',
        '刚刚', originalUrl, referrerUrl, '本次会话下载'));
      this.dlState = '下载完成';
      this.dlPercent = 100;
    });
    // 绑定到 controller:网页内触发的下载才会进入上述回调(try-catch 消除抛错告警)
    try {
      this.webController.setDownloadDelegate(this.downloadDelegate);
    } catch (error) {
      console.error(`ErrorCode: ${(error as BusinessError).code}, Message: ${(error as BusinessError).message}`);
    }
  }

setupDownloadDelegate() 是整个应用最关键的方法,它注册了 WebDownloadDelegate 的四个回调,实现了完整的下载生命周期管理。

第一个回调 onBeforeDownload 在下载开始前触发。这里有一个关键约束:必须调用 item.start(path) 方法并提供沙箱保存路径,否则下载任务会永远停留在 PENDING 状态。代码通过 this.getUIContext().getHostContext() 获取宿主上下文,再取 filesDir 作为沙箱目录,拼接 getSuggestedFileName()(下载项的推荐文件名)组成完整保存路径。getHostContext() 可能为 null,所以用三元运算符做兜底处理。

第二个回调 onDownloadUpdated 在下载进行中反复触发。每次触发时调用 item.getPercentComplete() 获取进度百分比,更新 dlPercentdlState 两个 @State 变量,驱动下载进度条和状态文案实时刷新。

第三个回调 onDownloadFailed 在下载失败时触发。将状态文案设为"下载失败"加 item.getGuid()(下载项唯一标识,便于排查),进度清零。

第四个回调 onDownloadFinish 是 HarmonyOS 6.1.1 的核心新特性所在。在这里调用了两个新增方法:item.getOriginalUrl() 返回下载项的原始 URL 地址(即素材站 CDN 直链,如 https://cdn.yinxiaowu.cn/sfx/env/ForestAmbience_48k24b.wav),item.getReferrerUrl() 返回引用页 URL 地址(即触发下载的音效包详情页,如 https://www.yinxiaowu.cn/pack/detail?id=2016&tab=env)。这两个 URL 构成了完整的下载溯源链——原始 URL 追踪"从哪下载",引用页 URL 记录"从哪个页面跳转下载"。随后用 unshift() 将新下载记录插入到 downloadRecords 数组头部(最新记录显示在最前面),文件大小通过 getTotalBytes() / 1048576 转换为 MB 单位。

最后通过 this.webController.setDownloadDelegate(this.downloadDelegate) 将下载代理绑定到 Web 控制器。这一步至关重要——只有绑定了代理,网页内点击下载链接才会触发上述四个回调。绑定操作用 try-catch 包裹,捕获可能的 BusinessError(如 Web 控制器尚未关联 Web 组件时),通过 console.error 打印错误码和消息,避免应用崩溃。

6.4 地址栏加载 loadUrl

  /** 地址栏"前往":校验协议前缀(无 http(s):// 时自动补 https://)再加载 */
  loadUrl() {
    let url = this.urlInput.trim();
    if (url === '') {
      return;
    }
    if (!url.startsWith('https://') && !url.startsWith('http://')) {
      url = 'https://' + url;
    }
    this.urlInput = url;
    this.webUrl = url;
  }

loadUrl() 方法处理地址栏"前往"按钮的点击逻辑。首先对输入值 trim() 去除首尾空格,空字符串直接返回不做任何操作。随后做协议前缀校验——如果用户输入的 URL 不以 https://http:// 开头,自动补全 https:// 前缀。这是 Web 浏览器地址栏的标准行为:用户输入 freesound.org 会被自动补全为 https://freesound.org

补全后同时更新 urlInput(让地址栏显示补全后的完整 URL)和 webUrl(触发 Web 组件重新加载新 URL)。这里体现了 urlInputwebUrl 双状态分离的价值——webUrl 的变化才会触发 Web 组件的 src 属性更新和页面重新加载。

6.5 应用侧主动下载 triggerDownload

  /** 应用侧主动发起下载(无需网页内点击,try-catch 包裹 BusinessError) */
  triggerDownload(url: string) {
    try {
      this.dlName = url.slice(url.lastIndexOf('/') + 1);
      this.dlPercent = 0;
      this.dlState = '已发起下载请求';
      this.webController.startDownload(url);
    } catch (error) {
      this.dlState = '发起失败 ' + (error as BusinessError).code;
    }
  }

triggerDownload(url) 方法实现了应用侧主动发起下载的能力。与"网页内点击下载链接"不同,startDownload(url) 允许应用直接传入一个 URL 发起下载,无需用户在网页内交互。这种方法适用于"已知直链直接下载"的场景——例如新建下载任务弹窗中用户粘贴了素材直链,或应用侧的演示按钮触发的下载。

方法首先从 URL 中提取文件名:url.slice(url.lastIndexOf('/') + 1) 取最后一个 / 之后的部分作为文件名显示。然后初始化下载状态,调用 this.webController.startDownload(url) 发起下载。下载结果会通过之前注册的 WebDownloadDelegate 四个回调返回。整个操作用 try-catch 包裹,捕获 BusinessError(如 URL 格式不合法、Web 控制器未就绪等),将错误码显示在状态文案中。


七、Canvas 柱状图绘制

7.1 drawBarChart 绘制方法

  /** 绘制音效分类下载分布柱状图(基线 moveTo/lineTo + fillRect 矩形柱 + 柱顶数值 + 底部标签) */
  drawBarChart() {
    const ctx = this.barCtx;
    const w = 330;        // 画布逻辑宽
    const h = 190;       // 画布逻辑高
    const baseY = 150;   // 柱底基线 Y
    const barW = 22;      // 柱宽
    const gap = (w - 20) / BAR_DATA.length;              // 柱间距
    const maxVal = 35;                                    // 数值上限(柱高换算基准)
    const wave = this.breath ? 1.05 : 0.95;               // 呼吸系数:柱高 ±5% 波动
    ctx.clearRect(0, 0, w, h);
    // 底部基线(moveTo/lineTo)
    ctx.strokeStyle = COLORS.line;
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(10, baseY);
    ctx.lineTo(w - 10, baseY);
    ctx.stroke();
    for (let i = 0; i < BAR_DATA.length; i++) {
      const d = BAR_DATA[i];
      const cx = 20 + gap * i + gap / 2;                  // 柱中心 X
      const barH = (d.val / maxVal) * 100 * wave;         // 柱高(呼吸波动)
      const top = baseY - barH;                           // 柱顶 Y
      // 矩形柱主体(fillRect,柱色按分类交替声波绿/琥珀橙)
      ctx.fillStyle = d.color;
      ctx.fillRect(cx - barW / 2, top, barW, barH);
      // 柱顶高光条(半透明,用后复位 globalAlpha)
      ctx.globalAlpha = 0.35;
      ctx.fillRect(cx - barW / 2, top, barW, 4);
      ctx.globalAlpha = 1;
      // 柱顶数值
      ctx.fillStyle = COLORS.title;
      ctx.font = '10px sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText(d.val + '万', cx, top - 6);
      // 底部分类标签
      ctx.fillStyle = COLORS.sub;
      ctx.font = '10px sans-serif';
      ctx.fillText(d.label, cx, baseY + 16);
    }
  }

drawBarChart() 方法使用 Canvas 2D API 绘制音效分类下载分布柱状图。方法首先获取 this.barCtx 上下文引用,定义画布逻辑尺寸(宽 330、高 190)、柱底基线 Y 坐标(150)、柱宽(22)。柱间距 gap 通过 (w - 20) / BAR_DATA.length 计算——画布宽度减去左右各 10 像素的内边距后均分给 8 个柱子。maxVal 设为 35 作为数值上限,用于柱高换算。

呼吸动画的核心是 wave 变量:this.breath 为 true 时取 1.05,为 false 时取 0.95,柱高因此产生 ±5% 的波动。由于 breath 状态每秒翻转一次(由 aboutToAppear 中的 setInterval 驱动),柱状图每秒重绘一次,柱子在 95%~105% 高度间交替跳动,形成"呼吸"效果。

绘制流程分为四个步骤。第一步 clearRect 清空整个画布,确保重绘不留残影。第二步绘制底部基线:设置描边色为 COLORS.line(#333333),线宽 1 像素,用 beginPath + moveTo(10, baseY) + lineTo(w-10, baseY) + stroke 绘制一条水平基线。

第三步循环遍历 BAR_DATA 的 8 个数据点,逐个绘制柱子。每个柱子的中心 X 坐标 cx 通过 20 + gap * i + gap / 2 计算——起点 20 加上第 i 个柱子的偏移再加上半个柱间距,使柱子均匀分布。柱高 barH 通过 (d.val / maxVal) * 100 * wave 计算:数值除以上限乘以基准高度 100,再乘以呼吸系数。柱顶 Y 坐标 topbaseY - barH

柱子主体用 fillRect(cx - barW/2, top, barW, barH) 绘制——以柱中心 X 减去半柱宽为左边界,柱顶 Y 为上边界,绘制宽度为 barW、高度为 barH 的矩形,填充色为 d.color(声波绿或琥珀橙)。柱顶高光条用半透明效果增强立体感:globalAlpha 设为 0.35 绘制柱顶 4 像素高的半透明白条,然后立即将 globalAlpha 复位为 1——这种"用后即复位"的模式避免了透明度状态泄漏到后续绘制操作。

第四步在柱顶上方 6 像素处绘制数值文本(如"32万"),在基线下方 16 像素处绘制分类标签(如"转场")。文本居中对齐(textAlign = 'center'),使用 10 像素 sans-serif 字体。


八、弹窗操作与生命周期

8.1 弹窗操作方法

  /** 打开编辑备注弹窗(回填当前记录备注) */
  openEditRecord(idx: number) {
    this.editIdx = idx;
    this.editNote = this.downloadRecords[idx].note;
    this.editModal = true;
  }

  /** 保存新建下载任务(空 URL 兜底默认演示直链,触发 startDownload) */
  saveDownload() {
    const url = this.formUrl === '' ? 'https://cdn.yinxiaowu.cn/sfx/env/ForestAmbience_48k24b.wav' : this.formUrl;
    this.triggerDownload(url);
    this.formUrl = '';
    this.formNote = '';
    this.addModal = false;
  }

  /** 保存编辑备注(整体刷新数组引用以刷新列表) */
  updateRecord() {
    if (this.editIdx >= 0 && this.editIdx < this.downloadRecords.length) {
      if (this.editNote !== '') {
        this.downloadRecords[this.editIdx].note = this.editNote;
      }
      this.downloadRecords = this.downloadRecords.slice();
    }
    this.editModal = false;
  }

  /** 删除下载记录(确认弹窗回调) */
  delRecord() {
    if (this.delIdx >= 0 && this.delIdx < this.downloadRecords.length) {
      this.downloadRecords.splice(this.delIdx, 1);
    }
    this.delModal = false;
  }

四个弹窗操作方法分别处理编辑备注、新建下载、保存备注和删除记录。

openEditRecord(idx) 在打开编辑弹窗前做"回填"操作——将当前记录的备注文本赋值给 editNote 状态变量,使弹窗的 TextInput 显示已有备注内容。这种回填模式让用户能基于已有备注修改,而非从零开始。

saveDownload() 处理新建下载任务。如果用户未输入 URL,使用默认演示直链 https://cdn.yinxiaowu.cn/sfx/env/ForestAmbience_48k24b.wav 作为兜底——确保即使用户不填 URL 也能触发下载演示。调用 triggerDownload(url) 后清空表单状态并关闭弹窗。

updateRecord() 保存编辑后的备注。这里有一个关键技巧:修改数组元素的 note 属性后,执行 this.downloadRecords = this.downloadRecords.slice()slice() 不传参数时返回数组的浅拷贝,赋值给自身相当于创建了一个新引用——@State 变量检测到引用变化后会触发视图刷新。这种"整体替换引用"的模式是 ArkUI 中更新数组元素属性的标准做法,因为直接修改元素属性不会触发 @State 的变更检测。

delRecord() 删除指定索引的记录,使用 splice(delIdx, 1) 从数组中移除一个元素。splice 方法会原地修改数组,同时改变 length 属性,ArkUI 的 @State 能检测到这种变化并触发刷新。

8.2 生命周期方法

  /** 生命周期:绑定下载代理 + 启动呼吸动画定时器(联动柱状图重绘) */
  aboutToAppear() {
    this.setupDownloadDelegate();
    this.timer = setInterval(() => {
      this.breath = !this.breath;
      if (this.canvasReady) {
        this.drawBarChart();
      }
    }, 1000);
  }

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

aboutToAppear() 是 ArkUI 组件的生命周期回调,在组件实例创建后、build() 执行前调用。这里完成两项初始化:第一,调用 setupDownloadDelegate() 注册下载代理,确保 Web 组件渲染后下载回调已就绪;第二,启动 setInterval 定时器,每 1000 毫秒(1 秒)翻转 breath 状态。定时器回调中先检查 canvasReady 标志——只有 Canvas 组件已就绪时才调用 drawBarChart() 重绘,避免在 Canvas 未初始化时操作上下文。定时器句柄保存到 timer 状态变量中,便于后续清理。

aboutToDisappear() 在组件销毁前调用,执行 clearInterval(this.timer) 清理定时器。这是内存管理的最佳实践——如果不清理,组件销毁后定时器仍在运行,持续翻转已不存在的组件状态,造成内存泄漏和潜在错误。


九、UI 构建与布局

9.1 主构建 build

  /** 页面主构建:Stack 包裹主内容与三层弹窗 */
  build() {
    Stack() {
      Column() {
        this.headerMain()
        Divider().strokeWidth(1).color(COLORS.line)
        Scroll() {
          Column() {
            if (this.currentTab === 0) {
              this.tabSound()
            } else if (this.currentTab === 1) {
              this.tabWeb()
            } else if (this.currentTab === 1) {
              this.tabWeb()
            } else if (this.currentTab === 2) {
              this.tabDownload()
            } 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 的子元素按声明顺序从底到顶堆叠,因此主内容 Column 在底层,三个弹窗面板在上层。

主内容 Column 自上而下排列:headerMain() 头部区域、Divider 分割线、Scroll 可滚动内容区、tabBar() 底部导航。Scroll 使用 layoutWeight(1) 占据剩余空间,scrollBar(BarState.Off) 隐藏滚动条保持界面简洁。

内容区通过 if-else 条件渲染四个 Tab 页面:currentTab === 0 显示音效 Tab,currentTab === 1 显示网页 Tab,currentTab === 2 显示下载 Tab,否则显示我的 Tab。ArkUI 的条件渲染在条件变化时自动创建/销毁组件,而非简单的显示/隐藏,确保非活跃 Tab 不占用渲染资源。

三个弹窗面板通过 if 条件渲染——只有对应的 @State 布尔值为 true 时才渲染弹窗,点击遮罩或取消按钮时调用传入的 onClose 回调将状态设为 false,弹窗自动销毁。这种"按需创建"的模式比"始终存在但 hidden"更节省内存。

9.2 头部 headerMain

  /** 头部:声波绿渐变 Banner(品牌 slogan + 本周上新数 + 下载入口)+ 搜索条 + 分类 chips */
  @Builder
  headerMain() {
    Column({ space: 12 }) {
      // 渐变 Banner
      Column({ space: 6 }) {
        Row() {
          Text('🎛 音效屋').fontSize(17).fontColor('#121212').fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('本周上新 128 条').fontSize(9).fontColor('rgba(18,18,18,0.72)')
        }
        .width('100%')
        Text('录音棚级音效素材 · 即下即用').fontSize(11).fontColor('rgba(18,18,18,0.88)')
        Text('48kHz/24bit 高采样 · ArkWeb 双 URL 溯源下载').fontSize(9).fontColor('rgba(18,18,18,0.58)')
      }
      .width('100%')
      .padding(14)
      .borderRadius(14)
      .linearGradient({ angle: 120, colors: [[COLORS.greenD, 0], [COLORS.green, 0.6], [COLORS.amber, 1]] })

      // 搜索条 + 新建下载按钮
      Row({ space: 8 }) {
        Row({ space: 6 }) {
          Text('🔍').fontSize(12)
          Text('搜索音效 / 粘贴素材直链').fontSize(10).fontColor(COLORS.text3)
        }
        .layoutWeight(1).height(34).padding({ left: 10, right: 10 })
        .backgroundColor(COLORS.chip).borderRadius(17)
        .onClick(() => {
          this.currentTab = 1;
        })
        Text('+ 新建下载').fontSize(10).fontColor('#121212')
          .padding({ left: 12, right: 12, top: 9, bottom: 9 })
          .backgroundColor(COLORS.green).borderRadius(17)
          .onClick(() => {
            this.addModal = true;
          })
      }
      .width('100%')

      // 分类 chips 横滑(音效 8 类)
      Scroll() {
        Row({ space: 8 }) {
          ForEach(CATE_TAGS, (tag: string, idx: number) => {
            Text(tag).fontSize(10)
              .fontColor(this.cateIdx === idx ? '#121212' : COLORS.sub)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor(this.cateIdx === idx ? COLORS.green : COLORS.chip)
              .borderRadius(13)
              .onClick(() => {
                this.cateIdx = idx;
              })
          }, (tag: string) => tag)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 12, bottom: 10 })
    .backgroundColor(COLORS.bg)
  }

headerMain() 使用 @Builder 装饰器声明为可复用的 UI 构建函数。@Builder 方法可以在 build() 中通过 this.xxx() 调用,将复杂 UI 拆分为独立单元,提升代码可读性和可维护性。

头部包含三个区域。渐变 Banner 使用 linearGradient 属性设置 120 度角的线性渐变,从 COLORS.greenD(深绿 #0E7A44)经 COLORS.green(声波绿 #3DDC84)到 COLORS.amber(琥珀橙 #FFB300),颜色数组中的第二个元素是停止位置(0 到 1)。Banner 内部文字使用深色(#121212),因为渐变背景是亮色,确保对比度。Banner 标注了"48kHz/24bit 高采样 · ArkWeb 双 URL 溯源下载",直接在品牌区展示核心技术卖点。

搜索条使用 layoutWeight(1) 自适应宽度,圆角 17 像素形成胶囊形。点击搜索条切换到网页 Tab(让用户在 Web 组件中搜索素材)。右侧"+ 新建下载"按钮使用声波绿背景,点击打开新建下载任务弹窗。

分类 chips 使用 Scroll + Row 实现横向滚动列表。ForEach 遍历 CATE_TAGS 8 个分类,每个 chip 根据是否选中(cateIdx === idx)切换文字颜色和背景色——选中态用声波绿背景+深色文字,未选中态用 chip 背景+灰色文字。点击更新 cateIdx 状态。scrollable(ScrollDirection.Horizontal) 设置横向滚动,scrollBar(BarState.Off) 隐藏滚动条。

9.3 音效 Tab:tabSound

  /** 音效主 Tab:今日推荐音效包渐变大卡 + 大编号热门音效榜 + Canvas 柱状图 */
  @Builder
  tabSound() {
    Column({ space: 12 }) {
      this.recBanner()
      this.rankCard()
      this.chartCard()
    }
    .width('100%')
  }

  /** 今日推荐音效包卡(3 条:包名 + 条数 + 时长 + 采样率) */
  @Builder
  recBanner() {
    Column({ space: 10 }) {
      Row() {
        Text('🎧 今日推荐音效包').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('每周五更新').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      ForEach(this.packList, (p: PackItem) => {
        Row({ space: 10 }) {
          Text(p.icon).fontSize(24)
          Column({ space: 3 }) {
            Text(p.name + ' ' + p.count).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('时长 ' + p.duration + ' · ' + p.spec).fontSize(9).fontColor(COLORS.sub)
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start)
          Text('获取').fontSize(10).fontColor(COLORS.amber)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(12)
            .border({ width: 1, color: COLORS.amber })
            .onClick(() => {
              this.currentTab = 1;
            })
        }
        .width('100%')
        .padding(11)
        .borderRadius(11)
        .backgroundColor(COLORS.chip)
      }, (p: PackItem) => p.name)
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
  }

音效 Tab 通过 tabSound() 组合三个子 Builder:recBanner()(今日推荐)、rankCard()(热门榜单)、chartCard()(柱状图)。

recBanner() 展示今日推荐的音效包。标题行使用 Column().layoutWeight(1) 作为弹性占位符将"每周五更新"推到右侧——这是 ArkUI 中实现左右布局的常用模式。ForEach 遍历 packList 的 3 条数据,每个音效包卡片包含 emoji 图标、包名+条数、时长+采样率规格、"获取"按钮。副信息行使用 maxLines(1)textOverflow({ overflow: TextOverflow.Ellipsis }) 确保超长文本单行截断并显示省略号。"获取"按钮使用琥珀橙描边样式(仅边框无填充),点击切换到网页 Tab 让用户在 Web 组件中获取素材。

热门音效榜 rankCard() 展示 8 条音效下载排行。每条记录的名次使用 20 像素大字号,前 3 名用声波绿高亮、4-8 名用灰色弱化。每行使用斑马纹交替背景(偶数行 COLORS.chip,奇数行 COLORS.card)增强可读性。右侧展示周下载量和趋势,趋势文案通过 trendIcon()trendColor() 两个辅助函数生成。

9.4 网页 Tab:tabWeb

  /** 网页 Tab:地址栏 + 快捷站点 + Web 组件 + 主动下载(ArkWeb 特性页) */
  @Builder
  tabWeb() {
    Column({ space: 10 }) {
      // 地址栏:输入 + 前往(urlInput/webUrl 双状态分离)
      Row({ space: 8 }) {
        TextInput({ text: this.urlInput, placeholder: '输入网址,如 freesound.org' })
          .layoutWeight(1).height(38).fontSize(11).fontColor(COLORS.title)
          .backgroundColor(COLORS.chip).borderRadius(10)
          .onChange((value: string) => {
            this.urlInput = value;
          })
        Text('前往').fontSize(11).fontColor('#121212')
          .padding({ left: 14, right: 14, top: 10, bottom: 10 })
          .backgroundColor(COLORS.green).borderRadius(10)
          .onClick(() => {
            this.loadUrl();
          })
      }
      .width('100%')

      // 快捷站点横滑(真实音效/BGM 素材站,点击即加载)
      Scroll() {
        Row({ space: 8 }) {
          ForEach(QUICK_SITES, (site: string) => {
            Row({ space: 5 }) {
              Text('🔗').fontSize(10)
              Text(siteHost(site)).fontSize(9).fontColor(COLORS.sub)
                .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(this.webUrl === site ? COLORS.green : COLORS.chip)
            .borderRadius(12)
            .onClick(() => {
              this.urlInput = site;
              this.webUrl = site;
            })
          }, (site: string) => site)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')

      // Web 组件本体:网页内点击下载链接自动进入 delegate 回调
      Web({ src: this.webUrl, controller: this.webController })
        .layoutWeight(1)
        .width('100%')
        .borderRadius(10)
        .backgroundColor(COLORS.chip)

      // 主动下载演示行(应用侧 startDownload 触发)
      Column({ space: 8 }) {
        Row() {
          Text('🧪 应用侧主动下载演示').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text(this.dlState).fontSize(9).fontColor(this.dlState === '下载完成' ? COLORS.green : COLORS.amber)
        }
        .width('100%')
        Row({ space: 10 }) {
          Text('下载森林环境声 WAV').fontSize(10).fontColor('#121212')
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.green).borderRadius(9)
            .onClick(() => {
              this.triggerDownload('https://cdn.yinxiaowu.cn/sfx/env/ForestAmbience_48k24b.wav');
            })
          Text('下载街机按钮音 ZIP').fontSize(10).fontColor(COLORS.amber)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 })
            .borderRadius(9).border({ width: 1, color: COLORS.amber })
            .onClick(() => {
              this.triggerDownload('https://cdn.yinxiaowu.cn/sfx/ui/ArcadeUI_Buttons_v2.zip');
            })
        }
        .width('100%')
        Text('提示:网页内点击素材下载链接同样会触发 WebDownloadDelegate 四回调').fontSize(8).fontColor(COLORS.text3)
      }
      .width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
    }
    .width('100%')
    .height('100%')
  }

网页 Tab 是 ArkWeb 特性的集中展示区。地址栏使用 TextInput 组件绑定 urlInput 状态,onChange 回调实时更新输入值但不触发加载。点击"前往"按钮调用 loadUrl() 方法,校验协议后更新 webUrl 触发 Web 组件重新加载。

Web 组件是 ArkWeb 的核心——通过 src 属性绑定 webUrl 状态,controller 绑定 webController 实例。当 webUrl 变化时 Web 组件自动加载新 URL。layoutWeight(1) 让 Web 组件占据剩余空间,borderRadius(10) 为网页区域添加圆角。网页内用户点击下载链接时,由于 downloadDelegate 已绑定到 webController,下载行为会自动进入四个回调函数。

主动下载演示区提供两个按钮,分别触发 WAV 和 ZIP 格式的音效包下载。triggerDownload() 调用 this.webController.startDownload(url) 发起应用侧下载——与网页内点击下载链接不同,这种方式由应用代码主动发起,无需用户在网页内交互。底部提示文案告知用户:网页内点击下载链接同样会触发回调。

9.5 下载 Tab:tabDownload

  /** 下载 Tab:进行中任务卡 + 完成记录双 URL 溯源列表 + 代码预览卡 */
  @Builder
  tabDownload() {
    Column({ space: 12 }) {
      // 进行中任务卡(进度条 + 状态)
      Column({ space: 10 }) {
        Row() {
          Text('⬇ 下载任务').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text(this.dlState).fontSize(9).fontColor(COLORS.green)
        }
        .width('100%')
        Text(this.dlName === '' ? '暂无进行中任务(可在网页 Tab 触发)' : this.dlName)
          .fontSize(10).fontColor(COLORS.sub)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Progress({ value: this.dlPercent, total: 100, type: ProgressType.Linear })
          .width('100%').height(6)
          .color(COLORS.green).backgroundColor(COLORS.chip)
        Row() {
          Text('进度 ' + this.dlPercent + '%').fontSize(9).fontColor(COLORS.sub)
          Column().layoutWeight(1)
          Text('保存至沙箱 filesDir').fontSize(9).fontColor(COLORS.text3)
        }
        .width('100%')
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

      // onDownloadFinish 代码预览卡(体现 6.1.1 新增双接口)
      Column({ space: 6 }) {
        Text('⌨️ onDownloadFinish 回调(HarmonyOS 6.1.1 新增)').fontSize(12)
          .fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column({ space: 4 }) {
          Text('this.downloadDelegate.onDownloadFinish(').fontSize(9).fontFamily('monospace').fontColor(COLORS.sub)
          Text('  (item: webview.WebDownloadItem) => {').fontSize(9).fontFamily('monospace').fontColor(COLORS.sub)
          Text('    const original = item.getOriginalUrl();').fontSize(9).fontFamily('monospace').fontColor(COLORS.green)
          Text('    const referrer = item.getReferrerUrl();').fontSize(9).fontFamily('monospace').fontColor(COLORS.amber)
          Text('  });').fontSize(9).fontFamily('monospace').fontColor(COLORS.sub)
        }
        .width('100%').padding(10).borderRadius(8).backgroundColor(COLORS.codeBg)
        Text('原始 URL 追踪素材直链来源,引用页 URL 记录触发下载的音效包页面').fontSize(8).fontColor(COLORS.text3)
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

      // 已完成记录列表(每条含双 URL 溯源信息)
      Column({ space: 10 }) {
        Row() {
          Text('🗂 历史下载记录').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text(this.downloadRecords.length + ' 条').fontSize(9).fontColor(COLORS.text3)
        }
        .width('100%')

        ForEach(this.downloadRecords, (rec: DownloadRecord, idx: number) => {
          Column({ space: 6 }) {
            Row({ space: 8 }) {
              Text('🎚').fontSize(14)
              Column({ space: 2 }) {
                Text(rec.fileName).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                  .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
                Text(rec.fileSize + ' · ' + rec.finishTime + ' · ' + rec.note).fontSize(9).fontColor(COLORS.sub)
                  .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Start)
              Text('改').fontSize(9).fontColor(COLORS.sub)
                .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                .backgroundColor(COLORS.chip).borderRadius(8)
                .onClick(() => {
                  this.openEditRecord(idx);
                })
              Text('删').fontSize(9).fontColor(COLORS.red)
                .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                .backgroundColor(COLORS.chip).borderRadius(8)
                .onClick(() => {
                  this.delIdx = idx;
                  this.delModal = true;
                })
            }
            .width('100%')

            // ★ 原始 URL 溯源行(getOriginalUrl 结果:素材站直链)
            Row({ space: 6 }) {
              Text('🔗').fontSize(9)
              Text(rec.originalUrl).fontSize(8).fontFamily('monospace').fontColor(COLORS.green)
                .layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .width('100%')

            // ★ 引用页 URL 溯源行(getReferrerUrl 结果:音效包详情页)
            Row({ space: 6 }) {
              Text('📄').fontSize(9)
              Text(rec.referrerUrl).fontSize(8).fontFamily('monospace').fontColor(COLORS.amber)
                .layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .width('100%')
          }
          .width('100%').padding(11).borderRadius(11).backgroundColor(COLORS.chip)
        }, (rec: DownloadRecord) => rec.fileName)
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
    }
    .width('100%')
  }

下载 Tab 是双 URL 溯源功能的可视化展示区,分为三个部分。

进行中任务卡使用 Progress 组件展示线性进度条,value 绑定 dlPercenttotal 设为 100。进度条颜色为声波绿,背景为 chip 灰。当无下载任务时显示"暂无进行中任务(可在网页 Tab 触发)"的提示文案。

代码预览卡用 fontFamily('monospace') 等宽字体模拟代码编辑器的显示效果。getOriginalUrl() 行用声波绿高亮,getReferrerUrl() 行用琥珀橙高亮,颜色与下方历史记录中的 URL 行一一对应——绿色代表原始 URL,橙色代表引用页 URL,形成了视觉编码的一致性。代码块背景使用 COLORS.codeBg(极深暗绿 #0A140E),模拟 IDE 暗色主题。

历史下载记录列表是溯源功能的灵魂。每条记录卡片包含三层信息:第一层是文件名、大小、时间、备注和操作按钮(改/删);第二层是 🔗 图标 + 原始 URL(绿色 monospace 字体),展示素材站 CDN 直链;第三层是 📄 图标 + 引用页 URL(琥珀橙 monospace 字体),展示音效包详情页地址。双 URL 行使用 layoutWeight(1)maxLines(1) + textOverflow(Ellipsis) 确保超长 URL 单行截断。颜色编码(绿/橙)让用户一眼区分"从哪下载"和"从哪个页面触发下载"。

9.6 我的 Tab:tabMine

  /** 我的 Tab:声音设计师渐变大卡 + 声音统计行 + 功能清单行 */
  @Builder
  tabMine() {
    Column({ space: 12 }) {
      // 声音设计师渐变大卡
      Column({ space: 8 }) {
        Row({ space: 12 }) {
          Text('🎚').fontSize(34)
          Column({ space: 4 }) {
            Text('采样师 · 阿声').fontSize(15).fontColor('#121212').fontWeight(FontWeight.Bold)
            Text('黄金音效师 · 已上架 86 条作品').fontSize(9).fontColor('rgba(18,18,18,0.72)')
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start)
          Text('录音棚 Lv.6').fontSize(10).fontColor('#121212')
            .padding({ left: 10, right: 10, top: 5, bottom: 5 })
            .borderRadius(10).backgroundColor('rgba(18,18,18,0.18)')
        }
        .width('100%')
        Row({ space: 8 }) {
          Column({ space: 2 }) {
            Text('186').fontSize(15).fontColor('#121212').fontWeight(FontWeight.Bold)
            Text('累计下载').fontSize(8).fontColor('rgba(18,18,18,0.66)')
          }
          .layoutWeight(1)
          Column({ space: 2 }) {
            Text('42').fontSize(15).fontColor('#121212').fontWeight(FontWeight.Bold)
            Text('本月记录').fontSize(8).fontColor('rgba(18,18,18,0.66)')
          }
          .layoutWeight(1)
          Column({ space: 2 }) {
            Text('256').fontSize(15).fontColor('#121212').fontWeight(FontWeight.Bold)
            Text('收藏音效').fontSize(8).fontColor('rgba(18,18,18,0.66)')
          }
          .layoutWeight(1)
          Column({ space: 2 }) {
            Text('86').fontSize(15).fontColor('#121212').fontWeight(FontWeight.Bold)
            Text('上传作品').fontSize(8).fontColor('rgba(18,18,18,0.66)')
          }
          .layoutWeight(1)
        }
        .width('100%')
      }
      .width('100%').padding(16).borderRadius(14)
      .linearGradient({ angle: 135, colors: [[COLORS.greenD, 0], [COLORS.green, 0.55], [COLORS.amber, 1]] })

      // 功能清单行
      Column({ space: 0 }) {
        ForEach(this.statList, (st: UserStat) => {
          Row({ space: 10 }) {
            Text(st.icon).fontSize(15)
            Text(st.label).fontSize(11).fontColor(COLORS.title)
              .layoutWeight(1)
            Text(st.value).fontSize(9).fontColor(COLORS.text3)
            if (st.arrow) {
              Text('›').fontSize(14).fontColor(COLORS.text3)
            }
          }
          .width('100%')
          .padding({ top: 11, bottom: 11 })
          .border({ width: { bottom: 1 }, color: COLORS.line })
          .onClick(() => {
            if (st.label === '下载历史') {
              this.currentTab = 2;
            }
          })
        }, (st: UserStat) => st.label)
      }
      .width('100%').padding({ left: 14, right: 14 }).backgroundColor(COLORS.card).borderRadius(12)

      Text('音效屋 v6.1.1 · ArkWeb 双 URL 溯源版').fontSize(8).fontColor(COLORS.text3)
    }
    .width('100%')
  }

我的 Tab 展示声音设计师的个人信息和功能清单。渐变大卡使用 135 度角的线性渐变(比头部 Banner 的 120 度略陡,产生视觉变化),从深绿到声波绿到琥珀橙。卡片内文字使用深色(#121212),与亮色渐变形成高对比。统计行使用 4 个等宽 Column(各 layoutWeight(1))展示累计下载、本月记录、收藏音效、上传作品四项数据。

功能清单使用 ForEach 遍历 statList 的 8 条功能项。每行包含图标、功能名、状态值和右箭头(由 st.arrow 控制是否显示)。行间使用 border({ width: { bottom: 1 }, color: COLORS.line }) 绘制底部分割线。点击"下载历史"行切换到下载 Tab(currentTab = 2),实现了页面间的跳转联动。底部标注"音效屋 v6.1.1 · ArkWeb 双 URL 溯源版"表明应用版本和核心特性。

9.7 底部导航 tabBar

  /** 底部导航:4 Tab 单排(选中声波绿高亮 + 图标放大) */
  @Builder
  tabBar() {
    Row() {
      ForEach(TAB_LIST, (t: TabMeta, idx: number) => {
        Column({ space: 3 }) {
          Text(t.icon).fontSize(this.currentTab === idx ? 20 : 17)
            .opacity(this.currentTab === idx ? 1 : 0.65)
          Text(t.label).fontSize(9)
            .fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
            .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
        .padding({ top: 7, bottom: 7 })
        .onClick(() => {
          this.currentTab = idx;
        })
      }, (t: TabMeta) => t.label)
    }
    .width('100%')
    .backgroundColor(COLORS.card)
    .border({ width: { top: 1 }, color: COLORS.line })
  }

底部导航栏使用 4 个等宽 Column(各 layoutWeight(1))排列 4 个 Tab。选中态通过三个维度的视觉变化体现:图标字号从 17 放大到 20(视觉放大效果)、图标透明度从 0.65 提升到 1(更鲜明)、标签文字从灰色变为声波绿并加粗。点击任一 Tab 更新 currentTab 状态,触发条件渲染切换页面内容。顶部使用 border({ width: { top: 1 }, color: COLORS.line }) 绘制分割线与内容区分隔。

9.8 弹窗系统

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

  /** 新建下载任务弹窗面板(音效包下载链接 + 备注) */
  @Builder
  panelAdd(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 12 }) {
        Text('新建下载任务').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

        Column({ space: 6 }) {
          Text('音效包下载链接').fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.formUrl, placeholder: 'https://cdn.yinxiaowu.cn/sfx/pack.zip' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.formUrl = value;
            })
        }
        .width('100%').alignItems(HorizontalAlign.Start)

        Column({ space: 6 }) {
          Text('备注(可选)').fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.formNote, placeholder: '如:自然采样局直链 / 战斗场景配乐' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.formNote = value;
            })
        }
        .width('100%').alignItems(HorizontalAlign.Start)

        Text('发起后经 startDownload 触发,完成回调记录双 URL').fontSize(8).fontColor(COLORS.text3)

        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('#121212').fontWeight(FontWeight.Bold)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.green).borderRadius(9)
            .onClick(() => {
              this.saveDownload();
            })
        }
        .width('100%')
      }
      .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }

弹窗系统由 modalOverlay(遮罩)和三个面板(panelAddpanelEditpanelDel)组成。modalOverlay 接收一个 onClose 回调函数作为参数——这是 ArkUI @Builder 方法传参的典型模式,用于在点击遮罩时关闭弹窗。遮罩使用 rgba(0,0,0,0.68) 半透明黑色覆盖全屏,alignContent(Alignment.Center) 让子内容居中。

panelAdd 新建下载任务弹窗叠加在遮罩之上(Stack 的堆叠顺序),面板宽度 78%、圆角 14 像素。面板包含 URL 输入框、备注输入框、提示文案和"取消/开始下载"双按钮。@Builder 方法接收 onClose 回调,点击"取消"或遮罩调用 onClose() 关闭弹窗,点击"开始下载"调用 saveDownload() 触发下载。

panelEdit 编辑备注弹窗结构类似,包含备注 TextInput 和"取消/保存"按钮。panelDel 删除确认弹窗使用红色"删除"按钮(COLORS.red)作为危险操作提示,并注明"仅移除记录,不影响已保存到沙箱的音效素材"消除用户顾虑。


十、技术方案对比

技术维度 传统单 URL 方案 HarmonyOS 6.1.1 双 URL 方案 优势说明
下载溯源能力 仅记录原始 URL,无法追溯触发页面 getOriginalUrl + getReferrerUrl 双 URL 完整溯源 可精确还原"从哪个详情页下载了哪个直链"
商用授权审计 人工记录来源,易遗漏出错 系统级自动捕获双 URL,审计有据可查 降低版权纠纷风险,审计效率提升
URL 获取时机 下载前手动记录,可能被重定向篡改 下载完成回调中由系统提供,确保最终 URL 避免重定向导致的 URL 不一致问题
下载触发方式 仅支持网页内点击下载 网页内点击 + 应用侧 startDownload 主动下载 支持粘贴直链直接下载,体验更灵活
下载生命周期管理 自行实现进度/失败/完成回调 WebDownloadDelegate 四回调完整覆盖 系统级管理,代码量少,稳定性高
沙箱存储路径 需手动指定保存目录 onBeforeDownload 回调中通过 hostContext.filesDir 获取 遵循应用沙箱安全模型,路径自动适配
图表渲染方案 引入第三方图表库 Canvas 2D API 原生绘制 零依赖,包体小,自定义灵活
动画实现方案 AnimationController + animateTo setInterval + breath 状态翻转 + 重绘 轻量级呼吸动画,每秒重绘开销低
状态管理方案 ViewModel 手动绑定 @State + @Observed + @Builder 声明式 框架自动追踪状态变化,视图自动更新
主题管理方案 各处硬编码色值 ColorPalette 接口 + COLORS 常量集中管理 统一换肤,类型安全,编译期检查

十一、总结

本文完整剖析了一个基于 HarmonyOS 6.1.1 ArkWeb 双 URL 溯源特性的音效素材市场应用。该应用以"音效屋"为品牌名,面向游戏开发者、影视后期团队和独立创作者,提供了从音效浏览、网页访问、下载管理到个人中心的完整功能闭环。核心技术亮点在于利用 HarmonyOS 6.1.1 在 WebDownloadDelegate.onDownloadFinish 回调中新增的 getOriginalUrl()getReferrerUrl() 双接口,实现了下载溯源的自动化——每次音效包下载完成时,系统自动捕获原始 URL(素材站 CDN 直链)和引用页 URL(音效包详情页地址),无需用户手动记录,为商用授权审计提供了完整且可信的证据链。

在架构设计层面,应用采用了模块化的分层结构:颜色系统通过 ColorPalette 接口和 COLORS 常量集中管理,确保主题一致性;数据模型使用 @Observed 装饰器标记 SoundItemPackItemDownloadRecordUserStat 四个类,配合 @State 实现声明式状态驱动;UI 构建通过 @Builder 方法将复杂界面拆分为 headerMaintabSoundtabWebtabDownloadtabMinetabBar 及三个弹窗面板,每个 Builder 职责单一、可独立维护。Stack + Column 的布局组合实现了弹窗层与主内容层的叠加,条件渲染确保非活跃 Tab 不占用渲染资源。

ArkWeb 的应用是本项目的核心价值所在。WebDownloadDelegate 的四个回调覆盖了下载的完整生命周期:onBeforeDownload 必须调用 item.start() 提供沙箱路径,否则任务停留 PENDING;onDownloadUpdated 实时刷新进度条;onDownloadFailed 捕获失败信息;onDownloadFinish 调用双 URL 接口完成溯源。此外,WebviewController.startDownload(url) 提供了应用侧主动下载能力,使应用不仅依赖网页内交互触发下载,还支持用户粘贴直链直接下载,大幅提升了使用灵活性。下载代理通过 setDownloadDelegate 绑定到控制器后,无论是网页内点击还是应用侧主动发起,都会进入统一的回调链路。

Canvas 柱状图的实现展示了 ArkUI 原生 2D 绘图能力。通过 CanvasRenderingContext2DfillRectmoveTo/lineTofillText 等 API,在应用内绘制了音效分类下载分布柱状图,无需引入任何第三方图表库。呼吸动画通过 setInterval 每秒翻转 breath 状态、配合 wave 系数(1.05/0.95)实现柱高 ±5% 波动重绘,营造出"音效在跳动"的动态视觉效果。Canvas 上下文使用 private 修饰而非 @State,体现了对状态管理边界的正确理解——Canvas 上下文不参与视图状态驱动,只需在 onReady 后持有引用即可。

深色录音棚主题(#121212 黑 + #3DDC84 声波绿 + #FFB300 琥珀橙)贯穿全局,渐变 Banner、渐变个人卡使用 linearGradient 属性实现品牌色渐变,代码预览区使用极深暗绿 #0A140E 模拟 IDE 暗色主题,双 URL 在历史记录中分别用绿色和琥珀橙高亮区分,形成了完整的视觉编码体系。弹窗系统采用全屏遮罩 + 居中面板的模式,通过 @State 布尔值控制三个弹窗的显示/隐藏,点击遮罩或取消按钮调用 onClose 回调关闭,交互逻辑清晰简洁。整体而言,该项目是 HarmonyOS ArkUI + ArkWeb 技术栈在音效素材市场场景下的完整工程实践,对鸿蒙生态开发者的下载溯源、Canvas 绘图、状态管理等技术环节具有直接的参考价值。

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

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


一、创建新项目

1.1 进入欢迎界面

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

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

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

在这里插入图片描述

1.2 选择项目模板

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

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

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

在这里插入图片描述

1.3 配置项目信息

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

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

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

在这里插入图片描述

1.4 完成创建

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

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

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

在这里插入图片描述

1.5 项目结构概览

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

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

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

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

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

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

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

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

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

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

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

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

在这里插入图片描述

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

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

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

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

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

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

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

在这里插入图片描述


三、小结

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

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


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

Logo

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

更多推荐