Android 无埋点统计简单实现

Android 无埋点统计简单实现

      • Android 无埋点统计简单实现
        • 一问题的引入
        • 二解决方案的形成
        • 三具体方案的实现基于 Android 60 系统
          • 1 Hook LayoutInflater
          • 2 统计代码的动态注入
        • 四总结

一、问题的引入

  在开发过程中,都避免不了事件的统计,比如对某个界面启动次数的统计,或者对某个按钮点击次数的统计,一般大的公司会有自己的统计 SDK ,而其他的公司则会选择友盟统计等第三方平台。但是他们都需要在代码里面的每一个事件产生的地方插入统计的代码,如某个事件触发的 onClick 事件,那么就在view.setOnClickListener() 的on Click 方法里面 写入 MobclickAgent.onEvent(MyApp.getInstance(), "login_click")。这时候会有人想有没有一种办法不用这么麻烦呢。
通过上面的分析,有过 AOP 的同学就会想到,这就是典型的 AOP 的应用场景啊。是的,使用 AOP 可以很好的解决这一问题。

二、解决方案的形成

   在友盟统计当中,每一个事件都会对应着一个 id,而这个可以由开发人员自己定义,对应的 id 会有一个米描述,不然产品会看不懂,这样就形成了一个表格,将这个表格上传到友盟的后台。当需要统计哪个事件的时候,只需将对象的 id 上报即可,而后台就会记录对应的 id 的事件统计。而通过观察大部分的 事件统计都是 onClick 事件。
接下来就是需要解决的几个问题:

  • 问题一:如何在对应的事件上动态注入代码
  • 问题二:如何动态的生成一个事件的唯一 id
  • 问题三:如何将 id 和事件的描述对应上

解决方案:

  • 答案一:在 AOP 里面有 Javassist 库,可以很便利动态修改 .class 文件。在 java 文件编译成 class 文件之后,可以找到所有实现 android.view.View.onClickListener 的类,包括匿名类,然后在它的 onClick(View v) 注入统计的代码。

  • 答案二:可能会有人认为直接就可以使用 View.getId() ,但是这个 ID是自己人为设置的,而且同一个 在不同的 layout.xml 可以设置相同的 ID,所以不能作为事件的唯一 ID。那么这个 ID 就必须我们自己生成了。思路是:在事件发生之前,将当前 Activity 的 layout 的整个 ViewTree 进行遍历,将所有 View 和 ViewGroup 的 Tag 设置为我们组合的唯一 ID, 这个 ID 是由我们 ID 发生器 和 当前 View 的 ViewParent 的 ID 组合而成,然后点 onClick 事件产生时,我们可以得到当前 View 的唯一 ID 了。

  • 答案三:获得唯一 ID 之后,通过代码很难知道这个 View 的具体描述是什么,所以必须手动的去配置,这里采用了很简单的办法,那就是在界面上一一点击我们想要统计的点击事件,然后将出对应 View 的 ID 写入到一个文件,然后在这个文件的对应 ID 上写上对应 View 的描述。其实还可以更加直观一点,那就是点击的时候直接弹出一个对话框,然后在这个对话框中输入对应的描述。

三、具体方案的实现(基于 Android 6.0 系统)

3.1 Hook LayoutInflater

Hook LayoutInflater 就是通过反射式的使得当调用 context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) 返回的是 我们自定义的 CustomLayoutInflater,这个类是继承自 LayoutInflater 的,所以我们覆写 inflate 方法就可以得到对应 ViewTree 了。这时候就可以为所欲为了。

通过阅读源码(android-25)知道,getSystemService 方法最终会到 android.app.SystemServiceRegistry 这个类的静态变量 SYSTEM_SERVICE_FETCHERS

private static final HashMap> SYSTEM_SERVICE_FETCHERS =
            new HashMap>();

    public static Object getSystemService(ContextImpl ctx, String name) {
        ServiceFetcher fetcher = SYSTEM_SERVICE_FETCHERS.get(name);
        return fetcher != null ? fetcher.getService(ctx) : null;
    }

中获取,而这个变量是在 registerService 方法赋值的

    private static  void registerService(String serviceName, Class serviceClass,
            ServiceFetcher serviceFetcher) {
        SYSTEM_SERVICE_NAMES.put(serviceClass, serviceName);
        SYSTEM_SERVICE_FETCHERS.put(serviceName, serviceFetcher);
    }

赋值的地方是在该类的 static 代码里面

static {
  ......
        registerService(Context.LAYOUT_INFLATER_SERVICE, LayoutInflater.class,
                new CachedServiceFetcher() {
            @Override
            public LayoutInflater createService(ContextImpl ctx) {
                return new PhoneLayoutInflater(ctx.getOuterContext());
            }});
    ......
}

知道这些我们就想着 通过反射调用 registerService,将我们自定义的 CustomLayoutInflater 注册进去,然后替换掉原本的 PhoneLayoutInflater,这个当系统获取 LayoutInflater 时候得到的是 CustomLayoutInflater。

下面开始实施我们的”犯罪行为“
由于 registerService(String serviceName, Class serviceClass, ServiceFetcher serviceFetcher) 需要 ServiceFetcher 的示例,而 ServiceFetcher 是一个接口,并且处于 该类的内部。所以我们只能通过反射拿到这个接口,并且创建一个类实现这个接口,然后实例化它。然而通过反射得到的 ServiceFetcher 的 Class 类型,如果调用接口的 Class.newInstance() 会直接 抛出异常,无法到达我们的目的。有个办法就是通过动态代理来生成一个 实现 ServiceFetcher 接口的类。

public class Hooker {
    private static final String TAG = "Hooker";
    public static void hookLayoutInflater() throws Exception {
        //获取 ServiceFetcher 实例 ServiceFetcherImpl
        Class ServiceFetcher = Class.forName("android.app.SystemServiceRegistry$ServiceFetcher");
        Object ServiceFetcherImpl = Proxy.newProxyInstance(Hooker.class.getClassLoader(),
                new Class[]{ServiceFetcher}, new ServiceFetcherHandler());//Proxy.newProxyInstance 返回的对象会实现指定的接口

        //获取 SystemServiceRegistry # registerService 方法
        Class SystemServiceRegistry = Class.forName("android.app.SystemServiceRegistry");
        Method registerService = SystemServiceRegistry.getDeclaredMethod("registerService",
                String.class, CustomLayoutInflater.class.getClass(), ServiceFetcher);
        registerService.setAccessible(true);

        //调用 registerService 方法,将自定义的 CustomLayoutInflater 设置到 SystemServiceRegistry
        registerService.invoke(SystemServiceRegistry,
                new Object[]{Context.LAYOUT_INFLATER_SERVICE, CustomLayoutInflater.class, ServiceFetcherImpl});

        // (测试)
        //获取 SystemServiceRegistry 的 SYSTEM_SERVICE_FETCHERS 静态变量
//        Field SYSTEM_SERVICE_FETCHERS = SystemServiceRegistry.getDeclaredField("SYSTEM_SERVICE_FETCHERS");
//        SYSTEM_SERVICE_FETCHERS.setAccessible(true);
//        Log.e(TAG, SYSTEM_SERVICE_FETCHERS.getName());
//        HashMap SYSTEM_SERVICE_FETCHERS_FIELD = (HashMap) SYSTEM_SERVICE_FETCHERS.get(SystemServiceRegistry);
//
//        Set set = SYSTEM_SERVICE_FETCHERS_FIELD.keySet();
//        Iterator iterator = set.iterator();

    }
}

ServiceFetcherHandler.java

public class ServiceFetcherHandler implements InvocationHandler{

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        //当调用 ServiceFetcherImpl 的 getService 时候会返回我们自定义的 LayoutInflater
        return new CustomLayoutInflater((Context) args[0]);
    }
}

CustomLayoutInflater 参考了系统自带的 PhoneLayoutInflater,然后加上自己的生成 View Id 的代码,具体的看代码,这部分不难

public class CustomLayoutInflater extends LayoutInflater {

    private static final String[] sClassPrefixList = {
            "android.widget.",
            "android.webkit."
    };

    public CustomLayoutInflater(Context context) {
        super(context);
    }

    protected CustomLayoutInflater(LayoutInflater original, Context newContext) {
        super(original, newContext);
    }

    @Override
    protected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException {
        for (String prefix : sClassPrefixList) {
            try {
                View view = createView(name, prefix, attrs);
                if (view != null) {
                    return view;
                }
            } catch (ClassNotFoundException e) {
                // In this case we want to let the base class take a crack
                // at it.
            }
        }
        return super.onCreateView(name, attrs);
    }

    public LayoutInflater cloneInContext(Context newContext) {
        return new CustomLayoutInflater(this, newContext);
    }

    @Override
    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
        View viewGroup = super.inflate(resource, root, attachToRoot);
        View rootView = viewGroup;
        View tempView = viewGroup;
        //得到根View
        while (tempView != null) {
            rootView = viewGroup;
            tempView = (ViewGroup) tempView.getParent();
        }
        //遍历根 View 所有的子 view
        traversalViewGroup(rootView);
        return viewGroup;
    }

    private void traversalViewGroup(View rootView) {
        if (rootView != null && rootView instanceof ViewGroup) {
            //如果 tag 的值已经存在了 那就不用在赋值了
            if (rootView.getTag() == null) {
                rootView.setTag(getViewTag());
            }
            ViewGroup viewGroup = (ViewGroup) rootView;
            int childCount = viewGroup.getChildCount();
            for (int i = 0; i < childCount; i++) {
                View childView = viewGroup.getChildAt(i);
                if (childView.getTag() == null) {
                    childView.setTag(combineTag(getViewTag(), viewGroup.getTag().toString()));
                }
                Log.e("Hooker", "childView name = " + childView.getClass().getName() + "id = " + childView.getTag().toString());
                if (childView instanceof ViewGroup) {
                    traversalViewGroup(childView);
                }
            }
        }
    }

    private String combineTag(String tag1, String tag2) {
        return getMD5(getMD5(tag1) + getMD5(tag2));
    }

    private static int VIEW_TAG = 0x10000000;

    private static String getViewTag() {
        return String.valueOf(VIEW_TAG++);
    }

    /**
     * 对字符串md5加密
     *
     * @param str
     * @return
     */
    public static String getMD5(String str) {
        try {
            // 生成一个MD5加密计算摘要
            MessageDigest md = MessageDigest.getInstance("MD5");
            // 计算md5函数
            md.update(str.getBytes());
            // digest()最后确定返回md5 hash值,返回值为8为字符串。因为md5 hash值是16位的hex值,实际上就是8位的字符
            // BigInteger函数则将8位的字符串转换成16位hex值,用字符串来表示;得到字符串形式的hash值
            return new BigInteger(1, md.digest()).toString(16);
        } catch (Exception e) {

        }
        return "null";
    }
}

最后在我们的 Application onCreate方法调用 Hooker.hookLayoutInflater() 方法,就可以了,想检验这一步的正确性,编写如下代码测试

        Button button = (Button) findViewById(R.id.button);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                //62e419e0f3c9772391c861b6c09a2abd = v.getTag()
                Toast.makeText(MainActivity.this, "this is a button !, " + v.getTag().toString(), Toast.LENGTH_LONG).show();
            }
        });

其实还有一种比较简单的办法 将所有的 View 设置 Id,那就是在 Activity onTouchEvent 方法里面 获取 getWindow().getDecorView(),然后遍历该 View 的子 View 设置 ID。这里会花费点时间,点击的响应会慢一点点。但是我感觉 hookLayoutInflater 更有技术含量。

3.2 统计代码的动态注入

这一部分需要有 AOP 编程基础,对 Javassist 有一定的了解才行,具体的自行脑部
首先新建一个 Java Library 类型 Module,必须命名为 BuildSrc,在 build.gradle 添加如下代码

apply plugin: 'groovy'

dependencies {
    compile gradleApi()//gradle sdk
    compile localGroovy()//groovy sdk
    compile "com.android.tools.build:gradle:2.3.1"
    compile 'org.javassist:javassist:3.20.0-GA'
    compile 'org.aspectj:aspectjtools:1.8.1'
}

repositories {
    jcenter()
}

新建插件类 JavassistPlugin

public class JavassistPlugin implements Plugin<Project> {

    void apply(Project project) {
        def log = project.logger
        log.error "========================";
        log.error "Javassist开始修改Class!";
        log.error "========================";
        log.error "========================"+ project.getClass().getName();
        project.android.registerTransform(new PreDexTransform(project))
    }
}

遍历输入类,注入代码后输入给 build 的下一步(class -> dex)

public class PreDexTransform extends Transform {
    Project mProject

    public PreDexTransform(Project project) {
        mProject = project
    }

    // Transfrom在Task列表中的名字
    // TransfromClassesWithPreDexForXXXX
    @Override
    String getName() {
        return "PreDex"
    }

    @Override
    Set.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    //指定 Transform 的作用范围
    @Override
    Set.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    @Override
    boolean isIncremental() {
        return false
    }

    @Override
    void transform(Context context, Collection inputs, Collection referencedInputs,
                   TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
        log("transform >>>>>")
        //Transform 的 input 有两种类型,目录 和 jar,分开遍历
        inputs.each { TransformInput input->
            input.directoryInputs.each { DirectoryInput directoryInput->
                log("directoryInput name = " + directoryInput.name +", path = " + directoryInput.file.absolutePath)

                JavassistInject.injectDir(directoryInput.file.getAbsolutePath(), "com", mProject)

                def dest = outputProvider.getContentLocation(directoryInput.name,
                        directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)

                //将 input 的目录复制到 output 指定目录
                FileUtils.copyDirectory(directoryInput.file, dest)
            }

            input.jarInputs.each { JarInput jarInput ->

                log("jarInput name = " + jarInput.name +", path = " + jarInput.file.absolutePath)

                JavassistInject.injectDir(jarInput.file.getAbsolutePath(), "com", mProject)

                //重命名输出文件(同目录 copyFile 会冲突)
                def jarName = jarInput.name
                def md5Name = jarInput.file.hashCode()
                if(jarName.endsWith(".jar")){
                    jarName = jarName.substring(0, jarName.length() - 4)
                }
                def dest = outputProvider.getContentLocation(jarName + md5Name,
                        jarInput.contentTypes, jarInput.scopes, Format.JAR)
                FileUtils.copyFile(jarInput.file, dest)
            }
        }
    }

    void log(String log){
        mProject.logger.error(log)
    }

}

具体的代码注入

public class JavassistInject {

    public static final String JAVA_ASSIST_APP = "com.meyhuan.applicationlast.MyApp"
    public static final String JAVA_ASSIST_MOBCLICK = "com.umeng.analytics.MobclickAgent"

    private final static ClassPool pool = ClassPool.getDefault()

    public static void injectDir(String path, String packageName, Project project) {
        pool.appendClassPath(path)
        String androidJarPath = project.android.bootClasspath[0].toString()
        log("androidJarPath: " + androidJarPath, project)
        pool.appendClassPath(androidJarPath)
        importClass(pool)
        File dir = new File(path)
        if(!dir.isDirectory()){
            return
        }
        dir.eachFileRecurse { File file->
            String filePath = file.absolutePath
            log("filePath : " + filePath, project)
            if(filePath.endsWith(".class") && !filePath.contains('R$')
                    && !filePath.contains('R.class') && !filePath.contains("BuildConfig.class")){
                log("filePath my : " + filePath, project)
                int index = filePath.indexOf(packageName);
                boolean isMyPackage = index != -1;
                if(!isMyPackage){
                    return
                }
                String className = JavassistUtils.getClassName(index, filePath)
                log("className my : " + className, project)
                CtClass c = pool.getCtClass(className)
                log("CtClass my : " + c.getSimpleName() , project)
                for(CtMethod method : c.getDeclaredMethods()){
                    log("CtMethod my : " + method.getName() , project)
                    //找到 onClick(View) 方法
                    if(checkOnClickMethod(method)){
                        log("checkOnClickMethod my : " + method.getName() , project)
                        injectMethod(method)
                        c.writeFile(path)
                    }
                }
            }
        }

    }

    private static boolean checkOnClickMethod(CtMethod method ){
        return method.getName().endsWith("onClick")  && method.getParameterTypes().length == 1 && method.getParameterTypes()[0].getName().equals("android.view.View") ;
    }

    private static void injectMethod(CtMethod method){
        method.insertAfter("System.out.println((\$1).getTag());")
        method.insertAfter("MobclickAgent.onEvent(MyApp.getInstance(), (\$1).getTag().toString());")
    }

    private static void log(String msg, Project project){
        project.logger.log(LogLevel.ERROR, msg)
    }

    private static void importClass(ClassPool pool){
        pool.importPackage(JAVA_ASSIST_APP)
        pool.importPackage(JAVA_ASSIST_MOBCLICK)
    }

通过这两步之后再我们的代码里面的 OnClickListener 实现类的 onClick 方法里面就会多出友盟统计的代码 MobclickAgent.onEvent(MyApp.getInstance(), v.getTag().toString());
具体源码

四、总结

上面只是简单的实现了 onClick 事件的统计的功能,还需要完善的地方还有很多,这里只是提供了一个参考的方案。这里总结下几个要点
- 通过 Hook LayoutInflater 的方式,遍历所有的 VIew 并且一一将 ID 设置到 Tag 里面
- 通过 Javassist 将统计的代码 注入到 onClick 方法里面,获取它的 ID,并且上传统计
- 手动的将 View ID 和 对应的 View 事件的描述 对应起来

(由于时间的关系写的比较粗糙,有问题请留言)

你可能感兴趣的:(Android)