海岸蓝与沙箱铃声的双轨溯源背后的HarmonyOS ArkUI 备考学习资源平台
一、技术前言
在考研备考这条漫长而艰辛的道路上,学习资源的有效管理往往决定了最终的成绩走向。从真题试卷的精准检索到下载存档的来路追溯,从学习提醒的定时推送到自定义铃声的差异化触达,从刷题量的可视化统计到错题趋势的动态跟踪——一个合格的备考学习资源平台需要将"内容获取、进程管理、行为激励、数据复盘"四个维度有机串联。传统的备考类应用普遍存在三大短板:真题下载后无法溯源来路导致资料真伪难辨、通知铃声千篇一律导致重要提醒被忽略、刷题数据仅停留在数字层面缺乏趋势化呈现。
HarmonyOS ArkUI 框架为上述痛点提供了系统级的声明式解决方案。ArkUI 以 ArkTS 语言为基础,通过 @Component 装饰器将 UI 封装为可复用的组件单元,通过 @State、@Observed、@Builder 等装饰器实现"数据驱动视图"的响应式渲染模型。具体而言,@State 管理组件内部状态,当状态值变化时自动触发依赖该状态的 UI 片段重新构建;@Observed 标记的类实例在属性被修改时能驱动引用它的列表视图局部刷新;@Builder 方法则将复杂的 UI 树拆解为可组合、可传参的构建块,有效降低 build() 方法的圈复杂度。这种架构天然契合备考场景中"数据-视图-交互"高度耦合的需求。
本平台深度融合了 HarmonyOS 6.1.1 的三大前沿特性。ArkWeb 提供了 WebDownloadDelegate 的四回调机制——onBeforeDownload(下载开始前提供沙箱路径)、onDownloadUpdated(下载进行中刷新进度)、onDownloadFailed(下载失败回收资源)、onDownloadFinish(下载完成溯源来路)。其中完成回调中新增的 getOriginalUrl()(获取下载项的原始 URL,即文件直链来源)与 getReferrerUrl()(获取引用页 URL,即触发下载的页面地址)双接口,让每一次真题下载都能精确溯源来路,彻底解决资料真伪难辨的问题。Notification Kit 实现了沙箱自定义铃声的完整链路——通过 buildWavBytes 函数在内存中合成正弦波 PCM 音频字节,写入 EL1 沙箱 filesDir 目录,再以 'uri::' + fileUri.getUriFromPath(沙箱路径) 的格式填入 NotificationRequest.sound 字段,让"开考铃"“交卷铃”"满分赞"等不同场景的提醒拥有差异化的听觉标识。Canvas 绘制 通过 drawBar() 方法绘制近六个月刷题量的渐变柱状图,并以一秒一次的呼吸动画联动柱高微幅波动重绘,让静态数据呈现"活着"的动态质感。
在技术决策层面,本平台选择了浅色海岸蓝白主题(底色 #F2F6FA、主色海岸蓝 #2F7BD9、辅色珊瑚橙 #FF7E5A),区别于深色主题的夜间巡检场景,浅色主题更契合日间备考的清醒专注氛围。六个 Tab 采用完全异构的布局结构——题库用科目横滚 chips 加 Canvas 柱状图、网页用地址栏加 Web 组件、下载用进度卡加双 URL 溯源列表、提醒用固定行高竖线时间轴、铃音用正弦波生成器加铃声库、我的用渐变大卡加错题本——这种"一 Tab 一布局"的策略最大化了各功能场景的表达力。弹窗系统采用 modalOverlay 全屏遮罩叠加三个面板(新增、编辑、删除)的方案,通过条件渲染实现按需弹出,避免预渲染带来的性能浪费。
二、整体架构流程图
整体架构以 Page1217 为根组件,采用 Stack 容器实现页面层叠:底层是 Column 纵向布局的头部 Banner + 可滚动内容区 + 底部 Tab 栏三层结构,顶层是条件渲染的三套弹窗面板。内容区通过 currentTab 状态索引在六个 @Builder 方法间做 if-else 分支切换,每个 Tab 拥有完全独立的布局结构、数据源和交互逻辑。三大前沿特性分别挂载在不同的 Tab 上:ArkWeb 下载溯源链路横跨网页 Tab 与下载 Tab(网页触发下载、下载展示溯源结果),Notification 沙箱铃声链路横跨提醒 Tab 与铃音 Tab(铃音生成落盘、提醒发布携带铃声),Canvas 与 ForEach 双可视化引擎分别挂载在题库 Tab 与我的 Tab(刷题量渐变柱状图、错题趋势传统柱状图)。但所有状态变量统一声明在组件顶层,实现跨 Tab 数据共享——例如 currentRingIdx 在铃音 Tab 中设定,却在提醒 Tab 的发布通知方法中被读取;dlState 在网页 Tab 中被写入,却在下载 Tab 中被展示。这种"状态上提、视图下沉"的设计是 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; // 渐变卡上的进度条轨道色
}
接口定义了十六个颜色字段,覆盖了页面底色、卡片底色、胶囊背景、多级文字、主辅色、分割线、Tab 激活色、弹窗遮罩以及渐变卡片上专用的三色(纯白文字、弱化白文字、进度条轨道色)。渐变卡片专用色的设计非常精妙——因为渐变 Banner 内部背景是深色的海岸蓝,普通的白色文字和浅色文字在上面会显得过于刺眼或过于暗淡,所以专门定义了 whiteSoft(82% 不透明度的白)用于副标题、trackW(32% 不透明度的白)用于进度条轨道,保证渐变卡内部的视觉层次柔和过渡。
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',
line: '#DDE7F0',
tabOn: '#2F7BD9',
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,作为渐变起点色和加粗数值色,与主色形成深浅搭配。orange 为珊瑚橙 #FF7E5A,辅色一,用于未完成、删除、发布提醒等需要警示但不危险的场景;green 为海藻绿 #34B37E,辅色二,用于已完成、已授权、进步趋势等积极语义;purple 为鸢尾紫 #8A6FD1,辅色三,用于 408 计算机科目这一特定分类的着色。
line 为浅雾线 #DDE7F0,分割线色,低对比度不干扰内容。tabOn 与 blue 一致,底部 Tab 选中态用主色高亮。mask 为半透明深海墨 rgba(34,48,60,0.5),弹窗遮罩,50% 不透明度保证遮罩下层内容隐约可见但不干扰弹窗交互。最后三色 white、whiteSoft、trackW 专用于渐变卡片内部,前文已述。
四、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 接口定义了底部导航项的结构:icon 为 Emoji 图标(跨平台兼容、无需图片资源)、label 为中文标签。TAB_LIST 常量数组定义了六个 Tab,单排排列。六个 Tab 的图标选择颇具语义:📚 书堆代表题库、🌐 地球代表网页浏览、📥 下载箭头代表下载存档、⏰ 闹钟代表时间提醒、🎵 音符代表铃声、👤 人像代表个人中心。底部导航栏通过 ForEach 遍历此数组渲染,选中态用 tabOn 色 + 加粗 + 不透明度 1.0,未选中态用 text3 色 + 常规字重 + 不透明度 0.65。
4.2 科目分类与快捷站点
const SUBJECT_TAGS: string[] = ['全部', '考研数学', '英语一', '政治', '408', '管综'];
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' }
];
SUBJECT_TAGS 是题库 Tab 的科目横滚 chips 数据,首项"全部"不过滤、其余项按科目关键词匹配过滤真题列表。六项覆盖了考研五大主流科目方向。QuickSite 接口定义了快捷站点结构(图标+名称+地址),QUICK_SITES 常量收录了四个教育考试类真实站点,这些站点都是考研备考者日常访问的官方渠道,点击即加载到 Web 组件,省去手动输入网址的步骤。
4.3 图表数据与铃声预设
const MONTH_LABELS: string[] = ['03月', '04月', '05月', '06月', '07月', '08月'];
const BRUSH_VAL: number[] = [128, 196, 242, 168, 286, 324];
const MISTAKE_VAL: number[] = [46, 38, 52, 31, 27, 19];
const RING_FREQ_PRESETS: number[] = [440, 660, 880, 1320];
const RING_DURATION_PRESETS: number[] = [600, 1200, 2000];
MONTH_LABELS 为近六个月月份标签,被 Canvas 柱状图和错题趋势图共用为底部标注。BRUSH_VAL 为刷题量 Mock 数据,整体呈上升趋势(从 128 题增长到 324 题),传递备考进度向好的信号。MISTAKE_VAL 为错题量 Mock 数据,整体呈下降趋势(从 46 题降至 19 题),与刷题量上升形成"刷题越多、错题越少"的正向反馈叙事。RING_FREQ_PRESETS 为正弦波基频四档(440Hz 标准 A 音、660Hz、880Hz 高 A 音、1320Hz),RING_DURATION_PRESETS 为时长三档(600ms 短促、1200ms 标准、2000ms 余音),组合可生成十二种不同音色的铃声。
五、工具函数
5.1 正弦波 WAV 音频合成函数
function buildWavBytes(freq: number, durationMs: number): ArrayBuffer {
const sampleRate = 44100;
const numSamples = Math.floor(sampleRate * durationMs / 1000);
const dataSize = numSamples * 2;
const buf = new ArrayBuffer(44 + dataSize);
const view = new DataView(buf);
const writeStr = (offset: number, s: string) => {
for (let i = 0; i < s.length; i++) {
view.setUint8(offset + i, s.charCodeAt(i));
}
};
writeStr(0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeStr(36, 'data');
view.setUint32(40, dataSize, true);
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
const env = Math.min(1, i / (sampleRate * 0.02));
const decay = Math.max(0, 1 - t / (durationMs / 1000));
const v = Math.sin(2 * Math.PI * freq * t) * 0.5 * env * decay;
view.setInt16(44 + i * 2, Math.round(v * 32767), true);
}
return buf;
}
这是整个平台最具技术深度的工具函数。它接收频率(Hz)和时长(ms)两个参数,在内存中合成一段符合 WAV 标准的 16bit 单声道 PCM 音频字节流,返回 ArrayBuffer。
函数的执行逻辑分为两阶段。第一阶段:写入 44 字节 WAV 文件头。WAV 文件遵循 RIFF 容器格式,文件头包含:RIFF 标识符(4 字节)、文件总大小减 8(4 字节)、WAVE 格式标识(4 字节)、fmt 子块标识(4 字节)、fmt 子块大小 16(4 字节)、音频编码格式 1=PCM(2 字节)、声道数 1=单声道(2 字节)、采样率 44100Hz(4 字节)、字节速率 88200(4 字节)、块对齐 2(2 字节)、位深度 16bit(2 字节)、data 子块标识(4 字节)、音频数据大小(4 字节)。所有多字节整数使用小端序(true 参数)。内部定义的 writeStr 闭包将字符串逐字节写入指定偏移,用于写入 RIFF、WAVE、fmt、data 四个 ASCII 标识符。
第二阶段:生成 PCM 采样数据。循环遍历每个采样点,计算当前时间 t(秒)、起音包络 env(前 20ms 从 0 渐升至 1,避免开声爆裂)、自然衰减 decay(从 1 线性衰减至 0,模拟铃声自然衰减),最终振幅 v = sin(2πf·t) × 0.5 × env × decay。其中 0.5 是基础音量系数,避免削波失真。将振幅乘以 32767(16bit 最大值)四舍五入后写入数据区。这个函数的精妙之处在于:它不依赖任何外部音频文件,纯数学运算生成可播放的音频字节,真正实现了"用户生成的音频文件",为后续写入沙箱、设为通知铃声提供了数据基础。
5.2 站点域名与颜色映射函数
function siteHost(url: string): string {
const head = 'https://';
if (url.startsWith(head)) {
return url.slice(head.length);
}
return url;
}
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;
}
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;
}
function doneColor(v: number): string {
if (v >= 80) { return COLORS.green; }
if (v >= 50) { return COLORS.blue; }
return COLORS.orange;
}
四个映射函数各司其职。siteHost 去掉 URL 的 https:// 前缀,用于地址栏提示和快捷站点卡的域名展示。subjectColor 将科目名映射为主题色——数学对应海岸蓝、英语对应珊瑚橙、政治对应海藻绿、408 对应鸢尾紫、管综对应深海岸蓝,这五色对应在真题清单行的左侧年份色块、进度条和错题本色条上,让用户凭颜色即可快速定位科目。dlStateColor 将下载状态文案映射为语义色——完成用绿、失败用橙、空闲用灰、进行中用蓝。doneColor 将完成度数值映射为三档颜色——80 及以上用绿(优秀)、50 至 80 用蓝(中等)、50 以下用橙(偏低),在真题清单右侧完成度数字上着色,形成视觉化的进度警示。
六、数据模型层
6.1 PaperItem 真题试卷条目
@Observed export class PaperItem {
subject: string;
year: string;
count: string;
done: number;
constructor(subject: string, year: string, count: string, done: number) {
this.subject = subject;
this.year = year;
this.count = count;
this.done = done;
}
}
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)
];
PaperItem 使用 @Observed 装饰器标记,意味着当其实例的属性被修改时,引用该实例的 ForEach 列表会局部刷新对应行。四个属性中,done(完成度)是最常被修改的字段——编辑弹窗通过 Slider 调整后写回 this.paperList[this.editIdx].done,@Observed 机制确保列表行右侧的完成度数字和进度条同步更新。PAPER_LIST 常量提供了八条 Mock 数据,覆盖五个科目、两个年份卷,完成度从 35% 到 90% 分布,既有高完成度的政治 90%,也有偏低的 408 计算机 35%,为颜色映射函数提供了完整的展示样本。
6.2 DownloadRecord 下载记录条目
@Observed export class DownloadRecord {
fileName: string;
fileSize: string;
finishTime: string;
originalUrl: string;
referrerUrl: string;
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 溯源特性的数据载体。五个属性中,originalUrl 存储的是 getOriginalUrl() 的返回值——下载项的原始 URL,即文件直链来源(如 https://files.neea.edu.cn/exam/2026/kaoyan/math1_2025_full.pdf?track=official);referrerUrl 存储的是 getReferrerUrl() 的返回值——引用页 URL,即触发下载的页面地址(如 https://www.neea.edu.cn/exam/download?subject=kaoyan-math)。这两个字段的区别在于:原始 URL 是文件实际下载的直链地址,引用页 URL 是用户当时浏览的页面地址。当下载的真题存在版本争议时,通过双 URL 可以追溯到"从哪个页面的哪个链接下载的",彻底解决资料来路不透明的问题。七条 Mock 数据中双 URL 均为带域名、路径、查询参数的完整真实感地址,让溯源展示更具说服力。
6.3 RemindItem 学习提醒条目
@Observed export class RemindItem {
time: string;
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 的 on 属性是时间轴行的核心交互字段——通过 Toggle 开关切换后,toggleRemind 方法修改 this.remindList[idx].on,@Observed 驱动该行的时间色、圆点色、状态文案同步刷新。六条 Mock 数据覆盖了备考全天的关键节点:清晨背单词、上午刷题、模考开始、午间错题回顾、报名截止提醒、睡前带背,重复规则包含"每天"“周六”"单次"三种模式。
6.4 RingItem 铃声条目与 NoticeLog 通知历史
@Observed export class RingItem {
name: string;
file: string;
freq: number;
duration: number;
size: string;
inSandbox: boolean;
// 构造函数省略
}
@Observed export class NoticeLog {
title: string;
text: string;
time: string;
// 构造函数省略
}
RingItem 是铃声坊的核心数据模型,六个属性完整描述了一个铃声的生命周期:name 是用户可读名称、file 是沙箱文件名、freq 和 duration 是生成参数、size 是文件大小(未生成时显示"—“)、inSandbox 是是否已写入 EL1 沙箱的状态标志。当 importRingToSandbox 方法执行后,inSandbox 置 true、size 更新为实际 KB 值,@Observed 驱动铃声库行的按钮文案从"生成到沙箱"切换为"已入沙箱”。五条初始 Mock 数据的铃声分别对应"开考铃"“交卷铃”“满分赞”“报名提醒铃”“晚自习轻铃”,频率从 220Hz 到 1320Hz 覆盖低沉到清亮。NoticeLog 则是已发布通知的历史记录,三条 Mock 数据展示了通知标题、正文和时间。
6.5 MistakeItem 错题本条目
@Observed export class MistakeItem {
subject: string;
question: string;
reason: string;
time: string;
// 构造函数省略
}
MistakeItem 的 reason 属性(错因标签)是错题本列表的亮点——七条 Mock 数据覆盖了"计算失误"“概念混淆”“主观臆断”“选项漏选”“公式记错”“偷换概念”"词义辨析"七种典型考研错因,用珊瑚橙标注,让用户一眼识别自己的薄弱环节。subject 属性通过 subjectColor 函数映射为左侧色条颜色,与题库 Tab 的科目色保持一致。
七、组件主体结构
7.1 状态变量声明
Page1217 组件顶部声明了大量状态变量,按功能可分为五组。
第一组:Tab 与动画状态。 currentTab 控制当前显示的 Tab 索引(0~5),breath 是呼吸动画的布尔翻转标志(每秒取反一次),timer 是 setInterval 的句柄,用于 aboutToDisappear 时清理。呼吸动画是本平台可视化双引擎的驱动力——breath 为 true 时柱高系数 1.0、为 false 时 0.93 或 0.92,Canvas 柱状图和 ForEach 柱状图都随 breath 翻转而微幅波动。
第二组:弹窗状态。 addModal、editModal、delModal 三个布尔开关分别控制新增、编辑、删除弹窗的显示;editIdx 和 delIdx 记录当前操作的真题索引。三个弹窗互斥显示,通过 build() 中的三个独立 if 条件渲染实现。
第三组:业务数据。 paperList、downloadRecords、remindList、ringList、noticeLogs、mistakeList 六个 @State 数组分别绑定六个 Tab 的列表数据,初始值来自对应的 Mock 常量。注意这些数组在修改后都会调用 this.xxxList = this.xxxList.slice() 做浅拷贝重新赋值——这是触发 ArkUI 数组级响应式刷新的关键手法,因为直接修改数组元素的属性虽然能通过 @Observed 刷新行,但新增/删除/排序等结构性变化必须通过重新赋值数组引用才能让 ForEach 重新遍历。
第四组:表单数据。 formSubject、formYear、formCount、formDone 是新增弹窗的四个表单字段;editDone 是编辑弹窗的完成度滑杆值。这些值在弹窗打开时初始化、在用户交互时实时更新、在保存时写入业务数据后清空。
第五组:ArkWeb 与 Notification 特性状态。 webController 和 downloadDelegate 是 private 非 @State 的 Web 控制器和下载代理实例;urlInput 和 webUrl 是地址栏的输入值和实际加载值——这种"双状态分离"设计让用户敲字时不触发 Web 组件重新加载,只有点"前往"后才更新 webUrl。dlName、dlPercent、dlState 是下载任务的实时状态。Notification 侧的 granted、notifyId、noticeCount、currentRingIdx、genFreq、genDuration、sandboxCount 分别管理授权状态、通知 ID 自增、发布计数、当前默认铃声索引、生成器频率/时长、已落盘铃声数。Canvas 侧的 canvasReady 标志和 barCtx 上下文用于控制柱状图的绘制时机。
7.2 生命周期方法
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);
}
aboutToDisappear() {
clearInterval(this.timer);
}
aboutToAppear 在组件创建后、build() 执行前调用,完成三项初始化:注册下载代理(绑定四回调到 Web 控制器)、查询通知授权状态(异步写入 granted)、启动呼吸动画定时器(每秒翻转 breath 并在 Canvas 就绪后重绘柱状图)。aboutToDisappear 在组件销毁前调用,清理定时器避免内存泄漏。这里的设计要点是 canvasReady 守卫——Canvas 组件的 onReady 回调在首次渲染完成后才触发,如果在 Canvas 就绪前调用 drawBar() 会因为上下文未初始化而报错,所以定时器内部做了 if (this.canvasReady) 判断。
7.3 build 主方法
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%')
if (this.addModal) { this.panelAdd(() => { this.addModal = false; }) }
if (this.editModal) { this.panelEdit(() => { this.editModal = false; }) }
if (this.delModal) { this.panelDel(() => { this.delModal = false; }) }
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
build 方法采用 Stack 包裹主内容与三层弹窗。主内容是 Column 纵向布局:头部 headerMain + 分割线 + 可滚动内容区(Scroll 包裹 layoutWeight(1) 撑满中间空间)+ 底部 tabBar。内容区通过 currentTab 索引做 if-else 分支,切换到对应的 @Builder 方法。三个弹窗面板在 Stack 顶层条件渲染——当对应布尔开关为 true 时才构建,这种"按需渲染"策略避免了弹窗未显示时仍占用渲染资源。每个弹窗面板接收一个 onClose 回调,用于点击遮罩或取消按钮时将对应开关置 false。整个 Stack 的背景色设为 bg 浅海雾蓝,奠定页面基调。
八、头部区域详解
@Builder
headerMain() {
Column({ space: 12 }) {
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, 0.65]] })
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)
}
头部区域是整个平台的视觉门面,由渐变 Banner 和搜索条两部分组成。
渐变 Banner 使用 linearGradient 从 120 度角铺设三段渐变:起点 blueD(深海岸蓝)→ 65% 处 blue(海岸蓝)→ 终点 blueD,形成"深-浅-深"的对称渐变效果,比单向渐变更有层次感。Banner 内部纵向三段:第一行是品牌名"🌊 学练港湾"(18 号加粗白字)+ 右侧倒计时胶囊(白底深蓝字"距 2027 初试 108 天"),倒计时胶囊用 borderRadius(11) 做成全圆角药丸形状;第二行是品牌语"真题为岸 · 每一套都值得溯源收藏"(11 号 whiteSoft 色),这句话点明了平台的溯源特色;第三行是三枚备考数据胶囊(今日刷题 86 题、待完成 4 套、连续打卡 46 天),用 trackW(32% 透明白)做背景,在深色渐变上形成柔和的悬浮感。
搜索条 由搜索入口和新增按钮组成。搜索入口是一个 Row 包含放大镜 Emoji 和提示文字"搜真题 / 粘贴下载链接",点击后切换到网页 Tab(currentTab = 1),引导用户去网页 Tab 输入网址或粘贴下载链接。新增按钮是海岸蓝底白字"+ 新增真题",点击后弹出 panelAdd 新增真题面板。这两个入口将"搜索"和"新增"两个核心操作前置到头部,保证用户在任何 Tab 下都能一键触达。
九、题库 Tab 详解
@Builder
tabPaper() {
Column({ space: 12 }) {
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%')
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)
// 真题卷(套)8、平均完成度 76% 同结构省略
}
.width('100%')
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)
this.barChartCard()
}
.width('100%')
}
题库 Tab 是平台的首页内容,由四个区块纵向排列:科目横滚 chips、三枚统计小卡、真题清单、Canvas 柱状图卡。
科目横滚 chips 使用 Scroll 横向滚动 + Row 包裹六个 Text 标签。每个标签的字体色和背景色根据 cateIdx === idx 判断:选中时白字蓝底、未选中时灰字白底。点击切换 cateIdx 后,visiblePapers() 方法根据索引过滤真题列表(索引 0 返回全部、其余按 SUBJECT_TAGS 关键词匹配科目名),驱动下方清单重新渲染。ForEach 的键值生成器 (tag: string) => tag 用标签文本作为唯一键,保证 chips 列表稳定。
三枚统计小卡 横向排列,每枚占 layoutWeight(1) 等宽。数值用 16 号加粗,分别用 blueD(累计刷题 1344)、orange(真题卷 8 套)、green(平均完成度 76%)三色着色,对应"数据量-待办量-达成率"三种语义。标签用 8 号 text3 灰字。
真题清单 是一个白底卡片,标题行右侧显示 visiblePapers().length 动态条数和操作提示"点击编辑 / 长按删除"。ForEach 遍历 visiblePapers() 返回的过滤后列表,键值用 item.subject + item.year 保证唯一性。
9.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))
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)
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; })
}
真题清单行是题库 Tab 的核心交互单元,横向三段式布局:左侧年份色块 + 中部科目进度 + 右侧完成度数字。
左侧年份色块 是 44×44 的圆角方块,背景色由 subjectColor(item.subject) 根据科目名映射,内部纵向显示年份(去掉"卷"字,如"2025")和"卷"字。这个色块是科目的视觉锚点——数学蓝、英语橙、政治绿、408 紫、管综深蓝,用户扫一眼即可定位科目。
中部科目进度 包含科目+年份标题(12 号加粗墨蓝,单行截断)、线性进度条(Progress 组件,4px 高,填充色与色块同色)、题量与刷题率文案(8 号灰字)。
右侧完成度数字 用 doneColor(item.done) 映射颜色——80% 以上绿、50%~80% 蓝、50% 以下橙,形成三档视觉警示。交互上,onClick 打开编辑弹窗(回填当前完成度到滑杆),onLongClick 打开删除确认弹窗。
9.2 Canvas 柱状图卡 barChartCard
@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)
}
Canvas 柱状图卡是题库 Tab 的数据可视化亮点。Canvas 组件绑定 barCtx 上下文,宽 100%、高 210,onReady 回调在 Canvas 首次渲染完成后触发,置 canvasReady = true 并调用 drawBar() 首次绘制。此后每秒呼吸定时器翻转 breath 并重绘,柱高系数在 1.0 和 0.93 间波动,形成"呼吸"效果。drawBar 方法的绘制逻辑前文工具函数部分已详述,核心是六根渐变柱(blue 到 blueD 线性渐变)+ 顶部数值标注 + 底部月份标签 + 背景三等分网格线。这种 Canvas 实时重绘的方案比静态图表组件更具动态质感,但需注意性能——每秒重绘 210×340 像素的 Canvas 在中端设备上开销可控。
十、网页 Tab 详解
@Builder
tabWeb() {
Column({ space: 10 }) {
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%')
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%')
Web({ src: this.webUrl, controller: this.webController })
.layoutWeight(1)
.width('100%')
.borderRadius(10)
.backgroundColor(COLORS.chip)
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%')
}
网页 Tab 是 ArkWeb 6.1.1 特性的主战场,由地址栏、快捷站点、Web 组件、主动下载按钮四部分组成。
地址栏 采用 urlInput 和 webUrl 双状态分离设计。TextInput 绑定 urlInput,onChange 实时更新输入值但不触发加载;点"前往"调用 loadUrl() 方法,该方法校验协议前缀(无 http(s):// 时自动补 https://),然后才将 urlInput 赋值给 webUrl,驱动 Web 组件重新加载。这种设计避免了用户每敲一个字符就触发一次网页加载的性能浪费。
快捷站点 横滚展示四个教育考试类真实站点,点击后同时更新 urlInput 和 webUrl(快捷站点无需校验协议,直接加载)。选中态用蓝底白字标识当前加载的站点。
Web 组件 是 ArkWeb 的核心载体,src 绑定 webUrl、controller 绑定 webController。网页内点击真题下载链接会自动进入下载代理的四回调流程;setDownloadDelegate 已在 aboutToAppear 中将代理绑定到控制器。
主动下载按钮 提供两个快捷下载入口——“下载数学一真题卷"和"下载 408 真题卷”,分别调用 triggerDownload(url) 方法,该方法通过 webController.startDownload(url) 主动发起下载(无需网页内点击)。下载状态文案 dlState 实时显示在按钮区上方,颜色由 dlStateColor 映射。底部提示文字引导用户去下载 Tab 查看双 URL 溯源结果。
10.1 下载代理注册方法
setupDownloadDelegate() {
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());
});
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;
});
this.downloadDelegate.onDownloadFinish((item: webview.WebDownloadItem) => {
const originalUrl: string = item.getOriginalUrl();
const referrerUrl: string = item.getReferrerUrl();
this.downloadRecords.unshift(new DownloadRecord(
item.getSuggestedFileName(),
Math.round(item.getTotalBytes() / 1048576) + ' MB',
'刚刚', originalUrl, referrerUrl));
this.dlState = '下载完成';
this.dlPercent = 100;
});
try {
this.webController.setDownloadDelegate(this.downloadDelegate);
} catch (error) {
console.error(`ErrorCode: ${(error as BusinessError).code}, Message: ${(error as BusinessError).message}`);
}
}
这是 ArkWeb 6.1.1 下载溯源链路的核心方法,注册四个回调并绑定到控制器。
onBeforeDownload 在下载开始前触发,必须调用 item.start(沙箱路径) 提供保存路径,否则任务永远停在 PENDING 状态。路径通过 getHostContext().filesDir 获取应用沙箱目录,拼接 getSuggestedFileName() 得到建议文件名。同时初始化 dlName、dlPercent、dlState 三个状态。
onDownloadUpdated 在下载进行中多次触发,通过 getPercentComplete() 获取进度百分比,更新进度条和文案。
onDownloadFailed 在下载失败时触发,通过 getGuid() 获取任务唯一标识写入失败文案,进度归零。
onDownloadFinish 是 6.1.1 新特性的焦点——调用 getOriginalUrl() 获取原始 URL、getReferrerUrl() 获取引用页 URL,将这两个地址连同文件名、大小(getTotalBytes() 除以 1048576 转 MB)、时间(“刚刚”)封装为 DownloadRecord 实例,unshift 置顶到 downloadRecords 数组。这一步完成了"下载完成即溯源记录"的闭环。最后 setDownloadDelegate 绑定到控制器,try-catch 包裹消除可能的抛错告警。
十一、下载 Tab 详解
@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)
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%')
}
下载 Tab 由进行中任务卡和已完成记录列表两部分组成。
进行中任务卡 实时显示当前下载任务的状态。标题行右侧显示 dlState 文案,颜色由 dlStateColor 映射(空闲灰、进行中蓝、完成绿、失败橙)。文件名行在无任务时显示提示文案、有任务时显示文件名(单行截断)。进度条 6px 高,蓝色填充。底部显示进度百分比和"保存至沙箱 filesDir"提示,让用户明确文件去向。
已完成记录列表 遍历 downloadRecords 数组,每条记录渲染为 recordCard。列表标题"🗂 已完成下载 · 双 URL 溯源"直白点明 6.1.1 特性,右侧显示条数。
11.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%')
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%')
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 溯源特性的展示终端,纵向四行:文件名行、标签行、原始 URL 行、引用页 URL 行。
文件名行 显示文件名(加粗墨蓝,单行截断)和完成时间(灰字)。标签行 显示文件大小(浅蓝胶囊)和"官方渠道 · PDF"标识(绿色文字)。原始 URL 行 前缀🔗图标,URL 用 monospace 等宽字体显示(深海岸蓝色),单行截断——等宽字体的选择让 URL 的域名、路径、参数对齐更清晰,便于用户辨识。引用页 URL 行 前缀📄图标,URL 同样用等宽字体(青灰蓝色),与原始 URL 形成深浅区分。
这两行 URL 的展示是溯源特性的可视化终点:用户可以对比"文件实际下载的直链地址"和"触发下载的页面地址",判断真题来源是否可信。例如原始 URL 是 files.neea.edu.cn 的直链,引用页 URL 是 www.neea.edu.cn/exam/download 的下载页,两者域名一致即可确认是官方渠道。
十二、提醒 Tab 详解
提醒 Tab 是 Notification Kit 沙箱铃声特性的展示终端,由通知授权卡、发布提醒按钮、学习提醒时间轴、通知历史四部分组成。
12.1 通知授权卡
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%')
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%')
}
通知授权卡展示 granted 状态——通过 notificationManager.isNotificationEnabled() 查询,在 aboutToAppear 中初始化。卡片用等宽字体显示 isNotificationEnabled = true/false 的原始 API 返回值,让技术细节透明可见。状态胶囊用绿/橙双色区分已授权/未授权。底部两个按钮:左侧"请求通知授权"调用 requestAuth() 方法(首次调用弹系统授权框、曾被拒绝则拉起通知设置页),右侧显示已发布通知条数。
12.2 发布学习提醒按钮
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 词');
})
}
发布按钮卡显示当前默认铃声名(从铃音 Tab 设定,跨 Tab 共享 currentRingIdx),并用等宽字体提示 sound 字段的技术原理。点击"发布背单词提醒"按钮调用 publishNotice 方法,该方法的核心逻辑是:先确保当前铃声已入沙箱(未入则自动导入),再调用 getSoundValue() 获取 'uri::' + fileUri.getUriFromPath(沙箱路径) 格式的 sound 值,构造 NotificationRequest(含 SOCIAL_COMMUNICATION 类型、BASIC_TEXT 内容、sound 字段),调用 notificationManager.publish() 发布。发布成功后 noticeCount 自增、通知历史 unshift 新记录。若失败码为 1600004(未授权)则自动调用 requestAuth() 引导授权。
12.3 学习提醒时间轴
@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)
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)
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 })
}
时间轴行采用固定行高 72 的横向三段式布局:时间列 + 圆点竖线轴 + 提醒卡。
时间列 固定宽 50,与行等高居中,显示时间和重复规则。时间色随 item.on 切换——开启时海岸蓝、暂停时雾蓝灰。
圆点竖线轴 是时间轴的视觉骨架。圆点(10×10,圆角 5)在顶部,颜色随 item.on 切换;竖线(宽 2,layoutWeight(1) 填满剩余高度)用 line 浅雾线色。竖线使用 layoutWeight(1) 在固定行高 72 内填满圆点到底部的空间,下一行的圆点接续上一行的竖线,形成连续时间轴。这种"固定行高 + layoutWeight 竖线"的布局手法是时间轴类 UI 的经典模式。
提醒卡 占 layoutWeight(1),显示提醒事项标题和状态文案(已开启绿色/已暂停灰色),右侧是 Toggle 开关(缩放 0.8 适配紧凑布局)。onChange 调用 toggleRemind(idx, isOn),修改 remindList[idx].on 后 slice() 重新赋值数组,驱动该行刷新。
十三、铃音 Tab 详解
铃音 Tab 是 Notification Kit 沙箱铃声特性的生成端,由当前默认铃声状态卡、正弦波铃声生成器、铃声库列表三部分组成。
13.1 正弦波铃声生成器
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)
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)
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%')
}
正弦波铃声生成器是铃音 Tab 的交互核心。频率四档(440/660/880/1320 Hz)和时长三档(600/1200/2000 ms)各用一行 ForEach 渲染药丸选项,选中态蓝底白字、未选中灰底灰字。用户选定参数后点"生成到沙箱 EL1"按钮,调用 createRingByGen() 方法——该方法用当前 genFreq 和 genDuration 构造一个新的 RingItem,push 到 ringList,然后调用 importRingToSandbox 写入沙箱。importRingToSandbox 内部调用 saveRingToSandbox,将 buildWavBytes 生成的字节流通过 fs.openSync + fs.writeSync 写入 EL1 沙箱 filesDir 目录,返回路径后更新铃声的 inSandbox 和 size。底部"已落盘 N 个"实时显示落盘计数。
13.2 铃声库列表 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)
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)
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 })
}
铃声库行横向三段:频率图标块 + 名称文件信息 + 操作按钮。频率图标块 40×44 圆角,显示🎶图标和频率数值(深蓝小字)。名称文件信息 显示铃声名(加粗墨蓝)和文件名+大小(等宽字体灰字 + 普通字体灰字)。操作按钮 根据状态条件渲染:入沙箱状态用绿色"已入沙箱"静态标签、未入沙箱用蓝色"生成到沙箱"可点击按钮;当前默认用蓝底白字"默认中 ✓"静态标签、非默认用"设为默认"可点击按钮。整行外边框颜色根据 currentRingIdx === idx 切换为蓝色高亮或浅雾线,让默认铃声行在列表中突出显示。
十四、我的 Tab 详解
我的 Tab 由备考身份渐变大卡、错题趋势传统柱状图、错题本清单三部分组成。
14.1 备考身份渐变大卡
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%')
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%')
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]] })
备考身份渐变大卡使用 130 度角 linearGradient 从 blueD 到 blue 单向渐变,比头部 Banner 的三段渐变更简洁有力。卡片内三段:身份行(🎓 Emoji + 姓名 + 目标院校 + LV.6 等级胶囊)、三栏统计(累计备考 216 天 / 累计刷题 1344 题 / 待复盘错题 213 题,用 trackW 竖线分隔)、备考进度条(68% 白色进度 + 冲刺阶段文案)。所有文字用 white 和 whiteSoft 两色,在深色渐变上形成清晰层次。
14.2 错题趋势传统柱状图 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%')
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)
}
错题趋势柱状图与题库 Tab 的 Canvas 柱状图形成"双引擎"对照。这里使用 Column + ForEach 的声明式柱状图,每根柱子是一个 Column 容器:顶部数值标注 + 中部柱体 + 底部月份标签。柱体宽 20,高度 Math.max(8, val * (this.breath ? 1.0 : 0.92))——随呼吸动画在 1.0 和 0.92 系数间波动,与 Canvas 柱状图的波动节奏一致但幅度略不同,形成"双图共振"的视觉效果。柱体使用 90 度角 linearGradient 从 blue(顶部 0.1)到 blueD(底部 1),比 Canvas 的渐变方向更直观。最后一根柱子的数值用绿色着色,呼应"持续下降=进步"的正向叙事。整行 alignItems(VerticalAlign.Bottom) 保证六根柱子底部对齐。
14.3 错题本清单
错题本清单遍历 mistakeList,每行横向:左侧科目色条(4 宽 34 高,subjectColor 着色)+ 右侧题目和错因。题目行用 11 号墨蓝字,单行截断;错因行包含科目胶囊(灰底灰字)、错因标签(珊瑚橙)、收录时间(灰字右对齐)。七条 Mock 数据覆盖七种典型考研错因,配合科目色条让用户快速识别薄弱科目和错误类型。
十五、底部 Tab 栏
@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 栏是六个 Tab 的导航中枢,单排排列。ForEach 遍历 TAB_LIST,每项 layoutWeight(1) 等宽。选中态:图标不透明度 1.0、标签 tabOn 海岸蓝、字重加粗;未选中态:图标不透明度 0.65、标签 text3 雾蓝灰、字重常规。点击切换 currentTab,驱动内容区 if-else 分支重新渲染对应 Tab 的 @Builder。整栏白底,顶部 1px 浅雾线分割。这种"Emoji 图标 + 文字标签 + 等宽分布"的底部导航是移动端应用的最经典范式。
十六、弹窗系统
16.1 全屏遮罩 modalOverlay
@Builder
modalOverlay(onClose: () => void) {
Stack() {
Column().width('100%').height('100%').backgroundColor(COLORS.mask)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
.onClick(() => onClose())
}
modalOverlay 是所有弹窗的公共遮罩层。Stack 容器内铺一层 mask 色(半透明深海墨)的全屏 Column,alignContent(Alignment.Center) 让后续叠加的面板居中显示。点击遮罩触发 onClose 回调关闭弹窗。这个 Builder 接收一个 onClose 闭包参数,体现了 ArkUI @Builder 方法支持函数参数的灵活性。
16.2 新增真题弹窗 panelAdd
新增真题弹窗叠加在 modalOverlay 之上,包含科目、年份卷、题量三个 TextInput 和一个完成度 Slider。三个输入框各有占位提示(如"如:考研数学一 / 英语一 / 408 计算机"),onChange 实时更新对应 formXxx 状态。完成度滑杆步进 5、0~100 范围,onChange 更新 formDone 并在标签上实时显示百分比。底部"取消"和"保存"按钮:取消调用 onClose 关闭弹窗、保存调用 savePaper() 方法——该方法对空字段做兜底默认值(科目空默认"考研数学一"、年份空默认"2026 卷"、题量空默认"23 题"),unshift 新条目到 paperList 头部,slice() 重新赋值驱动列表刷新,清空表单后关闭弹窗。
16.3 编辑完成度弹窗 panelEdit
编辑弹窗比新增弹窗精简,只包含一个完成度 Slider。打开时 openEditPaper(idx) 方法回填 editIdx 和当前完成度到 editDone。保存调用 updatePaper()——校验索引有效后写入 paperList[editIdx].done,slice() 刷新列表,关闭弹窗。@Observed 机制确保对应行的完成度数字和进度条同步更新。
16.4 删除确认弹窗 panelDel
删除弹窗展示🗑 Emoji、删除标题、当前真题的科目和年份信息,底部"取消"和"删除"按钮。删除按钮用珊瑚橙背景与其他弹窗的蓝色保存按钮区分,强化危险操作的视觉警示。点击删除调用 delPaper()——splice(delIdx, 1) 删除条目,slice() 刷新列表,关闭弹窗。
十七、功能模块对比表
| 功能模块 | 所在 Tab | 核心布局 | 数据源 | HarmonyOS 特性 | 交互方式 | 颜色锚点 |
|---|---|---|---|---|---|---|
| 题库清单 | 题库 Tab | 横向三段行(色块+进度+数字) | paperList | @Observed 局部刷新 | 点击编辑/长按删除 | subjectColor 科目色 |
| Canvas 柱状图 | 题库 Tab | Canvas 210 高 | BRUSH_VAL | Canvas drawBar 渐变柱 | 呼吸动画每秒重绘 | blue→blueD 渐变 |
| 网页浏览 | 网页 Tab | 地址栏+Web 组件 | urlInput/webUrl | ArkWeb WebviewController | 双状态分离加载 | blue 主色 |
| 主动下载 | 网页 Tab | 两个下载按钮 | dlState/dlPercent | startDownload 主动发起 | 点击触发 | blue/orange 状态色 |
| 下载溯源 | 下载 Tab | 进度卡+记录卡列表 | downloadRecords | getOriginalUrl+getReferrerUrl | 四回调自动记录 | green/orange/blue 状态色 |
| 通知授权 | 提醒 Tab | 状态卡+按钮 | granted | isNotificationEnabled | 请求/重新检测 | green/orange 授权色 |
| 发布提醒 | 提醒 Tab | 按钮+铃声提示 | currentRingIdx | NotificationRequest.sound | 点击发布 | orange 主操作色 |
| 学习时间轴 | 提醒 Tab | 固定行高 72 竖线 | remindList | @Observed Toggle 刷新 | 开关切换 | blue/text3 开关色 |
| 铃声生成器 | 铃音 Tab | 频率/时长药丸+按钮 | genFreq/genDuration | buildWavBytes WAV 合成 | 选档+生成 | blue 选中色 |
| 铃声落盘 | 铃音 Tab | 铃声库行列表 | ringList | EL1 沙箱 fileIo 写入 | 生成到沙箱/设为默认 | green 已入/line 边框 |
| 备考身份卡 | 我的 Tab | 渐变大卡+三栏统计 | 静态数据 | linearGradient 渐变 | 展示 | blueD→blue 渐变 |
| 错题趋势图 | 我的 Tab | ForEach 柱状图 | MISTAKE_VAL | Column+linearGradient | 呼吸动画联动 | blue→blueD 渐变 |
| 错题本 | 我的 Tab | 色条+题目+错因 | mistakeList | @Observed 列表 | 展示 | subjectColor 科目色 |
| 底部导航 | 全局 | 6 Tab 等宽单排 | TAB_LIST | ForEach layoutWeight | 点击切换 | tabOn/text3 选中色 |
| 弹窗系统 | 全局 | Stack 遮罩+面板 | addModal/editModal/delModal | 条件渲染按需弹出 | 遮罩关闭/按钮操作 | mask 遮罩+card 面板 |
十八、总结与展望
本平台以"学练港湾"为产品概念,围绕考研备考全流程构建了一个功能完整的 HarmonyOS ArkUI 组件化应用。从架构层面看,平台采用"单组件多 Builder"的扁平结构——所有状态上提到 Page1217 顶层声明,所有视图下沉到十余个 @Builder 方法中按需组合。这种结构在中等复杂度的业务场景中找到了"开发效率"与"可维护性"的平衡点:相比拆分为多个独立 @Component 子组件,单组件内的 Builder 方法共享状态更直接、无需 @Prop/@Link 跨组件传递;相比将所有 UI 塞进 build() 方法,Builder 拆分让每个 Tab 的布局独立可读、可单独修改。当应用进一步复杂化时,建议将各 Tab 的 Builder 升级为独立 @Component,通过 @Link 或 @Provide/@Consume 共享状态,实现真正的组件化解耦。
从特性层面看,平台深度融合了 HarmonyOS 6.1.1 的三大前沿能力。ArkWeb 下载双 URL 溯源通过 getOriginalUrl 和 getReferrerUrl 两个接口,让每一次真题下载都能追溯到"文件直链来源"和"触发下载的页面",这种双轨溯源能力在备考资料真伪鉴别场景中具有极高的实用价值——用户可以对照原始 URL 和引用页 URL 的域名是否一致,快速判断资料是否来自官方渠道。Notification Kit 沙箱自定义铃声通过 buildWavBytes 内存合成 WAV + EL1 沙箱落盘 + 'uri::' + fileUri.getUriFromPath 三步链路,让通知铃声从"系统预设"升级为"用户生成",开考铃、交卷铃、满分赞等差异化铃声让备考提醒更具仪式感和辨识度。Canvas 与 ForEach 双可视化引擎分别用 Canvas 2D API 和声明式组件两种方式实现柱状图,前者适合精确控制绘制细节(渐变、网格、标注)、后者适合快速构建响应式图表,两者通过呼吸动画联动形成"双图共振"的动态质感。
从色彩层面看,浅色海岸蓝白主题(#F2F6FA 底 + #2F7BD9 主 + #FF7E5A 辅)在备考场景中营造了清醒专注的日间氛围。五色科目映射体系(数学蓝/英语橙/政治绿/408 紫/管综深蓝)让用户凭颜色即可定位科目,完成度三档色(绿/蓝/橙)和下载状态三色(绿/橙/蓝)形成了统一的"语义-颜色"映射规则。渐变卡片专用的 whiteSoft 和 trackW 两色保证了深色渐变背景上的文字和进度条层次柔和。
展望未来,本平台可在以下方向持续演进。第一,真题下载与解析联动——下载完成的双 URL 记录可直接关联到题库 Tab 的真题清单,实现"下载即入库、溯源即归档"的自动化流程,避免手动新增真题的重复操作。第二,铃声生成的音频可视化——正弦波生成器可增加一个 Canvas 波形预览区,实时绘制所选频率和时长对应的波形曲线,让用户"看见"声音。第三,错题趋势的智能分析——错题本可按科目、错因类型做聚合统计,结合刷题量和错题量的双曲线趋势,自动推荐"下周应重点复习的科目和题型"。第四,学习提醒的智能调度——结合刷题量和完成度数据,动态调整提醒时间(如完成度低于 50% 的科目自动增加提醒频次),实现从"定时提醒"到"智能催学"的升级。第五,多设备协同——HarmonyOS 的分布式能力可让备考数据在手机、平板、智慧屏间无缝流转,平板刷题、手机提醒、智慧屏展示趋势,构建全场景备考体验。
在这些演进中,ArkUI 的声明式范式将继续发挥核心优势——数据驱动渲染让新增功能只需扩展状态和 Builder,无需手动操作 DOM;@Observed 的细粒度刷新让大规模列表保持流畅;@Builder 的参数化组合让复杂 UI 可拆可合。HarmonyOS 的"一次开发、多端部署"理念,也将让备考平台从单一手机应用成长为覆盖全场景的学习伴侣。海岸蓝的浪潮已起,沙箱铃声已鸣,备考之旅的每一套真题、每一次提醒、每一笔数据,都在这片"学练港湾"中找到了归属与溯源。
附录:DevEco Studio 创建新项目与查看 SDK 版本
本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。
一、创建新项目
1.1 进入欢迎界面
启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:
- 新建项目:从头创建新项目
- 打开项目:打开本地已有项目
- 克隆仓库:从 Git 等版本控制拉取代码
点击 “新建项目” 按钮,进入项目创建向导。

1.2 选择项目模板
在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:
| 类型 | 说明 |
|---|---|
| 应用(Application) | 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期 |
| 元服务(Atomic Service) | 开发轻量级的原子化服务,无需安装即可使用 |
选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

1.3 配置项目信息
点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:
| 配置项 | 示例值 | 说明 |
|---|---|---|
| 项目名称(Project name) | rollboat |
应用的项目名称,建议使用英文命名 |
| 包名(Bundle name) | com.rollboat.myapplication |
应用唯一标识,采用反向域名格式 |
| 保存路径(Save location) | D:\CodeFactory\rollboat |
项目本地存储路径,避免使用中文和空格 |
| 兼容 SDK(Compatible SDK) | 6.1.1(24) |
目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异 |
| 模块名称(Module name) | entry |
主模块名称,默认 entry 为应用入口模块 |
| 设备类型(Device types) | ☑ Phone | 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV |
右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

1.4 完成创建
确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:
- 生成项目骨架(Stage 模型目录结构)
- 执行
ohpm install安装依赖 - 运行 Hvigor 构建初始化(
Build Init)
构建日志中显示 “退出代码为 0” 表示项目初始化成功。

1.5 项目结构概览
创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:
rollboat/
├── .hvigor/ # Hvigor 构建工具缓存
├── .idea/ # IDE 配置文件
├── AppScope/ # 应用级全局配置
│ └── app.json5
├── entry/ # 主模块(入口模块)
│ ├── src/main/ets/
│ │ ├── entryability/ # Ability 生命周期管理
│ │ │ └── EntryAbility.ets
│ │ └── pages/ # UI 页面
│ │ └── Index.ets # 首页(默认 Hello World)
│ ├── src/main/resources/ # 资源文件
│ ├── module.json5 # 模块配置
│ └── build-profile.json5 # 构建配置
├── oh_modules/ # OHPM 依赖包
├── build-profile.json5 # 工程构建配置
├── hvigorfile.ts # Hvigor 构建脚本
└── oh-package.json5 # 包管理配置
核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:
@Entry
@Component
struct Index {
@State message: string = 'Hello World';
build() {
RelativeContainer() {
Text(this.message)
.id('HelloWorld')
.fontSize($r('app.float.page_text_font_size'))
.fontWeight(FontWeight.Bold)
.alignRules({
center: { anchor: '__container__', align: VerticalAlign.Center },
middle: { anchor: '__container__', align: HorizontalAlign.Center }
})
.onClick(() => {
this.message = 'Welcome';
})
}
.height('100%')
.width('100%')
}
}
| 关键语法 | 作用 |
|---|---|
@Entry |
标记为页面入口,可用于路由跳转 |
@Component |
声明为自定义组件 |
@State |
状态变量,数据变更时自动触发 UI 刷新 |
RelativeContainer |
相对布局容器,替代传统线性布局 |
.onClick() |
点击事件,此处点击后文本变为 “Welcome” |
打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

二、查看 SDK 版本
2.1 查看 HarmonyOS SDK
DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:
文件 → 设置 → HarmonyOS SDK(或快捷键
Ctrl + Alt + S搜索 “HarmonyOS SDK”)
在设置面板中,可以看到当前已安装的 SDK 版本信息:
| 名称 | 阶段 | 状态 |
|---|---|---|
| HarmonyOS 6.1.1 | Release | ✅ 已安装 |
界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

2.2 查看 ArkUI-X SDK(跨平台扩展)
如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:
文件 → 设置 → 语言和框架 → ArkUI-X
在这里可以查看已安装和可选的 ArkUI-X SDK 版本:
| 版本 | SDK 版本号 | 阶段 | 状态 |
|---|---|---|---|
| API Version 24 | 6.1.1.100 | Release | ✅ 已安装 |
| API Version 23 | 6.1.0.28 | Beta1 | 未安装 |
| API Version 22 | 6.0.2.112 | Release | 未安装 |
安装路径示例:D:\DevTools\ArkUI-X\sdk
说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

三、小结
| 步骤 | 操作 | 关键点 |
|---|---|---|
| 创建项目 | 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 | 使用 Stage 模型 + ArkTS 语言 |
| 查看 SDK | 设置 → HarmonyOS SDK | SDK 已内置,无需手动安装 |
| 跨平台扩展 | 设置 → ArkUI-X | 根据需要安装对应 API 版本 |
至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。
本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。
更多推荐



所有评论(0)