Android ABTest 设计与原理

96
zyl06
3.2 2018.12.23 23:26 字数 9694

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 属性修改和布局修改
// 'model01' 对应网站添加的产品模块名称
boolean flag = AdhocTracker.getFlag("module01", false);
if (flag) {
    btn01.setBackgroundColor(getResources().getColor(android.R.color.black));
    btn01.setTextColor(getResources().getColor(android.R.color.white));
    btn01.setTextSize(getResources().getDimension(R.dimen.textsize_small));
    btn01.setText("实验版本B");
    tv_tracking.setVisibility(View.VISIBLE);
} else {
    btn01.setBackgroundColor(getResources().getColor(android.R.color.white));
    btn01.setTextColor(getResources().getColor(android.R.color.black));
    btn01.setTextSize(getResources().getDimension(R.dimen.textsize));
    btn01.setText("实验版本A");
    tv_tracking.setVisibility(View.GONE);
}

AppAdhoc Android SDK 使用样例

代码样例来源链接

1.2 云眼

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

eyecloud_ui_edit.jpg

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

  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 布局。

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);

...

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 数据格式如下:

//abtest.json
[
    {
        "itemId":"SimpleTest_001",
        "accessory":"",
        "testCase":{
            "caseId":"001",
            "accessory":""
        }
    },
    {
        "itemId":"SimpleTest_002",
        "accessory":"",
        "testCase":{
            "caseId":"000",
            "accessory":""
        }
    }
]

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

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

@ABTesterAnno(itemId = "SimpleTest_001", updateType = ABTestUpdateType.IMMEDIATE_UPDATE)
public class OneABTester extends BaseABTester {

    private String name;

    public OneABTester() {
    }

    @Override
    protected void onUpdateConfig() {

    }

    @ABTestInitMethodAnnotation(caseId = "000", defaultInit = true)
    public void initA(@Nullable String accessory, @Nullable ABTestCase testVO) {
        name = "hanmeimei";
    }

    @ABTestInitMethodAnnotation(caseId = "001")
    public void initB(@Nullable String accessory, @Nullable ABTestCase testVO) {
        name = "lilei";
    }

    @ABTestInitMethodAnnotation(caseId = "002")
    public void initC(@Nullable String accessory, @Nullable ABTestCase testVO) {
        name = "lili";
    }

    public String getName() {
        return name;
    }
}
  • 注解 ABTesterAnno 指定了 ABTest 的 itemId

  • 注解 ABTesterAnno 指定了 ABTest 的 updateType

    • ABTestUpdateType.IMMEDIATE_UPDATE:json 数据请求更新,主动回调 onUpdateConfig 方法
    • ABTestUpdateType.HOT_UPDATE:json 数据请求更新后,重新创建 ABTester 生效
    • ABTestUpdateType.COLD_UPDATE:json 数据请求更新,需等到下次 app 启动生效
  • 注解 ABTestInitMethodAnnotation 指定了对应测试 case 触发时,会被执行初始化的代码

    • 若对应 itemId 数据无或并没有找到匹配的 testId,则执行 defaultInit 指定的初始化方法
    • 若有对应 itemId 和对应 testId 执行匹配的初始化方法
    • initA,initB,initC 并无命名要求
    • 初始化方法中,必须要有一个且仅有一个指定 defaultInit = true

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

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    List<ABTestItem> testItems = parseJsonFromAsset();
    ABTestConfig.getInstance().init(this.getApplication(), testItems, ABTestFileUtil.readUiCases(this));

    OneABTester test1 = new OneABTester();
    TextView tvName = (TextView) findViewById(R.id.tv_name);
    tvName.setText(test1.getName());
}
simple_test_case_0.jpg

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

// ABTest 初始化,设置为 null,未指定任何数据
ABTestConfig.getInstance().init(this.getApplication(), null, ABTestFileUtil.readUiCases(this));

OneABTester test1 = new OneABTester();
TextView tvName = (TextView) findViewById(R.id.tv_name);
tvName.setText(test1.getName());
simple_test_case_1.jpg

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

3.2 实现原理

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

  1. 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
    }
    
  2. 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)
        }
        ...
    }
    
  3. ABTest 实例执行选择的初始化方法

    protected void initAB() {
        if (!mIsInited) {
            mIsInited = true;
    
            ABTestCase testVO = getValidTest();
            if (mInitABMethod != null) {
                invokeMethod(mInitABMethod, testVO);
            }
        }
    }
    

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

    @ABTesterAnno(itemId = "SimpleTest_001")
    

public class OneABTester extends BaseABTester {

    ...

    public OneABTester() {
        initAB();
    }
    ...
}
```

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

```java
@Aspect
public class AspectABTester {

    @After("execution(com.netease.lib.abtest.BaseABTester+.new(..)) && !within(com.netease.lib.abtest.BaseABTester)")
    public void afterMethodExecution(JoinPoint joinPoint) {
        ...
        ((BaseABTester) joinPoint.getTarget()).initAB();
    }
}
```

3.3 小结

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

  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 如下图所示:

<!-- view_third_insert.xml -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="horizontal">

    <TextView
        android:id="@+id/text3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="5dp"
        android:text="text3"/>

    <TextView
        android:id="@+id/text4"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="5dp"
        android:text="text4"/>

</LinearLayout>
viewpath_viewtree_myopt_after.png

图 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 的蓝黑色节点)

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

    XPath:[{"className":"ContentFrameLayout","environment":"com.netease.demo.abtest.third.ThirdActivity","idName":"content","index":0},{"className":"LinearLayout","index":0,"resName":"activity_third"},{"className":"LinearLayout","index":0,"resName":"activity_third"},{"className":"LinearLayout","index":0,"resName":"view_third_insert"},{"className":"AppCompatTextView","index":0,"resName":"view_third_insert"}]
    ViewID:b184ec2565fff410af9ffad5a5cd6ace1773b4899f07970eaf499d9b675ff462
    

4.2.2 局部静态 XPath 计算

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

public class ThirdActivity extends AppCompatActivity {

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_third);
        ...
    }
    ...
}

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

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

private void replaceActivityLayoutInflater(Activity activity) {
    LayoutInflater inflater0 = (LayoutInflater) activity.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    if (!(inflater0 instanceof ABTestProxyLayoutInflater)) {
        LayoutInflater proxyInflater = new ABTestProxyLayoutInflater(inflater0);
        RefInvoker.setFieldObject(activity, ContextThemeWrapper.class, "mInflater", proxyInflater);
    }

    Window window = activity.getWindow();
    LayoutInflater inflater1 = activity.getWindow().getLayoutInflater();
    if (!(inflater1 instanceof ABTestProxyLayoutInflater)) {
        LayoutInflater proxyInflater = new ABTestProxyLayoutInflater(inflater1);
        if (RefInvoker.isInstanceOf(window, "com.android.internal.policy.PhoneWindow")) {
            RefInvoker.setFieldObject(window, "com.android.internal.policy.PhoneWindow", "mLayoutInflater", proxyInflater);
        } else if (RefInvoker.isInstanceOf(window, "com.android.internal.policy.impl.PhoneWindow")) {
            RefInvoker.setFieldObject(window, "com.android.internal.policy.impl.PhoneWindow", "mLayoutInflater", proxyInflater);
        }
    }
}

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

正常 Dialog,PopupWindow 使用的是 inflater1

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

@Override
public View inflate(int resource, ViewGroup root) {
    View result = mInflater.inflate(resource, root);

    View created = (root != null && root.getChildCount() > 0) ?
            root.getChildAt(root.getChildCount() - 1) :
            result;

    ViewPathUtil.setXmlLayoutLocalPathTag(getContext(), created, resource);

    onInflate(created);

    return result;
}

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

针对 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 的布局文件为:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/text_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="6dp"
        android:textSize="15dp"/>

</FrameLayout>

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

XPath:[{"className":"ContentFrameLayout","environment":"com.netease.demo.abtest.second.SecondActivity","idName":"content","index":0},{"className":"LinearLayout","idName":"real_main_view","index":0,"resName":"activity_second"},{"className":"FrameLayout","index":0,"resName":"activity_second"},{"className":"FrameLayout","environment":"com.netease.demo.abtest.second.ShoppingCartFragment","index":0},{"className":"ListView","idName":"listview","index":0},{"className":"FrameLayout","resName":"item_list_1","type":0},{"className":"AppCompatTextView","index":0,"resName":"item_list_1"}]
ViewID:e991bec2797470ed5eaaf25973c6538f266c0f53cc622c1e2c88aea3fa8301dd

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

{"className":"FrameLayout","resName":"item_list_1","type":0}

4.2.4 ViewPager 控件处理

ViewPager 较为特殊,虽然控件中需要区分 child view 是否有 DecorView 注解。decor 类型的 child 不是 ItemView,不参与复用;其他 child 是 ItemView,参与复用。ItemView 这里需要在 ViewPager 每次滑动的时候,更新复用的 ItemView 的 position

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

图 4-2-2 ViewPager 测试界面

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.v4.view.ViewPager
        android:id="@+id/vp_viewpager"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <android.support.design.widget.TabLayout
            android:id="@+id/tab_layout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:tabMode="fixed"
            app:tabGravity="fill">
        </android.support.design.widget.TabLayout>
    </android.support.v4.view.ViewPager>

</RelativeLayout>

ItemView 里的 居家 TextView ViewID 计算:

XPath:[{"className":"ContentFrameLayout","environment":"com.netease.demo.abtest.second.SecondActivity","idName":"content","index":0},{"className":"LinearLayout","idName":"real_main_view","index":0,"resName":"activity_second"},{"className":"FrameLayout","index":0,"resName":"activity_second"},{"className":"RelativeLayout","environment":"com.netease.demo.abtest.second.UserPageFragment","index":0},{"className":"ViewPager","idName":"vp_viewpager","index":0},{"className":"FrameLayout","pageIndex":2},{"className":"AppCompatTextView","idName":"text_view","index":0}]
ViewID:c8cbb5dfa8d384c68339f09653a8ac7927581c21cfd539f987e0c7670cd5d3f0

TabLayout 里面的 居家 TextView ViewID 计算:

XPath:[{"className":"ContentFrameLayout","environment":"com.netease.demo.abtest.second.SecondActivity","idName":"content","index":0},{"className":"LinearLayout","idName":"real_main_view","index":0,"resName":"activity_second"},{"className":"FrameLayout","index":0,"resName":"activity_second"},{"className":"RelativeLayout","environment":"com.netease.demo.abtest.second.UserPageFragment","index":0},{"className":"ViewPager","idName":"vp_viewpager","index":0},{"className":"android.support.design.widget.TabLayout","idName":"tab_layout","index":0},{"className":"android.support.design.widget.TabLayout$SlidingTabStrip","index":0},{"className":"android.support.design.widget.TabLayout$TabView","index":2},{"className":"AppCompatTextView","index":0}]
ViewID:b4f1b770e3dd2895ddb343856737eced4813b3bba713ffec9de164f22ceca038

5 控件属性动态修改

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

  1. 如何定位控件?

    参考前面 4 讲述的 ViewID 计算

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

  3. 如何将配置数据应用到控件上?

  4. 如何生成 ABTest 配置数据,如何检查效果?

  5. 如何处理业务层的自定义控件属性

5.1 配置数据格式定义

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

[
  {
    "uiProps": [
      {
        "floatValue": 0.5,
        "intValue": 0,
        "name": "alpha"
      },
      {
        "floatValue": 0.0,
        "intValue": -1979711233,
        "name": "textColor"
      },
      {
        "floatValue": 0.0,
        "intValue": 0,
        "name": "textSize",
        "value": "40.0px"
      }
    ],
    "viewID": "22b721d900197856706fc68083c4c3deba5e31a0d8e44438a96eb6473bbc9e0a"
  },
  ...
]

代码 5-2-1

viewID 指定线上的目标控件(这里不需要指定控件类型,因为同一个 viewID 不可能指向多个不同的 view)。uiProps 指定具体的属性数据。如 alpha 指定 View 的 alpha 属性,floatValue 指定新的 alpha 值;textColor 指定 TextView 的文本颜色,intValue 指定颜色值为 #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

其他实例:

配置数据:

{
    "uiProps": [
      {
        "floatValue": 0.0,
        "intValue": -16777216,
        "name": "textColor"
      },
      {
        "floatValue": 0.0,
        "intValue": 0,
        "name": "text",
        "value": "exit"
      }
    ],
    "viewID": "0d46ac5d749c137cb2ab0b65dad0248e09fd745ac24fa31d7dae5d837bac3cec"
}
view_prop_apply_dialog.jpg

图 5-2-2

5.3 配置数据生成

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

view_prop_edit_demo.gif
[
  {
    "uiProps": [
      {
        "floatValue": 0.0,
        "intValue": 0,
        "name": "imageSrc",
        "value": "com.netease.demo.abtest/mipmap/android_n_lg"
      }
    ],
    "viewID": "267685e5d7299dca525cc7a09b801d59def9c6eb02ef12dacd1f674e4b8e3d0a"
  },
  {
    "uiProps": [
      {
        "floatValue": 0.0,
        "intValue": -1979711233,
        "name": "textColor"
      },
      {
        "floatValue": 0.0,
        "intValue": 0,
        "name": "text",
        "value": "Hello World Netease!!!"
      },
      {
        "floatValue": 0.0,
        "intValue": 0,
        "name": "textSize",
        "value": "50.0px"
      }
    ],
    "viewID": "f22bc639075f3e7c0f7cbd4be1201716ae73ecec058cb2e9734df51569129400"
  },
  {
    "uiProps": [
      {
        "floatValue": 0.0,
        "intValue": 0,
        "name": "text",
        "value": "Exit"
      }
    ],
    "viewID": "0d46ac5d749c137cb2ab0b65dad0248e09fd745ac24fa31d7dae5d837bac3cec"
  }
]

5.4 业务层自定义属性支持

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

5.4.1 设置属性自定义

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

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

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

public interface IPropSetter {
    /**
     * Use to apply view with new TypedValue
     * @param view
     * @param prop
     * @return success or not
     */
    boolean apply(View view, UIProp prop);

    /**
     * @return prop name
     */
    String name();
}

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

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

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface UIPropSetterAnno {
}

5.4.2 编辑属性自定义

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

package com.netease.tools.abtestuicreator.view.prop;

...

public class EditPropView<T> extends FrameLayout implements TextWatcher {
    
    ...
    
    protected void onRestoreValue(View v) {

    }

    protected void onUpdateView(View v, Editable value) {

    }

    protected void onBindView(View v) {

    }
    ...
}

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

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface UIPropCreatorAnno {
    Class viewType();
    String name();
}

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

5.4.2 自定义属性支持示例

SimpleDraweeDraweesetImageURI 为例,定义属性名为 fresco_src

  • 自定义设置属性类

    @UIPropSetterAnno()
    public class FrescoSrcPropSetter implements IPropSetter {
    
        @Override
        public boolean apply(View view, UIProp prop) {
            if (prop.value instanceof String) {
                Uri uri = Uri.parse((String) prop.value);
                ((SimpleDraweeView) view).setImageURI(uri);
    
                return true;
            }
    
            return false;
        }
    
        @Override
        public String name() {
            return "fresco_src";
        }
    }
    
  • 自定义编辑属性类

    @UIPropCreatorAnno(viewType = SimpleDraweeView.class, name = "fresco_src")
    public class SimpleDraweeViewFrescoSrcPropView  extends EditPropView<String> {
    
        private Uri mOldValue;
    
        public SimpleDraweeViewFrescoSrcPropView(Context context) {
            this(context, null);
        }
    
        public SimpleDraweeViewFrescoSrcPropView(Context context, AttributeSet attrs) {
            this(context, attrs, 0);
        }
    
        public SimpleDraweeViewFrescoSrcPropView(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
        }
    
        @TargetApi(Build.VERSION_CODES.LOLLIPOP)
        public SimpleDraweeViewFrescoSrcPropView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
            super(context, attrs, defStyleAttr, defStyleRes);
        }
    
        @Override
        protected void onRestoreValue(View v) {
            super.onRestoreValue(v);
            if (mOldValue != null) {
                ((SimpleDraweeView) v).setImageURI(mOldValue);
            }
        }
    
        @Override
        protected void onUpdateView(View v, Editable value) {
            super.onUpdateView(v, value);
    
            try {
                mNewValue = value.toString();
                Uri uri = Uri.parse(mNewValue);
                ((SimpleDraweeView) v).setImageURI(uri);
            } catch (NumberFormatException e) {
                e.printStackTrace();
            }
        }
    
        @Override
        protected void onBindView(View v) {
            try {
                PipelineDraweeController controller = (PipelineDraweeController) ((SimpleDraweeView) v).getController();
                if (controller != null) {
                    Object dataSourceSupplier =
                            RefInvoker.invokeMethod(controller, "getDataSourceSupplier", null, null);
                    AbstractDraweeControllerBuilder builder = (AbstractDraweeControllerBuilder) RefInvoker.getFieldObject(dataSourceSupplier, "this$0");
                    ImageRequest imageRequest = (ImageRequest) builder.getImageRequest();
    
                    if (imageRequest != null) {
                        mOldValue = imageRequest.getSourceUri();
                    }
    
                    if (mOldValue != null) {
                        setValue(mOldValue.toString());
                    }
                }
            } catch (Exception e) {
                ABLog.e(e);
            }
        }
    }
    

    编辑属性类仅在开发生成配置 json 数据时使用,并不会上线,所以代码中的一些反射代码,并无影响

  • 程序演示

    view_prop_edit_demo_fresco.gif

6 UI 重排版

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

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 计算并不受影响,为此子节点的属性动态设置仍能生效

演示示例:

<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,水平布局

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,可以看到里面的资源相关文件:

resources.arsc
res
    layout
        activity_suit.xml
        ...
    ...

其中布局文件 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 指针

    查看源码:

    // 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.

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

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

    // LayoutInflater.java
    public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot)
    

    具体里面如何解析 xml 标签如何使用这里不做解析,因为已经能通过 public 方法能构建 View 了

7.2 自定义布局实现

观察 XmlBlock 的构造函数,可以发现传入字节流 data 生成 mNative 和 7.1 的流程一样,都是生成 ResXMLTree*。为此我们可以考虑下发新编译的二进制布局 xml 下发,并解析得到 View。

这里下发的是 二进制布局 xml 内容的 base64

public XmlBlock(byte[] data) {
    mAssets = null;
    mNative = nativeCreate(data, 0, data.length);
    mStrings = new StringBlock(nativeGetStringBlock(mNative), false);
}
// android_util_XmlBlock.cpp
static jlong android_content_XmlBlock_nativeCreate(JNIEnv* env, jobject clazz,
                               jbyteArray bArray,
                               jint off, jint len)
{
    ...

    jsize bLen = env->GetArrayLength(bArray);
    ...

    jbyte* b = env->GetByteArrayElements(bArray, NULL);
    ResXMLTree* osb = new ResXMLTree();
    osb->setTo(b+off, len, true);
    ...

    return reinterpret_cast<jlong>(osb);
}

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

xmlgenlayout_demo.gif

选择的 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 并替换,查看效果

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 类。这些可以在以后的版本中进行优化。
Android