这是你们项目中WebView的样子吗?

作者简介:Serpit,Android开发工程师,2023年加入37手游技术部,目前负责国内游戏发行 Android SDK 开发。

前言

开始前先问大家一个问题,你们项目中或者理想中的WebView的使用姿势是如何的?有哪些规范、功能?都可以在下方评论中说出来大家讨论一下。下面正式开始介绍我们对于一个WebView使用的一些理解。

可监控

可监控是线上项目很重要的一个功能,监控的东西可以是用户体验相关数据、加载情况数据、报错等。那么,如何监控呢?这里分两点提下。

加载时间

利用WebViewClient的onPageStartedonPageFinished回调,但是注意,这两回调在某些情况下会回调多次,比如在发生重定向时候,会有多次的onPageStarted回调出现,这就需要用一些标记位或者拦截等手段来保证统计到最开始的onPageStarted和最后的onPageFinished之间的耗时。

这里贴上一段伪代码

@Override
    public void onPageStarted(WebView webView, String url, Bitmap favicon) {
        super.onPageStarted(webView, url, favicon);
        if (TextUtils.isEmpty(url)) {
            return;
        }
        consumeMap.put(getKey(url), SystemClock.uptimeMillis());
    }

    @Override
    public void onPageFinished(WebView webView, String url) {
        super.onPageFinished(webView, url);
        if (TextUtils.isEmpty(url)) {
            return;
        }
        Long loadConsuming = consumeMap.remove(getKey(url));
        if (loadConsuming == null) {
            return;
        }
        trackWebLoadFinish(url, SystemClock.uptimeMillis() - loadConsuming); //记录耗时,埋点
    }

报错监控

报错这一块可以使用WebViewClient等一系列回调,诸如onReceivedErroronReceivedHttpErroronReceivedSslError这几个。只要在这几个方法中加上相应的埋点日志即可。但同时有注意的点是onReceivedError这个方法会有些报错是无用的,不影响用户使用,需要进行过滤。这里可以参考网上的一些方法做以下处理

  • 加载失败的url跟WebView里的url不是同一个url,过滤
  • errorCode=-1,表明是ERROR_UNKNOWN的错误,为了保证不误判,过滤
  • failingUrl=null&errorCode=-12,由于错误的url是空而不是ERROR_BAD_URL,过滤

除了这些常规的,还有一个是使用onConsoleMessage,去监控前端的错误日志,发现到前端的一些报错~也是一个不错的方向。

与前端的交互

与前端的交互,这个方式相信在网上随便一搜“Android与JS相互通信”等关键词,就可以搜出一大堆。在Android侧需要注册一个JavascriptInterface,里面定义各个方法,然后前端就可以调用到。然后需要调用到前端的函数,则利用WebView的evaluateJavascript方法即可。这些都比较基础,这里就不展开说,不清楚的同学可以用搜索引擎搜索一下~

这里想说的点其实是,在项目中如果简单定义JavascriptInterface可能会有“风险”。想象一个场景,定义了一个getToken方法,给到前端获取token方法,用于获取用户信息,这是一个很常见的方法。但是,重点来了,假如应用加载了一个外部的“恶意网站”,调用了这个getToken方法,就造成了信息泄漏。在安全上就出现了问题。

那么,有什么思路可以限制一下呢?有同学可能会想到,“混淆”把接口方法换成一些奇奇怪怪的方法,但这也太不利于代码可读性了吧。那这里可以提供一个思路,就是“鉴权拦截”。如何做?

先上代码

private class WebViewJsInterface {
    @JavascriptInterface
    public void callAndroid(final String method, final String params) {
        boolean result = intercept(method, params); //拦截方法
        if (!result){
            dispatcher.callAndroid(method, params);
        }
    }
}

这里想说的是,可以在项目里面使用统一调度的方式,前端调用安卓的入口只有一个,然后通过传入方法名来分发到不同方法上,这样的好处是,可以做到统一,统一的目的也是为了做拦截!在拦截上,我们就可以做很多文章,诸如域名校验,白名单上的域名直接不让调用原生方法。这样可以增加一定的安全性。

关于WebView的一些使用封装思路

我们知道WebView的灵魂其实有三个部分

  • WebView.getSetting()的设置
  • WebViewClient
  • WebChromeClient

我们做的很多功能都是基于着这三者来实现,那么在代码中,很多项目或者同学都是直接封装一个BaseWebViewClient,然后在里面做一堆逻辑处理,比如上面说的监控方法,或者loading操作等。这么做没有说不好,但是会由于业务的日益拓展,会使得这个“Base”日益臃肿,变得难以维护。在经历过这个阶段的我们,也想出了一个办法去优化。那就是用拦截器的思路,架构图如下:

这是你们项目中WebView的样子吗?_ide

这里,由于WebView不可以设置多个Client,那么就使用拦截器,将WebViewClient和WebChromeClient所有方法都封装起来,分发出去,每一个拦截器都负责自己的功能即可。比如实现一个loading的逻辑:

public class ProgressWebHook extends WebHook {
    
    private final IWebViewLoading mWebViewLoading;

    public ProgressWebHook(IWebViewLoading loading) {
        this.mWebViewLoading = loading;
    }

    @Override
    public void onPageStarted(WebView webView, String url, Bitmap favicon) {
        super.onPageStarted(webView, url, favicon);
        startLoading();
    }

    @Override
    public void onPageFinished(WebView webView, String url) {
        super.onPageFinished(webView, url);
        stopLoading();
    }

    @Override
    public void onReceivedError(WebView webView, String url, int errorCode, String description) {
        super.onReceivedError(webView, url, errorCode, description);
        stopLoading();
    }

    @Override
    public void onProgressChanged(WebView webView, int newProgress) {
        super.onProgressChanged(webView, newProgress);
        mWebViewLoading.onProgress(getContext(), newProgress);
    }
}

这样是不是简洁很多。再比如监控的时候,也实现一个WebHook,只负责监控相关的方法重写即可。就可以将这些方法不同功能方法隔离在不同的类中,方便解耦。至此,也会有同学想看下如何实现的拦截器,那这里也简单给大家看下。

public class BaseWebChromeClient extends WebChromeClient {

    private final WebHookDispatcher mWebHookDispatcher;

    public BaseWebChromeClient(WebHookDispatcher webHookDispatcher) {
        this.mWebHookDispatcher = webHookDispatcher;
    }

    @Override
    public void onPermissionRequest(PermissionRequest request) {
        mWebHookDispatcher.onPermissionRequest(request);
    }

    @Override
    public void onReceivedTitle(WebView view, String title) {
        super.onReceivedTitle(view, title);
        mWebHookDispatcher.onReceivedTitle(view, title);
    }

    @Override
    public void onProgressChanged(WebView view, int newProgress) {
        super.onProgressChanged(view, newProgress);
        mWebHookDispatcher.onProgressChanged(view, newProgress);
    }

    // For Android >= 5.0
    @Override
    public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback,
        FileChooserParams fileChooserParams) {
        return mWebHookDispatcher.onShowFileChooser(webView, filePathCallback, fileChooserParams);
    }

    @Override
    public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
        mWebHookDispatcher.onConsoleMessage(consoleMessage);
        return super.onConsoleMessage(consoleMessage);
    }

    @Override
    public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
        if (mWebHookDispatcher.onJsAlert(view, url, message, result)) {
            return true;
        }
        return super.onJsAlert(view, url, message, result);
    }

    @Override
    public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
        if (mWebHookDispatcher.onJsPrompt(view, url, message, defaultValue, result)) {
            return true;
        }
        return super.onJsPrompt(view, url, message, defaultValue, result);
    }

    @Override
    public boolean onJsBeforeUnload(WebView view, String url, String message, JsResult result) {
        if (mWebHookDispatcher.onJsBeforeUnload(view, url, message, result)) {
            return true;
        }
        return super.onJsBeforeUnload(view, url, message, result);
    }

    @Override
    public void onShowCustomView(View view, CustomViewCallback callback) {
        super.onShowCustomView(view, callback);
        mWebHookDispatcher.onShowCustomView(view, callback);
    }

    @Override
    public void onHideCustomView() {
        super.onHideCustomView();
        mWebHookDispatcher.onHideCustomView();
    }
}

拦截分发代码如下:

public class WebHookDispatcher extends SimpleWebHook {

    /**
     * 因为shouldInterceptRequest是一个异步的回调,所以这个类需要加锁
     */
    private final List<WebHook> webHooks = new CopyOnWriteArrayList<>();

    public void addWebHook(WebHook webHook) {
        webHooks.add(webHook);
        if (hasInit) {
            webHook.onWebInit(mWebView);
        }
    }

    public void addWebHooks(Collection<WebHook> webHooks) {
        this.webHooks.addAll(webHooks);
        if (hasInit) {
            for (WebHook webHook : webHooks) {
                webHook.onWebInit(mWebView);
            }
        }
    }

    public void addWebHook(int position, WebHook webHook) {
        webHooks.add(position, webHook);
        if (hasInit) {
            webHook.onWebInit(mWebView);
        }
    }

    public void addWebHooks(int position, Collection<WebHook> webHooks) {
        this.webHooks.addAll(position, webHooks);
        if (hasInit) {
            for (WebHook webHook : webHooks) {
                webHook.onWebInit(mWebView);
            }
        }
    }

    @Nullable
    public WebHook findWebHookByClass(Class<? extends WebHook> clazz) {
        for (WebHook webHook : webHooks) {
            if (webHook.getClass().equals(clazz)) {
                return webHook;
            }
        }
        return null;
    }

    public void removeWebHook(WebHook webHook) {
        webHooks.remove(webHook);
    }

    @NonNull
    public List<WebHook> getWebHooks() {
        return webHooks;
    }

    public void clear() {
        webHooks.clear();
    }

    //dispatch method ----------------

    @Override
    public boolean shouldOverrideUrlLoading(WebView webView, String url) {
        for (WebHook webHook : webHooks) {
            if (webHook.shouldOverrideUrlLoading(webView, url)) {
                return true;
            }
        }
        return super.shouldOverrideUrlLoading(webView, url);
    }


    @Override
    public void onPageFinished(WebView webView, String url) {
        for (WebHook webHook : webHooks) {
            webHook.onPageFinished(webView, url);
        }
    }

    @Override
    public void onReceivedTitle(WebView webView, String title) {
        for (WebHook webHook : webHooks) {
            webHook.onReceivedTitle(webView, title);
        }
    }

    @Override
    public void onProgressChanged(WebView webView, int newProgress) {
        for (WebHook webHook : webHooks) {
            webHook.onProgressChanged(webView, newProgress);
        }
    }

    @Override
    public void onPageStarted(WebView webView, String url, Bitmap favicon) {
        for (WebHook webHook : webHooks) {
            webHook.onPageStarted(webView, url, favicon);
        }
    }


    @Override
    public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback,
        FileChooserParams fileChooserParams) {
        for (WebHook webHook : webHooks) {
            if (webHook.onShowFileChooser(webView, filePathCallback, fileChooserParams)) {
                return true;
            }
        }
        return false;
    }

    @Override
    public boolean onActivityResult(int requestCode, int resultCode, Intent intent) {
        for (WebHook webHook : webHooks) {
            if (webHook.onActivityResult(requestCode, resultCode, intent)) {
                return true;
            }
        }
        return false;
    }


    @Override
    public void onReceivedError(WebView webView, WebResourceRequest request, WebResourceError error) {
        for (WebHook webHook : webHooks) {
            webHook.onReceivedError(webView, request, error);
        }
    }

    @Override
    public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
        for (WebHook webHook : webHooks) {
            WebResourceResponse response = webHook.shouldInterceptRequest(view, request);
            if (response != null) {
                return response;
            }
        }
        return super.shouldInterceptRequest(view, request);
    }

    @Override
    public boolean onBackPressed() {
        for (WebHook webHook : webHooks) {
            if (webHook.onBackPressed()) {
                return true;
            }
        }
        return super.onBackPressed();
    }

    @Override
    public boolean onKeyUp(int keyCode, KeyEvent event) {
        for (WebHook webHook : webHooks) {
            if (webHook.onKeyUp(keyCode, event)) {
                return true;
            }
        }
        return super.onKeyUp(keyCode, event);
    }

    @Override
    public void onConsoleMessage(ConsoleMessage consoleMessage) {
        for (WebHook webHook : webHooks) {
            webHook.onConsoleMessage(consoleMessage);
        }
    }

    @Override
    public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
        for (WebHook webHook : webHooks) {
            webHook.onReceivedSslError(view, handler, error);
        }
        super.onReceivedSslError(view, handler, error);
    }

    @Override
    public void onReceivedError(WebView webView, String url, int errorCode, String description) {
        for (WebHook webHook : webHooks) {
            webHook.onReceivedError(webView, url, errorCode, description);
        }
        super.onReceivedError(webView, url, errorCode, description);
    }


    @Override
    public void onPermissionRequest(PermissionRequest request) {
        super.onPermissionRequest(request);
        for (WebHook webHook : webHooks) {
            webHook.onPermissionRequest(request);
        }
    }
    // ...其余回调代码省略