API call 예제
api을 통해 데이터를 요청하고, 테이블 뷰를 새로고침 하는 간단한 예제를 만들어보자.
우선, NetworkManager.swift를 생성해주자.
import Foundation
import Combine
class NetworkManager {
// single 톤 패턴
static let shared = NetworkManager()
// subscriber들의 메모리를 참조 할 수 있는 저장소
private var cancellables = Set()
private let baseURL = "<https://jsonplaceholder.typicode.com/>"
}
- cancellables는 publisher을 구독하는 subscriber들의 메모리를 참조 할 수 있는 저장소이다. 나중에 나오겠지만 store 메서드를 통해 저장하는 로직이 나온다.
- api 통신은 https://jsonplaceholder.typicode.com/ 을 이용할 예정이다.
본격적으로 api 통신을 만들기 전에 몇가지 enum 타입을 만들어주자.
나는 NetworkManager Class 외부에 생성했다.
// baseURL 뒤에 붙게 될 주소 값
enum Endpoint: String {
case posts
}
// network 에러 처리 종류
enum NetworkError: Error {
case invalidURL
case responseError
case unknown
}
// network 에러 처리 description
extension NetworkError: LocalizedError {
var errorDescription: String? {
switch self {
case .invalidURL:
return NSLocalizedString("Invalid URL", comment: "Invalid URL")
case .responseError:
return NSLocalizedString("Unexpected status code", comment: "Invalid response")
case .unknown:
return NSLocalizedString("Unknown error", comment: "Unknown error")
}
}
}
다시 NetworkManager Class안으로 돌아와 Future를 반환하는 getData메서드를 만들어주자.
// 실패 할 수 있는 Future publisher을 반환합니다.
func getData<T: Decodable>(endPoint: Endpoint, type: T.Type) -> Future<[T], Error> {
return Future<[T], Error> { [weak self] promise in
// promise : (Result<[T], Error>) -> Void
// 여기서 promise는 Future의 결과를 하나의 클로저로 받는 argument임
// 이 내부에서, 비동기작업을 수행 할 수 있습니다.
// 비동기 작업이 끝났을 때, 작업의 결과(failure or success)를 promise에 넣어줘야합니다.
// URL 검증
guard let self = self, let url = URL(string: self.baseURL.appending(endPoint.rawValue)) else {
return promise(.failure(NetworkError.invalidURL))
}
print("URL is \\(url.absoluteString)")
}
}
주석으로 설명을 써놓았는데, Future의 경우 Apple에서 제공해주는 Publisher라고 생각하면 된다.
봐야할 부분은 Future에서 비동기 작업을 완료하고 promise라는 closure에 failure, success를 태워 보낸다는 점이다.
URLSession을 사용하는 다음 코드를 살펴보자.
// 실패 할 수 있는 Future publisher을 반환합니다.
func getData<T: Decodable>(endPoint: Endpoint, type: T.Type) -> Future<[T], Error> {
return Future<[T], Error> { [weak self] promise in
/*
...에러처리 부분
*/
// Apple에서 만들어놓은 URLSession의 publisher를 통해 URL 요청
URLSession.shared.dataTaskPublisher(for: url)
// api 통신 에러 처리
.tryMap { (data, response) -> Data in
guard let httpResponse = response as? HTTPURLResponse, 200...299 ~= httpResponse.statusCode else {
throw NetworkError.responseError
}
return data
}
// response받은 JSON 데이터를 Object로 매핑하는 작업
.decode(type: [T].self, decoder: JSONDecoder())
// Returns the run loop of the main thread.
.receive(on: RunLoop.main)
// subscriber 생성
// 실패 할일이 없는 Publisher라면, receiverCompletion이 생략가능하지만,
// 네트워크 요청이 실패가 없을리가 없기 때문에 receiveCompletion을 작성해줌.
.sink(receiveCompletion: { (completion) in
// completion: Subscribers.Completion<Error>
// completion가 .failure(error)에 해당 할 경우 실행됨.
// publisher와 subscriber 둘 모두 failure가 있어 사용 할 수 있는 코드로 보임.
if case let .failure(error) = completion {
switch error {
case let decodingError as DecodingError:
promise(.failure(decodingError))
case let apiError as NetworkError:
promise(.failure(apiError))
default:
promise(.failure(NetworkError.unknown))
}
}
}, receiveValue: {
// data 요청,변환 성공과 데이터를 전달함.
promise(.success($0))
})
// subscriber들을 참조하기위해 cancellables에 저장해놓음
.store(in: &self.cancellables)
}
}
주석으로 설명을 작성해놨는데, 아래 순서대로 작업이 일어난다.
- Apple에서 만들어놓은 URLSession의 publisher를 통해 URL 요청
- 네트워크 에러 처리
- decode 작업
- subscriber 연결
- 성공, 실패 처리
- 성공 데이터 반환
- subscriber의 메모리를 참조 할 수 있게 cancellables에 저장
처리작업을 여러개 묶어서 할 수 있는 점도 재밌는데,
새로운 URLSession Publisher안에서 promise 클로저를 사용해 Future에게 알려준다는 점이 재밌다.
여기까지 하면 Decode를 위한 post struct만 만들고, 이제 테스트만 하면 된다.
Decode를 위한 Post Struct
import Foundation
//[{
// "userId": 1,
// "id": 1,
// "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
// "body": "quia et suscipit\\nsuscipit recusandae consequuntur expedita et cum\\nreprehenderit molestiae ut ut quas totam\\nnostrum rerum est autem sunt rem eveniet architecto"
// }
//]
// JSON data에 맞는 struct 구성
public struct Post: Codable {
var userId: Int
var id: Int
var title: String
var body: String
}
ViewController 테스트
아마, storyBoard로 뷰를 구성하였을 경우 아래 코드가 동작하지 않는다.
코드를 통해 구성했음으로, 아래 내용을 참고해서 코드로 바꿔주자.
//
// ViewController.swift
// CombineDemo
//
// Created by Taehoon Kim on 2022/03/22.
//
import UIKit
import Combine
class ViewController: UIViewController {
// 사용하기
// NetworkManager.shared.getData에 대한 subscriber을 위한 저장소.
private var cancellables = Set<AnyCancellable>()
// publisher 간단하게 생성
// 추가되거나 제거될때마다 변경사항 알림을 보내고자하는 경우 @Published를 붙임
// SwiftUI의 경우 @Publisehd가 붙은 변수가 있는 뷰를 새로고침함.
@Published var posts = [Post]() {
didSet {
tableView.reloadData()
}
}
private var tableView = UITableView()
// Published
// private extension Published {
// func valueDidChange() {
// for closure in observations.value {
// closure(wrappedValue)
// }
// }
// }
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
fetchData()
view.backgroundColor = .white
// 마지막으로 자동으로 변경되는지 확인하기 위해 만들어봄.
configureTableView()
}
func fetchData() {
NetworkManager.shared.getData(endPoint: .posts, type: Post.self).sink(receiveCompletion: {
completion in
switch completion {
case .finished:
print("Finished")
case .failure(let err):
print("Error is \\(err.localizedDescription)")
}
}, receiveValue: { [weak self] data in
// networkManager의 promiss(.success($0))에서
// $0에 해당하는 데이터를 전달받음.
print("receiveValue")
self?.posts = data
})
.store(in: &cancellables)
}
// tableView 추가
func configureTableView() {
self.view.addSubview(tableView)
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 0).isActive = true
tableView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 0).isActive = true
tableView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: 0).isActive = true
tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: 0).isActive = true
tableView.dataSource = self
}
}
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return posts.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.textLabel?.text = posts[indexPath.row].title
return cell;
}
}
위 코드를 보면 또 cancellables를 만들어주고 @Published를 posts 변수에 붙여주는 걸 볼 수 있다.
- cancellables의 경우 NetworkManager.shared.getData가 Future(Publisher)을 반환 하기 때문에 이 녀석을 구독하는 subscriber을 저장 할 수 있는 저장소를 만들어주는 것이다.
- @Publisher는 SwiftUI를 사용할 때, 해당 데이터가 변경되면 뷰를 새로고침 하나 보다.
실행해보기
위의 코드를 돌려보면, 아무것도 없는 빈 테이블 뷰가 나온 후에 post 데이터 요청이 끝난 후 tableView가 업데이트 됨을 볼 수 있다.
문제점
코드를 보면, tableView.reload() 이 부분이 없었으면 좋겠다는 생각이 든다.
그건 그렇고 publisher을 사용하는 이유 중 하나가 subscriber 들에게 변경됨을 알리는 용도아닌가?
위의 예제는 publisher을 설명 할 수 없는 예제 아닌가 싶다
2가지를 더 해보자.
- Publisher 값들 ViewModel로 나누기
- 다른 class에서 동일한 Publisher 호출 되는지 확인해보기
더 해보기) ViewModel에 나누기
문제 발생
💡 @Publisher로 생성된 publisher을 sink로 받으면, 데이터가 세팅 되기 전에 동작하게 되는 문제가 발생했다. @Pulisher는 SwiftUI에서 사용되기 위해 만들어진 느낌이다. UIKit에서는 CurrentValueSubject를 사용하여 만들 수 있다.
- 아래와 같이CurrentValueSubject를 사용한 ViewModel을 하나 생성해주자.
//
// ViewModel.swift
// CombineDemo
//
// Created by Taehoon Kim on 2022/03/22.
//
import UIKit
import Combine
class PostViewModel {
// NetworkManager.shared.getData에 대한 subscriber을 위한 저장소.
private var cancellables = Set<AnyCancellable>()
// publisher 간단하게 생성
// 추가되거나 제거될때마다 변경사항 알림을 보내고자하는 경우 @Published를 붙임
// SwiftUI의 경우 @Publisehd가 붙은 변수가 있는 뷰를 새로고침함.
// @Published var posts = [Post]()
// UIKi이기 때문에, CurrentValueSubject를 사용함.
var posts = CurrentValueSubject<[Post], Never>([Post]())
func fetchData() {
NetworkManager.shared.getData(endPoint: .posts, type: Post.self).sink(receiveCompletion: {
completion in
switch completion {
case .finished:
print("Finished")
case .failure(let err):
print("Error is \\(err.localizedDescription)")
}
}, receiveValue: { [weak self] data in
// networkManager의 promiss(.success($0))에서
// $0에 해당하는 데이터를 전달받음.
print("receiveValue")
// posts데이터 넣어주기
self?.posts.send(data)
})
.store(in: &cancellables)
}
// 처음 생성 될 때, 데이터를 호출함.
init() {
fetchData()
}
}
- CurrentValueSubject:
- CurrentValueSubject<[타입], Never>( 초기값) 으로 초기값을 세팅을 해줘야한다.
- send를 통해 외부에서 데이터를 주입하고 Subscriber에게 전달하는 놈인데 , Publisher로 보면 된다. 즉, 외부에서 데이터를 주입가능한 Publisher이다.
- PassthroughSubject와 CurrentValueSubject의 차이
- 둘 모두 send메서드를 통해 외부에서 publisher의 데이터를 설정 할 수 있음
- PassthroughSubject: 초기값을 가지고 있지않고, finished과 같은 publisher의 상태값을 value로 전달 하여 publisher를 종료할 수 있음.
- CurrentValueSubject: 초기값을 가지고 있고, value로 들어가는 값은 진짜 값임 ㅇㅇ
- Controller에 ViewModel을 연결해주자.
- 기존의 posts는 제거하고 cancellables을 아래처럼 바꿔주자.
let viewModel = PostViewModel() private var cancellables: Set<AnyCancellable> = .init()
- cancellables은 안바꿔줘도 무관하다.
- table View의 데이터를 viewModel로 바꿔주자.
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.posts.value.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.textLabel?.text = viewModel.posts.value[indexPath.row].title
return cell;
}
}
- viewModel의 posts를 구독하자
// 구독 함수 private func subscribeToViewModel() { // posts의 값이 변경되면, tableView를 reload함. viewModel.posts .sink { [unowned self] _ in self.tableView.reloadData()} .store(in: &cancellables) } override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. view.backgroundColor = .white // 여기서 호출 subscribeToViewModel() configureTableView() }
- 아래 함수는 viewDidLoad에서 호출 해주면된다.
더해보기) 다른 Class에서 호출되는지 확인하지
이렇게 작성하고 실행하면, 1부의 예제처럼 잘나온다.
그러면 다른 class에서도 반응하는지 테스트 하기위해 class를 하나 추가해주자.
class AnotherListener {
private var cancellables: Set<AnyCancellable> = .init()
let viewModel = PostViewModel()
init() {
print("AnotherListener is call")
viewModel.posts.sink { [unowned self] _ in
print("Post Change!")
}
.store(in: &cancellables)
}
}
class ViewController: UIViewController {
let anotherListener = AnotherListener()
// ....
}
위의 코드를 추가하고 실행하게 되면, 아래처럼 posts가 변경될 때, 같이 호출이된다.
참고
- https://www.youtube.com/watch?v=2ORJcQgP4a0
- https://www.donnywals.com/using-promises-and-futures-in-combine/
- https://eunjin3786.tistory.com/310
- https://alisoftware.github.io/swift/pattern-matching/2016/05/16/pattern-matching-4/
- https://www.swiftbysundell.com/articles/published-properties-in-swift/
- https://www.raywenderlich.com/7864801-combine-getting-started
- https://www.youtube.com/watch?v=O8vY5LUDagY
'iOS' 카테고리의 다른 글
Combine - Operator(switchToLastest, FlatMap) (0) | 2022.04.19 |
---|---|
Combine - Operator(map, compactMap, tryMap) (0) | 2022.04.19 |
combine - 1. Combine에 관하여 (0) | 2022.03.14 |
iOS 동시성 (GCD Grand Central Dispatch) 기본 개념 (0) | 2022.02.07 |
3. View의 생명주기 (LifeCycle) (0) | 2022.02.04 |