[iOS/Swift] Combine 알아보기 iv
유저 인터페이스 업데이트 하기
1,2,3 장은 Combine을 확실히 이해하기 위한 기본적인 지식과 Combine을 효율적으로 사용하기 위해 이해도를 높이는 시간이었다.
이제는 Combine을 이용해 User Interface와 상호작용하는 것에 대해 알아본다.
모든 예시는 UIKit으로 구현한다.
- SwiftUI는 Combine과 밀접한 연관이 있으므로 SwiftUI 영역의 경계와 Combine 영역의 경계를 구분하는 것이 어렵다.
모델과 데이터를 위한 publisher 생성하기
지금까지 Publishers.Sequence를 주로 사용했었다. 하지만 Publishers.Sequence로는 개발을 하면서 마주하는 여러 문제를 해결하는 것이 어렵다.
모델과 데이터를 위한 publisher 만드는 방법은 총 3가지가 있다.
- PassthroughSubject
- CurrentValueSubject
- @Published property wrapper
Subject
subject는 개발자가 스트림에 값을 주입할 수 있는 특별한 publisher
subject는 send(_:) 메서드를 사용해 값을 스트림에 주입할 수 있다.
subject는 send(completion:) 메서드를 사용해 스트림을 끝낼 수 있다.
PassthroughSubject
Combine 프레임워크에서 존재하는 두 가지 subject 중 하나
값을 출발지(origin)에서 publisher를 거쳐 subscriber에게 도달하게 하는 것
- 상태를 보관하지 않으므로 이벤트와 같은 데이터(값)을 전달할 때 사용하면 좋다.
Example
var cancellables = Set<AnyCancellable>()
let notificationCenter = NotificationCenter.default
let notificationName = UIResponder.keyboardWillShowNotification
let publisher = notificationCenter.publisher(for: notificationName)
publisher
.sink(receiveValue: { notification in
print(notification)
}).store(in: &cancellables)
notificationCenter.post(Notification(name:notificationName))
위의 예시는 notificationCenter.publisher를 사용해 지정한 Notification이 왔을 때 sink 내부의 클로저가 호출된다.
위의 예시에 PassthroughSubject를 적용해보자
// PassthroughSubject 사용
var cancellables = Set<AnyCancellable>()
let notificationSubject = PassthroughSubject<Notification, Never>()
let notificationCenter = NotificationCenter.default
let notificationName = UIResponder.keyboardWillShowNotification
notificationCenter.addObserver(forName: notificationName, object: nil, queue: nil) { notification in
notificationSubject.send(notification)
}
notificationSubject
.sink(receiveValue: { notification in
print(notification)
}).store(in: &cancellables)
notificationCenter.post(Notification(name:notificationName))
위의 예시는 PassthroughSubject를 적용한 버전이다.
addObserver를 사용해 지정한 Notification을 관찰하고 해당 Notification이 오면 notificationSubject에 값을 전달해준다.
특징
PassthroughSubject의 가장 큰 특징은 값을 가지지 않는다는 것이다.
위의 예시를 보면 알겠지만 PassthroughSubject에 값이 들어오면 이를 subscriber에게 전해주고 그 값을 버린다.
CurrentValueSubject
PassthroughSubject와 유사한 subject
- 같은 Subject 프로토콜을 준수하기 때문
가장 큰 차이점은 subscriber에 값을 전달한 후 그 값을 버리지 않고 갖고 있는다.
Example
class Car {
var onBatteryChargeChanged: ((Double) -> Void)?
var kwhInBattery = 50.0 {
didSet {
onBatteryChargeChanged?(kwhInBattery)
}
}
let kwhPerKilometer = 0.14
func drive(kilometers: Double) {
let kwhNeeded = kilometers * kwhPerKilometer
// 조건이 false 일 때 문자열 출력
assert(kwhNeeded <= kwhInBattery, "Not Enough Batter")
kwhInBattery -= kwhNeeded
}
}
let car = Car()
let label = UILabel()
label.text = "The car now has \\(car.kwhInBattery)kwh in its battery"
car.onBatteryChargeChanged = { newCharge in
label.text = "The car now has \\(newCharge)kwh in its battery"
}
위의 예시에 didSet 부분에 집중해보자.
kwhInBattery가 업데이트 되면 Car이 갖고 있는 옵셔널 클로저를 호출한다.
- 옵셔널 클로저는 Car모델의 owner에 의해 지정된다.
위와 같은 패턴을 Combine의 CurrentValueSubject를 사용해 동일하게 구현할 수 있다.
// CurrentValueSubject 적용
class Car {
var kwhInBattery = CurrentValueSubject<Double, Never>(50.0)
let kwhPerKilometer = 0.14
func drive(kilometers: Double) {
let kwhNeeded = kilometers * kwhPerKilometer
assert(kwhNeeded <= kwhInBattery.value, "Not enough battery")
kwhInBattery.value -= kwhNeeded
}
}
let car = Car()
let label = UILabel()
var cancellables = Set<AnyCancellable>()
car.kwhInBattery
.sink(receiveValue: { newCharge in
label.text = "The car now had \\(newCharge)kwhn in its battery"
print(newCharge)
})
.store(in: &cancellables)
car.drive(kilometers: 100)
위의 예시에서 krwInBattery는 Double이 아닌 CurrentValueSubject<Double, Never>다.
drive 메서드를 확인해보면 subject의 value 프로퍼티에 접근한다.
- subject가 상태를 갖고 있어 현재 상태값을 value 프로퍼티를 통해 얻을 수 있다.
특징
CurrentValueSubject는 값을 넣어줄 때 명시적으로 send(_:) 메서드를 호출하지 않는다.
- CurrentValueSubject의 value 프로터피를 변경하며 자동으로 값을 subscriber에게 전달한다.
CurrentValueSubject를 구독하면 CurrentValueSubject의 현재 값이 바로 subscriber에게 전달된다.
@Published Property Wrapper
@Published는 CurrentValueSubject와 유사하게 동작한다.
- 조금의 차이점이 존재하기 한다.
class Car {
@Published var kwhInBattery = 50.0
let kwhPerKilometer = 0.14
func drive(kilometers: Double) {
let kwhNeeded = kilometers * kwhPerKilometer
assert(kwhNeeded <= kwhInBattery, "Not enough battery")
kwhInBattery -= kwhNeeded
}
}
let car = Car()
var cancellables = Set<AnyCancellable>()
car.$kwhInBattery
.sink(receiveValue: { newCharge in
print(newCharge)
})
.store(in: &cancellables)
car.drive(kilometers: 100)
@Published를 사용하면 kwhInBattery의 값에 직접적으로 접근해서 값을 변경할 수 있다.
- kwnInBattery의 타입가 Double이기 때문에 kwnInBattery를 직접적으로 구독할 순 없다.
@Published 프로퍼티에 변경사항이 생길 때마다 구독하기 위해선 프로퍼티 이름 앞에 $를 붙여주면 된다.
- $을 사용하면 Property Wrapper 타입 그 자체 접근할 수 있다(Projected Value)
- Property Wrapper가 정의한 프로퍼티나 메서드에 접근할 수 있다.
@Published와 CurrentValueSubject의 차이
@Published는 새로운 값이 들어오면 그 값을 subscriber에게 내려주고 프로퍼티의 값을 새로운 값으로 갱신한다.
CurrentValueSubject는 새로운 값이 들어오면 그 값으로 갱신한 후 subscriber에게 내려준다.
class Counter {
@Published var publishedValue = 1
var subjectValue = CurrentValueSubject<Int, Never>(1)
}
var cancellables = Set<AnyCancellable>()
let c = Counter()
c.$publishedValue.sink(receiveValue: { int in
print("published", int == c.publishedValue)
print("published \\(int) \\(c.publishedValue)")
})
.store(in: &cancellables)
c.subjectValue.sink(receiveValue: { int in
print("subject", int == c.subjectValue.value)
print("subject \\(int) \\(c.subjectValue.value)")
})
c.publishedValue = 2
c.subjectValue.value = 2
/*
published true
published 1 1
subject true
subject 1 1
published false
**published 2 1**
subject true
subject 2 2
*/
@Published는 오직 클래스에서만 사용할 수 있지만, CurrentValueSubject는 struct와 class 모두 사용 가능하다.
@Published는 send 메서드를 호출할 수 없다.
- completion 이벤트를 보낼 수 없다.
정보를 publish하기 위해 적절한 방법 선택하기
이벤트 스트림을 전달하거나 이전 값을 저장할 필요가 없다면
→ PassthroughSubject
struct 내부에서 사용하고 이전 값을 가지고 있어야 한다면
→ CurrentValueSubject
클래스 내부에서 사용하고 CurrentValueSubject와 유사하게 동작하면서 stream을 종료할 필요가 없다면
→ @Published
Publisher의 출력을 바로 할당하기
assign(to:on)을 사용해 pulisher로부터 받은 값을 클래스의 프로퍼티에 할당한다.
// data
class Car {
@Published var kwhInBattery = 50.0
let kwhPerKilometer = 0.14
}
/*
ViewModel은 Car의 kwhInBattery를 구독해서 얻은 값을 변형해 필요한 객체들에게 넘겨줘야 하므로
anyPublisher를 하나 만든다
*/
struct CarViewModel {
// lazy인 이유는 Car 인스턴스가 먼저 생성되고 batterySubject가 생성되어야 하기 때문
// batterySubject의 타입이 String?인 이유 assign하는 대상인 프로퍼티의 타입이 String?이기 때문
lazy var batterySubject: AnyPublisher<String?, Never> = {
return car.$kwhInBattery.map( { newCharge in
return "The car now has \\(newCharge)kwh in its battery"
})
.eraseToAnyPublisher()
}()
var car: Car
mutating func drive(kilometers: Double) {
let kwhNeeded = kilometers * car.kwhPerKilometer
assert(kwhNeeded <= car.kwhInBattery, "Not enough battery")
car.kwhInBattery -= kwhNeeded
}
}
class CarStatusViewController: UIViewController {
let label = UILabel()
lazy var button = UIButton(configuration: .gray()).then {
$0.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside)
$0.setTitle("button", for: .normal)
}
var viewModel: CarViewModel
var cancellables = Set<AnyCancellable>()
init(viewModel: CarViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
self.view.backgroundColor = .white
render()
setupLabel()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
func render() {
view.addSubview(label)
view.addSubview(button)
label.snp.makeConstraints {
$0.top.equalToSuperview().inset(100)
$0.leading.trailing.equalToSuperview().inset(20)
$0.height.equalTo(50)
}
button.snp.makeConstraints {
$0.center.equalToSuperview()
}
}
func setupLabel() {
viewModel.batterySubject
// assing을 사용해 subject로부터 받은 값을 label의 text 프로퍼티에 넣어준다.
.assign(to: \\.text, on: label)
.store(in: &cancellables)
}
@objc
func buttonTapped(_ button: UIButton) {
viewModel.drive(kilometers: 10)
}
주의할 점
assign(to:, on:)에서 self에 대한 keypath를 설정하면 retain cycle이 발생할 수 있다.
Combine을 사용해 CollectionView 그리기
CollectionView가 바라보는 데이터가 변경되면 collectionView도 그에 따라 업데이트해 새로운 값을 보여줘야 한다.
예시는 UICollectionViewDiffableDatasource를 사용한다.
DataProvider
모두가 공유하는 데이터
DataProvider를 통해 데이터를 받아와 collectionView를 갱신한다.
struct CardModel: Hashable, Decodable {
let title: String
let subTitle: String
let imageName: String
}
class DataProvider {
let dataSubject = CurrentValueSubject<[CardModel], Never>([])
func fetch() {
let cards = (0..<20).map {
return CardModel(
title: "Title \\($0)",
subTitle: "Subtitle \\($0)",
imageName: "image_\\($0)"
)
}
dataSubject.value = cards
}
}
위의 예시에는 data를 가져오는 로직인 fetch와 data를 subscriber에게 전달하는 객체인 subject가 분리되어있다.
- 어떤 한 객체가 fetch메서드를 호출하면 dataSubject를 구독하는 모든 객체가 갱신된 값을 받는다.
위의 예시는 데이터를 broadcasting 하고 fetch한 데이터를 보유하고 있을 때 유용하다.
공유하지 않고 새로운 Publisher 생성 후 전달
struct CardModel: Hashable, Decodable {
let title: String
let subTitle: String
let imageName: String
}
class DataProvider {
let dataSubject = CurrentValueSubject<[CardModel], Never>([])
func fetch() -> AnyPublisher<[CardModel], Never> {
let cards = (0..<20).map {
return CardModel(
title: "Title \\($0)",
subTitle: "Subtitle \\($0)",
imageName: "image_\\($0)"
)
}
return Just(cards).eraseToAnyPublisher()
}
}
fetch 메서드를 호출하는 인스턴스에게 개별의 Publisher를 전달한다.
무한 스크롤
class InfiniteScrollDataProvder {
let dataSubject = CurrentValueSubject<[CardModel], Never>([])
var currentPage = 0
var cancellables = Set()
func fetchNextPage() {
let url = URL(string:"<https://www.server.com/page/\\(currentPage)>")!
currentPage += 1
URLSession.shared.dataTaskPublisher(for: url)
.sink(
receiveCompletion: {
print("completed with \\($0)")
},
receiveValue: { [weak self] value in
guard let self = self else { return }
if let models = try? JSONDecoder().decode([CardModel].self, from: value.data) {
self.dataSubject.value += models
}
}
).store(in: &cancellables)
}
}
DataProvider로부터 받은 값으로 CollectionView 그리기
class CombineCollectionViewViewController: UIViewController, UICollectionViewDelegateFlowLayout {
var dataSource: UICollectionViewDiffableDataSource<Int, CardModel>!
let dataProvider = DataProvider()
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
render()
collectionView.register(CardCell.self, forCellWithReuseIdentifier: "CardCell")
dataSource = UICollectionViewDiffableDataSource<Int, CardModel>(collectionView: collectionView) { collectionView, indexPath, model in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CardCell", for: indexPath) as! CardCell
cell.fetch(with: model)
return cell
}
collectionView.dataSource = dataSource
let layout = UICollectionViewFlowLayout()
layout.itemSize = CGSize(width: 120, height: 44)
layout.sectionInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
collectionView.collectionViewLayout = layout
dataProvider.fetch()
// dataSubject로부터 받은 값을 applySnapshot에게 전달해
// snapshot을 만들어 그 snapshot으로 화면을 그린다.
dataProvider.dataSubject
.sink(receiveValue: self.applySnapshot(_:))
.store(in: &cancellables)
}
func render() {
view.addSubview(collectionView)
collectionView.snp.makeConstraints {
$0.top.equalTo(view.safeAreaLayoutGuide.snp.top)
$0.leading.trailing.bottom.equalToSuperview()
}
}
func applySnapshot(_ models: [CardModel]) {
var snapshot = NSDiffableDataSourceSnapshot<Int, CardModel>()
snapshot.appendSections([0])
snapshot.appendItems(models)
dataSource.apply(snapshot)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: collectionView.frame.width, height: 150)
}
}
class CardCell: UICollectionViewCell {
let titleLabel = UILabel()
let subTitleLabel = UILabel()
let image = UILabel().then {
$0.textColor = .blue
}
override init(frame: CGRect) {
super.init(frame: frame)
render()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func render() {
contentView.addSubview(titleLabel)
contentView.addSubview(subTitleLabel)
contentView.addSubview(image)
titleLabel.snp.makeConstraints {
$0.top.equalToSuperview().inset(10)
$0.leading.trailing.equalToSuperview().inset(20)
}
subTitleLabel.snp.makeConstraints {
$0.top.equalTo(titleLabel.snp.bottom).offset(10)
$0.leading.trailing.equalToSuperview().inset(20)
}
image.snp.makeConstraints {
$0.top.equalTo(subTitleLabel.snp.bottom).offset(10)
$0.leading.trailing.equalToSuperview().inset(20)
$0.bottom.equalToSuperview()
}
}
func fetch(with data: CardModel) {
titleLabel.text = data.title
subTitleLabel.text = data.subTitle
image.text = data.imageName
}
}
url로 이미지를 받아 UICollectionViewCell에게 전달하기
모델을 Cell에 전달했을 때 대부분의 경우는 추가적인 업데이트가 필요하지 않다.
하지만 웹에서부터 비동기적으로 이미지를 받아와야 할 땐 이미지가 로드 된 후 그 이미지를 반영해줘야 한다.
class DataProvider {
let dataSubject = CurrentValueSubject<[CardModel], Never>([])
func fetch() {
let cards = (0..<20).map {
return CardModel(
title: "Title \\($0)",
subTitle: "Subtitle \\($0)",
imageName: "image_\\($0)"
)
}
dataSubject.value = cards
}
func fetchImage(named imageName: String) -> AnyPublisher<uiimage?, urlerror)="" {="" let="" url="URL(string:" "<<a="" href="https://www.image.com/">https://www.image.com/\\(imageName)>"
return URLSession.shared.dataTaskPublisher(for: url)
.map { result in
return UIImage(data: result.data
}
.eraseToAnyPublisher()
}
}
</uiimage?,>
여기서 가장 중요한 포인트는 누가 Cancellable을 갖고 있어야 하는가
- AnyCancellable을 저장하지 않으면 구독이 끊긴다.
Cell이 fetchImage에 대한 cancellable을 갖고 있으면 해당 cell이 재사용될 때 이전 데이터의 이미지 네트워킹을 끊을 수 있고 항상 최신값을 가질 수 있게 된다.
class CardCell: UICollectionviewCell {
var cancellable: Cancellable?
// ...
}
let datasource = UICollectionViewDiffableDataSource<Int, CardModel>(collectionView: collectionView) { collectionView, indexPath, item in
let cell = collectionView.dequeReusableCell(withReuseIdentifier: CardCell.identifier, for: indexPath) as! CardCell
cell.cancellable = self.dataProvider.fetchImage(named: item.imageName)
.sink(receiveCompletion: {
}, receiveValue: { image in
// UI 업데이트는 메인 스레드에서
DispatchQueue.main.async {
cell.imageView.image = image
}
})
}
assign(to:)를 사용해 publisher의 output을 @published 프로퍼티로 할당하기
iOS14부터 지원
어떤 객체가 @Published 프러퍼티를 갖고 있고 여러 외부 요인으로 인해 이 프로퍼티가 갱신될 때 사용한다.
Example
class InfiniteScrollDataProvder {
let dataSubject = CurrentValueSubject<[CardModel], Never>([])
var currentPage = 0
var cancellables = Set()
func fetchNextPage() {
let url = URL(string:"<https://www.server.com/page/\\(currentPage)>")!
currentPage += 1
URLSession.shared.dataTaskPublisher(for: url)
.sink(
receiveCompletion: {
print("completed with \\($0)")
},
receiveValue: { [weak self] value in
guard let self = self else { return }
if let models = try? JSONDecoder().decode([CardModel].self, from: value.data) {
self.dataSubject.value += models
}
}
).store(in: &cancellables)
}
}
위 코드는 pagination을 통해 데이터를 갱신하는 DataProvider
위 코드에 assert(to:)를 적용할 수 있다.
class InfiniteScrollDataProvder {
@Published var fetchedModels = [CardModel]()
var currentPage = 0
func fetchNextPage() {
let url = URL(string:"<https://www.server.com/page/\\(currentPage)>")!
currentPage += 1
URLSession.shared.dataTaskPublisher(for: url)
// tryMap을 사용해 network의 에러와 파싱할 때 에러가 나면 에러를 방출한다.
.tryMap({ [weak self] value in
guard let self = self else { return }
models = try! JSONDecoder().decode([CardModel].self, from: value.data) {
return self.fetchedModels + models
})
// 에러가 방출되면 fetchModels로 갈음한다.
.replaceError(with: fetchedModels)
.assign(to: &$fetchedModels)
}
}
특징
assign(to:)는 &가 붙은 것을 봐서는 inout 매개변수를 사용한다.
assign(to:)를 사용하기 위해선 Failure 타입이 Never이어야 한다.
assign(to:)는 AnyCancellable을 반환하지 않는다
- 구독자가 자신의cancellable을 관리하기 때문
SwiftUI와 assign(to:)
SwiftUI에서 assign(to: on:) 또는 sink를 사용해 UI와 publisher를 이을 수 없다.
SwiftUI에서 모든 것은 상태를 관리하는 property wrapper를 통해 진행된다.
- @ObservedObject, @StateObject 프로퍼티
- 위의 두 property wrapper들은 ObservableObject를 준수하는 객체만 wrapping한다.
ObservableObject에는 objectWillChangePublisher 프로퍼티가 있다.
- objectWillChangePublisher 프로퍼티는 모든 ObservableObject가 갖고 있는 모든 @Published 프로퍼티를 관찰한다.
- 변경 사항이 생기면 objectWillChangePublisher를 통해 방출한다.
- 이를 통해 SwiftUI는 ObservableObject의 현재 상태를 화면에 반영할 수 있다.
SwiftUI에서 직접적으로 publisher를 구독할 수 없으니 assign(to:) 메서드를 사용해 여러 publsiher를 @Publisehd 프로퍼티에 연결할 수 있다.