---
slug: mobile-app
title: "没有一个移动端工程师，我们怎么做出 Raft 的手机 App"
language: zh-CN
canonicalUrl: https://raft.build/zh-cn/resources/raft-on-raft/mobile-app/
datePublished: 2026-11-09
dateModified: 2026-11-09
---

> 出处：Raft on Raft，raft.build · https://raft.build/zh-cn/resources/raft-on-raft/mobile-app/

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

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

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

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

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

## 一、团队是怎么搭起来的

App 的代码是开源的（[botiverse/mobile](https://github.com/botiverse/mobile)），文中提到的测试和规则都可以在里面找到。先说代码。App 用 [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) 和 [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) 写，一份代码编出安卓和 iOS 版。好处是一个问题只需要修一次，各个平台就都好了，开头那个公式崩溃就是这样修的。

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

- **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，按交接文档在 joint channel 里完成交接。

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

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

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

### 1. 截图比对：和标准图比，和网页比

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

我们还会拿 App 的截图和网页逐像素比对，[每次比对的报告是公开的](https://botiverse.github.io/mobile/visual-testing/)。网页是我们的设计基准，App 要尽量和它长得一样。第一次系统比对时，平均逐像素相似度只有 68.8%；最新一次比对 85 个画面，平均到了 87.6%。剩下差距较大的，再一项项拿来让我拍板。

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

![侧边栏未读红点：改前浮在两个按钮之间，改后贴在图标右上角](/raft-on-raft/mobile-app/rail-before-after.png)

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

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

![「打第一个字」测试：敲 @ 弹出建议框，关掉后检查输入框上方是否干净。截图来自 testbed 云手机，CI 里同样的操作在安卓模拟器上自动跑。](/raft-on-raft/mobile-app/virtual-phone-mention.png)

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

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

CI 里跑的是模拟器。遇到需要真机确认的问题，agent 会通过 testbed 租一台云手机。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 完全相同，说明不是偶发；从红到绿，说明修复对准了问题。](/raft-on-raft/mobile-app/red-green-ratio.png)

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

## 三、流程上的自动化

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

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

### 2. 崩溃从上报到修复

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

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

![崩溃从上报到修复的六步，只有最后一步需要人。](/raft-on-raft/mobile-app/crash-pipeline.png)

### 3. 发版流程自动化

版本号怎么升，后来应我的要求[写进了仓库](https://github.com/botiverse/mobile/blob/main/.agents/skills/raft-release-versioning/SKILL.md)，agent 每次发版前都按这份规则自己判断：

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

还有一条做法是更新日志。每个版本在代码仓库里有一个自己的文件夹，比如 [1.20.0](https://github.com/botiverse/mobile/tree/v1.20.0/change-logs/1.20.0/changesets)。哪个改动要进这个版本，做这个改动的 agent 就在合入时往文件夹里放一个小文件：改了什么、属于哪一块、哪些平台有，用中文和英文各写一两句，只写用户能看到的变化，不写代码细节。1.20.0 这个文件夹里现在有 57 个这样的文件。

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

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

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

上面说的这些，落地时几乎都经过 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 上的状态变化[推到我们的发版频道里](https://hands.build/docs/zh/store-release-notifications/)：提交成功、等待审核、审核通过、上架、被拒，每一步都是一条消息。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」。](/raft-on-raft/mobile-app/store-notifications.png)

## 四、人在哪里把关

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

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

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

![testbed 验证会话：人在网页上直接操作一台真实的云端安卓手机。](/raft-on-raft/mobile-app/testbed-verify.png)

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

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

![同样 1024 宽：字号跟着窗口宽度走，按钮大小跟着输入方式走](/raft-on-raft/mobile-app/scale-compare.png)

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

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

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

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

## 文中提到的功能

- **[Thread（消息列）](https://docs.raft.build/zh-cn/features/messaging/channels/#your-channels)**: 挂在某条消息下的子对话。只有关注它的人和 Agent 会收到回复，频道里的主线保持清爽。
- **[服务器](https://docs.raft.build/zh-cn/features/server/)**: 团队在 Raft 里的空间。服务器里的人、Agent、频道和工作都属于这个服务器；一个人可以加入多个服务器。
- **[联合频道（Joint channel）](https://docs.raft.build/zh-cn/features/messaging/joint-channels/)**: 多个 Raft 服务器共享的同一个频道。和有自己 Raft 服务器的客户、合作伙伴协作时用它，双方都不必加入对方的服务器，也看不到对方的其他频道。
- **[Stamp](https://stamp.build/)**: Agent 写代码用的 Git 网关：每个 Agent 用自己的身份推代码、开 PR、评审和合并，不借用人的 GitHub 账号。批准只对批准时的那一版代码有效。
- **[截图比对报告](https://botiverse.github.io/mobile/visual-testing/)**: 每次比对的公开报告：每个画面的 App 截图和网页并排放着，标出相似度和差异，任何人都能打开看。
- **[testbed](undefined)**: 我们内部的云端真机平台：上面是真实的安卓手机，Agent 可以远程装包、操作和截图；也能开一个验证会话，把链接发给人，人在网页上直接操作那台手机。
- **[提醒（Reminder）](https://docs.raft.build/zh-cn/features/agents/reminders/)**: Agent 给自己定的闹钟。到点时只唤醒它自己，它会回到原来的消息或 Thread 里接着工作。可以只响一次，也可以按周期重复。
- **[Hands](https://hands.build/)**: 我们自己做的发布和反馈平台：Agent 用它打包、把构建推到各个分发渠道；用户反馈和崩溃报告也会作为工单进到这里。
- **[记忆](https://docs.raft.build/zh-cn/features/agents/#memory)**: Agent 会把你的偏好、做过的决定和手上的工作写进所在计算机上的笔记，每次重新开始时先读这些笔记。想让它记住什么，直接告诉它。


