背景
业务需要采集在app上执行任务的整个过程,原始方案相对复杂,修改需要协调多方人员,因而考虑是否有更轻量级的方案。
原始需求:
- 记录完成任务的每一步操作(点击、滑动、输入等)
- 记录操作前后的截图和布局xml
基于Adb的方案
最容易考虑到的方案是就是通过adb去实现,要获取到当前页面的xml、当前页面截图,所以只需要将每一步操作通过adb发送给手机端即可。
步骤
- 通过adb连接设备,编写一个agent程序接收网页操作请求,并通过adb发送指令执行
- adb获取当前页面xml(uiautomator dump)
- adb获取当前页面截图(screencap),agent通过ws发送到网页端
- 网页显示图片,监控鼠标点击事件,计算出点击位置
- 将相关操作通过adb发送到设备,模拟操作
- 循环步骤2-5
弄清楚流程,可以直接告诉编程LLM,代码秒成,考虑到golang依赖较少,我们直接让LLM生成golang代码。
下面介绍部分实现,比如golang调用adb,网页端传入deviceid和操作:
func executeCommand(deviceID string, action string, parameters string) error {cmdArgs := []string{"-s", deviceID, "shell", action}if parameters != "" {cmdArgs = append(cmdArgs, parameters)}fmt.Println(cmdArgs)cmd := exec.Command("adb", cmdArgs...)err := cmd.Run()if err != nil {return err}return nil
}
比如截图,调用screencap截取png格式的图片:
func screenshot(deviceID string) ([]byte, error) {cmd := exec.Command("adb", "-s", deviceID, "exec-out", "screencap", "-p")var out bytes.Buffercmd.Stdout = &outerr := cmd.Run()if err != nil {return nil, err}return out.Bytes(), nil
}
JavaScript端显示:
socket.onmessage = (event) => {if (event.data instanceof Blob) { const url = URL.createObjectURL(event.data);imgElement.src = url;}
}
图片上方可以加一个div层,用来监控鼠标事件,模拟操作:
overlayElement.addEventListener('mousedown', (e) => {startX = e.offsetX;startY = e.offsetY;startTime = Date.now();
});overlayElement.addEventListener('mouseup', (e) => {const endX = e.offsetX;const endY = e.offsetY;const elapsedTime = Date.now() - startTime;const duration = Math.max(elapsedTime / 1000, 0.001); // Avoid zero divisionconst imgStartX = (startX / imgDisplayWidth) * imgWidth;const imgStartY = (startY / imgDisplayHeight) * imgHeight;const imgEndX = (endX / imgDisplayWidth) * imgWidth;const imgEndY = (endY / imgDisplayHeight) * imgHeight;if (Math.abs(imgStartX - imgEndX) > 5 || Math.abs(imgStartY - imgEndY) > 5) {sendCommand('input swipe', `${imgStartX} ${imgStartY} ${imgEndX} ${imgEndY}`);}else if (duration > 500) {// 长按sendCommand('input swipe', `${imgStartX} ${imgStartY} ${imgEndX} ${imgEndY} ${duration / 1000}`);}else {sendCommand('input tap', `${imgStartX} ${imgStartY}`);}
});
效果与问题
效果如下:
问题也很多:
- screencap 比较慢,测试模拟器需要600~700ms,显示起来感觉比较卡顿
- 大部分时候,页面没操作,图片基本不变化,重复传输浪费网络
- uiautomator dump 更夸张,2~3s
优化
图像差分传输,截图后检查下是否变化,没有变化就不发送,有变化就发送diff图像,这样JavaScript端合并图像就可以了。
diff 用最简单的策略,相同的改为全透明,不同的保留原图像,计算diff图:
// CalculateDifference 计算两个RGBA图像之间的差异, 并返回新的RGBA图像
// 如果两个图片完全一致,则返回全透明的图像
func CalculateDifference(img1, img2 image.Image) *image.NRGBA {bounds := img1.Bounds()diff := image.NewNRGBA(bounds)for y := bounds.Min.Y; y < bounds.Max.Y; y++ {for x := bounds.Min.X; x < bounds.Max.X; x++ {c1 := img1.At(x, y).(color.NRGBA)c2 := img2.At(x, y).(color.NRGBA)if c1 == c2 {diff.Set(x, y, color.NRGBA{}) // 完全一致时,设置为全0continue} else {diff.Set(x, y, c2)}// 组合RGB和Alpha通道为一个16位灰度值(分开存储Alpha通道可能更实际)}}return diff
}
Javascrit 接收到后可以结合上一张图进行还原,前端可以用canvas去操作diff图像进行合并:
function createImageFromBlob(blob) {return new Promise((resolve, reject) => {const img = new Image();img.onload = () => resolve(img);img.onerror = reject;img.src = URL.createObjectURL(blob);});
}async function restoreImage(diffImageBlob) {const refImage = imgElement;const diffImage = await createImageFromBlob(diffImageBlob);canvas.width = refImage.width;canvas.height = refImage.height;ctx.drawImage(refImage, 0, 0);const refImageData = ctx.getImageData(0, 0, refImage.width, refImage.height);ctx.drawImage(diffImage, 0, 0);const diffImageData = ctx.getImageData(0, 0, diffImage.width, diffImage.height);const resultImageData = ctx.createImageData(refImage.width, refImage.height);const refData = refImageData.data;const diffData = diffImageData.data;const resultData = resultImageData.data;for (let i = 0; i < refData.length; i += 4) {// Assuming diff is non-zero means it contains the correct pixelresultData[i] = diffData[i] !== 0 ? diffData[i] : refData[i]; // RresultData[i + 1] = diffData[i + 1] !== 0 ? diffData[i + 1] : refData[i + 1]; // GresultData[i + 2] = diffData[i + 2] !== 0 ? diffData[i + 2] : refData[i + 2]; // BresultData[i + 3] = diffData[i + 3] !== 0 ? diffData[i + 3] : refData[i + 3]; // A}ctx.putImageData(resultImageData, 0, 0);
}
结论
虽然思路可行,但是因为adb 截图和获取xml比较慢,最终方案用不了,只能换一个思路去解决。
基于uiautomator2的方案
uiautomator2 是一个python库,用python调用设备上uiautomator服务来获取页面信息、控制设备,其原理也比较简单,就是通过adb在设备上启动atxagent和server等程序,然后通过http和ws去连接设备从而实现控制。
uiautomator2可以几十ms的时间获取xml,截图也因为高效的minicap,可以提供更高的fps。
使用uiautomator2就需要将golang转成python,幸运的是直接扔给LLM,先转成python,然后让将使用adb的改成使用uiautomator2,基本上大差不差,稍微缝缝补补搞定。
这里要感慨,LLM对程序员真是好助手,做好方案设计,扔给LLM就能比较好的去实现,心有灵犀。(PS: 胸中有丘壑,LLM才是好助手)
讲一些改动, 执行命令,可以提供一个更通用的方法,方便前端直接调用:
async def execute_command(device_id, action, parameters):try:device = get_device(device_id)command = getattr(device, action)if parameters:command(**parameters)else:command()except Exception as e:print(f"Error executing command on device {device_id}: {e}")
JS 调用
sendCommand('click', {"x": imgStartX, "y": imgStartY});async function sendCommand(action, parameters) { const command = JSON.stringify({ type: 'action', deviceID: deviceID, action: action, parameters: parameters });socket.send(command);console.log('Command:', command);
}
优化
模拟click按键,发送按键事件:
// SendKeyEvent 按下一个键(字符或功能键)
func (d *Driver) SendKeyEvent(keyCode string) error {cmd := exec.Command("adb", d.deviceID, "shell", "input", "keyevent", keyCode)err := cmd.Run()if err != nil {return err}return nil
}
准实时投屏的方案
上面采用的minicap,截图已经很快了,一秒钟传输几张图片,基本上满足这个场景够用了。 还有更准实时的方案吗?
专业的开源投屏控制软件 scrpy
是一个好的选择,scrpy
实现原理其实类似上面的uiautomator2,会在device上启动一个server,通过server获取音视频流,以及控制。
scrpy
技术上相对更成熟,而uiautomator2依赖的minicap则缺乏维护,对安卓新版本支持不够好。
所以,在获取截图方面,也可以考虑调用scrpy
的server来实现准实时控制。但是就如标题所说,从入门到放弃,上面的方案已经可以满足我们需求,没必要在这里投入更多的精力,所以这个方案放弃。
结语
本文主要记录投屏控制相关的实践过程,通过从adb方案开始,到uiautomator2,以及最后放弃scrpy方案,在这个热闹的周末,正好闲暇的时间,了解过去不曾接触的知识,也是一个有趣的过程。