本文由 简悦SimpRead 转码,原文地址 codewithandrea.com
Flutter中的单子介绍:它们解决了哪些问题,引入了哪些其他问题,......
在软件开发社区,单子是一个非常有争议和争论的话题。
- 有些人说你应该不惜一切代价避免它们。❌
- 其他人则更务实,只在特定情况下使用它们。🔍
- 有些人随意使用它们,就像没有明天一样。😅
为了让大家更清楚地了解情况,本文将介绍以下内容。
- Dart/Flutter中的单子介绍
- 它们解决了什么问题
- 它们带来了哪些其他问题以及如何克服这些问题
- 单元的替代方法
到最后,你会更好地理解为什么单子会使你的代码缺乏可维护性和可测试性,以及你可以做什么来替代。
准备好了吗?
什么是单例?
根据维基百科上的这个页面。
单身模式是一种软件设计模式,它将一个类的实例化限制在一个 "单一 "的实例中。
该页面还说,单子模式通过允许它解决了一些问题。
- 确保一个类只有一个实例。
- 轻松地访问一个类的唯一实例
- 控制其实例化
- 限制实例的数量
- 访问一个全局变量
换句话说,单子模式确保只创建一个类的实例,使其很容易被作为全局变量访问。
如何在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插件都使用了单子模式。而调用它们的方法的唯一方法是使用instancegetter。
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应用程序有深度嵌套的小部件树。因此,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());
// crete 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. Singletons是隐式依赖
让我们回顾一下前面的例子。
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.Lazy Initialization
初始化某些对象可能很昂贵。
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中,所有的全局变量默认都是懒加载的(对于静态类变量也是如此)。这意味着它们只有在第一次使用时才会被初始化。另一方面,本地变量一经声明就被初始化,除非它们被声明为 "晚"。
作为一种选择,我们可以使用诸如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和事件循环的视频。
单子的替代品
在回顾了使用singletons的主要缺点后,让我们看看有哪些替代方案很适合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类,并在深度嵌套的widgets(或代码的其他地方)中使用它?
使用get_it作为服务定位器
如果我们使用get_it包,我们可以在应用程序启动时将我们的类注册为一个懒加载的单例。
void main() {
// GetIt本身是一个单例,更多信息见下面的注释
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树之外。这使得他们在编译时可以安全地使用,没有运行时的异常。关于这一点的更多信息,请阅读我的Essential Guide to Riverpod。
结语
现在我们已经介绍了使用单子的主要缺点和它们的替代品,我想根据个人经验给你留下一些实用的提示。
1.不要创建你自己的单集
除非你是一个包的作者,而且你有充分的理由这样做,否则不要创建你自己的单子。即使你将第三方API作为单体访问,也不要到处使用Singleton.instance,因为这使得你的代码难以测试。
相反,通过传递任何依赖性作为构造参数来创建你的类。
然后,按照第2步进行。👇
2.使用get_it或Riverpod等包
这些包可以让你更好地控制你的依赖关系,这意味着你可以很容易地初始化、访问和处置它们,而没有上述的任何缺点。
一旦你掌握了这一点,你需要弄清楚不同**种对象(部件、控制器、服务、资源库等)之间存在哪些依赖关系。这就导致了第三步。👇
3.选择一个好的应用程序架构
当构建复杂的应用程序时,选择一个好的应用程序架构,帮助你。
- 结构化你的代码,并在你的代码库增长时支持你的代码库
- 决定不同的对象应该(和不应该)依赖什么。
通过遵循这个建议,我建立了一个中等规模的电子商务应用程序,具有可测试的和可维护的代码,没有创建任何单子,使用这个基于Riverpod的参考应用程序架构。
总结
单元使得在你的代码中访问依赖关系变得容易。但它们产生的问题比解决的要多。
一个更好的选择是使用经过战斗检验的包来管理依赖关系,例如get_it和Riverpod。
因此,选择一个并在你的应用程序中使用它,以及一个好的架构。通过这样做,你会避免许多陷阱,并最终得到一个更好的代码库。👍
编码愉快!
新的Flutter课程现在可用
我推出了一个全新的课程,它非常深入地涵盖了Flutter应用程序的架构,以及其他重要的主题,如状态管理、导航和路由、测试,以及更多。