存钱 App 开发手记:restitution 0.3 是怎么试出来的,以及 86400 秒不等于一天

26 阅读5分钟

想让硬币掉进罐子里,结果它们互相穿透了

我在做一个存钱 App「聚沙攒钱」,核心交互是每次存钱时,硬币从屏幕顶部掉落,叮叮当当落进罐子里。

一开始用 SwiftUI 原生动画。withAnimation 配合 .offset.rotationEffect,单个硬币没问题。但同时掉 5 个硬币的时候,它们互相穿透了——5 个幽灵重叠在一起往下飘。

我试过自己写碰撞检测,GeometryReader 拿每个硬币的 frame,每帧跑 O(n²) 的距离计算。搞了两天,要么硬币抽搐式抖动,要么直接飞出屏幕。10 个硬币的时候 CPU 占用飙到 40%。

老老实实上了 SpriteKit。

物理参数:二三十种组合里挑出来的

SpriteKit 自带物理引擎,碰撞堆叠都是现成的。真正耗时间的是 restitution(弹跳系数)、friction(摩擦力)和 density(密度)这三个参数的调试。

先说我试过的错误组合:

restitution = 0.6:硬币落地后蹦来蹦去停不下来,跟弹力球似的,过了 3 秒还在跳。

restitution = 0.1:硬币直接"啪"趴地上,像石头,没有任何金属质感。

friction = 0.1:硬币堆叠时往两边滑,像在冰面上,永远堆不住。

density = 0.3:太轻了,后落的硬币把先落的弹飞,整个画面像在演桌球。

最终定在 restitution 0.3 + friction 0.5 + density 1.2。落地弹一下然后稳住,视觉上最接近真实硬币掉到桌面的感觉。

下面是一个最小可运行的 SpriteKit + SwiftUI 桥接 demo,你可以直接建个新项目粘进去跑:

import SwiftUI
import SpriteKit

class CoinScene: SKScene {
    override func didMove(to view: SKView) {
        physicsBody = SKPhysicsBody(edgeLoopFrom: frame) // 四面围墙
        backgroundColor = .clear
    }
    func dropCoin() {
        let coin = SKShapeNode(circleOfRadius: 16)
        coin.fillColor = .systemYellow
        coin.strokeColor = .orange
        coin.position = CGPoint(
            x: CGFloat.random(in: 40...(size.width - 40)),
            y: size.height - 20
        )
        coin.physicsBody = SKPhysicsBody(circleOfRadius: 16)
        coin.physicsBody?.restitution = 0.3
        coin.physicsBody?.friction = 0.5
        coin.physicsBody?.density = 1.2
        addChild(coin)
    }
}

struct CoinDropView: View {
    @State private var scene: CoinScene = {
        let s = CoinScene(size: CGSize(width: 300, height: 500))
        s.scaleMode = .resizeFill
        return s
    }()
    var body: some View {
        VStack {
            SpriteView(scene: scene, options: [.allowsTransparency])
                .frame(height: 400)
                .background(Color.black.opacity(0.05))
            Button("存一笔") { scene.dropCoin() }
        }
    }
}

性能方面,iPhone 12 上同时 20 个硬币节点跑物理模拟,稳定 60fps,内存增量 8MB 左右。SpriteView 这个桥接组件是 iOS 14 引入的,一行代码就能把 SpriteKit 场景嵌进 SwiftUI 视图树。

86400 秒不等于一天:夏令时的坑

App 内置了 4 个存钱挑战:30天、52周、100天、365天。其中 52 周挑战的 totalDays 是 364(52×7),suggestedTargetAmount 是 1378,就是经典的 1+2+3+...+52 递增存法。

日期计算上我犯了个经典错误。最早用的是:

nextDate = Date(timeIntervalSince1970: start.timeIntervalSince1970 + Double(dayOffset) * 86400)

在美西时间(Pacific Time)2024 年 3 月 10 日踩雷了。这天凌晨 2:00 DST 切换,时钟直接跳到 3:00,这一天实际只有 23 小时(82800 秒)。加 86400 秒后,Date 落在了 3 月 11 日 01:00:00,看起来日期进了一位,但如果用户在 3 月 10 日 00:30 触发计算,加 86400 秒后得到的是 3 月 10 日 23:30——还是同一天,日期没进位。

具体表现是:用户第 10 天打卡,App 显示"第 9 天",连续记录少算了一天。

修法很简单,用 Calendar API 让系统处理时区偏移:

let next = Calendar.current.date(byAdding: .day, value: dayOffset, to: startDate)!

说白了就是别自己算天数。Calendar 内部知道每个时区的 DST 规则,不会出这种差一天的问题。

徽章系统怎么让人"再坚持一下"

存钱是反人性的,光靠意志力撑不了多久。我做了 16 个成就徽章,核心设计是每个徽章带一个条件闭包,存款后统一遍历检查解锁。

几类徽章和解锁阈值的取舍:

金额里程碑:500 / 1000 / 10000。500 是入门级,大部分人一两周能到。10000 是长线目标。

连续打卡:7 天连续存款解锁「Week Streak」。我自己测的时候,到第 5 天明明不想存了,但想到还差 2 天就拿徽章,硬是打开 App 存了 10 块。自己给自己设计的套路,自己还真吃这套。

时间段行为:「Night Owl」需要 10 次夜间存款,「Early Bird」需要 10 次早晨存款。最早设的是 3 次解锁,测试时发现两天就拿到了,毫无成就感。改成 10 次后,至少要在 10 个不同的日子里特定时段打开 App,才有"坚持"的意味。

特殊行为:「Ritual Master」需要完成 5 次存钱仪式(砸罐子动画),「Collector」需要同时维护 5 个活跃目标。这两个比较难,属于重度用户才能触碰的。

有一个有意思的问题:断签后挑战怎么办?我最终选择允许继续,但连续记录清零。如果断一天整个挑战就废了,大部分人会直接卸 App。清零连续记录已经够"疼"了。

双模式的由来

App 有两种储蓄模式——愿望模式(有目标金额,看进度条)和聚沙模式(无固定目标,纯定期积累)。数据层共用一个 Goal 模型,mode 字段区分 wishfree,free 模式下 targetAmount 为 0。

加聚沙模式是因为朋友试用后说:"我没有想买的东西,你非让我填目标金额,我填了 99999,体验很怪。"

说实话这个反馈让我意识到,不是所有人存钱都有明确目标。有些人就是想养成习惯,先攒着,以后再说用来干什么。

目前的状态

这个项目下载量很低。存钱类 App 在 App Store 搜索量本身就不大,品类太窄了。

如果你也在做工具 App 里的小型动画场景,SpriteKit 通过 SpriteView 桥接 SwiftUI 是个投入产出比很高的方案。20 个物理节点满帧跑,内存个位数 MB,比引入 Unity 或 Lottie 轻量太多。上面那段 demo 代码可以直接跑,改改贴图就能用在你自己的场景里。