蘋果在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算法的核心流程:
為newArray里的每個(gè)數(shù)據(jù)創(chuàng)建一個(gè)IGListEntry,將其newCounter計(jì)數(shù)+1,并push一個(gè)NSNotFound到entry的oldIndexes占位
-
為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
通過遍歷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ù)是否更新
遍歷所有老的數(shù)據(jù)源,如果他沒有出現(xiàn)在新數(shù)據(jù)源中,則標(biāo)記為delete,并加入到delete容器中
-
遍歷所有新的數(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