Androidのテストで利用できるスクリーンショット取得APIのまとめ

## はじめに

Android開発でUIテストを実装していると、画面の表示確認やVisual Regression Test等の用途でスクリーンショットを取りたい場面が出てきます。
しかし、スクリーンショットを取得できるAPIは複数あり、適したものを選択しないと思ったようなスクリーンショットが取れないことがあります。
本記事では、Androidのテストで使用できるスクリーンショットのAPIを紹介し、それらを使うと実際にどのようなスクリーンショットが取れるのかを見ていきたいと思います。

こちらは [Mobility Technologies Advent Calendar 2020](https://adventar.org/calendars/5399) の14日目の記事です。


## スクリーンショットを取得できるAPI

スクリーンショットは以下のAPIを利用して取得することができます。

- View#getDrawingCache
  - API level 28からdeprecated    
- Canvasを使ったViewの書き出し
- [UiDevice#takeScreenshot](https://developer.android.com/reference/androidx/test/uiautomator/UiDevice#takescreenshot)
  - API level 17から利用可 
- [MediaProjection](https://developer.android.com/reference/android/media/projection/MediaProjection)
  - API level 21から利用可
- [PixelCopy](https://developer.android.com/reference/android/view/PixelCopy)
  - API level 24から利用可

本記事では、Canvasを使ったViewの書き出し・PixelCopy・UiDevice#takeScreenshotの3つのAPIを中心に説明していきます。
まずは、3つのAPIの利用方法を確認します。

### CanvasをつかったViewの書き出し

Canvasをつかったスクリーンショットの取得は次のような実装になります。
ここではActivityを引数にとる実装をしていますが、任意のViewのインスタンスを渡すこともできるので、画面内の一部のViewに限定したスクリーンショットを取得することが可能です。

```kotlin
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](https://developer.android.com/training/testing/ui-automator)というUIテストフレームワークのAPIです。

UiAutomatorをテストコードから使用できるようにするには、次のdependencyを追加します。

```groovy

androidTestImplementation 'androidx.test.uiautomator:uiautomator: X.X.X'
```

また、UiAutomatorはInstrumentation Testのコードからのみ使用することができます。

スクリーンショットを取得するコードは次のとおりです。

```kotlin
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のスクリーンショットを取得することができます。

```kotlin

// 画面全体のスクリーンショットを取得する例
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に限定したスクリーンショットを取得することもできます。

```kotlin
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の使い方を見てきました。

さっそくこれらを使って、次の画面のスクリーンショットを取得します。


![Screenshot_1607916288.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279160/7eda1e86-1832-bfaf-b672-8b94faad3cab.png)


結果は以下のとおりです。

| Canvas  | PixelCopy | UiDevice|
| :-------------: |:-------------:|:-------------:|
|![canvas1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279160/7f24dd3e-a1af-8dc2-e754-5b8019cc60d3.png)|![pixel_copy1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279160/e18567a0-6ce4-a2f8-f1c6-d4b4fea8128e.png)|![uiautomator1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279160/c788661e-548c-3d5e-60c8-ba31559dcc2d.png)|


シンプルな画面ですが、取得できたスクリーンショットに差分がでています。
CanvasのスクリーンショットにはActionBarの下のEvaluationが反映されていません。
また、UiDeviceのみステータスバー内のコンテンツ(時間や電池残量など)も含まれています。

ここでは特に、Viewから書き出されたBitmapにはShadowやOutline Clipが反映されないことに注意が必要です。

## ダイアログのスクリーンショットを比較する

次に、Dialogを表示している画面のスクリーンショットを取得します。

![Screenshot_1607916651.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279160/4e5b330a-2bf5-d981-ea50-8747b71c04ff.png)


結果は次のようになりました。

| Canvas  | PixelCopy | UiDevice|
| :-------------: |:-------------:|:-------------:|
| ![canvas2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279160/6761e6e7-6dd0-7863-b84a-221700796a44.png)|![pixel_copy2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279160/df71fd2b-0436-469d-44fe-1089d029084e.png)|![uiautomator2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279160/34c957d2-baaf-f589-5152-d0581876500a.png)|


CanvasとPixelCopyではダイアログが表示されていません。これは、スクリーンショットを取得対象のActivityとダイアログのWindowが異なるためです。
一方、UiDevice#takeScreenshotはActivityに依存せずに画面全体を取得できるため、ダイアログも含まれたスクリーンショットが取得できています。

CanvasとPixelCopyでこの問題を解決するためには、リフレクションを使ってGlobalなWindowManagerからすべてのWindowを取得する必要があります。
[Falcon](https://github.com/jraska/Falcon)というスクリーンショットLibraryは、[上記の実装](https://github.com/jraska/Falcon/blob/bf8100136eee32cc64e7e93746eb7fd9aa2c9cdf/falcon/src/main/java/com/jraska/falcon/Falcon.java#L201)をすることで、ダイアログを含んだスクリーンショットを取得できることができます。
Falconで撮影したスクリーンショットは次のようになります。(一方、Shadowは含まれていません)

![falcon2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279160/7bacb9c0-1bd5-2804-9f14-b981830410c4.png)

自前でダイアログを含むスクリーンショットを取得する仕組みを実装する場合は、Falconの実装が参考になると思います。

## SurfaceViewを含む画面のスクリーンショット比較する

次に、SurfaceViewを含む画面のスクリーンショットを取得します。カメラのPreviewがSurfceViewで実装されています。

![Screenshot_1607915205.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279160/7b4951bf-f78f-e1ae-daf0-a7166b2bfeb7.png)


結果は次のようになりました。

| Canvas  | PixelCopy | UiDevice|
| :-------------: |:-------------:|:-------------:
| ![canvas3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279160/bffebdca-48fb-3c2d-a8b2-d5052cc4a726.png)| ![pixel_copy3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279160/1a1043db-8d07-4927-f355-74b0c01d8122.png)| ![uiautomator3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279160/1063f254-b120-11f0-87cf-1ef2c7b97e6f.png)|


意外なことに、PixelCopyでSurfaceViewの中身がキャプチャできていません。
PixelCopyはSurfaceやSurfaceViewを引数にとる場合だとSurfaceViewの中身をキャプチャできるのですが、Windowを引数にとるAPIを使用した場合だとうまく取得できないようです。

(PixelCopyでSurfaceViewを引数にとった場合のスクリーンショット)
![pixel_copy_surface.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279160/aadb3e4a-16a6-72c8-72b1-56c746e41e82.png)

UiDevice#takeScreenshotはここでも安定してSurfaceViewの中身も含んだスクリーンショットが取得できています。

その他MediaProjectionを利用すると、このような画面でも正確なスクリーンショットを取得することができます。ただ、MediaProjectionは利用時にダイアログが表示されて操作が必要だったりと、テストコードでは若干使いにくいAPIです。

(MediaProjection利用時のダイアログ)

![Screenshot_20201214-114351.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279160/f9c35f2b-688c-936e-6596-adb6cf54a79e.png)



個人的には、素直にUiDevice#takeScreenshotを使ったほうが簡単だと思っています。

## まとめ

ここまでの結果をまとめると次のようになります。

|API|API Levelの制限|一部のViewのキャプチャ| Shadow・Outline Clip等|ダイアログ|SurfaceView|
| :-------------: |:-------------:|:-------------:|:-------------:|:-------------:|:-------------:|
| Canvas|なし|○|✕|△<br>(リフレクションを使用すれば可)|✕|
| PixelCopy|24<br>(一部のAPIは26)|○|○|△<br>(リフレクションを使用すれば可)|△<br>(一部のAPIで可)|
| UiDevice|17から|✕|○|○|○|

実際のアプリと同じ見た目のスクリーンショットを取得するためには、これらのAPIを適切に選択することが必要です。
本記事が、スクリーンショット取得の手段を選定する際に助けになれば幸いです。

[Mobility Technologies Advent Calendar 2020](https://adventar.org/calendars/5399) の15日目は、reki2000さんによる `自作Cコンパイラのセルフホストに成功した話` です。お楽しみに！