Flutter WebView 預載入實現 -- Http Server

語言: CN / TW / HK

背景

WebView是在APP中,可以很方便的展示web頁面,並且與web互動APP的資料。方便,並且更新內容無需APP釋出新版本,只需要將最新的web程式碼部署完成,使用者重新重新整理即可。

在WebView中,經常能夠聽到的一個需求就是:減少首次白屏時間,加快載入速度。因為載入web頁面,必然會受到網路狀況等的影響,無法像原生內容一樣把靜態內容秒加載出來。

分析

在原生Android和iOS中,有一種預快取資源,並在載入時攔截web請求,將事先快取好的資源替換上去,從而實現預載入的方案。

  • iOS常見的攔截的框架是CocoaHTTPServer / Telegraph
  • Android則是在WebViewClientshouldInterceptRequest去進行攔截

道理都是一樣的。

那麼,Flutter有沒有類似的方式去實現預載入web資源呢?

有!類似iOS中的CocoaHTTPServer,flutter也有一個HttpServer,可以發現,他們基本是一樣的功能,並且Flutter HttpServer支援Android和iOS。

HttpServer

HttpServer包含在http的包中,在pub.dev找到最新的版本加入即可。

```yaml dependencies: flutter: sdk: flutter

http: ^0.13.4 ``` 許可權要求

因為要http服務,所以需要配置一下允許各平臺的http請求。

啟動服務 dart abstract class HttpServer implements Stream<HttpRequest> dart var server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); HttpServer.bind方法會開啟偵聽對應Address的請求,第一個入參address可以自定,第二個port可以為0,也可以自定,為0的話,則由系統隨機分配一個臨時埠

非同步返回一個HttpServer,可以拿到最終的地址,也可以配置一些屬性 dart curAddresses = _server!.address.address; curPort = _server!.port; _server!.sessionTimeout = 60;

並且,可以設定攔截偵聽! dart serverSub = _server!.listen(_responseWebViewReq, onError: (e) => log(e, name: _logKey)); listen即常見的StreamSubscription,關閉時需要Cancel。 在listen的onDate中,會提供一個HttpRequest,即被攔截的請求的HttpRequest。 dart _responseWebViewReq(HttpRequest request) 我們可以取得其當前請求的Uri,並且可以根據不同的Uri,返回不同的結果給到該請求的response dart var uri = request.requestedUri; final data = await _getResponseData(uri); request.response.add(data); 也可以設定headers request.response.headers.add('Content-Type', '$mime; charset=utf-8'); finally,在所有請求結束時,關閉該response request.response.close();

至此,HttpServer攔截的功能就實現了。

接下來?

當然僅僅實現HttpServer攔截是不夠的,既然我們要實現預載入,最主要的攔截方案已經有了,那麼,接下來就需要考慮,資源的配置,資源的下載和儲存,版本的管理,如何根據實際url獲取對應HttpServer bind的url等。不在意的話也可以直接跳到最後看Demo。

PS:因為專案中命名為LocalServerWebview,所以後面程式碼中可能稱其為LocalServer。

資源配置

我們需要知道,哪些資源是需要被下載的,被使用在LocalServer服務中的。所以我設計了一個json配置檔案,儲存在服務端中,每次開啟App時下發。大致的格式為: json { "option": [ { "key": "test", "open": 1, "priority": 0, "version": "20222022" }, { "key": "test2", "open": 0, "priority": 0, "version": "20222222" } ], "assets": { "test": { "compress": "/local-server/test.zip" }, "test2": { "compress": "/local-server/test2.zip" } }, "basics": { "common": { "compress": "/local-server/common.zip", "version": "20220501" } }, "local_server_open": 1 } 主要根據我這邊的web專案配置,option為配置的對應webPath的開關下載優先順序版本號

assets中則是option對應的key的壓縮包地址(也可以一起寫在option中,不過實際業務中還有別的配置,所以就這樣吧)

basics則是統一資源的配置,比如common,所有web通用的js、json資源等,便統一下載,避免重複。

local_server_open是總開關,關閉時則LocalServer服務不會使用。

然後便是獲取到配置後,對符合條件的資源進行下載解壓和儲存。 dart // 觸發basics預下載 LocalServerDownloadService.instance.preloadBasicsData(json['basics'], basics, oldBasic); dart // 觸發assets預下載 LocalServerDownloadService.instance.preloadAssetsData(_diffAssets(value, assets));

下載解壓與本地儲存

這邊使用的Dio進行download, Dio().download(queueItem.zipUrl, zipPath).then((resp) { if (resp.statusCode != 200) { _log('下載ls 壓縮包失敗 err:${resp.statusCode} zipUrl:${queueItem.zipUrl}'); throw Exception('下載ls 壓縮包失敗 err:${resp.statusCode}'); } return unarchive(queueItem, zipPath); }) archive包進行解壓 ```dart // 找到對應zipUrl的本地檔案路徑 Directory saveDirct = LocalServerConfiguration.getCurrentZipPathSyncDirectory(item.zipUrl); final zipFile = File(downPath); if (!zipFile.existsSync()) { throw Exception('Local server 下載包檔案路徑不存在:$downPath'); }

List bytes = zipFile.readAsBytesSync(); Archive archive = ZipDecoder().decodeBytes(bytes); ··· // 清理之前的快取 File oldfile = File(downPath); if (oldfile.existsSync()) { oldfile.deleteSync(); } ``` zip檔案在解壓完成後會被清理,根據zipUrl來決定儲存的檔案路徑。 若已經存在資源,則無需下載。

若是下載失敗的話,會被標記為failure,在重啟app後的新下載任務中會重新嘗試。 也可以加個重試幾次的邏輯。 dart queueItem.loadState = LoadStateType.failure; queueItem.downloadCount += 1;

版本管理與更新

在配置json中可以看到version相關的設定,在上一步的下載解壓完成之後,會把檔案狀態、對應的option、assets、basics資料(版本)儲存起來。

首先檢查對應的版本號是否能對上,若對不上的話,舊的資料將不會用來去重,而是直接使用最新獲取到的配置進行下載和覆蓋。 dart // 處理 assets 資源,和版本控制 LocalServerConfigCache.getOptions().then((oldOptions) { // assets 快取和版本處理 LocalServerConfigCache.getAssets().then((value) { var oldAssets = value; // 版本不對,則移除,並需要下載 if (oldOptions != null) { for (var e in oldOptions) { var res = options.where((element) => element.key == e.key); if (res.isNotEmpty && res.first.version != e.version) { _log('資源 ${e.key} 需要更新'); oldAssets?.removeWhere((key, value) => key == e.key); } } } // 觸發預下載 LocalServerDownloadService.instance.preloadAssetsData(_diffAssets(value, assets)); **});** });

在預下載加入下載佇列前,會檢查之前儲存的檔案狀態,若是suceess,則跳過不進行下載。 dart _assetsBucket.forEach((key, value) { for (var tmpItem in value) { switch(tmpItem.loadState) { case LoadStateType.unLoad: case LoadStateType.loading: _addQueue(tmpItem); break; case LoadStateType.success: sucCount++; break; case LoadStateType.failure: _addQueue(tmpItem); break; } } });

獲取LocalServer Url並載入Webview

開啟Webview前,則需要開啟LocalServer服務,並且可以根據不同的url獲取得到對應的LocalServerUrldart return LocalServerService.instance.getLocalServerWebUrl(h5Path, query.isEmpty ? path : path + '?' + query);

dart String _getLocalServerWebUrl(String oriUrl, String localServerKey) { return 'http://${curAddresses ?? InternetAddress.loopbackIPv4.address}:$curPort$localServerKey'; } 其實就是在bind成功之後,將addressport儲存下來,並在獲取的時候將query與其拼接。

然後將處理後的url給到webview進行載入,即會觸發

這裡有個處理是將basics統一資源的連結,動態的新增到每個web頁面的資源列表裡。Binder在初始化配置和資源下載完成後,會儲存ConfigbasicCache到記憶體中。並且統記webpage開啟數量,避免HttpServer還在使用時被關閉。 dart @override void initState() { super.initState(); log('頁面開始載入:${DateTime.now()}', name: 'web-time'); _localServerBuilder = LocalServerCacheBinder()..initBinder(); LocalServerWebViewManager.instance.registerBuilder(_localServerBuilder); _innerUrl = _localServerBuilder.convertH5Url2LocalServerUrl(widget.url); }

WebView dart WebView( initialUrl: _innerUrl, debuggingEnabled: true, ··· )

兜底措施

會存在些情況就是,預載入的資源還沒有下載解壓完成或者說資源下載失敗了,使用者就開啟了Webview,這時候我們就需要用源連結(baseDomain)去實時獲取到資料來替換,避免web頁面異常。

```dart // 找不到本地檔案,使用網路下載拿到原始資料 var nowUri = request.requestedUri; var baseDomain = LocalServerCacheBinderSetting.instance.baseDomain; var baseUri = Uri.parse(baseDomain); // 替換為原始url nowUri = nowUri.replace( scheme: baseUri.scheme, host: baseUri.host, port: baseUri.port); // dio請求,responseType 必須是bytes var res = await Dio().getUri(nowUri, options: Options(responseType: ResponseType.bytes)); data = res.data; name = basename(nowUri.path.split('/').toList().last); mime = lookupMimeType(name);

request.response.headers.add('Content-Type', '$mime; charset=utf-8'); return data; ```

統一管理

最終所有的模組由一個manager進行統一管理,繼承LocalServerClientManger,設定相應的初始化和配置即可。 dart class LocalServerClientManager implements LocalServerStatusHandler, LocalServerDownloadServiceProtocol ```dart class LocalServerWebViewManager extends LocalServerClientManager {

factory LocalServerWebViewManager() => _getInstance();

static LocalServerWebViewManager get instance => _getInstance(); static LocalServerWebViewManager? _instance;

static LocalServerWebViewManager _getInstance() { _instance ??= LocalServerWebViewManager._internal(); return _instance!; }

LocalServerWebViewManager._internal();

/// 測試的配置 void initSetting() { init(); LocalServerCacheBinderSetting.instance.setBaseHost('https://jomin-web.web.app'); Map baCache = {'common': {'compress': '/local-server/common.zip', "version": "20220503"}}; LocalServerClientConfig localServerClientConfig = LocalServerClientConfig.fromJson({ 'option': [{'key': 'test-one', 'open': 1, 'priority': 0, "version": "20220503"}], 'assets': { 'test-one': {'compress': '/local-server/test-one.zip'} }, 'basics': baCache, }); prepareManager(localServerClientConfig); startLocalServer(); }

} ``` 可以寫對應的獲取配置json的方法,設定上去,然後在需要的時候開啟LocalServer。

展示與分析

Android模擬機展示 Android模擬機展示

分析

使用我這邊的幾個實際專案中的webview進行測試,對於越“靜態”的頁面的優化效果越好,就是說,可被LocalServer實際服務到的資源越多,首次載入的優化效果就越好。

比如純靜態頁面,iOS的載入完成時間,取20次首次載入的平均值, - 未開啟LocalServer的平均載入時間為343ms - 開啟LocalServer的平均載入時間為109ms (時間由Safari的網頁檢查器統計)

非首次則優化相對沒有這麼明顯,因為未開啟情況下除了html均會被快取。 - 未開啟LocalServer的非首次平均載入時間為142ms - 開啟LocalServer的非首次平均載入時間為109.4ms 未開啟的最快的載入時間還會比開啟的快。由html的載入速度決定。

若是非純靜態頁面,開啟和未開啟的時間都會受到網路狀況的影響,開啟LocalServer依舊有優化效果,

image.png 未開啟LocalServer

image.png 開啟LocalServer

但可以看到靜態資源的讀取速度LocalServer下依舊比較快,而其他的資源則不穩定了。

總結

對於打包到資源包中的資源,首次載入LocalServer可以有比較明顯的優化效果,且速度比較穩定,不會受到網路波動的影響。

但是呢,使用了LocalServer,便無法使用瀏覽器自身的快取,對於非首次情況優化效果不大。

並且,LocalServer可能會有更新的問題,何時去檢查配置是否有更新?或許可以通過長鏈下發通知的方式,但沒有長鏈的話就得考慮下其他的方法來解決更新及時性的問題了。

Demo

Demo地址:https://github.com/EchoPuda/local_server_webview

是個外掛形式,可以直接使用。 有些東西可以根據業務調整,比如新增特殊的配置、資源包是否要分包、LocalServer的服務也可以根據url來開啟不同的服務等。

我是觸發預載入後會將下載成功或已經成功的資源儲存到記憶體中,也可以在讀取時再進行對應的IO讀取檔案,速度會相應慢一點。