在软件开发社区,单子是一个非常有争议的、有争论的话题。
- 有些人说你应该不惜一切代价避免它们。❌
- 其他人则更务实,只在特定情况下使用它们。🔍
- 还有一些人随意使用它们,就像没有明天一样。😅
为了让大家更清楚地了解情况,本文将介绍以下内容。
- Dart/Flutter中的单子介绍
- 它们能解决什么问题
- 它们带来了哪些其他问题以及如何克服这些问题
- 单元的替代品
到最后,你会更好地理解为什么单字节会使你的代码变得不那么可维护和可测试,以及你可以做什么来代替它。
准备好了吗?
什么是Singleton?
根据维基百科上的这个页面。
单子模式是一种软件设计模式,它将一个类的实例化限制在一个 "单一 "实例上。
该页面还说,单子模式通过允许它解决了问题。
- 确保一个类只有一个实例
- 方便地访问一个类的唯一实例
- 控制其实例化
- 限制实例的数量
- 访问一个全局变量
换句话说,单子模式确保一个类只有一个实例被创建,这使得它很容易作为一个全局变量被访问。
如何在Dart中实现一个单子
这是最简单的方法。
class Singleton {
/// private constructor
Singleton._();
/// the one and only instance of this singleton
static final instance = Singleton._();
}
通过使构造函数私有化,我们确保该类不能在定义它的文件之外被实例化。
因此,访问它的唯一方法就是在我们的代码中调用Singleton.instance 。
在某些情况下,使用静态getter变量是比较好的。关于在Dart中实现单子的其他方法,请阅读StackOverflow上的这个主题。
Flutter中的一些单子例子
如果你以前用过Firebase,你会熟悉这段代码,它可以用来在按下一个按钮时进行登录。
ElevatedButton(
// access FirebaseAuth as a singleton and call one of its methods
onPressed: () => FirebaseAuth.instance.signInAnonymously(),
child: Text('Sign in anonymously'),
)
所有的Firebase插件都使用了单子模式。而调用它们的方法的唯一方法是使用instance getter。
FirebaseFirestore.instance.doc('path/to/document');
FirebaseFunctions.instance.httpsCallable('createOrder');
FirebaseMessaging.instance.deleteToken();
但是,请等一下!如果官方的Firebase插件都是作为单子实现的,那么用同样的方式来设计你的类也是可以的,对吗?🧐
没有那么快。
只有一个实例
你看,这些类被设计成单例,以防止你在代码中创建多个实例。
// Note: this code won't compile since the constructor is private
// inside WidgetA
final auth1 = FirebaseAuth();
// inside WidgetB - different instance:
final auth2 = FirebaseAuth();
上面的代码不会被编译。它不应该被编译,因为你只有一个认证服务,作为一个单一的真理来源。
// inside WidgetA
final auth1 = FirebaseAuth.instance;
// inside WidgetB - same instance:
final auth2 = FirebaseAuth.instance;
这是一个非常崇高的目标,而且单机往往是库或包设计的一个合理的解决方案。
但在编写应用程序代码时,我们应该非常小心地使用它们,因为它们会导致我们代码库中的许多问题。
Flutter应用程序有深度嵌套的widget树。因此,singletons使我们可以很容易地从任何widget中访问我们需要的对象。但是,单子有很多缺点,而且有更好的替代品,仍然易于使用。
单子的缺点
为了更好地理解为什么单子会有问题,这里列出了一些常见的缺点,以及可能的解决方案。
1.单子难以测试
使用单子使你的代码难以测试。
考虑一下这个例子。
class FirebaseAuthRepository {
Future<void> signOut() => FirebaseAuth.instance.signOut();
}
在这种情况下,不可能写一个测试来检查FirebaseAuth.instance.signOut() 是否被调用。
test('calls signOut', () async {
final authRepository = FirebaseAuthRepository();
await authRepository.signOut();
// how to expect FirebaseAuth.instance.signOut() was called?
});
一个简单的解决方案是将FirebaseAuth 作为一个依赖关系 注入,像这样。
class FirebaseAuthRepository {
// declare a FirebaseAuth property and pass it as a constructor argument
const FirebaseAuthRepository(this._auth);
final FirebaseAuth _auth;
// use it when needed
Future<void> signOut() => _auth.signOut();
}
因此,我们可以很容易地在我们的测试中模拟这个依赖关系,并针对它编写期望。
import 'package:mocktail/mocktail.dart';
// declare a mock class that implements the type of our dependency
class MockFirebaseAuth extends Mock implements FirebaseAuth {}
test('calls signOut', () async {
// create the mock dependency
final mock = MockFirebaseAuth();
// stub its method(s) to return a value when called
when(mock.signOut).thenAnswer((_) => Future.value());
// create the object under test and pass the mock as an argument
final authRepository = FirebaseAuthRepository(mock);
// call the desired method
await authRepository.signOut();
// check that the method was called on the mock
expect(mock.signOut).called(1);
});
查看mocktail包,了解更多关于如何使用mock编写测试的信息。
2.单子是隐式依赖关系
让我们回顾一下前面的例子。
class FirebaseAuthRepository {
Future<void> signOut() => FirebaseAuth.instance.signOut();
}
在这种情况下,我们很容易看到FirebaseAuthRepository 依赖于 FirebaseAuth 。
但是一旦我们有了几十行代码的类,就很难发现单子了。
另一方面,当依赖关系作为明确的构造函数参数传递时,就更容易看到了。
class FirebaseAuthRepository {
// easy to find the dependencies here,
// even if this class becomes very large
const FirebaseAuthRepository(this._auth);
final FirebaseAuth _auth;
Future<void> signOut() => _auth.signOut();
}
3.懒惰的初始化
初始化某些对象可能是很昂贵的。
class HardWorker {
HardWorker._() {
print('work started');
// do some heavy processing
}
static final instance = HardWorker._();
}
void main() {
// prints 'work started' right away
final hardWorker = HardWorker.instance;
}
在上面的例子中,只要我们在main() 方法中初始化hardWorker 变量,所有繁重的处理代码就会运行。
在这种情况下,我们可以使用late ,将对象的初始化推迟到以后(当它真正被使用时)。
void main() {
// prints nothing
// initialization will happen later when we *use* hardWorker
late final hardWorker = HardWorker.instance;
...
// initialization happens here
// prints 'work started' from the constructor
hardWorker.logResult();
}
然而,这种方法很容易出错,因为太容易忘记使用late 。
注意:在Dart中,所有的全局变量在默认情况下都是懒得加载的(对于静态类变量也是如此)。这意味着它们只有在第一次使用时才会被初始化。另一方面,局部变量一经声明就会被初始化,除非它们被声明为
late。
作为替代方案,我们可以使用**get_it这样的包,这样就可以很容易地注册一个懒惰的**单子。
class HardWorker {
HardWorker() {
// do some heavy processing
}
}
// register a lazy singleton (won't be created yet)
getIt.registerLazySingleton<HardWorker>(() => HardWorker());
// when we need it, do this
final hardWorker = getIt.get<HardWorker>();
而我们也可以用**Riverpod做同样的事情,因为所有的提供者默认**都是懒惰的。
// create a provider
final hardWorkerProvider = Provider<HardWorker>((ref) {
return HardWorker();
});
// read the provider
final hardWorker = ref.read(hardWorkerProvider);
因此,我们需要的对象只有在我们第一次使用它时才会被创建。
我最喜欢Riverpod的一点是,它使使用提供者测试代码变得非常容易。更多细节,请阅读Riverpod关于测试的文档。
4.实例生命周期
当我们初始化一个单子实例时,它将一直保持活力,直到时间结束(也就是应用程序关闭时😅)。
而且,如果这个实例消耗了大量的内存或者保持了一个开放的网络连接,我们就不能提前释放它,如果我们想的话。
另一方面,像**get_it和Riverpod这样的包让我们对某个实例的处置**时间有更多控制。
事实上,Riverpod相当聪明,让我们轻松地控制一个提供者的状态的生命周期。
例如,我们可以使用 autoDispose修改器来确保我们的HardWorker ,一旦最后一个监听器被删除,我们就会立即处理掉。
final hardWorkerProvider = Provider.autoDispose<HardWorker>((ref) {
return HardWorker();
});
当我们想在使用一个对象的widget被卸载后立即处置该对象时,这是最有用的。
5.线程安全
在多线程语言中,我们需要注意在不同的线程中访问单子,如果它们共享易变的数据,可能需要一些同步机制。
但在Dart中,这通常不是一个问题,因为Flutter应用内的所有应用代码都属于主隔离区。
不过,如果我们最终创建了单独的隔离体来执行一些繁重的计算,我们就需要更加小心了。
隔离器不应该修改任何可能被保存在单子中的可变数据。
欲了解更多信息,请观看关于Isolates和事件循环的视频。
单子的替代品
在回顾了使用单子的主要缺点后,让我们看看有哪些替代方案非常适合Flutter应用开发。
依赖性注入
维基百科将依赖性注入定义为
一种设计模式,其中一个对象接收它所依赖的其他对象。
在Dart中,这很容易通过显式构造器参数来实现。
class FirebaseAuthRepository {
// inject the dependency as a constructor argument
const FirebaseAuthRepository(this._auth);
// this property is a dependency
final FirebaseAuth _auth;
// use it when needed
Future<void> signOut() => _auth.signOut();
}
依赖注入促进了良好的关注点分离,使类独立于它们所依赖的对象的创建。
但是我们怎样才能初始化我们上面创建的FirebaseAuthRepository 类,并在深度嵌套的小部件(或代码的其他地方)中使用它呢?
使用get_it作为一个服务定位器
如果我们使用**get_it包,我们可以在应用程序启动时将我们的类注册为一个懒惰的单子**。
void main() {
// GetIt itself is a singleton, see note below for more info
final getIt = GetIt.instance;
getIt.registerLazySingleton<FirebaseAuthRepository>(
() => FirebaseAuthRepository(FirebaseAuth.instance),
);
runApp(const MyApp());
}
然后我们可以在需要时像这样访问它。
final authRepository = getIt.get<FirebaseAuthRepository>();
注意:
GetIt类本身就是一个单子。但这没关系,因为重要的是,它允许我们将我们的依赖关系与需要它们的对象解耦。对于更深入的概述,请阅读包的文档。
使用Riverpod供应商
Riverpod使得创建提供者作为全局变量变得很容易。
final authRepositoryProvider = Provider<FirebaseAuthRepository>((ref) {
return FirebaseAuthRepository(FirebaseAuth.instance);
});
而如果我们有一个ref 对象,我们可以很容易地读取任何提供者来获得其值。
final authRepository = ref.read(authRepositoryProvider);
因此,我们只需要在整个代码库中调用一次 FirebaseAuth.instance (而不是多次),因为我们现在可以获得或读取其值(使用get_it或Riverpod)。
与基于InheritedWidget或Provider包的解决方案不同,Riverpod提供者生活在widget树之外。这使得他们在编译时可以安全使用,没有运行时的异常。欲了解更多相关信息,请阅读我的《Riverpod基本指南》。
总结
现在我们已经介绍了使用单体的主要缺点和它们的替代方案,我想根据个人经验给你留下一些实用的提示。
1.不要创建你自己的单子
除非你是一个包的作者,而且你有充分的理由这样做,否则不要创建你自己的单子。即使你将第三方API作为单子访问,也不要到处使用Singleton.instance ,因为这使你的代码难以测试。
相反,通过将任何依赖性作为构造参数来创建你的类。
然后,按照第2步进行。👇
2.使用get_it或Riverpod之类的包
这些包可以让你更好地控制你的依赖关系,这意味着你可以很容易地初始化、访问和处置它们,而不会有上述的任何缺点。
一旦你掌握了这一点,你需要弄清楚不同类型的对象(小部件、控制器、服务、存储库等)之间存在哪些依赖关系。这就导致了第三步。👇
3.选择一个好的应用架构
当构建复杂的应用程序时,选择一个好的应用程序架构,以帮助你。
- 架构你的代码,并在你的代码库增长时支持你的代码库
- 决定不同的对象应该(和不应该)依赖什么
通过遵循这些建议,我使用这个基于Riverpod的参考应用架构,建立了一个中等规模的电子商务应用,具有可测试和可维护的代码,没有创建任何单子。
总结
单元使你很容易访问代码中的依赖关系。但它们产生的问题比它们解决的问题要多。
一个更好的选择是使用**get_it和Riverpod**等经过战斗考验的包来管理依赖关系。
因此,选择一个并在你的应用程序中使用它,以及一个好的架构。通过这样做,你将避免许多陷阱,并最终得到一个更好的代码库。👍
编码愉快!