暗黙のIntentを投げてみた

AndroidフレームワークにはAndroidを強く特徴付けるIntentという仕組みがある。
IntentはActivityやServiceといった実行単位のコンポーネントを起動する場合に利用される。
プログラマはstartActivityやstartServiceといった関数を用いてActivityとServiceの起動を命ずるが、このとき引数にIntentを使うことにより実行対象を指定する。


Intentが優れているのはこの指定方法にある。
Intentは明示的にクラス名を指定することにより明らかに実行されるコンポーネントを指定することが可能だ。
しかし、より優れたIntentの利用方法は暗黙的なIntentの利用である。
暗黙的なIntentを用いることにより、直接実行コンポーネントを指定する必要がなくなる。
これによりコンポーネント間の結合はより緩やかな疎結合となる。
呼出元のコンポーネントを一切変更することなく、呼出先のコンポーネントを変更することが可能となる。


先日、SDK1.1r1がリリースされたとき、ドキュメントには大きな更新が施され、開発者に対する情報量が飛躍的に増えた。
Intentに関する記述も非常に詳細な情報が得られるようになった。

次のオフィシャルドキュメントに必要な情報が記述された。
Intents and Intent Filters  |  Android Developers

またIntentクラスのドキュメントにも詳細な説明がある。
Intent  |  Android Developers

上のドキュメントはSDKをダウンロードすればANDROID_HOMEのdocsの下に存在する。

今回は暗黙的なIntentの利用方法について記述する。

暗黙的IntentとIntentFilterの関係

Intentには明示的なものと暗黙的なものがあることは先に触れた。
明示的なものは非常にわかりやすい。起動先のクラス名を指定すれば良いのであるからGOTO文に等しい。


暗黙的なIntentはこれに比べるとわかりにくい。
暗黙的なIntentにはいくらかの引数を指定する。しかしこれはどれも明示的には実行コンポーネントを指定しない。
それではAndroidはどうやって実行コンポーネントを決定するのだろうか。


実はAndroidではActivityを構築する時、manifestファイルにて必ずIntentFilterを記述する。
このIntentFilterがそのActivityにて使用可能な暗黙的Intentを決定している。


IntentFilterとは、システムがIntentを受けとったときどのActivityを実行するか決定するためにシステムにて用いられるフィルターだ。IntentFilterにはそのActivityがどのような条件のIntentを受け入れられるかが記述される。


しかし通常、Activityを作成するとそのmanifestにはデフォルトのIntentFilterが記述される。
また明示的なIntentの利用ではIntentFilterを記述する必要がなかった。
開発者はIntentFilterに気を取られなくてもアプリの開発は行えていた。


システムは全てのAcitivityからそのIntentに対応するActivityを、個別のActivityがそれぞれ持つIntentFilterを用いて探す。
従って暗黙のIntentを利用する場合にはまずIntentFilterを記述することが必須となる。


例えばデフォルトのIntentFilterの記述は以下のようになる。

        <activity android:name=".Memezo"
                  android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

IntentFilterの詳細

暗黙的なIntentで重要な項目は次の4つだ。

  • ACTION
  • CATEGORY
  • DATA
  • TYPE

Intentを投げる時、この4つの項目のうち、少くともACTIONとDATAの2つを指定する。
ACTIONは実行する内容を示す。例えばVIEWやEDITだ。
DATAはURIを指定する。例えばhttp://www.google.com, file:///sdcard/といったものだ。
DATAに対し、ACTIONを行いたいという意図をIntentに設定し、システムに委ねる訳だ。


CATEGORYはIntentをカテゴライズする。通常DEFAULTを用いるが、複数指定することも可能だ。
これはActionとDATAにて使用するActivityを絞りこむ時に、付加情報として与えることによりより細かな絞り込みを可能とする。
詳細は後で説明する。
TYPEはDATAを補完する。これはWebなどでも用いられるMIMEタイプである。httpやfileといったURIの先頭をスキームと呼び、URIの種類を示すがこれだけではDATAの型が決定しない場合がある。その場合にTYPEにて型の指定を補完する。


ACTIONもCATEGORYも文字列である。
Intentクラスに定数としていくつか標準が用意されているので通常はその定数の値を用いる。
システムがIntentクラスにて用意したACTIONやCATEGORYは全て上記のデフォルト値のようにパッケージとクラス名の組み合わせのような文字列になっている。*1


デフォルトのIntentFilterの意味するところはランチャー向けのアプリケーションだということだ。
CATEGORYのLAUNCHERはシステムのLAUNCHERに登録できることを示す。
ACTIONのMAINはそのActivityがアプリケーションであり、引数もなく単独で最初に実行されるActivityだということを示す。

amコマンドにてIntentの動作を学ぶ


以前に解説したがAndroidには標準コマンドにamがあり、Intentをコマンドラインから投げることができる。
AndroidのIntentをコマンドから投げる - minghaiの日記


amコマンドを用いるとIntentの動作が非常に分かりやすいのでそちらを用いて暗黙的Intentを説明しよう。


まず、実験のためサンプルのNotePadをインストールする。
これはSDKのsamplesフォルダの中にある。
Androidの公式チュートリアルにて作成されるNotepadの完成形だ。


NotepadのインストールにはEclipseにて新しいプロジェクトを作成し、既存ソースのインポートからNotepadのフォルダを選択する。プロジェクトの作成が済んだら実行を選択し、エミュレータを起動する。
エミュレータのランチャーにNotepadが登録されていることをホーム画面にて確認すること。


次にエミュレータ上のLinuxに接続する。
unix系の人はターミナルを、Windowsの人はコマンドプロンプトを立ち上げる。
PATHにANDROID_HOME/toolsを追加する。ANDORID_HOMEはandroid SDKをインストールした場所であり、自動では
用意されないので適宜置き換えること。

unix系では

PATH="$PATH":$ANDROID_HOME/tools

windows系では

path %path%;$ANDROID_HOME/tools

次にadbを実行し、androidに接続する。

$ adb shell
#

#はrootにてログインしたことを示しているコマンドプロンプトだ。


ここでamだけ入力するとamコマンドの使い方が表示される。

# am
usage: am [start|broadcast|instrument]
       am start -D INTENT
       am broadcast INTENT
       am instrument [-r] [-e <ARG_NAME> <ARG_VALUE>] [-p <PROF_FILE>]
                [-w] <COMPONENT> 

       INTENT is described with:
                [-a <ACTION>] [-d <DATA_URI>] [-t <MIME_TYPE>]
                [-c <CATEGORY> [-c <CATEGORY>] ...]
                [-e|--es <EXTRA_KEY> <EXTRA_STRING_VALUE> ...]
                [--ez <EXTRA_KEY> <EXTRA_BOOLEAN_VALUE> ...]
                [-e|--ei <EXTRA_KEY> <EXTRA_INT_VALUE> ...]
                [-n <COMPONENT>] [-f <FLAGS>] [<URI>]
# 

3つの使い方があることがわかるが、ここでは最初のstartしか使わない。
-Dが必須のように見えるが-Dはオプションであり必要ないので使わない。デバッグ用のようだ。


am startではINTENTを引数に指定するとそのIntentを投げる。
試しに明示的Intentを投げてみよう。
エミュレータの画面をホーム画面が表示されている状態にする。ホームボタンを押すこと。
次に明示的IntentにてNotepadを実行してみよう。
まずNotepadのAndroidManifest.xmlを確認して実行クラスを探そう。

        <activity android:name="NotesList" android:label="@string/title_notes_list">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

この内容から実行Activitiyのクラス名はNotesListなのが確認できる。
Eclipseのソースからパッケージ名はcom.example.android.notepadなのがわかる。
従って明示的なIntentは以下のように指定する。

# am start -n com.example.android.notepad/.NotesList
Starting: Intent { comp={com.example.android.notepad/com.example.android.notepad.NotesList} }
# 

うまくいけばエミュレータの上ではNotepadが実行されているはずだ。

暗黙のIntentの実行

まずはコンポーネントを直接指定する明示的Intentを実行した。
次に暗黙的Intentを実行してみよう。


再びNotepadのAndroidManifest.xmlを確認する。


NotepadにはNotesList、NoteEditor、TitleEditorの3つのActivityがあることがXMLのタグからすぐにわかるだろう。

メインである最初に実行されるNotesListにはonCreateに以下の記述がある。

        // If no data was given in the intent (because we were started
        // as a MAIN activity), then use our default content provider.
        Intent intent = getIntent();
        if (intent.getData() == null) {
            intent.setData(Notes.CONTENT_URI);
        }

Eclipseにてsearchを利用してCONTENT_URIを探せば直ぐに次の設定を見つけることができる。

    public static final String AUTHORITY = "com.google.provider.NotePad";

        /**
         * The content:// style URL for this table
         */
        public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/notes");


つまりNotepadにてデフォルトにて利用されるURIはcom.google.provider.NotePad/notesになる。


さて、NotesListにて新しいテキストを挿入する部分は以下のとおりである。

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
        case MENU_ITEM_INSERT:
            // Launch activity to insert a new item
            startActivity(new Intent(Intent.ACTION_INSERT, getIntent().getData()));
            return true;
        }
        return super.onOptionsItemSelected(item);
    }


従って新しいテキストを挿入するときに、IntentはIntent.ACTION_INSERTをACTIONに、CONTENT_URIをDATAにして投げれば良い。
amコマンドは型の指定が必須のようであるのでmanifestファイルからタイプがvnd.android.cursor.dir/vnd.google.noteであることを追加する。
エミュレータの画面がホームであることを確認し、以下のamコマンドを投げると新規のテキストを挿入することになる。

# am start -a android.intent.action.INSERT -d content://com.google.provider.NotePad/notes -t vnd.android.cursor.dir/vnd.google.note
Starting: Intent { action=android.intent.action.INSERT data=content://com.google.provider.NotePad/notes type=vnd.android.cursor.dir/vnd.google.note }
# 

正しく上記のコマンドが入力されれば新しいテキストの入力画面となっているはずだ。
ここまでわかればタイトル編集のActivityを実行するのも簡単なはずだ。

暗黙的Intentの設計

ここまで暗黙的Intentの利用法を学んできた。
NotepadではContentProviderをテキストの保存に利用していたため暗黙的Intentの利用が比較的優しかったと思う。
しかし我々が個人でAndroidのアプリケーションを作成するとき、暗黙的Intentを活用するために何を考慮するべきだろうか?


まずActivityを設計するとき、できるだけ画面遷移に注目するべきだろう。
画面が変化するとき、それはActivityが変化するときと取らえることが可能だ。
実際、ActivityはDATAに対し、ACTIONを行うというRESTfulな考え方に基いた設計を期待している。

RESTful Webサービス

RESTful Webサービス

RESTではWebサーバーに対する個々の通信はステートレスである代わりに、ハイパーテキスト上のハイパーリンクを用いて画面を遷移していくクライアントがステートを持つオブジェクトであると定義する。
Androidのアプリケーションもまた個々のActivityの間をIntentを用いて遷移することがクライアントの状態遷移を司るにすぎない。
従って画面更新を多く行う巨大なActivityを設計するよりも複数のActitivityを暗黙的Intentを用いることにより状態遷移するアプリケーションを設計することがAndroidらしいアプリケーションを作成することに繋がる。

暗黙的Intentの一例

暗黙的Intentを活用したアプリの一例としてファイルエクスプローラを実装してみよう。
ファイルエクスプローラとはWindowsの標準アプリであり、ディレクトリツリーを自由に探索することが可能なアプリだ。
WindowsWindowsキー+Eを押すと表示される。
ファイルエクスプローラはカレントディレクトリというステートを持つ。
カレントディレクトリの中身をウィンドウに表示し、ユーザーがファイルをダブルクリックすればそのファイルを開き、ディレクトリ(フォルダ)をダブルクリックすればそのディレクトリの中身を表示する。


Androidにてファイルエクスプローラを実装してみよう。
ファイルエクスプローラはカレントディレクトリというステートを持つ。
これを設計するにはファイルエクスプローラというActivityを実行するにはfile://で始まるスキームを持つURIをデータとして、ACTIONはVIEWを用いるActivityとして設計しよう。
ファイルエクスプローラURIにはディレクトリしか指定できないように、タイプにてさらにtext/directoryを指定するようにしよう。


ファイルエクスプローラのIntentFilterは以下のようになる。

    <application android:icon="@drawable/clanbomber_48x48" android:label="@string/app_name">
        <activity android:name=".Finder"
                  android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <intent-filter>
            	<action android:name="android.intent.action.VIEW" />
            	<category android:name="android.intent.category.DEFAULT" />
            	<data android:scheme="file" android:mimeType="text/directory" />
            </intent-filter>
        </activity>
    </application>

ファイルエクスプローラ自体はメインのアクティビティとなるため、ACTIONがMAINであり、かつCATEGORYがLAUNCHERであるIntentにも対応している。
このため、ファイルエクスプローラのonCreateではIntentがnullである場合に供えて以下のコードを記述している。

        // If no data was given in the intent (because we were started
        // as a MAIN activity), then use our default directory.
        Intent intent = getIntent();
        if (intent.getData() == null) {
            uri = Uri.parse("file://" + "/");
        } else {
            uri = intent.getData();
        }

ファイルエクスプローラはただのListActivityである。
ここでファイルエクスプローラの名前をより短くて親しみあるFinderとしよう。
FinderはListActivityであり、カレントディレクトリの中身を羅列する。
ユーザーがリスト表示されたカレントディレクトリのある項目をクリックすると以下のonListItemClickが呼び出される。
Finderは選択された項目の型が何であるかをファイルの拡張子から判断し、暗黙Intentを投げる。

protected void onListItemClick(ListView l, View v, int position, long id) {
    	File file = files[position];

    	if (file.isDirectory()) {
    		Intent intent = new Intent(Intent.ACTION_VIEW);
    		intent.setDataAndType(Uri.parse("file://" + file.getPath()), "text/directory");
    		Log.d("TEST", "intent: " + intent);
    		startActivity(intent);
    	} else {
    		String type = ea.getType(position);
    		if (type != null) {
    			Intent intent = new Intent(Intent.ACTION_VIEW);
    			intent.setDataAndType(Uri.parse("file://" + file.getPath()), type);
    			startActivity(intent);
    		} else {
    			// If it gets to here, it must be unknown file type.
    			Toast.makeText(getApplication(), "Unknown file type",
    					Toast.LENGTH_LONG).show();
    		}
    	}
}


まずFinderは選択項目がディレクトリであるか確認する。
ディレクトリであれば暗黙Intentに新しいディレクトリのfile://のURIとTYPEにtext/directoryを指定し、ACTIONをVIEWにて暗黙的Intentを投げる。これによりシステムにてFinder自身が選択される訳だ。


選択項目がディレクトリでない場合は選択項目の型を判定する。
選択項目がFinderの知る型である場合にはTYPEをその型に設定した新しい暗黙的Intentを投げる。
そうでない場合には未知の型であることを表示して実行を諦める。


つまりユーザーが何かしらの選択をしたとき、その選択項目に対する暗黙的Intentを投げるわけだ。
ここではTYPEの指定を既知のファイル型のみに対し行っている。
しかしURIのみにて型の指定が判別できるのであれば実行されるActivityの選択を全てシステムに委ねてしまっても問題はない。


Finderの状態遷移を図示すると次のようになる。

Finderのソースは以下にて公開されている。
日本Androidの会

IntentFilterの衝突

個々のActivityにて指定されたIntentFilterが同じ内容になったとき、システムはIntentを処理するActivityを決定することができない。
このときシステムはIntentを処理可能なActivityの一覧を表示し、ユーザーに選択を促す。
ユーザーは実行するActivityを選択するだけでなく、そのIntentに対するデフォルトのActivityを指定することが可能となる。デフォルトのActivityを指定すると以後Activityの選択画面は表示されなくなる。
デフォルトとして選択されたActivityを解除すにはホーム画面からSettingsを起動する。
Settingsを起動してApplicationを選択し、さらにManage Applicationsを選択するとインストールされているアプリケーションの一覧が表示される。
ここでデフォルトとして選択したアプリケーションを選択し、Launch By Defaultの項目のclear defaultsボタンを押すことによりデフォルト動作としてのActivity実行を解除することが可能になる。

メニュー項目への暗黙的Intentによる外部Activityの追加

暗黙的Intentの活用方法としてActivityの実行時以外にも強力な活用法が存在する。
Androidではmenu項目の作成時に実行中のActivityが存在を知らないActivityを呼びだすmenu項目を暗黙的Intentを用いることにより追加することが可能となる。
これを用いることにより、自作のActivityがmenuの項目追加時にどのような暗黙的Intentを用いるかを公表することにより、Activityのメニュー項目に第三者のAcitivityを起動する項目を追加表示することが可能となる。


例として、サンプルのNotepadのメインアクティビティであるNotesListには以下の記述がある。

    public boolean onPrepareOptionsMenu(Menu menu) {
        super.onPrepareOptionsMenu(menu);
        final boolean haveItems = getListAdapter().getCount() > 0;

        // If there are any notes in the list (which implies that one of
        // them is selected), then we need to generate the actions that
        // can be performed on the current selection.  This will be a combination
        // of our own specific actions along with any extensions that can be
        // found.
        if (haveItems) {
            // This is the selected item.
            Uri uri = ContentUris.withAppendedId(getIntent().getData(), getSelectedItemId());

            // Build menu...  always starts with the EDIT action...
            Intent[] specifics = new Intent[1];
            specifics[0] = new Intent(Intent.ACTION_EDIT, uri);
            MenuItem[] items = new MenuItem[1];

            // ... is followed by whatever other actions are available...
            Intent intent = new Intent(null, uri);
            intent.addCategory(Intent.CATEGORY_ALTERNATIVE);
            menu.addIntentOptions(Menu.CATEGORY_ALTERNATIVE, 0, 0, null, specifics, intent, 0,
                    items);

            // Give a shortcut to the edit action.
            if (items[0] != null) {
                items[0].setShortcut('1', 'e');
            }
        } else {
            menu.removeGroup(Menu.CATEGORY_ALTERNATIVE);
        }

        return true;
    }

ソースからわかるとおり、メニュー項目の作成時にAndroidは暗黙的IntentをACTIONにEDIT、URIにnoteのContentProviderに選択項目の番号を追加したURI、さらにCATEGORYにIntent.CATEGORY_ALTERNATIVEを指定した暗黙的Intentを投げることにより未知のメニュー項目が表示されることを可能にしている。


NotepadのAndroidManifest.xmlを参照すると以下のIntentFilterがこれに該当することがわかる。

	<activity android:name="TitleEditor" android:label="@string/title_edit_title" 
				android:theme="@android:style/Theme.Dialog">
            <!-- This activity implements an alternative action that can be
                 performed on notes: editing their title.  It can be used as
                 a default operation if the user invokes this action, and is
                 available as an alternative action for any note data. -->
            <intent-filter android:label="@string/resolve_title" android:icon="@drawable/app_notes">
                <!-- This is the action we perform.  It is a custom action we
                     define for our application, not a generic VIEW or EDIT
                     action since we are not a general note viewer/editor. -->
                <action android:name="com.android.notepad.action.EDIT_TITLE" />
                <!-- DEFAULT: execute if being directly invoked. -->
                <category android:name="android.intent.category.DEFAULT" />
                <!-- ALTERNATIVE: show as an alternative action when the user is
                     working with this type of data. -->
                <category android:name="android.intent.category.ALTERNATIVE" />
                <!-- SELECTED_ALTERNATIVE: show as an alternative action the user
                     can perform when selecting this type of data. -->
                <category android:name="android.intent.category.SELECTED_ALTERNATIVE" />
                <!-- This is the data type we operate on. -->
                <data android:mimeType="vnd.android.cursor.item/vnd.google.note" />
            </intent-filter>
        </activity>

IntentFilterのCATEGORYよりこのActivity、TitleEditがNotesListのActivity実行時において、項目が選択されているときにメニューボタンを押すと表示されることがわかる。
メニューに表示されるメニュー項目の内容はIntentFilterのandroid:label属性の値になる。
ここではrscのstring/resolve_titleが指定されているため、res/values/strings.xmlの次の内容が表示される。

   <string name="resolve_title">Edit title</string>  

もちろんこのメニュー項目が選択された場合、noteのタイトルを編集することとなる。
またandroid:labelの中身を好きな文字列に変更することでメニュー項目に好きな文字列を表示できるので試してほしい。
あるActivityの表示するメニューの項目に、全く別のActivityのリソース上の文字列がシステムにより表示されるわけである。


メニューに表示するIntentFilterの内容が衝突した場合、そのメニュー項目はAndroid Systemなる表示がなされる。
ユーザーがそのメニューを選択するとシステムによるActivity選択のダイアログが表示される。
しかしこの表示は何を意味するのか非常にわかりにくい。ぜひとも改善してほしい部分である。


まとめ

AndroidではActivityの実行にIntentを指定する。
Intentでは明示的に実行されるクラス名を指定することも可能であるが、暗黙的にACTIONとDATAの組であるIntentを指定することが可能だ。
暗黙的Intentを用いれば呼出元のActivityに変更を行うことなく呼出先のActivityを変更することが可能になる。
また暗黙的なIntentにて起動されるActivityはAndroidのシステム上にて自由に用いることが可能なライブラリとなる。


暗黙的なIntentを利用するにはAcitivityのAndroidManifest.xmlにIntentFilterを記述する。
IntentFilterには最低限としてACTIONが必要だ。処理対象としてDATAを指定するのが普通であり、DATAでは確定できない型の指定にTYPEを指定する。さらにIntentの衝突を防ぐために実行時のコンテキストを想定したCATEGORYを設定するのが望ましい。


暗黙的なIntentを用いることこそがAndroidらしいアプリを作成する道である。
Activityの粒度をできるだけ細かくし、Acitivityの再利用を促すべきだ。
自作のActiivityを公開するとき、そのActivityを起動する暗黙的Intentを公開することにより、より広く自作アプリが用いられることになるだろう。
常に自作アプリが自作のコンポーネントのみにて成立するのでないことを理解しよう。
部分的処理は他人のコンポーネントに処理できるようにしよう。ユーザーの選択を許すことが可用性を向上する。


Activityを設計するとき、そのActivityを利用する暗黙的Intentを設計しよう。
そしてメニューからActivityを実行するときも暗黙的Intentを用いて将来他のActivityが処理候補として表示されるように実装しよう。
暗黙的Intentを活用することにより個別のActivityは再利用の機会をより多く得ることができ、さらに他の見知らぬActivityと連携して利用されることを可能とするだろう。

*1:XMLJavaでは定数値と定数名という別の次元の文字列を用いるので注意が必要だ。これは直感で変更できるように名前付けのルールに従って定義されている。