@zyl06
2018-12-19T21:29:36.000000Z
字数 35334
阅读 1904
Android
ABTest
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 相关的测试数据如何生成。
参考 AppAdhoc Android SDK 的使用,虽然已经提供了 A/B 测试
的数据提供接口,然而还是能发现几个明显问题:
if/else
逻辑
// '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 使用样例
参考 云眼 Android,支持线上 UI 属性修改。
其前端编辑界面移植 mixpanel
代码,前端编辑操作较为方便,但也有局限如下:
Fresco
等无法识别Dialog
和 PopupWindow
若 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
初始化代码针对 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 版本发布并上线才能支持,若能有一套线上动态布局的方案,就可以在运营产品和分析师提出需求时,立马线上实施得到数据。
我们需要一套框架,解决上述问题,并对业务层开发透明
针对业务逻辑 A/B 测试,提供实例编写规范,避免业务层 if/else
逻辑
业务层逻辑并不需要自己现在执行的是 A 还是 B
方便 AB 测试实例的统一管理和后期维护
提供一定能力的动态布局能力,创建新的布局
动态布局,可以分为重排版和替换为新布局
约定 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
ABTester
生效注解 ABTestInitMethodAnnotation
指定了对应测试 case 触发时,会被执行初始化的代码
itemId
数据无或并没有找到匹配的 testId
,则执行 defaultInit
指定的初始化方法itemId
和对应 testId
执行匹配的初始化方法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());
}
图 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());
图 3-2 运行结果,结果显示由
defaultInit
指定的 caseId000
,执行初始化方法 initA,显示 hanmeimei
上述逻辑封装较为简单,具体逻辑如下:
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()
执行初始化方法
@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();
}
}
以上讲述了普通 ABTest 实例的编码使用和原理,对于上层业务层完成以下目的:
if/else
执行对应的 A/B/n
逻辑流程,在讲述如何线上动态修改控件属性,修改替换 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 都不会发生变化。
首先排除 View.getId(),因为布局文件中未指定 id 和动态代码 new 出来的 View 都是 NO_ID
,而即便是布局文件中指定了 id 的 view,在不同版本编译产生的 id 也可能不一致。
参考无埋点技术,ViewID 主流的技术方案有 XPath
和 TouchTarget
。
XPath 方法较为主流,如 mixpanel、百分点埋点、网易乐得埋点、网易HubbleData。基本原理是根据当前 view 到 rootView(android.R.id.content)的路径,并结合当前界面的 Activity,Fragment,view tag,view id 等,最终生成一个字符串表示当前 View 的 ViewID。
上述各家方案,会有细节差异,但 view tree 逻辑基本思路一致
简单示例如下:
图 4-1-1
针对以上布局,其 view tree 如下:
图 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 标记,则可以避免这种情况:
图 4-1-4 此时如果
btn1
被移除了,后面的 TextView ViewID 并不会受影响。
其他如何计算 ViewPager、ListView、RecyclerView 里的 ItemView 的 ViewID,以及 Fragment 中的控件 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 检查工具
根据当前目标,线上动态修改目标 View 的属性,为此必须在 Activity 界面展示给用户看之前就找到目标 View 并修改属性,为此 TouchTarget
计算 ViewID 方案并不可行,不能等到用户点击才计算 ViewID。XPath
方案基本符合当前场景,但也存在部分不符合场景和缺陷的地方:
见图 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 的全局节点做分类。这里引入新的概念:
动态布局的节点:包括 java 代码动态 new 出来的 view 和静态布局的根节点
动态布局节点的 index 计算,需要根据兄弟动态节点计算(隔离静态布局和动态布局之间的干扰),另外计算的是相同类型节点的索引
全局 XPath
:当前节点在整个页面布局 ViewTree 上的 XPath
值,经过 sha256
加密就是最终的 ViewID 值
局部静态 XPath
:当前节点由 layout xml 生成,当前节点到 layout 根节点的 XPath
值
继续针对 图 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>
图 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
以上动静 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;
}
针对 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}
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;
}
图 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
控件属性,是指 View
的背景颜色,透明度、是否显示等,TextView
的文本内容、文本颜色等属性。为了支持线上控件属性的动态修改,我们需要解决一下问题:
如何定位控件?
参考前面 4 讲述的 ViewID 计算
如何定义下发的配置数据?
这里定义配置文件的格式如下:
[
{
"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
。
目标控件必须在 UI 界面被用户看到之前设置相关属性,为此这里有几个时间点能应用:
ActivityLifecycleCallbacks.onActivityCreated(Activity activity, Bundle savedInstanceState)
LayoutInflater.inflate(@LayoutRes int resource, @Nullable ViewGroup root)
onViewAttachedToWindow(View v)
未添加至 Activity 的控件可以做监听设置,在 onViewAttachedToWindow
中触发
根据 4.1 的配置数据,界面生效前后如下所示:
图 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"
}
图 5-2-2
查看 代码 5-2-1 的配置信息,不可能让开发人肉去填写,为此提供了一个可视化的工具
[
{
"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"
}
]
SDK 层面仅能针对系统常见的控件属性提供设置和编辑功能,如针对 View
的 background
、alpha
,针对 TextView
的 text
、textColor
、textSize
,针对 ImageView
等的 src
属性等。而各个业务 app 都会集成相关的第三方组件或自定义控件,SDK 预置的属性永远可能不满足业务方的全部需求。为此就必须支持业务方自定义设置属性和编辑属性。
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 {
}
为支持可视化生成 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() 返回待编辑的属性名称
以 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 数据时使用,并不会上线,所以代码中的一些反射代码,并无影响
程序演示
大部分修改 UI 属性用作 ABTest
,业务场景相对有限,更多的是,需要做 UI 局部重新布局
图 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,水平布局
图 6-4 以一层布局作为示例,需要多层布局的,CSSLayout 配置数据嵌套多层即可
考虑到特殊情况,就是需要重新替换布局,并且有创建新控件的场景,而这种情况,上面的重排版就无法实现了。考虑实现方案:
类似 LuaView、Weex、RN 下发脚本,动态解析,自行创建 View
可以自行实现,但太重了,实现了一整套脚本控制控件创建和布局,几乎可以理解为实现了一个动态化方案,同时如何保持主题等细节问题处理起来会比较繁琐。
另外,可以考虑直接接入上述的动态化方案,动态构建脚本容器进行替换,但考虑到,如果是过于复杂的场景,可以考虑发版本提供 ABTest
,过重的方案本身已经不合适。
参考资源热更新的方案,同前面的观点,热更新应该仅用于线上严重崩溃问题,过于复杂的技术方案这里不考虑
热更新方案容易引起其他不可知问题,参考作者当时使用 1.7.3 版本 Tinker 方案,严选线上发布后导致 WebView 获取资源失败;
补丁加载成功后WebView获取资源失败android.content.res.Resources$NotFoundException: Resource ID #0x0
如果是复用 Android 的 xml 布局,那么如何使用生成、如何解析使用、是否有限制是需要考虑的问题
解压 apk,可以看到里面的资源相关文件:
resources.arsc
res
layout
activity_suit.xml
...
...
其中布局文件 activity_suit.xml
等都是二进制格式的 XML 文件。为何我们开发时编辑的是 XML 文件需要编译成二进制格式的原因是:
跟踪布局解析源码:
其中关键节点:
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 了
观察 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.2 已经演示了使用动态下发二进制布局文件 base64 来显示动态布局的方案,看起来很方便很好用,然而其中的局限性也需要了解下:
XmlBlock
实例,为此可能在个别版本或者特殊机型获取失败,为此需要事先知道这项功能是否可行以上 Android 端 ABTest 框架总结如下:
XPath
,进一步保证了页面变化情况下 XPath
的唯一性和一致性;CSSLayout
语法的配置数据,实现线上 UI 的动态重布局;以上动态方案,对线上 ABTest 的及时分析与数据收集,提供了帮助。
除此,本方案也有以下不足之处,可以通过初始化时监测后屏蔽掉处理为默认情况(如默认为 A )