iOS App Performanceの改善を行いました(1) - Image Memory Cache

はじめに

こんにちは アプリ開発チームの@レイです

最近約1ヶ月間、RettyのiOSアプリパフォーマンスの改善について集中的に作業しました。 本記事では、

  • 今回の改善の必要性について
  • どのような改善をしたのか
  • どのような効果があったのか

上記について2章にわたってご紹介しようと思います。

画像のキャッシュを管理するマネージャーの導入背景

Rettyアプリでは数多くの投稿写真を表示しているので、写真に対してキャッシュメモリーを多く使っています。

それによってメモリーが多く消費されてしまう可能性が高いのですが、 これはパフォーマンス的に遅くなる上、メモリー不足問題につながってアプリがクラッシュする問題がありました。

よって、周期的にキャッシュを削除する機能が必要でした。 そのため、下記のようなキャッシュマネージャーを作ろうとしました。

  • Default キャッシュだけで管理するのではなく、1いくつかの特殊な場合の画面についてはキャッシュを別々に管理する
  • キャッシュに対して設定と特定のキャッシュを削除するなどのコントロールを行う
  • Singletonパターンで管理する

Image Cache Manager

iOSでは非同期写真のライブラリーとしてKingfisherを利用しています。
Kingfisherでは、すでにキャッシュ機能がある程度入っているので、簡単にマネージャーを作ることができました。

そのため、下記のコードでキャッシュマネージャーを構成しています。

ImageCacheManagerのコード

import Foundation
import Kingfisher

typealias ImageCacheType = ImageCacheManager.ImageCacheType

final class ImageCacheManager {
    static let shared = ImageCacheManager()

    enum ImageCacheType: String {
        case restaurantCache = "restaurant-top-cache.retty.me"
        case reportCache = "report-detail-cache.retty.me"
        case defaultCache

        func getCache() -> ImageCache {
            switch self {
            case .restaurantCache:
                return ImageCacheManager.shared.restaurantCache
            case .reportCache:
                return ImageCacheManager.shared.reportCache
            case .defaultCache:
                return ImageCacheManager.shared.defaultCache
            }
        }
    }

    private let restaurantCache: ImageCache
    private let reportCache: ImageCache
    private let defaultCache = ImageCache.default

    init() {
        // 100 mb memory, 60 sec to expiration
        let megabyte: Int = 1024 * 1024
        restaurantCache = ImageCache(name: ImageCacheType.restaurantCache.rawValue)
        restaurantCache.memoryStorage.config.totalCostLimit = 100 * megabyte
        restaurantCache.memoryStorage.config.countLimit = 100
        restaurantCache.memoryStorage.config.cleanInterval = 60
        restaurantCache.memoryStorage.config.expiration = .seconds(60)
        restaurantCache.diskStorage.config.sizeLimit = UInt(300 * megabyte)

        reportCache = ImageCache(name: ImageCacheType.reportCache.rawValue)
        reportCache.memoryStorage.config.totalCostLimit = 100 * megabyte
        reportCache.memoryStorage.config.countLimit = 100
        reportCache.memoryStorage.config.cleanInterval = 60
        reportCache.memoryStorage.config.expiration = .seconds(60)
        reportCache.diskStorage.config.sizeLimit = UInt(300 * megabyte)

        defaultCache.memoryStorage.config.totalCostLimit = 300 * megabyte
        defaultCache.memoryStorage.config.countLimit = 100
        defaultCache.memoryStorage.config.cleanInterval = 30
        defaultCache.memoryStorage.config.expiration = .seconds(60)
        defaultCache.diskStorage.config.sizeLimit = UInt(500 * megabyte)
    }

    func clearCache(_ types: [ImageCacheType]) {
        for type in types {
            switch type {
            case .restaurantCache:
                restaurantCache.clearMemoryCache()
            case .reportCache:
                reportCache.clearMemoryCache()
            case .defaultCache:
                defaultCache.clearMemoryCache()
            }
        }
    }

    func removeExpiredCache() {
        restaurantCache.memoryStorage.removeExpired()
        reportCache.memoryStorage.removeExpired()
        defaultCache.memoryStorage.removeExpired()
    }
}

それぞれのCacheタイプを利用している画面の例

ホーム(defaultCache)
店舗詳細(restaurantCache)
投稿詳細(reportCache)

画像に対するキャッシュ保存設定

例-UIKitの画像設定コード

public func rt_setImage(
        withURL url: URL?,
        placeholderImage: UIImage? = nil,
        processors: [ImageProcessor] = [],
        fade: TimeInterval = 0.4,
        targetCache: ImageCache? = nil,
        completion: ((UIImage?) -> Void)? = nil
    ) {
        image = placeholderImage
        guard let url = url else {
            return
        }
        let cache: ImageCache
        if let targetCache = targetCache {
            cache = targetCache
        } else {
            cache = ImageCacheType.defaultCache.getCache()
        }

        let kf = KF.url(url)
            .targetCache(cache)
            .diskCacheExpiration(.seconds(60))
            .memoryCacheExpiration(.seconds(60))
            .onSuccess { result in completion?(result.image) }
            .placeholder(placeholderImage)
            .fade(duration: fade)

        processors.forEach {
            _ = kf.setProcessor($0)
        }

        kf.set(to: self)
    }

例-SwiftUIの画像設定コード

KFImage(URL(string: ""))
        .targetCache(ImageCacheType.restaurantCache.getCache())

上記のようにパラメータでImageCacheTypeを伝えると、ダウンロードする際にそのキャッシュに保存されるように指定しています。

キャッシュ削除

例-キャッシュ削除コード

ImageCacheManager.shared.clearCache([.restaurantCache, .reportCache])
ImageCacheManager.shared.removeExpiredCache()

これにより画面遷移を行なったりメモリーに対する初期化が必要な場合によっては上記のようなコードで満了したキャッシュを削除したり、これ以上キャッシュを必要としてないキャッシュタイプに対して削除を行っています。

今後改善するもの

1. SwiftUI

KFImageは内部にImageBinding Instanceを持っていますが、これにはKFCrossPlatformImageと言うImageTypeが入っています。

問題は、これはUITableViewやUICollectionViewのセルが消えてもMemory Freeにならないです。 したがって、今後はSwiftUIではAsyncImageを利用しようと思います。 ただこれはキャッシュ機能は入っていないので、直接キャッシュ機能を作る必要があります。

2. Disk Cache

現在全てのキャッシュについてはメモリーに保存しているため、2自分のデータ(例:自分の投稿写真)などについては非効率的に管理しています。 したがって、自分の写真データについてはメモリー上ではなく、ディスクに保存するようにして、ディスクから写真を持ってくるように改善しようと思っています。

3. まだCache Typeが追加されていない他の画面についてもImage Cache Typeを追加する

まだ初期段階なので、いくつかの画面に対してのみ開発されています。 したがって、対応が必要な画面については、追加的にメモリー管理ができるようCache Typeを追加していく予定です。

効果

Before

After

予想通り、画面が使わなくなった時や、画像のキャッシュが必要なくなった場合にメモリーリリースされることがわかりました。
あとは設定したメモリーサイズよりキャッシュを保存しないことも確認できました👍

結果、ユーザーさんがより安定したアプリを使うことができるようになりました!🚀

  • このテスト結果は数百件の写真がある店舗で写真を全部表示し、お店の画面を閉じたときのメモリー情報です
  • Before / Afterテスト結果はImageCacheManagerをOff / Onした時の挙動で、表示したお店と画像は同じです。
  • 画面のメモリーリークはありません

おわりに

今回の記事ではパフォーマンス改善のためのSTEP第一として画像キャッシュマネージャーについてご紹介しました。

結果としては、各画面で使用しているイメージキャッシュメモリーに対して適切に管理ができるようになり、 よりアプリが効率的に動作できるようになりました。

次の章ではDiffableDataSourceを導入して、リスト画面でのパフォーマンス改善を行った話や、 具体的に1ヶ月間でどれぐらい改善ができたのかについて話をしようと思います。

ぜひ、お楽しみにしてください!

アプリ開発チームのメンバーとMeetyでお話しできます!本記事でRettyのアプリ開発チームについて興味が湧きましたら、ぜひMeetyで気軽にお話ししましょう!

meety.net


  1. ホームの画面以外の画面は基本的に閉じたら、そこで使われた画像キャッシュは特に要らないので別のキャッシュとして管理する

  2. 自分のデータは他のユーザーのデータと違ってアクセスする場合が多いので、メモリーキャッシュよりディスクキャッシュの方が良いと判断しています。