
做移动端自动化的朋友应该都经历过这种场景需求方说某个核心链路必须自动化回归结果你打开控件树一看登录页躺着一张验证码主业务区直接是自定义绘制控件Appium 那一套 findElement 在这两个场景面前基本等于摆设。我最近在一个模拟项目里把 Appium 和多模态大模型结合专门啃这两块硬骨头实测下来比预想中靠谱得多。这篇就把方案选型、完整实现、踩过的坑一次性说清楚。适用对象说白了就两类人一类是天天被验证码卡住的测试开发另一类是面对复杂自定义控件定位无门的自动化脚本维护者。如果你已经熟练使用 Appium 的基础定位方式但遇到这两个场景就头大那这套思路可以直接抄作业。1. 先别急着写代码传统Appium方案到底卡在哪1.1 验证码不是识别技术不行是规则在反识别验证码之所以能成为自动化的拦路虎根源在于它的设计目标就是和机器作对。传统的 OCR 技术典型的就是 Tesseract在处理清晰印刷体字符时有不错的效果但市面上真正在用的验证码早就不按常理出牌了字符扭曲、背景噪点、干扰线、字符粘连还有旋转和遮挡。我在实际项目里用过 Tesseract 识别某系统的数字验证码无干扰时准确率还能到九成一旦加上轻微扭曲和噪点识别率直接掉到六成以下这对测试来说根本不可用。更要命的是滑块验证码和点选验证码。滑块验证码要识别的不只是字符还有目标凹槽的位置和滑块本身的位置点选验证码需要理解文字语义比如按顺序点击苹果、香蕉、葡萄这已经不是传统 OCR 能处理的范畴了它需要模型理解图片里的物体和文字语义。多模态大模型的优势恰好在这里它不是在做字符识别而是在做看图理解对扭曲、干扰、语义指令的容忍度比传统方案高一个量级。这里必须强调一个边界验证码本质是安全防线我们的自动化手段只能用在测试环境、测试包或白名单机制允许的场景里。正规操作是先在服务端为测试账号配置验证码白名单或者使用固定的测试验证码大模型识别是兜底手段而不是用来突破真实线上风控的。这个合规底线踩不得。1.2 复杂自定义控件控件树里根本没有你要的东西Appium 传统定位方式依赖一个前提被测应用的控件树是完整的、带有可识别属性的。它的定位策略本质上就是在 XML 控件树里做查询靠 resource-id、class、text、content-desc或者顺着层级写 XPath。但复杂自定义控件正好把这个前提给打破了。我遇到的典型场景是某直播类 App 的礼物面板。整个礼物区域是用 Canvas 绘制的所有礼物图标都是画出来的控件树里只暴露了一个巨大 View子节点一概没有什么 resource-id、text 全是空。Appium 对这种情况根本无从下手XPath 只能定位到那个空白的大 View你拿它什么都做不了。还有一个常见场景是游戏界面。游戏引擎渲染出来的一帧画面在控件树里就是一个 SurfaceView内部结构完全不暴露。另一个是自定义日历控件日期格子是绘制出来的你无法直接定位到下个月 15 号这个特定格子。这种场景下传统的求稳方案是走图像定位Appium 内置的 image 定位策略底层靠 OpenCV 模板匹配但它有个致命缺陷必须维护一套模板图。分辨率一变、主题色一换、控件状态一改比如选中和未选中模板图就得重新截维护成本高到劝退。2. 让多模态大模型当眼睛方案设计与关键决策2.1 传统视觉方案的短板与多模态大模型的本质优势在引入多模态大模型之前我先把市面上能用的视觉方案捋了一遍列个对比表方便你参考方案适用场景抗干扰能力维护成本单次成本延迟Tesseract OCR清晰印刷字符弱中低低OpenCV 模板匹配固定静态图标中高需维护模板图低低多模态大模型语义描述的目标强低改提示词即可高中多模态大模型的核心价值在于两点第一是语义理解能力你可以用自然语言描述找那个红色的确认按钮它不需要精确的模板图就能理解目标是什么第二是免训练迁移换个业务界面你只需要改提示词不用重新训练模型、不用重新截模板图。这两点直接解决了传统方案在快速迭代场景下维护成本爆炸的问题。但这不代表之前的方案一无是处。实际上我最终的架构是分层的优先尝试 Appium 原生定位原生定位不到再切到多模态大模型识别最后还可以加一层 OpenCV 模板匹配兜底。这样既控制了成本也保证了成功率。2.2 整体链路架构与三个关键决策整套方案的链路其实不复杂核心是四步Appium 脚本控制设备截图拿到当前界面的图像把截图转成 Base64 编码连同提示词一起发给多模态大模型接口模型返回结构化结果通常是 JSON包含验证码内容或目标控件的坐标脚本解析 JSON把坐标换算成屏幕实际坐标通过 W3C Actions 执行点击、输入或滑动。设计这套链路时有三个关键决策要提前想清楚。第一个决策是模型怎么部署。如果你所在团队对数据隐私要求高测试界面截图不能出内网那就得走私有化部署如果只是普通业务自动化直接调公有云的多模态大模型 API 就行了。我个人的建议是先拿公有云 API 跑通流程验证效果再做私有化或者换更强模型的打算。项目初期不要纠结部署形态效果验证优先。第二个决策是模型返回格式。多模态大模型输出的是自然语言如果你让它把验证码告诉我它可能回一句好的验证码是 3s7f。这种文本解析起来很痛苦所以必须在提示词里强制指定 JSON 输出格式并且给出明确的字段名和类型。第三个决策是截图策略。验证码类目标截全屏图就行模型自己会找到验证码区域控件定位类目标建议先找到控件树中那个空白大 View的 bounds只截这一块区域发给模型减少画面中的干扰元素识别准确率和响应速度都会好很多。2.3 什么时候不该用这套方案这方案看着美好但它有明确的适用边界我替你踩过坑帮你画清楚。如果你的目标控件有正常的 resource-id 或 text老老实实用原生定位。多模态大模型方案的延迟通常在一到三秒原生定位是毫秒级杀鸡不用牛刀。有些页面只有三五个验证码形态更轻量的做法是直接对接测试白名单只有当你确实拿不到验证码绕过方案、又必须端到端验证时才考虑大模型识别。成本也是硬约束。公有云多模态 API 是按图片张数和 token 计费的每跑一轮完整回归如果几十个用例都在调大模型账单数字会让人心慌。所以一定得上缓存和降级后面第三部分我会给具体实现。如果界面里是静态图表、二维码这类固定图形OpenCV 模板匹配是更好的选择它零成本、毫秒级响应大模型反而又慢又贵。多模态大模型最适合解决的是目标可以通过语义描述、但无法通过属性定位的场景。3. 落地实操验证码识别与自定义控件定位的完整实现3.1 工程结构与环境准备我用的技术栈是 Python Appium 2.x requests 直接调大模型接口没有引入额外的测试框架这样最小可行链路的依赖最少。Appium 端需要确认的事项Python 端安装 Appium-Python-Client服务端启动 Appium 2.x设备连上后能正常执行基础的 find_element 和截图操作。工程的目录结构按功能拆成三个模块driver.py负责创建 Appium 会话封装截图、点击、输入等基础操作vision_service.py统一封装多模态大模型调用对外只暴露两个方法recognize_captcha()和locate_element()main_flow.py实际业务用例组合调用前两个模块。这种拆分的好处是很明显的后续如果换模型供应商只需要改vision_service.py一个文件业务代码一行都不用动。3.2 验证码识别实现一个可复用的调用代码先说最简单的登录验证码场景。界面上有一个验证码图片控件我不能直接读取它的内容于是把整张屏幕截图发给大模型。先封装一个通用的截图转 Base64 方法import base64 def screenshot_to_base64(driver): # Appium 原生截图方法返回 PNG 图片的 base64 字符串 return driver.get_screenshot_as_base64()然后封装大模型调用。这里要注意不同模型的接口格式略有差异但主流通用格式长这样一个 user 消息里同时包含文本提示词和图片内容import requests import base64 API_ENDPOINT https://api.example.com/v1/chat/completions API_KEY your-api-key MODEL_NAME multimodal-model-v1 def call_vision_model(prompt, image_base64): payload { model: MODEL_NAME, messages: [ { role: user, content: [ {type: text, text: prompt}, { type: image_url, image_url: { url: fdata:image/png;base64,{image_base64} }, }, ], } ], temperature: 0, } headers {Authorization: fBearer {API_KEY}} resp requests.post(API_ENDPOINT, headersheaders, jsonpayload, timeout30) resp.raise_for_status() data resp.json() return data[choices][0][message][content]验证码识别的提示词我踩过几次坑后沉淀下来的版本是这样的CAPTCHA_PROMPT 你是一个自动化测试助手。请看这张移动端截图。 1. 如果图中存在验证码通常是扭曲的数字或字母组合请识别其中的字符内容 2. 如果图中不存在验证码请返回空字符串。 只输出JSON不要输出任何解释或代码块标记格式如下 {captcha: 识别出来的验证码字符无验证码则为空字符串} 调用后拿到的返回可能是{captcha: 3s7f}也可能因为模型输出不稳定多了一些围栏符号。我写了段解析容错逻辑把非 JSON 的杂质剥掉用正则提取最外层 JSONimport json import re def parse_json_response(text): # 兼容模型可能输出的 markdown 围栏 cleaned re.sub(r^(?:json)?|$, , text.strip(), flagsre.MULTILINE) cleaned cleaned.strip() return json.loads(cleaned)最后把验证码填入输入框整个流程就闭环了def auto_login_with_captcha(driver, username, password): # 定位用户名密码输入框正常用原生定位 driver.find_element(id, username).send_keys(username) driver.find_element(id, password).send_keys(password) # 截图识别验证码 img_b64 screenshot_to_base64(driver) raw call_vision_model(CAPTCHA_PROMPT, img_b64) captcha parse_json_response(raw)[captcha] driver.find_element(id, captcha_input).send_keys(captcha) driver.find_element(id, login_btn).click()3.3 复杂控件定位坐标返回与屏幕换算控件定位比验证码稍微绕一点因为模型返回的是图片上的像素坐标而 Appium 执行点击需要的是屏幕坐标。绝大多数情况下Appium 截图的尺寸和屏幕实际尺寸是一致的但有些设备因为系统 UI 缩放、状态栏高度等原因截图和实际屏幕之间存在比例偏差不做换算就会发生点击偏了几个像素的诡异问题。先看定位提示词。以我那个模拟项目里的直播礼物面板为例目标是点击火箭礼物图标LOCATOR_PROMPT 请看这张移动端截图中被框定的区域。图中有多个礼物图标。 请找到名称为火箭的礼物图标输出它中心点在图片中的像素坐标。 只输出JSON格式如下 {found: true, point: {x: 数字, y: 数字}} 如果找不到输出{found: false, point: null} 在调用前我通常做一步区域裁剪。先用 Appium 定位到那个唯一的空白大 View拿它的 bounds 做裁剪把代码里保存的截图用 Pillow 裁一下再转 Base64。这样做的好处一是减少模型的处理面积二是减少同屏相似图标的干扰。坐标换算代码如下from PIL import Image from io import BytesIO def convert_coords(driver, image_base64, raw_x, raw_y): # 拿到截图的真实宽高 image Image.open(BytesIO(base64.b64decode(image_base64))) img_w, img_h image.size # 拿到设备屏幕的宽高 win_size driver.get_window_size() screen_w, screen_h win_size[width], win_size[height] # 计算换算比例 scale_x screen_w / img_w scale_y screen_h / img_h real_x int(raw_x * scale_x) real_y int(raw_y * scale_y) return real_x, real_y如果实际操作发现还有固定的偏移量比如截图把状态栏裁掉导致 Y 轴整体偏移就在换算结果上再叠加一个补偿值。这个在实际项目中基本都会遇到我会在第四部分细说。最后执行点击动作。Appium 2.x 推荐用 W3C Actionsfrom appium.webdriver.common.appiumby import AppiumBy from selenium.webdriver.common.actions.action_builder import ActionBuilder from selenium.webdriver.common.actions import interaction from selenium.webdriver.common.actions.pointer_input import PointerInput def tap_by_coords(driver, x, y): action_builder ActionBuilder(driver) pointer PointerInput(interaction.POINTER_TOUCH, touch) action_builder.add_pointer_input(pointer) action_builder.pointer_action.move_to_location(x, y) action_builder.pointer_action.pointer_down() action_builder.pointer_action.pause(0.1) action_builder.pointer_action.pointer_up() action_builder.perform()滑块验证码的实现思路也一样模型返回滑块和目标凹槽两个坐标换算后用一个 swipe 动作从起点滑到终点速度要模拟人类拖拽的变速模式。这里有个技巧不要匀速直线滑动分段改变速度更容易通过服务端的行为校验。3.4 性能优化缓存、降级与并发把链路跑通之后就要面对成本和效率问题。我加了三层优化实测同一套用例的执行时间从每屏三秒左右降到了原始用例的零头账单也好看很多。第一层是本地缓存。多数时候同一个测试用例跑到同一个界面控件位置是固定的。我用页面标识加目标描述做缓存键比如 activity 名加火箭礼物图标命中缓存就直接用坐标不再调大模型。缓存失效的时机可以简单粗暴杀掉 App 进程时清空。import functools functools.lru_cache(maxsize256) def locate_with_cache(activity_name, target_desc): # 真正调大模型的逻辑 passPython 的functools.lru_cache足够满足这个需求不用引入额外依赖。第二层是降级。模型识别失败或超时不能直接让用例挂掉。我的策略是第一次识别失败重新截图再试一次第二次失败切到 OpenCV 模板匹配尝试还不行就标记用例失败并保存现场截图方便人工排查。这套降级逻辑在实际落地中非常关键它保证了自动化框架在模型偶发抽风时不至于全线飘红。第三层是并发。如果用例里同一屏要识别多个目标串行调模型会等很久。可以先把整张图发给一次模型请求用提示词让它一次性返回所有目标坐标而不是一个目标请求一次。一次请求的成本比多次请求低得多也大幅减少了总耗时。4. 实操中踩过的坑与排查思路4.1 坐标偏差不定最先要排查像素缩放比例我在模拟项目里第一次看到模型返回的坐标后直接按原值去点火箭礼物图标结果点击位置偏到了它旁边的飞机上。第一反应以为是模型识别错了后来检查才发现是截图尺寸和设备逻辑分辨率不一致。排查方法是把模型输入的原始图片宽高打印出来和driver.get_window_size()的宽高对比。如果不一致按第三部分的换算公式处理。如果比例一致仍然有偏移再看是不是状态栏导致的系统坐标偏移解决方法是叠加一个固定补偿值。这个补偿值不要凭感觉调最好的办法是做一个标定脚本在屏幕上取左上、右上、左下、右下四个点分别定位并调用模型返回坐标反推出实际偏移量和缩放比例。4.2 验证码识别率波动prompt与图像质量缺一不可验证码识别率低第一个问题是图像被压缩。Appium 截图是 PNG就算原图清晰经过 Base64 传输到模型侧模型内部也可能压缩图片。我实测过验证码在原图里的区域如果很小识别率会明显下降尤其是字母数字混合的情况。解决办法是裁剪放大验证码区域再发送如果验证码区域无法提前确定就全屏截图后在提示词里让模型先检测验证码位置这一步本身会稍微增加耗时但准确率提升明显。第二个问题是 model 的随机性。虽然我在代码里设置了temperature0但某些模型对图片识别的输出仍存在波动。我的做法是同一个图跑两次识别两次结果一致才采用不一致则默认保留第一次结果并报一个警告。对于关键链路还可以用两个不同模型投票单次成本会上涨但稳定性和成功率确实能拉到近乎百分百。第三个问题在 prompt 本身。原版提示词我只写了识别验证码中字符模型偶尔会把干扰线也当成字母输出。后来我在提示词里追加了一句忽略背景线条、噪点和装饰性元素识别率就稳定住了。提示词细节对结果的影响极大多试几种表达把最优版本固化下来这也是维护这套方案时最省钱有效的手段。4.3 响应慢与成本失控如何把单次识别成本压下来模型接口的速度通常在 1.5 到 3 秒之间这个数字单独看不夸张但几十个用例串行跑下来执行时间直接翻倍。我做了几个针对性优化。首先是压缩图片尺寸。截图分辨率高发送的 token 数量跟图片分辨率强相关。如果被测区域就是一个 300x200 的验证码就没必要送全屏截图。我在调用前统一把目标区域缩放到宽 512 像素以内既保证识别精度又大幅降低 token 消耗。实测下来响应时间能减少三分之一以上。其次是缓存命中率的提升。前文说的lru_cache是关键而且缓存键要设计得够细。同一个界面不同状态比如选中和未选中的控件位置可能不同我就在目标描述里加上界面状态关键词宁可多缓存几个变体也不要因为缓存冲突导致点击错误。最后是超时控制。requests.post里的timeout参数我一开始没设遇到过模型服务偶发卡住导致整个用例挂五分钟的情况。现在统一设 30 秒超时超时就重试一次再超时直接走降级逻辑。这一个小小的改动让整个自动化框架的稳定性提升了一个台阶。4.4 合规与安全自动化测试里的验证码边界这块很容易被忽略但我认为必须单独拿出来说。自动化测试的本质目标是保障产品质量不是攻破业务的安全防线。所以涉及验证码的一切实操都应该限定在测试环境里执行。如果你的验证码校验逻辑在测试环境无法关闭或绕过优先推动开发在服务端加白名单而不是依赖自动化脚本去硬闯。另外测试过程中截取的屏幕截图很可能包含用户信息和业务数据。这些图片发送到外部模型服务时要做脱敏和最小化处理。比如截图前先裁剪掉顶部的用户头像、手机号等区域或者使用内部私有化部署的模型服务。数据合规问题在引入任何外部服务时都要提前评估不要在问题出现之后才补救。5. 这套方案的后续扩展方向到这里核心的验证码识别和复杂控件定位已经能稳定跑了但这个架构能做的事情远不止这些。我把扩展思路简单梳理一下大家可以根据自己的业务场景直接套用。第一个方向是视觉回归测试。现在截图识别链路已经通了那么我可以把每次操作后的截图和上一次的基线截图做对比多模态大模型来判断哪些像素级变化是预期的交互反馈哪些是 UI 渲染异常。这比传统的像素 diff 工具智能得多传统工具对滚动、弹窗、动态内容往往是误报大户。第二个方向是智能流程探索。Appium 传统的测试脚本需要人写死路径而有了视觉加语义理解能力可以让大模型根据当前界面截图决定下一步操作看到登录页就自动找账号密码输入框和登录按钮看到弹窗就找关闭按钮。这本质上就是基于视觉的智能回归探索比 Monkey 那种随机乱点高效得多因为每一步操作都是基于语义理解的。第三个方向是跨端一致性。同一套页面在 Android 和 iOS 上控件结构差异很大但视觉表现应该是一致的。用截图加语义描述的方式可以以视觉结果为基准对两端进行一致性比对。这在业务方要求两端体验一致的场景下非常有用。最后一个值得提的方向是脚本自愈。跑自动化最烦的事情就是产品小版本迭代改了控件属性脚本一夜之间全挂。如果每次定位不到控件时自动切到视觉识别兜底那脚本的鲁棒性会大幅提升维护成本直线下降。我个人在实际项目中体会最深的一点是这套方案的定位不是替代 Appium而是给 Appium 补上一双眼睛。它把传统自动化方案的能力边界往前推了一大截让以前根本不可能自动化的场景变成了可能。当然模型成本、响应延迟和结果稳定性这些短板还是客观存在的所以架构上一定要保留降级链路不能把全部赌注压在一个模型上。如果你现在正被验证码和自定义控件折磨建议先搭一个最小链路跑通核心流程选一个你项目里最疼的场景动手试效果验证之后再逐步铺开慢慢就能摸出适合自己业务的那套边界了。