Combine in swift

Combine 을 알기전에…

Combine 을 알기전에 앞서서, Combine 이 나오기전에 어떻게 비동기 event 를 처리했는지 보자. 아래의 코드는 간단한 Fake Jason Data 를 Load 해서 UI 에 뿌리는 용도이다. @escaping 이라는걸 간단하게 이야기하자면, 일단 함수안에 completionHandler 가 매개상수로 들어오고, 이 closure 는 함수가 시작한 이후에 바로 실행시킬수 있게끔 되어있다. 만약 asyncAfter 를 사용하게 되면, 함수는 끝나는데 closure 가 살아있을수가 없기 때문에, @escaping 사용해서 closure 가 비동기로 사용할수 있게끔 만들어주는거다. 이를 통해서 비동기 처리를 통해서 Data 를 받아 올수 있었다.

import SwiftUI

struct PostModel: Identifiable, Codable {
    let userId: Int
    let id: Int
    let title: String
    let body: String
}

class DownloadwWithEscapingViewModel : ObservableObject {
    @Published var posts: [PostModel] = []
    init(){
        getPosts()
    }
    
    func getPosts(){
        guard let url = URL(string : "https://jsonplaceholder.typicode.com/posts") else { return }
        downloadData(fromURL: url) { (returnedData) in
            if let data = returnedData {
                guard let newPosts = try? JSONDecoder().decode([PostModel].self, from: data) else { return }
                DispatchQueue.main.async{ [weak self] in
                    self?.posts = newPosts
                }
            }
        }
    }
    
    func downloadData(fromURL url: URL, completionHandler: @escaping (_ data: Data?) -> Void) {
        URLSession.shared.dataTask(with: url) { (data, res, err) in
            guard
                let data = data,
                err == nil,
                let res = res as? HTTPURLResponse,
                res.statusCode >= 200 && res.statusCode < 300 else {
                print("No data.")
                completionHandler(nil)
                return
            }
            completionHandler(data)
        }.resume() // start
    }
}

struct DownloadWithEscapingBootcamp: View {
    @StateObject var vm = DownloadwWithEscapingViewModel()
    
    var body: some View {
        List {
            ForEach(vm.posts) { post in
                VStack(alignment: .leading){
                    Text(post.title)
                        .font(.headline)
                    Text(post.body)
                        .foregroundColor(.gray)
                }
                .frame(maxWidth: .infinity, alignment: .leading)
            }
        }
    }
}

#Preview {
    DownloadWithEscapingBootcamp()
}

Combine

애플 Dev 공식문서에 보면…

The Combine framework provides a declarative Swift API for processing values over time. These values can represent many kinds of asynchronous events. Combine declares publishers to expose values that can change over time, and subscribers to receive those values from the publishers.

즉 Publisher 와 Subscriber 가 있고, Publisher 는 Data 를 방출하거나, 뭔가의 완료의 Signal 보내는 역활을 하고, Subscriber 는 Publisher 가 쏴준 Data 나 완료 Signal 을 받는 역활을 한다. 그리고 그 사이에 Operator 가 있는데 Publisher 가 생성하는 이벤트를 처리하는 역활을 한다. 이때 연산자(map, filter, reduce) 를 사용할수 있다.

기본적인 예시는

import Combine

let publisher = [10, 20, 30, 40, 50].publisher 

publisher
    .map { $0 * 2 } // Operator 를 통해서 값 변환
    .sink { print($0) } // 값을 recevied

Publisher 연산자에도 (Just, Sequence, Future, Fail, Empty, Deferred, Record) 등이 있다. Just 를 사용한 Publisher 를 사용해보자.

import Combine
let justPublisher = Just(20)

justPublisher
    .map { $0 + 50 }
    .sink { print($0) }

아래의 코드는 여기에서 "https://jsonplaceholder.typicode.com/todos/1" todo 를 하나 가지고 와서, Data 를 Publisher 를 통해서 가지고 온이후에 subscriber 로 receive 받은 이후, UI 를 main thread 에서 update 를 해주는 코드이다.

struct PostModel: Identifiable, Codable {
    let title: String
}

class DownloadTodoViewModel : ObservableObject {
    @Published var todos: [PostModel] = []
    var cancellables = Set<AnyCancellable>()
    init() {
        getTodo()
    }

    func getTodo() {
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/todos/1") else {return}

        URLSession.shared.dataTaskPublisher(for:url)
            .subscribe(on: DispatchQueue.global(qos: .background))
            .receive(on: DispatchQueue.main)
            .tryMap { (data, response) -> Data in
                guard let response = response as ? HTTPURLResponse,
                    response.statusCode >= 200 & response.statusCode < 300 else {
                        throw URLError(.badServerResponse)
                    }
                    return data
            }
            .decode(type: PostModel.self, decoder: JSONDecoder())
            .sink { (completion) in

            } receiveValue: { [weak self] (returnTodo) in 
                self?.todos.append(returnTodo)
            }
            .store(in: &cancellables)
    }
}

그리고 Timer Publisher 의 사용법을 하려고 한다. Timer Thread 의 on 은 어떤 RunLoop 에서 Timer 를 사용할지를 정해줄수 있다. 여기에서는 main thread 를 사용하고, 어떠한 방식으로 RunLoop 을 실행할건지를 넣어주는데 default 값을 넣어주었다. 여기에서 필수적으로 알아야하는건 Timer 가 반환하는 Publisher 는 ConnectablePublisher 이며, 이 Publisher 를 subscribe 해서 실행하려면, connect()autoconnect() 를 사용(첫 subscriber 구독 할때). sink 로 receive 해서 print 를 하면된다. 그리고 main thread 가 기다리면서, 5 초 뒤에 subscribe 취소 해주면된다.

import Combine

let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
var cancellable: AnyCancellable?

cancellable = timer
    .sink { print($0) }

DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
    cancellable?.cancel()
}

Resource