flutterfluter文章收藏

Flutter 中自定义键盘

2020-06-28  本文已影响0人  Yue_Q

相关API解读

背景

这是关于 bug 的故事。
为了解决自定义键盘与原生键盘互相切换bottom计算不准确的问题,研究了系统键盘与自定义键盘是如何工作的。

  1. didChangeMetrics() 如何计算键盘的高度
  2. debug 模式切换键盘没有问题,到 relase 版本下却不行了?

一、基础 API

二、 window

它公开了显示的大小,核心调度程序API,输入事件回调(语言改变、 textScaleFactor 改变回调 ),图形绘制API以及其他此类核心服务。
MediaQuery 的 MediaQueryData 来源于 WidgetsBinding.instance.window
https://flutter.github.io/assets-for-api-docs/assets/widgets/window_padding.mp4

Window.viewInsets 更新流程

image.png
  1. Engine 通过 _updateWindowMetrics() 更新 window
  2. window 通过 handleMetricsChanged() 调用 RendererBingdingwidgetsBinding 去更新UI,回调 didChangeMetrics()

三、BindingBase

/// 提供单例服务的mixin们的基类
///
/// 使用on继承此类并实现initInstances方法 这个mixin在app的生命周期内只能被构建一次,在checked
/// 模式下会对此断言
///
/// 用于编写应用程序的最顶层将具有一个继承自[BindingBase]并使用所有各种[BindingBase] 
/// mixins(例如[ServicesBinding])的具体类。
/// 比如Flutter中的Widgets库引入了一个名为[WidgetsFlutterBinding]的binding,定义了如何绑定
/// 可以隐性(例如,[WidgetsFlutterBinding]从[runApp]启动),或者需要应用程序显式调用构造函数
abstract class BindingBase {
  BindingBase() {
    initInstances();//在构造函数里进行初始化
    initServiceExtensions();//初始化扩展服务
  }

  ui.Window get window => ui.window;//提供window
  
  @protected
  @mustCallSuper
  void initInstances() {//初始化,其他binding mixin可以重写此类
  }

  @protected
  @mustCallSuper
  void initServiceExtensions() {}//用于子类重写该方法,用于注册一些扩展服务。

  @protected
  bool get locked => _lockCount > 0;
  int _lockCount = 0;

/// /锁定异步事件和回调的分派,直到回调的未来完成为止。
/// 这会导致输入滞后,因此应尽可能避免。 它主要用于非用户交互时间
  @protected
  Future<void> lockEvents(Future<void> callback()) {
    developer.Timeline.startSync('Lock events');

    _lockCount += 1;
    final Future<void> future = callback();
    future.whenComplete(() {
      _lockCount -= 1;
      if (!locked) {
        unlocked();
      }
    });
    return future;
  }

  @protected
  @mustCallSuper
  void unlocked() {//解锁
    assert(!locked);
  }

/// 开发过程中使用
///
/// 使整个应用程序重新绘制,例如热装后。
///通过发送`ext.flutter.reassemble`服务扩展信号来手动进行
/// 在此方法运行时,事件被锁定(例如,指针事件未锁定)
  Future<void> reassembleApplication() {
    return lockEvents(performReassemble);
  }

/// 不会继续指向旧代码,并刷新以前计算的值的所有缓存,以防新代码对它们进行不同的计算。
/// 例如,渲染层在调用时触发整个应用程序重新绘制。
  @mustCallSuper
  @protected
  Future<void> performReassemble() {//重绘方法,需要重写
    return Future<void>.value();
  }
    ···省略一些服务扩展方法
}

其他 Bingding

各种Bingding.png

四、ServicesBinding mixin

侦听平台消息,并将其定向到defaultBinaryMessenger。另注册了 LicenseEntryCollector 做证书相关处理。
DefaultBinaryMessenger 继承自 BinaryMessenger,BinaryMessenger 是一个信使,它通过Flutter平台发送二进制数据。

image.png

DefaultBinaryMessenger 继承自 BinaryMessenger

/// 一个信使,它通过Flutter平台发送二进制数据。

abstract class BinaryMessenger {
  /// A const constructor to allow subclasses to be const.
  const BinaryMessenger();

  /// 设置一个回调,以在给定通道上接收来自平台插件的消息,而无需对其进行解码。 
  /// 给定的回调将替换当前注册的回调频道(如果有)。 要删除处理程序,请将null作为[handler]参数传递。
  void setMessageHandler(String channel, Future<ByteData> handler(ByteData message));
  
  /// 与上述类似,但不会回调native端
  void setMockMessageHandler(String channel, Future<ByteData> handler(ByteData message));

  /// 将二进制消息发送到给定通道上的平台插件。返回一个二进制的Future
  Future<ByteData> send(String channel, ByteData message);

  /// 处理 native 端发来的消息
  Future<void> handlePlatformMessage(String channel, ByteData data, ui.PlatformMessageResponseCallback callback);

}

DefaultBinaryMessenger

image.png
另外 PlatformBinaryMessenger 与 DefaultBinaryMessenger 实现类似,不同点在于 PlatformBinaryMessenger 不支持 setMockMessageHandler() 。
/// The default implementation of [BinaryMessenger].
/// 发送消息到 native,并处理 native 返回的消息
class _DefaultBinaryMessenger extends BinaryMessenger {
  const _DefaultBinaryMessenger._();

  static final Map<String, MessageHandler> _handlers =
      <String, MessageHandler>{};

  static final Map<String, MessageHandler> _mockHandlers =
      <String, MessageHandler>{};

  Future<ByteData> _sendPlatformMessage(String channel, ByteData message) {
    final Completer<ByteData> completer = Completer<ByteData>();
    ///  ui.window.sendPlatformMessage 调用 C++ 的方法。
    ui.window.sendPlatformMessage(channel, message, (ByteData reply) {
      try {
        completer.complete(reply);
      } catch (exception, stack) {
      ...
      }
    });
    return completer.future;
  }

  @override
  Future<void> handlePlatformMessage(
    String channel,
    ByteData data,
    ui.PlatformMessageResponseCallback callback,
  ) async {
    ByteData response;
    try {
      final MessageHandler handler = _handlers[channel];
      if (handler != null) {
        response = await handler(data);
      } else {
        /// 存储通道消息,直到通道被完全路由为止,即,当消息处理程序附加到框架侧的通道时。
        ui.channelBuffers.push(channel, data, callback);
        callback = null;
      }
    } catch (exception, stack) {
    ...
    } finally {
      if (callback != null) {
        callback(response);
      }
    }
  }

  @override
  Future<ByteData> send(String channel, ByteData message) {
    final MessageHandler handler = _mockHandlers[channel];
    if (handler != null)
      return handler(message);
    return _sendPlatformMessage(channel, message);
  }

  @override
  void setMessageHandler(String channel, MessageHandler handler) {
    if (handler == null)
      _handlers.remove(channel);
    else
      _handlers[channel] = handler;
    /// 允许在引擎和框架之间存储消息。
    ui.channelBuffers.drain(channel, (ByteData data, ui.PlatformMessageResponseCallback callback) async {
      await handlePlatformMessage(channel, data, callback);
    });
  }

  @override
  void setMockMessageHandler(String channel, MessageHandler handler) {
    if (handler == null)
      _mockHandlers.remove(channel);
    else
      _mockHandlers[channel] = handler;
  }
}

五、Flutter 中如何弹出系统键盘?

image.png
FocusScope.of(context).requestFocus(focusNode)
  /// 解决自定义键盘与原生键盘提供方案。
  @override
  void didChangeMetrics() {
    // window.viewInsets.bottom 用来获得键盘的高度
    if (_lastBottomViewInset < WidgetsBinding.instance.window.viewInsets.bottom) {
      _showCaretOnScreen();// 计算屏幕中位置
    }
    _lastBottomViewInset = WidgetsBinding.instance.window.viewInsets.bottom;
  }


  /// EditableText 1706
  void _handleFocusChanged() {
    /// 打开和关闭输入连接
    _openOrCloseInputConnectionIfNeeded();
    // 播放 Cursor 动画
    _startOrStopCursorTimerIfNeeded();
    // SelectionOverlay 复制粘贴相关
    _updateOrDisposeSelectionOverlayIfNeeded();

    if (_hasFocus) {
      // Listen for changing viewInsets, which indicates keyboard showing up.
      // 监听viewInsets的改变,当键盘出现的时候
      WidgetsBinding.instance.addObserver(this);
      _lastBottomViewInset = WidgetsBinding.instance.window.viewInsets.bottom;
      _showCaretOnScreen();
      if (!_value.selection.isValid) {
        // 如果我们收到焦点时选择无效,请将光标放在末尾。
        _handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), renderEditable, null);
      }
    } else {
      WidgetsBinding.instance.removeObserver(this);
      // 如果失去焦点,则清除选择和合成状态。
      _value = TextEditingValue(text: _value.text);
    }
  }
  /// EditableText 1391的构造方法
  void _openInputConnection() {
    if (!_hasInputConnection) { // 注册与原生的通信
      final TextEditingValue localValue = _value;
      _lastKnownRemoteTextEditingValue = localValue;
      _textInputConnection = TextInput.attach(// 
        this,
        TextInputConfiguration(
          inputType: widget.keyboardType,
          ...
        ),
      );
      _textInputConnection.show(); // 展示原生键盘

      _updateSizeAndTransform();
      final TextStyle style = widget.style;
      _textInputConnection
        ..setStyle(
          fontFamily: style.fontFamily,
          ...
        )
        ..setEditingState(localValue);
    } else {
      _textInputConnection.show();
    }
  }

(1)_hasInputConnection方法会初始化 TextInput() 对象,调用 binaryMessenger.setMethodCallHandler(),创建与原生的通信。_handleTextInputInvocation() 注册了交互的事件,比如:更新光标的位置、给默认值、关闭连接.

  /// TextInput 828 的构造方法
  TextInput._() {
    _channel = OptionalMethodChannel(
      'flutter/textinput',
      JSONMethodCodec(),
  );
    _channel.setMethodCallHandler(_handleTextInputInvocation);
  }

  /// 监听键盘输入中事件
  Future<dynamic> _handleTextInputInvocation(MethodCall methodCall) async {
    // The incoming message was for a different client.
    if (client != _currentConnection._id)
      return;
    switch (method) {
      case 'TextInputClient.updateEditingState':
      // 输入事件,更新输入焦点、暂停,开始光标动画
        _currentConnection._client.updateEditingValue(TextEditingValue.fromJSON(args[1]));
        break;
      case 'TextInputClient.performAction':
        // TextInputAction.send,go 事件等。通过 onSubmitted() 回调
        _currentConnection._client.performAction(_toTextInputAction(args[1]));
        break;
      case 'TextInputClient.updateFloatingCursor':
        // 更新浮动光标的位置和状态。
        _currentConnection._client.updateFloatingCursor(_toTextPoint(_toTextCursorAction(args[1]), args[2]));
        break;
      case 'TextInputClient.onConnectionClosed':
        _currentConnection._client.connectionClosed();
        break;
      default:
        throw MissingPluginException();
    }
  }

(2) TextInput.attach() 调用'TextInput.setClient'方法与 native 建立连接,configuration信息传入传给native,例如键盘的类型。

  void _attach(TextInputConnection connection, TextInputConfiguration configuration) {
  final ByteData result = await binaryMessenger.send(
      'TextInput.setClient',
      codec.encodeMethodCall(MethodCall(method, arguments)),
    );
    _currentConnection = connection;
    _currentConfiguration = configuration;
  }

(3) _textInputConnection.show() 调用 'TextInput.show 方法显示键盘。
(4) _updateSizeAndTransform() 方法,调用 TextInput.setEditableSizeAndTransform 将文本大小参数传递过去,设置输入文字的大小

  void _handleFocusChanged() {
    _openOrCloseInputConnectionIfNeeded(); /// 建立关闭连接
    _startOrStopCursorTimerIfNeeded(); /// 光标
    _updateOrDisposeSelectionOverlayIfNeeded(); /// 选择的文本
    if (_hasFocus) {
      // Listen for changing viewInsets, which indicates keyboard showing up.
      WidgetsBinding.instance.addObserver(this);
      _lastBottomViewInset = WidgetsBinding.instance.window.viewInsets.bottom; /// 获取系统键盘的高度更新视图
      _showCaretOnScreen(); 
      if (!_value.selection.isValid) { /// TextSelectionOverlay 复制/粘贴 布局
        // Place cursor at the end if the selection is invalid when we receive focus.
        _handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), renderEditable, null);
      }
    } else {
      WidgetsBinding.instance.removeObserver(this);
      // Clear the selection and composition state if this widget lost focus.
      _value = TextEditingValue(text: _value.text);
    }
    updateKeepAlive();
  }

六、InsightBank 中如何调用自定义键盘

键盘相关的事件

  static void _interceptInput() {
    // custom keyboard will cover the driver's MockMessageHandler
    // so we disable custom keyboard when run driver test
    if (_isIntercepted || moduleConfig.isDriverTestMode) return;
    _isIntercepted = true;
    ServicesBinding.instance.defaultBinaryMessenger.setMockMessageHandler(
      _channelTextInput,
      (ByteData data) async {
        final methodCall = _codec.decodeMethodCall(data);
        switch (methodCall.method) {
          case _methodShow:
            return _handleShow(methodCall, data);
          case _methodHide:
            return _handleHide(methodCall, data);
          case _methodSetClient:
            return _handleSetClient(methodCall, data);
          case _methodClearClient:
            return _handleClearClient(methodCall, data);
          case _methodSetEditingState:
            return _handleSetEditingState(methodCall, data);
          default:
            return _dispatchOriginResponse(data);
        }
      },
    );
  }

七、如何解决自定义键盘与原生键盘互相切换问题

(1)之前的解决方案

image.png
final insets = window.viewInsets.bottom / window.devicePixelRatio;就是键盘的高度

(2)_showCaretOnScreen()
_showCaretOnScreen() 起到的作用如下

  1. 计算文字,横向滚动
  2. 计算 bottomSpacing ,将输入框显示在屏幕上合适的位置。

问题:

  1. 为什么使用的是:addPostFrameCallback 是否可以换成 setState()
    换成 setState 后输入文字出现闪烁的情况

  2. debug 模式下,键盘互相切换没有问题,release版本却不行(偶现问题)。
    我猜想,一下前提条件

由于 release 版本更快,window 更新时,_showCaretOnScreenScheduled 控制着 _showCaretOnScree() 无法更新,所以出现了输入框被遮挡的情况。

同理 debug 版本相对慢,意味着 _showCaretOnScree() 执行完成后,因为 window 更新又执行了一遍

  @override
  void didChangeMetrics() {
    // 展示键盘的事件
    if (_lastBottomViewInset < WidgetsBinding.instance.window.viewInsets.bottom) {
      _showCaretOnScreen();
    }
    _lastBottomViewInset = WidgetsBinding.instance.window.viewInsets.bottom;
  }

  // 调用时机,didChangeMetrics(), initState(), 输入字符时候。
  void _showCaretOnScreen() {
    if (_showCaretOnScreenScheduled) {
      return;
    }
    _showCaretOnScreenScheduled = true;
    // 这里没有使用 setState 而是使用了 addPostFrameCallback
    SchedulerBinding.instance.addPostFrameCallback((Duration _) {
      _showCaretOnScreenScheduled = false;
      if (_currentCaretRect == null || !_scrollController.hasClients) {
        return;
      }
      final double scrollOffsetForCaret = _getScrollOffsetForCaret(_currentCaretRect);
      /// 1. 文字横向滚动,如果输入文字超过一屏幕
      _scrollController.animateTo(
        scrollOffsetForCaret,
        duration: _caretAnimationDuration,
        curve: _caretAnimationCurve,
      );
      final Rect newCaretRect = _getCaretRectAtScrollOffset(_currentCaretRect, scrollOffsetForCaret);
      // Enlarge newCaretRect by scrollPadding to ensure that caret is not
      // positioned directly at the edge after scrolling.
      double bottomSpacing = widget.scrollPadding.bottom;
      if (_selectionOverlay?.selectionControls != null) {
        final double handleHeight = _selectionOverlay.selectionControls
          .getHandleSize(renderEditable.preferredLineHeight).height;
        final double interactiveHandleHeight = math.max(
          handleHeight,
          kMinInteractiveDimension,
        );
        final Offset anchor = _selectionOverlay.selectionControls
          .getHandleAnchor(
            TextSelectionHandleType.collapsed,
            renderEditable.preferredLineHeight,
          );
        final double handleCenter = handleHeight / 2 - anchor.dy;
        bottomSpacing = math.max(
          handleCenter + interactiveHandleHeight / 2,
          bottomSpacing,
        );
      }
      final Rect inflatedRect = Rect.fromLTRB(
          newCaretRect.left - widget.scrollPadding.left,
          newCaretRect.top - widget.scrollPadding.top,
          newCaretRect.right + widget.scrollPadding.right,
          newCaretRect.bottom + bottomSpacing,
      );

       /// 2. 找到输入框光标的位置,显示在屏幕上
      _editableKey.currentContext.findRenderObject().showOnScreen(
        rect: inflatedRect,
        duration: _caretAnimationDuration,
        curve: _caretAnimationCurve,
      );
    });
  }

总结:

  1. nativeBottomInsets 可以通过 window 直接获取。
  2. addPostFrameCallback() 具有延迟计算的作用,第一帧使用老的 insets,这样焦点还在原来的位置不变。
    即使,出现了以上情况,
    https://phabricator.d.xiaomi.net/D224058
 static double get nativeBottomInsets => window.viewInsets.bottom / window.devicePixelRatio;

 /// 在 ScrollView 中,原生键盘切换自定义键盘,会发生 ScrollView 滚动位置不准确问题。
 ///
 /// 原因:ScrollView 的计算依赖与第一帧 viewInsets 去计算。
 /// updateBottomInsets() 会在 [window] 更新 viewInsets 之前触发。所以使用 [onPostFrame] 延迟处理。
 /// 另外,setState() 可以更新 viewInsets 重绘,但在 release 版本下会变得不可控。
 void updateBottomInsets(double insets) {
   // 收起自定义键盘时,不能立即更新 insets ,会造成 ScrollView 滚动位置不准确。
   if (insets < customBottomInsets) {
     return UiUtils.onPostFrame(() => setState(() => customBottomInsets = insets));
   }
   // 展示自定义键盘需要立刻更新 insets, 会造成 ScrollView 滚动位置不准确
   setState(() => customBottomInsets = insets);
 }`
上一篇下一篇

猜你喜欢

热点阅读