Flutter 上的一個(gè) Bug 帶你了解鍵盤與路由的另類知識(shí)點(diǎn)

事情是這樣的纱昧,由于近期 Flutter 發(fā)布了 1.17 的穩(wěn)定版刨啸,按照“慣例”開始著手把生產(chǎn)項(xiàng)目升級(jí)到 1.12.13+hotfix.9 版本,在升級(jí)適配完成之后识脆,一個(gè)突如其來的 Bug 讓我陷入了沉思设联。

image

如上圖所示善已,可以看到在鍵盤 B 頁面打開后,退回上一個(gè)頁面 A 時(shí)鍵盤已經(jīng)收起离例,但是原先鍵盤所在的區(qū)域在 A 頁面變成了空白换团,而 A 頁面內(nèi)容也被 resize 成了鍵盤彈出后的大小。

1宫蛆、Scaffold

針對(duì)這個(gè)問題艘包,首先想到的 ScaffoldresizeToAvoidBottomInset 屬性。

在 Flutter 中 Scaffold 默認(rèn)情況下 resizeToAvoidBottomInsettrue耀盗,當(dāng) resizeToAvoidBottomInsettrue 時(shí)想虎,Scaffold 內(nèi)部會(huì)將 mediaQuery.viewInsets.bottom 參與到 BoxConstraints 的大小計(jì)算,也就是鍵盤彈起時(shí)調(diào)整了內(nèi)部的 bottom 位置來迎合鍵盤叛拷。

但是問題發(fā)送在 A 界面舌厨,這時(shí)候鍵盤已經(jīng)收起,mediaQuery.viewInsets.bottom 應(yīng)該更新為 0 忿薇,那為何界面沒有產(chǎn)生應(yīng)有的更新呢邓线?

2、MediaQuery

那么猜測(cè)問題可能出現(xiàn)在 MediaQuery 上煌恢。

從源碼我們得知 MediaQuery 是一個(gè) InheritedWidget,它會(huì)往下共享對(duì)應(yīng)的 MediaQueryData震庭,在 MediaQueryData 中保存了各種設(shè)備的信息瑰抵,比如 sizedevicePixelRatio 器联、 textScaleFactor 二汛、 viewPadding 以及 viewInsets 等。

viewInsets 是什么的呢拨拓?官方的解釋是:

“可以被系統(tǒng)顯示的區(qū)域肴颊,通常是和設(shè)備的鍵盤等相關(guān),當(dāng)鍵盤彈出時(shí) viewInsets.bottom 對(duì)應(yīng)的就是鍵盤的頂部渣磷⌒鲎牛”

那上面的 bug 看起來可能就是 ScaffoldviewInsets.bottom 在鍵盤收起來時(shí)沒有正常重置。

3醋界、Window

那這里首先我們要知道 MediaQueryviewInsets 是怎么被設(shè)置的竟宋?

通過分析源碼可以知道 MediaQueryMediaQueryData 來源于 WidgetsBinding.instance.window,默認(rèn)是在 MaterialApp_MediaQueryFromWindow 中被設(shè)置:

  @override
  void didChangeMetrics() {
    setState(() {
      // The properties of window have changed. We use them in our build
      // function, so we need setState(), but we don't cache anything locally.
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return MediaQuery(
      data: MediaQueryData.fromWindow(WidgetsBinding.instance.window),
      child: widget.child,
    );
  }

如上代碼可以看到 MediaQueryMediaQueryData 是來源于 Window形纺,并且這里還注冊(cè)了 WidgetsBindingObserverdidChangeMetrics 回調(diào)丘侠,也就是當(dāng) window 改變時(shí),調(diào)用 setState 來更新 MediaQuery 中的 MediaQueryData 逐样。

而在 MediaQueryData.fromWindow 中蜗字, viewInsets 是通過將 window.viewInsetswindow.devicePixelRatio 相除后得到的像素密度值打肝。

viewInsets = EdgeInsets.
fromWindowPadding(window.viewInsets, window.devicePixelRatio),

Window 的值又是哪里來的?

其實(shí) Window 的值來源于 Flutter Engine挪捕,在鍵盤彈出時(shí) Flutter Engine 會(huì)通過 _updateWindowMetrics 方法更新 Window 數(shù)據(jù)粗梭,并執(zhí)行 window.onMetricsChangedwindow._onMetricsChangedZone 方法。

其中 onMetricsChanged 回調(diào)最終會(huì)觸發(fā) handleMetricsChanged 方法担神,從而執(zhí)行 scheduleForcedFrame() 更新界面和 observer.didChangeMetrics(); 通知 MaterialApp 中的 MediaQueryData 更新楼吃。

@pragma('vm:entry-point')
// ignore: unused_element
void _updateWindowMetrics(
  double devicePixelRatio,
  double width,
  double height,
  double depth,
  double viewPaddingTop,
  double viewPaddingRight,
  double viewPaddingBottom,
  double viewPaddingLeft,
  double viewInsetTop,
  double viewInsetRight,
  double viewInsetBottom,
  double viewInsetLeft,
  double systemGestureInsetTop,
  double systemGestureInsetRight,
  double systemGestureInsetBottom,
  double systemGestureInsetLeft,
) {
  window
    .._devicePixelRatio = devicePixelRatio
    .._physicalSize = Size(width, height)
    .._physicalDepth = depth
    .._viewPadding = WindowPadding._(
        top: viewPaddingTop,
        right: viewPaddingRight,
        bottom: viewPaddingBottom,
        left: viewPaddingLeft)
    .._viewInsets = WindowPadding._(
        top: viewInsetTop,
        right: viewInsetRight,
        bottom: viewInsetBottom,
        left: viewInsetLeft)
    .._padding = WindowPadding._(
        top: math.max(0.0, viewPaddingTop - viewInsetTop),
        right: math.max(0.0, viewPaddingRight - viewInsetRight),
        bottom: math.max(0.0, viewPaddingBottom - viewInsetBottom),
        left: math.max(0.0, viewPaddingLeft - viewInsetLeft))
    .._systemGestureInsets = WindowPadding._(
        top: math.max(0.0, systemGestureInsetTop),
        right: math.max(0.0, systemGestureInsetRight),
        bottom: math.max(0.0, systemGestureInsetBottom),
        left: math.max(0.0, systemGestureInsetLeft));
  _invoke(window.onMetricsChanged, window._onMetricsChangedZone);
}

所以可以看到,當(dāng)鍵盤彈出和收起時(shí)妄讯,Engine 會(huì)更新 Window 的數(shù)據(jù)孩锡,Window 觸發(fā)界面繪制更新,同時(shí)更新 MaterialApp 中的 MediaQueryData 亥贸。

4躬窜、Route

那按照這個(gè)情況,不可能出現(xiàn)上述鍵盤導(dǎo)致空白區(qū)域的問題炕置,那問題可能就是出現(xiàn)在 Scaffold 使用的 MediaQueryData 沒有更新荣挨。

這時(shí)候我突然想起,之前為了鎖定頁面的字體大小不跟隨系統(tǒng)縮放朴摊,我在路由層使用了 MediaQueryData.fromWindow 復(fù)制一份 MediaQuery默垄,問題很可能出在這里:

Navigator.of(context).push(new CupertinoPageRoute(builder: (context) {
   return MediaQuery(
      data:MediaQueryData.fromWindow(WidgetsBinding.instance.window)
                         .copyWith(textScaleFactor: 1),
                  child: Page2(), );
   }));

不過這也不對(duì),出現(xiàn)問題的是有鍵盤的 B 頁面返回到?jīng)]有鍵盤的 A 頁面甚纲,這時(shí)候 A 頁面已經(jīng)打開口锭,那之前打開 A 頁面的 WidgetsBinding.instance.window 應(yīng)該是對(duì)的,而 A 頁面所在的 CupertinoPageRoutebuilder 方法介杆,不可能在鍵盤 B 頁面打開時(shí)再次被執(zhí)行才對(duì)鹃操?

但是在經(jīng)過調(diào)試后震驚的發(fā)現(xiàn),程序在進(jìn)入 B 頁面彈出鍵盤后春哨,居然會(huì)觸發(fā)了 A 頁面 CupertinoPageRoutebuilder 方法重新執(zhí)行荆隘。

能夠在跨頁面觸發(fā)更新,第一個(gè)想到的就是全局的狀體管理框架赴背,因?yàn)閼?yīng)用需要全局切換主題椰拒、多語言和用戶信息共享等,在應(yīng)用的頂層一般會(huì)通過狀體管理框架往下共享和管理這些信息癞尚。

由于原本項(xiàng)目比較復(fù)雜耸三,所以重新做了一個(gè)簡(jiǎn)單的測(cè)試 Demo ,并且引入比較簡(jiǎn)單的 ScopedModel 框架管理浇揩,然后在打開有鍵盤的 B 頁面后執(zhí)行延時(shí)一會(huì)執(zhí)行notifyListeners();仪壮,發(fā)現(xiàn)果然出現(xiàn)了同樣的問題。

    return ScopedModel(
      model: t,
      child: ScopedModelDescendant<TestModel>(
        builder: (context, child, model) {
          return MaterialApp(
            title: 'Flutter Demo',
            theme: ThemeData(
              primarySwatch: Colors.blue,
            ),
            home: MyHomePage(title: 'Flutter Demo Home Page'),
          );
        },
      ),
    );

5胳徽、Navigator

這里不禁就有疑問积锅,為什么 MaterialApp 的更新會(huì)導(dǎo)致 PageRoute 重新 builder 呢爽彤?

這就涉及 Navigator 的相關(guān)邏輯,我們常用的 Navigator 其實(shí)是一個(gè) StatefulWidget缚陷,當(dāng) MaterialApp 被更新時(shí)适篙,可以看到在 NavigatorStatedidUpdateWidget 回調(diào)中會(huì)調(diào)用 _history 里所有路由的 changedExternalState() 方法。

 @override
  void didUpdateWidget(Navigator oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.observers != widget.observers) {
      for (NavigatorObserver observer in oldWidget.observers)
        observer._navigator = null;
      for (NavigatorObserver observer in widget.observers) {
        assert(observer.navigator == null);
        observer._navigator = this;
      }
    }
    for (Route<dynamic> route in _history)
      route.changedExternalState();
  }
  

changedExternalState 執(zhí)行后會(huì)調(diào)用 _forceRebuildPage 將路由里的 _page 清空箫爷,這樣自然下次 Routebuild 時(shí)觸發(fā)的 PageRoute 重新 builder 方法嚷节。

@override
 void changedExternalState() {
   super.changedExternalState();
   if (_scopeKey.currentState != null)
     _scopeKey.currentState._forceRebuildPage();
 }
 
·····

 void _forceRebuildPage() {
   setState(() {
     _page = null;
   });
 }

所以回歸到最初的問題:這個(gè) bug 首先是因?yàn)椴灰?guī)范使用了 MediaQueryData.fromWindow(WidgetsBinding.instance.window) ,之后又恰好在有鍵盤的頁面打開后觸發(fā)了 MaterialApp 的更新虎锚,導(dǎo)致了 PageRoute 重新 builder硫痰, 使得沒有鍵盤的 Scaffold 使用了彈出鍵盤的 viewInsets.bottom

所以這里只需要將 MediaQueryData.fromWindow 換成 MediaQuery.of(context) 就可以解決問題窜护,而當(dāng)在沒有 context 或者需要直接使用 MediaQueryData.fromWindow 時(shí)效斑,那一定要搭配上 WidgetsBindingObserver.didChangeMetrics 配合更新。

    Navigator.of(context).push(new CupertinoPageRoute(builder: (context) {
      return MediaQuery(
        data:MediaQuery.of(context)
            .copyWith(textScaleFactor: 1),
        child: Page2(), );
    }));

最后說一句柱徙,雖然這個(gè) bug 并不復(fù)雜缓屠,但是恰好能帶出挺多經(jīng)常忽略的知識(shí)點(diǎn),所以長(zhǎng)篇介紹這么多护侮,也希望這樣的 bug 解決思路敌完,可以幫助到大家在日常開發(fā)過程中解決更多問題。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末羊初,一起剝皮案震驚了整個(gè)濱河市蠢挡,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌凳忙,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,348評(píng)論 6 491
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件禽炬,死亡現(xiàn)場(chǎng)離奇詭異涧卵,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)腹尖,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,122評(píng)論 2 385
  • 文/潘曉璐 我一進(jìn)店門柳恐,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人热幔,你說我怎么就攤上這事乐设。” “怎么了绎巨?”我有些...
    開封第一講書人閱讀 156,936評(píng)論 0 347
  • 文/不壞的土叔 我叫張陵近尚,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我场勤,道長(zhǎng)戈锻,這世上最難降的妖魔是什么歼跟? 我笑而不...
    開封第一講書人閱讀 56,427評(píng)論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮格遭,結(jié)果婚禮上哈街,老公的妹妹穿的比我還像新娘。我一直安慰自己拒迅,他們只是感情好骚秦,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,467評(píng)論 6 385
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著璧微,像睡著了一般作箍。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上往毡,一...
    開封第一講書人閱讀 49,785評(píng)論 1 290
  • 那天蒙揣,我揣著相機(jī)與錄音,去河邊找鬼开瞭。 笑死懒震,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的嗤详。 我是一名探鬼主播个扰,決...
    沈念sama閱讀 38,931評(píng)論 3 406
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼葱色!你這毒婦竟也來了递宅?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,696評(píng)論 0 266
  • 序言:老撾萬榮一對(duì)情侶失蹤苍狰,失蹤者是張志新(化名)和其女友劉穎办龄,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體淋昭,經(jīng)...
    沈念sama閱讀 44,141評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡俐填,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,483評(píng)論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了翔忽。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片英融。...
    茶點(diǎn)故事閱讀 38,625評(píng)論 1 340
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖歇式,靈堂內(nèi)的尸體忽然破棺而出驶悟,到底是詐尸還是另有隱情,我是刑警寧澤材失,帶...
    沈念sama閱讀 34,291評(píng)論 4 329
  • 正文 年R本政府宣布痕鳍,位于F島的核電站,受9級(jí)特大地震影響龙巨,放射性物質(zhì)發(fā)生泄漏额获。R本人自食惡果不足惜够庙,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,892評(píng)論 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望抄邀。 院中可真熱鬧耘眨,春花似錦、人聲如沸境肾。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,741評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽奥喻。三九已至偶宫,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間环鲤,已是汗流浹背纯趋。 一陣腳步聲響...
    開封第一講書人閱讀 31,977評(píng)論 1 265
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留冷离,地道東北人吵冒。 一個(gè)月前我還...
    沈念sama閱讀 46,324評(píng)論 2 360
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像西剥,于是被迫代替她去往敵國(guó)和親痹栖。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,492評(píng)論 2 348