一、技术前言

在文化遗产保护与文博文旅数字化领域,遗产导览应用正经历从"静态名录浏览"到"多维度交互探索"的深刻变革。从长安古城的唐塔明墙到碑林石刻的千年文脉,从地图标注的精准定位到百科溯源的下载追踪,每一处文化遗产都需要匹配不同的展示维度、交互深度和数据溯源机制。传统文博应用面临三大挑战:地图交互能力薄弱导致古迹定位体验割裂、下载来源不可追溯导致资源可信度存疑、图片元数据不可读写导致工坊能力缺失。
在这里插入图片描述

HarmonyOS ArkUI 框架为这些挑战提供了系统级解决方案。ArkUI 的声明式 UI 范式通过 @Component 封装可复用组件、@State 管理响应式状态、@Builder 拆分复杂 UI 结构,天然适合"名录-地图-搜索-百科-下载-工坊"多 Tab 架构。@Observed 装饰器让数据模型字段级变化被 UI 感知,实现"数据更新即视图刷新"的流畅体验。@Entry 标注的根组件通过单一 build() 方法编排全部布局,状态变量统一声明在组件顶层实现跨 Tab 共享。

在这里插入图片描述

本平台深度融合 HarmonyOS 6.1.1 的三大前沿特性。Map Kit 提供 MapComponent 地图组件与 MapEventManager 双长按监听能力链——通过 onMarkerLongClick 和 onPoiLongClick 两个 6.1.1 新增接口实现 Marker 标记与底图 POI 的长按事件捕获,配合 addMarker 批量标注古迹点位和 searchByText 关键字 POI 检索(读取 reliability 相关性分数),构建完整的"标注-监听-搜索"地图交互闭环。ArkWeb 提供 WebDownloadDelegate 下载代理四回调体系——onBeforeDownload 提供沙箱路径、onDownloadUpdated 刷新进度、onDownloadFailed 处理异常、onDownloadFinish 完成 6.1.1 双 URL 溯源(getOriginalUrl 读取原始下载地址、getReferrerUrl 读取引用页来源),配合 startDownload 应用侧主动下载实现"网页触发+应用触发"双通道。ImageKit 提供 WebP 元数据类型化读写能力——readImageMetadataByType 配合 MetadataType.WEBP_METADATA 读取五字段快照(canvasWidth/canvasHeight/delayTime/unclampedDelayTime/loopCount),writeImageMetadata 以字面量构造 WebPMetadata 写回并立即重建 ImageSource 回读校验,-1 占位策略统一处理 undefined 字段的"未提供"展示。三大特性在同一文件中叠加,形成文博文旅场景下"地图交互+下载溯源+元数据工坊"的三位一体能力矩阵。

二、整体架构流程图

生命周期

弹窗系统

三大特性能力链

内容区七 Tab

Page1236 根组件

headerMain 品牌头部

内容区 7 Tab 切换

tabBar 底部导航

弹窗系统 add/edit/del

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

Tab1 地图
双Toggle+MapComponent+长按日志流

Tab2 搜索
搜索框+reliability分数列表

Tab3 百科
地址栏+快捷站点+Web组件

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

Tab5 工坊
WebP元数据四区块

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

Map Kit
Marker/POI双长按监听

ArkWeb
下载代理双URL溯源

ImageKit
WebP元数据读写回读

Map Kit
searchByText相关性

panelAdd 收录新古迹

panelEdit 修订古迹档案

panelDel 移出名录确认

aboutToAppear
呼吸定时器+地图回调+下载代理

aboutToDisappear
清理定时器

架构以 Page1236 为根组件,使用 Column 容器纵向排列:顶部 headerMain 品牌头部、分割线、内容区(Scroll 滚动或全高不套 Scroll 两种分支)、底部 tabBar 导航栏,顶层是三个独立弹窗(panelAdd/panelEdit/panelDel 各自条件渲染)。内容区通过 currentTab 状态变量在 7 个 @Builder 方法间切换,地图 Tab 和百科 Tab 因需全高展示而不套 Scroll 容器,其余五个 Tab 走 Scroll 滚动分支。三大特性分散在地图(双长按监听)、百科(下载代理)、下载(双 URL 溯源)和工坊(WebP 元数据读写)四个 Tab 上,搜索 Tab 通过 searchByText 读取 POI 的 reliability 分数也属于 Map Kit 能力延伸。生命周期 aboutToAppear 中一次性装配呼吸动画定时器、地图回调函数和下载代理绑定,确保三大特性的初始化在组件出现时完成。

三、色彩体系设计

3.1 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 选中色使用 gold(鎏金)而非 bronze(青铜绿),这是因为金色在深褐背景上对比度更高,用户视觉定位更迅速。朱砂红专门用于全国重点文物保护单位的徽章标识和删除操作按钮,形成"等级越高色彩越浓"的语义层级。我的 Tab 的渐变大卡使用 linearGradient 从 bronzeD 到 dark 的 135 度渐变,模拟青铜器从受光面到暗面的过渡。

四、Tab 元数据与常量定义

4.1 底部导航 Tab 定义

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

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

七 Tab 单排布局是本平台的导航骨架。每个 Tab 的图标和标签都经过文博语义映射:首页用建筑图标代表名录总览,地图用地图图标代表空间探索,搜索用放大镜代表 POI 检索,百科用地球图标代表知识溯源,下载用接收箭头代表资源获取,工坊用试管图标代表元数据实验,我的用人像图标代表探访者画像。TabMeta 接口仅含两个字段,保持导航元数据的极简性,ForEach 渲染时以 label_idx 组合键确保唯一性。

4.2 地图标注点与快捷站点

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

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: '唐 · 玄奘墓塔' }
];

城市中心点定位于西安钟楼一带(北纬 34.3416 度、东经 108.9398 度),这是 Map Kit 定位与 POI 搜索的基准坐标。六处长安古迹覆盖唐代楼阁式砖塔(大雁塔)、密檐式砖塔(小雁塔)、明代城垣遗存(西安城墙)、石刻碑林(碑林博物馆)、宫殿遗址(大明宫)和玄奘墓塔(兴教寺塔),每处古迹都附带朝代与形制标签,既是地图 Marker 的标注数据,也是我的 Tab 足迹清单的展示来源。

快捷站点列表收录四个文博类真实站点:国家文物局(ncha.gov.cn)、故宫博物院(dpm.org.cn)、陕西历史博物馆(sxhm.com)和中国考古网(kaogu.cn),这些站点既是百科 Tab 的快捷跳转入口,也是下载溯源场景的演示数据来源。

4.3 老照片纹理与探访数据

const WEBP_QUALITY: number = 90;
const CANVAS_SIZE: number = 96;
const DELAY_PRESETS: number[] = [120, 200, 500];
const LOOP_PRESETS: number[] = [0, 1, 3, 5];

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: '同心环' }
];

工坊 Tab 的 WebP 编码参数中,质量设为 90(高质量压缩),画布边长 96 像素(足够展示纹理细节又不过大),帧延迟三档预设 120/200/500 毫秒覆盖快慢区间,循环次数四档预设 0/1/3/5 次(0 表示不限循环)。五种老照片纹理各有算法对应:斑驳走对角斜纹(行列和取模)、雕花走棋盘格(8x8 分块取模)、夯土走横带层理(行号取模)、碑刻走竖带刻痕(列号取模)、年轮走同心环(距中心距离取模),每种纹理从同一调色板取色但分布模式完全不同。

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

近六个月探访量数据用于首页柱状图,从 03 月到 08 月的探访热度依次为 126、98、154、183、216、172 次,07 月峰值反映了暑期文博旅游高峰的特征。柱状图通过 ForEach 渲染六根柱子,breath 状态联动柱高波动模拟实时数据脉动。

五、工具函数详解

5.1 相关性分数与保护等级映射

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

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

reliabilityScore 函数将 Map Kit searchByText 返回的 reliability 相关性分数(0 到 1 的浮点数)映射为三档等级标签与颜色:0.8 以上为高相关(鎏金色)、0.5 以上为中相关(青铜绿色)、其余为低相关(陶土灰色)。这个分级直接影响搜索结果卡片上等级徽章的视觉表现,用户一眼即可区分 POI 命中的可信度。

levelColor 函数将文物保护等级映射为徽章颜色:全国重点用朱砂红(最高等级视觉冲击)、省级用青铜绿(中等视觉强调)、市县级用陶土灰(弱化不抢眼)。这种"等级越高色彩越浓"的设计确保用户在浏览古迹名录时能快速识别文保级别。

5.2 颜色转换与格式化工具

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

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

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

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

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

hexToRgba 是工坊 Tab 像素画生成的基础工具,将 #RRGGBB 格式的十六进制颜色字符串转换为 0xFFBBGGRR 格式的 32 位整数。由于 ArkUI 的 RGBA_8888 像素缓冲按字节序排列为 R、G、B、A(A 固定 255),该函数通过位运算将蓝分量左移 16 位、绿分量左移 8 位、红分量保持低位,并与 0xFF000000(Alpha 通道满值)做或运算,生成符合缓冲要求的颜色值。

fmtField 函数处理 WebP 元数据的 undefined 兜底:当字段值为 -1(占位符)时返回"未提供"文本,否则返回值加单位的格式化字符串。这个函数在元数据快照卡片的每一行都被调用,统一处理五字段的空值展示。

formatSize 将字节数转换为人类可读的体积文本,1MB 以上用 MB 单位、1KB 以上用 KB 单位、其余用 B 单位,保留一位小数。hostOf 从 URL 提取域名用于快捷站点标签。nowTime 返回 HH:mm:ss 格式时间戳,被事件日志、下载记录和元数据操作日志三个场景共用。

六、数据模型层

6.1 古迹名录实体

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

HeritageItem 是古迹名录的核心实体,使用 @Observed 装饰器使其字段级变化被 UI 感知。五个字段分别表示名称、朝代、保护等级、所在区域和文保评分。构造函数全参传入,确保每条记录在创建时就具备完整信息。Mock 数据预置了八条长安古迹记录,覆盖唐、明、西汉、清四个朝代,以及全国重点、省级、市县级三档保护等级,为弹窗的增删改操作提供完整的演示数据基础。

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

SearchRecord 实体映射 Map Kit searchByText 返回的 POI 搜索结果,四个字段分别对应站点名称、格式化地址、直线距离和相关性分数。reliability 字段是 0 到 1 的浮点数,直接来自 Map Kit 的 site.Site.reliability 属性,通过 reliabilityScore 函数映射为三档等级标签。Mock 数据预置了七条以"大雁塔"为中心的周边 POI 记录,相关性从 0.97 递减到 0.18,覆盖高中低三档完整区间。

6.3 事件日志与下载记录实体

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

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

EventLog 实体记录地图长按事件的四元组:类型(Marker/POI/系统)、名称、经纬度和时间戳。构造函数在创建时自动调用 nowTime() 填充时间字段,确保每条日志都有精确到秒的创建时间。Marker 长按事件的名称为 #id 格式,POI 长按事件的名称为 POI 名称,系统事件(如监听开关)的经纬度为 0。

DownloadRecord 实体是 6.1.1 双 URL 溯源特性的数据载体,五个字段中 originalUrl 和 referrerUrl 分别对应 WebDownloadItem 的 getOriginalUrl() 和 getReferrerUrl() 两个 6.1.1 新增接口的返回值。Mock 数据预置了五条下载记录,覆盖 PDF、ZIP、XLSX、PNG 四种文件类型,每条记录都包含原始 URL(带查询参数)和引用页 URL,完整演示双溯源字段的展示形态。

6.4 WebP 元数据快照与操作日志

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

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

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

WebpMetaSnapshot 是 ImageKit WebP 元数据的快照实体,五个字段对应 WebPMetadata 接口的五属性:画布宽、画布高、钳制后帧延迟、未钳制帧延迟和循环次数。所有字段在读取时以 -1 作为 undefined 的占位值,渲染时通过 fmtField 函数转换为"未提供"文本。这个实体被读取结果和回读校验两个场景共用,通过 highlight 参数区分卡片的背景色。

MetaOpLog 记录元数据操作的全链路日志,四类操作(生成/读取/写入/回读)各自携带详情和时间戳,以 unshift 方式插入列表头部形成逆序日志流。

七、组件主体与生命周期

7.1 状态变量声明

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

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

  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;

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

  @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[] = [];
}

组件状态变量分为五大集群。第一集群是 Tab 与弹窗状态:currentTab 控制当前展示的 Tab 页,三个布尔弹窗开关控制弹窗的显示隐藏,editIdx 和 delIdx 记录当前操作的目标索引,breath 配合定时器实现呼吸动画效果。第二集群是古迹业务数据:heritageList 是可增删改的名录数组,四个 input 变量绑定弹窗的表单输入。第三集群是 Map Kit 状态:mapOptions 设置初始位置和缩放级别,mapCallback 是地图初始化回调函数,mapController 和 mapEventManager 分别是地图控制器和事件管理器,eventLogs 是长按事件日志数组,两个 Toggle 状态控制监听开关。第四集群是 ArkWeb 状态:webController 是 Web 组件控制器,downloadDelegate 是下载代理,urlInput 和 webUrl 双状态分离地址栏输入与实际加载,dlName/dlPercent/dlState 三状态联动展示下载进度,downloadRecords 是完成记录列表。第五集群是 WebP 元数据状态:pixelMap 是像素图引用,webpPath 是沙箱文件路径,genState 是生成状态文案,textureSel 是当前选中纹理,metaSnapshot 和 verifySnapshot 分别是读取结果和回读校验的快照,writeDelay 和 writeLoop 是写入控制台的参数预设,opLogs 是操作日志列表。

7.2 生命周期与三大特性初始化

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

aboutToDisappear() {
  clearInterval(this.timer);
}

aboutToAppear 生命周期在组件出现时执行三项初始化:启动 1000 毫秒间隔的呼吸定时器(切换 breath 布尔值驱动统计格变色和柱状图波动)、装配地图回调函数(定义 Map Kit 初始化完成后的 Marker 添加和长按监听注册逻辑)、绑定下载代理(注册四个下载回调并绑定到 Web 控制器)。aboutToDisappear 在组件销毁时清理定时器,防止内存泄漏。这种"生命周期统一初始化"的模式确保三大特性的回调在 Web 组件渲染前完成绑定,避免下载事件丢失。

八、头部详解

@Builder
headerMain() {
  Row({ space: 10 }) {
    Text('🏛️').fontSize(26)
    Column({ space: 2 }) {
      Text('遗产导览').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Text('文化遗产探索地图 · 长安篇').fontSize(18).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 })
}

头部 headerMain 采用 Row 横向布局,从左到右依次排列四个元素。建筑图标以 26 号字体居首,作为品牌视觉锚点。紧随其后的 Column 容器纵向排列品牌名"遗产导览"(18 号粗体宣纸暖白)和副标题"文化遗产探索地图 · 长安篇"(10 号绢帛黄),左对齐确保文字层次清晰。layoutWeight(1) 让 Column 占据中间剩余空间,将右侧元素推至行尾。

收录徽章动态展示 heritageList.length 条目数,使用鎏金色文字配深色背景的胶囊形态。最右侧的"+"号按钮是快捷收录入口,30x30 圆形(borderRadius 15)配青铜绿背景,点击时先清空表单输入再打开新增弹窗,确保每次打开弹窗都是干净的输入状态。

九、各 Tab 分析

9.1 首页 Tab:统计行与双列古迹卡

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

统计格 statCell 是首页的三等分单元,Column 纵向排列图标、数值和标签三层。数值文字的 fontColor 通过三元表达式绑定 breath 状态:呼吸周期中间值切换为鎏金色(高亮),其余时间保持绢帛黄(常态),这种 1000 毫秒周期的色彩闪烁模拟实时数据脉动的视觉效果。layoutWeight(1) 确保三个统计格在 Row 中等分宽度。

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

古迹卡 heritageCard 是双列瀑布流的核心单元,宽度设为 49% 确保两列间留有 2% 间隙。卡片顶部 Row 并排展示朝代徽章(鎏金色)和保护等级徽章(通过 levelColor 函数映射颜色),等级徽章使用 maxLines(1) 配合 Ellipsis 溢出策略确保长文本不破坏布局。中间三行依次展示名称(14 号粗体)、区域(10 号陶土灰)和文保分(青铜绿色)。底部 Row 的"编"和"删"两个操作按钮均为 22x22 圆形,编辑按钮触发 openEdit 回填表单数据后打开编辑弹窗,删除按钮设置 delIdx 后打开删除确认弹窗。

首页整体布局通过 Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) 实现双列瀑布流,ForEach 遍历 heritageList 渲染每个古迹卡,以 name_idx 组合键确保列表项唯一性。

9.2 地图 Tab:Map Kit 双长按监听

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

地图回调函数 setupMapCallback 是 Map Kit 初始化的核心逻辑,遵循"err 判空 - controller 获取 - eventManager 获取 - Marker 群添加 - 双长按监听注册"五步流程。回调首先检查 err 参数是否为空,非空则打印错误日志并提前返回。随后将传入的 mapController 保存到成员变量,并通过 getEventManager() 获取事件管理器。

Marker 群添加阶段遍历六处长安古迹标注点,为每处古迹构造 MarkerOptions 对象(位置、可点击、可见、锚点居中底部、不可拖拽、不贴地),逐个 await controller.addMarker() 添加并 try-catch 捕获异常,确保单个 Marker 添加失败不影响后续添加。

6.1.1 双长按监听注册是本平台的核心特性之一。onMarkerLongClick 回调接收 map.Marker 对象,通过 getPosition() 获取经纬度、getId() 获取标识,构造 EventLog 并 unshift 到日志列表头部。onPoiLongClick 回调接收 mapCommon.Poi 对象(仅含 id/name/position 三字段),以 ?? 空值合并运算符处理未命名 POI 的兜底文案。

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

监听开关函数 toggleMarkerListen 实现长按监听的动态开关。关闭时调用 offMarkerLongClick()(不传参即清除该类型全部订阅),开启时重新注册回调并插入系统日志。POI 监听开关 togglePoiListen 逻辑完全对称。这种设计允许用户在运行时控制监听行为,同时通过系统日志可视化监听状态的变化。

地图 Tab 的 UI 布局顶部放置两个 Toggle 开关(Marker 长按和 POI 长按),中间是全高 MapComponent 地图组件,底部是长按事件日志流卡片。日志流通过 List 容器限制高度 116 像素,空列表时展示引导文案"暂无事件:长按地图 Marker 或 POI 试一试…"。

9.3 搜索 Tab: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}),保留当前推荐`;
  }
}

搜索函数 runSearch 是 Map Kit searchByText POI 检索的封装。构造 SearchByTextParams 参数对象,设置查询关键字、定位坐标(城市中心点)、搜索半径(5000 米)和语言(中文)。异步调用 site.searchByText() 后以 ?? 空值合并处理 sites 数组,空数组时保留当前推荐列表并提示"无结果"。

搜索结果通过 sites.map() 映射为 SearchRecord 实体数组,每个站点的 name、formatAddress、distance 和 reliability 字段都经过空值兜底处理。catch 分支处理无 AGC 配置或无网络的异常情况,保留 Mock 数据并提示错误码,保证演示链路的完整性。

搜索结果卡片 searchResultCard 展示四层信息:顶部 Row 并排名称和 reliabilityScore 映射的等级标签(高/中/低相关配不同颜色),中间展示地址和距离,底部展示 reliability 分数的线性进度条和精确数值。进度条颜色用青铜绿,分数文字用鎏金色,形成"条形+数值"的双重可视化。

9.4 百科 Tab:ArkWeb 与下载代理

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

下载代理绑定函数 setupDownloadDelegate 是 ArkWeb 下载溯源的核心逻辑,注册四个回调并绑定到 Web 控制器。onBeforeDownload 在下载开始前触发,必须调用 item.start() 提供沙箱路径(通过 getHostContext().filesDir 获取应用文件目录),否则任务停在 PENDING 状态。同时通过 getSuggestedFileName() 获取建议文件名并更新 UI 状态。

onDownloadUpdated 在下载进行中持续触发,通过 getPercentComplete() 获取进度百分比并更新进度条。onDownloadFailed 在下载失败时触发,更新状态文案提示失败文件名。

onDownloadFinish 是 6.1.1 双 URL 溯源特性的核心回调,通过 getOriginalUrl() 读取原始下载地址(带查询参数)、getReferrerUrl() 读取引用页来源 URL,构造 DownloadRecord 实体并 unshift 到记录列表头部。四个回调注册完成后,通过 webController.setDownloadDelegate() 绑定到控制器,确保网页触发的下载才会进入上述回调。

百科 Tab 的 UI 布局从上到下依次为:地址栏(TextInput + 前往按钮)、快捷站点横滑列表(ForEach 渲染四个文博站点域标签)、全高 Web 组件、主动下载触发区。地址栏跳转函数 loadUrl 实现无 http(s) 前缀自动补 https:// 的逻辑,urlInput 和 webUrl 双状态分离输入与加载,确保用户输入过程中不触发 Web 组件重新加载。

9.5 下载 Tab:双 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)
}

下载记录卡片 downloadRecordCard 是 6.1.1 双 URL 溯源特性的展示单元。顶部 Row 展示文件图标、文件名和体积/完成时间。中间分两组展示双 URL:第一组用链接图标和"原始URL getOriginalUrl()"标签(鎏金色),下方以 monospace 字体展示原始 URL 值(带查询参数);第二组用文档图标和"引用页URL getReferrerUrl()"标签(青铜绿色),下方展示引用页 URL。两组 URL 的标签和值都使用 maxLines(1) 配合 Ellipsis 溢出策略,确保长 URL 不破坏卡片布局。

下载 Tab 顶部还展示进行中任务卡(文件名/状态/百分比/线性进度条),配合 dlName/dlPercent/dlState 三状态实时更新,形成"进行中任务 + 完成溯源列表"的双区域布局。

9.6 工坊 Tab:WebP 元数据读写回读

工坊 Tab 是 ImageKit 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.ImagePackerOptions = { 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}`));
  }
}

样图生成函数 genWebpFile 走"像素画 - WebP 编码 - 沙箱落盘"三步流程。首先释放旧 PixelMap 防止内存增长,然后按当前选中纹理生成 96x96 的 RGBA_8888 像素缓冲(每像素四字节,共 36864 字节),通过 pickTextureColor 函数为每个像素点计算颜色值。随后用 image.createPixelMap 从缓冲创建 PixelMap,用 image.createImagePacker 创建编码器,以 image/webp 格式和质量 90 编码为 WebP 字节流。最后通过 fileIo.openSync 以读写+创建+截断模式打开沙箱文件,写入 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}`));
  }
}

元数据读取函数 readMeta 以读写模式打开 WebP 文件,通过 image.createImageSource(file.fd) 从文件描述符创建 ImageSource,调用 6.1.1 类型化读取接口 readImageMetadataByType(参数为 [WEBP_METADATA] 类型数组和帧索引 0)。读取的 ImageMetadata 对象的 webPMetadata 属性包含五字段,每个字段都通过 ?? 空值合并运算符兜底为 -1,构造 WebpMetaSnapshot 快照。静态 WebP 通常仅提供画布尺寸,帧延迟和循环次数字段为 undefined(-1),渲染时通过 fmtField 转为"未提供"。

区块三:写入控制台

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=参数非法)`));
  }
}

元数据写入函数 writeMeta 以字面量构造 WebPMetadata 对象(官方样例同款模式),五字段分别为画布尺寸(固定 96)、帧延迟(用户选择的预设值)、未钳制帧延迟(与钳制值相同)和循环次数(用户选择的预设值)。将 WebPMetadata 挂在 ImageMetadata 上调用 writeImageMetadata 写回文件。写入成功后立即调用 verifyRead 进行回读校验,形成"写入即回读"的原子操作。catch 分支记录错误码并提示常见错误含义(7700202=不支持、7700204=参数非法)。

区块四:回读校验与操作日志

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

回读校验函数 verifyRead 重新打开文件并创建 ImageSource(先 release 再重开 fd),以与读取相同的类型化接口回读元数据,构造 verifySnapshot 快照。随后比对回读的 delayTime 和 loopCount 与写入值是否一致,一致则记录"已生效"日志,不一致则记录差异详情。回读校验卡片使用 highlight=true 参数(深色背景)与读取结果卡片(普通卡片背景)视觉区分。

工坊 Tab 的 UI 布局按四区块纵向排布,每区块以序号圆圈(①②③④)开头,区块间通过 12 像素间距和卡片背景色形成视觉分隔。第一区块包含纹理选择横滑列表、画布参数展示、PixelMap 预览和生成按钮;第二区块包含读取按钮和元数据快照卡片;第三区块包含帧延迟和循环次数两组预设选择器以及写入按钮;第四区块包含回读快照卡片(高亮背景)和操作日志列表。

9.7 我的 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]] })
    ...
  }
}

我的 Tab 顶部是渐变探访者大卡,使用 linearGradient 从青铜深色到次级容器底色的 135 度渐变,模拟青铜器受光面到暗面的过渡。大卡内依次排列:探访者身份行(建筑图标 + 昵称"长安拾遗人" + 等级"Lv.5")、三统计格(收录/踏访/里程)、等级进度条(64% 距离白银级)和升级提示文案。

下方是足迹清单,ForEach 遍历 MARKER_SPOTS 渲染每处古迹的已踏访状态行(绿色勾标记 + 名称 + 朝代形制标签 + "已踏访"状态),底部是权限与说明行,展示三大特性声明的 AboutRow 数据(地图数据需 INTERNET 权限与签名、百科与下载支持双 URL 溯源、工坊沙箱支持 WebP 元数据读写、当前版本 HarmonyOS 6.1.1 API 24)。

十、图表卡片:月度柱状图

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

月度柱状图卡片 chartCard 是首页的核心数据可视化组件,通过纯 ArkUI 组件(Column + ForEach)实现柱状图,无需引入图表库。顶部 Row 展示标题"近 6 个月探访热度"和单位"次/月"。柱状图主体通过 ForEach 遍历六个月份索引,每根柱子是一个 Column 容器,内部从上到下排列数值文字、柱体和月份标签。

柱体高度通过 VISIT_VAL[i] * 0.62 计算缩放(最大值 216 乘以 0.62 约等于 134 像素,适配 150 像素的容器高度),breath 状态联动时柱体额外加 8 像素高度模拟数据脉动效果。柱体颜色通过 i % 2 交替使用青铜绿和青铜深色,形成偶数月和奇数月的色彩交替区分。柱体顶部圆角(topLeft: 5, topRight: 5)模拟传统建筑柱头的柔和形态。整个柱状图容器使用 alignItems(VerticalAlign.Bottom) 确保所有柱体底部对齐。

十一、底部 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 栏 tabBar 是七 Tab 单排导航栏,通过 Row 容器横向排列七个 Tab 项。每个 Tab 项是 Column 容器,内部纵向排列图标(18 号字体)和标签(9 号字体),layoutWeight(1) 确保七等分宽度。选中态通过 currentTab === idx 三元表达式控制标签颜色:选中时为鎏金色(tabOn),未选中时为陶土灰色(text3)。Tab 栏整体高度 58 像素,背景色为卡片底色,上下各 6 像素内边距。点击时设置 currentTab 为当前索引,触发内容区 Builder 切换。

十二、弹窗系统

12.1 通用遮罩与表单行

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

@Builder
fieldRow(label: string, value: string, placeholder: string, onInput: (v: string) => void) {
  Column({ space: 4 }) {
    Text(label).fontSize(11).fontColor(COLORS.text3)
    TextInput({ text: value, placeholder: placeholder })
      .height(38).fontSize(12)
      .backgroundColor(COLORS.dark).fontColor(COLORS.title)
      .onChange((v: string) => { onInput(v); })
  }
  .width('100%').alignItems(HorizontalAlign.Start)
}

弹窗系统采用 Stack 层叠模式,底层是全屏半透黑遮罩(modalOverlay),点击空白处触发 onClose 回调关闭弹窗。上层是居中的卡片容器,宽度 86%(新增/编辑)或 72%(删除)。

fieldRow 是通用表单行 Builder,Column 纵向排列标签和 TextInput 输入框。TextInput 的 text 属性绑定外部状态变量,onChange 回调通过高阶函数 onInput 将输入值回传给调用方,实现数据的双向绑定。这种"Builder 接收回调参数"的设计让新增和编辑弹窗可以复用同一个表单行组件。

12.2 新增弹窗

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

新增弹窗 panelAdd 展示"收录新古迹"标题和四个表单行(古迹名称、朝代、保护等级、所在区域),底部是取消和确认收录两个按钮。确认收录按钮调用 confirmAdd 函数,该函数以 unshift 方式将新古迹添加到名录顶部,空字段自动填充默认值(朝代填"未考"、区域填"待考订"、保护等级通过 normLevel 归一化为三档标准名称),新记录的文保分固定为 60。

12.3 编辑弹窗

编辑弹窗 panelEdit 与新增弹窗结构对称,但标题改为"修订古迹档案",增加"留空的字段将保持原值不变"的提示文案。openEdit 函数在打开弹窗前将目标条目的字段值回填到输入变量,confirmEdit 函数在确认时仅覆盖非空字段,确保用户只想修改部分字段时不会意外清空其他字段。

12.4 删除弹窗

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

删除弹窗 panelDel 宽度更窄(72%),展示"移出名录"标题和包含目标古迹名称的确认文案。底部"再想想"按钮(深色背景)取消操作,"确认移出"按钮(朱砂红背景)调用 confirmDel 函数通过 splice 删除目标索引的条目。删除弹窗的朱砂红按钮与新增/编辑弹窗的青铜绿按钮形成色彩对比,强化删除操作的破坏性语义。

十三、功能模块对比表

功能模块所在 Tab核心 API/接口数据实体关键特性
地图标注地图MapComponent + addMarkerSpotItem / EventLog六处古迹 Marker 批量添加,锚点居中底部
Marker 长按监听地图onMarkerLongClick / offMarkerLongClickEventLog6.1.1 新增,记录 id 与经纬度,Toggle 动态开关
POI 长按监听地图onPoiLongClick / offPoiLongClickEventLog6.1.1 新增,参数 mapCommon.Poi,仅 id/name/position
POI 关键字搜索搜索site.searchByTextSearchRecord读取 reliability 相关性分数,三档等级映射
网页浏览百科Web + WebviewController—urlInput/webUrl 双状态分离,地址自动补 https://
下载代理百科/下载WebDownloadDelegate 四回调DownloadRecordonBeforeDownload 提供沙箱路径,start() 必须调用
双 URL 溯源下载getOriginalUrl / getReferrerUrlDownloadRecord6.1.1 新增,原始 URL 带查询参数 + 引用页 URL
应用侧下载百科webController.startDownload—无需网页内点击,应用侧直接触发
WebP 样图生成工坊createPixelMap + ImagePacker + fileIoPixelMap五种纹理像素画,RGBA_8888 编码落盘
WebP 元数据读取工坊readImageMetadataByTypeWebpMetaSnapshot6.1.1 类型化读取,-1 占位兜底 undefined
WebP 元数据写入工坊writeImageMetadataWebPMetadata6.1.1 字面量构造写回,全可选字段
回读校验工坊重建 ImageSource 回读WebpMetaSnapshot写入即回读,delayTime/loopCount 比对
古迹名录管理首页/弹窗unshift / splice / 字段覆盖HeritageItem@Observed 响应式,新增 unshift 顶部、编辑部分覆盖
色彩体系全局ColorPalette + COLORS—青铜绿主色 + 鎏金强调色,材质隐喻映射
呼吸动画全局setInterval + breath 状态—1000ms 周期,统计格变色 + 柱状图波动

十四、总结与展望

本平台以"青铜绿 + 鎏金"的文博色彩体系为视觉基底,通过七大 Tab 的差异化布局构建了文化遗产探索的完整交互闭环。在技术架构层面,三大 HarmonyOS 6.1.1 前沿特性在同一组件文件中实现了深度叠加:Map Kit 贡献了地图标注(Marker 批量添加)、双长按监听(onMarkerLongClick / onPoiLongClick)和 POI 检索(searchByText + reliability 分数)三项能力,覆盖了从空间标注到事件捕获再到语义搜索的完整地图交互链路;ArkWeb 贡献了下载代理四回调体系(onBeforeDownload / onDownloadUpdated / onDownloadFailed / onDownloadFinish)和双 URL 溯源(getOriginalUrl / getReferrerUrl)两项能力,实现了从网页触发下载到应用侧主动下载再到来源追溯的完整下载溯源链路;ImageKit 贡献了 WebP 像素画生成(createPixelMap + ImagePacker)、类型化元数据读取(readImageMetadataByType)和字面量元数据写入回读(writeImageMetadata + verifyRead)三项能力,构建了从样图生成到元数据读写再到回读校验的完整工坊实验链路。

在工程实践层面,本平台展现了多个值得借鉴的设计模式。第一是"生命周期统一初始化"模式,aboutToAppear 中一次性装配呼吸定时器、地图回调和下载代理,确保三大特性的回调在 Web 组件渲染前完成绑定,避免下载事件丢失。第二是"双状态分离"模式,地址栏的 urlInput 和 webUrl 分离输入与加载状态,确保用户输入过程中不触发 Web 组件重新加载。第三是"-1 占位兜底"模式,WebP 元数据的 undefined 字段统一用 -1 占位,渲染时通过 fmtField 函数转为"未提供"文本,统一处理空值展示。第四是"写入即回读"原子模式,writeMeta 成功后立即调用 verifyRead 进行回读校验,形成写入-回读-比对的完整验证链路。第五是"Builder 接收回调参数"模式,fieldRow 和 modalOverlay 通过高阶函数参数实现数据双向绑定和关闭行为注入,让新增和编辑弹窗复用同一套表单组件。

展望未来,本平台可在以下方向继续深化。第一是地图能力的纵向扩展,可引入 Marker 自定义图标(用古迹朝代对应的建筑剪影替代默认图标)、聚合显示(解决高缩放级别下 Marker 重叠问题)和路径规划(连接多处古迹形成探访路线)。第二是下载溯源的横向延伸,可将双 URL 溯源字段与文件哈希校验结合,实现"来源可追溯 + 内容可验证"的双重可信度保障。第三是 WebP 元数据的动画帧支持,当前样图为静态 WebP,未来可扩展为多帧动画 WebP,验证 delayTime 和 loopCount 在多帧场景下的实际表现。第四是数据持久化,当前名录数据使用内存数组管理,可引入分布式数据管理(RelationalStore)实现名录的持久化存储和多设备同步。第五是探访者画像的动态化,当前我的 Tab 的踏访次数和里程为静态数据,可接入实际地图轨迹记录实现真实探访数据的动态统计。

在 HarmonyOS 全场景战略的背景下,文博文旅应用有着广阔的延伸空间。手机端的遗产导览可向平板端扩展(利用大屏展示古迹的高清全景图和碑文拓片),向智能手表端延伸(佩戴手表踏访古迹时自动记录到达时间和位置),向车机端延伸(自驾文旅路线上语音播报沿途古迹信息)。HarmonyOS 的分布式能力天然支持这种多设备协同,@Observed 装饰器的响应式数据模型可以跨设备同步名录和探访记录,让"长安拾遗人"的探索足迹在手机、平板、手表、车机之间无缝流转。随着 ArkUI 的持续演进和 HarmonyOS NEXT 的生态成熟,文博文旅应用将迎来更丰富的交互可能和更广阔的部署场景。

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

更多推荐