Android

Android エンジニアが Android の WebView で苦しんだ話

More than 3 years have passed since last update.

はじめに

Gingerbread から Android を触り続けて、たぶん5年くらい。
ネイティブのアプリ作るのは困らないくらいの Android エンジニアになりました、わーい。

で、この間お仕事でちょっと WebView を触らなければいけなくなった。
まぁいろいろ噂は聞いていたんだけど、
「結局のところ、ビューに HTML 設定するだけっしょ?余裕っすわ!」と舐めきっておりました。

もうね、大変だった。

大変だった!

ということで、困ったポイントをメモしておきます。

やりたいこと

  1. 動的に HTML を生成
  2. WebView のサイズにスケーリングして表示
  3. リンクをタッチしたらブラウザアプリを起動

困ったポイント1. 謎のパディング

じゃあ、とりあえず WebView に HTML を設定してみよう!と実装して、ファ!?ってなったのがコレ。
コンテンツに謎のパディングが発生する…。

おいおい、早速かよ…と思いながら調べた結果、
以下のスタイルタグを HTML に埋め込んだら、解決した。

<style>* {margin:0; padding:0;}</style>

追記

コメントの方でやりとりしてるけども、
上のスタイルタグは、reset.css の位置付けなので、以下のように訂正します。

/**
 * HTML の読み込み
 */
private void loadHTML() {
    // HTMLの生成
    String html = createHtml();

    // 生成した HTML の読み込み
    // Assets からリソースを読み込みたいので、Assets の URL を指定
    loadDataWithBaseURL("file:///android_asset/", html, "text/html", "utf−8", null);
}

@SuppressWarnings("StringBufferReplaceableByString")
@Override
protected String createHtml() {
    StringBuilder htmlBuilder = new StringBuilder();

    htmlBuilder.append("<html>");
    htmlBuilder.append("<head>");

    // リセットするアレ
    htmlBuilder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"reset.css\">");

    // HTML 読み込んだら全文表示のスクリプト
    htmlBuilder.append("<script>window.onload=function(){var html=document.getElementsByTagName('html');console.log(html[0].outerHTML);}</script>");

    htmlBuilder.append("</head>");
    htmlBuilder.append("<body>");

    // 普通のリンクを設定
    htmlBuilder.append("<a href=\"https://www.google.co.jp/\" target=\"_blank\">");
    htmlBuilder.append(String.format("<img width=%f height=%f border=\"0\" src=\"google.jpg\" alt=\"Google\" />", CONTENT_WIDTH, CONTENT_HEIGHT));
    htmlBuilder.append("</a>");

    htmlBuilder.append("</body>");
    htmlBuilder.append("</html>");

    return htmlBuilder.toString();
}

reset.css 自体は、Eric Meyer's "Reset CSS" 2.0 です。
ちなみに Normalize.css でも期待通りに動きました。
今回使っていない理由は、WebView オンリーで動けば良いコンテンツだったというだけです。

スクリプトの結果を logcat に出したい場合は、
WebSettings の setJavaScriptEnabled を有効にしつつ、
WebChromeClient の onConsoleMessage をオーバーライドしてくださいな。

setWebChromeClient(new WebChromeClient() {
    @Override
    public boolean onCreateWindow(WebView view, boolean isDialog, boolean isUserGesture, Message resultMsg) {
        ...
    }

    @Override
    public boolean onConsoleMessage(@NonNull ConsoleMessage consoleMessage) {
        // Javascript の console.log を出す
        Log.d(TAG, "javascript: " + consoleMessage.message());

        return super.onConsoleMessage(consoleMessage);
    }
});

困ったポイント2. コンテンツのスケーリング

とりあえず、無事に左上に寄ってくれるようになったが、
当然コンテンツのスケールが WebView の幅にあっていなかったので、合わせようと模索開始。

setUseWideViewPort(true); をしつつ、
メタタグのビューポートの initial-scale とかにpx単位で計算した値を入れたりした。

その結果、何も触らない状態ではちゃんと WebView の幅にあった表示ができるようになったのだが、
横に謎の空白が生まれて、横スクロールができる状態に…ナンデカナー。

まぁ、スクロールできないようにすれば…と思ってタッチイベントを横取りしたんだけど、
レビューで「そりゃいかんでしょ」となって再検討、ぐぬぬ。

結果、

  • setUseWideViewPort を使わない
  • メタタグのビューポートも使わない
  • setInitialScale に計算したスケールをパーセンテージで設定

により、解決。

/**
 * リサイズ処理
 *
 * @param contentWidth コンテンツの幅
 */
private void resizeContent(final float contentWidth) {
    final float webViewWidth = getWidth();
    // 幅を基準にスケール計算
    float scale = webViewWidth / contentWidth;
    // initial−scale の設定
    final int scalePercent = (int)(scale * 100);
    setInitialScale(scalePercent);
}

追記

dp単位でコンテンツのスケーリングを行いたい場合は、DisplayMetrics の値を設定すればいい感じです。

// dp 単位にスケーリング
DisplayMetrics metrics = getResources().getDisplayMetrics();
// initial−scaleの設定
final int scalePercent = (int)(metrics.density * 100);
setInitialScale(scalePercent);

困ったポイント3. リンク URL のパラメータ名だけが消える

ぱっと見は正常になったので、リンクをタッチしてみる。

…正常に遷移しない orz
突き詰めたところ、リンク先への URL が想定通りのものではなかった。

リンクの形式として、正しくは、

http://www.example.com/~?a=~&b=hogehoge~

みたいな感じなんだけど、HTML の読み込み完了後に HTML 吐き出してみたら、

http://www.example.com/~?a=~hogehoge~

ってなってた…「&b=」はどこいった!?

これは、最初ロジックの問題だと思ってて、解決にやたらめったら時間がかかった。
Androidのバグだよ!ちくしょう!!

loadData(html, "text/html", "utf-8");

loadDataWithBaseURL(null, html, "text/html", "utf-8", null);

に直したら、起きなくなりました。

困ったポイント4. iframe コンテンツの shouldOverrideUrlLoading コールバックは呼ばれない

リンクがなんとかなったので、
ご所望されている「リンクをタッチしたらブラウザアプリを起動する」を実装開始。

ふーん、WebViewClient の shouldOverrideUrlLoading に処理を書けばいいのねと実装。
動作確認…なんか WebView 内で遷移するパターンがあるんですけど!?

そのパターンは、どうやら iframe 内のコンテンツのリンクをタッチした場合の模様。
調べた結果、Android の仕様によりアプリ外のコンテンツはハンドリングできないとか。
…まぁ、そりゃそうか。

じゃあってんで、解決策を調べ始める。
ところが、ところがですよ!満足できる方法が見つからなかった!!!

ヒットする方法が、「WebViewClient の onLoadResource で URL を判別しよう」しかない。

でも、ちょっと待ってほしい。
この onLoadResource ってのは、その名の通りリソースを読み込むタイミングで呼ばれるわけだ。
HTML だろうが、JavaScript だろうが、画像だろうがね。
だから、URL で判別して処理を分けないといけないんだけど、
このアプリのリンク先の URL に規則性なんてないのだよ、ワトソン君。

URL から判別できないなんてどうすればいいんや…と頭抱えたり、
似たようなことやってそうなライブラリをデコンパイルしたりした結果、
「新しいウィンドウを生成するタイミングでどうにかしよう!」に至った。
…共通点がそこしかなかったとも言うんだけども。

正直、かなりイケてないと思う。
素人にも玄人にもオススメできないから、万が一実行するなら自己責任ということで。

手順は、こんな感じ。

  1. WebSettings の setSupportMultipleWindows を有効にする
  2. WebChromeClient の onCreateWindow をオーバーライドし、
    その中で第二の WebView を生成してリンク先の読み込みを開始させる
  3. 第二の WebView の onPageStarted でURLを取得し、
    ブラウザアプリを起動&第二の WebView を破棄

コードは以下のような感じです。
※ WebView を拡張して書いてます。

private WebView mIframeWebView;

...

private void initialize() {
    // onCreateWindow が呼ばれるようにマルチウィンドウを許可する
    final WebSettings settings = getSettings();
    settings.setSupportMultipleWindows(true);

    setWebViewClient(new WebViewClient() {
        @Override
        public boolean shouldOverrideUrlLoading(WebView view, String url) {
            // iframe内のリンクでは発生しないイベントのため、onCreateWindowで全て処理する

            return super.shouldOverrideUrlLoading(view, url);
        }
    });
    setWebChromeClient(new WebChromeClient() {
        @Override
        public boolean onCreateWindow(WebView view, boolean isDialog, boolean isUserGesture,
                                      Message resultMsg) {
            // 第二の WebView を生成
            mIframeWebView = new WebView(getContext());
            mIframeWebView.setWebViewClient(new WebViewClient() {
                @Override
                public void onPageStarted(WebView view, String url, Bitmap favicon) {
                    // URL が取得できたので、ブラウザアプリを起動
                    final Uri uri = Uri.parse(url);
                    final Intent intent = new Intent(Intent.ACTION_VIEW, uri);
                    getContext().startActivity(intent);

                    // 用済みなので、第二の WebView は破棄する
                    mIframeWebView.stopLoading();
                    mIframeWebView.setWebViewClient(null);
                    mIframeWebView.setWebChromeClient(null);
                    mIframeWebView.destroy();
                    mIframeWebView = null;
            });

            // 生成した WebView にターゲットを渡す
            final WebView.WebViewTransport transport = (WebView.WebViewTransport) resultMsg.obj;
            transport.setWebView(mIframeWebView);
            resultMsg.sendToTarget();

            return true;
        }
    });
}

THE 力技☆
まさかの WebView 二刀流です、本当にすみませんでした。
釈然としないけど、想定通り動くようになった。
他に方法があったら、教えてほしいです、切に。

困ったポイント5. onPageStarted は2度呼ばれる

これは、この記事に向けてサンプルアプリ作った時に気づいた。

どういうわけか、onPageStarted が2度呼ばれるパターンがある…(^p^)

調べた結果、読み込みに失敗してエラーページを表示しようとした時にも呼ばれるようだ。
そりゃないぜ!

とはいえ、こいつの対応はけっこう簡単。
エラー発生時に onReceivedError が呼ばれるので、
エラーが発生した URL を保持し、onPageStarted 内で URL を比較、
失敗した URL だったらそこで終了するようにした。

mIframeWebView.setWebViewClient(new WebViewClient() {
    private String mFailingUrl;

    @Override
    public void onPageStarted(WebView view, String url, Bitmap favicon) {
        if (TextUtils.equals(url, mFailingUrl)) {
            // エラーページの読み込みなので、処理終了
            return;
        }

        // URL が取得できたので、ブラウザアプリを起動
        ...
    }

    @Override
    public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
        mFailingUrl = failingUrl;
        super.onReceivedError(view, errorCode, description, failingUrl);
    }
});

追記

ついさっき、onCreateWindow でも onReceivedError が呼ばれることが発覚…TT
エラーコードは −1(ERROR_UNKNOWN)、description は「ネットワークエラーが発生しました」
…電波繋がっとるわ!!(激おこ

ということで、原因不明すぎてすぐにはわからなそうなので、
応急処置的に onPageStarted の後に呼ばれた時だけ、失敗した URL を保持するようにしました。

mIframeWebView.setWebViewClient(new WebViewClient() {
    private String mFailingUrl;
    private boolean mIsPageStarted;

    @Override
    public void onPageStarted(WebView view, String url, Bitmap favicon) {
        // onPageStarted を処理したフラグをON
        mIsPageStarted = true;

        if (TextUtils.equals(url, mFailingUrl)) {
            // エラーページの読み込みなので、処理終了
            return;
        }

        // URL が取得できたので、ブラウザアプリを起動
        ...
    }

    @Override
    public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
        // onCreateWindow → onReceivedError → onPageStarted の順番で呼ばれると動かないので、
        // onPageStarted の後であった場合にのみエラーが起きた URL を保持する
        if (mIsPageStarted) {
            mFailingUrl = failingUrl;
        }

        super.onReceivedError(view, errorCode, description, failingUrl);
    }
});

再現性すごい低いんだけど、なんなんだろう?
機種依存とかかなぁ…。

おまけ

View のレンダリング後のサイズを知りたいときは、以下のようにすると取れるよ!

getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
    @Override
    public void onGlobalLayout() {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
            getViewTreeObserver().removeGlobalOnLayoutListener(this);
        } else {
            getViewTreeObserver().removeOnGlobalLayoutListener(this);
        }

        // ロード処理とかスケール処理とか...
    }
});

まとめ

  • WebView こわい(物理)
  • Android で iframe 使うのは、やめよう(迫真)
  • 需要があったら、サンプルプロジェクトを公開します

参考