AI工具 码道 推荐: https://developer.huaweicloud.com/codeartsco.html?source=dmzntgwatomgit1&sourcead=dmzntgwatomgiths

欢迎加入 CPF-Flutter 鸿蒙社区: https://atomgit.com/CPF-Flutter

本文配套仓库: https://atomgit.com/oh-flutter/holding-harmony

本文以 holding 插件的真实适配工程为基础,完整讲解如何把 HarmonyOS 的智感握姿能力封装成 Flutter 插件,并在 Flutter 3.44.9、HarmonyOS 7.0.0(API 26) 环境中完成开发、构建、签名和真机验证。


一、最终运行效果

完成适配后,Flutter 应用能够持续接收系统返回的握持状态,并区分 左手握持右手握持双手握持未握持未识别五种结果。

下面三张图片均来自 HarmonyOS 真机实际运行。

左手握持 双手握持 右手握持

左手握持双手握持右手握持
系统回调状态码 1系统回调状态码 3系统回调状态码 2

说明: 智感握姿依赖系统服务、设备硬件和产品能力。系统版本满足要求,并不必然代表所有型号都能返回握持事件;最终应以目标真机的实际能力为准。


二、什么是智感握姿

智感握姿是 HarmonyOS 多模态感知能力的一部分。应用订阅 holdingHandChanged 事件后,系统会根据设备传感器和系统算法给出当前握持状态。业务层不需要自己读取原始传感器数据,也不需要自行实现左右手识别算法。

典型应用场景包括:

  • 阅读器根据左右手动态调整翻页热区;
  • 相机把高频操作按钮移动到当前握持手一侧;
  • 视频播放器根据单手或双手状态优化控制区域;
  • 大屏设备根据握持方式切换紧凑布局或双手布局;
  • 无障碍场景中减少跨屏操作距离;
  • 游戏根据握持状态改变虚拟按键位置。

这个能力最适合用“事件订阅”建模。应用发起一次订阅,原生侧持续监听系统事件,每次状态变化时再把结果推送给 Dart,而不是让 Flutter 使用定时器轮询原生接口。


三、适配目标与版本矩阵

本文对应仓库当前 main 分支,核心版本如下。

项目版本或配置用途
Flutter OHOS SDK3.44.9+ohos-0.0.1-canary1Flutter 编译与 OHOS 平台工具链
Flutter 分支oh-3.44.9-devCPF-Flutter 对应开发分支
Dart SDK3.12.2Dart 语言与包管理环境
HarmonyOS7.0.0(API 26)当前编译和目标系统版本
compileSdkVersion26.0.0编译时使用的 SDK API
targetSdkVersion26.0.0应用面向的行为版本
compatibleSdkVersion5.1.0(18)当前工程声明的最低兼容版本
插件版本0.0.1pubspec.yaml 中的包版本
原生语言ArkTSHarmonyOS 插件实现
插件产物HAR被应用 entry 模块依赖

3.1 7.0.0(26)26.0.0 为什么不同

这两个写法描述的是相关但不同的概念:

  • 7.0.0(API 26) 是面向开发者和用户表达的 HarmonyOS 产品版本与 API 对应关系;
  • 26.0.0 是新版 DevEco Studio/Hvigor 工程配置中 compileSdkVersiontargetSdkVersion 使用的格式;
  • 5.1.0(18) 是当前项目 compatibleSdkVersion 使用的兼容版本格式。

因此,工程中应该这样写:

{
  "name": "default",
  "compatibleSdkVersion": "5.1.0(18)",
  "compileSdkVersion": "26.0.0",
  "targetSdkVersion": "26.0.0",
  "runtimeOS": "HarmonyOS"
}

不要因为手机显示的是 API 24,就把本机 API 26 SDK 删除或把所有配置改成 24。编译 SDK 决定构建时可使用哪些 API,兼容 SDK 决定允许安装的最低系统版本,设备 API 则是运行时环境,三者职责不同。


四、整体适配架构

Flutter 侧和 HarmonyOS 侧之间使用两条通道:

  1. MethodChannel('holding'):负责发送“订阅”和“取消订阅”命令;
  2. EventChannel('holding/events'):负责持续把握持状态从 ArkTS 推送到 Dart。

Flutter 页面

Holding 对外 API

HoldingPlatform 平台接口

MethodChannel holding

ArkTS HoldingPlugin

MultimodalAwarenessKit motion

EventChannel holding/events

HoldingHandStatus 枚举

这种拆分有三个直接收益:

  • 命令调用有明确的成功或失败结果;
  • 状态变化可以长期、异步地传输;
  • Dart 层可以用平台接口替换真实实现,方便单元测试和后续扩展。

4.1 一次完整订阅的时序

HarmonyOS motion HoldingPlugin.ets MethodChannelHolding Flutter App HarmonyOS motion HoldingPlugin.ets MethodChannelHolding Flutter App subscribeHoldingHand(options) EventChannel onListen invokeMethod(subscribeHoldingHand) motion.on(holdingHandChanged, callback) result.success(null) success() HoldingHandStatus eventSink.success(code) onChange(enum) unsubscribeHoldingHand() invokeMethod(unsubscribeHoldingHand) motion.off(holdingHandChanged) cancel EventChannel stream

五、工程目录与职责

适配后的关键目录如下:

holding-harmony/
├── lib/
│   ├── holding.dart
│   ├── holding_hand_status.dart
│   ├── holding_method_channel.dart
│   └── holding_platform_interface.dart
├── ohos/
│   ├── index.ets
│   ├── oh-package.json5
│   └── src/main/
│       ├── ets/components/plugin/HoldingPlugin.ets
│       ├── module.json5
│       └── resources/base/element/string.json
├── example/
│   ├── lib/main.dart
│   └── ohos/entry/
├── test/
├── doc/
└── pubspec.yaml
文件主要职责
lib/holding.dart为业务应用提供最简入口
lib/holding_hand_status.dart声明跨端统一的状态枚举和状态码转换
lib/holding_platform_interface.dart定义平台无关接口、参数和回调
lib/holding_method_channel.dart实现 MethodChannel/EventChannel 通信
HoldingPlugin.ets注册 Flutter 通道并调用 HarmonyOS 原生能力
插件 module.json5声明 HAR 模块及权限
示例 entry module.json5声明宿主应用 Ability、设备类型和权限场景
example/lib/main.dart展示订阅、取消订阅、状态显示和事件日志

六、准备 Flutter OHOS 3.44.9 环境

6.1 获取 CPF-Flutter SDK

Flutter 的普通官方 SDK 不包含 ohos 平台工具链。应使用 CPF-Flutter 提供的 OHOS 分支:

git clone https://atomgit.com/CPF-Flutter/flutter_flutter.git \
  -b oh-3.44.9-dev flutter_flutter

export PATH="$(pwd)/flutter_flutter/bin:$PATH"
flutter --version

预期输出的重点不是只有 3.44.9,还应看到 OHOS 版本后缀,例如:

Flutter 3.44.9+ohos-0.0.1-canary1
Dart 3.12.2

检查要点: 如果终端仍显示 3.41.x,通常是 PATH 中旧 Flutter 的优先级更高。执行 which -a flutter 查看所有命令来源,再把新 SDK 的 bin 放到 PATH 最前面。IDE 中还需要同步修改 Flutter SDK 路径,重启 IDE 和终端后再验证。

6.2 安装 HarmonyOS API 26 SDK 组件

在 DevEco Studio 的 SDK Manager 中安装 API 26 对应组件。一个可用于完整 Flutter/HarmonyOS 构建的 SDK 至少应包含:

  • ArkTS/ETS;
  • JS;
  • Native;
  • Previewer;
  • Toolchains。

然后告诉 Flutter SDK 实际位置:

flutter config --ohos-sdk /path/to/OpenHarmony/Sdk
flutter doctor -v

这里的路径必须指向真正安装了各版本组件的 SDK 根目录,不能仅指向 DevEco Studio 应用包内一个不完整的占位目录。

6.3 环境自检

which flutter
flutter --version
flutter doctor -v
hdc list targets

建议达到以下状态后再开始插件构建:

  • flutter --version3.44.9 OHOS 分支;
  • Dart 是 3.12.2
  • flutter doctor -v 能定位 HarmonyOS/OpenHarmony SDK;
  • SDK 中存在 API 26 所需组件;
  • hdc list targets 能看到已连接真机;
  • 手机已允许 USB 调试并完成电脑授权。

七、添加插件依赖

当前仓库源码可以直接通过 AtomGit 引入:

dependencies:
  flutter:
    sdk: flutter
  holding:
    git:
      url: https://atomgit.com/oh-flutter/holding-harmony.git
      ref: main

本地联调时使用路径依赖更高效:

dependencies:
  holding:
    path: ../holding-harmony

然后获取依赖:

flutter pub get

pubspec.yaml 中的 flutter: ">=3.44.9" 是最低 Flutter 约束。业务工程还需要实际选中带 OHOS 支持的 Flutter SDK,仅满足版本数字并不能自动获得 ohos 平台能力。


八、Dart 层状态模型

原生 API 返回整数状态码。为了避免业务页面到处出现难以理解的数字,插件把状态统一映射为枚举:

enum HoldingHandStatus {
  none(0),
  left(1),
  right(2),
  both(3),
  unknown(16);

  const HoldingHandStatus(this.code);

  final int code;

  static HoldingHandStatus fromCode(int code) {
    return HoldingHandStatus.values.firstWhere(
      (item) => item.code == code,
      orElse: () => HoldingHandStatus.unknown,
    );
  }
}
Dart 枚举状态码业务含义
HoldingHandStatus.none0当前未识别到握持
HoldingHandStatus.left1左手握持
HoldingHandStatus.right2右手握持
HoldingHandStatus.both3双手握持
HoldingHandStatus.unknown16系统无法识别,或收到未知值

fromCode 中必须保留兜底逻辑。以后系统新增状态码时,旧版本插件至少会得到 unknown,不会因为枚举解析失败而让事件流抛出异常。


九、设计平台无关接口

插件没有把 MethodChannel 直接暴露给业务层,而是定义了 HoldingPlatform。这样,真实的 HarmonyOS 通道实现、测试替身以及未来可能增加的平台实现都遵守同一份契约。

typedef HoldingHandChangeCallback = void Function(HoldingHandStatus status);

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;
}

class SubscribeHoldingHandOptions extends SubscribeOptionsBase {
  const SubscribeHoldingHandOptions({
    required this.onChange,
    super.success,
    super.fail,
    super.complete,
  });

  final HoldingHandChangeCallback onChange;
}

typedef UnsubscribeHoldingHandOptions = SubscribeOptionsBase;

回调的语义需要特别区分:

  • onChange:状态每次变化时调用,可以执行多次;
  • success:原生订阅或取消订阅命令成功时调用一次;
  • fail:命令失败或事件流报错时调用;
  • complete:一次命令结束后调用,成功和失败都会触发。

对外入口保持简洁:

class Holding {
  void subscribeHoldingHand(SubscribeHoldingHandOptions options) {
    HoldingPlatform.instance.subscribeHoldingHand(options);
  }

  void unsubscribeHoldingHand([UnsubscribeHoldingHandOptions? options]) {
    HoldingPlatform.instance.unsubscribeHoldingHand(options);
  }
}

十、Dart 通道实现

10.1 通道名称必须两端完全一致

final methodChannel = const MethodChannel('holding');
final holdingEventChannel = const EventChannel('holding/events');

通道名称属于跨语言协议。Dart 和 ArkTS 任何一端拼写不一致,都会出现“方法未实现”或“收不到事件”等问题。

10.2 订阅实现

StreamSubscription<dynamic>? _holdingEventSubscription;
SubscribeHoldingHandOptions? _currentHoldingOptions;

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'),
        );
      });
}

这里的关键点有四个:

  1. 先保存最新 options,状态变化会回调到最新的业务监听者;
  2. 使用 ??= 避免 Dart 端反复创建 EventChannel 订阅;
  3. 对非整数事件统一转换成 unknown
  4. MethodChannel 只确认原生监听是否启动成功,真实状态由 EventChannel 返回。

10.3 取消订阅

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'),
        );
      });
}

只有原生取消成功后,Dart 层才清理事件订阅和回调引用。这样失败时仍有机会获知原生侧的真实状态,而不是提前丢失所有上下文。


十一、ArkTS 原生插件实现

原生插件位于:

ohos/src/main/ets/components/plugin/HoldingPlugin.ets

11.1 引入 Flutter 和 HarmonyOS 能力

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';

其中:

  • FlutterPlugin 负责接入 Flutter Engine 生命周期;
  • MethodChannel 接收 Dart 发来的命令;
  • EventChannelEventSink 向 Dart 推送连续事件;
  • motion 提供 holdingHandChanged 系统能力;
  • BusinessError 用于读取 HarmonyOS 异常码和异常消息;
  • hilog 用于原生侧诊断日志。

11.2 连接 Flutter Engine

private channel: MethodChannel | null = null;
private holdingEventChannel: EventChannel | null = null;
private holdingEventSink: EventSink | null = null;
private holdingCallback: ((data: motion.HoldingHandStatus) => void) | null = null;

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;
    }
  });
}

onListen 表示 Dart 已开始监听事件流,此时保存 EventSinkonCancel 表示 Dart 事件流已经取消,原生侧也应立即停止系统监听,防止页面退出后继续消耗资源。

11.3 订阅系统握持事件

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, '订阅握持手状态成功');
}

第一行幂等判断很重要。Flutter 页面可能因为重复点击、状态恢复或组件重建再次调用订阅。如果每次都注册新的系统回调,同一个状态就可能被发送多次,并造成监听器泄漏。

11.4 取消系统监听

private stopHoldingListening(): void {
  if (this.holdingCallback == null) return;

  motion.off('holdingHandChanged');
  this.holdingCallback = null;
  hilog.info(0, TAG, '取消订阅握持手状态成功');
}

调用 motion.off('holdingHandChanged') 后还需要把 holdingCallback 设为 null,否则下一次订阅会被幂等保护误判为“已经订阅”。

11.5 处理 MethodChannel 命令

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;
    default:
      result.notImplemented();
      break;
  }
}

safeInvoke 对异常做统一处理:

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
    );
  }
}

异常不能只写日志。通过 result.error 返回 Dart 后,应用才能进入 failcomplete 回调,向用户展示可理解的失败状态。

11.6 Engine 解绑时释放资源

onDetachedFromEngine(_binding: FlutterPluginBinding): void {
  this.stopHoldingListening();
  this.holdingEventSink = null;

  if (this.channel != null) {
    this.channel.setMethodCallHandler(null);
    this.channel = null;
  }

  if (this.holdingEventChannel != null) {
    this.holdingEventChannel.setStreamHandler(null);
    this.holdingEventChannel = null;
  }
}

插件不能假设业务一定会主动取消订阅。Flutter Engine 被销毁时,原生监听、MethodChannel Handler、EventChannel Handler 和 EventSink 都应释放。


十二、声明插件与宿主应用权限

智感握姿使用了多模态感知能力,当前工程声明了以下权限:

ohos.permission.DETECT_GESTURE
ohos.permission.ACTIVITY_MOTION

12.1 插件 HAR 的权限

在插件的 ohos/src/main/module.json5 中声明:

{
  "module": {
    "name": "holding",
    "type": "har",
    "deviceTypes": ["default", "tablet"],
    "requestPermissions": [
      {
        "name": "ohos.permission.DETECT_GESTURE",
        "reason": "$string:detect_gesture_reason"
      },
      {
        "name": "ohos.permission.ACTIVITY_MOTION",
        "reason": "$string:activity_motion_reason"
      }
    ]
  }
}

12.2 应用 entry 的权限

最终安装的是宿主应用,因此业务工程的 ohos/entry/src/main/module.json5 也应明确声明权限和使用场景:

{
  "module": {
    "requestPermissions": [
      {
        "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"
        }
      }
    ]
  }
}

权限原因资源写入 ohos/entry/src/main/resources/base/element/string.json

{
  "string": [
    {
      "name": "detect_gesture_reason",
      "value": "用于识别设备握持手状态"
    },
    {
      "name": "activity_motion_reason",
      "value": "用于获取活动与运动感知状态"
    }
  ]
}

权限是否需要动态申请、是否受系统应用级别限制,以及应用上架时需要哪些说明,应以目标 HarmonyOS 版本的官方权限文档和应用市场审核规则为准。


十三、配置 HarmonyOS 7.0.0(API 26)

业务工程的 ohos/build-profile.json5 应在 products 中设置版本。下面只展示与 SDK 相关的公开配置;签名信息不要复制到文章或提交到公开仓库。

{
  "app": {
    "products": [
      {
        "name": "default",
        "signingConfig": "default",
        "compatibleSdkVersion": "5.1.0(18)",
        "runtimeOS": "HarmonyOS",
        "compileSdkVersion": "26.0.0",
        "targetSdkVersion": "26.0.0"
      }
    ]
  },
  "modules": [
    {
      "name": "entry",
      "srcPath": "./entry",
      "targets": [
        {
          "name": "default",
          "applyToProducts": ["default"]
        }
      ]
    }
  ]
}

配置后,在 DevEco Studio 中执行一次 Sync Project。如果 entry 模块正常识别,Project 视图中会出现 entry,并能打开 entry 模块的签名配置。


十四、在 Flutter 页面中使用

下面是一份完整但保持紧凑的使用示例:

import 'package:flutter/material.dart';
import 'package:holding/holding.dart';

class HoldingPage extends StatefulWidget {
  const HoldingPage({super.key});

  
  State<HoldingPage> createState() => _HoldingPageState();
}

class _HoldingPageState extends State<HoldingPage> {
  final Holding _holding = Holding();
  HoldingHandStatus _status = HoldingHandStatus.none;
  bool _subscribed = false;

  String get _statusText => switch (_status) {
        HoldingHandStatus.none => '未握持',
        HoldingHandStatus.left => '左手握持',
        HoldingHandStatus.right => '右手握持',
        HoldingHandStatus.both => '双手握持',
        HoldingHandStatus.unknown => '未识别',
      };

  void _subscribe() {
    _holding.subscribeHoldingHand(
      SubscribeHoldingHandOptions(
        onChange: (HoldingHandStatus status) {
          if (!mounted) return;
          setState(() => _status = status);
        },
        success: () {
          if (!mounted) return;
          setState(() => _subscribed = true);
        },
        fail: (String message) {
          if (!mounted) return;
          setState(() => _subscribed = false);
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('订阅失败:$message')),
          );
        },
        complete: (String message) {
          debugPrint(message);
        },
      ),
    );
  }

  void _unsubscribe() {
    _holding.unsubscribeHoldingHand(
      UnsubscribeHoldingHandOptions(
        success: () {
          if (!mounted) return;
          setState(() => _subscribed = false);
        },
        fail: (String message) {
          debugPrint('取消订阅失败:$message');
        },
      ),
    );
  }

  
  void dispose() {
    _holding.unsubscribeHoldingHand();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('智感握姿')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text('当前状态:$_statusText'),
            const SizedBox(height: 16),
            FilledButton(
              onPressed: _subscribed ? null : _subscribe,
              child: const Text('订阅'),
            ),
            OutlinedButton(
              onPressed: _subscribed ? _unsubscribe : null,
              child: const Text('取消订阅'),
            ),
          ],
        ),
      ),
    );
  }
}

14.1 页面生命周期注意事项

  • 所有异步回调在调用 setState 前检查 mounted
  • 页面销毁时取消订阅,避免继续接收无用事件;
  • 按钮根据订阅状态禁用,降低重复操作概率;
  • fail 回调要向用户反馈,不要只打印日志;
  • 复杂应用建议由服务层集中维护唯一订阅,页面只观察业务状态。

实践建议: 示例在 dispose 中直接发起取消命令即可,不要等待异步结果后再调用 super.dispose()。生产项目可以让更高层的状态管理服务持有插件实例,从根源上避免页面切换引起反复订阅。


十五、插件自动注册

pubspec.yaml 通过以下配置声明 OHOS 插件类:

flutter:
  plugin:
    platforms:
      ohos:
        pluginClass: HoldingPlugin

插件的 ohos/index.ets 需要导出实现:

export { default as HoldingPlugin } from './src/main/ets/components/plugin/HoldingPlugin';

执行 flutter pub get 和构建后,Flutter 工具会为应用生成插件注册代码。通常不应手工编辑 GeneratedPluginRegistrant.ets,因为下次构建可能覆盖它。

如果运行时出现 MissingPluginException,依次检查:

  1. 应用是否使用支持 OHOS 的 Flutter SDK;
  2. pubspec.yaml 是否声明了 ohos 和正确的 pluginClass
  3. index.ets 是否正确导出插件;
  4. oh-package.json5 是否能解析插件依赖;
  5. 清理构建缓存后是否重新生成注册文件。

十六、构建、签名与真机运行

16.1 获取依赖与静态检查

flutter clean
flutter pub get
flutter analyze
flutter test

16.2 确认设备连接

hdc list targets
flutter devices

设备首次连接电脑时,需要在手机端确认调试授权。设备列表为空时,先处理 USB 连接、驱动、调试模式和授权,不要先修改插件代码。

16.3 配置签名

真机安装的 HAP 通常需要有效签名。推荐使用 DevEco Studio 为 entry 模块配置自动签名:

  1. 用 DevEco Studio 打开 example/ohos,不是仓库根目录;
  2. 等待工程 Sync 成功,确认 Project 视图中存在 entry 模块;
  3. 打开 File > Project Structure > Signing Configs
  4. default product 选择或生成签名;
  5. 确认设备、应用包名、证书和 Profile 匹配;
  6. 再回到终端执行 Flutter 构建或运行。

安全提醒: 签名口令、私钥文件、证书、Profile 和本机绝对路径都不应提交到公开仓库。CI 应使用密钥管理服务或受保护的环境变量注入签名材料。

16.4 运行示例

cd example
flutter run -d <device-id>

也可以先构建 HAP:

flutter build hap --debug

典型产物位于:

example/ohos/entry/build/default/outputs/default/

目录中通常包含已签名和未签名 HAP。真机安装应选择与当前设备匹配的已签名产物。

16.5 真机验收动作

  1. 打开应用,确认初始状态为“未握持”和“未订阅”;
  2. 点击“订阅”,确认界面进入“已订阅”;
  3. 分别使用左手、右手和双手自然握持设备;
  4. 检查界面状态和时间日志是否随握姿变化;
  5. 点击“取消”,继续改变握姿,确认不再产生新日志;
  6. 退出页面再进入,确认不会出现重复事件;
  7. 多次订阅和取消,确认无崩溃、无重复回调。

十七、测试策略

通道插件至少需要覆盖三类测试。

17.1 平台接口测试

确认默认实例是 MethodChannel 实现:

test('MethodChannelHolding is the default instance', () {
  expect(
    HoldingPlatform.instance,
    isInstanceOf<MethodChannelHolding>(),
  );
});

17.2 通道协议测试

test('uses the expected platform channels', () {
  final platform = MethodChannelHolding();
  expect(platform.methodChannel.name, 'holding');
  expect(platform.holdingEventChannel.name, 'holding/events');
});

建议继续补充以下用例:

  • 状态码 0/1/2/3/16 能正确转换;
  • 未知整数转换为 unknown
  • 非整数事件转换为 unknown
  • 订阅成功触发 successcomplete
  • 平台异常触发 failcomplete
  • 取消成功后清理 EventChannel 订阅;
  • 重复订阅不会创建多个 Dart StreamSubscription。

17.3 Widget 测试

示例工程应至少验证初始 UI:

testWidgets('shows the initial holding state', (tester) async {
  await tester.pumpWidget(const MyApp());

  expect(find.text('Holding 插件示例'), findsOneWidget);
  expect(find.text('未握持'), findsOneWidget);
  expect(find.text('未订阅'), findsOneWidget);
  expect(find.text('订阅'), findsOneWidget);
});

Dart 单元测试无法替代真机测试。motion 的系统算法、硬件支持、权限状态和签名环境只能通过真实 HarmonyOS 设备验证。


十八、常见问题与排查方法

18.1 Missing SDK components

典型错误如下:

Missing SDK components. SDK path: ...,
missing components: toolchains,ets,js,native,previewer.

这不是因为手机不是 API 26。报错发生在 Hvigor 同步阶段,说明构建工具当前找到的 SDK 根目录缺少组件,甚至还没有进入设备安装阶段。

处理顺序:

  1. 在 DevEco Studio SDK Manager 中确认 API 26 组件已经下载完整;
  2. 检查 Flutter 和 DevEco Studio 使用的 SDK 路径是否一致;
  3. 避免误用 /Applications/DevEco-Studio.app/Contents/sdk 之类的不完整目录;
  4. 确认 SDK 根目录下存在 toolchainsetsjsnativepreviewer
  5. 执行 flutter config --ohos-sdk <正确路径>
  6. 重新执行 flutter doctor -v 和 DevEco Studio Sync。
为什么连接 API 24 手机仍然会报这个错误?

因为 Sync 和 Compile 首先读取 Mac 本地 SDK。手机 API 版本只在部署、安装和运行时参与兼容判断。即使完全不连接手机,本地 SDK 不完整时也会得到相同错误。

当前工程的 compatibleSdkVersion 是 API 18,因此 API 24 在安装版本门槛上是满足的;但设备还必须支持智感握姿能力,并满足签名和权限要求。

18.2 DevEco Studio 中看不到 entry 模块

插件根目录自身是 HAR 模块,不是可安装应用,所以不会天然拥有业务应用的 entry。本仓库的可运行模块位于 example/ohos/entry

请直接使用 DevEco Studio 打开:

holding-harmony/example/ohos

如果仍看不到 entry,先解决 SDK Sync 错误,再检查 example/ohos/build-profile.json5modules 是否包含 ./entry。同步失败时,Project Structure 无法正确解析模块,签名界面也可能不显示 entry。

18.3 无法手动签名

签名配置依附于可构建的应用模块和 product。只有 HAR 插件模块、工程 Sync 失败,或者打开了错误目录时,DevEco Studio 都可能无法提供 entry 签名入口。

建议先确认:

  • 打开的是 example/ohos
  • SDK 组件完整并且 Sync 成功;
  • entry 的模块类型为 entry
  • default product 和 target 已正确关联;
  • 当前账号、证书和调试设备状态有效。

18.4 能安装但没有状态变化

按以下顺序检查:

  1. UI 是否已经显示“已订阅”;
  2. entry 是否声明 DETECT_GESTUREACTIVITY_MOTION
  3. 系统是否授予相关权限;
  4. hilog 中是否出现“订阅握持手状态成功”;
  5. hilog 中是否出现“握持手状态变化”;
  6. 目标设备型号和系统是否真正支持 holdingHandChanged
  7. 是否使用过厚保护壳或不符合设备识别条件的握持方式。

18.5 MissingPluginException

这通常表示 Dart 通道找不到已注册的原生插件,而不是 motion API 自身失败。清理并重新生成:

flutter clean
flutter pub get
cd example
flutter run -d <device-id>

如果仍然出现,检查自动生成的插件注册文件中是否包含 HoldingPlugin,同时核对 pubspec.yamlohos/index.etsoh-package.json5

18.6 重复收到相同事件

重点检查两处幂等保护:

  • Dart 侧使用 _holdingEventSubscription ??=
  • ArkTS 侧在 holdingCallback != null 时直接返回。

还应确认业务层没有同时创建多个 Holding 服务实例并分别订阅。如果多个页面都需要握持状态,推荐在应用级服务中只订阅一次,再通过状态管理分发。

18.7 编译成功但安装失败

常见原因包括:

  • HAP 未签名或使用了错误的 Profile;
  • 设备未加入调试设备列表;
  • 包名与签名 Profile 不匹配;
  • 安装包的 compatibleSdkVersion 高于设备 API;
  • 手机上已经安装了使用不同证书签名的同包名应用。

先保留完整安装错误码,再判断是签名、版本还是包冲突,不建议只反复执行 flutter clean


十九、生产环境设计建议

示例工程证明能力可用后,正式项目还应考虑稳定性、体验和可观测性。

19.1 集中管理订阅

在应用级服务中持有唯一 Holding 实例,把原始枚举转换为业务状态,再由 Provider、Riverpod、Bloc 或项目现有状态管理方案分发。这样可以避免多个页面同时注册系统监听。

holding 插件唯一订阅

HoldingService

阅读页

相机页

设置页

19.2 防抖与状态稳定

传感器结果可能在短时间内快速切换。业务如果立即重排整个页面,会造成视觉抖动。可以根据场景增加 100~300 ms 的防抖或连续多次一致后再确认,但不要在插件底层强制写死阈值,因为阅读器、相机和游戏对延迟的容忍度不同。

19.3 能力降级

把智感握姿视为增强能力,而不是关键路径的唯一入口:

  • 设备不支持时保留普通操作布局;
  • 权限拒绝时提供手动左右手设置;
  • 收到 unknown 时维持上一个稳定状态或回退默认布局;
  • 订阅失败时允许用户重试,但避免无限循环重试。

19.4 日志与隐私

建议记录订阅成功、失败码、取消订阅和能力不可用等诊断信息,但不要把用户握持历史与身份信息长期关联。发布版本应控制日志级别,并遵守隐私政策和最小化采集原则。

19.5 CI 建议

持续集成至少执行:

flutter pub get
dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test
flutter build hap --debug

同时注意:

  • 固定 Flutter OHOS SDK 分支或提交,避免构建环境漂移;
  • 固定 ohpm/Hvigor 依赖版本;
  • 缓存依赖但不要缓存签名秘密;
  • 构建日志中屏蔽证书路径和密码;
  • 使用专门的真机流水线完成硬件能力回归。

二十、总结

holding 的鸿蒙化适配看似只是调用一次 motion.on,真正需要处理的是一整条跨端链路:

Flutter 业务 API → 平台接口 → MethodChannel 命令 → ArkTS 系统订阅 → EventChannel 事件 → Dart 枚举 → 页面状态

一份可靠的适配不仅要“能收到左手或右手”,还要做到版本配置准确、权限完整、通道协议稳定、重复订阅幂等、生命周期可释放、异常可回传,并在真实设备上覆盖订阅和取消订阅的完整闭环。

本文工程已经完成 Flutter 3.44.9 与 HarmonyOS 7.0.0(API 26) 的适配配置,并通过三种典型握姿进行了真机验证。开发者可以直接参考配套仓库,把智感握姿进一步接入阅读、相机、影音、游戏和大屏交互场景。

相关链接


本文代码和配置以配套仓库当前 main 分支为准。HarmonyOS SDK、Flutter OHOS 工具链和设备能力会持续演进,升级后请重新执行静态检查、构建测试和真机回归。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐