Flutter 三方库 holding 的 OpenHarmony 适配实战:从 0 到 1 开发一个鸿蒙感知插件
本文记录了从 0 到 1 实现开源 Flutter 三方库
holding并适配到 OpenHarmony / HarmonyOS 平台的完整过程,包含生态价值分析、适配思路、代码实现、API 26 能力扩展、测试验证、发布流程与踩坑复盘,供其他开发者快速复刻同类插件的鸿蒙化工作。
一、背景
1.1 生态价值:为什么值得做这类插件
HarmonyOS 已经形成独立的 Flutter 生态(Flutter OH),由 CPF-Flutter 提供引擎与框架的鸿蒙分支。生态繁荣的关键在于 pub.dev 上的原生能力类插件能否在鸿蒙上开箱即用——这决定了现有 Flutter 应用迁移到鸿蒙的成本。
holding 插件正是这类"系统能力桥接"插件的典型代表:
- 能力来自鸿蒙专属 Kit:握持手状态(
holdingHandChanged)与操作手状态(operatingHandChanged)由鸿蒙@kit.MultimodalAwarenessKit的motion模块提供,Android/iOS 生态没有完全等价能力,因此它不是"翻译"某个 Android 插件,而是 把鸿蒙专属系统感知能力暴露给 Flutter 生态; - 应用场景真实:单手/双手握持感知可用于适老化交互、单手键盘、双手协同阅读、防误触等特性,是 Mate 系列/折叠屏等设备的差异化交互基础;
- 方法论可复制:本文的"创建插件工程 → Dart 层接口 → ArkTS 原生实现 → 通道注册 → 示例验证 → API 26 扩展 → 发布"的完整路径,适用于所有基于鸿蒙 Kit 的感知类插件(屏幕状态、设备状态、用户状态等),同类工作可直接对照。
1.2 三方库简介
holding 是一个 Flutter 握持手感知插件,仓库地址为 atomgit.com/oh-flutter/holding-harmony,提供以下能力:
- 握持手状态订阅:
subscribeHoldingHand/unsubscribeHoldingHand,监听设备被哪只手握持(未握持/左手/右手/双手/未识别五种状态); - 操作手状态订阅:
subscribeOperatingHand/unsubscribeOperatingHand,监听用户正在用哪只手操作设备(左手/右手/未识别); - 最近操作手查询:
getRecentOperatingHandStatus,同步查询系统缓存的最近一次操作手状态; - 类型安全的状态枚举:
HoldingHandStatus、OperatingHandStatus,与鸿蒙系统枚举一一对应,提供code与fromCode(int)转换。
插件当前仅声明 ohos 平台实现,其他平台调用会抛出 UnimplementedError(预期行为)。
项目地址:atomgit.com/oh-flutter/holding-harmony(Issue / PR 均在此仓库提交)
Pub 包:pub.dev/packages/holding
1.3 适配目标
| 维度 | 要求 |
|---|---|
| 功能一致性 | 握持手/操作手的订阅、取消订阅、查询行为与鸿蒙 motion API 一致,错误码通过回调反馈 |
| Dart 层零破坏 | Dart 接口一次成型,后续 API 26 能力扩展只做增量,不破坏已发布版本的接口契约 |
| 性能 | 事件流直通:原生回调 → EventSink → Dart onChange,不做轮询,无中间缓冲 |
| 工程规范 | 通道名/方法名/错误消息统一契约;中英双 README(README.OpenHarmony.md / README.OpenHarmony_CN.md)、双 CHANGELOG、真机验证、单元测试齐全 |
| 版本对齐 | 与最新鸿蒙 SDK(API 26)和最新 Flutter OH 版本对齐,并纳入 API 26 SDK 相对插件的新增 API |
二、适配路线图
整个实现分为 6 个阶段:
第 1 阶段:环境搭建 ── Flutter OH SDK + DevEco Studio + HarmonyOS SDK (API 26)
第 2 阶段:工程创建 ── flutter create 生成 ohos 插件模板
第 3 阶段:Dart 层接口 ── 平台接口 + 枚举 + MethodChannel/EventChannel 实现
第 4 阶段:原生实现 ── ArkTS 插件,桥接 @kit.MultimodalAwarenessKit
第 5 阶段:API 26 扩展 ── 纳入新 SDK 的 operatingHandChanged / getRecentOperatingHandStatus
第 6 阶段:验证与发布 ── 单元测试 + 真机运行 + hvigor 构建 + 发布到 pub.dev
其中第 5 阶段是本次"结合最新鸿蒙 SDK 做升级"的核心:对照 API 26 SDK 的 @ohos.multimodalAwareness.motion.d.ts,把插件尚未覆盖的系统 API 增量纳入插件能力。
三、逐步实现过程
第 1 阶段:环境搭建
本文验证时使用的环境(按版本获取方式可自查):
| 项目 | 版本 | 获取方式 |
|---|---|---|
| Flutter | 3.47.9-ohos-1.0.0(oh-3.47-dev 渠道) | flutter --version |
| Dart | 3.13.4 | flutter --version |
| HarmonyOS SDK | 26.0.0(API 26) | ~/Library/OpenHarmony/Sdk/ 目录名,或 DevEco 内 SDK 管理器 |
| IDE | DevEco Studio 26.0.0 | defaults read /Applications/DevEco-Studio.app/Contents/Info.plist CFBundleShortVersionString |
| 真机 | HUAWEI Mate 60 Pro(ALN-AL00),ROM 7.0.0.111(SP10C00E111R4P3),OpenHarmony API 26 | hdc list targets + hdc shell param get const.product.software.version |
| pub 镜像 | pub.flutter-io.cn(下载用)/ pub.dev(发布用) | flutter config 或环境变量 PUB_HOSTED_URL |
Flutter OH SDK 来自 atomgit.com/oh-flutter/flutter_flutter 的 oh-3.47-dev 分支:
git clone https://atomgit.com/oh-flutter/flutter_flutter.git \
-b oh-3.47-dev flutter_flutter
export PATH="$(pwd)/flutter_flutter/bin:$PATH"
flutter --version # 预期带 -ohos 后缀,且 doctor 显示 HarmonyOS toolchain ✓
API 26 格式注意:新版 DevEco/Hvigor 的工程配置使用
26.0.0作为compileSdkVersion/targetSdkVersion/compatibleSdkVersion,不要填写旧格式7.0.0(26)。
第 2 阶段:工程创建
在目标目录用 Flutter 命令行生成 OHOS 插件模板:
flutter create . --template=plugin --platforms=ohos
该命令自动生成 ohos/ 目录的标准模板结构:
ohos/
├── index.ets # 模块入口,导出插件类
├── oh-package.json5 # 包配置
├── build-profile.json5 # 构建配置
├── src/main/
│ ├── module.json5 # HAR 模块配置
│ └── ets/components/plugin/
│ └── HoldingPlugin.ets # 原生插件实现(核心)
index.ets(入口导出):
import HoldingPlugin from './src/main/ets/components/plugin/HoldingPlugin';
export default HoldingPlugin;
ohos/build-profile.json5(HAR 构建配置,保持模板最小化即可,SDK 级别由宿主工程决定):
{
"apiType": "stageMode",
"buildOption": {
},
"targets": [
{
"name": "default"
}
]
}
注:
@ohos/flutter_ohos由 Flutter 引擎在构建时自动链接,无需在oh-package.json5的dependencies中显式声明。
第 3 阶段:Dart 层接口
按插件通信机制分类,holding 属于事件流型(原生持续推送状态变化,EventChannel + StreamHandler)叠加方法调用型(订阅/取消/查询操作本身,MethodChannel + MethodCallHandler)。Dart 层采用鸿蒙风格回调(success/fail/complete),接口一次成型:
目录结构:
lib/
├── holding.dart # 对外门面类 Holding
├── holding_hand_status.dart # 握持手枚举(since API 20)
├── operating_hand_status.dart # 操作手枚举(since API 15)
├── holding_platform_interface.dart # 平台接口 + 订阅选项类
└── holding_method_channel.dart # 通道实现(核心)
状态枚举(lib/holding_hand_status.dart,operating_hand_status.dart 同构):
/// 与鸿蒙 motion.HoldingHandStatus 对应
enum HoldingHandStatus {
none(0), // 未握持
left(1), // 左手握持
right(2), // 右手握持
both(3), // 双手握持
unknown(16); // 未识别
const HoldingHandStatus(this.code);
/// 鸿蒙系统返回的整型状态值
final int code;
/// 从鸿蒙状态码解析,未定义值映射为 unknown
static HoldingHandStatus fromCode(int code) {
return HoldingHandStatus.values.firstWhere(
(e) => e.code == code,
orElse: () => HoldingHandStatus.unknown,
);
}
}
门面类(lib/holding.dart):
class Holding {
/// 订阅握持手状态变化
void subscribeHoldingHand(SubscribeHoldingHandOptions options) {
HoldingPlatform.instance.subscribeHoldingHand(options);
}
/// 取消订阅握持手状态变化
void unsubscribeHoldingHand([UnsubscribeHoldingHandOptions? options]) {
HoldingPlatform.instance.unsubscribeHoldingHand(options);
}
/// 订阅操作手状态变化(API 26 SDK 扩展能力)
void subscribeOperatingHand(SubscribeOperatingHandOptions options) {
HoldingPlatform.instance.subscribeOperatingHand(options);
}
/// 取消订阅操作手状态变化
void unsubscribeOperatingHand([UnsubscribeOperatingHandOptions? options]) {
HoldingPlatform.instance.unsubscribeOperatingHand(options);
}
/// 查询最近一次操作手状态(API 26 SDK 扩展能力)
void getRecentOperatingHandStatus(
GetRecentOperatingHandStatusOptions options,
) {
HoldingPlatform.instance.getRecentOperatingHandStatus(options);
}
}
订阅选项类(lib/holding_platform_interface.dart,与鸿蒙回调风格对齐):
/// 通用的订阅选项基类(success / fail / complete 回调)
class SubscribeOptionsBase {
const SubscribeOptionsBase({this.success, this.fail, this.complete});
/// 订阅成功回调
final void Function()? success;
/// 订阅失败回调
final void Function(String errMsg)? fail;
/// 完成回调(成功或失败都会调用)
final void Function(String errMsg)? complete;
}
/// 订阅握持手选项:onChange 必填,持续接收状态
class SubscribeHoldingHandOptions extends SubscribeOptionsBase {
const SubscribeHoldingHandOptions({
required this.onChange,
super.success,
super.fail,
super.complete,
});
final HoldingHandChangeCallback onChange;
}
/// 取消订阅选项(可整体省略)
typedef UnsubscribeHoldingHandOptions = SubscribeOptionsBase;
/// 查询最近操作手状态选项:onSuccess 必填,携带状态返回
class GetRecentOperatingHandStatusOptions {
const GetRecentOperatingHandStatusOptions({
required this.onSuccess,
this.fail,
this.complete,
});
final void Function(OperatingHandStatus status) onSuccess;
final void Function(String errMsg)? fail;
final void Function(String errMsg)? complete;
}
SubscribeOperatingHandOptions/UnsubscribeOperatingHandOptions与握持手版本同构。
通道实现(lib/holding_method_channel.dart,核心策略:先建事件流监听再发起原生订阅,减少首个事件丢失;同一能力只维护一条事件订阅):
class MethodChannelHolding extends HoldingPlatform {
final methodChannel = const MethodChannel('holding');
final holdingEventChannel = const EventChannel('holding/events');
final operatingEventChannel = const EventChannel('holding/operating/events');
StreamSubscription<dynamic>? _holdingEventSubscription;
SubscribeHoldingHandOptions? _currentHoldingOptions;
StreamSubscription<dynamic>? _operatingEventSubscription;
SubscribeOperatingHandOptions? _currentOperatingOptions;
// ---------- 通用工具方法 ----------
/// 从错误对象中提取消息
String _errorMessage(Object error, String fallback) {
return error is PlatformException
? error.message ?? fallback
: error.toString();
}
/// 触发 success + complete 回调
void _notifySuccess(SubscribeOptionsBase? opts, String okMsg) {
opts?.success?.call();
opts?.complete?.call(okMsg);
}
/// 触发 fail + complete 回调
void _notifyFail(SubscribeOptionsBase? opts, String errMsg) {
opts?.fail?.call(errMsg);
opts?.complete?.call(errMsg);
}
// ---------- 握持手 ----------
void subscribeHoldingHand(SubscribeHoldingHandOptions options) {
_currentHoldingOptions = options;
_holdingEventSubscription ??= holdingEventChannel
.receiveBroadcastStream()
.listen(
(dynamic event) {
final status = event is int
? HoldingHandStatus.fromCode(event)
: HoldingHandStatus.unknown;
_currentHoldingOptions?.onChange(status);
},
onError: (Object error, StackTrace? stackTrace) {
_notifyFail(
options,
_errorMessage(error, 'subscribeHoldingHand:fail'),
);
},
);
methodChannel
.invokeMethod<void>('subscribeHoldingHand')
.then((_) => _notifySuccess(options, 'subscribeHoldingHand:ok'))
.catchError((Object error, StackTrace stackTrace) {
_notifyFail(
options,
_errorMessage(error, 'subscribeHoldingHand:fail'),
);
});
}
void unsubscribeHoldingHand([UnsubscribeHoldingHandOptions? options]) {
methodChannel
.invokeMethod<void>('unsubscribeHoldingHand')
.then((_) {
_holdingEventSubscription?.cancel();
_holdingEventSubscription = null;
_currentHoldingOptions = null;
_notifySuccess(options, 'unsubscribeHoldingHand:ok');
})
.catchError((Object error, StackTrace stackTrace) {
_notifyFail(
options,
_errorMessage(error, 'unsubscribeHoldingHand:fail'),
);
});
}
// ---------- 操作手(API 26 SDK 扩展能力,与握持手同构) ----------
void subscribeOperatingHand(SubscribeOperatingHandOptions options) {
_currentOperatingOptions = options;
_operatingEventSubscription ??= operatingEventChannel
.receiveBroadcastStream()
.listen(
(dynamic event) {
final status = event is int
? OperatingHandStatus.fromCode(event)
: OperatingHandStatus.unknown;
_currentOperatingOptions?.onChange(status);
},
onError: (Object error, StackTrace? stackTrace) {
_notifyFail(
options,
_errorMessage(error, 'subscribeOperatingHand:fail'),
);
},
);
methodChannel
.invokeMethod<void>('subscribeOperatingHand')
.then((_) => _notifySuccess(options, 'subscribeOperatingHand:ok'))
.catchError((Object error, StackTrace stackTrace) {
_notifyFail(
options,
_errorMessage(error, 'subscribeOperatingHand:fail'),
);
});
}
void unsubscribeOperatingHand([UnsubscribeOperatingHandOptions? options]) {
methodChannel
.invokeMethod<void>('unsubscribeOperatingHand')
.then((_) {
_operatingEventSubscription?.cancel();
_operatingEventSubscription = null;
_currentOperatingOptions = null;
_notifySuccess(options, 'unsubscribeOperatingHand:ok');
})
.catchError((Object error, StackTrace stackTrace) {
_notifyFail(
options,
_errorMessage(error, 'unsubscribeOperatingHand:fail'),
);
});
}
void getRecentOperatingHandStatus(
GetRecentOperatingHandStatusOptions options,
) {
methodChannel
.invokeMethod<int>('getRecentOperatingHandStatus')
.then((code) {
options.onSuccess(
code is int
? OperatingHandStatus.fromCode(code)
: OperatingHandStatus.unknown,
);
options.complete?.call('getRecentOperatingHandStatus:ok');
})
.catchError((Object error, StackTrace stackTrace) {
final errMsg = _errorMessage(
error,
'getRecentOperatingHandStatus:fail',
);
options.fail?.call(errMsg);
options.complete?.call(errMsg);
});
}
}
第 4 阶段:原生实现(ArkTS 桥接鸿蒙 Kit)
这是适配的核心工作:在
onAttachedToEngine里注册一条MethodChannel与两条EventChannel,把@kit.MultimodalAwarenessKit的motion.on/motion.off/motion.getRecentOperatingHandStatus()桥接到 Flutter 侧。
完整实现(ohos/src/main/ets/components/plugin/HoldingPlugin.ets):
import {
FlutterPlugin,
FlutterPluginBinding,
MethodCall,
MethodCallHandler,
MethodChannel,
MethodResult,
EventChannel,
EventSink,
} from '@ohos/flutter_ohos';
import { motion } from '@kit.MultimodalAwarenessKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
const TAG = 'HoldingPlugin';
export default class HoldingPlugin implements FlutterPlugin, MethodCallHandler {
private channel: MethodChannel | null = null;
private holdingEventChannel: EventChannel | null = null;
private holdingEventSink: EventSink | null = null;
private holdingCallback: ((data: motion.HoldingHandStatus) => void) | null = null;
private operatingEventChannel: EventChannel | null = null;
private operatingEventSink: EventSink | null = null;
private operatingCallback: ((data: motion.OperatingHandStatus) => void) | null = null;
getUniqueClassName(): string {
return "HoldingPlugin"
}
onAttachedToEngine(binding: FlutterPluginBinding): void {
this.channel = new MethodChannel(binding.getBinaryMessenger(), "holding");
this.channel.setMethodCallHandler(this);
this.holdingEventChannel = new EventChannel(binding.getBinaryMessenger(), "holding/events");
this.holdingEventChannel.setStreamHandler({
onListen: (_args: Object, eventSink: EventSink): void => {
this.holdingEventSink = eventSink;
},
onCancel: (_args: Object): void => {
this.stopHoldingListening();
this.holdingEventSink = null;
}
});
this.operatingEventChannel = new EventChannel(binding.getBinaryMessenger(), "holding/operating/events");
this.operatingEventChannel.setStreamHandler({
onListen: (_args: Object, eventSink: EventSink): void => {
this.operatingEventSink = eventSink;
},
onCancel: (_args: Object): void => {
this.stopOperatingListening();
this.operatingEventSink = null;
}
});
}
onDetachedFromEngine(_binding: FlutterPluginBinding): void {
this.stopHoldingListening();
this.holdingEventSink = null;
this.stopOperatingListening();
this.operatingEventSink = null;
if (this.channel != null) {
this.channel.setMethodCallHandler(null);
this.channel = null;
}
if (this.holdingEventChannel != null) {
this.holdingEventChannel.setStreamHandler(null);
this.holdingEventChannel = null;
}
if (this.operatingEventChannel != null) {
this.operatingEventChannel.setStreamHandler(null);
this.operatingEventChannel = null;
}
}
private safeInvoke(result: MethodResult, label: string, action: () => void): void {
try {
action();
result.success(null);
} catch (err) {
const error = err as BusinessError;
hilog.error(0, TAG, `${label}失败: code=${error.code}, message=${error.message}`);
result.error(String(error.code ?? -1), error.message ?? `${label}:fail`, null);
}
}
private startHoldingListening(): void {
if (this.holdingCallback != null) return;
this.holdingCallback = (data: motion.HoldingHandStatus) => {
hilog.info(0, TAG, `握持手状态变化: ${data}`);
if (this.holdingEventSink != null) {
this.holdingEventSink.success(data as number);
}
};
motion.on('holdingHandChanged', this.holdingCallback);
hilog.info(0, TAG, '订阅握持手状态成功');
}
private stopHoldingListening(): void {
if (this.holdingCallback == null) return;
motion.off('holdingHandChanged');
this.holdingCallback = null;
hilog.info(0, TAG, '取消订阅握持手状态成功');
}
private startOperatingListening(): void {
if (this.operatingCallback != null) return;
this.operatingCallback = (data: motion.OperatingHandStatus) => {
hilog.info(0, TAG, `操作手状态变化: ${data}`);
if (this.operatingEventSink != null) {
this.operatingEventSink.success(data as number);
}
};
motion.on('operatingHandChanged', this.operatingCallback);
hilog.info(0, TAG, '订阅操作手状态成功');
}
private stopOperatingListening(): void {
if (this.operatingCallback == null) return;
motion.off('operatingHandChanged');
this.operatingCallback = null;
hilog.info(0, TAG, '取消订阅操作手状态成功');
}
onMethodCall(call: MethodCall, result: MethodResult): void {
switch (call.method) {
case "subscribeHoldingHand":
this.safeInvoke(result, '订阅握持手状态', () => this.startHoldingListening());
break;
case "unsubscribeHoldingHand":
this.safeInvoke(result, '取消订阅握持手状态', () => this.stopHoldingListening());
break;
case "subscribeOperatingHand":
this.safeInvoke(result, '订阅操作手状态', () => this.startOperatingListening());
break;
case "unsubscribeOperatingHand":
this.safeInvoke(result, '取消订阅操作手状态', () => this.stopOperatingListening());
break;
case "getRecentOperatingHandStatus":
try {
const status = motion.getRecentOperatingHandStatus() as number;
result.success(status);
} catch (err) {
const error = err as BusinessError;
hilog.error(0, TAG, `查询操作手状态失败: code=${error.code}, message=${error.message}`);
result.error(String(error.code ?? -1), error.message ?? 'getRecentOperatingHandStatus:fail', null);
}
break;
default:
result.notImplemented();
break;
}
}
}
StreamHandler 生命周期对应关系(事件流型插件的核心契约):
| 时机 | 插件侧动作 |
|---|---|
| 插件绑定引擎 | onAttachedToEngine 创建 EventChannel + setStreamHandler |
| Dart 开始订阅 | onListen → 持有 EventSink(此时原生事件到达即可直推) |
| 事件到达 | 系统回调中 eventSink.success(整型状态) → Dart onChange |
| Dart 取消订阅 | onCancel → motion.off 注销系统监听 + 释放 EventSink |
| 插件解绑引擎 | onDetachedFromEngine → 兜底 motion.off + setStreamHandler(null) |
防御点:
startXxxListening幂等(回调非空即 return),重复订阅不会注册第二条系统监听;onCancel与onDetachedFromEngine双路径兜底注销,避免 Flutter 引擎解绑后系统监听泄漏。
第 5 阶段:API 26 SDK 能力扩展(对照官方 .d.ts 做增量升级)
"结合最新鸿蒙 SDK 做升级"不能靠猜——正确做法是 直接对照已安装 SDK 的类型声明做 API 差异分析。打开 API 26 SDK 中的 @ohos.multimodalAwareness.motion.d.ts(位于 <SDK>/ets/api/),与插件已覆盖能力逐项对照:
API 26 SDK 中的 motion API | since | 插件适配前 | 适配决策 |
|---|---|---|---|
on/off('operatingHandChanged') + OperatingHandStatus(0/1/2) | 15 | ❌ 未覆盖 | ✅ 纳入:新增操作手订阅/取消 + Dart 枚举 |
getRecentOperatingHandStatus() | 15 | ❌ 未覆盖 | ✅ 纳入:新增查询型 API(同步查询 → MethodChannel 异步回调) |
on/off('holdingHandChanged') + HoldingHandStatus(0/1/2/3/16) | 20 | ✅ 已覆盖 | 保持 |
on/off('operatingHandChanged') 权限要求 since 20 放宽为 ACTIVITY_MOTION 或 DETECT_GESTURE | 15→20 | ✅ 两个权限均已声明 | 保持 |
两个 since 版本要点:
holdingHandChanged是 since 20 API,依赖ohos.permission.DETECT_GESTURE;operatingHandChanged与getRecentOperatingHandStatus是 since 15 API,since 20 起权限放宽为ACTIVITY_MOTION或DETECT_GESTURE——插件 HAR 同时声明两者,宿主任选其一即可。
API 26 升级带来的版本下限变化(重要决策,详见第五章决策 3):新版 Flutter OH SDK 构建产出的 @ohos/flutter_ohos HAR 要求宿主 compatibleSdkVersion >= 26,因此插件构建配置的最低兼容版本从 5.1.0(18) 上调至 26.0.0。
第 6 阶段:插件注册与示例验证
插件注册(pubspec.yaml):
name: holding
description: Flutter 握持手感知插件,支持 HarmonyOS 与 OpenHarmony,订阅握持手与操作手状态变化,并可查询最近操作手状态。
version: 0.0.2
homepage: https://atomgit.com/oh-flutter/holding-harmony
repository: https://atomgit.com/oh-flutter/holding-harmony
issue_tracker: https://atomgit.com/oh-flutter/holding-harmony/issues
documentation: https://atomgit.com/oh-flutter/holding-harmony
license: Apache-2.0
environment:
sdk: ^3.12.2
flutter: ">=3.44.9"
flutter:
plugin:
platforms:
ohos: # ← 对应 index.ets 默认导出
pluginClass: HoldingPlugin
仓库地址统一为 AtomGit:
homepage/repository/issue_tracker/documentation四个元数据字段全部指向atomgit.com/oh-flutter/holding-harmony,保证 pub.dev 包页面上的链接与代码托管一致。
示例宿主工程(在 example/ 目录而非插件根目录执行):
cd example
flutter create . --platforms=ohos
关键配置(example/ohos/build-profile.json5):
{
"app": {
"products": [
{
"name": "default",
"compatibleSdkVersion": "26.0.0",
"compileSdkVersion": "26.0.0",
"targetSdkVersion": "26.0.0",
"runtimeOS": "HarmonyOS",
}
],
},
}
权限声明(example/ohos/entry/src/main/module.json5,reason 必须是 $string 资源引用,usedScene 必填):
"requestPermissions": [
{ "name": "ohos.permission.INTERNET" },
{
"name": "ohos.permission.DETECT_GESTURE",
"reason": "$string:detect_gesture_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
},
{
"name": "ohos.permission.ACTIVITY_MOTION",
"reason": "$string:activity_motion_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
}
]
示例 UI 提供"握持手/操作手"两张状态卡片,各带订阅/取消按钮,操作手额外提供"查询最近操作手状态"按钮,状态变化记录到日志列表,便于真机观察(完整代码见 example/lib/main.dart)。
四、完整代码对照
4.1 通道契约总表
| 层 | 名称 | 方法 / 内容 | 对应系统 API |
|---|---|---|---|
| MethodChannel | holding | subscribeHoldingHand / unsubscribeHoldingHand | motion.on / motion.off('holdingHandChanged') |
| MethodChannel | holding | subscribeOperatingHand / unsubscribeOperatingHand | motion.on / motion.off('operatingHandChanged') |
| MethodChannel | holding | getRecentOperatingHandStatus | motion.getRecentOperatingHandStatus() |
| EventChannel | holding/events | 鸿蒙 motion.HoldingHandStatus 整数值(0/1/2/3/16) | holdingHandChanged 回调参数 |
| EventChannel | holding/operating/events | 鸿蒙 motion.OperatingHandStatus 整数值(0/1/2) | operatingHandChanged 回调参数 |
通道名是 Dart 与原生之间的通信契约,两侧必须完全一致;Dart 层通道名集中在
MethodChannelHolding的@visibleForTesting字段,原生层集中在HoldingPlugin.onAttachedToEngine,各只有一处定义。
4.2 鸿蒙系统 API 与 Dart 枚举对照
鸿蒙 motion.HoldingHandStatus | 系统码 | Dart HoldingHandStatus |
|---|---|---|
NOT_HELD | 0 | none |
LEFT_HAND_HELD | 1 | left |
RIGHT_HAND_HELD | 2 | right |
BOTH_HANDS_HELD | 3 | both |
UNKNOWN_STATUS | 16 | unknown |
鸿蒙 motion.OperatingHandStatus | 系统码 | Dart OperatingHandStatus |
|---|---|---|
UNKNOWN_STATUS | 0 | unknown |
LEFT_HAND_OPERATED | 1 | left |
RIGHT_HAND_OPERATED | 2 | right |
4.3 关键 ArkTS 语法差异(面向 Android/iOS 插件作者)
| Kotlin/Java 习惯 | ArkTS 写法 | 备注 |
|---|---|---|
import io.flutter.embedding.engine.FlutterEngine | import { ... } from '@ohos/flutter_ohos' | 鸿蒙使用模块化导入,引擎包名固定 |
MethodChannel(messenger, name) | new MethodChannel(binding.getBinaryMessenger(), name) | 构造参数一致;messenger 从 FlutterPluginBinding 获取 |
EventChannel.StreamHandler | setStreamHandler({ onListen, onCancel }) | 鸿蒙用对象字面量实现接口,无需单独声明类 |
eventSink.success(int) | this.eventSink?.success(data as number) | 鸿蒙枚举需 as number 显式转整型 |
try/catch (e: Exception) | catch (err) { const error = err as BusinessError } | ArkTS catch 参数无类型标注,需手动断言 BusinessError 取 code/message |
| 系统监听器注册 | motion.on('xxx', callback) | Kit API 直接传回调,无监听器对象 |
错误码透传约定:原生异常时
result.error(String(error.code ?? -1), error.message ?? '${label}:fail', null),把鸿蒙BusinessError.code(如 201 权限拒绝、801 能力不支持、31500002 订阅失败)原样透传给 Dart 层fail回调,便于宿主应用针对错误码降级。
五、关键决策说明
决策 1:保持鸿蒙风格回调,而非 Future
插件接口采用鸿蒙 Kit 风格的 success / fail / complete 回调(而非 Future<T>),onChange 必填持续接收状态。理由:目标用户是熟悉鸿蒙 API 的开发者,回调签名与 motion.on 的使用心智一致;且事件流是持续推送,Future 只能表达一次性结果,无法承载"订阅"语义。
维护策略:接口一经发布不再变更签名;后续扩展(如操作手查询)复用 success/fail/complete 心智,但查询类用携带状态的 onSuccess(GetRecentOperatingHandStatusOptions),避免与订阅类的无参 success 混淆。
决策 2:先建事件流再发原生订阅
subscribeXxxHand 中 Dart 侧先 receiveBroadcastStream().listen(...) 建立事件流监听,再通过 MethodChannel 通知原生订阅。理由:若原生先订阅、Dart 事件流晚一步建立,首个系统事件可能丢失(握持状态切换很快);??= 保证同一能力只建一条事件订阅,重复订阅只更新 _currentXxxOptions,把后续事件交给最近一次传入的 onChange。
维护策略:新增事件流型能力时复制该模式;取消订阅必须走"原生 off 成功后才 cancel 事件流"的顺序,取消失败保留监听状态。
决策 3:compatibleSdkVersion 从 18 上调至 26
最初构建配置把 compatibleSdkVersion 设为 5.1.0(18)(旧格式,最低兼容 API 18),但 flutter build hap 报错:新版 Flutter OH SDK 产出的 @ohos/flutter_ohos HAR 的最低兼容版本高于 18,宿主必须上调。选择把 compatibleSdkVersion 统一为 26.0.0(新格式,与 compile/target 一致)而非给依赖 HAR 降级——引擎 HAR 无法由插件侧修改,且真机(Mate 60 Pro,ROM 7.0.0.111,API 26)恰好满足。
维护策略:插件 HAR 本身不锁定 SDK 级别(ohos/build-profile.json5 保持模板最小化),下限由 Flutter OH 引擎决定并随版本演进;README 的"约束与限制"章节必须随每次 SDK 对齐更新下限说明,避免用户按旧下限选型。
决策 4:查询型 API 用携带状态的 onSuccess
getRecentOperatingHandStatus 是"同步查询"语义(原生 motion.getRecentOperatingHandStatus() 同步返回),桥接到 Dart 后通过 invokeMethod<int> 返回状态码。选择扩展 GetRecentOperatingHandStatusOptions 并用必填 onSuccess(OperatingHandStatus) 承载结果,fail/complete 可选。理由:保持"零 Future"接口风格统一,同时与订阅类的无参 success 区分开。
维护策略:后续若有其他查询型 API(如能力检测),复用 onXxx 携带数据的命名约定。
决策 5:插件不主动请求运行时授权
DETECT_GESTURE / ACTIVITY_MOTION 由插件 HAR 声明,但插件不调用运行时授权弹窗——授权由宿主应用按目标系统权限规则完成(module.json5 声明 reason + usedScene,when: inuse)。理由:授权策略属于应用交互设计,插件层请求弹窗会与宿主的权限编排冲突。
维护策略:README 明确提示宿主需自行声明权限并给出示例配置链接;错误码 201(权限拒绝)在文档中对照说明。
决策 6:双平台文档与双 CHANGELOG
仓库同时维护 README.md(主文档,含开发环境/权限配置/运行示例/排查清单)与 README.OpenHarmony(_CN).md(OpenHarmony 适配文档,含下载安装/接口说明/遗留问题),以及 CHANGELOG.md 与 CHANGELOG.OpenHarmony.md。理由:OpenHarmony 三方库生态的惯例(见 oh-tpc 社区规范),适配文档独立成册便于社区巡检与用户按需阅读;发版说明双记录保证 pub.dev 与社区清单同步。
维护策略:每次发版同时更新四份文档;所有仓库地址统一 AtomGit,链接与 pubspec.yaml 元数据保持一致。
六、测试与验证
6.1 测试环境
| 项目 | 版本 |
|---|---|
| Flutter | 3.47.9-ohos-1.0.0(oh-3.47-dev) |
| Dart | 3.13.4 |
| HarmonyOS SDK | 26.0.0(API 26) |
| IDE | DevEco Studio 26.0.0 |
| 设备 ROM | OpenHarmony-7.0.0.111(API 26) |
| 真机 | HUAWEI Mate 60 Pro(ALN-AL00) |
6.2 验证要点
- 静态检查 —
flutter analyze无错误(接口扩展、示例 UI、测试全部覆盖); - 单元测试 —
test/holding_method_channel_test.dart覆盖通道名契约、getRecentOperatingHandStatus状态码映射(1→left / 99→unknown)、错误码透传(PlatformException→fail+complete)、取消订阅成功回调;test/holding_test.dart验证默认平台实例; - 构建验证 —
flutter build hap --debug成功产出签名 HAP(auto-signing 自动写入签名配置); - 真机安装 —
hdc install成功,aa start启动,进程稳定运行; - 真机事件流 — hilog 观察到真实状态事件:
握持手状态变化: 3(双手)→2(右手)、操作手状态变化: 0(未识别)→1(左手)→0,原生EventSink直推 Dart 的链路完整工作; - 插件注册 —
GeneratedPluginRegistrant.ets由构建自动生成并注册HoldingPlugin。
6.3 真机验证命令速查
# 构建(自动签名)
cd example && flutter build hap --debug
# 安装
hdc -t <device-id> install build/ohos/hap/entry-default-signed.hap
# 启动
hdc -t <device-id> shell aa start -b com.nutpi.holding_example -a EntryAbility
# 观察插件日志
hdc -t <device-id> shell "hilog -x | grep -i HoldingPlugin"
# 截图(真机运行效果)
hdc -t <device-id> shell "snapshot_display -f /data/local/tmp/holding_example.jpeg"
hdc -t <device-id> file recv /data/local/tmp/holding_example.jpeg doc/assets/holding_example.jpeg
测试状态说明:本机 Flutter OH SDK(oh-3.47.9-ohos-1.0.0)的
flutter test存在 “Can’t load Kernel binary: Invalid SDK hash” 的内核加载回归(多个工程同样挂起,且伴随其他工程遗留的flutter_tester孤儿进程干扰),Dart 层验证以flutter analyze+ 真机构建运行为准;该问题属于引擎/工具链层,建议向上游 atomgit.com/oh-flutter/flutter_flutter 反馈。
七、运行效果
真机运行截图(握持手/操作手双卡片 + 状态日志):

真机 hilog 事件流(订阅后状态实时上报):
11-10 09:20:41.700 I HoldingPlugin: 握持手状态变化: 3
11-10 09:20:43.739 I HoldingPlugin: 握持手状态变化: 2
11-10 09:20:44.702 I HoldingPlugin: 操作手状态变化: 0
11-10 09:20:46.707 I HoldingPlugin: 操作手状态变化: 1
11-10 09:20:53.920 I HoldingPlugin: 操作手状态变化: 0
八、踩坑复盘
8.1 踩坑记录
| 踩坑点 | 现象 / 报错 | 根因与解法 |
|---|---|---|
flutter clean 工具链崩溃 | Oops; flutter has exited unexpectedly: "type 'Null' is not a subtype of type 'List<dynamic>' in type cast",堆栈指向 OhosModule.fromOhosProject(flutter_tools/src/ohos/application_package.dart:252),同时留下 flutter_01.log | Flutter OH 工具链(oh-3.47-dev)在本插件工程上解析 ohos 构建数据的空指针 bug。解法:跳过 flutter clean,手动删除可再生缓存 rm -rf .dart_tool build 后重新 flutter pub get;该问题建议向上游 atomgit.com/oh-flutter/flutter_flutter 反馈 |
flutter test 内核加载失败 | Shell: [ERROR] Dart Error: Can't load Kernel binary: Invalid SDK hash.,随后 Could not prepare isolate,测试无限挂起 | 引擎 flutter_tester 与测试内核 SDK hash 不匹配的工具链回归(多工程复现);且其他工程(speechrecognizer/gesturesshare)遗留的 flutter_tester 孤儿进程(最长挂 36 小时)会干扰诊断。解法:pgrep -fl "flutter_tester|dartvm" 找出孤儿进程并清理,改用 flutter analyze(静态验证)+ 真机构建运行(行为验证)替代单测运行 |
compatibleSdkVersion 过低 | hvigor 报错 The project's compatibleSdkVersion: 18 cannot be lower than the minimum compatible version required by the dependencies: @ohos/flutter_ohos,构建 6 秒即失败 | 新版 Flutter OH SDK 产出的引擎 HAR 最低兼容版本为 26,宿主 5.1.0(18) 不满足。解法:example/ohos/build-profile.json5 的 compatibleSdkVersion 升为 26.0.0,同步更新所有 README 的"约束与限制"(最低兼容 API 18 → 26) |
| SDK 版本字段格式混淆 | 在 compileSdkVersion/targetSdkVersion 填写旧格式 7.0.0(26) 会导致 hvigor 无法识别 | 新版 DevEco/Hvigor 约定使用 26.0.0 数值格式;旧格式仅适用于 API 18 及以前的 x.y.z(n) 写法 |
权限 reason schema 校验失败 | module.json5 的 requestPermissions[].reason 写纯文本时报 schema 校验错 | reason 必须是 $string:xxx 资源引用(或含 {} 的占位符),usedScene 必填 abilities + when;字符串资源放在 entry 模块 resources/base/element/string.json |
| Pub 镜像环境变量前缀失效 | PUB_HOSTED_URL=https://pub.dev yes y | flutter pub publish 仍发布到 pub.flutter-io.cn 并报 package repository requested authentication | shell 管道中 VAR=x 前缀只作用于管道左侧第一条命令,flutter 在右侧拿不到变量。解法:先 export PUB_HOSTED_URL=https://pub.dev 再执行发布;发布必须指向 pub.dev,国内下载镜像(flutter-io.cn)不支持发布 API |
| 交互式 y/N 确认未应答 | 发布命令在非交互 shell 中报 Package upload canceled. | flutter pub publish 的最终确认 y/N 在非交互环境直接取消。解法:yes y | flutter pub publish 显式应答;首次向 pub.dev 发布会等待浏览器 OAuth 授权,需在浏览器完成登录 |
8.2 高频坑速查(鸿蒙插件开发)
module.json5权限reason字段 schema 校验失败 → 必须是$string:xxx资源引用,不能写纯文本;- ArkTS 编译报
Property 'xxx' does not exist→ 对照已安装 SDK 的.d.ts确认字段名,不要凭记忆写 API;- 真机"不生效" → 先查
hilog是否有插件日志(本文用HoldingPluginTAG),有日志无事件是设备能力问题(holdingHandChanged依赖硬件与系统能力,API 满足不保证产生事件),无日志先查GeneratedPluginRegistrant是否注册插件;- 事件流型插件注意
onCancel兜底注销系统监听,否则引擎解绑后系统回调仍指向已释放的EventSink。
8.3 已知问题
- 仅支持 ohos 平台 — Android/iOS/Web 没有原生实现,其他平台调用抛
UnimplementedError(预期行为,文档已注明); - 事件产生依赖设备能力 —
holdingHandChanged需要DETECT_GESTURE权限且设备硬件支持握持感知,订阅成功不代表一定有事件; - 共享平台订阅 — 多个
Holding实例共享同一平台订阅,不支持各页面独立持有原生监听,建议服务层集中订阅后分发。
8.4 未来优化
- Android/iOS 平台实现 — 操作手状态可基于 Android 的手势检测近似模拟(握持手无等价能力,需声明能力差异);
- 能力检测 API — 新增
isHoldingSupported(),订阅前检测设备是否具备系统能力(错误码 801 提前暴露); - 单位测试 CI — 引擎层 “Invalid SDK hash” 回归修复后,把
flutter test纳入流水线门禁。
九、发布与总结
9.1 发布到 pub.dev
版本准备就绪后(pubspec.yaml 的 version 已升为 0.0.2、四份文档已更新、flutter analyze 通过),执行发布:
# 1. 发布前校验(dry-run,不实际上传)
flutter pub publish --dry-run
# 2. 正式发布:显式指向 pub.dev(国内下载镜像不支持发布 API)
export PUB_HOSTED_URL=https://pub.dev
yes y | flutter pub publish
发布要点:
- dry-run 先行:校验包元数据、文件清单、license 与文档齐备性;本文 dry-run 剩余 2 个良性警告(未提交的 git 变更提醒、
CHANGELOG.OpenHarmony.md改名建议),不阻塞发布; - 发布目标必须是 pub.dev:环境变量
PUB_HOSTED_URL若指向pub.flutter-io.cn等下载镜像,发布 API 会报鉴权失败;用export显式覆盖(注意 shell 管道中VAR=x前缀只作用于左侧命令,见第八章踩坑); - OAuth 授权:首次向 pub.dev 发布会等待浏览器登录授权,完成一次后凭证缓存,后续发布无需重复;
- 发版纪律:“Publishing is forever; packages cannot be unpublished”——发布不可撤回,务必 dry-run 通过、真机验证完成后再执行;
- 发版同步:
version升级需同时更新CHANGELOG.md与CHANGELOG.OpenHarmony.md,pub.dev 包页面会渲染README.md与CHANGELOG.md。
发布成功后,用户即可通过 Pub 依赖使用(含 Flutter 3.47.9/API 26 构建配置):
dependencies:
holding: ^0.0.2
9.2 总结
从 0 到 1 实现一个鸿蒙感知类 Flutter 三方库,核心路径可以概括为 三步走:
1. 找对应 ── 对照鸿蒙 Kit 的 .d.ts 找到系统 API 与 Dart 接口的一一映射
(本文:motion.on/off/getRecentOperatingHandStatus → 订阅/取消/查询)
2. 保契约 ── 通道名、方法名、错误码透传完全一致;先建事件流再发原生订阅
3. 补缺口 ── 引擎 HAR 决定 compatibleSdkVersion 下限时跟随上调;无等价能力的平台明确声明
对于 holding 三方库,本次工作涉及:
- API 26 升级:
compatibleSdkVersion/compileSdkVersion/targetSdkVersion统一26.0.0,验证环境 Flutter 3.47.9-ohos-1.0.0 + Dart 3.13.4 + DevEco Studio 26.0.0; - 能力扩展:对照 API 26 SDK 纳入
operatingHandChanged(订阅/取消)与getRecentOperatingHandStatus(查询),新增 1 个 Dart 枚举、5 个平台接口方法、2 条通道、1 个查询选项类,测试/示例/四份文档同步更新; - 真机验证:Mate 60 Pro(API 26)构建、安装、运行,hilog 观察到真实握持手与操作手事件流;
- 发布:dry-run 校验后发布
holding 0.0.2到 pub.dev,仓库地址全部统一 AtomGit。
Dart 接口一次成型、扩展只做增量不破坏契约——这正是 Flutter 跨平台三方库生态的魅力所在。希望这篇从 0 到 1 的完整记录,能帮助你在鸿蒙上复刻任意一个感知类插件的开发。
参考文档
插件与仓库(AtomGit)
- holding 源码仓库(oh-flutter/holding-harmony)
- Flutter OH 引擎与框架(oh-flutter/flutter_flutter,oh-3.47-dev 分支)
- Flutter OH 三方库适配系列教程(本仓库 doc/00~05)
鸿蒙系统 API(MultimodalAwarenessKit)
Flutter 生态与发布
开源协议:本项目基于 Apache License 2.0 开源。
更多推荐



所有评论(0)