Flutter使得构建用户界面变得极为迅速。这种高效性正是该框架最大的优势之一,但同时也带来了一个潜在问题:应用程序的发展速度往往远快于其架构设计的调整速度。
最初只有几个屏幕的应用,很快就会发展成包含数十个屏幕的系统。那些原本相互独立的功能也会开始产生交互作用:认证机制会影响导航流程,通知系统会影响到新用户的入门体验,某些功能开关的设置还会改变业务流程,本地数据存储机制则会引起同步方面的问题,而应用程序中各个不相关部分之间的状态信息也可能会开始出现混乱。
所有这些变化都不是突然发生的。
大多数使用Flutter开发的代码库都会逐渐变得复杂起来。那些起初看起来无害的小技巧和简化措施会不断累积,最终导致修改某个功能时就需要理解整个应用程序的半数逻辑。
通常在这种情况下,开发团队才会开始被动地采用一些架构模式来解决问题。不幸的是,许多开发者在没有弄清楚造成系统复杂性的真正原因之前,就试图通过添加抽象层来解决扩展性问题。
大型应用程序失败的原因往往并不是因为缺乏某种架构模式,而是因为职责边界变得模糊不清。
本文提出了一种实用的方法,用于构建大型Flutter应用程序,以确保随着代码库的发展,复杂性依然能够被清晰地识别和管理。这里关注的重点不是理论上的纯粹性,而是在实际生产环境中确保代码的长期可维护性。
目录
先决条件
本指南假定读者已经熟悉Flutter组件、使用`Future`和`async/await`进行异步编程,以及诸如Provider、Riverpod或BLoC之类的基本状态管理机制。
同时,读者也应该已经具备构建复杂应用程序的能力,而不仅仅局限于编写简单的演示程序。本文的重点并不在于 Flutter的基础知识,而是那些在应用程序发展成由多名开发者长期维护的大型系统后才会出现的架构决策问题。
是什么导致Flutter应用程序难以扩展
大型应用程序之所以难以扩展,通常并非仅仅因为用户界面过于复杂。大多数扩展问题实际上源于各组件之间的协调难度。
一个简单的登录流程就能很好地说明这一点。最初,认证过程可能仅涉及发送凭证、接收令牌以及跳转到主屏幕而已。
然而,生产环境中的系统会迅速发展变化。最终,认证功能会承担以下职责:
-
恢复用户会话
-
更新过期的令牌
-
预加载用户数据
-
触发数据分析
-
处理新用户的初始设置流程
-
同步本地缓存数据
-
应用不同的功能配置选项
-
支持深度链接功能
虽然用户界面看起来仍然很简单,但实际上背后的协调逻辑已经变得错综复杂、相互关联。
如果没有明确的架构边界,这种复杂性就会蔓延到系统的各个部分:
-
组件
-
数据存储模块
-
路由管理机制
-
拦截器
全局服务
状态管理容器
到了这种地步,即使是很小的修改也会带来风险,因为那些无关的系统会开始共享相同的生命周期处理逻辑。
这就是Flutter应用程序中最重要的架构规律之一:系统的复杂性是通过各个组件之间的交互来体现的,而不是通过屏幕的数量来决定的。
为什么小型架构会失败
许多Flutter应用程序最初都是按照这样的结构来设计的:
lib/
screens/
widgets/
services/
providers/
models/
对于小型应用程序来说,这种结构确实非常适用。但一旦功能变得更多、相互之间的依赖关系也更加复杂,问题就会出现了。
以实现“收藏”功能为例:这个功能的代码可能位于screens/目录中,状态管理逻辑在providers/目录里,网络请求处理代码在services/目录中,而数据模型则存储在models/目录里。
这样一来,一个单一的业务功能就会贯穿整个项目结构。
这就引发了一个隐蔽但重要的问题:应用程序的结构不再能够真实反映产品的实际构成。
开发人员不再从功能的角度来思考问题,而是开始按照技术类别来进行分类。
随着时间的推移,各模块的归属关系会变得模糊不清,依赖关系也会变得隐含起来,无关的功能之间还会产生耦合现象,调试代码时也常常需要在不同目录之间来回切换。
这种架构最终会倾向于优化文件的组织结构,而不是提升系统的整体性能。
这种区别其实比我们最初想象的要重要得多。
大型系统之所以能够顺利运行,正是因为各模块的归属关系清晰明确。一旦这些边界变得模糊,维护成本就会急剧上升。
按功能进行组织
减少架构碎片化的最有效方法,就是根据业务功能来组织应用程序的结构,而不是按照技术层次来进行划分。
一个功能应该包含其运行所需的所有要素:
-
呈现逻辑
-
业务逻辑
-
状态信息
-
数据持久化机制
-
测试代码
例如:
lib/
features/
authentication/
presentation/
domain/
data/
随着功能的不断发展,其结构也可以自然地扩展:
features/
authentication/
presentation/
pages/
widgets/
state/
domain/
entities/
usecases/
repositories/
data/
models/
repositories/
sources/
现在,认证系统作为一个完整的模块存在,而不再分散在代码的各个部分中。
这种设计大大提高了代码的可维护性——当开发者修改认证功能的实现时,他们能够立刻知道状态信息存储在哪里、业务规则定义在何处、数据持久化机制是如何实现的,以及测试代码应该放在哪里。
当多名开发者同时开发不同的功能时,这种清晰的职责划分尤为重要。明确的权限边界可以有效避免代码之间的不必要的耦合,从而使并行开发变得更加安全。
呈现层会根据状态的变化来更新用户界面:
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context) {
return BlocConsumer<LoginCubit, LoginState>>(
listener: (context, state) {
if (state.isSuccess) {
context.go('/home');
}
},
builder: (context, state) {
return LoginView(
isLoading: stateisLoading,
onSubmit: (email, password) {
context.read<LoginCubit>>().login(
email,
password,
);
},
);
},
);
}
}
这里的关键并不在于BLoC这种设计模式本身,而在于职责的明确划分。
UI组件负责渲染用户界面并接收用户的操作指令,但它并不直接参与基础设施相关的逻辑处理。
这些复杂的协调工作是由其他部分来完成的:
class LoginCubit extends Cubit<LoginState>> {
final LoginUseCase loginUseCase;
LoginCubit(this.loginUseCase)
: super(const LoginState.initial());
Future<void>> login(
String email,
String password,
) async {
emit(state.loading());
final result = await loginUseCase(
email,
password,
);
result.fold(
(failure) => emit(
state.failure(failure.message),
),
(_) => emit(
state.success(),
),
);
}
}
这种职责划分能够防止UI代码逐渐演变成一个充满副作用的复杂协调层。
分离呈现逻辑、业务逻辑和数据
在大型Flutter应用程序中,最重要的架构原则之一就是将呈现逻辑、业务逻辑以及基础设施相关的功能明确区分开来。
这些组成部分的发展速度各不相同:用户界面会不断变化,而业务规则的变化则较为缓慢,基础设施的改动更是难以预测。
如果不对这些部分进行分离,基础设施相关的问题就会逐渐渗透到展示代码中,导致组件与API、数据库、缓存机制以及重试逻辑等紧密地绑定在一起。
ElevatedButton(
onPressed: () async {
final response = await dio.post(
'/login',
data: {
'email': email,
'password': password,
},
);
if (response.statusCode == 200) {
Navigator.pushNamed(
context,
'/home',
);
}
},
)
乍看之下,这种设计似乎并无不妥,但实际上它将网络请求、导航逻辑、副作用处理以及组件的生命周期管理紧密地结合在了一起。
ElevatedButton(
onPressed: () {
context.read().login(
email,
password,
);
},
)
实际上,这些功能应该由应用程序层来负责协调和管理。
abstract class AuthenticationRepository {
Future login(
String email,
String password,
);
}
class LoginUseCase {
final AuthenticationRepository repository;
LoginUseCase(this.repository);
Future call(
String email,
String password,
) {
return repository.login(
email,
password,
);
}
}
class AuthenticationApi {
final Dio dio;
AuthenticationApi(this.dio);
Future login(
String email,
String password,
) async {
final response = await dio.post(
'/login',
data: {
'email': email,
'password': password,
},
);
return UserDto.fromJson(
response.data,
);
}
}
class AuthenticationRepositoryImpl
implements AuthenticationRepository {
final AuthenticationApi api;
AuthenticationRepositoryImpl(this.api);
@override
Future login(
String email,
String password,
) async {
final dto = await api.login(
email,
password,
);
return dto.toDomain();
}
}
Use cases coordinate business behavior independently from infrastructure details:
use case UserLogin {
final AuthenticationRepository authenticationRepository;
UserLogin(this.authenticationRepository);
Future login(String email, String password) {
return authenticationRepository.login(email, password);
}
}
这种分离机制非常重要,因为业务规则不应该直接依赖于HTTP客户端、数据库或序列化细节。
class AuthenticationApi {
final Dio dio;
AuthenticationApi(this.dio);
Future login(
String email,
String password,
) async {
final response = await dio.post(
'/login',
data: {
'email': email,
'password': password,
},
);
return UserDto.fromJson(
response.data,
);
}
}
Repository implementations coordinate infrastructure concerns while keeping those details isolated from the rest of the system:
class AuthenticationRepositoryImpl
implements AuthenticationRepository {
final AuthenticationApi api;
AuthenticationRepositoryImpl(this.api);
@override
Future login(
String email,
String password,
) async {
final dto = await api.login(
email,
password,
);
return dto.toDomain();
}
}
这种架构虽然增加了系统的结构层次,但同时也明确了各部分的责任边界,使得系统能够更安全地发展。此外,实现细节被封装在接口之后,这样的设计有助于进行测试和依赖注入操作。
状态边界与状态管理
大多数关于Flutter状态管理的讨论都集中在各种库的使用上。
但实际上,系统扩展时遇到的问题往往源于责任边界的划分,而非工具本身的限制。
真正棘手的问题并不在于“我们应该使用Riverpod还是BLoC”,而在于“谁应该负责管理这个状态?这个状态应该存在多久?哪些功能会依赖于它?系统的重建边界又该如何确定?”
许多应用程序最终都会积累大量的全局状态数据:
class AppBloc extends Bloc {
// 认证
// 用户资料
// 通知
// 设置
// 分析数据
}
起初,这种设计看起来很方便,因为所有数据都可以被全局访问。
但随着时间的推移,那些没有直接关联的功能会开始共享相同的生命周期逻辑。由于状态数据的共享,各个功能之间会变得紧密耦合,系统的重建流程也会变得更加复杂,调试状态变化也变得越来越耗时。
因此,我们应该优先采用按功能来划分责任边界的设计:
features/
profile/
state/
checkout/
state/
notifications/
state/
每个功能都应该负责管理自己的生命周期和状态变化。
例如:
class CartCubit extends Cubit {
CartCubit()
: super(const CartState.empty());
void addProduct(Product product) {
emit(state.copyWith(products: [
...state.products,
product,
],
);
}
}
这种设计能够显著减少隐藏的耦合关系。
其他功能应该通过事件、抽象层或用例来进行交互,而不是直接修改共享的状态数据。
全局状态的数据范围应该仅限于那些确实具有全局意义、且需要被多个功能共同使用的场景。例如:
-
认证
-
本地化设置
-
主题样式
-
应用程序会话状态
其他所有数据都应该尽可能地被限制在特定的作用域内。
大规模导航系统设计
导航系统的复杂性增长速度往往超出了大多数团队的预期。
起初,路由操作看起来很简单:推送一个界面、弹出另一个界面,或者为某些路径设置访问权限即可。
但在实际的生产环境中,导航系统还会面临以下挑战:
-
新用户引导流程
-
深度链接
-
嵌套导航结构
-
认证机制
-
模态窗口的协调使用
-
多个导航入口点
状态恢复逻辑
导航逻辑应当与业务逻辑保持分离,因为随着应用程序的发展,开发者需要专注于业务逻辑的实现。将导航逻辑与业务逻辑解耦是架构设计中的重要最佳实践。
存储层绝对不应该了解路由相关的信息:
class AuthenticationRepository {
Future
Navigator.pushNamed(
context,
'/home',
);
}
}
这种代码会导致基础设施层与展示层之间产生耦合。
sealed class LoginResult {}
class LoginSuccess extends LoginResult {}
class LoginFailure extends LoginResult {
final String message;
LoginFailure(this.message);
}
展示层应该根据这些结果来做出相应的反应。
BlocListener
listener: (context, state) {
if (state.isSuccess) {
context.go('/home');
}
},
child: const LoginView(),
)
这样,路由决策就被保留在展示层这个它所属的地方。
这样做还能简化测试、调试工作,并明确各部分代码的负责范围。
管理共享代码
大型应用程序不可避免地会产生大量共享代码。
危险在于,像shared/、common/或core/这样的文件夹很容易被用来存放那些彼此无关的代码。
shared/
widgets/
app_button.dart
app_text_field.dart
theme/
spacing/
但是,与特定功能相关的代码仍然应该保留在相应的功能模块中。
shared/
authhelpers.dart
checkout_utils.dart
一旦业务逻辑被放入共享代码层,就会出现以下问题:
- 代码的归属关系会变得模糊不清。
- 不相关的功能模块之间会出现耦合现象。
- 架构边界也会逐渐消失。
过早地进行抽象化设计往往会导致长期维护成本的增加,而适度的重复编码反而能更有效地保持各部分代码之间的独立性。
如果未来两个功能模块的发展方向不同,重复编码可能比强制使用共享代码更能确保它们之间的隔离。
可维护性才是最重要的考虑因素,而不是追求最高的代码复用率。
扩展依赖注入机制
依赖注入有助于将基础设施层与业务逻辑分离,并提升代码的可测试性,但如果不加控制,依赖注入很容易变成隐藏的“全局状态”。
class ProfileCubit extends Cubit
final LoadProfileUseCase loadProfile;
ProfileCubit(this.loadProfile)
: super(const ProfileState.initial());
}
构造函数注入仍然是实现依赖注入的最佳方式之一。
依赖关系仍然清晰可见。
按功能进行模块化设计也能提升代码的可维护性:
void registerAuthenticationModule() {
getIt.registerLazySingleton<
AuthenticationRepository>(
() => AuthenticationRepositoryImpl(
getIt(),
),
);
getIt.registerFactory(
() => LoginCubit(
getIt(),
),
);
}
应避免在组件内部随意使用服务定位器来获取依赖对象:
getIt<ApiClient>>()
隐藏的依赖关系会大大增加调试难度,因为这些关系的归属关系变得不明确。
只要可能,依赖关系的归属应与功能模块的归属保持一致。
生产环境下的注意事项
许多关于架构的设计讨论,在涉及到实际运行方面的问题时就会停止。
生产环境会带来一些限制因素,这些因素会对架构设计产生重大影响,例如:
-
应用程序的启动速度
-
问题的可观测性
-
部署的安全性
-
系统迁移的复杂性
-
调试的便利性
-
系统运行的稳定性
应避免在`main()`方法中执行耗时的同步初始化操作:
Future<void>> main() async {
WidgetsFlutterBinding
.ensureInitialized();
await configureDependencies();
runApp(
const App(),
);
}
延迟初始化能够提升应用程序的启动速度,同时减少应用程序启动时的阻塞现象。
当应用程序规模扩大后,问题的可观测性就变得尤为重要:
FlutterError onError =
FirebaseCrashlytics.instance
.recordFlutterFatalError;
如果没有良好的可观测性,调试生产环境中的问题将会变得非常困难,因为故障现象很难在本地环境中重现。
功能开关可以帮助降低部署风险,并支持逐步推进应用程序的更新过程:
if (
featureFlags.isEnabled(
'new_checkout',
)
) {
return const NewCheckoutPage();
}
return const LegacyCheckoutPage();
随着团队规模的扩大,系统运行的稳定性变得越来越重要。
大型应用程序需要进行代码检查、格式化处理、自动化测试、静态分析以及拉取请求验证等工作。
仅靠良好的架构设计本身是无法确保系统的可维护性的,还需要在整个开发过程中严格遵守相关的工程规范。
结论
只有当团队致力于优化代码的可修改性、明确界定各组件的职责范围、保持状态边界的一致性、确保数据流的可预测性,并使系统具备良好的可维护性时,大型Flutter应用程序才能取得成功。
优秀的架构设计并不能消除复杂性,但它能帮助人们更好地理解这些复杂性。
应围绕具体的功能模块来组织代码结构,保持基础设施的独立性,避免使用隐藏的依赖关系,认真处理状态管理的问题,并谨慎使用那些被广泛使用的抽象层。<最重要的是,应逐步对架构进行改进与优化。>
最好的架构很少是一开始就设计完成的。它们是在应用程序、开发团队以及运营流程的复杂性逐渐降低的过程中逐步形成的。




