1. 项目概述:当大语言模型“看”懂手机屏幕

最近在折腾一个挺有意思的项目:让大语言模型(LLM)来驱动手机自动化操作。听起来像是科幻片里的场景,对吧?但实际做起来,你会发现核心挑战非常具体: 如何让模型“理解”手机屏幕上正在发生什么 。这是所有后续自动化操作(比如点击、滑动、输入文字)的基础。如果模型“看”错了,那后续所有指令都会跑偏。

目前,主流的思路有两种,正好对应了项目标题里的“屏幕文本”与“截图”。第一种是直接获取屏幕上的UI层级信息,提取出所有控件的文本、坐标、类型(比如按钮、输入框)。这相当于把屏幕的“源代码”喂给模型,告诉它:“这里有个‘登录’按钮,坐标是(200,300)”。第二种更直接,就是截一张图,把整个屏幕的视觉画面(一张JPG或PNG图片)传给模型,让它自己“看”图说话,识别出图中的元素和文字。

这两种方法孰优孰劣?在实际项目中,我花了大量时间对它们进行性能对比和失败分析。结果发现,没有绝对的赢家,只有适合不同场景的选择。文本提取速度快、精度高,但遇到游戏界面、自定义控件或者纯图片按钮就傻眼了;截图输入信息全面,模型能“看到”一切,但处理慢、成本高,而且对模型的“看图”能力要求极高,一不小心就识别错误。

这篇文章,我就以一个实际踩坑者的身份,拆解这两种方案的底层逻辑、实操细节,并分享那些在官方文档里找不到的“血泪教训”。无论你是想自己动手搭建一个自动化脚本,还是单纯好奇大语言模型如何与真实世界交互,相信这些实战经验都能给你带来启发。

2. 核心方案选型:文本提取 vs. 视觉识别

在开始写代码之前,我们必须搞清楚两种方案的根本差异,这决定了整个项目的技术栈和架构设计。这不是一个简单的“二选一”,而是基于不同需求的技术路径分叉。

2.1 方案一:屏幕文本提取(结构化数据输入)

这个方案的核心思想是绕过图像识别,直接从手机系统底层获取屏幕的“元数据”。在Android上,这通常通过 AccessibilityService (无障碍服务)或 UI Automator 来实现;在iOS上,则依赖于 XCUITest 框架。它们能获取到一个包含所有屏幕控件信息的XML树状结构(在Android上称为 UI Hierarchy )。

技术原理浅析 : 当你启动一个基于 UI Automator 的自动化脚本时,它就像是一个超级用户,可以查询系统:“当前屏幕最顶层是什么?它下面有哪些子视图?” 返回的数据是结构化的,例如一个按钮控件,你会得到它的 resource-id (如 com.example.app:id/login_button )、 text 属性(如“登录”)、 bounds 属性(描述它在屏幕上的矩形区域 [left, top, right, bottom] ),以及 clickable enabled 等状态。我们将这些信息整理成一段结构化的文本描述,发送给大语言模型。

它的核心优势非常明显

  1. 速度快、成本低 :传输和处理的是一段很小的文本(通常几KB),相比动辄几百KB的图片,API调用延迟和token消耗(对于按token收费的模型API)可以忽略不计。
  2. 精度接近100% :文本内容、控件坐标是系统直接提供的,不存在识别错误。你可以明确告诉模型“点击resource-id为 com.example.app:id/ok 的按钮”,这个指令是绝对精确的。
  3. 可操作性强 :获取到的 bounds 坐标可以直接用于模拟点击、滑动等操作,无需额外的坐标转换。

然而,它的致命缺陷也同样突出

  1. 信息缺失 :对于纯图片按钮(没有 text 属性, resource-id 可能是无意义的哈希值)、游戏画面、视频播放界面、或是大量自定义绘制的控件,UI层级中可能没有任何有意义的文本信息。模型拿到一段只有 android.view.View 和一堆坐标的数据,根本无法理解该做什么。
  2. 依赖系统接口 :不同手机厂商、系统版本对无障碍服务的支持程度不同,可能会遇到权限限制、接口不一致或性能问题。

实操心得 :在决定使用文本提取方案前,务必先用 UI Automator Viewer Appium Inspector 等工具,连接你的目标手机和目标应用,查看其UI层级结构。如果关键交互元素(特别是按钮)都有清晰的 text content-desc 属性,那么这个方案就是首选,效率极高。如果满眼都是 android.widget.ImageView android.view.View ,那就要慎用。

2.2 方案二:屏幕截图输入(视觉语言模型输入)

当文本提取走不通时,我们只能让模型“亲眼看看”。这个方案就是定时或触发式地对手机屏幕进行截图,将截图图片输入给具有视觉理解能力的大语言模型(如GPT-4V、Gemini Pro Vision、Claude 3、或开源的LLaVA等),让模型描述图片内容并生成操作指令。

技术流程解析

  1. 截图捕获 :通过ADB命令( adb exec-out screencap -p )、 Appium 的接口或手机系统自身的截图能力,获取当前屏幕的RGB图像。
  2. 图像预处理 :有时为了减少传输数据量和模型负担,会对截图进行压缩或裁剪。但要注意,过度压缩可能损失关键细节(如小号文字)。
  3. 视觉理解与指令生成 :将图片(通常以Base64编码形式)与精心设计的提示词(Prompt)一起发送给多模态大语言模型。Prompt需要明确指令,例如:“分析这张手机截图,描述所有可交互元素(如按钮、输入框)及其可能的功能,并根据我的目标‘找到并点击登录按钮’,给出下一步的操作建议和具体坐标。”

它的优势在于“全能”

  1. 信息完备 :屏幕上的一切,无论是文字、图标、图片还是动态内容,都被包含在图像中。理论上,只要模型足够强,它就能理解任何界面。
  2. 跨平台一致性 :截图方法在Android、iOS乃至模拟器上基本一致,避开了系统底层接口的差异。

但代价是高昂的

  1. 性能瓶颈 :图片数据量大,传输耗时;多模态大模型推理速度远慢于纯文本模型;API调用成本也高出一个数量级。
  2. 识别不确定性 :模型可能会“看错”。比如把“立即购买”图标误认为“购物车”,或者对相似图标产生混淆。坐标预测也可能出现几个像素的偏差,在密集的UI中可能导致误触。
  3. 提示词工程复杂 :如何让模型稳定、准确地输出结构化的、可解析的操作指令(如 CLICK [x, y] ),是一个巨大的挑战。模型可能会用自然语言描述,需要额外的后处理来解析。

2.3 混合策略:因地制宜的实践智慧

在实际项目中,纯用一种方案往往无法应对所有场景。我采用的是一种 动态混合策略 ,这也是性能对比后得出的最优解。

策略核心 :默认使用高效的屏幕文本提取方案。同时,设立一个“异常检测”机制。当从UI层级中提取到的有效文本信息过少(例如,超过80%的控件没有 text 或可理解的 content-desc ),或者模型基于文本信息连续多次都无法做出有效决策时,系统自动切换至截图模式,调用视觉模型进行“视觉救援”。

实现上的关键点

  1. 状态判断阈值 :需要根据具体应用设定阈值。比如,统计当前屏幕有意义的文本控件占比,低于30%则触发截图。
  2. 缓存与降级 :对于成功通过截图模式解析的界面,可以缓存其截图特征或解析结果。下次遇到类似界面时,可以尝试直接使用文本方案结合缓存信息进行匹配,减少对视觉模型的调用。
  3. 操作验证 :无论是哪种方案产生的操作指令(尤其是坐标),在执行后,都应该通过再次获取屏幕状态(文本或截图)来验证操作是否生效。例如,点击“登录”后,检查是否出现了登录成功的提示或页面跳转。如果没有,则触发重试或切换方案。

3. 技术实现细节与工具链搭建

理论说完了,我们来看看具体怎么干。这里我以Android平台为例,搭建一个基于Python的自动化测试框架,集成两种方案。

3.1 基础环境与工具准备

首先,你需要一个“桥梁”来连接电脑和手机,并执行命令。

  1. ADB (Android Debug Bridge) :这是基石。确保安装并配置好,手机开启USB调试模式,通过 adb devices 能识别到设备。
  2. Python环境 :推荐3.8+版本。
  3. 核心Python库
    • pure-python-adb : 一个纯Python的ADB客户端库,比直接调用命令行更优雅。
    • Pillow (PIL) : 用于处理截图图片,如压缩、裁剪、保存。
    • openai / anthropic / google-generativeai 等:根据你选用的多模态大语言模型API选择对应的官方SDK。
    • xml.etree.ElementTree : Python标准库,用于解析从UI Automator dump出来的XML文件。

项目目录结构建议

llm_mobile_auto/
├── config.yaml          # 配置文件,存放API密钥、模型选择、阈值等
├── main.py              # 主程序入口
├── core/
│   ├── __init__.py
│   ├── screen_capturer.py   # 截图模块
│   ├── ui_extractor.py      # UI文本提取模块
│   ├── llm_client.py        # LLM客户端封装
│   └── action_executor.py   # 动作执行模块(ADB点击、滑动)
├── prompts/             # 存放不同任务的提示词模板
│   └── vision_analysis_prompt.txt
└── utils/
    └── image_utils.py   # 图像处理工具函数

3.2 屏幕文本提取的实现

我们通过ADB命令获取UI层级信息。

# core/ui_extractor.py
import subprocess
import xml.etree.ElementTree as ET
from typing import List, Dict

class UIExtractor:
    def __init__(self, device_id: str = None):
        self.device_id = device_id
        self.adb_prefix = ['adb'] if not device_id else ['adb', '-s', device_id]

    def dump_ui_hierarchy(self) -> str:
        """获取当前屏幕的UI层级XML"""
        # 将XML输出到设备的临时文件,再拉取到本地
        tmp_file = '/sdcard/window_dump.xml'
        subprocess.run(self.adb_prefix + ['shell', 'uiautomator', 'dump', tmp_file], capture_output=True)
        subprocess.run(self.adb_prefix + ['pull', tmp_file, 'local_dump.xml'], capture_output=True)
        with open('local_dump.xml', 'r', encoding='utf-8') as f:
            xml_content = f.read()
        # 清理临时文件
        subprocess.run(self.adb_prefix + ['shell', 'rm', tmp_file])
        return xml_content

    def parse_ui_elements(self, xml_content: str) -> List[Dict]:
        """解析XML,提取关键控件信息"""
        root = ET.fromstring(xml_content)
        elements = []
        # 遍历所有节点,寻找包含文本或可点击的节点
        for node in root.iter('node'):
            bounds = node.get('bounds')
            text = node.get('text')
            resource_id = node.get('resource-id')
            clickable = node.get('clickable') == 'true'
            enabled = node.get('enabled') == 'true'

            # 只收集有文本或可点击且启用的元素,减少噪音
            if (text and text.strip()) or (clickable and enabled):
                # 解析bounds字符串 "[x1,y1][x2,y2]" 为坐标元组
                if bounds:
                    coords = bounds.replace('[', '').replace(']', ',').split(',')
                    coords = [int(c) for c in coords if c]
                    center_x = (coords[0] + coords[2]) // 2
                    center_y = (coords[1] + coords[3]) // 2
                else:
                    center_x, center_y = None, None

                elements.append({
                    'text': text.strip() if text else '',
                    'resource_id': resource_id,
                    'bounds': bounds,
                    'center': (center_x, center_y),
                    'clickable': clickable,
                    'class': node.get('class')
                })
        return elements

    def get_screen_description(self) -> str:
        """生成给LLM的屏幕文本描述"""
        xml = self.dump_ui_hierarchy()
        elements = self.parse_ui_elements(xml)
        description_lines = ["当前屏幕有以下可交互元素:"]
        for idx, elem in enumerate(elements):
            desc = f"{idx+1}. "
            if elem['text']:
                desc += f"文本: '{elem['text']}', "
            if elem['resource_id']:
                desc += f"ID: {elem['resource_id']}, "
            desc += f"类型: {elem['class']}, 可点击: {elem['clickable']}"
            if elem['center']:
                desc += f", 中心坐标: ({elem['center'][0]}, {elem['center'][1]})"
            description_lines.append(desc)
        return "\n".join(description_lines)

注意事项 uiautomator dump 命令在某些系统或界面下可能失败或返回空内容。务必增加异常处理,并准备一个备用的截图方案。此外,解析出的坐标是屏幕绝对坐标,在不同分辨率设备上需要做归一化处理(如果模型需要的话),但执行点击时直接用绝对坐标即可。

3.3 屏幕截图与视觉识别的实现

接下来是截图和调用多模态模型的环节。

# core/screen_capturer.py
import subprocess
from PIL import Image
import io
import base64

class ScreenCapturer:
    def __init__(self, device_id: str = None):
        self.device_id = device_id
        self.adb_prefix = ['adb'] if not device_id else ['adb', '-s', device_id]

    def capture_to_bytes(self) -> bytes:
        """通过ADB截图并返回字节流"""
        # 使用exec-out直接获取二进制流,效率比先存文件再pull高
        result = subprocess.run(self.adb_prefix + ['exec-out', 'screencap', '-p'], capture_output=True)
        return result.stdout

    def capture_to_image(self) -> Image.Image:
        """截图并返回PIL Image对象"""
        img_bytes = self.capture_to_bytes()
        # 注意:ADB screencap的原始格式可能需要处理(如去除头部信息),但现代设备通常直接输出png
        return Image.open(io.BytesIO(img_bytes))

    def capture_and_encode(self, max_size: tuple = (800, 1600)) -> str:
        """截图,调整大小(保持比例),并编码为Base64字符串"""
        img = self.capture_to_image()
        # 调整图片尺寸,减少传输量和模型处理负担
        img.thumbnail(max_size, Image.Resampling.LANCZOS)
        buffered = io.BytesIO()
        img.save(buffered, format="PNG", optimize=True)
        img_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
        return img_base64
# core/llm_client.py
import yaml
from openai import OpenAI # 以OpenAI GPT-4V为例

class VisionLLMClient:
    def __init__(self, config_path: str = 'config.yaml'):
        with open(config_path, 'r') as f:
            config = yaml.safe_load(f)
        self.api_key = config['openai']['api_key']
        self.model = config['openai'].get('vision_model', 'gpt-4-vision-preview')
        self.client = OpenAI(api_key=self.api_key)

    def analyze_screen(self, image_base64: str, user_prompt: str) -> str:
        """调用多模态模型分析截图"""
        # 从文件读取提示词模板,并注入用户的具体目标
        with open('prompts/vision_analysis_prompt.txt', 'r') as f:
            system_prompt = f.read()
        full_prompt = system_prompt + "\n用户当前目标是:" + user_prompt

        response = self.client.chat.completions.create(
            model=self.model,
            messages=[
                {"role": "system", "content": system_prompt},
                {
                    "role": "user",
                    "content": [
                        {"type": "text", "text": full_prompt},
                        {
                            "type": "image_url",
                            "image_url": {
                                "url": f"data:image/png;base64,{image_base64}"
                            }
                        }
                    ]
                }
            ],
            max_tokens=500,
        )
        return response.choices[0].message.content

提示词工程(prompts/vision_analysis_prompt.txt)示例

你是一个手机自动化助手。你需要分析用户提供的手机屏幕截图,并给出下一步操作建议。

请严格按照以下格式输出:
1. **屏幕描述**:用一句话简要描述当前屏幕的主要内容(例如:“这是一个微信的聊天列表页面”)。
2. **可交互元素**:列出图中所有可能可点击或输入的元素,包括按钮、输入框、图标等。对每个元素,描述其外观、位置(用上下左右中、左上区域等相对位置描述)和可能的功能。
3. **操作建议**:根据“用户当前目标”,给出具体的下一步操作。操作必须是可执行的,例如:
   - 如果目标是点击某个按钮,请输出:`ACTION: CLICK [元素描述,如‘绿色的‘登录’按钮’]`。
   - 如果目标是输入文本,请输出:`ACTION: INPUT [元素描述,如‘顶部的搜索框’] [要输入的文本]`。
   - 如果需要滑动,请输出:`ACTION: SWIPE [起始区域] [方向] [距离,如‘短距离’/‘长距离’]`。
4. **坐标估算(可选)**:如果你能非常确定元素的位置,可以提供其屏幕中心的大致坐标,格式为:`COORD: (x, y)`。注意:坐标必须基于图片像素位置估算,x范围0~[图片宽度],y范围0~[图片高度]。如果不确定,请不要提供。

请确保输出清晰、结构化,便于程序解析。

3.4 动作执行与决策循环

最后,我们需要一个大脑来协调文本提取、视觉分析和动作执行。

# main.py 简化示例
from core.ui_extractor import UIExtractor
from core.screen_capturer import ScreenCapturer
from core.llm_client import VisionLLMClient
from core.action_executor import ActionExecutor
import re
import time

class MobileAutoAgent:
    def __init__(self):
        self.ui_extractor = UIExtractor()
        self.screen_capturer = ScreenCapturer()
        self.vision_llm = VisionLLMClient()
        self.action_executor = ActionExecutor()
        self.last_action = None
        self.failure_count = 0

    def run_task(self, ultimate_goal: str):
        """执行一个自动化任务"""
        current_goal = ultimate_goal
        max_steps = 20
        for step in range(max_steps):
            print(f"\n=== 步骤 {step+1} ===")
            print(f"当前目标: {current_goal}")

            # 1. 尝试文本提取方案
            print("正在通过UI层级分析屏幕...")
            text_description = self.ui_extractor.get_screen_description()
            # 简单启发式判断:如果可交互元素太少或没有文本,则视为文本方案信息不足
            if len(text_description.split('\n')) < 3: # 举例阈值
                print("文本信息不足,切换到视觉分析。")
                analysis_result = self._analyze_by_vision(current_goal)
            else:
                # 将文本描述和目标一起发送给纯文本LLM(此处简化,实际需调用文本模型)
                # 假设我们有一个函数能基于文本做决策
                analysis_result = self._analyze_by_text(text_description, current_goal)

            # 2. 解析LLM返回的指令
            action, coord = self._parse_llm_output(analysis_result)
            if not action:
                print("无法解析LLM输出,尝试视觉分析。")
                analysis_result = self._analyze_by_vision(current_goal)
                action, coord = self._parse_llm_output(analysis_result)

            # 3. 执行动作
            if action and coord:
                print(f"执行: {action} 于坐标 {coord}")
                self.action_executor.click(coord[0], coord[1])
                self.last_action = (action, coord)
                self.failure_count = 0
                time.sleep(2) # 等待界面响应
                # 4. 验证/更新目标 (此处简化)
                # 可以再次获取屏幕状态,判断目标是否达成,并更新current_goal
                # 例如,如果目标是“登录”,点击登录按钮后,current_goal可更新为“检查是否登录成功”
            else:
                print("未能生成有效动作指令。")
                self.failure_count += 1
                if self.failure_count > 3:
                    print("连续失败多次,任务终止。")
                    break

            # 简单模拟:假设任务完成
            if step > 5: # 示例终止条件
                print("任务流程执行完毕。")
                break

    def _analyze_by_vision(self, goal: str) -> str:
        """使用视觉模型分析"""
        image_b64 = self.screen_capturer.capture_and_encode()
        return self.vision_llm.analyze_screen(image_b64, goal)

    def _parse_llm_output(self, output: str):
        """从LLM输出中解析动作和坐标(简单正则示例)"""
        action_match = re.search(r'ACTION:\s*(CLICK|INPUT|SWIPE)\s*(.*)', output)
        coord_match = re.search(r'COORD:\s*\((\d+),\s*(\d+)\)', output)
        action = action_match.group(1) if action_match else None
        action_desc = action_match.group(2).strip() if action_match else None
        coord = (int(coord_match.group(1)), int(coord_match.group(2))) if coord_match else None

        # 如果没有提供坐标,但有点击动作描述,可以尝试从之前的UI元素中匹配或使用启发式方法估算(此处简化)
        if action == 'CLICK' and not coord and action_desc:
            # 这里可以加入逻辑:例如,如果action_desc包含“登录”,则去UI元素列表中寻找文本包含“登录”的元素的坐标
            pass
        return action, coord

if __name__ == '__main__':
    agent = MobileAutoAgent()
    agent.run_task("打开设置,然后进入WLAN页面")

4. 性能对比实测与数据分析

纸上谈兵终觉浅,我搭建了测试环境,对两种方案进行了定量和定性的对比测试。测试环境为一台中端安卓手机(分辨率1080x2400),电脑通过USB连接,使用GPT-4 Turbo(文本)和GPT-4V(视觉)作为后端模型。

4.1 响应延迟对比

我选取了三种典型页面进行测试,每个页面操作10次,取平均值:

页面类型 文本提取+LLM推理总耗时 截图+视觉LLM推理总耗时 视觉方案慢的倍数
标准列表页 (微信通讯录) 1.2 - 1.8 秒 8 - 15 秒 5x - 8x
图文混合页 (小红书信息流) 1.5 - 2.5 秒 10 - 18 秒 6x - 7x
游戏/全屏应用 (简单游戏界面) 2.0+ 秒(信息少,LLM困惑) 12 - 20 秒 6x - 10x

分析

  • 文本方案延迟主要来自 uiautomator dump 和拉取XML文件(约0.5-1秒)+ 文本LLM API调用(约0.5-1秒)。延迟稳定且较低。
  • 视觉方案延迟主要来自 :截图与传输(约0.5秒)+ 图片Base64编码(约0.2秒)+ 视觉LLM API调用(占大头,7-15秒) 。延迟高且波动大,受图片复杂度、网络、API负载影响显著。

实操心得 :如果你追求的是接近人类操作的流畅速度(比如做自动化测试),文本方案是唯一可行的选择。视觉方案的延迟在复杂任务链中会累积成难以忍受的等待时间。视觉方案更适合作为“备用侦察兵”,在文本方案失效时,执行单次的关键识别。

4.2 识别准确率与指令可用性对比

准确率不能只看模型“说了什么”,更要看它输出的指令能否被 正确解析并成功执行

测试场景 文本方案成功率 视觉方案成功率 主要失败原因
标准按钮点击 (“登录”、“确定”、“下一步”) ~99% ~85% 文本:几乎无失败。视觉:图标相似误认、坐标估算偏差。
图标识别 (购物车、搜索、分享) <10% ~75% 文本:图标通常无文本,resource-id可能无意义。视觉:主流图标识别较好,小众或设计独特的图标易错。
文字输入 ~95% ~70% 文本:能精确定位输入框。视觉:可能将提示文字误判为已输入内容,或定位不准。
动态内容 (视频播放器控件、滚动列表) ~30% ~65% 文本:动态控件可能不在UI树中或状态变化快。视觉:能“看到”但控件状态(如播放/暂停)可能识别错误。

关键发现

  1. 文本方案的“可用性”严重依赖应用开发规范 。对于遵循Material Design、控件属性完善的主流App,其成功率极高。但对于游戏、Hybrid App(WebView)或某些金融类App的自定义控件,成功率断崖式下跌。
  2. 视觉方案的准确率非常依赖提示词和后续解析 。模型可能会说“点击右下角的蓝色发送箭头”,但你需要将“右下角”转换为具体坐标。这个转换过程(通过描述估算坐标)本身就会引入误差。我尝试让模型直接输出坐标,但其稳定性不如输出描述。
  3. 混合策略效果显著 :在测试中,采用“文本优先,视觉兜底”的混合策略,整体任务成功率从纯文本方案的~70%提升到了~92%,而平均耗时只比纯文本方案增加了约15%(因为大部分步骤仍走文本通道)。

4.3 成本分析

对于使用商用API的开发者,成本是必须考虑的。

  • 文本方案 :以GPT-4 Turbo为例,一次包含屏幕描述和指令生成的交互,大约消耗500-1000 tokens。按$10 / 1M tokens输入计算,单次成本约**$0.005-$0.01**。
  • 视觉方案 :以GPT-4V为例,处理一张1080x2400的压缩截图(约800x1600,低细节模式),单次调用成本约**$0.01-$0.03**(取决于图片token数量)。

结论 :视觉方案的单次调用成本是文本方案的2-6倍。在需要高频交互的自动化流程中,这个成本差异会被急剧放大。

5. 典型失败案例分析与避坑指南

在实际运行中,你会遇到各种光怪陆离的失败情况。下面分享几个典型案例和解决思路。

5.1 案例一:文本方案遭遇“透明”控件

现象 :在一个新闻App中,需要点击文章底部的“点赞”心形图标。UI层级显示该区域是一个 FrameLayout ,其下有一个 ImageView ,但 text 为空, content-desc 也为空, resource-id 是一串类似 com.xxx:id/abc 的自动生成ID。文本方案无法理解这是个点赞按钮。

根因 :这是非常常见的开发模式,图标作为图片资源嵌入,没有添加无障碍标签( contentDescription )。

解决方案

  1. 混合策略触发 :当解析到当前屏幕有大量无文本的 ImageView 且与目标相关时,自动切换到视觉分析。
  2. 图像特征缓存 :首次通过视觉模型成功识别并点击“点赞”图标后,记录下该图标出现的大致屏幕区域(例如,在文章底部区域)以及其相邻的文本控件(如“分享”、“评论”)。下次在类似位置看到相似的UI布局时,即使没有文本信息,也可以根据上下文和位置历史进行概率性点击。
  3. 资源ID映射表 :对于固定的App,可以手动或半自动地建立一个 resource-id 到功能的映射表。虽然 id 看似随机,但在同一App版本内,特定功能的 id 是固定的。通过前期“训练”记录下“点赞”按钮的 resource-id ,后续就可以直接使用。

5.2 案例二:视觉模型“指鹿为马”

现象 :让模型在电商App首页点击“购物车”图标。截图中有顶部导航栏的购物车图标,也有底部Tab栏的购物车图标。模型错误地指示点击了底部Tab栏的图标(该图标已处于选中状态,颜色不同),而实际需要的是顶部的。

根因 :多模态模型对UI状态(选中/未选中)的感知能力较弱,更侧重于图标形状和文本标签的识别。提示词中未强调“非选中状态的”或“位于顶部的”等区分性信息。

解决方案

  1. 精细化提示词 :在Prompt中明确要求模型注意元素的状态和精确位置。例如:“请注意元件的视觉状态,如颜色深浅、是否高亮,这通常表示是否已被选中。请优先选择未选中状态的目标元件。”
  2. 后处理校验 :执行点击后,再次截图,让模型分析操作结果。如果目标状态未发生预期变化(例如,顶部购物车图标未被点击),则判定为操作失败,触发重试或尝试点击另一个候选目标。
  3. 结合文本上下文 :即使在视觉模式下,也可以将UI层级中有限的文本信息(如顶部导航栏的标题文字“首页”)一并提供给模型,帮助其进行区域定位。

5.3 案例三:坐标漂移与点击无效

现象 :模型输出的坐标 (500, 1200) ,但实际执行 adb shell input tap 500 1200 后没有任何反应。

根因

  1. 坐标系统差异 :模型估算的坐标是基于你提供的截图分辨率。如果你截图后进行了缩放,但未将坐标等比映射回手机屏幕的实际分辨率,就会出错。
  2. 控件点击区域 :有些控件的可点击区域( clickable 区域)小于其视觉边界。点击在视觉中心可能无效,需要点击在特定子控件上。
  3. 界面延迟加载 :点击时,目标控件可能尚未加载完成或处于动画过渡中。

解决方案

  1. 坐标映射 :务必记录截图的原始尺寸和手机屏幕的实际分辨率。执行点击前,将模型返回的坐标按比例进行转换: 实际x = 模型x * (屏幕宽 / 截图宽) 实际y 同理。
  2. 使用UI Automator进行点击 :不要总是用 input tap 。如果通过文本方案已经定位到控件,优先使用基于 resource-id bounds 的精准点击命令,如 adb shell uiautomator click <resource-id> ,这能直接触发控件的点击事件,避开了坐标问题。
  3. 增加重试与等待 :点击操作后,加入显式等待(如 time.sleep(1) ),并检查屏幕是否变化。未变化则重试。对于网络加载慢的界面,需要更智能的等待条件,比如轮询直到某个特定元素出现。

5.4 稳定性优化与经验总结

经过大量测试,我总结出几条提升整体稳定性的关键经验:

  1. 降级与超时机制 :为每个LLM API调用设置严格的超时(如10秒)。超时后,自动降级到更简单的规则引擎或预定义的操作序列。
  2. 操作原子化与状态检查 :将每个“目标-动作”对设计得尽可能小。例如,“登录”不是一个原子操作,它应拆分为“输入用户名”、“输入密码”、“点击登录按钮”、“验证登录成功”等多个步骤。每个步骤前后都要进行屏幕状态检查,确保上一步已生效再执行下一步。
  3. 引入确定性备份路径 :对于关键流程(如App启动后的首页导航),不要完全依赖LLM。可以预先配置一些基于 resource-id 或固定坐标的确定性操作作为备份路径。当LLM连续失败时,启用备份路径将流程拉回正轨。
  4. 日志与回放 :详细记录每一步的屏幕信息(文本或截图摘要)、LLM的决策依据、执行的操作和结果。这不仅能用于调试,还可以作为数据反哺,用于优化提示词或训练更专用的模型。

大语言模型驱动手机自动化,目前仍处于“辅助”和“探索”阶段。纯视觉方案受限于成本、速度和精度,难以承担高频任务;而纯文本方案则受制于应用的可访问性。将两者结合,以文本为主、视觉为辅,并辅以传统的自动化脚本逻辑,是当前最务实、最高效的路径。这个项目的价值不仅在于实现自动化本身,更在于为我们理解如何让大模型与复杂的物理世界(在这里是数字界面)进行稳定、可靠的交互,提供了一个绝佳的试验场。每一次失败的分析,都在为未来更智能的Agent积累宝贵的经验。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐