应用架构
Flutter 的框架代码是开源的,遵循 BSD 开源协议,并拥有蓬勃发展的第三方库生态来补充核心库功能。
架构层
Flutter 被设计为一个可扩展的分层系统。它可以被看作是各个独立的组件的系列合集,上层组件各自依赖下层组件。组件无法越权访问更底层的内容,并且框架层中的各个部分都是可选且可替代的
应用剖析
下图为你展示了一个通过 flutter create 命令创建的应用的结构概览。该图展示了引擎在架构中的定位,突出展示了 API 的操作边界,并且标识出了每一个组成部分。
Dart 应用
- 将 widget 合成预期的 UI。
- 实现对应的业务。
- 由应用开发者进行管理。
框架(源代码)
- 提供了上层的 API 封装,用于构建高质量的应用(例如 widget、触摸检测、手势竞技、无障碍和文字输入)。
- 将应用的 widget 树构建至一个 Scene 中。
引擎(源代码)
- 将已经合成的 Scene 进行栅格化。
- 对 Flutter 的核心 API 进行了底层封装(例如图形图像、文本布局和 Dart 的运行时)
- 将其功能通过 dart:ui API 暴露给框架。
- 使用 嵌入层 API 与平台进行整合。
嵌入层(源代码)
- 协调底层操作系统的服务,例如渲染层、无障碍和输入。
- 管理事件循环体系。
- 将 特定平台的 API 暴露给应用集成嵌入层。
运行器
- 将嵌入层暴露的平台 API 合成为目标平台可以运行的应用包。
- 部分内容由
flutter create生成,由应用开发者进行管理。
应用架构概览
参考 Architecting Flutter apps 一文介绍如下:
架构是构建可维护、健壮且可扩展的Flutter应用的重要组成部分。在本指南中,您将学习Flutter应用架构的原则和最佳实践。
“架构”是一个难以定义的词汇。它是一个广泛的术语,可以指代任何数量的主题。但是,在Flutter应用的上下文中,它通常指的是设计应用的结构和组织方式,以便随着项目需求和团队的增长而扩展。
- 有意为之的架构的好处:良好的应用架构为工程团队和最终用户带来了许多好处。
- 常见的架构原则:了解在应用开发中普遍接受的架构原则。
- Flutter团队推荐的应用架构:Flutter团队提供了一套建议的架构模式,以帮助开发者构建高效、可维护的应用。
- MVVM和状态管理:了解Model-View-ViewModel(MVVM)设计模式和状态管理在Flutter应用中的应用。
- 依赖注入:学习如何在Flutter应用中使用依赖注入来提高代码的灵活性和可测试性。
- 常见的设计模式:了解编写健壮Flutter应用时常用的设计模式。
有意为之的架构的好处
良好的应用架构为工程团队和最终用户提供了许多好处。这些好处包括:
- 可维护性:应用架构使得随着时间的推移更容易修改、更新和修复问题。
- 可扩展性:经过深思熟虑的应用允许更多人同时参与同一个代码库的开发,且代码冲突最小。
- 可测试性:具有有意为之架构的应用通常更容易测试,因为它们具有更清晰的接口和更少的依赖关系。
设计理念
参考 Architecture concepts 一文介绍如下:
关注点分离
- 关注点分离是应用开发中的一个核心原则,它通过将应用程序的功能划分为不同的、自包含的单元,来促进模块化和可维护性。从高层次来看,这意味着将您的UI逻辑与业务逻辑分开。这通常被描述为分层架构。在每个层内,您应该按功能或特性进一步分离您的应用程序。例如,您的应用程序的身份验证逻辑应该位于与搜索逻辑不同的类中。在Flutter中,这同样适用于UI层中的小部件。您应该编写可重用的、精简的小部件,它们包含的逻辑尽可能少。
分层架构
-
Flutter应用程序应该按层编写。分层架构是一种软件设计模式,它将应用程序组织成不同的层,每层具有特定的角色和责任。通常,根据复杂性,应用程序被划分为2到3层。
- UI层:显示由业务逻辑层公开的数据,并处理用户交互。这也通常被称为“表示层”。
- 逻辑层:实现核心业务逻辑,并促进数据层和UI层之间的交互。通常被称为“域层”。逻辑层是可选的,只有在您的应用程序有发生在客户端的复杂业务逻辑时才需要实现。许多应用程序只关注向用户展示数据并允许用户更改该数据(俗称CRUD应用程序)。这些应用程序可能不需要这个可选层。
- 数据层:管理与数据源(如数据库或平台插件)的交互。向业务逻辑层公开数据和方法。这些被称为“层”,因为每层只能与直接位于其下方或上方的层通信。UI层不应该知道数据层的存在,反之亦然。
单一数据源
- 您的应用程序中的每个数据类型都应该有一个单一数据源(SSOT) 。真相源负责表示本地或远程状态。如果数据可以在应用程序中修改,那么SSOT类应该是能够执行此操作的唯一类。这可以大大减少您应用程序中的错误数量,并且因为您只会有同一数据的副本,所以可以简化代码。通常,您应用程序中给定类型数据的真相源保存在称为Repository的类中,该类是数据层的一部分。这一原则可以在您的应用程序的层和组件之间以及单个类内部应用。例如,一个Dart类可能会使用getter从SSOT字段派生值(而不是拥有需要独立更新的多个字段),或者使用记录的列表来分组相关值(而不是可能会不同步的并行列表)。
单向数据流
-
单向数据流(UDF)
是指一种设计模式,它有助于将状态与显示该状态的UI解耦。简单来说,状态从数据层通过逻辑层最终流向UI层中的小部件。用户交互产生的事件流向相反的方向,从表示层通过逻辑层到数据层。在UDF中,从用户交互到重新渲染UI的更新循环如下所示:
- [UI层] 由于用户交互(如按钮被点击)而发生事件。
- 小部件的事件处理程序回调调用逻辑层中类暴露的方法。
- [逻辑层] 逻辑类调用存储库暴露的方法,这些方法知道如何修改数据。
- [数据层] 存储库更新数据(如果需要),然后将新数据提供给逻辑类。
- [逻辑层] 逻辑类保存其新状态,并将其发送到UI。
- [UI层] UI显示视图模型的新状态。
新数据也可以从数据层开始。例如,一个存储库可能会轮询HTTP服务器以获取新数据。在这种情况下,数据流只进行后半段旅程。最重要的思想是数据更改始终发生在SSOT(即数据层)中。这使得您的代码更容易理解、出错率更低,并防止创建格式错误或意外的数据。
UI是不可变状态的函数
- Flutter是声明式的,这意味着它构建其UI以反映您应用程序的当前状态。当状态更改时,您的应用程序应该触发依赖于该状态的UI的重建。在Flutter中,您经常会听到“UI是状态的函数”这样的描述。至关重要的是,您的数据应该驱动您的UI,而不是相反。数据应该是不可变的和持久的,并且视图应该包含尽可能少的逻辑。这减少了应用程序关闭时数据丢失的可能性,并使您的应用程序更容易测试和更具弹性。
可扩展性
- 架构的每个部分都应该有一个定义良好的输入和输出列表。例如,逻辑层中的视图模型应该只接受数据源(如存储库)作为输入,并且应该只暴露为视图格式化的命令和数据。以这种方式使用清晰的接口允许您替换类的具体实现,而无需更改任何消耗该接口的代码。
可测试性
- 使软件可扩展的原则也使软件更容易测试。例如,您可以通过模拟存储库来测试视图模型的自包含逻辑。视图模型测试不需要您模拟应用程序的其他部分,并且您可以单独测试UI逻辑,而不依赖于Flutter小部件本身。您的应用程序也将更加灵活。添加新逻辑和新UI将是直接且低风险的。例如,添加一个新的视图模型不会破坏数据层或业务逻辑层的任何逻辑。
设计指南
本文档提供了构建Flutter应用的最佳实践指南,旨在帮助开发者构建易于扩展、测试和维护的应用。以下是对文章内容的概括,保留了段落结构,建议并非一成不变的规则,开发者应根据自己的独特需求进行适应。
项目结构概览
-
关注点分离:是设计Flutter应用时最重要的原则。应用应被拆分为不同的层,每层进一步拆分为具有不同职责、明确接口、边界和依赖关系的组件。你的Flutter应用程序应该分成两大层:UI层和数据层
-
MVVM:如果接触过Model-View-ViewModel设计模式(MVVM),那么可以将应用分为三部分:Model、ViewModel和View。View和ViewModel构成应用的UI层,而Repositories和Services则代表应用的数据,即MVVM的模型层。
UI层
-
职责:负责与用户交互。它向用户显示应用数据,并接收用户输入。
-
组件:
- Views:描述如何呈现应用数据。一个视图通常(但并非总是)是一个包含Scaffold小部件及其下方小部件树中所有小部件的屏幕。视图还负责将事件传递给视图模型以响应用户交互。
- View Models:包含将应用数据转换为UI状态的逻辑。视图和视图模型应具有1:1的关系。
数据层
-
职责:处理业务数据和逻辑。由Services和Repositories两部分构成。
-
组件:
- Repositories:负责从服务中获取数据,并将其转换为域模型。域模型代表应用所需的数据,以视图模型类可以消耗的方式格式化。对于应用中处理的不同类型数据,都应有相应的Repository类。
- Services:包装API端点,并暴露异步响应对象,如Future和Stream对象。它们仅用于隔离数据加载,并不持有状态。应用应针对每个数据源有一个Service类。
可选的域层
- 引入原因:随着应用的增长和功能的增加,可能需要抽象出域层。
- 组件:域层中的类通常被称为Interactor或Use-Case。Use-Case负责简化UI层和数据层之间的交互,并使其更具可重用性。
应用实践
概览
架构实践涉及以下内容:
使用数据层中的仓库和服务以及UI层中的MVVM设计模式来实现Flutter的应用架构指南 使用命令模式来安全地在数据变化时渲染用户界面 使用ChangeNotifier和Listenable对象来管理状态 使用package:provider来实现依赖注入 在遵循推荐架构时如何设置测试 大型Flutter应用的有效包结构
代码组织有两种流行的方法:
按功能组织——每个功能所需的类被组合在一起。
例如,您可能有一个auth目录,其中包含如auth_viewmodel.dart、login_usecase.dart、logout_usecase.dart、login_screen.dart、logout_button.dart等文件。
按类型组织——每种“类型”的架构被组合在一起。
例如,您可能有如repositories、models、services和viewmodels等目录。
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
UI 层(UI Layer)
UI层
Flutter应用中每个功能的UI层应由两个组件组成:视图(View) 和视图模型(ViewModel) 。从最广泛的意义上讲,视图模型管理UI状态,而视图则显示UI状态。视图和视图模型具有一对一的关系;对于每个视图,都有一个对应的视图模型来管理该视图的状态。每一对视图和视图模型共同构成了单个功能的UI。
例如,一个应用可能包含名为LogOutView和LogOutViewModel的类。
定义视图模型
视图模型是一个Dart类,负责处理UI逻辑。视图模型接收域数据模型作为输入,并将这些数据作为UI状态暴露给其对应的视图。它们封装了视图可以附加到事件处理程序(如按钮点击)的逻辑,并管理将这些事件发送到应用的数据层,从而避免视图直接访问应用的数据层。
// 下面的代码片段是一个名为HomeViewModel的视图模型类的类声明。它的输入是提供其数据的存储库
class HomeViewModel {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) :
// 存储库是手动分配的,因为它们是私有成员
_bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
// ...
}
UI状态
视图模型的输出是视图渲染所需的数据,通常称为UI状态或简称状态。UI状态是不可变数据的快照,是完全渲染视图所需的数据。视图模型将状态作为公共成员公开。
需要强调的是,UI状态应该是不可变的。这是无错误软件的关键部分。Compass应用使用package:freezed来强制数据类的不变性。例如,以下代码展示了User类的定义。freezed提供了深度不可变性,并为有用的方法(如copyWith和toJson)生成了实现。
随着任何给定模型的UI状态复杂性的增加,视图模型可能会有来自更多仓库的更多数据暴露给视图。在某些情况下,您可能需要创建专门表示UI状态的对象。例如,您可以创建一个名为HomeUiState的类。
// UI状态应该是不可变的
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 = [];
/// [unmodiableelistview]中的项不能被直接修改,但是可以修改源列表。
/// _bookings 是私有的而订票不是,视图没有办法修改直接列表。
UnmodifiableListView<BookingSummary> get bookings => UnmodifiableListView(_bookings);
// ...
}
使用package: frozen来对数据类强制执行不变性:
@freezed
class User with _$User {
const factory User({
required String name,
required String picture,
}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
更新UI状态
除了存储状态外,视图模型还需要在数据发生变化时通知Flutter重新渲染视图。ChangeNotifier是Flutter SDK的一部分,并为状态变化时更新UI提供了一个很好的解决方案。您也可以使用强大的第三方状态管理解决方案,如package:riverpod、package:flutter_bloc或package:signals。这些库提供了不同的工具来处理UI更新。
class HomeViewModel extends ChangeNotifier {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
// HomeViewModel.User是视图所依赖的公共成员。
// 当新数据从数据层流出并且需要发出新状态时,将调用notifyListeners
User? _user;
User? get user => _user;
List<BookingSummary> _bookings = [];
List<BookingSummary> get bookings => _bookings;
// ...
}
以下图表从高层次展示了存储库中的新数据如何传播到用户界面(UI)层,并触发Flutter组件的重新构建:
1、存储库向视图模型提供新状态。 2、视图模型更新其UI状态以反映新数据。 3、调用ViewModel.notifyListeners,通知视图有新的UI状态。 4、视图(组件)重新渲染。
例如,当用户导航到主屏幕并创建视图模型时,会调用_load方法。在该方法完成之前,UI状态为空,视图会显示一个加载指示器。当_load方法完成时,如果成功,视图模型中会有新数据,并且它必须通知视图有新数据可用:
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();
}
}
}
定义视图
视图是应用中的一个组件(widget)。通常,视图代表应用中的一个屏幕,该屏幕拥有自己的路由,并且在组件子树的顶部包含一个Scaffold,如HomeScreen,但并非总是如此。
有时,视图是一个单一的用户界面元素,它封装了需要在整个应用中重复使用的功能。例如,Compass应用有一个名为LogoutButton的视图,它可以放置在组件树的任何位置,只要用户期望在那里找到注销按钮即可。LogoutButton视图拥有自己的视图模型,称为LogoutViewModel。在较大的屏幕上,屏幕上可能会有多个视图,这些视图在移动设备上会占据整个屏幕。
“视图”是一个抽象术语,一个视图并不等于一个组件。组件是可组合的,可以将多个组件组合在一起以创建一个视图。因此,视图模型与组件之间不是一对一的关系,而是与一组组件之间有一一对应的关系。
视图中的组件有三个职责:
- 它们显示来自视图模型的数据属性。
- 它们监听来自视图模型的更新,并在有新数据时重新渲染。
- 如果适用,它们将视图模型的回调附加到事件处理程序上。
// 定义HomeScreen
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.viewModel});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
// ...
);
}
}
在视图中显示UI数据
视图依赖于视图模型来获取其状态。在Compass应用中,视图模型是作为参数在视图的构造函数中传递的。
@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小部件使用ListenableBuilder小部件监听来自视图模型的更新。当提供的Listenable发生变化时,ListenableBuilder小部件下的子树中的所有小部件都会重新渲染。在这种情况下,提供的Listenable是视图模型。请记住,视图模型是ChangeNotifier的类型,它是Listenable类型的子类型。
处理用户事件
最后,视图需要监听来自用户的事件,以便视图模型可以处理这些事件。这是通过在视图模型类上公开回调方法来实现的,该方法封装了所有逻辑。在HomeScreen上,用户可以通过滑动Dismissible小部件来删除以前预订的事件。
final resultDelete = await _bookingRepository.delete(id);
在Compass应用中,这些处理用户事件的方法称为命令(Command) 。命令负责从UI层开始并流回数据层的交互。在这个应用中,具体来说,命令也是一种类型,有助于无论响应时间或内容如何,都能安全地更新UI。Command类包装了执行逻辑,并在方法Command.execute中多次调用notifyListeners。这允许视图使用非常少的逻辑来处理不同的状态。
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方法中,notifyListeners被多次调用。这使得视图能够以非常少的逻辑来处理不同的状态。您可能已经注意到,Command是一个抽象类。它由具体类(如Command0、Command1等)实现,数字表示命令携带的参数数量。
数据层(Data Layer)
在MVVM术语中,应用的数据层被称为“model”,它是应用数据的唯一真实来源。作为真实来源,数据层是应用数据应被更新的唯一地方。它负责从各种外部API消费数据,将这些数据暴露给用户界面(UI),处理来自UI的需要更新数据的事件,并根据需要向那些外部API发送更新请求。本指南中的数据层有两个主要组件:存储库(repositories)和服务(services)。
存储库(Repositories) 是应用数据的真实来源,包含与这些数据相关的逻辑,比如响应用户新事件更新数据或从服务轮询数据。存储库负责在支持离线功能时同步数据,管理重试逻辑,以及缓存数据。
服务(Services) 是无状态的Dart类,它们与API(如HTTP服务器和平台插件)进行交互。应用需要的任何不在应用代码本身内创建的数据都应该从服务类中获取。
定义服务
服务类是所有架构组件中最不模糊的。它是无状态的,其函数没有副作用。它的唯一工作是包装一个外部API。通常,每个数据源都有一个服务类,比如一个客户端HTTP服务器或一个平台插件。
例如,在Compass应用中,有一个APIClient服务,它处理面向客户端服务器的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 { /* ... */ }
}
服务本身是一个类,其中每个方法包装一个不同的API端点,并暴露异步响应对象。继续前面删除保存的预订的示例,deleteBooking方法返回一个Future<Result>。
提示:有些方法返回的数据类是专门为来自API的原始数据设计的,比如BookingApiModel类。稍后您将看到,存储库提取数据并以不同的格式将其暴露给UI层。
定义存储库
存储库的唯一责任是管理应用数据。存储库是单一类型应用数据的真实来源,并且它应该是该数据类型被修改的唯一地方。存储库负责从外部源轮询新数据,处理重试逻辑,管理缓存数据,以及将原始数据转换为领域模型。您的应用中每种不同类型的数据都应该有一个单独的存储库。
例如,Compass应用有名为UserRepository、BookingRepository、AuthRepository、DestinationRepository等的存储库。
以下示例是Compass应用中的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的抽象类。这个基类用于为不同的环境创建存储库。例如,Compass应用还有一个名为BookingRepositoryLocal的类,它用于本地开发。您可以在GitHub上看到BookingRepository类之间的差异。
BookingRepository接受ApiClient服务作为输入,它使用该服务从服务器获取和更新原始数据。重要的是,服务是一个私有成员,这样UI层就不能绕过存储库直接调用服务。有了ApiClient服务,存储库可以轮询服务器上用户保存的预订的更新,并发出POST请求来删除保存的预订。
存储库将原始数据转换为应用模型的数据可以来自多个源和多个服务,因此存储库和服务具有多对多的关系。一个服务可以由任意数量的存储库使用,并且一个存储库可以使用多个服务。
领域模型(Domain Models)
BookingRepository输出Booking和BookingSummary对象,这些是领域模型。所有存储库都输出相应的领域模型。这些数据模型与API模型的不同之处在于,它们只包含应用其余部分所需的数据。API模型包含原始数据,这些数据通常需要过滤、组合或删除才能对应用有用。存储库精炼原始数据并将其作为领域模型输出。在示例应用中,领域模型通过诸如BookingRepository.getBooking等方法上的返回值暴露出来。getBooking方法负责从ApiClient服务获取原始数据,并将其转换为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);
/// 在Compass应用中,服务类返回Result对象。
/// Result是一个实用程序类,它包装异步调用,并使处理错误和管理依赖于异步调用的UI状态变得更容易。
/// 这个模式是一个建议,但不是必需的。本指南中推荐的架构可以在没有它的情况下实现。
/// 您可以在Result食谱配方中了解这个类
return Result.ok(
Booking(
startDate: booking.startDate,
endDate: booking.endDate,
destination: destination,
activity: activities,
),
);
} on Exception catch (e) {
return Result.error(e);
}
}
完成事件循环
在本页中,您已经看到用户如何删除保存的预订,从用户在Dismissible小部件上滑动的事件开始。视图模型通过委托给BookingRepository来处理该事件的实际数据变更。以下代码段展示了BookingRepository.deleteBooking方法。
Future<Result<void>> delete(int id) async {
try {
return _apiClient.deleteBooking(id);
} on Exception catch (e) {
return Result.error(e);
}
}
存储库使用_apiClient.deleteBooking方法向API客户端发送POST请求,并返回一个Result。HomeViewModel消费这个Result及其包含的数据,并最终调用notifyListeners,完成循环。
依赖注入(Dependency Injection)
在定义架构中每个组件的明确职责时,还需要考虑组件之间的通信方式。这既涉及规定通信的规则,也涉及组件通信的技术实现。应用的架构应回答以下问题:
- 哪些组件允许与其他组件(包括同类型的组件)进行通信?
- 这些组件相互暴露哪些输出?
- 给定层是如何与另一层“连接”的?
以本图作为指南,接合规则如下:
依赖注入:本指南已展示了这些不同组件如何通过输入和输出来相互通信。在任何情况下,两层之间的通信都是通过将组件传递到消费其数据的组件的构造方法(例如,将Service传递到Repository)中来实现的。然而,这里缺少的是对象的创建。在应用程序中,MyService实例是在哪里创建的,以便它可以被传递到MyRepository中?这个问题的答案涉及一个被称为依赖注入的模式。
class MyRepository {
MyRepository({required MyService myService})
: _myService = myService;
late final MyService _myService;
}
在Compass应用中,依赖注入是使用package:provider来处理的。基于构建Flutter应用的经验,Google团队推荐使用package:provider来实现依赖注入。服务和存储库作为Provider对象暴露给Flutter应用的小部件树的顶层。
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(),
);
服务仅被暴露,以便它们可以立即通过provider的BuildContext.read方法注入到存储库中,如前面的代码片段所示。然后,存储库被暴露,以便它们可以根据需要注入到视图模型中。在小部件树的稍低位置,对应于全屏的视图模型是在package:go_router配置中创建的,其中再次使用provider来注入必要的存储库。
// 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: [
// ...
],
),
],
);
在视图模型或存储库中,注入的组件应该是私有的。例如,HomeViewModel类如下所示:
- 私有方法可防止有权访问视图模型的视图直接调用存储库上的方法。
class HomeViewModel extends ChangeNotifier {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
// ...
}
测试
在Flutter开发中,测试是保证应用质量和稳定性的关键环节。随着应用功能的增加,手动测试变得愈发困难,因此自动化测试显得尤为重要。Flutter提供了多种测试类型,以满足不同层次的测试需求。
1. 单元测试(Unit Tests)
单元测试是对单个函数、方法或类的测试。其主要目的是验证在不同条件下,某个逻辑单元的正确性。在单元测试中,通常会模拟(mock)被测试单元的外部依赖,以避免对外部系统的依赖和潜在的副作用。单元测试不涉及磁盘读写、屏幕渲染或接收外部用户操作,因此执行速度快,易于维护和扩展。
2. 小部件测试(Widget Tests)
小部件测试(在其他UI框架中可能称为组件测试)是对单个小部件的测试。小部件测试的目标是验证小部件的UI外观和交互是否符合预期。由于小部件测试涉及多个类,因此需要提供一个适当的小部件生命周期上下文。在测试中,小部件应能够接收和响应用户操作和事件,执行布局,并实例化子小部件。与单元测试相比,小部件测试的环境更为复杂,但仍然比完整的UI系统简单得多。
3. 集成测试(Integration Tests)
集成测试是对完整应用或应用大部分部分的测试。集成测试的目标是验证所有被测试的小部件和服务是否按预期协同工作。此外,集成测试还可以用于验证应用的性能。集成测试通常在真实设备或操作系统模拟器(如iOS模拟器或Android模拟器)上运行。为了确保测试结果的准确性,被测试的应用通常与测试驱动代码隔离。
4. 持续集成(Continuous Integration)
持续集成(CI)服务允许在推送新代码更改时自动运行测试。这提供了及时的反馈,以确认代码更改是否按预期工作,并且没有引入错误。通过持续集成,开发团队可以更快地识别和解决问题,从而提高开发效率和应用的稳定性。
打包
构建和发布为iOS应用
详细参考 iOS应用
内容:
1、前提条件
- 使用Xcode进行iOS应用的构建和发布,需在macOS系统上操作。
- 在发布前,需熟悉Apple的App Store审核指南。
- 发布到App Store需注册Apple Developer Program。
2、App Store Connect注册
- 注册应用需在App Store Connect上完成,需登记唯一的套装ID(Bundle ID)。
- 在App Store Connect中创建应用记录,填写应用细节,选择iOS平台。
3、Xcode设置
- 在Xcode中打开Flutter工程的ios/Runner.xcworkspace文件。
- 验证并设置应用名称、套装ID、签名与功能等关键配置。
- 设置iOS部署目标版本,确保与Flutter支持的最低版本兼容。
4、应用图标与启动图
- 替换Xcode项目中的占位图标和启动图为自定义图标和启动图。
- 遵循iOS的App Icon指南,确保图标符合要求。
5、构建与版本管理
- 更新应用的构建编号和版本号,在pubspec.yaml文件中设置。
- 使用flutter build ipa命令创建构建归档,可添加混淆和分割调试信息的选项。
- 在Xcode中也可设置构建名称和编号,覆盖pubspec.yaml中的设置。
6、上传到App Store Connect
- 使用Transporter应用或命令行工具altool将构建归档上传到App Store Connect。
- 在Xcode中验证构建归档,成功后分发应用。
- 可在App Store Connect的应用详情页面查看构建状态。
7、发布应用
- 通过TestFlight将应用发布给内部或外部测试人员测试。
- 准备发布到App Store时,完善定价与可用性信息,提交审核。
- 审核通过后,根据Version Release部分的设置发布应用。
构建和发布为 Android 应用
详细参考 安卓
内容:
1、测试应用
- 可以使用
flutter run命令或IntelliJ工具栏中的Run和Debug功能来测试应用。
2、自定义启动图标
- Flutter应用创建时带有默认启动图标,可以通过使用
flutter_launcher_icons包或手动操作来自定义图标。 - 手动操作包括查看Material Design图标设计指南,将图标文件放置在指定的资源文件夹中,并更新
AndroidManifest.xml文件中的android:icon属性。
3、启用Material组件
- 如果应用使用了平台视图,可能需要通过Android平台入门指南中的步骤来使用Material组件。
- 需要在
build.gradle文件中添加Android Material组件依赖,并在styles.xml文件中设置亮色和深色主题。
4、为应用签名
- 发布到Play商店的应用需要进行数字签名。
- 需要创建用于上传的密钥库,并在
key.properties文件中定义密钥库位置。 - 在
build.gradle文件中配置签名,以便在release模式下构建应用时使用上传密钥。
5、使用R8压缩代码
- R8是谷歌推出的代码压缩器,在打包release版本的APK或AAB时会默认开启。
- 混淆和压缩会显著增加Android应用的编译时间,但代码缩减是始终在release构建中启用的。
6、启用Multidex支持
- 当应用较大或使用体量较大的插件时,可能会遇到dex的64k方法数限制问题。
- Flutter工具支持以便捷的方式启用Multidex支持,当工具提示需要支持时,跟随指示进行调整即可。
- 也可以手动配置Android项目以支持Multidex。
7、检查App Manifest文件
- 需要审查默认的App Manifest文件,并验证相关属性值。
8、审查Gradle构建配置
- 需要审查
android块中的默认Gradle构建脚本,并可以根据需要更改属性值。
9、构建生产版本应用
- 当要发布到Play Store时,有两种发布方式的选择:APK或App Bundle。
- 需要使用
flutter build apk或flutter build appbundle命令来构建生产版本的应用。
持续部署
通过Flutter实现持续集成(CI)和持续交付(CD)的最佳实践,详细阐述了使用fastlane和其他工具自动打包、测试及部署Flutter应用的步骤。以下是核心内容:
1、Flutter CI/CD工具选择
- 内置选择:Codemagic等All-in-one解决方案。
- 使用Fastlane:结合GitHub Actions、Cirrus、Travis、GitLab、CircleCI等工具。
2、 Fastlane本地设置
- 安装Fastlane:通过gem install fastlane或brew install fastlane安装。
- 环境变量配置:设置FLUTTER_ROOT环境变量,指向Flutter SDK根目录。
- 初始化Fastlane项目:在Android和iOS项目目录中分别运行fastlane init。
3、 配置Appfile和Fastfile
- Appfile:配置应用的基本数据,如包名、bundle identifier、apple_id等。
- Fastfile:为每个平台创建Fastfile脚本,定义上传至应用商店的lane。
4、 应用商店凭据设置
- Google Play Store:使用json_key_file配置上传密钥。
- iTunes Connect:将iTunes密码设置到FASTLANE_PASSWORD环境变量。
5、 代码签名
- iOS:使用分发证书而非开发证书进行签名。
- Android:配置签名密钥和密钥库。
6、 在本地运行部署
- 构建应用:使用flutter build appbundle和flutter build ipa构建发布模式的应用。
- 运行Fastfile脚本:在Android和iOS目录中分别运行fastlane [lane名称]。
7、 云构建和部署设置
- 迁移前准备:确保本地流程有效。
- 加密环境变量:存储私有数据,如Play Store服务帐户JSON或iTunes分发证书。
- Gemfile管理依赖:使用Gemfile和bundle exec fastlane确保依赖稳定。
8、 Xcode Cloud配置
- 准备工作:确保Xcode 13.4.1或更高版本,加入Apple开发者计划。
- 自定义构建脚本:在Xcode Cloud中设置post-clone脚本,安装Flutter、CocoaPods等依赖。
- 工作流配置:创建Xcode Cloud工作流,定义触发条件和执行步骤。
Flavors
Flutter Flavors 【参考链接】 是一种在 Flutter 应用程序中用于创建和管理不同环境或版本配置的功能。这些不同的版本或环境通常被称为“flavors”,它们允许开发者使用相同的代码库为应用创建多个变体,以满足不同的需求或目标市场。
在Flutter项目中配置Flavors(构建配置)以创建不同环境的应用版本,如免费版、付费版等,并详细阐述了在iOS、macOS和Android平台上的配置步骤。以下是核心内容:
1、 Flavors的概念与用途
- 定义:Flavors(在iOS和macOS中称为构建配置)允许开发者使用相同的代码库为应用创建不同的环境。
- 用途:可用于创建生产应用的完整版本、免费版本、测试实验性功能等。
2、 iOS与macOS平台的配置
- 先决条件:安装Xcode,并有一个现有的Flutter项目。
- 配置步骤:在Xcode中打开项目,添加新的Scheme,并为每个Scheme定义构建配置。需要复制Debug、Release和Profile配置,并为它们添加特定的后缀(如-free)。
- 修改Scheme:设置产品包标识符(Bundle Identifier)以区分不同的Scheme,并在Build Settings中设置产品名称(Product Name)。
3、 Plugin配置
- 对于使用Flutter插件的应用,需要更新ios/Podfile和macos/Podfile,以匹配Xcode构建配置。
- 在Podfile中,更改Debug、Profile和Release的默认配置,以匹配新的Scheme构建配置。
4、 Android平台的配置
- 在Flutter项目的android/app/build.gradle文件中配置Flavors。
- 创建flavorDimension以分组添加的产品Flavors,并添加productFlavors对象,指定dimension、resValue和applicationIdSuffix等值。
5、 配置launch.json
- 在VSCode中,添加launch.json文件,以便运行flutter run --flavor [environment name]命令。
- 为每个Flavor添加一个配置对象,包括name、request、type、program和args等键。
6、 修改Dart代码
- 在lib/main.dart中修改Dart代码,以根据Flavor配置不同的行为。
- 可以使用appFlavor API来确定应用是使用哪个Flavor构建的。
7、 管理特定Flavor的资产
- 如果应用中有特定于某个Flavor的资产,可以配置它们仅在构建该Flavor时被打包到应用中。
- 这可以通过在flutter:assets配置中指定flavors来实现。
8、 测试Flavor配置
- 使用flutter run --flavor [flavor name]命令在命令行或IDE中测试Flavor配置。
- 可以查看集成测试样本来了解iOS、macOS和Android平台上Flavors的构建示例。