用 SwiftData 写了个订阅管理 App,聊聊我怎么把「取消订阅」做成一件有爽感的事

4 阅读5分钟

起因

上个月翻信用卡账单,发现一笔 ¥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 天分别提醒。提醒时间可以在设置里逐个开关,对应的就是 remindBefore7DaysremindBefore3DaysremindBefore1Day 这几个字段。

内置订阅服务预设库

手动录入订阅名称、价格、周期,这个过程如果太麻烦,用户就不会用。所以我做了一个 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。