移动测试 | 解析 Totoro 无侵入、全场景截图及图像技术体系

为提高业务研发的开发效率,解决业务基础工具技术痛点,让研发主要精力回归业务核心问题解决上,我们在不同的维度做了大量体系化的技术构建,为不同的自动化场景提供稳定、易用的自动化底盘。

本文主要分享 Totoro 框架在移动端截图及图像处理及在全链路体系上的技术方案和业务支持能力。

基础截图技术方案

一台手机上获取屏幕截图简单,但是想要兼容集群环境云真机平台上的大量移动设备,突破系统各种安全限制、安全页面(密码、转账等页面)反截图安全限制、及 ROM 差异性等不同维度的兼容性问题,且是非侵入方式,则会有一点技术小挑战。

这里从基础截图方案和突破系统限制达到截图能力全场景覆盖两方面分享 Totoro 的一些实践。

1. 一般常见截图方案选型及改造

在移动端自动化(测试)场景, 最长见的截图方法就是使用adb shell screencapuiautomator截图接口。然而 adb shell screencap 方式使用很不方便,需要将截图先保存到手机磁盘上,然后再pull到 PC 设备上。
Totoro 框架中采用了一次性的获取方式如下:

adb -s sn exec-out screencap -p >  name

但是即使采用adb exec-out方式也存在图片无法压缩导致的截图耗时较长的问题,并且 Java 项目中每次都要 Process 创建进程,遇到频繁截图情况,耗费系统资源,影响稳定性。所以在 Totoro 框架中提供的 SDK 默认 API 底层采用的的是 Uiautomator 提供的接口方法。

UI Automator 测试框架提供了一组 API,用于构建在用户应用和系统应用上执行交互的界面测试。通过 UI Automator API,您可以执行在测试设备中打开“设置”菜单或应用启动器等操作。

同样发现,UIAutomator 提供默认截图方法 生成图片文件也是非常大,压缩算法并没生效。导致截图链路上整体耗时会造成每次截图在平均 2-5s 左右,影响到了自动化整体时间。 考虑到单独引进一套压缩算法较重,且可能后续无资源维护,所以决定研究安卓源码,试图从根本解决压缩无效的问题。

    /**
     * Write a compressed version of the bitmap to the specified outputstream.
     * If this returns true, the bitmap can be reconstructed by passing a
     * corresponding inputstream to BitmapFactory.decodeStream(). Note: not
     * all Formats support all bitmap configs directly, so it is possible that
     * the returned bitmap from BitmapFactory could be in a different bitdepth,
     * and/or may have lost per-pixel alpha (e.g. JPEG only supports opaque
     * pixels).
     *
     * @param format   The format of the compressed image
     * @param quality  Hint to the compressor, 0-100. 0 meaning compress for
     *                 small size, 100 meaning compress for max quality. Some
     *                 formats, like PNG which is lossless, will ignore the
     *                 quality setting
     * @param stream   The outputstream to write the compressed data.
     * @return true if successfully compressed to the specified stream.
     */
    public boolean compress(CompressFormat format, int quality, OutputStream stream) {
      ...
    }

public boolean takeScreenshot(File storePath, int quality) {
        Bitmap screenshot = mUiAutomation.takeScreenshot();
        ...
        BufferedOutputStream bos = null;
        try {
            bos = new BufferedOutputStream(new FileOutputStream(storePath));
                screenshot.compress(Bitmap.CompressFormat.PNG, quality, bos);
        }
  ...
        return true;
    }

根据以上安卓源码,跟踪到系统压缩方法。看注释,似乎发现了问题所在,看 takeScreenshot 方法, 调用了 Bitmap 的 Compress 压缩方法,传的参数写死了 Bitmap.CompressFormat.PNG,格式,根据注释说明,Compress 方法又对 png 格式图片的压缩忽略的,所以导致获取到的图片都很大。马上重写系统方法,直接调用压缩方法,传入 JPEG 去验证可行性,马上得出了解决方案。

找到问题所在,解决方案也就出来了,主要改造逻辑可以通过三个核心步骤完成:

  1. 自行获取 Bitmap 文件。
  2. 调用 Bitmap 压缩方法,指定 jpeg 格式,并传入自定义压缩值。
  3. 获取压缩流数据,直接 Base64 编码传递给 SDK 层。

通过以上优化,减少了两次文件读写,并且有效压缩了图片大小,整体一次截图时间控制在了 200-800ms,对比原来速度最少提升 2-3 倍以上。

2.突破系统安全限制做到截图全覆盖

Totoro 的最大业务需求 来自 钱包系 相关 App 的自动化业务,而支付宝属于安全隐私要求较高
App,各个 ROM 针对钱包或者钱包自身都会做大量安全保护,例如在密码输入页面或转账页面,会导致截图获取失败,并且我们发现有些限制级别较高页面,连系统自身的截图功能也无法使用。

但是在测试场景下,数据及安全级别是可控的,多个业务线强烈提出 能否通过技术手段 获取到这些被限制的页面截图,来满足业务验证逻辑的正确性,去除人工页面截图审核死角,来进一步提高研发效能质量。

由于 Totoro 框架是对 App 是非侵入式的,也给我们的技术实现添加来不小挑战。但是,本着业务价值首位考虑,为了保障开发者使用效率,我们开始调研安卓各种截图方案,跟踪安卓截图源码,试图采用Hook方式绕过安全校验机制。

针对安卓平台各种全局截图方法的深入研究,最后发现,各种入口类的系统接口调用到底层都会调用SurfaceControl类或对应的 c 层代码, SurfaceControl 类成了关键研究对象。然而,从 Java 层的 SurfaceControl 类中各种 Hook 接口调用尝试发现,只要被安全页面限制,该方案下的截图方案就会失败或截到一张黑屏。这意味着系统针对截图的安全限制可以覆盖到目前我们了解到的任何场景,让我们的解决方案一时陷入困境,失去了思路方向。

然而在做 SLM 云测平台设备远程租用过程中,发现录屏功能在这些截图限制页面依然是可以正常使用,这说明系统或安全限制只是针对截图相关 API,这给我们的截图提供了新的思路,通过录屏来获取屏幕截屏。所以开始研究系统的录屏功能,能否在录屏过程中拉一帧的画面,作为截图新的实现方式。

A.自研技术方案

实现原理很简单,通过直接解码 Surface 获取屏幕视频流,然后从视频中获取一张可用帧,转换为 JPEG 格式,最后回传给 PC 上的接口。该方案从 Java 层实现,兼容性强,成本底,下面是关键代码逻辑:

    MediaCodec mcodec = MediaCodec.createEncoderByType("video/avc");//创建解码器
    Surface surface = codec.createInputSurface();//构造目标Surface
    SurfaceControl.setDisplaySurface(display, surface);//通过反射SurfaceControl,将构造的Surface设置到系统
     int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, -1);//开始解码
    //判断有效帧
    ...
    Bitmap  bitmap = mediaMetadataRetriever.getFrameAtTime(index);//获取屏幕bitmap值
    bitmap.compress(Bitmap.CompressFormat.JPEG, quailty, fos);//保存截图
    ...
    //截图回传到PC接口层

然后就是打包程序,利用系统 app_process 服务,可以避开各种权限弹窗问题,直接运行程序,即可获取到屏幕截图。
目前该方式已全面应用到 Totoro 底层截图方案,可以兼容安卓 5.0-10 版本,但是仍然有改进地方,后续还可从以下几点进一步优化:

  • 视频流有效帧判断逻辑优化,减少截图时间。
  • 目前仍然需要一次手机端的文件保持读写逻辑,后续可考虑读流方式,直接获取图片 Base64 值。
  • 可执行文件参数标准化,可满足多场景技术输出。
B.备用兜底方案

以上 A 方法主要针对 5.0+ 的安卓设备,且针对个别 OPPO、VIVO 设备开启了系统恶意录屏功能,就会造成录屏功能限制,引起截图失败。针对这部分的设备,我们设想 Android 能否像 Linux 系统一样,绕过 Java 层权限限制,直接从C层读取操作系统 framebuffer(Linux 设备/dev/graphics/fb0),来获取屏幕图像。

期间调研了开源的 minicap 录屏方案,发现 Surface 方案在个别设备不能兼容情况下, minicap 依然可以使用,其底层原理就是采用 NDK 开发的工具,直接读取操作系统 framebuffer,为了不重复造*,决定把 minicap 工具中的手机端的服务单独剥离出来集成到 Totoro,作为备用兜底方法提供截图能力。

String cmd = String.format("adb -s %s shell LD_LIBRARY_PATH=/data/local/tmp /data/local/tmp/minicap -P %sx%s@%sx%s/0 -Q 90 -s -t > %s", deviceId, w, h, w, h, localPathFile);

按照上面命令单独调用 minicap 工具截图,并从流中解析到图片保持到制定目录,经大量测试,该命令并不一定稳定,有时会报格式错误,有时会报流处理错误,所以我们添加了降级方案,如以下命令。

String cmd = String.format("adb -s %s shell \"LD_LIBRARY_PATH=/data/local/tmp /data/local/tmp/minicap -P %sx%s@%sx%s/0 -Q 90 -s -t > /sdcard/.totoro/%s\"", deviceId, w, h, w, h, name);

//然后将图片Pull到PC上制定目录

两种技术方案相互降级搭配使用,这样正好弥补了 Java 层 Surface 方案的一些可能失败场景,作为兜底方法集成到了 Totoro 框架中,目前为止暂未发现两种方法都失败场景,保障了截图能力的底盘稳定性。

业务多场景支持

1.基础截图场景

通过以上相关多层技术方案兼容,Totoro 目前可以提供稳定的基础截图能力,业务层无需关注底层的具体技术实现方案或兜底方案,只需要调用一个 Totoro 实验 SDK 暴露的 API 即可。

    String path = BizUtils.saveScreen(name);

2.长截屏技术方案

长截图能力一直是业务方提出的痛点需求,一直到 19 年末我们才抽出时间弥补了 Totoro 这块能力缺失。
起先,先调研了行业内其他实现方案,基本思路都是滑动截图,然后合并多张截图,最后生成长截图。然而,难点在多张截图的完美合并,针对移动端的页面截图,存在页头、页尾、小 banner 更新、小红点等多重影响,给长截图的实现增加了不少难度。

可行方案分析:

  1. 直接利用第三方拼接工具。需要添加额外依赖,其在移动端个别极限场景拼接存在问题,维护成本高。
  2. 利用算法部门资源,特征值直接匹配。需要添加算法库,且要推动算法部门不断迭代更新,后续更新可能不及时。
  3. 自己实现一套,难度大,初期成本高。需要兼容移动端页面各种情况,但是后续维护成本低,升级方便且及时。

权衡利弊后,最终我们采用了方案 3,决定自己实现了一套针对移动端截图特征的拼接能力,采用了自研像素行灰度值末尾优先对比技术方案,核心逻辑思路是默认向上滑动生成的图片组,两两从底部优先开始对比行像素的灰度值,找到拼接点和裁剪点。为了减少噪点及个别红点、红线、小 banner、及底部导航栏影响,在不同的对比点及区域添加了对应的参数。其核心代码思路如下:

    public static File mergeImg(File imgA, File imgB, String mergedImgPathName) {
            int[][] listA = getPX(imgA);//获取像素数组
            int samePartEnd = 0, sameBottom = srcALen;//定义查找目标位置
            int samePart = mergeLen >> 2;//定义相似颗粒
            for (int al = Math.max(0, listA.length - mergeLen + (samePart >> 1)), l = listA.length - 1; l >= al; l--) {
                for (int x = mergeLen - (listA.length - l), y = Math.max(0, (samePart >> 1)); x >= y; x--) {
                    if (compareRowPx(listA[l], listB[x])) {// 对比行灰度相似度
                        //找到基础对比点
                        for (int i = 0, partLen = (l == listA.length - 1) ? samePart : samePart >> 1; i < partLen; i++) {
                            //寻找拼接点
                            if (compareRowPx(listA[curA], listB[curB])) {
                                if (i == partLen - 1) {
                                    //相同区域达到阀值,找到拼接点
                                    samePartEnd = x;
                                    sameBottom = l - 1;
                                    break;
                            } else {
                                //寻找底部相同区域,如底部导航Tab栏
                                break;
                            }
                        }
                        if (matchedBottom || samePartEnd > 0) {
                            //拼接参数找到,提取结束循环
                            break;
                        }
                    }
                }
            }

            String fileSuffixA = getFileRealExt(imgA);//读取二进制流,获取原始图文件真实后缀
            String fileSuffixB = getFileRealExt(imgB);
            String fileSuffixC = mergedImgPathName.substring(1 + mergedImgPathName.lastIndexOf("."));
            if (samePartEnd == mergeLen - 1) {
                //图片相同,直接返回第一张图片
                imgA.renameTo(file);
                return file;
            }
            //开始合并逻辑
            int newY = srcALen - (srcALen - sameBottom) + (mergeLen - samePartEnd);
            ...//读取原始图片值
            if (sameBottom > 0) {
                  //    去除底部导航tab栏
            } else {
                //没有底部操作tab栏
                newA = reader.read(0);
            }
            ...//重新拼接图片
            imgNew.setRGB(0, newA.getHeight(), width, newB.getHeight(), imgArrayB, 0, width);
            ImageIO.write(imgNew, fileSuffixC, file);
              ..//其他资源回收
            return file;
    }

效果图如下:

移动测试 | 解析 Totoro 无侵入、全场景截图及图像技术体系

该方案目前已全量上线,并且满足了目前业务方需求,在遇到长截图拼接有重叠或缺少问题时,也可以通过调节对比参数,快速优化合并逻辑代码,达到了既定研发目标。

3.局部控件截图

在自动化过程中,有些业务需要获取某个控件的截图, iOS 端可以通过 WDA 相关接口实现,但是安卓端是没有现成方案的。因此,Totoro 采用了按照坐标截图切割方式,结合控件坐标信息,可以在 PC 端实现一套局部控件获取方法。该 API 可以同时适用到
iOS 和安卓双平台,局部控件截图核心实现思路:

    src = BizUtils.saveScreen(name);
    WebElement element = driver.findElement(By);
    ...//根据element获取到element的坐标信息
    ImageCheckUtils.imageCut( src,  dest,  x,  y,  width,  height);//根据坐标信息切图,获取到控件截图dest文件

4.多屏幕截屏

目前 Totoro 中只集成了 adb 原生方式,用来为一些支付 IoT 设备,提供多屏幕截图能力。

#附屏幕截屏
adb shell screencap -d /dev/graphics/fb1 fb1.png
#主屏幕截屏
adb shell screencap -d /dev/graphics/fb0 fb0.png

后续根据需求反馈,会调研采用 UIAutomater 或 Surface 方式实现。

图像智能算法分析

Totoro 借助兄弟团队(工程数据技术组)的算法能力,针对截图实现了页面智能分析、检测等能力,完成了图片相关技术体系小闭环。智能算法能力集成可以归类为以下三种。

1.控件分析

移动测试 | 解析 Totoro 无侵入、全场景截图及图像技术体系

如上截图所示,可以通过以下 API 获取页面控件信息,可识别控件类型如 button、image、label、progress、switch、more、editView、popUp、checkBox、return、close 等。

2.页面异常检测

异常检测能力可抽象分为通用异常和业务异常,具体说明如下:

  • 通用异常分析,如:黑白屏、加载失败、图片或文字截断,控件排列重叠等常见页面 UI 问题;
  • 业务异常分析,如:业务弹窗、授权弹窗阻断等,且更加不同的业务异常场景需求,可定制化服务。

Totoro 中可通过一个接口获取页面异常信息,业务中通过自动化的检测,完成了之前很多无法实现的自动化场景。

3.业务场景识别

为实现更加智能的页面分析,为后续自动化测试场景中自遍历或自填充提供基础能力支撑,依赖算法能力可以智能分析当前页面场景,例如可以通过算法相关接口来自动判断当前页面属于哪个业务流程,比如登录流程场景、输入场景等。

目前内部只开放了登录场景的识别,已上线并应用到小程序自动化注入及巡检场景,可以代替小程序登录繁杂脚本工作量,大大减少用例脚本维护成本。

4.图片相似度分析

移动测试 | 解析 Totoro 无侵入、全场景截图及图像技术体系

如上两张截图,由于存在大面积 Banner 变化和背景渐变颜色差异,如果单纯通过像素对比,很难分析出是一个页面上的截图,这里主要借助算法能力,通过提取页面特征值、图像指纹、颜色通道等多维度来精确计算出两个截图的相似度。

移动测试 | 解析 Totoro 无侵入、全场景截图及图像技术体系

如以上截图所示,可以通过 Totoro 封装的 API 得出两张图相似度为 54,业务层代码可通过相似度应用到自己的实际业务场景。(只要相似度 >=50 则可认为两个截图是一个相似业务场景)

后续计划

针对很小的一个图片业务能力,都需要投入量巨大的努力 来保障 Totoro 的自动化底盘易用性和稳定性,业务不断扩张过程中,图片相关能力体系还是有多个细节仍需要不断调优,其中明确需要投入研究和后期开发计划有以下几点:

  • 截图技术方法模块化抽离,进一步优化调用链路,加快整体截图获取速度。
  • 长截图技术方案优化,泛化拼接方向,组件化包装,可单独灵活对外输出技术能力。
  • 多屏截图场景优化,调研更加便捷稳定的多屏截图方案,满足越来越多的 IoT 场景。
  • 进一步推动算法能力深化,通过埋点上报,加大算法数据闭环支持。
上一篇:支付宝端智能化探索与实践 | xMedia:多媒体端智能应用框架


下一篇:我的博客即将入驻“云栖社区”,诚邀技术同仁一同入驻。