轉載地址:http://gank.io/post/5823bcf6421aa90e799ec2ad
前言
在開發我的 TimeMachine 時,我有一個複雜的聊天頁面,於是我設計了我的類型池系統,它是完全解耦的,因此我能夠輕鬆將它抽離出來分享,並給它取名爲 MultiType.
從前,比如我們寫一個類似微博列表頁面,這樣的列表是十分複雜的:有純文本的、帶轉發原文的、帶圖片的、帶視頻的、帶文章的等等,甚至穿插一條可以橫向滑動的好友推薦條目。不同的 item 類型衆多,而且隨着業務發展,還會更多。如果我們使用傳統的開發方式,經常要做一些繁瑣的工作,代碼可能都堆積在一個 Adapter
中:我們需要覆寫 RecyclerView.Adapter
的 getItemViewType
方法,羅列一些 type
整型常量,並且 ViewHolder
轉型、綁定數據也比較麻煩。一旦產品需求有變,或者產品設計說需要增加一種新的
item 類型,我們需要去代碼堆裏找到我們原來的邏輯去修改,或者找到正確的位置去增加代碼。這些過程都比較繁瑣,侵入較強,需要小心翼翼,以免改錯影響到其他地方。
現在好了,我們有了 MultiType,簡單來說,MultiType 就是一個多類型列表視圖的中間分發框架,它能幫助你快速並且清晰地開發一些複雜的列表頁面。它本是爲聊天頁面開發的,聊天頁面的消息類型也是有大量不同種類,並且新增頻繁,而 MultiType 能夠輕鬆勝任,代碼模塊化,隨時可拓展新的類型進入列表當中。它內建了 類型
- View
的複用池系統,支持 RecyclerView
,使用簡單靈活,令代碼清晰、擁抱變化。
因此,我寫了這篇文章,目的有幾個:一是以作者的角度對 MultiType 進行入門和進階詳解。二是傳遞我開發過程中的思想、設計理念,這些偏細膩的內容,即使不使用 MultiType,想必也能帶來很多啓發。最後就是把我自覺得不錯的東西分享給大家,試想如果你製造的東西很多人在用,即使沒有帶來任何收益,也是一件很自豪的事情。
目錄
- MultiType 的特性
- 總覽
- MultiType 基礎用法
- 設計思想
- 高級用法
- 更多示例
- 仿造微博的數據結構和二級 ViewProvider
- drakeet/about-page
- 線性和網格佈局混排
- drakeet/TimeMachine
- 類似 Bilibili iOS 端首頁
- Q & A
- Q: 全局類型池的主要作用是什麼,能取消全局的使用嗎?
- Q: 使用全局類型的話,只能是在 Application 中進行註冊嗎?
- Q: 爲什麼不全然使用全局類型池?
- Q: 覺得 MultiType 不夠精簡,應該怎麼做?
- Q: 在
ItemViewProvider
中如何拿到Context
對象?
- 感謝
- 引用文獻
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
用於綁定數據到View
s.
一般一個 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
和 itemclass
的對應關係,簡單直觀。另外,現在我們有 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
- MessageContent
,Message
包含了 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
對象發給 provider
,provider
可進行分層,如下:
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 其實本身就支持
HeaderView
、FooterView
,只要創建一個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
內部包含了Comment
s
數據,或換句話說 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 (示例) 程序,這些示例秉承了一貫的代碼清晰、乾淨的風格,十分易於閱讀:
-
這是一個類似微博數據結構的示例,數據兩層結構,Item 也是兩層結構:一層框架(包含頭像用戶名等),一層 content view(微博內容),內容嵌套於框架中。微博的每一條微博 item 都包含了這樣兩層嵌套關係,這樣做的好處是,你不必每個 item 都去重複製造一遍外層框架。
或者換一個比喻,就像聊天消息,一條聊天消息也是兩層的,一層頭像、用戶名、聊天氣泡框,一層你的文字、圖片等。另外,每一種消息都有左邊和右邊的樣式,分別對應別人發來的消息和你發出的消息。如果左邊算一種,右邊又算一種,就是比較不好的設計了,會導致佈局內容重複、冗餘,修改操作都要做兩遍。最好的方案是讓他們視被爲同一種類型,然後在 item 框層次進行左右邊判斷和框架相關數據綁定。
我提供的這個二級
ViewProvider
示例便是這樣的兩層結構。它能夠讓你每次新增加一個類型,只要實現內容即可,框不應該重複實現。如果再不明白,或許你可以看看我的這個示例中 微博 Item 框的佈局:
從我這個
frame
佈局可以看出來,它內部有一個FrameLayout
作爲container
將用於容納不同的微博內容,而這一層框架則是共同的。這個例子算高級中的高級,但實際上也是很簡單,展示了 MultiType 優秀的可拓展能力。完整運行結果展示如下:
注:以上我們並沒有提到服務端 JSON 數據轉爲我們定義的 Weibo 對象過程,實際上對於完整鏈路,這個過程是需要做數據轉換,我們需要在
WeiboContent
層加一個type
或describe
字段用於描述微博內容類型,然後再將微博內容的 JSON 文本轉爲具體微博內容對象交給 Weibo. 這個內容建議直接閱讀這個 sample 的WeiboContentDeserializer
源碼,我利用了一種很簡單又巧妙的方式,在 JSON 解析底層便進行抽象數據具體化,使得客戶端和服務端都能夠輕鬆適應這種微博和微博內容嵌套關係。 -
一個 Material Design 的關於頁面,核心基於 MultiType,包含了多種 items,美觀,容易使用。
-
使用
MultiType
和GridLayoutManager
實現網格和線性混合佈局,實現一個選集頁面。 -
TimeMachine 使用了 MultiType 來創建一個複雜的聊天頁面,頁面和需求雖然複雜,但使用 MultiType 顯得輕鬆簡單。
-
使用
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 來加載圖片需要獲取到 ActivityContext
對象,要怎麼才能拿到Context
對象?這是一個特別簡單的問題,但我想既然有人問,應該比較典型,我就詳細解答下:首先,在 Android 開發中,任何View
對象都能通過view.getContext()
拿到Context
對象,這些對象本質上都是Activity
對象的引用。而在我們的ItemViewProvider
中,可以通過holder.itemView.getContext()
獲取到Context
對象,也可以通過 viewHolder 的任意View
對象getContext()
方法拿到Context
對象.Context
中文釋義是 "上下文對象",一般情況下,都是由Activity
傳遞給View
s,View
s 內部再進行傳遞。比如我們使用RecyclerView
,Activity
會將它的Context
傳遞給RecyclerView
,RecyclerView
再傳遞給Adapter
,Adapter
再傳遞給ViewHolder
的itemView
,itemView
再傳遞給它的各個子View
s,傳遞來傳遞去,其實都是同一個對象的引用。總而言之,拿到
Context
對象非常簡單,只要你能拿到一個View
對象,調用view.getContext()
即可。另外,也可以參考 與 provider 通訊 章節,我們可以很方便地給provider
傳遞任何對象進去,包括Context
對象。
感謝
在 MultiType 開發維護過程中,很多朋友給了我很多反饋,我也非常樂意於與大家交流,有問必答,因爲這是一個難得不錯的項目,它比較接近我心中對於一個完美項目的要求:設計精巧,代碼乾淨漂亮。
我向來是不太在意項目的 star 數目的,但熱衷於把我的好東西分享給更多人使用,因此在我的 GitHub 首頁我不會把我一些高 star 項目擺出來,而是放一些我覺得代碼相對比較好的項目。這是我的動力,我想寫一份完美的代碼,就像王垠的 40 行一樣,達到自覺得天衣無縫、猶如天神衣袖般的優雅,嗯,要是哪天我做到了,我就停止開源,哈哈。
話說回來,這個項目,特別感謝大家的幫忙、反饋,感謝一些朋友的 PR、貢獻和推薦,是你們讓我覺得開源是一件除了完善自我之外 還充滿了意義的一件事情 -- 能夠與更多人協同,能夠面向更寬廣的世界,謝謝大家!以下是感謝名單:
70kg、zubinxiong、WanLiLi、代碼家、CaMnter、android-xiaowei、burgessjp、lixi0912、simidaxu、咕咚、LuckyJayce、BelongsH、tmexcept、TellH、Ray Pan、Zack、Chris
引用文獻
- 《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