Edited at

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