- Fragment
控件ID规则
相对于页面而言,控件的 ID 定义规则要更加复杂。起初我们会想到用『R.id』,在编译时 Android aapt 会给每个写在 xml 里的控件生成一个唯一 ID,但是从 aapt 的生成规则来看,这个 ID 并不是固定不变的,在资源文件发生变化的时候,id 也可能会出现变化,也就是不同版本的相同控件的 ID 是有可能不同的。根据 ID 需要具备的『唯一性』和『稳定性』来看,这个 ID 具备『唯一性』,但『稳定性』非常差,因此这个方案不可行。
紧接着我们想到,每个界面所有的控件根据其父子关系可以绘制出页面的视图树,从控件本身出发,根据控件的类名加上其所处层级的位置等特征信息,并逐级的向上遍历,直至找到根节点位置,这样我们就能得到一个控件在该视图树中的一个控件路径;反过来说,根据这个控件路径,我们就能在这个视图树中唯一确定一个控件。下图是一个简单的 ViewTree 模型:
根据上文所述控件路径生成规则,对于 Button 而言,其路径为:FrameLayout[0]/LinearLayout[1]/Button[0]
,在一个页面中,这个路径就可以帮我们唯一定位到这个 Button,但是对于不同的页面而言,还是存在不同的控件相同的路径的情况,因此控件 ID 的生成规则应为:『页面 ID: 控件路径』。
上文页面 ID 的生成规则中我们说到,对于 Android 来说,页面有 Activity 和 Fragment 两种,因为一个 Activity 可以包含不同的 Fragment,所以控件如果是存在于 Fragment 中的,则页面 ID 需要为其所在的 Fragment 的页面 ID,如果不在 Fragment 中,则包含 Activity 的页面 ID 即可,那么如何能够从控件本身的实例获取到其所在的 Activity 或者 Fragment。对于 Activity 而言比较简单,我们可以通过如下代码实现:
对于 Fragment 则相对比较麻烦,我们只能事先将 Fragment 对应的页面 ID 和控件本身绑定,即通过打 tag 的方式,在 Fragment 的 OnViewCreated 方法中,拿到 Fragment 容器中的根 View,并打上 Fragment 的页面 ID,然后遍历该 View,为其所有的子控件都打上标记,核心代码如下:
所以当我们拿到一个 View 的实例时,我们先看是否能拿到这个 tag 对应的页面 ID,如果拿不到再去找其所属的 Activity,然后得到页面 ID,随后根据它本身的控件路径,拼凑出控件的 ID,核心代码如下:
控件ID的优化
基于我们上述的控件 ID 定义,在页面元素不发生变动的情况下,基本能够保证『稳定性』和『唯一性』,但是页面元素发送动态变化,或者不同版本之间 UI 进行改版的情况下,我们的控件 ID 就会变得不够稳定,比如以下情况:
在插入一个 FrameLayout 之后,我们 Button 的控件路径就变成了 FrameLayout[0]/LinearLayout[2]/Button[0]
,与之前的 ID 相比,已经发生了改变,变得不那么『稳定』了,于是我们做了以下的优化:
-
优化1:将兄弟节点中的位置,变成相同类型控件的位置。优化后的控件路径为:
FrameLayout[0]/LinearLayout[1]/Button[0]
,即使在插入 FrameLayout 后,其路径仍旧不变,相较之前会更加稳定一些。但如果插入的是 LinearLayout,或者整个页面的 UI 进行了重构,控件路径依旧会发生改变。 -
优化2:因为不同的系统版本或手机厂商,会对页面的根 View 做一定的处理,所以我们需要屏蔽掉这种情况,对于我们而言,我们只关心我们自定义的那部分布局,即通过 setContentView 传入的布局。我们可以通过判断控件 ID 是否等于
android.R.id.content
来获取我们自定义的布局的根 View,并将其作为我们控件路径的起点。 -
优化3:在 Android 中,除了
R.id
和控件路径之外,还有一个比较常用的可以作为控件 ID 的特征信息,那就是开发者写在布局文件中,关联控件的 Resource ID。Resource ID 是开发者自己定义的关联 View 的标识,在一个页面当中,理论上是唯一的(为什么说是理论上,因为还是存在有多个相同 Resource ID 的情况,比如动态的 add 多个 layout,且包含了相同的 Resource ID,但这种情况非常少),并且在页面的重构过程中,Resource ID 也一般不会修改,因此用 Resource ID 来作为控件 ID 是非常合适的。但并不是所有的控件都有 Resource ID,我们可以先尝试去获取这个 ID,假如 Resource ID 存在,则使用 Resource ID 来作为控件 ID,假如 Resource ID 不存在,则降级使用控件路径作为控件 ID。核心代码如下:
控件的点击、长按指标
有了控件 ID 的生成规则,控件的点击和长按指标我们就能很方便的进行统计,因为在 Android 中,控件的点击和长按都有非常标准的回调函数,即 onClick(View v)
和 onLongClick(View v)
方法。在回调函数中调用 SDK 封装好的方法,传入被点击控件的 View 对象,通过 View 对象本身的特征信息,得到这个控件的唯一 ID,然后上传埋点,即可统计出我们想要的控件相关的点击、长按指标。
- 点击
- 长按
代码插桩
通过上文的描述,我们得到了页面和控件的 ID 的定义规则,也知道了只需要在相应的回调函数中写入 SDK 代码获得我们想要的对象,就能够计算出我们想要的指标,那么如何才能自动的往我们现有的工程中写入获得对象的代码。
在指定的切点插入指定的代码,这个业务场景可能很多同学都非常熟悉,我们常用 AOP 的方式来解决这类问题,将所有的代码插桩逻辑集中在一个 SDK 内处理,这样可以最大程度的不侵入业务。
Javassist
Javassist 是一个基于字节码操作的 AOP 框架,它允许开发者自由的在一个已经编译好的类中添加新的方法,或是修改已经存在的方法。但是和其他的类似库不同的是,Javassist 并不要求开发者对字节码方面具有多么深入的了解,同样的,它也允许开发者忽略被修改的类本身的细节和结构。一个简单的修改方法体的例子如下:
gradle 插件
Javassist 需要操作已经编译好的类,Android 的打包流程从下图可以了解,我们可以在 Java 编译器编译完工程代码,.class 文件转成 dex 之前使用 Javassist 来进行我们需要的代码插桩工作。
了解过 gradle 插件的同学可能知道,在 Android Gradle Plugin 版本在 1.5.0 及以上,我们可以使用官方提供的最新的 Transform API,在打包编译时 .class 打包成 dex 之前对 class 文件进行处理。具体的自定义插件过程不在赘述,我们只需要定义一个自己的 Transform,继承系统的 Transform,重写 transform 方法即可。
在 transform 方法的第二个参数里,我们可以获取到工程内所有的源码编译出来的 .class 文件以及所有依赖的 jar 包,我们挨个遍历所有的 .class 文件,以及解压缩所有的 jar 包,拿到 jar 包内的 .class 文件,即可实现对所有的文件进行代码插桩的需求,核心代码如下:
拿到 .class 文件之后,我们会按照上述 Javassist 的工作流程进行代码插桩:
- 先根据类名得到
CtClass
对象 - 再根据我们想要寻找的切入点,页面就找
onResume()
方法,控件就找onClick(View view)
方法 - 然后根据方法名和参数类型,得到
CtMethod
对象 - 调用
CtMethod
对象的编辑方法体的 API,在原始方法体之前插入就调用insertBefore
,之后就调用insertAfter
,传入需要插入的代码块 - 调用
CtClass
的writeFile()
方法,保存这次编辑
将项目中所有的源文件遍历一边后,我们就完成了整个项目代码的插桩,在我们想要的切入点(页面的曝光、控件的点击等回调函数),就成功的插入了相应捕获页面、控件对象的代码,在页面曝光或者控件点击时,就能够获得相应的对象,生成唯一 ID 并上报相应的埋点事件,完成整一个无痕埋点的流程了。
阶段二:可视化管理后台
完成阶段一的无痕埋点之后,我们可以通过接入一个 SDK 来轻松的实现页面曝光、控件点击等指标的数据获取,但是通过上文我们可以知道,我们定义的 ID 其实对于业务方(产品、运营、BI 等非业务开发人员)而言是不友好的,他们无法根据 ID 中的类名、Resource ID 等特征信息来关联到埋点具体的业务含义,因此我们需要通过一些工具来帮助他们将埋点元素 ID 和具体的业务含义进行关联,甚至是跨平台(Android、iOS 的自动埋点 ID 是不一致的)的关联。
从另外一个角度来说,有了这样的可视化管理后台,我们还可以通过下发配置表的方式来收集想要的埋点,这其实就是我们开篇说的可视化埋点。所以有了这样的管理后台并基于自动埋点的数据采集方式,我们可以根据具体的业务场景,灵活的选择是无痕埋点(全量采集)还是可视化埋点(根据配置表定向采集)。
一个简单的用户操作可视化管理后台的时序图如下:
从图中我们可以知道,可视化管理后台的核心内容就是上传手机界面截图及控件相关信息,可以让用户在后台对相关的页面、控件与自定义的业务 ID 进行绑定并在后台生成配置,界面实际效果如下:
在上图的可视化管理平台中,主要有这么几大块内容,最上方是当前和管理后台建立连接的设备信息,左下方是当前界面已经绑定过自定义业务 ID 的埋点元数据,右下方是手机当前界面在管理平台上的映射,并标记出界面内所有可埋点的控件,已绑定过自定义业务 ID 的控件标记绿色,未绑定的标记红色,这样用户就可以非常方便的选择自己想要的控件进行操作。
要实现上图这样的效果,我们只需要遍历当前页面,并上传所有可被埋点的控件信息,对于目前我们想要实现的数据指标而言,我们只关心控件的点击和长按事件,换句话说就是我们只需要找到当前页面内所有的可被点击或长按的控件即可。
上报控件信息
对于需要上报的控件需要满足以下几个条件:
- 可被点击或长按
- 在当前界面可见
对于控件是否可被点击或长按,我们没法直接通过系统的 API 来获取,但是通过源码我们可以看到,View 内部还是有私有变量来存储点击或长按的监听器的,在 API14 之前的 mOnClickListener 对象和 API14 之后的 mListenerInfo 对象,均可用来判断当前 View 对象是否被设置了点击监听函数,我们可以通过反射来拿到这些对象,并进行判断,长按的判断也同理,核心代码如下:
处理完可被点击或长按的条件后,我们要判断控件在当前界面是否可见,因为我们需要在截图上把控件全选出来,如果控件本身是不可见的也被圈出来,用户就会比较迷茫。通过一定的调研,我们发现满足以下几点条件,即表示该控件在屏幕内可见:
- 判断 View 本身可见性属性
View 本身可见性属性比较容易判断,我们只需要判断 View.isShown()
并且 View.getVisibility() == View.VISIBLE
即可。
- 判断 View 所处的位置是否在当前屏幕内
一个 Activity 加载了多 Fragment 的情况下,可能会出现控件本身可见性属性达标,但实际并不在屏幕内的情况。这种情况我们根据 View.getLocationOnScreen(int[] outLocation)
,然后通过判断 outLocation[0]
,是否大于等于 0 且小于等于屏幕宽度,就能判断控件是否在当前屏幕内。
- 判断控件是否被其他控件完全遮挡
遍历所有与该控件有关联的控件(同层控件、父控件、父控件的同层控件等),通过 View.getGlobalVisibleRect(Rect viewRect)
来得到控件所对应的 Rect 信息,然后通过 Rect.contains(Rect r)
来判断两个控件对应的 Rect 是否完全包含即可。
控件符合上述的可被点击或长按且在当前界面可见这两个条件,其信息就会被并上传至管理后台,用户就可以对这个控件进行编辑,绑定自定义的业务 ID,管理后台得到控件与自定义业务 ID 的关联关系后,即可生成配置表,并下发至 App。这样采集上来的埋点就会带上自定义业务 ID,用户在后续的数据使用过程中就可以非常方便的查看相应的业务指标。
可视化管理后台核心的逻辑就是上述的客户端和管理后台建立连接并上传相应信息,其他配置的生成、下发等都非常容易处理,就不在赘述。
阶段三:埋点DSL
文章开头我们有提到过,无论是无痕埋点还是可视化埋点,都是基于自动化采集埋点的方式来做的,在这样的采集方式下,我们无法通过埋点携带更多的信息,这也是我们面临的一个痛点。基于这样的需求之下,我们考虑可以用DSL来解决这个问题。
什么是DSL
DSL 即 Domain-specific language,翻译为领域特定语言,意为在特定领域解决特定任务的语言。
哪些场景下需要用到DSL
上文提到的自动埋点以页面和控件为切入点,hook 页面曝光和控件点击事件,并获取页面及控件相关信息作为特征值写入埋点。在简单的场景下,这样的逻辑尚可胜任,但在某些复杂的场景,比如典型的 banner 轮播、资源位曝光等,控件相同但实际内容不同的埋点,无法根据控件信息来区分。对于手动埋点而言,获取接口内的信息,然后传入埋点就能进行区分,但是自动埋点无法关联这部分接口信息,于是需要 DSL 来定义简单的规则,通过运行时的方式来获取内存中的这部分数据,从而写入埋点,进行更加精细的区分。
如何实现DSL
DSL 的构建与编程语言其实比较类似,想想我们在重新实现编程语言时,需要做那些事情;实现编程语言的过程可以简化为定义语法与语义,然后实现编译器或者解释器的过程,而 DSL 的实现与它也非常类似,我们也需要对 DSL 进行语法与语义上的设计。总结下来,实现 DSL 总共有这么两个需要完成的工作:
- 设计语法和语义,定义 DSL 中的元素是什么样的,元素代表什么意思
- 实现解释器,对 DSL 解析,最终通过反射(runtime)来执行
设计语法和语义
这部分其实是千人千面的,我们可以根据自己的业务需求来不断的迭代,但是核心思路是定义一些特殊的字符串,并对应调用各自的 API,一些简单的语法大致有以下这些:
- 用
.
来标识对象调用,比如test.a
表示实例test
中的a
字段 - 用
.()
来表示方法调用,比如test.test()
表示实例test
中的test()
方法调用 - 用
[]
来表示数组或列表
实现解释器
说是解释器,其实只是一段预先写好在 SDK 内的代码逻辑。通过预先约定好的语法和语义,业务开发者在可视化平台针对某个控件进行代码编写,然后下发这部分代码,SDK 根据规则解析这部分代码,然后通过反射(runtime)的方式来获取相应的数据并写入自动埋点。
平台配套
可视化平台在元素录入的时候或者后期编辑的时候,可以额外录入事件发生时想要获取的数据的路径,这部分内容需要由业务开发人员根据 SDK 这边给出的规则进行路径的录入。成功录入后,生成配置文件下发至 App。SDK 在事件发生时,获取到相应事件携带的数据路径,根据 DSL 约定的规则解析路径并获取相应的数据,存放至埋点相应字段内上传。
总结
最后
对于程序员来说,要学习的知识内容、技术有太多太多,要想不被环境淘汰就只有不断提升自己,从来都是我们去适应环境,而不是环境来适应我们!
最后,我再重复一次,如果你想成为一个优秀的 Android 开发人员,请集中精力,对基础和重要的事情做深度研究。
对于很多初中级Android工程师而言,想要提升技能,往往是自己摸索成长,不成体系的学习效果低效漫长且无助。整理的这些架构技术希望对Android开发的朋友们有所参考以及少走弯路,本文的重点是你有没有收获与成长,其余的都不重要,希望读者们能谨记这一点。
为了大家能够顺利进阶中高级、架构师,我特地为大家准备了一套高手学习的源码和框架视频等精品Android架构师教程,保证你学了以后保证薪资上升一个台阶。
以下是今天给大家分享的一些独家干货:
弯路,本文的重点是你有没有收获与成长,其余的都不重要,希望读者们能谨记这一点。
为了大家能够顺利进阶中高级、架构师,我特地为大家准备了一套高手学习的源码和框架视频等精品Android架构师教程,保证你学了以后保证薪资上升一个台阶。
以下是今天给大家分享的一些独家干货:
[外链图片转存中…(img-1augYypQ-1723530346712)]