
去年年中我们团队对 Flutter 项目动了一场“大手术”把一个 30 多人同时提交代码的单体仓库拆成了一套完整的企业级模块化架构。拆完之后最直观的变化是六个业务模块可以由六个小组并行开发发版前一晚不再需要通宵解冲突线上出问题也能在几分钟内定位到具体模块。这篇文章不是教科书式的架构理论而是我们把方案推翻过三轮之后最终沉淀下来的详细设计与实现。我会从模块边界怎么划分讲起到模块内部的分层设计、模块间的通信机制再到 Melos 多包管理、Impeller 渲染引擎、part 文件拆分、Bloc 状态管理这些新特性的实际落点最后把我们在改造过程中踩过的最典型的坑一个一个拿出来说。不管你是刚准备把老项目拆成多模块还是已经在模块化路上遇到麻烦这篇文章都值得你花二十分钟看完。1. 模块怎么切先定边界再写代码1.1 按业务能力域切分不是按页面切分很多人第一次做 Flutter 模块化习惯性按页面去切module_login、module_home、module_mine。这种方案最大的问题是页面只是 UI 概念一个页面背后往往挂着完整的业务逻辑链条。比如登录页背后有账号体系、token 刷新、设备管理、风控校验如果登录被当成一个“页面模块”那它依赖的所有服务就会被反复复制到其他模块里最后还是逃不掉改一处崩三处的命运。我用的划分思路来自 DDD 里的“限界上下文”Bounded Context思想一个业务能力域拥有自己的领域模型、业务规则和数据访问方式对外只暴露协作接口内部完成闭环。拿电商 App 举例我们最终划分成六个业务模块和一个基础能力层模块核心职责user用户域账号、登录态、个人资料、地址簿product商品域商品详情、SKU、价格、库存cart购物车域加购、改购、商品勾选状态order订单域订单创建、订单列表、订单状态流转payment支付域收银台、支付渠道、支付结果同步after_sale售后域退换货申请、退款进度、售后原因这里有一个关键原则一个模块内部要能自解释“我这个业务域的完整故事”。以订单域为例它不只是订单列表和订单详情两个页面还包括订单状态枚举、订单金额计算规则、倒计时逻辑、订单数据的本地缓存。把这些东西都收进 order 模块之后商品域想改价格展示时根本不需要关心订单模块内部怎么计算金额两边只通过“接口 数据模型”协作。1.2 三级依赖壳工程、业务模块、Shared整个工程从依赖关系上分成三层App 壳工程只做一件事把模块组装起来。main() 里初始化依赖容器、组装路由、配置全局生命周期。壳工程不写任何业务代码。业务模块层features上一节提到的六个模块它们之间不允许相互依赖业务复用通过下沉接口或中台模块解决。Shared 基础能力层无业务语义的通用能力比如设计系统、网络封装、存储封装、通用工具、埋点 SDK。依赖方向是自上而下的壳工程依赖所有模块业务模块依赖 SharedShared 不依赖任何业务。这里最容易犯的错是让业务模块之间产生直接依赖比如 order 模块为了拿用户信息直接import package:user/user_models.dart。一开始看起来省事但 user 模块一旦改动模型order 模块就要跟着变。正确做法是把需要共享的数据模型放进 Shared 层或者在 order 模块里定义自己的领域模型通过接口从 user 模块获取数据并做一次适配转换。1.3 Shared 不是垃圾桶下沉要有判断标准我见过很多项目把公共代码越抽越乱最后 Shared 变成了一个包含几百个文件的“大泥球”。问题出在缺少下沉标准。我目前坚持三条判断标准一个人觉得不好使至少能保证团队讨论时有统一的依据没有业务语义。像网络请求、磁盘缓存、日期格式化这种属于纯技术能力可以下沉。被两个及以上模块复用。只被一个模块用的工具类就先留在那个模块内部不要急着下沉。接口相对稳定。如果这个能力还在频繁变动下沉到 Shared 会让所有业务模块跟着一起发版成本反而更高。有一个反例特别典型订单列表卡片和购物车里的商品卡片长得几乎一样设计师顺手复用了同一个 widget。有人提议把这个卡片类抽到 Shared我否了。因为“商品卡片”外观一样但它承载的点击行为、埋点参数、业务状态在订单和购物车两个域里完全不同。把这种有业务语义的组件抽到 Shared等于在基础层埋了依赖钩子后续两个业务模块的需求演化都会被这个“共用卡片”绑架。2. 模块内部怎么做分层与依赖倒置2.1 模块内部的三层结构有了模块边界之后如果模块内部还是一堆文件塞在一个文件夹里复杂度只是从“全局的大泥球”变成了“多个局部的小泥球”。所以每个业务模块内部我强制使用三层结构Presentation 层页面 widget、Bloc/Cubit、页面状态。Domain 层实体模型、仓储接口、业务用例。Data 层仓储实现、网络数据源、本地数据源。依赖方向是 presentation → domain ← data。也就是说presentation 依赖 domain 里定义的接口data 层实现 domain 里的接口但 presentation 不依赖 data。控制反转通过依赖注入容器来完成。因为 Dart 里没有 Java 那样的interface关键字我直接用abstract interface class来声明领域接口。2.2 Domain 层是模块的心脏但别造一堆空壳Domain 层是整个模块里最值钱的部分因为业务规则集中在这里。但这里我要泼一盆冷水不要为了追求“标准对称”给每个模块都铺上完整的 Use Case 层。我们第一版架构就是照着网上某篇文章把每一个操作都包成了一个 Use Case 类比如FetchProductDetailUseCase、ConfirmOrderUseCase。静态看很规范实际写业务时80% 的 Use Case 只有一个方法方法体里就一行调用仓储接口。项目真实跑起来之后代码量增加了 30%维护成本也上去了。现在我们的策略是简单场景直接让 Bloc 调仓储接口复杂场景才抽 Use Case。复杂场景的判断标准是这个操作涉及多个仓储协作或者有独立的、可复用的业务规则。比如“创建订单”要校验库存、计算金额、锁定优惠券这种逻辑就有独立抽取的价值。单函数的 Use Case能省则省。2.3 完整走一遍商品详情模块的分层实现我拿商品详情这个最小闭环来演示实际代码结构。先看目录packages/features/product/ ├── pubspec.yaml └── lib/ ├── product.dart # 模块汇总导出文件 ├── presentation/ │ ├── pages/ │ │ └── product_detail_page.dart │ └── bloc/ │ ├── product_detail_cubit.dart │ └── product_detail_state.dart ├── domain/ │ ├── entities/ │ │ └── product_detail.dart │ └── repositories/ │ └── product_repository.dart └── data/ ├── models/ │ └── product_detail_dto.dart └── repositories/ └── product_repository_impl.dartDomain 层只放接口和实体。实体是纯 Dart 对象不依赖 Flutter 框架// domain/entities/product_detail.dart class ProductDetail { const ProductDetail({ required this.id, required this.title, required this.priceInCents, required this.coverUrl, }); final String id; final String title; final int priceInCents; final String coverUrl; }仓储接口定义在 domain 里这是关键。它决定了 UI 层只跟抽象契约打交道// domain/repositories/product_repository.dart abstract interface class ProductRepository { FutureProductDetail fetchDetail(String productId); }Data 层实现这个接口。网络协议相关的东西全部封装在 Data 层内部presentation 完全无感知// data/repositories/product_repository_impl.dart class ProductRepositoryImpl implements ProductRepository { ProductRepositoryImpl(this._api); final ApiClient _api; override FutureProductDetail fetchDetail(String productId) async { final json await _api.getMapString, dynamic( /v1/products/$productId, ); final dto ProductDetailDto.fromJson(json); return dto.toEntity(); } }UI 层通过依赖注入拿到ProductRepository的具体实现但它只知道接口的契约class ProductDetailCubit extends CubitProductDetailState { ProductDetailCubit(this._repository) : super(const ProductDetailLoading()); final ProductRepository _repository; Futurevoid load(String productId) async { try { final detail await _repository.fetchDetail(productId); emit(ProductDetailLoaded(detail)); } on AppException catch (e) { emit(ProductDetailError(e.message)); } } }这种设计带来的直接好处是以后要把商品详情接口从 HTTP 换成 GraphQL或者加一层缓存只需要改 Data 层的实现Domain 和 Presentation 一层都不用动。对架构来说这种“把变化关在笼子里”的能力比任何花哨的框架都重要。2.4 状态管理统一用 Bloc但要选对粒度模块化之后最怕的就是“技术栈百花齐放”有人用 provider有人用 riverpod有人用 get。每个模块独立演进没问题但统一的架构基线是必须的。我最终选型是 flutter_bloc准确说大部分场景用 Cubit需要复杂事件转换时才切到完整 Bloc。理由有三个单向数据流UI 只是状态的投影调试时能回放事件序列。不依赖 BuildContext纯 Dart 类单元测试很好写。团队协作时心智负担低新成员看两个例子就能上手。Bloc 在模块内的组织方式我踩过一次很深的坑。刚开始为了让“架构更统一”我给整个订单域做了一个全局 OrderBloc所有页面共享一个大状态。结果就是页面越多状态里的字段越多事件也成倍增长一个事件触发后要同时更新七八个字段根本没法维护。后来把所有全局状态全部拆散推行“一个页面场景一个 Cubit/Bloc”只有真正跨页面共享的状态才提升到上一级。这才是 Bloc 的正确打开方式local state 留在页面shared state 才需要提升。3. 模块间怎么通信路由、服务、事件3.1 第一原则模块间禁止相互 import 业务实现模块化架构能不能存活取决于这一条原则执行得有多彻底。features 模块之间的直接 import短期看只是图上多一条线长期看是灾难。这条线会导致两个模块的编译时间相互叠加、发版节奏被迫绑在一起、重构时想改一个模块的内部实现却发现另一个模块在用它的 class。我们最终把模块间通信收口成三种方式路由跳转、服务接口调用、事件通知。下面分别说。3.2 路由解耦go_router 模块路由注册表页面跳转是传统单工程里最普遍的耦合来源。一个按钮Navigator.push(context, MaterialPageRoute(builder: (_) OrderDetailPage(orderId: id)))就直接 import 了 order 模块的页面类。在模块化架构里这个按钮所在的模块和 order 模块就产生了编译期依赖。我们把路由交给了 go_router每个业务模块自己声明子路由壳工程只负责把子路由组合进总路由表。以 product 模块为例// product/presentation/routes/product_routes.dart class ProductRoutes { static const String detail /products/:id; static ListGoRoute build() { return GoRoute[ GoRoute( path: detail, builder: (context, state) { final id state.pathParameters[id]!; return ProductDetailPage(productId: id); }, ), ]; } }壳工程的路由就一行行平铺final GoRouter appRouter GoRouter(routes: GoRoute[ ...ProductRoutes.build(), ...OrderRoutes.build(), ...UserRoutes.build(), ...CartRoutes.build(), ]);这样页面之间跳转只需要知道路径和参数context.push(ProductRoutes.detail, extra: productId);命令式跳转里的extra需要谨慎使用go_router 对extra的类型在编译期不做检查传错类型会在运行时报错。推荐的做法是优先把参数写在路径里复杂对象通过服务接口获取避免传整个序列化对象。3.3 服务契约get_it 容器 模块注册器路由只能解决页面跳转模块之间需要数据或操作时就要依赖“服务接口 服务定位器”。比如订单提交后要清空购物车这个操作属于 cart 模块order 模块不能直接 import cart 的内部实现。我们的做法是cart 模块对外暴露一个CartService接口orders 模块只依赖这个接口运行时通过 get_it 拿到实现。// cart/domain/services/cart_service.dart abstract interface class CartService { Futurevoid clearCheckedItems(); }每个模块提供一个Registrar负责向 get_it 注册自己的服务实现// cart/cart_registrar.dart class CartRegistrar { CartRegistrar(this._injector); final GetIt _injector; void register() { _injector ..registerLazySingletonCartService(() CartServiceImpl()) ..registerLazySingletonCartRepository( () CartRepositoryImpl(_injectorApiClient()), ); } }壳工程在 main() 里统一调用各模块的 Registrarvoid main() { WidgetsFlutterBinding.ensureInitialized(); final injector GetIt.instance; SharedRegistrar(injector).register(); UserRegistrar(injector).register(); ProductRegistrar(injector).register(); CartRegistrar(injector).register(); OrderRegistrar(injector).register(); runApp(const EcommerceApp()); }这里有一个决策要特别说明为什么选 get_it 而不是手动构造器注入在纯 Dart 层用一个轻量级依赖注入容器能省掉大量“打电话传递依赖”的样板代码而且 get_it 的注册时机在 main() 里集中管理调试起来非常直观。它的问题在于“隐式依赖”——你很难从构造函数一眼看出一个 class 依赖什么。所以我们在写业务代码时有个约定Bloc/Cubit 的依赖必须在构造函数里声明绝对不允许在成员方法里去调用 GetIt这样保留了可测试性。3.4 事件通知能用却要慎用的跨模块广播服务接口解决的是“我调你”的场景但有些场景是“你发生了某件事我要响应”比如登录态变化、购物车角标更新。服务接口解决不了这种双向通知就需要事件机制。很多项目直接引入 event_bus全项目发广播。这样做初期很爽但后期很痛苦一个CartChangedEvent发出来你不知道谁在监听代码可读性极差甚至出现事件风暴一次操作触发二十几个事件的循环连锁。我们的替代方案是收敛事件的使用面不设全局广播总线而是在 Shared 层提供类型安全的窄播 Stream。class SessionEvents { SessionEvents(this._controller); final StreamControllerSessionEvent _controller; StreamT ofTypeT extends SessionEvent() _controller.stream.where((e) e is T).castT(); void add(SessionEvent event) _controller.add(event); } class SessionLoggedIn implements SessionEvent { SessionLoggedIn(this.userId); final String userId; }具体模块按需依赖某个事件通道而不是依赖一个“万能总线”。这个改动让事件的可追踪性大大提升——每个 Stream 都有明确的业务命名监听方也集中在一个地方注册销毁。写代码时用 IDE 搜索“监听”的注册入口永远能找到唯一位置。4. 工程化基建网络、模型、并发与启动4.1 网络层做成黑盒Dio 别泄漏到业务代码模块化架构里Shared 层的网络封装是所有业务模块的地基。地基最重要的特征是稳定所以网络层对外接口不要直接暴露 Dio 或 http 的类型。我们封装了一个ApiClient统一处理 baseUrl 切换、token 注入、超时、重试和错误转换。业务模块只认识ApiClient和AppException这一组异常体系。abstract interface class ApiClient { FutureT getT(String path, {MapString, dynamic? query}); FutureT postT(String path, {Object? data}); FutureT putT(String path, {Object? data}); FutureT deleteT(String path, {MapString, dynamic? query}); }DioException 在 Shared 层内部被映射成领域异常这样业务层就不用关心底层网络库的细节try { await _api.get(/v1/users/me); } on DioException catch (e) { throw switch (e.type) { DioExceptionType.connectionTimeout || DioExceptionType.receiveTimeout const NetworkTimeoutException(), DioExceptionType.connectionError const NetworkException(), _ AppException.fromMessage(e.message ?? 请求失败), }; }4.2 模型解析与兼容性设计企业级项目一定会遇到线上版本的缓存数据兼容问题。我们用的方案是freezedjson_serializable模型定义集中在各模块的 Data 层。以 order 模块的订单模型为例freezed abstract class OrderDto with _$OrderDto { const factory OrderDto({ required String orderId, required int status, required int createTime, int version 1, }) _OrderDto; factory OrderDto.fromJson(MapString, dynamic json) { final version json[version] as int? ?? 1; if (version 2) { json migrateFromV1(json); } return _$OrderDtoFromJson(json); } }你一定要在模型层做这个版本迁移的兜底否则老版本缓存的 JSON 在新版本里会因为字段类型变化直接抛CastError。这个坑我们线上真实遇过后续在 4.3 节细说。4.3 Isolate 与多线程的使用边界Dart 单线程事件循环的模型决定了 CPU 密集任务会卡 UI但多线程不是银弹。我们总结出三类真正需要开 Isolate 的场景大 JSON 解析比如列表接口返回几 MB 数据图片压缩、缩放加密解密计算现在的 Dart 提供了Isolate.run比老式compute更灵活。以图片压缩为例FutureUint8List compressImage(Uint8List input, int quality) { return Isolate.run(() _doCompress(input, quality)); } Uint8List _doCompress(Uint8List input, int quality) { // 使用 image 包进行压缩 final decoded img.decodeImage(input)!; final resized img.copyResize(decoded, width: 1280); return Uint8List.fromList(img.encodeJpg(resized, quality: quality)); }在模块化架构里这些耗时逻辑一律封装在 Data 层上层只看到一个FutureUint8List。等将来需要把压缩算法替换成原生实现只改 Data 层一个文件就行这就是分层带来的重构自由。4.4 启动流程解耦与原生启动图模块化改造前App 启动时所有模块的初始化代码都堆在 main() 里数据库建表、埋点 SDK、消息推送、各种 Bloc 预热。首帧被拖到两秒多体验很差。改造后我们坚持“按需初始化”壳工程只注册基础设施ApiClient、本地存储、异常上报业务模块的初始化逻辑放进各自模块的initialize()方法只有用户真正进入该业务场景时才触发。原生启动图这块很多 Flutter 项目只配了一张 LaunchImage 就完事结果启动时从原生启动图切换到 Flutter 首帧之间出现闪白。Android 12 要求使用 SplashScreen APIiOS 侧要保证 Assets 里的 LaunchScreen 配置正确同时 Flutter 引擎在FlutterEngineGroup里做预热。这些细节和模块化没有直接关系但会影响整体架构的“企业级”完成度——用户不会区分是原生慢还是 Flutter 慢他只会觉得 App 启动慢。5. 依赖治理我用 Melos 管理多包仓库5.1 单包多文件夹 vs Melos 多包为什么选后者模块化有两种落地形态一种是在同一个 Flutter 工程里用文件夹做逻辑隔离另一种是用 Melos/FVM 组织多 package 仓库。我们对比过之后选了后者因为真正的模块化必须做到编译隔离和依赖强校验。下面是两种方案的对比维度单包多文件夹Melos 多包编译隔离弱一个文件改动全工程编译强只编译受影响的包import 边界靠 Code Review 人工保证靠包依赖声明自动约束版本管理所有代码同版本发布各包可独立发版团队协作容易产生大量 merge 冲突按包拆分 Git 仓库或子目录上手成本低需要学习 Melos 命令和配置如果你的团队在 20 人以下、业务线单一单包加严格目录纪律也能活下去。但超过 30 人之后单包模式的编译时间会拖垮所有人的效率。我们切到 Melos 之后单个业务模块的增量编译时间从原来的三分钟降到一分钟以内这种体感变化是团队能坚持推进改造的核心动力。5.2 Melos 的配置与常用命令现在的 Melos 已经支持 pub 官方的 workspace 能力。根目录的 pubspec.yaml 声明 workspace# 根 pubspec.yaml name: ecommerce_app resolution: workspace environment: sdk: ^3.6.0 workspace: - packages/features/* - packages/shared/*melos.yaml 集中配置脚本和额外命令name: ecommerce workspace: - packages/features/* - packages/shared/* scripts: analyze: dart analyze --fatal-infos test: melos run analyze melos exec flutter test check_deps: dart run tool/check_dependencies.dart常用命令就三个melos bootstrap安装所有包的依赖并生成本地连接的 packagemelos run analyze跑全仓静态检查melos exec在匹配的包内批量执行命令。我们 CI 的核心检查就是melos run analyze加一条自定义的依赖检查脚本。5.3 本地开发与线上发布的依赖切换多包开发时有个绕不开的问题开发期希望依赖走本地 path发布时希望依赖走 pub.dev 的版本号。我们约定开发分支统一使用 workspace 的 path 依赖release 分支用 CI 脚本把所有 path 依赖替换成hosted版本号并完成pub get。这个替换脚本不复杂核心是解析每个包子目录的 pubspec.yaml把path: ../开头的依赖改成^x.y.z。彻底切到 workspace 之后pub get的行为也有变化首次 bootstrap 后生成的.dart_tool/package_config.json必须提交到 Git否则 CI 上其他成员拉下来会重新解析过慢。这是个小坑但第一次踩到会觉得莫名其妙。6. 最新 Flutter 特性在架构里的落点6.1 Impeller 渲染引擎默认开启后的收益与适配Flutter 从 3.x 开始在 iOS 上默认启用 Impeller 渲染引擎Android 这边也在逐步推进。Impeller 解决了几年来 Skia 引擎在复杂页面上的着色器编译 jank 问题。对架构设计来说Impeller 是透明的——我们不需要为它调整模块划分或代码结构但要做三件事在低端 Android 真机上跑一轮图形回归测试因为 Impeller 在 Vulkan 不可用时会有 OpenGL fallback部分自定义 shader 表现不一致。关注图片解码和模糊效果的渲染结果Impeller 对部分图片滤镜的支持和 Skia 有差异。灰度发布时关注首帧耗时Impeller 的预编译策略使得首帧稳定性更好但极端机型上需要回退开关。6.2 part 与 part of模块内部拆文件的正确方式Dart 的part指令在 Flutter 社区里争议很大因为它能访问主库的私有成员很容易被滥用成“把一堆文件缝成一个巨型类”的工具。但在企业级模块化架构里它有一个合理的应用场景当某个大型类的内部逻辑需要拆文件又不想暴露公开 API 时。举一个真实例子登录表单的 Bloc 文件里有一堆校验方法包括邮箱校验、手机号校验、密码强度校验这些方法只服务这个 Bloc不需要被外部调用。我们就可以用 part 拆出来// sign_in_form_bloc.dart part validators.dart; class SignInFormCubit extends CubitSignInFormState { bool _isEmailValid(String email) _validateEmail(email); } // validators.dart part of sign_in_form_bloc.dart; bool _validateEmail(String email) { return RegExp(r^[^][^]\.[^]$).hasMatch(email); }注意新版 Dart 对part of的写法有要求推荐带 URI 的形式part of sign_in_form_bloc.dart;不推荐旧式库名写法。我这里再强调一次优先用普通 import 拆分公共逻辑part 只是兜底方案。如果你发现项目里 part 文件比普通文件还多那一定是用反了。6.3 平台通道封装MethodChannel 别散落在页面企业级 App 逃不开调用原生能力比如扫描二维码、读取设备信息。热词里提到的“调用 Java 组件”就是这个场景。我们在架构上做了一个约束MethodChannel 的通道名和调用逻辑集中封装在 Shared 层的原生服务适配器里业务模块只依赖纯 Dart 接口。class QrCodeScanner { static const MethodChannel _channel MethodChannel(app/qr_code); FutureString? scan() async { return _channel.invokeMethodString(scan); } }这样设计的好处是原生侧通道名统一管理不用每个页面都去写MethodChannel(app/qr_code)后面如果要从 Android 原生扫描切换到第三方 SDK只改适配器内部代码即可。6.4 Gradle 配置旧式命令式 apply 的坑Flutter 新版本对 Android 构建配置的推荐写法是使用 plugins DSL而不是命令式 apply。如果你看到类似You are applying Flutters main Gradle plugin imperatively using the apply script method的告警说明 build.gradle 里还在用老式写法// 不推荐 apply plugin: com.android.application apply plugin: dev.flutter.flutter-gradle-plugin推荐改成plugins { id(com.android.application) id(dev.flutter.flutter-gradle-plugin) id(org.jetbrains.kotlin.android) }注意 plugins DSL 必须在文件顶部声明而且不能和 apply 混用。这个坑在我们重构时反复出现因为团队成员用的是不同版本的模板生成的工程文件合并时很容易把两套写法堆在同一个文件里。建议统一项目模板并在 CI 里跑一条 gradle 告警检查。7. 踩坑记录模块化改造中遇到的典型问题7.1 循环依赖最让人崩溃的编译错误模块化早期我们出现过一次 order 模块依赖 user 模块的 Address 模型user 模块的一个顺风车功能又依赖 order 模块的状态枚举形成了 A → B → A 的循环。Flutter 编译到循环依赖时报错信息非常不直观有时候是cannot read ... because ...有时候是模棱两可的 abstract class 冲突。排查思路是先用flutter analyze列出一个模块的全部 import 清单再一条一条标出哪些 import 指向了其他业务模块。发现自己模块依赖了对端模块时对端模块里很可能也藏着对你的依赖。解决办法是把共享的模型下沉到 Shared 层或新建一个domain_models中台包。循环依赖最好的解决办法不是“解”而是“让循环不成立”——共享模型永远放在依赖链更底层的地方。7.2 热重载变慢增量编译反而恶化模块化后有一段时间我们遇到了一个诡异的现象改动 page 一个样式热重载要等十几秒。定位后发现原因不在 Flutter而在于壳工程把所有模块都通过 path 依赖挂在一起每次改动会触发整个依赖图的重建。后来我们做了两件事一是确保业务模块之间没有无谓依赖二是 Shared 层拆得更细把 design_system 和 network 分开避免 UI 改动触发网络层编译。如果项目里有些包很重但改动频繁可以考虑用melos exec --scope单独开发某个包而不是每次都从壳工程编译。真正的多包隔离应该做到“改哪个包只编译哪个包”这一条做到了开发效率才有质的提升。7.3 模型字段变更引发的线上兼容事故印象最深的一次线上事故和订单模型有关。v2.3 版本把orderStatus字段改名成了status发布之后大量老用户启动即崩溃。崩溃日志指向OrderDto.fromJson因为老版本缓存里存的是旧字段名json[status]取出来是 null而空安全要求 int 类型不能为 null直接抛错。事后我们复盘加了三条规矩模型类必须有version字段fromJson入口做迁移判断。发布前跑一遍“老包缓存 新包启动”的兼容性用例。字段改名不是单纯的代码重构属于接口变更必须走兼容评审。这套规矩现在已经成为所有业务模块的硬性要求。7.4 弱网下的 SocketException不是业务错误热词里的flutter socketexception是个真实痛点。弱网环境下请求失败会抛 SocketException如果 UI 层没兜底用户看到的是红屏或者白屏体验极差。我们后来把网络异常统一归类SocketException 映射成NetworkException并在 UI 层统一展示重试页on NetworkException { emit(ProductDetailError(网络开小差了请检查网络后重试)); }这里有两个细节值得注意一是重试逻辑要带退避策略普通接口重试一次即可频繁重试会加重弱网下的服务器压力二是 SocketException 不一定是“网络不可用”也可能是 DNS 解析失败或代理问题所以错误提示文案别写死“网络不可用”用“网络异常请稍后重试”更安全。7.5 import 边界靠 Code Review 守不住必须上 CI模块化架构里最容易烂掉的就是 import 边界。即使团队约定了 features 之间不准 import紧张的项目排期下总会有人“临时先这样写一下”然后这条违规依赖就沉淀下来直到变成一个无法拆除的大坑。我们用一条自定义脚本在 CI 里强制检查扫描所有 features 目录下的代码凡是出现import package:other_feature_name/...的就直接 fail pipeline。melos run check_deps这条规则是模块化长期稳定的发动机。没有它架构图画得再漂亮半年后就变成了一张废纸。8. 落地节奏与最终体会8.1 演进式落地不搞大爆炸重构我们最开始的方案是想用三个月把整个工程重写一遍后来发现风险太大改成了演进式迁移先搭好 Shared 层和壳工程的骨架然后新需求一律按新模块写老功能按业务域边界逐模块搬迁。每迁完一个模块就跑一轮核心链路回归。三个月的硬切换硬生生被拉成了九个月的平滑过渡但线上几乎没出过大事故团队节奏也稳住了。8.2 每个模块配一个独立入口为了让开发者不依赖壳工程就能开发单个模块我们在每个 feature 包里都放了一个 dev main 文件packages/features/product/ └── example/ └── lib/ └── main_product_dev.dart开发商品模块时直接跑flutter run -t packages/features/product/example/lib/main_product_dev.dart这个入口只注册 Product 模块的依赖和路由不初始化其他业务模块。好处是首帧启动飞快调试时不用被其他模块的启动逻辑干扰。代价是要维护多一个入口的依赖注册但这笔成本换来的开发效率提升非常值得。8.3 架构的生命力来自团队共识和持续纠偏最后分享一点个人体会。架构方案写出来不难难的是让整个团队在三个月、半年后还愿意遵守它。我们在每次迭代里安排了架构守护者角色轮值检查代码里的依赖违规、状态管理误用和 Shared 层膨胀。慢慢你会发现架构不是一次设计出来的而是持续演进出来的。模块化的核心收益是把“变化限制在模块内部”这句话说出口很容易做出来要靠每一条规则、每一个 CI 检查和每一次 Code Review 来守护。如果这篇分享对你有帮助可以照着这个方案从你项目里最小的一个业务模块开始试点跑通之后再逐步推广。架构没有标准答案但长期主义的方向是确定的让代码的依赖关系比团队规模更清晰让每一次业务变化都有明确的落点。