作者:位元組跳動終端技術——侯華勇 & 林學彬
一、背景
在使用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 的 WindowInsert 特性的改變,引起頁面的重繪
2.1、鍵盤調起之後頁面重繪邏輯
圖 2-1-1 調起鍵盤後,WindowInsert 引數變更及傳遞路徑圖
上述流程看起來路徑雖然比較長,但是邏輯並不複雜,可以簡單歸納為如下幾步:
- 鍵盤彈出佔用 FlutterView 的空間,造成 FlutterView 的 WindowInsert 屬性變化
- WindowInsert 變化後,引起 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
自頂向下傳遞資訊。
自此是否有思路了?
正常手機是在鍵盤吊起的時候觸發了一次 WindowInsert 值的變化,而在三星 S10 上則是觸發了多次。這裡可以瞭解到兩者的系統的鍵盤彈起動畫的處理方式。
- 正常手機:一次申請高度為 400 的空間,然後通過變更鍵盤 View 的 translateY 做出場動畫
- 三星 S10:每次申請不同的高度,0, 10, 40, .... 300, 350, 400 如此實現動畫的過程
如此每次都會觸發 Flutter Metirics 的變化,造成大面積的 buid 行為。
解決方式:
1、我們在 Flutter 裡面新增了 Perforamce.setCurrentIsKeyboardScene
函式,當進入需要鍵盤的場景之後,將上述開關標記為 true,如此在呼叫 keyboard 的 show 及 hide 函式的 300 ms 內,我們將遮蔽因 WindowInsert 引起的 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, // 將鍵盤的Enter鍵顯示為 傳送按鈕
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以後,使用系統自帶輸入法長按空格,也可以實現快捷移動游標。快捷移動游標可以有效幫助我們提升打字效率,在手機輸入文字的時候需要頻繁的修改和移動游標位置進行編輯,活用移動游標可以快速定位到想要更改文字的地方,如下圖
在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:github.com/flutter/eng…
四、總結
在Flutter中遇到鍵盤相關問題的時候,瞭解整個鍵盤的執行流程的話會更加容易查詢問題然後解決此類問題。
Flutter中鍵盤與輸入功能緊密相連,Flutter輸入功能的本質是藉助Native的輸入能力通過Channel在Flutter和Native側進行資料的同步,任何一側的資料發生變化都會被同步到另一側(如文字變化、選擇和游標移動)有一些問題會在這個同步的過程之中產生。而當鍵盤彈出的時候時候導致的頁面變動則是由於WindowInset變化之後引起的Metrics發生變化,最後呼叫 scheduleForceFrame 強制觸發繪製。對我們來說需要做的就是針對問題產生的不同場景分析對應的流程和程式碼,在分析問題的時候一些工具比如Systrace和Instruments,也能幫助我們找到一些蛛絲馬跡。
在使用鍵盤過程中有一些效能相關的問題我們也在不斷的探索,如果大家有好的思路歡迎提出。
關於位元組終端技術團隊
位元組跳動終端技術團隊(Client Infrastructure)是大前端基礎技術的全球化研發團隊(分別在北京、上海、杭州、深圳、廣州、新加坡和美國山景城設有研發團隊),負責整個位元組跳動的大前端基礎設施建設,提升公司全產品線的效能、穩定性和工程效率;支援的產品包括但不限於抖音、今日頭條、西瓜視訊、飛書、瓜瓜龍等,在移動端、Web、Desktop等各終端都有深入研究。
就是現在!客戶端/前端/服務端/端智慧演算法/測試開發 面向全球範圍招聘!一起來用技術改變世界,感興趣請聯絡 chenxuwei.cxw@bytedance.com,郵件主題 簡歷-姓名-求職意向-期望城市-電話。