在任何应用程序中,都不可避免地会遇到这样一个基本问题:当某个事件发生时,其他多个组件需要对此作出相应的反应。
例如,当用户登录时,应用程序需要保存用户的令牌、缓存用户信息、触发分析事件,并将界面切换到主屏幕。
又比如,当一笔支付被确认后,库存数据需要更新,用户需要收到付款收据,同时配送系统也需要开始执行发货流程。
再或者,当某个传感器的读数发生变化时,三个不同的用户界面组件需要同时显示新的数值。
一种简单的解决方法是把所有这些逻辑都写在一个地方——要么编写一个能够完成所有功能的函数,要么创建一个包含所有相关信息的类。
起初这种方法确实有效,但后来需求会发生变化:可能需要添加新的处理逻辑,或者删除原有的某些功能,有时还会出现一些副作用,导致整个系统出现问题。这样一来,代码就会变得结构混乱、难以维护且容易出错。
观察者设计模式正是为了解决这类问题而存在的。它提供了一种结构化、适用于生产环境的解决方案,使得当某个事件发生时,能够自动通知所有相关的组件,而无需事件源去知道这些组件具体是哪些。
通过学习这本手册,你将从基础原理入手了解观察者设计模式,掌握它在Dart语言中的实现方式,理解它与事件驱动架构的关系,并了解如何在实际的Flutter应用程序中将其与领域驱动设计和Riverpod框架结合起来使用。
学习完这些内容后,你不仅会了解这个设计模式本身,还会知道如何有意识地在生产环境中运用它。
目录
什么是观察者设计模式?
观察者模式是一种行为设计模式,它定义了对象之间的一对多依赖关系。当某个对象的状态发生变化或触发某个事件时,所有依赖于它的对象都会自动得到通知并相应地更新自身状态。
以报纸订阅服务为例吧。报纸出版商并不知道具体的订阅者是谁,也不会逐一联系每一位读者。它只是按时发布报纸,而所有注册过的订阅者都会收到这份报纸。
任何订阅者都可以随时取消订阅,新的订阅者也可以随时加入。出版商的工作始终没有改变——它只需要负责发布报纸而已。
这就是观察者模式的简单含义。
在这里,出版商被称为主题对象,订阅者则被称为观察者对象,而报纸本身就代表了一个事件。
这一设计模式最早在《四人组》编写的《设计模式:可复用面向对象软件的基础要素》一书中被正式提出。至今,它仍然是软件工程中应用最为广泛的模式之一,尤其是在那些基于反应式机制和事件驱动的系统里。
它解决的问题
让我们来看看如果没有观察者模式会发生什么。
假设你有一个登录功能。当登录成功后,你需要执行以下四项操作:
-
将认证令牌保存到安全存储空间中
-
将用户资料缓存起来
-
跳转到首页
-
触发分析事件
如果采用直接编码的方式,那么这四项操作都可以被放在同一个函数中:
Future login(String email, String password) async {
final response = await _authRepository.login(email, password);
await _secureStorage.write(key: 'token', value: response.token);
await _userCache.save(response.user);
_navigationService.navigate('/home');
_analytics.track('login_success', {'userId': response.user.id});
}
乍一看,这样的代码似乎没什么问题。但仔细想想,这个函数竟然需要因为以下这些原因而不断修改:
-
如果令牌的存储方式发生了变化,就需要修改这个函数。
-
如果导航目标发生了改变,同样需要修改这个函数。
-
如果分析事件的名称或数据内容发生变化,还是得修改这个函数。
-
如果用户缓存的逻辑有所调整,这个函数也必须随之修改。
只要这四个方面的任何一项发生变化,你就不得不回到这个函数中进行修改。而每次修改这个函数,都有可能破坏它原本所承担的另外三项功能。
现在再想象一下,如果你需要添加第五项操作,比如为用户开启推送通知服务,你又会再次打开这个函数并添加新的代码。这样一来,这个函数就会变得越来越复杂,对其进行测试时也需要模拟四到五个不同的依赖组件。新加入的团队成员也会很难理解这个函数到底负责什么功能。而事实上,这个函数几乎承担了所有相关的任务——这就是问题所在。
这种现象被称为紧密耦合。登录逻辑与成功登录后所产生的所有后果都紧密地绑定在了一起。
观察者模式彻底打破了这种耦合关系。登录逻辑只负责完成登录操作并公布结果,而后续的所有处理工作则由独立的观察者来完成。每个观察者都有自己特定的职责,它们之间互不了解,登录逻辑也根本不知道这些观察者的存在。
核心组件
观察者模式由四个核心组成部分构成。在编写代码之前先理解这些组成部分,会让实现过程更加清晰易懂。
主题对象
主题对象是那些会发生某些变化的对象。它负责保存观察者的列表,并在事件发生时通知它们。主题对象还提供了让观察者注册或取消注册的方法。主题对象并不关心观察者们会如何处理这些通知信息,它只是简单地传递这些通知而已。
观察者
观察者是一个接口或抽象类,它定义了所有观察者都必须遵守的规则。该接口规定了主题对象在发送通知时将会调用的方法。任何想要对事件做出反应的类都必须实现这个接口。
具体主题对象
具体主题对象是主题对象的实际实现版本。它负责管理观察者的列表、处理订阅请求,并在适当的时候发送通知。
具体观察者类
这些就是真正实现了观察者接口的类。每个具体观察者类在收到通知后都会执行特定的任务:有的负责保存令牌,有的负责导航操作,还有的负责生成分析数据。它们之间互不了解,也不需要互相了解。
主题对象 (LoginService)
|
|-- subscribe(观察者) <- 观察者进行注册
|-- unsubscribe(观察者) <- 观察者进行取消注册
|-- notifySuccess(数据) <- 登录成功时发送通知
|-- notifyFailure(错误) <- 登录失败时发送通知
|
|-----> TokenObserver.onLoginSuccess()
|----->> UserObserver.onLoginSuccess()
|----->> NavigationObserver.onLoginSuccess()
|----->> AnalyticsObserver.onLoginSuccess()
主题对象会向所有观察者发送通知,而各个观察者则会独立地完成自己的任务。
在Dart中实现观察者模式
让我们一步步来构建这个观察者模式吧。
步骤1:定义观察者接口
abstract class LoginObserver {
void onLoginSuccess(UserDto user);
void onLoginFailed(AppException error);
}
这就是所有观察者都必须遵守的规则。任何想要对登录事件做出反应的类都必须实现这两个方法。
onLoginSuccess会在登录成功并获取到用户数据时被调用。onLoginFailed则会在登录失败并接收到错误信息时被调用。
步骤 2:定义主题接口
abstract class LoginSubject {
void subscribe(LoginObserver observer);
void unsubscribe(LoginObserver observer);
void notifySuccess(UserDto user);
void notifyFailure(AppException error);
}
subscribe方法允许观察者加入通知列表。unsubscribe方法允许观察者退出通知列表。notifySuccess方法会将包含用户数据的成功事件广播给所有已注册的观察者。notifyFailure方法则会将包含错误信息的失败事件广播给所有已注册的观察者。
将其定义为一个抽象类而不是直接定义为一个具体类是非常重要的。这样,任何依赖于该主题的部分实际上都是依赖于这个抽象接口,而非具体的实现方式。这样一来,你的代码就具备了可测试性和可替换性。
步骤 3:实现具体主题
class LoginService implements LoginSubject {
final List〈LoginObserver〉 _observers = [];
@override
void subscribe(LoginObserver observer) {
_observers.add(observer);
}
@override
void unsubscribe(LoginObserver observer) {
_observers.remove(observer);
}
@override
void notifySuccess(UserDto user) {
for (final observer in List.of(_observers)) {
try {
observer.onLoginSuccess(user);
} catch (e) {
debugPrint('观察者在处理成功事件时出现错误:$e');
}
}
}
@override
void notifyFailure(AppException error) {
for (final observer in List.of(_observers)) {
try {
observer.onLoginFailed(error);
} catch (e) {
debugPrint('观察者在处理失败事件时出现错误:$e');
}
}
}
}
在这个实现过程中,有两个非常重要的设计决策,但它们很容易被忽略。
1. 使用List.of()进行快照迭代
我们并没有直接遍历 `_observers` 列表,而是使用了 List.of(_observers)。这样,在循环开始执行之前,这个列表就会被复制一份。
为什么这样做很重要呢?想象一下,如果有一个 NavigationObserver,在导航到主屏幕后决定不再接收通知信息,那么如果在 notifySuccess 循环仍在遍历同一个列表时调用 unsubscribe,Dart 会抛出 ConcurrentModificationError 错误。因为此时列表正在被修改,而同时也有其他代码在读取这个列表。
使用 List.of() 可以完全避免这种问题。循环遍历的是副本列表,因此在对原始列表进行修改时,不会引发任何错误。
2. 为每个观察者添加 try/catch 块
每个观察者的调用都被包裹在各自的`try/catch`块中。这是经过慎重考虑后做出的设计。如果`TokenObserver`在将令牌写入安全存储时抛出异常,你肯定不希望`NavigationObserver`和`AnalyticsObserver`因此而无法正常执行它们的功能。无论其他观察者发生了什么,每个观察者都有机会被执行。
如果没有这样的设计,那么只要有一个观察者出现故障,整个通知链就会停止运行。这种隐藏的错误在生产环境中是极其难以排查的。
一个现实世界的例子:登录流程
现在,让我们利用这些基础构建完整的登录流程吧。
登录逻辑
class LoginLogic {
final LoginSubject _subject;
final AuthRepository _repository;
LoginLogic({
required LoginSubject subject,
required AuthRepository repository,
}) : _subject = subject,
_repository = repository;
Future callLogin(LoginRequest request) async {
try {
final user = await _repository.login(request);
_subject.notifySuccess(user);
} on AppException catch (e) {
_subjectnotifyFailure(e);
} catch (e) {
_subject.notifyFailure(AppException.unknown(message: e.toString()));
}
}
}
让我们仔细分析一下这段代码。
LoginLogic通过其构造函数接收两个依赖项:一个`LoginSubject`和一个`AuthRepository`。需要注意的是,它接收的是抽象类`LoginSubject`,而不是具体实现类`LoginService`。这意味着在测试或不同的环境中更换这些依赖项的实现时,完全不需要修改`LoginLogic`本身。
callLogin方法中的逻辑非常简单:它调用`AuthRepository`来执行登录操作。如果登录成功,就会用返回的用户信息调用`notifySuccess`方法;如果抛出`AppException`异常,就会用该异常信息调用`notifyFailure`方法;如果是其他类型的异常,也会将其包装成`AppException.unknown`并触发失败通知。
LoginLogic并不负责保存令牌、进行导航操作、缓存数据或执行分析功能,同时也不知道系统中存在多少观察者以及它们各自的具体职责是什么。
它的唯一职责就是:完成登录操作并告知结果。
具体的观察者类
class TokenObserver implements LoginObserver {
final SecureStorageService _storage;
TokenObserver(this._storage);
@override
void onLoginSuccess(UserDto user) {
_storage.write(key: 'auth_token', value: user.token);
}
@override
void onLoginFailed(AppException error) {
_storage.delete(key: 'auth_token');
}
}
`TokenObserver`的唯一职责就是管理认证令牌:在登录成功时保存令牌,在登录失败时清除存储中可能存在的过期令牌。它与导航、缓存或分析功能毫无关系。
class UserObserver implements LoginObserver {
final UserCacheService _cache;
UserObserver(this._cache);
@override
void onLoginSuccess(UserDto user) {
_cache.save(user);
}
@override
void onLoginFailed(AppException error) {
_cache.clear();
}
}
UserObserver的唯一职责是管理用户缓存:在登录成功时,它会保存用户的个人信息;而在登录失败时,它会清除缓存。该观察者对令牌、导航功能或分析数据一无所知。
class NavigationObserver implements LoginObserver {
final NavigationService _navigation;
NavigationObserver(this._navigation);
@override
void onLoginSuccess(UserDto user) {
_navigation.navigate('/home');
}
@override
void onLoginFailed(AppException error) {
_navigation.showError(error.message);
}
}
NavigationObserver的唯一功能是在登录尝试后处理导航逻辑。它使用的是注入的NavigationService抽象层,而不是BuildContext对象。这种设计是有意为之——因为依赖BuildContext的观察者会与UI组件的生命周期紧密绑定,而使用抽象层则能让这类观察者完全独立于UI层。
class AnalyticsObserver implements LoginObserver {
final AnalyticsService _analytics;
AnalyticsObserver(this._analytics);
@override
void onLoginSuccess(UserDto user) {
_analytics.track('login_success', {'userId': user.id});
}
@override
void onLoginFailed(AppException error) {
_analytics_track('login_failed', {'reason': error.message});
}
}
AnalyticsObserver的唯一任务是针对不同的登录结果触发相应的分析事件。它对数据存储、导航功能或缓存机制一无所知。
每个观察者都只承担一项职责,它们发生变化的原因也各不相同:当需要修改分析数据的相关逻辑时,只需调整AnalyticsObserver即可;而当导航规则发生变化时,也只需要修改NavigationObserver即可,其他部分都不会受到影响。
将各个组件组合起来
void setupLogin() {
final service = LoginService();
service
..subscribe(TokenObserver(secureStorage))
..subscribe(UserObserver(userCache))
..subscribe(NavigationObserver(navigationService))
..subscribe(AnalyticsObserver(analyticsService));
final loginLogic = LoginLogic(
subject: service,
repository: authRepository,
);
}
这就是组件的组合过程:所有观察者都会与它们所依赖的组件一起被创建出来,然后注册到相应的服务对象上。使用..这种链式调用方式,可以多次对同一个服务对象调用subscribe方法,从而使代码结构更加清晰易读。
LoginLogic将service作为其LoginSubject。从这一点开始,每当调用callLogin方法并且有结果产生时,这四个观察者都会被自动通知到。
添加第五个观察者,比如一个PushNotificationObserver,意味着需要创建这个类,并在代码中添加这一行:..subscribe(PushNotificationObserver(pushService))。整个代码库中其他部分都不需要做任何修改。
使用通用事件总线使其具备生产级功能
上面的登录示例运行得很好,但它仅适用于登录场景。在真实的应用程序中,许多功能都会遇到类似的场景:支付确认、订单下达、个人资料更新、会话过期等等。所有这些情况都需要通过一个事件来触发多个独立的处理流程。
为每个功能都重新编写Subject和Observer接口既繁琐又没有必要。更好的方法是使用通用的EventBus,这样任何功能都可以使用它。
abstract class DomainObserver {
void onSuccess(T data);
void onFailure(AppException error);
}
DomainObserver是一个通用的观察者接口。类型参数T表示观察者在操作成功时期望接收的数据类型。例如,登录操作的观察者应为DomainObserver,支付操作的观察者则为DomainObserver。接口本身是相同的,只是数据类型会根据不同的功能而有所变化。
class EventBus {
final List.DomainObserver> _observers = [];
void subscribe(DomainObserver observer) {
_observers.add(observer);
}
void unsubscribe(DomainObserver observer) {
_observers.remove(observer);
}
void publishSuccess(T data) {
for (final observer in _observers) {
try {
observer.onSuccess(data);
} catch (e) {
debugPrint('[EventBus] 观察者在操作成功时遇到错误: $e');
}
}
}
void publishFailure(AppException error) {
for (final observer in _observers) {
try {
observer.onFailure(error);
} catch (e) {
debugPrint('[EventBus] 观察者在操作失败时遇到错误: $e');
}
}
}
}
EventBus是一个通用的“主题”类。它负责管理一组特定类型的观察者,并以我们之前介绍的那种方式,为每个观察者单独发送通知信息,同时确保错误信息能够被正确地隔离处理。
现在,每个功能都可以使用这套统一的架构,而无需重复编写任何代码:
final loginBus = EventBus();
final paymentBus = EventBus();
final orderBus = EventBus();
每种事件总线都与其所对应的业务领域相匹配。注册在loginBus上的观察者永远不会收到与支付操作相关的通知,因为类型系统会确保这种匹配关系的正确性。
观察者模式早已存在于你的Flutter代码中
在进一步探讨架构设计之前,有一点值得注意:其实你一直在使用观察者模式,只不过并没有直接用“观察者模式”这个名称来称呼它而已。
流与StreamController:
final controller = StreamController();
controller.stream.listen((event) {
print('观察到了事件:$event');
});
controller.sink.add('登录成功');
StreamController实际上是一个“主题”对象。stream.listen相当于订阅某个事件;而sink.add则相当于通知观察者。任何对流的订阅行为都等同于成为观察者。这种模式本质上是相同的,只是Flutter用不同的名称来描述它而已。
ChangeNotifier:
class CounterModel extends ChangeNotifier {
int _count = 0;
void increment() {
_count++;
notifyListeners();
}
}
notifyListeners()会遍历所有已注册的观察者并调用它们。addListener就是订阅操作,而removeListener则相当于取消订阅。因此,ChangeNotifier》实际上是一个具体的“主题”对象。
BLoC:
当一个BLoC发出新的状态变化时,所有使用BlocBuilder或BlocListener包装它的组件都会对此作出反应。在这里,BLoC就是“主题”对象,而那些构建器与监听器则属于观察者;状态的变化本身就相当于一种通知机制。
Flutter的整个响应式系统(包括流、ChangeNotifier、BLoC以及ValueNotifier)其实都是基于观察者模式设计的,并且内置了生命周期管理机制。如果能够从这种根本层面理解这一模式,你就会明白为什么这些工具会以特定的方式工作——你不仅仅是在使用它们,而是真正理解了它们的工作原理。
深入探讨事件驱动架构
在类层面上理解观察者模式是基础。而当这一模式被应用到架构层面时,它的作用会变得更为强大,而这时事件驱动架构就派上了用场。
什么是事件驱动架构?
事件驱动架构是一种设计范式,在这种架构中,应用程序的运行流程是由各种事件来决定的。组件之间不是直接相互调用,而是通过共享的事件总线或通道来产生和传递事件来进行通信。
组件A直接调用组件B
组件B完成工作后返回结果
组件A等待结果后再继续执行
在这种传统的请求驱动模式下,组件A必须明确知道组件的存在,并且直接通过名称来调用它;如果想让组件C也对组件A的行为作出反应,就必须在组件A中添加相应的调用逻辑。
但这样一来,组件A的职责就会变得过于复杂——它甚至需要负责处理一些自己本不应了解的事情。
组件A向事件总线发布一个事件
事件总线将这个事件传递给所有已注册的观察者
组件B处理这个事件
组件C也处理这个事件
组件D同样也会处理这个事件
在事件驱动模式下,情况就完全不同了:各个组件不需要直接相互调用,而是通过事件总线来传递信息,从而实现解耦和协作。
组件A并不知道组件的B、C或D的存在,也不会等待它们的响应。它会将自己所发生的事情进行记录,然后继续执行后续操作。新的处理程序可以随时被添加进来,而完全不需要修改组件A本身。这就是将观察者模式应用到架构层面后的结果。
事件是事实,而非命令
这种区分是事件驱动架构中最重要的概念之一。
命令表示“执行这个操作”;它是一种可以被拒绝的指令,并且会要求收到相应的响应。
而事件则只是记录某件事情已经发生的事实;它不会要求任何回应,也不关心谁会来处理这些事件。
SaveUserToken属于命令类型,而UserLoggedIn则属于事件类型。
当你在系统中将事件视为事实来进行建模时,你就会获得应用程序中发生过的所有事情的历史记录。你可以重新执行这些事件来恢复系统状态,也可以添加新的处理程序来处理这些历史事件。这样的系统具备可审计性和可预测性,这是命令驱动型系统所无法实现的。
在Dart中建模领域事件
事件应该被定义为不可变的值对象。它们代表的是事实,而事实一旦发生就不会再发生变化。
abstract class DomainEvent {
final DateTime occurredAt;
final String eventId;
const DomainEvent({
required this.occurredAt,
required this.eventId,
});
}
DomainEvent是系统中所有事件的基础类。每个事件都包含一个记录发生时间的时间戳occurredAt,以及一个用于追踪目的的唯一标识符eventId。
class UserLoggedIn extends DomainEvent {
final UserDto user;
const UserLoggedIn({
required this.user,
required super.occurredAt,
required super.eventId,
});
}
class LoginFailed extends DomainEvent {
final AppException error;
const LoginFailed({
required this.error,
required super.occurredAt,
required super.eventId,
});
}
UserLoggedIn用于存储用户信息,LoginFailed则用于记录登录失败的原因。这两者都是不可变的对象,都包含时间戳和标识符,它们都真实地反映了领域中发生的某些事件。
类型安全的领域事件总线
现在我们可以构建一个专门为领域事件设计的事件总线:
abstract class EventHandler {
void handle(T event);
}
EventHandler是这种架构中的观察者接口。任何想要处理特定领域事件的类,都需要实现这个接口,并指定自己所关心的事件类型。
class DomainEventBus {
final _handlers = >{};
void register(EventHandler handler) {
_handlers.putIfAbsent(T, () => []).add(handler);
}
void publish(T event) {
final handlers = List.of(_handlers[T] ?? []);
for (final handler in handlers) {
try {
(handler as EventHandler).handle(event);
} catch (e) {
debugPrint('[DomainEventBus] 处理事件 ${T} 时发生错误:$e');
}
}
}
}
让我们仔细研究一下DomainEventBus。
_handlers是一个映射结构,其中键是Type(即事件类本身,例如UserLoggedIn),而值则是为该事件类型注册的所有处理器的列表。
register方法会接收一个处理器,并将其添加到类型为T的事件处理器列表中。putIfAbsent方法则确保:如果这是该事件类型的第一个处理器,那么就会创建相应的列表。
publish方法会查找所有为正在发布的事件类型注册的处理器,并依次调用它们各自的handle方法。之所以要使用List.of()来生成事件列表,同时也为每个处理器添加try/catch语句,原因正如我们之前所讨论过的那样。
以下是注册处理器以及发布事件的示例代码:
// 启动时仅执行一次注册操作
eventBus.register(TokenHandler(secureStorage));
eventBus.register(UserCacheHandler(userCache));
eventBus.register(NavigationHandler(navigationService));
eventBus.register(UserLoggedIn)(AnalyticsHandler(analyticsService));
eventBus.register(ReceiptHandler(receiptService));
eventBus.register(InventoryHandler(inventoryService));
// 发布操作是在具体的用例层面进行的
eventBus.publish(UserLoggedIn(
user: user,
occurredAt: DateTime.now(),
eventId: const Uuid().v4(),
));
当UserLoggedIn事件被发布时,只有那些已注册的处理器才会被触发。与支付相关的处理器不会被调用。UserLoggedIn事件的每个处理器都会独立运行,并且系统会确保它们之间的错误处理是相互隔离的。
领域驱动设计中的应用程序
事件驱动架构与观察者模式在领域驱动设计中得到了最为系统的应用。领域驱动设计为我们提供了相应的术语和结构,使我们能够清楚地了解事件属于哪个领域、谁负责生成这些事件,以及谁来处理它们。
你需要掌握的领域驱动设计核心概念
在领域驱动设计中,领域事件是至关重要的组成部分。它们代表了业务领域中发生的具有实际意义的事情——而不是某种技术细节,也不是HTTP响应结果,而是真正的业务事实。
UserLoggedIn就是一个领域事件,而LoginResponseDto则是一个数据传输对象。这两者之间的区别非常重要:领域事件属于领域模型,用于表达业务逻辑;而数据传输对象则属于数据层,用于表示数据结构。
聚合体是产生领域事件的天然来源。聚合体是由多个领域对象组成的群体,它们共同构成了一个保持数据一致性的边界。当聚合体内的状态发生重大变化时,聚合体会执行相应的业务规则并触发相关的领域事件。
用例>则是整个系统中的协调者。用例会调用相应的存储层接口获取结果,然后触发相应的领域事件,并最终返回处理结果。它本身并不直接处理任何副作用,而是仅仅负责通知其他组件发生了什么,之后由这些被注册的处理器来处理后续的操作。
在清晰架构中,所有组件都处于同一层中
lib/
core/
events/
domain_event.dart <- 基础领域事件类
domain_event_bus.dart <- 领域事件总线
event_handler.dart <- 基础事件处理接口
features/
auth/
domain/
events/
user_logged_in.dart <- 领域事件(纯Dart代码,不使用Flutter)
login_failed.dart <- 领域事件
handlers/
token_handler.dart <- 负责处理令牌存储
user_cache_handler.dart <- 负责处理用户缓存
analytics_handler.dart - 仅作为抽象接口使用
usecases/
login_usecase.dart - 负责协调操作并发布事件
data/
repositories/
auth_repository_impl.dart
datasources/
auth_remote_datasource.dart
presentation/
providers/
login_provider.dart - Riverpod通知器组件(轻量级实现)
pages/
login_page.dart
一条重要的规则是:领域层必须使用纯Dart代码编写,不允许引入Flutter相关代码、Riverpod库或HTTP相关模块。DomainEventBus、领域事件、事件处理程序以及相关用例都位于领域层中,且完全不依赖于任何框架。
这意味着同样的领域逻辑无论是在Flutter环境中、服务器端的Dart代码中,还是CLI工具中使用,都不需要修改任何一行代码。即使框架版本从Riverpod 2.x升级到未来的版本,领域层的代码也不会受到影响。针对领域层的单元测试可以在几毫秒内完成,而且不会产生与组件测试相关的额外开销。
在DDD架构中的登录用例
class LoginUseCase {
final AuthRepository _repository;
final DomainEventBus _eventBus;
LoginUseCase({
required AuthRepository repository,
required DomainEventBus eventBus,
}) : _repository = repository,
_eventBus = eventBus;
Future<Result<UserDto, AppException>>>> execute(LoginRequest request) async {
try {
final user = await _repository.login(request);
_eventBus.publish(UserLoggedIn(
user: user,
occurredAt: DateTime.now(),
eventId: const Uuid().v4(),
});
return Result.success(user);
} on AppException catch (e) {
_eventBuspublish(LoginFailed(
error: e,
occurredAt: DateTime.now(),
eventId: const Uuid().v4(),
).
return Result.failure(e);
}
}
}
让我们逐步分析这个示例。
LoginUseCase依赖于两个组件:一个AuthRepository抽象层和一个DomainEventBus。这两个组件都不是具体的实现类,在测试中可以随意替换它们。
在execute方法内部,它会调用AuthRepository来执行登录操作。如果登录成功,就会向DomainEventBus发布一个UserLoggedIn事件,这样所有注册好的事件处理程序都会立即得到通知。随后,该方法会返回一个包含用户信息的Result.success对象。
如果捕获到了AppException异常,它就会向总线发布一个LoginFailed事件,从而通知所有错误处理程序。随后,它会返回一个包含该错误的Result.Failure对象。
使用案例并不知道注册了多少个处理程序,也不知道这些处理程序的具体功能。它只是执行相应的操作,将结果以领域事件的形式发布出去,然后返回最终的结果。
Result类型是用于让调用者(即Riverpod通知器)了解操作结果的返回值,而领域事件则是用来通知所有副作用处理程序的。这两者都源自同一个使用案例调用,正是这种设计使得整个架构显得非常清晰、简洁。
Riverpod混合架构:实践中的简洁设计
在真正的Flutter应用程序中,所有这些组件都会在这里共同发挥作用。
我们正在解决的问题
在使用Riverpod的Flutter应用程序中,存在两个常见的问题:
组件中冗余的ref.listen代码:
// 这种写法非常繁琐
ref.listen:>(loginProvider, (previous, next) {
next.whenData((user) {
if (user != null) {
secureStorage.write(key: 'token', value: user.token);
userCache.save(user);
context.go('/home');
analytics.track('login_success');
}
});
});
当这个组件被挂载到界面上后,如果它在所有操作完成之前就被卸载了,那么一些副作用可能就无法被执行。像令牌存储和导航这样的业务逻辑不应该依赖于某个组件是否仍然处于活跃状态中——这种架构设计是脆弱的。
功能过于复杂的通知器:
// 这个通知器的功能太多了
Future login(LoginRequest request) async {
state = const AsyncLoading();
try {
final user = await _loginUseCase.execute(request);
await _secureStorage.write(key: 'token', value: user.token);
await _userCache.save(user);
_navigationService.navigateTo('/home');
_analytics.track('login_success');
state = AsyncData(user);
} catch (e, st) {
state = AsyncError(e, st);
}
}
这个通知器违反了“单一职责原则”——它同时负责登录操作、令牌存储、用户缓存管理、导航功能、数据分析以及UI状态的更新。一个类承担了六种不同的职责,这样的设计既难以进行清晰地测试,也极其不利于维护。
简洁性原则
在探讨解决方案之前,首先需要明确这样一个规则:
使用案例负责处理与业务逻辑相关的后果;通知器负责管理UI状态;组件本身不承担任何职责。
使用案例执行相应的操作并发布领域事件,当这些事件被触发时,处理程序会独立于组件的生命周期来运行。通知器会接收来自使用案例的结果,并根据不同的结果状态发送加载中、成功或失败等信号,从而让UI能够知道应该显示什么内容。组件则会读取这些状态信息并进行相应的渲染操作。
这就是整个情况的全貌。这意味着,无论登录操作是通过小部件触发、生物识别方式触发、深度链接触发,还是通过后台服务触发,这种架构都能正常工作。在任何情况下,相关处理逻辑都会被执行;通知器仅负责处理用户界面状态的变化。
了解AsyncNotifier
在编写通知器的相关代码之前,我们首先需要了解AsyncNotifier是什么以及它是如何工作的。
AsyncNotifier是Riverpod 2.0中专门为异步状态处理而设计的一个类。它包含一个AsyncValue类型的变量,这个类型有三个可能的取值:
AsyncData表示操作已经成功完成,数据也已经获取到。AsyncLoading表示操作正在进行中。AsyncError表示操作失败了。
当你继承AsyncNotifier这个类时,你需要实现一个build方法来指定初始状态,并且还需要编写其他方法,在异步操作进行的过程中更新state的值。
使用@riverpod指令生成代码时,你只需要为你的类添加相应的注解,然后运行flutter pub run build_runner build命令,生成工具会自动为你创建所需的所有代码结构。这样你就可以完全专注于编写业务逻辑了。
以下是用于生成代码的完整配置示例:
# pubspec.yaml
dependencies:
flutter_riverpod: ^2.5.1
riverpod_annotation: ^2.3.5
dev_dependencies:
riverpod_generator: ^2.4.0
build_runner: ^2.4.9
精简型通知器
// login_provider.dart
part 'login-provider.g.dart';
@riverpod
class LoginNotifier extends _$LoginNotifier {
@override
AsyncValue build() {
return const AsyncData(null);
}
Future login(LoginRequest request) async {
state = const AsyncLoading();
final result = await ref.read(loginUseCaseProvider).execute(request);
result.fold(
onSuccess: (user) => state = AsyncData(user),
onFailure: (error) => state = AsyncError(error, StackTrace.current),
);
}
}
让我们逐行分析这段代码。
part 'login-provider.g.dart'这一行告诉Dart,生成的文件属于这个库的一部分。@riverpod注解以及_$LoginNotifier基类也是从生成的文件中继承而来的。
build()方法是用于初始化对象的状态的。当第一次访问这个提供者时,这个方法会被调用。它返回AsyncData(null),这意味着初始状态是“操作成功但尚未获取到用户信息”的状态。这是合理的,因为此时还没有进行任何登录尝试。
在login方法中,我们首先将state设置为Const AsyncLoading(),这样就会立即通知所有正在使用这个提供者的组件,告诉它们当前有操作正在进行中。用户界面可以据此显示加载提示符。
然后我们调用该用例,并使用 `await` 来获取其返回结果。该用例会返回一个类型为 `Result` 的对象,这种类型要么包含表示成功的值,要么包含表示失败的值,但永远不会同时包含两者。我们会对这个对象使用 `fold` 方法来处理各种可能的情况。
在onSuccess分支中,我们设置state = AsyncData(user)。这告诉UI操作已经成功,并且现在有了可以渲染的用户数据。
在onFailure分支中,我们设置state = AsyncError(error, StackTrace.current)。这告诉UI出现了问题,因此它可以显示相应的错误状态。
这就是整个通知器的功能:它只做一件事——将用例的执行结果反映到UI状态中。
需要注意的是,这里并没有进行任何令牌的保存操作,也没有涉及导航、缓存或分析功能。这些都已经由其他组件处理好了。当用例在execute方法内部调用_eventBus.publish(UserLoggedIn(...))时,所有已注册的处理程序都会自动被触发。等到result返回给通知器时,所有的副作用就已经完成,通知器只需要更新UI即可。
这种设计实现了最清晰的功能分离:用例负责处理业务逻辑产生的结果,而通知器则负责更新UI状态。两者各自只承担一项职责。
组件
class LoginPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final loginState = ref.watch(loginNotifierProvider);
return Scaffold(
body: loginState.when(
data: (_) => const LoginForm(),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
),
);
}
}
class LoginForm extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
children: [
ElevatedButton(
onPressed: () {
ref.read(loginNotifierProvider.notifier).login(
LoginRequest(email: 'user@example.com', password: 'secret'),
);
},
child: const Text('登录'),
),
],
);
}
}
ref.watch.loginNotifierProvider)使这个组件能够监听通知器的状态变化。每当通知器中的state发生变化时,build方法会被重新调用,从而更新组件的渲染内容。
loginState.when用于处理AsyncValue的各种状态:当状态为AsyncData时,会显示登录表单;当状态为AsyncLoading时,会显示加载指示器;当状态为AsyncError时,会显示错误信息。
这个组件对令牌、导航或缓存功能一无所知。它只负责根据状态指示来渲染相应的内容,这就是它的全部职责。
组合根组件的连接方式
所有处理程序的注册操作都在应用程序启动时,在Riverpod提供者中完成:
@riverpod
DomainEventBus eventBus(EventBusRef ref) {
final bus = DomainEventBus();
bus.register<UserLoggedIn>(
TokenHandler(ref.read(secureStorageProvider)),
);
bus.register<UserLoggedIn>(
UserCacheHandler(ref.read(userCacheProvider)),
);
bus.register<UserLoggedIn>(
NavigationHandler(ref.read(navigationServiceProvider)),
);
bus.register<UserLoggedIn>>(
AnalyticsHandler(ref.read(analyticsServiceProvider)),
);
bus.register<LoginFailed>>(
AnalyticsFailureHandler(ref.read(analyticsServiceProvider)),
);
return bus;
}
eventBus是一个用于创建DomainEventBus并在首次被读取时注册所有处理器的组件。由于Riverpod组件默认采用惰性加载机制,在首次创建后会将相关内容缓存起来,因此这个初始化过程只会执行一次,之后该事件总线就会在整个应用程序会话期间持续有效。
每个处理器都会通过ref.read获取所需的依赖项。没有任何代码是硬编码的,在测试过程中这些依赖关系是可以被替换的。
LoginUseCase通过自身的组件以依赖项的形式获取了这个事件总线:
@riverpod
LoginUseCase loginUseCase(LoginUseCaseRef ref) {
return LoginUseCase(
repository: ref.read(authRepositoryProvider),
eventBus: ref.read(eventBusProvider),
);
}
这是唯一一个将用例与事件总线连接起来的地方。通知器只接收来自用例的信号,而组件仅接收通知器的状态信息。每一层都只了解其直接下方的那一层,而对其他层次一无所知。
如果要为登录功能添加新的副作用,只需创建一个新的处理器类,并在组合树的相应位置添加一行bus.register>代码即可。通知器、用例逻辑、组件以及现有的所有处理器都不会因此受到任何影响。
测试观察者架构
这种架构最显著的优势之一就是它能够非常清晰地划分测试的范围——每一层都有自己专门的测试目标。
测试用例
void main() {
group('LoginUseCase', () {
late LoginUseCase useCase;
late MockAuthRepository mockRepository;
late MockDomainEventBus mockEventBus;
setUp(() {
mockRepository = MockAuthRepository();
mockEventBus = MockDomainEventBus();
useCase = LoginUseCase(
repository: mockRepository,
eventBus: mockEventBus,
);
});
test('在操作成功时发布UserLoggedIn事件', () async {
final user = UserDto(id: '1', token: 'token123');
when(() => mockRepository.login(any)).thenAnswer((_) async => user);
await useCase.execute(LoginRequest(email: 'a@b.com', password: '123'));
verify(() => mockEventBus.publish(any<UserLoggedIn>>()).called(1);
});
test('在操作失败时发布LoginFailed事件', () async {
when(() => mockRepository.login(any))
.thenThrow(AppException.unauthorized(message: 'Invalid credentials'));
await useCase.execute(LoginRequest(email: 'a@b.com', password: 'wrong'));
verify(() => mockEventBus.publish任何<LoginFailed>>()).called(1);
});
});
}
这个用例测试对仓库和事件总线进行了模拟,它验证了在每种结果情况下是否发布了正确的事件类型。但它并不测试任何处理器的具体行为——这并非用例的职责范围,因此也不属于用例的测试内容。
测试每个处理器
void main() {
group('TokenHandler', () {
late TokenHandler handler;
late MockSecureStorageService mockStorage;
setUp(() {
mockStorage = MockSecureStorageService();
handler = TokenHandler(mockStorage);
});
test('在用户登录成功时,将令牌写入安全存储', () {
final event = UserLoggedIn(
user: UserDto(id: '1', token: 'abc123'),
occurredAt: DateTime.now(),
eventId: 'event-1',
);
handler.handle(event);
verify(
() => mockStorage.write(key: 'auth_token', value: 'abc123'),
).called(1);
});
});
}
每个处理器的测试都非常简单:创建包含模拟依赖项的处理器,触发相应的事件,然后验证该处理器应该产生的具体副作用。测试过程中不会涉及其他处理器、通知器或任何组件。
测试通知器
void main() {
group('LoginNotifier', () {
test('当操作成功时,系统状态会从“加载中”变为“数据已获取”', () async {
final mockUseCase = MockLoginUseCase();
final user = UserDto(id: '1', token: 'token123');
when(() => mockUseCase.execute(any()))
.thenAnswer((_) async =&> Result.success(user));
final container = ProviderContainer(overrides: [
loginUseCaseProvider.overrideWithValue(mockUseCase),
});
final notifier = container.read(loginNotifierProvider.notifier);
await notifier.login(LoginRequest(email: 'a@b.com', password: '123');
expect(
container.read(loginNotifierProvider),
isA<AsyncData<>UserDto?>>(),
);
});
test('当操作失败时,系统状态会从“加载中”变为“出现错误”', () async {
final mockUseCase = MockLoginUseCase();
final error = AppException.unauthorized(message: '无效的凭证');
when(() => mockUseCase.execute(any())
.thenAnswer((_) async =&> Result.failure(error));
final container = ProviderContainer(overrides: [
loginUseCaseProvider.overrideWithValue(mockUseCase),
});
final notifier = container.read(loginNotifierProvider.notifier);
await notifier.login(LoginRequest(email: 'a@b.com', password: 'wrong');
expect(
container.read(loginNotifierProvider),
isA<AsyncError>>(),
);
});
});
}
通知器的测试仅用于验证状态变化过程。由于通知器本身并不直接操作事件总线,因此无需对事件总线进行模拟;这一任务由相应的用例来完成,而那些用例也有自己的测试来确保事件能够被正确地发布。系统的每一层都在进行完全独立的测试,彼此之间没有重叠。
何时使用观察者模式
在以下情况下应使用观察者模式:
-
当一个事件需要触发多个独立的响应时
-
当你想要添加或移除某些响应,而不需要修改事件源时
-
当需要将副作用与业务逻辑分离时
-
当每个响应都需要被独立测试时
-
当系统的多个部分需要对相同的状态变化做出反应时
-
当你正在开发一个功能,而该功能的副作用会随着时间逐渐增加时
何时不应使用观察者模式
在以下情况下应避免使用观察者模式:
-
当你只有一个消费者,并且不预期会有更多的消费者出现时
-
生产者与消费者之间的关系简单直接时
-
使用该模式会增加代码的结构复杂性,但并不会带来实质性的好处时
-
流、ChangeNotifier或Riverpod等内置的反应式机制已经能够自然地解决相关问题时
-
当副作用的顺序必须严格控制,而扇形分布结构使得这种顺序难以保证时
结论
观察者设计模式是软件工程师工具箱中最重要的工具之一。其原因并非在于这个模式本身多么高明,而在于它能够解决所有正在发展的应用程序都会面临的一个问题:如何让一个事件触发多种反应,同时又避免使代码结构变得过于复杂、难以维护?
你首先了解了这一模式的核心原理:Subject负责保存观察者的列表,并在事件发生时通知它们。你还看到了如何在Dart语言中逐步实现这一模式——通过快照迭代机制来防止并发修改错误,使用针对每个观察者的try/catch语句来避免故障的连锁反应,以及运用依赖倒置原则来确保所有代码都具备可测试性。
你进一步发现,观察者模式早已内嵌在Flutter的流、ChangeNotifier以及BLoC等组件中。理解这些组件的工作原理,也就意味着你明白了为什么它们会以这样的方式运行。
随后,你将这一模式应用到了事件驱动架构中,在这种架构中,事件成为了不可变的领域事实,而系统则由生产者和消费者组成,它们之间不存在直接的耦合关系。
在领域驱动设计中,你再次运用了观察者模式,让事件在独立于任何框架、具备高度可移植性和可测试性的纯Dart领域层中得到妥善处理。
你还看到了这一模式是如何与Riverpod结合使用的——通过一种混合架构来实现这种集成,其中有一个明确且必须遵守的规则:处理程序负责处理副作用,通知器负责管理UI状态,而组件本身则不承担任何相关职责。
最终,你得到了一个能够优雅扩展的代码库。当需要添加新的副作用时,你只需创建一个新的处理程序并将其注册到相应的位置,其他部分都不需要做任何修改。这正是观察者模式所承诺的功能。正如你在本书中看到的那样,这一承诺确实得到了兑现。
祝编码愉快!