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