一、技术前言

在考研备考领域,真题试卷的获取、管理与刷题进度追踪构成了学习者最核心的数字需求链条。从历年真题的精准检索到下载溯源,从每日刷题量的可视化统计到学习提醒的铃声个性化,每一个环节都要求移动应用具备数据驱动渲染、跨组件状态同步与系统能力深度集成的能力。传统备考应用常常面临三大困境:真题下载来源不可追溯导致资料可信度存疑、通知铃声千篇一律导致用户难以区分提醒优先级、刷题数据缺少动态可视化导致学习趋势感知迟钝。

在这里插入图片描述

HarmonyOS ArkUI 框架以其声明式 UI 范式为这些问题提供了系统级的解决方案。ArkUI 基于 TypeScript 扩展的 ArkTS 语言,通过 @Component 装饰器封装可复用组件,通过 @State@Observed 等状态管理装饰器实现数据驱动渲染,通过 @Builder 方法将复杂的 UI 结构拆分为可组合的构建块。这种架构天然适合备考场景中"数据-视图-交互"紧耦合的需求:一个状态变量的变更可以自动触发关联视图的刷新,而无需手动操作 DOM 节点。
在这里插入图片描述
本平台深度融合了 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() 方法在画布上绘制六个月刷题量的渐变柱状图,借助呼吸动画定时器每秒翻转 breath 状态实现柱高的微幅波动重绘,使数据可视化呈现"活"的动态感。

在这里插入图片描述

二、整体架构流程图

Page1237 主组件

headerMain 头部渐变Banner

内容区 6 Tab切换

tabBar 底部导航

modalOverlay 弹窗遮罩

Tab0 题库
科目chips+统计卡+真题清单+Canvas柱状图

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

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

Tab3 提醒
通知授权卡+发布按钮+时间轴+通知历史

Tab4 铃音
铃声状态卡+正弦波生成器+铃声库

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

ArkWeb
WebDownloadDelegate四回调

ArkWeb 6.1.1
getOriginalUrl+getReferrerUrl双溯源

Notification Kit
沙箱自定义铃声

Notification Kit
正弦波WAV生成+EL1落盘

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

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

panelAdd 新增真题试卷

panelEdit 编辑完成度

panelDel 删除确认

整体架构以 Page1237 为根组件,采用 Stack 容器实现页面层叠:底层是 Column 纵向布局的头部 Banner + 内容滚动区 + 底部 Tab 栏,顶层是三个全屏弹窗面板的按需叠加。内容区通过 currentTab 状态索引在六个 @Builder 方法间条件切换,每个 Tab 拥有完全独立的布局结构与交互逻辑。三大特性(ArkWeb 下载双 URL 溯源、Notification 沙箱自定义铃声、Canvas 渐变柱状图)分别挂载在网页、下载/铃音/提醒、题库/我的 Tab 上,但它们的状态变量统一声明在组件顶层,实现跨 Tab 数据共享。弹窗系统通过 modalOverlay 通用遮罩构建器叠加在所有内容之上,新增、编辑、删除三个面板按布尔状态变量按需渲染,形成完整的真题试卷 CRUD 闭环。

在这里插入图片描述

三、色彩体系设计

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

接口设计遵循"语义命名"原则:每个字段名直接表达颜色的使用场景而非色值本身。bg 代表页面最底层背景,card 代表卡片容器的底色,chip 代表胶囊标签与输入框等交互元素的底色,titlesubtext3 形成三级文字层级递进。渐变卡片上专用的 whitewhiteSofttrackW 三个字段则保证了深色渐变背景上文字与进度条的可读性。

在这里插入图片描述

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

色彩设计遵循"海岸蓝白 + 珊瑚橙"的浅色双主色策略。海岸蓝 #2F7BD9 作为品牌主色贯穿头部渐变 Banner、Tab 选中态、进度条填充与柱状图渐变;珊瑚橙 #FF7E5A 作为辅色专用于删除操作、待完成度偏低与错因标签,形成"蓝为基调、橙为警示"的视觉语义体系。三辅色(海藻绿、鸢尾紫、深海岸蓝)分别映射到"已完成"、“408 科目”、"管综科目"等特定语义,使科目分类在视觉上可快速辨识。渐变卡片上的三个半透明白色字段(whiteSofttrackW)则巧妙利用透明度在深色渐变上创建文字层级,避免了额外定义颜色常量的冗余。

在这里插入图片描述

四、Tab 元数据与常量定义

4.1 底部导航 Tab 定义

interface TabMeta {
  icon: string;  // Tab 图标(Emoji)
  label: string; // Tab 标签文字
}

const TAB_LIST: TabMeta[] = [
  { icon: '📚', label: '题库' },
  { icon: '🌐', label: '网页' },
  { icon: '📥', label: '下载' },
  { icon: '⏰', label: '提醒' },
  { icon: '🎵', label: '铃音' },
  { icon: '👤', label: '我的' }
];

底部导航采用六 Tab 单排布局,每个 Tab 由 Emoji 图标与中文标签组成。TabMeta 接口将图标与标签封装为结构体,配合 ForEach 渲染实现数据驱动的导航栏构建。选中态使用海岸蓝高亮,未选中态使用雾蓝灰弱化,通过 opacity 属性进一步区分图标的视觉权重。六个 Tab 分别承载题库管理、网页浏览、下载溯源、学习提醒、铃声定制与个人中心六大功能域,每个 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 以"全部"为首项实现不过滤逻辑,其余五项按考研常见科目排列。QuickSite 接口封装教育考试类真实站点的图标、名称与完整 URL,点击即加载到 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 模拟近六个月刷题量数据(整体上升趋势),驱动 Canvas 渐变柱状图绘制;MISTAKE_VAL 模拟错题量数据(整体下降趋势=进步),驱动传统柱状图渲染。两套数据形成"刷题量上升 + 错题量下降"的双重进步叙事。铃声生成器的频率预设四档(440/660/880/1320 Hz)对应纯音音阶递进,时长预设三档(600/1200/2000 ms)控制衰减节奏,为用户提供了"可控参数 + 即时落盘"的音频生成体验。

五、工具函数层

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);          // PCM 编码
  view.setUint16(22, 1, true);          // 单声道
  view.setUint32(24, sampleRate, true); // 采样率
  view.setUint32(28, sampleRate * 2, true);
  view.setUint16(32, 2, true);
  view.setUint16(34, 16, true);         // 16bit 量化
  writeStr(36, 'data');
  view.setUint32(40, dataSize, true);
  for (let i = 0; i < numSamples; i++) {
    const t = i / sampleRate;
    const env = Math.min(1, i / (sampleRate * 0.02));       // 起音包络
    const decay = Math.max(0, 1 - t / (durationMs / 1000)); // 自然衰减
    const v = Math.sin(2 * Math.PI * freq * t) * 0.5 * env * decay;
    view.setInt16(44 + i * 2, Math.round(v * 32767), true);
  }
  return buf;
}

这是整个铃声沙箱化链路的起点。函数接收频率(Hz)与时长(ms)两个参数,生成标准 WAV 文件格式的 ArrayBuffer 字节序列。函数内部首先计算采样数与数据大小,然后通过 DataView 按小端序写入 44 字节的 WAV 文件头:RIFF 标识、WAVE 格式、fmt 子块、PCM 编码(format=1)、单声道(channels=1)、44100 Hz 采样率、16bit 量化位深。音频数据部分通过正弦波公式 sin(2 * PI * freq * t) 生成,叠加了两层包络:起音包络 env 在前 20ms 内从 0 渐升到 1 模拟自然音起振,衰减包络 decay 从 1 线性衰减到 0 模拟铃声自然消散。最终采样值乘以 0.5 限幅并映射到 16bit 整数范围(-32768~32767)。这种纯算法生成的音频无需任何外部音频文件资源,完全在应用层完成"合成-落盘-播放"闭环。

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 将下载状态文案映射到状态色:完成绿、失败橙、空闲灰、进行中蓝,使用户在下载 Tab 能凭颜色快速识别任务状态。doneColor 将完成度数值分段映射:80 分以上绿(优秀)、50 分以上蓝(中等)、50 分以下橙(偏低),为真题清单右侧的完成度数字提供语义着色。

六、数据模型层

6.1 真题试卷数据模型

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

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 装饰器声明为可观察类,当其属性变更时,绑定了该实例的 UI 组件会自动刷新。这是 ArkUI 响应式编程的核心机制之一:@Observed@State 配合使用,@State 负责追踪数组引用的变化(如 this.paperList = this.paperList.slice()),@Observed 负责追踪对象属性的变化。八条 Mock 数据覆盖五个科目与两个年份卷,完成度从 35% 到 90% 分布,兼顾了"接近完成"与"刚起步"两种典型场景。

6.2 下载记录数据模型

@Observed export class DownloadRecord {
  fileName: string;     // 文件名
  fileSize: string;     // 大小文本
  finishTime: string;   // 完成时间
  originalUrl: string;  // ★ getOriginalUrl() 结果
  referrerUrl: string;  // ★ getReferrerUrl() 结果

  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 是 HarmonyOS 6.1.1 ArkWeb 下载双 URL 溯源特性的数据载体。originalUrl 存储通过 item.getOriginalUrl() 获取的下载文件直链地址,referrerUrl 存储通过 item.getReferrerUrl() 获取的触发下载的页面地址。这两个字段共同构成了"文件从哪里来"的完整溯源链:原始 URL 回答"文件实际存储在哪个服务器路径",引用页 URL 回答"用户是从哪个页面点击触发了这次下载"。七条 Mock 数据中双 URL 均为带域名、路径与查询参数的完整真实感地址,模拟了从教育考试网站下载真题时的典型来路。

6.3 学习提醒与铃声数据模型

@Observed export class RemindItem {
  time: string;    // 提醒时间
  title: string;   // 提醒事项
  repeat: string;  // 重复规则
  on: boolean;     // 是否开启
}

@Observed export class RingItem {
  name: string;       // 铃声名
  file: string;       // 沙箱文件名
  freq: number;       // 生成频率 Hz
  duration: number;   // 时长 ms
  size: string;       // 文件大小展示
  inSandbox: boolean; // 是否已写入沙箱 EL1
}

RemindItem 封装学习提醒的四要素:时间、事项、重复规则与开关状态,六条 Mock 数据覆盖背单词、刷题、模考、报名截止等典型备考节点。RingItem 封装铃声的六要素:名称、沙箱文件名、频率、时长、大小与沙箱状态。inSandbox 布尔字段是铃声沙箱化链路的关键状态标志——只有当它为 true 时,该铃声才能被 getSoundValue() 方法转换为通知 sound 字段的有效 URI。初始五条铃声数据 inSandbox 均为 false,用户需要手动"生成到沙箱"或通过设为默认时自动触发导入。

6.4 通知历史与错题本数据模型

@Observed export class NoticeLog {
  title: string;  // 通知标题
  text: string;   // 通知正文
  time: string;   // 发布时间
}

@Observed export class MistakeItem {
  subject: string;  // 科目
  question: string; // 题目摘要
  reason: string;   // 错因标签
  time: string;     // 收录时间
}

NoticeLog 记录已发布通知的标题、正文与时间,通知发布成功后通过 unshift 置顶到列表。MistakeItem 记录错题的科目、题目摘要、错因标签与收录时间,七条 Mock 数据涵盖计算失误、概念混淆、主观臆断、选项漏选、公式记错、偷换概念、词义辨析七种典型错因,每条数据都模拟了真实备考场景中考生记录错题的典型格式。

七、组件主体结构

7.1 状态变量声明

@Entry
@Component
struct Page1237 {
  @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;

  @State formSubject: string = '';
  @State formYear: string = '';
  @State formCount: string = '';
  @State formDone: number = 0;
  @State editDone: number = 0;

@Entry 标识该组件为页面入口,@Component 声明其为可复用组件。状态变量分为四组:导航与动画状态(currentTabbreathtimer)、弹窗状态(addModaleditModaldelModal 及其操作索引)、数据列表状态(六组 @Observed 数组)与表单状态(新增与编辑表单字段)。所有状态统一声明在组件顶层,确保跨 Tab 数据共享:例如下载 Tab 的 downloadRecords 数组在网页 Tab 的下载回调中被 unshift 更新后,切换到下载 Tab 即可看到最新记录,无需手动刷新。

7.2 ArkWeb 状态与 Canvas 状态

  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 = '空闲';

  @State canvasReady: boolean = false;
  private barCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));

ArkWeb 相关状态采用"双状态分离"策略:urlInput 绑定地址栏输入框的实时值,webUrl 绑定 Web 组件实际加载的值。用户敲字时只更新 urlInput,点击"前往"后校验协议并更新 webUrl,避免了输入过程中 Web 组件反复加载的问题。下载状态由 dlName(文件名)、dlPercent(进度百分比)与 dlState(状态文案)三字段共同表达。webControllerdownloadDelegate 作为 private@State 成员,不触发视图刷新但持有跨生命周期的控制器引用。barCtx 同样为 private,在 Canvas onReady 回调中将 canvasReady 置为 true 后才允许定时器调用 drawBar() 重绘。

八、头部详解

  @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, 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 与搜索条两部分组成。渐变 Banner 使用 linearGradient 属性以 120 度角实现三段渐变:深海岸蓝(起点)到海岸蓝(65% 处)再到深海岸蓝(终点),形成中间偏亮的弧形光泽感。Banner 内部包含品牌标题"答题星球"、初试倒计时胶囊、品牌语与三枚备考数据胶囊。倒计时胶囊使用白底深蓝字的反色设计,在渐变背景上形成视觉焦点。三枚数据胶囊(今日刷题、待完成、连续打卡)使用 trackW 半透明白色底色,与渐变背景自然融合。

搜索条区域采用"搜索占位 + 新增按钮"的双元素布局。搜索占位行点击后跳转到网页 Tab(currentTab = 1),引导用户通过网页检索真题。新增真题按钮点击后打开 addModal 弹窗。两个交互元素共用圆角 17 的胶囊造型,形成视觉一致性。

九、各 Tab 详细分析

9.1 题库 Tab(tabPaper)

题库 Tab 是默认首页,包含科目分类横滚 chips、三枚统计小卡、真题试卷清单与 Canvas 刷题量柱状图四个区块。

科目分类 chips 通过 Scroll 横向滚动容器包裹 Row,使用 ForEach 遍历 SUBJECT_TAGS 渲染。选中态为海岸蓝底白字,未选中态为白底青灰蓝字,点击更新 cateIdx 触发 visiblePapers() 重新过滤清单。

  visiblePapers(): PaperItem[] {
    if (this.cateIdx === 0) {
      return this.paperList;
    }
    const tag: string = SUBJECT_TAGS[this.cateIdx];
    const out: PaperItem[] = [];
    for (const p of this.paperList) {
      if (p.subject.indexOf(tag) >= 0) {
        out.push(p);
      }
    }
    return out;
  }

visiblePapers() 方法实现了"全部"直接返回、其余按科目关键词子串匹配的过滤逻辑。当 cateIdx 为 0 时返回完整列表,否则使用 indexOf 进行子串匹配过滤。这种设计使得"考研数学一"能被"考研数学"标签匹配到,保证了分类筛选的灵活性。

真题清单行(paperRow)采用"左侧年份色块 + 中部科目进度 + 右侧完成度数字"三栏布局。左侧色块使用 subjectColor 函数根据科目着色,色块内显示年份数字与"卷"字。中部使用 Progress 线性进度条展示完成度,颜色同样映射到科目主题色。右侧完成度数字使用 doneColor 函数着色。行点击触发 openEditPaper 打开编辑弹窗,长按触发删除确认弹窗。

9.2 网页 Tab(tabWeb)

网页 Tab 是 ArkWeb 特性的主阵地,包含地址栏、快捷站点横滑、Web 组件本体与主动下载操作区。

  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() 方法实现了协议自动补全逻辑:当用户输入的 URL 不以 https://http:// 开头时,自动补 https:// 前缀,然后将处理后的 URL 同步到 urlInput(回显到地址栏)与 webUrl(触发 Web 组件加载)。triggerDownload() 方法通过 webController.startDownload(url) 从应用侧主动发起下载,使用 try-catch 包裹以捕获 BusinessError,失败时将错误码展示到状态文案中。这种设计使得用户无需在网页内手动点击下载链接,直接通过按钮即可触发真题下载。

Web 组件通过 Web({ src: this.webUrl, controller: this.webController }) 创建,src 绑定到 webUrl 状态实现地址变化时自动加载新页面。网页内触发的下载链接会自动进入 downloadDelegate 的四回调链路,无需额外绑定。

9.3 下载 Tab(tabDownload)

下载 Tab 展示下载双 URL 溯源特性的完整数据链路,包含进行中任务卡与已完成记录列表。

  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 下载代理四回调的核心注册逻辑。onBeforeDownload 回调在下载开始前触发,必须调用 item.start(沙箱路径) 提供存储位置,否则下载任务永远停留在 PENDING 状态。方法内通过 getHostContext().filesDir 获取应用沙箱目录,拼接建议文件名作为完整存储路径。onDownloadUpdated 回调在下载进行中反复触发,通过 getPercentComplete() 获取进度百分比并更新状态文案。onDownloadFailed 回调在下载失败时触发,通过 getGuid() 获取任务唯一标识用于错误追踪。onDownloadFinish 回调是 6.1.1 新特性的关键:调用 getOriginalUrl() 获取文件直链来源 URL,调用 getReferrerUrl() 获取触发下载的页面 URL,将两者连同文件名、大小与时间封装为 DownloadRecord 通过 unshift 置顶到记录列表。最后通过 setDownloadDelegate 将代理绑定到控制器,try-catch 包裹消除首次绑定可能抛出的告警。

下载记录卡(recordCard)展示双 URL 溯源信息的完整布局:顶部文件名与完成时间行,中部文件大小与官方渠道标签行,底部两行分别用链接图标与文档图标标注原始 URL 与引用页 URL,使用等宽字体 monospace 配合单行截断展示完整地址。

9.4 提醒 Tab(tabRemind)

提醒 Tab 是 Notification Kit 特性的交互入口,包含通知授权卡、发布提醒按钮、学习提醒时间轴与通知历史四个区块。

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

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

requestAuth() 方法实现了两段式通知授权:首先调用 requestEnableNotification 弹出系统授权框,若用户曾拒绝则调用 openNotificationSettings 拉起通知设置页引导二次授权。publishNotice() 方法是沙箱自定义铃声通知链路的终点:先确保当前铃声已导入沙箱(未导入时自动导入),再调用 getSoundValue() 获取 'uri::' + fileUri.getUriFromPath(沙箱路径) 格式的 sound 值,填入 NotificationRequest.sound 字段后调用 notificationManager.publish 发布通知。通知 ID 通过 notifyId++ 自增保证不覆盖。发布成功后更新计数并置顶通知历史记录。失败时若错误码为 1600004(未授权)则触发 requestAuth() 重新申请权限。

学习提醒时间轴行(remindRow)采用"时间列 + 圆点竖线 + 提醒卡"三栏布局,固定行高 72 确保竖线在行内填满。圆点颜色随开关状态变化,竖线使用 layoutWeight(1) 在固定行高内自动拉伸。提醒卡内包含事项标题与 Toggle 开关组件,开关状态变更时调用 toggleRemind 更新数据并刷新数组引用。

9.5 铃音 Tab(tabRing)

铃音 Tab 是铃声沙箱化链路的完整操作面板,包含默认铃声状态卡、正弦波铃声生成器与铃声库列表。

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

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

saveRingToSandbox() 方法实现了"生成字节到 EL1 落盘"的核心逻辑。首先通过 getApplicationContext() 获取应用上下文,将 area 设置为 contextConstant.AreaMode.EL1 确保通知铃声音频位于 EL1 区域(HarmonyOS 6.1.1 要求通知 sound 音频必须位于 EL1)。然后拼接沙箱 filesDir 路径与文件名,调用 buildWavBytes 生成 WAV 字节,通过 fs.openSync 以创建+只写+截断模式打开文件,fs.writeSync 写入字节,fs.closeSync 关闭文件。try-catch 包裹确保沙箱写入失败时静默降级到系统铃声。

getSoundValue() 方法是 6.1.1 新特性的 URI 转换接口。它读取当前默认铃声的沙箱路径,通过 fileUri.getUriFromPath() 将文件系统路径转换为 file:// 格式的 URI,再拼接 'uri::' 前缀。这个前缀告诉 Notification Kit 该 sound 值指向应用沙箱内的自定义音频文件,而非系统内置铃声类型。

铃声库行(ringRow)展示频率图标、名称文件与大小、以及"生成到沙箱"和"设为默认"两个操作按钮。已入沙箱的铃声显示绿色"已入沙箱"标签,当前默认铃声显示蓝色"默认中"标签,其余显示可点击的操作按钮。行边框颜色在当前默认铃声时使用海岸蓝高亮,其余使用浅雾线弱化。

9.6 我的 Tab(tabMine)

我的 Tab 展示备考身份渐变大卡、错题趋势传统柱状图与错题本清单。

备考身份卡使用 linearGradient 以 130 度角从深海岸蓝到海岸蓝的渐变填充,内含头像 Emoji、姓名目标、等级胶囊、三列统计数据(累计备考天数、累计刷题题数、待复盘错题数)与总体备考进度条。三列统计数据之间使用 1 像素宽的半透明白色竖线分隔,进度条使用白色填充半透明白色轨道,文字使用 whiteSoft 弱化白色保证渐变背景上的层次感。

  @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 + ForEach 的纯组件化方式构建,与题库 Tab 的 Canvas 柱状图形成技术路线对比。每根柱体是一个 Column 组件,高度由 Math.max(8, val * (this.breath ? 1.0 : 0.92)) 计算:breathtrue 时柱高为 val 原值,为 false 时乘以 0.92 系数使柱高微幅收缩。Math.max(8, ...) 确保最小高度不低于 8 像素,避免零值柱体完全消失。柱体使用 linearGradient 以 90 度角从海岸蓝(10% 处)到深海岸蓝(100% 处)填充。最后一根柱体的数值标注使用海藻绿色表示"最近错题最少 = 进步"。

十、Canvas 图表卡片详解

  drawBar() {
    const ctx = this.barCtx;
    const w = 340;
    const h = 210;
    const pad = 26;
    const labelSpace = 16;
    const plotH = h - pad * 2 - labelSpace;
    const max = 340;
    const n = BRUSH_VAL.length;
    const slot = (w - pad * 2) / n;
    const barW = 24;
    const wave = this.breath ? 1.0 : 0.93;
    ctx.clearRect(0, 0, w, h);
    const yBase = pad + plotH;
    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 绘制的核心。首先清空画布,然后绘制三等分背景网格线。六根渐变柱通过循环绘制:每根柱的 X 坐标由 pad + slot * i + (slot - barW) / 2 计算居中位置,Y 坐标由 yBase - bh 计算底部对齐,高度 bh = (val / max) * plotH 按数据值占比映射。wave 系数随 breath 状态在 1.0 与 0.93 之间切换,使柱高每秒微幅波动 7% 实现"呼吸"效果。每根柱使用 createLinearGradient 从海岸蓝(顶部)到深海岸蓝(底部)填充。柱顶标注数值使用加粗 11 像素字体居中,底部标注月份使用 10 像素雾蓝灰字体。左上角标注"单位:题"说明文字。

Canvas 组件通过 onReady 回调在画布就绪后将 canvasReady 置为 true 并首次调用 drawBar()。呼吸动画定时器在 aboutToAppear 中以 1000ms 间隔启动,每次翻转 breath 后检查 canvasReady 标志决定是否重绘。

十一、底部 Tab 栏与弹窗系统

11.1 底部 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 栏使用 Row 等分六列,每列内为纵向排列的图标与标签。选中态通过 opacity 全不透明与 tabOn 海岸蓝色加粗体现,未选中态通过 0.65 透明度与 text3 雾蓝灰色弱化。顶部 1 像素分割线与内容区分离,纯白底色与页面背景形成微妙层次。点击更新 currentTab 即可切换内容区,无需额外路由逻辑。

11.2 弹窗遮罩系统

  @Builder
  modalOverlay(onClose: () => void) {
    Stack() {
      Column().width('100%').height('100%').backgroundColor(COLORS.mask)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
    .onClick(() => onClose())
  }

modalOverlay 是通用弹窗遮罩构建器,接收一个 onClose 回调函数。遮罩使用半透深海墨色 rgba(34,48,60,0.5) 覆盖全屏,点击遮罩区域触发 onClose 关闭弹窗。三个具体弹窗面板(panelAddpanelEditpanelDel)均在 Stack 中先调用 modalOverlay(onClose) 再叠加自身的 Column 面板内容,实现了遮罩与面板的复用式组合。

新增真题弹窗(panelAdd)包含科目、年份卷、题量三个 TextInput 与完成度 Slider 滑杆,保存时调用 savePaper() 将新条目 unshift 到列表顶部。编辑弹窗(panelEdit)包含当前真题信息展示与完成度滑杆,保存时调用 updatePaper() 直接修改指定索引的 done 属性。删除确认弹窗(panelDel)展示待删除真题信息与珊瑚橙色的删除按钮,确认后调用 delPaper() 执行 splice 删除。三个弹窗都通过整体刷新数组引用 this.paperList = this.paperList.slice() 来驱动 ForEach 重绘。

11.3 生命周期管理

  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 在组件销毁时清理定时器,防止内存泄漏。这种"初始化-运行-清理"的三段式生命周期管理是 ArkUI 组件的标准实践。

十二、页面主构建方法

  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 纵向排列头部、分割线、内容滚动区与底部 Tab 栏。内容区通过 if-else if 条件链在六个 Tab 构建器间切换,Scroll 容器包裹内容并隐藏滚动条,layoutWeight(1) 使内容区占据头部与底部之间的全部剩余空间。顶层三个弹窗面板按各自的布尔状态变量按需渲染,Stack 的层叠特性使弹窗自然覆盖在主内容之上。这种"Stack 层叠 + 条件构建"的架构简洁而高效,是 ArkUI 复杂页面布局的经典模式。

十三、功能模块对比表

功能模块技术栈核心接口/方法数据模型视觉表达特性亮点
题库管理ArkUI 声明式组件visiblePapers() 过滤 + ForEach 渲染PaperItem(@Observed)科目色块 + 进度条 + Canvas 柱状图呼吸动画联动 Canvas 重绘
网页浏览ArkWebWebviewController + loadUrl()urlInput/webUrl 双状态分离地址栏 + 快捷站点横滑 + Web 组件协议自动补全 + 双状态防抖
下载溯源ArkWeb 6.1.1WebDownloadDelegate 四回调DownloadRecord(双 URL)进度卡 + 等宽字体 URL 列表getOriginalUrl + getReferrerUrl 双溯源
学习提醒Notification KitrequestEnableNotification + publishRemindItem + NoticeLog固定行高时间轴 + Toggle 开关两段式授权 + 通知历史置顶
铃声定制Notification Kit + CoreFileKitbuildWavBytes + saveRingToSandboxRingItem(频率/时长/沙箱状态)正弦波生成器 + 铃声库列表正弦波 PCM 合成 + EL1 落盘 + uri:: 前缀
个人中心ArkUI + CanvaschartCard ForEach 柱状图MistakeItem(@Observed)渐变身份卡 + 传统柱状图 + 错题清单Column 柱状图与 Canvas 柱状图技术路线对比
弹窗系统ArkUI @BuildermodalOverlay 通用遮罩formSubject/editDone 表单状态Stack 层叠面板新增/编辑/删除三面板复用遮罩
色彩系统接口 + 常量ColorPalette 接口 + COLORS 常量16 色字段集中声明海岸蓝白 + 珊瑚橙浅色双主色语义命名 + 科目色映射函数

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

布局方式与数据流

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

页面根结构通常由头部、内容区和底部 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 框架为基础,对"答题星球"备考真题下载平台进行了完整的组件化架构解析。从色彩体系设计到数据模型声明,从工具函数实现到六大 Tab 的逐段代码分析,从 Canvas 图表绘制到弹窗系统构建,我们看到了 ArkUI 声明式编程范式在复杂业务场景下的系统性表达力。

架构层面的核心收获体现在三个方面。第一,状态管理的层次化设计:所有状态变量统一声明在组件顶层,通过 @State@Observed 的配合实现了跨 Tab 数据共享——下载 Tab 的记录在网页 Tab 的下载回调中更新后无需手动刷新即可在切换后看到,这是声明式 UI 的天然优势。第二,@Builder 方法的模块化拆分:头部、六个 Tab、底部栏、三个弹窗面板各自封装为独立的构建器,使 1799 行代码的可维护性远超传统的命令式布局。第三,通用构建器的复用模式:modalOverlay 遮罩构建器被三个弹窗面板复用,paperRowrecordCardremindRowringRow 等行级构建器配合 ForEach 实现了数据驱动的列表渲染。

HarmonyOS 6.1.1 三大特性的深度实践是本平台的技术核心。ArkWeb 的 WebDownloadDelegate 四回调链路通过 getOriginalUrlgetReferrerUrl 双接口实现了真题下载的完整来源溯源,让每份试卷的"文件直链来源"与"触发下载页面"都可追溯,解决了备考资料可信度验证的痛点。Notification Kit 的沙箱自定义铃声链路通过"正弦波 PCM 合成到 EL1 落盘到 uri:: 前缀 URI 转换"三步实现了通知铃声的完全个性化,让不同类型的备考提醒拥有差异化铃声标识。Canvas 绘制通过 drawBar() 方法在画布上实现渐变柱状图与呼吸动画的联动,使刷题量数据可视化呈现动态生命力。

展望未来,本平台可在以下方向持续演进。首先,数据层目前使用 Mock 常量数组,后续可接入 @ohos.data.relationalStore 关系型数据库或云端同步实现持久化存储,让真题清单与下载记录在应用重启后保留。其次,ArkWeb 的双 URL 溯源能力可进一步与反钓鱼安全检测结合,在下载前校验 originalUrl 域名是否在可信白名单内。第三,Notification Kit 的通知能力可扩展为定时提醒(通过 NotificationRequest.deliveryTime 实现到点自动推送),使学习提醒无需应用前台运行。第四,Canvas 图表可引入更多图表类型(折线图、饼图、雷达图)丰富数据可视化的表达维度。第五,铃声生成器可从单频正弦波扩展为多音叠加的和弦合成,甚至支持用户导入 MIDI 序列生成更丰富的铃声。最后,整个平台可适配 HarmonyOS 的多设备形态,在平板与折叠屏上以自适应布局呈现更宽的真题清单与更大的图表画布。

ArkUI 框架的声明式范式与 HarmonyOS 的系统能力开放,为移动应用开发提供了从 UI 表达到系统集成的完整工具链。本平台的实践证明,"声明式 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 将自动执行以下操作:

  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.1Release✅ 已安装

界面顶部提示:“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 246.1.1.100Release✅ 已安装
API Version 236.1.0.28Beta1未安装
API Version 226.0.2.112Release未安装

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

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

在这里插入图片描述


三、小结

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

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


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

Logo

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

更多推荐