大家好,我是 17。
WebView 的文章分两篇
- 在 Flutter 中使用 webview_flutter 4.0 | js 交互
- Flutter WebView 性能优化,让 h5 像原生页面一样优秀
本篇和大家一起讨论下性能优化的问题。
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 =awaitgetApplicationDocumentsDirectory();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';classMyWebViewextendsStatefulWidget{constMyWebView({super.key, required this.url,this.cachedPagePaths =const[]});finalString url;finalList<String> cachedPagePaths;@overrideState<MyWebView>createState()=>_MyWebViewState();}class _MyWebViewState extendsState<MyWebView>{
late finalWebViewController controller;@overridevoidinitState(){
controller =WebViewController()..setJavaScriptMode(JavaScriptMode.unrestricted)..setNavigationDelegate(NavigationDelegate(
onNavigationRequest:(request)async{var uri =Uri.parse(request.url);// TODO: 还应该判断下 hostif(widget.cachedPagePaths.contains(uri.path)){var url ='http://localhost:8080/${uri.path}';Future.microtask((){
controller.loadRequest(Uri.parse(url));});returnNavigationDecision.prevent;}else{returnNavigationDecision.navigate;}},))..loadRequest(Uri.parse(widget.url));super.initState();}@overridevoiddidUpdateWidget(covariantMyWebView oldWidget){if(oldWidget.url!=widget.url){
controller.loadRequest(Uri.parse(widget.url));}super.didUpdateWidget(oldWidget);}@overrideWidgetbuild(BuildContext context){returnColumn(
children:[Expanded(child:WebViewWidget(controller: controller))],);}}
优化图片请求
如果页面中有很多图片,你会发现,体验上还是不如 Flutter 页面,为什么呢?原来 Flutter Image Widget 使用了缓存,把请求到的图片都缓存了起来。 要达到相同的体验,h5 页面也需要实现相同的缓存功能。
关于 Flutter 图片请参见 快速掌握 Flutter 图片开发核心技能
代码实现
要如何实现呢?只需要两步。
- 打包的时候需要把图片的外链请求改成本地请求
- 本地 server 对图片请求进行拦截,优先读缓存,没有再去请求网络。
第 1 条我举个例子,比如图片的地址为
https://juejin.com/logo.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>
''';voidmain()async{runApp(constMyApp());}classMyAppextendsStatefulWidget{constMyApp({super.key});@overrideState<MyApp>createState()=>_MyAppState();}class _MyAppState extendsState<MyApp>{WebViewController? controller;@overridevoidinitState(){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'));});}@overrideWidgetbuild(BuildContext context){returnMaterialApp(
home:Scaffold(
body:SafeArea(
child: controller ==null?Container():WebViewWidget(controller: controller!),),));}}classServer17{Server17({this.remoteSchema ='https',
required this.remoteHost,this.port =8080,this.webFolder ='www'});finalString remoteSchema;finalString remoteHost;final int port;finalString webFolder;String? _webRoot;Stringget webRoot {if(_webRoot ==null)throwException('请在初始化后读取');return _webRoot!;}init()async{var documentDirectory =awaitgetApplicationDocumentsDirectory();
_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){finalui.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();}returnnull;}FutureOr<Response>getImageHandler(Request request)async{if(RegExp(r'\.(png|image)$',).hasMatch(request.url.path)){var url ='$remoteSchema://$remoteHost/${request.url.path}';var imageData =awaitloadImage(url);//TODO: 如果 imageData 为空,改成错误图片returnResponse.ok(imageData);}else{returnResponse.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 报错,浪费了一个上午,再有就是写了一版后,并不满意,又重写了一版,最后才定稿。一直写到深夜才把主要内容写完。早上起来又做了补充修改。
由于时间紧,有不妥之处,还请各位大佬雅正。
版权归原作者 IAM17前端 所有, 如有侵权,请联系我们删除。