iOS画像非同期取得

こんにちは、モバイル基盤のヴァンサン(@vincentisambart)です。

半年くらい前に、iOSクックパッドアプリで画像非同期取得を自作することになりました。導入してから何ヶ月も問題なく動いているので、どう動いているのか紹介しようと思います。でもその前に自作することになった経緯を説明しましょう。

自作経緯

長年画像非同期取得に既存のライブラリを使っていましたが、昨年ライブラリの不具合で画像の取得が稀に失敗していたバグがいくつかありました。バグが修正されて、その数ヶ月後にまた似た問題。

この状態が好ましくなかったので、以下の選択肢のどれかにしようと議論しました。

  • 使っているライブラリのメンテナンスにもっと直接参加する
    • コードが古くメンテナンスしやすくなさそうでした。
  • 使っているライブラリのバージョンを固定する
    • 自動的に更新をやめても、バグ修正や最新のOSの対応のために定期的に更新した方が良いでしょう。
  • 別のライブラリにするか
    • 選定が難しいでしょう。例えばアプリをリリースしないとライブラリの安定性が判断しづらいです。
  • 自作するか

ライブラリを使っていたものの、複雑な機能は使っていませんでした。必要だったのは画像のダウンロード、キャッシュ、WebP対応、くらいです。

クックパッドでモバイルアプリの画像はWebPを使っているので、画像取得を自作することになっても、WebP読み込みにライブラリが必要だと思っていました。でも議論中に、iOS 14以降UIImageがWebPを読み込めるのが発覚しました。その時点で最新のiOSクックパッドアプリの最小サポートバージョンはすでにiOS 15でした。

画像ダウンロードとキャッシュだけが必要なら自作してみても良いかもという結論になりました。

実装

経緯の次は実装の詳細を説明しようと思います。その中で、まずは一番複雑そうなキャッシュの実装はどうしましょうか。

キャッシュ

多くの画像非同期取得ライブラリがキャッシュを2段階で行います:

  • 取得された画像ファイルをディスクにキャッシュします。アプリを再起動してもデータは残ります(ただし端末に空き容量が足りなくなった場合、OSが一部を消すことがあります)。
  • 画像ファイルが読み込まれたUIImageをメモリ上にキャッシュします。もちろんアプリを再起動したら一掃されます。ディスクからのファイル読み込みも、画像データのデコードも、メインスレッドでやらない方が良いことですが、UIImageをメモリ上でキャッシュするとどっちも必要ないのでこのキャッシュは直接メインスレッドで扱えます。

どちらのキャッシュの種類もできれば自分で実装したくありません。ディスクやメモリの空き容量、キャッシュの使っている容量、を気にする必要があるのはややこしそうです。でも実はFoundationにそのためのツールがあります。

ダウンロードされるデータをキャッシュするためにURLCacheがあります。ダウンロードに使うURLSessionconfigurationに代入するだけでダウンロードするデータがキャッシュされるようになります。

メモリ上でデータをキャッシュするためにNSCacheがあります。使い方がDictionaryに近いです。キーと値がAnyObjectであるべきなのでSwiftから使う場合少し不便な場面もありますが、今回キー(URL)をNSURLに簡単にキャストできるので、別のものにラップせずにAnyOjectにできます。

API

キャッシュのためのツールが揃ったので、画像取得APIを見てみましょう。

enum LoadingImage {
    case cached(UIImage)
    case inProgress(Task<UIImage, any Error>)
}

final class ImageLoader {
    func loadImage(from imageURL: URL) -> LoadingImage {

Taskが見られるのでSwift Concurrencyが使われているのですが、asyncメソッドではありません。

ビューの読み込みや表示時にOSから呼ばれるメソッド(collectionView(_:cellForItemAt:)viewDidLoad、など)は基本的にasyncではありません。

loadImageの定義がfunc loadImage(from imageURL: URL) async throws -> UIImageでしたら、asyncでないメソッドから呼ぶと新規タスクを作成する必要があります。

func viewDidLoad() {
    Task { @MainActor in
        // このコードが`viewDidLoad`のタイミングで実行されるのではなく、
        // `MainActor`が次回実行するようにキューされます。
        let image = await imageLoader.loadImage(from: imageURL)
        imageView.image = image
    }
}

loadImage内でawaitをせずにreturnしたとしても、同じMainActorで実行される別のタスク内なので、viewDidLoadの後に実行されてしまいます。画像がメモリ上のキャッシュにあったとしても、ビューの最初に描写で画像が表示されない可能性があります。最初の描写で画像なし、次の描写で画像あり、はチカチカして雑に見えます。フェードインを使えば少しマシですが、すぐ表示できればしたいです。

loadImage

本来のloadImageのコードを見てみましょう。

final class ImageLoader {
    func loadImage(from imageURL: URL) -> LoadingImage {
        // 画像がメモリ上キャッシュに入っていれば、すぐ返します。
        if let image = cachedImage(for: imageURL) {
            return .cached(image)
        }

        // バックグラウンドタスクで取得とデコードを行います。
        // `detached`を使うのはactorを引き継がないためです。
        return .inProgress(Task.detached {
            let request = URLRequest(url: imageURL)

            // コードの分かりやすさのために画像が`URLCache`に入っているのかどうか区別していませんが、
            // 必要であれば`urlCache.cachedResponse(for: request)`で入っているかどうか確認できます。

            let data = try await self.loadData(for: request)
            return try self.decode(data, for: imageURL)
        })
    }

ダウンロードをキャンセルしたければ、inProgress()に入ったタスクのcancel()メソッドを呼びます。Task.init()や今回のようにTask.detached()を使うとstructured concurrencyではないので、loadImage(from:)の戻り値を放置してもタスクがキャンセルされることはありません。

上記に使われているcachedImage(for:)NSCacheのメソッドを呼ぶだけです。URLAnyObjectではないので、NSCacheのキーに使うにはNSURLにキャストする必要があります。

final class ImageLoader {
    private let memoryCache = NSCache<NSURL, UIImage>()

    func cachedImage(for imageURL: URL) -> UIImage? {
        memoryCache.object(forKey: imageURL as NSURL)
    }

loadData(for:)に関しては、URLSession.data(for:)をもう少し使いやすくするだけです。

final class ImageLoader {
    private let session: URLSession
    private func loadData(for request: URLRequest) async throws -> Data {
        do {
            return try await session.data(for: request).0
        } catch {
            // タスクがキャンセルされた時、`session.data(for:)`がCocoaのエラーを発生されるのですが、
            // Swiftでは`CancellationError`がもっと自然だと思います。
            // タスクがキャンセルされている場合`CancellationError`を発生させる`try Task.checkCancellation()`がちょうど良いです。
            try Task.checkCancellation()
            throw error
        }
    }

decode(_:for:)UIImage(data:)をラップして、デコードされた画像をNSCacheに入れてくれるだけです。

final class ImageLoader {
    private func decode(_ data: Data, for imageURL: URL) throws -> UIImage {
        if let image = UIImage(data: data) {
            memoryCache.setObject(image, forKey: imageURL as NSURL)
            return image
        } else {
            throw InvalidImageDataError(url: imageURL)
        }
    }

ImageLoaderの全コードをこの記事の一番下にまとめました。上記になかったinitも含まれています。

使い方

上記に実装したAPIは基本的に以下のように使えば良いのではないでしょうか。

// 以前行われた取得が終わっていなければキャンセルします。
// キャンセルされる可能性ないなら、`loadTask`インスタンス変数は要らないでしょう。
if let loadTask {
    loadTask.cancel()
    self.loadTask = nil
}

// 取得を始めます。
switch imageLoader.loadImage(from: imageURL) {
case let .cached(image):
    // `image`をそのまま表示できます。

case let .inProgress(loadTask):
    // キャンセルできるために`loadTask`をとっておきます。
    self.loadTask = loadTask
    Task { @MainActor [weak self] in
        do {
            let image = try await loadTask.value
            // 無事に画像を取得できたので表示できます。
        } catch is CancellationError {
            // 待ち合わせていた`loadTask`がキャンセルされたので何もやるべきではありません。
        } catch {
            // 取得が失敗したので、placeholderを表示することが多いです。
        }
    }
}

移行

iOSクックパッドアプリで以前使っていたライブラリから自作ImageLoaderへの移行が割とスムーズでした。モジュール化によって画像読み込みが抽象化されていた場面多かったですし、ほとんどの画像が限られた数のビューに表示されています。

画像取得がまだ抽象化されていなかったら、まず抽象化するか、新しい実装の上に以前のに近いAPIを用意するか、の2択ですかね。前者は抽象化が終わったら移行するけど、後者は新しいコードに移行してから以前のAPIの利用を少しずつ減らします。

最後に

iOSの既存のツールを使えば、シンプルな画像取得は割とシュッと実装できました。だからといって画像取得を自作した方が良いわけでもありません。アプリによって状況が違います。自作したら自分でメンテナンスする必要がありますし、必要になった機能も自分で実装します。

自作するかどうか関係なく、画像取得が抽象化されていると、別のライブラリに移行しやすいので、いま直接ライブラリを使っていていて変える可能性があれば、とりあえず抽象化しても良いかもしれません。

この記事のコードを元に自作するのでしたら、機能の追加が必要かもしれません。ここで紹介されていませんが、iOSクックパッドアプリでは、画像をprefetchする仕組みや、メモリ上キャッシュになかった画像を表示にフェードインで表示させるFadeInImageView(UIKit版)とFadeInImage(SwiftUI版)があります。

// This project is licensed under the MIT No Attribution license.
// 
// Copyright (c) 2023 Cookpad Inc.
// 
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import Foundation
import UIKit

public enum LoadingImage {
    case cached(UIImage)
    case inProgress(Task<UIImage, any Error>)
}

public struct InvalidImageDataError: Error {
    public var url: URL
    public init(url: URL) {
        self.url = url
    }
}

public final class ImageLoader {
    private let session: URLSession
    private let memoryCache = NSCache<NSURL, UIImage>()

    public init() {
        let configuration = URLSessionConfiguration.default
        let cacheDirectoryURL: URL?
        do {
            let systemCacheURL = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
            cacheDirectoryURL = systemCacheURL.appendingPathComponent("CookpadImageLoader", isDirectory: true)
        } catch {
            assertionFailure("Could not create cache path: \(error)")
            cacheDirectoryURL = nil
        }
        // デフォルトでは`URLCache.shared`が使われますが、もう少しディスク容量を使える画像専用のを使います。
        configuration.urlCache = URLCache(
            // `memoryCapacity`は試した限り0でも問題なく動きそうですが、一応念の為少しのメモリを割り当てます。
            memoryCapacity: URLCache.shared.memoryCapacity,
            diskCapacity: URLCache.shared.diskCapacity * 4,
            directory: cacheDirectoryURL
        )
        session = .init(configuration: configuration)
    }

    public func cachedImage(for imageURL: URL) -> UIImage? {
        memoryCache.object(forKey: imageURL as NSURL)
    }

    private func decode(_ data: Data, for imageURL: URL) throws -> UIImage {
        if let image = UIImage(data: data) {
            memoryCache.setObject(image, forKey: imageURL as NSURL)
            return image
        } else {
            throw InvalidImageDataError(url: imageURL)
        }
    }

    private func loadData(for request: URLRequest) async throws -> Data {
        do {
            return try await session.data(for: request).0
        } catch {
            // タスクがキャンセルされた時、`session.data(for:)`がCocoaのエラーを発生されるのですが、
            // Swiftでは`CancellationError`がもっと自然だと思います。
            // タスクがキャンセルされている場合`CancellationError`を発生させる`try Task.checkCancellation()`がちょうど良いです。
            try Task.checkCancellation()
            throw error
        }
    }

    public func loadImage(from imageURL: URL) -> LoadingImage {
        if let image = cachedImage(for: imageURL) {
            return .cached(image)
        }

        return .inProgress(Task.detached {
            let request = URLRequest(url: imageURL)

            // コードの分かりやすさのために画像が`URLCache`に入っているのかどうか区別していませんが、
            // 必要であれば`urlCache.cachedResponse(for: request)`で入っているかどうか確認できます。

            let data = try await self.loadData(for: request)
            return try self.decode(data, for: imageURL)
        })
    }
}