Android ABTest 设计与原理
0 概念
A/B
测试是为 Web 或 app 界面或流程制作两个(A/B)或多个(A/B/n)版本,在同一时间维度,分别让组成成分相同(相似)的访客群组随机的访问这些版本,收集各群组的用户体验数据和业务数据,最后分析评估出最好版本正式采用。
摘自百度百科
其他有关
A/B
的内容和作用,可以参考 abtest-现状,困境以及解决方案,HubbleData通用A/B测试服务揭秘 等
在 app 开发中,也有很多涉及 A/B
测试的逻辑。既有 UI 界面相关,如购物车去凑单按钮的设计;也有纯逻辑相关,是否支持 httpDNS 等。经过多版本的迭代,我们需要管理 A/B/n
测试各个实例,如部分实例需要废弃,部分实例需要调整默认项(未指定时的默认选项),新加的实例等。
参考 ABTest 全链路,涉及客户端 (实行 A/B/n 逻辑执行和数据采集),后端(A/B/n 数据生成、下发、分析)、前端(A/B/n 测试可视化面板)等,本文仅关注 Android 客户端的 ABTest 框架如何实现,部分 ui 相关的测试数据如何生成。
1. 现有 A/B 测试应用情况及考虑
1.1 AppAdhoc
参考 AppAdhoc Android SDK 的使用,虽然已经提供了 A/B 测试
的数据提供接口,然而还是能发现几个明显问题:
- 数据使用上,还是需要业务层写大量的
if/else
逻辑 - 相同的 ABTest 实例,在不同的页面,容易出现重复代码
- 后期维护容易出错,如部分测试实例需要废弃,需要工程中找出多处逻辑并修改
- 不支持普通 ui 属性修改和布局修改
// 'model01' 对应网站添加的产品模块名称
boolean flag = AdhocTracker.getFlag("module01", false);
if (flag) {
btn01.setBackgroundColor(getResources().getColor(android.R.color.black));
btn01.setTextColor(getResources().getColor(android.R.color.white));
btn01.setTextSize(getResources().getDimension(R.dimen.textsize_small));
btn01.setText("实验版本B");
tv_tracking.setVisibility(View.VISIBLE);
} else {
btn01.setBackgroundColor(getResources().getColor(android.R.color.white));
btn01.setTextColor(getResources().getColor(android.R.color.black));
btn01.setTextSize(getResources().getDimension(R.dimen.textsize));
btn01.setText("实验版本A");
tv_tracking.setVisibility(View.GONE);
}
AppAdhoc Android SDK 使用样例
1.2 云眼
参考 云眼 Android,支持线上 UI 属性修改。
eyecloud_ui_edit.jpg其前端编辑界面移植 mixpanel
代码,前端编辑操作较为方便,但也有局限如下:
- 不支持自定义控件,甚至较为常用的第三方库,如
Fresco
等无法识别 - 前端界面无法处理
Dialog
和PopupWindow
- 不支持动态重布局
1.3 线上动态支持方案考虑
若 app 部分模块已使用 H5 页面,或者使用 RN、weex 等动态化框架实现,则这部分逻辑已经原生支持线上动态支持 ABTest。若 APP 业务模块已经实现了拆分和插件化,则插件模块也支持线上动态 A/B
(参考 携程Android App插件化和动态加载实践)。上述 2 种情况,同时支持纯 UI 和普通逻辑的线上动态 A/B
测试,而缺点也十分明显:
-
针对非动态化页面和宿主包部分代码,无法支持线上动态
很多 app 集成了动态化框架,然而一般是少量经常变化的页面才会使用 weex 等实现
H5 页面相比会使用的更加广泛,严选详情页、专题页、会员中心等页面都会使用 H5,而本文更关注的 native 的
A\B
实现。H5 的相关内容可查看 abtest-web在线页面编辑实现-abtest可视化实验,abtest-现状,困境以及解决方案,HubbleData通用A/B测试服务揭秘 -
现有 app 支持插件化且支持动态下发比较少,而为了
A/B
测试集成插件化就很难想象了相比更多 app 支持了业务模块化,但模块化并不支持动态加载
-
用户更新频繁
A\B
测试在 app 后期优化阶段,会用的比较频繁,而如果每次都是全量动态脚本代码或是全量插件包下发,流量会有一定消耗,开发者需要考虑增量更新,而增量更新又需要一个增量包的管理平台
除了 H5、动态化和插件化等方案,也有如 Tangram 这种半动态化方案,将 RecycleView
的每个 ViewHolder 看成卡片,通过动态下发 json 数据或自定义格式的 xml 来动态定制卡片的 UI 布局。
recyclerView = (RecyclerView) findViewById(R.id.main_view);
//Step 1: init tangram
TangramBuilder.init(this.getApplicationContext(), new IInnerImageSetter() {
@Override
public <IMAGE extends ImageView> void doLoadImageUrl(@NonNull IMAGE view,
@Nullable String url) {
Picasso.with(TangramActivity.this.getApplicationContext()).load(url).into(view);
}
}, ImageView.class);
//Tangram.switchLog(true);
mMainHandler = new Handler(getMainLooper());
//Step 2: register build=in cells and cards
builder = TangramBuilder.newInnerBuilder(this);
//Step 3: register business cells and cards
// recommend to use string type to register component
builder.registerCell("testView", TestView.class);
...
// register component with integer type was not recommend to use
builder.registerCell(1, TestView.class);
builder.registerCell(10, SimpleImgView.class);
...
// 支持自定义的 xml 布局,但需要编码注册好
builder.registerVirtualView("vvtest");
//Step 4: new engine
engine = builder.build();
engine.setVirtualViewTemplate(VVTEST.BIN);
engine.setVirtualViewTemplate(DEBUG.BIN);
...
//Step 6: enable auto load more if your page's data is lazy loaded
engine.enableAutoLoadMore(true);
//Step 7: bind recyclerView to engine
engine.bindView(recyclerView);
...
查看使用,从 ABTest
角度也可以发现 Tangram
也有较大的局限性:
- 绑定仅支持
RecyclerView
- 需要事先在代码中编写如上的
Tangram
初始化代码 - 能支持的卡片类型初始化的时候预置
2 A/B Test 考虑和框架目标
针对 H5、动态化框架,不能因为 A/B
测试将大部分 Native 页面改成脚本页面;同理,app 也不能因为 A/B
而集成插件化,为此个人认为完全动态的线上 A/B
能力并不现实
排除热更新方案,热更新应该仅用于线上问题修复;
已经使用动态化框架、插件化的 APP,可以顺带支持下线上A/B
动态能力;
考虑线上相当一部分场景是纯 UI 界面改动的 A/B
测试,如重新布局,部分文案颜色修改等,而这部分场景我们可以通过其他手段来实现线上动态的目标。剩余复杂 UI 场景和业务逻辑场景,可代码写入 app,等线上启用。
图 2-1 严选第一个版本的 ABTest 实例,协助分析不同 UI 样式下,用户凑单的形式
针对上述情况,我们可以理解为是简单的布局重排逻辑,其中 去凑单
的隐藏,可以通过设置 View 宽度为 0 实现。若按照常规的 ABTest
框架,如 AppAdhoc 等,还是需要等待 APP 版本发布并上线才能支持,若能有一套线上动态布局的方案,就可以在运营产品和分析师提出需求时,立马线上实施得到数据。
2.1 框架目标
我们需要一套框架,解决上述问题,并对业务层开发透明
-
支持同步后台 A/B 测试 json 数据
-
提供多种生效策略,支持立即生效、热启动生效和冷启动生效
-
针对业务逻辑 A/B 测试,提供实例编写规范,避免业务层
if/else
逻辑业务层逻辑并不需要自己现在执行的是 A 还是 B
-
方便 AB 测试实例的统一管理和后期维护
-
针对普通 UI 属性,支持线上动态实验
-
提供一定能力的动态布局能力,创建新的布局
动态布局,可以分为重排版和替换为新布局
3 A/B/n 测试使用规范及实现
3.1 A/B/n 测试使用规范
约定 ABTest 实例的 json 数据格式如下:
//abtest.json
[
{
"itemId":"SimpleTest_001",
"accessory":"",
"testCase":{
"caseId":"001",
"accessory":""
}
},
{
"itemId":"SimpleTest_002",
"accessory":"",
"testCase":{
"caseId":"000",
"accessory":""
}
}
]
代码样例 3-1;
id 是SimpleTest_001
和SimpleTest_002
的测试数据;
itemId
指定具体是哪个 ABTest,caseId 指定 A or B
可以理解相同的 ABTest case,如果在程序逻辑中有多处,那么这些代码应该都是一致的,同时业务层不应该关心当前是否有对应 ABTest 的 json 数据(如果没有走 A/B/n
的默认逻辑,这里假设 "000" 为默认逻辑)。基于此,对应每个 ABTest case 都封装了对应的类
@ABTesterAnno(itemId = "SimpleTest_001", updateType = ABTestUpdateType.IMMEDIATE_UPDATE)
public class OneABTester extends BaseABTester {
private String name;
public OneABTester() {
}
@Override
protected void onUpdateConfig() {
}
@ABTestInitMethodAnnotation(caseId = "000", defaultInit = true)
public void initA(@Nullable String accessory, @Nullable ABTestCase testVO) {
name = "hanmeimei";
}
@ABTestInitMethodAnnotation(caseId = "001")
public void initB(@Nullable String accessory, @Nullable ABTestCase testVO) {
name = "lilei";
}
@ABTestInitMethodAnnotation(caseId = "002")
public void initC(@Nullable String accessory, @Nullable ABTestCase testVO) {
name = "lili";
}
public String getName() {
return name;
}
}
-
注解
ABTesterAnno
指定了 ABTest 的itemId
; -
注解
ABTesterAnno
指定了 ABTest 的updateType
- ABTestUpdateType.IMMEDIATE_UPDATE:json 数据请求更新,主动回调 onUpdateConfig 方法
- ABTestUpdateType.HOT_UPDATE:json 数据请求更新后,重新创建
ABTester
生效 - ABTestUpdateType.COLD_UPDATE:json 数据请求更新,需等到下次 app 启动生效
-
注解
ABTestInitMethodAnnotation
指定了对应测试 case 触发时,会被执行初始化的代码- 若对应
itemId
数据无或并没有找到匹配的testId
,则执行defaultInit
指定的初始化方法 - 若有对应
itemId
和对应testId
执行匹配的初始化方法 - initA,initB,initC 并无命名要求
- 初始化方法中,必须要有一个且仅有一个指定
defaultInit = true
- 若对应
查看 ABTest 实例的 json 数据查看 代码样例 3-1
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
List<ABTestItem> testItems = parseJsonFromAsset();
ABTestConfig.getInstance().init(this.getApplication(), testItems, ABTestFileUtil.readUiCases(this));
OneABTester test1 = new OneABTester();
TextView tvName = (TextView) findViewById(R.id.tv_name);
tvName.setText(test1.getName());
}
simple_test_case_0.jpg
图 3-1 根据
SimpleTest_001
指定的 caseId001
,执行初始化方法 initB,显示 lilei
// ABTest 初始化,设置为 null,未指定任何数据
ABTestConfig.getInstance().init(this.getApplication(), null, ABTestFileUtil.readUiCases(this));
OneABTester test1 = new OneABTester();
TextView tvName = (TextView) findViewById(R.id.tv_name);
tvName.setText(test1.getName());
simple_test_case_1.jpg
图 3-2 运行结果,结果显示由
defaultInit
指定的 caseId000
,执行初始化方法 initA,显示 hanmeimei
3.2 实现原理
上述逻辑封装较为简单,具体逻辑如下:
-
ABTestConfig
单例初始化后,会记录全部的ABTestItem
,并提供接口使用itemId
查询的接口。// ABTestConfig.java public void init(Application app, List<ABTestItem> normalCases, List<ABTestUICase> uiCases) { if (normalCases == null) { normalCases = new LinkedList<>(); } ... mABTestConfigModel.abtestConfig = normalCases; ... notifyAllTesters(); } ... public ABTestItem getNormalCase(String itemId, ABTestUpdateType updateType) { // 1. 如果是立即更新或热启动更新,则从 mABTestConfigModel.abtestLasestNorCases 尝试获取 itemId 匹配的值,并返回 // 2. 尝试从 mABTestConfigModel.abtestNorCases 获取 itemId 匹配的值,并返回 // 3. 若找不到,返回 null }
-
ABTest 实例创建的时候,在构造函数中会根据注解的值去查询配置数据,查询并设置初始化方法和有效的 ABTest 数据实例。
public abstract class BaseABTester { protected ABTestItem mTestCase; protected String mItemId; private ABTestCase mValidTestVO; private Method mInitABMethod; public BaseABTester() { ABTesterAnno anno = getClass().getAnnotation(ABTesterAnno.class); if (anno != null) { mItemId = anno.itemId(); mTestCase = ABTestConfig.getInstance().getNormalCase(mItemId); chooseInitMethod(getTestCase()); // 记录全部的 ABTest 实例,用于后期数据更新通知 ABTestConfig.getInstance().mABTesterRefs.add(new ObjWeakRef<>(this)); } } private void chooseInitMethod(ABTestCase testCase) { // 寻找含有 ABTestInitMethodAnnotation 注解的初始化方法 // 1. 根据 caseId 找到对应方法,设置 mInitABMethod 和 mValidTestVO // 2. 找不到对应方法,根据 defaultInit 找到默认初始化方法,设置 mInitABMethod(mValidTestVO 为null) } ... }
-
ABTest 实例执行选择的初始化方法
protected void initAB() { if (!mIsInited) { mIsInited = true; ABTestCase testVO = getValidTest(); if (mInitABMethod != null) { invokeMethod(mInitABMethod, testVO); } } }
通过反射运行初始化方法,然而由于初始化方法是子类的中定义,为此不能在基类的构造函数中执行,只能在子类构造函数的执行的最后执行。
@ABTesterAnno(itemId = "SimpleTest_001")
public class OneABTester extends BaseABTester {
...
public OneABTester() {
initAB();
}
...
}
```
而通过编码规范要求各个 ABTest 实例的构造函数最后写 `initAB()`,个人感觉比较机械,而且容易被业务开发遗漏。这里通过 `aspectJ` 在业务层的全部的 ABTest 实例子类的构造函数的最后插入 `initAB()` 执行初始化方法
```java
@Aspect
public class AspectABTester {
@After("execution(com.netease.lib.abtest.BaseABTester+.new(..)) && !within(com.netease.lib.abtest.BaseABTester)")
public void afterMethodExecution(JoinPoint joinPoint) {
...
((BaseABTester) joinPoint.getTarget()).initAB();
}
}
```
3.3 小结
以上讲述了普通 ABTest 实例的编码使用和原理,对于上层业务层完成以下目的:
- 使用注解标记 ABTest 的 itemId 和 caseId,代码逻辑更加清晰
- 支持立即更新、热启动更新、冷启动更新
- 隐藏了 ABTest 的原始数据解析和使用
- 避免了业务开发使用
if/else
执行对应的A/B/n
逻辑流程, - 将全部和 ABTest 相关的业务代码封装到实例子类当中,方便 ABTest 对象管理,避免业务层多处使用相同 ABTest 产生的重复代码
4 如何定位控件 - ViewID
在讲述如何线上动态修改控件属性,修改替换 UI 布局等之前,首先需要处理的是如何定位目标控件。为此,需要为界面上的每一个控件分配一个唯一的 ViewID
。这里同埋点方案的 ViewId 概念基本一致,需要具备唯一性和一致性,但也有差异。埋点方案中需要准确区分每一个 View,比如 ListView,RecyclerView 的相同 type 的 item view,必须认为是不一样的,甚至相同 item view 实例由于复用而导致的 position 不一致,ViewID 也必须要是不一致的。而这里的场景是为了 ABTest,如果列表中只有一个 item view 发生布局变化意义并不大。为此认为同一个 ListView 或 RecyclerView 中相同 type 的 item view 都是一致的,需要计算出相同的 ViewID。
在埋点方案中也有类似的 ViewID 概念,此 ViewID 需要具备唯一性和和一致性。唯一性是指每个 View 的 ViewID 都是唯一的,不会与其他的 View 的 ViewID 发生重复。一致性是指 APP 运行过程中,多次进入相同界面,或者界面发生变化,View 的 ViewID 都不会发生变化。
4.1 现有方案
首先排除 View.getId(),因为布局文件中未指定 id 和动态代码 new 出来的 View 都是 NO_ID
,而即便是布局文件中指定了 id 的 view,在不同版本编译产生的 id 也可能不一致。
参考无埋点技术,ViewID 主流的技术方案有 XPath
和 TouchTarget
。
4.1.1 XPath
XPath 方法较为主流,如 mixpanel、百分点埋点、网易乐得埋点、网易HubbleData。基本原理是根据当前 view 到 rootView(android.R.id.content)的路径,并结合当前界面的 Activity,Fragment,view tag,view id 等,最终生成一个字符串表示当前 View 的 ViewID。
上述各家方案,会有细节差异,但 view tree 逻辑基本思路一致
简单示例如下:
viewpath_layout.png图 4-1-1
针对以上布局,其 view tree 如下:
viewpath_viewtree.png图 4-1-2 view tree
若要计算第 4 层第 3 个节点的 TextView 的 ViewID,可以根据当前节点到根节点的路径,结合当前 Activity、Fragment 等额外信息来表示。
XPath
方法在页面动态变化较多的场景,如 View 动态插入、删除等情况,就不太容易能保证唯一性和一致性。为此各家埋点方案也做了很多的优化方案,比较常见的一种优化是:相同层级 view 的 index 计算修改为根据同类型控件 index 计算。
如上图,当 id 为 btn1
的 Button 被移除会导致后面的全部控件的 view path 发生变化,这些控件的 ViewID 一致性就无法保证,甚至节点 3 的 TextView index 变成 2,ViewID 的唯一性也无法保证了
图 4-1-3
若相同层级根据同类型 view 之间的 index 标记,则可以避免这种情况:
viewpath_viewtree_opt_after.png图 4-1-4 此时如果
btn1
被移除了,后面的 TextView ViewID 并不会受影响。
其他如何计算 ViewPager、ListView、RecyclerView 里的 ItemView 的 ViewID,以及 Fragment 中的控件 ViewID 等,如何保证一致性和唯一性的优化方案,参考以下文章,这里不在重复描述
4.1.2 利用 TouchTarget 计算 ViewID
该方案参考 得到Android团队无埋点方案
由于无埋点基本上解决的是线上控件点击的埋点事件收集,所以作者从 View 点击发生时的运行时信息入手,通过在 Activity 的 window 上调用 window.setCallback()
接管窗口的事件派发,在 dispatchTouchEvent
函数中处理 up 事件,通过 ViewGroup TouchTarget
链表找到当前交互的目标控件,最后通过 Activity 类名
+ 控件所在的 layout 文件名
+ 控件 id 对应的资源名
来确定目标控件的唯一标识。
其中 layout 文件的根 View id 和控件所在的 layout 文件名一致,子 View 的 id 名不能和根 View id 一样,同时各个 View 之间的 View id 均不能一致。除此之外还有其他规则。具体规则的保证,作者提供了 自定义 Lint 检查工具
4.2 方案选择与实现优化
根据当前目标,线上动态修改目标 View 的属性,为此必须在 Activity 界面展示给用户看之前就找到目标 View 并修改属性,为此 TouchTarget
计算 ViewID 方案并不可行,不能等到用户点击才计算 ViewID。XPath
方案基本符合当前场景,但也存在部分不符合场景和缺陷的地方:
- ViewTree 动态变化的场景适应力有限
- ListView、RecyclerView 等 ItemView 不能以 position 区分,而是以 type 区分
4.2.1 ViewTree 动静分离适配动态变化
见图 4-1-4
,已有的 XPath
方法能较好的处理 btn1
被移除的情况,而 btn1
的下一个节点(红色 TextView)被移除,则还是会导致下一个 TextView 的 ViewID 一致性失效,同时 ViewID 变成被移除 TextView 的 ViewID,则唯一性也失效了。
考虑到 app 中显示的 UI 界面基本以 xml 生成,而 java 代码代码动态生成的场景较少(从规范上,也不推荐)。为此,重新查看图 4-1
,可以发现当前布局全部由 layout xml 布局决定,为此 ViewTree 中的每个节点(除了根节点 android.R.id.content)的 ViewID 可以由 layout xml 的 ViewTree 结构唯一决定,不管是在 ViewTree 中插入节点还是删除节点,ViewTree 中保留节点 的 ViewID 还是应该按照 layout xml 的 ViewTree 计算,而不应该按照新的动态场景树计算,所以原有节点 ViewID 均不受影响,而新插入的节点还是按照 XPath
原有的方式计算 ViewID。
根据以上考虑,我们需要将 ViewTree 的全局节点做分类。这里引入新的概念:
-
静态布局:利用 layout xml 生成的 ViewTree
-
动态布局:利用 java 代码生成的 ViewTree,或者在已有 ViewTree 上进行删除、插入操作
-
静态布局节点:静态布局的子节点,不含根节点(根节点最终要动态加入 android.R.id.content 或其他布局)
-
动态布局的节点:包括 java 代码动态 new 出来的 view 和静态布局的根节点
动态布局节点的 index 计算,需要根据兄弟动态节点计算(隔离静态布局和动态布局之间的干扰),另外计算的是相同类型节点的索引
-
全局
XPath
:当前节点在整个页面布局 ViewTree 上的XPath
值,经过sha256
加密就是最终的 ViewID 值 -
局部静态
XPath
:当前节点由 layout xml 生成,当前节点到 layout 根节点的XPath
值- 根节点会有标记,标识当前节点是根节点;
- 全部局部节点都有标记是哪个 layout 布局的节点;
- 叶子节点或子树被动态移除,被移除的全部节点 layout 布局的标记需要清除,之后若加入场景树,全部节点都认为是动态布局;
- 子节点 index 根据在父节点的位置决定,不用按照相同类型的节点来算节点
继续针对 图 4-1-2
,我们删除橘红色节点 TextView,并在当前位置插入另一个布局 view_third_insert.xml
和一个 TextView
,则当前 ViewTree 如下图所示:
<!-- view_third_insert.xml -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/text3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:text="text3"/>
<TextView
android:id="@+id/text4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:text="text4"/>
</LinearLayout>
viewpath_viewtree_myopt_after.png
viewpath_viewtree_opt_after_1.png图 4-2-1 新的布局
图 4-2-2 静态布局和动态布局区分后的 ViewTree;
黑色节点为动态布局节点,红色节点为静态布局节点
按照优化后的 XPath 计算,我们把静态布局和动态布局做了区分,白色是根节点,蓝黑色的全部节点是由 activity_third.xml
生成,亮蓝色的全部节点由 view_third_insert.xml
生成,绿色节点由 java 代码动态生成。此时我们可以发现第 4 层的第 5 个节点(index 为 1 的 TextView)的 XPath
计算并不受影响,索引依然为 3,根据它最初在静态布局中的索引,而不是因为前面动态加入的绿色 TextView 节点计算得到。动态加入的绿色节点,不管是在下一个 TextView 的前面还是后面,它的 index 均为 0,隔离了静态布局和动态布局之间的相互影响
优化后的 XPath 计算结果:
-
index 3 的 TextView(图 4-2-1 数字 3 的蓝黑色节点)
XPath:[{"className":"ContentFrameLayout","environment":"com.netease.demo.abtest.third.ThirdActivity","idName":"content","index":0},{"className":"LinearLayout","index":0,"resName":"activity_third"},{"className":"LinearLayout","index":0,"resName":"activity_third"},{"className":"android.support.v7.widget.AppCompatTextView","index":3,"resName":"activity_third"}] ViewID:30802f2fa775198da5b6d5e59d098a5f8adc47a744ba5f0bc6e1dcbc417e42be
其中节点的局部静态
XPath
为:[{"className":"LinearLayout","index":0,"resName":"activity_third"},{"className":"LinearLayout","index":0,"resName":"activity_third"},{"className":"android.support.v7.widget.AppCompatTextView","index":3,"resName":"activity_third"}]
根节点所在的动态布局
XPath
为:[{"className":"ContentFrameLayout","environment":"com.netease.demo.abtest.third.ThirdActivity","idName":"content","index":0},{"className":"LinearLayout","index":0,"resName":"activity_third"}]
-
绿色节点 TextView
XPath:[{"className":"ContentFrameLayout","environment":"com.netease.demo.abtest.third.ThirdActivity","idName":"content","index":0},{"className":"LinearLayout","index":0,"resName":"activity_third"},{"className":"LinearLayout","index":0,"resName":"activity_third"},{"className":"TextView","index":0}] ViewID:6ec1e6ee512db7c031ed0a638a2320496da5e9ae84e092eaa19fe8e297b0f830
-
R.id.text3 的 TextView(图 4-2-1,数字为 0 亮蓝色节点)
XPath:[{"className":"ContentFrameLayout","environment":"com.netease.demo.abtest.third.ThirdActivity","idName":"content","index":0},{"className":"LinearLayout","index":0,"resName":"activity_third"},{"className":"LinearLayout","index":0,"resName":"activity_third"},{"className":"LinearLayout","index":0,"resName":"view_third_insert"},{"className":"AppCompatTextView","index":0,"resName":"view_third_insert"}] ViewID:b184ec2565fff410af9ffad5a5cd6ace1773b4899f07970eaf499d9b675ff462
4.2.2 局部静态 XPath 计算
以上动静 XPath
分离的方案,关键是如何计算局部静态 XPath
。我们必须在布局 xml inflate 后就针对当前局部布局计算并保存。查看我们的 Activity 的常规写法:
public class ThirdActivity extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_third);
...
}
...
}
可以看到 super.onCreate(...) 在 setContentView(...) 前面。其中,super.onCreate(...) 里面会调用 ActivityLifecycleCallbacks.onActivityCreated(...)
,而 setContentView(...)
里面会调用 LayoutInflator.inflate(...)
为此我们可以在 ActivityLifecycleCallbacks.onActivityCreated(...)
替换 LayoutInflator
private void replaceActivityLayoutInflater(Activity activity) {
LayoutInflater inflater0 = (LayoutInflater) activity.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
if (!(inflater0 instanceof ABTestProxyLayoutInflater)) {
LayoutInflater proxyInflater = new ABTestProxyLayoutInflater(inflater0);
RefInvoker.setFieldObject(activity, ContextThemeWrapper.class, "mInflater", proxyInflater);
}
Window window = activity.getWindow();
LayoutInflater inflater1 = activity.getWindow().getLayoutInflater();
if (!(inflater1 instanceof ABTestProxyLayoutInflater)) {
LayoutInflater proxyInflater = new ABTestProxyLayoutInflater(inflater1);
if (RefInvoker.isInstanceOf(window, "com.android.internal.policy.PhoneWindow")) {
RefInvoker.setFieldObject(window, "com.android.internal.policy.PhoneWindow", "mLayoutInflater", proxyInflater);
} else if (RefInvoker.isInstanceOf(window, "com.android.internal.policy.impl.PhoneWindow")) {
RefInvoker.setFieldObject(window, "com.android.internal.policy.impl.PhoneWindow", "mLayoutInflater", proxyInflater);
}
}
}
正常 LayoutInflator.from(Context), setContentView(...) 使用的是
inflater0
正常 Dialog,PopupWindow 使用的是
inflater1
替换之后我们就可以在 LayoutInflator.inflate
方法中计算局部静态 XPath
了
@Override
public View inflate(int resource, ViewGroup root) {
View result = mInflater.inflate(resource, root);
View created = (root != null && root.getChildCount() > 0) ?
root.getChildAt(root.getChildCount() - 1) :
result;
ViewPathUtil.setXmlLayoutLocalPathTag(getContext(), created, resource);
onInflate(created);
return result;
}
4.2.3 ListView,RecyclerView,Spinner 等特殊控件处理
针对 ListView
,RecyclerView
等控件,期望同一个配置能使相同 type
的 ItemView 都生效,为此相同 type
的 ItemView 的 ViewID 都要一致。为此,这里不能使用 position
作为 XPath
中的一个变量,而是应该使用 type
。
图 4-2-2
ListView
测试界面。白底 ItemView type 为 0,灰底 ItemView type 为 1。因为
RecyclerView
、Spinner
和ListView
计算XPath
完全类似,所以这里仅仅讲述ListView
。
其中每个 item view 的布局文件为:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="6dp"
android:textSize="15dp"/>
</FrameLayout>
白底 ItemView 里面的 TextView 的 ViewID 结果如下
XPath:[{"className":"ContentFrameLayout","environment":"com.netease.demo.abtest.second.SecondActivity","idName":"content","index":0},{"className":"LinearLayout","idName":"real_main_view","index":0,"resName":"activity_second"},{"className":"FrameLayout","index":0,"resName":"activity_second"},{"className":"FrameLayout","environment":"com.netease.demo.abtest.second.ShoppingCartFragment","index":0},{"className":"ListView","idName":"listview","index":0},{"className":"FrameLayout","resName":"item_list_1","type":0},{"className":"AppCompatTextView","index":0,"resName":"item_list_1"}]
ViewID:e991bec2797470ed5eaaf25973c6538f266c0f53cc622c1e2c88aea3fa8301dd
其中 ItemView 根节点的 ViewPathElement
如下。由于没有 position 信息,所以全部白底 ItemView 里面的 TextView 的 ViewID 全部一致
{"className":"FrameLayout","resName":"item_list_1","type":0}
4.2.4 ViewPager 控件处理
ViewPager
较为特殊,虽然控件中需要区分 child view 是否有 DecorView
注解。decor 类型的 child 不是 ItemView,不参与复用;其他 child 是 ItemView,参与复用。ItemView 这里需要在 ViewPager 每次滑动的时候,更新复用的 ItemView 的 position
。
// ViewPager.java
private static boolean isDecorView(@NonNull View view) {
Class<?> clazz = view.getClass();
return clazz.getAnnotation(DecorView.class) != null;
}
viewpath_viewpager.jpg
图 4-2-2
ViewPager
测试界面
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v4.view.ViewPager
android:id="@+id/vp_viewpager"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.design.widget.TabLayout
android:id="@+id/tab_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="fixed"
app:tabGravity="fill">
</android.support.design.widget.TabLayout>
</android.support.v4.view.ViewPager>
</RelativeLayout>
ItemView 里的 居家
TextView ViewID 计算:
XPath:[{"className":"ContentFrameLayout","environment":"com.netease.demo.abtest.second.SecondActivity","idName":"content","index":0},{"className":"LinearLayout","idName":"real_main_view","index":0,"resName":"activity_second"},{"className":"FrameLayout","index":0,"resName":"activity_second"},{"className":"RelativeLayout","environment":"com.netease.demo.abtest.second.UserPageFragment","index":0},{"className":"ViewPager","idName":"vp_viewpager","index":0},{"className":"FrameLayout","pageIndex":2},{"className":"AppCompatTextView","idName":"text_view","index":0}]
ViewID:c8cbb5dfa8d384c68339f09653a8ac7927581c21cfd539f987e0c7670cd5d3f0
TabLayout
里面的 居家
TextView ViewID 计算:
XPath:[{"className":"ContentFrameLayout","environment":"com.netease.demo.abtest.second.SecondActivity","idName":"content","index":0},{"className":"LinearLayout","idName":"real_main_view","index":0,"resName":"activity_second"},{"className":"FrameLayout","index":0,"resName":"activity_second"},{"className":"RelativeLayout","environment":"com.netease.demo.abtest.second.UserPageFragment","index":0},{"className":"ViewPager","idName":"vp_viewpager","index":0},{"className":"android.support.design.widget.TabLayout","idName":"tab_layout","index":0},{"className":"android.support.design.widget.TabLayout$SlidingTabStrip","index":0},{"className":"android.support.design.widget.TabLayout$TabView","index":2},{"className":"AppCompatTextView","index":0}]
ViewID:b4f1b770e3dd2895ddb343856737eced4813b3bba713ffec9de164f22ceca038
5 控件属性动态修改
控件属性,是指 View
的背景颜色,透明度、是否显示等,TextView
的文本内容、文本颜色等属性。为了支持线上控件属性的动态修改,我们需要解决一下问题:
-
如何定位控件?
参考前面 4 讲述的 ViewID 计算
-
如何定义下发的配置数据?
-
如何将配置数据应用到控件上?
-
如何生成 ABTest 配置数据,如何检查效果?
-
如何处理业务层的自定义控件属性
5.1 配置数据格式定义
这里定义配置文件的格式如下:
[
{
"uiProps": [
{
"floatValue": 0.5,
"intValue": 0,
"name": "alpha"
},
{
"floatValue": 0.0,
"intValue": -1979711233,
"name": "textColor"
},
{
"floatValue": 0.0,
"intValue": 0,
"name": "textSize",
"value": "40.0px"
}
],
"viewID": "22b721d900197856706fc68083c4c3deba5e31a0d8e44438a96eb6473bbc9e0a"
},
...
]
代码 5-2-1
viewID
指定线上的目标控件(这里不需要指定控件类型,因为同一个 viewID
不可能指向多个不同的 view)。uiProps
指定具体的属性数据。如 alpha
指定 View 的 alpha 属性,floatValue
指定新的 alpha 值;textColor
指定 TextView 的文本颜色,intValue
指定颜色值为 #8A0000FF
;textSize
指定 TextView 的字体大小,value
指定新的字体大小为 40.0px
。
5.2 配置数据使用
目标控件必须在 UI 界面被用户看到之前设置相关属性,为此这里有几个时间点能应用:
-
ActivityLifecycleCallbacks.onActivityCreated(Activity activity, Bundle savedInstanceState)
-
LayoutInflater.inflate(@LayoutRes int resource, @Nullable ViewGroup root)
-
onViewAttachedToWindow(View v)
未添加至 Activity 的控件可以做监听设置,在
onViewAttachedToWindow
中触发
根据 4.1 的配置数据,界面生效前后如下所示:
view_prop_apply_case1.jpg图 5-2-1 RecyclerView 的 ItemView 中的 TextView 的属性修改。这里全部的 type 均为 0
其他实例:
配置数据:
{
"uiProps": [
{
"floatValue": 0.0,
"intValue": -16777216,
"name": "textColor"
},
{
"floatValue": 0.0,
"intValue": 0,
"name": "text",
"value": "exit"
}
],
"viewID": "0d46ac5d749c137cb2ab0b65dad0248e09fd745ac24fa31d7dae5d837bac3cec"
}
view_prop_apply_dialog.jpg
图 5-2-2
5.3 配置数据生成
查看 代码 5-2-1 的配置信息,不可能让开发人肉去填写,为此提供了一个可视化的工具
view_prop_edit_demo.gif[
{
"uiProps": [
{
"floatValue": 0.0,
"intValue": 0,
"name": "imageSrc",
"value": "com.netease.demo.abtest/mipmap/android_n_lg"
}
],
"viewID": "267685e5d7299dca525cc7a09b801d59def9c6eb02ef12dacd1f674e4b8e3d0a"
},
{
"uiProps": [
{
"floatValue": 0.0,
"intValue": -1979711233,
"name": "textColor"
},
{
"floatValue": 0.0,
"intValue": 0,
"name": "text",
"value": "Hello World Netease!!!"
},
{
"floatValue": 0.0,
"intValue": 0,
"name": "textSize",
"value": "50.0px"
}
],
"viewID": "f22bc639075f3e7c0f7cbd4be1201716ae73ecec058cb2e9734df51569129400"
},
{
"uiProps": [
{
"floatValue": 0.0,
"intValue": 0,
"name": "text",
"value": "Exit"
}
],
"viewID": "0d46ac5d749c137cb2ab0b65dad0248e09fd745ac24fa31d7dae5d837bac3cec"
}
]
5.4 业务层自定义属性支持
SDK 层面仅能针对系统常见的控件属性提供设置和编辑功能,如针对 View
的 background
、alpha
,针对 TextView
的 text
、textColor
、textSize
,针对 ImageView
等的 src
属性等。而各个业务 app 都会集成相关的第三方组件或自定义控件,SDK 预置的属性永远可能不满足业务方的全部需求。为此就必须支持业务方自定义设置属性和编辑属性。
5.4.1 设置属性自定义
ABTest UI 属性配置数据下发,json 数据如何分配到各个设置类上,这里通过 IPropSetter
的实现类实现。为支持自定义的属性,业务开发实现 IPropSetter
的自定义类。
for (UIProp prop : uiCase.getUiProps()) {
IPropSetter setter = sUIPropFactory.getPropSetter(prop.name);
if (setter != null) {
setter.apply(v, prop);
}
}
通过 IPropSetter.apply 方法设置对应属性
public interface IPropSetter {
/**
* Use to apply view with new TypedValue
* @param view
* @param prop
* @return success or not
*/
boolean apply(View view, UIProp prop);
/**
* @return prop name
*/
String name();
}
IPropSetter 接口。name() 返回属性名,apply(View, UIProp) 设置属性
另外提供了注解 UIPropSetterAnno
,支持编译期将业务层自定义 IPropSetter
实现类加入 sUIPropFactory
.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface UIPropSetterAnno {
}
5.4.2 编辑属性自定义
为支持可视化生成 json 数据,需要编辑 UI 需要支持自定义属性。同样提供了基类 EditPropView
package com.netease.tools.abtestuicreator.view.prop;
...
public class EditPropView<T> extends FrameLayout implements TextWatcher {
...
protected void onRestoreValue(View v) {
}
protected void onUpdateView(View v, Editable value) {
}
protected void onBindView(View v) {
}
...
}
为将业务层自定义的编辑控件加入目标编辑 View 的编辑列表中(不同的类,需要有不同的编辑列表,如 text
属性编辑不能用于 ImageView
),提供了注解 UIPropCreatorAnno
。
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface UIPropCreatorAnno {
Class viewType();
String name();
}
viewType() 返回属性编辑支持的类
name() 返回待编辑的属性名称
5.4.2 自定义属性支持示例
以 SimpleDraweeDrawee
的 setImageURI
为例,定义属性名为 fresco_src
-
自定义设置属性类
@UIPropSetterAnno() public class FrescoSrcPropSetter implements IPropSetter { @Override public boolean apply(View view, UIProp prop) { if (prop.value instanceof String) { Uri uri = Uri.parse((String) prop.value); ((SimpleDraweeView) view).setImageURI(uri); return true; } return false; } @Override public String name() { return "fresco_src"; } }
-
自定义编辑属性类
@UIPropCreatorAnno(viewType = SimpleDraweeView.class, name = "fresco_src") public class SimpleDraweeViewFrescoSrcPropView extends EditPropView<String> { private Uri mOldValue; public SimpleDraweeViewFrescoSrcPropView(Context context) { this(context, null); } public SimpleDraweeViewFrescoSrcPropView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public SimpleDraweeViewFrescoSrcPropView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @TargetApi(Build.VERSION_CODES.LOLLIPOP) public SimpleDraweeViewFrescoSrcPropView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } @Override protected void onRestoreValue(View v) { super.onRestoreValue(v); if (mOldValue != null) { ((SimpleDraweeView) v).setImageURI(mOldValue); } } @Override protected void onUpdateView(View v, Editable value) { super.onUpdateView(v, value); try { mNewValue = value.toString(); Uri uri = Uri.parse(mNewValue); ((SimpleDraweeView) v).setImageURI(uri); } catch (NumberFormatException e) { e.printStackTrace(); } } @Override protected void onBindView(View v) { try { PipelineDraweeController controller = (PipelineDraweeController) ((SimpleDraweeView) v).getController(); if (controller != null) { Object dataSourceSupplier = RefInvoker.invokeMethod(controller, "getDataSourceSupplier", null, null); AbstractDraweeControllerBuilder builder = (AbstractDraweeControllerBuilder) RefInvoker.getFieldObject(dataSourceSupplier, "this$0"); ImageRequest imageRequest = (ImageRequest) builder.getImageRequest(); if (imageRequest != null) { mOldValue = imageRequest.getSourceUri(); } if (mOldValue != null) { setValue(mOldValue.toString()); } } } catch (Exception e) { ABLog.e(e); } } }
编辑属性类仅在开发生成配置 json 数据时使用,并不会上线,所以代码中的一些反射代码,并无影响
-
程序演示
view_prop_edit_demo_fresco.gif
6 UI 重排版
大部分修改 UI 属性用作 ABTest
,业务场景相对有限,更多的是,需要做 UI 局部重新布局
goodsdetail_abtest.jpeg图 6-1 严选购物车页面,协助分析不同 UI 样式下,用户凑单的形式
去凑单
文本的消失也认为是排版的一种,如 width 为 0
图 6-2 严选详情图。A:强化加购;B:强化立即购买
针对上述场景,纯 UI 排版的情况,并无新控件的出现,为此期望能有一套方案能支持线上动态重排版。而为了实现重排版,我们需要解决一下几点问题:
-
如何查找目标组件
可以通过前面的
XPath
逻辑查找 -
如何防止原有布局的排版
Android 已有布局,如
FrameLayout
、LinearLayout
、RelativeLayout
、GridLayout
等会对控件进行布局,而布局的发生过程在各个 View 的onMeasure
和onLayout
。由于是线上逻辑,我们更不可能通过继承重写的方式放置原有onMeasure
和onLayout
的方法逻辑执行。另外考虑能否清除属性的方式,也无法完全避免 Android 已有的布局干扰:
-
FrameLayout
:若清除父控件gravity
属性,清除子控件layout_gravity
,可以认为已经满足条件 -
RelativeLayout
:子控件按照属性进行布局,若子控件布局属性全部清空,则和FrameLayout
一致 -
LinearLayout
:父控件orientation
属性无法避免 -
GridLayout
:父控件orientation
、rowCount
、columnCount
等属性无法避免
-
-
如何对布局进行重排版
参考
Weex
、ReactiveNative
、LuaView
使用 Facebook 开源的CSSLayout
布局,这里也直接使用CSSLayout
。而CSSLayout
如何应用到线上已有的一个ViewGroup
? -
如何保持
ViewID
不变重布局之后,控件属性动态设置还需要生效
-
如何恢复布局
常见的如,编辑界面编辑的时候,取消当前操作,需要恢复布局
这里针对 2 和 3 的疑点,可以暂时清除 gravity
、layout_gravity
等属性,而 orientation
和 RelativeLayout
特有的布局属性可以不用关心。
通过在父控件和子控件中间插入一个透明的 StubCSSLayout
,来实现目的。
图 6-3 SubCSSLayout 插入
中间层 StubCSSLayout
的作用:
- 隔离父控件和子控件,既能解除父控件对子控件的排版功能
- 利用
StubCSSLayout
对子控件进行CSSLayout
排版 - 过滤
StubCSSLayout
,并未真正破坏 ViewTree 结构,XPath
计算并不受影响,为此子节点的属性动态设置仍能生效
演示示例:
<LinearLayout
android:layout_width="match_parent"
android:layout_height="100dp"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Line 1"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Line 2"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Line 3"/>
</LinearLayout>
待修改布局,垂直布局
{'flexDirection':'row','flexWrap':'wrap','children':[{"sizetofit":true},{"sizetofit":true},{"sizetofit":true}]}
csslayout_edit_demo.gifCSSLayout,水平布局
图 6-4 以一层布局作为示例,需要多层布局的,CSSLayout 配置数据嵌套多层即可
7 控件布局动态替换
考虑到特殊情况,就是需要重新替换布局,并且有创建新控件的场景,而这种情况,上面的重排版就无法实现了。考虑实现方案:
-
类似 LuaView、Weex、RN 下发脚本,动态解析,自行创建 View
可以自行实现,但太重了,实现了一整套脚本控制控件创建和布局,几乎可以理解为实现了一个动态化方案,同时如何保持主题等细节问题处理起来会比较繁琐。
另外,可以考虑直接接入上述的动态化方案,动态构建脚本容器进行替换,但考虑到,如果是过于复杂的场景,可以考虑发版本提供ABTest
,过重的方案本身已经不合适。 -
参考资源热更新的方案,同前面的观点,热更新应该仅用于线上严重崩溃问题,过于复杂的技术方案这里不考虑
热更新方案容易引起其他不可知问题,参考作者当时使用 1.7.3 版本 Tinker 方案,严选线上发布后导致 WebView 获取资源失败;
补丁加载成功后WebView获取资源失败android.content.res.Resources$NotFoundException: Resource ID #0x0 -
如果是复用 Android 的 xml 布局,那么如何使用生成、如何解析使用、是否有限制是需要考虑的问题
7.1 layout id 到 View 关键流程解析
解压 apk,可以看到里面的资源相关文件:
resources.arsc
res
layout
activity_suit.xml
...
...
其中布局文件 activity_suit.xml
等都是二进制格式的 XML 文件。为何我们开发时编辑的是 XML 文件需要编译成二进制格式的原因是:
- 二进制的 XML 元素的标签、属性名称、属性值和内容字符串会被统一收集到字符串资源池中(resources.arsc),XML 二进制文件只需持有资源索引的整数值,因此二进制 XML 文件大小更小
- 二进制 XML 文件的元素解析,避免了字符串解析,进而解析效率更高。
跟踪布局解析源码:
setContentView.jpg其中关键节点:
-
AssetManager.loadResourceValue
中根据资源R.layout.activity_main
获取 TypedView,其中 value.string 为res/layout/activity_main.xml
-
AssetManager.openXmlAssetNative
根据res/layout/activity_main.xml
获取 long 类型的xmlBlock
xmlBlock 其实是
ResXMLTree
指针查看源码:
// android_util_AssetManager.cpp static jlong android_content_AssetManager_openXmlAssetNative(JNIEnv* env, jobject clazz, jint cookie, jstring fileName) { ... int32_t assetCookie = static_cast<int32_t>(cookie); Asset* a = assetCookie ? am->openNonAsset(assetCookie, fileName8.c_str(), Asset::ACCESS_BUFFER) : am->openNonAsset(fileName8.c_str(), Asset::ACCESS_BUFFER, &assetCookie); ... const DynamicRefTable* dynamicRefTable = am->getResources().getDynamicRefTableForCookie(assetCookie); ResXMLTree* block = new ResXMLTree(dynamicRefTable); status_t err = block->setTo(a->getBuffer(true), a->getLength(), true); ... return reinterpret_cast<jlong>(block); }
其中
am->openNonAsset
会调用openNonAssetInPathLocked
// AssetManager.cpp Asset* AssetManager::openNonAssetInPathLocked(const char* fileName, AccessMode mode, const asset_path& ap) { ··· /* check the appropriate Zip file */ ZipFileRO* pZip = getZipFileLocked(ap); if (pZip != NULL) { //printf("GOT zip, checking NA '%s'\n", (const char*) path); ZipEntryRO entry = pZip->findEntryByName(path.string()); if (entry != NULL) { //printf("FOUND NA in Zip file for %s\n", appName ? appName : kAppCommon); pAsset = openAssetFromZipLocked(pZip, entry, mode, path); pZip->releaseEntry(entry); } } ··· }
可以看到,其实是根据
res/layout/activity_main.xml
从 source apk 中读取 xml 文件数据,最后通过block->setTo(...)
拷贝了一份数据,用于生成对象ResXMLTree
. -
AssetManager.openXmlBlockAsset
中根据XmlBlock(AssetManager assets, long xmlBlock)
构建XmlBlock
,最后通过XmlBlock.newParser()
生成XmlResourceParser
-
最后使用
XmlResourceParser
作为参数,用于构建 View// LayoutInflater.java public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot)
具体里面如何解析 xml 标签如何使用这里不做解析,因为已经能通过 public 方法能构建 View 了
7.2 自定义布局实现
观察 XmlBlock
的构造函数,可以发现传入字节流 data 生成 mNative
和 7.1 的流程一样,都是生成 ResXMLTree*
。为此我们可以考虑下发新编译的二进制布局 xml 下发,并解析得到 View。
这里下发的是 二进制布局 xml 内容的 base64
public XmlBlock(byte[] data) {
mAssets = null;
mNative = nativeCreate(data, 0, data.length);
mStrings = new StringBlock(nativeGetStringBlock(mNative), false);
}
// android_util_XmlBlock.cpp
static jlong android_content_XmlBlock_nativeCreate(JNIEnv* env, jobject clazz,
jbyteArray bArray,
jint off, jint len)
{
...
jsize bLen = env->GetArrayLength(bArray);
...
jbyte* b = env->GetByteArrayElements(bArray, NULL);
ResXMLTree* osb = new ResXMLTree();
osb->setTo(b+off, len, true);
...
return reinterpret_cast<jlong>(osb);
}
为方便根据文本布局 XML 文件得到二进制 XML 文件内容的 base64,这里开发的相关 AS 插件 AndroidXmlLayout
,方便编辑使用
选择的 xml 示例:
// test_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="47dp"
android:background="#FAFAFA"
android:orientation="horizontal">
<FrameLayout
android:id="@+id/pre_month"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:paddingLeft="18dp"
android:paddingRight="18dp">
<TextView
android:id="@+id/tv_alert_content"
android:layout_width="7dp"
android:layout_height="12.5dp"
android:layout_gravity="center"
android:tag="R.id.tv_alert_content"
android:background="#3cd088" />
</FrameLayout>
<TextView
android:id="@+id/current_month"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="2018年5月"
android:textColor="#333333"
android:textSize="16dp"
android:textStyle="bold"
android:tag="tag_data"/>
<FrameLayout
android:id="@+id/next_month"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:paddingLeft="18dp"
android:paddingRight="18dp">
<TextView
android:id="@+id/tv_next_month"
android:layout_width="7dp"
android:layout_height="12.5dp"
android:layout_gravity="center"
android:background="#3cd088"
android:tag="R.id.tv_right" />
</FrameLayout>
</LinearLayout>
生成的二进制布局 XML 文件 base64 数据
AwAIALAHAAABABwA/AIAABkAAAAAAAAAAAAAAIAAAAAAAAAAAAAAABwAAAA6AAAAUgAAAGwAAAB0AAAAjgAAAKoAAADKAAAA1AAAAPIAAAAEAQAAEAEAACYBAAA6AQAAUAEAAGIBAAC6AQAAvgEAANoBAAD0AQAACAIAADYCAABIAgAAXAIAAAwAbABhAHkAbwB1AHQAXwB3AGkAZAB0AGgAAAANAGwAYQB5AG8AdQB0AF8AaABlAGkAZwBoAHQAAAAKAGIAYQBjAGsAZwByAG8AdQBuAGQAAAALAG8AcgBpAGUAbgB0AGEAdABpAG8AbgAAAAIAaQBkAAAACwBwAGEAZABkAGkAbgBnAEwAZQBmAHQAAAAMAHAAYQBkAGQAaQBuAGcAUgBpAGcAaAB0AAAADgBsAGEAeQBvAHUAdABfAGcAcgBhAHYAaQB0AHkAAAADAHQAYQBnAAAADQBsAGEAeQBvAHUAdABfAHcAZQBpAGcAaAB0AAAABwBnAHIAYQB2AGkAdAB5AAAABAB0AGUAeAB0AAAACQB0AGUAeAB0AEMAbwBsAG8AcgAAAAgAdABlAHgAdABTAGkAegBlAAAACQB0AGUAeAB0AFMAdAB5AGwAZQAAAAcAYQBuAGQAcgBvAGkAZAAAACoAaAB0AHQAcAA6AC8ALwBzAGMAaABlAG0AYQBzAC4AYQBuAGQAcgBvAGkAZAAuAGMAbwBtAC8AYQBwAGsALwByAGUAcwAvAGEAbgBkAHIAbwBpAGQAAAAAAAAADABMAGkAbgBlAGEAcgBMAGEAeQBvAHUAdAAAAAsARgByAGEAbQBlAEwAYQB5AG8AdQB0AAAACABUAGUAeAB0AFYAaQBlAHcAAAAVAFIALgBpAGQALgB0AHYAXwBhAGwAZQByAHQAXwBjAG8AbgB0AGUAbgB0AAAABwAyADAAMQA4AHReNQAIZwAACAB0AGEAZwBfAGQAYQB0AGEAAAANAFIALgBpAGQALgB0AHYAXwByAGkAZwBoAHQAAAAAAIABCABEAAAA9AABAfUAAQHUAAEBxAABAdAAAQHWAAEB2AABAbMAAQHRAAEBgQEBAa8AAQFPAQEBmAABAZUAAQGXAAEBAAEQABgAAAACAAAA/////w8AAAAQAAAAAgEQAHQAAAACAAAA//////////8SAAAAFAAUAAQAAAAAAAAAEAAAAAMAAAD/////CAAAEAAAAAAQAAAAAgAAAP////8IAAAd+vr6/xAAAAAAAAAA/////wgAABD/////EAAAAAEAAAD/////CAAABQEvAAACARAAiAAAAAgAAAD//////////xMAAAAUABQABQAAAAAAAAAQAAAABAAAAP////8IAAABAAADfxAAAAAFAAAA/////wgAAAUBEgAAEAAAAAYAAAD/////CAAABQESAAAQAAAAAAAAAP////8IAAAQ/v///xAAAAABAAAA/////wgAABD/////AgEQAJwAAAAPAAAA//////////8UAAAAFAAUAAYAAAAAAAAAEAAAAAcAAAD/////CAAAEREAAAAQAAAABAAAAP////8IAAABAQADfxAAAAAIAAAAFQAAAAgAAAMVAAAAEAAAAAIAAAD/////CAAAHYjQPP8QAAAAAAAAAP////8IAAAFAQcAABAAAAABAAAA/////wgAAAUhAEAGAwEQABgAAAAVAAAA//////////8UAAAAAwEQABgAAAAWAAAA//////////8TAAAAAgEQAOwAAAAYAAAA//////////8UAAAAFAAUAAoAAAAAAAAAEAAAAA0AAAD/////CAAABQEQAAAQAAAADgAAAP////8IAAARAQAAABAAAAAMAAAA/////wgAAB0zMzP/EAAAAAoAAAD/////CAAAEREAAAAQAAAABAAAAP////8IAAABAgADfxAAAAAIAAAAFwAAAAgAAAMXAAAAEAAAAAAAAAD/////CAAABQEAAAAQAAAAAQAAAP////8IAAAQ/////xAAAAALAAAAFgAAAAgAAAMWAAAAEAAAAAkAAAD/////CAAABAAAgD8DARAAGAAAACIAAAD//////////xQAAAACARAAiAAAACQAAAD//////////xMAAAAUABQABQAAAAAAAAAQAAAABAAAAP////8IAAABAwADfxAAAAAFAAAA/////wgAAAUBEgAAEAAAAAYAAAD/////CAAABQESAAAQAAAAAAAAAP////8IAAAQ/v///xAAAAABAAAA/////wgAABD/////AgEQAJwAAAArAAAA//////////8UAAAAFAAUAAYAAAAAAAAAEAAAAAcAAAD/////CAAAEREAAAAQAAAABAAAAP////8IAAABBAADfxAAAAAIAAAAGAAAAAgAAAMYAAAAEAAAAAIAAAD/////CAAAHYjQPP8QAAAAAAAAAP////8IAAAFAQcAABAAAAABAAAA/////wgAAAUhAEAGAwEQABgAAAAxAAAA//////////8UAAAAAwEQABgAAAAyAAAA//////////8TAAAAAwEQABgAAAA0AAAA//////////8SAAAAAQEQABgAAAA0AAAA/////w8AAAAQAAAA
同样通过 XPath
查找 View 并替换,查看效果
7.3 自定义布局局限性
7.2 已经演示了使用动态下发二进制布局文件 base64 来显示动态布局的方案,看起来很方便很好用,然而其中的局限性也需要了解下:
- 因为这里需要通过反射获取
XmlBlock
实例,为此可能在个别版本或者特殊机型获取失败,为此需要事先知道这项功能是否可行 - 二进制布局文件里面的标签字符串通过 int 索引从资源池中查找。其中标签分为 2 类,一类为系统标签,另一类为 app 工程中自定义的资源,系统资源索引可以认为是不变的,而自定义资源则每次编译可能发生变化,为此我们下发的布局文件,不能引用新定义的资源 id,也不能引用 app 工程中已经定义好的资源。为此布局文件中的资源,如颜色、文本、尺寸等都必须直接写死,不能使用资源引用。
8 总结和不足
以上 Android 端 ABTest 框架总结如下:
- 通过 ABTest 类和协议一一对应的原则,理清协议和开发逻辑;
- 通过注解的方式自动选择初始化方法,规避了传统 if/else 代码在业务层的侵入;
- 通过动静分离计算
XPath
,进一步保证了页面变化情况下XPath
的唯一性和一致性; - 通过 UI 配置数据下发,动态修改线上 UI 属性;
- 提供模拟器编辑工具,可视化方式生成 UI 配置数据,保证了数据的准确性,支持 Activity、Dialog、PopupWindow;
- 提供基类和注解,业务 app 能自定义实现自定义控件的特殊 UI 属性设置和对应的可视化编辑器;
- 通过使用
CSSLayout
语法的配置数据,实现线上 UI 的动态重布局; - 通过下发自定义的二进制布局 XML base64数据,实现线上布局动态替换。
以上动态方案,对线上 ABTest 的及时分析与数据收集,提供了帮助。
除此,本方案也有以下不足之处,可以通过初始化预知简单屏蔽掉处理为默认情况(如默认为 A )
- 动态修改编辑,由于是 Android app 中直接编辑,操作方便性比起前端界面要差;
- 下发自定义的二进制布局 xml Base64数据,实现创造新布局,有一定局限性,不支持引用 app 资源或者新资源;
- Window LayoutInflator 替换可能存在失败的风险,部分厂家 rom 会自定义 PhoneWindow 类。这些可以在以后的版本中进行优化。