++ 맨 아래를 참고해주세요
SwiftUI를 사용할 때 ForEach를 자주 사용합니다.
저는 회사에서 채팅 서비스를 개발하고있습니다.
List를 사용할 수 있지만 List를 사용하는 방법보단
ForEach + LazyVStack + ForEach를 사용하는 방법이 더욱 다양한 기능을 적용시키기 좋아서 후자를 선택해 사용하고있습니다.
ForEach와 StateObject를 사용할 때에는 몇 가지 문제점이 있습니다.
우선 ForEach는 View의 id를 기준으로 뷰를 구분하고 업데이트합니다.
물론 @Published 프로퍼티가 바뀌게되어도 뷰를 업데이트 하긴 하죠.
하지만 @StateObject를 사용해 ForEach를 사용한다고 가정할 경우 뷰 업데이트에 대한 문제가 생길 수 있습니다.
만약 여러분이 채팅어플리케이션 개발을 위해 다음과 같은 구조체를 사용한다 가정해봅시다.
struct Message: Identifiable {
var id: AnyHashable = UUID().uuidString
var content: String
var didRead: Bool = false
}
우리는 message를 구분하기 위해, 또는 ForEach를 사용하기위해, 또는 원하는 message로 스크롤 해야하는 기능이 필요할 때 등등 다양한 상황을 대비하기위해 각 message에 id를 부여할겁니다.
그럼 우리는 forEach를 어떻게 사용할까요?
ForEach(viewModel.messages, id: \.id) { message in
ChatBubble(item: message)
}
LazyVStack, ScrollView는 생략했습니다.
ChatBubble의 구조에서 문제가 발생합니다.
struct ChatBubble: View {
@StateObject var viewModel: ChatBubbleViewModel
init(message: Message) {
self._viewModel = StateObject(wrappedValue: ChatBubbleViewModel(item: message))
}
var body: some View {
HStack {
Text("\(viewModel.message.content)")
RoundedRectangle(cornerRadius: 30)
.frame(width: 30, height: 30)
.foregroundStyle(viewModel.message.didRead ? Color.green : Color.black)
}
}
}
class ChatBubbleViewModel: NSObject, ObservableObject {
@Published var message: Message
init(item: Message) {
self.message = item
}
}
여기서 발생하는 문제?
- 최상위 ForEach에서 id를 사용한다.
- ChatBubble의 ViewModel이 @StateObject이다.
Why?
2번 이유가 왜 문제인지 생각해보겠습니다.
@ObservedObject로 선언하면 해당 프로퍼티는 외부에서 주입받습니다. 그럼 viewModel이 지속적으로 생성되어 새로운 프로퍼티가 할당되고 할당된 프로퍼티의 데이터에 맞게 UI가 업데이트됩니다.
뷰에 들어가는 뷰모델이 메모리에서 사라지고, 나타나는 과정이 없다면 문제될게 없습니다.
하지만 들어가는 채팅 버블이 다양하고 이 버블은 스크롤되어 메모리에 올라가고 / 내려가고를 반복합니다.
채팅앱은 ForEach의 하나의 row에 다양한 Bubble이 들어갈 수 있습니다. 예를 들어 동영상, 사진, 링크, 텍스트, 이모티콘, 위치, 파일 등등등...
하지만 채팅앱의 특성상 다양한 데이터를 보여줘야하고 ObservedObject로 사용하면 지속적으로 뷰모델을 생성해서 주입하게되면 성능저하가 발생하더군요, 또한 특정 bubble에서는 뷰의 상태가 변할때마다 화면이 깜빡거리는 이슈가 발생했습니다.(뷰가 새로그려지는 시점이라고 생각합니다)
그래서 @StateObject를 사용하기로 했습니다.
@StateObject는 내부에서 뷰모델을 생성하고 메모리공간을 유지합니다. 매번 새로운 데이터와 함께 뷰모델을 외부에서 주입받지 않습니다.
여기서 2번 문제로 인해 1번 문제가 발생합니다.
1번 문제 "최상위 ForEach에서 id를 사용한다." 가 문제가 되는 이유는 다음과 같습니다.
ForEach는 id를 기준으로 view를 업데이트합니다. ==>> id값이 변하지 않으면 View를 업데이트 하지 않는다.
는 문제가 발생합니다.
그 이유는 위에서 설명했기 때문에 넘어가겠습니다.
즉, 내부에 didRead값이 변하더라도 id가 변하지 않으면 뷰를 다시그리지 않습니다.
위의 코드를 테스트하면 다음과 같은 결과가 발생합니다.
Button(action: {
viewModel.messages[0].didRead = true
}, label: {
Text("updateFirstItem")
})
updateFirstItem을 클릭하면 0번 index의 didRead를 true로 바꿉니다.
그럼 초록색으로 변해야하는데 변하지 않습니다. 스크롤을 해두요
* 참고로 스유로 채팅앱을 개발할 때 스크롤뷰를 뒤집어서 사용하는 경우가 많습니다. 스크롤뷰가 뒤집어져있어 "0입니다"가 0번인덱스 라는걸 알아주세요
해결방법
이 문제를 해결한 제 아이디어는 다음과 같습니다.
- message를 구분할 Id와 View에서 사용할 Id를 구분한다.
Swift에는 Hashable라는 프로토콜이 존재합니다.
이는 hashvalue를 제공하고 다양한 값으로 hash함수를 사용할 수 있는 기능을 제공합니다.
참고로 Hash함수란 N => 해시함수 => N에 대한 고유한값 이 나오게 되는 함수입니다.
struct Message: Identifiable, Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(messageId)
hasher.combine(content)
hasher.combine(didRead)
}
var id: AnyHashable {
return hashValue
}
var messageId: AnyHashable = UUID().uuidString
var content: String
var didRead: Bool = false
}
Message를 구분할 id와 View에서 사용할 id를 구분합니다.
hash는 상태에 따라 view가 변경되어야 하는 값을 넣습니다.
ex) isSending, isDownloaded 등등.. 다양한 값이 들어갈 수 있을거에요.
하나라도 변경되면 View의 id값이 변경되고 @StateObject라도 forEach는 id의 변경을 감지하고 View를 다시그리게됩니다.
제 해결방법이 틀렸다거나, 더 좋은 방법이 있다면 알려주시면 감사하겠습니다.
+++ 수정)
ChatBubbleViewModel을 사용하지 않고 ChatRoomViewModel을 사용하도록 변경했습니다.
ChatBubble은 ChatRoom 내부의 뷰 컴포넌트이고 각자의 ViewModel이 필요하지 않다고 생각했습니다.
StateObject와 ObservedObject, SwiftUI의 View 갱신 방법등에 대해 공부하는 내용이라고 생각해 주시면 감사하겠습니다.
데이터의 id를 View의 id로 설정하는 방법은 좋은 방법이 아닙니다.
SwiftUI가 뷰를 다시 그리는 방법을 위에서 설명드리긴 하였으나 이 방식으로 하면 해당 뷰 전체가 갱신되어 리소스 낭비와 데이터 불일치, 컴포넌트가 깜빡거리는 것 처럼 보이는 등 다양한 문제가 생길 수 있습니다.
메시지는 ChatRoomViewModel이 관리하며 하위 뷰 들은 Binding을 받아서 데이터를 보여주는 역할을 하고 UI로직만을 처리하는 방식이 올바른 방법이라 생각됩니다.