ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • AsyncImage 구현하기(1)
    SwiftUI 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

    댓글

Designed by Tistory.