最后核验:2026-09-08
本篇结论
一次加载需要一个结果,用 Future;同一次订阅需要陆续接收变化,才考虑 Stream。本篇用 async* 生成三次灵感列表快照,用 await for 依次处理,并明确正常结束、发生错误和主动停止的差别。
Stream 不会自动监测变量、文件或数据库。本例的变化由预先写好的本地数据提供,只验证 Dart 事件消费与订阅边界,不代表 Spark 已经实现实时同步或页面自动刷新。
学完你能做到
- 根据一次结果或连续变化选择 Future 与 Stream。
- 区分一条灵感事件和一份完整列表快照。
- 用
async*、yield和await for产生并接收事件。 - 处理流中的错误,并主动停止接收。
- 解释单订阅流的限制及广播流的适用边界。
- 验证事件顺序、空流、错误与取消订阅。
开始前检查
先完成 第 13 课 。本篇继续复用第 11~13 课的对象、数据转换、待办筛选和本地延迟加载,保留这些文件的相对位置:
examples/lesson_11_functions_and_classes.dart
examples/lesson_12_collections_and_patterns.dart
examples/lesson_13_async_and_errors.dart
examples/lesson_14_streams.dart
scripts/verify_lesson_14.dart
按 第 08 课 设置 FLUTTER_UPSTREAM_HOME,从仓库根目录确认版本:
"$FLUTTER_UPSTREAM_HOME/bin/dart" --version
本篇实际验证版本为 Dart 3.13.2。示例只用标准库及本仓库文件,不需要下载第三方包、登录账号或连接网络服务。
01 先判断是否需要 Stream
第 13 课加载一次列表,调用方等到结果后就可以继续执行。此时 Future<List<SparkIdea>> 已经足够。
如果一个消费者需要在同一次订阅中接收「新增灵感之后的列表」「完成一条灵感之后的列表」,就出现了多个异步事件。可以用 Stream<List<SparkIdea>> 表达这种输入约定。
| 场景 | 本课程的选择 | 原因 |
|---|---|---|
| 清洗已经在内存中的标题 | 普通函数 | 立即得到结果 |
| 用户点击刷新,加载一次列表 | Future | 一次调用对应一个结果或错误 |
| 消费者持续接收多次列表变化 | Stream | 同一次订阅陆续收到事件 |
| 只修改一个局部计数值 | 先用普通变量和函数 | 尚无异步事件源,不需要引入订阅生命周期 |
Stream 可以没有数据事件,也可以有一个或多个;不能只按数据条数决定类型。一份含一百条灵感的列表仍可作为 Future 的一次结果。Future 与 Stream 的异步语义见 Dart 官方 Stream 教程 。
02 每次事件是一份完整快照
本篇默认数据源依次提供三份列表:
| 次数 | 列表含义 | 总数 | 待办数 |
|---|---|---|---|
| 第一次 | 只有「整理 Flutter 笔记」 | 1 | 1 |
| 第二次 | 新增「练习 Stream」 | 2 | 2 |
| 第三次 | 第一条已经完成 | 2 | 1 |
这里每个事件的类型是 List<SparkIdea>,不是单条 SparkIdea。消费者每次对当前完整列表重新计算待办,不把三份快照追加为五条灵感。
源数据的外层列表表示事件序列,内层列表表示某次事件里的全部原始记录。数据量与事件次数是两件事,这个约定必须在产生事件之前说清楚。
03 用 async* 产生快照
本例的生成函数如下:
Stream<List<SparkIdea>> watchIdeaSnapshots(
Iterable<List<Object?>> snapshots,
) async* {
for (final records in snapshots) {
final ideas = await loadIdeas(records);
yield List<SparkIdea>.unmodifiable(ideas);
}
}
async* 表示异步生成器,返回 Stream;第 13 课的 async 返回 Future。yield 发出一次数据事件,之后可以继续产生下一次事件。普通 return 结束生成器,不能用它代替 yield 返回某份快照。语法依据见 Dart 官方生成器说明 。
调用 watchIdeaSnapshots() 时只创建流,直到有人订阅才执行函数体。本例逐次调用 loadIdeas(),等待解析完成后发出不可修改列表,再处理下一份输入。没有提前创建一批 Future,也没有同时发起三次加载。创建与启动规则见 Dart 官方流创建说明 。
List.unmodifiable 延续第 12 课的集合边界,防止消费者修改收到的快照。每次解析创建新的 SparkIdea,所以后来的完成状态不会回头改变先前事件里的对象。原始输入仍按第 13 课约定,在消费期间不要修改。
04 用 await for 依次处理
在完整示例的异步入口中,用循环消费流:
await for (final ideas in stream) {
received++;
final pending = pendingIdeas(ideas);
print('第 $received 次快照:${ideas.length} 条灵感,${pending.length} 条待办');
if (mode == 'cancel') {
print('主动停止接收');
break;
}
}
await for 必须放在异步函数中。它等待下一次数据事件,进入循环体;流正常结束后,执行循环后面的代码。循环体如需等待另一项异步操作,也可以使用 await,让当前处理完成后再处理后续事件。
如果数据源长期不结束,循环后面的代码也不会仅因为「暂时没有新数据」而执行。本例数据有限,消费三次后会正常结束。接收规则见 Dart 官方事件消费说明 。
05 把错误和正常结束分开
Stream 有数据事件、错误事件和结束通知。invalid 模式先发出一份有效快照,第二份把优先级写成字符串,随后还安排了一份有效输入。
第二次 loadIdeas() 解析失败,其 Future 的错误通过生成器中的 await 抛出。由于生成器没有捕获这个错误,本例的流发出错误并关闭;消费端的 await for 退出,进入外层 on FormatException catch。第三份输入不会被消费。
错误不意味着所有 Stream 都必须关闭。有的数据源可以在错误后继续发事件,但 await for 遇到流错误会结束循环及订阅。不能把本例的行为泛化为所有数据源。依据见 Stream API 和 官方错误事件说明 。
完整示例沿用第 13 课:预期格式错误打印失败信息,并把命令行 exitCode 设为 1;finally 打印已接收次数。这个次数保留了错误之前已经收到的事件,不代表全部接收成功。
06 不再需要事件时,停止订阅
cancel 模式在收到第一次快照后执行 break。离开 await for 会取消它管理的订阅。本例验证了此时不会继续读取第二份原始输入,退出状态为 0,因为这是主动完成消费。
取消的基本含义是停止接收事件,不能一概理解为立即中断所有底层工作。对 async*,取消会在生成器到达 yield 时使其退出;如果它正在等待某项异步操作,不能据此宣称该操作已被强制停止。相关边界见 官方生成器取消说明 。
另一种消费方式是 listen()。它返回 StreamSubscription,适合需要在独立回调中处理事件、并显式保存订阅的场景。使用它时,要安排错误处理和停止订阅的时机;遗漏 onError 会让流错误成为未处理异步错误。见 listen API 。
显式调用订阅的 cancel() 会返回 Future<void>,该 Future 表示清理完成;后续操作如果依赖清理结果,就需要等待它。见 cancel API 。本篇运行示例统一使用 await for,页面生命周期中的订阅管理留到实际 UI 场景。
07 单订阅流不能重复消费
本例的 async* 每次调用创建一条新的单订阅流。同一条流只能订阅一次,即使第一次已经完成或取消,再次订阅也不允许。
因此,不要先在同一个流上调用 toList(),再用 await for 想重放一遍。测试需要再次运行时,重新调用 watchIdeaSnapshots() 创建新流。单订阅语义见 Stream API 。
广播流允许多个监听者,但监听者通常只能收到订阅期间的事件,不能把它当作自动重放历史或保存当前列表的容器。未来确实出现多个独立消费者时,再设计共享数据源、缓存和订阅责任。本例没有这个需求,不加入广播转换、StreamController 或事件总线。广播边界见 官方两类流说明 。
08 运行四种路径
打开 完整示例 ,从仓库根目录执行:
"$FLUTTER_UPSTREAM_HOME/bin/dart" format --output=none --set-exit-if-changed \
examples/lesson_14_streams.dart scripts/verify_lesson_14.dart
"$FLUTTER_UPSTREAM_HOME/bin/dart" analyze \
examples/lesson_14_streams.dart scripts/verify_lesson_14.dart
"$FLUTTER_UPSTREAM_HOME/bin/dart" run examples/lesson_14_streams.dart
预期无格式变更,分析显示 No issues found!。默认成功路径的输出为:
已创建 Stream,开始订阅
第 1 次快照:1 条灵感,1 条待办
第 2 次快照:2 条灵感,2 条待办
第 3 次快照:2 条灵感,1 条待办
数据源正常结束
本次订阅结束,已接收 3 次快照
运行空流:
"$FLUTTER_UPSTREAM_HOME/bin/dart" run examples/lesson_14_streams.dart empty
预期输出:
已创建 Stream,开始订阅
数据源正常结束
本次订阅结束,已接收 0 次快照
运行中途错误,并立即检查退出状态:
"$FLUTTER_UPSTREAM_HOME/bin/dart" run examples/lesson_14_streams.dart invalid
echo $?
预期输出:
已创建 Stream,开始订阅
第 1 次快照:1 条灵感,1 条待办
接收失败:灵感数据需要 title 字符串、priority 整数和 isCompleted 布尔值
本次订阅结束,已接收 1 次快照
1
最后运行主动取消:
"$FLUTTER_UPSTREAM_HOME/bin/dart" run examples/lesson_14_streams.dart cancel
预期输出:
已创建 Stream,开始订阅
第 1 次快照:1 条灵感,1 条待办
主动停止接收
本次订阅结束,已接收 1 次快照
成功、空流和主动取消的退出状态均为 0;错误路径为 1。模式参数最多一个,省略时等同于 success。
09 验证事件与订阅边界
执行 验证脚本 :
"$FLUTTER_UPSTREAM_HOME/bin/dart" run scripts/verify_lesson_14.dart
预期输出 第 14 课 Stream 边界验证通过。脚本同时验证数据行为,并通过子进程核对四种模式的完整输出及退出状态,避免只看到结束标记就判定成功。
| 场景 | 预期结果 |
|---|---|
| 三份连续快照 | 列表长度为 1、2、2,待办数为 1、2、1 |
| 后一次标记完成 | 先前快照的对象状态保持原值 |
| 修改收到的快照列表 | 抛出 UnsupportedError |
空输入序列 [] |
零次数据事件后结束 |
包含空列表的序列 [[]] |
收到一次空快照后结束 |
| 创建流但尚未订阅 | 不读取输入序列 |
第一次事件后 break |
不再读取第二份输入 |
| 取消后再次订阅同一流 | 抛出 StateError |
| 第一份成功、第二份无效、第三份有效 | 只收到第一份,随后捕获格式错误 |
| 重新调用生成函数 | 新流独立运行成功 |
脚本只对有限测试流使用 toList() 收集结果。该方法需要等待流结束,不能用于无结束条件的实时订阅。聚合接口见 Stream.toList API 。
三端差异
- Track A:使用 Dart 3.13.2 验证本地快照、错误传播、主动取消和单订阅限制。
- iOS/Android:本例不调用移动平台 API,不为两端重复执行独立 Dart 示例;另行完成
Spark分析、测试、iOS Simulator 构建及 Android debug APK 回归构建。未把命令行事件打印描述成设备页面行为。 - Track B:本机尚未安装 Flutter-OH,未执行该轨道,不宣称鸿蒙运行或插件兼容已通过。
预期结果
- 能为一次加载保留 Future,只在连续变化场景中选择 Stream。
- 能逐次消费完整快照,不混淆事件数和列表元素数。
- 能区分正常完成、错误退出和主动取消。
- 能通过边界脚本发现重复订阅、错误后的数据消费等问题。
常见问题
创建了 Stream,为什么没有输出
本例的 async* 函数体在订阅后才运行。只保存返回的 Stream,没有用 await for、listen() 或其他消费方法,就不会开始加载。
列表为空和流结束是否一样
不同。空列表可以是一次有效数据事件,表示当前没有灵感;流结束表示这个数据源不再发出后续事件。本篇空流模式没有发出任何快照。
收到错误后,能不能继续原来的 await for
该循环遇到流错误会退出,不会自动恢复。本例生成器也已因未捕获错误结束;后续重试需要新操作,不能重复订阅原来的单订阅流。
为什么不把所有状态都放到 StreamController
目前只有一个顺序生成的本地数据源,async* 已能表达。额外控制器会增加发送、关闭和生命周期管理责任,课程尚无对应需求。
取消订阅是否等于取消网络请求
不能直接等同。取消保证订阅不再接收事件,底层资源如何停止取决于数据源实现。本例只验证本地生成器,不涉及网络取消能力。
完成检查
- 我能说明 Future 与 Stream 的业务选择依据。
- 我知道每次事件代表一份完整快照。
- 我能解释
async*、yield和await for的职责。 - 我能分清数据事件、错误和结束。
- 我知道什么时候停止订阅,以及单订阅流不能重复消费。
- 我已核对四种运行路径,并通过边界验证脚本。