[Swift] Image and Memory

o_jooon_·2024년 3월 28일
0

swift

목록 보기
4/8
post-thumbnail

캐시에 이어서 이미의 메모리 최적화 관련을 공부하던 중, WWDC에서 해당 내용을 다룬 영상이 있어 다운샘플링을 구현하기 전에 먼저 시청하고 정리해보았습니다.
이미지 다운샘플링에 관련된 부분만 보려다가 이미지 관련 기초지식을 다루는 부분도 있어서 그 부분도 함께 정리하였습니다!

이를 이용해 구현한건 다음 포스팅에 작성하겠습니다.

과거 포스팅된 다른 블로그들을 보면 WWDC2018에서 iOS Memory Deep DiveImages and Graphics Best Practices 두 영상이 있던데, 지금은 Memory Deep Dive 밖에 없더라구요.. 그래서 저것만 참고하였습니다.

[WWDC2018: iOS Memory Deep Dive, Understand the true memory cost of an image]


WWDC2018 이미지 부분 내용

iOS에서의 이미지

What can be some of the largest object in iOS apps, and that’s images.

영상에서 말하듯이, 이미지는 iOS 앱에서 가장 큰 오브젝트라고 합니다.

The most important thing about images to remember is that the memory use is related to the dimensions of the image, not the file size.

게다가, 가장 중요한 것은 메모리의 사용이 이미지의 파일 용량이 아닌, 이미지의 해상도와 관련되어 있다고 해요. 당연한 말이죠.

이미지 렌더링 과정

590KB의 크기를 가진 이미지 파일이 있습니다.
보시다시피, 이미지의 사이즈는 2048x1536의 크기이죠.
iOS에서 해당 이미지를 불러오는 경우, 얼마만큼의 메모리가 사용될까요??

놀랍게도 590KB 짜리가 10MB 만큼의 메모리를 사용한다고 합니다.
기존 파일의 크기인 590KB보다 약 20배나 많은 메모리를 차지하게 되는 것이죠.
왜 이런일이 일어나는지 친절하게 설명해 주시는데, 이유는 이렇습니다.

현재 이미지는 590KB 크기의 JPEG 파일(압축 되어 있음)입니다.
이미지를 렌더링하는 과정은 다음과 같습니다.

  1. Load
    • 메모리에 데이터화된 이미지가 load됩니다.
  2. Decode
    • 로드된 이미지를 GPU가 읽을 수 있는 형태로 decode합니다.
    • 해당 과정에서 JPEG로 압축된 파일을 압축 해제하며 10MB가 됩니다.
  3. Render
    • decode된 이미지를 렌더링하여 이미지의 형태로 변경합니다.

(이 부분에서 더 많은 정보는 Images and Graphics Best Practices 영상을 참고하라는데, 지금은 영상이 내려가서 자세한 과정이 바뀌었나 생각이 들었습니다..)

그 다음은, 왜 10MB가 되는지와 함께 이미지 렌더링의 사용되는 포맷들을 알려줍니다.

Image-Rendering formats

SRGB format 입니다.
가장 기본적인 포맷으로, 픽셀 당 4바이트를 필요로 합니다.
4바이트는 red, green, blue, alpha 가 각각 1바이트씩 차지하고 있습니다.
익숙하지 않으신가요?? 맞습니다. Xcode에서 색상 관련 코드에서 주로 볼 수 있습니다.
해당 포맷을 기본으로 사용하여 2048 x 1536 x 4 = 10MB 가 되는 것이었습니다.

Wide format 입니다.
SRGB보다 큰 포맷으로, 픽셀 당 8바이트를 필요로 합니다.
8바이트는 red, green, blue, alpha 가 각각 2바이트씩 차지하고 있습니다. SRGB의 2배죠.
2배로 확장된 만큼, 엄청나게 정확한 색상을 표현하며, 넓은 디스플레이에 유용합니다.
iOS에서 해당 포맷으로도 렌더링이 가능하다고 하는데, 사용할 일은 거의 없을 것 같네요.

Luminance and alpha 8 format 입니다.
SRGB보다 작은 포맷으로, 픽셀 당 2바이트를 필요로 합니다.
2바이트는 포맷의 이름과 같이 luminance, alpha 가 각각 1바이트씩 차지하고 있습니다.
luminance는 흑백의 정도를 나타내며, 주로 그림자를 처리하는 데에 사용합니다.
8이 붙은 이유는 8비트를 나타냅니다. (모든 포맷에 들어가 있는 alpha와 비교하고 싶어서 붙인 느낌)
잘 사용하지 않습니다.

마지막으로, Alpha 8 format 입니다.
가장 작은 포맷으로, 픽셀 당 1바이트를 필요로 합니다.
alpha 값 하나로만 이루어져 있습니다.
SRGB보다 75%나 적은 메모리를 사용하며, 모노크롬 텍스트와 마스크에 사용된다고 합니다.
모노크롬 텍스트는 흑백의 텍스트를, 마스크는 투명도 라고 이해하면 될것같습니다.

자 이제, 기본적인 이론은 끝났고, 예시 코드를 통해 더 효율적인 방법을 보여줍니다.

Example Code

How do I pick the right format?
Don’t pick the format. Let the format pick you.

기본적으로 사용되는 SRGB 포맷 말고, 이미지 처리를 할 때, 효율적인 메모리 사용을 위해서 어떤 포맷을 사용해야 하나요?
-> 알아서 해줄거야.
멋지네요.

예전에 사용되던 API인 UIGraphicsBeginImageContextWithOptions를 사용하지 말고,
iOS 10에 공개된 UIGraphicsImageRenderer를 사용하라고 합니다.

UIGraphicsBeginImageContextWithOptions의 경우, 항상 SRGB를 사용한다고 해요.
그렇기 때문에 픽셀 당 4바이트를 사용하고, Alpha 8과 Wide처럼 더 작거나 큰 포맷을 사용할 수가 없습니다.
반대로, UIGraphicsImageRenderer는 최적의 포맷을 알아서 적용시켜준다고 합니다. (iOS 12부터)

공개된 코드를 보겠습니다.

UIGraphicsImageContext

// Circle via UIGraphicsImageContext
let bounds = CGRect(x: 0, y: 0, width:300, height: 100)
UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0)

// Drawing Code
UIColor.black.setFill()
let path = UIBezierPath(roundedRect: bounds,
				  byRoundingCorners: UIRectCorner.allCorners,
                        cornerRadii: CGSize(width: 20, height: 20))
path.addClip()
UIRectFill(bounds)

let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

구식 방법인 UIGraphicsImageContext를 통해 검은색 원을 그리는 코드입니다.

UIgraphicsImageRenderer

// Circle via UIGraphicsImageRenderer
let bounds = CGRect(x: 0, y: 0, width:300, height: 100)
let renderer = UIGraphicsImageRenderer(size: bounds.size)
let image = renderer.image { context in
	// Drawing Code
	UIColor.black.setFill()
	let path = UIBezierPath(roundedRect: bounds,
					  byRoundingCorners: UIRectCorner.allCorners,
						    cornerRadii: CGSize(width: 20, height: 20))
    path.addClip()
	UIRectFill(bounds)
}

동일한 방법으로 UIGraphicsImageRenderer를 통해 검은색 원을 그리는 코드입니다.

위의 방법은 SRGB 포맷을, 아래의 방법은 Alpha 8 포맷을 사용하여 아래의 코드가 75% 적게 메모리를 사용한다고 합니다. 동일한 품질임에도 말이죠. 이게 끝이 아닙니다.

// Make circle render blue, but stay at 1 byte-per-pixel image
let imageView = UIImageView(image: image)
imageView.tintColor = .blue

해당 코드를 통해 위에서 얻은 이미지의 tintColor를 파란색으로 바꾸어도 여전히 픽셀 당 1바이트만 사용하여 메모리는 훨씬 적게 사용하면서 색상을 입힐 수 있다고 합니다.
이유는 다음과 같습니다.
-> 렌더링할 때 적용되는 색상이 변경될 뿐, 이미지의 실제 데이터를 건드리는 것이 아니기 때문에 메모리를 추가로 사용하지 않음.

Downsampling

UIImage는 이미지를 리사이징 하는 과정에서 많은 비용을 소모한다고 합니다.
위에서 사용된 UIGraphicsImageRenderer는 UIImage를 통해 리사이징을 합니다.
그렇기 때문에, 직접 도형을 그리는 과정에선 구식 API보다 효율적이지만, 이미지의 크기를 바꾸는 과정에서는 비효율적이라고 합니다.

그 이유를 다음과 같이 설명합니다.

  • 이미지의 전체 정보를 압축 해제하여 메모리에 로드한다.
    -> 해당 포스트 가장 위에서 설명한 것과 같습니다.
  • UIImage 객체는 내부적으로 이미지의 크기, 위치 및 다른 속성들을 관리하기 위해 내부의 좌표 공간을 사용한다.
    -> 이미지를 회전, 크기 조정 또는 반전 등의 작업을 위해서는 이 좌표 공간들을 변환하는데에 비용이 많이 든다.

애플에서는 UIImage 수준에서 이미지를 다루는 것보다 효율적인 방법을 소개하였습니다.

Image I/O

이미지 리사이징(다운샘플링)을 위한 가장 효율적인 방법으로 Image I/O가 있습니다.
Image I/O 에 대한 document의 설명은 다음과 같습니다.

The Image I/O framework allows applications to read and write most image file formats.
This framework offers high efficiency, color management, and access to image metadata.

ImageIO는 Foundation, UIKit, SwiftUI와 같은 프레임 워크입니다.
해당 프레임워크를 통해 대부분의 이미지 파일 형식을 읽고 쓸 수 있고,
효율적이면서 색상 관리나 메타데이터 접근을 제공합니다.

ImageIO는 이미지를 효율적으로 다운샘플링 할 수 있으며, 해당 프레임워크의 Streaming API를 이용하여 다운샘플링에 필요한 사이즈와 메타데이터를 제외한 더티 메모리들은 사용하지 않습니다.

ImageIO는 UIImage가 아닌 CGImage로 작업을 하는데요, CGImage는 UIImage와 다르게 크기와 스케일 등의 정보를 제외한 이미지의 데이터만 가지고 있습니다.

CGImage는 UIImage보다 저수준의 방식으로 처리를 하는거겠죠?? 저수준으로 갈수록 까다로워지지만, 그만큼 정교하게 컨트롤할 수 있어 사용하는 것 같습니다.

다운샘플링을 공부하기 전 이것저것 알아봤는데, 드디어 다운샘플링 관련 코드입니다! 이 부분을 잘 공부하도록 하죠.

예시 코드를 보겠습니다.

Example code

UIGraphicsImageRenderer

// Image size with UIImage
import UIKit

// Getting image size
let filePath =/path/to/image.jpg”
let image = UIImage(contentsOfFile: filePath)
let imageSize = image.size

// Resizing image
let scale = 0.2
let size = CGSize(image.size.width * scale, image.size.height * scale)
let renderer = UIGraphicsImageRenderer(size: size)
let resizedImage = renderer.image { context in
	image.draw(in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
}

UIGraphicsImageRenderer를 통해 원하는 사이즈의 값으로 다운샘플링을 진행하는 코드입니다.
이미지를 0.2배로 작게 변경하고 있네요.

위에서 도형을 그리는 것과는 다르게, .draw() 라는게 추가되었죠?
size에 해당하는 크기로 이미지를 다시 렌더링 하는 작업입니다.

하지만, 해당 작업에서 위에서 설명한 것과 같이 비용이 많이 들기 때문에 ImageIO를 사용합니다.
-> 비용이 많이 든다 == CPU 및 메모리의 사용량이 높다.

ImageIO

// Image size with ImageIO
import ImageIO

let filePath =/path/to/image.jpg”
let url = NSURL(fileURLWithPath: path)

let imageSource = CGImageSourceCreateWithURL(url, nil)
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil)
let options: [NSString: Any] = [
	kCGImageSourceThumbnailMaxPixelSize: 100,
	kCGImageSourceCreateThumbnailFromImageAlways: true
]

let scaledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options)

ImageIO를 통해 원하는 사이즈의 값으로 다운샘플링을 진행하는 코드입니다.
MaxPixelSize가 100이라고 나와있는데, width와 height 중 더 큰 값을 100으로 맞추면서 비율을 조절해준다고 해요. 원하는 값을 주면 해당하는 크기에 맞게 다운샘플링이 되겠죠?
뭐가 뭔지 하나도 모르겠으니 document를 뒤져봅시다.

NSURL

저수준의 작업을 진행하기 때문에, URL보다 낮은 수준의 NSURL을 통해 경로를 설정해줍니다.
NSURL은 Objective-C와 호환성이 좋다고 합니다.

CGImageSourceCreateWithURL()
func CGImageSourceCreateWithURL(
    _ url: CFURL,
    _ options: CFDictionary?
) -> CGImageSource?
  • url: 이미지의 url입니다.
    -> URL보다 저수준의 CFURL(CF는 Foundation의 저수준 버전인 Core Foundation의 약자입니다.)객체를 통해 경로를 받는 것 같습니다.
  • options: 반환 타입에 옵션을 부여할 Dictionary입니다.
    -> CF가 붙었으니 위와 같습니다. 굳이 특별한 일이 필요하지 않으면 nil로 하는 것 같습니다.

CG, CF 등의 접두사가 붙어서 어려워 보일 뿐. 그냥 url을 통해 이미지의 소스를 받아오는 기능을 합니다.
이미지 소스가 뭔데?

CGImageSource: An opaque type that you use to read image data from a URL, data object, or data consumer.

그냥 이미지의 데이터라고 생각하면 될 것 같네요. 설명에 있다시피 URL 뿐만이 아니라 Data타입을 통해서도 이미지 소스를 생성할 수 있습니다.

CGImageSourceCopyPropertiesAtIndex()
func CGImageSourceCopyPropertiesAtIndex(
    _ isrc: CGImageSource,
    _ index: Int,
    _ options: CFDictionary?
) -> CFDictionary?
  • isrc: 이미지 소스입니다.
    -> 위에서와 같이 URL, Data 등으로부터 생성된 이미지 소스를 사용합니다.
  • index: 이미지 소스의 인덱스입니다.
    -> 보통 하나의 이미지 소스를 사용하기 때문에 0으로 놓습니다.
    -> 인덱스가 유효하지 않으면 NULL(document엔 NULL이라 나와있지만 nil)을 반환합니다.
  • options: 위와 마찬가지로, 반환 타입에 옵션을 부여할 Dictionary입니다.

이미지와 관련된 속성을 반환합니다.
예시 코드에선 사용되지 않는데도 적혀 있길래 그냥 가져왔습니다.

kCGImageSource~~
kCGImageSourceThumbnailMaxPixelSize: CFNumber
kCGImageSourceCreateThumbnailFromImageAlways: CFBoolean

이미지 소스에 사용할 수 있는 다양한 옵션들입니다.
Key-Value의 Dictionary 타입이지만, 저수준이라 정확히는 CFDictionary 타입입니다.

  • kCGImageSourceThumbnailMaxPixelSize: 이미지 소스를 통해 생성할 썸네일의 최대 픽셀입니다.
    -> 해당 값을 지정해주지 않으면, 생성될 이미지가 원본 이미지와 같아지므로 줄어들 크기를 value로 줍니다.
    -> A x B의 비율이 있다면, 더 큰 쪽의 값으로 MaxPixelSize를 맞춘 후 알아서 비율을 맞춰줍니다.
  • kCGImageSourceCreateThumbnailFromImageAlways: 이미지 소스를 통한 썸네일의 크기 조정 여부 입니다.
    -> 기본값은 false이며, MaxPixelSize에서 크기를 설정해 주었다면 True로 해야합니다.
    -> false로 두면 위와 동일하게 원본 이미지와 크기가 같아지므로 다운샘플링 시 설정해주어야 합니다.

이 외에도 다양한 옵션들을 줄 수 있습니다. 특정한 Key-Value를 통해 날짜와 시간, 메타데이터 등을 추가해줄 수 있습니다.

CGImageSourceCreateImageAtIndex()
func CGImageSourceCreateImageAtIndex(
    _ isrc: CGImageSource,
    _ index: Int,
    _ options: CFDictionary?
) -> CGImage?

매개변수들 자체는 위에서 설명한 CGImageSourceCopyPropertiesAtIndex()과 같습니다.
kCGImageSource 어쩌구들을 통해 크기를 바꿔줄 옵션을 추가했으니, options에 해당 옵션을 추가해줍니다.

최종적으로 URL, Data 등을 통해 얻어온 이미지 소스에 특정 옵션을 추가하여 CGImage? 형태로 반환합니다.
이제 해당 메서드를 통해 생성된 CGImage를 UIImage로 바꾸어 UI에 표시해주는 것입니다.

ImageIO를 사용하여 다운샘플링을 한 코드가 UIGraphicsImageRenderer를 사용한 코드보다 50%나 빠르다고 합니다.
당연히 CPU와 메모리의 사용량도 많이 줄어들었겠죠??

다음 포스팅에서 직접 구현해보고 시간과 CPU, 메모리 모두의 차이를 비교해보도록 하겠습니다.

profile
iOS개발 공부 중입니다.

0개의 댓글