一、技术前言

在数字文博与智慧文旅蓬勃发展的当下,文化遗产的探索方式正经历从"纸质导览册"到"沉浸式数字地图"的深刻变革。从大雁塔的唐风楼阁到大明宫的宫殿遗址,从碑林博物馆的石刻瑰宝到兴教寺塔的玄奘遗韵,每一处长安古迹都承载着不同的朝代信息、保护等级和地理坐标。传统文博导览应用面临三大挑战:地图标注缺乏交互深度导致用户只能被动浏览、网页下载来源不可追溯导致数字资源可信度模糊、图像元数据无法读写验证导致老照片数字档案管理缺失闭环。

HarmonyOS ArkUI 框架为这些挑战提供了系统级解决方案。ArkUI 的声明式 UI 范式通过 @Component 封装可复用组件、@State 管理响应式状态、@Builder 拆分复杂 UI 结构,天然适合"名录-地图-搜索-百科-下载-工坊-我的"七 Tab 架构。@Observed 装饰器让数据模型字段级变化被 UI 感知,实现"收录即刷新、下载即溯源、写入即回读"的流畅体验。@Entry 标注的根组件通过 Stack 容器层叠底层布局与顶层弹窗,配合 if/else 条件渲染实现 Tab 切换与弹窗系统的优雅分离。

本平台深度融合 HarmonyOS 6.1.1 的三大前沿特性。Map Kit 提供地图组件渲染与事件监听能力链——通过 MapComponent 组件初始化地图场景,MapComponentController 获取控制器句柄,MapEventManager 统一管理事件订阅,再以 onMarkerLongClickonPoiLongClick 两步实现标注与兴趣点的双长按事件捕获;同时 site.searchByText 接口配合 reliability 相关性分数字段,实现 POI 搜索结果的可信度量化展示。ArkWebWebDownloadDelegate 下载委托引入了 onBeforeDownloadonDownloadUpdatedonDownloadFailedonDownloadFinish 四回调全生命周期管理,其中 onDownloadFinish 内通过 getOriginalUrl()getReferrerUrl() 两个 6.1.1 新字段实现下载文件的双 URL 溯源——原始 URL 标记文件的真实来源,引用页 URL 追踪用户从哪个页面发起下载,为数字文博资源的版权追溯提供完整证据链。ImageKit 的 WebP 元数据读写能力通过 image.createPixelMap 生成像素画、ImagePacker.packToData 编码为 WebP 字节流并落盘,再以 readImageMetadataByType 配合 MetadataType.WEBP_METADATA 类型化读取 canvasWidthcanvasHeightdelayTimeunclampedDelayTimeloopCount 五字段快照,最后通过 writeImageMetadata 字面量构造 WebPMetadata 写回并立即重建 ImageSource 回读校验,形成"生成-读取-写入-回读"的元数据闭环。

二、整体架构流程图

数据模型层

弹窗系统层

三大核心特性

七大 Tab 模块

页面布局层

根组件层

Page1282 根组件

headerMain 头部品牌行

内容区 7 Tab 切换

tabBar 底部导航栏

弹窗系统 add/edit/del

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

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

Tab2 搜索
关键字搜索+reliability分数列表

Tab3 百科
地址栏+快捷站点+Web组件+主动下载

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

Tab5 工坊
像素画生成+WebP元数据四区块

Tab6 我的
渐变大卡+足迹清单+权限说明

Map Kit
双长按监听+reliability评分

ArkWeb
下载委托四回调+双URL溯源

ImageKit
WebP元数据读写回读闭环

panelAdd 收录新古迹

panelEdit 修订古迹档案

panelDel 确认移出名录

HeritageItem 古迹名录

SearchRecord 搜索结果

EventLog 事件日志

DownloadRecord 下载记录

WebpMetaSnapshot 元数据快照

MetaOpLog 操作日志

架构以根组件为核心,使用 Column 容器纵向排列:顶部品牌头行(含收录徽章与快捷收录按钮)、分割线、内容区与底部 Tab 栏。内容区采用差异化布局策略——地图 Tab 和百科 Tab 因需要全屏承载地图组件与 Web 组件,直接以 layoutWeight(1) 撑满高度不套 Scroll;其余五个 Tab 统一包裹在 Scroll 可滚动容器内,通过 currentTab 状态变量在 if/else 分支间切换。三大特性分散在地图(双长按监听)、百科与下载(双 URL 溯源)、工坊(WebP 元数据闭环)三个 Tab 上,弹窗系统以三个布尔状态变量(addModal/editModal/delModal)独立控制条件渲染,状态变量统一声明在组件顶层实现跨 Tab 共享。

数据模型层的六个 @Observed 类分别支撑各自业务场景:HeritageItem 是贯穿首页和弹窗系统的核心业务模型,承载古迹名录的增删改查全生命周期;SearchRecord 封装 POI 搜索结果,驱动搜索 Tab 的相关性量化展示;EventLog 记录地图长按事件,构成地图 Tab 的事件日志流;DownloadRecord 封装下载记录与双 URL 溯源信息,服务于下载 Tab 的版权追溯;WebpMetaSnapshotMetaOpLog 共同支撑工坊 Tab 的元数据读写与操作审计。

三、色彩体系设计

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;    // 青蓝(POI命中标识)
  line: string;    // 分割线
  tabOn: string;   // Tab 选中色
  mask: string;    // 弹窗遮罩
}

这段接口定义体现了 ArkTS 的类型安全优势。与普通 JavaScript 动态添加属性不同,ColorPalette 接口在编译期即约束所有颜色字段必须是 string 类型,任何拼写错误或类型不匹配都会在编译阶段暴露。接口注释采用"字段名 + 用途"的格式,使每个颜色的语义角色一目了然,后续维护者无需追踪代码即可理解色彩用途。值得注意的是,接口中并未声明 dark 字段,但在实际常量中补充了该字段——这是因为 dark 作为次级容器底色,属于实现层面的细化,开发者在实现时可按需扩展接口或使用类型断言。

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)' // 半透黑遮罩
};

色彩体系以"青铜绿 + 鎏金"为核心对比,构建了完整的深色文博视觉语言。bg#1B150F 深褐墨色,模拟博物馆暗光展厅的沉浸式氛围,使内容区域的文物信息成为视觉焦点。card#282017 深木色,比背景仅亮一档,形成柔和的卡片边界,模拟展柜木质框架的质感。dark#332A1E 更深一级的木色,用于徽章、标签、输入框底等次容器,与卡片底形成微妙的层次区分。

文字色分为三档梯度。title#F5EDDE 宣纸暖白,主标题色,与深色背景形成高对比度但不刺眼,模拟宣纸在暗光下的温润光泽。sub#C8B99C 绢帛黄,副标题和正文色,在标题与弱文本之间架起层次过渡,色调偏暖符合文博主题。text3#8F8266 陶土灰,三级弱文本,用于辅助说明、时间戳和占位文字,视觉权重最低,避免干扰主要信息。

主色与强调色构成语义化配色系统。bronze#5E9C8B 青铜绿,平台主色,象征千年青铜器的锈蚀质感,贯穿按钮、进度条、柱状图、青铜级标签等核心交互元素。bronzeD#46786A 青铜深色,用于渐变起点和奇偶交替的柱状图,形成斑驳的金属质感。gold#D9A441 鎏金强调色,用于文保评分、等级标签、Tab 选中态和高光数据,是整个深色界面中最醒目的视觉锚点。red#C0392B 朱砂红,仅用于全国重点文物保护单位徽章和删除操作按钮,通过低频使用强化警示语义——最高保护等级与最危险操作共用红色,形成"珍贵即危险"的视觉心理暗示。blue#6B8FBF 青蓝,专用于 POI 命中标识,与主绿色形成冷暖对比。

辅助色承担结构功能。line#3A3122 褐黑分割线,低对比度不干扰内容阅读,同时复用为 Canvas 网格线。值得特别关注的是 tabOn 使用鎏金色而非青铜绿主色——这是经过深思熟虑的设计决策:鎏金在深褐背景上的对比度远高于青铜绿,用户切换 Tab 时能瞬间定位当前位置,提升导航效率。mask 为半透明黑色 rgba(0,0,0,0.6),弹窗遮罩使用 60% 透明度,既保证底层内容的模糊可见性以维持空间感,又足够暗以使弹窗内容成为焦点。

3.3 色彩语义映射体系

整个应用的色彩使用遵循严格的语义映射规则,确保用户无需阅读文字即可通过颜色直觉判断信息等级。保护等级采用三色梯度:全国重点文物保护单位使用朱砂红(最高警示级别,如同文物上的红色印章)、省级使用青铜绿(中等识别度,与主色一致)、市县级使用陶土灰(弱化处理,降低视觉权重)。搜索相关性同样采用三色梯度,但映射关系不同:高相关使用鎏金(最匹配结果用最醒目的颜色)、中相关使用青铜绿(主色表示合格)、低相关使用陶土灰(弱化处理)。这种"同一颜色在不同场景有不同语义"的设计,要求色彩系统必须配合上下文标签使用,避免用户产生混淆。

四、Tab 元数据与辅助数据

4.1 底部导航 Tab 定义

应用采用 7 Tab 单排底部导航,每个 Tab 拥有完全不同的布局风格和交互逻辑,通过 TabMeta 接口约束图标与标签字段,实现导航元数据与 UI 渲染的解耦:

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

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

TabMeta 接口定义了 Tab 导航项的最小数据结构:icon 为 emoji 字符串,利用 Unicode 字符天然支持多尺寸渲染且无需额外图片资源;label 为中文标签文字。TAB_LIST 常量数组按顺序声明七个 Tab 项,分别对应首页、地图、搜索、百科、下载、工坊和我的。

七个 Tab 覆盖文博探索的完整用户旅程:首页作为信息聚合入口,展示收录名录与探访统计,让用户一眼掌握全局;地图承载 Map Kit 交互能力,是空间探索的核心场景;搜索提供 POI 相关性量化检索,帮助用户快速定位目标古迹;百科嵌入 ArkWeb 浏览与下载能力,连接线上文博资源;下载记录所有下载任务与双 URL 溯源信息,构建数字资源的版权证据链;工坊运行 ImageKit WebP 元数据沙箱,提供老照片数字档案的实验性功能;我的汇总探访者画像与权限声明,完成用户身份闭环。

这种将导航元数据与 UI 渲染分离的设计使 Tab 配置可独立维护,新增或调整 Tab 只需修改数组而无需触碰 @Builder 方法。底部导航栏在 tabBar() 构建器中通过 ForEach 遍历此数组渲染,选中态通过 currentTab 索引与 index 比较判断。值得注意的是,七个 Tab 单排在 58px 高度的 Tab 栏中,每个 Tab 平均仅分配约 14% 的宽度,因此使用 18px emoji + 9px 文字的紧凑布局,确保在小屏设备上仍有足够的触摸热区。

4.2 地图标注与站点常量

地图中心点定位于西安钟楼一带(纬度 34.3416、经度 108.9398),作为 Map Kit 定位与搜索的基准坐标。六处长安古迹标注点涵盖大雁塔、小雁塔、西安城墙、碑林博物馆、大明宫遗址和兴教寺塔,每处携带名称、经纬度与朝代标签信息:

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

CITY_CENTER 定义了西安钟楼附近的经纬度坐标,作为地图初始视野中心和 POI 搜索的 location 基准点。初始缩放级别设为 12,恰好能将六处古迹标注点纳入视野范围。SpotItem 接口定义了门店标注点的四字段结构,name 为古迹名称、lat/lng 为经纬度坐标、tag 为朝代与建筑类型的组合标签。MARKER_SPOTS 数组包含六处长安古迹的模拟数据,覆盖唐代楼阁式砖塔、密檐式砖塔、石刻碑林、宫殿遗址、玄奘墓塔和明代城垣遗存六种业态,充分展现长安作为十三朝古都的丰富文化遗产。

这些数据在 setupMapCallback 中通过 mapController.addMarker 批量添加到地图上,每个 Marker 携带十个显式属性(clickable、visible、rotation、zIndex、alpha、anchorU、anchorV、draggable、flat),确保标注行为的精确可控。同时,MARKER_SPOTS 数据还被"我的"Tab 的足迹清单复用,实现一处定义多处使用的数据共享。

百科快捷站点预置四个文博类真实站点,用户可一键跳转浏览:

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

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

四个快捷站点分别对应国家文物局、故宫博物院、陕西历史博物馆和中国考古网,均为文博领域的权威官方平台。RESOURCE_DL_URL 为主动下载测试资源,指向国家文物局网站上的古建筑图谱 PDF 文件,用于演示 webController.startDownload() 从应用侧直接发起下载的能力。

4.3 工坊与图表常量

工坊 Tab 的 WebP 编码参数包括编码质量、画布尺寸、帧延迟预设和循环次数预设:

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

WEBP_QUALITY 设为 90,处于 0 到 100 质量区间的高端,保证生成的 WebP 老照片样图有足够的清晰度,同时文件体积不会过大。CANVAS_SIZE 设为 96 像素见方,是兼顾预览清晰度与生成速度的折中选择——96x96 共 9216 像素,逐像素计算纹理颜色的循环可在毫秒级完成。DELAY_PRESETS 提供 120ms、200ms、500ms 三档帧延迟预设,均在 WebP 规范要求的合法区间 [100, 65535] 内,用户可通过点击切换写入参数。LOOP_PRESETS 提供不限(0)、1次、3次、5次四档循环次数预设,其中 0 表示无限循环,这是 WebP 动画的标准约定。

老照片纹理五选一通过 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: '同心环' }
];

五种纹理分别模拟不同的文物材质质感:斑驳纹理用对角斜纹模拟壁画的岁月剥落,雕花纹理用棋盘格模拟古建筑门窗的雕花格扇,夯土纹理用横带层理模拟城墙夯土层的水平肌理,碑刻纹理用竖带刻痕模拟石碑的竖向刻痕,年轮纹理用同心环模拟古树或青铜器物的环状纹路。每种纹理都有对应的 key(程序标识)、label(中文显示名)和 note(算法说明),形成从代码到 UI 的完整映射。

月度探访量数据由三个平行数组驱动柱状图渲染:

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

MONTH_IDX 为索引数组,用于 ForEach 的 key 生成和数据定位;MONTH_NAME 为月份名称数组,显示在柱状图横轴;VISIT_VAL 为探访量数值数组,驱动柱体高度。数据呈现明显的季节性规律:3月126次起步,4月降至98次(可能受春季假期影响),5月开始攀升至154次,6月183次,7月达到峰值216次(暑期旅游旺季),8月回落至172次。这种真实感的数据曲线让柱状图更具说服力。

4.4 弹窗选项与说明行常量

弹窗保护等级三档提供了归一化后的标准取值,用于输入提示和下拉选项:

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

这三档标准名称与 normLevel 归一化函数的输出严格对应,确保用户输入的各种简写(如"国保"“省保”“市保”"国家级"等)都能被正确映射到标准名称。

我的 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 需要 INTERNET 权限和应用签名)、百科与下载(标注 ArkWeb 双 URL 溯源特性自 API 24 起支持)、工坊沙箱(说明 Image Kit WebP 元数据读写能力)和当前版本(显示 HarmonyOS 版本号和 API 等级)。这些信息对于开发者了解应用的技术栈和权限要求非常重要,也体现了文博应用对数据来源可追溯性的重视。

五、工具函数分析

本应用在组件外部定义了七个纯函数,负责颜色转换、数据格式化、等级映射和时间生成等通用逻辑,保持组件内部关注 UI 渲染而非数据处理,遵循"关注点分离"的设计原则。

5.1 相关性分数评级函数

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

该函数将 searchByText 返回的 reliability 字段(0 到 1 浮点数)映射为三档视觉等级。ScoreInfo 接口定义了返回值结构,label 为中文等级标签,color 为对应颜色值。函数内部采用两道阈值的分级策略:0.8 以上为"高相关"配鎏金色(最醒目的强调色,表示匹配度最高)、0.5 以上为"中相关"配青铜绿(主色,表示合格匹配)、其余为"低相关"配陶土灰(弱文本色,表示匹配度较低)。

这种分级策略让用户无需阅读具体数值即可快速判断搜索结果的可信度,是 Map Kit reliability 字段在 UI 层落地的关键桥梁。返回的对象同时携带标签文字和颜色值,调用方可直接将 label 渲染为胶囊文字、color 渲染为胶囊文字色和进度条颜色,实现数据到视图的一步映射。在搜索结果卡片中,该函数被调用两次——一次用于等级标签的文字和颜色,一次隐式驱动 Progress 组件的颜色(使用青铜绿主色统一进度条视觉)。

5.2 保护等级颜色映射函数

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

levelColor 函数将三档保护等级映射为语义化颜色。全国重点文物保护单位对应朱砂红(最高警示级别,如同文物上的红色印章,象征最高等级的保护地位)、省级对应青铜绿(中等识别度,与应用主色保持一致)、市县级对应陶土灰(弱化处理,降低视觉权重)。这种三色梯度与徽章系统的视觉语言保持一致,用户在首页双列卡、搜索结果、足迹清单等多个场景中都能通过颜色直觉判断文保等级。

函数采用精确字符串匹配而非 includes 模糊匹配,这是因为输入值已经过 normLevel 函数归一化为标准名称,精确匹配的性能更高且不会产生误判。若未来新增保护等级,只需在函数中增加对应的 if 分支即可。

5.3 十六进制转 RGBA 整数函数

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

此函数将 #RRGGBB 格式的十六进制颜色字符串转换为 0xFFBBGGRR 格式的 32 位整数。转换逻辑按字节序排列:使用 slice 方法分别提取 R、G、B 三个两位十六进制子串,通过 parseInt(..., 16) 转换为十进制整数。返回值的构造使用位运算:Alpha 通道固定为 255(0xFF)占据最高 8 位,B 分量左移 16 位,G 分量左移 8 位,R 分量在最低 8 位。

需要特别注意的是字节序问题——函数返回的是 0xFFBBGGRR 格式(ARGB 顺序,但以 B、G、R 的顺序排列),这是因为 RGBA_8888 格式的像素缓冲区在小端序系统上的内存布局恰好是 R、G、B、A 从低地址到高地址排列。当使用 Uint32Array 视图写入时,每个 32 位整数的字节会按小端序展开,因此需要以反向顺序(B、G、R、A)构造整数,才能在内存中得到正确的 RGBA 排列。

该函数服务于工坊 Tab 的像素画生成——pickTextureColor 方法将颜色调色板的十六进制值批量转换为 RGBA_8888 缓冲区所需的整数像素值,是 ImageKit 像素级操作的基础工具。每调用一次生成 9216 个像素(96x96 画布),该函数会被执行相同次数,因此其性能直接影响像素画生成速度。

5.4 元数据字段格式化函数

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

fmtField 函数将元数据字段值格式化展示。WebP 元数据的五个字段(canvasWidth、canvasHeight、delayTime、unclampedDelayTime、loopCount)均为可选字段,当某字段不存在时,代码中使用 -1 作为 undefined 的占位符。该函数判断值是否小于 0,若是则返回"未提供"文本,否则将数值与单位拼接。

这一设计让 WebP 元数据的可选字段在 UI 上有了统一的兜底表现。静态 WebP 文件通常只提供画布尺寸,帧延迟和循环次数字段不存在,此时会显示"未提供"而不是显示 -1,避免用户困惑。函数接收 unit 参数使调用方可以灵活指定单位(如"px"“ms”" 次"),提高了函数的复用性。

5.5 文件大小格式化函数

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

formatSize 函数将字节数转为可读体积文本,采用三档自适应策略:大于等于 1MB(1048576 字节)时以 MB 为单位保留一位小数,大于等于 1KB(1024 字节)时以 KB 为单位保留一位小数,否则直接显示字节数。该函数在下载记录卡片中使用,将 fileSize 字段(以字节为单位的整数)转换为用户友好的显示格式。

函数使用 1024 作为进制单位(二进制前缀),而非 1000(十进制前缀),这符合计算机存储的传统表示方式。保留一位小数的设计在精度与简洁性之间取得平衡,既不会因为整数显示导致信息丢失,也不会因为多位小数造成视觉杂乱。

5.6 URL 域名提取函数

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

hostOf 函数从完整 URL 提取域名作为快捷站点标签。实现方式简洁高效:先通过两次 replace 移除 https://http:// 前缀,再以 / 为分隔符分割字符串并取第一个元素,即为主机名。

该函数用于百科 Tab 的快捷站点横滑行,将四个完整 URL 转换为简洁的域名标签显示在界面上。用户点击域名标签即可跳转到对应网站,既保持了界面的简洁性,又能让用户一眼识别站点身份。虽然实现方式较为基础(未处理端口号、URL 编码等边缘情况),但对于预置的四个标准官方站点完全够用,体现了"够用即最好"的工程哲学。

5.7 当前时间戳生成函数

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

nowTime 函数获取当前时刻并格式化为 HH:mm:ss 字符串。通过 String(...).padStart(2, '0') 实现两位补齐——当小时、分钟或秒为个位数时,在前面补零,确保始终显示两位数字。例如 9 点 5 分 3 秒会显示为 “09:05:03” 而非 “9:5:3”。

该函数在三个独立场景共用:地图长按事件日志的时间戳、下载记录的完成时间、工坊操作日志的时间戳。统一的时间格式保证了全应用时间展示的一致性,同时将时间生成逻辑抽离为纯函数也便于测试和维护。值得注意的是,EventLogMetaOpLog 的构造函数会自动调用 nowTime() 填充时间戳,使创建时间与实例化时刻严格绑定,避免了手动传参可能导致的时间不一致问题。

六、数据模型层

数据模型是整个应用的业务核心,所有 UI 渲染和交互逻辑都围绕数据模型展开。本应用采用六个 @Observed 类分别封装不同业务域的数据实体,每个类都具备字段级可观察能力,当实例属性变更时自动触发关联 UI 的刷新。

6.1 古迹名录实体 HeritageItem

@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 装饰器修饰,表示该类的实例具备字段级可观察性。当任何实例的属性发生变化时(如编辑后修改名称、朝代或等级),所有引用该实例的 @State 数组会收到通知并触发 UI 刷新。

五个属性分别对应古迹的五维信息:name 为古迹名称(如"大雁塔"“西安城墙”),dynasty 为建造朝代(如"唐"“明”“西汉”),level 为保护等级(三档标准名称),region 为所在区域(格式为"城市·区县"),score 为文保评分(0 到 100 的整数,数值越高代表文物价值越大)。构造函数逐字段赋值,确保实例创建时所有字段都被正确初始化。

export 关键字使该类可被其他文件引用,便于在多页面应用中共享数据模型。初始 Mock 数据预置八处长安古迹:

const HERITAGE_LIST: HeritageItem[] = [
  new HeritageItem('大雁塔', '唐', '全国重点文物保护单位', '西安·雁塔区', 98),
  new HeritageItem('西安城墙', '明', '全国重点文物保护单位', '西安·碑林区', 95),
  new HeritageItem('汉长安城遗址', '西汉', '全国重点文物保护单位', '西安·未央区', 93),
  new HeritageItem('兴教寺塔', '唐', '全国重点文物保护单位', '西安·长安区', 88),
  new HeritageItem('香积寺善导塔', '唐', '省级文物保护单位', '西安·长安区', 76),
  new HeritageItem('八仙宫', '清', '省级文物保护单位', '西安·碑林区', 72),
  new HeritageItem('大学习巷清真寺', '明', '市县级文物保护单位', '西安·莲湖区', 64),
  new HeritageItem('高家大院', '明', '市县级文物保护单位', '西安·莲湖区', 61)
];

八条初始数据覆盖唐、明、西汉、清多个朝代和三档保护等级,文保分从 61 到 98 不等,为应用提供完整的演示链路。数据分布经过精心设计:四处全国重点文物保护单位(最高档)、两处省级(中间档)、两处市县级(最低档),比例合理;朝代分布以唐代为主(四处),符合长安作为唐代都城的历史定位;区域覆盖雁塔区、碑林区、未央区、长安区、莲湖区五个行政区,展现西安全域文化遗产分布。文保分数与保护等级正相关但非严格对应——同一等级内的分数差异体现了文物个体价值的细微差别。

6.2 POI 搜索结果实体 SearchRecord

@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 封装 POI 搜索结果条目。name 为地点名称,address 为格式化地址,distance 为直线距离(单位:米),reliability 是最关键的设计——它直接对应 Map Kit site.Site 对象上的同名属性,取值范围 0 至 1,量化衡量搜索结果与关键字的匹配程度。

初始 Mock 数据预置七条雁塔区周边搜索结果:

const SEARCH_LIST: SearchRecord[] = [
  new SearchRecord('大雁塔', '西安市雁塔区雁塔南路大慈恩寺内', 850, 0.97),
  new SearchRecord('大雁塔北广场音乐喷泉', '西安市雁塔区广场东路', 900, 0.86),
  new SearchRecord('唐大慈恩寺遗址公园', '西安市雁塔区雁塔南路', 950, 0.74),
  new SearchRecord('陕西历史博物馆', '西安市雁塔区小寨东路91号', 2100, 0.69),
  new SearchRecord('大兴善寺', '西安市雁塔区兴善寺西街', 1600, 0.41),
  new SearchRecord('青龙寺遗址', '西安市雁塔区西影路铁炉庙村', 3200, 0.33),
  new SearchRecord('曲江池遗址公园', '西安市雁塔区曲江池东路', 3800, 0.18)
];

七条数据的 reliability 值从 0.97 递减到 0.18,完整覆盖高(≥0.8,两条)、中(≥0.5,两条)、低(<0.5,三条)三档评级,配合 reliabilityScore 函数实现色彩分级展示。距离值从 850 米到 3800 米不等,与相关性分数并非完全正相关——距离近的不一定相关性高,这真实反映了 POI 搜索的实际情况:搜索"古迹"时,"大雁塔"虽然距离稍远但相关性最高(0.97),因为它是最著名的古迹;而"大兴善寺"距离更近但相关性只有 0.41,因为它与"古迹"关键词的匹配度相对较低。这种数据设计让用户理解 reliability 不是距离的倒数,而是独立的匹配质量指标。

6.3 长按事件日志实体 EventLog

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

EventLog 记录地图长按事件。type 区分三种事件来源:'Marker'(标注点长按)、'POI'(兴趣点长按)、'系统'(系统级状态变更,如监听开关切换)。name 为标记 ID 或 POI 名称,Marker 类型以 # 前缀标识 ID(如 #marker_0),POI 类型为地点名称。lat/lng 为触发位置的经纬度,系统事件的经纬度设为 0,0(无实际地理意义)。time 为触发时刻,构造函数自动调用 nowTime() 填充,确保事件时间与创建时刻严格绑定。

该实体由 onMarkerLongClickonPoiLongClick 两个回调生产,以 unshift 方式插入日志列表头部,确保最新事件始终置顶展示。日志流采用滑动窗口策略(虽然代码中未显式限制长度,但在实际生产环境中通常会限制最大条数防止内存无限增长),使用户可以看到最近的交互历史,同时不会因日志过多而影响性能。

6.4 下载记录实体 DownloadRecord

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

DownloadRecord 封装下载记录的五维信息,其中 originalUrlreferrerUrl 是 HarmonyOS 6.1.1 的双 URL 溯源字段——这是整个下载模块最核心的技术亮点。originalUrl 通过 getOriginalUrl() 获取文件的真实下载地址,标记文件的来源服务器;referrerUrl 通过 getReferrerUrl() 获取用户发起下载时所在页面的 URL,追踪下载行为的触发上下文。这两个字段联合使用,为数字文博资源的版权追溯和来源验证提供完整证据链。

初始 Mock 数据预置五条下载记录:

const DOWNLOAD_LIST: DownloadRecord[] = [
  new DownloadRecord('gujianzhu-atlas-2026.pdf', 8642300, '09:12:36',
    'https://www.ncha.gov.cn/upload/file/gujianzhu-atlas-2026.pdf?zone=north',
    'https://www.ncha.gov.cn/col/col2446/index.html'),
  new DownloadRecord('datang-3d-tour.zip', 52438912, '10:02:11',
    'https://www.dpm.org.cn/resource/datang/datang-3d-tour.zip?ver=2.4',
    'https://www.dpm.org.cn/explore/tang-dynasty.html'),
  new DownloadRecord('stele-rubbings-vol3.pdf', 12740864, '11:47:58',
    'https://www.kaogu.cn/download/stele-rubbings-vol3.pdf?src=cn',
    'https://www.kaogu.cn/zixun/zhenti/2026.html'),
  new DownloadRecord('citywall-survey.xlsx', 2097152, '13:20:04',
    'https://www.sxhm.com/attach/citywall-survey-2026.xlsx?token=8f3k',
    'https://www.sxhm.com/research/survey.html'),
  new DownloadRecord('hancheng-guji-map.png', 4587520, '15:33:40',
    'https://www.ncha.gov.cn/upload/img/hancheng-guji-map.png?dpi=300',
    'https://www.ncha.gov.cn/gujianzhu/shaanxi.html')
];

五条记录涵盖 PDF(古建筑图谱、碑刻拓片)、ZIP(大唐 3D 漫游资源包)、XLSX(城墙测绘数据表)、PNG(韩城古迹地图)多种文件类型,文件大小从 2MB 到 50MB 不等,每条均携带完整的双 URL 信息。原始 URL 包含查询参数(如 zone=northver=2.4token=8f3k),真实反映了 Web 下载链接的常见形式;引用页 URL 指向具体的栏目页面,清晰展示了用户从哪个页面发起的下载。

6.5 WebP 元数据快照实体 WebpMetaSnapshot

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

WebpMetaSnapshot 封装 WebP 五字段快照。五个字段分别为:canvasWidth 画布宽度(像素)、canvasHeight 画布高度(像素)、delayTime 钳制后帧延迟(毫秒,经过 WebP 规范最小 100ms 钳制后的值)、unclampedDelayTime 未钳制帧延迟(原始写入值)、loopCount 循环次数(0 表示无限循环)。

所有字段以 -1 作为 undefined 的占位符,渲染时通过 fmtField 函数转为"未提供"文本。使用 -1 而非 null 或 undefined 是因为 @Observed 类的属性需要是具体类型,使用数值类型的特殊值作为哨兵可以简化类型声明和比较逻辑。

该实体同时服务于读取结果和回读校验两个场景——metaSnapshot 存储读取结果,verifySnapshot 存储回读校验结果,两者类型相同,可以使用同一个 metaCard 构建器渲染,通过 highlight 参数区分高亮样式。

6.6 元数据操作日志实体 MetaOpLog

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

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

MetaOpLog 封装元数据操作日志。op 为操作类型,包括"生成样图"“读取元数据”“写入元数据”"回读校验"四类;detail 为操作详情,描述具体的操作内容和结果;time 为操作时间戳,构造函数自动调用 nowTime() 填充。

操作日志构成工坊 Tab 的全链路操作追踪,用户每执行一步操作都会在日志流中留下一条记录,形成"生成样图 → 读取元数据 → 写入元数据 → 回读校验"的完整审计链路。日志以 unshift 方式插入列表头部,最新操作始终置顶,使用户可以直观地看到操作的时间顺序和因果关系。

七、组件主体结构

根组件 Page1282 是整个应用的入口和核心控制器,承载了所有状态管理、业务逻辑和 UI 构建。组件采用声明式架构,通过 @Entry 标记为页面入口,@Component 声明为可复用组件。状态变量按业务域分组声明,生命周期方法负责初始化与清理,build 方法统筹整体布局,二十余个 @Builder 方法拆分各功能模块的 UI 构建。

7.1 状态变量分层设计

根组件的状态变量按业务域分为六组,每组内部又按响应式需求选择是否使用 @State 装饰器。这种分层设计使状态管理清晰有序,避免了"一锅粥"式的状态声明。

第一组:Tab 与弹窗状态。 控制全局导航和弹窗显隐,是最高层级的 UI 状态:

@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(首页),是整个内容区切换的核心状态。三个布尔弹窗开关(addModal/editModal/delModal)分别控制新增、编辑、删除三个弹窗的显隐,初始均为 false。editIdxdelIdx 记录当前编辑或删除的目标索引,-1 表示无选中目标。breath 为呼吸动画状态布尔值,每秒翻转一次,驱动统计格数值变色和柱状图柱高微波动。timer 为定时器 ID,不使用 @State 因为定时器 ID 本身不需要触发 UI 刷新——它只是一个用于清理的句柄。

第二组:古迹业务数据与面板输入。 承载核心业务数据和弹窗表单的临时状态:

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

heritageList 为古迹名录数组,使用 HERITAGE_LIST.slice() 创建副本而非直接引用,确保对列表的增删改操作不会影响原始 Mock 数据,便于重置和数据隔离。四个 input 开头的字符串变量为弹窗表单输入缓存,分别对应古迹名称、朝代、保护等级和所在区域,新增和编辑弹窗共用这组状态变量——打开新增弹窗前调用 clearInputs() 清空,打开编辑弹窗前调用 openEdit() 回填目标条目字段。

第三组: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,使用 private 修饰因为它是静态配置不需触发 UI 刷新。mapCallback 为地图初始化回调函数,在 MapComponent 组件准备就绪时被调用。mapControllermapEventManager 在回调中赋值,分别用于 Marker 操作和事件订阅。eventLogs 为事件日志数组,驱动地图 Tab 的日志流 UI 刷新。markerListenOnpoiListenOn 为两个长按监听开关状态,驱动 Toggle 组件的选中态显示,默认均为开启。

第四组:POI 搜索状态。 管理搜索输入、结果列表和搜索状态:

@State queryInput: string = '古迹';
@State searchRecords: SearchRecord[] = SEARCH_LIST.slice();
@State searchState: string = '待搜索';

queryInput 绑定搜索框输入值,默认值为"古迹",与 Mock 数据的场景一致。searchRecords 为搜索结果数组,初始使用预置 Mock 数据保证页面打开即有内容展示,真实搜索时会被接口返回数据覆盖。searchState 为搜索状态文案,在"待搜索"“搜索中…”“返回 N 条结果”"搜索失败"等多种状态间切换,为用户提供明确的操作反馈。

第五组:ArkWeb 状态。 管理 Web 浏览、下载委托和溯源记录:

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

webController 为 Web 组件控制器,用于加载 URL、发起下载等操作,使用 private 因为它是命令式对象不需响应式追踪。downloadDelegate 为下载委托对象,注册四个回调后绑定到控制器。urlInputwebUrl 采用双状态分离设计——前者绑定地址栏输入框(用户输入时实时变化),后者驱动 Web 组件实际加载(点击"前往"后才更新),避免用户输入过程中 Web 组件频繁重加载。dlNamedlPercentdlState 分别为下载文件名、进度百分比和状态文案,驱动下载 Tab 的进行中任务卡。downloadRecords 为下载完成记录数组,初始使用 Mock 数据。

第六组: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[] = [];

这组状态最为复杂,支撑工坊 Tab 的四区块功能。pixelMap 存储生成的像素图对象,用于预览显示,类型为可空的联合类型(未生成时为 undefined)。webpPath 为 WebP 文件的沙箱路径,生成成功后填充。genState 为生成状态文案,在"待生成"“生成中…”“已生成 X KB”"生成失败"间切换。textureSel 为当前选中的纹理 key,默认值 'mottle'(斑驳)。metaSnapshotverifySnapshot 分别为读取结果和回读校验的元数据快照。writeDelaywriteLoop 为写入控制台的帧延迟和循环次数选择值。opLogs 为操作日志数组,记录四步操作的完整链路。

7.2 生命周期方法

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

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

aboutToAppear 生命周期在组件创建后、build 执行前调用,承担三项初始化职责。第一项是启动呼吸动画定时器:使用 setInterval 每 1000 毫秒翻转一次 breath 状态,驱动统计格数值颜色切换和柱状图柱高微波动,营造"实时刷新"的视觉氛围。定时器 ID 保存到 timer 变量供后续清理。

第二项是装配地图回调 setupMapCallback():该方法内部定义地图初始化完成后的回调函数,包括批量添加 Marker、注册双长按监听等操作。虽然 MapComponent 组件的实际渲染发生在 build 之后、用户切换到地图 Tab 时,但回调函数的定义必须在组件挂载阶段完成,否则当地图首次渲染时可能找不到回调引用。

第三项是绑定下载委托 setupDownloadDelegate():该方法注册下载委托的四个回调并绑定到 Web 控制器。与地图回调类似,下载委托必须在 Web 组件渲染前完成绑定,才能确保网页内触发的下载事件被正确捕获。

aboutToDisappear 生命周期在组件销毁前调用,负责清理呼吸动画定时器,防止内存泄漏。这是"初始化集中化、清理对称化"的设计原则——在 aboutToAppear 中创建的资源,必须在 aboutToDisappear 中对应释放。虽然代码中仅清理了定时器,但在生产环境中还应考虑释放地图控制器、Web 控制器和 PixelMap 等资源。

7.3 根构建方法

build() {
  Column() {
    this.headerMain()
    Divider().strokeWidth(1).color(COLORS.line)
    if (this.currentTab === 1) {
      this.tabMap()
    } else if (this.currentTab === 3) {
      this.tabWeb()
    } else {
      Scroll() {
        Column({ space: 12 }) {
          if (this.currentTab === 0) {
            this.tabHome()
          } else if (this.currentTab === 2) {
            this.tabSearch()
          } else if (this.currentTab === 4) {
            this.tabDownload()
          } else if (this.currentTab === 5) {
            this.tabStudio()
          } else if (this.currentTab === 6) {
            this.tabMine()
          }
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 12, bottom: 12 })
      }
      .layoutWeight(1)
      .width('100%')
      .scrollBar(BarState.Off)
    }
    this.tabBar()
    if (this.addModal) {
      this.panelAdd(() => {
        this.addModal = false;
      })
    }
    if (this.editModal) {
      this.panelEdit(() => {
        this.editModal = false;
      })
    }
    if (this.delModal) {
      this.panelDel(() => {
        this.delModal = false;
      })
    }
  }
  .backgroundColor(COLORS.bg)
  .height('100%')
}

主布局采用 Column 容器纵向排列,从顶部到底部分别为:头部品牌行、分割线、内容区、底部 Tab 栏。背景色设为深褐墨色 COLORS.bg,高度撑满全屏。

内容区采用差异化渲染策略,这是整个布局设计的核心亮点。地图 Tab(索引 1)和百科 Tab(索引 3)不套 Scroll 容器,直接以 layoutWeight(1) 撑满剩余高度——这是因为 MapComponentWeb 组件需要明确的有界高度才能正确渲染,若内嵌在 Scroll 容器中会因高度无限而无法正常显示。其余五个 Tab 统一包裹在 Scroll 可滚动容器内,内部 Column 间距 12,内边距 12px 四边,关闭滚动条(scrollBar(BarState.Off))保持界面整洁。

五个 Scroll 内 Tab 通过 if/else 链判断 currentTab 状态切换渲染,每次只渲染一个 Tab 的内容。这种"条件渲染 + 单 Scroll 复用"的策略相比"七个 Scroll 各自独立"的方案有两大优势:一是内存占用更低,同一时刻只有一个 Tab 的 UI 树存在;二是滚动位置天然隔离,切换 Tab 后回到顶部。

弹窗系统通过三个独立的 if 条件渲染,每个弹窗接收一个 onClose 回调函数用于关闭自身(将对应的 Modal 状态设为 false)。三个弹窗状态变量相互独立,理论上可同时打开多个弹窗,但实际交互中通过按钮逻辑保证每次只打开一个。弹窗使用 Stack 层叠布局实现遮罩层 + 内容面板的结构,点击遮罩层空白处即可关闭。

八、头部区域详解

头部 headerMain 是整个应用的品牌展示区和快捷操作入口,采用 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 })
}

品牌图标使用 26px 的古典建筑 emoji(🏛️),与应用的文博主题高度契合,同时无需额外图片资源。品牌名称"古迹行记"使用 18 号粗体宣纸暖白色,副标题"文化遗产探索地图 · 长安篇"使用 10 号绢帛黄色,两行文字通过 Column 纵向排列,alignItems(HorizontalAlign.Start) 左对齐。

品牌列使用 layoutWeight(1) 占满中间剩余空间,将左右两侧的元素推到两端——这是 ArkUI 中实现"两端对齐 + 中间自适应"布局的经典模式。

收录计数徽章动态显示当前名录条目数,使用鎏金色文字配深木色背景,圆角 10px,文字大小 10px。徽章内容通过模板字符串绑定 this.heritageList.length,当用户新增或删除古迹时,徽章数字会自动更新,实现"收录即刷新"的响应式体验。

快捷收录按钮为圆形青铜绿背景,中心是鎏金色的加号文字,尺寸 30x30px,圆角 15px(即正圆)。点击按钮执行两个操作:先调用 clearInputs() 清空输入面板的四个字段(避免上次编辑的残留值干扰新增),再将 addModal 设为 true 打开新增弹窗。这个按钮是用户录入新古迹的主入口,放置在头部最右侧的醒目位置,符合移动端"操作按钮靠右"的交互习惯。

整个头部区域使用 14px 左右内边距和 10px 上下内边距,保证内容与屏幕边缘有足够的呼吸空间,同时不过多占用纵向空间。

九、各 Tab 深度分析

七个 Tab 每个都有完全不同的布局风格和交互逻辑,从信息展示到空间探索,从资源检索到实验工坊,覆盖了文博探索的完整用户旅程。以下逐个 Tab 进行深度解析。

9.1 首页 Tab:统计与名录

首页是用户进入应用的第一屏,承担信息聚合和全局导航的角色。由三部分纵向排列:统计行(三个统计格)、双列古迹卡(Flex 瀑布流)、月度探访量柱状图。

@Builder
tabHome() {
  Column({ space: 12 }) {
    Row({ space: 8 }) {
      this.statCell('🏛️', `${this.heritageList.length}`, '收录古迹')
      this.statCell('📍', `${MARKER_SPOTS.length}`, '地图标注')
      this.statCell('🧭', '128.6', '探索里程')
    }
    .width('100%')
    Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
      ForEach(this.heritageList, (item: HeritageItem, idx: number) => {
        this.heritageCard(item, idx)
      }, (item: HeritageItem, idx: number) => `${item.name}_${idx}`)
    }
    .width('100%')
    this.chartCard()
  }
  .width('100%')
}

首页采用三层信息架构:顶层统计格让用户一眼掌握核心数据(收录数、标注数、里程数),中层双列卡展示具体的古迹名录(可滚动浏览),底层柱状图呈现时间维度的探访趋势。三部分通过 Column({ space: 12 }) 纵向排列,间距 12px,形成清晰的视觉层次。

9.1.1 统计格组件
@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 是一个通用统计格构建器,接收三个参数:icon(图标 emoji)、value(数值文本)、label(标签文字)。内部用 Column 纵向排列,从上到下依次为图标、数值、标签。

数值文字使用 15 号粗体,颜色随 breath 状态在鎏金与绢帛黄间切换——当 breath 为 true 时显示鎏金色(高亮态),为 false 时显示绢帛黄色(常态)。这种呼吸式变色配合 1 秒的切换频率,营造出"数据实时刷新"的视觉暗示,虽然数据本身并未变化,但动态效果让界面更具生命力。

统计格使用 layoutWeight(1) 等分宽度,三个统计格在一行中均匀分布。背景色为深木色卡片底,圆角 10px,上下各 10px 内边距。justifyContent(FlexAlign.Center) 确保内容在垂直方向居中对齐。

三个统计格分别展示:收录古迹数(动态绑定 heritageList.length,随增删操作实时变化)、地图标注数(绑定 MARKER_SPOTS.length,静态值 6)、探索里程(静态值 128.6km)。一动两静的组合既展示了可变业务数据,又提供了固定的参考指标。

9.1.2 古迹双列卡片

古迹双列卡是首页的核心内容区,使用 Flex 容器配合 FlexWrap.Wrap 实现瀑布流式双列布局。

@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%,两张并排后剩余 2% 作为列间距(由 FlexAlign.SpaceBetween 自动分配)。卡片背景为深木色,圆角 12px,内边距 10px,内容左对齐。

卡片内容从上到下分为四行:

第一行为双徽章行,展示朝代徽章和保护等级徽章。朝代徽章使用鎏金色文字(体现历史价值的珍贵感),保护等级徽章使用 levelColor 函数动态着色——全国重点为朱砂红、省级为青铜绿、市县级为陶土灰。等级徽章使用 layoutWeight(1) 占满剩余宽度,文字超出时自动省略。两枚徽章均使用深木色底色,圆角 6px。

第二行为古迹名称,14 号粗体宣纸暖白色,单行省略。这是卡片最核心的信息,使用最大字号和最高对比度。

第三行为所在区域,10 号陶土灰色,单行省略。提供地理上下文信息。

第四行为操作行,左侧显示文保分(青铜绿色),右侧为两个圆形操作按钮。"编"按钮使用绢帛黄文字,点击调用 openEdit(idx) 打开编辑弹窗并回填字段;"删"按钮使用朱砂红色文字(危险操作语义),点击设置 delIdx 并打开删除确认弹窗。两个按钮尺寸均为 22x22px,圆角 11px(正圆),深木色背景。

Flex 容器使用 FlexWrap.Wrap 允许换行,FlexAlign.SpaceBetween 使每行的两张卡片分别贴靠左右边缘,中间自动留出间距。这种布局天然支持任意数量的卡片自动排列,最后一行不足两张时靠左对齐(因为 SpaceBetween 在只有一项时该项居左)。

ForEach 的 key 生成函数使用 ${item.name}_${idx} 格式,将古迹名称与索引组合确保唯一性——理论上古迹名称可能重复,索引保证了即使名称相同也能生成唯一 key。

9.1.3 月度探访量柱状图
@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)
}

柱状图以纯 ArkUI 组件实现,无需 Canvas 绘制。整个图表卡片包含标题行和图表区两部分。标题行左侧为 13 号粗体"近 6 个月探访热度",右侧为 10 号弱文本"次/月"单位说明。

图表区使用 150px 高度的 Row 容器,设置 alignItems(VerticalAlign.Bottom) 底部对齐,确保所有柱体从底部向上生长。通过 ForEach 遍历 MONTH_IDX 六个月份索引,每月生成一个 Column 子列。

每个柱子由三部分组成:顶部为访问量数值(8号绢帛黄色)、中间为柱体、底部为月份标签(9号陶土灰色)。柱体高度按 VISIT_VAL[i] * 0.62 等比缩放——0.62 是根据最大值 216 和容器高度 150px 计算出的比例系数(216 * 0.62 ≈ 134px,留出顶部数值文字的空间)。

breath 为 true 时,柱体高度额外增加 8px,模拟呼吸波动效果。柱体颜色按奇偶交替使用青铜绿和青铜深色,形成斑驳的金属质感,呼应青铜器文物的视觉主题。柱体顶部圆角 5px(仅左上和右上),底部直角与容器底部贴合。

每个子列使用 layoutWeight(1) 等分宽度,alignItems(HorizontalAlign.Center) 水平居中,justifyContent(FlexAlign.End) 底部对齐。Rowspace: 10 设置柱间间距为 10px。

这种"ForEach + Column 高度绑定"的柱状图方案虽然轻量但效果直观,且天然响应 breath 状态实现动态律动,是 ArkUI 声明式范式的典型应用——只需描述"数据是什么"和"柱高如何随数据变化",框架自动完成渲染和更新。

9.2 地图 Tab:双长按监听

地图 Tab 是 Map Kit 特性的核心承载,也是整个应用空间探索能力的集中展示。顶部为双 Toggle 开关行,中间为 MapComponent 地图组件,底部为长按事件日志流。

@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)
    Column({ space: 6 }) {
      Row({ space: 6 }) {
        Text('长按事件日志').fontSize(12).fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title).layoutWeight(1)
        Text(`${this.eventLogs.length}`).fontSize(10).fontColor(COLORS.text3)
      }
      .width('100%')
      if (this.eventLogs.length === 0) {
        Text('暂无事件:长按地图 Marker 或 POI 试一试…')
          .fontSize(10).fontColor(COLORS.text3).width('100%')
      } else {
        List({ space: 6 }) {
          ForEach(this.eventLogs, (log: EventLog, idx: number) => {
            ListItem() {
              this.eventLogRow(log)
            }
          }, (log: EventLog, idx: number) => `${log.time}_${idx}_${log.type}`)
        }
        .width('100%').height(116)
      }
    }
    .width('100%').padding(10).borderRadius(10).backgroundColor(COLORS.card)
    .alignItems(HorizontalAlign.Start)
  }
  .width('100%').layoutWeight(1)
  .padding({ left: 12, right: 12, bottom: 10 })
}

地图 Tab 使用 Column({ space: 8 }) 纵向排列四个区域:开关行、提示文字、地图组件、日志卡片。整个 Tab 使用 layoutWeight(1) 撑满剩余高度,因为地图组件需要明确的高度约束。

9.2.1 双 Toggle 开关行

顶部开关行包含两个 Switch 类型的 Toggle 组件和对应的标签文字。第一个 Toggle 控制 Marker 长按监听,第二个控制 POI 长按监听。两个 Toggle 的选中态分别绑定 markerListenOnpoiListenOn 状态变量,onChange 回调分别调用 toggleMarkerListen()togglePoiListen() 方法执行实际的注册/注销操作。

第一个标签"Marker长按"使用 layoutWeight(1) 占满中间空间,将第二个 Toggle 和标签推到右侧。这种布局使两个开关对称分布在一行的左右两侧,中间留白,视觉上清晰分明。

9.2.2 地图组件

MapComponent 是整个地图 Tab 的核心,传入两个参数:mapOptions(地图初始选项,定义中心点和缩放级别)和 mapCallback(地图初始化回调,当地图组件准备就绪时被调用)。组件使用 layoutWeight(1) 撑满剩余高度,宽度 100%,圆角 12px。

地图回调 setupMapCallback 内部遵循五步初始化流程:

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;
    // 批量添加古迹 Marker
    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}`);
      }
    }
    // Marker 长按监听
    manager.onMarkerLongClick((marker: map.Marker) => {
      const pos: mapCommon.LatLng = marker.getPosition();
      this.eventLogs.unshift(new EventLog('Marker', `#${marker.getId()}`, pos.latitude, pos.longitude));
    });
    // POI 长按监听
    manager.onPoiLongClick((poi: mapCommon.Poi) => {
      this.eventLogs.unshift(new EventLog('POI', poi.name ?? '未命名POI',
        poi.position.latitude, poi.position.longitude));
    });
  };
}

第一步是错误判空:检查 err 参数是否非空,若地图初始化失败则打印错误日志并直接返回,避免后续操作抛出异常。

第二步是获取控制器:将回调参数中的 mapController 保存到组件实例变量,同时赋值给局部变量 controller 供后续使用。

第三步是获取事件管理器:通过 controller.getEventManager() 获取 MapEventManager 对象,用于注册和注销地图事件。

第四步是批量添加古迹 Marker:遍历 MARKER_SPOTS 数组,逐个构造 MarkerOptions 对象并调用 addMarker 添加。每个 Marker 显式设置十个属性——position(经纬度位置)、clickable(可点击)、visible(可见)、rotation(旋转角度 0)、zIndex(层级 0)、alpha(不透明度 1)、anchorU/anchorV(锚点,底部居中)、draggable(不可拖动)、flat(非扁平模式)。全显式设置确保 Marker 行为完全符合预期,不依赖默认值的隐式行为。由于 addMarker 是异步方法,使用 await 逐个添加并配合 try-catch 捕获单个 Marker 的添加失败,避免一处失败影响全部。

第五步是注册双长按监听:onMarkerLongClick 回调接收 map.Marker 对象,通过 getPosition() 获取经纬度、getId() 获取标识,构造 EventLog 插入日志头部;onPoiLongClick 回调接收 mapCommon.Poi 对象(仅含 id/name/position 三字段),提取名称和坐标后同样插入日志。

9.2.3 长按监听开关逻辑
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;
}

两个监听开关方法(toggleMarkerListentogglePoiListen)逻辑对称。方法开头先判空 mapEventManager,若事件管理器尚未初始化(地图未加载完成)则直接返回,避免空指针异常。

关闭监听时调用 offMarkerLongClick()(不传参即清除该类型全部订阅),并向日志流插入一条"系统"类型的关闭状态变更记录。开启监听时重新注册回调函数(与初始化时的回调逻辑完全相同),并插入一条开启状态变更记录。最后翻转 markerListenOn 状态变量,更新 Toggle 的选中态显示。

这种"动态注册/注销 + 系统事件记录"的设计,让用户可以直观地看到监听开关的操作历史,同时验证开关功能确实生效。系统事件的经纬度设为 0,0(无地理意义),但在日志列表中仍会显示为"0.0000, 0.0000",在生产环境中可考虑对系统事件隐藏坐标列。

9.2.4 事件日志流

底部的事件日志卡片使用 List 容器展示 EventLog 数组,固定高度 116px,超出部分可滚动。日志为空时显示提示文字,有日志时遍历渲染。

@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 类型用鎏金(与 POI 搜索高相关色一致)、系统类型用陶土灰(弱化处理)。

事件名称使用 layoutWeight(1) 占满中间空间,单行省略。经纬度坐标使用等宽字体(fontFamily('monospace')),保留四位小数,确保数字对齐美观。时间戳显示在最右侧,9 号陶土灰色。

ForEach 的 key 生成使用 ${log.time}_${idx}_${log.type} 格式,组合时间戳、索引和类型确保唯一性——即使同一秒内产生多条相同类型的日志,索引也能区分。

9.3 搜索 Tab:POI 相关性量化

搜索 Tab 提供 POI 关键字搜索能力,是 Map Kit 搜索特性的核心展示场景。顶部为搜索输入框和搜索按钮,中部为搜索状态提示行,下方为搜索结果卡片列表。

@Builder
tabSearch() {
  Column({ space: 12 }) {
    Row({ space: 8 }) {
      TextInput({ text: this.queryInput, placeholder: '输入关键字,如 古迹' })
        .layoutWeight(1).height(40).fontSize(12)
        .backgroundColor(COLORS.dark).fontColor(COLORS.title)
        .onChange((v: string) => {
          this.queryInput = v;
        })
      Button('搜索').height(36).fontSize(12).backgroundColor(COLORS.bronze)
        .onClick(() => {
          this.runSearch();
        })
    }
    .width('100%')
    Row({ space: 6 }) {
      Text('searchByText').fontSize(9).fontFamily('monospace').fontColor(COLORS.text3)
      Text(this.searchState).fontSize(10).fontColor(COLORS.sub).layoutWeight(1)
      Text('半径 5000m · zh').fontSize(9).fontColor(COLORS.text3)
    }
    .width('100%')
    ForEach(this.searchRecords, (rec: SearchRecord, idx: number) => {
      this.searchResultCard(rec)
    }, (rec: SearchRecord, idx: number) => `${rec.name}_${idx}`)
  }
  .width('100%')
}

搜索 Tab 使用 Column({ space: 12 }) 纵向排列三个区域:搜索栏、状态行、结果列表。整个 Tab 包裹在 Scroll 容器内(因为它不是地图或百科 Tab),当结果较多时可滚动浏览。

搜索栏使用 Row({ space: 8 }) 横向排列:左侧 TextInput 占满剩余宽度(layoutWeight(1)),高度 40px,深木色背景,宣纸暖白文字色,占位符提示"输入关键字,如 古迹"。输入框内容绑定 queryInput 状态变量,onChange 回调实时更新输入值。右侧青铜绿"搜索"按钮,高度 36px,点击调用 runSearch() 方法发起搜索。

状态行展示三个信息:左侧为 searchByText 方法名(等宽字体,陶土灰色),标识使用的 API;中间为搜索状态文案(绢帛黄色),在"待搜索"“搜索中…”“返回 N 条结果”“搜索失败"等状态间切换;右侧为搜索参数说明"半径 5000m · zh”(陶土灰色),提示搜索范围和语言设置。

搜索结果使用 ForEach 遍历 searchRecords 数组渲染,每张结果卡片之间自然形成间距(由外层 Column 的 space: 12 控制)。

9.3.1 搜索结果卡片
@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)
}

单条搜索结果卡片包含四行信息,层次分明地展示 POI 结果的各个维度。

第一行为标题行,左侧为地点名称(13号粗体宣纸暖白色,单行省略),右侧为相关性等级标签。等级标签调用 reliabilityScore 函数获取标签文字和颜色——高相关鎏金、中相关青铜绿、低相关陶土灰——使用深木色背景,圆角 8px。这行是卡片的视觉锚点,用户一眼即可看到地点名称和匹配质量。

第二行为地址信息(11号绢帛黄色,单行省略),提供 POI 的具体位置描述。

第三行为辅助信息行,左侧显示直线距离(陶土灰色),右侧显示"POI 命中"标识(青蓝色)。"POI 命中"标签明确告知用户该结果来自 Map Kit 的 POI 搜索而非本地数据,增强结果的可信度。

第四行为相关性进度条行,是整个卡片最具技术含量的部分。Progress 组件使用线性类型,值绑定 rec.reliability * 100(将 0-1 的浮点数转换为 0-100 的百分比),进度条颜色为青铜绿主色,背景为深木色。进度条右侧显示精确的 reliability 数值(鎏金色,保留两位小数),使用户既可以通过进度条直觉感知匹配度,又可以通过数字获得精确信息。

这种"等级标签 + 进度条 + 精确数值"的三重展示方式,将 Map Kit 的 reliability 字段价值最大化——不同认知习惯的用户都能找到适合自己的信息获取方式:视觉型用户看进度条,分类型用户看等级标签,数据型用户看精确数值。

9.3.2 搜索执行逻辑
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 方法是搜索功能的核心实现,采用异步 async/await 模式调用 Map Kit 的 site.searchByText 接口。

方法开始时先将搜索状态设为"搜索中…",为用户提供即时反馈,避免因网络延迟导致的操作无响应感。然后构造 SearchByTextParams 参数对象,包含四个字段:query 为用户输入的搜索关键字、location 为搜索基准点(使用 CITY_CENTER 西安坐标)、radius 为搜索半径 5000 米、language 为搜索结果语言(中文)。

搜索成功时,从返回的 SearchByTextResult 中提取 sites 数组,若数组为空则更新状态为"无结果,已保留当前推荐"并直接返回(保留原有 Mock 数据不被覆盖)。若有结果,则通过 mapsite.Site 对象转换为 SearchRecord 实体——提取名称、格式化地址、距离和 reliability 四个字段,使用空值合并运算符 ?? 提供兜底值,防止字段缺失导致的运行时错误。最后更新状态文案为"返回 N 条结果"。

搜索失败时(抛出异常),捕获 BusinessError 并更新状态文案为"搜索失败(错误码),保留当前推荐"。这种失败时保留 Mock 数据的策略称为"优雅降级"——当无 AGC 配置、无网络或接口限流时,应用仍能展示预置数据,保证演示链路的完整性,这对于 Demo 应用和开发调试阶段尤为重要。

9.4 百科 Tab:Web 组件与主动下载

百科 Tab 嵌入 ArkWeb 浏览能力,是连接应用与线上文博资源的桥梁。顶部为地址栏和快捷站点,中部为 Web 浏览区,底部为主动下载触发区。

@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({ 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)
  }
  .width('100%').layoutWeight(1)
  .padding({ left: 12, right: 12, top: 10, bottom: 10 })
}

百科 Tab 使用 Column({ space: 8 }) 纵向排列四个区域:地址栏、快捷站点横滑、Web 组件、主动下载触发区。整个 Tab 使用 layoutWeight(1) 撑满剩余高度——与地图 Tab 类似,Web 组件需要明确的高度约束才能正确渲染。

9.4.1 地址栏与 URL 加载逻辑

地址栏由 TextInput 输入框和"前往"按钮组成。输入框绑定 urlInput 状态变量,按钮点击调用 loadUrl() 方法。

loadUrl() {
  let target: string = this.urlInput.trim();
  if (target === '') {
    return;
  }
  if (!target.startsWith('http://') && !target.startsWith('https://')) {
    target = `https://${target}`;
  }
  this.urlInput = target;
  this.webUrl = target;
}

loadUrl 方法实现 URL 的规范化与加载。首先对输入内容执行 trim() 去除首尾空格,若为空则直接返回。然后检查是否以 http://https:// 开头,若缺少协议前缀则自动补全 https://——这一设计极大提升了用户体验,用户只需输入域名(如 www.ncha.gov.cn)即可访问,无需手动输入协议头。最后同时更新 urlInput(将规范化后的 URL 回填到输入框)和 webUrl(触发 Web 组件加载)。

urlInputwebUrl 的双状态分离设计是一个重要的架构决策。urlInput 绑定输入框,反映用户的实时输入状态;webUrl 驱动 Web 组件,只有在用户确认后才更新。这种分离避免了用户每输入一个字符 Web 组件就尝试加载一次的性能问题,也给了用户修正输入的机会。

9.4.2 快捷站点横滑行

快捷站点横滑行使用 Scroll 容器包裹 Row,设置 scrollable(ScrollDirection.Horizontal) 支持横向滚动,scrollBar(BarState.Off) 隐藏滚动条。内部通过 ForEach 遍历 QUICK_SITES 数组,每个站点渲染为一个圆角胶囊标签。

标签的选中态通过 this.webUrl === u 判断——当 Web 组件当前加载的 URL 与该站点 URL 完全匹配时,标签文字变为鎏金色、背景变为深木色(高亮态);否则文字为绢帛黄色、背景为卡片色(常态)。点击标签时同时更新 urlInputwebUrl,实现一键跳转。

这种"当前 URL 匹配高亮"的交互模式让用户可以直观地看到当前访问的是哪个快捷站点,也可以快速在多个站点间切换。由于使用 hostOf(u) 提取域名作为标签文字,界面保持简洁不拥挤。

9.4.3 Web 组件与主动下载

Web 组件是百科 Tab 的核心,传入 src(当前加载的 URL)和 controller(Web 控制器),使用 layoutWeight(1) 撑满剩余高度,圆角 12px。Web 组件内部的浏览行为完全由 ArkWeb 引擎处理,包括页面渲染、JavaScript 执行、Cookie 管理等。

底部的主动下载触发区是 ArkWeb 下载能力的演示入口。左侧为功能说明文字(9号陶土灰色),标注 webController.startDownload(url) 方法名,提示这是从应用侧发起的下载而非网页内点击。右侧为鎏金色"触发"按钮(30px 高度),点击调用 triggerDownload(RESOURCE_DL_URL) 方法。

triggerDownload(url: string) {
  try {
    this.webController.startDownload(url);
  } catch (error) {
    const err = error as BusinessError;
    console.error(`ErrorCode: ${err.code}, Message: ${err.message}`);
  }
}

triggerDownload 方法封装了 webController.startDownload() 调用,使用 try-catch 捕获可能的异常。该方法的独特之处在于它从应用侧直接发起下载,无需用户在网页内点击下载链接——这对于需要程序化下载资源的场景(如批量下载文物图片、自动同步文博资料)非常有用。下载触发后,整个下载生命周期由 WebDownloadDelegate 的四个回调接管。

9.5 下载 Tab:进度与双 URL 溯源

下载 Tab 展示下载全生命周期,是 ArkWeb 下载委托特性的核心展示区。顶部为进行中任务卡,下方为完成记录列表,每条记录携带双 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)
    Row({ space: 6 }) {
      Text('完成记录').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title).layoutWeight(1)
      Text(`${this.downloadRecords.length}`).fontSize(10).fontColor(COLORS.text3)
    }
    .width('100%')
    ForEach(this.downloadRecords, (rec: DownloadRecord, idx: number) => {
      this.downloadRecordCard(rec)
    }, (rec: DownloadRecord, idx: number) => `${rec.fileName}_${idx}`)
  }
  .width('100%')
}

下载 Tab 包含两大部分:进行中任务卡和完成记录列表。

进行中任务卡展示当前下载任务的状态。顶部行从左到右依次为:下载图标(📥)、文件名与状态列、进度百分比。当 dlName 为空时显示"暂无进行中任务"的占位文字。文件名使用 layoutWeight(1) 占满中间空间,单行省略。进度百分比为 12 号粗体鎏金色,是整个卡片的视觉焦点。

中部为线性进度条,绑定 dlPercent 值,青铜绿进度色 + 深木色背景。进度条下方为说明文字:左侧提示下载触发方式(网页内点击或主动触发),右侧标注技术实现(delegate 四回调)。

完成记录列表展示所有已完成的下载记录,标题行显示"完成记录"和记录总数。

9.5.1 下载记录卡片
@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 溯源两大部分,层次清晰地展示下载记录的完整信息。

第一行为文件信息行:文件图标(📄)+ 文件名和体积时间列。文件名 12 号宣纸暖白色,第二行显示格式化后的文件体积(通过 formatSize 函数转换)和完成时间(10 号陶土灰色)。

第二、三行为原始 URL 溯源:图标 + 标签行(鎏金色,标注 getOriginalUrl() 方法名),下方为 URL 文本(等宽字体,绢帛黄色,单行省略)。原始 URL 标记文件的真实下载来源,是版权追溯的第一重证据。

第四、五行为引用页 URL 溯源:图标 + 标签行(青铜绿色,标注 getReferrerUrl() 方法名),下方为 URL 文本(等宽字体,绢帛黄色)。引用页 URL 追踪用户从哪个页面发起的下载,是版权追溯的第二重证据。

双 URL 溯源的设计是 HarmonyOS 6.1.1 的核心特性之一——通过 originalUrlreferrerUrl 两个字段的联合使用,可以完整回答"文件从哪里来""用户从哪个页面下载的"两个关键问题,为数字文博资源的版权追溯和来源验证提供完整证据链。两个 URL 使用不同的颜色标签(鎏金 vs 青铜绿)区分,避免混淆。

9.5.2 下载委托四回调
setupDownloadDelegate() {
  // 下载开始前:必须调用 start() 提供沙箱路径
  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()}`;
  });
  // 下载完成:读取原始 URL 与引用页 URL(双溯源)
  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));
  });
  // 绑定到 controller
  try {
    this.webController.setDownloadDelegate(this.downloadDelegate);
  } catch (e) {
    console.error(`setDownloadDelegate failed: ${(e as BusinessError).code}, ${(e as BusinessError).message}`);
  }
}

setupDownloadDelegate 方法注册下载委托的四个回调并绑定到 Web 控制器,构成下载全生命周期管理。四个回调分别对应下载的不同阶段:

onBeforeDownload(下载开始前): 这是最关键的回调——必须在此回调中调用 item.start(path) 提供沙箱保存路径,否则下载任务会停在 PENDING 状态无法开始。方法内部先通过 getUIContext().getHostContext() 获取宿主上下文,再取 filesDir 作为沙箱文件目录。然后更新 UI 状态:设置文件名、状态设为"下载中"、进度归零。最后调用 start() 传入完整路径(目录 + 建议文件名),启动下载。

onDownloadUpdated(下载进行中): 下载进度更新时被持续调用,通过 item.getPercentComplete() 获取当前进度百分比并更新 dlPercent 状态,驱动进度条 UI 刷新。

onDownloadFailed(下载失败): 下载失败时被调用,更新状态文案为"下载失败:文件名",为用户提供明确的失败反馈。

onDownloadFinish(下载完成): 下载完成时被调用,这是双 URL 溯源的核心场景。通过 item.getOriginalUrl() 获取原始下载 URL,通过 item.getReferrerUrl() 获取引用页 URL——这两个方法是 HarmonyOS 6.1.1 的新增 API,是整个下载模块最具技术价值的部分。然后设置进度为 100%、状态为"已完成",并构造 DownloadRecord 对象插入记录列表头部(unshift),新完成的下载始终显示在最上方。

四个回调注册完成后,调用 webController.setDownloadDelegate(delegate) 将委托绑定到 Web 控制器——只有绑定成功后,网页内触发的下载才会进入上述回调链。绑定操作使用 try-catch 包裹,防止控制器未就绪时抛出异常。

9.6 工坊 Tab:WebP 元数据四区块

工坊 Tab 是 ImageKit WebP 元数据特性的核心承载,也是整个应用技术含量最高的模块。纵向排布四个区块,分别对应元数据操作的四个步骤:生成样图、读取元数据、写入元数据、回读校验与操作日志。

@Builder
tabStudio() {
  Column({ space: 12 }) {
    // 区块一:老照片样图生成
    Row({ space: 6 }) {
      Text('①').fontSize(12).fontColor(COLORS.gold)
      Text('老照片样图生成').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title).layoutWeight(1)
      Text('像素画→WebP→沙箱').fontSize(9).fontColor(COLORS.text3)
    }
    .width('100%')
    Column({ space: 8 }) {
      Row({ space: 6 }) {
        Text('纹理').fontSize(11).fontColor(COLORS.text3)
        ForEach(TEXTURE_LIST, (t: TextureItem) => {
          Text(t.label).fontSize(10)
            .padding({ left: 9, right: 9, top: 4, bottom: 4 }).borderRadius(10)
            .fontColor(this.textureSel === t.key ? COLORS.tabOn : COLORS.text3)
            .backgroundColor(this.textureSel === t.key ? COLORS.dark : COLORS.card)
            .onClick(() => {
              this.textureSel = t.key;
            })
        }, (t: TextureItem) => t.key)
      }
      .width('100%')
      Row({ space: 10 }) {
        Text(`画布 ${CANVAS_SIZE}×${CANVAS_SIZE}`).fontSize(10).fontColor(COLORS.sub)
        Text(`质量 ${WEBP_QUALITY}`).fontSize(10).fontColor(COLORS.sub)
        Text('RGBA_8888').fontSize(10).fontColor(COLORS.sub)
        Text(this.textureSel === 'ring' ? '同心环' : '条纹').fontSize(10).fontColor(COLORS.sub)
      }
      .width('100%')
      Row({ space: 12 }) {
        if (this.pixelMap) {
          Image(this.pixelMap!).width(120).height(120).borderRadius(12).objectFit(ImageFit.Fill)
        } else {
          Column() {
            Text('尚未生成').fontSize(11).fontColor(COLORS.text3)
          }
          .width(120).height(120).borderRadius(12)
          .backgroundColor(COLORS.dark).justifyContent(FlexAlign.Center)
        }
        Column({ space: 4 }) {
          Text(this.genState).fontSize(11).fontColor(COLORS.sub)
          if (this.webpPath !== '') {
            Text(this.webpPath).fontSize(9).fontFamily('monospace').fontColor(COLORS.text3)
              .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
          } else {
            Text('filesDir/heritage_photo.webp').fontSize(9).fontFamily('monospace').fontColor(COLORS.text3)
          }
        }
        .alignItems(HorizontalAlign.Start).layoutWeight(1)
      }
      .width('100%').alignItems(VerticalAlign.Top)
      Button('生成 WebP 老照片样图').fontSize(12).width('100%').backgroundColor(COLORS.bronze)
        .onClick(() => {
          this.genWebpFile();
        })
    }
    .width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
    .alignItems(HorizontalAlign.Start)
    // 区块二:五字段元数据读取
    Row({ space: 6 }) {
      Text('②').fontSize(12).fontColor(COLORS.gold)
      Text('五字段元数据读取').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title).layoutWeight(1)
      Button('读取').fontSize(11).height(30).backgroundColor(COLORS.bronze)
        .onClick(() => {
          this.readMeta();
        })
    }
    .width('100%')
    if (this.metaSnapshot) {
      this.metaCard('readImageMetadataByType 读取结果', this.metaSnapshot!, false)
    } else {
      Row({ space: 6 }) {
        Text('静态 WebP 通常仅提供画布尺寸,帧延迟/循环显示"未提供"')
          .fontSize(10).fontColor(COLORS.text3).layoutWeight(1)
      }
      .width('100%').padding(10).borderRadius(10).backgroundColor(COLORS.card)
    }
    // 区块三:写入控制台
    Row({ space: 6 }) {
      Text('③').fontSize(12).fontColor(COLORS.gold)
      Text('写入控制台 writeImageMetadata').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
    }
    .width('100%')
    Column({ space: 8 }) {
      Row({ space: 6 }) {
        Text('帧延迟').fontSize(11).fontColor(COLORS.text3)
        ForEach(DELAY_PRESETS, (ms: number) => {
          Text(`${ms}ms`).fontSize(10)
            .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
            .fontColor(this.writeDelay === ms ? COLORS.tabOn : COLORS.text3)
            .backgroundColor(this.writeDelay === ms ? COLORS.dark : COLORS.card)
            .onClick(() => {
              this.writeDelay = ms;
            })
        }, (ms: number) => `d_${ms}`)
      }
      .width('100%')
      Row({ space: 6 }) {
        Text('循环').fontSize(11).fontColor(COLORS.text3)
        ForEach(LOOP_PRESETS, (n: number) => {
          Text(n === 0 ? '不限' : `${n}`).fontSize(10)
            .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
            .fontColor(this.writeLoop === n ? COLORS.tabOn : COLORS.text3)
            .backgroundColor(this.writeLoop === n ? COLORS.dark : COLORS.card)
            .onClick(() => {
              this.writeLoop = n;
            })
        }, (n: number) => `l_${n}`)
      }
      .width('100%')
      Button('写入并回读校验').fontSize(12).width('100%').backgroundColor(COLORS.bronze)
        .onClick(() => {
          this.writeMeta();
        })
    }
    .width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
    .alignItems(HorizontalAlign.Start)
    // 区块四:回读校验与操作日志
    Row({ space: 6 }) {
      Text('④').fontSize(12).fontColor(COLORS.gold)
      Text('回读校验与操作日志').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title).layoutWeight(1)
    }
    .width('100%')
    if (this.verifySnapshot) {
      this.metaCard('写入后重建 ImageSource 回读', this.verifySnapshot!, true)
    } else {
      Row({ space: 6 }) {
        Text('尚未回读:写入成功后自动重建 ImageSource 比对').fontSize(10).fontColor(COLORS.text3).layoutWeight(1)
      }
      .width('100%').padding(10).borderRadius(10).backgroundColor(COLORS.card)
    }
    Column({ space: 4 }) {
      if (this.opLogs.length === 0) {
        Text('操作日志为空:生成样图开始全链路').fontSize(10).fontColor(COLORS.text3)
      } else {
        ForEach(this.opLogs, (log: MetaOpLog, idx: number) => {
          Row({ space: 6 }) {
            Text(log.op).fontSize(10).fontColor(COLORS.sub)
              .padding({ left: 6, right: 6, top: 3, bottom: 3 })
              .borderRadius(6).backgroundColor(COLORS.dark)
            Text(log.detail).fontSize(10).fontColor(COLORS.text3).layoutWeight(1)
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            Text(log.time).fontSize(9).fontColor(COLORS.text3)
          }
          .width('100%')
        }, (log: MetaOpLog, idx: number) => `${log.time}_${idx}_${log.op}`)
      }
    }
    .width('100%').padding(10).borderRadius(10).backgroundColor(COLORS.card)
    .alignItems(HorizontalAlign.Start)
    .height(150)
  }
  .width('100%')
}

工坊 Tab 的四个区块用序号①②③④标记,形成清晰的操作流程指引。每个区块有独立的标题行和内容卡片,视觉层次分明。

区块一(老照片样图生成) 提供五种纹理选择(斑驳/雕花/夯土/碑刻/年轮),点击切换 textureSel 状态。显示画布尺寸、编码质量、像素格式和纹理类型信息。生成后展示 Image 预览或"尚未生成"占位,以及生成状态和沙箱路径。点击"生成 WebP 老照片样图"按钮调用 genWebpFile 方法。

区块二(五字段元数据读取) 提供"读取"按钮调用 readMeta 方法。读取结果通过 metaCard 渲染,显示五字段快照。未读取时显示提示文字,说明静态 WebP 通常只提供画布尺寸。

区块三(写入控制台) 提供帧延迟三档预设(120/200/500ms)和循环四档预设(不限/1次/3次/5次)选择,点击切换 writeDelaywriteLoop 状态。点击"写入并回读校验"按钮调用 writeMeta 方法。

区块四(回读校验与操作日志) 显示回读校验结果(高亮样式)和操作日志列表。操作日志固定高度 150px,展示四步操作的完整审计链路。

9.6.1 像素画生成与 WebP 编码
async genWebpFile() {
  this.genState = '生成中…';
  try {
    // 重复生成前释放旧 PixelMap
    if (this.pixelMap) {
      this.pixelMap.release();
      this.pixelMap = undefined;
    }
    // 1. 生成像素画(RGBA_8888,四字节一像素)
    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;
    // 2. ImagePacker 编码为 WebP
    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();
    // 3. 写入沙箱文件
    const ctx = this.getUIContext().getHostContext();
    const dir: string = ctx ? ctx.filesDir : '';
    if (dir === '') {
      this.genState = '生成失败:沙箱目录不可用';
      return;
    }
    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 编码 → 沙箱落盘。整个方法使用 async/await 异步模式,配合 try-catch 捕获异常。

第一步:像素画生成。 首先判空并释放已有的 pixelMap(若存在),防止重复生成导致内存泄漏。然后分配 CANVAS_SIZE * CANVAS_SIZE * 4 字节的 ArrayBuffer(RGBA_8888 格式每像素 4 字节),用 Uint32Array 视图操作。通过单层循环遍历所有像素,计算每个像素的行列坐标,调用 pickTextureColor 根据当前选中的纹理类型计算颜色值并写入缓冲区。最后调用 image.createPixelMap 创建 RGBA_8888 格式的 PixelMap 对象。

第二步:WebP 编码。 创建 ImagePacker 对象,设置格式为 'image/webp'、质量为 WEBP_QUALITY(90),调用 packToData 将像素图编码为 WebP 字节流。编码完成后释放 packer 资源。

第三步:沙箱落盘。 通过 getUIContext().getHostContext().filesDir 获取沙箱文件目录,以 READ_WRITE | CREATE | TRUNC 模式打开文件(可读可写、不存在则创建、已存在则截断),写入 WebP 字节流后关闭。新文件生成后重置读取和回读快照(设为 undefined),并向操作日志插入一条"生成样图"记录。

纹理颜色计算函数 pickTextureColor 实现了五种不同的纹理算法:

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

调色板使用五种文博主题色(青铜绿、鎏金、绢帛黄、陶土灰、青铜深),通过不同的索引计算方式产生不同纹理:

  • 斑驳(mottle):行列之和取模,产生对角斜纹效果
  • 雕花(carve):行列分别除以 8 取整后相加取模,产生棋盘格效果
  • 夯土(rammed):行除以 8 取整取模,产生水平层理效果
  • 碑刻(stele):列除以 8 取整取模,产生竖向刻痕效果
  • 年轮(ring):计算距中心的欧氏距离取模,产生同心环效果

每种算法都通过 hexToRgba 函数将十六进制颜色转换为 RGBA_8888 格式的整数像素值。

9.6.2 元数据读取与写入
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 方法以 READ_WRITE 模式打开 WebP 文件,通过文件描述符创建 ImageSource,以 [image.MetadataType.WEBP_METADATA] 类型数组调用 readImageMetadataByType(types, 0)(index 为 0,静态 WebP 恒取 0)。从返回的 ImageMetadata 上取 webPMetadata 字段,以 ?? -1 空值合并运算符判空兜底后构造 WebpMetaSnapshot 五字段快照。读取完成后释放资源并向操作日志插入记录。

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 对象(官方样例同款模式——全可选字段允许字面量构造),挂载到 ImageMetadata 上,调用 source.writeImageMetadata(meta) 写入。写入成功后立即调用 verifyRead() 进行回读校验。错误提示中附带了常见错误码的含义(7700202=不支持,7700204=参数非法),方便调试。

9.6.3 回读校验与操作日志
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 并与写入值比对 delayTimeloopCount——一致则日志记录"已生效",不一致则记录差异详情。

这种"写入后立即回读校验"的设计是元数据工坊的核心价值所在——它不仅演示了元数据的写入能力,更通过回读验证证明了写入确实生效,形成了完整的闭环。对于老照片数字档案管理场景,这种可验证的元数据写入能力确保了档案信息的可靠性和可追溯性。

操作日志列表使用 ForEach 遍历 opLogs 数组,每条日志展示操作类型徽章、详情文本和时间戳。四类操作(生成样图、读取元数据、写入元数据、回读校验)按时间倒序排列,构成工坊 Tab 的完整操作审计链路。

9.6.4 元数据快照卡片
@Builder
metaCard(title: string, snap: WebpMetaSnapshot, highlight: boolean) {
  Column({ space: 6 }) {
    Text(title).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
    Row() {
      Text('canvasWidth 画布宽').fontSize(11).fontColor(COLORS.text3).layoutWeight(1)
      Text(fmtField(snap.canvasWidth, ' px')).fontSize(11).fontColor(COLORS.sub)
    }
    .width('100%')
    Row() {
      Text('canvasHeight 画布高').fontSize(11).fontColor(COLORS.text3).layoutWeight(1)
      Text(fmtField(snap.canvasHeight, ' px')).fontSize(11).fontColor(COLORS.sub)
    }
    .width('100%')
    Row() {
      Text('delayTime 帧延迟(钳制)').fontSize(11).fontColor(COLORS.text3).layoutWeight(1)
      Text(fmtField(snap.delayTime, ' ms')).fontSize(11).fontColor(COLORS.sub)
    }
    .width('100%')
    Row() {
      Text('unclampedDelayTime 未钳制').fontSize(11).fontColor(COLORS.text3).layoutWeight(1)
      Text(fmtField(snap.unclampedDelayTime, ' ms')).fontSize(11).fontColor(COLORS.sub)
    }
    .width('100%')
    Row() {
      Text('loopCount 循环次数').fontSize(11).fontColor(COLORS.text3).layoutWeight(1)
      Text(snap.loopCount < 0 ? '未提供' : `${snap.loopCount}${snap.loopCount === 0 ? '(不限)' : ' 次'}`)
        .fontSize(11).fontColor(COLORS.sub)
    }
    .width('100%')
  }
  .width('100%').padding(12).borderRadius(10)
  .backgroundColor(highlight ? COLORS.dark : COLORS.card)
  .alignItems(HorizontalAlign.Start)
}

metaCard 是一个通用的元数据快照卡片构建器,被读取结果和回读校验两个场景共用。接收三个参数:title(卡片标题)、snap(快照数据)、highlight(是否高亮显示)。

五字段以标签-值对的形式逐行展示,标签使用陶土灰色(弱文本),值使用绢帛黄色。所有数值字段通过 fmtField 函数格式化——值为 -1 时显示"未提供",否则显示数值加单位。循环次数字段有特殊处理:0 时追加"(不限)"后缀,符合 WebP 规范约定。

highlight 参数控制卡片背景色——为 true 时使用深木色(高亮态,用于回读校验结果,强调这是验证后的结果),为 false 时使用卡片色(常态,用于读取结果)。

9.7 我的 Tab:探访者画像

我的 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]] })
    // 足迹清单
    Row({ space: 6 }) {
      Text('足迹清单').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title).layoutWeight(1)
      Text('Marker 标注 6 处').fontSize(10).fontColor(COLORS.text3)
    }
    .width('100%')
    ForEach(MARKER_SPOTS, (spot: SpotItem) => {
      this.footRow(spot)
    }, (spot: SpotItem) => spot.name)
    // 权限与说明
    Row({ space: 6 }) {
      Text('权限与说明').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title).layoutWeight(1)
    }
    .width('100%')
    ForEach(ABOUT_ROWS, (row: AboutRow) => {
      Row({ space: 8 }) {
        Text(row.icon).fontSize(12)
        Text(row.title).fontSize(12).fontColor(COLORS.title).layoutWeight(1)
        Text(row.note).fontSize(10).fontColor(COLORS.text3)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .width('100%').padding(10).borderRadius(10).backgroundColor(COLORS.card)
    }, (row: AboutRow) => row.title)
  }
  .width('100%')
}

我的 Tab 采用三段式布局:顶部用户画像大卡、中部足迹清单、底部权限说明。三部分通过 Column({ space: 12 }) 纵向排列。

9.7.1 探访者渐变大卡

探访者大卡是我的 Tab 的视觉焦点,使用 linearGradient 从青铜深色到深木色的 135 度渐变背景,模拟青铜器表面由深到浅的金属光泽过渡。卡片内边距 16px,圆角 14px,是整个应用中最具质感的卡片。

卡片顶部为用户信息行:左侧 34px 古典建筑图标,中间为用户称号("长安拾遗人"17号粗体宣纸暖白色)和副标题("青铜级探访者 · 已点亮 6 处唐迹"10号绢帛黄色),右侧为鎏金色等级标识 Lv.5。

中部为三个统计格,调用 mineStat 构建器,分别展示收录数、踏访次数和里程数。统计格的数值颜色随 breath 状态在鎏金与宣纸白间切换,与首页统计格的呼吸效果一致。

底部为升级进度条:鎏金色进度(64%)+ 深木色背景,下方提示文字"距离白银级还差 2 处踏访"(9号绢帛黄色)。进度条和提示文案为用户提供清晰的成长目标指引。

@Builder
mineStat(label: string, value: string, unit: string) {
  Column({ space: 3 }) {
    Text(value).fontSize(16).fontWeight(FontWeight.Bold)
      .fontColor(this.breath ? COLORS.gold : COLORS.title)
    Text(`${label}(${unit})`).fontSize(9).fontColor(COLORS.sub)
  }
  .layoutWeight(1).alignItems(HorizontalAlign.Center)
}

mineStat 是我的 Tab 专用统计格构建器,与首页 statCell 类似但样式不同——无背景色、无圆角、无内边距,直接放在渐变背景上,数值 16 号粗体更大更醒目。

9.7.2 足迹清单

足迹清单展示六处已踏访的古迹,数据复用 MARKER_SPOTS 数组。每行使用 footRow 构建器:

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

足迹行从左到右依次为:已完成勾选图标(✅)、古迹名称与朝代标签列、"已踏访"状态标签(青铜绿色)。每行卡片底色为深木色,圆角 10px,内边距 10px。

标题行显示"足迹清单"和"Marker 标注 6 处"计数,明确告知用户足迹数据来源于地图标注点。这种数据复用设计减少了冗余定义,同时保证了地图标注与足迹清单的一致性。

9.7.3 权限与说明区

权限与说明区通过 ForEach 渲染 ABOUT_ROWS 四行声明,每行包含图标、标题和说明三列。四行内容分别为:

  1. 地图数据(Map Kit · 需 INTERNET 权限与签名)——提示地图功能需要网络权限和应用签名
  2. 百科与下载(ArkWeb · 双 URL 溯源 since 24)——标注 Web 浏览和下载溯源能力
  3. 工坊沙箱(Image Kit · WebP 元数据读写)——说明图像处理和元数据能力
  4. 当前版本(HarmonyOS 6.1.1 · API 24)——显示系统版本和 API 等级

这些信息对于开发者了解应用的技术栈和权限要求非常重要,也体现了文博应用对数据来源可追溯性的重视——每一项能力都明确标注了技术来源和依赖条件。

十、底部 Tab 栏

底部 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 栏以 Row 容器承载七个 Tab 项,每个项通过 layoutWeight(1) 等分宽度。选中态标签文字使用鎏金色(COLORS.tabOn),未选中态使用陶土灰色,形成明确的视觉焦点。

10.1 布局结构与交互逻辑

每个 Tab 项使用 Column({ space: 3 }) 纵向排列图标和标签:图标为 18px emoji,标签为 9px 中文字。图标不区分选中态(始终显示同一 emoji),只有标签文字颜色会变化——这种设计简化了状态管理,同时保证了足够的视觉区分度。

点击 Tab 项即更新 currentTab 状态,触发 build 方法的 if/else 分支切换对应 Tab 内容。整个切换过程由 ArkUI 框架的声明式渲染机制自动完成——开发者只需描述"当前 Tab 是什么",框架自动处理 UI 的创建、更新和销毁。

Tab 栏背景为深木色卡片底,高度 58px,上下各 6px 内边距,确保每个 Tab 项有足够的触摸热区(约 46px 高度)。58px 的总高度符合移动端底部导航的常见尺寸规范,既不会占用过多屏幕空间,也不会因太矮导致误触。

10.2 选中态设计考量

值得特别关注的是 Tab 选中色使用鎏金色(COLORS.tabOn = COLORS.gold)而非青铜绿主色——这是一个经过深思熟虑的设计决策。在深色背景上,鎏金色(#D9A441)的对比度远高于青铜绿(#5E9C8B),用户切换 Tab 时能瞬间定位当前位置,提升导航效率。

从视觉心理学角度看,鎏金色作为最醒目的强调色,用于"当前位置"指示比用于按钮更合理——按钮是可交互元素,需要吸引用户点击;而当前 Tab 是状态指示,需要让用户快速感知自己在哪里。将最醒目的颜色用于位置指示,符合"导航优先"的交互设计原则。

10.3 七 Tab 单排的挑战与应对

七个 Tab 单排在底部导航栏中是一个相对较多的数量——大多数应用使用 3 到 5 个 Tab。七个 Tab 带来的挑战是每个 Tab 的宽度较窄,文字可能拥挤。本应用通过以下方式应对:

  1. 精简标签文字:每个 Tab 的标签仅使用两个汉字(首页、地图、搜索、百科、下载、工坊、我的),最大限度压缩文字宽度。
  2. 小字号标签:标签文字使用 9px 字号,是整个应用中最小的字号之一,以换取更多的横向空间。
  3. Emoji 图标:使用 18px emoji 作为主要视觉标识,即使文字较小,用户也可通过图标快速识别 Tab 功能。
  4. 紧凑间距:图标与标签间距仅 3px,Tab 栏上下内边距各 6px,充分利用纵向空间。

这种"图标为主、文字为辅"的导航模式在 Tab 数量较多时尤为有效——用户主要通过图标定位,文字作为辅助确认,兼顾了功能数量和可用性。

十一、弹窗系统

弹窗系统由三个 @Builder 方法构成(新增、编辑、删除),通过 Stack 容器层叠遮罩层和内容面板。三个弹窗共享通用的遮罩层组件 modalOverlay 和输入行组件 fieldRow,实现了代码复用和交互一致性。

11.1 通用遮罩层与输入行

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

通用遮罩层 modalOverlay 为全屏半透黑 Column,背景色使用 COLORS.maskrgba(0,0,0,0.6),60% 透明度的黑色)。点击遮罩层空白处触发 onClose 回调关闭弹窗——这是移动端弹窗的标准交互模式,用户点击弹窗外的区域即可关闭,符合直觉预期。

遮罩层的作用有三:一是视觉上弱化底层内容,突出弹窗的焦点地位;二是阻止用户操作底层 UI,避免弹窗打开时的误操作;三是提供点击关闭的交互热区,增加关闭操作的可达性。

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

通用输入行 fieldRow 封装标签与 TextInput 的组合,接受四个参数:label(输入框标签文字)、value(当前输入值)、placeholder(占位提示文字)、onInput(输入变化回调)。

输入行纵向排列,标签在上(11号陶土灰色),输入框在下(38px 高度、深木色背景、宣纸暖白文字色)。onChange 回调将输入值透传给 onInput 参数,由调用方决定如何处理输入变化——新增弹窗直接赋值给对应的 inputXxx 状态变量,编辑弹窗同理。

这种"通用输入行 + 回调函数"的设计模式将输入 UI 的结构与数据处理逻辑解耦,同一个 fieldRow 构建器可以服务于新增和编辑两个弹窗,同时支持四种不同字段的输入,大大减少了重复代码。

11.2 新增弹窗 panelAdd

新增弹窗用于收录新古迹,是用户向名录中添加文物条目的主入口。

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

新增弹窗使用 Stack 层叠布局——底层是遮罩层,顶层是内容面板。内容面板宽度为屏幕的 86%,左右各留 7% 的边距,确保在各种屏幕尺寸下都有合适的视觉比例。面板内边距 16px,圆角 14px,深木色背景。

面板标题为"收录新古迹"(15号粗体宣纸暖白色),下方依次排列四个输入行:古迹名称、朝代、保护等级、所在区域。每个输入行的占位符提供了示例值,引导用户正确填写。

底部为操作按钮行,两个按钮各占一半宽度(layoutWeight(1))。左侧"取消"按钮为深木色背景 + 绢帛黄文字,右侧"确认收录"按钮为青铜绿背景 + 宣纸白文字。取消按钮直接调用 onClose() 关闭弹窗,确认按钮先调用 confirmAdd() 执行收录逻辑,再调用 onClose() 关闭弹窗。

11.2.1 确认收录逻辑
confirmAdd() {
  if (this.inputName.trim() === '') {
    return;
  }
  this.heritageList.unshift(new HeritageItem(
    this.inputName.trim(),
    this.inputDynasty.trim() === '' ? '未考' : this.inputDynasty.trim(),
    this.normLevel(this.inputLevel),
    this.inputRegion.trim() === '' ? '待考订' : this.inputRegion.trim(),
    60));
  this.clearInputs();
}

confirmAdd 方法处理新增确认逻辑。首先对古迹名称进行非空校验——若名称为空(去除首尾空格后),直接返回不执行任何操作,防止创建空名称的条目。

然后通过 unshift 方式将新 HeritageItem 插入名录顶部(最新收录的显示在最前面)。五个字段的处理策略各不相同:

  • 名称trim() 去除首尾空格后直接使用
  • 朝代:空值时填充默认值"未考",表示朝代待考证
  • 保护等级:通过 normLevel 函数归一化为标准名称(支持简写输入)
  • 区域:空值时填充默认值"待考订",表示地理位置待确认
  • 文保分:默认值 60 分(及格线),后续可通过编辑调整

最后调用 clearInputs() 清空四个输入字段,为下一次新增做准备。

11.3 编辑弹窗 panelEdit

编辑弹窗用于修订已有古迹的档案信息,打开时自动回填当前条目的字段值。

@Builder
panelEdit(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)
    Column({ space: 10 }) {
      Text('修订古迹档案').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Text('留空的字段将保持原值不变').fontSize(10).fontColor(COLORS.text3)
      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.confirmEdit();
            onClose();
          })
      }
      .width('100%')
    }
    .width('86%').padding(16).borderRadius(14).backgroundColor(COLORS.card)
  }
  .width('100%').height('100%')
  .alignContent(Alignment.Center)
}

编辑弹窗的布局结构与新增弹窗基本一致,但有几处差异:标题为"修订古迹档案",标题下方增加了一行提示文字"留空的字段将保持原值不变"(10号陶土灰色),告知用户部分修改的语义;占位符文字改为"留空保持原值"(除保护等级外),强化部分修改的交互预期。

打开编辑弹窗的逻辑由 openEdit 方法处理:

private openEdit(idx: number) {
  if (idx < 0 || idx >= this.heritageList.length) {
    return;
  }
  const target: HeritageItem = this.heritageList[idx];
  this.inputName = target.name;
  this.inputDynasty = target.dynasty;
  this.inputLevel = target.level;
  this.inputRegion = target.region;
  this.editIdx = idx;
  this.editModal = true;
}

openEdit 方法先进行边界校验(索引越界则返回),然后获取目标条目,将四个字段的值回填到对应的 inputXxx 状态变量中,设置 editIdx 为目标索引,最后打开编辑弹窗。这种"打开前回填"的模式确保用户看到的输入框中已经有当前值,方便进行修改。

11.3.1 确认编辑逻辑
confirmEdit() {
  if (this.editIdx < 0 || this.editIdx >= this.heritageList.length) {
    return;
  }
  const target: HeritageItem = this.heritageList[this.editIdx];
  if (this.inputName.trim() !== '') {
    target.name = this.inputName.trim();
  }
  if (this.inputDynasty.trim() !== '') {
    target.dynasty = this.inputDynasty.trim();
  }
  target.level = this.normLevel(this.inputLevel);
  if (this.inputRegion.trim() !== '') {
    target.region = this.inputRegion.trim();
  }
  this.clearInputs();
  this.editIdx = -1;
}

confirmEdit 方法处理编辑确认逻辑。首先进行索引边界校验,然后获取目标条目。逐字段判断:若输入非空则覆盖原值,若为空则保持原值不变——这就是"留空的字段将保持原值不变"提示的实际含义。

保护等级比较特殊——无论输入是否为空,都调用 normLevel 归一化后赋值。这是因为保护等级有三档标准值,即使输入为空,normLevel 也会返回默认的"市县级文物保护单位",但实际由于编辑时已回填了原值,用户若不修改等级字段,输入框中的值就是原来的标准名称,归一化后仍然是原值。

编辑完成后调用 clearInputs() 清空输入,并将 editIdx 重置为 -1。由于 HeritageItem@Observed 类,直接修改实例属性会自动触发 UI 刷新——首页双列卡的对应条目会即时更新,无需手动替换数组元素。

11.3.2 等级归一化函数
private normLevel(input: string): string {
  if (input.includes('全国')) {
    return '全国重点文物保护单位';
  }
  if (input.includes('省级')) {
    return '省级文物保护单位';
  }
  return '市县级文物保护单位';
}

normLevel 函数将用户输入的保护等级简写归一化为三档标准名称。使用 includes 进行模糊匹配——只要输入包含"全国"就映射到全国重点、包含"省级"就映射到省级、其余情况默认市县级。

这种宽容的输入匹配设计降低了用户的输入门槛——用户可以输入"国保"“全国”“国家级”"全国重点"等各种简写,都能被正确识别为最高等级。默认值设为市县级(最低档),避免因输入不匹配导致等级虚高。

11.4 删除弹窗 panelDel

删除弹窗用于确认移出名录,是一个危险操作确认对话框,设计上通过红色按钮强化警示语义。

@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%(比新增/编辑弹窗窄),因为内容较少——只有标题、确认文案和两个按钮。更窄的面板增强了"警告对话框"的视觉感受。

标题为"移出名录",下方为确认文案"确定将「XXX」移出收录名录吗?"——通过 delName() 方法动态获取目标古迹的名称,让用户明确知道自己在删除什么,防止误删。

按钮行左侧为"再想想"(取消按钮的委婉说法,符合文博主题的文案风格),深木色背景 + 绢帛黄文字;右侧为"确认移出",朱砂红色背景 + 宣纸白文字。删除按钮使用朱砂红色是整个应用中最强烈的视觉警示——红色与危险操作的关联是跨文化的通用设计语言,用户看到红色按钮会本能地谨慎操作。

11.4.1 确认删除逻辑
confirmDel() {
  if (this.delIdx < 0 || this.delIdx >= this.heritageList.length) {
    return;
  }
  this.heritageList.splice(this.delIdx, 1);
  this.delIdx = -1;
}

confirmDel 方法处理删除确认。首先进行索引边界校验,然后调用 splice 删除目标索引的 1 个条目,最后将 delIdx 重置为 -1。删除操作直接修改 heritageList 数组,由于数组是 @State 状态变量,首页双列卡会自动刷新,头部收录计数徽章也会自动更新。

删除目标名称的获取由 delName 方法处理:

private delName(): string {
  if (this.delIdx < 0 || this.delIdx >= this.heritageList.length) {
    return '未选择古迹';
  }
  return this.heritageList[this.delIdx].name;
}

delName 方法同样进行边界校验,越界时返回"未选择古迹"的兜底文案,确保即使状态异常也不会显示空白或崩溃。

11.5 弹窗系统的架构特点

整个弹窗系统体现了几个重要的架构设计原则:

独立状态控制。 三个弹窗使用三个独立的布尔状态变量(addModal/editModal/delModal),每个弹窗的显隐互不干扰。这种设计虽然理论上允许同时打开多个弹窗,但实际交互中通过按钮逻辑保证了每次只打开一个。独立状态的好处是弹窗之间的耦合度为零,新增或修改一个弹窗不会影响其他弹窗。

回调式关闭。 每个弹窗接收一个 onClose 回调函数,内部所有关闭操作(取消按钮、确认按钮、遮罩层点击)都调用这个回调,而不是直接修改 Modal 状态变量。这种设计将"如何关闭"的逻辑封装在弹窗内部,将"关闭后做什么"的决策权交给调用方,提高了弹窗的复用性。

输入状态共享。 新增和编辑弹窗共用同一组 inputXxx 状态变量,通过 clearInputs() 在打开前清空或通过 openEdit() 在打开前回填。这种共享设计减少了状态变量的数量,但要求开发者在打开弹窗前确保输入状态正确——这也是为什么快捷收录按钮点击时先调用 clearInputs() 再打开新增弹窗。

数据驱动的 UI。 弹窗中所有动态内容(删除目标名称、表单输入值、按钮可用性)都由状态变量驱动,遵循 ArkUI 的声明式范式。开发者只需维护状态,UI 自动跟随变化。

十二、功能模块对比表

以下从多个维度对比各功能模块的技术特性,帮助读者快速建立全局认知。

功能模块 所属 Tab 核心 API / 组件 关键状态变量 技术亮点
品牌头部 全局 @Builder headerMain heritageList.length 收录计数动态绑定,快捷收录按钮直达弹窗
双列古迹卡 首页 Flex + ForEach heritageList FlexWrap.Wrap 瀑布流双列,朝代/等级双徽章语义着色
月度柱状图 首页 ForEach + Column breath 纯组件柱状图,breath 联动柱高 ±8px 微波动
统计格 首页/我的 @Builder statCell/mineStat breath 呼吸式变色,一动两静数据组合
地图初始化 地图 MapComponent mapController / mapEventManager 五步初始化流程,十字段 Marker 属性全显式
Marker 长按监听 地图 onMarkerLongClick markerListenOn / eventLogs 动态注册/注销,记录 id 与经纬度
POI 长按监听 地图 onPoiLongClick poiListenOn / eventLogs mapCommon.Poi 仅 id/name/position 三字段
事件日志流 地图 List + ForEach eventLogs 三类事件语义着色,等宽字体坐标对齐
POI 搜索 搜索 site.searchByText queryInput / searchRecords reliability 量化三档评级,失败保留 Mock
搜索结果卡 搜索 Progress 线性 reliability 等级标签+进度条+精确数值三重展示
Web 浏览 百科 Web 组件 webController / webUrl 地址栏双状态分离,无前缀自动补 https://
快捷站点 百科 Scroll 横滑 urlInput / webUrl 当前 URL 匹配高亮,四站点一键跳转
下载委托 百科/下载 WebDownloadDelegate downloadDelegate / dlPercent 四回调全生命周期,onBeforeDownload 必须调 start()
双 URL 溯源 下载 getOriginalUrl / getReferrerUrl downloadRecords 6.1.1 新字段,原始 URL + 引用页 URL 联合追溯
主动下载 百科 webController.startDownload dlState 应用侧直接发起,无需网页内点击
像素画生成 工坊 createPixelMap / ImagePacker pixelMap / textureSel 五种纹理算法,RGBA_8888 四字节一像素
WebP 编码落盘 工坊 packToData / fileIo webpPath / genState 90 质量 WebP,沙箱 `READ_WRITE
元数据读取 工坊 readImageMetadataByType metaSnapshot WEBP_METADATA 类型化读取,五字段 -1 占位兜底
元数据写回 工坊 writeImageMetadata writeDelay / writeLoop 字面量构造 WebPMetadata,全可选字段
回读校验 工坊 重建 ImageSource verifySnapshot 先 release 再重开 fd,写入值与回读值比对
操作日志 工坊 ForEach opLogs 生成/读取/写入/回读四类审计追踪
探访者画像 我的 linearGradient breath 135 度青铜渐变大卡,统计格呼吸变色
足迹清单 我的 ForEach MARKER_SPOTS 六处标注点已踏访状态展示,数据复用
权限说明 我的 ForEach ABOUT_ROWS 三特性 + 版本号四行声明,技术透明化
底部导航 全局 Row + ForEach currentTab 七 Tab 单排,鎏金选中色高对比度
新增弹窗 全局 Stack + modalOverlay addModal / inputXxx 四字段录入,朝代/区域空值兜底,等级归一化
编辑弹窗 全局 Stack + modalOverlay editModal / editIdx 回填原值 + 留空保持,部分修改语义
删除弹窗 全局 Stack + modalOverlay delModal / delIdx 朱砂红按钮警示,确认文案含目标名称

12.1 三大核心特性对比

特性维度 Map Kit 双长按 ArkWeb 双 URL 溯源 ImageKit WebP 元数据
承载 Tab 地图 百科 + 下载 工坊
核心 API onMarkerLongClick / onPoiLongClick getOriginalUrl() / getReferrerUrl() readImageMetadataByType / writeImageMetadata
新增版本 6.1.1 6.1.1 (API 24) 6.1.1
数据实体 EventLog DownloadRecord WebpMetaSnapshot + MetaOpLog
状态变量数 5 个 7 个 9 个
核心方法数 3 个(setup/toggleMarker/togglePoi) 3 个(setup/trigger/loadUrl) 5 个(gen/read/write/verify/pick)
交互复杂度 中(开关 + 日志) 中(进度 + 记录) 高(四区块 + 操作流)
业务价值 地图交互深度 版权追溯证据链 元数据闭环验证
调试难度
代码行数占比 ~15% ~20% ~30%

三大特性在技术深度和代码量上呈现递增趋势:Map Kit 双长按相对最直观(事件回调 + 日志记录),ArkWeb 下载溯源涉及异步生命周期管理,ImageKit WebP 元数据则涉及像素级操作和多步状态流转。这种梯度设计使应用既有易理解的入门特性,又有足够深入的高级特性,适合作为技术演示和学习样本。

十三、总结与展望

本文深度解析了基于 HarmonyOS ArkUI 框架构建的文化遗产探索地图应用,该应用以"青铜锈绿 + 鎏金"的深色文博主题为视觉基调,通过七个布局风格各异的 Tab 覆盖文博探索的完整链路。三大核心特性的技术贡献各有侧重:Map Kit 的双长按监听能力为地图交互提供了事件级深度,reliability 相关性分数为 POI 搜索提供了可信度量化;ArkWeb 的 WebDownloadDelegate 四回调配合 getOriginalUrlgetReferrerUrl 双 URL 字段,为数字文博资源的版权追溯构建了完整证据链;ImageKit 的 readImageMetadataByTypewriteImageMetadata 配合"生成-读取-写入-回读"四步流水线,为老照片数字档案的元数据管理提供了闭环验证机制。

从架构设计角度看,本应用体现了声明式 UI 范式的诸多优势。组件化拆分使每个功能模块独立封装、职责清晰——二十余个 @Builder 方法将复杂界面拆解为可复用的构建单元,新增功能时只需添加新的构建器而不影响现有代码。状态分层管理使数据流清晰可控——六组状态变量按业务域分组声明,@Observed 装饰器实现字段级响应式,@State 管理 UI 级状态,private 隐藏非响应式对象,层次分明。差异化布局策略使不同特性的 Tab 各得其所——需要全屏承载的地图和 Web 组件直接撑高,内容型 Tab 统一走 Scroll 滚动容器,兼顾了功能性和一致性。

从视觉设计角度看,深色文博主题的色彩体系经过精心打磨。青铜绿与鎏金的主辅色搭配既呼应了青铜器文物的材质质感,又提供了足够的对比度保证可读性。保护等级的三色梯度、搜索相关性的三色梯度、事件类型的三色梯度,构成了完整的语义化配色系统,用户无需阅读文字即可通过颜色直觉判断信息等级。呼吸动画、柱状图波动等微动效的运用,让静态界面有了"生命力",营造出实时数据更新的视觉氛围。

从工程实践角度看,本应用在多个层面体现了良好的代码质量。工具函数的抽离使通用逻辑与 UI 渲染分离,纯函数的设计便于测试和复用。Mock 数据的精心设计保证了离线演示链路的完整性,失败时的优雅降级策略使应用在无网络、无签名等环境下仍可正常展示核心功能。操作日志和状态文案的全链路覆盖,为用户提供了清晰的操作反馈,也便于开发者调试定位问题。

展望未来,本应用可在以下方向持续演进。第一,地图交互可引入 addMarker 的批量动画与聚合策略,当标注点扩展到数十处时实现缩放级聚合以保持视觉清晰;searchByText 可增加多关键字联合查询与结果分页,提升大型遗址区域的搜索体验;双长按监听可扩展为自定义信息窗展示,在长按 Marker 时弹出古迹详情卡片。第二,下载溯源可引入 referrerUrl 的页面快照缓存机制,在引用页失效时仍可通过快照回溯下载场景,进一步增强数字资源的长期可追溯性;下载记录可增加分类筛选和搜索功能,当记录数量增长时仍能快速定位目标文件。第三,WebP 元数据工坊可扩展 delayTimeloopCount 之外的帧级元数据读写,支持多帧 WebP 动画的逐帧编辑与预览,为老照片动态化修复提供更精细的工具支持;纹理生成可引入真实老照片的风格迁移算法,使生成的样图更具历史质感。第四,弹窗系统可引入表单验证的实时反馈,在输入过程中即时提示格式错误而非等到提交时才拦截;可增加弹窗进入/退出的过渡动画,提升交互流畅度。第五,整体架构可探索 @StorageLink 跨组件状态共享与 Navigation 路由管理,为多页面文博应用提供更规范的导航与数据流方案;可引入 @Provide/@Consume 实现跨层级数据传递,减少状态变量的层层传递。

随着 HarmonyOS 生态的持续演进,文博文旅应用将拥有更丰富的设备能力与更广阔的创新空间。从 Map Kit 的三维地形渲染到 ArkWeb 的离线资源包,从 ImageKit 的 AI 图像修复到分布式数据管理的跨设备协同,文化遗产的数字化探索之路将越走越宽。而 ArkUI 声明式框架为这一切提供了坚实的技术底座——开发者只需专注于业务逻辑与用户体验,框架自动处理渲染、状态和生命周期,让创意能够更快地落地为产品。

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

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


一、创建新项目

1.1 进入欢迎界面

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

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

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

在这里插入图片描述

1.2 选择项目模板

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

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

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

在这里插入图片描述

1.3 配置项目信息

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

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

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

在这里插入图片描述

1.4 完成创建

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

  1. 生成项目骨架(Stage 模型目录结构)
  2. 执行 ohpm install 安装依赖
  3. 运行 Hvigor 构建初始化(Build Init

构建日志中显示 “退出代码为 0” 表示项目初始化成功。

在这里插入图片描述

1.5 项目结构概览

创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:

rollboat/
├── .hvigor/                   # Hvigor 构建工具缓存
├── .idea/                     # IDE 配置文件
├── AppScope/                  # 应用级全局配置
│   └── app.json5
├── entry/                     # 主模块(入口模块)
│   ├── src/main/ets/
│   │   ├── entryability/      # Ability 生命周期管理
│   │   │   └── EntryAbility.ets
│   │   └── pages/             # UI 页面
│   │       └── Index.ets      # 首页(默认 Hello World)
│   ├── src/main/resources/    # 资源文件
│   ├── module.json5           # 模块配置
│   └── build-profile.json5    # 构建配置
├── oh_modules/                # OHPM 依赖包
├── build-profile.json5        # 工程构建配置
├── hvigorfile.ts              # Hvigor 构建脚本
└── oh-package.json5           # 包管理配置

核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:

@Entry
@Component
struct Index {
  @State message: string = 'Hello World';

  build() {
    RelativeContainer() {
      Text(this.message)
        .id('HelloWorld')
        .fontSize($r('app.float.page_text_font_size'))
        .fontWeight(FontWeight.Bold)
        .alignRules({
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .onClick(() => {
          this.message = 'Welcome';
        })
    }
    .height('100%')
    .width('100%')
  }
}
关键语法 作用
@Entry 标记为页面入口,可用于路由跳转
@Component 声明为自定义组件
@State 状态变量,数据变更时自动触发 UI 刷新
RelativeContainer 相对布局容器,替代传统线性布局
.onClick() 点击事件,此处点击后文本变为 “Welcome”

打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:

文件 → 设置 → HarmonyOS SDK(或快捷键 Ctrl + Alt + S 搜索 “HarmonyOS SDK”)

在设置面板中,可以看到当前已安装的 SDK 版本信息:

名称 阶段 状态
HarmonyOS 6.1.1 Release ✅ 已安装

界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

在这里插入图片描述

2.2 查看 ArkUI-X SDK(跨平台扩展)

如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:

文件 → 设置 → 语言和框架 → ArkUI-X

在这里可以查看已安装和可选的 ArkUI-X SDK 版本:

版本 SDK 版本号 阶段 状态
API Version 24 6.1.1.100 Release ✅ 已安装
API Version 23 6.1.0.28 Beta1 未安装
API Version 22 6.0.2.112 Release 未安装

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

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

在这里插入图片描述


三、小结

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

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


Logo

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

更多推荐