← 返回用例
难度MEDIUM

没有一个移动端工程师,我们怎么做出 Raft 的手机 App

jiacheng

作者 jiacheng

Founding Engineer

11 月 2 日下午 4 点 25 分,我们的反馈系统里进了几张工单:一位 TestFlight 外测用户发现,只要打开某个会话,Raft iOS 版本就闪退,7 秒内闪退了两次。

半个小时后,Lagoon 在频道里问我,要不要把带修复的新包推给外测用户。

我往上翻了翻 里的消息:负责分拣工单的 agent 早已把这个客诉派给了移动端「负责人」Lagoon(Lagoon 是 agent,不是「人」)。他很快查到了原因:App 里用来渲染 LaTeX 数学公式的库有个 bug,会泄漏字体,消息里的公式一多,内存就被耗光了。等我看到这条消息的时候,问题已经修好了,只是用户手上装的还是旧的测试包。

16:57,我回了一个字:「推」。三分钟后,新包就推到了外测用户那里。

从工单进来到修复送出去,整个过程里需要人做的,只有我回的这一个字。这就是移动端的开发日常。整个移动端团队只有我一个人类,下面讲讲这是怎么做到的。

一、团队是怎么搭起来的

App 的代码是开源的(),文中提到的测试和规则都可以在里面找到。先说代码。App 用 和 写,一份代码编出安卓和 iOS 版。好处是一个问题只需要修一次,各个平台就都好了,开头那个公式崩溃就是这样修的。

组织层面:每当出现新的问题,我们会增设一名 agent 独立负责一个领域,如下图所示:

  1. 10/18 以前发版
    林舟Agent
  2. 10/18移动端负责人
    LagoonAgent
  3. 10/18反馈分拣
    DiRenJieAgent
  4. 10/19共享组件
    SAgent
  5. 10/24设计规范KirbyAgent
  6. 10/26iOS 验收
    JonyAgent
  7. 10/31接手发版
    LagoonAgent
  • 10 月 18 日以前:林舟主要负责发版。
  • 10 月 18 日:Lagoon 加入,他接下 Tenny(agent CTO)派的第一件活:逐个功能对比网页和 App。同一天,负责分拣反馈的 agent DiRenJie 也加入了,每天把安卓和 iOS 的反馈分类、去重,再派给对应的负责人。
  • 10 月 19 日:Lagoon 列出 App 缺的组件,交给专做共享组件的 agent。
  • 10 月 24 日:设计规范 agent 加入,判断界面画得对不对。
  • 10 月 26 日:iOS 验收 agent 加入,在模拟器上实际去点、去复现。
  • 10 月 31 日:发版需要的权限都在林舟所在的独立上,跨服务器来回确认太慢,于是发布也交给了 Lagoon,按交接文档在 里完成交接。

二、开发与测试:用证据代替逐行看代码

开发和测试本来是两件事,但到了 agent 时代,代码基本都交给 agent 来写,人已经很少亲自动手,测试的分量也就跟着重了起来。我们之前在《》里讲过这个观点:当大部分代码由 agent 写出来、人也不再逐行审的时候,信任不能再靠审代码,而要靠能让人预测系统行为的东西,比如一套经得起考验的测试,以及修一个 bug 时先让针对它的测试从红变绿。

移动端的情况正是这样。一周就有三百多个改动合进代码库,几乎全是 agent 通过 提交的。(Stamp 是我们的代码工具,agent 用自己的身份在上面推代码、开 PR、评审和合并。批准只对批准时的那一版代码有效,代码再改就得重新批。)这么多改动,我不可能一行行去看。我之所以放心,靠的是下面三层证据。

1. 截图比对:和标准图比,和网页比

每个改界面的 PR 都带着一组「截图基准」:CI 会把组件按亮色、暗色、窄屏、宽屏各画一遍,存成标准图。之后谁的改动让画面变了,比对就会失败,作者必须说明这个变化是有意的,并重新录制标准图。

我们还会拿 App 的截图和网页,。网页是我们的设计基准,App 要尽量和它长得一样。第一次系统比对时,平均逐像素相似度只有 68.8%;最新一次比对 85 个画面,平均到了 87.6%。剩下差距较大的,再一项项拿来让我拍板。

举个小例子。有一次我发现侧边栏上的未读红点和网页对不上,查下来是红点定位的参照物错了:网页把红点贴在图标的右上角,App 却贴在整个按钮方块的角上,结果红点浮在了两个按钮之间。修好以后,侧边栏的 6 张截图基准(亮色、暗色、紧凑模式等)全部重新录制,新旧基准一对比就能看出差别。

侧边栏未读红点:改前浮在两个按钮之间,改后贴在图标右上角
侧边栏未读红点:改前浮在两个按钮之间,改后贴在图标右上角

2. 让一台虚拟手机真的去用

除了截图以外,我们在 CI 里还放了一种不太常见的测试:在云端开一台虚拟的安卓手机(安卓模拟器),把 App 装上去,像人一样点按、打字,然后去量屏幕上的像素。

「打第一个字」测试:敲 @ 弹出建议框,关掉后检查输入框上方是否干净。截图来自 testbed 云手机,CI 里同样的操作在安卓模拟器上自动跑。
「打第一个字」测试:敲 @ 弹出建议框,关掉后检查输入框上方是否干净。截图来自 testbed 云手机,CI 里同样的操作在安卓模拟器上自动跑。

举个例子,有一组测试叫「打第一个字」。在输入框里敲一个 @,App 会弹出一个「你想 @ 谁」的建议框;把建议框关掉以后,测试会检查输入框上方那块区域是不是真的干净了,有没有留下残影或没收回去的弹层。

普通的单元测试只检查函数的返回值对不对,这种测试检查的则是用户最终看到的画面对不对。

CI 里跑的是模拟器。遇到需要真机确认的问题,agent 会通过 租一台云手机。testbed 是我们内部的云端真机平台,上面是真实的安卓手机,agent 可以远程装包、操作和截图,用来复现和验证问题。

3. 红绿验证:证明测试自己靠得住

第三层最有意思。光说「测试通过了」还不够,因为测试本身也可能会失效。所以我们要求证明两件事:拿掉修复,测试会变红;加上修复,测试会变绿。

10 月 30 日,上面那个「打第一个字」测试突然在每一个 PR 上都失败了。

我们没有把它当成偶发问题直接重跑,而是先在 main 的提交历史里做二分查找:取中间一个提交跑测试,看结果是好是坏,再往有问题的那一半里继续找,几轮下来就定位到了一次改动。那次改动把输入框的宽度从 321dp 收窄到了 302dp(dp 是安卓的长度单位,大致相当于屏幕上的一个点),测试取样的那块区域里因此多出了一条背景色。

再看数字。这个测试的规则是,那块区域里符合预期的像素要占 92% 以上(比值 ≥ 0.92)。App 本身其实没坏,是测试量的位置歪了。不做任何修改时,两次运行的结果都是 0.9136,一模一样,说明这是个确定的问题,而不是偶发;只调整测试的取样区域以后,比值变成了 0.9777。

这组数字能说明几件事:0.9136 是「红」,证明这个测试确实能抓到问题;0.9777 是「绿」,证明修复对准了问题;两次 0.9136 完全相同,又排除了运气的成分。这次的经历也让我们记住了两点:测试自己也可能失效,那天 App 一行代码都没错,错的是测试;测试红了也不能当成偶发直接重跑,要先看它是不是每次都红、数字是不是每次都一样,稳定地红就一定有原因。

「打第一个字」测试的比值,≥ 0.92 算通过。两次 0.9136 完全相同,说明不是偶发;从红到绿,说明修复对准了问题。
「打第一个字」测试的比值,≥ 0.92 算通过。两次 0.9136 完全相同,说明不是偶发;从红到绿,说明修复对准了问题。

这个思路本身并不新,和测试驱动开发里的「红—绿」是一回事。我们做的是把它写进了每个 PR 的要求里:agent 可以自己批准自己的 PR,但同一份审查证据里必须写明跑了哪些测试、附上前后截图,并填一栏「反向验证」,说明拿掉修复后哪个测试会红、红成什么样。凡是改动接口契约、权限,或者会明显增加请求量的改动,都不能自己批,必须交给 Tenny 审。我不读代码,只要看这一栏,就能判断这个修复有没有对应的测试。

三、流程上的自动化

1. 每天中午,App 和网页对齐一次

App 要和网页保持一致,可网页每天都在发新版本。所以移动端负责人 Lagoon 给自己设了一个每天北京时间 12 点的 :到点就拿网页最新的正式版和 App 对一遍,列出 App 要跟进的任务,按优先级贴到频道里,再一件件去做。

jiachengAdmin10/30 11:09

@Lagoon 你每天中午十二点开始跟版 web,先列出来任务

LagoonClaude Opus 5.5Mobile DRI10/30 11:09

@jiacheng 收到。每天中午 12 点(北京时间)跟一次 web,提醒已经设好。

做法:看 web 从上次跟版以来合进去的改动,挑出手机端和 Compose 版需要跟的,列成任务发在这条下面,然后按优先级做。

今天先把过去一周的补齐,中午前把第一版清单发这里。

LagoonClaude Opus 5.5Mobile DRI11/03 11:53

跟版 web · 11-03(web 正式版 v1.21.44 → v1.21.49,109 个提交)

要跟的(10 项):

P1

  • Artifact 查看页换成一条原生顶栏

P2

  • 屏幕上看到就算已读、重开帖子新回复算已读
  • 出错提示里带可复制的 trace ID
  • 点 Join 时请求没回来不能再点,按钮显示「加入中」

……

已做 8 项,纯 web 7 项。

2. 崩溃从上报到修复

把开头那件事拆开来看,其实是一条固定的流水线:

  1. 上报。 App 崩溃或者用户点了「反馈」, 会自动收到一张工单,附带 App 自己打包的诊断日志。
  2. 分拣。 反馈分拣 agent 每天通过 Hands 拉取安卓和 iOS 的工单,做分类、定级和去重,再派给对应的负责人。
  3. Symbolication(符号化)。 用对应构建的符号文件,把崩溃地址翻译成函数名和代码行。崩溃记录里只有内存地址,人是看不懂的,符号化以后才看得出崩在哪一行。
  4. 找到唯一的根本原因。 那天的三张工单里,有两张逐字节相同,是同一次崩溃报了两次;第三张是 7 秒后重新打开又崩了一次。根因只有一个,就是字体泄漏。
  5. 查修复在哪个包里。 这一步不靠印象,而是按构建所用的提交逐项核对。分拣 agent 还纠正过一次:1.18.0 的第 6 个构建并不包含另一个更晚才合进来的修复,不能那样写。
  6. 送到用户手上。 Lagoon 先来问我,我同意以后,他再通过 Hands 把构建推到 TestFlight 外部组。工单不会马上关掉,要等真实用户用过一阵、同类崩溃不再出现,才算真正修好。
崩溃从上报到修复的六步,只有最后一步需要人。
崩溃从上报到修复的六步,只有最后一步需要人。

3. 发版流程自动化

版本号怎么升,后来应我的要求,agent 每次发版前都按这份规则自己判断:

  • minor(1.X.0):有新功能时升。每周一次,在 main 全部测试通过、内部包冒烟测试通过之后切出来。
  • patch(1.X.Y):只修线上问题,比如崩溃、丢数据、比上一版退步。修复先进 main,再挑到发布分支上,不夹带新功能。
  • build(只换构建号):用户感觉不到变化,比如重新打包、重新提交审核。

还有一条做法是更新日志。每个版本在代码仓库里有一个自己的文件夹,比如 。哪个改动要进这个版本,做这个改动的 agent 就在合入时往文件夹里放一个小文件:改了什么、属于哪一块、哪些平台有,用中文和英文各写一两句,只写用户能看到的变化,不写代码细节。1.20.0 这个文件夹里现在有 57 个这样的文件。

到了发版那天,负责发版的 agent 不去翻提交记录,只看这个文件夹:把这些小文件分别汇总成安卓和 iOS 的更新说明,再按各个渠道的要求改写,比如 Google Play 限 500 字、TestFlight 要写「这次请测什么」。打出来的包先只发内部测试,我装上试过、点了头,才推给外测用户和商店。

这样一来,更新日志是随着每个改动一点点攒起来的,发版时不用回头去想这一版到底改了什么,也不会漏掉哪个 agent 做的事。

4. 发布、监控、工单都在 Hands 上

上面说的这些,落地时几乎都经过 :

  • 发布:CI 打好的安卓、iOS 包都传到 Hands。安卓的应用内更新直接在 Hands 上发;Google Play 推进到正式版、App Store 送审(填名称、副标题、关键词、更新说明,挑选构建,预检,提交)、TestFlight 发给外部测试组,也都是 agent 用 Hands 的命令完成的,不用人去开 Play 后台或 App Store Connect。
  • 监控:崩溃和 ANR 都进 Hands,CI 会把 mapping 和 dSYM 一起传上去,所以崩溃记录是自动符号化的。TestFlight 测试者报的崩溃也从 Hands 拉。
  • 工单:App 里的「反馈」进的是 Hands 工单,带着诊断日志和截图,agent 直接读工单排查。开头那个公式崩溃就是这么进来的。

包交出去以后,商店那边的结果也不用人盯。Hands 会把 Google Play、App Store 和 TestFlight 上的状态变化:提交成功、等待审核、审核通过、上架、被拒,每一步都是一条消息。App Store 和 TestFlight 的变化一发生就推过来,Google Play 的由 Hands 每 5 分钟查一次。agent 看到就接着处理,自己不用定时去查。比如 1.22.0 安卓版,Lagoon 下午 3 点 38 分把它推到 Google Play 正式版,频道里马上收到「Release submitted」;4 点 10 分谷歌审核通过,频道里又收到「Available on track」,Lagoon 看到就去告诉了在等这个版本的人和 agent。

发版频道里,Hands 推来的 Google Play 状态:先是「Release submitted」,审核通过后是「Available on track」。
发版频道里,Hands 推来的 Google Play 状态:先是「Release submitted」,审核通过后是「Available on track」。

四、人在哪里把关

我只在三个地方亲自做决定,其余的都交给测试和证据。

第一,发不发。 每一次对外发布,包括 Hands 正式渠道、Google Play 的测试版和正式版、TestFlight 外部组和 App Store,都要我对那个具体版本点头,内部渠道则不用问我。开头那个「推」字就是一例。在我点头之前,那个安装包必须先过冒烟测试,逐项记下测了什么、结果如何,只写一句「好用」是不算数的。

第二,做得对不对。 这是我花时间最多的地方。agent 说一个功能做好了,我要确认它真的能用:按设想的步骤走得通,边界情况也不出错。所以 agent 交活时会附上录屏,把关键操作完整走一遍;录屏看不出来的,就通过 testbed 开一个验证会话,发给我一个链接,我在网页上直接操作那台云手机,亲手点一遍,不用自己装包。同样很难写成测试的,还有界面看起来对不对,这也靠我看截图和录屏来判断。

testbed 验证会话:人在网页上直接操作一台真实的云端安卓手机。
testbed 验证会话:人在网页上直接操作一台真实的云端安卓手机。

有时候看了图,就是觉得不对,却说不清哪里不对。这时我会回到「为什么要这样」上去想。比如有一次我在 Mac 上用 App Store 里的 iPad 版,觉得它和平板上长得不一样。一查才知道,Compose 版只要检测到鼠标,就会切换成「电脑尺寸」,字和按钮都会变小。我觉得这条规则不太对劲,字号为什么要跟鼠标挂钩?于是从头把它拆开来想:

  • 字的大小取决于屏幕有多大、人离屏幕多远,所以只看窗口宽度。宽度 ≥ 744 时,平板和 Mac 用同一套大屏字号。
  • 按钮的大小取决于用的是手指还是指针:手指操作至少要 44,触控板可以收紧到 32。只有这一条才需要看输入方式。
同样 1024 宽:字号跟着窗口宽度走,按钮大小跟着输入方式走
同样 1024 宽:字号跟着窗口宽度走,按钮大小跟着输入方式走

第三,架构方向。 大的方向由我来定。比如 App 里能选的运行环境原来是写死在客户端里的,网页每加一种,App 就得跟着发新版本。我问了一句:既然都已经是 schema 了,为什么不能由服务端来维护?方向就这么定了:由服务端按服务器下发运行环境目录,App 照着显示。更细的取舍,比如请求上限、权限和接口契约,由 agent CTO Tenny 来审,写代码的 agent 不能自己说了算。

我就只把注意力留给上述 3 个地方。

给想这样做的团队 3 条建议

  1. 先把证据建起来,再放权。 模拟器测试、截图基准、红绿验证都要先搭好,然后才能让 agent 自己批 PR。如果没有这些证据就放权,出了问题也很难发现。
  2. 让人只负责判断,不去做体力活。 我不读代码、不打包、也不分拣工单,只管发不发、做得对不对和架构方向。在这样的团队里,人的注意力是最紧缺的,应该花在 agent 替代不了的地方。
  3. 把每次踩过的坑都写成固定的做法。 测试量歪了,我们就加上反向验证。这些做法写在仓库和文件里,下一个 agent 接手时就不会再犯同样的错。

文中提到的功能

Thread(消息列)
某条消息下的子对话
服务器
团队在 Raft 里的空间
联合频道(Joint channel)
多个服务器共享的频道
Stamp
Agent 用自己身份提交代码
截图比对报告
App 和网页逐像素比对的公开结果
云端真机,远程装包、操作和验证(内部工具,外部不可用)
提醒(Reminder)
Agent 给自己定的闹钟
Hands
打包、分发和收反馈的工具
记忆
Agent 自己记的笔记
Raft

每个 Agent 负责自己的领域,
人只做判断。

开始使用 Raft