Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SpriteKit not deallocating all used memory

I have ready many (if not all) articles on SO and other sites about the disasters of dealing with SpriteKit and memory issues. My problem, as many others have had, is after i leave my SpriteKit scene barely any of the memory added during the scene session is released. I've tried to implement all suggested solutions in the articles i've found, including, but not limited to...

1) Confirm the deinit method is called in the SKScene class.

2) Confirm no strong references to the parent VC in the scene class.

3) Forcefully remove all children and actions, and set the scene to nil when the VC disappears. (Setting the scene to nil was what got the deinit method to eventually get called)

However, after all of that, memory still exists. Some background, this app goes between standard UIKit view controllers and a SpriteKit scene (it's a professional drawing app). As an example, the app is using around 400 MB before entering a SpriteKit scene. After entering the scene and creating multiple nodes, the memory grows to over 1 GB (all fine so far). When i leave the scene, the memory drops maybe 100 MB. And if i re-enter the scene, it continues to pile on. Are there any ways or suggestions on how to completely free all memory that was used during a SpriteKit session? Below is a few of the methods being used to try and fix this.

SKScene class

func cleanScene() {
    if let s = self.view?.scene {
        NotificationCenter.default.removeObserver(self)
        self.children
            .forEach {
                $0.removeAllActions()
                $0.removeAllChildren()
                $0.removeFromParent()
        }
        s.removeAllActions()
        s.removeAllChildren()
        s.removeFromParent()
    }
}

override func willMove(from view: SKView) {
    cleanScene()
    self.removeAllActions()
    self.removeAllChildren()
}

Presenting VC

var scene: DrawingScene?

override func viewDidLoad(){
    let skView = self.view as! SKView
    skView.ignoresSiblingOrder = true
    scene = DrawingScene(size: skView.frame.size)
    scene?.scaleMode = .aspectFill
    scene?.backgroundColor = UIColor.white
    drawingNameLabel.text = self.currentDrawing?.name!
    scene?.currentDrawing = self.currentDrawing!

    scene?.drawingViewManager = self

    skView.presentScene(scene)
}

override func viewDidDisappear(_ animated: Bool) {
    if let view = self.view as? SKView{
        self.scene = nil //This is the line that actually got the scene to call denit.
        view.presentScene(nil)
    }
}
like image 455
TheValyreanGroup Avatar asked Dec 05 '17 21:12

TheValyreanGroup


1 Answers

As discussed in the comments, the problem is probably related to a strong reference cycle.

Next steps

  1. Recreate a simple game where the scene is properly deallocated but some of the nodes are not.
  2. I'll reload the scene several time. You'll see the scene is properly deallocated but some nodes into the scene are not. This will cause a bigger memory consumption each time we replace the old scene with a new one.
  3. I'll show you how to find the origin of the problem with Instruments
  4. And finally I'll show you how to fix the problem.

1. Let's create a game with a memory problem

Let's just create a new game with Xcode based on SpriteKit.

We need to create a new file Enemy.swift with the following content

import SpriteKit

class Enemy: SKNode {
    private let data = Array(0...1_000_000) // just to make the node more memory consuming
    var friend: Enemy?

    override init() {
        super.init()
        print("Enemy init")
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    deinit {
        print("Enemy deinit")
    }
}

We also need to replace the content of Scene.swift with the following source code

import SpriteKit

class GameScene: SKScene {

    override init(size: CGSize) {
        super.init(size: size)
        print("Scene init")
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        print("Scene init")
    }

    override func didMove(to view: SKView) {
        let enemy0 = Enemy()
        let enemy1 = Enemy()

        addChild(enemy0)
        addChild(enemy1)
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let newScene = GameScene(size: self.size)
        self.view?.presentScene(newScene)
    }

    deinit {
        print("Scene deinit")
    }
}

As you can see the game is designed to replace the current scene with a new one each time the user taps the screen.

Let's start the game and look at the console. Will' see

Scene init Enemy init Enemy init

It means we have a total of 3 nodes.

Now let's tap on the screen and let's look again at the console Scene init Enemy init Enemy init Scene init Enemy init Enemy init Scene deinit Enemy deinit Enemy deinit

We can see that a new scene and 2 new enemies have been created (lines 4, 5, 6). Finally the old scene is deallocated (line 7) and the 2 old enemies are deallocated (lines 8 and 9).

So we still have 3 nodes in memory. And this is good, we don't have memory leeks.

If we monitor the memory consumption with Xcode we can verify that there is no increase in the memory requirements each time we restart the scene.

enter image description here

2. Let create a strong reference cycle

We can update the didMove method in Scene.swift like follows

override func didMove(to view: SKView) {
    let enemy0 = Enemy()
    let enemy1 = Enemy()

    // ☠️☠️☠️ this is a scary strong retain cycle ☠️☠️☠️
    enemy0.friend = enemy1
    enemy1.friend = enemy0
    // **************************************************

    addChild(enemy0)
    addChild(enemy1)
}

As you can see we now have a strong cycle between enemy0 and enemy1.

Let's run the game again.

If now we tap on the screen and the look at the console we'll see

Scene init Enemy init Enemy init Scene init Enemy init Enemy init Scene deinit

As you can see the Scene is deallocated but the Enemy(s) are no longer removed from memory.

Let's look at Xcode Memory Report

enter image description here

Now the memory consumption goes up every time we replace the old scene with a new one.

3. Finding the issue with Instruments

Of course we know exactly where the problem is (we added the strong retain cycles 1 minute ago). But how could we detect a strong retain cycle in a big project?

Let click on the Instrument button in Xcode (while the game is running into the Simulator).

enter image description here

And let's click on Transfer on the next dialog.

Now we need to select the Leak Checks

enter image description here

Good, at this point as soon as a leak is detected, it will appear in the bottom of Instruments.

4. Let's make the leak happen

Back to the simulator and tap again. The scene will be replaced again. Go back to Instruments, wait a few seconds and...

enter image description here

Here it is our leak.

Let's expand it.

enter image description here

Instruments is telling us exactly that 8 objects of type Enemy have been leaked.

We can also select the view Cycles and Root and Instrument will show us this

enter image description here

That's our strong retain cycle!

Specifically Instrument is showing 4 Strong Retain Cycles (with a total of 8 Enemy(s) leaked because I tapped the screen of the simulator 4 times).

5. Fixing the problem

Now that we know the problem is the Enemy class, we can go back to our project and fix the issue.

We can simply make the friend property weak.

Let's update the Enemy class.

class Enemy: SKNode {
    private let data = Array(0...1_000_000)
    weak var friend: Enemy?
    ... 

We can check again to verify the problem is gone.

like image 93
Luca Angeletti Avatar answered Sep 21 '22 05:09

Luca Angeletti