独立开发健康手账:拨轮交互、SwiftData 多档案、Siri Shortcut,踩了哪些坑

4 阅读6分钟

上线已经一周了,下载量还是 0。我把这个 App 发给我爸用了,他现在每天早晚还在用,每次测完血压就拨一拨,这对我来说够了。但我还是想把过程写出来——做这个 App 的过程里有几个技术决策挺有意思,值得记一下。

为什么要做这个

我爸有高血压,每天早晚要测血压,然后他就对着一个小本子写数字。我帮他装过几个血压记录 App,界面都很丑,还有的要注册账号、要订阅会员。他觉得麻烦,用了两天就卸了。

更大的问题是:就算记了好几个月的数据,去医院的时候医生一脸茫然,因为数据都在 App 里,没法给医生看。

所以我想做的事就两件:录入快到不嫌麻烦数据能直接给医生用


拨轮录入:为什么不用数字键盘

同类 App 基本都是点「添加」→ 弹出数字键盘 → 分别输入收缩压/舒张压/脉搏。操作步骤不少,老年人的拇指也不够精准。

我最后选了 iOS 原生 Picker 做成拨轮形式,收缩压、舒张压、脉搏三列并排,物理滚动,有阻尼感。拇指一拨就到位,熟练之后确实 3 秒能完成一次录入。

这里有个细节——Picker 默认在 Form 里会折叠成一行点击展开的样式,要强制显示 wheel 形态需要加 .pickerStyle(.wheel),但多列对齐需要自己用 HStack 包裹多个 Picker,不能直接用 Picker 的多 binding。

HStack(spacing: 0) {
    Picker(收缩压, selection: $systolic) {
        ForEach(60...250, id: \.self) { v in
            Text(\(v)).tag(v)
        }
    }
    .pickerStyle(.wheel)
    .frame(maxWidth: .infinity)

    Picker(舒张压, selection: $diastolic) {
        ForEach(40...150, id: \.self) { v in
            Text(\(v)).tag(v)
        }
    }
    .pickerStyle(.wheel)
    .frame(maxWidth: .infinity)
}

拨轮宽度用 .frame(maxWidth: .infinity) 均分,不然默认宽度会挤在一起。


数据模型:SwiftData 的坑

这次全程用 SwiftData,没有用 CoreData。模型很简单,一条 HealthRecord 挂了收缩压、舒张压、脉搏、体重,还有一个 tagIDs: [String] 存状态印章。

tagIDs 存的是 StatusTag 的 id 字符串,而不是直接用 @Relationship 关联对象。原因是踩过一次坑:用户删除一个自定义 tag 定义时,所有引用它的 HealthRecord 的关联字段会被 SwiftData 置 nil,复盘视图里那些印章标记直接消失,时间线乱掉了。改成字符串 ID 松耦合之后,删 tag 定义不影响历史记录,查询时再按 ID 查一次就行。

多人档案也是类似思路:每条 HealthRecord 挂一个 profileID,查询时加 #Predicate { $0.profileID == activeID } 过滤,没有复杂的权限体系,家庭场景下数据全在本地,隔离靠 ID 就够了。

另一个坑:ModelContainer 初始化时如果开了 iCloud 同步(cloudKitDatabase: .automatic),但用户设备没有登录 iCloud,会直接抛异常 crash。我在 AppSettings 里加了一个 icloudEnabled 开关,默认关闭,用户手动开启,规避了冷启动 crash。但说实话这个方案有点粗糙,后面会提到。


StatusTag:状态印章

「今天吃了降压药吗?运动了吗?」这些干预行为如果能和血压数据放在同一时间线上,复盘时就能看出关联——某天血压偏高,看一眼印章,刚好那天没吃药。

StatusTag 有几个内置默认值:降压药、减脂餐、运动、好睡眠、黑咖啡,用 SF Symbols 做图标,颜色可自定义。录入时选好 tag 存进 tagIDs,趋势图上对应时间点就会显示印章标记。

这个功能实现不难,但「把行为和数据放在一条时间线上」这个思路,比单纯记数字有意思得多。


Siri Shortcut:一句话触发录入

AppIntents 做了一个 Shortcut:说「用健康手账记录健康数据」,App 打开同时弹出录入 sheet。

struct LogHealthRecordIntent: AppIntent {
    static let title: LocalizedStringResource = 记录健康数据
    static let openAppWhenRun: Bool = true

    @MainActor
    func perform() async throws -> some IntentResult {
        try await Task.sleep(for: .milliseconds(200))
        NotificationCenter.default.post(
            name: .healthLogShowInputSheet, object: nil
        )
        return .result()
    }
}

openAppWhenRun = true 会先启动 App,但 view hierarchy 还没 ready 的时候发 Notification 是没人接收的,所以加了 200ms 延迟。有点 hacky,我试过监听 scenePhase 变成 .active 再发通知,但那个时序更难控制——scenePhase 回调触发的时机和 SwiftUI 视图 onAppear 的顺序在不同设备上不一致,反而更容易丢通知。200ms 延迟测下来更稳定,就留着了。

Shortcuts 注册了两个触发短语,用 AppShortcutsProvider 统一管理,用户装完 App 就能直接对 Siri 说,不需要手动在捷径 App 里配置。


PDF 就医报告

这个功能做了挺久,方案换了三次。

一开始用 UIGraphicsPDFRenderer 手画,代码量大,分页逻辑全靠手算坐标,改一个字段位置要调半天。然后改成把 SwiftUI View 渲染成 UIImage 再合成 PDF,清晰度差,A4 分页也要自己切图,体验很糟。

最后选了 WKWebView 渲染 HTML 模板再调 createPDF() 输出。HTML 模板是本地字符串拼接,数据填进去之后用 loadHTMLString(_:baseURL:) 加载,离屏渲染,不需要把 WebView 加到视图层级里。createPDF() 直接返回 Data,清晰度高,A4 分页是浏览器引擎处理的,代码量反而比前两个方案少。

let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 595, height: 842))
webView.loadHTMLString(htmlString, baseURL: nil)
// 等渲染完成后
let pdfData = try await webView.pdf()

宽度设 595pt 对应 A4 宽度,WKWebViewpdf() 方法是 iOS 16+ 的 async API,需要等 webView(_:didFinish:) 回调后再调用,不然拿到的是空白页。

报告格式参考了医院的血压记录单:日期、时段(晨测/午测/晚测)、收缩压/舒张压/脉搏分列,底部附均值和最高/最低值。导出后微信发给子女,陪诊时打印出来递给医生。


买断还是订阅

纠结了一段时间。高血压患者需要长期记录,订阅会让人觉得「万一哪天不续费,我的数据怎么办」。对于慢病用户,「买断一次,永久用」的信任感更重要。

Pro 版买断,基础记录功能免费,多人档案 + PDF 报告 + 更多分析功能收费。


现在的状态

上线一周,下载 0,评分暂时没有。接下来准备联系几个养生健康类博主做测评,重点让他们录拨轮操作的视频——文字描述「3秒录入」说服力有限,视觉冲击才够直接。

技术上还有一个问题一直没解决得很满意:iCloud 同步目前是让用户手动开关,根本原因是我没找到一个可靠的方式在运行时判断当前设备的 iCloud 账户状态,再决定 ModelContainer 用不用 CloudKit。FileManager.default.ubiquityIdentityToken 可以判断是否登录,但 ModelContainer 初始化只能做一次,切换同步状态需要重启 App,体验很割裂。

有没有在 SwiftData + CloudKit 这块有更好实践的同学,评论区聊聊?