目錄
1.1先看下爲什麼採用View.AccessibilityDelegate方式,通過View源碼如何執行點擊事件:
1.2完成替換View中AccessibilityDelegate類,實現sendAccessibilityEvent(this, eventType)方法
2.3.1Activity及Fragment相關hook接受,點擊事件接受
手動無埋點的方式,效率低,成本高,見效慢,故開發一套sdk自動採集pv,click等事件;
實現無埋點主流方案有幾下幾種:
1.View.AccessibilityDelegate
1.1先看下爲什麼採用View.AccessibilityDelegate方式,通過View源碼如何執行點擊事件:
public boolean performClick() {
final boolean result;
final ListenerInfo li = mListenerInfo;
if (li != null && li.mOnClickListener != null) {
playSoundEffect(SoundEffectConstants.CLICK);
li.mOnClickListener.onClick(this);
result = true;
} else {
result = false;
}
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
return result;
}
第一步:調用View設置的點擊事件實例onClick()回調方法,View接受到點擊事件回調執行登錄,註冊,播放視頻等操作;
ListenerInfo.mOnClickListener.onClick(this);
第二步:發送給定類型的可訪問性事件,AccessibilityEvent定義了可訪問的事件類型例如:點擊事件-TYPE_VIEW_CLICKED,長按事件-TYPE_VIEW_LONG_CLICKED等;
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
public void sendAccessibilityEvent(int eventType) {
if (mAccessibilityDelegate != null) {
mAccessibilityDelegate.sendAccessibilityEvent(this, eventType);
} else {
sendAccessibilityEventInternal(eventType);
}
}
通過如上代碼我們發現最後調用AccessibilityDelegate實例對象方法sendAccessibilityEvent(this, eventType),那麼是否可以重寫AccessibilityDelegate類和sendAccessibilityEvent(this, eventType)來完成埋點操作呢?
public void setAccessibilityDelegate(@Nullable AccessibilityDelegate delegate) {
mAccessibilityDelegate = delegate;
}
我們發現可以自己手動設置AccessibilityDelegate實例替換View中現有AccessibilityDelegate實例對象,在自定義AccessibilityDelegate類sendAccessibilityEvent(this, eventType)完成埋點操作;
1.2完成替換View中AccessibilityDelegate類,實現sendAccessibilityEvent(this, eventType)方法
在Application提供了監聽Activity生命週期的方法registerActivityLifecycleCallbacks(),在生命週期回調onActivityResumed()方法中爲RootView下的所有視圖設置自己實現AccessibilityDelegate類進行埋點;
public void initActivityLifeCycle(){
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
@Override
public void onActivityResumed(Activity activity) {
sendLog(activity, "onActivityResumed");
ViewGroup viewGroup = (ViewGroup) activity.getWindow().getDecorView().findViewById(android.R.id.content);
mOnGlobalLayoutListener = new MOnGlobalLayoutListener(viewGroup);
viewGroup.getViewTreeObserver().addOnGlobalLayoutListener(mOnGlobalLayoutListener);
}
@Override
public void onActivityPaused(Activity activity) {
sendLog(activity, "onActivityPaused");
ViewGroup viewGroup = (ViewGroup) activity.getWindow().getDecorView().findViewById(android.R.id.content);
viewGroup.getViewTreeObserver().removeGlobalOnLayoutListener(mOnGlobalLayoutListener);
mOnGlobalLayoutListener = null;
}
});
}
//遍歷設置AccessibilityDelegate實現類
public void setAccessibilityDelegate(ViewGroup viewGroup){
int childCount = viewGroup.getChildCount();
for(int i=0; i<childCount; i++){
if(viewGroup.getChildAt(i) instanceof ViewGroup){
setAccessibilityDelegate((ViewGroup) viewGroup.getChildAt(i));
}else{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) {
if(viewGroup.getChildAt(i).hasOnClickListeners()){
BuryingPointAccessibilityDelegate accessibilityDelegate = new BuryingPointAccessibilityDelegate();
viewGroup.getChildAt(i).setAccessibilityDelegate(accessibilityDelegate);
}
}
}
}
}
private class MOnGlobalLayoutListener implements ViewTreeObserver.OnGlobalLayoutListener{
private ViewGroup viewGroup;
public MOnGlobalLayoutListener(ViewGroup viewGroup){
this.viewGroup = viewGroup;
}
@Override
public void onGlobalLayout() {
setAccessibilityDelegate(viewGroup);
}
}
private MOnGlobalLayoutListener mOnGlobalLayoutListener;
埋點實現:
/**
* AccessibilityDelegate實現類,完成埋點操作
*/
public class BuryingPointAccessibilityDelegate extends View.AccessibilityDelegate {
@Override
public void sendAccessibilityEvent(View host, int eventType) {
super.sendAccessibilityEvent(host, eventType);
//埋點
sendLog(host, eventType);
}
private void sendLog(View host, int eventType) {
if(eventType == AccessibilityEvent.TYPE_VIEW_CLICKED){
Log.d(ViewPathUtil.getViewPath(host), "AccessibilityEvent.TYPE_VIEW_CLICKED");
}
//...
}
}
MainActivity[OneFragment]:LinearLayout/FrameLayout[0]/LinearLayout[0]/AppCompatTextView[0]
AccessibilityEvent.TYPE_VIEW_CLICKED
注意事項:
a.跟視圖是指的android.R.id.content,activity.getWindow().getDecorView().findViewById(android.R.id.content);
b.需要爲跟視圖添加OnGlobalLayoutListener全部佈局變化監聽器,方便視圖變化時重新設置實現AccessibilityDelegate類進行埋點,viewGroup.getViewTreeObserver().addOnGlobalLayoutListener(mOnGlobalLayoutListener); 在onActivityPaused()方法要記得移除OnGlobalLayoutListener;
缺點:
a.獲取的是Activity跟視圖重新設置了AccessibilityDelegate類進行埋點,無法爲遊離Activity上的Dialog,PopWindow進行埋點;
b.viewGroup.getChildAt(i).hasOnClickListeners(),hasOnClickListeners()需要api版本爲15;
c.每次視圖有變化遍歷所有的視圖,重新設置AccessibilityDelegate類進行埋點,耗費性能;
處理替換AccessibilityDelegate方式,也可以採用Hook方式用OnClickListener代理類替換View下ListenerInfo持有的OnClickListener實例方式實現埋點:
參考:https://blog.csdn.net/ahou2468/article/details/106503190
2.gradle插件字節碼插裝
通過修改字節碼的方式進行埋點效果如下:
Activity
protected void onCreate(@Nullable Bundle var1) {
InterceptEventHanlder.activityOnCreate(this);
super.onCreate(var1);
}
public void onClick(View var1) {
InterceptEventHanlder.onClick(var1);
switch(var1.getId()) {}
}
通過自定義gradle插件攔截View的onClick方法及Activity,fragment生命週期方法,插入自定義的採集方法,從而監聽pv,click事件;
2.1Android下apk編譯流程
通過上圖可以看出,我們就是在class文件打包到dex文件的過程中增加transform任務,執行插入代碼;
2.2編寫gradle插件模塊(groovy文件實現)
自定義插件參考:https://blog.csdn.net/ahou2468/article/details/106675881
groovy文件編寫可以當Java寫;
2.2.1工程下新建buildSrc模塊(系統保留名稱)
2.2.2編寫插件
import com.android.build.gradle.BaseExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
/**
* @author harvie
*/
class NoTracePointPlugin implements Plugin<Project>{
@Override
void apply(Project project) {
project.extensions.create(ClassModifyUtil.CONFIG_NAME,NoTracePointPluginParams)
registerTransform(project)
}
def static registerTransform(Project project){
BaseExtension extension = project.extensions.getByType(BaseExtension)
NoTracePointTransform transform = new NoTracePointTransform(project)
extension.registerTransform(transform)
}
}
其中apply方法中的project對象用於讀取build.gradle(例如:模塊module的app)文件的一些配置信息,將自定義的transform類註冊進去後,執行工程編譯命令時就會執行自定義的transform中的代碼;
2.2.3編寫Transform
import com.android.build.api.transform.*
import com.android.build.gradle.BaseExtension
import com.android.build.gradle.internal.pipeline.TransformManager
import groovy.io.FileType
import org.gradle.api.Project
import java.util.jar.JarEntry
import java.util.jar.JarFile
/**
* @author harvie
*/
class NoTracePointTransform extends Transform{
private static Project project
private static BaseExtension android
//需要掃描的目標包名集合
private static Set<String> targetPackages = new HashSet<>()
NoTracePointTransform(Project project) {
this.project = project
this.android = project.extensions.getByType(BaseExtension)
ClassModifyUtil.project = project
ClassModifyUtil.noTracePointPluginParams = project.noTracePoint
}
@Override
String getName() {
//transform任務名稱,隨意
return "noTracePointTransform"
}
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
//輸入類型 class文件
return TransformManager.CONTENT_CLASS
}
@Override
Set<? super QualifiedContent.Scope> getScopes() {
//作用域 全局工程
return TransformManager.SCOPE_FULL_PROJECT
}
@Override
boolean isIncremental() {
//是否增量構建
return true
}
@Override
void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
//核心操作
long t1 = System.currentTimeMillis()
HLog.i("transform start: "+t1)
// 取build.gradle中配置包名數組
HashSet<String> tempPackages = project.noTracePoint.targetPackages
//此處省略部分非核心代碼
// 開始遍歷全局jar包
inputs.each {TransformInput input->
input.jarInputs.each { JarInput jarInput->
/** 獲得輸出文件*/
File dest = outputProvider.getContentLocation(jarInput.file.absolutePath, jarInput.contentTypes, jarInput.scopes, Format.JAR)
File modifiedJar = null
modifiedJar = ClassModifyUtil.modifyJarFile(jarInput.file,context.getTemporaryDir(),android,targetPackages)
if (modifiedJar == null){
modifiedJar = jarInput.file
}
// 因爲當前transform的輸出文件會成爲下一個任務的輸入,故需要將修改的文件copy到輸出目錄
FileUtils.copyFile(modifiedJar,dest)
}
//遍歷目錄
input.directoryInputs.each { DirectoryInput directoryInput->
File dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
File dirFile = directoryInput.file
if (dirFile){
HashMap modifyMap = new HashMap()
dirFile.traverse(type: FileType.FILES,nameFilter:~/.*\.class/){
File classFile ->
//此處省略部分非核心代碼,與上面修改class類似
}
}
}
}
long t2 = System.currentTimeMillis()
HLog.i("transform end 耗時: "+(t2-t1)+"毫秒")
}
}
2.2.4字節碼修改
import org.objectweb.asm.ClassVisitor
import org.objectweb.asm.MethodVisitor
import org.objectweb.asm.Opcodes
/**
* @author harvie
* asm 字節碼操作工具類
*/
class HClassVisitor extends ClassVisitor{
private String[] interfaces
private String superName
private String className
private ClassVisitor classVisitor
//記錄已訪問的fragment方法
private HashSet<String> methodName = new HashSet<>();
HClassVisitor(ClassVisitor cv){
super(Opcodes.ASM5,cv)
this.classVisitor = cv
}
/**
* 訪問類頭部信息
* @param version
* @param access
* @param name
* @param signature
* @param superName
* @param interfaces
*/
@Override
void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
this.interfaces = interfaces
this.superName = superName
this.className = name.contains('$')?name.substring(0,name.indexOf('$')):name
super.visit(version, access, name, signature, superName, interfaces)
}
/**
* 訪問類方法
* @param access
* @param name
* @param desc
* @param signature
* @param exceptions
* @return
*/
@Override
MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
MethodVisitor mv = cv.visitMethod( access, name, desc, signature, exceptions)
String nameDesc = name+desc
return new MethodVisitor(this.api, mv){
@Override
void visitCode() {
//點擊事件
if (interfaces!=null && interfaces.length>0){
MethodCode methodCode = InterceptEventConfig.interfaceMethods.get(nameDesc)
if(methodCode!=null){
mv.visitVarInsn(Opcodes.ALOAD, 1)
mv.visitMethodInsn(Opcodes.INVOKESTATIC, methodCode.owner, methodCode.agentName, methodCode.agentDesc, false)
}
}
//activity生命週期hook
if (instanceOfActivity(superName)){
MethodCode methodCode = InterceptEventConfig.activityMethods.get(nameDesc)
if (methodCode!=null){
methodName.add(nameDesc)
mv.visitVarInsn(Opcodes.ALOAD, 0)
mv.visitMethodInsn(Opcodes.INVOKESTATIC, methodCode.owner, methodCode.agentName, methodCode.agentDesc, false)
}
}
super.visitCode()
}
@Override
void visitInsn(int opcode) {
//fragment 頁面hook
if (instanceOfFragemnt(superName)) {
MethodCode methodCode = InterceptEventConfig.fragmentMethods.get(nameDesc)
if (methodCode != null) {
methodName.add(nameDesc)
if (opcode == Opcodes.RETURN) {
mv.visitVarInsn(Opcodes.ALOAD, 0)
mv.visitVarInsn(Opcodes.ILOAD, 1)
if (superName == 'android/app/Fragment'){
mv.visitMethodInsn(Opcodes.INVOKESTATIC, methodCode.owner, methodCode.agentName, '(Landroid/app/Fragment;Z)V', false)
}else {
mv.visitMethodInsn(Opcodes.INVOKESTATIC, methodCode.owner, methodCode.agentName, '(Landroid/support/v4/app/Fragment;Z)V', false)
}
}
}
}
super.visitInsn(opcode)
}
}
}
@Override
void visitEnd() {
if (instanceOfActivity(superName)){
//防止activity沒有複寫oncreate方法,再次檢測添加
Iterator iterator = InterceptEventConfig.activityMethods.keySet().iterator()
while (iterator.hasNext()) {
String key = iterator.next()
MethodCode methodCell = InterceptEventConfig.activityMethods.get(key)
if (methodName.contains(key)) {
continue
}
//添加需要的生命週期方法
if (key == 'onCreate(Landroid/os/Bundle;)V' || key == 'onResume()V'){
MethodVisitor methodVisitor = classVisitor.visitMethod(Opcodes.ACC_PUBLIC, methodCell.name, methodCell.desc, null, null)
methodVisitor.visitCode()
methodVisitor.visitVarInsn(Opcodes.ALOAD, 0)
methodVisitor.visitVarInsn(Opcodes.ALOAD, 0)
if (key == 'onCreate(Landroid/os/Bundle;)V') {
methodVisitor.visitVarInsn(Opcodes.ALOAD, 1)
}
methodVisitor.visitMethodInsn(Opcodes.INVOKESPECIAL, superName, methodCell.name, methodCell.desc, false)
methodVisitor.visitMethodInsn(Opcodes.INVOKESTATIC, methodCell.owner, methodCell.agentName, methodCell.agentDesc, false)
methodVisitor.visitInsn(Opcodes.RETURN)
methodVisitor.visitMaxs(2, 2)
methodVisitor.visitEnd()
}
}
}else if (instanceOfFragemnt(superName)){
Iterator iterator = InterceptEventConfig.fragmentMethods.keySet().iterator()
while (iterator.hasNext()){
String key = iterator.next()
MethodCode methodCell = InterceptEventConfig.fragmentMethods.get(key)
if (methodName.contains(key)){
continue
}
//添加需要的生命週期方法
MethodVisitor methodVisitor = classVisitor.visitMethod(Opcodes.ACC_PUBLIC, methodCell.name, methodCell.desc, null, null)
methodVisitor.visitCode()
methodVisitor.visitVarInsn(Opcodes.ALOAD, 0)
methodVisitor.visitVarInsn(Opcodes.ILOAD, 1)
methodVisitor.visitMethodInsn(Opcodes.INVOKESPECIAL, superName, methodCell.name, methodCell.desc, false)
methodVisitor.visitVarInsn(Opcodes.ALOAD, 0)
methodVisitor.visitVarInsn(Opcodes.ILOAD, 1)
if (superName == 'android/app/Fragment'){
methodVisitor.visitMethodInsn(Opcodes.INVOKESTATIC, methodCell.owner, methodCell.agentName, '(Landroid/app/Fragment;Z)V', false)
}else {
methodVisitor.visitMethodInsn(Opcodes.INVOKESTATIC, methodCell.owner, methodCell.agentName, '(Landroid/support/v4/app/Fragment;Z)V', false)
}
methodVisitor.visitInsn(Opcodes.RETURN)
methodVisitor.visitMaxs(2, 2)
methodVisitor.visitEnd()
}
}
super.visitEnd()
}
}
下面是字節碼操作後的代碼示例:
public class MainActivity extends Activity {
public MainActivity() {
}
protected void onCreate(Bundle var1) {
//這一行就是通過插件植入的代碼
ActivityHelper.onCreate(this);
super.onCreate(var1);
this.setContentView(2131296284);
((TextView)this.findViewById(2131165331)).setOnClickListener(new 0(this));
}
public void onResume() {
super.onResume();
//這一行就是通過插件植入的
ActivityHelper.onResume(this);
}
}
2.3編寫事件處理模塊(Java)
2.3.1Activity及Fragment相關hook接受,點擊事件接受
public class InterceptEventHanlder {
private static String TAG = "tracepoint";
//------------------- activity 事件接收
public static void activityOnCreate(Activity activity){
Log.e(TAG,activity.getClass().getName());
}
public static void activityOnResume(Activity activity) {
Log.e(TAG,activity.getClass().getName());
}
//------------------- fragment 事件接收
public static void setUserVisibleHint(Fragment fragment, boolean visiable){
if (visiable){
Log.e(TAG,"pv:"+fragment.getClass().getName());
}
}
public static void onHiddenChanged(Fragment fragment,boolean hidden){
if (!hidden){
Log.e(TAG,"pv:"+fragment.getClass().getName());
}
}
public static void setUserVisibleHint(android.support.v4.app.Fragment fragment,boolean visiable){
if (visiable){
Log.e(TAG,"pv:"+fragment.getClass().getName());
}
}
public static void onHiddenChanged(android.support.v4.app.Fragment fragment,boolean hidden){
if (!hidden){
Log.e(TAG,"pv:"+fragment.getClass().getName());
}
}
//------------------- click 事件接收
public static void onClick(View view){
try {
//以下ViewPath工具類可從源碼app模塊中獲取
//Activity activity = ViewPathUtil.getActivity(view);
String path = ViewPathUtil.getViewPath(view);
Log.e(TAG,"viewPath:"+path);
}catch (Exception e){e.printStackTrace();}
}
}
3.保證View的唯一路徑
package fan.fragmentdemo.notracepoint;
import android.app.Activity;
import android.content.Context;
import android.content.ContextWrapper;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
/**
* view唯一ID生成器
*/
public class ViewPathUtil {
//設置Fragment下View的Tag對應的key
public static final int FRAGMENT_NAME_TAG = 0xff000001;
/**
* 獲取view的頁面唯一值
* @return
*/
public static String getViewPath(View view){
Activity activity = getActivity(view);
String pageName = (String)view.getTag(FRAGMENT_NAME_TAG);
if(TextUtils.isEmpty(pageName)){
pageName = activity.getClass().getSimpleName();
}else{
pageName = activity.getClass().getSimpleName()+"["+pageName+"]";
}
String vId = getViewId(view);
return pageName+":"+ vId;//MD5Util.md5(vId);
}
/**
* 獲取頁面名稱
* @param view
* @return
*/
public static Activity getActivity(View view){
Context context = view.getContext();
while (context instanceof ContextWrapper){
if (context instanceof Activity){
return ((Activity)context);
}
context = ((ContextWrapper) context).getBaseContext();
}
return null;
}
/**
* 獲取view唯一id,根據xml文件內容計算
* @param currentView
* @return
*/
private static String getViewId(View currentView){
StringBuilder sb = new StringBuilder();
//當前需要計算位置的view
View view = currentView;
ViewParent viewParent = view.getParent();
while (viewParent!=null && viewParent instanceof ViewGroup){
ViewGroup tview = (ViewGroup) viewParent;
if(((View)view.getParent()).getId() == android.R.id.content){
sb.insert(0,view.getClass().getSimpleName());
break;
}else{
int index = getChildIndex(tview,view);
sb.insert(0,"/"+view.getClass().getSimpleName()+"["+(index==-1?"-":index)+"]");
}
viewParent = tview.getParent();
view = tview;
}
Log.e("Path", sb.toString());
return sb.toString();
}
/**
* 計算當前 view在父容器中相對於同類型view的位置
*/
private static int getChildIndex(ViewGroup viewGroup,View view){
if (viewGroup ==null || view == null){
return -1;
}
String viewName = view.getClass().getName();
int index = 0;
for (int i = 0;i < viewGroup.getChildCount();i++){
View el = viewGroup.getChildAt(i);
String elName = el.getClass().getName();
if (elName.equals(viewName)){
//表示同類型的view
if (el == view){
return index;
}else {
index++;
}
}
}
return -1;
}
}
以上埋點的基本流程,參考代碼:https://github.com/harvie1208/TracePoint
4.總結
a.gradle插件話埋點無需要手動埋點,採用字節碼埋點,大大減少手動埋點的工作量;
b.gradle插件話埋點不需要哦後期查補埋點;
c.一些業務數據需要手動埋點;
d.舊的頁面項目升級時頁面結構發生變化可能導致View的唯一路徑發生變化,後臺需要重新爲此View命名;
參考:
https://juejin.im/post/5dae95c4f265da5bb7466357#heading-2