likes
comments
collection
share

你的 Flutter 空安全真的安全吗?

作者站长头像
站长
· 阅读数 33

背景

Dart 3.0 稳定版已在 2023 年 5 月发布,Dart 3.0 默认健全的空安全将成为唯一受支持的模式。也就意味着如果你的代码还没有迁移健全的空安全上来无法将 Flutter 版本升级到 3.10.0 及以上版本(Flutter 3.10.0 依赖 Dart 3.0)。对于 Flutter 开发来讲升级空安全是一件不得不做的事情(重要非紧急),刚好前两个月我负责组织完成了一个大概有 40 万行 Dart 代码的项目,历时长达半年,踩了一些坑,如果你正在迁移的路上或者还没有开始迁移这篇文章将非常适合你。

对于大项目的空安全迁移是一件非常痛苦而又漫长的事情,属于吃力还不讨好。对于你老板来讲迁移代码是你应该做的,但如果因为迁移出现线上问题你得负责。对于产品来讲,产品不理解你所事情的意义有多大,所以业务需求优先级仍然是最高的,业务还得按正常排期往前赶。迁移代码牵一发而动全身,回归测试工作量也是巨大,测试同学也不想趟这趟浑水。因此在这个过程中需要付出较大的沟通成本,即便将迁移工作拆成多个迭代来做,其中的风险仍然很大,回顾整个过程都是如履薄冰心惊胆战。这里给你的建议是:将迁移的问题上升一个级别,反馈到部门领导甚至CTO这个层级,你的迁移工作开展不会被动。

你的 Flutter 空安全真的安全吗?

迁移

在开始之前你需要制定一个基础的迁移步骤:依赖迁移 -> 非健全的空安全 -> 健全的空安全。

依赖迁移这个没有什么好说的,你只需要将不支持空安全的依赖进行升级或替换即可,查找非安全的 Package 依赖使用如下命令:

flutter pub outdated --mode=null-safety

在这个可能会过程会遇到依赖地狱问题,简单来说就是你打算把 A Package 升级到空安全 2.0.0 版本,pub get 后发现报错告诉你 B Package 的只能依赖低于 2.0.0 的 A Package 版本,等你改好了 B Package 又告诉你 B 依赖的 C 也依赖了 A,你不得不又将 C 的依赖进行调整,如此循环。这个过程没有快捷方式,你只有保持耐心一个一个的慢慢或升级或替换(一个无聊的体力活)。

你的 Flutter 空安全真的安全吗?

非健全的空安全

非健全的空安全,会让你的代码运行在「空安全」与「非空安全」混合模式下,空安全与非安全的代码会同时存在于你的项目中。混合模式允许你按文件进行迁移,当前在此之前最好完成了所有 package 依赖的迁移,否则你可能需要对同一个代码文件进行二次甚至多次迁移。

混合模下你需要将 main.dart 的迁移工作放到最后,如果你迁移了 main.dart 静态检查会默认你运行在健全的空安全模式,从而会使未迁移的代码报错。按文件迁移只需要在 *.dart 文件开头加上一行注释,注释里的版本范围是 2.12~2.19(下面示例是 2.18)

// @dart=2.18

在文件第一行添加上面这行后运行 flutter pub get 命令,你将在当前文件得到一大堆报错,不要慌。接下来你只需要找到报错的位置用 ? ! late required 这几个关键字或语法糖完成迁移。是不是很简单?虽然简单但我这里还是有几条建议给你,帮你规范迁移流程减少异常。

1、业务代码迁移步骤:大多数业务依赖关系大致为,UI(Widget) -> State -> Model 因此其实空安全迁移最核心的模块,还是在于 Model 层所以迁移的顺序,可以按照 Model -> State -> UI(Widget) 的顺序来进行。

2、迁移 Model 时基础数据类型(bool, num, String)不要无脑给空类型(?), 太多的空类型会使你在后面的 State 业务代码迁移时写很多 if 的空判断或者 ?? ,否则如果业务迁移是另一个倒霉的同事他大概率要骂娘,所以基础数据类型尽量用 ?? 给默认值(false,0或空串)。

// Model依赖层级较深,非必要不给空类型
class GoodsBizModel {
  final String name;
  final String price;
  final Stirng total;
  final String left;
  
  GoodsBizModel({
    // 省略其它
  });
   
  factory GoodsBizModel.fromJson(Map<String, dynamic> json) => 
				GoodsBizModel({
          name: json['name'] ?? '', // 基础类型给默认值
         	// 省略其它
        });
}

3、迁移 Model 时基础数据类型也尽量避免使用 latelate 关键字是给静态检查开了一个后门,这个后门以后是否正确需开发人员自己保证。典型场景是在某个属性在 if elase 两个分支内初始化,哪天 else 分支内的初始化代码被误删掉静态检查不会有任何提醒,这个 else 一旦没有在测试时覆盖,问题将会直接带到线上。

class UserModel {
	late String name;
	int age;
  String level;
  
  UserModel({
    required this.name,
    required this.age,
    required this.level,
  });
   
	UserModel.fromJson(Map<String, dynamic> json) : 
  	age = json['age'] ?? 0, 
  	level = json['level'] ?? '0' 	{
	if (level != 0) {
      name = json['prefix_name'] + level; // else 分支缺失会造成异常
    }
  }
}

4、同理在 State 里如果不是在 initState 里显式的且同步的初始化的属性也不要给 late 关键词,这点很重要。

class _SomeState extends State<SomeType> {
  // 明确的同步初始化可以用 late
  late ScrollController controller;
  
  void initState() {
    super.initState();
    controller = ScrollController();
  }
}

5、容器类型(Map/List/Set)大部分情况都可以直接给空值(字面量 {}[]),空的容器类型会节省很多取值时的判空代码。

class ExampleModel {
	late String name;
	List<String> deps = []; // 空容器默认值
  Map<String, String> subModel = {}; // 空容器默认值
  // ...省略
}

6、业务代码迁移时不要无脑使用 ! 强解取值,务必要结合上下文逻辑来进行判断,确认使用 ! 不会造成边界异常再使用。

void bizMethod(Map<String, dynamic> nameMapping) {
	final name = nameMapping['takeName'];
	if (name == null) return; 
  // ...
  // ...
  // 省略代几十行码
  // 结合上面的判空逻辑 name 这里才可以用 ! 取值
  List sp = name!.split('&');
}

7、勿必进行代码 Review,代码 Review 规则如上。

8、不要使用迁移工具,迁移工具应付不了复杂的逻辑,很多场景还是需要开发自己判断哪种方式更合理,依赖迁移工具只会让你放松警惕性。

以上 8 条建议将会帮你减少一部分重复的工作或可能会出现的异常,熟悉之后甚至不用再动脑子,剩下的就交给时间了(又是体力活)。随着你迁移的深入你可能需要统计迁移进度或按文件分配迁移任务,下面提供一些命令供你使用。

// 查找已迁移的文件
find . -type f -name '*.dart' | xargs grep  '@dart=' | wc -l
// 查找未迁移的文件
find . -type f -name '*.dart' | xargs grep -L '@dart=' | wc -l
// 过滤未迁移的业务模块
find . -type f -name '*.dart' | xargs grep -L '@dart=' | sort | grep  '你的业务模块文件关键词' | xargs wc -l | sort

健全的空安全

当你完成 main.dart 文件的迁移后,便可开启健全的的空安全,将 pubspec.yaml 文件中的依赖升级到 3.0.0。

environment:
  sdk: '>=3.0.0 <4.0.0'

还记得在 dart 代码文件开头加的那句 // @dart=2.18 注释吗?开启健全的空安全前记得删掉这行。到此代码层面迁移的工作全部结束了,你可以放心大胆的升级 Flutter 到 3.10 以上了。等等,还记得本文的标题吗?你以为到这步你迁移的空安全就一定安全了吗?

接下来才是重点,仔细看!!!

在 Flutter 中开启全局空安全后 Dart 官方称为 "enforces sound null safety" 也叫为「健全的空安全模式」。在这之前我们已经用混合模式完成了空安全迁移,混合模式下的空安全与单纯健全空安全存在一定的「运行时」差异,这些差异不会出现在静态检查中,只有在运行时才会暴露出来。

dynamic 引发的问题

不管是在混合模式亦或健全模式下将 dynamic 类型的值赋值给一个非空类型的变量时都不会触发静态检查异常,但在运行时两者将出现差异,示例代码如下:

class ButtonShowItem {
  String btnKey = "";
  bool sort = false;

   ButtonShowItem({
     required this.btnKey,
     required this.sort,
   });

  ButtonShowItem.fromJson(Map<String, dynamic> json):
    btnKey = json['btnKey'], // 可能为 null,混合模式正常
    sort = json['sort'];
}

可以看到当前类的成员属性值类型(btnKey/sort)均有确定的类型且为非空,且构造函数内 json 参数的类型为 Map<String, dynamic>,意味着将 dynmaic 类型的值赋值将赋给当前类的属性(也就是 sort),这并不会出现静态类型检查异常(IDE 不会有任何提示)。真正差别如下:

  • 在混合模式下:即使 dynamic 类型实际值是 null 并赋值给非空类型的成员属性在「运行时」正常运行。真正的异常需要在使用 null 值成员属性时才会出现,但如果在使用时进行「画蛇添足」式的空值判断那也不会出现任何异常。下面这段代码是从非空安全迁移过来的,大多数情况下都有空值判断&检查,所以混合模式下问题不会轻易暴露。
Widget buttonShow(List<ButtonShowItem> items) {
  List<Widget> list = [];
  if (btnKey != null && btnKey.isNotEmpty) { // 混合模式下 btnKey 为 null 有判空正常运行
    list = items.map((x) => BuyButton(...));
  }
  return list;
}
  • 在健全空安全模式下:null 赋值给非空类型的属性在「运行时」将会立即抛异常,出现致命错误从而影响业务。还是以上面的 ButtonShowItem 为例,当 jsonvaluenull 值时将会抛出异常。

      ButtonShowItem.fromJson(Map<String, dynamic> json):
        btnKey = json['btnKey'], // 为 null 时,健全空安全模式会抛出异常
        sort = json['sort'];
    

解决方案:dynamic 类型的值需要做判空或加默认值处理

  ButtonShowItem.fromJson(Map<String, dynamic> json):
    btnKey = json['btnKey'] ?? "", // 健全空安全正常运行
    sort = json['sort'] ?? false; 

混合模比下的空安全检查更偏重静态类型检查,运行时检查而并不严格。dynamic 引发的这种问题非常隐晦但又非常常见,在实际项目迁移过程中防不胜防,尤其是当迁移进入尾声再发现这种问题就只能听天由命了。所以在迁移开始前,一定要在团队内同步这一差异,遇到 dynamic 则需要打起12分的精神。

健全空安全模式下在运行时有更强的类型限制,这点将在下面的几个例子中继续体现。

Provider 引发的问题

在混合模式使用 Provider 时可能会遇到如下写法:

	@override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<SKUPopupUtil>.value(
      value: state.skuProvider, // 可空类型
        child: Consumer<SKUPopupUtil>(builder: (context, provider, _) {
        // Consumer 消费非空类型
          if (provider == null) return const SizedBox.shrink();
          // other code

Consumer 内接收到的 provider 值可能为空或者非空因此内部做了空判断,在混合模式下这段代码逻辑严谨,但在健全的空安全模式下却异出了异常。按常理分析,这里虽然 skuProvider 类型为可空但实际传的是非空值,Consumer 接收到的也是非空按理讲应该不会抛异常才对。

异常:A provider for SKUPopupUtil unexpectedly returned null.

深入 Provider 源码发现 Consumer 内部会使用 Provider.of<T>(context); 取上层的 skuProvider 值。of 函数内有一段检查逻辑:

    if (_isSoundMode) {
      if (value is! T) {
        throw ProviderNullException(T, context.widget.runtimeType);
      }
      return value;
    }

_isSoundModetrue 时会检查取到值 skuProvider 类型是否是 Consumer 泛型限定的类型(此处为 SKUPopupUtil,非空)。当 skuProvidernullSKUPopupUtil? 值时就会抛出异常。查看 _isSoundMode 的源码发现这段注释:

/// Whether the runtime has null safe sound mode enabled.
///
/// In sound mode, all code is null safe and null safety is enforced everywhere.
/// Nullability in generics is also enforced, which is how this code detects
/// sound mode.
///
/// In unsound mode, there can be a mix of null safe and legacy code. Some null
/// checks are not done, and generics are not compared for null safety.
final bool _isSoundMode = <int?>[] is! List<int>;

直白点说就是在健全的空安全模式下 Provider 会开启强制泛型类型检查,健全的空安全判断方式是检查 List<int?>List<int> 是否是同一类型。

解决方案: 将 Consumer 泛型由 SKUPopupUtil 改为 SKUPopupUtil? 即可。

	@override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<SKUPopupUtil>.value(
      value: state.skuProvider, // 可空类型,
            child: Consumer<SKUPopupUtil?>(builder: (context, provider, _) {  
            // Consumer 消费可空类型
          if (provider == null) return const SizedBox.shrink();
          // other code

由上可知在健全的空全模式下 List 类型发生了改变,由此也会产生一些异常。

List 类型变化

Dart 中字面量定义数组对象默认生成的是可变数组。当向可变数组中插入/添加元素时要分两步:一、改变组数长度 二、将元素放置对应位置

final list = <int>[]; 
// 等效
final list = List.empty(growable:true);

// 添加元素实际上分两步
list.add(1); 
// 第一步,改变长度
list.length = 1;
// 第二步,放置元素到对应位置
list[0] = 1;

可变数据可通过设置 length 改变数组长度,当增加数据长度时默认插入的值是 null 值。在混合模式下由于运行时类型限制相对宽松, <int>[] 类型插入 null 类型是允许的。也就是说混合模式下 List<int?>/List<int> 是同一类型。

final list = <int>[]; // []
list.length = 3; // [null, null, null]

一旦回到健全的空安全模式下,<int>[] 类型数组无法改变数组长度。因为类型检查更为严格,null 类型不允许插入 <int>[] 类型数组内。

final list = <int>[]; // []
list.length = 3; // type 'Null' is not a subtype of type 'List' in type cast

解决方案:1、可变数组需要定义为 <T?>[],即保证元素为可空 2、构数组时使用 filled 构造函数填入默认值

// 方案一
final list = <int?>[]; // 数组内容为空 []
list.length = 3; // 正常运行,数组内容 [null, null, null]
final listTypeSafe = list.where<int>(); // 正常取值
// 方案二
final list = List.filled(3, 0);  // [0, 0 ,0]

与此相关的问题,如果你使用 ListMixin 封装了自己的 List 类型也会造成一些问题,这里以 Dio 源码 Interceptors 为例:

class Interceptors extends ListMixin<Interceptor> {
  /// Define a nullable list to be capable with growable elements.
  final List<Interceptor?> _list = [const ImplyContentTypeInterceptor()];

  @override
  int get length => _list.length;

  @override
  set length(int newLength) {
    _list.length = newLength;
  }
	.... 省略
}

注意上面内部 _list 变量类型了吗?他的元素可空类型(即使当前泛型限制为非空),如果不是可空类型 Interceptors 在添加元素时会出现异常,并且注释也明确了这一点。

最后

空安全迁移的目的是为代码的安全,但实际迁移操作过程中反而会引入不安全的因素,但这种不安全的影响因子从项目开发的全生命周期来看值得投入的。迁移最终会让我们的代码安全性提升一个数量级,能提高代码健壮性,极大减少异常的发生。迁移的过程是痛苦的,但当你迁移完成之后再去写业务代码,你会发现你所做的一切都值得。

转载自:https://juejin.cn/post/7311602994572607515
评论
请登录