淺談Flutter熱過載(上)

奮鬥的Leo發表於2019-09-09

更新記錄

  • 本文完成於 本文寫於 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 原始碼等等,這些不會在後面重復討論,所以有需要的同學可以先看下第一篇。

熱過載是 Flutter 的一個大殺器,非常受歡迎,特別是對於客戶端開發的同學來說,專案大了以後,可能就會出現,程式碼改一行,構建半小時的場面。之前非常火熱的元件化方案其實一點就是為了解決構建時間過長的痛點。而對於 Flutter 來說,有兩種模式可以快速應用修改:hot reload(熱過載)和 hot restart(熱重啟),其中 hot reload 只需要幾百毫秒就可以完成更新,速度非常快,hot restart 稍微慢一點,需要秒單位。在修改了資原始檔或需要重新構建狀態,只能使用 hot restart。

原始碼解析

在第一篇文章中,我們說到,對於每個 Flutter 命令,都有一個 Command 類與之對應,我們使用的 flutter run 是由 RunCommand 類處理的。

預設在 debug 模式下會開啟 hot mode,release 模式下預設關閉,可以在執行 run 命令的時候,新增 --no-hot 來禁用 hot mode。

當啟用 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()

初始化

  • 構建應用,以 Anroid 為例,這裡會呼叫 gradle 去執行 assemble task 來生成 APK 檔案

    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 連線,轉發主機的埠到裝置上。

    這裡的主機指的是,執行 Flutter 命令的環境,一般是 PC。裝置指的是,執行 Flutter 應用的環境,這裡指手機。

    轉發埠的意義是為了與裝置上 Dart VM(虛擬機器)進行通訊,這個後面會說到。

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

寫到這裡,其實距離實現動態更新的目標也越來越清晰,第一,生成增量包;第二,在合適的時候,重新載入重新整理增量包。

相關文章