一、概述

今天一时兴起研究了一下智感握持功能,于是顺道把之前一款基于Flutter开发的鸿蒙应用做了智感握持功能的适配。写一篇文章记录分享一下。

“智感握持”并不是一个单独页面或复杂业务,而是一个感知左右手握持状态的能力,在本应用中,最终落在一个非常明确的用户体验点:某页面右下角的“新建按钮”会根据左右手握持自动决定在左侧还是右侧出现(有点类似“开发者联盟”APP中社区页面的设计)

相关开发文档:获取用户动作开发指导-Multimodal Awareness Kit(多模态融合感知服务)-硬件-系统 - 华为HarmonyOS开发者

@ohos.multimodalAwareness.motion (动作感知能力)-ArkTS API-Multimodal Awareness Kit(多模态融合感知服务)-硬件-系统 - 华为HarmonyOS开发者

二、整体架构

我们的 UI 是 Flutter 写的,所以必须把 ArkTS 拿到的数据送到 Dart。

标准做法就是 EventChannel:Native 持续推送事件 → Dart 收到一个 Stream。

所以链路是:

  1. HarmonyOS Motion 推出 HoldingHandStatus/OperatingHandStatus
  2. ArkTS 订阅 Motion,把 status 通过 EventChannel 推给 Flutter
  3. Dart Service订阅 EventChannel 得到 Stream
  4. Dart Controller把 Stream 变成 ChangeNotifier 状态
  5. UI 监听状态变化并做换边动画

三、实现

3.1 权限声明

开发文档中明确指出,使用motion模块获取用户操作手时,需要这些权限。

若没有获取相关权限的话,会直接导致能力不可用。

"requestPermissions":[
    {
      "name" : "ohos.permission.ACTIVITY_MOTION"
    },
    {
      "name" : "ohos.permission.DETECT_GESTURE"
    }
  ]

3.2 HarmonyOS侧

文件:HoldingHandPlugin.ets

它要做的事:订阅 Motion,拿到 status;把 status 推给 Flutter。

3.2.1 通道名必须一致

Dart 侧会用同一个字符串创建 EventChannel,CHANNEL_NAME 必须和 Dart 完全一致

const CHANNEL_NAME = 'XXX/motion_holding_hand'
3.2.2 registerWith()

把 EventChannel 绑到 FlutterEngine

  static registerWith(flutterEngine: FlutterEngine): void {
    const channel = new EventChannel(flutterEngine.dartExecutor, CHANNEL_NAME)

    const handler: StreamHandler = {
      onListen: (args: Any, events: EventSink) => {
        HoldingHandPlugin.start(events)
      },
      onCancel: (args: Any) => {
        HoldingHandPlugin.stop()
      },
    }

    channel.setStreamHandler(handler)

    Log.i(TAG, 'registered')
  }

Dart 侧开始监听(receiveBroadcastStream())→ ArkTS 触发 onListen → 我们在 start() 里开始 motion.on(...)

Dart 侧取消监听 → ArkTS 触发 onCancel → 我们在 stop() 里 motion.off(...)

3.2.3 start(events):优先握持手,801 再降级到操作手

订阅握持手优先,失败再 fallback

  private static start(events: EventSink): void {
    HoldingHandPlugin.sink = events

    if (HoldingHandPlugin.isListening) {
      return
    }

    HoldingHandPlugin.isListening = true

    HoldingHandPlugin.holdingCallback = (data: motion.HoldingHandStatus) => {
      HoldingHandPlugin.emitStatus('holding', data as number)
    }

    try {
      motion.on('holdingHandChanged', HoldingHandPlugin.holdingCallback)
      HoldingHandPlugin.emitStatus('holding', -1)
      Log.i(TAG, 'motion.on(holdingHandChanged) succeeded')
      return
    } catch (err) {
      const error = err as BusinessError
      Log.e(TAG, 'motion.on(holdingHandChanged) failed, code=' + error.code)

      if (error.code !== 801) {
        HoldingHandPlugin.emitError('holdingHandChanged', error)
        return
      }

      HoldingHandPlugin.tryFallbackToOperating()
    }
  }
3.2.4 tryFallbackToOperating():订阅操作手 + 取一次 recent
  private static tryFallbackToOperating(): void {
    HoldingHandPlugin.operatingCallback = (data: motion.OperatingHandStatus) => {
      HoldingHandPlugin.emitStatus('operating', data as number)
    }

    try {
      motion.on('operatingHandChanged', HoldingHandPlugin.operatingCallback)
      Log.i(TAG, 'fallback: motion.on(operatingHandChanged) succeeded')
      try {
        const recent = motion.getRecentOperatingHandStatus()
        HoldingHandPlugin.emitStatus('operating', recent as number)
      } catch (recentErr) {
        // ignore
      }
    } catch (err) {
      const error = err as BusinessError
      HoldingHandPlugin.emitError('operatingHandChanged', error)
      Log.e(TAG, 'fallback: motion.on(operatingHandChanged) failed, code=' + error.code)
    }
  }

降级后:motion.on('operatingHandChanged')

3.2.5 stop():取消订阅
  private static stop(): void {
    HoldingHandPlugin.isListening = false

    try {
      motion.off('holdingHandChanged')
    } catch (err) {
      // ignore
    }

    try {
      motion.off('operatingHandChanged')
    } catch (err) {
      // ignore
    }

    HoldingHandPlugin.holdingCallback = null
    HoldingHandPlugin.operatingCallback = null
    HoldingHandPlugin.sink = null
  }

如果不取消订阅,会导致:

  • 页面/订阅重建导致重复注册回调
  • 一直工作,耗电且不可控
3.2.6 emitStatus():真正把数据送到 Flutter
  private static emitStatus(source: string, status: number): void {
    if (HoldingHandPlugin.sink == null) {
      return
    }

    HoldingHandPlugin.sink.success({
      source: source,
      status: status,
      ts: Date.now(),
    })
  }
3.2.7 插件注册:EntryAbility.ets

没有这一步,插件不会生效:

import { FlutterAbility, FlutterEngine } from '@ohos/flutter_ohos';
import { GeneratedPluginRegistrant } from '../plugins/GeneratedPluginRegistrant';
import { HoldingHandPlugin } from '../plugins/HoldingHandPlugin';

export default class EntryAbility extends FlutterAbility {
  configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
    GeneratedPluginRegistrant.registerWith(flutterEngine)
    HoldingHandPlugin.registerWith(flutterEngine) // 智感握持
  }
}

3.3 Flutter Service

3.3.1 通道名必须一致

_channelName 必须和鸿蒙侧 HoldingHandPlugin.ets 的 CHANNEL_NAME 完全一致

  static const String _channelName = 'lrtimer/motion_holding_hand';

  final EventChannel _channel = const EventChannel(_channelName);
3.3.2 stream:只在 ohos 端订阅
  Stream<dynamic> get stream {
    if (Platform.operatingSystem != 'ohos') {
      return const Stream<dynamic>.empty();
    }
    return _channel.receiveBroadcastStream();
  }
3.3.3 parseSide():按文档映射
  static const int _leftValue = 1;
  static const int _rightValue = 2;

  HoldingSide parseSide(Object? event) {
    int? status;

    if (event is Map) {
      final raw = event['status'];
      if (raw is int) {
        status = raw;
      }
    }

    status ??= event is int ? event : null;

    if (status == _leftValue) {
      return HoldingSide.left;
    }
    if (status == _rightValue) {
      return HoldingSide.right;
    }
    return HoldingSide.unknown;
  }
}
  • Motion → status 数值
  • Service → 把 status 变成 HoldingSide
  • UI 只看 HoldingSide

3.4 Flutter Controller

把 Stream 变成全局状态

void start() {
  _sub?.cancel();
  _sub = _service.stream.listen(
    (event) {
      final next = _service.parseSide(event);
      if (next == _side) {
        return;
      }
      _side = next;
      notifyListeners();
    },
    onError: (_) {
      // 不支持/无权限等情况:保持默认 unknown,不崩溃。
    },
  );
}

3.5 在 App 启动时把 Controller 跑起来

在 main.dart:

ChangeNotifierProvider<HoldingHandController>(
  create: (_) => HoldingHandController()..start(),
),
  • App 启动即订阅
  • 任意页面都能通过 Provider 读取当前 side

3.6 UI接入

我们这里用 GripAwareNewFab 做了三件事:

  1. 读 HoldingHandController 的 side
  2. side 变了就启动两段式动画:先滑出 → 切边 → 再滑入
  3. 用 Positioned(left/right) 决定按钮贴哪边

关键监听逻辑:

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();

    final next = context.read<HoldingHandController>();
    if (_controller != next) {
      _controller?.removeListener(_handleGripChanged);
      _controller = next;
      _controller?.addListener(_handleGripChanged);

      // 初始化显示位置
      _displaySide ??= _controller?.side;
      _pendingSide ??= _displaySide;
    }
  }

  void _handleGripChanged() {
    // ...
    _pendingSide = nextSide;

    if (_stage != _FabTransitionStage.idle) {
      return;
    }
    if (_displaySide == nextSide) {
      return;
    }

    _stage = _FabTransitionStage.exiting;
    _anim
      ..reset()
      ..forward();

    setState(() {});
  }

决定贴左还是贴右:

return Positioned(
  left: isLeft ? horizontal : null,
  right: isLeft ? null : horizontal,
  bottom: bottom,
  // ...
);

四、实现效果

智感握持效果

Logo

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

更多推荐