假设你接手一个新 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. 怎么证明这套架构真的能用?
我会把"可测试"当成验收条件,而不是一句口号:
- Repository 单测:远端成功、超时、错误响应、缓存命中或过期,各返回什么;本地与远端冲突谁赢。
- ViewModel 单测:加载、空、失败、重试;连续两次加载时旧结果不能覆盖新结果;销毁后不再更新 UI。
- Widget 测试:加载态、错误态、空态、商品展示和按钮反馈;Fake Repository 注入是否容易。
- 集成测试:从商品列表到购物车再到结算,检查路由、会话及核心用户流程;涉及原生权限或平台视图时补充设备级验证。
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。无论用哪种,购物车都属于会话级数据,旧请求不得覆盖新状态,登出必须清理。这些规则比包名更能说明架构能力。
最后准备一个自己真做过的例子:说清当时的业务约束、原方案出了什么问题、你改了哪个边界、用什么测试或线上指标确认。若没有对应项目经历,就把上面的购物车案例明确称为设计题示例。面试官通常能听出"我设计过"和"我亲手排过坑"的区别;坦诚区分,答案反而更稳。
从零搭架构的起点,是把一次用户操作和一次数据更新走通。等这条链有清楚的归属、生命周期和测试,目录与状态管理库才真正有了可评估的作用。