Help us understand the problem. What is going on with this article?

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした