Android 記一次解決問題的過程

以前我寫過一篇文章,介紹我在GitHub開源的滑動控件ConsecutiveScroller是如何實現佈局吸頂功能的。有興趣的朋友能夠去看一下:Android滑動佈局ConsecutiveScrollerLayout實現佈局吸頂功能。文章介紹了ConsecutiveScrollerLayout是如何經過計算佈局的滑動距離,給吸頂view設置y軸偏移量,讓它懸停在頂部。不過當view懸停在頂部時,它會與後面的view重疊而被覆蓋。這是因爲Android佈局的顯示層級,兩個view重疊時,後添加的會將先添加的覆蓋。而咱們但願的是當吸頂view與其餘view重疊時,吸頂view能顯示在最上層,覆蓋後面的view。當時個人解決方法是給吸頂view設置translationZ,讓它的顯示圖層高於其餘的view,這樣它就不會被其餘view覆蓋了。這樣作的確很好的解決了view重疊顯示的問題,不過美中不足的是,translationZ是Android 5.0才支持的方法,5.0如下的手機沒法使用這個方法設置view的顯示圖層高度。這使得ConsecutiveScrollerLayout的吸頂功能只能在Android 5.0以上的手機才能使用,這大大的限制了它的適用範圍。若是咱們的項目是支持5.0如下的,那麼咱們不可能讓吸頂的功能只在5.0以上的手機有效,而無論5.0如下的手機。因此我須要找到一種方法,讓5.0如下的手機也能正常使用吸頂的功能。java

分析問題

5.0如下不能使用吸頂,是由於setTranslationZ()方法是5.0方法是5.0之後有的,那麼Android是否提供了向下兼容的方法呢?因而我找到了ViewCompat.setTranslationZ()方法。android

public static void setTranslationZ(@NonNull View view, float translationZ) {
        if (VERSION.SDK_INT >= 21) {
            view.setTranslationZ(translationZ);
        }
    }
複製代碼

真是讓人失望,它只是判斷了如下版本,讓5.0如下不至於報錯,其實它什麼都沒作。既然連Android自己都沒有對5.0如下作處理,顯然讓view的Z軸向下兼容是不大可能的。git

迴歸問題自己,咱們但願吸頂view顯示在界面的最上層,不被其餘view所覆蓋。Android界面上顯示的全部內容都是繪製在一張畫布(Canvas)上面的,同一個區域,若是被繪製屢次,先繪製的內容會被後繪製的內容覆蓋。而view的繪製順序是先添加的先繪製,後添加的後繪製,因此當view重疊時,後面的view會覆蓋前面的view。只要保證吸頂的view在其餘view以後繪製,吸頂view就會顯示在其餘view之上,不會被其餘view覆蓋。那麼有沒有方法能保證吸頂view最後繪製?最簡單直接的方法固然是讓吸頂view最後添加,但問題是view的添加順序不只會影響繪製順序,一樣也會影響view的排列和顯示位置。而咱們想要的是改變view的繪製順序,不改變view的顯示位置。因此這種方法顯然也是不行的。有什麼方法能夠在不改變view的添加順序的狀況下,改變它的繪製順序呢?咱們知道佈局在measure、layout和draw的過程當中,都會遍歷它的子view,分發測量、佈局、繪製的流程。若是咱們在佈局draw以前修改子view的順序,draw以後恢復,那麼是否就保證了只改變view的繪製順序。github

解決方案 1.0

ViewGroup的子view保存在mChildren數組中。面試

private View[] mChildren;
複製代碼

因爲它是private的,要獲取和修改它,須要經過反射來執行。canvas

// 獲取mChildren
private View[] getChildren() {
    try {
        Class aClass = Class.forName("android.view.ViewGroup");
        Field field = aClass.getDeclaredField("mChildren");
        field.setAccessible(true); 
        Object resultValue = field.get(this);
        return (View[]) resultValue;
    } catch (Exception e) {
        e.printStackTrace();
    }
    return null;
}
// 設置mChildren
private void setChildren(View[] children) {
    try {
        Class aClass = Class.forName("android.view.ViewGroup");
        Field field = aClass.getDeclaredField("mChildren");
        field.setAccessible(true); // 私有屬性必須設置訪問權限
        field.set(this, children);
    } catch (Exception e) {
        e.printStackTrace();
    }
}
複製代碼

繪製前,修改view的排序,繪製後恢復。數組

// 臨時變量,保存mChildren原數組
private View[] tempViews = null;

@Override
public void draw(Canvas canvas) {
   // 兼容5.0如下吸頂功能
   if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP
           && !getStickyChildren().isEmpty()) {
       tempViews = getChildren();
       if (tempViews != null) {
         // 修改mChildren
           setChildren(sortViews(tempViews.length));
       }
    }

    super.draw(canvas);

   // 兼容5.0如下吸頂功能
   if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP
           && !getStickyChildren().isEmpty() && tempViews != null) {
     // 恢復mChildren
       setChildren(tempViews);
   }
}

// 返回排序後的children數組
private View[] sortViews(int size) {
    View[] views = new View[size];
    int index = 0;
    int count = getChildCount();
    for (int i = 0; i < count; i++) {
        View child = getChildAt(i);
        // 普通view
        if (!isStickyChild(child)) {
            views[index] = child;
            index++;
        }
    }

    for (int i = 0; i < count; i++) {
        View child = getChildAt(i);
        // 吸頂view
        if (isStickyChild(child)) {
            views[index] = child;
            index++;
        }
    }
    return views;
}
複製代碼

修改好,運行測試一下,當view吸頂時,能正常顯示在最上層,不會被下面的view覆蓋了,好像問題已經完美解決了。但是當我點擊界面上的控件時,新的問題出現了,我點擊的view和響應的view不是同一個,事件的傳遞亂了。由於咱們把view的繪製順序改變了,因此咱們實際看到的、操做的view,跟系統判斷的可能不是同一個view了。顯然這種解決方法引起了新的問題,是不可取的。ide

分析源碼

既然經過修改mChildren的方法行不通,只能另尋方案。我嘗試跟蹤view的繪製源碼,期待能有一些新思路。ViewGroup繪製子view的源碼調用路徑是:draw()-->dispatchDraw()。ViewGroup中的dispatchDraw()方法是繪製子view的關鍵代碼,經過閱讀源碼,我發現了幾句關鍵代碼。佈局

@Override
    protected void dispatchDraw(Canvas canvas) {
        
				// step 1:獲取預約義的排序列表
        final ArrayList<View> preorderedList = usingRenderNodeProperties
                ? null : buildOrderedChildList();
				
				// step 2:判斷是否須要自定義排序
        final boolean customOrder = preorderedList == null && isChildrenDrawingOrderEnabled();
				
        for (int i = 0; i < childrenCount; i++) {
						// step 3:根據繪製順序獲取view下標
            final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);
						// step 4:根據下標獲取子view
            final View child = getAndVerifyPreorderedView(preorderedList, children, childIndex);
            if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE || child.getAnimation() != null) {
								// step 5:繪製子view
                more |= drawChild(canvas, child, drawingTime);
            }
        }
    }
複製代碼

第一步:獲取預約義的排序列表。若是開啓了硬件加速usingRenderNodeProperties爲true,preorderedList爲null。不然執行buildOrderedChildList()方法,這個方法大部分狀況下也直接返回null,因此preorderedList通常都是null的。buildOrderedChildList()方法只有在沒有設置硬件加速,而且子view設置了Z軸高度的狀況下才不會返回null。咱們知道,Android 4.0後,默認都是開啓硬件加速的,而5.0前,是不支持view的Z軸的,因此只有在5.0後關閉硬件加速,而且設置了子view的Z軸,buildOrderedChildList()方法纔不會返回null,這個方法就是處理這種狀況的,並且它對view的排序處理跟咱們下面分析的邏輯基本同樣,因此這個方法咱們能夠忽略不看。 第二步:判斷是否須要自定義排序。既然preorderedList爲null,那麼是否須要自定義排序的判斷就是isChildrenDrawingOrderEnabled()方法,這個方法默認爲false,只有設置爲true,自定義的排序才生效,這是咱們須要關注的第一個方法。 第三步:根據繪製順序獲取view下標。直接看代碼:post

private int getAndVerifyPreorderedIndex(int childrenCount, int i, boolean customOrder) {
        final int childIndex;
        if (customOrder) {
          // 若是自定義排序,根據順序獲取view下標
            final int childIndex1 = getChildDrawingOrder(childrenCount, i);
            if (childIndex1 >= childrenCount) {
                throw new IndexOutOfBoundsException("getChildDrawingOrder() "
                        + "returned invalid index " + childIndex1
                        + " (child count is " + childrenCount + ")");
            }
            childIndex = childIndex1;
        } else {
          // 不是自定義排序,下標和順序一致
            childIndex = i;
        }
        return childIndex;
    }
複製代碼

在這個方法裏,若是不排序,返回的下標和順序同樣,因此默認繪製順序就是view的添加順序。若是須要排序,經過getChildDrawingOrder獲取須要繪製的view的下標,繪製順序由這個方法的返回值決定。

protected int getChildDrawingOrder(int childCount, int drawingPosition) {
    return drawingPosition;
}
複製代碼

能夠看到,這個方法的返回值依然是順序自己,因此它的默認繪製順序也view的添加順序。可是這個方法是protected,也就是說咱們能夠覆寫這個方法,返回咱們想要的index,改變view的繪製順序。這是咱們須要關注的第二個方法。

第四步:根據下標,調用getAndVerifyPreorderedView或者須要繪製的子view。

private static View getAndVerifyPreorderedView(ArrayList<View> preorderedList, View[] children, int childIndex) {
        final View child;
        if (preorderedList != null) {
            child = preorderedList.get(childIndex);
            if (child == null) {
                throw new RuntimeException("Invalid preorderedList contained null child at index "
                        + childIndex);
            }
        } else {
            child = children[childIndex];
        }
        return child;
    }
複製代碼

這個方法很簡單,就是根據下標或者view,若是有預約義排序,就從preorderedList中獲取,不然就從children數組獲取,children數組就是保存子view的數組,按添加順序排列。

第五步:drawChild,就是調用child的draw方法繪製子view。

最終實現

如今咱們知道,想要改變ViewGroup的子view繪製順序,只有開啓自定義排序,而且覆寫getChildDrawingOrder方法就能夠了。

在自定義ViewGroup的構造方法中調用:

// 開啓自定義排序
setChildrenDrawingOrderEnabled(true);
複製代碼

預先處理view的排序

// 保存預先處理的排序
private final List<View> mViews = new ArrayList<>();

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
  
  //忽略其餘的代碼 
  
  	// 排序
    sortViews();
}

private void sortViews() {
    List<View> list = new ArrayList<>();
    int count = getChildCount();
    for (int i = 0; i < count; i++) {
        View child = getChildAt(i);
      // 添加非吸頂view
        if (!isStickyChild(child)) {
            list.add(child);
        }
    }

    for (int i = 0; i < count; i++) {
        View child = getChildAt(i);
      // 添加吸頂view
        if (isStickyChild(child)) {
            list.add(child);
        }
    }
    mViews.clear();
    mViews.addAll(list);
}
複製代碼

這裏要說明一下,由於getChildDrawingOrder方法是根據繪製的順序drawingPosition返回須要繪製的子view下標,因此咱們須要提早知道最終繪製的順序,才能根據drawingPosition找到相應的index,因此須要提早對view排序好。而把排序的時機選擇在onLayout,是由於在個人需求裏,子view的添加、移除、和setLayoutParams都有可能改變排序,而這些操做剛好都會從新調用父佈局的onLayout方法。最後排序的方式是先添加非吸頂view,後添加吸頂view,這樣保證了吸頂view在最後繪製,view重疊時也就不會被其餘view覆蓋了。

最後覆寫getChildDrawingOrder

@Override
protected int getChildDrawingOrder(int childCount, int drawingPosition) {
    if (mViews.size() > drawingPosition) {
      // 根據drawingPosition找到子view,返回子view在ViewGroup中的index
        return indexOfChild(mViews.get(drawingPosition));
    }
    return super.getChildDrawingOrder(childCount, drawingPosition);
}
複製代碼

至此,咱們的功能就實現好了。

寫在最後

這篇文章的重點就一個getChildDrawingOrder方法,可是若是我只是想告訴你們有這麼一個方法,那麼徹底沒有必要寫這篇文章。我寫這篇文章的主要目的是記錄這個問題的解決過程,中間會踩坑,也會有意外收穫。網上有朋友吐槽,面試時面試官會問:「你遇到過哪些難題,最後時怎麼解決的」。不少人都不知道怎麼回答,由於全部已經被解決的問題都不是問題,而沒有被解決的問題你是不會提起的。就拿個人這個問題來講,若是我早知道有這麼個方法,這仍是問題嗎?咱們每每在解決問題後就忽略了問題的解決過程,甚至是問題自己,決定原來這個問題如此簡單。殊不知,這個過程對咱們纔是最有意義和收穫的。

最後說一句:從源碼中尋找答案,永遠是解決問題的最有效方法。

相關文章
相關標籤/搜索