本文记录了从 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 阶段:环境搭建

本文验证时使用的环境(按版本获取方式可自查):

项目版本获取方式
Flutter3.47.9-ohos-1.0.0(oh-3.47-dev 渠道)flutter --version
Dart3.13.4flutter --version
HarmonyOS SDK26.0.0(API 26)~/Library/OpenHarmony/Sdk/ 目录名,或 DevEco 内 SDK 管理器
IDEDevEco Studio 26.0.0defaults read /Applications/DevEco-Studio.app/Contents/Info.plist CFBundleShortVersionString
真机HUAWEI Mate 60 Pro(ALN-AL00),ROM 7.0.0.111(SP10C00E111R4P3),OpenHarmony API 26hdc 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 APIsince插件适配前适配决策
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_GESTURE15→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
MethodChannelholdingsubscribeHoldingHand / unsubscribeHoldingHandmotion.on / motion.off('holdingHandChanged')
MethodChannelholdingsubscribeOperatingHand / unsubscribeOperatingHandmotion.on / motion.off('operatingHandChanged')
MethodChannelholdinggetRecentOperatingHandStatusmotion.getRecentOperatingHandStatus()
EventChannelholding/events鸿蒙 motion.HoldingHandStatus 整数值(0/1/2/3/16)holdingHandChanged 回调参数
EventChannelholding/operating/events鸿蒙 motion.OperatingHandStatus 整数值(0/1/2)operatingHandChanged 回调参数

通道名是 Dart 与原生之间的通信契约,两侧必须完全一致;Dart 层通道名集中在 MethodChannelHolding 的 @visibleForTesting 字段,原生层集中在 HoldingPlugin.onAttachedToEngine,各只有一处定义。

4.2 鸿蒙系统 API 与 Dart 枚举对照

鸿蒙 motion.HoldingHandStatus系统码Dart HoldingHandStatus
NOT_HELD0none
LEFT_HAND_HELD1left
RIGHT_HAND_HELD2right
BOTH_HANDS_HELD3both
UNKNOWN_STATUS16unknown
鸿蒙 motion.OperatingHandStatus系统码Dart OperatingHandStatus
UNKNOWN_STATUS0unknown
LEFT_HAND_OPERATED1left
RIGHT_HAND_OPERATED2right

4.3 关键 ArkTS 语法差异(面向 Android/iOS 插件作者)

Kotlin/Java 习惯ArkTS 写法备注
import io.flutter.embedding.engine.FlutterEngineimport { ... } from '@ohos/flutter_ohos'鸿蒙使用模块化导入,引擎包名固定
MethodChannel(messenger, name)new MethodChannel(binding.getBinaryMessenger(), name)构造参数一致;messenger 从 FlutterPluginBinding 获取
EventChannel.StreamHandlersetStreamHandler({ 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 测试环境

项目版本
Flutter3.47.9-ohos-1.0.0(oh-3.47-dev)
Dart3.13.4
HarmonyOS SDK26.0.0(API 26)
IDEDevEco Studio 26.0.0
设备 ROMOpenHarmony-7.0.0.111(API 26)
真机HUAWEI Mate 60 Pro(ALN-AL00)

6.2 验证要点

  1. 静态检查 — flutter analyze 无错误(接口扩展、示例 UI、测试全部覆盖);
  2. 单元测试 — test/holding_method_channel_test.dart 覆盖通道名契约、getRecentOperatingHandStatus 状态码映射(1→left / 99→unknown)、错误码透传(PlatformException → fail + complete)、取消订阅成功回调;test/holding_test.dart 验证默认平台实例;
  3. 构建验证 — flutter build hap --debug 成功产出签名 HAP(auto-signing 自动写入签名配置);
  4. 真机安装 — hdc install 成功,aa start 启动,进程稳定运行;
  5. 真机事件流 — hilog 观察到真实状态事件:握持手状态变化: 3(双手)→ 2(右手)、操作手状态变化: 0(未识别)→ 1(左手)→ 0,原生 EventSink 直推 Dart 的链路完整工作;
  6. 插件注册 — 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 反馈。


七、运行效果

真机运行截图(握持手/操作手双卡片 + 状态日志):

Mate 60 Pro 真机运行效果

真机 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.logFlutter 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 authenticationshell 管道中 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 是否有插件日志(本文用 HoldingPlugin TAG),有日志无事件是设备能力问题(holdingHandChanged 依赖硬件与系统能力,API 满足不保证产生事件),无日志先查 GeneratedPluginRegistrant 是否注册插件;
  • 事件流型插件注意 onCancel 兜底注销系统监听,否则引擎解绑后系统回调仍指向已释放的 EventSink。

8.3 已知问题

  1. 仅支持 ohos 平台 — Android/iOS/Web 没有原生实现,其他平台调用抛 UnimplementedError(预期行为,文档已注明);
  2. 事件产生依赖设备能力 — holdingHandChanged 需要 DETECT_GESTURE 权限且设备硬件支持握持感知,订阅成功不代表一定有事件;
  3. 共享平台订阅 — 多个 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)

鸿蒙系统 API(MultimodalAwarenessKit)

Flutter 生态与发布

开源协议:本项目基于 Apache License 2.0 开源。


Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐