When loading H5 pages with mobile devices, if some public resources such as CSS, JS, images are too large, they need to be loaded from local resources through the interception network. In the Android native WebView, we can use the shouldInterceptRequest method in the WebViewClient to intercept replacement resources. However, the WebView plugin for Flutter, neither the official webview_flutter nor the Flutter_webview_plugin, supports loading local resources. Fortunately, the underlying implementation of WebView_FLUTTER is based on WebView(Android) and WKWebView(iOS). With a few modifications to the official Webview_FLUTTER, offline resource loading can be implemented.

The project address

Making: github.com/iamyours/we… pub: iwebview_flutter

The Android client implementation

First we download the latest Archive from webview_FLUTTER (currently using 0.3.15+1). After decompressing, open it with AndroidStudio, right-click the project directory and open it in Android mode

If you want to implement WebView request interception, you must set WebViewCilent to WebView, global search setWebViewClient found only one implementation:

//FlutterWebView.java
private void applySettings(Map<String, Object> settings) {
    for (String key : settings.keySet()) {
        switch (key) {
            ...
            case "hasNavigationDelegate":
                final boolean hasNavigationDelegate = (boolean) settings.get(key);

                final WebViewClient webViewClient =
                        flutterWebViewClient.createWebViewClient(hasNavigationDelegate);

                webView.setWebViewClient(webViewClient);
                break; . }}}Copy the code

Modify the WebViewClient

The createWebViewClient method contains the following logic:

WebViewClient createWebViewClient(boolean hasNavigationDelegate) {
	this.hasNavigationDelegate = hasNavigationDelegate;

	if(! hasNavigationDelegate || android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {return internalCreateWebViewClient();
	}

	return internalCreateWebViewClientCompat();
}
Copy the code

Then in internalCreateWebViewClient and add shouldInterceptRequest internalCreateWebViewClientCompat method, Add a shouldInterceptRequest method to FlutterWebViewClient using onPageFinished:

private WebViewClient internalCreateWebViewClient(a) {
    return new WebViewClient() {
        ...
        @Override
        public void onPageFinished(WebView view, String url) {
            FlutterWebViewClient.this.onPageFinished(view, url); }.../ / reference
        @Override
        public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
            WebResourceResponse response = FlutterWebViewClient.this.shouldInterceptRequest(view, url);
            if(response ! =null) return response;
            return super.shouldInterceptRequest(view, url); }}; }Copy the code

Asynchronous variable synchronization. MethodChannel receives data from the Flutter layer

In shouldInterceptRequest we receive data from the Flutter world, such as binary data in assets. Note that receiving data via MethodChannel is in the form of an asynchronous callback, but the shouldInterceptRequest method needs to receive data synchronously, so an asynchronous variable synchronous executor is required, and the MethodChannel call must be called on the main thread. There are a number of methods, and I’m doing this with CountDownLatch.

public class SyncExecutor {
    private final CountDownLatch countDownLatch = new CountDownLatch(1);
    Handler mainHandler = new Handler(Looper.getMainLooper());
    WebResourceResponse res = null;
    public WebResourceResponse getResponse(final MethodChannel methodChannel, final String url) {
        res = null;
        mainHandler.post(new Runnable() {
            @Override
            public void run(a) {
                methodChannel.invokeMethod("shouldInterceptRequest", url, new MethodChannel.Result() {
                    @Override
                    public void success(Object o) {
                        if (o instanceof Map) {
                            Map<String, Object> map = (Map<String, Object>) o;
                            byte[] bytes = (byte[]) map.get("data");
                            String type = (String) map.get("mineType");
                            String encode = (String) map.get("encoding");
                            res = new WebResourceResponse(type, encode, new ByteArrayInputStream(bytes));
                        }
                        countDownLatch.countDown();
                    }

                    @Override
                    public void error(String s, String s1, Object o) {
                        res = null;
                        countDownLatch.countDown();
                    }
                    @Override
                    public void notImplemented(a) {
                        res = null; countDownLatch.countDown(); }}); }});try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        returnres; }}Copy the code

Note here that success receives Map data which we will pass to the Flutter layer next.

The Flutter layer passes data

In webView_method_channel.dart we find onPageFinished receiving calls from Android or iOS. Using the onPageFinished method, we should add a shouldInterceptRequest method, as well as a shouldInterceptRequest method in the class corresponding to _platformCallbacksHandler. And so on up.

Future<dynamic> _onMethodCall(MethodCall call) async {
    switch (call.method) {
      
      case 'onPageFinished':
        _platformCallbacksHandler.onPageFinished(call.arguments['url']);
        return null;
      case 'shouldInterceptRequest':
        String url = call.arguments;
        var response = await _platformCallbacksHandler.shouldInterceptRequest(url);
        if(response ! =null) {
          return {"data": response.data, "mineType": response.mineType, "encoding": response.encoding};
        }
        return null;
    }
Copy the code
//webview_method_channel.dart
abstract class WebViewPlatformCallbacksHandler {...void onPageFinished(String url);

  /// iamyours:Invoked by [WebViewPlatformController] when a request url intercepted.
  Future<Response> shouldInterceptRequest(String url);
}
Copy the code
//webview_method_channel.dart
class Response {
  final String mineType;
  final String encoding;
  final Uint8List data;

  Response(this.mineType, this.encoding, this.data);
}

typedef void PageFinishedCallback(String url);

/// iamyours Signature for when a [WebView] interceptRequest .
typedef Future<Response> ShouldInterceptRequestCallback(String url);

class WebView extends StatefulWidget {...const WebView({
    ...
    this.onPageFinished,
    this.shouldInterceptRequest,
    ...,
  })

class _WebViewState extends State<WebView> {...@override
  void onPageFinished(String url) {
    if(_widget.onPageFinished ! =null) { _widget.onPageFinished(url); }}...@override
  Future<Response> shouldInterceptRequest(String url) async{
    if(_widget.shouldInterceptRequest ! =null) {
      return _widget.shouldInterceptRequest(url);
    }
    return null; }}Copy the code

Then we implement a simple logo substitution effect in Example

WebView(
  initialUrl: "https://wap.sogou.com/",
  javascriptMode: JavascriptMode.unrestricted,
  debuggingEnabled: true,
  onProgressChanged: (int p){
    setState(() {
      progress = p/100.0;
    });
  },
  backgroundColor: Colors.red,
  shouldInterceptRequest: (String url) async {// Replace sogou logo with Baidu
    var googleLogo = "https://wap.sogou.com/resource/static/index/images/logo_new.6f31942.png";
    print("============url:$url");
    if (url == googleLogo) {
      ByteData data = await rootBundle.load("assets/baidu.png");
      Uint8List bytes = Uint8List.view(data.buffer);
      return Response("image/png".null, bytes);
    }
    return null; },),Copy the code

The final result

The iOS side implementation

Webview_flutteriOS is implemented based on WKWebview, and the interception request is implemented through NSURLProtocol. You can refer to the article on iOS WKWebview (NSURLProtocol) Intercepting JS, CSS and Image Resources. This method interception is global, so a global variable is required to store all FlutterMethodChannels. Here we define a singleton to store this data.

//FlutterInstance.h
#import <Foundation/Foundation.h>
#import <Flutter/Flutter.h>
NS_ASSUME_NONNULL_BEGIN

@interface FlutterInstance : NSObject
@property(nonatomic,retain)NSMutableDictionary *channels;
+(FlutterInstance*)get;
+(FlutterMethodChannel*)channelWithAgent:(NSString*)agent;
+(void)removeChannel:(int64_t)viewId;
@end

NS_ASSUME_NONNULL_END
Copy the code

We add #_viewId to the agent of the corresponding WKWebview in order to distinguish which channel the request belongs to

//
//  FlutterInstance.m

#import "FlutterInstance.h"

@implementation FlutterInstance

static FlutterInstance *instance = nil;
+(FlutterInstance *)get
{
    @synchronized(self)
    {
        if(instance==nil) { instance= [FlutterInstance new]; instance.channels = [NSMutableDictionary dictionary]; }}return instance;
}
+(FlutterMethodChannel*)channelWithAgent:(NSString*)agent{
    NSRange range = [agent rangeOfString:@"#" options:NSBackwardsSearch];
    NSLog(@"range:%d,%d",range.length,range.location);
    NSString *key = [agent substringFromIndex:range.location+1];
    NSDictionary *channels = [self get].channels;
    FlutterMethodChannel *channel = (FlutterMethodChannel*)[channels objectForKey:key];
    return channel;
}
+(void)removeChannel:(int64_t)viewId{
    NSMutableDictionary *channels = [self get].channels;
    NSString *key = [NSString stringWithFormat:@"%lld",viewId];
    [channels removeObjectForKey:key];
}
@end
Copy the code

UserAgent distinguish MethodChannel

We changed the userAgent in the loadUrl of WKWebview to distinguish the viewId corresponding to each WebView.

//FlutterWebView.m
- (bool)loadUrl:(NSString*)url withHeaders:(NSDictionary<NSString*, NSString*>*)headers {
  NSURL* nsUrl = [NSURL URLWithString:url];
  if(! nsUrl) {return false;
  }
  NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:nsUrl];
    NSString *vid = [NSString stringWithFormat:@"%lld",_viewId];
    [_webView evaluateJavaScript:@"navigator.userAgent" completionHandler:^(id result, NSError *error) {
        NSString *fixAgent = [NSString stringWithFormat:@"%@#%d",result,_viewId];
        [_webView setCustomUserAgent:fixAgent];
    }];
  [_webView loadRequest:request];
  return true;
}
Copy the code

NSURLProtocol implements request interception

Then request interception is implemented in startLoading method in NSURLProtocol

//  FlutterNSURLProtocol.h

#import <Foundation/Foundation.h>
#import <Flutter/Flutter.h>
NS_ASSUME_NONNULL_BEGIN

@interface FlutterNSURLProtocol : NSURLProtocol

@end

NS_ASSUME_NONNULL_END
Copy the code
// FlutterNSURLProtocol.m - (void)startLoading { NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy]; // Set an identifier for the requests we processed to prevent infinite loops, [NSURLProtocol]setProperty:@YES forKey:KFlutterNSURLProtocolKey inRequest:mutableReqeust];

    NSString *agent = [mutableReqeust valueForHTTPHeaderField:@"User-Agent"];
    
    FlutterMethodChannel *channel = [FlutterInstance channelWithAgent:agent];

    if(channel==nil){
        NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:nil];
        self.task = [session dataTaskWithRequest:self.request];
        [self.task resume];
        return;
    }
    [channel invokeMethod:@"shouldInterceptRequest" arguments:url result:^(id  _Nullable result) {
        if(result! =nil){ NSDictionary *dic = (NSDictionary *)result; FlutterStandardTypedData *fData = (FlutterStandardTypedData *)[dic valueForKey:@"data"];
            NSString *mineType = dic[@"mineType"];
            NSString *encoding = dic[@"encoding"];
            if([encoding isEqual:[NSNull null]])encoding = nil;
            NSData *data = [fData data];

            NSURLResponse* response = [[NSURLResponse alloc] initWithURL:self.request.URL MIMEType:mineType expectedContentLength:data.length textEncodingName:encoding];
            [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed];
            [self.client URLProtocol:self didLoadData:data];
            [self.client URLProtocolDidFinishLoading:self];
        }else{
            NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:nil];
            self.task = [session dataTaskWithRequest:self.request];
            [self.task resume];
        }
    }];
}
Copy the code

Get the corresponding FlutterMethodChannel through the userAgent set earlier. Call shouldInterceptRequest to get the Flutter data. Debug the Flutter using Xcode. We know the corresponding byte data type is FlutterStandardTypedData. Or see the following image:

The specific effect

Implement Google search logo replacement

Webview Night mode Flutter side practice

Because I played kotlin version of Android client before, and adapted to the dark mode of articles on various sites, relevant CSS resources need to be replaced, so I thought of modifying webview_FLUTTER plug-in. The following is the dark mode effect produced by the change of the flutter version to the nuggets article CSS file

Android nuggets article Night mode

IOS Nuggets article Night mode