自己做了一個模仿簡書的小專案練手,主要佈局是上面的scrollview有一排label,下面的scrollview有多個UITableView。點選上面的label,下面就可以顯示不同的頁面。具體效果可以開啟簡書官方的APP檢視,很多新聞軟體也是這種效果。
一開始的思路就是載入所有ViewController,因為是TableView,所以每個TableView還有自己的DataSource,真機執行了一下,發現佔用記憶體大概是36M左右。於是我開始著手對這種原始的實現方案進行逐步優化,主要是記憶體佔用相關的,以及一些其他的小技巧。
專案在Github開源,本文涉及到的相關程式碼都可以自行檢視。專案地址:MJianshu
優化一:分離DataSource
為了輕量化UIViewController
,同時也為了後期的解耦,我首先把DataSource
從UIViewController
中分離出來。思路是在UIViewController
中引用一個DataSource
物件,然後把table
的dataSource屬性設定成這個變數而不是自己,用程式碼描述就是:
1 2 3 4 |
// UIViewController.swift var dataSource = ContentTableDatasource() tableView.dataSource = dataSource |
把DataSource相關的代理方法都放到ContentTableDatasource
中去:
1 2 3 4 5 6 7 8 9 |
extension ContentTableDatasource { func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { //行數 } func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { //返回cell } } |
這樣做的好處在於,UIViewController
對具體的資料獲取一無所知,它只負責給table
委派資料來源的任務。只要改變資料來源,table
的內容就可以改變。這也符合MVC模式中M和C的解耦。更詳細的介紹在objc.io的Lighter View Controllers一文中。
優化二:重用ViewController
如果不考慮點選頂部標籤的情況,也就是隻能滑動BottomScrollview
,我們可以注意到一個事實。比如當前我在第五頁,不管我要滑到其他的任何一頁,都必須經過第四頁或第六頁。也就是說在這種情況下,除了4、5、6這三頁的UIViewController
,其他的都是無用的。一旦我向左滑到第四頁,那麼第六頁的UIViewController
也是無用的,它可以被重複利用,裝載第三頁所顯示的UIView
所以,思路就是模仿UITableView
的重用機制維護一個佇列,實現UIViewController
的重用。每當一個UIViewController
變成無用的,就放入重用佇列。需要UIViewController
時先從重用佇列中找,如果找不到就新建。這樣一來記憶體中最多隻會儲存三個UIViewController
的例項,所以佔用記憶體大幅度降低。核心程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
func scrollViewDidScroll(scrollView: UIScrollView) { // 載入即將出現的頁面 loadPage(page) } func loadPage(page: Int) { guard currentPage != page else { return } //還在當前頁面就不用載入了 currentPage = page var pagesToLoad = [page - 1, page, page + 1] // 篩選出需要載入的頁面,一般只有一個 var vcsToEnqueue: Array = [] // 把用不到的ViewController入隊 } func addViewControllerForPage(page: Int) { let vc = dequeueReusableViewController() // 從佇列中獲取VC vc.pageID = page // 新增檢視 } func dequeueReusableViewController() -> ContentTableController { if reusableViewControllers.count > 0 { return reusableViewControllers.removeFirst() // 如果有可以重用的VC就直接返回 } else { //否則就建立。程式剛開始執行的時候一般需要執行這一步 let vc = ContentTableController() return vc } } |
關於重用佇列,可以參考這個專案:Reuse
優化三:點選Label後的過渡
如果從第一頁滑動到第三頁,那麼第二頁也會快速閃過。這樣會導致使用者體驗比較差。我的思路是首先在第二頁的位置上覆蓋一個和第一頁一模一樣的UIView
,然後不加動畫的切換到第二頁。這一瞬間使用者感覺不到任何變化。然後再有動畫的滑動到第三頁。滑動完成之後需要移除這個臨時新增的UIView
,關鍵步驟如下所示
1 2 3 4 5 6 7 8 9 10 |
var maskView = UIView() maskView = bottomScrollViewController.currentDisplayViewController()?.view // 獲取用於遮蓋的view bottomScrollView.addBottomViewAtIndex(targetPage - 1, view: maskView) // 把view新增到目標頁的前一頁 buttomScrollView.bottomScroll.setContentOffset(CGPointMake(previousOffSetX, 0), animated: false) //無動畫滑動 buttomScrollView.bottomScroll.setContentOffset(CGPointMake(offSetX, 0), animated: true) //有動畫滑動 func scrollViewDidEndScrollingAnimation(scrollView: UIScrollView) { maskView.removeFromSuperview() // 滑動結束後移除臨時檢視 } |
實際操作遠比這個複雜。因為要實現UIViewController
的重用,所以在scrollViewDidScroll
這個代理方法中需要時刻監聽滑動狀態並載入下一頁。在點選Label的時候需要禁掉這個特性。
總的來說,點選Label的切換和滑動切換頁面並不是同一個原理,所以要保證他們之間的邏輯互不干擾
優化四:快取DataSource
最初的邏輯是每個UIViewController
自己處理自己的dataSource
,現在因為在BottomScrollview
中處理UIViewController
的重用邏輯,所以dataSource的快取和獲取也就一併放在這裡處理了。每個UIViewController
重用時都會根據自己的頁數去快取中查詢dataSource
是否已經存在,如果已經存在的話就直接獲取了。關鍵程式碼如下所示:
1 2 3 4 5 6 7 8 |
var dataSources: [Int: ContentTableDatasource] = [:] // 鍵是頁數,值是datasource物件 func bindDataSourceWithViewController(viewController: ContentTableController, page: Int) { if dataSources[page] == nil { // 如果不存在,就去新建datasource dataSources[page] = ContentTableDatasource(page: page) } viewController.dataSource = dataSources[page] } |
實際上dataSource
也可以重用,但是這樣做並不能節省太多記憶體,反而會導致dataSource
中內容的反覆切換,有點得不償失
防掉坑指南
最後再談一談UIScrollView
中的一些坑,之前也寫過一篇文章——史上最簡單的UIScrollView+Autolayout出坑指南,主要是關於UIScrollView
在Autolayout下的佈局問題。在後續的開發過程中,還是遇到了一些值得注意的地方。
因為UIScrollView
是可以滑動的,所以對它的佈局約束要格外小心。舉個例子,一個子檢視的left
已經確定,這時候不管設定它的right
約束還是width
約束都可以固定它的位置。但是在UIScrollView
,千萬不要設定right
約束。否則你可以想象一下,有一個橡皮筋,一端被固定,另一端被拉伸的感覺:
1 2 |
make.right.equalTo(view) // 滑動時檢視會被拉伸 make.width.equalTo(viewWidth) // 正確 |
這樣的bug非常難找到,所以我個人的經驗是,在對UIScrollView
的子檢視佈局時,儘量不要用兩端的位置來確定檢視自己的長度,而是應該通過自己長度確定另一端的位置。或者,乾脆不要依賴於外部檢視佈局,而是用一個Container
容器。這也是我在之前的文章中強烈推薦的方法。
成果:
記憶體佔用顯著減少,只有大約原來的一半。考慮到程式還有其他地方佔用記憶體,可以認為重用機制降低了Scrollview
超過50%的記憶體佔用:
不過這麼做還是稍有不足,如果資料量比較大,頻繁的重用UIViewController
會導致多次reloadData()
。切換頁面的時候會稍有卡頓的感覺。也許是我哪裡考慮欠周,歡迎指正。目前來看,重用機智更適合於呈現靜態內容的UIViewController
。
專案地址:戳這裡,歡迎star。