이미지 형식과 기본 개념
왜 이미지 처리가 중요할까?
이미지 처리 최적화를 안 하면 어떻게 될까요? 메모리가 부족해서 앱이 강제 종료되거나, 스크롤이 버벅거리는 경험을 해보신 적 있으실 거예요.
이미지의 두 가지 방식
래스터 이미지 (Bitmap)
픽셀로 이루어져 해상도가 고정되어 있어 확대 축소하면 품질 저하가 발생합니다. 대표적으로 JPEG, PNG, HEIF가 있어요.
벡터 이미지 (Vector)
수학적 좌표+기하학적 형태 기반이어서 계산을 통해 크기를 자유롭게 조정해도 품질이 같습니다. SVG, PDF가 대표적이죠.
그렇다면 어떻게 구별해서 사용해야 할까?(PNG, JPEG, HEIF SVG)
PNG
- 무손실 압축으로 이미지 품질이 유지됩니다
- 투명도를 지원합니다
- 아이콘이나 로고에 적합해요
JPEG
- 손실 압축 방식을 사용해 파일 크기가 작아집니다
- 투명도 지원 X
- 사진에 적합해요
HEIF
- JPEG보다 더 효율적인 압축 제공
- iOS 기기의 기본 포맷
- Live Photos를 지원합니다
HIG 문서에서 권장하는 사용법을 보면:
- 일반적인 비트맵 혹은 래스터 작업: 인터레이싱 없는 PNG 파일포맷
- 24비트 컬러가 필요하지 않은 PNG 그래픽: 8비트 색상 팔레트를 사용하는 PNG 포맷
- 사진: JPEG 또는 HEIC. 왜? 풍부한 디테일이 들어있으므로 압축해야 함! 너무 크거든요
- 평면 아이콘, UI아이콘 및 고해상도 스케일링 필요한 아트워크: 벡터이미지인 PDF, SVG. 그 이유는 크기 조정이 잦기 때문입니다
스케일 팩터
Asset Catalog에서 1x, 2x, 3x를 왜 만들어야 할까요? 이건 단순히 해상도 때문이 아니에요.
알아둬야할 용어들
PPI (Pixels Per Inch)
1인치 안에 몇 개의 픽셀이 존재하는가를 나타냅니다.
예시: 10PPI
→ 가로 1인치에 10개, 세로 1인치에 10개
→ 총 100개의 픽셀이 들어있음
→ 많을수록 선명!
1포인트란?
아이폰의 해상도가 높아지면서 같은 픽셀의 양이여도 화면에서 그리는 게 달라졌어요.
왜? 해상도가 높을수록 픽셀이 더 많으므로... 1인치를 72로 나눈 것이 1포인트입니다.
픽셀의 크기별 메모리 사용량
- 1바이트: Alpha값만 저장
- 2바이트: 명도(Gray) + Alpha
- 4바이트: RGB + Alpha (일반적인 컬러 이미지)
- 8바이트: 와이드 포맷으로 픽셀당 2바이트 (고해상도 이미지, HDR 비디오)
HIG를 보면
When creating bitmap images, you specify a scale factor which determines the resolution of an image. You can visualize scale factor by considering the density of pixels per point in 2D displays of various resolutions
이미지의 해상도를 결정하는 스케일 팩터를 지정한다고 나와있다.
즉 scale factor = 포인트 안에 픽셀의 밀도에 따른 배율!이다.
HIG문서를 더 보면 1x = 1포인트당 1픽셀을 의미한다. 당연히 고해상도에서는 2:1 또는 3:1 같이 픽셀 밀도가 높다.
1x = 1포인트당 1픽셀
2x = 1포인트당 4픽셀 (2×2)
3x = 1포인트당 9픽셀 (3×3)
App Slicing과 디바이스별 최적화
이제 1x, 2x, 3x 에셋을 저장하면 앱에는 엄청 많은 정보가 들어가 있어요. 이미지를 보면 또 저걸 기기(Mac, iPhone, iPad) 별로 따로따로 저장하는 것을 확인할 수 있습니다.
하지만 앱스토어에서 다운로드할 때는 해당 정보들을 다 받는 것이 아니라 다운받는 기기에 맞춰 필요한 것만 App Slicing 과정을 통해 다운받을 수 있어요.
어떤 디바이스에 어떤 에셋이 들어가는지는 iOS Resolution 링크에 모든 디바이스가 나와있습니다.
만약 1x만 등록해 놓으면? iPhone 16 Pro Max에서는 디자이너가 아무리 이쁘게 해도 개발자가 다 망쳐놓는 거예요... 픽셀이 늘어나면서 흐릿하게 보이거든요
https://www.ios-resolution.com
iOS Resolution // Display properties of every iPhone, iPad, iPod touch and Apple Watch Apple ever made
Last Updates: 2024-09-22 Added iPhone 16 models. All devices 112 iPhones 46 iPads 33 iPods touch 7 Apple Watches 26 Family & Model Logical Width Logical Height Physical Width Physical Height PPI Scale Factor Screen Diagonal Release iPhone 16 Pro Max Logica
www.ios-resolution.com
이미지 렌더링 프로세스
이미지가 화면에 그려지는 과정
왜 이미지 처리가 메모리를 많이 먹을까요? 이 과정을 이해하면 답이 보여요.
JPEG, PNG 등의 이미지 파일은 디코딩(GPU에서 읽을 수 있도록)을 통해 이미지 버퍼에 저장된 후 UIImageVIew 또는 SwiftUI에서는 Image뷰를 통해 렌더링(매 초마다 화면에 업데이트되어 보여줌. 60HZ-> 초 60회 번씩 그려주는 거) 됩니다.
좀 더 자세히 살펴보겟습니다.
먼저 로드할때의 UIImage는 UIKit의 데이터 타입이고 렌더링하는 UIImageView는 UIImage를 표시하는 UIKit클래스다.
그 사이에는 디코딩이 존재합니다.
Buffer
버퍼는 단순한 메모리의 연속적인 영역입니다
이미지와 같이 동일한 크기를 가진 Element들의 Sequence로 구성되어 있는 연속적인 영역인거죠.
ImageBuffer
이미지 버퍼는 이미지의 메모리 표현을 나타내는 버퍼입니다. 각 요소는 단일 픽셀의 색상과 알파값을 나타내기 때문에 버퍼의 크기와 이미지 크기는 비례합니다.
즉 이미지 버퍼의 크기는 뷰의 크기 또는 파일의 용량이 아니라!! 이미지 픽셀 크기(우리가 아까 위에서 다룬 4가지 포맷)에 따라 달라집니다.
여기에 담기는 게 뭐냐면 이전에 PNG, JPEG를 로드했잖아요? 해당 이미지를 비트맵 형식으로 디코딩 변환합니다.
그래서 여기서 각 픽셀의 색상 정보와 알파값을 포함한 비트맵 데이터로 변환한 메모리의 표현이에요.
Frame Buffer
이미지 버퍼의 데이터를 최종 화면 좌표에 매핑합니다. 이게 렌더링이에요. GPU가 각 픽셀의 최종 색상과 위치를 결정합니다.
그리고 Frame Buffer란? 실제 앱의 렌더링 결과를 보관하는 버퍼입니다. (GPU에 위치해 있어요)
앱의 뷰 계층이 변경되면 UIKit은 변경된 부분을 포함한 UIWindow와 그 하위들을 기반으로 프레임 버퍼를 갱신하고 렌더링합니다.
이후 프레임 버퍼는 화면에 표시될 최종 픽셀 정보를 제공하며, 디스플레이는 이 프레임 버퍼의 내용을 출력합니다.
여기서 Hz 개념!
고정된 간격으로 컨텐츠를 표시하는데 제가 사용하는 iPhone 14는 1/60초마다 표시하고 프로들은 120Hz로 1/120초마다 표시합니다. 그래서 더 빠르게 동작하기에 "와 부드럽다"라는 걸 체감 많이 할 수 있어요.
그러면 AOD는?배터리 어떡하지?
이때는 1Hz까지 떨어져서 배터리 효율을 좋게 할 수 있습니다. 앱에서 아무것도 변경하지 않았다면 디스플레이는 이전의 프레임 버퍼에서 동일한 데이터를 다시 가져옵니다.
앱에서 만약 컨텐츠를 변경하면 UIKit은 UIWindow를 프레임 버퍼에 재렌더링하고 하드웨어는 프레임 버퍼에서 새로운 정보를 얻어와 표시해요.
Data Buffer
데이터 버퍼는 Bytes의 Sequence를 포함하는 버퍼입니다. 이미지의 크기 같은 메타데이터와 JPEG, PNG 같은 이미지 형식으로 encode된 이미지 데이터가 저장됩니다.
서버에서 이미지를 다운로드하면 이미지를 인코딩한 데이터가 올 건데 그 데이터를 담는 버퍼가 요놈이에요!
데이터 버퍼는 프레임 버퍼에 바로 적용할 수 없습니다
각 픽셀들이 가진 색상과 투명도 정보가 없기 때문에!!! 그래서 위에서 나온 이미지 버퍼로 변경해야 해서 디코드합니다.
전체 이미지 렌더링 과정
1. UIImage는 데이터 버퍼에 저장된 이미지 크기만큼 이미지 버퍼를 할당합니다
2. UIImageView의 contentMode에 맞게 디코딩 작업을 수행합니다
3. UIKit이 UIImageView에 렌더링을 요청하면 이미지 버퍼의 색상 및 투명도 정보를 프레임 버퍼에 복사하고 크기를 조정합니다
그런데 주의해야할것이 위의 과정은 메모리 할당이 지속적으로 발생하고 CPU를 많이 사용한다.
왜?????
- 이미지 버퍼와 프레임 버퍼가 in-memory에 저장되어야 하기 때문에 순간적, 혹은 영구적으로 메모리 사용량이 증가합니다. 이미지 버퍼는 해당 UIImage 인스턴스가 메모리 해제되기 전까지 남아있어요
- 반복 렌더링을 위해 디코딩된 이미지 버퍼를 유지*합니다. (디코딩에 많은 연산이 들어가므로 디코드된 이미지 버퍼를 영구적으로 가집니다)
- 뷰 크기가 아닌 원본 이미지의 픽셀 총량에 비례합니다
그 결과...큰 용량을 메모리 주소에 할당하기 위해 Memory Fragmentation이 일어날 수 있다.
메모리 단편화: 메모리 단편화는 RAM에서 사용 중인 메모리 블록과 비어 있는 메모리 블록이 서로 얽히고 작은 조각으로 나뉘는 현상을 의미. 이는 연속적인 큰 메모리 블록을 확보하기 어려워지는 상황을 초래합니다.. 즉 메모리 공간은 남아있는데 연속적이지 않아서 데이터가 들어갈수가없다..
UIImage는 참조타입이기에 힙영역에 저장되고 힙 영역에서 메모리가 동적으로 할당되는데, 이는 객체가 필요할 때마다 힙에서 메모리를 할당하고, 필요 없으면 해제하는데 해제된 메모리 블록이 다른 블록들 사이에 흩어져 남아 있어, 물리적인 연속적인 메모리 공간이 부족해질 수 있다.
즉 넣고 해제하고 하는데 그게 공간이 점점 흩어지게 남아있으면서 내가 들어갈 공간이 없어질 수 있다.
참조 지역성 저하: UIImage디코딩 후 이미지 버퍼가 메모리의 여러곳에 흩어지면 캐시 효율 떨어진다.
근데 iOS는 페이지 스왑방식이 아니라 Compressing하는 방식으로 Ram을 관리한다. 그래서 System이 Compressing 메모리를 실행하고 이 작업은 CPU를 사용하기에 디바이스의 CPU사용량이 증가한다. 즉 OS가 백그라운드 프로세스부터 순차적으로 종료시키다가.......부정적인 경우에는 내 앱도 종료될 수 있다.
이미지 성능 최적화
1. 적절한 이미지 크기 사용(이미지 리사이징)
필요 이상으로 고해상도 이미지를 사용하지 않으면 됩니다.
기존에는 UIGraphicsBeginImageContextWithOptions을 사용하였지만 4바이트의 픽셀 포맷을 선택하므로 사용하지 말라고 한다.
대신 UIGraphicsImageRenderer -> 자동으로 최적의 이미지 렌더 포맷을 선택해 준다. 이론상 75% 이상의 메모리 절약 효과가 있다고 한다.
let image = UIImage(named: "photo")!
let imageSize: CGSize = imageView.frame.size
let render = UIGraphicsImageRenderer(size: imageSize)
let renderImage = render.image { _ in
image.draw(in: CGRect(origin: .zero, size: imageSize))
}
extension UIImage {
// 불러온 이미지 사이즈 변경 (Compact 버전)
func resized(to size: CGSize) -> UIImage {
let imageSize = size
return UIGraphicsImageRenderer(size: imageSize).image { _ in
draw(in: CGRect(origin: .zero, size: imageSize))
}
}
}
let image = UIImage(named: "photo")!
let imageSize: CGSize = imageView.frame.size
let renderImage = image.resized(to: imageSize)
하지만 이 방법의 경우 draw에서 CPU 비용을 사용하게 됩니다. 디코딩과 렌더링에서 위의 과정을 하기 때문이에요.
그래서 큰 해상도 이미지 사용하거나 다시 그릴 때는 앱 성능에 문제가 발생합니다.
2. 다운샘플링이 나왔다!
프레임 버퍼는 이미지 버퍼를 복사할 때 모든 픽셀을 사용하는 것이 아닙니다.
UIImageView의 사이즈나 스케일 등을 고려하여 필요한 픽셀의 정보만 복사!
즉 표시할 UIImageView 크기가 이미지보다 작으면 메모리 양을 줄이기 위해 다운샘플링을 할 수 있어요.
GPU에서 이미지 데이터를 읽는 디코딩 과정에서 메모리가 사용되는데 만약 필요한 크기만큼 데이터를 미리 축소 후 썸네일로 캡처하여 불필요한 DataBuffer를 제거한 채로 디코딩 작업을 하면 메모리를 절약할 수 있습니다.
디코딩에서의 메모리 비용을 줄이는 방법이에요.
먼저 이미지 소스를 로드하고 썸네일을 만들고 난 후 UIImage에 decoded된 이미지 버퍼를 캡처합니다. 그리고 UIImage를 UIImageView에 할당. 그 후 이미지에 이미 포함되어 있는 databuffer를 제거하여 더 적은 메모리 사용량을 가질 수 있어요.
func downsampleImage(at imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage {
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
] as CFDictionary
let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)!
return UIImage(cgImage: downsampledImage)
}
CGImageSource
CGImageSource는 이미지 파일을 메모리에 로드할 때, 전체 파일을 한 번에 읽어들이지 않고, 필요한 부분만 로드하거나 저해상도로 로드할 수 있어요.
1. CGImageSource를 만든다
- CGImageSourceCreateWithURL: URL로 지정된 위치에서 읽는 이미지 소스를 만듭니다
- kCGImageSourceShouldCache: 이미지를 디코딩된 형식으로 캐싱할 것인지? 우리는 다운샘플링 후 버릴 것입니다. 즉 캐싱하면 안 돼요
2. Thumbnail에 대한 옵션을 만든다
- kCGImageSourceShouldCacheImmediately: 썸네일을 생성할 때 이미지 버퍼를 생성하라고 알려줍니다. 가장 중요!!! Core Graphics에게 썸네일이 생성되었으므로 디코딩된 이미지 버퍼를 생성하라고 알려주는 것이에요
- kCGImageSourceCreateThumbnailFromImageAlways: 원본 파일에 썸네일이 있어도 전체 이미지를 이용해 썸네일을 만들지 결정
- kCGImageSourceCreateThumbnailWithTransform: 이미지 방향 및 종횡비 일치하도록 썸네일 이미지 회전 및 크기 조정할지 여부
- kCGImageSourceThumbnailMaxPixelSize: 썸네일 이미지의 최대 가로, 세로. point가 아닌 픽셀로 해야 합니다. 이 옵션을 지정하지 않으면 썸네일의 크기가 원본 이미지 크기랑 동일해집니다
3. cgImage를 만들 수 있고 이 CGImage를 통해 UIImage 생성
- CGImageSourceCreateThumbnailAtIndex: 지정된 인덱스에 이미지 썸네일을 만들어 CGImage return
3. Prefetching/ Background
Prefetching: 필요한 이미지 미리 디코딩하여 CPU 사용량을 분산시킵니다.
Background: 백그라운드에서 다운샘플링을 진행합니다
예를 들어 컬렉션 뷰의 이미지를 다 받아오는 것을 다운샘플링해서 셀의 이미지뷰에 할당할 수 있어요.
그러나 현재의 방법대로 하면 CPU가 디코딩하는 동안 스크롤이 안 되고, 디코딩 끝나야지만 스크롤됩니다.
그래? 그러면 메인 스레드에서 안 하면 되지? 글로벌 큐에서 백그라운드 스레드 활용하도록 할게~~? 안 돼~~~!
cell의 prefetch가 호출될 때마다 DispatchQueue.global(qos:)로 인해 큐에서 매번 새로운 스레드 찾아 실행 블락을 할당할 텐데 이렇게 되면 잦은 context switching으로 Thread Explosion 위험이 있어요.
그래서 단일 Serial Queue를 이용하여 다운샘플링을 직렬로 수행해 CPU Switching에 더 적은 시간 소비합니다.
4. 이미지 에셋
앱에서 지원할때는 이미지 에셋을 사용하자.
왜 Asset Catalog을 써야 할까요?
- 이름으로 에셋을 찾기 때문에 디스크의 파일을 검색하는 것보다 빠릅니다
- 버퍼 캐싱을 관리합니다
- Xcode가 앱 컴파일 시점에 벡터 아트워크를 디바이스의 스케일 팩터(1x, 2x, 3x)에 맞춰 미리 래스터화합니다. 빌드 타임에 최적화된 비트맵을 생성하는 거예요. 그래서 UIImage 객체가 로드될 때 이미 래스터화된 비트맵 데이터를 사용하므로 효율적이에요
- 이미지가 원래 크기로 표시될 때, Xcode가 미리 래스터화한 아트워크가 사용됩니다. 뭔 소리냐면 벡터 이미지가 100×100 크기로 설계되었을 때 이 크기에 맞춰 미리 변환된 비트맵이 UIImage에 로드됩니다
Asset Catalog 이미지 로딩 과정
- App Bundle의 Asset Catalog에 저장된 이미지 파일을 메모리에 로드하고 데이터 버퍼에 읽어옵니다.UIImage는 기본적으로 메모리 캐시에 이미지를 저장합니다
- ImageIO 프레임워크가 압축된 데이터 버퍼를 비트맵 형식으로 디코딩하여 이미지 버퍼에 저장합니다
- 렌더링: 화면에 맞게 렌더링하고 렌더링된 것을 프레임 버퍼로 저장합니다
추가자료
URL을 통해 이미지를 가져와 띄워줄때는 KingFisher를 잘 활용한다. 그럼 KingFisher는 어떻게 이루어져있나?
/// Creates a downsampled image from given data to a certain size and scale.
///
/// - Parameters:
/// - data: The image data contains a JPEG or PNG image.
/// - pointSize: The target size in point to which the image should be downsampled.
/// - scale: The scale of result image.
/// - Returns: A downsampled `Image` object following the input conditions.
///
/// - Note:
/// Different from image `resize` methods, downsampling will not render the original
/// input image in pixel format. It does downsampling from the image data, so it is much
/// more memory efficient and friendly. Choose to use downsampling as possible as you can.
///
/// The pointsize should be smaller than the size of input image. If it is larger than the
/// original image size, the result image will be the same size of input without downsampling.
public static func downsampledImage(data: Data, to pointSize: CGSize, scale: CGFloat) -> KFCrossPlatformImage? {
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let imageSource = CGImageSourceCreateWithData(data as CFData, imageSourceOptions) else {
return nil
}
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions: [CFString : Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
]
guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions as CFDictionary) else {
return nil
}
return KingfisherWrapper.image(cgImage: downsampledImage, scale: scale, refImage: nil)
}
훔냥...위에서 봤던 공식코드랑 비슷하다. 그래서 우리가 KFImage(url: url)해줄때 frame을 명시해준것 같다. 주석을 보면 resize 메서드와 다르다는 것을 강하게 명시. 이미지 데이터 자체를 pointSize에 맞춰 줄여버리고 있다.
쪼맨 길었다잉... 이후에는 내 볼레또프로젝트에 적용하는것을 적어볼까한다.
자료들의 출처
https://developer.apple.com/design/human-interface-guidelines/images
Images | Apple Developer Documentation
To make sure your artwork looks great on all devices you support, learn how the system displays content and how to deliver art at the appropriate scale factors.
developer.apple.com
'면접준비' 카테고리의 다른 글
Autolayout 모든 것: 사이클부터 제약조건까지 (0) | 2025.03.26 |
---|---|
Apple의 보안 (0) | 2025.03.15 |
근본으로 돌아가자(5) - 프로토콜 (0) | 2025.03.03 |
Dynamic Dispatch는 어떻게 이루어지는가? 클래스 VS 프로토콜 (0) | 2025.03.02 |
NSObjcet 음.. SwiftUI에선? (0) | 2025.02.16 |