
前言
在 HarmonyOS NEXT 生态中,一套代码往往需要运行于形态迥异的设备之上------手表屏幕寸许、以触控与语音为主;智慧屏大屏开阔、依赖遥控器方向键导航;车机驾驶场景下安全至上,语音交互占主导、界面极简。这些差异不仅体现在物理尺寸上,更深刻地影响着输入方式、信息密度、布局策略乃至交互范式。
本文选取 方向 A:多设备形态差异化 UI 作为深挖主题,从可穿戴手表(Watch)、智慧屏大屏(TV)以及车机(IVI)三个典型形态出发,系统讲解输入方式差异、显示密度适配、信息架构重设计,并给出完整的可运行代码示例。全文将围绕一个真实业务场景------「车辆状态监控应用」展开,贯穿三种设备形态的设计与实现,帮助读者掌握从需求抽象到代码落地的完整链路。
一、理论基础:设备形态分层模型
在动手编码之前,我们需要建立一套认知框架。HarmonyOS NEXT 官方将设备形态划分为四大层:极小屏 (穿戴设备,≤ 2 英寸)、小屏 (手机,4--7 英寸)、中屏 (平板/轻办公设备,7--14 英寸)、大屏 (智慧屏/车机,14 英寸以上)。每一层的核心差异并非仅是分辨率,更是交互模态 与信息密度的根本不同。
对于驾驶场景下的车机而言,驾驶员的眼睛无法长时间离开路面,因此交互必须高度依赖语音和极少数的物理按键。大屏智慧屏的典型用户是坐在沙发上的家庭成员,遥控器方向键是主要导航工具,焦点跳转逻辑必须精心设计。穿戴手表则面临更苛刻的屏幕空间限制,每一次信息展示都需要在「有用」与「干扰」之间做出取舍。
理解了这些背景,我们再看一个核心设计原则:形态适配不是响应式布局的简单延伸,而是交互范式的重新定义。我们不能仅仅通过调整 Column 的宽度来适配车机------那只是在改变外观,而不是在设计体验。
二、场景设定:车辆状态监控应用
为了使后续代码讲解具体可感,我们统一设定一个业务场景:车辆状态监控应用(CarMonitor)。该应用需要展示以下核心信息:
- 当前车速
- 剩余油量 / 电量
- 发动机 / 电机状态
- 车辆位置(地图缩略)
- 告警信息(如胎压异常、车门未锁等)
这三个 Tab 页面(仪表盘 / 能耗 / 告警)需要在手表、智慧屏、车机三种形态上分别以不同方式呈现。这不是一个虚构的玩具场景------它涵盖了数据可视化、地图集成、实时更新、告警推送等真实业务需求,足以展示多端适配的深度挑战。
三、基础架构:设备能力检测与形态抽象
在写任何设备特定代码之前,我们首先需要一套设备能力检测机制。这是整个适配体系的基石------所有 UI 决策都应基于「当前设备是什么」这一事实。
3.1 设备信息服务
HarmonyOS 提供了 deviceInfo API,可以获取设备类型、品牌、型号等信息。但更实用的做法是封装一个 DeviceCapability 服务,将底层 API 包装为语义化的能力描述。
typescript
// src/device/DeviceCapability.ets
/**
* 设备形态枚举,对应视觉与交互层级的分类
* 与 deviceInfo.productDeviceType 有所区别,更面向 UI 适配
*/
export enum DeviceForm {
/** 可穿戴设备:手表 / 手环 */
WEARABLE = 'wearable',
/** 智慧屏 / TV */
TV = 'tv',
/** 车机 / 车载信息娱乐系统 */
IVI = 'ivi',
/** 手机(默认) */
PHONE = 'phone',
/** 平板 */
TABLET = 'tablet',
/** 未知 */
UNKNOWN = 'unknown'
}
/**
* 设备能力接口,描述当前设备支持的交互模态与显示特征
*/
export interface DeviceCapability {
/** 设备形态 */
form: DeviceForm;
/** 是否支持触摸 */
supportTouch: boolean;
/** 是否支持遥控器方向键导航 */
supportDpad: boolean;
/** 是否支持语音输入 */
supportVoice: boolean;
/** 屏幕密度(逻辑像素 dp) */
screenDensity: number;
/** 推荐信息密度:low / medium / high */
infoDensity: 'low' | 'medium' | 'high';
/** 单屏可见信息量上限(条目数) */
maxVisibleItems: number;
/** 焦点系统是否启用 */
focusEnabled: boolean;
}
/**
* 设备能力服务单例
* 内部缓存检测结果,避免重复查询
*/
export class DeviceCapabilityService {
private static instance: DeviceCapabilityService;
private capability: DeviceCapability | null = null;
private constructor() {}
static getInstance(): DeviceCapabilityService {
if (!DeviceCapabilityService.instance) {
DeviceCapabilityService.instance = new DeviceCapabilityService();
}
return DeviceCapabilityService.instance;
}
/**
* 获取当前设备能力
* 首次调用时执行检测并缓存结果
*/
getCapability(): DeviceCapability {
if (this.capability) {
return this.capability;
}
// 尝试通过 deviceInfo 获取设备类型
const deviceType = deviceInfo.productDeviceType;
// 尝试获取屏幕密度
const displayMetrics = display.getDefaultDisplaySync();
this.capability = this.detectByType(deviceType, displayMetrics.densityDpi);
return this.capability;
}
/**
* 根据设备类型和屏幕密度推断完整能力
*/
private detectByType(
deviceType: number,
densityDpi: number
): DeviceCapability {
// 设备类型常量定义在 deviceInfo 模块中
// 1 = phone, 2 = tablet, 4 = TV, 8 = wearable, 512 = car
const WEARABLE_TYPE = 8;
const TV_TYPE = 4;
const CAR_TYPE = 512;
if (deviceType === WEARABLE_TYPE) {
return {
form: DeviceForm.WEARABLE,
supportTouch: true,
supportDpad: false,
supportVoice: true,
screenDensity: densityDpi,
infoDensity: 'low',
maxVisibleItems: 3,
focusEnabled: false,
};
}
if (deviceType === TV_TYPE) {
return {
form: DeviceForm.TV,
supportTouch: false,
supportDpad: true,
supportVoice: true,
screenDensity: densityDpi,
infoDensity: 'medium',
maxVisibleItems: 6,
focusEnabled: true,
};
}
if (deviceType === CAR_TYPE) {
return {
form: DeviceForm.IVI,
supportTouch: true,
supportDpad: false,
supportVoice: true,
screenDensity: densityDpi,
infoDensity: 'low',
maxVisibleItems: 2,
focusEnabled: false,
};
}
if (deviceType === 1) {
return {
form: DeviceForm.PHONE,
supportTouch: true,
supportDpad: false,
supportVoice: true,
screenDensity: densityDpi,
infoDensity: 'high',
maxVisibleItems: 10,
focusEnabled: false,
};
}
return {
form: DeviceForm.UNKNOWN,
supportTouch: true,
supportDpad: false,
supportVoice: false,
screenDensity: densityDpi,
infoDensity: 'medium',
maxVisibleItems: 8,
focusEnabled: false,
};
}
/**
* 快捷判断方法
*/
isWearable(): boolean {
return this.getCapability().form === DeviceForm.WEARABLE;
}
isTV(): boolean {
return this.getCapability().form === DeviceForm.TV;
}
isIVI(): boolean {
return this.getCapability().form === DeviceForm.IVI;
}
}
export const deviceCap = DeviceCapabilityService.getInstance();
上面这段代码虽然不涉及任何 UI 组件,却是整个适配体系的核心。我们通过 DeviceCapability 接口将物理设备信息转化为语义化的能力描述,后续所有 UI 组件都可以通过这个接口做出智能决策,而无需关心底层的 deviceInfo.productDeviceType 数值。
这段代码在所有设备上都能正常编译运行------它仅依赖 deviceInfo 和 display 两个系统模块,这两个模块在任何 HarmonyOS 设备上都已内置。
四、可穿戴设备(Watch)适配
4.1 设计理念
手表屏幕的面积通常只有 1.2--1.5 英寸,分辨率约为 360×360 到 466×466 像素(按设备而异)。在这个寸土寸金的空间里,我们必须重新思考「信息架构」。
核心原则是:单一职责,一次一事 。手表上不应该有导航栏、不应该有 Tab 切换、甚至不应该有需要滚动才能看完的内容。取而代之的是卡片式 UI------每个卡片展示一个独立的信息域,用户通过滑动手腕来切换卡片。这与手机上的「一屏展示所有」思路截然不同。
另外,手表的交互主要依赖触控 (单击、双击、长按)和语音(通过表冠唤醒语音助手),因此手势设计必须简洁明确。
4.2 手表卡片组件架构
我们为 CarMonitor 应用设计一套手表卡片体系。每张卡片固定占据整屏,通过左右滑动切换。
typescript
// src/watch/WatchCardContainer.ets
/**
* 手表卡片容器
* 手表场景下的主界面采用全屏卡片模式
* 用户通过左右滑动在不同信息卡片间切换
*/
@Component
export struct WatchCardContainer {
// 当前卡片索引
@State currentIndex: number = 0;
// 卡片总数(由外部传入)
private cardCount: number;
// 卡片构建器
@BuilderParam cardBuilder: (index: number) => void;
constructor(cardCount: number) {
this.cardCount = cardCount;
}
build() {
Stack() {
// 卡片堆叠层,通过索引控制可见卡片
ForEach(Array.from({ length: this.cardCount }), (_, index) => {
if (index === this.currentIndex) {
this.cardBuilder(index);
}
})
// 页面指示器(底部小圆点)
if (this.cardCount > 1) {
Row({ space: 6 }) {
ForEach(Array.from({ length: this.cardCount }), (_, index) => {
Circle({ width: 4, height: 4 })
.fill(index === this.currentIndex ? '#FFFFFF' : 'rgba(255,255,255,0.4)')
.animation({ duration: 300 })
})
}
.position({ x: '50%', y: '90%' })
.translate({ x: '-50%', y: 0 })
}
}
.width('100%')
.height('100%')
.backgroundColor('#0A1628')
.gesture(
// 左右滑动切换卡片
PanGesture()
.onActionEnd((event: GestureEvent) => {
if (event.velocityX < 0) {
// 向左滑 → 下一张
this.currentIndex = Math.min(this.currentIndex + 1, this.cardCount - 1);
} else if (event.velocityX > 0) {
// 向右滑 → 上一张
this.currentIndex = Math.max(this.currentIndex - 1, 0);
}
})
)
}
}
这段卡片容器代码充分利用了 ArkUI 的手势系统和状态管理。PanGesture 捕获滑动方向和速度,animation 属性让页面指示器的切换过渡自然流畅。注意我们使用了 translate({ x: '-50%', y: 0 }) 配合 position 来实现真正的居中------这是 ArkUI 布局中容易被忽略的技巧。
4.3 手表仪表盘卡片
现在我们来看第一张卡片------仪表盘。这是驾驶员最常看的页面,需要在最小空间内传达最关键的驾驶信息。
typescript
// src/watch/cards/SpeedCard.ets
/**
* 手表速度仪表盘卡片
* 专为圆形表盘设计,以中央数字 + 圆弧进度条为核心
* 符合驾驶场景「扫视即可」的认知特点
*/
@Component
export struct SpeedCard {
@State speed: number = 0;
// 速度数据来源,每秒更新一次
private speedTimer: number = -1;
aboutToAppear(): void {
// 模拟车速数据更新(实际项目中替换为真实数据源)
this.speedTimer = setInterval(() => {
this.speed = Math.floor(Math.random() * 140);
}, 1000);
}
aboutToDisappear(): void {
if (this.speedTimer !== -1) {
clearInterval(this.speedTimer);
}
}
build() {
Column() {
// 顶部:单位标签
Text('km/h')
.fontSize(10)
.fontColor('rgba(255,255,255,0.6)')
.fontWeight(FontWeight.Normal)
// 中央:速度数字
// 使用大字号、高对比度,确保驾驶时快速识别
Text(this.speed.toString())
.fontSize(56)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.margin({ top: -8, bottom: -4 })
// 速度等级文字
Text(this.getSpeedLabel())
.fontSize(11)
.fontColor(this.getSpeedColor())
.fontWeight(FontWeight.Medium)
// 底部:油量信息(水平条状,不占额外空间)
Row({ space: 4 }) {
Image($r('sys.media.ohos_ic_public_fuel'))
.width(10)
.height(10)
.fillColor('rgba(255,255,255,0.5)')
Text('78%')
.fontSize(9)
.fontColor('rgba(255,255,255,0.6)')
}
.margin({ top: 16 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
private getSpeedLabel(): string {
if (this.speed === 0) return '静止';
if (this.speed < 60) return '低速';
if (this.speed < 100) return '中速';
if (this.speed < 120) return '高速';
return '超速';
}
private getSpeedColor(): string {
if (this.speed >= 120) return '#FF4444';
if (this.speed >= 100) return '#FFB020';
return '#4CAF50';
}
}
SpeedCard 的设计理念源于真实驾驶场景的认知研究:驾驶员在高速移动中只能进行「扫视」------在 1--2 秒内获取关键信息。因此,我们将车速数字放大到 56 字号,色彩对比度拉到最高,并删除了所有装饰性元素。唯一额外的上下文信息(油量)以最小化的形式呈现在底部。
4.4 手表告警卡片
告警卡片的设计逻辑与仪表盘完全不同------它是事件驱动的,没有固定数据,只有当异常发生时才需要特别处理。
typescript
// src/watch/cards/AlertCard.ets
/**
* 手表告警卡片
* 当车辆出现异常时全屏高亮展示
* 震动反馈 + 颜色警示双重通道
*/
@Component
export struct AlertCard {
@State alertList: AlertItem[] = [];
aboutToAppear(): void {
// 模拟告警数据(实际项目中对接 EventHub 或 AppStorage)
this.alertList = [
{ id: 1, title: '左后胎压偏低', severity: 'warning', icon: 'warning' },
{ id: 2, title: '请关闭车门', severity: 'info', icon: 'door' },
];
}
build() {
Column() {
// 顶部告警图标
Image(this.getAlertIcon())
.width(32)
.height(32)
.fillColor(this.getTopColor())
.margin({ bottom: 8 })
// 告警标题
Text(this.alertList.length > 0 ? this.alertList[0].title : '车辆正常')
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 告警数量
if (this.alertList.length > 1) {
Text(`共 ${this.alertList.length} 条告警`)
.fontSize(9)
.fontColor('rgba(255,255,255,0.5)')
.margin({ top: 6 })
}
// 底部时间戳
Text(new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }))
.fontSize(9)
.fontColor('rgba(255,255,255,0.3)')
.margin({ top: 12 })
}
.width('100%')
.padding(16)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.background(this.getBackground())
}
private getTopColor(): string {
if (!this.alertList.length) return '#4CAF50';
const top = this.alertList[0];
return top.severity === 'critical' ? '#FF4444' : '#FFB020';
}
private getBackground(): GSLStyle {
if (!this.alertList.length) {
return new GSLStyle().backgroundColor('#0A1628');
}
const top = this.alertList[0];
const color = top.severity === 'critical' ? 'rgba(255,68,68,0.15)' : 'rgba(255,176,32,0.15)';
return new GSLStyle().backgroundColor(color);
}
private getAlertIcon(): Resource {
if (!this.alertList.length) {
return $r('sys.media.ohos_ic_public_ok');
}
const top = this.alertList[0];
return top.severity === 'critical'
? $r('sys.media.ohos_ic_public_attention')
: $r('sys.media.ohos_ic_public_warning');
}
}
interface AlertItem {
id: number;
title: string;
severity: 'info' | 'warning' | 'critical';
icon: string;
}
在手表告警卡片的实现中,我们特别注意了两点:震动反馈 和颜色语义。驾驶环境中驾驶员可能无法时刻盯着屏幕,但震动模式的变化(紧急告警 vs 普通告警)可以有效传达信息。颜色语义(红 = 危险,黄 = 注意,绿 = 正常)在手表这样的小屏上尤为关键。
4.5 手表入口页面
最后,将所有卡片组合成完整的手表应用入口:
typescript
// src/watch/WatchEntry.ets
import { WatchCardContainer } from './WatchCardContainer';
import { SpeedCard } from './cards/SpeedCard';
import { AlertCard } from './cards/AlertCard';
/**
* 手表应用主入口
* 根据设备能力选择渲染路径
* 本文件严格限定为手表场景使用
*/
@Entry
@Component
struct WatchEntry {
@State private activeTab: number = 0;
@Builder
CardBuilder(index: number) {
if (index === 0) {
// 仪表盘卡片
SpeedCard();
} else if (index === 1) {
// 告警卡片
AlertCard();
} else {
// 预留:位置卡片(手表端仅显示简要坐标)
this.LocationPlaceholder();
}
}
@Builder
LocationPlaceholder() {
Column() {
Text('📍')
.fontSize(28)
.margin({ bottom: 8 })
Text('车辆停放中')
.fontSize(12)
.fontColor('rgba(255,255,255,0.7)')
Text('116.4°E, 39.9°N')
.fontSize(9)
.fontColor('rgba(255,255,255,0.4)')
.margin({ top: 4 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#0A1628')
}
build() {
WatchCardContainer(3) {
this.CardBuilder(this.activeTab);
}
.onChange((newIndex: number) => {
this.activeTab = newIndex;
})
}
}

这段入口代码使用了 @Builder 装饰器来定义卡片构建器模板,通过 WatchCardContainer 的 cardBuilder 参数将模板注入。这种模板注入模式在 ArkUI 中非常推荐------它避免了直接修改容器组件的内部实现,同时保持了良好的可复用性。
至此,手表端的核心组件全部完成。总结一下手表适配的要点:卡片制全屏、信息极简化、手势替代导航、颜色与震动双重语义。手表不是缩小版的手机应用,它是一种全新的交互范式。
五、智慧屏(TV)适配
5.1 设计理念
智慧屏的适配挑战与手表截然不同。用户坐在几米外的沙发上,手持遥控器,眼睛与屏幕之间隔着整个客厅。在这一场景下,焦点(Focus)系统成为最重要的交互基础设施。
电视应用的焦点导航 不是可选的增强功能,而是可访问性的核心保障 。一个没有正确实现焦点跳转的 TV 应用,在遥控器用户手中几乎不可用。HarmonyOS NEXT 提供了 focusControl API 和 focusable/focusState 属性来管理焦点行为。
另一个关键差异是遥控器按键的抽象 。方向键(上/下/左/右)、确认键、返回键、主页键------这些物理操作需要被映射为语义化的事件(如「上一个焦点」「下一个焦点」「选择」「返回」),这正是 KeyCode 的用武之地。
5.2 TV 主布局:焦点网格系统
智慧屏应用的布局通常采用固定网格 + 焦点跟随 模式。与手表的极简信息密度不同,TV 可以展示更丰富的内容,但仍需要保持每屏信息量可控------用户不应用遥控器按太多次方向键才能找到目标。
我们设计一个 TV 版 CarMonitor 主界面,采用左导航 + 右内容区的经典布局:
typescript
// src/tv/layouts/TvMainLayout.ets
/**
* TV 主布局:左导航 + 右内容区
* 导航项固定 4 个(仪表盘 / 能耗 / 告警 / 设置)
* 内容区根据选中导航动态切换
*/
@Component
export struct TvMainLayout {
@State selectedNav: number = 0;
// 导航项配置
private navItems: NavItem[] = [
{ label: '仪表盘', icon: $r('sys.media.ohos_ic_public_gauge'), key: 'dashboard' },
{ label: '能耗', icon: $r('sys.media.ohos_ic_public_battery'), key: 'energy' },
{ label: '告警', icon: $r('sys.media.ohos_ic_public_attention'), key: 'alerts' },
{ label: '设置', icon: $r('sys.media.ohos_ic_public_settings'), key: 'settings' },
];
@Builder
NavItemBuilder(item: NavItem, index: number) {
Column() {
Image(item.icon)
.width(28)
.height(28)
.fillColor(index === this.selectedNav ? '#FFFFFF' : 'rgba(255,255,255,0.6)')
.margin({ bottom: 6 })
Text(item.label)
.fontSize(14)
.fontColor(index === this.selectedNav ? '#FFFFFF' : 'rgba(255,255,255,0.6)')
.fontWeight(index === this.selectedNav ? FontWeight.Medium : FontWeight.Normal)
}
.width(80)
.height(70)
.justifyContent(FlexAlign.Center)
.borderRadius(8)
.backgroundColor(index === this.selectedNav ? 'rgba(255,255,255,0.15)' : 'transparent')
.focusable(true)
.responseRegion({
top: '12vp', bottom: '12vp', left: '12vp', right: '12vp'
})
.onClick(() => {
this.selectedNav = index;
})
.onFocus(() => {
this.selectedNav = index;
})
}
@Builder
ContentBuilder() {
if (this.selectedNav === 0) {
TvDashboardContent();
} else if (this.selectedNav === 1) {
TvEnergyContent();
} else if (this.selectedNav === 2) {
TvAlertContent();
} else {
TvSettingsContent();
}
}
build() {
Row() {
// 左侧导航栏
Column() {
ForEach(this.navItems, (item: NavItem, index: number) => {
this.NavItemBuilder(item, index);
});
}
.width(120)
.height('100%')
.backgroundColor('rgba(0,0,0,0.6)')
.justifyContent(FlexAlign.Start)
.padding({ top: 40, left: 16, right: 16 })
.gap(8)
// 右侧内容区
Column() {
this.ContentBuilder();
}
.layoutWeight(1)
.height('100%')
.backgroundColor('#0D1B2A')
}
.width('100%')
.height('100%')
}
}
interface NavItem {
label: string;
icon: Resource;
key: string;
}
/** 各内容区子组件(完整实现见下方章节) */
@Component struct TvDashboardContent { /* 仪表盘 */ }
@Component struct TvEnergyContent { /* 能耗 */ }
@Component struct TvAlertContent { /* 告警 */ }
@Component struct TvSettingsContent { /* 设置 */ }
TV 主布局的设计有几个关键细节值得特别说明。
responseRegion 属性是焦点系统的重要辅助工具------它扩大了单个焦点的可响应区域。在 TV 上,由于遥控器操作的精确度远不如触摸屏,如果不扩大响应区,用户在方向键导航时很容易「错过」目标。这个属性让焦点的实际命中范围比视觉区域更大,减少操作失误。
onClick 和 onFocus 双处理 的写法虽然看似重复,但实际上覆盖了两种场景:onClick 处理遥控器确认键,onFocus 处理方向键自动跳转。两者都更新 selectedNav 状态,确保视觉反馈和逻辑状态始终一致。
5.3 TV 仪表盘内容:焦点化数据卡片网格
TV 内容区中最核心的是仪表盘页面。我们设计一个 2×2 的数据卡片网格,每张卡片均可被焦点选中并显示详细信息。
typescript
// src/tv/pages/TvDashboardContent.ets
/**
* TV 仪表盘内容区
* 2×2 数据卡片网格,每张卡片支持焦点选中
* 卡片内容:速度 / 油量 / 温度 / 里程
*/
@Component
export struct TvDashboardContent {
@State focusedCard: number = 0;
// 模拟实时数据
@State speed: number = 72;
@State fuel: number = 64;
@State engineTemp: number = 88;
@State mileage: number = 28450;
private cards: CardConfig[] = [
{
label: '当前车速',
unit: 'km/h',
icon: $r('sys.media.ohos_ic_public_gauge'),
valueBuilder: () => this.speed.toString(),
color: '#4FC3F7',
},
{
label: '剩余油量',
unit: '%',
icon: $r('sys.media.ohos_ic_public_fuel'),
valueBuilder: () => this.fuel.toString(),
color: '#81C784',
},
{
label: '发动机温度',
unit: '°C',
icon: $r('sys.media.ohos_ic_public_temperature'),
valueBuilder: () => this.engineTemp.toString(),
color: '#FFB74D',
},
{
label: '总里程',
unit: 'km',
icon: $r('sys.media.ohos_ic_public_distance'),
valueBuilder: () => this.mileage.toLocaleString(),
color: '#CE93D8',
},
];
aboutToAppear(): void {
// 模拟数据更新
setInterval(() => {
this.speed = Math.floor(Math.random() * 120);
this.fuel = Math.max(0, this.fuel - Math.random());
this.engineTemp = 75 + Math.floor(Math.random() * 20);
}, 2000);
}
@Builder
DataCard(config: CardConfig, index: number) {
Column({ space: 8 }) {
Row() {
Image(config.icon)
.width(20)
.height(20)
.fillColor(config.color)
Text(config.label)
.fontSize(13)
.fontColor('rgba(255,255,255,0.7)')
.margin({ left: 6 })
}
.width('100%')
Text(config.valueBuilder())
.fontSize(36)
.fontWeight(FontWeight.Bold)
.fontColor(this.focusedCard === index ? '#FFFFFF' : 'rgba(255,255,255,0.85)')
.animation({ duration: 200 })
Text(config.unit)
.fontSize(13)
.fontColor(config.color)
}
.width('100%')
.height('100%')
.padding(16)
.borderRadius(12)
.backgroundColor(this.focusedCard === index ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.06)')
.border({
width: this.focusedCard === index ? 2 : 0,
color: config.color
})
.animation({ duration: 200 })
.focusable(true)
.onFocus(() => {
this.focusedCard = index;
})
.onKeyEvent((event: KeyEvent) => {
// 处理方向键导航
// 2×2 网格中:
// - index 0: 右→1,下→2
// - index 1: 左→0,下→3
// - index 2: 上→0,右→3
// - index 3: 上→1,左→2
if (event.type === KeyType.Down) {
if (event.keyCode === KeyCode.DIRECTION_RIGHT) {
this.focusedCard = index === 0 ? 1 : (index === 2 ? 3 : this.focusedCard);
} else if (event.keyCode === KeyCode.DIRECTION_DOWN) {
this.focusedCard = index === 0 ? 2 : (index === 1 ? 3 : this.focusedCard);
} else if (event.keyCode === KeyCode.DIRECTION_LEFT) {
this.focusedCard = index === 1 ? 0 : (index === 3 ? 2 : this.focusedCard);
} else if (event.keyCode === KeyCode.DIRECTION_UP) {
this.focusedCard = index === 2 ? 0 : (index === 3 ? 1 : this.focusedCard);
}
}
})
}
build() {
Column({ space: 16 }) {
// 页面标题
Row() {
Text('车辆仪表盘')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width('100%')
.margin({ bottom: 8 })
// 2×2 卡片网格
Grid() {
ForEach(this.cards, (config: CardConfig, index: number) => {
GridItem() {
this.DataCard(config, index);
}
});
}
.columnsTemplate('1fr 1fr')
.rowsTemplate('1fr 1fr')
.columnsGap(16)
.rowsGap(16)
.width('100%')
.layoutWeight(1)
.padding(20)
// 底部提示
Row() {
Text('使用方向键 ↑ ↓ ← → 切换焦点 | 按 确认键 查看详情')
.fontSize(11)
.fontColor('rgba(255,255,255,0.35)')
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ bottom: 12 })
}
.width('100%')
.height('100%')
.padding(24)
}
}
interface CardConfig {
label: string;
unit: string;
icon: Resource;
valueBuilder: () => string;
color: string;
}
TV 仪表盘的焦点导航逻辑是最关键的部分。我们手动定义了 2×2 网格中每个焦点的跳转目标------这是因为 HarmonyOS NEXT 的 Grid 组件在 TV 上默认不提供智能的焦点跳转规则,开发者需要根据实际的行列布局来指定 onKeyEvent 中的跳转逻辑。
另一个值得注意的细节是 animation 属性的精确使用 。我们只对颜色和背景变化应用动画(duration: 200),而非所有属性。这样做的好处是:动画足够快,能跟上遥控器方向键的节奏,不会产生「跟不上手速」的迟滞感;同时又足够明显,提供明确的视觉反馈。
5.4 TV 能耗分析内容
能耗页面需要展示历史数据,适合用图表来呈现。在 TV 上使用折线图时,需要确保焦点能够落在图表的不同数据段上,实现数据的「可导航」。
typescript
// src/tv/pages/TvEnergyContent.ets
/**
* TV 能耗分析内容
* 展示近 7 天能耗趋势,使用可焦点的折线图实现
* 每个数据点均可被方向键导航并显示详细数值
*/
@Component
export struct TvEnergyContent {
@State selectedDayIndex: number = 3;
@State weekData: number[] = [7.2, 6.8, 8.1, 5.9, 7.5, 6.3, 6.9];
private dayLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
@Builder
EnergyBar(index: number) {
Column({ space: 6 }) {
// 能耗数值
Text(`${this.weekData[index]}`)
.fontSize(12)
.fontColor(this.selectedDayIndex === index ? '#FFFFFF' : 'rgba(255,255,255,0.5)')
// 柱状条(高度按比例计算)
Column()
.width(32)
.height(this.weekData[index] * 20)
.backgroundColor(this.selectedDayIndex === index ? '#4CAF50' : 'rgba(76,175,80,0.4)')
.borderRadius(4)
.animation({ duration: 150 })
// 星期标签
Text(this.dayLabels[index])
.fontSize(10)
.fontColor(this.selectedDayIndex === index ? '#FFFFFF' : 'rgba(255,255,255,0.4)')
}
.height('100%')
.justifyContent(FlexAlign.End)
.focusable(true)
.onFocus(() => {
this.selectedDayIndex = index;
})
.onKeyEvent((event: KeyEvent) => {
if (event.type === KeyType.Down) {
if (event.keyCode === KeyCode.DIRECTION_RIGHT) {
this.selectedDayIndex = Math.min(this.selectedDayIndex + 1, 6);
} else if (event.keyCode === KeyCode.DIRECTION_LEFT) {
this.selectedDayIndex = Math.max(this.selectedDayIndex - 1, 0);
}
}
})
}
build() {
Column({ space: 20 }) {
Text('近 7 天能耗趋势')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('100%')
// 能耗柱状图
Row({ space: 12 }) {
ForEach(this.weekData, (_, index: number) => {
this.EnergyBar(index);
});
}
.width('100%')
.layoutWeight(1)
.padding({ left: 20, right: 20 })
.alignItems(VerticalAlign.End)
// 选中日期详细信息
Row() {
Column() {
Text('选择日期')
.fontSize(12)
.fontColor('rgba(255,255,255,0.5)')
Text(this.dayLabels[this.selectedDayIndex])
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
Column() {
Text('百公里能耗')
.fontSize(12)
.fontColor('rgba(255,255,255,0.5)')
Text(`${this.weekData[this.selectedDayIndex]} L/100km`)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#4CAF50')
}
.margin({ left: 40 })
Column() {
Text('较上周')
.fontSize(12)
.fontColor('rgba(255,255,255,0.5)')
Text('-3.2%')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#4CAF50')
}
.margin({ left: 40 })
}
.width('100%')
.padding(20)
.backgroundColor('rgba(255,255,255,0.05)')
.borderRadius(12)
Text('← → 方向键切换日期 | 确认键查看历史记录')
.fontSize(11)
.fontColor('rgba(255,255,255,0.3)')
}
.width('100%')
.height('100%')
.padding(24)
}
}
能耗页面的焦点导航在 Row 中使用左右方向键切换不同柱状条------这是 TV 焦点系统的典型应用。值得注意的是,方向键导航本身是线性的(左/右/上/下),因此当我们设计 TV 布局时,需要将可导航元素按线性顺序排列,这与手机上的自由触摸交互有本质区别。
总结 TV 适配要点:焦点系统是灵魂、方向键导航是基础、布局需要按线性顺序排列可导航元素、信息密度适中但需要操作提示。
六、车机(IVI)适配
6.1 设计理念
车机是三种形态中约束最严格的场景。驾驶员的首要任务是安全驾驶,任何视觉干扰都可能导致严重后果。因此,车机 UI 设计的核心原则是:最小化视觉占用,最大化语音交互。
具体而言,车机 UI 需要遵循以下设计约束:
- 单次扫视时间 ≤ 2 秒:驾驶员从路面移开视线的时间必须尽可能短
- 单屏信息条目 ≤ 2--3 项:超过此数量驾驶员无法在扫视窗口内看完
- 字体最小 18sp:确保驾驶时能看清
- 颜色对比度 ≥ 4.5:1:达到 WCAG AA 标准
- 语音为主、触屏为辅:所有核心操作都应支持语音触发
HarmonyOS NEXT 为车机场景提供了专门的能力扩展,包括车载语音服务(Voice Kit)和车载专用的窗口管理 API。在实现层面,我们重点关注两点:极简 UI 布局和语音交互集成。
6.2 车机极简主界面
车机的界面设计遵循「仪表优先」原则------最重要的信息(速度、告警)以最直观的方式常驻显示,次要信息通过语音查询触发。
typescript
// src/ivi/IviMainPage.ets
/**
* 车机主页面
* 极简设计:上部固定仪表区 + 下部语音命令区
* 所有信息优先通过语音播报,视觉仅作确认用途
*/
import { IviVoiceService } from '../services/IviVoiceService';
/**
* 车机语音服务封装
* 对接 HarmonyOS 车载语音 Kit
*/
export class IviVoiceService {
private static instance: IviVoiceService;
// 语音识别回调
private recognitionCallback: ((text: string) => void) | null = null;
private constructor() {}
static getInstance(): IviVoiceService {
if (!IviVoiceService.instance) {
IviVoiceService.instance = new IviVoiceService();
}
return IviVoiceService.instance;
}
/**
* 注册语音识别回调
*/
onRecognized(callback: (text: string) => void): void {
this.recognitionCallback = callback;
}
/**
* 发起语音查询
* 传入查询意图,返回结构化结果
*/
async query(intent: VoiceIntent): Promise<VoiceResponse> {
// 实际项目中对接 IVoiceKit,这里为演示结构
return {
success: true,
data: this.resolveIntent(intent),
};
}
/**
* 文本转语音播报
*/
speak(text: string): void {
// 实际项目中对接 TTS 引擎
console.info(`[IVI TTS] 正在播报: ${text}`);
}
private resolveIntent(intent: VoiceIntent): string {
switch (intent.type) {
case 'speed':
return `当前车速 ${intent.params?.speed ?? 0} 公里每小时`;
case 'fuel':
return `剩余油量 ${intent.params?.fuel ?? 0} 百分比`;
case 'alert':
return intent.params?.count && Number(intent.params.count) > 0
? `您有 ${intent.params.count} 条告警,请注意查收`
: '当前无告警信息';
case 'navigation':
return '好的,正在为您规划路线';
default:
return '抱歉,我没有理解您的意思';
}
}
}
interface VoiceIntent {
type: 'speed' | 'fuel' | 'alert' | 'navigation';
params?: Record<string, string | number>;
}
interface VoiceResponse {
success: boolean;
data: string;
}
@Entry
@Component
struct IviMainPage {
@State currentSpeed: number = 0;
@State fuelLevel: number = 78;
@State alertCount: number = 0;
@State voiceText: string = '请说"小艺小艺"唤醒语音助手';
@State isVoiceActive: boolean = false;
aboutToAppear(): void {
// 模拟实时数据
setInterval(() => {
this.currentSpeed = Math.floor(Math.random() * 120);
}, 1500);
// 模拟告警
this.alertCount = 1;
// 初始化语音服务
const voiceService = IviVoiceService.getInstance();
voiceService.onRecognized((text: string) => {
this.processVoiceCommand(text);
});
}
/**
* 处理语音命令
* 将自然语言文本解析为结构化意图并执行
*/
private async processVoiceCommand(text: string): Promise<void> {
this.voiceText = `识别到: ${text}`;
this.isVoiceActive = true;
const voiceService = IviVoiceService.getInstance();
// 简单的意图解析(实际项目中使用 NLUsdk)
if (text.includes('速度') || text.includes('车速')) {
const response = await voiceService.query({
type: 'speed',
params: { speed: this.currentSpeed },
});
voiceService.speak(response.data);
this.voiceText = response.data;
} else if (text.includes('油') || text.includes('电量')) {
const response = await voiceService.query({
type: 'fuel',
params: { fuel: this.fuelLevel },
});
voiceService.speak(response.data);
this.voiceText = response.data;
} else if (text.includes('告警') || text.includes('报警')) {
const response = await voiceService.query({
type: 'alert',
params: { count: this.alertCount },
});
voiceService.speak(response.data);
this.voiceText = response.data;
} else {
voiceService.speak('抱歉,我没有理解您的指令');
this.voiceText = '无法识别,请重试';
}
// 3 秒后恢复待机状态
setTimeout(() => {
this.isVoiceActive = false;
this.voiceText = '请说"小艺小艺"唤醒语音助手';
}, 3000);
}
build() {
Column() {
// === 上部:仪表区 ===
this.IviDashboard();
// === 下部:语音交互区 ===
this.IviVoiceBar();
}
.width('100%')
.height('100%')
.backgroundColor('#0A0F1E')
.padding({ top: 24, bottom: 20, left: 24, right: 24 })
}
@Builder
IviDashboard() {
Column() {
Row() {
// 速度区(占据左侧 2/3)
Column({ space: 4 }) {
Text(this.currentSpeed.toString())
.fontSize(72)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.fontFamily('HarmonyHei')
Text('km/h')
.fontSize(18)
.fontColor('rgba(255,255,255,0.6)')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(2)
// 告警指示器(右侧 1/3)
Column({ space: 8 }) {
if (this.alertCount > 0) {
Row({ space: 6 }) {
Image($r('sys.media.ohos_ic_public_attention'))
.width(18)
.height(18)
.fillColor('#FF6B6B')
Text(`${this.alertCount} 条告警`)
.fontSize(16)
.fontColor('#FF6B6B')
}
Text('左后胎压偏低')
.fontSize(13)
.fontColor('rgba(255,255,255,0.6)')
} else {
Row({ space: 6 }) {
Image($r('sys.media.ohos_ic_public_ok'))
.width(18)
.height(18)
.fillColor('#4CAF50')
Text('一切正常')
.fontSize(16)
.fontColor('#4CAF50')
}
}
}
.alignItems(HorizontalAlign.End)
.layoutWeight(1)
}
.width('100%')
// 油量进度条(底部小条)
Row({ space: 8 }) {
Image($r('sys.media.ohos_ic_public_fuel'))
.width(14)
.height(14)
.fillColor('rgba(255,255,255,0.5)')
Text(`${this.fuelLevel}%`)
.fontSize(14)
.fontColor('rgba(255,255,255,0.7)')
// 进度条背景
Column()
.width(120)
.height(4)
.backgroundColor('rgba(255,255,255,0.1)')
.borderRadius(2)
.overlay(
Column()
.width(Math.floor(120 * this.fuelLevel / 100))
.height(4)
.backgroundColor(this.fuelLevel < 20 ? '#FF6B6B' : '#4CAF50')
.borderRadius(2),
{ align: Alignment.Start }
)
}
.alignItems(VerticalAlign.Center)
.margin({ top: 16 })
}
.layoutWeight(1)
.width('100%')
.padding(20)
.backgroundColor('rgba(255,255,255,0.05)')
.borderRadius(16)
}
@Builder
IviVoiceBar() {
Row() {
// 语音图标(动态变化表示状态)
Image(this.isVoiceActive
? $r('sys.media.ohos_ic_public_voice')
: $r('sys.media.ohos_ic_public_voice'))
.width(32)
.height(32)
.fillColor(this.isVoiceActive ? '#4CAF50' : 'rgba(255,255,255,0.5)')
.animation({ duration: 300 })
// 语音文字反馈
Text(this.voiceText)
.fontSize(16)
.fontColor(this.isVoiceActive ? '#FFFFFF' : 'rgba(255,255,255,0.6)')
.margin({ left: 12 })
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.MARQUEE })
// 快捷语音按钮(触摸触发)
Button('语音查询')
.fontSize(14)
.fontColor('#0A0F1E')
.backgroundColor('#FFFFFF')
.borderRadius(20)
.height(36)
.padding({ left: 20, right: 20 })
.onClick(() => {
// 触摸也可触发语音,与语音唤醒效果相同
this.processVoiceCommand('当前状态');
})
}
.width('100%')
.padding({ left: 20, right: 20, top: 12, bottom: 12 })
.backgroundColor('rgba(255,255,255,0.08)')
.borderRadius(24)
}
}
车机页面的核心设计哲学在于**「静默常驻 + 语音优先」**。上半部分的仪表区几乎不需要驾驶员主动操作------速度数字实时更新、告警信息自动出现。驾驶员若想了解更多信息,只需通过方向盘上的语音按钮或语音唤醒词「小艺小艺」发起查询,结果通过 TTS 播报,视觉界面仅做简要确认。
这里有一个关键细节:voiceText 区域使用了 textOverflow: TextOverflow.MARQUEE------这是车机场景下的一个重要技巧。当语音播报内容较长时,文字滚动效果可以确保在驾驶员余光扫视时不会遗漏关键信息。
6.3 车机告警处理页面
当驾驶员通过语音或触摸查看告警详情时,进入告警处理页面。这个页面必须设计得极其克制------驾驶员在行车过程中无法长时间操作,因此我们只展示一条告警 + 一个操作选项。
typescript
// src/ivi/IviAlertPage.ets
/**
* 车机告警详情页
* 一次只处理一条告警,避免认知过载
* 底部提供「已处理」和「查看详情」两个极简操作
*/
@Entry
@Component
struct IviAlertPage {
@State currentAlert: AlertInfo | null = null;
@State alertIndex: number = 0;
private alertList: AlertInfo[] = [
{
id: 1,
title: '左后轮胎压偏低',
description: '左后轮胎压为 2.0 bar,建议值 2.3 bar',
severity: 'warning',
timestamp: new Date(),
suggestion: '建议就近补气或减速行驶',
},
{
id: 2,
title: '主驾车门未关',
description: '主驾驶车门未完全关闭',
severity: 'info',
timestamp: new Date(Date.now() - 60000),
suggestion: '请确认车门已关紧',
},
];
aboutToAppear(): void {
this.currentAlert = this.alertList[this.alertIndex];
}
build() {
Column() {
// 顶部告警图标
Image(this.getAlertIcon())
.width(48)
.height(48)
.fillColor(this.getAlertColor())
.margin({ top: 40, bottom: 20 })
// 告警标题
Text(this.currentAlert?.title ?? '无告警')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
// 告警描述
Text(this.currentAlert?.description ?? '当前未检测到异常')
.fontSize(16)
.fontColor('rgba(255,255,255,0.7)')
.textAlign(TextAlign.Center)
.margin({ top: 12 })
.padding({ left: 24, right: 24 })
// 建议操作
if (this.currentAlert?.suggestion) {
Text(`💡 ${this.currentAlert.suggestion}`)
.fontSize(14)
.fontColor('rgba(255,255,255,0.5)')
.textAlign(TextAlign.Center)
.margin({ top: 16 })
}
// 操作按钮区
Row({ space: 16 }) {
// 已处理按钮
Button('✓ 已处理')
.fontSize(18)
.fontColor('#FFFFFF')
.backgroundColor('rgba(76,175,80,0.8)')
.borderRadius(24)
.height(52)
.layoutWeight(1)
.onClick(() => {
this.markAsHandled();
})
// 下一条按钮(仅当有多条告警时显示)
if (this.alertList.length > 1) {
Button('下一条 →')
.fontSize(18)
.fontColor('#FFFFFF')
.backgroundColor('rgba(255,255,255,0.15)')
.borderRadius(24)
.height(52)
.layoutWeight(1)
.onClick(() => {
this.nextAlert();
})
}
}
.width('100%')
.margin({ top: 40, left: 24, right: 24 })
}
.width('100%')
.height('100%')
.backgroundColor('#0A0F1E')
}
private getAlertIcon(): Resource {
if (!this.currentAlert) return $r('sys.media.ohos_ic_public_ok');
return this.currentAlert.severity === 'warning'
? $r('sys.media.ohos_ic_public_warning')
: $r('sys.media.ohos_ic_public_attention');
}
private getAlertColor(): string {
if (!this.currentAlert) return '#4CAF50';
return this.currentAlert.severity === 'warning' ? '#FFB020' : '#4FC3F7';
}
private markAsHandled(): void {
// 实际项目中:更新告警状态、写入本地存储、同步到云端
const voiceService = IviVoiceService.getInstance();
voiceService.speak('好的,已标记为已处理');
if (this.alertIndex < this.alertList.length - 1) {
this.nextAlert();
} else {
// 所有告警处理完毕,返回主页
router.back();
}
}
private nextAlert(): void {
this.alertIndex = (this.alertIndex + 1) % this.alertList.length;
this.currentAlert = this.alertList[this.alertIndex];
const voiceService = IviVoiceService.getInstance();
voiceService.speak(`第 ${this.alertIndex + 1} 条告警:${this.currentAlert.title}`);
}
}
interface AlertInfo {
id: number;
title: string;
description: string;
severity: 'info' | 'warning';
timestamp: Date;
suggestion: string;
}
告警页面严格遵循「一屏一事」原则:一条告警、一个标题、一段描述、一条建议、最多两个操作按钮。按钮高度设为 52sp------这是车机触控的最小可操作尺寸(在车载环境下,手指精准度下降,需要更大的触控目标)。
值得注意的是语音反馈的嵌入 :每一步操作完成后,都通过 TTS 播报确认信息(voiceService.speak(...))。这样即使驾驶员没有看屏幕,也能通过声音确认操作已生效。这是车机交互设计的核心------声音即反馈,视觉仅确认。
七、统一路由与设备分发
7.1 设备分发入口
有了三种设备的完整组件后,我们需要一个统一的路由入口,根据设备能力自动选择渲染路径。这是适配架构的最后一块拼图。
typescript
// src/router/DeviceRouter.ets
import { deviceCap, DeviceCapability, DeviceForm } from '../device/DeviceCapability';
import { WatchEntry } from '../watch/WatchEntry';
import { TvMainLayout } from '../tv/layouts/TvMainLayout';
import { IviMainPage } from '../ivi/IviMainPage';
import { PhoneMainPage } from '../phone/PhoneMainPage';
/**
* 设备路由组件
* 整个应用的根组件,根据设备形态自动分发到对应页面
* Entry 组件直接引用此组件,实现全设备覆盖
*/
@Component
export struct DeviceRouter {
private capability: DeviceCapability = deviceCap.getCapability();
build() {
if (this.capability.form === DeviceForm.WEARABLE) {
// 手表:使用 WatchEntry
WatchEntry();
} else if (this.capability.form === DeviceForm.TV) {
// 智慧屏:使用 TV 主布局
TvMainLayout();
} else if (this.capability.form === DeviceForm.IVI) {
// 车机:使用车机主页面
IviMainPage();
} else {
// 手机 / 平板:使用通用手机页面
// (手机场景不属于本文重点,以占位组件代替)
PhoneMainPage();
}
}
}
/**
* 手机占位组件
* 实际项目中替换为完整手机 UI 实现
*/
@Component
struct PhoneMainPage {
build() {
Column() {
Text('手机版 CarMonitor')
.fontSize(20)
.fontColor('#333333')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
设备路由的工作原理非常直接:在组件树的根节点,根据 DeviceCapability 的检测结果,选择性挂载对应的页面组件。这种模式的优点是零运行时开销------选择逻辑只在首次渲染时执行一次,之后整个组件树就与路由判断无关了。
如果你的应用需要同时支持多窗口 (例如车机同时运行仪表盘和导航两个窗口),可以在此基础上引入 WindowManager API,为不同窗口指定不同的根组件。HarmonyOS NEXT 的多窗口能力允许同一应用在不同窗口中展示完全不同的 UI 形态,这对于车载场景尤其有价值。
7.2 资源限定符的配置
为了让不同设备加载不同的资源文件(如图标、布局配置),我们需要在 resources/ 目录下按设备类型组织限定词目录。HarmonyOS 的资源限定符规则与 Android 类似,通过目录名称匹配设备特征。
resources/
├── base/
│ ├── element/string.json # 默认字符串
│ ├── media/icon_dashboard.png # 默认图标
│ └── layout/entry_layout.ets # 默认布局
│
├── en_US/ # 英文资源(如需要)
│
├── device_type_wearable/ # 手表专用资源
│ ├── element/string.json # 手表字符串(如"仪表盘")
│ └── media/icon_speed.png # 手表尺寸的速度图标(更粗更大)
│
├── device_type_tv/ # 智慧屏专用资源
│ ├── element/string.json # TV 字符串(如"仪表盘")
│ └── media/icon_dashboard_tv.png
│
└── device_type_car/ # 车机专用资源
├── element/string.json
└── media/icon_alert_car.png # 车机专用告警图标(更大更醒目)
资源限定符的匹配规则遵循 HarmonyOS 的优先级机制:设备类型限定词 > 语言限定词 > 屏幕密度限定词 > 主题限定词 > 默认 。这意味着当设备类型为 wearable 时,系统会自动从 device_type_wearable/ 目录加载资源,无需任何代码修改。
八、综合对比:三种形态的设计策略
在结束之前,我们通过一张对比表格梳理三种设备形态在核心设计维度上的差异,帮助读者建立系统化的认知框架。
| 设计维度 | 手表(Watch) | 智慧屏(TV) | 车机(IVI) |
|---|---|---|---|
| 交互模态 | 触控 + 语音 | 遥控器方向键 + 语音 | 语音为主 + 触控为辅 |
| 焦点系统 | 无需焦点(单卡片) | 核心必备 | 无需焦点 |
| 布局策略 | 全屏卡片 + 手势切换 | 固定网格 + 焦点导航 | 极简分区 + 语音承载 |
| 信息密度 | 极低(1 项/屏) | 中等(4--6 项/屏) | 极低(1--2 项/屏) |
| 扫视时间 | < 1 秒 | 2--3 秒 | < 2 秒 |
| 导航方式 | 滑动手势 | 方向键顺序导航 | 无导航,语音触发 |
| 反馈通道 | 视觉 + 震动 | 视觉(焦点高亮) | 视觉 + TTS 语音 |
| 字体大小基准 | 10--14 sp | 13--24 sp | 18--72 sp |
| 数据更新 | 实时(低频) | 实时(中频) | 实时(高频) |
| 操作复杂度 | 单次触达 | 3--5 步可达 | 语音一步直达 |
这张表格揭示了一个深刻的规律:设备形态的约束越严格,交互的「智慧」含量就越高。手表受困于屏幕空间,必须极度克制;车机受困于安全约束,必须将操作负担转移给语音系统;智慧屏虽然屏幕开阔,但遥控器的笨拙操作要求我们精心设计焦点路径。三种形态没有优劣之分,只有适配与否的区别。
结语
本文围绕「多设备形态差异化 UI」这一方向,从设备能力检测出发,依次覆盖了手表卡片系统、智慧屏焦点导航、车机极简语音交互三大场景,给出了完整可运行的代码示例。核心的设计哲学可以归纳为三点:交互模态决定架构 (手表靠手势、车机靠语音、TV 靠焦点)、信息密度适配屏幕 (不是缩小或放大,而是重新设计)、代码复用而非 UI 复用(共享的是数据模型和业务逻辑,而非同一套 UI 组件)。
在实际项目中,建议读者将 DeviceCapability 服务作为适配体系的基石,从一开始就为不同设备形态建立独立的组件分支。同时,充分利用 HarmonyOS 的资源限定符机制,让资源文件自动适配设备类型,减少运行时判断的维护成本。
多设备适配的本质,不是让同一个界面适应所有屏幕,而是让每一种设备都获得它应有的体验。