[SwiftUI] Async/Await, @escaping, Combine

Junyoung Park·2022년 8월 26일
0

SwiftUI

목록 보기
55/136
post-thumbnail
post-custom-banner

Download images with Async/Await, @escaping, and Combine | Swift Concurrency #2

Async/Await, @escaping, Combine

구현 목표

  1. 이스케이핑 클로저를 통해 비동기적 데이터를 다운로드한다.
  2. Combine 프레임워크를 통해 비동기적 데이터를 다운로드한다.
  3. async/await 구문을 통해 비동기적 데이터를 다운로드한다.
  4. UI 업데이트를 메인 스레드에서 하기 위한 DispatchQueue 또는 MainActor를 사용한다.

구현 태스크

  1. URLSession의 데이터를 다운로드하는 컴플리션 핸들러
  2. URLSession의 데이터 퍼블리셔를 구독
  3. URLSession의 데이터를 await하는 async 함수

핵심 코드

import SwiftUI
import Combine

class DownloadImageAsyncBootCampDataService {
    let urlString: String
    init(urlString: String) {
        self.urlString = urlString
    }
    
    func getURL() -> URL? {
        guard let url = URL(string: urlString) else { return nil }
        return url
    }
    
    func downloadWithEscaping(completionHandler: @escaping (_ image: UIImage?, _ error: Error?) -> ()) {
        guard let url = getURL() else { return }
        URLSession.shared.dataTask(with: url) { data, response, error in
            guard
                let data = data,
                let image = UIImage(data: data),
                let response = response as? HTTPURLResponse,
                response.statusCode >= 200 && response.statusCode < 300 else {
                completionHandler(nil, error)
                return
            }
            completionHandler(image, nil)
        }
        .resume()
    }
    
    func handleResponse(data: Data?, response: URLResponse?) -> UIImage? {
        guard
            let data = data,
            let image = UIImage(data: data),
            let response = response as? HTTPURLResponse,
            response.statusCode >= 200 && response.statusCode < 300 else {
            return nil
        }
        return image
    }
    
    func downloadWithEscaping2(completionHandler: @escaping (_ image: UIImage?, _ error: Error?) -> ()) {
        guard let url = getURL() else { return }
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard let self = self else { return }
            let image = self.handleResponse(data: data, response: response)
            completionHandler(image, error)
        }
        .resume()
    }
    
    func downloadWithCombine() -> AnyPublisher<UIImage?, Error> {
        guard let url = getURL() else { return Fail(error: URLError(.badURL)).eraseToAnyPublisher() }
        return URLSession.shared.dataTaskPublisher(for: url)
            .map(handleResponse)
            .mapError{$0}
            .eraseToAnyPublisher()
    }
    
    func downloadWithAsync() async throws -> UIImage? {
        // 1. weak self -> no need
        // 2. safer code (completionHandler usage X)
        guard let url = getURL() else { throw URLError(.badURL) }
        do {
            let (data, response) = try await URLSession.shared.data(from: url)
            return handleResponse(data: data, response: response)
        } catch {
            throw error
        }
    }
}
  • handleResponse를 통해 (Data?, URLResponse?)를 핸들링하여 UIImage?를 리턴한다.
  • Combine을 쓸 때 eraseToAnyPublisher 메소드를 통해 손쉽게 AnyPublisher<Success, Failure> 타입을 리턴, 이후 뷰 모델에서 해당 퍼블리셔를 구독 가능
  • Async를 쓸 때 try await를 통해 실패 가능한 접근 방법을 택해야 함 → do catch를 통해 에러 핸들링 가능 → 더 이상 Result<Success, Failure>를 통해 completionswitch case하지 않아도 됨(리턴 타입의 옵셔널도 없어지는 것은 당연한 귀결)

소스 코드

import SwiftUI
import Combine

class DownloadImageAsyncBootCampDataService {
    let urlString: String
    init(urlString: String) {
        self.urlString = urlString
    }
    
    func getURL() -> URL? {
        guard let url = URL(string: urlString) else { return nil }
        return url
    }
    
    func downloadWithEscaping(completionHandler: @escaping (_ image: UIImage?, _ error: Error?) -> ()) {
        guard let url = getURL() else { return }
        URLSession.shared.dataTask(with: url) { data, response, error in
            guard
                let data = data,
                let image = UIImage(data: data),
                let response = response as? HTTPURLResponse,
                response.statusCode >= 200 && response.statusCode < 300 else {
                completionHandler(nil, error)
                return
            }
            completionHandler(image, nil)
        }
        .resume()
    }
    
    func handleResponse(data: Data?, response: URLResponse?) -> UIImage? {
        guard
            let data = data,
            let image = UIImage(data: data),
            let response = response as? HTTPURLResponse,
            response.statusCode >= 200 && response.statusCode < 300 else {
            return nil
        }
        return image
    }
    
    func downloadWithEscaping2(completionHandler: @escaping (_ image: UIImage?, _ error: Error?) -> ()) {
        guard let url = getURL() else { return }
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard let self = self else { return }
            let image = self.handleResponse(data: data, response: response)
            completionHandler(image, error)
        }
        .resume()
    }
    
    func downloadWithCombine() -> AnyPublisher<UIImage?, Error> {
        guard let url = getURL() else { return Fail(error: URLError(.badURL)).eraseToAnyPublisher() }
        return URLSession.shared.dataTaskPublisher(for: url)
            .map(handleResponse)
            .mapError{$0}
            .eraseToAnyPublisher()
    }
    
    func downloadWithAsync() async throws -> UIImage? {
        // 1. weak self -> no need
        // 2. safer code (completionHandler usage X)
        guard let url = getURL() else { throw URLError(.badURL) }
        do {
            let (data, response) = try await URLSession.shared.data(from: url)
            return handleResponse(data: data, response: response)
        } catch {
            throw error
        }
    }
}
class DownloadImageAsyncBootCampViewModel: ObservableObject {
    @Published var image: UIImage? = nil
    let dataService = DownloadImageAsyncBootCampDataService(urlString: "https://picsum.photos/200")
    var cancellables = Set<AnyCancellable>()
    
    init() {
        fetchImageWithAsyncTask()
    }
    
    func fetchImageWithEscapingClosure() {
        dataService.downloadWithEscaping2 { image, error in
            if let error = error {
                print(error.localizedDescription)
                return
            } else {
                DispatchQueue.main.async { [weak self] in
                    guard let self = self else { return }
                    self.image = image
                }
            }
        }
    }
    
    func fetchImageWithCombine() {
        dataService.downloadWithCombine()
            .sink { completion in
                switch completion {
                case .failure(let error):
                    print(error.localizedDescription)
                    break
                case .finished:
                    break
                }
            } receiveValue: { [weak self] image in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.image = image
                }
            }
            .store(in: &cancellables)
    }
    
    func fetchImageWithCombine2() {
        dataService.downloadWithCombine()
            .receive(on: DispatchQueue.main)
            .sink { completion in
                switch completion {
                case .failure(let error):
                    print(error.localizedDescription)
                    break
                case .finished:
                    break
                }
            } receiveValue: { [weak self] image in
                guard let self = self else { return }
                self.image = image
            }
            .store(in: &cancellables)
    }
    
    func fetchImageWithAsyncTask() {
        Task {
            await fetchImageWithAsync()
        }
    }
    
    func fetchImageWithAsync() async {
        do {
            guard let image = try await dataService.downloadWithAsync() else {
                return
            }
            await MainActor.run {
                self.image = image
            }
        } catch {
            print(error.localizedDescription)
        }
    }
}
  • UI 업데이트 시 weak self를 통해 강한 참조 사이클을 피하는 데 주의
  • 퍼블리셔 구독 시 AnyCancellables 집합 변수에 해당 값을 저장하는 데 주의
  • async로 데이터 서비스가 리턴하는 데이터를 받아올 때 에러를 throw할 수 있기 때문에 do catch가 필수이고, await해야 함 → image를 UI 업데이트할 때 MainActor 클래스 내부에서 업데이트 헤야 함 → 해당 async 작업은 Task를 통해 일어나야 함
struct DownloadImageAsyncBootCamp: View {
    @StateObject private var viewModel = DownloadImageAsyncBootCampViewModel()
    
    var body: some View {
        VStack {
            if let image = viewModel.image {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 250, height: 250, alignment: .center)
                    .onTapGesture {
                        viewModel.fetchImageWithAsyncTask()
                    }
            }
        }
    }
}
  • 이미지 클릭 시 이미지 다운로드 함수 호출

구현 화면

profile
JUST DO IT
post-custom-banner

0개의 댓글