目录
背景
WebView是在APP中,可以很方便的展示web页面,并且与web交互APP的数据。方便,并且更新内容无需APP发布新版本,只需要将最新的web代码部署完成,用户重新刷新即可。
在WebView中,经常能够听到的一个需求就是:减少首次白屏时间,加快加载速度。因为加载web页面,必然会受到网络状况等的影响,无法像原生内容一样把静态内容秒加载出来。
分析
在原生Android和iOS中,有一种预缓存资源,并在加载时拦截web请求,将事先缓存好的资源替换上去,从而实现预加载的方案。
- iOS常见的拦截的框架是CocoaHTTPServer / Telegraph
- Android则是在WebViewClient中shouldInterceptRequest去进行拦截
道理都是一样的。
那么,Flutter有没有类似的方式去实现预加载web资源呢?
有!类似iOS中的CocoaHTTPServer,flutter也有一个HttpServer,可以发现,他们基本是一样的功能,并且Flutter HttpServer支持Android和iOS。
HttpServer
HttpServer包含在http的包中,在pub.dev找到最新的版本加入即可。
dependencies: flutter: sdk: flutter http: ^0.13.4
权限要求
因为要http服务,所以需要配置一下允许各平台的http请求。
启动服务
abstract class HttpServer implements Stream<HttpRequest>
var server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
HttpServer.bind方法会开启侦听对应Address的请求,第一个入参address可以自定,第二个port可以为0,也可以自定,为0的话,则由系统随机分配一个临时端口。
异步返回一个HttpServer,可以拿到最终的地址,也可以配置一些属性
curAddresses = _server!.address.address; curPort = _server!.port; _server!.sessionTimeout = 60;
并且,可以设置拦截侦听!
serverSub = _server!.listen(_responseWebViewReq, onError: (e) => log(e, name: _logKey));
listen即常见的StreamSubscription,关闭时需要Cancel。 在listen的onDate中,会提供一个HttpRequest,即被拦截的请求的HttpRequest。
_responseWebViewReq(HttpRequest request)
我们可以取得其当前请求的Uri,并且可以根据不同的Uri,返回不同的结果给到该请求的response
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时下发。大致的格式为:
{ "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服务不会使用。
然后便是获取到配置后,对符合条件的资源进行下载解压和存储。
// 触发basics预下载 LocalServerDownloadService.instance.preloadBasicsData(json['basics'], basics, oldBasic);
// 触发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包进行解压
// 找到对应zipUrl的本地文件路径 Directory saveDirct = LocalServerConfiguration.getCurrentZipPathSyncDirectory(item.zipUrl); final zipFile = File(downPath); if (!zipFile.existsSync()) { throw Exception('Local server 下载包文件路径不存在:$downPath'); } List<int> bytes = zipFile.readAsBytesSync(); Archive archive = ZipDecoder().decodeBytes(bytes); ··· // 清理之前的缓存 File oldfile = File(downPath); if (oldfile.existsSync()) { oldfile.deleteSync(); }
zip文件在解压完成后会被清理,根据zipUrl来决定存储的文件路径。 若已经存在资源,则无需下载。
若是下载失败的话,会被标记为failure,在重启app后的新下载任务中会重新尝试。 也可以加个重试几次的逻辑。
queueItem.loadState = LoadStateType.failure; queueItem.downloadCount += 1;
版本管理与更新
在配置json中可以看到version相关的设置,在上一步的下载解压完成之后,会把文件状态、对应的option、assets、basics数据(版本)存储起来。
首先检查对应的版本号是否能对上,若对不上的话,旧的数据将不会用来去重,而是直接使用最新获取到的配置进行下载和覆盖。
// 处理 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,则跳过不进行下载。
_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获取得到对应的LocalServerUrl。
return LocalServerService.instance.getLocalServerWebUrl(h5Path, query.isEmpty ? path : path + '?' + query);
String _getLocalServerWebUrl(String oriUrl, String localServerKey) { return 'http://${curAddresses ?? InternetAddress.loopbackIPv4.address}:$curPort$localServerKey'; }
其实就是在bind成功之后,将address和port存储下来,并在获取的时候将query与其拼接。
然后将处理后的url给到webview进行加载,即会触发
这里有个处理是将basics统一资源的链接,动态的添加到每个web页面的资源列表里。Binder在初始化配置和资源下载完成后,会存储Config和basicCache到内存中。并且统记webpage打开数量,避免HttpServer还在使用时被关闭。
@override void initState() { super.initState(); log('页面开始加载:${DateTime.now()}', name: 'web-time'); _localServerBuilder = LocalServerCacheBinder()..initBinder(); LocalServerWebViewManager.instance.registerBuilder(_localServerBuilder); _innerUrl = _localServerBuilder.convertH5Url2LocalServerUrl(widget.url); }
WebView
WebView( initialUrl: _innerUrl, debuggingEnabled: true, ··· )
兜底措施
会存在些情况就是,预加载的资源还没有下载解压完成或者说资源下载失败了,用户就开启了Webview,这时候我们就需要用源链接(baseDomain)去实时获取到数据来替换,避免web页面异常。
// 找不到本地文件,使用网络下载拿到原始数据 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,设置相应的初始化和配置即可。
class LocalServerClientManager implements LocalServerStatusHandler, LocalServerDownloadServiceProtocol
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<String, dynamic> 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模拟机展示
分析
使用我这边的几个实际项目中的webview进行测试,对于越“静态”的页面的优化效果越好,就是说,可被LocalServer实际服务到的资源越多,首次加载的优化效果就越好。
比如纯静态页面,iOS的加载完成时间,取20次首次加载的平均值,
- 未开启LocalServer的平均加载时间为343ms
- 开启LocalServer的平均加载时间为109ms
(时间由Safari的网页检查器统计)
非首次则优化相对没有这么明显,因为未开启情况下除了html均会被缓存。
- 未开启LocalServer的非首次平均加载时间为142ms
- 开启LocalServer的非首次平均加载时间为109.4ms
未开启的最快的加载时间还会比开启的快。由html的加载速度决定。
若是非纯静态页面,开启和未开启的时间都会受到网络状况的影响,开启LocalServer依旧有优化效果,
未开启LocalServer
开启LocalServer
但可以看到静态资源的读取速度LocalServer下依旧比较快,而其他的资源则不稳定了。
总结
对于打包到资源包中的资源,首次加载LocalServer可以有比较明显的优化效果,且速度比较稳定,不会受到网络波动的影响。
但是呢,使用了LocalServer,便无法使用浏览器自身的缓存,对于非首次情况优化效果不大。
并且,LocalServer可能会有更新的问题,何时去检查配置是否有更新?或许可以通过长链下发通知的方式,但没有长链的话就得考虑下其他的方法来解决更新及时性的问题了。
Demo
Demo地址:github.com/EchoPuda/lo…
是个插件形式,可以直接使用。 有些东西可以根据业务调整,比如新增特殊的配置、资源包是否要分包、LocalServer的服务也可以根据url来开启不同的服务等。
我是触发预加载后会将下载成功或已经成功的资源保存到内存中,也可以在读取时再进行对应的IO读取文件,速度会相应慢一点。