[UIKit] Combine: Fetching Data & Networking

Junyoung Park·2022년 10월 3일
0

UIKit

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

How to use Combine with MVVM for UIKit and SwiftUI - fetching tweets example project

Combine: Fetching Data & Networking

구현 목표

  • 트위터 API를 통해 특정 단어를 검색한 데이터를 패치한다.
  • MVVM 형식을 통해 데이터 서비스, 뷰 모델, 뷰 간의 상호 관계를 표현한다.
  • URLSession 데이터 퍼블리셔를 구독한다.

구현 태스크

  1. 트위터 API를 사용하기 위한 데이터 서비스 클래스 구현
  2. API 사용을 위한 토큰 인증
  3. 검색 바의 클릭 이벤트와 API 호출 연결
  4. 뷰 모델의 데이터의 API 검색 결과 데이터 구독
  5. 뷰 모델의 데이터 변화에 따른 UI 변경 구독

핵심 코드

    func getSearchTweets(with query: String) -> AnyPublisher<[TweetModel], Error>  {
        guard let request = getSearchRequest(with: query) else {
            return Fail(error: URLError(.badURL)).eraseToAnyPublisher()
        }
        return URLSession
            .shared
            .dataTaskPublisher(for: request)
            .tryMap { data, response in
                return data
            }
            .decode(type: TweetData.self, decoder: JSONDecoder())
            .map({ data in
                return data.data
            })
            .eraseToAnyPublisher()
    }
  • 데이터 서비스 클래스 단에서 토큰, 쿼리문을 통해 검색 결과 API를 호출하는 함수
  • URLSession의 데이터 태스크 퍼블리셔를 사용, 매핑 및 JSON 디코딩한 결과물을 통해 트위트 모델 데이터 퍼블리셔 리턴
    private func searchFetchTweets(){
        searchText
            .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
            .map { [unowned self] text -> AnyPublisher<[TweetModel], Never> in
                self.dataService.getSearchTweets(with: text)
                    .catch { _ in
                        return Just([TweetModel]())
                    }
                    .eraseToAnyPublisher()
            }
            .switchToLatest()
            .sink { [unowned self] tweets in
                self.tweets.send(tweets)
            }
            .store(in: &cancellables)
    }
  • 뷰 모델 이니셜라이즈 단에서 최초로 호출되는 구독 함수
  • 뷰의 검색 바의 텍스트와 연결되어 있는 searchText와 데이터 서비스 클래스의 getSearchTweets를 연결
  • URLSession의 데이터 퍼블리셔 결과물은 뷰 모델 내의 데이터로 send
    private func setupSearchBarListener() {
        let publisher = NotificationCenter.default.publisher(for: UISearchTextField.textDidChangeNotification, object: searchController.searchBar.searchTextField)
        publisher
            .compactMap { notification in
                return (notification.object as? UISearchTextField)?.text
            }
            .sink { [weak self] result in
                guard let self = self else { return }
                print(result)
                self.viewModel.searchText.send(result)
            }
            .store(in: &cancellables)
        viewModel.tweets
            .sink { [weak self] _ in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.tableView.reloadData()
                }
            }
            .store(in: &cancellables)
    }
  • 노티피케이션 퍼블리셔를 통해 서치 텍스트 필드의 이벤트 관찰
  • 뷰 모델의 데이터 (tweets) 변화를 구독, sink 파트에서 해당 데이터로 그려주는 UI를 패치
  • 테이블 뷰의 데이터 패치는 메인 스레드에서 이루어져야 함
  • tableView.reloadData()를 통해 여전히 UIKit의 target-action 형식을 취하고 있기 때문에 Rx 식의 리팩토링 가능

소스 코드

struct TweetModel: Codable {
    let created_at: String
    let id: String
    let text: String
}

struct TweetData: Codable {
    let data: [TweetModel]
}
  • JSON 데이터를 쉽게 디코딩하기 위한 Codable을 따르는 구조체 데이터 모델 구현
import Foundation
import Combine

final class TwitterDataService {
    static let shared = TwitterDataService()
    var accessToken: String?
    private let consumerKey = "[YOUR CONSUMER KEY]"
    private let consumerSecret = "[YOUR CONSUMER SECRET]"
    private let authURL = "https://api.twitter.com/oauth2/token"
    
    private init() {
        getBearerToken { result in
            switch result {
            case .success(let token):
                print("Successfully connected")
            case .failure(let error):
                print(error.localizedDescription)
            }
        }
    }
    
    private func getBase64String(consumerKey: String, consumerSecret: String) -> String {
        let consumerKeyRFC1738 = consumerKey.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
        let consumerSecretRFC1738 = consumerSecret.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
        let consumerInfo = consumerKeyRFC1738! + ":" + consumerSecretRFC1738!
        let consumerData = consumerInfo.data(using: String.Encoding.ascii, allowLossyConversion: true)
        let base64String = consumerData?.base64EncodedString(options: NSData.Base64EncodingOptions())
        return base64String!
    }
    
    func getBearerToken(completionHandler: @escaping ((Result<String,Error>) -> Void)) {
        guard let url = URL(string: authURL) else { return }
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        let base64String = getBase64String(consumerKey: consumerKey, consumerSecret: consumerSecret)
        let base64StringValue = "Basic " + base64String
        let contentType = "application/x-www-form-urlencoded;charset=UTF-8"
        let grantType = "grant_type=client_credentials"
        request.addValue(base64StringValue, forHTTPHeaderField: "Authorization")
        request.setValue(contentType, forHTTPHeaderField: "Content-Type")
        request.httpBody = grantType.data(using: .utf8)
        URLSession.shared.dataTask(with: request) { data, response, error in
            guard
                let data = data,
                error == nil
            else {
                print(error!.localizedDescription)
                completionHandler(.failure(error!))
                return
            }
            do {
                guard
                    let result = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) as? [String:Any],
                    let accessToken = result["access_token"] as? String else { return }
                self.accessToken = accessToken
                completionHandler(.success(accessToken))
            } catch {
                print(error.localizedDescription)
                completionHandler(.failure(error))
            }
        }
        .resume()
        
    }
    
    private func getSearchRequest(with query: String) -> URLRequest? {
        guard
            let accessToken = accessToken,
            let url = URL(string: "https://api.twitter.com/2/tweets/search/recent?query=\(query)&tweet.fields=created_at&max_results=100") else { return nil }
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.setValue("Bearer " + accessToken, forHTTPHeaderField: "Authorization")
        return request
    }
    
    func getSearchTweets(with query: String) -> AnyPublisher<[TweetModel], Error>  {
        guard let request = getSearchRequest(with: query) else {
            return Fail(error: URLError(.badURL)).eraseToAnyPublisher()
        }
        return URLSession
            .shared
            .dataTaskPublisher(for: request)
            .tryMap { data, response in
                return data
            }
            .decode(type: TweetData.self, decoder: JSONDecoder())
            .map({ data in
                return data.data
            })
            .eraseToAnyPublisher()
    }
}
  • 트위터 API를 사용하는 데이터 서비스 클래스
  • 이니셜라이즈 단에서 트위터 API 사용을 위한 인증 토큰 발급을 위한 base64String, Bearer Toekn을 얻기 위한 별도의 과정을 통해 액세스 토큰을 발급 (트위터 개발자 등록 및 키 발급 필요)
  • 동일한 태스크 수행을 보장하기 위해 싱글턴 구현
  • UI를 그리는 데 사용할 API는 액세스 토큰을 URL 리퀘스트에 쿼리와 함께 보내고 받아오는 트위터 검색

    Search Tweets

import Foundation
import Combine

class TwitterViewModel {
    var tweets = CurrentValueSubject<[TweetModel], Never>([TweetModel(created_at: "2022-01-01", id: "1", text: "mock data")])
    var searchText = CurrentValueSubject<String, Never>("Search")
    private let dataService: TwitterDataService
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        dataService = TwitterDataService.shared
        searchFetchTweets()
    }
    
    private func searchFetchTweets(){
        searchText
            .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
            .map { [unowned self] text -> AnyPublisher<[TweetModel], Never> in
                self.dataService.getSearchTweets(with: text)
                    .catch { _ in
                        return Just([TweetModel]())
                    }
                    .eraseToAnyPublisher()
            }
            .switchToLatest()
            .sink { [unowned self] tweets in
                self.tweets.send(tweets)
            }
            .store(in: &cancellables)
    }
}
  • 뷰 모델은 뷰에서 사용할 데이터를 관리
  • 이니셜라이즈 단에서 이전의 데이터 서비스 클래스를 사용, tweets와 검색 결과를 연결
  • 데이터 스트림 상에서 tweets의 데이터 변화를 감지, 이후 뷰에서 UI를 그리는 데 사용
import UIKit
import Combine

class TwitterViewController: UIViewController {
    private let searchController: UISearchController = {
        let searchBar = UISearchController(searchResultsController: nil)
        searchBar.obscuresBackgroundDuringPresentation = false
        return searchBar
    }()
    private let tableView: UITableView = {
        let tableView = UITableView()
        tableView.register(TweetCell.self, forCellReuseIdentifier: TweetCell.identifier)
        return tableView
    }()
    private var cancellables = Set<AnyCancellable>()
    private var viewModel = TwitterViewModel()
    override func viewDidLoad() {
        super.viewDidLoad()
        setTwitterViewUI()
        setupSearchBarListener()
    }
    
    private func setTwitterViewUI() {
        title = "Twitter"
        navigationController?.navigationBar.prefersLargeTitles = true
        navigationItem.searchController = searchController
        view.addSubview(tableView)
        tableView.frame = view.bounds
        tableView.dataSource = self
    }
    
    private func setupSearchBarListener() {
        let publisher = NotificationCenter.default.publisher(for: UISearchTextField.textDidChangeNotification, object: searchController.searchBar.searchTextField)
        publisher
            .compactMap { notification in
                return (notification.object as? UISearchTextField)?.text
            }
            .sink { [weak self] result in
                guard let self = self else { return }
                print(result)
                self.viewModel.searchText.send(result)
            }
            .store(in: &cancellables)
        viewModel.tweets
            .sink { [weak self] _ in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.tableView.reloadData()
                }
            }
            .store(in: &cancellables)
    }
}

extension TwitterViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.tweets.value.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: TweetCell.identifier, for: indexPath) as? TweetCell else {
            return UITableViewCell()
        }
        let model = viewModel.tweets.value[indexPath.row]
        cell.configure(model: model)
        return cell
    }
}
  • 검색 결과에 따른 뷰 모델의 결과 데이터를 테이블 뷰로 보여주는 뷰
  • 테이블 뷰의 UI 패치를 직접적으로 reloadData() 함수로 호출하고 있는 게 현재 리팩토링 가능한 지점
import UIKit
import LBTATools

final class TweetCell: UITableViewCell {
    static let identifier = "TweetCell"
    private let dateLabel: UILabel = {
        let label = UILabel()
        label.font = .boldSystemFont(ofSize: 16)
        label.textColor = .black
        return label
    }()
    private let tweetTextLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 16)
        label.textColor = .darkGray
        label.numberOfLines = 0
        return label
    }()
    private let idLabel: UILabel = {
        let label = UILabel()
        label.font = .boldSystemFont(ofSize: 10)
        label.numberOfLines = 0
        label.textColor = .black
        return label
    }()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setTweetCellLayout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setTweetCellLayout() {
        hstack(
            idLabel.withSize(.init(width: 50, height: 50)),
            stack(dateLabel, tweetTextLabel, spacing: 8),
            spacing: 20,
            alignment: .top
        ).withMargins(.allSides(24))
    }
    
    func configure(model: TweetModel) {
        tweetTextLabel.text = model.text
        idLabel.text = model.id
        dateLabel.text = model.created_at
    }
}
  • 레이아웃을 설계, 주어진 데이터에 따라 UI만 변경하기 위한 configure 함수는 테이블 뷰를 구현한 해당 뷰의 함수 내에서 직접적으로 사용

구현 화면

  • Combine 사용법이 아니라 트위터 API의 인증 토큰을 다루는 데 더 시간을 많이 쏟았다. (유튜브 영상의 STTwitter는 oAuth2를 사용해 인증하는 데 있어 다소 적절치 못하다.)
profile
JUST DO IT
post-custom-banner

0개의 댓글