본문 바로가기

iOS/SwiftUI

SwiftUI) URL로 비동기 이미지 생성하기 - Combine과 Network

1. NetworkService

우선 네트워크 처리를 할 수 있는 객체를 생성해야 합니다. 네트워크 처리를 위해 필요한 것으로 URLSessionURLRequest이 있습니다.

URLRequest

URLRequest는 네트워킹하는 곳에서 매번 생성하기에는 귀찮은 작업입니다. NetworkMethodString으로 입력하므로 오탈자가 발생하는 등의 문제가 생길 수도 있습니다. 따라서 따로 객체로 분리하면 더 편하게 사용할 수 있습니다. 아래 코드에서 body는 보통 post할 때 서버로 보낼 데이터를 저장합니다.

enum NetworkMethod: String {
    case get
    case post
    case put
    case patch
    case delete
}

struct RequestBuilder {
    let url: URL?
    let method: NetworkMethod = .get
    let body: Data?
    let headers: [String: String]?

    func create() -> URLRequest? {
        guard let url = url else { return nil }
        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue.uppercased()
        if let body = body {
            request.httpBody = body
        }
        if let headers = headers {
            request.allHTTPHeaderFields = headers
        }
        return request
    }
}

URLSession

세션은 상황에 따라 달라질 수 있으므로 외부에서 주입받도록 합니다. 부스트코스: URLSession과 URLSessionDataTask

final class NetworkService {
    private let session: URLSession

    init(session: URLSession) {
        self.session = session
    }
}

Request Method

Combine을 이용하면 코드가 더 단순해집니다. 대신 AnyPublisher<Data, NetworkError>로 반환하기 위해 .eraseToAnyPublisher() 메소드를 추가해야 합니다.

func request(request: URLRequest) -> AnyPublisher<Data, NetworkError> {
    return session.dataTaskPublisher(for: request)
        .tryMap { data, response -> Data in
            guard let httpResponse = response as? HTTPURLResponse,
                  (200...299).contains(httpResponse.statusCode) else {
                throw NetworkError.invalidRequest
            }
            return data
        }
        .mapError { error -> NetworkError in
            .unknownError(message: error.localizedDescription)
        }
        .eraseToAnyPublisher()
}

NetworkError

네트워크 에러를 처리할 수 있는 enum을 만들었습니다.

enum NetworkError: Error {
    case invalidRequest
    case unknownError(message: String)
}

전체 코드

import Foundation
import Combine

final class NetworkService {

    enum NetworkError: Error {
        case invalidRequest
        case unknownError(message: String)
    }

    private let session: URLSession

    init(session: URLSession) {
        self.session = session
    }

    func request(request: URLRequest) -> AnyPublisher<Data, NetworkError> {
        return session.dataTaskPublisher(for: request)
            .tryMap { data, response -> Data in
                guard let httpResponse = response as? HTTPURLResponse,
                      (200...299).contains(httpResponse.statusCode) else {
                    throw NetworkError.invalidRequest
                }
                return data
            }
            .mapError { error -> NetworkError in
                .unknownError(message: error.localizedDescription)
            }
            .eraseToAnyPublisher()
    }

    deinit {
        session.invalidateAndCancel()
    }
}

2. ImageLoader

ObservableObject

지속적으로 상태 변화를 감지하기 위해 이미지는 @Published property wrapper로 감싸주고 해당 객체는 ObservableObject 프로토콜을 채택합니다.

class URLImageLoader: ObservableObject {
    @Published var image = UIImage(named: "logo")
}

AnyCancellable

그 다음으로 위에서 만든 네트워크 객체를 생성하고 fetch method를 작성합니다.
네트워크 객체의 request 메소드를 호출했을 때에는 sink로 결과를 받을 수 있도록 합니다.
데이터 처리는 뷰와 관련된 것이므로 DispatchQueue.main.async으로 감싸서 작업할 수 있도록 합니다.
마지막으로 sink의 반환값을 cancellables에 저장합니다.

private let network = NetworkService(session: URLSession.shared)
private var cancellables = Set<AnyCancellable>()

func fetch(urlString: String?) {
    guard let urlString = urlString else { return }
    let url = URL(string: urlString)
    let urlRequest = RequestBuilder(url: url,
                                    body: nil,
                                    headers: nil).create()

    guard let request = urlRequest else { return }
    network.request(request: request)
        .sink { result in
            switch result {
            case .failure(let error):
                print(error)
            case .finished:
                print("success")
            }
        } receiveValue: { [weak self] data in
            DispatchQueue.main.async {
                self?.image = UIImage(data: data)
            }
        }
        .store(in: &cancellables)
}

AnyCancellable Cancel

마지막으로 객체가 제거될 때 모든 AnyCancellablecancel할 수 있도록 합니다.

deinit {
    cancellables.forEach { $0.cancel() }
}

전체 코드

import SwiftUI
import Combine

class URLImageLoader: ObservableObject {
    @Published var image = UIImage(named: "logo")

    private let network = NetworkService(session: URLSession.shared)
    private var cancellables = Set<AnyCancellable>()

    func fetch(urlString: String?) {
        guard let urlString = urlString else { return }
        let url = URL(string: urlString)
        let urlRequest = RequestBuilder(url: url,
                                        body: nil,
                                        headers: nil).create()

        guard let request = urlRequest else { return }
        network.request(request: request)
            .sink { result in
                switch result {
                case .failure(let error):
                    print(error)
                case .finished:
                    print("success")
                }
            } receiveValue: { [weak self] data in
                DispatchQueue.main.async {
                    self?.image = UIImage(data: data)
                }
            }
            .store(in: &cancellables)
    }

    deinit {
        cancellables.forEach { $0.cancel() }
    }
}

3. URLImage

URL만으로 Image를 생성할 수 있도록 URLImage 객체를 만들어 줍니다.

onAppear

View가 화면에 표시될 때 이미지를 로드할 수 있도록 다음과 같은 코드를 추가해줍니다.
view lifecycle events: onAppear and onDisappear

var body: some View {
    content
        .onAppear {
            imageLoader.fetch(urlString: urlString)
        }
}

View와 분기

SwiftUI에서 if문과 같이 분기를 나누기 위해서는 Group으로 감싸주거나 해당 뷰 자체를 ViewBuilder로 만들어줘야합니다.

private var content: some View {
    Group {
        if let image = imageLoader.image {
            Image(uiImage: image)
                .resizable()
                .aspectRatio(contentMode: .fit)

        } else {
            ActivityIndicatorView()
                .padding()
        }
    }
}

전체 코드

import SwiftUI
import Combine

struct URLImage: View {
    @StateObject private var imageLoader = URLImageLoader()

    private let urlString: String?

    init(urlString: String?) {
        self.urlString = urlString
    }

    var body: some View {
        content
            .onAppear {
                imageLoader.fetch(urlString: urlString)
            }
    }

    private var content: some View {
        Group {
            if let image = imageLoader.image {
                Image(uiImage: image)
                    .resizable()
                    .aspectRatio(contentMode: .fit)

            } else {
                ActivityIndicatorView()
                    .padding()
            }
        }
    }
}

4. 참고