最难的是把云容器的冷启动藏起来
AI 服务跑在会缩容到零的云容器上,用户点开对话页很可能撞上几秒的冷启动。这个延迟没法消除,只能提前发生:回到前台、切换标签、进入对话页三个时机都触发一次免鉴权的健康检查把容器叫醒,并发的预热请求做单飞去重,成功后 60 秒内认为是热的。发送侧加一道门,容器没热时最多等约 1.5 秒就放行——宁可让请求自己去撞冷启动,也不能让用户对着一个不响应的输入框干等。
技术难点
共 10 条,每条都是实际改过代码的地方。
预热门控与重试机会的分配
- 问题
- 把预热和发送耦合起来后,一个容易踩的坑是重试次数叠加:容器冷启动返回 5xx 重试一次,Token 过期 401 刷新后又重试一次,两者叠在一起就变成了对同一条消息发三次请求。
- 解法
- 让这两种重试共享同一次机会,而不是各记各的。健康检查自身超时设 10 秒,状态收敛到主队列避免竞态;门为空时退化成立即发送,这样测试里可以直接注入一个假的门。
- 结果
- 单元测试专门覆盖了「门没放行前一个 SSE 请求都不能发出去」和「冷启动服务端错误只重试一次」两条。
- 冷启动
- 单飞去重
- 重试策略
SSE 流式的错误收口
- 问题
- 流式接口的失败姿势比普通请求多得多:HTTP 层可能 400 / 401 / 404 / 5xx,也可能 HTTP 200 但事件流里塞了一个 error 帧,还可能连到一半断掉。任何一种没接住,界面就会卡在「正在输入」。
- 解法
- 传输层把各类 HTTP 状态映射成明确的错误类型,同时把 200 响应里的 error 事件也按失败收口,正常结束认 data: [DONE] 标记。解析器单独一层,只负责把字节流切成增量事件。
- 结果
- 解析、传输、服务三层各自可测;测试覆盖了每种状态码映射、200 内错误帧、正常增量与结束标记。
- SSE
- 流式解析
- 错误处理
一千行 Objective-C 手写的呼吸动画
- 问题
- 想要天空和水面随呼吸涨落、文字穿过水面时颜色自然切换、波纹随时间逐渐平静。这类效果没有现成的库能直接用。
- 解法
- 两个 CAGradientLayer 分别画天空和水面,中间一条 CAShapeLayer 画正弦地平线 y = A·sin(ωt + kx),振幅只给 5pt。水面下叠三层波纹,频率 1.5 / 2.3 / 3.1、相位错开 120°,避免看起来整齐划一。
- 结果
- 全程没有引入任何第三方动画库。
- Core Animation
- CAShapeLayer
- 正弦波
每帧改 path 会被隐式动画糊掉
- 问题
- CALayer 的 path、frame 这类属性一改就自带 0.25 秒过渡。逐帧更新的场景下,这个「贴心」行为会让画面糊成一团。
- 解法
- 把一帧内的所有属性变更包进 CATransaction,并显式关掉隐式动画。
- 结果
- 同一个事务机制后来还用在了主题切换上——那里反过来需要控制过渡时长。
- CATransaction
- Core Animation
文字穿过水面时要换颜色
- 问题
- 引导文字会从水面升进天空。浅色模式下天空很亮,白字看不清;但深色字放在暗色水面上同样看不清。
- 解法
- 把每句文字做成两份叠在一起,一份深色一份浅色,各自用 CAShapeLayer 做遮罩,分别只在天空区域和水面区域可见。每帧按地平线位置更新遮罩矩形。
- 结果
- 踩过的坑:遮罩的 path 是 label 自己的坐标系,必须先做一次坐标转换,否则遮罩位置整个错开。文字穿越时还刻意用了更快的缓动曲线——穿得慢会暴露两份文字的拼接痕迹。
- layer.mask
- 坐标转换
- 视觉细节
用 CADisplayLink 而不是 NSTimer
- 问题
- NSTimer 基于 RunLoop 调度,和屏幕刷新不同步,可能一帧触发两次也可能跳帧,画面会抖。
- 解法
- 改用 CADisplayLink 绑到 VSync,60Hz 下每 16.6ms 回调一次,用它的 duration 推进状态机、timestamp 喂给正弦波。注册时用 NSRunLoopCommonModes,否则用户一滑动列表动画就停了。
- 结果
- 缓动用三次贝塞尔曲线,靠牛顿迭代反求参数,迭代 8 次收敛,逐帧调用也不吃性能。
- CADisplayLink
- RunLoop
- 缓动曲线
Objective-C 项目里用 Swift-only 的库
- 问题
- Markdown 渲染想用 Down,但它是纯 Swift 库,OC 代码没法直接 import。
- 解法
- 用 Swift 写一层继承 NSObject、标了 @objc 的渲染器包住它,OC 侧 import 编译期生成的桥接头调用。
- 结果
- 踩过的坑:Down 依赖的 libcmark 会和显式模块打架,需要把 SWIFT_ENABLE_EXPLICIT_MODULES 关掉才能链接通过。
- OC/Swift 混编
- 桥接头
- 构建配置
双击标签进冥想,但不能影响正常切换
- 问题
- 想让冥想标签双击进入另一个页面,同时单击还要正常切换标签,两者不能打架。
- 解法
- 在 shouldSelectViewController: 里先判双击:命中就播圆形揭露转场并返回 NO 把这次点击消费掉,标签不切换;没命中就放行,顺便触发一次 AI 预热。didSelectViewController: 只负责切换后的触觉反馈,不管预热状态。
- 结果
- 预热的去重交给网络层,标签层不需要知道容器现在热不热。
- UITabBarController
- 转场动画
- 职责边界
记录先落本地,再批量补传
- 问题
- 呼吸、冥想、心情都是随手产生的记录,如果每条都等网络回来才算成功,断网时体验就断了。
- 解法
- 写入直接落 Core Data,另开一条批量同步路径把未同步的记录一起上传,上传成功再标记已同步。
- 结果
- 关键是同步标记要写对,否则会重复上传。持久化容器用的是 NSPersistentCloudKitContainer,改 schema 只能轻量迁移或只加字段。
- Core Data
- CloudKit
- 离线优先
Token 只进 Keychain,还要兼容历史存储
- 问题
- 早期版本的 Token 存在两套不同的位置,直接切换会让老用户被登出。
- 解法
- 鉴权请求优先用当前内存会话的 Token,取不到再依次兼容两套历史 Keychain 存储;敏感数据一律不进普通偏好存储。
- 结果
- 测试用自定义 session 配置注入 Stub URLProtocol,把请求契约、业务错误码、Token 刷新重试和历史兼容都覆盖住了。
- Keychain
- 向后兼容
- 可测试性
工程实践
不构成难点,但决定了这个项目好不好继续写下去。
按模块写记忆文档
每个模块目录下有一份 memory.md 交代职责、文件清单和内部约定,根文件只留全局概览和跨模块约定。改哪个模块先读哪份,不用把整个工程加载进脑子。
面向协议而不是实现
对话页只认一个流式协议,真正的实现来自网络层;测试里换成 Mock 客户端就能跑。页面不持有 SSE 客户端,也不绕过发送门。
134 项单元测试
AI 对话是测试主战场,12 个测试文件覆盖枚举、消息模型、输入栏、协议一致性、Markdown 渲染、请求构建、SSE 客户端与解析、服务门控与重试、预热管理器。
主题抽出去,View 层零改动
呼吸动画的配色由一个主题对象统一管理并跟随系统外观切换。加一套新主题只要多写一个工厂方法。
Watch 端不引第三方依赖
手表端保持纯 SwiftUI,包体和启动速度都更可控;与手机端通过 WatchConnectivity 双向通信。
技术栈
双端与服务端的分层。
Objective-C + UIKit
iPhone 端全部用 Objective-C 写,布局统一走 Masonry;四标签框架自己实现了双击检测与圆形揭露转场。
Core Data + CloudKit
四个实体承载用户、心情、呼吸与冥想记录;写入先落本地再批量同步,断网也不丢。
watchOS 与 OC/Swift 混编
Watch 端是 Swift + SwiftUI,与 iPhone 端通过桥接头互通;AI 的 Markdown 渲染器也是 Swift 实现。
SSE 流式与冷启动预热
回到前台、切标签、进入对话页都会触发预热请求,单飞去重;发送侧等门控就绪最多约 1.5 秒,遇 5xx 或超时自动重试一次。