起因:信用卡账单上那笔想不起来的扣费
去年年底,我翻信用卡账单的时候发现一笔 15 块的扣费,死活想不起来是什么。查了半天才发现是某个 App 的试用期过了自动续费了——我甚至都没打开过第二次。
这事儿让我挺不爽的。我就想,能不能做一个工具把自己所有的订阅服务都记下来,哪些该续、哪些该砍,一眼就能看清楚。
于是「订阅斩」就这么开始做了。
产品定位:不只是记账,要有「斩掉」的仪式感
市面上有不少订阅管理工具,但大多数做法就是一个列表加一个总价。我觉得这不够——问题不在于「看见」,在于「行动」。
所以我给 App 设计了一个核心交互隐喻:斩。你不是在删除一条记录,你是在斩断一笔持续失血的开支。停用之后它会进入「已斩」Tab,带有一种完成任务的成就感。
说白了就是把无聊的记账操作包装成带点游戏感的动作。
技术选型:SwiftData + Swift 6
项目从一开始就用了 SwiftData 做持久化。说实话当时 SwiftData 刚出来不久,踩了不少坑,但好处是跟 SwiftUI 的数据流配合很顺畅,不用写一堆 NSFetchedResultsController 的样板代码。
核心的数据模型是 Subscription,长这样:
@Model
final class Subscription {
var id: UUID
var name: String
var price: Double
var cycle: Int // 0=月付, 1=季付, 2=年付
var isTrial: Bool
var nextBillingDate: Date
var status: Int // 活跃 / 已斩
var killedDate: Date?
var isShared: Bool
var membersCount: Int
var icon: String
var unsubscribeURL: String
var source: Int
var categoryStorage: String?
}
```
这里有个设计取舍:`cycle` 和 `status` 我存的是 Int rawValue 而不是直接存枚举。原因是 SwiftData 对枚举的迁移支持当时还不太稳定,用 Int 更保险,上层再包一个计算属性转回枚举就行。
## 账单周期统一换算
用户录入订阅时可以选月付、季付、年付,但仪表盘要展示的是「每月总支出」,所以需要把所有订阅统一折算成月度金额:
```swift
enum BillingCycle: Int, CaseIterable {
case monthly = 0
case quarterly = 1
case yearly = 2
var multiplierToMonthly: Double {
switch self {
case .monthly: 1
case .quarterly: 1.0 / 3.0
case .yearly: 1.0 / 12.0
}
}
}
```
计算月度开销的时候,每条订阅的 `price * billingCycle.multiplierToMonthly` 再求和就行。看着简单,但我一开始犯了个低级错误——季付写成了 `3.0 / 1.0`,导致仪表盘显示的金额离谱地高,自己测试的时候还以为是 UI 刷新问题,排查了好一阵。
## 风险检测:哪些订阅该「斩」
这是我比较满意的一个功能。App 会根据几个维度给每条订阅打风险标签:
- **高危**:试用期订阅且距离下次扣费不到 3 天(很多人就是在这个窗口期忘了取消)
- - **中危**:超过 60 天没有手动确认仍需要的订阅
中危这个怎么判定呢?实现上很简单——每条订阅详情页有个「我仍需要」的按钮,用户点击后会重置一个 `lastConfirmedDate` 时间戳。App 拿当前日期跟这个时间戳做 diff,超过 60 天就标中危。如果你从来没点过这个按钮,那从创建日期开始算。
另外共享订阅会降低风险权重(毕竟分摊了成本,单人月均花费低)。
逻辑不复杂,但实测下来确实能帮自己抓出不少「订阅刺客」。我自己用了两个月,砍掉了 4 个订阅,每月省了大概 80 块。
## 内置订阅预设库
为了降低用户录入成本,我在代码里维护了一个 `SubscriptionCatalog`,内置了常见订阅服务的预设信息——名称、图标、默认周期、参考价格、是否适合共享等。用户输入名称的时候会做模糊匹配,命中预设就自动填充其他字段。
目前收录了 Netflix、YouTube Premium、Disney+、Spotify、iCloud+ 这些主流服务,还有国内的爱奇艺、B站大会员、QQ音乐等。这个库还在慢慢补充。
## 提醒系统
`AppSettings` 里有三档提醒配置:提前7天、3天、1天。我用 UserNotifications 在每次订阅数据变更时重新计算并注册本地通知。
这里有个细节:iOS 对本地通知的 pending 数量有 64 条的限制。如果用户录了很多订阅且三档全开,很容易超限。我的做法是按紧急程度排序,优先注册距离最近的扣费提醒,超出的部分下次打开 App 时再补。
## 多币种支持
因为我自己有美元订阅也有人民币订阅(对,就是那些海外 SaaS 工具),所以 App 支持多币种显示。默认币种根据设备 Locale 自动推断:
- `CNY` → ¥
- - `USD` → $
- - `JPY` → JP¥
- - `TWD` → NT$
- - 其他一律 fallback 到 ¥
为什么 fallback 选 ¥ 而不是 $ 或者直接显示货币代码?原因很实际——我的目标用户绝大多数是中文用户,设备 Locale 拿不到货币信息的情况基本只发生在一些小众地区设置上,这种边缘 case 显示 ¥ 比显示一个用户可能不认识的三字母代码(比如 MYR、THB)体验更好。以后如果海外用户多了再改成显示货币代码也来得及。
## 上架情况
App 2024年底上架 App Store,当前版本 1.2。感兴趣的可以在 App Store 搜「订阅斩」。
说实话下载量还很少,独立开发最难的不是写代码,是让别人知道你做了这东西。没有投过广告,也没找人推过,就靠自然搜索慢慢来。
## 几个踩坑点总结
1. **SwiftData 的 `@Attribute(originalName:)` 是救命的**。我中途改过好几次字段名,有这个注解才不至于丢用户数据。
2. **枚举存 rawValue 虽然丑但稳**。Swift 的类型安全是好事,但持久化层还是得对迁移友好一点。
3. **游戏化设计比我预想的有效**。加了「已斩」计数器之后,我自己都会有冲动去多砍几个订阅。这算是产品设计中一个小验证。
4. **预设库的模糊匹配最初用的是 `contains`,后来改成了加权评分**。因为 `contains` 会把 "Apple TV+" 匹配到所有带 "apple" 的预设上,误报太多。
## 下一步计划
- 支持 OCR 识别截图中的订阅信息(已经做了基础版本,每日有免费额度限制)
- - iCloud 同步(模型层已经预留了 `iCloudSyncEnabled` 字段)
- - 考虑做一个 HarmonyOS NEXT 版本,ArkTS + ArkUI 那套我还在学,等 iOS 版稳定了再说
---
对了,想问问同样在做 iOS 独立开发的朋友:你们现在持久化选 SwiftData 还是 CoreData?SwiftData 的 lightweight migration 你们踩过坑吗?我遇到过改字段类型之后 migration 静默失败的情况,最后是靠 `@Attribute(originalName:)` 绕过去的,不知道有没有更好的解法。
282



被折叠的 条评论
为什么被折叠?



