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


在HarmonyOS NEXT开发中,很多场景需要将设备感知能力和硬件驱动结合起来。比如,设备放入支架后自动开启风扇,检测到用户离开后关闭外设。这类需求看起来很直观,但真正落到代码里,你会发现状态同步、生命周期管理、驱动通信这三个环节每个都不简单。
这一篇我们把之前讲过的 设备状态感知 和 用户状态感知 和 USB串口驱动 串起来,做一个完整的端到端项目------智能支架助手。
这个项目解决什么问题
一个典型的场景:
- 手机/平板放到支架上 → 自动开启散热风扇(通过USB串口控制)
- 设备从支架上取下 → 自动关闭风扇
- 检测到用户不再使用设备 → 风扇进入低功耗模式
- 用户重新操作 → 风扇恢复全速
说白了,就是用 设备姿态 和 用户状态 两个条件组合,来决定外设的行为。
官方文档里,Multimodal Awareness Kit 提供了 deviceStatus 和 userStatus 两个模块。前者可以判断设备是否处于支架态,后者能感知用户是否在使用设备。
但官方示例只展示了如何订阅事件,没有告诉你:拿到状态之后怎么用、生命周期怎么管、驱动层怎么对接。
下面我们直接上工程。
环境说明
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:userStatus 的 isUserPresent 依赖于设备的前置摄像头和红外传感器。如果设备没有这些硬件(比如一些平板),这个值可能永远为 true。建议降级方案:增加一个闲置超时判断,比如屏幕息屏一段时间后强制进入低功耗。
最佳实践
-
不要在 build() 中初始化感知模块 。ArkUI 的 build() 会被频繁调用,重复初始化会导致多个订阅实例。统一在
aboutToAppear中做一次初始化。 -
状态管理用 @State,不要手动传递 。在回调里直接修改
@State变量,ArkUI 会自动触发组件刷新。不要自己维护一个全局状态对象,容易出现不同步的问题。 -
驱动层的错误不要直接吞掉 。USB 通信中断后最好触发 UI 层的重连提示。可以在
sendCommand失败时抛出一个自定义事件,UI 层收到后自动尝试重连。 -
测试时优先真机。模拟器的加速度计行为可能和真机不一致,支架态在模拟器上可能永远不会被触发。用户状态感知也依赖真实硬件传感器。
-
考虑多设备兼容性。不同设备的 USB VID/PID 不同,建议做成可配置的常量表。如果设备不支持 userStatus,就只依靠支架态做逻辑判断。
关于这个项目,核心思路就这些。实际开发中,多模态感知和驱动层的联动,本质上就是 状态机 + 事件驱动。支架态和用户状态是两个独立的信号源,最终合并成一个输出指令,控制外设。
如果你也遇到过类似的 感知状态误判 或 USB 驱动不稳定 的问题,重点去检查生命周期管理和去抖动逻辑。官方文档提供的 API 本身不复杂,真正难的部分在边缘情况的处理上。