无埋点操作,是通过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都可以获取触摸坐标了。
这里要感谢海哥的无私提醒,才能让我打通这一关。
