一、技术前言

在这里插入图片描述

在文博文旅领域,文化遗产的数字化探索正成为连接古今的重要纽带。从大雁塔的楼阁式砖塔到大明宫遗址的宫殿基址,从碑林博物馆的石刻经卷到兴教寺塔的玄奘遗骨,每一处古迹都承载着厚重的历史信息,需要精确的地理定位、丰富的检索能力以及可靠的资料溯源体系。传统文旅应用往往面临三大瓶颈:地图标注与实地探访数据脱节导致导览割裂、下载资源的来源不可追溯导致学术引用困难、图片元数据的读写缺乏沙箱闭环导致技术验证困难。

在这里插入图片描述
HarmonyOS ArkUI 框架以其声明式 UI 范式为文博文旅场景提供了系统级的解决方案。ArkUI 基于 TypeScript 扩展的 ArkTS 语言,通过 @Component 装饰器封装可复用组件,通过 @State、@Observed 等状态管理装饰器实现数据驱动渲染,通过 @Builder 方法将复杂的 UI 结构拆分为可组合的构建块。在文化遗产探索地图这类应用中,数据层(古迹名录、POI 搜索结果、下载记录)与视图层(地图标注、百科浏览、元数据可视化)之间存在大量双向联动需求,ArkUI 的状态驱动模型天然适配这种"数据变更即视图刷新"的交互模式。

在这里插入图片描述
本应用深度融合了 HarmonyOS 6.1.1 的三大前沿特性。Map Kit 提供了 Marker 群管理与双长按事件监听能力——通过 MapComponent 组件渲染交互式地图,在 mapCallback 异步回调中批量 addMarker 添加六处长安古迹标注,再通过 onMarkerLongClick 与 onPoiLongClick 两个 6.1.1 新增事件监听器实现标注点与底图兴趣点的长按交互记录,配合 reliability 相关性分数从 Map Kit 搜索接口 searchByText 返回,形成"地图标注-长按记录-POI 搜索"的完整地理交互链路。ArkWeb 实现了下载双 URL 溯源链路——通过 WebDownloadDelegate 的四回调(onBeforeDownload/onDownloadUpdated/onDownloadFailed/onDownloadFinish)管控下载生命周期,在 onDownloadFinish 中通过 6.1.1 新增的 getOriginalUrl() 与 getReferrerUrl() 双方法读取原始下载地址与引用页地址,实现学术资源下载来源的双向溯源。ImageKit 提供了 WebP 元数据类型化读写闭环——通过 readImageMetadataByType 读取 WEBP_METADATA 五字段快照(画布尺寸、钳制/未钳制帧延迟、循环次数),通过 writeImageMetadata 以字面量构造 WebPMetadata 写回,写回后立即重建 ImageSource 回读校验,形成"像素画生成→WebP 编码落盘→元数据读取→元数据写回→回读校验"的完整沙箱验证链路。

在这里插入图片描述
三大特性在同一种"深色文博"设计语言下叠加呈现:Map Kit 负责地理空间表达,ArkWeb 负责资料溯源与获取,ImageKit 负责图像元数据的技术验证。这种三特性同文件叠加的架构设计,使应用在单页面内即可完成从"找古迹→查资料→下载文献→验证图片"的全链路操作,为文博从业者提供了完整的数字化探索工具链。

在这里插入图片描述

二、整体架构流程图

Page1208 主组件

headerMain 头部品牌行

内容区 7 Tab 切换

tabBar 底部导航

modalOverlay 弹窗遮罩

Tab0 首页
双列古迹卡+月度柱状图

Tab1 地图
MapComponent+双长按日志流

Tab2 搜索
POI相关性分数列表

Tab3 百科
地址栏+Web组件+主动下载

Tab4 下载
进度任务+双URL溯源列表

Tab5 工坊
WebP元数据四区块

Tab6 我的
渐变大卡+足迹清单

Map Kit
Marker群+双长按监听

ArkWeb
下载双URL溯源

ImageKit
WebP元数据读写闭环

panelAdd 收录新古迹

panelEdit 修订古迹档案

panelDel 移出名录确认

整体架构以 Page1208 为根组件,采用 Column 纵向布局承载三个层级:顶部 headerMain 品牌头行展示应用名称与收录计数,中部内容区通过 currentTab 状态索引在七个 @Builder 方法间切换,底部 tabBar 提供七个 Tab 的导航入口。内容区的布局策略具有差异化特征——地图 Tab 与百科 Tab 因需要全高度渲染 MapComponent 与 Web 组件,直接挂载不套 Scroll 容器;其余五个 Tab 统一走 Scroll 分支,内部以 Column 纵向排列各自的内容卡片。三个弹窗面板(新增、编辑、删除)通过 Stack 层叠在内容之上,各自携带半透明遮罩层实现点击空白关闭。

在这里插入图片描述
三大特性分别挂载在不同 Tab 上:Map Kit 的 Marker 群与双长按监听在地图 Tab 完成地理交互;ArkWeb 的下载代理与双 URL 溯源在百科 Tab 与下载 Tab 协同工作——百科 Tab 触发下载、下载 Tab 展示溯源记录;ImageKit 的 WebP 元数据读写闭环独立运行在工坊 Tab。但所有特性的状态变量统一声明在组件顶层,实现了跨 Tab 数据共享,例如下载记录在百科 Tab 触发后可直接在下载 Tab 查看,地图事件日志在地图 Tab 产生后其数量也会反映在首页统计中。

在这里插入图片描述

三、色彩体系设计

3.1 ColorPalette 接口定义

应用采用深色文博主题,背景取深褐墨色,卡片取深木色,主色取青铜器锈绿,点缀鎏金与朱砂红,通过 ColorPalette 接口集中声明全部颜色字段:

interface ColorPalette {
  bg: string;      // 页面背景(深褐)
  card: string;    // 卡片底色(深木)
  title: string;   // 标题(宣纸暖白)
  sub: string;     // 副文(绢帛黄)
  text3: string;   // 三级弱文(陶土灰)
  bronze: string;  // 青铜绿主色
  bronzeD: string; // 青铜深色
  gold: string;    // 鎏金
  red: string;     // 朱砂红(国保徽章/删除)
  blue: string;    // 青蓝
  line: string;    // 分割线
  tabOn: string;   // Tab 选中色
  mask: string;    // 弹窗遮罩
}

3.2 COLORS 常量逐色分析

const COLORS: ColorPalette = {
  bg: '#1B150F',      // 深褐墨色,模拟考古土层断面的沉稳底色
  card: '#282017',    // 深木色卡片,比背景略亮一档,营造展柜感
  dark: '#332A1E',    // 次级容器底色,用于徽章背景与进度条底
  title: '#F5EDDE',   // 宣纸暖白,模拟古籍用纸的温润色泽
  sub: '#C8B99C',     // 绢帛黄,副标题层次柔和过渡
  text3: '#8F8266',   // 陶土灰,三级弱文本不抢视觉焦点
  bronze: '#5E9C8B',  // 青铜绿主色,取自青铜器锈蚀后的氧化色泽
  bronzeD: '#46786A', // 青铜深色,渐变起点与深色按钮底色
  gold: '#D9A441',    // 鎏金,点缀色,用于统计数值与选中高亮
  red: '#C0392B',     // 朱砂红,全国重点文物保护单位徽章与删除操作
  blue: '#6B8FBF',   // 青蓝,POI 命中标识与信息链接
  line: '#3A3122',    // 分割线,低对比度不干扰内容
  tabOn: '#D9A441',   // Tab 选中色与鎏金一致
  mask: 'rgba(0,0,0,0.6)' // 半透黑遮罩,弹窗聚焦时压暗背景
};

色彩设计遵循"文博意象"原则:每一组色彩都对应一种实体文物材质——深褐背景模拟考古土层,宣纸暖白模拟古籍用纸,青铜绿取自铜器锈蚀,鎏金点缀模拟金箔贴片,朱砂红对应文保徽章。这种从实物到色彩的映射使用户在深色环境下凭颜色即可感知文博氛围。我的 Tab 的渐变大卡使用 linearGradient 从 bronzeD(青铜深色)到 dark(次级底色)实现青铜器质感的自然过渡,底部 Tab 栏选中态使用 gold(鎏金)高亮,未选中态使用 text3(陶土灰)弱化,形成"金灰对比"的导航视觉语言。

四、Tab 元数据与辅助数据

4.1 底部导航 Tab 定义

应用底部导航采用 7 Tab 单排布局,通过 TabMeta 接口定义每个 Tab 的图标与标签:

interface TabMeta {
  icon: string;
  label: string;
}

const TAB_LIST: TabMeta[] = [
  { icon: '🏛️', label: '首页' },
  { icon: '🗺️', label: '地图' },
  { icon: '🔍', label: '搜索' },
  { icon: '🌐', label: '百科' },
  { icon: '📥', label: '下载' },
  { icon: '🧪', label: '工坊' },
  { icon: '👤', label: '我的' }
];

七个 Tab 的命名均围绕"文化遗产探索"的核心场景展开:首页提供古迹名录总览与探访热度统计,地图提供地理空间定位与长按交互,搜索提供 POI 关键字检索与相关性分数展示,百科提供文博站点浏览与资源下载入口,下载提供下载进度追踪与双 URL 溯源记录,工坊提供 WebP 元数据读写沙箱验证,我的提供探访者等级与足迹清单。这种 Tab 划分方式覆盖了文博探索的完整流程:发现(首页/地图)→检索(搜索)→查阅(百科)→获取(下载)→验证(工坊)→回顾(我的)。

4.2 城市中心点与地图标注数据

地图以西安钟楼一带为地理中心,定位与搜索基准坐标通过 CITY_CENTER 常量定义:

const CITY_CENTER: mapCommon.LatLng = { latitude: 34.3416, longitude: 108.9398 };

地图标注点选取六处长安古迹作为 Marker 群,通过 SpotItem 接口定义标注点的名称、经纬度与朝代标签:

interface SpotItem {
  name: string;
  lat: number;
  lng: number;
  tag: string;
}

const MARKER_SPOTS: SpotItem[] = [
  { name: '大雁塔', lat: 34.2185, lng: 108.9640, tag: '唐 · 楼阁式砖塔' },
  { name: '小雁塔', lat: 34.2411, lng: 108.9434, tag: '唐 · 密檐式砖塔' },
  { name: '西安城墙', lat: 34.2569, lng: 108.9424, tag: '明 · 城垣遗存' },
  { name: '碑林博物馆', lat: 34.2555, lng: 108.9313, tag: '唐 · 石刻碑林' },
  { name: '大明宫遗址', lat: 34.2763, lng: 108.9420, tag: '唐 · 宫殿遗址' },
  { name: '兴教寺塔', lat: 34.1166, lng: 108.9570, tag: '唐 · 玄奘墓塔' }
];

六处古迹覆盖了唐、明两个主要朝代,包含楼阁式砖塔、密檐式砖塔、城垣遗存、石刻碑林、宫殿遗址、墓塔等多种遗存类型,为地图交互提供了丰富的标注样本。这些标注数据在地图 Tab 中通过 addMarker 批量添加到 MapComponent 上,同时在我的 Tab 中作为"足迹清单"展示,实现数据的多场景复用。

4.3 百科快捷站点与下载资源

百科 Tab 提供四个文博类真实站点作为快捷入口,方便用户一键访问国家文物局、故宫博物院、陕西历史博物馆、中国考古网等权威文博平台:

const QUICK_SITES: string[] = [
  'https://www.ncha.gov.cn',
  'https://www.dpm.org.cn',
  'https://www.sxhm.com',
  'https://www.kaogu.cn'
];

同时定义了一条用于应用侧主动触发下载的示范资源 URL,指向国家文物局的古建筑图集 PDF 文件:

const RESOURCE_DL_URL: string = 'https://www.ncha.gov.cn/upload/file/gujianzhu-atlas-2026.pdf?zone=north';

4.4 WebP 编码参数与纹理预设

工坊 Tab 中的 WebP 编码使用一组预设参数控制输出质量与画布尺寸:

const WEBP_QUALITY: number = 90;                  // 编码质量 0~100
const CANVAS_SIZE: number = 96;                   // 样图画布边长(px)
const DELAY_PRESETS: number[] = [120, 200, 500];  // 帧延迟三档预设(ms)
const LOOP_PRESETS: number[] = [0, 1, 3, 5];      // 循环次数预设(0=不限)

编码质量设为 90 以平衡文件体积与图像保真度,画布边长 96 像素适合作为老照片纹理样图的小尺寸预览。帧延迟三档预设(120ms/200ms/500ms)均在 WebP 规范要求的 [100, 65535] 区间内,循环次数四档预设(0 不限/1 次/3 次/5 次)覆盖了从无限循环到有限播放的常见场景。

老照片纹理提供五种风格选择,通过 TextureItem 接口定义纹理的键名、中文名与技术原理描述:

interface TextureItem {
  key: string;
  label: string;
  note: string;
}

const TEXTURE_LIST: TextureItem[] = [
  { key: 'mottle', label: '斑驳', note: '对角斜纹' },
  { key: 'carve', label: '雕花', note: '棋盘格' },
  { key: 'rammed', label: '夯土', note: '横带层理' },
  { key: 'stele', label: '碑刻', note: '竖带刻痕' },
  { key: 'ring', label: '年轮', note: '同心环' }
];

五种纹理分别模拟不同的老照片质感:斑驳对应对角斜纹的磨损痕迹,雕花对应棋盘格的纹饰布局,夯土对应横带层理的土层断面,碑刻对应竖带刻痕的石碑纹理,年轮对应同心环的树木横截面。这些纹理在 pickTextureColor 函数中通过不同的坐标计算算法生成。

4.5 柱状图数据与弹窗选项

首页柱状图展示近 6 个月的探访热度数据,月名与探访量通过两个平行数组定义:

const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_NAME: string[] = ['03月', '04月', '05月', '06月', '07月', '08月'];
const VISIT_VAL: number[] = [126, 98, 154, 183, 216, 172];

探访量数据呈现先升后降的趋势,从 3 月的 126 次逐步攀升至 7 月的 216 次峰值,8 月略有回落至 172 次,反映了春夏季节文博探访活跃度的自然变化。

弹窗中的保护等级提供三档标准选项,对应我国文物保护的法定分级体系:

const LEVEL_OPTIONS: string[] = ['全国重点文物保护单位', '省级文物保护单位', '市县级文物保护单位'];

4.6 我的 Tab 说明行

我的 Tab 底部的权限与说明区域通过 AboutRow 接口声明四行信息,明确标注三大特性所需的权限与版本信息:

interface AboutRow {
  icon: string;
  title: string;
  note: string;
}

const ABOUT_ROWS: AboutRow[] = [
  { icon: '🗺️', title: '地图数据', note: 'Map Kit · 需 INTERNET 权限与签名' },
  { icon: '🌐', title: '百科与下载', note: 'ArkWeb · 双 URL 溯源 since 24' },
  { icon: '🧪', title: '工坊沙箱', note: 'Image Kit · WebP 元数据读写' },
  { icon: '📌', title: '当前版本', note: 'HarmonyOS 6.1.1 · API 24' }
];

四行说明分别声明了 Map Kit 的网络权限与签名要求、ArkWeb 的双 URL 溯源特性来源(since 24 即 API 24 引入)、ImageKit 的沙箱元数据读写能力,以及当前运行环境版本。

五、工具函数

5.1 相关性分数映射函数

Map Kit 搜索接口返回的 reliability 字段是一个 [0, 1] 区间的浮点数,reliabilityScore 函数将其映射为三档等级标签与对应颜色:

interface ScoreInfo {
  label: string;
  color: string;
}

function reliabilityScore(v: number): ScoreInfo {
  if (v >= 0.8) {
    return { label: '高相关', color: COLORS.gold };
  }
  if (v >= 0.5) {
    return { label: '中相关', color: COLORS.bronze };
  }
  return { label: '低相关', color: COLORS.text3 };
}

分数大于等于 0.8 标记为"高相关"并使用鎏金色高亮,0.5 至 0.8 之间标记为"中相关"并使用青铜绿,低于 0.5 标记为"低相关"并使用陶土灰弱化。这种三档分级使用户在浏览搜索结果时能够快速识别哪些 POI 与查询关键字最匹配。

5.2 保护等级颜色映射函数

levelColor 函数将文物保护等级映射为对应的徽章颜色,实现"国保朱砂、省保青铜、市县陶土"的视觉分级:

function levelColor(level: string): string {
  if (level === '全国重点文物保护单位') {
    return COLORS.red;
  }
  if (level === '省级文物保护单位') {
    return COLORS.bronze;
  }
  return COLORS.text3;
}

全国重点文物保护单位使用朱砂红(COLORS.red),这与国保徽章在现实中使用的红色系视觉语言一致;省级文物保护单位使用青铜绿(COLORS.bronze),呼应青铜器的沉稳质感;市县级文物保护单位使用陶土灰(COLORS.text3),以低对比度弱化展示。

5.3 十六进制颜色转 RGBA 缓冲值

hexToRgba 函数将 #RRGGBB 格式的颜色字符串转换为 RGBA_8888 像素缓冲区所需的整数值。由于 RGBA_8888 缓冲区按字节序排列为 R、G、B、A,Alpha 通道固定为 255(不透明),因此转换结果为 0xFFBBGGRR:

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

这个函数是工坊 Tab 中像素画生成的核心辅助——五种纹理的每个像素颜色都通过此函数从 COLORS 常量转换为可写入 Uint32Array 缓冲区的整数值。

5.4 元数据字段格式化函数

fmtField 函数处理 WebP 元数据中 undefined 字段的显示问题。由于 readImageMetadataByType 读取静态 WebP 时帧延迟与循环次数可能为 undefined,应用统一用 -1 作为占位值,渲染时通过此函数转换为"未提供"文本:

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

5.5 字节数格式化函数

formatSize 函数将下载文件的字节数转换为人类可读的体积文本,自动在 B/KB/MB 之间切换:

function formatSize(bytes: number): string {
  if (bytes >= 1048576) {
    return `${(bytes / 1048576).toFixed(1)}MB`;
  }
  if (bytes >= 1024) {
    return `${(bytes / 1024).toFixed(1)}KB`;
  }
  return `${bytes}B`;
}

5.6 URL 域名提取与时间格式化函数

hostOf 函数从完整 URL 中提取域名部分,用于百科 Tab 快捷站点标签的简短展示:

function hostOf(url: string): string {
  const rest: string = url.replace('https://', '').replace('http://', '');
  return rest.split('/')[0];
}

nowTime 函数返回当前时间的 HH:mm:ss 格式字符串,被事件日志、下载记录与操作日志三类实体共用:

function nowTime(): string {
  const d: Date = new Date();
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
}

六、数据模型层

6.1 HeritageItem 古迹名录实体

HeritageItem 是应用的核心业务实体,通过 @Observed 装饰器声明为可观察对象,包含名称、朝代、保护等级、区域与文保分五个字段:

@Observed export class HeritageItem {
  name: string;
  dynasty: string;
  level: string;
  region: string;
  score: number;

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

@Observed 装饰器使该类的实例属性变更能够被 ArkUI 框架感知并触发视图刷新。当用户通过编辑面板修改某个古迹的朝代或保护等级时,首页双列卡片中的对应字段会立即更新。初始 Mock 数据 HERITAGE_LIST 包含 8 处古迹,覆盖唐、西汉、明、清四个朝代以及三级保护等级,文保分从 61 到 98 不等,为首页的瀑布流布局提供了丰富的展示样本。

6.2 SearchRecord POI 搜索结果实体

SearchRecord 封装 Map Kit searchByText 接口返回的 POI 搜索结果,包含名称、地址、直线距离与相关性分数四个字段:

@Observed export class SearchRecord {
  name: string;
  address: string;
  distance: number;
  reliability: number;

  constructor(name: string, address: string, distance: number, reliability: number) {
    this.name = name;
    this.address = address;
    this.distance = distance;
    this.reliability = reliability;
  }
}

初始 Mock 数据 SEARCH_LIST 包含 7 条搜索结果,reliability 值从 0.97(大雁塔,高度匹配)到 0.18(曲江池遗址公园,低度匹配),完整覆盖了三档相关性分级,使搜索 Tab 在无网络环境下也能展示完整的 UI 链路。

6.3 EventLog 长按事件日志实体

EventLog 记录地图上 Marker 长按与 POI 长按产生的事件,包含类型、名称、经纬度与时间四个字段:

@Observed export class EventLog {
  type: string;
  name: string;
  lat: number;
  lng: number;
  time: string;

  constructor(type: string, name: string, lat: number, lng: number) {
    this.type = type;
    this.name = name;
    this.lat = lat;
    this.lng = lng;
    this.time = nowTime();
  }
}

type 字段有三个取值:'Marker' 表示标注点长按事件,'POI' 表示底图兴趣点长按事件,'系统' 表示监听开关的开启/关闭操作。每条日志在构造时自动调用 nowTime() 记录当前时间,并通过 unshift 方法插入到日志列表头部,实现最新事件优先展示。

6.4 DownloadRecord 下载记录实体

DownloadRecord 是 ArkWeb 下载双 URL 溯源特性的核心数据载体,除了常规的文件名、字节数与完成时间外,还包含 6.1.1 新增的原始 URL 与引用页 URL 两个溯源字段:

@Observed export class DownloadRecord {
  fileName: string;
  fileSize: number;
  finishTime: string;
  originalUrl: string;
  referrerUrl: string;

  constructor(fileName: string, fileSize: number, finishTime: string, originalUrl: string, referrerUrl: string) {
    this.fileName = fileName;
    this.fileSize = fileSize;
    this.finishTime = finishTime;
    this.originalUrl = originalUrl;
    this.referrerUrl = referrerUrl;
  }
}

初始 Mock 数据 DOWNLOAD_LIST 包含 5 条下载记录,每条都携带完整的双 URL 信息——originalUrl 指向资源的直接下载地址,referrerUrl 指向触发下载的引用页面地址。例如第一条记录的原始 URL 是国家文物局的 PDF 直链,引用页 URL 是该网站的栏目页面,这种双溯源机制使学术引用可以精确标注资源的获取路径。

6.5 WebpMetaSnapshot 元数据快照实体

WebpMetaSnapshot 封装 WebP 元数据的五字段快照,用于读取结果与回读校验结果的统一存储:

@Observed export class WebpMetaSnapshot {
  canvasWidth: number;        // 画布宽(px),-1=未提供
  canvasHeight: number;       // 画布高(px),-1=未提供
  delayTime: number;          // 钳制后帧延迟(ms),-1=未提供
  unclampedDelayTime: number; // 未钳制帧延迟(ms),-1=未提供
  loopCount: number;          // 循环次数,-1=未提供(0=不限)

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

五个字段中,canvasWidth 与 canvasHeight 描述画布尺寸,delayTime 是经系统钳制后的帧延迟(确保落在 [100, 65535] 区间内),unclampedDelayTime 是未经钳制的原始帧延迟值,loopCount 是循环次数(0 表示无限循环)。所有字段使用 -1 作为 undefined 的占位值,在渲染时通过 fmtField 函数转换为"未提供"文本。

6.6 MetaOpLog 元数据操作日志实体

MetaOpLog 记录工坊 Tab 中元数据操作的日志,包含操作类型、详情与时间:

@Observed export class MetaOpLog {
  op: string;
  detail: string;
  time: string;

  constructor(op: string, detail: string) {
    this.op = op;
    this.detail = detail;
    this.time = nowTime();
  }
}

操作类型包括"生成样图"“读取元数据”“写入元数据”"回读校验"四类,完整记录了 WebP 元数据读写的全链路操作过程,使用户可以追溯每一步操作的执行结果与时间戳。

七、组件主体结构

7.1 状态变量声明

Page1208 组件通过 @Entry 与 @Component 装饰器声明为入口组件,内部管理着大量状态变量,按业务域可分为五组。

Tab 与弹窗状态组管理页面导航与弹窗显隐:

@State currentTab: number = 0;
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = -1;
@State delIdx: number = -1;
@State breath: boolean = false;
timer: number = -1;

currentTab 控制当前展示的 Tab 索引(0-6),三个 Modal 布尔值控制新增/编辑/删除弹窗的显隐,editIdx 与 delIdx 记录当前操作的古迹索引。breath 是一个特殊的呼吸动画状态,通过 setInterval 每秒翻转一次,驱动统计格数值、柱状图柱高、我的 Tab 数值等位置的色彩波动效果。

古迹业务数据与面板输入组管理首页名录的增删改:

@State heritageList: HeritageItem[] = HERITAGE_LIST.slice();
@State inputName: string = '';
@State inputDynasty: string = '';
@State inputLevel: string = '';
@State inputRegion: string = '';

Map Kit 状态组管理地图控制器、事件管理器与长按事件日志:

private mapOptions: mapCommon.MapOptions = { position: { target: CITY_CENTER, zoom: 12 } };
private mapCallback?: AsyncCallback<map.MapComponentController>;
private mapController?: map.MapComponentController;
private mapEventManager?: map.MapEventManager;
@State eventLogs: EventLog[] = [];
@State markerListenOn: boolean = true;
@State poiListenOn: boolean = true;

mapOptions 设定地图初始位置为西安市中心、缩放级别 12。markerListenOn 与 poiListenOn 两个布尔值控制 Marker 长按与 POI 长按监听的开关状态,配合地图 Tab 中的 Toggle 组件实现监听的动态启停。

ArkWeb 状态组管理网页浏览、下载委托与溯源记录:

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

urlInput 与 webUrl 双状态分离——urlInput 绑定地址栏输入框,webUrl 绑定 Web 组件的 src 属性,只有在用户点击"前往"按钮时才将 urlInput 同步到 webUrl,避免输入过程中网页频繁刷新。

WebP 元数据状态组管理样图像素图、沙箱路径与读写快照:

@State pixelMap: image.PixelMap | undefined = undefined;
@State webpPath: string = '';
@State genState: string = '待生成';
@State textureSel: string = 'mottle';
@State metaSnapshot: WebpMetaSnapshot | undefined = undefined;
@State writeDelay: number = 120;
@State writeLoop: number = 3;
@State verifySnapshot: WebpMetaSnapshot | undefined = undefined;
@State opLogs: MetaOpLog[] = [];

metaSnapshot 存储读取结果,verifySnapshot 存储写入后回读的校验结果,两者共用 WebpMetaSnapshot 类型但在 UI 中以不同的背景色区分——读取结果使用普通卡片底色,回读校验结果使用高亮的深色底色。

7.2 生命周期方法

组件在 aboutToAppear 中完成三项初始化工作:启动呼吸动画定时器、装配地图回调、绑定下载代理:

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

呼吸动画每秒翻转一次 breath 布尔值,由于该状态被多个 @Builder 方法引用(统计格数值颜色、柱状图柱高、我的 Tab 数值颜色),每次翻转都会触发这些位置的 UI 刷新,形成周期性的视觉脉动效果。aboutToDisappear 中清理定时器以防止内存泄漏。

八、头部区域详解

头部区域通过 headerMain Builder 方法构建,采用 Row 横向布局,包含品牌图标、标题副标题列、收录徽章与快捷收录按钮四个元素:

@Builder
headerMain() {
  Row({ space: 10 }) {
    Text('🏛️').fontSize(26)
    Column({ space: 2 }) {
      Text('遗迹地图').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Text('文化遗产探索地图 · 长安篇').fontSize(10).fontColor(COLORS.sub)
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    Text(`收录 ${this.heritageList.length}`)
      .fontSize(10).fontColor(COLORS.gold)
      .padding({ left: 8, right: 8, top: 4, bottom: 4 })
      .borderRadius(10).backgroundColor(COLORS.dark)
    Text('+')
      .fontSize(16).fontColor(COLORS.tabOn)
      .width(30).height(30).textAlign(TextAlign.Center)
      .borderRadius(15).backgroundColor(COLORS.bronze)
      .onClick(() => {
        this.clearInputs();
        this.addModal = true;
      })
  }
  .width('100%')
  .padding({ left: 14, right: 14, top: 10, bottom: 10 })
}

品牌图标使用大雁塔 emoji 配合 26px 字号,紧随其后的 Column 容器以左对齐方式承载"遗迹地图"主标题与"文化遗产探索地图 · 长安篇"副标题,通过 layoutWeight(1) 占据中间剩余空间。收录徽章动态显示当前 heritageList 数组的长度,使用鎏金色文字配深色底背景,当用户新增或删除古迹时此数值会实时更新。最右侧的"+"按钮以圆形外观(30x30 尺寸配 15 圆角)呈现,青铜绿底色配鎏金文字,点击时先清空面板输入再打开新增弹窗。

九、首页 Tab 分析

9.1 统计行

首页顶部以三个统计格横向排列展示关键指标,通过 statCell Builder 方法构建可复用的统计单元:

@Builder
statCell(icon: string, value: string, label: string) {
  Column({ space: 4 }) {
    Text(icon).fontSize(16)
    Text(value).fontSize(15).fontWeight(FontWeight.Bold)
      .fontColor(this.breath ? COLORS.gold : COLORS.sub)
    Text(label).fontSize(9).fontColor(COLORS.text3)
  }
  .layoutWeight(1)
  .padding({ top: 10, bottom: 10 })
  .borderRadius(10).backgroundColor(COLORS.card)
  .justifyContent(FlexAlign.Center)
}

每个统计格由图标、数值与标签三层构成。数值的颜色与 breath 状态联动——呼吸为 true 时显示鎏金色,为 false 时显示绢帛黄,形成每秒交替的色彩脉动。三个统计格分别展示"收录古迹"数量(动态来自 heritageList.length)、"地图标注"数量(固定为 6 处 Marker)、“探索里程”(固定值 128.6 公里)。

9.2 古迹双列卡片

首页核心内容区使用 Flex 容器配合 FlexWrap.Wrap 实现双列瀑布流布局,每个古迹通过 heritageCard Builder 方法构建独立的卡片:

@Builder
heritageCard(item: HeritageItem, idx: number) {
  Column({ space: 6 }) {
    Row({ space: 6 }) {
      Text(item.dynasty).fontSize(10).fontColor(COLORS.gold)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .borderRadius(6).backgroundColor(COLORS.dark)
      Text(item.level).fontSize(8).fontColor(levelColor(item.level))
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .borderRadius(6).backgroundColor(COLORS.dark)
        .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        .layoutWeight(1)
    }
    .width('100%')
    Text(item.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
    Text(item.region).fontSize(10).fontColor(COLORS.text3)
      .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
    Row({ space: 6 }) {
      Text(`文保分 ${item.score}`).fontSize(10).fontColor(COLORS.bronze).layoutWeight(1)
      Text('编').fontSize(9).fontColor(COLORS.sub)
        .width(22).height(22).textAlign(TextAlign.Center)
        .borderRadius(11).backgroundColor(COLORS.dark)
        .onClick(() => { this.openEdit(idx); })
      Text('删').fontSize(9).fontColor(COLORS.red)
        .width(22).height(22).textAlign(TextAlign.Center)
        .borderRadius(11).backgroundColor(COLORS.dark)
        .onClick(() => {
          this.delIdx = idx;
          this.delModal = true;
        })
    }
    .width('100%')
  }
  .width('49%')
  .padding(10).borderRadius(12).backgroundColor(COLORS.card)
  .alignItems(HorizontalAlign.Start)
}

每张卡片宽度设为 49%,配合 Flex 的 SpaceBetween 对齐方式实现双列等间距排布。卡片内部自上而下分为四层:朝代与保护等级徽章行(朝代用鎏金色、保护等级用 levelColor 函数动态取色)、古迹名称(宣纸暖白粗体)、所在区域(陶土灰弱文)、底部操作行(文保分加编辑/删除两个圆形按钮)。编辑按钮点击时调用 openEdit 方法回填当前条目数据到编辑面板,删除按钮点击时设置 delIdx 并打开删除确认弹窗。

9.3 月度柱状图

首页底部的柱状图卡片通过 chartCard Builder 方法构建,使用 Column + ForEach 的方式手绘柱状图:

@Builder
chartCard() {
  Column({ space: 8 }) {
    Row() {
      Text('近 6 个月探访热度').fontSize(13).fontWeight(FontWeight.Bold)
        .fontColor(COLORS.title).layoutWeight(1)
      Text('次/月').fontSize(10).fontColor(COLORS.text3)
    }
    .width('100%')
    Row({ space: 10 }) {
      ForEach(MONTH_IDX, (i: number) => {
        Column({ space: 4 }) {
          Text(`${VISIT_VAL[i]}`).fontSize(8).fontColor(COLORS.sub)
          Column()
            .width('100%')
            .height(this.breath ? VISIT_VAL[i] * 0.62 + 8 : VISIT_VAL[i] * 0.62)
            .borderRadius({ topLeft: 5, topRight: 5 })
            .backgroundColor(i % 2 === 0 ? COLORS.bronze : COLORS.bronzeD)
          Text(MONTH_NAME[i]).fontSize(9).fontColor(COLORS.text3)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .justifyContent(FlexAlign.End)
      }, (i: number) => `m_${i}`)
    }
    .height(150).alignItems(VerticalAlign.Bottom).width('100%')
  }
  .width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
  .alignItems(HorizontalAlign.Start)
}

柱状图的每根柱子是一个 Column 组件,高度按探访量乘以 0.62 的比例系数计算。呼吸动画为 true 时柱高额外增加 8 像素,形成每秒一次的柱体"呼吸"效果。偶数索引月份使用青铜绿(COLORS.bronze),奇数索引月份使用青铜深色(COLORS.bronzeD),交替配色增强视觉节奏感。整个图表区域的 Row 容器高度固定为 150 像素,底部对齐使柱子从下向上生长,模拟传统柱状图的视觉规范。

十、地图 Tab 分析

10.1 Map Kit 初始化与 Marker 群装配

地图 Tab 的核心是 MapComponent 组件及其初始化回调。setupMapCallback 方法在 aboutToAppear 中被调用,构建了一个完整的异步初始化链路:

setupMapCallback() {
  this.mapCallback = async (err: BusinessError, mapController: map.MapComponentController) => {
    if (err) {
      console.error(`Map init failed, code: ${err.code}, message: ${err.message}`);
      return;
    }
    this.mapController = mapController;
    const controller: map.MapComponentController = mapController;
    const manager: map.MapEventManager = controller.getEventManager();
    this.mapEventManager = manager;
    for (const spot of MARKER_SPOTS) {
      const markerOptions: mapCommon.MarkerOptions = {
        position: { latitude: spot.lat, longitude: spot.lng },
        clickable: true, visible: true, rotation: 0,
        zIndex: 0, alpha: 1, anchorU: 0.5, anchorV: 1,
        draggable: false, flat: false
      };
      try {
        await controller.addMarker(markerOptions);
      } catch (e) {
        console.error(`addMarker failed: ${(e as BusinessError).message}`);
      }
    }
    manager.onMarkerLongClick((marker: map.Marker) => {
      const pos: mapCommon.LatLng = marker.getPosition();
      this.eventLogs.unshift(new EventLog('Marker', `#${marker.getId()}`, pos.latitude, pos.longitude));
    });
    manager.onPoiLongClick((poi: mapCommon.Poi) => {
      this.eventLogs.unshift(new EventLog('POI', poi.name ?? '未命名POI',
        poi.position.latitude, poi.position.longitude));
    });
  };
}

初始化流程严格遵循"err 判空→controller 获取→eventManager 获取→Marker 群添加→双长按监听注册"的顺序。每处古迹的 MarkerOptions 都显式设置了全部十字段参数(position、clickable、visible、rotation、zIndex、alpha、anchorU、anchorV、draggable、flat),其中 anchorU: 0.5 与 anchorV: 1 将标注锚点设在底部中心,使标注图标如针状插入地图。addMarker 是异步操作,使用 await 逐个添加并包裹 try-catch,确保单个标注添加失败不会中断整体流程。

6.1.1 的两个新特性在 Marker 群添加完成后注册:onMarkerLongClick 监听用户长按标注点事件,回调参数是 map.Marker 对象,通过 getPosition() 与 getId() 获取标注的经纬度与标识;onPoiLongClick 监听用户长按底图兴趣点事件,回调参数是 mapCommon.Poi 对象,仅包含 id、name、position 三个字段。两种事件都通过 unshift 方法将 EventLog 实例插入到日志列表头部,确保最新事件优先展示。

10.2 双长按监听开关

地图 Tab 顶部提供两个 Toggle 开关,分别控制 Marker 长按与 POI 长按监听的启停。toggleMarkerListen 方法实现了监听的动态切换:

toggleMarkerListen() {
  if (!this.mapEventManager) {
    return;
  }
  if (this.markerListenOn) {
    this.mapEventManager.offMarkerLongClick();
    this.eventLogs.unshift(new EventLog('系统', 'Marker 长按监听已关闭', 0, 0));
  } else {
    this.mapEventManager.onMarkerLongClick((marker: map.Marker) => {
      const pos: mapCommon.LatLng = marker.getPosition();
      this.eventLogs.unshift(new EventLog('Marker', `#${marker.getId()}`, pos.latitude, pos.longitude));
    });
    this.eventLogs.unshift(new EventLog('系统', 'Marker 长按监听已开启', 0, 0));
  }
  this.markerListenOn = !this.markerListenOn;
}

关闭监听时调用 offMarkerLongClick()(不传参数表示清除该类型的全部订阅),并插入一条系统事件日志标注操作。重新开启时重新注册回调函数。togglePoiListen 方法采用完全相同的模式处理 POI 长按监听的开关。

10.3 事件日志流展示

地图 Tab 底部的日志流通过 eventLogRow Builder 方法构建单条日志的视觉呈现:

@Builder
eventLogRow(log: EventLog) {
  Row({ space: 6 }) {
    Text(log.type).fontSize(9).fontColor(COLORS.title)
      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      .borderRadius(6)
      .backgroundColor(log.type === 'Marker' ? COLORS.bronze : log.type === 'POI' ? COLORS.gold : COLORS.text3)
    Text(log.name).fontSize(10).fontColor(COLORS.sub).layoutWeight(1)
      .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
    Text(`${log.lat.toFixed(4)}, ${log.lng.toFixed(4)}`)
      .fontSize(9).fontFamily('monospace').fontColor(COLORS.text3)
    Text(log.time).fontSize(9).fontColor(COLORS.text3)
  }
  .width('100%')
}

日志行的类型徽标根据事件类型动态取色——Marker 事件用青铜绿、POI 事件用鎏金色、系统事件用陶土灰。经纬度使用 monospace 等宽字体确保数字对齐。当日志列表为空时显示提示文案"暂无事件:长按地图 Marker 或 POI 试一试…",引导用户进行交互。日志列表通过 List 容器承载,高度固定为 116 像素,超出部分可滚动浏览。

10.4 地图 Tab 整体布局

@Builder
tabMap() {
  Column({ space: 8 }) {
    Row({ space: 12 }) {
      Toggle({ type: ToggleType.Switch, isOn: this.markerListenOn })
        .onChange(() => { this.toggleMarkerListen(); })
      Text('Marker长按').fontSize(11).fontColor(COLORS.sub).layoutWeight(1)
      Toggle({ type: ToggleType.Switch, isOn: this.poiListenOn })
        .onChange(() => { this.togglePoiListen(); })
      Text('POI长按').fontSize(11).fontColor(COLORS.sub)
    }
    .width('100%').padding({ top: 10 })
    Text('中心:西安 · zoom 12 · 长按地图标记或底图 POI 记录事件')
      .fontSize(9).fontColor(COLORS.text3).width('100%')
    MapComponent({ mapOptions: this.mapOptions, mapCallback: this.mapCallback })
      .layoutWeight(1).width('100%').borderRadius(12)
    // ... 事件日志流区域
  }
  .width('100%').layoutWeight(1)
  .padding({ left: 12, right: 12, bottom: 10 })
}

地图 Tab 不套 Scroll 容器(在 build 方法中直接挂载),因为 MapComponent 需要全高度渲染才能保证地图交互的流畅性。整个 Tab 以 Column 纵向布局,依次放置 Toggle 开关行、说明文字、MapComponent 地图主体(layoutWeight(1) 占据剩余高度)、事件日志流卡片。

十一、搜索 Tab 分析

11.1 POI 关键字搜索

搜索 Tab 通过 runSearch 异步方法调用 Map Kit 的 searchByText 接口,实现基于关键字的 POI 检索:

async runSearch() {
  this.searchState = '搜索中…';
  const params: site.SearchByTextParams = {
    query: this.queryInput,
    location: CITY_CENTER,
    radius: 5000,
    language: 'zh'
  };
  try {
    const result: site.SearchByTextResult = await site.searchByText(params);
    const sites: site.Site[] = result.sites ?? [];
    if (sites.length === 0) {
      this.searchState = '无结果,已保留当前推荐';
      return;
    }
    this.searchRecords = sites.map((s: site.Site) => new SearchRecord(
      s.name ?? '未命名地点', s.formatAddress ?? '暂无地址',
      s.distance ?? 0, s.reliability ?? 0));
    this.searchState = `返回 ${sites.length} 条结果`;
  } catch (e) {
    const err = e as BusinessError;
    this.searchState = `搜索失败(${err.code}),保留当前推荐`;
  }
}

搜索参数以西安市中心为基准点,搜索半径 5000 米,语言设为中文。搜索结果中的 sites 数组通过 map 方法转换为 SearchRecord 实例列表,每个站点的 reliability 相关性分数被保留用于后续的分数条展示。搜索状态在搜索前设为"搜索中…",成功后显示返回结果数量,无结果时保留当前推荐列表并提示,异常时(如无 AGC 配置或无网络)捕获错误码并保留 Mock 数据,保证演示链路的完整性。

11.2 搜索结果卡展示

每条搜索结果通过 searchResultCard Builder 方法构建独立卡片,包含名称与等级标签、地址、距离与 POI 命中标识、reliability 分数条:

@Builder
searchResultCard(rec: SearchRecord) {
  Column({ space: 6 }) {
    Row({ space: 8 }) {
      Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        .layoutWeight(1)
      Text(reliabilityScore(rec.reliability).label).fontSize(10)
        .fontColor(reliabilityScore(rec.reliability).color)
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .borderRadius(8).backgroundColor(COLORS.dark)
    }
    .width('100%')
    Text(rec.address).fontSize(11).fontColor(COLORS.sub)
      .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
    Row({ space: 8 }) {
      Text(`直线 ${rec.distance}m`).fontSize(10).fontColor(COLORS.text3)
      Text('POI 命中').fontSize(10).fontColor(COLORS.blue)
    }
    .width('100%')
    Row({ space: 8 }) {
      Progress({ value: rec.reliability * 100, total: 100, type: ProgressType.Linear })
        .layoutWeight(1).color(COLORS.bronze).backgroundColor(COLORS.dark)
      Text(`reliability ${rec.reliability.toFixed(2)}`).fontSize(10).fontColor(COLORS.gold)
    }
    .width('100%')
  }
  .width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
  .alignItems(HorizontalAlign.Start)
}

相关性等级标签通过 reliabilityScore 函数动态生成,标签颜色与分数档位对应。底部的 Progress 线性进度条以 reliability 值乘以 100 作为进度值,青铜绿色进度条配深色底,旁边以鎏金色等宽显示精确的小数分数值(保留两位小数),形成"条形可视化+数值精确展示"的双重表达。

十二、百科 Tab 分析

12.1 地址栏与快捷站点

百科 Tab 顶部提供地址栏输入框与四个快捷站点标签,实现网页浏览的导航入口:

@Builder
tabWeb() {
  Column({ space: 8 }) {
    Row({ space: 8 }) {
      TextInput({ text: this.urlInput, placeholder: '输入网址开始溯源' })
        .layoutWeight(1).height(38).fontSize(12)
        .backgroundColor(COLORS.dark).fontColor(COLORS.title)
        .onChange((v: string) => { this.urlInput = v; })
      Button('前往').height(34).fontSize(12).backgroundColor(COLORS.bronze)
        .onClick(() => { this.loadUrl(); })
    }
    .width('100%')
    Scroll() {
      Row({ space: 8 }) {
        ForEach(QUICK_SITES, (u: string) => {
          Text(hostOf(u)).fontSize(10)
            .fontColor(this.webUrl === u ? COLORS.gold : COLORS.sub)
            .padding({ left: 10, right: 10, top: 5, bottom: 5 })
            .borderRadius(12)
            .backgroundColor(this.webUrl === u ? COLORS.dark : COLORS.card)
            .onClick(() => {
              this.urlInput = u;
              this.webUrl = u;
            })
        }, (u: string) => u)
      }
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    // ... Web 组件与下载触发区域
  }
  .width('100%').layoutWeight(1)
  .padding({ left: 12, right: 12, top: 10, bottom: 10 })
}

地址栏的 loadUrl 方法实现了 URL 前缀自动补全——用户输入的地址如果没有 http:// 或 https:// 前缀,会自动补上 https://,然后将处理后的地址同步到 urlInput(输入框显示值)与 webUrl(Web 组件加载值)。快捷站点标签通过 hostOf 函数提取域名作为简短显示文本,当前选中的站点标签以鎏金色文字配深色底高亮,未选中标签以绢帛黄文字配卡片底色。

12.2 Web 组件与主动下载触发

百科 Tab 中部嵌入 Web 组件渲染文博站点,底部提供应用侧主动触发下载的入口:

Web({ src: this.webUrl, controller: this.webController })
  .layoutWeight(1).width('100%').borderRadius(12)
Row({ space: 8 }) {
  Column({ space: 2 }) {
    Text('webController.startDownload(url) 应用侧发起')
      .fontSize(9).fontColor(COLORS.text3)
      .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
  }
  .alignItems(HorizontalAlign.Start).layoutWeight(1)
  Button('触发').fontSize(11).height(30).backgroundColor(COLORS.gold)
    .onClick(() => {
      this.triggerDownload(RESOURCE_DL_URL);
    })
}
.width('100%').padding(10).borderRadius(10).backgroundColor(COLORS.card)

Web 组件以 webController 实例为控制器,加载 webUrl 指向的文博站点。底部的"触发"按钮调用 triggerDownload 方法,通过 webController.startDownload(url) 从应用侧主动发起下载(区别于网页内点击下载链接触发的被动下载),下载目标为预定义的古建筑图集 PDF 资源。

十三、下载 Tab 分析

13.1 下载代理装配

下载功能的生命周期管控通过 setupDownloadDelegate 方法实现,在 aboutToAppear 中调用,注册四个回调并绑定到 Web 控制器:

setupDownloadDelegate() {
  this.downloadDelegate.onBeforeDownload((item: webview.WebDownloadItem) => {
    const hostCtx = this.getUIContext().getHostContext();
    const dir: string = hostCtx ? hostCtx.filesDir : '';
    this.dlName = item.getSuggestedFileName();
    this.dlState = '下载中';
    this.dlPercent = 0;
    item.start(`${dir}/${item.getSuggestedFileName()}`);
  });
  this.downloadDelegate.onDownloadUpdated((item: webview.WebDownloadItem) => {
    this.dlPercent = item.getPercentComplete();
  });
  this.downloadDelegate.onDownloadFailed((item: webview.WebDownloadItem) => {
    this.dlState = `下载失败:${item.getSuggestedFileName()}`;
  });
  this.downloadDelegate.onDownloadFinish((item: webview.WebDownloadItem) => {
    const originalUrl: string = item.getOriginalUrl();
    const referrerUrl: string = item.getReferrerUrl();
    this.dlPercent = 100;
    this.dlState = '已完成';
    this.downloadRecords.unshift(new DownloadRecord(
      item.getSuggestedFileName(), item.getTotalBytes(), nowTime(), originalUrl, referrerUrl));
  });
  try {
    this.webController.setDownloadDelegate(this.downloadDelegate);
  } catch (e) {
    console.error(`setDownloadDelegate failed: ${(e as BusinessError).code}, ${(e as BusinessError).message}`);
  }
}

四个回调覆盖下载的完整生命周期。onBeforeDownload 在下载开始前触发,必须调用 item.start(沙箱路径) 提供文件保存路径,否则任务会停留在 PENDING 状态无法启动。沙箱路径通过 getUIContext().getHostContext().filesDir 获取应用沙箱目录,与文件名拼接形成完整保存路径。onDownloadUpdated 在下载进行中持续触发,通过 getPercentComplete() 刷新进度条。onDownloadFailed 在下载失败时设置状态提示。onDownloadFinish 是 6.1.1 双 URL 溯源特性的核心——通过 getOriginalUrl() 读取原始下载地址,通过 getReferrerUrl() 读取引用页地址,将这两个 URL 连同文件名、大小与完成时间封装为 DownloadRecord 实例插入到下载记录列表头部。

13.2 进行中任务卡与溯源记录列表

下载 Tab 顶部展示当前下载任务的状态卡,底部展示已完成下载的双 URL 溯源记录列表:

@Builder
tabDownload() {
  Column({ space: 12 }) {
    Column({ space: 8 }) {
      Row({ space: 8 }) {
        Text('📥').fontSize(14)
        Column({ space: 2 }) {
          Text(this.dlName === '' ? '暂无进行中任务' : this.dlName)
            .fontSize(12).fontColor(COLORS.title)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            .layoutWeight(1)
          Text(this.dlState).fontSize(10).fontColor(COLORS.sub)
        }
        .alignItems(HorizontalAlign.Start).layoutWeight(1)
        Text(`${this.dlPercent}%`).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.gold)
      }
      .width('100%')
      Progress({ value: this.dlPercent, total: 100, type: ProgressType.Linear })
        .width('100%').color(COLORS.bronze).backgroundColor(COLORS.dark)
      Row({ space: 8 }) {
        Text('网页内点击下载或百科 Tab 主动触发').fontSize(9).fontColor(COLORS.text3).layoutWeight(1)
        Text('delegate 四回调').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')
    }
    .width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
    .alignItems(HorizontalAlign.Start)
    // ... 完成记录列表
  }
  .width('100%')
}

进行中任务卡展示文件名(无任务时显示"暂无进行中任务")、状态文案与百分比进度,进度条使用青铜绿色配深色底。每条下载记录通过 downloadRecordCard Builder 方法构建独立的溯源卡片,分别展示原始 URL 与引用页 URL:

@Builder
downloadRecordCard(rec: DownloadRecord) {
  Column({ space: 6 }) {
    Row({ space: 8 }) {
      Text('📄').fontSize(14)
      Column({ space: 2 }) {
        Text(rec.fileName).fontSize(12).fontColor(COLORS.title)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(`${formatSize(rec.fileSize)} · ${rec.finishTime} 完成`)
          .fontSize(10).fontColor(COLORS.text3)
      }
      .alignItems(HorizontalAlign.Start).layoutWeight(1)
    }
    .width('100%')
    Row({ space: 6 }) {
      Text('🔗').fontSize(10)
      Text('原始URL getOriginalUrl()').fontSize(9).fontColor(COLORS.gold)
    }
    .width('100%')
    Text(rec.originalUrl).fontSize(10).fontFamily('monospace').fontColor(COLORS.sub)
      .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
    Row({ space: 6 }) {
      Text('📄').fontSize(10)
      Text('引用页URL getReferrerUrl()').fontSize(9).fontColor(COLORS.bronze)
    }
    .width('100%')
    Text(rec.referrerUrl).fontSize(10).fontFamily('monospace').fontColor(COLORS.sub)
      .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
  }
  .width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
  .alignItems(HorizontalAlign.Start)
}

溯源卡片以双区块结构展示 URL 信息——原始 URL 区域用链接 emoji 配鎏金色标签文字,引用页 URL 区域用文档 emoji 配青铜绿色标签文字,URL 本身以 monospace 等宽字体显示,单行截断确保布局整齐。这种双 URL 溯源机制使学术研究者可以精确标注资源的直接来源与发现路径。

十四、工坊 Tab 分析

14.1 WebP 像素画生成

工坊 Tab 的第一区块是"老照片样图生成",通过 genWebpFile 异步方法实现从像素画到 WebP 文件的完整生成链路:

async genWebpFile() {
  this.genState = '生成中…';
  try {
    if (this.pixelMap) {
      this.pixelMap.release();
      this.pixelMap = undefined;
    }
    const total: number = CANVAS_SIZE * CANVAS_SIZE;
    const buf: ArrayBuffer = new ArrayBuffer(total * 4);
    const pixels: Uint32Array = new Uint32Array(buf);
    for (let i: number = 0; i < total; i++) {
      const row: number = Math.floor(i / CANVAS_SIZE);
      const col: number = i % CANVAS_SIZE;
      pixels[i] = this.pickTextureColor(this.textureSel, row, col);
    }
    const opts: image.InitializationOptions = {
      size: { width: CANVAS_SIZE, height: CANVAS_SIZE },
      pixelFormat: image.PixelMapFormat.RGBA_8888
    };
    const pm: image.PixelMap = await image.createPixelMap(buf, opts);
    this.pixelMap = pm;
    const packer: image.ImagePacker = image.createImagePacker();
    const packOpts: image.PackingOption = { format: 'image/webp', quality: WEBP_QUALITY };
    const webpBuf: ArrayBuffer = await packer.packToData(pm, packOpts);
    await packer.release();
    const ctx = this.getUIContext().getHostContext();
    const dir: string = ctx ? ctx.filesDir : '';
    const path: string = `${dir}/heritage_photo.webp`;
    const file: fileIo.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.metaSnapshot = undefined;
    this.verifySnapshot = undefined;
    this.opLogs.unshift(new MetaOpLog('生成样图',
      `${this.textureLabel()}纹理 ${CANVAS_SIZE}×${CANVAS_SIZE} 编码为 WebP 落盘`));
  } catch (e) {
    const err = e as BusinessError;
    this.genState = `生成失败(${err.code})`;
    this.opLogs.unshift(new MetaOpLog('生成样图', `失败:code ${err.code},${err.message}`));
  }
}

生成流程分为三步。第一步按当前选中纹理生成像素画——创建 CANVAS_SIZE * CANVAS_SIZE * 4 字节的 ArrayBuffer(每像素 4 字节对应 RGBA_8888 格式),以 Uint32Array 视图操作,遍历每个像素调用 pickTextureColor 函数根据纹理类型与像素坐标计算颜色值。第二步通过 ImagePacker 将 PixelMap 编码为 WebP 字节流,编码质量为 90。第三步将 WebP 字节流写入沙箱文件 heritage_photo.webp,文件以 READ_WRITE | CREATE | TRUNC 模式打开(读写、不存在则创建、存在则截断),写入后立即关闭文件描述符。

14.2 五种纹理算法

pickTextureColor 函数实现了五种老照片纹理的像素级算法,根据纹理键名与像素坐标返回对应的 RGBA 缓冲值:

private pickTextureColor(key: string, row: number, col: number): number {
  const palette: string[] = [COLORS.bronze, COLORS.gold, COLORS.sub, COLORS.text3, COLORS.bronzeD];
  if (key === 'mottle') {
    return hexToRgba(palette[(row + col) % palette.length]);
  }
  if (key === 'carve') {
    return hexToRgba(palette[(Math.floor(row / 8) + Math.floor(col / 8)) % palette.length]);
  }
  if (key === 'rammed') {
    return hexToRgba(palette[Math.floor(row / 8) % palette.length]);
  }
  if (key === 'stele') {
    return hexToRgba(palette[Math.floor(col / 8) % palette.length]);
  }
  const dx: number = row - CANVAS_SIZE / 2;
  const dy: number = col - CANVAS_SIZE / 2;
  const ring: number = Math.floor(Math.sqrt(dx * dx + dy * dy));
  return hexToRgba(palette[ring % palette.length]);
}

五种纹理分别使用不同的坐标计算逻辑。斑驳纹理使用 (row + col) % palette.length 生成对角斜纹——相邻像素颜色沿对角线方向渐变,模拟照片表面的斑驳磨损。雕花纹理使用 (Math.floor(row / 8) + Math.floor(col / 8)) % palette.length 生成棋盘格——每 8x8 像素为一个色块单元,模拟雕花纹饰的规律布局。夯土纹理使用 Math.floor(row / 8) % palette.length 生成横带——颜色仅随行号变化,模拟夯土层理的横向条纹。碑刻纹理使用 Math.floor(col / 8) % palette.length 生成竖带——颜色仅随列号变化,模拟石碑刻痕的竖向纹路。年轮纹理使用到画布中心的距离 Math.sqrt(dx*dx + dy*dy) 取整后对调色板长度取模,生成从中心向外扩散的同心环,模拟树木年轮的横截面。调色板由五种文博主题色组成:青铜绿、鎏金、绢帛黄、陶土灰、青铜深色。

14.3 元数据类型化读取

第二区块通过 readMeta 异步方法读取 WebP 文件的五字段元数据快照:

async readMeta() {
  if (this.webpPath === '') {
    this.opLogs.unshift(new MetaOpLog('读取元数据', '请先生成 WebP 样图'));
    return;
  }
  try {
    const file: fileIo.File = fileIo.openSync(this.webpPath, fileIo.OpenMode.READ_WRITE);
    const source: image.ImageSource = image.createImageSource(file.fd);
    const types: image.MetadataType[] = [image.MetadataType.WEBP_METADATA];
    const meta: image.ImageMetadata = await source.readImageMetadataByType(types, 0);
    const webp: image.WebPMetadata | undefined = 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, ' 次')}`));
  } catch (e) {
    const err = e as BusinessError;
    this.opLogs.unshift(new MetaOpLog('读取元数据', `失败:code ${err.code},${err.message}`));
  }
}

读取过程使用 6.1.1 的 readImageMetadataByType 接口,传入 MetadataType.WEBP_METADATA 类型数组和帧索引 0(静态 WebP 恒取 0)。返回的 ImageMetadata 对象的 webPMetadata 属性包含五个可选字段,使用 ?? -1 将 undefined 统一转换为 -1 占位值存入 WebpMetaSnapshot。静态 WebP 通常仅提供画布尺寸字段,帧延迟与循环次数可能为 undefined(渲染时显示"未提供"),这符合 WebP 动画元数据在静态图上的缺失预期。

14.4 元数据写回与回读校验

第三区块与第四区块协同完成元数据的写回与回读校验。writeMeta 方法以字面量构造 WebPMetadata 对象并调用 writeImageMetadata 写回文件:

async writeMeta() {
  if (this.webpPath === '') {
    this.opLogs.unshift(new MetaOpLog('写入元数据', '请先生成 WebP 样图'));
    return;
  }
  try {
    const file: fileIo.File = fileIo.openSync(this.webpPath, fileIo.OpenMode.READ_WRITE);
    const source: image.ImageSource = image.createImageSource(file.fd);
    const webpMeta: image.WebPMetadata = {
      canvasWidth: CANVAS_SIZE,
      canvasHeight: CANVAS_SIZE,
      delayTime: this.writeDelay,
      unclampedDelayTime: this.writeDelay,
      loopCount: this.writeLoop
    };
    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} 次`));
    await this.verifyRead();
  } catch (e) {
    const err = e as BusinessError;
    this.opLogs.unshift(new MetaOpLog('写入元数据',
      `失败:code ${err.code},${err.message}(7700202=不支持,7700204=参数非法)`));
  }
}

WebPMetadata 的所有字段均为可选,允许以对象字面量方式构造,canvasWidth 与 canvasHeight 设为画布尺寸 96,delayTime 与 unclampedDelayTime 均设为用户选择的帧延迟值(写入时二者一致,读取时可能因系统钳制而不同),loopCount 设为用户选择的循环次数。写入成功后立即调用 verifyRead 进行回读校验。

verifyRead 方法重建 ImageSource(先 release 再重开文件描述符)回读元数据,并与写入值进行比对:

async verifyRead() {
  try {
    const file: fileIo.File = fileIo.openSync(this.webpPath, fileIo.OpenMode.READ_WRITE);
    const source: image.ImageSource = image.createImageSource(file.fd);
    const meta: image.ImageMetadata =
      await source.readImageMetadataByType([image.MetadataType.WEBP_METADATA], 0);
    const webp: image.WebPMetadata | undefined = 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: boolean = this.verifySnapshot!.delayTime === this.writeDelay
      && this.verifySnapshot!.loopCount === this.writeLoop;
    if (ok) {
      this.opLogs.unshift(new MetaOpLog('回读校验', '已生效:delayTime/loopCount 与写入值一致'));
    } else {
      this.opLogs.unshift(new MetaOpLog('回读校验',
        `差异:delayTime=${fmtField(this.verifySnapshot!.delayTime, 'ms')},` +
        `loopCount=${fmtField(this.verifySnapshot!.loopCount, ' 次')}`));
    }
  } catch (e) {
    const err = e as BusinessError;
    this.opLogs.unshift(new MetaOpLog('回读校验', `失败:code ${err.code},${err.message}`));
  }
}

回读校验的核心逻辑是比较 verifySnapshot 中的 delayTime 与 loopCount 是否与写入值完全一致。如果一致则记录"已生效"日志,如果不一致则记录差异值。这种"写入即回读"的模式使用户可以即时验证元数据写入是否真正生效,形成完整的闭环验证链路。

14.5 工坊 Tab 四区块布局

工坊 Tab 以纵向四区块结构排布,每个区块以序号标记(①②③④):

  • 区块①"老照片样图生成":纹理选择标签行 + 编码参数信息行 + 样图预览与状态行 + 生成按钮
  • 区块②"五字段元数据读取":读取按钮 + 元数据快照卡(或提示文案)
  • 区块③"写入控制台":帧延迟三档选择 + 循环四档选择 + 写入按钮
  • 区块④"回读校验与操作日志":回读快照卡(高亮底色)+ 操作日志列表

帧延迟与循环次数的选择标签采用与纹理选择相同的视觉模式——选中态用鎏金色文字配深色底,未选中态用陶土灰文字配卡片底色。元数据快照卡通过 metaCard Builder 方法构建,读取结果与回读校验结果共用此方法,通过 highlight 参数控制背景色是否高亮。

十五、我的 Tab 分析

15.1 探访者渐变大卡

我的 Tab 顶部以渐变大卡展示探访者等级与统计信息:

@Builder
tabMine() {
  Column({ space: 12 }) {
    Column({ space: 10 }) {
      Row({ space: 10 }) {
        Text('🏛️').fontSize(34)
        Column({ space: 3 }) {
          Text('长安拾遗人').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
          Text('青铜级探访者 · 已点亮 6 处唐迹').fontSize(10).fontColor(COLORS.sub)
        }
        .alignItems(HorizontalAlign.Start).layoutWeight(1)
        Text('Lv.5').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.gold)
      }
      .width('100%')
      Row({ space: 8 }) {
        this.mineStat('收录', `${this.heritageList.length}`, '处')
        this.mineStat('踏访', '18', '次')
        this.mineStat('里程', '128.6', 'km')
      }
      .width('100%')
      Progress({ value: 64, total: 100, type: ProgressType.Linear })
        .width('100%').color(COLORS.gold).backgroundColor(COLORS.dark)
      Text('距离白银级还差 2 处踏访').fontSize(9).fontColor(COLORS.sub)
    }
    .width('100%').padding(16).borderRadius(14)
    .linearGradient({ angle: 135, colors: [[COLORS.bronzeD, 0.0], [COLORS.dark, 1.0]] })
    // ... 足迹清单与说明行
  }
  .width('100%')
}

渐变大卡使用 linearGradient 从 135 度方向以青铜深色(bronzeD)渐变到次级底色(dark),模拟青铜器的质感光泽。卡内包含探访者身份信息行(大雁塔 emoji 配"长安拾遗人"称号与"青铜级探访者"副标题)、三个统计格(收录数量动态来自 heritageList.length、踏访次数固定 18、探索里程固定 128.6 公里)、等级进度条(64% 进度配鎏金色)与升级提示文案。统计格数值的颜色同样与 breath 状态联动,形成呼吸脉动效果。

15.2 足迹清单与说明行

渐变大卡下方展示六处长安古迹的足迹清单,每处通过 footRow Builder 方法构建:

@Builder
footRow(spot: SpotItem) {
  Row({ space: 8 }) {
    Text('✅').fontSize(12)
    Column({ space: 2 }) {
      Text(spot.name).fontSize(12).fontColor(COLORS.title)
      Text(spot.tag).fontSize(10).fontColor(COLORS.text3)
    }
    .alignItems(HorizontalAlign.Start).layoutWeight(1)
    Text('已踏访').fontSize(10).fontColor(COLORS.bronze)
  }
  .width('100%').padding(10).borderRadius(10).backgroundColor(COLORS.card)
}

每行足迹以勾选 emoji 开头,古迹名称与朝代标签纵向排列,右侧以青铜绿色"已踏访"标记表示完成状态。足迹清单数据复用 MARKER_SPOTS 常量,与地图 Tab 的 Marker 群共享同一数据源,体现了数据的多场景复用设计。

底部的权限与说明区域通过 ForEach 遍历 ABOUT_ROWS 常量,每行展示图标、标题与说明文本,声明三大特性所需的权限与版本信息。

十六、底部 Tab 栏

底部导航栏通过 tabBar Builder 方法构建,七个 Tab 单排排列:

@Builder
tabBar() {
  Row() {
    ForEach(TAB_LIST, (t: TabMeta, idx: number) => {
      Column({ space: 3 }) {
        Text(t.icon).fontSize(18)
        Text(t.label).fontSize(9)
          .fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
      }
      .layoutWeight(1)
      .justifyContent(FlexAlign.Center)
      .onClick(() => {
        this.currentTab = idx;
      })
    }, (t: TabMeta, idx: number) => `${t.label}_${idx}`)
  }
  .width('100%').height(58).backgroundColor(COLORS.card)
  .padding({ top: 6, bottom: 6 })
}

每个 Tab 以 Column 纵向排列 emoji 图标与文字标签,通过 layoutWeight(1) 等分宽度。选中态标签文字使用鎏金色(COLORS.tabOn),未选中态使用陶土灰(COLORS.text3),形成"金灰对比"的导航视觉语言。点击时设置 currentTab 为对应索引,触发内容区的 Tab 切换。Tab 栏整体高度 58 像素,背景色为卡片底色,上下各留 6 像素内边距。

十七、弹框系统

17.1 通用遮罩层

所有弹窗共享一个通用的半透明遮罩层,通过 modalOverlay Builder 方法构建:

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

遮罩层使用 rgba(0,0,0,0.6) 半透明黑色覆盖全屏,点击遮罩区域时调用 onClose 回调关闭弹窗。每个弹窗面板通过 Stack 容器将遮罩层与内容卡片层叠,内容卡片居中对齐。

17.2 新增弹窗

新增弹窗通过 panelAdd Builder 方法构建,提供四个输入字段用于收录新古迹:

@Builder
panelAdd(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)
    Column({ space: 10 }) {
      Text('收录新古迹').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      this.fieldRow('古迹名称', this.inputName, '如 兴教寺塔', (v: string) => { this.inputName = v; })
      this.fieldRow('朝代', this.inputDynasty, '如 唐 / 明', (v: string) => { this.inputDynasty = v; })
      this.fieldRow('保护等级', this.inputLevel, '全国重点 / 省级 / 市县级', (v: string) => { this.inputLevel = v; })
      this.fieldRow('所在区域', this.inputRegion, '如 西安·长安区', (v: string) => { this.inputRegion = v; })
      Row({ space: 10 }) {
        Button('取消').fontSize(12).layoutWeight(1).backgroundColor(COLORS.dark).fontColor(COLORS.sub)
          .onClick(() => { onClose(); })
        Button('确认收录').fontSize(12).layoutWeight(1).backgroundColor(COLORS.bronze)
          .onClick(() => {
            this.confirmAdd();
            onClose();
          })
      }
      .width('100%')
    }
    .width('86%').padding(16).borderRadius(14).backgroundColor(COLORS.card)
  }
  .width('100%').height('100%')
  .alignContent(Alignment.Center)
}

每个输入字段通过 fieldRow Builder 方法构建,包含标签文本与 TextInput 输入框。确认收录时调用 confirmAdd 方法,该方法对输入进行空值校验与默认值填充——名称为空则不执行操作,朝代为空填"未考",区域为空填"待考订",保护等级通过 normLevel 方法归一化为三档标准名称之一。新古迹的文保分默认设为 60,通过 unshift 方法插入到名录顶部。

17.3 编辑弹窗

编辑弹窗通过 panelEdit Builder 方法构建,与新增弹窗结构类似但增加了"留空保持原值"的提示文案。编辑时通过 openEdit 方法先将当前条目的字段值回填到输入框,用户修改后调用 confirmEdit 方法覆盖选中条目——只有非空输入才会覆盖对应字段,空输入保持原值不变,这种设计使用户可以只修改需要变更的字段。

17.4 删除弹窗

删除弹窗通过 panelDel Builder 方法构建,展示确认提示与双重按钮:

@Builder
panelDel(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)
    Column({ space: 12 }) {
      Text('移出名录').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Text(`确定将「${this.delName()}」移出收录名录吗?`)
        .fontSize(11).fontColor(COLORS.sub)
      Row({ space: 10 }) {
        Button('再想想').fontSize(12).layoutWeight(1).backgroundColor(COLORS.dark).fontColor(COLORS.sub)
          .onClick(() => { onClose(); })
        Button('确认移出').fontSize(12).layoutWeight(1).backgroundColor(COLORS.red)
          .onClick(() => {
            this.confirmDel();
            onClose();
          })
      }
      .width('100%')
    }
    .width('72%').padding(16).borderRadius(14).backgroundColor(COLORS.card)
  }
  .width('100%').height('100%')
  .alignContent(Alignment.Center)
}

删除弹窗宽度比新增/编辑弹窗窄(72% vs 86%),形成更聚焦的确认视觉。确认按钮使用朱砂红色(COLORS.red)背景,取消按钮使用深色底配绢帛黄文字。确认移出时调用 confirmDel 方法通过 splice 删除指定索引的条目。

十八、功能模块对比表

对比维度首页 Tab地图 Tab搜索 Tab百科 Tab下载 Tab工坊 Tab我的 Tab
布局结构Flex双列+柱状图Column全高Column列表Column全高Column列表Column四区块Column渐变卡
是否套Scroll是否是否是是是
核心数据源heritageListMARKER_SPOTSsearchRecordswebUrldownloadRecordsmetaSnapshotheritageList+MARKER_SPOTS
交互特性卡片编辑/删除Marker长按+POI长按关键字搜索网页浏览+下载触发下载进度+双URL溯源WebP元数据读写等级展示+足迹清单
HarmonyOS特性无(纯UI)Map Kit双长按Map Kit searchByTextArkWeb Web组件ArkWeb双URL溯源ImageKit WebP元数据无(纯UI)
主色调青铜绿+鎏金青铜绿+鎏金青铜绿+陶土灰青铜绿+鎏金青铜绿+鎏金青铜绿+鎏金青铜深色渐变+鎏金
呼吸联动统计格+柱状图无无无无无统计格数值
弹窗关联编辑/删除弹窗无无无无无无
数据可变性增删改日志累积搜索替换URL切换记录累积读写快照无(只读)

十九、总结与展望

本文以一个文化遗产探索地图应用为例,完整剖析了 HarmonyOS ArkUI 在文博文旅场景下的组件化架构实践。应用以"深色文博"设计语言为视觉基调,通过青铜绿与鎏金的色彩体系营造出考古土层与青铜器锈蚀的文博氛围,七个 Tab 以完全不同的布局结构覆盖了从古迹发现、地理定位、POI 检索、资料浏览、资源下载、元数据验证到探访者回顾的完整探索流程。

三大 HarmonyOS 6.1.1 特性在同一种设计语言下叠加呈现,各自承担独立的技术职责又通过状态共享实现跨 Tab 协同。Map Kit 的双长按监听(onMarkerLongClick 与 onPoiLongClick)将地图交互事件结构化记录为可追溯的日志流,searchByText 的 reliability 相关性分数为 POI 检索结果提供了量化匹配度评估。ArkWeb 的下载代理四回调配合 getOriginalUrl() 与 getReferrerUrl() 双 URL 溯源方法,使文博资源的获取来源可被精确标注,为学术引用提供了可追溯的数字证据链。ImageKit 的 readImageMetadataByType 与 writeImageMetadata 配合"写入即回读"的校验模式,在应用沙箱内完成了 WebP 元数据从读取到写回到验证的完整闭环。

从架构设计角度审视,本应用体现了三个值得借鉴的工程实践。第一是状态变量的统一声明与跨 Tab 共享——所有业务数据(古迹名录、下载记录、地图事件日志)的状态变量都声明在组件顶层,任何一个 Tab 产生的数据变更都能被其他 Tab 即时感知,这得益于 ArkUI 的 @State 与 @Observed 状态驱动模型。第二是布局策略的差异化处理——地图 Tab 与百科 Tab 因需要全高度渲染原生组件而不套 Scroll 容器,其余 Tab 统一走 Scroll 分支,这种"按需套壳"的布局策略避免了原生组件被滚动容器裁切的问题。第三是数据模型与视图的严格分离——六个 @Observed 数据类(HeritageItem、SearchRecord、EventLog、DownloadRecord、WebpMetaSnapshot、MetaOpLog)各自封装独立的数据结构,@Builder 方法仅负责数据到视图的映射,不包含业务逻辑,使数据层与视图层保持清晰的边界。

展望未来,该应用可以在以下方向持续演进。在地图能力方面,可以引入 Map Kit 的路径规划 API,为实地探访提供从当前位置到目标古迹的导航路线;在元数据能力方面,可以扩展 ImageKit 支持更多图片格式(如 EXIF 元数据读写),覆盖更广泛的文博图片处理场景;在数据持久化方面,当前应用的古迹名录与下载记录仅存在于内存中,可以引入关系型数据库或 Preferences 实现数据的本地持久化,使应用重启后数据不丢失;在多设备协同方面,可以利用 HarmonyOS 的分布式能力,将探访足迹同步到手表或平板设备,实现跨设备的文博探索体验。随着 HarmonyOS NEXT 的持续演进,ArkUI 的声明式范式与系统级 Kit 能力的深度结合,将为文博文旅领域的数字化创新提供更广阔的技术空间。

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

更多推荐