客戶端埋點是數(shù)據(jù)收集的最基本手段啄育,但由于業(yè)務迭代速度很快,手動埋點方案雖然靈活多變,但是極大的增加了客戶端開發(fā)人員的工作量羊娃。開發(fā)完成業(yè)務功能需要花費很大的精力處理埋點事宜,而且隨著迭代版本埃跷,埋點的數(shù)量會越來越多蕊玷,這些老舊埋點的維護工作也需要付出不小的努力。并且弥雹,手動埋點的正確性同樣是個極度考驗開發(fā)人員的耐性和認真程度的問題垃帅,在所難免會出現(xiàn)這樣那樣的問題。所以剪勿,如果能夠研發(fā)出一款不需要或者很少需要開發(fā)人員介入就能實現(xiàn)根據(jù)不同業(yè)務場景埋點的功能sdk對于提高版本迭代速度和開發(fā)人員的幸福感絕對是一件非常有價值的事情贸诚。
更大的價值還在于,不需要開發(fā)人員介入窗宦,運營或者用研的同學就可以隨時動態(tài)調(diào)整數(shù)據(jù)收集方案赦颇。
縱觀目前比較成熟的無埋點方案,存在著如下問題:
問題1:通過XPath定位控件赴涵,理論上可行媒怯,但實踐表明這個方案的復雜度非常高,尤其對于處理像GridView髓窜,ListView扇苞,RecyclerView的控件更是捉襟見肘。不僅如此寄纵,生成xpath的過程本身就是一個及其耗費性能的行為鳖敷,它需要遍歷view tree,存儲非常多的路徑信息到view上程拭。
問題2:獲取控件對應的數(shù)據(jù)是通過 data path的方式解決定踱,每次添加新埋點時,如果需要上報數(shù)據(jù)恃鞋,那用研人員需要和開發(fā)人員逐一確認控件數(shù)據(jù)的path崖媚,這極大的限制了客戶端開發(fā)的自由度亦歉,即使簡單的重構(gòu)也會使得之前配置的埋點信息失效。
針對如上問題畅哑,我們經(jīng)過深挖內(nèi)在邏輯關(guān)系及對比優(yōu)劣肴楷,總結(jié)出了一套更靈活,更合理的無埋點方案荠呐,下面分三個部分逐一介紹實現(xiàn)考量及內(nèi)部機制赛蔫。
一、定位與用戶產(chǎn)生交互行為的目標控件
關(guān)于定位交互控件泥张,我們也考慮過xpath的方案呵恢,但是考慮到其實現(xiàn)的復雜度,不靈活和各種潛在的問題圾结,我們拋棄了這種方案瑰剃。通過反復的閱讀View的touch事件處理相關(guān)的源碼,我們終于發(fā)現(xiàn)了解決問題的更好的方式筝野。
ViewGroup中有一個TouchTarget 類型的變量 mFirstTouchTarget晌姚,表示消費當前觸摸事件的控件列表。例如歇竟,點擊屏幕上一個按鈕挥唠,那么按鈕所在ViewGroup的mFirstTouchTarget 變量就指向這個按鈕。當ViewGroup派發(fā)觸摸事件時焕议,他會首先判斷變量mFirstTouchTarget是否存在宝磨,如果變量存在,會循環(huán)遍歷TouchTarget鏈表元素盅安,找到能處理該事件的View并將MotionEvent 派發(fā)給該View唤锉。如果不存在TouchTarget,ViewGroup 會循環(huán)遍歷所有child view别瞭,直到找到一個能處理該事件的View窿祥,并將該View作為first touch target 賦值給mFirstTouchTarget。
當用戶觸發(fā)Down事件時蝙寨,會執(zhí)行如下邏輯晒衩,尋找消費當前事件的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 消費了觸摸事件
..
..
// 根據(jù)消費了觸摸事件的View創(chuàng)建TouchTarget
newTouchTarget = addTouchTarget(child, idBitsToAssign);
..
..
break;
}
}
當觸發(fā)Down事件并且找到TouchTarget,或者觸發(fā)非Down事件時虹菲,執(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ā)生時找到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對應的View正確消費了事件
handled = true;
}
..
..
}
..
..
}
}
提示:由于消費觸摸事件的控件可能為多個(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的這種事件處理機制菠赚,我們通過在Activity的window上調(diào)用window.setCallback() 接管窗口的事件派發(fā),并在dispatchTouchEvent處理函數(shù)中添加analyzeMotionEvent()方法郑藏。如果接收到up事件,執(zhí)行處理邏輯瘩欺,通過ViewGroup TouchTarget鏈表必盖,找到本次交互行為的目標控件。拿到控件后俱饿,通過 Activity的類名+控件所在的layout文件名+控件id對應的資源名歌粥,我們就可以確定目標控件的唯一標識。
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í)行時間
if (!AutoPointer.isOnlineEnv()) {
long time = System.currentTimeMillis() - t;
DDLogger.d(TAG, String.format(Locale.CHINA, "處理時間:%d 毫秒", time));
}
return super.dispatchTouchEvent(ev);
}
analyzeMotionEvent源碼如下:
/**
* 分析用戶的點擊行為
*/
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; //對于非Activity DecorView 的情況處理
}
Pair<View, Object> targets = findActionTargets(content);
if (targets == null) {
DDLogger.e(TAG, "has no action targets!!!");
return;
}
//發(fā)送任務在單線程池中
int hashcode = targets.first.hashCode();
if (mIgnoreViews.contains(hashcode)) return;
PointerExecutor.getHandler().post(PointPostAction.create(targets.first, targets.second));
}
二失驶、獲取與目標控件對應的業(yè)務數(shù)據(jù)
對于獲取控件數(shù)據(jù),為了最大化獲取速度枣购,我們在系統(tǒng)中配置了多個數(shù)據(jù)獲取策略嬉探。如果目標控件是AbsListView或者RecyclerView 的child view及child view 的chid,那我們可以通過child view在adapter中的位置獲取到我們想要的數(shù)據(jù)棉圈。這種方式能夠處理大多數(shù)頁面控件數(shù)據(jù)的獲取問題涩堤。系統(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);
}
對于那些完全自定義布局繪制的頁面,例如個人中心等頁面分瘾,業(yè)務開發(fā)人員需要通過框架api建立一個控件樹到數(shù)據(jù)的映射關(guān)系胎围,這樣框架在需要獲取數(shù)據(jù)時,通過這個關(guān)系就可以非常容易的獲取到想要的數(shù)據(jù)德召。
/**
* 配制自定義布局的數(shù)據(jù)綁定關(guān)系白魂,自定義布局內(nèi)的任何
* 控件發(fā)生點擊行為時,發(fā)送的埋點都會攜帶改數(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)系上岗,我們可以非常簡單的獲取到綁定的數(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ù)綁定問題
if (dataAdapter != null) {
return Pair.create(touchTarget, dataAdapter.getData());
}
三、實現(xiàn)埋點的動態(tài)可配置
在測試環(huán)境下液茎,用研人員會通過手動模擬點擊的方式獲取sdk上報的控件唯一id和數(shù)據(jù)信息逞姿,在確認id,和數(shù)據(jù)的正確性之后捆等,需要手動配置id和埋點事件的對應關(guān)系滞造,及上報的數(shù)據(jù)字段,并存儲到配置倉庫栋烤。在線上環(huán)境谒养,當用戶啟動app會拉取配置信息并加載到內(nèi)存。這樣,當用戶觸發(fā)點擊行為時买窟,會根據(jù)第一步獲取的id信息查詢配置丰泊,如果在配置中查到對應的條目,會將對應的事件及數(shù)據(jù)上報到服務器始绍。
為了處理配置下拉失敗無法發(fā)送埋點的情況瞳购,我們需要將同樣的配置放在主項目的assets目錄下,每次啟動app請求配置接口判斷配置信息是否發(fā)生變化亏推,如果配置沒有變化学赛,直接使用assets中的配置文件,否則吞杭,下拉最新配置盏浇,使用最新的埋點配置信息。
四芽狗、無痕埋點方案對現(xiàn)有項目的約束
使用無埋點sdk需要遵循一定的開發(fā)規(guī)范绢掰,關(guān)于具體的開發(fā)規(guī)范請查看工程README。為了確保項目編碼的規(guī)范性童擎,我們開發(fā)了一系列l(wèi)int檢查規(guī)則來幫助發(fā)現(xiàn)錯誤滴劲。
lint 工程代碼 https://github.com/jessie345/CustomLintRules.git
集成lint功能 https://github.com/jessie345/CustomLintsUsage.git
五、繼續(xù)優(yōu)化
目前顾复,集成這個無埋點方案有一些使用約束并且需要在主項目中添加一些特定的配置函數(shù)哑芹。下一步需要做的就是解耦。通過javasist技術(shù)捕透,盡量將所有約束遷移到用動態(tài)技術(shù)保證聪姿,而不是通過lint規(guī)范,將其侵入性降到最低乙嘀。
至此,無埋點sdk的核心運作機制已經(jīng)全部梳理清楚末购。