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

欢迎关注微信公众号:FSA全栈行动 👋

一、背景

上周一尝试从 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

以下是可复现问题的代码

diff 复制代码
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 加了如下代码:

diff 复制代码
// 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

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

diff 复制代码
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 了。

diff 复制代码
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 来判断数据是否发生变化,进而决定是否通知相关依赖。

diff 复制代码
+ class _ModalScopeStatus extends InheritedWidget {
  ...

  @override
+  bool updateShouldNotify(_ModalScopeStatus old) {
+    return isCurrent != old.isCurrent ||
           canPop != old.canPop ||
           impliesAppBarDismissal != old.impliesAppBarDismissal ||
           route != old.route;
  }
  ...
}
diff 复制代码
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

dart 复制代码
@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 参数,为开发者提供了是否创建依赖的选择。目前还在审核中~

dart 复制代码
  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 的调用来应对

diff 复制代码
// 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();
  }
}

五、最后

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

如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有 iOS 技术,还有 AndroidFlutterPython 等文章, 可能有你想要了解的技能知识点哦~

相关推荐
一只大侠的侠7 小时前
Flutter开源鸿蒙跨平台训练营 Day 10特惠推荐数据的获取与渲染
flutter·开源·harmonyos
崔庆才丨静觅9 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60619 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了10 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅10 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅10 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
未来侦察班10 小时前
一晃13年过去了,苹果的Airdrop依然很坚挺。
macos·ios·苹果vision pro
renke336410 小时前
Flutter for OpenHarmony:色彩捕手——基于HSL色轮与感知色差的交互式色觉训练系统
flutter
崔庆才丨静觅10 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment10 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端