今回は監視フレームワーク・通知機構のObservationの話。
Observationを使用してメモリが圧迫する状況があったので調査しました。
はじめに
ObservationはiOS17で使用できるようになり、@Observable
をクラスに付けるだけでプロパティ毎に監視できるようになりました。
Combineの時はモデル層が深いとViewの層まで変更通知を繋ぎ込む必要がありますが、Observationではその中間層の繋ぎこむコードが要らなくなり可読性が上がります。
他にも動的に監視対象を変更する手間もObservationにより解決できます。
その辺りの詳しい話は元記事へ
しかし、CombineからObservationへ監視フレームワークを変更すると、メモリ使用量が上がり続ける問題に直面したので、Observationの挙動について調査しました。
Observationの挙動調査
調査ではMVVMでCounter
を実装しました。
ただしメモリ増加がわかりやすくなるように、1000個のCounterView
をリストで表示しています。
また比較対象にCombineでも同様に実装しました。
メモリが安定するパターン
30MBから始まり、下へスクロールすることで45MBまで増加しますが、その後は何度かスクロールを繰り返しても50MB前後で安定しました。
最初の下スクロールでメモリ増加する理由は、Listで表示しているので最初のスクロールでCounterView
とCounterViewModel
を作成していると思われます。
その後は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実装の方ではスクロールごとにメモリがどんどん使われることがわかります。
どうやらObservableを付与したプロパティのGetterへ頻繁にアクセスすることが原因のようです。
追加検証
試しに、Getterのアクセス(@Observableのaccess
の呼び出し)を制限してみます。
@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を使うのがいいのではないでしょうか?
監視フレームワーク検討の詳細はこちら