[UIKit] Combine: Map Operations 1

Junyoung Park·2022년 10월 3일
0

UIKit

목록 보기
51/142
post-thumbnail

Combine framework tutorial: transforming Operators part 1 - map, compactMap and tryMap

Combine: Map Operations 1

구현 목표

구현 목표

  • 데이터 퍼블리셔 구현
  • 퍼블리셔 데이터의 고차 함수를 통한 가공 → map, tryMap, compactMap 등을 통해 핸들링

구현 태스크

  • URLSession 데이터 태스크 퍼블리셔를 통해 API 데이터 리턴
  • 리턴받은 데이터를 바탕으로 데이터 모델 디코딩 리턴
  • 뷰 모델을 구독하고 있는 뷰의 데이터를 사용한 뷰 패치

핵심 코드

private func addSubscriber() {
        guard let url = URL(string: urlString) else { return }
        URLSession
            .shared
            .dataTaskPublisher(for: url)
            .receive(on: DispatchQueue.global(qos: .background), options: nil)
            .tryMap({ output in
                return output.data
            })
            .decode(type: [CommentModel].self, decoder: JSONDecoder())
            .sink { completion in
                switch completion {
                case .failure(let error):
                    print(error.localizedDescription)
                case .finished:
                    print("Successfully Get data")
                }
            } receiveValue: { [weak self] returnedValue in
                guard let self = self else { return }
                self.commentSubject.send(returnedValue)
            }
            .store(in: &cancellables)
    }
  • URLSession의 데이터 퍼블리셔를 다루는 함수
  • 뷰 모델 이니셜라이저 단에서 최초 실행
  • receive를 통해 비동기 이벤트를 백그라운드 스레드로 받기
  • tryMap을 통해 데이터 퍼블리셔의 특정 값을 핸들링, 에러가 있을 경우 throw 가능
  • sink를 통해 뷰 모델 내의 데이터에 반영
private func addSubscriber() {
        viewModel
            .commentSubject
            .receive(on: DispatchQueue.main)
            .sink { [weak self] comments in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.tableView.reloadData()
                }
            }
            .store(in: &cancellables)
    }
  • 뷰 모델의 특정 데이터를 구독하는 뷰 단의 섭스크라이버 추가 함수
  • commentSubject의 데이터 플로우를 받아 데이터를 리턴하는 데 사용

소스 코드

import Foundation

struct CommentModel: Codable {
    let postId: Int
    let id: Int
    let name: String
    let email: String
    let body: String
}
  • JSON 데이터를 디코딩하기 위한 Codable 프로토콜을 준수하는 구조체
final class CommentViewModel {
    let commentSubject = CurrentValueSubject<[CommentModel], Never>([CommentModel(postId: 1, id: 1, name: "Name", email: "Mock@email.com", body: "doloribus at sed quis culpa deserunt consectetur qui praesentium\naccusamus fugiat dicta\nvoluptatem rerum ut voluptate autem\nvoluptatem repellendus aspernatur dolorem i")])
    let urlString: String
    private var cancellables = Set<AnyCancellable>()
    
    init(urlString: String = "https://jsonplaceholder.typicode.com/comments") {
        self.urlString = urlString
        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
            self.addSubscriber()
        }
    }
    
    private func addSubscriber() {
        guard let url = URL(string: urlString) else { return }
        URLSession
            .shared
            .dataTaskPublisher(for: url)
            .receive(on: DispatchQueue.global(qos: .background), options: nil)
            .tryMap({ output in
                return output.data
            })
            .decode(type: [CommentModel].self, decoder: JSONDecoder())
            .sink { completion in
                switch completion {
                case .failure(let error):
                    print(error.localizedDescription)
                case .finished:
                    print("Successfully Get data")
                }
            } receiveValue: { [weak self] returnedValue in
                guard let self = self else { return }
                self.commentSubject.send(returnedValue)
            }
            .store(in: &cancellables)
    }
}
  • MVVM 디자인 패턴에 따라 CommentViewController의 데이터를 관리하고 있는 뷰 모델
  • commentSubject가 초깃값을 가지고 있는 데이터 퍼블리셔
  • 이니셜라이저 단에서 URLSession의 데이터 태스크 퍼블리셔를 사용, 뷰가 구독할 퍼블리셔에 데이터를 sink
import UIKit
import Combine

class CommentViewController: UIViewController {
    private var viewModel = CommentViewModel()
    private let tableView: UITableView = {
        let tableView = UITableView()
        tableView.register(CommentCell.self, forCellReuseIdentifier: CommentCell.identifier)
        tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        tableView.rowHeight = UITableView.automaticDimension
        tableView.estimatedRowHeight = 200
        return tableView
    }()
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setCommentViewUI()
        addSubscriber()
    }
    
    private func setCommentViewUI() {
        title = "Comments"
        navigationController?.navigationBar.prefersLargeTitles = true
        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.frame = view.bounds
        tableView.dataSource = self
    }
    
    private func addSubscriber() {
        viewModel
            .commentSubject
            .receive(on: DispatchQueue.main)
            .sink { [weak self] comments in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.tableView.reloadData()
                }
            }
            .store(in: &cancellables)
    }
}

extension CommentViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.commentSubject.value.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: CommentCell.identifier, for: indexPath) as? CommentCell else {
            return UITableViewCell()
        }
        let model = viewModel.commentSubject.value[indexPath.row]
        cell.configure(with: model)
        return cell
    }
}
  • 뷰 모델의 데이터 퍼블리셔를 구독, 해당 데이터를 테이블 뷰에서 패치
  • addSubscriber를 통해 데이터 퍼블리셔의 sink 이벤트가 발생할 때마다 self.tableView.reloadData() 함수를 통해 뷰를 다시 그림
import UIKit

final class CommentCell: UITableViewCell {
    static let identifier = "CommentCell"
    private let postIdLabel: UILabel = {
        let label = UILabel()
        label.text = "Post Id"
        label.textColor = .darkGray
        label.numberOfLines = 0
        return label
    }()
    private let nameLabel: UILabel = {
        let label = UILabel()
        label.text = "Name"
        label.textColor = .black
        label.numberOfLines = 0
        return label
    }()
    private let emailLabel: UILabel = {
        let label = UILabel()
        label.text = "abc@def.com"
        label.font = .preferredFont(forTextStyle: .subheadline)
        label.textColor = .black
        label.numberOfLines = 0
        return label
    }()
    private let bodyLabel: UILabel = {
        let label = UILabel()
        label.text = "comment body"
        label.font = .preferredFont(forTextStyle: .body)
        label.textColor = .black
        label.numberOfLines = 0
        return label
    }()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setCommentCellLayout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setCommentCellLayout() {
        nameLabel.translatesAutoresizingMaskIntoConstraints = false
        emailLabel.translatesAutoresizingMaskIntoConstraints = false
        postIdLabel.translatesAutoresizingMaskIntoConstraints = false
        bodyLabel.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(nameLabel)
        contentView.addSubview(emailLabel)
        contentView.addSubview(postIdLabel)
        contentView.addSubview(bodyLabel)
        separatorInset = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
        nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5).isActive = true
        nameLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor).isActive = true
        postIdLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 5).isActive = true
        postIdLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
        postIdLabel.topAnchor.constraint(equalTo: nameLabel.topAnchor).isActive = true
        emailLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 5).isActive = true
        emailLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor).isActive = true
        emailLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor).isActive = true
        emailLabel.bottomAnchor.constraint(equalTo: bodyLabel.topAnchor, constant: -5).isActive = true
        bodyLabel.topAnchor.constraint(equalTo: emailLabel.bottomAnchor, constant: 5).isActive = true
        bodyLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor).isActive = true
        bodyLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor).isActive = true
        bodyLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -5).isActive = true
    }
    
    func configure(with model: CommentModel) {
        postIdLabel.text = model.postId.description
        emailLabel.text = model.email
        nameLabel.text = model.name
        bodyLabel.text = model.body
    }
}
  • 데이터 크기에 따라 동적으로 크기를 변하게 하는 커스텀 셀

구현 화면

강의 내용은 일반적인 JSON을 사용하고 있었는데, 실제 비동기 이벤트를 다뤄보는 연습을 하기 위해 URLSession을 사용했다.

  • 그런데 이러한 데이터 퍼블리셔 구독에 있어서는 최초의 데이터 스트림만 일어난 뒤 이후에는 변하지 않는데, 실질적으로 여러 번 이벤트(유저의 클릭 등)에 의해 데이터가 변할 때에 어떻게 전체 모델을 구조화하는 게 좋은 선택일까?
  • MVVM 모델을 현재 사용하고 있다는 점에서 생각해보자면, 현재 뷰 모델 단에서 이니셜라이저 단에서 사용하고 있는 URLSession + 데이터 퍼블리셔 구독을 분리, 전자를 별도의 데이터 서비스 클래스 내 함수로 추가할 수 있을 것이다.
  • AnyPublisher를 리턴하는 함수가 곧 API를 호출해서 새롭게 데이터를 리턴하도록 하는 곳이다. 즉 뷰 모델에서의 최초 구독은 해당 데이터 서비스 클래스의 함수의 출력값과 연결하기만 하면 보다 간단해진다. 이 경우 물론 뷰의 버튼 이벤트를 별도로 연결해야 하는 추가 일이 생긴다.
profile
JUST DO IT

0개의 댓글