Android
AndroidO

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

More than 1 year has passed since last update.

この記事は?

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 では特に触れられていません。

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

実際の挙動

アプリ内にある画像を Bitmap にして Canvas を使って並行移動するアプリを作成しました(CanvasSetBitmapTester)。
[Translate] というボタンを押すと画像を右下に並行移動するアプリです。

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

端末

  • Nexus 5X, Android 8.0.0

コード

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

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 のままであることに注意が必要です。

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 だけ画像が平行移動します。

押す前

押した後

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

targetSdkVersion>=26

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

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] を押しても画像が右下に平行移動しません。

押す前

押した後

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

修正

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

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 には次のように書かれています。

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 として意図したものであることが分かります。

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

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 のコンストラクタで次のように決定されています。

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 を返してくれるメソッドです。

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 を使っています。

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

小話2: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 も影響を受けていました)