ViewModel은 반드시 View의 extension 안에 중첩 클래스로 선언한다.
extension CollectionView {
@Observable
final class ViewModel {
// 1. 외부에서 읽기만 허용하는 상태
private(set) var loadingState: LoadState<[Local.Collection]> = .notRequested
private(set) var output: Output?
// 2. 내부 의존성 (Combine 구독 등 @Observable 추적 불필요한 것)
@ObservationIgnored private let cancelBag: CancelBag
// 3. 주입받는 의존성
private let appStore: AppStore
private let collectionInteractor: CollectionInteractor
init(appStore: AppStore, collectionInteractor: CollectionInteractor) {
self.appStore = appStore
self.collectionInteractor = collectionInteractor
self.cancelBag = .init()
bindAppStore()
}
}
}규칙:
@Observable+final class조합 고정- 상태 프로퍼티는
private(set)— View에서 읽기만 허용 CancelBag등 Combine 관련 객체는@ObservationIgnored필수init에서bindAppState()호출로 구독 설정
비동기 데이터 로딩은 반드시 LoadState<T>를 사용한다.
// 최초 로딩 (로딩 인디케이터 표시)
func loadPosts() async {
guard loadingState == .notRequested || loadingState.inError else { return }
loadingState = await loadingState.load {
try await collectionInteractor.fetchMyCollections()
}
}
// 새로고침 (로딩 인디케이터 없이 갱신)
func refresh() async {
guard let current = loadingState.value else { return }
do {
let updated = try await collectionInteractor.fetchMyCollections()
loadingState = .success(updated)
} catch { }
}View로 보내는 이벤트는 Output enum을 통해서만 전달한다. 직접 coordinator를 ViewModel이 들고 있지 않는다.
enum Output: Equatable {
case navigateToDetail(id: Int)
case scrollToTop(UUID)
}
// ViewModel 내부
private(set) var output: Output?
func resetOutput() {
output = nil
}AppStore는 두 채널을 제공한다.
| 채널 | API | 용도 |
|---|---|---|
| 상태 | appStore.updates(for: keyPath) |
영속 값 변화 감지 (authStatus, currentUser 등) |
| 이벤트 | appStore.events |
일회성 명령 (스크롤, 탭 간 화면 열기 등) |
init에서 cancelBag.collect {} 블록으로 한꺼번에 구독한다.
private func bindAppStore() {
cancelBag.collect {
// 일회성 이벤트 — compactMap으로 원하는 케이스만 추출
appStore.events
.compactMap { guard case .collectionDetailRequested(let id) = $0 else { return nil }; return id }
.weakSink(on: self) { viewModel, id in
viewModel.output = .navigateToDetail(id: id)
}
appStore.events
.filter { if case .collectionScrollToTop = $0 { return true }; return false }
.weakSink(on: self) { viewModel, _ in
viewModel.output = .scrollToTop(UUID())
}
// 영속 상태 변화 감지
appStore
.updates(for: \.authStatus)
.map { $0 == .guest }
.removeDuplicates()
.weakSink(on: self) { viewModel, isGuest in
viewModel.isGuestMode = isGuest
}
}
}규칙:
weakSink(on: self)사용 — retain cycle 방지- 구독은
cancelBag.collect {}블록 하나로 모은다 - AppState 변경: 반드시
appStore.send(.action)— 직접 keyPath 쓰기 금지 - 상태 단순 읽기(초기값 세팅):
appStore[\.authStatus]subscript 허용
상세 화면처럼 여러 데이터를 동시에 로드할 때 사용한다.
func loadInitial() async {
loadState = .loading
await withTaskGroup(of: Void.self) { group in
group.addTask { await self.fetchDetail() }
group.addTask { await self.fetchComments() }
group.addTask { await self.fetchSuggestions() }
await group.waitForAll()
}
loadState = .success(())
}텍스트 입력 후 지연 실행이 필요한 경우에 사용한다.
private var searchDebounceTask: Task<Void, Never>?
func updateSearchText(_ text: String) {
searchDebounceTask = Task.debounce(delay: 400_000_000, task: searchDebounceTask) {
await self.performSearch(text)
}
}// init에서 초기값 세팅 (subscript 읽기)
self.isGuestMode = appStore[\.authStatus] == .guest
// init에서 변화 감지 (cancelBag.collect 블록 내)
appStore.updates(for: \.authStatus)
.map { $0 == .guest }
.removeDuplicates()
.weakSink(on: self) { viewModel, isGuest in viewModel.isGuestMode = isGuest }
func loadPosts() async {
guard !isGuestMode else { return }
// ...
}- ViewModel이
AppCoordinator를 직접 참조하거나 주입받지 않는다 → Output으로 View에 위임 @MainActor전체 클래스 지정 금지 — 필요한 메서드에만 개별 지정- UI 관련 import (
SwiftUI) 최소화 — 불가피한 경우(Color,Image타입)만 허용 - 비즈니스 로직을 ViewModel에 직접 작성 금지 → Interactor에 위임