起因:一张信用卡账单
上个月查账单的时候,我发现有一笔 68 块的扣款,死活想不起来是什么服务。查了半天才知道是去年试用的一个设计工具,试用期过了自动续费,扣了整整 6 个月我都没发现。
408 块,就这么没了。
这事儿让我挺难受的。我翻了一下 App Store 里的订阅管理工具,要么太重(恨不得把你的银行卡都接进去),要么就是纯记个账没什么用。我想要的很简单:把我所有的订阅服务录进去,告诉我每个月到底花了多少钱,哪些该砍了。
所以就自己做了一个——订阅斩。
核心思路:「斩」掉订阅,而不是「删除」
产品层面我做了一个刻意的设计:不用「删除」「取消」这种词,用「斩」。
说白了就是个心理暗示。你删除一个订阅,感觉像在做减法、在失去什么东西;但你「斩」掉一个订阅,有一种主动止损的爽感。App 里有一个「已斩」Tab,专门存放被你干掉的订阅,看着那个列表越来越长,说实话有点上瘾。
这个设计上线之后,我在 AppSettings 里加了一个 unsubscribeCompletionCount 字段来追踪用户斩掉订阅的次数,后续打算做成就体系。
技术选型:SwiftData 踩坑记录
整个项目用的 Swift + SwiftData,没上 Core Data。原因很简单——我想试试新东西,而且这个 App 的数据模型不复杂,正好合适。
订阅服务的核心模型长这样:
@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?
}
这里有个取舍值得说一下。cycle 和 status 我存的是 Int 而不是枚举的 rawValue 直接映射。一开始我试过用枚举类型直接标注 @Model,但 SwiftData 对枚举的支持有些地方表现不稳定,尤其是 migration 的时候。最后老老实实存 Int,在 Model 上加计算属性做转换:
var billingCycle: BillingCycle {
BillingCycle(rawValue: cycle) ?? .monthly
}
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 这个属性是用来统一计算月支出的。用户录入的可能是年费 298、季付 88、月付 15 这种混合数据,展示的时候需要全部折算成月均。一个简单的乘法,但对用户来说看到那个月总支出的数字,冲击力是很直接的。
做了一套订阅服务预设库
手动输入订阅信息是很烦的事。Netflix 多少钱一个月?Spotify 家庭版几个人共享?大部分人根本记不住。
所以我内置了一个 SubscriptionCatalog,预设了几十个常见服务的信息,包括名称、图标、默认周期、参考价格、是否推荐共享、共享人数等。用户输入名字的时候做模糊匹配,命中了直接填充。
匹配逻辑也不复杂,对 canonicalName 和 aliases 数组做字符串包含判断,返回一个带 score 的 match 结构体,取得分最高的。比如用户输入「youtube」,能匹配到 YouTube Premium,自动填上 ▶️ 图标、月付周期、13.99 美元参考价。
这个功能做完之后,录入一个订阅从大概 40 秒缩短到不到 10 秒。对留存帮助挺大的——说实话,如果录入订阅本身就是一件麻烦事,没人会坚持用。
多币种和本地化
因为订阅服务天然是全球化的,币种处理不能偷懒。我在 AppSettings 里根据设备 locale 自动推断默认货币符号:
static func defaultCurrencySymbol() -> String {
switch Locale.current.currency?.identifier ?? "CNY" {
case "USD": return "$"
case "EUR": return "€"
case "JPY": return "JP¥"
case "TWD": return "NT$"
case "HKD": return "HK$"
default: return "¥"
}
}
这里我没用 NumberFormatter 的自动格式化,因为订阅斩的场景里,用户看到「¥68/月」比看到「CNY 68.00」要直观得多。这是一个刻意的简化。
风险检测:哪些订阅该斩了
App 里我做了一个简单的风险等级检测。逻辑并不复杂:
- 高危:试用期订阅 + 距离下次扣费不到 7 天(大概率忘记取消)
- 中危:价格高于月均的 1.5 倍,或者超过 3 个月没有手动确认过
- 其余标记为正常
这个功能的价值在于「主动提醒」。大部分记账类 App 是被动的——你录了数据它帮你展示,但不会告诉你「这笔钱你可能不该花」。我希望订阅斩能多走一步,直接告诉用户「这个该斩了」。
提醒策略也是可配置的,支持提前 7 天、3 天、1 天三个档位,默认全开。这几个 flag 存在 AppSettings 里,用 @Attribute(originalName:) 做了字段映射,方便后续 schema migration。
一些遗憾和后续计划
说几个做得不够好的地方:
- 没做账单自动解析。我试过用 OCR 识别截图里的订阅信息,做了
ocrUsageCount的配额限制,但识别准确率对中文账单来说实在不理想,目前这个功能只能算半成品。 - 共享订阅的拆账。模型里虽然有
isShared和membersCount字段,展示的时候也会除以人数算人均,但没有真正的多人协作——谁付了钱、谁该补差价,这块还没想清楚怎么做轻量。 - iCloud 同步。用 SwiftData + CloudKit 的方案,字段上
iCloudSyncEnabledStorage是有的,但 SwiftData 的 CloudKit 同步在 iOS 17.0 和 17.1 上坑不少,偶尔会丢数据。我的建议是如果你也在用 SwiftData 做同步,至少等 iOS 17.2 以上。
我从这个项目里学到的
做订阅管理这种工具型 App,最难的不是技术实现,是让用户愿意把数据录进来。
我试过三种引导方案:新手教程、预设模板导入、空状态引导。最后发现效果最好的是第二种——在 SeedData 里不预设任何数据(没有假数据),但在 onboarding 结束后推荐一个「常用订阅包」让用户勾选导入,命中率比空白页面手动添加高很多。
对了,如果你也在做 SwiftData 的项目,关于 optional 字段的 migration 我踩了不少坑。建议新增字段一律用 optional + Storage 后缀的命名方式,在计算属性里给默认值,这样老数据升上来不会炸。订阅斩的 AppSettings 里那堆 xxxStorage 字段就是这么来的,虽然看着丑,但有效。
目前这个 App 刚上 1.2 版本,还在打磨阶段。如果你也被订阅刺客困扰过,或者对 SwiftData 的实践有什么经验,评论区聊聊。