纵观近年来比较成熟的自动埋点方案,存在着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](https://i-blog.csdnimg.cn/blog_migrate/4f7dd5b4ccb8dc8a63629091c666bbef.jpeg)
它通过遍历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](https://i-blog.csdnimg.cn/blog_migrate/2ad2b2e62ab746d177b03524d78075c0.jpeg)
因此我们的唯一标识的组成是:
ActivityName + idName + position。
相对于xpath方案,这一组合省略了xpath的中间节点,因此中间节点的变更不会影响到当前控件,一定程度上保证了唯一标识的一致性。
3 事件拦截
标识了控件之后,我们要解决的问题是,当某个控件被点击了,要拿到这个时机进行点击事件上报。
我们分析了已有的事件拦截方案:
- 代理监听
- 编译期插桩
考虑到代理监听方案涉及到控件树遍历,我们选择了编译期插桩的方案。
我们采用Android gradle插件提供的Transform api在编译期,class打包成dex之前,插入自定义Transform,在自定义的Transform中调用ASM核心API对class文件的字节码进行扫描,当扫描到目标事件响应函数时,在函数头部插入自定义代码。这一流程如下图所示:
![afcd664a6bd1882bff224c49381b9638.png](https://i-blog.csdnimg.cn/blog_migrate/8a814dec712bbb8afeef7f89b2c51303.jpeg)
例如,在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](https://i-blog.csdnimg.cn/blog_migrate/aabeca67bc613a3d0657e24409346ff5.jpeg)
比如,要搜集下面这个列表里每一项的产品id,根据datapath语法,下发的路径应为item.productId:
![68a6eb28509e9c39a517844343028690.png](https://i-blog.csdnimg.cn/blog_migrate/d442c9a829fcfbc197cafdb6c34606d0.jpeg)
它通过下发配置文件定制要收集的数据,下发的配置示例如下:
//第一部分:描述
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](https://i-blog.csdnimg.cn/blog_migrate/5ee37d9a7269258a6ec6963ac2cb14d4.jpeg)
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](https://i-blog.csdnimg.cn/blog_migrate/4705cabbf246fa0f4aa1e2d473c57510.jpeg)
每一帧包括两部分:一个局部变量部分和一个操作数栈部分。局部变量部分包含可根据索引以随机顺序访问的变量。由名字可以看出,操作数栈部分是一个栈,其中包含了供字节代码指令用作操作数的值。这意味着这个栈中的值只能按照“后入先出”顺序访问。不要将操作数栈和线程的执行栈相混淆:执行栈中的每一帧都包含自己的操作数栈。
![de744dde040b95c4f8587ae084153736.png](https://i-blog.csdnimg.cn/blog_migrate/d7e896a7240566c4ad681a4aafe61ef8.jpeg)
局部变量部分与操作数栈部分的大小取决于方法的代码。这一大小是在编译时计算的,并随字节码指令一起存储在已编译类中。
通过以上分析,可知变量搜集的难点在于变量搜集指令的编写,且要保证插桩前后onClick方法的局部变量表和操作数栈状态一致。
插桩过程分析
下面分析插入代码前后,onClick方法的局部变量表和操作数栈状态。
- 初始frame状态
如果传递了n个参数给某个实例方法,则当前栈帧会按照约定,依参数传递顺序来接收这些参数,将它们保存到方法的第1个至第n个局部变量之中。按照约定,需要给实例方法传递一个指向该实例的引用作为方法的第0个局部变量。
当调用onClick(view)方法时,Java虚拟机会创建一个新的栈帧,传递给onClick方法的参数值会成为新栈帧中对应局部变量的初始值。即类实例的引用this和传递给onClick方法的参数view,会作为onClick方法栈帧的第0、1个局部变量。初始状态,操作数栈为空。如下图所示:
![0afdea7abafca12fc2fdd94ad85103ef.png](https://i-blog.csdnimg.cn/blog_migrate/45cd4aa1049daf1f272a7cc9d22711e0.jpeg)
- 插入指令的frame状态
创建ArrayList局部变量obj这句java源码对应4条字节码指令:
![3f9c4b9d0db0d830a902e19baa85ffd0.png](https://i-blog.csdnimg.cn/blog_migrate/8e4adfed8849193f62789c98acb8bbd0.jpeg)
每条指令执行后局部变量表和操作数栈状态如下:
![c8de0f669938a956ef3f5d25d507adac.png](https://i-blog.csdnimg.cn/blog_migrate/79223e3b57366d6566150a67ad15b2f4.jpeg)
![7b091b4324f00cec584f8a6177b25b44.png](https://i-blog.csdnimg.cn/blog_migrate/5233c04a6f97c6227102e20826136edd.jpeg)
由于分析过程类似,中间的成员变量添加到obj的指令分析就省略了。直接看最后一步:搜集obj。
搜集obj这句的java源码对应3条字节码指令:
![28a3ad133828476df9caf9a3394d5897.png](https://i-blog.csdnimg.cn/blog_migrate/86311915d091a64135a5d590ab85dc2d.jpeg)
每条指令执行后局部变量表和操作数栈状态如下:
![eeb0ffff3ae38b13ae3f6afb7277fafd.png](https://i-blog.csdnimg.cn/blog_migrate/80346b716b8bb1349bf68237035e4dec.jpeg)
- 插桩后frame状态
![fcde2644684629bfff42c3f7d064276b.png](https://i-blog.csdnimg.cn/blog_migrate/6513542e38a65d372480e7ed4fb96051.jpeg)
插桩后,局部变量表索引为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) 控件业务数据搜集采用编译期搜集变量 + 搜集列表数据源。
在实践中我们认识到,现有的自动埋点方案并不能解决所有应用场景,需要在业务验证的迭代中逐步完善,因此我们仍需探索与改进。