快速上手 Flutter 空安全

9,046 阅读6分钟

学习最忌盲目,无计划,零碎的知识点无法串成系统。学到哪,忘到哪,面试想不起来。这里我整理了Flutter面试中最常问以及Flutter framework中最核心的几块知识,欢迎关注,共同进步。image.png 欢迎搜索公众号:进击的Flutter或者runflutter 里面整理收集了最详细的Flutter进阶与优化指南。关注我,探讨你的问题,获取我的最新文章~

导语

在 Flutter 2.0 中,一项重要的升级就是 Dart 支持 空安全Alex 为我们贴心地翻译了多篇关于空安全的文章 :迁移指南深入理解空安全 等,通过 迁移指南 我也将 fps_monitor 迁移空安全。但在对项目适配后,日常开发中我们该怎么使用?空安全究竟是什么?下面我们通过几个练习来快速上手 Flutter 空安全。


一、空安全解决了什么问题?

要想弄明白空安全是什么,我们先要知道空安全帮我们解决了什么?

先来看个例子

void main() {
  String stringNullException;
  print(stringNullException.length);
}

在适配空安全之前,这段代码在 在编译阶段不会有任何提示。但显然这是一段有问题的代码。在 Debug 模式下会抛出空异常,屏幕爆红提示。

I/flutter (31305): When the exception was thrown, this was the stack:
I/flutter (31305): #0      Object.noSuchMethod (dart:core-patch/object_patch.dart:53:5)

在 release 模式下,这个异常会让整个屏幕变成灰色。

这是一个典型的例子,stringNullException 在没有赋值的情况下是空的,但是却我们调用了 .length 方法,导致程序异常。

同样的代码在适配空安全之后,在编译期便给出了报错提示,开发者可以及时进行修复。

image.png

所以简单的来说,空安全在代码编辑阶段帮助我们提前发现可能出现的空异常问题,但这并不意味着程序不会出现空异常


二、如何使用空安全?

那么空安全包含哪些内容,我们在日常开发的时候该如何使用?下面我们通过 Null safety codelab 中的几个练习来进行学习。

1、非空类型和可空类型

在空安全中,所有类型在默认情况下都是非空的。例如,你有一个 String 类型的变量,那么它应该总是包含一个字符串。

如果你想要一个 String 类型的变量接受任何字符串或者 null,通过在类型名称后添加一个问号(?)表示该变量可以为空。例如,一个类型为 String? 可以包含任何字符串,也可以为空。

练习 A:非空类型和可空类型

void main() {
  int a;
  a = null; // 提示错误,因为 int a 表示 a 不能为空
  print('a is $a.');
}

这段代码通过 int 声明了变量 a 是一个非空变量,在执行 a = null 的时候报错。可以修改为 int? 类型,允许 a 为空:

void main() {
  int? a; // 表示允许 a 为空
  a = null; 
  print('a is $a.');
}

练习 B:泛型的可空类型

void main() {
  List<String> aListOfStrings = ['one', 'two', 'three'];
  List<String> aNullableListOfStrings = [];
  // 报错提示,因为泛型 String 表示非 null
  List<String> aListOfNullableStrings = ['one', null, 'three']; 

  print('aListOfStrings is $aListOfStrings.');
  print('aNullableListOfStrings is $aNullableListOfStrings.');
  print('aListOfNullableStrings is $aListOfNullableStrings.');
}

在这个练习中,因为 aListOfNullableStrings 变量的类型是 List<String> ,表示非空的 String 数组,但在后面创建过程中却提供了一个 null 元素,引起报错。因此可以将 null 改成其他字符串,或者在泛型中表示为可空的字符串。

void main() {
  List<String> aListOfStrings = ['one', 'two', 'three'];
  List<String> aNullableListOfStrings = [];
  // 数组元素允许为空,所以不再报错
  List<String?> aListOfNullableStrings = ['one', null, 'three'];

  print('aListOfStrings is $aListOfStrings.');
  print('aNullableListOfStrings is $aNullableListOfStrings.');
  print('aListOfNullableStrings is $aListOfNullableStrings.');
}

2、空断言操作符(!)

如果确定某个 可为空的表达式 非空,可以使用空断言操作符 ! 使 Dart 将其视为非空。通过添加 ! 在表达式之后,可以将其赋值给一个非空变量。

练习 A:空断言

/// 这个方法的返回值可能为空
int? couldReturnNullButDoesnt() => -3;

void main() {
  int? couldBeNullButIsnt = 1;
  List<int?> listThatCouldHoldNulls = [2, null, 4];

  // couldBeNullButIsnt 变量虽然可为空,但是已经赋予初始值,因此不会报错
  int a = couldBeNullButIsnt;
  // 列表泛型中声明元素可为空,与 int b 类型不匹配报错
  int b = listThatCouldHoldNulls.first; // first item in the list
  // 上面声明这个方法可能返回空,而 int c 表示非空,所以报错
  int c = couldReturnNullButDoesnt().abs(); // absolute value

  print('a is $a.');
  print('b is $b.');
  print('c is $c.');
}

在这个练习中,方法 couldReturnNullButDoesnt 和数组 listThatCouldHoldNulls 都通过可空类型进行声明,但是后面的变量 b 和 c,都是通过非空类型来声明,因此报错。可以在表达式最后加上 ! 表示操作非空(你必须确认这个表达式是一定不会为空,否则仍然可能引起空指针异常)修改如下:

int? couldReturnNullButDoesnt() => -3;

void main() {
  int? couldBeNullButIsnt = 1;
  List<int?> listThatCouldHoldNulls = [2, null, 4];

  int a = couldBeNullButIsnt;
  // 添加 ! 断言 表示非空,赋值成功
  int b = listThatCouldHoldNulls.first!; // first item in the list
  int c = couldReturnNullButDoesnt()!.abs(); // absolute value

  print('a is $a.');
  print('b is $b.');
  print('c is $c.');
}

3、类型提升

Dart 的 流程分析 中已经扩展到考虑零值性。不可能为空的可空变量会被视为非空变量,这种行为称为类型提升

bool isEmptyList(Object object) {
  if (object is! List) return false;
  // 在空安全之前会报错,因为 Object 对象并不包含 isEmpty 方法
  // 在空安全后不报错,因为流程分析会根据上面的判断语句将 object 变量提升为 List 类型。
  return object.isEmpty; 
}

这段代码在空安全之前会报错,因为 object 变量是 Object 类型,并不包含 isEmpty 方法。

在空安全后不会报错,因为流程分析会根据上面的判断语句将 object 变量提升为 List 类型。

练习 A:明确地赋值

void main() {
  String? text;
  //if (DateTime.now().hour < 12) {
  //  text = "It's morning! Let's make aloo paratha!";
  //} else {
  //  text = "It's afternoon! Let's make biryani!";
  //}
  print(text);
  // 报错提示,text 变量可能为空
  print(text.length);
}

这段代码中我们使用 String? 声明了一个可空的变量 text,在后面直接使用了 text.length。Dart 会认为这是不安全的,因此报错提示。

但当我们去掉上面注释的代码后,将不会在报错。因为 Dart 对 text 赋值的地方判断后,认为 text 不会为空,将 text 提升为非空类型(String),不再报错。

练习 B:空检查

int getLength(String? str) {
  // 此处报错,因为 str 可能为空
  return str.length;
}

void main() {
  print(getLength('This is a string!'));
}

这个例子中,因为 str 可能为空,所以使用 str.length 会提示错误,通过类型提升我们可以这样修改:

int getLength(String? str) {
  // 判断 str 为空的场景 str 提升为非空类型
  if (str == null) return 0;
  return str.length;
}

void main() {
  print(getLength('This is a string!'));
}

提前判断 str 为空的场景,这样后面 str 的类型由 String?(可空)提升为 String(非空),不再报错。

3、late 关键字

有时变量(例如:类中的字段或顶级变量)应该是非空的,但不能立即给它们赋值。对于这种情况,使用 late 关键字。

当你把 late 放在变量声明的前面时,会告诉 Dart 以下信息:

  • 先不要给变量赋值。
  • 稍后将为它赋值
  • 你会在使用前对这个变量赋值。
  • 如果在给变量赋值之前读取该变量,则会抛出一个错误。

练习 A:使用 late

class Meal {
  // description 变量没有直接或者在构造函数中赋予初始值,报错
  String description;

  void setDescription(String str) {
    description = str;
  }
}
void main() {
  final myMeal = Meal();
  myMeal.setDescription('Feijoada!');
  print(myMeal.description);
}

这个例子中,Meal 类包含一个非空变量 description,但该变量却没有直接或者在构造函数中赋予初始值,因此报错。这种情况下,我们可以使用 late 关键字 表示这个变量是延迟声明:

class Meal {
  // late 声明不在报错
  late String description;
  void setDescription(String str) {
    description = str;
  }
}
void main() {
  final myMeal = Meal();
  myMeal.setDescription('Feijoada!');
  print(myMeal.description);
}

练习 B:循环引用下使用 late

class Team {
  // 非空变量没有初始值,报错
  final Coach coach;
}

class Coach {
  // 非空变量没有初始值,报错
  final Team team;
}

void main() {
  final myTeam = Team();
  final myCoach = Coach();
  myTeam.coach = myCoach;
  myCoach.team = myTeam;

  print('All done!');
}

通过添加 late 关键字解决报错。注意,我们不需要删除 final。late final 声明的变量表示:只需设置它们的值一次,然后它们就成为只读变量

class Team {
  late final Coach coach;
}

class Coach {
  late final Team team;
}

void main() {
  final myTeam = Team();
  final myCoach = Coach();
  myTeam.coach = myCoach;
  myCoach.team = myTeam;
  print('All done!');
}

练习 C:late 关键字和懒加载

int _computeValue() {
  print('In _computeValue...');
  return 3;
}

class CachedValueProvider {
  final _cache = _computeValue();
  int get value => _cache;
}

void main() {
  print('Calling constructor...');
  var provider = CachedValueProvider();
  print('Getting value...');
  print('The value is ${provider.value}!');
}

这个练习并不会报错,不过可以看看运行这段代码的输出结果:

Calling constructor...
In _computeValue...
Getting value...
The value is 3!

在打印完第一句 Calling constructor... 之后,生成 CachedValueProvider() 对象。生成过程会初始化它的变量 final _cache = _computeValue() 所以打印第二句话 In _computeValue...,再打印后续的语句。

当我们对 _cache 变量添加 late 关键字后,结果又如何?

int _computeValue() {
  print('In _computeValue...');
  return 3;
}

class CachedValueProvider {
  // late 关键字,该变量不会在构造的时候初始化
  late final _cache = _computeValue();
  int get value => _cache;
}

void main() {
  print('Calling constructor...');
  var provider = CachedValueProvider();
  print('Getting value...');
  print('The value is ${provider.value}!');
}

日志如下:

Calling constructor...
Getting value...
In _computeValue...
The value is 3!

日志中In _computeValue... 的执行被延后了,其实就是 _cache 变量没有在构造的时候初始化,而是延迟到了使用的时候。


四、空安全并不意味没有空异常

这几个练习,也更加的反应了安全的作用:空安全在代码编辑阶段帮助我们提前发现可能出现的空异常问题。但要注意,这并不意味着不存在空异常。例如下面的例子

void main() {
  String? text;
  print(text);
  // 不会报错,因为使用 ! 断言 表示 text 变量不可能为空
  print(text!.length);
}

因为 text!.length 表示变量 text 不可能为空。但实际上 text 可能因为各种原因(例如,json 解析为 null)为空,导致程序异常。

上面 late 关键字的场景同样也会存在:

class Meal {
  // late 声明编辑阶段将不会报错
  late String description;
  void setDescription(String str) {
    description = str;
  }
}
void main() {
  final myMeal = Meal();
  // 先去读取这个未初始化变量,导致异常
  print(myMeal.description);
  myMeal.setDescription('Feijoada!');
}

我们在对 description 赋值之前提前读取,同样会导致程序异常。

所以还是那句话:空安全只是在代码编辑阶段帮助我们提前发现可能出现的空异常问题,但这并不意味着程序不会出现空异常。开发者任需要对代码进行完善的边界判断,确保程序的健壮运行!

看到这儿给大家留个作业,如何在空安全下写工厂单例,欢迎在评论区留下你的答案,我会在下周公布答案~。

如果你还想了解更多关于空安全的文章,推荐:


五、最后 感谢各位吴彦祖和彭于晏的点赞和关注

感谢 Alex 在空安全文档上的贡献。

image.png

我近期也将翻译:Null safety codelab 欢迎关注。

如果你对 Flutter 其他内容感兴趣,推荐阅读往期精彩文章:

ListView流畅度翻倍!!Flutter卡顿分析和通用优化方案 将在本月内进行开源,欢迎关注

Widget、Element、Render树究竟是如何形成的?

ListView的构建过程与性能问题分析

深度分析·不同版本中的 Flutter 生命周期差异

欢迎搜索公众号:进击的Flutter或者runflutter 里面整理收集了最详细的Flutter进阶与优化指南。关注我,探讨你的问题,获取我的最新文章~