downsampling
UICollectionView で大量の画像を表示する場合や、大きな画像データを縮小して表示する場合等に、適切な実装をしないとメモリ使用量や CPU 使用量を増加させてしまう恐れがある。CPU 使用量が増加すると、バッテリーの寿命が縮む上にパフォーマンスも低下する。メモリ使用量が増加すると CPU 使用量の増加にもつながるため、バッテリーやパフォーマンスに影響する。 https://gyazo.com/3ac0be0c49a8c8fa43f44ba62ee6d2a9
UIImage を UIImageView に渡し表示するだけの関係に見えるが、実際はもう少し複雑なことをやっている。特に重要なのが、docode と呼ばれる工程になる。この工程がアプリのパフォーマンスに大きく影響する。 https://gyazo.com/673c7a31544915b02eade758787e3049
バッファ
あるデータを格納するためにメモリ上に確保された領域を バッファ と呼ぶ。今回特に重要な以下の 3 種類のバッファについて説明する。
Image Buffer
Frame Buffer
Data Buffer
ある画像データに対し、そのピクセル情報を保持したバッファを Image Buffer と呼ぶ。画像の各ピクセルの色と透明度を表す情報を格納する。ピクセル単位の情報をそのまま保持しているため、Image Buffer のサイズは画像サイズに比例する。
ディスプレイに表示するための画像のピクセル情報を保持したバッファを Frame Buffer と呼ぶ。Image Buffer は画像サイズに比例したサイズになるのに対し、Frame Buffer のサイズはディスプレイ上の画像の表示サイズに比例する。Frame Buffer はデバイスの refresh rate に応じてディスプレイに反映される。iPhone/iPad であれば 60 fps、iPad Pro であれば 120 fps の速度でディスプレイに反映されることになる。
任意のフォーマットの画像データの情報を保持したバッファを Data Buffer と呼ぶ。フォーマットには jpeg や png 等があり、通常、画像サイズ等のメタデータが先頭に格納されていて、その後にエンコード済みの画像データが続く。格納されているのはエンコード済みのデータであるため、ピクセル情報は直接格納されていない。
ディスク上の画像データは Data Buffer としてメモリ上にロードされ、UIImage はそれを保持する。が、そのままでは場面に描画できないので、Data Buffer から最終的に Frame Buffer を得る必要がある。 そのため、UIImage はまず Daba Buffer を画像と同じサイズの Image Buffer へと decode する。その後、UIKit により描画が要求されると、UIImageView は Image Buffer を、自身の contentMode に応じて拡大縮小した上で Frame Buffer にコピーする。 https://gyazo.com/0e137db189a91d7d85530efc37fbb3d4
大きな画像の decode は CPU 負荷も大きいため、UIImage は一度 decode すると Image Buffer をキャッシュする。その結果、アプリケーションは画像ごとに Image Buffer 分のメモリ割り当てが必要となる。Image Buffer は Data Buffer と同等のサイズになるため、最終的には拡大縮小前の大きな画像データ分のメモリが確保され続けることになる。
巨大なメモリ割り当てはフラグメンテーションを引き起こし、パフォーマンスを低下させる。また、OS が物理メモリの内容を圧縮し始めることで、アプリ内からは制御できないグローバルな CPU 使用率が増加し、最終的にはアプリケーションが物理メモリを大量に消費してしまい、OS によってプロセスが終了させられてしまう恐れもある。
Downsampling
3. サムネイルをデコードして、Image Buffer を作成する
4. UIImage を Image Buffer から作成する
Data Buffer がキャッシュされない
5. UIImageView で UIImage を描画する
実装的には、以下のようになる。
code:swift
func donwsampling(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage {
// デコード結果をキャッシュするかどうか。true だとデコードが行われてしまう
// この時点ではデコードはしないので、false にする
let imageSource = CGImageSourceCerateWithURL(imageURL as CFURL, imageSourceOptions)!
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
// サムネイル作成時のオプションを選択する
let downsampleOptions = [
// CGImageSource 内にサムネイルが存在しても、元画像からサムネイルを生成するかどうか
// CGImageSource オブジェクトが既にサムネイルを保持している場合があるらしい
// デフォルトは false であり、true にすると常に元画像からサムネイルを生成する
// サイズは kCGImageSourceThumbnailMaxPixelSize で指定したサイズとなり、
// サイズが未指定だと元画像と同じサイズでサムネイルが生成されてしまう
kCGImagesourceCreateThumbnailFromImageAlways: true,
// サムネイル作成時に即座にデコード結果をキャッシュするかどうか
// これがとても重要で、true にすると即座にデコードが行われる
// すると、data buffer を捨てられるようになる
kCGImageSourceShouldCacheImmediateley: true,
// 元画像の向きとアスペクト比に合わせて、サムネイルを回転/スケールさせるかどうか
// デフォルトはfalse
kCGImageSourceCreateThumbnailWithTransform: true,
// 作成するサムネイルの一辺の最長サイズを指定する
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
] as CFDictionary
let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, donwsampleOptions)!
return UImage(cgImage: downsampledImage)
}
Tips
CPU 使用量をスパイクさせると、バッテリーの寿命が減ってしまう
スパイクさせないためにできる工夫がいくつかある
prefetch を利用する
ただし、thread explosion は防ぐこと
単に GCD で global に処理を dispatch し続けると、スレッドが増え続ける CPU はスレッドを行き来しながら処理を進めるため、横断がオーバーヘッドになる
シリアルキューを使うなどで工夫すると良い
参考