AR 眼镜之-HID 蓝牙戒指-接入方案

📂 前言

在 AR 眼镜的交互方案中,手势控制是核心痛点之一,传统的触控板、语音控制、按键等方式各有局限性,而HID 蓝牙戒指作为一种新型输入设备,能够提供更自然、更精准的操作体验。本文将详细介绍三种渐进式接入方案,从简单到复杂,逐步实现戒指控制 AR 眼镜的目标。

核心流程:

戒指支持的手势事件:

  • 前滑:向前滑动

  • 后滑:向后滑动

  • 进入:点击/确认

  • 退出:返回/取消


1. 🔱 技术方案概述

1.1 方案对比

|----------------|-----------|----------|----------------|---------------|
| 方案 | 适用场景 | 息屏支持 | 优点 | 缺点 |
| 方案一:前台界面监听 | App 前台运行时 | - | 实现简单,无需系统权限 | App 后台或锁屏时失效 |
| 方案二:无障碍服务 | 前台/后台/锁屏 | - | 无需修改戒指协议,系统级拦截 | 息屏时可能失效,需用户授权 |
| 方案三:私有蓝牙协议 | 全场景 | Y | 功能完整,体验最优 | 需要戒指厂商提供协议文档 |

1.2 架构设计


2. ⚛️ 方案一:前台界面监听

2.1 方案原理

当蓝牙戒指作为 HID 设备连接到手机后,戒指的手势操作会转化为标准的指针事件(PointerEvent)或按键事件(KeyEvent),通过在 Flutter 界面中使用 Listener 组件,可以直接捕获这些事件。

2.2 实现方案

创建一个专门的中转 Widget,负责:

  1. 监听戒指发出的指针事件(滑动、点击、长按)

  2. 识别手势类型(单击、双击、长按、滑动)

  3. 将手势转换为控制指令

  4. 通过蓝牙/Wi-Fi 转发给 AR 眼镜

2.2.1 核心代码:Focus.Listener
复制代码
  Widget build(BuildContext context) {
    return Focus(
      focusNode: _focusNode,
      autofocus: true,
      onFocusChange: (hasFocus) {
        logI("焦点状态改变: $hasFocus", tag: "RingToGlassesControllerWidget");
      },
      child: Listener(
        // 2. 指针按下时,开启手势判定管道
        onPointerDown: _handlePointerDown,
        // 3. 指针移动时,检查是否移动过大需取消点击
        onPointerMove: _handlePointerMove,
        // 4. 指针抬起,识别单击或双击
        onPointerUp: _handlePointerUp,

        // 5. 娱乐模式下的纯移动事件(用于滑动)
        onPointerHover: (PointerHoverEvent event) {
          if (event.delta.dy > 1.0) {
            _internalEventStream.add("HOVER_DOWN");
          } else if (event.delta.dy < -1.0) {
            _internalEventStream.add("HOVER_UP");
          }
        },
      ),
    );
  }
2.2.2 完整代码:RingToGlassesControllerWidget.dart
复制代码
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:mb_app/device/manager/device_setting_manager.dart';
import 'package:mb_app/device/view/widgets/glasses_remote_controller_popup.dart';

class RingToGlassesControllerWidget extends StatefulWidget {
  const RingToGlassesControllerWidget({Key? key}) : super(key: key);

  @override
  State<RingToGlassesControllerWidget> createState() => _RingToGlassesControllerWidgetState();
}

class _RingToGlassesControllerWidgetState extends State<RingToGlassesControllerWidget> {
  final FocusNode _focusNode = FocusNode();

  final List<String> _consoleLogs = [];


  bool _isConnectedToGlasses = false;
  bool _isConnecting = false;

  final StreamController<String> _internalEventStream = StreamController<String>.broadcast();
  StreamSubscription<String>? _eventForwardingSub;

  Offset? _pointerDownPosition;
  DateTime? _pointerDownTime;
  Timer? _longPressTimer;
  Timer? _clickDelayTimer;
  DateTime? _lastClickTime;

  static const Duration _longPressThreshold = Duration(milliseconds: 500);
  static const Duration _clickThreshold = Duration(milliseconds: 250);
  static const Duration _doubleClickThreshold = Duration(milliseconds: 300);
  static const double _moveTolerance = 15.0;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) _focusNode.requestFocus();
    });
    _eventForwardingSub = _internalEventStream.stream.listen((command) {
      _sendDataToGlasses(command);
    });
    _connectToGlasses();
  }

  @override
  void dispose() {
    _focusNode.dispose();
    _eventForwardingSub?.cancel();
    _internalEventStream.close();
    _longPressTimer?.cancel();
    _clickDelayTimer?.cancel();
    _disconnectFromGlasses();
    super.dispose();
  }

  void _connectToGlasses() async {
    if (_isConnectedToGlasses || _isConnecting) return;
    setState(() {
      _isConnecting = true;
      _logToConsole("正在尝试建立与眼镜的无线通信链路...");
    });
    await Future.delayed(const Duration(seconds: 2));
    if (!mounted) return;
    setState(() {
      _isConnecting = false;
      _isConnectedToGlasses = true;
      _logToConsole("🟢 眼镜连接成功!可以开始中转指环指令。");
    });
  }

  void _sendDataToGlasses(String command) {
    if (!_isConnectedToGlasses) return;
    
    int code = RemoteControlKeyCode.touchBarForward;
    if (command == "HOVER_DOWN") {
      code = RemoteControlKeyCode.touchBarForward;
    } else if (command == "HOVER_UP") {
      code = RemoteControlKeyCode.touchBarBackward;
    } else if (command == "CONFIRM_CLICK" || command == "TOUCH_CLICK") {
      code = RemoteControlKeyCode.touchBarSingleClick;
    } else if (command == "TOUCH_DOUBLE_CLICK") {
      code = RemoteControlKeyCode.touchBarDoubleClick;
    } else if (command == "TOUCH_LONG_PRESS") {
      code = RemoteControlKeyCode.touchBarLongPressed;
    }

    unawaited(deviceSettingManager.sendCmdControlVirtualKey(code));
  }

  void _handlePointerDown(PointerDownEvent event) {
    _pointerDownPosition = event.localPosition;
    _pointerDownTime = DateTime.now();
    _longPressTimer?.cancel();
    _longPressTimer = Timer(_longPressThreshold, () {
      if (_pointerDownPosition != null) {
        _internalEventStream.add("TOUCH_LONG_PRESS");
        _cleanupGestureState();
      }
    });
  }

  void _handlePointerMove(PointerMoveEvent event) {
    if (_pointerDownPosition == null) return;
    final double distance = (event.localPosition - _pointerDownPosition!).distance;
    if (distance > _moveTolerance) {
      _longPressTimer?.cancel();
      _pointerDownPosition = null;
    }
  }

  void _handlePointerUp(PointerUpEvent event) {
    _longPressTimer?.cancel();
    if (_pointerDownPosition == null || _pointerDownTime == null) return;
    
    final DateTime now = DateTime.now();
    final Duration pressDuration = now.difference(_pointerDownTime!);
    
    if (pressDuration < _clickThreshold) {
      if (_lastClickTime != null && now.difference(_lastClickTime!) < _doubleClickThreshold) {
        _clickDelayTimer?.cancel();
        _internalEventStream.add("TOUCH_DOUBLE_CLICK");
        _lastClickTime = null;
      } else {
        _lastClickTime = now;
        _clickDelayTimer?.cancel();
        _clickDelayTimer = Timer(_doubleClickThreshold, () {
          _internalEventStream.add("TOUCH_CLICK");
          _lastClickTime = null;
        });
      }
    }
    _cleanupGestureState();
  }

  void _cleanupGestureState() {
    _pointerDownPosition = null;
    _pointerDownTime = null;
    _longPressTimer?.cancel();
  }

  @override
  Widget build(BuildContext context) {
    return Focus(
      focusNode: _focusNode,
      autofocus: true,
      child: Listener(
        onPointerDown: _handlePointerDown,
        onPointerMove: _handlePointerMove,
        onPointerUp: _handlePointerUp,
        onPointerHover: (PointerHoverEvent event) {
          if (event.delta.dy > 1.0) {
            _internalEventStream.add("HOVER_DOWN");
          } else if (event.delta.dy < -1.0) {
            _internalEventStream.add("HOVER_UP");
          }
        },
        onPointerSignal: (PointerSignalEvent pointerSignal) {
          if (pointerSignal is PointerScrollEvent) {
            if (pointerSignal.scrollDelta.dy > 0) {
              _internalEventStream.add("SLIDE_DOWN");
            } else if (pointerSignal.scrollDelta.dy < 0) {
              _internalEventStream.add("SLIDE_UP");
            }
          }
        },
        child: Container(
          padding: const EdgeInsets.all(16.0),
          decoration: BoxDecoration(
            color: Colors.grey.shade100,
            borderRadius: BorderRadius.circular(12),
            border: Border.all(color: Colors.grey.shade300),
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Text("OnCtrl 中转中心", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
                  _buildStatusBadge(),
                ],
              ),
              const SizedBox(height: 12),
              Text(
                _isConnectedToGlasses ? "请操作指环,指令将通过手机自动实时透传至眼镜..." : "等待眼镜连接通道准备就绪...",
                style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
              ),
              const SizedBox(height: 12),
              const Text("实时同步日志:", style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500)),
              const SizedBox(height: 6),
              Expanded(
                child: Container(
                  padding: const EdgeInsets.all(8),
                  decoration: BoxDecoration(
                    color: Colors.black87,
                    borderRadius: BorderRadius.circular(6),
                  ),
                  child: _consoleLogs.isEmpty
                      ? const Center(child: Text("等待指环物理信号...", style: TextStyle(color: Colors.greenAccent, fontSize: 12)))
                      : ListView.builder(
                          itemCount: _consoleLogs.length,
                          itemBuilder: (context, index) => Text(
                            _consoleLogs[index],
                            style: const TextStyle(color: Colors.greenAccent, fontFamily: 'monospace', fontSize: 11),
                          ),
                        ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildStatusBadge() {
    if (_isConnecting) {
      return const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.orange));
    }
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      decoration: BoxDecoration(
        color: _isConnectedToGlasses ? Colors.green.shade100 : Colors.red.shade100,
        borderRadius: BorderRadius.circular(4),
      ),
      child: Text(
        _isConnectedToGlasses ? "眼镜在线" : "眼镜离线",
        style: TextStyle(
          color: _isConnectedToGlasses ? Colors.green.shade800 : Colors.red.shade800,
          fontSize: 11,
          fontWeight: FontWeight.bold,
        ),
      ),
    );
  }
}

2.3 方案总结

适用场景:开发调试阶段、简单演示、用户主动打开控制界面时

核心问题:当 App 进入后台或手机锁屏时,Listener 将无法接收事件,导致戒指控制失效。


3. 💠 方案二:无障碍服务

3.1 方案原理

Android 的 AccessibilityService 提供了系统级的事件监听能力,当戒指作为 HID 设备发送按键事件时,无障碍服务可以在任何应用(包括系统界面)中拦截这些事件,无论 App 是否在前台运行。

  • 关键特性:支持前台、后台、锁屏状态下的事件监听

  • 限制:息屏状态下,大多数 Android 设备会暂停无障碍服务

3.2 实现方案

3.2.1 无障碍服务配置:accessibility_service_config.xml
复制代码
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
    android:accessibilityEventTypes="typeAllMask"
    android:accessibilityFeedbackType="feedbackGeneric"
    android:accessibilityFlags="flagDefault|flagRequestFilterKeyEvents"
    android:canRequestFilterKeyEvents="true"
    android:description="@string/accessibility_service_description"
    android:notificationTimeout="100" />
3.2.2 服务描述:strings.xml
复制代码
<string name="accessibility_service_description">用于在后台实时转发智能指环的多媒体控制指令至眼镜终端。</string>
3.2.3 核心代码:RingAccessibilityService.kt
复制代码
package com.metabounds.mojieglass.plugin.ring

import android.accessibilityservice.AccessibilityService
import android.util.Log
import android.view.KeyEvent
import android.view.accessibility.AccessibilityEvent
import com.metabounds.libglass.v2.req.CmdV2ReqControllerKey
import com.metabounds.mojieglass.bluetooth.manager.MBBTManager

class RingAccessibilityService : AccessibilityService() {
    override fun onAccessibilityEvent(event: AccessibilityEvent?) {
        // 娱乐模式下,指环动作可能触发窗口状态改变或焦点变化
        // 如果指环发送特定 Accessibility 类型的节点点击,可以在此拦截
    }

    override fun onInterrupt() {
        Log.e(TAG, "无障碍服务被强行中断")
    }

    override fun onServiceConnected() {
        super.onServiceConnected()
        Log.i(TAG, "🟢 指环后台无障碍中转服务已成功连接!")
    }

    override fun onKeyEvent(event: KeyEvent): Boolean {
        val keyCode = event.keyCode
        val action = event.action

        if (action == KeyEvent.ACTION_DOWN) {
            when (keyCode) {
                KeyEvent.KEYCODE_MEDIA_NEXT -> BTManager.sendCmd(CmdV2ReqControllerKey(0))
                KeyEvent.KEYCODE_MEDIA_PREVIOUS -> BTManager.sendCmd(CmdV2ReqControllerKey(1))
                KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY -> 
                    BTManager.sendCmd(CmdV2ReqControllerKey(2))
                KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_ESCAPE -> 
                    BTManager.sendCmd(CmdV2ReqControllerKey(11))
            }
            Log.i(TAG, "onKeyEvent: keyCode=$keyCode")
            return true
        }
        return super.onKeyEvent(event)
    }

    companion object {
        private const val TAG = "RingAccessibility"
    }
}
3.2.4 注册服务:AndroidManifest.xml
复制代码
<service
    android:name=".plugin.ring.RingAccessibilityService"
    android:exported="true"
    android:label="指环无障碍控制服务"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
    <intent-filter>
        <action android:name="android.accessibilityservice.AccessibilityService" />
    </intent-filter>
    <meta-data
        android:name="android.accessibilityservice"
        android:resource="@xml/accessibility_service_config" />
</service>
3.2.5 引导用户授权:MainActivity.kt
复制代码
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // ... 其他初始化
    openAccessibilitySettings()
}

private fun openAccessibilitySettings() {
    try {
        val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        }
        startActivity(intent)
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

3.3 方案总结

适用场景:需要后台持续监听、锁屏状态下使用

核心问题:

  1. 用户需要手动在系统设置中开启无障碍权限,流程较复杂

  2. 息屏状态下,部分 Android 厂商会限制无障碍服务运行

  3. 不同厂商的系统对无障碍服务的行为可能存在差异


4. 🎯 方案三:私有蓝牙协议

4.1 方案原理

目前市面上的 HID 蓝牙戒指大多基于标准 HID 协议工作,将手势转换为键盘/鼠标事件,但这种方式存在以下问题:

  1. 功能受限:只能映射到有限的标准按键

  2. 兼容性差:不同设备对 HID 事件的处理不一致

  3. 体验不佳:无法区分戒指的特定手势(如前滑、后滑)

最佳方案:与戒指厂商合作,开放私有蓝牙协议,通过 BLE GATT 特征值直接传输手势数据。

4.2 协议设计

复制代码
// 监听 GATT 特征值变化
ringGatt?.setCharacteristicNotification(gestureCharacteristic, true)
val descriptor = gestureCharacteristic.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805F9B34FB"))
descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
ringGatt?.writeDescriptor(descriptor)

// 接收手势事件
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
    val data = characteristic.value
    val eventType = data[0].toInt()
    val direction = data[1].toInt()
    val pressure = data[2].toInt()
    
    when (eventType) {
        0x01 -> handlePress(direction)
        0x02 -> handleRelease(direction)
        0x03 -> handleSlide(direction)
        0x04 -> handleLongPress(direction)
    }
}

private fun handleSlide(direction: Int) {
    val command = when (direction) {
        0x01 -> CmdV2ReqControllerKey(0)  // 上滑
        0x02 -> CmdV2ReqControllerKey(1)  // 下滑
        0x03 -> CmdV2ReqControllerKey(2)  // 左滑
        0x04 -> CmdV2ReqControllerKey(3)  // 右滑
        else -> return
    }
    BTManager.sendCmd(command)
}

4.3 方案总结

适用场景:正式产品、追求最佳体验

优点

  1. 功能完整:支持自定义手势,不受标准 HID 限制

  2. 体验最优:低延迟、高精度,支持压力感应

  3. 息屏支持:BLE 连接在息屏状态下仍可保持

  4. 扩展性强:可通过协议升级增加新功能

缺点:

  1. 需要戒指厂商配合开放协议

  2. 开发成本较高,需要双方联调


5. 📊 方案对比与选择建议

5.1 综合对比

|-------|-------|-------|-------|
| 维度 | 方案一 | 方案二 | 方案三 |
| 开发周期 | 1-2 天 | 3-5 天 | 2-4 周 |
| 用户体验 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 后台运行 | ❌ | ✅ | ✅ |
| 息屏支持 | ❌ | ❌ | ✅ |
| 手势种类 | 有限 | 有限 | 丰富 |
| 系统兼容性 | 高 | 中 | 高 |
| 用户授权 | 无需 | 需要 | 需要 |

5.2 选择建议

  1. 初期:使用方案一快速验证戒指与眼镜的联动效果

  2. 中期:接入方案二,在 App 后台和锁屏时也能工作

  3. 长期:推动戒指厂商开放私有协议,实现方案三


6. ⚡ 常见问题与解决方案

6.1 戒指连接不稳定

**问题:**蓝牙连接频繁断开,尤其在手机息屏后。

解决方案:

  1. 使用 BLE 长连接,设置合适的连接参数

  2. 在 Android 端申请 FOREGROUND_SERVICE_CONNECTED_DEVICE 权限

  3. 添加电池优化白名单,防止系统杀死蓝牙进程

6.2 事件识别不准确

**问题:**滑动和点击事件混淆,长按判定不准确。

解决方案:

  1. 调整手势识别参数(时间阈值、位移阈值)

  2. 使用状态机管理手势状态

  3. 在硬件层面(戒指端)增加防抖处理

6.3 无障碍服务被系统关闭

**问题:**部分国产 ROM 会在后台自动关闭无障碍服务。

解决方案:

  1. 在 App 启动时检查服务状态,未开启则引导用户

  2. 使用前台服务保活,增加服务优先级

  3. 提供详细的开启指引文档


7. 📝 小结

本文介绍了三种 HID 蓝牙戒指接入 AR 眼镜的方案,从简单到复杂,逐步递进:

  1. 方案一(前台界面监听):实现简单,适合开发调试和基础演示

  2. 方案二(无障碍服务):系统级拦截,支持后台和锁屏状态,但息屏受限

  3. 方案三(私有蓝牙协议):功能完整,体验最优,是正式产品的最佳选择

在实际项目中,建议采用渐进式策略:先用方案一验证可行性,再用方案二提升用户体验,最终推动戒指厂商开放私有协议实现方案三。

相关推荐
恋猫de小郭14 小时前
Flutter 全新真 3D 实现,用 flutter_scene 能开发一个「我的世界」
android·前端·flutter
woshihuanglaoshi15 小时前
数据迁移与版本管理 - Flutter在鸿蒙平台实现数据库升级策略
数据库·学习·flutter·华为·harmonyos·鸿蒙·鸿蒙系统
GitLqr1 天前
玩转 WebSocket:从原理到 Flutter 实战
websocket·网络协议·flutter
世人万千丶1 天前
参数管理_Flutter在鸿蒙平台路由参数最佳实践
学习·flutter·华为·harmonyos·鸿蒙
zhazhazsy1 天前
Flutter 多 Isolate 实战:搭建主 Isolate 数据与能力桥
flutter
math_hongfan1 天前
鸿蒙Flutter setState机制深入理解
学习·flutter·华为·harmonyos·鸿蒙
woshihuanglaoshi2 天前
参数验证_Flutter在鸿蒙平台确保路由参数类型安全
学习·flutter·华为·harmonyos·鸿蒙·鸿蒙系统
末代iOS程序员华仔2 天前
Swift 的角色演变:从“主角”到“最佳配角”——iOS 应用被 Flutter 替换,Swift 变成辅助?
flutter·ios·swift