Flutter for OpenHarmony 实战:三方库 dchs_motion_sensors 的鸿蒙化适配指南
欢迎加入 CPF-Flutter 鸿蒙社区:https://atomgit.com/CPF-Flutter
上一篇做的是音量控制,这一篇换到传感器。选 dchs_motion_sensors 的理由是它的通道结构比一般插件复杂——一个 MethodChannel 加七个 EventChannel,覆盖加速度计、陀螺仪、磁力计、线性加速度、姿态、绝对姿态和屏幕方向。通道一多,单位换算和坐标系约定的坑就会集中暴露出来。
适配后的仓库:https://atomgit.com/oh-flutter/dchs_motion_sensors

一、查重:这一轮排掉了四个库
动手前照例先查重。这轮的结果值得单独说,因为待适配清单又坑了三次。
open_file、app_settings、flutter_displaymode 在适配清单里都还标着"需要适配",实际到 AtomGit 上一查:
✅ 存在 CPF-Flutter/fluttertpc_open_file ohos/=✓
✅ 存在 CPF-Flutter/fluttertpc_app_settings ohos/=✓
✅ 存在 oh-flutter/flutter_displaymode ohos/=✓
三个都已经适配完了,文档齐全。清单的数据抓取于一周前,而适配是持续在发生的——只信清单,这一轮就白干三个库。
还有一个更隐蔽的情况:device_apps(1.3 万下载,看起来是个很值得做的库)查重是干净的,但它做不了。原因是它的核心功能是枚举已安装应用,而鸿蒙的对应接口有权限门槛:
L1048 getBundleInfo(bundleName, bundleFlags, callback)
@permission ohos.permission.GET_BUNDLE_INFO_PRIVILEGED or ohos.permission.GET_BUNDLE_INFO
L1513 ... @permission ohos.permission.GET_BUNDLE_INFO_PRIVILEGED
GET_BUNDLE_INFO_PRIVILEGED 是系统级特权权限,普通三方应用申请不到。也就是说这个插件在鸿蒙上只能查到自己的信息,核心场景直接不成立——选它等于交一个功能残缺的适配。
这条经验值得记住:查重只能确认"没人做过",不能确认"能不能做"。 选库时要顺着上游的 API 逐个确认鸿蒙侧有没有对等能力,以及那个能力是否需要拿不到的权限。
二、先看清通道契约
dchs_motion_sensors 的通道结构:
| 通道 | 类型 | 推送内容 |
|---|---|---|
motion_sensors/method | MethodChannel | 可用性查询、采样间隔设置 |
motion_sensors/accelerometer | EventChannel | [x, y, z],含重力,m/s² |
motion_sensors/gyroscope | EventChannel | [x, y, z],rad/s |
motion_sensors/magnetometer | EventChannel | [x, y, z],μT |
motion_sensors/user_accelerometer | EventChannel | [x, y, z],已去重力 |
motion_sensors/orientation | EventChannel | [yaw, pitch, roll],弧度 |
motion_sensors/absolute_orientation | EventChannel | [yaw, pitch, roll],弧度 |
motion_sensors/screen_orientation | EventChannel | double,角度 |
两个 MethodChannel 方法:
Future<bool> isSensorAvailable(int sensorType);
Future<void> setSensorUpdateInterval(int sensorType, int interval);
sensorType 用的是 Android 的 Sensor.TYPE_* 常量(1 加速度计、2 磁力计、4 陀螺仪、10 线性加速度、11 旋转矢量、15 姿态),所以鸿蒙侧需要一张映射表。
Android 实现里有两个细节必须照搬,否则行为会不一致:
// 姿态由旋转矢量算得,且 yaw / pitch 取负
val sensorValues = listOf(-orientation[0], -orientation[1], orientation[2])
// 屏幕方向只在变化时上报
if (rotation != lastRotation) { eventSink?.success(rotation); lastRotation = rotation }
三、鸿蒙侧的映射:三处必须做的换算
@ohos.sensor 的接口形状和 Android 很接近,但有三处语义不一致,逐条踩过。
换算一:采样间隔,微秒 vs 纳秒
Dart 侧的 accelerometerUpdateInterval 单位是微秒,而鸿蒙的 Options.interval 是纳秒:
/** 微秒 → 纳秒。 */
const MICROS_TO_NANOS: number = 1000;
setUpdateInterval(intervalMicros: number): void {
this.intervalNanos = intervalMicros * MICROS_TO_NANOS;
if (this.listening) {
this.unsubscribe();
this.subscribe();
}
}
顺带一个鸿蒙特有的约束:没有单独的"改频率"接口,只能先退订再按新间隔重新订阅。所以 Handler 里必须记住当前是否处于订阅状态,否则改频率会把订阅弄丢。
换算二:姿态角,度 vs 弧度
Dart 侧约定弧度,而鸿蒙的 OrientationResponse 直接给度数:
alpha: 绕 z 轴转角,单位度,取值 0-360
beta: 绕 x 轴转角,单位度,取值 ±180
gamma: 绕 y 轴转角,单位度,取值 ±90
对照 Android 的 getOrientation 输出顺序(yaw 绕 z、pitch 绕 x、roll 绕 y),三者正好一一对应,换算加取负即可:
private onData = (data: sensor.OrientationResponse): void => {
const yaw: number = -data.alpha * DEG_TO_RAD;
const pitch: number = -data.beta * DEG_TO_RAD;
const roll: number = data.gamma * DEG_TO_RAD;
this.emit([yaw, pitch, roll]);
};
换算三:绝对姿态,四元数 → 欧拉角
这个最麻烦。Android 的 TYPE_ROTATION_VECTOR 和鸿蒙的 SensorId.ROTATION_VECTOR 都给四元数,但 Dart 侧要的是欧拉角,得自己转:
private onData = (data: sensor.RotationVectorResponse): void => {
const x: number = data.x;
const y: number = data.y;
const z: number = data.z;
const w: number = data.w;
// 四元数转欧拉角:yaw 绕 z、pitch 绕 y、roll 绕 x
const yaw: number = Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z));
// 中间量需钳制到 [-1, 1],浮点误差会让 asin 的参数越界并得到 NaN
const sinPitch: number = Math.min(1, Math.max(-1, 2 * (w * y - z * x)));
const pitch: number = Math.asin(sinPitch);
const roll: number = Math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y));
this.emit([-yaw, -pitch, roll]);
};
asin 那个钳制不是多余的。四元数带浮点误差时 2(wy - zx) 可能算出 1.0000001,asin 直接返回 NaN,而这个 NaN 会一路传到 Dart 侧的 toStringAsFixed 里才炸——排查成本很高。
四、屏幕方向:换了个更靠谱的实现
Android 那边把 screen_orientation 挂在加速度计上,每次加速度事件读一次屏幕旋转角再去重。这个做法能用,但代价是:没有加速度计的设备上这个流就废了。
鸿蒙有直接的显示变化事件,所以这里换了个实现:
onListen(args: Object | null, events: EventSink): void {
this.sink = events;
display.on('change', this.onDisplayChange);
// 立即上报一次当前方向,避免订阅方要等到方向改变才拿到首个值
this.report();
}
对外行为一致(只在方向变化时上报),但不再占用传感器。这属于适配中值得做的改进——上游的实现方式不一定是最适合目标平台的,映射的是行为而不是代码。
五、权限:一个会静默失败的坑
这一节是这次适配里最值得记的部分。
第一次跑起来,磁力计有数据(0.0625),姿态有数据(0.0096),加速度计和陀螺仪全是 0.0000。
看上去像"这两个传感器模拟器没有",但实际是权限问题。查鸿蒙的接口声明:
L206 @permission ohos.permission.ACCELEROMETER
L221 function on(type: SensorId.ACCELEROMETER, ...)
L308 @permission ohos.permission.GYROSCOPE
L323 function on(type: SensorId.GYROSCOPE, ...)
L427 function on(type: SensorId.MAGNETIC_FIELD, ...) ← 无权限注解
加速度计、陀螺仪、线性加速度需要权限;磁力计、姿态、旋转矢量不需要。所以现象完全对得上:无权限的那几个没数据,有权限的那几个正常。
麻烦在于失败是静默的。插件里 sensor.on() 抛错时如果只写个 catch 吞掉,调用方看到的就是"这个流一直没数据",无从判断是设备没有传感器、还是权限被拒、还是代码写错了。
处理办法有两步。
第一步:让应用声明权限。 插件是 HAR,无法代为声明,必须由宿主应用在 module.json5 里写:
"requestPermissions": [
{ "name": "ohos.permission.ACCELEROMETER" },
{ "name": "ohos.permission.GYROSCOPE" }
]
第二步:插件在订阅前主动申请,并把失败经错误通道上报。
申请权限需要 UIAbilityContext,而 FlutterPluginBinding 只给 ApplicationContext。要拿前者得额外实现 AbilityAware:
export default class MotionSensorsPlugin implements FlutterPlugin, MethodCallHandler, AbilityAware {
private uiAbilityContext: common.UIAbilityContext | null = null;
onAttachedToAbility(binding: AbilityPluginBinding): void {
this.uiAbilityContext = binding.getAbility().context;
}
onDetachedFromAbility(): void {
this.uiAbilityContext = null;
}
}
拿到 context 后就能申请,并把结果回传而不是吞掉:
this.requestPermission(this.permission)
.then((granted: boolean) => {
if (!granted) {
events.error('PERMISSION_DENIED',
`OpenHarmony 需要 ${this.permission} 权限才能读取该传感器`, null);
return;
}
this.listening = true;
this.subscribe();
});
实测结论补充一句:在 HarmonyOS 7.0.0(26.0.0) 上,这两个权限是声明即授予(
reqPermissionStates返回 0,未弹出确认框),属于 system_grant 级。运行时申请代码没有触发弹窗。保留它的价值在于防御性——一旦设备或系统版本把它们当 user_grant 处理,这段代码能保证不静默失败,而不是等用户来报"加速度计没数据"。
六、另一个真实的编译错误
鸿蒙的 MethodCall 接口和 Flutter 官方的不完全一样。isSensorAvailable(int) 传的是位置参数,而 Dart 侧的方法签名是 invokeMethod('isSensorAvailable', sensorType),参数没有包成 Map。
第一版我按 Flutter 的习惯写了 call.arguments,编译直接报错:
10505001 ArkTS Compiler Error
Property 'arguments' does not exist on type 'MethodCall'. Did you mean 'argument'?
MotionSensorsPlugin.ets:131:54
鸿蒙的 MethodCall 暴露的是这两个:
| 成员 | 用途 |
|---|---|
args: Any | 位置参数(本次要用这个) |
argument(key: string): Any | 命名参数,要求参数是 Map 或对象 |
改成 call.args as number 后编译通过。这个错误的提示信息很准,直接给出了正确写法,比盲试省事。
七、真机验证
验证在 HarmonyOS 7.0.0(26.0.0) 的 API 26 模拟器上完成。结论分两类。
验证在 HarmonyOS 7.0.0(26.0.0) 的 API 26 模拟器上完成。先把"能证的"和"证不了的"分开说。
能证的:通道与链路
| 通道 | 实测读数 | 说明 |
|---|---|---|
accelerometer | 0.0000 | hilog 显示以约 6.7 Hz 持续推送事件,通道是通的 |
magnetometer | 0.0625 | 通道通、能反序列化;但值是常数,见下方说明 |
orientation | 0.0000, -0.0096, 0.0096 | 通道通;示例界面显示的是度,见下方说明 |
screenOrientation | 0.0000 | 竖屏,符合预期 |
证不了的:数值量级与单位换算
这里必须纠正一处我最初的误读。示例工程里姿态那行显示的是:
Text(degrees(_orientation.y).toStringAsFixed(4))
vector_math 的 degrees() 是"弧度→度"——也就是说,示例把我插件输出的弧度又转回了度再显示。所以界面上的 -0.0096 是 -0.0096 度,不是弧度。
这意味着:
- 模拟器的姿态传感器读数本身几乎为 0(设备水平放置),无论换算做没做,显示出来都是接近 0 的数;
- 那组
-0.0096不能证明"度→弧度换算生效"——我最初把它当成"非零弧度",这是错的。
同理,磁力计的 0.0625 是个常数(1/16),大概率是模拟器上报的固定值,而非真实地磁数据。
所以诚实的结论是:模拟器能证明七条事件通道都通、事件能反序列化到 Dart 侧,但证明不了任何数值量级或单位换算。 度→弧度、四元数→欧拉角这两处换算目前只能靠代码审查确认,最终要用真机读数来验证。

如果要验收"数值正确",请在真机上跑:把设备倾斜,看
orientation与absoluteOrientation是否随姿态变化、量级是否合理。模拟器做不了这一步。

八、已知限制
除了上面那条硬件限制,还有三处需要写进文档:
setSensorUpdateInterval对屏幕方向无效——它由系统事件驱动,不接受采样间隔,与上游 Android 行为一致。- 姿态与绝对姿态的符号约定沿用了上游 Android 的取值方式(对 yaw、pitch 取负),以保证跨平台行为一致。如果应用原本依赖 iOS 实现的符号,需要实测确认。
orientation与absoluteOrientation的参考基准不同:前者取自姿态传感器、不含磁力计基准,后者由旋转矢量换算、是绝对朝向。这一点上游 Dart 代码的注释里没说,容易误用。
小结
这个库的适配工作量主要在"对齐语义"而不是"写接口":
- 三个 EventChannel 变成七个,每个都要处理订阅生命周期;
- 三处单位与表示形式的换算(微秒/纳秒、度/弧度、四元数/欧拉角);
- 权限导致的静默失败必须先解决,否则一半的传感器对用户是废的;
- 上游挂在加速度计上的实现方式,在鸿蒙上可以换成更直接的系统事件。
另外这轮最大的收获其实是查重环节:三个库已经被适配、一个库因权限门槛做不了。选库这一步花的半小时,省下了至少一个库的无效工作量。
适配后的仓库和完整文档:
https://atomgit.com/oh-flutter/dchs_motion_sensors
dependencies:
dchs_motion_sensors:
git:
url: https://atomgit.com/oh-flutter/dchs_motion_sensors.git
ref: 2.0.2-ohos-1.0.0-beta.1
欢迎加入 CPF-Flutter 鸿蒙社区:https://atomgit.com/CPF-Flutter
Flutter 三方库鸿蒙适配清单:https://atomgit.com/oh-flutter/flutter-ohos-adaptation-checklist
更多推荐



所有评论(0)