musicLineアプリ開発日記

作曲を誰でも楽しく簡単に♪

Observation (iOS17+) の注意点

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

Observationでメモリが圧迫



はじめに

musicLineではMIDI楽譜を編集するためのデータ構造として、トラックやフレーズ、音符、座標というように階層的にモデルを構築しています。
例えば、曲データは複数のトラック、トラックは複数のフレーズを保持しているような構造です。

https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230513/20230513152016.png
データ階層

詳しくはこちら


このような階層的なデータ構造だと、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の層まで変更通知を届けられるため、中間の煩雑なコードが削減できます。

qiita.com

またmusicLineでは、音符やフレーズの追加・削除が頻繁にされるため、その度に繋ぎこむ処理が必要なので監視対象の管理も大変でした。
この動的な監視対象の管理問題もObservationにより解決できましたが、新たな問題がありました。


Observationの問題点

@Observableにより末端モデルの音符を監視します。
移動した音符は黄色になっており、通知が適切にViewに届くことを確認できます。
しかしスクロールするたびに、メモリが増え続ける問題がありました。
試しに、1トラック300フレーズの中に音符約1000個を敷き詰めてメモリ量を確認しました。

Observationでメモリ使用量が増加

スクロールで画面を再描画する度にメモリが上がり続けることがわかります。iPhoneのメモリは8GBとか16GBなので、これは見逃せる増加量ではないですね。

そもそも、音符を配置しただけでメモリが1GBを超えているのも問題です。
ここまで音符を敷き詰めることは稀ではありますが、メモリはなるべく少なく抑えたいところです。

音符は4個のプロパティ(位置とサイズ等)があるため、120万(300 x 1000 x 4)のプロパティを監視している計算になります。
この時点で120万のプロパティを全て監視しようとする使い方が間違えているなあという感じはしました。


そして、Observationの挙動調査へ

musicLineの音符のように膨大なプロパティを監視する状況では、Observationの使用は不適切だと認識しました。(メモリ増加問題を除いても)
しかし他の場面(コミュニティの曲リスト等)で使用できるか確認したかったので、どういうパターンの時にメモリ増加問題が起こるかを調査しました。

なおmusicLineのモデル実装が悪い可能性があるので、Observationの最小限サンプルを作成しました。




Observationの挙動調査

調査では通知を上層へ繋ぐことを想定して、MVVMでCounterを実装しました。ただしメモリ増加がわかりやすくなるように、1000個のCounterViewをリストで表示しています。
また比較対象にCombineでも同様に実装しました。

CounterView

CounterViewのリスト


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

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のアクセス(@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
}

accessの呼び出しを制限してメモリ増加を抑える

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

スクロール後の通知はされない



Observation注意点のまとめ

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

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




監視フレームワークを再考

Observationの問題がわかりましたが、結局musicLineの音符のように膨大なプロパティを監視するための適切なフレームワークはどうするべきでしょう。もう一度Combineを使う方針に戻って考えてみます。

Combineでは次の観点で問題になることがあります。

  • メモリの圧迫
  • 監視対象の管理が大変


これは特に

  • モデル階層が深い
  • 監視するプロパティが膨大
  • 監視対象が動的に変化

といった特徴のときに起こります。


EventBus

Combineは膨大なモデルを全てObservableObjectとして監視するため、メモリが圧迫します。さらに、階層が深くなることで通知を繋ぎこむ処理が必要になります。

この問題を解消するためにObservableObjectは1個のオブジェクトのみにしました。つまり監視するオブジェクトはEventBusのみにして、EventBusを仲介して通知を送ります。

greenrobot.org

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で実装しているライブラリがありました。

swiftpackageindex.com


検証用のサンプル全コードを表示

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以下に抑えられています。
フレームワークは慎重に選ばないと大変なことになることがわかりました。
ともかく問題が解決してよかった。