サムネイルを並べた瞬間にスクロールが詰まる
私は2014年から個人開発を続けていて、壁紙アプリは累計5,000万ダウンロードを超えるまでになりました。長く運用していると、初期のコードがそのまま残っている箇所が必ず出てきます。今回つまずいたのもそこでした。
サムネイルの一覧画面で、画像が数十枚を超えたあたりからスクロールが目に見えてカクつくのです。指を離した後の慣性スクロールが小刻みに引っかかり、端末によってはメモリ警告まで出る。手元のiPhoneで計測すると、一覧を一度開いただけでアプリのメモリ使用量が約480MBまで跳ね上がっていました。スクロール中のフレームレートも40fps前後まで落ち込み、ProMotion対応端末の滑らかさが完全に殺されていました。壁紙という性質上、扱う画像が大きいことは分かっていましたが、サムネイルの表示にここまで食うのは明らかに何かがおかしい。
原因はメインスレッドでのフルサイズデコードだった
コードを追っていくと、原因は二つ重なっていました。
ひとつは、UICollectionView の cellForItemAt の中で画像を同期的にデコードしていたこと。UIImage(contentsOfFile:) は一見すると軽い処理に見えますが、実際にピクセルへ展開(デコード)されるのは画像が描画される瞬間です。つまりスクロールで新しいセルが出てくるたびに、メインスレッド上で重いデコードが走っていたわけです。これがフレーム落ちの直接的な原因でした。
もうひとつは、サムネイル表示なのにフルサイズ画像をそのまま読み込んでいたこと。表示領域はせいぜい一辺200ポイント程度なのに、3000ピクセル四方を超える元画像をメモリ上に展開していました。展開後のビットマップは「幅 × 高さ × 4バイト」で効いてくるので、たとえば3000×3000の画像は1枚で約36MBになります。これを画面ぶん何枚も保持すれば、あっという間に数百MBです。これがメモリ膨張の正体でした。
公式のサンプルでも UIImage(named:) や contentsOfFile: がよく登場しますが、一覧表示で大量に扱う場面では、このまま使うと破綻します。公式ドキュメントには「一覧では縮小してから持て」と明示されていないのが、この落とし穴にハマる人が多い理由だと思います。表示に必要なサイズまで「読み込む段階で」縮小しておくのが実務上の正解です。
ImageIOでダウンサンプリングする
縮小の鍵になるのが ImageIO の CGImageSourceCreateThumbnailAtIndex です。UIImage を作ってから draw(in:) でリサイズする方法もありますが、それだと一度フルサイズで展開してしまうため、メモリのピークを抑えられません。私は ImageIO を使う方を強く推奨します。デコードと縮小をまとめて行い、必要な解像度のビットマップだけを生成できるからです。
import ImageIO
import UIKit
func downsampledImage(at url: URL, pointSize: CGSize, scale: CGFloat) -> UIImage? {
let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else {
return nil
}
let maxPixel = max(pointSize.width, pointSize.height) * scale
let thumbnailOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixel
] as CFDictionary
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions) else {
return nil
}
return UIImage(cgImage: cgImage)
}
ポイントは三つあります。kCGImageSourceShouldCache: false でソース生成時の不要なキャッシュを避け、kCGImageSourceShouldCacheImmediately: true でこの呼び出しの中(=バックグラウンドスレッド)でデコードまで済ませてしまうこと。そして kCGImageSourceThumbnailMaxPixelSize に「ポイントサイズ × スケール」を渡すことで、Retinaでも過不足ない解像度に縮小できます。kCGImageSourceCreateThumbnailWithTransform: true を入れておくと、EXIFの回転情報も反映されるので、横向き写真が90度倒れて表示される事故も回避できます。
バックグラウンドキューで呼んでメインに戻す
ダウンサンプリング自体は重い処理なので、必ずメインスレッドの外で走らせます。呼び出し側はシンプルです。
final class ThumbnailLoader {
private let queue = DispatchQueue(label: "thumbnail.downsample", qos: .userInitiated, attributes: .concurrent)
private let cache = NSCache<NSURL, UIImage>()
func loadThumbnail(url: URL, pointSize: CGSize, completion: @escaping (UIImage?) -> Void) {
if let cached = cache.object(forKey: url as NSURL) {
completion(cached)
return
}
queue.async { [weak self] in
let scale = UIScreen.main.scale
let image = downsampledImage(at: url, pointSize: pointSize, scale: scale)
if let image = image {
self?.cache.setObject(image, forKey: url as NSURL)
}
DispatchQueue.main.async { completion(image) }
}
}
}
NSCache を挟むのは、一度縮小した画像を作り直さないためです。NSCache はメモリ逼迫時に自動でオブジェクトを破棄してくれるので、自前でメモリ管理を書くより安全です。壁紙アプリのように画像点数が読めない場面では、この「勝手に手放してくれる」性質が効きます。qos を .userInitiated にしているのは、ユーザーがスクロールして待っている操作だからで、個人的には .utility まで落とすと体感で遅く感じたため、ここは実際に両方試して決めました。
プリフェッチとセル再利用のキャンセル
スクロールをさらに滑らかにするには、UICollectionViewDataSourcePrefetching を使って、表示される少し前から縮小を始めておきます。
extension GalleryViewController: UICollectionViewDataSourcePrefetching {
func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
let url = items[indexPath.item].fileURL
loader.loadThumbnail(url: url, pointSize: cellSize) { _ in }
}
}
}
あわせて忘れてはいけないのが、セルが再利用されるときに古い読み込みを捨てることです。スクロールが速いと、まだ縮小が終わっていないセルが別の画像に使い回されます。完了ハンドラの中で「このセルが今もこのindexPathを表示しているか」を確認し、ずれていたら結果を破棄します。
func collectionView(_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Thumb", for: indexPath) as! ThumbCell
let url = items[indexPath.item].fileURL
cell.representedURL = url
loader.loadThumbnail(url: url, pointSize: cell.bounds.size) { image in
guard cell.representedURL == url else { return } // 再利用でずれていたら破棄
cell.imageView.image = image
}
return cell
}
これをやらないと、一瞬だけ違う画像がちらつく、いわゆる「画像の入れ替わり」という落とし穴にはまります。私も最初これを入れ忘れていて、速くスクロールすると全然関係ない壁紙が一瞬出る、という不具合に悩まされました。本番のアプリで実際にレビューでも指摘された箇所なので、ここは確実に回避しておくことをお勧めします。
私が踏んだ手順を整理すると
同じ症状に当たった方のために、私が実際に進めた順番をまとめておきます。
- Instruments の Time Profiler でスクロール中のメインスレッドを計測し、デコードが重いことを特定する
cellForItemAt 内の同期デコードを洗い出す
- ImageIO のダウンサンプリング関数を用意し、表示サイズに合わせて縮小する
- 縮小処理をバックグラウンドキューへ移し、結果をメインで反映する
NSCache で縮小済み画像をキャッシュする
- プリフェッチと再利用キャンセルを足して、スクロールの先読みとちらつき防止を仕上げる
この順で進めると、どの一手がどれだけ効いたかを切り分けながら確認できます。
実測でどれだけ変わったか
この構成に入れ替えたところ、一覧を開いた直後のメモリ使用量は約480MBから約90MBまで落ちました。割合にすると約80%の削減で、ピーク時のメモリは約5倍も軽くなった計算です。フルサイズのビットマップを保持しなくなったぶんが、ほぼそのまま効いた形です。スクロールも、メインスレッドからデコードを追い出したことで40fps前後から60fpsへ戻り、引っかかりが体感でなくなりました。Instrumentsで見ても、スクロール中のフレーム落ちが大きく減っています。
数字は端末や画像点数で変わりますが、「フルサイズで展開しない」「デコードをメインスレッドから外す」という二点を押さえるだけで、ここまで変わるという手応えがありました。
手を動かして直すということ
私の祖父は二人とも宮大工でした。木を一本ずつ手で削って組み上げていく姿を見て育ったので、「丁寧に手を入れることそのものが、ものづくりの信心のようなものだ」という感覚が自分の根っこにあります。パフォーマンス改善は派手な機能追加ではありませんが、ユーザーが毎日触る画面の手触りを静かに整える作業で、私はこういう仕事が好きです。
もし同じように一覧のカクつきで悩んでいる方がいたら、まずは cellForItemAt の中で何をデコードしているかを疑ってみてください。多くの場合、答えはそこにあります。お読みいただきありがとうございました。