Observation (iOS17+) の注意点

今回は監視フレームワーク・通知機構のObservationの話。  
Observationを使用してメモリが圧迫する状況があったので調査しました。  

<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231105/20231105102957.gif" title="Observationでメモリが圧迫"> 

開発アプリPR
<a href="https://play.google.com/store/apps/details?id=jp.gr.java.conf.createapps.musicline">
    <img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231111/20231111170552.png" alt="３分作曲-musicLine-" title="3分作曲-musicLine- Androidアプリ">
</a>


<br>
<br>

# はじめに

ObservationはiOS17で使用できるようになり、`@Observable`をクラスに付けるだけでプロパティ毎に監視できるようになりました。
Combineの時はモデル層が深いとViewの層まで変更通知を繋ぎ込む必要がありますが、Observationではその中間層の繋ぎこむコードが要らなくなり可読性が上がります。

他にも動的に監視対象を変更する手間もObservationにより解決できます。
その辺りの詳しい話は元記事へ

https://musicline-developer.hatenablog.com/entry/2023/11/06/143139/

しかし、CombineからObservationへ監視フレームワークを変更すると、メモリ使用量が上がり続ける問題に直面したので、Observationの挙動について調査しました。

<br>
<br>

# Observationの挙動調査

調査ではMVVMで`Counter`を実装しました。

<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231106/20231106075749.png" title="CounterView"> 

ただしメモリ増加がわかりやすくなるように、1000個の`CounterView`をリストで表示しています。  
また比較対象にCombineでも同様に実装しました。

<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231105/20231105162208.png" title="CounterViewのリスト"> 

<br>
<br>

## メモリが安定するパターン

Observation
<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231105/20231105155237.gif" title="Observation"> 

Combine
<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231105/20231105155206.gif" title="Combine"> 

30MBから始まり、下へスクロールすることで45MBまで増加しますが、その後は何度かスクロールを繰り返しても50MB前後で安定しました。  

最初の下スクロールでメモリ増加する理由は、Listで表示しているので最初のスクロールで`CounterView`と`CounterViewModel`を作成していると思われます。  
その後は`CounterView`の作成は行われないためメモリが安定します。

### Observationの実装

View  
```swift
struct Page: View {
    let counters = (0..<1000).map{ _ in Counter() }
    var body: some View {
        List(0..<1000) { num in
            let counter = counters[num]
            CounterView(label: "Counter\(num)", viewModel: .init(counter: counter))
        }
    }
}
```

```swift
struct CounterView: View{
    let label: String
    let viewModel: CounterViewModel
    
    var body: some View {
        HStack {
            Text("\(label):")
            Text("\(viewModel.count)")
            Button("+") { viewModel.increment() }
        }
    }
}
```

ViewModel
```swift
class CounterViewModel {
    var count: Int {
        counter.count
    }
    private let counter: Counter

    init(counter: Counter) {
        self.counter = counter
    }
    
    func increment() {
        counter.increment()
    }
}
```

Model
```swift
@Observable
class Counter {
    var count = 0

    func increment() {
        self.count += 1
    }
}
```

<br>

### Combineの実装

View
```swift
struct CounterView: View{
    ...
    @ObservedObject var viewModel: CounterViewModel
    ...
}
```

ViewModel
```swift
class CounterViewModel: ObservableObject {
    ...
    private var anyCancellable = Set<AnyCancellable>()
    
    init(counter: Counter) {
        self.counter = counter
        counter.objectWillChange.sink{
            self.objectWillChange.send()
        }.store(in: &anyCancellable)
    }
}
```

Model
```swift
class Counter: ObservableObject {
    @Published var count = 0
    ...
}
```

ソースコードの詳細はこちら

https://musicline-developer.hatenablog.com/entry/2023/11/06/143139?_gl=1*bkslt8*_gcl_au*NzcwMjc2MzYzLjE2OTUwODE3MjE.#:~:text=swiftpackageindex.com-,%E6%A4%9C%E8%A8%BC%E7%94%A8%E3%81%AE%E3%82%B5%E3%83%B3%E3%83%97%E3%83%AB%E5%85%A8%E3%82%B3%E3%83%BC%E3%83%89%E3%82%92%E8%A1%A8%E7%A4%BA,-import%20SwiftUI%0A%0Astruct

<br>
<br>

## メモリ増加になるパターン

前のパターンでは`CounterView`の作成後は再描画が行われませんでした。  
次は`CounterView`の再描画が頻繁に行われるパターンで検証してみます。  

今回は`GeometryReader`により、スクロールに応じてアニメーションを付けることで、頻繁に再描画する状況を作りました。

```swift
struct CounterView: View{
    let label: String
    let viewModel: CounterViewModel
    
    var body: some View {
        GeometryReader{ geometry in
            let shift = sin(geometry.frame(in: .global).minY / geometry.size.height) * 10
            let x = geometry.size.width * 0.5 - shift
            let y = geometry.size.height * 0.5
                HStack {
                    Text("\(label):")
                    Text("\(viewModel.count)")
                    Button("+") { viewModel.increment() }
                }
                .position(x: x, y: y)
    }
}
```

Observation
<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231105/20231105102957.gif" title="Observation"> 

Combine
<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231105/20231105154811.gif" title="Combine"> 

そうすると、Observation実装の方ではスクロールごとにメモリがどんどん使われることがわかります。
どうやらObservableを付与したプロパティのGetterへ頻繁にアクセスすることが原因のようです。

### 追加検証

試しに、Getterのアクセス（@Observableの`access`の呼び出し）を制限してみます。

```swift

@Observable
class Counter {
    var count: Int{
        get {
            if !passingAccess{
                passingAccess = true
                access(keyPath: \._count )
            }
            return _count
        }
        
        set {
            passingAccess = false
            withMutation(keyPath: \._count ) {
                _count  = newValue
            }
        }
    }

    @ObservationIgnored
    var _count = 0

    @ObservationIgnored
    var passingAccess = false
}
```

<img width="500" src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231106/20231106104143.gif" title="accessの呼び出しを制限してメモリ増加を抑える"> 

そうすると、メモリ増加が収まりました。  
ただし、この方法では適切な通知はできないようです。

<img width="500" src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231105/20231105172513.gif" title="スクロール後の通知はされない"> 


<br>
<br>

# Observation注意点のまとめ

`GeometryReader`で座標によりアニメーションするような場合はObservationによるプロパティ監視は控えた方がいいです。そのような場合はViewの再描画によりGetterに頻繁にアクセスしますが、それがメモリ増加が止まらない問題に繋がっていると考えられます。  
ObservationではプロパティのGetterで監視イベントを登録するような仕組みになっているため、このような現象が起こるかもしれません。

そもそもSwiftUIでは必要のないViewの再描画を極力抑える思想なので、再描画が頻繁に行われる設計が問題なのかもしれません。  
でもスクロールによるアニメーション等で再描画が頻繁に行われるケースは結構ありそうな気もします。
そういうケースのための監視フレームワークについてはEventBusを使うのがいいのではないでしょうか？

監視フレームワーク検討の詳細はこちら

https://musicline-developer.hatenablog.com/entry/2023/11/06/143139?_gl=1*bkslt8*_gcl_au*NzcwMjc2MzYzLjE2OTUwODE3MjE.#:~:text=%E6%B0%97%E3%82%82%E3%81%97%E3%81%BE%E3%81%99%E3%80%82-,%E7%9B%A3%E8%A6%96%E3%83%95%E3%83%AC%E3%83%BC%E3%83%A0%E3%83%AF%E3%83%BC%E3%82%AF%E3%82%92%E5%86%8D%E8%80%83,-Observation%E3%81%AE%E5%95%8F%E9%A1%8C

<br>

開発アプリPR
<a href="https://play.google.com/store/apps/details?id=jp.gr.java.conf.createapps.musicline">
    <img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20231111/20231111170552.png" alt="３分作曲-musicLine-" title="3分作曲-musicLine- Androidアプリ">
</a>

<br>