在用移動(dòng)設(shè)備加載H5頁(yè)面時(shí),一些公共資源如css,js,圖片等如果比較大時(shí),就需要通過(guò)攔截網(wǎng)絡(luò),改從本地資源加載。在Android原生WebView中,我們可以在WebViewClient中的shouldInterceptRequest方法來(lái)攔截替換資源。
然而在Flutter中的WebView插件,不管是官方的webview_flutter,還是flutter_webview_plugin都不支持加載本地資源。
慶幸的是webview_flutter底層實(shí)現(xiàn)是基于WebView(Android)和WKWebView(iOS)。只要在官方webview_flutter上稍作修改,就可以實(shí)現(xiàn)離線資源加載。
項(xiàng)目地址
github: https://github.com/iamyours/webview_flutter
pub: iwebview_flutter
Android端實(shí)現(xiàn)
首先我們從webview_flutter中下載最新Archive(當(dāng)前使用0.3.15+1)。
解壓后,使用AndroidStudio打開(kāi),右鍵工程目錄,使用Android模式打開(kāi)

如果要實(shí)現(xiàn)WebView請(qǐng)求攔截,就必須給webView設(shè)置WebViewCilent,全局搜索setWebViewClient找到只有一處實(shí)現(xiàn):
//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
通過(guò)以上代碼我們知道具體邏輯在createWebViewClient方法中:
WebViewClient createWebViewClient(boolean hasNavigationDelegate) {
this.hasNavigationDelegate = hasNavigationDelegate;
if (!hasNavigationDelegate || android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return internalCreateWebViewClient();
}
return internalCreateWebViewClientCompat();
}
然后在internalCreateWebViewClient和internalCreateWebViewClientCompat添加shouldInterceptRequest方法,然后參照onPageFinished在FlutterWebViewClient加入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層數(shù)據(jù)
我們?cè)?code>shouldInterceptRequest中接收來(lái)自Flutter世界的數(shù)據(jù),如assets中的二進(jìn)制數(shù)據(jù)。但是要注意的是通過(guò)MethodChannel接收數(shù)據(jù)是通過(guò)異步回調(diào)的形式,但是shouldInterceptRequest方法需要同步接收數(shù)據(jù),因此需要一個(gè)異步變同步的執(zhí)行器,同時(shí)MethodChannel調(diào)用必須在主線呈調(diào)用。方法有很多,我這里通過(guò)CountDownLatch實(shí)現(xiàn)。
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數(shù)據(jù),我們會(huì)在接下來(lái)Flutter層傳過(guò)來(lái)。
Flutter層傳遞數(shù)據(jù)
在webview_method_channel.dart中我們找到了onPageFinished接收來(lái)自Android或iOS的調(diào)用。參照onPageFinished方法,我們加入shouldInterceptRequest方法,同樣的在_platformCallbacksHandler對(duì)應(yīng)的類中加入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;
}
}
然后我們?cè)?code>example中實(shí)現(xiàn)一個(gè)簡(jiǎn)單的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;
},
),
最終效果

iOS端實(shí)現(xiàn)
NSURLProtocol攔截請(qǐng)求
webview_flutteriOS端是基于WKWebview實(shí)現(xiàn)的,攔截請(qǐng)求通過(guò)NSURLProtocol實(shí)現(xiàn),可以參照iOS WKWebView (NSURLProtocol)攔截js、css,圖片資源一文。此法攔截是全局?jǐn)r截的,所以需要一個(gè)全局變量存儲(chǔ)所有的FlutterMethodChannel,這里定義一個(gè)單例存儲(chǔ)這些數(shù)據(jù)。
//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
這里為了區(qū)分對(duì)應(yīng)的請(qǐng)求是在哪個(gè)channel下的,我們?cè)诮o相應(yīng)的WKWebview的agent最后加入#_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區(qū)分MethodChannel
我們?cè)?code>WKWebview的loadUrl中修改userAgent區(qū)分各個(gè)WebView對(duì)應(yīng)的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實(shí)現(xiàn)請(qǐng)求攔截
然后在NSURLProtocol協(xié)議中的startLoading方法實(shí)現(xiàn)請(qǐng)求攔截
// 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];
//給我們處理過(guò)的請(qǐng)求設(shè)置一個(gè)標(biāo)識(shí)符, 防止無(wú)限循環(huán),
[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];
}
}];
}
通過(guò)之前設(shè)置的userAgent獲取相應(yīng)的FlutterMethodChannel,調(diào)用shouldInterceptRequest方法獲取Flutter數(shù)據(jù),通過(guò)Xcode調(diào)試,我們知道相應(yīng)的byte數(shù)據(jù)類型為FlutterStandardTypedData?;蛘邊⒄障聢D:

具體效果
實(shí)現(xiàn)google搜索logo替換

Webview黑夜模式Flutter端實(shí)踐
因?yàn)橹白隽送鍭ndroid客戶端kotlin版,適配各個(gè)站點(diǎn)文章黑夜模式,需要替換相關(guān)css資源,因此才會(huì)想到改造webview_flutter插件,下面是flutter版本替換掘金文章css文件產(chǎn)生的黑夜模式效果
Android掘金文章黑夜模式

iOS掘金文章黑夜模式
