
两年多前我还在靠控件树过活的时候最崩溃的时刻不是用例写不出来而是脚本跑得越来越慢——慢不是慢在操作是慢在判断界面。每点一下等控件加载等元素出现等结果反馈这些零碎的等待叠加起来比实际操作本身耗时长得多。后来我开始尝试一种新思路让一个视觉模型来看界面每一步操作前的判断全部交给它。这就是今天想聊的Jev。它承接了自动化流程里最耗时也最容易出问题的那一段——界面判断。这篇文章我会把方案选型、接入方式、实测性能和踩过的坑都摊开来聊给正在被控件树和轮询等待折磨的人一个可参考的替代路线。1. 移动端自动化里的界面判断为什么成了提速瓶颈1.1 传统控件树方案的三个慢性病早期做移动端自动化绕不开Appium这类基于控件树的方案。它的逻辑其实是探针式的脚本通过UIAutomator、XCUITest或者WebDriverAgent拿到当前页面的控件层级和解剖信息然后再从这棵控件树里找到目标按钮、输入框、列表项。听起来很精确但实际跑起来有三个特别难受的点。第一是等待的不可控。UI这东西不是静态的列表在滚动、图片在加载、接口回包之后局部刷新控件树的生成时刻和页面实际渲染完成时刻往往对不上。所以脚本里塞满了WebDriverWait、sleep、轮询找元素。为了稳定等待时间只能往大了设结果就是十步操作里八步都在等。我统计过自己维护的回归脚本平均每一步的等待查找元素耗时是操作本身的3到5倍。第二是跨端差异性。iOS和Android的控件树结构完全不同同一个业务在两端可能要各写一套定位逻辑如果产品用了Flutter、React Native或者小程序情况更麻烦控件树经常不是标准结构定位路径一升级就断。维护成本最狠的是——控件的唯一标识不定期变化比如Android里的resource-id一旦被重构改名回归脚本里十几个定位器全部报废。第三是动态内容的视线盲区。控件树描述的是页面结构不是页面视觉状态。按钮是否被弹窗遮住了列表是不是已经滚动到了底部图片是否真的加载出来了这些控件树没法直接回答。我以前就遇到过脚本明明已经找到了按钮并且点下去了实际屏幕上那个按钮被一个开屏弹窗盖住点击事件全打在弹窗的View上直接”假成功“。这种翻车最恶心因为它不报错只产生坏数据。1.2 从查表到看图视觉判断重新回到舞台控件树查不到的、查得慢的、查了也不准的那些信息本质上都是视觉信息。所以自然会有人想到干脆让机器直接看屏幕截图用图像识别来做判断。这条路其实不新鲜Airtest很早就引入了图像识别靠模板匹配找图标和特征区域。但传统模板匹配非常脆弱换个图标、换个字体、换个分辨率匹配就失效了而且它只能回答这里有没有某张参考图没法回答这个页面现在处于什么状态用户操作之后的反馈是否正常这类抽象问题。真正让看图重新可行的是视觉语言模型VLM的出现。这类模型把屏幕截图当作图片输入模型本身具备理解界面布局、语义和交互逻辑的能力。它不是找一张图完全相等的模板而是理解这个页面上有一个蓝色的大的搜索按钮大概在屏幕上方三分之一位置”是自然语言级别的理解。这就意味着相同的脚本逻辑可以在iOS、Android、微信小程序甚至游戏中复用——只要屏幕截图的视觉信息足够模型就能给出判断。1.3 Jev的具体定位一个本地化的界面判断服务Jev就是在这个背景下出现的。它本质上是一个为界面理解优化的视觉模型服务来自一个学术界背景的团队原本是为了让数据采集系统能够自动读取各种界面、理解页面状态后来被用到了移动端自动化场景。和一般直接跑在笔记本电脑上的AI框架不同它被打包成了一个本地的模型服务输入是截图输出是结构化的界面状态描述比如当前页面名称、关键元素位置、可操作状态、弹窗遮挡情况。这个定位很像给自动化框架装了一个视觉队长每一步动作之前先把屏截图发给它由它判断这一步能不能做、目标元素在哪里、做完之后页面有没有正确变化。之所以强调承接每一步是因为在自动化流程里界面判断不是一个独立环节而是一个密集调用的小动作。每一步点击、输入、滑屏背后都需要至少两次判断——操作前确认目标界面和目标元素操作后确认状态变化是否符合预期。用Jev把这两次判断全部接管后自动化代码里就不用再塞各种等待和控件查询了从逻辑上看脚本变得更像一个动作指令清单界面状态检查被抽离到一个统一的服务里。2. Jev承接每一步界面判断的核心设计2.1 把界面判断拆成四类子任务我在把Jev接入自动化脚本时最先做的一件事是把界面判断这个模糊的大概念拆成四类可调用的子任务。拆开之后才发现其实每一步自动化动作就对应着一到两个明确的判断类型。判断类型输入输出对应的自动化动作页面确认当前截图、期望页面名是否匹配、置信度确认已进入目标页面元素定位当前截图、目标元素描述元素坐标、可见性点击、滑动、断言状态识别操作后截图、期望状态状态是否达成结果校验、流程推进障碍检测当前截图、目标元素描述是否被遮挡、遮挡区域关闭弹窗、调整操作实际使用中页面确认和障碍检测是最容易被忽视但最有用的两类。就拿前面说的弹窗盖按钮的情况来举例如果先用Jev做一次障碍检测判断出目标元素被弹窗遮挡脚本先调用一次关闭弹窗的动作再去点目标按钮整个流程就稳了。以前我做自动化从来不考虑这类视觉遮挡现在它成了标准动作。2.2 为什么要本地部署而不是调用云端接口Jev最打动我的一点就是它可以完全本地部署。现在很多大模型能力都只在云端开放API但对移动端自动化来说云端方案有几个我非常介意的问题。一个是延迟自动化脚本里每一步都要做界面判断如果每次判断都走一次公网往返平均延迟至少多出来几百毫秒遇到网络波动直接卡死另一个是稳定性很多自动化测试环境是在内网或者无外网隔离环境里跑的完全依赖外网API根本没法落地。本地部署带来的另一个好处是数据闭环。界面截图往往包含业务敏感信息比如工单编号、客户名称、内部系统的表单数据。如果这些截图直接传到云上合规压力很大。本地部署之后截图从生成到判断再到销毁全程不离开本机测试环境隔离也好做安全边界就很清晰。从成本上说模型推理只需一块普通GPU甚至纯CPU也能跑比起按次数收费的云端API跑大规模回归时的整体成本是可控的。2.3 模型服务的小步快跑设计界面判断和对话聊天不一样它要求的是毫秒级响应和稳定的调用接口。Jev的实现按我理解是围绕小步快跑做的设计模型本身是量化过的轻量版本服务端做了预加载避免每次调用都重复初始化客户端走的是常驻连接而不是每次新建连接最重要的是它的判断逻辑被封装成了纯函数式的接口输入一张截图返回一个JSON自动化脚本这边完全不需要关心模型内部细节。这几个设计思路非常对路。我接入后发现一次界面判断在本地GPU上的耗时基本上能控制在300到800毫秒。对于自动化来说这个量级意味着每一步操作前后各做一次判断增加的延迟在1秒左右——远比以前动不动sleep三五秒的等待策略快得多。而且因为判断是纯函数式的缓存很好做如果同一个页面连续多次出现直接命中缓存连推理都省了。3. 把Jev接入移动端自动化的完整实操3.1 部署Jev模型服务部署过程没有想象中复杂。到项目GitHub仓库把代码克隆下来按README准备Python环境和模型权重然后启动服务。我在Windows工作机上部署过一次也在Ubuntu服务器上部署过一次主要差别在于模型推理后端Windows上要确保CUDA版本和PyTorch对齐Ubuntu上相对省心。启动之后服务默认监听本机的一个端口提供HTTP接口。第一次启动慢是因为要加载模型权重到显存或者内存里加载完成之后后续调用都很快。我习惯把这个服务设置成开机自启或者用进程守护工具托管。因为自动化脚本跑起来经常是半夜的回归任务如果服务中途挂了脚本只会报一堆连接错误排查起来很头疼。稳定运行几天之后你会发现这个服务的资源占用并不高显存占用大概在2到4GB和跑一个大一点的应用差不多。3.2 写一个通用的界面判断客户端部署好服务之后第二步是让自动化脚本能方便地调用它。项目仓库自带的客户端模块我会直接用没有的话自己封装也很简单。核心就一个方法传截图、传目标描述返回判断结果。这里分享一个我封装的客户端代码结构比较典型。import base64 import json import time import requests class JevClient: def __init__(self, endpointhttp://127.0.0.1:8765): self.endpoint endpoint def _encode_image(self, image: bytes) - str: return base64.b64encode(image).decode(utf-8) def judge(self, image: bytes, target: str, timeout5): payload { image: self._encode_image(image), target: target, task_type: element_location, confidence_threshold: 0.6, } resp requests.post(self.endpoint /v1/judge, jsonpayload, timeouttimeout) resp.raise_for_status() return resp.json() def wait_for(self, image_provider, target, timeout10, interval0.5): start time.time() while time.time() - start timeout: result self.judge(image_provider(), target) if result.get(matched) and result.get(confidence, 0) 0.6: return result time.sleep(interval) raise TimeoutError(fJev 判断超时: {target})这段代码的重点是judge方法。它把截图转成base64字符串和目标描述一起打包成JSON发给模型服务模型返回的结果里包含是否匹配、置信度、目标元素坐标。wait_for方法则是对页面等待的封装轮询调用判断函数直到条件满足但不同于传统方式的盲等它每轮轮询都有真实的视觉反馈所以在检查循环里可以很放心地设置较短的间隔。3.3 在主流程中用Jev驱动每一步有了上面这个客户端自动化主流程会变得异常清晰。我把一套完整的用户注册流程写成过这样的结构不需要在每一步里繁琐地找元素只需要声明我要做什么、目标在哪里Jev替我去找、去判断、去确认。steps [ {action: wait_for, target: 注册页面}, {action: tap, target: 手机号输入框}, {action: input, target: 手机号输入框, text: 13800138000}, {action: tap, target: 获取验证码按钮}, {action: wait_for, target: 验证码已发送提示}, {action: input, target: 验证码输入框, text: 123456}, {action: tap, target: 完成注册按钮}, {action: wait_for, target: 注册成功页面}, ] for step in steps: if step[action] wait_for: result jev.wait_for(lambda: driver.screenshot(), step[target]) elif step[action] tap: result jev.wait_for(lambda: driver.screenshot(), step[target]) x, y result[coordinates][center_x], result[coordinates][center_y] driver.tap(x, y) elif step[action] input: result jev.judge(driver.screenshot(), step[target]) x, y result[coordinates][center_x], result[coordinates][center_y] driver.tap(x, y) driver.input_text(step[text]) result jev.judge(driver.screenshot(), f输入框内容为{step[text]}) if not result.get(matched): raise RuntimeError(f输入未生效: {step[target]})可以看到这个流程里已经完全没有控件树的概念了。每执行一个动作之前Jev先确认目标元素位置执行完之后Jev再确认状态变化。判断目标用什么语言描述都行人类能理解的自然语言描述模型基本都能理解。这个用自然语言写自动化的感觉在维护上带来的舒适感非常明显。3.4 与Appium/Airtest的混合双引擎方案不过我要泼一点冷水如果短时间内要大规模替换现有自动化资产直接用Jev重写所有脚本不现实更稳妥的做法是混合双引擎。我现在维护的这套框架就是让Jev当视觉主引擎Appium的控件树只作为一种兜底手段。举个例子输入框点击之前我先用Jev视觉定位点击位置但输入文本内容的时候还是会通过控件树设置文本。因为视觉模型虽然能指出这是个输入框但真实键入时如果输入法弹层变化、键盘遮挡界面光靠视觉处理还是容易出现偏差。另一个典型的混合场景是列表滑动和元素可达性判断。列表里的目标项可能在屏幕外视觉模型不会主动去滚动列表这时候就需要脚本先判断目标项是否在屏幕内不在就通过控件树查找列表是否可以再滚动或者直接用滑屏手势翻页。所以我的建议是把Jev承接界面状态判断和元素视觉定位把原生操作能力仍交给已有的自动化框架。两者各司其职稳定性最好。4. 实测中的性能调优与问题排查4.1 推理耗时的三个降速因素和优化手段把Jev接进真实测试流程后我第一件事就是测性能。推理本身不是瓶颈瓶颈往往出在截图处理、请求打包和模型输入尺寸上。我遇到过第一次一个判断平均要1.5秒后来排查发现是截图原图太大模型输入前需要缩放而服务端做缩放消耗了额外时间。最后我在客户端直接先压缩好截图再发过去把单次判断耗时降到了600毫秒以内。还有一个很容易踩的坑是HTTP请求超时设置。模型在冷启动之后的第一次推理会慢一些如果客户端超时设得太短经常触发超时重试反而更慢。我的做法是客户端做一次预热请求测试任务开跑前先发一张普通截图给Jev让模型权重和服务端缓存全部就绪后面再调用就稳定了。预热这个动作强烈建议保留在执行任务的setup阶段。另外如果测试机很多同时发起大量判断请求本地服务会堆积。我试过的有效优化手段是客户端加一个简单的信号量限制并发推理请求数让服务端保持稳定的吞吐。经验值是单卡上并发数设成4到8最合适超过这个值延迟明显上升吞吐反而没有提升。4.2 视觉误判的典型场景和处理思路任何视觉模型都会误判Jev也不例外。我遇到的误判主要集中在几类第一类是相似页面的混淆两个页面布局很像但功能不同模型给了较高的置信度从而导致流程走错。解决办法是在目标描述里增加区分性信息比如包含‘我的订单’四个字的订单列表页面模型就会启动文字感知能力准确率高很多。第二类是弹窗和浮层干扰。现在很多App喜欢运营弹窗模型经常把弹窗当成页面的正常内容导致元素坐标偏移。这个没有完美的自动方案我的做法是接入了障碍检测每次判断请求同时传入目标元素描述和该元素是否被遮挡的检查项。如果模型返回遮挡脚本会先查找关闭按钮并点击再继续业务流程。第三类是动画和过渡态。页面切换时的ViewPager滑动、加载动画、Toast提示这几个状态特别容易造成误判。我自己总结出来一个笨但有效的办法对每个容易出问题的步骤加入前后两次连续判断如果两次结果不一致就重试一次。这个二次确认机制加在一个关键步骤上误判导致的失败率能降下来一半以上。4.3 坐标偏移与多分辨率适配Jev返回的坐标是基于截图坐标系的这和设备实际屏幕坐标系之间通常有一个缩放关系。起初我忽略了这一点在分辨率为2340乘1080的设备上录好脚本换到分辨率为1280乘720的设备上跑点击坐标全都偏了。后来我统一做了一次坐标换算规则很简单模型服务端统一把截图缩放到某个输入尺寸再识别坐标输出是相对坐标百分比客户端拿到相对坐标后乘以设备实际屏幕的宽高这样任何机型都适配。rel_x result[coordinates][center_x] # 取值 0~1 rel_y result[coordinates][center_y] # 取值 0~1 real_x int(rel_x * device_width) real_y int(rel_y * device_height)这个改动解决了多机型兼容的问题。但要注意如果设备开启了系统级显示缩放或者字体大小设置相对坐标依然会出现偏差建议在测试环境里统一关闭系统显示缩放保证基线一致。4.4 常见问题速查表问题现象可能原因解决办法每次判断耗时超过2秒截图未压缩、请求体过大客户端预压缩截图控制单张在200KB以内首次调用就报超时模型冷启动、服务未预加载调用前做预热请求或服务端常驻模型权重同页面两次定位坐标不一致页面存在动态布局或弹窗干扰二次确认机制加上障碍检测换机型后点击位置偏移坐标系未归一化统一输出相对坐标再乘以设备宽高深色模式或夜间模式下识别失败训练样本亮色界面占多收集深色截图给模型补充few-shot样本模型服务内存缓涨不降长时间运行连接未释放定期重启服务或监控服务进程内存5. 一些过来人的经验和后续扩展做了一段时间Jev接管的自动化我最大的体会是界面判断本身是一个可以被产品化的模块它不应该散落在每个用例脚本里而应该像数据库连接池一样抽象成一个基础设施。Jev真正带来的不是某一步快了而是让我把自动化脚本从面向控件编程变成了面向意图编程。我现在写用例时只需要想清楚用户在这个页面要做什么、做成什么样算成功剩下的坐标、等待、校验全都交给了Jev的每一步判断。如果后续要往更深处发展我觉得有三个方向值得试。第一把Jev的判断结果和录制回放工具结合人工点一遍业务流Jev自动转成可执行步骤自动化用例产出效率会大幅提升。第二把每一次判断的截图和结果存下来做一个回归样本集遇到模型误判就人工标注纠偏引入主动学习能力判断会越来越准。第三把Jev从测试环境用到线上监控对生产环境的App界面做实时观测页面出问题或者布局异常时第一时间报警。Jev承接界面判断这件事适用面其实比我一开始以为的要宽得多。