[Flutter翻译]在Flutter中的单例:如何避免它们,以及该怎么做?

679 阅读11分钟

本文由 简悦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_itRiverpod这样的包让我们对某个实例何时被处置有了更多的控制。

事实上,Riverpod相当聪明,让我们轻松地控制一个提供者的状态的生命周期。

例如,我们可以使用autoDispose修改器来确保我们的HardWorker在最后一个监听器被移除后立即被处置。

final hardWorkerProvider = Provider.autoDispose<HardWorker>((ref) {
  return HardWorker();
});

当我们想要处置一个对象时,这是最有用的,因为使用该对象的widget被卸载

5.线程安全

在多线程语言中,我们需要注意在不同的线程中访问单子,如果它们共享易变的数据,一些同步机制可能是必要的。

但在Dart中,这通常不是一个问题,因为Flutter应用中的所有应用代码都属于主隔离区

不过,如果我们最终创建了单独的隔离体来执行一些繁重的计算,我们就需要更加小心。

image.png 隔离器不应该修改任何可能被保存在单子中的可变数据。

欲了解更多信息,请观看关于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_itRiverpod)。

与基于InheritedWidgetProvider包的解决方案不同,Riverpod提供者生活在widget树之外。这使得他们在编译时可以安全地使用没有运行时的异常。关于这一点的更多信息,请阅读我的Essential Guide to Riverpod

结语

现在我们已经介绍了使用单子的主要缺点和它们的替代品,我想根据个人经验给你留下一些实用的提示。

1.不要创建你自己的单集

除非你是一个包的作者,而且你有充分的理由这样做,否则不要创建你自己的单子。即使你将第三方API作为单体访问,也不要到处使用Singleton.instance,因为这使得你的代码难以测试。

相反,通过传递任何依赖性作为构造参数来创建你的类。

然后,按照第2步进行。👇

2.使用get_it或Riverpod等包

这些包可以让你更好地控制你的依赖关系,这意味着你可以很容易地初始化访问处置它们,而没有上述的任何缺点。

一旦你掌握了这一点,你需要弄清楚不同**种对象(部件、控制器、服务、资源库等)之间存在哪些依赖关系。这就导致了第三步。👇

3.选择一个好的应用程序架构

当构建复杂的应用程序时,选择一个好的应用程序架构,帮助你。

  • 结构化你的代码,并在你的代码库增长时支持你的代码库
  • 决定不同的对象应该(和不应该)依赖什么。

通过遵循这个建议,我建立了一个中等规模的电子商务应用程序,具有可测试的可维护的代码,没有创建任何单子,使用这个基于Riverpod的参考应用程序架构

总结

单元使得在你的代码中访问依赖关系变得容易。但它们产生的问题比解决的要多。

一个更好的选择是使用经过战斗检验的包来管理依赖关系,例如get_itRiverpod

因此,选择一个并在你的应用程序中使用它,以及一个好的架构。通过这样做,你会避免许多陷阱,并最终得到一个更好的代码库。👍

编码愉快!

新的Flutter课程现在可用

我推出了一个全新的课程,它非常深入地涵盖了Flutter应用程序的架构,以及其他重要的主题,如状态管理、导航和路由、测试,以及更多。


www.deepl.com 翻译