背景

业务需要采集在app上执行任务的整个过程,原始方案相对复杂,修改需要协调多方人员,因而考虑是否有更轻量级的方案。

原始需求:

  • 记录完成任务的每一步操作(点击、滑动、输入等)
  • 记录操作前后的截图和布局xml

基于Adb的方案

最容易考虑到的方案是就是通过adb去实现,要获取到当前页面的xml、当前页面截图,所以只需要将每一步操作通过adb发送给手机端即可。

步骤

  1. 通过adb连接设备,编写一个agent程序接收网页操作请求,并通过adb发送指令执行
  2. adb获取当前页面xml(uiautomator dump)
  3. adb获取当前页面截图(screencap),agent通过ws发送到网页端
  4. 网页显示图片,监控鼠标点击事件,计算出点击位置
  5. 将相关操作通过adb发送到设备,模拟操作
  6. 循环步骤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.Buffer
cmd.Stdout = &out
err := 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 division const 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{}) // 完全一致时,设置为全0
continue
} 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 pixel
resultData[i] = diffData[i] !== 0 ? diffData[i] : refData[i]; // R
resultData[i + 1] = diffData[i + 1] !== 0 ? diffData[i + 1] : refData[i + 1]; // G
resultData[i + 2] = diffData[i + 2] !== 0 ? diffData[i + 2] : refData[i + 2]; // B
resultData[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方案,在这个热闹的周末,正好闲暇的时间,了解过去不曾接触的知识,也是一个有趣的过程。

Android网页投屏控制从入门到放弃的更多相关文章

  1. Android 电脑投屏工具Vysor Pro介绍

    Chrome的插件,直接到chrome的扩展程序里面搜索Vysor,安装即可 如何破解: C:\Users\lanlan.shi\AppData\Local\Google\Chrome\User Da ...

  2. 各手机PC品牌投屏功能连接方法

    一.iOS终端(iPhone/iPad)无线投屏: 1.将iPhone或iPad与必捷会议盒子连接至同一路由器: 2.滑动iPhone/iPad的屏幕,调出Airplay功能,选择需要投屏的主机,开始 ...

  3. 海豚星空扫码投屏 Android 接收端 SDK 集成 六步骤

    一 跟目录的build.gradle添加私有mevan仓库 maven {url 'http://nexus.dolphinstar.cn/repo/openmavenx'} 二 app/build. ...

  4. Ubuntu安装scrcpy手机投屏和控制(Ubuntu用QQ微信的另一种方法)

    Scrcpy 安装 snap install scrcpy adb服务安装 sudo apt-get install android-tools-adb adb配置 查看手机的USB识别号 手机通过U ...

  5. 同时支持Android 和 ios 投屏到电脑的软件,Support Android and ios screen shrare to PC - 希沃授课助手

    最近学校由粉笔黑板更换了智慧电子黑板,然后发现了一个好玩的软件. 感谢希沃公司的开发: 希沃授课助手,这是一款同时支持Android 和 ios 投屏和远程控制的. 效果很流畅,非常赞

  6. scrcpy投屏android手机到电脑

    在mac os下 投票iPhone投屏是最简单不过了,只需要用Quicktime player就可以. 但是在mac下咱投屏android的手机呢,就需要用到scrcpy了. 1.打开终端,输入命令: ...

  7. 看完小白也会使用,Android投屏神器scrcpy详细教程

    楔子 做为一个软件测试工程师,在使用手机测试的时候,缺陷附件想附上截图.视频,需要从手机把图片.视频发送到拷贝或发送到电脑,非常麻烦. 所以想到使用投屏软件,把手机的屏幕投屏到电脑,便可以直接在电脑上 ...

  8. ios11苹果手机怎么投屏到电脑

    使用过苹果手机的用户都知道,苹果手机触摸屏操作极为流畅,网页浏览也非常轻松,各种网络上的应用可以说是非常完美.iPhone的娱乐功能相当的强大,能让苹果iPhone超越了其他手机很大的距离.但是手机怎 ...

  9. QML - 实现Gstreamer投屏 投屏画面遮挡

    1.     背景介绍 中控端运行的操作系统是Android,中控软件主要功能有导航.收音机.媒体(音乐).蓝牙(连接).手机互联.行车辅助和系统设置等. 仪表端运行的操作系统是Linux,仪表软件主 ...

  10. 分享一个开源的windows安卓投屏工具,scrcpy

    看到scrcpy可能很多人会以为是大名鼎鼎的Scrcpy(一个十分强大的多线路爬虫框架),sorry今天分享的主角不是他,而是他: github地址:https://github.com/Genymo ...

随机推荐

  1. 结构型模式(Structural Pattern)

    模式介绍 结构型模式(Structural Pattern)的主要目的就是将不同的类和对象组合在一起,形成更大或者更复杂的结构体.该模式并不是简单地将这些类或对象摆放在一起,而是要提供它们之间的关联方 ...

  2. python 动态导入模块并结合反射,动态获取类、方法(反射太好用),动态执行方法

    背景: 关键字驱动框架,不同的关键字方法分别定义在不同的类,真正执行关键字方法又在不同的类(简称A),这样就需要在执行前,要在文件A下import要使用的模块,如果有很多页面操作或很多模块时,就需要每 ...

  3. 在C#中进行单元测试

    单元测试 前言 时隔多个月,终于抽空学习了点新知识,那么这次来记录一下C#怎么进行单元测试,单元测试是做什么的. 我相信大部分刚毕业的都很疑惑单元测试是干什么的?在小厂实习了6个月后,我发现每天除了写 ...

  4. Linux 内核:I2C子系统分析(1)基于子系统的驱动分析与实现

    Linux 内核:I2C子系统分析(1)基于子系统的驱动分析与实现 背景 在学习高通平台的有关知识,看到一篇博客中介绍了GPIO模拟I2C设备,觉得挺有意思的. 看了看有关的实现,发现自己之前学习从L ...

  5. 千万别忽视基础!十张图带你一步步理解Java内存结构!

    作为一个Java程序员,在日常的开发中,不必像C/C++程序员那样,为每一个内存的分配而操心,JVM会替我们进行自动的内存分配和回收,方便我们开发.但是一旦发生内存泄漏或者内存溢出,如果对Java内存 ...

  6. 价破天荒!99元国产ARM工业“评估板”再袭,14天限量抢购!

    上线即爆款!2000家企业选择! 凭借"79元超高性价比"."双核A7@1.2GHz"."国产化率100%"."ARM + DSP ...

  7. sqlite相关

    前言 本文记录一些sqlite相关笔记,随时更新. 正文 时间函数 datetime() -- 当前时间 2022-03-24 17:32:43 select datetime('now'); --2 ...

  8. Redis挂了,怎么补救?谈谈如何实现redis的高可用

    Redis挂了,怎么补救?谈谈如何实现redis的高可用! Redis实现高可用主要有三种部署模式:主从模式.哨兵模式和集群模式. 分区 分区(Partitioning)是一种最为简单的拓展方式. 在 ...

  9. C++中的引用(Reference)

    1. 引用(Reference) 在 C++ 中,引用(Reference)是一个变量的别名. 它允许你通过不同的名字访问同一个变量. 与指针不同,引用在定义时必须被初始化,并且一旦绑定到某个变量,之 ...

  10. 效率工具RunFlow完全手册之进阶篇

    欢迎来到RunFlow手册的进阶篇,如果您还不了解RunFlow,建议先阅读我们的基础篇. (Solo 社区投稿) 搜索文件 按文件大小过滤,添加 len 参数,比如:len:1kb-2kb,len: ...