You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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:
    1. 框架从头部开始逐位匹配,发现新列表索引0位置的组件是ItemWidget类型,和旧列表索引0位置的组件类型完全一致,就直接把旧列表索引0对应的State(widgetId=0)绑定到新列表索引0的Widget(item=1)上
    2. 旧列表索引1位置的State(widgetId=1)在新列表里找不到同位置的匹配组件,才会被框架销毁
  • 最终就出现了观测到的现象:被销毁的是最后一个组件的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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.29 16:39:19