How to use Combine with MVVM for UIKit and SwiftUI - fetching tweets example project
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()
}
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
를 연결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]
}
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()
}
}
base64String
, Bearer Toekn
을 얻기 위한 별도의 과정을 통해 액세스 토큰을 발급 (트위터 개발자 등록 및 키 발급 필요)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
}
}
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
}
}
configure
함수는 테이블 뷰를 구현한 해당 뷰의 함수 내에서 직접적으로 사용Combine
사용법이 아니라 트위터 API의 인증 토큰을 다루는 데 더 시간을 많이 쏟았다. (유튜브 영상의 STTwitter
는 oAuth2를 사용해 인증하는 데 있어 다소 적절치 못하다.)