Dart 和 Flutter 中的不可变数据模式

4,553 阅读23分钟

原文链接: Immutable Data Patterns in Dart and Flutter
作者 : MONTY RASMUSSEN

不可变的数据是指那些在初始化后就不能在修改到数据。不可变数据在 Dart 语言中随处可见。实际上,大多数基本变量类型都是以这种方式运行的。比如字符串、数字类型或者布尔类型,一旦创建就不能再次被修改了。字符串变量本身不包含字符串数据,它只是字符串数据在内存中位置的引用。非 final 类型到字符串所代表到引用能够被重新赋值,可以指向新的字符串数据,但是字符串一旦被创建,字符串数据本身就不会更改,无论是内容或长度:

var str = "This is a string.";
str = "This is another string.";

这段代码声明了一个名为的字符串变量(虽然通过 var 定义,但是可以类型推断得出)str。引号之间到字符数据组成了字符串,数据被放置在内存中,然后这个字符串在内存中到地址被存储到了 str。

第二行创建一个全新的字符串,然后把这个字符串内存位置的引用重新分配给了 str 这个变量,从而覆盖对第一个字符串的引用。但是原始字符串不会更改,如果你的代码中不再使用这个字符串,这个字符串就会被标记为不可访问,最终 Dart 中到垃圾回收器会把这个字符串占用到内存进行回收。

使用不可变数据模式有许多优点,不可变数据模式天生就是是线程安全的,因为一旦创建,就不可以更改其内容,那么无论任何代码访问这个数据,都能保证这个数据是一样的。你可以很安全的使用这个数据通过传递这个数据的引用,也不用再考虑什么保护性拷贝来传递这个数据,防止数据被意外的更改。在项目中使用不可变的数据可让项目变得更加简单,开发更加容易,因为不用再关心数据一致性与数据安全问题了。

我们将通过查看final const关键字来开始讨论 Dart 中内置的数据不变性功能,这是两种声明数据不可变的基本方式。

本文的代码已经过Dart 2.8.4和Flutter 1.17.5的测试。

一、Final 与 Const 声明的常量

对于初学者来说,可能不太好区分 Dart 中 final 与 const 这两个关键字。当我们自己使用 final 和 const 声明常量时,我们自己能够理解这两者的区别并且知道它们的使用场景是非常重要的。

final 声明的变量,只能被初始化一次,并且一旦使用一个值对其进行了初始化,就无法重新分配该变量:

final str = "This is a final string.";
str = "This is another string.";  // error

使用 final 声明的变量 str,一旦被初始化,dart 将不再允许我们对其进行修改(重新赋值)。final 类型变量可能依赖代码的运行来决定其最终的数值,但是其赋值一定是在初始化的时候。除了禁止重新分配外,final 变量在各个方面都类似于常规变量。

Dart中的常量是编译时常量,使用 const 来修饰。常量的数值或者状态在编译期就确定了。常量值不会依赖代码的运行,一旦程序运行起来,常量的就会被冻结起来不能被再次修改了。

Dart 中的常量具有三个主要属性:

  • 常量的不可变性是具有依赖关系的

比如你想要创建一个常量类型的数据结构(list,map 等),那么这个数据结构里面的每一个元素都必须是常量。

  • 常量值必须在编译期就确定。

    例如,DateTime.now()不能声明为常量,因为它依赖于仅在运行时可用的数据来创建自身。

    Flutter中的SizedBox 中的属性都是 final 类型的,其构造函数也是常量类型,因此 SizedBox 可以是一个常量:const SizedBox(width: 10 + 10)。代码中包含构造该实例所需的一切。Dart编译器可以在编译过程中执行简单的数学运算或字符串连接。

  • 常量无法重新创建。

    对于任何给定的常量值,无论常量表达式计算了多少次,都将在内存中创建一个对象。常量对象在需要时可以重用,但从不重新创建。 常量示例:

const str = "This is a constant string.";
const SizedBox(width: 10);  // a constant object
const [1, 2, 3];            // a constant collection
1 + 2;                      // a constant expression

str 常量被分配了一个字符串,该常量始终是编译时常量。
此处创建的 SizedBox 实例可以是不变的,Dart 可以在执行程序之前对其进行设置,因为 SizedBox 的所有属性在内部都是 final 类型的,并且我们使用数值对参数进行了初始化。

常量数据集合也是可以的,只要每个元素也是常量。

表达式1 + 2可以由Dart编译器在执行代码之前计算得出,因此它也可以视为常量。

因为常量是共享的,Dart 中数据比较默认是比较是否是同一个对象,但是两个看似独立的常量实例之间的比较是相等的,因为它们引用内存中的完全相同的对象:

List<int> get list => [1, 2, 3];
List<int> get constList => const [1, 2, 3];
var a = list;
var b = list;
var c = constList;
var d = constList;
print(a == b);  // false
print(c == d);  // true

尽管 a,b,c,d 都指向了内容完全相同的集合,但是只有 const 声明的常量之间的比较才视为相等,是因为,dart 比较的是代表集合的引用,而不是集合中的元素,尽管每次调用 get constList 都返回了一个集合,但是由于这个集合是 const 声明的,实际上 dart 只把这个集合初始化了一次放到了内存中,然后每次都返回相同的引用。

接下来,我们将研究 Flutter 框架如何利用不可变数据。

二、Flutter中的不可变数据

Flutter应用程序在很多地方都可以使用数据不可变模型来提高程序可读性或性能。 Flutter 框架中的很多类都是设计为可以用不可变模式创建,两个常见的示例是 SizedBox 和 Text:

Row(
  children: <Widget>[
    const Text("Hello"),
    const SizedBox(width: 10),
    const Text("Hello"),
    const SizedBox(width: 10),
    const Text("Can you hear me?"),
  ],
)

Row 有五个孩子。当我们使用 const 关键字创建具有 const 构造函数的类的实例时(稍后会详细介绍),这些值在编译时创建,并且每个唯一值仅在内存中存储一次。前两个 Text 实例将解析为对内存中同一对象的引用,两个 SizedBox 实例也是如此。如果要添加 const SizedBox(width: 15),则将为该新值创建一个单独的常量实例。

同样也可用使用 new 关键字来创建实例而不是使用 const,最终的运行效果也是一样的,但是如果你想减少你程序运行占用的内存或者想提高程序的性能,那么你最好还是使用 const。

让我们看另一个Text示例:

final size = 12.0;
const Text(
  "Hello",
  style: TextStyle(
    fontSize: size,    // error
  ),
)

这段代码可就有很多知识点了。

我们正在尝试创建Text的常量实例,但是注意了,这个常量内部的成员也得是常量才行,字符串文字 “hello” 即使我们省略了 const 关键字,但是也是可以正常工作的,同样的,Dart 也会尝试将TextStyle 创建为常量,因为它知道 TextStyle 必须为常量才能成为常量 Text 实例的一部分。但是由于 TextStyle 依赖于变量 size,因此此处不能为常数,直到运行时才具有值。 此处编译者就会提示你,这行代码有问题。要解决此问题,您必须替换 size 为常量引用或直接使用数字,例如 12.0。当然,如果 size 提前用 final 声明 size 也会同样报错。

有时,你需要防止程序中代表状态的数据被意外的改变,接下来我们将要看一下 Dart 语言中是如何保证这点的。

三、创建自己的不可变数据类

创建一个不可变的类,也是非常容易的,只要用 const 声明构造函数,同时变量用 final 修饰即可。

class Employee {
  final int id;
  final String name;
  
  const Employee(this.id, this.name);
}

Employee类具有两个属性,两个属性都声明为final,并且这些属性由构造函数自动初始化。构造函数使用const关键字告诉Dart可以将此类实例化为编译时常量:

const emp1 = Employee(1, "Jon");
var emp2 = const Employee(1, "Jon");
final emp3 = const Employee(1, "Jon");

此处仅创建Employee的一个常量实例,并且为每个变量分配了对其的引用。对于emp1,我们不需要通过 const 来声明构造函数,因为我们已经使用 const 声明了变量引用,就暗示了这个构造函数就是通过 const 修饰的,当然显示指定也是没有问题的。

该emp2变量是一种类型为Employee的常规变量,但我们已为其指定了对不变的常量对象的引用。emp3 变量与 emp2 是相等的,因为他们都不会被赋值成新的引用。无论你怎么使用这两个变量,如何传递它们,你都能肯定,这个对象的 id 就是 1,对象里面的名字就是 “Jon”,如果你去内存中检查这个变量,你会发现始终相同。

注意,将数据类中的 final 类型属性声明为 private 不是常见的做法。它们无法更改,并且通过限制对它们的读取访问通常来说也没有什么意义。当然如果你确实有原因想要屏蔽其他代码对这些属性对访问,或者这个类对内部状态与用户无关,你也可以考虑使用 private 声明。

如果你成功创建了一个数据不可变对类,Dart 中是否有什么办法能够帮助你理解已经成功创建了数据不可变的类了?接着往下看。

1、使用注解

您可以使用 meta 包里面的 @imutable注解来帮助我们分析那些我们想要声明为不可变的类,然后给出警告。

import 'package:meta/meta.dart';
@immutable
class Employee {
  int id;            // not final
  final String name;
  Employee(this.id, this.name);
}

@immutable 注解并没有让你的类不可变,但在这个例子中,你会得到一个警告,指出了一个或多个字段不是 final 类型的。如果用 const 修改构造函数,但是其属性是可变的,同样的会被警告。如果一个类被 @immutable修饰,但是其子类并不是不可变的,那么你同样会收到警告提示。 一些属性类型如果声明为不可变,可能会额外的增加一些复杂性,比如:对象和集合。接下来,我们将研究如何处理这些问题。 不可变类中的复杂对象 如果员工的名字用比字符串更复杂的对象表示,该怎么办?举个例子:

class EmployeeName {
  String first;
  String middleInitial;
  String last;
  EmployeeName(this.first, this.middleInitial, this.last);
}

因此,Employee现在看起来像这样:

class Employee {
  final int id;
  final EmployeeName name;
  const Employee(this.id, this.name);
}

在大多数情况下,员工类的使用方式与以前一样,但有一个主要区别。由于我们尚未将EmployeeName 定义为不可变的类,因此其属性在初始化后可能会发生变化:


var emp = Employee(1, EmployeeName('John', 'B', 'Goode'));
emp.name = EmployeeName('Jane', 'B', 'Badd');  // blocked
emp.name.last = 'Badd';                        // allowed

Employee 中的 name 属性是 final 类型的,因此 Dart 禁止对其重新赋值。但是,EmployeeName 里的属性不是以相同的方式受到保护的,因此允许更改该数据。如果你本意员工数据是不可变的,那这可能是一个意想不到的漏洞。要解决此问题,请确保你所有使用到到类都是不可变的。

2、不变的数据集合

集合同样为数据不可变带来了一个挑战。即使使用了 fianl 修饰了 list 或者 map,但是集合里面的元素还是能够被修改的。此外,Dart中的 list 和 map 本身就是可变的复杂对象,因此他们支持添加,删除或重新排序其元素。 考虑一个使用聊天消息数据的简单示例:

class Message {
  final int id;
  final String text;
  const Message(this.id, this.text);
}
class MessageThread {
  final List<Message> messages;
  const MessageThread(this.messages);
}

这样的类声明,数据是相对安全的。创建的每个消息都是不可变的,同样,当 MessageThread 一旦创建完毕,就不可以修改或者替换 messages 列表内部的元素了。但是,list 集合还是能够被外部代码操纵的:

final thread = MessageThread([
  Message(1, "Message 1"),
  Message(2, "Message 2"),
]);
thread.messages.first.id = 10;                 // blocked
thread.messages.add(Message(3, "Message 3"));  // Uh-oh. This works!

可能不是你的本意。那么如何防止这种情况呢?有以下几个办法可以使用。

返回这个集合的拷贝

如果你不介意使用该集合的可变副本,则可以在每次从类外部访问主列表时使用Dart getter返回主列表的副本:

class MessageThread {
  final List<Message> _messages;
  List<Message> get messages => _messages.toList();
  const MessageThread(this._messages);
}
thread.messages.add(Message(3, "Message 3"));  // new list

使用此MessageThread类,实际的消息列表是私有的。只能通过构造函数设置一次。messages定义了一个名为getter的getter ,它返回_messages列表的副本。当外部代码调用列表的add()方法时,它是在列表的单独副本上执行此操作的,因此原始表不会被修改。虽然返回的副本会新增一条消息数据,但是MessageThread对象中的列表将保持不变。

这种方法很简单,但并非没有缺点。首先,如果列表很大或访问太过频繁,将引起性能问题,因为没有访问 messages 是,都会对列表进行浅拷贝。其次,这会使类的使用者感到困惑,因为它们看上去就像允许他们修改原始列表一样。他们可能还不知道返回对是副本,这可能会导致一些意想不到对事情发生。

返回提示无法修改的集合或视图

防止更改数据类中的集合的另一种方法是使用getter返回无法修改的版本或无法修改的视图:

class MessageThread {
  final List<Message> _messages;
  List<Message> get messages => List.unmodifiable(_messages);
  const MessageThread(this._messages);
}
thread.messages.add(Message(3, "Message 3"));  // exception!

该方法与先前讨论的方法非常相似。依然是返回一个集合的拷贝,但是现在我们返回的副本不可更改。我们使用了工厂方法构造方法来返回一个新的集合。现在,当用户尝试向列表的副本中添加新消息时,在运行时就会引发异常,并阻止修改。这个方法好一点,但仍然存在一些弊端。编译器没有发出警告,调用add() 方法会在运行时失败,并且用户没有被明确告知,他们使用的是一个拷贝而不是直接引用。

我们可以使用dart:collection库中的UnmodifiableListView类对方法进行稍加改进:

import 'dart:collection';
class MessageThread {
  final List<Message> _messages;
  UnmodifiableListView<Message> get messages =>
      UnmodifiableListView<Message>(_messages);
  const MessageThread(this._messages);
}
thread.messages.add(Message(3, "Message 3"));  // exception!

以这种方式执行可能会更好一些,因为 UnmodifiableListView 不会创建原始集合的副本。而是将原始数据包装在防止修改的视图中。不幸的是,这种方式同样的要在运行时才能起作用。尽管仍然存在一些缺点,但是此方法作为解决方案,因为它足以应付许多情况,够用了。

那其他集合类型呢?其他集合(例如Map和Set)也具有unmodifiable() factory构造函数,并且dart:collection库中提供了对应的不可修改的视图提供给我们使用。

当你尝试防止更改集合时,还需要考虑一些其他事项。

3、真正不可变的数据集合

您可能已经注意到,我们使用 getter 返回的是原始数据的拷贝,但是原始数据依然不是不可变的。虽然我们可以使用 private 类型的 _messages 来进一步保证数据安全,但是对于一些处女座开发者来说,可能就是想要原始数据的不可更改。 实现此目的的一种方法是在构造MessageThread对象时创建不可修改的集合版本或视图:

class MessageThread {
  final List<Message> messages;
  const MessageThread._internal(this.messages);
  factory MessageThread(List<Message> messages) {
    return MessageThread._internal(List.unmodifiable(messages));
  }
}

我们需要做的第一件事是从库外部的代码中隐藏常量构造函数。我们将其更改为带下划线前缀的命名构造函数,从而使其私有。该MessageThread._internal()构造函数可以和我们的老默认的构造做了完全一样的工作,但它只能通过内部代码访问。 然后,将默认的公共构造构造函数改为工厂方法构造函数。工厂的工作static方式与方法非常相似,因为工厂必须显式地返回类的实例,而不是像常规构造函数那样自动地返回。这是一个有用的区别,因为在这里,我们需要对传入的消息列表进行调整,然后再准备将其用作final属性的初始化程序。工厂构造函数将传入列表复制到不可修改的列表中,然后再传递给创建实例的私有构造函数。用户没有明智的选择,因为他们以与以往相同的方式创建实例:

final thread = MessageThread([
  Message(1, "Message 1"),
  Message(2, "Message 2"),
]);

这样的代码依然可以工作,并且如果在不看源码的情况下,没人知道他们正在调用工厂构造函数而不是常规的构造函数。顺便说一句,这种解决办法有点像是 Dart 中的单例模式。 现在,存储的 list 集合变得不可修改,即使是同一类或库中的代码也无法更改它。但是,对于大部分 app 来说,证实数据更新才他们的功能变得有意义,那么我们如何安全地更新不可变数据呢?

三、更新不可变数据

将所有应用程序状态安全地保存在不可变的结构中之后,您可能想知道如何更新它。您的类的单个实例不应是可变的(至少从外部),但是其数据状态肯定需要更改。有多种不同的方式实现这个目的,这里只介绍其中的几个。

1、使用顶层函数

更新不可变状态的最常见方法之一是使用某种状态更新功能(库)。在 Redux 里面,用的是 reducer,同样的,当使用 BLoC 模式的时候,里面也有相似的结构。无论状态更新的功能是在哪里实现的,它通常负责获取输入,执行业务逻辑,然后根据输入和旧状态输出新状态。 从最简单的示例开始,让我们看一下前面介绍的不可变Employee 的状态更新功能。需要注意的是,这些功能不是Employee类的一部分:

class Employee {
  final int id;
  final String name;
  const Employee(this.id, this.name);
}
Employee updateEmployeeId(Employee oldState, int id) {
  return Employee(id, oldState.name);
}
Employee updateEmployeeName(Employee oldState, String name) {
  return Employee(oldState.id, name);
}

这是最简单的一种方式,并且可以确保仅完成受支持的更新,这非常有用。基本上,每个函数都引用先前的员工状态,然后使用该数据和新数据构造一个全新的实例,并将其返回给调用方。 这种方式,即使是一个简单的变量更新,同样需要很多的样板代码。

这种方法的另一个缺点是,它在重构中带来了一些困难。如果要添加,删除或更改Employee的任何属性,最终可能需要修改很多地方。

由于更新功能通常是在代码库的完全不同的部分中编写的,因此这种方法倾向于使业务逻辑与数据保持独立。对于某些项目来说,这可能也是一个优势。

2、类方法

如果你希望在状态类中去管理状态,那么你可以使用类方法,而不是使用顶层函数。

class Employee {
  final int id;
  final String name;
  const Employee(this.id, this.name);
  
  Employee updateId(int id) {
    return Employee(id, name);
  }
  Employee updateName(String name) {
    return Employee(id, name);
  }
}

使用这种方法,您可以省去一些冗长的命名,因为很明显每个更新方法都属于Employee类。同样,您不再需要显式传递旧状态,因为它假定当前实例为旧状态。如果不仔细看代码,似乎两个更新方法都具有相同的代码,但是updateId()正在使用传入的id参数和old 创建新的Employee实例name。该updateName()方法正相反。

这种方式的缺点是更新值的逻辑有些固定,直接与状态类绑定。在某些情况下,这可能正是您想要的,而在其他情况下,这两种方法都不重要。

为不可变类中的每个属性创建更新方法可能会很麻烦。接下来,我们将研究一种将功能合并为单个方法的方案。

3、拷贝

在Dart和Flutter项目中使用的具有不变数据的常见的一种做法是在类中添加 copyWith 方法。它可以使您使用的任何策略更简单,更统一:

class Employee {
  final int id;
  final String name;
  const Employee(this.id, this.name);
  Employee copyWith({int id, String name}) {
    return Employee(
      id ?? this.id, 
      name ?? this.name,
    );
  }
}

该copyWith()方法通常应使用没有默认值的命名可选参数。该return语句使用Dart的if null运算符??来确定雇员的副本是应该为每个属性获取一个新值还是保留现有状态的值。如果该方法收到的值id,则不会为null,因此该值将在副本中使用。如果不存在或显式设置为null,this.id则将使用。复制方法非常灵活,允许在单个调用中更新任意数量的属性。

示例用法copyWith():

final emp1 = Employee(1, "Bob");
final emp2 = emp1.copyWith(id: 3);
final emp3 = emp1.copyWith(name: "Jim");
final emp4 = emp1.copyWith(id: 3, name: "Jim");

执行此代码后,该emp2变量将引用emp1具有更新id值的副本,但name不会更改。该emp3副本将有一个新的名字和原来的ID。 emp4复制操作与完全创建一个新对象相同,因为它替换了每个值。 状态更新功能或方法可以利用copyWith()它们来执行任务,从而可以大大简化你的代码:

 Employee updateEmployeeId(Employee oldState, int id) {
  return oldState.copyWith(id: id);
}
Employee updateEmployeeName(Employee oldState, String name) {
  return oldState.copyWith(name: name);
}

你甚至可能认为在这里使用状态更新功能有点过犹不及,因为 copyWith() 方法仅仅是对创建新对象的一个包装。在许多情况下,允许外部代码直接使用复制功能也是可以的,因为没有办法破坏原始对象的数据。 当不可变类的属性也是不可变类时,您可能需要嵌套调用copyWith()以更新嵌套属性。接下来我们讨论这种情况。

4、更新复杂对象的属性

如果您的一个或多个属性也是不可变对象,该怎么办?需要为每个依赖的类实现 CopyWith 方法。

class EmployeeName {
  final String first;
  final String last;
  const EmployeeName({this.first, this.last});
  EmployeeName copyWith({String first, String last}) {
    return EmployeeName(
      first: first ?? this.first,
      last: last ?? this.last,
    );
  }
}
class Employee {
  final int id;
  final EmployeeName name;
  const Employee(this.id, this.name);
  Employee copyWith({int id, EmployeeName name}) {
    return Employee(
      id: id ?? this.id,
      name: name ?? this.name,
    );
  }
}

现在,Employee包含一个EmployeeName类型的属性,并且这两个类都是不可变的,并且具有copyWith()促进更新的方法。使用此设置,如果您需要更新员工的姓氏,则可以执行以下操作:

final updatedEmp = oldEmp.copyWith(
  name: oldEmp.name.copyWith(last: "Smith"),
);

如您所见,为了更新员工的姓氏,必须同时使用两个版本copyWith()。

5、更新集合

如何更新不可变的集合既取决于如何设置集合,又取决于你打算更新到何种地步。 为了简化介绍,我们使用一个简单数据类:

class NumberList {
  final List<int> _numbers;
  List<int> get numbers => List.unmodifiable(_numbers);
  NumberList(this._numbers);
}

此类具有可变列表,但仅将不可修改的一个拷贝副本公开给外界。如何要更新这个列表,可以使用如下方法:

NumberList addNumber(NumberList oldState, int number) {
  final list = oldState.numbers.toList();
  return NumberList(list..add(number));
}

这种方法不是非常有效。该表达式oldState.numbers为我们提供了oldState列表的副本,但是它是不可修改的,因此我们需要使用它toList()来制作另一副本,该副本是可变的。然后,我们创建一个新的NumberList,将添加了新数字的列表副本传递给它。我们使用Dart的级联运算符,..在将其添加到构造函数之前对列表执行添加。 我们可以尝试一种更新方法:

class NumberList {
  final List<int> _numbers;
  List<int> get numbers => List.unmodifiable(_numbers);
  NumberList(this._numbers);
  NumberList add(int number) {
    return NumberList(_numbers..add(number));
  }
}

关于此方法有很多好处。实现不是很复杂,并且需要更少的代码。要注意的一个点是我们对 _numbers 进行类重用。这种方式可以在类内部代码中实现,你可能会对这种方法感到满意,但是对于相等比较可能会产生副作用。

一些状态管理模式会产生状态流。每次创建新状态时(每次更新时),新状态实例都将被馈送到流中并传递给侦听的UI代码。为了获得最大的效率,您可以检查新接收到的状态是否实际上不同于先前的状态。我们add()上面的代码创建NumberList的新实例,而不是一个新的实例_numbers。根据相等性比较的实现方式,比较代码可能会误以为我们会不断产生相同状态,因为存储在其中的列表引用_numbers永远不会改变。 由于这个原因和其他原因,有些人希望每次更改后都重新创建列表:

class NumberList {
  final List<int> _numbers;
  List<int> get numbers => List.unmodifiable(_numbers);
  NumberList(this._numbers);
  NumberList add(int number) {
    return NumberList(_numbers.toList()..add(number));
  }
}

通过添加 toList() 解决该问题,因为它创建的副本_numbers,将新值添加到该副本,然后返回NumberList的新实例,其中包含我们新的更新列表。

四、结论

处理对象和集合不变的方法有很多,现在您应该熟悉 Dart 中防止复杂数据意外被改变的一些方法。虽然我们没有进行代码演示,但是如果您想了解更多有关此的信息,请查看诸如 build_value之类的 Dart 包里的内容来学习更多的东西。

欢迎关注「Flutter 编程开发」微信公众号 。