拆解 HarmonyOS ArkUI 三Kit融合旅游服务:夜航深蓝与霓虹青的跨境旅行向导
一、技术前言
在跨境旅游服务领域,一款合格的旅行向导应用需要同时具备三大核心能力:精准的目的地地图导航、实时的多语种语音字幕翻译、以及可靠的本机行程提醒推送。从维多利亚港的夜景导览到旺角街市的问路翻译,从值机提醒的铃声定制到港珠澳大桥口岸的集合通知,每一项旅行场景都要求"地图定位-语音翻译-通知推送"三者协同运作。然而传统旅行类应用常常面临三大瓶颈:地图交互仅限于查看而无法监听长按事件、语音字幕仅支持单一语言而无翻译方向切换、通知铃声千篇一律无法自定义个性化音频。
HarmonyOS ArkUI 框架以其声明式 UI 范式为这些瓶颈提供了系统级的解决方案。ArkUI 基于 TypeScript 扩展的 ArkTS 语言,通过 @Component 装饰器封装可复用组件,通过 @State、@Observed 等状态管理装饰器实现数据驱动渲染,通过 @Builder 方法将复杂的 UI 结构拆分为可组合的构建块。这种架构天然适合旅行场景中"地图-语音-通知"紧耦合的需求。ArkUI 的 ForEach 列表渲染配合 @Observed 可观察类,能够在数据变更时自动驱动视图刷新,无需手动调用 setState 等命令式接口。
本旅行向导深度融合了 HarmonyOS 的三大前沿 Kit。Map Kit 提供了 searchByText 关键字搜索能力链——通过 query、location、radius、language 四参数构造 SearchByTextParams,返回 site.Site[] 数组并读取每条结果的 reliability 相关性分数;同时 MapEventManager 的 onMarkerLongClick 和 onPoiLongClick 双长按监听,让用户长按地图标注点或兴趣点时自动记录坐标与时间日志。Speech Kit 提供了 AICaptionComponent AI 字幕组件——通过 AICaptionOptions 的 sourceLanguage(源语言)、targetLanguage(目标语言)、fontSize(字号枚举)、fontColor(字体颜色)四项新字段,实现跨境讲解场景下英文源转中英双语字幕、中文字号四档切换、五种颜色预设的个性化配置;配合 AICaptionController.writeAudio 写入 PCM 音频块驱动字幕实时滚动。Notification Kit 实现了 EL1 沙箱自定义铃声链路——通过 buildWavBytes 生成正弦波 PCM 音频字节,写入 EL1 沙箱 filesDir 目录,再以 'uri::' + fileUri.getUriFromPath(沙箱路径) 拼接填入 NotificationRequest.sound 字段,让登机叮咚、口岸钟声等六种铃声在行程提醒通知中个性化播放。
二、整体架构流程图
整体架构以 Page1220 为根组件,采用 Stack 容器实现页面层叠:底层是 Column 纵向布局的头部横幅 + 内容区 + 底部 Tab 栏,顶层是全屏弹窗遮罩系统。内容区通过 currentTab 状态索引在 7 个 @Builder 方法间切换,每个 Tab 拥有完全独立的布局结构。值得注意的是,地图 Tab 因 MapComponent 需要有界高度,单独走 layoutWeight(1) 路径不进入 Scroll 容器,其余 6 个 Tab 统一进入主滚动容器。三大 Kit 的状态变量统一声明在组件顶层,实现跨 Tab 数据共享——例如 currentRing 在铃音 Tab 中设为默认,在提醒 Tab 中被发布通知时读取,体现了"状态集中管理、视图分散渲染"的设计理念。
三、色彩体系:ColorPalette 与 COLORS 常量
3.1 ColorPalette 接口定义
本旅行向导采用深色"夜航深蓝"主题,通过 ColorPalette 接口集中声明全部颜色字段,确保全文件颜色取值统一可控、无散落硬编码:
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; // 弹窗遮罩
}
该接口定义了 15 个颜色字段,覆盖了页面背景、卡片底色、文本三级层次(主标题冷白、副标题雾蓝灰、三级弱文本暗蓝灰)、主色霓虹青及其深色变体、辅助暖色落日橙、以及签证语义色(星紫对应电子签、通过绿对应免签、警示红对应删除操作)等全部视觉场景需求。
3.2 COLORS 常量逐色分析
const COLORS: ColorPalette = {
bg: '#0D1522', // 夜航深蓝,模拟夜间航行与暗光旅行场景
card: '#16233A', // 深海军蓝卡片底色,比背景略亮一档
dark: '#1D2E4A', // 次级容器底色(统计格/进度条底/状态胶囊底)
title: '#E8F0FA', // 冷白主标题,高对比度保证暗光可读
sub: '#9DB4D0', // 雾蓝灰副标题,层次柔和过渡
text3: '#67819E', // 暗蓝灰弱文本,辅助信息不抢视觉
cyan: '#38C8D8', // 霓虹青主色,行程进度的视觉锚点
cyanD: '#2298A8', // 霓虹青深色,渐变终点与选中态深色底
orange: '#FF8A4C', // 落日橙辅助暖色,POI标识与未授权状态
purple: '#8A7FE8', // 星紫,电子签徽标与字幕目标语言选中
green: '#4EC98A', // 通过绿,免签徽标与沙箱就绪状态
red: '#E86060', // 警示红,删除确认与字幕服务异常
line: '#223450', // 分割线,低对比度不干扰内容
tabOn: '#38C8D8', // Tab选中色与主色一致
mask: 'rgba(0,0,0,0.6)' // 半透黑弹窗遮罩
};
色彩设计遵循"夜航指引"原则:霓虹青、落日橙、星紫、通过绿、警示红五色分别对应"主功能/POI搜索/电子签翻译/免签通过/危险操作"五种语义状态。用户在深色环境下凭颜色即可快速识别信息类型——看到绿色徽标即知免签可说走就走,看到紫色即知需提前办电子签,看到橙色即知落地签或 POI 搜索结果。头部横幅使用 dark 到 card 的线性渐变,底部 Tab 栏选中态使用 tabOn(与霓虹青主色一致)高亮,未选中态使用 text3 暗蓝灰弱化处理。
四、Tab 元数据与辅助数据
4.1 底部导航 Tab 定义
底部导航采用单排 7 项布局,通过 TabMeta 接口定义图标与标签,再用 TAB_LIST 常量数组集中配置:
interface TabMeta {
icon: string;
label: string;
}
const TAB_LIST: TabMeta[] = [
{ icon: '🧭', label: '行程' },
{ icon: '🗺️', label: '地图' },
{ icon: '🔍', label: '搜索' },
{ icon: '⏰', label: '提醒' },
{ icon: '🎵', label: '铃音' },
{ icon: '🗣', label: '字幕' },
{ icon: '👤', label: '我的' }
];
七个 Tab 从行程规划到个人主页,覆盖了跨境旅行的完整闭环:行程 Tab 管理目的地与签证状态,地图 Tab 提供地标长按探索,搜索 Tab 按 POI 相关性查找景点,提醒 Tab 推送行程时间轴通知,铃音 Tab 管理沙箱自定义铃声,字幕 Tab 提供多语 AI 字幕,我的 Tab 展示旅行家主页与足迹。
4.2 Tab 联动副标题与城市中心点
const TAB_SUBS: string[] = [
'跨境行程总览与签证速览',
'香港地标长按探索',
'景点 POI 相关性搜索',
'行程提醒与本地通知',
'沙箱自定义通知铃声',
'AI 字幕多语讲解',
'旅行家主页与足迹'
];
const CITY_CENTER: mapCommon.LatLng = { latitude: 22.3193, longitude: 114.1694 };
TAB_SUBS 与 TAB_LIST 一一对应,头部区域根据 currentTab 索引动态显示副标题文案,实现 Tab 切换时头部信息的联动更新。CITY_CENTER 定义为香港中环坐标(纬度 22.3193、经度 114.1694),作为跨境游枢纽的地图初始视野中心点与 POI 搜索基准位置。
4.3 地图标注点与搜索快捷关键字
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: '口岸' }
];
const QUICK_QUERIES: string[] = ['景点', '餐厅', '地铁站', '酒店', '口岸', '博物馆'];
六个标注点覆盖了夜景、观景、海滨、市集、乐园、口岸六类跨境游客常用地标,在地图初始化回调中通过 addMarker 批量打点。搜索快捷关键字选取了跨境游客高频 POI 类型,点击即触发 searchByText 搜索。
4.4 字幕语言与字号选项
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: '中英双语' }
];
const SIZE_OPTIONS: SizeOption[] = [
{ size: AICaptionFontSize.SMALL, name: '小号' },
{ size: AICaptionFontSize.NORMAL, name: '标准' },
{ size: AICaptionFontSize.BIG, name: '大号' },
{ size: AICaptionFontSize.LARGE, name: '超大' }
];
const CAPTION_FONT_COLORS: string[] = ['#FFFFFF', '#7CE8F5', '#FFD9A8', '#C9F2D9', '#FFC2CE'];
源语言支持中文与英文两种选项。当源语言为英文时,目标语言可选中文、英文或中英双语三档;当源语言为中文时,目标语言锁定为 zh(无翻译方向)。字号采用 AICaptionFontSize 枚举四档(SMALL/NORMAL/BIG/LARGE),字体颜色预设五种色值,覆盖冷白、霓虹青、落日暖、薄荷绿、樱粉红五种风格。
4.5 月度出行数据与足迹国家
const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const MONTH_DAYS: number[] = [3, 5, 2, 6, 4, 8];
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月' }
];
月度出行天数为柱状图提供数据源,呼吸动画联动柱体高度波动。足迹国家清单展示了六个国家/地区的国旗、解锁城市数与最近到访月份。
五、工具函数
本组件在 ④ 辅助函数 区域定义了五个工具函数,分别服务于搜索相关性映射、长按事件配色、签证类型配色、时间格式化和 WAV 音频字节生成。
5.1 搜索相关性等级映射
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 };
}
该函数将 searchByText 返回的 reliability 分数(取值范围 [0,1])映射为三档标签与颜色:0.8 以上为高相关配通过绿,0.5 以上为中相关配落日橙,其余为低相关配暗蓝灰弱文本色。搜索结果列表中每条记录的等级标签和分数条颜色均由该函数动态返回。
5.2 长按事件类型与签证类型配色
function typeColor(type: string): string {
if (type === 'Marker') { return COLORS.cyan; }
if (type === 'POI') { return COLORS.orange; }
return COLORS.text3;
}
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;
}
typeColor 将长按事件类型映射为徽标色:Marker 标注点配霓虹青,POI 兴趣点配落日橙。visaColor 将签证类型映射为四色徽标:免签配通过绿(说走就走),落地签配霓虹青(抵达办理),电子签配星紫(提前在线申请),需面签配落日橙(须到领事馆)。两函数均通过字符串前缀匹配实现灵活适配。
5.3 时间格式化与 WAV 字节生成
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 格式字符串,用于长按事件日志与通知历史的时间戳。
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);
// ... WAV 头部写入与 PCM 采样数据生成
return buf;
}
buildWavBytes 是整个铃音系统的核心——它生成符合 WAV 标准格式的音频字节流:44 字节 WAV 头部(RIFF/WAVE/fmt/data 四段式)加 16bit 单声道 PCM 采样数据。采样率为 44100Hz,每个采样点用 setInt16 写入 16 位有符号整数。音频内容为指定频率的正弦波,配合起音包络(前 20ms 渐入)和自然衰减(随时长线性衰减),使铃声听感不刺耳。该函数返回的 ArrayBuffer 将被写入 EL1 沙箱文件目录,作为通知自定义铃声的音频源。
六、数据模型层:@Observed 可观察类
本组件定义了七个 @Observed 可观察类,分别对应七类业务实体。@Observed 装饰器使类的实例属性变更能被 ArkUI 框架自动追踪,当在 @State 数组中修改元素属性时,引用该属性的 UI 组件会自动刷新,无需手动触发重渲染。
6.1 行程条目 TripItem
@Observed export class TripItem {
city: string; // 目的地城市
days: number; // 行程天数
visa: string; // 签证类型
plan: string; // 行程概要
constructor(city: string, days: number, visa: string, plan: string) { ... }
}
TripItem 是业务主 Tab(行程)的核心实体,绑定新增、编辑、删除三个弹窗。七个 Mock 行程覆盖了免签备案(香港)、电子签(东京/大阪)、落地签(曼谷)、免签(新加坡/首尔)、需面签(巴黎)四类签证场景。
6.2 搜索结果 SearchRecord
@Observed export class SearchRecord {
name: string; // 地点名称
address: string; // 格式化地址
distance: number; // 直线距离(米)
reliability: number; // 相关性分数 [0,1]
}
SearchRecord 封装 POI 搜索结果,六条 Mock 数据覆盖了高相关(0.94/0.88)、中相关(0.71/0.62)、低相关(0.45/0.22)三档,配合 reliabilityScore 函数展示等级标签与分数条。
6.3 事件日志 EventLog 与提醒 RemindItem
@Observed export class EventLog {
type: string; // Marker / POI
name: string; // 标记 id 或 POI 名称
lat: number; // 纬度
lng: number; // 经度
time: string; // 触发时刻
}
@Observed export class RemindItem {
time: string; // 提醒时刻
title: string; // 提醒标题
repeat: string; // 重复规则
on: boolean; // 开关状态
}
EventLog 记录地图长按事件的类型、名称、坐标与时间,通过 unshift 置顶并最多保留 12 条。RemindItem 封装行程提醒条目,六条提醒覆盖了值机、酒店入住、集合出发、景点预约、跨境巴士、夜间导览六类旅行场景,每条均可通过 Toggle 独立开关控制。
6.4 铃声 RingItem 与字幕场景 CaptionScene
@Observed export class RingItem {
name: string; // 铃声名
file: string; // 沙箱文件名
freq: number; // 生成频率 Hz
duration: number; // 时长 ms
size: string; // 文件大小展示
inSandbox: boolean; // 是否已写入沙箱
}
@Observed export class CaptionScene {
scene: string; // 场景名
desc: string; // 场景说明
src: string; // 推荐源语言
tgt: string; // 推荐目标语言
}
RingItem 封装铃声库条目,六种铃声(登机叮咚、口岸钟声、巴士到站、行李转盘、集合哨声、夜航星光)各有不同频率与时长。inSandbox 标记是否已生成 WAV 并写入沙箱。CaptionScene 封装字幕场景推荐,五个场景(双语导览讲解、外语点餐对话、问路应急翻译、机场广播听译、中文讲解复盘)各自预设了源语言与目标语言组合,点击即一键套用。
6.5 通知历史 NoticeLog
@Observed export class NoticeLog {
title: string; // 通知标题
text: string; // 通知正文
time: string; // 发布时刻
}
NoticeLog 记录已发布的行程通知历史,通过 unshift 置顶并最多保留 8 条,在提醒 Tab 底部以倒序流形式展示。
七、组件主体:@State 状态与生命周期
7.1 状态变量声明
Page1220 组件在顶层声明了五大类状态变量,实现跨 Tab 数据共享:
@Entry
@Component
struct Page1220 {
@State currentTab: number = 0; // Tab 索引
@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; // 呼吸动画
@State tripList: TripItem[] = TRIP_LIST; // 行程数据
@State remindList: RemindItem[] = REMIND_LIST;// 提醒数据
@State ringList: RingItem[] = RING_LIST; // 铃声数据
@State noticeLogs: NoticeLog[] = NOTICE_SEED; // 通知历史
}
弹窗状态采用三个布尔值独立控制(addModal/editModal/delModal),配合 editIdx/delIdx 索引定位操作目标。表单缓存四个字段(formCity/formDays/formVisa/formPlan)在弹窗打开时初始化、确认时提交。breath 呼吸布尔值由定时器每秒翻转,联动柱状图柱体高度波动和头部圆点透明度闪烁。
Map Kit 状态包括地图选项 mapOptions、控制器 mapController、事件管理器 mapEventManager、长按事件日志 eventLogs、双长按监听开关 markerListenOn/poiListenOn、搜索关键字 queryInput、搜索结果 searchRecords 和搜索状态文案 searchState。Speech Kit 状态包括字幕控制器 captionController、显示状态 captionShown(@Link 双向绑定)、源语言 srcLang、目标语言 tgtLang、字号 captionSize、颜色 captionColor、就绪状态 captionReady、错误信息 captionErrMsg 和音频写入计数 captionFed。Notification 状态包括授权状态 granted、通知 ID 自增基数 notifyId、当前铃声 currentRing 和发布结果反馈 noticeState。
7.2 生命周期方法
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 在组件即将出现时执行三步初始化:装配地图初始化回调(setupMapCallback)、查询通知授权状态(isNotificationEnabled)、启动呼吸动画定时器(每秒翻转 breath)。aboutToDisappear 在组件即将消失时清除定时器,防止内存泄漏。这种"初始化-运行-清理"的生命周期管理是 ArkUI 声明式组件的标准范式。
7.3 build 根构建
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() }
// ... 其余 Tab 分支
}.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%')
}
根构建采用 Stack 层叠容器:底层 Column 纵向排列头部、分割线、内容区和底部 Tab 栏,顶层叠加三个弹窗。内容区的关键设计在于——地图 Tab(索引 1)因 MapComponent 需要有界高度,单独走 this.tabMap() 路径不进入 Scroll;其余 Tab 统一进入 Scroll 容器并设置 EdgeEffect.Spring 弹性边缘效果。弹窗通过条件渲染 if (this.xxxModal) 控制显隐,每个弹窗接收一个 onClose 回调用于关闭时重置状态。
八、头部区域
@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 水平三段式布局:左侧 Column 显示应用名"旅语通"与 Tab 联动副标题(根据 currentTab 索引从 TAB_SUBS 数组动态取值),右侧三枚状态胶囊纵向堆叠,最右侧呼吸圆点。三枚胶囊分别反映三大 Kit 的实时状态:地图长按监听开关(On/Off 配霓虹青/暗蓝灰)、字幕语言流向(srcLang→tgtLang 配星紫色)、通知授权状态(已授权/未授权配通过绿/落日橙)。呼吸圆点通过 breath 布尔值联动 opacity 在 1 与 0.25 之间切换,实现每秒一次的呼吸闪烁效果,作为应用活跃状态的视觉指示器。
九、Tab0 行程分析
行程 Tab 是业务主 Tab,采用"横滑大卡 + 清单行 + 柱状图"三层布局结构。
第一层是目的地横滑大卡区域。使用 Scroll 横向滚动容器包裹 Row,通过 ForEach 遍历 tripList 渲染每个 TripItem 为 170 宽度的渐变大卡。卡内包含城市名与天数徽标(霓虹青底深蓝字)、行程概要文本(雾蓝灰两行省略)、签证类型色点与文字(由 visaColor 函数动态配色)。大卡使用 linearGradient 从 dark 到 card 的 135 度对角渐变,营造深海军蓝的层次感。
第二层是行程清单行区域。使用 Column 纵向排列,每行同样遍历 tripList,展示图标、城市名、签证徽标、天数、行程概要,以及编辑(✏️)和删除(🗑)两个操作入口。点击编辑调用 openEdit(idx) 回填表单并打开编辑弹窗,点击删除调用 openDel(idx) 打开删除确认弹窗。
第三层是新增行程入口与月度柱状图。新增入口为全宽霓虹青按钮,点击调用 openAdd() 清空表单并打开新增弹窗。柱状图调用 chartCard() Builder 方法(详见第十六章)。
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,视觉醒目引导用户添加行程。
十、Tab1 地图分析
地图 Tab 是三大 Kit 中 Map Kit 的核心承载区,布局分为三层:监听开关行、MapComponent 本体、长按事件日志流。
10.1 双长按监听开关
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)
}
两个 Toggle 开关分别控制 Marker 长按和 POI 长按监听的启用与禁用。Marker 开关选中色为霓虹青,POI 开关选中色为落日橙,与 typeColor 函数的配色逻辑一致。切换时调用 toggleMarkerListen() 或 togglePoiListen(),其逻辑为:若当前已开启则调用 offMarkerLongClick()(不传参即清除该类型全部订阅),若当前已关闭则重新调用 bindMarkerLongClick() 注册监听。
10.2 MapComponent 与长按监听注册
MapComponent({ mapOptions: this.mapOptions, mapCallback: this.mapCallback })
.layoutWeight(1).width('100%').borderRadius(12)
MapComponent 接收 mapOptions(含 position 目标坐标与 zoom 缩放级别)和 mapCallback(异步初始化回调)。回调内依次执行:错误判空、获取 mapController、获取 mapEventManager、循环 addMarker 批量添加六处地标标注点、注册 Marker 长按监听、注册 POI 长按监听。
Marker 长按监听通过 mapEventManager.onMarkerLongClick 注册,回调参数类型为 map.Marker,可读取 getId() 和 getPosition() 获取标注点 ID 与经纬度坐标。POI 长按监听通过 mapEventManager.onPoiLongClick 注册,回调参数类型为 mapCommon.Poi,可读取 name 和 position 获取兴趣点名称与坐标。两者的触发结果均通过 unshift 置顶写入 eventLogs 数组并限制最多 12 条。
10.3 长按事件日志流
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)
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)
日志流容器固定高度 172,内含可滚动 Scroll 展示事件列表。每条日志行包含四个元素:类型徽标(Marker 霓虹青/POI 落日橙,由 typeColor 函数配色)、名称文本(雾蓝灰单行省略)、坐标文本(暗蓝灰等宽字体四位小数)、时间文本(暗蓝灰)。底部提示"off 不传参=清除该类型全部订阅"以等宽字体展示,作为 API 使用备忘。
十一、Tab2 搜索分析
搜索 Tab 是 Map Kit 的 searchByText 能力承载区,布局分为搜索框行、快捷关键字芯片行、搜索状态文案、结果列表四层。
11.1 搜索框与快捷关键字
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(); })
}
搜索框与按钮水平排列,输入框绑定 queryInput 状态,onChange 实时更新关键字。点击搜索按钮调用 runSearch() 异步方法。
runSearch 方法构造 SearchByTextParams(含 query 查询关键字、location 中心坐标、radius 搜索半径 5000 米、language 返回语言中文),调用 site.searchByText(params) 获取结果。成功时将 site.Site[] 映射为 SearchRecord[] 更新到 searchRecords;无结果时保留当前推荐数据并提示;失败时(无 AGC 配置或无网络)捕获异常并提示错误码,保留 Mock 数据体现调用链完整性。
快捷关键字芯片行通过 ForEach 遍历 QUICK_QUERIES 数组渲染六个芯片,当前选中项配霓虹青底深蓝字,未选中项配暗蓝灰底雾蓝灰字。点击芯片同时更新 queryInput 并立即触发 runSearch()。
11.2 搜索结果列表与 reliability 分数条
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%')
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}`)
}
每条搜索结果卡片包含三行信息:第一行为地点名称(冷白粗体单行省略)与相关性等级标签(由 reliabilityScore 返回的高/中/低标签配通过绿/落日橙/暗蓝灰);第二行为格式化地址(雾蓝灰单行省略);第三行为距离文本、reliability 线性进度条和分数值。进度条使用 Progress 组件的 Linear 类型,value 为 reliability * 100(将 0-1 映射到 0-100),color 与等级标签同色,实现"分数越高条越满、颜色越偏绿"的直观视觉反馈。
十二、Tab3 提醒分析
提醒 Tab 是 Notification Kit 的核心承载区,布局分为通知授权状态卡、行程提醒时间轴、发布行程提醒入口、通知历史四层。
12.1 通知授权状态卡
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%')
}
授权状态卡使用呼吸圆点(联动 breath 透明度)配状态色(已授权通过绿/未授权落日橙),标题文案与圆点同色。未授权时显示"去授权"按钮,调用 requestAuth() 方法——该方法通过 requestEnableNotification(context) 请求系统授权弹框,若用户曾拒绝(返回错误码 1600004)则通过 openNotificationSettings(context) 拉起通知设置页引导手动开启。底部说明文案告知用户铃声来源链路。
12.2 行程提醒时间轴
ForEach(this.remindList, (item: RemindItem, idx: number) => {
Row() {
// 时间列(固定宽度 52)
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%')
// 提醒内容列
Column({ space: 6 }) {
Text(item.title).fontSize(12).fontColor(item.on ? COLORS.title : COLORS.text3)
Text(item.on ? '提醒开启中' : '已暂停')
.fontSize(9).fontColor(item.on ? COLORS.green : COLORS.text3)
}.layoutWeight(1)
// 开关列
Toggle({ type: ToggleType.Switch, isOn: item.on })
.selectedColor(COLORS.cyan).width(40).height(22)
.onChange(() => { this.toggleRemind(idx); })
}
.width('100%').height(72).backgroundColor(COLORS.card).borderRadius(12)
}, ...)
时间轴采用四列布局:时间列(固定宽 52,展示时刻与重复规则)、竖线列(宽 20,圆点 + 竖线填充 72 行高,开启时霓虹青/关闭时暗蓝灰)、内容列(弹性宽度,展示标题与状态文案)、开关列(Toggle 切换提醒启用状态)。每行固定高度 72,竖线通过 layoutWeight(1) 填充行高剩余空间,实现时间轴的连续视觉效果。Toggle 切换调用 toggleRemind(idx) 翻转 item.on 属性,由于 RemindItem 是 @Observed 类,属性变更自动驱动该行视图刷新。
12.3 发布行程提醒与通知历史
发布入口为霓虹青全宽按钮,点击调用 publishNotice(title, text) 发布携带沙箱自定义铃声的通知。该方法首先获取 UIAbilityContext,设置 appCtx.area = EL1 确保在 EL1 沙箱区域操作,拼接沙箱路径并通过 fileUri.getUriFromPath 转为 URI,最终构造 NotificationRequest(含 id 自增、SlotType.SOCIAL_COMMUNICATION、基础文本内容、sound: 'uri::' + uri 自定义铃声字段),调用 notificationManager.publish(request) 发布。成功时更新 noticeState 反馈并将通知写入 noticeLogs 历史流(最多 8 条),失败时捕获错误码与信息。
通知历史区域通过 ForEach 遍历 noticeLogs 展示每条通知的标题、正文与时间,使用暗蓝灰底卡片弱化处理,不抢夺时间轴的视觉焦点。
十三、Tab4 铃音分析
铃音 Tab 是 Notification Kit 沙箱自定义铃声链路的管理区,布局分为当前铃声预览卡和铃声库列表两层。
13.1 当前铃声预览卡
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)
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%')
}
预览卡展示当前默认铃声的名称、频率/时长/大小三元组与沙箱状态徽标(EL1 ✓ 通过绿/未落盘 落日橙)。底部以等宽风格文案展示完整链路:“生成 WAV → 写入 EL1 files → 设默认 → sound 填 uri:: 前缀发布”,让用户一目了然铃声从生成到播放的完整流程。
13.2 铃声库列表
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')
}.layoutWeight(1)
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)
}, ...)
每条铃声行包含选中圆点(当前默认配霓虹青/非默认配分割线色)、铃声信息(名称、沙箱状态、文件名/频率/时长/大小等宽文本)、生成按钮和设默认按钮。点击"生成"调用 genRing(idx),该方法通过 saveRingToSandbox 将 buildWavBytes 生成的 WAV 字节写入 EL1 沙箱 filesDir 目录,成功后更新 ring.inSandbox 为 true 并计算文件大小(KB)刷新到 ring.size。点击"设默认"调用 setCurrentRing(idx) 将该铃声设为 currentRing,后续发布通知时 sound 字段即取该铃声的沙箱 URI。
soundPreview() 方法返回当前铃声的 sound 字段完整值字符串,格式为 sound: 'uri::' + fileUri.getUriFromPath('沙箱路径/铃声文件名'),作为实时预览展示在铃声 Tab 底部。
十四、Tab5 字幕分析
字幕 Tab 是 Speech Kit 的 AICaptionComponent 核心承载区,布局分为 AI 字幕实时预览、语言设置、字号四档、字体颜色五色卡、字幕场景卡五层。
14.1 AI 字幕实时预览
AICaptionComponent({
isShown: this.captionShown,
controller: this.captionController,
options: this.buildCaptionOptions()
})
.width('100%').height(110).borderRadius(10)
AICaptionComponent 接收三个参数:isShown 为 @Link 双向绑定(直接传 @State 引用 this.captionShown),控制字幕显示与隐藏;controller 为 AICaptionController 实例,用于 writeAudio 写入音频流驱动字幕滚动;options 为 AICaptionOptions 对象,由 buildCaptionOptions() 方法组装。
buildCaptionOptions() 方法集中体现 Speech Kit 四项新字段配置:
const opts: AICaptionOptions = {
initialOpacity: 1,
sourceLanguage: this.srcLang, // 源语言('zh' | 'en')
targetLanguage: this.tgtLang, // 目标语言('zh' | 'en' | 'zh-en')
fontSize: this.captionSize, // 字号枚举
fontColor: this.captionColor, // 字体颜色
onPrepared: () => { this.captionReady = true; },
onError: (error: BusinessError) => { this.captionErrMsg = '...'; }
};
onPrepared 回调在字幕服务准备就绪时将 captionReady 置 true,onError 回调在服务异常时捕获错误码与信息写入 captionErrMsg。预览区下方展示两个按钮:开启/隐藏字幕按钮(翻转 captionShown)和写入演示音频按钮(调用 feedAudioStream() 写入 640 字节 PCM 块,16kHz/16bit/单声道约 20ms,正弦波 440Hz 模拟音频输入)。
14.2 语言联动与字号颜色
语言设置区域体现源语言与目标语言的联动逻辑:当源语言切换为中文时,目标语言锁定为 zh(无翻译方向可选,展示"中文(锁定 zh)"静态文案);当源语言切换为英文时,目标语言默认切换为中英双语 zh-en,用户可在中文/英文/中英双语三档间自由选择。该联动由 switchSourceLang(code) 方法实现。
字号四档区域通过 ForEach 遍历 SIZE_OPTIONS 渲染四个选择卡,每卡展示字号预览字母(小/标/大/大A)与名称,选中时配霓虹青色和暗色底加边框。字体颜色五色卡区域通过 ForEach 遍历 CAPTION_FONT_COLORS 渲染五个色块,选中时加霓虹青边框并标注"使用中"。
14.3 字幕场景卡
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)
}.layoutWeight(1)
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); })
}, ...)
五个场景卡覆盖了双语导览讲解、外语点餐对话、问路应急翻译、机场广播听译、中文讲解复盘五类跨境旅行高频场景。每卡展示场景名、说明文案与推荐语言组合徽标,点击调用 applyScene(idx) 一次性套用该场景的 src 与 tgt 到 srcLang 和 tgtLang 状态,实现语言组合的快速切换。场景卡底部展示 onError 兜底错误信息(红色文本),确保字幕服务异常时用户可见反馈。
十五、Tab6 我的分析
我的 Tab 采用"渐变大卡 + 足迹清单 + 版本声明"三层布局。
15.1 旅行家渐变大卡
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)
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)
// ... 解锁城市 38 / 出行天数 28 / 待启程 6
}.width('100%')
}
.width('100%').padding(16).borderRadius(16)
.linearGradient({
angle: 160,
colors: [[COLORS.cyanD, 0], [COLORS.dark, 0.55], [COLORS.card, 1]]
})
大卡使用 160 度三段渐变(霓虹青深色起、暗蓝中段、卡片底终),营造从青到蓝的深色渐变质感。顶部展示旅行家身份与会员信息,右侧呼吸圆点联动活跃状态。下方四格统计分别使用霓虹青、落日橙、星紫、通过绿四色展示足迹国家(12)、解锁城市(38)、出行天数(28)、待启程(6)四项数据,四色与签证语义色体系一脉相承。
15.2 足迹国家清单行
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)
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)
}, ...)
足迹清单展示六个国家/地区的国旗 emoji、名称、解锁城市数与最近到访月份,月份以暗蓝灰底徽标形式展示。底部版本声明行居中展示"旅语通 v6.1.1 · Map Kit + Speech Kit + Notification Kit"与"多语旅行服务 · 深色主题"两行文案,明确标注应用版本与所集成的三大 Kit。
十六、图表卡片:月度出行柱状图
@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)
}
柱状图采用 Column + ForEach 传统方式渲染,每月一根柱子。柱体高度由 MONTH_DAYS 数据值乘以 9 像素系数加基础高度计算,并受 breath 呼吸状态联动——呼吸开启时柱体增高 2 像素(14 + v * 9),关闭时恢复(12 + v * 9),实现每秒一次的柱体波动效果。柱体使用 180 度纵向渐变(霓虹青顶到霓虹青深底),容器固定高度 92 并通过 justifyContent(FlexAlign.End) 将柱子底部对齐。每根柱子上方标注天数、下方标注月份,头部右侧标注合计天数。
十七、底部 Tab 栏
@Builder
tabBar() {
Row() {
ForEach(TAB_LIST, (tab: TabMeta, idx: number) => {
Column({ space: 3 }) {
Text(tab.icon).fontSize(18).opacity(this.currentTab === idx ? 1 : 0.55)
Text(tab.label).fontSize(9)
.fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
.fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
}.layoutWeight(1).padding({ top: 7, bottom: 7 }).onClick(() => {
this.currentTab = idx;
})
}, (tab: TabMeta, idx: number) => `tab-${idx}-${tab.label}`)
}
.width('100%').backgroundColor(COLORS.card)
.border({ width: { top: 1 }, color: COLORS.line })
}
底部 Tab 栏为单排 7 项布局,使用 Row 包裹 ForEach 渲染七个 TabMeta。每项等宽(layoutWeight(1)),包含图标与标签两行。选中态图标全不透明、标签配 tabOn 霓虹青色加粗;未选中态图标 0.55 透明度、标签配 text3 暗蓝灰常规字重。点击切换 currentTab 索引驱动内容区切换。底部使用 border 仅设置顶边分割线,与内容区视觉分隔。
十八、弹框系统
弹窗系统由一个遮罩层 Builder 和三个业务弹窗 Builder 组成,通过 Stack 层叠在主界面上方。
18.1 全屏遮罩层
@Builder
modalOverlay(onClose: () => void) {
Column() {
Column()
.width('100%').height('100%')
.backgroundColor(COLORS.mask)
}
.width('100%').height('100%')
.onClick(() => { onClose(); })
}
遮罩层为全屏半透明黑色 Column(rgba(0,0,0,0.6)),点击任意区域触发 onClose 回调关闭弹窗。该 Builder 接收一个 () => void 类型的回调参数,是所有业务弹窗的公共底层。
18.2 新增行程弹窗
@Builder
panelAdd(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 14 }) {
// 标题行 + 关闭按钮
// 城市输入框
// 天数输入框(InputType.Number)
// 签证类型输入框
// 行程概要输入框
Row({ space: 10 }) {
Button('取消').layoutWeight(1)...
Button('确认新增').layoutWeight(1)
.backgroundColor(COLORS.cyan).fontColor(COLORS.bg)
.onClick(() => { this.doAdd(); })
}
}
.width('86%').padding(18).borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').alignContent(Alignment.Center)
}
新增弹窗包含四个 TextInput 表单字段(城市、天数、签证类型、行程概要),天数输入框设置 InputType.Number 限制数字输入。确认按钮调用 doAdd() 方法,该方法解析表单缓存(天数 parseInt、空值兜底默认值),构造 TripItem 实例通过 unshift 置顶写入 tripList,最后关闭弹窗。
18.3 编辑与删除弹窗
编辑弹窗结构与新增弹窗一致,但在 openEdit(idx) 时回填当前行程字段到表单缓存。确认按钮调用 doEdit() 方法,该方法通过 editIdx 索引定位 tripList 中的 TripItem 实例,就地修改其 city/days/visa/plan 字段。由于 TripItem 是 @Observed 类,属性变更自动驱动行程横滑大卡和清单行的视图刷新。
删除弹窗为精简版,仅展示目标城市确认文案与取消/确认删除两个按钮。确认删除按钮使用警示红底冷白字,调用 doDel() 方法通过 splice(this.delIdx, 1) 从 tripList 中移除目标行程。
三个弹窗均采用 Stack 层叠遮罩与内容卡片,内容卡片宽度 86%(删除弹窗 78%),alignContent(Alignment.Center) 居中显示,borderRadius(16) 圆角配合 COLORS.card 深海军蓝底色。
十九、各 Tab 功能模块对比表
| Tab 索引 | 标签 | 核心布局 | 承载 Kit | 关键状态变量 | 核心 API / 方法 | 数据模型 |
|---|---|---|---|---|---|---|
| 0 | 行程 | 横滑大卡+清单行+柱状图 | 无(纯 UI) | tripList, breath | openAdd/openEdit/openDel/doAdd/doEdit/doDel | TripItem |
| 1 | 地图 | Toggle行+MapComponent+日志流 | Map Kit | eventLogs, markerListenOn, poiListenOn | setupMapCallback/bindMarkerLongClick/bindPoiLongClick/toggleMarkerListen/togglePoiListen | EventLog |
| 2 | 搜索 | 搜索框+芯片+结果列表 | Map Kit | queryInput, searchRecords, searchState | runSearch(site.searchByText) | SearchRecord |
| 3 | 提醒 | 授权卡+时间轴+发布+历史 | Notification Kit | granted, remindList, noticeLogs, noticeState | requestAuth/publishNotice/toggleRemind | RemindItem, NoticeLog |
| 4 | 铃音 | 预览卡+铃声库列表 | Notification Kit | ringList, currentRing, noticeState | genRing/saveRingToSandbox/setCurrentRing/soundPreview | RingItem |
| 5 | 字幕 | 预览+语言+字号+颜色+场景 | Speech Kit | captionShown, srcLang, tgtLang, captionSize, captionColor, captionReady | buildCaptionOptions/switchSourceLang/applyScene/feedAudioStream | CaptionScene |
| 6 | 我的 | 渐变大卡+足迹清单+版本 | 无(纯 UI) | breath | 无 | FootRow |
从对比表可以清晰看出三大 Kit 的分布特征:Map Kit 覆盖地图与搜索两个 Tab(长按监听与关键字搜索),Notification Kit 覆盖提醒与铃音两个 Tab(授权发布与沙箱铃声生成),Speech Kit 独占字幕一个 Tab(AI 字幕四字段配置)。行程与我的两个 Tab 为纯 UI 展示层,不涉及 Kit 调用但共享 breath 呼吸动画状态。所有 Tab 的数据模型均采用 @Observed 可观察类,确保属性变更自动驱动视图刷新。
深化解析:从代码结构到业务闭环
布局方式与数据流
跨境旅行页面把行程、地图、搜索、提醒、铃声和字幕组合为连续服务。用户从行程进入地点探索,再把搜索结果加入计划,由提醒和自定义铃声保证节点执行,字幕解决语言沟通。代码解读要关注目的地、地点结果、通知记录和语言选项如何跨 Tab 共享,并说明深蓝、霓虹青和落日橙的场景语义。
页面根结构通常由头部、内容区和底部 Tab 栏组成。头部负责展示当前业务状态,内容区根据索引选择不同的 @Builder,底部导航负责修改索引。这样的结构把“当前显示什么”收敛为一个明确状态:用户点击 Tab 后先更新索引,ArkUI 再重新计算相关分支。各个 Builder 虽然共享主题色和页面级数据,却可以采用完全不同的布局方式;高密度列表适合纵向 Scroll,概览数据适合横向统计卡或双列 Flex,实时预览类组件需要独占有界高度,历史事件则适合时间轴或固定行高 List。
数据模型层承担界面与业务之间的契约。使用 @Observed 的实体保存可编辑字段,页面级 @State 数组负责驱动 ForEach。新增时创建新实体并插入数组,编辑时修改目标实体,删除时移除对应项。为了让列表差分稳定,key 应来自不会改变的唯一标识,不宜使用标题等可编辑字段。统计数字、完成比例和分类数量属于派生信息,可以从数组即时计算,避免同时维护两份状态后出现卡片已经更新、图表仍显示旧值的情况。
弹窗表单使用独立缓存是必要的。打开新增弹窗时清空缓存,打开编辑弹窗时复制目标字段,用户确认后才写回正式模型。这样点击取消不会污染列表数据。若直接把 TextInput 双向绑定到列表实体,用户尚未保存时卡片就可能跟着变化,破坏“确认提交”的交互语义。删除弹窗还需要保存目标索引或唯一标识,并在确认时再次校验目标存在,避免列表变化后误删其他项。
核心代码与状态驱动机制
@State 的价值不是简单替代普通变量,而是建立状态与界面之间的依赖关系。当前 Tab、筛选条件、动画开关、弹窗显隐、下载进度或能力状态发生变化时,只有读取这些变量的组件需要刷新。代码段中连续的修饰器调用分别控制尺寸、间距、背景、字体和事件,它们共同构成声明式描述;阅读时应从容器方向、子项分布、状态绑定和交互回调四个层面理解,而不是逐个孤立翻译属性名称。
ForEach 负责把数组映射为重复 UI。回调中的 item 提供业务字段,index 适合显示顺序,但不适合作为长期身份。列表发生新增或删除时,稳定 key 可以让框架复用未变化节点,减少重建。若直接修改对象属性后界面没有按预期刷新,可在保持实体身份的前提下替换数组引用;但不应为了刷新把所有元素都重新构造,否则会增加无意义渲染并丢失局部状态。
条件渲染体现了页面状态机。空闲时展示引导,准备中展示进度,成功时展示结果,失败时展示原因和重试入口。相比一个布尔值,四态文案更能覆盖异步能力。系统接口调用前先检查权限、设备支持和会话状态,调用后再读取结果校验。异常处理除了记录错误码,还要把可理解的反馈写入响应式状态,让用户知道失败发生在哪一步。
动画效果与颜色使用策略
呼吸动画通常由定时器周期翻转 breath,再把该状态映射为透明度、柱高或圆点半径的小幅变化。它适合表达“正在运行”或让统计图保持生命感,但幅度应克制,不能改变核心数据含义。柱状图的基础高度仍由真实数值计算,动画只能在很小范围内偏移;进度环的角度仍由完成比例决定,不能为了视觉效果显示超过真实进度的结果。页面离开时必须清理定时器,避免后台继续刷新。
颜色常量应按语义使用。主色承担选中态和主要操作,辅助色突出数据或次级动作,绿色表达完成与可用,橙色表达进行中或需要注意,红色只用于失败、逾期和删除等高风险场景。弱文本与分割线降低视觉权重,遮罩色用于聚焦弹窗。颜色不能成为唯一的状态信息,还要配合文字、图标或进度值,保证色觉差异用户也能理解。
渐变更适合头部大卡、核心指标或柱状图,不宜在每个小元素上重复使用。深色主题要检查正文与卡片背景的对比度,浅色主题则要避免辅助文字过淡。选中和未选中 Tab 除颜色差异外,还可以通过字重、图标透明度或底部指示器区分。这样既保持主题统一,又能建立清晰的信息层级。
各 Tab 之间的交互联动
各 Tab 不应只共享一个导航索引,还应围绕业务对象建立必要联动。列表页新增或编辑数据后,头部计数、图表和个人统计要同步更新;网页或地图产生的结果应写入记录模型,供下载、日志或我的页面继续展示;通知、字幕、相机等系统能力的状态应在头部胶囊或对应 Tab 中保持一致。跨 Tab 跳转时先更新必要参数,再修改当前索引,可以避免目标页面读取到旧条件。
切换离开重型组件时需要处理资源边界。相机输入、地图监听、字幕控制器、Web 下载代理和定时器都不能只创建不释放。可以在统一的 switchTab 方法中判断来源与目标,离开能力页时解除监听或停止会话;页面销毁时再执行兜底释放。释放方法应允许重复调用,并对每个资源独立判空,确保一次异常不会阻止后续清理。
交互反馈要覆盖成功与失败。按钮点击后先进入处理中状态并防止重复提交;成功后更新模型、关闭弹窗并显示结果;失败后保留用户输入,展示错误原因和重试入口。权限拒绝、能力不支持、网络失败、文件不存在和输入非法都属于正常业务分支。通过状态卡或行内提示展示这些分支,比只在控制台打印更符合完整产品体验。
边界场景与验证思路
空列表时应显示占位说明和新增入口,不能只留下空白。长标题需要限制行数并使用省略号,数字字段需要限定上下界,文本提交前要去除首尾空格。筛选后无结果应保留清除条件的入口。删除最后一项后,当前选择索引要回退到有效范围。异步搜索连续触发时,应防止较早请求晚返回后覆盖新结果。
验证数据链路时,可以依次检查新增、编辑、删除和筛选:新增后列表条数、统计数字和图表是否同时变化;编辑取消后正式数据是否保持不变;删除后 ForEach key 是否稳定;切换 Tab 再返回时必要数据是否仍在。验证系统能力时分别模拟支持、拒绝和异常,确认界面都有明确状态。验证动画时检查页面离开后是否停止,低性能设备上是否仍保持流畅。
视觉验收需要检查不同屏幕宽度、系统字体放大、深浅背景对比和长文本换行。表格中的布局方式、模型、字段数、核心操作、动画、状态颜色、数据量和特殊组件应与正文一致。Mermaid 图则需要对应真实的数据流和能力链路,节点文字加引号以避免中文或特殊字符导致解析失败。
组件化设计的进一步理解
参数化 Builder 适合抽取重复的统计格、状态行、标签和按钮组。参数只传入渲染所需数据和事件,不让子构建器直接依赖过多页面变量,可以降低耦合。业务复杂后,可把模型与系统能力封装为独立控制器,页面只负责组合 UI 和响应状态。这样既保留声明式代码的直观性,也能让权限、错误码翻译和资源释放得到集中管理。
当前单页面集中展示完整源码,便于博文逐段讲解。若演进为正式项目,可以按领域拆分组件:导航和页面框架位于容器层,列表、图表和弹窗位于展示层,数据读写和 Kit 接入位于服务层。组件之间通过参数、回调、@Link 或 @ObjectLink 传递状态,不使用全局变量代替清晰的数据流。
性能优化首先来自减少不必要刷新。派生数据不要重复存储,动画状态不要进入列表 key,长列表使用稳定标识,Canvas 只在数据或尺寸变化时重绘。其次是控制资源生命周期,页面不可见时停止高成本任务。最后才是微调阴影、渐变和绘制细节。这样的优先级能保证页面在功能增加后仍然可维护。
通过以上补充,可以看到 ArkUI 的声明式模式并非只让布局语法更简洁,它更重要的价值是把数据变化、界面刷新和交互反馈连接为可追踪链路。理解每个代码段读取什么状态、写入什么状态、影响哪些组件,才能真正掌握文章中多个 Tab、图表、弹窗和系统能力协同工作的原理。
二十、总结与展望
本文以"旅语通"跨境旅行向导为案例,深度解析了 HarmonyOS ArkUI 声明式 UI 范式下"三 Kit 融合"的组件化架构实践。从色彩体系的夜航深蓝主题到七大 Tab 的差异化布局,从 @Observed 数据模型层到 @State 状态管理层,从 Map Kit 的双长按监听到 Speech Kit 的 AI 字幕四字段配置,从 Notification Kit 的 EL1 沙箱自定义铃声到弹窗系统的三弹窗联动,本组件完整呈现了"状态集中管理、视图分散渲染、Kit 协同运作"的设计理念。
回顾全文,有六个值得深入思考的技术要点。其一,地图 Tab 的有界高度处理——MapComponent 必须通过 layoutWeight(1) 获得有界高度,因此单独走不进入 Scroll 的路径,这一设计决策体现了 ArkUI 布局系统对原生组件的特殊约束处理。其二,@Observed 类的就地修改驱动刷新——编辑行程时不替换数组元素而是直接修改 TripItem 实例属性,框架自动追踪属性变更并驱动引用该属性的 UI 组件刷新,这是 ArkUI 区别于命令式框架的核心优势。其三,searchByText 的 reliability 字段兜底——搜索结果映射时对 name、formatAddress、distance、reliability 四个可选字段均使用 ?? 空值合并运算符兜底,确保字段缺失时不崩溃。其四,源语言与目标语言的联动锁定——中文源时目标语言锁定 zh 无翻译方向,英文源时默认切双语并可自由选择,该联动逻辑在 switchSourceLang 方法中集中实现。其五,EL1 沙箱铃声的完整链路——从 buildWavBytes 生成 PCM 字节到 fs.openSync 写入沙箱目录,从 fileUri.getUriFromPath 转换 URI 到 'uri::' + uri 拼接 sound 字段,五个环节环环相扣缺一不可。其六,呼吸动画的全局联动——breath 布尔值由单一定时器驱动,同时联动头部圆点透明度、柱状图柱体高度、授权状态卡圆点透明度、旅行家大卡圆点透明度四处视觉元素,体现了"一个状态驱动多个视图"的响应式精髓。
展望未来,本组件仍有多个演进方向。第一,行程数据可接入云端同步,实现多设备间行程共享与协同编辑。第二,地图 Tab 可扩展 onMarkerClick 单击监听与 setMarkerIcon 自定义图标,丰富地标交互维度。第三,搜索 Tab 可增加 searchByCoordinate 坐标反查与 searchByCategory 分类筛选,提升 POI 检索精度。第四,字幕 Tab 可接入真实麦克风音频流替代演示 PCM 块,实现真正的实时语音转字幕。第五,铃音 Tab 可扩展用户录音上传与音频剪辑功能,让铃声完全自定义。第六,通知系统可增加 SlotType 分级策略,不同等级行程提醒使用不同通知渠道与铃声。随着 HarmonyOS 持续演进,ArkUI 声明式范式与三大 Kit 的融合深度将进一步拓展,为跨境旅行服务场景带来更丰富的交互体验与更强大的系统能力支撑。
附录:DevEco Studio 创建新项目与查看 SDK 版本
本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。
一、创建新项目
1.1 进入欢迎界面
启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:
- 新建项目:从头创建新项目
- 打开项目:打开本地已有项目
- 克隆仓库:从 Git 等版本控制拉取代码
点击 “新建项目” 按钮,进入项目创建向导。

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

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

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

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

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

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

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



所有评论(0)