- 근본으로 돌아가자(6) Image2025년 03월 05일
- 2료일
- 작성자
- 2025.03.05.:06
이번글은 단순히 UIImage, Image를 말하는 게 아닌 더 딥한 정보들을 정리할 계획이다. 먼저
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
우선 이미지는 두가지 방식으로 구성된다.
래스터 이미지: 픽셀로 이루어져 해상도가 고정되어 있어 확대 축소하면 품질 저하. (JPEG, PNG)
벡터 이미지: 수학적 좌표+기하학적 형태 기반 이어서 계산을 통해 크기를 자유롭게 조정해도 품질이 같다 SVG.
Asset Catalog
보면 1x,2x,3x같이 3개를 넣으라고 나온다. 이게 의미하는 게 뭘까?=> iOS만 보더라도 iphone 8, 14 plus, 16 pro max 등 디스플레이가 각각이 너무 다르다. 이건 해상도가 다르다는 것을 의미한다. 각각의 해상도에 맞는 이미지를 보여줘야 유저가 사용할 때 깨지지 않고 보여줄 수 있다. 원래의 표준해상도에서는 1포인트=1px이다. 요즘 말하는 고해상도는 뭐가 다를까? 한 포인트 안에 px이 더 많다. 즉 고해상도일수록 1포인트에 들어가는 픽셀의 밀도가 더 큰 이미지가 필요한 것이다.
- PPI: 1인치안에 몇개의 픽셀이 존재하는가. ex) 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 같이 픽셀 밀도가 높다.
HIG의 1x, 2x,3x 비교이다. 가로 10 세로 10으로 이루어진 포인트가 있을때 1x는 10*10총 100개 픽셀 2x는 400픽셀, 3x는 900픽셀로 이루어진다.
이제 1x, 2x , 3x에셋을 저장하면 앱에는 이렇게 많이 정보가 들어가 있다. 이미지를 보면 또 저걸 기기(mac, iphone, ipad) 별로 따로따로 저장하는 것을 확인할 수 있다. 앱스토어에서 다운로드할때는 해당 정보들을 다 받는 것이 아니라 다운받는 기기에 맞춰 필요한 것만 App slicing과정을 통해 다운받을 수 있다.
어떤 디바이스에 어떤 에셋이 들어가는지는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
이 링크에 디바이스 모두가 나와있다. 1x 만 등록해 놓으면 뭐 이제 16프로 맥스에서는 디자이너가 아무리 이쁘게 해도 개발자가 다 망쳐놓는 거..
벡터 이미지(수학적 좌표+기하학적 형태 기반 이어서 계산을 통해 크기를 자유롭게 조정해도 품질이 같다.): SVG.
그렇다면 어떻게 구별해서 사용해야 하는데?(PNG, JPEG, HEIF SVG)
PNG: 무손실 압축으로 이미지 품질이 유지된다. + 투명도를 지원한다.
JPEG: 손실 압축 방식을 사용해 파일 크기가 작아진다. 투명도지원 X.
HEIF: JPEG보다 더 효율적인 압축 제공. iOS기기의 기본 포맷, Live Photos를 지원한다
HIG문서이다.
- 일반적인 비트맵 혹은 래스터 작업은 인터레이싱 없는 PNG파일포맷으로 해야 한다.
- 24비트 컬러가 필요하지 않은 PNG그래픽은 8비트 색상 팔레트를 사용하는 PNG포맷이다.
- 사진은 JPEG 또는 HEIC이다. 왜? 풍부한 디테일이 들어있으므로 압축해야 함! 너무 커
- 평면 아이콘, UI아이콘 및 고해상도 스케일링 필요한 아트워크는 벡터이미지인 PDF, SVG이다. 그 이유는 크기 조정이 잦기 때문이다.
이미지 렌더링 프로세스
그렇다면 이 이미지들은 메모리 사용량과 성능에 큰 영향을 주는데 어떻게 적절하게 최적화할 수 있을까?
JPEG, PNG 등의 이미지 파일은 디코딩(GPU에서 읽을 수 있도록)을 통해 이미지 버퍼에 저장된 후 UIImageVIew 또는 SwiftUI에서는 Image뷰를 통해 렌더링(매 초마다 화면에 업데이트되어 보여줌. 60HZ-> 초 60회 번씩 그려주는 거) 된다.
좀 더 자세히 살펴보자. 먼저 로드할때의 UIImage는 UIKit의 데이터 타입이고 렌더링하는 UIImageView는 UIImage를 표시하는 UIKit클래스다. 그 사이에는 디코딩이 있다.
Buffer
버퍼는 단순한 메모리의 연속적인 영역이다. 이미지와 같이 동일한 크기를 가진 Element들의 Sequence로 구성되어 있는 연속적인 영역이다.
ImageBuffer
이미지 버퍼는 이미지의 메모리 표현을 나타내는 버퍼. 위의 그림과 같이 각요소는 단일 픽셀의 색상과 알파값을 나타내기 때문에 버퍼의 크기와 이미지 크기는 비례하다. 즉 이미지 버퍼의 크기는 뷰의 크기 또는 파일의 용량이 아니라!! 이미지 픽셀 크기(우리가 아까 위에서 다룬 4가지 포맷)에 따라 달라진다. 여기에 담기는게 뭐냐면 이전에 PNG, JPEG를 로드했잖아? 해당 이미지를 비트맵 형식으로 디코딩 변환한다. 그래서 여기서 각 픽셀의 색상 정보와 알파값을 포함한 비트맵 데이터로 변환한 메모리의 표현이다.
Frame Buffer
이미지 버퍼의 데이터를 최종 화면 좌표에 매핑한다. 이게 렌더링. GPU가 각 픽셀의 최종 색상과 위치를 결정한다.
그리고 FrameBuffer란 ? 실제 앱의 렌더링 결과를 보관하는 버퍼.(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를 제거하여 더 적은 메모리 사용량을 가질 수 있다.
CGImageSource: CGImageSource는 이미지 파일을 메모리에 로드할 때, 전체 파일을 한 번에 읽어들이지 않고, 필요한 부분만 로드하거나 저해상도로 로드 ㄱㄴ
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) }
1. CGImgageSource를 만든다.
- 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 switchng으로 Thread Explosion 위험
그래서 단일 Serial Queue를 이용하여 다운샘플링을 직렬로 수행해 CPUSwitching에 더 적은 시간 소비.
4. 이미지 에셋
앱에서 지원할때는 이미지 에셋을 사용하자.
- 이름으로 에셋을 찾기 때문에 디스크의 파일을 검색하는것보다 빠르다.
- 버퍼 캐싱을 관리한다.
- XCode가 앱 컴파일 시점에 벡터 아트워크를 디바이스의 스케일 팩터(1x, 2x,3x)에 맞춰 미리 래스터화한다. 빌드 타임에 최적화된 비트맵을 생성하는겨. 그래서 UIImage객체가 로드될 때 이미 래서트화된 비트맵 데이터를 사용하므로 효율적.
- 이미지가 원래크기로 표시될 때, Xcode가 미리 래스터화한 아트워크가 사용된다. 뭔소리냐면 벡터 이미지가 100*100크기로 설계되었을때 이크기에 맞춰 미리 변환된 비트맵이 UIImage에 로드된다.
1. App Bundle의 Asset Catalog에 저장된 이미지 파일을 메모리에 로드하고 데이터 버퍼에 읽어온다. UIImage는 기본적으로 메모리 캐시에 이미지를 저장한다.
2. ImageIO 프레임워크가 압축된 데이터 버퍼를 비트맵 형식으로 디코딩하여 이미지 버퍼에 저장한다.
3. 렌더링: 화면에 맞게 렌더링하고 렌더링되어진것을 프레임 버퍼로 저장.
추가자료
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에 맞춰 줄여버리고 있다.
쪼맨 길었다잉... 이후에는 내 볼레또프로젝트에 적용하는것을 적어볼까한다.
'면접준비' 카테고리의 다른 글
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 다음글이전글이전 글이 없습니다.댓글