Android WebView ハンズオン
2012年2月吉日
Egglang 江川 崇( EGAWA Takashi )
WebView hands-on
ここでは、WebViewの使い方について実際に動かしながら学びます。
WebViewは簡単に使えますし、純粋なWEB技術のみでマルチプラットフォーム対応アプリを作ることができるなど、便利で可能性を秘めています。その反面、奥が深く、またバージョン間の差異が激しいこともあり、使いこなすのは意外と大変です。そこで、今回はWebViewをただ使うだけではなく、なぜそうなっているのか?という理由についてもなるべく理解するようにします。
なお、WebViewを使っている代表的な例は標準ブラウザです。標準ブラウザのソースはとても参考になります。そこで、標準ブラウザのソースコードについても適宜確認していきたいと思います。
以下を順番にやっていこうと思います。
対象のソースコード : WebViewHandsOn_1
実装例 : WebViewHandsOn_1_done
やること:
初期状態についての説明:
ソースコード WebViewHandsOn_1 をEclipseに取り込み、実行すると以下のような画面が表示されます。
プロジェクトの初期状態の時点で、AndroidManifest.xmlには
<uses-permission android:name="android.permission.INTERNET" /> |
を記述済みです。さらに、テキスト入力エリアのエンターキーを補足し、WebViewにURLをロードさせる処理も記述済みです。
しかし、この状態では、まだ目的を達成できていません。
参考)ソースコードの取り込み
Eclipseのメニューから「File」->「New」->「Android Project」を選びます。
「Next」を押すとBuild Targetを選ぶページへ
最初の方のStepは、API Level 10(Android 2.3.3)以降ならどれを選んでも大丈夫ですが、Step.4 以降を実行するためは Android 3以降でなければ駄目です。悩みたくない場合は、4.0.3にしておいて下さい。
これでEclipseにソースコードが取り込めます。
手順:
手順1. 実行し、どのような動きをするのかを確認する。
WebViewにそのページが表示される訳ではなく、
アクション:android.intent.action.VIEW
カテゴリ:android.intent.category.BROWSABLE
のIntentが発行され、このIntentを処理できるアクティビティ(標準ブラウザなど)によって処理される。
手順2. なぜそうなるのかを理解する(読み飛ばしてもいいです)
この理由を知るのは結構難しいです。Android SDKのソースコードを読む必要があります。
frameworks/base/core/java/android/webkit/CallbackProxy.java
のuiOverrideUrlLoadingメソッド
/** * Called by the UI side. Calling overrideUrlLoading from the WebCore * side will post a message to call this method. */ public boolean uiOverrideUrlLoading(String overrideUrl) { if (overrideUrl == null || overrideUrl.length() == 0) { return false; } boolean override = false; if (mWebViewClient != null) { override = mWebViewClient.shouldOverrideUrlLoading(mWebView, overrideUrl); } else { Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(overrideUrl)); intent.addCategory(Intent.CATEGORY_BROWSABLE); // If another application is running a WebView and launches the // Browser through this Intent, we want to reuse the same window if // possible. intent.putExtra(Browser.EXTRA_APPLICATION_ID, mContext.getPackageName()); try { mContext.startActivity(intent); override = true; } catch (ActivityNotFoundException ex) { // If no application can handle the URL, assume that the // browser can handle it. } } return override; } |
CallbackProxyとはHandler。
class CallbackProxy extends Handler { |
AndroidアプリのUIスレッドとWebViewのCoreモジュールとの間をつなぐもの。
参考:
frameworks/base/core/java/android/webkit
external/webkit/WebCore
通常は、android/webkit の中だけを見ていれば充分です。
それでも、もしWebCoreのソースを見たい場合は、external/webkit/WebCoreのディレクトリ内に、VisualStudio用と、Xcode用のプロジェクトファイルがあるので、これを開くとソースコードを俯瞰できるので楽です。
Xcode用 :WebCore.xcodeproj
VisualStudio用 :WebCore.vcproj
手順3. 対策を施す
WebViewのインスタンスに、WebViewClientのインスタンスをセットする。(nullでなければよいだけなので、なんでもよい)
以下を追加
WebViewClient webViewClient = new WebViewClient() { }; mWebView.setWebViewClient(webViewClient); |
WebViewClientとは、WebViewのページの状態が変更されようとする際に、コールバックを受け取り、処理できるオブジェクトです。
例)
WebViewを使うときは、ほぼ全ての状況でWebViewClientをセットしなければならないと思っておいた方がよいでしょう。
【応用】手順4. WebViewClientを使ってみる
WebViewClientを使い、ページの読み込み中はタイトルバーにプログレス表示するようにします。
requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); setContentView(R.layout.main); setProgressBarIndeterminateVisibility(false); |
WebViewClient webViewClient = new WebViewClient() { @Override public void onPageStarted(WebView view, String url, Bitmap favicon) { super.onPageStarted(view, url, favicon); setProgressBarIndeterminateVisibility(true); } @Override public void onPageFinished(WebView view, String url) { super.onPageFinished(view, url); setProgressBarIndeterminateVisibility(false); } }; |
shouldOverrideUrlLoadingメソッドは、URLを直接入力した際には呼ばれませんが、ページ内のリンクをクリックしたときなどにはまず最初に呼ばれます。ついでにshouldOverrideUrlLoadingメソッドも実装し、ログを出力して呼び出される順番を確認しても面白いかもしれません。
なお、標準ブラウザでは、ページ内のリンクをクリックするたびに、
アクション:android.intent.action.VIEW
カテゴリ:android.intent.category.BROWSABLE
のIntentが発行されるようになっています。これはどのような実装になっているかと言うと、以下のようにshouldOverrideUrlLoadingメソッドの中でIntentを発行しています。
com.android.browser.BrowserActivity
boolean shouldOverrideUrlLoading(WebView view, String url) { ・・・ // sanitize the Intent, ensuring web pages can not bypass browser // security (only access to BROWSABLE activities). intent.addCategory(Intent.CATEGORY_BROWSABLE); ・・・ try { if (startActivityIfNeeded(intent, -1)) { return true; // true: 処理を上書。/ false: 上書せずにWebCoreへ委譲 } } catch (ActivityNotFoundException ex) { // ignore the error. If no application can handle the URL, // eg about:blank, assume the browser can handle it. } ・・・ } |
標準ブラウザのBrowserActivity自身もBROWSABLEのIntentを受け取れますので、当然自分が処理できるようにもなっています。
標準ブラウザのAndroidManifest.xml
<activity android:name="BrowserActivity" ・・・ android:launchMode="singleTask" ・・・ /> ・・・ <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="http" /> <data android:scheme="https" /> <data android:scheme="about" /> <data android:scheme="javascript" /> </intent-filter> ・・・ </activity> |
同じActivityが複数起動されることを防ぐために、singleTaskになっています。Intentの受け取り(URLのロード)はonNewIntentメソッドの中で行っています。
実装例 :WebViewHandsOn_2_done
やること:
初期状態についての説明:
Googleの検索ページはWebViewで動かすのが意外と難しいです。まず、JavaScriptを使っているので、JavaScriptを有効にしなければ利用できません。
また、エミュレーターではあまり関係がありませんが、4.0未満の実機では検索キーワードを入力しようとしてもソフトキーボードが表示されないことがあります。(毎回必ず、ではありません。)
手順:
手順1. JavaScriptを有効にする。
WebSettings webSettings = mWebView.getSettings(); webSettings.setJavaScriptEnabled(true); |
WebViewに対する設定は、WebSettingsクラスのインスタンスに対して行います。
その他の例)
settings.setPluginState(PluginState.ON); settings.setUseWideViewPort(true); settings.setLoadWithOverviewMode(true); settings.setSupportZoom(true); settings.setBuiltInZoomControls(true); |
など。(時間があれば試してみて下さい)
手順2. 明示的にフォーカスを与える
エミュレータやハードキーボードでの入力であれば気付きませんが、なぜかWEBページ内のテキスト入力エリアをタップしてもソフトキーボードが出ないことがあるので、明示的にフォーカスを与えます。
参考) http://code.google.com/p/android/issues/detail?id=7189
mWebView.setOnTouchListener(new View.OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_UP: if (!v.hasFocus()) { v.requestFocus(); } break; } return false; } }); |
手順3. 初期フォーカスを外す
ただし、手順2を施したことで、次の図の右上のように、当たって欲しくないところにフォーカスの領域が表示されてしまうことがあります。
これは少々見苦しいので、初期フォーカスを外します。
WebSettings webSettings = mWebView.getSettings(); webSettings.setJavaScriptEnabled(true); webSettings.setNeedInitialFocus(false); |
実装例 : WebViewHandsOn_3_done
やること:
初期状態についての説明:
HTML5のvideoタグは、ページ内に動画を簡単に埋め込むことができるとても便利なものですが、今の状態ではvideoタグで記述された動画をWebViewで再生することができません。
手順:
手順1. 動画再生可能なWebサイト( http://m.youtube.com/ など)へ行き、再生できないことを確認する。
確かに今のままでは出来ないのだな、ということを確認します。
手順2. res/layout/main.xml を変更し WebView をFrameLayoutの中に入れる。
res/layout/main.xml のコメントのように、WebViewをFrameLayoutの中に入れます。layout_heightやlayout_widthなども、レイアウトの構造に合わせて変更します。
<FrameLayout android:id="@+id/frame" android:layout_width="fill_parent" android:layout_height="0dp" android:layout_weight="1" > <WebView android:id="@+id/webview" android:layout_width="fill_parent" android:layout_height="fill_parent" android:fadeScrollbars="true" android:scrollbarStyle="outsideOverlay" /> </FrameLayout> |
videoタグの再生は、WebViewそのものが行うわけではなく、他のViewが行います。それらのViewをWebViewの上に重ね合わせることが出来るよう、FrameLayoutの中に入れておくことにします。もちろん、FrameLayoutの中に入れなければならないという訳ではなく、画面上のどの位置に配置しても問題はありません。
手順3. 動画再生できるようにする
WebChromeClientというクラスを使います。WebChromeClientは、先ほど用いたWebViewClientよりもさらに詳細なハンドリングができることに加え、WebCoreとアクセスするいくつかの手段が設けられています。WebViewClientと併せて、WebChromeClientのインスタンスもセットしておくケースが多いのではないかと思います。
WebChromeClient webChromeClient = new WebChromeClient() { @Override public void onShowCustomView(View view, CustomViewCallback callback) { super.onShowCustomView(view, callback); ViewGroup frame = (ViewGroup) findViewById(R.id.frame); frame.addView(view); } }; mWebView.setWebChromeClient(webChromeClient); |
videoタグなど、特定のオブジェクトが選択された場合、WebCore内で適切なViewを生成し、WebChromeClientのonShowCustomViewを呼び出してくれます。
なお、動画関連の他のハンドラとしては、以下のようなものが代表的です。
HTML5のvideoタグのposter属性が無かった場合に表示する画像を定義できる
動画を読み込むまでの間に表示するViewを定義できる
補足:
ここまでの状態で動画の再生は可能です。しかし実は問題があり、一旦再生されたViewが消えることはありません。このハンズオンでは実施しませんが、もしご興味があれば、適切なタイミングでViewを消すことにも是非チャレンジしてみて下さい。
実装例 :WebViewHandsOn_4_done
やること:
手順:
手順1. 検索用ボタンを配置する
res/layout/main.xml のコメントを外し、少しダサいですが、キーワード検索用のボタンを画面下部に配置します。
手順2. クリック時にキーワード検索ボックスを表示する(Android 3以降のみ)
public void find(View v) { mWebView.showFindDialog("", true); } |
この方法で、以下のような検索ボックスが出ます。すごく楽なのですが、Android 3以降でしか動作しません。
手順3. Android 2系でも動くようにする
まず、バージョンで場合分けし、2.x系の場合は非公開メソッド(setFindIsUp)を使う必要があります。setFindIsUpは、ICSなどでは既に存在しないメソッドになってしまっているので、ICSで呼び出すとエラーになります。
WebView周りには便利な非公開メソッドがたくさんあり、ついついリフレクションやプロキシで呼んでしまいたくなりますが、非公開メソッドを使う場合は、各バージョンで動くかどうかを常にウォッチしなければならなくなる為、注意が必要です。
ちなみに、2.3.3のWebView.java に含まれる非公開メソッド( @hide )の数は、42個もあります。
hideは禁断の果実。食べると甘い蜜の味がしますが、一生その十字架を背負うことになります。
public void find(View v) { if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { mWebView.showFindDialog("", true); } else { LayoutInflater factory = LayoutInflater.from(this); final View keywordView = factory.inflate(R.layout.keyword, null); final AlertDialog dialog = new AlertDialog.Builder(this) .setView(keywordView) .setIcon(android.R.drawable.ic_dialog_info) .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int whichButton) { String keyword = ((EditText) keywordView.findViewById(R.id.keyword)).getText().toString(); callFindIsUp(); mWebView.findAll(keyword); }}) .create(); dialog.show(); } } private void callFindIsUp() { try { final Method method = mWebView.getClass().getMethod("setFindIsUp", new Class[] { boolean.class }); method.invoke(mWebView, new Object[] {true}); } catch (SecurityException e) { } catch (NoSuchMethodException e) { } catch (IllegalArgumentException e) { } catch (IllegalAccessException e) { } catch (InvocationTargetException e) { } } |
対象のプロジェクト :WebViewHandsOn_5
( 4の完了状態からEditTextを消しています)
実装例 :WebViewHandsOn_5_done
やること:
初期状態についての説明:
標準ブラウザのURL入力エリアは、アプリ内のViewを使っている訳ではなく、SearchBoxを使っていることにお気づきでしょうか?
標準ブラウザでは、SearchBoxをブラウザのContentProviderと連結することで、ブックマーク、履歴、サジェストなどと連動した入力エリアを実現しています。ここでは標準ブラウザに倣い、このアプリケーションでもSearchBoxを使うことにします。
WebViewHandsOn_5は、WebViewHandsOn_4の完了状態から、URL入力用のEditTextをres/layout/main.xmlから消し、Activityの中の関連の処理も削除したものです。
手順:
手順1. Search Boxを定義する
<string name="search_label">WebViewHandsOn</string> <string name="search_hint">検索またはURLを入力</string> <string name="search_button_text">実行</string> |
Search Boxは、文字列直書きでは動作しないので、文字列をリソースとして定義します。
<?xml version="1.0" encoding="utf-8"?> <searchable xmlns:android="http://schemas.android.com/apk/res/android" android:icon="@drawable/ic_launcher" android:label="@string/search_label" android:hint="@string/search_hint" android:searchButtonText="@string/search_button_text" android:searchMode="queryRewriteFromData" android:voiceSearchMode="launchWebSearch" android:inputType="textUri" android:imeOptions="actionGo" android:searchSuggestAuthority="browser" android:searchSuggestSelection="url LIKE ?" android:searchSuggestIntentAction="android.intent.action.VIEW" /> |
標準ブラウザがもつContentProviderのスキーマは ”browser”です。このContentProviderと接続するようにします。
AndroidManifest.xmlに以下の2つのパーミッションを追加します。
<uses-permission android:name="com.android.browser.permission.READ_HISTORY_BOOKMARKS"/> <uses-permission android:name="com.android.browser.permission.WRITE_HISTORY_BOOKMARKS"/> |
【参考】
標準ブラウザのContentProviderの実体は、
Browser/src/com/android/browser/BrowserProvider.java
です。
面白いことに、BrowserProviderでは、bookmarks という名称のテーブルで、本来のブックマークと、アクセス履歴(アクセス回数なども含む)の双方を管理しています。また、ContentProviderから独自に定義したCursorオブジェクトを返す例としてもわかりやすく、勉強材料としてお勧めです。
ただし、どうもICSでは、BrowserProvider2というクラスが動いている形跡があり、内部の形式が変更になっている可能性があるため、この理解が当てはまらない箇所があるかもしれません。
なお、実際のアプリケーションからBrowserProviderに直接アクセスするケースは殆どなく、Android SDKの android.provider.Browserというクラスを介してアクセスする方法が一般的です。このクラスにはgetAllBookmarksやgetAllVisitedUrlsといった取得系メソッド、updateVisitedHistoryやsaveBookmarkといった登録系メソッドがstaticメソッドとして定義されており、手軽に利用することが出来ます。
手順2. ActivityがSearch Boxからの入力を受け取れるようにする
AndroidManifest.xmlのActivity定義部を拡張します。
<activity android:label="@string/app_name" android:name=".WebViewHandsOnActivity" android:launchMode="singleTop"> ・・・ <intent-filter> <action android:name="android.intent.action.SEARCH" /> <category android:name="android.intent.category.DEFAULT" /> </intent-filter> <meta-data android:name="android.app.searchable" android:resource="@xml/searchable" /> </activity> |
入力するたびにActivityが開くと見苦しいのでlaunchModeを変更します。
android.intent.action.SEARCHに反応する為に、Intent Filterを追加します。
手順3. Search Boxからの入力に基づき実行する
ActivityにonNewIntentメソッドを記述します。
@Override protected void onNewIntent(Intent intent) { String action = intent.getAction(); String url = null; if (Intent.ACTION_VIEW.equals(action)) { url = intent.getData().toString(); } else if (Intent.ACTION_SEARCH.equals(action)) { String keyword = intent.getStringExtra(SearchManager.QUERY); if (Patterns.WEB_URL.matcher(keyword).matches()) { url = URLUtil.guessUrl(keyword); } else { url = URLUtil.composeSearchUrl(keyword, "http://www.google.com/m?q=%s", "%s"); } } if (!TextUtils.isEmpty(url)) { mWebView.loadUrl(url); } } |
URLが直接渡されるケースと、サジェストのキーワードが渡されるケースがあるので、IntentのActionやキーワードの内容(Patterns.WEB_URL にマッチしているか)で分岐します。キーワード検索であると判断した場合は、URLエンコードした上でGoogleの検索ページを開くようにします。
ここまで実装してアプリを実行し、検索キーを押すと、URL入力エリアの代わりとして利用できるSearch Boxが表示されます。入力エリアの下にブックマークなどが候補として表示されます。
何か文字を入力すると、その文字から始まる候補が表示されます。
検索キーが無いデバイスで確認したい方は、何らかの操作に対してstartSearchメソッドを割り当てて確認して下さい。以下は、戻るキーのロングタップに割り当てている例です。ただし、実際のアプリでは、このようなことはしないでしょう。
@Override public boolean onKeyLongPress(int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_BACK) { startSearch(null, false, null, false); return true; } return super.onKeyLongPress(keyCode, event); } |
時間が余って仕方ない人に向けて、個人的にここを見ておくと面白いかも、という箇所を書いておきます。標準ブラウザのソースコードを眺める参考にして下さい。
ブラウザとしては、5までの状態では不十分なところが多く、実用には耐えません。 BrowserActivity の onNewIntent や handleWebSearchIntent などを見てもらえれば、結構泥臭い処理を頑張ってやっていることがわかりますので一度御覧下さい。
HTTP認証がかけられているページにアクセスしたときに、今の状態ではユーザーIDやパスワードを入力できません。入力できるようにするにはどうすればよいでしょうか?
HTTPSのページを見たときに、証明書が不正だったことを知るにはどうすればよいでしょうか?
標準ブラウザの処理の至る所で、rlzパラメーターに関係する処理が数多く入っています。rlzをご存知でしょうか。
Java とJavaScriptを連携する方法をご存知ですか?ご存知無い方は一度試してみて下さい。
参考)WebView#addJavascriptInterface
Froyo(2.2)からSDKの機能にDownloadManagerが追加されましたが、元々は2.1までの標準ブラウザに含まれていたもの(com.android.mms.util.DownloadManager)で、実はかなり歴史があるクラスです。GingerBread(2.3.3)のcom.android.providers.downloads.DownloadProviderのonUpgradeメソッドなどを見ると、歴史の長さがよくわかります。DB_VERSIONが106ですが、106回アップデートした訳ではなく、31から100まで一気にバージョンを上げているタイミングがあります。
このように、ContentProviderのonUpgradeをみることは、そのアプリの歴史や発展の経緯を知る良い方法です。例えば、BrowserProviderでは、Version 23までは、Google Gears関係のテーブルがありましたが、23以降では削除されています。面白いですね。
WebDriverでテストができるようです。もしご興味があれば御覧下さい。
http://android-developers.blogspot.com/2011/10/introducing-android-webdriver.html
[EOF]