【v7 appcompat library を読む】 レイアウト XML のインフレート時に各種 view が compatible widget に変換される仕組み
Android アプリ開発時にお世話になる v7 appcompat library と LayoutInflater
の話です。
この記事の内容は、v7 appcompat library のバージョン 23.1.1 をもとに記述しました。
v7 appcompat library の compatible widget
v7 appcompat library には、AppCompatTextView
などの compatible widget *1 がいくつか含まれています。 いくつか下に挙げてみます。
AppCompatTextView
:TextView
の compatible widgetAppCompatButton
:Button
の compatible widgetAppCompatImageView
:ImageView
の compatible widgetAppCompatCheckBox
:CheckBox
の compatible widgetAppCompatSpinner
:Spinner
の compatible widget- などなど
これらのクラスは、新しい API level で導入された機能の一部を古いプラットフォームにも提供してくれます。 クラスによって提供される機能は違うものの、主には widget tinting 周りの機能 (+α) が提供されると考えて良さそうです。
appcompat ライブラリによる widget tinting については下のブログエントリを参照してください。
インフレート時に自動的に変換される
各 compatible widget のドキュメントを読むと、以下のような説明が書かれています。 (以下は AppCompatTextView
のもの。)
This will automatically be used when you use
AppCompatTextView | Android DevelopersTextView
in your layouts. You should only need to manually use this class when writing custom views.
つまり、レイアウト XML 中に <TextView ...>
って書いておけば、インフレート時に自動的に AppCompatTextView
になる、ってことですね。 便利です。
インフレート時に自動で変換される仕組み
便利なのはいいけどどういう仕組みなのかわかっておかないと嵌ったりすることもあるので、どういう仕組みなのか調べてみました。
LayoutInflater
に Factory をセットすることでインフレート時の挙動を変更できる
まずは LayoutInflater
について調べてみます。 インフレート時に XML ファイル中の要素名を各 view のクラスに変換する処理は、Factory (LayoutInflater.Factory
/LayoutInflater.Factory2
オブジェクト) が担っているようです。 下記メソッドを見てみると、ドキュメントにいろいろ書かれています。
AppCompatActivity
が LayoutInflater
に Factory をセットしている
AppComaptActivity#onCreate
メソッドの中を見ると、以下のように AppCompatDelegate#installViewFactory()
メソッドを呼んでいます。
getDelegate().installViewFactory();
ドキュメントには 『Installs AppCompat's LayoutInflater
Factory so that it can replace the framework widgets with compatible tinted versions』 と書かれていて、このメソッドが LayoutInflater
に独自の Factory をセットすることがわかります。 実装を追っていくと、このメソッドの中では LayoutInflaterCompat.setFactory
メソッドが呼ばれていました。
まとめ
上で調べたように、AppCompatActivity
が LayoutInflater
に独自の Factory をセットすることで、インフレート時の自動変換が実現されています。 よって、インフレーション時については以下のようにまとめられます。
AppCompatActivity
で使う限り、LayoutInflater#from(Context)
メソッドで取得できるLayoutInflater
でのインフレート時に compatible widget への自動変換がはたらくと考えて良い。AppCompatActivity
を使わないのであれば、AppCompatDelegate#installViewFactory
メソッドを使うことで使うことで同等の機能が実現される。
インフレーション時以外に何か良しなに変換してくれたりはしないので、以下のことにも気を付けましょう。 (appcompat ライブラリを使っている環境での話です。)
Chrome からの共有で onNewIntent が呼ばれない問題 (Android アプリの documentLaunchMode の話)
Android の API level 21 で導入された documentLaunchMode
に関する Activity の挙動にバグっぽいところがあって、結構扱いに困るのでまとめておきます。 「documentLaunchMode
? 関係ないや」 って思ってる人でも、外部アプリからの Intent を扱うアプリを書くときに影響されるかもしれません。
まとめ
- Activity の
documentLaunchMode
としてintoExisting
が指定されており、既存の Activity が再利用される場合は、launchMode
がstandard
であっても Activity のonNewIntent
メソッドが呼ばれるべき *1 だが、実際には呼ばれない。- バグっぽい。
- AndroidManifext.xml で
documentLaunchMode
の値を指定していなくても、外部アプリが投げる Intent にintoExisting
相当のフラグ (FLAG_ACTIVITY_NEW_DOCUMENT
フラグ) が設定されていることがあるので、外部からの Intent を受け取る Activity を書いている場合には否が応でもこの問題に悩まされる。 - とりあえずの対処法は、AndroidManifest.xml で、Activity に
launchMode="singleTop"
を指定すること。- アプリ内部で使用することを考えて
singleTop
にしづらい場合は、内部で使用する Activity と外部からの Intent を受け取るための Activity を別に定義して (単純にサブクラスを作れば良いだろう)、外部からの Intent を受け取るための Activity のlaunchMode
をsingleTop
にする、などの対応が必要。
- アプリ内部で使用することを考えて
関連する発表資料
この記事の内容に関連した話を 2015 年 9 月 30 日の 「関西モバイル研究会 #6」 で発表しました。
背景
この問題に行きついた背景です。
- 外部アプリからテキスト (
Intent.EXTRA_TEXT
) を含む Intent を受け取って処理する Activity を含むアプリを開発していた。- Chrome アプリの 「共有」 で投げられる Intent も受け取れる。
- 次のようなユーザー操作を行うと、Activity が新しい Intent を扱えなくて困った。
- Chrome でとあるページ (例として 「http://example.com/1」) で 「共有」 し、Activity を起動。 → Activity では Intent から 「http://example.com/1」 というテキストを取りだせる。
- Activity を終了せずに、アプリを切り替えて Chrome に戻る。
- 別のページ (例として 「http://example.com/2」) で再度 「共有」 し、同じ Activity を起動。 → Activity が破棄されずに残っていた場合、
onStart
メソッドやonResume
メソッドが呼ばれるが、onNewIntent
メソッドが呼ばれず、getIntent
メソッドで取得できる Intent も前に開いたときの Intent になっている。 → 新しい Intent の情報が得られない!
documentLaunchMode
の話
documentLaunchMode
とはなんぞや、という話。
- 公式ドキュメントとしては以下のページを読むとわかりやすいです。
- API level 21 で導入。
- Activity 起動時の挙動を制御するためのもの。
- これを指定することで、「最近のタスク一覧」 に同じアプリケーションの複数ドキュメントを表示できる。
- AndroidManifest.xml で指定することもできるし、
startActivity
にフラグとして渡すこともできる。
intoExisting
documentLaunchMode
の値としてintoExisting
を指定すると、既存のすべてのタスクの中から Intent のコンポーネント情報とdata
の URI が同じものが探される。- あれば、そのタスクの中身がリセットされて、Activity が再利用されて Activity の
onNewIntent
メソッドが呼ばれる。 - なければ、新しいタスクが生成される。
- あれば、そのタスクの中身がリセットされて、Activity が再利用されて Activity の
- と、ドキュメントには書かれているが、
onNewIntent
メソッドが呼ばれるのはタスクの Activity のlaunchMode
としてsingleTop
などの値が指定されている場合で、standard では呼ばれなかった。 (まじかよ……) - DocumentCentricApps というサンプルコードがあるが、サンプルコードのコメントなどはドキュメント通りの挙動を期待しているが、実際の挙動はドキュメントどおりにはならなかった。 (Nexus 5; Android 5.1.1 で確認)
バグっぽい
- ドキュメントを読む限り、
documentedLaunchMode
がintoExisting
ならば、たとえlaunchMode
がstandard
でもonNewIntent
メソッドが呼ばれてもよさそうだけど、実際には呼ばれない。 - API level 23 (Android 6.0 Marshmallow) での挙動も見てみたけどやっぱり同じだった。
- もともと
launchMode
がstandard
だとonNewIntent
メソッドは呼ばれないものなので、仕様なのかもしれないけど仕様だとしたら糞仕様だしバグならつらい。 - ていうか 『Activities launched with the
FLAG_ACTIVITY_NEW_DOCUMENT
flag must have theandroid:launchMode="standard"
attribute value』 って書かれてるし……。 - StackOverflow でこの問題について書いてる人が居た : Android API guide > Overview Screen: onNewIntent() not called with FLAG_ACTIVITY_NEW_DOCUMENT - Stack Overflow
- バグ報告した : Issue 188033 - android - onNewIntent method not called with FLAG_ACTIVITY_NEW_DOCUMENT flag - Android Open Source Project - Issue Tracker - Google Project Hosting
Chrome アプリの 「共有」 で onNewIntent
が呼ばれない問題
原因
Chrome アプリの 「共有」 (メニューの 「共有」 の右側に表示されている、最後に起動したアプリアイコンをタップ) で投げられる Intentを見てみたところ、以下のフラグが設定されていました。
FLAG_ACTIVITY_PREVIOUS_IS_TOP
FLAG_ACTIVITY_FORWARD_RESULT
FLAG_GRANT_READ_URI_PERMISSION
FLAG_ACTIVITY_NEW_DOCUMENT
(API level 21;documentLaunchMode="intoExisting"
相当のフラグ)
上述のとおり、documentLaunchMode
が intoExisting
で、launchMode
が standard
だと、古い Activity が再利用されるもの onNewIntent
メソッドが呼ばれないのです。 つらいですね。
回避策
一つの解決策としては、Activity に launchMode="singleTop"
を設定する方法があります。 これだと onNewIntent
メソッドが呼ばれるようになります。
しかし、外部からの Intent を受け取るだけじゃなくてアプリ内部からも起動される Activity の場合は launchMode="singleTop"
を設定することができないことも多いでしょう。 そういう場合は、アプリ内部からしか起動されない Activity と外部からの Intent を受け取る Activity を別のクラスにしてしまって (片方をもう一方のサブクラスとするなど)、外部からの Intent を受け取る Activity にだけ launchMode="singleTop"
を設定するなどの方法を採ることになるでしょうか。 もうちょっといい方法があれば嬉しいですが、それぐらいしか思いつきませんでした。
終わり
この問題、とにかく辛いのですがあんまり困ってる人を見かけないので、もしいい感じの回避策があるのでしたら教えてください!!!!
*1:ドキュメントを読む感じだとそうだと思われる
実機の Android 端末に対して Hierarchy Viewer を使って View の階層構造を調べる
Android アプリを開発する際に便利な Hierarchy Viewer ですが、日本語のブログ記事だと実機の Android 端末に対して Hierarchy Viewer を使う際に ViewServer を使わない方法を紹介してるものがあまり見当たらない *1 ので、ViewServer を使わない方法を紹介しておきます。
Hierarchy Viewer とは
Hierarchy Viewer は、Android アプリの UI のデバッグや最適化を行う際に便利なツールです。レイアウトの View の階層構造を表示する機能 (Web 開発でいうところの DOM インスペクタみたいな感じ) や、ディスプレイの表示内容を拡大して表示する機能 (Pixel Perfect というツール) があります。
Hierarchy Viewer を使うための準備
上記ページに書いてあります。 Android 4.1 以降の端末であれば、次の 2 つのことをすれば良いようです。
- Android 端末の開発者向けオプションを有効にする
- 開発に使用している PC の環境変数
ANDROID_HVPROTO
の値をddm
にする。
ロックされている Android 4.1 未満の端末の場合は、ViewServer を使用することになります。 エミュレータのような非ロックの Android 4.1 未満の端末なら何もする必要はなさそうです。 (エミュレータは非ロックです。)
使い方
公式のドキュメントを読むのが一番良いと思うのでリンクをはっておきます。
下記ページにて、Hierarchy Viewer の起動 (Android Studio のメニューから起動する方法) から一通りの使用方法が説明されています。
また、Hierarchy Viewer のより細かな使い方や、Pixel Perfect の使い方などが以下のページで説明されています。
関連ページ
- [Android] Hierarchy View を使ってレイアウトを見直す: スタジオプリズム㐧3ブログ
- 非rootなAndroid端末でHierarchy Viewerを使う方法 - Just for Fun
- ViewServer の導入方法が詳しく説明されています。
- Android - Viewの構造を丸見えにするHierarchy Viewerの使い方 - Qiita
- 使い方が説明されています。 日本語で使い方を見たい方はこちらを参考にするとよいでしょう。
- 実機でHierarchy Viewerを使う 電脳羊(Android Dream)/ウェブリブログ
- こちらも ViewServer を使う方法。
Android の Canvas#saveLayer メソッドと xfermode について
Android アプリ開発に関して Canvas
クラスの saveLayer
メソッドや Paint
の xfermode について調べたのでまとめておきます。
Canvas#saveLayer
メソッド
saveLayer
メソッドのドキュメントには、『This behaves the same as save()
, but in addition it allocates and redirects drawing to an offscreen bitmap.』 と書かれています。
save
メソッドと基本的には同じ。- 異なる点は、キャンバス外 (offscreen) のビットマップを用意し、以降の描画処理をそちらにリダイレクトするようにする、ということ。
save
メソッドは何をするのか
じゃあ save
メソッドは何をするのか調べましょう。 save
メソッドのドキュメントには、『Saves the current matrix and clip onto a private stack. Subsequent calls to translate
, scale
, rotate
, skew
, concat
or clipRect
, clipPath
will all operate as usual, but when the balancing call to restore()
is made, those calls will be forgotten, and the settings that existed before the save()
will be reinstated.』 と書かれています。
- 呼びだし時点の座標変換行列とクリッピングの設定を保存する。
- それ以降に、座標変換行列を変化させるメソッド (
translate
やscale
など) やクリッピング設定を変化させるメソッド (clipRect
とclipPath
) が呼ばれると通常通り適用される。 - 対応する
restore
メソッドが呼ばれるとsave
メソッド呼び出し前の状態に戻される。
つまり、描画される位置を決定するための情報が保存され、あとから復元することができるようになる、という感じですね。 描画されているビットマップの情報が保存されるわけではないので注意しましょう。
ちなみに、save(int)
メソッドを使い、引数として Canvas.MATRIX_SAVE_FLAG
や Canvas.CLIP_SAVE_FLAG
を渡すことで、保存・復元の対象を座標変換行列だけにしたり、クリッピングの設定だけにしたりできます。 (が、両方を保存・復元の対象にする方が単純で速いので、できるだけ使わない方がいいみたいです。)
何度も save
した後、指定のところまで一気に復元する
save
メソッドを何度も呼び出した場合、restore
メソッドを同じ回数呼び出すことで元の状態に戻せます。 しかし何度も呼ぶのは面倒ですね。 save
時の返り値を保持しておき、復元時にその値を restoreToCount
メソッドに渡すと、その状態まで一気に復元できます。
// 返り値を保持しておく。 final int sc = canvas.save(); // 位置 A /* ... ここで何度も save メソッドを呼ぶ。 ... */ // 途中で何度 save メソッドを呼んでいたとしても、位置 A のときの状態まで復元される。 canvas.restoreToCount(sc);
キャンバス外のビットマップに描かれたものはどこへ?
さて、saveLayer
メソッドの話に戻りましょう。 save
レイヤーと同じように座標変換行列とクリッピングの設定の保存ができることについては説明は不要だと思います。
問題は、saveLayer
メソッド呼び出し後の描画がキャンバス外のビットマップにリダイレクトされて、最終的にそのビットマップに描かれたものがどうなるのか、です。 メソッドの説明には次のように書かれています。
Only when the balancing call to
restore()
is made, is that offscreen buffer drawn back to the current target of the Canvas (either the screen, it's target Bitmap, or the previous layer).
restore
メソッドの呼び出しが行われて初めてターゲットとなるキャンバス (スクリーンだったり、Bitmap
だったり、より前に作られたレイヤーだったりする) に描き戻されるわけですね!
Xfermode と PorterDuffXfermode
Xfermode について
Xfermode
クラスのドキュメントには 『Xfermode is the base class for objects that are called to implement custom "transfer-modes" in the drawing pipeline.』 と書かれています。 「xfermode」 は transfer-mode を表すみたいですね。 (これが世間一般での命名なのか Android の世界だけの命名なのかよくわかりません。)
ちゃんとドキュメントには書かれていませんが、2 つの画像を合成する場合や、既に何かが描かれているところに新たに描画する際に、どのように合成するのかを表すもののようです。
PorterDuffXfermode
昔 Porter さんと Duff さんが画像合成の 12 通りのルールを論文にしたそうで、それが Porter-Duff ルールと呼ばれているそうです。
具体的にどういうものかは上のページを見るとわかりやすいです。 例となる画像があります。
これらのルールは、Android SDK では PorterDuffXfermode
クラスで表現されます。 (Android SDK では 12 通り以上のモードが定義されているので、Porter-Duff ルールに含まれないものも入っているのかも?)
例えば、次のページに書かれているように PorterDuff.Mode.CLEAR
を使うことで対象となるキャンバスの内容 (destination) も描くもの (source) も両方消去することができます (つまり消しゴムにできる)。
ちなみに、ImageView#onDraw
メソッドなどに渡されてくる Canvas
オブジェクトは背景が透明ではないので、透明にしようとしても透明にならないようです。 (真っ黒になった。)
saveLayer
メソッドと xfermode のサンプルコード
赤い四角をもとのキャンバス (destination) に描き、青い円を新しく用意したレイヤー (source) に描き、Porter-Duff の Overlay モードで合成するサンプルコードです。
// 必要な import 文。 import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.PorterDuff; import android.graphics.PorterDuffXfermode; // 使用する Paint オブジェクトの用意。 Paint redPaint = new Paint(); redPaint.setAntiAlias(true); redPaint.setColor(getResources().getColor(android.R.color.holo_red_light)); Paint bluePaint = new Paint(); bluePaint.setAntiAlias(true); bluePaint.setColor(getResources().getColor(android.R.color.holo_blue_bright)); Paint xfermodePaint = new Paint(); xfermodePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.OVERLAY)); // Canvas 用意。 Bitmap bm = Bitmap.createBitmap(300, 300, Bitmap.Config.ARGB_8888); bm.setDensity(DisplayMetrics.DENSITY_XXHIGH); Canvas c = new Canvas(bm); // 四角を描画。 c.drawRect(0, 0, 210, 210, redPaint); // 新しいレイヤーを準備。 int sc = c.saveLayer(null, xfermodePaint, Canvas.CLIP_TO_LAYER_SAVE_FLAG); // 新しいレイヤーに円を描画。 c.drawCircle(180, 180, 120, bluePaint); // 新しいレイヤーに描かれたものを描き戻す。 // (saveLayer 時に指定した xfermodePaint の xfermode である Porter-Duff の OVERLAY モードで。) c.restoreToCount(sc); // 表示してみる。 (imageView は android.widget.ImageView オブジェクト。) imageView.setImageBitmap(bm);
結果は以下の画像のようになります。
DST_ATOP
や SRC_ATOP
、SRC_OUT
などを使うとマスク処理みたいなこともできますし、面白いですね。
備考
- Xfermode を指定して画像の合成をする方法はいろいろあって、
Canvas#saveLayer
メソッドを使う必要は必ずしもありません。
Espresso 2.0 が Android support library の一部としてリリースされた
上記エントリにあるように、Espresso 2.0 がリリースされた。 Espresso は Android アプリの自動テストのためのライブラリである。 ほぼ上のエントリに書かれている内容であるが、Espresso 2.0 のリリースについて紹介する。
変更内容など
一番大きな変更は、Android support library の一部になったことだと思われる。 (上のエントリにもそう書かれている。) *1 そのおかげで、Android SDK で 「Android Support Repository」 をインストールしておけば (JAR をダウンロードしたりせずに) 簡単に使用できるようになった。
API 的には、パッケージ名の変更以外は大きな変更点はなさそうである。 とはいえ一部非互換な変更が加わっているので、そこら辺は気を付ける必要がある。 変更内容はリリースノートを見ると良い。
また、Instrumentation テストランナーとして GoogleInstrumentationTestRunner
にいくつかの機能を追加した AndroidJUnitRunner
が含まれている。 JUnit 4 サポートも含まれていて、これを使うことで JUnit 4 を使ったテストを書けるようになる。
ドキュメント
2015 年には Android Developers の方にドキュメントが移される予定のようだが、今のところはまだ android-test-kit でホストされている。 2016 年現在、ドキュメントは既に移されている。
- Android Developers 内のドキュメント : Testing Support Library | Android Developers
- Testing support library の GitHub Page : Android Testing Support Library
Testing Support Library の Javadoc は以下。
サンプルコード
サンプルプロジェクトが GitHub にホストされている。 JUnit 4 を使ったテストの例などもあり、参考になる。
また、私が公開している Espresso を使用したテストの例も Espresso 2.0 に対応させた。
「Espresso を使って PreferenceActivity の自動 UI テストを行う」 のエントリを書いたときに作ったサンプルプロジェクトである。
関連エントリ
JUnit 4 によるテストの説明や紹介をしているエントリがあります。
変更履歴
- 2016-10-02 : リンク先が Not Found になっているものがあったので、リンク先を変更しました。
*1:早く Volley も support library の一部にならないかな。