从零搭建一套 Flutter 架构:先跑通数据流

假设你接手一个新 Flutter 项目:商品列表要拉接口,点击"加入购物车"后,列表角标和结算页都要更新。最初把请求写进 initState()、把数量放进页面 setState(),两个页面很快就会各有一份购物车。再加登录、缓存、失败重试,页面开始同时负责展示、请求、存储和异常处理。面试官问"你会怎么搭架构",只回答"我用 Riverpod"显然说不清这些问题。

一句话结论:从零搭 Flutter 架构,先定数据的真实来源和一次操作的流向,再按 UI、数据源、依赖组装、路由与测试逐步落地;面试时沿一条真实业务链路说明职责、取舍和验证方式。

本文讨论的是业务工程的组织方式与数据流,不是 Widget、RenderObject、Engine 的底层实现。以 Flutter 3.47 官方应用架构文档 为参考,重点是可迁移的设计边界。目录、类名和代码片段是教学示意 ,不是 SDK 强制的工程模板,也没有在本机编译;具体库版本和接线 API 应以项目锁定版本为准。Flutter 应用架构指南

1. 开工前,先回答四个问题

架构不是先创建十几个文件夹。我会先与产品和后端确认四件事:

问题 购物车例子里的答案 它决定什么
哪份数据算真? 商品信息以服务端为准;本地购物车可即时展示,结算价格由服务端确认 Repository 的职责、缓存和同步规则
数据活多久? 商品列表随页面可刷新;购物车属于登录会话 对象的作用域与登出清理
失败时用户看到什么? 列表有加载、空、失败、重试;加购失败能回退或解释 UI State 和错误模型
哪些入口会到达页面? 首页、推送、商品深链;登录状态可能改变去向 路由、鉴权和恢复逻辑

这些答案会改变实现。一个离线可编辑的购物车,与一个必须在线确认库存的购物车,写入和冲突处理完全不同。这里先用"在线读取商品、加购后更新本地会话购物车、下单前服务端重新报价"的约束继续往下搭。

2. 先画一条来回的数据流,再选框架

Flutter 官方建议把应用至少分成 UI 层 和数据层 :View 展示状态并接收点击,ViewModel 把数据整理成页面状态,Repository 管理应用数据的真实来源,Service 对接 HTTP、数据库或平台插件。业务逻辑复杂且跨多个 Repository 时,再增加可选的 Domain / UseCase 层。它是一种推荐的职责划分,并不要求每个页面机械地凑齐所有类。Flutter 应用架构指南、架构建议

text 复制代码
用户点"加入购物车"
  → CartView 只转发事件
  → CartViewModel 调用 CartRepository.add(productId)
  → CartRepository 更新会话内的购物车,并在需要时调用 CartApiService
  → CartRepository 发出新购物车数据
  → CartViewModel 转成角标、按钮、错误等 UI 状态
  → CartView / 结算页各自重绘依赖的部分

这条链最重要的是"谁能修改购物车 "。如果列表页、详情页和结算页各自维护 cartCount,三个数字迟早不一致;Repository 持有或协调同一份会话数据,页面只读它产生的结果。用户事件向下走,数据更新向上走,就是单向数据流。官方也把"单一真实来源"和"单向数据流"列为核心原则。Flutter 架构概念

这里的 ViewModel 是职责名,不等于必须使用 ChangeNotifier。Provider、Riverpod、Bloc/Cubit 都能承载页面状态;go_router 解决路由,Repository 解决数据边界,它们不是同一类选择。我的建议是先把上图的箭头和对象寿命说清楚,再决定状态管理库。 若团队已有稳定且可测试的 Bloc 或 Riverpod 工程,迁移到另一套库通常没有比修正数据边界更优先。Flutter 官方依赖注入示例

3. 从空项目到第一个可交付页面,按什么顺序搭?

第一步:搭一个很薄的应用外壳

先只有 main / bootstrap、应用级依赖组装、路由、主题和第一个功能。下面是按功能聚合的目录示意,不是唯一标准:

text 复制代码
lib/
  main.dart
  app/
    app.dart                 MaterialApp、主题、路由入口
    bootstrap.dart           创建环境配置和应用级依赖
    router.dart              页面路径、深链、登录跳转
  core/
    network/                 HTTP 客户端、超时、鉴权接线
    error/                   错误分类与用户可见文案映射
  features/
    catalog/
      data/                  ProductApiService、ProductRepository 实现
      model/                 Product 等应用数据模型
      presentation/          ProductView、ProductViewModel、UI State
    cart/
      data/                  CartRepository、会话购物车
      presentation/          CartView、CartViewModel
  shared/                    真正被多个功能复用的 UI 组件
test/
  features/

目录按功能聚合,进入 catalog/ 就能找到它的页面与数据实现。core/ 只放跨功能的基础能力,shared/ 只放已经出现复用的东西;不要一开始就建空的 domain/、utils/、base/ 大仓库。若团队规模很小,也可以先按 UI / data 两层组织,后续再按功能迁移。目录是代码导航方式,依赖方向才是架构约束。

第二步:完成一个垂直切片

不要先把网络层、缓存层、状态管理、埋点、国际化全封装完。先让"打开商品列表"完整地穿过 Service、Repository、ViewModel 和 View,连同加载、空数据、错误、重试一起跑通。之后新增功能才有一条能照着走的路径。

下面用 Flutter SDK 的 ChangeNotifier 表达 ViewModel 边界,仅示意关键接口与异步保护 ;Product 模型、网络解析、错误类型和 Widget 接线按项目补全:

dart 复制代码
abstract interface class ProductRepository {
  Future<List<Product>> list();
}

class ProductViewModel extends ChangeNotifier {
  ProductViewModel(this._repository);

  final ProductRepository _repository;
  List<Product> products = const [];
  bool isLoading = false;
  String? errorMessage;
  int _requestId = 0;
  bool _disposed = false;

  Future<void> load() async {
    final requestId = ++_requestId;
    isLoading = true;
    errorMessage = null;
    notifyListeners();

    try {
      final result = await _repository.list();
      if (_disposed || requestId != _requestId) return;
      products = List.unmodifiable(result);
    } on Exception {
      if (_disposed || requestId != _requestId) return;
      errorMessage = '加载失败,请重试';
    } finally {
      if (!_disposed && requestId == _requestId) {
        isLoading = false;
        notifyListeners();
      }
    }
  }

  @override
  void dispose() {
    _disposed = true;
    _requestId++;
    super.dispose();
  }
}

View 触发 load(),Repository 返回商品,ViewModel 发布页面状态。_requestId 使较早发起、较晚返回的请求不能覆盖新请求;dispose() 后也不再通知监听者。它没有取消底层 HTTP 请求 ,只是阻止旧结果写回;若接口支持取消,仍应按页面生命周期处理。实际项目可以把三个字段收敛为不可变的 Loading / Success / Empty / Failure UI State,并让错误保留可诊断的原因,不要只剩一条中文字符串。Flutter 架构概念、Result 错误处理模式

第三步:在入口组装依赖,不让页面自己造网络客户端

ProductApiService 负责 HTTP 与响应解析;ProductRepository 决定缓存、刷新和错误转换;ProductViewModel 通过构造参数拿到 Repository。应用入口创建长寿命的 Service / Repository,进入页面时再创建页面级 ViewModel。测试时把 Repository 换成 Fake,页面逻辑就不必依赖真实网络。官方案例也以构造注入连接这些层,并用 provider 完成对象组装;这里选哪种注入容器不是重点。Flutter 依赖注入案例

先把对象寿命写成一张表,比讨论"全局单例好不好"更管用:

对象 典型寿命 销毁或重置点
HTTP 客户端、基础配置 App 进程或依赖容器 App 结束或容器关闭
登录会话、购物车 Repository 当前登录会话 登出、切换账号
商品列表 ViewModel 页面或路由作用域 页面退出、作用域销毁
搜索框输入与动画控制器 当前 Widget Widget dispose()

若把购物车 Repository 放在商品详情页下面,返回列表时状态就会丢;若把一次搜索的 ViewModel 放成全局单例,旧查询和监听又可能跨页面残留。这是架构面试里很好讲的具体取舍。

第四步:路由和横切能力只在需求出现时接入

只有简单页面跳转,用 Navigator 足够;有深链、Web 地址栏、嵌套路由或登录重定向时,再集中采用 go_router 等 Router 方案。路由表在 app/,功能页只接收参数与导航意图,不直接解析推送载荷。Flutter 文档也不建议把传统 named routes 作为多数项目的首选。Flutter 导航文档

网络、存储、日志、错误上报、环境配置应各有一个明确入口,但不要把所有请求都塞进一个 ApiManager。相机、定位等平台插件也可以由 Service 封装权限与生命周期,再交给 Repository 表达业务数据;页面不必直接操作平台通道。离线能力则要先定一致性规则:是"请求失败后读缓存",还是"本地先写、后台同步"?后一种必须处理同步失败和冲突,不能只在 Repository 里加一次本地写入就宣布支持离线。Flutter 应用架构指南、离线优先指南

4. 什么时候需要再加一层?什么时候该停?

"标准分层"经常走向两个极端:页面直接调 Dio、直接解析 JSON;或者每个按钮都配一个 UseCase、Repository 接口、实现、Mapper、DTO,五层文件只传递同一个参数。我更倾向让复杂度跟着业务增长:

症状 先做什么 何时再升级
ViewModel 里出现 HTTP、数据库代码 抽到 Service / Repository 立即;已经越过职责边界
多个 ViewModel 复制同一业务规则 把规则抽成共享操作 涉及多个 Repository 或规则足够复杂时,用 UseCase
多页需要同一购物车 共享会话级 Repository 明确登出、切账号的重置规则
单个页面逻辑很少 保持一个简洁 ViewModel 不为形式增加空转的 Domain 层
功能团队互相改同一批文件 按功能聚合目录、收紧公共 API 真有独立构建/发布需求时再考虑包或模块化

官方指南也把 Domain / UseCase 视为可选层 ,主要用于跨 Repository、复杂或可复用的业务逻辑。状态管理库可替换,但如果数据归属和失败规则没有写清,换库只会让同一个问题有另一套语法。Flutter 应用架构指南

5. 怎么证明这套架构真的能用?

我会把"可测试"当成验收条件,而不是一句口号:

  1. Repository 单测:远端成功、超时、错误响应、缓存命中或过期,各返回什么;本地与远端冲突谁赢。
  2. ViewModel 单测:加载、空、失败、重试;连续两次加载时旧结果不能覆盖新结果;销毁后不再更新 UI。
  3. Widget 测试:加载态、错误态、空态、商品展示和按钮反馈;Fake Repository 注入是否容易。
  4. 集成测试:从商品列表到购物车再到结算,检查路由、会话及核心用户流程;涉及原生权限或平台视图时补充设备级验证。

Flutter 官方建议以大量单元和 Widget 测试覆盖规则与界面,再用少量集成测试覆盖关键链路。integration_test 能测 App 流程,但不能直接操作系统权限弹窗等原生 UI;这种场景要另选合适的设备测试手段。Flutter 测试总览

CI 的第一批门禁可以很简单:格式检查、静态分析、单测和关键 Widget 测试。别让"架构工程化"变成先建设一整套自动化平台、却连加购失败都没有测试。先让第一条业务链有可重复的验证,再扩大覆盖面。

6. 面试时怎么梳理,怎么说?

面试回答不必从 MVVM、MVP、Clean Architecture 的定义背起。先讲约束 ,再画数据流 ,最后讲为什么这样分 和怎么验证。没有做过的离线同步、微前端或大型模块化不要说成自己的项目经验;可以明确说"如果需求升级到这一步,我会这样设计"。

60 秒回答

我会先确认数据来源、页面和会话的生命周期、失败策略以及有没有深链和离线需求。基础架构分 UI 和数据两层:View 只负责展示与事件,ViewModel 维护加载、成功、失败等页面状态;Repository 是业务数据的统一入口,Service 对接接口或本地存储。依赖在应用入口组装,页面只拿自己需要的 Repository。比如点击加购,事件经 ViewModel 到购物车 Repository,Repository 更新会话数据,再把新状态发回列表和结算页。状态管理我按团队现状选 Provider、Riverpod 或 Bloc,不让库决定数据归属。最后用 Repository、ViewModel、Widget 测试和一条核心集成链路验证;跨多个数据源的复杂规则出现后,才补 UseCase。

这段话的顺序是"约束 → 分层 → 一次事件 → 技术取舍 → 验证"。面试官如果追问任何一点,都能顺着实际代码或失败场景往下讲。

想讲到 3 分钟,就补三个追问

"为什么不能页面直接调用接口?" 因为缓存、重试、错误解释与会话状态会散落在页面里,多个页面还可能各自保存一份数据。Repository 把这些规则集中为一个入口,页面测试也能注入 Fake。这不是为了多一层,而是为了固定数据归属。

"为什么不用一上来就上 Clean Architecture 全套?" 简单 CRUD 页可能只有 View、ViewModel、Repository、Service 就够。真正出现跨 Repository 的结算规则、复杂校验或多页复用时,我再抽 UseCase。若项目有强合规、多人并行或多端共享领域规则等约束,提前严格分层才可能更划算。

"状态管理你选什么?" 我先看团队已有技术栈、异步与作用域复杂度,以及能否容易测试。小范围局部状态用 Flutter SDK 足够;跨页面共享和较多异步依赖可以评估 Provider / Riverpod / Bloc。无论用哪种,购物车都属于会话级数据,旧请求不得覆盖新状态,登出必须清理。这些规则比包名更能说明架构能力。

最后准备一个自己真做过的例子:说清当时的业务约束、原方案出了什么问题、你改了哪个边界、用什么测试或线上指标确认。若没有对应项目经历,就把上面的购物车案例明确称为设计题示例。面试官通常能听出"我设计过"和"我亲手排过坑"的区别;坦诚区分,答案反而更稳。

从零搭架构的起点,是把一次用户操作和一次数据更新走通。等这条链有清楚的归属、生命周期和测试,目录与状态管理库才真正有了可评估的作用。

相关推荐
程序员老刘18 小时前
Flutter版本选择指南:3.44稳定了11周正式转正 | 2026年10月
flutter·ai编程·客户端
chicken_h1 天前
【无标题】
flutter·打包
事圆则缓1 天前
Flutter 底层渲染引擎怎么变了:从 Skia 到 Impeller,以及迁移时的坑
flutter
码林鼠2 天前
flutter状态管理入门教程-Riverpod
开发语言·javascript·flutter
码林鼠2 天前
flutter网络http请求入门教程-dio
网络协议·flutter·http
码林鼠2 天前
flutter BLoC 单向数据流完整教程
flutter
恋猫de小郭2 天前
Flutter 官方表示:AI 时代代码越来越容易写,为什么还要一套代码库?
android·前端·flutter
喵个咪3 天前
GoWind ShortPlay 拆解:Flutter 沉浸式 Feed 的播放器工程——生命周期、手势状态机与游客降级
前端·flutter·架构