背景
我们在原生开发中,经常性出现的场景就是等待编译,为了验证修改后的运行效果,必须要经过漫长的重新编译,才能同步到设备上,并且随着你工程的规模增加,编译时长也持续增加,这就诞生了组件化,二进制的一些操作,这里先不谈这个。
但是在flutter中,由于debug模式下支持JIT,并且为开发期的运行和调试提供了大量优化,因此代码修改后,我们可以通过亚秒级的热重载(hot reload)进行增量代码的快速刷新,而无需经过全量的代码编译,从而大大缩短了从修改到验证结果所需要的时间。
那flutter的热重载到底是如果实现的呢?
热重载
热重载就是在不中断App正常运行的情况下,动态注入修改后的代码。这个能力的背后,离不开flutter所提供的运行时编译能力。为了更好的理解flutter的热重载实现原理,我们先简单回顾一下flutter编译模式背后的技术。
- JIT(Just In Time),指的是即时编译编译或运行时编译,在debug模式中使用,可以动态下发和执行代码,启动速度快,但是执行性能受运行时编译影响。
- AOT(Ahead Of Time),指的是提前编译或运行前编译,在release模式下使用,可以为特定的平台生成稳定的二进制代码,执行性能好,运行速度快,但每次执行需要提前编译,开发调试效率低。
从上面的示意图,我们可以看到,flutter提供的两种编译模式中,AOT是静态编译,即编译成设备可执行的二进制码。而JIT是动态编译,即将Dart代码编译成中间代码(Script Snapshot),在运行时设备需要dart VM解释执行。
热重载之所以只能在debug模式下使用,是因为在debug模式下,flutter采用的是JIT动态编译,而在release模式下,采用的是AOT静态编译。JIT编译器将Dart代码编译成可以运行在dart VM上的Dart Kernel,而Dart Kernel是可以动态更新的,这就实现了代码的实时更新功能。
总结来说,热重载的流程可以分为扫描工程改动、增量编译、推送更新、代码合并、Widget重建5个步骤:
1.工程改动。热重载模块会注意扫描工程中的文件,检查是否有新增、删除或者改动,直到找到在上次编译之后,发生变化的Dart代码。
2.增量编译。热重载模块将发生变化的Dart代码,通过编译转化为增量的Dart Kernel文件。
3.推送更新。热重载模块将增量的Dart Kernel文件通过HTTP端口,发送给正在移动设备商运行的Dart VM。
4.代码合并。Dart VM会将收到的增量Dart Kernel文件,与原有的Dart Kernel文件进行合并,然后重新加载新的Dart Kernel文件
5.Widget重建。在确认Dart VM资源加载成功后,Flutter会将其UI线程重置,通知Flutter Framework重建Widget
可以看到,flutter提供的热重载在收到代码变更后,并不会让App重新启动执行,而只会触发widget树的重绘机制,因此可以保持改动前的状态,这就大大节省了调试复杂交互界面的时间。
不支持热重载的场景
flutter提供的亚秒级热重载一直是开发者的调试利器。通过热重载我们可以快速修改UI,调试bug。但是flutter的热重载也有一定的局限性,因为涉及到状态的保存与回复,所以并不是所有的代码改动都可以通过热重载来更新的。
下面几个就是不支持热重载的典型场景:
- 代码出现编译错误
- widget状态无法兼容
- 全局变量和静态属性的更改
- main方法里的更改
- initState方法里的更改
- 枚举和泛类型更改
下面我们对这几个典型场景做一下具体说明,遇到这些情况如何解决
代码出现编译错误
这个很好理解,就是更改代码导致的无法编译通过,此时热重载会提示错误信息,更正代码就可以继续使用热重载了
Widget状态无法兼容
当代码更改会影响Widget的状态时,会使得热重载前后Widget所使用的数据不一致,即应用程序保留的状态与新的更改不兼容,此时热重载是无法使用的。
//改动前
class MyWidget extends StatelessWidget {
Widget build(BuildContext context) {
return GestureDetector(onTap: () => print('T'));
}
}
//改动后
class MyWidget extends StatefulWidget {
@override
State<MyWidget> createState() => MyWidgetState();
}
class MyWidgetState extends State<MyWidget> { /*...*/ }
这种情况下,我们需要重启应用,才能使更新的内容生效。
全局变量和静态属性的变更
在flutter中,全局变量和静态属性都被视为状态,在第一次运行应用程序是,会将他们的值设为初始化语句执行的结果,因此在热重载期间不会重新初始化。
比如下面的代码中,我们修改了一个静态 Text 数组的初始化元素。虽然热重载并不会报错,但由于静态变量并不会在热重载之后初始化,因此这个改变并不会产生效果:
//改动前
final sampleText = [
Text("T1"),
Text("T2"),
];
//改动后
final sampleText = [
Text("T1"),
Text("T10"), // 改动点
];
如果我们需要更改全局变量和静态属性的初始化语句,重启应用才能查看更改效果。
main方法里的修改
在 flutter 中,由于热重载之后只会根据原来的根节点重新创建控件树,因此 main 函数的任何改动并不会在热重载后重新执行。所以,如果我们改动了 main 函数中的代码,是无法通过热重载看到更新效果的。
//更新前
class MyAPP extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const Center(child: Text('Hello World', textDirection: TextDirection.ltr));
}
}
void main() => runApp(new MyAPP());
//更新后
void main() => runApp(const Center(child: Text('Hello, 2022', textDirection: TextDirection.ltr)));
由于 main 函数并不会在热重载后重新执行,因此以上改动是无法通过热重载查看更新的.
initState方法里的更改
在热重载时,flutter 会保存 Widget 的状态,然后重建 Widget。而 initState 方法是 Widget 状态的初始化方法,这个方法里的更改会与状态保存发生冲突,因此热重载后不会产生效果。
在下面的例子中,我们将计数器的初始值由2021改为2022:
//更改前
class _MyHomePageState extends State<MyHomePage> {
int _counter;
@override
void initState() {
_counter = 2021;
super.initState();
}
...
}
//更改后
class _MyHomePageState extends State<MyHomePage> {
int _counter;
@override
void initState() {
_counter = 2022;
super.initState();
}
...
}
由于这样的改动发生在 initState 方法中,因此无法通过热重载查看更新,我们需要重启应用,才能看到更改效果.
枚举和泛类型更改
在 flutter 中,枚举和泛型也被视为状态,因此对它们的修改也不支持热重载。比如在下面的代码中,我们将一个枚举类型改为普通类,并为其增加了一个泛型参数:
//更改前
enum Color {
red,
green,
blue
}
class C<U> {
U u;
}
//更改后
class Color {
Color(this.r, this.g, this.b);
final int r;
final int g;
final int b;
}
class C<U, V> {
U u;
V v;
}
这两类更改都会导致热重载失败,并生成对应的提示消息。同样的,我们需要重启应用,才能查看到更改效果。
总结
Flutter 的热重载是基于 JIT 编译模式的代码增量同步。由于 JIT 属于动态编译,能够将 Dart 代码编译成生成中间代码,让 Dart VM 在运行时解释执行,因此可以通过动态更新中间代码实现增量同步。而另一方面,由于涉及到状态保存与恢复,因此涉及状态兼容与状态初始化的场景,热重载是无法支持的,比如改动前后 Widget 状态无法兼容、全局变量与静态属性的更改、main 方法里的更改、initState 方法里的更改、枚举和泛型的更改等。
可以发现,热重载提高了调试 UI 的效率,非常适合写界面样式这样需要反复查看修改效果的场景。但由于其状态保存的机制所限,热重载本身也有一些无法支持的边界。