iOS原生端開發過程中, 列表是最常見的需求之一. 隨著業務和UI交互設計的迭代, 我們逐漸會接觸到這樣的需求:
- 列表中出現多種不同樣式的Cell
- 列表中出現復雜的Cell插入, 更新, 刪除, 移位動畫
接著我們就遇到這樣的問題:
- 同一列表中適配多種Cell, 導致dataSource部分代碼臃腫不好維護
- 同一列表中復雜的Cell帶來同樣多的回調適配, 進一步增加臃腫度和維護難度
- 復雜的列表更新策略配合多種不同的數據類型, 導致批量更新列表同樣麻煩
- 針對某些Cell組合的業務邏輯復用
Instagram 團隊的開源框架IGListKit是一個非常好用的解決方案.
介紹
IGListKit 可以做什么?
簡單地說IGListKit封裝了很多友好的API去幫我們適配和更新UICollectionView
/UITableView
(在4.0版中加入了對UITableView
的支持, 但是主要API還是服務于UICollectionView
), 它專注于處理列表的數據源和操作行為.
那么IGListKit是如何做到的呢?
如果我們最基本地使用IGListKit, 我們會接觸到下面這幾個類型:
- ListAdapter
- ListSectionController
- ListDiffable
ListAdapter
ListAdapter
是我們調用更新UI的API的入口, 它幫我們橋接了UICollectionView
的一些API. 在這個類型中有以下幾個關鍵API:
@property (nonatomic, nullable, weak) UIViewController *viewController;
@property (nonatomic, nullable, weak) UICollectionView *collectionView;
@property (nonatomic, nullable, weak) id <IGListAdapterDataSource> dataSource;
@property (nonatomic, nullable, weak) id <IGListAdapterDelegate> delegate;
@property (nonatomic, nullable, weak) id <UICollectionViewDelegate> collectionViewDelegate;
- (void)performUpdatesAnimated:(BOOL)animated completion:(nullable IGListUpdaterCompletion)completion;
- (void)reloadDataWithCompletion:(nullable IGListUpdaterCompletion)completion;
- (void)reloadObjects:(NSArray *)objects;
源碼
從名字上我們就可以看出, ListAdapter
其實做了一些本來是UICollectionView
做的事情, 比如更新行為.
而IGListKit的example中也告訴了我們這句話:使用ListAdapter
去更新界面而不要再自己調用UICollectionView
的接口.
除此以外, 我們還看到了dataSource
, delegate
, scrollDelegate
這類原來在UICollectionView
上的屬性, 實際上它就是橋接了對應的屬性.
我們還可以見到一個viewController
的屬性, 后面我們再討論為什么會出現這個屬性.
IGListAdapterDataSource
我們可以看到, 這是一個協議. 它非常簡單, 只有幾個的API:
- (NSArray<id <IGListDiffable>> *)objectsForListAdapter:(IGListAdapter *)listAdapter;
- (IGListSectionController *)listAdapter:(IGListAdapter *)listAdapter sectionControllerForObject:(id)object;
- (nullable UIView *)emptyViewForListAdapter:(IGListAdapter *)listAdapter;
源碼
在這里, 我們看到了另外兩個關鍵類型ListSectionController
和IGListDiffable
.
從函數名字和注釋我們可以看出,dataSource
是我們提供另外兩個關鍵類型的數據的地方, 以及提供列表沒有數據時候的提示UI組件的地方.(上面代碼塊中注釋被刪掉了)
ListSectionController
ListAdapter
是我們發起更新的地方, 那么ListSectionController
就是我們做行為適配的地方了.
上面我們已經可以看到, 在IGListAdapterDataSource
協議中我們需要返回一個ListSectionController
的實例. 而對這個函數里面除了提供了一個ListAdapter
的實例變量, 和一個id
類型的變量.
我們不難理解這個listAdapter
, 那么這個object
變量又是做什么的呢? 它和ListSectionController
又有什么聯系呢?
先給出直接答案:
這個object
就是我們另一個關鍵類型ListDiffable
. 而我們在這個函數中到底返回怎么樣的ListSectionController
就取決于我們要對什么樣的ListDiffable
數據進行適配.
接著看一下ListSectionController
的部分API:
- (NSInteger)numberOfItems;
- (CGSize)sizeForItemAtIndex:(NSInteger)index;
- (__kindof UICollectionViewCell *)cellForItemAtIndex:(NSInteger)index;
- (void)didUpdateToObject:(id)object;
- (void)didSelectItemAtIndex:(NSInteger)index;
- (void)didDeselectItemAtIndex:(NSInteger)index;
- (void)didHighlightItemAtIndex:(NSInteger)index;
@property (nonatomic, weak, nullable, readonly) UIViewController *viewController;
@property (nonatomic, weak, nullable, readonly) id <IGListCollectionContext> collectionContext;
@property (nonatomic, assign) UIEdgeInsets inset;
@property (nonatomic, assign) CGFloat minimumLineSpacing;
@property (nonatomic, assign) CGFloat minimumInteritemSpacing;
@property (nonatomic, weak, nullable) id <IGListSupplementaryViewSource> supplementaryViewSource;
@property (nonatomic, weak, nullable) id <IGListDisplayDelegate> displayDelegate;
源碼
在這里我們看到了一些很熟悉的函數名和屬性, 跳過一下像supplementaryViewSource
和displayDelegate
這樣還不明確的屬性. 我們已經可以猜出ListSectionController
做的事情:
- 適配
UICollectionViewCell
的數量 - 適配對應的
UICollectionViewCell
實例 - 適配Cell的大小
- 適配Cell以及本Section的間距
- 適配用戶操作行為以及事件響應行為
- 可以獲取當前所在的
UIViewController
ListDiffable
回顧ListAdapter
和ListSectionController
的API, 我們已經明白, 我們每次更新列表, 就是我們更新ListDiffable
數組. 到現在我們已經知道了, ListDiffable
是IGListKit封裝的API中列表的數據單位.
那么問題就是, 我們要怎么去生成這個數據單位呢?
查看代碼, 其實ListDiffable
是一個非常簡單的協議:
NS_SWIFT_NAME(ListDiffable)
@protocol IGListDiffable
- (nonnull id<NSObject>)diffIdentifier;
- (BOOL)isEqualToDiffableObject:(nullable id<IGListDiffable>)object;
@end
源碼
只有兩個API:
-
diffIdentifier
明顯是用于標識這條數據唯一性 - 函數
isEqualToDiffableObject(:)
則是具體實現如何判別這條數據和另一條數據不一樣.
怎樣接入IGListKit
有了大致了解之后, 我們看一下要怎樣接入IGListKit. 這里先以UICollectionView為例.
參考IGListKit的demo, 其中有一個比較簡單的例子StoryboardViewController.
在這里我們看到了:
-
ListAdapter
的創建以及調用 - 在協議函數里返回了一個
ListSectionController
的子類StoryboardLabelSectionController
- 實現了
ListDiffable
協議的數據Person
ListAdapter的使用:
/*
創建的時候就需要傳入viewController, 以及一個updater, 這個updater暫時不討論.
*/
lazy var adapter: ListAdapter = {
return ListAdapter(updater: ListAdapterUpdater(), viewController: self)
}()
/*
必要參數賦值, dataSource, 托管的collectionView
*/
adapter.collectionView = collectionView
adapter.dataSource = self
/*
在回調中更新UICollectionView.
可以通過adapter找到對應的section, 修改數據后調用adapter的performUpdates函數.
*/
func removeSectionControllerWantsRemoved(_ sectionController: StoryboardLabelSectionController) {
let section = adapter.section(for: sectionController)
people.remove(at: Int(section))
adapter.performUpdates(animated: true)
}
ListSectionController的使用:
接著我們看一下這個StoryboardLabelSectionController
的代碼
final class StoryboardLabelSectionController: ListSectionController {
private var object: Person?
weak var delegate: StoryboardLabelSectionControllerDelegate?
override func sizeForItem(at index: Int) -> CGSize {
return CGSize(width: (self.object?.name.count)! * 7, height: (self.object?.name.count)! * 7)
}
override func cellForItem(at index: Int) -> UICollectionViewCell {
guard let cell = collectionContext?.dequeueReusableCellFromStoryboard(withIdentifier: "cell",
for: self,
at: index) as? StoryboardCell else {
fatalError()
}
cell.text = object?.name
return cell
}
override func didUpdate(to object: Any) {
self.object = object as? Person
}
override func didSelectItem(at index: Int) {
delegate?.removeSectionControllerWantsRemoved(self)
}
}
可以看出:
-
StoryboardLabelSectionController
持有了Person
對象, 就是在didUpdate(to:)函數中獲得的. 而在適配Cell的時候用到了它. - 在這個例子中, 每個Section中只有1條數據. 但是其實SectionController控制的是
UICollectionView
中的Section, 所以也可以在這里適配多個數據或者多種Cell. - Cell的點擊回調發生在
didSelectItem(at:)
中, 此處用了delegate作為回調方式. 而我們上面已經知道在ListSectionController
中有一個屬性viewController
, 也可以通過這個屬性實現回調.
Person:
final class Person: ListDiffable {
let pk: Int
let name: String
init(pk: Int, name: String) {
self.pk = pk
self.name = name
}
func diffIdentifier() -> NSObjectProtocol {
return pk as NSNumber
}
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
guard let object = object as? Person else { return false }
return self.name == object.name
}
}
可以看到Person
類中除了ListDiffable
協議的2個必需的函數以外, 還有2個屬性:
-
pk
屬性被用作唯一標識 -
name
屬性被用于在適配Cell的時候加載顯示 -
isEqual(toDiffableObject:)
中做了類型對比和name
屬性的對比
到這里, 我們可以知道:
-
ListAdapter
的數據源就是實現了ListDiffable
協議的數據的數組, 我們更新CollectionView需要調用ListAdapter
的函數 -
ListDiffable
類型對應的是CollectionView中的Section單元的數據, 它里面的數據也對應這個Section里面的Cell -
ListSectionController
把相應ListDiffable
數據適配成對應的Section, 在它這里適配Cell的樣式和回調
借用一張來自raywenderlich的圖:
所以我們需要做的事情, 小結就是:
- 用
ListAdapter
橋接ViewController和CollectionView - 把原來CollectionView的
dataSource
的協議函數改成ListAdapter
的dataSource
協議函數 - 給原來的數據源類型實現
ListDiffable
協議, 記得ListDiffable
數據對應的是Section - 把Cell的適配和回調代碼遷移到
ListSectionController
的子類中
IGListKit 4.0 新增對于UITableView的支持
上面我們討論了CollectionView場景接入IGListKit, 而在4.0更新之后, IGListKit甚至可以支持TableView的組件更新.
而這是通過子模塊IGListDiffKit實現的.
我們會在ListDiffableKit中接觸以下類型:
- ListIndexPathResult
- ListIndexSetResult
這兩個類型存儲了列表組件變化的數據, 而它們的關系就類似IndexPath
和IndexSet
的關系. 我們先只看ListIndexPathResult
@property (nonatomic, copy, readonly) NSArray<NSIndexPath *> *inserts;
@property (nonatomic, copy, readonly) NSArray<NSIndexPath *> *deletes;
@property (nonatomic, copy, readonly) NSArray<NSIndexPath *> *updates;
@property (nonatomic, copy, readonly) NSArray<IGListMoveIndexPath *> *moves;
@property (nonatomic, assign, readonly) BOOL hasChanges;
- (nullable NSIndexPath *)oldIndexPathForIdentifier:(id<NSObject>)identifier;
- (nullable NSIndexPath *)newIndexPathForIdentifier:(id<NSObject>)identifier;
- (IGListIndexPathResult *)resultForBatchUpdates;
源碼
可以看到它這幾個關鍵API:
- 屬性
inserts
,deletes
,updates
,moves
, 分別對應插入, 刪除, 更新, 移動的數據 - 屬性
hasChanges
代表這條結果和列表上一次的結果是否出現不同 - 函數
oldIndexPathForIdentifier(:)
和newIndexPathForIdentifier(:)
可以根據唯一標識找到更新前/后, 其在列表中對應的IndexPath - 函數
resultForBatchUpdates
, 返回可以用于安全更新TableView或CollectionView的ListIndexPathResult
實例
我們可以在demo中找到一個對應的例子DiffTableViewController, 它就借助了ListIndexPathResult
去更新UITableView:
@objc func onDiff() {
let from = people
let to = usingOldPeople ? newPeople : oldPeople
usingOldPeople = !usingOldPeople
people = to
// 調用全局函數, 傳入更新前后的數據源, 獲得ListIndexPathResult實例
let result = ListDiffPaths(fromSection: 0, toSection: 0, oldArray: from, newArray: to, option: .equality).forBatchUpdates()
// 調起tableView的批量更新
tableView.beginUpdates()
// 調起tableView的deleteRows, 從result的deletes屬性獲得被刪除的IndexPath數組
tableView.deleteRows(at: result.deletes, with: .fade)
// 調起tableView的insertRows, 從result的inserts屬性獲得被刪除的IndexPath數組
tableView.insertRows(at: result.inserts, with: .fade)
// 由于UITableView沒有批量移動IndexPath的API, 所以要遍歷result的moves屬性, 逐個執行tableView的moveRow(at:, to:)函數
result.moves.forEach { tableView.moveRow(at: $0.from, to: $0.to) }
// 結束批量更新
tableView.endUpdates()
}
我們可以到, 僅僅使用ListIndexPathResult
, 我們不需要借助ListAdapter
也可以順利更新列表.
我們需要做的關鍵點是:
- 使用
ListDiffable
數據作為數據源 - 獲得更新前和更新后的
dataSource
數組和對應的section - 調用
ListDiffPaths()
函數得到ListIndexPathResult
- 調起TableView/CollectionView的批量更新函數, 取出變更的
IndexPath
數據進行對應操作
注意:
在這個例子中ListDiffable
已經不是對應Section的數據單位!
因為UITableView并沒有對應的ListSectionController
去專門處理ListDiffable
數據.
引發的思考
接入IGListKit后, 代碼結構發生了以下改善:
- 通過
ListSectionController
對不同類型的Cell進行單獨適配, 減輕了dataSource和delegate的負擔 - 通過
ListAdapter
更新CollectionView讓我們不需要再自行維護具體的數據變化 - 通過
ListIndexPathResult
/ListIndexSetResult
也可以快速地讓TableView的更新變得簡單化 - 如果遇到需要復用的Cell組合業務邏輯, 可以直接復用
ListSectionController
- 接入IGListKit無需改變Cell的代碼, 也不影響CollectionView和UITableView本身在其
superview
上的布局狀態
那么, 難道接入IGListKit就只有好處嗎?
看看接入IGListKit的副作用:
- 使用
ListSectionController
適配對應的ListDiffable
數據, 項目整體代碼量增加, 會延長開發周期. - CollectionView界面迭代后需要進行大量代碼遷移, 如果界面中業務邏輯比較復雜容易引發錯誤, 需要重新測試.
- 如果原界面是通過UITableView實現的話, 想要得到
ListSectionController
帶來的便利, 需要把所有涉及的TableViewCell改成CollectionViewCell. - 必須把數據源換成
ListDiffable
類型. 因此要對原數據類型進行改造. 如果不想/無法改造原類型代碼, 則需要另外定義新的類型.
接入IGListKit也是有一定成本的.
既然如此, 接入IGListKit的取舍是什么?
- 如果只是有復雜的列表更新需求, 但是沒有復雜的Cell適配, 優先使用ListDiffableKit.
- 遇上復雜Cell適配情況或者需要復用固定的Cell組合業務, 使用
ListSectionController
. 如果是界面重構, 預留時間做測試. - 如果使用Swift開發, 優先使用extension給原來的Model添加
ListDiffable
協議, 這樣可以避免修改原Model的代碼. - 如果使用了OC開發, 原來的Model不方便改造, 考慮定義新的類型作為數據源, 但是需要更新對應Cell的代碼.
以上是對IGListKit接入的第一步小結, 隨著對列表開發的深入, 我們還需要知道IGListKit的其他API及其運作機制. 如:
-
ListDiffable
作為IGListKit的基礎數據源, 到底有什么意義. - IGListKit通過什么方式幫我們處理了那些復雜的數據更新邏輯.
-
IGListKit是一個高性能體的框架, 到底現在什么地方.
...
后面我們將會繼續探討IGListKit.