普通视图

发现新文章,点击刷新页面。
昨天以前掘金 iOS

独立开发了一款健康记录 App,聊聊几个让我纠结很久的设计决策

作者 SameX
2026年4月25日 07:57

上线一周,下载量是零。

说实话这很正常,冷启动就是这样。但我还是想把「健康手账」这个项目的一些设计思路写出来,因为做的过程中有几个决策点我觉得挺有意思,适合和做 iOS 工具类 App 的朋友聊聊。

为什么又做了一个健康记录 App

这类 App 的竞品多到数不清。我当时下载了七八个,用下来有个共同问题:录入太麻烦。

打开 App、点击新建、手动输入收缩压、再输舒张压、再输脉搏——三个字段,最快也要 15 秒。对于每天早晚要测两次血压的高血压患者,这个摩擦力不小。更别说帮父母操作,老人对数字键盘并不友好。

我想解决的核心问题就一个:把「记一次数据」压缩到 3 秒以内。

拨轮交互:试了三个方案,前两个全删了

最直接的想法是自动识别——用手机摄像头拍血压计,OCR 识别数值。我试了一周,识别率在不同光线下差异很大,而且用户还得配合把手机对准屏幕,反而更麻烦。

第二个方案是预设范围的快捷选择,类似「上次是 128/82,这次有变化吗?」。问题是这个交互对新用户完全不直觉,而且首次录入没有历史数据根本跑不起来。

最后用的方案是物理拨轮(Picker 风格,但带阻尼感的自定义实现)。收缩压、舒张压、脉搏各一个拨轮,默认值锁定在上次录入附近,打开就能拨,拨完直接存。实际操作下来,熟悉之后真的 3-5 秒能完成一次录入。

这个交互特别适合老年用户,因为拨轮比点击键盘容错率高——拨过了再拨回来,不需要删除重输。

数据模型:tagIDs 关联干预行为

健康数据好记,但「今天血压偏高是因为没睡好还是昨晚喝酒了」这种因果关系很难追踪。

我在 HealthRecord 上加了一个 tagIDs: [String] 字段,对应一套可自定义的状态标签(StatusTag)。内置的有「降压药」「运动」「好睡眠」「黑咖啡」等,用户每次录入时可以顺手打几个印章。

@Model
final class HealthRecord {
    var id: UUID
    var timestamp: Date
    var systolic:  Int?
    var diastolic: Int?
    var pulse:     Int?
    var weight:    Double?
    var tagIDs:    [String]   // 关联当次的干预行为
    var profileID: String = "default"
}

趋势图里,这些标签会作为事件标记叠加在折线上。比如连续几天运动后血压数值的变化,一眼就能看出来。这个设计借鉴了运动 App 里「训练日志」的思路,但放在健康场景下我觉得更有价值,因为慢病管理真正需要的是「行为-数据」的对照。

PDF 就医报告:一个被低估的功能

大多数健康 App 的数据只能在 App 里看,或者最多导出 CSV。但去医院看诊时,医生没时间看你手机屏幕,更不可能帮你分析折线图。

我加了一个「生成就医报告」功能,一键输出标准格式 PDF:患者基本信息、最近 N 天的血压/体重数据表格、趋势图、备注。打印出来或者直接发给医生。

这个功能在开发时我有点犹豫要不要做,感觉实现成本不低(PDF 布局、图表渲染都要搞一遍)。但想想「数据记了,但医生看不懂」这个痛点,还是做了。说实话现在觉得这是产品里最有差异化的地方。

Siri 快捷指令:让录入更快一步

做完拨轮之后我想,录入的最大摩擦其实不是界面操作,而是「打开 App 这个动作本身」。

用 AppIntents 实现了一个 Siri 快捷指令,说「用健康手账记录健康数据」直接跳到录入界面,不需要找图标、不需要滑动。实现上用了一个 NotificationCenter 的广播机制——intent perform 之后 post 一个通知,主视图监听到就弹出录入 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()
    }
}

延迟 200ms 是因为 App 冷启动时视图层级还没就绪,直接 post 通知会丢失。这个 bug 我在真机上踩了才发现,模拟器里完全复现不了。

本地存储 + 可选 iCloud 同步

数据全部用 SwiftData 存在本地。这是一个主动决策,不是因为懒得做后端。

健康数据比较敏感,尤其是帮父母记录的场景,很多用户对「数据上云」有顾虑。本地存储让这个顾虑直接消失,也不需要注册账号、不需要联网。

iCloud 同步作为可选项保留,用 CloudKit 实现,在设置里手动开启。代码里的处理也很直接:

let config = ModelConfiguration(
    schema: schema,
    isStoredInMemoryOnly: false,
    cloudKitDatabase: icloud ? .automatic : .none
)

买断制也是基于同样的逻辑——慢病患者要长期用,订阅制的心理负担对他们不友好。

多人档案:一个 App 管全家

HealthRecord 上有个 profileID 字段,支持创建多个独立档案。这个场景是子女帮父母管理健康数据时用的:爸妈各一个档案,切换一下就能看各自的趋势。

下次陪父母去复诊,不用临时整理数据,直接切到对应档案导出 PDF 就完事了。

一些还没做好的地方

趋势图的异常检测现在还比较简陋,只是超过阈值就标红,没有考虑到「白大衣高血压」这种场景下连续几次都偏高但实际没问题的情况。这块我想引入一个滑动窗口均值,但暂时还没动。

血压分类标准支持切换(国内标准 vs ACC/AHA 2017),但界面上没有做到位,大多数用户根本发现不了这个设置在哪。


这个项目目前还在冷启动阶段,有兴趣的朋友可以在 App Store 搜「健康手账」试试——特别是家里有需要记血压的长辈的,帮他们装一个比较实在。

如果你也在做类似的健康或工具类 App,欢迎在评论区聊聊你在数据录入和用户习惯培养上的做法,我挺好奇不同产品的解法有什么差异的。

iOS 足迹 App 的成就系统,我推倒重做了一次——踩了3个坑之后

作者 SameX
2026年4月24日 11:34

上线三周,成就页的打开率掉到了 2%。我盯着这个数字看了好一会儿,意识到设计完全错了。

「雁过留痕」是我做的一个足迹记录 App,核心思路是把你走过的路变成可量化的探索面积(km²),用 25m 精度网格覆盖地图,慢慢把省份和城市染色。这个核心玩法我觉得还不错,但成就系统上线之后直接拖了后腿。

第一版成就系统到底错在哪

最早的版本只有几个维度:总距离、总录制天数、省份解锁数量。听起来挺完整,上线之后发现问题很具体——用户解锁了头三个徽章,然后成就页就再也不打开了。

原因其实事后想想很显然:目标太稀疏,中间段完全是空白期。你解锁了「初探者」,下一个目标要再走 500km 才能到「漫游者」,这中间几个月看不到任何进展反馈,等于告诉用户「别来了」。

游戏设计里有个基本原则:玩家需要随时都能看到「我离下一个里程碑还有多远」。我第一版完全忽略了这件事。

推倒之后怎么重建

重做的核心思路是把成就拆成多个 Track,每个 Track 内部是连续的多级徽章,保证任意时刻都有「快到了」的感觉。

enum BadgeTrack: String, CaseIterable, Identifiable {
    case all
    case exploration   // 面积、城市、省份
    case consistency   // 连续打卡、累计月数
    case china         // 省级/大区解锁
    case world         // 全球探索
    case pro           // Pro 会员专属

    func matches(_ definition: BadgeDefinition) -> Bool {
        self == .all || definition.badgeTrack == self
    }
}

这个分组做出来之后,成就页的平均停留时长从 8 秒涨到了 19 秒。说实话这个数字比我预期高,主要原因我猜是「中国赛道」——省份解锁这个玩法对国内用户有天然吸引力,很多人打开成就页就是去看自己还差哪几个省。

数据聚合这块踩的坑

成就判断需要的数据维度很多:总距离、连续天数、省份数量、面积……最开始每个徽章自己去查数据库,成就页一打开要跑几十次查询,加载卡顿肉眼可见。

后来抽了一个 BadgeMetrics 结构统一做一次聚合,所有徽章判断共用同一份数据:

struct BadgeMetrics {
    let totalDistanceKilometers: Double
    let recordedDays: Int
    let currentStreakDays: Int
    let longestStreakDays: Int
    let chinaProvinceCount: Int
    let chinaAreaKm2: Double
    let cityUnlockCount: Int
    let globalAreaKm2: Double

    static func build(
        stats: TraceStats,
        segments: [TraceSegment],
        geo: GeographicProfile,
        proMembershipActive: Bool,
        proMembershipActivatedAt: Date?
    ) -> BadgeMetrics { ... }
}

顺带提一个细节:segments 在 build 里有一步过滤,只保留 pointCount >= 8 的记录。这个阈值对应大约 10-15 秒的有效移动,过滤掉了打开 App 又马上锁屏的噪声。这个数值调了好几次,太小的话徽章进度会被一堆无效数据撑高,用户觉得「奇怪,我没走多少怎么进度涨这么快」,反而破坏信任感。

中国坐标系的坑,顺便说一下

省份解锁要判断「这个 GPS 点是否在某个省内」,但 GPS 原始数据是 WGS-84,国内地图用 GCJ-02,直接拿坐标去匹配行政区边界,边境附近会出现「明明走在省内却没解锁」的情况。

我在 GeographicProfile 里做了坐标系转换,省级和地市级边界数据全部内置,不走网络请求。好处是离线也能正常触发成就,坏处是包体增加了大概 3MB——这个取舍我觉得值,足迹类 App 很多场景就是在没网络的山里。

现在纠结的一个设计问题

下一步想加「状态徽章」:比如「连续 30 天记录」解锁之后不是永久持有,断了会变灰,需要重新激活。

但我现在真的没想清楚该不该做。

没压力就没粘性,这个逻辑说得通,健身 App 基本都用这套。但足迹记录和健身不一样——用户可能就是出去旅行才开,平时根本不用,强迫他们「每天打卡」会让 App 变成一个焦虑来源。我不想做那种让人觉得「没开就有罪恶感」的产品。

但如果完全没有时间压力,成就全部永久持有,用户解锁完一批之后可能又回到当初那个 2% 的困境。

这个矛盾我现在还没有好答案。如果你做过类似的游戏化设计,或者作为用户对「会过期的成就」有什么感受,真想听听。

我用 SpriteKit 给存钱罐装了个物理引擎

作者 SameX
2026年4月24日 10:41

调参数调到一半,我顺手往测试账号里又存了 200 块,纯粹是因为看硬币掉落太过瘾。那一刻我觉得这个 App 的核心方向对了。

存钱 App 不少,但我自己用了一圈,基本上三天就卸载了。问题不是功能不够,而是「存进去」这个动作没有任何正向反馈。往银行账户转 500 块,余额多了个数字,然后呢?没声音没动静,脑子里毫无反应,下次就很难再重复这个行为。做「聚沙攒钱」大概花了三个月,现在刚上线,核心想解决的就是这一件事:让存款这个动作本身变得有意思。

用 SpriteKit 做硬币物理动画

这是整个 App 里我花时间最多的部分。存款的时候,硬币从屏幕上方掉落,碰到罐子边缘会弹一下,堆在罐底。存的金额越大,掉的硬币越多。用 SpriteKit 的物理引擎实现,核心逻辑大概是这样:

func spawnCoins(count: Int, into scene: SKScene) {
    for _ in 0..<count {
        let coin = SKSpriteNode(imageNamed: "coin")
        coin.physicsBody = SKPhysicsBody(circleOfRadius: coin.size.width / 2)
        coin.physicsBody?.restitution = 0.4
        coin.physicsBody?.friction = 0.6
        let startX = CGFloat.random(in: scene.size.width * 0.3...scene.size.width * 0.7)
        coin.position = CGPoint(x: startX, y: scene.size.height + 20)
        scene.addChild(coin)
    }
}

restitution 控制弹性,friction 控制摩擦。这两个值调了挺久——最开始设 restitution = 0.8,硬币在罐里弹来弹去像乒乓球,完全不对;换成 0.1,又像石头直接沉底,没有金属感。来回试了大概二十组,0.4 + 0.6 是我自己觉得最接近「真实硬币掉进陶瓷罐」的感觉。就是在调这个参数的过程中,我忍不住顺手存了那笔 200 块。

双模式:短期愿望 vs 长期定投

产品结构上做了两种模式。

愿望模式:适合「我要攒钱买 AirPods Max」这种有明确目标的场景,设目标金额,每次存款推进度条,距离目标还差多少天一目了然。

聚沙模式:基于 DCA(定期定额)逻辑,设定每周或每月固定存入金额,内置复利计算器,输入年化收益率之后可以看到 N 年后的预估结果,适合想养成长期储蓄习惯的场景。

两种模式放在一起,设计阶段我自己也担心会让人觉得混乱。但在早期十几个测试用户里,有三四个两个模式都开着——一个用来存旅行基金,一个用来强迫自己每月定存。这个比例让我觉得放在一起是对的,两种心理状态确实可以并存。

成就徽章系统

参考了健身 App 的逻辑,把可见的里程碑作为习惯强化手段。徽章判断条件全部基于 StatsSummary 这个结构体,包括总存款金额、连续天数、存款时间段等等:

BadgeDefinition(id: "streak_7", name: "Week Streak",
    description: "Deposit 7 days in a row",
    category: "streak") {
    $0.currentStreak >= 7
},
BadgeDefinition(id: "night_owl", name: "Night Owl",
    description: "Deposit 10 times at night",
    category: "special") {
    $0.nightDeposits >= 10
}

「Night Owl」和「Early Bird」是我比较喜欢的两个,晚上存了 10 次和早上存了 10 次分别解锁。有测试用户看到「Night Owl」的时候说「这个 App 懂我」,这个反馈挺好的——徽章在记录的不只是金额,还有一个人存钱的时间节奏。

每日语录:18×18 组合生成

这个模块有点意思。我不想手写几百句鸡汤,所以用了组合逻辑——18 个「主语」乘以 18 个「谓语」,生成 324 种组合,足够一年内不重复。

比如「固定的存钱节奏」+「会让焦虑一点点淡下去」,「一杯奶茶的钱」+「能抵消很多小小的冲动消费」。有些组合挺通顺,有些拼出来确实略生硬,读起来像机器写的。生硬的那些我做了一个黑名单手动过滤,大概淘汰了 40 句,剩下的整体可读性还不错。说白了,这是个半自动流程,机器打草稿,人工做最后一道筛。

做错了的几个决定

订阅定价改了两次。最开始想做纯免费带广告,后来发现存钱 App 里放广告体验很糟,用户存钱存到一半弹出来一个游戏广告,心情直接崩了。改成一次性内购之后反而顺一些。

数据备份功能上线比预想晚了一个版本。有个测试用户换手机之后数据全没了,找我反馈,最后一条消息就是「我的数据没了」,然后就没再说话。我盯着那条消息看了挺久,没法回复什么。那之后备份功能直接插队到下个版本,别的需求全往后推。用户数据这件事,v1 就该做好,没有借口。

「聚沙模式」的 UI 一开始做得太复杂,复利计算器有七八个输入项,我自己用的时候都觉得烦,后来砍掉大半只留核心参数。试了三个方案,最后全删了重来。功能多不等于有用。

一个没解决的 SpriteKit 问题

目前有个穿模问题没有根治:硬币数量一多,相互重叠之后会出现轻微穿透。我现在的做法是限制单次最大生成数量,同时用 categoryBitMask 给硬币单独分一个碰撞分组,让它们只和罐壁、罐底以及彼此发生碰撞,不影响 UI 层的其他节点:

coin.physicsBody?.categoryBitMask = PhysicsCategory.coin
coin.physicsBody?.collisionBitMask = PhysicsCategory.coin | PhysicsCategory.jar
coin.physicsBody?.contactTestBitMask = PhysicsCategory.jar

这样能减少无关碰撞计算,但硬币堆多了之后还是会穿模,治标不治本。有做过 SpriteKit 堆叠物理的朋友吗?是怎么处理这个问题的?

我做了一个把专注计时变成「声音护照」的 iOS App,聊聊数据可视化和成长系统的设计思路

作者 SameX
2026年4月24日 10:15

最近上线了一个叫「声境护照」的 iOS App,做的事情说起来很简单:番茄钟 + 环境音 + 数据可视化。但我想聊的不是功能本身,而是做这个 App 过程中一些有意思的设计决策——尤其是「把专注数据包装成旅行叙事」这条路到底值不值得走。

从一个具体的厌倦感出发

我用过很多专注类 App,Forest、潮汐、番茄ToDo,都挺好用。但用着用着有个感受:完成了专注,然后呢?数字加一,然后就没了。

说实话,这种「完成即消失」的感觉有点可惜。你花了 25 分钟认真写东西,这件事值得被记住。所以我想做一个让每次专注都留下「印记」的工具。

护照的比喻就是从这里来的——每次专注是一次起飞,声景是目的地,累计时长变成飞行里程,连续打卡天数是你的「航班记录」。

成长系统的数据结构设计

游戏化成长系统是这个 App 最核心的部分,我在 ExpeditionModels.swift 里把整个探险体系建模成章节 + 任务的结构:

struct ExpeditionChapterDefinition: Identifiable, Codable, Equatable {
    let id: String
    let sceneId: String
    let cityName: String       // 对应一个声景目的地
    let tagline: String
    let bonusBounces: Int
    let missions: [ExpeditionMissionDefinition]
}

enum ExpeditionMissionKind: String, Codable {
    case sessionCount    // 完成 N 次专注
    case focusMinutes    // 累计 N 分钟
    case deepFocusCount  // 深度专注 N 次
}

每个「城市章节」绑定一个声景 ID,完成章节里的任务才能解锁下一个城市。这样声音选择就不只是 UI 装饰,而是有推进感的目标。

ExpeditionMissionKind 只有三种,我故意控制得很少。试了几个方案,加过「连续打卡天数」「特定时段专注」等类型,最后都删了——任务类型越多,用户反而不知道该干什么。

会话战报的「下一步建议」逻辑

每次专注结束会弹出一张战报,这个战报除了展示当次数据,还会给出下一次专注的建议。这个逻辑在 SessionReportSheetViewModel 里:

func nextActionAdvice(taskCompleted: Bool) -> SessionNextActionAdvice {
    let remainingTodayPlan = max(0,
        store.weeklyPlanTodayTargetSegments - store.weeklyPlanTodayActualSegments
    )
    let streakHint: String
    if store.streakDays >= 5 {
        streakHint = "你已连续 \(store.streakDays) 天,重点是稳定复用。"
    } else if store.streakDays >= 2 {
        streakHint = "再坚持 1-2 天可进入稳定习惯区。"
    } else {
        streakHint = "建议先连续 3 天完成每日最小闭环。"
    }
    // ...
}

这里有个取舍:建议文案是写死的字符串模板,不是 AI 生成的。我考虑过接 LLM,但一来成本不好控制,二来我发现这类「行为引导」场景其实不需要千变万化的文案,固定的几条反而更有仪式感,用户知道这是 App 在认真跟踪自己的状态。

连续天数的分层(1天 / 2-4天 / 5天以上)是我根据习惯养成的一般规律拍的,不是什么严格实验得出的结论。连续 3 天是个心理门槛,5 天以上用户大概率已经进入节奏了,策略应该从「建立习惯」切换到「维持节奏」。

分享卡片:让专注数据变成内容

这是我觉得最值得展开讲的部分。

专注类 App 的自然增长渠道几乎只有两条:AppStore 搜索,和用户分享。Forest 靠的是「种了一棵树」的视觉,潮汐靠的是精美的音景截图。我的切入点是「数据卡片」——把当次战报或周回顾渲染成一张可以直接发朋友圈的图片。

ShareCardFormatter 负责格式化卡片里的时间信息,战报卡片、成就徽章卡片、周回顾卡片用的日期格式各不相同(yyyy/MM/dd HH:mm vs yyyy/MM/dd),看起来细节,但如果格式乱掉整张卡片的质感就垮了。

卡片设计我做了三个版本,第一版太「仪表盘」,数字密密麻麻;第二版太「极简」,信息量不够,朋友看不出你做了什么;第三版找到了平衡——突出时长和等级称号,次要展示声景和任务名,底部放一行小字的里程数。

StatsService 和 GrowthService 的分层

统计相关的逻辑我拆成了两个 Service:

  • StatsService:纯数据聚合,负责按时间范围汇总 FocusLog,输出 StatsData
  • GrowthService:负责把 FocusLog 转换成 GrowthProfile,计算等级、经验值、称号

这两个 Service 都是无状态的纯函数风格,输入 logs 数组输出结果,在多个 ViewModel(StatsSheetViewModelProfileSheetViewModelWeekReviewSheetViewModel)里复用。

有一个小设计:当 focusLogs 为空时,会调用 StatsService.createDemoFocusLogs() 生成演示数据。新用户第一次打开统计页不会看到空白界面,而是看到一个「如果你用了两周会是什么样子」的预览。这个 onboarding 细节我觉得挺重要——空页面对新用户很劝退。

现在的状态和一些遗憾

App 刚上线 1.3 版本,下载量还很少,老实说基本还在 0 起步阶段。

有几个功能是做到一半放在 _disabled_features 目录里的——统计报告、周回顾、分享卡片这些模块代码都写完了,但 UI 打磨还不够,我没有在 1.3 开放。这种「功能写完了但藏起来」的状态有点难受,但比发出去然后体验很差要好。

声景库目前内容量不够丰富,「东京雨夜」「咖啡馆白噪音」这类场景音是有的,但城市章节太少,探险系统的推进感不强。这是接下来要重点补的。

还有一个我没想清楚的问题:护照 + 飞行里程这套叙事对喜欢旅行的用户很有共鸣,但对完全不在意这个比喻的用户来说可能显得有点奇怪。这个产品定位的边界到底在哪,我还在摸索。


如果你也在做类似的「工具 + 游戏化」方向的 iOS App,或者对专注类产品有什么看法,欢迎在评论区聊聊——我对「游戏化到底会不会让用户厌倦」这个问题挺好奇的,想听不同角度的判断。

鸿蒙呼吸动画踩了三个坑:GPU降级时机、设计Token校验、i18n漏key——具体怎么处理的

作者 SameX
2026年4月24日 08:28

在鸿蒙上做呼吸动画,我以为最难的是 ArkTS 语法,结果最麻烦的是——我根本不知道用户的设备跑到哪一档了。

呼吸动画是「呼吸视界」这个 App 的核心体验:吸气时圆圈缓慢扩张,屏气时保持,呼气时收缩。这个动画一旦卡顿,「跟着 App 呼吸」的节奏就断了,用户能感觉到「哪里不对」,但不会告诉你是帧率问题。

先说一下这个 App 是干什么的,方便后面的技术背景理解。

产品背景:一个给自己做的呼吸训练工具

「呼吸视界」(iOS App Store ID: 6758613852)做的是结构化呼吸训练引导——4-7-8 呼吸法、盒式呼吸、Wim Hof 法这些。网上这些方法的文字说明很多,但照着文字练,你得自己数秒、记顺序,练着练着就分心了。

我做这个的起因很功利:开会前容易紧张,想找个东西帮我两分钟之内把状态重置一下。找了一圈没找到合适的,就自己写了。

App 有三块核心功能:带动画节奏的引导式练习、本地持久化的训练记录、以及一个课程进度系统(不只是单次练习,而是完整的训练计划)。iOS 版目前评分 5 分,样本量不大,但有个用户说「可以跟随练习呼吸,保持稳定的心情」——说实话这个反馈比我预期的更朴实,我自己用下来觉得更直接的感受是:开会前真的有用,两分钟够了。

鸿蒙版最近发布,把移植过程里踩的几个坑整理一下。

坑一:呼吸动画的 GPU 降级,我不知道该在哪个阈值切

呼吸动画用 GPU 渲染时效果最好,过渡顺滑,缩放曲线自然。但鸿蒙设备碎片化比 iOS 严重得多,中低端机上 GPU 渲染直接掉帧,整个动画变得一顿一顿的。

所以我做了一套自适应降级:检测到性能不足时切到 Canvas fallback 模式,同时把当前渲染质量分成 highbalancedlow 三档。

问题来了:切换阈值怎么定?

我的判断方式是盯 frameMs(单帧渲染耗时)和连续低帧计数:

// 连续低帧超过阈值时触发降级
if (frameMs > 22 && consecutiveLowFpsCount >= 3) {
  // 22ms ≈ 45fps,低于此值且连续3帧 → 切 balanced
  adaptRenderer('degrade quality -> balanced');
  consecutiveLowFpsCount = 0;
}
if (frameMs > 33 && consecutiveLowFpsCount >= 3) {
  // 33ms ≈ 30fps,连续3帧 → 切 canvas fallback
  adaptRenderer('switch renderer -> canvas fallback');
}

这个阈值不是凭感觉拍的,是我把 hilog 日志抓出来跑脚本分析的结果。日志里会输出每帧的 fpsframeMstickMs 以及当前渲染质量档位,降级事件会打 BF_PERF_ADAPT 标签,比如 degrade quality -> balanced 或者 switch renderer -> canvas fallback

对独立开发者来说这套日志分析挺重要——没有 QA、没有用户主动反馈卡顿,只能靠工具自己发现问题。我在没有真机的情况下,靠日志回放重现了好几个卡顿场景。

目前 Canvas 模式下动画过渡还是不如 GPU 顺滑,这个还在打磨,算是没解决干净的问题。

坑二:设计 Token 漏用——一个脚本比 code review 更可靠

App 的调性是「平静克制」,UI 上我比较在意所有间距、圆角、阴影、动画时长要统一。如果哪个地方直接写了魔法数字,整体质感就散了。

鸿蒙版我把所有设计 token 收进一个 Style.ets,导出四个命名空间:SPACERADIUSSHADOWMOTION。问题是开发过程中很容易手滑——改某个组件时直接写 borderRadius(8) 而不是 RADIUS.card,这种事我自己也干过。

所以我写了一个 check_design_foundation.py,逻辑很简单:用 path.read_text() 读取关键文件内容,用字符串匹配检查是否包含预期的 token 引用。比如检查 AppBackdrop.ets 里有没有 export struct AppBackdrop,检查 SheetBackground.ets 里有没有调用 AppBackdrop(,检查根页面 RootPage.ets 里有没有 AppBackdrop({

不是正则匹配魔法数字(那个误报太多),而是检查「关键结构是否存在」——更像一个架构约束验证器。

真实案例:有一次我重构了背景组件 AppBackdrop,改了对外接口,但忘了更新 SheetBackground 里的调用方式,就是被这个脚本拦下来的。如果没有这个检查,这个问题可能得等到真机运行时才会发现。

我还把几个类似的脚本整合进一个 check_foundation_alignment.py,统一管理:设计 token 校验、按压反馈检查、页面过渡检查、i18n 对等检查、路由检查——提交前一起跑,哪个挂了去修哪个。独立开发没有 code review,这套东西算是自己给自己兜底。

坑三:i18n 漏 key,双语维护是个持续性的低级错误

App 支持中英双语,维护四个语言文件:strings_app_en.etsstrings_app_zh-Hans.ets 以及对应的 base 版本。每次加新功能往里填 key,英文填了忘了填中文,或者反过来,这种事经常发生。

check_i18n_keys.py 做的事很直白:把四个文件里的 key 全部提取出来做集合差运算,输出「哪些 key 在英文有但中文没有」以及反向的情况。

这个脚本帮我发现过好几次漏掉的 key,有时候漏的是边缘功能的文案,有时候是一个按钮标题——后者如果漏了,用户看到的就是 key 字符串本身,很难看。

课程进度系统:本地存储是主动选择,不是偷懒

ProgramProgressRecord 记录用户在某个训练计划里完成了哪些 session、当前在第几阶段。数据全部本地存储,没有云同步。

说实话云同步我也不想做。OAuth 接入、服务器费用、隐私合规、多端数据冲突处理……这一套对独立开发者来说投入产出比太低。用户的训练记录放本地就够了,鸿蒙的 Preferences 和 RelationalStore 用起来比我预期顺手,持久化这块没遇到太大麻烦。

自定义呼吸节奏的交互,我还没想明白

用户可以自由设置吸气、屏气、呼气各阶段的时长。这个功能的交互我试了三版:滑动条、数字步进器、转盘——感觉都差点意思。滑动条精度不够,步进器操作次数太多,转盘在小屏上很难操作。

这个目前还搁着,UI 做得比较简陋。如果你做过类似的时长输入控件——尤其是整数秒精度、范围大概 1-30 秒的场景——很想听听你用了什么方案。

我做了个专注 App,把连续打卡阈值从 3/7/14 改成 2/5 之后留存明显好了

作者 SameX
2026年4月23日 09:57

起点

我在 App Store 搜「专注计时」,前十名的截图几乎一模一样:一个大圆圈倒计时,白底或深色背景,偶尔配个绿色进度环。点进去功能也差不多,计时、响铃、记录时长,结束。

作为开发者我看这些截图的第一反应是:这赛道已经死了吗?还是说用户根本不在意差异化?

我选择赌后者不成立。声境护照的核心假设是:计时工具留不住人,不是因为功能不够,而是因为「完成一次专注」这件事本身没有叙事——没有积累感,没有值得回头看的东西。所以我把每次专注包装成一段「声音旅行」:选声景、积里程、攒护照印章,结束后拿一张可以发朋友圈的战报卡片。

下面聊几个做这个 App 时真正踩过坑的技术决定。


探险系统:把定义层和状态层拆开

探险章节的数据模型是我返工次数最多的部分。

早期我把任务进度直接存在 definition 结构里,targetValueprogresscompletedAt 全塞一起。有次用户完成任务后触发回写逻辑,targetValue 被意外覆盖成了 0——因为写进度的地方用了同一个赋值路径——任务直接从列表里消失了。用户以为是 bug,其实是数据结构没设计好。

后来拆成了「定义层」和「状态层」两套结构:

// 定义层:静态配置,只读,不随用户行为变化
struct ExpeditionMissionDefinition: Identifiable, Codable {
    let id: String
    let title: String
    let kind: ExpeditionMissionKind  // sessionCount / focusMinutes / deepFocusCount
    let targetValue: Int
    let rewardMiles: Int
}

// 状态层:只存进度和完成时间戳
struct ExpeditionMissionState: Identifiable, Codable {
    let id: String
    var progress: Int
    var completedAt: Date?   // nil = 未完成
    var completed: Bool { completedAt != nil }
}

两层通过 id 关联,定义层只从远端下发,本地不写。这样之后就算服务端更新了任务内容,也不会碰用户本地的进度状态。deepFocusCount 的判定逻辑是单次时长超过 20 分钟且中途没有中断,这个阈值调了四五次才定下来,最开始设的是 15 分钟,太容易达到,用户没有「深度」的感觉。


连续天数阈值:从 3/7/14 改成 2/5

会话结束后 App 会给一条下一步建议,根据当天计划完成情况和连续打卡天数动态生成:

let streakHint: String
if store.streakDays >= 5 {
    streakHint = "你已连续 \(store.streakDays) 天,重点是稳定复用。"
} else if store.streakDays >= 2 {
    streakHint = "再坚持 1-2 天可进入稳定习惯区。"
} else {
    streakHint = "建议先连续 3 天完成每天的最低目标。"
}

早期版本阈值是 3/7/14,对应「习惯养成」里常见的节点说法。结果我发现第一个门槛「再坚持 4 天」对新用户压力很大——刚用第一天,看到这句话心理上已经开始算成本了。

改成 2/5 之后,第一个提示变成「再坚持 1-2 天」,心理距离近了很多。我没有大样本数据来证明这个改动有多少提升,但从我自己用和几个测试用户的反馈来看,看到「1-2 天」比看到「4 天」更容易当天再开一次计时。

说白了就是:第一个里程碑要近到「今晚就能拿到」。


统计页的 Demo 模式

新用户第一次打开,专注记录为空,统计页一片白——这是工具类 App 最难看的冷启动体验。

处理方式是:focusLogs 为空时用 StatsService.createDemoFocusLogs() 生成假数据填充,同时打 isDemo: true 标记,UI 层显示「这是示例数据」的提示。几乎所有 ViewModel 都是这一行:

private var logs: [FocusLog] {
    store.focusLogs.isEmpty
        ? StatsService.createDemoFocusLogs()
        : store.focusLogs
}

这个方案有个明显缺陷:demo 数据是静态写死的,不会根据时区或当前时间调整,周一打开看到的「本周统计」热力图和周五打开是一样的。这是我已知的技术债,下个版本会改成基于当前时间动态生成。但在真实数据出现之前,给用户看一个「满血状态」的统计页,比空白页的跳失率要低——这是我在几个类似工具上观察到的规律,所以先凑合用着。


分享卡片:为什么最终选了 SwiftUI 截图方案

会话结束后可以导出一张数据卡片:时长、效率指数、声景名、里程和等级称号。这个功能我试了三个方案。

Core Graphics 手绘:可控性最高,但每次改卡片样式要同时维护 UI 代码和绘制代码两套,改了一个忘了另一个,有次导出的卡片和 App 里显示的样式差了半个版本,挺尴尬的。

WKWebView 渲染 HTML:样式灵活,服务端可以随时更新模板,但首次渲染有明显延迟,用户点「生成卡片」之后要等将近一秒才出图,这个等待感在分享场景里特别割裂。

最后选了 SwiftUI 视图截图:UI 和卡片共用同一套组件,改一处两边同步,维护成本低。代价是在部分低端设备上截图后文字抗锯齿发虚,看起来不如原生渲染清晰。我接受这个取舍——大多数用户用的是近三年的机型,发虚的问题不常见。


卡在一个问题上,想听听大家的看法

App 现在刚上线,还在冷启动阶段。我目前卡在一个判断上:「护照 + 里程」这套叙事,对重度效率用户来说会不会显得幼稚?

我身边用这类工具的人大概分两种:一种要的是纯粹的效率,恨不得界面越简单越好;另一种喜欢打卡晒图,仪式感对他们来说本身就是动力。声境护照明显是为第二种人做的,但我不确定第一种人会不会因为「太花哨」直接关掉。

你们做工具类 App 的时候,怎么处理这两类用户的需求冲突?或者说,你们自己用专注工具,更看重哪一面?

删掉ML推荐、砍掉五时段分析——做专注App时我三次推翻自己,换来了什么

作者 SameX
2026年4月19日 19:55

Forest、Toggl、OmniFocus、Structured都用过,最后用得最久的反而是系统自带的计时器。不是这些App不好,是用完就完了——那些数字消失在列表里,感觉不到自己在积累什么。

带着这个不满,我做了声境护照:一个把每次专注包装成「出发」的 iOS App,有声景、有里程、有战报卡片。核心问题是:怎么让坚持本身变得值得被记录,甚至被分享。

下面聊三次推翻自己的决定,每次都有点难受,但推翻之后换来的东西我觉得是对的。

第一次推翻:里程任务维度从七个砍到三个

最早设计里程任务系统时,我列了七个维度:完成段数、累计分钟、深度专注次数、连续天数、特定时间段专注、周内完成率、任务完成比例……看起来覆盖全面。

最后砍到三类:sessionCount(完成段数)、focusMinutes(累计分钟)、deepFocusCount(深度专注次数)。

enum ExpeditionMissionKind: String, Codable {
    case sessionCount
    case focusMinutes
    case deepFocusCount
}

struct ExpeditionMissionDefinition: Identifiable, Codable, Equatable {
    let id: String
    let title: String
    let kind: ExpeditionMissionKind
    let targetValue: Int
    let rewardMiles: Int
}

说一下「深度专注」的判定:连续 25 分钟以上、中途没有切出 App,才算一次 deepFocusCount。没有复杂的中断检测算法,就是这一条规则。

streakDays 本来也在候选里,后来发现它和全局连续打卡逻辑完全重叠——去掉之后刚好三类。这个「三类用户」的判断不是什么严谨调研,是我在早期内测群里发了一个很简陋的问卷,问大家「你觉得自己是哪种专注习惯」,回收了二十几份,粗略分出了喜欢短频快的、偏好长沉浸的、在意质量的三个方向,和这三个维度基本能对上。多了就是噪音,少了又覆盖不到,凑巧。

第二次推翻:ML 方案和五时段分析,全扔掉了

会话结束后给用户推荐「下一次」的时间、时长、声景——这个功能我试了三版。

第一版想用 Core ML 做简单时间序列预测,根据历史专注时段推测用户下次最高效的时间窗口。原型做到一半卡死:新用户没有历史数据,冷启动完全没法用。整个方案扔掉。

第二版换成规则,但规则做复杂了。把一天分成早晨/上午/下午/傍晚/夜间五个时段,分析用户在各时段的完成率和平均时长,生成一段带时段标签的推荐文案。

我找了内测的 8 个用户,用 Screen Recording 录屏后逐个掐表,用户在战报页平均停留不到 4 秒,而这段推荐文案大概需要 6 秒才能读完。时间对不上,文案再准确也是废的。

第三版把规则压缩到三层判断:

func nextActionAdvice(taskCompleted: Bool) -> SessionNextActionAdvice {
    let remainingTodayPlan = max(0, store.weeklyPlanTodayTargetSegments
                                    - store.weeklyPlanTodayActualSegments)
    let streakHint: String
    if store.streakDays >= 5 {
        streakHint = "你已连续 \(store.streakDays) 天,重点是稳定复用。"
    } else if store.streakDays >= 2 {
        streakHint = "再坚持 1-2 天可进入稳定习惯区。"
    } else {
        streakHint = "建议先连续 3 天完成每日最小闭环。"
    }
    // 日计划缺口 + 任务状态 + 连续天数,三句话拼完
}

今天计划还差几段、当前任务完没完、连续天数处于哪个阶段——三个判断,输出一两句话。用户扫一眼就走,刚好够用。说白了,「推荐系统」这四个字很容易把自己往复杂方向带,但用户真正需要的可能只是一个不需要思考的下一步提示。

第三次推翻:Demo 数据到底算不算欺骗用户

新用户第一次打开 App,统计页空白,成长页空白,护照里没有任何印记。我做了个有点争议的决定:数据为空时用 StatsService.createDemoFocusLogs() 填充演示数据,界面上标注「示例」。

「有点像造假」——这个疑虑我自己也有。我发了两批内测链接,各 10 人左右,一批是空白状态版,一批是带 Demo 数据版。空白版里大概 2 个人完成第一次专注后还回来用过第二次,Demo 版里差不多 6 个人。样本很小,不算严格测试,但这个差距让我觉得方向是对的。

我猜原因是 Demo 数据让用户在真正开始之前就理解了「完成之后我会看到什么」,降低了对未知的不确定感。标注「示例」是底线,不能省。但空白状态本身也不是什么「诚实」,它只是让用户更快放弃。

附:分享卡片里差点漏掉的小事

用户可以生成三种卡片:单次战报、周回顾、成就徽章。卡片的核心价值是:用户发出去,别人看到,顺手搜一下 App 名字。

有个内测用户截图给我看,说「卡片上的日期格式和你 App 里别的地方不一样」。我才意识到战报卡用的是 yyyy/MM/dd HH:mm,成就徽章用的是 MM/dd/yyyy——就这一个格式不统一,卡片看起来像拼凑的,掉价。统一之后是小事,但没统一之前印象分直接打折。

周回顾的分享文案用 StatsService.buildStatsShareText 动态生成,包含当周里程增量、高峰专注时段、完成段数,每周输出不一样。固定模板发两次用户就能背出来了,不值得。

现在的状态和一个真实困惑

App 刚上线,下载量还在爬坡。三次推翻自己带来了什么,现在看到的结果是:内测阶段第 7 天留存从最早版本的大概 15% 爬到了 40% 出头,不算亮眼,但每次推翻之后都能往上走一点,这让我觉得这些决定没白费。鸿蒙版在做,雷达界面可以拖拽「音效球」混音调声场,ArkTS 的手势处理比 UIKit 繁琐不少,但逼出了一个新交互,反倒成了差异点。

三次推翻都是我自己逼自己,复盘时看数据、看录屏、发问卷——但我知道自己的盲区越来越大,靠自己否定自己能走的路越来越短。现在需要外力了。如果你做过工具类 App,怎么找到愿意认真挑毛病的那批用户的?鼓励听着舒服,但对改产品帮助不大,这事儿我有点卡。

春节期间独立开发者从 0 到 1:呼吸训练 iOS App 的工程化落地

作者 SameX
2026年2月24日 16:33

项目:呼吸视界(iOS 已上架)
技术栈:SwiftUI + SwiftData + StoreKit 2 + WidgetKit + ActivityKit

各位新年快乐,春节期间体验到了人挤人,车挤车,闲来无事撸了个一直想做的APP,分享点技术心得大家共勉。

1. 架构目标:把“训练体验”和“增长闭环”同时做出来

这个项目不是只做一个呼吸动画,而是做一条完整链路:

  • 训练引擎:稳定跑节奏(吸气/停顿/呼气)
  • 多感官反馈:视觉 + 音频 + 触觉一致
  • 习惯闭环:课程进度、训练记录、分享卡片
  • 增长入口:提醒、Widget、Live Activity、深链
  • 商业化:订阅、恢复购买、权益门控

核心分层:

  • 状态中枢:breathing-iOS/breathing/Domain/AppStore.swift
  • 页面编排:breathing-iOS/breathing/UI/RootView.swift
  • 能力引擎:breathing-iOS/breathing/Engines/*
  • 数据模型:breathing-iOS/breathing/Data/*
  • 外部触达:breathing-iOS/breathingWidget/* + BreathingLiveActivityManager

2. 单一状态中枢:AppStore 统一收口

AppStore@MainActor + ObservableObject 统一管理业务状态,避免“每个页面自己存一份状态”。

@MainActor
final class AppStore: ObservableObject {
    @Published var activeMode: BreathingMode
    @Published var activeDuration: Int
    @Published var isPro: Bool
    @Published var settings: AppSettings
    @Published var soundEnabled: Bool
    @Published var soundscapeId: String

    let breathingEngine: BreathingEngine
    let hapticsEngine: HapticsEngine
    private let soundscapePlayer = SoundscapePlayer()
    private let liveActivityManager = BreathingLiveActivityManager()
}

同时把订阅商品 ID 固定在内部,避免散落字符串:

private enum ProProductID {
    static let monthly = "com.xun.breathing.pro.monthly"
    static let yearly = "com.xun.breathing.pro.yearly"
    static let all = [monthly, yearly]
}

收益:UI 层只绑定状态,不再承担复杂业务判断;后续加模式/加权益不会牵一发而动全身。


3. 训练引擎:状态机 + 双 Task 保证节奏稳定

BreathingEngine 的关键是“阶段推进”和“总时长倒计时”分离:

@MainActor
final class BreathingEngine: ObservableObject {
    @Published private(set) var phase: BreathPhase = .ready
    @Published private(set) var isPlaying: Bool = false
    @Published private(set) var timeRemaining: Int

    private var cycleTask: Task<Void, Never>?
    private var countdownTask: Task<Void, Never>?
    private var sessionId = UUID()
}

启动时并行两条异步任务:

func start() {
    guard !isPlaying else { return }
    isPlaying = true
    sessionId = UUID()
    timeRemaining = duration

    runCountdown(sessionId: sessionId)
    switch courseType {
    case .standard:
        runBreathingLoop(sessionId: sessionId)
    case .wimHof(let config):
        runWimHofSession(sessionId: sessionId, config: config)
    }
}

倒计时任务只做一件事:

private func runCountdown(sessionId: UUID) {
    countdownTask?.cancel()
    countdownTask = Task { [weak self] in
        guard let self else { return }
        while !Task.isCancelled {
            try? await Task.sleep(nanoseconds: 1_000_000_000)
            guard sessionId == self.sessionId, self.isPlaying else { return }
            self.timeRemaining = max(0, self.timeRemaining - 1)
            if self.timeRemaining <= 0 {
                self.completeSession()
                return
            }
        }
    }
}

收益:暂停/恢复/切模式时行为稳定,不会出现“相位跳变”或“倒计时错乱”。


4. 音景引擎:缓存 + 淡入淡出,解决听感跳变

音频引擎里最关键是三点:

  • bufferCache:避免每次重新解码 mp3
  • fadeIn/fadeOut:切换音景不突兀
  • updatePlayback:统一播放入口(按 isPlaying/isEnabled 决策)
func updatePlayback(isPlaying: Bool, isEnabled: Bool, soundscapeId: String) {
    guard isEnabled, isPlaying else {
        stop()
        return
    }
    play(soundscapeId)
}

private func loadBuffer(for soundscape: Soundscape) -> AVAudioPCMBuffer? {
    if let cached = bufferCache[soundscape.id] { return cached }
    guard let url = Bundle.main.url(forResource: soundscape.fileName, withExtension: "mp3") else { return nil }
    do {
        let file = try AVAudioFile(forReading: url)
        let frameCount = AVAudioFrameCount(file.length)
        guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: frameCount) else { return nil }
        try file.read(into: buffer)
        bufferCache[soundscape.id] = buffer
        return buffer
    } catch {
        return nil
    }
}

停止时先淡出再停引擎:

fade(to: 0, duration: fadeOutDuration) { [weak self] in
    self?.stopNow(resetSession: resetSession)
}

5. 通知提醒:权限、频率、撤销一体化

提醒模块用 UNUserNotificationCenter,重点是“配置即覆盖”而不是“叠加创建”。

func configure(enabled: Bool, minutes: Int, frequency: ReminderFrequency) async -> Bool {
    if !enabled {
        cancel()
        return true
    }
    let allowed = await requestAuthorizationIfNeeded()
    guard allowed else {
        cancel()
        return false
    }
    schedule(minutes: minutes, frequency: frequency)
    return true
}

按周频次时生成固定 ID,方便后续精确取消:

let id = "\(weekdayPrefix)\(weekday)"
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
center.add(request, withCompletionHandler: nil)

6. Live Activity:状态去重,避免无效刷新

Live Activity 不是“每帧都更新”,而是先比对状态,只有变化才推送:

func update(state: BreathingLiveActivityAttributes.ContentState) {
    guard #available(iOS 16.1, *) else { return }
    guard let activity else { return }
    guard state != lastState else { return }
    lastState = state
    Task {
        let content = ActivityContent(state: state, staleDate: nil)
        await activity.update(content)
    }
}

收益:减少无意义更新,降低系统开销。


7. 数据闭环:训练记录 + 课程进度

7.1 会话记录模型(SwiftData)

@Model
final class SessionRecord {
    var id: UUID
    var timestamp: Date
    var modeId: String
    var courseId: String?
    var programId: String?
    var programDay: Int?
    var duration: Int
    var preCheckin: String?
    var postCheckin: String?
}

preCheckin/postCheckin 让“训练前后变化”可追踪,这是后续留存和转化分析的基础字段。

7.2 课程进度推进

static func nextDayIndex(program: BreathingProgram, record: ProgramProgressRecord?) -> Int? {
    let completed = Set(record?.completedDays ?? [])
    for index in program.plan.indices {
        if !completed.contains(index) {
            return index
        }
    }
    return nil
}

这个实现很朴素,但稳定,且便于后续做“断点继续”。


8. Widget 深链:缩短回流路径

Widget 直接绑定深链,用户从桌面可一跳进入训练:

private let quickURL = URL(string: "breathing://start?type=quick")!
private let emergencyURL = URL(string: "breathing://start?type=emergency")!

这比“打开 App -> 选模式 -> 开始”少至少 2 步,对高频场景(焦虑急救/会前调整)很关键。


9. 订阅链路:StoreKit 2 的最小闭环

关键流程:拉商品 -> 发起购买 -> 校验交易 -> finish -> 刷新权益。

func purchaseSelectedProduct() async {
    guard let product = selectedProduct else { return }
    let result = try await product.purchase()
    switch result {
    case .success(let verification):
        let transaction = try checkVerified(verification)
        await transaction.finish()
        await refreshSubscriptionStatus()
    case .pending, .userCancelled:
        break
    @unknown default:
        break
    }
}

恢复购买也单独兜底:

try await StoreKit.AppStore.sync()
await refreshSubscriptionStatus()

10. 工程复盘:最值得复用的 4 个点

  1. 状态收口AppStore 统一管理跨页面状态。
  2. 节奏分治:阶段循环和倒计时分为两条 Task。
  3. 增长内建:提醒/Widget/Live Activity 不是后补功能,而是留存系统。
  4. 数据先行:从第一天就保留训练前后字段,后续分析成本最低。

后记:APP已经上架,某书上反响还不错,赚钱是次要的,主要产品有人用,技术有积累就很开心啦。 体验链接:apps.apple.com/cn/app/%E5%… PS:要兑换码好说,哈哈~

7e175ace-ca50-4f8f-8f0f-fbc0a82eecd7.jpg

cbebea05-dd13-4128-bf5e-06d7dce991d5.jpg

❌
❌