1. 程式人生 > >一步步分析知乎中CoordinatorLayout的實現

一步步分析知乎中CoordinatorLayout的實現

640?wx_fmt=png&wxfrom=5&wx_lazy=1

今日快訊

針對近日外界對微信使用者資訊保安的熱議,騰訊集團高階執行副總裁兼微信事業群總裁張小龍近日在微信公開課Pro版上回應稱,“我們不會看微信聊天記錄。微信釋出的第一天,我們就這樣設計了。”

作者簡介

本篇來自 我不是死胖子 的投稿,分享了知乎的 CoordinatorLayout 的實現,一起來看看!

我不是死胖子  的部落格地址:

https://www.jianshu.com/u/8aaf1247f484

前言

CoordinatorLayout(協調員佈局)能實現一種類似協調檢視之間改變效果的佈局,文章分為三部分:

1. CoordinatorLayout 概念的簡介

2. 仿寫知乎自定義簡單 behavior(非gif演示)

3. 完整的拆包分析知乎和仿寫知乎包括 MainActivity, 首頁回答的 ListFragment, 和點選首頁回答開啟的回答詳情的 DetailFragment (gif演示的實現)

控制元件效果圖如下所示:

0?wx_fmt=gif

文章中的demo地址如下:

https://github.com/sunxlfred/FindZhihu

協調員 CoordinatorLayout

概念

鋪墊: 正常的事件分發傳遞, 如果某一層 View 消費掉了 Down 事件。之後的 MOVE, UP 事件都是傳遞到這一層, 無法直接實現子 view 和其他子 view 的「協調」。於是谷歌在15年的DesignSupportLibrary 包 Revision 22.2.0 加入了 CoordinatorLayout。

作為「協調員」的核心外部控制元件 CoordinatorLayout: 是實現了 NestedScrollingParent 介面的 ViewGroup。根據谷歌的描述, 它作為一個頂級佈局, 同時作為子檢視互動的容器 FrameLayout。

那麼它的子檢視有哪些呢?

  • 被依賴子View, 即可滾動的view, 實現 NestedScrollingChild 的子 view 。(RecyclerView 已實現)

  • 依賴子View, 如 AppbarLayout (隨著滑動的進行而跟著操作的 view )

而依賴子 view 依靠 behavior 控制. 谷歌對於 behavior 的描述是: CoordinatorLayout 的子view 的互動行為外掛, 一個 behavior 可以實現一個或者多個互動。

Behavior 裡的方法

分類1: view 監聽另一個 view 的狀態變化, 比如大小, 位置, 顯示。

  • onDependentViewChanged 響應被依賴子View的變化

  • layoutDependsOn 用來確定子View是否有另一個同級的View作為佈局從屬

分類2: view 監聽 CoordinatorLayout 裡的滑動狀態。

  • onStartNestedScroll  我們要不要關心, 要關心什麼樣的滑動

  • onNestedPreScroll 在巢狀滑動進行時,物件消費滾動距離前回調(使用的最頻繁)

「協調」Demo-簡單Behavior連結:

http://blog.csdn.net/qibin0506/article/details/50290421

下面我們開始仿造知乎的詳情頁來自定義一個 Behavior。

 知乎 Behavior 歸納

MainActivity 裡的 Listfragment (首頁)

  • 滑動一定距離就隱藏

  •  觸發效果後, 手不鬆, 在新的位置重置觸發第1步所需的距離

  • 和點選無關, 如果嘗試過預設的behavior就會發現很像系統預設的 behavior。

app:layout_behavior="@string/appbar_scrolling_view_behavior"

詳情 DetailFragment

  • 檔案內容夠長才開啟滑動

  • 剛進來時底view顯示 

  • 快速滑動才顯示/隱藏view

  • 底view隱藏時, 單擊顯示/隱藏頂和底view

  • 下拉到底放出頂和底view

MainActivity 的底部 TabLayout 再分析

  • 如果極慢的滑動會發現, 對於同一個動畫效果, 底部 TabLayout先於頂部 Toolbar 執行, 可以得出app第一頁所看到的頂部和底部不是一個behavior控制的

我們首先用behavior來寫一個仿知乎的效果

自定義 behavior 仿知乎佈局

建立 CoordinatorLayout 佈局

<CoordinatorLayout>
     <RecycleView/>
         <LinearLayout          app:layout_behavior="@string/my_behavior"/>            
</CoordinatorLayout>

如果一定要 listview, 請判斷版本 api21 後再 setNestedScrollingEnabled

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
{
   listView.setNestedScrollingEnabled(true);
}

自定義底部隱藏的動畫

public class MyBehaviorAnim { 

    private View mBottomView; 
    private float mOriginalY; 

    public BottomBehaviorAnim(View bottomView) { 
        mBottomView = bottomView; 
        mOriginalY = mBottomView.getY(); 
    } 


    public void show() { 
        ValueAnimator animator = ValueAnimator.ofFloat(mBottomView.getY(), mOriginalY); 
        animator.setDuration(400); 
        animator.setInterpolator(new LinearOutSlowInInterpolator()); 
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 
            @Override 
            public void onAnimationUpdate(ValueAnimator valueAnimator) { 
                mBottomView.setY((Float) valueAnimator.getAnimatedValue()); 
            } 
        }); 
        animator.start(); 
    } 


    public void hide() { 
        ValueAnimator animator = ValueAnimator.ofFloat(mBottomView.getY(), mOriginalY + mBottomView.getHeight()); 
        animator.setDuration(400); 
        animator.setInterpolator(new LinearOutSlowInInterpolator()); 
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 
            @Override 
            public void onAnimationUpdate(ValueAnimator valueAnimator) { 
                mBottomView.setY((Float) valueAnimator.getAnimatedValue()); 
            } 
        }); 
        animator.start(); 
    } 
}

自定義底部隱藏的 Behavior

  • 判斷手勢

  • 計算距離

  • 觸發動畫

public class MyBehavior extends CoordinatorLayout.Behavior<View> { 

    protected BottomBehaviorAnim mBottomAnim; 
    private boolean isHide; 
    private boolean canScroll = true; 
    private int mTotalScrollY; 
    protected boolean isInit = true; //防止new Anim導致的parent 和child座標變化 

    private int mDuration = 400; 
    private Interpolator mInterpolator = new LinearOutSlowInInterpolator(); 
    private int minScrollY = 5;//觸發滑動動畫最小距離 
    private int scrollYDistance = 40;//設定最小滑動距離 

    //1. 必須重寫兩個引數的構造方法, 因為behavior的例項化�是反射這個構造方法實現的 
    public BottomBehavior(Context context, AttributeSet attrs) { 
        super(context, attrs); 
    } 

    //2. 關心誰 
    @Override 
    public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) { 
        return super.layoutDependsOn(parent, child, dependency); 
    } 

    /** 
     * 觸發滑動巢狀滾動之前呼叫的方法 
     * 
     * @param coordinatorLayout coordinatorLayout父佈局 
     * @param child             使用Behavior的子View 
     * @param target            觸發滑動巢狀的View(實現NestedScrollingChild介面) 
     * @param dx                滑動的X軸距離 
     * @param dy                滑動的Y軸距離 
     * @param consumed          父佈局消費的滑動距離,consumed[0]和consumed[1]代表X和Y方向父佈局消費的距離,預設為0 
     */ 
    @Override 
    public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, View child, View target, 
                                  int dx, int dy, int[] consumed) { 
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed); 
    } 

    /** 
     * 滑動巢狀滾動時觸發的方法 
     * 
     * @param coordinatorLayout coordinatorLayout父佈局 
     * @param child             使用Behavior的子View 
     * @param target            觸發滑動巢狀的View 
     * @param dxConsumed        TargetView消費的X軸距離 
     * @param dyConsumed        TargetView消費的Y軸距離 
     * @param dxUnconsumed      未被TargetView消費的X軸距離 
     * @param dyUnconsumed      未被TargetView消費的Y軸距離(如RecyclerView已經到達頂部或底部,而使用者繼續滑動,此時dyUnconsumed的值不為0,可處理一些越界事件) 
     */ 
    @Override 
    public void onNestedScroll(CoordinatorLayout coordinatorLayout, View child, View target, 
                               int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) { 
        super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed); 
        if (canScroll) { 
            mTotalScrollY += dyConsumed; 
            if (Math.abs(dyConsumed) > minScrollY || Math.abs(mTotalScrollY) > scrollYDistance) { 
                if (dyConsumed < 0) { 
                    if (isHide) { 
                        mBottomAnim.show(); 
                        isHide = false; 
                    } 
                } else if (dyConsumed > 0) { 
                    if (!isHide) { 
                        mBottomAnim.hide(); 
                        isHide = true; 
                    } 
                } 
                mTotalScrollY = 0; 
            } 
        } 
    } 

    @Override 
    public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View directTargetChild, @NonNull View target, int nestedScrollAxes) { 
        if (isInit) { 
            mBottomAnim = new BottomBehaviorAnim(child); 
            isInit = false; 
        } 
        return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; 
    } 

    @Override 
    public void onStopNestedScroll(CoordinatorLayout coordinatorLayout, View child, final View target) { 
        super.onStopNestedScroll(coordinatorLayout, child, target); 
    } 

}

關聯 xml 和 behavior
先在 string 裡新增 behavior, 再在依賴子 view 中新增

<resources>  
    <string name="my_behavior">com.clickdemo.Behavior.MyBehavior</string>  
</resources>
<LinearLayout 
  app:layout_behavior="@string/my_behavior" />

思考: 自定義 behavior 確實能做到類似知乎的隱藏 TabView 的效果, 但是知乎真的是這麼實現的嗎? Activity 和 Fragment 都用上了 CoordinatorLayout? 

知乎拆包靜態分析

準備

  • 分析物件

知乎的首頁和回答詳情頁.

  • 材料

拆包拿到的 xml 等檔案(4.1.8包來源於酷安app端的歷史版本)

Luyten (檢視.jar檔案裡的混淆後的 java 程式碼)

Hierarchy View (AndroidStudio 檢視檢視工具)

  • 前提

拆包拿到的activity_main.xml中可以看到, 底部的com.zhihu.android.base.widget.ZHTabLayout 用到了 layout_behavior。

<?xml version="1.0" encoding="utf-8"?>  
<com.zhihu.android.app.ui.widget.ZHInsetsFrameLayout    android:id="@id/content_container"    android:tag="layout/activity_main_0"    android:layout_width="fill_parent"    android:layout_height="fill_parent"    xmlns:android="http://schemas.android.com/apk/res/android"    xmlns:zhihu="http://schemas.android.com/apk/res-auto">      <com.zhihu.android.app.ui.widget.reveal.widget.RevealFrameLayout          android:background="?zhihu.background.window"          android:layout_width="fill_parent"          android:layout_height="fill_parent">          <android.support.design.widget.CoordinatorLayout              android:id="@id/coordinator_layout"              android:layout_width="fill_parent"              android:layout_height="fill_parent">              <com.zhihu.android.base.widget.NonSwipeableViewPager                  android:id="@id/main_pager"                  android:layout_width="fill_parent"                 android:layout_height="fill_parent" />              <FrameLayout                   android:id="@android:id/content"                   android:layout_width="fill_parent"                   android:layout_height="fill_parent"                   zhihu:layout_behavior="com.zhihu.android.base.widget.SnackBarBehavior"                   zhihu:layout_anchorGravity="end|center|bottom" />              <com.zhihu.android.base.widget.ZHTabLayout                   android:layout_gravity="end|bottom|center"                   android:id="@id/main_tab"                   android:background="?zhihu.background.navigation.tab.bottom"                   android:layout_width="fill_parent"                   android:layout_height="@dimen/bottom_navigation_height"                   zhihu:layout_behavior="com.zhihu.android.base.widget.FooterBehavior"                   zhihu:layout_anchorGravity="end|center|bottom"                   zhihu:tabIndicatorColor="@android:color/transparent"                   zhihu:tabGravity="fill" />          </android.support.design.widget.CoordinatorLayout>      </com.zhihu.android.app.ui.widget.reveal.widget.RevealFrameLayout>      <com.zhihu.android.base.widget.ZHFrameLayout           android:id="@id/overlay_container"           android:layout_width="fill_parent"           android:layout_height="fill_parent" />
</com.zhihu.android.app.ui.widget.ZHInsetsFrameLayout>
  • 推測

MainActivity 包著四個Fragment, 底部依靠「tabLayout」切換, 然後進入詳情頁把內容都換一遍, 然後每一頁一個 CoordinatorLayout?

  • 目的

拿到詳情和首頁 Fragment 的 xml 和java 檔案。

用 Hierarchy View 拿到 view 的 id

模擬器開啟 app, 進入首頁的一個回答下, 用 Hierarchy View (在 Android Studio自帶的 tool -> Android Device Monitor) 發現是 CoordinatorLayout 包著 NonSwipeableViewPager + +ZHFrameLayout + ZHTabLayout , 之後進入詳情頁也是替換了 NonSwipeableViewPager 裡的 FrameLayout。

0?wx_fmt=png

activity_main 的效果如上圖, 此時的 FrameLayout 顯示的是首頁 ListFragment

0?wx_fmt=png

上圖是 FrameLayout 顯示的是回答詳情 DetailFragment

疑問1: 等等, 只有一個 CoordinatorLayout?

之前猜的是有多個 CoordinatorLayout 啊?

常規使用下, CoordinatorLayout 的子 view 的子 view,只能跟著上一級 view 整體實現協調, 即使 AppBarLayout, 協調的效果也是固定的幾種

所以知乎 Fragment 裡底部的 view 和頭部的 toolbar 是不依賴 CoordinatorLayout 進行協調的?

還是先找到 Activity 和 Fragment.java 檔案吧

根據 view 的 id 來找在哪些 xml 用到了它

接下來我們用之前反編譯好的資源開始尋找, 根據第1步, 我們看到了一個看似根佈局的view, FrameInterceptLayout。那就在 res/layout 裡搜尋下。果然用到 FrameInterceptLayout 的地方不是很多。而這 fragment_pager 和fragment_pager_2 看起來很可疑。

0?wx_fmt=png

根據 xml 的 id 來找在哪些 java 檔案裡用到了它

那接下來看下哪些地方用到了「fragment_pager」, 通過 Android 逆向之旅 可以得出: apk被反編譯後會產生一個至關重要的 public.xml 檔案, 就在 res/values/public.xml 下, 開啟後搜一下, 哈, 找到你了,面碼, 0x7f0400be 和 0x7f0400bf。

0?wx_fmt=png

但是這是倆 十六進位制 的東西啊. 對, Android逆向之旅裡也告訴我們怎麼看 0x7f0400be:

這裡可以看到,一個id欄位,都有對應的型別,名稱,和id值的

而這裡的id值是一個整型值,8個位元組;由三部分組成的:

PackageId+TypeId+EntryId

PackageId:是包的 Id 值,Android 中如果是第三方應用的話,這個值預設就是 0x7F,系統應用的話就是 0x01,具體我們可以後面看 aapt 原始碼得知,他佔用兩個位元組。

TypeId:是資源的型別Id值,一般 Android 中有這幾個型別:attr,drawable,layout,dimen,string,style 等,而且這些型別的值是從1開始逐漸遞增的,而且順序不能改變,attr=0x01,drawable=0x02….他佔用兩個位元組。

EntryId:是在具體的型別下資源實體的 id 值,從0開始,依次遞增,他佔用四個位元組。

那就用計算器轉換一下, 拿到十進位制的 2130968766 和 2130968767

然後開啟 Luyten (替代 JD_GUI, 有些檔案 JD_GUI 開啟後是一片空白)。開啟一個 jar 檔案, 先搜尋一下 2130968766, 找到了。

0?wx_fmt=png

看到 databinding 感嘆下, 原來知乎早就上了databinding.

不過我們要的是下面這個 com/zhihu/app/ui/fragment/b/i.class...

找到了這句話

public View a(final LayoutInflater layoutInflater, final ViewGroup viewGroup, final Bundle bundle) { 
    this.b = android.databinding.e.a(layoutInflater, 2130968766, viewGroup, false); 
    return this.b.h(); 
}

可以猜到這句話就是 fragment 的 onCreateView 啦!這就拿到了主頁 Fragment 的 java 檔案和 xml 檔案了。同理拿到了 詳情頁的 ZHObservableWebView, 首頁列表的ZHRecyclerView extends ObservableRecyclerView。這個頻繁出現的 ObservableXXXView 根據包名, 可以找到這個專案 ksoichiro/Android-ObservableScrollView:

https://github.com/ksoichiro/Android-ObservableScrollView

當然了, 如果在第1步的 HierarchyView 裡能找到特殊的 id 的 view 可以直接搜尋, 比如詳情頁可以靠 fragment_paging_layout 搜到。詳情頁的 view 是 fragment_paging.xml, 如下圖

0?wx_fmt=png

破案了! 結合疑問1, 可以得出:

知乎的 Fragment 們的協調不是通過 CoordinatorLayout 的 behavior, 而是使用了觀察者模式的開源專案。

總結

這裡先總結一下:

  • MainActivity 的底部 TabLayout 使用的是 CoordinatorLayout 的 layout_behavior

  • 首頁ListFragment 的頂部toolbar可能是 ZHObservableRecyclerView 也可能是 FrameInterceptLayout控制, xml檔案是 fragment_paging, Java 檔案在

  • 首頁詳情 DetailFragment 的頂部和底部是 ZHObservableWebView 控制 xml檔案是 fragment_pager, Java 檔案在com/zhihu/app/ui/fragment/b/i.class

為啥首頁詳情頁這麼肯定是 ZHObservableWebView 控制的呢, 因為在Luyten裡檢視到的知乎原始碼中重寫了onScrollChange 方法, 多返回了int l, t, oldl, oldt 四個引數, 而我自己在寫的時候發現也需要這四個引數才方便實現結果.

大致效果如圖:

0?wx_fmt=jpeg

具體Demo 地址:

https://github.com/sunxlfred/FindZhihu

核心部分原理描述:

  • 從 MainActivity 的 ListFragment 切換到 DetailFragment 時, 把 ListFragment 的 toolbar 和 MainActivity 的 tabLayout 恢復原樣(先 GONE 掉).

  • 從 DetailFragment 切換回 ListFragment 時, 直接VISIBLE 底部tabLayout。

彎路

  • 如何只在快速下滑時才觸發 behavior 的 onNestedScroll(仿知乎詳情頁)

  • 原思路: 通過 event 監聽手勢Y軸速度. 傳給behavior. 又因為「協調」時 onTouchEvent 無法接收到事件, behavior 不屬於 ViewGroup, 無法在 behavior 裡呼叫 dispatchTouchEvent, 只能在 Activity 裡調, 所以採用 activity 監聽的 dispatchTouchEvent 裡 event 的 Y 軸速度, 再回調給 behavior。

  • 現思路: 其實 onNestedScroll 的引數 dyConsumed (Y 軸偏移量的大小)就是速度...

  • 如何判斷滑到底

  • 原思路: 在 「協調」監聽 onNestedScroll 裡, dyConsumed == 0 時表示滑到邊界了, dyUnconsumed > 0 表示滑到邊界了還在下拉, 所以通過( dyConsumed == 0 && dyUnconsumed > 0)來判斷

  • 現思路: boolean view.canScrollVertically(1) 的返回值表示能夠下拉, -1的返回值表示上拉

  • 詳情 Fragment 覆蓋了本該在前面的 TabLayout, 即如何讓 xml 中被 TabLayout 擋住的ViewPager, 反過來擋住 TabLayout.

  • 原思路: 看到原始碼中 afollestad/material-dialogs, 誤以為新開的 Fragment 都是 DialogFragment

  • 原思路: 通過 View.bringToFront

  • 現思路: DialogFragment 在 Hierarchy View 裡能看到多開了一個 MainActivity 程序。裡面重新從 DecorView 開始的佈局, View.bringToFront 即使不開啟新的 DecorView, 也改變了原程序, 無法在 Hierarchy View上與知乎的佈局一樣。

  • 為啥不直接使用 dispatchTouchEvent?

  • behavior 裡沒有重寫 dispatchTouchEvent

  • 為啥不 onTouchEvent?

  • behavior 裡在拿到一段 ACTION_MOVE 後會攔截掉直接扔給 Behavior 一個 ACTION_CANCEL

歡迎長按下圖 -> 識別圖中二維碼

或者 掃一掃 關注我的公眾號

640.png?

0?wx_fmt=jpeg