Flutter 疑難雜症系列:鍵盤原理及常見問題解決方案

作者:字節跳動終端技術——候華勇 & 林學彬

​一、背景

在使用 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、 運維、產品經理、項目經理以及運營角色,提供一站式整體研發解決方案,助力企業研發模式升級,降低企業研發綜合成本。可點擊鏈接進入官網查看更多產品信息。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章