基于网络的内容 – Android WebView 混合开发
基于网络的内容 – Android WebView 混合开发

基于网络的内容 – Android WebView 混合开发

阅读时长 ≈19 分, 6 秒

Loading

Web 开发具有成本低、效率高、体验好、维护便捷等优势。但当其在 Android 中作为 App 时,纯 Web 开发会受到一些限制,这时候就需要混合开发来帮助我们完成一些需求。

首先,我们需要做一些准备工作:为应用添加一个启用了 JavaScript 的 WebView,声明 INTERNET 权限(WebView 需此权限才能加载页面,即使页面内容为本地资源),在 Assets 资源文件夹中放置页面并加载。

Layout
    ...
    <WebView
        android:id="@+id/webview"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
    />
    ...
XML
Manifest
    <manifest ... >
        <uses-permission android:name="android.permission.INTERNET" />
        ...
    </manifest>
XML
MainActivity
import android.annotation.SuppressLint;
import android.os.Bundle;
import android.webkit.WebView;
    ...
    @SuppressLint("SetJavaScriptEnabled")
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        WebView mWebView = findViewById(R.id.webview);
        mWebView.getSettings().setDefaultTextEncodingName("utf-8");
        mWebView.getSettings().setJavaScriptEnabled(true);
        mWebView.loadUrl("file:///android_asset/www/index.html"); // You can directly use file://android_asset/ to load the files in the assets folder
    }
    ...
Java

JavascriptInterface

在 Android 和 Web 混合开发中,免不了 Java 与 JavaScript 代码相互调用,而 WebView 就给我们提供了这样一个接口:JavascriptInterface

public abstract @interface JavascriptInterface implements Annotation

Annotation that allows exposing methods to JavaScript. Starting from API level Build.VERSION_CODES.JELLY_BEAN_MR1 and above, only methods explicitly marked with this annotation are available to the Javascript code.

简单来说,在 Android 4.2 Jelly Bean(API 17)后,应用需要在方法中声明 @JavascriptInterface 注解,并将其所在类添加到 WebView 中,允许应用内启用了 JavaScript 的 WebView 直接调用其类成员方法。

MainActivity
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Bundle;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import android.widget.Toast;
    ...
    @SuppressLint("StaticFieldLeak")
    private static Context mContext;

    @SuppressLint("SetJavaScriptEnabled")
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mContext = getApplicationContext();
        WebView mWebView = findViewById(R.id.webview);
        mWebView.getSettings().setDefaultTextEncodingName("utf-8");
        mWebView.getSettings().setJavaScriptEnabled(true);

        mWebView.addJavascriptInterface(new JavaScriptBridge(), "Android"); // Export class JavaScriptBridge to WebView and map it to window.Android object in JavaScript

        mWebView.loadUrl("file:///android_asset/www/index.html"); // You can directly use file://android_asset/ to access the assets folder, or use file://android_res/ to access the res folder
    }

    @SuppressWarnings("unused")
    public static class JavaScriptBridge {

        @JavascriptInterface
        public void makeToast(final String message) {
            Toast.makeText(mContext, message, Toast.LENGTH_LONG).show();
        }
    }
    ...
Java
WebPage
...
<script type="text/javascript">
    "use strict";
    window.Android.makeToast("Hello world");
</script>
...
HTML

上述示例代码将允许 JavaScript 通过 window.Android 对象,调用 JavaScriptBridge 类中声明了 @JavascriptInterface 注解的 makeToast 方法。运行后显示一个内容为 Hello world 的 Toast。


链接访问拦截

WebViewClient 提供了 shouldOverrideUrlLoading 事件,可以让我们在 URL 加载时做一些事情,比如拦截某个链接。

public boolean shouldOverrideUrlLoading (WebView view, WebResourceRequest request)

Give the host application a chance to take control when a URL is about to be loaded in the current WebView. If a WebViewClient is not provided, by default WebView will ask Activity Manager to choose the proper handler for the URL. If a WebViewClient is provided, returning true causes the current WebView to abort loading the URL, while returning false causes the WebView to continue loading the URL as usual.
MainActivity
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.webkit.WebResourceRequest;
import android.webkit.WebView;
import android.webkit.WebViewClient;
    ...
    @SuppressLint("StaticFieldLeak")
    private static Context mContext;

    @SuppressLint("SetJavaScriptEnabled")
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mContext = getApplicationContext();
        WebView mWebView = findViewById(R.id.webview);
        mWebView.getSettings().setDefaultTextEncodingName("utf-8");
        mWebView.getSettings().setJavaScriptEnabled(true);

        mWebView.setWebViewClient(new WebViewClient() {
            @Override
            public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
                if (request.getUrl().toString().equalsIgnoreCase("https://www.google.cn/")) {
                    view.loadUrl("https://www.google.com/ncr");
                    return true;
                } else if (request.getUrl().toString().startsWith("meowcat://open_settings")) {
                    final Intent intent = mContext.getPackageManager().getLaunchIntentForPackage("com.android.settings");
                    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
                    mContext.startActivity(intent);
                    return true;
                }
                return false;
            }
        });

        mWebView.loadUrl("file:///android_asset/www/index.html"); // You can directly use file://android_asset/ to access the assets folder, or use file://android_res/ to access the res folder
    }
    ...
Java

上述示例代码将在加载 https://www.google.cn/ 时跳转到 https://www.google.com/ncr*1,或在链接为 meowcat://open_settings 时打开系统设置。

除示例代码外,也可以直接 return true; 来中断页面加载。

注:该方法不适用于 POST 请求,页面在进行表单提交等 POST 请求时不会调用。


在页面内执行外部 JavaScript 代码

出于调试需求,我们可能需要通过 Java 代码在页面内执行一些 JavaScript 代码,使用 loadUrl(String)evaluateJavascript(String, ValueCallback<String>) 方法即可轻松实现该需求。若代码需要在页面加载完毕后执行,WebViewClient 也为我们提供了 onPageFinished 事件。

public void loadUrl (String url)

Loads the given URL.
Also see compatibility note on evaluateJavascript(String, ValueCallback).

public void evaluateJavascript (String script, ValueCallback resultCallback)

Asynchronously evaluates JavaScript in the context of the currently displayed page. If non-null, resultCallback will be invoked with any result returned from that execution. This method must be called on the UI thread and the callback will be made on the UI thread.
Compatibility note. Applications targeting Build.VERSION_CODES.N or later, JavaScript state from an empty WebView is no longer persisted across navigations like loadUrl(java.lang.String). For example, global variables and functions defined before calling loadUrl(java.lang.String) will not exist in the loaded page. Applications should use addJavascriptInterface(Object, String) instead to persist JavaScript objects across navigations.

public void onPageFinished (WebView view, String url)

Notify the host application that a page has finished loading. This method is called only for main frame. Receiving an onPageFinished() callback does not guarantee that the next frame drawn by WebView will reflect the state of the DOM at this point. In order to be notified that the current DOM state is ready to be rendered, request a visual state callback with WebView#postVisualStateCallback and wait for the supplied callback to be triggered.
MainActivity
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Bundle;
import android.webkit.WebView;
import android.webkit.WebViewClient;
    ...
    @SuppressLint("StaticFieldLeak")
    private static Context mContext;

    @SuppressLint("SetJavaScriptEnabled")
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mContext = getApplicationContext();
        WebView mWebView = findViewById(R.id.webview);
        mWebView.getSettings().setDefaultTextEncodingName("utf-8");
        mWebView.getSettings().setJavaScriptEnabled(true);
        mWebView.setWebViewClient(new WebViewClient() {

            @Override
            public void onPageFinished(WebView view, String url) {
                if (url.startsWith("https://www.google.")) {
                    view.loadUrl("javascript:(() => {window.location = 'https://www.google.com/ncr';})();");
                    // Equals with
                    // view.evaluateJavascript("window.location = 'https://www.google.com/ncr';", null);
                }
                super.onPageFinished(view, url);
            }

        });
        mWebView.loadUrl("file:///android_asset/www/index.html"); // You can directly use file://android_asset/ to access the assets folder, or use file://android_res/ to access the res folder
    }
    ...
Java

上述示例代码将在页面加载完毕后,打开 https://www.google.cn/,而后被 shouldOverrideUrlLoading 方法跳转到 https://www.google.com/ncr

代码中 loadUrlevaluateJavascript 的示例等价,选用其一即可。

注:若使用 evaluateJavascript 方法的回调功能,则此方法与回调方法都必须在主线程(UI 线程)中执行或声明。


本地资源加载

在上面的示例代码中,我们使用了 file:///android_asset/ 来直接加载 assets 资源文件夹中的资源。但由于一些强制执行的安全策略(Content Security Policy)限制,使得该非同源 URL 无法正常被加载,这时候就可以使用 WebViewClient 提供的 shouldInterceptRequest 事件来辅助加载。

public WebResourceResponse shouldInterceptRequest (WebView view, WebResourceRequest request)

Notify the host application of a resource request and allow the application to return the data. If the return value is null, the WebView will continue to load the resource as usual. Otherwise, the return response and data will be used.
This callback is invoked for a variety of URL schemes (e.g., http(s):data:file:, etc.), not only those schemes which send requests over the network. This is not called for javascript: URLs, blob: URLs, or for assets accessed via file:///android_asset/ or file:///android_res/ URLs.
In the case of redirects, this is only called for the initial resource URL, not any subsequent redirect URLs.
MainActivity
import android.annotation.SuppressLint;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.webkit.JavascriptInterface;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Toast;

import java.io.IOException;
    ...
    @SuppressLint("StaticFieldLeak")
    private static Context mContext;

    @SuppressLint("SetJavaScriptEnabled")
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mContext = getApplicationContext();
        WebView mWebView = findViewById(R.id.webview);
        mWebView.getSettings().setDefaultTextEncodingName("utf-8");
        mWebView.getSettings().setJavaScriptEnabled(true);
        mWebView.addJavascriptInterface(new JavaScriptBridge(), "Android"); // Export class JavaScriptBridge to WebView and map it to window.Android object in JavaScript
        mWebView.setWebViewClient(new WebViewClient() {
            @Override
            public void onPageFinished(WebView view, String url) {
                view.loadUrl("javascript:(() => {const script = document.createElement('script'); script.src = '/MeowCat-Android-Asset/www/js/main.js'; document.body.append(script);})();");
                super.onPageFinished(view, url);
            }

            @Override
            public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest webResourceRequest) {
                String url = webResourceRequest.getUrl().toString();
                Uri uri = Uri.parse(url);
                String key = uri.getScheme() + "://" + uri.getHost() + "/MeowCat-Android-Asset/";
                if (url.contains(key)) {
                    String assetsPath = url.replace(key, "");
                    try {
                        return new WebResourceResponse("text/plain", "UTF-8", getAssets().open(assetsPath));
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                return super.shouldInterceptRequest(view, webResourceRequest);
            }

        });
        mWebView.loadUrl("https://www.google.com/ncr");
    }

    @SuppressWarnings("unused")
    private static class JavaScriptBridge {

        @JavascriptInterface
        public void makeToast(final String message) {
            Toast.makeText(mContext, message, Toast.LENGTH_LONG).show();
        }
    }
    ...
Java
Main
"use strict";
window.Android.makeToast("Hello world");
JavaScript

上述示例代码将打开 https://www.google.com/ncr(因 shouldInterceptRequest 方法不会在加载特殊 Schemes 时被调用,故选用 Google 作为示例),页面加载完毕后插入 script 标签,加载并执行位于 file://android_asset/www/js/main.js 中的代码。运行后显示一个内容为 Hello world 的 Toast。

注:在 Android 官方开发文档 中,还有另一种使用 WebViewAssetLoader 的本地资源加载方式,感兴趣的可以自行研究一下,本文不再赘述。


JavaScript 弹窗提示

上面的示例代码已经可以帮助我们完成大多数需求,但在实际应用中发现了另外一个问题,JavaScript 的 alert() comfirm() prompt() 函数全部失效,这不是我们期望的行为。WebChromeClient 为我们提供了 onJsAlert onJsConfirm onJsPrompt 事件,分别对应上述函数,我们需要自行实现上述方法。

MainActivity
import androidx.appcompat.app.AlertDialog;

import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Bundle;
import android.webkit.JsPromptResult;
import android.webkit.JsResult;
import android.webkit.WebChromeClient;
import android.webkit.WebView;
    ...
    @SuppressLint("StaticFieldLeak")
    private static Context mContext;

    @SuppressLint("SetJavaScriptEnabled")
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mContext = getApplicationContext();
        WebView mWebView = findViewById(R.id.webview);
        mWebView.getSettings().setDefaultTextEncodingName("utf-8");
        mWebView.getSettings().setJavaScriptEnabled(true);

        mWebView.setWebChromeClient(new WebChromeClient() {
            @Override
            public boolean onJsAlert(WebView view, String url, String message, final JsResult result) {
                onJsDialog(DialogType.ALERT, view, url, message, result, null, null);
                return true;
            }

            @Override
            public boolean onJsConfirm(WebView view, String url, String message, final JsResult result) {
                onJsDialog(DialogType.CONFIRM, view, url, message, result, null, null);
                return true;
            }

            @Override
            public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, final JsPromptResult result) {
                onJsDialog(DialogType.PROMPT, view, url, message, null, defaultValue, result);
                return true;
            }
        });

        mWebView.loadUrl("file:///android_asset/www/index.html"); // You can directly use file://android_asset/ to access the assets folder, or use file://android_res/ to access the res folder
    }

    private enum DialogType {
        ALERT,
        CONFIRM,
        PROMPT
    }

    private static void onJsDialog(DialogType type, WebView view, String url, String message, final JsResult result, String defaultValue, final JsPromptResult promptResult) {
        AlertDialog.Builder builder = new AlertDialog.Builder(view.getContext());
        String[] content = message.split(":", 2);
        builder.setTitle(content[0]);
        builder.setMessage(content[1] + "\n" + url);
        builder.setCancelable(false);
        switch (type) {
            case PROMPT:
                builder.setPositiveButton(android.R.string.ok, (dialog, which) -> promptResult.confirm(defaultValue)); // TODO: Input
                break;
            case CONFIRM:
                builder.setCancelable(true);
                builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> result.cancel());
            case ALERT:
            default:
                builder.setPositiveButton(android.R.string.ok, (dialog, which) -> result.confirm());
        }
        builder.create().show();
    }
    ...
Java
HTML
...
<script type="text/javascript">
    "use strict";
    alert("Alert Title:This is an alert");
    confirm("Confirm Title:This is a confirm") ? alert("Alert Title (Confirm):You confirmed the dialog") : alert("Alert Title (Confirm):You canceled the dialog");
    alert("Alert Title (Prompt):Prompt content is " + prompt("Prompt Title:This is a prompt", "Hello world"));
</script>
...
HTML

上述示例代码中,onJsDialog 方法统一处理了来自 WebChromeClient 的 onJsAlert onJsConfirm onJsPrompt 事件,添加了标题(JavaScript 函数仅支持信息传参,这里以第一个 : 作为标题和信息的分隔符),弹出对话框并返回;DialogType 用于判断事件类型。

运行后依次弹出对话框,内容分别为:

Alert Title
This is an alert
file:///android_asset/www/index.html
                                    [OK]
Confirm Title
This is a confirm
file:///android_asset/www/index.html
                    [CANCEL] [OK]

若点击了 OK

Alert Title (Confirm)
You confirmed the dialog
file:///android_asset/www/index.html
                                    [OK]

若点击了 CANCEL

Alert Title (Confirm)
You canceled the dialog
file:///android_asset/www/index.html
                                    [OK]
Prompt Title
This is a prompt
file:///android_asset/www/index.html
                                    [OK]
Alert Title (Prompt)
Prompt content is Hello world
file:///android_asset/www/index.html
                                    [OK]

亦可根据其他需求定制对话框的样式和(或)功能。

注:onJsDialog 方法仅作为示例,并未实现 prompt() 函数的输入功能,以默认值返回。


实战:在页面中插入 vConsole 并在成功插入后弹出提示对话框

vConsole 是腾讯出品的一个轻量、可拓展、针对手机网页的前端开发者调试面板,可以在 Vue、React 或其他任何框架中使用。用于移动设备调试非常好用,下面的实例将使用本文所介绍的所有技巧,在页面底部插入 vConsole。

下载 vconsole.min.js 并保存至 assets 资源文件夹中:https://cdn.jsdelivr.net/npm/vconsole@latest/dist/vconsole.min.js

Java
import androidx.appcompat.app.AlertDialog;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.webkit.JavascriptInterface;
import android.webkit.JsPromptResult;
import android.webkit.JsResult;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Toast;

import java.io.IOException;
    ...
    @SuppressLint("StaticFieldLeak")
    private static Context mContext;

    @SuppressLint("SetJavaScriptEnabled")
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mContext = getApplicationContext();
        WebView mWebView = findViewById(R.id.webview);
        mWebView.getSettings().setJavaScriptEnabled(true);
        mWebView.addJavascriptInterface(new JavaScriptBridge(), "Android"); // Export class JavaScriptBridge to WebView and map it to window.Android object in JavaScript
        mWebView.setWebViewClient(new WebViewClient() {
            @Override
            public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
                if (request.getUrl().toString().equalsIgnoreCase("https://www.google.cn/")) {
                    view.loadUrl("https://www.google.com/ncr");
                    return true;
                } else if (request.getUrl().toString().startsWith("meowcat://open_settings")) {
                    final Intent intent = mContext.getPackageManager().getLaunchIntentForPackage("com.android.settings");
                    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
                    mContext.startActivity(intent);
                    return true;
                }
                return false;
            }

            @Override
            public void onPageFinished(WebView view, String url) {
                view.loadUrl("javascript:(() => {const script = document.createElement('script'); script.src='/MeowCat-Android-Asset/www/js/vconsole.min.js'; document.body.append(script); script.onload = () => {alert('vConsole:Loaded!'); if (typeof VConsole !== 'undefined') {new VConsole({onReady: () => {const vc = document.getElementById('__vconsole'); const vc_switch = vc.querySelector('.vc-switch'); vc.style.position = 'relative'; vc.style.zIndex = 9999999999; vc_switch.style.opacity = 'opacity' in this ? this.opacity : .5;},});}};})();");
                super.onPageFinished(view, url);
            }

            @Override
            public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest webResourceRequest) {
                String url = webResourceRequest.getUrl().toString();
                Uri uri = Uri.parse(url);
                String key = uri.getScheme() + "://" + uri.getHost() + "/MeowCat-Android-Asset/";
                if (url.contains(key)) {
                    String assetsPath = url.replace(key, "");
                    try {
                        return new WebResourceResponse("text/plain", "UTF-8", getAssets().open(assetsPath));
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                return super.shouldInterceptRequest(view, webResourceRequest);
            }
        });
        mWebView.setWebChromeClient(new WebChromeClient() {
            @Override
            public boolean onJsAlert(WebView view, String url, String message, final JsResult result) {
                onJsDialog(DialogType.ALERT, view, url, message, result, null, null);
                return true;
            }

            @Override
            public boolean onJsConfirm(WebView view, String url, String message, final JsResult result) {
                onJsDialog(DialogType.CONFIRM, view, url, message, result, null, null);
                return true;
            }

            @Override
            public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, final JsPromptResult result) {
                onJsDialog(DialogType.PROMPT, view, url, message, null, defaultValue, result);
                return true;
            }
        });
        mWebView.loadUrl("file:///android_asset/www/index.html"); // You can directly use file://android_asset/ to load the files in the assets folder
    }

    private enum DialogType {
        ALERT,
        CONFIRM,
        PROMPT
    }

    private static void onJsDialog(DialogType type, WebView view, String url, String message, final JsResult result, String defaultValue, final JsPromptResult promptResult) {
        AlertDialog.Builder builder = new AlertDialog.Builder(view.getContext());
        String[] content = message.split(":", 2);
        builder.setTitle(content[0]);
        builder.setMessage(content[1] + "\n" + url);
        builder.setCancelable(false);
        switch (type) {
            case PROMPT:
                builder.setPositiveButton(android.R.string.ok, (dialog, which) -> promptResult.confirm(defaultValue)); // TODO: Input
                break;
            case CONFIRM:
                builder.setCancelable(true);
                builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> result.cancel());
            case ALERT:
            default:
                builder.setPositiveButton(android.R.string.ok, (dialog, which) -> result.confirm());
        }
        builder.create().show();
    }

    @SuppressWarnings("unused")
    private static class JavaScriptBridge {

        @JavascriptInterface
        public void makeToast(final String message) {
            Toast.makeText(mContext, message, Toast.LENGTH_LONG).show();
        }
    }
    ...
Java
WebPage
...
<script type="text/javascript">
    "use strict";
    alert("Alert Title:This is an alert");
    confirm("Confirm Title:This is a confirm") ? alert("Alert Title (Confirm):You confirmed the dialog") : alert("Alert Title (Confirm):You canceled the dialog");
    alert("Alert Title (Prompt):Prompt content is " + prompt("Prompt Title:This is a prompt", "Hello world"));
    window.Android.makeToast("Hello world");
    window.location = "https://www.google.cn/";
</script>
...
HTML

运行代码,最终您将能够看到如下提示:

vConsole
Loaded!
https://www.google.com/
                                    [OK]

然后在页面的右下角,会出现一个绿色按钮,上面写着 vConsole。我们做到了,那正是我们想要的。


现在你已经学会 Android WebView 混合开发的小技巧了,快去试一试吧。

引申阅读:在 Android 开发者文档 中,还有更多关于 Android WebView 混合开发的内容。


*1: NCR: No Country Redirect,Google 支持禁用地区跳转功能。

发表回复