iyOmSd/Title: SwiftUI

[SwiftUI] StateObject init 생성자

냄수 2023. 8. 28. 20:41
반응형

이글을 쓰게된 동기는...

정말 아무렇지않게 습관처럼 코드를 작성하다가 겪은 이슈를 공유하려 합니다!

 

StateObject를 사용할 때 어떻게 알고 사용하시나요?

 

보통은 ObservedObject랑 비교를 하면서

ownership이 다르다

뷰의 생명주기와 별개다

별도로 저장돼서 사용된다

뷰의 수명동안 새로운 인스턴스를 한번만 생성한다

정도로 알고 사용하는거라고 생각해요

그래서 ObservedObject와 차이를 두며 사용을 하곤하는것 같아요! (저도 이랬던거같네요... ㅎ)

 

이렇게 사용하다보면 간혹 만나는 문제점이 생겨요

struct StartView: View {
    @State private var selectedNum: Int = 10
    
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 10) {
                Button {
                    selectedNum = 1
                } label: {
                    Text("1")
                }
                Button {
                    selectedNum = 4
                } label: {
                    Text("2")
                }
                Button {
                    selectedNum = 6
                } label: {
                    Text("3")
                }
                Button {
                    selectedNum += 1
                } label: {
                    Text("+")
                }
                Text("num: \(selectedNum)")
                Divider()
                ContentView(num: selectedNum)
            }
        }
        .refreshable {
            selectedNum = 20
        }
    }
}

 

class ViewModel: ObservableObject {
    @Published var num: Int
    @Published var result: Int = 0
    
    init(num: Int) {
        self.num = num
        print("init ViewModel")
        print("init num: ", self.num)
    }
}

 

struct ContentView: View {
    @ObservedObject private var viewModel: ViewModel
    
    init(num: Int) {
        print("view init")
        self.viewModel = ViewModel(num: num)
    }
    
    var body: some View {
        VStack {
            Text("View2")
            Text("num: \(viewModel.num)")
        }
    }
}

/*
+ 버튼을 누를때마다 로그
view init
init ViewModel
init num:  10
view init
init ViewModel
init num:  11
view init
init ViewModel
init num:  12
view init
init ViewModel
init num:  13

*/

회색선사이로 뷰1과 뷰2인 구조를 만들었어요

+버튼을누르면 영상처럼 같이 숫자가 증가하는거를 기대하곤하죠!

 

하지만 StateObject를 잘못사용한다면

케이스 1.

struct ContentView: View {
    @StateObject private var viewModel: ViewModel  << 변경
    
    init(num: Int) {
        print("view init")
        self._viewModel = StateObject(wrappedValue: ViewModel(num: num))  << 변경
    }
    
    var body: some View {
        VStack {
            Text("View2")
            Text("num: \(viewModel.num)")
        }
    }
}

/*
view init
init ViewModel
init num:  10
view init
view init
view init
view init
view init
view init
*/

view2에서 num이 변경되질않아요

ViewModel init이 1번만 불려서 +를 눌러서 증가된 num의 값을 전달할 수 없기 때문이죠

StateObject의 생성은 최초 1번만 이뤄져요!

자세한건 아래에서 다시 다뤄볼게요

 

다음케이스

케이스2.

struct ContentView: View {
    @StateObject private var viewModel: ViewModel
    
    init(num: Int) {
        print("view init")
        let viewModel = ViewModel(num: num)   << 변경
        self._viewModel = StateObject(wrappedValue: viewModel)    << 변경
    }
    
    var body: some View {
        VStack {
            Text("View2")
            Text("num: \(viewModel.num)")
        }
    }
}

/*
view init
init ViewModel
init num:  10
view init
init ViewModel
init num:  11
view init
init ViewModel
init num:  12
view init
init ViewModel
init num:  13
view init
init ViewModel
init num:  14
view init
init ViewModel
init num:  15
view init
init ViewModel
init num:  16
*/

케이스1과 같이 케이스2에서 뷰2의 데이터는 변하지않아요

하지만 케이스1과 다르게 생성자가 안불려서 변하지않는게 아니라

뷰모델의 생성자가 불리는데도 불구하고 num의 값은 변치않고있죠

 

예상대로라면

생성자가 실행되니까 새로운 값으로 대치돼서 보여야하죠

 

보통 문서의 일부분만 읽습니다

위에서 저는 이렇게말햇죠

ObservedObject와 차이를 두며 사용을 하곤하는것 같아요

정말 중요한건 아래에있었습니다...!

SwiftUI only initializes a state object the first time you call its initializer in a given view. This ensures that the object provides stable storage even as the view’s inputs change. However, it might result in unexpected behavior or unwanted side effects if you explicitly initialize the state object.

 

SwiftUI는 지정된 뷰에서 생성자를 처음 호출 할때만 StateObject를 초기화한다

이렇게하면 뷰입력이 변경되더라도 객체가 안정적인 저장소를 제공할 수 있다.

그러나 명시적으로 StateObject를 초기화하면 원치않는 동작이나 side effect가 발생할 수 있다

습관적으로 코드를 이쁘게하기위해

        let viewModel = ViewModel(num: num)
        self._viewModel = StateObject(wrappedValue: viewModel)

이렇게 나눠서 구현하는건 StateObject생성에 한해서 절대로 좋지못한 행동이였습니다 😳

 

길어도 더러워도 아래코드와 같이 선언하기를 권장해요!

self._viewModel = StateObject(wrappedValue: ViewModel(num: num))

 

위 영상처럼 값이 나뉜이유는

view1의 값을 view2에 최초로 전달할때 10을 전달하면서 10을 들고있는 스토리지(a)를 처음생성하여 사용하고

view1값이 변할때 위의 코드처럼 view2 뷰모델이 매번 새로생성되고

+되며 11, 12, 13값을 전달하며 새로운스토리지(b)를 만들게되죠

이러한 스토리지(b)는 무시되며 (+할때마다 11, 12, 13값의 뷰모델이 deinit이 되는걸 확인할 수 있죠)

StateObject특성상 처음에 생성된 스토리지(a)를 바라보며 10인 초기값을 들고있게됩니다

 

view2에있는 뷰모델에 접근해서 +를 구현해서 동작하면 스토리지(a)의값이 정상적으로 11, 12로 변합니다.

StateObject니까 생성을 1번만하겠지하고 생성자에 코드를 구현하면

생성자의 코드는 이러한 실수로 여러번 동작할 수 있고

로직을 돌린다던가 통신을 한다던가 다른작업을 하면 원치않는 동작을 할 수 있어서 

아찔할 수 있습니다...!

 

그러면 StateObject를 사용해서는 뷰의 변경사항을 다시 랜더링하면서 상태를 초기화 할 수 없는가?!

강제로 다시 초기화할 수 있는 방법을 제공해줍니다

 

StateObject는 뷰의 id를 참조하여 이 뷰가 변했는지를 체크한다고해요

.id() 뷰모디파이어를 사용해서 변경되는 값에 뷰 id를 전달해주면

뷰가 새로그려치면서 id가 바뀌고 StateObject가 이를 비교하고 다른 id이기때문에 다시 재 초기화가 일어나는 

과정이 일어난다고해요

ForEach안에 뷰가 표시되는경우 해당 데이터 식별자를 사용하는 .id() 메서드를 암시적으로 수신한다고하네요

 

하지만 이렇게 입력이 변경될때마다 StateObject를 다시 초기화해야하는 것은 성능비용을 염두하고 사용해야해요

또한 뷰 id를 변경할때 사이드이펙트의 우려도 있어요

뷰id 변경시

State, FocusState, GestureState등 관리하는 값을 포함하여 뷰가 보유한 모든 상태가 재설정된다고해요

 

struct ContentView: View {
    @StateObject private var viewModel: ViewModel
    
    init(num: Int) {
        print("view init")
        self._viewModel = StateObject(wrappedValue: ViewModel(num: num))
    }
    
    var body: some View {
        VStack {
            Text("View2")
            Text("num: \(viewModel.num)")
        }
    }
}

struct StartView: View {
    @State private var selectedNum: Int = 10
    
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 10) {
                Button {
                    selectedNum = 1
                } label: {
                    Text("1")
                }
                ...
                Text("num: \(selectedNum)")
                Divider()
                ContentView(num: selectedNum)
                  .id(selectedNum) << id추가
            }
        }
        .refreshable {
            selectedNum = 20
        }
    }
}

/*
view init
init ViewModel
init num:  10
view init
init ViewModel
init num:  11
view init
init ViewModel
init num:  12
view init
init ViewModel
init num:  13

*/

.id(selectedNum) 를 추가해주면

selectedNum이 바뀔때마다 새로운 id가 적용돼서 StateObject가 재 초기화되고

새로운 num값을 적용해서 같이 변경되는걸 볼 수 있어요

 

 

 

 

참고

https://developer.apple.com/documentation/swiftui/stateobject

 

StateObject | Apple Developer Documentation

A property wrapper type that instantiates an observable object.

developer.apple.com

 

반응형