Swift

Swift: Combine(4) - 실전압축콤바인예제

songmoro 2023. 11. 3. 16:33
728x90

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

【SwiftUI】AlamofireをCombineで使ってみた

https://reqres.in

MVVM with SwiftUI + Combine

728x90