客戶端埋點(diǎn)是數(shù)據(jù)收集的最基本手段,但由于業(yè)務(wù)迭代速度很快,手動(dòng)埋點(diǎn)方案雖然靈活多變,但是極大的增加了客戶端開(kāi)發(fā)人員的工作量。開(kāi)發(fā)完成業(yè)務(wù)功能需要花費(fèi)很大的精力處理埋點(diǎn)事宜,而且隨著迭代版本,埋點(diǎn)的數(shù)量會(huì)越來(lái)越多,這些老舊埋點(diǎn)的維護(hù)工作也需要付出不小的努力。并且,手動(dòng)埋點(diǎn)的正確性同樣是個(gè)極度考驗(yàn)開(kāi)發(fā)人員的耐性和認(rèn)真程度的問(wèn)題,在所難免會(huì)出現(xiàn)這樣那樣的問(wèn)題。所以,如果能夠研發(fā)出一款不需要或者很少需要開(kāi)發(fā)人員介入就能實(shí)現(xiàn)根據(jù)不同業(yè)務(wù)場(chǎng)景埋點(diǎn)的功能sdk對(duì)于提高版本迭代速度和開(kāi)發(fā)人員的幸福感絕對(duì)是一件非常有價(jià)值的事情。
更大的價(jià)值還在于,不需要開(kāi)發(fā)人員介入,運(yùn)營(yíng)或者用研的同學(xué)就可以隨時(shí)動(dòng)態(tài)調(diào)整數(shù)據(jù)收集方案。
縱觀目前比較成熟的無(wú)埋點(diǎn)方案,存在著如下問(wèn)題:
問(wèn)題1:通過(guò)XPath定位控件,理論上可行,但實(shí)踐表明這個(gè)方案的復(fù)雜度非常高,尤其對(duì)于處理像GridView,ListView,RecyclerView的控件更是捉襟見(jiàn)肘。不僅如此,生成xpath的過(guò)程本身就是一個(gè)及其耗費(fèi)性能的行為,它需要遍歷view tree,存儲(chǔ)非常多的路徑信息到view上。
問(wèn)題2:獲取控件對(duì)應(yīng)的數(shù)據(jù)是通過(guò) data path的方式解決,每次添加新埋點(diǎn)時(shí),如果需要上報(bào)數(shù)據(jù),那用研人員需要和開(kāi)發(fā)人員逐一確認(rèn)控件數(shù)據(jù)的path,這極大的限制了客戶端開(kāi)發(fā)的自由度,即使簡(jiǎn)單的重構(gòu)也會(huì)使得之前配置的埋點(diǎn)信息失效。
針對(duì)如上問(wèn)題,我們經(jīng)過(guò)深挖內(nèi)在邏輯關(guān)系及對(duì)比優(yōu)劣,總結(jié)出了一套更靈活,更合理的無(wú)埋點(diǎn)方案,下面分三個(gè)部分逐一介紹實(shí)現(xiàn)考量及內(nèi)部機(jī)制。
一、定位與用戶產(chǎn)生交互行為的目標(biāo)控件
關(guān)于定位交互控件,我們也考慮過(guò)xpath的方案,但是考慮到其實(shí)現(xiàn)的復(fù)雜度,不靈活和各種潛在的問(wèn)題,我們拋棄了這種方案。通過(guò)反復(fù)的閱讀View的touch事件處理相關(guān)的源碼,我們終于發(fā)現(xiàn)了解決問(wèn)題的更好的方式。
ViewGroup中有一個(gè)TouchTarget 類(lèi)型的變量 mFirstTouchTarget,表示消費(fèi)當(dāng)前觸摸事件的控件列表。例如,點(diǎn)擊屏幕上一個(gè)按鈕,那么按鈕所在ViewGroup的mFirstTouchTarget 變量就指向這個(gè)按鈕。當(dāng)ViewGroup派發(fā)觸摸事件時(shí),他會(huì)首先判斷變量mFirstTouchTarget是否存在,如果變量存在,會(huì)循環(huán)遍歷TouchTarget鏈表元素,找到能處理該事件的View并將MotionEvent 派發(fā)給該View。如果不存在TouchTarget,ViewGroup 會(huì)循環(huán)遍歷所有child view,直到找到一個(gè)能處理該事件的View,并將該View作為first touch target 賦值給mFirstTouchTarget。
當(dāng)用戶觸發(fā)Down事件時(shí),會(huì)執(zhí)行如下邏輯,尋找消費(fèi)當(dāng)前事件的TouchTarget。
if (actionMasked == MotionEvent.ACTION_DOWN){
//如果是down事件,遍歷child,找到TouchTarget
..
..
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = getAndVerifyPreorderedIndexchildrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(preorderedList, children, childIndex);
..
..
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// child 消費(fèi)了觸摸事件
..
..
// 根據(jù)消費(fèi)了觸摸事件的View創(chuàng)建TouchTarget
newTouchTarget = addTouchTarget(child, idBitsToAssign);
..
..
break;
}
}
當(dāng)觸發(fā)Down事件并且找到TouchTarget,或者觸發(fā)非Down事件時(shí),執(zhí)行如下處理邏輯。
if (mFirstTouchTarget == null) {
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,TouchTarget.ALL_POINTER_IDS);
} else {
//Down事件發(fā)生時(shí)找到TouchTarget,或者非Down事件直接執(zhí)行如下邏輯
// 將事件派發(fā)給TouchTarget表示的View
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)|| intercepted;
if (dispatchTransformedTouchEvent(ev, cancelChild,target.child,target.pointerIdBits)) {
//指定TouchTarget對(duì)應(yīng)的View正確消費(fèi)了事件
handled = true;
}
..
..
}
..
..
}
}
提示:由于消費(fèi)觸摸事件的控件可能為多個(gè)(splitting touch events),所以需要遍歷TouchTarget鏈表。引用官方原文:
This behavior is enabled by default for applications that target an SDK version of 11 (Honeycomb) or newer. On earlier platform versions this feature was not supported and this method is a no-op.
MotionEvents may be split and dispatched to different child views depending on where each pointer initially went down. This allows for user interactions such as scrolling two panes of content independently, chording of buttons, and performing independent gestures on different pieces of content.
利用ViewGroup的這種事件處理機(jī)制,我們通過(guò)在Activity的window上調(diào)用window.setCallback() 接管窗口的事件派發(fā),并在dispatchTouchEvent處理函數(shù)中添加analyzeMotionEvent()方法。如果接收到up事件,執(zhí)行處理邏輯,通過(guò)ViewGroup TouchTarget鏈表,找到本次交互行為的目標(biāo)控件。拿到控件后,通過(guò) Activity的類(lèi)名+控件所在的layout文件名+控件id對(duì)應(yīng)的資源名,我們就可以確定目標(biāo)控件的唯一標(biāo)識(shí)。
dispatchTouchEvent源碼如下:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (!AutoPointer.isAutoPointEnable()) {
return super.dispatchTouchEvent(ev);
}
int actionMasked = ev.getActionMasked();
if (actionMasked != MotionEvent.ACTION_UP) {
return super.dispatchTouchEvent(ev);
}
long t = System.currentTimeMillis();
analyzeMotionEvent();
//非線上版本,打印執(zhí)行時(shí)間
if (!AutoPointer.isOnlineEnv()) {
long time = System.currentTimeMillis() - t;
DDLogger.d(TAG, String.format(Locale.CHINA, "處理時(shí)間:%d 毫秒", time));
}
return super.dispatchTouchEvent(ev);
}
analyzeMotionEvent源碼如下:
/**
* 分析用戶的點(diǎn)擊行為
*/
private void analyzeMotionEvent() {
if (mViewRef == null || mViewRef.get() == null) {
DDLogger.e(TAG, "window is null");
return;
}
ViewGroup decorView = (ViewGroup) mViewRef.get();
int content_id = android.R.id.content;
ViewGroup content = (ViewGroup) decorView.findViewById(content_id);
if (content == null) {
content = decorView; //對(duì)于非Activity DecorView 的情況處理
}
Pair<View, Object> targets = findActionTargets(content);
if (targets == null) {
DDLogger.e(TAG, "has no action targets!!!");
return;
}
//發(fā)送任務(wù)在單線程池中
int hashcode = targets.first.hashCode();
if (mIgnoreViews.contains(hashcode)) return;
PointerExecutor.getHandler().post(PointPostAction.create(targets.first, targets.second));
}
二、獲取與目標(biāo)控件對(duì)應(yīng)的業(yè)務(wù)數(shù)據(jù)
對(duì)于獲取控件數(shù)據(jù),為了最大化獲取速度,我們?cè)谙到y(tǒng)中配置了多個(gè)數(shù)據(jù)獲取策略。如果目標(biāo)控件是AbsListView或者RecyclerView 的child view及child view 的chid,那我們可以通過(guò)child view在adapter中的位置獲取到我們想要的數(shù)據(jù)。這種方式能夠處理大多數(shù)頁(yè)面控件數(shù)據(jù)的獲取問(wèn)題。系統(tǒng)配置策略的方式如下:
private static Map<String, DataStrategy> mStrategies = new HashMap<>();
static {
//configure RecyclerView and subclass's search strategy
DataStrategy recyclerViewStrategy = new RecyclerViewStrategy();
mStrategies.put("RecyclerView", recyclerViewStrategy);
mStrategies.put("DDCollectionView", recyclerViewStrategy);
//ExpandableListView
DataStrategy EListViewStrategy = new ExpandableListViewStrategy();
mStrategies.put("ExpandableListView", EListViewStrategy);
mStrategies.put("DDExpandableListView", EListViewStrategy);
DataStrategy adapterViewStrategy = new AdapterViewStrategy();
//ListView
mStrategies.put("ListView", adapterViewStrategy);
mStrategies.put("DDListView", adapterViewStrategy);
mStrategies.put("ListViewCompat", adapterViewStrategy);
//GridView
mStrategies.put("GridView", adapterViewStrategy);
mStrategies.put("DDGridView", adapterViewStrategy);
//ViewPager
DataStrategy viewPagerStrategy = new ViewPagerStrategy();
mStrategies.put("ViewPager", viewPagerStrategy);
//TabLayout
DataStrategy tabLayoutStrategy = new TabLayoutStrategy();
mStrategies.put("TabLayout", tabLayoutStrategy);
}
對(duì)于那些完全自定義布局繪制的頁(yè)面,例如個(gè)人中心等頁(yè)面,業(yè)務(wù)開(kāi)發(fā)人員需要通過(guò)框架api建立一個(gè)控件樹(shù)到數(shù)據(jù)的映射關(guān)系,這樣框架在需要獲取數(shù)據(jù)時(shí),通過(guò)這個(gè)關(guān)系就可以非常容易的獲取到想要的數(shù)據(jù)。
/**
* 配制自定義布局的數(shù)據(jù)綁定關(guān)系,自定義布局內(nèi)的任何
* 控件發(fā)生點(diǎn)擊行為時(shí),發(fā)送的埋點(diǎn)都會(huì)攜帶改數(shù)據(jù)
*
* @param id
* @param object
* @return
*/
@NonNull
@Override
public DataConfigureImp configLayoutData(@IdRes int id, @NonNull Object object) {
Preconditions.checkNotNull(object);
mDataLayout.put(id, object);
return this;
}
根據(jù)TouchTarget找到數(shù)據(jù)獲取策略或者數(shù)據(jù)映射關(guān)系,我們可以非常簡(jiǎn)單的獲取到綁定的數(shù)據(jù),獲取數(shù)據(jù)的算法如下:
if (strategyView != null) {
Object data = strategy.fetchTargetData(strategyView);
return Pair.create(touchTarget, data);
}
if (configDataView != null) {
return Pair.create(touchTarget, mDataLayout.get(configId));
}
//解決自定義布局的數(shù)據(jù)綁定問(wèn)題
if (dataAdapter != null) {
return Pair.create(touchTarget, dataAdapter.getData());
}
三、實(shí)現(xiàn)埋點(diǎn)的動(dòng)態(tài)可配置
在測(cè)試環(huán)境下,用研人員會(huì)通過(guò)手動(dòng)模擬點(diǎn)擊的方式獲取sdk上報(bào)的控件唯一id和數(shù)據(jù)信息,在確認(rèn)id,和數(shù)據(jù)的正確性之后,需要手動(dòng)配置id和埋點(diǎn)事件的對(duì)應(yīng)關(guān)系,及上報(bào)的數(shù)據(jù)字段,并存儲(chǔ)到配置倉(cāng)庫(kù)。在線上環(huán)境,當(dāng)用戶啟動(dòng)app會(huì)拉取配置信息并加載到內(nèi)存。這樣,當(dāng)用戶觸發(fā)點(diǎn)擊行為時(shí),會(huì)根據(jù)第一步獲取的id信息查詢(xún)配置,如果在配置中查到對(duì)應(yīng)的條目,會(huì)將對(duì)應(yīng)的事件及數(shù)據(jù)上報(bào)到服務(wù)器。
為了處理配置下拉失敗無(wú)法發(fā)送埋點(diǎn)的情況,我們需要將同樣的配置放在主項(xiàng)目的assets目錄下,每次啟動(dòng)app請(qǐng)求配置接口判斷配置信息是否發(fā)生變化,如果配置沒(méi)有變化,直接使用assets中的配置文件,否則,下拉最新配置,使用最新的埋點(diǎn)配置信息。
四、無(wú)痕埋點(diǎn)方案對(duì)現(xiàn)有項(xiàng)目的約束
使用無(wú)埋點(diǎn)sdk需要遵循一定的開(kāi)發(fā)規(guī)范,關(guān)于具體的開(kāi)發(fā)規(guī)范請(qǐng)查看工程README。為了確保項(xiàng)目編碼的規(guī)范性,我們開(kāi)發(fā)了一系列l(wèi)int檢查規(guī)則來(lái)幫助發(fā)現(xiàn)錯(cuò)誤。
lint 工程代碼 https://github.com/jessie345/CustomLintRules.git
集成lint功能 https://github.com/jessie345/CustomLintsUsage.git
五、繼續(xù)優(yōu)化
目前,集成這個(gè)無(wú)埋點(diǎn)方案有一些使用約束并且需要在主項(xiàng)目中添加一些特定的配置函數(shù)。下一步需要做的就是解耦。通過(guò)javasist技術(shù),盡量將所有約束遷移到用動(dòng)態(tài)技術(shù)保證,而不是通過(guò)lint規(guī)范,將其侵入性降到最低。
至此,無(wú)埋點(diǎn)sdk的核心運(yùn)作機(jī)制已經(jīng)全部梳理清楚。