目录

空安全:常见问题

此处涵盖了一些我们在迁移 Google 内部代码至 空安全 时遇到的常见问题。

在迁移代码时,我应该注意哪些运行时的改动?

在空安全迁移中的大部分影响,不会立刻出现在刚刚迁移完成的开发者身上:

  • 静态的空安全检查,会在开发者完成迁移后立刻生效。

  • 完整的空安全检查,只会在所有代码都已迁移,并且启用了完整的空安全模式时生效。

但是有两项例外你需要注意:

  • 对于任何模式而言,! 操作符都是在运行时进行的空检查。所以在进行迁移时,请确保你仅对 null 可能由混合模式造成污染的代码位置添加 !,就算发起调用的代码还未迁移至空安全,也应如此。

  • late 会在运行时检查。所以请你仅在确定它被使用前一定会被初始化的情况下使用 late

如果在测试中的值始终为 null 应该怎样处理?

如果在测试中某个值始终为 null,可以通过将测试传值和测试需要的值改为非空,来改进你的测试。

新的 required@required 关键词有何异同?

@required 注解会将参数标记为必须传递。如果未传,分析器会给出一个提示。

有了空安全,非空类型的命名参数要么需要默认值,要么需要使用 required 关键字修饰。否则,它在未传递时默认会是 null,就显得不合理了。

在旧的代码中,required 关键词会被看作 @required 注解,即参数未传递时会显示一个分析器提示。

当你在空安全的代码中使用空安全代码时,如果 required 修饰的参数未传递,会显示一个错误。

那么它对于迁移来说意味着什么呢?当你给以前没有使用 @required 注解的参数加上 required 时要十分小心。任何没有传递新需要的参数的代码,都无法进行编译。实际上,你可以加上默认值,或是将参数变为可空类型。

我应该如何迁移应该为 final 而目前并不是的字段?

一些赋值计算可以移动到静态的初始化中。与其使用下面的方式:

// Initialized without values
ListQueue _context;
Float32List _buffer;
dynamic _readObject;

Vec2D(Map<String, dynamic> object) {
  _buffer = Float32List.fromList([0.0, 0.0]);
  _readObject = object['container'];
  _context = ListQueue<dynamic>();
}

你可以这样做:

// Initialized with values
final ListQueue _context = ListQueue<dynamic>();
final Float32List _buffer = Float32List.fromList([0.0, 0.0]);
final dynamic _readObject;

Vec2D(Map<String, dynamic> object) : _readObject = object['container'];

然而,在构造函数中通过计算进行初始化的字段,是无法为 final 的。在使用空安全的时候,你会发现想让它们的类型为非空,也不是一件容易的事。如果初始化的时机不合适,那么直到初始化前,它都只能是可空的类型。幸运的是,你还有其他选择:

  • 将构造转变为工厂方法,并将其委托给一个直接初始化所有字段的真正的构造函数。在 Dart 中,这样的私有构造通常是一个下划线:_。如此一来,字段就可以是 final 且非空了。在空安全迁移介入 之前,你就可以这样进行调整。

  • 或者,将字段标记为 late final。这会使得字段只被初始化一次。在它被读取之前必须被初始化。

我应该如何迁移 built_value 类?

使用了 @nullable 注解的 getter 应当直接转变为可空的类型,然后移除所有的 @nullable 注解。例如:

@nullable
int get count;

变成

int? get count; //  Variable initialized with ?

就算迁移工具建议,没有 使用 @nullable 注解的 getter 也不应该是可空的类型。这时可以根据需要添加 ! 操作符,并且重新进行分析。

我应该如何迁移可能返回 null 的工厂方法?

优先使用不返回 null 的工厂方法。我们看到了很多代码,本意是想在调用不正确时抛出一个异常,但最终却返回了空。

与其这样写:

factory StreamReader(dynamic data) {
  StreamReader reader;
  if (data is ByteData) {
    reader = BlockReader(data);
  } else if (data is Map) {
    reader = JSONBlockReader(data);
  }
  return reader;
}

不如这样:

factory StreamReader(dynamic data) {
  if (data is ByteData) {
    // Move the readIndex forward for the binary reader.
    return BlockReader(data);
  } else if (data is Map) {
    return JSONBlockReader(data);
  } else {
    throw ArgumentError('Unexpected type for data');
  }
}

如果一个工厂方法的初衷就是可能返回空值,将其转为可返回 null 的静态方法是更好的选择。

我应该如何迁移现在提示无用的 assert(x != null)

对于完全迁移的代码而言,这个断言是不必要的,但是如果你希望保留该检查,那么它 也需要 留下。几种方式可供你选择:

  • 确定是否真的需要这个断言,然后将其删除。当断言启用时,这是一种行为上的变更。

  • 确定断言始终会被检查,接着将其转换为 ArgumentError.checkNotNull。当断言未启用时,这是一种行为上的变更。

  • 通过添加 //ignore: unnecessary_null_comparison 来绕过警告并且保持原有的行为。

我应该如何迁移现在提示不必要的运行时空判断?

如果 arg 为非空时,编译器会在运行时将显式空安全判断标记为非必要。

if (arg == null) throw ArgumentError(...)`

混合模式下的程序必须包含这样的判断。在所有代码都迁移且运行在完全的空安全模式下前, arg 仍然可能为 null

保留这项行为的最简单的方法是将判断改为 ArgumentError.checkNotNull

运行时的检查同样适用。如果 arg 指定了静态类型为 String,那么 if (arg is! String) 实际上是在检查 arg 是否为 null。尽管代码在迁移到空安全后,arg 应该是永远不为 null 的,但是在不完全的空安全中它仍有可能为 null 的。

Iterable.firstWhere 方法不再接受 orElse: () => null

导入 package:collection package 并使用 firstWhereOrNull 扩展方法代替 firstWhere

我应该如何处理有 setters 的属性?

与上文说到的 late final 的建议不同的是,这些字段不能被标记为终值。通常,可被修改的属性也没有初始值,因为它们可能会在稍后才被赋值。

在这样的情况下,你有两种选择:

  • 为其设置初始值。通常情况下,初始值未被设置是无意的错误,而不是有意为之。

  • 如果你 确定 这个属性需要在访问之前被赋值,将它标记为 late

    注意late 关键词会在运行时添加检查。如果在 set 之前调用了 get,会在运行时抛出异常。

我需要怎样标记映射的返回值为非空类型?

映射类型的 查询操作符 ([]) 返回的值默认是可空类型。此处没有办法告诉 Dart ,返回的值一定是非空的。

在这种情况下,你应该使用强制非空操作符 (!) 将可空的类型转为非空 (V)。

return blockTypes[key]!;

如果 map 返回了 null,则会抛出异常。如果你希望手动处理这些情况:

var result = blockTypes[key];
if (result != null) return result;
// Handle the null case here, e.g. throw with explanation.

为什么我的 List/Map 中的泛型是可空的?

下面这样以可空内容结尾的代码是一种典型的代码异味:

List<Foo?> fooList; // fooList can contain null values

它隐含了 fooList 可能包含空值的信息。在你以长度初始化列表并循环填入值时,这种情况可能会出现。

如果你仅仅想要以相同的值初始化列表,你应该使用 filled 构造。

_jellyCounts = List<int?>(jellyMax + 1);
for (var i = 0; i <= jellyMax; i++) {
  _jellyCounts[i] = 0; // List initialized with the same value
}
_jellyCounts = List<int>.filled(jellyMax + 1, 0); // List initialized with filled constructor

如果你需要通过索引来设置元素,或者使用不同的值填充每个元素,则应该使用列表的字面量表达式来构建列表。

_jellyPoints = List<Vec2D?>(jellyMax + 1);
for (var i = 0; i <= jellyMax; i++) {
  _jellyPoints[i] = Vec2D(); // Each list element is a distinct Vec2D
}
_jellyPoints = [
  for (var i = 0; i <= jellyMax; i++)
    Vec2D() // Each list element is a distinct Vec2D
];

默认的 List 构造有什么改动?

你可能会遇到这样的错误:

The default 'List' constructor isn't available when null safety is enabled. #default_list_constructor

默认的列表构造会将列表用 null 填充,会造成问题。

将它变为 List.filled(length, default) 即可。

我在迁移使用了 package:ffi 的代码的时候遇到了 Dart_CObject_kUnsupported 的错误。发生了什么?

通过 ffi 发送的列表只能是 List<dynamic>,而不能是 List<Object>List<Object?>。就算你未在迁移过程中手动更改类型,类型也可能会被改变,因为启用空安全后,类型推导推算出了这样的结果。

手动创建 List<dynamic> 类型的列表可以解决这个问题。

为什么迁移工具在我的代码中添加了注释

空安全模式下,在当某个表达式的结果一定为 false 或 true 的时候,迁移工具会自动添加 /* == false */ 或者 /* == true */ 这样的注释。这样的注释将会导致自动迁移出现错误,并且需要人工干预。例如:

if (registry.viewFactory(viewDescriptor.id) == null /* == false */)

在这些情况下,迁移工具无法区分防御性编码情况或是确实需要空值的情况。那么该工具会告诉你「这看起来永远为 false!」并让你进行决定。

关于编译为 JavaScript 时的空安全我应该知道什么?

空安全带来了代码体积减小及性能提升等优化。表面上 Flutter 编译为原生端的构建的优化会更加明显,例如 AOT。我们先前已经在 Web 的生产构建器上已经引入了一些类似空安全的优化。所以,Web 应用上的变化可能并不如原生端明显。

依然有几点值得注意:

  • 生产环境下的 JavaScript 编译器会生成 ! 空断言,在比较输出的时候你可能不会注意到它。这是因为编译器已支持了对空值进行检查。

  • 无论是健全或非健全的安全,又或是不同的优化等级,编译器都会生成这些空断言,实际上在使用 -O3--omit-implicit-checks 时编译器都不会移除 !

  • 生产环境下的 JavaScript 编译器可能会移除没有必要的空检查,一般会发生在生产环境下的 Web 编译器在空安全之前做了一些优化,当它知道值不为空的时候,就删除了这些检查。

  • 默认情况下,编译器会生成参数子类型的检查。(用于确保协变的虚拟调用使用了合适的参数的运行时检查)。与先前相同,使用 --omit-implicit-checks 编译器会省略它们。回想一下,如果类型无效,这个开关会让程序出现异常,因此我们依然建议代码测试覆盖率尽可能地高,以避免任何事故。特别是编译器会基于传入值符合类型声明这一条件来优化代码。如果代码提供了无效类型的参数,优化将不是正确的,导致程序异常。这对于之前的类型不一致是正确的,对于现在健全的空安全的可空性不一致也是如此。

  • 你可能会注意到开发版的 JavaScript 编译器和 Dart VM 对于空检查的错误有比较特殊的错误提示,但是为了保持应用的轻量体积,生产环境下的编译器并没有这样的提示。

  • 你可能会看到 .toStringnull 上未找到的错误。这不是一个 bug,是编译器一直以来添加的空检查。编译器会通过对接收者对象属性的访问来压缩一些空检查。它生成的是 a.toString 而不是 if (a == null) throw。在 JavaScript 对象中定义的 toString 方法可以快速验证对象是否可空。

    如果空检查后的第一个行为是当值为空时会崩溃,编译器可以删除空检查并让动作抛出错误。

    例如,print(a!.foo()); 语句可以直接转换为:

      P.print(a.foo$0());
    

    这是因为调用 a.foo$() 会在 a 为空时崩溃。如果 dart2js 内联 foo,它将保留空检查。例如,如果 fooint foo() => 1;,编译器可能会生成:

      a.toString;
      P.print(1);
    

    如果内联方法做的第一件事是对接收者的字段访问,例如 int foo() => this.x + 1;,那么 dart2js 可以再次删除多余的 a.toString 空检查,就像非内联调用一样生成:

      P.print(a.x + 1);
    

资源