起因
上个月翻信用卡账单,发现一笔 ¥68 的扣费,愣了半天才想起来是去年试用的某个 PDF 工具自动续费了。说实话有点难受——不是因为这笔钱多大,而是完全不记得自己还在为它付费。
我相信做开发的人多少都有这个问题。GitHub Copilot、JetBrains 全家桶、各种云服务、流媒体……订阅制无处不在,每一笔单独看不多,加一起可能占月收入的 5%-10%。
所以我做了「订阅斩」,一个帮自己(也帮别人)管理和砍掉不必要订阅的 iOS App。
技术选型:SwiftData + 纯 SwiftUI
项目从一开始就决定只支持 iOS 17+,原因很简单——我想用 SwiftData。
Core Data 那套 NSManagedObject + NSFetchedResultsController 的写法真的太啰嗦了。SwiftData 用 @Model 宏直接标注,配合 SwiftUI 的 @Query 就能自动驱动 UI 刷新,开发体验差距很大。
核心数据模型长这样:
@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 // active / killed
var killedDate: Date?
var isShared: Bool
var membersCount: Int
var icon: String
var unsubscribeURL: String
var source: Int
var categoryStorage: String?
}
一个 @Model 类搞定持久化,不需要 .xcdatamodeld 文件,不需要手写 migration policy(至少目前 v1.2 还没遇到复杂迁移场景)。
把月/季/年统一换算成月支出
做订阅管理最核心的一个计算:用户录入的可能是月付 ¥15,也可能是年付 ¥298,要统一展示"你每个月到底花了多少"。
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
}
}
}
这个 multiplierToMonthly 用在仪表盘汇总的时候:把每条订阅的 price * multiplierToMonthly 求和,就是月度总支出。逻辑很简单,但我发现很多用户之前从没认真算过这个数。有人录完发现自己每月光订阅就 ¥800+,当场就想取消几个。
「斩」这个动作的设计
说白了,取消订阅就是把 status 从 active 改成 killed,再记录一个 killedDate。技术上没什么复杂的。
但我花了不少时间在交互感受上——
普通的做法是一个"删除"或者"归档"按钮。我把它包装成了「斩」:动词带攻击性,配合震动反馈和动画,让用户觉得自己在主动止损,而不是在做一个无聊的列表操作。
这事儿听起来有点玄学,但从用户行为看确实有效。App 里有个 unsubscribeCompletionCount 计数器,记录用户总共「斩」了多少个订阅,后续可以做成就系统。目前 v1.2 还没做完整的成就体系,先埋了数据。
风险检测逻辑
我给每个订阅加了风险等级判断。规则不复杂:
- 高危:试用期订阅 + 距离下次扣费 ≤ 3 天(用户很可能忘了取消试用)
- 中危:超过 60 天没有打开对应服务(这个目前靠用户自己标记,后续考虑接 Screen Time API)
- 低危:正常使用中的订阅
高危订阅会在首页用红色标签高亮,配合本地通知在扣费前 7 天、3 天、1 天分别提醒。提醒时间可以在设置里逐个开关,对应的就是 remindBefore7Days、remindBefore3Days、remindBefore1Day 这几个字段。
内置订阅服务预设库
手动录入订阅名称、价格、周期,这个过程如果太麻烦,用户就不会用。所以我做了一个 SubscriptionCatalog,预设了几十个常见服务:Netflix、YouTube Premium、Spotify、iCloud+、Notion、GitHub Copilot……
每个预设包含默认图标、推荐周期、参考价格、是否适合拼车(sharedRecommended)、拼车人数等信息。用户输入名称时做模糊匹配,命中就自动填充,录入时间从 30 秒缩短到 5 秒左右。
模糊匹配用的是 aliases 数组 + 简单的字符串包含判断,没上什么高级算法,够用就行。
目前的状态和一些取舍
说几个我纠结过的点:
iCloud 同步:用 SwiftData + CloudKit 做的,默认开启。踩了一个坑——SwiftData 的 CloudKit 同步在 iOS 17.0-17.1 有偶发的合并冲突问题,升到 17.2 之后基本稳定了。但我还是加了一个手动开关 iCloudSyncEnabled,让不信任云同步的用户可以关掉。
多币种:最开始只做了人民币,后来想想用户可能有美元订阅(App Store 美区)、港币(Apple TV+ 港区)等,就加了货币符号设置。默认根据设备 locale 推断,覆盖了 USD/EUR/GBP/JPY/KRW/TWD/HKD 等主要币种。
OCR 识别订阅:v1.2 加了一个实验功能,拍账单截图识别订阅信息。做了免费次数限制(ocrUsageCount + ocrUsageResetDate 按月重置),体验还行但识别准确率不够理想,后面再优化。
有一个功能我删了:最初想做自动跳转到各平台取消订阅的页面(存在 unsubscribeURL 里),实际测试发现大部分取消流程都需要登录态,跳过去用户还是得自己操作,引导意义有限。最后保留了 URL 字段但降低了展示优先级。
写给同样想做工具 App 的开发者
这个 App 从立项到上架大概花了 6 周业余时间,技术难度不高,SwiftData 确实降低了不少数据层的工作量。
我觉得订阅管理这个品类最大的挑战不在技术,在于「用户录入意愿」。再好的功能,如果要用户花 10 分钟手动填 20 个订阅,大部分人第一步就放弃了。所以预设库、OCR、快速录入这些降低门槛的事情,比花哨的图表功能优先级高得多。
如果你也在做类似的个人工具项目,建议把「减少用户输入」这件事排到 P0。