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.
This commit is contained in:
Mario Zechner 2025-08-27 11:21:23 +02:00
parent cb62bd70c0
commit ab53d271a4
4 changed files with 43 additions and 1 deletions

View File

@ -28,35 +28,60 @@
*****************************************************************************/ *****************************************************************************/
import SpineiOS import SpineiOS
import SpineSwift
import SwiftUI 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 { struct MainView: View {
var body: some View { var body: some View {
List { List {
Section { Section {
NavigationLink("Simple Animation") { NavigationLink("Simple Animation") {
SimpleAnimation() SimpleAnimation()
.reportLeaksOnDisappear()
} }
NavigationLink("Play/Pause") { NavigationLink("Play/Pause") {
PlayPauseAnimation() PlayPauseAnimation()
.reportLeaksOnDisappear()
} }
NavigationLink("Animation State Listener") { NavigationLink("Animation State Listener") {
AnimationStateEvents() AnimationStateEvents()
.reportLeaksOnDisappear()
} }
NavigationLink("Debug Rendering") { NavigationLink("Debug Rendering") {
DebugRendering() DebugRendering()
.reportLeaksOnDisappear()
} }
NavigationLink("Dress Up") { NavigationLink("Dress Up") {
DressUp() DressUp()
.reportLeaksOnDisappear()
} }
NavigationLink("IK Following") { NavigationLink("IK Following") {
IKFollowing() IKFollowing()
.reportLeaksOnDisappear()
} }
NavigationLink("Physics") { NavigationLink("Physics") {
Physics() Physics()
.reportLeaksOnDisappear()
} }
NavigationLink("Disable Rendering") { NavigationLink("Disable Rendering") {
DisableRendering() DisableRendering()
.reportLeaksOnDisappear()
} }
} header: { } header: {
Text("Swift + SwiftUI") Text("Swift + SwiftUI")
@ -66,6 +91,7 @@ struct MainView: View {
SimpleAnimationViewControllerRepresentable() SimpleAnimationViewControllerRepresentable()
.navigationTitle("Simple Animation") .navigationTitle("Simple Animation")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.reportLeaksOnDisappear()
} }
} header: { } header: {
Text("ObjC + UIKit") Text("ObjC + UIKit")

View File

@ -28,11 +28,17 @@
*****************************************************************************/ *****************************************************************************/
import SpineiOS import SpineiOS
import SpineSwift
import SwiftUI import SwiftUI
@main @main
struct SpineExampleApp: App { struct SpineExampleApp: App {
init() {
// Enable debug extension for memory leak detection
enableDebugExtension(true)
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
NavigationView { NavigationView {

View File

@ -110,6 +110,13 @@ public final class SpineController: NSObject, ObservableObject {
drawable?.dispose() // TODO move drawable out of view? 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. /// The ``Atlas`` from which images to render the skeleton are sourced.
public var atlas: Atlas { public var atlas: Atlas {

View File

@ -247,12 +247,15 @@ extension SpineUIView {
} }
private func initRenderer(atlasPages: [UIImage]) throws { 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( renderer = try SpineRenderer(
device: SpineObjects.shared.device, device: SpineObjects.shared.device,
commandQueue: SpineObjects.shared.commandQueue, commandQueue: SpineObjects.shared.commandQueue,
pixelFormat: colorPixelFormat, pixelFormat: colorPixelFormat,
atlasPages: atlasPages, atlasPages: atlasPages,
pma: false // TODO: Get PMA flag from atlas when API is available pma: pmaFlag
) )
renderer?.delegate = controller renderer?.delegate = controller
renderer?.dataSource = controller renderer?.dataSource = controller