Swift: Combine(4) - 실전압축콤바인예제
Alamofire & Combine을 사용해 MVVM 패턴을 구현해보겠습니다.
HTTP 테스트를 위해 https://reqres.in 를 사용했습니다.
프로젝트는 위 사이트의 API를 통해 한 명의 유저를 불러오는 코드를 포함합니다.
모델
터미널에서 curl을 사용해 데이터를 확인하면 아래와 같아요.
// curl <https://reqres.in/api/users/1>
{
"data": {
"id": 2,
"email": "janet.weaver@reqres.in",
"first_name": "Janet",
"last_name": "Weaver",
"avatar": "<https://reqres.in/img/faces/2-image.jpg>"
},
"support": {
"url": "<https://reqres.in/#support-heading>",
"text": "To keep ReqRes free, contributions towards server costs are appreciated!"
}
}
API에 맞춰 모델을 작성해 줍니다.
추후 뷰 단에서 ForEach로 뿌려줄 것이기 때문에 Identifiable을 함께 채택합니다.
{
"data": { ... },
"support": { ... }
}
struct User: Codable, Identifiable {
let id = UUID()
let data: UserData
let support: UserSupport
enum CodingKeys: String, CodingKey {
case data
case support
}
}
data와 support는 하위 모델로 따로 작성해줄게요.
data 코드
{
"data": {
"id": 2,
"email": "janet.weaver@reqres.in",
"first_name": "Janet",
"last_name": "Weaver",
"avatar": "<https://reqres.in/img/faces/2-image.jpg>"
}
}
struct UserData: Codable {
let id: Int
let email: String
let firstName: String
let lastName: String
let avatar: String
enum CodingKeys: String, CodingKey {
case id
case email
case firstName = "first_name"
case lastName = "last_name"
case avatar
}
}
support 코드
{
"support": {
"url": "<https://reqres.in/#support-heading>",
"text": "To keep ReqRes free, contributions towards server costs are appreciated!"
}
}
struct UserSupport: Codable {
let url: String
let text: String
enum CodingKeys: String, CodingKey {
case url
case text
}
}
모델 전체 코드
import SwiftUI
// MARK: - User
struct User: Codable, Identifiable {
let id = UUID()
let data: UserData
let support: UserSupport
enum CodingKeys: String, CodingKey {
case data
case support
}
}
//: - User
// MARK: - User Data
struct UserData: Codable {
let id: Int
let email: String
let firstName: String
let lastName: String
let avatar: String
enum CodingKeys: String, CodingKey {
case id
case email
case firstName = "first_name"
case lastName = "last_name"
case avatar
}
}
//: - User Data
// MARK: - User Support
struct UserSupport: Codable {
let url: String
let text: String
enum CodingKeys: String, CodingKey {
case url
case text
}
}
//: - User Support
API Error case
API를 불러올 때 발생할 수 있는 에러들을 열거형으로 사전 정의해 줄게요.
// MARK: - API Error
enum APIError: Error {
case request(message: String)
case network(message: String)
case status(message: String)
case parsing(message: String)
case other(message: String)
static func map(_ error: Error) -> APIError {
return (error as? APIError) ?? .other(message: error.localizedDescription)
}
}
//: - API Error
Fetcher
API를 호출하고, 데이터를 처리하기 위한 클래스를 정의합니다.
예제 프로젝트에서는 하나의 Fetcher를 사용하지만, 확장을 위한 예로 제네릭 타입을 사용하는 프로토콜을 정의해볼게요.
protocol Fetchable {
func fetch<T>(with url: URL) -> AnyPublisher<DataResponse<T, APIError>, Never> where T: Decodable
}
Fetchable은 Decodable 한 타입을 받아 Publisher를 반환하는 함수입니다.
DataResponse<T, APIError>를 통해 에러 핸들링을 할 것이기 때문에 AnyPublisher<…, Never>로 Publisher는 에러를 반환하지 않게 선언할게요.
UserFetchable은 Fetchable을 채택해서 User 정보를 받아오는 역할을 합니다.
protocol UserFetchable {
func fetchUser() -> AnyPublisher<DataResponse<User, APIError>, Never>
}
class UserFetcher {
private let url = URL(string: "<https://reqres.in/api/users/1>")!
}
extension UserFetcher: UserFetchable, Fetchable {
func fetchUser() -> AnyPublisher<Alamofire.DataResponse<User, APIError>, Never> {
return fetch(with: url)
}
func fetch<T>(with url: URL) -> AnyPublisher<Alamofire.DataResponse<T, APIError>, Never> where T : Decodable {
return AF.request(url, method: .get)
.validate()
.publishDecodable(type: T.self)
.map { response in
response.mapError { error in
APIError.network(message: error.localizedDescription)
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
User Fetcher 전체 코드
// MARK: - Fetchable
protocol Fetchable {
func fetch<T>(with url: URL) -> AnyPublisher<DataResponse<T, APIError>, Never> where T: Decodable
}
//: - Fetchable
// MARK: - User Fetchable
protocol UserFetchable {
func fetchUser() -> AnyPublisher<DataResponse<User, APIError>, Never>
}
//: - User Fetchable
// MARK: - User Fetcher
class UserFetcher {
private let url = URL(string: "https://reqres.in/api/users/1")!
}
extension UserFetcher: UserFetchable, Fetchable {
func fetchUser() -> AnyPublisher<Alamofire.DataResponse<User, APIError>, Never> {
return fetch(with: url)
}
func fetch<T>(with url: URL) -> AnyPublisher<Alamofire.DataResponse<T, APIError>, Never> where T : Decodable {
return AF.request(url, method: .get)
.validate()
.publishDecodable(type: T.self)
.map { response in
response.mapError { error in
APIError.network(message: error.localizedDescription)
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
//: - User Fetcher
View Model
모델과 Fetcher를 통해 데이터를 불러오고 저장하는 뷰 모델을 작성해 줄게요.
뷰 모델에서 사용하기 위한 인터페이스를 먼저 만들어 줍니다.
protocol ViewModelInterface: ObservableObject {
var userList: [User] { get set }
func fetchUser()
}
상태 변화를 추적하기 위해 Published 어노테이션을 채택하고, 이니셜라이즈를 해줍니다.
class ViewModel {
@Published var userList: [User]
let userFetcher: UserFetcher
private var cancellable = Set<AnyCancellable>()
required init() {
self.userList = [User]()
self.userFetcher = UserFetcher()
}
}
extension ViewModel: ViewModelInterface {
func fetchUser() {
userFetcher
.fetchUser()
.receive(on: DispatchQueue.main)
.sink { [weak self] response in
switch response.result {
case .success(let user):
self?.userList.append(user)
case .failure(let failure):
print(failure.localizedDescription)
self?.userList = []
}
}
.store(in: &cancellable)
}
}
View Model 전체 코드
// MARK: - ViewModel
protocol ViewModelInterface: ObservableObject {
var userList: [User] { get set }
func fetchUser()
}
class ViewModel {
@Published var userList: [User]
let userFetcher: UserFetcher
private var cancellable = Set<AnyCancellable>()
required init() {
self.userList = [User]()
self.userFetcher = UserFetcher()
}
}
extension ViewModel: ViewModelInterface {
func fetchUser() {
userFetcher
.fetchUser()
.receive(on: DispatchQueue.main)
.sink { [weak self] response in
switch response.result {
case .success(let user):
self?.userList.append(user)
case .failure(let failure):
print(failure.localizedDescription)
self?.userList = []
}
}
.store(in: &cancellable)
}
}
//: - ViewModel
View
뷰 모델을 통해 데이터를 불러오고, 이후 데이터를 뿌려주기 위한 뷰 코드를 작성합니다.
struct ContentView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
VStack {
ForEach(viewModel.userList) { user in
AsyncImage(url: URL(string: user.data.avatar))
Text("name: \(user.data.firstName) \(user.data.lastName)")
Text("email: \(user.data.email)")
Text("support: \(user.support.text)")
}
}
.onAppear {
viewModel.fetchUser()
}
}
}
결과 화면

-끗-
프로젝트 링크: https://github.com/songmoro/CombineMockup
(CombineMockup.xcworkspace를 실행하셔야 코코아팟이 인식됩니다.)
참고
Sending and receiving Codable data with URLSession and SwiftUI