时间市场

技术实现

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

iOS 18+ · SwiftUI · SwiftData · EventKit
最难的地方

最难的不是某个算法,是本地数据与系统日历的一致性

SwiftData 和 EventKit 是两套互不知情的存储。移动或删除一个时间块时,既要更新本地模型,也要处理对应的 EKEvent;EventKit 事件 ID 存在时间块上。写操作因此收口到统一操作层,失败显式返回给界面,成功后再重跑冲突检测,避免各个 ViewModel 各写一套逻辑。

技术难点

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

  1. 删除时的级联顺序

    问题
    SwiftData 的 deleteRule: .cascade 会自动删掉任务关联的时间块,但它管不到 EventKit——日历事件是系统外部数据,不在它的对象图里。
    解法
    删任务前先遍历它所有的时间块,逐个调用日历服务移除对应事件,再让 cascade 接管 SwiftData 的清理。顺序不能反:先删任务的话 cascade 立刻触发,时间块没了,就再也拿不到日历事件 ID 了。
    结果
    取消任务和删除任务两条路径共用同一套顺序;EventKit 删除失败会显式抛出,不再假装整个操作已成功。
    • SwiftData
    • EventKit
    • 数据一致性
  2. 本地自然语言解析

    问题
    建任务是核心路径,如果必须等待远程服务,网络和服务配置就会变成用户无法输入的单点。
    解法
    稳定路径使用本地解析器,识别「半小时」「明天下午」「周三」这类中文表达,把标题、时长、截止日与优先级抽出来。
    结果
    解析不依赖远程接口,用户离线也能建立任务,并在草稿中确认解析后的字段。
    • 本地 NLP
    • 离线可用
    • 可确认草稿
  3. 排程引擎的三个算法

    问题
    要在一堆日历事件的缝隙里,按截止日和优先级把任务塞进去,还要保证塞完不打架。
    解法
    拆成三个纯函数引擎。空闲查找用游标扫描,关键是游标推进写成 currentStart = max(currentStart, eventEnd) 而不是直接赋值——嵌套事件(B 完全包在 A 里面)时游标不会回退,否则会凭空多出一段错误的空闲时间。排程用贪心,取 min(剩余时长, 空档长度, 专注块上限) 三者最小,超长任务自动被拆成多个块,调用方不用管。冲突检测排序后利用单调性早终止:一旦 j 的开始时间不早于 i 的结束时间,后面的 j 都不可能重叠,直接 break。
    结果
    三个引擎都不依赖网络也不依赖框架,是可以单独跑单元测试的纯 Swift。
    • 算法
    • 游标扫描
    • 贪心
    • 早终止
  4. 技术预览:MCP 风格的工具注册表

    问题
    为远程 Agent 技术预览准备客户端能力时,如果把业务操作一个个硬编码进网络层,以后换模型或加能力都要大改。
    解法
    先把业务逻辑收进一个门面层,再用工具注册表把它们包成带 JSON Schema 的工具定义,参数类型和必填字段都由 schema 约束,统一走一个 executeJSON 入口分发。服务端要什么能力,注册表导出什么定义。
    结果
    客户端的工具边界可独立测试;这是预览架构的可扩展性,不等于远程 Agent 已稳定上线。
    • MCP
    • JSON Schema
    • 可扩展性
  5. 一个参数表达三种语义

    问题
    更新任务时,「不修改截止日」「清除截止日」「改成某一天」是三件不同的事,但只有一个参数位。
    解法
    用 Date?? 双重可选:不传是不修改,传 .some(nil) 是清除,传 .some(date) 是更新。
    结果
    接口不用为了区分「没传」和「传了空」再加一个布尔开关。
    • Swift
    • API 设计
  6. 技术预览:WebSocket Agent 的会话状态机

    问题
    一轮 Agent 会话要来回好几趟:启动会话、服务端发起工具调用、客户端执行完回传结果、可能还要停下来问用户一句、最后才是结论。乱序或者重复的消息会让状态错乱。
    解法
    把会话状态显式建模成状态机并校验每次转移是否合法,非法转移直接报错而不是带着脏状态往下跑。首条消息设 30 秒超时,传输层只接受文本帧,协议字段全用 snake_case 与服务端对齐。
    结果
    预览界面只消费一条事件流(会话开始、工具活动、回复、询问、完成、取消、失败),客户端协议逻辑可被单元测试;远程服务仍不列为稳定能力。
    • WebSocket
    • 状态机
    • 协议设计
  7. 拖到屏幕边缘要自动滚动

    问题
    在时间轴上拖时间块到屏幕边缘时,需要让底下的滚动视图跟着滚。SwiftUI 的 animation 没法被手势打断,Timer 的精度又不够,两个都不合适。
    解法
    用 CADisplayLink 跟屏幕刷新同步,每帧算一次速度:proximity = 1 - 手指到边缘的距离 / 80pt,速度 = 最大速度 × proximity²。用平方而不是线性,靠近边缘时是平滑加速而不是突然窜出去。
    结果
    CADisplayLink 要求 @objc 的 NSObject 目标,而管理器是 Swift 的 @Observable 类,中间加了个包装器桥接——这是 Swift 与 Objective-C 互操作的经典问题。
    • CADisplayLink
    • 手势
    • Swift/ObjC 互操作
  8. 一个任务拆成多个时间块

    问题
    三小时的任务不可能一口气排进一个空档,得拆。但拆完之后「这个任务还剩多少没排」就成了一个容易写错的状态。
    解法
    不存这个状态,改成算:剩余时长 = 预估时长 − 已排块时长之和。每次排程前读一次。
    结果
    已排 90 分钟的三小时任务,下次自动只排剩下的 90 分钟。一致性由计算属性保证,没有需要手动维护的进度字段。
    • 数据建模
    • 计算属性
  9. 锁定与排不下,都是给用户的信号

    问题
    自动排程如果覆盖掉用户手动调过的安排,那这个功能就是负分。任务排不下时如果静默丢弃,用户会以为系统吃了他的任务。
    解法
    手动拖过的块标记来源为手动放置并锁定,重排时跳过。当天塞不下的任务标为超载并单独在界面上列出来。
    结果
    系统不替用户做决定,只把「今天满了,这几个你决定推迟还是删掉」这个信息给到位。
    • 产品设计
    • 状态建模

工程实践

不构成难点,但决定了这个项目好不好继续写下去。

  • @MainActor 全程单线程

    数据层和视图层都在主 actor 上,配合 @Observable 不需要加锁,也不会有数据竞争;引擎层是纯函数,天然线程安全。不靠 DispatchQueue.main.async 打补丁。

  • 依赖延迟注入

    数据仓库是个空壳单例,容器由 App 入口注入。测试时传一个 isStoredInMemoryOnly 的容器进去,业务代码零改动,也不会污染真实数据库。

  • 枚举存 rawValue 并兜底

    SwiftData 底层还是 Core Data,枚举不能直接持久化,用字符串存再由计算属性转回来。转换失败兜底到默认值——以后加了新枚举值,旧库里的未知值不会让 App 崩,只会降级。

  • 统一的时间区间抽象

    日历事件和自己的时间块都会占用时间。抽出一个公共的时间区间类型,日历服务返回它,引擎也只认它,算法层因此完全不知道数据是从哪来的。

  • 把重复实现收敛掉

    移动时间块的逻辑一度在 ViewModel 和门面层各有一份。现在界面写操作统一经由操作层:手动移动会标记来源并锁定,EventKit 失败也从同一条路径返回界面。

技术栈

客户端与服务端的分层。

  • MVVM + 操作层

    Views → ViewModels → 操作层 → Services / Engine 分层,界面不直接触碰引擎。

  • 纯 Swift 排程引擎

    空闲查找、贪心排程、冲突检测都是不依赖网络的纯 Swift 实现,可离线运行并被单元测试覆盖。

  • SwiftData 持久化

    任务、时间块与排程偏好三个模型,枚举以字符串存储并暴露类型化计算属性。

  • 服务端技术预览

    Go 与腾讯云 CloudBase 已有单函数路由和统一 { code, message, data } 响应骨架;它不被宣称为已完成生产认证的稳定服务。