LoginSignup
21
11

More than 1 year has passed since last update.

StateObjectのwrappedValueはイニシャライザのclosure内で初期化した方が良い話

Posted at

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
ObservedObject.gif StateObject.gif

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))
    }
    // ......
}
21
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
11