浅谈Flutter热重载(上)

更新记录

  • 本文完成于 本文写于 2019.09.10,Flutter SDK 版本为 v1.5.4-hotfix.2
  • 2019.09.12 更新,将差别包字眼变动为增量包
  • 2019.09.12 更新,--not-hot 写错,应该为 --no-hot

前言

这是浅谈 Flutter 系列的第二篇,上一篇是 浅谈Flutter构建,在上一篇中,主要是理清 Flutter 在 debug 和 release 模式下生成的不一样产物分别是什么,怎么调试 build_tools 源码等等,这些不会在后面重复讨论,因此有须要的同窗能够先看下第一篇。android

热重载是 Flutter 的一个大杀器,很是受欢迎,特别是对于客户端开发的同窗来讲,项目大了之后,可能就会出现,代码改一行,构建半小时的场面。以前很是火热的组件化方案其实一点就是为了解决构建时间过长的痛点。而对于 Flutter 来讲,有两种模式能够快速应用修改:hot reload(热重载)和 hot restart(热重启),其中 hot reload 只须要几百毫秒就能够完成更新,速度很是快,hot restart 稍微慢一点,须要秒单位。在修改了资源文件或须要从新构建状态,只能使用 hot restart。git

源码解析

在第一篇文章中,咱们说到,对于每一个 Flutter 命令,都有一个 Command 类与之对应,咱们使用的 flutter run 是由 RunCommand 类处理的。github

默认在 debug 模式下会开启 hot mode,release 模式下默认关闭,能够在执行 run 命令的时候,添加 --no-hot 来禁用 hot mode。web

当启用 hot mode 时,会使用 HotRunner 来启动 Flutter 应用。正则表达式

if (hotMode) {                                          
  runner = HotRunner(                                   
    flutterDevices,                                     
    target: targetFile,                                 
    debuggingOptions: _createDebuggingOptions(),        
    benchmarkMode: argResults['benchmark'],             
    applicationBinary: applicationBinaryPath == null    
        ? null                                          
        : fs.file(applicationBinaryPath),               
    projectRootPath: argResults['project-root'],        
    packagesFilePath: globalResults['packages'],        
    dillOutputPath: argResults['output-dill'],          
    saveCompilationTrace: argResults['train'],          
    stayResident: stayResident,                         
    ipv6: ipv6,                                         
  );                                                    
} 
复制代码

hot mode 开启后,首先会进行初始化,这部分相关的代码在 HotRunner run()json

初始化

  • 构建应用,以 Anroid 为例,这里会调用 gradle 去执行 assemble task 来生成 APK 文件api

    if (!prebuiltApplication || androidSdk.licensesAvailable && androidSdk.latestVersion == null) {   
      printTrace('Building APK');                                                                     
      final FlutterProject project = FlutterProject.current();                                        
      await buildApk(                                                                                 
          project: project,                                                                           
          target: mainPath,                                                                           
          androidBuildInfo: AndroidBuildInfo(debuggingOptions.buildInfo,                              
            targetArchs: <AndroidArch>[androidArch]                                                   
          ),                                                                                           
      );                                                                                              
      // Package has been built, so we can get the updated application ID and 
      // activity name from the .apk. 
      package = await AndroidApk.fromAndroidProject(project.android);                                 
    }                                                                                                 
    复制代码
  • 构建 APK 成功,则会使用 adb 启动它,并创建 sockets 链接,转发主机的端口到设备上。bash

    这里的主机指的是,运行 Flutter 命令的环境,通常是 PC。设备指的是,运行 Flutter 应用的环境,这里指手机。markdown

    转发端口的意义是为了与设备上 Dart VM(虚拟机)进行通讯,这个后面会说到。app

    在使用 adb 启动应用后,会监听 log 输出,使用正则表达式去获取 sockets 链接地址后,设置端口转发。

    void _handleLine(String line) {                                                                                  
      Uri uri;                                                                                                       
      final RegExp r = RegExp('${RegExp.escape(serviceName)} listening on ((http|\/\/)[a-zA-Z0-9:/=_\\-\.\\[\\]]+)');
      final Match match = r.firstMatch(line);                                                                        
                                                                                                                     
      if (match != null) {                                                                                           
        try {                                                                                                        
          uri = Uri.parse(match[1]);                                                                                 
        } catch (error) {                                                                                            
          _stopScrapingLogs();                                                                                       
          _completer.completeError(error);                                                                           
        }                                                                                                            
      }                                                                                                              
                                                                                                                     
      if (uri != null) {                                                                                             
        assert(!_completer.isCompleted);                                                                             
        _stopScrapingLogs();                                                                                         
        _completer.complete(_forwardPort(uri));                                                                      
      }                                                                                                              
                                                                                                                     
    }
    
    // 转发端口
    Future<Uri> _forwardPort(Uri deviceUri) async {                                                         
      printTrace('$serviceName URL on device: $deviceUri');                                                 
      Uri hostUri = deviceUri;                                                                              
                                                                                                            
      if (portForwarder != null) {                                                                          
        final int actualDevicePort = deviceUri.port;                                                        
        final int actualHostPort = await portForwarder.forward(actualDevicePort, hostPort: hostPort);       
        printTrace('Forwarded host port $actualHostPort to device port $actualDevicePort for $serviceName');
        hostUri = deviceUri.replace(port: actualHostPort);                                                  
      }                                                                                                     
                                                                                                            
      assert(InternetAddress(hostUri.host).isLoopback);                                                     
      if (ipv6) {                                                                                           
        hostUri = hostUri.replace(host: InternetAddress.loopbackIPv6.host);                                 
      }                                                                                                     
                                                                                                            
      return hostUri;                                                                                       
    }                                                                                                       
    复制代码

    在个人设备上,匹配地址以下:

    09-08 14:14:12.708  6122  6149 I flutter : Observatory listening on http://127.0.0.1:45093/6p_NsmXILHw=/
    复制代码
  • 根据第二步创建的 sockets 链接地址和转发的端口,创建 RPC 通讯,这里使用的 json_rpc_2

    关于 Dart VM 支持的 RPC 方法能够看这里:Dart VM Service Protocol 3.26

    关于 JSON-RPC,能够看这里:JSON-RPC 2.0 Specification

    注意:Dart VM 只支持 WebSocket,不支持 HTTP。

    "The VM will start a webserver which services protocol requests via WebSocket. It is possible to make HTTP (non-WebSocket) requests, but this does not allow access to VM events and is not documented here."

    static Future<VMService> connect(                                                                            
      Uri httpUri, {                                                                                             
      ReloadSources reloadSources,                                                                               
      Restart restart,                                                                                           
      CompileExpression compileExpression,                                                                       
      io.CompressionOptions compression = io.CompressionOptions.compressionDefault,                              
    }) async {                                                                                                   
      final Uri wsUri = httpUri.replace(scheme: 'ws', path: fs.path.join(httpUri.path, 'ws'));                   
      final StreamChannel<String> channel = await _openChannel(wsUri, compression: compression);                 
      final rpc.Peer peer = rpc.Peer.withoutJson(jsonDocument.bind(channel), onUnhandledError: _unhandledError); 
      final VMService service = VMService(peer, httpUri, wsUri, reloadSources, restart, compileExpression);      
      // This call is to ensure we are able to establish a connection instead of 
      // keeping on trucking and failing farther down the process. 
      await service._sendRequest('getVersion', const <String, dynamic>{});                                       
      return service;                                                                                            
    }                                                                                                            
    复制代码

    关于 Dart VM 具体的使用,能够看 FlutterDevice.getVMs()FlutterDevice.refreshViews() 两个函数。

    getVMs() 用于获取 Dart VM 实例,最终调用的是 getVM 这个 RPC 方法:

    @override                                                             
    Future<Map<String, dynamic>> _fetchDirect() => invokeRpcRaw('getVM'); 
    复制代码

    getVM

    refreshVIews() 用于获取最新的 FlutterView 实例,最终调用的是 _flutter.listViews 这个 RPC 方法:

    // When the future returned by invokeRpc() below returns, 
    // the _viewCache will have been updated. 
    // This message updates all the views of every isolate. 
    await vmService.vm.invokeRpc<ServiceObject>('_flutter.listViews');     
    复制代码

    这个方法不属于 Dart VM 定义的,是 Flutter 额外扩展的方法,定义位于 Engine-specific-Service-Protocol-extensions

    listViews

  • 这是初始化的最后一步,使用 devfs 管理设备文件,当执行热重载时,会从新生成增量包再同步到设备上。

    首先,会在设备上生成一个目录,用于存放重载的资源文件和增量包。

    @override                                                                             
    Future<Uri> create(String fsName) async {                                             
      final Map<String, dynamic> response = await vmService.vm.createDevFS(fsName);       
      return Uri.parse(response['uri']);                                                  
    }                                                                               
    
    /// Create a new development file system on the device. 
    Future<Map<String, dynamic>> createDevFS(String fsName) {                           
      return invokeRpcRaw('_createDevFS', params: <String, dynamic>{'fsName': fsName}); 
    }                                                                                   
    复制代码

    生成的 Uri 相似这种:file:///data/user/0/com.example.my_app/code_cache/my_appLGHJYJ/my_app/,每一个 FlutterDevice 都会有个 DevFS devFS 用于封装对设备文件的同步。设备上建立的目录以下:

    code_cache

    每执行一次 flutter run 都会生成一个新的 my_appXXXX 目录,修改的资源都会同步到这个目录中。

    注意这里我是用的测试项目 my_app

    在生成目录后,会同步一次资源文件,将 fonts、packages、AssetManifest.json 等同步到设备中。

    final UpdateFSReport devfsResult = await _updateDevFS(fullRestart: true);
    复制代码

    code_cache_my_app

监听输入

当修改了 dart 代码后,咱们须要输入 r 或者 R 来使得咱们的修改生效,其中 r 表示 hot reload,R 表示 hot restart。

首先,须要先注册输入处理函数:

void setupTerminal() {                               
  assert(stayResident);                              
  if (usesTerminalUI) {                              
    if (!logger.quiet) {                             
      printStatus('');                               
      printHelp(details: false);                     
    }                                                
    terminal.singleCharMode = true;                  
    terminal.keystrokes.listen(processTerminalInput);
  }                                                  
}                                                    
复制代码

当输入 r 时,最终会调用到 restart(false) 这个方法:

if (lower == 'r') {                                                             
  OperationResult result;                                                       
  if (code == 'R') {                                                            
    // If hot restart is not supported for all devices, ignore the command. 
    if (!canHotRestart) {                                                       
      return;                                                                   
    }                                                                           
    result = await restart(fullRestart: true);                                  
  } else {                                                                      
    result = await restart(fullRestart: false);                                 
  }                                                                             
  if (!result.isOk) {                                                           
    printStatus('Try again after fixing the above error(s).', emphasis: true);  
  }                                                                             
}                                                     
复制代码

restart() 函数的核心代码在 _reloadSources() 函数中,这个函数的主要做用以下:

  • 调用 _updateDevFS() 方法,生成增量包,并同步到设备上,DevFS 用于管理设备文件系统。

    首先比较资源文件的修改时间,判断是否须要更新:

    // Only update assets if they have been modified, or if this is the 
    // first upload of the asset bundle. 
    if (content.isModified || (bundleFirstUpload && archivePath != null)) {  
      dirtyEntries[deviceUri] = content;                                     
      syncedBytes += content.size;                                           
      if (archivePath != null && !bundleFirstUpload) {                       
        assetPathsToEvict.add(archivePath);                                  
      }                                                                      
    }                                                                        
    复制代码

    dirtyEntries 用于存放须要更新的内容,syncedBytes 计算须要同步的字节数。

    接着,生成代码增量包,以 .incremental.dill 结尾:

    final CompilerOutput compilerOutput = await generator.recompile(                                              
      mainPath,                                                                                                   
      invalidatedFiles,                                                                                           
      outputPath:  dillOutputPath ?? getDefaultApplicationKernelPath(trackWidgetCreation: trackWidgetCreation),   
      packagesFilePath : _packagesFilePath,                                                                       
    );                                                                                                            
    复制代码

    最后经过 http 写入到设备中:

    if (dirtyEntries.isNotEmpty) {                                                        
      try {                                                                               
        await _httpWriter.write(dirtyEntries);                                            
      } on SocketException catch (socketException, stackTrace) {                          
        printTrace('DevFS sync failed. Lost connection to device: $socketException');     
        throw DevFSException('Lost connection to device.', socketException, stackTrace);  
      } catch (exception, stackTrace) {                                                   
        printError('Could not update files on device: $exception');                       
        throw DevFSException('Sync failed', exception, stackTrace);                       
      }                                                                                   
    }                                                                                     
    复制代码
  • 调用 reloadSources() 方法通知 Dart VM 从新加载 Dart 增量包,一样的这里也是调用的 RPC 方法:

    final Map<String, dynamic> arguments = <String, dynamic>{                                      
      'pause': pause,                                                                              
    };                                                                                             
    if (rootLibUri != null) {                                                                      
      arguments['rootLibUri'] = rootLibUri.toString();                                             
    }                                                                                              
    if (packagesUri != null) {                                                                     
      arguments['packagesUri'] = packagesUri.toString();                                           
    }                                                                                              
    final Map<String, dynamic> response = await invokeRpcRaw('_reloadSources', params: arguments); 
    return response;                                                                               
    复制代码
  • 最后调用 flutterReassemble() 方法从新刷新页面,这里调用的是 RPC 方法 ext.flutter.reassemble

    Future<Map<String, dynamic>> flutterReassemble() {                
      return invokeFlutterExtensionRpcRaw('ext.flutter.reassemble');  
    }                                                                 
    复制代码

关于增量包

咱们用一个很是简单的 DEMO 来看下生成的增量包的内容。DEMO 有两个 dart 文件,首先是 main.dart,这个是入口文件:

void main() => runApp(MyApp());          
                                         
class MyApp extends StatelessWidget {    
  @override                              
  Widget build(BuildContext context) {   
    return MaterialApp(                  
      title: 'Flutter Demo',             
      theme: ThemeData(                  
        primarySwatch: Colors.blue,      
      ),                                 
      home: HomePage(),                  
    );                                   
  }                                      
}                                        
复制代码

home.dart 也很是简单,就显示一个文本:

class HomePage extends StatelessWidget {   
  @override                                
  Widget build(BuildContext context) {     
    return Scaffold(                       
      body: Center(                        
        child: Text('Hello World'),        
      ),                                   
      appBar: AppBar(                      
        title: Text('My APP'),             
      ),                                   
    );                                     
  }                                        
}                                          
复制代码

这里咱们作两个地方的修改,首先是将主题颜色从 Colors.blue 改为 Colors.red,将 HomePage 中的 "Hello World" 改为 "Hello Flutter"。

修改完成后,在终端键入 r 后执行,会在 build 目录下生成 app.dill.incremental.dill,什么是 dill 文件?其实这里面就是咱们的代码产物,用于提供给 Dart VM 执行的。咱们用 strings 命令查看下内容:

incremental.dill

修改的内容已经包含在增量包中了,当咱们执行 _updateDevFS() 方法后,incremental.dill 也被同步到设备中了。

app_incremental_dill

名字虽然不同,但内容一致的。如今设备是已经包含了增量包,接着下来就是通知 Dart VM 刷新了,先调用 reloadSources(),最后调用 flutterReassemble(),执行完以后,咱们就能够看到新的界面了。

new_ui

总结

热重载功能的实现,首先是增量包的实现,这里咱们没有细讲,留到后面的文章中,生成的增量包,文件后缀以 incremental.dill 结尾,文件的同步则经过 adb 创建的 sockets 链接进行传输,并且这个 sockets 另一个很是重要的功能就是,创建和 Dart VM 的 RPC 通讯,Dart VM 自己就已经定义了一些 RPC 方法,Flutter 又扩展了一些,获取 Dart VM 信息,刷新 Flutter 视图等等都是经过 RPC 实现的。

由于篇幅的缘由,这里咱们并无讲解增量包的生成实现,还有 Dart VM 和 Flutter engine 对 RPC 方法的实现,这个留到后面的文章。

写到这里,其实距离实现动态更新的目标也愈来愈清晰,第一,生成增量包;第二,在合适的时候,从新加载刷新增量包。

相关文章
相关标签/搜索