メモリキャッシュとディスクキャッシュの違いと実装方法~パフォーマンスの比較~

# はじめに
画像をキャッシュするという言葉をよく聞きます。キャッシュとは、一度読み込んだ内容を一時的に保存しておくことです。キャッシュすることにより再度同じ内容を閲覧する際に素早く読み込むことが可能になります。
以前にメモリキャッシュの実装経験はありましたが、ディスクキャッシュについては知りませんでした。そこでメモリキャッシュとディスクキャッシュの違いと実装方法とパフォーマンスの違いについて学んだ内容を紹介します。
# メモリキャッシュとディスクキャッシュの違い

## **メモリキャッシュ (Memory Cache)**
メモリキャッシュは、アプリが実行中にデータを一時的にRAM（メモリ）に保存する方法。

**メリット**
高速: メモリに保存されるため、データへのアクセス速度が非常に速い。

**デメリット**
小容量: 小容量であるため、過剰なメモリ使用はシステムからアプリが強制終了される原因になる。

**特徴**
揮発性: アプリが終了したり、システムのメモリが不足した場合、キャッシュデータは消失する。

短期的なキャッシュの利用に適しており、アプリが閉じられた後に保持する必要がないデータにメモリキャッシュは使用される。高速なので、データのアクセスが遅いとユーザー体験が悪化する処理に利用される。

## **ディスクキャッシュ (Disk Cache)**
ディスクキャッシュは、データをiPhoneのストレージ（SSDやHDDなど）に保存する方法。

**メリット**
大容量: デバイスのストレージ容量に依存するが、メモリよりも保存容量が大きい。

**デメリット**
低速: ストレージへのアクセスはメモリよりも遅い。

**特徴**
不揮発性: アプリが終了してもデータは保持される。デバイスを再起動しても消えない。
アプリを再起動しても残しておきたいデータやネットワークがない場合でも使えるようにしたいデータにディスクキャッシュは使用される。大容量データ(画像、動画、ファイルなど)、メモリには載せきれない大きなデータのキャッシュに利用。

# パフォーマンス検証前の仮説
メモリキャッシュとディスクキャッシュの実装を実際に比較する前にのそれぞれのパフォーマンスについての仮説を立てました。
## ①メモリの使用量の違い

**メモリキャッシュ > ディスクキャッシュ**。
メモリキャッシュを利用することで、アプリの動作中にデータをRAMに一時保存するため、メモリを消費する量が大きくなる。一方、ディスクキャッシュはメモリではなく、ストレージに保存するため、メモリ使用量は少ない。そのためディスクキャッシュよりもメモリキャッシュの方がメモリの使用量が大きくなる。

## ②ディスク読み書きの使用量
**ディスクキャッシュ > メモリキャッシュ**
メモリキャッシュはRAMに保存されるため、ディスクへの書き込みや読み込みはほぼ発生しません。一方、ディスクキャッシュでは、データをストレージに保存するため、書き込み操作が発生します。また、そのデータを再利用する際は、ディスクから読み込むため、読み込み操作も発生します。そのためメモリキャッシュよりもディスクキャッシュの方がディスク読み書きの使用量が大きくなる。

## ③ビルド時間
**ディスクキャッシュ > メモリキャッシュ**
メモリキャッシュではデータの読み書きがRAM内で迅速に行われる。ディスクキャッシュはストレージへの読み書きに時間がかかる。そのためメモリキャッシュよりもディスクキャッシュの方がビルド時間が長くなる。

:::note warn
**注意**
**ビルド時間について立てた仮説は誤り**でした。実際はキャッシュの種類によるビルド時間への影響はありません。その理由は**パフォーマンスの検証と比較の仮説の訂正**で詳しく説明しています。
:::


# メモリキャッシュとディスクキャッシュの実装方法
## メモリキャッシュの実装
キャッシュライブラリを使用することもできますが、今回はAppleのAPIの`NSCache`を使用しました。

https://developer.apple.com/documentation/foundation/nscache

```Swift:
import FirebaseStorage
import UIKit

class ImageManager {
    
    let imageCache = NSCache<StorageReference, UIImage>()

    private func downloadMemoryCacheImage(path: StorageReference, handler: @escaping (_ image: UIImage?) -> Void) {
        //　キャッシュしていたらそれを使用
        if let cachedImage = imageCache.object(forKey: path) {
            print("🟩キャッシュした画像を使用")
            handler(cachedImage)
        } else {
            path.getData(maxSize: 27 * 1024 * 1024) { returnedImageData, _ in
                if let data = returnedImageData, let image = UIImage(data: data) {
                    print("🟩初めて画像を使用")
                    self.imageCache.setObject(image, forKey: path)
                    handler(image)
                } else {
                    print("Error getting data from path for image")
                    handler(nil)
                }
            }
        }
    }
    ...............
}
```
[`NSCache`](https://developer.apple.com/documentation/foundation/nscache)の一意のkeyとしてFirebaseの`StorageReference`を設定し、valueとしては`UIImage`を設定しました。[`object(forKey:)`](https://developer.apple.com/documentation/foundation/nscache/1415458-object)メソッドを使用し、もしメモリキャッシュを１度したことがあれば、valueを返し、初めて読み取った画像は[`setObject(_:forKey:)`](https://developer.apple.com/documentation/foundation/nscache/1408223-setobject)メソッドを使用し、keyとvalueを設定してあげる実装にしました。

## ディスクキャッシュの実装
キャッシュライブラリを使用することもできますが、今回はAppleのAPIの`FileManager`を使用しました。

https://developer.apple.com/documentation/foundation/filemanager

```Swift:
import FirebaseStorage
import UIKit

class ImageManager {
    private func downloadDiskCacheImage(path: StorageReference, handler: @escaping (_ image: UIImage?) -> Void) {
        let key = path.fullPath.data(using: .utf8)?.base64EncodedString() ?? UUID().uuidString
        let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
        let fileURL = cacheDirectory.appendingPathComponent(key)

        if let imageData = try? Data(contentsOf: fileURL) {
            let readImage = UIImage(data: imageData)
            print("🟩キャッシュされた画像を使用")
            handler(readImage)
        } else {
            path.getData(maxSize: 27 * 1024 * 1024) { returnedImageData, _ in
                guard let data = returnedImageData, let image = UIImage(data: data) else { return }
                print("🟩初めて画像を使用")
                if let data = image.jpegData(compressionQuality: 1.0) {
                    do {
                        try data.write(to: fileURL)
                    } catch {
                        print("画像のディスク保存に失敗: \(error)")
                        handler(nil)
                        return
                    }
                }
                handler(image)
            }
        }
    }
    ...............
}
```
[`FileManager`](https://developer.apple.com/documentation/foundation/filemanager)の[`urls(for:in:)`](https://developer.apple.com/documentation/foundation/filemanager/1407726-urls)メソッドを使用し、ディスクキャッシュの保存先のディレクトリのURLを作成。[`appendingPathComponent(_:)`](https://developer.apple.com/documentation/foundation/nsurl/1410614-appendingpathcomponent/)を使用し、先ほどのURLにpathComponentとしてkeyを追加することにより一意のURLを作成。
[`init(contentsOf:options:)`](https://developer.apple.com/documentation/foundation/data/3126626-init/)を使用し、もしメモリキャッシュを１度したことがあれば、その`imageData`を使用し、初めて読み取った画像はJPEG形式にし、[`write(to:options:)`](https://developer.apple.com/documentation/foundation/data/1779858-write/)でデータを書き込み、書き込み場所として`fileURL`を指定します。
# パフォーマンスの検証と比較
## ①メモリの使用量の違い
メモリ使用量は
**メモリキャッシュ > ディスクキャッシュ**
となり、仮説と同じ結果になりました。
|キャッシュ実装前|メモリキャッシュ実装後|ディスクキャッシュ実装後|
|---|---|---|
|<img src="https://github.com/user-attachments/assets/3a9818b2-a4e9-4128-b482-e6487826ceaa">|<img src="https://github.com/user-attachments/assets/913674ec-309a-4b32-82c1-ecfe28281958">|<img src="https://github.com/user-attachments/assets/69534bbd-688d-4c71-95d9-a4861056723b">|
|79.4MB|79.3MB|68.5MB|



## ②ディスク読み書きの使用量
最初の最初のディスクキャッシュ実装後は初めてのキャッシュなのでWritingの使用量が大きくなった。
ディスク読み書きの使用量は
**ディスクキャッシュ > メモリキャッシュ**
となり、仮説と同じ結果になりました。
|キャッシュ実装前|メモリキャッシュ実装後|最初のディスクキャッシュ実装後|2回目ディスクキャッシュ読み込み後|
|---|---|---|---|
|<img src="https://github.com/user-attachments/assets/82b443ac-c965-4ecb-a951-7bdb5c3000bc">|<img src="https://github.com/user-attachments/assets/276ef5d3-8ab8-4f07-bac1-91a7c900112f">|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3090887/6b739149-10f3-372b-f58e-d0f4a8e547b8.png">|<img src="https://github.com/user-attachments/assets/9549dad0-5972-44fb-9143-207612624b8a">|
|Reading:39.9MB Writing:1.8MB|Reading:35.3MB Writing:2.1MB|Reading:52.1MB Writing:3.5MB|Reading:47.9MB Writing:1.1MB|

## ③ビルド時間
ビルド時間は
**ディスクキャッシュ > メモリキャッシュ**となったのですがそれは偶然で、
**実際はキャッシュの種類がビルド時間に影響することはありません。**

:::note alert
**仮説の訂正**
メモリキャッシュを使用するか、ディスクキャッシュを使用するかは**ビルド時ではなく、実行時の話なので、そもそもビルド時間に影響するということはない**ので仮説として誤っていました。
今回ビルド時間が変わったのは実装の違いによる影響で、仮説の通りになったのは偶然で**キャッシュの種類の違いによりビルド時間が変わったわけではありません。**
:::

|キャッシュ実装前|メモリキャッシュ実装後|ディスクキャッシュ実装後|
|---|---|---|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3090887/9ebba558-36d1-642b-665d-f100a13033fe.png">|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3090887/7f66a483-af57-21f6-8f6b-6db5cedf7c0c.png">|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3090887/2b5edcaa-f079-2a25-0116-74b9bfff1db0.png">|
|PostView:70ms HomeView:61ms|PostView:69ms HomeView:65ms|PostView:74ms HomeView:70ms|

**BuildTimeAnalyzer-for-Xcode**を使用してファイルごとのビルド時間を計測しました。

https://github.com/RobertGummesson/BuildTimeAnalyzer-for-Xcode


# おまけ
今回Firebaseを使用した個人開発にキャッシュを導入しました。
下記のアプリでアイコン画像をディスクキャッシュして、ポスト画像をメモリキャッシュするように実装しました。
|HomeView|
|---|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3090887/b699d8b8-1d5e-8b04-7618-ef8c104c2211.png"width=300>|


Firebase側でのキャッシュもできるとのことで今後使用してみたいなと思います。

https://firebase.google.com/docs/firestore/manage-data/enable-offline?hl=ja

# 参考文献

https://developers.cyberagent.co.jp/blog/archives/7815/

https://qiita.com/john-rocky/items/32c6c17c937007350ce3

https://qiita.com/sauna1137/items/de22f16eacd8978d3e98


https://x.com/k_katsumi/status/1833028094258807071?s=46&t=zVgJsFIm4XuVOot39f8ebg