移动应用性能工具探索之路——安卓篇
一、前言
随着PerfDog收费,之前无人问津的自研的性能工具突然变成了一件举足轻重的事。但当有用户开始使用后,没有经过考验的工具就暴露出了很多问题,这些问题让我意识到,这个工具不仅仅是只要能获取性能这么简单。在随后的对这个产品不断深耕的过程中,我积累了很多知识,也获得了一些感悟,接下来,我将会把我的经验分享给大家。
二、探索之路
初识adb
在最开始做安卓性能采集时,在网上收集资料,几乎所有的路都通向一个地方——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来获取各个线程采集到的数据。
- 怎么样才能实现像PerfDog一样的间隔1s一个性能数据,且保证各个由不同命令取出的数据的时间一致呢?
在最初的版本中,各个取数据的线程中,记录执行方法的时间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
过程中,如果没有获取到返回值,则判定应用已退出,结束收集,并告知前端应用已退出。
- 使用adb截图功能比较耗性能怎么办?
使用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即可。
- 使用dumpsys meminfo获取PSS会导致卡顿如何解决?
PerfDog的官方文档中,明确了安卓内存取的是PSS。查找了大量资料,使用adb能稳定获取的渠道只有dumpsys meminfo package
。但经过反复测试,发现这个方法有个致命缺陷就是会消耗大量cpu资源,导致卡顿。
会导致卡顿这一点,对于任何性能测试工具来说都是致命缺陷,但如果不取PSS又不行,那只能跳脱出adb,寻找新的方向。
突破
转机是我发现了这篇文章:https://testerhome.com/topics/27321 ,文章中对app_process已经介绍的很详细了,我就不再赘述。
但问题来了,他并没有放出代码,我该怎么搞?在网上一通寻找,发现了一个项目:https://github.com/anysou/APP_process ,在一番折腾后,也是成功打出了第一个apk,取出了其中的dex文件。至此,性能工具也是进入了下一个阶段。
- 执行shell
使用adb shell需要通过adb server和手机交互,若能直接和手机交互是不是就更快?
在上述的项目中已经实现了这个功能,使用app_process启动项目后,只要使用socket连接手机的8888(项目代码中预设端口),然后发送命令,以换行符为结尾即可获得执行结果。
其原理是调用Runtime.getRuntime().exec()
可惜的是,执行dumpsys meminfo package
依然会卡顿。
- 获取Context
对于使用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
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时不能获取应用名和图标的尴尬。
- ActivityManager
用于获取进程内存数据,用它获取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吧。这一路走来,发现我对写业务代码不是特别感兴趣,但对于写这种工具特别积极,也许这算是走对了路。记得我有一次面试时,面试官形容我“野路子比较多”,当时我还对这个说法嗤之以鼻,但工作后,我越发感觉到这个形容非常贴切。
我不知道我还能在测开这条路上走多远,但还是希望在剩下的时间里,能努力创造,而不是一直追寻他人的脚步。
最后的最后,很可惜这篇文章没有代码只有干货,但这毕竟是公司财产,很抱歉暂时不能开源,我也已经向上级申请开源,若后续有结果一定广而告之。