Android之hook dispatchTouchEvent方法

無埋點操作,是通過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都可以獲取觸摸座標了。

這裏要感謝海哥的無私提醒,才能讓我打通這一關。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章