淺談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 方法的實現,這個留到後面的文章。

寫到這裏,其實距離實現動態更新的目標也愈來愈清晰,第一,生成增量包;第二,在合適的時候,從新加載刷新增量包。

相關文章
相關標籤/搜索