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è)明顯問題:
- 數(shù)據(jù)使用上倘要,還是需要業(yè)務(wù)層寫大量的
if/else
邏輯 - 相同的 ABTest 實(shí)例圾亏,在不同的頁面,容易出現(xiàn)重復(fù)代碼
- 后期維護(hù)容易出錯(cuò)封拧,如部分測(cè)試實(shí)例需要廢棄志鹃,需要工程中找出多處邏輯并修改
- 不支持普通 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 屬性修改泽西。
其前端編輯界面移植 mixpanel
代碼曹铃,前端編輯操作較為方便,但也有局限如下:
- 不支持自定義控件尝苇,甚至較為常用的第三方庫(kù)铛只,如
Fresco
等無法識(shí)別 - 前端界面無法處理
Dialog
和PopupWindow
- 不支持動(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)也十分明顯:
-
針對(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ù)揭秘 -
現(xiàn)有 app 支持插件化且支持動(dòng)態(tài)下發(fā)比較少岗宣,而為了
A/B
測(cè)試集成插件化就很難想象了相比更多 app 支持了業(yè)務(wù)模塊化蚂会,但模塊化并不支持動(dòng)態(tài)加載
-
用戶更新頻繁
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);
...
查看使用浪规,從 ABTest
角度也可以發(fā)現(xiàn) Tangram
也有較大的局限性:
- 綁定僅支持
RecyclerView
- 需要事先在代碼中編寫如上的
Tangram
初始化代碼 - 能支持的卡片類型初始化的時(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袖订,等線上啟用。
圖 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ā)透明
支持同步后臺(tái) A/B 測(cè)試 json 數(shù)據(jù)
提供多種生效策略塘娶,支持立即生效、熱啟動(dòng)生效和冷啟動(dòng)生效
-
針對(duì)業(yè)務(wù)邏輯 A/B 測(cè)試痊夭,提供實(shí)例編寫規(guī)范刁岸,避免業(yè)務(wù)層
if/else
邏輯業(yè)務(wù)層邏輯并不需要自己現(xiàn)在執(zhí)行的是 A 還是 B
方便 AB 測(cè)試實(shí)例的統(tǒng)一管理和后期維護(hù)
針對(duì)普通 UI 屬性,支持線上動(dòng)態(tài)實(shí)驗(yàn)
-
提供一定能力的動(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_001
和SimpleTest_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
- 若對(duì)應(yīng)
查看 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());
}
圖 3-1 根據(jù)
SimpleTest_001
指定的 caseId001
捂齐,執(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());
圖 3-2 運(yùn)行結(jié)果,結(jié)果顯示由
defaultInit
指定的 caseId000
瞻想,執(zhí)行初始化方法 initA压真,顯示 hanmeimei
3.2 實(shí)現(xiàn)原理
上述邏輯封裝較為簡(jiǎn)單,具體邏輯如下:
-
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 }
-
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) } ... }
-
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ù)層完成以下目的:
- 使用注解標(biāo)記 ABTest 的 itemId 和 caseId臂痕,代碼邏輯更加清晰
- 支持立即更新伯襟、熱啟動(dòng)更新、冷啟動(dòng)更新
- 隱藏了 ABTest 的原始數(shù)據(jù)解析和使用
- 避免了業(yè)務(wù)開發(fā)使用
if/else
執(zhí)行對(duì)應(yīng)的A/B/n
邏輯流程握童, - 將全部和 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ù)方案有 XPath
和 TouchTarget
荠藤。
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)單示例如下:
圖 4-1-1
針對(duì)以上布局渐行,其 view tree 如下:
圖 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 的唯一性也無法保證了
圖 4-1-3
若相同層級(jí)根據(jù)同類型 view 之間的 index 標(biāo)記吟孙,則可以避免這種情況:
圖 4-1-4 此時(shí)如果
btn1
被移除了澜倦,后面的 TextView ViewID 并不會(huì)受影響。
其他如何計(jì)算 ViewPager杰妓、ListView藻治、RecyclerView 里的 ItemView 的 ViewID,以及 Fragment 中的控件 ViewID 等巷挥,如何保證一致性和唯一性的優(yōu)化方案桩卵,參考以下文章,這里不在重復(fù)描述
- SDK無埋點(diǎn)技術(shù)在百分點(diǎn)的探索和實(shí)踐
- Android無埋點(diǎn)數(shù)據(jù)收集SDK關(guān)鍵技術(shù)解析
- 網(wǎng)易HubbleData之Android無埋點(diǎn)實(shí)踐
4.1.2 利用 TouchTarget 計(jì)算 ViewID
由于無埋點(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)景和缺陷的地方:
- ViewTree 動(dòng)態(tài)變化的場(chǎng)景適應(yīng)力有限
- 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)做分類危纫。這里引入新的概念:
靜態(tài)布局:利用 layout xml 生成的 ViewTree
動(dòng)態(tài)布局:利用 java 代碼生成的 ViewTree,或者在已有 ViewTree 上進(jìn)行刪除乌庶、插入操作
靜態(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 或其他布局)
-
動(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)的索引
全局
XPath
:當(dāng)前節(jié)點(diǎn)在整個(gè)頁面布局 ViewTree 上的XPath
值螃征,經(jīng)過sha256
加密就是最終的 ViewID 值-
局部靜態(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>
圖 4-2-1 新的布局
圖 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é)果:
-
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"}]
-
綠色節(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
-
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
静稻。
圖 4-2-2
ListView
測(cè)試界面警没。白底 ItemView type 為 0,灰底 ItemView type 為 1振湾。因?yàn)?
RecyclerView
杀迹、Spinner
和ListView
計(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;
}
圖 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)修改帅容,我們需要解決一下問題:
-
如何定位控件?
參考前面 4 講述的 ViewID 計(jì)算
如何定義下發(fā)的配置數(shù)據(jù)伍伤?
如何將配置數(shù)據(jù)應(yīng)用到控件上并徘?
如何生成 ABTest 配置數(shù)據(jù),如何檢查效果嚷缭?
如何處理業(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)用:
ActivityLifecycleCallbacks.onActivityCreated(Activity activity, Bundle savedInstanceState)
LayoutInflater.inflate(@LayoutRes int resource, @Nullable ViewGroup root)
-
onViewAttachedToWindow(View v)
未添加至 Activity 的控件可以做監(jiān)聽設(shè)置,在
onViewAttachedToWindow
中觸發(fā)
根據(jù) 4.1 的配置數(shù)據(jù)佣渴,界面生效前后如下所示:
圖 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"
}
圖 5-2-2
5.3 配置數(shù)據(jù)生成
查看 代碼 5-2-1 的配置信息,不可能讓開發(fā)人肉去填寫辛润,為此提供了一個(gè)可視化的工具
[
{
"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ì) View
的 background
、alpha
砂竖,針對(duì) TextView
的 text
真椿、textColor
、textSize
乎澄,針對(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 自定義屬性支持示例
以 SimpleDraweeDrawee
的 setImageURI
為例剃盾,定義屬性名為 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 局部重新布局
圖 6-1 嚴(yán)選購(gòu)物車頁面,協(xié)助分析不同 UI 樣式下烦周,用戶湊單的形式
去湊單
文本的消失也認(rèn)為是排版的一種尽爆,如 width 為 0
圖 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)問題:
-
如何查找目標(biāo)組件
可以通過前面的
XPath
邏輯查找 -
如何防止原有布局的排版
Android 已有布局闰靴,如
FrameLayout
彪笼、LinearLayout
、RelativeLayout
蚂且、GridLayout
等會(huì)對(duì)控件進(jìn)行布局配猫,而布局的發(fā)生過程在各個(gè) View 的onMeasure
和onLayout
。由于是線上邏輯杏死,我們更不可能通過繼承重寫的方式放置原有onMeasure
和onLayout
的方法邏輯執(zhí)行泵肄。另外考慮能否清除屬性的方式捆交,也無法完全避免 Android 已有的布局干擾:
-
FrameLayout
:若清除父控件gravity
屬性,清除子控件layout_gravity
腐巢,可以認(rèn)為已經(jīng)滿足條件 -
RelativeLayout
:子控件按照屬性進(jìn)行布局品追,若子控件布局屬性全部清空,則和FrameLayout
一致 -
LinearLayout
:父控件orientation
屬性無法避免 -
GridLayout
:父控件orientation
冯丙、rowCount
肉瓦、columnCount
等屬性無法避免
-
-
如何對(duì)布局進(jìn)行重排版
參考
Weex
、ReactiveNative
胃惜、LuaView
使用 Facebook 開源的CSSLayout
布局泞莉,這里也直接使用CSSLayout
。而CSSLayout
如何應(yīng)用到線上已有的一個(gè)ViewGroup
? -
如何保持
ViewID
不變重布局之后船殉,控件屬性動(dòng)態(tài)設(shè)置還需要生效
-
如何恢復(fù)布局
常見的如鲫趁,編輯界面編輯的時(shí)候,取消當(dāng)前操作利虫,需要恢復(fù)布局
這里針對(duì) 2 和 3 的疑點(diǎn)挨厚,可以暫時(shí)清除 gravity
、layout_gravity
等屬性糠惫,而 orientation
和 RelativeLayout
特有的布局屬性可以不用關(guān)心疫剃。
通過在父控件和子控件中間插入一個(gè)透明的 StubCSSLayout
,來實(shí)現(xiàn)目的寞钥。
圖 6-3 SubCSSLayout 插入
中間層 StubCSSLayout
的作用:
- 隔離父控件和子控件慌申,既能解除父控件對(duì)子控件的排版功能
- 利用
StubCSSLayout
對(duì)子控件進(jìn)行CSSLayout
排版 - 過濾
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您炉,水平布局
圖 6-4 以一層布局作為示例柒爵,需要多層布局的,CSSLayout 配置數(shù)據(jù)嵌套多層即可
7 控件布局動(dòng)態(tài)替換
考慮到特殊情況赚爵,就是需要重新替換布局棉胀,并且有創(chuàng)建新控件的場(chǎng)景,而這種情況冀膝,上面的重排版就無法實(shí)現(xiàn)了唁奢。考慮實(shí)現(xiàn)方案:
-
類似 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)不合適。 -
參考資源熱更新的方案犯建,同前面的觀點(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 如果是復(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)制格式的原因是:
- 二進(jìn)制的 XML 元素的標(biāo)簽枚尼、屬性名稱、屬性值和內(nèi)容字符串會(huì)被統(tǒng)一收集到字符串資源池中(resources.arsc)砂吞,XML 二進(jìn)制文件只需持有資源索引的整數(shù)值署恍,因此二進(jìn)制 XML 文件大小更小
- 二進(jìn)制 XML 文件的元素解析,避免了字符串解析蜻直,進(jìn)而解析效率更高盯质。
跟蹤布局解析源碼:
其中關(guān)鍵節(jié)點(diǎn):
AssetManager.loadResourceValue
中根據(jù)資源R.layout.activity_main
獲取 TypedView,其中 value.string 為res/layout/activity_main.xml
-
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
. AssetManager.openXmlBlockAsset
中根據(jù)XmlBlock(AssetManager assets, long xmlBlock)
構(gòu)建XmlBlock
赎瑰,最后通過XmlBlock.newParser()
生成XmlResourceParser
-
最后使用
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
,方便編輯使用
選擇的 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 并替換芋膘,查看效果
7.3 自定義布局局限性
7.2 已經(jīng)演示了使用動(dòng)態(tài)下發(fā)二進(jìn)制布局文件 base64 來顯示動(dòng)態(tài)布局的方案鳞青,看起來很方便很好用,然而其中的局限性也需要了解下:
- 因?yàn)檫@里需要通過反射獲取
XmlBlock
實(shí)例为朋,為此可能在個(gè)別版本或者特殊機(jī)型獲取失敗臂拓,為此需要事先知道這項(xiàng)功能是否可行 - 二進(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é)如下:
- 通過 ABTest 類和協(xié)議一一對(duì)應(yīng)的原則痘绎,理清協(xié)議和開發(fā)邏輯;
- 通過注解的方式自動(dòng)選擇初始化方法糟秘,規(guī)避了傳統(tǒng) if/else 代碼在業(yè)務(wù)層的侵入简逮;
- 通過動(dòng)靜分離計(jì)算
XPath
,進(jìn)一步保證了頁面變化情況下XPath
的唯一性和一致性尿赚; - 通過 UI 配置數(shù)據(jù)下發(fā)散庶,動(dòng)態(tài)修改線上 UI 屬性黍衙;
- 提供模擬器編輯工具旧蛾,可視化方式生成 UI 配置數(shù)據(jù),保證了數(shù)據(jù)的準(zhǔn)確性又固,支持 Activity冰寻、Dialog须教、PopupWindow;
- 提供基類和注解,業(yè)務(wù) app 能自定義實(shí)現(xiàn)自定義控件的特殊 UI 屬性設(shè)置和對(duì)應(yīng)的可視化編輯器轻腺;
- 通過使用
CSSLayout
語法的配置數(shù)據(jù)乐疆,實(shí)現(xiàn)線上 UI 的動(dòng)態(tài)重布局; - 通過下發(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 )
- 動(dòng)態(tài)修改編輯咖杂,由于是 Android app 中直接編輯,操作方便性比起前端界面要差蚊夫;
- 下發(fā)自定義的二進(jìn)制布局 xml Base64數(shù)據(jù)诉字,實(shí)現(xiàn)創(chuàng)造新布局,有一定局限性知纷,不支持引用 app 資源或者新資源奏窑;
- Window LayoutInflator 替換可能存在失敗的風(fēng)險(xiǎn),部分廠家 rom 會(huì)自定義 PhoneWindow 類屈扎。這些可以在以后的版本中進(jìn)行優(yōu)化。