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版本通常會混淆程式碼,方法的名稱就改變了,所以最好的註冊點選事件是在程式碼中設定。