Android 複雜的列表視圖新寫法 MultiType

轉載地址:http://gank.io/post/5823bcf6421aa90e799ec2ad


前言

在開發我的 TimeMachine 時,我有一個複雜的聊天頁面,於是我設計了我的類型池系統,它是完全解耦的,因此我能夠輕鬆將它抽離出來分享,並給它取名爲 MultiType.

從前,比如我們寫一個類似微博列表頁面,這樣的列表是十分複雜的:有純文本的、帶轉發原文的、帶圖片的、帶視頻的、帶文章的等等,甚至穿插一條可以橫向滑動的好友推薦條目。不同的 item 類型衆多,而且隨着業務發展,還會更多。如果我們使用傳統的開發方式,經常要做一些繁瑣的工作,代碼可能都堆積在一個 Adapter 中:我們需要覆寫 RecyclerView.Adapter 的 getItemViewType 方法,羅列一些 type 整型常量,並且 ViewHolder 轉型、綁定數據也比較麻煩。一旦產品需求有變,或者產品設計說需要增加一種新的 item 類型,我們需要去代碼堆裏找到我們原來的邏輯去修改,或者找到正確的位置去增加代碼。這些過程都比較繁瑣,侵入較強,需要小心翼翼,以免改錯影響到其他地方。

現在好了,我們有了 MultiType,簡單來說,MultiType 就是一個多類型列表視圖的中間分發框架,它能幫助你快速並且清晰地開發一些複雜的列表頁面。它本是爲聊天頁面開發的,聊天頁面的消息類型也是有大量不同種類,並且新增頻繁,而 MultiType 能夠輕鬆勝任,代碼模塊化,隨時可拓展新的類型進入列表當中。它內建了 類型 - View 的複用池系統,支持 RecyclerView,使用簡單靈活,令代碼清晰、擁抱變化。

因此,我寫了這篇文章,目的有幾個:一是以作者的角度對 MultiType 進行入門和進階詳解。二是傳遞我開發過程中的思想、設計理念,這些偏細膩的內容,即使不使用 MultiType,想必也能帶來很多啓發。最後就是把我自覺得不錯的東西分享給大家,試想如果你製造的東西很多人在用,即使沒有帶來任何收益,也是一件很自豪的事情。

目錄

MultiType 的特性

  • 輕盈,整個類庫只有 10 個類文件,aar 或 jar 包大小隻有 10KB
  • 周到,支持 局部類型池 和 全局類型池,並支持二者共用,當出現衝突時,以局部的爲準
  • 靈活,幾乎所有的部件(類)都可被替換、可繼承定製,面向接口/抽象編程
  • 純粹,只負責本分工作,專注多類型的列表視圖 類型分發
  • 高效,沒有性能損失,內存友好,最大限度發揮 RecyclerView 的複用性
  • 可讀,代碼清晰乾淨、設計精巧,極力避免複雜化,可讀性很好,爲拓展和自行解決問題提供了基礎

總覽

MultiType 能輕鬆實現如下頁面,它們將在示例篇章具體提供:

MultiType 的源碼關係:

MultiType 基礎用法

可能有的新手看到以上特性介紹說什麼 "衝突"、抽象編程的,還有那看不懂的總覽圖,都是一臉懵逼,完全不要緊,不懂可以回過頭來再看,我們先從基礎用法入手,其實 MultiType 使用起來特別簡單。使用 MultiType 一般情況下只要 maven 引入 + 三個小步驟。之後還會介紹使用插件生成代碼方式,步驟將更加簡化:

引入

在你的 build.gradle:

dependencies {
    compile 'me.drakeet.multitype:multitype:2.2.2'
}

注:MultiType 內部引用了 recyclerview-v7:24.2.1,如果你不想使用這個版本,可以使用 exclude 將它排除掉,再自行引入你選擇的版本。示例如下:

dependencies {
    compile('me.drakeet.multitype:multitype:2.2.2', {
       exclude group: 'com.android.support'
    })
    compile 'com.android.support:recyclerview-v7:你選擇的版本'
}

使用

Step 1. 創建一個 class,它將是你的數據類型或 Java bean/model. 對這個類的內容沒有任何限制。示例如下:

public class Category {

    @NonNull public String text;

    public Category(@NonNull final String text) {
        this.text = text;
    }
}

Step 2. 創建一個 class 繼承 ItemViewProvider.

ItemViewProvider 是個抽象類,其中 onCreateViewHolder 方法用於生產你的 Item View Holder, onBindViewHolder 用於綁定數據到Views. 一般一個 ItemViewProvider 類在內存中只會有一個實例對象,MultiType 內部將複用這個 provider 對象來生產所有相關的 Item Views 和綁定數據。示例:

public class CategoryViewProvider
    extends ItemViewProvider<Category, CategoryViewProvider.ViewHolder> {

    @NonNull @Override
    protected ViewHolder onCreateViewHolder(
        @NonNull LayoutInflater inflater, @NonNull ViewGroup parent) {
        View root = inflater.inflate(R.layout.item_category, parent, false);
        return new ViewHolder(root);
    }

    @Override
    protected void onBindViewHolder(
        @NonNull ViewHolder holder, @NonNull Category category) {
        holder.category.setText(category.text);
    }

    static class ViewHolder extends RecyclerView.ViewHolder {

        @NonNull private final TextView category;

        ViewHolder(@NonNull View itemView) {
            super(itemView);
            this.category = (TextView) itemView.findViewById(R.id.category);
        }
    }
}

Step 3. 好了,你不必再創建新的類文件了,在 Activity 中加入 RecyclerView 和 List 並註冊你的類型就完事了,示例:

public class MainActivity extends AppCompatActivity {

    private MultiTypeAdapter adapter;

    /* Items 等價於 ArrayList<Object> */
    private Items items;

    @Override protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        RecyclerView recyclerView = (RecyclerView) findViewById(R.id.list);

        items = new Items();
        adapter = new MultiTypeAdapter(items);

        /* 註冊類型和 View 的對應關係 */
        adapter.register(Category.class, new CategoryViewProvider());
        adapter.register(Song.class, new SongViewProvider());

        /* 模擬加載數據,也可以稍後再加載,然後使用
         * adapter.notifyDataSetChanged() 刷新列表 */
        for (int i = 0; i < 20; i++) {
            items.add(new Category("Songs"));
            items.add(new Song("小艾大人", R.drawable.avatar_dakeet));
            items.add(new Song("許岑", R.drawable.avatar_cen));
        }

        recyclerView.setAdapter(adapter);
    }
}

大功告成!這就是 MultiType 的基礎用法了,簡單、符合直覺。其中 onCreateViewHolder 和 onBindViewHolder 方法名沿襲了使用RecyclerView 的習慣,令人一目瞭然,減少了新人的學習成本。

設計思想

MultiType 設計伊始,我給它定了幾個原則:

  • 要簡單,便於他人閱讀代碼

    因此我極力去避免將它複雜化,比如引入顯性 item id 機制(MultiType 內部有隱性 id),比如加入許多不相干的內容,比如使用 apt + 註解完成類型和 View 自動綁定、自動註冊,再比如,使用反射。這些我都是拒絕的。我想寫人人可讀的代碼,使用簡單的方式,去實現複雜的需求。過多不相干、沒必要的代碼,將會使項目變得令人暈頭轉向,難以閱讀,遇到需要定製、解決問題的時候,無從下手。

  • 要靈活,便於拓展和適應各種需求

    很多人會得意地告訴我,他們把 MultiType 源碼精簡成三四個類,甚至一個類,以爲代碼越少就是越好,這我也是不能贊同的。MultiType 考慮得比他們更遠,這是一個提供給大衆使用的類庫,過度的精簡只會使得靈活性大幅失去。它或許不是使用起來最簡單的,但很可能是使用起來最靈活的。 在我看來,靈活性的優先級大於簡單性。因此,MultiType 各個組件都是以接口或抽象進行連接,這意味着它所有的角色、組件都可以被替換,或者被拓展和繼承。如果你覺得它使用起來還不夠簡單,完全可以通過繼承來封裝出更具體符合你使用需求的方法。它已經暴露了足夠豐富、周到的接口以供自行實現,我們不應該直接去修改源碼,這會導致一旦後續發現你的精簡版滿足不了你的需求時,已經沒有回頭路了。

  • 要直觀,使用起來能令項目代碼更清晰、模塊化

    MultiType 提供的 ItemViewProvider 沿襲了 RecyclerView Adapter 的接口命名,使用起來更加舒適,符合習慣。另外,手動寫一個新的 ItemViewProvider 需要提供了 類型 泛型,雖然略微有點兒麻煩,但能帶來一些好處,指定泛型之後,我們不再需要自己做強制轉型,而且代碼能夠顯式表明 ItemViewProvider 和 item class 的對應關係,簡單直觀。另外,現在我們有 MultiTypeTemplates 插件來自動生成代碼,這個過程變得更加順滑簡單。

高級用法

介紹了基礎用法和設計思想後,我們可以來介紹一下 MultiType 的高級用法。這是一些典型需求和案例,它們是基礎用法的延伸,也是設計思想的體現。也許一開始並不會使用到,但如若瞭解,能夠拓寬使用 MultiType 的思路,並且其中也分享了許多有意思的內容和考慮問題的角度。

使用 MultiTypeTemplates 插件自動生成代碼

在基礎用法中,我們了通過 3 個步驟完成 MultiType 的初次接入使用,實際上這個過程可以更加簡化,MultiType 提供了 Android Studio 插件來自動生成代碼:MultiTypeTemplates,源碼也是開源的,https://github.com/drakeet/MultiTypeTemplates,不僅提供了一鍵生成 item 類文件和 ItemViewProvider,而且是一個很好的利用代碼模版自動生成代碼的示例。其中使用到了官方提供的代碼模版 API,也用到了我自己發明的更靈活修改模版內容的方法,有興趣做這方面插件的可以看看。

話說回來,安裝和使用 MultiTypeTemplates 非常簡單:

Step 1. 打開 Android Studio 的設置 -> Plugin -> Browse repositories,搜索 MultiTypeTemplates 即可獲得下載安裝:

Step 2. 安裝完成後,重啓 Android Studio. 右鍵點擊你的 package,選擇 New -> MultiType Item,然後輸入你的 item 名字,它就會自動生成 item 模型類 和 ItemViewProvider 文件和代碼。

比如你輸入的是 "Category",它就會自動生成 Category.java 和 CategoryViewProvider.java.

特別方便,相信你會很喜歡它。未來這個插件也將會支持自動生成佈局文件,這是目前欠缺的,但不要緊,其實 AS 在這方面已經很方便了,對佈局 R.layout.item_category 使用 alt + enter 快捷鍵即可自動生成佈局文件。

使用 全局類型池

在基礎用法中,我們並沒有提到 全局類型池,實際上,MultiType 支持 局部類型池 和 全局類型池,並支持二者共用,當出現衝突時,以局部的爲準。使用局部類型池就如上面的示例,調用 adapter.register() 即可。而使用全局類型池也是很容易的,MultiType 提供了一個內置的 GlobalMultiTypePool 作爲全局類型池來存儲類型和 view 關係,使用如下:

只要在使用你的全局類型之前任意位置註冊類型,通過調用 GlobalMultiTypePool.register(...) 靜態方法完成註冊。推薦統一在Application 初始便進行註冊,這樣代碼便於尋找和閱讀。

之後回到你的 Activity,調用 adapter.applyGlobalMultiTypePool() 方法應用你註冊過的全局類型即可。

GlobalMultiTypePool 讓一些普適性的類型能夠全局共用,但使用全局類型池不當也會帶來問題,這是沒有全然採用全局類型池的原因。問題在於全局類型池是靜態的,如果你在 Activity 中註冊全局類型(雖然並不推薦。因爲全局類型最好統一在一個地方註冊,便於管理),並傳入帶 Activity 引用的變量進去,就可能造成內存泄露。舉個例子,如下是一個很常見的場景,我們把一個點擊回調傳遞給provider,並註冊到全局類型池:

public class LeakActivity extends Activity {

    private MultiTypeAdapter adapter;
    private Items items;

    @Override protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_leak);
        RecyclerView recyclerView = (RecyclerView) findViewById(R.id.list);
        items = new Items();
        adapter = new MultiTypeAdapter(items);

        OnClickListener listener = new OnClickListener() {
            @Override
            public void onClick(View v) {
                // ...
            }
        }

        /* 在 applyGlobalMultiTypePool 之前註冊全局 */
        GlobalMultiTypePool.register(Post.class, new PostViewProvider(listener));

        adapter.applyGlobalMultiTypePool(); // <- 使全局的類型加入到局部中來

        recyclerView.setAdapter(adapter);
    }
}

由於 Java 匿名內部類 或 非靜態內部類,都會默認持有 外部類 的引用,比如這裏的 OnClickListener 匿名類對象會持有LeakActivity.this,當 listener 傳遞給 new PostViewProvider() 構造函數的時候,GlobalMultiTypePool 內置的靜態類型池將長久持有 provider -> listener -> LeakActivity.this 引用鏈,若沒有及時釋放,就會引起內存泄露。

因此,在使用全局類型池時,最好不要給 provider 傳遞迴調對象或者外部引用,否則就應手動釋放或使用弱引用(WeakReference)。除此之外,全局類型池沒有什麼其他問題,類型池都只會持有 class 和非常輕薄的 provider 對象。我做過一個試驗,就算擁有上萬個類型和provider,內存佔用也是很少的,索引速度也很快,在主線程連續註冊一萬個類型花費不過 10 毫秒的時間,何況一般一個應用根本不可能有這麼多類型,完全不必擔心這方面的問題。

另外一個特性是,不管是全局類型池還是局部類型池,都支持重複註冊類型。當發現重複時,之後註冊的會把之前註冊的類型覆蓋掉,因此對於全局類型池,需要謹慎進行重複註冊,以免影響到其他地方。

一個類型對應多個 ViewProvider

注:本文所有的 ViewProvider 都指的是 ItemViewProvider.

MultiType 天然支持一個類型對應多個 ViewProvider,但僅限於在不同的列表中。比如你在 adapter1 中註冊了 Post.class 對應SinglePostViewProvider,在另一個 adapter2 中註冊了 Post.class 對應 PostDetailViewProvider,這便是一對多的場景。只要是在不同的局部類型池中,無論如何都不會相互干擾,都是允許的。

而對於在 同一個列表中 一對多的問題,首先這種場景非常少見,再者不管支不支持一對多,開發者都要去判斷哪個時候運用哪個ViewProvider,這是逃不掉的,否則程序就無所適從了。因此,MultiType 不去特別解決這個問題,如果要實現同一個列表中一對多,只要空繼承你的類型,然後把它視爲新的類型,註冊到你的類型池中即可

與 ViewProvider 通訊

ItemViewProvider 對象可以接受外部類型、回調函數,只要在使用之前,傳遞進去即可,例如:

OnClickListener listener = new OnClickListener() {
    @Override
    public void onClick(View v) {
        // ...
    }
}
adapter.register(Post.class, new PostViewProvider(xxx, listener));

但話說回來,對於點擊事件,能不依賴 provider 外部內容的話,最好就在 provider 內部完成。provider 內部能夠接收到 Views 和 數據,大部分情況下,完全有能力不依賴外部 獨立完成邏輯。這樣能使代碼更加模塊化,便於解耦,例如下面便是一個完全自包含的例子:

public class SquareViewProvider extends ItemViewProvider<Square, SquareViewProvider.ViewHolder> {

    @NonNull @Override
    protected ViewHolder onCreateViewHolder(
        @NonNull LayoutInflater inflater, @NonNull ViewGroup parent) {
        View root = inflater.inflate(R.layout.item_square, parent, false);
        return new ViewHolder(root);
    }

    @Override
    protected void onBindViewHolder(@NonNull ViewHolder holder, @NonNull Square square) {
        holder.square = square;
        holder.squareView.setText(valueOf(square.number));
        holder.squareView.setSelected(square.isSelected);
    }

    public class ViewHolder extends RecyclerView.ViewHolder {

        private TextView squareView;
        private Square square;

        ViewHolder(final View itemView) {
            super(itemView);
            squareView = (TextView) itemView.findViewById(R.id.square);
            itemView.setOnClickListener(new View.OnClickListener() {
                @Override public void onClick(View v) {
                    itemView.setSelected(square.isSelected = !square.isSelected);
                }
            });
        }
    }
}

使用斷言,比傳統 Adapter 更加易於調試

衆所周知,如果一個傳統的 RecyclerView Adapter 內部有異常導致崩潰,它的異常棧是不會指向到你的 Activity,這給我們開發調試過程中帶來了麻煩。如果我們的 Adapter 是複用的,就不知道是哪一個頁面崩潰。而對於 MultiTypeAdapter,我們顯然要用於多個地方,而且可能出現開發者忘記註冊類型等等問題。爲了便於調試,開發期快速失敗,MultiType 提供了很方便的斷言 API:MultiTypeAsserts,使用方式如下:

import static me.drakeet.multitype.MultiTypeAsserts.assertAllRegistered;
import static me.drakeet.multitype.MultiTypeAsserts.assertHasTheSameAdapter;

public class SimpleActivity extends MenuBaseActivity {

    private Items items;
    private MultiTypeAdapter adapter;

    @Override protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        RecyclerView recyclerView = (RecyclerView) findViewById(R.id.list);

        items = new Items();
        adapter = new MultiTypeAdapter(items);
        adapter.register(TextItem.class, new TextItemViewProvider());

        for (int i = 0; i < 20; i++) {
            items.add(new TextItem(valueOf(i)));
        }

        /* 斷言所有使用的類型都已註冊 */
        assertAllRegistered(adapter, items);
        recyclerView.setAdapter(adapter);
        /* 斷言 recyclerView 使用的是正確的 adapter */
        assertHasTheSameAdapter(recyclerView, adapter);
    }
}

assertAllRegistered 和 assertHasTheSameAdapter 都是可選擇性使用,assertAllRegistered 需要在加載或更新數據之後,assertHasTheSameAdapter 必須在 recyclerView.setAdapter(adapter) 之後。

這樣做以後,MultiTypeAdapter 相關的異常都會報到你的 Activity,並且會詳細註明出錯的原因,而如果符合斷言,斷言代碼不會有任何副作用或影響你的代碼邏輯,這時你可以把它當作廢話。關於這個類的源代碼也是很簡單,有興趣可以直接看看源碼:drakeet/multitype/MultiTypeAsserts.java

支持 Google AutoValue

AutoValue 是 Google 提供的一個在 Java 實體類中自動生成代碼的類庫,使你更專注於處理項目的其他邏輯,它可使代碼更少,更乾淨,以及更少的 bug.

當我們使用傳統方式創建一個 Java 模型類的時候,經常需要寫一堆 toString()hashCode()、getter、setter 等等方法,而且對於 Android 開發,大多情況下需要實現 Parcelable 接口。這樣的結果是,我本來想要一個只有幾個屬性的小模型類,但出於各種原因,這個模型類方法數變得十分繁複,閱讀起來很不清爽,並且難免會寫錯內容。AutoValue 的出現解決了這個問題,我們只需定義一些抽象類交給 AutoValue,AutoValue 會自動生成該抽象類的具體實現子類,並攜帶各種樣板代碼。

更詳細的介紹內容和使用教程,我會在文章末尾會給出 AutoValue 的相關鏈接,不熟悉 AutoValue 可以藉此機會看一下,在這裏就不做過多介紹了。新手暫時看不懂也不必糾結,瞭解之後都是十分容易的。

MultiType 支持了 Google AutoValue,支持自動映射某個已經註冊的類型的子類到同一 View Provider,規則是:如果子類註冊,就用註冊的映射關係;如果子類註冊,則該子類對象使用註冊過的父類映射關係。

對 class 進行二級分發

我的另外一個項目,即一開始提到的 TimeMachine,它是一個看起來特別像聊天軟件的 SDK,但還處於非常初期階段,大家可以不必太關心它。話說回來,在我的 TimeMachine 中,我的消息數據結構是 Message - MessageContentMessage 包含了 MessageContent. 因此產生了一個問題,我的 message 對象們都是一樣的 Message 類型,但 message 包含的 content 對象不一樣,我需要根據 content 來分發數據到 ItemViewProvider,但我加入 Items 中的數據都是 Message 對象,因此,如果什麼也不做,它們會被視爲同一類型。對於這種場景,我們可以繼承 MultiTypeAdapter 並覆寫 onFlattenClass(@NonNull Item message) 方法進行二級分發,以我的 MessageAdapter 爲例:

public class MessageAdapter extends MultiTypeAdapter {

    public MessageAdapter(@NonNull List<Message> messages) {
        super(messages);
    }


    @NonNull @Override public Class onFlattenClass(@NonNull Object message) {
        return ((Message) message).content.getClass();
    }
}

是不是十分簡單?這樣以後,我就可以直接將 MessageContent.class 註冊進類型池,而將包含不同 content 的 Message 對象 add 進Items List,MessageAdapter 會自動取出 message 的 content 對象,並以它爲基準定位 ItemViewProvider 同時會把整個 Message對象發給 providerprovider 可進行分層,如下:

public abstract class MessageViewProvider<C extends Content, V extends RecyclerView.ViewHolder>
    extends ItemViewProvider<Message, V> {

    @SuppressWarnings("unchecked") @Override
    protected void onBindViewHolder(@NonNull V holder, @NonNull Message message) {
        onBindViewHolder(holder, (C) message.content, message);
    }

    /* 留給子類的抽象方法 */
    protected abstract void onBindViewHolder(
        @NonNull V holder, @NonNull C content, @NonNull Message message);
}

總的來說,對 class 進行二級分發往往要伴隨着對 ItemViewProvider 進行二級處理,對此我給出了一個詳細的示例,到本文到 "示例" 章節中我們會再詳細介紹 ItemViewProvider 二級分發的場景和更具體運用。

MultiType 與下拉刷新、加載更多、HeaderView、FooterView、Diff

MultiType 設計從始至終,都極力避免往複雜化方向發展,一開始我的設計宗旨就是它應該是一個非常純粹的、專一的項目,而非各種亂七八糟的功能都要囊括進來的多合一大型庫,因此它很剋制,期間有許多人給我發過一些無關特性的 Pull Request,表示感謝,但全被拒絕了。

對於很多人關心的 下拉刷新、加載更多、HeaderView、FooterView、Diff 這些功能特性,其實都不應該是 MultiType 的範疇,MultiType 的分內之事是做類型、事件與 View 的分發、連接工作,其餘無關的需求,都是可以在 MultiType 外部完成,或者通過繼承 進行自行封裝和拓展,而作爲一個基礎、公共類庫,我想它是不應該包含這些內容。

但很多新手可能並不習慣代碼分工、模塊化,因此在此我有必要對這幾個點簡單示範下如何在 MultiType 之外去實現:

  • 下拉刷新:

    對於下拉刷新,Android 官方提供了 support.v4 SwipeRefreshLayout,在 Activity 層面,可以拿到 SwipeRefreshLayout 並setOnRefreshListener.

  • 加載更多:

    RecyclerView 提供了 addOnScrollListener 滾動位置變化監聽,要實現加載更多,只要監聽並檢測列表是否滾動到底部即可,有多種方式,鑑於 LayoutManager 本應該只做佈局相關的事務,因此我們推薦直接在 OnScrollListener 層面進行判斷。提供一個簡單版OnScrollListener 繼承類:

    public abstract class OnLoadMoreListener extends RecyclerView.OnScrollListener {
    
      private LinearLayoutManager layoutManager;
      private int itemCount, lastPosition, lastItemCount;
    
      public abstract void onLoadMore();
    
      @Override
      public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
          if (recyclerView.getLayoutManager() instanceof LinearLayoutManager) {
              layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
    
              itemCount = layoutManager.getItemCount();
              lastPosition = layoutManager.findLastCompletelyVisibleItemPosition();
          } else {
              Log.e("OnLoadMoreListener", "The OnLoadMoreListener only support LinearLayoutManager");
              return;
          }
    
          if (lastItemCount != itemCount && lastPosition == itemCount - 1) {
              lastItemCount = itemCount;
              this.onLoadMore();
          }
      }
    }
  • 獲取數據後做 Diff 更新:

    可以在 Activity 中進行 Diff,或者繼承 MultiTypeAdapter 提供接收數據方法,在方法中進行 Diff. MultiType 不提供內置 Diff 方案,不然需要依賴 v4 包,並且這也不應該屬於它的範疇。

  • HeaderView、FooterView

    MultiType 其實本身就支持 HeaderViewFooterView,只要創建一個 Header.class - HeaderViewProvider 和 Footer.class -FooterViewProvider 即可,然後把 new Header() 添加到 items 第一個位置,把 new Footer() 添加到 items 最後一個位置。需要注意的是,如果使用了 Footer View,在底部插入數據的時候,需要添加到 最後位置 - 1,即倒二個位置,或者把 Footer remove 掉,再添加數據,最後再插入一個新的 Footer.

實現 RecyclerView 嵌套橫向 RecyclerView

MultiType 天生就適合實現類似 Google Play 或 iOS App Store 那樣複雜的首頁列表,這種頁面通常會在垂直列表中嵌套橫向列表,其實橫向列表我們完全可以把它視爲一種 Item 類型,這個 item 持有一個列表數據和當前橫向列表滑動到的位置,類似這樣:

public class PostList {

    public final List<Post> posts;
    public int currentPosition;

    public PostList(@NonNull List<Post> posts) {this.posts = posts;}
}

對應的 HorizontalItemViewProvider 類似這樣:

public class HorizontalItemViewProvider
    extends ItemViewProvider<PostList, HorizontalItemViewProvider.ViewHolder> {

    @NonNull @Override
    protected ViewHolder onCreateViewHolder(
        @NonNull LayoutInflater inflater, @NonNull ViewGroup parent) {
        /* item_horizontal_list 就是一個只有 RecyclerView 的佈局 */
        View view = inflater.inflate(R.layout.item_horizontal_list, parent, false);
        return new ViewHolder(view);
    }

    @Override
    protected void onBindViewHolder(@NonNull ViewHolder holder, @NonNull PostList postList) {
        holder.setPosts(postList.posts);
    }

    static class ViewHolder extends RecyclerView.ViewHolder {

        private RecyclerView recyclerView;
        private PostsAdapter adapter;

        private ViewHolder(@NonNull View itemView) {
            super(itemView);
            recyclerView = (RecyclerView) itemView.findViewById(R.id.post_list);
            LinearLayoutManager layoutManager = new LinearLayoutManager(itemView.getContext());
            layoutManager.setOrientation(LinearLayoutManager.HORIZONTAL);
            recyclerView.setLayoutManager(layoutManager);
            /* adapter 只負責灌輸、適配數據,佈局交給 LayoutManager,可複用 */
            adapter = new PostsAdapter();
            recyclerView.setAdapter(adapter);
            /* 在此設置橫向滑動監聽器,用於記錄和恢復當前滑動到的位置,略 */
            ...
        }

        private void setPosts(List<Post> posts) {
            adapter.setPosts(posts);
            adapter.notifyDataSetChanged();
        }
    }
}

實現線性佈局和網格佈局混排列表

這個課題其實也不屬於 MultiType 的範疇,MultiType 的職責是做數據類型分發,而不是佈局,但鑑於很多複雜頁面都會需要線性佈局和網格佈局混排,我就簡單講一講,關鍵在於 RecyclerView 的 LayoutManager. 雖然是線性和網格混合,但實現起來其實只要一個網格佈局 GridLayoutManager,如果你查看 GridLayoutManager 的官方源碼,你會發現它其實繼承自 LinearLayoutManager. 以下是示例和解釋:

public class MultiGridActivity extends MenuBaseActivity {

    private final static int SPAN_COUNT = 5;
    private MultiTypeAdapter adapter;
    private Items items;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_multi_grid);
        items = new Items();
        RecyclerView recyclerView = (RecyclerView) findViewById(R.id.list);

        final GridLayoutManager layoutManager = new GridLayoutManager(this, SPAN_COUNT);

        /* 關鍵內容:通過 setSpanSizeLookup 來告訴佈局,你的 item 佔幾個橫向單位,
           如果你橫向有 5 個單位,而你返回當前 item 佔用 5 個單位,那麼它就會看起來單獨佔用一行 */
        layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
            @Override
            public int getSpanSize(int position) {
                return (items.get(position) instanceof Category) ? SPAN_COUNT : 1;
            }
        });
        recyclerView.setLayoutManager(layoutManager);

        adapter = new MultiTypeAdapter(items);
        adapter.applyGlobalMultiTypePool();
        adapter.register(Square.class, new SquareViewProvider());

        assertAllRegistered(adapter, items);
        recyclerView.setAdapter(adapter);
        loadData();
    }

    private void loadData() {
        // ...
    }
}

數據扁平化處理

在一個垂直 RecyclerView 中,item 們都是同級的,沒有任何嵌套關係,但我們的數據結構往往存在嵌套關係,比如 Post 內部包含了Comments 數據,或換句話說 Post 嵌套了 Comment,就像微信朋友圈一樣,"動態" 伴隨着 "評論"。那麼如何把 非扁平化 的數據排布在 扁平 的列表中呢?必然需要一個數據扁平化處理的過程,就像 ListView 的數據需要一個 Adapter 來適配,Adapter 就像一個油漏斗,把油引入瓶子中。我們在面對嵌套數據結構的時候,可以採用如下的扁平化處理,關於扁平化這個詞,不必太糾結,簡單說,就是把嵌套數據都拉出來,攤平,讓 Comment 和 Post 同級,最後把它們都 add 進同一個 Items 容器,交給 MultiTypeAdapter. 示例:

假設:你的 Post 是這樣的:

public class Post {

    public String content;
    public List<Comment> comments; 
}

假設:你的 Comment 是這樣的:

public class Comment {

    public String content;
}

假設:你服務端返回的 JSON 數據是這樣的:

[
    {
        "content":"I have released the MultiType v2.2.2", 
        "comments":[
            {"content":"great"},
            {"content":"I love your post!"}
        ]
    }
]

那麼你的 JSON 轉成 Java Bean 之後,你拿到手應該是個 List<Post> posts 對象,現在我們寫一個扁平化處理的方法:

private List<Object> flattenData(List<Post> posts) {
    final List<Object> items = new ArrayList<>();
    for (Post post : posts) {
        /* 將 post 加進 items,Provider 內部拿到它的時候,
         * 我們無視它的 comments 內容即可 */
        items.add(post);
        /* 緊接着將 comments 拿出來插入進 items,
         * 評論就能正好處於該條 post 下面 */
        items.addAll(post.comments);
    }
    return items;
}

最後我們所有的 posts 在加入全局 MultiType Items 之前,都需要經過扁平化處理:

items.addAll(flattenData(posts));
adapter.notifyDataSetChanged();

整個過程其實並不困難,相信大家都已經理解了。

更多示例

MultiType 的開源項目提供了許多的 sample (示例) 程序,這些示例秉承了一貫的代碼清晰、乾淨的風格,十分易於閱讀:

  • 仿造微博的數據結構和二級 ViewProvider

    這是一個類似微博數據結構的示例,數據兩層結構,Item 也是兩層結構:一層框架(包含頭像用戶名等),一層 content view(微博內容),內容嵌套於框架中。微博的每一條微博 item 都包含了這樣兩層嵌套關係,這樣做的好處是,你不必每個 item 都去重複製造一遍外層框架。

    或者換一個比喻,就像聊天消息,一條聊天消息也是兩層的,一層頭像、用戶名、聊天氣泡框,一層你的文字、圖片等。另外,每一種消息都有左邊和右邊的樣式,分別對應別人發來的消息和你發出的消息。如果左邊算一種,右邊又算一種,就是比較不好的設計了,會導致佈局內容重複、冗餘,修改操作都要做兩遍。最好的方案是讓他們視被爲同一種類型,然後在 item 框層次進行左右邊判斷和框架相關數據綁定。

    我提供的這個二級 ViewProvider 示例便是這樣的兩層結構。它能夠讓你每次新增加一個類型,只要實現內容即可,框不應該重複實現。

    如果再不明白,或許你可以看看我的這個示例中 微博 Item 框的佈局:

    從我這個 frame 佈局可以看出來,它內部有一個 FrameLayout 作爲 container 將用於容納不同的微博內容,而這一層框架則是共同的。

    這個例子算高級中的高級,但實際上也是很簡單,展示了 MultiType 優秀的可拓展能力。完整運行結果展示如下:

    注:以上我們並沒有提到服務端 JSON 數據轉爲我們定義的 Weibo 對象過程,實際上對於完整鏈路,這個過程是需要做數據轉換,我們需要在 WeiboContent 層加一個 type 或 describe 字段用於描述微博內容類型,然後再將微博內容的 JSON 文本轉爲具體微博內容對象交給 Weibo. 這個內容建議直接閱讀這個 sample 的 WeiboContentDeserializer 源碼,我利用了一種很簡單又巧妙的方式,在 JSON 解析底層便進行抽象數據具體化,使得客戶端和服務端都能夠輕鬆適應這種微博和微博內容嵌套關係。

  • drakeet/about-page

    一個 Material Design 的關於頁面,核心基於 MultiType,包含了多種 items,美觀,容易使用。

  • 線性和網格佈局混排

    使用 MultiType 和 GridLayoutManager 實現網格和線性混合佈局,實現一個選集頁面。

  • drakeet/TimeMachine

    TimeMachine 使用了 MultiType 來創建一個複雜的聊天頁面,頁面和需求雖然複雜,但使用 MultiType 顯得輕鬆簡單。

  • 類似 Bilibili iOS 端首頁

    使用 MultiType 實現類似 Bilibili iOS 端首頁複雜的多類型列表視圖,包括嵌套橫向 RecyclerView.

附:一個第三方示例,採用真實的網絡請求數據演示 MultiType 框架的用法 by WanLiLi. 點評:看了下,代碼不夠清爽,但實現效果十分不錯。

Q & A

  • Q: 全局類型池的主要作用是什麼,能取消全局的使用嗎?

    A: 全局類型池的主要作用是,註冊一些能夠各個地方複用的類型,可以存一些比如:Line、Space、Header、LoadMoreFooter. 默認情況下,全局類型池是不會生效的,只有你調用 adapter.applyGlobalMultiTypePool() 使用全局類型池,它纔會被應用,並加入到你當下的局部類型池中。沒有調用這一行代碼,全局的就不會參入你的局部類型池。也就是說,終歸都是局部類型池,只是你確定使用全局的時候,它會把全局的拷貝一份,然後加入到你這個局部類型池中。

  • Q: 使用全局類型的話,只能是在 Application 中進行註冊嗎?

    A: 不,只是推薦這麼做而已。在 Application 初始註冊,能夠確保類型在使用之前就註冊好。另外,位置統一固定,有利於尋找代碼。不然出了問題,你需要到處尋找是在哪註冊了全局類型。註冊全局的代碼如果分散到各個地方,就不好控制和追尋,因此最好統一一個地方註冊。換一個角度來說,註冊全局類型的動作存在着約定性,約定的東西、可被破壞的東西,有時會比較不可靠,因此能夠使用局部類型池的情況,最好使用局部類型池。

  • Q: 爲什麼不全然使用全局類型池?

    A: MultiType 最早的版本是隻支持全局類型池的,因爲它帶來的好處諸多,但隨着更多人使用,它的問題也逐漸暴露出來。一,全局類型池的註冊容易分散到許多地方,這是無法約束的,會導致代碼難以追尋。二,如果使用不當,可能引起內存泄漏問題,我自己是不會寫出內存泄漏的代碼的,但如果提供了可能性,就有很多人會趟上去。三,爲了解決一對多的問題,我想了許多方案,很多幾乎寫好了,但都被推翻了,後來我發現,這些麻煩,都是因爲一開始基於全局類型池引起的,那些方案固然都可以,但會使代碼變得複雜,我不喜歡。

  • Q: 覺得 MultiType 不夠精簡,應該怎麼做?

    A: 在前面 "設計思想" 中我們談到:MultiType 或許不是使用起來最簡單的,但很可能是使用起來最靈活的。其中的緣由是它高度可定製、可拓展,而不是把一些路封死。作爲一個基礎類庫,簡單和靈活需要一個均衡點,過度精簡便要以失去靈活性爲代價。如果覺得MultiType 不夠精簡,想將它修改得更加容易使用,我推薦的方式是去繼承 MultiTypeAdapter 或 ItemViewProvider,甚至你可以重新實現一個 TypePool 再設置給 MultiTypeAdapter. 我們不應該直接到底層去修改、破壞它們。總之,利用開放接口或繼承的做法不管對於 MultiType 還是其它開源庫,都應該是定製的首選。

  • Q: 在 ItemViewProvider 中如何拿到 Context 對象?

    A: 有人問我說,他在 ItemViewProvider 裏使用 Glide 來加載圖片需要獲取到 Activity Context 對象,要怎麼才能拿到 Context 對象?這是一個特別簡單的問題,但我想既然有人問,應該比較典型,我就詳細解答下:首先,在 Android 開發中,任何 View 對象都能通過view.getContext() 拿到 Context 對象,這些對象本質上都是 Activity 對象的引用。而在我們的 ItemViewProvider 中,可以通過holder.itemView.getContext() 獲取到 Context 對象,也可以通過 viewHolder 的任意 View 對象 getContext() 方法拿到 Context 對象. Context 中文釋義是 "上下文對象",一般情況下,都是由 Activity 傳遞給 Views,Views 內部再進行傳遞。比如我們使用RecyclerViewActivity 會將它的 Context 傳遞給 RecyclerViewRecyclerView 再傳遞給 AdapterAdapter 再傳遞給ViewHolder 的 itemViewitemView 再傳遞給它的各個子 Views,傳遞來傳遞去,其實都是同一個對象的引用。

    總而言之,拿到 Context 對象非常簡單,只要你能拿到一個 View 對象,調用 view.getContext() 即可。另外,也可以參考 與 provider 通訊 章節,我們可以很方便地給 provider 傳遞任何對象進去,包括 Context 對象。

感謝

在 MultiType 開發維護過程中,很多朋友給了我很多反饋,我也非常樂意於與大家交流,有問必答,因爲這是一個難得不錯的項目,它比較接近我心中對於一個完美項目的要求:設計精巧,代碼乾淨漂亮。

我向來是不太在意項目的 star 數目的,但熱衷於把我的好東西分享給更多人使用,因此在我的 GitHub 首頁我不會把我一些高 star 項目擺出來,而是放一些我覺得代碼相對比較好的項目。這是我的動力,我想寫一份完美的代碼,就像王垠的 40 行一樣,達到自覺得天衣無縫、猶如天神衣袖般的優雅,嗯,要是哪天我做到了,我就停止開源,哈哈。

話說回來,這個項目,特別感謝大家的幫忙、反饋,感謝一些朋友的 PR、貢獻和推薦,是你們讓我覺得開源是一件除了完善自我之外 還充滿了意義的一件事情 -- 能夠與更多人協同,能夠面向更寬廣的世界,謝謝大家!以下是感謝名單:

70kgzubinxiongWanLiLi代碼家CaMnterandroid-xiaoweiburgessjplixi0912simidaxu咕咚LuckyJayceBelongsHtmexceptTellHRay PanZackChris

引用文獻

  • 《Android 內存泄漏案例和解析》https://drakeet.me/android-leaks
  • 《Android 複雜的多類型列表視圖新寫法:MultiType》https://drakeet.me/multitype
  • 《使用 Google AutoValue 自動生成代碼》http://tedyin.me/2016/04/11/auto-value/
  • 我的個人博客 http://drakeet.me
  • MultiType 源代碼 https://github.com/drakeet/MultiType

發佈了130 篇原創文章 · 獲贊 53 · 訪問量 24萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章