1. 程式人生 > >Android XML註冊onClick事件詳解

Android XML註冊onClick事件詳解

做專案的時候用Fragment展示介面,在onCreateView里加載XML佈局檔案。佈局檔案種有一個Button按鈕控制元件設定了onClick屬性,對應的方法實現則寫在了Fragment裡。隨後執行程式發現按鈕點選沒有反應,最開始懷疑是定義的方法介面不正確,仔細檢查之後發現實現沒有問題。百思不得其解之下就去查看了一下系統在載入XML檔案時是如何解析android:onClick事件的原始碼,終於明白了問題發生的原因。
既然是從XML佈局檔案種動態解析生成的檢視樹(ViewTree),自然第一個要檢視的類就是LayoutInflater.inflate方法,該方法的原始碼如下:

public
View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) { final Resources res = getContext().getResources(); final XmlResourceParser parser = res.getLayout(resource); try { return inflate(parser, root, attachToRoot); } finally
{ parser.close(); } }

這個方法首先獲取了資源物件,然後根據佈局檔案資源id獲取佈局檔案對應的XML解析器。多說一句,這個方法呼叫有一個需要注意的地方,如果root也就是載入的佈局到的父容器為空,那麼佈局檔案最外面一層的寬高配置、Gravity等都不會被解析。這個問題在寫ListView的Adapter時候經常會出現,讓人覺得莫名奇妙。如果希望最外層的佈局引數起效,需要保證root不為空。root不為空,第三個引數為false的情況下返回的是佈局檔案的根物件,如果為true的話返回的物件就是傳入的root。inflate(parser…)這個方法內部會呼叫rInflate方法解析XML檔案裡的每個節點,r就是recursive遞迴的意思。

 void rInflate(XmlPullParser parser, View parent, Context context,
            AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
...
        while (((type = parser.next()) != XmlPullParser.END_TAG ||
                parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
...
            final String name = parser.getName();
            if (TAG_REQUEST_FOCUS.equals(name)) {
                parseRequestFocus(parser, parent);
            } else if (TAG_TAG.equals(name)) {
                parseViewTag(parser, parent, attrs);
            } else if (TAG_INCLUDE.equals(name)) { 
            // 這裡是從根節點開始呼叫,如果根節點為include,也就是當前節點深度為0
                if (parser.getDepth() == 0) {
                    throw new InflateException("<include /> cannot be the root element");
                }
                parseInclude(parser, context, parent, attrs);
            } else if (TAG_MERGE.equals(name)) { // merge標籤執行到這裡就表示merge被巢狀在其他的ViewGroup裡                
            throw new InflateException("<merge /> must be the root element");
            } else {
                final View view = createViewFromTag(parent, name, context, attrs);
                final ViewGroup viewGroup = (ViewGroup) parent;
                final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
                rInflateChildren(parser, view, attrs, true);
                viewGroup.addView(view, params);
            }
        }

可以看到這個方法裡對Merge、include等標籤都做了單獨的判斷。Button按鈕屬於View標籤,對應執行的就是最後的else操作,所以要檢視createViewFromTag這個方法的實現。

View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
            boolean ignoreThemeAttr) {
        ....
        // 使用自定義的工廠類生成View物件,這裡沒有自定義的工廠類所以不用考慮這些邏輯
                   if (view == null) {
                final Object lastContext = mConstructorArgs[0];
                mConstructorArgs[0] = context;
                try {
                // 比如Button因為是系統控制元件所以走這裡
                    if (-1 == name.indexOf('.')) {
                        view = onCreateView(parent, name, attrs);
                    } else {// 比如com.example.MyView就會走這裡,自定義控制元件生成
                        view = createView(name, null, attrs);
                    }
                } finally {
                    mConstructorArgs[0] = lastContext;
                }
            }
            return view;
        } catch (InflateException e) {
        ...// 異常處理邏輯
        }
    }

順著onCreateView方法就能找到LayoutInflater最終生成Button控制元件是呼叫了Button的建構函式物件反射生成了一個按鈕物件。程式碼邏輯如下:

public final View createView(String name, String prefix, AttributeSet attrs)
            throws ClassNotFoundException, InflateException {
        Constructor<? extends View> constructor = sConstructorMap.get(name); // LayoutInflater實際上對這些控制元件的建構函式進行了快取,這樣就能提高解析生成物件的效率
        Class<? extends View> clazz = null;
        try { // 如果是頭一次解析該物件,那麼就要通過classloader根據物件的類名載入類物件
            if (constructor == null) {
                               clazz = mContext.getClassLoader().loadClass(
                        prefix != null ? (prefix + name) : name).asSubclass(View.class);

                                // 然後獲取類物件裡的建構函式物件,並放入快取中
                constructor = clazz.getConstructor(mConstructorSignature);
                constructor.setAccessible(true);
                sConstructorMap.put(name, constructor);
            } else {
               ...   
            }

            Object[] args = mConstructorArgs;
            args[1] = attrs;
            // 到了這裡就是通過建構函式生成View物件了
            final View view = constructor.newInstance(args);
            if (view instanceof ViewStub) { // 這裡對ViewStub做特殊處理
                final ViewStub viewStub = (ViewStub) view;
              viewStub.setLayoutInflater(cloneInContext((Context) args[0]));
            }
            return view;
        } catch (NoSuchMethodException e) {
           ...
       }
    }

接下來檢視Button的類繼承關係,可以發現Button繼承自TextView,而TextView繼承自View。所以可以知道Button的建構函式執行之前會執行TextView的建構函式,TextView的建構函式執行之前又會執行View的建構函式。因為XML檔案解析過程中控制元件的屬性會被放到AttributeSet中,所以檢視帶有AttributeSet的建構函式就知道android:onClick的處理過程。在View的建構函式中找到了如下的程式碼:

public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        this(context);
        .... // 其他的初始化

                case R.styleable.View_onClick:
                   ...
                   final String handlerName = a.getString(attr);
                    if (handlerName != null) {
                        setOnClickListener(new DeclaredOnClickListener(this, handlerName));
                    }
                    break;

如果做過View自定義控制元件的屬性就會知道R.styleable.View_onClick這個就是onClick所代表的屬性常量。可以看到setOnClickListener設定了點選的回撥介面物件,這個物件被包裝成DeclaredOnClickListener型別。檢視DeclaredOnClickListener的原始碼如下:

private static class DeclaredOnClickListener implements OnClickListener {
        ...
        private Method mMethod;
        @Override // 點選按鈕時實際呼叫的回撥函式
        public void onClick(@NonNull View v) {
            if (mMethod == null) {
                mMethod = resolveMethod(mHostView.getContext(), mMethodName);
            }
            // 使用反射回調context裡的android:onClick方法
            try {
                mMethod.invoke(mHostView.getContext(), v);
            } catch (IllegalAccessException e) {
              ....
            }
        }

        @NonNull // 從Context物件中獲取註冊的方法物件
        private Method resolveMethod(@Nullable Context context, @NonNull String name) {
            while (context != null) {
                try {
                    if (!context.isRestricted()) {
                        return context.getClass().getMethod(mMethodName, View.class);
                    }
                } catch (NoSuchMethodException e) {
                }
           }
        }
    }

上面的getContext就是Button所在的activity物件,所以如果註冊方法在Activity裡那麼就能夠回撥成功。不過android:onClick註冊在debug時能執行正常,切換成release版本時就又有問題了。這是因為release版本通常會混淆程式碼,方法的名稱就改變了,所以最好的註冊點選事件是在程式碼中設定。