Android ABTest 設(shè)計(jì)與原理

0 概念

A/B 測(cè)試是為 Web 或 app 界面或流程制作兩個(gè)(A/B)或多個(gè)(A/B/n)版本,在同一時(shí)間維度,分別讓組成成分相同(相似)的訪客群組隨機(jī)的訪問這些版本,收集各群組的用戶體驗(yàn)數(shù)據(jù)和業(yè)務(wù)數(shù)據(jù),最后分析評(píng)估出最好版本正式采用。

摘自百度百科

其他有關(guān) A/B 的內(nèi)容和作用宙址,可以參考 abtest-現(xiàn)狀,困境以及解決方案调卑,HubbleData通用A/B測(cè)試服務(wù)揭秘

在 app 開發(fā)中抡砂,也有很多涉及 A/B 測(cè)試的邏輯。既有 UI 界面相關(guān)令野,如購(gòu)物車去湊單按鈕的設(shè)計(jì)舀患;也有純邏輯相關(guān),是否支持 httpDNS 等气破。經(jīng)過多版本的迭代聊浅,我們需要管理 A/B/n 測(cè)試各個(gè)實(shí)例,如部分實(shí)例需要廢棄现使,部分實(shí)例需要調(diào)整默認(rèn)項(xiàng)(未指定時(shí)的默認(rèn)選項(xiàng))低匙,新加的實(shí)例等。

參考 ABTest 全鏈路碳锈,涉及客戶端 (實(shí)行 A/B/n 邏輯執(zhí)行和數(shù)據(jù)采集)顽冶,后端(A/B/n 數(shù)據(jù)生成、下發(fā)售碳、分析)强重、前端(A/B/n 測(cè)試可視化面板)等绞呈,本文僅關(guān)注 Android 客戶端的 ABTest 框架如何實(shí)現(xiàn),部分 ui 相關(guān)的測(cè)試數(shù)據(jù)如何生成间景。

1. 現(xiàn)有 A/B 測(cè)試應(yīng)用情況及考慮

1.1 AppAdhoc

參考 AppAdhoc Android SDK 的使用佃声,雖然已經(jīng)提供了 A/B 測(cè)試 的數(shù)據(jù)提供接口,然而還是能發(fā)現(xiàn)幾個(gè)明顯問題:

  1. 數(shù)據(jù)使用上倘要,還是需要業(yè)務(wù)層寫大量的 if/else 邏輯
  2. 相同的 ABTest 實(shí)例圾亏,在不同的頁面,容易出現(xiàn)重復(fù)代碼
  3. 后期維護(hù)容易出錯(cuò)封拧,如部分測(cè)試實(shí)例需要廢棄志鹃,需要工程中找出多處邏輯并修改
  4. 不支持普通 ui 屬性修改和布局修改
// 'model01' 對(duì)應(yīng)網(wǎng)站添加的產(chǎn)品模塊名稱
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("實(shí)驗(yàn)版本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("實(shí)驗(yàn)版本A");
    tv_tracking.setVisibility(View.GONE);
}

AppAdhoc Android SDK 使用樣例

代碼樣例來源鏈接

1.2 云眼

參考 云眼 Android,支持線上 UI 屬性修改泽西。

eyecloud_ui_edit.jpg

其前端編輯界面移植 mixpanel 代碼曹铃,前端編輯操作較為方便,但也有局限如下:

  1. 不支持自定義控件尝苇,甚至較為常用的第三方庫(kù)铛只,如 Fresco 等無法識(shí)別
  2. 前端界面無法處理 DialogPopupWindow
  3. 不支持動(dòng)態(tài)重布局

1.3 線上動(dòng)態(tài)支持方案考慮

若 app 部分模塊已使用 H5 頁面,或者使用 RN糠溜、weex 等動(dòng)態(tài)化框架實(shí)現(xiàn),則這部分邏輯已經(jīng)原生支持線上動(dòng)態(tài)支持 ABTest直撤。若 APP 業(yè)務(wù)模塊已經(jīng)實(shí)現(xiàn)了拆分和插件化非竿,則插件模塊也支持線上動(dòng)態(tài) A/B(參考 攜程Android App插件化和動(dòng)態(tài)加載實(shí)踐)。上述 2 種情況谋竖,同時(shí)支持純 UI 和普通邏輯的線上動(dòng)態(tài) A/B 測(cè)試红柱,而缺點(diǎn)也十分明顯:

  1. 針對(duì)非動(dòng)態(tài)化頁面和宿主包部分代碼,無法支持線上動(dòng)態(tài)

    很多 app 集成了動(dòng)態(tài)化框架蓖乘,然而一般是少量經(jīng)常變化的頁面才會(huì)使用 weex 等實(shí)現(xiàn)

    H5 頁面相比會(huì)使用的更加廣泛锤悄,嚴(yán)選詳情頁、專題頁嘉抒、會(huì)員中心等頁面都會(huì)使用 H5零聚,而本文更關(guān)注的 native 的 A\B 實(shí)現(xiàn)。H5 的相關(guān)內(nèi)容可查看 abtest-web在線頁面編輯實(shí)現(xiàn)-abtest可視化實(shí)驗(yàn)些侍,abtest-現(xiàn)狀隶症,困境以及解決方案,HubbleData通用A/B測(cè)試服務(wù)揭秘

  2. 現(xiàn)有 app 支持插件化且支持動(dòng)態(tài)下發(fā)比較少岗宣,而為了 A/B 測(cè)試集成插件化就很難想象了

    相比更多 app 支持了業(yè)務(wù)模塊化蚂会,但模塊化并不支持動(dòng)態(tài)加載

  3. 用戶更新頻繁

    A\B 測(cè)試在 app 后期優(yōu)化階段,會(huì)用的比較頻繁耗式,而如果每次都是全量動(dòng)態(tài)腳本代碼或是全量插件包下發(fā)胁住,流量會(huì)有一定消耗趁猴,開發(fā)者需要考慮增量更新,而增量更新又需要一個(gè)增量包的管理平臺(tái)

除了 H5彪见、動(dòng)態(tài)化和插件化等方案躲叼,也有如 Tangram 這種半動(dòng)態(tài)化方案,將 RecycleView 的每個(gè) ViewHolder 看成卡片企巢,通過動(dòng)態(tài)下發(fā) json 數(shù)據(jù)或自定義格式的 xml 來動(dòng)態(tài)定制卡片的 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 布局,但需要編碼注冊(cè)好
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 角度也可以發(fā)現(xiàn) Tangram 也有較大的局限性:

  1. 綁定僅支持 RecyclerView
  2. 需要事先在代碼中編寫如上的 Tangram 初始化代碼
  3. 能支持的卡片類型初始化的時(shí)候預(yù)置

2 A/B Test 考慮和框架目標(biāo)

針對(duì) H5或听、動(dòng)態(tài)化框架,不能因?yàn)?A/B 測(cè)試將大部分 Native 頁面改成腳本頁面笋婿;同理誉裆,app 也不能因?yàn)?A/B 而集成插件化,為此個(gè)人認(rèn)為完全動(dòng)態(tài)的線上 A/B 能力并不現(xiàn)實(shí)

排除熱更新方案缸濒,熱更新應(yīng)該僅用于線上問題修復(fù)足丢;
已經(jīng)使用動(dòng)態(tài)化框架、插件化的 APP庇配,可以順帶支持下線上 A/B 動(dòng)態(tài)能力斩跌;

考慮線上相當(dāng)一部分場(chǎng)景是純 UI 界面改動(dòng)的 A/B 測(cè)試,如重新布局捞慌,部分文案顏色修改等耀鸦,而這部分場(chǎng)景我們可以通過其他手段來實(shí)現(xiàn)線上動(dòng)態(tài)的目標(biāo)。剩余復(fù)雜 UI 場(chǎng)景和業(yè)務(wù)邏輯場(chǎng)景啸澡,可代碼寫入 app袖订,等線上啟用。

shoppingcart_abtest.jpg

圖 2-1 嚴(yán)選第一個(gè)版本的 ABTest 實(shí)例嗅虏,協(xié)助分析不同 UI 樣式下洛姑,用戶湊單的形式

針對(duì)上述情況,我們可以理解為是簡(jiǎn)單的布局重排邏輯皮服,其中 去湊單 的隱藏楞艾,可以通過設(shè)置 View 寬度為 0 實(shí)現(xiàn)。若按照常規(guī)的 ABTest 框架冰更,如 AppAdhoc 等产徊,還是需要等待 APP 版本發(fā)布并上線才能支持,若能有一套線上動(dòng)態(tài)布局的方案蜀细,就可以在運(yùn)營(yíng)產(chǎn)品和分析師提出需求時(shí)舟铜,立馬線上實(shí)施得到數(shù)據(jù)。

2.1 框架目標(biāo)

我們需要一套框架奠衔,解決上述問題谆刨,并對(duì)業(yè)務(wù)層開發(fā)透明

  1. 支持同步后臺(tái) A/B 測(cè)試 json 數(shù)據(jù)

  2. 提供多種生效策略塘娶,支持立即生效、熱啟動(dòng)生效和冷啟動(dòng)生效

  3. 針對(duì)業(yè)務(wù)邏輯 A/B 測(cè)試痊夭,提供實(shí)例編寫規(guī)范刁岸,避免業(yè)務(wù)層 if/else 邏輯

    業(yè)務(wù)層邏輯并不需要自己現(xiàn)在執(zhí)行的是 A 還是 B

  4. 方便 AB 測(cè)試實(shí)例的統(tǒng)一管理和后期維護(hù)

  5. 針對(duì)普通 UI 屬性,支持線上動(dòng)態(tài)實(shí)驗(yàn)

  6. 提供一定能力的動(dòng)態(tài)布局能力她我,創(chuàng)建新的布局

    動(dòng)態(tài)布局虹曙,可以分為重排版和替換為新布局

3 A/B/n 測(cè)試使用規(guī)范及實(shí)現(xiàn)

3.1 A/B/n 測(cè)試使用規(guī)范

約定 ABTest 實(shí)例的 json 數(shù)據(jù)格式如下:

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

代碼樣例 3-1;
id 是 SimpleTest_001SimpleTest_002 的測(cè)試數(shù)據(jù)番舆;
itemId 指定具體是哪個(gè) ABTest酝碳,caseId 指定 A or B

可以理解相同的 ABTest case,如果在程序邏輯中有多處恨狈,那么這些代碼應(yīng)該都是一致的疏哗,同時(shí)業(yè)務(wù)層不應(yīng)該關(guān)心當(dāng)前是否有對(duì)應(yīng) ABTest 的 json 數(shù)據(jù)(如果沒有走 A/B/n 的默認(rèn)邏輯,這里假設(shè) "000" 為默認(rèn)邏輯)禾怠》捣睿基于此,對(duì)應(yīng)每個(gè) ABTest case 都封裝了對(duì)應(yīng)的類

@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 數(shù)據(jù)請(qǐng)求更新芽偏,主動(dòng)回調(diào) onUpdateConfig 方法
    • ABTestUpdateType.HOT_UPDATE:json 數(shù)據(jù)請(qǐng)求更新后,重新創(chuàng)建 ABTester 生效
    • ABTestUpdateType.COLD_UPDATE:json 數(shù)據(jù)請(qǐng)求更新牲证,需等到下次 app 啟動(dòng)生效
  • 注解 ABTestInitMethodAnnotation 指定了對(duì)應(yīng)測(cè)試 case 觸發(fā)時(shí)哮针,會(huì)被執(zhí)行初始化的代碼

    • 若對(duì)應(yīng) itemId 數(shù)據(jù)無或并沒有找到匹配的 testId,則執(zhí)行 defaultInit 指定的初始化方法
    • 若有對(duì)應(yīng) itemId 和對(duì)應(yīng) testId 執(zhí)行匹配的初始化方法
    • initA坦袍,initB,initC 并無命名要求
    • 初始化方法中等太,必須要有一個(gè)且僅有一個(gè)指定 defaultInit = true

查看 ABTest 實(shí)例的 json 數(shù)據(jù)查看 代碼樣例 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 根據(jù) SimpleTest_001 指定的 caseId 001捂齐,執(zhí)行初始化方法 initB,顯示 lilei

// ABTest 初始化缩抡,設(shè)置為 null奠宜,未指定任何數(shù)據(jù)
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 運(yùn)行結(jié)果,結(jié)果顯示由 defaultInit 指定的 caseId 000瞻想,執(zhí)行初始化方法 initA压真,顯示 hanmeimei

3.2 實(shí)現(xiàn)原理

上述邏輯封裝較為簡(jiǎn)單,具體邏輯如下:

  1. ABTestConfig 單例初始化后蘑险,會(huì)記錄全部的 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. 如果是立即更新或熱啟動(dòng)更新佃迄,則從 mABTestConfigModel.abtestLasestNorCases 嘗試獲取 itemId 匹配的值泼差,并返回
        // 2. 嘗試從 mABTestConfigModel.abtestNorCases 獲取 itemId 匹配的值贵少,并返回
        // 3. 若找不到,返回 null
    }
    
  2. ABTest 實(shí)例創(chuàng)建的時(shí)候堆缘,在構(gòu)造函數(shù)中會(huì)根據(jù)注解的值去查詢配置數(shù)據(jù)滔灶,查詢并設(shè)置初始化方法和有效的 ABTest 數(shù)據(jù)實(shí)例。

    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 實(shí)例吼肥,用于后期數(shù)據(jù)更新通知
                ABTestConfig.getInstance().mABTesterRefs.add(new ObjWeakRef<>(this));
            }
        }
    
        private void chooseInitMethod(ABTestCase testCase) {
            // 尋找含有 ABTestInitMethodAnnotation 注解的初始化方法
            // 1. 根據(jù) caseId 找到對(duì)應(yīng)方法录平,設(shè)置 mInitABMethod 和 mValidTestVO
            // 2. 找不到對(duì)應(yīng)方法,根據(jù) defaultInit 找到默認(rèn)初始化方法缀皱,設(shè)置 mInitABMethod(mValidTestVO 為null)
        }
        ...
    }
    
  3. ABTest 實(shí)例執(zhí)行選擇的初始化方法

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

    通過反射運(yùn)行初始化方法斗这,然而由于初始化方法是子類的中定義,為此不能在基類的構(gòu)造函數(shù)中執(zhí)行唆鸡,只能在子類構(gòu)造函數(shù)的執(zhí)行的最后執(zhí)行涝影。

    @ABTesterAnno(itemId = "SimpleTest_001")
    

public class OneABTester extends BaseABTester {

    ...

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

而通過編碼規(guī)范要求各個(gè) ABTest 實(shí)例的構(gòu)造函數(shù)最后寫 `initAB()`,個(gè)人感覺比較機(jī)械争占,而且容易被業(yè)務(wù)開發(fā)遺漏燃逻。這里通過 `aspectJ` 在業(yè)務(wù)層的全部的 ABTest 實(shí)例子類的構(gòu)造函數(shù)的最后插入 `initAB()` 執(zhí)行初始化方法

```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 小結(jié)

以上講述了普通 ABTest 實(shí)例的編碼使用和原理,對(duì)于上層業(yè)務(wù)層完成以下目的:

  1. 使用注解標(biāo)記 ABTest 的 itemId 和 caseId臂痕,代碼邏輯更加清晰
  2. 支持立即更新伯襟、熱啟動(dòng)更新、冷啟動(dòng)更新
  3. 隱藏了 ABTest 的原始數(shù)據(jù)解析和使用
  4. 避免了業(yè)務(wù)開發(fā)使用 if/else 執(zhí)行對(duì)應(yīng)的 A/B/n 邏輯流程握童,
  5. 將全部和 ABTest 相關(guān)的業(yè)務(wù)代碼封裝到實(shí)例子類當(dāng)中姆怪,方便 ABTest 對(duì)象管理,避免業(yè)務(wù)層多處使用相同 ABTest 產(chǎn)生的重復(fù)代碼

4 如何定位控件 - ViewID

在講述如何線上動(dòng)態(tài)修改控件屬性澡绩,修改替換 UI 布局等之前稽揭,首先需要處理的是如何定位目標(biāo)控件。為此肥卡,需要為界面上的每一個(gè)控件分配一個(gè)唯一的 ViewID溪掀。這里同埋點(diǎn)方案的 ViewId 概念基本一致,需要具備唯一性和一致性步鉴,但也有差異揪胃。埋點(diǎn)方案中需要準(zhǔn)確區(qū)分每一個(gè) View,比如 ListView氛琢,RecyclerView 的相同 type 的 item view喊递,必須認(rèn)為是不一樣的,甚至相同 item view 實(shí)例由于復(fù)用而導(dǎo)致的 position 不一致阳似,ViewID 也必須要是不一致的骚勘。而這里的場(chǎng)景是為了 ABTest,如果列表中只有一個(gè) item view 發(fā)生布局變化意義并不大障般。為此認(rèn)為同一個(gè) ListView 或 RecyclerView 中相同 type 的 item view 都是一致的调鲸,需要計(jì)算出相同的 ViewID盛杰。

在埋點(diǎn)方案中也有類似的 ViewID 概念,此 ViewID 需要具備唯一性和和一致性藐石。唯一性是指每個(gè) View 的 ViewID 都是唯一的即供,不會(huì)與其他的 View 的 ViewID 發(fā)生重復(fù)。一致性是指 APP 運(yùn)行過程中于微,多次進(jìn)入相同界面撑瞧,或者界面發(fā)生變化加匈,View 的 ViewID 都不會(huì)發(fā)生變化统屈。

4.1 現(xiàn)有方案

首先排除 View.getId()躏仇,因?yàn)椴季治募形粗付?id 和動(dòng)態(tài)代碼 new 出來的 View 都是 NO_ID,而即便是布局文件中指定了 id 的 view恋腕,在不同版本編譯產(chǎn)生的 id 也可能不一致抹锄。

參考無埋點(diǎn)技術(shù),ViewID 主流的技術(shù)方案有 XPathTouchTarget荠藤。

4.1.1 XPath

XPath 方法較為主流伙单,如 mixpanel百分點(diǎn)埋點(diǎn)哈肖、網(wǎng)易樂得埋點(diǎn)吻育、網(wǎng)易HubbleData∮倬基本原理是根據(jù)當(dāng)前 view 到 rootView(android.R.id.content)的路徑布疼,并結(jié)合當(dāng)前界面的 Activity,F(xiàn)ragment币狠,view tag游两,view id 等,最終生成一個(gè)字符串表示當(dāng)前 View 的 ViewID漩绵。

上述各家方案器罐,會(huì)有細(xì)節(jié)差異,但 view tree 邏輯基本思路一致

簡(jiǎn)單示例如下:

viewpath_layout.png

圖 4-1-1

針對(duì)以上布局渐行,其 view tree 如下:

viewpath_viewtree.png

圖 4-1-2 view tree

若要計(jì)算第 4 層第 3 個(gè)節(jié)點(diǎn)的 TextView 的 ViewID,可以根據(jù)當(dāng)前節(jié)點(diǎn)到根節(jié)點(diǎn)的路徑铸董,結(jié)合當(dāng)前 Activity祟印、Fragment 等額外信息來表示。

XPath 方法在頁面動(dòng)態(tài)變化較多的場(chǎng)景粟害,如 View 動(dòng)態(tài)插入蕴忆、刪除等情況,就不太容易能保證唯一性和一致性悲幅。為此各家埋點(diǎn)方案也做了很多的優(yōu)化方案套鹅,比較常見的一種優(yōu)化是:相同層級(jí) view 的 index 計(jì)算修改為根據(jù)同類型控件 index 計(jì)算站蝠。

如上圖,當(dāng) id 為 btn1 的 Button 被移除會(huì)導(dǎo)致后面的全部控件的 view path 發(fā)生變化卓鹿,這些控件的 ViewID 一致性就無法保證菱魔,甚至節(jié)點(diǎn) 3 的 TextView index 變成 2,ViewID 的唯一性也無法保證了

viewpath_viewtree_opt_before.png

圖 4-1-3

若相同層級(jí)根據(jù)同類型 view 之間的 index 標(biāo)記吟孙,則可以避免這種情況:

viewpath_viewtree_opt_after.png

圖 4-1-4 此時(shí)如果 btn1 被移除了澜倦,后面的 TextView ViewID 并不會(huì)受影響。

其他如何計(jì)算 ViewPager杰妓、ListView藻治、RecyclerView 里的 ItemView 的 ViewID,以及 Fragment 中的控件 ViewID 等巷挥,如何保證一致性和唯一性的優(yōu)化方案桩卵,參考以下文章,這里不在重復(fù)描述

  1. SDK無埋點(diǎn)技術(shù)在百分點(diǎn)的探索和實(shí)踐
  2. Android無埋點(diǎn)數(shù)據(jù)收集SDK關(guān)鍵技術(shù)解析
  3. 網(wǎng)易HubbleData之Android無埋點(diǎn)實(shí)踐

4.1.2 利用 TouchTarget 計(jì)算 ViewID

該方案參考 得到Android團(tuán)隊(duì)無埋點(diǎn)方案

由于無埋點(diǎn)基本上解決的是線上控件點(diǎn)擊的埋點(diǎn)事件收集倍宾,所以作者從 View 點(diǎn)擊發(fā)生時(shí)的運(yùn)行時(shí)信息入手雏节,通過在 Activity 的 window 上調(diào)用 window.setCallback() 接管窗口的事件派發(fā),在 dispatchTouchEvent 函數(shù)中處理 up 事件凿宾,通過 ViewGroup TouchTarget 鏈表找到當(dāng)前交互的目標(biāo)控件矾屯,最后通過 Activity 類名 + 控件所在的 layout 文件名 + 控件 id 對(duì)應(yīng)的資源名來確定目標(biāo)控件的唯一標(biāo)識(shí)。

其中 layout 文件的根 View id 和控件所在的 layout 文件名一致初厚,子 View 的 id 名不能和根 View id 一樣件蚕,同時(shí)各個(gè) View 之間的 View id 均不能一致。除此之外還有其他規(guī)則产禾。具體規(guī)則的保證排作,作者提供了 自定義 Lint 檢查工具

4.2 方案選擇與實(shí)現(xiàn)優(yōu)化

根據(jù)當(dāng)前目標(biāo),線上動(dòng)態(tài)修改目標(biāo) View 的屬性亚情,為此必須在 Activity 界面展示給用戶看之前就找到目標(biāo) View 并修改屬性妄痪,為此 TouchTarget 計(jì)算 ViewID 方案并不可行,不能等到用戶點(diǎn)擊才計(jì)算 ViewID楞件。XPath 方案基本符合當(dāng)前場(chǎng)景衫生,但也存在部分不符合場(chǎng)景和缺陷的地方:

  1. ViewTree 動(dòng)態(tài)變化的場(chǎng)景適應(yīng)力有限
  2. ListView、RecyclerView 等 ItemView 不能以 position 區(qū)分土浸,而是以 type 區(qū)分

4.2.1 ViewTree 動(dòng)靜分離適配動(dòng)態(tài)變化

圖 4-1-4罪针,已有的 XPath 方法能較好的處理 btn1 被移除的情況,而 btn1 的下一個(gè)節(jié)點(diǎn)(紅色 TextView)被移除黄伊,則還是會(huì)導(dǎo)致下一個(gè) TextView 的 ViewID 一致性失效泪酱,同時(shí) ViewID 變成被移除 TextView 的 ViewID,則唯一性也失效了。
考慮到 app 中顯示的 UI 界面基本以 xml 生成墓阀,而 java 代碼代碼動(dòng)態(tài)生成的場(chǎng)景較少(從規(guī)范上毡惜,也不推薦)。為此斯撮,重新查看圖 4-1经伙,可以發(fā)現(xiàn)當(dāng)前布局全部由 layout xml 布局決定,為此 ViewTree 中的每個(gè)節(jié)點(diǎn)(除了根節(jié)點(diǎn) android.R.id.content)的 ViewID 可以由 layout xml 的 ViewTree 結(jié)構(gòu)唯一決定吮成,不管是在 ViewTree 中插入節(jié)點(diǎn)還是刪除節(jié)點(diǎn)橱乱,ViewTree 中保留節(jié)點(diǎn) 的 ViewID 還是應(yīng)該按照 layout xml 的 ViewTree 計(jì)算,而不應(yīng)該按照新的動(dòng)態(tài)場(chǎng)景樹計(jì)算粱甫,所以原有節(jié)點(diǎn) ViewID 均不受影響泳叠,而新插入的節(jié)點(diǎn)還是按照 XPath 原有的方式計(jì)算 ViewID。

根據(jù)以上考慮茶宵,我們需要將 ViewTree 的全局節(jié)點(diǎn)做分類危纫。這里引入新的概念:

  1. 靜態(tài)布局:利用 layout xml 生成的 ViewTree

  2. 動(dòng)態(tài)布局:利用 java 代碼生成的 ViewTree,或者在已有 ViewTree 上進(jìn)行刪除乌庶、插入操作

  3. 靜態(tài)布局節(jié)點(diǎn):靜態(tài)布局的子節(jié)點(diǎn)种蝶,不含根節(jié)點(diǎn)(根節(jié)點(diǎn)最終要?jiǎng)討B(tài)加入 android.R.id.content 或其他布局)

  4. 動(dòng)態(tài)布局的節(jié)點(diǎn):包括 java 代碼動(dòng)態(tài) new 出來的 view 和靜態(tài)布局的根節(jié)點(diǎn)

    動(dòng)態(tài)布局節(jié)點(diǎn)的 index 計(jì)算,需要根據(jù)兄弟動(dòng)態(tài)節(jié)點(diǎn)計(jì)算(隔離靜態(tài)布局和動(dòng)態(tài)布局之間的干擾)瞒大,另外計(jì)算的是相同類型節(jié)點(diǎn)的索引

  5. 全局 XPath:當(dāng)前節(jié)點(diǎn)在整個(gè)頁面布局 ViewTree 上的 XPath 值螃征,經(jīng)過 sha256 加密就是最終的 ViewID 值

  6. 局部靜態(tài) XPath:當(dāng)前節(jié)點(diǎn)由 layout xml 生成,當(dāng)前節(jié)點(diǎn)到 layout 根節(jié)點(diǎn)的 XPath

    • 根節(jié)點(diǎn)會(huì)有標(biāo)記透敌,標(biāo)識(shí)當(dāng)前節(jié)點(diǎn)是根節(jié)點(diǎn)盯滚;
    • 全部局部節(jié)點(diǎn)都有標(biāo)記是哪個(gè) layout 布局的節(jié)點(diǎn);
    • 葉子節(jié)點(diǎn)或子樹被動(dòng)態(tài)移除酗电,被移除的全部節(jié)點(diǎn) layout 布局的標(biāo)記需要清除魄藕,之后若加入場(chǎng)景樹,全部節(jié)點(diǎn)都認(rèn)為是動(dòng)態(tài)布局撵术;
    • 子節(jié)點(diǎn) index 根據(jù)在父節(jié)點(diǎn)的位置決定背率,不用按照相同類型的節(jié)點(diǎn)來算節(jié)點(diǎn)

繼續(xù)針對(duì) 圖 4-1-2,我們刪除橘紅色節(jié)點(diǎn) TextView嫩与,并在當(dāng)前位置插入另一個(gè)布局 view_third_insert.xml 和一個(gè) TextView寝姿,則當(dāng)前 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 靜態(tài)布局和動(dòng)態(tài)布局區(qū)分后的 ViewTree;
黑色節(jié)點(diǎn)為動(dòng)態(tài)布局節(jié)點(diǎn)划滋,紅色節(jié)點(diǎn)為靜態(tài)布局節(jié)點(diǎn)

按照優(yōu)化后的 XPath 計(jì)算会油,我們把靜態(tài)布局和動(dòng)態(tài)布局做了區(qū)分,白色是根節(jié)點(diǎn)古毛,藍(lán)黑色的全部節(jié)點(diǎn)是由 activity_third.xml 生成,亮藍(lán)色的全部節(jié)點(diǎn)由 view_third_insert.xml 生成,綠色節(jié)點(diǎn)由 java 代碼動(dòng)態(tài)生成稻薇。此時(shí)我們可以發(fā)現(xiàn)第 4 層的第 5 個(gè)節(jié)點(diǎn)(index 為 1 的 TextView)的 XPath 計(jì)算并不受影響嫂冻,索引依然為 3,根據(jù)它最初在靜態(tài)布局中的索引塞椎,而不是因?yàn)榍懊鎰?dòng)態(tài)加入的綠色 TextView 節(jié)點(diǎn)計(jì)算得到桨仿。動(dòng)態(tài)加入的綠色節(jié)點(diǎn),不管是在下一個(gè) TextView 的前面還是后面案狠,它的 index 均為 0服傍,隔離了靜態(tài)布局和動(dòng)態(tài)布局之間的相互影響

優(yōu)化后的 XPath 計(jì)算結(jié)果:

  1. index 3 的 TextView(圖 4-2-1 數(shù)字 3 的藍(lán)黑色節(jié)點(diǎn))

    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
    

    其中節(jié)點(diǎn)的局部靜態(tài) 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"}]
    

    根節(jié)點(diǎn)所在的動(dòng)態(tài)布局 XPath 為:

    [{"className":"ContentFrameLayout","environment":"com.netease.demo.abtest.third.ThirdActivity","idName":"content","index":0},{"className":"LinearLayout","index":0,"resName":"activity_third"}]
    
  2. 綠色節(jié)點(diǎn) 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,數(shù)字為 0 亮藍(lán)色節(jié)點(diǎn))

    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 局部靜態(tài) XPath 計(jì)算

以上動(dòng)靜 XPath 分離的方案骂铁,關(guān)鍵是如何計(jì)算局部靜態(tài) XPath吹零。我們必須在布局 xml inflate 后就針對(duì)當(dāng)前局部布局計(jì)算并保存。查看我們的 Activity 的常規(guī)寫法:

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(...) 里面會(huì)調(diào)用 ActivityLifecycleCallbacks.onActivityCreated(...),而 setContentView(...) 里面會(huì)調(diào)用 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 方法中計(jì)算局部靜態(tài) 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 等特殊控件處理

針對(duì) ListView烁挟,RecyclerView 等控件婴洼,期望同一個(gè)配置能使相同 type 的 ItemView 都生效,為此相同 type 的 ItemView 的 ViewID 都要一致撼嗓。為此柬采,這里不能使用 position 作為 XPath 中的一個(gè)變量,而是應(yīng)該使用 type静稻。

viewpath_listview.jpg

圖 4-2-2 ListView 測(cè)試界面警没。白底 ItemView type 為 0,灰底 ItemView type 為 1振湾。

因?yàn)?RecyclerView杀迹、SpinnerListView 計(jì)算 XPath 完全類似,所以這里僅僅講述 ListView押搪。

其中每個(gè) 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 結(jié)果如下

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 根節(jié)點(diǎn)的 ViewPathElement 如下树酪。由于沒有 position 信息,所以全部白底 ItemView 里面的 TextView 的 ViewID 全部一致

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

4.2.4 ViewPager 控件處理

ViewPager 較為特殊大州,雖然控件中需要區(qū)分 child view 是否有 DecorView 注解续语。decor 類型的 child 不是 ItemView,不參與復(fù)用厦画;其他 child 是 ItemView疮茄,參與復(fù)用滥朱。ItemView 這里需要在 ViewPager 每次滑動(dòng)的時(shí)候,更新復(fù)用的 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 測(cè)試界面

<?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 計(jì)算:

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 計(jì)算:

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 控件屬性動(dòng)態(tài)修改

控件屬性徙邻,是指 View 的背景顏色,透明度畸裳、是否顯示等缰犁,TextView 的文本內(nèi)容、文本顏色等屬性怖糊。為了支持線上控件屬性的動(dòng)態(tài)修改帅容,我們需要解決一下問題:

  1. 如何定位控件?

    參考前面 4 講述的 ViewID 計(jì)算

  2. 如何定義下發(fā)的配置數(shù)據(jù)伍伤?

  3. 如何將配置數(shù)據(jù)應(yīng)用到控件上并徘?

  4. 如何生成 ABTest 配置數(shù)據(jù),如何檢查效果嚷缭?

  5. 如何處理業(yè)務(wù)層的自定義控件屬性

5.1 配置數(shù)據(jù)格式定義

這里定義配置文件的格式如下:

[
  {
    "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 指定線上的目標(biāo)控件(這里不需要指定控件類型饮亏,因?yàn)橥粋€(gè) viewID 不可能指向多個(gè)不同的 view)。uiProps 指定具體的屬性數(shù)據(jù)阅爽。如 alpha 指定 View 的 alpha 屬性路幸,floatValue 指定新的 alpha 值;textColor 指定 TextView 的文本顏色付翁,intValue 指定顏色值為 #8A0000FF简肴;textSize 指定 TextView 的字體大小,value 指定新的字體大小為 40.0px百侧。

5.2 配置數(shù)據(jù)使用

目標(biāo)控件必須在 UI 界面被用戶看到之前設(shè)置相關(guān)屬性砰识,為此這里有幾個(gè)時(shí)間點(diǎn)能應(yīng)用:

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

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

  3. onViewAttachedToWindow(View v)

    未添加至 Activity 的控件可以做監(jiān)聽設(shè)置,在 onViewAttachedToWindow 中觸發(fā)

根據(jù) 4.1 的配置數(shù)據(jù)佣渴,界面生效前后如下所示:

view_prop_apply_case1.jpg

圖 5-2-1 RecyclerView 的 ItemView 中的 TextView 的屬性修改辫狼。這里全部的 type 均為 0

其他實(shí)例:

配置數(shù)據(jù):

{
    "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 配置數(shù)據(jù)生成

查看 代碼 5-2-1 的配置信息,不可能讓開發(fā)人肉去填寫辛润,為此提供了一個(gè)可視化的工具

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 業(yè)務(wù)層自定義屬性支持

SDK 層面僅能針對(duì)系統(tǒng)常見的控件屬性提供設(shè)置和編輯功能膨处,如針對(duì) Viewbackgroundalpha砂竖,針對(duì) TextViewtext真椿、textColortextSize乎澄,針對(duì) ImageView 等的 src 屬性等突硝。而各個(gè)業(yè)務(wù) app 都會(huì)集成相關(guān)的第三方組件或自定義控件,SDK 預(yù)置的屬性永遠(yuǎn)可能不滿足業(yè)務(wù)方的全部需求置济。為此就必須支持業(yè)務(wù)方自定義設(shè)置屬性和編輯屬性解恰。

5.4.1 設(shè)置屬性自定義

ABTest UI 屬性配置數(shù)據(jù)下發(fā)锋八,json 數(shù)據(jù)如何分配到各個(gè)設(shè)置類上,這里通過 IPropSetter 的實(shí)現(xiàn)類實(shí)現(xiàn)修噪。為支持自定義的屬性查库,業(yè)務(wù)開發(fā)實(shí)現(xiàn) IPropSetter 的自定義類。

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

通過 IPropSetter.apply 方法設(shè)置對(duì)應(yīng)屬性

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) 設(shè)置屬性

另外提供了注解 UIPropSetterAnno整慎,支持編譯期將業(yè)務(wù)層自定義 IPropSetter 實(shí)現(xiàn)類加入 sUIPropFactory.

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

5.4.2 編輯屬性自定義

為支持可視化生成 json 數(shù)據(jù)脏款,需要編輯 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) {

    }
    ...
}

為將業(yè)務(wù)層自定義的編輯控件加入目標(biāo)編輯 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

  • 自定義設(shè)置屬性類

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

    編輯屬性類僅在開發(fā)生成配置 json 數(shù)據(jù)時(shí)使用,并不會(huì)上線淤袜,所以代碼中的一些反射代碼痒谴,并無影響

  • 程序演示

    view_prop_edit_demo_fresco.gif

6 UI 重排版

大部分修改 UI 屬性用作 ABTest,業(yè)務(wù)場(chǎng)景相對(duì)有限铡羡,更多的是积蔚,需要做 UI 局部重新布局

shoppingcart_abtest.jpg

圖 6-1 嚴(yán)選購(gòu)物車頁面,協(xié)助分析不同 UI 樣式下烦周,用戶湊單的形式
去湊單 文本的消失也認(rèn)為是排版的一種尽爆,如 width 為 0

goodsdetail_abtest.jpeg

圖 6-2 嚴(yán)選詳情圖。A:強(qiáng)化加購(gòu)读慎;B:強(qiáng)化立即購(gòu)買

針對(duì)上述場(chǎng)景漱贱,純 UI 排版的情況,并無新控件的出現(xiàn)夭委,為此期望能有一套方案能支持線上動(dòng)態(tài)重排版幅狮。而為了實(shí)現(xiàn)重排版,我們需要解決一下幾點(diǎn)問題:

  1. 如何查找目標(biāo)組件

    可以通過前面的 XPath 邏輯查找

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

    Android 已有布局闰靴,如 FrameLayout彪笼、LinearLayoutRelativeLayout蚂且、GridLayout 等會(huì)對(duì)控件進(jìn)行布局配猫,而布局的發(fā)生過程在各個(gè) View 的 onMeasureonLayout。由于是線上邏輯杏死,我們更不可能通過繼承重寫的方式放置原有 onMeasureonLayout 的方法邏輯執(zhí)行泵肄。

    另外考慮能否清除屬性的方式捆交,也無法完全避免 Android 已有的布局干擾:

    • FrameLayout:若清除父控件 gravity 屬性,清除子控件 layout_gravity腐巢,可以認(rèn)為已經(jīng)滿足條件
    • RelativeLayout:子控件按照屬性進(jìn)行布局品追,若子控件布局屬性全部清空,則和 FrameLayout 一致
    • LinearLayout:父控件 orientation 屬性無法避免
    • GridLayout:父控件 orientation冯丙、rowCount肉瓦、columnCount 等屬性無法避免
  3. 如何對(duì)布局進(jìn)行重排版

    參考 WeexReactiveNative胃惜、LuaView 使用 Facebook 開源的 CSSLayout 布局泞莉,這里也直接使用 CSSLayout。而 CSSLayout 如何應(yīng)用到線上已有的一個(gè) ViewGroup?

  4. 如何保持 ViewID 不變

    重布局之后船殉,控件屬性動(dòng)態(tài)設(shè)置還需要生效

  5. 如何恢復(fù)布局

    常見的如鲫趁,編輯界面編輯的時(shí)候,取消當(dāng)前操作利虫,需要恢復(fù)布局

這里針對(duì) 2 和 3 的疑點(diǎn)挨厚,可以暫時(shí)清除 gravitylayout_gravity 等屬性糠惫,而 orientationRelativeLayout 特有的布局屬性可以不用關(guān)心疫剃。
通過在父控件和子控件中間插入一個(gè)透明的 StubCSSLayout,來實(shí)現(xiàn)目的寞钥。

subcsslayout.jpg

圖 6-3 SubCSSLayout 插入

中間層 StubCSSLayout 的作用:

  1. 隔離父控件和子控件慌申,既能解除父控件對(duì)子控件的排版功能
  2. 利用 StubCSSLayout 對(duì)子控件進(jìn)行 CSSLayout 排版
  3. 過濾 StubCSSLayout,并未真正破壞 ViewTree 結(jié)構(gòu)理郑,XPath 計(jì)算并不受影響蹄溉,為此子節(jié)點(diǎn)的屬性動(dòng)態(tài)設(shè)置仍能生效

演示示例:

<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 配置數(shù)據(jù)嵌套多層即可

7 控件布局動(dòng)態(tài)替換

考慮到特殊情況赚爵,就是需要重新替換布局棉胀,并且有創(chuàng)建新控件的場(chǎng)景,而這種情況冀膝,上面的重排版就無法實(shí)現(xiàn)了唁奢。考慮實(shí)現(xiàn)方案:

  1. 類似 LuaView窝剖、Weex麻掸、RN 下發(fā)腳本,動(dòng)態(tài)解析赐纱,自行創(chuàng)建 View

    可以自行實(shí)現(xiàn)脊奋,但太重了熬北,實(shí)現(xiàn)了一整套腳本控制控件創(chuàng)建和布局,幾乎可以理解為實(shí)現(xiàn)了一個(gè)動(dòng)態(tài)化方案诚隙,同時(shí)如何保持主題等細(xì)節(jié)問題處理起來會(huì)比較繁瑣讶隐。
    另外,可以考慮直接接入上述的動(dòng)態(tài)化方案久又,動(dòng)態(tài)構(gòu)建腳本容器進(jìn)行替換巫延,但考慮到,如果是過于復(fù)雜的場(chǎng)景地消,可以考慮發(fā)版本提供 ABTest烈评,過重的方案本身已經(jīng)不合適。

  2. 參考資源熱更新的方案犯建,同前面的觀點(diǎn),熱更新應(yīng)該僅用于線上嚴(yán)重崩潰問題瓜客,過于復(fù)雜的技術(shù)方案這里不考慮

    熱更新方案容易引起其他不可知問題适瓦,參考作者當(dāng)時(shí)使用 1.7.3 版本 Tinker 方案,嚴(yán)選線上發(fā)布后導(dǎo)致 WebView 獲取資源失斊滓恰玻熙;
    補(bǔ)丁加載成功后WebView獲取資源失敗android.content.res.Resources$NotFoundException: Resource ID #0x0

  3. 如果是復(fù)用 Android 的 xml 布局,那么如何使用生成疯攒、如何解析使用嗦随、是否有限制是需要考慮的問題

7.1 layout id 到 View 關(guān)鍵流程解析

解壓 apk,可以看到里面的資源相關(guān)文件:

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

其中布局文件 activity_suit.xml 等都是二進(jìn)制格式的 XML 文件敬尺。為何我們開發(fā)時(shí)編輯的是 XML 文件需要編譯成二進(jìn)制格式的原因是:

  1. 二進(jìn)制的 XML 元素的標(biāo)簽枚尼、屬性名稱、屬性值和內(nèi)容字符串會(huì)被統(tǒng)一收集到字符串資源池中(resources.arsc)砂吞,XML 二進(jìn)制文件只需持有資源索引的整數(shù)值署恍,因此二進(jìn)制 XML 文件大小更小
  2. 二進(jìn)制 XML 文件的元素解析,避免了字符串解析蜻直,進(jìn)而解析效率更高盯质。

跟蹤布局解析源碼:

setContentView.jpg

其中關(guān)鍵節(jié)點(diǎn):

  1. AssetManager.loadResourceValue 中根據(jù)資源 R.layout.activity_main 獲取 TypedView,其中 value.string 為 res/layout/activity_main.xml

  2. AssetManager.openXmlAssetNative 根據(jù) res/layout/activity_main.xml 獲取 long 類型的 xmlBlock

    xmlBlock 其實(shí)是 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 會(huì)調(diào)用 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);
            }
        }
        
        ···
    }
    

    可以看到概而,其實(shí)是根據(jù) res/layout/activity_main.xml 從 source apk 中讀取 xml 文件數(shù)據(jù)呼巷,最后通過 block->setTo(...) 拷貝了一份數(shù)據(jù),用于生成對(duì)象 ResXMLTree.

  3. AssetManager.openXmlBlockAsset 中根據(jù) XmlBlock(AssetManager assets, long xmlBlock) 構(gòu)建 XmlBlock赎瑰,最后通過 XmlBlock.newParser() 生成 XmlResourceParser

  4. 最后使用 XmlResourceParser 作為參數(shù)王悍,用于構(gòu)建 View

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

    具體里面如何解析 xml 標(biāo)簽如何使用這里不做解析,因?yàn)橐呀?jīng)能通過 public 方法能構(gòu)建 View 了

7.2 自定義布局實(shí)現(xiàn)

觀察 XmlBlock 的構(gòu)造函數(shù)乡范,可以發(fā)現(xiàn)傳入字節(jié)流 data 生成 mNative 和 7.1 的流程一樣配名,都是生成 ResXMLTree*啤咽。為此我們可以考慮下發(fā)新編譯的二進(jìn)制布局 xml 下發(fā),并解析得到 View渠脉。

這里下發(fā)的是 二進(jìn)制布局 xml 內(nèi)容的 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);
}

為方便根據(jù)文本布局 XML 文件得到二進(jìn)制 XML 文件內(nèi)容的 base64宇整,這里開發(fā)的相關(guān) 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>

生成的二進(jìn)制布局 XML 文件 base64 數(shù)據(jù)

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 已經(jīng)演示了使用動(dòng)態(tài)下發(fā)二進(jìn)制布局文件 base64 來顯示動(dòng)態(tài)布局的方案鳞青,看起來很方便很好用,然而其中的局限性也需要了解下:

  1. 因?yàn)檫@里需要通過反射獲取 XmlBlock 實(shí)例为朋,為此可能在個(gè)別版本或者特殊機(jī)型獲取失敗臂拓,為此需要事先知道這項(xiàng)功能是否可行
  2. 二進(jìn)制布局文件里面的標(biāo)簽字符串通過 int 索引從資源池中查找。其中標(biāo)簽分為 2 類习寸,一類為系統(tǒng)標(biāo)簽胶惰,另一類為 app 工程中自定義的資源,系統(tǒng)資源索引可以認(rèn)為是不變的霞溪,而自定義資源則每次編譯可能發(fā)生變化孵滞,為此我們下發(fā)的布局文件,不能引用新定義的資源 id鸯匹,也不能引用 app 工程中已經(jīng)定義好的資源坊饶。為此布局文件中的資源,如顏色殴蓬、文本匿级、尺寸等都必須直接寫死,不能使用資源引用染厅。

8 總結(jié)和不足

以上 Android 端 ABTest 框架總結(jié)如下:

  1. 通過 ABTest 類和協(xié)議一一對(duì)應(yīng)的原則痘绎,理清協(xié)議和開發(fā)邏輯;
  2. 通過注解的方式自動(dòng)選擇初始化方法糟秘,規(guī)避了傳統(tǒng) if/else 代碼在業(yè)務(wù)層的侵入简逮;
  3. 通過動(dòng)靜分離計(jì)算 XPath,進(jìn)一步保證了頁面變化情況下 XPath 的唯一性和一致性尿赚;
  4. 通過 UI 配置數(shù)據(jù)下發(fā)散庶,動(dòng)態(tài)修改線上 UI 屬性黍衙;
  5. 提供模擬器編輯工具旧蛾,可視化方式生成 UI 配置數(shù)據(jù),保證了數(shù)據(jù)的準(zhǔn)確性又固,支持 Activity冰寻、Dialog须教、PopupWindow;
  6. 提供基類和注解,業(yè)務(wù) app 能自定義實(shí)現(xiàn)自定義控件的特殊 UI 屬性設(shè)置和對(duì)應(yīng)的可視化編輯器轻腺;
  7. 通過使用 CSSLayout 語法的配置數(shù)據(jù)乐疆,實(shí)現(xiàn)線上 UI 的動(dòng)態(tài)重布局;
  8. 通過下發(fā)自定義的二進(jìn)制布局 XML base64數(shù)據(jù)贬养,實(shí)現(xiàn)線上布局動(dòng)態(tài)替換挤土。

以上動(dòng)態(tài)方案,對(duì)線上 ABTest 的及時(shí)分析與數(shù)據(jù)收集误算,提供了幫助仰美。

除此,本方案也有以下不足之處儿礼,可以通過初始化預(yù)知簡(jiǎn)單屏蔽掉處理為默認(rèn)情況(如默認(rèn)為 A )

  1. 動(dòng)態(tài)修改編輯咖杂,由于是 Android app 中直接編輯,操作方便性比起前端界面要差蚊夫;
  2. 下發(fā)自定義的二進(jìn)制布局 xml Base64數(shù)據(jù)诉字,實(shí)現(xiàn)創(chuàng)造新布局,有一定局限性知纷,不支持引用 app 資源或者新資源奏窑;
  3. Window LayoutInflator 替換可能存在失敗的風(fēng)險(xiǎn),部分廠家 rom 會(huì)自定義 PhoneWindow 類屈扎。這些可以在以后的版本中進(jìn)行優(yōu)化。
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末撩匕,一起剝皮案震驚了整個(gè)濱河市鹰晨,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌止毕,老刑警劉巖模蜡,帶你破解...
    沈念sama閱讀 217,185評(píng)論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異扁凛,居然都是意外死亡忍疾,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,652評(píng)論 3 393
  • 文/潘曉璐 我一進(jìn)店門谨朝,熙熙樓的掌柜王于貴愁眉苦臉地迎上來卤妒,“玉大人,你說我怎么就攤上這事字币≡蚺” “怎么了?”我有些...
    開封第一講書人閱讀 163,524評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵洗出,是天一觀的道長(zhǎng)士复。 經(jīng)常有香客問我,道長(zhǎng)翩活,這世上最難降的妖魔是什么阱洪? 我笑而不...
    開封第一講書人閱讀 58,339評(píng)論 1 293
  • 正文 為了忘掉前任便贵,我火速辦了婚禮,結(jié)果婚禮上冗荸,老公的妹妹穿的比我還像新娘承璃。我一直安慰自己,他們只是感情好俏竞,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,387評(píng)論 6 391
  • 文/花漫 我一把揭開白布绸硕。 她就那樣靜靜地躺著,像睡著了一般魂毁。 火紅的嫁衣襯著肌膚如雪玻佩。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,287評(píng)論 1 301
  • 那天席楚,我揣著相機(jī)與錄音咬崔,去河邊找鬼。 笑死烦秩,一個(gè)胖子當(dāng)著我的面吹牛垮斯,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播只祠,決...
    沈念sama閱讀 40,130評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼兜蠕,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了抛寝?” 一聲冷哼從身側(cè)響起熊杨,我...
    開封第一講書人閱讀 38,985評(píng)論 0 275
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎盗舰,沒想到半個(gè)月后晶府,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,420評(píng)論 1 313
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡钻趋,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,617評(píng)論 3 334
  • 正文 我和宋清朗相戀三年川陆,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片蛮位。...
    茶點(diǎn)故事閱讀 39,779評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡较沪,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出失仁,到底是詐尸還是另有隱情购对,我是刑警寧澤,帶...
    沈念sama閱讀 35,477評(píng)論 5 345
  • 正文 年R本政府宣布陶因,位于F島的核電站骡苞,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜解幽,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,088評(píng)論 3 328
  • 文/蒙蒙 一贴见、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧躲株,春花似錦片部、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,716評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至望浩,卻和暖如春辖所,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背磨德。 一陣腳步聲響...
    開封第一講書人閱讀 32,857評(píng)論 1 269
  • 我被黑心中介騙來泰國(guó)打工缘回, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人典挑。 一個(gè)月前我還...
    沈念sama閱讀 47,876評(píng)論 2 370
  • 正文 我出身青樓酥宴,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親您觉。 傳聞我的和親對(duì)象是個(gè)殘疾皇子拙寡,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,700評(píng)論 2 354

推薦閱讀更多精彩內(nèi)容

  • 【Android 自定義View】 [TOC] 自定義View基礎(chǔ) 接觸到一個(gè)類,你不太了解他琳水,如果貿(mào)然翻閱源碼只...
    Rtia閱讀 3,948評(píng)論 1 14
  • 發(fā)現(xiàn) 關(guān)注 消息 iOS 第三方庫(kù)倒庵、插件、知名博客總結(jié) 作者大灰狼的小綿羊哥哥關(guān)注 2017.06.26 09:4...
    肇東周閱讀 12,098評(píng)論 4 62
  • 近期炫刷,令很多影迷興奮的是,主演過《神探夏洛克》郁妈、《奇異博士》浑玛、《復(fù)仇者聯(lián)盟3》、《梅爾羅斯》等大熱電影和英劇的“卷...
    小播讀書閱讀 2,687評(píng)論 0 6
  • 說起過年,每個(gè)地方的習(xí)俗都不太一樣胃碾,今天就想說說除夕之夜到大年初一的凌晨涨享。 除夕之夜(大年三十),大家都會(huì)熬夜...
    努力努力再努力Xu閱讀 222評(píng)論 0 0
  • 記得幾年前表弟說過我一句話仆百,靜雅姐是工作狂厕隧。我當(dāng)時(shí)對(duì)這句話不置可否,我打心里不認(rèn)。但加入雙百以來總會(huì)想到這句話吁讨,雖...
    aya1212閱讀 613評(píng)論 0 0