Javassist实现无痕埋点

1,936 阅读6分钟

Javassist基础

Javassist是一个操作Java字节码的类库,它可以在一个已经编译好的类中添加新的方法,或者是修改已有的方法,并且不需要对字节码有深入的了解。它可以绕过编译,直接操作字节码,实现代码注入。使用它的最佳时机就是在构建工具gradle将源文件编译成.class之后,再将.class打包成.dex之前。

  • 读写字节码:一个Javassist.CtClass表示一个.class文件,所以他就是处理.class的关键。
  • 对象的获取:通过ClassPool.getClass(className)或者是ClassPool.get(class全路径)。这里的classPool它是CtClass的容器,内部是一个hash表,类名作为hash表的key,classPool对象一般通过ClassPool.getDefault()来获取,这里使用的是JVM的类搜索路径。如果想要添加额外的类搜索路径可以通过pool.insert方法添加,比如pool.insertClassPath("user/local/lib"),则将“user/local/lib”添加到搜索路径中
  • 冻结类:一个类只能被JVM加载一次,所以如果要修改类的话要对其进行解冻,利用ctClass.defrost(),这样就可以对类进行修改了,解冻之前一般都会判断是否被冻住了(ctClass.isFrozen())
  • 保存和释放:如果对一个类修改了,那么就要对其进行保存,使用的是ctClass.writeFIle(path)。一般为了避免内存溢出(CtClass对象过多导致),一般在修改保存后,手动对该对象进行删除(ctClass.detach())
  • ctClass中的重要方法:getName(),getAnnotations(),getDeclaredMethod(),getField(),getInterfaces(),getDeclaredConstruct()
  • 在方法体中插入代码:也就通过ctClass.getDeclaredMethod()方法中返回的方法ctMethod,调用insertBefore(),insertAfter()和addCatch()方法,把代码片段插入到方法体中。上面的三个方法都能接收一个表示语句或者语句块的String对象。所以这里的$是有特殊含义的。$0表示this,$1,$2等表示第一个,第二个参数等

无痕埋点的实现细节

同样它也需要定义plugin,它与aspectJ一样需要引入操作的框架

apply plugin: 'groovy'
apply plugin: 'maven'
dependencies {
    compile gradleApi()
    compile localGroovy()

    compile 'com.android.tools.build:gradle:3.1.4'

    compile 'org.javassist:javassist:3.20.0-GA'
}
repositories {
    jcenter()
}

uploadArchives {
    repositories.mavenDeployer {
        //本地仓库路径,以放到项目根目录下的 repo 的文件夹为例
        repository(url: uri('../repo'))

        //groupId ,自行定义
        pom.groupId = 'com.sensorsdata'

        //artifactId
        pom.artifactId = 'autotrack.android'

        //插件版本号
        pom.version = '1.0.0'
    }
}

这里的transform具体代码为

class SensorsAnalyticsTransform extends Transform {
    private static Project project

    SensorsAnalyticsTransform(Project project) {
        this.project = project
    }

    @Override
    String getName() {
        return "SensorsAnalyticsAutoTrack"
    }

    /**
     * 需要处理的数据类型,有两种枚举类型
     * CLASSES 代表处理的 java 的 class 文件,RESOURCES 代表要处理 java 的资源
     * @return
     */
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    /**
     * 指 Transform 要操作内容的范围,官方文档 Scope 有 7 种类型:
     * 1. EXTERNAL_LIBRARIES        只有外部库
     * 2. PROJECT                   只有项目内容
     * 3. PROJECT_LOCAL_DEPS        只有项目的本地依赖(本地jar)
     * 4. PROVIDED_ONLY             只提供本地或远程依赖项
     * 5. SUB_PROJECTS              只有子项目。
     * 6. SUB_PROJECTS_LOCAL_DEPS   只有子项目的本地依赖项(本地jar)。
     * 7. TESTED_CODE               由当前变量(包括依赖项)测试的代码
     * @return
     */
    @Override
    Set<QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    @Override
    boolean isIncremental() {
        return false
    }

    @Override
    void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
        if (!incremental) {
            outputProvider.deleteAll()
        }

        /**Transform 的 inputs 有两种类型,一种是目录,一种是 jar 包,要分开遍历 */
        inputs.each { TransformInput input ->
            /**遍历 jar*/
            input.jarInputs.each { JarInput jarInput ->
                /**重命名输出文件(同目录copyFile会冲突)*/
                String destName = jarInput.file.name

                /**截取文件路径的 md5 值重命名输出文件,因为可能同名,会覆盖*/
                def hexName = DigestUtils.md5Hex(jarInput.file.absolutePath).substring(0, 8)
                /** 获取 jar 名字*/
                if (destName.endsWith(".jar")) {
                    destName = destName.substring(0, destName.length() - 4)
                }

                File copyJarFile =  SensorsAnalyticsInject.injectJar(jarInput.file.absolutePath, project)
                def dest = outputProvider.getContentLocation(destName + hexName, jarInput.contentTypes, jarInput.scopes, Format.JAR)
                FileUtils.copyFile(copyJarFile, dest)

                context.getTemporaryDir().deleteDir()
            }

            /**遍历目录*/
            input.directoryInputs.each { DirectoryInput directoryInput ->
                SensorsAnalyticsInject.injectDir(directoryInput.file.absolutePath, project)

                def dest = outputProvider.getContentLocation(directoryInput.name,
                        directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
                /**将input的目录复制到output指定目录*/
                FileUtils.copyDirectory(directoryInput.file, dest)
            }
        }
    }
}

这里重点就是SensorsAnalyticsInject.injectJar()和SensorsAnalyticsInject.injectDir,也就是对dir和jar的处理。injectDir其实就是遍历,执行injectClass

private static void injectClass(File classFile, String path) {
    String filePath = classFile.absolutePath
    if (!filePath.endsWith(".class")) {
        return
    }

    if (!filePath.contains('R$')
            && !filePath.contains('R2$')
            && !filePath.contains('R.class')
            && !filePath.contains('R2.class')
            && !filePath.contains("BuildConfig.class")) {
        int index = filePath.indexOf(path)
        String className = filePath.substring(index + path.length() + 1, filePath.length() - 6).replaceAll("/", ".")
        if (!className.startsWith("android")) {
            try {
                CtClass ctClass = pool.getCtClass(className)

                //解冻
                if (ctClass.isFrozen()) {
                    ctClass.defrost()
                }

                boolean modified = false

                CtClass[] interfaces = ctClass.getInterfaces()

                if (interfaces != null) {
                    Set<String> interfaceList = new HashSet<>()
                    for (CtClass c1 : interfaces) {
                        interfaceList.add(c1.getName())
                    }

                    for (CtMethod currentMethod : ctClass.getDeclaredMethods()) {
                        MethodInfo methodInfo = currentMethod.getMethodInfo()
                        AnnotationsAttribute attribute = (AnnotationsAttribute) methodInfo
                                .getAttribute(AnnotationsAttribute.visibleTag)
                        if (attribute != null) {
                            for (Annotation annotation : attribute.annotations) {
                                if ("@com.sensorsdata.analytics.android.sdk.SensorsDataTrackViewOnClick" == annotation.toString()) {
                                    if ('(Landroid/view/View;)V' == currentMethod.getSignature()) {
                                        currentMethod.insertAfter(SDK_HELPER + ".trackViewOnClick(\$1);")
                                        modified = true
                                        break
                                    }
                                }
                            }
                        }

                        String methodSignature = currentMethod.name + currentMethod.getSignature()

                        if ('onContextItemSelected(Landroid/view/MenuItem;)Z' == methodSignature) {
                            currentMethod.insertAfter(SDK_HELPER + ".trackViewOnClick(\$0,\$1);")
                            modified = true
                        } else if ('onOptionsItemSelected(Landroid/view/MenuItem;)Z' == methodSignature) {
                            currentMethod.insertAfter(SDK_HELPER + ".trackViewOnClick(\$0,\$1);")
                            modified = true
                        } else {
                            SensorsAnalyticsMethodCell methodCell = SensorsAnalyticsConfig.isMatched(interfaceList, methodSignature)
                            if (methodCell != null) {
                                StringBuffer stringBuffer = new StringBuffer()
                                stringBuffer.append(SDK_HELPER)
                                stringBuffer.append(".trackViewOnClick(")
                                for (int i = methodCell.getParamStart(); i < methodCell.getParamStart() + methodCell.getParamCount(); i++) {
                                    stringBuffer.append("\$")
                                    stringBuffer.append(i)
                                    if (i != (methodCell.getParamStart() + methodCell.getParamCount() - 1)) {
                                        stringBuffer.append(",")
                                    }
                                }
                                stringBuffer.append(");")
                                currentMethod.insertAfter(stringBuffer.toString())
                                modified = true
                            }
                        }
                    }
                }

                if (modified) {
                    ctClass.writeFile(path)
                    ctClass.detach()//释放
                }
            } catch (Exception e) {
                e.printStackTrace()
            }
        }
    }
}

其实上面做的事无非就是以下几件: 1:排除一些不需要处理的类比如R.class 2:获取类所实现的所有接口 3:如果实现了某些接口,遍历类中的所有方法 4:如果方法的signature与当前的一致(如onContextItemSelected(Landroid/view/MenuItem;)Z)或者添加了某个注解,那么就执行insertAfter(XXX)代码,当然XXX是定义好的 5:如果修改了文件就保存和释放

剩下的则是定义plugin和生成resource文件,此处省略。

属性值的传递,注解值的获取

首先依然是定义plugin和transform,其代码与上面是一样的,这两步就忽略了,主要看操作字节码的过程 这里首先是像某个特定方法中插入代码,比如有个方法为public void test(),那么在获取到ctClass后,遍历它的所有方法,然后通过方法名字和signature来确定是否是这个方法

String methodSignature = currentMethod.name + currentMethod.getSignature()
System.out.println("methodSignature = " + methodSignature)
if ('test()V' == methodSignature) {
    currentMethod.insertAfter(SDK_HELPER + ".injectLog();")
    modified = true
}

其中MainActivity中的代码为

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        test();
    }

    public void test() {
        Log.e("tag", "这是一个测试方法");
    }
}

打点的helper代码为

public class InjectMethodHelper {

    public static void injectLog() {
        Log.e("tag", "这是插入的一段打点代码");
    }

    public static void injectAnnotationLog(String id, String value) {
        Log.e("Tag", "这是插入的一段注解打点代码===>>id====>>" + id + "=====>>value====>>" + value);
    }
}

在build的时候就可以看到log

运行成功之后则显示了如下log

这只是一个普通的方式,有的时候还需要加入注解来达到某些特定功能 比如定义了一个注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AnnotationTest {
    String id();

    String value();

}

在mainActivity中声明一个方法,并且在onCreate中调用

@AnnotationTest(id = "idNo.1", value = "valueNo.1")
public void testAnnotation() {
    Log.e("tag", "这是一个有注解的方法");
}

那么在处理字节码时就需要处理注解,其处理过程如下

for (CtMethod currentMethod : ctClass.getDeclaredMethods()) {

    //拿到所有注解
    Object[] annotations = currentMethod.getAnnotations()
    if (annotations != null && annotations.length > 0) {
        MethodInfo methodInfo = currentMethod.getMethodInfo()
        AnnotationsAttribute attribute = (AnnotationsAttribute) methodInfo.getAttribute(AnnotationsAttribute.visibleTag)
        //拿到所需要的注解
        Annotation annotation = attribute.getAnnotation(ANNOTATION_HELPER)
        if (annotation != null) {
            //注解中定义的值的名字
            def names = annotation.getMemberNames()
            //拿到注解的值
            String id = annotation.getMemberValue("id")
            String values = annotation.getMemberValue("value")

            //打印log,这个会在build的时候就能打印
            System.out.println("annnotationMemberNames =" + names.toString())
            System.out.println("annnotationMemberId =" + id)
            System.out.println("annnotationMemberValue =" + values)

            //将得到的值传给某个方法时要采用${value}这种形式,而且像insertXXX方法中传递的时候也要用StringBuilder
            //如果用String+string的方式会报编译错误
            StringBuffer stringBuffer = new StringBuffer()
            stringBuffer.append(SDK_HELPER)
            stringBuffer.append(".injectAnnotationLog(")
            stringBuffer.append("${id}")
            stringBuffer.append(",")
            stringBuffer.append("${values}")
            stringBuffer.append(");")
            System.out.println(stringBuffer.toString())
            currentMethod.insertAfter(stringBuffer.toString())

            modified = true

        }

    }

    String methodSignature = currentMethod.name + currentMethod.getSignature()
    System.out.println("methodSignature = " + methodSignature)
    if ('test()V' == methodSignature) {
        currentMethod.insertAfter(SDK_HELPER + ".injectLog();")
        modified = true
    }
}

上面的注释已经很明白了,即通过MethodInfo拿到所有运行时注解Attribute,然后利用这个attribute拿到指定的Annotation,通过getMemberValue就可以获取到指定注解的值,这里直接写死了,如果不写死,那么就通过getMemberNames来获取直接的名字,然后利用这里的名字取出注解的值。值得注意的是这里的insertAfter方法,传入的是利用StringBuilder创建的一个String,原因在于这里需要将刚才获取到的属性值传递给固定的方法,如果采用String+String的方式的话,编译器会报错。 这时候我们可以看一下build的过程中打印log

可以看到获取到了直接的值,在运行之后则可以看到确实成功了

所以完整的处理class的过程如下

private static void injectClass(File classFile, String path) {
    String filePath = classFile.absolutePath
    if (!filePath.endsWith(".class")) {
        return
    }

    if (!filePath.contains('R$')
            && !filePath.contains('R2$')
            && !filePath.contains('R.class')
            && !filePath.contains('R2.class')
            && !filePath.contains("BuildConfig.class")) {
        int index = filePath.indexOf(path)
        String className = filePath.substring(index + path.length() + 1, filePath.length() - 6).replaceAll("/", ".")
        if (!className.startsWith("android")) {
            try {
                CtClass ctClass = pool.getCtClass(className)

                //解冻
                if (ctClass.isFrozen()) {
                    ctClass.defrost()
                }

                boolean modified = false

                for (CtMethod currentMethod : ctClass.getDeclaredMethods()) {

                    //拿到所有注解
                    Object[] annotations = currentMethod.getAnnotations()
                    if (annotations != null && annotations.length > 0) {
                        MethodInfo methodInfo = currentMethod.getMethodInfo()
                        AnnotationsAttribute attribute = (AnnotationsAttribute) methodInfo.getAttribute(AnnotationsAttribute.visibleTag)
                        //拿到所需要的注解
                        Annotation annotation = attribute.getAnnotation(ANNOTATION_HELPER)
                        if (annotation != null) {
                            //注解中定义的值的名字
                            def names = annotation.getMemberNames()
                            //拿到注解的值
                            String id = annotation.getMemberValue("id")
                            String values = annotation.getMemberValue("value")

                            //打印log,这个会在build的时候就能打印
                            System.out.println("annnotationMemberNames =" + names.toString())
                            System.out.println("annnotationMemberId =" + id)
                            System.out.println("annnotationMemberValue =" + values)

                            //将得到的值传给某个方法时要采用${value}这种形式,而且像insertXXX方法中传递的时候也要用StringBuilder
                            //如果用String+string的方式会报编译错误
                            StringBuffer stringBuffer = new StringBuffer()
                            stringBuffer.append(SDK_HELPER)
                            stringBuffer.append(".injectAnnotationLog(")
                            stringBuffer.append("${id}")
                            stringBuffer.append(",")
                            stringBuffer.append("${values}")
                            stringBuffer.append(");")
                            System.out.println(stringBuffer.toString())
                            currentMethod.insertAfter(stringBuffer.toString())

                            modified = true

                        }

                    }

                    String methodSignature = currentMethod.name + currentMethod.getSignature()
                    System.out.println("methodSignature = " + methodSignature)
                    if ('test()V' == methodSignature) {
                        currentMethod.insertAfter(SDK_HELPER + ".injectLog();")
                        modified = true
                    }
                }


                if (modified) {
                    ctClass.writeFile(path)
                    ctClass.detach()//释放
                }
            } catch (Exception e) {
                e.printStackTrace()
            }
        }
    }
}