用 SwiftData 做了一个订阅管理 App,从数据建模到产品设计的一些经验

5 阅读6分钟

起因

上个月翻信用卡账单,发现有三笔自动续费我完全不记得是啥时候订的。一个是某个 PDF 工具的年费,一个是试用期过了自动转付费的云存储,还有一个到现在都没想起来是什么服务。

市面上订阅管理类的 App 不少,但我试了几个,要么本身就是订阅制(套娃了属于是),要么就是功能太重。所以自己动手做了一个,叫「订阅斩」。核心思路:让「停掉一个订阅」这件事变得有仪式感,像游戏里砍怪一样爽。

数据建模:SwiftData 里枚举字段的取舍

项目最低版本定的 iOS 17+,SwiftData 是自然选择。订阅模型看起来简单,实际要存的字段比预想多不少:

@Model
final class Subscription {
    var id: UUID
    var name: String
    var price: Double
    var cycle: Int          // 月/季/年,存 rawValue
    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 notesStorage: String?
    var categoryStorage: String?
}

这里有个关键决策:cyclestatussource 全部存 Int 而不是直接用枚举类型。

SwiftData 本身支持 Codable 枚举的持久化,但我在开发阶段遇到过一次迁移事故——给 BillingCycle 加了一个 .weekly case 插在中间位置,rawValue 全错位了,旧数据读出来直接 crash。后来的策略是:存储层永远用 Int,读取时通过 computed property 转枚举,找不到对应值就给个 fallback 默认值。多写几行代码,但迁移安全性高很多。

账单周期换算和家庭共享

用户可能同时有月付的 Netflix、季付的 iCloud、年付的 Notion,要算「每月总支出」必须统一换算:

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
        }
    }
}

还有一个上线后才被用户提醒的场景:家庭共享。Netflix Premium 22.99 刀/月,4 个人分摊,实际每人不到 6 刀。所以模型里加了 isSharedmembersCount,算月均时除人头。这个需求说出来很明显,但我自己最初确实没想到——独立开发就是这样,总有盲区。

预设服务目录:减少输入成本

手动输入订阅名称、价格、周期太繁琐了。我内置了一个服务预设目录,数据结构长这样:

struct SubscriptionServicePreset: Identifiable, Hashable {
    let canonicalName: String
    let aliases: [String]        // 模糊匹配用
    let icon: String
    let defaultCycle: BillingCycle
    let suggestedSource: UnsubscribeSource
    let referencePrice: Double
    let sharedRecommended: Bool
    let membersCount: Int
}

匹配逻辑很朴素:用户输入文本转小写后,遍历每个 preset 的 aliases 数组做 contains 判断,命中则计分,最终按分数排序取 top 结果。没有上模糊匹配算法或 NLP,对于几十条预设数据来说,暴力遍历够快够简单。

用户输 "netflix" 就能自动填充图标 🎬、月付周期、参考价格 22.99、甚至退订链接指向 App Store 订阅管理页。这一步省下的输入时间,在用户初次录入七八个订阅时体感很明显。

风险检测:哪些订阅该「斩」

这是和其他同类 App 拉开差距的点。大部分订阅管理工具只帮你记录,不会主动说"这个你可能不需要了"。

我的做法是根据可获取的数据算风险等级:

  • 高危:标记了试用期(isTrial = true)且距续费日不足 7 天的;用户手动标记为"很少使用"频率的
  • 中危:价格高于同品类 referencePrice 均值的;同一 category 下订阅了 3 个以上服务的(比如同时订了 Netflix、Disney+、HBO、Apple TV+)

说明一下:iOS 沙盒机制下没法检测用户是否真的打开过第三方 App。我最初想过用 Screen Time 的 DeviceActivityReport API,但那套东西限制很多,需要 Family Controls entitlement,审核也麻烦。最终方案是让用户在添加订阅时自己选一个使用频率标签(经常用 / 偶尔用 / 几乎不用),风险检测基于这个标签来判断。简单但有效,用户反馈最多的就是"提醒我有个订阅好久没用了"。

货币适配:为什么没用 NumberFormatter

AppSettings 里根据设备 locale 推断默认货币符号。有人可能会问为什么不直接用 NumberFormattercurrencySymbol

原因是 NumberFormatter 返回的是当前 locale 对应的完整货币格式(比如中国 locale 下显示 "¥15.00"),但我的 UI 里需要把符号和数字分开展示——符号在左上角小字,数字是大号字体。拆开后自己拼接的灵活度更高。另外多币种场景下(比如人在国内但有美元订阅),用户手动切换显示货币时,我需要的是纯符号字符串而不是 formatter 绑定的格式化结果。

没做自动汇率换算是个有意的取舍。引入汇率数据源意味着网络依赖、更新频率、精度问题,对一个本地优先的轻量工具来说太重了。有用户提过这个需求,但目前优先级不高。

提醒机制:时区的坑

AppSettings 里做了三档提醒:续费前 7 天、3 天、1 天,默认全开。

7 天提醒不是拍脑袋的——很多平台的退订要求提前 48 小时甚至更长时间生效,给用户留操作窗口很有必要。

实现用的 UNUserNotificationCenter,根据 nextBillingDate 往前推算触发时间。踩过一个坑:用户改系统时区后,之前注册的 UNCalendarNotificationTrigger 的 dateComponents 不会自动更新。我后来改成用 UNTimeIntervalNotificationTrigger,在 App 启动时计算距离目标时间的 interval 重新注册,保证时区变化后依然准确。

「斩」这个动作的产品设计

最早版本用的是"归档"。改成"斩"之后配合 haptic feedback 和一个简单的划线动画,用户执行意愿明显变高了。本质和记账 App 里把"记一笔"换成"打卡"一样——心理暗示不同。

被斩掉的订阅进入「已斩」Tab,随时可以恢复。这个设计降低决策压力:"反正斩了还能捡回来"。App 里还有个 unsubscribeCompletionCount 计数器,累计斩了多少个订阅——小小的成就感积累。

目前状态

说实话刚上线不久,还在冷启动阶段,下载量很少。但我自己每个月初确实会打开看一眼本月预计支出。上个月靠它发现了一个完全忘记的年付域名监控服务,省了 400 多块,算是回本了。

后面计划做的:OCR 识别银行账单截图自动提取订阅信息(字段已经预留了 ocrUsageCount)、iCloud 多设备同步、按月的支出趋势图。


想问问各位:SwiftData 做 schema migration 时,你们是怎么处理枚举字段新增 case 导致 rawValue 错位的?我现在的方案是"全用 Int 存 + computed property 转换",总觉得有点笨,但确实稳。有没有更好的实践?