Android 開発中によく考慮される問題の一つは OOM(Out Of Memory)、つまりメモリ不足です。一方で、大量の画像を読み込むと OOM が発生する可能性がありますが、サンプリングによって画像を圧縮することで OOM を回避できます。もう一方では、1024 x 768 ピクセルの画像が 128 x 96 の ImageView に縮小表示される場合、これは明らかに無駄です。適切な縮小版をメモリにサンプリングして読み込むことで、メモリの消費を減らすことができます。Bitmap の最適化には主に以下の二つの側面があります:
- 大きな画像を効果的に処理する
- ビットマップをキャッシュする
この記事は主に大きなビットマップを効果的に処理する方法に焦点を当てています。
さらに、Android でビットマップをサンプリングして縮小版をメモリに読み込む際に考慮すべき要素は何でしょうか?
- 完全な画像を読み込むために必要なメモリを推定する
- この画像を読み込むために必要なスペースが他のメモリ要求に与える影響
- 画像のターゲット ImageView または UI コンポーネントのサイズ
- 現在のデバイスの画面サイズまたは密度。
本文の内容は以下の通りです:
- ビットマップサンプリング
- Bitmap メモリ計算
- テスト効果
ビットマップサンプリング#
画像には異なる形状とサイズがあり、大きな画像を読み込むとメモリを消費します。ビットマップのサイズとタイプを読み込むために、さまざまなリソースからビットマップを作成するために、BitmapFactory クラスは多くのデコード方法を提供しています。画像データリソースに基づいて最も適切なデコード方法を選択します。これらの方法は、ビットマップを構築するためにメモリの割り当てを要求しようとするため、OOM 例外を引き起こす可能性があります。各タイプのデコード方法には、BitMapFactory.Options クラスを介してデコードオプションを指定できる追加の特徴があります。デコード時に inJustDecodeBounds を true に設定すると、メモリを割り当てることなく画像のサイズとタイプを読み取ることができます。以下のコードは、シンプルなビットマップサンプリングを実現しています:
/**
* ビットマップサンプリング
* @param res
* @param resId
* @return
*/
public Bitmap decodeSampleFromResource(Resources res, int resId){
//BitmapFactoryのオプションを設定
BitmapFactory.Options options = new BitmapFactory.Options();
//サンプリング比率を設定
options.inSampleSize = 200;
Bitmap bitmap = BitmapFactory.decodeResource(res,resId,options);
return bitmap;
}
注意:他の decode... メソッドは decodeResource に似ていますが、ここでは decodeResource を例にしています。
実際の使用時には、具体的な幅と高さの要件に基づいて適切な inSampleSize を計算してビットマップのサンプリングを行う必要があります。たとえば、解像度が 2048 x 1536 の画像を inSampleSize 値 4 でエンコードして 512 x 384 の画像を生成する場合、ここではビットマップの設定が ARGB_8888 であると仮定すると、メモリに読み込まれるのは 0.75M であり、元の 12M ではありません。画像が占めるメモリの計算については後述します。以下は、必要な幅と高さに基づいてサンプリング比率を計算する方法です:
/**
* 1.ビットマップサンプリング比率を計算
*
* @param option
* @param reqWidth
* @param reqHeight
* @return
*/
public int calculateSampleSize(BitmapFactory.Options option, int reqWidth, int reqHeight) {
//画像の元の幅と高さを取得
int width = option.outWidth;
int height = option.outHeight;
int inSampleSize = 1;
if (width > reqWidth || height > reqHeight) {
if (width > height) {
inSampleSize = Math.round((float) height / (float) reqHeight);
} else {
inSampleSize = Math.round((float) width / (float) reqWidth);
}
}
return inSampleSize;
}
/**
* 2.ビットマップサンプリング比率を計算
* @param options
* @param reqWidth
* @param reqHeight
* @return
*/
public int calculateSampleSize1(BitmapFactory.Options options, int reqWidth, int reqHeight) {
//画像の元の幅と高さを取得
int height = options.outHeight;
int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
// 実際の幅と高さと目標の幅と高さの比率を計算
final int heightRatio = Math.round((float) height / (float) reqHeight);
final int widthRatio = Math.round((float) width / (float) reqWidth);
/**
* 幅と高さの最小の比率を inSampleSize の値として選択することで、最終的な画像の幅と高さが
* 必ず目標の幅と高さ以上になることを保証します。
*/
inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio;
}
return inSampleSize;
}
サンプリング比率を取得した後は、必要な幅と高さに基づいて大きな画像を処理できます。以下は、必要な幅と高さに基づいて計算された inSampleSize を使用して大きなビットマップをサンプリングする方法です:
/**
* ビットマップサンプリング
* @param resources
* @param resId
* @param reqWidth
* @param reqHeight
* @return
*/
public Bitmap decodeSampleFromBitmap(Resources resources, int resId, int reqWidth, int reqHeight) {
//ビットマップファクトリーのオプションを設定
BitmapFactory.Options options = new BitmapFactory.Options();
//この属性を true に設定すると、デコード時に width、height、mimeType のみを取得できます
options.inJustDecodeBounds = true;
//デコード
BitmapFactory.decodeResource(resources, resId, options);
//サンプリング比率を計算
int inSampleSize = options.inSampleSize = calculateSampleSize(options, reqWidth, reqHeight);
//この属性を false に設定して、実際のデコードを実現します
options.inJustDecodeBounds = false;
//デコード
Bitmap bitmap = BitmapFactory.decodeResource(resources, resId, options);
return bitmap;
}
デコードプロセスでは BitmapFactory.decodeResource () メソッドが使用され、具体的には以下の通りです:
/**
* 指定された ID のリソースファイルをデコード
*/
public static Bitmap decodeResource(Resources res, int id, BitmapFactory.Options opts) {
...
/**
* 指定された ID に基づいてデータストリームを開いてリソースを読み込み、
* 同時に TypeValue をコピーして元のリソースの density などの情報を取得します
* 画像が drawable-xxhdpi にある場合、density は 480dpi です
*/
is = res.openRawResource(id, value);
//入力ストリームからビットマップオブジェクトをデコードし、opts に基づいてビットマップをスケーリングします
bm = decodeResourceStream(res, value, is, null, opts);
...
}
明らかに、実際のデコードメソッドは decodeResourceStream () メソッドであり、具体的には以下の通りです:
/**
* 入力ストリームからビットマップをデコードし、そのビットマップを適切にスケーリングします
*/
public static Bitmap decodeResourceStream(Resources res, TypedValue value,
InputStream is, Rect pad, BitmapFactory.Options opts) {
if (opts == null) {
//デフォルトの Option オブジェクトを作成
opts = new BitmapFactory.Options();
}
/**
* inDensity の値が設定されている場合は、設定された inDensity に基づいて計算します
* そうでない場合は、リソースフォルダが示す density を inDensity に設定します
*/
if (opts.inDensity == 0 && value != null) {
final int density = value.density;
if (density == TypedValue.DENSITY_DEFAULT) {
opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
} else if (density != TypedValue.DENSITY_NONE) {
opts.inDensity = density;
}
}
/**
* 同様に、BitmapFactory.Option オブジェクトを使用して inTargetDensity を設定できます
* inTargetDensity は densityDpi を示し、つまり携帯電話の density です
* DisplayMetrics オブジェクトの densityDpi を使用して取得します
*/
if (opts.inTargetDensity == 0 && res != null) {
opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
}
//decodeStream() メソッド内でネイティブメソッドが呼び出されます
return decodeStream(is, pad, opts);
}
inDensity と inTargetDensity を設定した後、decodeStream () メソッドが呼び出され、このメソッドは完全にデコードされた Bitmap オブジェクトを返します。具体的には以下の通りです:
/**
* デコードされた Bitmap を返します
*/
public static Bitmap decodeStream(InputStream is, Rect outPadding, BitmapFactory.Options opts) {
...
bm = nativeDecodeAsset(asset, outPadding, opts);
//ネイティブメソッドを呼び出しました:nativeDecodeStream(is, tempStorage, outPadding, opts);
bm = decodeStreamInternal(is, outPadding, opts);
//Options に基づいて新しくデコードされたビットマップの密度を設定します
setDensityFromOptions(bm, opts);
...
return bm;
}
明らかに、decodeStream () メソッドは主にネイティブメソッドを呼び出して Bitmap のデコードを完了します。ソースコードを追跡すると、nativeDecodeAsset () と nativeDecodeStream () メソッドはどちらも doDecode () メソッドを呼び出しています。doDecode メソッドの重要なコードは以下の通りです:
/**
* BitmapFactory.cpp ソースコード
*/
static jobject doDecode(JNIEnv*env, SkStreamRewindable*stream, jobject padding, jobject options) {
...
if (env -> GetBooleanField(options, gOptions_scaledFieldID)) {
const int density = env -> GetIntField(options, gOptions_densityFieldID);
const int targetDensity = env -> GetIntField(options, gOptions_targetDensityFieldID);
const int screenDensity = env -> GetIntField(options, gOptions_screenDensityFieldID);
if (density != 0 && targetDensity != 0 && density != screenDensity) {
//スケーリング比率を計算
scale = (float) targetDensity / density;
}
}
...
//元のビットマップ
SkBitmap decodingBitmap;
...
//元のビットマップの幅と高さ
int scaledWidth = decodingBitmap.width();
int scaledHeight = decodingBitmap.height();
//density と targetDensity を考慮して最終的な幅と高さを計算
if (willScale && decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
scaledWidth = int(scaledWidth * scale + 0.5f);
scaledHeight = int(scaledHeight * scale + 0.5f);
}
...
//x、y方向のスケーリング比率は、ほぼ scale と等しい
const float sx = scaledWidth / float(decodingBitmap.width());
const float sy = scaledHeight / float(decodingBitmap.height());
...
//キャンバスを scale で拡大し、ビットマップを描画します
SkCanvas canvas (outputBitmap);
canvas.scale(sx, sy);
canvas.drawARGB(0x00, 0x00, 0x00, 0x00);
canvas.drawBitmap(decodingBitmap, 0.0f, 0.0f, & paint);
}
上記のコードでは、スケーリング比率の計算や、density と targetDensity が Bitmap の幅と高さに与える影響が確認できます。実際には、Bitmap が占めるメモリのサイズにも間接的に影響します。この問題については後述の例で説明します。density は現在の Bitmap に対応するリソースファイル(画像)のディレクトリに関連しています。たとえば、画像が drawable-xxhdpi ディレクトリにある場合、その Bitmap の density は 480dpi であり、targetDensity は DisplayMetric の densityDpi、つまり携帯電話の画面が示す density です。Android のネイティブメソッドの実装を確認するには、以下のリンクを参照してください:
BitmapFactory.cpp、ネイティブメソッドの名前を直接検索できますので、試してみてください。
Bitmap メモリ計算#
まず、大きな画像 6000 x 4000 を提供します。この画像は約 12M です。【クリックして取得】この画像を直接メモリに読み込むと、確実に OOM が発生します。もちろん、適切なビットマップサンプリングを行って画像を縮小することで OOM を回避できますが、Bitmap が占めるメモリはどのように計算されるのでしょうか。一般的には次のように計算します:
bitmap.getConfig () を使用して Bitmap のフォーマットを取得できます。ここでは ARGB_8888 です。この Bitmap フォーマットでは、1 ピクセルあたり 4 バイトを占めるため、4 を掛けます。画像を Android のリソースフォルダに配置する場合、計算方法は次のようになります:
上記は Bitmap が占めるメモリの計算方法を簡単にまとめたもので、検証時には次の方法を使用して Bitmap が占めるメモリサイズを取得できます:
選択したこの画像を直接読み込むと OOM が発生するため、以下の例ではすべてサンプリングして圧縮した後、Bitmap が占めるメモリの計算を行います。
直接サンプリング#
この方法は、サンプリング比率 inSampleSize の値を直接指定し、まずサンプリングしてからサンプリング後のメモリを計算します。ここでは inSampleSize を 200 に指定します。
- この画像を drawable-xxhdpi ディレクトリに配置します。この時、drawable-xxhdpi が示す density は 480(density)であり、私の携帯電話の画面が示す density は 480(targetDensity)です。明らかに、この時 scale は 1 です。まず画像をサンプリングし、その後画像をメモリに読み込みます。この時 Bitmap が占めるメモリは:
- この画像を drawable-xhdpi ディレクトリに配置します。この時、drawable-xhdpi が示す density は 320 であり、私の携帯電話の画面が示す density は 480(targetDensity)です。この時 Bitmap が占めるメモリは:
サンプリング計算#
この方法は、要求された幅と高さに基づいて適切な inSampleSize を計算するもので、inSampleSize を任意に指定するのではありません。実際の開発では、この方法が最も一般的です。ここでは要求される幅と高さを 100x100 とします。具体的な inSampleSize の計算は前述の通りです。
- この画像を drawable-xxhdpi ディレクトリに配置します。この時、drawable-xxhdpi が示す density は 480 であり、私の携帯電話の画面が示す density は 480(targetDensity)です。この時 Bitmap が占めるメモリは:
- この画像を drawable-xhdpi ディレクトリに配置します。この時、drawable-xhdpi が示す density は 320 であり、私の携帯電話の画面が示す density は 480(targetDensity)です。この時 Bitmap が占めるメモリは:
ビットマップサンプリングおよび Bitmap が異なる状況で占めるメモリの計算の大まかなプロセスは以上の通りです。
テスト効果#
テスト効果の画像は以下の通りです:
drawable-xhdpi | drawable-xxhdpi |
---|
|
Bitmap
ビットマップサンプリングとメモリ計算はこれで終了です。