LoginSignup
107
73

More than 5 years have passed since last update.

iOSの画像ローダーの選定とキャッシュについて -こうして私はNukeを選んだ-

Posted at

前提

新規プロジェクトを立ち上げる際に、画像ローダーという別にどれでも良さそうだけどほぼ必ず利用しなければならないライブラリを選定する上で、最終的な判断をするまでの経緯と組み込んだ時に得た知見をまとめます。

この記事はNuke6.0-beta1の時点で書かれています。

候補としてはNukeかKingfisher

前のプロジェクトでも使っていたこともあり、自分の中ではとりあえずKingfisherが第一候補でしたが、使わなかった理由は以下のような感じです。

UIImageViewのextensionは必要ない

UIImageViewに取得処理を追加して画像取得後に自動的に表示させるよりも、画像が取得できたらキャッシュや加工など一連の処理を行なった後にUIImageViewに表示するという流れにしたほうが、色々と都合が良いことの方が多いのです。

例えば、キャッシュ処理を独自のものにしたいとか、あるいは画像データだけ通信処理を事前に実行して取得しておくだとか、そういったカスタマイズをextensionで追加された処理に対して行うとなると容易ではありません。

最近のプロジェクトではReactiveプログラミング前提でRxSwiftなどのライブラリが導入されることもあるかと思いますが、これらのライブラリを使って画像の表示処理を行うと、役割をきれいに分けることと、実行中のスレッドの変更や処理の破棄を行うことが手軽に行えるようになるのでおすすめです。

ということで結局はKingfisherManagerしか使っていなかった

上記の理由から、画像のDLやCacheを行なってくれるKingfisherManagerextensionで処理を追加し、画像URLが取得できたらその処理に流すような実装にして、UIImageViewextensionは使わずにいました。

以下にその時の処理の一部を記載しておきます。

extension KingfisherManager: ReactiveCompatible {}

extension Reactive where Base: KingfisherManager {
    func retrieveImage(with resource: ImageResource, options: KingfisherOptionsInfo? = nil) -> Observable<UIImage?> {
        return Observable.create { observer in
            let task = self.base.retrieveImage(with: resource, options: options, progressBlock: nil, completionHandler: { image, error, _, _ in
                if let error = error {
                    observer.onError(error)
                    return
                }

                observer.onNext(image)
                observer.onCompleted()
            })

            return Disposables.create {
                task.cancel()
            }
        }
    }
}

画像が取得できたらUIImageViewに表示させます。

imageResource.asObservable()
    .flatMap { KingfisherManager.shared.rx.retrieveImage(with: $0) }
    .observeOn(MainScheduler.instance)
    .bind(to: imageView.rx.image)
    .disposed(by: disposeBag)

※コピペでは動きません

表示処理は、見た目でも何が起こっているかが分かりやすいと思います。

SDWebImageという古いライブラリにインスパイア

Kingfisher自体、Swiftが公開されたタイミングを考えるとかなり歴史のあるライブラリだと言えます。当時の画像ローダーのさきがけとも言える存在ですが、その実装はObjective-Cの実装でもともと有名だったSDWebImageを模して作られたものと言っても過言では無いと思います。

最近では色々改善されているとは言え、元の設計思想が変わらない限りはそれ以上のスケールは難しく、カスタマイズも容易ではないため沢山の機能が詰まってしまっているように感じます。

Nukeを選んだ理由

まず、Swift製の中から選ぼうという思いが前提としてあり、あとはGitHub上でのスター数やコントリビューター数、情報量などを加味した上で候補としてはNukeKingfisherかなぁといった感じでした。AlamofireImageも使わなそうな処理が多そうだったので候補から外しています。

それで言うとNukeに関しては日本語の情報量はそこまで多くはなかったのですが、以下のような部分を考慮して選びました。

ちなみに、車輪の再発明上等で欲しい部分だけを自作しようかとも思いましたが、その欲しいものがNukeにはある程度揃ってた感じです。

画像の取得速度が速いとの噂

Swiftの有名画像キャッシュライブラリを比較してみた」にあったのですが、1.画像を表示する際、初期表示の画像取得速度が他のライブラリに比べ速いということだったので、自分で検証することはなく鵜呑みにしています。

検証時の通信状況とかどんな感じだったのかなど詳細はわからないですが、この件に関しては処理が増えれば増えるほど遅くなるはずなので、よほど変な処理が行われていなければシンプルなものがより良いのではないかと思います。

カスタマイズが可能

基本的にprotocolに準拠したオブジェクトを渡しているので、独自のオブジェクトもそのprotocolにさえ準拠してしまうだけでいいのでインジェクションが容易で部分的なカスタマイズに耐えうる設計になっています。

例えば画像ロードを行うためのprotocolLoadingというもので、それに準拠したクラスがLoaderになっています。このクラスはシングルトンオブジェクトを持っていてそのオブジェクトが実際に使いまわされていますが、更に内部でデータ取得用のDataLoadingというprotocolに準拠したクラスをLoaderのシングルトンオブジェクトの初期化時にインスタンス化して渡しています。

このDataLoadingに準拠したクラスを独自に用意してあげることで、通信部分をAlamofireに置き換えるようなことも可能になります。

import Alamofire
import Nuke

class AlamofireDataLoader: Nuke.DataLoading {
    private let manager: Alamofire.SessionManager

    init(manager: Alamofire.SessionManager = Alamofire.SessionManager.default) {
        self.manager = manager
    }

    // MARK: Nuke.DataLoading

    func loadData(with request: Nuke.Request, token: CancellationToken?, completion: @escaping (Nuke.Result<(Data, URLResponse)>) -> Void) {
        // Alamofire.SessionManager automatically starts requests as soon as they are created (see `startRequestsImmediately`)
        let task = self.manager.request(request.urlRequest).response(completionHandler: { (response) in
            if let data = response.data, let response: URLResponse = response.response {
                completion(.success((data, response)))
            } else {
                completion(.failure(response.error ?? NSError(domain: NSURLErrorDomain, code: NSURLErrorUnknown, userInfo: nil)))
            }
        })
        token?.register {
            task.cancel() // gets called when the token gets cancelled
        }
    }
}

Third Party Librariesより抜粋

Loaderクラスのシングルトンオブジェクトは更にManagerクラスの初期化時に渡され、Managerクラスのシングルトンオブジェクトで保持されていますが、これらのインスタンスを新たに作って保持してしまえば独自の処理が行えるようになります。

let loader = Nuke.Loader(loader: AlamofireDataLoader())
let manager = Nuke.Manager(loader: loader, cache: Cache.shared)

manager.loadImage(with: url, into: imageView)

Third Party Librariesより抜粋

上記の処理はプラグインとしても公開されているようです。

また、「URLCacheは十分なパフォーマンスが出ないよ(意訳)」とNukeの作者は言っていますが、必要ならばディスクキャッシュもこのようにカスタマイズして置き換えることが前提となっているようです。

webpも行けるよとissueに上がってた

webpを使うことで画像取得コストを減らすことができるので試してみたいのですが、iOSではサポートされていないのでその実装を組み込む必要があります。ハードウェアデコードがサポートされていないのでコストを考えたら導入は微妙かもしれませんが、組み込むことが可能かどうかという問題に関しては解決できると思うよ、ってことみたいでした。

参考)WebP support #49

ディスクキャッシュを利用する上での注意点

Nukeはメモリキャッシュに関する処理は独自に組み込まれていますが、ディスクキャッシュに関してはURLCacheに任されています。キャッシュを返すかどうかはURLRequestの中で色々な判定が入った後に決まりますが、URLRequestCachePolicyが初期値のままでは積極的にはキャッシュを返してはくれません。

http_caching_2x_820a949f-9d5d-4a85-9ca2-50b42b339e18.png

NSURLRequest.CachePolicyより

例えば、Twitterのプロフィール画像などを取得してみると、max-ageExpiresも設定されているのにオフライン環境ではURLRequestはキャッシュを返してくれません(must-revalidateが設定されているからかも)。画像のような静的なリソースに関してはなるべくキャッシュを活用したいので、キャッシュがローカルにあればとりあえず返してくれるような処理をしたい場合、.returnCacheDataElseLoadあるいは.returnCacheDataDontLoadをセットする必要があります。

.returnCacheDataDontLoadをセットすると、次はキャッシュがない場合の通信処理が行われなくなるので.returnCacheDataElseLoadをセットしてあげるとキャッシュがない場合に通信処理を行なってくれるようになります。

URLCacheを使う

ということでURLRequestCachePolicyを見直したはいいんですが、.returnCacheDataElseLoadをセットした時の問題としてキャッシュデータがURLCacheの上限(実際には2~3分の1くらい)に達したときにしかキャッシュが消えません。上限をどれくらいにするのが良いのかはサービスが扱うデータサイズにもよりますし、ユーザの操作方法にも依存するというアンコントローラブルな状態です。

キャッシュが消えるまでは同じURL先のコンテンツの変更を取り込むことはできないという辛い状況になってしまうので、個人的にはレスポンスヘッダに含まれる有効期限の間だけはキャッシュを返して欲しいところです。

ディスクキャッシュライブラリの導入も検討したのですが、それはそれで面倒なのでURLCacheが有効期限に準拠するように手を加えてみました。

HTTPレスポンスヘッダの有効期限に従う方法

まずはHTTPURLResponseからレスポンスヘッダを取得して、キャッシュの有効期限を確認する必要があります。有効期限に関わる値は、ExpiresあるいはCache-Controlmax-ageを参照して判断します。

PageSpeed Insightsのドキュメントによると、レスポンスの有効期限の設定は「Expiresをおすすめします」とのことなので、クライアントとしてもまずはExpiresの値で判断し、判断できなかった場合にmax-ageの値で判断できれば問題無さそうです。

struct CacheControl {
    var maxAge: Int?
}

struct Header {
    var cacheControl: CacheControl?
    var date: Date?
    var expires: Date? {
        // Expiresの値か、date + cacheControl.maxAgeの値を返す
    }
}

extension HTTPURLResponse {
    var header: Header
}

上記のような構造体を用意しておいて、expiresget内でExpiresの値かあるいはdateに対してmax-ageの値を加えて返すような実装にできればいいと思います。

次に、URLCacheのサブクラスを作って、キャッシュを保持する処理とキャッシュを返す処理を上書きします。

キャッシュを保持する処理では有効期限が過ぎているデータは保持する必要が無いので、レスポンスヘッダの有効期限を見て必要なものだけキャッシュするようにします。

override func storeCachedResponse(_ cachedResponse: CachedURLResponse, for request: URLRequest) {
    if let date = (cachedResponse.response as? HTTPURLResponse)?.header.expires, Date() < date {
        super.storeCachedResponse(cachedResponse, for: request)
    }
}

キャッシュを取り出す際、有効期限が過ぎているキャッシュは参照させないようにするため、その値を返さないように変更します。

override func cachedResponse(for request: URLRequest) -> CachedURLResponse? {
    let cache = super.cachedResponse(for: request)

    if let date = (cache?.response as? HTTPURLResponse)?.header.expires, Date() < date {
        return cache
    }

    return nil
}

これでキャッシュに対して有効期限を設けることができました。

expiresとの比較にDate()をそのまま使っていますが、ここを変更することで例えば1週間は問答無用でキャッシュを保存したり返したりするように変更することも可能になります。

ちなみに、有効期限が過ぎてしまっているキャッシュは削除したいのですが、URLCacheに用意されている削除系の以下のメソッドは機能しませんでした :innocent:

func removeCachedResponse(for request: URLRequest)

ちゃんとした消し方があるのかな?キャッシュ上限まで行ったら古いデータから削除されるようなので、ここでは一旦無視しておきます。

キャッシュの全削除は機能したので、必要であればそちらを使うことをおすすめします。

func removeAllCachedResponses()

ローダーの作成

最後に、キャッシュ処理を上書きしたCacheクラスを利用したURLSessionConfigurationを作成し、それを利用できるようにします。

キャッシュ処理

import Foundation

class Cache: URLCache {
    override func cachedResponse(for request: URLRequest) -> CachedURLResponse? {
        let cache = super.cachedResponse(for: request)

        if let date = (cache?.response as? HTTPURLResponse)?.header.expires, Date() < date {
            return cache
        }

        return nil
    }

    override func storeCachedResponse(_ cachedResponse: CachedURLResponse, for request: URLRequest) {
        if let date = (cachedResponse.response as? HTTPURLResponse)?.header.expires, Date() < date {
            super.storeCachedResponse(cachedResponse, for: request)
        }
    }
}

画像ローダー

static var defaultConfiguration: URLSessionConfiguration {
    let urlCache = DataLoader.sharedUrlCache
    let conf = URLSessionConfiguration.default
    // sharedUrlCacheのdiskPathが参照不可のため、文字列を直接設定
    conf.urlCache = Cache(memoryCapacity: urlCache.memoryCapacity, diskCapacity: urlCache.diskCapacity, diskPath: "com.github.kean.Nuke.Cache")
    // .returnCacheDataElseLoadをセットして積極的にキャッシュを利用する
    conf.requestCachePolicy = .returnCacheDataElseLoad
    return conf
}

let loader = Nuke.Loader(loader: Nuke.DataLoader(configuration: defaultConfiguration))
let manager = Nuke.Manager(loader: loader, cache: Cache.shared)

FoundationURLCacheをほぼそのまま使う形なので、導入も簡単にできたと思います。もし実際に組み込む場合は、プロダクトにあった形に最適化していただければいいと思います。

上記判定を行うことができる処理を含んだCacheライブラリを実装しましたので、よろしければ使ってもらえると幸いです。

107
73
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
107
73