musicLineアプリ開発日記

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

描画コストの最適化

musicLineでは画面をSwiftUIを使って描画しています。
今回はその描画のコストが気になったので、その調査と最適化した話。

描画コストの表示


はじめに

musicLineは作曲するアプリですが、壮大な曲になると音符が多くなります。画面に表示する音符が増えると、描画コストが高くなり操作感が悪くなります。

例えば、画面移動する時や音符を移動する時に描画コストが高いと、操作に時差が出たりコマ落ちしてカクカクした動きになります。できれば作り込んだり縮小したりして、画面内の音符が多くなっても、画面は滑らかに動いて欲しいものです。

その辺りが気になったので、プロジェクトが大きくなる前に、SwiftUIで無駄なく適切に描画できているか調査しました。
具体的には、描画コストとして3項目を確認しました。

  • Viewの描画回数
  • ViewModelの作成回数
  • ViewModelからの通知回数

描画コストの表示
Viewが33回の描画、ViewModelが1回の作成・10回の通知している状態


描画コストの表示は自作したDrawCostMonitorViewを調査対象のViewに貼り付けることで行っています。DrawCostMonitorViewの実装はまたの機会に紹介します。


調査対象の操作パターン

今回は2パターンの操作で描画コストを調査しました。

  • 音符を動かす
  • 画面を動かす


この操作で影響するViewは5個あります。

  • CompositionView
  • TransformView
  • SongView
  • EditTrackView
  • MelodyNotesView
    (親から子の順)


なお、各々のViewは以下の画面部分について描画の役割を担っています。

各ビューの描画部分

Viewのクラス図等の詳細はこちら


現状の問題

調査対象の操作パターンを行い、現状の問題を表示しました。

音符移動
画面移動

SwiftUIの挙動として、ViewModelからViewに通知した場合、Viewを再描画してViewに貼ってある子Viewが再作成される流れになります。

そのため、Viewと一緒にViewModelを作成していると、ViewModelも再作成されます。

つまり、Viewが描画される度に子ViewのViewModelが再作成されることがコスト上問題になります。


解決方法

Viewが描画される時にViewModelの再作成を回避する方法を調査しました。

ViewModelをキャッシュする

ViewのプロパティにViewModelを保持していますが、今まではObservedObjectでラップしてました。また、Viewを作成する時に、コンストラクターの引数にViewModelを作成して渡していました。

struct TrackView: View {
    @ObservedObject var viewModel: TrackViewModel
    ...
}

TrackView(viewModel: song.createTrackViewModel())

ObservedObject@PublishedObservableObjectからの通知を受け取り、再描画します。

通知の詳細はこちら


描画コストを考えなければObservedObjectで問題ないですが、ここでStateObjectでラップすることで、ObservedObjectの機能に加えて、さらにインスタンスをキャッシュする機能も付与できます。

struct TrackView: View {
    @StateObject var viewModel: TrackViewModel
    ...
}

TrackView(viewModel: song.createTrackViewModel())

Viewを再作成するときに、Viewの状態(他のプロパティ値)が異なる場合に再描画(bodyの内容に更新)されますが、StateObjectでラップしているプロパティはキャッシュしたインスタンスを再利用します。


ちなみに、StateObjectのコンストラクタを見ると、@autoclosureになっており、Viewを新しく作成する時にファクトリーのような感じでクロージャーを使用していると考えられます。なので、実際には初期化の時点でViewModelのインスタンスを作成している訳ではないです。

 @inlinable public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)


StateObjectの確認サンプル

StateObjectでラップしたインスタンスが本当にキャッシュされるかを確認するために、サンプルを作ってみました。

StateObjectの挙動確認

StateConterViewObservedCounterViewが子Viewとして、親のCounterViewにぶら下がっている構造です。
CounterViewのカウントをアップすると、各々の子Viewの親カウントもアップしますが、StateConterViewObservedCounterViewで挙動が異なります。
通知されたCounterViewは再描画で子Viewを再作成しますが、StateConterViewのViewModelは作成していません。そのため、StateConterViewではカウントアップした状態で親Viewを再描画しても、カウントを維持できています。

全コードを表示

struct CounterView: View {        
    @StateObject private var counter =  Counter()
        
    var body: some View{
        VStack {
            Spacer()
                
            DrawCostMonitorView(id: "CounterView", observedObject: counter)
            Button {
                counter.count += 1
            } label: {
                CountView(title: "カウントアップ", count: counter.count)
            }
            Spacer()
                
            Text("子View")
            StateCounterView(counter: Counter(), parentCount: $counter.count).padding().border(Color.black)
            ObservedCounterView(counter: Counter(), parentCount: $counter.count).padding().border(Color.black)
        }
    }
}
struct StateCounterView: View {        
    @StateObject var counter: Counter
    @Binding var parentCount: Int

    var body: some View{
        VStack {
            DrawCostMonitorView(id: "StateCounterView", observedObject: counter)
            HStack(spacing: 50) {
                CountView(title: "親カウント", count: parentCount)
                Button {
                    counter.up()
                } label: {
                    CountView(title: "カウントアップ", count: counter.count)
                }
            }
        }
    }
}
struct ObservedCounterView: View {        
    @ObservedObject var counter: Counter
    @Binding var parentCount: Int

    var body: some View {
        VStack {
            DrawCostMonitorView(id: "ObservedCounterView", observedObject: counter)
            HStack(spacing: 50) {
                CountView(title: "親カウント", count: parentCount)
                Button {
                    counter.up()
                } label: {
                    CountView(title: "カウントアップ", count: counter.count)
                }
            }
        }
    }
}
struct CountView: View {
    let title: String
    let count: Int

    var body: some View {
        VStack {
            Text("\(count)").font(.title)
            Text(title).font(.caption)
        }
        .padding()
    }
}
class Counter: ObservableObject {
    @Published var count = 0
    func up() { count += 1 }
}


配列をObservableObjectでラップする

複数のViewModelを配列にして作成する箇所があり、これだと描画する度に多くのViewModelが再作成されることになります。

var body: some View {
    ZStack {
        let viewModels = song.createTrackViewModels()
        ForEach(viewModels, id: \.id){
            TrackView(trackViewModel: $0)
        }
    }
}


配列もStateObjectでキャッシュできれば良いですが、配列はObservableObjectではないのでStateObjectでラップできません。

そのため、配列をObservableObjectでラップします。

public class ListContainerViewModel<TObject: Any>: ObservableObject{
    
    @Published public var list: [TObject]
    
    public init(list: [TObject]) {
        self.list = list
        print("ListContainerViewModel.init")
    }
}

ObservableObjectを継承したListContainerViewModelに配列を格納することで、StateObjectでキャッシュできるようになりました。

後は新たにViewを作成して、ListContainerViewModelプロパティをキャッシュします。

@StateObject var viewModel: ListContainerViewModel<TrackView>

var body: some View {
    ZStack {
        ForEach(viewModel.list, id: \.id){
            TrackView(trackViewModel: $0)
        }
    }
}
var body: some View {
    TracksView(trackViewModel: song.createTracksContainerViewModel())
}


おわりに

解決方法を適用することで、描画コストを最適化しました。

音符移動
画面移動

音符移動や画面移動時に、必要以上にViewModelを再作成せず、Viewを再描画することができました。