본문 바로가기

프로젝트/Film-in

메모리 사용량을 줄이기 위한 노력 - 이미지 다운샘플링

프로젝트의 특성상
이미지를 많이 출력하기 때문에 View가 쌓일 수록

이미지에서 사용되는 메모리의 사용량은 기하급수적으로 증가할 수밖에 없다.

 

구현을 진행하면서

계속 용량 줄려야지, 줄여야지 하면서 계속 미뤄왔던

이미지 다운샘플링 구현 방식에 대해 간단히 진행해보겠다.

 

Apple에서 진행했던 WWDC 18 세션 중 두 세션을 참고했다.

WWDC 18 iOS Memory Deep Dive 🔗

WWDC 18 Image and Graphics Best Practices 🔗

 

1️⃣  이미지 메모리 사용량 계산

메모리의 사용량은 파일 크기가 아닌 이미지의 크기로 계산이 이루어진다.

 

이미지의 메모리 사용량은 아래 공식으로 계산된다.

Memory Usage (Bytes) = Width(pixel) × Height(pixel) × bytes per pixel

 

  • Width, Height: 이미지의 해상도 (픽셀 단위).
  • bytes per pixel: 각 픽셀당 사용되는 바이트 수. 일반적으로 RGBA(32비트) 이미지는
    4 바이트를 사용.

예를 들어, 2048 x 1536 해상도를 갖는 이미지는 아래와 같이 계산된다.

2048 x 1536 x 4 = 12,582,912 bytes (약 12MB)

 

코드로 구현해보면 아래와 같다.

func imageMemoryUseage(url: String) -> Double {
	guard let url = URL(string: url) else {
		print("url error")
		return 0.0
	}
	do {
		let data = try Data(contentsOf: url)
		guard let image = UIImage(data: data) else {
			print("image error")
			return 0.0
		}
		
		let imageSize = image.size
		let totalBytes = imageSize.width * imageSize.height * 4
		let totalMegaBytes = (totalBytes / pow(1024, 2))
		return totalMegaBytes
	} catch {
		print("data error")
		return 0.0
	}
}

 

2️⃣  iOS 이미지 처리 파이프라인

  1. Disk I/O (로드):
    • 이미지는 디스크에서 로드됩니다 (JPEG/PNG 등).
    • Core Graphics 또는 Core Image 프레임워크가 이미지를 디코딩.
  2. Decompression (디코딩):
    • 압축된 이미지 데이터를 디코딩하여 원시 픽셀 데이터로 변환.
    • CPU와 메모리를 크게 소모하는 작업.
  3. Rendering (렌더링):
    • 디코딩된 이미지가 GPU에 의해 화면에 렌더링.
    • 적절한 크기로 스케일링되거나 클리핑 처리.

 

 

 

3️⃣ 효율적인 이미지 다운샘플링

WWDC 18 세션에서 이미지 다운샘플링 방식으로 두 가지를 소개.

  • UIImage를 활용한 방식
  • ImageIO를 활용한 방식

번역해보면

  • UIImage(크기 조정 및 리사이징에 있어 비용이 많이 듭니다.):
    • 원본 이미지를 메모리에 디컴프레싱한다.
    • 내부 좌표 공간 변환은 비용이 많이 든다.
  • ImageIO:
    • 메모리를 더럽히지 않고 이미지 크기와 메타데이터 정보를 읽을 수 있다.
    • 리사이즈된 이미지에 대한 비용만으로 이미지를 리사이즈할 수 있다.

이해하기 쉽도록 다시 번역해보면

 

  • UIImage(크기 조정 및 리사이징에 있어 비용이 많이 듭니다.):
    • 원본 이미지를 사용할 때, 압축된 이미지를 메모리에 풀어야 하기 때문에 처리 과정에서 자원을 많이 사용한다.
    • 이미지의 내부 구조를 변환하는 데도 추가적인 작업이 필요해 시간이 더 걸릴 수 있다.
  • ImageIO:
    • 이미지의 크기나 정보를 확인할 때 메모리를 많이 사용하지 않다.
    • 필요한 크기만큼 이미지를 줄이는 데만 자원을 사용하므로, 더 가볍게 작업할 수 있다.

 

 

4️⃣ 구현 코드

WWDC 영상을 참고한 코드이다.

여기서 내가 중요하게 생각되는 부분은 scale이다.

구현할 때 scale을 얼마나 잡아야 할 지 모르기 때문에

iOS 디바이스의 화면 크기와 맞는 scale 값으로 계산하면 될 것이라고 생각했다.

import ImageIO

func downsampling(data: Data, pointSize: CGSize, scale: CGFloat) -> UIImage? {
    let imageSourceOption = [kCGImageSourceShouldCache: false] as CFDictionary
    guard let imageSource = CGImageSourceCreateWithData(data as CFData, imageSourceOption) else {
        print("\(#function) -> imageSource exit")
        return nil
    }

    let maxDimensionsInPixels = max(pointSize.width, pointSize.height) * scale
    let downsampleOptions = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDimensionsInPixels
    ] as CFDictionary

    guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else {
        print("\(#function) -> downsampledImage exit")
        return nil
    }

    return UIImage(cgImage: downsampledImage)
}

 

iOS 디바이스는 다양한 화면 크기와 그에 대응되는 Scale Factor(1x, 2x, 3x)를 갖고 있다.

 

디바이스에 맞는 Scale Factor를 얻는 방법은

let scale = UITraitCollection.current.displayScale

 

참고
https://developer.apple.com/documentation/uikit/uitraitcollection/displayscale
 

displayScale | Apple Developer Documentation

The display scale of the trait collection.

developer.apple.com

 

5️⃣ Kingfisher를 활용한 이미지 다운샘플링

import SwiftUI
import ImageIO
import Kingfisher

struct PosterImage: View {
    let url: URL?
    let size: CGSize
    let title: String
    let isDownsampling: Bool
    
    var body: some View {
        if isDownsampling {
            KFImage(url)
                .resizable()
                .setProcessor(DownsampleProcessor(pointSize: size))
                .placeholder{
                    Text(verbatim: title)
                        .foregroundStyle(.appText)
                }
                .cacheOriginalImage()
                .cancelOnDisappear(true)
                .fade(duration: 0.25)
                .aspectRatio(contentMode: .fill)
                .frame(width: size.width, height: size.height)
                .clipped()
        } else {
            KFImage(url)
                .resizable()
                .placeholder{
                    Text(verbatim: title)
                        .foregroundStyle(.appText)
                }
                .cacheOriginalImage()
                .cancelOnDisappear(true)
                .fade(duration: 0.25)
                .aspectRatio(contentMode: .fill)
                .frame(width: size.width, height: size.height)
                .clipped()
        }
    }
}

struct DownsampleProcessor: ImageProcessor {
    let identifier: String
    let pointSize: CGSize
    
    init(pointSize: CGSize) {
        self.identifier = "com.example.DownsampleProcessor.\(pointSize)"
        self.pointSize = pointSize
    }

    func process(item: ImageProcessItem, options: KingfisherParsedOptionsInfo) -> KFCrossPlatformImage? {
        switch item {
        case .image(let image):
            guard let data = image.pngData() else { return nil }
            return downsampling(data: data, pointSize: pointSize, scale: UITraitCollection.current.displayScale)
        case .data(let data):
            return downsampling(data: data, pointSize: pointSize, scale: UITraitCollection.current.displayScale)
        }
    }

    private func downsampling(data: Data, pointSize: CGSize, scale: CGFloat) -> KFCrossPlatformImage? {
        let imageSourceOption = [kCGImageSourceShouldCache: false] as CFDictionary
        guard let imageSource = CGImageSourceCreateWithData(data as CFData, imageSourceOption) else {
            print("\(#function) -> imageSource exit")
            return nil
        }
        
        let maxDimensionsInPixels = max(pointSize.width, pointSize.height) * scale
        let downsampleOptions = [
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceShouldCacheImmediately: true,
            kCGImageSourceCreateThumbnailWithTransform: true,
            kCGImageSourceThumbnailMaxPixelSize: maxDimensionsInPixels
        ] as CFDictionary
        
        guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else {
            print("\(#function) -> downsampledImage exit")
            return nil
        }
        
        return UIImage(cgImage: downsampledImage)
    }
}

 

5️⃣ 결론

내 프로젝트에 적용해본 결과

1.13GB 를 사용하던 메모리 사용량이 342.2MB 로 줄었다.

즉, 이미지 다운샘플링을 통해 메모리 사용량 70% 를 줄였다.