基于HarmonyOS ArkTS API 24 ForEach精准Diff渲染机制,解析Key包含动态状态值实现视图精准重绘原理
一、技术前言
在研究生入学考试备考领域,学习资源管理正经历从"零散搜集"到"溯源闭环"的深刻变革。从考研数学历年真题的逐卷精刷,到英语一阅读理解的专项突破,再到政治主观题的背诵打卡,每一位考生都需要一个整合题库管理、真题下载、学习提醒和错题复盘的全链路工具。传统备考应用面临三大痛点:真题下载来源无法溯源导致资料真伪难辨、学习提醒铃声千篇一律导致通知体验割裂、刷题进度缺乏可视化呈现导致备考节奏失控。
HarmonyOS ArkUI 框架为这些痛点提供了系统级解决方案。ArkUI 的声明式 UI 范式通过 @Component 封装可复用组件、@State 管理响应式状态、@Builder 拆分复杂 UI 结构,天然适合"题库-下载-提醒"三层架构。@Observed 装饰器让数据模型字段级变化被 UI 感知,实现"完成度更新即进度条刷新"的流畅体验。@Entry 标注入口组件,ForEach 驱动列表渲染,Toggle 组件提供开关交互,Slider 组件实现连续值调节,这些声明式原语让复杂的备考场景在一屏之内有序展开。
本平台深度融合 HarmonyOS 6.1.1 的三大前沿特性。ArkWeb 提供 WebDownloadDelegate 四回调链——通过 onBeforeDownload、onDownloadUpdated、onDownloadFailed、onDownloadFinish 四个回调实现下载全生命周期管理;onDownloadFinish 回调中新增 getOriginalUrl(原始 URL,文件直链来源)与 getReferrerUrl(引用页 URL,触发下载的页面)双接口,每次下载真题都能溯源来路,确保资料来源可信。Notification Kit 的 sound 字段支持沙箱自定义铃声,通过 'uri::' + fileUri.getUriFromPath(沙箱路径) 将应用 EL1 区域内用户生成的音频文件设为通知铃声,实现"开考铃""满分赞"等个性化提醒音效。Canvas 绘制 实现 drawBar 方法绘制近六个月刷题量渐变柱状图,通过 createLinearGradient 创建海岸蓝渐变柱体,breath 状态每秒翻转联动柱高 ±7% 微波动模拟实时刷新效果。
从架构分层来看,源码遵循"常量声明在前、数据模型居中、组件主体在后、Builder 群函数收尾"的纵向组织原则。文件头部依次声明颜色系统接口与常量、Tab 元数据接口与列表、科目分类与快捷站点常量、图表数据与铃声预设常量;随后是五个工具函数(WAV 字节生成、域名提取、科目着色、下载状态着色、完成度着色);紧接着是六个 @Observed 数据模型类及其 Mock 数据数组;最后是 @Entry @Component 主组件,内部包含状态变量声明(分为 Tab 切换与呼吸动画、题库筛选与弹窗状态、数据列表状态、表单状态、ArkWeb 状态、Notification 状态、Canvas 状态七大组)、业务方法群(下载代理注册、URL 加载、通知授权、铃声沙箱化、通知发布、真题增删改等十余方法)、生命周期回调(aboutToAppear 初始化与 aboutToDisappear 清理)以及 build 主构建方法和十余个 @Builder 渲染函数。这种分层让近 1800 行代码在单文件内保持了良好的可读性和可维护性。
从工程实践的角度审视,本平台在设计上体现了若干值得深入探讨的架构决策。第一,单文件组件化策略——尽管 ArkUI 支持多文件组件拆分,但本平台选择将全部功能集中在单个 `` 文件中,这是出于演示性和教学性的考量:开发者打开一个文件即可看到完整的功能链路,从数据模型到 UI 渲染再到系统能力调用,形成闭环式学习体验。第二,状态集中管理模式——所有 @State 变量统一声明在组件顶层,即使某些状态仅被单个 Tab 使用,这种设计虽然牺牲了一定的封装性,但换来了跨 Tab 数据共享的便捷性和状态追踪的直观性。第三,Mock 数据与真实能力并存——下载记录、提醒列表、铃声库等数据均有预置的 Mock 数组,确保应用在无网络、无系统授权的情况下也能完整展示 UI 效果,同时真实的 ArkWeb 下载、Notification 发布、Canvas 绘制等能力又提供了可交互的功能演示。
二、整体架构流程图
整体架构以 Page1254 为根组件,采用 Stack 容器实现页面层叠:底层是 Column 纵向布局的头部渐变横幅 + 分割线 + Scroll 内容区 + 底部 Tab 栏,顶层是三组全屏弹窗遮罩(新增、编辑、删除)。内容区通过 currentTab 状态索引在六个 @Builder 方法间切换,网页 Tab 因 Web 组件需要有界高度而独占内容区不进入 Scroll,其余五个 Tab 共享一个 Scroll 滚动容器。三大特性(ArkWeb 双 URL 溯源、Notification 沙箱铃声、Canvas 柱状图)分别挂载在网页/下载、提醒/铃音、题库三个 Tab 上,但它们的状态变量统一声明在组件顶层,实现跨 Tab 数据共享。数据模型层的六个 @Observed 类分别支撑各自 Tab 的列表渲染,PaperItem 同时被弹窗系统引用以实现 CRUD 操作。
从数据流的角度来看,整个应用遵循"状态驱动视图"的单向数据流原则。用户交互(点击 Tab、点击按钮、滑动 Slider、切换 Toggle 等)触发状态变量变更,状态变更驱动 @Builder 方法重新渲染对应 UI 区域。对于列表类数据(如 paperList、downloadRecords、ringList),由于使用了 @Observed 装饰器,对象属性的变化可被直接感知,但为了确保 ForEach 列表整体刷新,代码中普遍采用 this.xxxList = this.xxxList.slice() 的方式创建新数组引用,这是 ArkUI 响应式系统驱动列表重绘的标准模式。
三、色彩体系设计
3.1 ColorPalette 接口定义
平台采用浅色"海岸蓝 + 珊瑚橙"主题,通过 ColorPalette 接口集中声明全部颜色字段,使全文件色彩管理统一可控:
interface ColorPalette {
bg: string; // 页面底色·浅海雾蓝
card: string; // 卡片底色·纯白
chip: string; // 胶囊/输入底色·浅云蓝
title: string; // 主标题·深海墨蓝
sub: string; // 次级文字·青灰蓝
text3: string; // 弱化文字·雾蓝灰
blue: string; // 主色·海岸蓝
orange: string; // 辅色·珊瑚橙
green: string; // 辅色·海藻绿
purple: string; // 辅色·鸢尾紫
line: string; // 分割线·浅雾线
tabOn: string; // Tab 激活色·海岸蓝
mask: string; // 弹窗遮罩·深海墨
white: string; // 渐变卡上的纯白文字
whiteSoft: string; // 渐变卡上的弱化白文字
trackW: string; // 渐变卡上的进度条轨道色
}
这段接口定义体现了 ArkTS 的类型安全优势。与普通 JavaScript 动态添加属性不同,ColorPalette 接口在编译期即约束所有颜色字段必须是 string 类型,任何拼写错误或类型不匹配都会在编译阶段暴露。接口包含 16 个颜色字段,覆盖了页面背景、卡片、胶囊、三级文字、四种主题色、分割线、Tab 选中态、弹窗遮罩以及渐变卡专用的白色系。接口注释采用"字段名 + 用途"的格式,使每个颜色的语义角色一目了然,后续维护者无需追踪代码即可理解色彩用途。
值得注意的是接口中增加了 blueD 字段(深海蓝)的实际使用,但接口定义中并未显式声明——这是因为在 TypeScript/ArkTS 中,对象字面量赋值给接口类型时允许多余属性存在(只要接口要求的属性都存在即可),这种"接口定义最小集、常量实现按需扩展"的模式在实际开发中很常见。blueD 作为 blue 的深色变体,主要用于渐变起点、柱状图渐变底部、文字强调等场景,与 blue 形成明暗对比。
3.2 COLORS 常量逐色分析
const COLORS: ColorPalette = {
bg: '#F2F6FA', // 浅海雾蓝,整体页面背景,营造清爽备考氛围
card: '#FFFFFF', // 纯白卡片底色,与雾蓝背景形成层次
chip: '#E7EEF5', // 浅云蓝,胶囊标签与输入框底色
title: '#22303C', // 深海墨蓝主标题,浅色背景高对比
sub: '#5E7285', // 青灰蓝副标题,层次柔和不抢视觉
text3: '#93A5B5', // 雾蓝灰弱文本,辅助信息与时间标注
blue: '#2F7BD9', // 海岸蓝主色,渐变横幅与按钮主色
blueD: '#1F5FA8', // 深海蓝渐变起点,头部横幅与柱状图渐变底
orange: '#FF7E5A', // 珊瑚橙辅色,提醒发布与删除操作
green: '#34B37E', // 海藻绿,完成状态与进步趋势
purple: '#8A6FD1', // 鸢尾紫,408 科目主题色
line: '#DDE7F0', // 浅雾线分割,低对比不干扰
tabOn: '#2F7BD9', // Tab 选中色为海岸蓝(与主色一致)
mask: 'rgba(34,48,60,0.5)', // 半透深海墨遮罩
white: '#FFFFFF', // 渐变卡纯白文字
whiteSoft: 'rgba(255,255,255,0.82)', // 渐变卡弱化白文字
trackW: 'rgba(255,255,255,0.32)' // 渐变卡进度条轨道色
};
色彩体系以"海岸蓝 + 珊瑚橙"为核心对比,每一个颜色都承载着明确的语义角色。
背景与卡片色系构成了页面的底层视觉层次。bg 为 #F2F6FA 浅海雾蓝,是整体页面的背景色,模拟海面薄雾的清爽感,适合长时间阅读的备考场景。card 为纯白 #FFFFFF,卡片底色与雾蓝背景形成柔和的明暗对比,保证信息区块的清晰边界。chip 为 #E7EEF5 浅云蓝,用于胶囊标签、输入框底色、进度条轨道等,与卡片底色仅差一档亮度,既区分又不突兀,形成"背景—卡片—胶囊"的三层浅色调递进。
文字色系分为三级,构建清晰的信息层级。title 为 #22303C 深海墨蓝,主标题文字色,与浅色背景形成高对比度但不刺眼,是信息权重最高的文字。sub 为 #5E7285 青灰蓝,副标题和正文文字色,在标题与弱文本之间架起层次过渡。text3 为 #93A5B5 雾蓝灰,三级弱文本色,用于辅助说明、时间戳、提示文案等视觉权重最低的信息。三级文字色的明度差控制在合理范围内,既保证了层次区分,又避免了对比度过强导致的视觉疲劳。
主题色系包含四种颜色,各自承担不同的功能语义。blue 为 #2F7BD9 海岸蓝,是平台的主色调,贯穿渐变横幅、按钮主色、Tab 选中态、进度条填充、链接文字等关键视觉元素。blueD 为 #1F5FA8 深海蓝,是海岸蓝的深色变体,用于渐变起点、柱状图渐变底部、强调文字等,与 blue 形成明暗渐变关系。orange 为 #FF7E5A 珊瑚橙,是最重要的辅助色,用于提醒发布按钮、删除按钮、错误状态、错因标签等需要用户特别注意的操作和信息,与海岸蓝形成冷暖对比。green 为 #34B37E 海藻绿,用于完成状态、成功提示、进步趋势等正向语义的表达。purple 为 #8A6FD1 鸢尾紫,专用于 408 计算机科目的主题色,与数学的蓝、英语的橙、政治的绿形成五科目五色的区分体系。
功能色系服务于特定的 UI 组件。line 为 #DDE7F0 浅雾线,用于分割线、Canvas 网格线等,低对比度不干扰内容阅读。tabOn 与 blue 同值,保证 Tab 选中态与主色一致,这种冗余设计是为了语义清晰——tabOn 明确表示"Tab 选中色",未来若需独立调整 Tab 选中色只需修改这一处。mask 为 rgba(34,48,60,0.5) 半透明深海墨色,弹窗遮罩使用 RGBA 格式实现 50% 透明度,与深海墨蓝的标题色系协调。
渐变卡白色系专门用于深色渐变背景上的文字和元素。white 为纯白 #FFFFFF,用于渐变卡上的主标题和重要数据。whiteSoft 为 rgba(255,255,255,0.82) 82% 不透明度的白色,用于副标题和次要信息,在深蓝渐变背景上保持可读性的同时降低视觉权重。trackW 为 rgba(255,255,255,0.32) 32% 不透明度的白色,用于渐变卡上的进度条轨道和数据胶囊底色,半透明效果让深色背景隐约透出,营造层次感。
3.3 科目到主题色的动态映射
平台还实现了科目到主题色的动态映射函数 subjectColor,根据科目关键词返回对应颜色:考研数学映射海岸蓝、英语一映射珊瑚橙、政治映射海藻绿、408 计算机映射鸢尾紫、管综映射深海蓝。这一映射贯穿真题清单行的左侧年份色块、完成度进度条着色和错题本条目的左侧色条,让用户在不同 Tab 间通过颜色一致性快速识别科目归属。
这种"一科一色"的设计策略在信息可视化中具有重要的认知价值。认知心理学研究表明,颜色是人类视觉系统最先感知的视觉特征之一,比形状和文字更快速地被大脑处理。通过为每个科目分配独特的主题色,考生在扫视清单时能够仅凭颜色快速定位目标科目相关的内容,无需逐行阅读文字。这种设计在错题本中尤其有用——左侧 4px 宽的色条让考生在翻阅大量错题记录时能够快速筛选特定科目的错题,提高复盘效率。
四、Tab 元数据与辅助数据
4.1 底部导航 Tab 定义
interface TabMeta {
icon: string;
label: string;
}
const TAB_LIST: TabMeta[] = [
{ icon: '📚', label: '题库' },
{ icon: '🌐', label: '网页' },
{ icon: '📥', label: '下载' },
{ icon: '⏰', label: '提醒' },
{ icon: '🎵', label: '铃音' },
{ icon: '👤', label: '我的' }
];
TabMeta 接口定义了 Tab 导航项的最小数据结构:icon 为 emoji 字符串,label 为中文标签文字。TAB_LIST 常量数组按顺序声明六个 Tab 项,分别对应题库、网页、下载、提醒、铃音和我的。这种将导航元数据与 UI 渲染分离的设计使 Tab 配置可独立维护,新增或调整 Tab 只需修改数组而无需触碰 @Builder 方法。底部导航栏在 tabBar() 构建器中通过 ForEach 遍历此数组渲染,选中态通过 currentTab 索引与 index 比较判断。
六个 Tab 的排列顺序体现了备考学习的典型路径:从题库(核心学习内容)出发,到网页(资源检索下载),到下载(资源管理溯源),到提醒(学习计划管理),到铃音(个性化体验),最后到我的(个人数据复盘)。这种从"内容获取"到"学习管理"再到"个人成长"的路径设计,符合考生从搜集资料到系统备考再到复盘提升的完整学习闭环。每个 Tab 的 emoji 图标都经过精心选择,与功能语义高度匹配:书本代表题库、地球代表网页、向下箭头代表下载、闹钟代表提醒、音符代表铃音、人像代表我的。
4.2 科目分类与快捷站点常量
const SUBJECT_TAGS: string[] = ['全部', '考研数学', '英语一', '政治', '408', '管综'];
SUBJECT_TAGS 常量定义了题库 Tab 科目分类横滚 chips 的六个标签。首项"全部"为默认选中态,不过滤任何真题;其余五个标签分别对应考研数学、英语一、政治、408 计算机和管综五个科目,点击后按科目关键词匹配筛选真题清单。科目的选择覆盖了考研最主流的几个科目类别,具有广泛的代表性。使用 string[] 简单数组而非对象数组,是因为科目标签只需要文字展示这一个维度的信息,简洁够用。
interface QuickSite {
icon: string; // 站点图标
name: string; // 站点名
url: string; // 站点地址
}
const QUICK_SITES: QuickSite[] = [
{ icon: '🏫', name: '中国教育考试网', url: 'https://www.neea.edu.cn' },
{ icon: '🎓', name: '研招网', url: 'https://yz.chsi.com.cn' },
{ icon: '📖', name: '学信网', url: 'https://www.chsi.com.cn' },
{ icon: '📚', name: '中国教育在线', url: 'https://www.eol.cn' }
];
QuickSite 接口定义了快捷站点的三字段结构:icon 为 emoji 图标、name 为站点名称、url 为站点地址。QUICK_SITES 常量包含四个教育考试类真实站点,都是考生备考过程中高频访问的官方渠道。中国教育考试网(neea.edu.cn)是教育部考试中心官方网站,发布各类考试政策和真题信息;研招网(yz.chsi.com.cn)是全国硕士研究生招生考试网上报名平台;学信网(chsi.com.cn)是教育部学历查询官方平台;中国教育在线(eol.cn)是综合性教育门户网站。这四个站点覆盖了政策查询、报名服务、学历验证和资讯获取四大需求,点击即加载到 Web 组件,方便考生快速访问官方渠道。
4.3 图表数据与铃声预设
const MONTH_LABELS: string[] = ['03月', '04月', '05月', '06月', '07月', '08月'];
MONTH_LABELS 常量定义了近 6 个月的月份标签,格式为"MM月",从 03 月到 08 月共 6 个月。这组标签同时被题库 Tab 的 Canvas 柱状图和我的 Tab 的错题趋势图共用,确保两个图表的时间轴对齐,方便考生对比同一时间段内的刷题量和错题量变化趋势。
const BRUSH_VAL: number[] = [128, 196, 242, 168, 286, 324];
BRUSH_VAL 常量定义了近 6 个月的刷题量 Mock 数据,单位为题数。数据呈整体上升趋势:128 → 196 → 242 → 168 → 286 → 324,其中 06 月出现小幅回落(168),模拟了备考中期可能遇到的疲惫期或其他事务干扰,随后 07、08 月持续攀升,反映了备考后期冲刺阶段的高强度学习。最高值 324 题出现在最新的 08 月,合计 1344 题(128+196+242+168+286+324=1344),与统计卡上的"累计刷题 1344 题"数据一致。
const MISTAKE_VAL: number[] = [46, 38, 52, 31, 27, 19];
MISTAKE_VAL 常量定义了近 6 个月的错题量 Mock 数据。数据呈整体下降趋势:46 → 38 → 52 → 31 → 27 → 19,其中 05 月出现小幅反弹(52),模拟了学习新知识点时错题量暂时增加的正常现象,随后持续下降,体现了持续练习带来的进步效果。最新的 08 月错题量仅 19 题,为 6 个月最低,用海藻绿标注,强调"持续下降 = 进步"的正向激励。
const RING_FREQ_PRESETS: number[] = [440, 660, 880, 1320];
RING_FREQ_PRESETS 常量定义了铃声生成器的四档频率预设,单位为 Hz。440Hz 对应标准音 A4(钢琴中央 C 上方的 A),是国际标准音高;660Hz 对应 E5,是 A4 的纯五度上行;880Hz 对应 A5,是 A4 的高八度;1320Hz 对应 E6,是 A5 的纯五度上行。这四档频率覆盖了从低沉到清亮的听感范围,440Hz 低沉稳重适合重要提醒,1320Hz 高频明亮适合正向激励。频率之间呈纯五度(3:2 频率比)和八度(2:1 频率比)的和谐音程关系,确保生成的铃声听起来悦耳不刺耳。
const RING_DURATION_PRESETS: number[] = [600, 1200, 2000];
RING_DURATION_PRESETS 常量定义了铃声生成器的三档时长预设,单位为毫秒。600ms 为短铃声,适合快速提醒如消息通知;1200ms 为中等时长,适合常规提醒如起床闹铃;2000ms 为长铃声,适合重要提醒如考试开始。三档时长的选择覆盖了从短促到悠长的不同场景需求,与四档频率组合可生成 12 种不同音色和时长的铃声组合。
五、工具函数分析
5.1 WAV 音频字节生成器 buildWavBytes
function buildWavBytes(freq: number, durationMs: number): ArrayBuffer {
const sampleRate = 44100;
const numSamples = Math.floor(sampleRate * durationMs / 1000);
const dataSize = numSamples * 2;
const buf = new ArrayBuffer(44 + dataSize);
const view = new DataView(buf);
const writeStr = (offset: number, s: string) => {
for (let i = 0; i < s.length; i++) {
view.setUint8(offset + i, s.charCodeAt(i));
}
};
writeStr(0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // PCM 编码
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); // 16bit 量化
writeStr(36, 'data');
view.setUint32(40, dataSize, true);
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
const env = Math.min(1, i / (sampleRate * 0.02)); // 起音包络
const decay = Math.max(0, 1 - t / (durationMs / 1000)); // 自然衰减
const v = Math.sin(2 * Math.PI * freq * t) * 0.5 * env * decay;
view.setInt16(44 + i * 2, Math.round(v * 32767), true);
}
return buf;
}
buildWavBytes 函数是铃声坊的核心引擎,生成标准 16bit 单声道 PCM WAV 音频字节。函数接收频率(Hz)和时长(毫秒)两个参数,采样率固定为 44100Hz(CD 音质标准),计算出总采样数和字节数后分配 ArrayBuffer。
函数内部定义了一个局部辅助函数 writeStr,用于向 DataView 指定偏移量写入字符串——通过循环遍历字符串的每个字符,使用 charCodeAt(i) 获取字符编码,再用 setUint8 写入一个字节。这种手写字符串写入的方式虽然不如高级 API 便捷,但在底层二进制操作中非常常见,体现了对 WAV 文件格式字节级的精确控制。
前 44 字节为 WAV 文件头,按照 RIFF/WAVE 规范逐字段写入:
- RIFF 标识(偏移 0,4 字节):写入字符串
'RIFF',表示这是一个 RIFF 格式文件。 - 文件大小(偏移 4,4 字节):
36 + dataSize,即整个文件减去 8 字节(RIFF 标识和文件大小字段本身)。使用setUint32以小端序写入。 - WAVE 格式(偏移 8,4 字节):写入字符串
'WAVE',表示这是 WAVE 音频格式。 - fmt 子块标识(偏移 12,4 字节):写入字符串
'fmt '(注意末尾空格,凑足 4 字节)。 - fmt 子块大小(偏移 16,4 字节):值为 16,表示 PCM 格式的 fmt 子块数据长度为 16 字节。
- 音频格式(偏移 20,2 字节):值为 1,表示 PCM 脉冲编码调制。
- 声道数(偏移 22,2 字节):值为 1,表示单声道。
- 采样率(偏移 24,4 字节):值为 44100,即 44.1kHz 采样率。
- 字节率(偏移 28,4 字节):
sampleRate * 2,即每秒字节数 = 采样率 × 声道数 × 位深度/8 = 44100 × 1 × 16/8 = 88200 字节/秒。 - 块对齐(偏移 32,2 字节):值为 2,即每个采样帧的字节数 = 声道数 × 位深度/8 = 1 × 16/8 = 2 字节。
- 位深度(偏移 34,2 字节):值为 16,表示 16bit 量化精度。
- data 子块标识(偏移 36,4 字节):写入字符串
'data'。 - 数据大小(偏移 40,4 字节):
dataSize,即采样数据的总字节数。
所有多字节整数的写入使用 true 参数表示小端序(Little-Endian),这是 WAV 规范的要求——WAV 文件基于 RIFF 格式,而 RIFF 格式使用小端序存储多字节整数。
数据区通过循环逐采样填充,这是函数最核心的音频合成逻辑。每个采样点的计算涉及四个因子:
- 正弦波基频:
Math.sin(2 * Math.PI * freq * t),生成指定频率的纯正弦波。t = i / sampleRate为当前采样对应的时间秒数。 - 振幅系数:
0.5,将正弦波的振幅限制在 -0.5 到 +0.5 之间,留出 6dB 的余量防止削波失真。 - 起音包络(Attack Envelope):
env = Math.min(1, i / (sampleRate * 0.02)),前 20ms 内从 0 线性上升到 1。这是为了避免音频开始时的瞬时满幅输出产生的爆音(Click 噪声)——如果没有起音包络,第一个采样点直接从 0 跳到某个正的振幅值,会产生一个陡峭的跳变,在扬声器上表现为刺耳的"咔哒"声。20ms 的线性上升时间是人耳感知不到的,但足以消除爆音。 - 自然衰减包络(Decay Envelope):
decay = Math.max(0, 1 - t / (durationMs / 1000)),从 1 线性下降到 0,模拟物理铃声敲击后的自然渐弱效果。
最终波形值为四个因子的乘积,然后通过 Math.round(v * 32767) 量化到 16bit 有符号整数范围(-32768 至 +32767),使用 setInt16 以小端序写入对应位置。setInt16 方法接收三个参数:偏移量、值、是否小端序,这里偏移量为 44 + i * 2(44 字节头部之后,每个采样占 2 字节)。
从音频工程角度审视,该函数的包络设计遵循了音频合成的经典 ADSR 模型的简化版本。ADSR 代表 Attack(起音)、Decay(衰减)、Sustain(持续)、Release(释放)四个阶段,而这里使用了简化的 Attack + Decay 两阶段模型——起音阶段快速上升到峰值,衰减阶段线性下降到零。这种模型非常适合模拟铃声类音色,因为真实的物理铃声就是敲击后快速达到最大振幅,然后逐渐衰减消失。0.5 的振幅系数留出了 6dB 的余量,防止多段音频叠加或后续处理时超过 16bit 量化上限而产生削波失真。
5.2 域名提取函数 siteHost
function siteHost(url: string): string {
const head = 'https://';
if (url.startsWith(head)) {
return url.slice(head.length);
}
return url;
}
siteHost 函数将站点 URL 转为展示域名,去掉 https:// 协议前缀。函数逻辑很简单:检查 URL 是否以 'https://' 开头,如果是则截取从第 8 个字符开始的子串(即去掉协议前缀),否则直接返回原 URL。这个函数用于地址栏提示和快捷站点卡的展示,让用户看到更简洁的域名形式而非完整 URL。
值得注意的是,这个函数只处理了 https:// 协议,没有处理 http:// 协议。这是因为在当前的安全网络环境下,教育考试类官方网站几乎全部使用 HTTPS 协议,而且快捷站点常量中的 URL 也都是 HTTPS。这种针对性的简化设计在特定业务场景下是合理的——如果确认所有输入都是 HTTPS URL,就没有必要处理 HTTP 的情况,代码更简洁高效。
5.3 科目着色函数 subjectColor
function subjectColor(s: string): string {
if (s.indexOf('数学') >= 0) { return COLORS.blue; }
if (s.indexOf('英语') >= 0) { return COLORS.orange; }
if (s.indexOf('政治') >= 0) { return COLORS.green; }
if (s.indexOf('408') >= 0) { return COLORS.purple; }
if (s.indexOf('管综') >= 0) { return COLORS.blueD; }
return COLORS.sub;
}
subjectColor 函数根据科目关键词返回对应主题色,是"一科一色"设计策略的核心实现。函数使用 indexOf 方法进行子串匹配,而非严格相等匹配,这样可以处理科目名称的变体——比如"考研数学一"“考研数学二”"数学一"都包含"数学"关键词,都会映射到海岸蓝。这种模糊匹配的设计提高了函数的鲁棒性,即使科目名称格式略有差异也能正确着色。
五个科目分别映射五种颜色:数学→海岸蓝(理性冷静的学科气质)、英语→珊瑚橙(语言学习的活力感)、政治→海藻绿(意识形态的生机感)、408→鸢尾紫(计算机科学的神秘感)、管综→深海蓝(管理综合的深邃感)。如果都不匹配,则返回 COLORS.sub(青灰蓝)作为默认色,保证即使遇到未知科目也能正常显示。
这个函数在整个应用中被多处调用:真题清单行的左侧年份色块背景色、完成度进度条的填充色、错题本条目左侧的色条色。通过统一的函数映射,确保了相同科目在不同位置的颜色一致性,形成了贯穿全局的视觉识别系统。
5.4 下载状态着色函数 dlStateColor
function dlStateColor(s: string): string {
if (s.indexOf('完成') >= 0) { return COLORS.green; }
if (s.indexOf('失败') >= 0) { return COLORS.orange; }
if (s === '空闲') { return COLORS.text3; }
return COLORS.blue;
}
dlStateColor 函数将下载状态文案映射为对应的颜色,使用户一眼就能判断下载状态。函数同样采用关键词匹配策略:包含"完成"映射海藻绿(成功语义)、包含"失败"映射珊瑚橙(错误/警告语义)、严格等于"空闲"映射雾蓝灰(中性/无活动语义),其余情况(如"正在下载"“已开始”"已发起"等)默认返回海岸蓝(进行中/活跃语义)。
这种"状态即颜色"的设计是信息可视化的基本模式。在下载场景中,用户最关心的三个问题是:下载成功了吗?下载失败了吗?还在下载吗?对应的三种颜色(绿、橙、蓝)给出了即时的视觉答案。绿色代表成功和完成,是全球通用的"通行"语义;橙色代表警告和异常,引起用户注意;蓝色代表进行中,是系统默认的活跃状态色。
5.5 完成度着色函数 doneColor
function doneColor(v: number): string {
if (v >= 80) { return COLORS.green; }
if (v >= 50) { return COLORS.blue; }
return COLORS.orange;
}
doneColor 函数将完成度数值(0~100)映射为三种颜色,直观反映学习进度状态。80% 及以上返回海藻绿(优秀/完成度高),50%~79% 返回海岸蓝(中等/进行中),50% 以下返回珊瑚橙(偏低/需要加油)。
两道阈值(80 和 50)的选择有其心理学依据。80% 通常被认为是"优秀"或"熟练掌握"的门槛——在考试评分体系中 80 分以上对应良好到优秀的水平;50% 则是"及格线"的心理预期——低于 50% 意味着尚未完成一半,需要加倍努力。三色分级系统既不过于简单(只有两档不够区分度)也不过于复杂(更多档位增加认知负担),三档恰好提供了"优—中—待加强"的清晰区分。
六、数据模型层
6.1 PaperItem 真题试卷模型
@Observed export class PaperItem {
subject: string; // 科目(考研数学一 / 英语一 / 政治 / 408 计算机 / 管综逻辑)
year: string; // 年份卷(2025 卷 / 2024 卷)
count: string; // 题量文本(23 题 / 52 题)
done: number; // 完成度百分数(0~100)
constructor(subject: string, year: string, count: string, done: number) {
this.subject = subject;
this.year = year;
this.count = count;
this.done = done;
}
}
PaperItem 是平台的业务主模型,用 @Observed 装饰器修饰,表示该类的实例具备可观察性。@Observed 是 ArkUI 响应式系统的核心装饰器之一,它使得类的实例属性变化能够被 UI 感知——当任何实例的属性发生变化时(如编辑后修改 done 字段),所有引用该实例的 @State 数组会收到通知并触发 UI 刷新。
四个属性各有其业务含义:
subject:科目名称,字符串类型,存储完整的科目描述如"考研数学一""英语一"等。year:年份卷标识,字符串类型,格式为"YYYY 卷"如"2025 卷"。count:题量文本,字符串类型而非数字类型,这是因为题量展示时需要带单位"题"字,直接存储字符串避免了每次渲染时的格式化操作。done:完成度百分比,数字类型,取值范围 0~100。
构造函数逐字段赋值,采用标准的构造函数模式。export 关键字使该类可被其他文件引用,但在本单文件架构中 export 更多是一种良好的编程习惯——表明该类是模块的公共 API。
const PAPER_LIST: PaperItem[] = [
new PaperItem('考研数学一', '2025 卷', '23 题', 86),
new PaperItem('考研数学一', '2024 卷', '23 题', 72),
new PaperItem('英语一', '2025 卷', '52 题', 64),
new PaperItem('英语一', '2024 卷', '52 题', 48),
new PaperItem('政治', '2025 卷', '38 题', 90),
new PaperItem('408 计算机', '2024 卷', '47 题', 35),
new PaperItem('管综逻辑', '2025 卷', '30 题', 58),
new PaperItem('管综数学', '2024 卷', '25 题', 76)
];
PAPER_LIST 常量预置了 8 条真题试卷 Mock 数据,覆盖考研数学一、英语一、政治、408 计算机和管综五个科目。数据设计有以下特点:
- 数学一和英语各有两套(2025 卷和 2024 卷),政治只有最新的 2025 卷,408 只有 2024 卷,管综分为逻辑和数学两个子科目各一套,模拟了真实备考中资料收集的不均衡状态。
- 完成度从 35% 到 90% 不等,平均完成度约 66%((86+72+64+48+90+35+58+76)/8 = 529/8 ≈ 66.1%),与统计卡上显示的"平均完成度 76%"略有出入——这可能是统计卡数据为了展示效果而使用了更乐观的数值。
- 题量差异明显:英语一 52 题最多(因为包含完形填空、阅读理解、新题型、翻译、写作等多种题型),管综数学 25 题最少。
6.2 DownloadRecord 下载记录模型
@Observed export class DownloadRecord {
fileName: string; // 文件名
fileSize: string; // 大小文本
finishTime: string; // 完成时间
originalUrl: string; // ★ getOriginalUrl() 结果:下载项原始 URL(文件直链来源)
referrerUrl: string; // ★ getReferrerUrl() 结果:引用页 URL(触发下载的页面)
constructor(fileName: string, fileSize: string, finishTime: string,
originalUrl: string, referrerUrl: string) {
this.fileName = fileName;
this.fileSize = fileSize;
this.finishTime = finishTime;
this.originalUrl = originalUrl;
this.referrerUrl = referrerUrl;
}
}
DownloadRecord 是 ArkWeb 6.1.1 双 URL 溯源特性的数据载体,是整个下载 Tab 的核心数据结构。前三个字段(fileName、fileSize、finishTime)是常规下载记录都会有的基本信息,而后两个字段(originalUrl、referrerUrl)则是 HarmonyOS 6.1.1 新增的关键能力——通过 WebDownloadDelegate 的 onDownloadFinish 回调中的 getOriginalUrl() 和 getReferrerUrl() 双接口获取。
从信息维度来看,originalUrl 回答的是"文件本身在哪里",它指向的是 CDN 或文件服务器上的直链地址,通常带有 track=official 等渠道追踪参数;referrerUrl 回答的是"用户从哪个页面发起的下载",它指向的是教育考试网站的某个详情页或下载列表页。两者的组合让每一份真题试卷的来源链路完全透明——如果原始 URL 的域名不在官方域名白名单内,或引用页 URL 指向非官方网站,考生即可判断该资料可能不可靠。这一设计在考研资料鱼龙混杂的现实场景中具有显著的实用价值。
const DOWNLOAD_RECORDS: DownloadRecord[] = [
new DownloadRecord('math1_2025_full.pdf', '18.6 MB', '今天 09:42',
'https://files.neea.edu.cn/exam/2026/kaoyan/math1_2025_full.pdf?track=official',
'https://www.neea.edu.cn/exam/download?subject=kaoyan-math'),
new DownloadRecord('english1_2024_answer.pdf', '9.8 MB', '今天 08:15',
'https://cdn.neea.edu.cn/papers/kaoyan/english1_2024_answer.pdf?ver=2&track=official',
'https://yz.chsi.com.cn/kyzx/kydt/202608/t20260812_2280731.html'),
new DownloadRecord('politics_2025_analysis.pdf', '12.4 MB', '昨天 21:36',
'https://files.kaoyan.cn/paper/politics/politics_2025_analysis.pdf?from=detail',
'https://www.kaoyan.cn/shiti/zhengzhi/9021.html'),
new DownloadRecord('cs408_2024_real.pdf', '22.1 MB', '昨天 19:04',
'https://dl.eol.cn/408/cs408_2024_real.pdf?track=official&part=all',
'https://www.eol.cn/e_ky/zt/common/cs408/index.html'),
new DownloadRecord('guanzong_logic_2025.pdf', '7.5 MB', '昨天 12:48',
'https://files.guanzong.cn/exam/2026/guanzong_logic_2025_paper.pdf?track=official',
'https://www.guanzong.cn/zhenti/list?page=2'),
new DownloadRecord('math2_2024_full.pdf', '16.9 MB', '08-27 10:22',
'https://files.neea.edu.cn/exam/2026/kaoyan/math2_2024_full.pdf?track=official',
'https://www.neea.edu.cn/exam/download?subject=kaoyan-math2'),
new DownloadRecord('english2_2023_paper.pdf', '8.7 MB', '08-26 15:31',
'https://cdn.kaoyan.cn/papers/kaoyan/english2_2023_paper.pdf?ver=3',
'https://www.kaoyan.cn/shiti/english/8157.html')
];
DOWNLOAD_RECORDS 预置了 7 条历史下载记录 Mock 数据,每条记录的双 URL 均为带域名、路径和参数的完整真实感地址。数据设计有以下亮点:
- 域名多样性:涵盖了 neea.edu.cn(中国教育考试网)、chsi.com.cn(学信网/研招网)、kaoyan.cn(考研网)、eol.cn(中国教育在线)、guanzong.cn(管综网)等多个不同域名,模拟了真实备考中从多个渠道下载资料的情况。
- URL 结构真实性:每个 URL 都包含完整的路径结构和查询参数,如
?track=official(官方渠道追踪)、?ver=2(版本号)、?from=detail(来源页面)、?part=all(完整版)等,非常接近真实网站的 URL 设计。 - 时间线排列:按时间倒序排列,最新的在最前面(今天 09:42 → 今天 08:15 → 昨天 21:36 → …),与
unshift置顶策略一致。 - 文件大小差异:从 7.5 MB 到 22.1 MB 不等,408 计算机真题最大(因为包含数据结构、计算机组成原理、操作系统、计算机网络四门课程内容)。
6.3 RemindItem 学习提醒模型
@Observed export class RemindItem {
time: string; // 提醒时间(06:40)
title: string; // 提醒事项(背单词打卡)
repeat: string; // 重复规则(每天 / 周六 / 单次)
on: boolean; // 是否开启
constructor(time: string, title: string, repeat: string, on: boolean) {
this.time = time;
this.title = title;
this.repeat = repeat;
this.on = on;
}
}
RemindItem 封装学习提醒条目,是提醒 Tab 时间轴的数据源。四个字段分别对应提醒的时间、内容、重复规则和开关状态。time 字段使用字符串而非 Date 对象,是因为在展示层面只需要固定格式的时间文本(HH:mm),无需进行复杂的日期运算。repeat 字段也是字符串类型,存储可读的重复规则描述而非枚举值,简化了展示逻辑。on 字段是布尔值,驱动时间轴中每行 Toggle 的选中态和文字色——开启时时间文字配海岸蓝、圆点配海岸蓝;暂停时配雾蓝灰,视觉即含义。
const REMIND_LIST: RemindItem[] = [
new RemindItem('06:40', '背单词打卡', '每天', true),
new RemindItem('08:30', '晨间数学刷题', '每天', true),
new RemindItem('09:00', '全真模考开始', '周六', true),
new RemindItem('12:00', '午间错题回顾', '每天', false),
new RemindItem('15:00', '研招网报名截止', '单次', true),
new RemindItem('21:30', '睡前政治带背', '每天', false)
];
REMIND_LIST 预置了 6 条学习提醒 Mock 数据,从早到晚覆盖了全天的学习节点。时间安排符合考研备考的典型作息:早上 6:40 背单词(利用早晨记忆力最好的时段)、8:30 数学刷题(数学考试在上午,同步锻炼上午的数学思维)、周六 9:00 全真模考(模拟真实考试时间)、中午 12:00 错题回顾(利用午休时间复盘)、下午 15:00 报名截止提醒(重要的一次性事件)、晚上 21:30 政治带背(睡前回顾强化记忆)。
重复规则有三种类型:每天(日常学习任务)、周六(周度模考)、单次(重要截止日期)。6 条提醒中有 4 条开启、2 条暂停,模拟了用户选择性开启提醒的真实使用状态。
6.4 RingItem 铃声条目模型
@Observed export class RingItem {
name: string; // 铃声名(开考铃)
file: string; // 沙箱文件名(ring_880.wav)
freq: number; // 生成频率 Hz
duration: number; // 时长 ms
size: string; // 文件大小展示(未生成时为 '—')
inSandbox: boolean; // 是否已写入沙箱 EL1
constructor(name: string, file: string, freq: number,
duration: number, size: string, inSandbox: boolean) {
this.name = name;
this.file = file;
this.freq = freq;
this.duration = duration;
this.size = size;
this.inSandbox = inSandbox;
}
}
RingItem 是铃音 Tab 铃声库的核心数据模型,封装了铃声的完整信息。六个字段可以分为三组:
- 标识信息:
name(铃声名称,用于展示)和file(沙箱文件名,用于文件操作)。 - 生成参数:
freq(频率 Hz)和duration(时长 ms),这两个参数是调用buildWavBytes生成音频的输入。 - 状态信息:
size(文件大小文本)和inSandbox(是否已写入沙箱),这两个字段反映了铃声的状态——inSandbox是最关键的状态字段,决定了铃声是否可以设为通知 sound——只有写入沙箱 EL1 区域的音频文件才能被通知系统读取。
const RING_LIST: RingItem[] = [
new RingItem('开考铃', 'ring_880.wav', 880, 1200, '—', false),
new RingItem('交卷铃', 'ring_660.wav', 660, 1500, '—', false),
new RingItem('满分赞', 'ring_1320.wav', 1320, 800, '—', false),
new RingItem('报名提醒铃', 'ring_440.wav', 440, 1000, '—', false),
new RingItem('晚自习轻铃', 'ring_220.wav', 220, 2000, '—', false)
];
RING_LIST 预置了 5 条预设铃声 Mock 数据,初始均未写入沙箱(inSandbox: false),文件大小显示为 '—' 占位符。5 条铃声的设计各具特色:
- 开考铃(880Hz, 1200ms):高频清亮,中等时长,模拟考试开始的正式铃声。
- 交卷铃(660Hz, 1500ms):中低频略低沉,时长稍长,暗示考试结束的沉稳感。
- 满分赞(1320Hz, 800ms):最高频最明亮,短时长,快速的"叮"声传递正向激励。
- 报名提醒铃(440Hz, 1000ms):标准音高,中等时长,稳重的提醒音。
- 晚自习轻铃(220Hz, 2000ms):最低频最悠长,长时长,轻柔的背景提示音。
频率分布从 220Hz 到 1320Hz 覆盖了三个八度的范围,时长从 800ms 到 2000ms 也有较大差异,确保用户有丰富的选择空间。
6.5 NoticeLog 通知历史模型
@Observed export class NoticeLog {
title: string; // 通知标题
text: string; // 通知正文
time: string; // 发布时间
constructor(title: string, text: string, time: string) {
this.title = title;
this.text = text;
this.time = time;
}
}
NoticeLog 记录已发布通知的历史,是提醒 Tab 底部通知历史列表的数据源。三个字段分别对应通知的标题、正文和时间,与 NotificationRequest 中的 normal.title、normal.text 和发布时间一一对应。使用 @Observed 装饰器确保每次发布新通知后列表能即时刷新。
const NOTICE_LOGS: NoticeLog[] = [
new NoticeLog('背单词提醒', '今日计划 120 词,已完成 86 词', '今天 06:40'),
new NoticeLog('模考开始提醒', '全真模考 3 小时后开始,请提前打印答题卡', '昨天 09:00'),
new NoticeLog('资料下载完成', '数学一 2025 卷真题已保存到沙箱', '08-27 21:04')
];
NOTICE_LOGS 预置了 3 条通知历史 Mock 数据,覆盖了学习提醒、模考提醒和下载完成通知三种场景。每条通知的标题简洁明确,正文提供具体的补充信息,时间格式与其他模块保持一致(“今天 HH:mm”“昨天 HH:mm”“MM-DD HH:mm”)。
6.6 MistakeItem 错题本模型
@Observed export class MistakeItem {
subject: string; // 科目
question: string; // 题目摘要
reason: string; // 错因标签(计算失误 / 概念混淆)
time: string; // 收录时间
constructor(subject: string, question: string, reason: string, time: string) {
this.subject = subject;
this.question = question;
this.reason = reason;
this.time = time;
}
}
MistakeItem 是错题本的数据模型,服务于"我的"Tab 的错题复盘功能。四个字段分别记录错题所属科目、题目内容摘要、错误原因标签和收录时间。reason 字段是错因分类的核心,帮助考生在复盘时快速归类薄弱知识点——知道自己为什么错比知道错了什么更重要。
const MISTAKE_LIST: MistakeItem[] = [
new MistakeItem('考研数学一', '2025 卷 T19 二重积分换序', '计算失误', '今天 11:20'),
new MistakeItem('考研数学一', '2024 卷 T12 级数收敛域', '概念混淆', '昨天 20:46'),
new MistakeItem('英语一', '2025 卷 阅读 Text3 第 35 题', '主观臆断', '昨天 16:02'),
new MistakeItem('政治', '2025 卷 马原多选第 27 题', '选项漏选', '08-27 19:33'),
new MistakeItem('408 计算机', '2024 卷 数据结构 邻接矩阵', '公式记错', '08-26 14:18'),
new MistakeItem('管综逻辑', '2025 卷 削弱型论证 T18', '偷换概念', '08-25 21:50'),
new MistakeItem('英语一', '2024 卷 完形填空 第 12 空', '词义辨析', '08-24 09:27')
];
MISTAKE_LIST 预置了 7 条错题 Mock 数据,覆盖五个科目和多种错因类型。错因标签的设计是这组数据的亮点,7 条错题对应 7 种不同的错因:
- 计算失误:数学题常见错误,会做但算错,属于熟练度问题。
- 概念混淆:对知识点的理解有偏差,属于概念掌握不牢。
- 主观臆断:英语阅读常见错误,过度推断原文未提及的内容。
- 选项漏选:政治多选题常见错误,漏选正确选项。
- 公式记错:计算机/数学常见错误,记忆偏差导致应用错误。
- 偷换概念:逻辑题常见错误,被选项中的概念替换误导。
- 词义辨析:英语完形填空常见错误,近义词区分不清。
这些错因标签对应了考研各科目中最常见的失分模式,帮助考生精准定位薄弱环节,从而有针对性地进行强化训练。错题的时间线从今天到 08-24 倒序排列,与其他列表的时间排序一致。
七、组件主体结构
7.1 组件声明与状态变量分层
@Entry
@Component
struct Page1254 {
/** 当前选中 Tab 索引 */
@State currentTab: number = 0;
/** 呼吸动画开关(每秒翻转,联动柱状图柱高波动) */
@State breath: boolean = false;
/** 呼吸动画定时器句柄 */
@State timer: number = -1;
/** 题库 Tab 科目 chips 选中索引(0=全部) */
@State cateIdx: number = 0;
/** 新增真题弹窗开关 */
@State addModal: boolean = false;
/** 编辑完成度弹窗开关 */
@State editModal: boolean = false;
/** 删除真题确认弹窗开关 */
@State delModal: boolean = false;
/** 当前编辑的真题索引 */
@State editIdx: number = 0;
/** 当前删除的真题索引 */
@State delIdx: number = 0;
主页面组件使用 @Entry 和 @Component 装饰器声明。@Entry 表示这是页面的入口组件,即用户打开页面时首先加载的组件;@Component 表示这是一个可复用的 UI 组件。组件名为 Page1254,采用大驼峰命名法,与 ArkUI 组件命名规范一致。
状态变量按功能分为七大组,这里展示的是前两组:
第一组:Tab 切换与呼吸动画状态(3 个变量)
currentTab:当前选中的 Tab 索引,默认值为 0(题库 Tab),是整个页面导航的核心状态。breath:呼吸动画开关,布尔值,默认 false,每秒翻转一次,联动柱状图柱高波动。timer:呼吸动画定时器句柄,默认值 -1(表示未启动),在aboutToAppear中赋值,aboutToDisappear中清理。
第二组:题库筛选与弹窗状态(6 个变量)
cateIdx:题库 Tab 科目分类 chips 的选中索引,默认 0(全部),驱动真题清单的筛选过滤。addModal、editModal、delModal:三个弹窗的开关状态,均为布尔值,默认 false(关闭)。editIdx、delIdx:当前编辑和删除的真题索引,默认 0,在打开弹窗时设置对应值。
/** 真题试卷清单数据 */
@State paperList: PaperItem[] = PAPER_LIST;
/** 下载记录列表数据(onDownloadFinish 回调 unshift 置顶) */
@State downloadRecords: DownloadRecord[] = DOWNLOAD_RECORDS;
/** 学习提醒时间轴数据 */
@State remindList: RemindItem[] = REMIND_LIST;
/** 铃声库数据 */
@State ringList: RingItem[] = RING_LIST;
/** 通知历史数据 */
@State noticeLogs: NoticeLog[] = NOTICE_LOGS;
/** 错题本数据 */
@State mistakeList: MistakeItem[] = MISTAKE_LIST;
第三组:数据列表状态(6 个变量)
这六个数组变量分别对应六个 @Observed 数据模型,初始值来自同名的 Mock 数据常量。每个数组支撑一个 Tab 的列表渲染:paperList → 题库 Tab、downloadRecords → 下载 Tab、remindList → 提醒 Tab、ringList → 铃音 Tab、noticeLogs → 提醒 Tab(通知历史)、mistakeList → 我的 Tab(错题本)。
使用 @State 装饰器修饰数组,意味着数组引用的变化会触发 UI 刷新。但由于数组元素是 @Observed 类的实例,元素属性的变化也能被感知。不过在实际代码中,为了确保 ForEach 列表整体刷新(特别是新增和删除操作),普遍采用 this.xxxList = this.xxxList.slice() 的方式创建新数组引用——这是 ArkUI 响应式系统驱动列表重绘的标准模式。
/** 新增表单:科目 */
@State formSubject: string = '';
/** 新增表单:年份卷 */
@State formYear: string = '';
/** 新增表单:题量 */
@State formCount: string = '';
/** 新增表单:完成度(滑杆 0~100) */
@State formDone: number = 0;
/** 编辑表单:完成度(滑杆 0~100) */
@State editDone: number = 0;
第四组:表单状态(5 个变量)
表单状态变量用于新增和编辑弹窗中的输入控件双向绑定。formSubject、formYear、formCount、formDone 四个变量对应新增真题弹窗的四个输入字段,editDone 对应编辑完成度弹窗的滑杆值。初始值分别为空字符串和 0,保存后清空重置。
// --- ArkWeb 状态(6.1.1 特性:下载双 URL 溯源) ---
/** Web 控制器(加载页面 + 绑定下载代理 + 主动发起下载) */
private webController: webview.WebviewController = new webview.WebviewController();
/** 下载代理(四个回调:开始前 / 进行中 / 失败 / 完成) */
private downloadDelegate: webview.WebDownloadDelegate = new webview.WebDownloadDelegate();
/** 地址栏输入值(敲字不等于加载,urlInput 与 webUrl 双状态分离) */
@State urlInput: string = QUICK_SITES[0].url;
/** Web 组件实际加载值(点"前往"校验协议后才更新) */
@State webUrl: string = QUICK_SITES[0].url;
/** 当前下载文件名 */
@State dlName: string = '';
/** 当前下载进度(0~100) */
@State dlPercent: number = 0;
/** 下载状态文案(空闲 / 正在下载 x% / 下载完成 / 下载失败) */
@State dlState: string = '空闲';
第五组:ArkWeb 状态(7 个变量)
ArkWeb 相关的状态变量是三大特性中最多的一组,涵盖了网页加载和下载管理的全流程:
webController:Web 组件的控制器,使用private修饰符和webview.WebviewController类型,用于加载页面、绑定下载代理、主动发起下载。它不是@State变量,因为控制器对象本身不需要响应式变化。downloadDelegate:下载代理对象,同样是private非响应式变量,用于注册四个下载生命周期回调。urlInput和webUrl:双状态分离设计——urlInput绑定 TextInput 的输入值,webUrl绑定 Web 组件的 src 属性。用户敲字不等于加载,只有点击"前往"并校验通过后才更新webUrl,避免输入过程中频繁触发网页加载。初始值均为第一个快捷站点的 URL。dlName、dlPercent、dlState:下载任务的三个状态变量,分别记录文件名、进度百分比和状态文案,实时反映下载进度。
// --- Notification 状态(6.1.1 特性:沙箱自定义铃声) ---
/** 通知授权状态(aboutToAppear 中 isNotificationEnabled 查询) */
@State granted: boolean = false;
/** 通知 id 自增计数器(相同 id 会覆盖上一条通知) */
@State notifyId: number = 100;
/** 已发布学习提醒条数 */
@State noticeCount: number = 3;
/** 当前默认铃声索引(发布通知时 sound 取该铃声沙箱 uri) */
@State currentRingIdx: number = 0;
/** 铃声生成器:当前频率(Hz) */
@State genFreq: number = 880;
/** 铃声生成器:当前时长(ms) */
@State genDuration: number = 1200;
/** 已写入沙箱的铃声数 */
@State sandboxCount: number = 0;
第六组:Notification 状态(7 个变量)
Notification 相关的状态变量涵盖了通知授权、铃声管理和铃声生成三大功能:
granted:通知授权状态,默认 false,在aboutToAppear中通过isNotificationEnabled()查询真实状态。notifyId:通知 ID 自增计数器,从 100 开始。每次发布通知时递增,相同 ID 的通知会覆盖上一条。noticeCount:已发布通知条数,默认 3(与 Mock 数据条数一致)。currentRingIdx:当前默认铃声的索引,默认 0(开考铃)。发布通知时使用该铃声作为 sound。genFreq、genDuration:铃声生成器的当前参数,默认 880Hz 和 1200ms。sandboxCount:已写入沙箱的铃声数量,默认 0,每次导入沙箱时递增。
// --- Canvas 状态(题库 Tab 刷题量渐变柱状图) ---
/** 柱状图 Canvas 就绪标志(onReady 后才允许定时器重绘) */
@State canvasReady: boolean = false;
/** 柱状图 Canvas 上下文(private,不用 @State) */
private barCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));
第七组:Canvas 状态(2 个变量)
Canvas 相关的状态变量最少但设计精巧:
canvasReady:Canvas 就绪标志,默认 false。只有在 Canvas 组件的onReady回调触发后才设为 true,此时呼吸动画定时器才会调用drawBar()重绘。这种设计防止了 Canvas 尚未初始化就尝试绘制的错误。barCtx:Canvas 2D 渲染上下文,使用private修饰符,不是@State变量。上下文对象只需创建一次,不需要响应式变化。构造参数new RenderingContextSettings(true)表示启用抗锯齿。
7.2 ArkWeb 下载代理初始化
setupDownloadDelegate() {
// 下载开始前:必须调用 start() 提供沙箱路径,否则任务永远停在 PENDING
this.downloadDelegate.onBeforeDownload((item: webview.WebDownloadItem) => {
const hostCtx = this.getUIContext().getHostContext();
const dir = hostCtx ? hostCtx.filesDir : '';
this.dlName = item.getSuggestedFileName();
this.dlPercent = 0;
this.dlState = '已开始';
item.start(dir + '/' + item.getSuggestedFileName());
});
setupDownloadDelegate 方法注册了 WebDownloadDelegate 的四个回调,构成完整的下载生命周期链。第一个回调 onBeforeDownload 在下载开始前触发,是下载流程的入口点。
这个回调的核心职责是提供保存路径并启动下载。通过 this.getUIContext().getHostContext() 获取宿主上下文,然后读取 filesDir 属性得到应用沙箱文件目录。如果获取失败(如上下文尚未初始化),则使用空字符串兜底。回调中同时初始化了下载状态:设置文件名为建议文件名、进度归零、状态文案改为"已开始"。
最关键的一行是 item.start(dir + '/' + item.getSuggestedFileName())——必须调用 start() 方法并传入完整的保存路径,下载任务才会真正开始。如果不调用 start(),下载任务将永远停在 PENDING(待处理)状态。这是 HarmonyOS 的安全设计:下载路径不由系统自动决定,而由应用显式提供并限制在沙箱范围内,防止恶意下载行为覆盖系统文件或用户隐私数据。
// 下载进行中:刷新进度条与百分比文案
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;
});
第二个回调 onDownloadUpdated 在下载进行中持续触发,频率较高,用于实时刷新进度条和百分比文案。通过 item.getPercentComplete() 获取当前完成百分比(0~100),同时更新状态文案为"正在下载 x%"。
第三个回调 onDownloadFailed 在下载失败时触发,将状态文案设为"下载失败 · GUID"(GUID 是下载任务的唯一标识符),并将进度清零。这里展示失败的 GUID 有助于调试——开发者可以通过 GUID 追踪具体是哪个下载任务失败了。
// 下载完成:★ 6.1.1 新特性——getOriginalUrl + getReferrerUrl 双 URL 溯源
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;
});
第四个回调 onDownloadFinish 是整个下载代理中最重要的回调,也是 HarmonyOS 6.1.1 新特性的核心展示点。在下载完成时,通过两个新增接口获取双 URL 溯源信息:
getOriginalUrl():返回下载项的原始 URL,即文件本身的直链地址。这个 URL 指向 CDN 或文件服务器上的具体文件,通常带有追踪参数。getReferrerUrl():返回引用页的 URL,即用户是从哪个页面点击的下载链接。这个 URL 指向的是包含下载链接的网页。
获取到双 URL 后,创建新的 DownloadRecord 对象并 unshift 置顶到下载记录列表。文件大小通过 item.getTotalBytes() / 1048576 计算(1MB = 1024KB = 1048576 字节),并用 Math.round 取整。时间标记为"刚刚",因为是刚完成的下载。最后更新状态文案和进度为完成态。
// 绑定到 controller:网页内触发的下载才会进入上述回调(try-catch 消除抛错告警)
try {
this.webController.setDownloadDelegate(this.downloadDelegate);
} catch (error) {
console.error(`ErrorCode: ${(error as BusinessError).code}, Message: ${(error as BusinessError).message}`);
}
}
方法的最后一步是将下载代理绑定到 Web 控制器。只有绑定之后,网页内触发的下载行为才会进入上述四个回调。使用 try-catch 包裹是为了捕获可能的 BusinessError 异常——在某些环境下(如预览模式、Web 组件尚未初始化完成)调用 setDownloadDelegate 可能会失败,捕获异常并打印错误日志可以避免应用崩溃,同时便于调试。
7.3 地址栏与主动下载
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 方法处理地址栏"前往"操作,包含三步逻辑:第一步,去除输入值两端的空白字符,如果为空则直接返回不做处理;第二步,校验协议前缀——如果 URL 既不以 https:// 开头也不以 http:// 开头,则自动补 https:// 前缀,确保加载安全协议;第三步,同时更新 urlInput 和 webUrl 两个状态变量——更新 urlInput 是为了让输入框显示补全后的完整 URL,更新 webUrl 是为了触发 Web 组件加载新页面。
这里采用 urlInput 与 webUrl 双状态分离设计是前端状态管理的经典模式。如果只用一个状态变量同时绑定输入框和 Web 组件,那么用户每敲一个字都会触发 Web 组件尝试加载一个不完整的 URL,不仅浪费资源,还可能导致页面闪烁和错误。双状态分离后,输入过程中只有 urlInput 变化,Web 组件不受影响;只有用户点击"前往"确认后,webUrl 才更新,页面才真正加载。
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 参数,通过 webController.startDownload(url) 直接触发下载。
方法内部做了几项准备工作:从 URL 中提取文件名(取最后一个斜杠之后的部分)、初始化进度为 0、设置状态为"已发起下载请求"。这些状态更新在调用 startDownload 之前完成,让用户立即看到反馈。
整个方法用 try-catch 包裹 BusinessError 异常。如果下载发起失败(如 URL 格式错误、网络不可用等),则将状态文案设为"发起失败 + 错误码",让用户知道出了问题。这种防御性编程确保了即使在异常情况下,UI 也能给出明确的反馈而非静默失败。
7.4 通知授权与铃声沙箱化
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 方法请求通知授权,采用"首次弹窗 + 失败跳转设置页"的二级授权策略。首先获取 UIAbilityContext 上下文,如果获取失败则直接返回。然后调用 requestEnableNotification 请求授权:
- 如果用户同意授权(
then分支),将granted设为 true。 - 如果用户拒绝授权或之前已经拒绝过(
catch分支),则调用openNotificationSettings打开系统通知设置页面,引导用户手动开启授权。如果打开设置页也失败,则将granted设为 false。
这种二级授权策略是通知权限请求的最佳实践。第一次调用会弹出系统授权对话框,用户可以直接选择允许或拒绝。如果用户拒绝了,再次调用时不会再弹框(系统限制),此时跳转到设置页让用户手动操作,提供了二次授权的路径。
saveRingToSandbox(fileName: string, freq: number, durationMs: number): string {
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) {
return '';
}
const appCtx = hostCtx.getApplicationContext();
appCtx.area = contextConstant.AreaMode.EL1; // 通知铃声音频必须位于 EL1 区域
const dir = appCtx.filesDir;
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 方法将生成的 WAV 音频写入沙箱 EL1 的 files 目录,是铃声沙箱化链路的核心方法。方法名中的"EL1"非常关键——通知铃声音频必须位于设备级加密区域 EL1,而不能位于应用级区域 EL2,这是 HarmonyOS 系统安全策略对音频文件可访问范围的约束。
方法执行流程如下:
- 获取应用上下文,将
area设置为EL1(加密等级 1,设备级加密区域)。 - 获取 EL1 区域的
filesDir路径,拼接出完整文件路径。 - 调用
buildWavBytes生成 WAV 音频字节数据。 - 使用
fs.openSync以创建 + 只写 + 截断模式打开文件。 - 使用
fs.writeSync将音频数据写入文件。 - 使用
fs.closeSync关闭文件句柄。 - 返回文件的完整沙箱路径。
整个文件操作使用 try-catch 包裹,如果写入失败(如权限不足、磁盘空间不够等),则静默忽略——这样设计是因为铃声是锦上添花的功能,即使生成失败也不影响通知的基本功能(会回退到系统默认铃声)。
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++;
this.ringList = this.ringList.slice();
}
importRingToSandbox 方法将铃声库中指定索引的铃声导入沙箱。方法首先获取铃声对象,调用 saveRingToSandbox 写入文件,然后更新铃声的状态:将 inSandbox 设为 true,计算文件大小并更新 size 字段。
文件大小的计算很有意思——它不是从文件系统读取的实际大小,而是通过公式计算的理论大小:(44 + Math.floor(44100 * r.duration / 1000) * 2) / 1024。其中 44 是 WAV 文件头的字节数,Math.floor(44100 * r.duration / 1000) * 2 是采样数据的字节数(采样数 × 2 字节/采样),除以 1024 转换为 KB,最后用 Math.round 取整。这种计算方式避免了写入后再读取文件大小的额外操作,因为生成的文件大小是完全可预测的。
最后递增 sandboxCount 计数,并通过 this.ringList.slice() 创建新数组引用刷新列表。
createRingByGen() {
const seq = this.ringList.length + 1;
const ring = new RingItem('自定义铃声' + seq, 'ring_custom_' + seq + '.wav',
this.genFreq, this.genDuration, '—', false);
this.ringList.push(ring);
this.importRingToSandbox(this.ringList.length - 1);
}
createRingByGen 方法用铃声生成器的当前参数创建新铃声并立即写入沙箱。方法首先生成序列号(当前铃声数 + 1),然后用序列号命名新铃声(“自定义铃声N"和"ring_custom_N.wav”),将新铃声追加到铃声库,最后调用 importRingToSandbox 立即导入沙箱。这个方法模拟了"用户生成的音频文件"的完整流程——从参数选择到音频生成再到沙箱落盘,一步到位。
setCurrentRing(idx: number) {
if (!this.ringList[idx].inSandbox) {
this.importRingToSandbox(idx);
}
this.currentRingIdx = idx;
}
setCurrentRing 方法设置默认通知铃声。方法设计了一个智能兜底:如果目标铃声尚未写入沙箱,则先自动导入沙箱,再设置为默认。这样即使用户直接点击"设为默认"而没有先生成,也能正常工作。这种"自动兜底"的设计减少了用户的操作步骤,提升了体验。
getSoundValue(): string {
const ring = this.ringList[this.currentRingIdx];
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) {
return 'uri::';
}
const appCtx = hostCtx.getApplicationContext();
appCtx.area = contextConstant.AreaMode.EL1;
const path = appCtx.filesDir + '/' + ring.file;
return 'uri::' + fileUri.getUriFromPath(path);
}
getSoundValue 方法构造通知 sound 字段的值,是沙箱自定义铃声特性的关键转换点。方法首先获取当前默认铃声,然后切换到 EL1 区域获取文件路径,最后通过 fileUri.getUriFromPath(path) 将文件路径转换为 URI,并在前面加上 'uri::' 前缀。
这个 'uri::' 前缀是 HarmonyOS 6.1.1 通知 sound 字段支持沙箱自定义铃声的协议格式。它告诉通知系统:后面的值是一个 URI 而不是系统铃声名称,系统应该从这个 URI 指向的文件读取音频数据。如果没有这个前缀,系统会尝试将字符串解析为系统铃声名称,导致播放失败。
7.5 通知发布与真题增删改
publishNotice(title: string, text: string) {
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: title,
text: text,
additionalText: '自定义铃声:' + ringName
}
},
sound: soundVal // ★ HarmonyOS 6.1.1:支持应用沙箱 EL1 内的音频路径
};
publishNotice 方法发布携带沙箱自定义铃声的学习提醒通知,是 Notification Kit 沙箱铃声特性的最终展示。方法首先检查当前默认铃声是否已入沙箱,没有则先导入。然后构造 NotificationRequest 请求对象,这是通知发布的核心数据结构。
请求对象包含几个关键字段:
id:通知 ID,使用自增计数器notifyId。相同 ID 的通知会覆盖上一条,自增可以确保每次发布都是新通知。notificationSlotType:通知槽类型,设为SOCIAL_COMMUNICATION(社交通信)。不同的槽类型有不同的通知行为和展示样式。content:通知内容,类型为基本文本(NOTIFICATION_CONTENT_BASIC_TEXT),包含标题、正文和附加文本。sound:通知铃声,填入getSoundValue()返回的'uri::' + uri,这是 HarmonyOS 6.1.1 的新特性——支持应用沙箱 EL1 内的音频路径作为通知铃声。
notificationManager.publish(request).then(() => {
this.noticeCount++;
this.noticeLogs.unshift(new NoticeLog(title, text, '刚刚'));
this.noticeLogs = this.noticeLogs.slice();
}).catch((err: BusinessError) => {
if (err.code === 1600004) {
this.requestAuth();
}
});
}
调用 notificationManager.publish(request) 发布通知。成功后(then 分支)递增通知计数、将通知记录 unshift 到历史列表顶部,并通过 slice() 刷新数组引用驱动列表重绘。
失败时(catch 分支)检查错误码——如果是 1600004(未授权错误),则自动调用 requestAuth() 引导用户重新授权。这体现了防御性编程的思维:通知发布失败不应让用户困惑,而应主动引导解决权限问题。1600004 是 HarmonyOS 中"通知未启用"的标准错误码,针对这个特定错误码做特殊处理是精准的错误恢复策略。
toggleRemind(idx: number, isOn: boolean) {
this.remindList[idx].on = isOn;
this.remindList = this.remindList.slice();
}
onRemindCount(): number {
let cnt: number = 0;
for (const r of this.remindList) {
if (r.on) {
cnt++;
}
}
return cnt;
}
toggleRemind 方法切换学习提醒的开关状态。方法接收索引和新的开关状态,修改数组中对应元素的 on 属性,然后通过 slice() 刷新数组引用。虽然 @Observed 装饰器理论上可以感知对象属性变化,但为了确保 ForEach 列表整体刷新(包括时间轴圆点颜色的变化),这里采用了整体刷新数组引用的稳妥方式。
onRemindCount 方法统计已开启的学习提醒数量,用于时间轴标题右侧的计数显示。方法通过遍历数组统计 on 为 true 的元素个数。这是一个纯计算函数,每次渲染都会调用,因此逻辑要尽量简单——这里用 for 循环而非 filter 是为了避免创建新数组带来的额外开销。
openEditPaper(idx: number) {
this.editIdx = idx;
this.editDone = this.paperList[idx].done;
this.editModal = true;
}
savePaper() {
const subject = this.formSubject === '' ? '考研数学一' : this.formSubject;
const year = this.formYear === '' ? '2026 卷' : this.formYear;
const count = this.formCount === '' ? '23 题' : this.formCount;
this.paperList.unshift(new PaperItem(subject, year, count, this.formDone));
this.paperList = this.paperList.slice();
this.formSubject = '';
this.formYear = '';
this.formCount = '';
this.formDone = 0;
this.addModal = false;
}
openEditPaper 方法打开编辑完成度弹窗,做了三件事:记录当前编辑的索引、回填当前完成度到滑杆、打开编辑弹窗。回填是很重要的用户体验细节——用户打开编辑弹窗时应该看到当前的完成度值,而不是从零开始。
savePaper 方法保存新增的真题试卷。方法首先对三个文本输入字段做兜底处理:如果为空则使用默认值(科目兜底"考研数学一"、年份兜底"2026 卷"、题量兜底"23 题")。这种兜底设计避免了空数据的产生,即使用户什么都不填直接保存,也能得到一条合理的记录。然后将新条目 unshift 置顶到列表,刷新数组引用,清空表单字段,关闭弹窗。
updatePaper() {
if (this.editIdx >= 0 && this.editIdx < this.paperList.length) {
this.paperList[this.editIdx].done = this.editDone;
this.paperList = this.paperList.slice();
}
this.editModal = false;
}
delPaper() {
if (this.delIdx >= 0 && this.delIdx < this.paperList.length) {
this.paperList.splice(this.delIdx, 1);
this.paperList = this.paperList.slice();
}
this.delModal = false;
}
updatePaper 方法保存编辑后的完成度。方法先做索引边界检查(防止索引越界错误),然后将 editDone 写入指定索引的 done 字段,刷新数组引用后关闭弹窗。
delPaper 方法删除指定的真题试卷。同样先做边界检查,然后通过 splice 方法删除指定索引的元素,刷新数组引用后关闭弹窗。splice(idx, 1) 表示从索引 idx 开始删除 1 个元素。
这三个 CRUD 方法(增/改/删)都遵循了"操作数据 → 刷新引用 → 关闭弹窗"的标准流程,是 ArkUI 列表操作的典型范式。
7.6 生命周期方法
aboutToAppear() {
this.setupDownloadDelegate();
notificationManager.isNotificationEnabled().then((enabled: boolean) => {
this.granted = enabled;
}).catch(() => {
});
this.timer = setInterval(() => {
this.breath = !this.breath;
if (this.canvasReady) {
this.drawBar();
}
}, 1000);
}
aboutToAppear 是组件即将出现时的生命周期回调,在这里完成三项初始化工作:
第一,调用 setupDownloadDelegate() 注册下载代理四回调,确保网页内任何下载行为都能被捕获。这一步必须在 Web 组件加载页面之前完成,否则页面内的下载可能无法被代理捕获。
第二,调用 notificationManager.isNotificationEnabled() 异步查询通知授权状态。这是一个异步操作,返回 Promise,成功时将结果写入 granted 状态变量。查询结果决定了提醒 Tab 中授权状态卡和按钮的展示形态——如果已授权显示"已授权"和绿色标签,如果未授权显示"未授权"和橙色标签及授权按钮。
第三,启动 setInterval 呼吸动画定时器,间隔为 1000 毫秒(1 秒)。定时器回调中做两件事:翻转 breath 布尔状态(true ↔ false),以及如果 Canvas 已就绪(canvasReady 为 true)则调用 drawBar() 重绘柱状图。canvasReady 检查是必要的——如果 Canvas 还没初始化好就调用绘制方法,会导致错误。
aboutToDisappear() {
clearInterval(this.timer);
}
aboutToDisappear 是组件即将消失时的生命周期回调,在这里清理定时器。调用 clearInterval(this.timer) 停止呼吸动画定时器,防止组件销毁后定时器继续执行导致内存泄漏。
这对生命周期回调的配对使用是 ArkUI 组件开发的标准范式——aboutToAppear 负责"进来时初始化",aboutToDisappear 负责"离开时清理"。遵循这个范式可以有效避免内存泄漏和资源浪费等问题。特别是定时器、事件监听、订阅等需要手动清理的资源,一定要在 aboutToDisappear 中释放。
7.7 根构建方法
build() {
Stack() {
Column() {
this.headerMain()
Divider().strokeWidth(1).color(COLORS.line)
Scroll() {
Column() {
if (this.currentTab === 0) {
this.tabPaper()
} else if (this.currentTab === 1) {
this.tabWeb()
} else if (this.currentTab === 2) {
this.tabDownload()
} else if (this.currentTab === 3) {
this.tabRemind()
} else if (this.currentTab === 4) {
this.tabRing()
} else {
this.tabMine()
}
}
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
this.tabBar()
}
.width('100%')
.height('100%')
根构建方法 build() 是整个页面的 UI 入口,采用 Stack 容器实现层叠布局。Stack 的底层是一个 Column 纵向布局,包含四个部分:头部横幅、分割线、滚动内容区、底部 Tab 栏。
内容区使用 Scroll 组件包裹,通过 layoutWeight(1) 占据剩余空间。scrollBar(BarState.Off) 隐藏滚动条,让界面更简洁。Scroll 内部的 Column 根据 currentTab 的值条件渲染对应的 Tab 内容——使用 if/else if 链而非 ForEach,因为每个 Tab 的内容结构完全不同,无法用统一的数据驱动。
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)
}
Stack 的顶层是三个弹窗,通过对应的布尔状态变量条件渲染。每个弹窗都接收一个 onClose 闭包参数,用于关闭弹窗——闭包中将对应的布尔状态设为 false。三个弹窗互不干扰,可以独立开关。
整个 Stack 设置 width('100%') 和 height('100%') 占满全屏,背景色为浅海雾蓝 COLORS.bg。这种 Stack 层叠布局是实现弹窗系统的经典模式——底层是正常内容,顶层是模态遮罩和弹窗面板,弹窗打开时覆盖在内容之上,遮罩层阻挡对下方内容的交互。
八、头部渐变横幅详解
@Builder
headerMain() {
Column({ space: 12 }) {
// 渐变 Banner:品牌 + 倒计时
Column({ space: 8 }) {
Row() {
Text('🌊 知识冲浪').fontSize(18).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('距 2027 初试 108 天').fontSize(9).fontColor(COLORS.blueD)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(COLORS.white).borderRadius(11)
}
.width('100%')
Text('真题为岸 · 每一套都值得溯源收藏').fontSize(11).fontColor(COLORS.whiteSoft)
Row({ space: 8 }) {
Text('今日刷题 86 题').fontSize(9).fontColor(COLORS.whiteSoft)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).backgroundColor(COLORS.trackW).borderRadius(9)
Text('待完成 4 套').fontSize(9).fontColor(COLORS.whiteSoft)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).backgroundColor(COLORS.trackW).borderRadius(9)
Text('连续打卡 46 天').fontSize(9).fontColor(COLORS.whiteSoft)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).backgroundColor(COLORS.trackW).borderRadius(9)
}
.width('100%')
}
.width('100%')
.padding(14)
.borderRadius(14)
.linearGradient({ angle: 120, colors: [[COLORS.blueD, 0], [COLORS.blue, 0.65], [COLORS.blueD, 1]] })
头部由渐变横幅和搜索条两部分组成,使用 Column 纵向排列,间距 12px。渐变横幅是整个页面最醒目的视觉元素,使用 120° 线性渐变,从深海蓝 blueD 经海岸蓝 blue 再回到深海蓝 blueD,形成中间亮两头暗的海岸光线效果。
横幅内部分三层:
- 顶层 Row:左侧是品牌名"🌊 知识冲浪",18px 加粗白字;中间用
layoutWeight(1)的空 Column 撑开;右侧是初试倒计时胶囊——白底蓝字圆角胶囊,显示"距 2027 初试 108 天",营造备考紧迫感。 - 中层:品牌副语"真题为岸 · 每一套都值得溯源收藏",使用
whiteSoft弱化白色(82% 不透明度),降低视觉权重。 - 底层 Row:三枚备考数据胶囊,分别展示"今日刷题 86 题"“待完成 4 套”“连续打卡 46 天”,均使用半透明白
trackW(32% 不透明度)作为底色,白色弱化文字,在深蓝渐变背景上保持清晰可读。
整个渐变横幅使用 14px 内边距和 14px 圆角,营造卡片感。渐变色标设置了三个停靠点:0% 位置为深海蓝、65% 位置为海岸蓝、100% 位置为深海蓝。这种"深—浅—深"的渐变模式模拟了海面阳光照射的效果——中间最亮,向两侧逐渐变暗,增加视觉层次感。
// 搜索条 + 新增真题按钮
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.card).borderRadius(17)
.onClick(() => {
this.currentTab = 1;
})
Text('+ 新增真题').fontSize(10).fontColor(COLORS.white)
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
.backgroundColor(COLORS.blue).borderRadius(17)
.onClick(() => {
this.addModal = true;
})
}
.width('100%')
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 10 })
.backgroundColor(COLORS.bg)
}
搜索条部分是"伪输入条 + 新增按钮"的组合。左侧的搜索条并不是真正的 TextInput,而是一个 Row 组合(放大镜图标 + 提示文字),点击后跳转到网页 Tab。这种设计是因为搜索功能实际上是由网页 Tab 的 Web 组件提供的,搜索条只是一个入口触发器。伪输入条使用纯白底色、34px 高度、17px 圆角(高度的一半),形成胶囊形状。
右侧的"+ 新增真题"按钮是海岸蓝实底白字,圆角同样是 17px,与搜索条高度一致。点击按钮打开新增真题弹窗。搜索条和按钮之间留有 8px 间距。
整个头部区域使用 14px 左右内边距、12px 顶部内边距、10px 底部内边距,背景色与页面背景一致(COLORS.bg)。这样的内边距设置让头部内容与屏幕边缘保持合理距离,同时与下方内容区形成视觉分隔。
九、各 Tab 深度解析
9.1 题库 Tab:科目筛选与 Canvas 柱状图
@Builder
tabPaper() {
Column({ space: 12 }) {
// 科目分类横滚 chips
Scroll() {
Row({ space: 8 }) {
ForEach(SUBJECT_TAGS, (tag: string, idx: number) => {
Text(tag).fontSize(10)
.fontColor(this.cateIdx === idx ? COLORS.white : COLORS.sub)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.cateIdx === idx ? COLORS.blue : COLORS.card)
.borderRadius(13)
.onClick(() => {
this.cateIdx = idx;
})
}, (tag: string) => tag)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
题库 Tab 自上而下包含四个区块,各区块间距 12px。第一个区块是科目分类横滚 chips,使用 Scroll 横向滚动容器 + Row + ForEach 渲染六个科目标签。
每个 chip 是一个 Text 组件,选中态(this.cateIdx === idx)为白字蓝底,未选中态为灰字白底。通过 this.cateIdx === idx 的三元表达式实现动态样式切换。chip 的圆角为 13px,上下内边距 6px,左右内边距 12px,形成圆角矩形胶囊形状。
点击 chip 时更新 cateIdx 状态,触发 visiblePapers() 重新筛选真题清单。ForEach 的第三个参数(键生成函数)使用 tag 字符串作为唯一标识,确保列表刷新时的正确复用。
// 三枚统计小卡
Row({ space: 8 }) {
Column({ space: 4 }) {
Text('1344').fontSize(16).fontColor(COLORS.blueD).fontWeight(FontWeight.Bold)
Text('累计刷题(题)').fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.card).borderRadius(12)
Column({ space: 4 }) {
Text('8').fontSize(16).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('真题卷(套)').fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.card).borderRadius(12)
Column({ space: 4 }) {
Text('76%').fontSize(16).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
Text('平均完成度').fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.card).borderRadius(12)
}
.width('100%')
第二个区块是三枚统计小卡,横向排列,使用 layoutWeight(1) 等分宽度。每张卡片是一个 Column,包含数字(16px 加粗)和标签(8px 弱文本),垂直居中。
三张卡片的数字分别使用三种不同的主题色,形成三色对比:
- 累计刷题 1344 题:深海蓝
blueD,代表核心学习量 - 真题卷 8 套:珊瑚橙
orange,代表资料数量 - 平均完成度 76%:海藻绿
green,代表学习进度
三种颜色分别对应三个数据维度,让考生一眼就能快速了解备考概况。卡片使用纯白底色和 12px 圆角,与页面背景形成层次。
// 真题清单(点击行编辑完成度,长按行删除)
Column({ space: 10 }) {
Row() {
Text('📄 真题试卷清单').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(this.visiblePapers().length + ' 套 · 点击编辑 / 长按删除').fontSize(8).fontColor(COLORS.text3)
}
.width('100%')
ForEach(this.visiblePapers(), (item: PaperItem, idx: number) => {
this.paperRow(item, idx)
}, (item: PaperItem) => item.subject + item.year)
}
.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
第三个区块是真题试卷清单,是题库 Tab 的核心内容。清单使用 Column 容器包裹,内部包含标题行和列表。标题行左侧是清单标题(带 📄 图标),右侧是套数和操作提示(“x 套 · 点击编辑 / 长按删除”),提示用户两种交互方式。
列表通过 ForEach 遍历 visiblePapers() 返回的筛选后数组。visiblePapers() 是一个计算方法,根据 cateIdx 筛选对应科目的真题——首项"全部"不过滤,其余按科目关键词匹配。ForEach 的键生成函数使用 item.subject + item.year 的组合,确保每条记录的唯一性。
清单卡片使用纯白底色、12px 内边距和 12px 圆角,与统计卡的视觉风格一致。
// Canvas 刷题量渐变柱状图卡
this.barChartCard()
}
.width('100%')
}
第四个区块是 Canvas 刷题量渐变柱状图卡,通过调用 barChartCard() Builder 方法渲染。这是题库 Tab 的特色功能,也是三大特性之一 Canvas 绘制的展示区域。
9.1.1 真题清单行 paperRow
@Builder
paperRow(item: PaperItem, idx: number) {
Row({ space: 10 }) {
// 左侧年份色块(科目主题色)
Column() {
Text(item.year.replace(' 卷', '')).fontSize(11).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
Text('卷').fontSize(7).fontColor(COLORS.whiteSoft)
}
.width(44).height(44).borderRadius(10)
.justifyContent(FlexAlign.Center)
.backgroundColor(subjectColor(item.subject))
真题清单行 paperRow 采用三段式布局,每行间距 10px。左侧是 44x44px 的年份色块,使用 subjectColor(item.subject) 动态设置背景色——不同科目显示不同颜色,实现"一科一色"的视觉区分。
色块内部是一个 Column,垂直居中排列两行文字:上行是年份数字(去掉" 卷"后缀,只保留年份),11px 加粗白字;下行是"卷"字,7px 弱化白字。色块圆角 10px,形成柔和的圆角矩形。
// 中部:科目 + 题量 + 完成度进度条
Column({ space: 5 }) {
Text(item.subject + ' · ' + item.year).fontSize(12)
.fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Progress({ value: item.done, total: 100, type: ProgressType.Linear })
.width('100%').height(4)
.color(subjectColor(item.subject)).backgroundColor(COLORS.chip)
Text('共 ' + item.count + ' · 已刷 ' + Math.round(item.done) + '%').fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
中部是信息主体,使用 layoutWeight(1) 占据剩余空间。内部包含三行:
- 科目年份:
item.subject + ' · ' + item.year组合显示,12px 加粗深海墨蓝字,单行截断省略号。 - 进度条:
Progress线性进度条,值为完成度item.done,总进度 100。进度条填充色同样使用subjectColor(item.subject)科目主题色,轨道色为浅云蓝chip。进度条高度仅 4px,纤细精致。 - 题量完成度说明:“共 x 题 · 已刷 y%”,8px 雾蓝灰弱文本。
进度条的颜色与左侧色块颜色一致,形成视觉呼应——同一科目在不同位置使用相同的主题色,强化"一科一色"的识别系统。
// 右侧完成度数字
Column({ space: 2 }) {
Text(item.done.toString() + '%').fontSize(12).fontWeight(FontWeight.Bold)
.fontColor(doneColor(item.done))
Text('完成度').fontSize(7).fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.Center)
}
.width('100%').padding(10)
.backgroundColor(COLORS.chip).borderRadius(10)
.onClick(() => {
this.openEditPaper(idx);
})
.onLongClick(() => {
this.delIdx = idx;
this.delModal = true;
})
}
右侧是完成度数字展示,垂直居中,包含两行:上行是完成度百分比(12px 加粗),下行是"完成度"标签(7px 弱文本)。完成度数字的颜色通过 doneColor(item.done) 动态映射——80% 以上绿色、50% 以上蓝色、偏低橙色,让用户一眼判断完成情况。
整行使用浅云蓝 chip 底色、10px 内边距和 10px 圆角,形成胶囊形状的列表项。行的交互有两种:
- 点击(
onClick):调用openEditPaper(idx)打开编辑完成度弹窗 - 长按(
onLongClick):设置删除索引并打开删除确认弹窗
两种交互的区分是移动端列表操作的经典模式——点击是主要操作(编辑/查看详情),长按是次要操作(删除/更多选项),符合用户的操作习惯预期。
9.1.2 Canvas 柱状图 barChartCard 与 drawBar
@Builder
barChartCard() {
Column({ space: 8 }) {
Row() {
Text('📊 近 6 个月刷题量').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('合计 1344 题').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
Canvas(this.barCtx)
.width('100%')
.height(210)
.onReady(() => {
this.canvasReady = true;
this.drawBar();
})
Text('渐变柱随呼吸动画微幅波动,顶部实时标注刷题量').fontSize(8).fontColor(COLORS.text3)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(12)
}
barChartCard 是 Canvas 柱状图的卡片容器,内部包含标题行、Canvas 组件和说明文字。标题行左侧是图表标题"📊 近 6 个月刷题量",右侧是合计题数"合计 1344 题"。
Canvas 组件绑定 this.barCtx 渲染上下文,宽度 100%、高度 210px。onReady 回调在 Canvas 初始化完成时触发,做两件事:设置 canvasReady 标志为 true(允许呼吸动画定时器重绘),以及首次调用 this.drawBar() 绘制初始图表。
底部说明文字提示用户"渐变柱随呼吸动画微幅波动,顶部实时标注刷题量",解释图表的动态效果。
drawBar() {
const ctx = this.barCtx;
const w = 340;
const h = 210;
const pad = 26;
const labelSpace = 16;
const plotH = h - pad * 2 - labelSpace;
const max = 340;
const n = BRUSH_VAL.length;
const slot = (w - pad * 2) / n;
const barW = 24;
const wave = this.breath ? 1.0 : 0.93;
ctx.clearRect(0, 0, w, h);
const yBase = pad + plotH;
drawBar 方法是 Canvas 绘制的核心实现,绘制 340x210 像素的渐变柱状图。方法开始先定义一系列绘图参数:
w = 340、h = 210:画布宽高pad = 26:左右和上边距labelSpace = 16:底部标签空间plotH = h - pad * 2 - labelSpace:实际绘图区高度max = 340:Y 轴最大值(比数据最大值 324 略大,留出顶部空间)n = BRUSH_VAL.length:数据点数量(6)slot = (w - pad * 2) / n:每根柱子的槽位宽度barW = 24:柱体宽度wave = this.breath ? 1.0 : 0.93:呼吸波动系数——breath 为 true 时 100% 高度,false 时 93% 高度
第一步操作是 ctx.clearRect(0, 0, w, h) 清除整个画布,然后计算 yBase = pad + plotH 即柱体底部的 Y 坐标。
// 背景横向网格线(3 等分)
ctx.strokeStyle = COLORS.line;
ctx.lineWidth = 1;
for (let g = 0; g <= 3; g++) {
const gy = pad + plotH * g / 3;
ctx.beginPath();
ctx.moveTo(pad, gy);
ctx.lineTo(w - pad, gy);
ctx.stroke();
}
第二步绘制三等分背景横向网格线。使用浅雾线色 COLORS.line 和 1px 线宽,通过循环绘制 4 条横线(g=0 到 3),将绘图区垂直分为三等分。网格线为柱体提供高度参照,帮助用户直观判断数值大小。
每条网格线的绘制遵循 Canvas 2D 的标准路径绘制流程:beginPath() 开始新路径、moveTo() 移动到起点、lineTo() 画到终点、stroke() 描边。
// 六根渐变柱:高度 = (值/max)*绘图区高,随 breath 波动
for (let i = 0; i < n; i++) {
const val = Math.round(BRUSH_VAL[i] * wave);
const bh = (val / max) * plotH;
const x = pad + slot * i + (slot - barW) / 2;
const y = yBase - bh;
const grad = ctx.createLinearGradient(x, y, x, yBase);
grad.addColorStop(0, COLORS.blue);
grad.addColorStop(1, COLORS.blueD);
ctx.fillStyle = grad;
ctx.fillRect(x, y, barW, bh);
第三步循环绘制六根渐变柱。每根柱子的计算涉及多个坐标:
val = Math.round(BRUSH_VAL[i] * wave):应用呼吸波动后的数值bh = (val / max) * plotH:柱体高度,按比例换算x = pad + slot * i + (slot - barW) / 2:柱体左侧 X 坐标,确保居中于槽位y = yBase - bh:柱体顶部 Y 坐标,从底部向上计算
渐变效果通过 ctx.createLinearGradient(x, y, x, yBase) 创建垂直线性渐变——起点在柱顶(x, y),终点在柱底(x, yBase)。渐变设置两个色标:顶部(0)为海岸蓝 blue,底部(1)为深海蓝 blueD,形成从上到下由浅入深的渐变效果。然后 ctx.fillStyle = grad 设置填充样式,ctx.fillRect(x, y, barW, bh) 绘制矩形柱体。
// 顶部数值标注(加粗居中)
ctx.fillStyle = COLORS.blueD;
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(val.toString(), x + barW / 2, y - 6);
// 底部月份标签
ctx.fillStyle = COLORS.text3;
ctx.font = '10px sans-serif';
ctx.fillText(MONTH_LABELS[i], x + barW / 2, yBase + 13);
}
在每根柱子的上方和下方分别绘制文字标注:
- 顶部数值:在柱顶上方 6px 处绘制数值,使用深海蓝色
blueD、11px 加粗字体、居中对齐。 - 底部月份标签:在柱底下方 13px 处绘制月份,使用雾蓝灰色
text3、10px 常规字体、居中对齐。
文字的 X 坐标均为 x + barW / 2,即柱子的水平中心位置。配合 textAlign = 'center',确保文字在柱子正上方/正下方居中显示。
// 左上角汇总文案
ctx.fillStyle = COLORS.sub;
ctx.font = '10px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('单位:题', pad, pad - 12);
}
最后在左上角绘制"单位:题"的汇总文案,使用青灰蓝色 sub、10px 常规字体、左对齐。位置在左上内边距处,为图表提供计量单位说明。
呼吸动画定时器每秒翻转 breath 状态并重绘 Canvas,柱高在 93%~100% 间微幅波动,营造数据实时刷新的动感。整个绘制过程不依赖任何外部图表库,纯靠 Canvas 2D API 手工实现,充分体现了 ArkUI 对底层绘制能力的开放程度。
9.2 网页 Tab:ArkWeb 地址栏与主动下载
@Builder
tabWeb() {
Column({ space: 10 }) {
// 地址栏:输入 + 前往(urlInput / webUrl 双状态分离)
Row({ space: 8 }) {
TextInput({ text: this.urlInput, placeholder: '输入网址检索真题,如 neea.edu.cn' })
.layoutWeight(1).height(38).fontSize(11).fontColor(COLORS.title)
.backgroundColor(COLORS.card).borderRadius(10)
.onChange((value: string) => {
this.urlInput = value;
})
Text('前往').fontSize(11).fontColor(COLORS.white)
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
.backgroundColor(COLORS.blue).borderRadius(10)
.onClick(() => {
this.loadUrl();
})
}
.width('100%')
网页 Tab 的核心是地址栏 + 快捷站点 + Web 组件 + 下载操作区的四层结构,各层间距 10px。第一层是地址栏,由 TextInput 和"前往"按钮组成。
TextInput 绑定 urlInput 状态,占位符提示"输入网址检索真题,如 neea.edu.cn",高度 38px、11px 字号、纯白底色、10px 圆角。onChange 回调实时更新 urlInput 值,但不会触发网页加载——这就是双状态分离设计的体现。
"前往"按钮是海岸蓝实底白字,左右内边距 14px,高度与输入框一致(38px)。点击调用 loadUrl() 方法,校验协议后更新 webUrl 触发加载。
// 快捷站点横滑(教育考试类真实站点,点击即加载)
Scroll() {
Row({ space: 8 }) {
ForEach(QUICK_SITES, (site: QuickSite) => {
Row({ space: 5 }) {
Text(site.icon).fontSize(10)
Text(site.name).fontSize(9)
.fontColor(this.webUrl === site.url ? COLORS.white : COLORS.sub)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(this.webUrl === site.url ? COLORS.blue : COLORS.card)
.borderRadius(12)
.onClick(() => {
this.urlInput = site.url;
this.webUrl = site.url;
})
}, (site: QuickSite) => site.url)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
第二层是快捷站点横滑区,使用横向滚动 Row + ForEach 渲染四个教育考试站点。每个站点是一个胶囊形状的 Row,包含 emoji 图标和站点名称。
选中态(this.webUrl === site.url)为蓝底白字,未选中态为白底灰字。点击站点胶囊时同时更新 urlInput 和 webUrl——前者更新地址栏显示,后者触发 Web 组件加载新页面。
快捷站点的存在大大降低了用户的操作成本——不需要手动输入网址,只需点击一下就能访问常用的教育考试网站。这对于备考场景特别实用,因为考生经常需要访问的网站就那几个。
// Web 组件本体:网页内点击真题下载链接自动进入 delegate 四回调
Web({ src: this.webUrl, controller: this.webController })
.layoutWeight(1)
.width('100%')
.borderRadius(10)
.backgroundColor(COLORS.chip)
第三层是 Web 组件本体,这是网页 Tab 的核心。Web 组件通过 src 属性绑定 webUrl 状态实现页面切换,通过 controller 属性绑定 webController 实现编程控制(加载页面、发起下载、绑定代理等)。
Web 组件使用 layoutWeight(1) 占据剩余空间,这意味着它会自动填满地址栏和下载操作区之间的所有空间。圆角 10px,背景色为浅云蓝 chip——在网页加载前显示这个底色,避免白屏闪烁。
用户在 Web 组件中浏览网页时,如果点击了下载链接,下载行为会自动被之前注册的 WebDownloadDelegate 捕获,进入四个回调的处理流程。这就是为什么要在 aboutToAppear 中提前注册下载代理——确保 Web 组件加载的第一个页面就能正常捕获下载。
Column({ space: 8 }) {
Row() {
Column().layoutWeight(1)
Text(this.dlState).fontSize(9).fontColor(dlStateColor(this.dlState))
}
.width('100%')
Row({ space: 10 }) {
Text('下载数学一真题卷').fontSize(10).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.blue).borderRadius(9)
.onClick(() => {
this.triggerDownload('https://files.neea.edu.cn/exam/2026/kaoyan/math1_2025_full.pdf?track=official');
})
Text('下载 408 真题卷').fontSize(10).fontColor(COLORS.blue)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 })
.borderRadius(9).border({ width: 1, color: COLORS.blue })
.onClick(() => {
this.triggerDownload('https://dl.eol.cn/408/cs408_2024_real.pdf?track=official&part=all');
})
}
.width('100%')
Text('完成后在「下载」Tab 查看原始 URL 与引用页 URL 双溯源').fontSize(8).fontColor(COLORS.text3)
}
.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
}
.width('100%')
.height('100%')
}
第四层是下载操作区,放在一张卡片里。操作区包含三行内容:
第一行是下载状态文案,右对齐显示,颜色通过 dlStateColor(this.dlState) 动态映射——完成绿色、失败橙色、进行中蓝色、空闲灰色。
第二行是两个主动下载按钮,等分宽度:
- “下载数学一真题卷”:海岸蓝实底白字(主按钮样式),点击调用
triggerDownload传入数学一真题 PDF 地址。 - “下载 408 真题卷”:海岸蓝描边蓝字(次按钮样式),点击调用
triggerDownload传入 408 真题 PDF 地址。
两个按钮分别演示了实底按钮和描边按钮两种样式,也代表了两种优先级的操作——主操作用实底按钮,次操作用描边按钮。
第三行是提示文字"完成后在「下载」Tab 查看原始 URL 与引用页 URL 双溯源",引导用户了解双 URL 溯源功能的位置。
整个网页 Tab 的下载状态文案和颜色通过 dlStateColor 函数动态映射,进度条实时反映 dlPercent 状态。主动下载功能让用户无需在网页内寻找下载链接,一键即可下载指定真题,提升了操作便捷性。
9.3 下载 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(dlStateColor(this.dlState))
}
.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.blue).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)
下载 Tab 分为两部分:进行中任务卡和已完成记录列表,间距 12px。
进行中任务卡展示当前下载任务的详细信息,包含四行:
- 标题行:左侧"⬇ 下载任务"标题,右侧状态文案(动态着色)。
- 文件名:如果
dlName为空显示提示文案"暂无进行中任务(可在网页 Tab 主动触发)",否则显示文件名。单行截断省略号。 - 进度条:线性进度条,绑定
dlPercent,高度 6px(比题库 Tab 的 4px 略粗,更醒目),海岸蓝填充色。 - 进度信息行:左侧"进度 x%“,右侧"保存至沙箱 filesDir”。右侧的保存位置提示让用户清楚知道文件存放在哪里,增加透明度。
// 已完成记录列表(每条含双 URL 溯源信息)
Column({ space: 10 }) {
Row() {
Text('🗂 已完成下载 · 双 URL 溯源').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, (item: DownloadRecord) => {
this.recordCard(item)
}, (item: DownloadRecord) => item.fileName + item.finishTime)
}
.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
}
.width('100%')
}
第二部分是已完成记录列表,这是 ArkWeb 6.1.1 双 URL 溯源特性的展示窗口。列表标题为"🗂 已完成下载 · 双 URL 溯源",强调了这个 Tab 的核心特色功能。右侧显示记录条数。
列表通过 ForEach 遍历 downloadRecords 数组,每条记录调用 recordCard(item) 渲染。键生成函数使用 fileName + finishTime 的组合确保唯一性。
9.3.1 下载记录卡 recordCard
@Builder
recordCard(item: DownloadRecord) {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text('📄 ' + item.fileName).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.finishTime).fontSize(8).fontColor(COLORS.text3)
}
.width('100%')
Row({ space: 6 }) {
Text(item.fileSize).fontSize(8).fontColor(COLORS.sub)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.chip).borderRadius(7)
Text('官方渠道 · PDF').fontSize(8).fontColor(COLORS.green)
}
.width('100%')
下载记录卡 recordCard 是每条下载记录的展示单元,包含四层信息。
第一层是文件名和时间行:左侧是带 📄 图标的文件名,11px 加粗,layoutWeight(1) 占据剩余空间,单行截断省略号;右侧是完成时间,8px 弱文本。
第二层是标签行,包含两个胶囊标签:
- 文件大小:如"18.6 MB",浅云蓝底色,青灰蓝文字。
- 渠道标签:“官方渠道 · PDF”,海藻绿色,表明这份资料来自官方渠道且是 PDF 格式。绿色传递"可信、安全"的语义,与双 URL 溯源的"确保资料可信"理念呼应。
// 🔗 原始 URL 行(getOriginalUrl 结果)
Row({ space: 4 }) {
Text('🔗').fontSize(9)
Text(item.originalUrl).fontSize(8).fontColor(COLORS.blueD)
.fontFamily('monospace').layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
// 📄 引用页 URL 行(getReferrerUrl 结果)
Row({ space: 4 }) {
Text('📄').fontSize(9)
Text(item.referrerUrl).fontSize(8).fontColor(COLORS.sub)
.fontFamily('monospace').layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
}
.width('100%').padding(10)
.backgroundColor(COLORS.chip).borderRadius(10)
}
第三、四层是双 URL 溯源信息,这是记录卡最核心的部分,也是 HarmonyOS 6.1.1 新特性的直接展示:
- 🔗 原始 URL 行:以链接图标开头,URL 使用等宽字体
monospace、深海蓝颜色,单行截断展示。这是getOriginalUrl()的返回结果,即文件的直链来源地址。 - 📄 引用页 URL 行:以页面图标开头,URL 同样使用等宽字体、青灰蓝颜色,单行截断。这是
getReferrerUrl()的返回结果,即触发下载的页面地址。
两行 URL 都使用等宽字体(fontFamily('monospace')),因为 URL 是技术文本,等宽字体更易读,也更符合开发者的视觉习惯。两个 URL 使用不同的颜色区分——原始 URL 用深海蓝(更醒目,因为是文件来源),引用页 URL 用青灰蓝(稍弱化,因为是页面来源)。
双 URL 并列展示让用户能清晰区分"文件从哪里来"和"从哪个页面点击下载"两个维度,实现真题来源的完整溯源闭环。如果考生发现原始 URL 的域名不是官方域名,或者引用页 URL 指向可疑网站,就能判断这份资料可能有问题,避免使用假冒真题。
9.4 提醒 Tab:通知授权与学习时间轴
@Builder
tabRemind() {
Column({ space: 12 }) {
// 通知授权状态卡
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🔔').fontSize(16)
Column({ space: 3 }) {
Text('通知授权状态').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(this.granted ? 'isNotificationEnabled = true' : 'isNotificationEnabled = false')
.fontSize(8).fontColor(COLORS.text3).fontFamily('monospace')
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(this.granted ? '已授权' : '未授权').fontSize(9)
.fontColor(this.granted ? COLORS.green : COLORS.orange)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(COLORS.chip).borderRadius(9)
}
.width('100%')
提醒 Tab 包含四个区块:通知授权卡、发布学习提醒按钮卡、学习提醒时间轴、通知历史,各区块间距 12px。
第一个区块是通知授权状态卡。卡片顶部是授权状态行,从左到右依次为:铃铛图标(16px)、标题和技术状态(标题"通知授权状态"12px 加粗,下方是等宽字体显示的 isNotificationEnabled = true/false 技术状态)、右侧状态标签("已授权"配海藻绿 / "未授权"配珊瑚橙)。
技术状态行使用等宽字体 monospace 显示布尔值,这是一种"技术感"设计——直接展示 API 返回值的原始形式,让开发者用户一眼就能看懂当前的授权状态变量值。这种设计在技术演示类应用中很常见,既展示了功能效果,又揭示了底层实现。
Text(this.granted ? '已授权 · 学习提醒将携带沙箱自定义铃声送达' : '未授权 · 点击右侧按钮申请通知权限')
.fontSize(9).fontColor(COLORS.sub)
Row({ space: 10 }) {
Text(this.granted ? '重新检测授权' : '请求通知授权').fontSize(11).fontColor(COLORS.white)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.blue).borderRadius(9)
.onClick(() => {
this.requestAuth();
})
Text('已发布 ' + this.noticeCount + ' 条').fontSize(9).fontColor(COLORS.blue)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 })
.borderRadius(9).border({ width: 1, color: COLORS.blue })
}
.width('100%')
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
授权状态卡的中部是状态说明文字,根据 granted 状态显示不同的提示——已授权时说明"学习提醒将携带沙箱自定义铃声送达",未授权时引导用户"点击右侧按钮申请通知权限"。
底部是两个按钮,等分宽度:
- 左侧主按钮:根据授权状态显示"重新检测授权"或"请求通知授权",海岸蓝实底白字,点击调用
requestAuth()方法。 - 右侧次按钮:显示"已发布 N 条",海岸蓝描边蓝字,展示已发布的通知数量。
两个按钮一实一虚,一主一次,形成清晰的视觉层级。
// 发布学习提醒按钮卡(sound 走铃音坊沙箱链路)
Column({ space: 8 }) {
Row() {
Text('📣 发布学习提醒').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('铃声:' + this.ringList[this.currentRingIdx].name).fontSize(9).fontColor(COLORS.blue)
}
.width('100%')
Text('通知 sound 字段填 \'uri::\' + fileUri.getUriFromPath(沙箱路径),播放自定义铃声')
.fontSize(8).fontColor(COLORS.text3)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text('发布背单词提醒').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
.width('100%').textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 }).backgroundColor(COLORS.orange).borderRadius(10)
.onClick(() => {
this.publishNotice('学习提醒', '该背单词啦:今日计划 120 词,已完成 86 词');
})
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
第二个区块是发布学习提醒按钮卡,这是 Notification Kit 沙箱铃声特性的直接交互入口。
卡片标题行左侧是"📣 发布学习提醒"标题,右侧显示当前使用的铃声名称(海岸蓝色),让用户知道发布通知时会播放什么铃声。
中间一行是技术说明文字,解释了沙箱铃声的工作原理:“通知 sound 字段填 ‘uri::’ + fileUri.getUriFromPath(沙箱路径),播放自定义铃声”。这段文字直接揭示了 HarmonyOS 6.1.1 新特性的使用方式,具有教学价值。
底部是一个醒目的珊瑚橙大按钮"发布背单词提醒",12px 加粗白字,11px 上下内边距,10px 圆角。珊瑚橙在以蓝色为主的界面中非常突出,吸引用户点击。点击调用 publishNotice 方法,发布一条背单词提醒通知,铃声为当前默认铃声。
// 学习提醒时间轴(行 Row 固定 height(72),竖线在固定行高内 layoutWeight 填满)
Column({ space: 6 }) {
Row() {
Text('⏰ 今日学习提醒').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('已开启 ' + this.onRemindCount() + ' 项').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
ForEach(this.remindList, (item: RemindItem, idx: number) => {
this.remindRow(item, idx)
}, (item: RemindItem) => item.time + item.title)
}
.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
第三个区块是学习提醒时间轴,这是提醒 Tab 最具设计感的部分。标题行左侧是"⏰ 今日学习提醒"标题,右侧显示"已开启 N 项"的计数——计数通过 onRemindCount() 方法实时计算,反映当前开启的提醒数量。
时间轴列表通过 ForEach 遍历 remindList 数组,每行调用 remindRow(item, idx) 渲染。列表间距为 6px,比其他列表更紧凑,因为时间轴本身已经有视觉分隔线。
// 通知历史
Column({ space: 10 }) {
Row() {
Text('🗒 通知历史').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(this.noticeLogs.length + ' 条').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
ForEach(this.noticeLogs, (item: NoticeLog) => {
Column({ space: 3 }) {
Row({ space: 8 }) {
Text(item.title).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.time).fontSize(8).fontColor(COLORS.text3)
}
.width('100%')
Text(item.text).fontSize(9).fontColor(COLORS.sub)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%').padding(10)
.backgroundColor(COLORS.chip).borderRadius(10)
}, (item: NoticeLog) => item.title + item.time)
}
.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
}
.width('100%')
}
第四个区块是通知历史列表,展示已发布通知的记录。每条记录包含标题(加粗)和正文(弱化)两行,右侧显示时间。记录使用浅云蓝底色和 10px 圆角,与其他列表项风格一致。
通知历史与提醒时间轴的区别在于:提醒时间轴是预设的学习计划(可开关),通知历史是实际发布过的通知记录(已发生)。一个面向未来,一个面向过去,两者共同构成完整的提醒系统。
9.4.1 学习提醒时间轴行 remindRow
@Builder
remindRow(item: RemindItem, idx: number) {
Row({ space: 10 }) {
// 时间列(固定宽,与行等高居中)
Column({ space: 3 }) {
Text(item.time).fontSize(12).fontColor(item.on ? COLORS.blue : COLORS.text3)
.fontWeight(FontWeight.Bold)
Text(item.repeat).fontSize(8).fontColor(COLORS.text3)
}
.width(50).height('100%')
.justifyContent(FlexAlign.Center)
学习提醒时间轴行 remindRow 采用三列布局,每行固定高度 72px,这是时间轴设计的关键——固定行高确保竖线能在各行之间连续。
第一列是时间列,固定宽度 50px,与行等高且内容垂直居中。包含两行文字:
- 时间:12px 加粗,颜色根据开关状态动态变化——开启时海岸蓝(醒目),关闭时雾蓝灰(弱化)。
- 重复规则:8px 弱文本,始终是雾蓝灰色。
时间颜色随开关状态变化的设计,让用户一眼就能分辨哪些提醒正在生效——蓝色表示活跃,灰色表示暂停,视觉即含义。
// 时间轴:圆点 + 竖线(在固定行高内 layoutWeight 填满)
Column() {
Column()
.width(10).height(10).borderRadius(5)
.backgroundColor(item.on ? COLORS.blue : COLORS.text3)
Column()
.layoutWeight(1).width(2)
.backgroundColor(COLORS.line)
}
.height('100%')
.alignItems(HorizontalAlign.Center)
第二列是时间轴主体,这是时间轴设计的视觉核心。时间轴由两部分组成:
- 圆点:10x10px 的圆形,圆角 5px(即正圆),颜色随开关状态变化——开启时海岸蓝,关闭时雾蓝灰。圆点是时间节点的视觉标识。
- 竖线:宽度 2px 的竖直线条,使用
layoutWeight(1)在固定行高内填满剩余空间。线条颜色为浅雾线line,与圆点形成对比。
竖线使用 layoutWeight(1) 是一个巧妙的设计——因为行高是固定的 72px,layoutWeight(1) 会让竖线自动填满圆点下方的所有空间,确保每条时间轴线的长度一致,无需手动计算高度。
// 提醒卡:事项 + 开关
Row({ space: 8 }) {
Column({ space: 4 }) {
Text(item.title).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.on ? '已开启 · 到点提醒' : '已暂停 · 不打扰').fontSize(8)
.fontColor(item.on ? COLORS.green : COLORS.text3)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Toggle({ type: ToggleType.Switch, isOn: item.on })
.scale({ x: 0.8, y: 0.8 })
.onChange((isOn: boolean) => {
this.toggleRemind(idx, isOn);
})
}
.layoutWeight(1).height('100%')
.padding({ left: 10, right: 10 })
.backgroundColor(COLORS.chip).borderRadius(10)
.alignItems(VerticalAlign.Center)
}
.width('100%')
.height(72)
.margin({ bottom: 6 })
}
第三列是提醒卡,占据剩余空间,与行等高,垂直居中。卡片内部左侧是提醒事项文本,右侧是 Toggle 开关。
左侧文本包含两行:
- 提醒标题:11px 加粗深海墨蓝字,单行截断省略号。
- 状态说明:8px,开启时显示"已开启 · 到点提醒"配海藻绿,关闭时显示"已暂停 · 不打扰"配雾蓝灰。
右侧是 Toggle 开关组件,类型为 Switch(开关样式),isOn 绑定 item.on。使用 scale({ x: 0.8, y: 0.8 }) 将开关缩小到 80%,使其更精致。onChange 回调调用 toggleRemind(idx, isOn) 方法更新提醒状态。
整行高度固定为 72px,底部有 6px 外边距。72px 的行高是经过设计的——既保证了内容的舒适排布(时间、圆点、提醒卡都有足够空间),又不会过于稀疏导致一屏显示的提醒太少。
9.5 铃音 Tab:正弦波生成器与铃声库
@Builder
tabRing() {
Column({ space: 12 }) {
// 当前默认铃声状态卡
Column({ space: 8 }) {
Row({ space: 8 }) {
Text('🎵').fontSize(16)
Column({ space: 3 }) {
Text('当前默认铃声').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(this.ringList[this.currentRingIdx].file + ' · ' + this.ringList[this.currentRingIdx].size)
.fontSize(8).fontColor(COLORS.text3).fontFamily('monospace')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(this.ringList[this.currentRingIdx].inSandbox ? '已入沙箱' : '未生成')
.fontSize(9).fontColor(this.ringList[this.currentRingIdx].inSandbox ? COLORS.green : COLORS.orange)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(COLORS.chip).borderRadius(9)
}
.width('100%')
Text('通知发布时 sound 字段读取该铃声的沙箱 uri(EL1 files 目录)').fontSize(8).fontColor(COLORS.text3)
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
铃音 Tab 是 CoreFileKit 文件操作与 Notification 铃声沙箱化的完整工作台,包含三个区块:当前默认铃声状态卡、正弦波铃声生成器、铃声库列表。
第一个区块是当前默认铃声状态卡,展示正在使用的铃声信息。卡片顶部从左到右依次为:音符图标(16px)、铃声名称和文件信息(名称 11px 加粗,文件名+大小 8px 等宽字体)、沙箱状态标签("已入沙箱"配海藻绿 / "未生成"配珊瑚橙)。
底部的说明文字"通知发布时 sound 字段读取该铃声的沙箱 uri(EL1 files 目录)"解释了铃声的使用机制——通知系统通过沙箱 URI 读取音频文件,而文件必须位于 EL1 加密区域。
// 正弦波铃声生成器(频率四档 + 时长三档 + 生成按钮)
Column({ space: 10 }) {
Text('🎛 正弦波铃声生成器').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column({ space: 6 }) {
Text('基频(Hz)').fontSize(9).fontColor(COLORS.sub)
Row({ space: 8 }) {
ForEach(RING_FREQ_PRESETS, (freq: number) => {
Text(freq.toString() + ' Hz').fontSize(9)
.fontColor(this.genFreq === freq ? COLORS.white : COLORS.sub)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.genFreq === freq ? COLORS.blue : COLORS.chip)
.borderRadius(11)
.onClick(() => {
this.genFreq = freq;
})
}, (freq: number) => freq.toString())
}
.width('100%')
}
.width('100%').alignItems(HorizontalAlign.Start)
第二个区块是正弦波铃声生成器,这是铃音 Tab 最具技术感的功能。生成器包含频率选择和时长选择两部分参数,以及生成按钮。
频率选择部分展示四档频率预设(440/660/880/1320 Hz),使用横向排列的胶囊按钮。选中态为蓝底白字,未选中态为浅云蓝底灰字。点击切换 genFreq 状态。
Column({ space: 6 }) {
Text('时长(ms)').fontSize(9).fontColor(COLORS.sub)
Row({ space: 8 }) {
ForEach(RING_DURATION_PRESETS, (dur: number) => {
Text(dur.toString() + ' ms').fontSize(9)
.fontColor(this.genDuration === dur ? COLORS.white : COLORS.sub)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.genDuration === dur ? COLORS.blue : COLORS.chip)
.borderRadius(11)
.onClick(() => {
this.genDuration = dur;
})
}, (dur: number) => dur.toString())
}
.width('100%')
}
.width('100%').alignItems(HorizontalAlign.Start)
时长选择部分展示三档时长预设(600/1200/2000 ms),UI 样式与频率选择一致。点击切换 genDuration 状态。
频率和时长的选择都采用"预设胶囊"而非 Slider 滑杆,是有意为之的设计决策。对于铃声生成这种参数空间不大的场景(4 档频率 × 3 档时长 = 12 种组合),预设按钮比滑杆更直观、操作更快——用户只需点击一下就能切换,不需要精确拖动滑杆。同时,预设值都是经过挑选的和谐频率和适中时长,保证了生成铃声的音质。
Row({ space: 10 }) {
Text('生成到沙箱 EL1').fontSize(11).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.blue).borderRadius(9)
.onClick(() => {
this.createRingByGen();
})
Text('已落盘 ' + this.sandboxCount + ' 个').fontSize(9).fontColor(COLORS.blue)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.borderRadius(9).border({ width: 1, color: COLORS.blue })
}
.width('100%')
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
生成器底部是两个按钮:
- “生成到沙箱 EL1”:主按钮,海岸蓝实底白字加粗,点击调用
createRingByGen()方法——用当前参数创建新铃声并立即写入沙箱。 - “已落盘 N 个”:次按钮,海岸蓝描边蓝字,展示已写入沙箱的铃声数量,作为生成操作的反馈。
按钮上明确标注"EL1",强调文件写入的是 EL1 加密区域——这是通知铃声能被系统读取的前提条件,也是 HarmonyOS 安全架构的重要概念。
// 铃声库列表(生成到沙箱 / 设为默认)
Column({ space: 10 }) {
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) => {
this.ringRow(item, idx)
}, (item: RingItem) => item.file)
}
.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
}
.width('100%')
}
第三个区块是铃声库列表,展示所有可用的铃声。标题行左侧是"🔔 铃声库"标题,右侧显示铃声总数。列表通过 ForEach 遍历 ringList 数组,每行调用 ringRow(item, idx) 渲染。
9.5.1 铃声库行 ringRow
@Builder
ringRow(item: RingItem, idx: number) {
Row({ space: 10 }) {
// 频率图标块
Column({ space: 1 }) {
Text('🎶').fontSize(13)
Text(item.freq.toString()).fontSize(7).fontColor(COLORS.blueD)
}
.width(40).height(44).borderRadius(10)
.justifyContent(FlexAlign.Center).backgroundColor(COLORS.chip)
铃声库行 ringRow 采用三列布局。第一列是频率图标块,40x44px,浅云蓝底色,圆角 10px。内部垂直居中排列两行:上行是音符 emoji(13px),下行是频率数字(7px 深海蓝)。这个图标块让用户快速识别铃声的频率特征。
// 名称 + 文件/大小
Column({ space: 4 }) {
Text(item.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 6 }) {
Text(item.file).fontSize(8).fontColor(COLORS.text3).fontFamily('monospace')
.layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.size).fontSize(8).fontColor(COLORS.sub)
}
.width('100%')
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
第二列是铃声信息主体,占据剩余空间。包含两行:
- 铃声名称:11px 加粗深海墨蓝字,单行截断省略号。
- 文件名和大小:文件名使用等宽字体雾蓝灰色,文件大小使用青灰蓝色。文件名是技术标识,文件大小是实用信息。
// 操作按钮:生成到沙箱 / 设为默认
Column({ space: 4 }) {
if (item.inSandbox) {
Text('已入沙箱').fontSize(8).fontColor(COLORS.green)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.chip).borderRadius(8)
} else {
Text('生成到沙箱').fontSize(8).fontColor(COLORS.blue)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.chip).borderRadius(8)
.onClick(() => {
this.importRingToSandbox(idx);
})
}
if (this.currentRingIdx === idx) {
Text('默认中 ✓').fontSize(8).fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.blue).borderRadius(8)
} else {
Text('设为默认').fontSize(8).fontColor(COLORS.blueD)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.chip).borderRadius(8)
.onClick(() => {
this.setCurrentRing(idx);
})
}
}
.alignItems(HorizontalAlign.Center)
}
.width('100%').padding(10)
.backgroundColor(COLORS.card).borderRadius(10)
.border({ width: 1, color: this.currentRingIdx === idx ? COLORS.blue : COLORS.line })
}
第三列是操作按钮区,垂直排列两个按钮。两个按钮的状态都根据铃声状态动态变化:
第一个按钮(沙箱状态):
- 如果
inSandbox为 true:显示"已入沙箱",海藻绿文字,浅云蓝底色(纯展示,不可点击)。 - 如果
inSandbox为 false:显示"生成到沙箱",海岸蓝文字,浅云蓝底色(可点击,调用importRingToSandbox(idx))。
第二个按钮(默认铃声状态):
- 如果是当前默认铃声:显示"默认中 ✓",白字蓝底(纯展示,不可点击)。
- 如果不是当前默认铃声:显示"设为默认",深海蓝文字,浅云蓝底色(可点击,调用
setCurrentRing(idx))。
整行的边框也会根据是否为默认铃声变化——默认铃声行使用海岸蓝边框,其他行使用浅雾线边框。这种边框高亮让用户一眼就能看出哪个铃声是当前默认的。
铃声库行的交互设计体现了"状态驱动 UI"的思想——同一位置的按钮根据不同的状态显示不同的文案、颜色和可点击性,所有状态都由数据模型(inSandbox、currentRingIdx)驱动,逻辑清晰,易于理解和维护。
9.6 我的 Tab:备考身份大卡与错题趋势
@Builder
tabMine() {
Column({ space: 12 }) {
// 备考身份渐变大卡
Column({ space: 10 }) {
Row({ space: 10 }) {
Text('🧑🎓').fontSize(26)
Column({ space: 3 }) {
Text('陈同学 · 26 届考研人').fontSize(15).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
Text('目标:华东师范大学 · 软件工程').fontSize(10).fontColor(COLORS.whiteSoft)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('LV.6').fontSize(9).fontColor(COLORS.blueD).fontWeight(FontWeight.Bold)
.padding({ left: 9, right: 9, top: 4, bottom: 4 })
.backgroundColor(COLORS.white).borderRadius(9)
}
.width('100%')
"我的"Tab 以备考身份渐变大卡开头,是个人中心的视觉焦点。大卡使用 130° 线性渐变从深海蓝到海岸蓝,营造专业感和沉浸感。
卡片顶部是身份信息行,从左到右依次为:学员头像 emoji(26px,是整个页面最大的图标)、姓名和目标院校专业、等级标签。
姓名行是"陈同学 · 26 届考研人",15px 加粗白字,是卡片上最醒目的文字。下方的目标院校"华东师范大学 · 软件工程"使用 10px 弱化白字,降低视觉权重。
等级标签"LV.6"使用白底蓝字的圆角胶囊,9px 加粗深海蓝字——白色在深蓝渐变背景上非常突出,等级标签传达了"成长体系"的概念,让备考过程更有游戏化的成就感。
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('216 天').fontSize(13).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
Text('累计备考').fontSize(8).fontColor(COLORS.whiteSoft)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column().width(1).height(24).backgroundColor(COLORS.trackW)
Column({ space: 2 }) {
Text('1344 题').fontSize(13).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
Text('累计刷题').fontSize(8).fontColor(COLORS.whiteSoft)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column().width(1).height(24).backgroundColor(COLORS.trackW)
Column({ space: 2 }) {
Text('213 题').fontSize(13).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
Text('待复盘错题').fontSize(8).fontColor(COLORS.whiteSoft)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%')
卡片中部是三列统计数据,用半透明白色竖线隔开。三列数据分别是:
- 累计备考 216 天:备考时长
- 累计刷题 1344 题:学习总量
- 待复盘错题 213 题:待处理任务
每列都是"数字 + 标签"的结构,数字 13px 加粗白字,标签 8px 弱化白字。分隔线是 1px 宽、24px 高的半透明白色竖线(trackW,32% 不透明度),在深蓝渐变背景上隐约可见,既分隔了数据又不破坏整体感。
三列数据的选择体现了备考的三个核心维度:时间投入(备考天数)、学习量(刷题数)、待改进项(错题数)。三者并列展示,让考生对自己的备考状态一目了然。
Progress({ value: 68, total: 100, type: ProgressType.Linear })
.width('100%').height(6)
.color(COLORS.white).backgroundColor(COLORS.trackW)
Text('总体备考进度 68% · 冲刺阶段').fontSize(8).fontColor(COLORS.whiteSoft)
}
.width('100%').padding(16).borderRadius(14)
.linearGradient({ angle: 130, colors: [[COLORS.blueD, 0], [COLORS.blue, 1]] })
卡片底部是总体备考进度条和说明文字。进度条使用纯白色填充(white)和半透明白色轨道(trackW),在深蓝渐变背景上形成"亮条在暗色背景上"的视觉效果。进度值为 68%,对应"冲刺阶段"——考研备考通常在 70% 左右进入冲刺期。
整个身份大卡使用 16px 内边距和 14px 圆角,渐变色从 130° 方向的深海蓝(起点)过渡到海岸蓝(终点)。130° 的渐变角度让左上角更深、右下角更亮,模拟光线从右下角照射的效果,增加立体感。
// 错题趋势传统柱状图(Column + ForEach,与 Canvas 柱状图区分)
this.chartCard()
// 错题本清单
Column({ space: 10 }) {
Row() {
Text('📕 错题本 · 待复盘').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(this.mistakeList.length + ' 条').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
ForEach(this.mistakeList, (item: MistakeItem) => {
Row({ space: 10 }) {
// 左侧科目色条
Column()
.width(4).height(34).borderRadius(2)
.backgroundColor(subjectColor(item.subject))
// 题目 + 错因
Column({ space: 4 }) {
Text(item.question).fontSize(11).fontColor(COLORS.title)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 6 }) {
Text(item.subject).fontSize(8).fontColor(COLORS.sub)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.chip).borderRadius(7)
Text(item.reason).fontSize(8).fontColor(COLORS.orange)
Column().layoutWeight(1)
Text(item.time).fontSize(8).fontColor(COLORS.text3)
}
.width('100%')
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%').padding({ top: 4, bottom: 4 })
}, (item: MistakeItem) => item.question)
}
.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
}
.width('100%')
}
“我的"Tab 的第三区块是错题本清单。标题为"📕 错题本 · 待复盘”,红色书本图标暗示"错题"的含义。右侧显示错题总数。
错题本每行采用"左侧色条 + 右侧内容"的结构。左侧是 4px 宽、34px 高的科目色条,圆角 2px,颜色通过 subjectColor(item.subject) 动态映射。色条虽小,但作用重要——它让用户在快速滚动列表时仅凭颜色就能识别错题所属科目。
右侧内容包含两行:
- 题目摘要:11px 深海墨蓝字,单行截断省略号。
- 标签行:从左到右依次是科目标签(浅云蓝底青灰蓝字胶囊)、错因标签(珊瑚橙色文字,不加底色更醒目)、时间(右对齐,雾蓝灰)。
错因标签使用珊瑚橙色文字而非胶囊背景,是因为错因是错题本的核心信息——用最醒目的颜色直接展示,不加背景以保持简洁。珊瑚橙在以蓝色为主的界面中非常突出,正好对应"错误、需要注意"的语义。
9.6.1 错题趋势柱状图 chartCard
@Builder
chartCard() {
Column({ space: 10 }) {
Row() {
Text('📉 近 6 个月错题数').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('持续下降 = 进步').fontSize(9).fontColor(COLORS.green)
}
.width('100%')
错题趋势柱状图卡 chartCard 与题库 Tab 的 Canvas 柱状图形成技术路线对比——这里使用声明式组件堆叠(Column + ForEach)实现,而非 Canvas 绘制。
标题行右侧的文案"持续下降 = 进步"使用海藻绿色,直接点出错题趋势图的解读方式:错题越少越好,下降趋势代表进步。这种正向解读帮助考生建立正确的学习观念——错题减少 = 掌握的知识增多。
Row({ space: 6 }) {
ForEach(MISTAKE_VAL, (val: number, idx: number) => {
Column({ space: 4 }) {
Text(val.toString()).fontSize(9)
.fontColor(idx === MISTAKE_VAL.length - 1 ? COLORS.green : COLORS.blueD)
.fontWeight(FontWeight.Bold)
// 柱体:高度随错题量与呼吸动画波动,渐变蓝填充
Column()
.width(20)
.height(Math.max(8, val * (this.breath ? 1.0 : 0.92)))
.borderRadius({ topLeft: 4, topRight: 4 })
.linearGradient({ angle: 90, colors: [[COLORS.blue, 0.1], [COLORS.blueD, 1]] })
Text(MONTH_LABELS[idx]).fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (val: number) => val.toString())
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.card)
.borderRadius(12)
}
图表主体使用 Row + ForEach 实现,每根柱子是一个 Column 组件。Row 设置 alignItems(VerticalAlign.Bottom) 让所有柱子从底部对齐,这是柱状图的基本要求。
每根柱子包含三部分:
- 顶部数值:9px 加粗。最后一根柱子(当前月)使用海藻绿色,其他使用深海蓝色。绿色标注当前月错题最少,强调进步。
- 柱体:宽度 20px,高度通过
height(Math.max(8, val * (this.breath ? 1.0 : 0.92)))计算——错题数值乘以呼吸波动系数(100% 或 92%),Math.max(8, ...)确保最小高度为 8px,避免错题数为 0 时柱体消失。柱体顶部圆角 4px(只设 topLeft 和 topRight),底部直角,形成"上圆下方"的经典柱状图造型。渐变填充使用 90° 从海岸蓝到深海蓝。 - 底部月份标签:8px 雾蓝灰文字。
与题库 Tab 的 Canvas 柱状图相比,这里的声明式柱状图有几个不同:第一,呼吸波动系数是 92%~100%(Canvas 是 93%~100%),波动幅度略大;第二,渐变方向是 90°(从左到右),而 Canvas 柱状图是从上到下的垂直渐变;第三,柱体顶部圆角,Canvas 柱状图是直角矩形。这些细微差异让两个柱状图各有特色,也体现了两种技术方案的不同表现力。
十、底部 Tab 栏深度解析
底部 Tab 栏是整个应用的导航中枢,六个 Tab 图标均匀分布在页面底部,用户通过点击切换不同的功能模块。Tab 栏的设计虽然简洁,但在视觉层次、交互反馈和状态管理上都有值得深入分析的细节。
10.1 TabBar 构建逻辑
@Builder
tabBar() {
Row() {
ForEach(TAB_LIST, (t: TabMeta, idx: number) => {
Column({ space: 3 }) {
Text(t.icon).fontSize(17)
.opacity(this.currentTab === idx ? 1 : 0.65)
Text(t.label).fontSize(9)
.fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
.fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 7, bottom: 7 })
.onClick(() => {
this.currentTab = idx;
})
}, (t: TabMeta) => t.label)
}
.width('100%')
.backgroundColor(COLORS.card)
.border({ width: { top: 1 }, color: COLORS.line })
}
Tab 栏的整体结构是一个 Row 容器,内部通过 ForEach 遍历 TAB_LIST 数组渲染六个 Tab 项。每个 Tab 项是一个 Column,包含图标(emoji)和文字标签,垂直排列,间距 3px。
Row 的宽度为 100%,背景色为纯白卡片色,顶部有一条 1px 的浅雾线分割线——这条分割线虽然细微,但在视觉上将内容区与导航栏清晰地分隔开,避免了白色内容与白色导航栏融为一体的问题。
每个 Tab 项使用 layoutWeight(1) 等分宽度,这是底部导航栏的标准做法——无论屏幕宽度如何变化,六个 Tab 始终均匀分布。alignItems(HorizontalAlign.Center) 让图标和文字在水平方向居中,上下内边距各 7px,形成舒适的点击热区。
10.2 选中态与未选中态的视觉差异
Tab 栏的核心交互是选中态切换。代码通过 this.currentTab === idx 的三元表达式在两个视觉状态间切换:
| 视觉元素 | 选中态 | 未选中态 |
|---|---|---|
| 图标 opacity | 1.0(完全不透明) | 0.65(65% 透明度) |
| 文字颜色 | COLORS.tabOn(海岸蓝) | COLORS.text3(雾蓝灰) |
| 文字字重 | FontWeight.Bold(加粗) | FontWeight.Normal(常规) |
三重视觉差异(透明度 + 颜色 + 字重)同时作用,让选中态与未选中态形成明显对比,但又不至于过于突兀。图标使用透明度变化而非颜色变化,是因为 emoji 图标的颜色由系统决定,无法直接通过 fontColor 修改——通过 opacity 调节是一种巧妙的折中方案。
文字的选中态使用海岸蓝(tabOn),与整个应用的主色调保持一致,形成视觉上的呼应。加粗字重让选中的 Tab 文字在视觉上更"重",引导用户的注意力。
10.3 点击交互与状态切换
.onClick(() => {
this.currentTab = idx;
})
点击事件的处理极为简洁——只需更新 currentTab 状态变量即可。由于 currentTab 是 @State 装饰的响应式变量,其值的变化会自动触发两处 UI 更新:
- Tab 栏自身重绘:
ForEach中的所有 Tab 项重新计算this.currentTab === idx的结果,更新选中态样式。 - 内容区切换:
build方法中的if (this.currentTab === 0)等条件判断重新求值,显示对应 Tab 的@Builder内容。
这种"单一状态驱动全局切换"的设计是声明式 UI 的经典模式——状态是唯一的数据源,UI 是状态的映射。修改状态后,框架自动计算所有依赖该状态的 UI 节点并刷新,开发者无需手动操作 DOM/组件引用。
10.4 Tab 元数据驱动的可扩展性
Tab 栏的数据来源于 TAB_LIST 常量数组,每个元素是一个 TabMeta 接口对象,包含 icon、label、color 三个字段。这种数据驱动的设计具有良好的可扩展性:
- 新增 Tab:只需在
TAB_LIST数组中添加一项,并在build方法中增加对应的if分支和@Builder方法。 - 修改图标/文字:只需修改
TAB_LIST中的对应字段,无需触碰 Tab 栏的渲染逻辑。 - 国际化:可以根据系统语言动态生成
TAB_LIST,实现多语言 Tab 标签。
ForEach 的键生成函数使用 t.label,这在 Tab 标签不重复的前提下是合理的。如果需要支持标签重名的场景,可以为 TabMeta 增加 id 字段作为唯一键。
10.5 Tab 栏在布局体系中的位置
Tab 栏在整体布局中位于 Column 容器的最底部,上方是 Scroll 内容区(使用 layoutWeight(1) 占据剩余空间)。这种"底部导航 + 内容滚动"的布局是移动端应用的标准模式:
┌─────────────────────────┐
│ 头部渐变横幅 │ 固定高度
├─────────────────────────┤
│ │
│ │
│ Scroll 内容区域 │ layoutWeight(1) 自适应
│ (5个 Tab 共享) │
│ │
│ │
├─────────────────────────┤
│ Tab 栏(6 项) │ 固定高度
└─────────────────────────┘
需要注意的是,网页 Tab 由于 Web 组件需要有界高度,不使用 Scroll 容器,而是直接放在 layoutWeight(1) 的容器中。这意味着 Tab 栏的位置在所有 Tab 中都是固定的——无论内容如何切换,底部导航始终可见,确保用户随时可以切换模块。
十一、弹窗系统深度解析
弹窗系统是题库 Tab 中 CRUD 操作的交互载体,包含三种弹窗:新增真题、编辑完成度、删除确认。三种弹窗共享同一个遮罩层组件,但面板内容和交互逻辑各有不同。
11.1 弹窗系统架构总览
弹窗系统采用"状态驱动 + 组件复用"的架构。三个布尔状态变量(addModal、editModal、delModal)分别控制三个弹窗的显示与隐藏。每个弹窗面板内部复用 modalOverlay 遮罩层组件,形成统一的视觉风格和交互模式。
在 build 方法的根 Stack 容器中,三个弹窗通过 if 条件判断叠加在页面最顶层:
if (this.addModal) {
this.panelAdd(() => {
this.addModal = false;
})
}
if (this.editModal) {
this.panelEdit(() => {
this.editModal = false;
})
}
if (this.delModal) {
this.panelDel(() => {
this.delModal = false;
})
}
每个弹窗都接收一个 onClose 回调函数作为参数——这是关闭弹窗的统一入口。无论是点击遮罩层还是点击"取消"按钮,最终都调用这个回调将对应的状态变量设为 false,触发弹窗消失。
11.2 全屏遮罩层 modalOverlay
/** 弹窗全屏遮罩(点击遮罩关闭弹窗) */
@Builder
modalOverlay(onClose: () => void) {
Stack() {
Column().width('100%').height('100%').backgroundColor(COLORS.mask)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
.onClick(() => onClose())
}
遮罩层是弹窗系统的基础组件,实现极为精简。它使用 Stack 容器铺满全屏,内部只有一个同样铺满全屏的 Column,背景色为 COLORS.mask(深海墨色,65% 不透明度)。
虽然 Stack 内部只有一个子组件,看起来多此一举,但这是为了与弹窗面板的 Stack 结构保持一致——面板在 Stack 中同时放置遮罩层和内容卡片,Stack 的 alignContent(Alignment.Center) 让内容卡片居中显示。
遮罩层的 onClick 事件绑定了 onClose 回调,实现"点击遮罩关闭弹窗"的通用交互模式。这是移动端弹窗的标准行为,用户已经形成了强烈的使用习惯。
遮罩层的颜色使用 COLORS.mask(深海墨色,即接近黑色的深蓝色),而非纯黑色。这是因为整个应用的色调以蓝色系为主,深蓝色遮罩与整体视觉风格更协调,不会产生"纯黑遮罩打断沉浸感"的问题。
11.3 新增真题弹窗 panelAdd
新增真题弹窗是三个弹窗中最复杂的一个,包含四个表单字段:科目、年份卷、题量、完成度。
11.3.1 弹窗标题与表单结构
@Builder
panelAdd(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('新增真题试卷').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
// 四个表单字段...
// 底部按钮组...
}
.width('80%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
}
弹窗面板是一个宽度为 80% 的白色卡片,16px 内边距,14px 圆角。卡片内容使用 Column 纵向排列,各区块间距 12px。
标题"新增真题试卷"使用 15px 加粗深海墨蓝字,是弹窗内最大的文字,明确告知用户当前操作的目的。
11.3.2 文本输入字段
Column({ space: 6 }) {
Text('科目').fontSize(9).fontColor(COLORS.sub)
TextInput({ text: this.formSubject, placeholder: '如:考研数学一 / 英语一 / 408 计算机' })
.fontSize(11).fontColor(COLORS.title)
.backgroundColor(COLORS.chip).borderRadius(8)
.onChange((value: string) => { this.formSubject = value; })
}
.width('100%').alignItems(HorizontalAlign.Start)
每个文本输入字段都是一个 Column,包含标签(9px 青灰蓝字)和输入框(11px 深海墨蓝字)。输入框使用浅云蓝色(chip)作为背景色,8px 圆角,形成"胶囊输入框"的视觉效果。
输入框的 placeholder 提供了具体的填写示例(如"考研数学一 / 英语一 / 408 计算机"),降低用户的认知负担。这种"示例式占位符"比"请输入科目"之类的泛泛提示更友好。
onChange 回调实时将输入内容同步到对应的 @State 变量(formSubject、formYear、formCount)。由于 ArkUI 的 TextInput 是受控组件,必须通过 text 属性绑定状态值并在 onChange 中更新状态,才能确保输入框内容与状态保持同步。
三个文本输入字段(科目、年份卷、题量)结构完全一致,只是标签、绑定变量和占位符不同。这种模式化的表单设计在代码中产生了一定的重复,但也让每个字段的逻辑独立清晰,易于理解和维护。
11.3.3 完成度滑块
Column({ space: 6 }) {
Text('完成度:' + this.formDone + '%').fontSize(9).fontColor(COLORS.sub)
Slider({ value: this.formDone, min: 0, max: 100, step: 5 })
.width('100%')
.blockColor(COLORS.blue)
.trackColor(COLORS.chip)
.selectedColor(COLORS.blue)
.onChange((value: number, mode: SliderChangeMode) => {
this.formDone = value;
})
}
.width('100%').alignItems(HorizontalAlign.Start)
完成度字段使用 Slider 滑块组件而非文本输入,因为完成度是一个连续值,滑块比输入框更直观、更有趣味性。
滑块的配置参数:
- value:当前值,绑定
this.formDone状态变量。 - min / max:取值范围 0 到 100,对应百分比。
- step:步长为 5,即滑块只能停在 5 的倍数位置(0、5、10…100),共 21 个档位。5% 的精度对于"完成度"这个概念来说已经足够,同时减少了用户的选择负担。
滑块的三部分颜色:
- blockColor(滑块按钮):海岸蓝,与主色调一致。
- trackColor(轨道背景):浅云蓝,与输入框背景色一致。
- selectedColor(已选中轨道):海岸蓝,与滑块按钮同色。
滑块的标签实时显示当前值('完成度:' + this.formDone + '%'),让用户在拖动过程中能精确看到当前的百分比数值。
11.3.4 底部按钮组
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.blue).borderRadius(9)
.onClick(() => {
this.savePaper();
})
}
.width('100%')
底部按钮组使用 Row 横向排列两个等宽按钮(layoutWeight(1) 等分),间距 10px。
"取消"按钮使用浅云蓝底 + 青灰蓝字的弱视觉样式,点击后调用 onClose() 关闭弹窗,不保存任何数据。
“保存"按钮使用海岸蓝底 + 白字加粗的强视觉样式,点击后调用 this.savePaper() 方法执行保存逻辑。保存按钮是弹窗的"主操作”,因此视觉权重更高——蓝色背景 + 白色文字 + 加粗字重,三重强调。
两个按钮都是 12px 字号,上下内边距 9px,9px 圆角,形成高度一致的胶囊形按钮。
11.4 编辑完成度弹窗 panelEdit
编辑完成度弹窗比新增弹窗简单,只包含一个滑块控件,用于调整已有真题的完成度。
@Builder
panelEdit(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('编辑完成度').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(this.editIdx >= 0 && this.editIdx < this.paperList.length
? this.paperList[this.editIdx].subject + ' · ' + this.paperList[this.editIdx].year
: '—').fontSize(10).fontColor(COLORS.sub)
// 滑块...
// 按钮组...
}
.width('80%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
}
// ...
}
编辑弹窗在标题下方增加了一行副标题,显示当前编辑的真题科目和年份(如"考研数学一 · 2023 卷")。这行副标题使用 10px 青灰蓝字,虽然字号很小,但作用关键——它让用户确认自己正在编辑的是哪一套真题,避免"点错了行却不知道"的操作失误。
副标题的取值有安全检查:this.editIdx >= 0 && this.editIdx < this.paperList.length。只有当索引有效时才显示科目和年份,否则显示"—"占位符。这种防御性编程确保了即使状态异常(如 editIdx 为 -1),UI 也不会崩溃。
编辑弹窗的滑块和按钮组与新增弹窗基本一致,只是保存时调用的方法不同——编辑调用 updatePaper(),新增调用 savePaper()。
11.5 删除确认弹窗 panelDel
删除确认弹窗是三个弹窗中最轻量的一个,只包含确认文案和两个按钮。
@Builder
panelDel(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 14 }) {
Text('🗑').fontSize(30)
Text('删除这套真题试卷?').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(this.delIdx >= 0 && this.delIdx < this.paperList.length
? this.paperList[this.delIdx].subject + ' · ' + this.paperList[this.delIdx].year
: '—').fontSize(9).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(COLORS.white).fontWeight(FontWeight.Bold)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.orange).borderRadius(9)
.onClick(() => {
this.delPaper();
})
}
.width('100%')
}
.width('74%').padding(18).backgroundColor(COLORS.card).borderRadius(14)
}
// ...
}
删除弹窗有几个独特的设计细节:
1. 更大的 emoji 图标(30px)
删除弹窗顶部有一个 30px 的垃圾桶 emoji(🗑),是整个应用中最大的图标之一。大图标在视觉上具有很强的冲击力,能立即引起用户注意,传达"这是一个重要操作"的信号。
2. 更窄的面板宽度(74%)
新增和编辑弹窗宽度都是 80%,删除弹窗只有 74%。更窄的宽度让删除弹窗看起来更"紧凑",也更符合"确认对话框"的经典比例。
3. 更大的内边距(18px)
内边距从 16px 增加到 18px,让内容与边缘之间有更多留白,增加呼吸感。
4. 珊瑚橙删除按钮
这是删除弹窗最关键的设计决策——删除按钮使用珊瑚橙色(COLORS.orange)而非海岸蓝色。在整个应用中,珊瑚橙是"警告、危险、需要注意"的语义色:
- 错因标签用珊瑚橙
- 删除按钮用珊瑚橙
- 真题卷统计用珊瑚橙(表示资料数量,略带警示意味)
用橙色作为删除按钮的颜色,符合用户对"危险操作"的色彩认知——红色/橙色 = 警告/危险。用户看到橙色按钮会下意识地更加谨慎,降低误删的概率。
5. 更大的行间距(14px)
删除弹窗的 Column 间距为 14px,比新增/编辑弹窗的 12px 更大。这是因为删除弹窗的内容更少(图标 + 标题 + 副标题 + 按钮组 = 4 行),适当增加间距可以避免内容显得过于稀疏。
11.6 弹窗触发与数据联动
三个弹窗的触发方式各不相同,对应不同的用户交互场景:
| 弹窗 | 触发方式 | 触发前准备 |
|---|---|---|
| 新增真题 | 点击"新增 +"按钮 | 重置表单字段为默认值 |
| 编辑完成度 | 点击真题列表行 | 设置 editIdx 和 editDone 初始值 |
| 删除确认 | 长按真题列表行 | 设置 delIdx |
以编辑弹窗为例,触发逻辑如下:
// 在 paperRow 方法中
.onClick(() => {
this.editIdx = idx;
this.editDone = item.done;
this.editModal = true;
})
点击真题行时,先设置编辑索引(editIdx)和初始完成度(editDone),然后打开编辑弹窗。数据准备必须在打开弹窗之前完成——因为弹窗渲染时会立即读取这些状态值。
删除弹窗的触发逻辑类似,但使用 gesture 手势监听长按事件:
.gesture(LongPressGesture()
.onAction(() => {
this.delIdx = idx;
this.delModal = true;
})
)
长按交互比点击交互的"操作成本"更高,因此适合用于删除这种不可逆的危险操作。用户需要按住一段时间才能触发删除确认,这在一定程度上防止了误触。
11.7 弹窗关闭的三种路径
每个弹窗都有三种关闭路径,最终都通过将状态变量设为 false 来关闭弹窗:
- 点击遮罩层:
modalOverlay的onClick→ 调用onClose回调 → 状态设为 false - 点击取消按钮:按钮的
onClick→ 调用onClose回调 → 状态设为 false - 操作成功后自动关闭:
savePaper()/updatePaper()/delPaper()方法内部将对应状态设为 false
第三种路径需要特别注意——操作成功后关闭弹窗是业务方法的职责之一。例如 savePaper 方法在保存数据后需要将 addModal 设为 false,否则弹窗不会自动关闭。
十二、功能模块对比表
本平台的六个 Tab 覆盖了备考全链路的不同环节,每个模块在技术实现、交互模式和数据结构上各有特点。本节从多个维度对各功能模块进行系统性对比,帮助读者快速把握各模块的定位和差异。
12.1 六 Tab 功能定位对比
| 维度 | 题库 Tab | 网页 Tab | 下载 Tab | 提醒 Tab | 铃音 Tab | 我的 Tab |
|---|---|---|---|---|---|---|
| 核心功能 | 科目筛选 + 真题清单 + 刷题统计 | 网页浏览 + 真题搜索 + 主动下载 | 下载进度 + 双 URL 溯源 | 通知授权 + 提醒管理 + 通知历史 | WAV 生成 + 沙箱落盘 + 铃声库 | 身份大卡 + 错题趋势 + 错题本 |
| 用户目标 | 管理真题试卷、追踪完成度 | 浏览资源站、下载真题文件 | 监控下载状态、验证来源 | 设置学习提醒、接收通知 | 定制个性化提醒铃声 | 查看个人数据、复盘错题 |
| 主要组件 | Scroll + ForEach + Canvas | Web + TextInput + Button | Progress + ForEach | Toggle + Slider + ForEach | Slider + ForEach + Button | Progress + ForEach + Column |
| 系统能力 | Canvas 2D 绘制 | ArkWeb + WebDownloadDelegate | ArkWeb 双 URL 溯源 | Notification Kit | CoreFileKit + 音频合成 | 声明式图表 |
| 数据模型 | PaperItem | 无独立模型 | DownloadRecord | RemindItem + NoticeLog | RingItem | MistakeItem |
| 交互模式 | 点击编辑 / 长按删除 | URL 输入 + 链接点击 | 状态展示(被动) | Toggle 切换 + Slider 调节 | 试听 + 生成 + 设默认 | 列表浏览 |
| 视觉焦点 | Canvas 渐变柱状图 | Web 网页内容区 | 下载进度卡片 | 通知授权状态卡 | 铃声坊渐变卡 | 身份渐变大卡 |
| 状态变量数 | 约 6 个 | 约 4 个 | 约 2 个 | 约 5 个 | 约 6 个 | 约 2 个 |
| 代码行数占比 | 约 20% | 约 12% | 约 10% | 约 15% | 约 18% | 约 12% |
从对比中可以看出,六个 Tab 在复杂度上相对均衡,但各有侧重。题库 Tab 和铃音 Tab 的代码量最大,因为它们涉及 Canvas 绘制和音频合成等复杂逻辑;下载 Tab 最简洁,主要是数据展示。
12.2 三大前沿特性对比
本平台深度整合了 HarmonyOS 6.1.1 的三大前沿特性,它们在技术难度、应用场景和用户价值上各有不同:
| 维度 | ArkWeb 双 URL 溯源 | Notification 沙箱铃声 | Canvas 渐变柱状图 |
|---|---|---|---|
| 所属系统能力 | ArkWeb 组件 | Notification Kit | Canvas 2D API |
| 核心 API | WebDownloadDelegate getOriginalUrl getReferrerUrl | NotificationRequest.sound fileUri.getUriFromPath | CanvasRenderingContext2D createLinearGradient fillRect |
| 解决的痛点 | 下载来源无法溯源、资料真伪难辨 | 通知铃声千篇一律、体验割裂 | 数据可视化缺乏动态效果 |
| 技术难度 | 中等 | 较高 | 中等 |
| 用户感知度 | 中(下载后才能看到) | 高(每次通知都能听到) | 高(视觉焦点) |
| 代码行数 | 约 80 行 | 约 120 行 | 约 60 行 |
| 涉及 Tab | 网页 Tab + 下载 Tab | 提醒 Tab + 铃音 Tab | 题库 Tab |
| 状态变量 | downloadRecords 数组 | remindEnabled、currentRingIdx 等 | breath、barCtx |
| 回调数量 | 4 个(下载生命周期) | 2 个(授权 + 发布) | 1 个(定时器) |
| 数据流向 | 被动接收 → 列表展示 | 主动生成 → 系统调用 | 定时触发 → Canvas 重绘 |
三大特性分别对应"可信下载"、“个性提醒”、"数据可视"三个价值方向,共同构成了平台的技术亮点。ArkWeb 解决的是信任问题(来源可查),Notification 解决的是体验问题(个性铃声),Canvas 解决的是表达问题(数据可视化)。
12.3 两种柱状图技术方案对比
题库 Tab 的 Canvas 柱状图与"我的"Tab 的声明式柱状图,实现了相似的视觉效果,但技术路线完全不同。两种方案各有优劣,适用于不同的场景:
| 维度 | Canvas 绘制柱状图 | 声明式组件柱状图 |
|---|---|---|
| 技术路线 | Canvas 2D API 逐像素绘制 | Column + ForEach 组件堆叠 |
| 所在 Tab | 题库 Tab | 我的 Tab(错题趋势) |
| 数据量 | 6 根柱子(六个月刷题量) | 6 根柱子(六个月错题数) |
| 渐变方向 | 垂直渐变(上浅下深) | 水平渐变(左浅右深) |
| 呼吸波动系数 | 93% ~ 100% | 92% ~ 100% |
| 柱体顶部形状 | 直角矩形 | 圆角(topLeft + topRight) |
| 网格线 | 3 条横向网格线 | 无网格线 |
| 顶部数值标注 | Canvas fillText 绘制 | Text 组件 |
| 底部标签 | Canvas fillText 绘制 | Text 组件 |
| 性能特点 | 单 Canvas 组件刷新,重绘成本固定 | 多个 Column 组件联动,组件数随数据量线性增长 |
| 灵活性 | 极高,可绘制任意图形 | 中等,受限于组件能力 |
| 开发效率 | 较低,需要手动计算坐标 | 较高,声明式布局直观 |
| 可维护性 | 中等,坐标计算逻辑较难理解 | 较高,组件结构清晰 |
| 适用场景 | 复杂图表、大数据量、自定义绘制 | 简单图表、小数据量、快速开发 |
两种方案的并存体现了平台的技术深度——不是简单地重复使用同一种实现,而是根据不同 Tab 的定位和数据特点选择最合适的技术路径。Canvas 方案用于题库 Tab 的核心数据展示(刷题量是核心指标),需要更精细的视觉效果(网格线、渐变、数值标注);声明式方案用于"我的"Tab 的辅助数据展示(错题趋势是次要指标),实现更简洁高效。
12.4 弹窗功能对比
三个弹窗虽然共享遮罩层,但在复杂度、字段数量和视觉设计上各有差异:
| 维度 | 新增真题弹窗 | 编辑完成度弹窗 | 删除确认弹窗 |
|---|---|---|---|
| 面板宽度 | 80% | 80% | 74% |
| 内边距 | 16px | 16px | 18px |
| 表单字段数 | 4 个(3 输入 + 1 滑块) | 1 个(滑块) | 0 个 |
| 主操作按钮色 | 海岸蓝(保存) | 海岸蓝(保存) | 珊瑚橙(删除) |
| 顶部图标 | 无 | 无 | 有(30px 🗑) |
| 副标题 | 无 | 有(科目 + 年份) | 有(科目 + 年份) |
| 触发方式 | 点击"新增 +"按钮 | 点击真题行 | 长按真题行 |
| 操作可逆性 | 可逆(可删除) | 可逆(可重新编辑) | 不可逆 |
| 业务方法 | savePaper() | updatePaper() | delPaper() |
| 认知负荷 | 高(需填写多项) | 中(调整单值) | 低(确认操作) |
三种弹窗的设计遵循了"操作越危险,确认越强"的原则——新增操作最安全,因此没有大图标警告;编辑操作可撤销,因此有副标题确认;删除操作不可逆,因此用橙色按钮 + 大图标 + 长按触发三重防护。
12.5 数据模型对比
六个 @Observed 数据模型类分别支撑六个功能模块,它们在结构复杂度和字段数量上各有特点:
| 维度 | PaperItem | DownloadRecord | RemindItem | RingItem | NoticeLog | MistakeItem |
|---|---|---|---|---|---|---|
| 字段数量 | 6 个 | 7 个 | 5 个 | 6 个 | 4 个 | 5 个 |
| 字符串字段 | 4 个 | 5 个 | 2 个 | 3 个 | 3 个 | 4 个 |
| 数字字段 | 2 个 | 2 个 | 2 个 | 1 个 | 1 个 | 1 个 |
| 布尔字段 | 0 个 | 0 个 | 1 个 | 1 个 | 0 个 | 0 个 |
| Mock 数据量 | 8 条 | 4 条 | 5 条 | 4 条 | 3 条 | 6 条 |
| 所属 Tab | 题库 | 下载 | 提醒 | 铃音 | 提醒 | 我的 |
| 是否参与 CRUD | 是(增删改) | 否(只读追加) | 否(只读切换) | 否(只读修改) | 否(只追加) | 否(只读) |
| 唯一键组合 | subject + year | fileName | title + time | name | id | question |
从对比可以看出,PaperItem 是唯一支持完整 CRUD 操作的数据模型,这也是题库 Tab 功能最复杂的原因之一。其他模型大多是只读或只追加的,交互相对简单。
12.6 工具函数对比
五个工具函数虽然简短,但在各自的应用场景中发挥着重要作用:
| 函数名 | 参数 | 返回值 | 功能 | 调用频次 | 所在模块 |
|---|---|---|---|---|---|
| buildWavBytes | freq: number durationMs: number | ArrayBuffer | 生成 WAV 音频字节 | 低(生成铃声时) | 铃音 Tab |
| extractDomain | url: string | string | 从 URL 提取域名 | 中(每条下载记录) | 下载 Tab |
| subjectColor | subject: string | string | 科目到颜色的映射 | 高(每行真题/错题) | 题库 + 我的 |
| downloadStatusColor | status: string | string | 下载状态到颜色的映射 | 中(每条下载记录) | 下载 Tab |
| doneColor | done: number | string | 完成度到颜色的映射 | 高(每行真题) | 题库 Tab |
subjectColor 和 doneColor 是调用频次最高的两个工具函数,因为它们在 ForEach 循环中被每行数据调用一次。虽然函数逻辑简单(条件判断 + 返回字符串),但在大数据量场景下,函数调用的累积开销也需要考虑。对于 ArkUI 的声明式渲染来说,这些函数会在每次列表刷新时被重新调用,因此保持函数的"纯函数"特性(无副作用、相同输入总是返回相同输出)非常重要。
十三、状态管理体系深度分析
本平台的状态管理采用"集中式声明式状态管理"模式——所有 @State 变量统一声明在组件顶层,通过状态变更驱动 UI 刷新。这种模式在单文件组件中简单直观,但随着状态数量的增长,也需要系统性的组织策略。
13.1 状态变量全景图
组件内共声明了约 30 个 @State 变量,按功能可分为七大组:
每组状态对应一个功能模块,但跨组的状态也存在关联。例如,currentTab 变化时会触发内容区切换,但不会影响其他组的状态;paperList 变化时会触发题库 Tab 的列表刷新,同时可能影响"我的"Tab 的错题统计(如果设计了联动的话)。
13.2 状态粒度设计
状态粒度的选择是状态管理的核心设计决策。本平台在状态粒度上体现了"按需拆分"的原则:
粗粒度状态(数组/对象级):
paperList: PaperItem[]- 整个真题列表作为一个状态downloadRecords: DownloadRecord[]- 整个下载记录列表作为一个状态ringList: RingItem[]- 整个铃声列表作为一个状态
粗粒度状态的优点是状态变量数量少、管理简单,缺点是修改单条数据时需要创建新数组引用才能触发刷新。代码中普遍使用 this.xxxList = this.xxxList.slice() 的方式来触发列表刷新。
细粒度状态(单值级):
currentTab: number- Tab 索引cateIdx: number- 科目分类索引breath: boolean- 呼吸动画状态addModal: boolean- 新增弹窗开关
细粒度状态的优点是变更精准、刷新范围小,缺点是状态变量数量多、命名和组织需要精心设计。
13.3 状态联动与派生计算
部分 UI 数据不是直接的状态变量,而是通过计算方法派生而来。最典型的是 visiblePapers() 方法:
visiblePapers(): PaperItem[] {
if (this.cateIdx === 0) {
return this.paperList;
}
const keyword = SUBJECT_TAGS[this.cateIdx];
return this.paperList.filter(p => p.subject.includes(keyword));
}
visiblePapers() 是 cateIdx 和 paperList 两个状态的派生值——只要其中任何一个变化,筛选结果就会变化。在声明式 UI 中,由于 ForEach 直接调用 this.visiblePapers(),每次状态变更导致重新渲染时,该方法会被重新执行,自动获得最新的筛选结果。
这种"计算方法 + 自动重算"的模式类似于 React 中的"计算属性"或 Vue 中的 computed,但在 ArkUI 中需要手动在 @Builder 中调用方法来实现。它的优点是不需要额外的状态变量,数据始终保持一致;缺点是每次渲染都会重新计算,对于复杂计算可能影响性能。
13.4 状态更新的最佳实践
代码中的状态更新遵循了若干最佳实践:
1. 单一数据源原则
每个数据只有一个"真源"。例如,真题列表的真源是 paperList,筛选后的列表是派生值;完成度的真源是 paperList 中每个 PaperItem 的 done 字段,编辑弹窗中的 editDone 只是临时状态。
2. 不可变更新模式
对于数组和对象类型的状态,采用不可变更新——不直接修改原数组/对象,而是创建新的数组/对象引用。例如:
// 正确:创建新数组
this.paperList = this.paperList.slice();
// 正确:使用展开运算符
this.paperList = [...this.paperList, newItem];
不可变更新确保了 @State 能够检测到变化并触发刷新。虽然 @Observed 装饰的对象可以检测属性变化,但数组的整体刷新仍然依赖于引用变化。
3. 状态变更原子化
每次操作尽量只变更最少的状态。例如,新增真题的 savePaper 方法只修改 paperList 和 addModal 两个状态,不涉及其他不相关的状态。
4. 防御性索引检查
在使用索引访问数组前进行有效性检查:
this.editIdx >= 0 && this.editIdx < this.paperList.length
这种防御性编程确保了即使状态异常(如索引越界),UI 也不会崩溃,而是优雅降级(显示占位符)。
十四、性能优化与渲染策略
虽然本平台是一个演示性质的应用,但在代码中仍然体现了若干性能优化意识和渲染策略。这些优化点在真实的生产环境应用中会更加重要。
14.1 ForEach 键生成函数
ForEach(this.visiblePapers(), (item: PaperItem, idx: number) => {
this.paperRow(item, idx)
}, (item: PaperItem) => item.subject + item.year)
ForEach 的第三个参数是键生成函数(key generator),它为列表中的每一项生成一个唯一的键。ArkUI 框架使用这个键来判断哪些项是新增的、哪些是删除的、哪些是移动的,从而最小化 DOM 操作。
本平台中各列表的键生成策略:
| 列表 | 键生成函数 | 唯一性分析 |
|---|---|---|
| 真题列表 | item.subject + item.year | 高(同一科目同一年份通常只有一套卷) |
| 下载记录 | item.fileName | 中(可能存在同名文件) |
| 提醒列表 | item.title + item.time | 高(同时刻同标题的提醒概率低) |
| 铃声列表 | item.name | 高(铃声名称通常不重复) |
| 通知历史 | item.id | 高(ID 天然唯一) |
| 错题本 | item.question | 中(可能有相同题面的错题) |
键生成函数的选择直接影响列表刷新的性能。使用稳定且唯一的键可以让框架更精准地复用组件,减少不必要的销毁和重建。
14.2 条件渲染与懒加载
if (this.currentTab === 0) {
this.tabPaper()
} else if (this.currentTab === 1) {
this.tabWeb()
}
// ... 其他 Tab
Tab 切换使用 if/else if 条件渲染,而非"全部渲染 + 显示/隐藏"。这意味着当前只渲染用户看到的那个 Tab,其他 Tab 的组件树完全不构建。
这种策略的优点是:
- 初始加载快:只构建第一个 Tab 的 UI,启动时间短
- 内存占用低:不同时存在六个 Tab 的组件树
- 状态隔离好:切换 Tab 时前一个 Tab 的组件被销毁,状态自然重置
缺点是:
- Tab 切换时有重建开销:每次切换都需要重新构建目标 Tab 的组件树
- 滚动位置不保留:切换 Tab 后再切回来,滚动位置会重置
对于本平台这种数据量不大的演示应用,条件渲染是更合适的选择——它更简单、更节省资源。如果是需要在 Tab 间频繁切换且保留状态的应用,可以考虑使用 Tabs 组件或自定义的"渲染 + 可见性控制"方案。
14.3 Canvas 绘制优化
Canvas 柱状图的绘制函数 drawBar 每次调用都会完全清除画布并重绘所有内容。对于 6 根柱子的简单图表,这完全不是问题,但对于更复杂的图表,可以考虑以下优化策略:
1. 局部重绘
只重绘变化的区域,而非整个画布。例如呼吸动画只影响柱体高度,可以只清除柱体区域并重绘,保留网格线和标签。
2. 离屏缓存
将静态部分(网格线、标签、背景)绘制到离屏 Canvas 上,每帧只需要把离屏 Canvas 贴上来再绘制动态部分(柱体)。
3. 帧率控制
当前呼吸动画使用 1 秒间隔(每秒翻转一次 breath 状态),远低于 60fps。这种低频动画对性能影响极小,完全不需要额外优化。
14.4 颜色常量与性能
将所有颜色集中定义为常量(COLORS 对象),除了便于统一管理外,还有微妙的性能影响:
- 避免重复创建字符串:每次使用
COLORS.blue都是引用同一个字符串对象,而非创建新的字符串。 - 减少样式计算:统一的颜色值可以让框架在底层做更多的样式缓存和复用。
当然,对于现代 JS 引擎来说,字符串的性能影响微乎其微。颜色常量的主要价值还是可维护性和一致性。
十五、总结与展望
15.1 架构设计总结
本平台以"海岸蓝 + 珊瑚橙"浅色主题为视觉基调,围绕研究生备考全链路构建了题库管理、真题下载、学习提醒、铃声定制和错题复盘六大功能模块,在单页面内实现了 HarmonyOS 6.1.1 三大前沿特性的同文件叠加。
从架构设计的角度审视,源码体现了以下几个核心理念:
1. 分层组织,纵向清晰
文件遵循"常量声明在前、数据模型居中、组件主体在后、Builder 群函数收尾"的纵向组织原则。这种分层让近 1800 行代码在单文件内保持了良好的可读性——开发者从上往下读,先了解数据定义,再了解业务逻辑,最后看 UI 渲染,符合认知规律。
2. 声明式 UI,状态驱动
全面采用 ArkUI 的声明式 UI 范式,通过 @State 管理响应式状态、@Builder 拆分复杂 UI、ForEach 驱动列表渲染。所有 UI 都是状态的映射,修改状态即可更新界面,开发者无需手动操作组件引用。
3. 数据驱动,配置先行
Tab 列表、科目分类、快捷站点等可配置项全部抽离为常量数组,UI 通过 ForEach 动态渲染。新增 Tab 或科目只需修改数据,无需改动渲染逻辑。这种数据驱动的设计大大提高了代码的可扩展性。
4. 组件复用,逻辑内聚
弹窗遮罩层 modalOverlay 被三个弹窗面板复用,避免了重复代码。真题行 paperRow 被题库 Tab 的清单列表复用,铃音行被铃声库列表复用。每个 @Builder 方法职责单一,逻辑内聚。
5. 防御编程,优雅降级
数组索引访问前的有效性检查、字符串空值处理、默认值兜底等防御性编程手段,确保了应用在状态异常时不会崩溃,而是优雅降级。
15.2 技术亮点回顾
ArkWeb 双 URL 溯源
WebDownloadDelegate 的四回调链(onBeforeDownload → onDownloadUpdated → onDownloadFinish / onDownloadFailed)为真题下载提供了完整的生命周期管理。onDownloadFinish 中的 getOriginalUrl 与 getReferrerUrl 双接口让每次下载都能溯源来路,解决了备考资料真伪难辨的痛点。
Notification 沙箱自定义铃声
通过 'uri::' + fileUri.getUriFromPath(沙箱路径) 将应用 EL1 区域内用户生成的音频文件设为通知铃声,实现了"开考铃""满分赞"等个性化提醒音效。WAV 音频字节的纯代码生成(正弦波 + 起音包络 + 自然衰减)展示了在不依赖音频资源文件的情况下,通过底层字节操作创造丰富音频体验的可能性。
Canvas 渐变柱状图 + 呼吸动画
drawBar 方法通过 Canvas 2D API 绘制带渐变填充、网格线、数值标注的专业级柱状图。breath 状态每秒翻转一次,联动柱高 ±7% 微波动,模拟实时数据刷新的效果。呼吸动画虽然简单,但为静态的数据可视化增添了生命感。
15.3 可优化空间与改进方向
尽管本平台在单文件演示的限制下已经实现了丰富的功能,但从生产级应用的角度来看,仍有多个可以优化和扩展的方向:
1. 组件拆分
当前所有功能集中在单个组件内,六个 Tab 的逻辑相互耦合。可以将每个 Tab 拆分为独立的子组件,各自管理自己的状态和方法,通过 props 和 events 与父组件通信。拆分后每个文件的代码量会大幅减少,可读性和可维护性显著提升。
2. 状态管理升级
对于跨 Tab 共享的数据(如用户信息、设置项),可以引入 AppStorage 或 PersistentStorage 进行全局状态管理。对于复杂的业务逻辑,可以考虑使用 ViewModel 模式将业务逻辑从 UI 组件中抽离。
3. 数据持久化
当前所有数据都是内存中的 Mock 数据,应用重启后会丢失。可以引入关系型数据库(RelationalStore)或首选项(Preferences)将用户数据持久化到本地,实现真正可用的备考记录管理。
4. 网络请求封装
网页 Tab 的下载功能依赖用户手动输入 URL。可以进一步封装网络请求模块,对接真实的真题资源 API,实现真题列表的在线获取和一键下载。
5. 更多图表类型
当前只有柱状图一种图表类型。可以增加折线图(展示刷题趋势)、饼图(展示科目占比)、雷达图(展示各科目能力分布)等更多图表类型,丰富数据可视化手段。
6. 动画增强
当前的动画效果较少(只有呼吸动画)。可以增加 Tab 切换动画、弹窗入场/出场动画、列表项入场动画、进度条变化动画等,提升应用的流畅感和品质感。
7. 深色模式
当前只有浅色主题。可以根据系统深色模式设置自动切换深色主题,或者提供主题切换选项。深色模式需要重新设计一整套颜色方案,确保对比度和可读性。
8. 多端适配
当前设计面向手机竖屏。可以适配平板、折叠屏等不同设备形态,以及横屏显示模式。响应式布局需要在断点处调整间距、字号、列数等。
15.4 技术价值与启示
本平台虽然是一个演示性质的单文件应用,但它展示的技术思路和架构模式具有普遍的参考价值:
对于 HarmonyOS 开发者,它展示了如何在 ArkUI 声明式范式下组织复杂页面、如何整合系统级能力(ArkWeb、Notification、Canvas)、如何设计数据模型与状态管理。
对于前端开发者,它印证了"声明式 UI + 状态驱动 + 组件化"的范式在跨端框架中的普适性——无论是 React、Vue 还是 ArkUI,核心思想都是相通的。
对于产品设计者,它展示了如何围绕用户全链路需求(题库 → 下载 → 提醒 → 复盘)构建功能闭环,以及如何通过细节设计(色彩语义、交互反馈、数据可视化)提升用户体验。
备考之路漫漫,技术探索之路亦然。希望这份深度解析能为读者带来启发,在各自的技术道路上不断精进,行稳致远。
附录: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 应用的功能开发。
更多推荐

所有评论(0)