Flutter WebView性能优化使h5像原生页面一样优秀

Laila ·
更新时间:2024-11-13
· 986 次阅读

目录

引言

服务端渲染

css 放哪里

更新 css

如何利用本地 css 快速显示页面

浏览器渲染

如何启动本地server

如何让 WebView 的页面请求走本地服务

优化图片请求

代码实现

代码逻辑

关于图片类型

关于图片地址

把图片缓存到磁盘。

总结一下

服务端染页面方案

浏览器渲染方案

图片缓存

番外

引言

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 = 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://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> '''; 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页面优化的资料请关注软件开发网其它相关文章!



flutter 优化 webview

需要 登录 后方可回复, 如果你还没有账号请 注册新账号
相关文章