Android ABTest 設(shè)計與原理

0 概念

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

摘自百度百科

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

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

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

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

1.1 AppAdhoc

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

  1. 數(shù)據(jù)使用上,還是需要業(yè)務(wù)層寫大量的 if/else 邏輯
  2. 相同的 ABTest 實例,在不同的頁面,容易出現(xiàn)重復(fù)代碼
  3. 后期維護容易出錯,如部分測試實例需要廢棄,需要工程中找出多處邏輯并修改
  4. 不支持普通 ui 屬性修改和布局修改
// 'model01' 對應(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("實驗版本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. 不支持動態(tài)重布局

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

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

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

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

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

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

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

  3. 用戶更新頻繁

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

除了 H5、動態(tài)化和插件化等方案,也有如 Tangram 這種半動態(tài)化方案,將 RecycleView 的每個 ViewHolder 看成卡片,通過動態(tài)下發(fā) json 數(shù)據(jù)或自定義格式的 xml 來動態(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 布局,但需要編碼注冊好
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. 能支持的卡片類型初始化的時候預(yù)置

2 A/B Test 考慮和框架目標

針對 H5、動態(tài)化框架,不能因為 A/B 測試將大部分 Native 頁面改成腳本頁面;同理,app 也不能因為 A/B 而集成插件化,為此個人認為完全動態(tài)的線上 A/B 能力并不現(xiàn)實

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

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

shoppingcart_abtest.jpg

圖 2-1 嚴選第一個版本的 ABTest 實例,協(xié)助分析不同 UI 樣式下,用戶湊單的形式

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

2.1 框架目標

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

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

  2. 提供多種生效策略,支持立即生效、熱啟動生效和冷啟動生效

  3. 針對業(yè)務(wù)邏輯 A/B 測試,提供實例編寫規(guī)范,避免業(yè)務(wù)層 if/else 邏輯

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

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

  5. 針對普通 UI 屬性,支持線上動態(tài)實驗

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

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

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

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

約定 ABTest 實例的 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 的測試數(shù)據(jù);
itemId 指定具體是哪個 ABTest,caseId 指定 A or B

可以理解相同的 ABTest case,如果在程序邏輯中有多處,那么這些代碼應(yīng)該都是一致的,同時業(yè)務(wù)層不應(yīng)該關(guān)心當前是否有對應(yīng) ABTest 的 json 數(shù)據(jù)(如果沒有走 A/B/n 的默認邏輯,這里假設(shè) "000" 為默認邏輯)?;诖?,對應(yīng)每個 ABTest case 都封裝了對應(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ù)請求更新,主動回調(diào) onUpdateConfig 方法
    • ABTestUpdateType.HOT_UPDATE:json 數(shù)據(jù)請求更新后,重新創(chuàng)建 ABTester 生效
    • ABTestUpdateType.COLD_UPDATE:json 數(shù)據(jù)請求更新,需等到下次 app 啟動生效
  • 注解 ABTestInitMethodAnnotation 指定了對應(yīng)測試 case 觸發(fā)時,會被執(zhí)行初始化的代碼

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

查看 ABTest 實例的 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 運行結(jié)果,結(jié)果顯示由 defaultInit 指定的 caseId 000,執(zhí)行初始化方法 initA,顯示 hanmeimei

3.2 實現(xiàn)原理

上述邏輯封裝較為簡單,具體邏輯如下:

  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 實例創(chuàng)建的時候,在構(gòu)造函數(shù)中會根據(jù)注解的值去查詢配置數(shù)據(jù),查詢并設(shè)置初始化方法和有效的 ABTest 數(shù)據(jù)實例。

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

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

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

    @ABTesterAnno(itemId = "SimpleTest_001")
    

public class OneABTester extends BaseABTester {

    ...

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

而通過編碼規(guī)范要求各個 ABTest 實例的構(gòu)造函數(shù)最后寫 `initAB()`,個人感覺比較機械,而且容易被業(yè)務(wù)開發(fā)遺漏。這里通過 `aspectJ` 在業(yè)務(wù)層的全部的 ABTest 實例子類的構(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 實例的編碼使用和原理,對于上層業(yè)務(wù)層完成以下目的:

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

4 如何定位控件 - ViewID

在講述如何線上動態(tài)修改控件屬性,修改替換 UI 布局等之前,首先需要處理的是如何定位目標控件。為此,需要為界面上的每一個控件分配一個唯一的 ViewID。這里同埋點方案的 ViewId 概念基本一致,需要具備唯一性和一致性,但也有差異。埋點方案中需要準確區(qū)分每一個 View,比如 ListView,RecyclerView 的相同 type 的 item view,必須認為是不一樣的,甚至相同 item view 實例由于復(fù)用而導(dǎo)致的 position 不一致,ViewID 也必須要是不一致的。而這里的場景是為了 ABTest,如果列表中只有一個 item view 發(fā)生布局變化意義并不大。為此認為同一個 ListView 或 RecyclerView 中相同 type 的 item view 都是一致的,需要計算出相同的 ViewID。

在埋點方案中也有類似的 ViewID 概念,此 ViewID 需要具備唯一性和和一致性。唯一性是指每個 View 的 ViewID 都是唯一的,不會與其他的 View 的 ViewID 發(fā)生重復(fù)。一致性是指 APP 運行過程中,多次進入相同界面,或者界面發(fā)生變化,View 的 ViewID 都不會發(fā)生變化。

4.1 現(xiàn)有方案

首先排除 View.getId(),因為布局文件中未指定 id 和動態(tài)代碼 new 出來的 View 都是 NO_ID,而即便是布局文件中指定了 id 的 view,在不同版本編譯產(chǎn)生的 id 也可能不一致。

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

4.1.1 XPath

XPath 方法較為主流,如 mixpanel、百分點埋點網(wǎng)易樂得埋點、網(wǎng)易HubbleData?;驹硎歉鶕?jù)當前 view 到 rootView(android.R.id.content)的路徑,并結(jié)合當前界面的 Activity,F(xiàn)ragment,view tag,view id 等,最終生成一個字符串表示當前 View 的 ViewID。

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

簡單示例如下:

viewpath_layout.png

圖 4-1-1

針對以上布局,其 view tree 如下:

viewpath_viewtree.png

圖 4-1-2 view tree

若要計算第 4 層第 3 個節(jié)點的 TextView 的 ViewID,可以根據(jù)當前節(jié)點到根節(jié)點的路徑,結(jié)合當前 Activity、Fragment 等額外信息來表示。

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

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

viewpath_viewtree_opt_before.png

圖 4-1-3

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

viewpath_viewtree_opt_after.png

圖 4-1-4 此時如果 btn1 被移除了,后面的 TextView ViewID 并不會受影響。

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

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

4.1.2 利用 TouchTarget 計算 ViewID

該方案參考 得到Android團隊無埋點方案

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

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

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

根據(jù)當前目標,線上動態(tài)修改目標 View 的屬性,為此必須在 Activity 界面展示給用戶看之前就找到目標 View 并修改屬性,為此 TouchTarget 計算 ViewID 方案并不可行,不能等到用戶點擊才計算 ViewID。XPath 方案基本符合當前場景,但也存在部分不符合場景和缺陷的地方:

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

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

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

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

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

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

  3. 靜態(tài)布局節(jié)點:靜態(tài)布局的子節(jié)點,不含根節(jié)點(根節(jié)點最終要動態(tài)加入 android.R.id.content 或其他布局)

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

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

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

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

    • 根節(jié)點會有標記,標識當前節(jié)點是根節(jié)點;
    • 全部局部節(jié)點都有標記是哪個 layout 布局的節(jié)點;
    • 葉子節(jié)點或子樹被動態(tài)移除,被移除的全部節(jié)點 layout 布局的標記需要清除,之后若加入場景樹,全部節(jié)點都認為是動態(tài)布局;
    • 子節(jié)點 index 根據(jù)在父節(jié)點的位置決定,不用按照相同類型的節(jié)點來算節(jié)點

繼續(xù)針對 圖 4-1-2,我們刪除橘紅色節(jié)點 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 靜態(tài)布局和動態(tài)布局區(qū)分后的 ViewTree;
黑色節(jié)點為動態(tài)布局節(jié)點,紅色節(jié)點為靜態(tài)布局節(jié)點

按照優(yōu)化后的 XPath 計算,我們把靜態(tài)布局和動態(tài)布局做了區(qū)分,白色是根節(jié)點,藍黑色的全部節(jié)點是由 activity_third.xml 生成,亮藍色的全部節(jié)點由 view_third_insert.xml 生成,綠色節(jié)點由 java 代碼動態(tài)生成。此時我們可以發(fā)現(xiàn)第 4 層的第 5 個節(jié)點(index 為 1 的 TextView)的 XPath 計算并不受影響,索引依然為 3,根據(jù)它最初在靜態(tài)布局中的索引,而不是因為前面動態(tài)加入的綠色 TextView 節(jié)點計算得到。動態(tài)加入的綠色節(jié)點,不管是在下一個 TextView 的前面還是后面,它的 index 均為 0,隔離了靜態(tài)布局和動態(tài)布局之間的相互影響

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

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

    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é)點的局部靜態(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é)點所在的動態(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é)點 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 亮藍色節(jié)點)

    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 計算

以上動靜 XPath 分離的方案,關(guān)鍵是如何計算局部靜態(tài) XPath。我們必須在布局 xml inflate 后就針對當前局部布局計算并保存。查看我們的 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(...) 里面會調(diào)用 ActivityLifecycleCallbacks.onActivityCreated(...),而 setContentView(...) 里面會調(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 方法中計算局部靜態(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 等特殊控件處理

針對 ListView,RecyclerView 等控件,期望同一個配置能使相同 type 的 ItemView 都生效,為此相同 type 的 ItemView 的 ViewID 都要一致。為此,這里不能使用 position 作為 XPath 中的一個變量,而是應(yīng)該使用 type

viewpath_listview.jpg

圖 4-2-2 ListView 測試界面。白底 ItemView type 為 0,灰底 ItemView type 為 1。

因為 RecyclerView、SpinnerListView 計算 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 結(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é)點的 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 每次滑動的時候,更新復(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 測試界面

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

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

  1. 如何定位控件?

    參考前面 4 講述的 ViewID 計算

  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 指定線上的目標控件(這里不需要指定控件類型,因為同一個 viewID 不可能指向多個不同的 view)。uiProps 指定具體的屬性數(shù)據(jù)。如 alpha 指定 View 的 alpha 屬性,floatValue 指定新的 alpha 值;textColor 指定 TextView 的文本顏色,intValue 指定顏色值為 #8A0000FFtextSize 指定 TextView 的字體大小,value 指定新的字體大小為 40.0px

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

目標控件必須在 UI 界面被用戶看到之前設(shè)置相關(guā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ù)據(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ā)人肉去填寫,為此提供了一個可視化的工具

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 層面僅能針對系統(tǒng)常見的控件屬性提供設(shè)置和編輯功能,如針對 Viewbackground、alpha,針對 TextViewtext、textColortextSize,針對 ImageView 等的 src 屬性等。而各個業(yè)務(wù) app 都會集成相關(guān)的第三方組件或自定義控件,SDK 預(yù)置的屬性永遠可能不滿足業(yè)務(wù)方的全部需求。為此就必須支持業(yè)務(wù)方自定義設(shè)置屬性和編輯屬性。

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

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

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

通過 IPropSetter.apply 方法設(shè)置對應(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 實現(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ù)層自定義的編輯控件加入目標編輯 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ù)時使用,并不會上線,所以代碼中的一些反射代碼,并無影響

  • 程序演示

    view_prop_edit_demo_fresco.gif

6 UI 重排版

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

shoppingcart_abtest.jpg

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

goodsdetail_abtest.jpeg

圖 6-2 嚴選詳情圖。A:強化加購;B:強化立即購買

針對上述場景,純 UI 排版的情況,并無新控件的出現(xiàn),為此期望能有一套方案能支持線上動態(tài)重排版。而為了實現(xiàn)重排版,我們需要解決一下幾點問題:

  1. 如何查找目標組件

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

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

    Android 已有布局,如 FrameLayout、LinearLayoutRelativeLayout、GridLayout 等會對控件進行布局,而布局的發(fā)生過程在各個 View 的 onMeasureonLayout。由于是線上邏輯,我們更不可能通過繼承重寫的方式放置原有 onMeasureonLayout 的方法邏輯執(zhí)行。

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

    • FrameLayout:若清除父控件 gravity 屬性,清除子控件 layout_gravity,可以認為已經(jīng)滿足條件
    • RelativeLayout:子控件按照屬性進行布局,若子控件布局屬性全部清空,則和 FrameLayout 一致
    • LinearLayout:父控件 orientation 屬性無法避免
    • GridLayout:父控件 orientation、rowCountcolumnCount 等屬性無法避免
  3. 如何對布局進行重排版

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

  4. 如何保持 ViewID 不變

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

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

    常見的如,編輯界面編輯的時候,取消當前操作,需要恢復(fù)布局

這里針對 2 和 3 的疑點,可以暫時清除 gravity、layout_gravity 等屬性,而 orientationRelativeLayout 特有的布局屬性可以不用關(guān)心。
通過在父控件和子控件中間插入一個透明的 StubCSSLayout,來實現(xiàn)目的。

subcsslayout.jpg

圖 6-3 SubCSSLayout 插入

中間層 StubCSSLayout 的作用:

  1. 隔離父控件和子控件,既能解除父控件對子控件的排版功能
  2. 利用 StubCSSLayout 對子控件進行 CSSLayout 排版
  3. 過濾 StubCSSLayout,并未真正破壞 ViewTree 結(jié)構(gòu),XPath 計算并不受影響,為此子節(jié)點的屬性動態(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 控件布局動態(tài)替換

考慮到特殊情況,就是需要重新替換布局,并且有創(chuàng)建新控件的場景,而這種情況,上面的重排版就無法實現(xiàn)了??紤]實現(xiàn)方案:

  1. 類似 LuaView、Weex、RN 下發(fā)腳本,動態(tài)解析,自行創(chuàng)建 View

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

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

    熱更新方案容易引起其他不可知問題,參考作者當時使用 1.7.3 版本 Tinker 方案,嚴選線上發(fā)布后導(dǎo)致 WebView 獲取資源失敗;
    補丁加載成功后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 等都是二進制格式的 XML 文件。為何我們開發(fā)時編輯的是 XML 文件需要編譯成二進制格式的原因是:

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

跟蹤布局解析源碼:

setContentView.jpg

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

  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 其實是 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 會調(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);
            }
        }
        
        ···
    }
    

    可以看到,其實是根據(jù) res/layout/activity_main.xml 從 source apk 中讀取 xml 文件數(shù)據(jù),最后通過 block->setTo(...) 拷貝了一份數(shù)據(jù),用于生成對象 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 標簽如何使用這里不做解析,因為已經(jīng)能通過 public 方法能構(gòu)建 View 了

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

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

這里下發(fā)的是 二進制布局 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 文件得到二進制 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>

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

  1. 因為這里需要通過反射獲取 XmlBlock 實例,為此可能在個別版本或者特殊機型獲取失敗,為此需要事先知道這項功能是否可行
  2. 二進制布局文件里面的標簽字符串通過 int 索引從資源池中查找。其中標簽分為 2 類,一類為系統(tǒng)標簽,另一類為 app 工程中自定義的資源,系統(tǒng)資源索引可以認為是不變的,而自定義資源則每次編譯可能發(fā)生變化,為此我們下發(fā)的布局文件,不能引用新定義的資源 id,也不能引用 app 工程中已經(jīng)定義好的資源。為此布局文件中的資源,如顏色、文本、尺寸等都必須直接寫死,不能使用資源引用。

8 總結(jié)和不足

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

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

以上動態(tài)方案,對線上 ABTest 的及時分析與數(shù)據(jù)收集,提供了幫助。

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

  1. 動態(tài)修改編輯,由于是 Android app 中直接編輯,操作方便性比起前端界面要差;
  2. 下發(fā)自定義的二進制布局 xml Base64數(shù)據(jù),實現(xiàn)創(chuàng)造新布局,有一定局限性,不支持引用 app 資源或者新資源;
  3. Window LayoutInflator 替換可能存在失敗的風險,部分廠家 rom 會自定義 PhoneWindow 類。這些可以在以后的版本中進行優(yōu)化。
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

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

友情鏈接更多精彩內(nèi)容