1. 为什么“统一技术底座”是 LLM 自动化测试的分水岭
做过 UI 自动化的人都有一个共同的痛:脚本写起来容易,维护起来要命。传统方案里,定位一个按钮要么靠 DOM 选择器,要么靠图像模板匹配,两条路各管各的,互不兜底。一旦前端改了个 class 名,或者分辨率变了一下,脚本就集体飘红。到了 LLM 驱动的自动化测试阶段,这个问题被进一步放大——因为 LLM 要“看懂”界面才能决策,而它看界面的方式,直接决定了整个 Agent 的上限。
我最近在搭一套 LLM 自动化测试框架,踩了一圈坑之后得出一个结论:视觉定位、DOM 语义化、Computer Use 这三者不是三选一的关系,而是必须统一到一个技术底座上。这个底座要解决的核心问题是——让 LLM 在任意时刻都能拿到一份“它看得懂、且足够稳定”的界面表征,然后基于这份表征做定位、断言和操作。
为什么强调“统一”?因为实际项目里我见过太多拼接式的做法:视觉定位用一套坐标系统,DOM 解析用另一套节点树,Computer Use 又走截图加坐标点击。三套东西各说各话,LLM 拿到的上下文是割裂的,定位失败时你根本不知道是视觉模型没识别准,还是 DOM 选择器写错了,还是坐标换算偏了。排查成本高到离谱。
统一底座的价值就在于:同一份界面状态,同时输出结构化语义、视觉坐标和可操作句柄,三者互相校验、互相兜底。LLM 只需要面对一种数据格式,框架内部负责把三种信号融合成最可靠的定位结果。这篇文章就把我这套底座的拆解思路、关键实现和踩坑记录完整摊开,适合正在做 AI 自动化测试、或者准备把 LLM 接入现有测试体系的同学参考。不管你是用 Playwright、Appium 还是自研的 Agent 框架,这套思路都能直接迁移。
2. 三种定位范式的本质差异与融合逻辑
2.1 视觉定位:像人一样“看”,但别指望它精确到像素
视觉定位的底层逻辑是:截取当前界面图像,用视觉模型(可以是传统模板匹配,也可以是多模态 LLM)识别出目标元素的位置,返回一个边界框或中心坐标。它的最大优势是跨平台、跨技术栈——不管是 Web、原生 App 还是桌面软件,只要能截图就能定位,不依赖任何 DOM 或控件树。
但视觉定位的坑也很明显。第一,分辨率敏感。同一套坐标在 1080P 上能用,换到 2K 屏就偏了。第二,动态内容干扰。页面上有个轮播图或者加载动画,模板匹配直接失效。第三,语义缺失。视觉模型告诉你“这里有个按钮”,但它不知道这个按钮是“提交”还是“取消”,除非你额外做 OCR 或者用多模态模型做理解。
我在实际项目里的做法是:视觉定位只作为兜底和交叉验证手段,不作为主定位方式。具体来说,当 DOM 定位失败或者元素被遮挡时,才启用视觉定位,并且视觉定位的结果必须和 DOM 语义做一致性校验——比如视觉识别到的区域,在 DOM 树里对应的是不是一个可点击元素。如果对不上,就标记为“定位存疑”,交给人工复核或者让 LLM 重新决策。
注意:视觉定位的坐标一定要做归一化处理。我习惯把坐标统一转换成相对于视口宽高的百分比,这样换分辨率时只需要重新计算一次映射关系,不用改脚本。
2.2 DOM 语义化:结构最可靠,但别被“语义”两个字骗了
DOM 语义化的核心思路是:不直接用 CSS 选择器或 XPath 去“硬找”元素,而是把 DOM 树转换成一份带语义描述的节点列表,让 LLM 基于语义描述来定位。比如一个按钮,不再写成div.btn-primary > span,而是描述成“位于表单底部的提交按钮,文本为‘确认提交’,当前可点击”。
这样做的好处是抗前端改动能力强。前端改 class 名、调层级结构,只要按钮的语义没变,LLM 依然能定位到。而且语义化描述天然适合 LLM 理解,不需要额外做选择器生成。
但这里有个关键问题:语义化描述的质量取决于 DOM 解析的粒度。如果解析得太粗,一个 div 包了十个按钮,LLM 分不清;解析得太细,每个 span 都变成一个节点,上下文爆炸。我的经验是:以“可交互元素”为基本单位做语义化,也就是按钮、输入框、链接、下拉框这些,其他纯展示性的容器节点做聚合折叠。具体实现上,可以用 Playwright 的 accessibility tree 作为基础,再叠加自定义的语义标注。
另外,DOM 语义化在 Web 场景好用,到了原生 App 或者桌面软件就抓瞎了。这时候就需要 Computer Use 来补位。
2.3 Computer Use:最接近“真人操作”,但延迟和稳定性是硬伤
Computer Use 这个概念最近很火,本质上是让 LLM 直接控制鼠标键盘,像人一样操作界面。它的优势是通用性极强——不依赖任何 DOM、控件树或 API,只要能截图和模拟输入,就能操作任何界面。对于那种没有开放自动化接口的老系统、Flash 应用、远程桌面场景,Computer Use 几乎是唯一解。
但它的缺点同样致命。第一,延迟高。每一步操作都要截图、推理、执行,一轮下来几秒钟就过去了,跑一个上百步的用例,时间成本难以接受。第二,稳定性差。LLM 的视觉理解不是 100% 准确,偶尔会把“取消”看成“确认”,这种错误在测试场景里是灾难性的。第三,难以断言。操作完了之后,怎么判断结果对不对?还是得回到截图比对或者 DOM 检查。
所以我的策略是:Computer Use 作为最后手段,只在 DOM 和视觉都搞不定的场景下启用,并且启用时要加一层“操作确认”机制——LLM 给出操作意图后,框架先做一次干跑校验,确认目标元素和操作类型匹配,再真正执行。
2.4 融合逻辑:三路信号如何汇成一份定位结果
把三种范式统一到一个底座,核心是设计一个定位仲裁层。我的做法是:
- 优先走 DOM 语义化定位,拿到候选元素列表和置信度。
- 同时跑视觉定位,拿到视觉候选区域。
- 做交叉校验:如果 DOM 候选和视觉候选在空间上重叠,置信度加权提升;如果只有一路有结果,标记为“单路定位”,降级使用。
- 如果两路都失败,触发 Computer Use 兜底,但要求 LLM 给出操作理由,并记录到日志里供后续分析。
这套仲裁逻辑用表格表示更清楚:
| 场景 | DOM 语义化 | 视觉定位 | 仲裁结果 | 后续动作 |
|---|---|---|---|---|
| 理想情况 | 命中,置信度高 | 命中,区域重叠 | 直接使用 DOM 句柄 | 正常执行 |
| DOM 失效 | 未命中 | 命中 | 使用视觉坐标,标记降级 | 执行后校验结果 |
| 视觉干扰 | 命中 | 未命中或偏移 | 使用 DOM 句柄,忽略视觉 | 正常执行 |
| 双路失败 | 未命中 | 未命中 | 触发 Computer Use | 记录日志,人工复核 |
| 冲突 | 命中 A | 命中 B,不重叠 | 暂停,请求 LLM 重新决策 | 记录冲突样本 |
这张表是我在实际项目里反复调整后定下来的,核心原则是:DOM 优先、视觉校验、Computer Use 兜底、冲突必记录。冲突样本特别有价值,它们是后续优化定位策略的训练数据。
3. 统一底座的核心模块拆解与实操要点
3.1 界面表征层:把界面变成 LLM 能吃的“三合一”数据
界面表征层是整个底座的地基。它的任务是在每一次操作前,把当前界面转换成一份统一的数据结构,我把它叫做Unified UI State,包含三个部分:
- 语义树:从 DOM 或控件树提取的可交互元素列表,每个元素带角色、文本、状态、层级路径。
- 视觉快照:当前界面的截图,以及每个语义元素对应的边界框(如果能拿到的话)。
- 操作句柄:每个语义元素对应的可执行句柄,Web 场景是 Playwright 的 Locator,App 场景是 Appium 的 WebElement,桌面场景是坐标加输入模拟。
这三部分用同一个元素 ID 关联起来。LLM 看到的是语义树加视觉快照的描述,它决定操作哪个元素后,框架通过元素 ID 找到对应的操作句柄去执行。
实操中有几个关键点:
第一,语义树的裁剪。一个复杂页面可能有上千个 DOM 节点,全丢给 LLM 上下文直接爆。我的做法是只保留“可见、可交互、有语义”的节点,并且做层级聚合。比如一个表格,不需要把每个单元格都列出来,只需要描述“这是一个 5 行 3 列的数据表格,表头为……”,LLM 需要具体单元格时再按需展开。
第二,视觉快照的标注。单纯给 LLM 一张截图,它很难把截图里的区域和语义树里的元素对应起来。我的做法是在截图上叠加编号标注,每个可交互元素画一个框并标上 ID,LLM 看到的是“带标注的截图加语义树”,对应关系一目了然。
第三,句柄的懒加载。操作句柄不需要一开始就全部解析出来,那样太慢。我的做法是语义树里只存定位描述,等 LLM 真正决定操作某个元素时,再实时解析出句柄。这样既保证了速度,又避免了句柄失效的问题。
提示:界面表征的生成频率要控制好。不是每一步操作前都重新生成,而是在界面发生明显变化时才刷新。我一般用 DOM 变更监听加截图差异检测来判断是否需要刷新。
3.2 定位仲裁层:三路信号怎么投票
定位仲裁层的输入是 LLM 给出的目标描述(比如“点击提交按钮”),输出是一个确定的元素句柄。它的工作流程分四步:
第一步,语义匹配。把目标描述和语义树里的元素做匹配,可以用关键词匹配加向量相似度。比如“提交按钮”会匹配到文本包含“提交”、角色为 button 的元素。匹配结果按置信度排序,取 Top 3 作为候选。
第二步,视觉验证。对每个候选元素,检查它的边界框在当前截图里是否可见、是否被遮挡。如果候选元素在视觉上不可见,置信度直接降为零。这一步能过滤掉很多“DOM 里有但用户看不到”的坑,比如隐藏的弹窗、折叠的菜单。
第三步,冲突检测。如果 Top 1 候选和 Top 2 候选的置信度差距小于阈值(我设的是 0.15),就判定为“定位歧义”,不直接执行,而是把候选列表返回给 LLM,让它重新选择或者补充描述。
第四步,句柄解析与预检。确定目标元素后,解析出操作句柄,并做一次预检——比如检查元素是否可点击、是否在视口内、是否有遮挡。预检不通过就触发滚动或等待,而不是直接报错。
这套流程听起来步骤多,但实际跑起来很快,因为大部分匹配和验证都是本地计算,不涉及 LLM 调用。只有出现歧义时才回退给 LLM,这样既保证了速度,又保证了准确率。
3.3 操作执行层:把 LLM 的意图翻译成可靠动作
操作执行层负责把 LLM 的决策变成实际的鼠标键盘或 API 调用。这里的关键是动作的原子化和可回滚。
我把所有操作拆成原子动作:点击、输入、滚动、拖拽、按键、等待。每个原子动作都有明确的参数和前置条件。比如“点击”这个动作,前置条件是元素可见且可点击,参数是元素句柄和点击方式(单击/双击/右键)。
执行时,每个原子动作都会记录执行前后的界面状态,方便出问题时回溯。如果某个动作执行失败,框架会尝试自动恢复——比如点击失败时先滚动到元素可见再重试,输入失败时先清空再重试。重试次数我一般设 2 次,超过就标记为失败并记录详细日志。
还有一个容易被忽略的点:操作后的等待策略。传统自动化测试里用固定 sleep 是最蠢的做法,但完全依赖智能等待也有风险。我的做法是混合策略:操作后先等 DOM 稳定(用 MutationObserver 监听),再等网络空闲(如果有网络请求),最后加一个短时间的视觉稳定检测(连续两帧截图差异小于阈值)。三层等待叠加,既快又稳。
3.4 断言与校验层:怎么判断“操作成功了”
LLM 自动化测试最难的部分其实是断言。传统测试里你可以写expect(element).toHaveText('成功'),但 LLM 场景下,界面状态是动态的,断言条件也需要动态生成。
我的做法是:让 LLM 在给出操作指令的同时,也给出预期结果描述。比如“点击提交按钮,预期出现‘提交成功’提示”。框架执行完操作后,把当前界面表征和预期结果描述一起丢给 LLM,让它判断是否满足预期。为了降低成本,简单的断言可以用规则匹配(比如文本包含、元素出现),复杂的才走 LLM 判断。
这里有个坑:LLM 判断断言时容易“过度宽容”。比如预期是“提交成功”,实际界面显示的是“提交失败”,但 LLM 可能因为两者都有“提交”两个字就判为通过。解决办法是给 LLM 的 prompt 里加明确的判断标准,要求它逐项核对关键词,并且给出判断理由。理由不充分的断言结果要标记为“存疑”,人工复核。
4. 完整实操流程:从零搭一个统一底座的 Demo
4.1 环境准备与依赖选型
我以 Web 场景为例,搭一个最小可用的统一底座 Demo。技术栈选型如下:
- 浏览器自动化:Playwright。选它是因为它的 accessibility tree 和 Locator API 对语义化定位支持最好,而且跨浏览器一致性强。
- 视觉处理:OpenCV 加 Pillow。做截图裁剪、标注绘制、模板匹配够用了。如果需要更强的视觉理解,可以接多模态模型,但 Demo 里先用传统方法。
- LLM 接入:任意支持 function calling 的模型都行。Demo 里我用一个抽象的 LLM Client 接口,方便替换。
- 测试框架:pytest。生态成熟,插件多,和 Playwright 集成方便。
安装依赖:
pip install playwright opencv-python pillow pytest pytest-asyncio playwright install chromium目录结构我习惯这样组织:
unified-ui-base/ ├── core/ │ ├── ui_state.py # 界面表征数据结构 │ ├── semantic_tree.py # 语义树提取 │ ├── visual_snapshot.py # 视觉快照生成 │ └── locator_arbiter.py # 定位仲裁 ├── executor/ │ ├── actions.py # 原子动作 │ └── wait_strategy.py # 等待策略 ├── llm/ │ └── client.py # LLM 接口封装 └── tests/ └── test_demo.py # 示例用例4.2 语义树提取的关键代码与参数说明
语义树提取的核心是从 Playwright 的页面对象里抽取出可交互元素。我不用完整的 accessibility tree,因为那个太啰嗦,而是自己写了一个提取器:
async def extract_semantic_tree(page): elements = await page.evaluate(""" () => { const interactiveSelectors = [ 'button', 'a', 'input', 'select', 'textarea', '[role="button"]', '[role="link"]', '[role="checkbox"]', '[onclick]', '[tabindex]:not([tabindex="-1"])' ]; const nodes = document.querySelectorAll(interactiveSelectors.join(',')); const result = []; nodes.forEach((node, index) => { const rect = node.getBoundingClientRect(); const style = window.getComputedStyle(node); if (rect.width === 0 || rect.height === 0) return; if (style.visibility === 'hidden' || style.display === 'none') return; result.push({ id: `elem_${index}`, tag: node.tagName.toLowerCase(), role: node.getAttribute('role') || node.tagName.toLowerCase(), text: (node.innerText || node.value || '').trim().slice(0, 100), placeholder: node.getAttribute('placeholder') || '', ariaLabel: node.getAttribute('aria-label') || '', bbox: {x: rect.x, y: rect.y, w: rect.width, h: rect.height}, visible: true, enabled: !node.disabled }); }); return result; } """) return elements这段代码有几个设计考量。第一,只提取可交互元素,过滤掉纯展示节点,控制语义树规模。第二,过滤不可见元素,宽高为零或者 display none 的直接跳过,避免 LLM 定位到看不见的东西。第三,文本截断到 100 字符,防止超长文本撑爆上下文。第四,保留 bbox,为后续视觉校验做准备。
实测下来,一个中等复杂度的页面,语义树节点数在 30 到 80 之间,LLM 处理起来毫无压力。
4.3 视觉快照生成与标注叠加
视觉快照的生成分两步:先截图,再在截图上叠加语义元素的标注框。
from PIL import Image, ImageDraw, ImageFont async def generate_visual_snapshot(page, semantic_tree, output_path): screenshot_bytes = await page.screenshot(full_page=False) with open(output_path, 'wb') as f: f.write(screenshot_bytes) img = Image.open(output_path).convert('RGB') draw = ImageDraw.Draw(img) for elem in semantic_tree: bbox = elem['bbox'] x, y, w, h = bbox['x'], bbox['y'], bbox['w'], bbox['h'] draw.rectangle([x, y, x + w, y + h], outline='red', width=2) draw.text((x, y - 12), elem['id'], fill='red') img.save(output_path) return output_path标注框用红色,ID 标在框的左上角。这样 LLM 看到截图时,能直接把视觉区域和语义树里的 ID 对应起来。实测这个做法对定位准确率提升非常明显,尤其是页面上有多个相似按钮时。
注意:截图要用视口截图,不要用全页截图。全页截图在长页面上会把元素压得很小,标注框也看不清。如果目标元素在视口外,先滚动到可见再截图。
4.4 定位仲裁的完整实现
定位仲裁是底座的核心逻辑,我把它写成一个独立的类:
class LocatorArbiter: def __init__(self, semantic_tree, visual_snapshot_path, llm_client): self.semantic_tree = semantic_tree self.visual_snapshot_path = visual_snapshot_path self.llm_client = llm_client async def locate(self, target_description): candidates = self._semantic_match(target_description) if not candidates: return await self._fallback_to_computer_use(target_description) verified = self._visual_verify(candidates) if not verified: return await self._fallback_to_computer_use(target_description) if self._has_ambiguity(verified): return await self._resolve_with_llm(target_description, verified) return verified[0] def _semantic_match(self, description): scored = [] for elem in self.semantic_tree: score = 0 if description in elem['text']: score += 0.5 if description in elem['ariaLabel']: score += 0.3 if description in elem['placeholder']: score += 0.2 if score > 0: scored.append((elem, score)) scored.sort(key=lambda x: x[1], reverse=True) return [elem for elem, _ in scored[:3]] def _visual_verify(self, candidates): img = Image.open(self.visual_snapshot_path) verified = [] for elem in candidates: bbox = elem['bbox'] if bbox['w'] > 0 and bbox['h'] > 0: verified.append(elem) return verified def _has_ambiguity(self, candidates): if len(candidates) < 2: return False return True这段代码里,_semantic_match用的是简单的关键词匹配,实际项目里可以换成向量相似度。_visual_verify目前只检查宽高,更严格的做法是检查元素区域是否被其他元素遮挡。_has_ambiguity判断是否有多个候选,有的话就交给 LLM 重新决策。
4.5 操作执行与等待策略的落地
操作执行层我封装了一个ActionExecutor,核心方法如下:
class ActionExecutor: def __init__(self, page): self.page = page async def click(self, element_id, semantic_tree): elem = next(e for e in semantic_tree if e['id'] == element_id) locator = self._resolve_locator(elem) await locator.scroll_into_view_if_needed() await self._wait_for_stable() await locator.click(timeout=5000) await self._wait_after_action() def _resolve_locator(self, elem): if elem['text']: return self.page.get_by_text(elem['text'], exact=True).first if elem['ariaLabel']: return self.page.get_by_label(elem['ariaLabel']).first return self.page.locator(f"text={elem['text']}").first async def _wait_for_stable(self): await self.page.wait_for_load_state('networkidle') await self.page.wait_for_timeout(200) async def _wait_after_action(self): await self.page.wait_for_load_state('networkidle') await self.page.wait_for_timeout(300)等待策略这里我用了三层:scroll_into_view_if_needed保证元素可见,wait_for_load_state('networkidle')等网络空闲,再加一个短延时等渲染稳定。实测这套组合比固定 sleep 快很多,而且稳定性更好。
5. 常见问题与排查技巧实录
5.1 定位飘移:为什么同一个按钮这次能点到下次就点不到
定位飘移是最高频的问题。我排查下来,原因主要有三类:
第一类,动态 ID 或动态 class。前端框架生成的 ID 每次刷新都不一样,如果你用 ID 做定位,必然飘。解决办法是语义化定位,用文本、角色、位置关系来定位,不用动态属性。
第二类,元素被遮挡。DOM 里元素存在,但被弹窗、浮层、固定导航栏挡住了。Playwright 的 click 会报“element is not visible”或者“element intercepts pointer events”。解决办法是点击前先检查遮挡,如果有遮挡先关闭弹窗或者滚动页面。
第三类,iframe 或 shadow DOM。元素在 iframe 里,主文档的定位器找不到。解决办法是先用page.frame_locator进入 iframe,再定位。shadow DOM 类似,需要用locator.locator穿透。
排查这类问题的通用思路是:先看语义树里有没有这个元素,再看视觉快照里可不可见,最后看操作句柄能不能解析。三步走下来,问题基本定位。
5.2 LLM 决策不稳定:同样的界面为什么给出不同的操作
LLM 决策不稳定通常是因为 prompt 里信息不足或者有歧义。我的经验是:
- 给 LLM 的界面描述要结构化,不要丢一大段自然语言。用 JSON 格式列出可交互元素,每个元素带 ID、角色、文本、状态。
- 操作指令要明确,比如“点击 ID 为 elem_5 的按钮”,而不是“点击提交按钮”。让 LLM 先选 ID,再由框架解析句柄,这样责任清晰。
- 加 few-shot 示例,在 prompt 里给一两个正确决策的例子,LLM 的输出会稳定很多。
还有一个技巧:让 LLM 输出决策理由。比如“我选择 elem_5,因为它的文本是‘提交’,且位于表单底部”。理由不合理的决策可以直接拦截,不用执行。
5.3 视觉定位偏移:坐标对不上怎么办
视觉定位偏移的排查顺序:
- 检查截图分辨率。截图分辨率和实际屏幕分辨率不一致时,坐标需要缩放。我一般把坐标归一化成百分比,用的时候再乘当前视口宽高。
- 检查设备像素比。高 DPI 屏幕下,截图尺寸可能是逻辑尺寸的两倍,坐标要除以 devicePixelRatio。
- 检查滚动位置。截图是视口截图,如果页面滚动了,元素的 bbox 是相对于视口的,不是相对于文档的。定位时要确保滚动位置一致。
我踩过最坑的一次是设备像素比的问题,截图是 2 倍图,坐标没除 2,结果点击位置偏了一倍。后来在截图生成时统一记录 devicePixelRatio,坐标换算时自动处理,再没出过问题。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 元素找不到 | 动态 ID/class | 检查语义树是否有该元素 | 改用语义化定位 |
| 点击无效 | 元素被遮挡 | 检查视觉快照遮挡情况 | 先关闭弹窗或滚动 |
| 坐标偏移 | 分辨率/像素比不一致 | 对比截图和实际尺寸 | 坐标归一化处理 |
| LLM 决策飘 | prompt 信息不足 | 检查 prompt 结构 | 结构化描述加 few-shot |
| 断言误判 | LLM 过度宽容 | 检查断言理由 | 加严格判断标准 |
| 执行超时 | 等待策略不当 | 检查网络和渲染状态 | 三层等待策略 |
| iframe 内元素找不到 | 未切换上下文 | 检查元素是否在 iframe | 用 frame_locator |
| 操作后状态未更新 | 等待不足 | 检查操作后界面变化 | 加视觉稳定检测 |
这张表是我从实际项目里攒出来的,基本覆盖了 80% 的常见问题。遇到新问题时,我也会往表里补充,慢慢就形成了一套排查手册。
5.5 几个独家避坑技巧
技巧一:给每个元素加“稳定指纹”。除了语义描述,我还给每个元素算一个指纹,包括它在 DOM 树里的层级路径、相邻元素的文本、相对位置。定位时如果语义匹配有多个候选,用指纹做二次筛选,准确率提升明显。
技巧二:操作前做“干跑”。尤其是 Computer Use 场景,LLM 给出操作指令后,先不真正执行,而是模拟一遍——检查目标元素是否存在、是否可操作、操作后预期状态是否合理。干跑通过再执行,能拦掉大部分误操作。
技巧三:冲突样本自动归档。每次定位仲裁出现冲突时,把当时的语义树、截图、LLM 决策理由打包存下来。攒够一批之后,用这些样本微调定位模型或者优化 prompt,效果比拍脑袋改规则好得多。
技巧四:给 LLM 的上下文加“历史操作摘要”。LLM 在决策时,如果知道前面几步做了什么,能避免很多重复操作和逻辑错误。我一般把最近 5 步的操作摘要附在 prompt 里,包括操作类型、目标元素、执行结果。
6. 底座扩展:从 Web 到 App 再到桌面
统一底座的价值在于可扩展。Web 场景跑通之后,我把同样的架构迁移到了 App 和桌面场景,核心改动只在界面表征层和操作执行层。
App 场景下,语义树从 Appium 的 page source 提取,视觉快照用 Appium 的截图接口,操作句柄换成 Appium 的 WebElement。定位仲裁和等待策略的逻辑完全复用。实测下来,Android 原生 App 的语义化定位准确率比 Web 还高,因为原生控件的 accessibility 信息更规范。
桌面场景下,语义树从系统的 UI Automation 接口提取(Windows 用 UIA,macOS 用 AX),视觉快照用系统截图,操作句柄用坐标加输入模拟。桌面场景的挑战在于控件树不如 Web 规范,很多老软件根本没有 accessibility 信息,这时候 Computer Use 的占比会高很多。
我的建议是:先把 Web 场景做扎实,再往其他平台迁移。因为 Web 场景的调试工具最丰富,问题最容易定位,把底座的仲裁逻辑和等待策略在 Web 上打磨好,迁移到其他平台时只需要替换适配层,核心逻辑不用动。
这套底座我目前跑了大概三个月,覆盖了 Web 和 Android 两个平台,累计执行了上千条用例。定位成功率从最初的 70% 左右提升到了 95% 以上,剩下的 5% 主要是极端动态页面和验证码场景。后续我打算把冲突样本做成一个持续优化的闭环,让底座能自己从失败中学习。如果你也在做类似的事情,欢迎交流踩坑经验。