使用 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 和列表:
- 点击按钮时开始订阅。
- 收到普通数据时追加到列表。
- 收到清空标记时调用
clear(),不显示标记本身。 - 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 重建而增加复杂结构。如果后续事件频率或单行构建成本明显提高,再根据性能分析结果决定是否批量更新或缩小重建范围。
备注:内容仅供参考。
