SwiftUIのLazyVGridで表示した画像のメモリを解放したい

記事の内容はこちらを参照してください。
https://mishimay.github.io/c6563c8251096443b639/
<!--

## ObservableObjectを使って実装

- ObservableObjectのinitと画像取得がlazyに実行されていることが確認できる
- しかしスクロールしていってもdeinitが呼ばれない
- したがって画像のメモリも解放されず、アプリの**メモリ使用量が増え続ける**

```swift
struct ContentView: View {
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
                ForEach(0..<1000, id: \.self) { id in
                    SwiftUIImageView(id: id)
                        .frame(width: 100, height: 100)
                }
            }
        }
    }
}

struct SwiftUIImageView: View {
    @StateObject var vm = SwiftUIImageViewModel()
    let id: Int

    var body: some View {
        ZStack {
            if let image = vm.image {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFill()
            }
        }
        .onAppear {
            vm.fetchImage(id: id)
        }
    }
}

class SwiftUIImageViewModel: ObservableObject {
    @Published var image: UIImage?
    var id: Int = 0

    init() {
        print(#fileID, #function)
    }

    func fetchImage(id: Int) {
        let url = URL(string: "https://picsum.photos/500/500")!
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            if let data = data, let image = UIImage(data: data) {
                DispatchQueue.main.async {
                    self?.image = image
                }
            }
        }.resume()

        print(#fileID, #function, id)
    }

    deinit {
        print(#fileID, #function, id)
    }
}
```

## AsyncImageを使って実装
- iOS 15 から使用可
- **メモリ使用量が増え続ける**
- そもそも、一度に大量の画像を読み込もうとすると以下のようなエラーが出て**表示されない画像がある**
  - `HTTP load failed, 0/0 bytes (error code: -1005 [1:57])`

```swift
struct ContentView: View {
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
                ForEach(0..<1000, id: \.self) { _ in
                    if #available(iOS 15.0, *) {
                        AsyncImage(url: URL(string: "https://picsum.photos/500/500")!) { image in
                            image
                                .resizable()
                                .scaledToFill()
                                .frame(width: 100, height: 100)
                        } placeholder: {
                            ProgressView()
                        }
                    }
                }
            }
        }
    }
}
```


## UIViewRepresentableを使って実装

- 画面外に行ったImageViewはdeinitが呼ばれ、**メモリ使用量の増加は見られない**
- ただ、**iOS 14 系で画面外ぎりぎりのViewのinitとdeinitが連続で呼ばれ続ける問題がありそう**
  - iOS 15 系では直っていそう
  - 一度取得した画像をキャッシュするなどの対策をすればそれほど問題にはならなそうだが・・・

```swift
struct ContentView: View {
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
                ForEach(0..<1000, id: \.self) { id in
                    UIViewImageView(id: id)
                        .frame(width: 100, height: 100)
                }
            }
        }
    }
}

struct UIViewImageView: UIViewRepresentable {
    class ImageView: UIImageView {
        private let id: Int
        init(id: Int) {
            self.id = id
            super.init(frame: .null)

            contentMode = .scaleAspectFill
            setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
            setContentCompressionResistancePriority(.defaultLow, for: .vertical)

            let url = URL(string: "https://picsum.photos/500/500")!
            URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
                if let data = data, let image = UIImage(data: data) {
                    DispatchQueue.main.async {
                        self?.image = image
                    }
                }
            }.resume()

            print(#fileID, #function, id)
        }
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        deinit {
            print(#fileID, #function, id)
        }
    }

    let id: Int
    func makeUIView(context: Context) -> UIView {
        ImageView(id: id)
    }
    func updateUIView(_ uiView: UIView, context: Context) {
    }
}
```

## UIViewControllerRepresentableを使って実装

- **メモリ使用量の増加は見られない**
- iOS 14 系でも期待通りに動いていそう

```swift
struct ContentView: View {
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
                ForEach(0..<1000, id: \.self) { id in
                    UIViewControllerImageView(id: id)
                        .frame(width: 100, height: 100)
                }
            }
        }
    }
}

struct UIViewControllerImageView: UIViewControllerRepresentable {
    class ImageViewController: UIViewController {
        private let id: Int
        private lazy var imageView: UIImageView = {
            let imageView = UIImageView()
            imageView.contentMode = .scaleAspectFill
            return imageView
        }()
        init(id: Int) {
            self.id = id
            super.init(nibName: nil, bundle: nil)

            print(#fileID, #function, id)
        }
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        override func viewDidLoad() {
            super.viewDidLoad()

            view.addSubview(imageView)

            let url = URL(string: "https://picsum.photos/500/500")!
            URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
                if let data = data, let image = UIImage(data: data) {
                    DispatchQueue.main.async {
                        self?.imageView.image = image
                    }
                }
            }.resume()
        }
        override func viewDidLayoutSubviews() {
            super.viewDidLayoutSubviews()

            imageView.frame = CGRect(origin: .zero, size: view.bounds.size)
        }
        deinit {
            print(#fileID, #function, id)
        }
    }

    let id: Int
    func makeUIViewController(context: Context) -> UIViewController {
        ImageViewController(id: id)
    }
    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
    }
}
```

入れ子にしても問題なさそう

```swift
struct ContentView: View {
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
                ForEach(0..<1000, id: \.self) { id in
                    VStack {
                        Text(String(id))
                        ContainerView {
                            UIViewControllerImageView(id: id)
                                .frame(width: 100, height: 100)
                        }
                    }
                }
            }
        }
    }
}

struct ContainerView<Content: View>: View {
    @ViewBuilder let content: Content
    var body: some View {
        content
    }
}
```


## GeometoryReaderで座標を取得して画面外に行ったらメモリを開放

- 以下のように座標を取得して適宜メモリを開放する機構を独自に作る
- 細かい制御が可能

https://qiita.com/mishimay/items/f2de749f0a2a6591e7cf


## 環境
Xcode 13.4
Swift 5.6.1


-->