大規模重構——重寫 Instagram Feed 的經驗之談
在 Instagram 團隊重寫他們全新的 iOS Feed 的過程中,他們積累了大量的經驗,遇到的坑無疑已經超出了他們的預料,比如說集合視圖、差異化 (Diffing) 以及冗長代碼所帶來的危險之處。在本次 try! Swift 講演之中,Ryan Nystrom 向我們分享了如何才能進行一次成功的重構,并且向我們介紹了 Instagram 的一個很贊的開源組件:IGListKit。
我想和大家分享一下,我們是如何重構 Instagram Feed 這個模塊的。
我們為什么要重寫 Feed 呢?答案很簡單:因為 技術債務 (Technical Debt) 。
Instagram 已經 6 歲半了,但是底層代碼仍然一成不變。如果您去搜索 git 歷史并進行歸類的話,就會發現有很多 Instagram 的初始代碼提交。此外還有很多手動內存管理時代的東西,代碼雜亂無序,簡直是一團糟。這使得我們要做一些新的事情就比較捉襟見肘。
最初是怎么實現的呢?
最開始,我們使用了集合視圖。當我們在查看 Instagram 上的一個 Post 的時候,我們會看到一大個 Section,我們可以將它們分解成多個小單元格。分解的結果如下:
可以看到,頂部有一個補充視圖 (supplementary view),中間是視圖單元格,然后是包含操作項的單元格,最后就是底部的那些文本單元格了。這是完全由一個名為 “Feed Item” 的數據模型驅動的。
這樣我們便使用 FeedItem 來決定有多少條評論、是否要顯示圖片、是否要播放視頻、用戶名是什么等各種問題。我們整個應用是完全基于這個 FeedItem 數據模型來構建的,而這個數據模型就需要包含有圖像、視頻、評論等各種信息。當有人過來說:「我們想向這個 Feed 中增加一個這玩意兒」,但是我們只能遺憾地告訴他們「不行,我們沒法做,因為它不在 FeedItem 這個模型里面」。
這是一個單元格,是一位設計師設計的,并且還有一名產品經理一直在跟進這個設計,但是它里面的數據實際上是一組用戶,而不是評論,因此我們毫無辦法。我覺得對我們自己的團隊說「做不到」是很不好的一件事。
Instagram 是 2010 年的時候發布的,那個時候里面的數據還只有圖片。隨著時間的推移,我們便想要增加諸如視頻、用戶以及其他類型的數據模型。我很確定那個時候我們只是想著「改一點點就好了」,而不是選擇重構,我們選擇了錯誤的做法……因此我們只是 胡亂堆砌 ,有功能就往上加。
好吧,這就是我們的做法。我們沒有創建一系列獨立的小模型,而是創建了一個臃腫模型。解析這個模型變得越來越復雜,并且極大地拖慢了我們的速度。要記住這個單元格是映射為由 FeedItem 驅動的。如果您看一下 Instagram 當中的 Feed,實際上您看到不僅僅只是一個簡單的 Post,這里面包含了大量的數據信息。
我們視圖控制器的任務就是獲取對應的數據模型,然后將其放到 Section 當中,然后配置這些單元格(視圖控制器的數目有很多個)。對于集合視圖而言還有一個單獨的視圖控制器,繼承自執行網絡任務的視圖控制器,而這個網絡任務視圖控制器繼承自執行通用 Feed 的視圖控制器,此外對于應用的主界面來說,同樣也還有一個視圖控制器。這使得這類視圖控制器有四層的縱深。添加新單元格變得無比困難。
您可能會想「代碼復雜了一點,可能有些時候會減慢開發速度,那么這種做法糟糕嗎?」
沒錯,非常糟糕!技術債務開始拖后腿了。因此我們決定要嚴肅對待這個問題,因此我們在三個月前上線了新版本的 Feed。
我們的主要目標之一是解決視圖控制器層疊繼承的問題,我們想讓 Feed 更加輕巧、簡單,并且還能夠讓開發人員使用不同的單元格和數據模型。我們希望能夠擺脫 Feed Item 這個方法,這個數據類型是完全不可控的。
我們首先想到了差異化操作,這個概念是創建一個包含一系列模型的數組。當我們對這個數組進行刪除、插入或者移動操作的時候,就發生了值的更新。在構建基礎框架的時候,差異化操作是非常有用的,但是要用在集合視圖當中就非常困難。
首先,我們必須要刪除舊數組當中的內容,然后重新加載數據,才能將數組當中的內容放置到合適的位置,接著基于最后的索引來執行插入操作。要做好這個操作需要一點點數學知識。
差異化操作最原始實現的時間復雜度是 O(n2) 。當操作過多的話,就會發現速度被大大減緩了。我看過的大多數實現方式都會前往后臺隊列當中,執行一些數學運算,然后再回到前臺并繼續操作,但是即便這樣速度仍然不夠快。因為他們是用低優先級隊列來解決復雜問題,那么為什么不直接在主線程上執行呢?
我們去搜索了一下解決方案,然后發現一篇 撰于 1978 年的論文 ,作者是 Paul Heckel。這篇文章使用一個名為 最小公共子序列 (least common subsequence) 的東西,使得這個問題能夠在線性時間內得以解決。
基于這篇論文,我們編寫了一個算法,從而能夠在線性時間內找到兩個數據集之間所有需要刪除的內容,然后重新加載,然后執行插入和移動。這使得我們可以在主隊列當中執行這個操作,這樣我們便可以在集合視圖上執行所有的這些更新;這對我們而言提供了一個更為簡單的模型。此外,我們還想出了集合視圖才能更好地配合這個算法進行工作,這個過程中耗費的時間是大家無法想象的。
讓我們回到視圖控制器來,這時候我們已經去除了很多的內容了,我們將這些內容改寫為共享對象、使用系統庫等等來規避掉了。這樣就不必繼承這么多視圖控制器了。網絡歸網絡,諸如分析之類的主 Feed 可以變為一個共享對象。但是我們仍然還是要對 Feed 進行一些處理。
這里用到了一個我們稱之為「世界」的概念,視圖控制器知曉項目數組的全部信息,知道這些項目該如何添加到 Section 當中,知道這些 Section 該如何配置,知道單元格該如何填充。它將處理用戶交互、日志記錄、顯示事件等一系列操作。
在我們創建的新基礎框架當中,我們決定將這些任務進行分解。我們創建了一個名為「項目控制器 (Item Controller)」的抽象概念。實際上它是專門實現 Section 的一個小型視圖控制器。
在這里,我們決定項目的數量、對單元格進行配置、返回單元格尺寸,以及處理用戶交互。但最為重要的是,這里存放了所有的業務邏輯。我們也沒有用什么黑科技,它就是一個集合視圖,但是通過這樣的分解方式,使得我們可以向集合視圖當中添加任意一種類型的對象
而我們所要做的就是創建一個新的項目控制器,它會自行處理所有的邏輯。
我們此前覺得這簡直是異想天開,但是我們還是將其實現了。整個團隊為之歡欣鼓舞,我們將這個架構變成了我們的基礎框架。
我們能給大家回饋什么?
當我們構建完框架之后,我們意識到我們已經解決了個大問題,因此我們捫心自問,我們能給社區回饋什么呢?我們想要大家擺脫這個問題的困擾。
我們將開源一個全新的框架: IGListKit (發布時間待定),這個框架會幫助您實現我上面所述的那些內容。
我們所有的示例應用和文檔是完全用 Swift 編寫的,此外還使用了 Objective-C 可空性、完善的注釋以及泛型。這是完全適配 Swift 的,C++ 被完全掩蓋住了,您將不會看到一丁點 C++ 的內容。
這個框架當中最為重要的一個類就是 IGItemController 了。這就是我在一開始所提到的「項目控制器」。這里面的代碼不是很多。它默認只是處理了一個帶有文本標簽的單元格,僅此而已。
要讓這個類派上用場的話,我們需要創建一個新的項目控制器,然后讓其實現 IGListItemType 協議。
class LabelItemController: IGListItemController, IGListItemType {
...
}
在編譯時,這個協議可以確保您實現了所有必需的方法,比如說返回項目的總數:
func numberOfItems() -> UInt {
return 1
}
在 Instagram 的主 Feed 當中,我們存放了一個包含圖片、評論和動作條的動態數組。我們這里將會返回這個數組的大小。同時也要注意到,我們還有一個上下文對象 (context object):
func sizeForItemAtIndex(index: Int) -> CGSize {
return CGSize(width: collectionContext!.containerSize.width, height: 55)
}
這樣可以將這個單元格設置為屏幕的寬度,或者設置為其父容器的寬度,并且設置其高度為 55 個點。接下來就是一個全新的概念了,這與傳統的集合視圖截然不同。我們需要實現這個 didUpdateToItem 方法:
var item: String?
func didUpdateToItem(item: AnyObject) {
self.item = item as? String
}
在這里,基礎框架將會向您的項目控制器傳遞所需的模型。通過使用映射,我們可以將所有的模型與項目控制器建立映射關系。在這種情況下,我們得到了一個項目之后,我們可以選擇將其轉換為字符串,然后存儲到一個實例變量當中。接下來我們就可以讀取這個實例變量,然后在對應索引的單元格項目當中,對這個單元格進行重用,然后設置標簽上的文本,然后再將這個單元格返回,這樣就和集合視圖的數據源類似了。
我們已經移除了重用標識符的概念,并且我們還完全消除了注冊單元格的需求和提供補充視圖的需求。因此,這就是這個控制器所做的工作了。那么我們該如何去使用這個項目控制器呢?
這里我們推出了 IGListAdapter 的概念:
//MARK: IgListAdapterDataSource
func itemsForListAdapter(listAdapter: IGListAdapter) -> [IGListDiffable] {
return [
"Foo",
"Bar",
"Baz"
]
}
func listAdapter(listAdapter: IGListAdapter, itemControllerForItem item: AnyObject) -> IGListItemController {
return LabelItemController()
}
它將會把相應的數據、所有的項目控制器以及您的集合視圖糅合在一起,讓它們能夠共同協作。為了要使用這個功能,我們需要連接數據源。
在第一個方法當中,我們僅僅只是返回了一個數組。現在它們當中的值全都是字符串。這里實際上可以是任何值。注意到返回類型實際上是 IGListDiffable 。我們已經為這個協議提供了標準的實現,因此大家無需去關注太多內容。不過,您仍然可以重寫并擴展這個協議,以便實現自己的處理操作,從而實現更靈活的差異化操作。然后還有另外一個方法,對于指定的項目,我們可以返回對應的項目控制器。這里我們只返回了相同的基礎項目控制器,這里面只有一個標簽。
假設當我們在等待一個網絡請求返回數據的時候,我們想要添加一個指示器。這樣我們可以創建一個令牌對象(這里只是一個名為 spinToken 的 NSObject 對象),我們可以將其放到數組的中間位置。由于這里是一個協議,因此我們可以放上我們所期望的任何類型的模型對象。接下來,當框架讓我們提供項目控制器的時候,我們需要檢查「這個項目是否是 spinToken?」如果是的話,我們就返回這個新的 SpinnerItemController 。否則就保持原樣:
func listAdapter(listAdapter: IGListAdapter,
itemControllerForItem item: AnyObject) -> IGListItemController {
if item === spinToken {
return SpinnerItemController()
} else {
return LabelItemController()
}
}
這樣便會在我們的單元格中間顯示一個指示器:
這看起來可能不是很 exciting,但是我其實對我們搞的這個大新聞很是驕傲。試想假設我們提供了一個 UISearchBar 。當用戶輸入了文本之后,我們就可以實時更新搜索結果了:
func searchBar(searchBar: UISearchBar, textDidChange searchText: String) {
filterString = text
adapter.performUpdatesAnimated(true, completion: nil)
}
因此在這個 searchBar 委托方法當中,我們向實例變量中存放了用戶搜索的文本,然后調用 performUpdatesAnimated 方法。這將告知框架去獲取新的項目,然后執行差異化操作,更新集合視圖。
我們同樣可以對數組進行匹配:
let words = ["Foo", "Bar", "Baz"]
func itemsForListAdapter(listAdapter: IGListAdapter) -> [IGListDiffable] {
return words.filter { word in
return word.containsString(filterString)
}
}
我們對字符串數組進行匹配操作,然后將匹配的結果返回。這個方法是一個數據源方法,因為只有當您通知框架進行更新的時候這個方法才會被調用。它會自動執行插入、刪除、更新等所有在集合視圖上發生的操作。我并沒有為這個集合視圖撰寫任何一條代碼;我只是配置了下單元格、項目控制器,然后通知適配器 (adapter) 進行更新就行了。所有的動畫和更新操作都在框架內部執行了。
為什么要使用 IGListKit 呢?
假設我有一個很簡單的應用,其中有一個很簡單的表視圖,我調用了它的 reloadData 方法。使用 IGListKit 有什么好處呢?其實,我建議當您遇到像 Feed 一樣,視圖當中有多種數據類型的時候使用 IGListKit 會更好。如果您的 Feed 非常復雜,并且也討厭去處理那些惱人的整數枚舉的話(我就是這樣的人),那么 IGListKit 正是您的選擇。
如果您希望有一個快速、不會發生崩潰、同時擁有更新動畫的 Feed 的話,那么您也可以選擇 IGListKit。這同樣也會鼓勵您撰寫可重用的功能組件,將您的單元格和項目控制器從視圖控制器當中分離。
我可以在某個地方編寫一個項目控制器,然后在其他視圖控制器當中使用,因為它們不需要去考慮其父容器的情況。同樣我也很高興,我不用再去調用那些煩人的 performBatchUpdates 或者 reloadData 方法了。
您可能會想「Instagram 編寫了這個框架,那么我該不該去使用它呢?」在應用發布的 15 分鐘后,我們在全球范圍內處理了 3,900 萬次差異化操作,而這些操作沒有一例發生了崩潰,并且這些操作都是在主線程進行的,也沒有卡頓的現象發生。
這整個項目源于我們期望重寫我們的 Feed。現在我們的「Explore」頁面、「Activity Feed」,甚至通信模塊當中的那些復雜單元格和交互也使用了 IGListKit 。我們一個月前發布的 Instagram Stories 這個產品,也使用了這個框架,并且是完完全全使用 IGListKit 構建的。我們當然非常贊同大家來使用這個框架。因為這正是我們未來應用也要使用的。
IGListKit 即將到來
我在這兒期望能和大家分享一些 Instagram 中工作的一些故事,我希望大家能從中學習到一些知識,并且將這些知識應用到您所在組織的應用當中。我真的很高興看到大家使用 IGListKit 構建自己的應用!
來自:https://realm.io/cn/news/tryswift-ryan-nystrom-refactoring-at-scale-lessons-learned-rewriting-instagram-feed/