3天4端41提交:与 AI 协同设计多端设置同步方案的完整复盘

comsince
FshareIM Team三天,四个仓库,41 个提交,约一万行新增,给飞享IM 加了一个听起来很小的功能:把「保存到通讯录的群组」「会话置顶」「消息免打扰」同步到服务端,换设备或重装后能拉回来。这篇复盘的重点不是功能本身,而是人与 AI 的分工是怎么切的:AI 负责设计、计划、实现、审查、修复的完整闭环,人负责定产品约束、做技术裁决、在 AI 判断失灵时给出现场证据。过程中同一类缺陷在四个端上换了四种形态,被这套协同流程拦下三次、漏掉一次——漏掉的那次,恰恰最说明协同的边界在哪。

一、需求:一个被消息拉取绑架的群列表
问题来自真实抱怨:每次登录都要重新拉取消息列表,那些消息久远的群组拉不到,于是它们就从界面上消失了。群列表的存在与否,被绑架在「这个群最近有没有人说话」上。
解法是把用户的个人偏好搬到服务端。飞享IM 服务端本来就有 t_user_setting 表,于是设计成三个信令:
| 信令 | 方向 | 语义 |
|---|---|---|
UG | 客户端 → 服务端 | 带本地 version 拉增量,服务端只回 _dt > version 的条目 |
UP | 客户端 → 服务端 | 上报一条设置,服务端落库后应答新 version |
UN | 服务端 → 客户端 | 通知「你的设置变了」,body 是 8 字节新 version,客户端据此自行发 UG |
四个 scope:1 会话免打扰、2 全局免打扰、3 会话置顶、6 收藏群组。会话类的 key 统一是 type-line-target,scope 6 的 key 是裸 groupId。
二、协同方式:人定约束,AI 跑闭环
这次没有「让 AI 写个功能」这种粗颗粒的委托,而是把流程拆成五段,每段的产出物都是可审查的文件:
设计阶段是一问一答磨出来的,不是一次生成的。 几个关键决策都来自人的追问和纠正:
人:「这个保存的群聊是用户自己要保存的……用户的设置,置顶,静音,也考虑同步过去」——把需求从「群列表」扩展到三类设置。
人:「不用兼容存量用户,存量用户数据不适用 UPB 信令上报,删除这个功能」——砍掉一个我在设计里加的批量上报信令。这条产品决策在两天后成了一次技术裁决的依据。
人:「现在推送可以跨节点推送」——直接纠正了我在设计文档里写错的一个架构事实。
人:「android 跟 ios 长按会话菜单里面都有没有免打扰/取消免打扰」——一句追问,逼出了「两端都缺」这个我原本写错的结论。
实现阶段每个任务派一个全新的 AI 子代理,它只拿到自己那一个任务的完整需求文件,拿不到会话历史。这么做是为了让每个任务的上下文足够干净——但代价是,任务之间的接缝只能靠审查兜住,后面第五节的缺陷正是从接缝里漏出来的。
审查是独立的另一个子代理,拿到需求文件、实现报告、完整 diff 三份输入,给两个必须的判定:规范符合性和代码质量。审查提出的问题进入修复轮,修完再做一次范围受限的复审,只判定「这几条解决了没有」外加「这次修复有没有引入新破坏」。
这套流程里有两条纪律,事后看是最关键的:
第一,不预判审查结果。 派发审查时绝不写「这个不用管」「这条最多算 Minor」。一旦开始替审查者做判断,审查就退化成了确认偏见。
第二,裁决权在人。 当审查的建议和已经拍板的产品约束冲突时——第六节那次——不是 AI 自己决定听谁的,而是回到人定下的约束。
三、四端交付
| 仓库 | 提交 | 代码量 |
|---|---|---|
| chat-server-pro | 15 | 31 文件,+3076 |
| android-chat-pro | 2 | 33 文件,+1927 |
| ios-chat-pro | 9 | 27 文件,+2251 |
| electron-vue-chat-pro | 15 | 27 文件,+2845 |
服务端先行,然后 Android、iOS、Electron 依次落地。有意思的是,随着端数增加,前面端踩过的坑被写进后面端的计划里,形成了一套可复用的缺陷词汇表。下面三节就是这套词汇表的三个词条。
四、词条一:本地设置与本地 version 必须同生共死
这是整个功能最要命的一条不变量,四个端上都埋着雷。
增量同步的核心是「客户端记住自己拉到哪了」。一旦出现「version 留着、设置没了」的本地状态,客户端下次登录会认为「我已经够新了」,于是永远不再拉取。设置永久丢失,而且这个故障在真机上极难复现——它只在特定的清理顺序下产生,产生之后又是静默的。
它在各端的形态:
Android:登出没有清理用户设置。换账号后,上一个账号的 version 还在,新账号的设置永远拉不回来。
iOS:
settingHead在同一个事务里推进,一旦设置写入失败而 version 成功,丢失就是永久的。Electron:
settings和version用两个独立的watch写进两个 localStorage key。两次setItem不是原子的,前者失败后者成功,磁盘上就出现了这个禁忌状态。
Electron 的修法最能说明问题:把两个 watch 合成一个,persist() 里先写 settings,失败就直接 return,绝不推进 version。
审查这条的方法也值得记:不是问「代码看起来对吗」,而是问「把这行删掉,有测试会红吗」。Electron 的第一版有 12 个测试,全部只断言内存态——把持久化的两行 watch 整段删掉,12 个测试依然全绿。核心职责一行测试都没盖到。
五、词条二:恢复必须有两条路径,只做一条等于没做
这是 iOS 端交付过的一个完全不工作的版本,成因值得每个做多端同步的人看一眼。
设置表是置顶/免打扰的真值来源,但会话列表里的会话对象上另有 isTop / isSilent 字段。很自然会写成:收到 UG 结果、apply 的那一刻,把设置投影到当时已存在的会话行上。
这是错的。 UG 到达时会话列表往往还是空的——首次登录、重装、清缓存后消息还没拉回来。投影全部落空。之后消息陆续到达、会话行一条条被创建出来,而这些后创建的行从来不会再去查设置表。结果:功能看起来实现了,实际一次都没生效。
正确做法是两条路径缺一不可:
UG落地时,把设置投影到已存在的会话行;- 创建会话行的那个代码路径自身,从设置表取初值。
这里有个细节值得警惕:我在 iOS 的计划里写过一句「upsertGroup 已核实会保留 isFav,无需改动」——这句话对已存在的行成立,对新建的行不成立。一句只在一半情况下正确的核实,就足以让整个功能不工作。
有了这条经验,Electron 的计划里提前写了这一条,派发时我特意在提示词里点名了这个坑。结果实现者把「新建行派生」做对了,「apply 时投影」整条却不存在——同一个缺陷跑到了另一半。终审抓住它时给的失败场景很具体:
设备 A 把会话 X 置顶。设备 B 正在运行,X 已经在列表里。B 收到
UN→ 拉UG→ 设置 store 更新了,但 B 的那一行还是isTop=false,列表不重排。重启也治不好:缓存里的行带着旧标志回来,之后每条消息都走 update 分支,而 update 分支按设计永不重新派生。
这条很能说明 AI 协同的一个特点:把坑写进提示词,能防住这个坑的这一半,防不住它的另一半。 真正兜住它的是独立审查,不是更详细的提示词。
六、词条三:把「条目不存在」当成「关」
补上投影之后,Electron 立刻引入了一个新的 Critical,而且是更隐蔽的一种。
投影代码这样写:row.isTop = settingValue(...) === '1'。看着没问题——直到你想起老用户。
这个功能上线之前,置顶和免打扰只写本地、从来没上过服务端。升级后首次启动:设置表是空的 → 投影把每一行的 isTop/isSilent 全刷成 false → 持久化立刻落盘 → 然后去服务端拉,服务端从来没有这些条目,拿不回来。用户升级一次,所有置顶和免打扰静默清零且不可恢复。
审查建议做一次性迁移:把现有会话行的标志反向写进设置表并上报。这条建议被否决了——第二节里人早已明确决定不兼容存量用户、删掉批量上报。
正确解法根本不是迁移,而是让投影遵守墓碑设计本来就有的三态语义:
顺带说明当初为什么取消要写墓碑 "0" 而不是删记录:删除在增量同步里是不可见的,其他设备永远拉不到「这条没了」这个事实。而「不存在」和「明确取消」本来就是两回事——墓碑机制存在的全部意义就是区分这两者。把「不存在」当成「关」,等于让墓碑在投影这条路径上完全失去意义。
这是整个协同过程里最能体现分工价值的一次:审查者看到的是代码,裁决者要看到的是产品决策的历史。 一个纯技术视角上完全正确的修法(做迁移),正好撞在一条两天前已经拍板的产品约束上。AI 审查能发现缺陷,但「这个修法该不该用」不是它能单独决定的。
七、词汇表失效的那一次
前三个词条让 Electron 提前避开了两个 Critical。但 Android 上线后,用户报了一个新现象:
iOS、PC 上设置免打扰、置顶、保存通讯录,Android 同步不过来;反过来在 Android 上操作,iOS 和 PC 都能同步过去。
这个不对称性本身就是信息:Android 的发送路径是通的(服务端收到了、也转发给别人了),只有接收路径不通。
查的过程是这样的:
- SubSignal 序号服务端与 Android 逐位比对,44 个全同 ✓
toEnum解码逻辑两侧一致 ✓- 两个 handler 都已注册,前面没有 handler 会吞掉
PUB_ACK/UG或PUBLISH/UN✓ userSettingMap与userSettingVersion同为纯内存、stop()里一起清零——词条一守住了 ✓- 登录时的全量拉取判定正确 ✓
- 落库后连「回写已存在会话行」的投影都做了——词条二也守住了 ✓
每一条已知检查项都通过。然后找到了对照组:JavaProtoLogic.onSettingUpdated() 这个回调,全仓零调用点;而同构的 onFriendRequestUpdated() 有两个调用点,好友同步因此一直正常。
整条刷新链路本来就建好了——ClientService 实现了回调,ChatManager 扇出给监听器,ConversationListViewModel 已经实现了对应接口,Fragment 里的 observer 就是 reloadConversations()——只是从来没有人扣扳机。
这解释了不对称性:Android 自己操作时 UI 是本端直接改的,看得见;别的端推过来时只有数据变了,界面不动。
修法用了一个在这个仓库里验证过的技巧:先把有缺陷的逻辑原样抽成纯函数,跑测试确认它确实会红,再修。 抽出的 UserSettingApplier 带 8 个单测,抽取后第一次运行恰好 3 条断言「通知」的变红,其余 5 条(落库、推进 version、不倒退、空增量)全过——这证明抽取是忠实的,测试真的咬住了这个缺陷,而不是跑过了那行代码。
八、AI 判断失灵,人给出证据
Android 这个 Bug 还有个尾巴,是这次协同里最值得留下的一段。
修完刷新之后,我给了一个可证伪的预测:既然数据是到了的,那么杀掉重开 App,设置应该已经在了。人回来说:群列表是空的。
预测被证伪,信息量很大——它意味着连 Android 自己保存的群组重启后都没了,这根本不是「跨端同步」问题,是 Android 从来没能把设置拉回来。
于是我沿着数据路径往上查:ConnectAck 的 protobuf 字段号两侧一致、getSettingHead 有回源数据库的修复、t_user_setting 表结构和唯一键都对、落库 SQL 是 ON DUPLICATE KEY UPDATE……每一环都是对的。到这一步我列出四个候选根因、做了一张判定表——然后停下来,请人去抓日志。
一行日志:
13 条设置确实拉回来了。 我基于「列表是空的」推断「数据没到」,这个推断是错的。真正的原因在渲染层——群列表拿到 groupId 后用「只读本地缓存、不向服务端刷新」的方式取群信息,而那时本地还没有这些群的信息。
两条教训:
症状发生在哪一层,和根因在哪一层,没有必然关系。 我在静态分析上花的时间远超必要,而系统里其实每个边界都已经有日志,抓一次就能把四个候选砍到一个。
协同里最不可替代的一环是现场证据。 AI 能读完四个仓库的代码、能列出所有候选根因、能设计判定表,但它读不到那台魅族手机上正在跑的进程。人提供的那三行 logcat,价值超过我前面三轮推断的总和。
九、一个反直觉的发现:为什么只有 Android 暴露了问题
排查中途有个假设很有说服力,虽然最后不成立,但它揭示的架构事实是真的:
Android 是唯一把用户设置放在纯内存里的客户端。 iOS 存 GRDB,Electron 存 localStorage。这意味着 iOS 和 Electron 每次启动都能从本地恢复出一份看起来正确的状态,它们根本没有真正依赖过服务端拉取这条链路。而 Android 每次启动 version 都归零、设置都清空,它是唯一一个每次冷启动都在做端到端验证的客户端。
所以如果服务端的恢复链路有任何问题,只有 Android 看得出来。
这条对多端项目有普遍意义:本地缓存越厚的端,越晚发现同步链路的问题。 排查多端同步时,优先看那个「最不信任本地缓存」的端,它的症状最接近协议层的真相。
十、协同侧的三条经验
1. 计划本身就是缺陷来源,而且是最难发现的一类。
这次多个缺陷可以追溯到 AI 自己写的实施计划:计划里逐字给出了一段没有 try/catch 保护的 JSON.parse(实现者照抄,成了 Critical);计划的数据流图里画了「apply 时投影」,却没有任何一个任务落实它;计划的示例代码把副作用写在 Vue 的 computed getter 里,导致重复网络请求。
计划写得越详细,实现者越倾向于照抄,包括抄走里面的缺陷。 这是分层委托的固有代价,唯一的解药是让审查者拿到需求和代码但不预设立场。
对照之下,一个健康的信号是:实现者敢于偏离计划。Electron 的 UI 任务里,实现者发现计划给的示例代码会打断 Vue 的 v-if/v-else-if 条件链、prop 名也写错了,两处都改对了并在报告里说明;另一个任务里实现者发现计划的提交文件清单漏列了一个必需文件,一并提交并记录了偏离理由。计划应该是需求的载体,不是不容置疑的代码来源。
2. 「测试有没有咬合」比「测试覆盖率」有用得多。
这次每一轮审查都在问同一个问题:把这段生产代码退回缺陷版本,哪条断言会变红?多次发现「测试跑到了那行代码,但断言在修好和没修好时都成立」的空转测试。最典型的一条:测墓碑 '0' 能把已置顶的行改回不置顶,但用例走的是新建分支,新建分支会用当时还空的设置表把 isTop 覆盖成 false——断言 false 在有没有投影时都成立。
3. 缺陷词汇表能跨端复用,但不要指望它覆盖下一个端。
前三个词条让我们在 Electron 上提前拦住了两个 Critical,这是实打实的收益。但 Android 的缺陷是第四种形态——不在词汇表里,而且它出现在一个所有「已知检查项」都通过的模块上。词汇表让协同更快,不让它更安全;剩下的那部分只能靠老老实实找根因,以及人在恰当的时候把现场证据递过来。
十一、结果
四个端全部上线并验证:
- 服务端 15 个提交,含三处既有缺陷修复(缓存失效遗漏、
settingHead不回源、MultiMap 残留重复条目),以及推送热路径上的会话免打扰缓存重构 - Android 真机验证通过,群列表、置顶、免打扰均能跨端同步与重装恢复
- iOS 真机验证通过
- Electron 26 个测试文件 / 187 个测试全绿,1.5.0 版本已发布
一个「把三个字段同步到服务端」的需求,最终产出的最有价值的东西,是四条能写进下一份设计文档的检查项:
- 本地数据与本地 version 是否同生共死?把任一清理路径删掉,有测试会红吗?
- 恢复是否有两条路径——投影到已有行,以及新建行时派生初值?
- 「条目不存在」是否被正确地区别于「明确取消」?
- 数据落地之后,有没有人通知界面?找一个同构的、已经工作的功能做对照。
以及一条关于协同本身的:AI 能把设计、实现、审查、修复跑成闭环,但闭环的两个端点仍然在人手里——上游的产品约束,和下游的现场证据。