관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - SwiftUI] 2. Pagination 방법 (페이지네이션, Combine) - 최적화, 이미지 캐싱 본문

iOS 응용 (SwiftUI)

[iOS - SwiftUI] 2. Pagination 방법 (페이지네이션, Combine) - 최적화, 이미지 캐싱

jake-kim 2022. 10. 6. 22:58

1. Pagination 방법 (페이지네이션, Combine) - 기초

2. Pagination 방법 (페이지네이션, Combine) - 메인 스레드 최적화, 이미지 캐싱

구현된 페이지네이션 (사진 로딩 최적화)

이미지를 불러올때 고려할 것

  • url을 가지고 이미지를 불러올 때, main thread에서 이미지를 가져오면 앱이 버벅이는 현상이 존재
    • 심지어 AsyncImage를 통해서 async하게 이미지를 불러와도 앱이 버벅이는 현상이 존재
    • background thread에서 url을 통해 이미지를 불러오도록 구현
  • Image Caching 적용을 통해 조금 더 빠른 로딩이 되도록 구현
    • 이미지 캐싱이 없다면 매번 url을 네트워킹을 통해서 이미지를 획득하므로 캐싱이 필수

구현 아이디어

  • url을 통해 이미지를 불러오는 코드는 background thread에서 동작하도록 구현
    • viewModel에서 url 배열이 있을때 이 배열들을 모두 순회하며 uiImage 형태로 모두 로드하여 한꺼번에 View쪽에 넘기는 형태로 구현
    • View에서 url을 가지고 로드하게되면 아무리 캐싱을 사용한다해도 계속 캐시를 확인하고 로드하는 과정이 있게되어 퍼포먼스 저하를 대비
  • 이미지 캐싱은 NSCache(NSString, UIImage)()을 이용하여 구현
    • 이미지 관련 Kingfisher, SDWebImage와 같은 것들을 사용해도 내부적으로 url을 가지고 이미지를 가져올때도 메인 스레드에서 동작하므로 직접 구현할것

구현

  • 사용 API - 이미지 리스트를 불러오는 Flickr
    • (예제를 위해서 페이지네이션이 있다고 가정하고 구현)
https://api.flickr.com/services/feeds/photos_public.gne?tags=texas&tagmode=any&format=json&nojsoncallback=1
  • API Decoding 모델 정의
struct PhotoModel: Codable {
  struct Item: Codable {
    struct Media: Codable {
      let m: String
    }
    let media: Media
    let description: String
  }
  let items: [Item]
}

extension PhotoModel {
  var url: String? {
    items.first?.media.m
  }
  var photoUrlStrings: [String] {
    items.map(\.media.m)
  }
  var coreItems: [(String, String)] {
    var res = [(String, String)]()
    for i in 0..<items.count {
      res.append((items[i].media.m, items[i].description))
    }
    return res
  }
}

 

  • UI에 표출될때 사용될 전용 Model 정의
    • 뷰쪽에서 urlString이 아닌 UIImage 그대로 사용할것이므로 모델에 UIImage 프로퍼티 선언 
    • ForEach를 사용하여 표출할것이므로 Identifiable 준수
    • 이미지를 표출할때 비율을 알아야하므로 width, height 필요
struct Photo: Identifiable {
  let url: String
  let uiImage: UIImage
  let width: CGFloat
  let height: CGFloat
}

extension Photo {
  var id: String { url }
}

extension Photo: Hashable {}
  • View, ViewModel 선언
import SwiftUI

struct ContentView: View {
  @ObservedObject var viewModel = ContentViewModel()
  private var photoWidth: CGFloat {
    UIScreen.main.bounds.width
  }
  
  init() {
    viewModel.fetchPhoto()
  }
  
  var body: some View {
    NavigationView {
      getList()
        .navigationTitle("사진")
    }
  }
  
  @ViewBuilder
  private func getList() -> some View {
    // TODO
  }
}

final class ContentViewModel: ObservableObject {
  @Published var photos = [Photo]()
  private var page = 0
  
  func fetchPhoto() {
    //TODO
  }
}
  • View의 getList 구현
    • tip) SwiftUI에서 제공하는 List, GridView 같은 것을 사용하지 않고 ScrollView, LazyVStack, ForEach, NavigationLink를 사용하여 구현해야 커스텀이 편리
    • 페이지네이션은 ForEach 마지막에 ProgressView를 추가하고 여기에 onAppear시 viewModel에 데이터를 가져와달라고 요청
  @ViewBuilder
  private func getList() -> some View {
    ScrollView {
      LazyVStack {
        ForEach(viewModel.photos) { photo in
          NavigationLink(
            destination: {
              Image(uiImage: photo.uiImage)
                .resizable()
                .frame(
                  width: photoWidth,
                  height: getHeight(imageWidth: photo.width, imageHeight: photo.height)
                )
            },
            label: {
              Image(uiImage: photo.uiImage)
                .resizable()
                .frame(
                  width: photoWidth,
                  height: getHeight(imageWidth: photo.width, imageHeight: photo.height)
                )
            }
          )
        }
        if !viewModel.photos.isEmpty {
          HStack {
            Spacer()
            ProgressView()
              .onAppear {
                viewModel.fetchPhoto()
              }
            Spacer()
          }
        }
      }
    }
  }
  
  private func getHeight(imageWidth: Double, imageHeight: Double) -> Double {
    photoWidth * imageHeight / imageWidth
  }
  • viewModel 의 fetchPhoto 구현
    • ImageCache를 사용하여 중간중간 urlString에 대한 UIImage 캐싱
    • 해당 API는 width와 height값이 문자열로 주어지므로, getWidthHeight라는 메소드를 통해 가져오도록 구현
  func fetchPhoto() {
    page += 1
    DispatchQueue.global().async {
      guard
        let url = URL(string: "https://api.flickr.com/services/feeds/photos_public.gne?tags=texas&tagmode=any&format=json&nojsoncallback=1")
      else { return }
      URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
        guard
          let ss = self,
          let data = data,
          let photoModel = try? JSONDecoder().decode(PhotoModel.self, from: data)
        else { return }
        
        var newPhotos = [Photo]()
        photoModel
          .coreItems
          .forEach { urlString, description in
            let widthHeight = ss.getWidthHeight(description: description)
            if let uiImage = ImageCache.shared.object(forKey: urlString as NSString) {
              newPhotos.append(.init(url: urlString, uiImage: uiImage, width: widthHeight.0, height: widthHeight.1))
            } else {
              guard
                let url = URL(string: urlString),
                let data = try? Data(contentsOf: url),
                let uiImage = UIImage(data: data)
              else { return }
              ImageCache.shared.setObject(uiImage, forKey: urlString as NSString)
              newPhotos.append(.init(url: urlString, uiImage: uiImage, width: widthHeight.0, height: widthHeight.1))
            }
          }
        DispatchQueue.main.async {
          ss.photos = ss.photos + newPhotos
        }
      }.resume()
    }
  }
  
  private func getWidthHeight(description: String) -> (CGFloat, CGFloat) {
    let array = description.split(separator: " ").map(String.init)
    let widthStr = array
      .first(where: { $0.prefix(5) == "width" })!
      .replacingOccurrences(of: "\"", with: "")
      .replacingOccurrences(of: "width=", with: "")
    let heightStr = array
      .first(where: { $0.prefix(6) == "height" })!
      .replacingOccurrences(of: "\"", with: "")
      .replacingOccurrences(of: "height=", with: "")
    return (Double(widthStr)!, Double(heightStr)!)
  }

완성)


전체 코드: https://github.com/JK0369/ExPagination-SwiftUI-Photo

Comments