最后核验: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 的职责。
  • 我能分清数据事件、错误和结束。
  • 我知道什么时候停止订阅,以及单订阅流不能重复消费。
  • 我已核对四种运行路径,并通过边界验证脚本。

一手来源