Flutter下實現WebView攔截載入離線資源

星星y發表於2019-11-21

在用移動裝置載入H5頁面時,一些公共資源如css,js,圖片等如果比較大時,就需要通過攔截網路,改從本地資源載入。在Android原生WebView中,我們可以在WebViewClient中的shouldInterceptRequest方法來攔截替換資源。
然而在Flutter中的WebView外掛,不管是官方的webview_flutter,還是flutter_webview_plugin都不支援載入本地資源。
慶幸的是webview_flutter底層實現是基於WebView(Android)和WKWebView(iOS)。只要在官方webview_flutter上稍作修改,就可以實現離線資源載入。

專案地址

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

Android端實現

首先我們從webview_flutter中下載最新Archive(當前使用0.3.15+1)。 解壓後,使用AndroidStudio開啟,右鍵工程目錄,使用Android模式開啟

Android模式

如果要實現WebView請求攔截,就必須給webView設定WebViewCilent,全域性搜尋setWebViewClient找到只有一處實現:

//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;
            ...
        }
    }
}
複製程式碼

修改WebViewClient

通過以上程式碼我們知道具體邏輯在createWebViewClient方法中:

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

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

	return internalCreateWebViewClientCompat();
}
複製程式碼

然後在internalCreateWebViewClientinternalCreateWebViewClientCompat新增shouldInterceptRequest方法,然後參照onPageFinishedFlutterWebViewClient加入shouldInterceptRequest方法:

private WebViewClient internalCreateWebViewClient() {
    return new WebViewClient() {
        ...
        @Override
        public void onPageFinished(WebView view, String url) {
            FlutterWebViewClient.this.onPageFinished(view, url);
        }

        ...//參照
        @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);
        }
    };
}
複製程式碼

非同步變同步,MethodChannel接收Flutter層資料

我們在shouldInterceptRequest中接收來自Flutter世界的資料,如assets中的二進位制資料。但是要注意的是通過MethodChannel接收資料是通過非同步回撥的形式,但是shouldInterceptRequest方法需要同步接收資料,因此需要一個非同步變同步的執行器,同時MethodChannel呼叫必須在主線呈呼叫。方法有很多,我這裡通過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() {
                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() {
                        res = null;
                        countDownLatch.countDown();
                    }
                });
            }
        });
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return res;
    }
}
複製程式碼

注意到這裡success中接收到Map資料,我們會在接下來Flutter層傳過來。

Flutter層傳遞資料

webview_method_channel.dart中我們找到了onPageFinished接收來自Android或iOS的呼叫。參照onPageFinished方法,我們加入shouldInterceptRequest方法,同樣的在_platformCallbacksHandler對應的類中加入shouldInterceptRequest方法。依次向上層類推。

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;
    }
複製程式碼
//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);
}
複製程式碼
//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;
  }
}
複製程式碼

然後我們在example中實現一個簡單的logo替換效果

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 {//替換搜狗搜尋logo為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;
  },
),
複製程式碼

最終效果

搜狗搜尋logo替換

iOS端實現

webview_flutteriOS端是基於WKWebview實現的,攔截請求通過NSURLProtocol實現,可以參照iOS WKWebView (NSURLProtocol)攔截js、css,圖片資源一文。此法攔截是全域性攔截的,所以需要一個全域性變數儲存所有的FlutterMethodChannel,這裡定義一個單例儲存這些資料。

//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
複製程式碼

這裡為了區分對應的請求是在哪個channel下的,我們在給相應的WKWebviewagent最後加入#_viewId

//
//  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
複製程式碼

userAgent區分MethodChannel

我們在WKWebviewloadUrl中修改userAgent區分各個WebView對應的viewId

//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;
}
複製程式碼

NSURLProtocol實現請求攔截

然後在NSURLProtocol協議中的startLoading方法實現請求攔截

//  FlutterNSURLProtocol.h

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

@interface FlutterNSURLProtocol : NSURLProtocol

@end

NS_ASSUME_NONNULL_END
複製程式碼
//  FlutterNSURLProtocol.m

- (void)startLoading
{
    NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
    //給我們處理過的請求設定一個識別符號, 防止無限迴圈,
    [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];
        }
    }];
}
複製程式碼

通過之前設定的userAgent獲取相應的FlutterMethodChannel,呼叫shouldInterceptRequest方法獲取Flutter資料,通過Xcode除錯,我們知道相應的byte資料型別為FlutterStandardTypedData。或者參照下圖:

MethodChannel資料型別對應關係

具體效果

實現google搜尋logo替換

google搜尋logo替換

Webview黑夜模式Flutter端實踐

因為之前做了玩Android客戶端kotlin版,適配各個站點文章黑夜模式,需要替換相關css資源,因此才會想到改造webview_flutter外掛,下面是flutter版本替換掘金文章css檔案產生的黑夜模式效果

Android掘金文章黑夜模式

Android效果

iOS掘金文章黑夜模式

iOS效果

相關文章