[UIKit] Combine: Map Operations 2

Junyoung Park·2022년 10월 3일
0

UIKit

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

Combine framework tutorial - Part 2 - nested publisher streams with switchToLatest and flatMap

Combine: Map Operations 2

구현 목표

  • Nested Publisher Stream 사용하기
  • 구독 중인 특정 데이터를 바탕으로 새로운 데이터 퍼블리셔 발행(URLSession) → 해당 데이터로 스트림 방향 변경

구현 태스크

  • 이미지 URL 목록을 패치, 테이블 뷰 구현
  • 테이블 뷰 셀 선택 시 해당 URL 패치, 이미지 다운로드 → 뷰 모델의 데이터 구독
  • 뷰 모델의 데이터 퍼블리셔를 뷰의 이미지 뷰가 구독

핵심 코드

    private func addSubscriber() {
        guard let url = URL(string: urlString) else { return }
        URLSession
            .shared
            .dataTaskPublisher(for: url)
            .receive(on: DispatchQueue.global(qos: .background))
            .tryMap { output in
                return output.data
            }
            .decode(type: [AlbumModel].self, decoder: JSONDecoder())
            .sink { completion in
                switch completion {
                case .finished: print("Successfully get album info")
                case .failure(let error): print(error.localizedDescription)
                }
            } receiveValue: { [weak self] returnedValue in
                guard let self = self else { return }
                self.albumSubject.send(returnedValue)
            }
            .store(in: &cancellables)
        imageUrlSubject
            .compactMap { urlString in
                return URL(string: urlString)
            }
            .map { url in
                URLSession
                    .shared
                    .dataTaskPublisher(for: url)
                    .map(\.data)
                    .compactMap { data in
                        return UIImage(data: data)
                    }
            }
            .switchToLatest()
            .sink { completion in
                switch completion {
                case .failure(let _): break
                case .finished: print("Successfully get Image URL")
                }
            } receiveValue: { [weak self] image in
                guard let self = self else { return }
                self.imageSubject.send(image)
            }
            .store(in: &cancellables)
    }
  • 뷰 모델은 첫 번째로 테이블 뷰에 그릴 데이터 albumSubject를 URLSession을 통해 구독
  • imageUrlSubject는 유저에 의해 선택되는 테이블 뷰 셀 내의 URL 문자열 데이터로 데이터 변경 시 곧바로 URLSession을 통해 데이터 패치
  • 두 개 이상의 데이터 파이프라인에서 switchToLatest()를 통해 변경 가능
    private func addSubscriber() {
        viewModel
            .albumSubject
            .sink { [weak self] albums in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.tableView.reloadData()
                }
            }
            .store(in: &cancellabels)
        viewModel
            .imageSubject
            .compactMap{$0}
            .sink { [weak self] image in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.imageView.image = image
                }
            }
            .store(in: &cancellabels)
    }
  • 테이블 뷰 데이터를 그리기 위한 albumSubject 구독
  • imageSubject는 테이블 뷰에서 선택한 imageUrlSubject 변경 뒤 패치된 데이터로 sink 단에 패치, 값 변경 뒤 UI를 그리기 위해 imageView의 이미지 데이터에 곧바로 넣기
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        let imageUrlString = viewModel.albumSubject.value[indexPath.row].url
        viewModel.imageUrlSubject.send(imageUrlString)
    }
  • imageUrlSubject로 값을 보내기 위한 테이블 뷰 선택 함수
  • 타겟-액션 패턴을 따르고 있기 때문에 해당 테이블뷰 함수 또한 퍼블리셔-섭스크라이버 패턴으로 리팩토링 가능할 것

소스 코드

import Foundation

struct AlbumModel: Codable {
    let albumId: Int
    let id: Int
    let title: String
    let url: String
    let thumbnailUrl: String
}
  • 디코딩에 용이한 Codable 프로토콜 준수하는 구조체
import Foundation
import Combine
import UIKit

final class AlbumViewModel {
    let albumSubject = CurrentValueSubject<[AlbumModel], Never>([AlbumModel]())
    let imageUrlSubject = CurrentValueSubject<String, Never>("")
    let imageSubject = CurrentValueSubject<UIImage?, Never>(nil)
    private let urlString: String
    private var cancellables = Set<AnyCancellable>()
    
    init(urlString: String = "https://jsonplaceholder.typicode.com/photos") {
        self.urlString = urlString
        addSubscriber()
    }
    
    private func addSubscriber() {
        guard let url = URL(string: urlString) else { return }
        URLSession
            .shared
            .dataTaskPublisher(for: url)
            .receive(on: DispatchQueue.global(qos: .background))
            .tryMap { output in
                return output.data
            }
            .decode(type: [AlbumModel].self, decoder: JSONDecoder())
            .sink { completion in
                switch completion {
                case .finished: print("Successfully get album info")
                case .failure(let error): print(error.localizedDescription)
                }
            } receiveValue: { [weak self] returnedValue in
                guard let self = self else { return }
                self.albumSubject.send(returnedValue)
            }
            .store(in: &cancellables)
        imageUrlSubject
            .compactMap { urlString in
                return URL(string: urlString)
            }
            .map { url in
                URLSession
                    .shared
                    .dataTaskPublisher(for: url)
                    .map(\.data)
                    .compactMap { data in
                        return UIImage(data: data)
                    }
            }
            .switchToLatest()
            .sink { completion in
                switch completion {
                case .failure(let _): break
                case .finished: print("Successfully get Image URL")
                }
            } receiveValue: { [weak self] image in
                guard let self = self else { return }
                self.imageSubject.send(image)
            }
            .store(in: &cancellables)
    }
}
  • 뷰에서 사용할 데이터(테이블 뷰, 이미지 뷰)를 관리하는 뷰 모델
  • 이미지 URL 문자열 데이터를 통해 URLSession의 데이터 퍼블리셔를 리턴한 뒤, 안긴 데이터 퍼블리셔로 데이터 스트림 방향을 변경하는 파트가 핵심
import UIKit
import Combine

class AlbumViewController: UIViewController {
    private let tableView: UITableView = {
        let tableView = UITableView()
        tableView.register(AlbumCell.self, forCellReuseIdentifier: AlbumCell.identifier)
        return tableView
    }()
    private let imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        return imageView
    }()
    private var cancellabels = Set<AnyCancellable>()
    private var viewModel = AlbumViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()
        setAlbumViewUI()
        addSubscriber()
    }
    
    private func setAlbumViewUI() {
        view.backgroundColor = .systemBackground
        tableView.dataSource = self
        tableView.delegate = self
        tableView.translatesAutoresizingMaskIntoConstraints = false
        imageView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(tableView)
        view.addSubview(imageView)
        tableView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -400).isActive = true
        imageView.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 20).isActive = true
        imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -100).isActive = true
    }
    
    private func addSubscriber() {
        viewModel
            .albumSubject
            .sink { [weak self] albums in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.tableView.reloadData()
                }
            }
            .store(in: &cancellabels)
        viewModel
            .imageSubject
            .compactMap{$0}
            .sink { [weak self] image in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.imageView.image = image
                }
            }
            .store(in: &cancellabels)
    }
}

extension AlbumViewController: UITableViewDataSource, UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        let imageUrlString = viewModel.albumSubject.value[indexPath.row].url
        viewModel.imageUrlSubject.send(imageUrlString)
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: AlbumCell.identifier, for: indexPath) as? AlbumCell else {
            return UITableViewCell()
        }
        let model = viewModel.albumSubject.value[indexPath.row]
        cell.configure(with: model)
        return cell
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.albumSubject.value.count
    }
}
  • 테이블 뷰 데이터를 그리기 위한 뷰 모델의 데이터 퍼블리셔 구독
  • 테이블 뷰 셀 선택 시 뷰 모델의 imageUrlSubject로 값 전송
  • 뷰 모델 내부에서 imageSubject 값 변화 시 곧바로 이미지 패치를 위한 구독
  • 널 값의 경우를 대비한 compactMap 등 고차 함수 활용
import UIKit

class AlbumCell: UITableViewCell {
    static let identifier = "AlbumCell"
    private let titleLabel: UILabel = {
        let label = UILabel()
        label.font = .preferredFont(forTextStyle: .title1)
        label.textColor = .black
        label.numberOfLines = 0
        return label
    }()
    
    private let urlLabel: UILabel = {
        let label = UILabel()
        label.font = .preferredFont(forTextStyle: .headline)
        label.textColor = .darkGray
        label.numberOfLines = 0
        return label
    }()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setAlbumCellLayout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setAlbumCellLayout() {
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        urlLabel.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(titleLabel)
        contentView.addSubview(urlLabel)
        separatorInset = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
        titleLabel.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor).isActive = true
        titleLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor).isActive = true
        titleLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor).isActive = true
        urlLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10).isActive = true
        urlLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor).isActive = true
        urlLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor).isActive = true
        urlLabel.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor).isActive = true
    }
    
    func configure(with model: AlbumModel) {
        titleLabel.text = model.title
        urlLabel.text = model.url
    }
}
  • 테이블 뷰 셀. 동적으로 높이 조정

구현 화면

profile
JUST DO IT
post-custom-banner

0개의 댓글