3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SwiftAdvent Calendar 2023

Day 8

Observation (iOS17+) の注意点

Last updated at Posted at 2023-11-12

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

開発アプリPR

3分作曲-musicLine-



はじめに

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

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

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



Observationの挙動調査

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

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



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

Observation

Combine

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

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

Observationの実装

View

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))
        }
    }
}
struct CounterView: View{
    let label: String
    let viewModel: CounterViewModel
    
    var body: some View {
        HStack {
            Text("\(label):")
            Text("\(viewModel.count)")
            Button("+") { viewModel.increment() }
        }
    }
}

ViewModel

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

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

Model

@Observable
class Counter {
    var count = 0

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

Combineの実装

View

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

ViewModel

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

Model

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

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



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

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

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

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

Combine

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

追加検証

試しに、Getterのアクセス(@Observableaccessの呼び出し)を制限してみます。


@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
}

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



Observation注意点のまとめ

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

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

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


開発アプリPR

3分作曲-musicLine-


3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?