Flutter Signals 学习与项目实践

16 阅读11分钟

Flutter Signals 学习与项目实践

前言:
  • 最近抽了一些下班后的时间,把项目里原有的 Bloc/Cubit 状态管理完整迁到了 Signals。一开始学习 Signals 的时候,也看了不少文章和示例,很多内容都在讲 signalcomputedeffect 这些基础概念,但真正结合项目页面、网络请求、分页刷新、局部更新来讲的并不算多。
  • 所以这篇文章就结合当前这个 Demo,聊一聊 Signals 在 Flutter 项目中的实际使用方式、设计思路和常见问题。项目中也顺手对网络请求、页面状态、通用壳子、列表刷新做了一轮收敛,现在整体已经不只是“把 Bloc 改个名字”,而是逐步形成了一套更轻的 Signals 项目写法。
  • 本篇文章更偏实战一些,重点讲的是 Signals 在项目中的落地,而不是单独讲 API。老规矩,先从整体思路开始。
正文:

当前项目中的 Signals 使用,主要从下面几个维度来理解:

  1. Signals 的基本思想
  2. Signals 在页面中的完整使用流程
  3. Signals 如何做颗粒化刷新和局部刷新
  4. Signals 如何组织网络请求和页面状态
  5. 项目中基础层、路由和通用组件的搭配思路
  • Signals 基本思想

Signals 的核心目标很简单,就是让“状态”和“依赖这个状态的界面”建立更直接的响应关系。

在传统的 Bloc 模式里,我们通常是:

  • Event 触发操作
  • Bloc 处理逻辑
  • emit(state) 更新状态
  • BlocBuilder 根据新 state 重建页面

而在 Signals 里,思路会更直接:

  • 页面或 store 改变某个 signal
  • 依赖这个 signalWatch 自动更新

也就是说,Signals 更像是把“状态变更”和“界面依赖”做成了天然绑定,不需要再专门经过 Event -> emit -> rebuild 这条链路。

这套思路最大的好处就是:

  • 写法更轻

  • 状态拆分更自然

  • 局部刷新更容易实现

  • 不必为了一个小状态变动去维护一整个 State 对象

  • Signals 的核心概念:
  • signal 最基础的响应式状态。它持有一个值,当值发生变化时,依赖它的地方会自动更新。

  • computed 派生状态。它不直接存数据,而是基于其他 signal 计算得到,适合做按钮是否可点击、列表是否为空、是否显示某段 UI 这种逻辑。

  • effect 副作用监听。适合做日志、埋点、导航、联动处理等。项目里我更建议把大多数业务逻辑放到 store 里,effect 只做少量副作用处理。

  • Watch signals_flutter 提供的监听组件。Watch 内部只要读取了某个 signal,当这个 signal 改变时,对应 builder 就会自动刷新。

  • Store 项目里我比较推荐用 feature store 的思路,也就是每个页面或者每条业务链路一个 store,把该页面需要的 signal、请求逻辑、分页逻辑、副作用逻辑都收在一起。

  • 使用 Signals 开发一个页面完整流程

下面以“网络列表页”为例,说明一个页面从 0 到 1 的常见写法。

  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;
  }
}
  1. 在页面中持有 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 里,我们常常需要借助:

  • buildWhen
  • listenWhen
  • 多个 BlocBuilder
  • 甚至一个页面拆两个 Bloc

来控制“哪里该刷新,哪里不该刷新”。

Signals 下,局部刷新会自然很多。

比如详情页里有两种变化:

  1. 页面初次进入,请求主数据、推荐数据、系列数据
  2. 页面滚动时,只让导航栏透明度变化

这时候最推荐的写法不是“一个大对象全塞进去”,而是把状态拆开:

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);

然后页面里:

  • 内容区域依赖 detailStatemainModelseriesListrecommendList
  • 导航栏区域只依赖 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 在项目中的几个实践建议
  1. 一个页面一个 store,但不要一个页面只放一颗总 signal
  2. 能拆分的状态尽量拆分,不要迷信“大而全”的 State
  3. computed 用来做派生状态,别把简单推导也写成手动更新
  4. 页面少写业务逻辑,把请求、分页、异常处理放进 store
  5. Watch 不要无脑包根节点,优先包真正会变化的区域
  6. 网络层尽量统一返回结果,不要在回调里四处修改 UI
  • 当前项目中的 Signals 架构总结

经过这一轮整理,项目里的核心链路基本已经形成了比较稳定的 Signals 写法:

  • 登录页使用本地 store + computed
  • Tab 使用轻量 store
  • 列表页使用 netState + items + page + isNoMoreData
  • 详情页按业务拆成多颗 signal
  • 页面通过 PageScaffold + PageStateView + Watch 组合
  • 网络层统一请求和错误处理
  • 基础层不再强依赖重型 BasePage / BaseState

如果只从学习成本和开发效率来看,Signals 在 Flutter 项目里确实是一种很舒服的状态管理方式,特别适合中小型页面和强调局部刷新的场景。

当然,它也不是“银弹”。如果一个项目的状态流特别复杂、事件流特别多、多人协作对流程约束要求很强,那还是要根据实际业务来判断是否适合。

结束:

这篇文章就先写到这里。相比 BlocSignals 更轻、更直接,也更适合做细粒度响应式更新。但真正落到项目里时,重点并不只是把 Bloc 改成 signal,而是要重新思考:

  • 状态应该怎么拆
  • 页面应该怎么组合
  • 网络请求怎么和状态更新配合
  • 基础层怎么做到“高复用但不过度设计”

如果这些点都理顺了,Signals 在实际项目中会非常顺手。

技术这东西,本质上还是拿来沟通和解决问题的。文章里有理解不到位或者更好的实践方式,也欢迎一起交流。

声明:

仅开源供大家学习使用,禁止从事商业活动,如出现一切法律问题自行承担。

仅学习使用,如有侵权,造成影响,请联系删除,谢谢。

Demo下载地址 Demo