IGListKit接入小結(一)

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;

源碼
在這里, 我們看到了另外兩個關鍵類型ListSectionControllerIGListDiffable.
從函數名字和注釋我們可以看出,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;

源碼
在這里我們看到了一些很熟悉的函數名和屬性, 跳過一下像supplementaryViewSourcedisplayDelegate這樣還不明確的屬性. 我們已經可以猜出ListSectionController做的事情:

  • 適配UICollectionViewCell的數量
  • 適配對應的UICollectionViewCell實例
  • 適配Cell的大小
  • 適配Cell以及本Section的間距
  • 適配用戶操作行為以及事件響應行為
  • 可以獲取當前所在的UIViewController

ListDiffable

回顧ListAdapterListSectionController的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為例.

參考IGListKitdemo, 其中有一個比較簡單的例子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的圖:

ListDiffableData, Adapter, SectionController, Cell之間的關系


所以我們需要做的事情, 小結就是:

  • ListAdapter橋接ViewControllerCollectionView
  • 把原來CollectionViewdataSource的協議函數改成ListAdapterdataSource協議函數
  • 給原來的數據源類型實現ListDiffable協議, 記得ListDiffable數據對應的是Section
  • Cell的適配和回調代碼遷移到ListSectionController的子類中

IGListKit 4.0 新增對于UITableView的支持

上面我們討論了CollectionView場景接入IGListKit, 而在4.0更新之后, IGListKit甚至可以支持TableView的組件更新.

而這是通過子模塊IGListDiffKit實現的.

我們會在ListDiffableKit中接觸以下類型:

  • ListIndexPathResult
  • ListIndexSetResult

這兩個類型存儲了列表組件變化的數據, 而它們的關系就類似IndexPathIndexSet的關系. 我們先只看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, 返回可以用于安全更新TableViewCollectionViewListIndexPathResult實例

我們可以在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進行單獨適配, 減輕了dataSourcedelegate的負擔
  • 通過ListAdapter更新CollectionView讓我們不需要再自行維護具體的數據變化
  • 通過ListIndexPathResult/ListIndexSetResult也可以快速地讓TableView的更新變得簡單化
  • 如果遇到需要復用的Cell組合業務邏輯, 可以直接復用ListSectionController
  • 接入IGListKit無需改變Cell的代碼, 也不影響CollectionViewUITableView本身在其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.

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,345評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,494評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,283評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,953評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,714評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,410評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,940評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,776評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,210評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,654評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373