StateObjectのwrappedValueはclosure内で初期化した方が良い話
SwiftUIで、外部から受け取った値を使ってStateObjectを初期化したい場合、イニシャライザのclosure内で初期化しないと無駄にリソースを使ってしまう可能性がありそうです
ObservedObjectとの違いを確認しながら、StateObjectとの挙動を色々確認した備忘録です
struct HogeView: View {
@StateObject var vm: HogeViewModel
init(count: Int) {
// StateObjectのイニシャライザのclosure内で初期化する例
_vm = StateObject(wrappedValue: HogeViewModel(count: count))
}
// ......
}
struct HogeView: View {
@StateObject var vm: HogeViewModel
init(count: Int) {
// StateObjectのイニシャライザのclosure外で初期化する例
let vm = HogeViewModel(count: count)
_vm = StateObject(wrappedValue: vm)
}
// ......
}
(参考)StateObject.initの定義
StateObject.init
のwrappedValueは@autoclosure
になっており、値を渡しているように見えていますが、closureを渡しています
なので、StateObjectのインスタンス作成と、wrappedValueの作成とはタイミングを変える事が可能になっています
@frozen @propertyWrapper public struct StateObject<ObjectType> : DynamicProperty where ObjectType : ObservableObject {
// ......
@inlinable public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)
// ......
}
ObservedObjectとStateObjectの違い
StateObjectを使うメリットを確認するために、ObservedObjectとStateObjectで挙動の違いを確認しました
ViewとViewModelのイニシャライザでprintしています
struct HogeView: View { // ObservedObjectを使ったHogeView
@ObservedObject var vm: HogeViewModel
init(count: Int) {
print("HogeView.init")
vm = HogeViewModel(count: count)
}
var body: some View { Text("Count: \(vm.count)") }
}
// ↑ or ↓ で比較
struct HogeView: View { // StateObjectを使ったHogeView
@StateObject var vm: HogeViewModel
init(count: Int) {
print("HogeView.init")
_vm = StateObject(wrappedValue: HogeViewModel(count: count))
}
var body: some View {
Text("Count: \(vm.count)")
}
}
class HogeViewModel: ObservableObject { // ViewModelは共通
@Published var count: Int
init(count: Int) {
print("HogeViewModel.init: \(count)")
self.count = count
}
}
上のHogeViewにNavigationLinkで遷移します
struct ContentView: View {
@State var count: Int = 0
var body: some View {
NavigationStack {
Button("Add Count: \(count)") {
count += 1
}
NavigationLink("Link") {
HogeView(count: count)
}
}
}
}
実行結果
それぞれのHogeViewにNavigationLinkで遷移した結果がこちらです
ObservedObject | StateObject |
---|---|
Viewの挙動自体は、一見同じですが、ログに違いがあります
両方ともHogeViewは、ContentViewのcountの更新で作成されます
一方でHogeViewModelは、ObservedObjectの場合はContentViewのcountの更新で、StateObjectの場合は遷移時に作成されます
つまり、ObservedObjectの場合はViewの作成時に、StateObjectの場合はViewの表示時にViewModelが作成されます
そのため、ViewModelのイニシャライザ内で何かしら処理を入れている場合、ObservedObjectでは親Viewの更新で無駄にその処理が実行されてしまう問題が発生してしまいます
StateObjectの場合、イニシャライザのclosureでViewModelを受け取っているので、遅延して必要な時だけ作成するという事ができるようです
StateObjectのwrappedValueはclosure内で初期化した方が良い話
ObservedObjectとStateObjectの違いが見えてきたので、最初の話に戻ります
closure外で初期化した場合、StateObjectがclosure内で必要な時だけ作成してくれる恩恵を受けられなくなります
struct HogeView: View {
@StateObject var vm: HogeViewModel
init(count: Int) {
print("HogeView.init")
// closure外で初期化する例
let vm = HogeViewModel(count: count)
_vm = StateObject(wrappedValue: vm)
}
// ......
}
実行してみると、たしかに親Viewの更新でViewModelが再生成されている事がわかります
もし、Viewの外部からViewModelを入れたい場合は、ViewのイニシャライザでもclosureでViewModelを受け取ると、StateObjectの恩恵を受けられるので良さそうです
struct ContentView: View {
var body: some View {
HogeView(viewModel: HogeViewModel(count: count))
}
}
struct HogeView: View {
@StateObject var vm: HogeViewModel
init(viewModel: @autoclosure @escaping () -> HogeViewModel) {
_vm = StateObject(wrappedValue: viewModel())
}
// ......
}
StateObjectを1つのViewの中のViewで使う場合
NavigationLinkで遷移するのではなく、1つのViewの中のViewでStateObjectを使う場合は工夫が必要です
struct ContentView: View {
@State var count: Int = 0
var body: some View {
VStack {
Button("Add Count: \(count)") {
count += 1
}
HogeView(count: count)
}
}
}
struct HogeView: View {
@StateObject var vm: HogeViewModel
init(count: Int) {
print("HogeView.init")
_vm = StateObject(wrappedValue: HogeViewModel(count: count))
}
var body: some View { Text("Count: \(vm.count)") }
}
実行してみると、HogeViewは再生成されていますが、HogeViewModelは古いインスタンスが使い回されるため、値が更新されません
これにより、ContentViewが更新されたとしても、HogeViewは影響を受けずに別で更新させる事ができるのですが、外部から値を受け取る実装になっている場合、意図しない挙動になる事があります
SwiftUIはViewごとにIDが振られており、StateObjectはViewのIDを参照してインスタンスを使い回すかどうか判定しているようです
そのため、View.id(:)
を使って明示的に更新してあげると、StateObjectも更新されます
struct ContentView: View {
@State var count: Int = 0
var body: some View {
VStack {
Button("Add Count: \(count)") {
count += 1
}
HogeView(count: count)
.id(count) // IDを明示的に更新する
}
}
}
struct HogeView: View {
@StateObject var vm: HogeViewModel
init(count: Int) {
print("HogeView.init")
_vm = StateObject(wrappedValue: HogeViewModel(count: count))
}
var body: some View { Text("Count: \(vm.count)") }
}
明示的にIDを指定する方法以外にも、ForEach.init(_:id:content:)
など、暗黙的にIDが指定される仕組みを使うことで、StateObjectを更新させる事が可能です
まとめ
StateObjectのwrappedValueはイニシャライザのclosure内で初期化した方が良い話でした
基本的に、StateObjectを使うことでインスタンスが必要に応じて使い回されるため、リソースが効率化されます
しかし、使い所によっては、意図しない形でインスタンスが使い回される事があるので注意する必要がありそうです
struct HogeView: View {
@StateObject var vm: HogeViewModel
init(count: Int) {
// closure内で初期化
_vm = StateObject(wrappedValue: HogeViewModel(count: count))
}
// ......
}