技术实现

这一节写给同行:真正花时间的地方在哪,为什么这么选,以及踩过的坑。

iOS · watchOS · Objective-C / UIKit · Core Data
最难的地方

最难的是把云容器的冷启动藏起来

AI 服务跑在会缩容到零的云容器上,用户点开对话页很可能撞上几秒的冷启动。这个延迟没法消除,只能提前发生:回到前台、切换标签、进入对话页三个时机都触发一次免鉴权的健康检查把容器叫醒,并发的预热请求做单飞去重,成功后 60 秒内认为是热的。发送侧加一道门,容器没热时最多等约 1.5 秒就放行——宁可让请求自己去撞冷启动,也不能让用户对着一个不响应的输入框干等。

技术难点

10 条,每条都是实际改过代码的地方。

  1. 预热门控与重试机会的分配

    问题
    把预热和发送耦合起来后,一个容易踩的坑是重试次数叠加:容器冷启动返回 5xx 重试一次,Token 过期 401 刷新后又重试一次,两者叠在一起就变成了对同一条消息发三次请求。
    解法
    让这两种重试共享同一次机会,而不是各记各的。健康检查自身超时设 10 秒,状态收敛到主队列避免竞态;门为空时退化成立即发送,这样测试里可以直接注入一个假的门。
    结果
    单元测试专门覆盖了「门没放行前一个 SSE 请求都不能发出去」和「冷启动服务端错误只重试一次」两条。
    • 冷启动
    • 单飞去重
    • 重试策略
  2. SSE 流式的错误收口

    问题
    流式接口的失败姿势比普通请求多得多:HTTP 层可能 400 / 401 / 404 / 5xx,也可能 HTTP 200 但事件流里塞了一个 error 帧,还可能连到一半断掉。任何一种没接住,界面就会卡在「正在输入」。
    解法
    传输层把各类 HTTP 状态映射成明确的错误类型,同时把 200 响应里的 error 事件也按失败收口,正常结束认 data: [DONE] 标记。解析器单独一层,只负责把字节流切成增量事件。
    结果
    解析、传输、服务三层各自可测;测试覆盖了每种状态码映射、200 内错误帧、正常增量与结束标记。
    • SSE
    • 流式解析
    • 错误处理
  3. 一千行 Objective-C 手写的呼吸动画

    问题
    想要天空和水面随呼吸涨落、文字穿过水面时颜色自然切换、波纹随时间逐渐平静。这类效果没有现成的库能直接用。
    解法
    两个 CAGradientLayer 分别画天空和水面,中间一条 CAShapeLayer 画正弦地平线 y = A·sin(ωt + kx),振幅只给 5pt。水面下叠三层波纹,频率 1.5 / 2.3 / 3.1、相位错开 120°,避免看起来整齐划一。
    结果
    全程没有引入任何第三方动画库。
    • Core Animation
    • CAShapeLayer
    • 正弦波
  4. 每帧改 path 会被隐式动画糊掉

    问题
    CALayer 的 path、frame 这类属性一改就自带 0.25 秒过渡。逐帧更新的场景下,这个「贴心」行为会让画面糊成一团。
    解法
    把一帧内的所有属性变更包进 CATransaction,并显式关掉隐式动画。
    结果
    同一个事务机制后来还用在了主题切换上——那里反过来需要控制过渡时长。
    • CATransaction
    • Core Animation
  5. 文字穿过水面时要换颜色

    问题
    引导文字会从水面升进天空。浅色模式下天空很亮,白字看不清;但深色字放在暗色水面上同样看不清。
    解法
    把每句文字做成两份叠在一起,一份深色一份浅色,各自用 CAShapeLayer 做遮罩,分别只在天空区域和水面区域可见。每帧按地平线位置更新遮罩矩形。
    结果
    踩过的坑:遮罩的 path 是 label 自己的坐标系,必须先做一次坐标转换,否则遮罩位置整个错开。文字穿越时还刻意用了更快的缓动曲线——穿得慢会暴露两份文字的拼接痕迹。
    • layer.mask
    • 坐标转换
    • 视觉细节
  6. 用 CADisplayLink 而不是 NSTimer

    问题
    NSTimer 基于 RunLoop 调度,和屏幕刷新不同步,可能一帧触发两次也可能跳帧,画面会抖。
    解法
    改用 CADisplayLink 绑到 VSync,60Hz 下每 16.6ms 回调一次,用它的 duration 推进状态机、timestamp 喂给正弦波。注册时用 NSRunLoopCommonModes,否则用户一滑动列表动画就停了。
    结果
    缓动用三次贝塞尔曲线,靠牛顿迭代反求参数,迭代 8 次收敛,逐帧调用也不吃性能。
    • CADisplayLink
    • RunLoop
    • 缓动曲线
  7. Objective-C 项目里用 Swift-only 的库

    问题
    Markdown 渲染想用 Down,但它是纯 Swift 库,OC 代码没法直接 import。
    解法
    用 Swift 写一层继承 NSObject、标了 @objc 的渲染器包住它,OC 侧 import 编译期生成的桥接头调用。
    结果
    踩过的坑:Down 依赖的 libcmark 会和显式模块打架,需要把 SWIFT_ENABLE_EXPLICIT_MODULES 关掉才能链接通过。
    • OC/Swift 混编
    • 桥接头
    • 构建配置
  8. 双击标签进冥想,但不能影响正常切换

    问题
    想让冥想标签双击进入另一个页面,同时单击还要正常切换标签,两者不能打架。
    解法
    在 shouldSelectViewController: 里先判双击:命中就播圆形揭露转场并返回 NO 把这次点击消费掉,标签不切换;没命中就放行,顺便触发一次 AI 预热。didSelectViewController: 只负责切换后的触觉反馈,不管预热状态。
    结果
    预热的去重交给网络层,标签层不需要知道容器现在热不热。
    • UITabBarController
    • 转场动画
    • 职责边界
  9. 记录先落本地,再批量补传

    问题
    呼吸、冥想、心情都是随手产生的记录,如果每条都等网络回来才算成功,断网时体验就断了。
    解法
    写入直接落 Core Data,另开一条批量同步路径把未同步的记录一起上传,上传成功再标记已同步。
    结果
    关键是同步标记要写对,否则会重复上传。持久化容器用的是 NSPersistentCloudKitContainer,改 schema 只能轻量迁移或只加字段。
    • Core Data
    • CloudKit
    • 离线优先
  10. 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 或超时自动重试一次。