Android 複雜的多型別列表檢視新寫法:MultiType 3.0
前言
在開發我的 時,我有一個複雜的聊天頁面,於是我設計了我的型別池系統,它是完全解耦的,因此我能夠輕鬆將它抽離出來分享,並給它取名為 MultiType.
從前,比如我們寫一個類似微博列表頁面,這樣的列表是十分複雜的:有純文字的、帶轉發原文的、帶圖片的、帶視訊的、帶文章的等等,甚至穿插一條可以橫向滑動的好友推薦條目。不同的 item 型別眾多,而且隨著業務發展,還會更多。如果我們使用傳統的開發方式,經常要做一些繁瑣的工作,程式碼可能都堆積在一個 Adapter
中:我們需要覆寫 RecyclerView.Adapter
的 getItemViewType
方法,羅列一些 type
整型常量,並且 ViewHolder
現在好了,我們有了 MultiType,簡單來說,MultiType 就是一個多型別列表檢視的中間分發框架,它能幫助你快速並且清晰地開發一些複雜的列表頁面。 它本是為聊天頁面開發的,聊天頁面的訊息型別也是有大量不同種類,且新增頻繁,而 MultiType 能夠輕鬆勝任。
MultiType 以靈活直觀為第一宗旨進行設計,它內建了 型別
- View
的複用池系統,支援 RecyclerView
因此,我寫了這篇文章,目的有幾個:一是以作者的角度對 MultiType 進行入門和進階詳解。二是傳遞我開發過程中的思想、設計理念,這些偏細膩的內容,即使不使用 MultiType,想必也能帶來很多啟發。最後就是把自我覺得不錯的東西分享給大家,試想如果你製造的東西很多人在用,即使沒有帶來任何收益,也是一件很自豪的事情。
目錄
MultiType 的特性
- 輕盈,整個類庫只有 14 個類檔案,
aar
或jar
包大小隻有 13 KB - 周到,支援 data type
<-->
item view binder 之間 一對一 和 一對多 的關係繫結 - 靈活,幾乎所有的部件(類)都可被替換、可繼承定製,面向介面 / 抽象程式設計
- 純粹,只負責本分工作,專注多型別的列表檢視 型別分發,絕不會去影響 views 的內容或行為
- 高效,沒有效能損失,記憶體友好,最大限度發揮
RecyclerView
的複用性 - 可讀,程式碼清晰乾淨、設計精巧,極力避免複雜化,可讀性很好,為拓展和自行解決問題提供了基礎
總覽
MultiType 能輕鬆實現如下頁面,它們將在示例篇章具體提供:
MultiType 的原始碼關係:
MultiType 基礎用法
可能有的新手看到以上特性介紹說什麼 “一對多”、抽象程式設計等等,都不太懂,我想說完全不要緊,不懂可以回過頭來再看,我們先從基礎用法入手,其實 MultiType 使用起來特別簡單。使用 MultiType 一般情況下只要 maven 引入 + 三個小步驟。之後還會介紹使用外掛生成程式碼方式,步驟將更加簡化:
引入
在你的 build.gradle
:
dependencies { compile 'me.drakeet.multitype:multitype:3.1.0'} |
注:MultiType 內部引用了
recyclerview-v7:25.3.1
,如果你不想使用這個版本,可以使用exclude
將它排除掉,再自行引入你選擇的版本。示例如下:
dependencies { compile('me.drakeet.multitype:multitype:3.1.0', { exclude group: 'com.android.support' }) compile 'com.android.support:recyclerview-v7:你選擇的版本'} |
使用
Step 1. 建立一個 class
,它將是你的資料型別或 Java bean
/ model. 對這個類的內容沒有任何限制。示例如下:
public class Category { public final String text; public Category(@NonNull String text) { this.text = text; }} |
Step 2. 建立一個 class
繼承 ItemViewBinder
.
ItemViewBinder
是個抽象類,其中 onCreateViewHolder
方法用於生產你的
Item View Holder, onBindViewHolder
用於繫結資料到 View
s.
一般一個 ItemViewBinder
類在記憶體中只會有一個例項物件,MultiType 內部將複用這個 binder
物件來生產所有相關的 item views 和繫結資料。示例:
public class CategoryViewBinder extends ItemViewBinder<Category, CategoryViewBinder.ViewHolder> { protected ViewHolder onCreateViewHolder(@NonNull LayoutInflater inflater, @NonNull ViewGroup parent) { View root = inflater.inflate(R.layout.item_category, parent, false); return new ViewHolder(root); } protected void onBindViewHolder(@NonNull ViewHolder holder, @NonNull Category category) { holder.category.setText(category.text); } static class ViewHolder extends RecyclerView.ViewHolder { private final TextView category; ViewHolder( 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; protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); RecyclerView recyclerView = (RecyclerView) findViewById(R.id.list); adapter = new MultiTypeAdapter(); /* 註冊型別和 View 的對應關係 */ adapter.register(Category.class, new CategoryViewBinder()); adapter.register(Song.class, new SongViewBinder()); recyclerView.setAdapter(adapter); /* 模擬載入資料,也可以稍後再載入,然後使用 * adapter.notifyDataSetChanged() 重新整理列表 */ items = new Items(); 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)); } adapter.setItems(items); adapter.notifyDataSetChanged(); }} |
大功告成!這就是 MultiType 的基礎用法了。其中 onCreateViewHolder
和 onBindViewHolder
方法名沿襲了使用 RecyclerView
的習慣,令人一目瞭然,減少了新人的學習成本。
設計思想
MultiType 設計伊始,我給它定了幾個原則:
-
要簡單,便於他人閱讀程式碼
因此我極力避免將它複雜化,避免加入許多不相干的內容。我想寫人人可讀的程式碼,使用簡單的方式,去實現複雜的需求。過多不相干、沒必要的程式碼,將會使專案變得令人暈頭轉向,難以閱讀,遇到需要定製、解決問題的時候,無從下手。
-
要靈活,便於拓展和適應各種需求
很多人會得意地告訴我,他們把 MultiType 原始碼精簡成三四個類,甚至一個類,以為程式碼越少就是越好,這我不能贊同。MultiType 考慮得更遠,這是一個提供給大眾使用的類庫,過度的精簡只會使得大幅失去靈活性。它或許不是使用起來最簡單的,但很可能是使用起來最靈活的。 在我看來,”直觀”、”靈活”優先順序大於”簡單”。因此,MultiType 以介面或抽象進行連線,這意味著它的角色、元件都可以被替換,或者被拓展和繼承。如果你覺得它使用起來還不夠簡單,完全可以通過繼承封裝出更具體符合你使用需求的方法。它已經暴露了足夠豐富、周到的介面以供拓展,我們不應該直接去修改原始碼,這會導致一旦後續發現你的精簡版滿足不了你的需求時,已經沒有回頭路了。
-
要直觀,使用起來能令專案程式碼更清晰可讀,一目瞭然
MultiType 提供的
ItemViewBinder
沿襲了RecyclerView Adapter
的介面命名,使用起來更加舒適,符合習慣。另外,MultiType 很多地方放棄使用反射而是讓使用者顯式指明一些關係,如:MultiTypeAdapter#register
方法,需要傳遞一個數據模型class
和ItemViewBinder
物件,雖然有很多方法可以把它精簡成單一引數方法,但我們認為顯式宣告資料模型類與對應關係,更具直觀。
高階用法
介紹了基礎用法和設計思想後,我們可以來介紹一下 MultiType 的高階用法。這是一些典型需求和案例,它們是基礎用法的延伸,也是設計思想的體現。也許一開始並不會使用到,但如若瞭解,能夠拓寬使用 MultiType 的思路,也能過了解到我們考慮問題的角度。
使用 MultiTypeTemplates 外掛自動生成程式碼
在基礎用法中,我們了通過 3 個步驟完成 MultiType 的初次接入使用,實際上這個過程可以更加簡化,MultiType 提供了 Android Studio 外掛來自動生成程式碼:
MultiTypeTemplates,原始碼也是開源的,https://github.com/drakeet/MultiTypeTemplates。這個外掛不僅提供了一鍵生成
item 類檔案和 ItemViewBinder
,而且是一個很好的利用程式碼模版自動生成程式碼的示例。其中使用到了官方提供的程式碼模版
API,也用到了我自己發明的更靈活修改模版內容的方法,有興趣做這方面外掛的可以看看。
話說回來,安裝和使用 MultiTypeTemplates 非常簡單:
Step 1. 開啟 Android Studio 的設定
-> Plugin
-> Browse
repositories
,搜尋 MultiTypeTemplates
即可獲得下載安裝:
Step 2. 安裝完成後,重啟 Android Studio. 右鍵點選你的 package,選擇 New
-> MultiType
Item
,然後輸入你的 item 名字,它就會自動生成 item 模型類 和 ItemViewBinder
檔案和程式碼。
比如你輸入的是 “Category”,它就會自動生成 Category.java
和 CategoryViewBinder.java
.
特別方便,相信你會很喜歡它。未來這個外掛也將會支援自動生成佈局檔案,這是目前欠缺的,但不要緊,其實 AS 在這方面已經很方便了,對佈局 R.layout.item_category
使用 alt
+ enter
快捷鍵即可自動生成佈局檔案。
一個型別對應多個 ItemViewBinder
MultiType 天然支援一個型別對應多個 ItemViewBinder
,註冊方式也很簡單,如下:
adapter.register(Data.class).to( new DataType1ViewBinder(), new DataType2ViewBinder()).withClassLinker(new ClassLinker<Data>() { public Class<? extends ItemViewBinder<Data, ?>> index( Data data) { if (data.type == Data.TYPE_2) { return DataType2ViewBinder.class; } else { return DataType1ViewBinder.class; } }}); |
或者:
adapter.register(Data.class).to( new DataType1ViewBinder(), new DataType2ViewBinder()).withLinker(new Linker<Data>() { public int index(@NonNull Data data) { if (data.type == Data.TYPE_2) { return 1; } else return 0; }}); |
如果你使用 Lambda 表示式,以上程式碼可以更簡潔:
解釋:
如上示例程式碼,對於一對多,我們需要使用 MultiType#register(class)
方法,它會返回一個 OneToManyFlow
讓你緊接著繫結多個 ItemViewBinder
例項,最後再呼叫 OneToManyEndpoint#withLinker
或 OneToManyEndpoint#withClassLinker
操作符方法類設定
linker. 所謂 linker,是負責動態連線這個 “一” 對應 “多” 中哪一個 binder 的角色。
這個方案具有很好的效能表現,而且可謂十分直觀。另外,我使用了 @CheckResult
註解來讓編譯器督促開發者一定要完整呼叫方法鏈才不至於出錯。
使用 全域性型別池
MultiType 在 3.0 版本之前一直是支援全域性型別池的,你可以往一個全域性型別池中 register 型別和 view binder,然後讓你的各個 MultiTypeAdapter
都能使用它。
但在 MultiType 3.0 之後,我們廢棄並刪除了內建的全域性型別池。原因在於全域性型別池容易對全域性產生不可見影響,比如你註冊了一堆全域性型別關係並在多處引用它,某一天你的夥伴不小心修改了全域性型別池的某個內容,將導致所有使用的地方皆受到變化,是我們不希望發生的。一個好的模組,應該是高內聚、自包含的,如果過多下放權力到外圍,很容易遭受破壞或影響。
另外,全域性型別池一般都是 static 形式的,如果我們給這個 static 容器傳遞了 Activity
或 Context
物件,而沒有在退出時釋放,就容易造出記憶體洩漏,這對新手來說很容易觸犯。
因此我們刪除了內建的全域性型別池,當你建立一個 MultiTypeAdapter
物件時,預設情況下,它內部會自動建立一個區域性型別池以供你接下來註冊型別。當然了,如果你實在需要它,完全可以自己建立一個
static 的 MultiTypePool
,然後通過 MultiTypeAdapter#registerAll(pool)
將這個型別池傳入,以此達到多個地方共同使用。
與 ItemViewBinder
通訊
ItemViewBinder
物件可以接受外部型別、回撥函式,只要在使用之前,傳遞進去即可,例如:
OnClickListener listener = new OnClickListener() { public void onClick(View v) { // ... }}adapter.register(Post.class, new PostViewBinder(xxx, listener)); |
但話說回來,對於點選事件,能不依賴 binder
外部內容的話,最好就在 binder
內部完成。binder
內部能夠拿到
Views 和 資料,大部分情況下,完全有能力不依賴外部 獨立完成邏輯。這樣能使程式碼更加模組化,實現解耦和內聚。例如下面便是一個完全自包含的例子:
public class SquareViewBinder extends ItemViewBinder<Square, SquareViewBinder.ViewHolder> { protected ViewHolder onCreateViewHolder( @NonNull LayoutInflater inflater, @NonNull ViewGroup parent) { View root = inflater.inflate(R.layout.item_square, parent, false); return new ViewHolder(root); } 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() { 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; 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 TextItemViewBinder()); 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,支援自動對映某個已經註冊的型別的子類到同一 ItemViewBinder
,規則是:如果子類有註冊,就用註冊的對映關係;如果子類沒註冊,則該子類物件使用註冊過的父類對映關係。
FlatTypeAdapter(已廢棄)
MultiType 3.0 之前提供了一個 FlatTypeAdapter
類,3.0 之後,這個類已經被刪除了,你可以完全不必關心它。如果你使用過它,現在它已經被一對多方案替代了,請轉成使用一對多功能實現。
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(); 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 更新:
MultiType 支援 onBindViewHolder with payloads,詳情見
ItemViewBinder
類文件。對於 Diff,可以在Activity
中進行 Diff,或者繼承MultiTypeAdapter
提供接收資料方法,在方法中進行 Diff. MultiType 不提供內建 Diff 方案,不然需要依賴 v4 包,並且這也不應該屬於它的範疇。 -
HeaderView、FooterView
MultiType 其實本身就支援
HeaderView
、FooterView
,只要建立一個Header.class
-HeaderViewBinder
和Footer.class
-FooterViewBinder
即可,然後把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;}} |
對應的 HorizontalItemViewBinder
類似這樣:
public class HorizontalItemViewBinder extends ItemViewBinder<PostList, HorizontalItemViewBinder.ViewHolder> { 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); } protected void onBindViewHolder(@NonNull ViewHolder holder, @NonNull PostList postList) { holder.setPosts(postList.posts); } static class ViewHolder extends RecyclerView.ViewHolder { |