1. iOS UICollectionView, UITableView
iOS는 UICollectionView, UITableView를 처리할 때 한번에 데이터 수만큼 cell을 만들지 않는다.
메모리 정책에 기반하여 view에 보여지는 만큼만 cell을 생성하고 나머지는 재사용한다. 이것이 cell 재사용 정책이다.
다음과 같이 cellForItemAt() 안에서 cell을 반환할 때 dequeueReusableCell 에서 구별자로 cell을 가져오는 것을 볼 수 있다.

그런데 이렇게 UICollectionView나 UITableView를 스크롤 하다 보면 이미지를 원격 저장소, 즉 이미지 url에서 이미지를 가져와서 cell에 보여주는 경우 이미지가 잘못 들어가거나 왔다 갔다 제대로 보이지 않는 경우가 발생할 수도 있다.
왜 이런 것일까?
2. 문제 상황의 원인: cell 재사용과 이미지 로딩
재사용 큐에 의해서 cell은 재사용된다. 즉 기존 cell의 정보들을 지우고 다시 새로운 정보를 넣어야 하는데 원격 저장소로부터 이미지를 로딩하는 작업은 디코딩 작업을 필요로 하기 때문에 global queue에서 작업한다.
queue에 대한 설명은 다음을 참고하면 좋다.
[iOS] GCD의 기본: DispatchQueue의 종류와 특성
지난 번에는 GCD가 무엇인지 기본을 알아보았다. [iOS] GCD의 기본: sync, async, serial, concurrent ios multi-threading ios에서 멀티 스레딩을 지원하는 방식은 Thread, OperationQueue, GCD 이다. 이 중에서 Thread는 복잡
josushell.tistory.com
global queue이기 때문에 concurrent고 이 말은 즉 끝나는 순서를 보장할 수 없다는 뜻이 된다. 또한 cell을 dequeue 할때 분명 queue임에도 불구하고 먼저 들어간게 먼저 재사용되지는 않는다.
따라서 기존 이미지가 로딩되었다가 새로운 이미지가 로딩되는 사이가 오~래 걸릴 수도 있고, 혹은 순서가 반대로 되서 새로운 이미지가 먼저 로딩된다면 그 위에 기존 이미지가 overwrite되어 기존 이미지가 계속 보일 수도 있다. 즉 엉망진창이 되어버린다.
3. 해결
일단 백에서 Async로 처리하는 것도 하나의 방법이지만 이미지 캐싱을 사용한다면 더 좋을 것 같다.
물론 swift만으로도 해결 가능하다. 비동기 처리를 위한 API가 많기 때문이다. preparingForDisplay() 라던지 prepareThumbnail() 등을 활용할 수 있을듯하다.
하지만 오늘은 Kingfisher 라이브러리를 사용하여 문제를 해결할 것이다.
4. Kingfisher
Kingfisher를 설치해준다. SPM, cocoaPod 모두 사용가능하다.
GitHub - onevcat/Kingfisher: A lightweight, pure-Swift library for downloading and caching images from the web.
A lightweight, pure-Swift library for downloading and caching images from the web. - GitHub - onevcat/Kingfisher: A lightweight, pure-Swift library for downloading and caching images from the web.
github.com
위의 README를 읽어보면 아주 쉽게 사용법이 나와있다.
import Kingfisher
let url = URL(string: "https://example.com/image.png")
imageView.kf.setImage(with: url)
진짜 이게 다다.
공식 문서에는 더 응용된 사용법도 나와있다.

1. 고해상도의 이미지를 다운로드한다.
2. imageView 사이즈에 맞게 다운 샘플링한다.
3. 코너에 둥글기 (radius)를 설정하여 둥글게 만들 수 있다.
4. 다운로드 되는 동안 placeholder 이미지나(준비 중 이미지), 로딩 인디케이터를 보여줄 수도 있다.
5. 준비가 되면 썸네일 이미지에 fade 효과를 주어 최종 이미지를 보여줄 수 있다.
6. 원래 큰 이미지는 디스크에 캐싱되어, 나중에 다시 다운로드할 필요가 없다.
7. 작업이 끝나거나, 성공, 실패시에는 콘솔에 log가 찍히게 된다.
각각의 step에 해당되는 코드는 다음과 같다.
// 1. 고해상도의 이미지를 다운로드한다.
let url = URL(string: "https://example.com/high_resolution_image.png")
// 2. imageView 사이즈에 맞게 다운 샘플링한다.
// 3. 코너에 둥글기 (radius)를 설정하여 둥글게 만들 수 있다.
let processor = DownsamplingImageProcessor(size: imageView.bounds.size)
|> RoundCornerImageProcessor(cornerRadius: 20)
// 4. 다운로드 되는 동안 placeholder 이미지나(준비 중 이미지), 로딩 인디케이터를 보여줄 수도 있다.
imageView.kf.indicatorType = .activity
imageView.kf.setImage(
with: url,
placeholder: UIImage(named: "placeholderImage"), // placeholder 이미지
options: [
.processor(processor),
.scaleFactor(UIScreen.main.scale),
.transition(.fade(1)), // 5. 준비가 되면 썸네일 이미지에 fade 효과를 주어 최종 이미지를 보여줄 수 있다.
.cacheOriginalImage // 6. 원래 큰 이미지는 디스크에 캐싱되어, 나중에 다시 다운로드할 필요가 없다.
])
{
// 7. 작업이 끝나거나, 성공, 실패시에는 콘솔에 log가 찍히게 된다.
result in
switch result {
case .success(let value):
print("Task done for: \(value.source.url?.absoluteString ?? "")")
case .failure(let error):
print("Job failed: \(error.localizedDescription)")
}
}
전체 코드는 다음과 같다.
let url = URL(string: "https://example.com/high_resolution_image.png")
let processor = DownsamplingImageProcessor(size: imageView.bounds.size)
|> RoundCornerImageProcessor(cornerRadius: 20)
imageView.kf.indicatorType = .activity
imageView.kf.setImage(
with: url,
placeholder: UIImage(named: "placeholderImage"),
options: [
.processor(processor),
.scaleFactor(UIScreen.main.scale),
.transition(.fade(1)),
.cacheOriginalImage
])
{
result in
switch result {
case .success(let value):
print("Task done for: \(value.source.url?.absoluteString ?? "")")
case .failure(let error):
print("Job failed: \(error.localizedDescription)")
}
}
5. 프로젝트에 적용하기
직접 사용해보자.
먼저 원래 프로젝트의 코드는 다음과 같다.

여기서 88번줄 대신 kingfisher를 이용해서 이미지를 로드해보자.
참고로 원래 load하는 코드는 다음과 같이 작성했었다.

다음처럼 설정하고 코드를 변경했다.
cell.layout_img.kf.indicatorType = .activity
cell.layout_img.kf.setImage(with: book[0], placeholder: UIImage(named: "noBookImg"), options: [.transition(.fade(1)), .cacheOriginalImage], progressBlock: nil)
.forceTransition은 이미지 로딩에 오랜 시간이 걸리지 않아도(캐싱해서) 강제로 fade 효과를 주는 것이다.
즉 언제나 fade 효과가 보인다.

결과는 다음과 같다. 원래는 이렇게 끊겨서 보였던게

매끄럽게 잘 보인다!

만족 그 자체
캐싱을 삭제하고 관리하는 부분은 나중에 해야겠다