Diffable DataSource

蘋果在WWDC2019的session中公開了iOS13一些新的系統(tǒng)API, 其中對于非常穩(wěn)定的UITableView和UICollectionView這2個(gè)控件,各自新增了一套Diffable DataSource的API。

本文從why, what, how的角度出發(fā),并結(jié)合一個(gè)優(yōu)秀的第三方庫IGListKit來分析下如何實(shí)現(xiàn)一套Diffable DataSource。

我們先來看第一個(gè)問題, 為什么需要一個(gè)Diffable DataSource?

要回答這個(gè)問題,我們先來看業(yè)務(wù)上一個(gè)最常見的場景,例如用戶手動刷新了下聊天列表,可能因?yàn)楦鞣N原因列表數(shù)據(jù)源發(fā)生了一些增刪改的變化,此時(shí)我們該如何對應(yīng)地刷新整個(gè)列表呢?

一般有兩類方法:

  • 粗暴方法

    [self.tableView reloadData];
    
  • 精巧方法

    [self.tableView beginUpdates];
    [self.tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationAutomatic];
    [self.models safeRemoveObjectAtIndex:indexPath.row];
    [self.tableView endUpdates];
    

粗暴的方法最簡單,幾乎不可能出現(xiàn)數(shù)據(jù)源不一致導(dǎo)致的異常等情況,但在數(shù)據(jù)量很大的情況下有一些性能的瓶頸,尤其在低端機(jī)型上。

精巧的方法,需要手動去計(jì)算數(shù)據(jù)源的變化,并使用對應(yīng)的API去更新,如下:

- (void)insertRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths withRowAnimation:(UITableViewRowAnimation)animation;

- (void)deleteRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths withRowAnimation:(UITableViewRowAnimation)animation;

- (void)moveRowAtIndexPath:(NSIndexPath *)indexPath toIndexPath:(NSIndexPath *)newIndexPath;

因?yàn)槭鞘謩觗iff數(shù)據(jù)源并調(diào)用相關(guān)API,如果計(jì)算不準(zhǔn)確就容易引起NSInternalInconsistencyException。

在蘋果出現(xiàn)Diffable data source API之前,就有很多地方庫實(shí)現(xiàn)了通過Diff數(shù)據(jù)源來實(shí)現(xiàn)既傻瓜又高效的列表刷新方式,比如IGListKit和DeepDiff,我們無法看到蘋果Diffable DataSource的源碼,但可以通過回顧下第三方庫IGList的源碼來大概看下,是能如何實(shí)現(xiàn)一個(gè)基于高效Diff算法的列表刷新的:

首先要實(shí)現(xiàn)一個(gè)Diffable Datasource,需要數(shù)據(jù)源能夠告訴我們,他們是否"一樣"。

在IGListKit中,需要實(shí)現(xiàn)IGListDiffable協(xié)議

@protocol IGListDiffable
- (nonnull id<NSObject>)diffIdentifier;
- (BOOL)isEqualToDiffableObject:(nullable id<IGListDiffable>)object;
@end

其中第一個(gè)接口來標(biāo)示是否是同一個(gè)數(shù)據(jù)源,而第二個(gè)接口來標(biāo)示它是否自身需要update

在判斷數(shù)據(jù)之間是否”一樣“之后,需要一個(gè)高效的Diff算法來計(jì)算出舊數(shù)據(jù)源更新到新數(shù)據(jù)源所需的"最短編輯距離",并調(diào)用相應(yīng)Api完成列表的更新。

IGListKit diff函數(shù)實(shí)現(xiàn)的是Paul Heckel的算法,它的時(shí)間復(fù)雜度為O(M+N)(M和N為新舊數(shù)據(jù)源的長度)。

IGlistKit diff函數(shù)的入?yún)⒅饕切屡f兩個(gè)數(shù)據(jù)源數(shù)組:

NSArray<id<IGListDiffable>> *oldArray,
NSArray<id<IGListDiffable>> *newArray,

其中新舊數(shù)據(jù)源中的每一個(gè)數(shù)據(jù)都有一個(gè)對應(yīng)的IGListEntry對象來表示和參與計(jì)算:

/// Used to track data stats while diffing.
struct IGListEntry {
    /// The number of times the data occurs in the old array
    NSInteger oldCounter = 0;
    /// The number of times the data occurs in the new array
    NSInteger newCounter = 0;
    /// The indexes of the data in the old array
    stack<NSInteger> oldIndexes;
    /// Flag marking if the data has been updated between arrays by checking the isEqual: method
    BOOL updated = NO;
};

IGListEntry的結(jié)構(gòu)和作用見上面代碼中的注釋,還是非常清晰的。

我們再來看整個(gè)diff算法的核心流程:

  1. 為newArray里的每個(gè)數(shù)據(jù)創(chuàng)建一個(gè)IGListEntry,將其newCounter計(jì)數(shù)+1,并push一個(gè)NSNotFound到entry的oldIndexes占位

  2. 為oldArray里的每個(gè)數(shù)據(jù)創(chuàng)建一個(gè)IGlistEntry(如果步驟1已創(chuàng)建的話則是獲取),將其oldCounter計(jì)數(shù)+1, 并push index到oldIndexes中。

    這里需要注意的是,oldArray是根據(jù)index倒序遍歷的,這樣是為了對應(yīng)oldIndexes使用的stack

  3. 通過遍歷newArray對應(yīng)的Entry List處理同時(shí)在新舊數(shù)據(jù)里出現(xiàn)的數(shù)據(jù),當(dāng)從oldIndexes pop出第一個(gè)元素不為NSNotFound,則代表這個(gè)數(shù)據(jù)在新舊數(shù)據(jù)源中都存在,并通過標(biāo)記這個(gè)數(shù)據(jù)是否更新

  4. 遍歷所有老的數(shù)據(jù)源,如果他沒有出現(xiàn)在新數(shù)據(jù)源中,則標(biāo)記為delete,并加入到delete容器中

  5. 遍歷所有新的數(shù)據(jù)源,

    如果他沒有出現(xiàn)在老的數(shù)據(jù)源中,則標(biāo)記為insert,并加入到insert容器中

    否則將其加入到update容器中,并通過比較delete和insert時(shí)記錄的indexOffset來判斷它是一個(gè)move還是update

從上面可以看出,這個(gè)Diff算法的空間和時(shí)間復(fù)雜度都是O(M+N),可以很好處理長列表的case(傳統(tǒng)LCS算法的復(fù)雜度需要O(N^2)!),且封裝了最后patch操作中offset相關(guān)的很多計(jì)算,杜絕了自己手動進(jìn)行更新時(shí)極容易出的index計(jì)算錯(cuò)誤導(dǎo)致的NSInternalInconsistencyException,只需要數(shù)據(jù)層實(shí)現(xiàn)IGListDiffable協(xié)議,就可以實(shí)現(xiàn)傻瓜又高效的列表刷新。

這也符合所有框架設(shè)計(jì)的哲學(xué):

將復(fù)雜易錯(cuò)的邏輯抽取封裝在久經(jīng)考驗(yàn)的代碼中,讓使用者只需要控制少量不容易犯錯(cuò)的”傻瓜“邏輯即可完成復(fù)雜的業(yè)務(wù)需求開發(fā)。

最后用Dart復(fù)刻了一遍IGListkit的diff算法 代碼在這里可以直接在線玩: diff in dart

參考資料:

A better way to update UICollectionView data in Swift with diff framework
Diff應(yīng)用:從LCS到UICollectionView

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容