最近、WebViewを使ったWebとNativeのハイブリッドアプリが流行っている。

iPhoneとAndroidで同一アプリを開発する場合、Web部分が共通化できるメリットが大きいからだろう。

と言うわけで WebView を使ったハイブリッドアプリの作り方を調べてみた。

iPhoneってどうしてるの?

私は iPhone はやってません。(だって開発するだけで金取られるんだもんw)
とは言え iPhone との互換性を無視しては意味ないのでやり方だけは調べて置きます。

ググると一杯出てきますが、通信のリクエストをフックして Native のコードで WebAPI を擬似的に実装するようです。

JavaScriptとの接続方法としては無理が無く良い方法だと思われます。

一方、Android は...

WebView#addJavascriptInterface() を使って Java のオブジェクトを登録すると JavaScript からそのまま使えると言う直接的な方法です。

が、これ結構致命的なセキュリホールが報告されています。
JavaScript から getClass() 等も呼べてしまうため、 WebView に信頼できないサイトの JavaScript が紛れ込むと何でも出来てしまいます。

スマホの運用条件を考えると PKI でサーバを固め、 アプリも XSS に細心の注意を払う必要がありそうです。
現実的にはかなりハードル高いっすね。

但し、API Level 17 (4.2.2) からは @JavascriptInterface アノテーションが追加されメソッドのアクセス制限ができる用になったので この問題は解決されています。
って、最近過ぎるだろw

それはそれとして使ってみる

最近のブラウザはカメラやバイブレーションの API も最初から持っていて以外に追加機能のネタが無かったりします。

なのでクロスドメイン可能な XMLHttpRequestXS とか追加してさらにセキュリティホールを広げて見たいと思います。

Javaオブジェクトの登録

基本ドキュメントの通りですが登録しているのはファクトリです。 ファクトリから得た Java オブジェクトも JavaScript から使えます。

登録処理抜粋:

private class SampleWebViewClient extends WebViewClient {
    @Override
    public void onPageStarted (WebView webview, String url, Bitmap favicon) {
        // 拡張XMLHttpRequestファクトリの初期化。
        XMLHttpRequestXSFactory factory = getXMLHttpRequestXSFactory();
        factory.setAccessControlList(_accessControlList);
        factory.setWebView(webview);
        webview.addJavascriptInterface(factory, "XMLHttpRequestXSFactory");
    }
    …省略
}

登録されるクラス抜粋:

public class XMLHttpRequestXSFactory {
    private WebView _webview;
    private AccessControlList _accessControlList;
    public XMLHttpRequestXSFactory() { }
    @JavascriptInterface
    public XMLHttpRequestXS getXMLHttpRequestXS() {
        return new XMLHttpRequestXS(this);
    }
    …省略
}

登録名に abc.XMLHttpRequestXSFactory とかしてみましたがダメでした。 navigator.~ もダメです。 グローバルの直下のみに登録できるようです。

登録のタイミングは onCreate() だけで無く WebViewClient#onPageStarted() にも必要なようです。

JavaScript から Java の呼び出し

登録されたJavaオブジェクトの呼び出しはほぼそのままです。 但し、フィールドにはアクセス出来ません。

JavaScript抜粋:

function XMLHttpRequestXS() {
    this._native = XMLHttpRequestXSFactory.getXMLHttpRequestXS();
    …省略
};
XMLHttpRequestXS.prototype = {
    open : function(method, url, async) {
        var error = this._native.open(method, url, async);
        if (error) throw error;
    },
    …省略
}

呼ばれ側Java抜粋:

public class XMLHttpRequestXS {
    …省略
    @JavascriptInterface
    public String open(String type, String url, boolean isAsync) throws Exception {
        Log.d(TAG,"open:"+type+" "+url);
        try {
            _isAsync = isAsync;
            if (GET.equalsIgnoreCase(type)) {
                _request = new HttpGet(url);
            } else {
                _request = new HttpPost(url);
            }
            _factory.checkDomain(_request.getURI());

            String cookie = CookieManager.getInstance().getCookie(url);
            _request.setHeader("Cookie", cookie);
            setReadyState(OPENED);
            return null;
        } catch (Throwable t) {
            Log.e(TAG, t.getMessage(), t);
            setReadyState(ERROR);
            return t.getMessage();
        }
    }
    …省略
}

ここで気になったのはメソッドへの引数です。 ドキュメントには JS -> Java の変換ルールが見つけられませんでした。

実際に試した方の情報では String,int,double,boolean,int[],String[] が受け取れたようです。

とりあえず、プリミティブまでは大丈夫そうな気がします。
JSON変換できるオブジェクトならこんな感じで渡せるようです。

Android.test(JSON.stringify({abc:"ABC", yyy:2}));

@JavascriptInterface
public void test(String jsonStr) throws JSONException {
    JSONObject json = new JSONObject(jsonStr);
    String abc = json.getString("abc");
    int yyy = json.getInt("yyy");
}

Java から JavaScript の呼び出し

ドキュメントの通り以下で呼び出せます。

webview.loadUrl("javascript:スクリプト");

が、JS->Java->JSと呼び出すと例外になります。

02-13 04:35:39.382: W/webview(3503): java.lang.Throwable: Warning: 
A WebView method was called on thread 'WebViewCoreThread'.
All WebView methods must be called on the UI thread.
Future versions of WebView may not support use on other threads.

JavaScript は WebView のスレッドで走っているので UIスレッドから呼べ、 と言うことらしいです。

つまり、JavaScriptから呼び出されたメソッドからコールバックしようとする場合、 HandlerかAsyncTaskを経由する必要があるようです。

Java から JavaScript オブジェクトの生成

どうも無理そうです。
JavaScript自体がJavaで実装されて無いと思われるので難しいのでしょう。

XMLやJSONは文字列で渡して JavaScript 側でパーズしてもらう事になりそうです。

動かしてみる

完成した XMLHttpRequestXS を jQuery.ajax() で実行してみます。

HTML:

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <script type="text/javascript" src="XMLHttpRequestXS.js" ></script>
    <script type="text/javascript" src="jquery-1.11.0.js" ></script>
    <script type="text/javascript">
jQuery.support.cors = true; // クロスドメインをjQueryでするために必要。

function testJqueryAsync() {
    console.log("testJqueryAsync");
    $.ajax({
        type: "GET", url: "http://www.redmine.org/issues.json",
        dataType: "json",
        success: function(data){
            var issues = data.issues;
            var $table = $("#table");
            for (var i=0; i<issues.length;i++) {
                $table.append($("<tr><td>"+issues[i].id+"</td><td>"+issues[i].subject+"</td></tr>"));
            }
        },
        error: function(xhr, status, error){
            alert("error:"+status+":"+error);
        },
        xhr: function() { // jQueryが使うXHRの差替用API
            return new XMLHttpRequestXS();
        }
    });
}
    </script>
</head>
<body onload="testJqueryAsync()">
    <h3>Ajax result from http://www.redmine.org/issues.json</h3>
    <table id="table" width="100%" border="1" >
        <tr><th>ID</th><th>Subject</th></tr>
    </table>
</body>
</html>

jQuery には XMLHttpRequest 差替え用の API が最初から付いてました。 さすが jQuery です。

実行結果:

外部サイトからちゃんとデータを取って来ています。

所感

一見単純そうに見えてやってみると以外に泥沼w
iPhoneとの共通化は表面を JavaScript の API にしてスタブを2種類用意する感じでしょうか。

課題も多いですが Andorid/iPhone のコード共通化やWebからアプリが更新できるメリットは大きいですね。

PhoneGap や Titanium を使う手もありますが HTML5 の機能拡張が著しいので足りない機能だけ Native で実装すると言う方がフレームワークの縛りが無い分楽かもしれません。

サンプルのソースコード一式は以下のSVNにあります。
注意:XMLHttpRequestXSはXSSのセキュリティホールを持ちます。技術検証以上の利用はしないで下さい。