Android全量埋点实践

一、项目背景

产品又提了一堆埋点需求,有简单的,如点击的。有复杂的,如统计页面A到页面B再到页面C的。有的还是接口相关的,本来以为后台能埋点的(我都调后台接口了,不能后台埋吗?),因为无法解释的原因,最终还是app端埋。
部分点位如下:

虽然之前通过 Tracker框架 可以实现长路径的埋点,但因为点位太多了,业务层写的也烦,所以就想着必须得做全量埋点。解放双手,让产品不用再找我们了。

全量埋点大致框架图:
全量埋点大致框架图

1、Tracker负责全局事件监听,但不做任何逻辑,由上层实现
2、Apt 注解处理器,用于在编译时生成 extFieldMapping.csv
3、FullPointer依赖于Tracker,事件发生后,收集数据。还包括解析mapping文件,参数->ext 映射,数据上传等。
4、APP 业务层负责 对一些关键点位绑定业务参数

根据产品需求分析,我们拆分为三种大的事件。

eventType 事件类型:
1、页面事件
2、交互事件
3、接口事件

二、页面page事件

页面的定义是由产品决定的,Android 端一般为Activity,Fragemnt。Activity 默认为一个页面,Fragement默认不是页面,需要业务层对 fragemnt 加上pageId,框架才会认为是页面,之后此 fragment 里的view 的点击事件,获取到的 pageId 就是 fragment的pageId,而不是Activity 的了。而Activity也可以加pageId,有pageId会取pageId,否则会取activity.className
page 会产生四种小事件,对应 页面 的 生命周期 lifecycle。(0 enter、1 show 、2 hide、 3 exit) 其中 enter与exit 一个页面存活时只会触发一次,而show与hide 会多次触发。
下面分别对activity、fragment 来实现四种事件说明

2.1 Activity 为 page
enter 进入
对应 activity.onCreate  
show 对用户可见
对应 activity.onResume  
hide 对用户不可见
对应 activity.onPause  
exit 页面退出
对应 activity.onDestory 

使用 Tracker 全局监听

//页面 activity show/hide
Track.fromAnyActivity().activityOnCreated().subscribe(activity -> {
    onPageLifeCycle(activity, LIFECYCLE_ENTER);
}).activityOnResumed().subscribe(activity -> {
    onActivityShowHide(activity, true);
}).activityOnPaused().subscribe(activity -> {
    onActivityShowHide(activity, false);
}).activityOnDestroyed().subscribe(activity -> {
    onPageLifeCycle(activity, LIFECYCLE_EXIT);
    TrackTools.cleanPageParams(activity);
});
2.2 Fragment 为 page
enter 进入
对应 fragment.onCreateView 
exit 页面退出
对应 fragment.onDestory 

因为 fragment 使用场景有多种,有ViewPage 嵌套 fragment 的,有通过 FragmentTransaction.show、hide 切换的,也可以直接add的。所以针对 fragment 的show、hide 需要多重处理。
使用 Tracker 全局监听

//fragment enter、exit
Track<?> fragmentTrack = Track.from(FragmentActivity.class);
fragmentTrack.fragmentOnCreateView(Fragment.class).subscribe(fragment -> {
    onPageLifeCycle(fragment, LIFECYCLE_ENTER);
}).fragmentOnDestroyed(Fragment.class).subscribe(fragment -> {
    onPageLifeCycle(fragment, LIFECYCLE_EXIT);
});
//fragment show、hide
fragmentTrack.fragmentOnHiddenChanged(Fragment.class).subscribe(fragment ->
	onFragmentShowHide(fragment, !fragment.isHidden()));
fragmentTrack.fragmentSetUserVisibleHint(Fragment.class).subscribe(fragment -> {
    if (fragment.isResumed()) {
	onFragmentShowHide(fragment, fragment.getUserVisibleHint());
    }
});
fragmentTrack.fragmentOnResumed(Fragment.class).subscribe(fragment -> {
    if (!fragment.isHidden() && fragment.getUserVisibleHint()) {
	onFragmentShowHide(fragment, true);
    }
});
fragmentTrack.fragmentOnPaused(Fragment.class).subscribe(fragment -> {
    if (!fragment.isHidden() && fragment.getUserVisibleHint()) {
	onFragmentShowHide(fragment, false);
    }
});
2.3 page 的唯一标识 pageId

如果直接拿activity、fragment的className当作页面的id。会导致android与ios端不一致。所以我们为了方便后续BI分析数据,需要业务层对页面setPageId,具体android端使用 @PageId("20062") 注解加在activity或fragment 上,当事件发生时,框架拿到 activity 或 fragment 对象,获取注解中的值。而有些fragment类是通用的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vG2csd3R-1607917412088)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a701192b5a3d45cabf1b1af129c478b8~tplv-k3u1fbpfcp-watermark.image)]
例:一个viewPage嵌套几个fragment,通过初始化参数展示不同的列表,产品认为它们是不同的页面。而代码全在一个fragment类中,如果我们通过 class 的注解 ,无法区分不同的页面。所以还需要有一个 setPageId(frg/act,"pageId")方法,而且有的页面还能携带参数。在上面例子中,需要携带页面所处的位置,即后台可以动态控制每个tab页面所处的位置。


@PageId(value = "21002", remarks = "我的出价-待成交")
public class MyPricePageBase {
    public int position;//页面位置标识
    public int type;//4010接口中的type字段
}

业务层 在 onCreate 或者其它地方 调用 setPageId()

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
    	……
        MyPricePageBase page=new MyPricePageBase();
        page.position=position;
        page.type=type;
    	TrackTools.setPageId(this, page);
    }

将pageId 注解加在bean上。并且与页面绑定起来,框架中当页面事件发生时,取出pageId与参数字段。

三、view交互事件

3.1 actionType 交互事件类型

交互事件主要是view的交互事件,分为四种小事件

1、点击事件
2、刷新事件
3、加载事件
4、切换事件

点击就是view的点击事件。刷新是在列表中,用户通过手势下拉刷新。加载就是上滑加载更多。虽然刷新与加载更多都会触发接口事件,但某些情况为了能区分是 自动进入页面刷新 还是 用户手动刷新,所以刷新、加载事件也就有必要了。切换是指在 banner、大图浏览,可以左右滑动切换的事件。因为场景较少,这里开放插入事件方法,由业务层主动插入事件。

使用Tracker 全局监听

//全view click 事件
Track.fromAnyActivity().anyViewClick().subscribe(view -> {
    String actionId = TrackTools.getViewActionId(view);
    String pageId = TrackTools.getPageId(view);
    Object params = TrackTools.getViewParams(view);
    Map map = extManager.getExtParamsMap(actionId, params, Block.VIEWTAG);

    //插入事件...
    LogUtil.i(TAG, "anyViewClick actionId: " + actionId + " pageId=" + pageId + " map=" + map);
});
//刷新
Track.fromAnyActivity().onRefresh(TrackManager.ANY_VIEW).subscribe(view -> {
    LogUtil.d(TAG, "onRefresh " + view);
    //插入事件
    onViewAction(view, REFRESH_ACTION);
});
//加载
Track.fromAnyActivity().onLoadMore(TrackManager.ANY_VIEW).subscribe(view -> {
    LogUtil.d(TAG, "onLoadMore " + view);
    //插入事件
    onViewAction(view, LOAD_MORE_ACTION);

});
3.2 交互事件的唯一标识 actionId

交互事件中,主要对象是view。我们需要区分是哪个view的点击。所以就需要生成view的唯一Id。而要区分某个页面的某个view。虽然有view.getId()方法,但是考虑到项目中,不一定所有可以点击的view都有id,而且并不能保证id在一个页面中是唯一的。可以通过viewTree 的方式。根据view的className与在父view的位置 Button[1],一直到根view的层级关系。大致长这样
CoordinatorLayout[1]/AutoConstraintLayout[1]/SmartRefreshLayout[0]/RecyclerView[0]/AutoLinearLayout[1]
根View 可以查找到 android.R.id.content 即可。而即便是viewTree 的方式,也无法保证在不同app版本,如果改变了布局,view的位置发生变化。那么viewTree就会发生变化。虽然网上有通过 “相对于同类view在父view的位置,而不是直接取在父view的位置”。但依然解决不了页面大变的情况。
所以我们对一些重要的按钮。(目前是重要页面,常用功能的view),对它setViewTag。就是一个别名id,并且android与ios一致,这样会方便BI后续分析数据。当然需要 业务层 在代码里手动调用set,业务层需要确保在布局变化后,tag依然一样。用于区分此页面的此view。框架在拿到view时,getViewTag就行。而没有setViewTag的view,我们通过viewTree的方式。虽然会有viewTree变化的问题,只不过我们选择忽略了。。。。

TrackTools.setTag(view,"home-search");
/**
* setTag 无参数
*/
public static View setTag(View v, String viewTag) {
	v.setTag(0xff000001, viewTag);
	return v;
}
3.3 交互事件携带参数

在某些情况下,view是动态的。通过接口数据配置按钮的背景、文字,点击跳转的路由等。例:
位置固定,但功能动态的按钮
那么当这种按钮点击的时候,需要携带业务参数,如id,路由地址之类的。使用如下:

//定义tag类
@ViewTag(value = "home-search",remarks = "首页-搜索框")
public class TestTag {
    public String id;
    public String url;
}

//tag与view绑定
TestTag tag=new TestTag();
tag.id=id;
tag.url=url;
TrackTools.setTag(button,tag);

TrackTools.setTag

/**
 * setTag 并且携带参数
 */
public static View setTag(View v, Object tagObj) {
	ViewTag tag = tagObj.getClass().getAnnotation(ViewTag.class);
	if (tag == null || TextUtils.isEmpty(tag.value())) {
		throw new RuntimeException(tagObj.getClass() + " don't has ViewTag Annotation");
	}
	v.setTag(0xff000001, tagObj);
	return v;
}

就是通过view.setTag与对象绑定起来,在点击事件中通过view再取出对象的tag与参数。

三、接口事件

接口事件是指网络接口请求事件。当请求成功后,把 接口号(接口的唯一标识)+ 业务参数作为一个事件,有些特殊接口事件还需要 返回参数。由于每家公司都会对网络请求再封装一层。监听实现方式肯定无法统一,这里只给出一个使用参考示例。

Tracker 全局监听 接口事件 示例
//全接口事件
Track.fromApplication().onAnyApiSuccess().subscribe(response -> {
    int code = response.getServiceCode();
    Object request=response.getRequest();
    Object result=response.getResult();

});

四、字段与ext的映射表

从上面三个大事件可以看出,都有可能携带参数。

page 事件必有 lifecycle生命周期字段,和特殊页面的业务id
交互事件中的点击事件 可能会携带业务id,位置,等
接口事件 必有 请求的业务参数,部分接口会需要 返回的业务参数

而参数的名字是各种各样的,同一个事件的不同app版本可能会增删参数。在与数据分析部门讨论后,为了方便BI后续分析。决定将每一个事件参数固定映射到 ext1…n 上,所以需要有一张 字段与ext的映射表 即:extFieldMapping.csv

如上图:接口事件2005中,获取到参数 age ,实际赋值 给 ext1,id->ext2,如果这个接口有新增字段,向后添加,而 最大 ext的列数 为字段参数最多的那个事件。在 home-msg 点击事件中,获取到arg3参数,赋值给 ext1。并且不同版本的 mapping 是只增加字段的,不能删除字段(因为老数据会有值)。那么android与ios统一按上面规则使用这张表。而BI分析时,通过事件id,可以反查到ext1对应的含义。如:2005 事件中。ext1字段的含义就是 age。

4.1 具体实现

表的格式是 .csv,这是一种简易的表,使用文本方式读写,换行符\n分隔为行,英文逗号分隔为列。第一行 为列名,后面的行 为数据。使用 excel 打开就是如上表格的样式,也方便程序中解析处理。文本打开如下:

actionId,remarks,ext1,ext2,ext3,ext4,ext5,ext6,ext7,ext8
//serviceCode,,,,,,,,,
2001,,actiStatus,actiStatusText,activityTag,applicantId,applicantLink,applicantName,applicantPhone,applicantReason
2002,,age,age1,agreeType,mobile,password,testMobile,username,
2003,,age,id,,,,,,
2005,,age,id,,,,,,
//pageId,,,,,,,,,
1000001,,lifecycle,,,,,,,
1000002,,lifecycle,position,,,,,,
1000003,,lifecycle,index,,,,,,
1000004,,lifecycle,,,,,,,
//viewTag,,,,,,,,,
home-msg,首页-消息,arg3,arg4,,,,,,
home-search,首页-搜索框,arg1,arg2,arg3,,,,,

表的生成 是由Android端生成的。编译时使用 Apt(注解处理器) 生成extFieldMapping.csv文件至app/src/main/assets(一般我们在使用Apt时,都是创建 .java文件,其实 Apt 过程也可以做其它事,例如拿到assets目录,读写文件,然后 打包过程会把最新文件打进app 中)
apt 获取 assets 目录,主要是拿到本地项目路径

/**
* 获取 assets 目录
*/
private File getAssetsDirFile() {
    try {
        mFiler = processingEnv.getFiler();
        FileObject temp = mFiler.getResource(StandardLocation.CLASS_OUTPUT, "", "META-INF");
        
        String url = temp.toUri().toString();
        //url=file:/Users/wzh/ttpc/gitlab/Android-Compiler/app/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/META-INF/test.csv
        try {//mac linux
            url = url.substring(0, url.indexOf("/build/")).replace("file:", "");
        } catch (Exception e) {//windows
            url = url.substring(0, url.indexOf("/build/")).replace("file:/", "");
        }
        url = url.substring(0, url.lastIndexOf("/")) + "/app/src/main/assets";//固定在app 目录

        File dir = new File(url);
        if (!dir.exists() || !dir.canWrite()) {
            throw new RuntimeException(dir.getAbsolutePath() + " don't exist or don't write,please check dir");
        }
        return dir;
    } catch (IOException e) {
        e.printStackTrace();
    }
    return null;
}
4.1.1 接口参数

接口参数是通过处理 接口类 注解,获取所有接口方法,然后解析获取 接口号与请求的bean,解析bean获取字段。
接口类使用 retrofit 定义方式,再封装了业务逻辑,大致定义如下:

@TtpcApi
public interface BossApi {

    @CODE(2005)
    @POST("/testapp/")
    Observable<BaseResult<LoginBean,Object>> loginBoss(@Body LoginRequest request);

    @CODE(2002)
    @POST("/testapp/")
    HttpTask<LoginResponse,Integer> login7(@Body LoginRequest request);

}
4.1.2 page参数

而page参数的获取则是通过解析pageId注解,pageId可以直接加在activity或fragment上,也可以加在普通bean上,如果是bean,则获取bean的所有字段,当做事件的参数。这也是解释 setPageid 为什么一定要pageId注解,也是为了表的生成。
pageId注解定义:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface PageId {
    /**
     * PageId 值,必填
     */
    String value();
    /**
     * 备注
     */
    String remarks() default "";
}
4.1.3 view参数

view的参数也是一样,解析ViewTag 注解的bean,获取字段当做参数。通过setTag(View v, String viewTag)的方式,表示没有参数,也就不需要出现在表里。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ViewTag {
    /**
     * tag 值,必填
     */
    String value();
    /**
     * 备注
     */
    String remarks() default "";
}

由于表的生成是由编译时自动生成的,不需要人为的去修改表。这样可以减少一些人为导致的错误发生。由于Android与ios用的是同一张表,运行时只需读取,解析表即可,所以ios无需生成表,我们的策略是在由安卓打正式包时,生成后的表,上传到局域网服务器,ios编译时下载放到项目里即可。

五、总结

可以看出,这套全量埋点框架的部分工作还是需要由业务层完成,通过手动setViewTag、setPageId的方式,好像并没有减轻业务层的工作量。但好在不多,主要还是因为需要这张ext与字段的映射表,所以业务层使用方式也得兼容表的生成功能。

5.1 上线后的后续工作

在数据传给后台,产品提出点位需求,BI分析阶段,我们需要提供某个点位或者好几个点位,如某个按钮的viewTree是啥,这个点应该怎么串。

5.2 业界方案

在view与业务参数绑定这块,网上有方案是通过后台下发配置,指定点击时的业务参数,app解析类似 resp.result.id 语法,这样后续工作就是填这种语法了。而view的唯一标识,没有通过setViewTag的view。使用viewTree 的方式,无法保证在布局改变后生成的一致。这一点也没什么好办法,欢迎大家讨论。

总之,没有完美的全量方案,也没有一劳永逸的方案。各有优缺,需要根据 app端,分析端,产品端的 需求来一起制定。谨供参考。

5.3 关于开源

全量埋点的部分,后续有需要的话会删除部分业务逻辑开源出来。
其中 Tracker 是开源的,可以实现全局监听功能,通用的可以监听全局 viewClick。和activity/fragment 的生命周期,页面的跳转。而下拉刷新,加载,各家用的都不一样,就没必要开源了。当然如果你需要 很长的埋点 需求,tracker 也可以实现,具体看项目介绍。

本文同步发送于:https://juejin.cn/post/6904900874183819277

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值