[Swift] Reactive Programming Combine - Networking
Networking
Codable
tryMap 내에서 JSON 디코딩하면 작동하지만 Combine에서 사용구를 줄이는 도움이 되는 연산자를 제공합니다.
dataTaskPublisher(for:)은 튜플을 반환하므로 데이터 부분만 반환하기 위해 map을 사용하고 decode(type:decoder:) 연산자를 사용해야 합니다. tryMap에서 매번 생성하는 것과는 달리 publisher를 설정할 때, JSONDecoder를 한 번만 인스턴스화합니다.
let subscription = URLSession.shared
.dataTaskPublisher(for: url)
.tryMap { data, _ in
try JSONDecoder().decode(MyType.self, from: data)
}
.sink(receiveCompletion: { completion in
if case .failure(let err) = completion {
print("Retrieving data failed with error \(err)")
}
}, receiveValue: { object in
print("Retrieved object \(object)")
})
// tryMap 변경
let subscription = URLSession.shared
.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: MyType.self, decoder: JSONDecoder())
.sink(receiveCompletion: { completion in
if case .failure(let err) = completion {
print("Retrieving data failed with error \(err)")
}
}, receiveValue: { object in
print("Retrieved object \(object)")
})
multiple subscribers
publisher를 구독할 때마다 publisher가 작업합니다.
네트워크 요청의 경우, 여러 subscriber가 결과를 필요한 경우 동일한 요청을 여러번 전송하는 것을 의미합니다.
shere 연산자를 사용할 수도 있지만 결과가 반환되기 전에 모든 구독자가 구독해야 하기에 까다롭습니다.
multicast 연산자를 사용하여 Subject를 통해 값을 게시하는 ConnectablePublisher를 만들어 해결할 수 있습니다.
이를 통해 subject를 여러 번 구독한 다음, publisher의 connect 메서드를 호출할 수 있습니다.
let publisher = URLSession.shared
.dataTaskPublisher(for: url)
.map(\.data)
.multicast { PassthroughSubject<Data, URLError>() }
let subscription1 = publisher
.sink { completion in
if case .failure(let err) = completion {
print(err)
}
} receiveValue: { data in
print(data)
}
let subscription2 = publisher
.sink { completion in
if case .failure(let err) = completion {
print(err)
}
} receiveValue: { data in
print(data)
}
let subscription = publisher.connect()
Codable 코드로 이해하기
- dataTaskPublisher에서 튜플 값 중에 response의 상태 코드를 확인
- 200번대의 상태 코드가 아니라면 에러로 방출
- 정상적인 데이터라면 decode 메서드로 인스턴스화
- eraseToAnyPublisher를 통해 추상화 및 은닉화
struct Article: Codable {
let id: Int
let title: String
let body: String
}
func fetchArticles() -> AnyPublisher<[Article], Error> {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
fatalError("Invalid URL")
}
return URLSession.shared.dataTaskPublisher(for: url)
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
}
return data
}
.decode(type: [Article].self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
let cancellable = fetchArticles()
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
print("Articles fetched successfully")
case .failure(let error):
print("Error fetching articles: \(error.localizedDescription)")
}
}, receiveValue: { articles in
print("Received articles: \(articles)")
})
multiple subscriber 코드로 이해하기
- fetchImage 함수로 랜덤 이미지를 가져옵니다.
- 2개의 구독자는 publisher를 구독
- liveView 확인
import Combine
import UIKit
import PlaygroundSupport
// Playground image live view 띄우기
let containerView = UIView(frame: CGRect(x: 0, y: 0, width: 300, height: 500))
containerView.backgroundColor = .white
let imageView1 = UIImageView(frame: CGRect(x: 50, y: 50, width: 200, height: 200))
imageView1.contentMode = .scaleAspectFit
let imageView2 = UIImageView(frame: CGRect(x: 50, y: 250, width: 200, height: 200))
imageView2.contentMode = .scaleAspectFit
containerView.addSubview(imageView1)
containerView.addSubview(imageView2)
PlaygroundPage.current.liveView = containerView
// multiple subsciriber 관련 코드
enum MyError: Error {
case failed
}
let url = URL(string: "https://source.unsplash.com/random")!
let publisher = URLSession.shared
.dataTaskPublisher(for: url)
.tryMap { data, _ in
guard let image = UIImage(data: data) else {
throw MyError.failed
}
return image
}
let cancellable1 = publisher
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
print("cancellable1: Image fetched successfully")
case .failure(let error):
print("cancellable1: Error loading image ", error)
}
}, receiveValue: { image in
imageView1.image = image
print("cancellable1: Received image")
})
let cancellable2 = publisher
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
print("cancellable2: Image fetched successfully")
case .failure(let error):
print("cancellable2: Error loading image ", error)
}
}, receiveValue: { image in
imageView2.image = image
print("cancellable2: Received image")
})
liveView를 보게 되면 publisher가 같지만, 다른 이미지를 가지게 됩니다.
multicast 연산자와 PassthroughSubject를 사용하여 두 구독자 각각에게 동일한 이미지를 공유합니다.
multicast publisher는 ConnectablePublisher이기에 connect를 호출해야만 게시가 시작됩니다.
let publisher = URLSession.shared
.dataTaskPublisher(for: url)
.tryMap { data, _ in
guard let image = UIImage(data: data) else {
throw MyError.failed
}
return image
}
.multicast(subject: PassthroughSubject<UIImage, Error>())
let cancellable1 = publisher
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
print("cancellable1: Image fetched successfully")
case .failure(let error):
print("cancellable1: Error loading image ", error)
}
}, receiveValue: { image in
imageView1.image = image
print("cancellable1: Received image")
})
let cancellable2 = publisher
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
print("cancellable2: Image fetched successfully")
case .failure(let error):
print("cancellable2: Error loading image ", error)
}
}, receiveValue: { image in
imageView2.image = image
print("cancellable2: Received image")
})
let subscription = publisher.connect()
share로 해결해도 상관없지 않을까?
share
이 연산자에서 반환된 publisher는 여러 subscriber를 지원하며, 모든 subscriber는 publisher로부터 변경되지 않은 요소 및 완료 상태를 받습니다. 사실상 multicast 및 PassthroughSubject의 조합으로 암시적으로 autoconnect가 포함되어 있습니다.
모든 subscriber가 구독하기 전에 publisher가 값을 내보내는 경우 문제가 발생합니다.
multicast와 달리 새로운 ConnectablePublisher를 생성하지 않고 Publisher를 공유합니다.
이러한 이유로 이미 값을 내보낸 경우나 이후에 subscriber가 추가된 경우에는 share보다는 multicast와 autoconnect를 사용하는 것이 더 안전하다고 합니다. 만약 사용하게 된다면, 값이 내보내는 시점과 구독하는 시점을 신중하게 고려해야 합니다.