PAYATHON 2026

使用 StreamBuilder 更新 ListView 时出现重复条目,如何避免?

排障赵师傅

重复条目是因为 ListView.builder 没有设置 itemCount,而且每个索引都返回同一个 snapshot.data。列表继续请求条目时,就会不断得到内容相同的行。

要实现“逐条追加,收到 token 后清空”,需要单独维护列表数据。StreamBuilder<String> 的 snapshot.data 只包含当前快照的数据,不会自动保存此前收到的所有字符串。

为什么 Text 正常,ListView 却出现重复?

原代码是这样写的:

ListView.builder(
  itemBuilder: (context, index) {
    return Text(snapshot.data.toString());
  },
);

这里有两个问题:

  • 没有提供 itemCount,也没有通过返回 null 告知列表何时结束。
  • 没有使用 index 读取对应的数据,每一行显示的都是同一个字符串。

例如,当前 snapshot.data 是 "result: 3",列表请求索引 0、1、2 时,得到的都会是 "result: 3"。

加上 itemCount: 1 可以让列表只显示一行,但这一行仍然只有最新结果,无法追加历史数据。要显示多条结果,需要按索引从集合中读取:

ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return Text(items[index]);
  },
);

Text 每次只显示一个值,所以不会出现这种多行重复。原示例中的控制台和 UI 分别调用了 messagesStream(),创建的是两个独立的 Stream,因此也无法据此判断同一个 Stream 是否被重复订阅。

用 listen 保存数据,再更新列表

对于由按钮触发、数据量有限的 Stream,可以直接在 State 中管理 StreamSubscription 和列表:

  1. 点击按钮时开始订阅。
  2. 收到普通数据时追加到列表。
  3. 收到清空标记时调用 clear(),不显示标记本身。
  4. Stream 结束后恢复按钮,组件销毁时取消订阅。

用下面的代码替换原来的 resultsStream() 和 _SearchResultsWidgetState,其余类定义和导入保持不变。

示例将 [CLEAR] 作为一条完整的控制消息,在第五个事件中发送。再次点击按钮时,列表会保留现有内容,直到收到下一次清空标记。

const clearToken = '[CLEAR]';

Stream<String> resultsStream() async* {
  for (int i = 1; i <= 10; i++) {
    await Future<void>.delayed(const Duration(seconds: 1));
    yield i == 5 ? clearToken : 'result: $i';
  }
}

class _SearchResultsWidgetState extends State<SearchResultsWidget> {
  final List<String> _items = [];
  StreamSubscription<String>? _subscription;

  bool _running = false;
  Object? _error;

  void _start() {
    if (_running) return;

    setState(() {
      _running = true;
      _error = null;
    });

    _subscription = resultsStream().listen(
      (data) {
        if (!mounted) return;

        setState(() {
          if (data == clearToken) {
            _items.clear();
          } else {
            _items.add(data);
          }
        });
      },
      onError: (Object error, StackTrace stackTrace) {
        if (!mounted) return;

        setState(() {
          _error = error;
        });
      },
      onDone: () {
        if (!mounted) return;

        setState(() {
          _running = false;
          _subscription = null;
        });
      },
      cancelOnError: false,
    );
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const SizedBox(height: 35),
        ElevatedButton(
          onPressed: _running ? null : _start,
          child: Text(_running ? '接收中…' : '开始接收'),
        ),
        if (_error != null)
          Text('接收失败:$_error'),
        Expanded(
          child: _items.isEmpty
              ? const Center(child: Text('暂无结果'))
              : ListView.builder(
                  itemCount: _items.length,
                  itemBuilder: (context, index) {
                    return Text(_items[index]);
                  },
                ),
        ),
      ],
    );
  }
}

这里使用 Expanded,是因为原示例中的 Scaffold.body 提供了有限的高度。如果把组件放进另一个可滚动容器,需要重新安排高度约束。

代码通过 data == clearToken 识别控制消息。如果实际协议规定字符串只要包含 token 就要清空,可以改用 data.contains(clearToken)。这时还要确定同一条消息中的其他内容是否保留;当前示例会丢弃整条控制消息。

cancelOnError: false 表示发生错误后继续接收后续事件,按钮要等到 Stream 结束才会恢复。如果业务要求遇错即停止,还需要在错误处理中结束当前订阅,并恢复按钮状态。

继续使用 StreamBuilder 时,先累计再展示

也可以继续使用 StreamBuilder,让它订阅 Stream<List<String>>。每个事件包含当前的完整列表,追加和清空操作都在进入 UI 构建回调之前完成:

Stream<List<String>> accumulateResults(
  Stream<String> source,
) async* {
  final items = <String>[];

  await for (final data in source) {
    if (data == clearToken) {
      items.clear();
    } else {
      items.add(data);
    }

    yield List<String>.unmodifiable(items);
  }
}

每次发送独立的只读列表快照,后续修改 items 就不会改变此前已经发出的数据。

转换后的 Stream 应保存在 State 字段中。如果需要自动开始接收,可以在 initState() 中初始化:

late final Stream<List<String>> _results;

@override
void initState() {
  super.initState();
  _results = accumulateResults(resultsStream());
}

StreamBuilder 负责读取并显示列表:

StreamBuilder<List<String>>(
  stream: _results,
  initialData: const <String>[],
  builder: (context, snapshot) {
    final items = snapshot.data ?? const <String>[];

    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        return Text(items[index]);
      },
    );
  },
)

如果需要按钮触发,就在按钮回调中创建并保存本次任务的 Stream,通过 setState() 更新订阅目标,同时防止任务重复启动。上面的转换函数每次订阅都会从空列表开始;如果多次任务之间需要保留结果,前面的 listen 方案更直接。

不要在 StreamBuilder.builder 中执行这样的累计操作:

items.add(snapshot.data!);

builder 的调用次数与 Stream 事件数并非一一对应。重建时,它可能再次看到相同数据,也可能只看到事件序列中的部分快照。在这里累计数据会造成重复或漏项,因此应由 Stream 的订阅或转换逻辑处理每个事件,UI 只读取结果。

也不要在所属组件的 build() 中直接调用 resultsStream()。每次这个 build() 重新执行,都会创建新的 Stream,可能导致 StreamBuilder 取消旧订阅并重新开始接收。

每次更新都会重建整个 ListView 吗?

StreamBuilder 的 builder 执行时,会创建新的 ListView Widget 配置。Flutter 随后根据 Widget 的类型、位置和 key 更新已有的元素及渲染对象。创建新 Widget 不等于销毁并重新创建全部列表行。

ListView.builder 按需构建视口及缓存范围内的条目,即使设置了 itemCount,也不会一次创建所有行。这里可以用列表状态驱动 ListView.builder。

直接使用 listen + setState 同样会触发重建,方便之处在于可以一起管理按钮、订阅和累计数据。如果上游已经提供列表状态,保留 StreamBuilder 也很合适。示例每秒接收一条数据,没有必要仅为避免 Widget 重建而增加复杂结构。如果后续事件频率或单行构建成本明显提高,再根据性能分析结果决定是否批量更新或缩小重建范围。

备注:内容仅供参考。