本系列文章如下:
Flutter App架构
Flutter 架构设计通用准则
Flutter 架构设计最佳实践
Flutter App架构案例
本文主要根据 Compass sample application 这个帮助用户安排旅行日程的demo。提供了路由、页面显示等组成的完整功能。demo中包含了和服务器通信、开发/正式环境、统一样式和高覆盖率单元测试。总之,就是模拟了一款日常使用中功能丰富的应用。
Compass app的架构就是之前讲过的MVVM架构。接下来就是把之前文章中的抽象概念一步一步落实到具体的代码中。
- 如何在Data layer中使用repository和service,如何在UI layer中实现MVVM。
- 数据改变时如何使用命令模式安全的渲染UI
- 如何使用
ChangeNotifier
和Listenable
对象管理状态 - 如何使用
pagckage:provider
实现依赖注入 - 如何在遵循app设计架构的基础上开展单元测试
- 如何为大型应用合理设计代码结构
代码结构
组织良好的代码在多人协作的过程中可以降低代码冲突并且对于对于新加入的开发者也更易于理解。设计良好的代码结构也是优秀架构的一部分。
有两种组织代码的方式:
- 按功能分包 - 相同业务的类在同一个包下。例如登录需要的类都在login包下。
- 按类型分包 - 例如通过repositories、models、services和viewmodels。
本文介绍的架构会混合使用这两种方式。Data layer中按类型分包,UI layer中按功能分包。大致的分类逻辑如下:
lib
├─┬─ ui
│ ├─┬─ core
│ │ ├─┬─ ui
│ │ │ └─── <shared widgets>
│ │ └─── themes
│ └─┬─ <FEATURE NAME>
│ ├─┬─ view_model
│ │ └─── <view_model class>.dart
│ └─┬─ widgets
│ ├── <feature name>_screen.dart
│ └── <other widgets>
├─┬─ domain
│ └─┬─ models
│ └─── <model name>.dart
├─┬─ data
│ ├─┬─ repositories
│ │ └─── <repository class>.dart
│ ├─┬─ services
│ │ └─── <service class>.dart
│ └─┬─ model
│ └─── <api model class>.dart
├─── config
├─── utils
├─── routing
├─── main_staging.dart
├─── main_development.dart
└─── main.dart
// The test folder contains unit and widget tests
test
├─── data
├─── domain
├─── ui
└─── utils
// The testing folder contains mocks other classes need to execute tests
testing
├─── fakes
└─── models
应用的大多数代码都在data
、domain
和 ui
中。Data layer中按类型分包是因为不同的view model中都会使用里面的repository和service。UI layer中按功能分包,因为每个功能都有唯一的view和view model对应。
这个代码分包结构其他需要注意的地方:
- UI 文件夹下包含了一个core文件夹,这里面包含了通用的widget和主题,例如有公司品牌色的button
- domain文件夹下包含应用的数据类,这些数据类会在后data和ui layer中使用
- app中包含三个“main”文件,作为应用development、staging和production的入口
- 有两个测试相关的文件夹作为lib,
test/
包含了测试需要的代码,testing/
包含了mock和测试工具类。
架构中的其他细节
这个demo中包含了之前推荐的架构设计的实现细节,但是并不是唯一的实现方案。app的UI部分主要通过ChangeNotifier
实现,但是也可以通过riverpod
、 flutter_bloc
或者 signals
来实现。layers之间的通信通过方法调用完成,例如从服务端获取新的数据。这也可以通过从repository中返回stream来实现。
UI layer结构分析
Flutter应用中UI layer由两部分组成 View
和 ViewModel
。
通常情况下,view model负责UI状态管理,view负责显示UI状态。View和view model保持一对一的关系。例如app的LogOutView
和 LogOutViewModel
。
view model实现
View mode是用于处理UI逻辑的Dart 类。View model输入domain中的data models并通过UI state暴露给UI。里面封装了View需要处理的事件逻辑,例如点击一个登录button并且将登录的数据传递给data layer。
下面会展示一个HomeViewModel
。依赖于repository中的BookingRepository
和 UserRepository
作为构造参数:
class HomeViewModel {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) :
// Repositories are manually assigned because they're private members.
_bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
// ...
}
View model通过构造函数传入的方式依赖data repository。View model和Repository之间是多对多的关系。
另外要注意的是,Repository在View mode中是私有的,否则view层就可以直接访问到data 层了。
UI state
view model的输出就是渲染view所需要的数据,或者称为UI State ,或者简称state。UI state是一个不可变的数据快照用于渲染view。
view model通过public的state向View暴露数据。下面这个view model中演示了如何向view暴露
User
对象和BookingSummary
:
class HomeViewModel {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
User? _user;
User? get user => _user;
List<BookingSummary> _bookings = [];
/// Items in an [UnmodifiableListView] can't be directly modified,
/// but changes in the source list can be modified. Since _bookings
/// is private and bookings is not, the view has no way to modify the
/// list directly.
UnmodifiableListView<BookingSummary> get bookings => UnmodifiableListView(_bookings);
// ...
}
再次强调下,这里的UI state都是不可被外部修改的。这对降低bug至关重要。
例子中的Compass app使用了package:freezed
来强制确保data class的不可变性。下面这段示例代码显示了User
类的定义,freezed
提供了深不变性(deep immutability),并且一会生成相应的copyWith
和toJson
方法:
@freezed
class User with _$User {
const factory User({
/// The user's name.
required String name,
/// The user's picture URL.
required String picture,
}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
注意:这上面的例子中,
User
和BookingSummary
两个对象就可以完成UI渲染了。但是随着业务复杂度的上升,会从更多的Repository中引入更多的数据。这是就需要专门的类(例如HomeUiState
)来暴露数据。
更新UI state
除了储存state,view models也需要在data layer发生改变的时候通知Flutter去更新UI渲染。在Compass app中通过view model继承ChangeNotifier
来实现:
class HomeViewModel extends ChangeNotifier {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
User? _user;
User? get user => _user;
List<BookingSummary> _bookings = [];
List<BookingSummary> get bookings => _bookings;
// ...
}
HomeViewModel.user
是对外暴露的public成员变量。当data layer数据更新需要刷新UI的时候,会调用notifyListeners
方法。
上图中的1-2-3-4显示了数据更新到UI更新的逻辑顺序。
- Repository中向view model提供新的state
- view model将新的数据更新到UI state
-
ViewModel.notifyListeners
被调用,通知View需要更新UI State - View(widget)重新渲染
例如,当用户进入Home页,对应的HomeViewModel被创建,_load()
方法被调用。在这个方法调用完成前,UI state是空的,view(widget)显示loading状态。当_load()
成功执行完成后,就需要通知View新的view model。
class HomeViewModel extends ChangeNotifier {
// ...
Future<Result> _load() async {
try {
final userResult = await _userRepository.getUser();
switch (userResult) {
case Ok<User>():
_user = userResult.value;
_log.fine('Loaded user');
case Error<User>():
_log.warning('Failed to load user', userResult.error);
}
// ...
return userResult;
} finally {
notifyListeners();
}
}
}
注意:
ChangeNofifier
和ListenableBuilder
(稍后介绍) 来自于Flutter SDK中,是一个解决UI更新的方式。当然也可以通过其他库完成这个工作,例如package:riverpod
,package:flutter_bloc
或者package:singals
。
实现View
Flutter中View就是应用中的widget。通常情况下,这个view有一个路由,并且使用Scaffold
作为最外层widget。
View也是某个业务功能的封装。例如Compass app中LogoutButton
可以被放在任何需要实现退出登录的地方。LogoutButton
有自己的view model称为LogoutViewModel
。
注意:这里的“View”是一个抽象概念,一个View很多时候并不等价于一个Widget。Widget是可被组合的,多个widget可以组合成一个View。所以之前强调的View和ViewModel是一对一的关系,和widget并不是。
view中的widget有三个功能:
- 根据view model显示data repository中的数据
- 监听view model中的数据更新,并重新渲染UI
- 相应用户的操作到view model中
image.png
下面展示了HomeScreen
这个view的伪代码:
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.viewModel});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
// ...
);
}
}
多数情况下,view的输入应该只有key
(这是Flutter widget的一个可选参数)和 View对应的view models。
在view中显示UI data
view依赖于view model提供state。在Compass app中,view model通过构造函数传入。
在widget中,可以从view model中访问数据。具体代码如下:
@override
Widget build(BuildContext context) {
return Scaffold(
// Some code was removed for brevity.
body: SafeArea(
child: ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(...),
SliverList.builder(
itemCount: viewModel.bookings.length,
itemBuilder: (_, index) => _Booking(
key: ValueKey(viewModel.bookings[index].id),
booking:viewModel.bookings[index],
onTap: () => context.push(Routes.bookingWithId(
viewModel.bookings[index].id)),
onDismissed: (_) => viewModel.deleteBooking.execute(
viewModel.bookings[index].id,
),
),
),
],
);
},
),
),
更新UI
HomeScreen
widget通过ListenableBuilder
widget监听view model的更新。当listenable(即viewmodel)
改变时,内部所有的子树都发生改变。
处理用户操作事件
最后,需要监听用户操作,并将操作转化为event传递个view model处理。通常就是调用view model中的方法。
在
HomeScreen
中用户可以通过Dismissiable
widget来删除之前的预定行程。当这个widget从屏幕上消失的时候,调用了viewmodel.deleteBooking
方法。很明显,处理删除预定行程数据的逻辑并不能放在这个View中,只有data层才能操作数据。所以
viewmodel.deleteBooking
调用了data 层中暴露的方法:
Future<Result<void>> _deleteBooking(int id) async {
try {
final resultDelete = await _bookingRepository.delete(id);
switch (resultDelete) {
case Ok<void>():
_log.fine('Deleted booking $id');
case Error<void>():
_log.warning('Failed to delete booking $id', resultDelete.error);
return resultDelete;
}
// Some code was omitted for brevity.
// final resultLoadBookings = ...;
return resultLoadBookings;
} finally {
notifyListeners();
}
}
在Compass app中,处理用户操作的方法称为命令(command)。
Command对象
Command用于将用户操作最终转化为data layer的变更。里面封装了方法当前的状态,例如running
、complete
和error
。这些状态用于帮助view显示不同的UI。例如command.running
显示loading UI。
abstract class Command<T> extends ChangeNotifier {
Command();
bool running = false;
Result<T>? _result;
/// true if action completed with error
bool get error => _result is Error;
/// true if action completed successfully
bool get completed => _result is Ok;
/// Internal execute implementation
Future<void> _execute(action) async {
if (_running) return;
// Emit running state - e.g. button shows loading state
_running = true;
_result = null;
notifyListeners();
try {
_result = await action();
} finally {
_running = false;
notifyListeners();
}
}
}
Command
本身继承自ChangeNotifier
,并且在Command.execute
中,会调用notifyListener
多次。这用于处理不同状态下的细微逻辑差异。
使用
flutter_command
这个库来实现Command
。
确保View在数据获取前也是可渲染的
view model中,command是在构造函数中创建的:
class HomeViewModel extends ChangeNotifier {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository {
// Load required data when this screen is built.
load = Command0(_load)..execute();
deleteBooking = Command1(_deleteBooking);
}
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
late Command0 load;
late Command1<void, int> deleteBooking;
User? _user;
User? get user => _user;
List<BookingSummary> _bookings = [];
List<BookingSummary> get bookings => _bookings;
Future<Result> _load() async {
// ...
}
Future<Result<void>> _deleteBooking(int id) async {
// ...
}
// ...
}
Command.execute
是异步的,所以不能保证view渲染的时候就是存在的。解决方案就是在Widget.build
方法中,对command的不同状态渲染不同的UI:
// ...
child: ListenableBuilder(
listenable: viewModel.load,
builder: (context, child) {
if (viewModel.load.running) {
return const Center(child: CircularProgressIndicator());
}
if (viewModel.load.error) {
return ErrorIndicator(
title: AppLocalization.of(context).errorWhileLoadingHome,
label: AppLocalization.of(context).tryAgain,
onPressed: viewModel.load.execute,
);
}
// The command has completed without error.
// Return the main view widget.
return child!;
},
),
// ...
Data layer
应用的data 层,也就是MVVM中的model,是应用的数据唯一来源。职责就是从各种API获取数据并暴露给UI层,相应UI层各种事件。
Data 层有两大部分组成:repository和service。
- Repository是应用的唯一数据来源,并且也会包含响应用户操作对数据的更改和从service获取数据。如果应用需要具备离线能力,处理重试和缓存数据,也应该在这里面处理。
- Service和各种API交互的无状态Dart类,例如从http server或者platform plugin获取数据。任何不包含在代码中的数据都应该从这里获取。
创建Service
Service类是应用架构中逻辑最清晰的类。无状态并且方法调用也没有其他影响,唯一的职责就是将外部API封装起来。通常每一个数据源都对应一个service类,例如http service或者platform plugin。
在Compass app中,有一个
APIClient
service用于处理CRUD调用。
class ApiClient {
// Some code omitted for demo purposes.
Future<Result<List<ContinentApiModel>>> getContinents() async { /* ... */ }
Future<Result<List<DestinationApiModel>>> getDestinations() async { /* ... */ }
Future<Result<List<ActivityApiModel>>> getActivityByDestination(String ref) async { /* ... */ }
Future<Result<List<BookingApiModel>>> getBookings() async { /* ... */ }
Future<Result<BookingApiModel>> getBooking(int id) async { /* ... */ }
Future<Result<BookingApiModel>> postBooking(BookingApiModel booking) async { /* ... */ }
Future<Result<void>> deleteBooking(int id) async { /* ... */ }
Future<Result<UserApiModel>> getUser() async { /* ... */ }
}
Service本身就是一个类,将各种API封装起来,并返回一个异步response。之前的deleteBooking
方法返回一个Future<Result<void>>
。
实现一个Repository
Repository的作用是负责管理应用数据,是应用的唯一数据来源,是数据唯一应该被修改的地方。这里也是从外部获取数据,处理重试逻辑,管理缓存和转化成业务需要的数据格式的地方。
不同的数据应该从不同的Repository中获取。例如Compass app中有
UserRepository
,BookingRepository
,AuthRepository
,DestinationRepostory
等。下面可以简单看下
BookingRepository
的代码:
class BookingRepositoryRemote implements BookingRepository {
BookingRepositoryRemote({
required ApiClient apiClient,
}) : _apiClient = apiClient;
final ApiClient _apiClient;
List<Destination>? _cachedDestinations;
Future<Result<void>> createBooking(Booking booking) async {...}
Future<Result<Booking>> getBooking(int id) async {...}
Future<Result<List<BookingSummary>>> getBookingsList() async {...}
Future<Result<void>> delete(int id) async {...}
}
开发环境切换
示例代码中有BookingRepositoryRemote
继承自基类BookingRepository
。这个基类可以用于创建不同的环境。例如还有一个BookingRepostoryLocal
,用于本地开发。
BookingRepository
通过构造函数传入ApiClient
,用于从服务端更新数据。并且传入的ApiClient
是private的,这样UI layer就无法直接访问了。一个Repository中可以有多个Servcie,一个Servcie也可能出现在多个Repository中。
Domain models
BookingRepository
输出Booking
和 BookingSummary
对象,这些对象被称为domain models。这个domain models和API直接返回的结构可能不一致,因为UI可能只需要部分数据或者需要被处理过的数据。
下面的代码示例中展示了BookingRepository.getBooking
方法从APIClient
service中获取数据,并返回Booking
对象。这个方法会从多个服务端接口中获取数据,并整合返回。
// This method was edited for brevity.
Future<Result<Booking>> getBooking(int id) async {
try {
// Get the booking by ID from server.
final resultBooking = await _apiClient.getBooking(id);
if (resultBooking is Error<BookingApiModel>) {
return Result.error(resultBooking.error);
}
final booking = resultBooking.asOk.value;
final destination = _apiClient.getDestination(booking.destinationRef);
final activities = _apiClient.getActivitiesForBooking(
booking.activitiesRef);
return Result.ok(
Booking(
startDate: booking.startDate,
endDate: booking.endDate,
destination: destination,
activity: activities,
),
);
} on Exception catch (e) {
return Result.error(e);
}
}
注意:
在Compass app中,service类返回了Result
对象。这个对象封装了异步调用,并且使得错误处理和UI状态管理变得更加方便。这只是一个推荐的方式,应用整体架构不受这个封装的影响。
响应用户操作
最后就是完成用户的操作响应。用户删除一个订阅后需要通知服务端。UI传递个view model,view model传递个BookingRepository
。示例代码如下:
Future<Result<void>> delete(int id) async {
try {
return _apiClient.deleteBooking(id);
} on Exception catch (e) {
return Result.error(e);
}
}
依赖注入
理清了架构中每个组件需要负责的范围,那么接下来就需要完成各个组件之间的沟通。组件之间沟通的协议是什么,如何在技术上实现这些协议,一个好的应用架构应当回答以下问题:
- 哪些组件允许互相通信
- 组件如何暴露输出
- 多个layer如何组合
接下来会通过代码演示不同的组件之间如何通过输入/输出进行通信。通常由构造函数完成输入的功能。
class MyRepository {
MyRepository({required MyService myService})
: _myService = myService;
late final MyService _myService;
}
这里面的MyService是如何被创建的?这个问题的答案就牵涉到依赖注入了。
在Compass app中,依赖注入(dependency injection) 是通过package:provider
完成的。这也是Google推荐的依赖注入方式。
Service和Repository都是在Flutter应用顶层级通过Provider
提供的:
runApp(
MultiProvider(
providers: [
Provider(create: (context) => AuthApiClient()),
Provider(create: (context) => ApiClient()),
Provider(create: (context) => SharedPreferencesService()),
ChangeNotifierProvider(
create: (context) => AuthRepositoryRemote(
authApiClient: context.read(),
apiClient: context.read(),
sharedPreferencesService: context.read(),
) as AuthRepository,
),
Provider(create: (context) =>
DestinationRepositoryRemote(
apiClient: context.read(),
) as DestinationRepository,
),
Provider(create: (context) =>
ContinentRepositoryRemote(
apiClient: context.read(),
) as ContinentRepository,
),
// In the Compass app, additional service and repository providers live here.
],
child: const MainApp(),
),
);
Service被创建后可以通过BuildContext.read
方法从provider
中获取。Repository随后也可以同样的方式注入到view model中。
后续的代码中通过package:go_router
创建View和相应的view model:
// This code was modified for demo purposes.
GoRouter router(
AuthRepository authRepository,
) =>
GoRouter(
initialLocation: Routes.home,
debugLogDiagnostics: true,
redirect: _redirect,
refreshListenable: authRepository,
routes: [
GoRoute(
path: Routes.login,
builder: (context, state) {
return LoginScreen(
viewModel: LoginViewModel(
authRepository: context.read(),
),
);
},
),
GoRoute(
path: Routes.home,
builder: (context, state) {
final viewModel = HomeViewModel(
bookingRepository: context.read(),
);
return HomeScreen(viewModel: viewModel);
},
routes: [
// ...
],
),
],
);
在view model或者Repository中,注入的对象应该是private,这样可以防止layer之间越级访问。