Android面試集錦系列(44)——ScrollView巢狀ListView的事件衝突
前言
2013年7月,百度將出資19億美元收購91無線訊息成為圈內熱談,我正好在這個時候,去91新成立了研發中心面試。面試官很和藹的和我討論了一些技術問題,大多數還能應付,記憶較深的便是如何處理巢狀ListView的滑動事件衝突問題。
這個問題當時我沒有回答好,主要是我對自定義View方面經驗不足,Touch事件的分佈機制也沒有理解清楚。之後91並沒有給我答覆,到是過了兩個月HR再次聯絡我,問我如果過去的話什麼時候能到崗,並強調他們是由於百度收購公司的手緒問題拖了這麼久。
只能感嘆能否進某家公司其實也是需要緣分的。我當時對在本地的公司已經不感興趣了,因為“世界這麼大,我想出去看看”。
面試題:如何解決ScrollView巢狀中一個ListView的滑動衝突?
後來我一試,發現ScrollView佈局中巢狀Listview顯示是不正常的,確切地說是隻會顯示ListView的第一個項。
先說下為什麼會只顯示ListView的第一個Item,簡單的說就是ListView在計算(比較正式的說法是:測量)自己的高度時對MeasureSpec.UNSPECIFIED這個模式在測量時只會返回一個List Item的高度(當然還有一些padding這些的值我們可以先忽略),而ScrollView的重寫了measureChildWithMargins方法導致它的子View的高度被強制設定成了MeasureSpec.UNSPECIFIED模式。
ListView.java的onMeasure()程式碼片段:
if (heightMode == MeasureSpec.UNSPECIFIED) { heightSize = mListPadding.top + mListPadding.bottom + childHeight + getVerticalFadingEdgeLength() * 2; }
ScrollView.java的measureChildWithMargins()程式碼片段:
final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec( lp.topMargin + lp.bottomMargin, MeasureSpec.UNSPECIFIED); child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
注意:ScrollView繼承於FrameLayout,但它的佈局中只能有一個子View,常用的是LinearLayout。
說到這裡,我們肯定要來看看MeasureSpec是什麼東西,而且這也是一個很好的面試題,如果做過自定義View,對它肯定不會陌生的。我們在XML在佈局檔案中,設定佈局的高和寬時,常常會用到“100dp”、“wrap_content”或者“match_parent”這類的值去設定它的android:layout_width和android:layout_height,而對於每個View控制元件來說,這兩個值都是必需的。
最終我們把View繪製到螢幕時,需要將View的寬高值對映到螢幕上的畫素大小,這就要在draw前先確定本身的寬高和每個子佈局的具體寬高(畫素值),這中間就需要一個轉換的過程,如把wrap_content轉換成100px,這就是measure的工作。
而佈局中有很多子佈局,或者說ViewGroup中可能會有多個ViewGroup和View,整個測量過程也是一次根結點開始的遍歷過程,在這個過程中父佈局需要告訴它的子佈局具體的模式和寬高值(對子佈局是一種約束,子佈局需要在允許的範圍內繪製),最終Android用一個int型來表示模式和值。
做過手機遊戲的一定很容易想到用位移。int佔4個位元組,32位(bit),前2位(高位)用於存Mode,後面30位用於存寬高的具體值。當然了我們不用具體去操作,有一個封裝好的MeasureSpec類會幫我們處理這些事情。這就是為什麼我們看別人的自定義UI原始碼時常常看到如下的程式碼:
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec);
Size為具體的值,而Mode就是我們說的三種模式:UNSPECIFIED,EXACTLY和AT_MOST。
UNSPECIFIED
不限定,父View不限制子View的具體的大小,所以子View可以按自己需求設定寬高(前面說的ScrollView就給子View設定了這個模式,ListView就會自己確認自己高度)。
EXACTLY
父View決定子View的確切大小,子View被限定在給定的邊界裡,忽略本身想要的大小。
AT_MOST
最多的,子View最大可以達到的指定大小(當設定為wrap_content時,模式為AT_MOST, 表示子view的大小最多是多少。)
知道了這些我們解決這個問題,就不算難了,我們也可以重寫ListView的onMeasure讓它按我們的要求測量高度。
顯示正常之後,遇到了91面試官和我說的滑動事件衝突問題,ScrollView和ListView都是上下滑動的,巢狀在一起後ScrollView中的ListView就沒法上下滑動了,事件被ScrollView響應了。
就裡又引出了一個常被問到的面試題:ViewGroup的Touch事件分發機制。我們觸控幕時會產生事件(MotionEvent):
ACTION_DOWN:手指開始觸控到螢幕的那一刻響應的是DOWN事件;
ACTION_MOVE:接著手指在螢幕上移動響應的是MOVE事件;
ACTION_UP:手指從螢幕上鬆開的那一刻響應的是UP事件。
事件的分發中我們較關注的三個方法:
分發事件:dispatchTouchEvent
在這裡進行事件的分發,onInterceptTouchEvent和onTouchEvent都是由dispatchTouchEvent負責排程的。
攔截事件:onInterceptTouchEvent
只有ViewGroup才有這個方法。攔截了的話,ViewGroup就不會把事件繼續分發給子View了,即子View的dispatchTouchEvent和onTouchEvent這兩個方法都不會被呼叫。返回true時,表示ViewGroup會攔截事件。
消費事件:onTouchEvent
onTouchEvent 返回true時,表示事件被消費掉了。一旦事件被消費掉了,其他父元素的onTouchEvent方法都不會被呼叫。
用一張圖簡單說明一下分發的的大體流程:

現在我們回過頭來看,ScrollView和ListView的事件衝突問題,從ScrollView的原始碼可以看到它對Touch事件(ACTION_MOVE)進行了攔截,所以滑動的事件傳遞不到ListView。
所以我們解決這個問題,需要讓在ListView區域的滑動事件ScrollView不要攔截。這樣在ListView區域外的還是由ScrollView去處理事件,ListView外滑動的就是ScrollView。這裡用到一個系統自帶的API來實現這種方案:requestDisallowInterceptTouchEvent(我覺得可以從名字直接讀出它的用途,不再解釋),程式碼也不復雜:
public class MyListView extends ListView { public MyListView(Context context) { super(context); } public MyListView(Context context, AttributeSet attrs) { super(context, attrs); } public MyListView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int newHeightMeasureSpec = MeasureSpec.makeMeasureSpec(480, // 固定高度(實際中這個值應該是根據手機螢幕計算出來的) MeasureSpec.AT_MOST); super.onMeasure(widthMeasureSpec, newHeightMeasureSpec); } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_MOVE: getParent().requestDisallowInterceptTouchEvent(true); break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: getParent().requestDisallowInterceptTouchEvent(false); break; } return super.onInterceptTouchEvent(ev); } }
小結
關於這部份其實還是有很多可以講的,但並不一定適合拿來做面試題,我覺得它們太偏細節了,很多地方自己久不做了也不一定說得出來(甚至說錯都可能)。而且,這種細節方面的問題可以編寫程式碼時就發現,不容易產生問題,不過對事件的分發機制有一個大體的瞭解還是很有必要的。
最後
在現在這個金三銀四的面試季,我自己在網上也蒐集了很多資料做成了文件和架構視訊資料免費分享給大家【 包括高階UI、效能優化、架構師課程、NDK、Kotlin、混合式開發(ReactNative+Weex)、Flutter等架構技術資料 】,希望能幫助到您面試前的複習且找到一個好的工作,也節省大家在網上搜索資料的時間來學習。
資料獲取方式:加入Android架構交流QQ群聊:513088520 ,進群即領取資料!!!
點選連結加入群聊【Android移動架構總群】:加入群聊

資料大全