你的 Flutter 空安全真的安全吗?
背景
Dart 3.0
稳定版已在 2023 年 5 月发布,Dart 3.0
默认健全的空安全将成为唯一受支持的模式。也就意味着如果你的代码还没有迁移健全的空安全上来无法将 Flutter 版本升级到 3.10.0 及以上版本(Flutter 3.10.0
依赖 Dart 3.0
)。对于 Flutter 开发来讲升级空安全是一件不得不做的事情(重要非紧急),刚好前两个月我负责组织完成了一个大概有 40 万行 Dart
代码的项目,历时长达半年,踩了一些坑,如果你正在迁移的路上或者还没有开始迁移这篇文章将非常适合你。
对于大项目的空安全迁移是一件非常痛苦而又漫长的事情,属于吃力还不讨好。对于你老板来讲迁移代码是你应该做的,但如果因为迁移出现线上问题你得负责。对于产品来讲,产品不理解你所事情的意义有多大,所以业务需求优先级仍然是最高的,业务还得按正常排期往前赶。迁移代码牵一发而动全身,回归测试工作量也是巨大,测试同学也不想趟这趟浑水。因此在这个过程中需要付出较大的沟通成本,即便将迁移工作拆成多个迭代来做,其中的风险仍然很大,回顾整个过程都是如履薄冰心惊胆战。这里给你的建议是:将迁移的问题上升一个级别,反馈到部门领导甚至CTO这个层级,你的迁移工作开展不会被动。

迁移
在开始之前你需要制定一个基础的迁移步骤:依赖迁移 -> 非健全的空安全 -> 健全的空安全。
依赖迁移这个没有什么好说的,你只需要将不支持空安全的依赖进行升级或替换即可,查找非安全的 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 的依赖进行调整,如此循环。这个过程没有快捷方式,你只有保持耐心一个一个的慢慢或升级或替换(一个无聊的体力活)。
非健全的空安全
非健全的空安全,会让你的代码运行在「空安全」与「非空安全」混合模式下,空安全与非安全的代码会同时存在于你的项目中。混合模式允许你按文件进行迁移,当前在此之前最好完成了所有 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
时基础数据类型也尽量避免使用 late
,late
关键字是给静态检查开了一个后门,这个后门以后是否正确需开发人员自己保证。典型场景是在某个属性在 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
为例,当json
的value
为null
值时将会抛出异常。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;
}
当 _isSoundMode
为 true
时会检查取到值 skuProvider
类型是否是 Consumer
泛型限定的类型(此处为 SKUPopupUtil
,非空)。当 skuProvider
为 null
或 SKUPopupUtil?
值时就会抛出异常。查看 _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