관리 메뉴

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

[iOS - swift] 이미지 캐시 (ImageCache) 구현 방법, URLSession, NSCache (애플 공식 문서 방법) 본문

iOS 응용 (swift)

[iOS - swift] 이미지 캐시 (ImageCache) 구현 방법, URLSession, NSCache (애플 공식 문서 방법)

jake-kim 2021. 10. 28. 03:12

* 기초 개념

URLSession 개념: https://ios-development.tistory.com/651

NSCache 개념: https://ios-development.tistory.com/658

Diffable Data Source 개념: https://ios-development.tistory.com/717


ImageCache를 사용하는 이유

  • TableView, CollectionView에서 사용자가 뷰를 스크롤 시 같은 이미지를 요청하는 경우가 생기고, 이때 cache를 통해서 이미지에 해당하는 URL은 API를 한 번만 호출하도록 하기 위함

  • ex) tableView에서 스크롤 시 화면에 보이는 cell의 모양을 계속 업데이트해야 하므로, cell을 만드는 메소드가 재호출되는 현상

Diffable data source를 사용하지 않으면 tableView의 cellForRowAt 델리게이트 메소드가 호출될 것이고,

Diffable data source를 사용하면 diffable data source의 클로저 부분이 계속 호출

// cell 업데이트가 재호출되는지 확인하기 위해서 indexPath를 출력
lazy var dataSource: UITableViewDiffableDataSource<Section, MyItem> = {
    return UITableViewDiffableDataSource<Section, MyItem>(tableView: tableView) { tableView, indexPath, itemIdentifier in

        print(indexPath) // <- 재호출되는지 디버그 용도

        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = itemIdentifier.title
        return cell
    }
}()

lazy var tableView: UITableView = {
    let view = UITableView()
    view.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
    return view
}()

이미 로드되었던 indexPath도 재호출되는 것을 확인

cache를 이용하여 Cell에 이미지를 표시하는 과정

1) 최초 or 스크롤되는 것을 감지(scrollViewDidLoad에서 파악)하여 미리 image에 해당하는 다수의 url 획득

2) 다량의 url을 획득하여 dataSource 배열에 삽입 

3) dataSource가 변경되었다고 tableView에 알림

4) tableView의 cell 업데이트 (Snapshot or cellForRowAt 델리게이트에서 ImageCache 모듈을 사용)

ImageCache 모듈

  • 프로퍼티
    • cachedImages: URL에 대해서 image들을 저장하고 있는 cache하는 프로퍼티
    • waitingResponseClosure: cache에 image가 없는 경우, urlSession을 통해 image를 얻어와야 하므로 response를 받은 후 결과값을 전달해야 할 때, 전달받기 위해서 선언된 프로퍼티
  • 메소드
    • `getImage(url:) -> UIImage?` : url을 인수로 받아서 cachedImages 프로퍼티에 접근하여 캐시된 이미지 획득하는 메소드
    • `load(url:item:completion:)` : url과 Item을 인수로 받아서 `getimage(url:) -> UIImage` 메소드로 이미지를 불러서 이미지가 있으면 그 이미지를 completion에 전달하고, 없으면 urlSession 후 얻어진 image를 completion에 전달

  • 코드
/// ImageCache에서 사용하는 모델은 Item을 준수하는 모델을 사용하도록 강제하기 위해 선언
public protocol Item {
    var image: UIImage? { get set }
    var imageUrl: URL { get }
    var identifier: String { get }
}

public class ImageCache {
    public static let shared = ImageCache()
    private init() {}

    private let cachedImages = NSCache<NSURL, UIImage>()
    private var waitingRespoinseClosure = [NSURL: [(Item, UIImage) -> Void]]()

    private final func image(url: NSURL) -> UIImage? {
        return cachedImages.object(forKey: url)
    }

    final func load(url: NSURL, item: Item, completion: @escaping (Item, UIImage?) -> Void) {
        // Cache에 저장된 이미지가 있는 경우
        if let cachedImage = image(url: url) {
            DispatchQueue.main.async {
                completion(item, cachedImage)
            }
            return
        }

        // Cache에 저장된 이미지가 없는 경우, 서버로 부터 데이터를 가져오고나서 데이터를 completion에 넘겨주어야 하기때문에 기록
        if waitingRespoinseClosure[url] != nil {
            /// 이미 같은 url에 대해서 처리중인 경우
            waitingRespoinseClosure[url]?.append(completion)
            return
        } else {
            /// 해당 url처리가 처음인 경우 > URLSession으로 data 획득 필요
            waitingRespoinseClosure[url] = [completion]
        }

        // .epemeral: 따로 NSCache를 사용하기 때문에 URLSession에서 cache를 사용하지 않게끔 설정
        let urlSession = URLSession(configuration: .ephemeral)
        let task = urlSession.dataTask(with: url as URL) { data, response, error in
            // 이미지 data 획득
            guard let responseData = data,
                  let image = UIImage(data: responseData),
                  let blocks = self.waitingRespoinseClosure[url], error == nil else {
                      DispatchQueue.main.async {
                          completion(item, nil)
                      }
                      return
                  }

            // 캐시에 저장 후 completion에 전달
            self.cachedImages.setObject(image, forKey: url, cost: responseData.count)
            for block in blocks {
                DispatchQueue.main.async {
                    block(item, image)
                }
            }
            return
        }

        task.resume()
    }
}

사용할 API, Model

  • url
https://itunes.apple.com/search?term=flappy&entity=software
  • data

  • DTO 모델 - results안의 screenshotUrls 중 첫번째 url 사용하기 위해 아래 처럼 정의
struct ItunesImageModel: Decodable {
    let resultCount: Int
    let results: [Result]

    struct Result: Codable {
        let screenshotUrls: [String]
    }
}
  • urlSession.shared.downloadTask(with: url)을 통해 url 정보 다운로드 > json을 Decodable 모델로 파싱
class ItunesAPI {
    static func fetchImages(completion: @escaping (Result<ItunesImageModel, Error>) -> Void) {
        guard let url = URL(string: "https://itunes.apple.com/search?term=flappy&entity=software") else { return }
        let task = URLSession.shared.downloadTask(with: url) { url, response, error in
            do {
                if let url = url {
                    if let data = try? Data(contentsOf: url) {
                        let jsonDecoder = JSONDecoder()
                        let itunesImages = try jsonDecoder.decode(ItunesImageModel.self, from: data)
                        completion(.success(itunesImages))
                    }
                    
                    
      ...

ImageCache 사용 주요 코드 - ImagesViewController

  • cell 업데이트 관련 코드에 ImageCache 모듈을 사용하여 url을 가지고 이미지 획득
    • DiffableDataSource의 클로저는 dataSource.apply(snapshot) 호출 시 동작
// viewDidLoad()에서 호출
private func setupDiffableDataSouce() {
    dataSource = UITableViewDiffableDataSource<Section, ImageItem>(tableView: tableView) { tableView, indexPath, itemIdentifier in
        let cell = tableView.dequeueReusableCell(withIdentifier: ImageTableViewCell.identifier, for: indexPath) as! ImageTableViewCell
        cell.model = itemIdentifier.image
        ImageCache.shared.load(url: itemIdentifier.imageUrl as NSURL, item: itemIdentifier) { originItem, fetchedImage in
            guard let fetchedImage = fetchedImage, fetchedImage != originItem.image,
                  let originItem = originItem as? ImageItem else { return }
            originItem.image = fetchedImage
            var snapshot = self.dataSource.snapshot()
            snapshot.reloadItems([originItem])
            self.dataSource.apply(snapshot, animatingDifferences: true)
        }
        return cell
    }
}
  • viewDidLoad에서 이미지 url 데이터들을 얻기 위해 API 호출
    • API 호출 후 위의 셀 업데이트 코드가 실행되기 위해, dataSource.apply(snapshot) 호출
// viewDidLoad()에서 호출
private func requestItunesImages() {
    ItunesAPI.fetchImages { [weak self] result in
        switch result {
        case .success(let itunesImageModel):
            guard var snapshot = self?.dataSource.snapshot() else { return }
            if snapshot.sectionIdentifiers.isEmpty == true { snapshot.appendSections([.main]) }
            let placeholderImage = UIImage(systemName: "rectangle")!
            let urlStrs = itunesImageModel.results.compactMap { $0.screenshotUrls.first }
            let urls = urlStrs.compactMap { URL(string: $0) }
            let imageItems = urls.map { return ImageItem(image: placeholderImage, imageUrl: $0) }
            snapshot.appendItems(imageItems)
            self?.dataSource.apply(snapshot)
        case .failure(let error):
            print(error)
        }
    }
}

* 전체 소스 코드: https://github.com/JK0369/ExImageCache

 

* 참고

Asynchronously Loading Images into Table and Collection Views: https://developer.apple.com/documentation/uikit/views_and_controls/table_views/asynchronously_loading_images_into_table_and_collection_views

Comments