標籤:

Android 坑檔案:那個Button,你憑什麼在我上面!

1、緣起

話說有那麼一天,我們寫了下面這個布局:

<RelativeLayoutn android:layout_width_="wrap_content"n android:layout_height="wrap_content">nn <Buttonn android:id="@+id/hello"n android:enabled="true"n android:layout_width_="match_parent"n android:layout_height="300dp"n android:layout_margin="10dp"/>nn <TextViewn android:id="@+id/textView"n android:layout_width_="match_parent"n android:layout_height="200dp"n android:background="#FF0000"/>nn</RelativeLayout>n

這個不透明的 TextView 是放到後面的,按照 Android ViewGroup 的默認繪製順序,顯然應該是先繪製 Button再繪製下面的 TextView,也就是下面這樣:

然而,現實總是那麼逗比,讓人哭笑不得。。

額。。這。。。難道是我對繪製順序的理解不夠全面??

2、ViewGroup 的繪製順序

話說,一直做 UI 的工作,ViewGroup 子 View 的繪製順序我還是很熟悉的啊,先來的先畫,後來的後畫,所以用我們的老話就是『磚頭砌牆,後來居上』。

int childIndex = customOrder ? getChildDrawingOrder(childrenCount, i) : i;nfinal View child = (preorderedList == null)n ? children[childIndex] : preorderedList.get(childIndex);nif ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE || child.getAnimation() != null) {n more |= drawChild(canvas, child, drawingTime);n}n

這段代碼摘自 ViewGroup.dispatchDraw,我們看到如果 customOrder 為 true,就採用 getChildDrawingOrder 返回的繪製順序,否則就按照子 View 的存放順序,而這個順序就是我們在布局當中給出的順序。

那麼再想想,是不是採用了自定義的順序?開玩笑,父 View 可是 RelativeLayout,我們沒有自定義啊!!

3、換個手機試試

這種情況,是在沒有頭緒,所以會在各種手機上都試試,尋求點兒自我安慰(萬一真的發現了什麼呢!!)。

於是我用我的 Nexus 5(4.4.2)跑了一次,結果,嘿,是對的!!效果就跟我們文章開頭給出的圖是一樣的!

再來看下出問題的設備,三星 s6(5.0.1),Nexus 6(6.0.1),華為 P8(5.1)。。奇怪,難道是 5.0 以後引入的 Bug?搞笑呢,如果真是 Bug,那這可真夠熱鬧的,畢竟這麼基礎的東西,如果真的出了問題,那可就糗大了,Google 怎麼可能開這種玩笑。

既然不是 Bug,那肯定還是繪製順序的問題。排除了運行時布局被修改的可能性,也確認了沒有使用自定義繪製順序,可我們還是看不到究竟是什麼原因導致最終結果與我們預期的不一致。

想來想去,還是繪製順序的問題,一定是 5.0 以後引入了新的技術或者特性,而這個特性會對繪製順序有影響。

4、真相,讓人慾哭無淚

說起來 5.0 引入的新特性。。大名鼎鼎的 Material 主題算是吧,恰好我們的 app 也使用了這個主題。難道這一切的幕後黑手都是 Material ?

說時遲,那時快,小手一抖,抖開了 Material 的官網,看了一下才知道,所謂 Material 就是讓我們的控制項看起來更符合物理學原理,那按鈕實際上是有高度的。

這沒什麼吧,它說得好有道理啊。

接著看,看到了下面這個頁面:Elevation and shadows

頁面裡面給了一張圖,說按鈕會被抬高 2dp。

所以,如圖所示,如果原來紅線在白線下面,並且他們倆處於同一個高度,那麼紅線繪製出來之後一定在白線的下面。可現在人家被抬高了哎!就好比你和你哥們上同一所大學,還在同一個班,他的名次還在你後面,可人家參加過 ACM 還拿了獎,所以一畢業就被 Google(為什麼是Google?)看見了,搶走了。。。

好像真相大白了呢!!

<RelativeLayoutn android:layout_width_="wrap_content"n android:layout_height="wrap_content">nn <Buttonn android:id="@+id/hello"n android:enabled="true"n android:layout_width_="match_parent"n android:layout_height="300dp"n android:layout_margin="10dp"/>nn <TextViewn android:id="@+id/textView"n android:layout_width_="match_parent"n android:layout_height="200dp"n <!-- 你說不是啊,我還發了10份專利呢 -->n android:elevation="2dp"n android:background="#FF0000"/>nn</RelativeLayout>n

這樣,你倆又在同一起跑線上了,結果當然是:

沒啥好說的,繩命是如此滴精彩。。。不知道從什麼時候開始,Android 的屏幕不再是一個二維平面,三體裡面流行降維來求得生存,我們這個,越來越高大上了有木有。你以為你搬的還是磚,後來居上呢,可結果人家給你的不是磚,哈哈,既然 elevation 可以決定 View 的繪製高度,那麼是不是 translationZ 也可以呢?當然。

說到這裡,我們既然知道了 elevation,就不能不去它們家看看這個熊孩子。在 View 的構造方法裡面有下面一段:

if (transformSet) {n setTranslationX(tx);n setTranslationY(ty);n setTranslationZ(tz);n setElevation(elevation);n setRotation(rotation);n setRotationX(rotationX);n setRotationY(rotationY);n setScaleX(sx);n setScaleY(sy);n}n

這就是說,elevation 這個屬性與其他屬性一樣,都最終會寫入變化矩陣,最終影響繪製。

那麼你會說,如果現在就想要讓文章開頭的 TextView 繪製在上面,卻又不想總是去設置什麼 elevation 或者 translationZ,怎麼辦?

很簡單啊,給那個 Button 外麵包一層就好了。

<RelativeLayoutn android:layout_width_="wrap_content"n android:layout_height="wrap_content">nn <FrameLayoutn android:layout_width_="match_parent"n android:layout_height="wrap_content">nn <Buttonn android:id="@+id/hello"n android:enabled="true"n android:layout_width_="match_parent"n android:layout_height="300dp"n android:layout_margin="10dp"/>n </FrameLayout>nn <TextViewn android:id="@+id/textView"n android:layout_width_="match_parent"n android:layout_height="200dp"n android:background="#FF0000"/>nn</RelativeLayout>n

說到這裡,我必須要規範一種說法了:其實,繪製順序一致都沒有發生變化,elevation 和 translationZ 影響的實際上是繪製矩陣,從而導致我們在屏幕上看到的 View 的 實際順序 發生了變化——也就是說,前面我們總是在強調繪製順序,但實際上我們是從我們看到的結果(也即 實際順序)來推測其繪製順序,殊不知這二者並不是完全對應。

5、那個 View,不許動!

本來故事講到這裡就應該結束了。可,哪裡有那麼簡單。話說後來產品(產品的內心戲:為什麼讓我背鍋!!)改需求,說按鈕在上面沒有問題,不過要增加一個事件:點擊按鈕的時候,後面的那個 TextView 消失掉~

我說,哎喲,這簡單呀!

既然這次按鈕要在上面,那麼布局就不用那麼費勁了:

<RelativeLayoutn android:layout_width_="wrap_content"n android:layout_height="wrap_content">n <TextViewn android:id="@+id/textView"n android:layout_width_="match_parent"n android:layout_height="200dp"n android:background="#FF0000"/>nn <Buttonn android:id="@+id/hello"n android:layout_width_="match_parent"n android:layout_height="300dp"n android:layout_margin="10dp"n android:enabled="true"/>n</RelativeLayout>n

hello.setOnClickListener(new OnClickListener() {n @Overriden public void onClick(View view) {n final ViewGroup parent = (ViewGroup) textView.getParent();n Animation alpha = new AlphaAnimation(1f, 0f);n alpha.setDuration(500);n textView.startAnimation(alpha);n parent.removeView(textView);n }n});n

這個看上去沒啥問題呀,對吧,交差,耶!

產品:你給我回來!我這個 4.4.2 的設備上面有問題! 我:我擦嘞。。

仔細看,是不是點擊按鈕之後的一瞬間,下面的按個紅色的 View 跑到按鈕上面了?這尼瑪又是為啥!

難道是我用法不對??好,我用個 Animator 好不好,趕下潮流嘛。

hello.setOnClickListener(new OnClickListener(){n @Override public void onClick(View view){n final ViewGroup parent = (ViewGroup)textView.getParent();n parent.startViewTransition(textView);n textView.animate().alpha(0).setDuration(500).withEndAction(new Runnable(){n @Override public void run(){n parent.endViewTransition(textView); n }n }).start();n parent.removeView(textView);n }n});n

這下該沒問題了吧,要知道 FragmentManager 裡面在播放 Fragment 移除的動畫時也是這麼乾的。

產品:你怎麼都不自測一下,還是有問題啊 我:。。。

好吧,那我接著改。。

hello.setOnClickListener(new OnClickListener(){n @Override public void onClick(View view){n final ViewGroup parent = (ViewGroup)textView.getParent();n textView.animate().alpha(0).setDuration(500).withEndAction(new Runnable(){n @Override public void run(){n parent.removeView(textView); n }n }).start();n }n});n

這樣總可以了吧。。

可那麼問題來了,為什麼前兩種代碼有問題呢?明明看上去很正常的代碼。。不過對比一下,我們就會發現 parent.removeView 的時機不一樣,前兩個都是啟動動畫後立即調用,後者在動畫結束後才調用。那這究竟有什麼影響呢?

private void removeViewInternal(int index, View view) {nn //省略清理點擊事件之類的部分n if (view.getAnimation() != null ||n (mTransitioningViews != null && mTransitioningViews.contains(view))) {n addDisappearingView(view);n } else if (view.mAttachInfo != null) {n view.dispatchDetachedFromWindow();n }nn //省略不相關代碼n}n

removeView 方法最終會調用到上面的代碼,我們發現,如果被移除的 View 動畫不為空(適用於第一種情況),或者包含在 mTransitioningViews(第二種情況我們調用了 startViewTransition 會導致這個為 true),那麼這個會被當作 DisappearingView 來對待。這意味著什麼呢?

ViewGroup.dispatchDraw

...nfor (int i = 0; i < childrenCount; i++) {n ...n //繪製子 Viewn}n...nn//繪製剛才的 DisappearingView !nif (mDisappearingChildren != null) {n final ArrayList<View> disappearingChildren = mDisappearingChildren;n final int disappearingCount = disappearingChildren.size() - 1;n // Go backwards -- we may delete as animations finishn for (int i = disappearingCount; i >= 0; i--) {n final View child = disappearingChildren.get(i);n more |= drawChild(canvas, child, drawingTime);n }n}n...n

沒錯,請相信你的眼睛!所有被標記為 DisappearingView 的被移除的子 View 都會繪製在其他子 View 的上面(更嚴謹的說法是,會最後繪製,如果不考慮 elevation和 translationZ,那麼就是繪製在上面)。所以,額。。我能說這就是真相了么。

其實對於我們自己寫的代碼來說,這個問題嘛,換種思路總是可以解決的,比如採用第三種代碼;可是,就像我前面提到的,FragmentManager 在切換 Fragment 的時候也用了類似的寫法:

case Fragment.ACTIVITY_CREATED:n if (newState < Fragment.ACTIVITY_CREATED) {n ...n f.performDestroyView();n if (f.mView != null && f.mContainer != null) {n Animator anim = null;n if (mCurState > Fragment.INITIALIZING && !mDestroyed) {n anim = loadAnimator(f, transit, false,n transitionStyle);n }n if (anim != null) {n final ViewGroup container = f.mContainer;n final View view = f.mView;n final Fragment fragment = f;n container.startViewTransition(view);n f.mAnimatingAway = anim;n f.mStateAfterAnimating = newState;n anim.addListener(new AnimatorListenerAdapter() {n @Overriden public void onAnimationEnd(Animator anim) {n container.endViewTransition(view);n if (fragment.mAnimatingAway != null) {n fragment.mAnimatingAway = null;n moveToState(fragment, fragment.mStateAfterAnimating,n 0, 0, false);n }n }n });n anim.setTarget(f.mView);n setHWLayerAnimListenerIfAlpha(f.mView, anim);n anim.start();nn }n f.mContainer.removeView(f.mView);n }n f.mContainer = null;n f.mView = null;n }n

也就是說,如果你在切換 Fragment 的時候,想要加個轉場動畫,那麼無論如何你看到的都會是新的 Fragment 在下面,舊的在上面,這就比較坑了,當然也不是完全沒有辦法,我們可以自定義一個 CustomLayout 在 dispatchDraw 之前先把其中的 mDisappearingChildren 使用反射處理掉,然後按照自己的需要選擇合適的時機去繪製他們就好了——只是涉及到反射,多少會有風險。

6、小結

本文主要關注點在於一些特殊情況下的 View 的繪製順序和實際順序的問題:

  • 通常我們的 View 按照布局的順序繪製,有自定義順序的按照自定義順序進行繪製
  • 實際順序也會受到 translationZ 的影響
  • 涉及到 Material 主題時,要考慮 elevation 對實際順序的影響
  • 有被移除的 View 要考慮是否存在動畫導致其對繪製順序的影響

推薦閱讀:

從事 Android 應用開發4年有餘,現在工資7500。很不爽!怎麼辦?
為什麼程序會「卡」?
谷歌重拳出擊為安卓用戶隱私護航

TAG:Android |