Flutter Signals 学习与项目实践
前言:
-
最近抽了一些下班后的时间,把项目里原有的
Bloc/Cubit状态管理完整迁到了Signals。一开始学习Signals的时候,也看了不少文章和示例,很多内容都在讲signal、computed、effect这些基础概念,但真正结合项目页面、网络请求、分页刷新、局部更新来讲的并不算多。 -
所以这篇文章就结合当前这个 Demo,聊一聊
Signals在 Flutter 项目中的实际使用方式、设计思路和常见问题。项目中也顺手对网络请求、页面状态、通用壳子、列表刷新做了一轮收敛,现在整体已经不只是“把 Bloc 改个名字”,而是逐步形成了一套更轻的Signals项目写法。 -
本篇文章更偏实战一些,重点讲的是
Signals在项目中的落地,而不是单独讲 API。老规矩,先从整体思路开始。
正文:
当前项目中的 Signals 使用,主要从下面几个维度来理解:
Signals的基本思想Signals在页面中的完整使用流程Signals如何做颗粒化刷新和局部刷新Signals如何组织网络请求和页面状态- 项目中基础层、路由和通用组件的搭配思路
-
Signals 基本思想
Signals 的核心目标很简单,就是让“状态”和“依赖这个状态的界面”建立更直接的响应关系。
在传统的 Bloc 模式里,我们通常是:
Event触发操作Bloc处理逻辑emit(state)更新状态BlocBuilder根据新state重建页面
而在 Signals 里,思路会更直接:
- 页面或
store改变某个signal - 依赖这个
signal的Watch自动更新
也就是说,Signals 更像是把“状态变更”和“界面依赖”做成了天然绑定,不需要再专门经过 Event -> emit -> rebuild 这条链路。
这套思路最大的好处就是:
-
写法更轻
-
状态拆分更自然
-
局部刷新更容易实现
-
不必为了一个小状态变动去维护一整个
State对象 -
Signals 的核心概念:
-
signal最基础的响应式状态。它持有一个值,当值发生变化时,依赖它的地方会自动更新。 -
computed派生状态。它不直接存数据,而是基于其他signal计算得到,适合做按钮是否可点击、列表是否为空、是否显示某段 UI 这种逻辑。 -
effect副作用监听。适合做日志、埋点、导航、联动处理等。项目里我更建议把大多数业务逻辑放到store里,effect只做少量副作用处理。 -
Watchsignals_flutter提供的监听组件。Watch内部只要读取了某个signal,当这个signal改变时,对应builder就会自动刷新。 -
Store项目里我比较推荐用feature store的思路,也就是每个页面或者每条业务链路一个store,把该页面需要的signal、请求逻辑、分页逻辑、副作用逻辑都收在一起。 -
使用 Signals 开发一个页面完整流程
下面以“网络列表页”为例,说明一个页面从 0 到 1 的常见写法。
- 创建
store
class CartoonStore {
final netState = signal(NetState.loadingState);
final items = signal<List<CartoonModelDataInfos>>(<CartoonModelDataInfos>[]);
final page = signal(1);
final isNoMoreData = signal(false);
Future<void> refresh() async {
page.value = 1;
isNoMoreData.value = false;
await _requestData(isRefresh: true);
}
Future<void> loadMore() async {
if (isNoMoreData.value) return;
page.value++;
await _requestData(isRefresh: false);
}
Future<void> _requestData({required bool isRefresh}) async {
if (isRefresh) {
netState.value = NetState.loadingState;
}
final response = await LttHttp().request<CartoonModel>(
'/mock/cartoon/page_${page.value}.json',
method: HttpConfig.mock,
);
if (response == null) {
netState.value = NetState.errorShowRefresh;
return;
}
final list = response.data?.infos ?? <CartoonModelDataInfos>[];
if (isRefresh) {
items.value = list;
} else {
items.value = [...items.value, ...list];
}
isNoMoreData.value = list.isEmpty;
netState.value = items.value.isEmpty
? NetState.emptyDataState
: NetState.dataSuccessState;
}
}
- 在页面中持有
store
class SignalsListPage extends StatefulWidget {
const SignalsListPage({super.key});
@override
State<SignalsListPage> createState() => _SignalsListPageState();
}
class _SignalsListPageState extends State<SignalsListPage> {
final store = CartoonStore();
@override
void initState() {
super.initState();
store.refresh();
}
@override
Widget build(BuildContext context) {
return PageScaffold(
title: 'signals-list',
body: Watch(
(_) => PageStateView(
netState: store.netState.value,
onRetry: store.refresh,
child: ListView.builder(
itemCount: store.items.value.length,
itemBuilder: (context, index) {
final item = store.items.value[index];
return Text(item.title ?? '');
},
),
),
),
);
}
}
完成上面几步,一个典型的网络列表页就基本搭起来了。
这套流程和 Bloc 最大的差异点就在于:
-
没有
Event -
没有
emit -
不需要维护一个庞大的
State clone() -
改哪个状态,就直接改哪颗
signal -
哪段 UI 依赖了哪个状态,就只刷新哪段 UI
-
Signals 适合做什么
如果从日常业务开发的角度看,Signals 特别适合下面这些场景:
- 登录页输入联动
- 列表页刷新和分页
- 详情页加载和局部更新
- 导航栏透明度变化
- 点赞、收藏、关注这种单点交互
- Tab 切换、筛选条件变化、局部区域刷新
项目里现在就有这些实际例子:
-
signals-list -
signals-grid -
signals-stagger -
signals-login -
单页面多接口并发请求 + 局部刷新案例
-
单页面多接口串行请求 + 局部刷新案例
-
颗粒化刷新或局部刷新
这是 Signals 最舒服的地方之一。
在 Bloc 里,我们常常需要借助:
buildWhenlistenWhen- 多个
BlocBuilder - 甚至一个页面拆两个
Bloc
来控制“哪里该刷新,哪里不该刷新”。
而 Signals 下,局部刷新会自然很多。
比如详情页里有两种变化:
- 页面初次进入,请求主数据、推荐数据、系列数据
- 页面滚动时,只让导航栏透明度变化
这时候最推荐的写法不是“一个大对象全塞进去”,而是把状态拆开:
final detailState = signal(NetState.loadingState);
final mainModel = signal<CartoonModelData?>(null);
final seriesList = signal<List<CartoonSeriesDataSeriesComics>>([]);
final recommendList = signal<List<CartoonRecommendDataInfos>>([]);
final navAlpha = signal(0.0);
然后页面里:
- 内容区域依赖
detailState、mainModel、seriesList、recommendList - 导航栏区域只依赖
navAlpha
这样当页面滚动时,只会刷新导航栏对应的 Watch,不会把整个页面根节点重新构建一遍。
这就是 Signals 下最自然的一种颗粒化刷新方式。
-
单页面多网络请求实现思路
在实际项目里,经常会碰到一个页面需要多个接口的情况,比如:
- 详情主数据
- 同系列数据
- 推荐数据
Signals 下常见有两种写法:
-
思路1:串行请求 适合有依赖关系的接口,前一个结果决定后一个请求参数。写法简单,但整体耗时会更长。
-
思路2:并行请求 适合相互独立的接口,使用
Future.wait并行获取数据,整体体验更好。
示例:
final results = await Future.wait([
LttHttp().request<CartoonModelData>(mainPath, method: HttpConfig.mock),
LttHttp().request<CartoonSeriesData>(seriesPath, method: HttpConfig.mock),
LttHttp().request<CartoonRecommendData>(recommendPath, method: HttpConfig.mock),
]);
mainModel.value = results[0]?.data;
seriesList.value = results[1]?.data?.seriesComics ?? [];
recommendList.value = results[2]?.data?.infos ?? [];
detailState.value = NetState.dataSuccessState;
和 Bloc 相比,这里不需要等所有数据准备好以后再统一 emit(state.clone()),拿到哪个结果就可以更新哪个 signal。如果业务允许,甚至可以边回来边展示,体验会更自然。
-
computed 的常见使用场景
很多同学刚开始用 Signals 时,会把所有逻辑都堆到页面里,其实 computed 非常适合做“派生状态”。
比如登录页里,手机号和验证码输入完成后按钮才可点击:
final phone = signal('');
final code = signal('');
late final canSubmit = computed(() {
return phone.value.length == 11 && code.value.length == 6;
});
这样页面里只关心:
Watch((_) => ElevatedButton(
onPressed: store.canSubmit.value ? store.submit : null,
child: const Text('登录'),
))
这比手动在多个输入回调里判断按钮状态要清晰很多。
-
Signals 中常见的几个误区
-
误区1:把整个页面状态塞进一个大对象,再用一个
signal(State())包起来
这其实只是把原来的 Bloc state 平移了一下,并没有真正发挥 Signals 的优势。更推荐拆成多颗更明确的 signal。
- 误区2:一个超大的
Watch包整个页面
这样虽然也能跑,但依赖范围过大,状态一变就容易整页刷新。更好的方式是按功能区拆 Watch。
- 误区3:页面直接写太多业务逻辑
网络请求、分页、错误处理、空态判断这些,最好都收进 store,页面只负责组织 UI。
- 误区4:该用
computed的地方,全部手动 set
只要某个值是“根据其他状态推导出来”的,就优先考虑 computed,这样更稳定也更不容易漏改。
-
针对 Signals 特性封装网络请求
项目中现在使用的是统一的网络入口:
final response = await LttHttp().request<CartoonModelData>(
path,
method: HttpConfig.get,
);
这套做法的好处有几点:
- 调用方式统一
- 支持泛型解析
- 接口返回值统一落到
ResponseModel<T> - 便于在
store里根据结果更新不同的signal
现在项目里的网络层主要包括这些能力:
- 基础请求封装
- 统一错误映射
- 多环境基础支持
- header 注入
- 超时控制
- 基础重试
在 Signals 架构下,网络层最重要的一点就是:请求方法要尽量返回明确结果,不要在回调里东一块西一块改页面状态。这样 store 才能清晰地决定该更新哪颗 signal。
-
页面状态设计
项目里目前保留了一个比较轻的 NetState 枚举,用来描述页面状态:
enum NetState {
initializeState,
loadingState,
error404State,
errorShowRefresh,
emptyDataState,
timeOutState,
dataSuccessState,
}
它的作用很直接,就是统一页面的:
- 加载中
- 空数据
- 网络错误
- 超时
- 加载成功
但和之前不一样的是,现在不再要求所有页面都继承一个沉重的 BaseState,而是每个 store 按需持有:
final netState = signal(NetState.loadingState);
需要分页的页面,再额外加:
final isNoMoreData = signal(false);
final page = signal(1);
这种方式比过去“所有页面都继承同一个大 state”要轻很多。
-
PageScaffold 设计
常规设计,满足日常开发使用。和之前重型 BasePage 不同,现在这一层更强调“轻壳子 + 组合”,主要负责:
- 页面标题栏
- 背景色
- 安全区域处理
- 通用布局结构
页面自己的业务内容,仍然由页面本身去组织。
-
PageStateView 设计
在真实项目里,一个页面往往要根据网络状态展示:
- 加载中
- 空数据
- 网络错误
- 超时
- 正常内容
这类逻辑如果每个页面都手写一遍,代码会非常重复,所以项目里抽了一个轻量的 PageStateView 来统一处理。
它和以前最大的区别是:
- 不再强依赖某个大而全的
BaseState - 只关心当前页面的
NetState - 页面仍然可以自由组合自己的业务 UI
组合起来会比较直白:
return PageScaffold(
title: 'signals-grid',
body: Watch(
(_) => PageStateView(
netState: store.netState.value,
onRetry: store.refresh,
child: mainWidget(),
),
),
);
这样做的好处也很明显:
-
没有强继承
-
页面结构更直观
-
通用能力还在
-
业务页不会被基础层反向绑架
-
路由设计
项目里路由还是统一管理的思路,这一点不管是 Bloc 还是 Signals 都一样重要。
统一路由的好处主要有:
- 跳转入口集中
- 页面参数更清晰
- 后续维护成本更低
状态管理变轻之后,页面跳转反而更建议把参数传清楚,不要再去页面里偷偷找全局状态。
-
通用列表、通用刷新组件设计
为了提高开发效率,项目里保留了一些高复用的基础组件,比如:
- 通用列表壳
- 通用网格壳
- 通用刷新壳
- 自定义刷新头和刷新尾
但和以前不同的是,现在更强调“保留真正高复用的壳”,而不是把业务判断都塞进基类。也就是说:
- 通用组件负责样式和基础能力
store负责状态- 页面负责拼装
这样职责边界会更清楚。
-
Signals 在项目中的几个实践建议
- 一个页面一个
store,但不要一个页面只放一颗总signal - 能拆分的状态尽量拆分,不要迷信“大而全”的
State computed用来做派生状态,别把简单推导也写成手动更新- 页面少写业务逻辑,把请求、分页、异常处理放进
store Watch不要无脑包根节点,优先包真正会变化的区域- 网络层尽量统一返回结果,不要在回调里四处修改 UI
-
当前项目中的 Signals 架构总结
经过这一轮整理,项目里的核心链路基本已经形成了比较稳定的 Signals 写法:
- 登录页使用本地
store + computed - Tab 使用轻量
store - 列表页使用
netState + items + page + isNoMoreData - 详情页按业务拆成多颗
signal - 页面通过
PageScaffold + PageStateView + Watch组合 - 网络层统一请求和错误处理
- 基础层不再强依赖重型
BasePage/BaseState
如果只从学习成本和开发效率来看,Signals 在 Flutter 项目里确实是一种很舒服的状态管理方式,特别适合中小型页面和强调局部刷新的场景。
当然,它也不是“银弹”。如果一个项目的状态流特别复杂、事件流特别多、多人协作对流程约束要求很强,那还是要根据实际业务来判断是否适合。
结束:
这篇文章就先写到这里。相比 Bloc,Signals 更轻、更直接,也更适合做细粒度响应式更新。但真正落到项目里时,重点并不只是把 Bloc 改成 signal,而是要重新思考:
- 状态应该怎么拆
- 页面应该怎么组合
- 网络请求怎么和状态更新配合
- 基础层怎么做到“高复用但不过度设计”
如果这些点都理顺了,Signals 在实际项目中会非常顺手。
技术这东西,本质上还是拿来沟通和解决问题的。文章里有理解不到位或者更好的实践方式,也欢迎一起交流。
声明:
仅开源供大家学习使用,禁止从事商业活动,如出现一切法律问题自行承担。
仅学习使用,如有侵权,造成影响,请联系删除,谢谢。