起因
我做了一个存钱工具 App「聚沙攒钱」,核心交互是每次存钱时屏幕上有硬币哗哗落下的物理动画。说白了,存钱这件事最大的敌人是无聊,改个数字没感觉,但看到硬币叮当掉进罐子里,心理上完全不一样。这篇主要聊 SpriteKit 实现硬币动画的参数调优,以及和 SwiftUI 混合使用时踩的几个坑。
硬币动画:为什么选 SpriteKit 而不是 SwiftUI 动画
一开始我试过 SwiftUI 的 withAnimation 配合 offset 做硬币下落。效果很假——匀速掉落,硬币之间没碰撞,堆不起来。
后来换了 SpriteKit。iOS 自带的 2D 物理引擎,不需要引第三方库,包体积零增加,审核也没额外麻烦。每个硬币是一个 SKSpriteNode,加上 SKPhysicsBody,重力、碰撞、弹性系数都是引擎自动算的。
实际效果:存 500 块掉落 5 枚大硬币,它们互相碰撞、弹开、最后叠在罐底。配合 Taptic Engine 的触觉反馈,点「存入」的瞬间震一下,体感上很像真的往罐子里丢了东西。
参数调优花了一周。 弹性系数太高硬币会蹦出屏幕,太低像掉进泥里。我试过的几组值:
| restitution | friction | 表现 |
|---|---|---|
| 0.6 | 0.2 | 硬币弹得太高,偶尔飞出可视区域 |
| 0.1 | 0.6 | 落地后纹丝不动,像粘住了 |
| 0.3 | 0.4 | 弹一下就稳住,不同硬币数量下都还行 |
最后定的是 restitution 0.3、friction 0.4。不过说实话我不太确定这组值是不是最优,后面再聊这个。
成就系统:用条件闭包做徽章判定
游戏化的另一个部分是成就徽章。我设计了十几个,比如「连续打卡 7 天」「累计存款过万」「凌晨存钱 10 次解锁夜猫子」等。
实现上每个徽章定义直接带一个条件闭包:
struct BadgeDefinition: Identifiable {
let id: String
let name: String
let description: String
let category: String
let condition: (StatsSummary) -> Bool
}
// 夜猫子徽章
BadgeDefinition(
id: "night_owl",
name: "Night Owl",
description: "Deposit 10 times at night",
category: "special"
) { $0.nightDeposits >= 10 }
StatsSummary 是个聚合结构体,包含连续天数、总存款额、夜间存款次数这些统计值。每次用户操作后重算 summary,遍历所有 badge 的 condition 闭包,新解锁的弹通知。
这个方案加新徽章特别快。后来我加「Ritual Master」(完成 5 次砸罐仪式),从想法到上线 20 分钟——写一个 BadgeDefinition 扔进数组就完事了。
双模式架构:愿望 vs 聚沙
App 有两种存钱模式。
愿望模式:设目标金额(比如攒 8000 买 AirPods Max),每次手动存入,看进度条走。适合短期有明确标的的场景。
聚沙模式:DCA 定投思路,设定每周存 200,App 生成排期表,到期提醒打卡。配复利计算器能看长期预期曲线。
一开始只做了愿望模式。自己用着发现问题:有些钱不是为了买什么,就是想攒着,没有目标金额的话进度条就没意义。所以加了自由模式,targetAmount 为 0 时 UI 层隐藏进度条,只显示累计金额。
最近在做的是「砸罐仪式」——目标达成后触发一个砸碎存钱罐的动画,让存钱有个正式的收尾感。
每日语录:18×18 组合覆盖全年
这功能技术含量不高但用户感知挺强。我写了 18 个主语片段和 18 个谓语片段,排列组合 324 句不重复的激励语:
private static let subjectsZh: [String] = [
"今天多存一枚硬币",
"一杯奶茶的钱",
"坚持 7 天的连续记录",
"复利的第一步",
// ...共 18 条
]
// 按年内第几天取主谓组合,离线可用,不依赖后端
let dayOfYear = Calendar.current.ordinality(of: .day, in: .year, for: Date())! - 1
let subject = subjectsZh[dayOfYear % subjectsZh.count]
let predicate = predicatesZh[(dayOfYear / subjectsZh.count) % predicatesZh.count]
不用网络请求,离线能跑。324 句够全年不重复,反正用户也不会记得上个月看过什么。
踩过的坑
SpriteKit 和 SwiftUI 混合的内存问题。 SpriteKitView 嵌在 SwiftUI 里,页面 dismiss 后 scene 没正确释放,内存会一直涨。我后来在 onDisappear 里手动调了 scene.removeAllChildren() 和 scene.removeAllActions(),才稳住。这个坑文档里基本没提。
通知的 pending 管理。 用户改提醒时间后,旧的 UNCalendarNotificationTrigger 不会自动取消。得先 removeAllPendingNotificationRequests 再重新注册。我是自己反复测才发现的,改了时间但还是老时间弹通知,排查了半天。
备份体积控制。 本地 JSON 导出备份,设了 8MB 上限。压测过 1200 个目标、每个 10 条交易记录的极端情况,导出大概 2MB,8MB 的 cap 够用。
目前状态
坦白说,App 刚上架不久,7 天下载量基本是零。作为 side project 我现在重心还在打磨产品。技术上倒是挺满足的——SpriteKit 在这种轻量物理场景下表现很好,SwiftUI + SpriteKit 的混合架构也比预期稳定(前提是自己管好生命周期)。如果你也在做工具 App 的游戏化交互,SpriteKit 真的值得考虑,不需要上 Unity 那么重。
对了,好奇大家觉得 restitution 0.3 这个弹性系数偏高还是偏低?有调过 SpriteKit 物理参数的同学可以交流下,我总感觉硬币落地后第一次弹跳还是有点「轻飘飘」的,但再调低又显得太死板。