7

Flutter 疑难杂症系列:键盘原理及常见问题解决方案

 2 years ago
source link: https://my.oschina.net/u/4180867/blog/5310865
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

作者:字节跳动终端技术——候华勇 & 林学彬

​一、背景

在使用 Flutter 的过程中我们经常会遇到与键盘相关联的问题,在 Flutter 的官方 issue 中以keyboard 作为关键字检索也会发现有比较多的问题,我们在业务发展的进程之中也遇到并解决了一些相关问题,本文主要描述 Flutter 调用软键盘的相关流程帮助大家理解键盘是如何弹出以及提供几个目前已知键盘问题的解决方案。

二、Flutter键盘流程及原理

接下来本文将从键盘弹出流程、Flutter 页面重绘以及页面收缩动画以及我们已知的问题这几个部分展开介绍。

图 2-1 Flutter TextField 调起键盘

查看 Flutter 源码可以看到键盘弹出流程,以 TextField 为例:

图 2-2 Flutter Android 端调用键盘流程图

通过图 2-2 我们知道,在Android 端点击 TextField 之后,通过 TextInputPlugin 调用系统的 InputMethonManager 的 showSoftInput 方法,实现了键盘的调起逻辑。在 iOS 端流程基本类似,是在 Native 端实现UITextInput协议的 FlutterTextInputView 实例通过调用becomeFirstResponder实现键盘弹出。在 图 2-1 我们可以看到,键盘吊起之后 Flutter 页面整体上移,并且键盘经过了一个渐隐及平移动画的过程之后出现,那么这里是如何实现的呢?上述流程分为两个点:

  1. 键盘弹出动画由系统触发,不受 Flutter 控制。
  2. Flutter 页面上移,添加键盘开始触发 FlutterView 的 WindowInsets 特性的改变,引起页面的重绘。

2.1、键盘调起之后页面重绘逻辑

图 2-1-1 调起键盘后,WindowInsets 参数变更及传递路径图

上述流程看起来路径虽然比较长,但是逻辑并不复杂,可以简单归纳为如下几步:

  • 键盘弹出占用 FlutterView 的空间,造成 FlutterView 的 WindowInsets 属性变化
  • WindowInsets 变化后,引起 Metrics 的变化,从 Platform 线程传递到 UI 线程
  • 最后调用 scheduleForceFrame 强制触发绘制的流程

2.2、页面收缩动画

从 图 2-1 可以看到,Metrics 的变化引起了页面的刷新只有一帧的绘制,变动比较生硬,可以在页面 Widget 外框加上 AnimatedContainer ,并根据 window.viewInsets.bottom / window.devicePixelRatio 的值的变化,设置不同的 Padding,实现比较平滑的动画效果。效果如下:

图 2-2-1 键盘动画

三、键盘相关问题

3.1 键盘动画卡顿

我们的一些业务反馈部分型号的手机上键盘弹出的过程中页面卡顿比较严重,下面提供的动图也可以明显的感受到在键盘弹出页面做动画的时候有一些卡顿。

图 3-1-1  键盘动画卡顿

我们随后也使用不同的手机机型在相同的场景下使用Systrace进行对比:

图 3-1-2  在 三星 S10 上的键盘卡顿 Systrace 图

图 3-1-3  正常手机上的键盘卡顿 Systrace 图通过对比图 3-1-2 和 图 3-1-3 我们可以比较直观的察觉到造成该问题的原因了——正常手机仅在动画开始的时候会触发一次页面的 build,而 S10 是每一帧都在重新触发。那么目前的关键是要找出页面被触发 build 操作的原因了。在此之前,我们不妨先看看具体哪些内容被 build 了,这个时候我们就需要借助 Flutter 的 track-widget-creation 功能,我们在 profie 模式下抓取下 timeline:

图 3-1-3  页面键盘弹启动画首帧 Timeline 图由于图 3-1-3 里面很多类涉及到业务逻辑,所以这里直接描述下分析结果:除图 3-1-3 的红框部分为真实需要做动画的内容,因而出现 build 行为是正常的。仔细观察每个 子 Widget 数,从上往下观察,这几个都包含了一个叫 MediaQuery 的内容。在此我们先简单介绍下 MediaQuery

图 3-1-4 MediaQuere UML 图从图中可知 MediaQuery 继承了 InheritedWidget,而 InheritedWidget 是 Flutter 内用于 widget 内数据传入的类,核心方法是 updateShouldNotify,用于判断是否相关的数据有变更行为。其中 MeidaQuery 的 updateShouldNotify 函数如下:

@override
// oldWidget.data is a MediaQueryData
bool updateShouldNotify(MediaQuery oldWidget) => data != oldWidget.data;

而 MediaQueryData 的 == 如下:

@override
bool operator ==(Object other) {
  if (other.runtimeType != runtimeType)
    return false;
  return other is MediaQueryData
      && other.size == size
      && other.devicePixelRatio == devicePixelRatio
      && other.textScaleFactor == textScaleFactor
      && other.platformBrightness == platformBrightness
      && other.padding == padding
      && other.viewPadding == viewPadding
      && other.viewInsets == viewInsets
      && other.alwaysUse24HourFormat == alwaysUse24HourFormat
      && other.highContrast == highContrast
      && other.disableAnimations == disableAnimations
      && other.invertColors == invertColors
      && other.accessibleNavigation == accessibleNavigation
      && other.boldText == boldText
      && other.navigationMode == navigationMode;

分析到此是否发现什么端倪了?在第二章中,我们提到 “ 键盘弹起之后,会引起 FlutterView 的 WindowInset 的变化”,这里刚好是变更了 ViewInsets,那么就触发了 MediaQuery 的 updateShouldNotify 返回 true 引起子树的 build 行为。让我们看下正常 hello world 代码是如何的:

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

那么这样我们可以简单的弄一个 widget 树的层级:

MyApp
    MaterialApp
        WidgetsApp
            Shortcuts
                Actions
                    FocusTraversalGroup
                        _MediaQueryFromWindow
                            MediaQuery
                                Localizations
                                    ...
                                        HomePage

我们再来看下 _MediaQueryFromWindow 的核心函数:

class _MediaQueryFromWindow extends StatefulWidget {
  const _MediaQueryFromWindow({Key key, this.child}) : super(key: key);
  final Widget child;
  @override
  _MediaQueryFromWindowsState createState() => _MediaQueryFromWindowsState();
}

class _MediaQueryFromWindowsState extends State<_MediaQueryFromWindow> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    // 注册 WidgetsBinding 的监听
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void didChangeMetrics() {
    // 当 size 变化的时候,触发刷新
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    // 更新 MediaQueryData 值
    MediaQueryData data = MediaQueryData.fromWindow(WidgetsBinding.instance.window);
    return MediaQuery(
      data: data,
      child: widget.child,
    );
  }
}

从上述代码可知 MediaQueryFromWindows 通过 监听 WidgetsBinding 监听的诸如 Viewport Size 、屏幕亮度、字体大小等系统行为,从而通过变更 MediaQueryData 并通过 MediaQuery 自顶向下传递信息。自此是否有思路了? 正常手机是在键盘吊起的时候触发了一次 WindowInsets 值的变化,而在三星 S10 上则是触发了多次。这里可以了解到两者的系统的键盘弹起动画的处理方式。

  • 正常手机:一次申请高度为 400 的空间,然后通过变更键盘 View 的 translateY 做出场动画
  • 三星 S10:每次申请不同的高度,0, 10, 40, .... 300, 350, 400 如此实现动画的过程

如此每次都会触发 Flutter Metirics 的变化,造成大面积的 buid 行为。解决方式: 1、我们在 Flutter 里面添加了 Perforamce.setCurrentIsKeyboardScene 函数,当进入需要键盘的场景之后,将上述开关标记为 true,如此在调用 keyboard 的 show 及 hide 函数的 300 ms 内,我们将屏蔽因 WindowInsets 引起的 MediaQuery 的变化;2、针对卡顿的三星 S10 及机型,我们主动监听 Metrics 的变化,如果在 32 ms 内连续收到 2次 Metrics 的变化,就将 第三章讲到的 AnimatorContaner 变为 Padding效果如下图所示:

图 3-1-5  三星 S10 上的优化后的 键盘 Systrace 图

3.2 锁屏后键盘无法收回

我们遇到的另外一个问题是,当键盘处于弹出状态的时候锁屏,当屏幕重新解锁之后键盘无法收起,具体出现问题的动图如下:

图  3-2-1 锁屏开屏后输入框失去焦点切键盘未收起首先我们先来关注下键盘收起的逻辑图,在 图 2-1 的基础下,我们很快就可以得到相应的流程图:

图 3-2-2  Flutter Android 端隐藏键盘流程图那么如何排查这个问题?首先我们观察到了开屏后 EditText 是失去焦点的状态,那么 _handleFoucusChanged 一定是调用了,不管如何我们可以首先在关键节点添加日志。通过日志分析,整体流程是 OK,EditText 失去了焦点、触发了 TextInput.hide 的 MessageChannel 的调用,这个时候我们看下 TextInputChannel  的 onMethodCall 方法:

public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
  if (textInputMethodHandler == null) {
    return;
  }

  switch (method) {
      case "TextInput.hide":
      textInputMethodHandler.hide();
      isKeyBoardShow = false;
      result.success(null);
      break;
      ...
  }
}

从上述代码可以看到,在某个情节下 textInputMethodHandler 被赋值为 null 从而造成了当前的问题。

SomeActivity.onPause()
    FlutterView.detachFromFlutterEngine()
        TexInputPlugin.destroy

            TextInputChannel.setTextInputMethodHandler(null)

注:上述逻辑因为要考虑混合路由及引擎复用,才会在 onPause 的时候进 detachFromFlutterEngine 操作。

如何修复?我们记录下键盘的是否 show 过,之后在  TextInputChannel.setTextInputMethodHandler(null) 的时候,调用下 hide 修复该问题。

3.3 iOS 上搜狗输入法长按发送未换行

业务同时反馈给我们的问题还有就是在使用三方输入法的时候的一些问题,这里是搜狗输入法,当长按回车之后没法进行换行,而是在后面附加了一个空格。

图 3-3-1 iOS 上搜狗输入法长按发送的异常(左)和修复 (右)
如图 3-3-1 所示,操作键盘之后,Flutter 像是添加了一个回车,而修复后则是正常的进行了换行的行为。这个问题稳定出现,我们可以直接写一个简单的Example 调试,代码如下:

TextField(
  keyboardType: TextInputType.multiline, // 必现是 multiline 否则回车也不生效
  maxLines: 5,
  minLines: 1,
  textInputAction: TextInputAction.send, // 将键盘的回车键显示为 发送按钮
  onChanged: (value) {
    // 文本变化的回调
  },
  onSubmitted: (_) {
   // 点击发送按钮的回调
  },
  decoration: const InputDecoration( // 以下是纯为了看起来美观点。。。。
    hintText: '输入',
    filled: true,
    fillColor: Colors.white,
    contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
    isDense: true,
    border: const OutlineInputBorder(
      gapPadding: 0,
      borderRadius: const BorderRadius.all(Radius.circular(4)),
      borderSide: BorderSide(
        width: 1,
        style: BorderStyle.none,
      ),
    ),
  ),
),

首先我们怀疑的是字符的问题,然后想回车这种,其实通过 String 显示并不是很直观,我们可以直接把 String 的每个 char 打印出来,如此我们只要重写下 onChanged 的回调:

for( int v in value.codeUnits) {
  print('char code is ${v}');
}

当我们长按发送按钮的时候,得到的结果是 13。之后我们将 textInputAction: TextInputAction.send  注释掉,让其回到正常的回车模式,得到的结果是 10。之后我们通过查询 ASCII  表,得到:

编码 含义 String 中的表示 10 LF 换行,新起一行 '\n' 13 CR 归位,一般指回到当前行的最开始 '\r'

如此并验证了 “输入的字符有问题” 的假设。修改起来就比较容易,因为EditableTextState.updateEditgingValue的关系可以在 Framework 层修改,也可以在 FlutterTextInputPlugin 中进行修改,将字符进行替换即可。

3.4 iOS 光标动画使得 CPU 飙升

在 iPhone 12 上做了一个简单的测试 ( Profile 模式,性能等切记不要使用 Debug 模式),一旦 EditText 获取到光标之后, CPU 从 4% 上升到了 16%。

图 3-4-1 iOS 光标动画 CPU 占用图光标动画逻辑在 EditableTextState 中,耗时 250ms 从 alpha 1.0 至 0.0 或 0.0 值 1.0,然后间隔 150 ms,之后再  250 ms 的动画,如此往复。最开始的怀疑点是光标相关的绘制比较耗时,目前光标和 Text 相关是在一次 paint 中完成,如此只要两者分离,就可以减少 CPU 的占用。但经过分析之后,发现这是 Flutter 动画框架刷新逻辑上的问题。目前比较可行的方案是,将光标动画和 Android 端对齐 ( Android 端是展示  alpha 为 1.0 或 0.0 没有中间的过度过程),以此来降低 cpu 的占用,详情对比如下:图 3-4-2  iOS 和 Android 的 Text光标动画区别

图 3-4-3 iOS 光标动画设置为 Android 模式后的 CPU 占用图

3.5 iOS 上键盘收起之后,光标依旧存在

在iOS的原生输入框处于输入状态的时候光标出现并且闪动,当输入法收回之后输入光标消失。而在Flutter之后的表现稍显不一致,当键盘收回之后光标依然存在闪动。图 3-5-1 键盘收起后关闭依然存在
从上图可知,在 iOS 上原生应用在用户手动收起虚拟键盘之后,光标消失。但是 Flutter 依旧保持光标闪动的动画。本身这并不是特别大的问题,但是由于3.4问题的存在就导致了额外的cpu消耗,本身并没有任何操作,却消耗了资源。修复:我们在 iOS 端上对键盘收起的动作做了相应的监听,实现了和原生一直的行为逻辑,监听键盘消失的通知,对光标进行处理。问:那 Android 如何呢?答:Android 原生却是键盘收起之后依旧闪动光标。

3.6 iOS12+ 长按系统输入法空格光标卡顿不灵敏

iOS 12 以后,使用系统自带输入法长按空格,也可以实现快捷移动光标。快捷移动光标可以有效帮助我们提升打字效率,在手机输入文字的时候需要频繁的修改和移动光标位置进行编辑,活用移动光标可以快速定位到想要更改文字的地方,如下图

图 3-6-1 系统输入法长按选择功能在 Flutter 中这个功能存在一定的缺陷,当输入了非英文字符之后会出现光标卡顿,无法进行顺畅的移动。

图 3-6-2 Flutter中长按选择功能
文章上面也提到过,Flutter 的文本输入的整体框架是基于 Native 来进行实现,然后通过 FlutterTextInputPlugin 进行 Flutter 端和 Native 端的数据同步,而键盘相关操作基本也是在Native 侧进行然后同步给 Flutter。这个系统输入法长按选中问题在很多Native实现的自定义输入控件中也会出现这个现象,在 Apple 官方的 UITextInteraction 的文档中有这么一段话:

PS : UITextInteraction | Apple Developer Documentation然后在 FlutterTextInputView 中添加一个 UITextInteraction 就正常了。

  if (@available(iOS 13.0, *)) {
   UITextInteraction* interaction = [UITextInteraction textInteractionForMode:UITextInteractionModeEditable];
   interaction.textInput = self;
   [self addInteraction:interaction];
  }

Google 官方修复 MR:https://github.com/flutter/engine/pull/26486

在 Flutter 中遇到键盘相关问题的时候,了解整个键盘的执行流程的话会更加容易查找问题然后解决此类问题。Flutter 中键盘与输入功能紧密相连,Flutter 输入功能的本质是借助 Native 的输入能力通过 Channel 在 Flutter 和 Native 侧进行数据的同步,任何一侧的数据发生变化都会被同步到另一侧(如文本变化、选择和光标移动)有一些问题会在这个同步的过程之中产生。而当键盘弹出的时候时候导致的页面变动则是由于 WindowInset 变化之后引起的 Metrics 发生变化,最后调用 scheduleForceFrame 强制触发绘制。对我们来说需要做的就是针对问题产生的不同场景分析对应的流程和代码,在分析问题的时候一些工具比如 Systrace 和 Instruments,也能帮助我们找到一些蛛丝马迹。在使用键盘过程中有一些性能相关的问题我们也在不断的探索,如果大家有好的思路欢迎提出。

关于字节终端技术团队

字节跳动终端技术团队(Client Infrastructure)是大前端基础技术的全球化研发团队(分别在北京、上海、杭州、深圳、广州、新加坡和美国山景城设有研发团队),负责整个字节跳动的大前端基础设施建设,提升公司全产品线的性能、稳定性和工程效率;支持的产品包括但不限于抖音、今日头条、西瓜视频、飞书、懂车帝等,在移动端、Web、Desktop等各终端都有深入研究。

就是现在!客户端/前端/服务端/端智能算法/测试开发 面向全球范围招聘!一起来用技术改变世界,感兴趣请联系 [email protected],邮件主题 简历-姓名-求职意向-期望城市-电话

火山引擎应用开发套件MARS是字节跳动终端技术团队过去九年在抖音、今日头条、西瓜视频、飞书、懂车帝等 App 的研发实践成果,面向移动研发、前端开发、QA、 运维、产品经理、项目经理以及运营角色,提供一站式整体研发解决方案,助力企业研发模式升级,降低企业研发综合成本。可点击链接进入官网查看更多产品信息。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK