無埋點操作,是通過gradle的Transform API在編譯期掃描整個項目生成的class文件,再利用ASM API對class文件插入我們的埋點方法來實現的。
在各種事件方法裏插樁埋點,基本上滿足我們的大部分埋點需求,但是產品會有這樣的需求,想看看用戶在某個界面裏哪些區域點擊比較頻繁,就需要知道用戶點擊的座標。獲取點擊事件可以辦到,還可以拿到點擊事件裏的View,但是無法獲取點擊的屏幕座標。那點擊屏幕座標在哪裏獲取呢?在View的dispatchTouchEvent裏,如果我們在此處插樁,可以拿到它的參數MotionEvent,通過它我們就可以拿到控件的點擊座標。
但是dispatchTouchEvent是系統類View的方法,項目編譯的時候是沒有android.jar供Transform掃描的。辦法是可以通過其他方式實現,比如每打開一個界面都創建一個透明層在屏幕上,通過自定義透明層View來重寫dispatchTouchEvent獲取MotionEvent。或者所有的控件都自定義View的方式重寫dispatchTouchEvent獲取MotionEvent。這兩種方式開銷都比較大,難以維護,不可取。
這裏介紹通過另一種方式來實現。大家都知道android系統出的support包和現在的androidx包都是爲了向下兼容,使得老控件,如textview、edittext等等,可以像Appcompattextview、Appcompatedittext一樣有更好的着色和主題樣式。但是怎麼實現呢?答案在Activity啓動過程裏。Activity的啓動過程大家自行百度,這裏先講oncreate方法
在AppcompatActivity的onCreate方法裏,有一個getDelegate()方法,返回AppCompatDelegate,
進去後,可以看到new了一個AppCompatDelegateImpl實現類,這個實現類又繼承自AppCompatDelegate。
到這裏還沒看到關鍵信息,我們先看下xml佈局文件是怎麼映射成控件view的,
在AppCompatDelegateImpl找createView方法,在createView裏有一個AppCompatViewInflater類,默認類名或設置爲null就new一個AppCompatViewInflater,否則就反射獲取AppCompatViewInflater對象。
到這裏,AppCompatActivity的getDelegate()方法,只是new了一個AppCompatDelegateImpl類,並未看到實質的東西。在裏面找一下可以看到createView這個方法,可以猜到肯定跟這個createView方法有關係。那它是什麼時候被調用的呢,我們回到AppCompatActivity,getDelegate()方法的下一行delegate.installViewFactory();
,進去後可以看到實例化了系統服務LAYOUT_INFLATER_SERVICE,並把它設置進setFactory2方法裏,
可以看到factory賦值給mFactory2接口類
回到setContentView()方法,我們可以看到LayoutInflater.from(mContext).inflate(resId, contentParent);
,步驟是在LayoutInflater裏先用xmlpullparser解析xml,根據tag來生成對應的控件
最終還是通過一系列調用createViewFromTag->tryCreateView->mFactory2.onCreateView(接口回調到AppCompatDelegateImpl
)->onCreateView->createView->mAppCompatViewInflater.createView,最後進入AppCompatViewInflater類裏,可以看到
final View createView(View parent, final String name, @NonNull Context context,
@NonNull AttributeSet attrs, boolean inheritContext,
boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
final Context originalContext = context;
// We can emulate Lollipop's android:theme attribute propagating down the view hierarchy
// by using the parent's context
if (inheritContext && parent != null) {
context = parent.getContext();
}
if (readAndroidTheme || readAppTheme) {
// We then apply the theme on the context, if specified
context = themifyContext(context, attrs, readAndroidTheme, readAppTheme);
}
if (wrapContext) {
context = TintContextWrapper.wrap(context);
}
View view = null;
// We need to 'inject' our tint aware Views in place of the standard framework versions
switch (name) {
case "TextView":
view = createTextView(context, attrs);
verifyNotNull(view, name);
break;
case "ImageView":
view = createImageView(context, attrs);
verifyNotNull(view, name);
break;
case "Button":
view = createButton(context, attrs);
verifyNotNull(view, name);
break;
case "EditText":
view = createEditText(context, attrs);
verifyNotNull(view, name);
break;
case "Spinner":
view = createSpinner(context, attrs);
verifyNotNull(view, name);
break;
case "ImageButton":
view = createImageButton(context, attrs);
verifyNotNull(view, name);
break;
case "CheckBox":
view = createCheckBox(context, attrs);
verifyNotNull(view, name);
break;
case "RadioButton":
view = createRadioButton(context, attrs);
verifyNotNull(view, name);
break;
case "CheckedTextView":
view = createCheckedTextView(context, attrs);
verifyNotNull(view, name);
break;
case "AutoCompleteTextView":
view = createAutoCompleteTextView(context, attrs);
verifyNotNull(view, name);
break;
case "MultiAutoCompleteTextView":
view = createMultiAutoCompleteTextView(context, attrs);
verifyNotNull(view, name);
break;
case "RatingBar":
view = createRatingBar(context, attrs);
verifyNotNull(view, name);
break;
case "SeekBar":
view = createSeekBar(context, attrs);
verifyNotNull(view, name);
break;
case "ToggleButton":
view = createToggleButton(context, attrs);
verifyNotNull(view, name);
break;
default:
// The fallback that allows extending class to take over view inflation
// for other tags. Note that we don't check that the result is not-null.
// That allows the custom inflater path to fall back on the default one
// later in this method.
view = createView(context, name, attrs);
}
if (view == null && originalContext != context) {
// If the original context does not equal our themed context, then we need to manually
// inflate it using the name so that android:theme takes effect.
view = createViewFromTag(context, name, attrs);
}
if (view != null) {
// If we have created a view, check its android:onClick
checkOnClickListener(view, attrs);
}
return view;
}
google在這裏做了個偷樑換柱的switch,把老控件替換成新控件,如果有點擊事件,也再重新給綁定上。這裏簡直就是個奇幻喵喵屋,大家可以進去盡情發揮自己的潛能。
我們要做的是hook dispatchTouchEvent方法,到這隻說了google是怎麼兼容老控件的,google把老控件用switch重新new一個新控件,如果我們可以讓它去new一個我們自定義的view,一切的view都由我們創建,那豈不快哉。那樣我們就可以在自己的自定義view裏去重寫dispatchTouchEvent方法,這樣就可以往重寫的方法裏插樁拿MotionEvent。但實現呢,簡單。
重寫一個CustomAppCompatViewInflater類,在裏面加上新控件
switch (name) {
case "androidx.appcompat.widget.AppCompatTextView":
case "TextView":
view = createTextView(context, attrs);
verifyNotNull(view, name);
break;
case "androidx.appcompat.widget.AppCompatImageView":
case "ImageView":
view = createImageView(context, attrs);
verifyNotNull(view, name);
break;
case "androidx.appcompat.widget.AppCompatButton":
case "Button":
view = createButton(context, attrs);
verifyNotNull(view, name);
break;
case "androidx.appcompat.widget.AppCompatEditText":
case "EditText":
view = createEditText(context, attrs);
verifyNotNull(view, name);
break;
case "androidx.appcompat.widget.AppCompatSpinner":
case "Spinner":
view = createSpinner(context, attrs);
verifyNotNull(view, name);
break;
case "androidx.appcompat.widget.AppCompatImageButton":
case "ImageButton":
view = createImageButton(context, attrs);
verifyNotNull(view, name);
break;
case "androidx.appcompat.widget.AppCompatCheckBox":
case "CheckBox":
view = createCheckBox(context, attrs);
verifyNotNull(view, name);
break;
case "androidx.appcompat.widget.AppCompatRadioButton":
case "RadioButton":
view = createRadioButton(context, attrs);
verifyNotNull(view, name);
break;
case "androidx.appcompat.widget.AppCompatCheckedTextView":
case "CheckedTextView":
view = createCheckedTextView(context, attrs);
verifyNotNull(view, name);
break;
case "AppCompatAutoCompleteTextView":
case "AutoCompleteTextView":
view = createAutoCompleteTextView(context, attrs);
verifyNotNull(view, name);
break;
case "androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView":
case "MultiAutoCompleteTextView":
view = createMultiAutoCompleteTextView(context, attrs);
verifyNotNull(view, name);
break;
case "androidx.appcompat.widget.AppCompatRatingBar":
case "RatingBar":
view = createRatingBar(context, attrs);
verifyNotNull(view, name);
break;
case "androidx.appcompat.widget.AppCompatSeekBar":
case "SeekBar":
view = createSeekBar(context, attrs);
verifyNotNull(view, name);
break;
case "androidx.appcompat.widget.AppCompatToggleButton":
case "ToggleButton":
view = createToggleButton(context, attrs);
verifyNotNull(view, name);
break;
default:
// The fallback that allows extending class to take over view inflation
// for other tags. Note that we don't check that the result is not-null.
// That allows the custom inflater path to fall back on the default one
// later in this method.
view = createView(context, name, attrs);
}
然後把自定義的view給new進去,寫一個基類繼承自AppCompatActivity,
重寫getDelegate方法,再寫一個CustomCompatDelegate類繼承自AppCompatDelegateImpl
在CustomCompatDelegate裏重寫createView方法
@Override
public View createView(View parent, String name, @NonNull Context context, @NonNull AttributeSet attrs) {
if (customAppCompatViewInflater == null) {
TypedArray a = mContext.obtainStyledAttributes(R.styleable.AppCompatTheme);
String viewInflaterClassName =
a.getString(R.styleable.AppCompatTheme_viewInflaterClass);
if ((viewInflaterClassName == null)
|| AppCompatViewInflater.class.getName().equals(viewInflaterClassName)) {
// Either default class name or set explicitly to null. In both cases
// create the base inflater (no reflection)
customAppCompatViewInflater = new CustomAppCompatViewInflater();
} else {
try {
Class viewInflaterClass = Class.forName(viewInflaterClassName);
customAppCompatViewInflater =
(CustomAppCompatViewInflater) viewInflaterClass.getDeclaredConstructor()
.newInstance();
} catch (Throwable t) {
Log.i(TAG, "Failed to instantiate custom view inflater "
+ viewInflaterClassName + ". Falling back to default.", t);
customAppCompatViewInflater = new CustomAppCompatViewInflater();
}
}
}
boolean inheritContext = false;
if (IS_PRE_LOLLIPOP) {
inheritContext = (attrs instanceof XmlPullParser)
// If we have a XmlPullParser, we can detect where we are in the layout
? ((XmlPullParser) attrs).getDepth() > 1
// Otherwise we have to use the old heuristic
: shouldInheritContext((ViewParent) parent);
}
return customAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext,
IS_PRE_LOLLIPOP, /* Only read android:theme pre-L (L+ handles this anyway) */
true, /* Read read app:theme as a fallback at all times for legacy reasons */
VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */
);
}
把裏面的AppCompatViewInflater替換成CustomAppCompatViewInflater。
替換完成後,我們的界面都繼承自這個基類Activity,重寫dispatchTouchEvent方法,這樣所有的view都可以獲取觸摸座標了。
這裏要感謝海哥的無私提醒,才能讓我打通這一關。