新闻案例 / NEWS · 技术解析
一千七百条测例四分钟跑完:Shopify给智能体搭的移动端无头架构
Shopify为智能体重写移动端App所设计的Reflex验证架构分析:界面只负责呈现与派发,核心逻辑全部收进不引界面库的独立模块,通过自省协议暴露状态与动作,双平台一千七百多条行为测试在命令行四分钟跑完。
编码 Agent 写一个界面的时间已经压到几分钟,证明这个界面没坏的时间还停在一圈几分钟。两边差不多的时候感觉不到问题;等一次改动要串上好几轮验证,写的那点时间就被等待吞掉了。Shopify 把移动端 App 搬回原生的 Swift 与 Kotlin 时,就撞在这堵墙上。
原来的验证回路是这样的:改完代码,编译一次,把 App 装进模拟器,照脚本把要验的那条路径点一遍,截几张图,人再对着截图判断屏幕上出现的东西对不对。中间任何一步失败,整条路从头再来。这条路的问题不在某一步慢,而在于每一步都要求一台设备在线、一个界面被真的画出来。
01
改动一次要等多久,为什么它就是上限
更麻烦的是失败信息的形状。截图对不上时,你得先判断是代码改错了,还是模拟器这一帧没渲染完,还是那条点击脚本恰好点在了动画中间。这类判断吃掉的时间不进任何指标,但它就压在每一次迭代的末尾,而且每次都要重做一遍。
反过来看:如果验证只需要读一次状态、发一个动作,这一圈短到不必再排一次队。不要设备,不要渲染,不要等人看截图。Shopify 把这件事做成了架构层的选择,把界面从验证回路里整个摘出去,让行为在没有任何视图被创建的情况下也能被断言。
把它当成架构问题而不是测试技巧,是因为这次改的是代码的形状。它要求每个界面的逻辑都不依赖界面库,要求每个界面的状态与可做动作被写成一份外部可读的契约,还要求这份契约在 iOS 与 Android 两侧长得一模一样。三件事叠加起来,才有「一份测例同时管两个原生 App」这个结果。
把界面从验证回路里整个摘出去,让行为在没有任何视图被创建的情况下也能被断言。
代价藏在三处。像素层不在这个回路里,夹具会越攒越大,自省层绑死了 Shopify 自己的会话模型与导航器、打包不出去。这三条都不是能绕过去的边角问题,它们决定了这套东西在多大体量的代码库里才划算。
02
界面在外、逻辑在内:一张四层的分工图
四层从上到下是界面层、核心层、自省层、命令行层。原文把它们叫四条支柱,但按依赖方向排成四层更好记,因为下面每一层都看不见上面那层,而上面每一层都要依赖下面那层。
界面层是 SwiftUI 与 Compose 写的那些视图,加上钥匙串、推送、相机、分享面板这类平台能力。一个视图只做两件事:读所属 ViewModel 的状态,把用户操作通过唯一的派发入口送出去。它不调 ViewModel 的方法,也不写状态。
核心层是这次改造的重心。ViewModel、服务、导航状态、GraphQL、持久化、深链,全部住在这里,而这个模块不引界面库,也没有任何依赖设备的代码。原文给了一个能验证的硬判据:核心层必须能在 macOS 或 JVM 上构建出来。构建不出来,说明还有跟界面绑在一起的东西漏在里面。
自省层是一份外部可读的契约。每个 ViewModel 配一个小扩展,把它描述成一个节点:名字、状态、它能接受的动作、指向下一层的子节点。命令行与测试 SDK 只跟这一层说话,App 自己从不调用它。原文甚至给了一个很干净的自检:把这个文件删掉,App 照样编译、照样运行,你只是失去了从终端驱动它的能力。
命令行层是平台无关的。它跟界面层一样依赖核心层,只是一个跑在 macOS 或 JVM 上,一个跑在手机上。两条入口构造的是同一个核心,区别只在命令行传进去的是夹具,手机传进去的是真实平台服务。这是整张图里最省事的一处:不用为测试另写一套逻辑,手机与终端跑的是同一份代码。
另一处判断是模块切分只有三段:核心、界面、命令行,自省层是第四个小模块,只放那一个协议。原文对 Kotlin 侧只补了一句差异,说 Kotlin 的扩展没法让一个类型去实现接口,所以那边把节点写成一个独立类,放在 ViewModel 旁边。除此之外两端结构同形,读起来几乎一样。
先建一个心智模型:把 App 想成一棵只装着状态与动作的树。根是 App 对象,它拿着网络客户端、当前会话与持久化;中间是导航器,Tab、Push、Modal 三种,各自持有当前压在屏上的那几条路由;叶子是一个界面一个 ViewModel。这棵树里没有任何东西知道界面长什么样。
界面在哪:在核心之外,界面模块里。一个视图绑一个 ViewModel,读它的状态,调它的派发入口。这就是视图与 App 其余部分之间的全部关系。把这一句想清楚,后面那些设计几乎都是它的推论。
ViewModel 的形状受 Elm 架构影响,拆成五样东西:状态是这个界面能显示的一切,只归 ViewModel 所有;动作是公开面,所有视图或者命令行能要求它做的事都写成动作枚举;消息是内部面,主要是服务回来的结果,只有 ViewModel 自己看得见;更新函数是唯一的写入口,所有路径最后都汇到它那里;服务是网络、存储这类有副作用的东西,通过构造函数以协议形式传进来。
为什么这个形状要用「严格」来形容:它把「状态从哪来、往哪去」压到了唯一一条通路上。一个界面写几百遍都是同一个骨架,这件事对人来说是纪律,对智能体来说是可复制的模式。原文那句话很直白,形状够严,智能体就能把它正确地重复几百次。
还有一条容易被忽略:核心层里没有任何第三方依赖。整个这套架构本身只有一个基础 ViewModel 类、一个很小的自省协议,加上「核心层不许引平台」这条纪律。智能体恰好擅长守纪律型的模式重复。
03
从点一次减号开始,看动作怎么走完全程
原文给了一个刻意做小的例子:一个库存管理 App,用户从列表里挑一个商品,进详情页,调一下库存数量。这个例子的全部价值在于它足够小,小到能把每一次状态变化都摊开来看。
先看界面侧的起点。用户在详情页点了一次减号。视图不做计算,也不改本地变量,它只把「减一号库存」这个动作通过派发入口送进 ViewModel。ViewModel 收到动作,转成一条消息交给更新函数;更新函数算出新状态、写回状态字段;视图观察到状态变了,重新读一遍,屏幕上那个数字少了一个。
这条链上有三个约束值得记住:一,状态只有更新函数能写;二,动作是唯一入口,视图与命令行走同一个入口;三,库存为零时减号该不该有反应,这个判断也放在更新函数里做,不放在视图里做。
现在把这条链搬到命令行上。命令行不创建视图,它读的是自省节点:先读根节点的名字与状态,顺着子节点一路往下,直到最深的那个,也就是用户正在看的那个界面。定位到之后它读一次状态,看到的字段与用户在屏幕上看到的一一对应。
发动作走的是同一条路。命令行发出「减一号库存」,节点背后那个闭包调用的就是派发入口,与视图点减号时调用的是同一个。核心层分辨不出这次动作来自手机还是来自终端,它也不需要分辨。
自省节点只有四样东西,但这四样把「从外面看一个界面」这件事说完了:名字,给测试用的稳定标识;状态,序列化后的全部可见字段;动作,每个动作一条,带一个标题,背后是调用派发入口的闭包;子节点,指向下一层,命令行靠它找到焦点。
动作可以带参数。调库存数量这个动作声明了一个输入类型,命令行发过来的是一个 JSON 对象,先被解码成那个类型再进闭包。输入类型的 JSON 结构由编译期宏从属性里生成,命令行能提前告诉使用者这个动作要什么、能拒掉格式不对的载荷,而这份结构没有任何人手工维护。
三条边线把节点的范围圈死了:第一条,用户能看见的都进状态,用户能做的都进动作。第二条,节点里不重复界面逻辑,它每次都把全部动作列出来,不写条件分支。第三条,动作只走那一个派发入口,节点从不直接调 ViewModel 的方法、也不改状态,命令行能做的事,一个视图全都能做,不多一件。
节点成一棵树,形状与前面那棵对象树严格对应:一个对象一个节点,用子节点连起来。App 根节点的子节点是导航器,导航器的子节点是当前最上面那条路由的 ViewModel。命令行永远聚焦在链条最深的那一个。
容器可以故意把动作借给焦点。栈导航器会把「返回」往下借一层,标签导航器会把「切页」借下去,普通 ViewModel 什么都不借。你让命令行执行一个既不在焦点、也没被上层借出的动作,它会拒绝,并且把当前可用的动作列出来。这保住了一个性质:命令行能碰到的动作集合,等于用户在当前屏幕上能碰到的动作集合。
传输只有两种,都简单。无头模式下,核心进程从标准输入读行分隔的 JSON,往标准输出写行分隔的 JSON。真实构建里,同一个核心挂一个很小的套接字服务,端口那边是同一批 ViewModel。那个套接字服务在发布构建里被编译剔除,线上包里不存在。
一个界面的行为到这里已经能被完整驱动,剩下的选择是这些测例落在哪一层。三条常见路线都不够用:跨端端到端测试慢且要模拟器;原生界面测试(XCUITest/Espresso)平台各自重复;单元测试测的是模拟版本且各自漂移。
这套架构落在中间,原文自己用的词是接近灰盒。测例看不见像素,也看不见内部实现,它看见的东西恰好等于界面看见的东西,也就是 ViewModel 的状态与它能接受的动作。下面全都是真的(更新函数、服务、GraphQL、导航),只在接触网络或设备的那几个边缘,把替身从构造函数塞进去。整个套件里没有一个打桩框架。
这个位置的好处是失败信息能读懂:报错会指明是哪一条断言没过、停在旅程哪一步、期望状态与实际状态是什么。智能体读这段信息就能自己找原因,不需要开调试器。
代价藏在夹具里。行为会话会装一个守卫,把宿主进程内的每一个 HTTP 请求都拦下,只要有未夹具的请求被发起就判失败。于是每一次网络调用都必须有夹具,对人是琐碎的维护负担,对智能体则可以接受。
速度是这套东西存在的理由:一千七百多条行为测试在持续集成上双平台一起跑,大约四分钟。单条用例在无设备的情况下是几百毫秒;同一条放到 iOS 模拟器上大约两秒。
两端同一份测例,顺手解决了另一件事:一个功能没法只在一个平台上线。代码不共享,但测试共享,两端之间不做抽象层,Swift 与 Kotlin 各用自己的界面工具与并发模型,对等性由测试而不是由代码保证。
04
这套契约的边界,和三个想当然
这套契约把「行为对不对」这一层管得很实,代价是它把另外几层的责任推给了别的机制。
第一个想当然:不用管界面了。这套测例不测界面。界面等价在这个回路之外,由另一道视觉评审兜着。每个检查点在落地前都要与参考 App 做视觉比对,另有少量截图测试守着确实在乎像素的那几个组件。两条是串联关系,不是替代关系。
第二个想当然:快就等于质量高。四分钟跑完一千七百多条,量的是反馈延迟,不是缺陷密度,这两笔账要分开记:一笔算研发效能,省下来的是等待;一笔算代码质量,要看缺陷漏没漏过去。反馈快解决的是智能体能不能连续迭代,它不解决测例本身写得对不对。
第三个想当然:这套自省层可以打包复用。原文的答案是不发布任何库:自省层绑死了他们的会话模型、导航器与夹具格式,换一个 App 全都不通用,能迁移的只有架构本身。你在别处看到的「某某架构开源了」,多半是文章本身充当了开源,而不是仓库。
还有一处局限:这套契约假定你能改自己的代码库。灰盒的前提是你能在核心层与界面层之间划一条线,而这条线在很多存量 App 里并不存在。要上这套东西,先得把线画出来,那是另一个量级的工程。
05
想在自己团队里试,先量三个数
如果你的团队也在让智能体写移动端代码,动工之前有三个数值得先量出来,它们决定这套东西对你值不值。
第一个数:一次改动的验证要等多久。不是编译时间,是从改完到能说「这条路径没坏」的全部时间,包括装包、起设备、走脚本、看结果。这个数如果在几分钟量级,而智能体的产出又是并行的,验证就已经是队列了。
第二个数:两个平台的行为重合度。如果绝大多数界面在两端的行为一样,共享一份测例的收益最大;如果两端本来就该不一样,共享测例会变成互相拖累,那时该共享的是判据而不是测例。
第三个数:夹具的维护成本。零网络是很强的承诺,代价是每条旅程都要把外部依赖编成数据。可以先挑一条依赖最少的旅程试,量一下夹具要写多少行、改动一次要跟着改几处。这个数会告诉你套件能长到多大。
落地切口建议小到不能再小:挑一个界面,把它的状态与可做动作写成一份节点描述,让命令行能读状态、能发动作,先不追求覆盖全部动作,也不追求双端。跑通之后你会得到两个东西:一个能自动断言的界面,和一份关于「这个界面的逻辑到底能不能脱离界面」的答案。后者比前者值钱。
智能体负责把行为跑对,人负责定义什么算对,以及那些只能靠眼睛判断的部分。
资料说明:本文基于 Shopify Engineering 官方工程博客《Reflex: an architecture for building mobile apps with agents》 整理。