likes
comments
collection
share

Flutter - 升级3.19之后页面多次rebuild?🤨

作者站长头像
站长
· 阅读数 6

一、背景

上周一尝试从 3.16.9 升级 3.19.3,主要有两个原因:

一是安卓端有一个疑似造成崩溃率上涨的 bugFlutter 3.16 上出现,相关issue: #138947, 该 bug3.13 不会出现,在 3.17.pre 上得到修复,而 3.16 之后的下个正式版本是 3.19

二是苹果的隐私清单审核政策。

在苹果发布的【关于 App Store 提交的隐私更新】新闻中指出

自 3 月 13 日起: 如果你上传新 App 或更新 App 到 App Store Connect,且该 App 使用了需要声明批准原因的 API,但你未在 App 的隐私清单中提供批准原因,我们会通过电子邮件告知你。这是对 App Store Connect 中现有通知的补充。

自 5 月 1 日起: 你需要就你的 App 代码使用的所列 API 提供批准原因,才能将新 App 或更新 App 上传到 App Store Connect。如果你没有合理的原因使用某个 API,请寻找替代的方案。如果你添加了常用第三方 SDK 列表中所列的新版第三方 SDK,那么这些 API、隐私清单和签名要求将应用于该 SDK。请务必使用包含其隐私清单的 SDK 版本,并注意在将该 SDK 添加为二进制依赖项时也需要提供签名。

在苹果的【即将发布的第三方SDK要求】一文中,列举出需要隐私清单和签名的 SDK,其中就包含了 Flutter。为了符合该审核要求,Flutter3.19 开始包含了 PrivacyInfo.xcprivacy 这个隐私清单文件。

文件位于: github.com/flutter/eng…

二、踩坑

升到到 3.19.3 后发现,从 页面A 跳转到 页面B 和返回 页面A 时,页面Abuild 方法都会被执行,降回 3.16.9 则不会,这就很奇怪。后来发现是因为 页面A 间接使用了 ModalRoute.of

以下是可复现问题的代码

class PageA extends StatefulWidget {
  @override
  State<PageA> createState() => _PageAState();
}

class _PageAState extends State<PageA> {
  @override
  Widget build(BuildContext context) {

    // ==== 这里 ====
+    final arguments = ModalRoute.of(context)?.settings.arguments;
+    print("PageA arguments:$arguments");

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('PageA'),
      ),
      body: const SizedBox.shrink(),
    );
  }
}

三、探索

在经过一番摸索后,发现 ModalRoute3.19 上面有一个小修改~

相关 issue 是: #112567

issue 主要是涉及在 Web 端上按 Tab 键切换焦点的问题,后续有个 PR: #130841 解决了该问题。

PR 因内部测试原因进行了回滚,后再重新登陆,现PR: #134554

而在该 PR 中就对 ModalRoute 加了如下代码:

// packages/flutter/lib/src/widgets/routes.dart

abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T> {
  ...
+  @override
+  void didChangeNext(Route<dynamic>? nextRoute) {
+    super.didChangeNext(nextRoute);
+    changedInternalState();
+  }
+
+  @override
+  void didPopNext(Route<dynamic> nextRoute) {
+    super.didPopNext(nextRoute);
+    changedInternalState();
+  }
+
  @override
  void changedInternalState() {
    super.changedInternalState();
-    setState(() { /* internal state already changed */ });
-    _modalBarrier.markNeedsBuild();
+    // No need to mark dirty if this method is called during build phase.
+    if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) {
+      setState(() { /* internal state already changed */ });
+      _modalBarrier.markNeedsBuild();
+    }
    _modalScope.maintainState = maintainState;
  }
...
}

didChangeNextdidPopNext 这两个方法对应的就是页面的 pushpop,现在在该 PR 中重写并调用了 changedInternalState 方法,在 changedInternalState 方法中调用了 setState

下面将以高亮的方式标出重点代码(不是新增代码)。

abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T> {
  ...
  @protected
  void setState(VoidCallback fn) {
    if (_scopeKey.currentState != null) {
+      _scopeKey.currentState!._routeSetState(fn);
    } else {
      // The route isn't currently visible, so we don't have to call its setState
      // method, but we do still need to call the fn callback, otherwise the state
      // in the route won't be updated!
      fn();
    }
  }
...
}

这个 ModalRoute 内的 setState 会使 _ModalScopeStatus_routeSetState 被调用,然后触发 _ModalScopeStatesetState,接着其 child: _ModalScopeStatus 就开始 rebuild 了。

class _ModalScopeState<T> extends State<_ModalScope<T>> {
  ...
  void _routeSetState(VoidCallback fn) {
    if (widget.route.isCurrent && !_shouldIgnoreFocusRequest && _shouldRequestFocus) {
      widget.route.navigator!.focusNode.enclosingScope?.setFirstFocus(focusScopeNode);
    }
+    setState(fn);
  }
  
  
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      ...
+      child: _ModalScopeStatus(
        ...
      ),
    );
  }
  ...
}

如下代码所示,_ModalScopeStatus 是一个 InheritedWidget,在经过一系列的处理后最终会走到其 InheritedElementupdate 方法,在 update 方法中通过调用 updateShouldNotify 来判断数据是否发生变化,进而决定是否通知相关依赖。

+ class _ModalScopeStatus extends InheritedWidget {
  ...

  @override
+  bool updateShouldNotify(_ModalScopeStatus old) {
+    return isCurrent != old.isCurrent ||
           canPop != old.canPop ||
           impliesAppBarDismissal != old.impliesAppBarDismissal ||
           route != old.route;
  }
  ...
}
class InheritedElement extends ProxyElement {
  ...
  @override
  void updated(InheritedWidget oldWidget) {
+    if ((widget as InheritedWidget).updateShouldNotify(oldWidget)) {
      super.updated(oldWidget);
    }
  }
  ...
}

_ModalScopeStatusisCurrent 表示当前页面是否处于最上层,所以在打开和关闭下一个页面时,其值必定切换,也就是 updateShouldNotify 必定返回 true,既而通知依赖(实际上就是找出一个个依赖进行标脏,然后等待 build 方法的重新调用)。

而我们在使用 ModalRoute.of 的时候,内部就是将当前页的 BuildContext 添加到依赖中,所以他这个改动就会影响到使用 ModalRoute.ofWidget,使其多次 rebuild

@optionalTypeArgs
static ModalRoute<T>? of<T extends Object?>(BuildContext context) {
  final _ModalScopeStatus? widget = context.dependOnInheritedWidgetOfExactType<_ModalScopeStatus>();
  return widget?.route as ModalRoute<T>?;
}

四、解决方案

方案一:调整 ModalRoute.of

在当前版本中,of 的用意就是找到相应的 ModalRoute 并且创建依赖关系,当数据改变时会重新 build ,这是符合它期望用意的。

但是有些场景下我们并不希望有这个 “特性”,比如,我打开新页面后,通过 ModalRoute.of(context)?.settings.arguments 取路由参数,当前页面的取参,与跳转和关闭下个页面是没有任何关系的,所以这种场景下触发 rebuild 将毫无意义。

所以我提了个 PR: #145389, 给 ModalRoute.of 添加了 createDependency 参数,为开发者提供了是否创建依赖的选择。目前还在审核中~

  static ModalRoute<T>? of<T extends Object?>(
    BuildContext context, {
    bool createDependency = true,
  }) {
    _ModalScopeStatus? widget;
    if (createDependency) {
      widget = context.dependOnInheritedWidgetOfExactType<_ModalScopeStatus>();
    } else {
      widget = context
          .getElementForInheritedWidgetOfExactType<_ModalScopeStatus>()
          ?.widget as _ModalScopeStatus?;
    }
    return widget?.route as ModalRoute<T>?;
  }

方案二:魔改源码

PR 是对 Tab 键切换焦点问题的修复,但对于移动端来说根本不算问题,因为用不上~ 😅 (当然,如果你们的用户有使用无障碍功能的,还需要自行斟酌一下)

如果这个问题到时还未解决(原 PR 的作者还在休假),那我们也可以先注释掉相关代码对 changedInternalState 的调用来应对

// packages/flutter/lib/src/widgets/routes.dart

abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T> {
  ...
  @override
  void didChangeNext(Route<dynamic>? nextRoute) {
    super.didChangeNext(nextRoute);
+    // changedInternalState();
  }

  @override
  void didPopNext(Route<dynamic> nextRoute) {
    super.didPopNext(nextRoute);
+    // changedInternalState();
  }
}

提供一个补丁

# 进入你的 flutter 目录,比如我用的是 fvm 下载的 3.19.3
# 记得将 cd 后面的路径换成你自己电脑上的~
cd /Users/lxf/fvm/versions/3.19.3

# 下载补丁
curl -O https://raw.githubusercontent.com/LinXunFeng/flutter_assets/main/patch/01_rollbak_3_19_routes_change/0001-Roll-back-changes-to-routes.dart.patch

# 应用补丁
git apply 0001-Roll-back-changes-to-routes.dart.patch

五、最后

总而言之,距离5月1日(苹果强制要求添加隐私清单文件的期限)还有一个月,我们现在大可保持在 3.13 版本先用着,免得折腾,同时也祈祷快点修复该问题,然后顺利升级上去~