👌

【翻訳】《Swift and Cocoa Essentials》Increasing Performance Through Caching

2023/07/26に公開

前回のエピソードでは、アプリケーションのパフォーマンスと使い勝手を劇的に改善しました。アプリケーションが反応するまでに数秒かかることはなくなりました。アプリケーションのパフォーマンスが良くなったとはいえ、改善の余地はあります。

このエピソードで取り上げる問題をお見せしましょう。アプリケーションを起動し、左側のデバッグ・ナビゲーターを開きます。オプションのリストから Network を選択してください。アプリケーションのネットワーク・アクティビティの概要が表示されます。 tableView の cell が表示されるたびに、アプリケーションはリモート・サーバから tableViewCell の画像を取得します。これは驚くべきことではありませんが、問題があります。

tableView を上下に数回スクロールすると、アプリケーションがリモート・サーバーから画像を取得し続けていることに気づきます。これは驚くべきことではありませんが、警鐘を鳴らすべきです。アプリケーションは同じリソースを何度もフェッチしているのです。これはパフォーマンスに影響し、デバイスのリソースにも影響します。リモート・サーバーからのデータ・フェッチには時間とエネルギーがかかります。アプリケーションの動作を改善する解決策を見つける必要があります。

リソースのキャッシュ

キャッシュ戦略を実装することで、アプリケーションのパフォーマンスを大幅に改善できます。キャッシュは単純な概念ですが、実装は複雑で、プロジェクトのニーズに依存することがよくあります。キャッシュとは、後で使うためにデータを保存する場所にほかなりません。その場所はメモリ上でもディスク上でも構いません。

先に述べたように、考え方は単純です。アプリケーションが画像などの特定のリソースのデータを要求すると、アプリケーションは後で使用するためにそのデータのコピーをストアに保存します。データはキャッシュされます。アプリケーションが後の時点で同じリソースを必要とする場合、まず、アプリケーションが要求しているデータがストアにあるかどうかをチェックします。ストアにデータがある場合、そのリソースはストアから提供され、サーバーへの往復が節約されます。

コンセプトはシンプルですが、それだけではありません。リソースをいつキャッシュから提供すべきかを決定することは、重要な検討事項です。この決定はキャッシュの効率に直接影響します。キャッシュは一定のサイズを持つべきであり、アプリケーションはもはや関連性のない古いデータをユーザーに見せないようにするために、キャッシュが保存するデータはある時点で期限切れになるべきです。このエピソードでは、キャッシュの細かい詳細については説明しません。このエピソードの焦点は、キャッシュ戦略を実装することでパフォーマンスを向上させることです。

データ・タスク

最初の変更はキャッシュとは関係ありません。アプリケーションが表示する画像のデータを取得するアプローチを変更する必要があります。現在の実装はとても基本的です。 Data 構造体 の init(contentsOf:options:) イニシャライザを呼び出して Data インスタンス を初期化します。

これは簡単な解決策ですが、リモートのリソースを扱う場合にはあまり使いたくないAPIです。データをフェッチする根本的なリクエストをキャンセルすることができないので、 table や collection view と組み合わせてAPIを使用する場合に問題になります。 table や collectionView の cell が再利用されようとしていて、データがまだリモート・サーバーからフェッチされている場合、操作をキャンセルする機能が必要です。 Data 構造体 の API にはこの機能がありません。

URLSession API を使う方がはるかに良い選択で、実装も難しくありません。ImageTableViewCell.swift を開き、 URLSessionDataTask?

import UIKit

final class ImageTableViewCell: UITableViewCell {

    // MARK: - Static Properties

    static var reuseIdentifier: String {
        return String(describing: self)
    }

    // MARK: - Properties

    private var dataTask: URLSessionDataTask?

    // MARK: -

    @IBOutlet private var titleLabel: UILabel!

    // MARK: -

    @IBOutlet private var thumbnailImageView: UIImageView!

    ...

}

dataTask(with:completionHandler:) を呼び出すことで、共有 URLSession インスタンスにデータタスクを依頼します。最初の引数はリクエストの URL です。第2引数のクロージャは、リクエストが成功または失敗して完了したときに呼び出されます。クロージャは3つのパラメータ、オプションの Data インスタンス、オプションの URLResponse インスタンス、オプションの Error インスタンスを定義します。

我々は Data インスタンス にしか興味がありません。Dataインスタンスを安全にアンラップし、それを使ってUIImage インスタンス を作成します。見覚えがあるはずです。メインスレッドで Grand Central Dispatch を使って image view の image プロパティ を更新します。

ImageTableViewCell インスタンス の dataTask プロパティ にデータタスクへの参照を格納するため、クロージャ内で弱く self を参照する必要があります。強い参照サイクルは作りたくありません。そのためにキャプチャー・リストを使います。

func configure(with title: String, url: URL?, session: URLSession) {
    // Configure Title Label
    titleLabel.text = title

    if let url = url {
        // Create Data Task
        let dataTask = URLSession.shared.dataTask(with: url) { [weak self] (data, _, _) in
            guard let data = data else {
                return
            }

            // Initialize Image
            let image = UIImage(data: data)

            DispatchQueue.main.async {
                // Configure Thumbnail Image View
                self?.thumbnailImageView.image = image
            }
        }
    }
}

リクエストを開始するには、 URLSessionDataTask インスタンスで resume() を呼び出します。URLSession API に慣れていない開発者は、このステップを見落としがちです。

func configure(with title: String, url: URL?, session: URLSession) {
    // Configure Title Label
    titleLabel.text = title

    if let url = url {
        // Create Data Task
        let dataTask = URLSession.shared.dataTask(with: url) { [weak self] (data, _, _) in
            guard let data = data else {
                return
            }

            // Initialize Image
            let image = UIImage(data: data)

            DispatchQueue.main.async {
                // Configure Thumbnail Image View
                self?.thumbnailImageView.image = image
            }
        }

        // Resume Data Task
        dataTask.resume()
    }
}

最後に、 ImageTableViewCell クラス の dataTask プロパティ にデータ・タスクへの参照を保存します。なぜデータ・タスクへの参照を保持するのかは、すぐに明らかになります。

func configure(with title: String, url: URL?, session: URLSession) {
    // Configure Title Label
    titleLabel.text = title

    if let url = url {
        // Create Data Task
        let dataTask = URLSession.shared.dataTask(with: url) { [weak self] (data, _, _) in
            guard let data = data else {
                return
            }

            // Initialize Image
            let image = UIImage(data: data)

            DispatchQueue.main.async {
                // Configure Thumbnail Image View
                self?.thumbnailImageView.image = image
            }
        }

        // Resume Data Task
        dataTask.resume()

        // Update Data Task
        self.dataTask = dataTask
    }
}

アプリケーションがリモート・サーバーからフェッチする画像はかなり大きく、 tableView をスクロールすると、アプリケーションは各画像を表示する時間内にフェッチする時間がありません。これは問題ではありませんが、注意しなければならない点です。

tableView の cell が再利用されようとしている場合、リクエストが完了していなければキャンセルする必要があります。データ・タスクへの参照を保持するのはそのためです。 prepareForReuse() で、URLSessionDataTask インスタンス の cancel() を呼び出します。これで完了です。また、dataTask プロパティを nil に設定することで、データ・タスクを破棄します。これは厳密には必要ではありませんが、不要になったオブジェクトを一掃するための良い習慣です。

override func prepareForReuse() {
    super.prepareForReuse()

    // Cancel Data Task
    dataTask?.cancel()

    // Discard Data Task
    dataTask = nil

    // Reset Thumnail Image View
    thumbnailImageView.image = nil
}

これが最初のパフォーマンス改善であり、実装がそれほど難しくなかったことに同意していただけると確信しています。次はキャッシュに焦点を当てましょう。

リソースのキャッシュ

アプリケーションのパフォーマンスをさらに向上させるために、キャッシュ戦略を実装してみましょう。ImagesViewController.swift を開きます。画像のデータ取得には、共有の URLSession インスタンスを使用しません。 private で lazy な、 URLSession 型 の変数プロパティ session を定義します。

private lazy var session: URLSession = {

}()

URLSession インスタンス を作成するには、 URLSessionConfiguration オブジェクト が必要です。その名の通り、このオブジェクトは URLSession インスタンス を構成します。デフォルトクラスのメソッドを呼び出して、デフォルトのセッション構成を作成します。

private lazy var session: URLSession = {
    // Create URL Session Configuration
    let configuration = URLSessionConfiguration.default
}()

URLSessionConfiguration インスタンスの requestCachePolicy プロパティを returnCacheDataElseLoad に設定することで、リクエストキャッシュポリシーを定義します。私たちが選択するキャッシュ戦略はシンプルです。リクエストに対してキャッシュされたレスポンスが利用可能な場合、キャッシュされたレスポンスが使用されます。リクエストに対してキャッシュされたレスポンスが利用できない場合、データはリモートサーバーからフェッチされます。これは画像のような静的なリソースに有効なシンプルなキャッシュ戦略です。

private lazy var session: URLSession = {
    // Create URL Session Configuration
    let configuration = URLSessionConfiguration.default

    // Define Request Cache Policy
    configuration.requestCachePolicy = .returnCacheDataElseLoad
}()

URLSessionConfiguration インスタンス を使用して、 URLSession インスタンス を初期化します。URLSession インスタンス の動作を定義するのは URLSessionConfiguration オブジェクト です。

private lazy var session: URLSession = {
    // Create URL Session Configuration
    let configuration = URLSessionConfiguration.default

    // Define Request Cache Policy
    configuration.requestCachePolicy = .returnCacheDataElseLoad

    return URLSession(configuration: configuration)
}()

configure(with:url:) メソッド の引数として、 URLSession インスタンス をImageTableViewCell に渡します。 ImageTableViewCell.swift を開き、configure(with:url:) メソッドに移動します。3つ目のパラメータとして、 URLSession 型 のsession を定義します。 ImageTableViewCell インスタンス は、 URLSession オブジェクト を使用してデータタスクを作成します。

func configure(with title: String, url: URL?, session: URLSession) {
    // Configure Title Label
    titleLabel.text = title

    if let url = url {
        // Create Data Task
        let dataTask = session.dataTask(with: url) { [weak self] (data, _, _) in
            guard let data = data else {
                return
            }

            // Initialize Image
            let image = UIImage(data: data)

            DispatchQueue.main.async {
                // Configure Thumbnail Image View
                self?.thumbnailImageView.image = image
            }
        }

        // Resume Data Task
        dataTask.resume()

        self.dataTask = dataTask
    }

ImagesViewController.swift を見直して、 tableView(_:cellForRowAt:) メソッド内のImageTableViewCell インスタンス の configure(with:url:) メソッド を更新します。

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell = tableView.dequeueReusableCell(withIdentifier: ImageTableViewCell.reuseIdentifier, for: indexPath) as? ImageTableViewCell else {
        fatalError("Unable to Dequeue Image Table View Cell")
    }

    // Fetch Image
    let image = dataSource[indexPath.row]

    // Configure Cell
    cell.configure(with: image.title, url: image.url, session: session)

    return cell
}

デバイスまたはシミュレータからアプリケーションを削除します。アプリケーションをビルドして実行します。画像が読み込まれるまで数秒かかるはずです。

左側の Debug Navigator を開き、 Network を選択します。ネットワーク・アクティビティは時間が経っても改善されないようです。何かがおかしいのです。アプリケーションがリモートサーバーから取得する画像はかなり大きいことを思い出してください。ほとんどの画像は1メガバイトを超えています。しかし、デフォルトのキャッシュサイズはそれほど大きくありません。キャッシュサイズを大きくする必要がありますが、それは簡単です。

ImagesViewController.swift を開き、先ほど定義した session プロパティ に移動します。URLSessionConfiguration インスタンス で urlCache プロパティ を設定することで、 URLSession インスタンス のキャッシュを設定できます。

デフォルトのURLセッション構成では、共有 URL キャッシュオブジェクトを使用します。このオブジェクトには、URLCache クラス の shared クラスメソッド を通じてアクセスできます。メモリ内キャッシュのサイズを調べるために、memoryCapacity プロパティ の値を表示してみましょう。

private lazy var session: URLSession = {
    print(URLCache.shared.memoryCapacity)

    // Create URL Session Configuration
    let configuration = URLSessionConfiguration.default

    // Define Request Cache Policy
    configuration.requestCachePolicy = .returnCacheDataElseLoad

    return URLSession(configuration: configuration)
}()

インメモリーキャッシュのサイズは半分のメガバイトしかありません。これではアプリケーションのニーズには不十分です。

512000

キャッシュのサイズを大きくしすぎるべきではありませんが、今回の問題を解決するために半分のギガバイトに増やしましょう。

private lazy var session: URLSession = {
    // Set In-Memory Cache to 512 MB
    URLCache.shared.memoryCapacity = 512 * 1024 * 1024

    // Create URL Session Configuration
    let configuration = URLSessionConfiguration.default

    // Define Request Cache Policy
    configuration.requestCachePolicy = .returnCacheDataElseLoad

    return URLSession(configuration: configuration)
}()

デバイスまたはシミュレータからアプリケーションを削除します。アプリケーションをもう一度ビルドして実行し、左側の Debug Navigator を開きます。アプリケーションのネットワーク・アクティビティは、アプリケーションの起動時にいくつかのスパイクを表示しますが、画像へのリクエストがキャッシュされると、アプリケーションはリクエストを実行しません。これが私たちが求めていたパフォーマンスの改善です。

画像サイズ

アプリケーションのパフォーマンスが大幅に改善されたとはいえ、まだ改善の余地があります。 table view のスクロールは、まだしっくりきません。画像がキャッシュから読み込まれてユーザーに表示されるたびに、スクロールのパフォーマンスが影響を受けます。先に述べたように、アプリケーションがダウンロードする画像はかなり大きく、クライアントが変更できるものではありません。しかし、アプリケーションのパフォーマンスを改善できる回避策があります。

UIImage インスタンスのサイズは、 tableView の cell に入力する画像のサイズを変更することで変更できます。これにより、 imageView のレンダリング・パフォーマンスが向上するはずです。新しいグループ、Extensions を作成し、そこに Swift ファイルを追加します。ファイル名を UIImage.swift とします。

Foundation 用の import ステートメントを UIKit 用の import ステートメントに置き換え、UIImage クラス用の拡張機能を作成します。

import UIKit

extension UIImage {

}

CGSize インスタンスを受け取るインスタンスメソッド resizedImage(with:) を定義します。インスタンスメソッドの戻り値の型は UIImage? です。

import UIKit

extension UIImage {

    func resizedImage(with size: CGSize) -> UIImage? {

    }

}

この後の議論では、実装は重要ではありません。考え方は単純です。引数として渡されたサイズでグラフィック・コンテキストを作成します。グラフィックス・コンテキストに画像を描画し、UIGlaficsGetImageFromCurrentImageContext() を呼び出して UIImage インスタンス を作成します。この関数は、現在のグラフィックス・コンテキストの内容に基づいて UIImage インスタンス を作成します。グラフィックスコンテキストをクリーンアップし、リサイズされた画像を返します。

import UIKit

extension UIImage {

    func resizedImage(with size: CGSize) -> UIImage? {
        // Create Graphics Context
        UIGraphicsBeginImageContextWithOptions(size, false, 0.0)

        // Draw Image in Graphics Context
        draw(in: CGRect(origin: .zero, size: size))

        // Create Image from Current Graphics Context
        let resizedImage = UIGraphicsGetImageFromCurrentImageContext()

        // Clean Up Graphics Context
        UIGraphicsEndImageContext()

        return resizedImage
    }

}

UIGraphicsGetImageFromCurrentImageContext() の戻り値の型が UIImage? です。

ヘルパー・メソッドを配置した状態で、 ImageTableViewCell.swift を再確認します。アプリケーションがリモートサーバーから取得した画像のサイズを変更し、 UIImage インスタンスを使用して tableViewCell の thumbnailImageView に入力します。

func configure(with title: String, url: URL?, session: URLSession) {
    // Configure Title Label
    titleLabel.text = title

    if let url = url {
        // Create Data Task
        let dataTask = session.dataTask(with: url) { [weak self] (data, _, _) in
            guard let data = data else {
                return
            }

            // Initialize Image
            let image = UIImage(data: data)?.resizedImage(with: CGSize(width: 200.0, height: 200.0))

            DispatchQueue.main.async {
                // Configure Thumbnail Image View
                self?.thumbnailImageView.image = image
            }
        }

        // Resume Data Task
        dataTask.resume()

        self.dataTask = dataTask
    }
}

アプリケーションを起動し、 tableView をスクロールします。画像の読み込みが速くなったわけではありませんが、アプリケーションのスクロール・パフォーマンスが大幅に向上しました。 tableView のスクロールがスムーズになり、もたつくことがなくなりました。画像のリサイズはバックグラウンド・スレッドで行われ、画像のレンダリングはメイン・スレッドで行われます。

アクティビティ・インディケーター・ビューの修正

完璧を目指すのだから、ちょっとした問題を修正する必要があります。アクティビティ・インディケーター・ビューは、tableView の cell が再利用された瞬間にアニメーションを停止します。これは簡単に修正できます。 ImageTableViewCell.swift でアクティビティ・インジケータ・ビューのアウトレットを定義します。

import UIKit

final class ImageTableViewCell: UITableViewCell {

    ...

    // MARK: - Properties

    @IBOutlet private var titleLabel: UILabel!

    // MARK: -

    @IBOutlet private var thumbnailImageView: UIImageView!

    // MARK: -

    @IBOutlet private var activityIndicatorView: UIActivityIndicatorView!

    ...

}

Main.storyboard を開き、右側の Connections Inspector を開き、 images view controller の tableView の cell を選択します。先ほど定義したアウトレットを、 tableViewCell のアクティビティインジケータビューに接続します。

ImageTableViewCell.swift を開き、 configure(with:url:session:) メソッド を再検討します。データ・タスクを作成する前に、アクティビティ・インジケータ・ビューで startAnimating() を呼び出します。

func configure(with title: String, url: URL?, session: URLSession) {
    // Configure Title Label
    titleLabel.text = title

    if let url = url {
        // Start Activity Indicator View
        activityIndicatorView.startAnimating()

        // Create Data Task
        let dataTask = session.dataTask(with: url) { [weak self] (data, _, _) in
            guard let data = data else {
                return
            }

            // Initialize Image
            let image = UIImage(data: data)?.resizedImage(with: CGSize(width: 200.0, height: 200.0))

            DispatchQueue.main.async {
                // Configure Thumbnail Image View
                self?.thumbnailImageView.image = image
            }
        }

        // Resume Data Task
        dataTask.resume()

        self.dataTask = dataTask
    }
}

アプリケーションを実行し、問題が修正されていることを確認してください。

始める前に

ImagesViewController クラスで URLSession インスタンスを明示的に作成しました。これは必要ありません。 URLSession API が公開している共有 URLSession オブジェクト を使用することもできます。URLSession インスタンス を明示的に作成することで、ボンネットの下では何も魔法が使われていないことが分かります。

キャッシュサイズを増やすことは、全てを動作させるために必要でした。アプリケーションがリモートサーバーからダウンロードする画像はかなり大きく、デフォルトのキャッシュサイズは小さすぎます。運用アプリケーションでは、キャッシュサイズをできるだけ小さく保つことが重要です。アプリケーションが何百メガバイトものメモリを占有するのは避けたいものです。

このエピソードは、 Swift と Cocoa の開発に不可欠な多くの側面を強調しました。まず、仕事に適したツールを使うことが重要です。 init(contentsOf:options:) イニシャライザを呼び出して Data インスタンス を作成するのは便利ですが、リソースのデータをフェッチするリクエストを制御することはできません。URLSession API はより強力です。柔軟で多用途に使えるように設計されています。

第二に、アプリケーションのパフォーマンスには常に注意を払う必要があります。メインスレッドで作業を行うのは問題ないとしても、プロジェクトが大きくなるにつれて、徐々にパフォーマンスの問題につながる可能性があります。ユーザーを満足させたいのであれば、アプリケーションのパフォーマンスを定期的に分析することが重要です。

第三に、アプリケーションのパフォーマンスを改善することは、複雑で難しいことである必要はありません。今回と前回のエピソードで行った変更は、ロケット科学ではありません。キャッシュは、アプリケーションのパフォーマンスと効率を改善するために、ソフトウェア開発で広く使われている戦略です。

そして最後に、アプリケーションが使用する API とサービスを精査することが重要です。アプリケーションがリモート・サーバーからフェッチする画像のサイズが小さければ、クライアント上でリサイズする必要はありません。クライアント上で画像のサイズを変更するのにも、時間とエネルギーがかかることを知っておいてください。

次の課題は?

アプリケーションのパフォーマンスが劇的に向上したとはいえ、まだまだ改善の余地はあります。例えば、 tableView の cell が表示されるたびにアプリケーションで画像のサイズを変更する必要はありません。また、 tableViewCell が画像のデータ・フェッチを担当すべきでしょうか? tableViewCell は view であり、 view はダム(データ処理しない)であるべきです。 view はネットワークとは無関係であるべきなのです。

改善の余地は常にあります。ここまでにしますが、これらの問題を解決するためのソリューションを自由に検討してください。

【翻訳元の記事】

Swift and Cocoa Essentials
Increasing Performance Through Caching
https://cocoacasts.com/swift-and-cocoa-fundamentals-increasing-performance-through-caching

Discussion