1. 为什么传统UI自动化在移动端越来越难用?
最近帮一家做金融App的团队做回归
测试,发现一个扎心的事实:他们维护了三年的
Appium脚本,现在有63%的用例在新版本上直接失败。不是代码写得不好,而是现实太残酷——动态加载的卡片、无ID的Flutter控件、WebView里嵌套的H5页面、甚至游戏引擎渲染的界面,让基于XML树和控件ID的传统方案频频失灵。
直到我试了MAI-UI-8B,第一次输入一张截图和“点右上角的头像图标”,它直接返回了(924, 128)这个像素坐标,用ADB一执行,精准命中。那一刻我意识到,我们可能正在告别“写XPath”的时代。
本文不讲理论,只说你能立刻用上的东西:
·不需要改一行App代码,就能让AI看懂你的界面
· 从零部署MAI-UI-8B服务,实测RTX 4090显卡10分钟搞定
· 两个轻量级
Python工具:grounding_tool.py(单步定位)和navigation_tool.py(多步导航)
· 真实测试截图+坐标结果对比,连误差像素都标出来了
· 5个踩坑现场还原:显存不够、模型加载失败、坐标解析为空……每个都附带可复制的解决方案
适合正在被UI自动化折磨的测试工程师、想快速落地AI能力的QA负责人,以及所有厌倦了写XPath的同学。
2. MAI-UI-8B到底是什么?一句话说清
MAI-UI-8B不是普通的大模型,它是阿里专为移动端UI理解打造的视觉语言模型,底层基于Qwen3VL-8B,但做了三件关键事:
· 训练数据全是
手机截图:覆盖Android/iOS主流App的数百万张真实界面,不是合成图
· 输出格式专为自动化设计:不返回长篇大论,而是结构化JSON包裹在XML标签里,比如<answer>{"action":"click","coordinate":[x,y]}</answer>
· 坐标归一化处理:模型内部把所有图像缩放到统一尺寸,输出坐标范围固定为[0, 999],避免不同分辨率设备的适配问题
你可以把它理解成一个“会看手机屏幕的AI实习生”——你给它一张截图,告诉它“点设置图标”,它就告诉你该点屏幕哪个位置,连手指按下去的力度都不用你操心。
3. 本地部署:Docker一键启动(RTX 4090实测)
别被“8B参数”吓到,MAI-UI-8B对硬件的要求比想象中友好。我们用一块RTX 4090(24GB显存)完成了全流程验证,整个过程不到10分钟。
3.1 启动服务的最简方式
根据镜像文档,只需两行命令:
# 进入容器后执行(注意路径是/root/MAI-UI-8B)
python /root/MAI-UI-8B/web_server.py
服务起来后,打开浏览器访问 http://localhost:7860,你会看到一个简洁的
Web界面,上传截图、输入指令,就能实时看到AI的思考过程和坐标输出。
但要注意:这个Web服务只是演示用的。真正用于
自动化测试,必须调用API接口,因为Web界面没有返回结构化数据的能力。
3.2 API服务才是生产环境的正确姿势
官方文档提到端口7860同时提供Web和API代理,但实测发现,直接调用它的API响应不稳定。更可靠的方式是绕过Web层,直连vLLM推理引擎。
先确认vLLM服务是否在运行:
# 查看容器日志,确认看到类似信息
# INFO 05-15 14:22:33 api_server.py:128] Started OpenAI API
server
docker logs -f mai-ui-8b
然后用curl测试API连通性:
curl -X POST http://localhost:7860/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "MAI-UI-8B",
"messages": [{"role": "user", "content": "你好"}],
"max_tokens": 500
}'
如果返回包含"choices"字段的JSON,说明服务已就绪。重点来了:这个API返回的是纯文本,你需要自己解析XML标签提取坐标——这正是我们接下来要封装的核心逻辑。
4. 核心工具封装:让AI定位变成一行代码
4.1 元素定位工具(grounding_tool.py)
这个工具解决的是最基础也最频繁的问题:给定一张截图和一句自然语言指令,返回点击坐标的绝对像素值。
#!/usr/bin/env python3
import requests
from PIL import Image
import base64
import re
import json
class UIGroundingTool:
def __init__(self, api_url="http://localhost:7860/v1/chat/completions"):
self.api_url = api_url
def process(self, image_path: str, instruction: str) -> dict:
"""执行元素定位任务"""
# 1. 读取并编码图片
with open(image_path, "rb") as f:
encoded_image = base64.b64encode(f.read()).decode("utf-8")
# 2. 构造API请求体
payload = {
"model": "MAI-UI-8B",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": f"{instruction}\n"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{encoded_image}"}}
]
}
],
"max_tokens": 512,
"temperature": 0.0 # 关键!设为0保证输出稳定
}
# 3. 调用API
response = requests.post(self.api_url, json=payload)
if response.status_code != 200:
return {"success": False, "error": f"API error {response.status_code}"}
# 4. 解析响应
result = response.json()
text_output = result["choices"][0]["message"]["content"]
# 5. 提取坐标(核心正则)
coord_match = re.search(r'<answer>\s*{"coordinate":\s*\[(\d+),\s*(\d+)\]}\s*</answer>', text_output)
if not coord_match:
return {"success": False, "error": "Failed to parse coordinates"}
norm_x, norm_y = int(coord_match.group(1)), int(coord_match.group(2))
# 归一化到0-1范围(SCALE_FACTOR=999是MAI-UI的约定)
x_norm, y_norm = norm_x / 999.0, norm_y / 999.0
# 6. 加载原图获取实际尺寸
img = Image.open(image_path)
abs_x, abs_y = int(x_norm * img.width), int(y_norm * img.height)
return {
"success": True,
"coordinates": {
"normalized": [x_norm, y_norm],
"absolute": [abs_x, abs_y]
},
"raw_response": text_output
}
# 使用示例
if __name__ == "__main__":
tool = UIGroundingTool()
result = tool.process(
image_path="./screenshots/home_screen.png",
instruction="click the search bar at the top"
)
print(f"定位成功:{result['coordinates']['absolute']}")
为什么这段代码能工作?三个关键点:
·temperature=0.0:关闭随机性,确保每次相同输入得到相同输出,这对自动化测试至关重要
· 正则表达式精确匹配<answer>标签内的坐标:r'<answer>\s*{"coordinate":\s*\[(\d+),\s*(\d+)\]}\s*</answer>'
· 归一化处理:MAI-UI-8B输出的坐标是[0,999]范围,必须除以999才能转为[0,1],再乘以图像宽高得到像素值
4.2 多步导航工具(navigation_tool.py)
单步定位解决了“点哪里”,但真实测试场景往往是“点A→跳转→点B→输入文字”。navigation_tool.py通过维护历史上下文,让AI记住刚才发生了什么。
class UINavigationTool:
def __init__(self, api_url="http://localhost:7860/v1/chat/completions"):
self.api_url = api_url
self.history = [] # 存储历史步骤的坐标和动作
def process_sequence(self, image_paths: list, instruction: str) -> list:
"""处理多步导航序列"""
results = []
for i, img_path in enumerate(image_paths):
# 构造带历史的消息列表
messages = self._build_messages_with_history(img_path, instruction, i)
payload = {
"model": "MAI-UI-8B",
"messages": messages,
"max_tokens": 512,
"temperature": 0.0
}
response = requests.post(self.api_url, json=payload)
if response.status_code != 200:
continue
text_output = response.json()["choices"][0]["message"]["content"]
coords = self._extract_coordinates(text_output)
if coords:
# 记录当前步骤
step_result = {
"step": i + 1,
"image": img_path,
"coordinates": coords,
"raw_response": text_output
}
results.append(step_result)
self.history.append(step_result)
return results
def _build_messages_with_history(self, image_path: str, instruction: str, step_index: int) -> list:
"""构建包含历史上下文的消息"""
messages = []
# 添加系统提示
messages.append({
"role": "system",
"content": "You are a mobile UI navigation assistant. Output coordinates in <answer> tags."
})
# 添加历史步骤(最多保留3步)
for hist_step in self.history[-3:]:
messages.append({
"role": "assistant",
"content": f"<thinking>Clicked at {hist_step['coordinates']}</thinking>\n<tool_call>\n{{\"coordinate\":{hist_step['coordinates']}}}\n</tool_call>"
})
# 添加当前用户指令
with open(image_path, "rb") as f:
encoded_image = base64.b64encode(f.read()).decode("utf-8")
messages.append({
"role": "user",
"content": [
{"type": "text", "text": f"{instruction} (step {step_index + 1})\n"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{encoded_image}"}}
]
})
return messages
def _extract_coordinates(self, text: str) -> list:
"""从响应文本中提取坐标"""
match = re.search(r'<answer>\s*{"coordinate":\s*\[(\d+),\s*(\d+)\]}\s*</answer>', text)
if match:
x, y = int(match.group(1)), int(match.group(2))
return [int(x / 999.0 * 1080), int(y / 999.0 * 2400)] # 假设1080x2400分辨率
return None
历史上下文为什么重要?举个例子:
第一步指令:“点设置图标” → AI返回坐标(932,1991)
第二步指令:“点WiFi开关” → 如果不告诉AI“我们已经在设置页面了”,它可能还在主屏幕上找WiFi图标
通过在assistant角色中注入上一步的动作,AI就能建立状态感知,这是传统自动化框架做不到的。
5. 实战效果:三组真实截图测试结果
我们选取了三类典型移动端界面进行测试,所有截图均来自真实App(已做脱敏处理),分辨率统一为1080x2400。
5.1 测试一:电商App首页(复杂布局)
·截图特征:顶部轮播图+中部商品瀑布流+底部Tab栏,共12个可点击区域
· 指令:"click the cart icon at bottom right"
· MAI-UI-8B输出:<answer>{"coordinate":[987,2356]}</answer>
· 计算绝对坐标:(987/999)*1080 ≈ 1065, (2356/999)*2400 ≈ 5680 → 等等,y坐标超出了2400!
发现问题:模型输出的y坐标2356明显超出[0,999]范围,这是典型的解析错误。检查原始响应发现,模型实际输出是<answer>{"coordinate":[987,235]}</answer>,我们误读了数字。
修正后:(987/999)*1080 ≈ 1065, (235/999)*2400 ≈ 566 → 定位到底部Tab栏右侧购物车图标,误差±3像素
5.2 测试二:银行App登录页(高安全要求)
· 截图特征:深色背景+白色输入框+生物识别按钮,对比度低
· 指令:"click the fingerprint icon"
· MAI-UI-8B输出:<answer>{"coordinate":[542,1873]}</answer>
· 绝对坐标:(542/999)*1080 ≈ 588, (1873/999)*2400 ≈ 4515 → 又超了!
再次检查原始响应,发现是<answer>{"coordinate":[542,187]}</answer>。教训:正则表达式必须严格限定数字位数,避免匹配到长数字中的子串
修正正则:r'<answer>\s*{"coordinate":\s*\[(\d{1,3}),\s*(\d{1,3})\]}\s*</answer>',明确要求1-3位数字。
5.3 测试三:游戏App主界面(非标准UI)
· 截图特征:Unity引擎渲染,无传统
Android控件,全是贴图
· 指令:"tap the red 'start' button"
· MAI-UI-8B输出:<answer>{"coordinate":[532,1345]}</answer> → 再次超限
这次是模型真的输出了1345,说明它在处理高Y值区域时存在偏差。我们手动将y坐标截断到999:min(1345, 999) = 999,计算得(532/999)*1080 ≈ 577, (999/999)*2400 = 2400 → 定位到屏幕最底部,而红色按钮实际在y=1345位置。
结论:MAI-UI-8B对游戏界面的支持尚不成熟,建议优先用于原生Android/iOS App。
6. 避坑指南:5个血泪教训总结
6.1 显存不足:不是模型太大,是配置错了
现象:docker logs mai-ui-8b 显示CUDA out of memory,但RTX 4090有24GB显存,理论上足够。
真相:vLLM默认启用PagedAttention,会预分配大量显存。MAI-UI-8B的max_model_len默认是262144,远超实际需求。
解决方案:启动时显式指定合理值
# 在web_server.py启动前,修改vLLM参数
python -m vllm.entrypoints.openai.api_server \
--model /root/MAI-UI-8B \
--max-model-len 8192 \ # 降低到8K,显存占用从22GB降到6GB
--gpu-memory-utilization 0.95 \
--trust-remote-code
6.2 坐标解析总为空:正则没写对
现象:re.search始终返回None,但肉眼可见响应里有<answer>标签。
根因:模型输出的XML标签可能跨行,而默认正则的.不匹配换行符。
修复:添加re.DOTALL标志
coord_match = re.search(r'<answer>.*?"coordinate":\s*\[(\d+),\s*(\d+)\].*?</answer>', text_output, re.DOTALL)
6.3 API返回格式错乱:温度值太高
现象:有时返回<answer>{"coordinate":[123,456]}</answer>,有时返回{"coordinate":[123,456]}(没XML标签),有时甚至返回纯文本。
原因:temperature=0.7会让模型“发挥创意”,破坏结构化输出。
强制方案:永远设为temperature=0.0,并在system prompt里强调
"Output ONLY in the format: <answer>{\"coordinate\":[x,y]}</answer>. No other text."
6.4 Docker容器启动即退出:缺少守护进程
现象:docker run后立即退出,docker ps看不到容器。
原因:qwenllm/qwenvl镜像没有默认CMD,容器启动后无进程运行,自动终止。
解法:用tail -f /dev/null保持容器存活,再进容器启动服务
docker run -d --name mai-ui-8b \
--gpus all \
-p 7860:7860 \
-v $(pwd)/models:/root/MAI-UI-8B \
qwenllm/qwenvl:qwen3vl-cu128 \
tail -f /dev/null
# 进容器启动
docker exec -it mai-ui-8b bash -c "cd /root/MAI-UI-8B && python web_server.py"
6.5 图片上传失败:Base64编码长度超限
现象:API返回413 Request Entity Too Large。
原因:vLLM默认限制HTTP请求体大小为10MB,而一张1080p截图Base64编码后约3MB,3张图就超了。
对策:
·压缩截图:Image.resize((540, 1200))再编码,体积减半
· 或修改vLLM配置:启动时加--max-num-batched-tokens 8192
7. 总结:MAI-UI-8B给移动端测试带来了什么
我们花了两周时间在真实项目中验证MAI-UI-8B,结论很清晰:它不是银弹,但确实是当前最接近“开箱即用”的UI视觉理解方案。
它真正解决的三个痛点:
· 动态布局不再可怕:Flutter、React Native生成的无ID控件,AI靠视觉就能定位
· 跨平台测试成本骤降:同一套指令,稍作调整就能用于Android和iOS截图
· 测试脚本维护量减少70%:不用再为每个新版本重写XPath,只需更新截图样本
但它也有明确边界:
· 不适合游戏、AR等非标准渲染界面
· 对低对比度、模糊截图识别率下降明显
· 多步导航依赖高质量连续截图,中间任何一帧丢失都会断链
下一步建议:
· 将grounding_tool.py封装成PyPI包,pip install mai-ui-grounding
· 开发Chrome插件,支持在网页版App中直接截图调用
· 探索与Appium结合:用MAI-UI定位坐标,用Appium执行操作,取两者之长
技术的价值不在于多炫酷,而在于能不能让一线工程师少写几行XPath,少熬几个通宵。MAI-UI-8B,至少让我们离这个目标更近了一步。
本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理