Physical Address:
ChongQing,China.
WebSite:
安卓系统中实现GUI Agent的工程实践心得。
随着大模型能力的增强,通过大模型来控制我们的安卓系统已经逐渐成为现实。基于大模型实现对安卓设备的控制,可以取代传统的基于规则脚本来控制安卓设备的方式,具有更好的性能表现、任务完成率以及更好的泛化性,极大地节省开发者与测试人员的时间。
当前比较成熟地基于大模型来控制安卓设备的项目包含由阿里开发的MobileAgent项目以及第三方个人的droidrun项目,前者基于MobileAgent的专用模型,具备端到端的能力。而后者则是利用OpenAI, Anthropic, Gemini等第三方大模型的能力,需要额外的前/后处理。当然,截止到我写这篇博客的时间,面壁智能推出的AgentCPM-GUI也已经开源。
在具体的实现上,两者也存在差距,阿里MobileAgent主要是基于ADB控制安卓设备,在使用时需要通过USB或者无线ADB的方式连接安卓设备与PC主机,从而进行设备控制;而后者主要是基于安卓系统提供的Accessibility API进行控制,需要安装特定的APK并开启相应的权限方能控制安卓设备。
无论哪种实现,我们都需要具备以下几种能力:
每一种能力都服务于特定的目的,在具体落地时采用的方案也可多样。今天这篇文章将根据工作日常中接触到的内容讲讲如何实现。
屏幕元素获取通常作为第一步,也是相当关键的一步。在具体落地实施上,以MobileAgent为例,通过ADB命令screencap直接截取当前屏幕的内容,这里的内容就是人眼可见的内容。尽管Android的图形绘制系统存在不同的窗口层级,但最终都会经过SurfaceFlinger到HWC进行合成送显,而screencap截取得内容实际上就是SurfaceFlinger中进行合成后的图像内容。无论是应用的Activity、SystemUI组件(状态栏、导航栏等)还是一个单独的dialog,都会进行合成显示。
除了使用ADB命令screencap,我们也可以使用Accessibility API来对安卓系统的屏幕视图信息进行获取,这里与screencap不同的是我们可以获取当前界面Layout下的XML布局信息,当然也可以进行截图,具体可参考如下代码:
//当接收到界面变化的事件时dump AccessibilityNodeInfo
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
Log.i(TAG, "Event: ${event?.eventType}, ${event?.text}")
// 当窗口内容变化时导出View树
if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) {
val rootNode = rootInActiveWindow
if (rootNode != null) {
exportViewTreeToXml(rootNode)
}
}
}
//序列化为Json
private fun exportViewTreeToXml(root: AccessibilityNodeInfo) {
try {
val file = File(filesDir, "view_tree.xml")
val fos = FileOutputStream(file)
val serializer = Xml.newSerializer()
serializer.setOutput(fos, "UTF-8")
serializer.startDocument("UTF-8", true)
serializer.startTag(null, "ViewTree")
dumpNodeToXml(root, serializer)
serializer.endTag(null, "ViewTree")
serializer.endDocument()
fos.close()
Log.i(TAG, "View tree exported to: ${file.absolutePath}")
} catch (e: Exception) {
Log.e(TAG, "Failed to export view tree", e)
}
}
//按照不同字段进行dump
private fun dumpNodeToXml(node: AccessibilityNodeInfo, serializer: org.xmlpull.v1.XmlSerializer) {
serializer.startTag(null, "Node")
serializer.attribute(null, "class", node.className?.toString() ?: "")
serializer.attribute(null, "text", node.text?.toString() ?: "")
serializer.attribute(null, "contentDescription", node.contentDescription?.toString() ?: "")
serializer.attribute(null, "resourceId", node.viewIdResourceName ?: "")
serializer.attribute(null, "clickable", node.isClickable.toString())
serializer.attribute(null, "focusable", node.isFocusable.toString())
serializer.attribute(null, "enabled", node.isEnabled.toString())
// 递归遍历子节点
for (i in 0 until node.childCount) {
val child = node.getChild(i)
if (child != null) {
dumpNodeToXml(child, serializer)
child.recycle()
}
}
serializer.endTag(null, "Node")
}
上面的代码示例使用无障碍服务提供的能力对界面的Layout布局信息进行了dump,大模型可以分析这些数据后来理解安卓系统的界面元素。
除了获取Layout布局信息,我们也可以直接使用无障碍服务来进行截图,这里我们其实使用的是无障碍服务的接口——takeScreenshot,如下所示:
public void takeScreenshot (int displayId,
Executor executor,
AccessibilityService.TakeScreenshotCallback callback)
//截图实现
@RequiresApi(Build.VERSION_CODES.R)
fun takeScreenshotExample() {
// 调用 takeScreenshot
val screenshotCallback = object : AccessibilityService.ScreenshotCallback() {
override fun onSuccess(result: ScreenshotResult) {
val bitmap: Bitmap? = result.screenshot
bitmap?.let { saveBitmap(it) }
Log.d(TAG, "Screenshot success!")
}
override fun onFailure(errorCode: Int) {
Log.e(TAG, "Screenshot failed, errorCode=$errorCode")
}
}
// 延迟截图或者触发截图逻辑
takeScreenshot(screenshotCallback, mainExecutor)
}
这里有两个注意点:
1.takeScreenshot只支持Android12及以上版本;
2.使用无障碍服务需要声明AccessibilityService_canTakeScreenshot
模拟触控输入其实就是模拟人类使用时的点击、滑动、拖动等,也包括软按键触摸。以MobileAgent的实现为例,模拟事件输入都采用ADB的方式完成。
Click:点击是指人类手指操作屏幕时短时间从按下到抬起的这么一个过程,在系统内核层这类事件以input event事件的形式进行上报,我们通过getevent -l命令进行查看,如下所示:
EV_ABS ABS_MT_POSITION_X 00000320 # X = 800
EV_ABS ABS_MT_POSITION_Y 000005dc # Y = 1500
EV_KEY BTN_TOUCH DOWN
EV_SYN SYN_REPORT 00000000
...
EV_KEY BTN_TOUCH UP
EV_SYN SYN_REPORT 00000000
其中每一行的内容都依照type、code、value的形式进行显示。ABS_MT_POSITION_X与ABS_MT_POSITION_Y 代表触点的X,Y坐标,value即对应的取值。在通过ADB进行模拟时,我们同样需要这两个参数来确定我们具体点击的位置。这里我们通过input tap命令进行模拟:
#模拟点击:点击x坐标145,y坐标为569的位置
input tap 145 569
除了这类简单的点击,在实际操作过程中还存在滑动与拖拽这两种比较复杂的操作,通常发生在处理进度条、悬浮窗这样的界面中。这样类似的操作也可以通过ADB名咯完成,如下所示:
#模拟滑动:从x坐标325,y坐标145的位置到x坐标325,y坐标为298的位置
input swipe 325 145 325 298
#模拟拖拽:从x坐标345,y坐标124的位置到x坐标348到y坐标459的位置
input draganddrop 345 124 348 459
除了ADB,我们也可以通过无障碍服务来实现这样的操作,此时我们需要借助无障碍服务提供的接口——dispatchGesture:
public final boolean dispatchGesture (GestureDescription gesture,
AccessibilityService.GestureResultCallback callback,
Handler handler)
//click点击
fun click(x: Float, y: Float) {
val path = Path().apply {
moveTo(x, y)
}
val gestureBuilder = GestureDescription.Builder()
gestureBuilder.addStroke(
GestureDescription.StrokeDescription(
path,
0, // 开始时间(ms)
100 // 持续时间(ms),越短越接近点击
)
)
val gesture = gestureBuilder.build()
dispatchGesture(gesture, null, null)
}
//swipe滑动
fun swipe(x1: Float, y1: Float, x2: Float, y2: Float) {
val path = Path().apply {
moveTo(x1, y1)
lineTo(x2, y2)
}
val gesture = GestureDescription.Builder()
.addStroke(GestureDescription.StrokeDescription(path, 0, 500))
.build()
dispatchGesture(gesture, null, null)
}
在使用无障碍服务来执行模拟点击时,当前屏幕上正在被处理的点击行为都会被取消,不管该行为是来自于用户还是其他接入无障碍服务的服务。
需要注意的是,使用无障碍服务来模拟触控行为需要确保我们的服务接入无障碍服务且声明了AccessibilityService_canPerformGestures。
在我们与安卓设备交互的过程中,文本输入是必不可少的环节。要实现GUI Agent也必然要实现对文本内容的输入。在具体的实现上,我们可以通过ADB也可以通过无障碍服务实现。
通过ADB来实现,安装系统本身支持input text命令,如果我们输入英文内容可以直接使用input text命令,但这种方式并不支持中文字符(Unicode编码),此时我们需要借助一个第三方的输入法:ADBKeyBoard,其原理是让使用者通过发送广播的方式来进行文本输入与编辑,使用方式如下所示:
adb shell am broadcast -a ADB_INPUT_TEXT --es msg '你好'
在使用之前我们需要安装ADBKeyBoard并切换输入法,具体操作步骤如下:
adb install ADBKeyboard.apk
adb shell ime enable com.android.adbkeyboard/.AdbIME
adb shell ime set com.android.adbkeyboard/.AdbIME
这种方式需要额外安装应用,如果我们更进一步,可以参考ADBKeyBoard的源码,在Agent服务内实现一套类似的,其原理也并不复杂,类似于实现一个简单的输入法。
如果我们不通过ADB方式来实现,亦可以通过无障碍服务来实现,此时也有两种办法:
1.使用AccessibilityNodeInfo.ACTION_SET_TEXT,可直接修改可编辑控件的文本,如下所示:
fun inputText(node: AccessibilityNodeInfo?, text: String) {
if (node == null) return
if (node.isEditable) {
val args = Bundle()
args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text)
node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
} else {
// 遍历子节点
for (i in 0 until node.childCount) {
inputText(node.getChild(i), text)
}
}
}
2.使用ACTION_FOCUS + ACTION_PASTE,将剪切板的内容直接进行粘贴
fun pasteText(node: AccessibilityNodeInfo?, text: String) {
if (node == null) return
if (node.isEditable) {
// 先聚焦
node.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
// 将文字放入剪贴板
val clipboard = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
val clip = android.content.ClipData.newPlainText("text", text)
clipboard.setPrimaryClip(clip)
// 粘贴
node.performAction(AccessibilityNodeInfo.ACTION_PASTE)
} else {
for (i in 0 until node.childCount) {
pasteText(node.getChild(i), text)
}
}
}
在实践过程中,我发现仅仅是实现文本输入可能还达不到目的,应为有些操作还是依赖输入法的,比如回车按键、搜索按键等,此时我们可以先执行点击输入框或者使用ACTION_FOCUS来调出键盘,在文本输入完成后再点击键盘完成完整操作。
这里也许有人会疑惑,为什么不按照人类的输入习惯直接操作键盘以拼音输入法的方式进行文本输入呢,事实上这种方式当然可以,不过其效率较低,实践中并不推荐。
以上就是在Android系统中实现GUI Agent的要点解析,希望能对你有所帮助。