SwiftUI

AsyncImage 구현하기(1)

basker 2023. 6. 9. 16:25

iOS15에 추가된 AsyncImage가 추가되었습니다. 이제 URL만 입력해 주면 알아서 해당 위치의 AsyncImage에 url 위치의 이미지를 다운로드 해줍니다. 해당 AnyncImage는 SwiftUI 특성 상 화면에 사라지면 소멸되고 다시 화면에 표시되려면 재 생성해야 하는데 캐시 기능이 없는 AsyncImage는 네트워크에서 이미지를 다시 다운로드 해야하는 단점이 있습니다. 또한 iOS15 이전에서는 사용할 수 없습니다. 그래서 여기에서는 AsyncImage 정의를 이용해서 동일한 타입의 CachedAsyncImage 를 구현 해 보도록 하겠습니다. 

 

먼저 AsyncImage의 아래와 같은 세개의 초기화 함수가 있습니다. 

public init(url: URL?, scale: CGFloat = 1) where Content == Image

public init<I, P>(url: URL?, 
	scale: CGFloat = 1, 
    @ViewBuilder content: @escaping (Image) -> I, 
    @ViewBuilder placeholder: @escaping () -> P) 
 where Content == _ConditionalContent<I, P>, I : View, P : View
 
public init(url: URL?, 
		scale: CGFloat = 1, 
        transaction: Transaction = Transaction(), 
        @ViewBuilder content: @escaping (AsyncImagePhase) -> Content)

AsyncImage(url:) 는 성공 시내부에 Image 뷰를 보여줍니다. 

AsyncImage(url:content:placeholder) 내부에 다운로드가 완료되면 Image 뷰를 그르고 아니면 placehoder를 보여 줍니다. 

AsyncImage(url:content)는 내부 AsyncImagePhase에 따라 처리할  사용자가 스스로 선택할 수 있게 합니다. 아 여기서 우리는 AsyncImage는 상태 값 AsnycImagePhase 에 따라 뷰를 변경하는구나를 알수 있습니다. 그럼 AsyncImagePhase에 대해서 알아봐야 겠죠. 

public enum AsyncImagePhase {
    case empty
    case success(Image)
    case failure(Error)
    public var image: Image? { get }
    public var error: (Error)? { get }
}

예 맞습니다. AsyncImagePhase 는 URL 요청에 따라 상태 값을 변경해 각각의 상황에 따라 해당 뷰를 처리하는 방식으로 동작하고 있습니다. 그럼 우선 CachedAsyncImage 도 이런 구조로 동작 할 수 있도록 틀을 잡아 보겠습니다. 

public enum CachedAsyncImagePhase {
    case empty
    case success(Image)
    case failure(Error)
    
    public var image: Image? {
        if case let .success(image) = self {
            return image
        }
        return nil
    }
    
    public var error: (Error)? {
        if case let .failure(error) = self {
            return error
        }
        return nil
    }
}

위와같이 CachedAsnycImage의 상태 전이를 표현할 CachedAsncyImagePhase 개체를 정의하고

struct CachedAsyncImage<Content>: View where Content: View{
    @State private var phase: CachedAsyncImagePhase
}

이렇게 추가해 주면 나중 네트워크 에서 데이터를 가져올 때, 실패 했을 때 에 따라 phase 값을 변경하면 뷰의 화면도 갱신될 겁니다. 

이제  가장 기본이 되는 마지막 초기화 함수를 작성하고 해당 초기화 함수를 이용해 나머지 두 초기화 함수도 구현해 주겠습니다. 

struct CachedAsyncImage<Content>: View where Content: View{
    private var url: URL?
    private var scale: CGFloat
    private var transaction: Transaction
    private var content: (CachedAsyncImagePhase) -> Content
    @State private var phase: CachedAsyncImagePhase
    
	init(url: URL?,
         scale: CGFloat = 1.0,
         transaction: Transaction = Transaction(),
         @ViewBuilder content: @escaping (CachedAsyncImagePhase) -> Content) {
        self.url = url
        self.scale = scale
        self.transaction = transaction
        self.content = content
        self._phase = State(wrappedValue: .empty)
    }

	var body: some View { 
    	content(phase) 
    }
}

여기서 주의 깊게 봐야 할 부분은 상태 값 phase 의 초기화 부분입니다. 초기화 되지 않은 상태값은  언더바(_)pahse 에 State(wrappedValue: .empy)를 통해 직접 State 변수를 생성해줘야 합니다. 

이제 init(url:URL?) 초기화 함수를 구현해 보고 이를 Preview를 통해 화면에 출력해 보죠. 

 init(url: URL?, scale: CGFloat = 1) where Content == Image {
        self.init(url: url) { phase in
            Image(systemName: "square.and.arrow.up")
        }
    }

init(url: URL?, scale: CGFloat) 함수는 위와 같이 self.init(url:scale:transaction:content)를 통해서 초기화 합니다. 위에서와 같이 content에서 Image 뷰를 표시하겠죠. 여기서는 Preview 상태를 보기 위해 square.and.arrow.up 이미지를 표시하도록 해 두었습니다. 

 

struct CachedAsyncImage_Previews: PreviewProvider {
    static var previews: some View {
        CachedAsyncImage(url: nil)
    }
}

그러면 아래와 같은 화면이 보여 집니다.

이제 나머지 초기화 함수를 아래와 같이 구현합니다. 

init<I, P>(url: URL,
           scale: CGFloat = 1,
           @ViewBuilder content: @escaping (Image) -> I,
           @ViewBuilder placeholder: @escaping () -> P)
    where Content == _ConditionalContent<I, P>, I : View, P : View {
            self.init(url: url, scale: scale) { phase in
                if case .success(let image) = phase {
                    content(image)
                } else {
                    placeholder()
                }
            }
        }

위의 함수를 보면 phase 의 상태가 성공하면 상태에서 image를 가져와 해당 이미지를 처리하는 content(image)를 호출하고 실패하거나  이미지가 없으면 placeholder() 를 사용하도록 구현합니다. 

벌써 반이나 왔습니다. 이제 URL을 이용해서 해당 주소에 image를 받아오는 부분만 처리하면 되겠죠. 

이제 뷰에 시작 시 URL 주소로 이미지 요청을 위해  iOS15.0 이상에서는 .task 한정자를 이용하겠습니다. 이전 버전은 .onAppear에 해당 부분을 구현하는 걸로 마무리 합니다. 

var body: some View {
        if #available(iOS 15.0, *) {
            content(phase)
                .task {
                    guard let url = self.url else { return }
                    let request = URLRequest(url: url)
                    do {
                    	//async await를 이용하여 구현 
                        let (data, response) = try await
                        URLSession.shared.data(for: request)
                        let httpResponse = response as! HTTPURLResponse
                        if httpResponse.statusCode < 200 || httpResponse.statusCode > 300 {
                            self.phase = .failure(CachedAsyncImageError.httpResponseError(httpResponse.statusCode))
                            print("http response error: \(httpResponse.statusCode)")
                        }
                        guard let uiImage = UIImage(data: data) else {
                            self.phase = .failure(CachedAsyncImageError.imageCreatedFail)
                            print("image create fail")
                            return
                        }
                        withAnimation(transaction.animation) {
                                self.phase = .success(Image(uiImage: uiImage))
                            }
                    } catch {
                        self.phase = .failure(error)
                        print("url request failed: \(error)")
                    }
                }
            
        } else {

아래 부분은 15.0 이후 버전에 동작하는 코드입니다. 

  } else {
            content(phase)
                .onAppear {
                    guard let url = self.url else { return }
                    let reqeust = URLRequest(url: url)
                    URLSession.shared.dataTask(with: reqeust){(data, response, error) in
                        DispatchQueue.main.async {
                            if let error = error { self.phase = .failure(error) }
                            let httpResponse = response as! HTTPURLResponse
                            if httpResponse.statusCode < 200 || httpResponse.statusCode > 300 {
                                self.phase = .failure(CachedAsyncImageError.httpResponseError(httpResponse.statusCode))
                                print("http response error: \(httpResponse.statusCode)")
                                return
                            }
                            guard let uiImage = UIImage(data: data!) else {
                                self.phase = .failure(CachedAsyncImageError.imageCreatedFail)
                                print("image create fail")
                                return
                            }
                            withAnimation(transaction.animation) {
                                self.phase = .success(Image(uiImage: uiImage))
                            }
                            
                        }
                    }.resume()
                }

이제 동작을 확인했으니 URLRequest 부분과 상태관리를 CachedAsyncImageViewModel로 변경해 보겠습니다. 

class CachedAsyncImageViewModel: ObservableObject {
    @Published var phase: CachedAsyncImagePhase = .empty

 

이제 URL 요청에 따른 상태 관리는 CachedAsyncImageViewModel을 통해서 이루어지고 CachedAsyncImageView는 상태값이 변경 될 때마다 자동으로 뷰를 재 생성합니다. 

struct CachedAsyncImage<Content>: View where Content: View{
    @ObservedObject private var viewModel = CachedAsyncImageViewModel()

이제 iOS15 이상 버전의 fetcth 함수와 이전 버전의 함수를 CachedAsyncImageViewModel로 이동시키는 데, 이전 버전의 함수는 Combine을 사용해 변경해 보겠습니다. 

class CachedAsyncImageViewModel: ObservableObject {
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
    func fetch(url: URL?) async {
        guard let url = url else {
            phase = .failure(CachedAsyncImageError.urlError)
            return
        }
        let request = URLRequest(url: url)
        do {
            let (data, response) = try await URLSession.shared.data(for: request)
            let httpResponse = response as! HTTPURLResponse
            if httpResponse.statusCode < 200 || httpResponse.statusCode > 300 {
                phase = .failure(CachedAsyncImageError.httpResponseError(httpResponse.statusCode))
                return
            }
            
            guard let uiImage = UIImage(data: data) else {
                phase = .failure(CachedAsyncImageError.imageCreatedFail)
                return
            }
            phase = .success(Image(uiImage: uiImage))
        } catch {
            phase = .failure(error)
        }
    }
func fetchImage(url: URL?) {
        guard let url = url else {
            phase = .failure(CachedAsyncImageError.urlError)
            return
        }
        cancelable = URLSession.shared.dataTaskPublisher(for: url)
            .tryMap { data, response in
            	//HTTP 에러 처리 completed 가 .failure를 전달하도록 변경 
                let httpResponse = response as! HTTPURLResponse
                if httpResponse.statusCode < 200 || httpResponse.statusCode > 300 {
                    throw CachedAsyncImageError.httpResponseError(httpResponse.statusCode)
                }
                //response 에 관심이 없으면 제거
                return (data, response)
            }
            .receive(on: DispatchQueue.main)
            .sink { completon in
                if case let .failure(error) = completon {
                    self.phase = .failure(error)
                }
            } receiveValue: { data, response in
                guard let uiImage = UIImage(data: data) else {
                    self.phase = .failure(CachedAsyncImageError.imageCreatedFail)
                    return
                }
                self.phase = .success(Image(uiImage: uiImage))
            }
        
    }

Combine으로 변경한 내용 중에 중요한 점은 상태 값 즉 퍼플리쉬 될 phase의 값의 변경은 메인 스레드에서해야 한다는 점입니다. 따라서 여기서 .receive(on: DispatchQueue.main)을 통해서 해당 전파가 메인 스레드에서 동작하도록 변경합니다. 

이제 CachedAsyncImage에 해당 코드를 추가 합니다. 

    var body: some View {
        if #available(iOS 15.0, *) {
            content(viewModel.phase)
                .task {
                    await viewModel.fetch(url: url)
                }
        } else {
            content(viewModel.phase)
                .onAppear {
                    viewModel.fetchImage(url: url)
                }
        }
    }

 

캐시 관련 내용은 다음에 작성하도록 하겠습니다. 

소스는 github 에서 다운받으 실 수 있습니다. 

https://github.com/hjpark0724/CacheAsyncImage/tree/main