flutter有三棵树:widgetTree,elementTree,renderObjectTree,widget包含了基本的配置信息,element决定要不要重新创建renderObject,而renderObject则负责实际的渲染工作。我们来看一个例子,代码如下:
main.dart
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Scaffold(
appBar: AppBar(
title: const Text("key demon"),
),
body: Center(
child:Column(children: [
Box(Colors.red),
Box(Colors.red),
Box(Colors.orange),
],)
),
),
);
}
}
基础框架就不看了,主要是body里面放了三个box的widget,下面是box的代码:
class Box extends StatefulWidget{
final color;
Box(this.color);
@override
State<StatefulWidget> createState() {
return _BoxState();
}
}
class _BoxState extends State<Box>{
var count = 0;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(top: 30),
alignment: AlignmentDirectional.center,
child: RawMaterialButton(
onPressed: () {
setState(() {
count++;
});
},
child: Text("$count",textAlign: TextAlign.center,style: const TextStyle(fontSize: 50,color: Colors.white),),
constraints: const BoxConstraints(minWidth: 80,minHeight: 80),
fillColor: widget.color,
)
);
}
}
很显然,box是一个statefulWidget,或根据传进来的color来改变自身的颜色,而它内部也有一个点击事件,用来改变state缓存的count变量,每次点击加一。运行效果如下图:
将第一个box点击几下使之变成5,运行效果如下图:
此时,如果我们把body里面的三个box最上面的一个去掉
body: Center(
child:Column(children: [
// Box(Colors.red),
Box(Colors.red),
Box(Colors.orange),
],)
),
得到的效果应该是并列的两个box,上面的为红色,下面为橙色,且两个数字都为0,但实际热更新编译的效果却是上面一个的红色box数字是5,下面的橙色box数字为0。如下图:
为啥会这样呢?
我们知道,flutter的ui渲染包含widget,element和renderObject,widget作为配置信息比较轻量级,每一帧渲染的时候就会有一个新的widget对象产生,而element和renderObject却只有在widget类型发生改变或者所持有的key不一致时才会进行重建。
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
因此,就可以有下一张图:
当box1被删除后,box2顶了上来。实际上只是box的widget发生了变化,由box1的widget替换成了box2的widget,而element依然还是box1的element,因此该element里面的state并没有发生变化,state里面缓存的值也没有发生改变。对应的,renderObject也没有改变。
同样的,box3也是widget由box2替换成了box3的,element依然还是box2的,因为widget变化了,相应的颜色也变化了,所以渲染之后的效果就变成了橙色的。而state里面的值依然还是0。
box3下面没有元素了,所以对应的widget和element都变成了null,因此原来的box3的element和renderObject直接从各自的树上移除掉。
说了这么多,依然还只是引子,那么,假如将box1移除后我们想要得到的效果是box2和box3直接上移,它们的颜色和值都不改变,应该怎么做呢?
答案就是使用key。
key
key我们可以理解为每个控件所独有的一个ID,用以识别是哪个UI。有了key,element和renderObject就会被强行重建,从而得到所见即所得的效果。
key可以分为LocalKey和GlobalKey,字面上理解就是局部key和全局key。
具体分类如下:
LocalKey
LocalKey 直接继承至 Key,它应用于拥有相同父 Element 的小部件进行比较的情况,也就是上述例子中,有一个多子 Widget 中需要对它的子 widget 进行移动处理,这时候你应该使用Localkey。它也是我们日常用到的比较多的key。
Localkey 派生出了许多子类 key:
-ValueKey : ValueKey(‘String’)
-ObjectKey : ObjectKey(Object)
-UniqueKey : UniqueKey()
- Valuekey 又派生出了 PageStorageKey : PageStorageKey(‘value’)
如果是statelessWidget,那么就没必要使用key了,因为每一帧widget都会重建;而statefulWidget由于使用了state,state当中通常会缓存很多需要在UI上展示的变量,它不会时常重建,所以就需要使用key来强制改变。
ValueKey
可以传入任何对象,比较的时候会对比它的内容是否一致。
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType)
return false;
return other is ValueKey<T>
&& other.value == value;
}
ObjectKey
可以传入任何参数,比较的时候会比较它的指针是否一致,即是否为同一个对象。
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType)
return false;
return other is ObjectKey
&& identical(other.value, value);
}
UniqueKey
不需要传任何参数,每次刷新都会生成一个新的值,通常用于动画的过渡当中。比如:
AnimatedSwitcher(
duration: const Duration(seconds: 1),
child: Text("no keyrrths", key: UniqueKey()),
)
每次改变文字时,假如不传uniqueKey,就不会有动画的渐变效果,而如果传了UniqueKey,则会有渐变动画效果。因为不传uniqueKey时,每次都只会认为text的widget发生了变化,只会将text的widget给替换为新的widget,而element还是同一个不会变化,所以会认为UI没有发生变化,因此不会改变;而如果传了uniqueKey时,每次widget比较时都会因为自身的key不一致而被认为是不同的widget,导致会重建element和renderObject,前后两个UI不一致,此时就会发生动画效果。
GlobalKey
全局变量,顾名思义,整个应用程序里都是唯一的,所以同一个globalKey只能作用在一个widget上。可以通过globalKey拿到所对应的state和element或者widget,用以改变state的状态或者变量值,以及刷新UI。不过不推荐这样做,一般推荐通过控制外面的变量来刷新UI。
floatingActionButton: FloatingActionButton(
onPressed: (){
var state = (_globalKey as BoxState);
state.count++;
state.setState(() {
});
},
child: const Icon(Icons.add),
),
(注意,这里带下划线的变量为私有变量,文件外部引用不到)
滑块游戏
接下来使用ReorderableListView来做一个滑块的小游戏。
主要包含两个文件:main.dart和move_game.dart(发现假如使用大写命名dart文件就会报一堆波浪线警告)
main.dart
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
MyApp({Key? key}) : super(key: key);
final _globalKey = GlobalKey();
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Scaffold(
appBar: AppBar(
title: const Text("key demon"),
),
body: Center(
child: MoveGameWidget(key:_globalKey),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
changeMoveGameStateByGlobalKey();
},
child: const Icon(Icons.add),
),
),
);
}
changeMoveGameStateByGlobalKey(){
var state = _globalKey.currentState as MoveGameState;
state.shuffle();
state.setState(() {
});
}
}
move_game.dart
class MoveGameWidget extends StatefulWidget{
const MoveGameWidget({Key? key}) : super(key: key);
@override
State<StatefulWidget> createState() {
return MoveGameState();
}
}
class MoveGameState extends State<MoveGameWidget> {
final boxes = [Box2(Colors.blue.shade100, "100",key: UniqueKey()),
Box2(Colors.blue.shade200, "200",key: UniqueKey()),
Box2(Colors.blue.shade300, "300",key: UniqueKey()),
Box2(Colors.blue.shade400, "400",key: UniqueKey()),
Box2(Colors.blue.shade500, "500",key: UniqueKey()),
Box2(Colors.blue.shade600, "600",key: UniqueKey()),
Box2(Colors.blue.shade700, "700",key: UniqueKey()),];
@override
void initState() => shuffle();
shuffle(){
boxes.shuffle();
}
@override
Widget build(BuildContext context) {
return ReorderableListView(
onReorder: (int oldIndex, int newIndex) {
print("oldIndex ="+oldIndex.toString()+",newIndex ="+newIndex.toString());
if (oldIndex < newIndex) {
newIndex -= 1;
}
var tmp = boxes.removeAt(oldIndex);
boxes.insert(newIndex, tmp);
},
children:boxes);
}
}
class Box2 extends StatelessWidget {
final Color color;
final String tag;
const Box2(this.color, this.tag,{Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
color: color,
width: 40,
height: 50,
alignment: Alignment.center,
margin: const EdgeInsets.all(10),
child: Text(tag,textAlign: TextAlign.center,style: const TextStyle(fontSize: 30,color: Colors.deepOrange),),
);
}
}
效果如下:
短短几十行代码就实现了色块的滑动插入功能。每个色块长按都可以滑动到指定位置,初始化的时候会调用shuffle函数将数组顺序打乱,按照数字顺序排好之后,点击加号按钮又会将顺序打乱,则又可以继续移动滑块了。
这里有两个地方用到了key,一个是点加号刷新的时候,会通过globalKey找到moveGameWidget的state从而进行数组重排;另外一个就是每个色块都拥有一个单独的uniqueKey,每次刷新都与之前不同,所以会进行element重建,从而形成过渡动画。
Flutter | 深入浅出Key
什么是Key
在 Flutter 中我们经常与状态打交道。我们知道 Widget 可以有 Stateful 和 Stateless 两种。Key 能够帮助开发者在 Widget tree 中保存状态,在一般的情况下,我们并不需要使用 Key。那么,究竟什么时候应该使用 Key呢。
我们来看看下面这个例子。
class StatelessContainer extends StatelessWidget {
final Color color = RandomColor().randomColor();
@override
Widget build(BuildContext context) {
return Container(
width: 100,
height: 100,
color: color,
);
}
}
这是一个很简单的 Stateless Widget,显示在界面上的就是一个 100 * 100 的有颜色的 Container。
RandomColor 能够为这个 Widget 初始化一个随机颜色。
我们现在将这个Widget展示到界面上。
class Screen extends StatefulWidget {
@override
_ScreenState createState() => _ScreenState();
}
class _ScreenState extends State<Screen> {
List<Widget> widgets = [
StatelessContainer(),
StatelessContainer(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: widgets,
),
),
floatingActionButton: FloatingActionButton(
onPressed: switchWidget,
child: Icon(Icons.undo),
),
);
}
switchWidget(){
widgets.insert(0, widgets.removeAt(1));
setState(() {});
}
}
这里在屏幕中心展示了两个 StatelessContainer 小部件,当我们点击 floatingActionButton 时,将会执行 switchWidget 并交换它们的顺序。
看上去并没有什么问题,交换操作被正确执行了。现在我们做一点小小的改动,将这个 StatelessContainer 升级为 StatefulContainer。
class StatefulContainer extends StatefulWidget {
StatefulContainer({Key key}) : super(key: key);
@override
_StatefulContainerState createState() => _StatefulContainerState();
}
class _StatefulContainerState extends State<StatefulContainer> {
final Color color = RandomColor().randomColor();
@override
Widget build(BuildContext context) {
return Container(
width: 100,
height: 100,
color: color,
);
}
}
在 StatefulContainer 中,我们将定义 Color 和 build 方法都放进了 State 中。
现在我们还是使用刚才一样的布局,只不过把 StatelessContainer 替换成 StatefulContainer,看看会发生什么。
这时,无论我们怎样点击,都再也没有办法交换这两个Container的顺序了,而 switchWidget 确实是被执行了的。
为了解决这个问题,我们在两个 Widget 构造的时候给它传入一个 UniqueKey。
class _ScreenState extends State<Screen> {
List<Widget> widgets = [
StatefulContainer(key: UniqueKey(),),
StatefulContainer(key: UniqueKey(),),
];
···
然后这两个 Widget 又可以正常被交换顺序了。
看到这里大家肯定心中会有疑问,为什么 Stateful Widget 无法正常交换顺序,加上了 Key 之后就可以了,在这之中到底发生了什么? 为了弄明白这个问题,我们将涉及 Widget 的 diff 更新机制。
Widget 更新机制
在之前的文章中,我们介绍了 Widget 和 Element 的关系。若你还对 Element 的概念感到很模糊的话,请先阅读 Flutter | 深入理解BuildContext。
下面来来看Widget的源码。
@immutable
abstract class Widget extends DiagnosticableTree {
const Widget({ this.key });
final Key key;
···
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
}
我们知道 Widget 只是一个配置且无法修改,而 Element 才是真正被使用的对象,并可以修改。
当新的 Widget 到来时将会调用 canUpdate 方法,来确定这个 Element是否需要更新。
canUpdate 对两个(新老) Widget 的 runtimeType 和 key 进行比较,从而判断出当前的 Element 是否需要更新。若 canUpdate 方法返回 true 说明不需要替换 Element,直接更新 Widget 就可以了。
StatelessContainer 比较过程
在 StatelessContainer 中,我们并没有传入 key ,所以只比较它们的 runtimeType。这里 runtimeType 一致,canUpdate 方法返回 true,两个 Widget 被交换了位置,StatelessElement 调用新持有 Widget 的 build 方法重新构建,在屏幕上两个 Widget 便被正确的交换了顺序。
StatefulContainer 比较过程
而在 StatefulContainer 的例子中,我们将 color 的定义放在了 State 中,Widget 并不保存 State,真正 hold State 的引用的是 Stateful Element。
当我们没有给 Widget 任何 key 的时候,将会只比较这两个 Widget 的 runtimeType 。由于两个 Widget 的属性和方法都相同,canUpdate 方法将会返回 true,于是更新 StatefulWidget 的位置,这两个 Element 将不会交换位置。但是原有 Element 只会从它持有的 state 实例中 build 新的 widget。因为 element 没变,它持有的 state 也没变。所以颜色不会交换。这里变换 StatefulWidget 的位置是没有作用的。
而我们给 Widget 一个 key 之后,canUpdate 方法将会比较两个 Widget 的 runtimeType 以及 key。并返回 false。(这里 runtimeType 相同,key 不同)
此时 RenderObjectElement 会用新 Widget 的 key 在老 Element 列表里面查找,找到匹配的则会更新 Element 的位置并更新对应 renderObject 的位置,对于这个例子来讲就是交换了 Element 的位置并交换了对应 renderObject 的位置。都交换了,那么颜色自然也就交换了。
比较范围
为了提升性能 Flutter 的比较算法(diff)是有范围的,它并不是对第一个 StatefulWidget 进行比较,而是对某一个层级的 Widget 进行比较。
···
class _ScreenState extends State<Screen> {
List<Widget> widgets = [
Padding(
padding: const EdgeInsets.all(8.0),
child: StatefulContainer(key: UniqueKey(),),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: StatefulContainer(key: UniqueKey(),),
),
];
···
在这个例子中,我们将两个带 key 的 StatefulContainer 包裹上 Padding 组件,然后点击交换按钮,会发生下面这件奇妙的事情。
两个 Widget 的 Element 并不是交换顺序,而是被重新创建了。
在 Flutter 的比较过程中它下到 Row 这个层级,发现它是一个 MultiChildRenderObjectWidget(多子部件的 Widget)。然后它会对所有 children 层逐个进行扫描。
在Column这一层级,padding 部分的 runtimeType 并没有改变,且不存在 Key。然后再比较下一个层级。由于内部的 StatefulContainer 存在 key,且现在的层级在 padding 内部,该层级没有多子 Widget。runtimeType 返回 flase,Flutter 的将会认为这个 Element 需要被替换。然后重新生成一个新的 Element 对象装载到 Element 树上替换掉之前的 Element。第二个 Widget 同理。
所以为了解决这个问题,我们需要将 key 放到 Row 的 children 这一层级。
···
class _ScreenState extends State<Screen> {
List<Widget> widgets = [
Padding(
key: UniqueKey(),
padding: const EdgeInsets.all(8.0),
child: StatefulContainer(),
),
Padding(
key: UniqueKey(),
padding: const EdgeInsets.all(8.0),
child: StatefulContainer(),
),
];
···
现在我们又可以愉快的玩耍了(交换 Widget 顺序)了。
扩展内容
slot 能够描述子级在其父级列表中的位置。多子部件 Widget 例如 Row,Column 都为它的子级提供了一系列 slot。
在调用 Element.updateChild 的时候有一个细节,若新老 Widget 的实例相同,注意这里是实例相同而不是类型相同, slot 不同的时候,Flutter 所做的仅仅是更新 slot,也就给他换个位置。因 为 Widget 是不可变的,实例相同意味着显示的配置相同,所以要做的仅仅是挪个地方而已。
abstract class Element extends DiagnosticableTree implements BuildContext {
···
dynamic get slot => _slot;
dynamic _slot;
···
@protected
Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
···
if (child != null) {
if (child.widget == newWidget) {
if (child.slot != newSlot)
updateSlotForChild(child, newSlot);
return child;
}
if (Widget.canUpdate(child.widget, newWidget)) {
if (child.slot != newSlot)
updateSlotForChild(child, newSlot);
child.update(newWidget);
assert(child.widget == newWidget);
assert(() {
child.owner._debugElementWasRebuilt(child);
return true;
}());
return child;
}
deactivateChild(child);
assert(child._parent == null);
}
return inflateWidget(newWidget, newSlot);
}
更新机制表
新WIDGET不为空 | 新 Widget不为空 | |
---|---|---|
child为空 | 返回null。 | 返回新的 Element |
child不为空 | 移除旧的widget,返回null. | 若旧的child Element 可以更新(canUpdate)则更新并将其返回,否则返回一个新的 Element. |
Key 的种类
Key
@immutable
abstract class Key {
const factory Key(String value) = ValueKey<String>;
@protected
const Key.empty();
}
默认创建 Key 将会通过工厂方法根据传入的 value 创建一个 ValueKey。
Key 派生出两种不同用途的 Key:LocalKey 和 GlobalKey。
Localkey
LocalKey 直接继承至 Key,它应用于拥有相同父 Element 的小部件进行比较的情况,也就是上述例子中,有一个多子 Widget 中需要对它的子 widget 进行移动处理,这时候你应该使用Localkey。
Localkey 派生出了许多子类 key:
- ValueKey : ValueKey('String')
- ObjectKey : ObjectKey(Object)
- UniqueKey : UniqueKey()
Valuekey 又派生出了 PageStorageKey : PageStorageKey('value')
GlobalKey
@optionalTypeArgs
abstract class GlobalKey<T extends State<StatefulWidget>> extends Key {
···
static final Map<GlobalKey, Element> _registry = <GlobalKey, Element>{};
static final Set<Element> _debugIllFatedElements = HashSet<Element>();
static final Map<GlobalKey, Element> _debugReservations = <GlobalKey, Element>{};
···
BuildContext get currentContext ···
Widget get currentWidget ···
T get currentState ···
GlobalKey 使用了一个静态常量 Map 来保存它对应的 Element。
你可以通过 GlobalKey 找到持有该GlobalKey的 Widget,State 和 Element。
注意:GlobalKey 是非常昂贵的,需要谨慎使用。
什么时候需要使用 Key
ValueKey
如果您有一个 Todo List 应用程序,它将会记录你需要完成的事情。我们假设每个 Todo 事情都各不相同,而你想要对每个 Todo 进行滑动删除操作。
这时候就需要使用 ValueKey!
return TodoItem(
key: ValueKey(todo.task),
todo: todo,
onDismissed: (direction){
_removeTodo(context, todo);
},
);
ObjectKey
如果你有一个生日应用,它可以记录某个人的生日,并用列表显示出来,同样的还是需要有一个滑动删除操作。
我们知道人名可能会重复,这时候你无法保证给 Key 的值每次都会不同。但是,当人名和生日组合起来的 Object 将具有唯一性。
这时候你需要使用 ObjectKey!
UniqueKey
如果组合的 Object 都无法满足唯一性的时候,你想要确保每一个 Key 都具有唯一性。那么,你可以使用 UniqueKey。它将会通过该对象生成一个具有唯一性的 hash 码。
不过这样做,每次 Widget 被构建时都会去重新生成一个新的 UniqueKey,失去了一致性。也就是说你的小部件还是会改变。(还不如不用😂)
PageStorageKey
当你有一个滑动列表,你通过某一个 Item 跳转到了一个新的页面,当你返回之前的列表页面时,你发现滑动的距离回到了顶部。这时候,给 Sliver 一个 PageStorageKey!它将能够保持 Sliver 的滚动状态。
GlobalKey
GlobalKey 能够跨 Widget 访问状态。
在这里我们有一个 Switcher 小部件,它可以通过 changeState 改变它的状态。
class SwitcherScreenState extends State<SwitcherScreen> {
bool isActive = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Switch.adaptive(
value: isActive,
onChanged: (bool currentStatus) {
isActive = currentStatus;
setState(() {});
}),
),
);
}
changeState() {
isActive = !isActive;
setState(() {});
}
}
但是我们想要在外部改变该状态,这时候就需要使用 GlobalKey。
class _ScreenState extends State<Screen> {
final GlobalKey<SwitcherScreenState> key = GlobalKey<SwitcherScreenState>();
@override
Widget build(BuildContext context) {
return Scaffold(
body: SwitcherScreen(
key: key,
),
floatingActionButton: FloatingActionButton(onPressed: () {
key.currentState.changeState();
}),
);
}
}
这里我们通过定义了一个 GlobalKey<SwitcherScreenState> 并传递给 SwitcherScreen。然后我们便可以通过这个 key 拿到它所绑定的 SwitcherState 并在外部调用 changeState 改变状态了。