HarmonyOS一次开发多端部署:多设备交互与功能兼容开发

摘要:本文深入讲解HarmonyOS一次开发,多端部署中的进阶课题:交互归一设计原则、SysCap系统能力动态管理、功能级一多实现策略以及折叠屏专属适配方案。通过完整的ArkTS代码示例和实战场景分析,帮助开发者打造在多种设备上都能提供一致且优秀用户体验的应用。


在这里插入图片描述


前言

在掌握了HarmonyOS多端部署的架构设计和响应式布局之后,开发者面临的下一个挑战是:如何让应用在不同设备上不仅"看起来对",还要"用起来对"?

手机上的滑动操作在智慧屏上可能完全不适用;平板支持的悬浮窗功能在车机上可能是安全隐患;折叠屏展开/收起时的状态续留更是独特挑战。这些问题都属于多设备交互与功能兼容的范畴。

本文将围绕以下核心议题展开深度探讨:

  1. 交互归一:统一不同设备上的交互体验
  2. SysCap能力管理:动态检测设备能力,实现功能级一多
  3. 功能级一多:让功能按需加载、按设备呈现
  4. 折叠屏适配:应对可折叠设备的独特场景

提示:本文内容基于HarmonyOS API 10/11版本,涉及部分高级特性。建议先完成前两篇一多系列文章的学习,再阅读本文。


一、交互归一设计

1.1 什么是交互归一

交互归一(Interaction Unification)是指在多端部署场景下,为不同设备提供符合其特性的、一致且自然的交互体验。它不是要求所有设备的交互完全一致,而是追求"相同意图,适当表达"。

HarmonyOS定义的交互归一原则包括:

  • 触控设备(手机/平板):以触摸手势为主,支持滑动、捏合、长按
  • 焦点设备(智慧屏/车机):以方向键/遥控器为主,需支持焦点导航
  • 穿戴设备(手表):以简单点击和旋转表冠为主,减少输入
  • 语音设备(音箱/部分车机):支持语音指令交互

1.2 交互抽象层设计

为了实现交互归一,推荐在工程中引入交互抽象层:

// interaction/InteractionAdapter.ets

interface InteractionHandler {
  onSelect(): void;
  onBack(): void;
  onMenu(): void;
  onNext(): void;
  onPrevious(): void;
}

class InteractionAdapter {
  private deviceType: string = 'phone';
  private handler: InteractionHandler | null = null;

  constructor() {
    this.deviceType = DeviceTypeDetector.getDeviceType();
  }

  setHandler(handler: InteractionHandler): void {
    this.handler = handler;
  }

  // 触发选择操作
  triggerSelect(): void {
    if (this.handler) {
      this.handler.onSelect();
    }
  }

  // 触发返回操作
  triggerBack(): void {
    if (this.handler) {
      this.handler.onBack();
    }
  }

  // 获取当前设备推荐的交互方式
  getPreferredInteraction(): InteractionMode {
    switch (this.deviceType) {
      case 'tv':
      case 'car':
        return InteractionMode.FOCUS;
      case 'wearable':
        return InteractionMode.SIMPLE_TAP;
      case 'phone':
      case 'tablet':
      default:
        return InteractionMode.TOUCH;
    }
  }
}

enum InteractionMode {
  TOUCH = 'touch',
  FOCUS = 'focus',
  SIMPLE_TAP = 'simple_tap',
  VOICE = 'voice'
}

export const interactionAdapter = new InteractionAdapter();

1.3 触控与焦点的统一处理

在实际组件中,需要同时支持触控和焦点两种交互模式:

@Component
struct UnifiedListItem {
  @Prop title: string;
  @Prop subtitle: string;
  @Prop icon: Resource;
  @Prop onAction: () => void;

  @State isFocused: boolean = false;
  @State isPressed: boolean = false;
  @State deviceMode: InteractionMode = interactionAdapter.getPreferredInteraction();

  private getBackgroundColor(): ResourceColor {
    if (this.isPressed) return '#E8E8E8';
    if (this.isFocused) return '#D0D0D0';
    return '#FFFFFF';
  }

  private getScaleEffect(): object {
    if (this.isFocused || this.isPressed) {
      return { x: 0.98, y: 0.98 };
    }
    return { x: 1, y: 1 };
  }

  build() {
    Row() {
      Image(this.icon)
        .width(40)
        .height(40)
        .borderRadius(8)
        .objectFit(ImageFit.Cover)

      Column() {
        Text(this.title)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#333333')

        Text(this.subtitle)
          .fontSize(13)
          .fontColor('#999999')
          .margin({ top: 4 })
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .layoutWeight(1)
      .margin({ left: 12 })
      .alignItems(HorizontalAlign.Start)

      Image($r('app.media.ic_arrow_right'))
        .width(20)
        .height(20)
        .fillColor('#CCCCCC')
        .visibility(this.deviceMode === InteractionMode.FOCUS ? Visibility.None : Visibility.Visible)
    }
    .width('100%')
    .height(72)
    .padding(16)
    .backgroundColor(this.getBackgroundColor())
    .borderRadius(12)
    .scale(this.getScaleEffect())
    .animation({ duration: 150, curve: Curve.EaseInOut })
    .focusable(this.deviceMode === InteractionMode.FOCUS)
    .defaultFocus(false)
    .onFocus(() => this.isFocused = true)
    .onBlur(() => this.isFocused = false)
    .onTouch((event: TouchEvent) => {
      if (event.type === TouchType.Down) {
        this.isPressed = true;
      } else if (event.type === TouchType.Up) {
        this.isPressed = false;
        this.onAction();
      }
    })
    .onClick(() => {
      if (this.deviceMode !== InteractionMode.FOCUS) {
        this.onAction();
      }
    })
    .onKeyEvent((event: KeyEvent) => {
      if (this.deviceMode === InteractionMode.FOCUS && event.keyCode === KeyCode.KEYCODE_ENTER) {
        this.onAction();
        return true;
      }
      return false;
    })
  }
}

1.4 手势与遥控器的映射

对于智慧屏等设备,需要将遥控器按键映射为应用操作:

import { inputConsumer } from '@kit.InputKit';

class RemoteControlMapper {
  private callbacks: Map<number, () => void> = new Map();

  initialize(): void {
    // 注册遥控器按键监听
    inputConsumer.on('keyDown', (event) => {
      this.handleKeyEvent(event);
    });
  }

  registerMapping(keyCode: number, callback: () => void): void {
    this.callbacks.set(keyCode, callback);
  }

  private handleKeyEvent(event: inputConsumer.KeyEvent): void {
    const callback = this.callbacks.get(event.keyCode);
    if (callback) {
      callback();
    }
  }

  // 常用遥控器按键映射
  setupDefaultMappings(handlers: {
    onUp: () => void;
    onDown: () => void;
    onLeft: () => void;
    onRight: () => void;
    onOK: () => void;
    onBack: () => void;
    onMenu: () => void;
    onHome: () => void;
  }): void {
    this.registerMapping(KeyCode.KEYCODE_DPAD_UP, handlers.onUp);
    this.registerMapping(KeyCode.KEYCODE_DPAD_DOWN, handlers.onDown);
    this.registerMapping(KeyCode.KEYCODE_DPAD_LEFT, handlers.onLeft);
    this.registerMapping(KeyCode.KEYCODE_DPAD_RIGHT, handlers.onRight);
    this.registerMapping(KeyCode.KEYCODE_ENTER, handlers.onOK);
    this.registerMapping(KeyCode.KEYCODE_BACK, handlers.onBack);
    this.registerMapping(KeyCode.KEYCODE_MENU, handlers.onMenu);
    this.registerMapping(KeyCode.KEYCODE_HOME, handlers.onHome);
  }
}

export const remoteControlMapper = new RemoteControlMapper();

二、SysCap能力管理

2.1 SysCap概述

SysCap(System Capability,系统能力)是HarmonyOS用于描述设备软硬件能力的一套机制。不同设备支持的系统能力存在差异:

设备类型支持的能力示例可能缺失的能力
手机相机、GPS、NFC、指纹、加速度计—
平板相机、GPS、手写笔、多窗口电话、NFC
智慧屏高清显示、遥控器、扬声器相机、GPS、触控
车机定位、语音、大屏显示相机、NFC
手表心率、步数、通知相机、GPS(部分有)

能力提示:在调用任何系统能力之前,务必先检查该能力是否在当前设备上可用。直接调用不存在的能力会导致应用崩溃。

2.2 能力查询与动态适配

HarmonyOS提供了canIUse和canIUseSync接口用于查询系统能力:

import { canIUse, canIUseSync } from '@kit.AbilityKit';

class SysCapManager {
  // 同步查询能力是否可用
  static hasCapability(capability: string): boolean {
    return canIUseSync(capability);
  }

  // 异步查询能力是否可用
  static async checkCapability(capability: string): Promise<boolean> {
    return await canIUse(capability);
  }

  // 批量查询能力
  static checkCapabilities(capabilities: Array<string>): Map<string, boolean> {
    const results = new Map<string, boolean>();
    capabilities.forEach(cap => {
      results.set(cap, canIUseSync(cap));
    });
    return results;
  }
}

// 常用系统能力定义
export const SystemCapabilities = {
  CAMERA: 'SystemCapability.Multimedia.Camera',
  LOCATION: 'SystemCapability.Location.Location',
  NFC: 'SystemCapability.Communication.NFC',
  FINGERPRINT: 'SystemCapability.UserIAM.UserAuth',
  BLUETOOTH: 'SystemCapability.Communication.Bluetooth',
  WIFI: 'SystemCapability.Communication.WiFi',
  SENSOR_ACCELEROMETER: 'SystemCapability.Sensors.Sensor',
  TELEPHONY: 'SystemCapability.Telephony.CoreService',
  MULTI_WINDOW: 'SystemCapability.Window.WindowManager',
  PEN_INPUT: 'SystemCapability.Multimedia.InputDevice',
  FOLDABLE: 'SystemCapability.Window.Foldable',
  VOICE_RECOGNITION: 'SystemCapability.AI.VoiceRecognition'
} as const;

2.3 功能级一多实现

基于SysCap,可以实现功能级一多——同样的应用在不同设备上呈现不同的功能集合:

// features/FeatureGate.ets

interface FeatureConfig {
  name: string;
  requiredCapabilities: Array<string>;
  fallbackBehavior: 'hide' | 'disable' | 'alternative';
  alternativeAction?: () => void;
}

class FeatureGate {
  private featureRegistry: Map<string, FeatureConfig> = new Map();
  private deviceCapabilities: Map<string, boolean> = new Map();

  async initialize(): Promise<void> {
    // 预检测所有相关能力
    const allCaps = Object.values(SystemCapabilities);
    this.deviceCapabilities = SysCapManager.checkCapabilities(allCaps);
  }

  registerFeature(config: FeatureConfig): void {
    this.featureRegistry.set(config.name, config);
  }

  isFeatureAvailable(featureName: string): boolean {
    const config = this.featureRegistry.get(featureName);
    if (!config) return false;

    return config.requiredCapabilities.every(cap => 
      this.deviceCapabilities.get(cap) === true
    );
  }

  getFeatureVisibility(featureName: string): Visibility {
    return this.isFeatureAvailable(featureName) ? Visibility.Visible : Visibility.None;
  }

  getAllAvailableFeatures(): Array<string> {
    return Array.from(this.featureRegistry.keys())
      .filter(name => this.isFeatureAvailable(name));
  }
}

export const featureGate = new FeatureGate();

实际应用中的功能开关:

// 在应用启动时初始化功能门控
async function initializeFeatureGate(): Promise<void> {
  await featureGate.initialize();

  // 注册各功能模块
  featureGate.registerFeature({
    name: 'camera_scan',
    requiredCapabilities: [SystemCapabilities.CAMERA],
    fallbackBehavior: 'hide'
  });

  featureGate.registerFeature({
    name: 'location_service',
    requiredCapabilities: [SystemCapabilities.LOCATION],
    fallbackBehavior: 'disable'
  });

  featureGate.registerFeature({
    name: 'nfc_payment',
    requiredCapabilities: [SystemCapabilities.NFC],
    fallbackBehavior: 'alternative',
    alternativeAction: () => {
      // 显示二维码支付作为替代
      showQRCodePayment();
    }
  });

  featureGate.registerFeature({
    name: 'fingerprint_unlock',
    requiredCapabilities: [SystemCapabilities.FINGERPRINT],
    fallbackBehavior: 'alternative',
    alternativeAction: () => {
      // 使用密码解锁作为替代
      showPasswordInput();
    }
  });

  featureGate.registerFeature({
    name: 'multi_window',
    requiredCapabilities: [SystemCapabilities.MULTI_WINDOW],
    fallbackBehavior: 'hide'
  });

  featureGate.registerFeature({
    name: 'pen_input',
    requiredCapabilities: [SystemCapabilities.PEN_INPUT],
    fallbackBehavior: 'hide'
  });
}

function showQRCodePayment(): void {
  // 实现二维码支付逻辑
}

function showPasswordInput(): void {
  // 实现密码输入逻辑
}

2.4 条件化UI渲染

结合功能门控,实现UI的条件化渲染:

@Entry
@Component
struct FeatureAdaptivePage {
  @State availableFeatures: Array<string> = [];

  aboutToAppear() {
    initializeFeatureGate().then(() => {
      this.availableFeatures = featureGate.getAllAvailableFeatures();
    });
  }

  build() {
    Column() {
      Text('功能面板')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin(16)

      // 相机扫描 - 需要相机能力
      if (featureGate.isFeatureAvailable('camera_scan')) {
        FeatureCard({
          icon: $r('app.media.ic_camera'),
          title: '扫码识别',
          description: '使用相机扫描二维码',
          onClick: () => this.startCameraScan()
        })
      }

      // 定位服务 - 需要定位能力
      if (featureGate.isFeatureAvailable('location_service')) {
        FeatureCard({
          icon: $r('app.media.ic_location'),
          title: '附近推荐',
          description: '基于位置的内容推荐',
          onClick: () => this.loadNearbyContent()
        })
      }

      // NFC支付 - 需要NFC能力,不可用显示替代方案
      if (featureGate.isFeatureAvailable('nfc_payment')) {
        FeatureCard({
          icon: $r('app.media.ic_nfc'),
          title: 'NFC支付',
          description: '一触即付',
          onClick: () => this.startNFCPayment()
        })
      } else {
        FeatureCard({
          icon: $r('app.media.ic_qrcode'),
          title: '二维码支付',
          description: '扫码完成支付',
          tintColor: '#FF9500',
          onClick: () => showQRCodePayment()
        })
      }

      // 多窗口 - 需要多窗口能力
      if (featureGate.isFeatureAvailable('multi_window')) {
        FeatureCard({
          icon: $r('app.media.ic_multitask'),
          title: '分屏浏览',
          description: '同时查看多个页面',
          onClick: () => this.enterSplitMode()
        })
      }

      // 手写输入 - 需要手写笔能力
      if (featureGate.isFeatureAvailable('pen_input')) {
        FeatureCard({
          icon: $r('app.media.ic_pen'),
          title: '手写笔记',
          description: '使用手写笔记录',
          onClick: () => this.openHandwritingPad()
        })
      }
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#F5F5F5')
  }

  private startCameraScan(): void {
    // 启动相机扫描
  }

  private loadNearbyContent(): void {
    // 加载附近内容
  }

  private startNFCPayment(): void {
    // 启动NFC支付
  }

  private enterSplitMode(): void {
    // 进入分屏模式
  }

  private openHandwritingPad(): void {
    // 打开手写板
  }
}

@Component
struct FeatureCard {
  @Prop icon: Resource;
  @Prop title: string;
  @Prop description: string;
  @Prop tintColor: string = '#007AFF';
  @Prop onClick: () => void;

  build() {
    Row() {
      Stack() {
        Image(this.icon)
          .width(24)
          .height(24)
          .fillColor(this.tintColor)
      }
      .width(48)
      .height(48)
      .backgroundColor(this.tintColor + '15')
      .borderRadius(12)

      Column() {
        Text(this.title)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#333333')

        Text(this.description)
          .fontSize(13)
          .fontColor('#999999')
          .margin({ top: 4 })
      }
      .layoutWeight(1)
      .margin({ left: 12 })
      .alignItems(HorizontalAlign.Start)

      Image($r('app.media.ic_arrow_right'))
        .width(20)
        .height(20)
        .fillColor('#CCCCCC')
    }
    .width('100%')
    .height(80)
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ bottom: 12 })
    .onClick(this.onClick)
  }
}

三、折叠屏适配

3.1 折叠屏设备特性

折叠屏设备具有独特的形态变化特性,应用需要适配以下场景:

状态屏幕特征布局策略
折叠(主屏)窄长条形,类似普通手机标准手机布局
展开(大屏)接近正方形,大屏体验平板/大屏布局
悬停部分折叠,可自立上下分栏或特殊交互
切换中形态正在变化保存状态,平滑过渡

折叠屏提示:折叠屏的展开/收起不是简单的尺寸变化,而是设备形态的切换。应用需要监听折叠状态变化,并相应调整布局和功能。

3.2 折叠状态监听

HarmonyOS提供了折叠屏状态监听API:

import { display } from '@kit.ArkUI';

enum FoldStatus {
  FOLDED = 'folded',           // 折叠状态
  EXPANDED = 'expanded',       // 展开状态
  HALF_FOLDED = 'half_folded', // 半折叠(悬停)状态
  UNKNOWN = 'unknown'
}

class FoldableDeviceManager {
  private currentStatus: FoldStatus = FoldStatus.UNKNOWN;
  private callbacks: Set<(status: FoldStatus) => void> = new Set();

  async initialize(): Promise<void> {
    try {
      // 获取当前折叠状态
      const foldInfo = display.getFoldInfo();
      this.currentStatus = this.mapFoldStatus(foldInfo.status);

      // 监听折叠状态变化
      display.on('foldStatusChange', (info: display.FoldStatusInfo) => {
        const newStatus = this.mapFoldStatus(info.status);
        if (newStatus !== this.currentStatus) {
          this.currentStatus = newStatus;
          this.callbacks.forEach(cb => cb(newStatus));
        }
      });
    } catch (error) {
      console.info('当前设备不是折叠屏或折叠屏API不可用');
      this.currentStatus = FoldStatus.UNKNOWN;
    }
  }

  private mapFoldStatus(status: display.FoldStatus): FoldStatus {
    switch (status) {
      case display.FoldStatus.FOLD_STATUS_EXPANDED:
        return FoldStatus.EXPANDED;
      case display.FoldStatus.FOLD_STATUS_FOLDED:
        return FoldStatus.FOLDED;
      case display.FoldStatus.FOLD_STATUS_HALF_FOLDED:
        return FoldStatus.HALF_FOLDED;
      default:
        return FoldStatus.UNKNOWN;
    }
  }

  onStatusChange(callback: (status: FoldStatus) => void): void {
    this.callbacks.add(callback);
    if (this.currentStatus !== FoldStatus.UNKNOWN) {
      callback(this.currentStatus);
    }
  }

  getCurrentStatus(): FoldStatus {
    return this.currentStatus;
  }

  isFoldable(): boolean {
    return this.currentStatus !== FoldStatus.UNKNOWN;
  }
}

export const foldableManager = new FoldableDeviceManager();

3.3 折叠屏响应式布局

@Entry
@Component
struct FoldableAdaptivePage {
  @State foldStatus: FoldStatus = FoldStatus.UNKNOWN;
  @State windowWidth: number = 0;
  @State windowHeight: number = 0;

  aboutToAppear() {
    foldableManager.initialize();
    foldableManager.onStatusChange((status) => {
      this.foldStatus = status;
    });

    windowSizeManager.onSizeChange((width, height) => {
      this.windowWidth = width;
      this.windowHeight = height;
    });
    windowSizeManager.initialize();
  }

  build() {
    Column() {
      if (this.foldStatus === FoldStatus.FOLDED || this.foldStatus === FoldStatus.UNKNOWN) {
        // 折叠状态:使用标准手机布局
        PhoneLayout()
      } else if (this.foldStatus === FoldStatus.EXPANDED) {
        // 展开状态:使用大屏布局
        ExpandedLayout()
      } else if (this.foldStatus === FoldStatus.HALF_FOLDED) {
        // 半折叠状态:使用悬停适配布局
        HalfFoldedLayout()
      }
    }
    .width('100%')
    .height('100%')
  }
}

@Component
struct PhoneLayout {
  build() {
    Column() {
      Text('折叠状态 - 手机布局')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .margin(16)

      List({ space: 12 }) {
        ForEach([1, 2, 3, 4, 5], (item) => {
          ListItem() {
            PhoneContentItem({ index: item })
          }
        })
      }
      .padding(16)
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

@Component
struct ExpandedLayout {
  build() {
    Row() {
      // 左侧导航
      Column() {
        Text('展开状态 - 大屏布局')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .margin(16)

        ForEach(['首页', '发现', '消息', '我的'], (item) => {
          Text(item)
            .fontSize(16)
            .fontColor('#333333')
            .width('100%')
            .height(48)
            .textAlign(TextAlign.Center)
            .backgroundColor('#FFFFFF')
            .borderRadius(8)
            .margin({ bottom: 8 })
        })
      }
      .width(200)
      .height('100%')
      .backgroundColor('#FFFFFF')
      .padding(16)

      // 右侧内容
      Grid() {
        ForEach([1, 2, 3, 4, 5, 6], (item) => {
          GridItem() {
            ExpandedContentCard({ index: item })
          }
        })
      }
      .columnsTemplate('1fr 1fr 1fr')
      .rowsGap(16)
      .columnsGap(16)
      .padding(16)
      .layoutWeight(1)
      .backgroundColor('#F5F5F5')
    }
    .width('100%')
    .height('100%')
  }
}

@Component
struct HalfFoldedLayout {
  build() {
    Column() {
      // 上半部分:内容显示
      Column() {
        Text('悬停模式 - 上屏显示内容')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)

        Image($r('app.media.content_image'))
          .width('80%')
          .aspectRatio(16 / 9)
          .objectFit(ImageFit.Cover)
          .borderRadius(12)
          .margin(16)
      }
      .layoutWeight(1)
      .width('100%')
      .justifyContent(FlexAlign.Center)

      // 下半部分:操作控制
      Column() {
        Text('下屏操作控制')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .margin({ bottom: 16 })

        Row({ space: 16 }) {
          Button('播放')
            .width(100)
            .height(40)
          Button('暂停')
            .width(100)
            .height(40)
          Button('下一首')
            .width(100)
            .height(40)
        }
      }
      .width('100%')
      .height(200)
      .backgroundColor('#FFFFFF')
      .borderRadius({ topLeft: 20, topRight: 20 })
      .justifyContent(FlexAlign.Center)
      .shadow({ radius: 8, color: 'rgba(0,0,0,0.1)', offsetY: -4 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F0F0F0')
  }
}

@Component
struct PhoneContentItem {
  @Prop index: number;

  build() {
    Row() {
      Image($r('app.media.item_image'))
        .width(60)
        .height(60)
        .borderRadius(8)
        .objectFit(ImageFit.Cover)

      Column() {
        Text(`内容项 ${this.index}`)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
        Text('这是内容的简要描述')
          .fontSize(13)
          .fontColor('#999999')
          .margin({ top: 4 })
      }
      .layoutWeight(1)
      .margin({ left: 12 })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
  }
}

@Component
struct ExpandedContentCard {
  @Prop index: number;

  build() {
    Column() {
      Image($r('app.media.card_image'))
        .width('100%')
        .aspectRatio(4 / 3)
        .objectFit(ImageFit.Cover)
        .borderRadius({ topLeft: 12, topRight: 12 })

      Column() {
        Text(`卡片 ${this.index}`)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
        Text('详细描述内容')
          .fontSize(13)
          .fontColor('#999999')
          .margin({ top: 4 })
      }
      .padding(12)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .shadow({ radius: 4, color: 'rgba(0,0,0,0.06)' })
  }
}

3.4 折叠屏状态保存与恢复

折叠屏在展开/收起切换时,应用需要保存和恢复状态:

import { AppStorage } from '@kit.ArkUI';

class FoldableStateManager {
  private static readonly STATE_KEY = 'foldable_app_state';

  // 保存当前页面状态
  static saveState(state: FoldablePageState): void {
    AppStorage.setOrCreate(this.STATE_KEY, JSON.stringify(state));
  }

  // 恢复页面状态
  static restoreState(): FoldablePageState | null {
    const saved = AppStorage.get<string>(this.STATE_KEY);
    if (saved) {
      try {
        return JSON.parse(saved) as FoldablePageState;
      } catch (e) {
        return null;
      }
    }
    return null;
  }

  // 清除保存的状态
  static clearState(): void {
    AppStorage.delete(this.STATE_KEY);
  }
}

interface FoldablePageState {
  scrollPosition: number;
  selectedItemId: string;
  expandedSections: Array<string>;
  inputValues: Record<string, string>;
}

// 在页面中使用
@Entry
@Component
struct StatefulFoldablePage {
  @State scrollPosition: number = 0;
  @State selectedItemId: string = '';
  @State foldStatus: FoldStatus = FoldStatus.UNKNOWN;

  aboutToAppear() {
    // 恢复之前保存的状态
    const savedState = FoldableStateManager.restoreState();
    if (savedState) {
      this.scrollPosition = savedState.scrollPosition;
      this.selectedItemId = savedState.selectedItemId;
    }

    // 监听折叠状态变化
    foldableManager.onStatusChange((status) => {
      if (this.foldStatus !== FoldStatus.UNKNOWN && status !== this.foldStatus) {
        // 形态即将变化,保存状态
        this.saveCurrentState();
      }
      this.foldStatus = status;
    });
  }

  private saveCurrentState(): void {
    FoldableStateManager.saveState({
      scrollPosition: this.scrollPosition,
      selectedItemId: this.selectedItemId,
      expandedSections: [],
      inputValues: {}
    });
  }

  build() {
    List({ scroller: new Scroller() }) {
      // 列表内容
    }
    .onScrollIndex((start, end) => {
      this.scrollPosition = start;
    })
  }
}

四、综合实战:多端音乐播放器

4.1 需求分析

设计一个适配手机、平板、智慧屏、折叠屏和车机的音乐播放器应用,各设备功能规划如下:

设备核心功能特殊适配
手机播放控制、歌单浏览、搜索标准触控交互
平板同手机 + 歌词大屏显示分屏多任务支持
智慧屏沉浸式播放、家庭共享遥控器焦点导航
折叠屏同手机 + 展开大屏浏览折叠状态切换
车机语音控制、驾驶安全模式大按钮、语音优先

4.2 核心服务层

// services/MusicPlayerService.ets

import { avSession } from '@kit.AVSessionKit';

@ObservedV2
class MusicPlayerService {
  @Trace currentTrack: TrackInfo | null = null;
  @Trace isPlaying: boolean = false;
  @Trace currentPosition: number = 0;
  @Trace playlist: Array<TrackInfo> = [];
  @Trace playMode: PlayMode = PlayMode.SEQUENCE;

  private audioPlayer: media.AudioPlayer | null = null;
  private avSession: avSession.AVSession | null = null;

  async initialize(): Promise<void> {
    // 初始化音频播放器
    this.audioPlayer = await media.createAudioPlayer();
    this.setupAudioCallbacks();

    // 创建AVSession用于系统媒体控制
    this.avSession = await avSession.createAVSession(
      getContext(),
      'MusicPlayer',
      avSession.AVSessionType.AUDIO
    );
    this.setupAVSessionCallbacks();
  }

  private setupAudioCallbacks(): void {
    if (!this.audioPlayer) return;

    this.audioPlayer.on('timeUpdate', (time: number) => {
      this.currentPosition = time;
    });

    this.audioPlayer.on('complete', () => {
      this.playNext();
    });
  }

  private setupAVSessionCallbacks(): void {
    if (!this.avSession) return;

    this.avSession.on('play', () => this.play());
    this.avSession.on('pause', () => this.pause());
    this.avSession.on('next', () => this.playNext());
    this.avSession.on('previous', () => this.playPrevious());
  }

  async playTrack(track: TrackInfo): Promise<void> {
    this.currentTrack = track;
    await this.audioPlayer?.setSource(track.url);
    await this.play();
    this.updateAVSessionMetadata(track);
  }

  async play(): Promise<void> {
    await this.audioPlayer?.play();
    this.isPlaying = true;
  }

  async pause(): Promise<void> {
    await this.audioPlayer?.pause();
    this.isPlaying = false;
  }

  playNext(): void {
    const currentIndex = this.playlist.findIndex(t => t.id === this.currentTrack?.id);
    let nextIndex: number;

    switch (this.playMode) {
      case PlayMode.SHUFFLE:
        nextIndex = Math.floor(Math.random() * this.playlist.length);
        break;
      case PlayMode.SINGLE_LOOP:
        nextIndex = currentIndex;
        break;
      case PlayMode.SEQUENCE:
      default:
        nextIndex = (currentIndex + 1) % this.playlist.length;
    }

    this.playTrack(this.playlist[nextIndex]);
  }

  playPrevious(): void {
    const currentIndex = this.playlist.findIndex(t => t.id === this.currentTrack?.id);
    const prevIndex = currentIndex <= 0 ? this.playlist.length - 1 : currentIndex - 1;
    this.playTrack(this.playlist[prevIndex]);
  }

  private updateAVSessionMetadata(track: TrackInfo): void {
    this.avSession?.setAVMetadata({
      assetId: track.id,
      title: track.title,
      artist: track.artist,
      album: track.album,
      duration: track.duration,
      mediaImage: track.coverUrl
    });
  }
}

interface TrackInfo {
  id: string;
  title: string;
  artist: string;
  album: string;
  duration: number;
  url: string;
  coverUrl: string;
}

enum PlayMode {
  SEQUENCE = 'sequence',
  SHUFFLE = 'shuffle',
  SINGLE_LOOP = 'single_loop'
}

export const musicPlayerService = new MusicPlayerService();

4.3 设备专属播放页面

// 入口页面,根据设备类型路由
@Entry
@Component
struct MusicPlayerEntry {
  @State deviceType: string = 'phone';

  aboutToAppear() {
    this.deviceType = DeviceTypeDetector.getDeviceType();
    musicPlayerService.initialize();
  }

  build() {
    Stack() {
      if (this.deviceType === 'tv') {
        TVPlayerPage()
      } else if (this.deviceType === 'car') {
        CarPlayerPage()
      } else if (this.deviceType === 'tablet') {
        TabletPlayerPage()
      } else {
        PhonePlayerPage()
      }
    }
    .width('100%')
    .height('100%')
  }
}

// 手机版播放器
@Component
struct PhonePlayerPage {
  @State service: MusicPlayerService = musicPlayerService;

  build() {
    Column() {
      // 封面
      Image(this.service.currentTrack?.coverUrl ?? $r('app.media.default_cover'))
        .width('70%')
        .aspectRatio(1)
        .objectFit(ImageFit.Cover)
        .borderRadius(16)
        .margin(32)
        .shadow({ radius: 20, color: 'rgba(0,0,0,0.2)' })

      // 歌曲信息
      Column() {
        Text(this.service.currentTrack?.title ?? '未播放')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Text(this.service.currentTrack?.artist ?? '--')
          .fontSize(14)
          .fontColor('#999999')
          .margin({ top: 8 })
          .maxLines(1)
      }
      .width('80%')
      .margin({ top: 16 })

      // 进度条
      Slider({
        value: this.service.currentPosition,
        min: 0,
        max: this.service.currentTrack?.duration ?? 100
      })
      .width('85%')
      .margin({ top: 24 })

      Row() {
        Text(this.formatTime(this.service.currentPosition))
          .fontSize(12)
          .fontColor('#999999')
        Blank()
        Text(this.formatTime(this.service.currentTrack?.duration ?? 0))
          .fontSize(12)
          .fontColor('#999999')
      }
      .width('85%')
      .margin({ top: 4 })

      // 控制按钮
      Row({ space: 32 }) {
        Image($r('app.media.ic_prev'))
          .width(32)
          .height(32)
          .onClick(() => this.service.playPrevious())

        Image(this.service.isPlaying ? $r('app.media.ic_pause') : $r('app.media.ic_play'))
          .width(64)
          .height(64)
          .onClick(() => {
            this.service.isPlaying ? this.service.pause() : this.service.play();
          })

        Image($r('app.media.ic_next'))
          .width(32)
          .height(32)
          .onClick(() => this.service.playNext())
      }
      .margin({ top: 32 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F8F8F8')
  }

  private formatTime(seconds: number): string {
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
  }
}

// 智慧屏版播放器 - 大按钮 + 焦点导航
@Component
struct TVPlayerPage {
  @State service: MusicPlayerService = musicPlayerService;
  @State focusedControl: string = 'play';

  aboutToAppear() {
    remoteControlMapper.initialize();
    remoteControlMapper.setupDefaultMappings({
      onUp: () => this.navigateFocus('up'),
      onDown: () => this.navigateFocus('down'),
      onLeft: () => this.navigateFocus('left'),
      onRight: () => this.navigateFocus('right'),
      onOK: () => this.triggerFocusedAction(),
      onBack: () => this.goBack(),
      onMenu: () => this.showPlaylist(),
      onHome: () => this.goHome()
    });
  }

  private navigateFocus(direction: string): void {
    // 焦点导航逻辑
  }

  private triggerFocusedAction(): void {
    // 触发当前焦点操作
  }

  private goBack(): void {}
  private showPlaylist(): void {}
  private goHome(): void {}

  build() {
    Column() {
      // 大封面
      Image(this.service.currentTrack?.coverUrl ?? $r('app.media.default_cover'))
        .width('50%')
        .aspectRatio(1)
        .objectFit(ImageFit.Cover)
        .borderRadius(16)
        .margin(48)

      Text(this.service.currentTrack?.title ?? '未播放')
        .fontSize(36)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')

      Text(this.service.currentTrack?.artist ?? '--')
        .fontSize(24)
        .fontColor('#AAAAAA')
        .margin({ top: 12 })

      // 大按钮控制区
      Row({ space: 48 }) {
        this.buildTVControlButton('prev', $r('app.media.ic_prev_large'))
        this.buildTVControlButton('play', 
          this.service.isPlaying ? $r('app.media.ic_pause_large') : $r('app.media.ic_play_large'))
        this.buildTVControlButton('next', $r('app.media.ic_next_large'))
      }
      .margin({ top: 48 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#1A1A1A')
    .justifyContent(FlexAlign.Center)
  }

  @Builder
  buildTVControlButton(id: string, icon: Resource) {
    Image(icon)
      .width(id === 'play' ? 96 : 64)
      .height(id === 'play' ? 96 : 64)
      .focusable(true)
      .onFocus(() => this.focusedControl = id)
      .scale(this.focusedControl === id ? { x: 1.2, y: 1.2 } : { x: 1, y: 1 })
      .animation({ duration: 200 })
  }
}

// 车机版播放器 - 超大按钮,驾驶安全
@Component
struct CarPlayerPage {
  @State service: MusicPlayerService = musicPlayerService;

  build() {
    Column() {
      Row() {
        Image(this.service.currentTrack?.coverUrl ?? $r('app.media.default_cover'))
          .width(120)
          .height(120)
          .borderRadius(12)
          .objectFit(ImageFit.Cover)

        Column() {
          Text(this.service.currentTrack?.title ?? '未播放')
            .fontSize(28)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .maxLines(1)

          Text(this.service.currentTrack?.artist ?? '--')
            .fontSize(20)
            .fontColor('#AAAAAA')
            .margin({ top: 8 })
            .maxLines(1)
        }
        .layoutWeight(1)
        .margin({ left: 24 })
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
      .padding(32)

      Blank()

      // 超大控制按钮
      Row({ space: 48 }) {
        Button('上一首')
          .width(160)
          .height(80)
          .fontSize(24)
          .backgroundColor('#333333')
          .onClick(() => this.service.playPrevious())

        Button(this.service.isPlaying ? '暂停' : '播放')
          .width(200)
          .height(100)
          .fontSize(28)
          .fontWeight(FontWeight.Bold)
          .backgroundColor(this.service.isPlaying ? '#FF9500' : '#34C759')
          .onClick(() => {
            this.service.isPlaying ? this.service.pause() : this.service.play();
          })

        Button('下一首')
          .width(160)
          .height(80)
          .fontSize(24)
          .backgroundColor('#333333')
          .onClick(() => this.service.playNext())
      }
      .margin({ bottom: 48 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#000000')
  }
}

五、测试与发布策略

5.1 多端测试清单

测试项手机平板智慧屏折叠屏车机
基础功能✓✓✓✓✓
触控交互✓✓—✓✓
焦点导航——✓—✓
语音控制✓✓✓✓✓
分屏/多窗口—✓—✓—
折叠切换———✓—
系统媒体控制✓✓✓✓✓
深色模式✓✓✓✓✓

5.2 发布配置

// entry/src/main/module.json5
{
  "module": {
    "name": "entry",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": [
      "phone",
      "tablet",
      "2in1",
      "tv",
      "car",
      "wearable"
    ],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:icon",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "entities": ["entity.system.home"],
            "actions": ["action.system.home"]
          }
        ]
      }
    ],
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      },
      {
        "name": "ohos.permission.READ_MEDIA"
      }
    ]
  }
}

总结

本文深入探讨了HarmonyOS一次开发,多端部署中的高级课题,从交互归一到SysCap能力管理,从功能级一多到折叠屏专属适配,为开发者提供了系统性的解决方案。

核心要点回顾:

  1. 交互归一:通过交互抽象层统一处理触控和焦点两种交互模式,让同一组件适配不同输入方式
  2. SysCap管理:使用canIUse动态检测设备能力,实现功能的条件化加载和优雅降级
  3. 功能级一多:不是所有功能都要在所有设备上呈现,根据能力检测结果智能决定功能可见性
  4. 折叠屏适配:监听折叠状态变化,保存和恢复页面状态,为折叠、展开、悬停三种形态提供专属布局

系列文章回顾:

  • 第一篇:架构设计与响应式布局(三层架构、断点系统、GridRow/GridCol)
  • 第二篇:多设备交互与功能兼容(本文:交互归一、SysCap、折叠屏)

掌握这些技术后,你的HarmonyOS应用将能够真正适配全场景设备,为用户提供一致且出色的体验。


推荐阅读与资源:

Logo

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

更多推荐