Flutter的热重载原理

2022-03-28 09:13:27 浏览数 (1)

Flutter的热重载功能,想必诸位都已经使用过好多次了。它的使用流程很简单,终端输入r或者R即可;但是其内部实现是非常复杂的,今天这篇文章,我们就是通过下断点这种动态调试的方式来一步一步在这些复杂的实现代码中去找到核心的流程。

Flutter的热重载速度非常快,那么它是怎么做到如此快速的热重载的呢

我在《LLVM(一)——编译流程》一文中介绍了,OC和Swift都是编译型语言,源代码通过LLVM编译器,经过编译预处理、词法分析、语法分析、语义分析、优化、生成汇编代码、最终生成二进制可执行文件。这是典型的AOT。

关于AOT和JIT,我在我之前的很多文章中都有过介绍,现在为大家罗列如下:

  • React Native、Flutter等,这些跨端方案怎么选?
  • Dart语言概览
  • Flutter区别于其他技术的关键是什么?

Flutter是使用的Dart这门语言,而Dart是运行在DartVM中的。DartVM,即Dart虚拟机,它的作用就是将Dart源代码编译成二进制可执行文件,只不过它同时支持AOT和JIT两种方式(LLVM只支持AOT),因此,我们说Dart既是一门编译型语言也是一门解释型语言。

在开发的时候,Flutter采用JIT即时编译,对于我们已经写出来的Dart代码,是边解释边执行。现在程序员改了两行代码,FlutterSDK会先找到改动的文件,然后将改动的文件丢给DartVM,Dart虚拟机会将改动后的文件与原文件进行对比,找到改动的代码,然后DartVM会只读这两句改动的代码,并不会将所有的代码再重新读取解释一遍,这就大大节省了解释的时间,因此,Flutter中的热重载速度非常快。

现在我们考虑这么一个问题,既然我们可以做到压秒级别的热重载,那么是否可以做到压秒级别的热更新呢?答案是,这在技术上是可以实现的。既然在技术上可以实现热更新,那么为什么现在市面上没有iOS的热更新技术存在呢?原因就是苹果基于自身利益以及用户安全的考虑,不允许使用了热更新技术的APP上线。那么这是不是意味着子啊iOS设备上的所有App就完全不能够使用热更新技术了呢?答案是否定的。假如我们的团队真的有热更新的需求,并且也有足够的资金支持,那么就可以专门组一个团队来研究Flutter引擎,进而实现Flutter中的热更新。

废话不多说,我们接下来玩一下。

一、热重载的挂载

首先创建一个Flutter Application Demo——flutter_hot_reload_demo,然后运行。运行完了之后,修改一下顶部栏标题,然后Command s保存,可以看到标题随即就变成刚才修改的标题了,这就是所谓的热重载。

那么这个热重载到底是咋实现的,它里面到底干了些啥,我们接下来就研究一下。

首先我们找到FlutterSDK中的热重载工具,如下:

这个flutter_tools文件夹里面就包含了Flutter热重载的工具。

接下来我使用AS打开flutter_tools文件夹(直接将该文件夹拖动到AS即可),如下:

这里的flutter_tools.dart就是Flutter热重载的入口,然后我在其main函数里面打了个断点。

然后按照如下截图中的步骤进行配置:

第一步,点击Edit Configurations...

第二步,增加一个新的Configuration

第三步,配置新增的这个Configuration,配置完了之后点按Apply进行应用

第四步,有些伙伴可能在上面配置的时候会遇到找不到对应的Dart SDK的错误,那么此时就打开Preference,按照如下进行配置即可:

按照上面四步配置好了之后,运行项目了:

该热重载工具tools项目运行完了之后,我们再去看最初的那个Demo工程:

可以看到,最初运行的这个Demo工程失去连接了,这是为什么呢?按照我现在的理解,这是两个完全不同的工程啊,为什么我的tools工程运行之后,原来的Demo工程就失去连接了呢?

其实,在Demo工程运行的时候,它就会找到FlutterSDK中的flutter_tools文件夹,也就是说,Demo工程会依赖flutter_tools工程去做热重载的工作。所以,当你的flutter_tools工程(挂载了Demo工程)独立运行的时候,原来的Demo工程中的连接就会断开。

我现在在Demo工程中修改顶部栏的标题为“LAVIE 666”,但是我在Demo工程中不热重载(其实此时Demog工程跟设备已经断开连接了,你想在Demo工程中热重载也重载不了了),而是来到挂载了Demo工程的flutter_tools工程,然后在flutter_tools工程中的终端命令行输入r:

此时发现,App中的标题变为了“LAVIE 666”。

以上案例说明了,flutter_tools工程已经挂载起了我们的Demo工程。

二、热重载功能的启动流程

现在我们已经顺利地给FlutterSDK中的热重载工具项目挂载上了一个Demo示例工程,接下来我们就可以进行调试了。

在flutter_tools工程中,入口处打一个断点,然后Debug模式运行,运行之后查看打印台,发现如下信息:

这里红框内的内容就是DartVM的信息展示。DartVM可以将Dart语言编译成汇编语言,所以在应用程序一启动的时候就会启动DartVM。我们点进上面红框中的网址:

可以看到这里面展示的就是当前正在解释执行的代码内容。实际上,该网址中展示的就是DartVM的服务,将来如果说自己去搞Flutter热更新的话,那么就将该DartVM的服务放到自己的服务器上面即可。

接下来我发现,程序断到了断点处:

点击args变量,发现它是一个数组,其中有一个元素是run:

实际上,这里的这个run就是我上面在Edit Configurations的时候配置的Program arguments:

接下来我们来到main函数中:

我们可以在终端中输入flutter doctor、flutter run这些命令,这些命令之所以能够被响应,就是因为Flutter在这里(上图红框部分)做了处理。将这些参数处理完了之后,会执行runner.run()函数,我们点进去:

再点进去:

再点进去:

这里的runCommand翻译成中文就是“运行命令”的意思,点进runCommand函数:

可以看到,在这里面可以遍历设备信息并且拿到有效设备的信息:

此时再点击断点的下一步,会发现终端控制台会打印出如下信息:

此时,程序跑到Xcode中去了,那么它是怎么跑到Xcode中去的呢?

Conmand shift f进行全局搜索,搜索“Running Xcode build...”:

然后在mac.dart中找到调用打印的地方

可以看到,在Flutter引擎中,是通过mac.dart文件中的buildXcodeProject函数去编译构建Xcode工程的,buildXcodeProject函数里面执行了一些脚本操作和打印操作。

最后会走到setUpTerminal函数,如下:

setUpTerminal函数的作用,实际上就是监听终端里面r、R等命令的输入。可以看到,里面调用了printHelp函数,我们通过断点找到其实现:

可以看到,residentRunner.printHelp的作用就是在终端控制台打印相关帮助信息

好,现在终端的帮助信息打印完了,接下来就要监听终端的输入了,也就是下面红框中的内容:

到现在为止,热重载功能已经启动完毕了,之后我们就可以通过终端输入 r 来进行热重载了。上面提到的flutter_tools.dart中的main函数实际上是tools里面各种工具初始化的入口,它里面包含了热重载的功能,那么真正的热重载功能的入口是什么呢?也就是说,当我们输入r进行热重载的时候,底层是做了什么事情呢?那就需要研究一下这里的listen函数做了什么。

三、热重载是如何找到增量文件的

接着上面讲,我们先来看下listen函数的实现:

代码语言:javascript复制
  StreamSubscription<T> listen(void onData(T event)?,
      {Function? onError, void onDone()?, bool? cancelOnError});

可以看到,这里面传入的参数是一个函数回调,也就是说,后面会调用到该函数。

接下来我找到传入listen函数的processTerminalInput:

接着看下_commonTerminalInputHandler的实现:

代码语言:javascript复制
  /// Returns [true] if the input has been handled by this function.
  Future<bool> _commonTerminalInputHandler(String character) async {
    _logger.printStatus(''); // the key the user tapped might be on this line
    switch (character) {
      case 'a':
        return residentRunner.debugToggleProfileWidgetBuilds();
      case 'b':
        return residentRunner.debugToggleBrightness();
      case 'c':
        _logger.clear();
        return true;
      case 'd':
      case 'D':
        await residentRunner.detach();
        return true;
      case 'g':
        await residentRunner.runSourceGenerators();
        return true;
      case 'h':
      case 'H':
      case '?':
        // help
        residentRunner.printHelp(details: true);
        return true;
      case 'i':
        return residentRunner.debugToggleWidgetInspector();
      case 'I':
        return residentRunner.debugToggleInvertOversizedImages();
      case 'L':
        return residentRunner.debugDumpLayerTree();
      case 'o':
      case 'O':
        return residentRunner.debugTogglePlatform();
      case 'M':
        if (residentRunner.supportsWriteSkSL) {
          await residentRunner.writeSkSL();
          return true;
        }
        return false;
      case 'p':
        return residentRunner.debugToggleDebugPaintSizeEnabled();
      case 'P':
        return residentRunner.debugTogglePerformanceOverlayOverride();
      case 'q':
      case 'Q':
        // exit
        await residentRunner.exit();
        return true;
      case 'r':
        if (!residentRunner.canHotReload) {
          return false;
        }
        final OperationResult result = await residentRunner.restart();
        if (result.fatal) {
          throwToolExit(result.message);
        }
        if (!result.isOk) {
          _logger.printStatus('Try again after fixing the above error(s).', emphasis: true);
        }
        return true;
      case 'R':
        // If hot restart is not supported for all devices, ignore the command.
        if (!residentRunner.supportsRestart || !residentRunner.hotMode) {
          return false;
        }
        final OperationResult result = await residentRunner.restart(fullRestart: true);
        if (result.fatal) {
          throwToolExit(result.message);
        }
        if (!result.isOk) {
          _logger.printStatus('Try again after fixing the above error(s).', emphasis: true);
        }
        return true;
      case 's':
        for (final FlutterDevice device in residentRunner.flutterDevices) {
          await residentRunner.screenshot(device);
        }
        return true;
      case 'S':
        return residentRunner.debugDumpSemanticsTreeInTraversalOrder();
      case 't':
      case 'T':
        return residentRunner.debugDumpRenderTree();
      case 'U':
        return residentRunner.debugDumpSemanticsTreeInInverseHitTestOrder();
      case 'v':
      case 'V':
        return residentRunner.residentDevtoolsHandler.launchDevToolsInBrowser(flutterDevices: residentRunner.flutterDevices);
      case 'w':
      case 'W':
        return residentRunner.debugDumpApp();
    }
    return false;
  }

可以看到,这里对终端输入的各种信息进行了处理。其中,热重载 r 的处理信息如下:

代码语言:javascript复制
      case 'r':
        if (!residentRunner.canHotReload) {
          return false;
        }
        final OperationResult result = await residentRunner.restart();
        if (result.fatal) {
          throwToolExit(result.message);
        }
        if (!result.isOk) {
          _logger.printStatus('Try again after fixing the above error(s).', emphasis: true);
        }
        return true;

这里的核心就是调用residentRunner.restart(),接下来我在此处打个断点,并且在终端输入 r ,之后断点来到这里:

然后我们断点点进restart函数(位于run_hot.dart文件下):

然后再来到_hotReloadHelper函数:

然后来到_reloadSources函数:

这里需要说明的一点就是,热重载的时候在控制台中打印的所需耗时(如下图)就是通过该变量来记录的

最终会调用_updateDevFS函数,该函数的作用就是去找到需要更新的代码(Flutter中的热重载是增量更新),它是怎么做的呢,我们看下其源码:

接下来来到updateDevFS函数的实现中:

这里的mainUri就是项目主路径。接下来我们来到devFS.update函数中,这是查找增量更新代码的最核心方法:

这里的content记录的就是本次更改的代码信息,我接下来在该处打个断点,看一下content的值:

可以看到,content里面存了一个file路径,我将该路径的值进行拷贝,然后来到桌面,Command Shift g,然后粘贴路径,将最后面的单引号以及最前面的LocalFile: '给删除,然后回车,就来到了如下路径:

app.dill.incremental.dill这个文件中承载的就是我们增量改动的代码信息

接下来再回到devFS.update函数中:

可以看到,在获取到单个文件中的代码变动信息之后,会以该文件的路径作为Key,以承载变动代码信息的文件作为Value存入Map中,然后将汇总了所有变动代码信息的Map通过网络传输给Dart虚拟机DartVM在接受到了承载了所有增量代码信息的Map(Map中存储的是所有有代码变动的文件信息)之后,会根据Map中的增量代码信息,去做文件对比,然后找到真正需要更新的代码,去解释执行和渲染

四、将代码变动文件信息传输给DartVM

现在断点断到了下面这里:

我们现在跳出代码层面想一下,文件改动了之后,我现在拿到了文件的改动信息,接下来就是将改动的代码给渲染出来,而从源代码到最终渲染到屏幕上这中间是有很多步骤的(词法分析、语法分析、语义分析、优化、生成汇编、生成字节码,然后交给Flutter引擎去渲染),其中从词法分析到生成字节码的这个编译阶段是通过DartVM实现的,DartVM是部署在服务器上面的(当前是在本地服务器,如果我们自己去自定义Flutter引擎的话,也有可能会将DartVM部署在自己的服务器上面),而Flutter引擎是被打包进你的项目工程当中的,因此Flutter引擎是存在于你的设备当中的。所在在拿到代码文件的变动信息之后,需要先将其传给DartVM,而上图中的红框内做的事情就是将代码文件的变动信息发送给DartVM。

断点走到这里之后,我查看下_httpWriter中的内容:

可以看到,_httpWriter中的地址跟项目一开始启动的时候启动的DartVM的地址是一样的,这就说明,这里的_httpWriter其实就是操作的DartVM,也就是说,(devFSWriter ?? _httpWriter).write这个写入操作就是向DartVM中去写入代码文件变动信息。

接下来研究下write的代码:

再来到_scheduleWrites:

再来到_startWrite:

代码语言:javascript复制

可以看到,_startWrite函数中实际上就是发送了一个Http网络请求,将代码变动信息传输给DartVM的服务器(这里是本地服务器)

我们前面也已经提到过了,DartVM是部署到本地服务器上面的,部署DartVM的服务器我们称之为VMServer。通过终端的打印信息我们也已经知道了,VMServer是在应用程序一启动的时候创建的。接下来我们就从代码层面去研究一下VMServer的创建以及DartVM的构建流程。

首先,Command Shift O搜索“vmservice.dart”,找到该文件。然后在VmService类的构造函数中打一个断点:

然后重新Debug运行,通过断点调试以及控制台打印我们可以看到,应用程序启动之后,先创建VMServer和DartVM,然后进入flutter_tools工程的主入口函数main,然后run(拿到设备信息、启动Xcode、编译执行),然后来到VmService类的构造函数中。其实本地的VMServer和DartVM在应用程序一启动的时候就已经启动了,这里创建的VmService类的作用就是去关联链接一开始创建的DartVM, 这样的话才可以在后面热重载的时候将变动文件传输给DartVM。

DartVM在接收到代码变动文件信息之后,会读取这些文件,然后最后输出变动的Dart源代码文件,之后将之传递给Flutter引擎做渲染

五、flutter_tools工程、Flutter Engine工程以及Flutter示例工程的联调

在本篇文章中,我们介绍了如何将热重载的flutter_tools工程挂载到示例工程上面,而在《Flutter引擎——下载、编译和调试》我介绍了如何通过在Xcode的Generated配置文件中进行配置来将自定义的engine工程挂载到示例工程上面。

接下来我来介绍一下另外一种给示例工程挂载自定义引擎的方法。

首先将如下代码进行拷贝:

代码语言:javascript复制
--local-engine-src-path /Users/liwei/Flutter/engine/src --local-engine=ios_debug_unopt

这里的 /Users/liwei/Flutter/engine/src 就是本地自定义engine的src路径,ios_debug_unopt就是当前是使用哪种架构下的engine。

复制完成之后,使用AndroidStudio打开热重载的示例工程,然后Edit Configurations...,然后将内容粘贴到Additional run args这一栏,最后Apply:

然后再次运行,运行成功之后,使用Xcode 打开热重载示例工程的iOS工程,然后找到Generated配置文件:

会发现多出了两个配置(红框内),而这里多出来的两个配置其实正是上面在AS中进行的配置,二者是对应的。

好,这里配置完了之后,我们自定义的引擎就已经挂载到了Flutter热重载示例工程当中了。至此,Flutter示例工程、自定义的Flutter Engine工程以及flutter_tools工程这三者就关联到一起了

我现在想将Flutter示例工程中的Xcode工程与其他的Flutter工程关联起来,这个时候该怎么办呢?其实很简单,首先我们在flutter_tools工程中使用AS运行项目,这个时候就将关联的示例工程给运行起来了,接下来我将Xcode工程附加到该实例工程里面来

打开Flutter示例工程的iOS工程,选择Debug->Attach to process->Runner:

此时会报如下错误:

遇到这样的问题就去Google一下,并且来回多试几遍,我反正是试着试着就附加上了:

接下来我们通过一个例子来走一遍跟踪引擎中某个方法的流程

首先在Flutter Engine工程中去搜索isolate_reload.cc文件:

我们知道,DartVM将源代码读完了之后,会找到Flutter引擎进行渲染,而DartVM和FlutterEngine之间的通信,就是通过IsolateGroupReloadContext::Reload方法进行的

接下来,通过Xcode打开Flutter示例工程的iOS工程,并且附加好Process,然后Pause program execution,然后在终端下符号断点:

代码语言:javascript复制
br set -n "IsolateGroupReloadContext::Reload"

然后终端输入c(continue)将该断点过掉,此时应用程序处于运行状态:

接下来回到flutter_tools工程中,在控制台输入r进行热重载,此时会发现,在XCode工程中断到了断点,如下:

然后依次往下走,最后走到如下断点:

于是,我们就在Xcode中断到了IsolateGroupReloadContext::Reload方法。

六、总结

1,应用程序启动起来了,此时修改了源代码;

2,依次找到修改了代码的文件,并据此生成一个增量代码文件,最后生成一个存储了许多增量代码文件的Map;

3,将生成的增量代码文件信息通过VMService写入(本质是发送Http请求进行文件传输)到DartVM;

4,DartVM对获取到的增量代码文件进行解析解读,然后生成最终需要修改的源代码,并进行解释,最终生成汇编代码;

5,将汇编代码交给Flutter Engine进行渲染

以上。

0 人点赞