SpriteKit性能调优以及加载GIF、Lottie、PAG等动效资源。

前言

SpriteKit是为iOS和macOS开发的2D游戏引擎,由苹果开发和维护。SpriteKit可以让开发者在应用中方便地添加动画、物理效果、粒子效果等。但是在使用SpriteKit时往往会出现CPU和内存占用高的问题,另外SpriteKit的坐标系和我们开发常用的坐标系也不相同并且SpriteKit中播放GIF、Lottie、PAG等都是很难甚至无法实现的。在次以一个射击小游戏为例介绍一下优化步骤

创建一个游戏场景

  1. 创建一个controller (SpriteKitMainViewController)并设置为根控制器
swift 复制代码
 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            let vc  = SpriteKitMainViewController()
            vc.view.frame = CGRect(x: 0, y: 0, width: SCREENW, height: SCREENH)
            window.rootViewController = vc
            self.window = window
            window.makeKeyAndVisible()
        }
    }
  1. 在SpriteKitMainViewController中创建SKMainScene(SKScene的子类)和SKView
swift 复制代码
import UIKit
import SpriteKit
class SpriteKitMainViewController: UIViewController {
    private lazy var skView :SKView = {
        let view = SKView(frame: CGRect(x: 0, y: 0, width: SCREENW, height: SCREENH))
        return view
    }()
    private lazy var scene :SKMainScene = {
        let scene = SKMainScene(size: CGSize(width: SCREENW, height: SCREENH))
        scene.scaleMode = .aspectFill
        return scene
    }()
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        setupUI()
        
    }
    
    private func setupUI(){
        self.view.addSubview(self.skView)
        //Create and configure the scene
        self.skView.presentScene(self.scene)
    }
   
}
  1. SKMainScene中创建物理场、背景、玩家角色、敌人角色,注意SpriteKit的描点默认是(0.5,0.5)并且坐标原点是在左下角和UIKit的不同
scss 复制代码
 override init(size: CGSize) {
        super.init(size: size)
        initPhysicsWorld()
        initBackground()
        initplayerRole()
        initFoeRole()
    }
ini 复制代码
private func initplayerRole() {
        let image = OCTool.image(toTransparent: "bear", deleteWhite: false)
        
        playerRole = SKSpriteNode(texture: SKTexture(image: image))
        playerRole?.position = CGPoint(x: 160, y: 50)
        playerRole?.size = CGSize(width: 100, height: 100)
        playerRole?.zPosition = 1
        playerRole?.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: (playerRole?.size.width ?? 0)-20, height: playerRole?.size.height ?? 0))
        playerRole?.physicsBody?.categoryBitMask = SKRoleCategory.SKRoleCategoryplayerRole.rawValue
        playerRole?.physicsBody?.collisionBitMask = SKRoleCategory.SKRoleCategoryFoeBullet.rawValue//0
        playerRole?.physicsBody?.contactTestBitMask = SKRoleCategory.SKRoleCategoryFoeBullet.rawValue//UInt32.init(SKRoleCategory.SKRoleCategoryplayerRole.rawValue)
        addChild(playerRole!)
       firingBullets(bulletType: 0)
    }
arduino 复制代码
 private func initFoeRole() {
         let image = UIImage(named: "monster")
        guard let image = image else {return}
        foeRole = SKSpriteNode(texture: SKTexture(image: image))
        guard let foeRole = foeRole else {return}
        foeRole.name = "foePlane"
        foeRole.size = CGSize(width: image.size.width*2, height: image.size.height*2)
        foeRole.physicsBody?.isDynamic = true
        //categoryBitMask  设置物理体标识符
        foeRole.physicsBody?.categoryBitMask = SKRoleCategory.SKRoleCategoryFoePlayer.rawValue
        //collisionBitMask  设置碰撞标识符  (非零 决定了物体能否碰撞反应)
        foeRole.physicsBody?.collisionBitMask = SKRoleCategory.SKRoleCategoryplayerRole.rawValue
        //contactTestBitMask 设置可以那类物理体碰撞  ->触发检测碰撞事件
        foeRole.physicsBody?.contactTestBitMask = SKRoleCategory.SKRoleCategoryplayerRole.rawValue
        foeRole.position = CGPoint(x: self.size.width*0.5, y: self.size.height - APPTool.topSafeAreaMargin - foeRole.size.height*0.5)
        
        self.addChild(foeRole)
        foeRoleAction()
        firingBullets(bulletType: 1)
     }
    

这三个属性是 SpriteKit 框架中 SKPhysicsBody 类的成员属性,用于定义物理体之间的碰撞和接触测试。

  1. categoryBitMask:该属性指定了物理体所属的分类,用一个位掩码(bitmask)来表示。物理体可以属于多个分类,如果两个物理体的分类位掩码相同,则它们属于同一类,将会相互影响。
  2. collisionBitMask:该属性指定了当前物理体可以发生碰撞的分类。如果两个物理体的分类位掩码的交集不为空,则它们将会发生碰撞。
  3. contactTestBitMask:该属性指定了当前物理体需要检测接触事件的分类。如果两个物理体的分类位掩码的交集不为空,则它们将会触发接触事件。

例如,假设要让一个小球能够弹跳,并且可以与墙壁发生碰撞。可以把小球的 categoryBitMask 设置为 0b001,把墙壁的 categoryBitMask 设置为 0b010,然后将小球的 collisionBitMask 设置为 0b010,这样小球就只会和墙壁发生碰撞,而不会和其他障碍物发生碰撞。

总之,这三个属性可以帮助开发者控制物理体之间的碰撞和接触测试,实现各种有趣的物理效果,如弹跳、摩擦、碰撞等。

  1. 玩家移动躲避子弹或走位发射子弹在SKMainScene中实现系统的touchesMoved方法
arduino 复制代码
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        for touch in touches {
            var location = touch.location(in: self)
            guard let playerRole = playerRole else {return}
           
            // 超出屏幕
            if location.x >= self.size.width - (playerRole.size.width / 2) {
                location.x = self.size.width - (playerRole.size.width / 2)
            } else if location.x <= (playerRole.size.width / 2) {
                location.x = playerRole.size.width / 2
            }

            if location.y >= self.size.height - (playerRole.size.height / 2) {
                location.y = self.size.height - (playerRole.size.height / 2)
            } else if location.y <= (playerRole.size.height / 2) {
                location.y = (playerRole.size.height / 2)
            }

            let action = SKAction.move(to: CGPoint(x: location.x, y: location.y), duration: 0.1)
            playerRole.run(action)
        }
    }
  1. 碰撞检测,主要检测敌方子弹和己方子弹碰撞时互相抵消,敌方子弹击中己方时敌方子弹消失(己方子弹击中敌人时效果未完善)
swift 复制代码
 func didBegin(_ contact: SKPhysicsContact) {
        if (contact.bodyA.categoryBitMask & SKRoleCategory.SKRoleCategoryFoeBullet.rawValue != 0 || contact.bodyB.categoryBitMask & SKRoleCategory.SKRoleCategoryFoeBullet.rawValue != 0) && (contact.bodyA.categoryBitMask & SKRoleCategory.SKRoleCategoryPlayerBullet.rawValue != 0 || contact.bodyB.categoryBitMask & SKRoleCategory.SKRoleCategoryPlayerBullet.rawValue != 0){
            contact.bodyA.node?.removeFromParent()
            contact.bodyB.node?.removeFromParent()
        } else if (contact.bodyA.categoryBitMask & SKRoleCategory.SKRoleCategoryFoeBullet.rawValue != 0 || contact.bodyB.categoryBitMask & SKRoleCategory.SKRoleCategoryFoeBullet.rawValue != 0) && (contact.bodyA.categoryBitMask & SKRoleCategory.SKRoleCategoryplayerRole.rawValue != 0 || contact.bodyB.categoryBitMask & SKRoleCategory.SKRoleCategoryplayerRole.rawValue != 0) {
            if contact.bodyA.categoryBitMask == SKRoleCategory.SKRoleCategoryFoeBullet.rawValue {
                contact.bodyA.node?.removeFromParent()
            }else if contact.bodyB.categoryBitMask == SKRoleCategory.SKRoleCategoryFoeBullet.rawValue {
                contact.bodyB.node?.removeFromParent()
            }
        }
        }

至此一个简单的射击小游戏完成

改进

问题

问题1:CPU和内存消耗大

看一下CPU的消耗如下图

目前界面主要是敌我双方和射击的子弹以及背景图CPU已经达到37%,为此是否有办法对CPU的使用率进行优化?

问题2:不能播放GIF等效果

另外虽然SKSpriteNode可以通过SKAction.repeatForever方法播放多张图片达到类似播放GIF的效果

less 复制代码
 let animationAction = SKAction.repeatForever(SKAction.animate(with: textureArray!, timePerFrame: 0.1))

需要注意的是:这种方法也具有一些明显的限制,比如无法像原生的GIF动画一样循环播放并且无法控制播放速度,同时也不能以任意的分辨率缩放GIF动画。当遇到播放lottie设置是pag格式的文件时更加显得无能为力。

思路

加载SKView造成了大量的CUP这里用自定义的View代替SKViwe渲染到屏幕上从而达到降低CPU的开销,另外通过重写func update(_ currentTime: TimeInterval)可以获得SKView每一帧的回调,如果采用UIView代替SKView上的元素SKNode在update函数中监听SKNode的位置大小等变化去更新UIView,那么在UIView上通过添加子控件就可以实现播放GIF、pag、lottie等文件

降低CPU和内存使用

代替SKView

  1. SpriteKitMainViewController中采用自定义的UIView(scene.contentView)代替skView
swift 复制代码
     private func setupUI(){
        //self.scene.contentView代替self.skView
        //self.view.addSubview(self.skView)
        self.view.addSubview(self.scene.contentView)
        
        //Create and configure the scene
        self.skView.presentScene(self.scene)
        scene.setUpUI()
    }

其中scene.contentView 为自定义UIView

SKMainScene中的实现

swift 复制代码
  private(set) lazy var contentView: SceneView = {
        let view = SceneView()
        view.touchesMovedClosure = {
            [weak self](touchs,event)  in
            guard let self = self else {
                return
            }
            self.touchesMoved(touchs, with: event)
        }
        return view
    }()
    
    class SceneView:UIView{
    
}

自定义AJSpriteNode代替原来SKSpriteNode并实现界面显示

  1. 自定义AJSPriteNode并且在AJSpriteNode中定义属性contentView
swift 复制代码
class AJSpriteNode: SKSpriteNode {
    private(set)  var contentView :NodeContentView? = NodeContentView()
    
    func removeContentView(){
        self.contentView?.removeFromSuperview()
        contentView = nil
    }
  
}

其中contentView是实际加到界面代替SKSpriteNode显示的视图,removeContentView方法是当子弹击中目标或者飞出屏幕时候移除的方法

  1. AJSpriteNode的contentView代替SKSpriteNode在界面显示,以用户玩家为例
swift 复制代码
 private func initplayerRole() {
        playerRole = AJSpriteNode()
       guard let playerRole = playerRole else {return}
        playerRole.position = CGPoint(x: 160, y: 50)
        playerRole.size = CGSize(width: 100, height: 100)
       //setUpNodeContent方法实现contentView添加到SceneView上并实现坐标转换
       setUpNodeContent(node: playerRole, imgStr: "bear")
        playerRole.zPosition = 1
       playerRole.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: (playerRole.size.width )-20, height: playerRole.size.height ))
        playerRole.physicsBody?.categoryBitMask = SKRoleCategory.SKRoleCategoryplayerRole.rawValue
        playerRole.physicsBody?.collisionBitMask = SKRoleCategory.SKRoleCategoryFoeBullet.rawValue
        playerRole.physicsBody?.contactTestBitMask = SKRoleCategory.SKRoleCategoryFoeBullet.rawValue
       addChild(playerRole)
      
       firingBullets(bulletType: 0)
    }

setUpNodeContent的具体实现

arduino 复制代码
private func setUpNodeContent(node:AJSpriteNode,imgStr:String?){
        if let imgStr = imgStr{
            //这里是考虑到子弹是根据颜色生成所以不走这个创建UIImage的方法
            node.contentView?.imgView.image =  OCTool.image(toTransparent: imgStr, deleteWhite: false)
        }
        //把SKSpriteNode所在的SpriteKit坐标转换成UIKit的坐标体系
        let point = view?.convert(node.position, from: self) ?? .zero
        node.contentView?.frame = CGRect(x: 0, y: 0, width: node.size.width, height: node.size.height)
        //这里设置centent是因为SKSpriteNode的锚点是(0.5,0.5)
        node.contentView?.AJCenterY = point.y
        node.contentView?.AJCenterX = point.x
        if let nodeContentView = node.contentView{
            contentView.addSubview(nodeContentView)
        }
        //加入到数组是为了后续方便取值
        nodeArray.append(node)
    }

玩家移动的实现修改

因为此时的SKView没有添加到界面(addSubView)所以原SKMainScene中的touchesMoved方法不会被执行,此时在屏幕移动触发的是添加到SpriteKitMainViewController.view的SceneView touchesMoved方法,为此这里把SceneView的touchesMoved回调到 SKMainScene上保存原来的逻辑

  1. SceneView touchesMoved实现和转发。实际上这里只是单纯的转发
swift 复制代码
class SceneView:UIView{
    var touchesMovedClosure:((_ touches: Set<UITouch>,_ event: UIEvent?)->Void)?
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?){
        //通过闭包回调到SKMainScene从而调用SKMainScene的touchesMoved方法
        self.touchesMovedClosure?(touches, event)
    }
}


//在SKMainScene中
 private(set) lazy var contentView: SceneView = {
        let view = SceneView()
        view.touchesMovedClosure = {
            [weak self](touchs,event)  in
            guard let self = self else {
                return
            }
            self.touchesMoved(touchs, with: event)
        }
        return view
    }()
  1. 这里相对原来的方法增加了坐标转换
arduino 复制代码
 override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        for touch in touches {
            var location = touch.location(in: self)
            guard let playerRole = playerRole else {return}
            //坐标系的转换
            location = convert(location, to: self)
            // 超出屏幕
            if location.x >= self.size.width - (playerRole.size.width / 2) {
                location.x = self.size.width - (playerRole.size.width / 2)
            } else if location.x <= (playerRole.size.width / 2) {
                location.x = playerRole.size.width / 2
            }

            if location.y >= self.size.height - (playerRole.size.height / 2) {
                location.y = self.size.height - (playerRole.size.height / 2)
            } else if location.y <= (playerRole.size.height / 2) {
                location.y = (playerRole.size.height / 2)
            }
            //这里的X、Y 不需要考虑锚点问题,因为会在update(_ currentTime: TimeInterval)中校正
            let action = SKAction.move(to: CGPoint(x: location.x, y: location.y), duration: 0.1)
            playerRole.run(action)
        }
    }

实时更新个Node元素的Frame

这里按实际出发因为这里的大小不会发生变化所以这里只更新Node的位置坐标,不更新大小

  1. 重写SKMainScene的func update(_ currentTime: TimeInterval)方法
swift 复制代码
 override func update(_ currentTime: TimeInterval) {
        
        let nodes = self.nodeArray
        for node in nodes{
            if let node = node as? AJSpriteNode {
                if node.contentView?.superview == nil,let contentView = node.contentView{
                    //防止在调用该方法时node.contentView未添加到SceneView上
                    self.contentView.addSubview(contentView)
                }
                
                let positon = self.convertPoint(toView: node.position)
                node.contentView?.AJCenterX = positon.x
                node.contentView?.AJCenterY = positon.y
            }
        }
    }

至此,完成了SpriteKit元素到UIKit元素的替换,运行项目看看CPU和内存情况

数据对比

静止时

上图为修改前

优化后

参数 修改前 优化后 优化比
CPU 37% 9% 75.7%
内存 65.8 28.7 56.6%

移动玩家时的参数

参数 修改前 优化后 优化比
CPU 41% 17% 59%
内存 65.5 28.8 56%

效果

暂时无法在飞书文档外展示此内容

播放GIF、PAG、lottie等资源

因为我们现在在界面上已经用UIView代替了SKNode所以播放上述资源的方案和我们平时的使用方法是一致的,这里以播放GIF为例

NodeContentView的修改

  1. 在NodeContentView中添加FLAnimatedImageView
ini 复制代码
 private var gifView :FLAnimatedImageView?
 
  func setupGIF(){
        gifView = FLAnimatedImageView()
        gifView?.isUserInteractionEnabled = true
        gifView?.isHidden = true
        gifView?.animationRepeatCount = 0
        gifView?.contentMode = .scaleAspectFill
        addSubview(gifView!)
        self.setNeedsLayout()
        self.layoutIfNeeded()
    }
  1. 暴露播放GIF的方法
ini 复制代码
    func playGIf(){
        imgView.isHidden = true
        gifView?.isHidden = false
        let path = Bundle.main.path(forResource: "bear", ofType: "gif")
        let data = NSData(contentsOfFile: path ?? "") as? Data
        guard let data = data else {return}
        gifView?.animatedImage = FLAnimatedImage(animatedGIFData: data)
        gifView?.startAnimating()
    }
  1. 点击图像(小熊)时停止播放并回调
swift 复制代码
@objc private func stopGif(){
        gifView?.isHidden = true
        gifView?.stopAnimating()
        imgView.isHidden = false
        self.stopClosure?()
    }

AJSpriteNode的修改

  1. 添加hitCount计算当小熊中弹3次时触发播放GIF

private var hitCount = 0

  1. 传入标识创建GIF,这里因为是demo所以用Mark == 100 代表小熊,才创建GIF。其他如敌机或者子弹等不创建实际开发中按实际情况处理
swift 复制代码
  //当Mark == 100是创建GIF
    var mark:Int8 = 0{
        didSet{
            if mark == 100{
                //玩家的mark为100
                contentView?.stopClosure = {
                //点击小熊的回调清空计数
                    [weak self] in
                    guard let self = self else {
                        return
                    }
                    self.hitCount = 0
                }
                contentView?.setupGIF()
            }
        }
    }
  1. 触发中弹的处理,中弹3次后触发GIF
csharp 复制代码
 //中弹的处理
    func getshot(){
        if hitCount < 3{
            hitCount += 1
            return
        }
        contentView?.playGIf()
    }

效果:

demo地址:gitee.com/liangaijun/...

结语

关于SpriteKit的其他优化方案网上也有介绍这里就不一一列出,如果文章长有出现纰漏希望大家指出

相关推荐
Unlimitedz11 小时前
iOS内存管理中的强引用问题
macos·ios·cocoa
雨夜赶路人12 小时前
iOS开发--接入ADMob广告失败
ios
旭日猎鹰13 小时前
iOS崩溃堆栈分析
ios
SY.ZHOU13 小时前
Flutter 与原生通信
android·flutter·ios
鸿蒙布道师16 小时前
鸿蒙NEXT开发文件预览工具类(ArkTs)
android·ios·华为·harmonyos·arkts·鸿蒙系统·huawei
鸿蒙布道师16 小时前
鸿蒙NEXT开发全局上下文管理类(ArkTs)
android·ios·华为·harmonyos·arkts·鸿蒙系统·huawei
归辞...17 小时前
【iOS】OC高级编程 iOS多线程与内存管理阅读笔记——自动引用计数(二)
笔记·ios·cocoa
码客前端18 小时前
ios接入穿山甲【Swift】
macos·ios·cocoa
键盘敲没电18 小时前
【iOS】UITableView性能优化
ios·性能优化·ipad
星鹿XINGLOO18 小时前
ChatGPT语音功能在iPad上支持吗?全面解答!
人工智能·安全·ios·ai·chatgpt·语音识别·ipad