咱们产线的主流程页面中有几个比较复杂的页面在版本迭代中流畅度频繁出现反复,常常因为开发的不注意致使变卡,主要是对流畅度缺乏必要的监控和可持续的优化手段,这个系列是对上半年实践App流畅度监控、优化过程当中的一点总结,但愿能够给须要的同窗一点小参考。java
固然App内存上的优化,尽可能减小内存抖动也能显著提高流畅度,内存优化方面能够参考以前的文章:实践App内存优化:如何有序地作内存分析与优化android
整个系列将主要包括如下几部分:git
卡顿与View的绘制过程解析
github
这部份内容比较多,主要是从源码层面解析一下整个过程,也是咱们后面作流畅度监控与优化的基础api
Debug阶段如何对实时帧率进行监控和显示
浏览器
根据上面的原理,设计一个显示实时帧率的工具,能够有效的在开发阶段发现问题服务器
如何实现流畅度自动化测试
markdown
实现一个流畅度UI自动化测试,在上线前跑一下UI自动化并生成流畅度报表邮件给相关人员app
线上的用户流畅度的监控方案
框架
实时反映真实用户的流畅度体验,线上庞大的数据能够敏感的反应出版本迭代间流畅度的变化
实现一个方便排查高耗时方法的工具
利用自定义gradle plugin+ASM插桩实现快速而准确的找出耗时的方法,进行针对性的优化
分享提高app流畅度的一些经验
分享一些成本小收益高的提高流畅度的方案
工欲善其事必先利其器,今天首先分享一下在优化页面流畅度过程当中本身实现的一个方便快速排查高耗时方法的工具:
MethodTraceMan
,毕竟保持主流程流畅,避免在主流程执行高耗时方法永远是优化卡顿最直接的手段,只要咱们能快速方便的排查到高耗时的方法,就能够作针对性优化。
日常咱们用来排查Android卡顿的比较熟悉的工具备TraceView
、systrace
等,通常分为两种模式:instrument
和sample
。可是这些工具不论是哪一种模式都有各自不足的地方,好比instruement模式,能够得到全部函数的调用过程,信息比较丰富,可是会带来极大的性能开销,致使统计的耗时与实际不符
;而sample模式是经过采样的方式进行分析的,因此信息丰富度上就大打折扣
,像systrace就属于sample型的,它只能监控一些系统调用的耗时状况。
除了上面说的工具,著名的JackWharton也实现了一个能够打印出出方法耗时的工具hugo
,它是基于注解触发的,在一个方法上加上特定注解便可打印出该方法的耗时等信息,可是若是咱们想排查高耗时方法,显然在全部方法上一个一个加注解太费劲了。
那么咱们在作卡顿优化的过程当中须要一个什么样的工具呢?
要实现这样一个工具,首先想到的就是经过插桩技术
来实现,在编译过程当中对全部的方法进行插桩,在方法进入和方法结束的地方进行打点,就能够在对性能影响很小的方式下统计到每一个方法的耗时。统计到每一个方法的耗时数据后,咱们再实现一个UI界面来展现这些数据,并实现耗时筛选、线程筛选、方法名搜索等功能,这样咱们就能够快速的找到主线程高耗时的方法进行针对性的优化了。
咱们先来看下最终实现的效果预览:
输出全部的方法耗时,高耗时方法以红色预警,同时支持对耗时筛选,线程筛选,方法名搜索等,好比想筛出主线程耗时大于50ms的方法,就能够很方便的找出。
详细的集成以及使用文档详见:MethodTraceMan
插桩技术其实充斥在咱们日常开发中的方方面面,能够帮助咱们实现不少繁琐复杂的功能,还能够帮助咱们提升功能的稳定性,好比ButterKnife、Protocol Buffers等都会在编译时期生成代码,固然插桩技术也分不少种,好比ButterKnife是利用APT在编译的开始阶段对java文件进行操做,而像AscpectJ、ASM等则是在java文件编译为字节码文件后,对字节码进行操做,固然还有一些能够在字节码文件被编译为dex文件后对dex进行操做的框架。 因为咱们的需求是在编译期对全部的方法的进入和结束的地方插桩进行耗时统计,因此最终的技术选型锁定在对字节码文件的操做。那么咱们来对比一下AspectJ和ASM两种字节码插桩的框架:
AspectJ是老牌的字节码处理框架了,其优势就是使用简单上手容易,不须要了解字节码相关知识也能够在项目中集成使用,只要指定简单的规则就能够完成对代码的插桩,好比咱们如今要实现对全部方法的进入和退出时进行插桩,十分简单,以下:
@Before("execution(* **(..))")
public void beforeMethod(JoinPoint joinPoint) {
//TODO 耗时统计
}
@After("execution(* **(..))")
public void afterMethod() {
//TODO 耗时统计
}
复制代码
固然相对于优势来讲,AspectJ的缺点是,因为其基于规则,因此其切入点相对固定,对于字节码文件的操做自由度以及开发的掌控度就大打折扣。还有就是咱们要实现的是对全部方法进行插桩,因此代码注入后的性能也是咱们须要关注的一个重要的点,咱们但愿只插入咱们想插入的代码,而AspectJ会额外生成一些包装代码,对性能以及包大小有必定影响。
ASM是一个十分强大的字节码处理框架,基本上能够实现任何对字节码的操做,也就是自由度和开发的掌控度很高,可是其相对来讲比AspectJ上手难度要高,须要对Java字节码有必定了解,不过ASM为咱们提供了访问者模式来访问字节码文件,这种模式下能够比较简单的作一些字节码操做,实现一些功能。同时ASM能够精确的只注入咱们想要注入的代码,不会额外生成一些包装代码,因此性能上影响比较微小。
上面说了不少,对于java字节码,这里作一些简单的介绍:
咱们都知道在java文件的经过javac编译后会生成十六进制的class文件,好比咱们先编写一个简单的Test.java文件:
public class Test {
private int m = 1;
public int add() {
int j = 2;
int k = m + j;
return k;
}
}
复制代码
而后咱们经过 javac Test.java -g
来编译为Test.class,用文本编辑器打开以下:
能够看到是一堆十六进制数,可是其实这一堆十六进制数是按严格的结构拼接在一块儿的,按顺序分别是:魔数(cafe babe)、java版本号、常量池、访问权限标志、当前类索引、父类索引、接口索引、字段表、方法表、附加属性等十个部分,这些部分以十六进制的形式表达出来并紧凑的拼接在一块儿,就是上面看到的class字节码文件。
固然上面的十六进制文件显然不具有可阅读性,因此咱们能够经过 javap -verbose Test
来反编译,有兴趣的能够本身试一试,就能够看到上面说的十个部分,因为咱们作字节码插桩通常和方法表关联比较大,因此咱们下面着重看一下方法表
,下面是反编译后的add()方法:
能够看到包括三部分:
Code
: 这里部分就是方法里的JVM指令操做码,也是最重要的一部分,由于咱们方法里的逻辑实际上就是一条一条的指令操做码来完成的。这里能够看到咱们的add方法是经过9条指令操做码完成的。固然插桩重点操做的也是这一块,只要能修改指令,也就能操控任何代码了。LineNumberTable
: 这个是表示行号表。是咱们的java源码与指令行的行号对应。好比咱们上面的add方法java源码里总共有三行,也就是上图中的line十、line十一、line12,这三行对应的JVM指令行数。有了这样的对应关系后,就能够实现好比Debug调试的功能,指令执行的时候,咱们就能够定位到该指令对应的源码所在的位置。LocalVariableTable
:本地变量表,主要包括This和方法里的局部变量。从上图能够看到add方法里有this、j、k三个局部变量。因为JVM指令集是基于栈的,上面咱们已经了解到了add方法的逻辑编译为class文件后变成了9个指令操做码,下面咱们简单看看这些指令操做码是如何配合操做数栈+本地变量表+常量池
来执行add方法的逻辑的:
按顺序执行9条指令操做码:
好的,关于java字节码的暂时就简单介绍这些,主要是让咱们基本了解字节码文件的结构,以及编译后代码时如何运行的。而ASM能够经过操做指令码来生成字节码或者插桩,当你能够利用ASM来接触到字节码,而且能够利用ASM的api来操控字节码时,就有很大的自由度来进行各类字节码的生成、修改、操做等等,也就能产生很强大的功能。
上面对于插桩框架的选择,咱们经过对比最终选择了ASM,可是ASM只负责操做字节码,咱们还须要经过自定义gradle plugin的形式来干预编译过程,在编译过程当中获取到全部的class文件和jar包,而后遍历他们,利用ASM来修改字节码,达到插桩的目的。
那么干预编译的过程,咱们的第一个念头可能就是,对class转为dex的任务进行hook
,在class转为dex以前拿到全部的class文件,而后利用ASM对这些字节码文件进行插桩,而后再把处理过的字节码文件做为transformClassesWithDex
任务的输入便可。这种方案的好处是易于控制,咱们明确的知道操做的字节码文件是最终的字节码,由于咱们是在transformClassesWithDex
任务的前一刻拿到字节码文件的。缺点就是,若是项目开启了混淆,那么在transformClassesWithDex
任务的前一刻拿到的字节码文件显然是通过了混淆了的,因此利用ASM操做字节码的时候还须要mapping文件进行配合才能找到正确的插桩点,这一点比较麻烦。
幸好gradle还为咱们提供了另外一种干预编译转换过程的方法:Transform
.其实咱们稍微翻一下gradle编译过程的源码,就会发现一些咱们熟知的功能都是经过Transform来实现的。还有一点,就是关于混淆的问题,上面咱们说了若是经过hook transformClassesWithDex
任务的方式来实现插桩,开启混淆的状况下会出现问题,那么利用Transform的方式会不会有混淆的问题呢?下面咱们从gradle源码上面找一下答案:
咱们从com.android.build.gradle.internal.TaskManager类里的createCompileTask()
方法看起,显然这是一个建立编译任务的方法:
protected void createCompileTask(@NonNull VariantScope variantScope) {
//建立一个将java文件编译为class文件的任务
JavaCompile javacTask = createJavacTask(variantScope);
addJavacClassesStream(variantScope);
setJavaCompilerTask(javacTask, variantScope);
//建立一些在编译为class文件后执行的额外任务,好比一些Transform等
createPostCompilationTasks(variantScope);
}
复制代码
接下来咱们看看createPostCompilationTasks()
方法,这个方法比较长,下面只保留重要的几个代码:
public void createPostCompilationTasks(@NonNull final VariantScope variantScope) {
、、、、、、
TransformManager transformManager = variantScope.getTransformManager();
、、、、、
// ----- External Transforms 这个就是咱们自定义注册进来的Transform-----
// apply all the external transforms.
List<Transform> customTransforms = extension.getTransforms();
List<List<Object>> customTransformsDependencies = extension.getTransformsDependencies();
、、、、、、
、、、、、、
// ----- Minify next 这个就是混淆代码的Transform-----
CodeShrinker shrinker = maybeCreateJavaCodeShrinkerTransform(variantScope);
、、、、、、
、、、、、、
}
复制代码
其实这个方法里有不少其余Transform,这里都省略了,咱们重点只看咱们自定义注册的Transform和混淆代码的Transform,从上面的代码上咱们自定义的Transform是在混淆Transform以前添加进TransformManager,因此执行的时候咱们自定义的Transform也会在混淆以前执行的,也就是说咱们利用自定义Transform的方式对代码进行插桩是不受混淆影响的
。
因此咱们最终肯定的方案就是 Gradle plugin + Transform +ASM
的技术方案。下面咱们正式说说利用该技术方案进行具体实现。
这里具体实现只挑重点实现步骤讲,详细的能够看具体源码,文章结尾提供了项目的github地址。
关于如何建立一个自定义gradle plugin的项目,这边就不细说了,能够网上搜索,或者直接看MethodTraceMan项目的源码也行,自定义gradle plgin继承自Plugin类,入口是apply方法,咱们的apply方法里很简单,就是建立一个自定义扩展配置,而后就是注册一下咱们自定义的Transform:
@Override
void apply(Project project) {
println '*****************MethodTraceMan Plugin apply*********************'
project.extensions.create("traceMan", TraceManConfig)
def android = project.extensions.getByType(AppExtension)
android.registerTransform(new TraceManTransform(project))
}
复制代码
这里咱们建立了一个名叫traceMan
的扩展,这样咱们能够再使用这个plugin的时候进行一些配置,好比配置插桩的范围,配置是否开启插桩等,这样咱们就能够根据本身的须要来配置。
接下来咱们看一下TraceManTransform
的实现:
public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
println '[MethodTraceMan]: transform()'
def traceManConfig = project.traceMan
String output = traceManConfig.output if (output == null || output.isEmpty()) {
traceManConfig.output = project.getBuildDir().getAbsolutePath() + File.separator + "traceman_output"
}
if (traceManConfig.open) {
//读取配置
Config traceConfig = initConfig()
traceConfig.parseTraceConfigFile()
Collection<TransformInput> inputs = transformInvocation.inputs
TransformOutputProvider outputProvider = transformInvocation.outputProvider if (outputProvider != null) {
outputProvider.deleteAll()
}
//遍历,分为class文件变量和jar包的遍历
inputs.each { TransformInput input ->
input.directoryInputs.each { DirectoryInput directoryInput ->
traceSrcFiles(directoryInput, outputProvider, traceConfig)
}
input.jarInputs.each { JarInput jarInput ->
traceJarFiles(jarInput, outputProvider, traceConfig)
}
}
}
}
复制代码
接下来看看遍历class文件后如何利用ASM的访问者模式进行插桩:
static void traceSrcFiles(DirectoryInput directoryInput, TransformOutputProvider outputProvider, Config traceConfig) {
if (directoryInput.file.isDirectory()) {
directoryInput.file.eachFileRecurse { File file ->
def name = file.name
//根据配置的插桩范围决定要对某个class文件进行处理
if (traceConfig.isNeedTraceClass(name)) {
//利用ASM的api对class文件进行访问
ClassReader classReader = new ClassReader(file.bytes)
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
ClassVisitor cv = new TraceClassVisitor(Opcodes.ASM5, classWriter, traceConfig)
classReader.accept(cv, EXPAND_FRAMES)
byte[] code = classWriter.toByteArray()
FileOutputStream fos = new FileOutputStream(
file.parentFile.absolutePath + File.separator + name)
fos.write(code)
fos.close()
}
}
}
//处理完输出给下一任务做为输入
def dest = outputProvider.getContentLocation(directoryInput.name,
directoryInput.contentTypes, directoryInput.scopes,
Format.DIRECTORY)
FileUtils.copyDirectory(directoryInput.file, dest)
}
复制代码
能够看到,最终是TraceClassVisitor类里对class文件进行处理的,咱们看一下TraceClassVisitor
:
class TraceClassVisitor(api: Int, cv: ClassVisitor?, var traceConfig: Config) : ClassVisitor(api, cv) {
private var className: String? = null
private var isABSClass = false
private var isBeatClass = false
private var isConfigTraceClass = false override fun visit( version: Int, access: Int, name: String?, signature: String?, superName: String?, interfaces: Array<out String>? ) {
super.visit(version, access, name, signature, superName, interfaces)
this.className = name
//抽象方法或者接口
if (access and Opcodes.ACC_ABSTRACT > 0 || access and Opcodes.ACC_INTERFACE > 0) {
this.isABSClass = true
}
//插桩代码所属类
val resultClassName = name?.replace(".", "/")
if (resultClassName == traceConfig.mBeatClass) {
this.isBeatClass = true
}
//是不是配置的须要插桩的类
name?.let { className ->
isConfigTraceClass = traceConfig.isConfigTraceClass(className)
}
}
override fun visitMethod( access: Int, name: String?, desc: String?, signature: String?, exceptions: Array<out String>? ): MethodVisitor {
val isConstructor = MethodFilter.isConstructor(name)
//抽象方法、构造方法、不是插桩范围内的方法,则不进行插桩
return if (isABSClass || isBeatClass || !isConfigTraceClass || isConstructor) {
super.visitMethod(access, name, desc, signature, exceptions)
} else {
//TraceMethodVisitor中对方法进行插桩
val mv = cv.visitMethod(access, name, desc, signature, exceptions)
TraceMethodVisitor(api, mv, access, name, desc, className, traceConfig)
}
}
}
复制代码
再来看看TraceMethodVisitor
:
override fun onMethodEnter() {
super.onMethodEnter()
//利用ASM在方法进入的时候 经过插入指令调用耗时统计的方法:start()
mv.visitLdcInsn(generatorMethodName())
mv.visitMethodInsn(INVOKESTATIC, traceConfig.mBeatClass, "start", "(Ljava/lang/String;)V", false)
}
override fun onMethodExit(opcode: Int) {
//利用ASM在方法进入的时候 经过插入指令调用耗时统计的方法:end()
mv.visitLdcInsn(generatorMethodName())
mv.visitMethodInsn(INVOKESTATIC, traceConfig.mBeatClass, "end", "(Ljava/lang/String;)V", false)
}
复制代码
这样,咱们就能够在全部配置的在插桩范围内的方法都在方法进入的时候调用TraceMan.start()方法,在方法退出的时候调用TraceMan.end()方法进行耗时统计。而TraceMan这个类也是可配置的,也就是你能够经过配置决定在方法进入和退出的时候调用哪一个类的哪一个方法。
至于TraceMan.start()
和TraceMan.end()
是如何实现对一个方法的耗时统计,如何输出全部方法的耗时,能够具体看源码里TraceMan
类的具体实现,这里就不具体展开了。
经过上面的方法插桩,以及耗时数据的处理,咱们已经能够获取到全部方法的耗时统计,那么为了这个工具的易用性,咱们再来实现一个UI展现界面,可让方法的耗时数据能够实时的展现在浏览器上,而且支持耗时筛选、线程筛选、方法名搜索等功能。
咱们使用React
实现了一个UI展现界面,而后在手机上搭建了一个服务器,这样在浏览器上就能够经过地址访问到这个UI展现界面,而且经过socket进行数据传输,咱们的插桩代码产生方法耗时数据,而后React实现的UI界面接收数据、消费数据、展现数据。
UI界面展现这部分的实现提及来比较琐碎,这里就不详细展开了,感兴趣的同窗能够看看源码。
该项目的源码和详细的集成以及使用方法,我在github上维护了详细的文档,欢迎提供意见: MethodTraceMan
以上就是咱们在优化流畅度的过程当中实现的一个协助咱们快速解决问题的工具,也简单分享了相关的技术知识,但愿对也为页面流畅度苦恼的同窗提供一点点想法。以后将分享其余的几个部分,主要包括:Android View绘制原理
、帧率流畅度监控
、帧率自动化测试
、流畅度优化实用技巧
等等。固然对于卡顿以及流畅度的监控及优化还有不少须要作的工做,咱们的主要目标是但愿从监控到排查问题工具再到卡顿解决造成一个闭环的方案,让版本迭代间的流畅度问题作到可控、可发现、易解决,这是咱们努力的方向。