swiftswiftuirenderlottie

Displaying multiple Lottie animations on screen cause memory spike


I am facing challenge in displaying multiple lottie animations on screen without causing memory spikes and performance issue.

Basically, I load and display multiple Lotties (json) in a grid as stickers:

 import SwiftUI
 import Lottie

struct StickersPackCollectionView: View
{
   private let columns = Array(repeating: GridItem(.flexible(), spacing: 1), count: 5)

   var body: some View
   {
    ScrollView
      {
        LazyVGrid(columns: columns, spacing: 1)
        {
            stickersCollectionView()
        }
        .padding()
    }
    .background(Color(ColorManager.navigationBarBackgroundColor))
  }
}

  extension StickersPackCollectionView
  {
     private func stickersCollectionView() -> some View
     {
       ForEach((1..<100)) { index in
           LottieView(animation: .named("vault_boy_test")) /// json from bundle
            .playbackMode(.playing(.toProgress(1, loopMode: .loop)))
            .resizable()
            .frame(width: 75, height: 75)
      }
    }
  }

simulator_Output

enter image description here

The json animation is 512x512 resolution. I have tried to cache images data and load it from memory cache and also dispatch load process to background thread, but it did not helped. Even without animation play it spikes the memory. It seems that the issue is in the rendering process of multiple images at the same time. Is there any way to optimize this? Is it even appropriate in case of multiple stickers to display them as lottie or should I switch to gif/webp formats ?


Solution

  • Okay, I was able to solve the issue. You can import the librlottie-Xcode and use it for creation and rendering of json lottie animations. Here is full code (UIKit), just change :

    private let animations = Stickers.Category.allCases
            .flatMap { $0.pack.map { $0.deletingPathExtension().lastPathComponent } }
    

    to your json file(s) paths.

    import UIKit
    import librlottie
    
    extension OpaquePointer: @unchecked @retroactive Sendable {}
    
    // MARK: - Animation Manager
    actor LottieAnimationManager
    {
        static let shared = LottieAnimationManager()
    
        private var cachedAnimations: [String: OpaquePointer] = [:]
    
        private init() {}
    
        // Async get with lazy loading
        func getAnimation(named name: String) async -> OpaquePointer?
        {
            if let cached = cachedAnimations[name] {
                return cached
            }
    
            // Do file loading off the actor context to avoid blocking
            return await withCheckedContinuation { continuation in
                DispatchQueue.global(qos: .userInitiated).async {
                    var anim: OpaquePointer? = nil
                    if let path = Bundle.main.path(forResource: name, ofType: "json") {
                        anim = lottie_animation_from_file(path)
                    }
    
                    Task { @MainActor in
                        continuation.resume(returning: anim)
                    }
                }
            }
        }
    
        // Store animation
        func cacheAnimation(_ anim: OpaquePointer, named name: String) {
            cachedAnimations[name] = anim
        }
    
        // Cleanup everything
        func cleanup() {
            for (_, anim) in cachedAnimations {
                lottie_animation_destroy(anim)
            }
            cachedAnimations.removeAll()
        }
    
        deinit { cleanup() }
    }
    
    
    // MARK: - ViewController
    class ViewController: UIViewController,
                          UICollectionViewDataSource,
                          UICollectionViewDelegateFlowLayout
    {
        private var collectionView: UICollectionView!
        private let animations = Stickers.Category.allCases
            .flatMap { $0.pack.map { $0.deletingPathExtension().lastPathComponent } }
        private var displayLink: CADisplayLink?
    
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .white
            setupCollectionView()
            startAnimationLoop()
        }
    
        override func viewWillDisappear(_ animated: Bool) {
            super.viewWillDisappear(animated)
            stopAnimationLoop()
        }
    
        private func setupCollectionView() {
            let spacing: CGFloat = 10
            let layout = UICollectionViewFlowLayout()
            let itemWidth = (view.bounds.width - spacing * 5) / 4
            layout.itemSize = CGSize(width: itemWidth, height: itemWidth)
            layout.minimumLineSpacing = spacing
            layout.minimumInteritemSpacing = spacing
            layout.sectionInset = UIEdgeInsets(top: spacing, left: spacing, bottom: spacing, right: spacing)
    
            collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
            collectionView.backgroundColor = .white
            collectionView.dataSource = self
            collectionView.delegate = self
            collectionView.register(LottieCell.self, forCellWithReuseIdentifier: LottieCell.identifier)
    
            view.addSubview(collectionView)
    
            collectionView.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
                collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
                collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
                collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
            ])
        }
    
        // MARK: - Animation Loop
        private func startAnimationLoop() {
            displayLink = CADisplayLink(target: self, selector: #selector(renderFrame))
            displayLink?.add(to: .main, forMode: .common)
        }
    
        private func stopAnimationLoop() {
            displayLink?.invalidate()
            displayLink = nil
        }
    
        @objc private func renderFrame() {
            let visibleIndexPaths = collectionView.indexPathsForVisibleItems
            for indexPath in visibleIndexPaths {
                if let cell = collectionView.cellForItem(at: indexPath) as? LottieCell {
                    cell.lottieView.renderNextFrame()
                }
            }
        }
    
        // MARK: - UICollectionViewDataSource
        func collectionView(_ collectionView: UICollectionView,
                            numberOfItemsInSection section: Int) -> Int {
            return animations.count
        }
    
        func collectionView(_ collectionView: UICollectionView,
                            cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let cell = collectionView.dequeueReusableCell(
                withReuseIdentifier: LottieCell.identifier,
                for: indexPath
            ) as! LottieCell
            cell.configure(withAnimationNamed: animations[indexPath.item])
            return cell
        }
    
        // MARK: - Visibility Management
        func collectionView(_ collectionView: UICollectionView,
                            willDisplay cell: UICollectionViewCell,
                            forItemAt indexPath: IndexPath) {
            (cell as? LottieCell)?.lottieView.setVisible(true)
        }
    
        func collectionView(_ collectionView: UICollectionView,
                            didEndDisplaying cell: UICollectionViewCell,
                            forItemAt indexPath: IndexPath) {
            (cell as? LottieCell)?.lottieView.setVisible(false)
        }
    
        deinit { stopAnimationLoop() }
    }
    
    // MARK: - LottieCell
    class LottieCell: UICollectionViewCell {
        static let identifier = "LottieCell"
        let lottieView = RLLottieView()
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            contentView.addSubview(lottieView)
            lottieView.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                lottieView.topAnchor.constraint(equalTo: contentView.topAnchor),
                lottieView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
                lottieView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
                lottieView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
            ])
        }
    
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    
        func configure(withAnimationNamed name: String) {
            lottieView.loadAnimation(named: name)
        }
    
        override func prepareForReuse() {
            super.prepareForReuse()
            lottieView.reset()
        }
    }
    
    // MARK: - RLLottieView
    class RLLottieView: UIView
    {
        private var animationName: String?
        private var animation: OpaquePointer?
        private var frameNumber: Int = 0
        private var totalFrames: Int = 0
        private let renderSize = CGSize(width: 200, height: 200)
        private var buffer: UnsafeMutablePointer<UInt32>?
        private var isVisible = false
        private var renderInProgress = false
        private var startTime: CFTimeInterval = 0
        private var randomOffset: TimeInterval = 0
    
        // Cached graphics objects
        private let cachedColorSpace: CGColorSpace
        private let cachedBitmapInfo: CGBitmapInfo
    
        private let renderQueue = DispatchQueue(label: "lottie.render.queue", qos: .userInitiated)
    
        override init(frame: CGRect) {
            cachedColorSpace = CGColorSpaceCreateDeviceRGB()
            cachedBitmapInfo = CGBitmapInfo(rawValue:
                CGBitmapInfo.byteOrder32Little.rawValue |
                CGImageAlphaInfo.premultipliedFirst.rawValue)
            super.init(frame: frame)
    
            let pixelCount = Int(renderSize.width * renderSize.height)
            buffer = UnsafeMutablePointer<UInt32>.allocate(capacity: pixelCount)
            buffer?.initialize(repeating: 0, count: pixelCount)
        }
    
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    
        // MARK: - Load Animation
        func loadAnimation(named name: String) {
            animationName = name
            animation = nil
            layer.contents = nil
    
            Task {
                if let anim = await LottieAnimationManager.shared.getAnimation(named: name) {
                    // Double-check cell is still expecting this animation
                    guard self.animationName == name else { return }
    
                    await LottieAnimationManager.shared.cacheAnimation(anim, named: name)
    
                    self.animation = anim
                    self.totalFrames = Int(lottie_animation_get_totalframe(anim))
                    self.frameNumber = 0
                    self.startTime = CACurrentMediaTime()
                    self.randomOffset = TimeInterval.random(in: 0..<2.0)
    
                    self.renderFirstFrame()
                }
            }
        }
    
        private func renderFirstFrame()
        {
            guard let animation = animation, let buffer = buffer else { return }
            renderQueue.async { [weak self] in
                guard let self = self, let animation = self.animation, let buffer = self.buffer else { return }
    
                lottie_animation_render(animation,
                                        0,
                                        buffer,
                                        size_t(self.renderSize.width),
                                        size_t(self.renderSize.height),
                                        size_t(Int(self.renderSize.width) * MemoryLayout<UInt32>.size))
    
                self.createAndDisplayImage(from: buffer)
                DispatchQueue.main.async { self.renderInProgress = false }
            }
    
        }
    
        func setVisible(_ visible: Bool) {
            isVisible = visible
        }
    
        func renderNextFrame() {
            guard isVisible,
                  let animation = animation,
                  let buffer = buffer,
                  !renderInProgress,
                  totalFrames > 0 else { return }
    
            renderInProgress = true
    
            let elapsed = CACurrentMediaTime() - startTime + randomOffset
            let duration = Double(totalFrames) / Double(lottie_animation_get_framerate(animation))
            let progress = fmod(elapsed, duration) / duration
            let currentFrame = Int(progress * Double(totalFrames))
    
            renderQueue.async { [weak self] in
                guard let self = self, let animation = self.animation, let buffer = self.buffer else { return }
    
                lottie_animation_render(animation,
                                        size_t(currentFrame),
                                        buffer,
                                        size_t(self.renderSize.width),
                                        size_t(self.renderSize.height),
                                        size_t(Int(self.renderSize.width) * MemoryLayout<UInt32>.size))
    
                self.createAndDisplayImage(from: buffer)
                DispatchQueue.main.async { self.renderInProgress = false }
            }
    
        }
    
        private func createAndDisplayImage(from cgBuffer: UnsafeMutableRawPointer)
        {
            guard let context = CGContext(data: cgBuffer,
                                          width: Int(renderSize.width),
                                          height: Int(renderSize.height),
                                          bitsPerComponent: 8,
                                          bytesPerRow: Int(renderSize.width) * 4,
                                          space: cachedColorSpace,
                                          bitmapInfo: cachedBitmapInfo.rawValue),
                  let cgImage = context.makeImage() else { return }
    
            DispatchQueue.main.async { [weak self] in
                self?.layer.contents = cgImage
            }
        }
    
        func reset() {
            isVisible = false
            renderInProgress = false
            frameNumber = 0
            animationName = nil
            animation = nil
            layer.contents = nil
        }
    
    
        deinit {
            buffer?.deallocate()
        }
    }