☰
Flutter BLoC状态管理实战:从setState到状态机重构鸿蒙应用
2026/10/15 4:33:19 网站建设 项目流程

没有主标题,直接从二级标题开始。我直接开始写博文内容。

开头部分:

说起来挺有意思,学习鸿蒙开发的第五天,我没有继续看组件文档,而是钻进了一个看起来有点“重”的状态管理方案——Flutter 的 BLoC 模式。毕竟在鸿蒙生态里用 Flutter 跑业务,最让人头疼的从来不是 UI 画不画得出来,而是页面一复杂,状态就开始四处乱窜。特别是当你需要同时处理登录态、倒计时、轮询刷新、异步操作结果这些互相牵扯的流程时,传统的 setState + 临时变量方案基本就是灾难现场的代名词。这篇文章就记录一下我这几天用 BLoC 的流式思维,把一个到处是临时状态变量的业务页面,重构成了有清晰状态机的过程,以及踩过的那些坑。


1. 状态爆炸:为什么需要 BLoC 来解决业务状态机问题

1.1 一个「简单」页面背后有多少状态在流动

我最初接手的那个页面,从 UI 上看并不复杂:一个登录表单、一个验证码倒计时按钮、进入首页后有一个设备列表,每五秒自动刷新一次,点击设备还能下发控制指令,指令执行过程中要显示 loading。听起来是不是很常规?

但真实写起来,这里面的状态远比想象中多。验证码按钮至少有四种状态:可点击、倒计时中、发送中、发送失败。登录表单有输入中、提交中、登录成功、登录失败。设备列表有加载中、加载成功、加载失败、正在刷新。每个设备还有在线、离线、控制指令执行中、执行成功、执行失败。如果再把「每五秒刷新一次」的定时器和「用户手动下拉刷新」这两个触发源叠加起来,乱不乱你自己想。

说实话,用 StatefulWidget 的 setState 硬写,也不是完全不能跑,但代码会变得极其脆弱。你大概率需要维护七八个成员变量,每个变量都要在多个回调里被赋值,然后手动调用 setState。更麻烦的是这些状态之间还有联动关系——比如登录成功后要立刻触发设备列表首刷,倒计时结束时要自动切回可点击状态,设备下发电时还要防止用户重复点击。

1.2 状态机失控的典型症状

我当时写了一个版本,跑了两天后回头一看,代码已经出现了几种典型的失控症状。

第一种是setState 满天飞。每个异步回调、每个用户事件处理函数里都有 setState,而且这些 setState 的调用顺序完全靠人肉保证。稍不留神,就会出现「先 setState 了 A 状态,又 setState 了 B 状态,但 A 和 B 之间有依赖关系」的情况,界面表现就会跳变。

第二种是状态变量散落各处。倒计时的 Timer 存在 State 对象里,设备列表的刷新状态存在另一个自定义 Controller 里,登录 token 存在全局单例里,控制指令的执行状态又存在某个临时 map 里。你要想搞清楚某一个时刻整个页面处于什么状态,得把七八个地方翻一遍。

第三种是很难测试。因为所有逻辑都耦合在 Widget 的生命周期和回调函数里,你想单测验证码倒计时逻辑,就得把 UI 也一起挂上。麻烦不说,还特别容易因为时序问题测得怀疑人生。

这时候我开始意识到,控制复杂度的关键不在 UI 层,而在于把「状态是如何产生的」、「状态之间如何流转」这件事,从 UI 里剥离出去,变成一个可以独立推演的系统。这也是我决定引入 BLoC 的直接动机。

1.3 BLoC 为什么能成为解决方案

关于 Flutter 的状态管理,网上方案很多,Provider、Riverpod、GetX、Bloc 各有拥趸。我选 BLoC 不是因为它是「最热门的」,而是因为它把「状态机」这件事做得最彻底。

BLoC 的核心模型是:UI 只能通过事件(Event)去改变状态,状态通过流(Stream)单向传递给 UI。中间的业务逻辑,全部收敛在 BLoC 内部。这个模型天然适合我前面说的那种多状态联动场景。

它的优势有几个层面。第一个是状态流转有迹可循。因为所有状态变更都发生在 BLoC 内部的一个个事件处理函数里,你随时能看清楚当前状态是什么、上一步发生了什么、下一步会变成什么样。第二个是UI 变得很薄。界面上不需要再保存业务状态,只需要根据 Stream 推送过来的状态去渲染对应的 UI 分支即可。第三个是可测试性大幅提升。BLoC 不依赖 Widget,你可以直接向它添加事件,然后断言它发出什么状态,跟 UI 完全解耦。

带着这些认知,我开始进入 BLoC 的实战环节。


2. BLoC 的运转内核:流式思维到底在解决什么问题

2.1 一个类比:把状态流当成电梯调度

在我动手重构之前,我觉得有必要先把 BLoC 的运转方式讲清楚,因为很多人在开始写 BLoC 的时候,会卡在「Stream 到底是个什么东西」上。

我把 BLoC 比作一个电梯调度中心。传统写法里,你要去某个楼层,是自己跑到电梯门口,直接伸手把每一层楼的按钮都按一遍(等价于到处 setState)。而 BLoC 的做法是:你只告诉调度中心「我要去 18 楼」(添加一个事件),调度中心根据当前电梯的位置、其他楼层请求、电梯的健康状态(当前状态机),来决定下一步怎么走,然后电梯每经过一层就广播一次「现在到几楼了」(向外发状态)。

在这个比喻里,Stream 就是那套广播系统。你不需要知道调度中心内部是怎么排队的,你只需要听广播,然后决定自己这个楼层的人要不要进出。听上去很绕,但它解决了两个很实际的问题:状态的产生和状态的使用被彻底分离了,而且状态的流转顺序由中心统一控制,不会出现两个地方同时改同一个变量的竞争问题。

2.2 事件、状态、Stream 的三者关系

具体到 Flutter 的 BLoC 实现里,我们需要理清楚这几个概念。

事件(Event)是外界告诉 BLoC 「发生了什么」的载体。用户点击了按钮、定时器响了、网络请求返回了,这些都可以转成事件。事件本身不包含如何处理的逻辑,只声明「这件事发生了」。

状态(State)是 BLoC 对外宣告「现在整个业务处于什么情况」的载体。状态通常是不可变的对象,比如「正在登录中」「验证码倒计时还剩 30 秒」「设备列表加载失败」。UI 只根据状态来渲染。

Stream(流)是状态从 BLoC 到 UI 的通道。BLoC 通过yield或者add的方式把新状态推入流中,UI 通过监听流来收到状态变化。

一个循环大概是这样的:用户在 UI 上操作 → UI 把操作包装成 Event 并add给 BLoC → BLoC 内部根据当前状态和事件类型运行逻辑,产生新状态 → 新状态通过 Stream 推送给 UI → UI 用BlocBuilder重新构建对应区域。

这个过程也叫单向数据流。状态不会反向修改事件,事件不会直接写状态,所有变更都严格沿着「事件进、状态出」这条路走。

2.3 为什么单向数据流适合复杂的业务状态机

我在实际重构的时候,特别体会到了单向数据流的好处——它强制你把乱糟糟的时序问题变成一个线性的状态迁移问题。

举个例子,验证码倒计时。用传统方式写,你需要在按钮点击事件里启动 Timer,自己维护一个 remainingSeconds 变量,还要在 dispose 里取消 Timer。如果用户中途退出页面再进来,Timer 状态很容易错乱。

换成 BLoC 之后,倒计时的处理变成了一串事件和状态:

  • 用户点击发送验证码 → 产生SendCodePressed事件
  • BLoC 收到事件后,先发一个CodeSendingInProgress状态,然后发起网络请求
  • 请求成功后,BLoC 启动一个 Timer,每隔一秒向自己add一个CountdownTicked事件
  • 每个CountdownTicked事件进来,BLoC 计算剩余秒数,发一个CountdownUpdated(seconds)状态
  • 倒计时归零,BLoC 发一个CountdownFinished状态

BlocBuilder只需要根据状态里包含的剩余秒数来刷新 UI 文本,根本没资格也不需要对 Timer 做任何操作。这样一来,倒计时的所有逻辑都集中在 BLoC 内部,页面销毁、重新创建、前后台切换,都不会导致 timer 错乱——因为 timer 的启停由事件驱动,状态流动也是由事件驱动,两者天然同步。

这是流式思维的核心:业务逻辑不要散落在 UI 里,而是把它变成一条事件流上的状态转换图。


3. 实战重构:从临时状态变量到标准化 BLoC 状态机

3.1 业务背景与状态机拆分

我在这次实战里做的项目代号为「某跨平台设备控制台」。主要业务是:用户输入手机号获取验证码→登录→进入首页查看设备列表→轮询刷新→对设备执行控制指令并查看执行结果。

为了把整个页面改造成状态机驱动,我先做了一件事:把所有可能的状态列成一张表。这一步听起来很基础,但特别有效。我列出的状态大概分成三块:

业务模块状态集合
登录/验证码初始状态、发送中、发送成功、倒计时中、校验中、登录成功、登录失败
设备列表加载中、加载成功、加载失败、刷新中、空列表
设备控制执行中、执行成功、执行失败、设备离线不可控

带着这张表,我就能清晰地设计事件和状态类了。

3.2 事件驱动设计:把所有「用户操作」和「异步回调」统一成事件

BLoC 的起点是事件。在设计阶段,我要求自己做到一件事:界面上每一个可交互的动作,以及每一个异步回调结果,都必须有对应的事件,不允许直接在 UI 里写 setState。

最终我定义的事件集合大体是这样的:

sealed class AppEvent {} // 验证码 class SendCodePressed extends AppEvent {} class CodeCountdownTicked extends AppEvent {} // 登录 class LoginSubmitted extends AppEvent { final String phone; final String code; LoginSubmitted({required this.phone, required this.code}); } // 设备列表 class DevicesRefreshRequested extends AppEvent {} class DevicesPollingStarted extends AppEvent {} class DevicesPollingStopped extends AppEvent {} // 设备控制 class DeviceCommandIssued extends AppEvent { final String deviceId; final String command; DeviceCommandIssued({required this.deviceId, required this.command}); } class DeviceCommandFinished extends AppEvent { final String deviceId; final bool success; final String? errorMessage; DeviceCommandFinished({required this.deviceId, required this.success, this.errorMessage}); }

这里我把事件做成了 sealed class,好处是switch分支能够穷举,编译器会提示你是否漏了某种事件类型。这一步在状态机建模里非常重要,它能帮你在写代码的阶段就避免「某个事件没人处理」的情况。

3.3 状态定义:用 sealed class 锁定合法的状态集合

接下来是状态类。我的原则是:状态要能完整描述当下 UI 需要展示的一切信息,但不包含任何业务逻辑。

同样用 sealed class 把它们组织起来:

sealed class ControlPanelState { const ControlPanelState(); } class Initializing extends ControlPanelState { const Initializing(); } class AwaitingCode extends ControlPanelState { final int remainingSeconds; const AwaitingCode({required this.remainingSeconds}); } class CodeSending extends ControlPanelState { const CodeSending(); } class LoginSubmitting extends ControlPanelState { const LoginSubmitting(); } class LoggedIn extends ControlPanelState { final List<DeviceInfo> devices; final bool isRefreshing; final Set<String> executingDeviceIds; const LoggedIn({ required this.devices, required this.isRefreshing, required this.executingDeviceIds, }); } class LoginFailed extends ControlPanelState { final String message; const LoginFailed({required this.message}); }

你可能会注意到,我没有给「倒计时中」「设备加载中」这些单独定义类,而是把它们合成到具体的业务状态里。这是因为倒计时和加载过程本质上都是「当前某一业务块处于中间状态」的附属信息,把它们合成到一个不可变对象里,反而更容易让 UI 层在一个状态下完成所有渲染,避免状态频繁切换导致闪烁。

就我个人经验来说,状态类的设计,宁可合并不要过度拆分。拆得过细,会让 BLoC 内部疯狂发状态,UI 层不停重建,最后性能反而变差。

3.4 核心 BLoC 实现

有了事件和状态,BLoC 主体的实现就顺理成章了。我用的是flutter_bloc包,它基于package:bloc提供了一整套便捷 API。

class ControlPanelBloc extends Bloc<AppEvent, ControlPanelState> { ControlPanelBloc({required this.authApi, required this.deviceApi}) : super(const Initializing()) { on<SendCodePressed>(_onSendCodePressed); on<CodeCountdownTicked>(_onCodeCountdownTicked); on<LoginSubmitted>(_onLoginSubmitted); on<DevicesRefreshRequested>(_onDevicesRefreshRequested); on<DevicesPollingStarted>(_onDevicesPollingStarted); on<DevicesPollingStopped>(_onDevicesPollingStopped); on<DeviceCommandIssued>(_onDeviceCommandIssued); on<DeviceCommandFinished>(_onDeviceCommandFinished); } final AuthApi authApi; final DeviceApi deviceApi; Timer? _countdownTimer; Timer? _pollingTimer; int _remainingSeconds = 0; Future<void> _onSendCodePressed( SendCodePressed event, Emitter<ControlPanelState> emit) async { emit(const CodeSending()); try { await authApi.requestLoginCode(); _remainingSeconds = 60; emit(AwaitingCode(remainingSeconds: _remainingSeconds)); _countdownTimer?.cancel(); _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { add(CodeCountdownTicked()); }); } catch (e) { emit(LoginFailed(message: '验证码发送失败,请重试')); } } void _onCodeCountdownTicked( CodeCountdownTicked event, Emitter<ControlPanelState> emit) { if (_remainingSeconds > 0) { _remainingSeconds--; emit(AwaitingCode(remainingSeconds: _remainingSeconds)); } else { _countdownTimer?.cancel(); } } // ... 其他事件处理函数的实现省略,形式一致 }

这段代码里最值得注意的几点:

  • emit 相当于向外发布状态,而不是修改某个成员变量。状态一经发出,UI 层就能立刻收到。
  • 定时器不直接调用 setState,而是向自己 add 事件。这样做的好处是,所有的状态变更仍然集中在事件处理函数里,逻辑链完整。
  • Emitter是异步处理的关键。当你在事件处理函数里await一个网络请求后,再调用emit,BLoC 的框架会确保状态是按序发出的,不会因为并发事件导致状态错乱。

3.5 UI 层接入:用 BlocBuilder 把状态变成视图

UI 层接入反而变得很机械。核心就三步:用BlocProvider提供 BLoC 实例、用BlocBuilder监听状态、根据状态分支渲染。

BlocProvider( create: (context) => ControlPanelBloc( authApi: RepositoryProvider.of<AuthApi>(context), deviceApi: RepositoryProvider.of<DeviceApi>(context), ), child: const ControlPanelPage(), )

页面主体通过BlocBuilder根据不同状态返回不同组件:

BlocBuilder<ControlPanelBloc, ControlPanelState>( builder: (context, state) { switch (state) { case Initializing(): return const LoadingScreen(); case AwaitingCode(:final remainingSeconds): return CodeInputView(remainingSeconds: remainingSeconds); case LoginSubmitting(): return const ProgressDialog(); case LoggedIn(:final devices, :final isRefreshing, :final executingDeviceIds): return DeviceListView( devices: devices, isRefreshing: isRefreshing, executingDeviceIds: executingDeviceIds, ); case LoginFailed(:final message): return ErrorBanner(message: message); } }, )

这是我特别想强调的一点:Dart 3 的 pattern matching 让 BLoC 的 UI 渲染变得非常优雅。你不再需要在 builder 里写一长串 if-else,一个 switch 表达式就把所有状态分支干净地列出来了。而且 sealed class 保证了你必须处理所有状态,否则编译器会直接报错提醒。

在这种结构下,UI 组件自身不再保存任何业务数据,所有信息都从 state 对象里解构出来。这意味着即使你在 UI 层对设备列表做了排序、筛选等操作,也只是基于 state 数据的派生渲染,不会污染 BLoC 内部的原始状态。UI 层可以放心地被随意重建,因为它只是一个「状态的翻译器」。


4. 实际落地中必须留意的坑与经验

4.1 Stream 背压问题与事件积压

BLoC 用起来之后,我遇到的第一个实际问题,是事件积压。

由于轮询和倒计时都会定时 add 事件,如果某一个事件的处理函数里出现了耗时较长的网络请求,后续的事件就会被暂时阻塞。flutter_bloc 默认是按事件到达顺序逐个处理的,前面的没处理完,后面的就会排队。

具体到我的场景,设备列表轮询的响应比较慢,结果用户在这期间又下拉刷新了一次,又点击了一个设备控制按钮,这些事件会全部排队等网络请求返回后才继续处理。体验上的直观感受就是:操作延迟、按钮无响应。

我的解决办法是有选择地使用emit.onEach和在新事件进来时主动取消旧任务。比如设备列表刷新,我不再让事件处理函数里直接 await 网络请求,而是改用一种「最新事件优先」的策略——新的事件进来时,如果上一轮刷新还没结束,就直接跳过这一轮刷新。代码如下:

on<DevicesRefreshRequested>((event, emit) async { if (_isRefreshing) return; _isRefreshing = true; ... _isRefreshing = false; });

用「当前有没有在途请求」这个标志位来判断是否忽略重复事件,比盲目堆积请求要稳妥得多。

4.2 定时器与 StreamSubscription 的释放

BLoC 虽然把逻辑从 UI 层拿出来了,但定时器的生命周期管理仍然需要你自己负责。Timer 是跟 BLoC 实例绑定的,而 BLoC 实例的生命周期一般跟页面绑定。

如果页面销毁了,但 BLoC 里的 Timer 还没取消,会导致两件事:一是定时器继续跑,白白消耗资源;二是 Timer 回调里如果 add 了事件,而页面已经销毁,就会产生无谓的状态更新,甚至触发异常。

我在ControlPanelBloc里重写了close方法:

@override Future<void> close() { _countdownTimer?.cancel(); _pollingTimer?.cancel(); return super.close(); }

这个方法会在BlocProvider销毁时自动被调用。如果你用 BLoC 是手动创建的,记得自己调一下close,否则所有订阅都会泄漏。

另外,如果你在 BLoC 内部监听了一个外部 Stream(比如某个全局的推送通道),一定要保存好StreamSubscription,并在close里 cancel。这个坑我踩过一次——页面关了,推送消息还在走回调,结果状态更新跑到了一个已经销毁的 widget 树上,问题排查起来非常费劲。

4.3 调试技巧与可观测性:让状态流转可视化

BLoC 模式调试起来,比传统 setState 爽不少,因为你可以看状态流转记录。flutter_bloc 自带的BlocObserver可以统一记录所有 BLoC 的事件和新状态:

class GlobalBlocObserver extends BlocObserver { @override void onEvent(Bloc bloc, Object? event) { super.onEvent(bloc, event); debugPrint('[${bloc.runtimeType}] event: $event'); } @override void onTransition(Bloc bloc, Transition transition) { super.onTransition(bloc, transition); debugPrint('[${bloc.runtimeType}] transition: ${transition.currentState} -> ${transition.nextState}'); } }

在main里注册:

Bloc.observer = GlobalBlocObserver();

这样每次点击按钮、每次定时器触发、每次网络请求返回,我都能在控制台看到一条完整的事件→状态变化记录。排查「为什么界面闪了一下」「为什么按钮状态不对」这类问题,只需要翻日志看最终状态是从哪条路径来的,比手动打断点高效得多。

在鸿蒙的开发环境里,日志输出和 Android 上的 Logcat 略有不同,但debugPrint的输出在 DevTools 里一样能看到。有条件的话,接一个 Firebase Crashlytics 或者自建的日志上报,把状态流转记录一起上报,线上问题定位会非常舒服。

4.4 行为差异:在鸿蒙环境下的实际表现

这次项目是在鸿蒙环境里跑 Flutter。需要提醒大家的是,虽然 BLoC 本身是纯 Dart 层实现,对平台没有依赖,但有几个表现差异值得留意。

首先是热重载的表现。鸿蒙环境下的热重载和 Android 上略有不同,有时候 BLoC 的状态不会因为热重载而重置,这会导致你改完代码热重载后,界面停留在旧状态。我遇到过一次倒计时文本一直卡在某个数值的情况,排查了半天,最后发现是热重载后 Timer 还在跑,旧状态继续发。所以遇到热重载后表现异常,优先考虑冷启动一下。

其次是包体积。flutter_bloc 和 bloc 依赖并不大,但如果同时引入了 Equatable 等辅助库,注意鸿蒙应用对整包体积有要求,尽量用--analyze-size检查一下各依赖的占用。我实测下来,bloc 系的库在鸿蒙上的体积增量很小,完全在可接受范围。

最后是时序与平台通道交互。如果你的业务里需要调用鸿蒙原生能力(比如系统推送、蓝牙状态感知),这些原生事件如果要转成 BLoC 事件,注意消息是异步到达的。我在做设备控制功能时,需要监听原生蓝牙回调,结果发现事件到达 BLoC 的顺序偶尔会乱。最终的处理方式是给事件带一个时间戳,在状态机里做简单的顺序校验,避免旧回调覆盖新状态。这个思路对任何有外部异步事件的场景都适用。

4.5 代码组织方式与团队协作

这里分享一个让我受益很大的组织习惯:把 BLoC 按业务领域而不是按页面拆分。

我在最初写 DeviceListBloc 的时候,把「轮询管理」和「设备控制」塞在一起,结果 BLoC 类很快膨胀到六百多行,事件和状态互相纠缠。后来我按领域拆分成了三个 BLoC:

  • AuthBloc:管理登录、验证码倒计时
  • DeviceListBloc:管理列表刷新、轮询、加载状态
  • DeviceCommandBloc:管理单台设备控制指令的执行状态

页面层面再用MultiBlocProvider把它们组合起来。

这样的好处是每个 BLoC 的状态机都足够简单,事件处理函数数量少,状态流转容易穷举。对应的BlocBuilder也只在需要的地方监听。否则一个页面整体监听一个巨大的 BLoC,任何小状态变化都会导致大半个页面重建,性能上也不合适。

如果 team 协作,建议制定简单的约定:事件类命名用「过去分词」或「动作 + Pending/Finished」的格式,状态类命名用「业务场景 + 状态」的格式。格式统一后,代码 review 和全局搜索都轻松很多。


5. 测试与维护:状态机重构后的长期收益

这次重构我最满意的部分,不是 UI 变得多顺滑,而是测试方式彻底变了。

传统写法下,要测试「验证码倒计时结束后按钮自动恢复可点击」,你得在 widget 测试里 pump 60 秒,过程又慢又脆弱。现在直接在 BLoC 测试里模拟事件序列就行:

test('验证码倒计时归零后回到可发送状态', () async { final bloc = ControlPanelBloc(...); bloc.add(SendCodePressed()); await Future<void>.delayed(const Duration(seconds: 1)); bloc.add(CodeCountdownTicked()); await expectLater( bloc.stream, emitsInOrder([ isA<CodeSending>(), isA<AwaitingCode>(), ]), ); });

由于状态是 Stream 推出来的,测试只需要断言 Stream 里的状态序列是否符合预期。这段测试跑完只要几百毫秒,不用等真实的 60 秒倒计时。

还有一个长期收益是对需求变更的响应速度。有一次产品要求把轮询间隔从 5 秒改成 30 秒,传统写法我得在 Timer 的初始化处改时长,还要注意会不会有残留的旧 Timer。在 BLoC 里,我只需要改一个常量,因为 Timer 的启停逻辑已经统一收敛在事件处理函数里,改起来非常稳。代码健壮性的提升,往往不是在写新功能时感受到的,而是在改旧逻辑、加新条件时不那么战战兢兢了。

从鸿蒙 Day 5 的角度看,BLoC 带给我的除了状态管理本身,还有一层更深的体会:流式思维其实是一种抽象能力。它逼着我先思考「整个业务流程的合法状态有哪些」、「哪些事件会触发状态迁移」、「迁移的边界条件是什么」,然后才动手写 UI。这个思考顺序,比任何框架技巧都值钱。

如果在实际项目中尝试 BLoC,我个人建议不要一上来就全量重构。可以找一个交互不太复杂的页面练手,先把事件和状态完整列出来,再一点点把 setState 逻辑迁移过去。跑通一两个流程后,你会对状态机的边界和粒度有更真切的感受,这时候再去啃复杂业务,就不会再踩我前面说的那些坑了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询