android onclick函数进不去_云音乐 Android 自动埋点实践

纵观近年来比较成熟的自动埋点方案,存在着XPath维护成本高、业务数据获取难的问题。

经过对比各种方案的优劣及深挖Java虚拟机字节码特性,我们总结出了一套更适用于云音乐的自动埋点方案。

1 背景

一些业务逻辑并不复杂的埋点,如果交由人工来做,一来比较机械,二来容易漏埋错埋,因此有必要抽取共性,实现埋点的自动化。

一般而言,自动埋点类型分为两大类:页面曝光与控件的点击、曝光。

  • Activity页面曝光
    Activity页面曝光,可以通过监听Activity生命周期实现自动化。
  • Fragment页面曝光
    Fragment页面曝光,可以通过监听Fragment显隐状态实现自动化。
    是否可见需要综合考虑多种因素。比如一个Fragment同时满足Activity处于前台、Tab被选中、Fragment被添加、Fragment没有隐藏、Fragment.View已经Attach等条件时,认为该Fragment真正可见。

相比于页面曝光,控件的点击、曝光自动化更加复杂。下面主要以点击为例进行说明。

2 唯一标识

点击自动化首先要解决的问题是为每一个view设定唯一标识,这个标识要满足区分性和一致性:

  • 区分性:将一个view与界面上的其他view区分开来;
  • 一致性:一个view无论界面布局如何动态变化,或者说多次进入同一个页面,或者版本迭代,这个标识都要保持不变。

2.1 已有方案分析

我们分析了已有的唯一标识方案xpath。一个xpath示例如下:

7790711b47e3bf1de1c93e0383d5531b.png

它通过遍历view tree,生成一条控件的全路径。

  • 优点:对开发人员来说无感知;上线后可以新增埋点。
  • 缺点:界面改版、动态增删容易引起路径变动,也就是破坏了一致性。

由于我们更注重唯一标识的一致性,因此我们没有采用xpath方案。

2.2 我们的方案

考虑到一般参与交互的控件,开发人员都会在布局中指定android:id属性,不同的控件对应不同的命名,且一般不会频繁变更命名,示例如下:

<com.netease.cloudmusic.theme.ui.TrackInteractiveTextView
    android:id="@+id/trackRepostBtn"// 给转发按钮命名
    style="@style/trackInteractTv"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:minWidth="80dp"
    android:paddingLeft="20dp"
    app:needApplyDrawableColor="true"
    app:normalDrawableColor="@color/iconColorNormal2" />

<com.netease.cloudmusic.theme.ui.TrackInteractiveTextView
    android:id="@+id/trackCmtBtn"// 给评论按钮命名
    style="@style/trackInteractTv"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:layout_marginLeft="80dp"
    android:minWidth="80dp"
    android:paddingLeft="20dp"
    app:needApplyDrawableColor="true"
    app:normalDrawableColor="@color/iconColorNormal2" />

如果布局被复用,例如列表场景,可以引入AdapterPosition进行区分:

9f0791530fd6b1d62fbe201ea0fcf7d6.png

因此我们的唯一标识的组成是:

ActivityName + idName + position。

相对于xpath方案,这一组合省略了xpath的中间节点,因此中间节点的变更不会影响到当前控件,一定程度上保证了唯一标识的一致性。

3 事件拦截

标识了控件之后,我们要解决的问题是,当某个控件被点击了,要拿到这个时机进行点击事件上报。

我们分析了已有的事件拦截方案:

  • 代理监听
  • 编译期插桩

考虑到代理监听方案涉及到控件树遍历,我们选择了编译期插桩的方案。

我们采用Android gradle插件提供的Transform api在编译期,class打包成dex之前,插入自定义Transform,在自定义的Transform中调用ASM核心API对class文件的字节码进行扫描,当扫描到目标事件响应函数时,在函数头部插入自定义代码。这一流程如下图所示:

afcd664a6bd1882bff224c49381b9638.png

例如,在onClick中插入自定义代码:

插桩前java源码

final Comment comment = new Comment();
this.findViewById(R.id.btn2).setOnClickListener(new View.OnClickListener(){
    @Override
    public void onClick(View v) {
        event2.getEndTime();
        event.setId(a);
        comment.setCommentId(0);
        commentEx.setContent("dfd");
    }
});

反编译插桩后的class,可以看到函数体多了一句PluginAgent.onClick的调用,传入了控件实例view。

this.findViewById(2131427435).setOnClickListener(new OnClickListener() {
    public void onClick(View var1) {
        PluginAgent.onClick(var1);// 此处是我们插入的代码        
        MainActivity.this.event2.getEndTime();
        MainActivity.this.event.setId((long)MainActivity.this.a);
        var2.setCommentId(0L);
        MainActivity.this.commentEx.setContent("dfd");
    }
});

PluginAgent.onClick内部进行具体点击上报行为。

4 业务数据搜集

由于前述编译期插桩仅拿到了控件实例view,上报的行为数据一般只是view自身属性,因此,我们仍需更进一步进行业务数据搜集。

4.1 已有方案分析

我们分析了网易乐得提出的数据路径方案。它的原理是下发要收集的目标数据的一条引用路径,解析这条路径并逐级反射直到拿到目标数据。它定义了一套dsl(领域特定语言)语言来描述数据路径:

60906d96e6a5b166f2dc44a0765fecc5.png

比如,要搜集下面这个列表里每一项的产品id,根据datapath语法,下发的路径应为item.productId:

68a6eb28509e9c39a517844343028690.png

它通过下发配置文件定制要收集的数据,下发的配置示例如下:

//第一部分:描述
PageName:MainActivity
ViewPath:DecorView/.../ViewPager[0]/ButtonFragment[0]/AppCompatButton[0]
EventType:ViewClick
//第二部分:数据路径(当描述符合时,按照此路径取数据)
DataPath:this.context.demoList[5]

由于datapath语义复杂,门槛高,这样的配置文件的输出需要开发人员全程参与。

随着版本迭代,业务代码也在变更,datapath可能需要重新定义,因此需要维护好版本与datapath的对应关系。

此外,它需要沿着datapath逐级反射直到拿到目标数据,有一定的性能损耗。

考虑到以上问题,我们没有选择datapath方案。

4.2 我们的方案

经过一番探索,我们总结出一套自己的业务数据搜集方案。
我们的方案基于这样一个事实,那就是控件关联的数据一般都会在onClick中被引用到。

例如下面的歌单点击事件中,引用了歌单Bean,我们要搜集这个歌单bean,可以转化为搜集onClick中引用的变量。

a438d0354e75d5f23557a2a2f4548d00.png

4.2.1 点击实现场景

那么onClick中引用的变量有什么特点呢?我们分析了点击的两大类实现场景下,Java源码与字节码的对应关系。

  • case 1:类直接实现OnClickListener接口

java源码

public class ActivityA extends Activity implements View.OnClickListener {
    private Comment comment = new Comment();
    private Event event = new Event();
    private int b;

    @Override
    public void onClick(View v) {
        List<Object> obj = new ArrayList<>();
        obj.add(comment);
        obj.add(event);
        comment.setLiked(true);
    }

    ...
}

字节码

public class ActivityA extends Activity  implements View$OnClickListener  {

  // compiled from: ActivityA.java
  public static abstract INNERCLASS View$OnClickListener View OnClickListener

  // 对应java源码中的comment
  private Comment comment

  // 对应java源码中的event
  private Event event

  // 对应java源码中的b
  private int b

  // onClick
  public onClick(View) : void
   L0
    LINENUMBER 22 L0
    NEW ArrayList
    DUP
    INVOKESPECIAL ArrayList.<init>() : void
    ASTORE 2
   L1
    LINENUMBER 23 L1
    ALOAD 2: obj
    ALOAD 0: this
    GETFIELD ActivityA.comment : Comment
    INVOKEINTERFACE List.add(Object) : boolean
    POP
   L2
    LINENUMBER 24 L2
    ALOAD 2: obj
    ALOAD 0: this
    GETFIELD ActivityA.event : Event
    INVOKEINTERFACE List.add(Object) : boolean
    POP
   L3
    LINENUMBER 25 L3
    ALOAD 0: this
    GETFIELD ActivityA.comment : Comment
    ICONST_1
    INVOKEVIRTUAL Comment.setLiked(boolean) : void
   L4
    LINENUMBER 26 L4
    RETURN
   L5
   ...
 } 

通过比对java源码与编译后的字节码,可以发现原java类的成员变量在字节码中都可以拿到。该场景下,若onClick中引用了类的成员变量,搜集这些变量可以转化为搜集类的成员变量。

  • case 2:匿名实现OnClickListener

java源码

public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    Comment comment = new Comment();
    Button btn = new Button(this);
    btn.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            event.setId(a);
            comment.setCommentId(0);        
        }
    });
}

字节码

// 匿名内部类ActivityB$1
class ActivityB$1 implements View$OnClickListener  {

  // compiled from: ActivityB.java
  OUTERCLASS ActivityB onCreate (Bundle) : void

  INNERCLASS ActivityB$1  

  public static abstract INNERCLASS View$OnClickListener View OnClickListener

  // 指向外部类的引用
  final synthetic ActivityB this$0

  // 对应java源码的comment
  private final synthetic Comment val$comment

  // onClick
  public onClick(View) : void
   L0
    LINENUMBER 29 L0
    ALOAD 0: this
    GETFIELD ActivityB$1.this$0 : ActivityB
    INVOKESTATIC ActivityB.access$0(ActivityB) : Event
    ALOAD 0: this
    GETFIELD ActivityB$1.this$0 : ActivityB
    INVOKESTATIC ActivityB.access$1(ActivityB) : int
    I2L
    INVOKEVIRTUAL Event.setId(long) : void
   L1
    LINENUMBER 30 L1
    ALOAD 0: this
    GETFIELD ActivityB$1.val$comment : Comment
    LCONST_0
    INVOKEVIRTUAL Comment.setCommentId(long) : void
   L2
    LINENUMBER 31 L2
    RETURN
   L3
}

编译成字节码后,onClick中引用的变量及指向外部类的引用构成了OnClickListener匿名实现类的成员变量。搜集这些变量可以转化为搜集匿名内部类的成员变量。

通过上述分析,可以认为搜集onClick中引用的变量,进一步转化为搜集OnClickListener实现类的成员变量。

4.2.2 OnClickListener实现类成员变量搜集

由于我们的事件拦截是通过编译期插桩实现的,具体的做法是扫描OnClickListener实现类,在onClick方法中插入代码,可于此时机同时插入成员变量搜集指令。

实现思路概括如下:

  • 方法头部插入
  • 创建ArrayList局部变量obj,遍历类成员变量并添加到列表obj,最后搜集局部变量obj。
  • 约定仅对引用类型(Type.OBJECT)且非静态的成员变量进行搜集

方法执行模型

要想在编译期准确无误地插入字节码指令,我们首先需要了解Java虚拟机方法执行模型。

每个线程都有自己的执行栈,栈由帧组成。每个帧表示一个方法调用。每次调用一个方法时,会将一个新帧压入当前线程的执行栈。当方法返回时,或者是正常返回,或者是因为异常返回,会将这个帧从执行栈中弹出,继续执行发出方法调用的方法(这个方法的帧现在位于栈的顶端)。

10453d23f1322b7a74f2148fede478ec.png

每一帧包括两部分:一个局部变量部分和一个操作数栈部分。局部变量部分包含可根据索引以随机顺序访问的变量。由名字可以看出,操作数栈部分是一个栈,其中包含了供字节代码指令用作操作数的值。这意味着这个栈中的值只能按照“后入先出”顺序访问。不要将操作数栈和线程的执行栈相混淆:执行栈中的每一帧都包含自己的操作数栈。

de744dde040b95c4f8587ae084153736.png

局部变量部分与操作数栈部分的大小取决于方法的代码。这一大小是在编译时计算的,并随字节码指令一起存储在已编译类中。

通过以上分析,可知变量搜集的难点在于变量搜集指令的编写,且要保证插桩前后onClick方法的局部变量表和操作数栈状态一致。

插桩过程分析

下面分析插入代码前后,onClick方法的局部变量表和操作数栈状态。

  • 初始frame状态

如果传递了n个参数给某个实例方法,则当前栈帧会按照约定,依参数传递顺序来接收这些参数,将它们保存到方法的第1个至第n个局部变量之中。按照约定,需要给实例方法传递一个指向该实例的引用作为方法的第0个局部变量。

当调用onClick(view)方法时,Java虚拟机会创建一个新的栈帧,传递给onClick方法的参数值会成为新栈帧中对应局部变量的初始值。即类实例的引用this和传递给onClick方法的参数view,会作为onClick方法栈帧的第0、1个局部变量。初始状态,操作数栈为空。如下图所示:

0afdea7abafca12fc2fdd94ad85103ef.png
  • 插入指令的frame状态

创建ArrayList局部变量obj这句java源码对应4条字节码指令:

3f9c4b9d0db0d830a902e19baa85ffd0.png

每条指令执行后局部变量表和操作数栈状态如下:

c8de0f669938a956ef3f5d25d507adac.png

7b091b4324f00cec584f8a6177b25b44.png

由于分析过程类似,中间的成员变量添加到obj的指令分析就省略了。直接看最后一步:搜集obj。

搜集obj这句的java源码对应3条字节码指令:

28a3ad133828476df9caf9a3394d5897.png

每条指令执行后局部变量表和操作数栈状态如下:

eeb0ffff3ae38b13ae3f6afb7277fafd.png
  • 插桩后frame状态

fcde2644684629bfff42c3f7d064276b.png

插桩后,局部变量表索引为0、1的变量未发生变化;操作数栈恢复成空栈。

要强调的一点是,我们在onClick方法的头部插入的代码,创建了一个obj变量,并保存在索引为2的局部变量表中,这并不会影响到后续代码复用索引为2的局部变量。

ASM插桩代码示例

以上都是针对字节码指令进行分析。实际编码中,我们使用ASM API来操作字节码指令。下面是一个编译期搜集类成员变量的ASM代码示例:

myMv = new MethodLogVisitor(methodVisitor) {
    @Override
    void visitCode() {
        super.visitCode();
        methodVisitor.visitTypeInsn(NEW, "java/util/ArrayList");
        methodVisitor.visitInsn(DUP);
        methodVisitor.visitMethodInsn(INVOKESPECIAL, "java/util/ArrayList", "<init>", "()V", false);
        methodVisitor.visitVarInsn(ASTORE, 2);
        for (int i = 0; i < mFieldNodes.size(); i++) {
            FieldNode fieldNode = mFieldNodes.get(i);
            Type type = Type.getType(fieldNode.desc);
            int staticTag = fieldNode.access & ACC_STATIC;
            if (type.getSort() == Type.OBJECT && staticTag == 0) {
                methodVisitor.visitVarInsn(ALOAD, 2);
                methodVisitor.visitVarInsn(ALOAD, 0);
                methodVisitor.visitFieldInsn(GETFIELD, className, fieldNode.name, fieldNode.desc);
                methodVisitor.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "add", "(Ljava/lang/Object;)Z", true);
                methodVisitor.visitInsn(POP);
            }
        }
        methodVisitor.visitVarInsn(ALOAD, 1);
        methodVisitor.visitVarInsn(ALOAD, 2);
        methodVisitor.visitMethodInsn(INVOKESTATIC, ReWriterConfig.sAgentClassName, "onClick", "(Landroid/view/View;Ljava/util/List;)V", false);
    }
}

变量搜集效果示例

  • case 1:类直接实现OnClickListener接口

下面是一个类直接实现OnClickListener接口的例子:

java源码

public class PlayListFragment extends PlayListAndAlbumFragmentBase implements OnClickListener {

    public static final String TOPLIST_SOARING = "soaring";//飙升榜
    public static final String TOPLIST_NEW = "new";//新歌榜
    public static final String TOPLIST_HOT = "hot";//热歌榜

    private LongSparseArray<SongPrivilege> mSps = null;
    private PlayList mDynamicPlaylistData = null;
    private Ad mCurAd;
    private ViewStub viewStub;
    private View adContainer;
    private CustomThemeTextView adText;
    private CustomThemeIconImageView goIcon;

    @Override
    public void onClick(View v) {
        super.onClick(v);
        switch (v.getId()) {
            case R.id.editBtn:
                editPlayList();
                break;
            default:
                break;
        }
    }

   ...
}

插桩后字节码

public void onClick(View var1) {
    /******此处是我们插入的代码 start******/
    ArrayList var2 = new ArrayList();
    var2.add(this.mSps);
    var2.add(this.mDynamicPlaylistData);
    var2.add(this.mCurAd);
    var2.add(this.viewStub);
    var2.add(this.adContainer);
    var2.add(this.adText);
    var2.add(this.goIcon);
    PluginAgent.onClick(var1, var2);
    /******此处是我们插入的代码 end******/

    super.onClick(var1);
    switch(var1.getId()) {
    case 2131822798:
        this.editPlayList();
    default:
    }
}

插桩后,引用类型的非静态变量都被搜集到。

  • case 2:匿名实现OnClickListener

下面是一个OnClickListener匿名实现的例子:

java源码

private void renderResource(final IVideoAndMvResource timelineData, final int position, final FragmentCallback callback) {
    if (timelineData == null) {
        return;
    }
    FrameLayout.LayoutParams layoutParam = (FrameLayout.LayoutParams) mainVideoPlayerCover.getLayoutParams();
    layoutParam.width = MV_IMAGE_WITH - NeteaseMusicUtils.dip2px(12f);
    layoutParam.height = (int) ((MV_IMAGE_WITH - NeteaseMusicUtils.dip2px(12f)) * MV_IMAGE_RATE);
    NovaImageLoader.loadImage(mainVideoPlayerCover, ImageUrlUtils.getSpecifiedSizeUrl(timelineData.getCoverUrl(), MV_IMAGE_WITH, MV_IMAGE_HEIGHT));
    mainVideoPlayerCover.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            if (timelineData instanceof  Video) {
                Video video = (Video) timelineData;
                if (video.getTargetUrl() != null) {
                    callback.onShowVideoDetailFromAdapter(mainVideoPlayerCover, getAdapterPosition(), timelineData, false);
                    return;
                }
            }
            callback.onItemClickFromAdapter(getAdapterPosition(), v, mainVideoItem, timelineData);
        }
    });
    ...
}

插桩后字节码

private void renderResource(final IVideoAndMvResource var1, final int var2, final FragmentCallback var3) {
    if (var1 != null) {
        LayoutParams var4 = (LayoutParams)this.mainVideoPlayerCover.getLayoutParams();
        var4.width = MainPageVideoAdapter.MV_IMAGE_WITH - NeteaseMusicUtils.dip2px(12.0F);
        var4.height = (int)((float)(MainPageVideoAdapter.MV_IMAGE_WITH - NeteaseMusicUtils.dip2px(12.0F)) * 0.5625F);
        NovaImageLoader.loadImage(this.mainVideoPlayerCover, ImageUrlUtils.getSpecifiedSizeUrl(var1.getCoverUrl(), MainPageVideoAdapter.MV_IMAGE_WITH, MainPageVideoAdapter.MV_IMAGE_HEIGHT));
        this.mainVideoPlayerCover.setOnClickListener(new OnClickListener() {
            public void onClick(View var1x) {
                /******此处是我们插入的代码 start******/
                ArrayList var2 = new ArrayList();
                var2.add(var1);//var1对应源码中的final变量timelineData
                var2.add(var3);//var3对应源码中的final变量callback
                var2.add(TimelineVideoHolder.this);//外部类引用
                PluginAgent.onClick(var1x, var2);
                /******此处是我们插入的代码 end******/

                if (var1 instanceof Video) {
                    Video var3x = (Video)var1;
                    if (var3x.getTargetUrl() != null) {
                        var3.onShowVideoDetailFromAdapter(TimelineVideoHolder.this.mainVideoPlayerCover, TimelineVideoHolder.this.getAdapterPosition(), var1, false);
                        return;
                    }
                }

                var3.onItemClickFromAdapter(TimelineVideoHolder.this.getAdapterPosition(), var1x, TimelineVideoHolder.this.mainVideoItem, var1);
            }
        });
        ...
}

插桩后,onClick中引用的变量var1、var3、及指向外部类的引用都被搜集到。

4.2.3 列表数据源搜集

对于列表子控件,如果编译期没能搜集到变量,可以从view出发,向上回溯找到列表对象,根据view所属item的position计算item数据源。

例如,可在RecyclerView的Adapter基类提供getItem接口,根据控件所在position获取对应的数据源:

public static abstract class NovaAdapter<T, VH extends NovaViewHolder> extends RecyclerView.Adapter<NovaViewHolder> {
    protected ArrayList<T> mItems = new ArrayList<>();

    private boolean mHasEmptyView;
    private boolean mShowEmptyView;
    private CharSequence mEmptyContent;
    private OnClickListener mEmptyOnClickListener;
    private boolean mHasLoadView;
    private boolean mShowLoadView;
    private Runnable mShowLoadViewRunnable;
    private boolean mFirstLoad = true;
    private int mPlaceholderViewHeight;
    private int mTextColor = Colors.C3;

    // 获取指定position对应数据
    public T getItem(int position) {
        return mItems.get(position);
    }

    ...
}

类似地,ListView的Adapter基类也提供了getItem接口:

public abstract class NeteaseMusicListAdapter<T> extends BaseAdapter {
    protected List<T> mList = new ArrayList<T>();
    protected Context context;

    // 获取指定position对应数据
    @Override
    public T getItem(int position) {
        if (mList != null) {
            return position < mList.size() ? mList.get(position) : null;
        }
        return null;
    }

    ...
}

小结:

  • 列表场景下,若编译期未搜集到变量,可进一步通过列表回溯拿到item数据源。
  • 非列表实现的场景,若编译期未搜集到变量,则拿不到业务数据。

我们的原则是尽可能多的搜集业务数据,至于具体上报哪些数据,可以根据不同的需求组织这些数据。

我们目前的做法是,通过接口IAutoTrack标记要上报的Model:

public interface IAutoTrack {
    com.alibaba.fastjson.JSONObject genAutoLog();
}

public class PlayList implements Serializable, IAutoTrack {
    private static final long serialVersionUID = -9159210152362779484L;
    private long id;//歌单id
    private String name;//歌单名字
    private String alg;//推荐的算法

    @Override
    public JSONObject genAutoLog() {
        Object[] values = new Object[]{"d_name", name};
        return AutoLogUtil.buildExtraJson(id, "list", alg, values);
    }

    ...
}

实际打点的时候,仅上报实现了IAutoTrack这个接口的Model,且每个Model上报的数据由genAutoLog给出。

我们的方案仅需业务开发人员按照约定对要上报的Model实现IAutoTrack接口,实现genAutoLog方法,无需关心后续的打点上报。

版本迭代过程中,已进行IAutoTrack标记的Model一般不会频繁改动,仅需对新增Model进行IAutoTrack标记。

相比于datapath方案,我们的方案更大程度上解放了业务开发人员,新旧版本也能很好地兼容。

5 结语

本文主要围绕自动埋点的几个关键技术点展开,分别对每个技术点的已有方案进行分析,再结合云音乐的实际情况进行改造或创新,总结出了这套适用于云音乐的自动埋点方案。总结一下就是:

(1) 通过监听Activity生命周期以及Fragment显隐状态实现页面曝光自动化。

(2) 控件唯一标识采用如下构造:

ActivityName + idName + position。

(3) 控件事件拦截采用 Gradle插件 + ASM 进行编译期插桩。

(4) 控件业务数据搜集采用编译期搜集变量 + 搜集列表数据源。

在实践中我们认识到,现有的自动埋点方案并不能解决所有应用场景,需要在业务验证的迭代中逐步完善,因此我们仍需探索与改进。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值