문제 상황
Combine으로 작성한 코드를 테스트하는 중인데 @Published 프로퍼티 래퍼가 말썽입니다.
ViewModel에 @Published 프로퍼티 래퍼 타입을 정의하고 ViewController에서 이 타입을 바인딩하여 ViewModel에 변화가 일어나면 ViewController가 감지할 수 있게 하기 위해서 사용했습니다.
위 상황을 코드로 간략하게 정리해봤습니다.
아래 코드는 책 목록을 보여주는 화면의 로직을 담당하는 ViewModel과 이를 바인딩하고 있는 ViewController입니다. bookList에 변화가 일어난다면 bookList publisher는 BookListViewController에 UI를 업데이트하라고 전달합니다.
final class BookListViewModel: BaseViewModel {
@Published var bookList: [Book]
init(bookList: [Book] = []) {
self.bookList = bookList
}
}
final class BookListViewController: BaseViewController {
var viewModel: BookListViewModel!
var cancellables: Set<AnyCancellable> = []
func bind() {
viewModel.$bookList
.receive(on: DispatchQueue.main)
.sink { [weak self] bookList in
// UI
self.activityIndicator.stopAnimating()
}
.store(in: &cancellables)
}
}
여기서 더 나아가서, 프로토콜 프로그래밍의 다형성을 위해서 viewModel의 비즈니스 로직들을 프로토콜로 정의하려고 했습니다. ViewController가 해당 프로토콜을 채택한 어떠한 ViewModel을 받을 수 있게 하기 위해서 입니다. 그래야 테스트 코드를 작성할 때 목 데이터를 쉽게 작성할 수 있기 때문입니다.
protocol BookListBusinessLogic {
@Published var bookList: [Book]
}
final class BookListViewModel: BaseViewModel, BookListBusinessLogic {
@Published var bookList: [Book]
init(bookList: [Book] = []) {
self.bookList = bookList
}
}
final class BookListViewController: BaseViewController {
let viewModel: BookListBusinessLogic
init(viewModel: BookListBusinessLogic = BookListViewModel()) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
}
하지만 @Published 프로퍼티 래퍼 타입을 프로토콜에 정의했더니 해당 기능은 지원하지 않는다고 합니다.
해결책
ViewController에서 ViewModel의 @Published 타입에 바인딩하기 위해 필요한 타입을 알아야 합니다.
viewModel.$bookList
.receive(on: DispatchQueue.main)
.sink { [weak self] bookList in
guard let self = self else { return }
// UI
self.activityIndicator.stopAnimating()
}
.store(in: &cancellables)
위 코드에서 $bookList에 바인딩을 하고 있고 이 타입은 Publisher입니다. 다시 생각하면 ViewController가 실제로 필요한 것은 @Published 프로퍼티 래퍼 타입이 아니라 bookList의 Publisher 타입입니다. ([Book]의 Publisher)
프로토콜에는 Publisher 타입을 정의하고 실제 구현에서 bookList의 Publisher를 반환하는 코드를 작성합니다.
protocol BookListBusinessLogic {
var bookListPublisher: Published<[Book]>.Publisher { get }
}
final class BookListViewModel: BaseViewModel, BookListBusinessLogic {
@Published var bookList: [Book]
var bookListPublisher: Published<[Book]>.Publisher { $bookList }
init(bookList: [Book] = []) {
self.bookList = bookList
}
}
ViewController에선 ViewModel을 프로토콜 타입으로 정의하고 bookListPublisher에 바인딩시킵니다.
final class BookListViewController: BaseViewController {
let viewModel: BookListBusinessLogic
init(viewModel: BookListBusinessLogic = BookListViewModel()) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
func bind() {
viewModel.bookListPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] bookList in
guard let self = self else { return }
// UI
self.activityIndicator.stopAnimating()
}
.store(in: &cancellables)
}
}
이를 통해 ViewModel과 ViewController의 바인딩 상태는 유지한 채 다형성을 성공적으로 적용시킬 수 있습니다.
테스트 코드를 작성할 때 BookListBusinessLogic를 따르는 MockBookListViewModel를 만들어 BookListViewController에 주입할 수 있습니다.
하지만, 단점도 있습니다. @Published 프로퍼티 래퍼 타입은 양방향 바인딩을 위한 녀석입니다. SwiftUI와 다르게 UIKit은 UI 컴포넌트에 직접 바인딩이 어려운 명령형 프로그래밍으로 이루어져 있습니다. 즉, 유저가 버튼을 누르면 연결된 데이터를 직접 바꿔주어야 하고, 데이터가 바뀌면 직접 UI를 업데이트해야 합니다. 만약 ViewController가 @Published 녀석에 대해 모르도록 숨겨버린다면 양방향 바인딩의 장점은 사라지고 단방향 데이터 흐름을 가지게 됩니다.
Combine에서 테스트 코드를 작성하다 작성한 글입니다. 틀린 점이 있다면 지적 부탁드려요!
읽어주셔서 감사합니다.
'iOS' 카테고리의 다른 글
ViewController 생명주기의 몰랐던 점 (feat. 트러블 슈팅) (0) | 2022.10.24 |
---|---|
[CoreAnimation] Layer에 CoreAnimation을 무한으로 체이닝할 수 있을까? (2) | 2022.10.23 |
[CoreAnimation] 물체를 path 따라 움직이기 (feat. 우주비행사 날리기) (0) | 2022.10.22 |
[CoreAnimation] AnchorPoint, position의 관계 (feat. SwiftUI에선 anchortPoint가 없다..?) (0) | 2022.10.22 |
[CoreAnimation] Layer Masking (feat. 키 모양 만들기) (0) | 2022.10.20 |