[关闭]
@zyl06 2018-12-19T13:29:36.000000Z 字数 35334 阅读 1769

Android ABTest 设计与原理

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 测试 的数据提供接口,然而还是能发现几个明显问题:

  1. 数据使用上,还是需要业务层写大量的 if/else 逻辑
  2. 相同的 ABTest 实例,在不同的页面,容易出现重复代码
  3. 后期维护容易出错,如部分测试实例需要废弃,需要工程中找出多处逻辑并修改
  4. 不支持普通 ui 属性修改和布局修改
  1. // 'model01' 对应网站添加的产品模块名称
  2. boolean flag = AdhocTracker.getFlag("module01", false);
  3. if (flag) {
  4. btn01.setBackgroundColor(getResources().getColor(android.R.color.black));
  5. btn01.setTextColor(getResources().getColor(android.R.color.white));
  6. btn01.setTextSize(getResources().getDimension(R.dimen.textsize_small));
  7. btn01.setText("实验版本B");
  8. tv_tracking.setVisibility(View.VISIBLE);
  9. } else {
  10. btn01.setBackgroundColor(getResources().getColor(android.R.color.white));
  11. btn01.setTextColor(getResources().getColor(android.R.color.black));
  12. btn01.setTextSize(getResources().getDimension(R.dimen.textsize));
  13. btn01.setText("实验版本A");
  14. tv_tracking.setVisibility(View.GONE);
  15. }

AppAdhoc Android SDK 使用样例

代码样例来源链接

1.2 云眼

参考 云眼 Android,支持线上 UI 属性修改。

eyecloud_ui_edit.jpg

其前端编辑界面移植 mixpanel 代码,前端编辑操作较为方便,但也有局限如下:

  1. 不支持自定义控件,甚至较为常用的第三方库,如 Fresco 等无法识别
  2. 前端界面无法处理 DialogPopupWindow
  3. 不支持动态重布局

1.3 线上动态支持方案考虑

若 app 部分模块已使用 H5 页面,或者使用 RN、weex 等动态化框架实现,则这部分逻辑已经原生支持线上动态支持 ABTest。若 APP 业务模块已经实现了拆分和插件化,则插件模块也支持线上动态 A/B(参考 携程Android App插件化和动态加载实践)。上述 2 种情况,同时支持纯 UI 和普通逻辑的线上动态 A/B 测试,而缺点也十分明显:

  1. 针对非动态化页面和宿主包部分代码,无法支持线上动态

    很多 app 集成了动态化框架,然而一般是少量经常变化的页面才会使用 weex 等实现

    H5 页面相比会使用的更加广泛,严选详情页、专题页、会员中心等页面都会使用 H5,而本文更关注的 native 的 A\B 实现。H5 的相关内容可查看 abtest-web在线页面编辑实现-abtest可视化实验abtest-现状,困境以及解决方案,HubbleData通用A/B测试服务揭秘

  2. 现有 app 支持插件化且支持动态下发比较少,而为了 A/B 测试集成插件化就很难想象了

    相比更多 app 支持了业务模块化,但模块化并不支持动态加载

  3. 用户更新频繁

    A\B 测试在 app 后期优化阶段,会用的比较频繁,而如果每次都是全量动态脚本代码或是全量插件包下发,流量会有一定消耗,开发者需要考虑增量更新,而增量更新又需要一个增量包的管理平台

除了 H5、动态化和插件化等方案,也有如 Tangram 这种半动态化方案,将 RecycleView 的每个 ViewHolder 看成卡片,通过动态下发 json 数据或自定义格式的 xml 来动态定制卡片的 UI 布局。

  1. recyclerView = (RecyclerView) findViewById(R.id.main_view);
  2. //Step 1: init tangram
  3. TangramBuilder.init(this.getApplicationContext(), new IInnerImageSetter() {
  4. @Override
  5. public <IMAGE extends ImageView> void doLoadImageUrl(@NonNull IMAGE view,
  6. @Nullable String url) {
  7. Picasso.with(TangramActivity.this.getApplicationContext()).load(url).into(view);
  8. }
  9. }, ImageView.class);
  10. //Tangram.switchLog(true);
  11. mMainHandler = new Handler(getMainLooper());
  12. //Step 2: register build=in cells and cards
  13. builder = TangramBuilder.newInnerBuilder(this);
  14. //Step 3: register business cells and cards
  15. // recommend to use string type to register component
  16. builder.registerCell("testView", TestView.class);
  17. ...
  18. // register component with integer type was not recommend to use
  19. builder.registerCell(1, TestView.class);
  20. builder.registerCell(10, SimpleImgView.class);
  21. ...
  22. // 支持自定义的 xml 布局,但需要编码注册好
  23. builder.registerVirtualView("vvtest");
  24. //Step 4: new engine
  25. engine = builder.build();
  26. engine.setVirtualViewTemplate(VVTEST.BIN);
  27. engine.setVirtualViewTemplate(DEBUG.BIN);
  28. ...
  29. //Step 6: enable auto load more if your page's data is lazy loaded
  30. engine.enableAutoLoadMore(true);
  31. //Step 7: bind recyclerView to engine
  32. engine.bindView(recyclerView);
  33. ...

Tangram 使用 demo 代码来源

查看使用,从 ABTest 角度也可以发现 Tangram 也有较大的局限性:

  1. 绑定仅支持 RecyclerView
  2. 需要事先在代码中编写如上的 Tangram 初始化代码
  3. 能支持的卡片类型初始化的时候预置

2 A/B Test 考虑和框架目标

针对 H5、动态化框架,不能因为 A/B 测试将大部分 Native 页面改成脚本页面;同理,app 也不能因为 A/B 而集成插件化,为此个人认为完全动态的线上 A/B 能力并不现实

排除热更新方案,热更新应该仅用于线上问题修复;
已经使用动态化框架、插件化的 APP,可以顺带支持下线上 A/B 动态能力;

考虑线上相当一部分场景是纯 UI 界面改动的 A/B 测试,如重新布局,部分文案颜色修改等,而这部分场景我们可以通过其他手段来实现线上动态的目标。剩余复杂 UI 场景和业务逻辑场景,可代码写入 app,等线上启用。

shoppingcart_abtest.jpg

图 2-1 严选第一个版本的 ABTest 实例,协助分析不同 UI 样式下,用户凑单的形式

针对上述情况,我们可以理解为是简单的布局重排逻辑,其中 去凑单 的隐藏,可以通过设置 View 宽度为 0 实现。若按照常规的 ABTest 框架,如 AppAdhoc 等,还是需要等待 APP 版本发布并上线才能支持,若能有一套线上动态布局的方案,就可以在运营产品和分析师提出需求时,立马线上实施得到数据。

2.1 框架目标

我们需要一套框架,解决上述问题,并对业务层开发透明

  1. 支持同步后台 A/B 测试 json 数据
  2. 提供多种生效策略,支持立即生效、热启动生效和冷启动生效
  3. 针对业务逻辑 A/B 测试,提供实例编写规范,避免业务层 if/else 逻辑

    业务层逻辑并不需要自己现在执行的是 A 还是 B

  4. 方便 AB 测试实例的统一管理和后期维护

  5. 针对普通 UI 属性,支持线上动态实验
  6. 提供一定能力的动态布局能力,创建新的布局

    动态布局,可以分为重排版和替换为新布局

3 A/B/n 测试使用规范及实现

3.1 A/B/n 测试使用规范

约定 ABTest 实例的 json 数据格式如下:

  1. //abtest.json
  2. [
  3. {
  4. "itemId":"SimpleTest_001",
  5. "accessory":"",
  6. "testCase":{
  7. "caseId":"001",
  8. "accessory":""
  9. }
  10. },
  11. {
  12. "itemId":"SimpleTest_002",
  13. "accessory":"",
  14. "testCase":{
  15. "caseId":"000",
  16. "accessory":""
  17. }
  18. }
  19. ]

代码样例 3-1;
id 是 SimpleTest_001SimpleTest_002 的测试数据;
itemId 指定具体是哪个 ABTest,caseId 指定 A or B

可以理解相同的 ABTest case,如果在程序逻辑中有多处,那么这些代码应该都是一致的,同时业务层不应该关心当前是否有对应 ABTest 的 json 数据(如果没有走 A/B/n 的默认逻辑,这里假设 "000" 为默认逻辑)。基于此,对应每个 ABTest case 都封装了对应的类

  1. @ABTesterAnno(itemId = "SimpleTest_001", updateType = ABTestUpdateType.IMMEDIATE_UPDATE)
  2. public class OneABTester extends BaseABTester {
  3. private String name;
  4. public OneABTester() {
  5. }
  6. @Override
  7. protected void onUpdateConfig() {
  8. }
  9. @ABTestInitMethodAnnotation(caseId = "000", defaultInit = true)
  10. public void initA(@Nullable String accessory, @Nullable ABTestCase testVO) {
  11. name = "hanmeimei";
  12. }
  13. @ABTestInitMethodAnnotation(caseId = "001")
  14. public void initB(@Nullable String accessory, @Nullable ABTestCase testVO) {
  15. name = "lilei";
  16. }
  17. @ABTestInitMethodAnnotation(caseId = "002")
  18. public void initC(@Nullable String accessory, @Nullable ABTestCase testVO) {
  19. name = "lili";
  20. }
  21. public String getName() {
  22. return name;
  23. }
  24. }

查看 ABTest 实例的 json 数据查看 代码样例 3-1

  1. @Override
  2. protected void onCreate(Bundle savedInstanceState) {
  3. super.onCreate(savedInstanceState);
  4. setContentView(R.layout.activity_main);
  5. List<ABTestItem> testItems = parseJsonFromAsset();
  6. ABTestConfig.getInstance().init(this.getApplication(), testItems, ABTestFileUtil.readUiCases(this));
  7. OneABTester test1 = new OneABTester();
  8. TextView tvName = (TextView) findViewById(R.id.tv_name);
  9. tvName.setText(test1.getName());
  10. }

simple_test_case_0.jpg

图 3-1 根据 SimpleTest_001 指定的 caseId 001,执行初始化方法 initB,显示 lilei

  1. // ABTest 初始化,设置为 null,未指定任何数据
  2. ABTestConfig.getInstance().init(this.getApplication(), null, ABTestFileUtil.readUiCases(this));
  3. OneABTester test1 = new OneABTester();
  4. TextView tvName = (TextView) findViewById(R.id.tv_name);
  5. tvName.setText(test1.getName());

simple_test_case_1.jpg

图 3-2 运行结果,结果显示由 defaultInit 指定的 caseId 000,执行初始化方法 initA,显示 hanmeimei

3.2 实现原理

上述逻辑封装较为简单,具体逻辑如下:

  1. ABTestConfig 单例初始化后,会记录全部的 ABTestItem,并提供接口使用 itemId 查询的接口。

    1. // ABTestConfig.java
    2. public void init(Application app,
    3. List<ABTestItem> normalCases,
    4. List<ABTestUICase> uiCases) {
    5. if (normalCases == null) {
    6. normalCases = new LinkedList<>();
    7. }
    8. ...
    9. mABTestConfigModel.abtestConfig = normalCases;
    10. ...
    11. notifyAllTesters();
    12. }
    13. ...
    14. public ABTestItem getNormalCase(String itemId, ABTestUpdateType updateType) {
    15. // 1. 如果是立即更新或热启动更新,则从 mABTestConfigModel.abtestLasestNorCases 尝试获取 itemId 匹配的值,并返回
    16. // 2. 尝试从 mABTestConfigModel.abtestNorCases 获取 itemId 匹配的值,并返回
    17. // 3. 若找不到,返回 null
    18. }
  2. ABTest 实例创建的时候,在构造函数中会根据注解的值去查询配置数据,查询并设置初始化方法和有效的 ABTest 数据实例。

    1. public abstract class BaseABTester {
    2. protected ABTestItem mTestCase;
    3. protected String mItemId;
    4. private ABTestCase mValidTestVO;
    5. private Method mInitABMethod;
    6. public BaseABTester() {
    7. ABTesterAnno anno = getClass().getAnnotation(ABTesterAnno.class);
    8. if (anno != null) {
    9. mItemId = anno.itemId();
    10. mTestCase = ABTestConfig.getInstance().getNormalCase(mItemId);
    11. chooseInitMethod(getTestCase());
    12. // 记录全部的 ABTest 实例,用于后期数据更新通知
    13. ABTestConfig.getInstance().mABTesterRefs.add(new ObjWeakRef<>(this));
    14. }
    15. }
    16. private void chooseInitMethod(ABTestCase testCase) {
    17. // 寻找含有 ABTestInitMethodAnnotation 注解的初始化方法
    18. // 1. 根据 caseId 找到对应方法,设置 mInitABMethod 和 mValidTestVO
    19. // 2. 找不到对应方法,根据 defaultInit 找到默认初始化方法,设置 mInitABMethod(mValidTestVO 为null)
    20. }
    21. ...
    22. }
  3. ABTest 实例执行选择的初始化方法

    1. protected void initAB() {
    2. if (!mIsInited) {
    3. mIsInited = true;
    4. ABTestCase testVO = getValidTest();
    5. if (mInitABMethod != null) {
    6. invokeMethod(mInitABMethod, testVO);
    7. }
    8. }
    9. }

    通过反射运行初始化方法,然而由于初始化方法是子类的中定义,为此不能在基类的构造函数中执行,只能在子类构造函数的执行的最后执行。

    1. @ABTesterAnno(itemId = "SimpleTest_001")
    2. public class OneABTester extends BaseABTester {
    3. ...
    4. public OneABTester() {
    5. initAB();
    6. }
    7. ...
    8. }

    而通过编码规范要求各个 ABTest 实例的构造函数最后写 initAB(),个人感觉比较机械,而且容易被业务开发遗漏。这里通过 aspectJ 在业务层的全部的 ABTest 实例子类的构造函数的最后插入 initAB() 执行初始化方法

    1. @Aspect
    2. public class AspectABTester {
    3. @After("execution(com.netease.lib.abtest.BaseABTester+.new(..)) && !within(com.netease.lib.abtest.BaseABTester)")
    4. public void afterMethodExecution(JoinPoint joinPoint) {
    5. ...
    6. ((BaseABTester) joinPoint.getTarget()).initAB();
    7. }
    8. }

3.3 小结

以上讲述了普通 ABTest 实例的编码使用和原理,对于上层业务层完成以下目的:

  1. 使用注解标记 ABTest 的 itemId 和 caseId,代码逻辑更加清晰
  2. 支持立即更新、热启动更新、冷启动更新
  3. 隐藏了 ABTest 的原始数据解析和使用
  4. 避免了业务开发使用 if/else 执行对应的 A/B/n 逻辑流程,
  5. 将全部和 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 主流的技术方案有 XPathTouchTarget

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 的唯一性也无法保证了

viewpath_viewtree_opt_before.png

图 4-1-3

若相同层级根据同类型 view 之间的 index 标记,则可以避免这种情况:

viewpath_viewtree_opt_after.png

图 4-1-4 此时如果 btn1 被移除了,后面的 TextView ViewID 并不会受影响。

其他如何计算 ViewPager、ListView、RecyclerView 里的 ItemView 的 ViewID,以及 Fragment 中的控件 ViewID 等,如何保证一致性和唯一性的优化方案,参考以下文章,这里不在重复描述

  1. SDK无埋点技术在百分点的探索和实践
  2. Android无埋点数据收集SDK关键技术解析
  3. 网易HubbleData之Android无埋点实践

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 方案基本符合当前场景,但也存在部分不符合场景和缺陷的地方:

  1. ViewTree 动态变化的场景适应力有限
  2. 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 的全局节点做分类。这里引入新的概念:

  1. 静态布局:利用 layout xml 生成的 ViewTree
  2. 动态布局:利用 java 代码生成的 ViewTree,或者在已有 ViewTree 上进行删除、插入操作
  3. 静态布局节点:静态布局的子节点,不含根节点(根节点最终要动态加入 android.R.id.content 或其他布局)
  4. 动态布局的节点:包括 java 代码动态 new 出来的 view 和静态布局的根节点

    动态布局节点的 index 计算,需要根据兄弟动态节点计算(隔离静态布局和动态布局之间的干扰),另外计算的是相同类型节点的索引

  5. 全局 XPath:当前节点在整个页面布局 ViewTree 上的 XPath 值,经过 sha256 加密就是最终的 ViewID 值

  6. 局部静态 XPath:当前节点由 layout xml 生成,当前节点到 layout 根节点的 XPath

    • 根节点会有标记,标识当前节点是根节点;
    • 全部局部节点都有标记是哪个 layout 布局的节点;
    • 叶子节点或子树被动态移除,被移除的全部节点 layout 布局的标记需要清除,之后若加入场景树,全部节点都认为是动态布局;
    • 子节点 index 根据在父节点的位置决定,不用按照相同类型的节点来算节点

继续针对 图 4-1-2,我们删除橘红色节点 TextView,并在当前位置插入另一个布局 view_third_insert.xml 和一个 TextView,则当前 ViewTree 如下图所示:

  1. <!-- view_third_insert.xml -->
  2. <?xml version="1.0" encoding="utf-8"?>
  3. <LinearLayout
  4. xmlns:android="http://schemas.android.com/apk/res/android"
  5. android:layout_width="wrap_content"
  6. android:layout_height="wrap_content"
  7. android:orientation="horizontal">
  8. <TextView
  9. android:id="@+id/text3"
  10. android:layout_width="wrap_content"
  11. android:layout_height="wrap_content"
  12. android:layout_margin="5dp"
  13. android:text="text3"/>
  14. <TextView
  15. android:id="@+id/text4"
  16. android:layout_width="wrap_content"
  17. android:layout_height="wrap_content"
  18. android:layout_margin="5dp"
  19. android:text="text4"/>
  20. </LinearLayout>

viewpath_viewtree_myopt_after.png

图 4-2-1 新的布局

viewpath_viewtree_opt_after_1.png

图 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 计算结果:

  1. index 3 的 TextView(图 4-2-1 数字 3 的蓝黑色节点)

    1. 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"}]
    2. ViewID30802f2fa775198da5b6d5e59d098a5f8adc47a744ba5f0bc6e1dcbc417e42be

    其中节点的局部静态 XPath 为:

    1. [{"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 为:

    1. [{"className":"ContentFrameLayout","environment":"com.netease.demo.abtest.third.ThirdActivity","idName":"content","index":0},{"className":"LinearLayout","index":0,"resName":"activity_third"}]
  2. 绿色节点 TextView

    1. 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}]
    2. ViewID6ec1e6ee512db7c031ed0a638a2320496da5e9ae84e092eaa19fe8e297b0f830
  3. R.id.text3 的 TextView(图 4-2-1,数字为 0 亮蓝色节点)

    1. 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"}]
    2. ViewIDb184ec2565fff410af9ffad5a5cd6ace1773b4899f07970eaf499d9b675ff462

4.2.2 局部静态 XPath 计算

以上动静 XPath 分离的方案,关键是如何计算局部静态 XPath。我们必须在布局 xml inflate 后就针对当前局部布局计算并保存。查看我们的 Activity 的常规写法:

  1. public class ThirdActivity extends AppCompatActivity {
  2. @Override
  3. protected void onCreate(@Nullable Bundle savedInstanceState) {
  4. super.onCreate(savedInstanceState);
  5. setContentView(R.layout.activity_third);
  6. ...
  7. }
  8. ...
  9. }

可以看到 super.onCreate(...) 在 setContentView(...) 前面。其中,super.onCreate(...) 里面会调用 ActivityLifecycleCallbacks.onActivityCreated(...),而 setContentView(...) 里面会调用 LayoutInflator.inflate(...)

为此我们可以在 ActivityLifecycleCallbacks.onActivityCreated(...) 替换 LayoutInflator

  1. private void replaceActivityLayoutInflater(Activity activity) {
  2. LayoutInflater inflater0 = (LayoutInflater) activity.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
  3. if (!(inflater0 instanceof ABTestProxyLayoutInflater)) {
  4. LayoutInflater proxyInflater = new ABTestProxyLayoutInflater(inflater0);
  5. RefInvoker.setFieldObject(activity, ContextThemeWrapper.class, "mInflater", proxyInflater);
  6. }
  7. Window window = activity.getWindow();
  8. LayoutInflater inflater1 = activity.getWindow().getLayoutInflater();
  9. if (!(inflater1 instanceof ABTestProxyLayoutInflater)) {
  10. LayoutInflater proxyInflater = new ABTestProxyLayoutInflater(inflater1);
  11. if (RefInvoker.isInstanceOf(window, "com.android.internal.policy.PhoneWindow")) {
  12. RefInvoker.setFieldObject(window, "com.android.internal.policy.PhoneWindow", "mLayoutInflater", proxyInflater);
  13. } else if (RefInvoker.isInstanceOf(window, "com.android.internal.policy.impl.PhoneWindow")) {
  14. RefInvoker.setFieldObject(window, "com.android.internal.policy.impl.PhoneWindow", "mLayoutInflater", proxyInflater);
  15. }
  16. }
  17. }

正常 LayoutInflator.from(Context), setContentView(...) 使用的是 inflater0

正常 Dialog,PopupWindow 使用的是 inflater1

替换之后我们就可以在 LayoutInflator.inflate 方法中计算局部静态 XPath

  1. @Override
  2. public View inflate(int resource, ViewGroup root) {
  3. View result = mInflater.inflate(resource, root);
  4. View created = (root != null && root.getChildCount() > 0) ?
  5. root.getChildAt(root.getChildCount() - 1) :
  6. result;
  7. ViewPathUtil.setXmlLayoutLocalPathTag(getContext(), created, resource);
  8. onInflate(created);
  9. return result;
  10. }

4.2.3 ListView,RecyclerView,Spinner 等特殊控件处理

针对 ListViewRecyclerView 等控件,期望同一个配置能使相同 type 的 ItemView 都生效,为此相同 type 的 ItemView 的 ViewID 都要一致。为此,这里不能使用 position 作为 XPath 中的一个变量,而是应该使用 type

viewpath_listview.jpg

图 4-2-2 ListView 测试界面。白底 ItemView type 为 0,灰底 ItemView type 为 1。

因为 RecyclerViewSpinnerListView 计算 XPath 完全类似,所以这里仅仅讲述 ListView

其中每个 item view 的布局文件为:

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <FrameLayout
  3. xmlns:android="http://schemas.android.com/apk/res/android"
  4. android:layout_width="match_parent"
  5. android:layout_height="match_parent">
  6. <TextView
  7. android:id="@+id/text_view"
  8. android:layout_width="match_parent"
  9. android:layout_height="wrap_content"
  10. android:padding="6dp"
  11. android:textSize="15dp"/>
  12. </FrameLayout>

白底 ItemView 里面的 TextView 的 ViewID 结果如下

  1. 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"}]
  2. ViewIDe991bec2797470ed5eaaf25973c6538f266c0f53cc622c1e2c88aea3fa8301dd

其中 ItemView 根节点的 ViewPathElement 如下。由于没有 position 信息,所以全部白底 ItemView 里面的 TextView 的 ViewID 全部一致

  1. {"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

  1. // ViewPager.java
  2. private static boolean isDecorView(@NonNull View view) {
  3. Class<?> clazz = view.getClass();
  4. return clazz.getAnnotation(DecorView.class) != null;
  5. }

viewpath_viewpager.jpg

图 4-2-2 ViewPager 测试界面

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <RelativeLayout
  3. xmlns:android="http://schemas.android.com/apk/res/android"
  4. xmlns:app="http://schemas.android.com/apk/res-auto"
  5. xmlns:tools="http://schemas.android.com/tools"
  6. android:layout_width="match_parent"
  7. android:layout_height="match_parent">
  8. <android.support.v4.view.ViewPager
  9. android:id="@+id/vp_viewpager"
  10. android:layout_width="match_parent"
  11. android:layout_height="match_parent">
  12. <android.support.design.widget.TabLayout
  13. android:id="@+id/tab_layout"
  14. android:layout_width="match_parent"
  15. android:layout_height="wrap_content"
  16. app:tabMode="fixed"
  17. app:tabGravity="fill">
  18. </android.support.design.widget.TabLayout>
  19. </android.support.v4.view.ViewPager>
  20. </RelativeLayout>

ItemView 里的 居家 TextView ViewID 计算:

  1. 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}]
  2. ViewIDc8cbb5dfa8d384c68339f09653a8ac7927581c21cfd539f987e0c7670cd5d3f0

TabLayout 里面的 居家 TextView ViewID 计算:

  1. 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}]
  2. ViewIDb4f1b770e3dd2895ddb343856737eced4813b3bba713ffec9de164f22ceca038

5 控件属性动态修改

控件属性,是指 View 的背景颜色,透明度、是否显示等,TextView 的文本内容、文本颜色等属性。为了支持线上控件属性的动态修改,我们需要解决一下问题:

  1. 如何定位控件?

    参考前面 4 讲述的 ViewID 计算

  2. 如何定义下发的配置数据?

  3. 如何将配置数据应用到控件上?
  4. 如何生成 ABTest 配置数据,如何检查效果?
  5. 如何处理业务层的自定义控件属性

5.1 配置数据格式定义

这里定义配置文件的格式如下:

  1. [
  2. {
  3. "uiProps": [
  4. {
  5. "floatValue": 0.5,
  6. "intValue": 0,
  7. "name": "alpha"
  8. },
  9. {
  10. "floatValue": 0.0,
  11. "intValue": -1979711233,
  12. "name": "textColor"
  13. },
  14. {
  15. "floatValue": 0.0,
  16. "intValue": 0,
  17. "name": "textSize",
  18. "value": "40.0px"
  19. }
  20. ],
  21. "viewID": "22b721d900197856706fc68083c4c3deba5e31a0d8e44438a96eb6473bbc9e0a"
  22. },
  23. ...
  24. ]

代码 5-2-1

viewID 指定线上的目标控件(这里不需要指定控件类型,因为同一个 viewID 不可能指向多个不同的 view)。uiProps 指定具体的属性数据。如 alpha 指定 View 的 alpha 属性,floatValue 指定新的 alpha 值;textColor 指定 TextView 的文本颜色,intValue 指定颜色值为 #8A0000FFtextSize 指定 TextView 的字体大小,value 指定新的字体大小为 40.0px

5.2 配置数据使用

目标控件必须在 UI 界面被用户看到之前设置相关属性,为此这里有几个时间点能应用:

  1. ActivityLifecycleCallbacks.onActivityCreated(Activity activity, Bundle savedInstanceState)

  2. LayoutInflater.inflate(@LayoutRes int resource, @Nullable ViewGroup root)

  3. onViewAttachedToWindow(View v)

    未添加至 Activity 的控件可以做监听设置,在 onViewAttachedToWindow 中触发

根据 4.1 的配置数据,界面生效前后如下所示:

view_prop_apply_case1.jpg

图 5-2-1 RecyclerView 的 ItemView 中的 TextView 的属性修改。这里全部的 type 均为 0

其他实例:

配置数据:

  1. {
  2. "uiProps": [
  3. {
  4. "floatValue": 0.0,
  5. "intValue": -16777216,
  6. "name": "textColor"
  7. },
  8. {
  9. "floatValue": 0.0,
  10. "intValue": 0,
  11. "name": "text",
  12. "value": "exit"
  13. }
  14. ],
  15. "viewID": "0d46ac5d749c137cb2ab0b65dad0248e09fd745ac24fa31d7dae5d837bac3cec"
  16. }

view_prop_apply_dialog.jpg

图 5-2-2

5.3 配置数据生成

查看 代码 5-2-1 的配置信息,不可能让开发人肉去填写,为此提供了一个可视化的工具

view_prop_edit_demo.gif

  1. [
  2. {
  3. "uiProps": [
  4. {
  5. "floatValue": 0.0,
  6. "intValue": 0,
  7. "name": "imageSrc",
  8. "value": "com.netease.demo.abtest/mipmap/android_n_lg"
  9. }
  10. ],
  11. "viewID": "267685e5d7299dca525cc7a09b801d59def9c6eb02ef12dacd1f674e4b8e3d0a"
  12. },
  13. {
  14. "uiProps": [
  15. {
  16. "floatValue": 0.0,
  17. "intValue": -1979711233,
  18. "name": "textColor"
  19. },
  20. {
  21. "floatValue": 0.0,
  22. "intValue": 0,
  23. "name": "text",
  24. "value": "Hello World Netease!!!"
  25. },
  26. {
  27. "floatValue": 0.0,
  28. "intValue": 0,
  29. "name": "textSize",
  30. "value": "50.0px"
  31. }
  32. ],
  33. "viewID": "f22bc639075f3e7c0f7cbd4be1201716ae73ecec058cb2e9734df51569129400"
  34. },
  35. {
  36. "uiProps": [
  37. {
  38. "floatValue": 0.0,
  39. "intValue": 0,
  40. "name": "text",
  41. "value": "Exit"
  42. }
  43. ],
  44. "viewID": "0d46ac5d749c137cb2ab0b65dad0248e09fd745ac24fa31d7dae5d837bac3cec"
  45. }
  46. ]

5.4 业务层自定义属性支持

SDK 层面仅能针对系统常见的控件属性提供设置和编辑功能,如针对 Viewbackgroundalpha,针对 TextViewtexttextColortextSize,针对 ImageView 等的 src 属性等。而各个业务 app 都会集成相关的第三方组件或自定义控件,SDK 预置的属性永远可能不满足业务方的全部需求。为此就必须支持业务方自定义设置属性和编辑属性。

5.4.1 设置属性自定义

ABTest UI 属性配置数据下发,json 数据如何分配到各个设置类上,这里通过 IPropSetter 的实现类实现。为支持自定义的属性,业务开发实现 IPropSetter 的自定义类。

  1. for (UIProp prop : uiCase.getUiProps()) {
  2. IPropSetter setter = sUIPropFactory.getPropSetter(prop.name);
  3. if (setter != null) {
  4. setter.apply(v, prop);
  5. }
  6. }

通过 IPropSetter.apply 方法设置对应属性

  1. public interface IPropSetter {
  2. /**
  3. * Use to apply view with new TypedValue
  4. * @param view
  5. * @param prop
  6. * @return success or not
  7. */
  8. boolean apply(View view, UIProp prop);
  9. /**
  10. * @return prop name
  11. */
  12. String name();
  13. }

IPropSetter 接口。name() 返回属性名,apply(View, UIProp) 设置属性

另外提供了注解 UIPropSetterAnno,支持编译期将业务层自定义 IPropSetter 实现类加入 sUIPropFactory.

  1. @Target(ElementType.TYPE)
  2. @Retention(RetentionPolicy.RUNTIME)
  3. public @interface UIPropSetterAnno {
  4. }

5.4.2 编辑属性自定义

为支持可视化生成 json 数据,需要编辑 UI 需要支持自定义属性。同样提供了基类 EditPropView

  1. package com.netease.tools.abtestuicreator.view.prop;
  2. ...
  3. public class EditPropView<T> extends FrameLayout implements TextWatcher {
  4. ...
  5. protected void onRestoreValue(View v) {
  6. }
  7. protected void onUpdateView(View v, Editable value) {
  8. }
  9. protected void onBindView(View v) {
  10. }
  11. ...
  12. }

为将业务层自定义的编辑控件加入目标编辑 View 的编辑列表中(不同的类,需要有不同的编辑列表,如 text 属性编辑不能用于 ImageView),提供了注解 UIPropCreatorAnno

  1. @Target(ElementType.TYPE)
  2. @Retention(RetentionPolicy.RUNTIME)
  3. public @interface UIPropCreatorAnno {
  4. Class viewType();
  5. String name();
  6. }

viewType() 返回属性编辑支持的类
name() 返回待编辑的属性名称

5.4.2 自定义属性支持示例

SimpleDraweeDraweesetImageURI 为例,定义属性名为 fresco_src

6 UI 重排版

大部分修改 UI 属性用作 ABTest,业务场景相对有限,更多的是,需要做 UI 局部重新布局

shoppingcart_abtest.jpg

图 6-1 严选购物车页面,协助分析不同 UI 样式下,用户凑单的形式
去凑单 文本的消失也认为是排版的一种,如 width 为 0

goodsdetail_abtest.jpeg

图 6-2 严选详情图。A:强化加购;B:强化立即购买

针对上述场景,纯 UI 排版的情况,并无新控件的出现,为此期望能有一套方案能支持线上动态重排版。而为了实现重排版,我们需要解决一下几点问题:

  1. 如何查找目标组件

    可以通过前面的 XPath 逻辑查找

  2. 如何防止原有布局的排版

    Android 已有布局,如 FrameLayoutLinearLayoutRelativeLayoutGridLayout 等会对控件进行布局,而布局的发生过程在各个 View 的 onMeasureonLayout。由于是线上逻辑,我们更不可能通过继承重写的方式放置原有 onMeasureonLayout 的方法逻辑执行。

    另外考虑能否清除属性的方式,也无法完全避免 Android 已有的布局干扰:

    • FrameLayout:若清除父控件 gravity 属性,清除子控件 layout_gravity,可以认为已经满足条件
    • RelativeLayout:子控件按照属性进行布局,若子控件布局属性全部清空,则和 FrameLayout 一致
    • LinearLayout:父控件 orientation 属性无法避免
    • GridLayout:父控件 orientationrowCountcolumnCount 等属性无法避免
  3. 如何对布局进行重排版

    参考 WeexReactiveNativeLuaView 使用 Facebook 开源的 CSSLayout 布局,这里也直接使用 CSSLayout。而 CSSLayout 如何应用到线上已有的一个 ViewGroup?

  4. 如何保持 ViewID 不变

    重布局之后,控件属性动态设置还需要生效

  5. 如何恢复布局

    常见的如,编辑界面编辑的时候,取消当前操作,需要恢复布局

这里针对 2 和 3 的疑点,可以暂时清除 gravitylayout_gravity 等属性,而 orientationRelativeLayout 特有的布局属性可以不用关心。
通过在父控件和子控件中间插入一个透明的 StubCSSLayout,来实现目的。

subcsslayout.jpg

图 6-3 SubCSSLayout 插入

中间层 StubCSSLayout 的作用:

  1. 隔离父控件和子控件,既能解除父控件对子控件的排版功能
  2. 利用 StubCSSLayout 对子控件进行 CSSLayout 排版
  3. 过滤 StubCSSLayout,并未真正破坏 ViewTree 结构,XPath 计算并不受影响,为此子节点的属性动态设置仍能生效

演示示例:

  1. <LinearLayout
  2. android:layout_width="match_parent"
  3. android:layout_height="100dp"
  4. android:orientation="vertical">
  5. <TextView
  6. android:layout_width="wrap_content"
  7. android:layout_height="wrap_content"
  8. android:text="Line 1"/>
  9. <TextView
  10. android:layout_width="wrap_content"
  11. android:layout_height="wrap_content"
  12. android:text="Line 2"/>
  13. <TextView
  14. android:layout_width="wrap_content"
  15. android:layout_height="wrap_content"
  16. android:text="Line 3"/>
  17. </LinearLayout>

待修改布局,垂直布局

  1. {'flexDirection':'row','flexWrap':'wrap','children':[{"sizetofit":true},{"sizetofit":true},{"sizetofit":true}]}

CSSLayout,水平布局

csslayout_edit_demo.gif

图 6-4 以一层布局作为示例,需要多层布局的,CSSLayout 配置数据嵌套多层即可

7 控件布局动态替换

考虑到特殊情况,就是需要重新替换布局,并且有创建新控件的场景,而这种情况,上面的重排版就无法实现了。考虑实现方案:

  1. 类似 LuaView、Weex、RN 下发脚本,动态解析,自行创建 View

    可以自行实现,但太重了,实现了一整套脚本控制控件创建和布局,几乎可以理解为实现了一个动态化方案,同时如何保持主题等细节问题处理起来会比较繁琐。
    另外,可以考虑直接接入上述的动态化方案,动态构建脚本容器进行替换,但考虑到,如果是过于复杂的场景,可以考虑发版本提供 ABTest,过重的方案本身已经不合适。

  2. 参考资源热更新的方案,同前面的观点,热更新应该仅用于线上严重崩溃问题,过于复杂的技术方案这里不考虑

    热更新方案容易引起其他不可知问题,参考作者当时使用 1.7.3 版本 Tinker 方案,严选线上发布后导致 WebView 获取资源失败;
    补丁加载成功后WebView获取资源失败android.content.res.Resources$NotFoundException: Resource ID #0x0

  3. 如果是复用 Android 的 xml 布局,那么如何使用生成、如何解析使用、是否有限制是需要考虑的问题

7.1 layout id 到 View 关键流程解析

解压 apk,可以看到里面的资源相关文件:

  1. resources.arsc
  2. res
  3. layout
  4. activity_suit.xml
  5. ...
  6. ...

其中布局文件 activity_suit.xml 等都是二进制格式的 XML 文件。为何我们开发时编辑的是 XML 文件需要编译成二进制格式的原因是:

  1. 二进制的 XML 元素的标签、属性名称、属性值和内容字符串会被统一收集到字符串资源池中(resources.arsc),XML 二进制文件只需持有资源索引的整数值,因此二进制 XML 文件大小更小
  2. 二进制 XML 文件的元素解析,避免了字符串解析,进而解析效率更高。

跟踪布局解析源码:

setContentView.jpg

其中关键节点:

  1. AssetManager.loadResourceValue 中根据资源 R.layout.activity_main 获取 TypedView,其中 value.string 为 res/layout/activity_main.xml

  2. AssetManager.openXmlAssetNative 根据 res/layout/activity_main.xml 获取 long 类型的 xmlBlock

    xmlBlock 其实是 ResXMLTree 指针

    查看源码:

    1. // android_util_AssetManager.cpp
    2. static jlong android_content_AssetManager_openXmlAssetNative(JNIEnv* env, jobject clazz,
    3. jint cookie,
    4. jstring fileName)
    5. {
    6. ...
    7. int32_t assetCookie = static_cast<int32_t>(cookie);
    8. Asset* a = assetCookie
    9. ? am->openNonAsset(assetCookie, fileName8.c_str(), Asset::ACCESS_BUFFER)
    10. : am->openNonAsset(fileName8.c_str(), Asset::ACCESS_BUFFER, &assetCookie);
    11. ...
    12. const DynamicRefTable* dynamicRefTable =
    13. am->getResources().getDynamicRefTableForCookie(assetCookie);
    14. ResXMLTree* block = new ResXMLTree(dynamicRefTable);
    15. status_t err = block->setTo(a->getBuffer(true), a->getLength(), true);
    16. ...
    17. return reinterpret_cast<jlong>(block);
    18. }

    其中 am->openNonAsset 会调用 openNonAssetInPathLocked

    1. // AssetManager.cpp
    2. Asset* AssetManager::openNonAssetInPathLocked(const char* fileName, AccessMode mode,
    3. const asset_path& ap) {
    4. ···
    5. /* check the appropriate Zip file */
    6. ZipFileRO* pZip = getZipFileLocked(ap);
    7. if (pZip != NULL) {
    8. //printf("GOT zip, checking NA '%s'\n", (const char*) path);
    9. ZipEntryRO entry = pZip->findEntryByName(path.string());
    10. if (entry != NULL) {
    11. //printf("FOUND NA in Zip file for %s\n", appName ? appName : kAppCommon);
    12. pAsset = openAssetFromZipLocked(pZip, entry, mode, path);
    13. pZip->releaseEntry(entry);
    14. }
    15. }
    16. ···
    17. }

    可以看到,其实是根据 res/layout/activity_main.xml 从 source apk 中读取 xml 文件数据,最后通过 block->setTo(...) 拷贝了一份数据,用于生成对象 ResXMLTree.

  3. AssetManager.openXmlBlockAsset 中根据 XmlBlock(AssetManager assets, long xmlBlock) 构建 XmlBlock,最后通过 XmlBlock.newParser() 生成 XmlResourceParser

  4. 最后使用 XmlResourceParser 作为参数,用于构建 View

    1. // LayoutInflater.java
    2. 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

  1. public XmlBlock(byte[] data) {
  2. mAssets = null;
  3. mNative = nativeCreate(data, 0, data.length);
  4. mStrings = new StringBlock(nativeGetStringBlock(mNative), false);
  5. }
  1. // android_util_XmlBlock.cpp
  2. static jlong android_content_XmlBlock_nativeCreate(JNIEnv* env, jobject clazz,
  3. jbyteArray bArray,
  4. jint off, jint len)
  5. {
  6. ...
  7. jsize bLen = env->GetArrayLength(bArray);
  8. ...
  9. jbyte* b = env->GetByteArrayElements(bArray, NULL);
  10. ResXMLTree* osb = new ResXMLTree();
  11. osb->setTo(b+off, len, true);
  12. ...
  13. return reinterpret_cast<jlong>(osb);
  14. }

为方便根据文本布局 XML 文件得到二进制 XML 文件内容的 base64,这里开发的相关 AS 插件 AndroidXmlLayout,方便编辑使用

选择的 xml 示例:

  1. // test_layout.xml
  2. <?xml version="1.0" encoding="utf-8"?>
  3. <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  4. android:layout_width="match_parent"
  5. android:layout_height="47dp"
  6. android:background="#FAFAFA"
  7. android:orientation="horizontal">
  8. <FrameLayout
  9. android:id="@+id/pre_month"
  10. android:layout_width="wrap_content"
  11. android:layout_height="match_parent"
  12. android:paddingLeft="18dp"
  13. android:paddingRight="18dp">
  14. <TextView
  15. android:id="@+id/tv_alert_content"
  16. android:layout_width="7dp"
  17. android:layout_height="12.5dp"
  18. android:layout_gravity="center"
  19. android:tag="R.id.tv_alert_content"
  20. android:background="#3cd088" />
  21. </FrameLayout>
  22. <TextView
  23. android:id="@+id/current_month"
  24. android:layout_width="0dp"
  25. android:layout_height="match_parent"
  26. android:layout_weight="1"
  27. android:gravity="center"
  28. android:text="2018年5月"
  29. android:textColor="#333333"
  30. android:textSize="16dp"
  31. android:textStyle="bold"
  32. android:tag="tag_data"/>
  33. <FrameLayout
  34. android:id="@+id/next_month"
  35. android:layout_width="wrap_content"
  36. android:layout_height="match_parent"
  37. android:paddingLeft="18dp"
  38. android:paddingRight="18dp">
  39. <TextView
  40. android:id="@+id/tv_next_month"
  41. android:layout_width="7dp"
  42. android:layout_height="12.5dp"
  43. android:layout_gravity="center"
  44. android:background="#3cd088"
  45. android:tag="R.id.tv_right" />
  46. </FrameLayout>
  47. </LinearLayout>

生成的二进制布局 XML 文件 base64 数据

  1. 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 并替换,查看效果

xmllayout_replace_demo.gif

7.3 自定义布局局限性

7.2 已经演示了使用动态下发二进制布局文件 base64 来显示动态布局的方案,看起来很方便很好用,然而其中的局限性也需要了解下:

  1. 因为这里需要通过反射获取 XmlBlock 实例,为此可能在个别版本或者特殊机型获取失败,为此需要事先知道这项功能是否可行
  2. 二进制布局文件里面的标签字符串通过 int 索引从资源池中查找。其中标签分为 2 类,一类为系统标签,另一类为 app 工程中自定义的资源,系统资源索引可以认为是不变的,而自定义资源则每次编译可能发生变化,为此我们下发的布局文件,不能引用新定义的资源 id,也不能引用 app 工程中已经定义好的资源。为此布局文件中的资源,如颜色、文本、尺寸等都必须直接写死,不能使用资源引用。

8 总结和不足

以上 Android 端 ABTest 框架总结如下:

  1. 通过 ABTest 类和协议一一对应的原则,理清协议和开发逻辑;
  2. 通过注解的方式自动选择初始化方法,规避了传统 if/else 代码在业务层的侵入;
  3. 通过动静分离计算 XPath,进一步保证了页面变化情况下 XPath 的唯一性和一致性;
  4. 通过 UI 配置数据下发,动态修改线上 UI 属性;
  5. 提供模拟器编辑工具,可视化方式生成 UI 配置数据,保证了数据的准确性,支持 Activity、Dialog、PopupWindow;
  6. 提供基类和注解,业务 app 能自定义实现自定义控件的特殊 UI 属性设置和对应的可视化编辑器;
  7. 通过使用 CSSLayout 语法的配置数据,实现线上 UI 的动态重布局;
  8. 通过下发自定义的二进制布局 XML base64数据,实现线上布局动态替换。

以上动态方案,对线上 ABTest 的及时分析与数据收集,提供了帮助。

除此,本方案也有以下不足之处,可以通过初始化时监测后屏蔽掉处理为默认情况(如默认为 A )

  1. 动态修改编辑,由于是 Android app 中直接编辑,操作方便性比起前端界面要差;
  2. 下发自定义的二进制布局 xml Base64数据,实现创造新布局,有一定局限性,不支持引用 app 资源或者新资源;
  3. Window LayoutInflator 替换可能存在失败的风险,部分厂家 rom 会自定义 PhoneWindow 类。这些可以在以后的版本中进行优化。
添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注