随着 PerfDog 收费,之前无人问津的自研的性能工具突然变成了一件举足轻重的事。但当有用户开始使用后,没有经过考验的工具就暴露出了很多问题,这些问题让我意识到,这个工具不仅仅是只要能获取性能这么简单。在随后的对这个产品不断深耕的过程中,我积累了很多知识,也获得了一些感悟,接下来,我将会把我的经验分享给大家。
在最开始做安卓性能采集时,在网上收集资料,几乎所有的路都通向一个地方——adb。
不过,才刚开始没多久,就遇上一个大坑。在使用adb shell dumpsys SurfaceFlinger --latency
获取 frame time 时,部分手机总是发两次命令才会有一次返回值。但进入 adb shell 进行交互式发送dumpsys SurfaceFlinger --latency
命令却没有这个问题。所以答案显而易见,直接执行adb shell <command>
命令时,输出有缓冲区,当缓冲区满时,才会一并输出。
为了解决这个问题,需要去了解一点点 adb 相关知识。网上相关资料很多,下面我简单说一下。
adb 有三个部分:client、server 和 daemon(adbd),其中 client 和 server 在 pc 端,daemon 在手机端。他们的通信方式为 client<-->server<-->daemon。adb server 默认会监听本地的 5037 端口,等待 client 的命令,而我只要自己实现一个 client 和 server 通信,就能及时的获取 server 的返回。
顺序是需要先发 ddmlib 协议,再发送 shell:,代码中会体现。
client 包格式:4 字节包长 + 指令
注:这个包长并非是字节形式,而是一个大端字节序的十六进制数,所以尽管有 4 字节,但却只相当于 unsigned short。在 python 中,直接使用"{:04x}".format(len(cmd)) 即可。
server 在每次收到命令后会先返回一个 4 字节的 OKAY,然后是返回数据,没有长度,读到空为止即可。
adb client 的 python 简单实现:
client = socket.socket()
client.connect(("127.0.0.1", 5037))
cmd = "host:transport:serial" #此处serial替换成手机的serial,这是ddmlib中的一种协议,用于执行adb -s <serialNumber> shell <command>
client.send("{:04x}{}".format(len(cmd), cmd).encode("utf-8"))
print(client.recv(4)) # server会返回b'OKAY'
cmd = "shell:ls"
client.send("{:04x}{}".format(len(cmd), cmd).encode("utf-8"))
print(client.recv(4)) # server会返回b'OKAY'
content = b""
while True:
chunk = client.read(4096)
if not chunk:
break
content += chunk
print(content)
当然,在 python 中,肯定有第三方库可以直接使用:adbutils
github:https://github.com/openatx/adbutils
初始化
监控设备的接入和拔出,同时实时更新设备列表,是一个性能工具必备的功能。
若要实现这点,则又要和 adb 打交道。这次的协议需要使用host:track-devices
,除了上文出现的host:transport:serial
,还有最后一个是host-serial
,用于映射端口。
在发送完该协议后,每当有设备变动,server 就会把设备列表发送给 client。
虽然这个实现也比较简单,但也不用重复造轮子,第三方库:ConnectionTracer
github:https://github.com/williamfzc/connectiontracer
在连接上设备后,就要加载设备列表了,但光靠pm list package
获取的数据仅仅只有包名。
为了获取应用的应用名和图标,我通过dumpsys package 包名
获取该 app 的 apk 所在位置,再将 apk pull 到本地,最后使用 aapt 解析出应用应用名和图标。
这样的方法毫无疑问是不可取的,若手机上游戏较多,受限于 usb 传输速率,每次初始化都会耗费很久的时间在 adb pull 上。然后就采用了使用文件服务器存储包名所对应的应用名和图标的方案,此外,使用pm list package -3
获取第三方包,减少了获取包的数量,这样可以先查询包名是否已有记录过的数据,若不存在则再 pull 到本地解析,解析结束后将该包信息上传到文件服务器,这样下次就不用再解析。
但这样还存在以下几个问题:
1、对于存在未收集过的包的手机还是要初始化很久;
2、包的图标固定不变,若游戏更新过图标,会和记录的不一致;
3、有一些包的图标并非是常规 jpg 或 png 格式,而是 svg 格式;
最终解决方案在下文 app_process 部分会给出。
性能收集
通过调用 adb shell,就已经可以获取大部分的性能数据了,各个数据的获取方法参考阿里的 mobileperf,github:https://github.com/alibaba/mobileperf
若要做到像 PerfDog 一样,我还遇到了以下问题:
首先若要同时获取这么多性能指标,线性执行肯定不行,而且是 io 操作居多,所以我采用了多线程的方法。
控制线程停止的方式不是粗暴的杀掉线程,而是使用 python threading 的 Event 来中断线程中的循环:while not self._stop_event.is_set():
。
采集到的数据则都放到同一个 queue 中,可以通过读取这个 queue 来获取各个线程采集到的数据。
在最初的版本中,各个取数据的线程中,记录执行方法的时间cost_time
,在获取到数据后,执行time.sleep(1-cost_time)
,这样,每个线程不会因为获取或者计算速度的偏差而导致时间间隔相差太大。
但这样只能保证时间间隔是一致的,不能保证不同线程的采集时间是完全一致。对于这点,主要是减少前端/客户端绘制图表的难度和保证图表数据的统一性。
我的方法是新加一个线程,用于控制间隔、生成统一的时间戳。新增加一个 Event,由这个线程控制,其他线程需要等待这个 Event 就绪后才可进行收集数据,最后获取这个线程提供的就绪时间作为数据的时间戳。
在开始获取数据前,查找对应包的 pid,命令:/data/local/tmp/busybox ps | grep \"{packageName}\" | grep -v grep | /data/local/tmp/busybox awk '{{print $1}}'
,若不使用 busybox,adb shell 中的 ps 并不能达到此效果。若在此时没有获得返回值,则直接结束收集,并告知前端应用未启动。
在后续使用adb shell ps pid
过程中,如果没有获取到返回值,则判定应用已退出,结束收集,并告知前端应用已退出。
使用 minicap 进行截图,能够有效降低截图的性能消耗,但需要在初始化时将相关组件推到手机内。具体使用方法参考 github:https://github.com/openstf/minicap
值得注意的是,当手机横屏时截出的图只有一半,需要改变 minicap 截图参数来设定是否旋转。
minicap 截图命令:adb shell LD_LIBRARY_PATH=/data/local/tmp /data/local/tmp/minicap -P 1080x1920@1080x1920/0
-P 后面的参数格式:{RealWidth}x{RealHeight}@{VirtualWidth}x{VirtualHeight}/{Rotation}
rotation 获取命令:adb shell dumpsys input | grep 'SurfaceOrientation' | awk 'NR==1{ print $2 }'
,0 是未横屏,1 是横屏,只要判断 rotion 是 1 则 Rotation 为 90 即可。
PerfDog 的官方文档中,明确了安卓内存取的是 PSS。查找了大量资料,使用 adb 能稳定获取的渠道只有dumpsys meminfo package
。但经过反复测试,发现这个方法有个致命缺陷就是会消耗大量 cpu 资源,导致卡顿。
会导致卡顿这一点,对于任何性能测试工具来说都是致命缺陷,但如果不取 PSS 又不行,那只能跳脱出 adb,寻找新的方向。
突破
转机是我发现了这篇文章:https://testerhome.com/topics/27321 ,文章中对 app_process 已经介绍的很详细了,我就不再赘述。
但问题来了,他并没有放出代码,我该怎么搞?在网上一通寻找,发现了一个项目:https://github.com/anysou/APP_process ,在一番折腾后,也是成功打出了第一个 apk,取出了其中的 dex 文件。至此,性能工具也是进入了下一个阶段。
使用 adb shell 需要通过 adb server 和手机交互,若能直接和手机交互是不是就更快?
在上述的项目中已经实现了这个功能,使用 app_process 启动项目后,只要使用 socket 连接手机的 8888(项目代码中预设端口),然后发送命令,以换行符为结尾即可获得执行结果。
其原理是调用Runtime.getRuntime().exec()
可惜的是,执行dumpsys meminfo package
依然会卡顿。
对于使用 app_process 启动的进程,我希望是一个后台进程,而不需要进行安装操作,在前台也没有显示,但这样的进程不是 Activity,所以也没有 Context。但做很多事情都需要 Context。那么该怎么样才能空手套白狼呢?
由于我对于 android 不甚了解,所以对网上各种方法也是一知半解。但最后我还是研究出了一种有效的办法。
import android.app.ActivityThread; // 如果你写到这里发现标红了,请继续看下文
import android.context.Context;
...
Context sysContext = ActivityThread.systemMain().getSystemContext();
android.app.ActivityThread 这个类正常情况是导不进的,因为 ActivityThread 类被@hide了。
破解方法很简单,参考这篇文章:https://hardiannicko.medium.com/create-your-own-android-hidden-apis-fa3cca02d345
感觉太复杂?那直接用现成的:https://drive.google.com/drive/folders/17oMwQ0xBcSGn159mgbqxcXXEcneUmnph
使用方法就是替换打包 sdk 的 android.jar,教程 github:https://github.com/anggrayudi/android-hidden-api
PackageManager 类似 adb 中的 pm,负责管理包,使用这个类,能够获取应用的各种信息。
PackageManager pm = sysContext.getPackageManager();
for (ApplicationInfo appInfo : pm.getInstalledApplication(0)) {
System.out.println(appInfo.loadLabel(pm).toString()); // 应用名
System.out.println(appInfo.packageName); // 包名
Drawable icon = appInfo.loadIcon(pm); // 图标
if(icon == null){
icon = appInfo.loadLogo(pm); // 上面那个获取不到还可以试试这个
}
}
这样就避免了上述使用 adb 时不能获取应用名和图标的尴尬。
用于获取进程内存数据,用它获取 PSS 不会导致卡顿。直接上代码:
Debug.MemoryInfo[] memoryInfo = ((ActivityManager) sysContext.getSystemService(Context.ACTIVITY_SERIVCE)).getProcessMemoryInfo(new int[]{pid});
if(memoryInfo.length > 0){
System.out.println((float) memoryInfo[0].getTotalPss() / 1024);
}
除此以外,还有 NetworkManager 和 BatteryManager 等,就不一一介绍了。
研究东西不能只停留在表面,全方面的去观察有时就能找到突破口。
不要只专于一门语言,多多尝试,做测开技术广度有时比深度重要。你永远不知道下一个支持的部门用的是什么语言。
作为支持部门,最重要的不是技术有多厉害,而是做出的工具要能够贴合测试组的日常使用需要。同时,及时只是提供给内部使用,也要在细节处不断打磨,要给用户更好的使用体验。
乐于分享,时常总结。
写在最后
毕业至今也有一年多了,回想当初校招时,也不知道为什么阴差阳错就变成测开了,也许是因为我当时只会 python 吧。这一路走来,发现我对写业务代码不是特别感兴趣,但对于写这种工具特别积极,也许这算是走对了路。记得我有一次面试时,面试官形容我 “野路子比较多”,当时我还对这个说法嗤之以鼻,但工作后,我越发感觉到这个形容非常贴切。
我不知道我还能在测开这条路上走多远,但还是希望在剩下的时间里,能努力创造,而不是一直追寻他人的脚步。
最后的最后,很可惜这篇文章没有代码只有干货,但这毕竟是公司财产,很抱歉暂时不能开源,我也已经向上级申请开源,若后续有结果一定广而告之。