From ab53d271a4f040219e040ca2063dbb7dabe63987 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 27 Aug 2025 11:21:23 +0200 Subject: [PATCH] feat(spine-ios): Add memory leak detection and fix PMA handling - Enable debug extension on app startup for leak detection - Add reportLeaks() calls when example views disappear - Fix PMA flag handling by reading it from atlas page instead of hardcoding to false - Add manual dispose() method to SpineController for explicit cleanup if needed Note: SwiftUI view caching may show false positive leaks when views disappear, as SwiftUI keeps views in memory for performance until they're truly no longer needed. --- .../Example/Spine iOS Example/MainView.swift | 26 +++++++++++++++++++ .../Spine iOS Example/SpineExampleApp.swift | 6 +++++ .../Sources/SpineiOS/SpineController.swift | 7 +++++ spine-ios/Sources/SpineiOS/SpineUIView.swift | 5 +++- 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/spine-ios/Example/Spine iOS Example/MainView.swift b/spine-ios/Example/Spine iOS Example/MainView.swift index 8a4ef021e..47551719c 100644 --- a/spine-ios/Example/Spine iOS Example/MainView.swift +++ b/spine-ios/Example/Spine iOS Example/MainView.swift @@ -28,35 +28,60 @@ *****************************************************************************/ import SpineiOS +import SpineSwift import SwiftUI +// View modifier to report memory leaks when view disappears +struct LeakReporter: ViewModifier { + func body(content: Content) -> some View { + content + .onDisappear { + reportLeaks() + } + } +} + +extension View { + func reportLeaksOnDisappear() -> some View { + modifier(LeakReporter()) + } +} + struct MainView: View { var body: some View { List { Section { NavigationLink("Simple Animation") { SimpleAnimation() + .reportLeaksOnDisappear() } NavigationLink("Play/Pause") { PlayPauseAnimation() + .reportLeaksOnDisappear() } NavigationLink("Animation State Listener") { AnimationStateEvents() + .reportLeaksOnDisappear() } NavigationLink("Debug Rendering") { DebugRendering() + .reportLeaksOnDisappear() } NavigationLink("Dress Up") { DressUp() + .reportLeaksOnDisappear() } NavigationLink("IK Following") { IKFollowing() + .reportLeaksOnDisappear() } NavigationLink("Physics") { Physics() + .reportLeaksOnDisappear() } NavigationLink("Disable Rendering") { DisableRendering() + .reportLeaksOnDisappear() } } header: { Text("Swift + SwiftUI") @@ -66,6 +91,7 @@ struct MainView: View { SimpleAnimationViewControllerRepresentable() .navigationTitle("Simple Animation") .navigationBarTitleDisplayMode(.inline) + .reportLeaksOnDisappear() } } header: { Text("ObjC + UIKit") diff --git a/spine-ios/Example/Spine iOS Example/SpineExampleApp.swift b/spine-ios/Example/Spine iOS Example/SpineExampleApp.swift index 96b40f3b5..f1aacef05 100644 --- a/spine-ios/Example/Spine iOS Example/SpineExampleApp.swift +++ b/spine-ios/Example/Spine iOS Example/SpineExampleApp.swift @@ -28,11 +28,17 @@ *****************************************************************************/ import SpineiOS +import SpineSwift import SwiftUI @main struct SpineExampleApp: App { + init() { + // Enable debug extension for memory leak detection + enableDebugExtension(true) + } + var body: some Scene { WindowGroup { NavigationView { diff --git a/spine-ios/Sources/SpineiOS/SpineController.swift b/spine-ios/Sources/SpineiOS/SpineController.swift index 46d9f970e..c88399e2f 100644 --- a/spine-ios/Sources/SpineiOS/SpineController.swift +++ b/spine-ios/Sources/SpineiOS/SpineController.swift @@ -110,6 +110,13 @@ public final class SpineController: NSObject, ObservableObject { drawable?.dispose() // TODO move drawable out of view? } } + + /// Manually dispose the drawable. Call this when you know the controller is no longer needed. + /// This is useful in SwiftUI where views may be cached and deinit may be delayed. + public func dispose() { + drawable?.dispose() + drawable = nil + } /// The ``Atlas`` from which images to render the skeleton are sourced. public var atlas: Atlas { diff --git a/spine-ios/Sources/SpineiOS/SpineUIView.swift b/spine-ios/Sources/SpineiOS/SpineUIView.swift index 302562ba8..887317d0e 100644 --- a/spine-ios/Sources/SpineiOS/SpineUIView.swift +++ b/spine-ios/Sources/SpineiOS/SpineUIView.swift @@ -247,12 +247,15 @@ extension SpineUIView { } private func initRenderer(atlasPages: [UIImage]) throws { + // Get PMA flag from first atlas page if available + let pmaFlag = controller.atlas.pages.count > 0 ? (controller.atlas.pages[0]?.pma ?? false) : false + renderer = try SpineRenderer( device: SpineObjects.shared.device, commandQueue: SpineObjects.shared.commandQueue, pixelFormat: colorPixelFormat, atlasPages: atlasPages, - pma: false // TODO: Get PMA flag from atlas when API is available + pma: pmaFlag ) renderer?.delegate = controller renderer?.dataSource = controller