以宣纸朱砂的非遗美学重构 HarmonyOS ArkUI 非遗纹样文化平台
一、技术前言
在非物质文化遗产数字化保护日益受到重视的今天,传统纹样的采集、整理与再创作正在从纸质档案走向数字平台。从唐草卷纹的织锦披帛底纹到宋代缠枝的汝窑描银,从万字回纹锦的霞帔坠錾刻到冰梅青花的盖碗釉下彩,每一件非遗纹样都承载着特定朝代的审美范式与工艺密码。然而,传统纹样管理平台往往面临三大困境:纹样品类的占比统计缺乏直观可视化手段、嵌套分类浏览的滑动体验割裂断裂、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 为根组件,采用 Stack 容器实现页面层叠:底层是 Column 纵向布局的头部 Banner + 内容区 + 底部 Tab 栏,顶层是全屏弹窗遮罩。内容区通过 currentTab 状态索引在 6 个 @Builder 方法间切换,每个 Tab 拥有完全独立的布局结构。三大特性(Canvas 绘制环形图、Tabs 嵌套滚动、ImageKit WebP 元数据)分别挂载在素材馆、频道、工坊/元数据四个 Tab 上,但它们的状态变量统一声明在组件顶层,实现跨 Tab 数据共享。弹窗系统以 modalOverlay 全屏遮罩为容器,按 addModal、editModal、delModal 三个布尔标志三选一渲染底部弹出面板,点击遮罩空白区即可关闭。

三、色彩体系设计
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 的 linearGradient 从 gradA(朱砂)到 bg(宣纸米白)实现从热烈到温润的自然过渡,底部 6 Tab 栏选中态使用 tabOn(朱砂)高亮,未选中态使用 text3(浅驼)弱化。会员大卡的 linearGradient 从 gradA 到 gradB 实现朱砂到深朱砂的层次渐变,鎏金呼吸圆点与苔绿完成徽标在大卡上形成视觉点缀。

四、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_PRESETS 与 LOOP_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 兜底值),0 在 loopCount 语义中表示无限循环。fmtField 是通用格式化器,loopText 和 sizeText 是针对循环次数与像素尺寸的专用格式化器,确保元数据卡片的展示语义准确。
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 保持一致。scoreBadge 与 scoreColor 将适配度评分映射到三档文案(精选/优选/良品)与三色配色(朱砂/鎏金/苔绿),使纹样卡片的品质评级一目了然。
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 函数取五色辅助色板,适配度档位通过 scoreBadge 与 scoreColor 函数取三档文案与配色。编辑和删除操作调用 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 滑到边缘后的行为:当 nestedMode 为 SELF_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 转为 PixelMap(RGBA_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}`));
}
}
readMeta 以 READ_WRITE 模式打开 WebP 文件,创建 ImageSource,调用 readImageMetadataByType([WEBP_METADATA], 0) 按类型读取元数据。第二个参数 0 是帧索引,静态 WebP 取 0。返回的 meta.webPMetadata 是可选字段对象,五个字段全部用 ?? -1 兜底——这是因为 WebPMetadata 的所有字段都是可选的,读取值可能为 undefined,禁止把 undefined 当 0 处理。读取完成后 release() 释放 ImageSource,closeSync 关闭文件,将快照存入 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/unclampedDelayTime 取 writeDelay,loopCount 取 writeLoop。使用 as image.WebPMetadata 类型断言是官方样例推荐的模式。writeImageMetadata 写回后立即调用 verifyRead() 进行回读校验。
verifyRead 重建 ImageSource 二次读取元数据——注意必须重新 createImageSource 而非复用写入时的 source,因为写入后文件内容已变更。回读快照存入 verifySnapshot 状态,通过比对 delayTime 与 loopCount 是否与写入值一致判断是否生效,结果记录到日志。错误码 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),下方小字标注"馆藏纹样件"。
半径 r 随 breath 在 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) 使面板贴底弹出。当 addModal、editModal、delModal 任一为 true 时,build 方法的 Stack 顶层条件渲染此遮罩。
17.2 三态面板
三个面板(panelAdd、panelEdit、panelDel)结构相似:顶部标题、中部说明文案或 TextInput 输入框、底部取消与确认双按钮。panelAdd 的 TextInput 使用 placeholder 模式,onChange 更新 inputText 状态;panelEdit 的 TextInput 使用 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 清空输入框后置 addModal 为 true;openEdit 带出当前用途描述到输入框;openDel 记录删除索引。closeAllModals 统一复位所有布尔标志、索引与输入文本。confirmAdd 以 unshift 将新纹样置顶到素材列表(朝代跟随当前筛选,品类默认回纹,适配度 82);confirmEdit 修改指定条目的用途描述(空输入不覆盖);confirmDel 以 splice 删除指定条目。三个确认方法均在执行后调用 closeAllModals 关闭弹窗,保证状态一致性。
十八、功能模块对比表
| Tab 模块 | 布局结构 | 核心特性 | 数据模型 | 交互亮点 | 关键技术点 |
|---|---|---|---|---|---|
| 素材馆 | 朝代横滚 chips + 双列卡片 + Canvas 环形图 + 月度柱状图 | Canvas 绘制(特性 A) | PatternItem | 朝代筛选 + 新建/编辑/删除弹窗 | drawPie 中心镂空 + breath 呼吸重绘 |
| 频道 | 外层朝代 Tabs × 内层纹样 Tabs 嵌套 | Tabs 嵌套滚动(特性 B) | InnerCard | 模式切换 SELF_FIRST/SELF_ONLY | nestedScroll 挂载内层 + 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 一样使用 arc、fill、fillText、globalAlpha 等标准 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 兜底,禁止把 undefined 当 0 处理——这是 ImageKit API 的核心约束。回读校验通过比对 delayTime 与 loopCount 是否与写入值一致判断是否生效,绿色描边高亮校验卡使用户能直观区分读取快照与回读值。
展望未来,非遗纹样平台可在以下方向深化:其一,引入分布式数据同步,让多端守艺人协同编辑纹样元数据,利用 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 将自动执行以下操作:
- 生成项目骨架(Stage 模型目录结构)
- 执行
ohpm install安装依赖 - 运行 Hvigor 构建初始化(
Build Init)
构建日志中显示 “退出代码为 0” 表示项目初始化成功。

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

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

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

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

所有评论(0)