musicLineでは画面をSwiftUIを使って描画しています。
今回はその描画のコストが気になったので、その調査と最適化した話。
はじめに
musicLineは作曲するアプリですが、壮大な曲になると音符が多くなります。画面に表示する音符が増えると、描画コストが高くなり操作感が悪くなります。
例えば、画面移動する時や音符を移動する時に描画コストが高いと、操作に時差が出たりコマ落ちしてカクカクした動きになります。できれば作り込んだり縮小したりして、画面内の音符が多くなっても、画面は滑らかに動いて欲しいものです。
その辺りが気になったので、プロジェクトが大きくなる前に、SwiftUIで無駄なく適切に描画できているか調査しました。
具体的には、描画コストとして3項目を確認しました。
- Viewの描画回数
- ViewModelの作成回数
- ViewModelからの通知回数
描画コストの表示は自作した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
は@Published
やObservableObject
からの通知を受け取り、再描画します。
通知の詳細はこちら
描画コストを考えなければ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
でラップしたインスタンスが本当にキャッシュされるかを確認するために、サンプルを作ってみました。
StateConterView
とObservedCounterView
が子Viewとして、親のCounterView
にぶら下がっている構造です。
CounterView
のカウントをアップすると、各々の子Viewの親カウントもアップしますが、StateConterView
とObservedCounterView
で挙動が異なります。
通知された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を再描画することができました。