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 } }
问题根源
- 动画视图生命周期管理错误:
animationView是CustomLottieView的存储属性,SwiftUI会保留该实例,导致已滑出屏幕的卡片对应的Lottie视图无法被正确释放,内存持续占用。 - 强引用导致资源无法回收:
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
相关产品推荐
相关产品推荐

