Flutter API创建
- 作者仓库星标 0
- 作者仓库 skills-registry
Real-Time Streams in Bloc
Couples a Stream<T> from the data layer to a Bloc's state machine. The Bloc consumes the stream inside an event handler using await emit.forEach(...), which ties the subscription's lifetime to the handler's lifetime and the Bloc's close() — no manual StreamSubscription to track, no leaks. Connection lifecycle (connect, disconnect, reconnect-with-backoff) is explicit events on the Bloc; the transport plumbing lives in the Repository. Builds on flutter-bloc-feature-pattern.
Contents
- Stream subscription pattern
- Connection lifecycle
- State design for streams
- Workflow: Wire a real-time stream into a Bloc
- Applied to Talabat-clone
- Examples
Stream subscription pattern
Inside an event handler, await emit.forEach(stream, onData: ..., onError: ...) keeps the handler alive for as long as the stream yields. When the Bloc closes — route pop, app background, hot restart — the subscription is cancelled automatically. There is no StreamSubscription field on the Bloc, and no cancel() call in close().
Future<void> _onConnect(
TrackingConnectRequested event,
Emitter<OrderTrackingState> emit,
) async {
emit(const OrderTrackingState.connecting());
await emit.forEach<DriverLocation>(
_repo.subscribeToDriver(event.orderId),
onData: (loc) => OrderTrackingState.connected(loc),
onError: (err, _) => OrderTrackingState.disconnected(reason: err.toString()),
);
}
Compare with the older stream.listen approach:
// AVOID — manual lifetime management
StreamSubscription? _sub;
Future<void> _onConnect(/* ... */) async {
_sub?.cancel();
_sub = _repo.subscribeToDriver(...).listen(
(loc) => emit(...), // emit-after-close hazard
onError: (e) => emit(...),
);
}
@override
Future<void> close() {
_sub?.cancel(); // easy to forget
return super.close();
}
emit.forEach eliminates two classes of bug: (1) emit-after-close (where .listen calls back after the Bloc is gone) and (2) leaked subscriptions in feature-with-route Blocs.
Connection lifecycle
A stream-driven Bloc has at least two explicit events:
| Event | Triggered by | Effect |
|---|---|---|
ConnectRequested(id) |
View initState after BlocProvider mounts; or after a RetryRequested |
Start emit.forEach on the stream |
DisconnectRequested |
View dispose; or business rule (e.g., Order moved past delivered) |
Calls _repo.disconnect(id) which closes the upstream stream. emit.forEach in _onConnect then completes naturally and emits Closed via its own epilogue. |
Crucial: _onDisconnect cannot terminate _onConnect's emit.forEach by just emitting Closed itself. Each event handler gets its own Emitter instance; marking one as done has no effect on the other's stream subscription. The Repository owns the stream's lifetime — the only correct way to stop tracking is to close the stream at the source.
Reconnection with backoff lives in the Repository, not the Bloc. The Repository wraps the raw stream so transient transport errors are caught, retried with exponential backoff, and capped at a maximum attempt count. From the Bloc's perspective, the stream stays "open" across transient failures. A Reconnecting state is reachable only if the Repository explicitly emits a reconnecting sentinel (e.g., a wrapper type like Either<Reconnecting, DriverLocation>); the simplest implementations just sleep silently between retries and never surface a Reconnecting state to the Bloc.
Use transformer: restartable() on the ConnectRequested handler so dispatching ConnectRequested again — say, after a navigation pop-then-push — cancels the previous handler cleanly. The if (!emit.isDone) emit(Closed) epilogue after emit.forEach exists so a restartable() cancellation (where the prior emitter is already done) doesn't leak a spurious Closed state during a normal restart.
State design for streams
Do not reuse Loading / Loaded from API-call states for streams. They mean different things: API Loaded is terminal, stream Connected is ongoing. Use distinct variants.
@freezed
sealed class OrderTrackingState with _$OrderTrackingState {
const factory OrderTrackingState.initial() = TrackingInitial;
const factory OrderTrackingState.connecting() = TrackingConnecting;
const factory OrderTrackingState.connected(DriverLocation location) = TrackingConnected;
const factory OrderTrackingState.reconnecting({required String reason}) = TrackingReconnecting;
const factory OrderTrackingState.disconnected({required String reason}) = TrackingDisconnected;
const factory OrderTrackingState.closed() = TrackingClosed; // terminal: stream is done
}
The View's switch over this state is exhaustive — adding a Reconnecting variant later forces every consumer to handle it explicitly.
Workflow: Wire a real-time stream into a Bloc
Task Progress
- Step 1 — Add Repository stream method.
Stream<T> subscribeTo<...>(args)returning a hot or coldStream. Lives inlib/data/repositories/. - Step 2 — Wrap transient errors. Inside the Repository, transform the raw transport stream so transient disconnects do not surface — emit a
Reconnectingsentinel and retry with backoff. Only fatal errors propagate. - Step 3 — Define stream events. Sealed
<Feature>EventwithConnectRequested(id),DisconnectRequested, and optionallyRetryRequested. - Step 4 — Define stream states. Sealed
<Feature>StatewithInitial,Connecting,Connected(data),Reconnecting(reason),Disconnected(reason),Closedvariants. Do not reuseLoaded/Failurefrom API skills. - Step 5 — Implement the handler with
emit.forEach. Usetransformer: restartable()so a secondConnectRequestedcancels the first. Never store aStreamSubscriptionon the Bloc. - Step 6 — Wire
DisconnectRequested. The handler callsawait _repo.disconnect(id)which closes the upstream stream._onConnect'semit.forEachthen completes naturally and emitsClosedvia its own epilogue. Do not just emitClosedfrom_onDisconnect— each handler has its ownEmitter, so it can't terminate another handler's stream subscription. - Step 7 — Dispatch from the View lifecycle.
BlocProvider.createshould addConnectRequested(id)immediately;BlocListenerforClosedcan pop the route or show a summary. - Step 8 — Feedback loop. Run on a device → background the app → foreground it → verify the Bloc resumes (
reconnecting → connected) rather than wedges inDisconnected. If it wedges, the issue is almost always the Repository swallowing a fatal error as transient or vice versa.
Applied to Talabat-clone
Two real-time features map to this pattern.
Order tracking (OrderTrackingBloc)
Per PRD_states.md §1, an Order's driver-tracking window is open only while the Order is in picked_up or in_transit. Before picked_up there is no driver assigned; after delivered / failed_delivery / cancelled the assignment is over.
Order state → OrderTrackingBloc behavior
─────────────────────────────────────────────────
awaiting_driver → not yet — show "looking for driver"
picked_up → add ConnectRequested(orderId)
in_transit → still subscribed; frames flow
delivered → add DisconnectRequested → emit Closed → show summary
failed_delivery → add DisconnectRequested → emit Closed → trigger refund flow
cancelled → add DisconnectRequested → emit Closed
The OrderBloc (separate, not in this skill) owns the Order state transitions. OrderTrackingBloc is a child route's Bloc; the parent screen reacts to OrderBloc's state changes via BlocListener and dispatches the tracking lifecycle events.
Chat thread (ChatThreadBloc)
PRD_states.md §15 makes the chat window time-computed, not state-stored: open iff Order.state ∈ {picked_up, in_transit} OR Order.state == delivered AND now() < delivered_at + 24h. The chat Bloc subscribes when this predicate is true and unsubscribes when it flips. Per-message translation status is the Message model's async job lifecycle — out of scope for this Bloc.
Examples
lib/data/repositories/order_repository.dart (excerpt)
import 'dart:async';
/// Real transport may be Pusher, WebSocket, or Firestore. Backoff/retry,
/// max-attempts, and per-orderId kill-switches are the Repository's
/// responsibility — the Bloc only consumes the resulting `Stream`.
class OrderRepository {
OrderRepository({required this.transport, this.maxRetries = 5});
final RealtimeTransport transport;
final int maxRetries;
// One StreamController per active subscription; closing the controller
// is the kill switch that propagates "stop tracking" to the consumer.
final Map<String, StreamController<DriverLocation>> _active = {};
Stream<DriverLocation> subscribeToDriver(String orderId) {
// If somehow a previous subscription is still around, close it first.
_active[orderId]?.close();
final controller = StreamController<DriverLocation>();
_active[orderId] = controller;
unawaited(_pumpWithRetry(orderId, controller));
return controller.stream;
}
/// Closes the upstream stream for `orderId`. `_onConnect` in the Bloc
/// sees `emit.forEach` complete and emits its own `Closed` state.
Future<void> disconnect(String orderId) async {
final controller = _active.remove(orderId);
await controller?.close();
}
Future<void> _pumpWithRetry(
String orderId,
StreamController<DriverLocation> sink,
) async {
var attempt = 0;
while (!sink.isClosed) {
try {
await for (final loc in transport.driverLocationStream(orderId)) {
if (sink.isClosed) return;
sink.add(loc);
attempt = 0; // reset on any successful frame
}
// Upstream ended normally — close the sink so `emit.forEach` exits.
if (!sink.isClosed) await sink.close();
return;
} on TransientTransportException catch (e) {
attempt++;
if (attempt > maxRetries) {
// Promote a transient failure to fatal after N attempts.
if (!sink.isClosed) sink.addError(FatalTransportException(e.toString()));
if (!sink.isClosed) await sink.close();
return;
}
final backoff = Duration(milliseconds: 500 * (1 << (attempt - 1).clamp(0, 5)));
await Future<void>.delayed(backoff);
}
}
}
}
lib/ui/features/order_tracking/bloc/order_tracking_bloc.dart
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../data/repositories/order_repository.dart';
import '../../../../domain/models/driver_location.dart';
import 'order_tracking_event.dart';
import 'order_tracking_state.dart';
class OrderTrackingBloc
extends Bloc<OrderTrackingEvent, OrderTrackingState> {
OrderTrackingBloc({required OrderRepository repo})
: _repo = repo,
super(const OrderTrackingState.initial()) {
on<TrackingConnectRequested>(_onConnect, transformer: restartable());
on<TrackingDisconnectRequested>(_onDisconnect);
}
final OrderRepository _repo;
Future<void> _onConnect(
TrackingConnectRequested event,
Emitter<OrderTrackingState> emit,
) async {
emit(const OrderTrackingState.connecting());
await emit.forEach<DriverLocation>(
_repo.subscribeToDriver(event.orderId),
onData: (loc) => OrderTrackingState.connected(loc),
onError: (err, _) =>
OrderTrackingState.disconnected(reason: err.toString()),
);
// Stream completed normally — driver delivered, etc.
if (!emit.isDone) emit(const OrderTrackingState.closed());
}
Future<void> _onDisconnect(
TrackingDisconnectRequested event,
Emitter<OrderTrackingState> emit,
) async {
// Route disconnection through the Repository: closing the upstream stream
// makes `_onConnect`'s `emit.forEach` complete naturally, at which point
// `_onConnect`'s `if (!emit.isDone) emit(Closed)` epilogue fires.
// Do NOT just emit `Closed` here — each handler has its own Emitter, so
// marking this one done has zero effect on the other's subscription.
await _repo.disconnect(event.orderId);
}
}
lib/ui/features/order_tracking/view/order_tracking_view.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/order_tracking_bloc.dart';
import '../bloc/order_tracking_event.dart';
import '../bloc/order_tracking_state.dart';
class OrderTrackingView extends StatelessWidget {
const OrderTrackingView({super.key, required this.orderId});
final String orderId;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (ctx) => OrderTrackingBloc(repo: ctx.read())
..add(TrackingConnectRequested(orderId)),
child: BlocConsumer<OrderTrackingBloc, OrderTrackingState>(
listenWhen: (_, n) => n is TrackingClosed,
listener: (ctx, _) => Navigator.of(ctx).pop(), // back to order summary
builder: (context, state) => switch (state) {
TrackingInitial() || TrackingConnecting() =>
const Center(child: CircularProgressIndicator()),
TrackingConnected(:final location) =>
_DriverMap(location: location),
TrackingReconnecting(:final reason) =>
_Banner(text: 'Reconnecting: $reason'),
TrackingDisconnected(:final reason) =>
_ErrorView(message: reason),
TrackingClosed() => const SizedBox.shrink(),
},
),
);
}
}
emit.forEach plus restartable() plus a single Closed sentinel: that is the whole pattern. Everything else — backoff timing, transport choice, frame format — lives one layer down in the Repository where Bloc-level skills have no business reaching.
<!-- tomevault:4.0:skill_md:2026-05-22 -->Source: abdallhMoukdad/flutter-bloc-skills — distributed by TomeVault.
- 流狐分类
- 工程开发
- 作者声明 Agent
- 未找到明确声明;不据此推断已兼容或已测试
- 静态检查
- 88 / 100 · 启发式扫描,不代表运行安全
- 作者 / 版本 / 许可
- @tomevault-io · 未声明 license
- 流狐 Token 估算
- 低消耗
- 流狐接入估算
- 需简单配置
- 是否需要外部 API Key
- 未发现要求
- 检测到的系统要求
- 未声明
- 底层运行要求
- 未声明
- 检测到的文件与系统行为
-
- 只读
- 允许写入 / 修改
- 检测到的网络行为
- 仅限本地
- 安装命令数
- 无(仅作为资料)
档案由构建时根据 SKILL.md 与安装命令自动衍生,可能与作者实际意图存在差异。
需要注意: 未限定 allowed-tools,默认拥有全部工具权限。
作者没有在当前 SKILL.md 中定义固定输出样例。 Stream subscription pattern Connection lifecycle State design for streams
Inside an event handler, await emit.forEach(stream, onData: ..., onError: ...) keeps the handler alive for as long as the stream yields. When the Bloc closes — route pop, app background, hot restart — the subscription is cancelled automatically. There is no…
A stream-driven Bloc has at least two explicit events: Event · Triggered by · Effect ConnectRequested(id) · View initState after BlocProvider mounts; or after a RetryRequested · Start emit.forEach on the stream
Do not reuse Loading / Loaded from API-call states for streams. They mean different things: API Loaded is terminal, stream Connected is ongoing. Use distinct variants. The View's switch over this state is exhaustive — adding a Reconnecting variant later forces…
Workflow: Wire a real-time stream into a Bloc
[ ] Step 1 — Add Repository stream method. Stream<T> subscribeTo<...>(args) returning a hot or cold Stream. Lives in lib/data/repositories/. [ ] Step 2 — Wrap transient errors. Inside the Repository, transform the raw transport stream so transient disconnects…
# Real-Time Streams in Bloc
Couples a `Stream<T>` from the data layer to a Bloc's state machine. The Bloc consumes the stream inside an event handler using `await emit.forEach(...)`, which ties the subscription's lifetime to the handler's lifetime and the Bloc's `close()` — no manual `StreamSubscription` to track, no leaks. Connection lifecycle (connect, disconnect, reconnect-with-backoff) is explicit events on the Bloc; the transport plumbing lives in the Repository. Builds on `flutter-bloc-feature-pattern`.
## Contents
- [Stream subscription pattern](#stream-subscription-pattern)
- [Connection lifecycle](#connection-lifecycle)
- [State design for streams](#state-design-for-streams)
- [Workflow: Wire a real-time stream into a Bloc](#workflow-wire-a-real-time-stream-into-a-bloc)
- [Applied to Talabat-clone](#applied-to-talabat-clone)
- [Examples](#examples)
## Stream subscription pattern
Inside an event handler, `await emit.forEach(stream, onData: ..., onError: ...)` keeps the handler alive for as long as the stream yields. When the Bloc closes — route pop, app background, hot restart — the subscription is cancelled automatically. There is **no `StreamSubscription` field** on the Bloc, and **no `cancel()` call** in `close()`.
```dart
Future<void> _onConnect(
TrackingConnectRequested event,
Emitter<OrderTrackingState> emit,
) async {
emit(const OrderTrackingState.connecting());
await emit.forEach<DriverLocation>(
_repo.subscribeToDriver(event.orderId),
onData: (loc) => OrderTrackingState.connected(loc),
onError: (err, _) => OrderTrackingState.disconnected(reason: err.toString()),
);
}
```
Compare with the older `stream.listen` approach:
```dart
// AVOID — manual lifetime management
StreamSubscription? _sub;
… 作者原文负责流程事实;流狐只索引当前章节、要点、文件与命令。
章节 -> Contents → Stream subscription pattern → Connection lifecycle → State design for streams → Workflow: Wire a real-time stream into a Bloc → Task Progress
要点 -> no StreamSubscription field · no cancel() call · Crucial · cannot · Reconnection with backoff lives in the Repository · not · Step 1 — Add Repository stream method. · Step 2 — Wrap transient errors.
文件/命令 -> Stream<T> · await emit.forEach(...) · close() · StreamSubscription · flutter-bloc-feature-pattern · await emit.forEach(stream, onData: ..., onError: ...) · cancel() · stream.listen
内容 SHA-256 -> d99bc9bec727
方法与流程
适用与边界
原文中的明确线索
Stream<T>、await emit.forEach(...)、close()、StreamSubscription、flutter-bloc-feature-pattern、await emit.forEach(stream, onData: ..., onError: ...)、cancel()、stream.listen