前提
新規プロジェクトを立ち上げる際に、画像ローダーという別にどれでも良さそうだけどほぼ必ず利用しなければならないライブラリを選定する上で、最終的な判断をするまでの経緯と組み込んだ時に得た知見をまとめます。
この記事はNuke
の6.0-beta1
の時点で書かれています。
候補としてはNukeかKingfisher
前のプロジェクトでも使っていたこともあり、自分の中ではとりあえずKingfisher
が第一候補でしたが、使わなかった理由は以下のような感じです。
UIImageViewのextensionは必要ない
UIImageView
に取得処理を追加して画像取得後に自動的に表示させるよりも、画像が取得できたらキャッシュや加工など一連の処理を行なった後にUIImageView
に表示するという流れにしたほうが、色々と都合が良いことの方が多いのです。
例えば、キャッシュ処理を独自のものにしたいとか、あるいは画像データだけ通信処理を事前に実行して取得しておくだとか、そういったカスタマイズをextension
で追加された処理に対して行うとなると容易ではありません。
最近のプロジェクトではReactiveプログラミング前提でRxSwift
などのライブラリが導入されることもあるかと思いますが、これらのライブラリを使って画像の表示処理を行うと、役割をきれいに分けることと、実行中のスレッドの変更や処理の破棄を行うことが手軽に行えるようになるのでおすすめです。
ということで結局はKingfisherManagerしか使っていなかった
上記の理由から、画像のDLやCacheを行なってくれるKingfisherManager
にextension
で処理を追加し、画像URLが取得できたらその処理に流すような実装にして、UIImageView
のextension
は使わずにいました。
以下にその時の処理の一部を記載しておきます。
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上でのスター数やコントリビューター数、情報量などを加味した上で候補としてはNuke
かKingfisher
かなぁといった感じでした。AlamofireImage
も使わなそうな処理が多そうだったので候補から外しています。
それで言うとNuke
に関しては日本語の情報量はそこまで多くはなかったのですが、以下のような部分を考慮して選びました。
ちなみに、車輪の再発明上等で欲しい部分だけを自作しようかとも思いましたが、その欲しいものがNuke
にはある程度揃ってた感じです。
画像の取得速度が速いとの噂
「Swiftの有名画像キャッシュライブラリを比較してみた」にあったのですが、1.画像を表示する際、初期表示の画像取得速度
が他のライブラリに比べ速いということだったので、自分で検証することはなく鵜呑みにしています。
検証時の通信状況とかどんな感じだったのかなど詳細はわからないですが、この件に関しては処理が増えれば増えるほど遅くなるはずなので、よほど変な処理が行われていなければシンプルなものがより良いのではないかと思います。
カスタマイズが可能
基本的にprotocol
に準拠したオブジェクトを渡しているので、独自のオブジェクトもそのprotocol
にさえ準拠してしまうだけでいいのでインジェクションが容易で部分的なカスタマイズに耐えうる設計になっています。
例えば画像ロードを行うためのprotocol
はLoading
というもので、それに準拠したクラスが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
}
}
}
Loader
クラスのシングルトンオブジェクトは更にManager
クラスの初期化時に渡され、Manager
クラスのシングルトンオブジェクトで保持されていますが、これらのインスタンスを新たに作って保持してしまえば独自の処理が行えるようになります。
let loader = Nuke.Loader(loader: AlamofireDataLoader())
let manager = Nuke.Manager(loader: loader, cache: Cache.shared)
manager.loadImage(with: url, into: imageView)
上記の処理はプラグインとしても公開されているようです。
また、「URLCache
は十分なパフォーマンスが出ないよ(意訳)」とNuke
の作者は言っていますが、必要ならばディスクキャッシュもこのようにカスタマイズして置き換えることが前提となっているようです。
webpも行けるよとissueに上がってた
webpを使うことで画像取得コストを減らすことができるので試してみたいのですが、iOSではサポートされていないのでその実装を組み込む必要があります。ハードウェアデコードがサポートされていないのでコストを考えたら導入は微妙かもしれませんが、組み込むことが可能かどうかという問題に関しては解決できると思うよ、ってことみたいでした。
ディスクキャッシュを利用する上での注意点
Nuke
はメモリキャッシュに関する処理は独自に組み込まれていますが、ディスクキャッシュに関してはURLCache
に任されています。キャッシュを返すかどうかはURLRequest
の中で色々な判定が入った後に決まりますが、URLRequest
の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のドキュメントによると、レスポンスの有効期限の設定は「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
}
上記のような構造体を用意しておいて、expires
のget
内で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
に用意されている削除系の以下のメソッドは機能しませんでした
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)
Foundation
のURLCache
をほぼそのまま使う形なので、導入も簡単にできたと思います。もし実際に組み込む場合は、プロダクトにあった形に最適化していただければいいと思います。
上記判定を行うことができる処理を含んだCacheライブラリを実装しましたので、よろしければ使ってもらえると幸いです。