はじめに
Android開発でUIテストを実装していると、画面の表示確認やVisual Regression Test等の用途でスクリーンショットを取りたい場面が出てきます。
しかし、スクリーンショットを取得できるAPIは複数あり、適したものを選択しないと思ったようなスクリーンショットが取れないことがあります。
本記事では、Androidのテストで使用できるスクリーンショットのAPIを紹介し、それらを使うと実際にどのようなスクリーンショットが取れるのかを見ていきたいと思います。
こちらは Mobility Technologies Advent Calendar 2020 の14日目の記事です。
スクリーンショットを取得できるAPI
スクリーンショットは以下のAPIを利用して取得することができます。
- View#getDrawingCache
- API level 28からdeprecated
- Canvasを使ったViewの書き出し
-
UiDevice#takeScreenshot
- API level 17から利用可
-
MediaProjection
- API level 21から利用可
-
PixelCopy
- API level 24から利用可
本記事では、Canvasを使ったViewの書き出し・PixelCopy・UiDevice#takeScreenshotの3つのAPIを中心に説明していきます。
まずは、3つのAPIの利用方法を確認します。
CanvasをつかったViewの書き出し
Canvasをつかったスクリーンショットの取得は次のような実装になります。
ここではActivityを引数にとる実装をしていますが、任意のViewのインスタンスを渡すこともできるので、画面内の一部のViewに限定したスクリーンショットを取得することが可能です。
fun capture(activity: Activity) {
val view = activity.window.decorView.rootView
// スクリーンショットを取得したいViewサイズにあわせたBitmapを作成する
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
// Canvasを生成し、スクリーンショットを取得したいViewをBitmapに書き出す
val canvas = Canvas(bitmap)
view.draw(canvas)
// Bitmap#compressでファイルに書き出し
}
UiDevice#takeScreenshot
UiDeviceはUiAutomatorというUIテストフレームワークのAPIです。
UiAutomatorをテストコードから使用できるようにするには、次のdependencyを追加します。
androidTestImplementation 'androidx.test.uiautomator:uiautomator: X.X.X'
また、UiAutomatorはInstrumentation Testのコードからのみ使用することができます。
スクリーンショットを取得するコードは次のとおりです。
fun capture() {
val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
// 保存先のFileクラスを引数にうけとる
uiDevice.takeScreenshot(File(dir, "capture.png"))
}
UiDevice#takeScreenshotは画面全体のスクリーンショットを取得します。画面内の一部のViewのみのスクリーンショットを撮ることはできません。
PixelCopy
PixelCopyはAPI level 24で追加されたAPIで、SurfaceViewのスクリーンショットを取得できるのが特徴です。
引数にSurfaceView/Surface/WindowをとるAPIが用意されており、SurfaceとWindowを引数にとるAPIは、API level 26で追加されました。
その新たに追加されたWindowを引数にとるAPIを利用することで、画面全体や任意のViewのスクリーンショットを取得することができます。
// 画面全体のスクリーンショットを取得する例
fun capture(activity: Activity) {
val window = activity.window
val view = window.decorView.rootView
// スクリーンショットを取得したいViewサイズにあわせたBitmapを作成する
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
PixelCopy.request(
window,
bitmap, // 保存先Bitmapの指定
{ copyResult ->
if (copyResult == PixelCopy.SUCCESS) {
// bitmap#compressでファイルに書き出し
}
},
Handler()
)
}
また、PixelCopyはRectを引数にうけとるAPIも用意されていので、画面内の一部のViewに限定したスクリーンショットを取得することもできます。
fun captureView(view: View, activity: Activity) {
val window = activity.window
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
// Windowの中のViewの位置を取得する
val viewLocation = IntArray(2)
view.getLocationInWindow(viewLocation)
PixelCopy.request(
window,
Rect( // Viewの位置をPixelCopyにわたすことでスクリーンショットを取得する範囲を限定する
viewLocation[0],
viewLocation[1],
viewLocation[0] + view.width,
viewLocation[1] + view.height
),
bitmap,
{ copyResult ->
if (copyResult == PixelCopy.SUCCESS) {
// bitmap#compressでファイルに書き出し
}
},
Handler()
)
}
スクリーンショットを比較する
ここまで、Canvasを使ったViewの書き出し・PixelCopy・UiDevice#takeScreenshotの3つのAPIの使い方を見てきました。
さっそくこれらを使って、次の画面のスクリーンショットを取得します。
結果は以下のとおりです。
Canvas | PixelCopy | UiDevice |
---|---|---|
シンプルな画面ですが、取得できたスクリーンショットに差分がでています。
CanvasのスクリーンショットにはActionBarの下のEvaluationが反映されていません。
また、UiDeviceのみステータスバー内のコンテンツ(時間や電池残量など)も含まれています。
ここでは特に、Viewから書き出されたBitmapにはShadowやOutline Clipが反映されないことに注意が必要です。
ダイアログのスクリーンショットを比較する
次に、Dialogを表示している画面のスクリーンショットを取得します。
結果は次のようになりました。
Canvas | PixelCopy | UiDevice |
---|---|---|
CanvasとPixelCopyではダイアログが表示されていません。これは、スクリーンショットを取得対象のActivityとダイアログのWindowが異なるためです。
一方、UiDevice#takeScreenshotはActivityに依存せずに画面全体を取得できるため、ダイアログも含まれたスクリーンショットが取得できています。
CanvasとPixelCopyでこの問題を解決するためには、リフレクションを使ってGlobalなWindowManagerからすべてのWindowを取得する必要があります。
FalconというスクリーンショットLibraryは、上記の実装をすることで、ダイアログを含んだスクリーンショットを取得できることができます。
Falconで撮影したスクリーンショットは次のようになります。(一方、Shadowは含まれていません)
自前でダイアログを含むスクリーンショットを取得する仕組みを実装する場合は、Falconの実装が参考になると思います。
SurfaceViewを含む画面のスクリーンショット比較する
次に、SurfaceViewを含む画面のスクリーンショットを取得します。カメラのPreviewがSurfceViewで実装されています。
結果は次のようになりました。
Canvas | PixelCopy | UiDevice |
---|---|---|
意外なことに、PixelCopyでSurfaceViewの中身がキャプチャできていません。
PixelCopyはSurfaceやSurfaceViewを引数にとる場合だとSurfaceViewの中身をキャプチャできるのですが、Windowを引数にとるAPIを使用した場合だとうまく取得できないようです。
(PixelCopyでSurfaceViewを引数にとった場合のスクリーンショット)
UiDevice#takeScreenshotはここでも安定してSurfaceViewの中身も含んだスクリーンショットが取得できています。
その他MediaProjectionを利用すると、このような画面でも正確なスクリーンショットを取得することができます。ただ、MediaProjectionは利用時にダイアログが表示されて操作が必要だったりと、テストコードでは若干使いにくいAPIです。
(MediaProjection利用時のダイアログ)
個人的には、素直にUiDevice#takeScreenshotを使ったほうが簡単だと思っています。
まとめ
ここまでの結果をまとめると次のようになります。
API | API Levelの制限 | 一部のViewのキャプチャ | Shadow・Outline Clip等 | ダイアログ | SurfaceView |
---|---|---|---|---|---|
Canvas | なし | ○ | ✕ | △ (リフレクションを使用すれば可) |
✕ |
PixelCopy | 24 (一部のAPIは26) |
○ | ○ | △ (リフレクションを使用すれば可) |
△ (一部のAPIで可) |
UiDevice | 17から | ✕ | ○ | ○ | ○ |
実際のアプリと同じ見た目のスクリーンショットを取得するためには、これらのAPIを適切に選択することが必要です。
本記事が、スクリーンショット取得の手段を選定する際に助けになれば幸いです。
Mobility Technologies Advent Calendar 2020 の15日目は、reki2000さんによる 自作Cコンパイラのセルフホストに成功した話
です。お楽しみに!