2021年3月4日,Flutter正式发布2.0版本——这一天,我回想起了,被脱发支配的恐怖……发际线再次失守的屈辱……

升级后的错误修复

如果升级后打开已有项目,还能够正常通过编译,那么恭喜你,这说明你拥有作为 IT 人的最强技能——强运,且敏捷属性点满自带回避 BUG 和异常的效果 👍

然而其实除非项目规模太小,就没引入几个依赖包,大概率此时会因为依赖冲突或者其他插件包内的错误导致编译失败的 😑 。比如我在做的公司项目,引入了接近 40 个依赖和插件,结果自然是原地爆炸 😒……下面就以我的情况为例,介绍一下几种常见情况的处理方法。

charts_flutter 与 flutter_localizations 冲突

我的项目中使用了 charts_flutter 这个库用于图表的显示,升级 SDK 后执行 flutter pub get 安装依赖时第一个报出的错误就是:
error1
根据报错信息得知,charts_flutter 这个库指定了其引用的 intl 库版本小于 0.17.0,而更新后 flutter SDK 中引用的 intl 库版本正好就是 0.17.0,所以引发了冲突。
碰到这种库中的依赖与 SDK 中的引用冲突的情况,应该优先去库的 issue 区看看有没有相关的讨论,比如:
issue1.webp
然后可以找到这样一个谈论:Add null safety #579,其中提到了两种解决方案:

依赖版本覆盖法 dependency_overrides

这是一种相对比较直接粗暴的方法,既然问题处在一个A包要求引用的C包小于某个版本,而B包要求引用的C包恰好大于等于这个版本,那么如果实际上A包实际上也能正常使用高版本的C包,只是因为依赖关系还没来得及更新,那么可以在我们项目的pubspec.yaml中加上类似这样的一段:

1
2
dependency_overrides:
intl: ^0.17.0

这样 flutter pub get 时就会忽略上面的错误,尝试使用高版本的包进行依赖分析和下载。

修改依赖法 通过 git 方式引用修改过的库

上面的修改完成后,这个包就可以通过依赖检查和编译了。但是当我升级了另外一个包时,又出现冲突,这次显示的是 charts_flutter 所以引用的 flutter_svg 这个库的版本和其他库有冲突。
这个问题在上面的 issue 中也有人提到,一种方法是继续增加 dependency_overrides:

1
2
3
dependency_overrides:
intl: ^0.17.0
flutter_svg: ^0.19.3

这样会导致这个覆盖列表越来越长,后面会忘记每条覆盖都是为了解决哪个库的冲突,所以还可以用另一种方式,将原本pubspec.yaml中的依赖从:

1
2
3
4
dependencies:
……
charts_flutter: ^0.9.0
……

改为:

1
2
3
4
5
6
7
dependencies:
……
charts_flutter:
git:
path: charts_flutter
url: https://github.com/ahammer/charts.git
……

这样的形式,也就是让flutter pub get解决下载依赖包时从制定的 git 仓库获取,而不是 pub 仓库。上面地址中的 charts_flutter 的依赖已经被仓库所有者更改过,避免了之前碰到的冲突。等到库的原作者更新了版本解决了问题,只要把这里的 git 依赖删掉,重新改为指定版本的形式,就可以切换回从 pub 仓库下载依赖,而不用去管 dependency_overrides 的问题了。

device_preview 编译错误

device_preview 是一个非常神奇且实用的工具包,参考这个在线 demo :device-preview-starter,它可以让我们在开发时在同一个界面上模拟查看 app 在各种平台和设备上的运行显示效果,非常适合做页面适配时的预览。除此之外,它还提供了快速切换语言、明暗主题、屏幕方向等功能。

当解决了各种依赖冲突之后,终于能够执行 flutter run编译项目,然而却报错了:
error2.webp
还是老样子,根据报错,确定是 device_preview 这个包的错误,那么去翻一下 issue,然后可以找到这个讨论:No named parameter with the name ‘nullOk’ #91
issue2.webp
也就是说出错的原因是包中使用的一个flutter API发生了变化,所以只能升级包的版本:

1
2
3
4
dependencies:
……
device_preview: ^0.6.2-beta
……

这样就能编译通过了。

迁移至空安全

关于空安全是什么,有什么好处,主要可以看看下面的文章和视频:

或许你在以往的开发过程中早就苦于各种“空异常(NPE)”久矣,或者看了上面的资料后中对现代化语言中关于“空安全”的讨论后,应该能够理解将整个项目迁移到空安全语法中可以大大降低程序出错的概率,从而节省了debug的时间提高了开发效率。然而迁移已有工程到空安全并不是一件容易的事……

关于迁移的官方建议和演示可以参考:

官方推荐的迁移流程是:
1. 等待项目依赖的所有软件包迁移完成。
2. 迁移项目代码,最好使用交互式的迁移工具。
3. 确保静态分析所有问题已被解决(IDE不再有报错)。
4. 运行代码,测试程序运行。
然而我觉得这有些过于理想不太现实……
首先,我的项目中的包相对比较多,相当一部分的包还没有迁移到空安全:
outdate.webp
而且其中有些包年久失修,想要等它们全部自己更新不知还要多久……另外官方推荐的那个交互式迁移工具感觉也不是很好用。

所以我的建议是,为了趁早获得空安全语法对后续开发的效率提升,可以先手工迁移项目中的源码,然后看情况更新一部分依赖包为 nullsafety 版本,并将项目的编译模式改为非健全的空安全混合模式,之后持续更新依赖包,并视情况手动更新一些升级无望的小众依赖包。

更新项目代码

做好心理准备,如果项目已经具有相当规模,接下来的修改将会是一场持久战(我花了大约5个多小时 💀

修改项目配置,激活空安全检查

migrate1.webp
如图,先修改项目的pubspec.yaml文件,将最低 SDK 版本设置到 2.12.0:

1
2
environment:
sdk: '>=2.12.0 <3.0.0'

然后执行dart pub get命令重新生成软件包的配置文件,点击 IDE 中 dart 静态分析器的刷新按钮,之后将看到分析出的项目中不符合空安全语法的错误:
migrate2.webp

善用 IDE 提示功能及自动修正功能 逐个修复所有问题

这一步就是大量的重复工作,简单来说是按需添加 ?、!、required 以及 late 来消除静态错误,但具体应该用哪个,实际上还是比较考验对 dart 语言的精通程度以及对 flutter 框架的理解的。举几个例子:

  1. ‘?’ 还是 ‘!’
    migrate3.webp
    在上图所示的问题处,IDE给我们的提示是,由于 addPostFrameCallback 方法可能作用于 ‘null’,所以需要在 WidgetsBinding.instance 的后面加上 ‘?’ 或者 ‘!’。如果只是为了修复语法错误,两者都是可以的,但实际上效果却完全不同:

    1. 如果使用了 ‘?’ ,那么运行时 runtime 还是会对WidgetsBinding.instance进行null检查,如果为空则不继续调用addPostFrameCallback 方法,从而避免了NPE异常,但缺点是非空检查会带来额外的性能开销;
    2. 如果使用了’!’,那么运行时 runtime 就不会对WidgetsBinding.instance进行null检查,可能会导致运行时的 NPE 空异常。

    查看WidgetsBinding.instance的源码如下:
    migrate4.webp
    可以得知,如果是在执行runApp之前调用此属性,那么需要先执行WidgetsFlutterBinding.ensureInitialized()方法确保其不为空,也就是说,只要是在runApp之后的代码里WidgetsBinding.instance都不会为 ‘null’ 的——查看runApp的源码如下:
    migrate5.webp
    可以看到该方法内已经执行了WidgetsFlutterBinding.ensureInitialized()
    由于我们的 App 都是以runApp作为入口开始的界面显示和事件循环,所以是可以保证WidgetsBinding.instance不为 ‘null’ 的,那么为了提高效率,项目中所有类似的错误都是可以放心大胆地使用 ‘!’ 来进行空安全语法的修复的。

  2. ‘required’ 和 ‘@required’
    migrate6.webp
    如上图的错误所示,在之前的语法中,函数定义中的命名参数默认是可选的,而调用时必填的参数则用 @required 修饰。而在新的空安全语法中,命名参数只允许有三种情况:

    1. 使用 ‘required’ 前缀修饰符,表示其调用时必须传入非空的值;
    2. 使用 ‘?’ 修饰其类型,表示调用时可以传入空值或不指明;
    3. 给该参数设置非空默认值。

    由于之前项目代码里所有用 ‘@required’ 修饰的代码都符合第一种情况,所以可以利用 IDE 的全局替换功能,直接把项目代码中所有的 ‘@required’ 批量替换成 ‘required’,节省一部分手工修改的工作量。

  3. ‘dynamic’ 和 ‘Object’
    项目中有些页面包含了相似的组件,比如一列按钮,这时会有类似这样的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    @override
    Widget build(BuildContext context) {
    final options = [
    [
    context.s.settings_label_upgrade,
    () => context.n.pushNamed(SettingPagesRouter.upgrade),
    ],
    [
    context.s.settings_label_app_setting,
    () => context.n.pushNamed(SettingPagesRouter.appSettings),
    ],
    [
    context.s.settings_label_share,
    H.voidFunc,
    ],
    ];

    return Column(
    children: options
    .map((e) => GestureDetector(
    onTap: e[1],
    child: Text(e[0]),
    ))
    .toList(),
    );
    }

    也就是将这些组件的一些属性提取出来作为数组,在代码中循环动态生成组件并应用这些属性。上面的代码在之前的语法中是正常的,因为options.map((e) => ...)这里的 e 的类型会被当作 List<dynamic>,也就是 e 中的元素都是任意类型,传入到需要 String 类型的地方它就会被当作字符串使用,传入需要函数的位置它就会被当作是函数使用,语法检查器不会报错。
    但是新语法下,会报如下错误:
    migrate7.webp
    也就是说现在如果以字面量方式声明一个 var 类型的数组,而且数组内的类型不止一种,那么语法检查器会认为数组的类型为List<Object>而不是List<dynamic>,Object 虽然是所有非空类型的父类,但是和 dynamic 不同,不能自动隐式转换为所需类型。所以这里有三种改法:

    1. 使用 as 显式转换类型:
      1
      2
      3
      4
      5
      6
      7
      8
      return Column(
      children: options
      .map((e) => GestureDetector(
      onTap: e[1] as GestureTapCallback,
      child: Text(e[0] as String),
      ))
      .toList(),
      );
    2. 指定数组类型为List<dynamic>
      1
      final List<List<dynamic>> options = [ …… ];
    3. 不用数组,改为定义class:
      migrate8.webp

其他还有一些迁移中容易碰到的问题,建议阅读:空安全:常见问题

运行混合版本的代码

迁移完成之后,即使语法检查全部通过,IDE 不再报错,此时运行项目还是会报如下错误:
migrate9.webp

参考:dart-doc : 非健全的空安全,最简单实用的方法还是在lib/main.dart的第一行加入// @dart=2.9

1
2
3
4
5
6
// @dart=2.9
import 'src/my_app.dart';

main() {
//...
}

这样在开发编写代码时,可以享受空安全语法检查带来的体验提升,而在编译时使用混合模式,不强制所有的依赖包都完全迁移到了空安全。

逐步迁移依赖库

为了最终可以实现整个项目健全的空安全模式,还是需要一步步迁移依赖库的。首先还是运行dart pub outdated --mode=null-safety命令,根据提示将已经发布了 nullsafety 版本的库的版本更新,然后运行flutter pub get看看是否存在依赖冲突,如果有的话用上面的方法尝试解决,不行的话则将对应的包的版本回退,优先确保项目能够正常运行。
之后每隔一段时间,比如一周左右,重复上面的操作,直至所有的依赖包不再冲突,全部升级为 nullsafety 版本后,删除lib/main.dart第一行的// @dart=2.9注释,使整个项目运行于健全的空安全模式。

手动修改部分依赖包

而对于某些小众的、升级无望的依赖包,可以尝试用本地引用的方式加入项目代码库,然后手动升级为 nullsafety 版本。
以我在 qrs_detector——Flutter应用中的心电心率识别 中提到的 iirjdart 这个库为例,它是一个很小众的用于信号滤波的库,从 2020-05-01 上传 0.0.1 版本以来没有过任何更新,仓库的 issue 区也没有任何活动,所以很可能作者不会主动去更新为 nullsafety 版本了。而且使用到现在,这个库本身的逻辑工作得相当稳定,也没有依赖其他什么包,所以很适合自己手工移植。

复制库源码至项目目录下

为了便于管理,我在项目根目录下创建了名为3rd_party的目录,将所有需要修改的第三方库的源码放在这个目录下

首先找到依赖库的源码,可以从本地缓存目录中复制,利用 IDE 快速找到缓存的源码位置方法如下:
migrate10.webp

或者也可以从 GitHub 上下载源码。
之后把整个源码目录去掉版本号,复制到创建的3rd_party目录下,删除源码内包括example在内等无用的代码和资源。

修改项目的pubspec.yaml,改为路径引用的方式:

1
2
3
4
5
dependencies:
……
iirjdart:
path: 3rd_party/iirjdart
……

将库改为支持 nullsafety 语法

修改3rd_party下库源码中的pubspec.yaml,和前面的操作一样,改为

1
2
environment:
sdk: '>=2.12.0 <3.0.0'

然后执行dart pub get命令重新生成软件包的配置文件,此时 IDE 即会显示该库的语法错误,用之前使用的各种技巧修改源码,使其通过语法检查。

有时候改完库的源码后,由于对外暴露的 API 也发生了变更,可能还需要回过头来改项目中调用位置的代码,需要注意一下

测试运行

这样修改之后,需要尽量进行全面的测试,确保没有给库带来意外的问题。如果确定修改成功,则可以在补完其example之后,通过提交 pr 的方式向库的原仓库贡献。这样一旦原作者接受 pr ,其他使用这个库的用户就可以方便的使用你修改的版本,而且项目中的依赖方式也可以不用再使用路径引用的方式,可以及时获得后续的更新。

写到这里,本次 Flutter 更新后项目代码修改移植的经历和经验就差不多完成了……但是实际上和 nullsafety 有关的尝试和折腾还远没有结束。。。后面几篇文章还会记录一下,我将自己三年前编写的、用于在 Flutter 开发中生成 json 解析模板类的工具更新到支持 nullsafety 语法的过程,以及我解决由于版本更新后,Flutter 的 Android Studio 多语言本地化插件不能正常工作的方法~