From e4efa005a2b9eff026fe74fad218ea5117ae5c9a Mon Sep 17 00:00:00 2001 From: TIANCHENG Date: Mon, 6 Nov 2023 18:25:43 +0800 Subject: [PATCH] feat: support preloadedBundles in WebF (#500) This PR adds a new preloadedBundles property to the WebF widget, allowing users to preload external resources (HTML, CSS, JavaScript, or Images) before WebF is mounted in Flutter. This feature can save a lot of time if all resources are preloaded before WebF is mounted. Here is a example to use this feature: ```dart Future> preloadWebFBundles(List entryPoints) async { List> bundles = entryPoints.map((e) async { WebFBundle bundle = WebFBundle.fromUrl(e); await bundle.resolve(); await bundle.obtainData(); return bundle; }).toList(); return await Future.wait(bundles); } @override Widget build(BuildContext context) { final MediaQueryData queryData = MediaQuery.of(context); final Size viewportSize = queryData.size; return Scaffold( body: Center( child: Column( children: [ FutureBuilder>(future: preloadWebFBundles([ // The lists with absolute path for remote resources 'assets:assets/bundle.html', 'assets:assets/bundle.js', 'assets:assets/webf.png', ]), builder: (BuildContext context, AsyncSnapshot> snapshot) { if (snapshot.data != null) { return WebF( devToolsService: ChromeDevToolsService(), viewportWidth: viewportSize.width - queryData.padding.horizontal, viewportHeight: viewportSize.height - appBar.preferredSize.height - queryData.padding.vertical, preloadedBundles: snapshot.data, bundle: snapshot.data![0] ); } return Text('Load error'); }) ], ), )); } ``` --- webf/lib/src/css/background.dart | 24 +++++++++++++-- webf/lib/src/css/font_face.dart | 6 ++-- webf/lib/src/foundation/bundle.dart | 38 ++++++++++++------------ webf/lib/src/html/head.dart | 5 ++-- webf/lib/src/html/img.dart | 22 +++++++------- webf/lib/src/html/script.dart | 5 ++-- webf/lib/src/launcher/controller.dart | 21 +++++++++++-- webf/lib/src/module/history.dart | 4 +-- webf/lib/src/painting/box_fit_image.dart | 14 +++++++-- webf/lib/src/widget/webf.dart | 14 +++++++-- webf/test/src/foundation/bundle.dart | 26 ++++------------ 11 files changed, 113 insertions(+), 66 deletions(-) diff --git a/webf/lib/src/css/background.dart b/webf/lib/src/css/background.dart index 02e38bc748..3a678249bb 100644 --- a/webf/lib/src/css/background.dart +++ b/webf/lib/src/css/background.dart @@ -5,10 +5,13 @@ import 'dart:convert'; import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui'; import 'package:flutter/painting.dart'; import 'package:flutter/rendering.dart'; import 'package:webf/painting.dart'; +import 'package:webf/html.dart'; import 'package:webf/css.dart'; import 'package:webf/launcher.dart'; import 'package:webf/rendering.dart'; @@ -253,6 +256,18 @@ class CSSBackgroundImage { ImageProvider? _image; + Future _obtainImage(Uri url) async { + ImageRequest request = ImageRequest.fromUri(url); + // Increment count when request. + controller.view.document.incrementRequestCount(); + + ImageLoadResponse data = await request.obtainImage(controller); + + // Decrement count when response. + controller.view.document.decrementRequestCount(); + return data; + } + ImageProvider? get image { if (_image != null) return _image; for (CSSFunctionalNotation method in functions) { @@ -267,8 +282,13 @@ class CSSBackgroundImage { Uri uri = Uri.parse(url); if (url.isNotEmpty) { uri = controller.uriParser!.resolve(Uri.parse(baseHref ?? controller.url), uri); - _image = getImageProvider(uri, contextId: controller.view.contextId); - return _image; + FlutterView ownerFlutterView = controller.ownerFlutterView; + return BoxFitImage( + boxFit: renderStyle.backgroundSize.fit, + url: uri, + loadImage: _obtainImage, + devicePixelRatio: ownerFlutterView.devicePixelRatio + ); } } } diff --git a/webf/lib/src/css/font_face.dart b/webf/lib/src/css/font_face.dart index 2f8160fea8..65baa30483 100644 --- a/webf/lib/src/css/font_face.dart +++ b/webf/lib/src/css/font_face.dart @@ -94,8 +94,10 @@ class CSSFontFace { } else { Uri? uri = _resolveFontSource(contextId, targetFont.src, baseHref); if (uri == null) return; - WebFBundle bundle = WebFBundle.fromUrl(uri.toString()); - await bundle.resolve(contextId); + final WebFController controller = WebFController.getControllerOfJSContextId(contextId)!; + WebFBundle bundle = controller.getPreloadBundleFromUrl(uri.toString()) ?? WebFBundle.fromUrl(uri.toString()); + await bundle.resolve(baseUrl: controller.url, uriParser: controller.uriParser); + await bundle.obtainData(); assert(bundle.isResolved, 'Failed to obtain $url'); FontLoader loader = FontLoader(removeQuotationMark(fontFamily)); Future bytes = Future.value(bundle.data?.buffer.asByteData()); diff --git a/webf/lib/src/foundation/bundle.dart b/webf/lib/src/foundation/bundle.dart index 428dbd5dc3..b43807ed91 100644 --- a/webf/lib/src/foundation/bundle.dart +++ b/webf/lib/src/foundation/bundle.dart @@ -9,7 +9,6 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:webf/foundation.dart'; -import 'package:webf/launcher.dart'; import 'package:webf/module.dart'; const String DEFAULT_URL = 'about:blank'; @@ -97,26 +96,27 @@ abstract class WebFBundle { Uint8List? data; // Indicate the bundle is resolved. - bool get isResolved => _uri != null && data != null; + bool get isResolved => _uri != null; // Content type for data. // The default value is plain text. ContentType contentType = ContentType.text; @mustCallSuper - Future resolve(int? contextId) async { + Future resolve({ String? baseUrl, UriParser? uriParser }) async { if (isResolved) return; // Source is input by user, do not trust it's a valid URL. _uri = Uri.tryParse(url); - if (contextId != null && _uri != null) { - WebFController? controller = WebFController.getControllerOfJSContextId(contextId); - if (controller != null) { - _uri = controller.uriParser!.resolve(Uri.parse(controller.url), _uri!); - } + + if (baseUrl != null && _uri != null) { + uriParser ??= UriParser(); + _uri = uriParser.resolve(Uri.parse(baseUrl), _uri!); } } + Future obtainData(); + // Dispose the memory obtained by bundle. @mustCallSuper void dispose() { @@ -183,6 +183,9 @@ class DataBundle extends WebFBundle { data = uriData.contentAsBytes(); this.contentType = contentType ?? ContentType.parse('${uriData.mimeType}; charset=${uriData.charset}'); } + + @override + Future obtainData() async {} } // The bundle that source from http or https. @@ -196,16 +199,14 @@ class NetworkBundle extends WebFBundle { Map? additionalHttpHeaders = {}; @override - Future resolve(int? contextId) async { - super.resolve(contextId); + Future obtainData() async { + if (data != null) return; + final HttpClientRequest request = await _sharedHttpClient.getUrl(_uri!); // Prepare request headers. request.headers.set('Accept', _acceptHeader()); additionalHttpHeaders?.forEach(request.headers.set); - if (contextId != null) { - WebFHttpOverrides.setContextHeader(request.headers, contextId); - } final HttpClientResponse response = await request.close(); if (response.statusCode != HttpStatus.ok) @@ -235,8 +236,9 @@ class AssetsBundle extends WebFBundle with _ExtensionContentTypeResolver { AssetsBundle(String url) : super(url); @override - Future resolve(int? contextId) async { - super.resolve(contextId); + Future obtainData() async { + if (data != null) return; + final Uri? _resolvedUri = resolvedUri; if (_resolvedUri != null) { final String assetName = getAssetName(_resolvedUri); @@ -245,7 +247,6 @@ class AssetsBundle extends WebFBundle with _ExtensionContentTypeResolver { } else { _failedToResolveBundle(url); } - return this; } /// Get flutter asset name from uri scheme asset. @@ -267,8 +268,8 @@ class FileBundle extends WebFBundle with _ExtensionContentTypeResolver { FileBundle(String url) : super(url); @override - Future resolve(int? contextId) async { - super.resolve(contextId); + Future obtainData() async { + if (data != null) return; Uri uri = _uri!; final String path = uri.path; @@ -279,7 +280,6 @@ class FileBundle extends WebFBundle with _ExtensionContentTypeResolver { } else { _failedToResolveBundle(url); } - return this; } } diff --git a/webf/lib/src/html/head.dart b/webf/lib/src/html/head.dart index 898896b85e..b0fbecb7a9 100644 --- a/webf/lib/src/html/head.dart +++ b/webf/lib/src/html/head.dart @@ -133,14 +133,15 @@ class LinkElement extends Element { isConnected && !_stylesheetLoaded.containsKey(_resolvedHyperlink.toString())) { String url = _resolvedHyperlink.toString(); - WebFBundle bundle = WebFBundle.fromUrl(url); + WebFBundle bundle = ownerDocument.controller.getPreloadBundleFromUrl(url) ?? WebFBundle.fromUrl(url); _stylesheetLoaded[url] = true; try { _loading = true; // Increment count when request. ownerDocument.incrementRequestCount(); - await bundle.resolve(contextId); + await bundle.resolve(baseUrl: ownerDocument.controller.url, uriParser: ownerDocument.controller.uriParser); + await bundle.obtainData(); assert(bundle.isResolved, 'Failed to obtain $url'); _loading = false; // Decrement count when response. diff --git a/webf/lib/src/html/img.dart b/webf/lib/src/html/img.dart index e49c862719..ee4457c46a 100644 --- a/webf/lib/src/html/img.dart +++ b/webf/lib/src/html/img.dart @@ -13,6 +13,7 @@ import 'package:webf/css.dart'; import 'package:webf/dom.dart'; import 'package:webf/bridge.dart'; import 'package:webf/foundation.dart'; +import 'package:webf/launcher.dart'; import 'package:webf/painting.dart'; import 'package:webf/rendering.dart'; import 'package:webf/svg.dart'; @@ -567,7 +568,7 @@ class ImageElement extends Element { void _loadSVGImage() { final builder = - SVGRenderBoxBuilder(_obtainImage(_resolvedUri!), target: this); + SVGRenderBoxBuilder(obtainImage(_resolvedUri!), target: this); builder.decode().then((renderObject) { final size = builder.getIntrinsicSize(); @@ -601,8 +602,9 @@ class ImageElement extends Element { provider = _currentImageProvider = BoxFitImage( boxFit: objectFit, url: _resolvedUri!, - loadImage: _obtainImage, + loadImage: obtainImage, onImageLoad: _onImageLoad, + devicePixelRatio: ownerDocument.defaultView.devicePixelRatio ); } @@ -639,12 +641,12 @@ class ImageElement extends Element { // To load the resource, and dispatch load event. // https://html.spec.whatwg.org/multipage/images.html#when-to-obtain-images - Future _obtainImage(Uri url) async { + Future obtainImage(Uri url) async { ImageRequest request = _currentRequest = ImageRequest.fromUri(url); // Increment count when request. ownerDocument.incrementRequestCount(); - final data = await request._obtainImage(contextId); + final data = await request.obtainImage(ownerDocument.controller); // Decrement count when response. ownerDocument.decrementRequestCount(); @@ -738,10 +740,11 @@ class ImageRequest { state == _ImageRequestState.completelyAvailable || state == _ImageRequestState.partiallyAvailable; - Future _obtainImage(int? contextId) async { - final WebFBundle bundle = WebFBundle.fromUrl(currentUri.toString()); - - await bundle.resolve(contextId); + Future obtainImage(WebFController controller) async { + final WebFBundle bundle = + controller.getPreloadBundleFromUrl(currentUri.toString()) ?? WebFBundle.fromUrl(currentUri.toString()); + await bundle.resolve(baseUrl: controller.url, uriParser: controller.uriParser); + await bundle.obtainData(); if (!bundle.isResolved) { throw FlutterError('Failed to load $currentUri'); @@ -752,7 +755,6 @@ class ImageRequest { // Free the bundle memory. bundle.dispose(); - return ImageLoadResponse(data, - mime: bundle.contentType.toString()); + return ImageLoadResponse(data, mime: bundle.contentType.toString()); } } diff --git a/webf/lib/src/html/script.dart b/webf/lib/src/html/script.dart index a0ee819b2f..94f88b7b89 100644 --- a/webf/lib/src/html/script.dart +++ b/webf/lib/src/html/script.dart @@ -80,7 +80,7 @@ class ScriptRunner { bundle = WebFBundle.fromContent(scriptCode); } else { String url = element.src.toString(); - bundle = WebFBundle.fromUrl(url); + bundle = _document.controller.getPreloadBundleFromUrl(url) ?? WebFBundle.fromUrl(url); } element.readyState = ScriptReadyState.interactive; @@ -121,7 +121,8 @@ class ScriptRunner { // Increment count when request. _document.incrementDOMContentLoadedEventDelayCount(); try { - await bundle.resolve(_contextId); + await bundle.resolve(baseUrl: _document.controller.url, uriParser: _document.controller.uriParser); + await bundle.obtainData(); if (!bundle.isResolved) { throw FlutterError('Network error.'); diff --git a/webf/lib/src/launcher/controller.dart b/webf/lib/src/launcher/controller.dart index 8b20d7ae99..be72ebf9ce 100644 --- a/webf/lib/src/launcher/controller.dart +++ b/webf/lib/src/launcher/controller.dart @@ -596,7 +596,7 @@ class WebFViewController implements WidgetsBindingObserver { switch (action.navigationType) { case WebFNavigationType.navigate: - await rootController.load(WebFBundle.fromUrl(action.target)); + await rootController.load(rootController.getPreloadBundleFromUrl(action.target) ?? WebFBundle.fromUrl(action.target)); break; case WebFNavigationType.reload: await rootController.reload(); @@ -814,6 +814,19 @@ class WebFController { final GestureListener? _gestureListener; + final List? preloadedBundles; + Map? _preloadBundleIndex; + WebFBundle? getPreloadBundleFromUrl(String url) { + return _preloadBundleIndex?[url]; + } + void _initializePreloadBundle() { + if (preloadedBundles == null) return; + _preloadBundleIndex = {}; + preloadedBundles!.forEach((bundle) { + _preloadBundleIndex![bundle.url] = bundle; + }); + } + // The kraken view entrypoint bundle. WebFBundle? _entrypoint; @@ -838,6 +851,7 @@ class WebFController { this.httpClientInterceptor, this.devToolsService, this.uriParser, + this.preloadedBundles, this.initialCookies, required this.ownerFlutterView, this.resizeToAvoidBottomInsets = true, @@ -845,6 +859,8 @@ class WebFController { _entrypoint = entrypoint, _gestureListener = gestureListener { + _initializePreloadBundle(); + _methodChannel = methodChannel; WebFMethodChannel.setJSMethodCallCallback(this); @@ -1096,7 +1112,8 @@ class WebFController { // Resolve the bundle, including network download or other fetching ways. try { - await bundleToLoad.resolve(view.contextId); + await bundleToLoad.resolve(baseUrl: url, uriParser: uriParser); + await bundleToLoad.obtainData(); } catch (e, stack) { if (onLoadError != null) { onLoadError!(FlutterError(e.toString()), stack); diff --git a/webf/lib/src/module/history.dart b/webf/lib/src/module/history.dart index 531ca559e8..5e44421d86 100644 --- a/webf/lib/src/module/history.dart +++ b/webf/lib/src/module/history.dart @@ -110,7 +110,7 @@ class HistoryModule extends BaseModule { return; } - WebFBundle bundle = WebFBundle.fromUrl(uri.toString()); + WebFBundle bundle = controller.getPreloadBundleFromUrl(uri.toString()) ?? WebFBundle.fromUrl(uri.toString()); HistoryItem history = HistoryItem(bundle, state, false); _addItem(history); } @@ -130,7 +130,7 @@ class HistoryModule extends BaseModule { return; } - WebFBundle bundle = WebFBundle.fromUrl(uri.toString()); + WebFBundle bundle = controller.getPreloadBundleFromUrl(uri.toString()) ?? WebFBundle.fromUrl(uri.toString()); HistoryItem history = HistoryItem(bundle, state, false); _previousStack.removeFirst(); diff --git a/webf/lib/src/painting/box_fit_image.dart b/webf/lib/src/painting/box_fit_image.dart index 073aa20ba8..3e087e8726 100644 --- a/webf/lib/src/painting/box_fit_image.dart +++ b/webf/lib/src/painting/box_fit_image.dart @@ -46,6 +46,7 @@ class BoxFitImage extends ImageProvider { required LoadImage loadImage, required this.url, required this.boxFit, + required this.devicePixelRatio, this.onImageLoad, }): _loadImage = loadImage; @@ -53,6 +54,7 @@ class BoxFitImage extends ImageProvider { final Uri url; final BoxFit boxFit; final OnImageLoad? onImageLoad; + final double devicePixelRatio; @override Future obtainKey(ImageConfiguration configuration) { @@ -84,11 +86,19 @@ class BoxFitImage extends ImageProvider { final ImmutableBuffer buffer = await ImmutableBuffer.fromUint8List(bytes); final ImageDescriptor descriptor = await ImageDescriptor.encoded(buffer); + + int? preferredWidth; + int? preferredHeight; + if (key.configuration?.size != null) { + preferredWidth = (key.configuration!.size!.width * devicePixelRatio).toInt(); + preferredHeight = (key.configuration!.size!.height * devicePixelRatio).toInt(); + } + final Codec codec = await _instantiateImageCodec( descriptor, boxFit: boxFit, - preferredWidth: key.configuration?.size?.width.toInt(), - preferredHeight: key.configuration?.size?.height.toInt(), + preferredWidth: preferredWidth, + preferredHeight: preferredHeight, ); // Fire image on load after codec created. diff --git a/webf/lib/src/widget/webf.dart b/webf/lib/src/widget/webf.dart index 9fef6ea47a..1a615c8eeb 100644 --- a/webf/lib/src/widget/webf.dart +++ b/webf/lib/src/widget/webf.dart @@ -51,6 +51,7 @@ class WebF extends StatefulWidget { final LoadErrorHandler? onLoadError; final LoadHandler? onLoad; + // https://developer.mozilla.org/en-US/docs/Web/API/Document/DOMContentLoaded_event final LoadHandler? onDOMContentLoaded; @@ -65,6 +66,10 @@ class WebF extends StatefulWidget { final UriParser? uriParser; + /// Remote resources (HTML, CSS, JavaScript, Images, and other content loadable via WebFBundle) can be pre-loaded before WebF is mounted in Flutter. + /// Use this property to reduce loading times when a WebF application attempts to load external resources on pages. + final List? preloadedBundles; + /// The initial cookies to set. final List? initialCookies; @@ -128,6 +133,7 @@ class WebF extends StatefulWidget { this.uriParser, this.routeObserver, this.initialCookies, + this.preloadedBundles, // webf's viewportWidth options only works fine when viewportWidth is equal to window.physicalSize.width / window.devicePixelRatio. // Maybe got unexpected error when change to other values, use this at your own risk! // We will fixed this on next version released. (v0.6.0) @@ -160,6 +166,7 @@ class WebFState extends State with RouteAware { bool _disposed = false; final Set customElementWidgets = {}; + void onCustomElementWidgetAdd(WebFWidgetElementToWidgetAdapter adapter) { Future.microtask(() { if (!_disposed) { @@ -333,7 +340,8 @@ class WebFRootRenderObjectWidget extends MultiChildRenderObjectWidget { @override RenderObject createRenderObject(BuildContext context) { double viewportWidth = _webfWidget.viewportWidth ?? currentView.physicalSize.width / currentView.devicePixelRatio; - double viewportHeight = _webfWidget.viewportHeight ?? currentView.physicalSize.height / currentView.devicePixelRatio; + double viewportHeight = + _webfWidget.viewportHeight ?? currentView.physicalSize.height / currentView.devicePixelRatio; WebFController controller = WebFController(shortHash(_webfWidget), viewportWidth, viewportHeight, background: _webfWidget.background, @@ -353,6 +361,7 @@ class WebFRootRenderObjectWidget extends MultiChildRenderObjectWidget { onCustomElementDetached: onCustomElementDetached, initialCookies: _webfWidget.initialCookies, uriParser: _webfWidget.uriParser, + preloadedBundles: _webfWidget.preloadedBundles, ownerFlutterView: currentView, resizeToAvoidBottomInsets: resizeToAvoidBottomInsets); @@ -378,7 +387,8 @@ class WebFRootRenderObjectWidget extends MultiChildRenderObjectWidget { bool viewportHeightHasChanged = controller.view.viewportHeight != _webfWidget.viewportHeight; double viewportWidth = _webfWidget.viewportWidth ?? currentView.physicalSize.width / currentView.devicePixelRatio; - double viewportHeight = _webfWidget.viewportHeight ?? currentView.physicalSize.height / currentView.devicePixelRatio; + double viewportHeight = + _webfWidget.viewportHeight ?? currentView.physicalSize.height / currentView.devicePixelRatio; if (controller.view.document.documentElement == null) return; diff --git a/webf/test/src/foundation/bundle.dart b/webf/test/src/foundation/bundle.dart index 82c5ffa5ed..f1538f3c14 100644 --- a/webf/test/src/foundation/bundle.dart +++ b/webf/test/src/foundation/bundle.dart @@ -10,28 +10,12 @@ import 'dart:typed_data'; import 'package:test/test.dart'; import 'package:webf/foundation.dart'; -import '../../local_http_server.dart'; - void main() { - var server = LocalHttpServer.getInstance(); - group('Bundle', () { - test('NetworkBundle basic', () async { - Uri uri = server.getUri('js_over_128k'); - var bundle = NetworkBundle(uri.toString()); - // Using contextId to active cache. - await bundle.resolve(1); - Uint8List data = await bundle.data!; - var code = utf8.decode(data); - - expect(bundle.isResolved, true); - expect(code.length > 128 * 1024, true); - }); - test('FileBundle basic', () async { var filename = '${Directory.current.path}/example/assets/bundle.js'; var bundle = FileBundle('file://$filename'); - await bundle.resolve(1); + await bundle.resolve(); expect(bundle.isResolved, true); }); @@ -39,7 +23,7 @@ void main() { test('DataBundle string', () async { var content = 'hello world'; var bundle = DataBundle.fromString(content, 'about:blank'); - await bundle.resolve(1); + await bundle.resolve(); expect(bundle.isResolved, true); expect(utf8.decode(bundle.data!), content); }); @@ -47,7 +31,7 @@ void main() { test('DataBundle with non-latin string', () async { var content = '你好,世界😈'; var bundle = DataBundle.fromString(content, 'about:blank'); - await bundle.resolve(1); + await bundle.resolve(); expect(bundle.isResolved, true); expect(utf8.decode(bundle.data!), content); }); @@ -55,7 +39,7 @@ void main() { test('DataBundle data', () async { Uint8List bytecode = Uint8List.fromList(List.generate(10, (index) => index, growable: false)); var bundle = DataBundle(bytecode, 'about:blank'); - await bundle.resolve(1); + await bundle.resolve(); expect(bundle.isResolved, true); expect(bundle.data, bytecode); }); @@ -63,7 +47,7 @@ void main() { test('WebFBundle', () async { Uint8List bytecode = Uint8List.fromList(List.generate(10, (index) => index, growable: false)); var bundle = WebFBundle.fromBytecode(bytecode); - await bundle.resolve(1); + await bundle.resolve(); expect(bundle.contentType.mimeType, 'application/vnd.webf.bc1'); }); });