背景
在深度使用 Flutter 开发过程中,我们遇到了业务代码耦合严重,代码可维护性糟糕,如入泥泞。我们需要一个统一的应用框架来摆脱当下的开发困境,而这也是 Flutter 领域空缺的一块处女地。
Fish Redux 是为解决上面问题上层应用框架,它是一个基于 Redux 数据管理的组装式 flutter 应用框架, 特别适用于构建中大型的复杂应用。
它的最大特点是配置式组装, 一方面将一个大的页面,对视图和数据层层拆解为互相独立的 Component|Adapter,上层负责组装,下层负责实现,另一方面将 Component|Adapter 拆分为 View,Reducer,Effect 等相互独立的上下文无关函数。所以它会非常干净,易编写、易维护、易协作。
Fish Redux 的灵感主要来自于 Redux、React、Elm、Dva 这样的优秀框架,而 Fish Redux 站在巨人的肩膀上,将集中,分治,复用,隔离做的更进一步。
业内情况
无痕埋点
无痕埋点也可称为无埋点或者全埋点,即在端上自动采集并上报尽可能多的数据,在计算时筛选出可用的数据。其优点是:很大程度上减少开发、测试的重复劳动,数据可以回溯并且全面。缺点是:采集信息不够灵活,并且数据量大。
可视化埋点
可视化埋点是通过可视化工具选择需要收集的埋点数据,下发配置给客户端,从而解析配置采集相应埋点的方式。其优点是:很大程度上减少开发、测试的重复劳动,数据量可控,可以在线上动态的进行埋点配置,无需等待 App 发版。其缺点同样是采集信息不够灵活,并且无法解决数据回溯的问题。
阶段一:无痕埋点
分析公司常用的一些数据指标,我们发现对于大部分指标而言,我们只需要有页面的曝光事件、控件的点击事件等一些发送时机、内容相对固定的埋点即可,而这部分埋点,恰恰可以比较方便的使用自动埋点(相对于代码埋点这种手动埋点来说,无痕埋点及可视化埋点均可被称为自动埋点)来进行采集。
相对于可视化埋点来说,无痕埋点在前期不需要可视化工具进行埋点收集,SDK 开发投入较小,因此我们进行了第一步从手动埋点到无痕埋点的迭代。
无痕埋点技术实现
无痕埋点需要自动采集数据,因此针对页面、控件等元素需要生成其 ID,该 ID 需尽量具备『唯一性』和『稳定性』。『唯一性』非常好理解,因为对于任意元素而言,其 ID 应该是与其他所有元素都不同的,这样我们才能根据 ID 唯一标识出那个我们想要的元素,采集上来的数据才是准确的,不重复的。而『稳定性』则是说,元素的 ID 应尽量不受版本的变动而改变,这样后期关联业务含义的操作才会更加便捷。
页面ID规则
页面的 ID 较容易定义,参考上文提到的『唯一性』和『稳定性』,我们很容易就可以想到将页面所在类的类名作为 ID。类名作为 ID,首先它是相对唯一的,除了页面复用,不存在其他类名相同的页面,而页面复用的情况可以通过页面标题名称等方式进行规避;其次它是相对稳定的,只有在页面类名被修改的情况下 ID 才会改变,而我们日常开发的过程中,除了一些页面重大的改版之外不会轻易修改类名。在 Android 中,页面有两种类型 Activity 和 Fragment,Fragment 可以镶嵌在不同的 Activity 内,因此两者的 ID 定义规则有些不同:
Activity,ID 规则为ActivityClassName|额外参数
Fragment,ID 规则为ActivityClassName[FragmentClassName]|额外参数
页面PV、UV
有了页面的唯一 ID 生成的规则,我们只需要在页面曝光的时候,生成这个 ID,然后上传即可实现页面的 PV、UV 指标。至于页面曝光的时机,在 Android 开发中很容易可以找到,因为对于 Activity 和 Fragment 而言都有标准的生命周期。针对业务中 PV、UV 的定义,我们可以将 Activity 的onResume()
方法,Fragment 的onResume()
、setUserVisibleHint(boolean isVisibleToUser)
、onHiddenChanged(boolean hidden)
方法作为曝光时机,在上述方法被回调时,调用 SDK 埋点方法,生成 ID 然后上传埋点。
Activity 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,用户在后续的数据使用过程中就可以非常方便的查看相应的业务指标。
可视化管理后台核心的逻辑就是上述的客户端和管理后台建立连接并上传相应信息,其他配置的生成、下发等都非常容易处理,就不在赘述。
《Android学习笔记总结+最新移动架构视频+大厂安卓面试真题+项目实战源码讲义》开源
Android开发优秀开源项目:
/public/P7/Android/git
/android
最后
Android学习是一条漫长的道路,我们要学习的东西不仅仅只有表面的 技术,还要深入底层,弄明白下面的 原理,只有这样,我们才能够提高自己的竞争力,在当今这个竞争激烈的世界里立足。
人生不可能一帆风顺,有高峰自然有低谷,要相信,那些打不倒我们的,终将使我们更强大,要做自己的摆渡人。
资源持续更新中,欢迎大家一起学习和探讨。
/android
最后
Android学习是一条漫长的道路,我们要学习的东西不仅仅只有表面的 技术,还要深入底层,弄明白下面的 原理,只有这样,我们才能够提高自己的竞争力,在当今这个竞争激烈的世界里立足。
人生不可能一帆风顺,有高峰自然有低谷,要相信,那些打不倒我们的,终将使我们更强大,要做自己的摆渡人。
资源持续更新中,欢迎大家一起学习和探讨。
如果觉得《5214页PDF的进阶架构师学习笔记 阿里巴巴内部Jetpack宝典意外流出》对你有帮助,请点赞、收藏,并留下你的观点哦!