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

## 前提

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

この記事は`Nuke`の`6.0-beta1`の時点で書かれています。


## 候補としてはNukeかKingfisher

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

### UIImageViewのextensionは必要ない

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

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

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

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

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

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

```swift
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`に表示させます。

```swift
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上でのスター数やコントリビューター数、情報量などを加味した上で候補としては`Nuke`か`Kingfisher`かなぁといった感じでした。`AlamofireImage`も使わなそうな処理が多そうだったので候補から外しています。

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

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


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

「[Swiftの有名画像キャッシュライブラリを比較してみた](https://qiita.com/H_Crane/items/422811dfc18ae919f8a4#%E6%AF%94%E8%BC%833-%E9%80%9F%E5%BA%A6)」にあったのですが、`1.画像を表示する際、初期表示の画像取得速度`が他のライブラリに比べ速いということだったので、自分で検証することはなく鵜呑みにしています。

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


### カスタマイズが可能

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

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

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

```swift
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](https://github.com/kean/Nuke/blob/master/Documentation/Guides/Third%20Party%20Libraries.md)より抜粋_

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

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

manager.loadImage(with: url, into: imageView)
```
_※[Third Party Libraries](https://github.com/kean/Nuke/blob/master/Documentation/Guides/Third%20Party%20Libraries.md)より抜粋_

上記の処理は[プラグインとしても公開](https://github.com/kean/Nuke-Alamofire-Plugin)されているようです。

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

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

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

参考）[WebP support #49](https://github.com/kean/Nuke/issues/49)

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

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

![http_caching_2x_820a949f-9d5d-4a85-9ca2-50b42b339e18.png](https://qiita-image-store.s3.amazonaws.com/0/36424/e5b82f3d-8d12-47f7-bdf7-8509f1b2dffd.png)

_※[NSURLRequest.CachePolicy](https://developer.apple.com/documentation/foundation/nsurlrequest.cachepolicy)より_

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

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

## URLCacheを使う

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

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

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


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

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

[PageSpeed Insightsのドキュメント](https://developers.google.com/speed/docs/insights/LeverageBrowserCaching#header_1)によると、レスポンスの有効期限の設定は「`Expires`をおすすめします」とのことなので、クライアントとしてもまずは`Expires`の値で判断し、判断できなかった場合に`max-age`の値で判断できれば問題無さそうです。

```swift
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
}
```

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

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

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

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

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

```swift
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:

```swift
func removeCachedResponse(for request: URLRequest)
```

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

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

```swift
func removeAllCachedResponses()
```

### ローダーの作成

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

#### キャッシュ処理

```swift
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)
        }
    }
}
```

#### 画像ローダー

```swift
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)
```

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

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

https://github.com/KyoheiG3/Exclusion