@zyl06
2018-12-19T13:29:36.000000Z
字数 35334
阅读 2172
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 tangramTangramBuilder.init(this.getApplicationContext(), new IInnerImageSetter() {@Overridepublic <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 cardsbuilder = TangramBuilder.newInnerBuilder(this);//Step 3: register business cells and cards// recommend to use string type to register componentbuilder.registerCell("testView", TestView.class);...// register component with integer type was not recommend to usebuilder.registerCell(1, TestView.class);builder.registerCell(10, SimpleImgView.class);...// 支持自定义的 xml 布局,但需要编码注册好builder.registerVirtualView("vvtest");//Step 4: new engineengine = builder.build();engine.setVirtualViewTemplate(VVTEST.BIN);engine.setVirtualViewTemplate(DEBUG.BIN);...//Step 6: enable auto load more if your page's data is lazy loadedengine.enableAutoLoadMore(true);//Step 7: bind recyclerView to engineengine.bindView(recyclerView);...
查看使用,从 ABTest 角度也可以发现 Tangram 也有较大的局限性:
RecyclerViewTangram 初始化代码针对 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() {}@Overrideprotected 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
@Overrideprotected 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.javapublic 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() 执行初始化方法
@Aspectpublic 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"?><LinearLayoutxmlns:android="http://schemas.android.com/apk/res/android"android:layout_width="wrap_content"android:layout_height="wrap_content"android:orientation="horizontal"><TextViewandroid:id="@+id/text3"android:layout_width="wrap_content"android:layout_height="wrap_content"android:layout_margin="5dp"android:text="text3"/><TextViewandroid: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 {@Overrideprotected 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 了
@Overridepublic 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"?><FrameLayoutxmlns:android="http://schemas.android.com/apk/res/android"android:layout_width="match_parent"android:layout_height="match_parent"><TextViewandroid: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.javaprivate 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"?><RelativeLayoutxmlns: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.ViewPagerandroid:id="@+id/vp_viewpager"android:layout_width="match_parent"android:layout_height="match_parent"><android.support.design.widget.TabLayoutandroid: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 {@Overridepublic 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;}@Overridepublic 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);}@Overrideprotected void onRestoreValue(View v) {super.onRestoreValue(v);if (mOldValue != null) {((SimpleDraweeView) v).setImageURI(mOldValue);}}@Overrideprotected 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();}}@Overrideprotected 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 计算并不受影响,为此子节点的属性动态设置仍能生效演示示例:
<LinearLayoutandroid:layout_width="match_parent"android:layout_height="100dp"android:orientation="vertical"><TextViewandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:text="Line 1"/><TextViewandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:text="Line 2"/><TextViewandroid: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.arscreslayoutactivity_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.cppstatic 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.cppAsset* 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.javapublic 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.cppstatic 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"><FrameLayoutandroid:id="@+id/pre_month"android:layout_width="wrap_content"android:layout_height="match_parent"android:paddingLeft="18dp"android:paddingRight="18dp"><TextViewandroid: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><TextViewandroid: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"/><FrameLayoutandroid:id="@+id/next_month"android:layout_width="wrap_content"android:layout_height="match_parent"android:paddingLeft="18dp"android:paddingRight="18dp"><TextViewandroid: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 )