서론
이미지에 대한 WWDC를 찾아보며 이번 포스팅에서 정리할 "Image and Graphics Best Practices"까지 왔습니다.
사실 바로 직전 WWDC 포스팅(WWDC18 - iOS Memory Deep Dive)은 이 세션을 위한 정리였어요 ㅎㅎ
iOS Memory Deep Dive의 이미지 파트 부분을 읽고 오시면 이해가 더 잘 되지 않을까 싶습니다.
Image and Graphics Best Practices
"Image and Graphics Best Practices" 세션은 총 세 개의 주제를 다룹니다.
UIImage와 UIImageView, UIKit을 이용해 그리는 방법, 고급 CPU와 GPU 기술입니다.
이번 포스팅에서는 UIImage와 UIImageView에 대한 주제만 다룹니다.
앱은 메모리와 CPU 같은 자원이 한정되어 있습니다.
배터리 수명과 앱의 반응성을 위해 메모리와 CPU를 분리해서 생각하지 말고 최적화할 수 있는 방법을 찾아야 합니다.
특히 이미지는 이러한 자원을 많이 사용하는 컨텐츠이므로 최적화를 꼭 해야합니다.
UIImage and UIImageView
이미지 데이터를 다루는 High-Level 클래스인 UIImage와 UIImageView에 대해 알아봅시다.
그래픽 콘텐츠(Graphical Content)는 두 개의 카테고리로 나뉩니다.
다양한 정보를 가지고 있는 사진(rich content photography)과 아이콘(Iconography)입니다.
UIImage는 버튼의 아이콘같은 것을 나타내는 데 사용하는 UIKit의 데이터 타입입니다.
UIImageView는 UIImage 표시를 위해 UIKit이 제공하는 클래스입니다.
MVC 스타일로 생각하면 UIImage는 Model, UIImageView는 View로 생각할 수 있겠네요.
UIImage는 Image를 로드하고, UIImageView는 이미지 표시와 렌더링을 하고요.
하지만 화면에 이미지를 보여주는 과정은 그렇게 단순하지 않습니다.
Load와 Render 사이에 Decode 과정이 있기 때문입니다.
이 Decode 과정은 앱의 성능을 위해 이해해야 하는 아주 중요한 단계입니다.
Buffer
디코딩 과정을 알아보기 전에 버퍼에 대해 알아봅시다.
버퍼를 알아야 디코딩을 이해할 수 있어요.
버퍼는 단순히 메모리의 연속적인 영역인데 일반적으로 버퍼를 말할 때는 같은 크기를 가진 동일한 Element들의 Sequence로 구성된 메모리를 뜻합니다.
왼쪽 사진은 "연속적인 메모리"를 표현한거고, 오른쪽은 "같은 크기의 Element의 Sequence"를 나타낸 거에요.
Image Buffer
지금부터 알아볼 이미지 버퍼도 버퍼 중 하나에요.
이미지 버퍼는 이미지의 메모리 표현을 나타내는 버퍼입니다.
이미지 버퍼의 각 요소는 단일 픽셀의 색상과 투명도를 나타내기 때문에 버퍼의 크기와 이미지의 크기는 비례합니다.
더 큰 이미지를 표현하려면 더 많은 이미지 버퍼가 필요한 거에요.
Frame Buffer
프레임 버퍼(Frame Buffer)는 버퍼에서 중요한 것 중 하나입니다.
프레임 버퍼라는 것은 실제 앱의 렌더링 결과를 보관하는 버퍼입니다.
앱이 View hierarchy를 변경하면 UIKit은 앱의 window와 모든 subviews를 프레임 버퍼로 렌더링합니다.
위 사진에서 가운데가 프레임 버퍼에요.
그후 프레임 버퍼는 디스플레이에 표시할 각 픽셀 색상 정보를 제공하고, 디스플레이는 프레임 버퍼가 제공한 콘텐츠를 표시합니다.
디스플레이는 고정된 간격으로 콘텐츠를 표시합니다.
60Hz의 일반 디스플레이에서는 1/60초마다 표시하고, 120Hz의 프로모션 디스플레이는 1/120초마다 표시합니다.
프로모션 디스플레이는 위 과정이 1/120초로 더 빠르게 동작하기 때문에 부드러운 모션을 나타낼 수 있고,
아이폰 14 프로, 프로맥스의 AOD는 1Hz까지 떨어지는데 1초마다 디스플레이를 표시하기 때문에 배터리 효율이 좋은 것 입니다.
앱에서 아무것도 변경하지 않았다면 디스플레이는 이전의 프레임 버퍼에서 동일한 데이터를 다시 가져옵니다.
앱에서 View Content를 변경하면 UIKIt은 UIWindow를 프레임버퍼에 Re-Render 시키고, 하드웨어는 프레임버퍼에서 새로운 정보를 얻어와 표시합니다.
Data Buffer
데이터 버퍼는 이미지 버퍼의 다른 종류입니다.
데이터 버퍼는 Bytes의 Sequence를 포함하는 버퍼로, 이미지의 크기같은 Metadata와 JPEG 또는 PNG같은 이미지 형식으로 encode된 이미지 데이터가 저장됩니다.
서버에서 이미지를 다운로드하면 이미지를 인코딩한 데이터가 넘어오잖아요? 그 데이터를 담는 버퍼를 의미합니다.
데이터 버퍼는 프레임 버퍼에 바로 적용할 수 없습니다.
각 픽셀들이 가진 색상과 투명도 정보가 없기 때문입니다.
위에서 얘기한 것처럼 픽셀의 색상과 투명도를 포함한 이미지 버퍼로 변경해야 해요.
이때 우리는 Decode를 사용할 겁니다.
(인코딩 된 이미지 데이터를 디코딩해서 사용한다! 어찌보면 당연한 말이죠?)
먼저 UIImage는 데이터 버퍼에 저장된 이미지 크기만큼 이미지 버퍼를 할당합니다.
그리고 UIImageView의 contentMode에 맞게 디코딩 작업을 수행합니다.
마지막으로 UIKit이 UIImageView에 Rendering을 요청하면 이미지 버퍼 안에 저장되어 있는 이미지 데이터(색상, 투명도)를 프레임 버퍼에 복사하고 크기를 조정합니다.
위 과정은 메모리 할당이 지속적으로 발생하고, CPU를 많이 사용하는 동작입니다.
이미지 버퍼와 프레임 버퍼가 in-memory에 저장되어야 하기 때문에 순간적, 혹은 영구적으로 메모리 사용량이 증가할 것입니다.
따라서 UIKit이 계속 UIImageView에게 렌더링하도록 요청하는 게 아니라, UIImage가 해당 이미지 버퍼를 계속 가지고 있게 해서 한 번만 작업이 수행됩니다.
디코딩된 이미지 데이터는 이미지 버퍼에 보관되기 때문에 이미지의 크기에 비례한 메모리 할당이 필요합니다.
대규모 메모리 할당이 일어나면 OS는 물리적 메모리 영역을 압축합니다.
이 작업은 CPU를 사용하기 때문에 디바이스의 CPU 사용량이 증가하고, OS가 백그라운드 프로세스부터 순차적으로 종료시키게 됩니다.
결국에는 지금 실행 중인 앱도 종료가 되겠죠 ㅠ
이런 부정적인 결과를 방지하기 위해 앱은 메모리와 CPU를 효율적으로 사용해야 합니다.
Downsampling
프레임 버퍼는 이미지 버퍼를 복사할 때 모든 픽셀을 사용하는 것은 아닙니다.
UIImageView의 사이즈나 스케일 등을 고려하여 필요한 픽셀의 정보만 복사됩니다.
표시할 UIImageView의 크기가 이미지보다 작을 때, 메모리 양을 줄이기 위해 Downsampling을 고려할 수 있습니다.
원본 이미지를 필요한 만큼(렌더링 UIImageView 사이즈만큼) 축소해서 썸네일을 만듭니다.
썸네일을 만든 뒤에 데이터버퍼를 없애면 그만큼 메모리 사용량을 줄일 수 있습니다.
그리고 썸네일을 디코딩하면 할당하는 이미지 버퍼의 크기도 줄일 수 있습니다.
그럼 더 낮은 메모리 사용량을 가질 수 있겠죠.
Downsampling 코드
Downsampling을 하는 코드를 살펴봅시다.
Downsampling은 Core Animation이 담당하기 때문에 코드가 다소 생소할 수 있습니다. (제 얘기임)
아래는 전체 코드입니다.
이 코드를 하나하나 살펴보도록 합시다.
먼저, CGImageSource 객체를 생성해야 합니다.
kCGImageSourceShouldCache는 이미지를 디코딩된 형식으로 캐싱할지 설정하는 옵션인데요.
위 내용에서 말했듯이 데이터 버퍼는 Downsampling 후 버릴 것이기 때문에 캐싱하지 않게 false로 설정했습니다.
그다음, scale과 렌더링할 크기에 맞춰 썸네일의 최대 크기를 계산합니다.
여기서 pointSize는 UIImageView의 사이즈로 이해하면 됩니다.
쉽게 말해 화면에 보여줄 크기를 결정하는 거에요!
옵션들은 공식 문서를 보라고 해서 공식 문서를 보겠습니다. 순서는 위 사진의 옵션 순서와 동일합니다.
- kCGImageSourceCreateThumbnailFromImageAlways : 이미지 원본 파일에 썸네일이 있어도 전체 이미지를 이용해 썸네일을 만들지 결정합니다. kCGImageSourceThumbnailMaxPixelSize를 지정하지 않으면 썸네일의 크기는 전체 이미지 크기가 됩니다. 위 코드에서는 항상 썸네일을 만들도록 설정했습니다.
- kCGImageSourceShouldCacheImmediately : 썸네일을 생성할 때 이미지 버퍼를 생성하라고 알려줍니다. 이 옵션이 가장 중요한데요. Core Graphics에게 지금 썸네일이 생성되었으니, 디코딩된 이미지 버퍼를 생성하라고 알려주기 때문입니다. 이 옵션을 통해 디코딩에 쓰이는 CPU Hit 순간을 정확히 제어할 수 있습니다.
- kCGImageSourceCreateThumbnailWithTransform : 원본 이미지의 방향 및 비율에 맞게 썸네일을 회전하고 scaling 할지 결정하는 옵션입니다.
- kCGImageSourceThumbnailMaxPixelSize : 썸네일 이미지의 최대 가로, 세로입니다. point가 아니라 픽셀 단위로 지정해야 합니다. 이 옵션을 지정하지 않으면 썸네일의 크기가 원본 이미지 크기와 동일하게 설정됩니다.
마지막으로 CGImage 타입의 downsampling한 썸네일 이미지를 생성합니다.
UIImage로 쓰기 위해 변환을 해주고 리턴을 해줍니다.
Downsampling을 이용하면 메모리 사용량이 얼마나 줄어들까요?
절반에 가깝게 용량이 줄어든 것을 볼 수 있습니다.
Downsampling... 반드시 써야겠죠?
Decoding in Scrollable Views
CollectionView처럼 스크롤이 가능한 뷰에 이미지를 넣었다고 가정합시다.
보여줄 이미지 크기가 작기 때문에 위에서 알아본 Downsampling을 하겠죠.
메모리 관점에서는 좋겠지만, CPU 관점에서는 좋지 않습니다.
포스팅 맨 처음에 메모리와 CPU를 종합적으로 생각해야 한다고 말했었습니다.
CPU 사용량이 많아지고 중간 중간 튀는 스파이크 현상이 발생하는데, 이러면 스크롤이 버벅이고 배터리 수명에도 좋지 않습니다.
CPU 사용량을 개선하는 방법은 두 가지가 있습니다.
Prefetching과 Background에서 디코딩/다운샘플링을 하는 것입니다.
Prefetching은 필요한 이미지를 미리 디코딩하는 것입니다.
Prefetching을 이용하면 CPU 사용량을 분산시킬 수 있습니다.
백그라운드 방식은 DispatchQueue의 글로벌 큐를 이용해 downsampling을 해줬습니다.
이런 방식의 처리는 Thread Explosion이 발생할 수 있습니다.
Thread Explosion은 잦은 Context Switching으로 인해 성능 하락이 발생합니다.
그럼 어떻게 처리해야할까요??
바로 Serial Queue입니다.
Downsampling을 직렬로 수행하여 CPU Switching에 더 적은 시간을 소비하도록 할 수 있습니다.
Image Assets
앱에서 지원하는 이미지가 있다면 이미지 에셋을 사용하는 것이 좋습니다.
이름으로 에셋을 찾기 때문에 디스크의 파일을 검색하는 것보다 빠르고, 버퍼 캐싱을 관리하는 유용한 기능도 있습니다.
벡터 데이터를 이용하기 때문에 이미지보다 크거나 작게 렌더링이 되도 흐릿해지지 않습니다.
아직은 초보 개발자입니다.
더 효율적인 코드 훈수 환영합니다!
공감과 댓글 부탁드립니다.