用 SwiftData 做了一个订阅管理 App,聊聊「把取消订阅做成游戏」这件事

7 阅读6分钟

起因:一张信用卡账单

上个月查账单的时候,我发现有一笔 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?
}

这里有个取舍值得说一下。cyclestatus 我存的是 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,预设了几十个常见服务的信息,包括名称、图标、默认周期、参考价格、是否推荐共享、共享人数等。用户输入名字的时候做模糊匹配,命中了直接填充。

匹配逻辑也不复杂,对 canonicalNamealiases 数组做字符串包含判断,返回一个带 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。

一些遗憾和后续计划

说几个做得不够好的地方:

  1. 没做账单自动解析。我试过用 OCR 识别截图里的订阅信息,做了 ocrUsageCount 的配额限制,但识别准确率对中文账单来说实在不理想,目前这个功能只能算半成品。
  2. 共享订阅的拆账。模型里虽然有 isSharedmembersCount 字段,展示的时候也会除以人数算人均,但没有真正的多人协作——谁付了钱、谁该补差价,这块还没想清楚怎么做轻量。
  3. 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 的实践有什么经验,评论区聊聊。