我在 SwiftUI 里塞了一个 SpriteKit 物理引擎——以及为什么用闭包驱动整套徽章系统

4 阅读4分钟

SpriteKit 已经很久没人提了,但我在一个 SwiftUI 存钱 App 里把它挖了出来。原因很具体:我想让硬币落进罐子的时候,能真实地互相碰撞、堆起来,而不是播一段动画文件。

这款 App 叫「聚沙攒钱」,核心功能是储蓄目标追踪和 DCA 定投计划。听起来是个工具 App,但我花最多时间的地方是物理动画和成就系统——因为存钱这件事本身太枯燥,纯数字变化留不住人。

SpriteKit 嵌进 SwiftUI:硬币掉落的物理模拟

存款触发时,硬币从罐口落下,数量和存款金额挂钩——存 50 块和存 1000 块看到的场景不一样。核心逻辑用 SKPhysicsBody 驱动:

func spawnCoins(count: Int, in scene: SKScene) {
    for _ in 0..<count {
        let coin = SKSpriteNode(imageNamed: "coin")
        coin.physicsBody = SKPhysicsBody(circleOfRadius: coin.size.width / 2)
        coin.physicsBody?.restitution = 0.3
        coin.physicsBody?.friction = 0.6
        let startX = CGFloat.random(in: scene.frame.midX - 20...scene.frame.midX + 20)
        coin.position = CGPoint(x: startX, y: scene.frame.maxY + 20)
        scene.addChild(coin)
    }
}

restitutionfriction 这两个参数调起来比想象的费劲。restitution 超过 0.5,硬币会弹出 SKScene 的边界直接消失;低于 0.1 又像石头砸下去,完全没有堆叠效果,更像在往罐子里扔土块。最后 0.3 是一点点拍出来的,弹性刚好让硬币落地后轻微抖一下,然后稳住。

帧率问题后来才暴露——SpriteKit 场景嵌在 SwiftUI 里,低端设备上存款页面会卡顿。排查发现是物理体数量没上限,场景里同时跑几十个 coin node 的物理运算撑不住。修法是:硬币落地静止超过 2 秒后,移除 physicsBody 但保留节点用于视觉展示,CPU 占用立刻掉下来了。

双模式的数据结构分叉

App 有两种储蓄逻辑:愿望模式(设定目标金额、追踪进度,适合「买耳机」「旅行基金」)和聚沙模式(DCA 定投,设定每期金额和频率,复利计算器给出积累曲线)。

这两种模式用的是同一个 SavingGoal model,用 mode 字段区分(wish / free)。wish 模式下 targetAmount 有意义,totalPeriodsplanAmount 驱动进度计算;free 模式下 targetAmount 为 0,重点是 nextDueDate 和每期的定投执行记录。

用同一个结构做两件事有一些妥协——比如 free 模式下进度百分比没有意义,需要在 UI 层判断 mode 来决定显示什么。当时也考虑过拆成两个 struct,但目标列表、统计汇总、备份导出都需要统一遍历,分开之后聚合逻辑会复杂很多,最后还是选了单结构加字段区分。

成就徽章:用闭包驱动的判断系统

徽章系统是后来加的。早期测试发现用户完成第一个目标后不知道下一步做什么,留存会掉。

所有徽章的解锁条件统一抽象成一个结构体:

struct BadgeDefinition: Identifiable {
    let id: String
    let name: String
    let description: String
    let category: String
    let condition: (StatsSummary) -> Bool
}

condition 是一个闭包,传入 StatsSummary(包含总存款、连续天数、活跃目标数、各时段存款次数等),返回是否解锁。这样加新徽章只往 BadgeLibrary.badges 数组追加一条,不改任何判断逻辑。

目前 13 个徽章,我比较喜欢 night_owl(晚上存款 10 次)和 early_bird(清晨存款 10 次)这两个——它们捕捉的是用户真实的生活习惯,不是纯消费行为,某种程度上也让用户发现「原来我是一个夜间记账的人」。

每日语录的 18×18 矩阵

语录功能做起来比想象的麻烦。最开始就是个字符串数组,写了几十条,几天内就开始重复,体验很差。

改成了「主语 × 谓语」组合生成:18 个主语短句 × 18 个谓语短句,交叉可以出 324 条。按日期取当天的「年内第几天」做索引,同一个用户同一天看到的是固定的那条:

func todayQuote() -> String {
    let calendar = Calendar.current
    let dayOfYear = calendar.ordinality(of: .day, in: .year, for: Date()) ?? 1
    let index = (dayOfYear - 1) % (subjects.count * predicates.count)
    let s = index / predicates.count
    let p = index % predicates.count
    return subjects[s] + predicates[p]
}

Calendar.current.ordinality(of: .day, in: .year, for:) 返回当天是今年第几天(1月1日返回 1),用这个对 324 取余,保证全年不重复,而且同一天多次打开看到的语录是同一条。「今天的那句话」比「每次随机」更有仪式感——这是我做这个功能时唯一坚持的一个判断。

现在的状态

App 刚上线,下载量还很小,评分暂时没有数据。存钱类工具市场不算空白,我做这个主要是因为用不惯现有的——要么太重(什么都要填),要么太轻(只有数字没有反馈)。SpriteKit 物理动画这条路,我没在同类 App 里见过,算是一个自己觉得值得验证的方向。

后续想加 Widget 和 Apple Watch 快速存款入口。

对了,有个问题想问掘金的同学:SwiftUI 里嵌 SpriteKit 场景,除了「落地后移除 physicsBody」这个思路,还有什么方案压帧率?我试过限制场景里同时存在的物理体数量上限,但会出现硬币「凭空消失」的视觉问题,没找到很干净的解法。