鸿蒙多设备形态适配实战:从手表到车机的差异化 UI 设计

前言

在 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 数值。

这段代码在所有设备上都能正常编译运行------它仅依赖 deviceInfodisplay 两个系统模块,这两个模块在任何 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 装饰器来定义卡片构建器模板,通过 WatchCardContainercardBuilder 参数将模板注入。这种模板注入模式在 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 上,由于遥控器操作的精确度远不如触摸屏,如果不扩大响应区,用户在方向键导航时很容易「错过」目标。这个属性让焦点的实际命中范围比视觉区域更大,减少操作失误。

onClickonFocus 双处理 的写法虽然看似重复,但实际上覆盖了两种场景: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 的资源限定符机制,让资源文件自动适配设备类型,减少运行时判断的维护成本。

多设备适配的本质,不是让同一个界面适应所有屏幕,而是让每一种设备都获得它应有的体验


相关推荐
工业HMI实战笔记2 小时前
HMI性能优化技巧:告别卡顿,打造流畅体验
前端·ui·性能优化·自动化·交互
ZZZMMM.zip2 小时前
基于鸿蒙PC与鸿蒙Flutter框架构建AI本地生活服务平台
人工智能·flutter·华为·harmonyos·鸿蒙
不羁的木木2 小时前
HarmonyOS APP实战-基于Image Kit的图像处理APP - 第1篇:环境搭建与项目初始化
图像处理·华为·harmonyos
解局易否结局2 小时前
鸿蒙新特性实战:元服务与卡片开发完全指南
华为·harmonyos
ltqvibe3 小时前
TokUI怎么让AI输出从文字墙变成可交互UI
人工智能·ui·交互
水龙吟啸4 小时前
华为2026.6.3机考选择题+编程题【速刷敲黑板】
人工智能·深度学习·算法·华为
爱吃大芒果4 小时前
商用门店应用开发:AbilityKit Kiosk 锁屏模式拆解
网络·华为·harmonyos
爸爸6194 小时前
30 原始 SQL 查询与聚合统计:querySql 实战
数据库·sql·华为·harmonyos·鸿蒙系统
星释13 小时前
鸿蒙智能体开发实战:23.常见问题
服务器·华为·ai·harmonyos·鸿蒙·智能体