Flutter移除列表首项时StatefulWidget状态与数据不一致问题
问题场景
持有一个整数类型列表,在build方法中遍历该列表,为每个列表项生成对应的StatefulWidget(ItemWidget)进行渲染,首页及列表逻辑代码如下:
import 'package:flutter/material.dart'; import 'package:widget_list/ItemWidget.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( title: 'Item list state demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: const MyHomePage(title: 'Item list state demo'), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({Key? key, required this.title}) : super(key: key); final String title; @override State<MyHomePage> createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { static int itemsCount = 0; final List<int> _items = List.empty(growable: true); void _add() { setState(() { _items.add(itemsCount++); }); } void _remove() { setState(() { _items.removeAt(0); }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: <Widget>[ Row( children: [ TextButton( onPressed: () => _add(), child: const Text('Add item'), ), TextButton( onPressed: () => _items.isNotEmpty ? _remove() : null, child: const Text('Remove item'), ), ], ), for (var item in _items) ItemWidget(item: item), ], ), ), ); } }
每个ItemWidget的State中维护了一个静态自增的widgetId字段,组件渲染时会同时展示传入的item值和当前组件的widgetId,ItemWidget实现代码如下:
import 'package:flutter/material.dart'; var widgetCount = 0; class ItemWidget extends StatefulWidget { final int item; const ItemWidget({ required this.item, Key? key, }) : super(key: key); @override State<ItemWidget> createState() => _ItemWidgetState(); } class _ItemWidgetState extends State<ItemWidget> { final int widgetId = widgetCount++; @override Widget build(BuildContext context) { print("Item ${widget.item} / Widget $widgetId"); return Text("Item ${widget.item} / Widget $widgetId"); } }
测试过程中发现:新增列表项时表现符合预期,例如首个列表项0会对应渲染在widgetId为0的组件中;但当移除列表开头的项(如移除item 0)时,被销毁的并不是首个组件而是最后一个组件,剩余的item 1会显示在widgetId为0的组件中。已知Widget的item字段为final类型不可修改,widgetId也未发生变化说明对应State并未重建,测试环境为Linux桌面端Flutter 3.0.1版本。
问题原因
这个现象是Flutter框架默认的组件树更新复用机制导致的,和字段final修饰没有关系:
- Flutter每次触发重建时,不会把旧的组件、对应的Element和State全部销毁重建,而是会对同层级的子节点做增量diff,尽可能复用已有的Element和State减少性能开销。
- 当生成
ItemWidget列表时没有给每个组件设置唯一标识Key,Flutter对子节点列表的默认匹配规则是同位置+同runtimeType:只要新旧两个列表同索引位置的组件类型一致,就会直接把旧节点对应的Element和State,重新绑定到新位置的Widget上,不会重新创建State。 - 当移除列表头部的item 0后,新的Widget列表长度从2变成1:
- 框架从头部开始逐位匹配,发现新列表索引0位置的组件是
ItemWidget类型,和旧列表索引0位置的组件类型完全一致,就直接把旧列表索引0对应的State(widgetId=0)绑定到新列表索引0的Widget(item=1)上 - 旧列表索引1位置的State(
widgetId=1)在新列表里找不到同位置的匹配组件,才会被框架销毁
- 框架从头部开始逐位匹配,发现新列表索引0位置的组件是
- 最终就出现了观测到的现象:被销毁的是最后一个组件的State,保留下来的State绑定了新位置的Widget数据,看起来就是State和Widget数据不一致。
修复方式
给每个列表项组件设置唯一的ValueKey即可,将遍历生成ItemWidget的代码修改为:
for (var item in _items) ItemWidget(item: item, key: ValueKey(item)),
添加唯一Key后,框架diff子节点时会优先根据Key值匹配对应的旧节点,移除头部项时会正确找到item 0对应的组件和State做销毁,剩余的item 1会匹配到原来自己对应的State,不会出现错位绑定的问题。
内容的提问来源于stack exchange,提问作者Oswin
相关产品推荐
相关产品推荐

