十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

AI Agent移动端自动化实践:基于Skill的UI交互与RPA实现

AI Agent移动端自动化实践:基于Skill的UI交互与RPA实现 1. 项目概述当AI Agent学会“动手”最近在捣鼓AI Agent项目时我遇到了一个典型的瓶颈Agent能说会道分析问题头头是道但一到让它去实际“操作”点什么比如在手机上帮我订个外卖、查个物流或者自动处理一些重复性的APP操作它就立刻“哑火”了。这感觉就像你有一个绝顶聪明的军师能给你制定完美的作战计划但他自己却连枪都拿不起来。这个痛点在移动互联网时代尤为突出——我们每天大量的交互都发生在手机APP里。于是我开始深入研究如何让Agent“学会动手”。这不仅仅是调用一个API那么简单它涉及到让AI理解复杂的、动态的图形用户界面并像真人一样进行精准的点击、滑动、输入等操作。最终我找到并实践了一套基于“Skill”的解决方案。简单来说Skill就是赋予Agent的“手”和“眼”。它让Agent从只会理解和思考的“大脑”进化成一个能真正在移动端APP环境中执行任务的“全能助手”。这补齐了自动化流程的“最后一公里”使得从意图识别到最终动作完成的闭环成为可能。无论你是想构建一个私人自动化助理还是开发企业级的RPA机器人流程自动化工具亦或是单纯对下一代人机交互感兴趣理解并掌握如何为Agent赋予执行能力都至关重要。接下来我将详细拆解这套系统的设计思路、核心组件、实操搭建过程以及我踩过的那些坑。2. 核心思路为什么是“Skill”而不是“API”在解决“执行”问题时很多人第一反应是给每个需要操作的APP都对接其官方API。这听起来很完美但现实中这条路几乎走不通。原因有三覆盖度问题绝大多数APP特别是国内的主流应用并未提供完整的、可供第三方调用的公开API。即使有也通常有严格的权限、频次和审核限制。稳定性问题API接口会变更一旦APP更新你的自动化流程就可能中断维护成本极高。灵活性问题API是预设的功能无法处理APP内那些未被接口化的、临时的或基于复杂UI交互的任务比如在一个新出的活动页面里完成任务。因此我们需要的是一种更底层、更通用的能力——直接与APP的UI界面进行交互。这就引出了“Skill”的概念。在我的定义里一个完整的移动端执行Skill通常包含以下核心能力视觉感知能“看到”手机屏幕上的内容识别UI元素按钮、文本框、列表等及其位置。意图映射能将高层的用户指令如“订一份附近评分最高的酸菜鱼外卖”分解并映射到一系列具体的UI操作序列打开美团-点击外卖-搜索“酸菜鱼”-按评分排序-选择第一个商家-……。精准操控能模拟人类的触摸操作包括点击、长按、滑动、输入文本等。状态判断与容错能判断操作是否成功如页面是否跳转并在出现弹窗、网络错误等异常时进行恢复或重试。这套方案不依赖于任何特定APP的后台接口而是站在“用户”的视角进行操作因而具备了普适性。只要是人能操作的APP理论上Agent都能通过Skill去操作。2.1 技术选型CV路线 vs 辅助功能路线实现UI自动化主要有两大技术路线路线一计算机视觉驱动这种方式通过实时分析手机屏幕截图使用OCR识别文字用目标检测模型识别图标和控件然后计算坐标进行模拟点击。代表工具有Appium测试领域和基于OpenCV、PaddleOCR、YOLO的自建方案。优点真正跨平台不依赖APP内部结构最接近真人操作。缺点技术栈复杂稳定性受图像识别准确率影响执行速度相对较慢对屏幕分辨率、主题变化敏感。路线二辅助功能/UI自动化框架驱动这种方式利用操作系统提供的无障碍服务或UI自动化测试框架如Android的AccessibilityService/UiAutomatoriOS的XCUITest直接获取UI控件树通过控件ID、文本等属性进行定位和操作。优点执行速度快定位精准不受界面颜色、主题影响更稳定。缺点需要一定的系统权限部分复杂或动态控件可能难以定位不同平台和版本有差异。我的选择与理由 对于追求稳定性和执行效率的生产环境我优先推荐“辅助功能路线”。它更可靠更像是“程序与程序”之间的对话。我们将以此为主线进行构建。视觉路线可以作为重要的补充和降级方案用于处理那些无法通过控件树定位的特殊场景如游戏界面、自定义绘制控件。3. 核心组件拆解构建一个执行Skill的四大支柱一个健壮的移动端执行Skill系统需要四大核心组件协同工作。我将以Android平台为例结合UiAutomator2这个强大的框架进行说明。3.1 支柱一设备连接与控制层这是所有操作的物理基础。我们需要稳定地连接手机或模拟器并能向其发送指令、获取屏幕信息。核心工具adb。这是Android调试桥是与设备通信的瑞士军刀。关键操作adb devices检查设备连接。adb shell进入设备shell环境。adb install安装APK。adb shell input发送触摸、按键事件。实操心得注意务必使用同一台电脑和同一条数据线进行长期开发测试避免驱动问题。对于无线连接虽然方便但在执行大量快速操作时稳定性不如有线连接。建议在关键业务流程中默认使用有线连接。3.2 支柱二UI元素探测与定位层这是Skill的“眼睛”。我们需要获取当前屏幕的UI控件树并从中找到目标元素。核心框架UiAutomator2。它提供了uiautomatorviewer工具已弃用但原理相通和weditor等更现代的工具来查看控件层级以及丰富的API来定位元素。定位策略按优先级排序resource-id最理想的定位方式唯一且稳定。如com.tencent.mm:id/btn1。content-desc / accessibility-id为无障碍服务设计的描述通常也很稳定。text控件的显示文本。但需注意多语言、动态文本的问题。className / xpath当以上都不行时使用。xpath功能强大但性能稍差且容易因UI微调而失效。代码示例Python uiautomator2库import uiautomator2 as u2 # 连接设备 d u2.connect() # 默认连接当前设备 # 定位并点击一个“同意”按钮 # 最佳情况使用resource-id if d(resourceIdcom.example.app:id/agree_btn).exists: d(resourceIdcom.example.app:id/agree_btn).click() # 次选使用text elif d(text同意).exists: d(text同意).click() # 备选使用组合定位 else: d(classNameandroid.widget.Button, textMatches.*同意.*).click()3.3 支柱三操作编排与逻辑层这是Skill的“大脑”和“小脑”。它将高层的任务分解成一系列原子操作并处理操作间的逻辑和状态判断。原子操作封装将点击、滑动、输入、返回等基础操作封装成可靠函数。def safe_click(selector, timeout10): 安全的点击操作包含等待和重试 element selector.wait(timeouttimeout) if element: element.click() return True else: logger.warning(f元素未找到: {selector.info}) return False def input_text_safely(selector, text, clearTrue): 安全的文本输入先点击聚焦再输入 if safe_click(selector): if clear: d.clear_text() # 有些控件需要 d.send_keys(text) return True return False任务流程编排使用明确的步骤函数来组织任务。def order_coffee(app_name, coffee_type): 订咖啡任务流程 steps [ (启动APP, lambda: launch_app(app_name)), (关闭弹窗, close_popups), (进入咖啡门店, enter_coffee_shop), (选择咖啡, lambda: select_coffee_type(coffee_type)), (结算, checkout), (支付确认, confirm_payment) ] for step_name, step_func in steps: logger.info(f执行步骤: {step_name}) if not step_func(): logger.error(f步骤失败: {step_name}) recover_from_failure() # 错误恢复 break状态判断操作后必须检查是否达到预期状态。例如点击登录按钮后应该检查是否出现了用户头像或者跳转到了主页而不是简单地等待几秒钟。def wait_for_homepage(timeout15): 等待直到主页关键元素出现 key_element d(resourceIdcom.example.app:id/home_tab) return key_element.wait(timeouttimeout)3.4 支柱四异常处理与恢复层这是Skill的“免疫系统”。移动端环境复杂多变必须有健壮的容错机制。常见异常元素查找超时页面加载慢或元素定位器失效。意外弹窗广告、权限申请、系统更新提示。网络异常操作中断页面显示错误。APP崩溃或无响应。恢复策略重试机制对于临时性失败如网络抖动立即重试1-2次。弹窗处理维护一个“常见弹窗黑名单”在每个关键步骤前后检查并关闭已知弹窗。def close_common_popups(): popup_selectors [ d(textMatches.*允许.*), d(textMatches.*确定.*), d(textMatches.*知道了.*), d(resourceIdcom.android.systemui:id/close_btn), ] for selector in popup_selectors: if selector.exists(timeout2): # 快速检查 selector.click() time.sleep(0.5)状态回滚如果多步任务中途失败尝试回退到上一个已知安全状态如返回主页面。终极方案重启APP或记录错误上下文后终止任务等待人工干预或更高层Agent的调度。4. 实战从零构建一个“外卖比价下单”Skill理论说再多不如动手做一遍。我们来构建一个相对复杂但实用的Skill在美团和饿了么上比价同一家餐厅的特定菜品并在更便宜的平台下单。4.1 环境准备与基础框架搭建首先确保你的开发环境就绪硬件一台Android手机开发者模式已开启USB调试已打开或性能足够的模拟器如Android Studio AVD。软件Python 3.8uiautomator2库pip install uiautomator2weditorUI查看器pip install weditor安装后运行python -m weditor会自动打开浏览器。手机端需要安装uiautomator2的守护进程在电脑上执行python -m uiautomator2 init它会自动给连接的设备安装必要的APK。项目目录结构建议如下food_ordering_skill/ ├── core/ │ ├── __init__.py │ ├── device_connector.py # 设备连接管理 │ ├── ui_locator.py # UI定位策略封装 │ └── action_executor.py # 原子操作封装 ├── skills/ │ ├── __init__.py │ ├── base_skill.py # Skill基类 │ ├── meituan_skill.py # 美团Skill │ └── eleme_skill.py # 饿了么Skill ├── tasks/ │ └── price_comparison_task.py # 比价下单总任务 ├── configs/ │ └── popup_config.json # 弹窗配置 ├── utils/ │ └── logger.py └── main.py # 主入口4.2 编写美团Skill核心操作我们以美团Skill为例拆解“搜索餐厅-选择菜品-获取价格”这个子流程。第一步启动与初始化在meituan_skill.py中我们继承一个BaseSkill类它封装了设备对象和公共方法。# skills/meituan_skill.py from .base_skill import BaseSkill import time class MeituanSkill(BaseSkill): APP_PACKAGE com.sankuai.meituan APP_NAME 美团 def __init__(self, device): super().__init__(device, self.APP_PACKAGE, self.APP_NAME) def launch(self): 启动美团并跳过开屏广告 self.device.app_start(self.APP_PACKAGE) time.sleep(3) # 等待启动 # 尝试跳过开屏广告 self.close_common_popups() # 检查是否成功进入主页 if self.wait_for_element(resourceIdcom.sankuai.meituan:id/home_tab, timeout10): return True return False第二步搜索并进入指定餐厅这里的关键是处理搜索框的定位和输入以及从搜索结果列表中精准找到目标餐厅。def search_and_enter_restaurant(self, restaurant_name): 搜索并进入餐厅页面 # 1. 确保在首页点击搜索框 home_search self.device(resourceIdcom.sankuai.meituan:id/search_edit_text) if not self.safe_click(home_search): # 可能不在首页尝试切换Tab self.device(resourceIdcom.sankuai.meituan:id/home_tab).click() time.sleep(1) self.safe_click(home_search) # 2. 输入餐厅名 self.input_text_safely(self.device(resourceIdcom.sankuai.meituan:id/search_edit_text), restaurant_name) # 模拟键盘搜索键 self.device.press(enter) time.sleep(2) # 等待搜索结果加载 # 3. 在结果列表中查找目标餐厅 - 这是一个难点 # 搜索结果通常是一个可滑动的列表我们需要遍历查找 list_container self.device(classNameandroidx.recyclerview.widget.RecyclerView) if not list_container.exists: logger.error(未找到搜索结果列表) return False # 尝试通过餐厅名文本定位但要注意文本可能被截断 target self.device(textContainsrestaurant_name) # 如果直接找到点击 if target.exists: target.click() return True else: # 如果没找到可能需要滑动列表 for _ in range(3): # 最多滑动3次 self.device.swipe(0.5, 0.7, 0.5, 0.3, 0.5) # 从屏幕中部向上滑动 time.sleep(1) target self.device(textContainsrestaurant_name) if target.exists: target.click() return True logger.error(f未找到餐厅: {restaurant_name}) return False第三步在餐厅页面选择指定菜品并获取价格进入餐厅后页面结构复杂有菜单分类、菜品列表、购物车等。我们需要更精细的定位。def select_dish_and_get_price(self, dish_name): 选择菜品并返回其价格 # 1. 等待餐厅页面加载完成 if not self.wait_for_element(resourceIdcom.sankuai.meituan:id/restaurant_menu_container, timeout10): return None # 2. 滑动查找菜品 - 这是最不稳定的环节因为菜单列表是动态的 dish_price None menu_list self.device(classNameandroidx.recyclerview.widget.RecyclerView, resourceIdcom.sankuai.meituan:id/restaurant_menu_list) if not menu_list.exists: return None found False max_scroll_attempts 10 for i in range(max_scroll_attempts): # 尝试在当前屏幕查找菜品名和价格元素 # 菜品名通常在一个TextView里价格在另一个TextView里它们有共同的父布局 # 使用XPath进行相对定位会更准确 # 注意uiautomator2对xpath支持有限这里用近似方法 dish_elements self.device(classNameandroid.widget.TextView, textContainsdish_name) if dish_elements.exists: # 找到菜品名元素后需要找到其兄弟节点或父节点的兄弟节点中的价格元素 # 这是一个经验性的定位需要针对具体APP分析 # 假设价格元素的resource-id包含‘price’ price_element dish_elements.up(classNameandroid.widget.LinearLayout).child(resourceIdMatches.*price.*) if price_element.exists: price_text price_element.get_text() # 解析价格文本如“38”、“38元” import re match re.search(r(\d(\.\d)?), price_text) if match: dish_price float(match.group(1)) found True # 尝试点击“加号”按钮加入购物车 add_btn dish_elements.up(classNameandroid.widget.LinearLayout).child(classNameandroid.widget.Button, clickableTrue) if add_btn.exists: add_btn.click() time.sleep(0.5) break # 未找到向下滑动菜单 if i max_scroll_attempts - 1: menu_list.swipe(up, steps10) # 小幅滑动 time.sleep(1.5) if found: logger.info(f在{self.APP_NAME}找到菜品[{dish_name}]价格: {dish_price}) return dish_price else: logger.warning(f在{self.APP_NAME}未找到菜品: {dish_name}) return None4.3 编写比价与决策任务在tasks/price_comparison_task.py中我们编排整个比价流程。# tasks/price_comparison_task.py from skills.meituan_skill import MeituanSkill from skills.eleme_skill import ElemeSkill import time class PriceComparisonTask: def __init__(self, device, restaurant_name, dish_name): self.device device self.restaurant_name restaurant_name self.dish_name dish_name self.meituan MeituanSkill(device) self.eleme ElemeSkill(device) # 假设已实现 self.results {} def run(self): 执行比价任务 logger.info(f开始比价任务: 餐厅[{self.restaurant_name}], 菜品[{self.dish_name}]) # 在美团查询 if self.meituan.launch(): if self.meituan.search_and_enter_restaurant(self.restaurant_name): price_mt self.meituan.select_dish_and_get_price(self.dish_name) self.results[meituan] price_mt self.meituan.back_to_home() # 自定义方法返回美团首页 else: logger.error(美团启动失败) self.results[meituan] None # 短暂等待避免设备过热或操作冲突 time.sleep(3) # 在饿了么查询 (流程类似需实现ElemeSkill) if self.eleme.launch(): if self.eleme.search_and_enter_restaurant(self.restaurant_name): price_ele self.eleme.select_dish_and_get_price(self.dish_name) self.results[eleme] price_ele self.eleme.back_to_home() else: logger.error(饿了么启动失败) self.results[eleme] None # 决策 return self.make_decision() def make_decision(self): 根据比价结果做出决策 price_mt self.results.get(meituan) price_ele self.results.get(eleme) if price_mt is None and price_ele is None: return {action: fail, reason: 两个平台均未找到菜品} elif price_mt is None: return {action: order_on, platform: eleme, price: price_ele} elif price_ele is None: return {action: order_on, platform: meituan, price: price_mt} else: # 两者都有价格选择便宜的 if price_mt price_ele: return {action: order_on, platform: meituan, price: price_mt} else: return {action: order_on, platform: eleme, price: price_ele}4.4 集成与执行最后在main.py中我们连接设备并触发任务。# main.py import uiautomator2 as u2 from tasks.price_comparison_task import PriceComparisonTask import logging logging.basicConfig(levellogging.INFO, format%(asctime)s - %(name)s - %(levelname)s - %(message)s) logger logging.getLogger(__name__) def main(): # 连接设备确保adb devices能看到你的设备 serial None # 为None则连接当前唯一设备或指定序列号如“emulator-5554” try: d u2.connect(serial) logger.info(f已连接设备: {d.info}) except Exception as e: logger.error(f连接设备失败: {e}) return # 定义任务 restaurant 星巴克 dish 拿铁咖啡大杯 task PriceComparisonTask(d, restaurant, dish) decision task.run() logger.info(f比价决策结果: {decision}) # 根据决策执行下单这里需要扩展下单Skill if decision[action] order_on: logger.info(f将在[{decision[platform]}]平台下单价格{decision[price]}元) # 这里可以调用对应平台的下单Skill # if decision[platform] meituan: # meituan_skill.execute_order_flow(...) else: logger.warning(比价任务未完成无法下单) if __name__ __main__: main()5. 避坑指南与进阶优化在实际开发和运行中你会遇到无数意想不到的问题。以下是我总结的血泪经验。5.1 稳定性提升让Skill“更抗造”元素定位器失效这是最常见的问题。APP一更新resource-id可能就变了。对策不要依赖单一定位器。采用“多属性组合定位”和“相对定位”。优先使用resource-id和content-desc其次是稳定的text最后才用className和xpath。建立定位器版本管理当检测到大量定位失败时触发告警提示需要更新定位策略。页面加载时间不确定对策永远不要使用固定的time.sleep来等待。用wait(timeout10)代替。结合关键元素出现、页面标题变化、网络请求完成可通过adb logcat抓取特定tag等多种方式判断页面是否就绪。弹窗与中断对策建立“弹窗处理中间件”。在每个原子操作如click,input之前和之后都运行一个check_and_handle_popups()函数。这个函数维护一个可配置的弹窗列表包含定位器和处理动作像杀毒软件一样扫描并清除干扰。5.2 性能优化让Skill“跑得更快”减少不必要的截图和查找uiautomator2的exists()和get_text()等操作会触发底层通信。避免在循环中频繁调用。对策对屏幕状态进行缓存。如果短时间内多次判断同一元素可以只查第一次假设页面未刷新。批量获取元素信息。操作间隔优化对策人类操作有间隔但机器可以更快。然而太快会导致APP反应不过来。通过实验找到每个APP能承受的最小稳定间隔。通常click后等待0.3-0.5秒页面跳转后等待1-2秒并检查目标元素是一个安全的起点。并行与异步对策如果比价不涉及共享状态如购物车美团和饿了么的查询完全可以放在两个线程中并行执行利用concurrent.futures库可以大幅缩短总任务时间。5.3 可维护性设计让Skill“更好管理”配置与代码分离所有定位器、等待超时时间、重试次数、弹窗处理规则等都应该抽离到JSON或YAML配置文件中。Skill代码只负责读取配置和执行逻辑。Skill注册与发现机制当Skill越来越多时需要一个中心化的注册表。可以定义一个Skill基类所有具体Skill都继承它并注册自己。主程序通过任务类型动态加载和执行对应的Skill。日志与监控记录详细的操作日志包括每个步骤的开始结束时间、定位器信息、成功与否。这不仅是调试的利器也能用于后续分析性能瓶颈和失败模式。可以考虑将日志结构化并输出到文件或监控系统。5.4 融合视觉与辅助功能打造“双保险”对于极端情况如游戏界面、完全自定义的控件纯辅助功能路线可能失效。此时需要引入计算机视觉作为降级方案。方案在UiAutomator2定位失败时触发视觉回退流程。使用d.screenshot()截取当前屏幕。使用预训练的图标检测模型如YOLO或模板匹配在图片中查找“返回按钮”、“关闭图标”、“确定按钮”等通用元素。计算坐标并使用d.click(x, y)进行点击。工具链opencv-python用于图像处理和模板匹配paddleocr用于OCR识别文字提示。虽然速度慢但作为保底手段能极大提高系统的鲁棒性。6. 典型问题排查实录即使准备得再充分线上运行时总会出问题。这里记录几个我遇到过的典型问题及其排查思路。问题一脚本在夜间运行时突然大量点击失败。现象白天运行正常的脚本在凌晨执行时click()操作经常无效但日志显示元素找到了。排查检查日志发现元素定位成功。手动在设备上操作发现APP响应缓慢。查看系统日志adb logcat | grep -i “input”发现点击事件确实发送了。怀疑是设备性能或APP在后台被限制。尝试在每次click前增加d.click(0.1, 0.1)点击一个无关的角落“唤醒”屏幕和APP。增加click后的等待时间并添加操作后状态验证。根本原因设备在夜间可能开启了省电模式或APP进程被系统“冻结”导致UI响应线程优先级降低快速连续的点击事件被合并或丢弃。解决方案在关键操作前加入一个轻微的“唤醒”操作并适当延长关键步骤间的等待时间。更彻底的方案是锁定设备CPU性能模式并确保APP在后台白名单中。问题二在列表中点选了第三个项目但实际选中的总是第一个。现象在一个RecyclerView列表中代码逻辑是找到第三个匹配项并点击但实际效果总是点击了第一个项。排查使用weditor查看控件树发现列表中的每一项的resource-id和text等属性完全相同。uiautomator2的child和sibling方法在动态列表中定位不准确。尝试用d(className...)[2]这样的索引方式发现依然不稳定。根本原因UiAutomator2在获取控件列表时顺序可能和屏幕显示顺序不完全一致特别是对于复杂的长列表。解决方案放弃通过索引定位。改为先获取列表中所有匹配元素的中心坐标然后根据它们在屏幕上的Y坐标排序再对排序后的第三个坐标执行点击。elements d(classNameandroid.widget.TextView, textMatches.*目标.*) if elements.count 3: # 获取所有元素的信息 info_list [] for i in range(elements.count): info elements[i].info info_list.append({index: i, center_y: (info[bounds][top] info[bounds][bottom])/2}) # 按Y坐标排序 sorted_list sorted(info_list, keylambda x: x[center_y]) # 点击Y坐标排序后的第三个 target_index sorted_list[2][index] elements[target_index].click()问题三输入框无法输入中文。现象d.send_keys(“中文”)后输入框里显示的是乱码或拼音。排查uiautomator2的send_keys方法底层依赖ADB输入对中文支持可能有问题。解决方案首选如果APP支持先点击输入框然后使用设备的系统输入法通过adb shell input text发送已经编码好的文本注意此命令不支持空格和特殊字符需处理。次选使用d.set_fastinput_ime(True)启用uiautomator2的快速输入法一个内置的输入法它通常对中文支持更好。但需要先安装com.github.uiautomator/.FastInputIME。保底对于极其复杂的输入可以截图然后调用云端的OCR识别输入框内容再通过坐标模拟点击虚拟键盘。这非常复杂非必要不采用。构建一个真正可靠、通用的移动端执行Skill是一个持续迭代和打磨的过程。它没有一劳永逸的银弹需要你对目标APP的UI结构有深入的理解对自动化框架的局限性有清醒的认识并准备好一套完善的监控、告警和自修复机制。但一旦跑通它所释放的生产力是巨大的——从自动处理日常琐事到搭建复杂的商业自动化流程这扇门后的世界充满想象。
返回列表