最难的不是某个算法,是本地数据与系统日历的一致性
SwiftData 和 EventKit 是两套互不知情的存储。移动或删除一个时间块时,既要更新本地模型,也要处理对应的 EKEvent;EventKit 事件 ID 存在时间块上。写操作因此收口到统一操作层,失败显式返回给界面,成功后再重跑冲突检测,避免各个 ViewModel 各写一套逻辑。
技术难点
共 9 条,每条都是实际改过代码的地方。
删除时的级联顺序
- 问题
- SwiftData 的 deleteRule: .cascade 会自动删掉任务关联的时间块,但它管不到 EventKit——日历事件是系统外部数据,不在它的对象图里。
- 解法
- 删任务前先遍历它所有的时间块,逐个调用日历服务移除对应事件,再让 cascade 接管 SwiftData 的清理。顺序不能反:先删任务的话 cascade 立刻触发,时间块没了,就再也拿不到日历事件 ID 了。
- 结果
- 取消任务和删除任务两条路径共用同一套顺序;EventKit 删除失败会显式抛出,不再假装整个操作已成功。
- SwiftData
- EventKit
- 数据一致性
本地自然语言解析
- 问题
- 建任务是核心路径,如果必须等待远程服务,网络和服务配置就会变成用户无法输入的单点。
- 解法
- 稳定路径使用本地解析器,识别「半小时」「明天下午」「周三」这类中文表达,把标题、时长、截止日与优先级抽出来。
- 结果
- 解析不依赖远程接口,用户离线也能建立任务,并在草稿中确认解析后的字段。
- 本地 NLP
- 离线可用
- 可确认草稿
排程引擎的三个算法
- 问题
- 要在一堆日历事件的缝隙里,按截止日和优先级把任务塞进去,还要保证塞完不打架。
- 解法
- 拆成三个纯函数引擎。空闲查找用游标扫描,关键是游标推进写成 currentStart = max(currentStart, eventEnd) 而不是直接赋值——嵌套事件(B 完全包在 A 里面)时游标不会回退,否则会凭空多出一段错误的空闲时间。排程用贪心,取 min(剩余时长, 空档长度, 专注块上限) 三者最小,超长任务自动被拆成多个块,调用方不用管。冲突检测排序后利用单调性早终止:一旦 j 的开始时间不早于 i 的结束时间,后面的 j 都不可能重叠,直接 break。
- 结果
- 三个引擎都不依赖网络也不依赖框架,是可以单独跑单元测试的纯 Swift。
- 算法
- 游标扫描
- 贪心
- 早终止
技术预览:MCP 风格的工具注册表
- 问题
- 为远程 Agent 技术预览准备客户端能力时,如果把业务操作一个个硬编码进网络层,以后换模型或加能力都要大改。
- 解法
- 先把业务逻辑收进一个门面层,再用工具注册表把它们包成带 JSON Schema 的工具定义,参数类型和必填字段都由 schema 约束,统一走一个 executeJSON 入口分发。服务端要什么能力,注册表导出什么定义。
- 结果
- 客户端的工具边界可独立测试;这是预览架构的可扩展性,不等于远程 Agent 已稳定上线。
- MCP
- JSON Schema
- 可扩展性
一个参数表达三种语义
- 问题
- 更新任务时,「不修改截止日」「清除截止日」「改成某一天」是三件不同的事,但只有一个参数位。
- 解法
- 用 Date?? 双重可选:不传是不修改,传 .some(nil) 是清除,传 .some(date) 是更新。
- 结果
- 接口不用为了区分「没传」和「传了空」再加一个布尔开关。
- Swift
- API 设计
技术预览:WebSocket Agent 的会话状态机
- 问题
- 一轮 Agent 会话要来回好几趟:启动会话、服务端发起工具调用、客户端执行完回传结果、可能还要停下来问用户一句、最后才是结论。乱序或者重复的消息会让状态错乱。
- 解法
- 把会话状态显式建模成状态机并校验每次转移是否合法,非法转移直接报错而不是带着脏状态往下跑。首条消息设 30 秒超时,传输层只接受文本帧,协议字段全用 snake_case 与服务端对齐。
- 结果
- 预览界面只消费一条事件流(会话开始、工具活动、回复、询问、完成、取消、失败),客户端协议逻辑可被单元测试;远程服务仍不列为稳定能力。
- WebSocket
- 状态机
- 协议设计
拖到屏幕边缘要自动滚动
- 问题
- 在时间轴上拖时间块到屏幕边缘时,需要让底下的滚动视图跟着滚。SwiftUI 的 animation 没法被手势打断,Timer 的精度又不够,两个都不合适。
- 解法
- 用 CADisplayLink 跟屏幕刷新同步,每帧算一次速度:proximity = 1 - 手指到边缘的距离 / 80pt,速度 = 最大速度 × proximity²。用平方而不是线性,靠近边缘时是平滑加速而不是突然窜出去。
- 结果
- CADisplayLink 要求 @objc 的 NSObject 目标,而管理器是 Swift 的 @Observable 类,中间加了个包装器桥接——这是 Swift 与 Objective-C 互操作的经典问题。
- CADisplayLink
- 手势
- Swift/ObjC 互操作
一个任务拆成多个时间块
- 问题
- 三小时的任务不可能一口气排进一个空档,得拆。但拆完之后「这个任务还剩多少没排」就成了一个容易写错的状态。
- 解法
- 不存这个状态,改成算:剩余时长 = 预估时长 − 已排块时长之和。每次排程前读一次。
- 结果
- 已排 90 分钟的三小时任务,下次自动只排剩下的 90 分钟。一致性由计算属性保证,没有需要手动维护的进度字段。
- 数据建模
- 计算属性
锁定与排不下,都是给用户的信号
- 问题
- 自动排程如果覆盖掉用户手动调过的安排,那这个功能就是负分。任务排不下时如果静默丢弃,用户会以为系统吃了他的任务。
- 解法
- 手动拖过的块标记来源为手动放置并锁定,重排时跳过。当天塞不下的任务标为超载并单独在界面上列出来。
- 结果
- 系统不替用户做决定,只把「今天满了,这几个你决定推迟还是删掉」这个信息给到位。
- 产品设计
- 状态建模
工程实践
不构成难点,但决定了这个项目好不好继续写下去。
@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 } 响应骨架;它不被宣称为已完成生产认证的稳定服务。