이미지는 앱에서 핵심적은 요소 중 하나입니다. 기본적으로 이미지는 정적이지만, 애니매이션 기능을 가진 이미지 역시 존재합니다. 이번 포스트에서는 iOS에서 이를 어떻게 다루는지, 서드파티에서는 이를 어떻게 취급하는지 알아보도록 하겠습니다.

  • 애니매이션의 원리
    위에서 말했다시피 이미지는 움직임이 없는 정적인 요소입니다. 이 때, 연관성이 있는 이미지들이 여러개 있을 때 이를 빠르게 전환하면 마치 움직이는 것처럼 보이게 착시효과를 줄 수 있습니다. 이러한 기법 자체를 애니매이션이라고 합니다. 즉, 애니매이션을 위해서는 여러장의 이미지가 필요한 것입니다. 일반적으로 동영상을 생각하기 쉽지만, 이미지 중에서도 애니매이션을 지원하는 경우가 있는데, 대표적인 포맷으로 gif, apng가 있습니다.

  • iOS에서의 이미지
    일반적으로 이미지를 다룰 때는 UIImage 객체를 사용합니다. 기본적으로 정적인 이미지를 표현하기 위한 객체지만, 애니매이션 이미지를 표현할 수 있는 기능 또한 가지고 있습니다. 내부 구조를 직접 알 수는 없지만 다음 생성자와 프로퍼티를 보면 대략적인 구조나 내부 동작을 유추해 볼 수 있습니다.

      // 실제 extension에 정의되었다는 뜻은 아니고, UIImage에 속한 것임을 나타내기 위함입니다.
    extension UIImage {
      class func animatedImage(with images: [UIImage], 
                      duration: TimeInterval) -> UIImage?
    
      // 애니매이션을 이루는 이미지 전체를 가져옵니다.
      // 애니매이션 이미지가 아닌 경우는 nil을 반환합니다.
      var images: [UIImage]?
    }
    

    즉, UIImage는 애니매이션을 표현하기 위한 재귀적인 구조를 가집니다.

    이미지는 기본적으로 용량을 줄이기 위해서 압축이 되어 있고, 이를 화면에 그리기 위해서는 압축을 풀어야 합니다. 이 과정을 디코딩이라고 합니다. 디코딩 과정은 오래 걸리기 때문에 필요할 때만 이루어지고, 한번 디코딩된 결과는 캐싱이 되어야 합니다. 따라서 앱에서 자주 쓰는 리소스들은 에셋 카탈로그에 넣는 것이 좋습니다. 에셋 카탈로그를 쓰면 디코딩된 이미지를 캐싱하고 메모리가 모자랄 때 캐싱된 이미지를 메모리에서 제거하는 과정을 시스템이 자동으로 해줍니다. 이렇게 디코딩 된 데이터는 UIImage 내부 버퍼에 채워지며, 각 픽셀의 색데이터를 표현하게 됩니다. 따라서 디코딩된 UIImage 객체가 차지하는 크기는 이미지의 가로세로 크기에 영향을 받습니다. 이 UIImage 버퍼의 데이터를 기기의 프레임 버퍼에 쓰면, 디스플레이는 이를 받아서 화면을 갱신하게 됩니다.

  • 임의의 애니매이션 이미지를 다루기 위한 방법
    에셋 카탈로그를 쓰면 자동적으로 캐싱을 해주지만, 네트워크를 통해서 가져온 이미지나 파일에서 읽어온 데이터는 객체를 공유하지 않는 이상은 이 혜택을 누릴 수 없습니다. 따라서 이런 경우에는 수동으로 이미지를 디코딩하고, 필요하다면 캐싱하는 과정을 구현해야 합니다. 이 과정은 복잡하기 때문에 일반적으로 라이브러리를 가져다 쓰는데, 핵심적인 원리는 유사합니다. 이 과정을 코드와 함께 간략하게 알아보겠습니다.

    1. CGImageSource를 만듭니다. CGImageSource는 URL 혹은 데이터에서 이미지 데이터를 읽어오는 과정을 추상화한 객체입니다. 수동으로 캐싱한다면, CGImageSource자체의 캐싱 옵션을 꺼줍니다.
       let data = // 이미지 데이터 
       guard let imageSource = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache as CFString : false as NSNumber] as CFDictionary) else {
           return
       }
      
    2. 해당 이미지가 애니매이션 이미지를 지원하는 타입인지 확인합니다. 이 과정에서 UTI(Uniform Type Identifier)를 사용합니다.
       guard let imageSourceContainerType = CGImageSourceGetType(imageSource),
           UTTypeConformsTo(imageSourceContainerType, kUTTypeGIF) else {
               return
       }
      
       // iOS 14 이후에는 UniformTypeIdentifier 라이브러리를 사용할 수 있습니다. 
       guard let imageSourceContainerType = CGImageSourceGetType(imageSource)
           UTType(imageSourceContainerType as String) == .gif else {
               return
       }
      
    3. 이미지 소스의 이미지 갯수를 확인합니다. 0개면 잘못된 이미지 소스고, 1개면 다른 이미지와 동일하게 다루면 됩니다.
       let imageCount = CGImageSourceGetCount(imageSource)
      
    4. 이미지를 꺼내옵니다. 추가 옵션이 필요하면 적용할 수 있고, 없으면 nil을 줍니다.
       guard let cgImage = CGImageSourceCreateImageAtIndex(imageSource, i, nil) else {
           return
       }
      
       let image = UIImage(cgImage: cgImage)
      
       // 수동으로 캐싱한다면, 여기서 이미지를 캐싱한다.
      
    5. 메타데이터가 필요하면 추가로 다룹니다.

    이렇게 만들어진 데이터는 imageView의 이미지를 CADisplayLink를 통해서 화면 갱신주기마다 image 프로퍼티에 바꿔 끼우는 방식으로 이미지를 갱신하게 됩니다. 메모리가 부족할 때는 시스템의 노티피케이션을 받아서 이를 핸들링 하는 방식으로 이루어집니다. 전체 구현은 여기서 확인해볼 수 있습니다.


이미지에 대해서는 더 많은 이야기가 있지만, 이번 포스트에서는 많이 쓰는 라이브러리에 대한 기본적인 컨셉에 대해서 살펴보았습니다.