深入理解Dart空安全

2021-08-17 10:13:21 浏览数 (1)

点击上方蓝字,发现更多精彩

导语

最近在迁移司内项目至空安全的过程中,深入研究了Dart的空安全特性。这项特性不仅能让开发者在编译阶段发现代码中存在的空指针异常,也能提升程序的运行效率。下面将从静态分析的角度讲一讲Dart如何对空安全特性进行支持、新旧版本之间的编码差异、如何迁移旧项目至空安全以及整个迁移原理做详细说明。

一、引入空安全

1.1 什么是空安全特性

Dart 语言在版本 2.12 中引入一项叫做空安全的新特性,在空安全版本下,运行时的NPE(NullPointer Exception)异常被提前到了开发阶段。

比如下面这个例子,在未引入空安全以前,是可以编译通过的;而引入了空安全以后,IDE编译器的静态检查阶段就能分析出该变量未被初始化,这样以致于不会把异常抛到运行时。

1.2 为什么要使用空安全

  • 更安全

有Java编码经验的应该都知道,Java在编写的时候经常会遇到NPE(NullPointerException)问题。相比Java,Kotlin的最大优点之一就是可以避免NPE问题,而Kotlin能避免空指针问题的本质就是Kotlin对类型系统进行了可空非空的划分。有了这个类型划分之后,每当定义一个非空变量但是没有进行初始化编译器就会提示报错,只有延迟初始化或者立即初始化报错才会消失;而当定义了一个可空变量,IDE会提示需要进行判空处理,这样一来就能有效解决空指针异常的问题了。

Dart的空安全本质和Kotlin是一样的,在未开启空安全之前,定义了一个变量,没有经过初始化就直接使用,编译器是无法检测到的,一旦使用了这个未初始化的变量就会在运行时抛出异常;而启用空安全版本之后,这些异常在开发阶段就能很好地提醒开发者,大大降低了运行时的空指针异常。

  • 生成更小、更快的代码

健全的空安全使得Dart的类型系统更加丰富,而Dart编译器也能基于健全的空安全来生成更快、更小的代码。

代码语言:javascript复制
int getAge(Animal a){  return a.age;}

比如上面这个Dart代码,在Dart2.0版本下通过一次AOT编译,可以生成如下10条机器指令,蓝色部分是该方法的开头和结尾(用于设置和恢复堆栈),红色部分执行空值检查,为空则跳转到helper 。

如果是在Dart2.12版本下通过一次AOT编译,生成的指令减少了3条,主要减少的就是空检查部分的指令。借助健全空安全,可以将此方法生成的代码减少到最少,不需要运行时检查和额外修补代码,更多的处理发生在编译时,最终得到了运行时更小、更快的代码,对性能提升帮助很大。

二、理解Dart的空安全原理

2.1 类型体系的改变

如下图所示:在空安全推出之前,静态类型系统允许所有类型的值为null,因为 Null 是所有类型的子类。

图摘自Understanding null safety

因此在变量没有被初始化的时候,变量的默认值是 null

代码语言:javascript复制
void main() {  ///未启用空安全  int a;  print(a); //null}

而在Dart空安全版本中,所有类型变成了默认不可空类型,Null不再是所有类型的子类,Null变成了和其他类型并行的类。

图摘自Understanding null safety

这时候如果我们在没有初始化变量的情况下使用这个变量,就会报编译检查的错误。比如下面这个例子, inta; 声明语句告诉编译器该变量不能为空,而却在后面使用了没有被赋值的 a,此时编译检查出错,

在类型体系发生了变化之后,如果我们要使用一个可以为空的 int变量,需要添加一个 ?标记,告诉编译器这个变量可以接收的变量是 int 或者 Null 类型。

2.2 静态检查分析

Dart2.0版本中通过使用静态检查运行时检查来保证类型安全。静态检查使用Dart的静态分析器在编译时找到错误,而空安全在编译时的错误提醒也是借助于静态分析器实现的。

查看SDK源码可以发现,Dart在对变量是否为空进行推断的时候,是将代码转换为一个可空推断图,然后对其进行可达性分析。分析代码中的所有流程控制语句,如果变量在控制流程中的每条路径都被明确赋值,则认为该变量是非空的,反之则将变量推断为可空类型的。对于 int型变量,可空 int?和非空 int是两个不同类型,定义的类型和推测的类型不符合则会报编译错误。

  • pkg/nnbd_migration/lib/src/nullability_node.dart
代码语言:javascript复制
/// 可空推断图class NullabilityGraph {  final NullabilityMigrationInstrumentation? instrumentation;  ///一个可空节点  final NullabilityNode always;  ///一个非空节点  final NullabilityNode never;  /// Set containing all sources being migrated.  final _sourcesBeingMigrated = <Source>{};  /// Set containing paths to all sources being migrated.  final _pathsBeingMigrated = <String>{};  /// 图中的所有节点  final Set<NullabilityNode?> nodes = {};  NullabilityGraph({this.instrumentation})      : always = _NullabilityNodeImmutable('always', true),        never = _NullabilityNodeImmutable('never', false);}

比如下面这个例子,静态分析在语句 print(b);提示错误,我们按照源码的思路将其转换为一个可达性分析图。由于 inta=1;语句被明确赋了值,所以 a的类型是非空的, intb;没有被赋值,所以暂时被推断为可空的。接着进入 if流程,会出现两条分支,一条分支 b

被赋了值,所以 b被推断为非空的,另一条没有被赋值, b依然是可空类型,最后 print(b);语句对 b 进行使用,它就会检查该节点中 b的类型,发现此时 b 的类型是与定义时候不符合,此时就会提示编译错误。

代码语言:javascript复制
///引入空安全void main() {  int a=1;  int b;  if(a>2){    b=3;  }  print(b); //编译出错}

2.3 编码时的流程分析

空安全特性依赖于更强的流程分析,而流程分析会对编码做出更加严格的限制。比如下面几点改变:

  • 非空函数必须有返回值

在引入空安全以前的 Dart 中,如下的代码是可以通过编译的,编译器将为程序自动的返回 Null。

代码语言:javascript复制
///引入空安全以前String foo(){}

那么在编写复杂代码的时候,就很容易出现如以下代码情况:

代码语言:javascript复制
///引入空安全以前String foo(int a){  if(a==1){    return "1";  }else if(a==2){    return "2";  }}

上面这段代码出现了没有返回值的情况,很容易使得程序在运行时发生异常。而在启用空安全的 Dart 中这段代码不能通过编译检查,减少了开发者容易发生错误的情况。

  • 非空变量必须被赋值

和上面一个例子类似,在编写一些 ifelse 的情况下容易忽略某些变量在某个分支未被初始化的情况。例如如下代码,开发者可能会忘记给不满十八岁的用户赋值,可能会在运行时出现空指针异常 。在启用空安全的 Dart 中则会提示下这段代码是无法通过编译的,变量 law 一定要在所有控制流程分支中被赋值。

代码语言:javascript复制
String calcType(int age){  String law;  if(age>=18){    law = "good";  }  return law;}

三、编码差异

3.1 空安全的基本使用

为了实现空安全,Dart 新增了一些语法分别是:?!laterequired ,下面来看具体如何使用这些符号。

3.1.1 空类型声明符 ?

在空安全中,所有类型在默认情况下都是非空的。如果定义了一个String类型的字符串,那么它应该总是包含一个字符串。如果想要一个变量接收任何字符串或者null,那么需要在后面添加一个 ? 表示该变量可以为空。

该符号执行编译时检查,声明一个可空类型的变量。

另外,对于集合和map来说,可空又分为集合可空以及数据项是否可空。具体区别如下:

类型

集合是否可空

数据项是否可空

List

List?

List<String>

List<String?>?

类型

集合是否可空

数据项是否可空

Map<String,int>

Map<String,int>?

Map<String,int?>

Map<String,int?>?

3.1.2 非空断言 !

如果确定某个可为空的表达式为非空,则可以使用非空断言操作符 !将其视为非空。该符号执行运行时检查,表示当前值一定不为空,但操作不当容易报运行时错误。

例如在开发过程中,我们可能对某些可空变量进行了非空判断后,编译器依然无法智能判断其非空,从而无法使用非空类型的方法和属性。

而此时我们确定了此处逻辑中变量是非空的,就可以使用非空断言 !,明确告诉编译器这是一个不为空的变量,使其通过静态检查。

注:要注意使用了非空断言必须保证变量不为null,否则会在运行时抛出异常。

3.1.3 late 延迟初始化

该符号执行运行时检查,表示延迟初始化变量,在编码的时候可以使当前暂未初始化的变量通过静态的非空检查。从前面可以知道Dart在加入空安全特性之后对于非空类型的变量需要进行初始化,初始化又分为声明默认初始化延迟初始化。但并非所有场景都适合使用声明处默认初始化,因此新增关键字 late表示延迟初始化,使用的使用一定要保证变量在调用前被赋值,否则会报运行时错误。

常见适用场景:通过异步操作赋值的非空变量;对于非内置基本数据类型一般建议采用。

代码语言:javascript复制
///引入空安全void main(){  ///对于非内置数据类型,建议采用late延迟初始化  late Student student;  ///对于基本数据类型,如果没有严格初始化,则可以直接采用默认值进行初始化,有延迟初始化需要再使用late  String name='';}class Student{  String name;  int age;  Student(this.name,this.age);}
3.1.4 required关键字

空安全出现之前,可以使用 @required注解的方式来定义必须的命名参数,现在 required作为一个内置修饰符,可以根据需要标记任何命名参数,在使用时一定要给他们赋值,使得他们不为空。

例如,在空安全版本中定义一个非空的命名参数,如果不给他赋默认值的话会报错,

解决方案是加上required修饰符或者设置默认值,要么就将该命名参数设置成可空类型。

3.2 详细编码差异

在实际开发过程中,我们更关心的是如何写出符合空安全规范的代码。编码差异集中在如下几个部分:

3.2.1 非空变量
  • 全局变量和静态变量必须被初始化

由于全局变量和静态变量能够在程序任何位置被访问到,引入空安全以后,要求这些变量在声明的时候被初始化,除非声明的是可空类型。

代码语言:javascript复制
///引入空安全int a=1;class someClass{  static int filed=0;}
代码语言:javascript复制
///未引入空安全int a;//未执行初始化不会报错class someClass{  static int filed;}
  • 实例变量必须被初始化

引入空安全以后,为保证实例变量的非空性,实例变量必须被初始化,可以直接进行初始化,或者是在构造函数中被初始化。

代码语言:javascript复制
///引入空安全class testClass{  int par_a;  ///直接初始化  int par_b=1;  int par_c;  ///或在在构造函数中被初始化  testClass(this.par_c):par_a=2;}
3.2.2 内置类型
  • 去除List中的非命名构造函数

空安全版本中List的非命名构造函数已经被废弃了,因为非命名构造函数会创建一个没有对任何元素初始化的列表,如果不小心访问了其中元素,就会出现异常。

代码语言:javascript复制
  /// If the element type is not nullable, [length] must not be greater than  /// zero.  @Deprecated("Use a list literal, [], or the List.filled constructor instead")  external factory List([int? length]);

为了保障健全的空安全特性,官方推荐直接赋值、 List.generate()List.filled() 或者其他集合转换生成列表,若是需要创建某个类型的一个空列表,则可以通过 List.empty() 来创建。

代码语言:javascript复制
///引入空安全void main(){  ///移除了非命名构造函数,直接使用编译不通过  // List<int> ii=List();  ///创建一个空List  List<String> ls_string=List.empty();  ///使用其他方法生成一个非空List  List<int>  ls_int=List.generate(3, (index) => index 2);  List<String> ls_a=List.filled(3, "2");  List<String> ls_double=List.from(ls_a);   List<String> a=["f","a"];}
  • Map的索引操作是可空的

Map类的 []索引操作符会在键值不存在的时候返回 null,这就暗示了操作符的返回类型必须是可空而不是非空的。因此如果此时直接调用map对象索引值的属性或者方法,无论键值存在与否,都会报编译错误,

如果我们在编码中确定该map中键存在并且键所对应的值存在,则可以在代码中加上一个非空断言 !来消除编译错误。

3.2.3 函数
  • 非空类型必须具有返回值

在引入空安全以前,如果一个函数返回值类型不为空,代码执行到最后,Dart会隐式返回一个null值。因为所有类型都是可空的,所以从代码层面来讲,这个函数是安全的。

而在引入空安全以后,这样的操作是会编译报错的,函数体在执行过程中必须返回一个值。

代码语言:javascript复制
///启用空安全String fun(){  //必须返回值,否则编译器报错  return "";}

并且在这里,分析器能够很智能地对函数中所有的控制流进行分析,只要有一个函数控制流返回了内容,就不会编译报错。

代码语言:javascript复制
///启用了空安全String absoluteReturn(){  int a=1;  if(a==1){    return "a=1";  }else{    return "a=2";  }}
  • 可选参数必须具有默认值

在未使用空安全以前,如果一个可选的位置参数或者命名参数可以没有默认值,在调用时没有内容传递的情况下,Dart会使用null进行填充。

在启用空安全之后,在函数中使用可选参数,要么它是可空类型(type?),否则它必须具有一个非空的默认值。

代码语言:javascript复制
//启用了空安全//不可空的可选参数必须具有默认值fun1([int a=1]){}//定义可选参数为可空fun1([int? a]){}

另外对于命名参数而言,还可以直接使用上文提到的标识符 required定义一个必须的命名参数。

代码语言:javascript复制
//必需的命名参数void requireFun({required int a}){}
3.2.4 操作符
  • 避空索引操作符 ?[] 主要是为了给索引运算符 [] 添加空判断能力,为空则返回null。
代码语言:javascript复制
  ///启用空安全  List<String>? lsName;  String? name=lsName?[1]; //null
  • 避空运算符 ?. 在空安全版本引入之前,Dart避空运算 ?. 的运行逻辑是,如果对象为null,那么右侧的属性就会被跳过,整个表达式作为null来处理。如果要处理比较长的链式调用时,那么就需要在每一处属性或方法的调用处加上 ?.
代码语言:javascript复制
///未使用空安全  String notArr;  // 运行时报错  // print(notArr?.length.isEven);    // 安全运行  print(notArr?.length?.isEven); //null

这样的操作不仅繁琐,而且还会对程序的分析造成干扰,因为在链式调用过程没法判断后面的避空运算符是针对哪一阶段值为null的处理。

Dart空安全为了解决这个问题,在链式调用使用避空运算符的情况下,如果对象为null,那么链式调用的后半部分都会被截断,表达式的值为null。

代码语言:javascript复制
  ///启用空安全  String? notArr;  //安全运行  print(notArr?.length.isEven); //null
  • 避空级联操作符?..

级联运算符有了新的判空运算符 ?.. ,他在级联操作的对象不为null时执行,且只能用在级联序列中的第一级运算符。

代码语言:javascript复制
  ///启用空安全  Receiver? receiver;  receiver?..showA()..showB();
3.2.5 late
  • late final用于常量延迟初始化

late final 关键字主要用于常量延迟初始化,且该常量只能被赋值一次。

代码语言:javascript复制
///启用空安全late final int number;//声明顶层延迟初始化 final 变量number = 100;//合法number = 200;//非法
3.2.6 更智能的流程分析

控制流程分析通常只在进行编译优化中使用,对于使用者而言是不可见的。Dart引入空安全以后,以类型提升的方式实现了部分流程分析,并且使用绝对赋值分析,能灵活地处理局部变量初始化。

  • is 、 is!类型判断

例如以下这个例子,在未启用空安全以前,是没法通过静态分析检查的,虽然此时 else分支仅会在object为List类型的时候执行。

启用了空安全以后,在执行到 else分支的时候,Dart会以类型提升的方式将 object的类型提升至 List,这样就能方便调用 List类型的属性和方法。

  • ==null 、 !=null 空检查

Dart引入空安全之后,类型被划分为了可空和非空类型,可空类型在没经过特殊处理之前,基本上不能对其进行任何有用的操作。而当我们在代码中对对象进行了 ==null!=null 的空判断之后,Dart就会将这个变量的类型提升至对应的非空类型,这样一来就可以调用类型所对应的方法了。

代码语言:javascript复制
///启用空安全String doSomething(String a,String? b){  if(b!=null){    ///至此b转化为非空的String类型    return a b;  }  else{    ///编译报错,b依然为可空类型,故无法使用String的操作符    return a b;  }} 
  • 绝对赋值分析

Dart能够追踪所有控制流路径的局部变量和参数的赋值,只要这个局部变量和参数在某一路径中被赋值,就视为已被初始化。

例如下面这个例子,声明一个未初始化的局部变量result,Dart经过流程分析可知在 if 、else 语句中result一定会被赋值,因此可以将非空的 result返回。而如果将 ifelse 语句注释掉,则 return 语句处会报错。

代码语言:javascript复制
///启用了空安全int tracingProcess(int n){  int result;  //如果没有if、else语句,则后面的return语句报错  if(n<2){    result=1;  }else{    result=3;  }  //result在控制流路径中一定会被赋值,因此可以看作已被初始化过  return result;}

四、如何迁移库/项目?

4.1 迁移步骤

从上一小节看出,引入了空安全机制后,Dart 新旧代码之间产生了互相不兼容的问题。为了解决这个问题,需要遵循如下迁移过程:

1. 遵循的迁移规则:

按顺序进行迁移,先迁移依赖关系中处于最末端的依赖。例如C依赖B,B依赖A,那么应该按照A->B->C的迁移顺序。

2. 首先检查依赖是否完全升级到空安全的版本:

这一步骤将检查pubspec.yaml文件下依赖的所有外部库对空安全的支持情况如何。

代码语言:javascript复制
dart pub outdated --mode=null-safety  # or 'flutter pub outdated --mode=null-safety'

3. 将依赖升级至所支持的空安全版本

这一步骤会将支持空安全的库自动迁移至空安全版本,并自动修改pubspec.yaml 文件。

代码语言:javascript复制
dart pub upgrade --null-safety

4. 迁移:

所有依赖的外部库都迁移至空安全之后,就可以对当前项目进行空安全的版本迁移了。这里有两种迁移方式(一般使用自动迁移):

  • 自动迁移:官网提供了一个命令行工具进行自动迁移,执行如下命令,成功之后会在命令行返回一个url地址,包含自动迁移的结果。
代码语言:javascript复制
dart migrate

注:使用该命令前需要保证当前代码没有编译错误,且项目中所依赖的库都支持空安全

  • 手动迁移: 当然也可以手动迁移。首先将pubspec.yaml文件中的dart版本修改为:
代码语言:javascript复制
environment:sdk: ">=2.12.0 <3.0.0"

然后执行 dart pub get 命令,原始文件会出现很多报红的地方,逐一对照手动修改即可。

5. 分析

任意使用一种方式迁移完成之后,更新package,接下来使用dart的分析工具进行分析:

代码语言:javascript复制
dart analyze

该命令通过静态检查的方式,可以进一步检查出迁移后的代码是否有无效的空安全。

6. 测试

通过分析之后,接下来使用如下命令进行测试:

代码语言:javascript复制
 dart test       # or `flutter test`

该命令通过运行时检查来检查test文件夹下的代码是否有运行时错误。

4.2 实际项目的迁移过程

官方提供的迁移方法基本能迁移大部分依赖简单以及本身不算复杂的工程。但是在实际情况下,我们的工程可能包含了很多未迁移至空安全的依赖,以及静态分析无法处理的逻辑,这就需要更多的运行时检查来帮助处理了。这里以一个实际项目的迁移过程为例来展示具体的迁移过程。

4.2.1 检查依赖情况

执行命令:

代码语言:javascript复制
dart pub outdated --mode=null-safety
  • 主库

可以发现项目所依赖的 test_coverage 还未支持空安全,这是暂不支持空安全的开源库,可以clone到本地作进一步分析。

  • test_coverage

同样使用上述命令检查test_coverage库发现test_coverage 库又出现一个还不支持空安全的库lcov。这种情况下一般有两种解决方式,寻找类似的已经支持空安全的库或者自己去迁移。经过查找发现pub.dart中已经有一个支持空安全的库lcov_dart 7.0.0 ,直接替换使用即可。

4.2.2 升级依赖

继续回到test_coverage库,执行以下命令:

代码语言:javascript复制
dart pub upgrade --null-safety

这样就可以继续将test_coverage的其余依赖升级为支持空安全的版本将test_coverage迁移完成后,继续回到主库执行升级依赖的命令,发现他所依赖的库也全部迁移至空安全,现在可以进行真正的迁移工作了。

4.2.3 迁移

这里使用工具进行自动迁移,在主库的根目录下执行以下命令:

代码语言:javascript复制
dart migrate

这里又出现了内部包的导入问题,这个原因在于dart迁移命令在执行过程中会检查所有外部和内部导入的库,看其是否支持空安全。内部库是从待迁移文件头部导入的,这些文件也是需要被迁移的,可以通过如下命令来忽略内部库的空安全依赖问题:

代码语言:javascript复制
dart migrate  --skip-import-check

接着又出现了新的问题,主要是测试代码的编译错误,由于这个库目前还在开发中,有些代码还没写完。但这部分的代码不影响主库,暂时将这部分的出错代码移出去,等主库迁移完成之后再来处理也可以。

暂时将有编译问题的测试文件夹移除之后,执行命令发现这次成功了。最后迁移工具会生成一个迁移完成的url地址,打开就能看到静态分析工具推断出的建议修改的空安全代码,可以逐个打开修改分析不符合预期的地方,然后直接将所有修改应用到源代码。

4.2.4 手动修复

用工具迁移完成之后,还会有部分代码没法通过静态分析检查,这时候就需要手动去修复这些问题。

4.2.5 分析

执行到这一步说明已经将代码迁移至静态分析通过的空安全版本,接下来使用如下命令作进一步的检查分析:

代码语言:javascript复制
dart analyze

静态分析工具可以标记出一些代码中一些不规范的地方,当然也包括使用不规范的空安全,这个时候手动将不正确的空安全处理掉即可。

4.2.6测试

处理好上一部分的空安全问题之后,接下来来到代码测试阶段。这里根据实际情况,我们测试了待测试文件下的代码运行情况,运行时出现了一些在静态检查阶段没有被发现的空安全问题,接下来继续手动修复这些运行时的空安全问题,逐一修复之后最后这个example能运行在空安全库上了。

4.3 迁移过程常见问题

  • This expression has a type of 'void' so its value can't be used
代码语言:javascript复制
await _udpConn!.close();

解决:这种错误常出现在用await去执行没有返回值的异步函数,若是内部函数则将异步函数的返回值修改为 Future<void>;若是外部函数,则在不修改语义的前提下将await去掉。

  • The default 'List' constructor isn't available when null safety is enabled.
代码语言:javascript复制
var filters = List<String?>(filter.length   c.filter?.length);

解决:用空安全支持的方式初始化List。

4.4 非健全空安全

一个Dart程序可以包含已经是空安全和未迁移至空安全的库,这种混合模式的程序会运行在非健全的空安全版本下。在迁移过程中,可以将暂时不考虑迁移的Dart文件顶部加上语言版本注释:

代码语言:javascript复制
// @dart=2.9

这样在2.12版本的package中为库指定为2.9的语言版本可以减少一些迁移的分析错误。

五、迁移源码分析

虽然官网提供了迁移工具能够快速地将旧版本迁移至空安全,但是迁移完之后还需要手动修改一部分,以及调整运行时异常,这对于不断迭代的旧项目来说造成了迁移困难。为了分析能够优化这个步骤,我们继续对迁移的关键方法进行分析,分别是 dart migratedart analyze命令。

5.1 dart migrate

该命令的入口函数如下:

  • pkg/nnbd_migration/lib/migration_cli.dart
代码语言:javascript复制
  /// 执行迁移过程  Future<void> run() async {    ///迁移过程    _fixCodeProcessor = _FixCodeProcessor(analysisContext, this);    ...        try {      //迁移      var analysisResult = await _fixCodeProcessor!.runFirstPhase();      ....    }

迁移过程从 runFirstPhase函数开始,这个函数对没有错误信息或忽略错误信息的单元进行迁移,迁移函数是 prepareUnit,函数内部主要调用 prepareInput函数。

代码语言:javascript复制
  ///主要迁移过程  Future<AnalysisResult> runFirstPhase() async {    var analysisErrors = <AnalysisError>[];      ....      _progressBar.tick();      //在分析dart源码时出现错误      List<AnalysisError> errors = result.errors          .where((error) => error.severity == Severity.error)          .toList();      if (errors.isNotEmpty) {        analysisErrors.addAll(errors);        _migrationCli.lineInfo[result.path] = result.lineInfo;      }      //忽略错误信息或者分析源码时没有错误信息      if (_migrationCli.options.ignoreErrors! || analysisErrors.isEmpty) {        //对源码的每一个单元进行迁移        await _task!.prepareUnit(result);      }    });   ....    return AnalysisResult(        analysisErrors,        _migrationCli.lineInfo,        _migrationCli.pathContext,        _migrationCli.options.directory,        allSourcesAlreadyMigrated);  }}
  • pkg/nnbd_migration/lib/src/nullability_migration_impl.dart

prepareInput函数中主要对 ResolvedUnitResult对象的 unit属性进行处理,查看源码可以知道 unitAstNode的子类, AstNode一般对应抽象语法树中的节点。这里就可以知道这个过程是以遍历AST节点的方式来对待迁移节点进行分析,所有节点都会继承 AstNodeAstNode提供了 accpet方法对树进行遍历操作,遍历过程是以访问者模式进行的,这里需要传入一个被实现的 visitor

代码语言:javascript复制
  ///以遍历AST节点的方式来对待迁移节点进行分析    void prepareInput(ResolvedUnitResult result) {    assert(        !_queriedUnmigratedDependencies,        'Should only query unmigratedDependencies after all calls to '        'prepareInput');    ....    }    //AST的根节点    //result的unit的类型为CompilationUnit    var unit = result.unit;    try {      DecoratedTypeParameterBounds.current = _decoratedTypeParameterBounds;      //accept方法可以遍历这颗树      unit.accept(NodeBuilder(          _variables,          unit.declaredElement!.source,          _permissive! ? listener : null,          _graph,          result.typeProvider,          _getLineInfo,          instrumentation: _instrumentation));    } finally {      DecoratedTypeParameterBounds.current = null;    }  }
代码语言:javascript复制
abstract class CompilationUnit implements AstNode {  ....  List<AstNode> get sortedDirectivesAndDeclarations;}
  • pkg/nnbd_migration/lib/src/node_builder.dart

继续查看 NodeBuilder,可以看到它确实是一个继承 AstVisitor的子类。对于AstVisitor而言,每遍历到一个节点就会走到其对应的访问方法里。例如遍历到构造函数,就会走到 visitConstructorDeclaration方法里。并且 NodeBuilder中出现了 NullabilityGraph类型的属性,可以推测出,迁移过程是将源码经过静态分析先转换成AST,然后以访问者模式对树节点进行访问,在访问过程中构造出可空推断图,来分析节点之间的可达性,最后将推断的类型返回到源码的相应部分。

代码语言:javascript复制
/// 继承自AstVisitor,visitor每遍历到一个节点就会走到相应的方法例class NodeBuilder extends GeneralizingAstVisitor<DecoratedType>    with        PermissiveModeVisitor<DecoratedType>,        CompletenessTracker<DecoratedType> {  /// Constraint variables and decorated types are stored here.  final Variables? _variables;  @override  final Source? source;  final LineInfo Function(String) _getLineInfo;  Map<String, DecoratedType?>? _namedParameters;  List<DecoratedType?>? _positionalParameters;  NullabilityNodeTarget? _target;  final NullabilityMigrationListener? listener;  final NullabilityMigrationInstrumentation? instrumentation; ///可空推断图  final NullabilityGraph _graph;  final TypeProvider _typeProvider;  NodeBuilder(this._variables, this.source, this.listener, this._graph,      this._typeProvider, this._getLineInfo,      {this.instrumentation});

5.2 dart analyze

在静态分析过程中还有一个比较重要的命令就是 dart analyze,我们继续对其进行分析。

  • pkg/analyzer/lib/dart/analysis/utilities.dart

分析命令将文件路径作为path参数传入,对输入的文件进行分析,返回一个 ParseStringResult对象。

代码语言:javascript复制
ParseStringResult parseFile(    {required String path,    ResourceProvider? resourceProvider,    required FeatureSet featureSet,    bool throwIfDiagnostics = true}) {  resourceProvider ??= PhysicalResourceProvider.INSTANCE;  var content = (resourceProvider.getResource(path) as File).readAsStringSync();  return parseString(      content: content,      path: path,      featureSet: featureSet,      throwIfDiagnostics: throwIfDiagnostics);}
  • pkg/analyzer/lib/dart/analysis/results.dart

ParseStringResult类中,存在一个 CompilationUnit类型的属性 unit,注释表示为一个未处理的编译单元。

代码语言:javascript复制
abstract class ParseStringResult {  String get content;  /// The analysis errors that were computed during analysis.  List<AnalysisError> get errors;  /// Information about lines in the content.  LineInfo get lineInfo;  /// The parsed, unresolved compilation unit for the [content].  CompilationUnit get unit;}
  • pkg/analyzer/lib/dart/ast/ast.dart

继续看 CompilationUnit这个类,从上面的分析可知这个类实现了 AstNode接口,可以知道这个类就是用于存储AST数据的,那么 ParseStringResult中的 unit应该就是所有树的根节点,从这个根节点遍历,应该就能提取出源码中所有节点信息,通过进一步的对节点信息进行推断便可检查出转换后的代码存在的问题。

代码语言:javascript复制
// Clients may not extend, implement or mix-in this class.abstract class CompilationUnit implements AstNode {  NodeList<CompilationUnitMember> get declarations; ....  CompilationUnitElement? get declaredElement;}

5.3 小结

  • 静态分析可以在编译时分析出源码中的错误,以此来减少运行时错误以及优化运行效率。
  • 通过对静态分析过程生成的AST树按照一定规则访问还可对源码进行修改,这种方式可运用于代码格式化、自动生成相应DSL等。

参考文章

  • 快速上手Flutter空安全: https://juejin.cn/post/6958965184631144478
  • 迁移至空安全: https://dart.cn/null-safety/migration-guide#step3-analyze
  • 强大的空安全: https://jishuin.proginn.com/p/763bfbd5540d
  • 深入理解Dart空安全: https://juejin.cn/post/6884108460514869261
  • Flutter Getx 02: https://ducafecat.tech/2021/04/09/flutter-getx/flutter-getx-02-null-safety/#空安全意味着什么

END

更多精彩推荐,请关注我们

你的每个赞和在看,我都喜欢!

0 人点赞