今回は監視フレームワーク・通知機構のObservationの話。
Observationを使用してメモリが圧迫する状況があったので調査しました。
はじめに
musicLineではMIDI楽譜を編集するためのデータ構造として、トラックやフレーズ、音符、座標というように階層的にモデルを構築しています。
例えば、曲データは複数のトラック、トラックは複数のフレーズを保持しているような構造です。
詳しくはこちら
このような階層的なデータ構造だと、Viewへ変更通知を送る場合に監視は自身のプロパティだけでなく、下層モデルのプロパティまで広がるため、適切な監視体制が求められます。
Combineの問題点
CombineのObservableObject
では@Published
を付与したプロパティが監視対象となりますが、変更通知はインスタンスを保持している上層にしか届きません。
そのため、Viewの層(@ObservedObject
や@StateObject
)まで通知を届けるとなると、親のモデルもObservableObject
にして、通知を繋ぎ込む(リレー的に監視する)必要がありました。
// 子を監視して、子からの変更通知があれば上へ流す counter.objectWillChange.sink{ self.objectWillChange.send() }.store(in: &anyCancellable)
詳しくはこちら
Observationで解決?
iOS17でObservationを使えるようになりました。
CombineのObservableObject
はクラス毎に変更を監視することに対し、Observationの@Observable
ではプロパティ毎に監視しています。
そのためどれだけModel階層が深くても、Viewでプロパティを使用すれば通知が届くようになりました。
つまり、Combineのように通知を繋ぎ込むことなくViewの層まで変更通知を届けられるため、中間の煩雑なコードが削減できます。
またmusicLineでは、音符やフレーズの追加・削除が頻繁にされるため、その度に繋ぎこむ処理が必要なので監視対象の管理も大変でした。
この動的な監視対象の管理問題もObservationにより解決できましたが、新たな問題がありました。
Observationの問題点
@Observable
により末端モデルの音符を監視します。
移動した音符は黄色になっており、通知が適切にViewに届くことを確認できます。
しかしスクロールするたびに、メモリが増え続ける問題がありました。
試しに、1トラック300フレーズの中に音符約1000個を敷き詰めてメモリ量を確認しました。
スクロールで画面を再描画する度にメモリが上がり続けることがわかります。iPhoneのメモリは8GBとか16GBなので、これは見逃せる増加量ではないですね。
そもそも、音符を配置しただけでメモリが1GBを超えているのも問題です。
ここまで音符を敷き詰めることは稀ではありますが、メモリはなるべく少なく抑えたいところです。
音符は4個のプロパティ(位置とサイズ等)があるため、120万(300 x 1000 x 4)のプロパティを監視している計算になります。
この時点で120万のプロパティを全て監視しようとする使い方が間違えているなあという感じはしました。
そして、Observationの挙動調査へ
musicLineの音符のように膨大なプロパティを監視する状況では、Observationの使用は不適切だと認識しました。(メモリ増加問題を除いても)
しかし他の場面(コミュニティの曲リスト等)で使用できるか確認したかったので、どういうパターンの時にメモリ増加問題が起こるかを調査しました。
なおmusicLineのモデル実装が悪い可能性があるので、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の再描画を極力抑える思想なので、再描画が頻繁に行われる設計が問題なのかもしれません。
でもスクロールによるアニメーション等で再描画が頻繁に行われるケースは結構ありそうな気もします。
監視フレームワークを再考
Observationの問題がわかりましたが、結局musicLineの音符のように膨大なプロパティを監視するための適切なフレームワークはどうするべきでしょう。もう一度Combineを使う方針に戻って考えてみます。
Combineでは次の観点で問題になることがあります。
- メモリの圧迫
- 監視対象の管理が大変
これは特に
- モデル階層が深い
- 監視するプロパティが膨大
- 監視対象が動的に変化
といった特徴のときに起こります。
EventBus
Combineは膨大なモデルを全てObservableObjectとして監視するため、メモリが圧迫します。さらに、階層が深くなることで通知を繋ぎこむ処理が必要になります。
この問題を解消するためにObservableObjectは1個のオブジェクトのみにしました。つまり監視するオブジェクトはEventBusのみにして、EventBusを仲介して通知を送ります。
中間の通知を繋ぎこむコードがなくなり、メモリもCombineの実装より3MBほど軽減されました。
ただし、EventBusだと不必要なViewを再描画してしまう欠点があります。EventBusは監視を1箇所にまとめるため、カウントアップしたCounterView
のみを再描画することはできず、再描画するときは必ず画面全体になります。またEventBusは通知を飛ばせるので便利ですが、基本的にはモデルの構造を無視するシングルトンみたいなものなので、あまり多用すると処理が追えなくなるのも注意です。
実装
EventBusの実装を単純化するためにシングルトンでDIしています。
View
struct Page: View { ... @StateObject private var eventBus = EventBus.shared ... }
Model
class Counter { var count = 0 { willSet { eventBus.send() } } private let eventBus = EventBus.shared } class EventBus: ObservableObject { static let shared = EventBus() func send(){ objectWillChange.send() } }
なおサンプルでは簡易的な実装ですが、Eventの種類やプロパティを送れるようにSwiftで実装しているライブラリがありました。
検証用のサンプル全コードを表示
import SwiftUI struct Page_Observation: View { let counters = (0..<1000).map{ _ in Counter_Observation() } var body: some View { content(CounterView_Observation.self, counters: counters) } } struct Page_Combine: View { let counters = (0..<1000).map{ _ in Counter_Combine() } var body: some View { content(CounterView_Combine.self, counters: counters) } } struct Page_EventBus: View { let counters = (0..<1000).map{ _ in Counter_EventBus() } @StateObject private var eventBus = EventBus.shared var body: some View { content(CounterView_EventBus.self, counters: counters) } } fileprivate func content<TCounterView: CounterView, TCounter: Counter>( _: TCounterView.Type, counters: [TCounter]) -> some View where TCounter == TCounterView.TViewModel.TCounter { List(0..<1000) { num in let counter = counters[num] TCounterView(label: "Counter\(num)", viewModel: .init(counter: counter)) .buttonStyle(.borderless) } }
import SwiftUI protocol CounterView: View { associatedtype TViewModel: CounterViewModel var label: String { get } var viewModel: TViewModel { get } init(label: String, viewModel: TViewModel) } struct CounterView_Observation: CounterView{ let label: String let viewModel: CounterViewModel_Observation var body: some View { animationContent(label: label, viewModel: viewModel) } } struct CounterView_Combine: CounterView{ let label: String @ObservedObject var viewModel: CounterViewModel_Combine var body: some View { animationContent(label: label, viewModel: viewModel) } } struct CounterView_EventBus: CounterView{ let label: String let viewModel: CounterViewModel_EventBus var body: some View { animationContent(label: label, viewModel: viewModel) } } fileprivate func animationContent(label: String, viewModel: some CounterViewModel) -> 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 content(label: label, viewModel: viewModel) .font(.body.monospacedDigit()) .position(x: x, y: y) } } fileprivate func content(label: String, viewModel: some CounterViewModel) -> some View{ HStack { Text("\(label):") Button("-") { viewModel.decrement() } Text("\(viewModel.count)") Button("+") { viewModel.increment() } }.font(.body.monospacedDigit()) }
import SwiftUI import Combine protocol CounterViewModel { associatedtype TCounter: Counter var counter: TCounter { get } init(counter: TCounter) } extension CounterViewModel { var count: Int { counter.count } func increment() { counter.increment() } func decrement() { counter.decrement() } } class CounterViewModel_Observation: CounterViewModel { let counter: Counter_Observation required init(counter: Counter_Observation) { self.counter = counter } } class CounterViewModel_Combine: ObservableObject, CounterViewModel { let counter: Counter_Combine private var anyCancellable = Set<AnyCancellable>() required init(counter: Counter_Combine) { self.counter = counter counter.objectWillChange.sink{ self.objectWillChange.send() }.store(in: &anyCancellable) } } class CounterViewModel_EventBus: CounterViewModel { let counter: Counter_EventBus required init(counter: Counter_EventBus) { self.counter = counter } }
import Foundation protocol Counter: AnyObject { var count: Int { get set } } extension Counter { func increment() { self.count += 1 } func decrement() { self.count -= 1 } } @Observable class Counter_Observation: Counter { var count = 0 } class Counter_Combine: ObservableObject, Counter { @Published var count = 0 } class Counter_EventBus: Counter { var count = 0 { willSet { eventBus.send() } } private let eventBus = EventBus.shared } class EventBus: ObservableObject { static let shared = EventBus() private init() {} func send(){ objectWillChange.send() } }
おわりに
今回はObservationの挙動について調査して注意点をまとめました。
Observationは監視フレームワークとして便利ですが、現時点ではGetterに頻繁にアクセスするような場合はメモリ増加が止まらない問題があるようです。
この問題を除いても、監視対象が膨大にある状況では全てプロパティを監視することになるのでメモリ圧迫に繋がります。
その場合は、従来通りCombineやEventBusのような方法を考えるといいと思います。
今回の結果を踏まえて、musicLineにEventBusの仕組みで通知機構を組み込んだのがこちら
なんということでしょう。
1GB以上使用していたメモリが100MB以下に抑えられています。
フレームワークは慎重に選ばないと大変なことになることがわかりました。
ともかく問題が解決してよかった。