《HarmonyOS技术精讲》五:实战项目 ── 智能支架助手

《HarmonyOS技术精讲》五:实战项目 ── 智能支架助手

在HarmonyOS NEXT开发中,很多场景需要将设备感知能力和硬件驱动结合起来。比如,设备放入支架后自动开启风扇,检测到用户离开后关闭外设。这类需求看起来很直观,但真正落到代码里,你会发现状态同步、生命周期管理、驱动通信这三个环节每个都不简单。

这一篇我们把之前讲过的 设备状态感知用户状态感知USB串口驱动 串起来,做一个完整的端到端项目------智能支架助手。


这个项目解决什么问题

一个典型的场景:

  1. 手机/平板放到支架上 → 自动开启散热风扇(通过USB串口控制)
  2. 设备从支架上取下 → 自动关闭风扇
  3. 检测到用户不再使用设备 → 风扇进入低功耗模式
  4. 用户重新操作 → 风扇恢复全速

说白了,就是用 设备姿态用户状态 两个条件组合,来决定外设的行为。

官方文档里,Multimodal Awareness Kit 提供了 deviceStatususerStatus 两个模块。前者可以判断设备是否处于支架态,后者能感知用户是否在使用设备。

但官方示例只展示了如何订阅事件,没有告诉你:拿到状态之后怎么用、生命周期怎么管、驱动层怎么对接。

下面我们直接上工程。


环境说明

text 复制代码
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:支持加速度计的 HarmonyOS 手机/平板

注意:支架态检测依赖加速度计,模拟器上可能会不生效,建议真机调试。


项目结构

复制代码
entry/src/main/ets/
├── EntryAbility.ets
├── pages/
│   └── SmartStandPage.ets          // UI页面
├── model/
│   ├── StationaryManager.ets       // 支架态感知模块
│   ├── UserStatusManager.ets       // 用户状态感知模块
│   └── USBSerialDriver.ets         // USB串口驱动封装
├── common/
│   └── DeviceConstants.ets         // 常量定义
└── resources/

整体设计思路:三层分离。

  • 感知层:StationaryManager、UserStatusManager ------ 只负责订阅事件和状态分发
  • 驱动层:USBSerialDriver ------ 只负责USB通信
  • UI层:SmartStandPage ------ 负责状态展示和用户交互

每一层都独立,如果后期要切换驱动协议,不需要改感知层代码。


核心实现

1. 常量定义

typescript 复制代码
// common/DeviceConstants.ets

export const USB_VENDOR_ID = 0x1A86; // 示例:某款USB转串口芯片厂商ID
export const USB_PRODUCT_ID = 0x7523;
export const BAUD_RATE = 9600;

export const CMD_FAN_ON = 0x01;       // 风扇全速
export const CMD_FAN_OFF = 0x00;      // 风扇关闭
export const CMD_FAN_SLOW = 0x02;     // 低功耗模式

export const STATIONARY_TIMEOUT_MS = 3000; // 进入支架态后的延迟校验

这里的关键点是 延迟校验。官方文档没提,但实际开发中你会发现,设备放到支架上那个瞬间,可能因为抖动产生误判。加一个 3 秒的延迟再执行动作,能避免频繁开关。


2. 支架态感知模块

typescript 复制代码
// model/StationaryManager.ets

import { deviceStatus } from '@kit.MultimodalAwarenessKit';

export class StationaryManager {
  private onStatusChange?: (isStanding: boolean) => void;
  private timerId: number | undefined;

  // 订阅支架态事件
  subscribe(callback: (isStanding: boolean) => void): void {
    this.onStatusChange = callback;

    try {
      deviceStatus.on('steadyStandingDetect', (data: deviceStatus.SteadyStandingStatus) => {
        // 3秒延迟校验,避免抖动误判
        if (this.timerId !== undefined) {
          clearTimeout(this.timerId);
        }

        this.timerId = setTimeout(() => {
          const isStanding = data === deviceStatus.SteadyStandingStatus.ENTER;
          this.onStatusChange?.(isStanding);
          this.timerId = undefined;
        }, STATIONARY_TIMEOUT_MS);
      });
    } catch (err) {
      console.error('Stationary subscribe failed: ' + JSON.stringify(err));
    }
  }

  // 取消订阅
  unsubscribe(): void {
    if (this.timerId !== undefined) {
      clearTimeout(this.timerId);
      this.timerId = undefined;
    }

    try {
      deviceStatus.off('steadyStandingDetect');
    } catch (err) {
      console.error('Stationary unsubscribe failed: ' + JSON.stringify(err));
    }
  }
}

这里有一个重要的设计:定时器管理 。如果每次状态变化都立即触发回调,用户设备放在支架上稍微动一下就会反复开关。用 setTimeout 做一个去抖动,3 秒内状态没有变化再执行动作。

注意 :在 unsubscribe 时要清理定时器,否则组件销毁后定时器还在运行,回调里访问已销毁的 UI 组件会 crash。


3. 用户状态感知模块

typescript 复制代码
// model/UserStatusManager.ets

import { userStatus } from '@kit.MultimodalAwarenessKit';

export class UserStatusManager {
  private onUserActive?: (isActive: boolean) => void;

  subscribe(callback: (isActive: boolean) => void): void {
    this.onUserActive = callback;

    try {
      userStatus.on('userStatus', (data: userStatus.UserStatusInfo) => {
        // 用户正在使用屏幕
        const isActive = data.isScreenOn && data.isUserPresent;
        this.onUserActive?.(isActive);
      });
    } catch (err) {
      console.error('UserStatus subscribe failed: ' + JSON.stringify(err));
    }
  }

  unsubscribe(): void {
    try {
      userStatus.off('userStatus');
    } catch (err) {
      console.error('UserStatus unsubscribe failed: ' + JSON.stringify(err));
    }
  }
}

userStatus 返回的信息里包含了屏幕状态和用户存在状态。我们组合判断:屏幕亮 + 用户在设备前才算活跃。


4. USB串口驱动封装

typescript 复制代码
// model/USBSerialDriver.ets

import { usbManager } from '@kit.USBManagerKit';

export class USBSerialDriver {
  private device: usbManager.USBDevice | undefined;
  private pipe: usbManager.USBDevicePipe | undefined;

  // 连接USB设备
  async connect(): Promise<boolean> {
    try {
      const devices = await usbManager.getDevices();
      this.device = devices.find(
        d => d.vendorId === USB_VENDOR_ID && d.productId === USB_PRODUCT_ID
      );

      if (!this.device) {
        console.error('USB device not found');
        return false;
      }

      // 请求权限
      await usbManager.requestDeviceAccess(this.device, {
        timeout: 5000
      });

      // 打开设备
      this.pipe = await usbManager.openDevice(this.device);
      return true;
    } catch (err) {
      console.error('USB connect failed: ' + JSON.stringify(err));
      return false;
    }
  }

  // 发送指令
  async sendCommand(cmd: number): Promise<boolean> {
    if (!this.pipe) {
      console.error('USB not connected');
      return false;
    }

    try {
      const buffer = new Uint8Array([cmd]);
      const transferResult = await usbManager.sendControlRequest(
        this.pipe,
        {
          requestType: usbManager.USBRequestType.HOST_TO_DEVICE,
          request: 0x40,
          value: cmd,
          index: 0,
          data: buffer
        }
      );

      return transferResult === 0;
    } catch (err) {
      console.error('Send command failed: ' + JSON.stringify(err));
      return false;
    }
  }

  // 断开连接
  disconnect(): void {
    if (this.pipe) {
      usbManager.closeDevice(this.pipe);
      this.pipe = undefined;
    }
  }
}

USB驱动这块有两个坑需要注意。

坑1:权限申请可能失败

requestDeviceAccess 有可能被用户拒绝。需要引导用户手动授权。建议在UI层先弹窗提示。

坑2:设备拔掉后 pipe 失效

设备热插拔后,pipe 会变成无效状态。需要在监听 USB 事件后重新连接。


5. UI层:完整页面

typescript 复制代码
// pages/SmartStandPage.ets

@Entry
@Component
struct SmartStandPage {
  @State fanStatus: string = '关闭';
  @State fanIcon: Resource = $r('app.media.fan_off');
  @State isStanding: boolean = false;
  @State usbStatus: string = '未连接';

  private stationaryManager: StationaryManager = new StationaryManager();
  private userStatusManager: UserStatusManager = new UserStatusManager();
  private usbDriver: USBSerialDriver = new USBSerialDriver();

  aboutToAppear(): void {
    this.initUSBConnection();
    this.initSensors();
  }

  aboutToDisappear(): void {
    // 反订阅时清理资源
    this.stationaryManager.unsubscribe();
    this.userStatusManager.unsubscribe();
    this.usbDriver.disconnect();
  }

  private async initUSBConnection(): Promise<void> {
    const connected = await this.usbDriver.connect();
    this.usbStatus = connected ? '已连接' : '连接失败';
  }

  private initSensors(): void {
    // 订阅支架态
    this.stationaryManager.subscribe((isStanding: boolean) => {
      this.isStanding = isStanding;
      this.updateFanStatus();
    });

    // 订阅用户状态
    this.userStatusManager.subscribe((isActive: boolean) => {
      // 用户活跃时,如果设备在支架上则恢复全速
      if (isActive && this.isStanding) {
        this.usbDriver.sendCommand(CMD_FAN_ON);
        this.fanStatus = '全速';
        this.fanIcon = $r('app.media.fan_on');
      } else if (!isActive && this.isStanding) {
        // 用户离开,进入低功耗
        this.usbDriver.sendCommand(CMD_FAN_SLOW);
        this.fanStatus = '低功耗';
        this.fanIcon = $r('app.media.fan_slow');
      }
    });
  }

  private updateFanStatus(): void {
    if (this.isStanding) {
      this.usbDriver.sendCommand(CMD_FAN_ON);
      this.fanStatus = '全速';
      this.fanIcon = $r('app.media.fan_on');
    } else {
      this.usbDriver.sendCommand(CMD_FAN_OFF);
      this.fanStatus = '关闭';
      this.fanIcon = $r('app.media.fan_off');
    }
  }

  build() {
    Column() {
      // USB连接状态
      Text(`USB设备:${this.usbStatus}`)
        .fontSize(16)
        .fontColor(this.usbStatus === '已连接' ? Color.Green : Color.Red)

      // 支架态显示
      Row() {
        Image($r('app.media.stand_icon'))
          .width(48)
          .height(48)
        Text(this.isStanding ? '设备已放入支架' : '设备未放入支架')
          .fontSize(18)
      }
      .margin({ top: 20 })

      // 风扇状态显示
      Row() {
        Image(this.fanIcon)
          .width(64)
          .height(64)
        Text(`风扇状态:${this.fanStatus}`)
          .fontSize(18)
      }
      .margin({ top: 20 })

      // 手动控制按钮(调试用)
      Button('手动开启风扇')
        .onClick(() => {
          this.usbDriver.sendCommand(CMD_FAN_ON);
          this.fanStatus = '全速';
        })
        .margin({ top: 16 })

      Button('手动关闭风扇')
        .onClick(() => {
          this.usbDriver.sendCommand(CMD_FAN_OFF);
          this.fanStatus = '关闭';
        })
        .margin({ top: 8 })
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .justifyContent(FlexAlign.Start)
  }
}

UI 层的主要逻辑:在 aboutToAppear 里初始化和订阅,在 aboutToDisappear 里取消订阅并关闭连接。这是官方文档没有强调的,但 不取消订阅会导致回调泄漏


常见问题

Q1:为什么真机调试时支架态一直返回 ENTER

A:检查设备的放置角度。文档要求 屏幕与水平面夹角在45°-135°。折叠屏需要处于折叠或完全展开状态。如果放在平的桌面上,角度接近0°,不会触发支架态。

Q2:USB 驱动 sendCommand 返回 false,但设备是连接状态?

A:大概率是 权限被拒绝 。用户在第一次授权时可能点了拒绝。可以在 initUSBConnection 失败后,用 usbManager.requestDeviceAccess 重新请求一次,并弹窗提示用户手动授权。

Q3:进入支架态后风扇频繁开关?

A:把 去抖动延迟 加大。我在代码里用了 3 秒,如果设备放在支架上不够稳定(比如在车上),建议延长到 5 秒。同时检查支架态回调里是否调用了 sendCommand,可以打印日志确认频率。

Q4:页面返回后再次打开,USB 连接失败?

A:问题出在 aboutToAppear 里重新连接 USB 设备时,上一次的 pipe 没有清理干净。在 aboutToDisappear 里调用 disconnect 之后,需要在 aboutToAppear 里重新 connect。注意 connect 是异步的,不要在主线程阻塞。

Q5:用户状态感知不准确,离开座位后仍然显示活跃?

A:userStatusisUserPresent 依赖于设备的前置摄像头和红外传感器。如果设备没有这些硬件(比如一些平板),这个值可能永远为 true。建议降级方案:增加一个闲置超时判断,比如屏幕息屏一段时间后强制进入低功耗。


最佳实践

  1. 不要在 build() 中初始化感知模块 。ArkUI 的 build() 会被频繁调用,重复初始化会导致多个订阅实例。统一在 aboutToAppear 中做一次初始化。

  2. 状态管理用 @State,不要手动传递 。在回调里直接修改 @State 变量,ArkUI 会自动触发组件刷新。不要自己维护一个全局状态对象,容易出现不同步的问题。

  3. 驱动层的错误不要直接吞掉 。USB 通信中断后最好触发 UI 层的重连提示。可以在 sendCommand 失败时抛出一个自定义事件,UI 层收到后自动尝试重连。

  4. 测试时优先真机。模拟器的加速度计行为可能和真机不一致,支架态在模拟器上可能永远不会被触发。用户状态感知也依赖真实硬件传感器。

  5. 考虑多设备兼容性。不同设备的 USB VID/PID 不同,建议做成可配置的常量表。如果设备不支持 userStatus,就只依靠支架态做逻辑判断。


关于这个项目,核心思路就这些。实际开发中,多模态感知和驱动层的联动,本质上就是 状态机 + 事件驱动。支架态和用户状态是两个独立的信号源,最终合并成一个输出指令,控制外设。

如果你也遇到过类似的 感知状态误判USB 驱动不稳定 的问题,重点去检查生命周期管理和去抖动逻辑。官方文档提供的 API 本身不复杂,真正难的部分在边缘情况的处理上。

相关推荐
枫叶丹41 小时前
【HarmonyOS 6.0】Map Kit瓦片图层深度解析:本地加载方式与瓦片数据缓存能力
开发语言·缓存·华为·harmonyos
大雷神1 小时前
第29篇|单拍按钮背后:从点击到 PhotoOutput 回调
harmonyos
不羁的木木1 小时前
《HarmonyOS底部页签-沉浸光感组件实战》模糊样式:打造毛玻璃效果
华为·harmonyos
大雷神8 小时前
第26篇|单摄预览会话:CameraInput、PreviewOutput、PhotoSession 的关系
harmonyos
博客-小覃13 小时前
Zabbix之华为交换机的日志记录信息操作详细教程
服务器·网络·华为·zabbix
不羁的木木16 小时前
Form Kit(卡片开发服务)学习笔记01-核心概念与架构设计
笔记·学习·harmonyos
不羁的木木16 小时前
ArkWeb实战学习笔记01-核心概念与架构设计
笔记·学习·harmonyos
Goway_Hui17 小时前
【鸿蒙原生应用开发--ArkUI--010】Recipe-app 菜谱应用开发教程
华为·harmonyos
●VON17 小时前
鸿蒙 BodyAR 实战:基于人体骨骼追踪的体感运动计数器开发全解
华为·ar·harmonyos·鸿蒙·新特性