一、技术前言

在非物质文化遗产数字化保护日益受到重视的今天,传统纹样的采集、整理与再创作正在从纸质档案走向数字平台。从唐草卷纹的织锦披帛底纹到宋代缠枝的汝窑描银,从万字回纹锦的霞帔坠錾刻到冰梅青花的盖碗釉下彩,每一件非遗纹样都承载着特定朝代的审美范式与工艺密码。然而,传统纹样管理平台往往面临三大困境:纹样品类的占比统计缺乏直观可视化手段、嵌套分类浏览的滑动体验割裂断裂、WebP 动图素材的元数据读写缺乏沙箱级闭环验证能力。

在这里插入图片描述

HarmonyOS ArkUI 框架以其声明式 UI 范式为这些问题提供了系统级的解决方案。ArkUI 基于 TypeScript 扩展的 ArkTS 语言,通过 @Component 装饰器封装可复用组件,通过 @State@Observed 等状态管理装饰器实现数据驱动渲染,通过 @Builder 方法将复杂的 UI 结构拆分为可组合的构建块。这种架构天然适合非遗纹样平台中"数据—视图—交互"紧耦合的需求:纹样素材的增删改需要响应式刷新卡片列表,朝代与品类的双层筛选需要嵌套滚动的流畅联动,WebP 动画的帧延迟与循环次数需要读写回读的全链路校验。
在这里插入图片描述
本平台深度融合了 HarmonyOS 6.1.1 的三大前沿特性。Canvas 绘制能力 提供了环形图的中心镂空绘制链路——通过 CanvasRenderingContext2D 获取 2D 绘图上下文,以 ctx.arc 逐扇区填色绘制五品类占比饼图,以 ctx.clearRect + 重绘实现 setInterval 驱动的呼吸微动动画,以 ctx.fillText 在扇区上标注百分比与中心镂空区域的馆藏总量。Tabs 嵌套滚动能力 通过 nestedScroll(TabsNestedScrollMode) 让内层纹样类别列表滑到边缘后联动外层朝代频道,SELF_FIRST(先内后外)与 SELF_ONLY(仅内层)两种模式可实时切换,并通过 onChange 回调将每一次翻页记录到滑动日志时间轴。Image Kit WebP 元数据能力 构建了"生成样图 → 读取元数据 → 写入元数据 → 回读校验"的完整沙箱闭环——通过 image.createPixelMap 将像素画编码为 WebP,以 fileIo.openSync 落盘 filesDir,再以 source.readImageMetadataByType 读取五字段快照,以 source.writeImageMetadata 写回帧延迟与循环次数,最后重建 ImageSource 二次读取比对,全程零权限、沙箱内完成。

在这里插入图片描述

二、整体架构流程图

Page1214 主组件

headerBanner 头部朱砂渐变Banner

内容区 6 Tab切换

tabBar 底部导航

modalOverlay 弹窗遮罩

Tab0 素材馆
朝代筛选chips+双列纹样卡+Canvas环形图+月度柱状图

Tab1 频道
朝代×纹样双层Tabs嵌套滚动

Tab2 日志
nestedScroll翻页事件时间轴

Tab3 工坊
纹理五选一WebP样图生成

Tab4 元数据
五字段读写回读校验

Tab5 我的
守艺人渐变大卡+任务清单

Canvas绘制
drawPie环形图中心镂空

Tabs嵌套滚动
nestedScroll模式

ImageKit
像素画编码WebP落盘

ImageKit
readImageMetadataByType

panelAdd 新建纹样收藏

panelEdit 编辑用途描述

panelDel 删除确认

整体架构以 Page1214 为根组件,采用 Stack 容器实现页面层叠:底层是 Column 纵向布局的头部 Banner + 内容区 + 底部 Tab 栏,顶层是全屏弹窗遮罩。内容区通过 currentTab 状态索引在 6 个 @Builder 方法间切换,每个 Tab 拥有完全独立的布局结构。三大特性(Canvas 绘制环形图、Tabs 嵌套滚动、ImageKit WebP 元数据)分别挂载在素材馆、频道、工坊/元数据四个 Tab 上,但它们的状态变量统一声明在组件顶层,实现跨 Tab 数据共享。弹窗系统以 modalOverlay 全屏遮罩为容器,按 addModaleditModaldelModal 三个布尔标志三选一渲染底部弹出面板,点击遮罩空白区即可关闭。

在这里插入图片描述

三、色彩体系设计

3.1 ColorPalette 接口定义

平台采用浅色宣纸米白主题,通过 ColorPalette 接口集中声明全部颜色字段:

interface ColorPalette {
  bg: string;       // 页面底色·宣纸米白
  card: string;     // 卡片底色·纯白
  chip: string;     // 胶囊/输入底色·米杏
  title: string;    // 主标题·墨褐
  sub: string;      // 次级文字·驼褐
  text3: string;    // 弱化文字·浅驼
  red: string;      // 主题色·朱砂
  redD: string;     // 主题色深·深朱砂
  blue: string;     // 辅色·黛蓝
  gold: string;     // 辅色·鎏金
  green: string;    // 辅色·苔绿
  line: string;     // 分割线·米灰
  tabOn: string;    // Tab 激活色·朱砂
  mask: string;     // 弹窗遮罩·墨褐半透
  onMain: string;   // 深色底上的白字
  gradA: string;    // 渐变起点·朱砂
  gradB: string;    // 渐变终点·深朱砂
}

接口定义了 17 个颜色字段,覆盖页面底色、卡片底色、胶囊底色、三级文字色、四色辅助色(朱砂/黛蓝/鎏金/苔绿)、分割线、Tab 选中色、遮罩色、渐变起止色等所有场景需求。这种集中声明的好处是:当需要切换深色主题时,只需替换 COLORS 常量的值而无需修改任何组件代码。

在这里插入图片描述

3.2 COLORS 常量逐色分析

const COLORS: ColorPalette = {
  bg: '#F7F3EC',      // 宣纸米白,模拟传统手工纸的温润底色
  card: '#FFFFFF',     // 纯白卡片,与米白背景形成柔和层次
  chip: '#EFE8DC',     // 米杏胶囊底色,用于筛选chips与参数标签
  title: '#3B2F25',    // 墨褐主标题,高对比度保证可读性
  sub: '#8A7A64',      // 驼褐次级文字,层次柔和过渡
  text3: '#B5A88F',    // 浅驼弱化文字,辅助信息不抢视觉
  red: '#C0392B',      // 朱砂主题色,非遗纹样的视觉锚点
  redD: '#9A2C20',     // 深朱砂,渐变终点与删除操作
  blue: '#2C3E7A',     // 黛蓝辅色,朝代徽标与信息标识
  gold: '#B8860B',     // 鎏金辅色,精选档位与外层频道
  green: '#5E8C61',    // 苔绿辅色,已完成状态与读取操作
  line: '#E8E0D0',     // 米灰分割线,低对比度不干扰内容
  tabOn: '#C0392B',    // Tab选中色与朱砂主色一致
  mask: 'rgba(59,47,37,0.55)', // 墨褐半透遮罩
  onMain: '#FFFFFF',   // 深色底上的白字
  gradA: '#C0392B',    // 渐变起点朱砂
  gradB: '#9A2C20'     // 渐变终点深朱砂
};

色彩设计遵循"宣纸朱砂"原则:朱砂、黛蓝、鎏金、苔绿四色分别对应"主题标识/朝代信息/精选档位/完成状态"四种语义状态,使用户在浅色环境下凭颜色即可快速识别纹样品类与任务进度。头部 Banner 的 linearGradientgradA(朱砂)到 bg(宣纸米白)实现从热烈到温润的自然过渡,底部 6 Tab 栏选中态使用 tabOn(朱砂)高亮,未选中态使用 text3(浅驼)弱化。会员大卡的 linearGradientgradAgradB 实现朱砂到深朱砂的层次渐变,鎏金呼吸圆点与苔绿完成徽标在大卡上形成视觉点缀。

在这里插入图片描述

四、Tab 元数据与辅助数据

4.1 底部导航 Tab 定义

interface TabMeta {
  icon: string;   // Tab 图标
  label: string;  // Tab 标签
}

const TAB_LIST: TabMeta[] = [
  { icon: '🏛️', label: '素材' },
  { icon: '🌀', label: '频道' },
  { icon: '📜', label: '日志' },
  { icon: '🎨', label: '工坊' },
  { icon: '🧬', label: '元数据' },
  { icon: '👤', label: '我的' }
];

底部导航采用 6 Tab 单排布局,每个 Tab 由 emoji 图标与中文标签组成。TabMeta 接口的定义使 Tab 数据与渲染逻辑解耦——tabBar() Builder 通过 ForEach 遍历 TAB_LIST 即可生成全部导航项,新增或调整 Tab 只需修改常量数组。

在这里插入图片描述

4.2 嵌套频道数据

interface ChannelItem {
  name: string;  // 朝代频道名
  icon: string;  // 朝代频道图标
}

const OUTER_CHANNELS: ChannelItem[] = [
  { name: '唐', icon: '🏯' },
  { name: '宋', icon: '🖌' },
  { name: '元', icon: '🏇' },
  { name: '明', icon: '🏮' },
  { name: '清', icon: '🪷' }
];

const INNER_TABS: string[] = ['回纹', '云纹', '方胜', '冰裂', '联珠'];

嵌套频道是 Tabs 嵌套滚动特性(特性 B)的数据基础。外层 OUTER_CHANNELS 提供 5 个朝代频道(唐/宋/元/明/清),内层 INNER_TABS 提供 5 个纹样类别子页签(回纹/云纹/方胜/冰裂/联珠),两者笛卡尔积构成 25 个内容组合。外层 Tabs 使用 barMode(BarMode.Scrollable) 实现横滑页签,内层 Tabs 挂载 nestedScroll 实现边缘联动。

4.3 Canvas 环形图数据

interface PieData {
  val: number;    // 占比(%,合计 100)
  label: string;  // 品类名
}

const PIE_DATA: PieData[] = [
  { val: 30, label: '回纹' },
  { val: 25, label: '云纹' },
  { val: 18, label: '方胜' },
  { val: 15, label: '冰裂' },
  { val: 12, label: '联珠' }
];

const PIE_COLORS: string[] = [COLORS.red, COLORS.blue, COLORS.gold, COLORS.green, COLORS.redD];
const PIE_TOTAL: number = 1280;

环形图数据定义了五品类馆藏占比:回纹 30%、云纹 25%、方胜 18%、冰裂 15%、联珠 12%,合计恰好 100%。PIE_COLORS 数组将五品类映射到五色辅助色板(朱砂/黛蓝/鎏金/苔绿/深朱砂),PIE_TOTAL 作为中心镂空区域的馆藏总量文字。这里需要注意 ArkTS 的语法约束:禁止使用 {val:number}[] 内联类型,必须先定义 interface PieData 再声明数组,这是 ArkTS 对 TypeScript 类型系统的严格化要求。

4.4 月度柱状图数据

const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const DOWNLOAD_VAL: number[] = [320, 410, 380, 520, 470, 610];
const BAR_MAX: number = 640;

月度柱状图记录了 03 月至 08 月共 6 个月的纹样素材成交量,满刻度 BAR_MAX 设为 640 件,用于将原始数值换算为柱状高度。MONTH_IDX 作为 ForEach 的遍历索引,DOWNLOAD_VAL 提供柱高数据,MONTH_NAME 提供底部月份标签。与环形图的 Canvas 自绘不同,柱状图采用 Column + ForEach 的传统布局方式,两种图表实现方式在同一页面形成对比演示。

4.5 WebP 纹理与色板数据

interface TextureItem {
  key: string;    // 像素算法键
  name: string;   // 对应纹样名
  label: string;  // 纹理标签
  desc: string;   // 寓意说明
}

const TEXTURES: TextureItem[] = [
  { key: 'diag', name: '回纹', label: '斜纹', desc: '横竖折线连绵不断,寓意福寿绵长、富贵不断头' },
  { key: 'checker', name: '方胜', label: '棋盘', desc: '两菱相扣同心相连,寓意同心永结、吉祥永续' },
  { key: 'horz', name: '云纹', label: '横带', desc: '如意云头层层叠叠,寓意平步青云、节节高升' },
  { key: 'vert', name: '冰裂', label: '竖带', desc: '冰面裂纹织理纵横,寓意破冰新生、寒尽春来' },
  { key: 'ring', name: '联珠', label: '同心环', desc: '圆珠连环成带成圈,寓意绵延不绝、珠联璧合' }
];

const WEBP_PALETTE: string[] = [COLORS.red, COLORS.blue, COLORS.gold, COLORS.green, COLORS.redD];
const CANVAS_SIZE: number = 96;
const WEBP_QUALITY: number = 90;
const DELAY_PRESETS: number[] = [120, 200, 500];
const LOOP_PRESETS: number[] = [0, 1, 3, 5];

纹理五选一是工坊 Tab 的核心数据。TEXTURES 将五种像素算法(斜纹/棋盘/横带/竖带/同心环)映射到五种非遗纹样(回纹/方胜/云纹/冰裂/联珠),并附带寓意说明。WEBP_PALETTE 复用主题辅助色板作为像素画五色。CANVAS_SIZE 固定为 96×96,WEBP_QUALITY 设为 90 保证编码质量。DELAY_PRESETSLOOP_PRESETS 分别提供帧延迟与循环次数的写入档位选择,所有值均在 ImageKit 的合法区间内。

4.6 内层卡片内容池与任务清单

const INNER_TITLES: string[] = [
  '织锦提花', '瓷器釉彩', '金银錾刻', '雕版印染',
  '建筑彩画', '漆器螺钿', '刺绣锁边', '珐琅掐丝'
];

const INNER_WORKS: string[] = [
  '《缠枝宝相》妆花缎', '《青花冰梅》盖碗', '《龟背联珠》錾花银盘',
  '《落花流水》蓝印花布', '《旋子彩画》梁枋小样', '《黑漆嵌螺》圆盒',
  '《方胜如意》锁绣香囊', '《掐丝回纹》珐琅杯垫'
];

const INNER_NOTES: string[] = [
  '提花综片循环节奏已复刻,可直接对接针织 CAD',
  '釉下青花分水五色阶,冰裂纹开片率控制在 12%',
  '錾刻走刀 0.3mm 阳线,联珠圈带间隔等距',
  '灰缬防染浆配方开源,适合文创批量印制',
  '和玺与旋子彩画线稿分层,含金量标注齐全',
  '螺钿厚片切 0.2mm 贝光层,灯下呈虹彩光泽',
  '锁绣针距 3mm 标准化,双面异色绣法注解',
  '掐丝 1.2mm 紫铜丝,回纹转角回填工艺图'
];

三个内容池各 8 条,配合期数序号拼出行业化的卡片标题与描述。这些数据确保内层每个子页签的内容超过一屏,为嵌套滚动的"滑到边缘"演示提供前提条件。MINE_TASKS 则定义了守艺人年度任务清单 6 行,含图标、任务名、工分奖励与完成态。

五、工具函数

5.1 嵌套模式文案函数

function modeLabel(mode: TabsNestedScrollMode): string {
  return mode === TabsNestedScrollMode.SELF_FIRST
    ? 'SELF_FIRST·先内后外' : 'SELF_ONLY·仅内层';
}

function modeShort(mode: TabsNestedScrollMode): string {
  return mode === TabsNestedScrollMode.SELF_FIRST ? '先内后外' : '仅内层';
}

modeLabel 返回嵌套模式的完整文案(含枚举名),用于日志记录的事件描述;modeShort 返回短文案,用于头部胶囊与模式切换 chips 的紧凑展示。两个函数将 TabsNestedScrollMode 枚举值翻译为人类可读文案,避免在模板中硬编码三元表达式。

5.2 元数据字段格式化函数

function fmtField(v: number, unit: string): string {
  return v < 0 ? '未提供' : `${v}${unit}`;
}

function loopText(v: number): string {
  if (v < 0) { return '未提供'; }
  if (v === 0) { return '0(不限)'; }
  return `${v}`;
}

function sizeText(v: number): string {
  return v < 0 ? '未提供' : `${v} px`;
}

这三个函数处理 WebP 元数据五字段的格式化展示。约定 -1 表示"未提供"(即 undefined 兜底值),0loopCount 语义中表示无限循环。fmtField 是通用格式化器,loopTextsizeText 是针对循环次数与像素尺寸的专用格式化器,确保元数据卡片的展示语义准确。

5.3 像素画核心函数

function hexToRgba(hex: string): number {
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return 0xFF000000 | (b << 16) | (g << 8) | r;
}

function pixelColor(row: number, col: number, key: string, palette: string[]): number {
  const n = palette.length;
  if (key === 'checker') {
    return hexToRgba(palette[(Math.floor(row / 8) + Math.floor(col / 8)) % n]);
  }
  if (key === 'horz') {
    return hexToRgba(palette[Math.floor(row / 16) % n]);
  }
  if (key === 'vert') {
    return hexToRgba(palette[Math.floor(col / 16) % n]);
  }
  if (key === 'ring') {
    const dx = col - CANVAS_SIZE / 2;
    const dy = row - CANVAS_SIZE / 2;
    const dist = Math.sqrt(dx * dx + dy * dy);
    return hexToRgba(palette[Math.floor(dist / 9) % n]);
  }
  return hexToRgba(palette[(row + col) % n]);
}

hexToRgba 将十六进制颜色字符串转为小端 RGBA8888 排布的 Uint32 值,这是 Uint32Array 直写像素的格式要求——Alpha 通道在高 8 位,Blue 在次高 8 位,依次类推。pixelColor 是纹理像素画的核心算法,根据不同的 key 选取不同的像素织理模式:棋盘按 8×8 分块行列块索引相加取模、横带每 16 行换一色、竖带每 16 列换一色、同心环按到画布中心的欧氏距离分环、斜纹按行列相加取模形成 45 度斜带。五种算法各有独特的视觉特征,对应五种非遗纹样的语义。

5.4 语义配色函数

function catColor(cat: string): string {
  if (cat === '回纹') { return COLORS.red; }
  if (cat === '云纹') { return COLORS.blue; }
  if (cat === '方胜') { return COLORS.gold; }
  if (cat === '冰裂') { return COLORS.green; }
  if (cat === '联珠') { return COLORS.redD; }
  return COLORS.text3;
}

function scoreBadge(score: number): string {
  if (score >= 92) { return '精选'; }
  if (score >= 88) { return '优选'; }
  return '良品';
}

function scoreColor(score: number): string {
  if (score >= 92) { return COLORS.red; }
  if (score >= 88) { return COLORS.gold; }
  return COLORS.green;
}

catColor 将五品类映射到五色辅助色板,与环形图的 PIE_COLORS 保持一致。scoreBadgescoreColor 将适配度评分映射到三档文案(精选/优选/良品)与三色配色(朱砂/鎏金/苔绿),使纹样卡片的品质评级一目了然。

5.5 状态与日志配色函数

function genStateColor(state: string): string {
  if (state.indexOf('失败') >= 0) { return COLORS.redD; }
  if (state.indexOf('生成中') >= 0) { return COLORS.gold; }
  if (state.indexOf('已生成') >= 0) { return COLORS.green; }
  return COLORS.text3;
}

function opColor(op: string): string {
  if (op === '生成样图') { return COLORS.blue; }
  if (op === '读取元数据') { return COLORS.green; }
  if (op === '写入元数据') { return COLORS.red; }
  return COLORS.gold;
}

function layerColor(layer: string): string {
  return layer === '外层朝代' ? COLORS.gold : COLORS.green;
}

genStateColor 根据 WebP 生成状态文案的关键词返回对应配色——失败用深朱砂、生成中用鎏金、已生成用苔绿。opColor 为元数据操作日志的四种操作名分配配色,layerColor 为滑动日志的外/内层翻页记录分配双色徽标。这些函数将业务语义映射到视觉语义,使日志流和状态胶囊的色彩具备信息密度。

六、数据模型层

6.1 PatternItem 纹样素材模型

@Observed
export class PatternItem {
  name: string;    // 纹样名
  dynasty: string; // 朝代
  cat: string;     // 品类
  uses: string;    // 用途描述
  score: number;   // 适配度评分

  constructor(name: string, dynasty: string, cat: string, uses: string, score: number) {
    this.name = name;
    this.dynasty = dynasty;
    this.cat = cat;
    this.uses = uses;
    this.score = score;
  }
}

PatternItem 是素材馆双列卡片的业务实体,标注 @Observed 装饰器使其属性变更可被 ArkUI 框架追踪。当弹窗确认新建、编辑或删除纹样时,patternList 数组的变更会触发 ForEach 的差异化渲染。构造函数接收五个字段:纹样名(如"唐草卷纹")、朝代(唐/宋/元/明/清)、品类(回纹/云纹/方胜/冰裂/联珠)、用途描述(工艺载体与应用场景)、适配度评分(0~100)。

6.2 InnerCard 子类卡片模型

@Observed
export class InnerCard {
  id: string;     // 唯一键
  tag: string;    // 所属纹样类别子页签名
  title: string;  // 卡片标题
  desc: string;   // 卡片描述

  constructor(id: string, tag: string, title: string, desc: string) {
    this.id = id;
    this.tag = tag;
    this.title = title;
    this.desc = desc;
  }
}

function innerMockData(channel: ChannelItem, tabName: string): InnerCard[] {
  const list: InnerCard[] = [];
  for (let i = 1; i <= 8; i++) {
    list.push(new InnerCard(
      `${channel.name}-${tabName}-${i}`,
      tabName,
      `${tabName}纹·${INNER_TITLES[i - 1]}${i}`,
      `${channel.icon}${channel.name}」频道「${tabName}」子类第 ${i} 条素材:${INNER_WORKS[i - 1]}${INNER_NOTES[i - 1]}`));
  }
  return list;
}

InnerCard 是嵌套 Tabs 内层列表的条目模型,id 由"朝代-品类-序号"拼接保证全局唯一,tag 标识所属纹样类别。innerMockData 是 Mock 数据生成器,接收外层频道项与内层页签名,循环 8 次拼出行业化标题与描述,确保每个子页签内容超过一屏——这是嵌套滚动"滑到边缘"可被感知的前提条件。

6.3 SwipeLog 滑动日志模型

@Observed
export class SwipeLog {
  layer: string;    // 层级
  tabName: string;  // 翻到的页签名
  fromIdx: number;  // 起始索引
  toIdx: number;    // 目标索引
  mode: string;     // 触发时的嵌套模式
  time: string;     // 记录时间

  constructor(layer: string, tabName: string, fromIdx: number, toIdx: number, mode: string) {
    this.layer = layer;
    this.tabName = tabName;
    this.fromIdx = fromIdx;
    this.toIdx = toIdx;
    this.mode = mode;
    const d = new Date();
    this.time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
  }
}

SwipeLog 记录每一次 Tabs 翻页事件,包含层级(外层朝代/内层类别)、目标页签名、起始与目标索引、事发时的嵌套模式、以及自动生成的时分秒时间戳。这些数据在日志 Tab 的时间轴中以固定行高 72 渲染,双色层徽标(外层鎏金/内层苔绿)使用户能直观区分翻页发生在哪一层。

6.4 WebpMetaSnapshot 元数据快照模型

@Observed
export class WebpMetaSnapshot {
  canvasWidth: number;
  canvasHeight: number;
  delayTime: number;
  unclampedDelayTime: number;
  loopCount: number;

  constructor(w: number, h: number, d: number, u: number, l: number) {
    this.canvasWidth = w;
    this.canvasHeight = h;
    this.delayTime = d;
    this.unclampedDelayTime = u;
    this.loopCount = l;
  }
}

WebpMetaSnapshot 镜像了 WebPMetadata 的五个字段,约定 -1 表示未提供。两个实例分别用于读取快照(metaSnapshot)与回读校验快照(verifySnapshot),在元数据 Tab 的卡片中以 monospace 字体并排展示,回读校验卡通过绿色描边高亮以区分。

6.5 MetaOpLog 元数据操作日志模型

@Observed
export class MetaOpLog {
  op: string;      // 操作名
  detail: string;  // 结果明细
  time: string;     // 记录时间

  constructor(op: string, detail: string) {
    this.op = op;
    this.detail = detail;
    const d = new Date();
    this.time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
  }
}

MetaOpLog 记录元数据操作的四类事件:生成样图、读取元数据、写入元数据、回读校验。每条日志包含操作名、结果明细(含错误码)与时间戳,以 unshift 置顶方式加入 opLogs 数组,超过 30 条时 pop 尾部,形成固定容量的操作日志流。

七、组件主体结构

7.1 组件声明与状态定义

@Entry
@Component
struct Page1214 {
  @State currentTab: number = 0;      // 当前 Tab 索引
  @State breath: boolean = false;     // 呼吸动画开关
  private timer: number = -1;         // 呼吸动画定时器句柄

  @State addModal: boolean = false;   // 新建纹样收藏弹窗
  @State editModal: boolean = false;  // 编辑用途弹窗
  @State delModal: boolean = false;   // 删除确认弹窗
  @State editIdx: number = -1;        // 编辑条目索引
  @State delIdx: number = -1;         // 删除条目索引
  @State inputText: string = '';      // 弹窗输入框内容

  @State patternList: PatternItem[] = PATTERN_LIST;  // 纹样素材数据
  @State activeDynasty: string = '全部';             // 当前朝代筛选

Page1214 是整个页面的入口组件,标注 @Entry@Component。状态变量分为四组:基础 UI 状态(当前 Tab 索引、呼吸动画开关、定时器句柄)、弹窗状态(三态布尔标志 + 编辑/删除索引 + 输入文本)、素材馆业务状态(纹样数据列表 + 朝代筛选)。breath 布尔值是全局动画驱动器,每秒由 setInterval 翻转一次,驱动 Canvas 环形图重绘与柱状图奇偶柱交替波动。

7.2 三大特性状态

  // 特性 A 状态(Canvas 绘制)
  private pieCtx: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(new RenderingContextSettings(true));

  // 特性 B 状态(Tabs 嵌套滚动)
  @State nestedMode: TabsNestedScrollMode = TabsNestedScrollMode.SELF_FIRST;
  @State outerIndex: number = 0;
  @State innerIndex: number = 0;
  @State swipeLogs: SwipeLog[] = [];

  // 特性 C 状态(WebP 元数据)
  @State textureIdx: number = 0;
  @State pixelMap?: image.PixelMap = undefined;
  @State webpPath: string = '';
  @State genState: string = '待生成';
  @State metaSnapshot?: WebpMetaSnapshot = undefined;
  @State writeDelay: number = 120;
  @State writeLoop: number = 3;
  @State verifySnapshot?: WebpMetaSnapshot = undefined;
  @State opLogs: MetaOpLog[] = [];

特性 A 的 pieCtx 声明为 private 而非 @State——因为 CanvasRenderingContext2D 实例本身不需要触发 UI 重绘,Canvas 的重绘由手动调用 drawPie() 方法驱动。特性 B 的 nestedMode 默认设为 SELF_FIRST(先内后外),内外层索引与翻页日志构成嵌套滚动的完整状态。特性 C 的状态覆盖了 WebP 元数据的完整生命周期:纹理选择索引、像素画预览、沙箱落盘路径、生成状态文案、读取快照、写入参数(帧延迟与循环次数)、回读校验快照、操作日志流。

7.3 生命周期与 build 方法

  aboutToAppear() {
    this.timer = setInterval(() => {
      this.breath = !this.breath;
      this.drawPie();
    }, 1000);
  }

  aboutToDisappear() {
    if (this.timer !== -1) {
      clearInterval(this.timer);
      this.timer = -1;
    }
  }

  build() {
    Stack({ alignContent: Alignment.Center }) {
      Column() {
        this.headerBanner()
        Divider().strokeWidth(1).color(COLORS.line)
        Column() {
          if (this.currentTab === 0) {
            this.tabPatterns()
          } else if (this.currentTab === 1) {
            this.tabNested()
          } else if (this.currentTab === 2) {
            this.tabLogs()
          } else if (this.currentTab === 3) {
            this.tabStudio()
          } else if (this.currentTab === 4) {
            this.tabMeta()
          } else {
            this.tabMine()
          }
        }.layoutWeight(1).width('100%')
        this.tabBar()
      }.width('100%').height('100%')
      if (this.addModal || this.editModal || this.delModal) {
        this.modalOverlay(() => { this.closeAllModals(); })
      }
    }.width('100%').height('100%').backgroundColor(COLORS.bg)
  }

aboutToAppear 在组件出现时启动呼吸动画定时器,每 1000 毫秒翻转 breath 并调用 drawPie() 重绘 Canvas——这是因为 Canvas 组件不像 @State 驱动的声明式 UI 那样自动重绘,必须手动调用绘制方法。aboutToDisappear 负责清理定时器,防止内存泄漏。build 方法以 Stack 为根容器,内层 Column 纵向排列头部 Banner、分割线、内容区与底部 Tab 栏,顶层条件渲染弹窗遮罩。内容区通过 if-else 链在 6 个 @Builder 方法间切换,这种写法保证了同一时刻只有一个 Tab 的布局被渲染,节省内存。

八、头部区域详解

  @Builder
  headerBanner() {
    Column({ space: 10 }) {
      Row() {
        Column({ space: 4 }) {
          Text('云纹馆 · 非遗纹样素材平台').fontSize(20).fontWeight(FontWeight.Bold)
            .fontColor(COLORS.onMain)
          Text(this.currentTab === 0 ? `素材馆 · 馆藏 ${this.patternList.length}`
            : this.currentTab === 1 ? '频道 · 朝代×纹样双层 Tabs'
              : this.currentTab === 2 ? '滑动日志 · nestedScroll 时间线'
                : this.currentTab === 3 ? 'WebP 纹理样图工坊'
                  : this.currentTab === 4 ? '元数据读写 · 五字段'
                    : '守艺人中心').fontSize(11).fontColor(COLORS.onMain).opacity(0.85)
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)

        Circle({ width: 10, height: 10 })
          .fill(COLORS.onMain)
          .opacity(this.breath ? 0.9 : 0.45)
      }.width('100%')

      Row({ space: 8 }) {
        Row({ space: 6 }) {
          Text('🏛️').fontSize(10)
          Text(`馆藏 ${PIE_TOTAL}`).fontSize(10).fontColor(COLORS.sub)
        }.padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .borderRadius(12).backgroundColor(COLORS.chip)

        Row({ space: 4 }) {
          Circle({ width: 6, height: 6 })
            .fill(this.nestedMode === TabsNestedScrollMode.SELF_FIRST ? COLORS.green : COLORS.gold)
          Text(`嵌套 ${modeShort(this.nestedMode)}`).fontSize(10).fontColor(COLORS.sub)
        }.padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .borderRadius(12).backgroundColor(COLORS.chip)

        Row({ space: 4 }) {
          Circle({ width: 6, height: 6 }).fill(genStateColor(this.genState))
          Text(`WebP ${this.genState}`).fontSize(10).fontColor(COLORS.sub)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        }.padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .borderRadius(12).backgroundColor(COLORS.chip).layoutWeight(1)
      }.width('100%')
    }.padding({ left: 16, right: 16, top: 12, bottom: 12 })
    .width('100%')
    .linearGradient({
      angle: 160,
      colors: [[COLORS.gradA, 0], [COLORS.bg, 1]]
    })
  }

头部 Banner 是整个页面的视觉入口,采用 linearGradient 从朱砂(gradA)到宣纸米白(bg)的 160 度角渐变,营造从热烈到温润的过渡。第一行是标题与副标题的 Column,副标题通过 currentTab 的六路三元表达式联动当前 Tab,展示不同 Tab 的定位文案。右侧的呼吸圆点随 breath 翻转在 0.9 与 0.45 透明度间切换,形成"心跳"视觉效果。

第二行是三个状态胶囊:馆藏胶囊展示总量 1280 件;嵌套模式胶囊用绿/金双色圆点标识当前 nestedScroll 模式(SELF_FIRST 绿 / SELF_ONLY 金);WebP 状态胶囊用 genStateColor 函数返回的配色圆点标识生成状态。三个胶囊将三大特性的实时状态压缩在头部一行,用户无需进入对应 Tab 即可感知全局运行态势。

九、素材馆 Tab 详解

9.1 朝代筛选横滚 chips

  @Builder
  tabPatterns() {
    Column({ space: 10 }) {
      Scroll() {
        Column({ space: 10 }) {
          Row() {
            Text(`纹样卡片 · ${this.filteredPatterns().length}`)
              .fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Blank()
            Text('+ 收藏').fontSize(11).fontColor(COLORS.red)
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(10).backgroundColor(COLORS.chip)
              .onClick(() => { this.openAdd(); })
          }.width('100%')

          Scroll() {
            Row({ space: 8 }) {
              ForEach(DYNASTY_CHIPS, (tag: string) => {
                Text(tag === '全部' ? '全部' : `${tag}`)
                  .fontSize(11)
                  .fontColor(this.activeDynasty === tag ? COLORS.onMain : COLORS.sub)
                  .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                  .borderRadius(14)
                  .backgroundColor(this.activeDynasty === tag ? COLORS.red : COLORS.chip)
                  .onClick(() => { this.switchDynasty(tag); })
              }, (tag: string) => `dyn_${tag}`)
            }.padding({ left: 4, right: 4 })
          }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width('100%')

素材馆 Tab 的第一段是标题行与"+收藏"入口按钮,点击调用 openAdd() 打开新建弹窗。第二段是朝代筛选横滚 chips,6 个档位(全部/唐/宋/元/明/清)以横滚 Scroll 容器排列,选中态用朱砂底白字、未选中态用米杏底驼褐字。switchDynasty 方法更新 activeDynasty 状态,触发 filteredPatterns() 重新计算筛选结果。

9.2 纹样双列卡片与筛选逻辑

  filteredPatterns(): PatternItem[] {
    if (this.activeDynasty === '全部') { return this.patternList; }
    const result: PatternItem[] = [];
    for (const item of this.patternList) {
      if (item.dynasty === this.activeDynasty) {
        result.push(item);
      }
    }
    return result;
  }

  patternPairs(): PatternItem[][] {
    const src = this.filteredPatterns();
    const pairs: PatternItem[][] = [];
    for (let i = 0; i < src.length; i += 2) {
      const pair: PatternItem[] = [];
      pair.push(src[i]);
      if (i + 1 < src.length) {
        pair.push(src[i + 1]);
      }
      pairs.push(pair);
    }
    return pairs;
  }

  indexOfPattern(item: PatternItem): number {
    for (let i = 0; i < this.patternList.length; i++) {
      if (this.patternList[i] === item) { return i; }
    }
    return -1;
  }

筛选逻辑采用预计算策略:filteredPatterns()ForEach 外部调用,返回筛选后的数组;patternPairs() 将筛选结果按两条一组切块为二维数组,供双列卡片布局使用。这种设计的核心考量是性能——如果在 ForEach 内部调用 filter,每次渲染都会重新执行过滤逻辑,而预计算只在实际数据变更时执行一次。indexOfPattern 解决了筛选列表与源列表的索引对齐问题:双列卡片接收的是筛选后的 PatternItem 引用,但编辑/删除操作需要在源列表 patternList 中定位真实索引,通过引用比对(===)而非值比对保证准确性。

9.3 纹样卡片 Builder

  @Builder
  patternCard(item: PatternItem) {
    Column({ space: 8 }) {
      Row({ space: 6 }) {
        Text(item.name).fontSize(13).fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title).maxLines(1).layoutWeight(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(item.dynasty).fontSize(9).fontWeight(FontWeight.Bold)
          .fontColor(COLORS.onMain)
          .padding({ left: 7, right: 7, top: 3, bottom: 3 })
          .borderRadius(8).backgroundColor(COLORS.blue)
      }.width('100%')

      Row({ space: 6 }) {
        Text(item.cat).fontSize(10).fontColor(COLORS.onMain)
          .padding({ left: 7, right: 7, top: 3, bottom: 3 })
          .borderRadius(8).backgroundColor(catColor(item.cat))
        Text(`${scoreBadge(item.score)} ${item.score}`).fontSize(10)
          .fontColor(scoreColor(item.score)).fontFamily('monospace')
      }.width('100%')

      Text(item.uses).fontSize(10).fontColor(COLORS.sub).maxLines(2).width('100%')
        .textOverflow({ overflow: TextOverflow.Ellipsis })

      Row({ space: 12 }) {
        Blank()
        Text('编辑').fontSize(10).fontColor(COLORS.blue)
          .onClick(() => { this.openEdit(this.indexOfPattern(item)); })
        Text('删除').fontSize(10).fontColor(COLORS.redD)
          .onClick(() => { this.openDel(this.indexOfPattern(item)); })
      }.width('100%')
    }.padding(12).borderRadius(12).backgroundColor(COLORS.card)
    .layoutWeight(1).alignItems(HorizontalAlign.Start)
  }

每张纹样卡片包含四行信息:纹样名与朝代徽标(黛蓝底白字)、品类色徽与适配度档位(monospace 字体)、用途描述(两行截断省略)、编辑与删除操作行。品类色徽通过 catColor 函数取五色辅助色板,适配度档位通过 scoreBadgescoreColor 函数取三档文案与配色。编辑和删除操作调用 indexOfPattern 获取真实索引后传入 openEdit/openDel 打开对应弹窗。

十、频道 Tab 详解——Tabs 嵌套滚动

10.1 模式切换与位置说明

  @Builder
  tabNested() {
    Column({ space: 10 }) {
      Row({ space: 8 }) {
        Text(`嵌套模式:${modeLabel(this.nestedMode)}`)
          .fontSize(11).fontColor(COLORS.sub).layoutWeight(1)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        ForEach([TabsNestedScrollMode.SELF_ONLY, TabsNestedScrollMode.SELF_FIRST],
          (m: TabsNestedScrollMode) => {
            Text(modeShort(m)).fontSize(10)
              .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
              .fontColor(this.nestedMode === m ? COLORS.onMain : COLORS.text3)
              .backgroundColor(this.nestedMode === m ? COLORS.red : COLORS.card)
              .onClick(() => { this.nestedMode = m; })
          }, (m: TabsNestedScrollMode) => `mode_${m}`)
      }.width('100%')

      Row({ space: 6 }) {
        Circle({ width: 6, height: 6 }).fill(COLORS.gold)
        Text(`外层 ${OUTER_CHANNELS[this.outerIndex].name}代频道`)
          .fontSize(10).fontColor(COLORS.sub)
        Blank()
        Circle({ width: 6, height: 6 }).fill(COLORS.green)
        Text(`内层 ${INNER_TABS[this.innerIndex]}(第 ${this.innerIndex + 1}/5 页)`)
          .fontSize(10).fontColor(COLORS.sub)
      }.width('100%')

频道 Tab 顶部是嵌套模式切换行,两个 chips(仅内层/先内后外)通过 onClick 切换 nestedMode 状态。下方是双层位置说明行,用鎏金圆点标识外层朝代频道、苔绿圆点标识内层纹样类别,实时展示当前双层位置。这种双色双徽标的设计贯穿了日志 Tab 的时间轴,形成跨 Tab 的视觉一致性。

10.2 外层宿主 Tabs

      Tabs({ barPosition: BarPosition.Start }) {
        ForEach(OUTER_CHANNELS, (ch: ChannelItem) => {
          TabContent() {
            this.innerTabs(ch)
          }.tabBar(`${ch.icon} ${ch.name}`)
        }, (ch: ChannelItem) => ch.name)
      }
      .barMode(BarMode.Scrollable)
      .onChange((index: number) => {
        this.swipeLogs.unshift(new SwipeLog('外层朝代', OUTER_CHANNELS[index].name,
          this.outerIndex, index, modeLabel(this.nestedMode)));
        this.outerIndex = index;
        if (this.swipeLogs.length > 40) { this.swipeLogs.pop(); }
      })
      .layoutWeight(1).width('100%')

外层 Tabs 是 5 个朝代频道的宿主容器,barMode(BarMode.Scrollable) 使页签可横滑。每个 TabContent 内部调用 innerTabs(ch) 渲染内层 Tabs。onChange 回调在频道切换时记录一条 SwipeLog 到日志数组,包含层级标记"外层朝代"、目标频道名、起始与目标索引、以及事发时的嵌套模式文案。日志数组超过 40 条时 pop 尾部保持固定容量。

10.3 内层嵌套 Tabs——nestedScroll 挂载点

  @Builder
  innerTabs(channel: ChannelItem) {
    Tabs({ barPosition: BarPosition.Start }) {
      ForEach(INNER_TABS, (name: string) => {
        TabContent() {
          List({ space: 10 }) {
            ForEach(innerMockData(channel, name), (item: InnerCard) => {
              ListItem() {
                Column({ space: 6 }) {
                  Row() {
                    Text(`${channel.icon} ${name}`).fontSize(13)
                      .fontWeight(FontWeight.Bold).fontColor(COLORS.title)
                    Blank()
                    Text(item.tag).fontSize(10).fontColor(COLORS.sub)
                  }.width('100%')
                  Text(item.title).fontSize(12).fontColor(COLORS.sub).maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text(item.desc).fontSize(11).fontColor(COLORS.text3).maxLines(2)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Row({ space: 8 }) {
                    Text(`${channel.name}代频道`).fontSize(9).fontColor(COLORS.gold)
                      .padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
                    Text('可商用授权').fontSize(9).fontColor(COLORS.green)
                      .padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
                  }.width('100%')
                }.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
              }
            }, (item: InnerCard) => item.id)
          }.width('100%').height('100%').scrollBar(BarState.Off)
        }.tabBar(name)
      }, (name: string) => name)
    }
    .barMode(BarMode.Scrollable)
    .onChange((index: number) => {
      this.swipeLogs.unshift(new SwipeLog('内层类别', INNER_TABS[index],
        this.innerIndex, index, modeLabel(this.nestedMode)));
      this.innerIndex = index;
      if (this.swipeLogs.length > 40) { this.swipeLogs.pop(); }
    })
    .nestedScroll(this.nestedMode)
    .layoutWeight(1).width('100%')
  }

内层 Tabs 是 nestedScroll 的挂载点——这是整个嵌套滚动特性的核心。.nestedScroll(this.nestedMode) 这一行代码决定了内层 Tabs 滑到边缘后的行为:当 nestedModeSELF_FIRST 时,内层滑到边缘后继续滑动会"接力"触发外层频道切换(先内后外);当为 SELF_ONLY 时,内层滑到边缘即止,不联动外层(仅内层)。内层每个 TabContent 包含一个 List,通过 innerMockData 生成 8 条 InnerCard,内容包含纹样品类标题、工艺载体期数标题、作品名与工艺注解描述、以及朝代频道与可商用授权双徽标。每条卡片的 id 由"朝代-品类-序号"拼接保证全局唯一,作为 ForEach 的键值。

十一、日志 Tab 详解——翻页事件时间轴

  @Builder
  tabLogs() {
    Column({ space: 10 }) {
      Row() {
        Text(`已记录 ${this.swipeLogs.length} 次翻页`).fontSize(13)
          .fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Blank()
        Button('清空日志')
          .fontSize(11).height(32).borderRadius(10)
          .fontColor(COLORS.sub).backgroundColor(COLORS.chip)
          .enabled(this.swipeLogs.length > 0)
          .onClick(() => { this.clearLogs(); })
      }.width('100%')

      Row({ space: 12 }) {
        Row({ space: 5 }) {
          Circle({ width: 6, height: 6 }).fill(COLORS.gold)
          Text('外层朝代翻页').fontSize(9).fontColor(COLORS.sub)
        }
        Row({ space: 5 }) {
          Circle({ width: 6, height: 6 }).fill(COLORS.green)
          Text('内层类别翻页').fontSize(9).fontColor(COLORS.sub)
        }
        Blank()
        Text(`当前 ${modeLabel(this.nestedMode)}`).fontSize(9).fontColor(COLORS.text3)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      }.width('100%')

      if (this.swipeLogs.length === 0) {
        Column({ space: 8 }) {
          Text('📜').fontSize(30)
          Text('暂无翻页记录').fontSize(12).fontColor(COLORS.sub)
          Text('去「频道」页滑动外层朝代或内层纹样类别,每一次翻页都会记入时间轴')
            .fontSize(10).fontColor(COLORS.text3).textAlign(TextAlign.Center)
        }.width('100%').padding({ top: 40, bottom: 40 }).borderRadius(12)
        .backgroundColor(COLORS.card)
      } else {
        List() {
          ForEach(this.swipeLogs, (log: SwipeLog) => {
            ListItem() {
              Row() {
                Column({ space: 4 }) {
                  Text(log.time).fontSize(10).fontWeight(FontWeight.Bold)
                    .fontColor(layerColor(log.layer)).fontFamily('monospace')
                  Text(log.layer === '外层朝代' ? '朝代' : '类别').fontSize(8)
                    .fontColor(COLORS.text3)
                }.width(48).height('100%').justifyContent(FlexAlign.Center)

                Column() {
                  Circle({ width: 10, height: 10 }).fill(layerColor(log.layer))
                  Column().width(2).layoutWeight(1).backgroundColor(COLORS.line)
                }.width(14).height('100%').alignItems(HorizontalAlign.Center)

                Column({ space: 4 }) {
                  Row({ space: 6 }) {
                    Text(log.tabName).fontSize(12).fontWeight(FontWeight.Bold)
                      .fontColor(COLORS.title)
                    Text(`${log.fromIdx + 1}${log.toIdx + 1}`).fontSize(10)
                      .fontColor(COLORS.sub).fontFamily('monospace')
                  }.width('100%')
                  Text(`事发模式:${log.mode}`).fontSize(9).fontColor(COLORS.text3)
                    .maxLines(1).width('100%')
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text(log.layer === '外层朝代' ? '外层朝代频道 TabContent 切换' : '内层纹样类别 TabContent 切换')
                    .fontSize(9).fontColor(COLORS.text3)
                }.layoutWeight(1).height('100%').justifyContent(FlexAlign.Center)
                .padding({ left: 10, right: 10, top: 8, bottom: 8 })
                .borderRadius(10).backgroundColor(COLORS.card)
              }.width('100%').height(72).margin({ bottom: 6 })
            }
          }, (log: SwipeLog) => `${log.time}_${log.tabName}_${log.toIdx}`)
        }.scrollBar(BarState.Off).width('100%').layoutWeight(1)
      }
    }.width('100%').height('100%').padding({ left: 12, right: 12, top: 4, bottom: 8 })
  }

日志 Tab 是嵌套滚动特性的"证据看板"。顶部是计数与清空按钮行,清空按钮在日志为空时禁用(enabled(this.swipeLogs.length > 0))。第二行是图例说明,鎏金圆点代表外层朝代翻页、苔绿圆点代表内层类别翻页,右侧显示当前嵌套模式。

当日志为空时渲染空态占位卡,引导用户去频道 Tab 滑动产生记录。当有日志时渲染时间轴 List:每行固定高度 72,分为三列——左侧时间列(固定宽 48,monospace 字体显示时分秒,下方显示层级短名"朝代"/“类别”)、中间竖线轨道列(固定宽 14,顶部圆点徽标 + 下方竖线通过 layoutWeight(1) 在固定行高内填满)、右侧事件卡片列(页签名 + 翻页索引箭头 + 事发模式 + TabContent 切换说明)。layerColor 函数根据层级返回鎏金或苔绿,使时间轴的圆点徽标与竖线轨道具备双色层次区分。日志以 unshift 倒序排列,最新翻页记录始终置顶。

十二、工坊 Tab 详解——WebP 样图生成

12.1 参数卡与纹理五选一

  @Builder
  tabStudio() {
    Column({ space: 10 }) {
      Scroll() {
        Column({ space: 10 }) {
          Column({ space: 8 }) {
            Text('样图参数').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Row({ space: 8 }) {
              this.paramChip('画布', `${CANVAS_SIZE}×${CANVAS_SIZE}`)
              this.paramChip('质量', `${WEBP_QUALITY}`)
              this.paramChip('纹理', TEXTURES[this.textureIdx].label)
            }.width('100%')
            Text('色板:朱砂 / 黛蓝 / 鎏金 / 苔绿 / 深朱砂 五色非遗色')
              .fontSize(9).fontColor(COLORS.text3).width('100%')
          }.padding(10).borderRadius(12).backgroundColor(COLORS.card).width('100%')

          Column({ space: 8 }) {
            Text('纹理五选一(像素算法 × 纹样语义)').fontSize(12)
              .fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Scroll() {
              Row({ space: 8 }) {
                ForEach(TEXTURES, (t: TextureItem, idx: number) => {
                  Text(`${t.label}·${t.name}`).fontSize(11)
                    .fontColor(this.textureIdx === idx ? COLORS.onMain : COLORS.sub)
                    .padding({ left: 12, right: 12, top: 7, bottom: 7 })
                    .borderRadius(14)
                    .backgroundColor(this.textureIdx === idx ? COLORS.red : COLORS.chip)
                    .onClick(() => { this.textureIdx = idx; })
                }, (t: TextureItem) => `tex_${t.key}`)
              }.padding({ left: 4, right: 4 })
            }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width('100%')
            Text(`${TEXTURES[this.textureIdx].label}对应「${TEXTURES[this.textureIdx].name}」:${TEXTURES[this.textureIdx].desc}`)
              .fontSize(9).fontColor(COLORS.text3).width('100%')
          }.padding(10).borderRadius(12).backgroundColor(COLORS.card).width('100%')
            .alignItems(HorizontalAlign.Start)

工坊 Tab 顶部是参数卡,三个 paramChip 小胶囊展示画布尺寸、编码质量与当前纹理标签。下方是纹理五选一横滚区,5 个 chips 将像素算法与纹样语义绑定(斜纹对应回纹、棋盘对应方胜等),选中态朱砂底白字,底部显示当前选中纹理的寓意说明。paramChip 是可复用的参数小胶囊 Builder,接受标签与值两个参数。

12.2 预览区与生成按钮

          Row({ space: 12 }) {
            if (this.pixelMap !== undefined) {
              Image(this.pixelMap!)
                .width(160).height(160).borderRadius(12)
                .objectFit(ImageFit.Fill)
            } else {
              Column({ space: 6 }) {
                Text('🎨').fontSize(30)
                Text('尚未生成样图').fontSize(10).fontColor(COLORS.text3)
              }.width(160).height(160).borderRadius(12).backgroundColor(COLORS.chip)
              .justifyContent(FlexAlign.Center)
            }

            Column({ space: 6 }) {
              Text(TEXTURES[this.textureIdx].name).fontSize(14)
                .fontWeight(FontWeight.Bold).fontColor(COLORS.title)
              Text(TEXTURES[this.textureIdx].desc).fontSize(10).fontColor(COLORS.sub)
              Text(this.genState).fontSize(11).fontColor(genStateColor(this.genState))
            }.layoutWeight(1).alignItems(HorizontalAlign.Start)
          }.width('100%').alignItems(VerticalAlign.Center)

          Button('生成 WebP 样图')
            .fontSize(12).height(38).borderRadius(10)
            .fontColor(COLORS.onMain).backgroundColor(COLORS.red)
            .width('100%')
            .onClick(() => { this.genWebpFile(); })

预览区采用判空渲染策略:当 pixelMap 存在时用 Image 组件直接渲染 PixelMap(160×160,objectFit(ImageFit.Fill) 填充);不存在时渲染占位卡(🎨 emoji + "尚未生成样图"文案)。右侧是纹理名、寓意说明与生成状态的 Column,生成状态文案通过 genStateColor 取色。底部是全宽生成按钮,点击调用 genWebpFile() 启动异步生成流程。

12.3 生成 WebP 样图核心方法

  async genWebpFile() {
    this.genState = '生成中…';
    const hostCtx = this.getUIContext().getHostContext();
    const dir = hostCtx ? hostCtx.filesDir : '';
    if (dir === '') {
      this.genState = '生成失败(无沙箱)';
      this.opLogs.unshift(new MetaOpLog('生成样图', '失败:未取到宿主 Context 的 filesDir'));
      return;
    }
    try {
      const texture = TEXTURES[this.textureIdx];
      const total = CANVAS_SIZE * CANVAS_SIZE;
      const buf = new ArrayBuffer(total * 4);
      const pixels = new Uint32Array(buf);
      for (let i = 0; i < total; i++) {
        const row = Math.floor(i / CANVAS_SIZE);
        const col = i % CANVAS_SIZE;
        pixels[i] = pixelColor(row, col, texture.key, WEBP_PALETTE);
      }
      const opts: image.InitializationOptions = {
        size: { width: CANVAS_SIZE, height: CANVAS_SIZE },
        pixelFormat: image.PixelMapFormat.RGBA_8888
      };
      const pm = await image.createPixelMap(buf, opts);
      this.pixelMap = pm;
      const packer = image.createImagePacker();
      const webpBuf = await packer.packToData(pm, { format: 'image/webp', quality: WEBP_QUALITY });
      await packer.release();
      const path = `${dir}/pattern_sample.webp`;
      const file = fileIo.openSync(path,
        fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
      fileIo.writeSync(file.fd, webpBuf);
      fileIo.closeSync(file);
      this.webpPath = path;
      this.genState = `已生成 ${(webpBuf.byteLength / 1024).toFixed(1)}KB`;
      this.opLogs.unshift(new MetaOpLog('生成样图',
        `${CANVAS_SIZE}×${CANVAS_SIZE} ${texture.label}${texture.name})像素画编码为 WebP 并落盘`));
      if (this.opLogs.length > 30) { this.opLogs.pop(); }
    } catch (e) {
      const err = e as BusinessError;
      this.genState = `生成失败(${err.code})`;
      this.opLogs.unshift(new MetaOpLog('生成样图', `失败:code ${err.code}${err.message}`));
    }
  }

genWebpFile 是 ImageKit WebP 元数据特性的第一步——生成样图。方法分为四步:

第一步,获取沙箱路径。通过 this.getUIContext().getHostContext() 获取宿主 Context,再取 filesDir 作为落盘目录。如果取不到则标记失败状态并记录日志。

第二步,逐像素织纹。分配 ArrayBuffer(total * 4)(每个像素 4 字节 RGBA),以 Uint32Array 视图按 pixelColor 函数逐像素填色。96×96 共 9216 个像素,每个像素根据其行列坐标和纹理算法键计算颜色值。

第三步,编码为 WebP。先以 image.createPixelMap(buf, opts)ArrayBuffer 转为 PixelMapRGBA_8888 格式),再以 image.createImagePacker().packToData(pm, { format: 'image/webp', quality: 90 }) 编码为 WebP 二进制数据。Packer 用后必须 release() 释放资源。

第四步,落盘沙箱。以 fileIo.openSync(path, READ_WRITE | CREATE | TRUNC) 创建/截断打开文件,writeSync 写入 WebP 数据,closeSync 关闭文件。落盘路径存入 webpPath 状态,供后续元数据读写使用。

异常处理通过 try-catch 捕获 BusinessError,将错误码与消息记录到操作日志。

十三、元数据 Tab 详解——五字段读写回读

13.1 读取元数据

  async readMeta() {
    if (this.webpPath === '') {
      this.opLogs.unshift(new MetaOpLog('读取元数据', '请先在「工坊」生成 WebP 样图'));
      return;
    }
    try {
      const file = fileIo.openSync(this.webpPath, fileIo.OpenMode.READ_WRITE);
      const source = image.createImageSource(file.fd);
      const types: image.MetadataType[] = [image.MetadataType.WEBP_METADATA];
      const meta = await source.readImageMetadataByType(types, 0);
      const webp = meta.webPMetadata;
      this.metaSnapshot = new WebpMetaSnapshot(
        webp?.canvasWidth ?? -1, webp?.canvasHeight ?? -1,
        webp?.delayTime ?? -1, webp?.unclampedDelayTime ?? -1,
        webp?.loopCount ?? -1);
      await source.release();
      fileIo.closeSync(file);
      this.opLogs.unshift(new MetaOpLog('读取元数据',
        `画布 ${this.metaSnapshot!.canvasWidth}×${this.metaSnapshot!.canvasHeight}` +
        `帧延迟 ${fmtField(this.metaSnapshot!.delayTime, 'ms')}` +
        `循环 ${fmtField(this.metaSnapshot!.loopCount, ' 次')}`));
      if (this.opLogs.length > 30) { this.opLogs.pop(); }
    } catch (e) {
      const err = e as BusinessError;
      this.opLogs.unshift(new MetaOpLog('读取元数据', `失败:code ${err.code}${err.message}`));
    }
  }

readMetaREAD_WRITE 模式打开 WebP 文件,创建 ImageSource,调用 readImageMetadataByType([WEBP_METADATA], 0) 按类型读取元数据。第二个参数 0 是帧索引,静态 WebP 取 0。返回的 meta.webPMetadata 是可选字段对象,五个字段全部用 ?? -1 兜底——这是因为 WebPMetadata 的所有字段都是可选的,读取值可能为 undefined,禁止把 undefined0 处理。读取完成后 release() 释放 ImageSourcecloseSync 关闭文件,将快照存入 metaSnapshot 状态并记录日志。

13.2 写入元数据与回读校验

  async writeMeta() {
    if (this.webpPath === '') {
      this.opLogs.unshift(new MetaOpLog('写入元数据', '请先在「工坊」生成 WebP 样图'));
      return;
    }
    try {
      const file = fileIo.openSync(this.webpPath, fileIo.OpenMode.READ_WRITE);
      const source = image.createImageSource(file.fd);
      const webpMeta = {
        canvasWidth: CANVAS_SIZE,
        canvasHeight: CANVAS_SIZE,
        delayTime: this.writeDelay,
        unclampedDelayTime: this.writeDelay,
        loopCount: this.writeLoop
      } as image.WebPMetadata;
      const meta: image.ImageMetadata = { webPMetadata: webpMeta };
      await source.writeImageMetadata(meta);
      await source.release();
      fileIo.closeSync(file);
      this.opLogs.unshift(new MetaOpLog('写入元数据',
        `帧延迟=${this.writeDelay}ms,循环=${this.writeLoop === 0 ? '不限' : this.writeLoop}`));
      if (this.opLogs.length > 30) { this.opLogs.pop(); }
      await this.verifyRead();
    } catch (e) {
      const err = e as BusinessError;
      this.opLogs.unshift(new MetaOpLog('写入元数据',
        `失败:code ${err.code}${err.message}(7700202=不支持,7700204=参数非法)`));
    }
  }

  async verifyRead() {
    try {
      const file = fileIo.openSync(this.webpPath, fileIo.OpenMode.READ_WRITE);
      const source = image.createImageSource(file.fd);
      const meta = await source.readImageMetadataByType([image.MetadataType.WEBP_METADATA], 0);
      const webp = meta.webPMetadata;
      this.verifySnapshot = new WebpMetaSnapshot(
        webp?.canvasWidth ?? -1, webp?.canvasHeight ?? -1,
        webp?.delayTime ?? -1, webp?.unclampedDelayTime ?? -1,
        webp?.loopCount ?? -1);
      await source.release();
      fileIo.closeSync(file);
      const ok = this.verifySnapshot!.delayTime === this.writeDelay
        && this.verifySnapshot!.loopCount === this.writeLoop;
      this.opLogs.unshift(new MetaOpLog('回读校验',
        ok ? '已生效:delayTime/loopCount 与写入值一致'
          : `差异:delayTime=${fmtField(this.verifySnapshot!.delayTime, 'ms')}` +
            `loopCount=${fmtField(this.verifySnapshot!.loopCount, ' 次')}`));
      if (this.opLogs.length > 30) { this.opLogs.pop(); }
    } catch (e) {
      const err = e as BusinessError;
      this.opLogs.unshift(new MetaOpLog('回读校验', `失败:code ${err.code}${err.message}`));
    }
  }

writeMeta 构造 WebPMetadata 对象字面量,五个字段来自控制台选择:canvasWidth/canvasHeight 固定为 CANVAS_SIZE(96),delayTime/unclampedDelayTimewriteDelayloopCountwriteLoop。使用 as image.WebPMetadata 类型断言是官方样例推荐的模式。writeImageMetadata 写回后立即调用 verifyRead() 进行回读校验。

verifyRead 重建 ImageSource 二次读取元数据——注意必须重新 createImageSource 而非复用写入时的 source,因为写入后文件内容已变更。回读快照存入 verifySnapshot 状态,通过比对 delayTimeloopCount 是否与写入值一致判断是否生效,结果记录到日志。错误码 7700202 表示不支持、7700204 表示参数非法,在异常日志中标注方便排查。

13.3 元数据 Tab 布局

元数据 Tab 的布局从上到下依次为:读取区(标题 + 读取按钮)、读取快照卡(判空渲染,metaCard Builder 以 false 表示非高亮)、写入控制台(帧延迟 chips + 循环 chips + 写入按钮)、回读校验卡(判空渲染,metaCard Builder 以 true 表示绿色高亮描边)、操作日志流(固定高度 140 的 Scroll,unshift 置顶)、五字段速查卡。

metaCard Builder 接受标题、快照对象与高亮标志三个参数,渲染五行字段名与值的 monospace 对应表。metaRow Builder 渲染单行字段,字段名用浅驼色、值用驼褐色,均使用 monospace 字体保证等宽对齐。高亮卡通过 border({ width: 1, color: COLORS.green }) 绿色描边与普通卡的 COLORS.line 米灰描边区分。

十四、我的 Tab 详解——守艺人中心

  @Builder
  tabMine() {
    Column({ space: 10 }) {
      Scroll() {
        Column({ space: 10 }) {
          Column({ space: 12 }) {
            Row({ space: 12 }) {
              Text('🧧').fontSize(38)
              Column({ space: 4 }) {
                Text('云纹馆 · 年度守艺人').fontSize(16).fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.onMain)
                Text('LV6 金纹匠 · ID Pattern-Keeper-1214').fontSize(10)
                  .fontColor(COLORS.onMain).opacity(0.85)
              }.alignItems(HorizontalAlign.Start).layoutWeight(1)
              Circle({ width: 8, height: 8 }).fill(COLORS.gold)
                .opacity(this.breath ? 0.9 : 0.45)
            }.width('100%')

            Row({ space: 8 }) {
              this.statBig('收藏纹样', '38', '套', COLORS.onMain)
              this.statBig('累计下载', '1260', '次', COLORS.gold)
              this.statBig('守艺工分', '8920', '分', COLORS.green)
            }.width('100%')

            Text('年度共建任务 68/100 · 距离「御纹匠」还差 32 项')
              .fontSize(9).fontColor(COLORS.onMain).opacity(0.85).width('100%')
          }.padding(14).borderRadius(14).width('100%')
          .linearGradient({
            angle: 135,
            colors: [[COLORS.gradA, 0], [COLORS.gradB, 1]]
          })

          Column() {
            ForEach(MINE_TASKS, (task: MineTask) => {
              Row({ space: 10 }) {
                Text(task.icon).fontSize(16)
                Column({ space: 3 }) {
                  Text(task.label).fontSize(12).fontColor(COLORS.title).maxLines(1).width('100%')
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text(task.hint).fontSize(9).fontColor(COLORS.text3)
                }.alignItems(HorizontalAlign.Start).layoutWeight(1)
                Text(task.done ? '已完成' : '待办').fontSize(9)
                  .fontColor(COLORS.onMain)
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(8)
                  .backgroundColor(task.done ? COLORS.green : COLORS.gold)
              }.padding({ top: 12, bottom: 12, left: 12, right: 12 }).width('100%')
            }, (task: MineTask) => task.label)
          }.borderRadius(12).backgroundColor(COLORS.card).width('100%')

          Text('云纹馆 v1.0.0 · HarmonyOS API 24 · Canvas × Tabs 嵌套滚动 × ImageKit WebP 元数据')
            .fontSize(9).fontColor(COLORS.text3).width('100%')
            .textAlign(TextAlign.Center).padding({ top: 6, bottom: 6 })
        }.width('100%')
      }.scrollBar(BarState.Off).width('100%').layoutWeight(1)
    }.width('100%').height('100%').padding({ left: 12, right: 12, top: 4, bottom: 8 })
  }

我的 Tab 以守艺人会员渐变大卡为视觉核心,采用 135 度角从朱砂到深朱砂的 linearGradient。大卡包含三部分:头部行(🧧 emoji + 守艺人称号 + 金纹匠等级 + 鎏金呼吸圆点)、三列战绩行(statBig Builder 渲染收藏纹样 38 套/累计下载 1260 次/守艺工分 8920 分,深蓝底白字小格)、年度任务进度文案。

下方是创作任务清单 6 行,每行包含任务图标、任务名与工分奖励、以及完成态徽标(已完成苔绿/待办鎏金)。底部是版本信息文案,标注三大特性的技术栈。statBig Builder 接受标签、数值、单位与数值配色四个参数,数值用 22px monospace 加粗、单位用 10px、标签用 9px,三层字号形成视觉层次。

十五、图表卡片

15.1 Canvas 环形图卡

  drawPie() {
    const ctx = this.pieCtx;
    const size = 210;
    const cx = size / 2;
    const cy = size / 2;
    const r = 66 + (this.breath ? 4 : 0);
    ctx.clearRect(0, 0, size, size);
    ctx.globalAlpha = 0.16;
    ctx.beginPath();
    ctx.arc(cx, cy, r + 10, 0, Math.PI * 2);
    ctx.strokeStyle = COLORS.red;
    ctx.lineWidth = 2;
    ctx.stroke();
    ctx.globalAlpha = 1;
    let start = -Math.PI / 2;
    for (let i = 0; i < PIE_DATA.length; i++) {
      const angle = (PIE_DATA[i].val / 100) * Math.PI * 2;
      ctx.beginPath();
      ctx.moveTo(cx, cy);
      ctx.arc(cx, cy, r, start, start + angle);
      ctx.fillStyle = PIE_COLORS[i];
      ctx.fill();
      const mid = start + angle / 2;
      ctx.fillStyle = COLORS.onMain;
      ctx.font = 'bold 10px sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText(`${PIE_DATA[i].val}%`, cx + Math.cos(mid) * r * 0.72, cy + Math.sin(mid) * r * 0.72 + 3);
      start += angle;
    }
    ctx.beginPath();
    ctx.arc(cx, cy, r * 0.58, 0, Math.PI * 2);
    ctx.fillStyle = COLORS.card;
    ctx.fill();
    ctx.fillStyle = COLORS.title;
    ctx.font = 'bold 20px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(`${PIE_TOTAL}`, cx, cy + 1);
    ctx.fillStyle = COLORS.sub;
    ctx.font = '10px sans-serif';
    ctx.fillText('馆藏纹样件', cx, cy + 17);
  }

drawPie 是 Canvas 绘制特性(特性 A)的核心方法。绘制流程分为四层:

第一层,清底重画。clearRect(0, 0, 210, 210) 清空整个画布,保证每秒重绘不留残影。

第二层,外圈呼吸描边。globalAlpha 设为 0.16 画一圈半径 r+10 的朱砂描边,画完立即恢复为 1——这种"用后复位"模式保证透明度不泄漏到后续绘制。

第三层,五扇区填色。从 12 点方向(-Math.PI / 2)顺时针遍历 PIE_DATA,按占比计算扇区角度,moveTo 圆心 + arc 画弧 + fill 填色。每个扇区在弧线中点位置用 fillText 标注百分比,百分比位置通过 Math.cos(mid) * r * 0.72 计算——0.72 系数使文字落在扇区中部偏内。

第四层,中心镂空。画一个半径 r * 0.58 的圆,用卡片底色填色覆盖扇区中心,形成环形图效果。镂空区域内用大字标注馆藏总量 PIE_TOTAL(1280),下方小字标注"馆藏纹样件"。

半径 rbreath 在 66 与 70 之间切换(66 + (this.breath ? 4 : 0)),形成每秒 4 像素的呼吸微动效果。由于 Canvas 不随 @State 自动重绘,aboutToAppear 中的 setInterval 每秒翻转 breath 后手动调用 drawPie() 驱动重绘。

环形图卡的 Builder 在 Canvas 右侧渲染五品类图例(色点 + 品类名 + 占比),图例下方有技术说明文案。Canvas 通过 .onReady(() => { this.drawPie(); }) 在组件就绪时执行首绘。

15.2 月度成交量柱状图卡

  barHeight(i: number): number {
    const base = DOWNLOAD_VAL[i] / BAR_MAX * 96;
    const wave = (i % 2 === 0) === this.breath ? 1.06 : 0.94;
    return Math.max(8, Math.round(base * wave));
  }

  @Builder
  chartCard() {
    Column({ space: 10 }) {
      Row() {
        Text('📊 纹样素材月度成交量').fontSize(13).fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title)
        Blank()
        Text('单位:件').fontSize(9).fontColor(COLORS.text3)
      }.width('100%')

      Row({ space: 6 }) {
        ForEach(MONTH_IDX, (i: number) => {
          Column({ space: 4 }) {
            Text(`${DOWNLOAD_VAL[i]}`).fontSize(8).fontColor(COLORS.text3)
              .fontFamily('monospace')
            Column()
              .width('64%')
              .height(this.barHeight(i))
              .borderRadius(4)
              .linearGradient({
                angle: 180,
                colors: [[COLORS.red, 0], [COLORS.redD, 1]]
              })
            Text(MONTH_NAME[i]).fontSize(9).fontColor(COLORS.sub)
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        }, (i: number) => `bar_${i}_${this.breath}`)
      }.width('100%').alignItems(VerticalAlign.Bottom).height(132)

      Text('柱高随 breath 呼吸在 ±6% 区间交替波动,满刻度按 640 件换算')
        .fontSize(9).fontColor(COLORS.text3).width('100%')
    }.padding(14).borderRadius(12).backgroundColor(COLORS.card).width('100%')
  }

柱状图卡采用 Column + ForEach 的传统布局方式,与环形图的 Canvas 自绘形成对比。每根柱子是一个 Column,内部从上到下为数值文本(monospace 8px)、柱体(width('64%') + height(this.barHeight(i)) + 朱砂到深朱砂的 180 度渐变 + 圆角 4)、月份标签。barHeight 方法将原始数值除以满刻度 640 再乘 96 得到基准高度,再根据 breath 与柱索引奇偶性乘以 1.06 或 0.94 的波动系数——breath 翻转时奇偶柱交替放大缩小,形成"呼吸"波动效果。Math.max(8, ...) 保证最小高度 8px 不至于消失。ForEach 的键值包含 this.breath,确保 breath 变化时柱子高度重新计算。

十六、底部 Tab 栏

  @Builder
  tabBar() {
    Row() {
      ForEach(TAB_LIST, (tab: TabMeta, index: number) => {
        Column({ space: 3 }) {
          Text(tab.icon).fontSize(17)
          Text(tab.label).fontSize(9)
            .fontColor(this.currentTab === index ? COLORS.tabOn : COLORS.text3)
        }.justifyContent(FlexAlign.Center)
        .layoutWeight(1)
        .padding({ top: 7, bottom: 7 })
        .onClick(() => { this.currentTab = index; })
      }, (tab: TabMeta) => tab.label)
    }.width('100%')
    .backgroundColor(COLORS.card)
    .border({ width: { top: 1 }, color: COLORS.line })
  }

底部 Tab 栏采用自绘单排布局,通过 ForEach 遍历 TAB_LIST 生成 6 个导航项。每个项是 Column(图标 17px + 标签 9px),layoutWeight(1) 等分宽度,justifyContent(FlexAlign.Center) 居中对齐。选中态标签用 tabOn(朱砂)着色,未选中态用 text3(浅驼)弱化。点击更新 currentTab 状态触发内容区切换。整栏以 card(纯白)为底色,顶部 1px 米灰边框与内容区分隔。这种自绘方式比系统 Tabs 组件更灵活,可精确控制图标与标签的字号、间距与配色。

十七、弹框系统

17.1 全屏遮罩容器

  @Builder
  modalOverlay(onClose: () => void) {
    Column() {
      Column().width('100%').layoutWeight(1)
        .onClick(() => { onClose(); })
      if (this.addModal) {
        this.panelAdd(onClose)
      } else if (this.editModal) {
        this.panelEdit(onClose)
      } else if (this.delModal) {
        this.panelDel(onClose)
      }
    }.width('100%').height('100%').backgroundColor(COLORS.mask)
    .justifyContent(FlexAlign.End)
  }

弹窗系统以 modalOverlay 为统一容器,采用"空白遮罩 + 底部面板"的布局模式。上半部分是 layoutWeight(1) 的空白遮罩区,点击触发 onClose 回调关闭弹窗;下半部分根据三个布尔标志三选一渲染对应面板。整容器以墨褐半透 mask 色为背景,justifyContent(FlexAlign.End) 使面板贴底弹出。当 addModaleditModaldelModal 任一为 true 时,build 方法的 Stack 顶层条件渲染此遮罩。

17.2 三态面板

三个面板(panelAddpanelEditpanelDel)结构相似:顶部标题、中部说明文案或 TextInput 输入框、底部取消与确认双按钮。panelAddTextInput 使用 placeholder 模式,onChange 更新 inputText 状态;panelEditTextInput 使用 text 参数带出当前用途描述。三个面板的顶部圆角(borderRadius({ topLeft: 16, topRight: 16 }))与纯白底色形成"从底部滑出的卡片"视觉效果。确认按钮调用 confirmAdd/confirmEdit/confirmDel 执行业务逻辑后统一调用 closeAllModals 复位所有状态。

17.3 弹窗操作方法

  openAdd() {
    this.inputText = '';
    this.addModal = true;
  }

  openEdit(idx: number) {
    this.editIdx = idx;
    this.inputText = idx >= 0 && idx < this.patternList.length ? this.patternList[idx].uses : '';
    this.editModal = true;
  }

  openDel(idx: number) {
    this.delIdx = idx;
    this.delModal = true;
  }

  closeAllModals() {
    this.addModal = false;
    this.editModal = false;
    this.delModal = false;
    this.editIdx = -1;
    this.delIdx = -1;
    this.inputText = '';
  }

  confirmAdd() {
    if (this.inputText.trim() !== '') {
      const dynasty = this.activeDynasty === '全部' ? '唐' : this.activeDynasty;
      this.patternList.unshift(new PatternItem(this.inputText.trim(), dynasty, '回纹',
        '新建收藏纹样 · 待匠人补全用途与适配说明', 82));
    }
    this.closeAllModals();
  }

  confirmEdit() {
    if (this.editIdx >= 0 && this.editIdx < this.patternList.length && this.inputText.trim() !== '') {
      this.patternList[this.editIdx].uses = this.inputText.trim();
    }
    this.closeAllModals();
  }

  confirmDel() {
    if (this.delIdx >= 0 && this.delIdx < this.patternList.length) {
      this.patternList.splice(this.delIdx, 1);
    }
    this.closeAllModals();
  }

弹窗操作方法分为打开、关闭、确认三组。openAdd 清空输入框后置 addModaltrueopenEdit 带出当前用途描述到输入框;openDel 记录删除索引。closeAllModals 统一复位所有布尔标志、索引与输入文本。confirmAddunshift 将新纹样置顶到素材列表(朝代跟随当前筛选,品类默认回纹,适配度 82);confirmEdit 修改指定条目的用途描述(空输入不覆盖);confirmDelsplice 删除指定条目。三个确认方法均在执行后调用 closeAllModals 关闭弹窗,保证状态一致性。

十八、功能模块对比表

Tab 模块布局结构核心特性数据模型交互亮点关键技术点
素材馆朝代横滚 chips + 双列卡片 + Canvas 环形图 + 月度柱状图Canvas 绘制(特性 A)PatternItem朝代筛选 + 新建/编辑/删除弹窗drawPie 中心镂空 + breath 呼吸重绘
频道外层朝代 Tabs × 内层纹样 Tabs 嵌套Tabs 嵌套滚动(特性 B)InnerCard模式切换 SELF_FIRST/SELF_ONLYnestedScroll 挂载内层 + onChange 记日志
日志翻页事件时间轴(固定行高 72)嵌套滚动证据看板SwipeLog双色层徽标 + 清空日志unshift 倒序 + 竖线 layoutWeight 填满
工坊参数卡 + 纹理五选一 + 预览区 + 生成按钮ImageKit 编码(特性 C-1)TextureItem像素画预览 + 沙箱落盘路径pixelColor 逐像素织纹 + packToData 编码
元数据读取快照 + 写入控制台 + 回读校验 + 操作日志ImageKit 元数据(特性 C-2/3/4)WebpMetaSnapshot + MetaOpLog五字段读写回读闭环readImageMetadataByType + writeImageMetadata
我的渐变会员卡 + 任务清单 + 版本信息无(纯展示)MineTask三列战绩 + 完成态徽标linearGradient 渐变 + statBig 复用

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

布局方式与数据流

非遗文博页面既要体现文化内容,也要维护年代、类别、来源与处理记录。素材馆或首页负责概览,地图和搜索提供空间探索,网页与下载承接外部资料,工坊和元数据页面支持数字化加工。逐段分析应关注朝代筛选、等级配色、双列卡片、地图事件和元数据日志之间的数据关系,避免只解释组件属性而忽略文化资产的流转。

页面根结构通常由头部、内容区和底部 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 各自独立的布局结构,再到三大前沿特性的深度融合,整个平台展现了 ArkUI 声明式范式在复杂业务场景下的表现力。

Canvas 绘制能力 的核心价值在于"自绘自由度"。环形图的中心镂空、扇区百分比标注、呼吸微动动画,这些都是系统组件难以直接实现的效果。通过 CanvasRenderingContext2D 获取 2D 上下文后,开发者可以像操作 HTML5 Canvas 一样使用 arcfillfillTextglobalAlpha 等标准 API。需要注意的是 Canvas 不随 @State 自动重绘——setInterval 翻转 breath 后必须手动调用 drawPie() 驱动重绘,这是 Canvas 与声明式 UI 的关键差异。globalAlpha 的"用后复位"模式(设为 0.16 画描边后立即恢复 1)是避免透明度泄漏的最佳实践。

Tabs 嵌套滚动能力 的核心价值在于"边缘联动"。传统嵌套 Tabs 的痛点是内层滑到边缘后无法自然过渡到外层,用户必须精确点击外层页签才能切换频道。nestedScroll(TabsNestedScrollMode) 用一行代码解决了这个问题——SELF_FIRST 模式下内层滑到边缘继续滑动即"接力"触发外层切换,SELF_ONLY 模式下内层独立不联动。两种模式可实时切换,配合 onChange 回调将每次翻页记录到 SwipeLog 日志数组,在日志 Tab 以固定行高时间轴可视化展示。这种"操作产生证据、证据可回溯"的设计思路值得在所有交互复杂的场景中借鉴。

Image Kit WebP 元数据能力 的核心价值在于"沙箱闭环"。从像素画逐像素织纹到 createPixelMap 创建位图,从 packToData 编码 WebP 到 fileIo.openSync 落盘 filesDir,从 readImageMetadataByType 读取五字段快照到 writeImageMetadata 写回帧延迟与循环次数,最后重建 ImageSource 二次读取比对——整个链路在应用沙箱内完成,零权限要求。五个字段(canvasWidth/canvasHeight/delayTime/unclampedDelayTime/loopCount)全部可选,读取后必须 ?? -1 兜底,禁止把 undefined0 处理——这是 ImageKit API 的核心约束。回读校验通过比对 delayTimeloopCount 是否与写入值一致判断是否生效,绿色描边高亮校验卡使用户能直观区分读取快照与回读值。

展望未来,非遗纹样平台可在以下方向深化:其一,引入分布式数据同步,让多端守艺人协同编辑纹样元数据,利用 HarmonyOS 的分布式能力实现跨设备实时协作;其二,将 Canvas 自绘扩展为纹样矢量编辑器,支持贝塞尔曲线绘制与路径导出 SVG,使平台从"素材展示"升级为"创作工具";其三,引入 AI 辅助纹样生成,基于已有纹样的像素算法模式训练生成模型,自动产出新纹样候选方案;其四,将 WebP 元数据链路扩展为动图帧编辑器,支持逐帧预览与帧延迟可视化调整,使非遗纹样的动态展示更加精细。HarmonyOS ArkUI 框架的声明式范式与系统能力开放性,为这些进阶方向提供了坚实的技术基座。

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

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


一、创建新项目

1.1 进入欢迎界面

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

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

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

在这里插入图片描述

1.2 选择项目模板

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

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

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

在这里插入图片描述

1.3 配置项目信息

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

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

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

在这里插入图片描述

1.4 完成创建

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

  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、测试、元服务和应用上架分发等。

更多推荐