独立开发存钱 App:SpriteKit 硬币掉落动画,gravity 为什么设 -4.5 而不是 -9.8

3 阅读3分钟

SwiftUI 做不了多物体碰撞堆叠

存钱的时候让硬币从顶部掉下来,落进罐子里自然堆叠——这个交互我折腾了差不多一周。

需求听起来简单:硬币下落、互相碰撞、堆在一起。但实际上 SwiftUI 的 withAnimation + offset 只能处理单物体的位移动画,多枚硬币的碰撞响应和稳定堆叠根本做不了。试过 Canvas + TimelineView 手动算物理,写了两天发现碰撞检测的代码量比业务逻辑还多,果断放弃。

最后选了 SpriteKit。自带物理引擎,碰撞、重力、弹性都现成。代价是要在 SwiftUI 里桥接一个 SpriteView,但 iOS 14 之后这事儿很简单。

我做的这个 App 叫「聚沙攒钱」,每次用户往目标存一笔钱,就触发硬币掉落动画。这篇聊聊物理参数的调优过程和踩过的坑。

SKScene 配置和参数怎么来的

核心场景大概长这样:

class CoinDropScene: SKScene {
    override func didMove(to view: SKView) {
        physicsWorld.gravity = CGVector(dx: 0, dy: -4.5)
        physicsBody = SKPhysicsBody(edgeLoopFrom: frame)
    }
    
    func dropCoin(at x: CGFloat) {
        let coin = SKSpriteNode(imageNamed: "coin_gold")
        coin.position = CGPoint(x: x, y: frame.maxY + 20)
        coin.physicsBody = SKPhysicsBody(circleOfRadius: coin.size.width / 2)
        coin.physicsBody?.restitution = 0.3
        coin.physicsBody?.friction = 0.6
        coin.physicsBody?.density = 1.2
        coin.physicsBody?.allowsRotation = true
        addChild(coin)
    }
}

这几个数字不是拍脑袋定的,每个都试了十几种组合。

gravity 为什么是 -4.5

SpriteKit 默认重力是 -9.8,模拟真实地球重力。但在手机屏幕上,场景高度也就 300pt 左右,-9.8 的效果是硬币一闪就到底,用户根本看不清下落过程。

减到 -4.5 大概是真实重力的一半。视觉上硬币有明显的"飘落感",但又不至于慢到像在水里。我试过 -3.0,太飘了,像在月球;-6.0 又太快,5 枚一起掉的时候眼睛跟不上。-4.5 是个平衡点。

restitution 0.3 的由来

restitution 是弹性系数。默认 0.2,我一开始设成 0.6,想让硬币弹起来更活泼。结果硬币落地后弹三四次才停,5 枚同时掉的时候画面完全乱了——像弹力球而不是硬币。

最后定在 0.3:弹一次,回弹高度大概是下落距离的 30%,然后稳住。视觉上既有物理反馈,又不会让人觉得在看弹珠台。

两个具体的坑

硬币穿透问题。 硬币多了之后(一次存入对应 10 枚),偶尔出现两枚卡在一起或穿过边界消失。原因是 SpriteKit 默认的 usesPreciseCollisionDetection 是 false,小而快的物体可能在两帧之间直接穿过另一个物体。对硬币开启精确碰撞检测就解决了,十几枚硬币的 CPU 开销完全无感。

SpriteView 的内存泄漏。SpriteView(scene:) 嵌入 SwiftUI 后,如果父 View 被 NavigationStack pop 再 push 回来,scene 不会自动释放。反复进出页面内存一直涨。后来在 .onDisappear 里手动 scene.removeAllChildren(),把 scene 实例存在 @StateObject 的 ViewModel 里跟着 View 生命周期走:

struct CoinJarView: View {
    @StateObject private var vm = CoinJarViewModel()
    
    var body: some View {
        SpriteView(scene: vm.scene, options: [.allowsTransparency])
            .frame(height: 300)
            .background(Color.clear)
            .onDisappear {
                vm.scene.removeAllChildren()
            }
    }
}

对了,allowsTransparency 这个 option 别忘加,不然 SpriteView 背景是纯黑的,和 SwiftUI 界面融不到一起。

回过头看这个技术选型

SpriteKit 在这种"微交互物理动画"场景确实好用,比手写碰撞检测省太多事。但它毕竟是个游戏框架,引入它只为了一个掉落动画,多少有点重。包体积多了 SpriteKit 的链接开销,而且调试的时候得在 Xcode 里切到 SpriteKit 的 debug 视图才能看到物理边界。

我一直在想有没有更轻量的替代。UIKit DynamicsUIDynamicAnimator)API 更轻,能做重力和碰撞,但它和 SwiftUI 的桥接比 SpriteView 还别扭——得套一层 UIViewRepresentable 然后自己管理 reference view。Canvas + 手写物理可以做到零依赖,但碰撞稳定性得调很久。

如果有人在 SwiftUI 里做过类似的多物体物理动画,用的不是 SpriteKit,聊聊你们的方案?