Shopify 介绍 Reflex 架构:利用智能体构建双端原生移动应用
Reflex: an architecture for building mobile apps with agents
Shopify 详细介绍了其用于支持 AI 智能体开发原生移动应用的 Reflex 架构,通过无头运行核心逻辑消除了模拟器调试瓶颈。该架构将业务逻辑抽离至无 UI 依赖的核心模块,通过统一的 JSON 契约让 CLI 直接驱动视图模型,实现用单套测试文件同步验证 iOS 与 Android 双端原生实现。
解耦UI并在无头环境下暴露状态与动作,为团队利用编程智能体同步维护双端原生应用提供了工程范式。
在《原生开发重归 Shopify 移动端主力》中,我们解释了重回 Swift 与 Kotlin 的原因;在《Helix》中,我们分享了智能体如何通过细粒度的检查点与严格的门禁来重构页面。两篇文章都提到了一个 CLI 工具,它能让智能体无需模拟器就能检查应用状态并执行操作。本文将详细介绍这套底层架构——我们称之为 Reflex。
当我们决定用原生技术重构应用时,我们回到底层重新思考了一个基本问题:当代码大部分由智能体编写时,移动应用究竟该如何架构?我们当时遇到的最大瓶颈是反馈回路。智能体可以在几分钟内写好一个页面,但要验证页面能否正常运行,却需要模拟器、打包构建、点击脚本以及漫长的等待。为了加速这一过程,我们曾大力投入模拟器工具链,但速度很快就达到了上限。
Reflex 便应运而生。应用的逻辑以无头模式运行,每个页面都通过一套轻量的协议对外暴露自身状态和可执行动作,CLI 则根据同一份测试文件在两大平台上调用这套协议。这种方式带来了极快的反馈回路,也让我们能够始终保持两套原生实现的同步。接下来我们将逐一拆解它的运作机制,并说明如何为你自己的应用搭建一套相同的体系。
本文要点:
- Reflex 概览——四大支柱
- 共享测试,而非代码——为什么两套原生应用只需一份测试文件
- 立足第一性原理构建的协议——模块拆分、ViewModel、自省工具包(Introspection Kit)与节点树
- 我们的测试金字塔(Test Trophy)——在什么阶段测什么,附完整业务流程示例
- 会开源吗?——自行搭建这套方案需要准备什么
- 常见问题
Reflex 概览
Reflex 是一套基于 MVVM 的架构,它让应用无需渲染任何 UI,就能被智能体快速构建、测试与驱动。它建立在四大支柱之上:
- 无 UI 的核心模块: 除了视图之外的所有内容,都放在一个没有引入任何 UI 库、也没有设备依赖代码的独立模块中,使应用逻辑能够以无头模式直接在 macOS 或 JVM 上运行。
- 严格的 ViewModel 协议:每个页面的 ViewModel 都遵循统一的 Elm 风格规范:输入动作、输出状态,且状态变更只在一个统一入口发生。
- ViewModel 自省能力: 每个 ViewModel 都会通过一份 JSON 协议暴露自身:包含名称、当前状态以及所接受的动作。正是这一机制,让 CLI 等宿主应用之外的外部进程能够读取页面状态并对其执行操作。
- CLI 命令行工具: 跨平台的命令行工具,允许智能体直接通过该协议与应用交互,无需启动任何模拟器。由于它与平台解耦,我们只需写一份测试用例就能同时覆盖双端。
四大支柱相辅相成,赋予了智能体自主逐个探索功能特性的能力,不仅摆脱了对设备的依赖,还带来了极速的反馈闭环。此外,由于 iOS 和 Android 共用同一份测试文件,任何单一平台如果进度落后,该功能就无法发布。
从智能体的视角来看,交互过程如下:
-
ls会打印出当前页面的名称、JSON 格式的状态以及支持的操作 -
do Action [json]会调用其中某个操作,精准模拟用户的交互行为 - 无论 CLI 是对接无头核心进程还是真实构建产物,它都始终只与 ViewModel 通信,从不触碰渲染后的 UI。
这就是全部的用户界面了。没有终端界面(TUI),没有色彩,也没有旋转加载动画。智能体不在乎这些,而我们省下的终端美化时间,全用在了打磨反馈闭环本身。
开发两套原生应用,意味着每个界面都要写两套实现,必须有某种机制来保证两者的一致性。我们的应对方式最终沉淀为一个反复践行的原则:共享测试,而非共享代码。代码写两遍,测试只写一次。这一原则建立在两项决定之上。
- 平台间不共享任何代码。iOS 用 Swift,Android 用 Kotlin,各自使用自家的 UI 工具包与并发模型,中间没有任何跨平台层。在智能体承担大部分代码编写的前提下,写第二套原生实现的成本已经远低于以往。相比之下,抽象层的固有代价依然沉重:它扩大了维护面,掩盖了后续优化性能所需的平台底层行为,还让每一次平台版本升级都变成了两步走的大迁移。把同一个 ViewModel 写两遍,成本反而比维护一套跨平台桥接层更低。
- 靠测试而非代码来强制保证一致性。如果不共享代码,就必须依靠其他手段来保持两端同步。在我们的工作流中,靠的就是测试套件:每项行为测试都会在两个平台上运行,只要其中一个没跑通,PR 就无法合并。测试才是唯一共享的核心资产。
接下来的问题是:该采用什么样的测试?常规方案各有弊端。
- 跨平台端到端(E2E)测试:一份测试覆盖两端应用,我们在少数特定业务流程中仍在使用。尽管依靠自研的 E2E 框架实现了极高的稳定性,但对于智能体循环和 CI 检查来说,执行速度依然不够快。此外,这种测试必须依赖模拟器或仿真器运行,一旦测试失败,排查时还得查看录屏或截图。
- 基于 XCUITest 和 Espresso 的原生 UI 测试:速度更快、更稳定,但依然离不开模拟器或仿真器,而且每个平台都得单独写一份。测试代码因而完全重复,一端通过了,也完全无法说明另一端的情况。
- 单元测试:虽然运行迅速,但测的只是应用的 Mock 伪装,而非应用本身。它们同样要按平台各写一遍,两个测试套件就会像两份业务代码一样逐渐产生分歧。而且单元测试与具体实现贴得太近,对比 Swift 和 Kotlin 的两套测试,很难看出两端应用是否完整覆盖了相同的用户流程。
最终,我们选择了一条折中路线,更接近灰盒测试。测试既不关注屏幕像素,也不窥探内部实现,而是与 UI 看到的一模一样:只观察 ViewModel 的状态,以及 ViewModel 接收的操作。底层的其他所有部分(真实的 reducer、真实的服务、真实的 GraphQL 客户端、真实的导航逻辑)全是真家伙,只在应用原本需要触碰网络或设备的最边缘位置替换为测试夹具(fixtures)。整套测试中完全没有引入任何 Mock 框架。
基于第一性原理建立的契约
将 UI 移出核心考量
我们用一个小例子来概括介绍其架构:一款简单的库存管理应用。用户从列表中挑选一件商品,打开详情页,然后修改库存数量。这个例子足以展示整体结构,许多细节在此特意略去。示例代码使用 Swift 编写,但 Kotlin 版本的结构完全一致,读起来也相差无几。
每个平台上的应用都拆分为两个模块,外加一个 CLI 模块。
-
Core负责除 UI 之外的一切:ViewModel、各类服务、导航状态、GraphQL、持久化逻辑以及深度链接。在 iOS 上它是一个普通的 Swift 软件包,在 Android 上则是一个纯粹的 JVM 模块。不包含
UIKit,也没有android.*。 - Mobile负责 UI 及平台集成:SwiftUI/Compose 视图、Keychain、推送、相机、系统分享面板。Mobile 依赖 Core,而 Core 对 Mobile 一无所知。
- CLI和 Mobile 一样依赖 Core,但它运行在 macOS 或 JVM 上,而不是手机上。正因如此,它无需屏幕即可控制应用。

Core 从不导入 Mobile。凡是需要平台提供的能力,Core 都通过定义协议(protocol)来声明,并在构造函数中接收具体实现。这是纯粹的依赖倒置,无需任何依赖注入(DI)框架。移动端应用和 CLI 只不过是负责构建 Core 的宿主环境。CLI 使用测试固件(fixture)来替代真实的平台服务。
这种拆分带来了几大优势:
- 导航逻辑内置在 Core 中。压入一个新屏幕只是导航器(navigator)上的状态变化,因此 CLI 可以在不创建任何视图的情况下遍历整个应用。深度链接也是同理。
- 只要语言能运行,Core 就能运行。Swift 核心可以在 macOS 上构建(理论上 Linux 也行);Kotlin 核心则是一个纯 JVM 模块。Android 的行为测试只需要 JVM,无需模拟器。
- 测试速度极快,因为无需渲染任何内容。没有布局计算,没有动画,也不用等待视图呈现。一次操作就是一个函数调用,紧跟着一次 JSON 读取。更关键的原因在于无头执行(headless execution):在 CI 上,双平台 1700 多个行为测试大约四分钟就能全部跑完。
- 测试固件作为构造函数参数传入。任何通常来自进程外部的状态(GraphQL 响应、Keychain 条目、生物识别可用性、特性开关),都直接传给构造函数。要模拟边缘情况,只需传入不同的测试固件,而不需要在测试执行到一半时去动态打桩。
Reflex 本身没有任何第三方依赖:只有一个基础 ViewModel 类、一个用于自省(introspection)的小协议(见下一节),以及绝不向 Core 中引入平台级依赖的纪律要求。对人类开发者来说,严格坚守这一纪律很难;但对智能体而言,这不过是一种不断复用的模式,而这正是它们所擅长的。
应用的架构形态
在 Core 内部,整个应用由一棵纯对象树构成。根节点是 app 对象,掌管网络客户端、当前会话和持久化逻辑。下层是各个导航器(Tab、Push、Modal),分别维护当前屏幕上展示的路由列表。叶子节点则是每个屏幕对应的 ViewModel。整棵树中没有任何组件知晓 UI 的存在。
视图位于 Core 之外的 Mobile 模块中。一个视图绑定到一个 ViewModel,读取它的 state,并调用 send(action:)。这就是视图与应用其余部分的全部关联。
Introspection Kit 是一个独立的模块,用于定义 ViewModel 与 CLI 之间的交互契约。每个 ViewModel 都会通过一个简短的扩展将其自身描述为一个节点:包含名称、JSON 格式的状态以及它所接收的操作。CLI 和测试 SDK 仅与该层通信,应用本身从不调用它。

本节接下来的内容将逐一介绍这些组成部分:ViewModel、自省节点,以及这些节点如何组合成一棵供 CLI 遍历的树。
ViewModel:输入操作,输出状态
每个屏幕背后都有一个 ViewModel,其设计思路借鉴了 Elm 架构。具体构成如下:
- State——屏幕呈现的一切内容,由 ViewModel 持有。View 只读取它,不碰其他任何东西。
-
Action——外部暴露的接口:即 View(或 CLI)能让 ViewModel 执行的所有操作。它定义为枚举,通过
send(action:)分发。 -
Message——内部流转的接口:
Action被包装在.viewAction中,主要用于承接各类服务返回的结果。它仅对 ViewModel 内部可见。 -
update(state, message)——即 reducer。它是修改状态的唯一入口,所有执行路径最终都在这里交汇。 - Services——网络、本地存储等所有会产生副作用的操作。依赖的协议均在构造函数中注入,便于 CLI 轻松替换成测试用的 fixture。
View 遵循的全部契约只有两条:读取 state,调用 send(action:)。它绝不会直接调用 ViewModel 的普通方法,也绝不写入任何状态。
以下是详情页的 ViewModel 代码。这是伪 Swift 代码:去掉了并发注解、访问控制等修饰,只保留展示架构所必需的骨架。
Action 是对外接口,Message 是内部接口,update 是唯一的写入入口。正因为架构规范极其严格,智能体即便重复生成几百遍也能做到分毫不差。
Introspection Kit:从外部审视 ViewModel
CLI 需要一种方式来读取 ViewModel 并向其发送 Action。我们把这一层称为 Introspection Kit,而 ViewModel 只需实现一个协议即可接入:
ViewModel 通过在 +Introspection 扩展中返回一个节点来对外暴露自身,该节点反向持有对 ViewModel 的引用。Android 端思路完全一致,仅有一点细微区别:Kotlin 扩展无法让既有类型实现新接口,因此节点是紧邻 ViewModel 的一个独立 InventoryDetailViewModelIntrospectionNode 类。App 本身绝不会调用这部分代码。即便删掉该文件,App 的编译和运行也不会受到任何影响,只是无法再通过终端来控制它。
该节点由四个部分组成:
-
name——当前屏幕的固定标识符,测试用它来判断当前所处的界面。 -
state——ViewModel 的状态,编码为 JSON 格式。包含用户能看到的所有字段。 -
actions——对应每个Action分支:包含一个标题以及一个调用send(action:)的闭包。Action 可以接收参数:由SetQuantity声明输入类型,CLI 发送 JSON 对象,闭包执行前会先将其反序列化为该类型。@IntrospectionInput宏会根据属性自动生成输入的 JSON Schema,因此 CLI 能直接显示 Action 所需的参数格式,并在格式错误的数据进入 ViewModel 之前将其拦截,无需人工维护 Schema。 -
child——可选字段,指向其下级子节点。CLI 从根节点逐级顺着child追踪,就能定位到当前获取焦点的节点,这也是 Action 从父节点传递到子节点的方式。
“手写”这些代码(实际上是用智能体生成)完全行得通,智能体很擅长按部就班地复刻。不过,鉴于节点的结构非常严谨,它也能完全自动化:只需在 ViewModel 上添加宏或注解,即可在构建期自动生成上述节点。
三条核心规则:
-
只要用户看得到,就放进
state。只要用户能操作,就放进actions。节点本质上就是 ViewModel 的纯 JSON 投影。 -
节点绝不重复实现 UI 逻辑。它每次都会无条件列出所有 Action,不加任何条件判断。库存为零时
Decrement是否生效完全由 ViewModel 决定,并在update里处理——处理方式与点击按钮完全一致。如果你发现自己在节点里写了 if 判断,那这段逻辑就该挪回 ViewModel,而结果则应当体现在state.中。 -
所有动作必须通过
send(action:)触发,绝无例外。节点绝不会直接调用 ViewModel 的方法,也不会自行修改状态。View 做不到的事情,CLI 同样做不到。
这种约定不仅适用于测试。我们希望将应用打造成“智能体优先”(agentic-first)的架构,Shopify 的 AI 助手 Sidekick 在应用内执行操作时,调用的正是 CLI 所使用的同一套“状态与动作”接口,两者共用一套体系,完全不需要专门的集成层。智能体在终端里能做的事情,应用内的智能体同样能做到。
树状结构
自省节点与本节开头提到的对象树一一对应,每个对象对应一个节点,并通过 child 连接。应用根节点的子节点是导航器,导航器节点的子节点则是当前最顶层路由的 ViewModel。CLI 会始终聚焦在这条链上层级最深的节点,就像真实用户只会与最上层的界面交互一样。
容器可以主动将动作“借”给当前聚焦的子节点。堆栈导航器会传递 goBack,标签页导航器则会传递 selectTab;普通 ViewModel 则绝不传递任何动作。如果要求 CLI 执行一个既不在当前聚焦节点上、也没有从祖先节点继承的动作,它会直接拒绝执行,并列出当前所有可用的动作。
传输层
在无界面的 CLI 模式下,核心进程从标准输入(stdin)读取换行符分隔的 JSON 数据,再写出到标准输出(stdout)。而在真实构建版本中,则由一个微型 Socket 服务通过端口完成同样的工作,该 Socket 会直接与屏幕绑定的 ViewModel 通信。正因如此,你在输入命令时模拟器界面会实时刷新:期间完全不需要与渲染后的 UI 产生任何交互,纯粹靠视图层监听状态变化即可。该服务在正式发布版中会被编译剔除,因此不会包含在我们交付的应用中。
我们的测试奖杯模型
我们最终形成的测试分布不像传统的金字塔,更像一座中间极宽的“奖杯”:
- 针对基础元件和共享组件的单元测试:主要覆盖格式化工具、解析器、基础 ViewModel、分页辅助函数等。这类测试体量小、数量也少。
- 针对其他所有内容的 CLI 行为测试:覆盖业务逻辑、校验规则、页面导航、错误处理以及完整的用户流程。这是我们测试覆盖率的大头,而且一份测试文件就能跨双平台运行。Helix 的行为门禁(behavior gate)背后也是这套测试:只有当 CLI 测试在双平台上均通过后,检查点才会向前推进。
- 顶层的薄薄一层:少数几个真实的线上集成端到端流程,以及一套通过同一协议驱动真实构建版本的性能测试套件。

关于这层中间测试,有几点值得一提:
强制彻底杜绝网络请求
行为测试会注入一个拦截器,拦截宿主进程中的所有 HTTP 请求;一旦发生请求尝试,测试就会判定失败,哪怕应用本身捕获了异常并成功恢复也不行。每次网络调用和外部依赖都必须使用本地固件(fixture)。这样做的好处是整个测试套件没有任何 I/O 开销,彻底消除了偶发不稳定性;代价则是固件体积会变得很大。单次业务流程可能就要附带十几个 GraphQL 响应,人工维护起来相当繁琐,但对智能体来说却轻而易举。
失败报错清晰易读
当灰盒测试失败时,报错信息会明确指出具体是哪一条断言未通过。智能体无需打开调试器,直接根据提示就能采取行动。
测试用例读起来就像完整的用户旅程
我们在 Bun 的测试运行器上为 Introspection Kit 写了一个轻量测试 SDK。它使用与 CLI 相同的 JSON 协议,没有运行时依赖,并在 bun:test: precondition 的基础上增加了两项功能:一是等待当前聚焦的节点变成期望节点,二是 do,即执行某个操作并等待新状态满足预期。以下是库存详情界面的测试示例(该测试无需修改即可在 iOS 和 Android 上运行,具体平台由测试框架调度):
这两个辅助方法都会轮询几秒钟,若超时则以最后获取到的状态宣告失败,因此既不需要硬编码休眠,也不需要“等待元素加载”这类辅助逻辑。测试记录下来的,就是用户的一连串操作与所见内容。
你完全可以把测试粒度切得很细,但我们有意不这么做。在单元测试中,每个用例只测一点确实能精准定位出问题的地方。但在我们这里,失败信息本身就已经说明了一切:流程走到哪一步、执行了什么操作、期望的状态是什么、实际抓到的又是什么。智能体很擅长解读这些信息并找出根源,根本不需要把测试拆得那么细碎。所以我们会把整条操作流程收拢到一个测试里,把省下来的开销用在缩短执行时间和降低维护成本上。只有像前文提到的保存失败这种需要独立 fixture 的异常场景,我们才会单独拆分——这是因为环境准备不同,而不是为了断言才拆开。

这些测试用例的运行速度极其惊人。预热过后,上面这个测试用例在两个平台上脱离真机运行,都只需两三百毫秒。

而同样的测试在 iOS 模拟器上跑一次,大约需要两秒钟。

那会开源吗?
这篇文章就是开源内容。
我们不会发布任何代码库,因为真正有价值的并不是我们的具体代码。Introspection Kit 与我们自己的会话模型、导航器以及 fixture 格式高度绑定,这些直接搬到别的应用里毫无用处。真正可以复用的是 Reflex 本身,也就是上文描述的这套架构。一个能力过关的代码生成智能体,完全可以参考这篇博客,用你熟悉的语言为你的应用实现一套出来,而且生成的效果会比我们打包的任何通用库都更贴合你的代码库。
要为你的应用引入 Reflex,你需要做到以下几点:
- 把平台相关逻辑与核心逻辑剥离。 独立出一个完全不引入 UI 依赖的模块,所有平台能力都通过传参注入。如果你无法在 macOS 或 JVM 上编译核心模块,说明解耦还没到位。
-
统一所有 ViewModel 的接口形态。 输入是操作,输出是状态,对外只暴露一个
update,业务服务全部通过构造函数注入。 -
以内省节点作为扩展形式接入。 状态序列化为 JSON,每个操作都包装为对
send(action:)的闭包,不要写条件分支逻辑。智能体完全可以根据 ViewModel 自动生成这些内容。 - 构建树状结构并聚焦叶子节点。 应用根节点和导航器也都是树上的节点。容器可以向下透传少量操作。其余一切节点皆不可触碰,就像用户看不到屏幕之外的内容一样。
- 禁止任何真实网络请求。 所有数据一律走 fixture。只有这样,测试套件规模变大后才能保证结果稳定可复现。
- 为智能体编写 skill,而不是写人类文档。 教你的智能体怎么运行 CLI、怎么添加 fixture、怎么给测试命名。CLI 能发挥多大威力,完全取决于智能体能不能自如地调用它。
- 剔除只给人类看的花哨设计。 不要 TUI,不要精美的排版输出,也不要交互式向导。这是给智能体用的工具,保持极致的简洁和迅速即可。
Reflex 为我们带来了契合智能体工作节奏的反馈回路,并用一套测试套件让两款原生应用保持同步。如今,这套套件已成为我们 iOS 和 Android 应用间共同的事实来源,其中绝大部分由智能体编写;要想了解应用具体能做什么,最快的方式依然是打开终端并输入 ls。
常见问题
如果不测试 UI,这些测试究竟有多大价值?
价值非常大。一个界面出问题,绝大多数都出在背后的逻辑上,而这正是这些测试所覆盖的重点。检查按钮是否正确绑定到 send(action:),往往很难抓出 bug。UI 验证放在了流程中的其他环节:每个 Helix 检查点在合入前,都会与参考应用进行视觉比对审查(参见关于 Helix 的文章);对于少数对像素精度要求极高的组件,我们保留了截图测试。再加上我们自己每天都在使用该应用,本身就有一层持续的实际使用检验。针对 ViewModel 状态的行为测试,再加上这两层保障,就是我们判断一个界面是否达到上线标准的依据。
为什么不采用单元测试,加上 XCUITest 和 Espresso 的 UI 测试?
我们前面谈到过单元测试和原生 UI 测试:这两种测试都需要在每个平台上各写一套,而且都无法说明双端体验是否对齐。此外,这种对比还忽略了探索性。这两者都无法让智能体在预设测试之外自主探索应用,而事实证明,这种探索能力和测试本身同等重要。如果智能体能像执行 ls 那样遍历浏览应用,它调试和构建功能的速度就会快得多。而且正是同一套接口,让应用天然具备了面向 Sidekick 的智能体优先特性,否则我们就只能事后生搬硬套了。
那为什么不用 Appium 或智能体驱动的设备自动化?
在合适的场景下我们确实会用这些工具。不过,由于代码库完全由我们掌控,我们可以提供一个灰盒接口,让智能体直接读取状态,而不必从无障碍树或截图中去推测,从而省去了智能体反复读取屏幕的额外开销。更关键的原因是无头运行:数千项测试在 CI 上只需几分钟就能跑完。如果在这个规模下做端到端测试,要跑完所有场景得花上几个小时,还需要大量基础设施来支撑并行。
来源:Shopify Engineering · shopify.engineering