Flutter - 升级3.19之后页面多次rebuild?🤨
# 一、背景
上周一尝试从 3.16.9
升级 3.19.3
,主要有两个原因:
一是安卓端有一个疑似造成崩溃率上涨的 bug
在 Flutter 3.16
上出现,相关issue: #138947 (opens new window), 该 bug
在 3.13
不会出现,在 3.17.pre
上得到修复,而 3.16
之后的下个正式版本是 3.19
。
二是苹果的隐私清单审核政策。
在苹果发布的【关于 App Store 提交的隐私更新 (opens new window)】新闻中指出
自 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要求 (opens new window)】一文中,列举出需要隐私清单和签名的 SDK
,其中就包含了 Flutter
。为了符合该审核要求,Flutter
从 3.19
开始包含了 PrivacyInfo.xcprivacy
这个隐私清单文件。
# 二、踩坑
升到到 3.19.3
后发现,从 页面A
跳转到 页面B
和返回 页面A
时,页面A
的 build
方法都会被执行,降回 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(),
);
}
}
# 三、探索
在经过一番摸索后,发现 ModalRoute
在 3.19
上面有一个小修改~
相关 issue
是: #112567 (opens new window) 。
该 issue
主要是涉及在 Web
端上按 Tab
键切换焦点的问题,后续有个 PR
: #130841 (opens new window) 解决了该问题。
该
PR
因内部测试原因进行了回滚,后再重新登陆,现PR: #134554 (opens new window)
而在该 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;
}
...
}
didChangeNext
和 didPopNext
这两个方法对应的就是页面的 push
和 pop
,现在在该 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
被调用,然后触发 _ModalScopeState
的 setState
,接着其 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
,在经过一系列的处理后最终会走到其 InheritedElement
的 update
方法,在 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);
}
}
...
}
_ModalScopeStatus
的 isCurrent
表示当前页面是否处于最上层,所以在打开和关闭下一个页面时,其值必定切换,也就是 updateShouldNotify
必定返回 true
,既而通知依赖(实际上就是找出一个个依赖进行标脏,然后等待 build
方法的重新调用)。
而我们在使用 ModalRoute.of
的时候,内部就是将当前页的 BuildContext
添加到依赖中,所以他这个改动就会影响到使用 ModalRoute.of
的 Widget
,使其多次 rebuild
。
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 (opens new window), 给 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
版本先用着,免得折腾,同时也祈祷快点修复该问题,然后顺利升级上去~
- 01
- Flutter - 危!3.24版本苹果审核被拒!11-13
- 02
- Flutter - 轻松搞定炫酷视差(Parallax)效果09-21
- 03
- Flutter - 轻松实现PageView卡片偏移效果09-08