フラッター適応遠隔制御ソリューション

我正在参加「掘金·启航计划」

序文

デスクトップデバイス上での Flutter の実践において、私たちはその技術とビジネスの実現可能性を完全に検証するためにいくつかのプロジェクトを実施してきました。同時に、Flutter に対しては、リモコンの適応など、より多くの要件が提示されています。

残念ながら、Flutter 公式はリモコンの適応をまったく考慮しておらず、そのフォーカス制御にも多くの問題 (スイッチのちらつき、高速操作でフォーカスが応答しないなど) があるため、この壁を自分たちで打ち破る必要があります。

リモコンはどのように機能しますか?

リモコンは実際にはソフトウェア レベルのボタン イベントであり、キーボードに相当しますAndroid 自体には TV 機器が搭載されているため、すでに完全な制御メカニズムが備わっています。

Android では、ペアだけが必要ですview声明可以获取焦点,指定焦点关系即可このフレームワークは、対応するコントロールに自動的に焦点を当てるのに役立ちます。単純な UI 状態は SelectDrawable で解決できます。

Flutterの宣言型UIと比較すると、Android命名式UI有天然的优势,通过生成的R文件,框架层可以通过FocusFinder轻易的找到对应的view,处理好焦点的分发流转和事件处理。

Flutterリモートコントロールの難しさ

  • Flutter は宣言型 UI であるため、コンパイル時にリソース クラスを生成するのは困難です。
  • Widget は単なる設定情報です。開発中に Widget の情報を取得したい場合は、Key 値を通じて Rander オブジェクトを取得する必要があります。方向を決定したり、イベントを実行したりするために各コンポーネントの情報を取得するのは非常に面倒です。
  • 実践を通じて、Flutter focusNode にはまだ多くの問題があることがわかりました。リクエスト プロセス中に、フォーカスが時折リクエストを繰り返し、フォーカスの急速な切り替えがエラーに応答します。これは Android デバイスでより顕著です。
  • Flutter のマテリアル コンポーネント ライブラリの一部のコントロールは、それ自体で focusNode を保持し、デフォルトでキーボード イベントに応答します。これは、独自のフォーカス管理と競合します。

実現原理

Flutter はこの転送をまったく支援しないため、Flutterの組み込み FocusNode 転送メカニズムを放棄してください。
自分でイベントをリッスンし、フォーカスを分散すると、ビジネス側が独自に ControlManager に参加して、現在どのフォーカスがトリガーされているかを確認します。个人.png

実装手順

1. グローバルリモコンイベント受信コンポーネント

既然Flutter无法轻松的生成焦点的流转关系,那我们就自行处理,定义一个全局的事件接收器。这个组件需要接收一个child,一般把MaterialApp传入。
组件内部接收按键事件,解析后进行事件分发。

class ControlApp extends StatelessWidget {
  ControlApp({Key? key, required this.app}) : super(key: key);

  final Widget app;

  final FocusNode remoteControl = FocusNode(debugLabel: 'Flutter遥控器');

  final EventBus _eventBus = EventBus.instance;

  @override
  Widget build(BuildContext context) {
    return RawKeyboardListener(
      onKey: (RawKeyEvent event) {
        if (event is RawKeyDownEvent) {
          RawKeyEventDataWindows rawKeyEventData =
              event.data as RawKeyEventDataWindows;
          switch (rawKeyEventData.keyCode) {
            case 38: //KEY_UP
              _eventBus.fire(EventType.up);
              break;
            case 40: //KEY_DOWN
              _eventBus.fire(EventType.down);
              break;
            case 37: //KEY_LEFT
              _eventBus.fire(EventType.left);
              break;
            case 39: //KEY_RIGHT
              _eventBus.fire(EventType.right);
              break;
            case 13: //KEY_ENTER
              _eventBus.fire(EventType.enter);
              break;
            case 8: //KEY_BACK
              _eventBus.fire(EventType.back);
              break;
            default:
              break;
          }
        }
      },
      focusNode: remoteControl,
      child: app,
    );
  }
}
复制代码

2. 统一焦点接收类管理

ControlApp统一分发后,ControlManager会进行接收,同时ControlManager将管理所有的遥控器监听者,提供addListenerremoveListenersyncFocusKeychangeTopFocus等接口;保证遥控事件只发送给最后加入的监听者。

final controlManager = ControlManager.instance;

class ControlManager {
  ControlManager._() {
    initEventListen();
  }

  static final ControlManager instance = ControlManager._();

  final EventBus _eventBus = EventBus.instance;

  final ObserverList<ControlListener> _listeners =
      ObserverList<ControlListener>();

  bool receiveEvents = true;

  int _currListenerIndex = 0;

  // 记录所有listener的焦点集合
  final List<List<FocusCollection>> _allCollectionList = [];

  // 记录每个listener选择的焦点集合
  final List<FocusCollection?> _selectedCollectionList = [];

  void addListener(ControlListener listener,
      {required List<FocusCollection> focusList}) {
    _listeners.add(listener);
    _allCollectionList.add(focusList);
    _currListenerIndex++;
    _selectedCollectionList.add(null);
  }

  void removeListener(ControlListener listener) {
    _listeners.remove(listener);
    _selectedCollectionList.removeLast();
    _allCollectionList.removeLast();
    _currListenerIndex--;
  }

  /// 修改顶层焦点集合
  void changeTopFocus(List<FocusCollection> focusList,
      {FocusCollection? curr}) {
    _allCollectionList.last = focusList;
    if (curr == null) {
      _selectedCollectionList[_currListenerIndex - 1] = null;
      _listeners.last.onCancelSelect();
    } else {
      _selectedCollectionList[_currListenerIndex - 1] = curr;
      _listeners.last.onWidgetSelect(curr);
    }
  }

  /// 是否暂停处理遥控器事件
  setReceiveStatus(bool isReceive) {
    receiveEvents = isReceive;
  }

  /// 同步焦点的建值
  syncFocusKey(FocusCollection focus) {
    _selectedCollectionList[_currListenerIndex - 1] = focus;
  }

  bool get focusIsEmpty => _allCollectionList[_currListenerIndex - 1].isEmpty;

  initEventListen() {
    _eventBus.on().listen((event) {
      if (!receiveEvents) return;
      switch (event) {
        case EventType.up:
        case EventType.left:
          if (focusIsEmpty) return;
          _listeners.last.onWidgetSelect(getFocusCollection(event));
          break;
        case EventType.enter:
          if (focusIsEmpty) return;
          if (_selectedCollectionList[_currListenerIndex - 1] == null) return;
          _listeners.last.onEnterEvent(getFocusCollection(event));
          break;

        case EventType.down:
        case EventType.right:
          if (focusIsEmpty) return;
          _listeners.last.onWidgetSelect(getFocusCollection(event));
          break;

        case EventType.back:
          if (focusIsEmpty) {
            _listeners.last.onBackEvent(null);
          } else {
            _listeners.last.onBackEvent(getFocusCollection(event));
          }
          break;
      }
    });
  }

  /// 获取命中的焦点集合
  FocusCollection getFocusCollection(EventType type) {
    if (_selectedCollectionList[_currListenerIndex - 1] == null) {
      var c = _allCollectionList[_currListenerIndex - 1].first;
      _selectedCollectionList[_currListenerIndex - 1] = c;
      return c;
    } else {
      FocusCollection c = _selectedCollectionList[_currListenerIndex - 1]!;
      List<FocusCollection> l = _allCollectionList[_currListenerIndex - 1];
      switch (type) {
        case EventType.up:
          if (c.up != null) c = l.firstWhere((e) => e.currKey == c.up);
          break;
        case EventType.down:
          if (c.down != null) c = l.firstWhere((e) => e.currKey == c.down);
          break;
        case EventType.left:
          if (c.left != null) c = l.firstWhere((e) => e.currKey == c.left);
          break;
        case EventType.right:
          if (c.right != null) c = l.firstWhere((e) => e.currKey == c.right);
          break;
        case EventType.enter:
        case EventType.back:
          break;
      }
      _selectedCollectionList[_currListenerIndex - 1] = c;
      return c;
    }
  }
}
复制代码

3. 提供遥控器监听抽象类

需要监听遥控事件的页面(或组件),需要混入这个类,然后在对应生命钩子中调用ControlManager中的addListener和removeListener事件。

abstract class ControlListener {
  void onWidgetSelect(FocusCollection collection) {}

  void onCancelSelect() {}

  void onEnterEvent(FocusCollection collection) {}

  void onBackEvent(FocusCollection? collection) {}
}
复制代码

重写ControlListener中提供的接口,就能准确接收到ControlManager发出的事件拉!

class _MyHomePageState extends State<MyHomePage> with ControlListener {
  int _counter = 0;

  String selectKey = ""; // 选择的焦点key

  // 自行维护焦点的关系
  List<FocusCollection> focusList = [
    FocusCollection("one", right: "two", down: "three"),
    FocusCollection("two", left: "three", up: "one"),
    FocusCollection("three", up: "one", right: "two")
  ];

  @override
  void initState() {
    super.initState();
    controlManager.addListener(this, focusList: focusList);
  }

  @override
  void dispose() {
    super.dispose();
    controlManager.removeListener(this);
  }

  @override
  void onWidgetSelect(FocusCollection collection) {
    super.onWidgetSelect(collection);
    setState(() {
      selectKey = collection.currKey;
    });
    print(">>>>> onWidgetSelect ${collection.currKey}");
  }

  @override
  void onBackEvent(FocusCollection? collection) {
    super.onBackEvent(collection);
    print(">>>>> onBackEvent ${collection?.currKey}");
  }

  @override
  void onEnterEvent(FocusCollection collection) {
    super.onEnterEvent(collection);
    int i = focusList.indexOf(collection);
    if (i == 2) {
      _incrementCounter();
    } else if (i == 0) {
      Navigator.push(
          context, MaterialPageRoute(builder: (context) => SecondPage()));
    }
  }

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Stack(children: [
        Positioned(
          top: 20,
          left: 20,
          child: FocusWidget(
            hasFocus: selectKey == "one",
            child: const Text('You have pushed the button this many times:'),
          ),
        ),
        Positioned(
          top: 60,
          right: 20,
          child: FocusWidget(
            hasFocus: selectKey == "two",
            child: Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ),
        ),
        Positioned(
          top: 60,
          left: 20,
          child: FocusWidget(
            hasFocus: selectKey == "three",
            child: GestureDetector(
              onTap: _incrementCounter,
              child: Icon(Icons.add, color: Theme.of(context).primaryColor),
            ),
          ),
        ),
      ]),
    );
  }
}
复制代码
  • 从上面的例子可以看到,业务端是需要提供焦点列表,自行指定好上下左右的焦点关键字;这个步骤其实就跟Android中,在xml指定焦点关系一样。
 android:nextFocusDown="@id/button1"
 android:nextFocusUp="@id/button2"
 android:nextFocusLeft="@id/button3"
 android:nextFocusRight="@id/button4"
复制代码
  • 同时例子中也有FocusWidget这个控件,我目前是完全摈弃了Flutter自带的这套FoucusNode,而是明确告诉Widget你被选中了、被点击了。这能解决FoucusNode本身的一些缺陷。
  • 当遇到带有FocusNode的组件,比如Input、Button、InkWell等,请一定要自主传入FoucusNode,并且设置skipTraversal为true,这样这个焦点就不会自动接收键盘事件,不会导致焦点冲突。
FocusNode focusNode_1 = FocusNode(skipTraversal: true);
复制代码

继续优化

在上面的方案中,我们发现一个最通过的点就是:接入相对复杂,特别是点击事件还要由页面级别通知到对应组件自行去执行。对于封装程度高的项目,这是很致命的。

因此我们把FocusWidget抽象出来,页面级别不再维护复杂的焦点关系列表;而是在需要处理焦点的控件外部包裹FocusWidget,传入需要的focus、nextFocusDown、nextFocusUp、nextFocusLeft、nextFocusRight;以及需要的UI状态、点击事件等。之后遥控器插件会自动找寻对应的FocusWidget,显示其选中状态,并触发传入的点击事件!

将焦点关系和事件处理完全融合在一起,不需要单独声明和维护,从而快速的让我们的应用适配遥控器 ~

写在最后

这个方案目前在业务上已经得到实践认可,但是细节以及优化方案还有很多需要注意的。
总之遥控器在Flutter上是个相对难啃的坑,只能不断往前爬,往前填。

おすすめ

転載: juejin.im/post/7228888718656127036