You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

CardStack搭配Lottie动画内存占用过高问题排查方向咨询

卡片堆叠Lottie动画内存累积问题修复方案

问题背景

用CardStack库实现卡片堆叠组件,Lottie负责卡片动画效果。当前CardStack配置为仅显示当前和下一张共2张卡片,但内存仅在视图状态变更时才会释放,导致内存持续累积直到最后一张卡片,100张卡片会引发严重内存问题。已确认无内存泄漏,试过各类Lottie加速方案无效,且该功能在其他操作系统运行正常,问题不在动画本身。

原实现代码

struct CustomLottieView: UIViewRepresentable {
    enum LoadingType {
        case url(URL)
        case data(DataResource)
    }
    
    let type: LoadingType
    
    let isPaused: Bool
    
    let card: CardModel?
    
    let animationView = LottieAnimationView()
    
    let transition: BoundCardType?
    
    init(type: LoadingType, isPaused: Bool = false, card: CardModel? = nil, transition: BoundCardType? = nil) {
        self.isPaused = isPaused
        self.type = type
        self.card = card
        self.transition = transition
    }
    
    func makeUIView(context: Context) -> UIView {
        let view = UIView(frame: .zero)
        
        switch type {
        case .url(let url):
            LottieAnimation.loadedFrom(url: url) { animation in
                animationView.animation = animation
                isPaused ? nil : animationView.play()
                animationView.loopMode = .loop
                
                if let skillCardModel = card as? SkillCardModel {
                    animationView.play(marker: String(skillCardModel.currentLevelIndex + 1))
                }
            }
        case .data(let data):
            animationView.animation = .named(data.name, bundle: data.bundle)
            
            if let skillCardModel = card as? SkillCardModel {
                animationView.play(marker: String(skillCardModel.currentLevelIndex + 1))
            }
        }
        
        animationView.contentMode = .scaleAspectFit
        
        animationView.play()
 
        view.addSubview(animationView)
 
        animationView.translatesAutoresizingMaskIntoConstraints = false
        animationView.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true
        animationView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
 
        return view
    }
 
    func updateUIView(_ uiView: UIView, context: Context) {
        if isPaused {
            context.coordinator.parent.animationView.pause()
        } else {
            if let skillCardModel = card as? SkillCardModel {
                if !context.coordinator.parent.animationView.isAnimationPlaying || animationView.animation == nil {
                    context.coordinator.parent.animationView.play(marker: String(skillCardModel.currentLevelIndex + 1))
                } else {
                    withAnimation {
                        var transitionMarker = ""
                        if let transition = transition {
                            switch transition {
                            case .left:
                                transitionMarker = context.coordinator.parent.animationView.animation?.markerNames.first(where: { $0.contains(">\(skillCardModel.currentLevelIndex + 2)") }) ?? ""
                                context.coordinator.parent.animationView.play(fromMarker: String(skillCardModel.currentLevelIndex + 2), toMarker: transitionMarker, loopMode: .playOnce)
                            case .right:
                                transitionMarker = context.coordinator.parent.animationView.animation?.markerNames.first(where: { $0.contains("\(skillCardModel.currentLevelIndex)>") }) ?? ""
                                context.coordinator.parent.animationView.play(fromMarker: transitionMarker, toMarker: String(skillCardModel.currentLevelIndex + 1), loopMode: .playOnce)
                            }
                        }
                        
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                            context.coordinator.parent.animationView.play(marker: String(skillCardModel.currentLevelIndex + 1), loopMode: .loop)
                        }
                    }
                }
            } else {
                context.coordinator.parent.animationView.play()
            }
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }
    
    struct Coordinator {
        var parent: CustomLottieView
    }
}

问题根源

  1. 动画视图生命周期管理错误:animationView是CustomLottieView的存储属性,SwiftUI会保留该实例,导致已滑出屏幕的卡片对应的Lottie视图无法被正确释放,内存持续占用。
  2. 强引用导致资源无法回收:updateUIView中通过context.coordinator.parent.animationView访问动画视图,形成强引用链,阻碍旧视图的内存回收。

修复后的代码

struct CustomLottieView: UIViewRepresentable {
    enum LoadingType {
        case url(URL)
        case data(DataResource)
    }
    
    let type: LoadingType
    let isPaused: Bool
    let card: CardModel?
    let transition: BoundCardType?
    
    init(type: LoadingType, isPaused: Bool = false, card: CardModel? = nil, transition: BoundCardType? = nil) {
        self.isPaused = isPaused
        self.type = type
        self.card = card
        self.transition = transition
    }
    
    func makeUIView(context: Context) -> UIView {
        let view = UIView(frame: .zero)
        let animationView = LottieAnimationView()
        animationView.tag = 1001 // 标记动画视图,方便后续获取
        animationView.contentMode = .scaleAspectFit
        
        switch type {
        case .url(let url):
            LottieAnimation.loadedFrom(url: url) { animation in
                animationView.animation = animation
                animationView.loopMode = .loop
                if !self.isPaused {
                    self.playTargetAnimation(animationView: animationView)
                }
            }
        case .data(let data):
            animationView.animation = .named(data.name, bundle: data.bundle)
            animationView.loopMode = .loop
            if !self.isPaused {
                self.playTargetAnimation(animationView: animationView)
            }
        }
        
        view.addSubview(animationView)
        animationView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            animationView.heightAnchor.constraint(equalTo: view.heightAnchor),
            animationView.widthAnchor.constraint(equalTo: view.widthAnchor)
        ])
        
        // 监听视图从父视图移除事件,释放资源
        view.addObserver(context.coordinator, forKeyPath: "superview", options: .old, context: nil)
        
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        guard let animationView = uiView.viewWithTag(1001) as? LottieAnimationView else { return }
        
        if isPaused {
            animationView.pause()
        } else {
            playTargetAnimation(animationView: animationView)
        }
    }
    
    // 抽离动画播放逻辑,避免重复代码
    private func playTargetAnimation(animationView: LottieAnimationView) {
        guard let skillCardModel = card as? SkillCardModel else {
            if !animationView.isAnimationPlaying {
                animationView.play()
            }
            return
        }
        
        if !animationView.isAnimationPlaying || animationView.animation == nil {
            animationView.play(marker: String(skillCardModel.currentLevelIndex + 1))
        } else if let transition = transition {
            var transitionMarker = ""
            switch transition {
            case .left:
                transitionMarker = animationView.animation?.markerNames.first(where: { $0.contains(">\(skillCardModel.currentLevelIndex + 2)") }) ?? ""
                animationView.play(fromMarker: String(skillCardModel.currentLevelIndex + 2), toMarker: transitionMarker, loopMode: .playOnce)
            case .right:
                transitionMarker = animationView.animation?.markerNames.first(where: { $0.contains("\(skillCardModel.currentLevelIndex)>") }) ?? ""
                animationView.play(fromMarker: transitionMarker, toMarker: String(skillCardModel.currentLevelIndex + 1), loopMode: .playOnce)
            }
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                animationView.play(marker: String(skillCardModel.currentLevelIndex + 1), loopMode: .loop)
            }
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
    
    class Coordinator: NSObject {
        override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
            guard keyPath == "superview", 
                  let view = object as? UIView,
                  let animationView = view.viewWithTag(1001) as? LottieAnimationView else {
                super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
                return
            }
            
            // 视图被移除时,停止动画并释放资源
            if change?[.oldKey] != nil {
                animationView.stop()
                animationView.animation = nil
                view.removeObserver(self, forKeyPath: "superview")
            }
        }
    }
}

关键修改说明

  • 动画视图本地化:将animationView移至makeUIView内部创建,确保每个CustomLottieView实例对应独立的动画视图,旧视图销毁时动画资源也能被回收。
  • 移除强引用链:通过tag从父UIView中获取动画视图,不再依赖coordinator.parent的强引用,打破循环引用。
  • 主动释放资源:监听视图的superview变化,当视图从屏幕移除时,主动停止动画并设置animation = nil,立即释放Lottie动画占用的内存资源。
  • 代码重构:抽离playTargetAnimation方法,简化逻辑,减少重复代码。

内容的提问来源于stack exchange,提问作者TDMNS

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.20 22:56:59