做了个 iOS 订阅管理 App,用 SwiftData 搞定「订阅刺客」检测

0 阅读7分钟

起因:一笔莫名其妙的扣费

上个月查信用卡账单,发现一笔 68 块的扣费,愣是想不起来是什么。翻了半天邮箱才找到——去年试用的一个 PDF 工具,早就不用了,但自动续费一直在跑。

说实话有点难受。不是因为 68 块钱,而是这种「被动失血」的感觉很糟糕。

我就想,做开发的人其实是订阅重灾区。随手数一下:GitHub Copilot、JetBrains 全家桶、Figma、Notion、各种云服务、iCloud 存储、流媒体……一个月下来轻松好几百甚至上千。这些订阅分散在 App Store、官网、支付宝各个渠道,根本没法统一看到全貌。

所以我给自己做了一个 App:订阅斩。名字直白——把不需要的订阅"斩"掉。

核心思路:把「取消订阅」变成一个有成就感的动作

市面上有记账 App 可以记订阅,但体验上就是个列表,跟管理待办没区别。我想做的不一样:把停用订阅做成一个带仪式感的「斩杀」动作,而不是无聊的删除按钮。

听起来有点中二,但实际用起来确实会让人更愿意去清理。有点像游戏里清怪的感觉,斩掉一个订阅,省下的钱会实时显示出来。

App 的主要功能就三块:

  1. 录入订阅——支持月付/季付/年付,自动换算成月均支出
  2. 风险检测——根据使用频率和试用状态判断哪些订阅是"高危"的
  3. 一键斩断——归档不需要的订阅,随时可以在"已斩"列表里恢复

技术选型:SwiftData + 统一计费周期换算

整个项目用 SwiftUI + SwiftData,没引入额外的数据库依赖。数据模型其实不复杂,但有几个设计上的考量值得说说。

订阅的计费周期是个关键问题。用户可能录入月付 15 块的 iCloud,也可能录入年付 688 的 JetBrains。要算出「这个月到底花多少钱」,就得有一个统一的换算逻辑。

enum BillingCycle: Int, CaseIterable, Identifiable {
    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 就搞定了。之前我试过另一个方案——把所有价格在录入时就转成月价存储。后来发现改计费周期的时候会有精度丢失问题,还是存原始数据、展示时再算更靠谱。

订阅模型用 SwiftData 的 @Model 宏,核心字段是价格、周期、状态、下次扣费日:

@Model
final class Subscription {
    var id: UUID
    var name: String
    var price: Double
    var cycle: Int          // BillingCycle rawValue
    var isTrial: Bool
    var nextBillingDate: Date
    var status: Int         // active / killed
    var killedDate: Date?
    var icon: String
    var categoryStorage: String?
    // ...
}

这里有个坑要特别提一下。cyclestatus 我都用 Int 存而不是直接存枚举。原因是 SwiftData 在 iOS 17.0 ~ 17.1 之间对枚举类型的 schema migration 有 bug,我之前直接存 BillingCycle 枚举值,升级 iOS 17.1 之后旧数据读不出来,控制台报 Failed to find a matching candidate in enum 的错误,整个列表空了。排查了大半天才定位到是 SwiftData 的 lightweight migration 在处理枚举 rawValue 映射时出了问题。改成存 Int 之后再没出过事。丑是丑了点,但稳。

「订阅刺客」检测:怎么把我从自动扣费里拦下来的

这是我觉得最有意思的功能,也是我自己受益最多的。

很多订阅管理工具只是被动记录,用户不看就等于没用。我想让 App 主动找到你:"这个订阅可能有问题。"

判断逻辑分三层:

  • 高危(红色标记):试用期的订阅(isTrial = true),且下次扣费日在 7 天内。这种最危险,因为很多人试用完就忘了,等扣了钱才想起来。
  • 中危(橙色标记):超过 3 个月没有主动查看或编辑过的订阅。这类订阅大概率已经不用了,但你一直在为它付钱。
  • 低危(灰色提示):月均成本超过月薪 5% 的订阅。不一定要砍掉,但至少应该知道它在你的开支里占了多大比例。

结合 AppSettings 里的提醒配置,App 支持在扣费前 7 天、3 天、1 天推送本地通知。

说一件我自己被这个检测"拦"下来的事。今年 3 月我试用了一个设计素材库,7 天免费,之后每月 98 块。试用的时候觉得素材还行,但其实后来根本没再打开过。到第 5 天的时候,订阅斩给我推了条通知:"⚠️ 高危订阅提醒:[XXX素材库] 试用将在 2 天后到期,届时自动扣费 ¥98/月"。

就这一条通知,帮我省了 98 块/月。到现在算下来已经省了快 500 了。

这个功能规则看起来简单,但我发现够用了。真正让人多花冤枉钱的就两种情况:忘了试用到期、忘了某个订阅的存在。把这两种情况拦住,就解决了 80% 的问题。

内置订阅目录:模糊匹配 + 预设数据

手动录入订阅是个很烦的事儿,我自己都不想一个个填价格和周期。所以做了一个内置的订阅服务目录(SubscriptionCatalog),目前预设了 47 个常见服务,覆盖了 Netflix、YouTube Premium、Spotify、iCloud、Notion 这些全球通用的,也有爱奇艺、B 站大会员、网易云音乐、WPS 会员这些国内的。

用户输入名字的时候会做模糊匹配,命中了就自动填充。比如输入 "youtube",会匹配到 YouTube Premium,自动带出 ▶️ 图标、月付周期和参考价。

每个预设还带了 suggestedSource(退订渠道),告诉你这个订阅应该去 App Store 退还是去官网退,这个信息其实挺实用的——很多人不知道某些订阅在 App Store 里根本找不到退订入口,因为它是在官网订的。

说实话维护这 47 个预设挺累的,价格还会变动,后面打算做成可更新的远程配置。

工时换算:把抽象的钱变成具体的时间

因为开发者用的订阅很多是人民币计价的,举个例子:某个云服务月费 99 块。在设置里填月薪 15000、月工时 176 小时,App 会算出这个订阅每月花费相当于你工作 1.16 小时。

单看一个好像不多。但我把自己所有活跃订阅加起来——月均支出大概 860 块,相当于每个月有 10 个小时在为订阅打工。

看到这个数字的时候是真的愣了一下。

App 支持多币种(根据设备 Locale 自动选择默认货币符号,也可以手动切换 $、€、£、¥ 等),但工时换算目前只在同一币种下计算,没做汇率转换。跨币种的订阅需要用户在录入时手动换算成本地货币,这个后面看看要不要接个汇率 API。

目前的状态和一些取舍

App 刚上线不久,说实话数据还很惨淡,下载量基本个位数。这也正常,工具类 App 冷启动本来就难,况且 App Store 里搜"订阅管理"出来的都是大厂产品。

有些功能我想做但暂时砍掉了:

  • 自动读取 App Store 订阅——苹果没给这个 API,只能手动录入,这是体验上最大的短板
  • AI 识别账单截图——做了 OCR 识别的雏形(代码里有 ocrUsageCount 的字段),但 Vision 框架识别中文账单的效果参差不齐,各银行 App 的账单格式千差万别。试了三个方案最后都不满意,先藏起来了

写给同样在管理一堆订阅的你

如果你现在打开手机设置 → Apple ID → 订阅,看到的列表比你以为的长——你可能就是我想解决问题的那类人。

我自己用了大概两周,斩掉了 4 个订阅,每月省下大概 120 块。数字不大,但那种「拿回掌控感」的体验挺好的。

App Store 搜「订阅斩」就能找到(App ID: 6761400615),代码暂时没开源,但如果对 SwiftData 踩坑经验或者订阅检测的规则设计感兴趣,评论区聊。

特别想知道大家作为开发者,订阅最多的时候同时养着几个服务?