前言
最近进行CodeReview的时候发现了一个比较有意思的小bug
先说下前提场景:在使用Provider作为状态管理的时候,某一个Widget中使用context.read去获取数据进行展示
案例Sample
来,上点超简单的代码示例
TestPage
scala
class TestPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<TestChanger>(
create: (ctx) {
return TestChanger();
},
child: Consumer<TestChanger>(
builder: (ctx, vm, child) {
return Scaffold(
body: Center(
child: ElevatedButton(
onPressed: (){
vm.doIncrease();
},
child: const Text("increase"),
),
),
bottomNavigationBar: const TestBottomWidget(),
);
},
),
);
}
}
这是页面的Widget
,可以看到页面就两个元素,中间有一个响应点击事件的文本,底部有一个TestBottomWidget
,接着来看看这个底部Widget
TestBottomWidget
scala
class TestBottomWidget extends StatelessWidget {
const TestBottomWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
TestChanger vm = context.read<TestChanger>();
return Container(
height: 50,
color: Colors.orange,
child: Center(
child: Text("count = ${vm.count.toString()}"),
),
);
}
}
更简单,就是通过BuildContext
去read
到对应的ChangeNotifier
,进行展示,最后就是状态类:
TestChanger
scala
class TestChanger extends ChangeNotifier {
int count = 0;
doIncrease() {
count++;
notifyListeners();
}
}
存储一个count
值,点击时进行++并对外通知;
现象
先看看效果
可以看到increase按钮都点冒烟了下面的count
数字也不变,这是为什么呢?
明明ChangeNotifier
执行了notifyListeners
,接着Page中的Consumer
的builder
也重新执行了,那么bottomNavigationBar
指向的TestBottomWidget
也就应该是个新的Widget
,那么其build
方法也就应该执行,展示的count
值就应该是最新的...
分析
这里我们要注意我们对于Scaffold#bottomNavigationBar
的值声明:
csharp
bottomNavigationBar: const TestBottomWidget(),
在这里使用了const
关键字,问题的根源就在这里;
const
我们来了解一下const
对象在Dart虚拟机中会怎么样分配:
在Dart虚拟机中,不同于JVM的内存分区设计(堆、栈、静态方法区、本地方法区等),不过也存在堆、栈这两大核心重要的区域。DVM的堆内存,一样也是用于存储动态分配的对象的区域。这包括通过 new
或 const
关键字创建的所有对象;
而const
对象是存在于堆内存中的一块单独区域当中,可以理解为一个"常量池";在Dart中,对于这些变量,会进行一系列的判断来决定是否可以复用池中的对象而不是新建一个。这其中会包括参数、方法域等判断,如果都一致的情况下,是不会去堆中再创建一个对象的。在我们这个例子中,TestBottomWidget
就会被DVM认为可以去"常量池"中进行复用。
到这里我们就明白了,原来每次Scaffold
更新时,看似我们传递了一个新的TestBottomWidget
对象,但实际上这个TestBottomWidget
一直都指向同一个地址。那么紧接着就是第二个问题,虽然对象是同一个,那么既然它的上级Widget
执行了build
方法,也应该驱动这个Widget
刷新,去执行build
方法,那应该也能通过context
去read
到最新的值才对,为什么不变化呢?
通过断点可以发现,TestBottomWidget
的build
方法根本不会执行!
这就需要我们到framework代码中去找答案了;
Element update
这里其实在Flutter ChangeNotifierProvider凭什么能实现局部刷新?中做过分析,不做逐层代码追踪,这里直接总结下执行逻辑并直接查看目标代码:
Flutter中
Widget
的build
方法是由其Element
去驱动调用的,Element
的更新核心逻辑在Element#updateChild
方法当中,这个方法决定了此次更新当前的Element
需不需要重建,是否需要重新调用其自身的update
方法
来看看具体代码:
判断共分为4大分支,首先判断了当前Element
(即child
对象)是否存在,如果不存在(如第一次运行),则直接进入分支4,去执行inflateWidget
,通过Widget
去创建一个Element
;否则则是Element
已经存在,也就是之前创建过这个Element
,本次进入该方法是组件树进行了刷新;
那么前置判断了Element
和Widget
是否属于同一类别,得到一个bool
值hasSameSuperclass
,这个同类别是指,StatelessWidget
要对应StatelessElement
,StatefulWidget
要对应StatefulElement
。接着我们看这个分支判断逻辑
- 分支1:如果是同一类别,且
Widget
对象为同一个(对象指针地址),则不做任何更新动作(这里的slot暂不分析) - 分支2:如果是同一类别,且当前
Element
对应的Widget
和新Widget
的运行时类型和key
一致(即代码中的Widget.canUpdate
方法),则认为当前Element
仍可使用,只是需要拿新的Widget
所携带的信息去更新该Element
,此流程就会触发到StatelessWidget
的build
方法 - 分支3:如果不属于上述两种情况,则认为
Element
不可复用,直接拿newWidget
去创建一个新的Element
分析到这里,根据我们前面分析的const
关键字,我们就知道了前因后果,我们每次点击页面中间按钮去更新count
值并刷新页面的时候,当页面组件树更新到了TestBottomWidget
的上级Element
,该去判断TestBottomWidget
要不要更新时,由于const
关键字声明了Widget
,所以从始至终就只有一个该对象,遂进入了上述的代码分支1,最终TestBottomWidget
的build
方法不会执行,也就不会更新展示最新count
值。
写在最后
首先,这个问题并不局限于使用Provider时才存在,任何这种组件自身内使用context或其他不依赖构造传参方式去动态获取数据并使用的场景下,都存在这种情况。归根结底就是组件自身的更新无法被父级驱动(因为父级认为你不需要更新)。
那么为什么要给Widget
声明const
关键字呢?
因为我们这个Widget
没有接受任何变量,IDE认为我们这个Widget
可以使用const
构造,也就可以在声明时增加const
关键字。。。
显然IDE没有想到我们这个Widget
虽然没有变量参数,但其内的展示是使用context
去read
了一些变量,并不是一个静态Widget
😮💨
在这个例子中,怎么解决呢?
- 去除
const
声明 - 在
TestBottomWidget
内,不能声明read
去获取值了,因为read
并不会把自己注册在Provider
刷新组件当中,仅仅是标记只读;可以使用watch
去代替,或者在组件的build
方法中返回时包裹一个Consumer
或Selector
去注册刷新
对于上述的第二种解决方案,其实就是换了种方式去驱动自身刷新。
最后的总结:
const
关键字声明的对象在DVM中会进行一系列的判断,通过方法域、参数值、对象上下文等因素,会进行对象的复用,不会新创建对象- 在
Element
的更新逻辑中如果此次更新时Widget
和当前Element
持有的Widget
是同一对象,则不会进行更新