首頁 > 軟體

Flutter WebView效能優化使h5像原生頁面一樣優秀

2023-02-14 06:02:16

引言

WebView 的文章分兩篇

本篇和大家一起討論下效能優化的問題。

WebView 頁面的體驗上之所以不如原生頁面,主要是因為原生頁面可以馬上顯示出頁面骨架,一下子就能看到內容。WebView 需要先根據 url 去載入 html,載入到 html 後才能載入 css ,css 載入完成後才能正常顯示頁面內容,至少多出兩步網路請求。有的頁面是用 js 渲染的,這樣時間會更長。要想讓 WebView 頁面能接近 Flutter 頁面的體驗,主要就是要省掉網路請求的時間。

做優化要考慮到很多方面,在成本與收益之間做平衡。如果不是新開專案,需要考慮專案當前的情況。下面分兩種情況討論一下。

伺服器端渲染

頁面 html 已經在伺服器端拼接完成。只需要 html,css 就可以正常檢視頁面(主要內容不受影響)。如果你的專案的頁面是這樣的,那麼我們已經有了一個好的起點。

WebView 要顯示一個頁面,需要序列下面的過程。通過 url 載入到 html 後再載入 css,css 載入完成後顯示頁面。

url -> html -> css -> 顯示

我們可以對 css 的請求做一下優化。優化方案有兩種

  • 內聯 css 到 html
  • 把 css 快取到本地。

第一種方案比較容易做,修改一下頁面的打包方案即可。很容易實現一份程式碼打包出兩個頁面,一個外連 css ,一個內聯css。但壞處也是很明顯的,每次都載入同樣的 css,會增加網路傳輸,如果網路不佳的話,對首屏時間可能會產生明顯的影響。就算拋開首屏時間,也會對使用者的流量造成浪費。

第二種方案可以解決 css 重複打包的問題。首先要考慮的問題是:css 放在原生的哪個地方?

css 放哪裡

有兩個地方可以放

  • 放在 asset,和 app 一起打包釋出,好處是簡單可靠,壞處是不方便更新。
  • 放在 檔案目錄,好處是可以隨時更新,壞處是邏輯上會複雜一些。

檔案目錄用於儲存只能由該應用存取的檔案,系統不會清除該目錄,只有在刪除應用時才會消失。

從技術上來說,這兩種方案都是可以的。先說下不方便更新的問題:既然 app 的其它頁面都不能隨便更新,為什麼不能接受這個頁面的樣式不能隨便更新?如果是害怕版本衝突,那也好解決,發一次版,更新一次頁面地址,每個版本都有其對應的頁面地址,這樣就不會衝突了。根本原因是掌控的誘因,即使你能控制住誘因,你的老闆也控制不住。所以還是老老實實選第二種方案吧。

放哪裡的問題解決了,接下來要考慮的是如何更新 css 的問題。

更新 css

因為有可能 app 啟動後第一個展示的就是這個頁面,所以要在 app 啟動後第一時間就更新 css。但又有一個問題,每次啟動都更新同樣的內容是在浪費流量。解決辦法是加一個設定,每次啟動後第一時間載入這個設定,通過設定資訊來判斷要不要更新 css。

這個設定一定要很小,比如可以用二進位制 01 表示true false,當然了可能不需要這麼極端,用一個 map 就好。

如何利用本地 css 快速顯示頁面

在 app 上啟動一個本地 http server 提供 css。 我們可以在打包的時候把 css 的外連寫成本地 http,比如 http://localhost:8080/index.css

除了 css,頁面的重要圖片,字型等靜態資源也可以放在本地,只要載入到 html 就可以立即顯示頁面,省了一步需要序列的網路請求。

到這裡伺服器端渲染頁面的優化就完成了,還是很簡單的吧,範例程式碼在後面。

瀏覽器渲染

近年來,隨著 vue,react 的興起,由 js 在瀏覽器中拼接 html 逐漸成為主流。雖然可以用同構的方案,但那樣會增加成本,除非必須,一般都是隻在瀏覽器渲染。可能你的頁面正是這樣的。我們來分析一下。

WebView 要顯示一個頁面,需要序列下面的過程。通過 url 載入到 html 後再載入 css、js,js 請求完資料後才能顯示頁面。

url -> html -> css,js -> js 去載入資料 -> 顯示

和伺服器端渲染的頁面相比,首次請求時間更長。多出了 js 載入資料的時間。除了要快取 css,還要快取 js 和資料。快取 js 是必須的,快取資料是可選的。好訊息是 html 只有骨架,沒有內容,可以連 html 也一起快取。

快取 js,html 的方案和快取 css 的方案是一樣的。快取資料會面臨資料更新的難題,所以只可以快取少量不需要時時更新的少量重要資料,不需要所有資料都快取。app 的原生頁面也是需要載入資料的,也不是每種資料都要快取。

資料更新之所以說是一個難題,是因為很多內容資料是需要即時更新的。但資料已經下發到使用者端,已經快取起來,使用者端不再發起新的請求,如何通知使用者端進行資料更新?雖然有輪詢,socket,伺服器端推播等方案可以嘗試,但開發成本都比較高,和獲得的收益相比,代價太大。

當快取了 html,css,js 等靜態資源後,h5 就已經和原生頁面站在同一起跑線上了,對於唯讀的頁面,體驗上相差無幾。

載入資料後還有js 拼接 html 的時間,和載入的時間相比,只要硬體還可以的情況下,消耗的時間可以忽略

圖片不適合用快取 css 的方案,因為圖片太大也太多。只能預載入少量最重要的圖片,其它大量圖片只能對二次載入做優化,我們會在後面討論

瀏覽器渲染的頁面也需要打包的配合,需要把所有的要快取的靜態資源地址都換成本地地址,這就要求釋出的時候一份程式碼需要釋出兩個頁面。一個是給瀏覽器用的,資源都通過網路載入。一個是給 WebView 用的,資源都從本地獲取。

思路已經有了,具體實現就簡單了。下面我給出關鍵環節的範例程式碼,供大家參考。

如何啟動本地server

本地不需要 https,用 http 用行了,但是需要在 AndroidManifest.xml 的 applictation 中做如下設定 android:usesCleartextTraffic="true"

import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_static/shelf_static.dart';
import 'package:path_provider/path_provider.dart';
Future<void> initServer(webRoot) async {
  var documentDirectory = await getApplicationDocumentsDirectory();
  var handler =
      createStaticHandler('${documentDirectory.path}/$webRoot', defaultDocument: 'index.html');
  io.serve(handler, 'localhost', 8080);
}

createStaticHandler 負責處理靜態資源。

如果要相容 windows 系統,路徑需要用 path 外掛的 join 方法拼接

如何讓 WebView 的頁面請求走本地服務

兩種方案:

  • 打包的時候需要快取的頁面的地址都改成本地地址
  • 對頁面請求 在 WebView 中進行攔截,讓已經快取的頁面走本地 server。

相比之下,第 2 種方案都好一些。可以通過組態檔靈活修改哪些頁面需要快取。

在下面的範例程式碼中 ,cachedPagePaths 儲存著需要快取的頁面的 path。

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class MyWebView extends StatefulWidget {
  const MyWebView({super.key, required this.url, this.cachedPagePaths = const []});
  final String url;
  final List<String> cachedPagePaths;
  @override
  State<MyWebView> createState() => _MyWebViewState();
}
class _MyWebViewState extends State<MyWebView> {
  late final WebViewController controller;
  @override
  void initState() {
    controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(NavigationDelegate(
        onNavigationRequest: (request) async {
          var uri = Uri.parse(request.url);
          // TODO: 還應該判斷下 host
          if (widget.cachedPagePaths.contains(uri.path)) {
            var url = 'http://localhost:8080/${uri.path}';
            Future.microtask(() {
              controller.loadRequest(Uri.parse(url));
            });
            return NavigationDecision.prevent;
          } else {
            return NavigationDecision.navigate;
          }
        },
      ))
      ..loadRequest(Uri.parse(widget.url));
    super.initState();
  }
  @override
  void didUpdateWidget(covariant MyWebView oldWidget) {
    if(oldWidget.url!=widget.url){
      controller.loadRequest(Uri.parse(widget.url));
    }
    super.didUpdateWidget(oldWidget);
  }
  @override
  Widget build(BuildContext context) { 
    return Column(
      children: [Expanded(child: WebViewWidget(controller: controller))],
    );
  }
}

優化圖片請求

如果頁面中有很多圖片,你會發現,體驗上還是不如 Flutter 頁面,為什麼呢?原來 Flutter Image Widget 使用了快取,把請求到的圖片都快取了起來。 要達到相同的體驗,h5 頁面也需要實現相同的快取功能。

關於 Flutter 圖片請參見 快速掌握 Flutter 圖片開發核心技能

程式碼實現

要如何實現呢?只需要兩步。

  • 打包的時候需要把圖片的外連請求改成本地請求
  • 本地 server 對圖片請求進行攔截,優先讀快取,沒有再去請求網路。

第 1 條我舉個例子,比如圖片的地址為 https://s3.ap-northeast-1.wasabisys.com/img.it145.com/202302/logoeep2bliljj2.png ,打包的時候需要修改為 http://localhost:8080/logo.png

第 2 條的實現上,我們取個巧,借用 Flutter 中的 NetworkImage,NetworkImage 有快取的功能。

下面給出完整範例程式碼,貼到 main.dart 中就能執行。執行程式碼後看到一段文字和一張圖片。

注意先安裝相關的外掛,外掛的名字 import 裡有。

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:async';
import 'dart:typed_data';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_static/shelf_static.dart';
import 'dart:ui' as ui;
import 'package:webview_flutter/webview_flutter.dart';
const htmlString = '''
<!DOCTYPE html>
<head>
<title>webview demo | IAM17</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, 
  maximum-scale=1.0, user-scalable=no,viewport-fit=cover" />
<style>
*{
  margin:0;
  padding:0;
}
body{
   background:#BBDFFC;  
   text-align:center;
   color:#C45F84;
   font-size:20px;
}
img{width:90%;}
p{margin:30px 0;}
</style>
</head>
<html>
<body>
<p>大家好,我是 17</p>
<img src='http://localhost:8080/tos-cn-i-k3u1fbpfcp/
c6208b50f419481283fcca8c44a2e3af~tplv-k3u1fbpfcp-watermark.image'/>
</body>
</html>
''';
void main() async {
  runApp(const MyApp());
}
class MyApp extends StatefulWidget {
  const MyApp({super.key});
  @override
  State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
  WebViewController? controller;
  @override
  void initState() {
    init();
    super.initState();
  }
  init() async {
    var server = Server17(remoteHost: 'p6-juejin.byteimg.com');
    await server.init();
    var filePath = '${server.webRoot}/index.html';
    var indexFile = File(filePath);
    await indexFile.writeAsString(htmlString);
    setState(() {
      controller = WebViewController()
        ..loadRequest(Uri.parse('http://localhost:${server.port}/index.html'));
    });
  }
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        home: Scaffold(
      body: SafeArea(
        child: controller == null
            ? Container()
            : WebViewWidget(controller: controller!),
      ),
    ));
  }
}
class Server17 {
  Server17(
      {this.remoteSchema = 'https',
      required this.remoteHost,
      this.port = 8080,
      this.webFolder = 'www'});
  final String remoteSchema;
  final String remoteHost;
  final int port;
  final String webFolder;
  String? _webRoot;
  String get webRoot {
    if (_webRoot == null) throw Exception('請在初始化後讀取');
    return _webRoot!;
  }
  init() async {
    var documentDirectory = await getApplicationDocumentsDirectory();
    _webRoot = '${documentDirectory.path}/$webFolder';
    await _createDir(_webRoot!);
    var handler = Cascade()
        .add(getImageHandler)
        .add(createStaticHandler(_webRoot!, defaultDocument: 'index.html'))
        .handler;
    io.serve(handler, InternetAddress.loopbackIPv4, port);
  }
  _createDir(String path) async {
    var dir = Directory(path);
    var exist = dir.existsSync();
    if (exist) {
      return;
    }
    await dir.create();
  }
  Future<Uint8List?> loadImage(String url) async {
    Completer<ui.Image> completer = Completer<ui.Image>();
    ImageStreamListener? listener;
    ImageStream stream = NetworkImage(url).resolve(ImageConfiguration.empty);
    listener = ImageStreamListener((ImageInfo frame, bool sync) {
      final ui.Image image = frame.image;
      completer.complete(image);
      if (listener != null) {
        stream.removeListener(listener);
      }
    });
    stream.addListener(listener);
    var uiImage = await completer.future;
    var pngBytes = await uiImage.toByteData(format: ui.ImageByteFormat.png);
    if (pngBytes != null) {
      return pngBytes.buffer.asUint8List();
    }
    return null;
  }
  FutureOr<Response> getImageHandler(Request request) async {
    if (RegExp(
      r'.(png|image)$',
    ).hasMatch(request.url.path)) {
      var url = '$remoteSchema://$remoteHost/${request.url.path}';
      var imageData = await loadImage(url);
      //TODO: 如果 imageData 為空,改成錯誤圖片
      return Response.ok(imageData);
    } else {
      return Response.notFound('next');
    }
  }
}

程式碼邏輯

  • 在本地檔案目錄的 www 資料夾中準備了一個 index.html 檔案
  • 啟動本地 server,通過存取 http://localhost:8080/index.html 請求本地頁面。
  • server 收到請求後,對圖片請求進行攔截,通過 NetworkImage 返回圖片。

第 2 條。本例中是直接存取的 localhost,實際應用中,頁面地址是外連地址,通過攔截的方式請求本地。如何做頁面地址攔截前面已經給出範例了。

第 3 條。打包後的時候對所有圖片地址都寫成了本地地址,改成本地地址的目的就是為了讓圖片請求都由本地 server 響應。本地 server 拿到 圖片地址後,再改回網路地址,通過 NetworkImage 請求圖片。NetworkImage 會首先判斷有沒有快取,有直接用,沒有就發起網路請求,然後再快取。

可能你覺得有點繞,既然最後還要用網路地址,為什麼還要先寫成本地地址,象攔截頁面請求那樣攔截圖片請求不香嗎?答案是不可以。兩個原因。

  • webview_flutter 只能攔截頁面請求。
  • 本地 server 不方便攔截 443 埠。

對比於攔截 443 埠,修改打包方案要容易的多。

關於圖片型別

在範例程式碼中,用 RegExp( r'.(png|image)$',) 判斷是否要響應請求。從正則可以看出,以 png 或 image 結果的圖片都能響應請求。判斷 image 是因為範例中的圖片地址是以 image 結尾的。

範例程式碼只能支援 png 格式的圖片,範例圖片雖然是 image 結尾,但格式也是 png 格式。如果要支援更多格式的圖片,需要用到第三方庫。

關於圖片地址

如果圖片地址失改,可以自行換一個,隨使在網上找個 png 圖片 地址就行。

把圖片快取到磁碟。

我們演示了把圖片快取到記憶體,當 app 被殺掉,快取都沒了,除非快取到磁碟。這項工作已經有外掛幫我們做了。 用 cached_network_image 替換 NetworkImage,稍加改動就可以實現磁碟快取了。

總結一下

伺服器端染頁面方案

  • 打包的時候需要打出兩個頁面,一個頁面的 css 外連線是外網,一個頁面的 css 連結是本地。
  • 在 App 啟動的時候根據設定資訊預載入 css 存到檔案目錄。
  • 啟動本地 server 響應 css 的請求。

瀏覽器渲染方案

  • 打包的時候需要打出兩個頁面,一個頁面的 css,js 連結是外網,一個頁面的 css,js 連結是本地。
  • 在 App 啟動的時候根據設定資訊預載入 html,css,js 存到檔案目錄。
  • 根據設定資訊攔截頁面請求,已經快取的頁面改走本地 server。
  • 啟動本地 server 響應 html,css,js 的請求

圖片快取

如果不做圖片快取,通過前面兩個方案,h5 速度就已經得到大大提高了。如果有餘力,可以做圖片快取。圖片快取是可選的,是對前面兩種方案的加強。

  • 給 app 用的頁面打包的時候把圖片地址換成本地地址。
  • 啟動本地 server 響應圖片請求,有快取就讀快取,沒有快取走網路。

可能你的專案不同,有不同的方案,歡迎一起討論。

本文到這裡就結束了,謝謝觀看。

番外

為了給自己一點壓力,上一篇 在 Flutter 中使用 webview_flutter 4.0 | js 互動 中我就預告說今天要發這篇效能優化的文章。結果壓力是有的了,但卻沒能按時完工(理想情況是週日下午完工,這樣可以休息一下)。一個原因是 升級 flutter 報錯,浪費了一個上午,再有就是寫了一版後,並不滿意,又重寫了一版,最後才定稿。一直寫到深夜才把主要內容寫完。早上起來又做了補充修改。

以上就是Flutter WebView效能優化使h5像原生頁面一樣優秀的詳細內容,更多關於Flutter WebView頁面優化的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com