WWDC18 Image and Graphics Best Practice 에서는 주로 기기를 사용할 때 부족해지는 리소스는 메모리와 CPU의 사용에 초점을 맞춥니다.
애플리케이션이 많은 CPU를 사용하게 되면 배터리의 수명과 애플리케이션의 응답성에 부정적인 영향을 미치는 것은 다들 알고 있습니다. 하지만 애플리케이션과 시스템이 메모리를 많이 사용하게 되면 CPU 사용률도 증가하게 되고 이는 배터리 수명과 성능에 나쁜 영향을 미치게 된다는 것은 잘 모르는 경향이 많습니다. 이런 리소스를 어떻게 아낄 수 있는지 개선하는 방법에 중점을 두고 설명하고 있습니다.
그리고 사진 앱처럼 사진 콘텐츠와 함께 동작하는 애플리케이션이 이러한 문제를 설명할 때 가장좋은 예시라고 하면서 사진을 편집하는 과정에 대해서 보여주었습니다.
그리고 애플리케이션의 그래픽 콘텐츠는 이사진과 같은 rich content(풍부한 콘텐츠, 비용이 비싼 콘텐츠)와 icongraphy(아이콘)으로 크게 두가지로 구분하게 됩니다.
UIImage는 이 버튼에 표시된 아이콘과 같은 것을 표현하는데 사용하는 UIKit의 데이터 타입이기도 합니다. 그리고 UIImageView는 UIImage를 표시하기 위해 UIKit이 제공하는 클래스 입니다.
고전적인 MVC 스타일로 생각해보면 UIImage는 모델 객체이고, UIImageView는 뷰 입니다. 그리고 모델과 뷰로서 UIImage는 이미지 콘텐츠 로드를 담당하고 UIImageView는 이를 표시하게 렌더링하는 역할을 한다. 라는 책임을 가지고 있습니다. 그러면 이제 이를 단순한 단방향 관계라고 이해할 수 있습니다.
하지만 실제과정은 조금 더 복잡합니다. 디코딩이라는 숨겨진 단계가 있고, 애플리케이션의 성능을 측정하기 위해서 이 디코딩에 대해 이해하는 것이 매우 중요합니다. 하지만 디코딩에 대해 설명하기 전에 먼저 버퍼라는 개념에 대해 설명해야 합니다.
이미지 버퍼
버퍼는 인접하게 연속되어 있는 메모리 영역을 말합니다. 그리고 우리가 지금 보고자하는 버퍼는 이미지 버퍼입니다. 이미지버퍼는 특정이미지의 in-memory 표현을 보관하는 버퍼에 사용하는 용어입니다. 이 버퍼의 각 요소는 이미지의 단일 픽셀의 색상과 투명도를 나타내고, 따라서 메모리에서 이 버퍼의 크기는 버퍼에 포함된 이미지의 크기에 비례하게 됩니다.
프레임 버퍼
프레임 버퍼는 애플리케이션의 실제 렌더링된 결과를 저장하는 역할을 합니다.
따라서 애플리케이션이 뷰 계층구조를 업데이트하면 UIKit은 애플리케이션의 window과 모든 하위 뷰를 프레임 버퍼에 할당합니다. 그리고 디스플레이 하드웨어가 디스플레이 픽셀을 비추기 위해 읽을 픽셀 별 색상 정보를 제공합니다.
장치에서 프레임버퍼를 초당 60번, ProMotion 디스플레이가 탑재된 iPad라면 초당 120번 프레임 버퍼에서 데이터를 가져와서 디스플레이에 보여주는 형태입니다. 만약 애플리케이션에서 뷰의 콘텐츠를 변경할때, 위의 예시에서는 새로운 UIImage를 UIImageView에 할당하게 되면 UIKit은 애플리케이션의 window를 프레임 버퍼에 다시 렌더링하고, 디스플레이가 프레임 버퍼에서 새로운 콘텐츠를 가져오게 됩니다.
데이터 버퍼
이제 이미지 버퍼를 다른 종류의 버퍼인 데이터 버퍼(바이트 시퀀스를 포함하는 버퍼)와 대조해보도록 하겠습니다.
우리의 경우에는 이미지 파일이 포함된 데이터 버퍼에대해서 다루게 됩니다. 이미지 파일이 포함된 데이터 버퍼는 일반적으로 해당 데이터버퍼에 저장된 이미지의 크기를 설명하는 메타데이터로 시작됩니다.
그 다음에는 이미지 데이터 자체가 포함되고, 이 데이터가 JPEG또는 PNG와 같은 형식으로 인코딩됩니다. 즉, 메타데이터 다음에 오는 바이트는 실제로 이미지의 픽셀에 대한 정보를 가지고 있지 않습니다.
이제 데이터 버퍼에 있던 이미지 데이터가 화면에 나타나는 파이프라인에 대해서 살펴보겠습니다.
위의 그림을 보면 UIImage를 나타내고자하는 UIImageView가 있고, 이미지의 데이터를 담고 있는 Data Buffer가 있습니다. 프레임 버퍼를 픽셀당 데이터로 채울 수 있어야 합니다.
이를 위해서 UIImage는 데이터 버퍼에 포함된 이미지의 크기(데이터 버퍼에 이미지 크기에 대한 정보가 담겨있다고 했죠?)와 같은 크기의 이미지 버퍼를 할당하고 그리고 디코딩작업을 수행해서 JPEG, PNG등으로 인코딩된 이미지 데이터를 픽셀당 이미지 정보로 변환합니다. 그런 다음 이미지 뷰의 content mode에 따르게 됩니다.
디코딩 문제점
UIKit이 이미지 뷰에 렌더링을 요청하면 이미지 버퍼에서 이미지 데이터를 복사하여 프레임 버퍼로 복사하면서 이미지 데이터를 복사하고 크기를 조정합니다. 이 디코딩 단계에서 큰 이미지의 경우 CPU를 많이 사용할 수 있습니다.
따라서, UIKit이 이미지 뷰에 렌더링을 요청할 때마다 매번 이 작업을 수행하는 대신에 UIImage가 해당 이미지 버퍼에 매달려서 한번만 작업이 수행되도록 합니다.
결과적으로 애플리케이션이 디코딩되는 모든 이미지에 대해 지속적으로 많은 양의 메모리를 할당하게 됩니다. 그리고 메모리를 많이 사용하는 만큼 처음에 언급했던 것처럼 성능에 부정적인 영향을 미치게 됩니다.
- fragmentation 증가시킴
- 메모리 지역성이 나빠짐
- 시스템이 메모리를 압축하기 시작
- 프로세스 종료
애플리케이션의 메모리 사용량이 누적되기 시작하면 운영체제가 개입해서 물리 메모리에 있는 콘텐츠를 압축하기 시작하고, 이 작업에 CPU가 관여해야하므로 애플리케이션의 CPU사용량도 증가하게 됩니다. 이렇게 사용자가 제어할 수 없는 글로벌 CPU사용량이 증가할 수 있다는 것이 문제점 입니다.
또 메모리를 많이 사용하게 됨으로써 OS가 프로세스를 종료해야할 수도 있습니다.
따라서 애플리케이션이 짧은 시간 동안만 메모리를 사용하더라도 CPU사용률에 꼬리에 꼬리를 물고 효과를 줄 수 있습니다. 따라서 애플리케이션이 사용하는 메모리의 양을 줄이고자 하고, 다운샘플링(downsampling)이라는 기술을 통해 문제를 해결할 수 있습니다.
위의 그림은 이미지 렌더링 파이프라인을 나타냅니다.
이미지에 표시할 이미지 뷰가 실제로 그 안에 표시할 이미지보다 작다는 조건이 주어져있는 상태입니다. 일반적으로 Core Animation 프레임워크가 렌더링 단계에서 이미지를 축소하지만 다운샘플링(downsampling)을 이용하면 메모리를 다음과 같이 절약할 수 있습니다. 그리고 기본적으로 축소 작업을 thumbnail이라는 오브젝트로 캡쳐하게 됩니다.
디코딩된 이미지 버퍼가 더 작아지기 때문에 총 메모리 사용량도 줄어들게 됩니다.
따라서 이미지 소스를 결정하고 Thumbnail을 만든 다음 디코딩된 이미지 버퍼를 UIImage로 캡쳐합니다. 그리고 Image View에 UIImage를 할당합니다. 그런다음 이미지가 포함된 원본데이터 버퍼를 버릴 수 있습니다. 그러면 애플리케이션의 장기적인 메모리 사용량이 훨씬 줄어들게 됩니다.
이를 위한 코드에는 몇가지 단계가 있습니다.
- CGImageSource 객체를 생성한다.
그리고 CGImageSourceCreate는 옵션이 담긴 Dictionary를 받을 수 있습니다. 여기서 전달할 사항은 kCGImageSourceShouldCache라고 보이는 ShouldCache 플래그 입니다. 이것은 Core Graphics 프레임워크에 이 URL에 있는 파일에 저장된 정보를 나타내는 객체를 생성하고 있을 알려줍니다. - 축적(Scale) 계산
이미지를 바로 디코딩 하지 말고 그냥 나타내는 객체를 생성해 주세요.
URL을 통해 이미지를 얻어와서 가로축과 세로축을 계산하고 렌더링할 scale과 픽셀단위로 더 큰 치수인 포인트를 기준으로 계산하게 됩니다. - Thumbnail에 대한 option Dictionary 만들기
downsampleOptions라는 이름의 변수로 몇가지 옵션들이 나열되어 있는 것을 볼 수 있습니다. 문서를 통해서 각각의 옵션이 어떤 역할을 하는지를 찾아볼 수 있습니다.
가장 중요한 옵션은 CacheImmediately 옵션인데, 이 옵션을 전달하면 Core Graphics에 Thumbnail을 생성해달라고 요청할 때 바로 그 순간에 디코딩 된 이미지 버퍼를 생성하라고 지시하는 것입니다.
4. Thumbnail을 생성하여 반환한다. (CGImage)
그 다음에는 예시를 하나보여주는데 31.5MB였던 이미지를 18.4MB로 메모리 사용량을 크게 줄였다는 내용이 나옵니다.
스크롤 뷰에서 디코딩
카메라 롤과 같은 UICollectionView로 구현된 이미지가 많이 나타나는 화면에서 곳에서 이런 이미지 다운샘플링을 사용하면 꽤좋은 방법이라고 생각됩니다. 하지만 안타깝게도 테이블 뷰나 컬렉션 뷰와같은 스크롤가능한 뷰에서 흔히 발생하는 또다른 문제가 해결되지는 않습니다.
애플리케이션을 스크롤 하다보면 스크롤할 때 끊김현상이 발생하는 것을 경험했던 적이 있을 것입니다. 스크롤하는 동안 CPU가 상대적으로 idle, 쉬고있는 상태이기 때문입니다. 디스플레이 하드웨어가 프레임 버퍼의 다음 복사본을 필요로 하기전에 스크롤이 가능하기 때문입니다.
따라서 프레임 버퍼가 업데이트 되고 디스플레이가 새 프레임을 제시간에 가져올 수 있을 때 부드러운 움직임이 나타납니다.
하지만 스크롤을 진행하면서 다른 행(row)를 표시하기 위해서 셀이 UICollectionView에 넘어가기 전에 Core Graphics에서 해당 이미지를 다시 디코딩하라고 요청하려고 합니다. 이 작업에는 많은 CPU시간이 소모됩니다.
이러한 동작으로 인해 응답성(Responsiveness)가 나빠질 뿐만 아니라 배터리 수명해도 악영향을 줍니다. iOS는 CPU에 대한 수요가 원활하고 일정할 때 배터리의 전력 수요를 관리하는데 매우 능숙하기 때문입니다. 이미지를 로드할 때마다 CPU 스파이크가 발생하는 것도 볼 수 있습니다.
이러한 CPU 사용량을 완화하는 데는 두가지 기술이 있습니다.
Prefetching and Background
첫번째는 prefetching입니다. prefetching에 대해 자세히 알고 싶다면, WWDC18 A Tour of CollectionView에 대해서 살펴보면 됩니다.
여기서 쓰이는 아이디어는 prefetching을 통해 CollectionView가 DataSource에 지금은 셀이 필요하지 않지만 머지않은 미래에 셀이 필요하다는 것을 알릴 수 있다는 것입니다. 따라서 해야할 작업이 있는 경우 미리 시작하게 하고 CPU 사용량을 분산시킬 수 있습니다. CPU 사용량의 최댓값을 줄여서 해결할 수 있습니다.
두번째 방법은 background에서 작업을 하는 것입니다.
이러한 방법으로 작업을 시간 경과에따라 분산 시켜서 사용가능한 CPU에 작업을 분산시킬 수 있습니다. 그 결과 애플리케이션의 응답성이 향상되고 배터리의 수명이 더 길어집니다.
실제 구현된 data source의 prefetch 메서드를 살펴보겠습니다.
helper function을 호출하여 CollectionView 셀에 표시할 이미지의 다운샘플링된 버전을 생성하고, global async queue중 하나에 작업을 디스패치해서 작업을 수행하는 코드입니다.
그러면 작업이 백그라운드에서 진행되고 있고 우리가 원하는대로 코드를 작성하였습니다.
하지만 여기에는 잠재적인 결함이 있습니다.
바로 스레드 폭발(Thread Explosion)이라고 부르는 현상입니다. 이는 시스템에 사용 가능한 CPU보다 더 많은 작업을 요청할 때 발생하는 현상입니다. 한 번에 6~8개의 이미지와 같이 많은 수의 이미지를 표시해야 하는데 CPU가 2개밖에 없는 기기에서 실행 중이라면 이 모든 작업을 한 번에 처리할 수 없습니다.
존재하지 않는 CPU를 통해 병렬 처리할 수 없기 때문입니다. 이제 global queue에 비동기식으로 디스패치할 때 교착 상태를 피하기 위해 GCD는 새 스레드를 생성하여 우리가 요청하는 작업을 캡처합니다. 그런 다음 CPU는 운영 체제에 요청한 모든 작업을 점진적으로 진행하기 위해 이러한 스레드 사이를 이동하는 데 많은 시간을 소비하게 됩니다.
그리고 이러한 스레드 간 전환에는 실제로 상당한 오버헤드가 발생합니다. 하나 이상의 CPU가 이미지를 처리할 수만 있다면 훨씬 더 나은 결과를 얻을 수 있을 것입니다.
그래서 concurrent가 아닌 serial한 dispatch queue를 사용해서 이 문제를 해결합니다. 코드의 제일위에 global serial queue를 하나 정의하고 비동기적으로 디스패치하는 것을 볼 수 있습니다.
개별적인 이미지의 다운샘플링 작업이 이전보다 늦게 진행될 수는 있지만 CPU가 작업을 전환하는데 소요되는 시간이 줄어든다는 의미이기도 합니다.
이미지를 어디서 가져오는가
표시되는 이미지는 여러 장소에서 가져올 수 있습니다. Image Asset을 통해 애플리케이션과 함께 제공될 수도 있고, 파일에 저장될 수도 있고 또는 네트워크에서 가져오거나 애플리케이션 document 디렉토리에 저장된 문서에 있을 수도 있고, 캐시에 저장될수도 있습니다.
애플리케이션과 함께 제공되는 것일 경우 image asset을 사용할 것을 권장하고 있습니다.
image assets는 name- and trait 기반 조회에 최적화되어 있습니다. 디스크에서 이름을 통해 파일을 검색하는 것보다 assets 카탈로그에서 image assets를 조회하는 것이 더 빠릅니다.
또한 런타임에는 버퍼 크기관리를 위한 몇가지 스마트한 기능이 포함되어있습니다. 또한 런타임 성능과 무관한 image assets전용 기능도 몇가지 있습니다.
- 애플리케이션이 실행할 디바이스와 벡터 artworks와 관련된 이미지 리소스만 다운로드하는 device thinning 등이 있습니다.
UIKit 커스텀 드로잉
위 사진과 같은 부분을 만들고 싶습니다. 오른쪽위의 버튼같은 경우는 UIImageView로 나타낼 수 있지만 가운데에 있는 라이브 스타일의 이미지를 활성화, 또는 비활성화 하기 위해 탭할 수 있는 라이브버튼 스타일의 버튼은 지원되지 않습니다.
그래서 몇가지 추가적인 작업을 수행해야합니다. UIView를 서브클래싱하고 draw 메서드를 오버라이드하는 것입니다. 여기서는 노란색 rouncdRect를 그리고 그 위에 이미지와 텍스트를 그리고 있는 것을 볼 수 있습니다.
하지만 이런방법을 추천하지는 않습니다…
UIView 서브클래스를 UIImageView와 비교해보겠습니다. 이미 알고 계셨겠지만 UIView는 사실 Core Animation 런타임에서 CALayer에 의해 지원됩니다.
UIImageView의 경우에는 이미지에 디코딩된 이미지 버퍼를 생성하도록 요청합니다. 그런 다음 디코딩된 이미지를 Layer의 컨텐츠로 사용할 수 있도록 CALayer에게 넘깁니다.
draw(_:)를 오버라이드해서 사용한 커스텀 뷰의 경우 비슷하지만 약간 다릅니다. CALayer가 draw(_:)메서드의 내용을 담을 이미지 버퍼를 생성하고 뷰는 draw(_:) 함수를 호출하여 해당 이미지 버퍼에 내용을 채우게 됩니다. 그런 다음 디스플레이의 필요에 따라 프레임 버퍼에 복사하게 됩니다.
왜 이게 문제가 되고 얼마나의 비용이 드는지 알아봅시다.
여기서 사용하고 있는 백킹 스토어는 CALayer에 첨부된 이미지 버퍼로, 그 크기는 표시하는 뷰에 비례합니다.
이제 iOS 12의 새로운 기능 및 최적화 기능 중 하나는 컬러 콘텐츠를 그리는지에 따라 백킹 스토어(Backing Store)의 요소 크기가 실제로 동적으로 커진다는 것입니다. 그리고 그 색상 콘텐츠가 표준 색상 범위 내에 있는지 또는 범위를 벗어나는지에 따라 달라집니다.
따라서 확장된 SRGB 색상을 사용하여 넓은 색상 콘텐츠를 그리는 경우 0에서 1 범위 내의 색상만 사용하는 경우보다 백킹 저장소가 실제로 더 커집니다. 이전 버전의 iOS에서는 코어 애니메이션에
이 뷰에서 와이드 컬러 콘텐츠를 지원할 필요가 없다는 것을 알고 있습니다.
또는
이 뷰에서 와이드 컬러 콘텐츠를 지원해야 한다는 것을 알고 있습니다.
라는 힌트로 CALayer의 콘텐츠 타입 프로퍼티를 설정할 수 있었습니다. 이렇게 하면 실제로 iOS 12에서 도입한 최적화를 비활성화하게 됩니다. 따라서layerWillDraw구현을 확인해야 합니다. iOS 12에서 실행할 때 코드에 도움이 될 수 있는 최적화를 실수로 쓸모없게 만들지 말아야합니다.

하지만 넓은 색상을 지원하는 backing store가 필요한지 여부에 대한 힌트만 제공하는 것보다 더 나은 방법을 찾아볼 수도 있습니다. 실제로 애플리케이션에 필요한 backing store의 총량을 줄일 수 있습니다.
왼쪽 그림처럼 큰 뷰를 더 작은 하위 뷰로 리팩토링하면 됩니다. 그리고 draw 메서드를 재정의하는 부분을 줄이거나 제거하면 됩니다. 이렇게하면 메모리에 존재하는 이미지 데이터의 중복 복사본을 제거하는데 도움이 됩니다. 또한 백업 저장소가 필요없는 최적화된 UIView 프로퍼티를 사용할 수 있습니다.
따라서 draw 메서드를 재정의 하려면 CALayer와 함께 사용할 backing store를 만들어야합니다. 그러나 UIView의 draw를 재정의하지 않더라도 일부 프로퍼티는 여전히 작동할 수 있습니다. (배경색 등… 단 패턴 색상은 안됨)
패턴색상을 사용하려면 대신 UIImageView를 만들고 해당 이미지뷰에 이미지를 할당합니다. 그리고 UIImageView의 메서드를 사용해서 타일링 매개변수를 적절하게 설정하면 됩니다.
요약
- 테이블 뷰와 컬렉션 뷰에 prefetch를 구현하여 미리 작업을 완료하고 장애를 방지할 수 있다.
- 뷰와 관련된 backing store의 크기를 줄이기 위해 UIKit이 제공하는 최적화를 무효화하지 않도록 주의하라.
- 애플리케이션과 함께 아트워크를 bundling하는 경우 에셋 카탈로그에 저장하라. 앱과 연결된 파일에 저장하지 마라.
- 동일한 아이콘을 다른 크기로 렌더링 하는 경우 preserver vector data 에 의존하지 마라.
'iOS' 카테고리의 다른 글
Swift와 경쟁상황(Race Condition) (0) | 2023.05.05 |
---|---|
MapKit 맵뷰 메모리 절약하기 (0) | 2023.05.05 |
Core Image 이용하여 이미지에 필터를 적용해보기 (0) | 2023.05.05 |
공식문서로 UIKit공부하기: UIKit에 대해서 (0) | 2022.03.27 |
SceneDelegate에 대해서... (0) | 2022.03.27 |