Flutter 上的一个 Bug 带你了解键盘与路由的另类知识点

事情是这样的,由于近期 Flutter 发布了 1.17 的稳定版,按照“惯例”开始着手把生产项目升级到 1.12.13+hotfix.9 版本,在升级适配完成之后,一个突如其来的 Bug 让我陷入了沉思。

image

如上图所示,可以看到在键盘 B 页面打开后,退回上一个页面 A 时键盘已经收起,但是原先键盘所在的区域在 A 页面变成了空白,而 A 页面内容也被 resize 成了键盘弹出后的大小。

1、Scaffold

针对这个问题,首先想到的 ScaffoldresizeToAvoidBottomInset 属性。

在 Flutter 中 Scaffold 默认情况下 resizeToAvoidBottomInsettrue,当 resizeToAvoidBottomInsettrue 时,Scaffold 内部会将 mediaQuery.viewInsets.bottom 参与到 BoxConstraints 的大小计算,也就是键盘弹起时调整了内部的 bottom 位置来迎合键盘。

但是问题发送在 A 界面,这时候键盘已经收起,mediaQuery.viewInsets.bottom 应该更新为 0 ,那为何界面没有产生应有的更新呢?

2、MediaQuery

那么猜测问题可能出现在 MediaQuery 上。

从源码我们得知 MediaQuery 是一个 InheritedWidget,它会往下共享对应的 MediaQueryData,在 MediaQueryData 中保存了各种设备的信息,比如 sizedevicePixelRatiotextScaleFactorviewPadding 以及 viewInsets 等。

viewInsets 是什么的呢?官方的解释是:

“可以被系统显示的区域,通常是和设备的键盘等相关,当键盘弹出时 viewInsets.bottom 对应的就是键盘的顶部。”

那上面的 bug 看起来可能就是 ScaffoldviewInsets.bottom 在键盘收起来时没有正常重置。

3、Window

那这里首先我们要知道 MediaQueryviewInsets 是怎么被设置的?

通过分析源码可以知道 MediaQueryMediaQueryData 来源于 WidgetsBinding.instance.window,默认是在 MaterialApp_MediaQueryFromWindow 中被设置:

  @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,并且这里还注册了 WidgetsBindingObserverdidChangeMetrics 回调,也就是当 window 改变时,调用 setState 来更新 MediaQuery 中的 MediaQueryData

而在 MediaQueryData.fromWindow 中, viewInsets 是通过将 window.viewInsetswindow.devicePixelRatio 相除后得到的像素密度值。

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

Window 的值又是哪里来的?

其实 Window 的值来源于 Flutter Engine,在键盘弹出时 Flutter Engine 会通过 _updateWindowMetrics 方法更新 Window 数据,并执行 window.onMetricsChangedwindow._onMetricsChangedZone 方法。

其中 onMetricsChanged 回调最终会触发 handleMetricsChanged 方法,从而执行 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);
}

所以可以看到,当键盘弹出和收起时,Engine 会更新 Window 的数据,Window 触发界面绘制更新,同时更新 MaterialApp 中的 MediaQueryData

4、Route

那按照这个情况,不可能出现上述键盘导致空白区域的问题,那问题可能就是出现在 Scaffold 使用的 MediaQueryData 没有更新

这时候我突然想起,之前为了锁定页面的字体大小不跟随系统缩放,我在路由层使用了 MediaQueryData.fromWindow 复制一份 MediaQuery,问题很可能出在这里:

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

不过这也不对,出现问题的是有键盘的 B 页面返回到没有键盘的 A 页面,这时候 A 页面已经打开,那之前打开 A 页面的 WidgetsBinding.instance.window 应该是对的,而 A 页面所在的 CupertinoPageRoutebuilder 方法,不可能在键盘 B 页面打开时再次被执行才对?

但是在经过调试后震惊的发现,程序在进入 B 页面弹出键盘后,居然会触发了 A 页面 CupertinoPageRoutebuilder 方法重新执行。

能够在跨页面触发更新,第一个想到的就是全局的状体管理框架,因为应用需要全局切换主题、多语言和用户信息共享等,在应用的顶层一般会通过状体管理框架往下共享和管理这些信息。

由于原本项目比较复杂,所以重新做了一个简单的测试 Demo ,并且引入比较简单的 ScopedModel 框架管理,然后在打开有键盘的 B 页面后执行延时一会执行notifyListeners();,发现果然出现了同样的问题。

    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 的更新会导致 PageRoute 重新 builder 呢?

这就涉及 Navigator 的相关逻辑,我们常用的 Navigator 其实是一个 StatefulWidget,当 MaterialApp 被更新时,可以看到在 NavigatorStatedidUpdateWidget 回调中会调用 _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 执行后会调用 _forceRebuildPage 将路由里的 _page 清空,这样自然下次 Routebuild 时触发的 PageRoute 重新 builder 方法。

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

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

所以回归到最初的问题:这个 bug 首先是因为不规范使用了 MediaQueryData.fromWindow(WidgetsBinding.instance.window) ,之后又恰好在有键盘的页面打开后触发了 MaterialApp 的更新,导致了 PageRoute 重新 builder, 使得没有键盘的 Scaffold 使用了弹出键盘的 viewInsets.bottom

所以这里只需要将 MediaQueryData.fromWindow 换成 MediaQuery.of(context) 就可以解决问题,而当在没有 context 或者需要直接使用 MediaQueryData.fromWindow 时,那一定要搭配上 WidgetsBindingObserver.didChangeMetrics 配合更新。

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

最后说一句,虽然这个 bug 并不复杂,但是恰好能带出挺多经常忽略的知识点,所以长篇介绍这么多,也希望这样的 bug 解决思路,可以帮助到大家在日常开发过程中解决更多问题。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342