[UIKit] Combine: Tutorial

Junyoung Park·2022년 10월 1일
0

UIKit

목록 보기
43/142
post-thumbnail
post-custom-banner

MVVM Combine Swift (2022) | UIKit | Transform Input & Output

Combine: Tutorial

구현 목표

  • UIKit + Combine + MVVM
  • 비동기 API 패치 Combine

구현 태스크

  1. 데이터 서비스 클래스
  2. 뷰 모델
  3. 뷰 (뷰 컨트롤러)

핵심 코드

    func getRandomData() -> AnyPublisher<QuoteModel, Error> {
        guard let url = URL(string: urlString) else {
            return Fail(error: URLError.badURL as! Error).eraseToAnyPublisher()
        }
        return URLSession.shared.dataTaskPublisher(for: url)
            .catch { error in
                return Fail(error: error).eraseToAnyPublisher()
            }
            .map {$0.data}
            .decode(type: QuoteModel.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
  • 비동기 데이터를 가장 먼저 처리하는 데이터 서비스 클래스의 함수
  • 해당 데이터 서비스 클래스가 프로토콜을 따르고 있기 때문에 해당 데이터 서비스 클래스를 사용하는 뷰 모델은 손쉽게 데이터 서비스 클래스를 교체 가능
    func transform(input: AnyPublisher<Input, Never>) -> AnyPublisher<Output, Never> {
        input
            .sink { [weak self] receivedValue in
                guard let self = self else { return }
            switch receivedValue {
            case .refreshButtonDidTap, .viewDidAppear:
                self.handleGetRandomQuote()
            }
        }
            .store(in: &cancellables)
        return output.eraseToAnyPublisher()
    }
  • 뷰 모델은 특정 입력값(해당 뷰가 나타났을 때, 리프레시 버튼을 호출했을 때 등)에 따라 출력값을 리턴
  • 뷰 모델이 구독하고 있는 데이터 서비스 클래스의 비동기 데이터를 sink
    private func handleGetRandomQuote() {
        output.send(.toggleButton(isEnabled: false))
        dataService.getRandomData()
            .receive(on: DispatchQueue.global(qos: .background))
            .sink { [weak self] completion in
                guard let self = self else { return }
                self.output.send(.toggleButton(isEnabled: true))
                switch completion {
                case .failure(let error):
                    self.output.send(.fetchQuoteDidFail(error: error))
                case .finished:
                    break
                }
            } receiveValue: { [weak self] receivedValue in
                guard let self = self else { return }
                self.output.send(.fetchQuoteDidSuccess(quote: receivedValue))
            }
            .store(in: &cancellables)
    }
  • 뷰가 새로운 데이터를 요청하는 입력값을 주었을 때 호출되는 비동기 데이터 처리 함수
  • 백그라운드 스레드를 통해 비동기 데이터를 receive
  • 데이터를 성공적으로 받았을 경우 그대로 데이터를 리턴(해당 데이터는 데이터 서비스 클래스 단에서 JSON 데이터가 해당 데이터 구조체로 디코딩된 이후의 상황)
    private func bind() {
        let output = viewModel.transform(input: input.eraseToAnyPublisher())
        output
            .receive(on: DispatchQueue.main)
            .sink { [weak self] receivedValue in
            guard let self = self else { return }
            switch receivedValue {
            case .fetchQuoteDidFail(error: let error):
                self.label.text = error.localizedDescription
            case .toggleButton(isEnabled: let isEnabled):
                self.button.isEnabled = isEnabled
            case .fetchQuoteDidSuccess(quote: let model):
                self.label.text = model.content
            }
        }
        .store(in: &cancellables)
    }
  • 사용자와의 인터렉션이 일어나는 뷰에서 특정 입력값(뷰 로드 완료, 버튼 클릭 등 이벤트)에 따라 달라지는 출력값으로 UI를 패치
  • UI 패치는 메인 스레드에서 실행하기 위한 receive

소스 코드

import UIKit
import Combine

class QuoteViewController: UIViewController {
    private let label: UILabel = {
        let label = UILabel()
        label.textColor = .black
        label.numberOfLines = 0
        return label
    }()
    
    private let button: UIButton = {
        let button = UIButton()
        var config = UIButton.Configuration.filled()
        config.baseBackgroundColor = .blue
        button.configuration = config
        button.setTitle("Refresh", for: .normal)
        button.setTitleColor(.white, for: .normal)
        button.layer.cornerRadius = 10
        return button
    }()
    
    private let viewModel = QuoteViewModel()
    private let input: PassthroughSubject<Input, Never> = .init()
    private var cancellables = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
        bind()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        input.send(.viewDidAppear)
    }
    
    private func bind() {
        let output = viewModel.transform(input: input.eraseToAnyPublisher())
        output
            .receive(on: DispatchQueue.main)
            .sink { [weak self] receivedValue in
            guard let self = self else { return }
            switch receivedValue {
            case .fetchQuoteDidFail(error: let error):
                self.label.text = error.localizedDescription
            case .toggleButton(isEnabled: let isEnabled):
                self.button.isEnabled = isEnabled
            case .fetchQuoteDidSuccess(quote: let model):
                self.label.text = model.content
            }
        }
        .store(in: &cancellables)
    }
    
    private func setUI() {
        label.translatesAutoresizingMaskIntoConstraints = false
        button.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(label)
        view.addSubview(button)
        label.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        label.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
        label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 30).isActive = true
        label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -30).isActive = true
        button.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        button.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 30).isActive = true
        button.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 30).isActive = true
        button.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -30).isActive = true
        button.addTarget(self, action: #selector(refreshButtonDidTap), for: .touchUpInside)
    }
    
    @objc private func refreshButtonDidTap() {
        input.send(.refreshButtonDidTap)
    }
}
  • inputPassthroughSubject로 구성, 초깃값을 줄 필요 없음
  • AnyCancellable을 통해 disposBag 역할, 메모리 누수 방지
  • viewDidAppear, refreshButtonDidTap 등 특정 이벤트에 따라 입력값 보내기 가능
import Foundation
import Combine

enum Input {
    case viewDidAppear
    case refreshButtonDidTap
}

enum Output {
    case fetchQuoteDidFail(error: Error)
    case fetchQuoteDidSuccess(quote: QuoteModel)
    case toggleButton(isEnabled: Bool)
}

class QuoteViewModel: ObservableObject {
    private let dataService: DataService
    private let output: PassthroughSubject<Output, Never> = .init()
    private let output2: CurrentValueSubject<Output, Never> = .init(.toggleButton(isEnabled: true))
    private var cancellables = Set<AnyCancellable>()
    init(dataService: DataService = QuoteService(urlString: "https://api.quotable.io/random")) {
        self.dataService = dataService
    }
    
    func transform(input: AnyPublisher<Input, Never>) -> AnyPublisher<Output, Never> {
        input
            .sink { [weak self] receivedValue in
                guard let self = self else { return }
            switch receivedValue {
            case .refreshButtonDidTap, .viewDidAppear:
                self.handleGetRandomQuote()
            }
        }
            .store(in: &cancellables)
        return output.eraseToAnyPublisher()
    }
    
    private func handleGetRandomQuote() {
        output.send(.toggleButton(isEnabled: false))
        dataService.getRandomData()
            .receive(on: DispatchQueue.global(qos: .background))
            .sink { [weak self] completion in
                guard let self = self else { return }
                self.output.send(.toggleButton(isEnabled: true))
                switch completion {
                case .failure(let error):
                    self.output.send(.fetchQuoteDidFail(error: error))
                case .finished:
                    break
                }
            } receiveValue: { [weak self] receivedValue in
                guard let self = self else { return }
                self.output.send(.fetchQuoteDidSuccess(quote: receivedValue))
            }
            .store(in: &cancellables)
    }
    
}
  • 뷰 컨트롤러의 UI 데이터를 담당하고 있는 뷰 모델
  • 인터렉션에 따라 어떤 데이터를 표시해야 할지 뷰 모델이 구독하고 있는 데이터 서비스 클래스를 통해 데이터를 뷰에 보내기
import Foundation
import Combine

protocol DataService {
    func getRandomData() -> AnyPublisher<QuoteModel, Error>
}

class QuoteService: DataService {
    
    private let urlString: String
    
    init(urlString: String) {
        self.urlString = urlString
    }
    
    func getRandomData() -> AnyPublisher<QuoteModel, Error> {
        guard let url = URL(string: urlString) else {
            return Fail(error: URLError.badURL as! Error).eraseToAnyPublisher()
        }
        return URLSession.shared.dataTaskPublisher(for: url)
            .catch { error in
                return Fail(error: error).eraseToAnyPublisher()
            }
            .map {$0.data}
            .decode(type: QuoteModel.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}
  • urlString을 통해 초기화
import Foundation

struct QuoteModel: Codable {
    let content: String
    let author: String
}

구현 화면

프로토콜, 의존성 주입, 컴바인 프레임워크, MVVM 등을 통해 구현한 간단한 형태의 비동기 데이터 처리. 특히 UIKit 프레임워크를 통해 구현하고 있다는 게 핵심적인데, 테이블 뷰, 컬렉션 뷰 등 보다 핵심적인 UI 패치 방법은 이후의 다른 자료를 통해 공부하자!

profile
JUST DO IT
post-custom-banner

0개의 댓글