一、技术前言

在研究生入学考试备考领域,考生面临的核心痛点正在从"资源稀缺"转向"资源过载后的管理困境"。从考研数学历年真题到英语一阅读理解,从政治马原多选到 408 计算机数据结构,每一门科目都积累了海量的真题试卷和下载资源。传统备考应用面临三大挑战:真题试卷下载来源不可溯源导致真伪难辨、学习提醒通知铃声千篇一律无法区分场景、刷题量统计仅停留在数字层面缺乏可视化趋势洞察。

HarmonyOS ArkUI 框架为这些挑战提供了系统级解决方案。ArkUI 的声明式 UI 范式通过 @Entry@Component 装饰器定义页面入口组件,通过 @State 管理响应式状态驱动视图自动刷新,通过 @Builder 装饰器将复杂 UI 拆分为可独立维护的构建函数,天然适合"题库-下载-提醒"多 Tab 架构。@Observed 装饰器让数据模型类的字段级变化被 UI 感知,实现"下载完成即列表刷新""完成度更新即进度条变化"的流畅体验。

本平台深度融合 HarmonyOS 6.1.1 的三大前沿特性。ArkWeb 提供 WebDownloadDelegate 的四回调机制——onBeforeDownload 在下载启动前提供沙箱路径、onDownloadUpdated 实时刷新进度百分比、onDownloadFailed 捕获失败 Guid、onDownloadFinish 在完成回调中调用 6.1.1 新增的 getOriginalUrl(原始 URL,文件直链来源)与 getReferrerUrl(引用页 URL,触发下载的页面)双接口,每次下载真题都能溯源来路。Notification Kit 的通知请求 sound 字段支持沙箱自定义铃声,通过 'uri::' + fileUri.getUriFromPath(沙箱路径) 拼接实现应用沙箱 EL1 区域内音频文件的播放,让"开考铃"“交卷铃”"满分赞"等备考场景铃声精准送达。Canvas 绘制 通过 drawBar 方法绘制近六个月刷题量渐变柱状图,以 createLinearGradient 生成海岸蓝渐变柱体,配合每秒翻转的 breath 状态实现柱高微幅波动,模拟数据实时复盘的动态效果。

二、整体架构流程图

Page1286 主组件

headerMain 头部渐变横幅

内容区 6 Tab 切换

tabBar 底部导航

弹窗系统 新增/编辑/删除

Tab0 题库
科目横滚chips+真题清单+Canvas柱状图

Tab1 网页
地址栏+快捷站点+Web组件+主动下载

Tab2 下载
进度卡+双URL溯源列表

Tab3 提醒
通知授权+学习提醒时间轴+通知历史

Tab4 铃音
铃声坊生成器+WAV落盘EL1+铃声库

Tab5 我的
备考身份大卡+错题趋势柱状图+错题本

ArkWeb 6.1.1
WebDownloadDelegate四回调

ArkWeb 6.1.1
getOriginalUrl+getReferrerUrl双溯源

Notification Kit
sound沙箱自定义铃声

Notification Kit
EL1落盘+uri路径拼接

Canvas绘制
drawBar渐变柱状图+呼吸动画

Column柱状图
错题趋势传统柱状图

panelAdd 新增真题试卷

panelEdit 编辑完成度

panelDel 删除确认

架构以 Page1286 为根组件,使用 Stack 容器层叠:底层 Column 纵向排列头部渐变横幅、分割线、Scroll 内容区和底部 Tab 栏,顶层是三个独立弹窗(panelAdd/panelEdit/panelDel 各自条件渲染)。内容区通过 currentTab 状态变量在 6 个 @Builder 方法间切换,三大 HarmonyOS 6.1.1 特性分散在网页(ArkWeb 下载代理)、下载(双 URL 溯源)、提醒与铃音(Notification 沙箱铃声)、题库(Canvas 渐变柱状图)多个 Tab 上,状态变量统一声明在组件顶层实现跨 Tab 共享。

三、色彩体系设计

3.1 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;   // 渐变卡上的进度条轨道色
}

色彩接口集中声明了页面所有颜色字段,采用"海岸蓝白 + 珊瑚橙"浅色系配色。值得注意的是,除了常规的前景色与背景色字段外,接口专门为渐变卡片场景定义了 whitewhiteSofttrackW 三个字段——这是因为头部横幅和我的 Tab 的备考身份卡使用了深色渐变背景,其上的文字和进度条需要使用半透明白色系,与浅色卡片上的文字色区分开。

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)'     // 渐变卡进度条轨道色
};

色彩体系以"海岸蓝 + 珊瑚橙"为核心对比。蓝色代表海岸线的沉稳与理性,珊瑚橙代表备考冲刺的热度与紧迫感。与深色主题不同,浅色主题的 Tab 选中色使用 blue(海岸蓝)与主色统一,这是因为浅色背景上蓝色对比度已足够高。头部横幅使用 linearGradientblueDblue 再回 blueD 的 120° 三段渐变,模拟海浪涌动又退去的韵律感。珊瑚橙专门用于发布学习提醒按钮和删除操作按钮,在蓝色主调中形成视觉锚点。

四、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: '我的' }
];

底部导航采用 6 Tab 单排布局,每个 Tab 由 emoji 图标和中文标签组成。6 个 Tab 分别对应题库管理、网页浏览、下载溯源、学习提醒、铃声管理和个人中心,覆盖了备考学习从资源获取到自我管理的完整闭环。

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' }
];

科目分类 chips 首项为"全部"表示不过滤,其余按科目关键词匹配。快捷站点选取了四个教育考试类真实站点,用户在网页 Tab 点击即加载,无需手动输入网址。这种设计将常用资源前置,降低操作成本。

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];

刷题量数据呈逐月上升趋势(128→324),模拟备考进入冲刺期后的刷题密度增长。错题量数据呈波动下降趋势(46→19),模拟复习深入后错误率持续降低。铃声频率预设覆盖 440Hz(标准音 A4)到 1320Hz(高音)四档,时长预设覆盖 600ms 到 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);
  // ... 44 字节 WAV 头写入 ...
  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;
}

该函数在应用端纯代码生成 16bit 单声道 PCM WAV 音频字节,无需任何外部音频文件资源。44 字节的 WAV 头包含 RIFF/WAVE 容器标识、PCM 编码声明、采样率、声道数、量化位深等标准字段。采样数据段通过正弦波公式 sin(2πft) 生成基频波形,叠加起音包络(前 20ms 线性渐入)和自然衰减包络(随时间线性衰减),模拟真实打击乐器的声学特征。这一设计是 Notification Kit 沙箱自定义铃声特性的前提——用户生成的音频文件需要写入沙箱 EL1 区域后,通知 sound 字段才能引用。

5.2 状态映射函数群

function siteHost(url: string): string { /* 去掉 https:// 前缀 */ }

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 { /* 完成绿/失败橙/空闲灰 */ }
function doneColor(v: number): string { /* ≥80绿/≥50蓝/偏低橙 */ }

subjectColor 通过科目名称关键词匹配返回主题色,使每个科目的年份色块、进度条、错题条拥有一致的色彩标识——数学为海岸蓝、英语为珊瑚橙、政治为海藻绿、408 为鸢尾紫、管综为深蓝。dlStateColordoneColor 分别将下载状态和完成度数值映射为语义颜色,让用户通过颜色即可感知状态。

六、数据模型层

6.1 真题试卷模型 PaperItem

@Observed export class PaperItem {
  subject: string;  // 科目
  year: string;     // 年份卷
  count: string;    // 题量文本
  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 装饰器装饰,这意味着当 paperList 数组中某个元素的 done 字段被修改时,引用该元素的 UI 组件(进度条、完成度数字)会自动刷新。初始 Mock 数据 8 条覆盖五个科目,完成度从 35% 到 90% 不等,形成完整的备考进度画像。

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) { /* ... */ }
}

DownloadRecord 是 ArkWeb 6.1.1 下载双 URL 溯源特性的数据载体。originalUrl 存储文件直链来源地址(如 https://files.neea.edu.cn/exam/2026/kaoyan/math1_2025_full.pdf?track=official),referrerUrl 存储触发下载的页面地址(如 https://www.neea.edu.cn/exam/download?subject=kaoyan-math)。两个 URL 均为带域名、路径、查询参数的完整真实感地址,让用户能判断真题来源是否官方渠道。Mock 数据 7 条,均标注"官方渠道"标识。

6.3 学习提醒与铃声模型

@Observed export class RemindItem {
  time: string; title: string; repeat: string; on: boolean;
}

@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;
}

@Observed export class MistakeItem {
  subject: string; question: string; reason: string; time: string;
}

四个模型类全部使用 @Observed 装饰,确保各自字段变化能驱动 UI 刷新。RemindItemon 字段联动 Toggle 开关状态,RingIteminSandbox 字段联动铃声库行的"已入沙箱/未生成"状态切换。NoticeLog 在通知发布成功后 unshift 置顶,MistakeItemreason 字段用珊瑚橙标注错因标签,让错题归类一目了然。

七、组件主体与状态管理

7.1 组件声明与状态变量

@Entry
@Component
struct Page1286 {
  @State currentTab: number = 0;
  @State breath: boolean = false;
  @State timer: number = -1;
  @State cateIdx: number = 0;

  @State addModal: boolean = false;
  @State editModal: boolean = false;
  @State delModal: boolean = false;
  @State editIdx: number = 0;
  @State delIdx: number = 0;

  @State paperList: PaperItem[] = PAPER_LIST;
  @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;
  // ... 表单状态 ...
}

组件顶层声明了全部状态变量,分为四组:Tab 切换与动画状态(currentTab/breath/timer)、弹窗控制状态(addModal/editModal/delModal 及其操作索引)、列表数据状态(六个 @Observed 数组)、表单输入状态(formSubject/formYear/formCount/formDone/editDone)。将所有状态声明在组件顶层而非各 Builder 内部,是因为多个 Tab 之间存在数据交叉引用——例如铃音 Tab 生成的铃声会被提醒 Tab 的通知发布逻辑读取。

7.2 ArkWeb 下载代理状态

private webController: webview.WebviewController = new webview.WebviewController();
private downloadDelegate: webview.WebDownloadDelegate = new webview.WebDownloadDelegate();
@State urlInput: string = QUICK_SITES[0].url;
@State webUrl: string = QUICK_SITES[0].url;
@State dlName: string = '';
@State dlPercent: number = 0;
@State dlState: string = '空闲';

ArkWeb 相关状态采用 urlInputwebUrl 双状态分离设计——urlInput 绑定地址栏输入框,用户敲字时仅更新 urlInputwebUrl 绑定 Web 组件的 src 属性,只有点击"前往"按钮并校验协议前缀后才更新 webUrl,避免用户每输入一个字符就触发网页重载。webControllerdownloadDelegate 作为 private@State 成员,因为它们不需要驱动 UI 刷新。

7.3 下载代理注册

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}`);
  }
}

这是 HarmonyOS 6.1.1 ArkWeb 下载代理的核心注册逻辑。四回调机制覆盖了下载的完整生命周期:onBeforeDownload 中必须调用 item.start() 并传入沙箱路径,否则任务永远停留在 PENDING 状态;onDownloadUpdated 通过 getPercentComplete() 获取进度百分比刷新 UI;onDownloadFailed 通过 getGuid() 标识失败任务;onDownloadFinish 是 6.1.1 新特性落地点——调用 getOriginalUrl() 获取文件直链来源 URL,调用 getReferrerUrl() 获取触发下载的页面 URL,将双 URL 溯源信息封装为 DownloadRecordunshift 置顶到列表。绑定代理时用 try-catch 包裹 setDownloadDelegate 调用,消除可能的抛错告警。

7.4 地址栏加载与主动下载

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;
}

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;
  }
}

loadUrl 实现协议前缀自动补全——用户输入"neea.edu.cn"时自动补全为"https://neea.edu.cn",避免裸域名加载失败。triggerDownload 是应用侧主动发起下载的入口,无需用户在网页内点击下载链接,通过 webController.startDownload(url) 直接触发,同样会进入下载代理的四回调流程。两个主动下载按钮分别指向数学一和 408 真题卷的直链地址,点击后下载 Tab 的进度卡和溯源列表同步更新。

7.5 Canvas 渐变柱状图绘制

drawBar() {
  const ctx = this.barCtx;
  const w = 340, h = 210, pad = 26, labelSpace = 16;
  const plotH = h - pad * 2 - labelSpace;
  const max = 340, 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;
  // 背景横向网格线(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();
  }
  // 六根渐变柱
  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);
    // 顶部数值标注
    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);
  }
  ctx.fillStyle = COLORS.sub;
  ctx.font = '10px sans-serif';
  ctx.textAlign = 'left';
  ctx.fillText('单位:题', pad, pad - 12);
}

drawBar 方法使用 Canvas 2D API 绘制六根渐变柱状图,包含三层视觉元素:背景网格线、渐变柱体、数值与月份标注。wave 变量由 breath 状态派生——当 breath 为 true 时 wave=1.0(满高度),为 false 时 wave=0.93(降至 93%),每秒翻转实现柱高微幅波动。渐变柱通过 createLinearGradientblue(顶部浅蓝)到 blueD(底部深蓝)线性渐变,模拟海水的深浅过渡。每根柱顶部加粗标注刷题量数值,底部标注月份标签,左上角标注"单位:题"说明文案。

7.6 通知授权与沙箱铃声链路

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; });
  });
}

saveRingToSandbox(fileName: string, freq: number, durationMs: number): string {
  const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
  if (!hostCtx) { return ''; }
  const appCtx = hostCtx.getApplicationContext();
  appCtx.area = contextConstant.AreaMode.EL1;
  const dir = appCtx.filesDir;
  const path = dir + '/' + fileName;
  try {
    const data = buildWavBytes(freq, durationMs);
    const file = fs.openSync(path, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC);
    fs.writeSync(file.fd, data);
    fs.closeSync(file);
  } catch (e) { /* 沙箱写入失败时忽略 */ }
  return path;
}

requestAuth 实现通知授权的双重路径:首次调用 requestEnableNotification 弹出系统授权框,若用户曾经拒绝(Promise reject),则 fallback 到 openNotificationSettings 拉起系统通知设置页进行二次引导。saveRingToSandbox 是沙箱铃声落盘的核心——通过 appCtx.area = contextConstant.AreaMode.EL1 将应用上下文切换到 EL1 区域(设备级加密存储区),调用 buildWavBytes 生成 WAV 字节后以 fs.openSync + fs.writeSync 写入 EL1 的 filesDir 目录。EL1 区域是 HarmonyOS 通知铃声沙箱化的硬性要求,非 EL1 区域的音频路径无法被通知 sound 字段识别。

7.7 通知发布与 sound 字段拼接

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);
}

publishNotice(title: string, text: string) {
  const ring = this.ringList[this.currentRingIdx];
  if (!ring.inSandbox) {
    this.importRingToSandbox(this.currentRingIdx);
  }
  const soundVal = this.getSoundValue();
  const request: notificationManager.NotificationRequest = {
    id: this.notifyId++,
    notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,
    content: {
      notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
      normal: {
        title: title,
        text: text,
        additionalText: '自定义铃声:' + ring.name
      }
    },
    sound: soundVal  // 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(); }
  });
}

getSoundValue 是 6.1.1 Notification Kit 沙箱自定义铃声特性的关键拼接点——将 EL1 沙箱路径通过 fileUri.getUriFromPath 转换为 URI,再以 'uri::' 为前缀拼接,形成通知 sound 字段的标准格式值。publishNotice 在发布前检查当前铃声是否已入沙箱,未入则自动导入,确保 sound 字段指向的文件确实存在。通知请求使用 SOCIAL_COMMUNICATION 槽位类型(高优先级),additionalText 标注当前使用的铃声名称。发布失败时检查错误码 1600004(未授权),自动触发 requestAuth 引导用户授权。

7.8 生命周期管理

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 在组件即将出现时完成三项初始化:注册下载代理(绑定四回调到 Web 控制器)、查询通知授权状态(同步 granted 状态变量)、启动呼吸动画定时器(每秒翻转 breath 并在 Canvas 就绪后重绘柱状图)。aboutToDisappear 清理定时器防止内存泄漏。呼吸定时器与 Canvas onReady 回调配合——只有在 Canvas 上下文就绪后才执行 drawBar,避免在 Canvas 未初始化时绘制报错。

八、头部详解

@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({...}).backgroundColor(COLORS.trackW).borderRadius(9)
        Text('待完成 4 套').fontSize(9).fontColor(COLORS.whiteSoft)
          .padding({...}).backgroundColor(COLORS.trackW).borderRadius(9)
        Text('连续打卡 46 天').fontSize(9).fontColor(COLORS.whiteSoft)
          .padding({...}).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]] })

    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,采用 120° 三段渐变(blueDblueblueD),品牌名"考点地图"配以海浪 emoji,右侧白底圆角胶囊显示初试倒计时。Banner 下方一行品牌语"真题为岸 · 每一套都值得溯源收藏"点明产品价值主张。底部三枚半透明白底胶囊分别展示今日刷题量、待完成套数和连续打卡天数,用 trackW(半透明白)作为背景色保持渐变卡上的层次感。下方搜索条是功能入口——点击跳转到网页 Tab 进行搜索,右侧"+ 新增真题"按钮打开新增弹窗。搜索条和新增按钮采用 17px 圆角胶囊设计,与 Banner 的 14px 圆角形成层次差异。

九、各 Tab 内容分析

9.1 题库 Tab:科目横滚 + 真题清单 + Canvas 柱状图

题库 Tab 由三部分组成。第一部分是科目分类横滚 chips,6 个标签横向排列,选中态为海岸蓝底白字,未选中态为白底灰字,点击切换 cateIdx 驱动 visiblePapers() 过滤逻辑。第二部分是三枚统计小卡——累计刷题 1344 题、真题卷 8 套、平均完成度 76%,分别用蓝、橙、绿三色数字区分维度。第三部分是真题清单,每行通过 paperRow Builder 构建。

@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; })
}

真题清单行采用"左色块 + 中信息 + 右数字"三段式布局。左侧 44x44 圆角方块显示年份(如"2025"),背景色由 subjectColor 按科目关键词映射,让每个科目的行在视觉上形成色彩分组。中部包含科目年份标题、线性进度条和题量完成度文案,进度条颜色与左侧色块保持科目色一致。右侧完成度数字通过 doneColor 函数着色——80% 以上绿、50% 以上蓝、偏低珊瑚橙,让用户一眼判断进度。点击行打开编辑完成度弹窗,长按行打开删除确认弹窗,两种手势操作互不冲突。

9.2 网页 Tab:ArkWeb 地址栏 + 快捷站点 + Web 组件

网页 Tab 是 ArkWeb 6.1.1 下载代理特性的主舞台。地址栏采用 urlInput/webUrl 双状态分离设计,TextInput 绑定 urlInput,"前往"按钮调用 loadUrl() 校验协议后更新 webUrl。快捷站点横滑列表的选中态通过 this.webUrl === site.url 判断,点击即同步更新 urlInputwebUrl。Web 组件本体绑定 webUrlwebController,网页内点击真题下载链接会自动进入下载代理的四回调流程。

底部两个主动下载按钮分别指向数学一和 408 真题卷的直链地址,调用 triggerDownload(url) 通过 webController.startDownload(url) 直接发起下载。状态文案通过 dlStateColor 函数着色——"空闲"为灰、"正在下载 x%"为蓝、"下载完成"为绿、"下载失败"为橙。底部提示文案引导用户完成后到下载 Tab 查看双 URL 溯源信息。

9.3 下载 Tab:进度卡 + 双 URL 溯源列表

@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({...}).backgroundColor(COLORS.chip).borderRadius(7)
      Text('官方渠道 · PDF').fontSize(8).fontColor(COLORS.green)
    }
    .width('100%')
    // 原始 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)
}

下载 Tab 上半部分是进行中任务卡——显示文件名、线性进度条、百分比和状态文案,进度条绑定 dlPercent 状态实时刷新。下半部分是已完成下载的双 URL 溯源列表。每条记录卡包含四行信息:文件名与完成时间、文件大小与渠道标识、🔗原始 URL 行(getOriginalUrl 结果,深蓝色等宽字体)、📄引用页 URL 行(getReferrerUrl 结果,灰色等宽字体)。两个 URL 使用 monospace 等宽字体显示,配合单行截断省略号,让长 URL 在有限宽度内保持可读性。颜色区分——原始 URL 用 blueD 深蓝强调(这是文件的真实来源),引用页 URL 用 sub 灰色弱化(这是触发下载的上下文页面),让用户能快速识别"文件从哪来"和"在哪点击的下载"。

9.4 提醒 Tab:通知授权 + 时间轴 + 通知历史

提醒 Tab 由三个区块组成。第一部分是通知授权状态卡——显示 isNotificationEnabled 的布尔值(用等宽字体呈现代码感),授权状态标签用绿/橙区分,下方两个按钮分别是"请求通知授权/重新检测授权"和已发布通知计数。第二部分是发布学习提醒按钮卡,显示当前默认铃声名称,橙色按钮触发 publishNotice 发布携带沙箱自定义铃声的通知。第三部分是学习提醒时间轴。

@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 })
}

时间轴行采用固定行高 72px 的三段式布局:左侧时间列(固定宽 50px)、中间圆点竖线列(圆点 10x10 圆形 + layoutWeight(1) 填满的 2px 竖线)、右侧提醒卡。固定行高是时间轴竖线连续的关键——竖线在固定行高内通过 layoutWeight(1) 填满剩余空间,多行竖线首尾相接形成连续的时间轴视觉。圆点颜色随 item.on 状态切换——开启为海岸蓝、暂停为雾蓝灰。Toggle 开关缩放 0.8 倍适配行高,onChange 回调调用 toggleRemind 修改状态并 slice() 刷新数组引用驱动列表重绘。

9.5 铃音 Tab:正弦波生成器 + 沙箱落盘 + 铃声库

铃声 Tab 是 Notification Kit 沙箱自定义铃声特性的核心操作区。顶部是当前默认铃声状态卡,显示铃声文件名、大小和沙箱状态(已入沙箱/未生成)。中部是正弦波铃声生成器——频率四档 chips(440/660/880/1320 Hz)和时长三档 chips(600/1200/2000 ms),"生成到沙箱 EL1"按钮调用 createRingByGen() 新建铃声并写入沙箱。底部是铃声库列表。

@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({...}).backgroundColor(COLORS.chip).borderRadius(8)
      } else {
        Text('生成到沙箱').fontSize(8).fontColor(COLORS.blue)
          .padding({...}).backgroundColor(COLORS.chip).borderRadius(8)
          .onClick(() => { this.importRingToSandbox(idx); })
      }
      if (this.currentRingIdx === idx) {
        Text('默认中 ✓').fontSize(8).fontColor(COLORS.white)
          .padding({...}).backgroundColor(COLORS.blue).borderRadius(8)
      } else {
        Text('设为默认').fontSize(8).fontColor(COLORS.blueD)
          .padding({...}).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 })
}

铃声库行采用"左图标 + 中信息 + 右操作"三段式布局。左侧频率图标块显示音符 emoji 和频率数值(如 880),用浅云蓝底圆角块包裹。中部显示铃声名称(如"开考铃")和文件名/大小,文件名用等宽字体。右侧操作按钮区根据状态动态切换——沙箱状态按钮在"已入沙箱"(绿字)和"生成到沙箱"(蓝字可点击)间切换,默认铃声按钮在"默认中 ✓"(蓝底白字)和"设为默认"(灰底深蓝字可点击)间切换。整行边框颜色随是否为当前默认铃声切换——默认铃声行为海岸蓝边框,其余为浅雾线边框,提供视觉聚焦。

9.6 我的 Tab:备考身份卡 + 错题趋势图 + 错题本

我的 Tab 顶部是备考身份渐变大卡,采用 130° 两段渐变(blueDblue),展示学员昵称、目标院校、等级徽章和三项核心数据(累计备考天数、累计刷题量、待复盘错题数),底部进度条显示总体备考进度 68%。渐变卡上的文字使用 whitewhiteSoft,进度条用 white 填充、trackW 轨道,与浅色卡片场景形成鲜明对比。

中部是错题趋势传统柱状图(chartCard),与题库 Tab 的 Canvas 柱状图形成对比——这里使用 Column + ForEach 的声明式方式绘制柱体,而非 Canvas 命令式绘制。每根柱体高度 Math.max(8, val * (this.breath ? 1.0 : 0.92)),最小高度 8 防止空柱,呼吸系数 0.92 联动 breath 状态实现微幅波动。柱体使用 90° 渐变(blueblueD),与 Canvas 柱状图渐变方向一致。最后一根柱体数值用绿色标注(最新月错题最少=进步),其余用深蓝色。

底部是错题本清单,每行左侧 4px 宽科目色条,中部题目摘要和错因标签(珊瑚橙),右侧收录时间,通过 subjectColor 按科目着色色条形成色彩分组。

十、图表卡片对比

本平台包含两种柱状图实现方式,分别服务于不同场景:

10.1 Canvas 渐变柱状图(题库 Tab)

@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 柱状图使用命令式绘制 API,通过 Canvas(this.barCtx) 绑定上下文,onReady 回调中设置 canvasReady 标志并首次绘制。优势在于可以绘制渐变填充、网格线、精确像素定位的数值标注,适合需要精细视觉控制的场景。缺点是需要手动管理重绘时机——通过 aboutToAppear 中的 setInterval 每秒调用 drawBar 实现呼吸动画。

10.2 Column 声明式柱状图(我的 Tab)

@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)
}

Column 柱状图使用声明式布局,每个柱体是一个 Column 组件,高度绑定 val * wave 表达式。优势是无需手动管理重绘——breath 状态翻转后 ArkUI 框架自动重新计算高度并刷新视图。缺点是无法绘制网格线和精确像素标注。两种方式互补:Canvas 适合精细绘制场景,声明式适合快速开发场景。

十一、底部 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 栏采用 6 Tab 单排 Row + ForEach 布局,每个 Tab layoutWeight(1) 等宽分配。选中态三重视觉区分:图标不透明度 1(未选中 0.65)、标签颜色为 tabOn(海岸蓝,未选中为 text3 雾蓝灰)、字重 Bold(未选中 Normal)。顶部 1px 浅雾线边框与内容区分隔。Tab 栏使用 card 纯白底,在浅海雾蓝页面底色上形成层次区分。

十二、弹窗系统

12.1 全屏遮罩 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 填满屏幕并设置半透深海墨背景色(rgba(34,48,60,0.5)),点击遮罩区域触发 onClose 回调关闭弹窗。遮罩居中承载弹窗面板内容。

12.2 新增真题弹窗 panelAdd

新增弹窗包含科目、年份卷、题量三个 TextInput 和一个完成度 Slider,底部"取消"和"保存"按钮。Slider 步长 5(step: 5),blockColortrackColorselectedColor 分别配置滑块、轨道、已选区域颜色。点击保存调用 savePaper(),空字段兜底默认值(科目兜底"考研数学一"、年份兜底"2026 卷"、题量兜底"23 题"),unshift 置顶新条目并 slice() 刷新数组引用。

12.3 编辑完成度弹窗 panelEdit

编辑弹窗仅包含一个完成度 Slider,顶部显示当前编辑的真题科目和年份。打开时通过 openEditPaper(idx) 回填 editDone 为当前完成度值,保存调用 updatePaper() 写入并 slice() 刷新。

12.4 删除确认弹窗 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({...}).backgroundColor(COLORS.chip).borderRadius(9)
          .onClick(() => onClose())
        Text('删除').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
          .layoutWeight(1).textAlign(TextAlign.Center)
          .padding({...}).backgroundColor(COLORS.orange).borderRadius(9)
          .onClick(() => { this.delPaper(); })
      }
      .width('100%')
    }
    .width('74%').padding(18).backgroundColor(COLORS.card).borderRadius(14)
  }
  .width('100%').height('100%').alignContent(Alignment.Center)
}

删除弹窗采用 74% 宽度(比新增/编辑的 80% 窄),顶部大号垃圾桶 emoji 增强警示感,中部显示待删除的真题科目和年份,底部"取消"(灰底)和"删除"(珊瑚橙底白字)按钮。珊瑚橙删除按钮在蓝色主调中形成视觉警示,与发布学习提醒的橙色按钮呼应——橙色在本平台中统一代表"需要注意的操作"。

三个弹窗均通过 Stack 层叠 modalOverlay 和面板 Column,面板居中对齐。弹窗的显示/隐藏通过 addModaleditModaldelModal 三个布尔状态变量控制,在 build() 方法的 Stack 顶层条件渲染。这种设计避免了使用系统 bindSheetCustomDialog 的复杂性,用纯声明式实现了自定义弹窗系统。

十三、功能模块对比表

功能模块 核心技术 关键接口/方法 数据模型 视觉特征
题库 Tab Canvas 2D 绘制 drawBar / createLinearGradient PaperItem 渐变柱状图 + 科目色块
网页 Tab ArkWeb 组件 Web / WebviewController QuickSite 地址栏双状态 + 快捷站点
下载 Tab ArkWeb 下载代理 WebDownloadDelegate 四回调 DownloadRecord 双 URL 溯源 + 进度卡
提醒 Tab Notification Kit requestEnableNotification / publish RemindItem / NoticeLog 固定行高时间轴 + Toggle
铃音 Tab CoreFileKit + Notification buildWavBytes / fs.openSync / sound RingItem 正弦波生成器 + 沙箱状态
我的 Tab Column 声明式柱状图 ForEach + linearGradient MistakeItem 渐变身份卡 + 错题趋势
弹窗系统 Stack 条件渲染 modalOverlay + panel 表单状态变量 全屏遮罩 + 居中面板
头部横幅 linearGradient 120° 三段渐变 海岸蓝 Banner + 搜索条
底部导航 Row + ForEach layoutWeight(1) 等宽 TAB_LIST 6 Tab 单排 + 选中态

深化解析:从代码结构到业务闭环

布局方式与数据流

备考页面沿着筛选试卷、下载资料、安排提醒、执行练习和复盘错题展开。题库模型保存科目与完成度,网页和下载代理负责获取资料并记录来源,通知与铃声把计划变为可执行提醒。分析时应说明进度条、柱状图、时间轴和下载记录各自回答什么问题,并检查列表更新后统计值是否同步。

页面根结构通常由头部、内容区和底部 Tab 栏组成。头部负责展示当前业务状态,内容区根据索引选择不同的 @Builder,底部导航负责修改索引。这样的结构把“当前显示什么”收敛为一个明确状态:用户点击 Tab 后先更新索引,ArkUI 再重新计算相关分支。各个 Builder 虽然共享主题色和页面级数据,却可以采用完全不同的布局方式;高密度列表适合纵向 Scroll,概览数据适合横向统计卡或双列 Flex,实时预览类组件需要独占有界高度,历史事件则适合时间轴或固定行高 List。

数据模型层承担界面与业务之间的契约。使用 @Observed 的实体保存可编辑字段,页面级 @State 数组负责驱动 ForEach。新增时创建新实体并插入数组,编辑时修改目标实体,删除时移除对应项。为了让列表差分稳定,key 应来自不会改变的唯一标识,不宜使用标题等可编辑字段。统计数字、完成比例和分类数量属于派生信息,可以从数组即时计算,避免同时维护两份状态后出现卡片已经更新、图表仍显示旧值的情况。

弹窗表单使用独立缓存是必要的。打开新增弹窗时清空缓存,打开编辑弹窗时复制目标字段,用户确认后才写回正式模型。这样点击取消不会污染列表数据。若直接把 TextInput 双向绑定到列表实体,用户尚未保存时卡片就可能跟着变化,破坏“确认提交”的交互语义。删除弹窗还需要保存目标索引或唯一标识,并在确认时再次校验目标存在,避免列表变化后误删其他项。

核心代码与状态驱动机制

@State 的价值不是简单替代普通变量,而是建立状态与界面之间的依赖关系。当前 Tab、筛选条件、动画开关、弹窗显隐、下载进度或能力状态发生变化时,只有读取这些变量的组件需要刷新。代码段中连续的修饰器调用分别控制尺寸、间距、背景、字体和事件,它们共同构成声明式描述;阅读时应从容器方向、子项分布、状态绑定和交互回调四个层面理解,而不是逐个孤立翻译属性名称。

ForEach 负责把数组映射为重复 UI。回调中的 item 提供业务字段,index 适合显示顺序,但不适合作为长期身份。列表发生新增或删除时,稳定 key 可以让框架复用未变化节点,减少重建。若直接修改对象属性后界面没有按预期刷新,可在保持实体身份的前提下替换数组引用;但不应为了刷新把所有元素都重新构造,否则会增加无意义渲染并丢失局部状态。

条件渲染体现了页面状态机。空闲时展示引导,准备中展示进度,成功时展示结果,失败时展示原因和重试入口。相比一个布尔值,四态文案更能覆盖异步能力。系统接口调用前先检查权限、设备支持和会话状态,调用后再读取结果校验。异常处理除了记录错误码,还要把可理解的反馈写入响应式状态,让用户知道失败发生在哪一步。

动画效果与颜色使用策略

呼吸动画通常由定时器周期翻转 breath,再把该状态映射为透明度、柱高或圆点半径的小幅变化。它适合表达“正在运行”或让统计图保持生命感,但幅度应克制,不能改变核心数据含义。柱状图的基础高度仍由真实数值计算,动画只能在很小范围内偏移;进度环的角度仍由完成比例决定,不能为了视觉效果显示超过真实进度的结果。页面离开时必须清理定时器,避免后台继续刷新。

颜色常量应按语义使用。主色承担选中态和主要操作,辅助色突出数据或次级动作,绿色表达完成与可用,橙色表达进行中或需要注意,红色只用于失败、逾期和删除等高风险场景。弱文本与分割线降低视觉权重,遮罩色用于聚焦弹窗。颜色不能成为唯一的状态信息,还要配合文字、图标或进度值,保证色觉差异用户也能理解。

渐变更适合头部大卡、核心指标或柱状图,不宜在每个小元素上重复使用。深色主题要检查正文与卡片背景的对比度,浅色主题则要避免辅助文字过淡。选中和未选中 Tab 除颜色差异外,还可以通过字重、图标透明度或底部指示器区分。这样既保持主题统一,又能建立清晰的信息层级。

各 Tab 之间的交互联动

各 Tab 不应只共享一个导航索引,还应围绕业务对象建立必要联动。列表页新增或编辑数据后,头部计数、图表和个人统计要同步更新;网页或地图产生的结果应写入记录模型,供下载、日志或我的页面继续展示;通知、字幕、相机等系统能力的状态应在头部胶囊或对应 Tab 中保持一致。跨 Tab 跳转时先更新必要参数,再修改当前索引,可以避免目标页面读取到旧条件。

切换离开重型组件时需要处理资源边界。相机输入、地图监听、字幕控制器、Web 下载代理和定时器都不能只创建不释放。可以在统一的 switchTab 方法中判断来源与目标,离开能力页时解除监听或停止会话;页面销毁时再执行兜底释放。释放方法应允许重复调用,并对每个资源独立判空,确保一次异常不会阻止后续清理。

交互反馈要覆盖成功与失败。按钮点击后先进入处理中状态并防止重复提交;成功后更新模型、关闭弹窗并显示结果;失败后保留用户输入,展示错误原因和重试入口。权限拒绝、能力不支持、网络失败、文件不存在和输入非法都属于正常业务分支。通过状态卡或行内提示展示这些分支,比只在控制台打印更符合完整产品体验。

边界场景与验证思路

空列表时应显示占位说明和新增入口,不能只留下空白。长标题需要限制行数并使用省略号,数字字段需要限定上下界,文本提交前要去除首尾空格。筛选后无结果应保留清除条件的入口。删除最后一项后,当前选择索引要回退到有效范围。异步搜索连续触发时,应防止较早请求晚返回后覆盖新结果。

验证数据链路时,可以依次检查新增、编辑、删除和筛选:新增后列表条数、统计数字和图表是否同时变化;编辑取消后正式数据是否保持不变;删除后 ForEach key 是否稳定;切换 Tab 再返回时必要数据是否仍在。验证系统能力时分别模拟支持、拒绝和异常,确认界面都有明确状态。验证动画时检查页面离开后是否停止,低性能设备上是否仍保持流畅。

视觉验收需要检查不同屏幕宽度、系统字体放大、深浅背景对比和长文本换行。表格中的布局方式、模型、字段数、核心操作、动画、状态颜色、数据量和特殊组件应与正文一致。Mermaid 图则需要对应真实的数据流和能力链路,节点文字加引号以避免中文或特殊字符导致解析失败。

组件化设计的进一步理解

参数化 Builder 适合抽取重复的统计格、状态行、标签和按钮组。参数只传入渲染所需数据和事件,不让子构建器直接依赖过多页面变量,可以降低耦合。业务复杂后,可把模型与系统能力封装为独立控制器,页面只负责组合 UI 和响应状态。这样既保留声明式代码的直观性,也能让权限、错误码翻译和资源释放得到集中管理。

当前单页面集中展示完整源码,便于博文逐段讲解。若演进为正式项目,可以按领域拆分组件:导航和页面框架位于容器层,列表、图表和弹窗位于展示层,数据读写和 Kit 接入位于服务层。组件之间通过参数、回调、@Link@ObjectLink 传递状态,不使用全局变量代替清晰的数据流。

性能优化首先来自减少不必要刷新。派生数据不要重复存储,动画状态不要进入列表 key,长列表使用稳定标识,Canvas 只在数据或尺寸变化时重绘。其次是控制资源生命周期,页面不可见时停止高成本任务。最后才是微调阴影、渐变和绘制细节。这样的优先级能保证页面在功能增加后仍然可维护。

通过以上补充,可以看到 ArkUI 的声明式模式并非只让布局语法更简洁,它更重要的价值是把数据变化、界面刷新和交互反馈连接为可追踪链路。理解每个代码段读取什么状态、写入什么状态、影响哪些组件,才能真正掌握文章中多个 Tab、图表、弹窗和系统能力协同工作的原理。

十四、总结与展望

本平台基于 HarmonyOS ArkUI 框架的声明式 UI 范式,以"海岸蓝 + 珊瑚橙"浅色系配色为视觉基底,构建了一个覆盖备考学习全链路的六 Tab 组件化应用。三大 HarmonyOS 6.1.1 前沿特性的深度融合是平台的技术核心:ArkWeb 的 WebDownloadDelegate 四回调机制配合 getOriginalUrlgetReferrerUrl 双接口实现了下载来源的可追溯性,让每一套真题试卷的来源渠道有据可查;Notification Kit 的 sound 字段支持沙箱 EL1 区域自定义音频路径,配合应用端纯正弦波 WAV 生成器实现了通知铃声的完全用户自定义;Canvas 2D API 的 drawBar 方法绘制渐变柱状图,配合 breath 状态的每秒翻转实现了柱高微幅波动的呼吸动画效果。

在架构设计层面,平台采用了"顶层状态统一声明 + Builder 函数分散构建 + Stack 层叠弹窗"的组织模式。所有 @State 状态变量声明在组件顶层实现跨 Tab 共享,12 个 @Builder 函数按功能拆分(头部 1 个、Tab 内容 6 个、行级卡片 4 个、底部导航 1 个、弹窗 4 个),弹窗系统通过 Stack 容器的条件渲染实现纯声明式自定义弹窗。@Observed 装饰器在 6 个数据模型类上的应用确保了字段级变化被 UI 精确感知,配合 slice() 数组引用刷新实现列表的可靠重绘。

展望未来,平台可在以下方向继续深化。第一,ArkWeb 下载代理可扩展 WebDownloadItem 的更多属性(如 getMimeType 文件类型校验、getTotalBytes 预估大小展示),进一步丰富溯源信息维度。第二,Notification Kit 的沙箱铃声机制可与分布式能力结合,实现多设备铃声同步——在手机端生成铃声后通过分布式数据同步到平板端,实现备考提醒的多端协同。第三,Canvas 柱状图可引入手势交互(如长按柱体查看月度详情),并扩展为折线图、饼图等多种可视化形态,构建更完整的备考数据洞察面板。第四,铃声生成器可从纯正弦波扩展为多波形叠加(方波、三角波、和弦音),并引入 ADSR 包络曲线精细控制音色,让自定义铃声更具音乐性。随着 HarmonyOS 持续演进,ArkUI 声明式范式的组件化能力和系级 API 的深度将备考应用推向更高的体验上限。

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

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


一、创建新项目

1.1 进入欢迎界面

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

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

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

在这里插入图片描述

1.2 选择项目模板

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

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

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

在这里插入图片描述

1.3 配置项目信息

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

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

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

在这里插入图片描述

1.4 完成创建

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

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

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

在这里插入图片描述

1.5 项目结构概览

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

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

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

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

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

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

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

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

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

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

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

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

在这里插入图片描述

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

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

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

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

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

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

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

在这里插入图片描述


三、小结

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

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


Logo

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

更多推荐