一、技术前言
在出境旅游服务领域,跨境行程管理正从"碎片化工具拼凑"走向"一体化智能向导"的深刻变革。从香港维港夜景打卡到东京浅草寺文化漫游,从曼谷水上市场淘货到新加坡环球影城亲子游,每一趟跨境旅程都需要精准的目的地导航、实时的多语种沟通能力和可靠的行程提醒机制。传统旅行应用面临三大痛点:地图交互局限于点击无法长按探索地标细节、外语讲解字幕语言切换僵硬导致跨文化沟通断裂、通知铃声千篇一律导致值机集合提醒被忽略。
HarmonyOS ArkUI 框架以其声明式 UI 范式为这些挑战提供了系统级解决方案。ArkUI 基于 TypeScript 扩展的 ArkTS 语言构建,其核心设计哲学包含三大支柱:声明式渲染 ------开发者只需描述界面"是什么"而非"怎么构建",框架通过虚拟 DOM diff 算法自动完成最小化 DOM 更新,使 UI 状态与数据模型保持同步;组件化架构 ------通过 @Component 装饰器将界面拆分为独立可复用的组件单元,每个组件拥有自己的状态管理和生命周期,组件间通过参数传递和回调函数实现松耦合通信;状态驱动机制 ------@State 装饰器监听变量变化并自动触发关联 UI 的重渲染,@Observed 装饰器使类的实例具备可观察性,当对象属性变更时通知所有引用处刷新,实现数据到视图的单向流动。这种架构天然适合旅行场景中"行程-地图-沟通"三位一体的紧耦合需求。
本平台深度融合了 HarmonyOS 6.1.1 的三大前沿特性。Map Kit 提供了 MapComponent 组件与 searchByText 检索能力的完整链路------通过 MapComponentController 的 getEventManager() 获取事件管理器,注册 onMarkerLongClick 和 onPoiLongClick 双长按监听,用户长按地图标注或兴趣点即可触发坐标采集与事件日志;同时 site.searchByText 接口返回的 Site 数组携带 reliability 相关性分数字段,让搜索结果按高/中/低三档可视化排序。Speech Kit 的 AI 字幕组件引入了 sourceLanguage、targetLanguage、fontSize、fontColor 四个 6.1.1 新字段,支持中英双向翻译、中英双语对照、四档字号调节和五色字体预设,配合 writeAudio 640 字节 PCM 块写入实现实时语音转字幕。Notification Kit 实现了沙箱自定义铃声链路------通过 buildWavBytes 生成正弦波 PCM 音频,写入 EL1 沙箱 filesDir,再以 'uri::' + fileUri.getUriFromPath(沙箱路径) 填入 NotificationRequest.sound,让登机叮咚、口岸钟声、集合哨声等旅行场景铃声各具特色。
平台采用"夜航深蓝 + 霓虹青 + 落日橙"的深色主题设计,7 个 Tab 各自拥有完全独立的布局架构:行程 Tab 以目的地横滑大卡和行程清单行展现跨境规划全景,地图 Tab 以双长按 Toggle 和事件日志流呈现地图交互闭环,搜索 Tab 以关键字检索和 reliability 分数条展示 POI 相关性量化,提醒 Tab 以时间轴和授权卡构建通知管理中枢,铃音 Tab 以铃声库和沙箱状态呈现自定义铃声链路,字幕 Tab 以五区块设置面板演绎 AI 字幕四新字段,我的 Tab 以旅行家渐变大卡和足迹国家清单呈现用户画像。三大特性分别挂载在四个 Tab 上,但状态变量统一声明在组件顶层,实现跨 Tab 数据共享与协同联动。
二、整体架构流程图
#mermaid-svg-BSk2RjRHLhhrfcGA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-BSk2RjRHLhhrfcGA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-BSk2RjRHLhhrfcGA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-BSk2RjRHLhhrfcGA .error-icon{fill:#552222;}#mermaid-svg-BSk2RjRHLhhrfcGA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BSk2RjRHLhhrfcGA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-BSk2RjRHLhhrfcGA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BSk2RjRHLhhrfcGA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BSk2RjRHLhhrfcGA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-BSk2RjRHLhhrfcGA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BSk2RjRHLhhrfcGA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BSk2RjRHLhhrfcGA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BSk2RjRHLhhrfcGA .marker.cross{stroke:#333333;}#mermaid-svg-BSk2RjRHLhhrfcGA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BSk2RjRHLhhrfcGA p{margin:0;}#mermaid-svg-BSk2RjRHLhhrfcGA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-BSk2RjRHLhhrfcGA .cluster-label text{fill:#333;}#mermaid-svg-BSk2RjRHLhhrfcGA .cluster-label span{color:#333;}#mermaid-svg-BSk2RjRHLhhrfcGA .cluster-label span p{background-color:transparent;}#mermaid-svg-BSk2RjRHLhhrfcGA .label text,#mermaid-svg-BSk2RjRHLhhrfcGA span{fill:#333;color:#333;}#mermaid-svg-BSk2RjRHLhhrfcGA .node rect,#mermaid-svg-BSk2RjRHLhhrfcGA .node circle,#mermaid-svg-BSk2RjRHLhhrfcGA .node ellipse,#mermaid-svg-BSk2RjRHLhhrfcGA .node polygon,#mermaid-svg-BSk2RjRHLhhrfcGA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-BSk2RjRHLhhrfcGA .rough-node .label text,#mermaid-svg-BSk2RjRHLhhrfcGA .node .label text,#mermaid-svg-BSk2RjRHLhhrfcGA .image-shape .label,#mermaid-svg-BSk2RjRHLhhrfcGA .icon-shape .label{text-anchor:middle;}#mermaid-svg-BSk2RjRHLhhrfcGA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-BSk2RjRHLhhrfcGA .rough-node .label,#mermaid-svg-BSk2RjRHLhhrfcGA .node .label,#mermaid-svg-BSk2RjRHLhhrfcGA .image-shape .label,#mermaid-svg-BSk2RjRHLhhrfcGA .icon-shape .label{text-align:center;}#mermaid-svg-BSk2RjRHLhhrfcGA .node.clickable{cursor:pointer;}#mermaid-svg-BSk2RjRHLhhrfcGA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-BSk2RjRHLhhrfcGA .arrowheadPath{fill:#333333;}#mermaid-svg-BSk2RjRHLhhrfcGA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-BSk2RjRHLhhrfcGA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-BSk2RjRHLhhrfcGA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BSk2RjRHLhhrfcGA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-BSk2RjRHLhhrfcGA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BSk2RjRHLhhrfcGA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-BSk2RjRHLhhrfcGA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-BSk2RjRHLhhrfcGA .cluster text{fill:#333;}#mermaid-svg-BSk2RjRHLhhrfcGA .cluster span{color:#333;}#mermaid-svg-BSk2RjRHLhhrfcGA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-BSk2RjRHLhhrfcGA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-BSk2RjRHLhhrfcGA rect.text{fill:none;stroke-width:0;}#mermaid-svg-BSk2RjRHLhhrfcGA .icon-shape,#mermaid-svg-BSk2RjRHLhhrfcGA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BSk2RjRHLhhrfcGA .icon-shape p,#mermaid-svg-BSk2RjRHLhhrfcGA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-BSk2RjRHLhhrfcGA .icon-shape .label rect,#mermaid-svg-BSk2RjRHLhhrfcGA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BSk2RjRHLhhrfcGA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-BSk2RjRHLhhrfcGA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-BSk2RjRHLhhrfcGA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 数据模型层
弹窗系统层
三大特性能力链
内容区 Tab 层
页面布局层
根组件层
Page1224 主组件
headerMain 头部区域
应用名+副标题+三特性胶囊+呼吸圆点
内容区 7 Tab 切换
tabBar 底部导航栏
modalOverlay 弹窗遮罩系统
Tab0 行程
目的地横滑大卡+行程清单行+月度柱状图
Tab1 地图
双长按Toggle+MapComponent+事件日志流
Tab2 搜索
关键字搜索+reliability分数条列表
Tab3 提醒
授权卡+时间轴+发布提醒+通知历史
Tab4 铃音
铃声预览卡+EL1沙箱铃声库
Tab5 字幕
AICaption预览+语言/字号/颜色/场景五区块
Tab6 我的
旅行家渐变大卡+足迹国家清单
Map Kit
Marker/POI双长按监听
Map Kit
searchByText reliability
Speech Kit
AI字幕四新字段
Notification Kit
EL1沙箱自定义铃声
panelAdd 新增行程
panelEdit 编辑行程
panelDel 删除确认
TripItem 行程条目
SearchRecord 搜索结果
EventLog 事件日志
RemindItem 提醒条目
RingItem 铃声条目
NoticeLog 通知历史
CaptionScene 字幕场景
FootRow 足迹国家
整体架构以 Page1224 为根组件,采用 Stack 容器实现页面层叠:底层是 Column 纵向布局的头部区域、分割线、内容区和底部 Tab 栏,顶层是三组全屏弹窗遮罩(新增、编辑、删除)。内容区的设计有一个关键决策:地图 Tab 需要有界高度(MapComponent 依赖 layoutWeight(1) 占满剩余空间),因此当 currentTab === 1 时直接渲染 tabMap() 而不包裹在 Scroll 中;其余 6 个 Tab 则统一包裹在 Scroll 容器内,支持内容超屏滚动。
三大特性(Map Kit 双长按监听与搜索、Speech Kit AI 字幕四字段、Notification Kit 沙箱铃声)分别挂载在地图、搜索、字幕和铃音四个 Tab 上,但它们的状态变量统一声明在组件顶层,实现跨 Tab 数据共享------例如铃音 Tab 设定的 currentRing 在提醒 Tab 的发布通知方法中被直接引用,字幕 Tab 选择的 srcLang 在头部胶囊中实时展示。数据模型层的八个 @Observed 类/接口分别支撑各自 Tab 的列表渲染,TripItem 同时被弹窗系统引用以实现 CRUD 操作。弹窗系统采用"三态标志 + 全屏遮罩 + 居中面板"的统一模式,新增/编辑/删除三个弹窗各自独立条件渲染,modalOverlay 提供半透黑遮罩并响应空白点击关闭。
三、色彩体系设计
3.1 ColorPalette 接口定义
平台采用深色夜航主题,通过 ColorPalette 接口集中声明全部颜色字段,使全文件色彩管理统一可控:
typescript
interface ColorPalette {
bg: string; // 页面背景(夜航深蓝)
card: string; // 卡片底色(深海军蓝)
title: string; // 主标题(冷白)
sub: string; // 副标题(雾蓝灰)
text3: string; // 三级弱文本(暗蓝灰)
cyan: string; // 霓虹青(主色)
cyanD: string; // 霓虹青深色
orange: string; // 落日橙(辅助暖色)
purple: string; // 星紫(电子签徽标)
green: string; // 通过绿(免签徽标)
red: string; // 警示红(删除 / 失败)
line: string; // 分割线
tabOn: string; // Tab 选中色
mask: string; // 弹窗遮罩
}
这段接口定义体现了 ArkTS 的类型安全优势。与普通 JavaScript 动态添加属性不同,ColorPalette 接口在编译期即约束所有颜色字段必须是 string 类型,任何拼写错误或类型不匹配都会在编译阶段暴露。接口注释采用"字段名 + 用途"的格式,使每个颜色的语义角色一目了然,后续维护者无需追踪代码即可理解色彩用途。接口中包含 14 个颜色字段,从背景到强调色形成完整的视觉层级,同时支持深色主题下的多层次对比需求。
3.2 COLORS 常量逐色分析
typescript
const COLORS: ColorPalette = {
bg: '#0D1522',
card: '#16233A',
dark: '#1D2E4A',
title: '#E8F0FA',
sub: '#9DB4D0',
text3: '#67819E',
cyan: '#38C8D8',
cyanD: '#2298A8',
orange: '#FF8A4C',
purple: '#8A7FE8',
green: '#4EC98A',
red: '#E86060',
line: '#223450',
tabOn: '#38C8D8',
mask: 'rgba(0,0,0,0.6)'
};
色彩设计遵循"夜航"主题原则,每一色都有明确的语义角色。bg 为 #0D1522 夜航深蓝黑,模拟机舱舷窗外的深邃夜空,降低屏幕亮度对眼睛的刺激,适合夜间旅行场景。card 为 #16233A 深海军蓝卡片底色,比背景亮一档,形成柔和的卡片边界。dark 为 #1D2E4A 次级容器底色,用于统计格、胶囊背景和进度条底色,比卡片底色再亮一档,形成三级深度关系。
title 为 #E8F0FA 冷白色标题,暗光环境下高对比可读,与深色背景形成清晰的主视觉焦点。sub 为 #9DB4D0 雾蓝灰副标题,在标题与弱文本之间架起层次过渡,柔和不刺眼。text3 为 #67819E 暗蓝灰弱文本,用于辅助说明和时间戳,视觉权重最低。
cyan 为 #38C8D8 霓虹青,平台主色,象征地图航线的荧光轨迹,贯穿地图标注、按钮主色、Tab 选中态和柱状图渐变。cyanD 为 #2298A8 霓虹青深色,用于渐变起点和已生成铃声的按钮态,与主色形成明暗层次。orange 为 #FF8A4C 落日橙辅助暖色,象征异国黄昏的温暖光感,用于 POI 长按、未授权警示和距离数值。purple 为 #8A7FE8 星紫色,专用于电子签徽标和字幕目标语言选中态,与主青色形成冷暖对比。green 为 #4EC98A 通过绿,用于免签徽标和沙箱就绪状态,传递"畅通""成功"的语义。red 为 #E86060 警示红,仅用于删除操作和失败信息,通过低频使用强化警示语义。
line 为 #223450 深蓝灰分割线,同时复用为部分边框色,低对比度不干扰内容。tabOn 与 cyan 同值,保证 Tab 选中态与主色一致。mask 为半透明深色 rgba(0,0,0,0.6),弹窗遮罩使用 RGBA 格式实现 60% 透明度,与深色系协调。
头部区域采用纯色背景搭配三特性状态胶囊(地图长按、字幕语言、通知授权),让用户在顶部即可一览三大特性的实时状态。右侧的霓虹青呼吸圆点随 breath 状态在 1 和 0.25 透明度间切换,实现"心跳"效果提示实时刷新。
四、Tab 元数据与辅助数据
4.1 底部导航 Tab 定义
typescript
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 字符串,label 为中文标签文字。TAB_LIST 常量数组按顺序声明七个 Tab 项,分别对应行程、地图、搜索、提醒、铃音、字幕和我的。这种将导航元数据与 UI 渲染分离的设计使 Tab 配置可独立维护,新增或调整 Tab 只需修改数组而无需触碰 @Builder 方法。底部导航栏在 tabBar() 构建器中通过 ForEach 遍历此数组渲染,选中态通过 currentTab 索引与 index 比较判断。
七个 Tab 的 emoji 图标各具象征意义:🧭 指南针代表行程规划与方向导航,🗺️ 地图图标代表地图探索与地标发现,🔍 放大镜代表景点搜索与 POI 检索,⏰ 闹钟代表行程提醒与时间管理,🎵 音符代表铃声定制与通知音效,🗣 说话头像代表 AI 字幕与语音翻译,👤 人像代表个人中心与旅行足迹。这种视觉化的 Tab 设计使用户在小屏幕上也能快速识别功能入口。
4.2 头部副标题联动数据
typescript
const TAB_SUBS: string[] = [
'跨境行程总览与签证速览',
'香港地标长按探索',
'景点 POI 相关性搜索',
'行程提醒与本地通知',
'沙箱自定义通知铃声',
'AI 字幕多语讲解',
'旅行家主页与足迹'
];
TAB_SUBS 数组定义了头部副标题与 Tab 的一一对应关系。当用户切换 Tab 时,头部副标题通过 TAB_SUBS[this.currentTab] 实时更新,为每个 Tab 提供一句精炼的功能描述。这种设计使用户在切换 Tab 时立即获得上下文确认,知道当前所处的功能模块。七个副标题分别概括了各 Tab 的核心价值:行程 Tab 强调"总览"与"签证速览",地图 Tab 强调"地标长按探索",搜索 Tab 强调"POI 相关性搜索",提醒 Tab 强调"行程提醒与本地通知",铃音 Tab 强调"沙箱自定义铃声",字幕 Tab 强调"AI 字幕多语讲解",我的 Tab 强调"旅行家主页与足迹"。
4.3 地图标注与搜索数据
typescript
const CITY_CENTER: mapCommon.LatLng = { latitude: 22.3193, longitude: 114.1694 };
interface SpotItem {
name: string;
lat: number;
lng: number;
tag: string;
}
const MARKER_SPOTS: SpotItem[] = [
{ name: '维多利亚港', lat: 22.2938, lng: 114.1722, tag: '夜景' },
{ name: '太平山顶', lat: 22.2759, lng: 114.1455, tag: '观景' },
{ name: '尖沙咀星光大道', lat: 22.2930, lng: 114.1718, tag: '海滨' },
{ name: '旺角街市', lat: 22.3217, lng: 114.1697, tag: '市集' },
{ name: '香港迪士尼乐园', lat: 22.3130, lng: 114.0420, tag: '乐园' },
{ name: '港珠澳大桥口岸', lat: 22.4947, lng: 113.9770, tag: '口岸' }
];
CITY_CENTER 定义了香港中环的经纬度坐标(22.3193, 114.1694),作为地图初始视野中心和 POI 搜索的 location 基准点。选择香港作为基准城市体现了跨境旅行向导的定位------香港是内地游客出境游的重要枢纽和首站目的地。SpotItem 接口定义了地标标注点的四字段结构,MARKER_SPOTS 数组包含六处香港跨境游客常用地标的模拟数据,覆盖夜景、观景、海滨、市集、乐园、口岸六种业态标签。这些数据在 setupMapCallback 中通过 mapController.addMarker 批量添加到地图上。
六个地标选择精心覆盖了香港旅游的核心体验:维多利亚港是香港夜景的代名词,太平山顶是俯瞰维港的最佳观景点,尖沙咀星光大道是海滨文化地标,旺角街市代表了市井文化,香港迪士尼乐园是亲子游首选,港珠澳大桥口岸则体现了跨境交通枢纽的主题。每个地标的 tag 字段为未来扩展筛选功能预留了数据基础。
typescript
const QUICK_QUERIES: string[] = ['景点', '餐厅', '地铁站', '酒店', '口岸', '博物馆'];
QUICK_QUERIES 定义了搜索 Tab 的六个快捷关键字 chip,覆盖跨境游客最常搜索的 POI 类型。点击任一 chip 即可将关键字填入搜索框并立即触发搜索,减少用户输入成本。六个关键字按照"游览→餐饮→交通→住宿→出入境→文化"的旅行决策逻辑排列,符合跨境游客的搜索习惯。
4.4 字幕语言与字号数据
typescript
interface LangOption {
code: string;
name: string;
}
const SRC_LANGS: LangOption[] = [
{ code: 'zh', name: '中文' },
{ code: 'en', name: '英文' }
];
const TGT_LANGS_EN: LangOption[] = [
{ code: 'zh', name: '中文' },
{ code: 'en', name: '英文' },
{ code: 'zh-en', name: '中英双语' }
];
LangOption 接口定义了语言选项的两字段结构:code 为语言码(对应 AI 字幕组件的 sourceLanguage 和 targetLanguage 字段取值),name 为中文展示名。SRC_LANGS 定义了两个源语言选项------中文和英文,对应 AICaptionOptions.sourceLanguage 的 'zh' | 'en' 类型约束。TGT_LANGS_EN 定义了英文源时的三个目标语言选项------中文翻译、英文原文、中英双语对照。中文源时目标语言锁定为 'zh'(原文直显不翻译),因此不需要独立的选项数组。
这种语言联动设计体现了产品的精细化思考:中文作为源语言时,翻译方向无意义(中译中是伪需求),因此直接锁定目标语言为中文;英文作为源语言时,用户可能需要纯中文翻译(快速理解)、纯英文原文(练习听力)或中英双语(对照学习),因此提供三选一。switchSourceLang 方法确保了源语言切换时目标语言的自动联动,避免了"中文源选择英文翻译"的无意义组合。
typescript
interface SizeOption {
size: AICaptionFontSize;
name: string;
}
const SIZE_OPTIONS: SizeOption[] = [
{ size: AICaptionFontSize.SMALL, name: '小号' },
{ size: AICaptionFontSize.NORMAL, name: '标准' },
{ size: AICaptionFontSize.BIG, name: '大号' },
{ size: AICaptionFontSize.LARGE, name: '超大' }
];
SizeOption 接口将 AICaptionFontSize 枚举值与中文名称绑定,四档字号从小到大依次为小号(SMALL)、标准(NORMAL)、大号(BIG)、超大(LARGE)。在字幕 Tab 的字号选择区,每个选项使用不同大小的示例文字直观展示字号效果------小号用 11px、标准用 14px、大号用 17px、超大用 20px,让用户在选择前即可预览实际效果。
typescript
const CAPTION_FONT_COLORS: string[] = ['#FFFFFF', '#7CE8F5', '#FFD9A8', '#C9F2D9', '#FFC2CE'];
CAPTION_FONT_COLORS 定义了五种字幕字体颜色预设,从经典白到霓虹青、落日暖杏、薄荷绿、樱花粉,覆盖了不同场景下的字幕可读性需求。经典白 #FFFFFF 是默认选择,适用于大多数场景;霓虹青 #7CE8F5 与主色呼应,适合科技感场景;落日暖杏 #FFD9A8 温暖柔和,适合夜间讲解;薄荷绿 #C9F2D9 清新护眼,适合长时间观看;樱花粉 #FFC2CE 温馨浪漫,适合特定主题。每种颜色都经过对比度验证,确保在深色字幕背景上的可读性。
4.5 行程与足迹数据
typescript
const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const MONTH_DAYS: number[] = [3, 5, 2, 6, 4, 8];
MONTH_NAME 和 MONTH_DAYS 为近 6 个月跨境出行天数数据,驱动行程 Tab 和我的 Tab 的柱状图。数据呈现波动上升趋势------从 3 月 3 天逐步增长到 8 月 8 天,8 月达到峰值,反映了暑期旅游旺季的出行规律。柱状图使用纯 ArkUI 组件(非 Canvas)绘制,柱高随 breath 状态在 1 和 0.92 系数间切换,实现呼吸波动效果。
typescript
interface FootRow {
flag: string;
name: string;
cities: number;
last: string;
}
const FOOT_ROWS: FootRow[] = [
{ flag: '🇭🇰', name: '中国香港', cities: 18, last: '08月' },
{ flag: '🇯🇵', name: '日本', cities: 6, last: '07月' },
{ flag: '🇹🇭', name: '泰国', cities: 3, last: '05月' },
{ flag: '🇸🇬', name: '新加坡', cities: 1, last: '04月' },
{ flag: '🇰🇷', name: '韩国', cities: 2, last: '03月' },
{ flag: '🇲🇴', name: '中国澳门', cities: 2, last: '02月' }
];
FootRow 接口定义了足迹国家清单行的四字段结构:flag 为国旗 emoji(提供视觉化的国家标识),name 为国家/地区名,cities 为解锁城市数量,last 为最近到访月份。FOOT_ROWS 包含 6 条足迹记录,按最近到访时间倒序排列(中国香港 08 月最新,中国澳门 02 月最早)。六个目的地涵盖了亚洲热门出境游目的地,城市数量从 18 到 1 不等,体现了旅行深度的差异。
五、工具函数分析
5.1 搜索相关性等级映射
typescript
interface ScoreLevel {
label: string;
color: string;
}
function reliabilityScore(score: number): ScoreLevel {
if (score >= 0.8) {
return { label: '高相关', color: COLORS.green };
}
if (score >= 0.5) {
return { label: '中相关', color: COLORS.orange };
}
return { label: '低相关', color: COLORS.text3 };
}
ScoreLevel 接口定义了相关性等级的返回结构:label 为中文标签,color 为对应颜色。reliabilityScore 函数接收 Map Kit searchByText 返回的 reliability 分数(取值范围 0 至 1),通过两道阈值判断将其分为三档:0.8 及以上为"高相关"配通过绿、0.5 及以上为"中相关"配落日橙、其余为"低相关"配暗蓝灰。
这种分级设计使搜索结果不依赖具体数值即可凭颜色快速判断匹配质量,绿橙灰三色形成"优---中---弱"的直觉梯度。返回的对象同时携带标签文字和颜色值,调用方可直接将 label 渲染为胶囊文字、color 渲染为胶囊文字色和进度条颜色,实现数据到视图的一步映射。在搜索结果列表中,Progress 组件的 color 属性直接绑定 reliabilityScore(rec.reliability).color,使分数条颜色与等级标签颜色完全一致,视觉信息传达高度统一。
5.2 事件类型与签证类型颜色映射
typescript
function typeColor(type: string): string {
if (type === 'Marker') {
return COLORS.cyan;
}
if (type === 'POI') {
return COLORS.orange;
}
return COLORS.text3;
}
typeColor 函数将地图长按事件类型映射为徽标色:Marker 类型配霓虹青(与地图标注点视觉一致,呼应主色系),POI 类型配落日橙(暖色调与冷色 Marker 形成对比,便于快速区分),其余回退暗蓝灰。在地图 Tab 的长按事件日志流中,每条日志的类型徽标背景色由该函数决定------深色徽标搭配深色文字(COLORS.bg)形成高对比度,确保小尺寸文字的可读性。
typescript
function visaColor(visa: string): string {
if (visa.startsWith('免签')) {
return COLORS.green;
}
if (visa.startsWith('落地签')) {
return COLORS.cyan;
}
if (visa.startsWith('电子签')) {
return COLORS.purple;
}
return COLORS.orange;
}
visaColor 函数将签证类型映射为徽标色,是行程 Tab 的核心色彩映射工具。四种签证类型对应四种语义色:免签配通过绿(最便捷,畅通无阻)、落地签配霓虹青(较便捷,抵达后办理)、电子签配星紫(需提前在线申请)、需面签配落日橙(最繁琐,需预约面签)。使用 startsWith 进行前缀匹配而非全等匹配,是为了兼容"免签备案""免签入境"等不同表述方式的签证类型,增强函数的容错性和适配能力。
签证颜色在行程 Tab 的多个位置复用:横滑大卡中的签证类型文字色、行程清单行中的签证徽标背景色和文字色,以及行程详情中的状态指示。这种统一的色彩映射使用户在不同视图中都能通过颜色快速识别签证便利程度。
5.3 时间戳函数
typescript
function nowTime(): string {
const d = new Date();
const p = (n: number) => n.toString().padStart(2, '0');
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
nowTime 函数获取当前时刻并格式化为 HH:mm:ss 字符串。内部定义了局部函数 p(pad 的缩写),将个位数补零(如 9 变为 "09"),通过 padStart(2, '0') 实现两位补齐。该函数在地图长按事件日志和通知历史时间戳两处使用,确保时间格式统一。使用局部函数而非重复编写 padStart 逻辑,体现了代码的 DRY(Don't Repeat Yourself)原则。时间精确到秒,足以反映事件的先后顺序,同时避免了毫秒级别的过度精确。
5.4 WAV 音频生成函数
typescript
function buildWavBytes(freq: number, durationMs: number): ArrayBuffer {
const sampleRate = 44100;
const numSamples = Math.floor(sampleRate * durationMs / 1000);
const dataSize = numSamples * 2;
const buf = new ArrayBuffer(44 + dataSize);
const view = new DataView(buf);
const writeStr = (offset: number, s: string) => {
for (let i = 0; i < s.length; i++) {
view.setUint8(offset + i, s.charCodeAt(i));
}
};
writeStr(0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeStr(36, 'data');
view.setUint32(40, dataSize, true);
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
const env = Math.min(1, i / (sampleRate * 0.02));
const decay = Math.max(0, 1 - t / (durationMs / 1000));
const v = Math.sin(2 * Math.PI * freq * t) * 0.5 * env * decay;
view.setInt16(44 + i * 2, Math.round(v * 32767), true);
}
return buf;
}
这是平台最底层的音频生成函数,用于在客户端动态构建 WAV 格式的正弦波音频字节。函数接收频率(Hz)和时长(毫秒)两个参数,采样率固定为 44100Hz(CD 音质标准),计算出总采样数和字节数后分配 ArrayBuffer。
前 44 字节为 WAV 文件头,包含四个关键段:RIFF 标识(资源交换文件格式)、文件大小(36 + 数据大小)、WAVE 格式标识、fmt 子块(格式信息)。格式子块详细定义了音频参数:PCM 编码(格式代码 1)、单声道、44100Hz 采样率、字节率(采样率 × 2)、块对齐(2 字节)、16bit 量化深度。最后是 data 子块标识和数据大小。所有多字节整数的写入使用 true 参数表示小端序(Little-Endian),符合 WAV 规范。
数据区通过循环逐采样填充:t 为当前采样对应的时间秒数,env 为起音包络(前 20ms 线性上升至 1,避免音频开始时的点击噪声),decay 为自然衰减包络(从 1 线性降至 0,模拟铃声自然渐弱的听感),最终波形为正弦函数乘以 0.5 振幅再乘以两个包络。setInt16 将浮点值映射到 16bit 整数范围(-32768 至 32767)并写入。
六条铃声分别使用不同频率:登机叮咚 990Hz(清脆高音,模拟登机提示音)、口岸钟声 523Hz(C5 音符,模拟钟声的中频共鸣)、巴士到站 660Hz(E5 音符,中等亮度)、行李转盘 440Hz(A4 标准音,低频沉稳)、集合哨声 880Hz(A5 音符,尖锐高亮,模拟哨声)、夜航星光 784Hz(G5 音符,柔和梦幻)。频率从低到高覆盖不同听感,让每种旅行场景的铃声各具辨识度。
六、数据模型层
6.1 TripItem 行程模型
typescript
@Observed export class TripItem {
city: string;
days: number;
visa: string;
plan: string;
constructor(city: string, days: number, visa: string, plan: string) {
this.city = city;
this.days = days;
this.visa = visa;
this.plan = plan;
}
}
const TRIP_LIST: TripItem[] = [
new TripItem('香港', 4, '免签备案', '维港夜景 · 太平山顶 · 迪士尼'),
new TripItem('东京', 6, '电子签', '浅草寺 · 涩谷十字 · 镰仓一日'),
new TripItem('曼谷', 5, '落地签', '大皇宫 · 水上市场 · 洽图洽周末市集'),
new TripItem('新加坡', 4, '免签', '滨海湾花园 · 环球影城 · 圣淘沙'),
new TripItem('首尔', 5, '免签', '景福宫 · 明洞 · 南怡岛'),
new TripItem('大阪', 6, '电子签', '环球影城 · 道顿堀 · 奈良喂鹿'),
new TripItem('巴黎', 9, '需面签', '卢浮宫 · 塞纳河游船 · 凡尔赛宫')
];
TripItem 是平台的业务主模型,用 @Observed 装饰器修饰,表示该类的实例具备可观察性。当任何实例的属性发生变化时(如编辑后修改 city 或 days),所有引用该实例的 @State 数组会收到通知并触发 UI 刷新。四个属性分别对应目的地城市、行程天数、签证类型和行程概要,构造函数逐字段赋值。export 关键字使该类可被其他文件引用。
TRIP_LIST 常量预置了 7 条行程数据,覆盖亚洲和欧洲的热门目的地。签证类型涵盖四种:免签备案(香港)、免签(新加坡、韩国)、落地签(曼谷)、电子签(东京、大阪)、需面签(巴黎),完整覆盖了 visaColor 函数的四种颜色映射。行程天数从 4 天到 9 天不等,反映了不同目的地的旅行深度需求。行程概要以"景点1 · 景点2 · 景点3"的格式浓缩了每条行程的核心亮点,便于用户快速浏览。
6.2 SearchRecord 搜索结果模型
typescript
@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;
}
}
const SEARCH_MOCK: SearchRecord[] = [
new SearchRecord('香港太空馆', '尖沙咀梳士巴利道10号', 420, 0.94),
new SearchRecord('星光大道', '尖沙咀海滨长廊', 680, 0.88),
new SearchRecord('香港文化中心', '尖沙咀梳士巴利道', 750, 0.71),
new SearchRecord('天星小轮尖沙咀码头', '尖沙咀天星码头', 910, 0.62),
new SearchRecord('海港城', '尖沙咀广东道3-27号', 1150, 0.45),
new SearchRecord('重庆大厦', '尖沙咀弥敦道36-44号', 1320, 0.22)
];
SearchRecord 封装 POI 搜索结果条目。reliability 字段是最关键的设计------它直接对应 Map Kit site.Site 对象上的同名属性,取值范围 0 至 1,量化衡量搜索结果与关键字的匹配程度。SEARCH_MOCK 预置了 6 条模拟数据,全部位于尖沙咀区域,reliability 值从 0.94 到 0.22 覆盖高、中、低三档,配合 reliabilityScore 函数实现色彩分级展示。distance 字段为直线距离(米),在结果列表中以暗蓝灰色显示,与 reliability 分数条形成信息互补------距离反映空间远近,reliability 反映语义相关度。
6.3 EventLog 事件日志模型
typescript
@Observed export class EventLog {
type: string;
name: string;
lat: number;
lng: number;
time: string;
constructor(type: string, name: string, lat: number, lng: number, time: string) {
this.type = type;
this.name = name;
this.lat = lat;
this.lng = lng;
this.time = time;
}
}
const EVENT_SEED: EventLog[] = [
new EventLog('Marker', '#0 维多利亚港(演示)', 22.2938, 114.1722, '09:12:40'),
new EventLog('POI', '尖沙咀海滨花园(演示)', 22.2932, 114.1716, '09:15:03')
];
EventLog 记录地图长按事件。type 区分 Marker 和 POI 两种长按来源,分别对应两种不同的回调参数类型:Marker 长按返回 map.Marker 对象(可调用 getId() 和 getPosition()),POI 长按返回 mapCommon.Poi 对象(仅含 id、name、position 三字段)。name 为标记 ID 或 POI 名称,lat/lng 为触发位置的经纬度(保留 4 位小数),time 为触发时刻。
EVENT_SEED 预置了 2 条种子数据,使用户首次进入地图 Tab 时就能看到日志流的效果,而非空白列表。实际运行时通过 unshift 置顶新事件并 pop 尾部,保持最多 12 条的滑动窗口,使日志流既有初始内容又不会无限增长。12 条的上限经过精心设计------日志卡高度 172px,每条日志约 24px,12 条足以展示丰富的历史记录同时不会超出可视区域太多。
6.4 RemindItem 提醒条目模型
typescript
@Observed export class RemindItem {
time: string;
title: string;
repeat: string;
on: boolean;
constructor(time: string, title: string, repeat: string, on: boolean) {
this.time = time;
this.title = title;
this.repeat = repeat;
this.on = on;
}
}
const REMIND_LIST: RemindItem[] = [
new RemindItem('06:30', '值机提醒:CX984 香港 → 东京', '出行日', true),
new RemindItem('09:00', '酒店入住:尖沙咀皇悦酒店', '单次', true),
new RemindItem('09:40', '集合出发:旺角地铁站 C3 口', '出行日', true),
new RemindItem('12:00', '景点预约:太平山缆车快速通道', '单次', false),
new RemindItem('15:30', '跨境巴士:港珠澳大桥口岸集合', '单次', true),
new RemindItem('20:00', '夜间导览:维港幻彩咏香江', '出行日', false)
];
RemindItem 封装行程提醒条目。time 为提醒时刻(24 小时制,精确到分钟),title 为提醒标题(包含具体内容),repeat 为重复规则(出行日/单次/每日等),on 为开关状态。REMIND_LIST 预置了 6 条提醒,从 06:30 值机提醒到 20:00 夜间导览,覆盖了跨境旅行一天的完整时间线。其中 4 条开启、2 条暂停,展示了时间轴的两种状态。时间轴设计中,on 属性驱动每行的多个视觉元素------圆点颜色、竖线颜色、标题文字色和 Toggle 开关状态,实现了"开关状态即视觉状态"的直观设计。
6.5 RingItem 铃声条目模型
typescript
@Observed export class RingItem {
name: string;
file: string;
freq: number;
duration: number;
size: string;
inSandbox: boolean;
constructor(name: string, file: string, freq: number,
duration: number, size: string, inSandbox: boolean) {
this.name = name;
this.file = file;
this.freq = freq;
this.duration = duration;
this.size = size;
this.inSandbox = inSandbox;
}
}
const RING_LIST: RingItem[] = [
new RingItem('登机叮咚', 'ring_boarding_ding.wav', 990, 900, '---', false),
new RingItem('口岸钟声', 'ring_gate_bell.wav', 523, 1400, '---', false),
new RingItem('巴士到站', 'ring_bus_arrive.wav', 660, 1000, '---', false),
new RingItem('行李转盘', 'ring_baggage_loop.wav', 440, 1200, '---', false),
new RingItem('集合哨声', 'ring_meet_whistle.wav', 880, 800, '---', false),
new RingItem('夜航星光', 'ring_night_star.wav', 784, 1500, '---', false)
];
RingItem 封装铃声库条目,是 Notification Kit 沙箱铃声链路的数据载体。name 为铃声中文名(描述性命名便于用户理解使用场景),file 为沙箱文件名(统一使用 ring_ 前缀 + 场景描述 + .wav 后缀的命名规范),freq 和 duration 驱动 buildWavBytes 生成音频参数,size 初始为 '---' 表示未生成,生成后回填 KB 大小,inSandbox 标记是否已写入 EL1 沙箱。
RING_LIST 预置了 6 条铃声,覆盖旅行中的关键场景:登机叮咚(机场值机)、口岸钟声(出入境口岸)、巴士到站(跨境巴士)、行李转盘(行李提取)、集合哨声(团队集合)、夜航星光(夜间航班)。频率从 440Hz 到 990Hz 不等,时长从 800ms 到 1500ms 各异,每种铃声的频率和时长都经过设计------短促的集合哨声用 800ms 高频 880Hz,悠扬的夜航星光用 1500ms 中频 784Hz,使铃声的听感与场景语义相匹配。
6.6 CaptionScene 字幕场景模型
typescript
@Observed export class CaptionScene {
scene: string;
desc: string;
src: string;
tgt: string;
constructor(scene: string, desc: string, src: string, tgt: string) {
this.scene = scene;
this.desc = desc;
this.src = src;
this.tgt = tgt;
}
}
const CAPTION_SCENES: CaptionScene[] = [
new CaptionScene('双语导览讲解', '英文导游讲解实时转中英双语字幕,边走边听', 'en', 'zh-en'),
new CaptionScene('外语点餐对话', '读懂菜单与侍应对话,点单不踩雷', 'en', 'zh'),
new CaptionScene('问路应急翻译', '街头问路实时出字,方向看得见', 'en', 'zh'),
new CaptionScene('机场广播听译', '登机口变更广播即时转文字提醒', 'en', 'zh-en'),
new CaptionScene('中文讲解复盘', '回国回放中文讲解录音,整理游记笔记', 'zh', 'zh')
];
CaptionScene 封装字幕场景推荐条目,是字幕 Tab 的场景化快捷入口。scene 为场景名称,desc 为场景说明(解释使用场景和价值),src 为推荐源语言,tgt 为推荐目标语言。点击场景卡时调用 applyScene 方法,将 srcLang 和 tgtLang 一次性设置为场景推荐值,省去用户手动选择的步骤。
五个场景覆盖了跨境旅行中字幕功能的典型使用场景:双语导览(最常用的观光场景,中英双语对照)、外语点餐(餐饮场景,只需中文翻译)、问路应急(街头场景,快速理解)、机场广播(交通场景,双语对照确保准确)、中文讲解复盘(特殊场景,中文源无需翻译)。场景设计遵循"80/20"原则------覆盖用户 80% 的使用场景,其余 20% 通过手动设置满足。
6.7 NoticeLog 通知历史模型
typescript
@Observed export class NoticeLog {
title: string;
text: string;
time: string;
constructor(title: string, text: string, time: string) {
this.title = title;
this.text = text;
this.time = time;
}
}
const NOTICE_SEED: NoticeLog[] = [
new NoticeLog('值机提醒', 'CX984 香港 → 东京 已开放线上值机,请核对护照有效期。', '06:30:00'),
new NoticeLog('集合提醒', '跨境巴士 15:30 港珠澳大桥口岸发车,请提前 20 分钟集合。', '15:10:00'),
new NoticeLog('夜间导览', '维港幻彩咏香江 20:00 开始,星光大道观景位已收藏。', '19:40:00')
];
NoticeLog 封装通知历史条目,仅含标题、正文和时间三字段。NOTICE_SEED 预置了 3 条种子数据(值机提醒、集合提醒、夜间导览),与 REMIND_LIST 中的提醒类型相呼应,形成"提醒设置 → 通知发布 → 历史记录"的完整链路。实际发布通知时通过 unshift 置顶新记录并保持最多 8 条,发布失败时也会置顶一条带错误码的失败记录,保持历史流的完整可追溯性。8 条的上限设计使通知历史既能展示足够的上下文,又不会因过多记录而影响浏览效率。
七、组件主体结构
7.1 状态变量声明
typescript
@Entry
@Component
struct Page1224 {
@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 formCity: string = '';
@State formDays: string = '';
@State formVisa: string = '';
@State formPlan: string = '';
@State breath: boolean = false;
timer: number = -1;
@Entry 装饰器标记此结构体为页面入口组件,@Component 声明其为可复用组件。状态变量按功能分组声明,第一组为 Tab 状态:currentTab 控制 7 个 Tab 切换,默认值 0 表示进入应用时显示行程 Tab。
第二组为弹窗状态:三个 boolean 变量(addModal、editModal、delModal)分别控制新增、编辑、删除三个弹窗的显隐;两个 number 变量(editIdx、delIdx)记录当前操作的行程索引,默认值 -1 表示无有效索引。将弹窗状态独立声明而非用一个枚举类型管理,是因为三个弹窗可能同时存在(虽然实际业务上互斥),但条件渲染的逻辑更清晰------每个弹窗对应一个 boolean 标志,互不干扰。
第三组为弹窗表单缓存:四个 string 变量(formCity、formDays、formVisa、formPlan)分别暂存新增/编辑弹窗中的四个输入框值。使用独立的表单缓存而非直接绑定数据模型,是为了实现"取消编辑不修改原数据"的交互模式------用户在弹窗中修改的是临时变量,只有点击确认时才将临时值写回数据模型。
第四组为动画状态:breath 布尔值每秒翻转驱动呼吸动画,timer 为定时器 ID,不使用 @State 装饰因为定时器 ID 的变化不需要触发 UI 刷新。将非响应式变量声明为普通属性(不加 @State)是一种性能优化------避免不必要的状态监听和 UI 重渲染。
typescript
@State tripList: TripItem[] = TRIP_LIST;
@State remindList: RemindItem[] = REMIND_LIST;
@State ringList: RingItem[] = RING_LIST;
@State noticeLogs: NoticeLog[] = NOTICE_SEED;
业务数据数组直接引用预置常量初始化。由于 @Observed 类的实例属性变更会自动触发关联 UI 刷新,这些数组支持增删改后自动重渲染。四个数组分别对应行程列表、提醒列表、铃声库和通知历史,覆盖了应用的主要数据实体。
typescript
private mapOptions: mapCommon.MapOptions = {
position: { target: CITY_CENTER, zoom: 13 }
};
private mapCallback?: AsyncCallback<map.MapComponentController>;
private mapController?: map.MapComponentController;
private mapEventManager?: map.MapEventManager;
@State eventLogs: EventLog[] = EVENT_SEED;
@State markerListenOn: boolean = true;
@State poiListenOn: boolean = true;
@State queryInput: string = '景点';
@State searchRecords: SearchRecord[] = SEARCH_MOCK;
@State searchState: string = '待搜索(等待实时搜索)';
Map Kit 相关状态中,mapOptions 为 private(不需 UI 刷新),定义地图初始视野以香港中环为中心、缩放级别 13。缩放级别 13 是一个精心选择的值------既能显示整个香港岛和九龙半岛的主要地标,又能看清街道级别的细节。mapCallback 为地图初始化回调,mapController 和 mapEventManager 在回调中赋值后用于 Marker 操作和长按监听,均为 private 可选类型。
eventLogs、markerListenOn、poiListenOn 为 @State 因为需驱动日志流和开关胶囊的 UI 刷新。markerListenOn 和 poiListenOn 默认为 true,使用户进入地图 Tab 时双长按监听已开启,可直接体验交互。queryInput 默认值为"景点",是跨境游客最常搜索的 POI 类型。searchRecords 初始化为 SEARCH_MOCK 模拟数据,确保在无网络或搜索失败时仍有内容展示。searchState 显示当前搜索状态,默认文案提示用户等待实时搜索。
typescript
private captionController: AICaptionController = new AICaptionController();
@State captionShown: boolean = false;
@State srcLang: string = 'en';
@State tgtLang: string = 'zh-en';
@State captionSize: AICaptionFontSize = AICaptionFontSize.NORMAL;
@State captionColor: string = CAPTION_FONT_COLORS[0];
@State captionReady: boolean = false;
@State captionErrMsg: string = '';
@State captionFed: number = 0;
Speech Kit 状态中,captionController 为 private,持有 AICaptionController 实例用于控制字幕组件和写入音频流。captionShown 控制字幕显隐,与 AICaptionComponent 的 isShown 属性双向绑定(@Link)。
srcLang 默认 'en' 英文源,因为跨境旅行场景中大多数讲解以英文为主(导游、广播、公告等)。tgtLang 默认 'zh-en' 中英双语,兼顾理解和学习需求。captionSize 默认 NORMAL 标准字号,captionColor 默认第一种颜色(经典白)。captionReady 标记字幕服务是否初始化完成(由 onPrepared 回调设置),captionErrMsg 记录错误信息(由 onError 回调设置),captionFed 统计已写入的音频块数量。
typescript
@State granted: boolean = false;
notifyId: number = 100;
@State currentRing: RingItem = RING_LIST[0];
@State noticeState: string = '尚未发布通知';
Notification 状态中,granted 驱动授权状态卡 UI,初始值为 false(未授权),在 aboutToAppear 中异步查询系统授权状态后回填。notifyId 为自增 ID 基数(从 100 开始,预留 0-99 给系统通知),不加 @State 因为 ID 变化不需触发 UI 刷新。currentRing 默认为第一条铃声(登机叮咚),是发布通知时使用的铃声。noticeState 显示通知发布的结果反馈,初始为"尚未发布通知"。
7.2 生命周期方法
typescript
aboutToAppear() {
this.setupMapCallback();
notificationManager.isNotificationEnabled().then((enabled: boolean) => {
this.granted = enabled;
}).catch((err: BusinessError) => {
console.error(`isNotificationEnabled failed: ${err.message}`);
});
this.timer = setInterval(() => {
this.breath = !this.breath;
}, 1000);
}
aboutToDisappear() {
clearInterval(this.timer);
}
aboutToAppear 在组件创建后、build 执行前调用,完成三项初始化工作。
第一项调用 setupMapCallback 装配地图回调函数。注意这里只是"装配"而非"执行"------setupMapCallback 方法将回调函数赋值给 this.mapCallback 属性,真正的地图初始化在 MapComponent 组件渲染时由框架异步触发。这种"先装配后执行"的模式确保了 mapCallback 在组件 build 之前就已就绪,避免了回调为 undefined 的时序问题。
第二项异步查询通知授权状态。调用 notificationManager.isNotificationEnabled() 返回一个 Promise,成功时将结果赋值给 this.granted,失败时记录错误日志但不中断初始化流程。这是一个典型的"优雅降级"设计------即使通知授权查询失败,应用的其他功能(地图、搜索、字幕)仍可正常使用。
第三项启动 1 秒间隔的呼吸动画定时器。定时器每秒翻转一次 breath 布尔值,驱动柱状图的呼吸波动效果。与标杆平台不同,本平台不使用 Canvas 绘制图表,因此定时器只需翻转 breath 状态而无需调用重绘方法------ArkUI 的响应式机制会自动检测 breath 变化并触发相关 UI 的重新计算和渲染。
aboutToDisappear 在组件销毁前清除定时器,防止内存泄漏。这是 ArkUI 开发的最佳实践------所有在 aboutToAppear 中启动的定时器、事件监听等资源,都应在 aboutToDisappear 中对应释放。
7.3 根构建方法
typescript
build() {
Stack() {
Column() {
this.headerMain()
Divider().strokeWidth(1).color(COLORS.line)
if (this.currentTab === 1) {
this.tabMap()
} else {
Scroll() {
Column({ space: 12 }) {
if (this.currentTab === 0) {
this.tabTrip()
} else if (this.currentTab === 2) {
this.tabSearch()
} else if (this.currentTab === 3) {
this.tabRemind()
} else if (this.currentTab === 4) {
this.tabRing()
} else if (this.currentTab === 5) {
this.tabCaption()
} else {
this.tabMine()
}
}.width('100%').padding({ left: 14, right: 14, top: 12, bottom: 16 })
}.layoutWeight(1).width('100%')
.scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring)
}
this.tabBar()
}.width('100%').height('100%')
if (this.addModal) {
this.panelAdd(() => {
this.addModal = false;
})
}
if (this.editModal) {
this.panelEdit(() => {
this.editModal = false;
})
}
if (this.delModal) {
this.panelDel(() => {
this.delModal = false;
})
}
}
.alignContent(Alignment.Center)
.backgroundColor(COLORS.bg)
.height('100%')
}
build 方法采用 Stack 层叠布局实现"主界面 + 弹窗"两层结构。Stack 的 alignContent 设为 Alignment.Center,使弹窗面板在屏幕中居中显示。背景色设为夜航深蓝 COLORS.bg,高度占满全屏。
底层 Column 纵向排列四个部分:头部区域 headerMain()、分割线 Divider、内容区(Tab 切换)、底部 Tab 栏 tabBar()。分割线使用 1px 宽度的深蓝灰色,与深色背景形成柔和过渡。
内容区通过 if/else 链判断 currentTab,这是整个布局的核心设计决策。地图 Tab(索引 1)直接渲染 tabMap() 且不进入 Scroll------因为 MapComponent 需要有界高度才能正确渲染地图表面(Surface),如果包裹在 Scroll 中,Scroll 会给子组件无限高度,导致 MapComponent 的 layoutWeight(1) 无法计算实际像素高度。
其余 6 个 Tab 共享一个 Scroll 滚动容器,内部 Column 间距 12,内边距 14/14/12/16。Scroll 设置 scrollBar(BarState.Off) 关闭滚动条(保持视觉简洁),edgeEffect(EdgeEffect.Spring) 启用弹簧边缘效果(滚到边缘时有弹性回弹,提升触摸体验)。layoutWeight(1) 使 Scroll 占满分割线与底部 Tab 栏之间的所有剩余空间。
顶层三个弹窗通过各自 @State 布尔值控制显隐,每个弹窗接收一个关闭回调函数用于重置状态。使用箭头函数作为回调参数(如 () => { this.addModal = false; })而非直接传递状态变量,确保了回调的正确 this 绑定------箭头函数继承外层作用域的 this,因此在弹窗内部调用时仍能正确访问组件实例。
八、头部区域详解
typescript
@Builder
headerMain() {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text('跨境行程')
.fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(TAB_SUBS[this.currentTab])
.fontSize(11).fontColor(COLORS.text3)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column({ space: 4 }) {
Row({ space: 4 }) {
Text('🗺️').fontSize(10)
Text(this.markerListenOn || this.poiListenOn ? '长按On' : '长按Off')
.fontSize(9).fontColor(this.markerListenOn || this.poiListenOn ? COLORS.cyan : COLORS.text3)
}.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(10).backgroundColor(COLORS.dark)
Row({ space: 4 }) {
Text('🗣').fontSize(10)
Text(this.srcLang + '→' + this.tgtLang).fontSize(9).fontColor(COLORS.purple)
}.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(10).backgroundColor(COLORS.dark)
Row({ space: 4 }) {
Text('🔔').fontSize(10)
Text(this.granted ? '已授权' : '未授权')
.fontSize(9).fontColor(this.granted ? COLORS.green : COLORS.orange)
}.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(10).backgroundColor(COLORS.dark)
}
Circle({ width: 8, height: 8 })
.fill(COLORS.cyan)
.opacity(this.breath ? 1 : 0.25)
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
}
头部区域是整个应用的"状态总览面板",采用 Row 横向布局,从左到右分为四个部分:应用标题区、三特性状态胶囊区、呼吸圆点。与标杆平台的两行纵向头部布局不同,本平台采用单行横向布局,使头部更加紧凑,为内容区留出更多空间------这是 7 个 Tab 应用的空间优化策略。
应用标题区 位于最左侧,使用 layoutWeight(1) 占满剩余宽度。内部为纵向排列的 Column,间距 3:第一行是 20 号粗体应用名"跨境行程",冷白色文字在深色背景上清晰醒目;第二行是 11 号副标题,通过 TAB_SUBS[this.currentTab] 与当前 Tab 联动显示,使用 text3 暗蓝灰色弱化视觉权重,maxLines(1) 和 textOverflow 确保文字超长时以省略号结尾而非换行。副标题的联动设计使用户在切换 Tab 时立即获得上下文确认,知道当前所处的功能模块。
三特性状态胶囊位于中间,是头部区域的核心亮点------三枚胶囊纵向排列(间距 4),分别对应三大特性的实时状态:
第一枚为地图长按状态胶囊:🗺️ emoji + 状态文字。状态文字由 markerListenOn || this.poiListenOn 决定------只要任一监听器开启就显示"长按On"配霓虹青色,两者都关闭时显示"长按Off"配暗蓝灰色。这种"或"逻辑的设计避免了显示两种状态造成的信息过载,用户可进入地图 Tab 查看详细的独立开关。
第二枚为字幕语言状态胶囊:🗣 emoji + 语言方向(如"en→zh-en")。语言方向文字固定使用星紫色,与字幕 Tab 中目标语言的选中态颜色一致,形成视觉呼应。此胶囊实时展示当前字幕配置,使用户无需切换到字幕 Tab 即可知道翻译方向。
第三枚为通知授权状态胶囊:🔔 emoji + 授权状态。已授权时显示"已授权"配通过绿,未授权时显示"未授权"配落日橙。绿色代表"畅通可用",橙色代表"需要注意",语义清晰直观。
每枚胶囊的样式统一:内边距 8/8/3/3,圆角 10,背景色为深色容器底色 COLORS.dark。统一的样式使三枚胶囊形成视觉整体,同时各自的文字颜色根据状态动态变化,传达不同的语义信息。
呼吸圆点 位于最右侧,直径 8px,填充霓虹青 COLORS.cyan,透明度随 breath 状态在 1 和 0.25 之间每秒切换一次,实现"心跳呼吸"效果。这个微小的动态元素传递了"应用正在运行、数据实时更新"的心理暗示,提升了用户对平台的信任感。与标杆平台的 10px 绿点相比,8px 的尺寸更加精致,符合深色主题的细腻感。
九、行程 Tab 深度分析
9.1 目的地横滑大卡
typescript
@Builder
tabTrip() {
Column({ space: 12 }) {
Row() {
Text('🧭 目的地横滑 · 签证状态一屏速览')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(`${this.tripList.length} 个行程`)
.fontSize(10).fontColor(COLORS.text3)
}.width('100%')
Scroll() {
Row({ space: 12 }) {
ForEach(this.tripList, (item: TripItem, idx: number) => {
Column({ space: 8 }) {
Row() {
Text(item.city).fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text(`${item.days}天`).fontSize(12).fontColor(COLORS.bg)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(8).backgroundColor(COLORS.cyan)
}.width('100%')
Text(item.plan).fontSize(10).fontColor(COLORS.sub)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis }).textAlign(TextAlign.Start)
Row({ space: 6 }) {
Circle({ width: 6, height: 6 }).fill(visaColor(item.visa))
Text(item.visa).fontSize(10).fontColor(visaColor(item.visa))
}.width('100%')
}
.width(170).padding(12).borderRadius(14)
.linearGradient({
angle: 135,
colors: [[COLORS.dark, 0], [COLORS.card, 1]]
})
}, (item: TripItem, idx: number) => `${idx}-${item.city}`)
}
.padding({ left: 2, right: 2 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
行程 Tab 是用户进入应用后的第一个 Tab,承担着"行程总览"的核心功能。整体采用纵向 Column 布局,间距 12,从上到下依次为:目的地横滑大卡标题行、横滑大卡区、行程清单行、新增行程入口、月度出行天数柱状图。
目的地横滑大卡 是行程 Tab 的视觉焦点。标题行采用三段式布局:左侧为带 emoji 的标题"🧭 目的地横滑 · 签证状态一屏速览",点明功能和价值;中间用 layoutWeight(1) 的空 Column 撑开空间;右侧显示行程总数"7 个行程",使用 text3 弱文本色。
横滑区域使用 Scroll + Row 的组合实现水平滚动:Scroll 设置 scrollable(ScrollDirection.Horizontal) 为横向滚动,关闭滚动条保持视觉简洁。内部 Row 间距 12,通过 ForEach 遍历 tripList 数组渲染每张目的地卡片。每张卡片宽度固定 170px,高度自适应内容,内边距 12,圆角 14,使用 135 度角的线性渐变(从 dark 到 card)营造层次感。
每张卡片内部结构分为三层:顶层为城市名行------18 号粗体城市名左对齐,右侧为天数徽章(霓虹青底色 + 深色文字 + 圆角 8),视觉上醒目突出;中层为行程概要------10 号雾蓝灰文字,最多 2 行,超出省略号,使用 textAlign(TextAlign.Start) 左对齐确保多语言下的阅读一致性;底层为签证类型行------6px 彩色圆点 + 签证类型文字,颜色由 visaColor 函数根据签证类型动态映射,使用户一眼即可判断签证便利程度。
ForEach 的第三参数(键值生成函数)使用 ${idx}-${item.city} 格式,结合了索引和城市名双重标识,确保即使城市名重复(理论上不会)也能生成唯一键,避免列表项复用错误。
9.2 行程清单行
typescript
Column({ space: 8 }) {
ForEach(this.tripList, (item: TripItem, idx: number) => {
Row({ space: 10 }) {
Text('🧭').fontSize(16)
Column({ space: 3 }) {
Row({ space: 8 }) {
Text(item.city).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(item.visa).fontSize(9).fontColor(visaColor(item.visa))
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
.borderRadius(6).backgroundColor(COLORS.dark)
Text(`${item.days}天`).fontSize(10).fontColor(COLORS.text3)
}
Text(item.plan).fontSize(10).fontColor(COLORS.sub)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('✏️').fontSize(14).onClick(() => {
this.openEdit(idx);
})
Text('🗑').fontSize(14).onClick(() => {
this.openDel(idx);
})
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
}, (item: TripItem, idx: number) => `row-${idx}-${item.city}`)
}.width('100%')
行程清单行是行程 Tab 的主要信息展示区,以列表形式呈现所有行程的详细信息。Column 容器间距 8,每条行程占一行卡片,卡片内边距 12,圆角 12,深海军蓝底色。
每条行程行从左到右分为四个部分:
- 图标列:16 号指南针 emoji 🧭,视觉化标识行程类型
- 信息列 :
layoutWeight(1)等分剩余宽度,内部纵向排列两行。第一行为城市名、签证徽章和天数------13 号粗体城市名(冷白色)+ 签证类型胶囊(签证色文字 + 深色容器底色 + 圆角 6)+ 天数(暗蓝灰色)。第二行为行程概要------10 号雾蓝灰色,单行省略,确保卡片高度统一。 - 编辑按钮 :✏️ emoji,点击调用
openEdit(idx)打开编辑弹窗 - 删除按钮 :🗑 emoji,点击调用
openDel(idx)打开删除确认弹窗
使用 emoji 作为操作按钮是一种巧妙的设计选择------既避免了图标资源的引入(减少包体积),又提供了直观的视觉语义,还天然支持深色主题(emoji 在深色背景上同样清晰)。编辑和删除操作直接放在每行右侧,符合移动端"就近操作"的交互原则,用户无需进入详情页即可快速修改或删除行程。
9.3 新增行程入口
typescript
Row() {
Text('+ 新增行程')
.fontSize(13).fontColor(COLORS.bg).fontWeight(FontWeight.Bold)
}
.width('100%').height(42).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor(COLORS.cyan)
.onClick(() => {
this.openAdd();
})
新增行程入口是一个全宽的霓虹青色按钮,高度 42,圆角 12。按钮文字为"+ 新增行程",使用深色(背景色 COLORS.bg)粗体,与霓虹青底色形成高对比度。按钮内部使用 Row + justifyContent(FlexAlign.Center) 实现文字居中。点击按钮调用 openAdd() 方法,该方法清空表单缓存(城市/天数/签证/概要)并将签证类型默认设为"免签",然后打开新增弹窗。
使用加号 "+" 前缀的按钮文案是常见的"新增"交互模式,符合用户的心理预期。按钮位于行程列表和柱状图之间,位置显眼但不突兀,使用户在浏览完现有行程后自然地看到新增入口。
9.4 月度出行天数柱状图
typescript
@Builder
chartCard() {
Column({ space: 10 }) {
Row() {
Text('📊 近 6 个月跨境出行天数')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('合计 28 天')
.fontSize(10).fontColor(COLORS.text3)
}.width('100%')
Row({ space: 10 }) {
ForEach(MONTH_DAYS, (v: number, idx: number) => {
Column({ space: 6 }) {
Text(`${v}天`)
.fontSize(10).fontColor(COLORS.sub)
Column() {
Column()
.width('100%')
.height(this.breath ? 14 + v * 9 : 12 + v * 9)
.borderRadius(4)
.linearGradient({
angle: 180,
colors: [[COLORS.cyan, 0], [COLORS.cyanD, 1]]
})
}
.height(92).width(20)
.justifyContent(FlexAlign.End)
Text(MONTH_NAME[idx])
.fontSize(10).fontColor(COLORS.text3)
}.layoutWeight(1)
}, (v: number, idx: number) => `month-${idx}-${v}`)
}.width('100%')
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
}
月度出行天数柱状图使用纯 ArkUI 组件(非 Canvas)绘制,是本平台的一个特色设计。与标杆平台的 Canvas 折线图不同,本平台选择纯组件柱状图方案,原因有二:一是柱状图的数据维度较少(6 个月),纯组件实现足够且性能更好;二是纯组件天然支持响应式动画(breath 状态变化自动触发高度过渡),无需手动重绘。
图表卡片的标题行显示带 emoji 的标题"📊 近 6 个月跨境出行天数"和合计天数"合计 28 天"。图表区域为 Row 容器,高度由柱体高度决定,ForEach 遍历 6 个月数据,每个月份为一个 Column(使用 layoutWeight(1) 等分宽度),内部从上到下为:
- 天数标签 :
${v}天文字,10 号雾蓝灰色,显示在柱体上方 - 柱体容器 :高度固定 92px,宽度 20px,
justifyContent(FlexAlign.End)底部对齐,确保柱体从下往上生长 - 柱体本身 :宽度 100%,高度由
14 + v * 9计算(v 为天数,每天 9px,基础高度 14px),圆角 4(顶部圆角,底部直角),使用 180 度线性渐变(霓虹青到霓虹青深色) - 月份标签 :
MONTH_NAME[idx],10 号暗蓝灰色,显示在柱体下方
呼吸动画通过 breath 状态控制柱高------breath 为 true 时高度系数为 14 + v9,为 false 时为 12 + v9,差值仅 2px,实现微妙的"心跳"效果。这种微动画设计既保持了视觉活力,又不会分散用户注意力。
纯组件柱状图相比 Canvas 方案有以下优势:代码更简洁(无需手动计算坐标和绘制)、天然响应式(状态变化自动更新)、无障碍友好(每个柱体都是独立组件可被读屏)、易于扩展交互(可直接添加点击事件)。劣势是不适合复杂图表(如折线图、散点图)和大数据量场景。
十、地图 Tab 深度分析
10.1 监听开关行
typescript
@Builder
tabMap() {
Column({ space: 10 }) {
Row({ space: 14 }) {
Toggle({ type: ToggleType.Switch, isOn: this.markerListenOn })
.selectedColor(COLORS.cyan)
.width(40).height(22)
.onChange(() => {
this.toggleMarkerListen();
})
Text('Marker长按')
.fontSize(11).fontColor(this.markerListenOn ? COLORS.cyan : COLORS.text3)
Toggle({ type: ToggleType.Switch, isOn: this.poiListenOn })
.selectedColor(COLORS.orange)
.width(40).height(22)
.onChange(() => {
this.togglePoiListen();
})
Text('POI长按')
.fontSize(11).fontColor(this.poiListenOn ? COLORS.orange : COLORS.text3)
Column().layoutWeight(1)
Text('长按地标/POI 试试')
.fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
.padding({ left: 14, right: 14 })
地图 Tab 是 Map Kit 双长按监听特性的核心展示区,采用纵向三段式布局:监听开关行、MapComponent 地图、长按事件日志流。整个 Tab 使用 layoutWeight(1) 占满内容区高度,不包裹在 Scroll 中------这是因为 MapComponent 需要有界高度才能正确渲染。
监听开关行是地图 Tab 的控制中心,包含两个独立的 Toggle 开关和一个提示文字。两个开关分别控制 Marker 长按和 POI 长按的监听状态,各自配有文字标签。
Marker 长按开关选中色为霓虹青(与 Marker 类型的徽标色一致),文字色随开关状态在霓虹青(开启)和暗蓝灰(关闭)间切换。POI 长按开关选中色为落日橙(与 POI 类型的徽标色一致),文字色同样随状态切换。这种"开关颜色 = 类型颜色"的设计使用户建立起颜色与类型的关联------看到青色就想到 Marker,看到橙色就想到 POI。
右侧提示文字"长按地标/POI 试试"以暗蓝灰 9 号字显示,引导用户尝试长按交互。layoutWeight(1) 的空 Column 将提示文字推到最右侧,形成左右对称的平衡布局。
两个 Toggle 组件的尺寸设为 40x22,比默认尺寸略小,适配移动端紧凑布局。onChange 回调分别调用 toggleMarkerListen() 和 togglePoiListen() 方法,实现监听的动态开关。
10.2 双长按监听实现原理
typescript
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;
this.mapEventManager = mapController.getEventManager();
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 this.mapController.addMarker(markerOptions);
} catch (e) {
console.error(`addMarker failed: ${(e as BusinessError).message}`);
}
}
this.bindMarkerLongClick();
this.bindPoiLongClick();
};
}
地图初始化链路从 setupMapCallback 开始,贯穿控制器获取、Marker 批量添加和双长按监听注册三个阶段。
mapCallback 是一个 AsyncCallback<map.MapComponentController> 类型的异步回调,在地图底层渲染完成后由框架异步触发。回调函数为 async 是因为内部需要使用 await 调用异步的 addMarker 方法。
回调内部第一步是错误判空------如果初始化失败(如地图服务不可用、API Key 无效等),直接打印错误日志并 return,避免后续操作空指针异常。第二步获取 mapController 和 mapEventManager:mapController 用于地图操作(添加标注、移动视野等),mapEventManager 通过 getEventManager() 从控制器获取,用于事件监听注册。
第三步遍历 MARKER_SPOTS 常量数组(6 处香港地标),为每个地标构造 MarkerOptions 并 await addMarker。MarkerOptions 的关键参数包括:position 为经纬度坐标,clickable: true(只有标记为可点击的 Marker 才能响应长按事件------这是一个重要的前提条件),visible: true(默认可见),anchorU: 0.5 和 anchorV: 1 将标注锚点设在底部中心(模拟图钉效果,标注底部贴合坐标点),draggable: false 和 flat: false(不支持拖拽和贴地效果,保持标准标注样式)。
每个 addMarker 调用都包裹在 try-catch 中,单个标注添加失败(如坐标越界)不会阻断后续标注的添加,体现了容错设计。最后两步调用 bindMarkerLongClick() 和 bindPoiLongClick() 注册双长按监听,构成完整的地图交互链路。
typescript
bindMarkerLongClick() {
if (!this.mapEventManager) {
return;
}
this.mapEventManager.onMarkerLongClick((marker: map.Marker) => {
const pos = marker.getPosition();
this.eventLogs.unshift(new EventLog('Marker',
'#' + marker.getId() + ' 地标标注', pos.latitude, pos.longitude, nowTime()));
if (this.eventLogs.length > 12) {
this.eventLogs.pop();
}
});
}
bindPoiLongClick() {
if (!this.mapEventManager) {
return;
}
this.mapEventManager.onPoiLongClick((poi: mapCommon.Poi) => {
this.eventLogs.unshift(new EventLog('POI',
poi.name ?? '未命名 POI', poi.position.latitude, poi.position.longitude, nowTime()));
if (this.eventLogs.length > 12) {
this.eventLogs.pop();
}
});
}
双长按监听的实现分别封装在 bindMarkerLongClick 和 bindPoiLongClick 两个方法中。两个方法都首先进行空值检查------如果 mapEventManager 未初始化则直接返回,避免空指针。
Marker 长按回调 的参数类型为 map.Marker,这是一个功能完整的对象,可通过 getId() 获取标注 ID(字符串格式,如"marker_0")、通过 getPosition() 获取经纬度坐标(LatLng 类型,含 latitude 和 longitude 字段)。回调内部构造一条 EventLog 实体,类型为 'Marker',名称格式为 '#ID 地标标注',经纬度从 getPosition() 获取,时间调用 nowTime() 函数。使用 unshift 将新事件置顶到日志流,同时限制最多保留 12 条(超出则 pop 尾部),防止日志无限增长。
POI 长按回调 的参数类型为 mapCommon.Poi,这是一个精简的数据结构,仅暴露 id、name、position 三个字段------比 Marker 的接口更简单,因为 POI 是地图上的兴趣点(而非开发者添加的标注),可操作的属性有限。代码中使用 poi.name ?? '未命名 POI' 的空值合并运算符兜底,确保即使 POI 没有名称也不会显示 undefined。
两个监听的开关逻辑由 toggleMarkerListen 和 togglePoiListen 方法实现:
typescript
toggleMarkerListen() {
if (!this.mapEventManager) {
return;
}
if (this.markerListenOn) {
this.mapEventManager.offMarkerLongClick();
} else {
this.bindMarkerLongClick();
}
this.markerListenOn = !this.markerListenOn;
}
关闭监听时调用 offMarkerLongClick()(不传参表示清除该类型全部订阅),开启时重新调用 bindMarkerLongClick 方法注册,最后翻转 markerListenOn 状态驱动 UI 刷新。这种"先操作后翻转"的顺序确保了状态变量与实际监听状态的一致性。togglePoiListen 逻辑完全对称。
10.3 长按事件日志流
typescript
Column({ space: 6 }) {
Row() {
Text('📍 长按事件日志')
.fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(`${this.eventLogs.length} 条`)
.fontSize(10).fontColor(COLORS.text3)
}.width('100%')
Scroll() {
Column({ space: 6 }) {
ForEach(this.eventLogs, (log: EventLog, idx: number) => {
Row({ space: 8 }) {
Text(log.type).fontSize(9).fontColor(COLORS.bg)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6).backgroundColor(typeColor(log.type))
Text(log.name).fontSize(11).fontColor(COLORS.sub).layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${log.lat.toFixed(4)}, ${log.lng.toFixed(4)}`)
.fontSize(9).fontColor(COLORS.text3).fontFamily('monospace')
Text(log.time).fontSize(9).fontColor(COLORS.text3)
}.width('100%')
}, (log: EventLog, idx: number) => `log-${idx}-${log.time}`)
}.width('100%')
}
.layoutWeight(1).width('100%')
.scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring)
Text('off 不传参=清除该类型全部订阅')
.fontSize(9).fontColor(COLORS.text3).fontFamily('monospace')
}
.width('100%').height(172)
.padding(10).borderRadius(12).backgroundColor(COLORS.card)
长按事件日志流是地图交互的反馈窗口,位于 MapComponent 下方。日志卡高度固定 172px,内边距 10,圆角 12,深海军蓝底色。内部结构分为三部分:标题行、日志滚动区、提示文字。
标题行 显示带 emoji 的标题"📍 长按事件日志"和日志条数 ${this.eventLogs.length} 条,条数实时反映当前日志数量。
日志滚动区 使用 Scroll + Column 实现垂直滚动,layoutWeight(1) 占满剩余高度。每条日志为一行 Row,从左到右包含:
- 类型徽标 :9 号深色文字,背景色由
typeColor(log.type)决定(Marker 霓虹青 / POI 落日橙),圆角 6,形成"彩色胶囊白字"的高对比度效果 - 名称 :11 号雾蓝灰文字,
layoutWeight(1)等分宽度,单行省略 - 经纬度 :9 号暗蓝灰等宽字体(
fontFamily('monospace')),保留 4 位小数,等宽字体确保数字对齐整齐 - 时间 :9 号暗蓝灰,
HH:mm:ss格式
日志流使用 unshift 置顶新事件,因此最新的事件始终显示在最上方,符合用户"先看最新"的阅读习惯。12 条的上限和固定高度的滚动区形成了一个"滑动窗口"------用户可滚动查看历史事件,但不会因事件过多而占用过多内存。
提示文字 位于日志卡底部,显示"off 不传参=清除该类型全部订阅",这是一个技术细节提示------说明 offMarkerLongClick() 和 offPoiLongClick() 不传参数时会清除该类型的全部订阅,这是 Map Kit 6.1.1 的 API 设计特点。使用等宽字体和暗蓝灰色,暗示这是开发者关心的技术信息,而非普通用户的关注点。
十一、搜索 Tab 深度分析
11.1 搜索框与快捷关键字
typescript
@Builder
tabSearch() {
Column({ space: 12 }) {
Row({ space: 8 }) {
TextInput({ text: this.queryInput, placeholder: '输入关键字,如:景点' })
.layoutWeight(1).height(38).fontSize(12).fontColor(COLORS.title)
.backgroundColor(COLORS.dark).placeholderColor(COLORS.text3).borderRadius(10)
.onChange((v: string) => {
this.queryInput = v;
})
Button('搜索').height(38).fontSize(12)
.backgroundColor(COLORS.cyan).fontColor(COLORS.bg).borderRadius(10)
.onClick(() => {
this.runSearch();
})
}.width('100%')
Row({ space: 8 }) {
ForEach(QUICK_QUERIES, (q: string) => {
Text(q).fontSize(11).fontColor(this.queryInput === q ? COLORS.bg : COLORS.sub)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
.backgroundColor(this.queryInput === q ? COLORS.cyan : COLORS.dark)
.onClick(() => {
this.queryInput = q;
this.runSearch();
})
}, (q: string) => q)
}.width('100%')
搜索 Tab 是 Map Kit searchByText 特性的展示区,采用纵向四段式布局:搜索框行、快捷关键字行、搜索状态文案、结果列表。
搜索框行 由 TextInput 和 Button 组成,间距 8。输入框等分宽度(layoutWeight(1)),高度 38,深色容器底色,圆角 10。placeholder 为"输入关键字,如:景点",使用暗蓝灰色提示文字。onChange 回调实时更新 queryInput 状态变量。搜索按钮高度 38,霓虹青底深色字,点击调用 runSearch() 方法执行搜索。按钮与输入框等高,形成整齐的视觉对齐。
快捷关键字行 使用 Row + ForEach 渲染 6 个快捷关键字 chip。每个 chip 为胶囊形状,内边距 10/10/5/5,圆角 12。选中态(queryInput === q)为霓虹青底深色字,未选中为深色容器底雾蓝灰字。点击 chip 时同时执行两个操作:将关键字赋值给 queryInput、调用 runSearch() 立即搜索。这种"一键搜索"设计大幅减少了用户的输入成本,尤其适合移动端小屏幕输入不便的场景。
六个快捷关键字------景点、餐厅、地铁站、酒店、口岸、博物馆------按照旅行决策的逻辑顺序排列,覆盖了跨境游客在目的地最常搜索的 POI 类型。用户点击"景点"搜索热门景点,点击"餐厅"查找附近美食,点击"地铁站"规划交通路线,点击"酒店"查找住宿,点击"口岸"查询出入境口岸位置,点击"博物馆"探索文化场所。
11.2 searchByText 检索链路
typescript
async runSearch() {
this.searchState = '搜索中...';
const params: site.SearchByTextParams = {
query: this.queryInput,
location: CITY_CENTER,
radius: 5000,
language: 'zh'
};
try {
const result: site.SearchByTextResult = await site.searchByText(params);
const sites: site.Site[] = result.sites ?? [];
if (sites.length === 0) {
this.searchState = '无结果,已保留当前推荐';
return;
}
this.searchRecords = sites.map((s: site.Site) => new SearchRecord(
s.name ?? '未命名地点', s.formatAddress ?? '暂无地址',
s.distance ?? 0, s.reliability ?? 0));
this.searchState = `返回 ${sites.length} 条结果`;
} catch (e) {
const err = e as BusinessError;
this.searchState = `搜索失败(${err.code}),保留当前推荐`;
}
}
runSearch 方法是 Map Kit site.searchByText 接口的完整调用链,将关键字检索结果按相关性分数可视化呈现。
搜索参数 SearchByTextParams 包含四个核心字段:query 为搜索关键字(来自输入框),location 为搜索基准坐标(复用 CITY_CENTER 香港中环),radius 为搜索半径(5000 米 = 5 公里,覆盖香港岛和九龙半岛的核心区域),language 为返回结果语言(中文 'zh')。
searchByText 返回 SearchByTextResult,其 sites 字段为 Site[] 数组。每个 Site 对象包含丰富的 POI 信息,代码中使用了四个关键字段:name(地点名称)、formatAddress(格式化地址)、distance(直线距离,单位米)和 reliability(相关性分数,0 到 1 之间)。
代码中大量使用 ?? 空值合并运算符做字段兜底------s.name ?? '未命名地点'、s.formatAddress ?? '暂无地址'、s.distance ?? 0、s.reliability ?? 0,确保即使服务端返回部分字段缺失也不会崩溃。这是一种防御性编程实践------不信任外部数据的完整性,每个字段都有默认值。
搜索结果通过 map 方法映射为 SearchRecord 实体数组,驱动结果列表渲染。搜索状态文案 searchState 在搜索过程中实时更新:开始搜索时设为"搜索中...",成功有结果时显示"返回 N 条结果",无结果时显示"无结果,已保留当前推荐"(保留 Mock 数据),失败时显示"搜索失败(错误码),保留当前推荐"(同样保留 Mock 数据)。
这种"优雅降级"设计是搜索功能的重要考量------在无 AGC 配置、无网络或服务异常时,搜索功能不会完全失效,而是回退到 Mock 数据并给出明确的状态提示,使用户仍能看到界面效果。这对于技术演示型应用尤为重要。
11.3 reliability 分数条结果列表
typescript
Text(this.searchState)
.fontSize(11).fontColor(COLORS.text3).width('100%')
List({ space: 10 }) {
ForEach(this.searchRecords, (rec: SearchRecord, idx: number) => {
ListItem() {
Column({ space: 7 }) {
Row() {
Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(reliabilityScore(rec.reliability).label).fontSize(10)
.fontColor(reliabilityScore(rec.reliability).color)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6).backgroundColor(COLORS.dark)
}.width('100%')
Text(rec.address).fontSize(10).fontColor(COLORS.sub).width('100%')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Text(`${rec.distance}m`).fontSize(10).fontColor(COLORS.text3).width(52)
Progress({ value: rec.reliability * 100, total: 100, type: ProgressType.Linear })
.layoutWeight(1).color(reliabilityScore(rec.reliability).color)
.backgroundColor(COLORS.dark).borderRadius(3)
Text(rec.reliability.toFixed(2)).fontSize(10).fontColor(COLORS.sub).width(36)
.fontFamily('monospace').textAlign(TextAlign.End)
}.width('100%')
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
}
}, (rec: SearchRecord, idx: number) => `rec-${idx}-${rec.name}`)
}
.width('100%').scrollBar(BarState.Off)
搜索结果列表使用 List 组件(而非 Column + ForEach),List 组件相比普通 Column 具有更好的滚动性能和内置的复用机制,适合可能较长的结果列表场景。列表间距 10,关闭滚动条。
每条结果为一张卡片,内边距 12,圆角 12,深海军蓝底色,内部纵向排列三行(间距 7):
第一行:名称 + 相关性等级 。左侧为 13 号粗体地点名称(冷白色),layoutWeight(1) 等分宽度,单行省略。右侧为相关性等级胶囊------标签文字和颜色均由 reliabilityScore 函数返回,深色容器底色,圆角 6,文字大小 10。高相关(通过绿)、中相关(落日橙)、低相关(暗蓝灰)三档一目了然。
第二行:地址。10 号雾蓝灰色,单行省略。地址信息帮助用户判断这是否是目标地点,尤其是在有多个同名地点时。
第三行:距离 + reliability 分数条 + 数值。从左到右:
- 距离(52px 宽,暗蓝灰色,如"420m")------反映空间远近
- Progress 分数条(
layoutWeight(1)等分剩余宽度)------将 0~1 的 reliability 映射到 0~100 的线性进度条,颜色由reliabilityScore动态决定(高相关绿色、中相关橙色、低相关灰色),背景色为深色容器底色,圆角 3(细窄的分数条样式) - reliability 数值(36px 宽,等宽字体,右对齐,保留两位小数,如"0.94")------精确的数值展示
这种"距离 + 分数条 + 数值"的三重信息设计,从定性到定量、从视觉到数字,全方位展示了搜索结果的相关性质量。用户可以通过颜色快速筛选高相关结果,通过距离判断远近,通过数值获得精确信息。
Progress 组件是 ArkUI 内置的进度条原语,type: ProgressType.Linear 指定线性样式。与标杆平台使用单一绿色进度条不同,本平台的进度条颜色随 reliability 等级动态变化,使分数条本身就成为信息载体------绿色代表高相关、橙色代表中相关、灰色代表低相关,用户无需阅读文字即可通过颜色快速判断结果质量。
十二、提醒 Tab 深度分析
12.1 通知授权状态卡
typescript
@Builder
tabRemind() {
Column({ space: 12 }) {
Column({ space: 8 }) {
Row({ space: 8 }) {
Circle({ width: 8, height: 8 })
.fill(this.granted ? COLORS.green : COLORS.orange)
.opacity(this.breath ? 1 : 0.4)
Text(this.granted ? '通知授权:已开启' : '通知授权:未开启')
.fontSize(13).fontWeight(FontWeight.Bold)
.fontColor(this.granted ? COLORS.green : COLORS.orange)
Column().layoutWeight(1)
if (!this.granted) {
Button('去授权')
.height(28).fontSize(11)
.backgroundColor(COLORS.orange).fontColor(COLORS.bg)
.borderRadius(14)
.onClick(() => {
this.requestAuth();
})
}
}.width('100%')
Text('开启后可接收值机、集合、入住等行程提醒;铃声来自「铃音」Tab 的沙箱自定义铃声。')
.fontSize(10).fontColor(COLORS.text3).width('100%')
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
提醒 Tab 是 Notification Kit 特性的核心展示区,采用纵向四段式布局:通知授权状态卡、行程提醒时间轴、发布行程提醒(带铃声信息)、通知历史。整体通过时间轴可视化提醒设置,通过授权卡管理通知权限,通过发布按钮演示通知效果,通过历史流记录发布结果。
通知授权状态卡是提醒 Tab 的入口级组件,使用户一眼即可了解通知授权状态并进行操作。卡片内边距 12,圆角 12,深海军蓝底色。
卡片第一行为授权状态行,从左到右包含:
- 状态圆点 :直径 8px,已授权填充通过绿,未授权填充落日橙。圆点透明度随
breath状态在 1 和 0.4 间切换,实现呼吸动画------与头部呼吸圆点呼应,强化"实时状态"的视觉暗示 - 状态文字:13 号粗体,颜色与圆点一致(绿/橙),文字内容为"通知授权:已开启"或"通知授权:未开启"
- 空白占位 :
layoutWeight(1)的空 Column 撑开空间 - 授权按钮 :仅在未授权时显示(通过
if (!this.granted)条件渲染),高度 28,落日橙底深色字,圆角 14(胶囊形按钮),点击调用requestAuth()方法
卡片第二行为说明文字,10 号暗蓝灰色,解释通知的用途(值机、集合、入住等行程提醒)和铃声来源(来自铃音 Tab 的沙箱自定义铃声),将两个 Tab 的功能关联起来。
这种"状态圆点 + 状态文字 + 操作按钮 + 说明文字"的四层信息结构,使用户从左到右、从视觉到文字逐步获取信息,符合信息设计的"概览---详情---操作"认知模型。
12.2 请求授权与二次引导
typescript
requestAuth() {
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) {
return;
}
notificationManager.requestEnableNotification(hostCtx).then(() => {
this.granted = true;
this.noticeState = '通知授权已开启';
}).catch((err: BusinessError) => {
notificationManager.openNotificationSettings(hostCtx).then(() => {
}).catch((e2: BusinessError) => {
this.granted = false;
this.noticeState = '授权失败 ' + e2.code + ':' + e2.message;
});
});
}
requestAuth 方法实现了通知授权的"首次申请 + 拒绝后二次引导"完整链路。这是 Notification Kit 开发中的最佳实践------用户可能在首次申请时误拒绝,需要提供二次引导路径。
方法首先获取 UIAbility 上下文(使用 getUIContext().getHostContext() 而非已废弃的 getContext(this)),上下文不可用时直接返回。然后调用 notificationManager.requestEnableNotification(hostCtx) 发起授权请求,该方法会弹出系统授权对话框(首次调用时)。
授权成功时(.then 分支),将 granted 设为 true,更新 noticeState 为"通知授权已开启"。
授权被拒绝时(.catch 分支),调用 notificationManager.openNotificationSettings(hostCtx) 拉起系统通知设置页面,引导用户手动开启授权。这是一个巧妙的降级策略------当系统弹窗不再出现时(用户曾拒绝过),直接跳转到设置页让用户手动操作,避免因无法弹出授权框而导致功能完全不可用。openNotificationSettings 的成功回调为空(用户从设置页返回后状态可能已变,实际应用中应在 onActive 生命周期中重新查询授权状态),失败时记录错误信息。
12.3 行程提醒时间轴
typescript
Text('⏰ 行程提醒时间轴')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Column() {
ForEach(this.remindList, (item: RemindItem, idx: number) => {
Row() {
Column({ space: 4 }) {
Text(item.time).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(item.repeat).fontSize(9).fontColor(COLORS.text3)
}
.width(52).height('100%').justifyContent(FlexAlign.Center)
Column({ space: 4 }) {
Circle({ width: 8, height: 8 }).fill(item.on ? COLORS.cyan : COLORS.text3)
Column().layoutWeight(1).width(2)
.backgroundColor(item.on ? COLORS.cyan : COLORS.line)
}
.width(20).height('100%')
.alignItems(HorizontalAlign.Center).padding({ top: 10 })
Column({ space: 6 }) {
Text(item.title).fontSize(12).fontColor(item.on ? COLORS.title : COLORS.text3)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.on ? '提醒开启中' : '已暂停')
.fontSize(9).fontColor(item.on ? COLORS.green : COLORS.text3)
}
.layoutWeight(1).height('100%')
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Start)
Toggle({ type: ToggleType.Switch, isOn: item.on })
.selectedColor(COLORS.cyan).width(40).height(22)
.onChange(() => {
this.toggleRemind(idx);
})
}
.width('100%').height(72).margin({ bottom: 6 })
.alignItems(VerticalAlign.Top)
.backgroundColor(COLORS.card).borderRadius(12)
.padding({ left: 12, right: 12 })
}, (item: RemindItem, idx: number) => `remind-${idx}-${item.time}`)
}.width('100%')
行程提醒时间轴是提醒 Tab 的核心可视化组件,将 6 条行程提醒以时间线的形式纵向排列。每条提醒固定高度 72px,底部间距 6,深海军蓝底色卡片,圆角 12,内边距 12/12/0/0(左侧 12,右侧 12)。
每行由四列构成,从左到右依次为:
第一列:时间列(固定宽度 52px,内容垂直居中)。显示提醒时刻(13 号粗体冷白色)和重复规则(9 号暗蓝灰色),时间是时间轴的锚点信息,使用粗体强化视觉权重。
第二列:竖线列(固定宽度 20px,内容水平居中,顶部内边距 10px)。顶部为 8px 圆点------开启时霓虹青,关闭时暗蓝灰;底部为 2px 宽竖线------开启时霓虹青,关闭时分割线色。圆点和竖线共同构成时间轴的"节点+连线"视觉,开启状态的青色线条形成连贯的时间线,关闭状态的灰色线条则表示该节点"断开"。
这种设计的巧妙之处在于:当多个连续提醒都开启时,它们的竖线会连成一条完整的青色时间线,视觉上呈现出"从早到晚的提醒链路";而关闭的提醒则像时间线上的"断点",一目了然。
第三列:提醒内容列 (layoutWeight(1) 等分剩余宽度,内容垂直居中,左对齐)。显示提醒标题(12 号,开启时冷白色,关闭时暗蓝灰色)和状态文字(9 号,开启时通过绿"提醒开启中",关闭时暗蓝灰"已暂停")。标题文字色随开关状态变化,强化"启用/禁用"的视觉反馈。
第四列:开关列 。Toggle 开关组件,选中色霓虹青,尺寸 40x22,onChange 回调调用 toggleRemind(idx) 方法切换提醒状态。
toggleRemind 方法实现非常简洁:
typescript
toggleRemind(idx: number) {
const item = this.remindList[idx];
item.on = !item.on;
}
直接修改 RemindItem 实例的 on 属性。由于 RemindItem 是 @Observed 类,属性变更会自动触发时间轴(圆点颜色、竖线颜色、标题文字色、Toggle 状态)和头部胶囊(通知授权状态不直接关联此处,但提醒状态的变化反映了数据模型的响应式能力)的 UI 刷新。
12.4 发布行程提醒与通知历史
typescript
Column({ space: 8 }) {
Row({ space: 8 }) {
Text('🎵').fontSize(14)
Text('当前铃声:' + this.currentRing.name).fontSize(11).fontColor(COLORS.sub)
.layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.currentRing.inSandbox ? '沙箱已就绪' : '未生成').fontSize(9)
.fontColor(this.currentRing.inSandbox ? COLORS.green : COLORS.orange)
}.width('100%')
Row() {
Text('📣 发布行程提醒').fontSize(13).fontColor(COLORS.bg).fontWeight(FontWeight.Bold)
}
.width('100%').height(40).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor(COLORS.cyan)
.onClick(() => {
this.publishNotice('行程提醒', '集合出发前 30 分钟,请核对证件与签证材料。');
})
Text(this.noticeState).fontSize(10).fontColor(COLORS.text3).width('100%')
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
发布行程提醒区域是 Notification Kit 铃声链路的演示入口。卡片内边距 12,圆角 12,深海军蓝底色,间距 8。
第一行显示当前铃声信息:🎵 emoji + 铃声名称("当前铃声:登机叮咚")+ 沙箱状态("沙箱已就绪"通过绿 / "未生成"落日橙)。铃声名称使用 layoutWeight(1) 等分宽度,单行省略,确保在铃声名称较长时布局不混乱。这一行将铃音 Tab 的设置与提醒 Tab 的发布关联起来,体现了跨 Tab 数据共享的设计。
第二行为发布按钮,全宽 40px 高,霓虹青底深色粗体字,圆角 12。按钮文字"📣 发布行程提醒",点击调用 publishNotice('行程提醒', '集合出发前 30 分钟,请核对证件与签证材料。') 方法发布一条携带沙箱自定义铃声的通知。
第三行为发布结果状态文字,10 号暗蓝灰色,显示 noticeState 的当前值(如"通知已发布(id 100)"、"发布失败 1600004:..."等)。
typescript
Column({ space: 8 }) {
Text('📜 通知历史')
.fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach(this.noticeLogs, (log: NoticeLog, idx: number) => {
Column({ space: 4 }) {
Row() {
Text(log.title).fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.sub)
Column().layoutWeight(1)
Text(log.time).fontSize(9).fontColor(COLORS.text3)
}.width('100%')
Text(log.text).fontSize(10).fontColor(COLORS.text3).width('100%')
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%').padding(10).borderRadius(10).backgroundColor(COLORS.dark)
}, (log: NoticeLog, idx: number) => `notice-${idx}-${log.time}`)
}.width('100%')
通知历史流展示 noticeLogs 数组中的记录,标题为"📜 通知历史"。每条记录为深色容器底色的卡片,内边距 10,圆角 10,包含标题行和正文行:
- 标题行:通知标题(11 号粗体雾蓝灰色)+ 时间(9 号暗蓝灰色),标题左对齐,时间右对齐
- 正文行:通知正文(10 号暗蓝灰色),最多 2 行,超出省略
历史记录的标题使用雾蓝灰色(而非冷白色),正文使用暗蓝灰色,整体色调较暗------这是因为历史记录是"已发生的事件",视觉权重应低于当前操作区域(如发布按钮)。这种"操作区高亮、历史区弱化"的设计,引导用户关注当前操作而非历史记录。
十三、铃音 Tab 深度分析
13.1 当前铃声预览卡
typescript
@Builder
tabRing() {
Column({ space: 12 }) {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text('🎵').fontSize(16)
Column({ space: 3 }) {
Text(this.currentRing.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(`${this.currentRing.freq}Hz · ${this.currentRing.duration}ms · ${this.currentRing.size}`)
.fontSize(10).fontColor(COLORS.text3)
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(this.currentRing.inSandbox ? 'EL1 ✓' : '未落盘').fontSize(10)
.fontColor(this.currentRing.inSandbox ? COLORS.green : COLORS.orange)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10).backgroundColor(COLORS.dark)
}.width('100%')
Text('生成 WAV → 写入 EL1 files → 设默认 → sound 填 uri:: 前缀发布')
.fontSize(9).fontColor(COLORS.text3).width('100%')
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
铃音 Tab 是 Notification Kit 沙箱自定义铃声特性的配置区,采用纵向三段式布局:当前铃声预览卡、铃声库列表、发布状态反馈。用户在此 Tab 生成铃声、选择默认铃声,设置结果会被提醒 Tab 的发布通知功能直接引用。
当前铃声预览卡展示当前选中的默认铃声信息。卡片内边距 12,圆角 12,深海军蓝底色,间距 8。
第一行为铃声信息行,从左到右:
- 🎵 emoji(16 号),视觉化标识
- 铃声名称(14 号粗体冷白色)+ 参数行(10 号暗蓝灰色,格式为"频率 · 时长 · 文件大小"),使用
layoutWeight(1)等分宽度 - 沙箱状态胶囊:已落盘显示"EL1 ✓"配通过绿,未落盘显示"未落盘"配落日橙。深色容器底色,圆角 10
第二行为链路说明文字,9 号暗蓝灰色,描述自定义铃声的完整流程:"生成 WAV → 写入 EL1 files → 设默认 → sound 填 uri:: 前缀发布"。这是一个技术教育设计------用简洁的四步流程向开发者展示沙箱铃声链路的关键节点。
13.2 铃声库列表与沙箱生成
typescript
Text('🔔 铃声库(点击「生成」写入 EL1 沙箱)')
.fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Column({ space: 8 }) {
ForEach(this.ringList, (ring: RingItem, idx: number) => {
Row({ space: 10 }) {
Circle({ width: 8, height: 8 })
.fill(this.currentRing === ring ? COLORS.cyan : COLORS.line)
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(ring.name)
.fontSize(12).fontWeight(FontWeight.Bold)
.fontColor(this.currentRing === ring ? COLORS.cyan : COLORS.title)
Text(ring.inSandbox ? '沙箱' : '未生成')
.fontSize(9).fontColor(ring.inSandbox ? COLORS.green : COLORS.text3)
}
Text(`${ring.file} · ${ring.freq}Hz · ${ring.duration}ms · ${ring.size}`)
.fontSize(9).fontColor(COLORS.text3)
.fontFamily('monospace')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('生成')
.fontSize(11).fontColor(COLORS.cyan)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(10).backgroundColor(COLORS.dark)
.onClick(() => {
this.genRing(idx);
})
Text('设默认')
.fontSize(11).fontColor(COLORS.bg)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(10)
.backgroundColor(this.currentRing === ring ? COLORS.cyanD : COLORS.cyan)
.onClick(() => {
this.setCurrentRing(idx);
})
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
}, (ring: RingItem, idx: number) => `ring-${idx}-${ring.name}`)
}.width('100%')
Text(this.noticeState)
.fontSize(10).fontColor(COLORS.text3).width('100%')
铃声库列表是铃音 Tab 的主体内容,展示 6 条旅行场景铃声。标题为"🔔 铃声库(点击「生成」写入 EL1 沙箱)",提示用户操作方式。
每条铃声为一行卡片(内边距 12,圆角 12,深海军蓝底色),从左到右包含:
选中指示圆点:8px 直径,当前默认铃声为霓虹青,其余为分割线色。这个圆点是"当前选中"的视觉锚点,配合铃声名称的颜色变化(当前为霓虹青,其余为冷白),形成双重选中指示。
铃声信息列 (layoutWeight(1) 等分宽度):
- 第一行:铃声名称(12 号粗体,当前为霓虹青,其余为冷白)+ 沙箱状态标签(9 号,已生成通过绿,未生成暗蓝灰)
- 第二行:技术参数字符串(9 号暗蓝灰等宽字体),格式为"文件名 · 频率 · 时长 · 大小",如"ring_boarding_ding.wav · 990Hz · 900ms · 77 KB"。使用等宽字体确保参数整齐对齐,
maxLines(1)防止文件名过长时破坏布局。
操作按钮列:两个文字按钮,分别为"生成"和"设默认"。
- "生成"按钮:深色容器底色 + 霓虹青文字,圆角 10,内边距 10/10/5/5。点击调用
genRing(idx)生成该铃声并写入 EL1 沙箱。 - "设默认"按钮:当前铃声为霓虹青深色底(
cyanD)+ 深色字,其他为霓虹青底 + 深色字。点击调用setCurrentRing(idx)将该铃声设为默认铃声。
"设默认"按钮的背景色随选中状态变化------当前铃声使用更深的 cyanD(表示"已选中/已生效"的暗色态),其他使用标准 cyan(表示"可操作"的亮色态)。这种"亮/暗"状态切换是按钮设计的常见模式。
13.3 沙箱写入核心方法
typescript
saveRingToSandbox(fileName: string, freq: number, durationMs: number): string {
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) {
return '';
}
const appCtx = hostCtx.getApplicationContext();
appCtx.area = contextConstant.AreaMode.EL1;
const dir = appCtx.filesDir;
const path = dir + '/' + fileName;
try {
const data = buildWavBytes(freq, durationMs);
const file = fs.openSync(path, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC);
fs.writeSync(file.fd, data);
fs.closeSync(file);
} catch (e) {
console.error(`saveRing failed: ${(e as BusinessError).message}`);
return '';
}
return path;
}
saveRingToSandbox 是 Notification Kit 铃声链路的核心方法,负责将生成的 WAV 音频写入 EL1 沙箱目录。
方法的关键步骤如下:
- 获取上下文 :通过
getUIContext().getHostContext()获取 UIAbility 上下文(注意使用新 API 而非已废弃的getContext(this)),上下文不可用时返回空字符串。 - 设置 EL1 区域 :
appCtx.area = contextConstant.AreaMode.EL1------这是最关键的一步,通知铃声文件必须位于 EL1 加密等级的沙箱目录中,否则系统通知服务无法读取该文件。EL1(Encryption Level 1)是 HarmonyOS 的第一级加密等级,提供基础的数据加密保护。 - 拼接路径 :
appCtx.filesDir + '/' + fileName------filesDir是应用文件存储目录,在 EL1 区域下对应加密的文件系统路径。 - 生成并写入音频 :调用
buildWavBytes生成 WAV 音频字节,使用fs.openSync以 CREATE | WRITE_ONLY | TRUNC 模式打开文件(创建新文件或截断已有文件),writeSync写入音频数据,closeSync关闭文件句柄。 - 错误处理:写入失败时打印错误日志并返回空字符串,调用方根据返回值判断是否成功。
typescript
genRing(idx: number) {
const ring = this.ringList[idx];
const path = this.saveRingToSandbox(ring.file, ring.freq, ring.duration);
if (path !== '') {
ring.inSandbox = true;
const kb = Math.round((44 + 44100 * ring.duration / 1000 * 2) / 1024);
ring.size = `${kb} KB`;
this.noticeState = `「${ring.name}」已生成到 EL1 沙箱`;
} else {
this.noticeState = '沙箱写入失败,请重试';
}
}
genRing 方法是 saveRingToSandbox 的业务封装,处理铃声生成后的状态更新。成功时设置 ring.inSandbox = true(标记已写入沙箱),计算文件大小(44 字节头 + 采样数 × 2 字节)并更新 ring.size,更新 noticeState 显示成功消息。失败时更新 noticeState 显示失败消息。
由于 RingItem 是 @Observed 类,修改 ring.inSandbox 和 ring.size 属性会自动触发铃声库列表中对应条目的 UI 刷新------"未生成"标签变为"沙箱"标签,大小从"---"变为具体 KB 数,这些变化都由 ArkUI 的响应式机制自动完成,无需手动刷新列表。
13.4 通知发布与 sound 链路
typescript
publishNotice(title: string, text: string) {
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) {
this.noticeState = '上下文不可用,发布取消';
return;
}
const appCtx = hostCtx.getApplicationContext();
appCtx.area = contextConstant.AreaMode.EL1;
const sandboxPath = appCtx.filesDir + '/' + this.currentRing.file;
const uri = fileUri.getUriFromPath(sandboxPath);
const request: notificationManager.NotificationRequest = {
id: this.notifyId++,
notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,
content: {
notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: {
title: title,
text: text,
additionalText: '铃声:' + this.currentRing.name
}
},
sound: 'uri::' + uri
};
notificationManager.publish(request).then(() => {
this.noticeState = `通知已发布(id ${this.notifyId - 1})`;
this.noticeLogs.unshift(new NoticeLog(title, text + ' · 铃声 ' + this.currentRing.name, nowTime()));
if (this.noticeLogs.length > 8) {
this.noticeLogs.pop();
}
}).catch((err: BusinessError) => {
this.noticeState = '发布失败 ' + err.code + ':' + err.message;
});
}
publishNotice 方法是 Notification Kit 铃声链路的终点,负责发布一条携带自定义铃声的通知。
方法的关键步骤:
- 获取上下文并设置 EL1 :与
saveRingToSandbox相同的 EL1 设置流程,确保路径一致。 - 构建 sound 值 :这是 6.1.1 新特性的核心------
fileUri.getUriFromPath(sandboxPath)将沙箱文件路径转换为 URI,再以'uri::' + uri的前缀格式拼接成sound字段值。在此之前,sound字段只能传 rawfile 资源文件名,无法使用动态生成的音频文件。 - 构建 NotificationRequest:包含通知 ID(自增)、通知渠道类型(社交通信,高优先级)、内容类型(基本文本)、标题、正文、附加信息(铃声名称)。
- 发布通知 :调用
notificationManager.publish(request)发布。成功时更新状态、置顶通知历史(最多 8 条);失败时更新错误状态。
sound: 'uri::' + uri 这一行代码是整个沙箱铃声链路的"最后一公里"------它将动态生成的 WAV 文件与系统通知服务连接起来,使每个通知都能携带独特的铃声。这是 6.1.1 版本 Notification Kit 的重要增强。
十四、字幕 Tab 深度分析
14.1 AICaptionComponent 实时预览
typescript
@Builder
tabCaption() {
Column({ space: 12 }) {
Column({ space: 10 }) {
Row() {
Text('🗣 AI 字幕实时预览')
.fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(this.captionReady ? '已就绪' : '初始化中')
.fontSize(10).fontColor(this.captionReady ? COLORS.green : COLORS.text3)
}.width('100%')
AICaptionComponent({
isShown: this.captionShown,
controller: this.captionController,
options: this.buildCaptionOptions()
})
.width('100%')
.height(110)
.borderRadius(10)
if (this.captionErrMsg !== '') {
Text(this.captionErrMsg)
.fontSize(10).fontColor(COLORS.red).width('100%')
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
}
Row({ space: 10 }) {
Button(this.captionShown ? '隐藏字幕' : '开启字幕')
.fontSize(12).height(32).layoutWeight(1)
.backgroundColor(COLORS.cyan).fontColor(COLORS.bg)
.borderRadius(10)
.onClick(() => {
this.captionShown = !this.captionShown;
})
Button(`写入演示音频(${this.captionFed})`)
.fontSize(12).height(32).layoutWeight(1)
.backgroundColor(COLORS.dark).fontColor(COLORS.sub)
.borderRadius(10)
.onClick(() => {
this.feedAudioStream();
})
}.width('100%')
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
字幕 Tab 是 Speech Kit AI 字幕特性的核心展示区,采用纵向五区块布局:AI 字幕实时预览、语言设置、字号设置、字体颜色、字幕场景。每个区块独立封装一个字幕配置维度,使用户可以全方位体验 AI 字幕的四新字段。
AI 字幕实时预览卡 是字幕 Tab 的核心组件,展示 AICaptionComponent 组件的实际效果。卡片内边距 12,圆角 12,深海军蓝底色,间距 10。
标题行显示"🗣 AI 字幕实时预览"和服务状态------已就绪(通过绿)或初始化中(暗蓝灰)。状态由 captionReady 变量控制,该变量在 onPrepared 回调中被设为 true。
AICaptionComponent 是 Speech Kit 提供的 AI 字幕组件,接收三个关键参数:
isShown:控制字幕显隐,与captionShown双向绑定(@Link语义,直接传@State引用即可)controller:AICaptionController实例,用于控制字幕和写入音频流options:AICaptionOptions配置对象,由buildCaptionOptions()方法组装
组件宽度 100%,高度固定 110px,圆角 10。这个高度足以容纳两行字幕内容。
错误信息条件渲染:当 captionErrMsg 不为空时显示红色错误文字,最多 2 行。错误信息由 onError 回调设置。
底部两个等分按钮:
- "开启字幕/隐藏字幕":霓虹青底深色字,切换
captionShown控制字幕显隐 - "写入演示音频(N)":深色容器底雾蓝灰字,调用
feedAudioStream()写入演示音频块,括号内显示已写入块数
14.2 AICaptionOptions 四新字段组装
typescript
buildCaptionOptions(): AICaptionOptions {
const opts: AICaptionOptions = {
initialOpacity: 1,
sourceLanguage: this.srcLang,
targetLanguage: this.tgtLang,
fontSize: this.captionSize,
fontColor: this.captionColor,
onPrepared: () => {
this.captionReady = true;
this.captionErrMsg = '';
},
onError: (error: BusinessError) => {
this.captionErrMsg = '字幕服务异常 ' + error.code + ':' + error.message;
}
};
return opts;
}
buildCaptionOptions 方法组装完整的 AICaptionOptions 配置对象,集中体现了 6.1.1 版本 AI 字幕组件的四个新增字段。
四个 6.1.1 新字段:
sourceLanguage:字幕源语言,取值'zh' | 'en',默认'en'(跨境场景以英文源为主)targetLanguage:字幕目标语言,取值'zh' | 'en' | 'zh-en',默认'zh-en'(中英双语对照)fontSize:字体大小,AICaptionFontSize枚举类型,四档可选(SMALL / NORMAL / BIG / LARGE),默认NORMALfontColor:字体颜色,ResourceColor类型,实际传'#RRGGBB'字符串,默认经典白#FFFFFF
两个回调兜底:
onPrepared:字幕服务初始化完成时触发,置captionReady = true并清空错误信息onError:服务异常时触发,将错误码和消息写入captionErrMsg,UI 层条件渲染红色错误提示
initialOpacity 为初始透明度(0~1),设为 1 表示完全不透明。
typescript
switchSourceLang(code: string) {
this.srcLang = code;
if (code === 'zh') {
this.tgtLang = 'zh';
} else {
this.tgtLang = 'zh-en';
}
}
switchSourceLang 方法实现源语言切换时的目标语言联动。中文源时目标语言锁定 'zh'(原文直显不翻译,因为中译中无意义),UI 层显示"中文(锁定 zh)"静态标签且无可选项;英文源时目标语言默认切换为 'zh-en'(中英双语),用户可在中文翻译、英文原文、中英双语三选间自由切换。这种联动设计避免了"中文源选择英文翻译"的无意义组合,是产品精细化思考的体现。
14.3 语言设置区块
typescript
Column({ space: 10 }) {
Text('🌐 语言设置(sourceLanguage → targetLanguage)')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Row({ space: 8 }) {
Text('源语言').fontSize(11).fontColor(COLORS.text3).width(48)
ForEach(SRC_LANGS, (opt: LangOption) => {
Text(opt.name)
.fontSize(11)
.fontColor(this.srcLang === opt.code ? COLORS.bg : COLORS.sub)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.srcLang === opt.code ? COLORS.cyan : COLORS.dark)
.onClick(() => {
this.switchSourceLang(opt.code);
})
}, (opt: LangOption) => opt.code)
}.width('100%')
if (this.srcLang === 'zh') {
Row({ space: 8 }) {
Text('目标语言').fontSize(11).fontColor(COLORS.text3).width(48)
Text('中文(锁定 zh)')
.fontSize(11).fontColor(COLORS.purple)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14).backgroundColor(COLORS.dark)
Text('中文源无翻译方向可选')
.fontSize(9).fontColor(COLORS.text3)
}.width('100%')
} else {
Row({ space: 8 }) {
Text('目标语言').fontSize(11).fontColor(COLORS.text3).width(48)
ForEach(TGT_LANGS_EN, (opt: LangOption) => {
Text(opt.name)
.fontSize(11)
.fontColor(this.tgtLang === opt.code ? COLORS.bg : COLORS.sub)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.tgtLang === opt.code ? COLORS.purple : COLORS.dark)
.onClick(() => {
this.tgtLang = opt.code;
})
}, (opt: LangOption) => opt.code)
}.width('100%')
}
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
语言设置区块包含源语言和目标语言两组选项。区块标题为"🌐 语言设置(sourceLanguage → targetLanguage)",直接点明对应的 API 字段名,兼具功能说明和技术教育意义。
源语言行 :左侧标签"源语言"(48px 宽,暗蓝灰色),右侧两个选项按钮------中文、英文。选中态为霓虹青底深色字,未选中为深色容器底雾蓝灰字。点击调用 switchSourceLang 方法切换源语言并联动目标语言。
目标语言行:根据源语言动态渲染。
- 中文源时:显示静态标签"中文(锁定 zh)"(星紫色,与目标语言选中态的紫色一致)+ 说明文字"中文源无翻译方向可选",明确告知用户为何不能选择目标语言
- 英文源时:显示三个选项按钮------中文、英文、中英双语。选中态为星紫底深色字,未选中为深色容器底雾蓝灰字。点击直接切换
tgtLang状态变量。
目标语言选中使用星紫色(purple)而非霓虹青,是为了与源语言的青色形成区分------源语言用主色青,目标语言用辅助色紫,形成"源→目标"的色彩层次。
14.4 字号与颜色设置
typescript
Column({ space: 10 }) {
Text('🔠 字幕字号(AICaptionFontSize 四档枚举)')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Row({ space: 8 }) {
ForEach(SIZE_OPTIONS, (opt: SizeOption) => {
Column({ space: 4 }) {
Text(opt.name === '超大' ? '大A' : (opt.name === '大号' ? '大' : (opt.name === '小号' ? '小' : '标')))
.fontSize(opt.size === AICaptionFontSize.LARGE ? 20 : (opt.size === AICaptionFontSize.BIG ? 17 : (opt.size === AICaptionFontSize.SMALL ? 11 : 14)))
.fontColor(this.captionSize === opt.size ? COLORS.cyan : COLORS.sub)
Text(opt.name)
.fontSize(9)
.fontColor(this.captionSize === opt.size ? COLORS.cyan : COLORS.text3)
}
.layoutWeight(1)
.padding({ top: 8, bottom: 8 })
.borderRadius(10)
.backgroundColor(this.captionSize === opt.size ? COLORS.dark : COLORS.card)
.border({
width: this.captionSize === opt.size ? 1 : 0,
color: this.captionSize === opt.size ? COLORS.cyan : COLORS.line
})
.onClick(() => {
this.captionSize = opt.size;
})
}, (opt: SizeOption) => opt.name)
}.width('100%')
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
字号设置区块提供四档字号选择(小号/标准/大号/超大),对应 AICaptionFontSize 枚举的四个值。每个选项为一个 Column,使用 layoutWeight(1) 等分宽度。
字号选择的设计亮点是"所见即所得"------示例文字的实际字号与选项对应的字号一致:小号 11px、标准 14px、大号 17px、超大 20px。用户选择时可以直观感受到每种字号的实际大小,而非仅凭文字描述想象。示例文字使用"小/标/大/大A"的简化表达,既传达了字号含义又节省空间。
选中态有三重视觉反馈:示例文字色变为霓虹青、名称文字色变为霓虹青、背景变为深色容器底色 + 1px 霓虹青边框。未选中态为卡片底色 + 无边框。
typescript
Column({ space: 10 }) {
Text('🎨 字幕颜色(fontColor · ResourceColor)')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Row({ space: 10 }) {
ForEach(CAPTION_FONT_COLORS, (c: string, idx: number) => {
Column({ space: 5 }) {
Column()
.width(30).height(30).borderRadius(15)
.backgroundColor(c)
.border({
width: this.captionColor === c ? 2 : 1,
color: this.captionColor === c ? COLORS.cyan : COLORS.line
})
Text(this.captionColor === c ? '使用中' : `#${idx + 1}`)
.fontSize(8)
.fontColor(this.captionColor === c ? COLORS.cyan : COLORS.text3)
}
.layoutWeight(1)
.onClick(() => {
this.captionColor = c;
})
}, (c: string, idx: number) => `color-${idx}-${c}`)
}.width('100%')
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
字体颜色设置区块提供五种预设颜色选择。每个颜色选项为一个 30px 的圆形色块(圆角 15 = 正圆),下方有文字标签。选中态有三重反馈:边框宽度 2px(未选中 1px)、边框色霓虹青(未选中分割线色)、标签文字变为霓虹青且内容为"使用中"(未选中为暗蓝灰 + "#序号")。
五种颜色分别为:经典白(#FFFFFF)、霓虹青(#7CE8F5)、落日暖杏(#FFD9A8)、薄荷绿(#C9F2D9)、樱花粉(#FFC2CE)。每种颜色都经过精心挑选,确保在深色字幕背景上的可读性,同时覆盖了不同的情感色调------白色经典、青色科技、橙色温暖、绿色清新、粉色浪漫。
14.5 字幕场景卡与音频写入
typescript
Column({ space: 8 }) {
Text('🧳 跨境讲解场景(点击套用推荐语言组合)')
.fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach(CAPTION_SCENES, (sc: CaptionScene, idx: number) => {
Row({ space: 10 }) {
Text('🗣').fontSize(14)
Column({ space: 3 }) {
Text(sc.scene)
.fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(sc.desc)
.fontSize(10).fontColor(COLORS.text3)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(sc.src + '→' + sc.tgt)
.fontSize(9).fontColor(COLORS.cyan)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10).backgroundColor(COLORS.dark)
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
.onClick(() => {
this.applyScene(idx);
})
}, (sc: CaptionScene, idx: number) => `scene-${idx}-${sc.scene}`)
if (this.captionErrMsg !== '') {
Text('onError 兜底:' + this.captionErrMsg)
.fontSize(9).fontColor(COLORS.red).width('100%')
}
}.width('100%')
字幕场景卡区块提供五个旅行场景的快捷设置,点击即可套用对应场景推荐的语言组合。这是一个"场景化设计"的典型案例------将技术参数(源语言/目标语言)包装成用户易懂的场景概念,降低使用门槛。
每个场景卡为一行,从左到右:🗣 emoji + 场景信息(场景名 + 场景描述)+ 语言方向胶囊。场景名 12 号粗体冷白色,描述 10 号暗蓝灰色单行省略。语言方向胶囊为霓虹青文字 + 深色容器底,格式为"源→目标"(如"en→zh-en")。
点击场景卡调用 applyScene(idx) 方法:
typescript
applyScene(idx: number) {
const sc = CAPTION_SCENES[idx];
this.srcLang = sc.src;
this.tgtLang = sc.tgt;
}
直接将 srcLang 和 tgtLang 设置为场景推荐值,一步完成语言配置。由于 ArkUI 的响应式机制,语言设置区块的选中态会自动同步更新。
typescript
feedAudioStream() {
const block = new Uint8Array(640);
for (let i = 0; i < 640; i += 2) {
const t = (i / 2) / 16000;
const v = Math.round(Math.sin(2 * Math.PI * 440 * t) * 6000);
block[i] = v & 0xFF;
block[i + 1] = (v >> 8) & 0xFF;
}
try {
const audioData: AudioData = { data: block };
this.captionController.writeAudio(audioData);
this.captionFed++;
} catch (e) {
this.captionErrMsg = '音频写入失败:' + (e as BusinessError).message;
}
}
feedAudioStream 方法演示音频流写入功能。生成 640 字节的 PCM 音频块(16kHz 采样率、16bit 量化、单声道,约 20ms 时长),使用 440Hz 正弦波模拟音频信号。每个采样点占 2 字节(16bit),通过位运算 v & 0xFF 取低字节、(v >> 8) & 0xFF 取高字节写入 Uint8Array。
组装好的 AudioData 对象通过 captionController.writeAudio 写入字幕引擎,captionFed 计数器递增。在真实场景中,音频数据来自麦克风采集,这里用合成正弦波演示调用链完整性。写入失败时 catch 分支记录错误消息到 captionErrMsg,UI 层红色提示。
十五、我的 Tab 深度分析
15.1 旅行家渐变大卡
typescript
@Builder
tabMine() {
Column({ space: 12 }) {
Column({ space: 12 }) {
Row({ space: 10 }) {
Text('🧑✈️').fontSize(30)
Column({ space: 3 }) {
Text('环球线旅行家 · VoyagerPro')
.fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('开通 680 天 · 跨境行程终身版')
.fontSize(10).fontColor(COLORS.sub)
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Circle({ width: 8, height: 8 })
.fill(COLORS.cyan)
.opacity(this.breath ? 1 : 0.3)
}.width('100%')
Row() {
Column({ space: 3 }) {
Text('12').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.cyan)
Text('足迹国家').fontSize(9).fontColor(COLORS.sub)
}.layoutWeight(1)
Column({ space: 3 }) {
Text('38').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.orange)
Text('解锁城市').fontSize(9).fontColor(COLORS.sub)
}.layoutWeight(1)
Column({ space: 3 }) {
Text('28').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.purple)
Text('出行天数').fontSize(9).fontColor(COLORS.sub)
}.layoutWeight(1)
Column({ space: 3 }) {
Text('6').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.green)
Text('待启程').fontSize(9).fontColor(COLORS.sub)
}.layoutWeight(1)
}.width('100%')
}
.width('100%').padding(16)
.borderRadius(16)
.linearGradient({
angle: 160,
colors: [[COLORS.cyanD, 0], [COLORS.dark, 0.55], [COLORS.card, 1]]
})
我的 Tab 是用户个人中心,采用纵向三段式布局:旅行家渐变大卡、足迹国家清单、版本声明。整体风格围绕"旅行成就"展开,通过渐变大卡和统计数据展示用户的旅行足迹。
旅行家渐变大卡是我的 Tab 的视觉焦点。卡片使用 160 度角的三段式线性渐变------从霓虹青深色(左上角)到深色容器(中间 55% 位置)再到深海军蓝(右下角),形成从"亮色高光"到"深色底色"的过渡,营造层次感和深度感。圆角 16,内边距 16。
卡片顶部为用户信息行:
- 🧑✈️ 飞行员 emoji(30 号),作为用户头像的替代
- 用户等级名称"环球线旅行家 · VoyagerPro"(16 号粗体冷白色)+ 会员信息"开通 680 天 · 跨境行程终身版"(10 号雾蓝灰色)
- 呼吸圆点(8px 霓虹青,透明度随 breath 在 1 和 0.3 间切换)
卡片底部为四栏统计数据,每栏 layoutWeight(1) 等分宽度,纵向排列数值和标签:
- 12 个足迹国家(霓虹青)
- 38 个解锁城市(落日橙)
- 28 天出行天数(星紫)
- 6 个待启程(通过绿)
四种颜色分别对应平台的四个强调色------青色、橙色、紫色、绿色,使统计数据既丰富多彩又与整体色彩体系保持一致。数值使用 20 号粗体强化视觉权重,标签使用 9 号弱化,形成"大数字 + 小标签"的经典统计卡片设计。
15.2 足迹国家清单
typescript
Text('🌏 足迹国家 / 地区')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Column({ space: 8 }) {
ForEach(FOOT_ROWS, (row: FootRow, idx: number) => {
Row({ space: 10 }) {
Text(row.flag).fontSize(20)
Text(row.name)
.fontSize(13).fontColor(COLORS.title)
.layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${row.cities} 城`)
.fontSize(11).fontColor(COLORS.sub)
Text(row.last)
.fontSize(10).fontColor(COLORS.text3)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(8).backgroundColor(COLORS.dark)
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
}, (row: FootRow, idx: number) => `foot-${idx}-${row.name}`)
}.width('100%')
足迹国家清单以列表形式展示用户到访过的国家和地区。标题为"🌏 足迹国家 / 地区"。每条记录为一行卡片(内边距 12,圆角 12,深海军蓝底色),从左到右包含:
- 国旗 emoji(20 号):视觉化的国家标识,比文字更易快速识别
- 国家/地区名 (13 号冷白色,
layoutWeight(1)等分宽度,单行省略):主要信息 - 城市数(11 号雾蓝灰色,格式"N 城"):旅行深度指标
- 最近到访(10 号暗蓝灰色,深色容器底胶囊,圆角 8):时间信息
列表按最近到访时间倒序排列(最新的在最上面),使用户首先看到最近的旅行记录。6 条记录涵盖了亚洲热门出境游目的地------中国香港、日本、泰国、新加坡、韩国、中国澳门,城市数量从 18 到 1 不等,体现了旅行深度的差异。
typescript
Column({ space: 4 }) {
Text('跨境行程 v6.1.1 · Map Kit + Speech Kit + Notification Kit')
.fontSize(9).fontColor(COLORS.text3).width('100%')
.textAlign(TextAlign.Center)
Text('多语旅行服务 · 深色主题')
.fontSize(9).fontColor(COLORS.text3).width('100%')
.textAlign(TextAlign.Center)
}
.width('100%').padding({ top: 4, bottom: 4 })
列表下方为版本与特性声明,两行居中文字(9 号暗蓝灰色),分别显示版本号和特性组合、产品定位和主题风格。这是一个常见的"关于"区域设计,既展示了产品信息,又填充了页面底部的空白空间。
十六、底部 Tab 栏构建逻辑
16.1 TabBar 整体结构
typescript
@Builder
tabBar() {
Row() {
ForEach(TABS, (tab: TabItem, idx: number) => {
Column({ space: 2 }) {
Text(tab.icon)
.fontSize(20)
.opacity(this.currentTab === idx ? 1 : 0.55)
Text(tab.name)
.fontSize(10)
.fontColor(this.currentTab === idx ? COLORS.cyan : COLORS.text3)
.maxLines(1)
if (tab.badge) {
Badge({ count: tab.badge, position: BadgePosition.RightTop }) {
Circle({ width: 0, height: 0 })
}
.fontColor(COLORS.bg).badgeColor(COLORS.orange)
.fontSize(9).maxCount(99)
.margin({ left: 28, bottom: 22 })
}
}
.layoutWeight(1).height('100%')
.justifyContent(FlexAlign.Center)
.onClick(() => {
if (this.currentTab !== idx) {
this.currentTab = idx;
}
})
}, (tab: TabItem, idx: number) => `tab-${idx}-${tab.name}`)
}
.width('100%').height(58)
.padding({ left: 4, right: 4, bottom: 6 })
.backgroundColor(COLORS.nav)
.borderRadius({ topLeft: 14, topRight: 14 })
}
底部 Tab 栏是应用的全局导航枢纽,承载 7 个 Tab 的切换功能。Tab 栏固定高度 58px,左右内边距 4px,底部内边距 6px,底部导航栏底色,顶部左右圆角 14px(形成"卡片式"底部栏的视觉效果)。
顶部圆角设计是一个细节亮点------与完全平直的底部栏相比,顶部圆角使 Tab 栏看起来像一个"浮在内容下方的托盘",增强了界面的层次感和精致感。圆角半径 14px 与内容卡片的 12px 圆角接近,保持了视觉语言的一致性。
16.2 Tab 项构建与状态切换
每个 Tab 项使用 layoutWeight(1) 等分宽度(7 个 Tab 各占 1/7),内容垂直居中,纵向间距 2px。
Tab 项包含三层内容:
第一层:图标 。20 号 emoji 图标,选中态透明度 1(完全不透明),未选中态 0.55(半透明)。使用透明度而非颜色变化来区分选中态,是因为 emoji 本身是多色的,无法通过 fontColor 改变颜色------透明度变化是处理 emoji 图标的常用技巧。
第二层:文字。10 号 Tab 名称,选中态霓虹青色,未选中态暗蓝灰色。文字颜色变化是 Tab 选中的主要视觉指示,配合图标的透明度变化形成双重反馈。
第三层:角标 (条件渲染)。当 tab.badge 有值时显示 Badge 组件。角标使用 BadgePosition.RightTop 定位在右上角,数字显示未读数量。底色调为落日橙,文字色为深色背景色,字号 9 号,最大显示 99(超过显示 99+)。角标的位置通过 margin({ left: 28, bottom: 22 }) 微调------Badge 组件包裹了一个 0 尺寸的 Circle(作为锚点),通过 margin 调整角标的精确位置。
点击 Tab 项时执行 onClick 回调:
typescript
.onClick(() => {
if (this.currentTab !== idx) {
this.currentTab = idx;
}
})
这里有一个小优化------只有点击非当前 Tab 时才更新 currentTab。虽然直接赋值也能正常工作(ArkUI 会自动跳过相同值的状态更新),但显式判断可以减少不必要的状态检查,是一种"防御性编程"的良好习惯。
16.3 Tabs 组件与 TabContent 映射
typescript
Tabs({ barPosition: BarPosition.End, index: this.currentTab, controller: this.tabsCtrl }) {
TabContent() { this.tabTrip() }.tabBar(this.tabBarBuilder(0))
TabContent() { this.tabMap() }.tabBar(this.tabBarBuilder(1))
TabContent() { this.tabSearch() }.tabBar(this.tabBarBuilder(2))
TabContent() { this.tabRemind() }.tabBar(this.tabBarBuilder(3))
TabContent() { this.tabRing() }.tabBar(this.tabBarBuilder(4))
TabContent() { this.tabCaption() }.tabBar(this.tabBarBuilder(5))
TabContent() { this.tabMine() }.tabBar(this.tabBarBuilder(6))
}
.barHeight(0)
.barMode(BarMode.Fixed)
.onChange((idx: number) => {
this.currentTab = idx;
})
.width('100%').layoutWeight(1)
Tabs 组件是 ArkUI 提供的标签页容器,barPosition: BarPosition.End 指定 Tab 栏在底部。index 属性绑定 currentTab 实现受控切换,controller 提供编程式切换能力。
每个 TabContent 对应一个 Tab 页面,.tabBar() 方法指定自定义 Tab 栏项构建器。这里使用了 tabBarBuilder(idx) 方法来统一构建每个 Tab 的栏项------传入索引即可复用构建逻辑。
注意 barHeight(0) 的设置------这是自定义 Tab 栏的关键技巧。将系统默认 Tab 栏的高度设为 0(完全隐藏),然后在页面底部使用自定义的 tabBar() 构建器渲染自定义 Tab 栏。这样做的好处是可以完全控制 Tab 栏的样式(如顶部圆角、角标位置、图标样式等),不受系统 Tab 栏默认样式的限制。
barMode(BarMode.Fixed) 指定固定模式,所有 Tab 平分宽度。
onChange 回调在用户滑动切换 Tab 时触发,将新的索引同步到 currentTab 状态变量。这确保了无论是点击自定义 Tab 栏还是滑动页面切换,currentTab 都能保持最新值,头部区域的副标题和状态胶囊也会同步更新。
十七、弹窗系统深度分析
17.1 行程详情弹窗
typescript
@Builder
tripDetailDialog() {
if (this.tripDetailIdx >= 0 && this.tripDetailIdx < TRIP_LIST.length) {
const t = TRIP_LIST[this.tripDetailIdx];
Column({ space: 14 }) {
Row() {
Text('📋 行程详情').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.text3)
.onClick(() => {
this.tripDetailIdx = -1;
})
}.width('100%')
Row({ space: 12 }) {
Text(t.city).fontSize(22).fontWeight(FontWeight.Bold).fontColor(COLORS.cyan)
Text(t.days + ' 天').fontSize(12).fontColor(COLORS.orange)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10).backgroundColor(COLORS.dark)
Text(t.visa).fontSize(12).fontColor(COLORS.purple)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10).backgroundColor(COLORS.dark)
}.width('100%')
Column({ space: 6 }) {
Text('行程概览').fontSize(12).fontColor(COLORS.sub).width('100%')
Text(t.plan).fontSize(13).fontColor(COLORS.title).width('100%')
.lineHeight(20)
}.width('100%')
Row({ space: 10 }) {
Button('设为当前行程')
.layoutWeight(1).height(40).fontSize(13)
.backgroundColor(COLORS.cyan).fontColor(COLORS.bg)
.borderRadius(10)
.onClick(() => {
this.currentCity = t.city;
this.tripDetailIdx = -1;
})
Button('查看地图')
.layoutWeight(1).height(40).fontSize(13)
.backgroundColor(COLORS.dark).fontColor(COLORS.sub)
.borderRadius(10)
.onClick(() => {
this.searchInput = t.city;
this.searchByText();
this.currentTab = 1;
this.tripDetailIdx = -1;
})
}.width('100%')
}
.width('86%').padding(18)
.borderRadius(16).backgroundColor(COLORS.card)
}
}
行程详情弹窗是行程 Tab 卡片点击后的详情展示弹窗,通过 tripDetailIdx 变量控制显隐(-1 表示关闭,非负索引表示显示对应行程)。弹窗宽度 86%,内边距 18,圆角 16,深海军蓝底色。
弹窗内容分为四个区域:
标题栏 :左侧"📋 行程详情"(16 号粗体冷白色),右侧关闭按钮"✕"(16 号暗蓝灰色),点击关闭弹窗。中间 layoutWeight(1) 空 Column 撑开空间。
行程信息行:城市名(22 号粗体霓虹青,最大视觉权重)+ 天数胶囊(落日橙 + 深色容器底)+ 签证胶囊(星紫 + 深色容器底)。三个信息元素从左到右视觉权重递减,符合"主信息---辅助信息---辅助信息"的信息层级。
行程概览区:标签"行程概览"(12 号雾蓝灰色)+ 行程描述(13 号冷白色,行高 20)。行高 20 是 13 号字体的约 1.54 倍,提供舒适的阅读行距。
操作按钮行 :两个等分按钮------"设为当前行程"(霓虹青底深色字)和"查看地图"(深色容器底雾蓝灰字)。点击"设为当前行程"将 currentCity 设置为该城市并关闭弹窗;点击"查看地图"则将搜索关键词设为城市名、触发地图搜索、切换到地图 Tab、关闭弹窗------实现了跨 Tab 的数据传递和页面跳转。
17.2 地图详情弹窗
typescript
@Builder
mapDetailDialog() {
if (this.showMapDetail && this.selectedPOI) {
const p = this.selectedPOI;
Column({ space: 12 }) {
Row() {
Text('📍 地点详情').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.text3)
.onClick(() => {
this.showMapDetail = false;
})
}.width('100%')
Column({ space: 3 }) {
Text(p.name).fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(p.address).fontSize(11).fontColor(COLORS.text3)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
}.width('100%').alignItems(HorizontalAlign.Start)
Row({ space: 10 }) {
Column({ space: 2 }) {
Text(p.lat).fontSize(12).fontColor(COLORS.cyan).fontFamily('monospace')
Text('纬度').fontSize(9).fontColor(COLORS.text3)
}.layoutWeight(1)
Column({ space: 2 }) {
Text(p.lng).fontSize(12).fontColor(COLORS.cyan).fontFamily('monospace')
Text('经度').fontSize(9).fontColor(COLORS.text3)
}.layoutWeight(1)
}.width('100%').padding(10).borderRadius(10).backgroundColor(COLORS.dark)
Row({ space: 10 }) {
Button('加到行程').layoutWeight(1).height(38).fontSize(13)
.backgroundColor(COLORS.cyan).fontColor(COLORS.bg).borderRadius(10)
.onClick(() => {
this.showMapDetail = false;
})
Button('路线导航').layoutWeight(1).height(38).fontSize(13)
.backgroundColor(COLORS.dark).fontColor(COLORS.sub).borderRadius(10)
.onClick(() => {
this.showMapDetail = false;
})
}.width('100%')
}
.width('86%').padding(18)
.borderRadius(16).backgroundColor(COLORS.card)
}
}
地图详情弹窗在长按 POI 或 Marker 后弹出,展示选中地点的详细信息。弹窗由 showMapDetail 布尔值和 selectedPOI 对象共同控制显隐------只有当两者都有效时才显示。
弹窗内容同样分为四个区域:
标题栏:"📍 地点详情" + 关闭按钮,与行程详情弹窗结构一致。
POI 信息区:POI 名称(16 号粗体冷白色,最多 2 行省略)+ 地址(11 号暗蓝灰色,最多 2 行省略)。左对齐布局。
经纬度展示区:深色容器底 + 圆角 10 + 内边距 10。两列等分显示纬度和经度,数值 12 号霓虹青等宽字体,标签 9 号暗蓝灰色。等宽字体确保经纬度数字整齐对齐,便于阅读。
操作按钮行:两个等分按钮------"加到行程"(霓虹青底深色字)和"路线导航"(深色容器底雾蓝灰字)。当前实现仅关闭弹窗(占位逻辑),真实应用中会触发相应业务操作。
17.3 通用弹窗容器与动画
typescript
Stack({ alignContent: Alignment.Center }) {
// 主内容区(Tabs + 头部 + 底部Tab栏)
Column() {
this.headerMain()
Tabs(...) { ... }
this.tabBar()
}
// 遮罩层 + 弹窗
if (this.tripDetailIdx >= 0 || this.showMapDetail) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(() => {
this.tripDetailIdx = -1;
this.showMapDetail = false;
})
this.tripDetailDialog()
this.mapDetailDialog()
}
}
弹窗系统采用 Stack 堆叠布局实现------主内容在底层,遮罩层和弹窗在顶层。Stack 的 alignContent: Alignment.Center 确保弹窗居中显示。
遮罩层 :全屏半透明黑色(rgba(0,0,0,0.55),55% 不透明度),点击遮罩关闭所有弹窗(将 tripDetailIdx 设为 -1,showMapDetail 设为 false)。55% 的不透明度是一个精心选择的平衡------既能明显区分前景弹窗和背景内容,又不会完全遮挡背景使用户失去上下文感。
弹窗内容 :tripDetailDialog() 和 mapDetailDialog() 两个 Builder 都有内部的显隐判断(if (this.tripDetailIdx >= 0 ...) 和 if (this.showMapDetail && this.selectedPOI)),因此即使同时出现在 Stack 中,也只会显示条件满足的那一个。两个弹窗互斥------一次只能打开一个(因为点击遮罩会同时关闭两者,且业务逻辑上也不会同时触发)。
弹窗的显示/隐藏是"瞬时"的------没有淡入淡出动画。如果需要添加动画效果,可以使用 animateTo 包裹状态变更,或使用 ArkUI 的 bindSheet / bindContentCover 等弹窗原语。本平台选择手动实现弹窗而非使用系统弹窗组件,主要是为了获得完全的样式控制权(如顶部圆角、内边距、颜色方案等)。
十八、功能模块多维度对比表
18.1 七大 Tab 功能对比
| 维度 | 行程 Tab | 地图 Tab | 搜索 Tab | 提醒 Tab | 铃音 Tab | 字幕 Tab | 我的 Tab |
|---|---|---|---|---|---|---|---|
| 核心功能 | 行程列表管理 | 地图交互与POI | POI文本搜索 | 通知提醒管理 | 自定义铃声生成 | AI字幕设置 | 用户足迹展示 |
| 对应 Kit | 无(基础UI) | Map Kit | Map Kit | Notification Kit | Notification Kit | Speech Kit | 无(基础UI) |
| 6.1.1 新特性 | --- | 长按监听双回调 | searchByText reliability | --- | EL1沙箱自定义铃声 | sourceLanguage/targetLanguage/fontSize/fontColor | --- |
| 数据模型 | TripItem | PoiItem | PoiItem + reliability | RemindItem | RingItem | --- | FootRow |
| 状态变量数 | 1(tripDetailIdx) | 6(markerListenOn, poiListenOn, showMapDetail, selectedPOI, mapReady, mapErr) | 5(searchInput, searchResult, searchMsg, currentTab, searchActive) | 3(granted, remindList, noticeLogs) | 2(ringList, currentRing) | 6(captionShown, captionReady, captionErrMsg, srcLang, tgtLang, captionSize, captionColor) | 0(纯展示) |
| 列表项数 | 7 条行程 | N/A | 8 条结果 | 6 条提醒 | 6 条铃声 | 5 个场景 | 6 个国家 |
| 主色 | 霓虹青 + 落日橙 | 霓虹青 | 霓虹青 + 通过绿/落日橙/灰 | 通过绿/落日橙 | 霓虹青 + 通过绿 | 霓虹青 + 星紫 | 霓虹青 + 四强调色 |
| 交互方式 | 卡片点击弹窗 | 长按Marker/POI | 输入 + 列表点击 | Toggle开关 + 按钮 | 生成/设默认按钮 | 选项点击 + 按钮 | 纯展示 |
| 跨Tab关联 | →地图Tab | ←行程Tab →搜索Tab | ←地图Tab | →铃音Tab | ←提醒Tab | --- | --- |
18.2 三大 Kit 技术特性对比
| 对比维度 | Map Kit | Speech Kit | Notification Kit |
|---|---|---|---|
| 核心能力 | 地图渲染、POI搜索、Marker管理 | AI语音识别与字幕生成 | 通知发布与管理 |
| 6.1.1 新增特性 | searchByText reliability 字段 Marker/POI 双长按监听 | AI字幕四新字段 (sourceLanguage/targetLanguage/fontSize/fontColor) | EL1沙箱自定义铃声 (sound uri:: 前缀) |
| 关键API | map.searchByText() onMarkerLongPress onPoiLongPress | AICaptionComponent AICaptionController AICaptionOptions | notificationManager.publish() saveRingToSandbox fileUri.getUriFromPath |
| 数据流向 | 输入关键词 → 搜索 → POI列表 → 地图标记 | 音频流 → 字幕引擎 → 文本显示 | 生成WAV → 写入EL1 → 发布通知 → 系统播放 |
| 回调机制 | 搜索回调 + 长按回调 | onPrepared + onError | Promise then/catch |
| 权限要求 | 粗略定位权限(可选) | 麦克风权限(真实场景) | 通知授权 |
| 错误处理 | try-catch + 错误消息展示 | onError 回调 + 红色提示 | catch 分支 + 状态文字 |
| 状态变量数 | 6 | 7 | 4 |
| UI 组件数 | 1(地图组件) | 1(AICaptionComponent) | 0(纯系统通知) |
| Mock 数据 | 8 条 POI | 5 个场景 | 6 条提醒 + 6 条铃声 |
| 代码行数占比 | ~20% | ~25% | ~20% |
18.3 设计语言一致性分析
整个应用在 7 个 Tab 中保持了高度一致的设计语言,体现在以下方面:
色彩体系一致性:所有 Tab 共享同一套 COLORS 常量,主色(霓虹青)、辅助色(落日橙、星紫、通过绿)、文字色(冷白、雾蓝灰、暗蓝灰)、背景色(夜航深蓝、深海军蓝、深色容器)在每个 Tab 中统一使用。这种一致性确保用户在不同 Tab 间切换时不会感到视觉割裂。
卡片样式一致性:所有内容区块都采用"深海军蓝底 + 12px 圆角 + 12px 内边距"的卡片样式。这种统一的卡片语言使用户能够快速识别"这是一个独立的内容模块",降低认知负担。
文字层级一致性:标题使用 13-16 号粗体冷白色,副标题使用 11-12 号雾蓝灰色,辅助文字使用 9-10 号暗蓝灰色。三级文字层级在所有 Tab 中保持一致,形成清晰的信息层次。
胶囊标签一致性:状态标签、分类标签等都采用"文字 + 深色容器底 + 10px 圆角"的胶囊样式,颜色根据语义选择(通过绿/落日橙/霓虹青/星紫)。这种标签语言在行程签证标签、铃声沙箱标签、字幕语言标签、地图 reliability 标签中统一使用。
按钮样式一致性:主要操作按钮使用霓虹青底 + 深色字 + 10-12px 圆角,次要操作按钮使用深色容器底 + 雾蓝灰字 + 10-12px 圆角。主/次按钮的视觉区分在所有 Tab 中保持一致。
十九、总结与展望
19.1 架构设计亮点
跨境行程应用在架构设计上展现了多项值得借鉴的实践:
单一组件集中式架构 :整个应用封装在一个 @Component 组件中,所有状态、方法、Builder 集中管理。对于中型规模的演示应用(约 1700 行代码),这种架构简化了状态共享和跨 Tab 数据传递------所有 Tab 共享同一个组件实例的状态变量,无需复杂的状态管理方案。
@Builder 模块化拆分 :虽然是单一组件,但通过 @Builder 装饰器将 UI 构建逻辑拆分为独立方法(headerMain、tabTrip、tabMap、tabSearch、tabRemind、tabRing、tabCaption、tabMine、tabBar、tripDetailDialog、mapDetailDialog),每个 Builder 负责一个独立的 UI 区块。这种"单组件 + 多 Builder"的模式,在保持状态共享便利性的同时,实现了 UI 逻辑的模块化组织。
@Observed 细粒度响应式 :数据模型类(TripItem、PoiItem、RemindItem、RingItem、NoticeLog)都使用 @Observed 装饰,使得对象属性的变更能够被 ArkUI 精确追踪。配合 @State 状态变量,实现了"修改即刷新"的响应式开发体验------开发者只需修改数据,UI 自动同步更新。
常量驱动的数据配置:Tab 定义、颜色常量、Mock 数据、选项列表等都通过顶层常量定义,与组件逻辑分离。这种"数据驱动"的设计使得修改配置(如增加 Tab、调整颜色、增减列表项)时无需改动业务逻辑代码,降低了维护成本。
19.2 三大 Kit 集成经验
Map Kit 集成要点:
searchByText的reliability字段为搜索结果质量评估提供了量化依据,建议在 UI 中可视化展示(如进度条、颜色分级),帮助用户判断结果可信度- Marker 和 POI 的长按监听是两个独立回调(
onMarkerLongPress/onPoiLongPress),需要分别设置监听开关。建议在 UI 中同时展示两个开关的状态,使用户明确当前监听的是哪种元素 - 地图组件的初始化是异步的,需要处理
onMapReady回调和错误场景,避免在地图未就绪时调用搜索方法导致异常
Speech Kit 集成要点:
- AI 字幕的四个新字段(sourceLanguage、targetLanguage、fontSize、fontColor)极大增强了字幕的可定制性。建议提供"场景预设"功能,将技术参数包装成用户易懂的场景概念,降低使用门槛
- 源语言和目标语言之间存在联动关系(如中文源无需翻译),需要处理边界情况,避免无意义的语言组合
- 音频流写入是字幕功能的关键入口,真实场景中需要配合麦克风采集使用。演示场景可使用合成音频验证调用链
Notification Kit 集成要点:
- EL1 沙箱自定义铃声的完整链路为"生成 WAV → 写入 EL1 filesDir → fileUri 转 URI → sound 填 uri::前缀 → publish 发布",每一步都需要确保正确
appCtx.area = contextConstant.AreaMode.EL1是关键设置------铃声文件必须在 EL1 区域,否则系统通知服务无法读取- 通知授权需要处理"首次申请 + 拒绝后二次引导"的完整链路,避免用户误拒绝后功能完全不可用
- 通知发布建议使用
SlotType.SOCIAL_COMMUNICATION等高优先级渠道,确保铃声和振动效果正常触发
19.3 后续优化方向
组件拆分优化 :当前单一组件的架构在代码量增长到 2000 行以上时,维护成本会显著上升。建议将每个 Tab 拆分为独立的子组件(如 TripTab、MapTab、SearchTab 等),通过 @Link、@Provide/@Consume 或 AppStorage 实现跨组件状态共享。每个子组件负责自身的 UI 构建和业务逻辑,主组件仅负责 Tab 切换和全局状态管理。
持久化存储 :当前所有数据(行程列表、提醒设置、铃声状态等)都存储在内存中,应用重启后丢失。建议引入 Preferences 或关系型数据库,实现用户设置和数据的持久化存储。特别是铃声的沙箱文件,虽然已写入 EL1,但铃声的"已生成"状态(inSandbox)需要持久化记录,避免每次启动都要重新生成。
真实地图数据:当前的 POI 搜索结果使用 Mock 数据模拟。在实际应用中,应接入真实的地图服务 API(如华为地图服务的搜索接口),获取真实的 POI 数据。同时需要处理网络请求的加载状态、分页加载、错误重试等场景。
多语言国际化 :应用面向跨境旅行场景,天然需要多语言支持。建议接入 HarmonyOS 的国际化框架(@ohos.i18n + Resource 资源文件),实现中英文等多语言的自动切换。当前代码中的硬编码中文字符串应抽取为资源引用。
性能优化:
- 地图 Tab 的地图组件是重量级组件,频繁的 Tab 切换可能导致性能问题。建议使用
Tabs的缓存机制或懒加载策略,减少地图组件的重复创建 - 长列表(如行程列表、搜索结果)建议使用
List组件替代Column + ForEach,利用 List 的虚拟化能力提升长列表性能 - 铃声生成是 CPU 密集型操作,建议移到 Worker 线程执行,避免阻塞 UI 线程
无障碍适配:建议添加无障碍支持,包括:
- 为所有可交互元素添加
accessibilityLabel和accessibilityHint - 确保颜色对比度符合无障碍标准(当前深色主题的对比度已较高,但需验证文字色与背景色的对比度是否达到 4.5:1)
- 支持字体大小动态调整
19.4 写在最后
跨境行程应用以出境旅游服务为场景,深度整合了 Map Kit(searchByText reliability + Marker/POI 双长按监听)、Speech Kit(AI 字幕四新字段)、Notification Kit(EL1 沙箱自定义铃声)三大 Kit 的 6.1.1 版本新特性,展示了 HarmonyOS 声明式 UI 开发的完整实践。
应用采用夜航深蓝 + 霓虹青 + 落日橙的深色主题配色,营造出"夜间航班"般的科技感与旅行氛围。7 个 Tab 覆盖了行程规划、地图导航、POI 搜索、行程提醒、自定义铃声、AI 字幕、个人中心等完整的旅行服务链路,每个 Tab 都有明确的功能定位和独立的交互设计。
通过对本应用的深度剖析,我们不仅可以学习到 ArkUI 声明式开发的最佳实践(@Builder 模块化、@Observed 响应式、常量驱动设计),更能掌握三大 Kit 新特性的集成方法和注意事项。希望这份深度解析能够为你的 HarmonyOS 开发之旅提供有价值的参考。
全文完
+# 附录:DevEco Studio 创建新项目与查看 SDK 版本
本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。
一、创建新项目
1.1 进入欢迎界面
启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 "项目",右侧提供三个主要入口:
- 新建项目:从头创建新项目
- 打开项目:打开本地已有项目
- 克隆仓库:从 Git 等版本控制拉取代码
点击 "新建项目" 按钮,进入项目创建向导。

1.2 选择项目模板
在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:
| 类型 | 说明 |
|---|---|
| 应用(Application) | 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期 |
| 元服务(Atomic Service) | 开发轻量级的原子化服务,无需安装即可使用 |
选择 "应用" 标签后,右侧展示多种模板。对于大多数场景,推荐选择 "Empty Ability" ------ 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

1.3 配置项目信息
点击 "下一步" 后,进入项目配置界面,需要填写以下核心参数:
| 配置项 | 示例值 | 说明 |
|---|---|---|
| 项目名称(Project name) | rollboat |
应用的项目名称,建议使用英文命名 |
| 包名(Bundle name) | com.rollboat.myapplication |
应用唯一标识,采用反向域名格式 |
| 保存路径(Save location) | D:\CodeFactory\rollboat |
项目本地存储路径,避免使用中文和空格 |
| 兼容 SDK(Compatible SDK) | 6.1.1(24) |
目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异 |
| 模块名称(Module name) | entry |
主模块名称,默认 entry 为应用入口模块 |
| 设备类型(Device types) | ☑ Phone | 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV |
右侧预览区会实时展示当前模板的默认效果 ------ 一个居中显示的 "Hello World" 文本。

1.4 完成创建
确认配置无误后,点击右下角 "完成" 按钮,IDE 将自动执行以下操作:
- 生成项目骨架(Stage 模型目录结构)
- 执行
ohpm install安装依赖 - 运行 Hvigor 构建初始化(
Build Init)
构建日志中显示 "退出代码为 0" 表示项目初始化成功。

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

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

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

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