Android全埋点技术方案——代理View.OnClickListener

关键技术

  • android.R.id.content
    android.R.id.content对应的视图是一个FrameLayout布局,它目前只有一个子元素,就是我们平时开发时,在onCreate方法中通过setContentView设置的View。换句话就是,当我们在layout文件中设置一个布局文件时,实际上该布局会被一个FrameLayout容器所包含,这个FrameLayout容器的android:id属性值就是android.R.id.content。至于,为什么是FrameLayout布局,官方文档的解释:通常,FrameLayout布局只能包含一个子视图,这是因为它很难确保它的子视图可以适配不同的屏幕大小而又不互相重叠。

需要注意的是,在不同的SDK版本下,android.R.id.content所指的显示区域有所不同,具体差异如下:

  • SDK14+(Native ActionBar):该显示区域指的是ActionBar下面的那部分。
  • Support Library Revision lower than 19:使用AppCompat,则显示区域包含ActionBar。
  • Support Library Revision 19(or greater):与第一种一致。

所以,如果不使用support library或者使用support library的最新版本,则android.R.id.content所指的区域都是ActionBar以下的内容。

  • DecorView
    我们通过android.R.id.content获取到的RootView是不包含Activity标题栏的,也就是不包含MenuItem的父容器,因此我们去遍历RootView时是无法遍历到MenuItem控件的,因此无法代理其OnClickListener对象。鉴于此,我们可以使用DecorView来实现。
    DecorView是整个Window界面的最顶层的View。DecorView只有一个子元素为LinearLayout,代表整个Window界面,它包含通知栏、标题栏、内容显示栏三块区域。这个LinearLayout里含有两个FrameLayout子元素。第一个FrameLayout为标题栏显示界面,第二个FrameLayout为内容栏显示界面,就是android.R.id.content。

  • ViewTreeObserver.OnGlobalLayoutListener
    当一个视图树的布局发生变化时,如果我们给当前的View设置了ViewTreeObserver.OnGlobalLayoutListener监听器,就可以被ViewTreeObserver.OnGlobalLayoutListener监听器监听到(实际上是触发了它的onGlobalLayout回调方法)。所以,基于这个原理,我们可以给当前Activity的RootView也添加一个ViewTreeObserver.OnGlobalLayoutListener监听器,当收到onGlobalLayout方法回调时(即视图树的布局发生变化,比如新的View被创建),我们重新去遍历一次RootView,即可找到那些没有被代理过OnclickListener对象的View。

有了上面这些概念,我们便可以通过代理onClickListener对象来实现全埋点。

案例如下

package com.mvp.myapplication

import android.app.Activity
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver.OnGlobalLayoutListener
import android.widget.*
import androidx.appcompat.app.AppCompatActivity
import java.lang.reflect.Field
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Method


class MainActivity : AppCompatActivity() {
    private lateinit var tv: TextView
    private var listener: OnGlobalLayoutListener? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        tv = findViewById(R.id.tv)

        tv.setOnClickListener {
            Log.e("MainActivity", "原始数据")
        }

        val rootView = getRootViewFromActivity(this, true)
        listener = OnGlobalLayoutListener {
            delegateViewsOnClickListener(this, rootView)
        }

    }

    override fun onResume() {
        super.onResume()
        val rootView = getRootViewFromActivity(this, true)
        rootView.viewTreeObserver.addOnGlobalLayoutListener(listener)
    }

    override fun onStop() {
        super.onStop()
        if (Build.VERSION.SDK_INT >= 16) {
            val rootView = getRootViewFromActivity(this, true)
            rootView.viewTreeObserver.removeOnGlobalLayoutListener(listener)
        }
    }

    private fun getRootViewFromActivity(activity: Activity, decorView: Boolean): ViewGroup {
        return if (decorView) {
            activity.window.decorView as ViewGroup
        } else {
            activity.findViewById(android.R.id.content)
        }
    }

    private fun delegateViewsOnClickListener(context: Context?, view: View?) {
        if (context == null || view == null) {
            return
        }
        //获取当前 view 设置的 OnClickListener
        val listener: View.OnClickListener? = getOnClickListener(view)

        //判断已设置的 OnClickListener 类型,如果是自定义的 WrapperOnClickListener,说明已经被 hook 过,防止重复 hook
        if (listener != null && listener !is WrapperOnClickListener) {
            //替换成自定义的 WrapperOnClickListener
            view.setOnClickListener(WrapperOnClickListener(listener))
        }

        //如果 view 是 ViewGroup,需要递归遍历子 View 并 hook
        if (view is ViewGroup) {
            val viewGroup = view
            val childCount = viewGroup.childCount
            if (childCount > 0) {
                for (i in 0 until childCount) {
                    val childView = viewGroup.getChildAt(i)
                    //递归
                    delegateViewsOnClickListener(context, childView)
                }
            }
        }
    }

    private fun getOnClickListener(view: View): View.OnClickListener? {
        val hasOnClick = view.hasOnClickListeners()
        if (hasOnClick) {
            try {
                val viewClazz = Class.forName("android.view.View")
                val listenerInfoMethod: Method = viewClazz.getDeclaredMethod("getListenerInfo")
                if (!listenerInfoMethod.isAccessible) {
                    listenerInfoMethod.isAccessible = true
                }
                val listenerInfoObj: Any = listenerInfoMethod.invoke(view)
                val listenerInfoClazz = Class.forName("android.view.View\$ListenerInfo")
                val onClickListenerField: Field =
                    listenerInfoClazz.getDeclaredField("mOnClickListener")
                if (!onClickListenerField.isAccessible()) {
                    onClickListenerField.setAccessible(true)
                }
                return onClickListenerField.get(listenerInfoObj) as View.OnClickListener?
            } catch (e: ClassNotFoundException) {
                e.printStackTrace()
            } catch (e: NoSuchMethodException) {
                e.printStackTrace()
            } catch (e: InvocationTargetException) {
                e.printStackTrace()
            } catch (e: IllegalAccessException) {
                e.printStackTrace()
            } catch (e: NoSuchFieldException) {
                e.printStackTrace()
            }
        }
        return null
    }

    internal class WrapperOnClickListener(private val source: View.OnClickListener?) :
        View.OnClickListener {
        override fun onClick(view: View) {
            //调用原有的 OnClickListener
            try {
                source?.onClick(view)
            } catch (e: Exception) {
                e.printStackTrace()
            }

            //插入埋点代码
            Log.e("MainActivity","埋点数据")
        }
    }
}

缺点

  • 由于使用反射,效率比较低,对App的整体性能有一定的影响,也可能会引入兼容性方面的风险
  • 无法直接支持采集游离于activity之上的View的点击,比如Dialog、PopupWindow等
  • View.hasOnClickListeners()要求API 15+
  • removeOnGlobalLayoutListener要求API16+
  • Application.ActivityLifecycleCallbacks要求API14+
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值