Android O 向けビルド（targetSdkVersion>=26）で Canvas.setBitmap() の挙動が変わった件

# この記事は？
Android O 向けビルド（targetSdkVersion>=26）で Canvas.setBitmap() の挙動が少し変わりました。

Canvas.setBitmap() を使って Canvas に Bitmap をセットすると、それ以前に実行した Canvas.translate() が効力を失います。また、Canvas.scale() なども同様です。

より正確に言うと、__setBitmap() によって Canvas にセットした変換行列がリセットされるようになりました__。

この点についてサンプルアプリを作成し、実際の挙動を確認して記事にまとめました。
また、挙動が変わる原因となった Android 側の修正についても解説しています。

# まとめ
- Android O 未満向けビルド（targetSdkVersion<26）の場合
  - Canvas.setBitmap() で Bitmap をセットしても、それ以前の translate() の値は保持されていた
  - すなわち Canvas にセットした変換行列はリセットされなかった
- Android O 向けビルド（targetSdkVersion>=26）の場合
  - Canvas.setBitmap() で Bitmap をセットすると、translate() の値は破棄される
  - すなわち Canvas にセットした変換行列がリセットされるようになった
- この挙動の変更は 2017/11/28 時点で [Apps targeting Android 8.0](https://developer.android.com/about/versions/oreo/android-8.0-changes.html#o-apps) では特に触れられていません。

この挙動はすべて Android 8.0.0 以上の OS で動作させた場合のものです。

# 実際の挙動
アプリ内にある画像を Bitmap にして Canvas を使って並行移動するアプリを作成しました（[CanvasSetBitmapTester](https://github.com/hshiozawa/CanvasSetBitmapTester)）。
[Translate] というボタンを押すと画像を右下に並行移動するアプリです。

<img src="https://qiita-image-store.s3.amazonaws.com/0/41604/2c1029ce-56e8-b0f0-7e60-7a62ca78c9d6.png" width=300>

これを使って Android O 向けにビルドした場合と、そうでない場合で挙動を比較しています。



## 端末
- Nexus 5X, Android 8.0.0

## コード
Canvas を使って Bitmap を変換する部分のコードは次のとおりです。

```java:MainActivity.java#L57
private Bitmap translateImage(@DrawableRes int targetId) {
    Bitmap src = BitmapFactory.decodeResource(getResources(), targetId);
    Bitmap dst = Bitmap.createBitmap(src.getWidth(), src.getHeight(), Bitmap.Config.ARGB_8888);

    Canvas canvas = new Canvas();

    // 1. translate() を実行
    // 2. setBitmap() で書き込み用の Bitmap をセット
    // 3. drawBitmap() で書き込み
    canvas.translate(200, 200);
    canvas.setBitmap(dst);
    canvas.drawBitmap(src, 0, 0, new Paint());

    return dst;
}
```
ref: https://github.com/hshiozawa/CanvasSetBitmapTester/blob/master/app/src/main/java/com/hjm/canvassetbitmaptester/MainActivity.java#L57

ポイントは setBitmap() の前に translate() している点です。

## 結果
### targetSdkVersion<26 の場合
Android O 未満向けビルドでは build.gradle の targetSdkVersion を 25 にしています。
compileSdkVersion は 26 のままであることに注意が必要です。

```java:build.gradle
android {
    compileSdkVersion 26
    buildToolsVersion "27.0.1"
    defaultConfig {
        applicationId "com.hjm.canvassetbitmaptester"
        minSdkVersion 19
        targetSdkVersion 25 // 25 にする
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    ...
}
```

この条件でビルドを行うと、[Translate] を押すと、右下に 200px だけ画像が平行移動します。

__押す前__


<img src="https://qiita-image-store.s3.amazonaws.com/0/41604/2c1029ce-56e8-b0f0-7e60-7a62ca78c9d6.png" width=300>

__押した後__

<img src="https://qiita-image-store.s3.amazonaws.com/0/41604/94d38168-1a09-406a-eceb-dfa72fa6d1a4.png" width=300>

Android O 未満向けビルドでは setBitmap() 前に実行した translate() が有効であると分かります。

### targetSdkVersion>=26
Android O 未満向けアプリでは次のように targetSdk を 26 にしています。

```java:build.gradle
android {
    compileSdkVersion 26
    buildToolsVersion "27.0.1"
    defaultConfig {
        applicationId "com.hjm.canvassetbitmaptester"
        minSdkVersion 19
        targetSdkVersion 26 // 変更
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    ...
}
```

この条件でビルドを行うと、[Translate] を押しても画像が右下に平行移動しません。

__押す前__

<img src="https://qiita-image-store.s3.amazonaws.com/0/41604/06347558-f89a-59b9-f64b-fd38fca7edae.png" width=300>


__押した後__

<img src="https://qiita-image-store.s3.amazonaws.com/0/41604/09849abd-7b7f-1b53-7c76-2f982134677b.png" width=300>

Android O 向けビルドでは setBitmap() の前に行った translate() が無効になっていることが分かります。

# 修正
問題は setBitmap() すると変換行列がリセットされてしまうです。
そのため setBitmap() のあとに translate() を呼ぶことでこの問題は解決します

```java:MainActivity.java#translateImage()
private Bitmap translateImage(@DrawableRes int targetId) {
    Bitmap src = BitmapFactory.decodeResource(getResources(), targetId);
    Bitmap dst = Bitmap.createBitmap(src.getWidth(), src.getHeight(), Bitmap.Config.ARGB_8888);

    Canvas canvas = new Canvas();

    // 1. setBitmap() で書き込み用の Bitmap をセット
    // 2. translate() を実施
    // 3. drawBitmap() で書き込み
    canvas.setBitmap(dst);
    canvas.translate(200, 200);
    canvas.drawBitmap(src, 0, 0, new Paint());

    return dst;
}
```

ただし、ここまでくると setBitmap() を使わずに new Canvas() に Bitmap を渡してしまうのが正解だと思います。

# 解説
## Canvas.setBitmap()
Canvas のソースコードを見ながらこのような挙動の違いが発生する原因を解説します。

まず、Canvas.setBitmap() の Javadoc には次のように書かれています。

```java:Canvas.java#L160
/**
 * Specify a bitmap for the canvas to draw into. All canvas state such as
 * layers, filters, and the save/restore stack are reset. Additionally,
 * the canvas' target density is updated to match that of the bitmap.
 *
 * Prior to API level {@value Build.VERSION_CODES#O} the current matrix and
 * clip stack were preserved.
 *
 * @param bitmap Specifies a mutable bitmap for the canvas to draw into.
 * @see #setDensity(int)
 * @see #getDensity()
 */
```
ref: https://github.com/aosp-mirror/platform_frameworks_base/blob/oreo-release/graphics/java/android/graphics/Canvas.java#L160

> Prior to API level Build.VERSION_CODES#O the current matrix and clip stack were preserved.

と書かれています。ここから、この挙動の違いはバグではなく Android として意図したものであることが分かります。

次に、実際にコードを見てみます。

```java:Canvas.java#L172
public void setBitmap(@Nullable Bitmap bitmap) {
    if (isHardwareAccelerated()) {
        throw new RuntimeException("Can't set a bitmap device on a HW accelerated canvas");
    }

    Matrix preservedMatrix = null;
    if (bitmap != null && sCompatibilitySetBitmap) { // (1) 
        preservedMatrix = getMatrix();
    }

    // (2)
    if (bitmap == null) {
        nSetBitmap(mNativeCanvasWrapper, null);
        mDensity = Bitmap.DENSITY_NONE;
    } else {
        if (!bitmap.isMutable()) {
            throw new IllegalStateException();
        }
        throwIfCannotDraw(bitmap);

        nSetBitmap(mNativeCanvasWrapper, bitmap);
        mDensity = bitmap.mDensity;
    }

    // (3)
    if (preservedMatrix != null) {
        setMatrix(preservedMatrix);
    }

    mBitmap = bitmap;
}
```
ref: https://github.com/aosp-mirror/platform_frameworks_base/blob/oreo-release/graphics/java/android/graphics/Canvas.java#L172

(1) の部分で sCompatibilitySetBitmap が true の場合、ローカル変数（preservedMatrix）に行列を保持しています。(2) で実際に Bitmap をセットする処理を native 側で行ったあと、(3) で prevervedMatrix に値があったら行列を元に戻しています。
sComaptibilitySetBitmap が true の時だけ setBitmap() 前の行列の値を保持し復元していることが見てとれます。

## sComatibilitySetBitmap
最後に処理の分岐に使われている sComatibilitySetBitmap の値を決めるコードを見てみます。
この値は View.java のコンストラクタで次のように決定されています。

```java:View.java#L4552
final int targetSdkVersion = context.getApplicationInfo().targetSdkVersion;
...
Canvas.sCompatibilitySetBitmap = targetSdkVersion < Build.VERSION_CODES.O;
```
ref: https://github.com/aosp-mirror/platform_frameworks_base/blob/oreo-release/core/java/android/view/View.java#L4552

まさに targetSdkVersion の値によって、setBitmap() の挙動を変えていることが分かります。

# 小話
## Bitmap.createBitmap() への影響
今回のサンプルアプリでは Canvas を使って Bitmap を変換するという処理を行っています。

実は Bitmap クラスに同じようなことをする static ファクトリーメソッドがあります。
ソースとなる Bitmap と、変換行列を渡すことで変換後の Bitmap を返してくれるメソッドです。

```java:Bitmap.java#L766
public static Bitmap createBitmap(@NonNull Bitmap source, int x, int y, int width, int height, @Nullable Matrix m, boolean filter)
```
ref: https://github.com/aosp-mirror/platform_frameworks_base/blob/oreo-release/graphics/java/android/graphics/Bitmap.java#L766

実はこのメソッドも内部的には Canvas を使っています。

```java:Bitmap.java#L856
...
Canvas canvas = new Canvas(bitmap);
canvas.translate(-deviceR.left, -deviceR.top);
canvas.concat(m);
canvas.drawBitmap(source, srcR, dstR, paint);
canvas.setBitmap(null);
...
```
ref: https://github.com/aosp-mirror/platform_frameworks_base/blob/oreo-release/graphics/java/android/graphics/Bitmap.java#L856

実はこのメソッドも今回の挙動の変更に影響を受けていました。

このファイルのコミット履歴を見ると、今回の挙動の変更に対して修正を入れていることが分かります。
translate() などの処理をする前にコンストラクタで Bitmap をセットするように修正されています。

> Modify createBitmap w/ crop and matrix to not rely on Canvas.setBitmap
>
> commit 29cd3e922612afff4cd5fa9694013e5e8ae93661
> drWulf committed on 16 Mar 


ref: https://github.com/aosp-mirror/platform_frameworks_base/commit/29cd3e922612afff4cd5fa9694013e5e8ae93661#diff-dc086aaa07dcf7c01d3ec78f7229afc5

## 小話２：Android O プレビュー版での変更
この挙動の変更は Android O Developer Preview 2 で最初に加えられたようです。

しかし、その時点では targetSdkVersion をチェックせず、すべての targetSdkVersion で setBitmap() 
によって変換行列がリセットするように修正されていました。

その後、Android O Developer Preview 3 にて targetSdkVersion によって挙動が変わるように変更されたようです。

一連の修正の痕跡は「コミット」⇒「リバート」⇒「再コミット」⇒「targetSdk チェックの追加」という形でコミットログから見ることができます。

> Add an O-release targetAPI check for Canvas.setBitmap.
> drWulf committed on 6 May
>
> Change behavior of setBitmap to cleanly reset the canvas 
> drWulf committed on 12 Apr
>
> Revert "Change behavior of setBitmap to cleanly reset the canvas - id… 
> Tony Mantler committed on 15 Mar
>
> Change behavior of setBitmap to cleanly reset the canvas 
> reed-at-google committed on 2 Mar 


ref: https://github.com/aosp-mirror/platform_frameworks_base/commits/oreo-release/graphics/java/android/graphics/Canvas.java

おそらくは Developer Preview でのレビューで影響をうけるアプリが多数あり、一度リバートして targetSdkVersion をチェックするように再度修正されたのだと思われます。

（実際、Android の Bitmap.java も影響を受けていました）