本文由CocoaChina--夜微眠(github)翻譯
作者:@Todd Kramer
原文:Improving UICollectionView & UITableView Scrolling Performance With AsyncDisplayKit
目標:使用AsyncDisplayKit和Alamofire的異步下載、緩存以及圖像解碼 來提升UICollectionView的滾動性能。
上一篇教程 Downloading & Caching Images Asynchronously In Swift With Alamofire (使用Alamofire異步下載以及緩存圖片)中,我描述了如何使用Alamofire和AlamofireImage 庫異步下載和緩存圖片,進而顯示在UICollectionView中。通過使用這些庫可以輕松實現滾動流暢的滾動視圖和集合視圖,但是如果你的UI很復雜,圖片很多,有可能不能達到60fps
此次教程中我們使用Facebook AsyncDisplayKit庫重建Glacier Scenics工程,AsyncDisplayKit有很多我們提升滾動流暢所需要的工具以及圖片異步下載功能(如果你對緩存不感興趣)。如果需要實現緩存的話,Alamofire和AlamofireImage還是可以派上用場。
AsyncDisplayKit 概覽
AsyncDisplayKit用相關node類,替換了UIView和它的子類,而且是線程安全的。它可以異步解碼圖片,調整圖片大小以及對圖片和文本進行渲染。在大部分項目中,主要的目標就是實現圖片異步解碼。UIImage顯示之前必須要先解碼完成,而且解碼還是同步的。尤其是在UICollectionView/UITableView 中使用 prototype cell顯示大圖,UIImage的同步解碼在滾動的時候會有明顯的卡頓。
另外一個很吸引人的點是AsyncDisplayKit可以把view層次結構轉成layer。因為復雜的view層次結構開銷很大,如果不需要view特有的功能(例如點擊事件),就可以使用AsyncDisplayKit 的layer backing特性從而獲得一些額外的提升。
AsyncDisplayKit還有很多其他的特性,最後要提到就是基於node把UICollectionView 和 UITableView 替換為 ASCollectionView 和 ASTableView 的特性。替換的類可以使用UIkit中大量的數據源和 delegate方法,這樣便於你很快適應從UIKit部分到基於node架構的變化。
盡管AsyncDisplayKit基於node的架構,但每個node都有相應UIView 屬性。這樣你可以添加不需要與node類有交互的子視圖。
設置
下圖就是我們完成的工程
工程依賴
使用CocoaPods獲取AsyncDisplayKit依賴,下面是Podfile
platform :ios, '8.0' use_frameworks! target 'GlacierScenics' do pod 'AsyncDisplayKit' end
數據
圖片的名稱和URL從property list(plist)文件獲取,分別是兩個帶有"name" and "imageURL"的數組。
Storyboard
項目中的Storyboard很簡單,是因為AsyncDisplayKit不支持Storyboard,所以相關約束都用代碼實現。我們只需要一個navigation controller 和root view controller(等下會被設成PhotosViewController)
ASCollectionView默認圖片下載
第一步從plist裡讀取數據。我們先定義一個簡單的struct GlacierScenic 存照片信息
struct GlacierScenic { let name: String let photoURLString: String }
這就可以了。下一步我們創建一個數據管理器從plist讀取和存儲照片信息。
class PhotosDataManager { static let sharedManager = PhotosDataManager() private var photos = [GlacierScenic]() func allPhotos() -> [GlacierScenic] { if !photos.isEmpty { return photos } guard let data = NSArray(contentsOfFile: dataPath()) as? [NSDictionary] else { return photos } for photoInfo in data { let name = photoInfo["name"] as! String let urlString = photoInfo["imageURL"] as! String let glacierScenic = GlacierScenic(name: name, photoURLString: urlString) photos.append(glacierScenic) } return photos } func dataPath() -> String { return NSBundle.mainBundle().pathForResource("GlacierScenics", ofType: "plist")! } }
接下來看下view controller代碼
import UIKit import AsyncDisplayKit class PhotosViewController: UIViewController { var collectionView: ASCollectionView! var photosDataSource = PhotosDataSource() //MARK: - View Controller Lifecycle override func viewDidLoad() { super.viewDidLoad() configureCollectionView() } override func prefersStatusBarHidden() -> Bool { return true } override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator) coordinator.animateAlongsideTransition({ (context) -> Void in self.collectionView.frame.size = self.view.frame.size self.collectionView.reloadData() }, completion: nil) } //MARK: - Collection View func configureCollectionView() { let layout = UICollectionViewFlowLayout() layout.minimumInteritemSpacing = 1 layout.minimumLineSpacing = 1 var frame = view.frame if let navigationBar = navigationController?.navigationBar { frame.size.height -= navigationBar.frame.height } collectionView = ASCollectionView(frame: frame, collectionViewLayout: layout) collectionView.backgroundColor = UIColor.blackColor() collectionView.asyncDataSource = photosDataSource view.addSubview(collectionView) collectionView.reloadData() } }
我們這所需要做的就是配置collection view 以及處理不同大小size classes之間的轉場變化。
這裡有一些注意事項:
第一,ASCollectionView使用asyncDataSource和asyncDelegate。這個很重要,因為ASCollectionView也有標准的data Source 和 delegate。所以獲取數據源和委托的時候不要混淆。
第二,ASCollectionView構造器需要UICollectionViewLayout參數,但是不是所有的布局配置能生效。一個很重要的例子就是cell大小,這個需要用另外的方法處理。
最後,collectionView的布局屬性有個方法invalidateLayout不起作用(問題),所以我們不使用viewWillTransitionToSize方法。
現在我們需要實現上邊設置的data source
import UIKit import AsyncDisplayKit class PhotosDataSource: NSObject, ASCollectionDataSource { func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int { return 1 } func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return PhotosDataManager.sharedManager.allPhotos().count } func collectionView(collectionView: ASCollectionView, nodeForItemAtIndexPath indexPath: NSIndexPath) -> ASCellNode { let glacierScenic = glacierScenicAtIndex(indexPath) return PhotoCollectionViewCellNode(glacierScenic: glacierScenic) } func glacierScenicAtIndex(indexPath: NSIndexPath) -> GlacierScenic { let photos = PhotosDataManager.sharedManager.allPhotos() return photos[indexPath.row] } }
這段代碼很簡單。我們用一個section顯示圖片。接著我們返回一個新的collection view cell node (AsyncDisplayKit的 ASCellNode 子類)。
要注意的是AsyncDisplayKit 用nodeForItem 方法替換了cellForItem方法,也就需要在collection view上注冊reuse identifiers。
最後就是PhotoCollectionViewCellNode。
import UIKit import AsyncDisplayKit class PhotoCollectionViewCellNode: ASCellNode { var loadingIndicator = UIActivityIndicatorView(activityIndicatorStyle: .WhiteLarge) var imageNode = ASNetworkImageNode() var blurView = UIVisualEffectView(effect: UIBlurEffect(style: .Light)) var captionContainerNode = ASDisplayNode() var captionLabelNode = AttributedTextNode() let glacierScenic: GlacierScenic var nodeSize: CGSize { let spacing: CGFloat = 1 let screenWidth = UIScreen.mainScreen().bounds.width let itemWidth = floor((screenWidth / 2) - (spacing / 2)) let itemHeight = floor((screenWidth / 3) - (spacing / 2)) return CGSize(width: itemWidth, height: itemHeight) } init(glacierScenic: GlacierScenic) { self.glacierScenic = glacierScenic super.init() configure() } func configure() { backgroundColor = UIColor.blackColor() configureLoadingIndicator() configureImageNode() configureCaptionNodes() } func configureLoadingIndicator() { loadingIndicator.center = loadingIndicatorCenter() view.addSubview(loadingIndicator) loadingIndicator.startAnimating() view.addSubview(loadingIndicator) } func loadingIndicatorCenter() -> CGPoint { let centerX = nodeSize.width / 2 let centerY = nodeSize.height / 2 - captionContainerFrame().height / 2 return CGPoint(x: centerX, y: centerY) } func configureImageNode() { imageNode.frame = viewFrame() imageNode.delegate = self imageNode.URL = NSURL(string: glacierScenic.photoURLString) addSubnode(imageNode) } func configureCaptionNodes() { configureCaptionBlurView() configureCaptionContainerNode() configureCaptionLabelNode() } func configureCaptionBlurView() { blurView.frame = captionContainerFrame() view.addSubview(blurView) } func configureCaptionContainerNode() { captionContainerNode.frame = captionContainerFrame() captionContainerNode.backgroundColor = UIColor.blackColor().colorWithAlphaComponent(0.5) addSubnode(captionContainerNode) } func configureCaptionLabelNode() { captionLabelNode.configure(glacierScenic.name, size: 16, textAlignment: .Center) let constrainedSize = CGSize(width: nodeSize.width, height: CGFloat.max) let labelNodeHeight: CGFloat = captionLabelNode.attributedString!.boundingRectWithSize(constrainedSize, options: .UsesFontLeading, context: nil).height let labelNodeYValue = captionContainerFrame().height / 2 - labelNodeHeight / 2 captionLabelNode.frame = CGRect(x: 0, y: labelNodeYValue, width: nodeSize.width, height: labelNodeHeight) captionContainerNode.addSubnode(captionLabelNode) } func captionContainerFrame() -> CGRect { let containerHeight: CGFloat = 35 return CGRect(x: 0, y: nodeSize.height - containerHeight, width: nodeSize.width, height: containerHeight) } func viewFrame() -> CGRect { return CGRect(x: 0, y: 0, width: nodeSize.width, height: nodeSize.height) } override func calculateLayoutThatFits(constrainedSize: ASSizeRange) -> ASLayout { return ASLayout(layoutableObject: self, size: nodeSize) } } extension PhotoCollectionViewCellNode: ASNetworkImageNodeDelegate { func imageNode(imageNode: ASNetworkImageNode, didLoadImage image: UIImage) { loadingIndicator.stopAnimating() } }
你可能已經注意到很多代碼都是layout代碼。是因為AsyncDisplayKit使用動態布局機制。復雜的布局已經超出了這篇教程的范圍,但如果你只需要一個固定的cell大小,那重寫calculateLayoutThatFits方法就可以了。注意,計算型屬性“nodeSize”代碼在類的頂部。
AsyncDisplayKit使得異步下載圖片變得非常簡單,此外ASImageNode可以作為UIImageView的一部分,AsyncDisplayKit還有ASNetworkImageNode子類 ,你只需要把圖片設置URL屬性就可以了。
在這個例子中,我們還需要一個在圖片下載完成時終止加載動畫的加載指示器。因為ASNetworkImageNode有delegate屬性,等下我們可以使用擴展來實現delegate和處理加載指示。delegate還提供了何時圖片解碼完成以及圖片下載失敗的方法。
下一步使用“AttributedTextNode”作為標題,與UILabel不同,ASTextNode沒有默認字體的“text”屬性,它使用attributed string。AttributedTextNode子類提供了一個實用的函數來處理node的attributed string
import UIKit import AsyncDisplayKit class AttributedTextNode: ASTextNode { func configure(text: String, size: CGFloat, color: UIColor = UIColor.whiteColor(), textAlignment: NSTextAlignment = .Left) { let mutableString = NSMutableAttributedString(string: text) let range = NSMakeRange(0, text.characters.count) mutableString.addAttribute(NSFontAttributeName, value: UIFont.systemFontOfSize(size), range: range) mutableString.addAttribute(NSForegroundColorAttributeName, value: color, range: range) let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = textAlignment mutableString.addAttribute(NSParagraphStyleAttributeName, value: paragraphStyle, range: range) attributedString = mutableString } }
最後,如上所述,我們能獲取cell node的view屬性來添加沒有相應node classes的subview 。本文例子中就有UIActivityIndicatorView 和 UIVisualEffectView
圖像緩存
AsyncDisplayKit讓異步圖片下載變得非常簡單,但是沒有默認緩存支持。那麼為了實現緩存,我們需要替代AsyncDisplayKit默認的下載器,所以我們用Alamofire和AlamofireImage實現下載和緩存。首先我們先更新Podfile
platform :ios, '8.0' use_frameworks! target 'GlacierScenics' do pod 'AsyncDisplayKit' pod 'AlamofireImage', '~> 2.0' end
警告:運行前,先執行pod install
之前,我們用無參數初始化network image node。AsyncDisplayKit還有另外一個以緩存和下載器為參數的構造器。
緩存和下載器需要遵照ASImageCacheProtocol 和 ASImageDownloaderProtocol 協議。我們工程中緩存和下載及都實現在PhotosDataManager 中,所以我們需要更新PhotosDataManager以實現這些協議並提供緩存。
import UIKit import Alamofire import AlamofireImage import AsyncDisplayKit class PhotosDataManager: NSObject { static let sharedManager = PhotosDataManager() private var photos = [GlacierScenic]() let photoCache = AutoPurgingImageCache( memoryCapacity: 100 * 1024 * 1024, preferredMemoryUsageAfterPurge: 60 * 1024 * 1024 ) func allPhotos() -> [GlacierScenic] { if !photos.isEmpty { return photos } guard let data = NSArray(contentsOfFile: dataPath()) as? [NSDictionary] else { return photos } for photoInfo in data { let name = photoInfo["name"] as! String let urlString = photoInfo["imageURL"] as! String let glacierScenic = GlacierScenic(name: name, photoURLString: urlString) photos.append(glacierScenic) } return photos } func cacheImage(url: String, image: Image) { photoCache.addImage(image, withIdentifier: url) } func cachedImage(url: String) -> Image? { return photoCache.imageWithIdentifier(url) } func dataPath() -> String { return NSBundle.mainBundle().pathForResource("GlacierScenics", ofType: "plist")! } } extension PhotosDataManager: ASImageDownloaderProtocol { func downloadImageWithURL(URL: NSURL, callbackQueue: dispatch_queue_t?, downloadProgressBlock: ((CGFloat) -> Void)?, completion: ((CGImage?, NSError?) -> Void)?) -> AnyObject? { let request = Alamofire.request(.GET, URL.absoluteString).responseImage { (response) -> Void in guard let image = response.result.value else { completion?(nil, nil) return } self.cacheImage(URL.absoluteString, image: image) completion?(image.CGImage, nil) } return request } func cancelImageDownloadForIdentifier(downloadIdentifier: AnyObject?) { if let request = downloadIdentifier where request is Request { (request as! Request).cancel() } } } extension PhotosDataManager: ASImageCacheProtocol { func fetchCachedImageWithURL(URL: NSURL?, callbackQueue: dispatch_queue_t?, completion: (CGImage?) -> Void) { if let url = URL, cachedImage = cachedImage(url.absoluteString) { completion(cachedImage.CGImage) return } completion(nil) } }
現在我們加了photoCache 以及兩個函數,一個用於緩存圖片,另外一個用於獲取緩存圖片。緩存最大為100MB,最優為60MB。緩存標識使用圖片的URL,AsyncDisplayKit協議將會在設置network image node的URL屬性後進行傳遞。
接著我們實現協議。第一個協議ASImageCacheProtocol就包含一個方法fetchCachedImageWithURL,用於獲取緩存圖片,如果對於的URL的圖片存在,就返回。否則nil傳給completion block,這樣就會觸發下載圖片。
第二個協議ASImageDownloaderProtocol 包含兩個方法,一個下載另一個取消下載。下載方法裡我們用Alamofire Request下載圖片,如果下載成功則進行緩存,然後調用 completion block。要注意的是,我們也要返回請求對象。如果取消下載,則"cancelImageDownloadForIdentifier"方法會用到它。
在取消方法裡,先檢查下載標識是否存在,request 是不是Request對象,然後在request上調用cancel()方法。
最後,我們替換掉PhotoCollectionViewCellNode 裡ASNetworkImageNode 構造器
func configureImageNode() { let manager = PhotosDataManager.sharedManager imageNode = ASNetworkImageNode(cache: manager, downloader: manager) imageNode.frame = viewFrame() imageNode.delegate = self imageNode.URL = NSURL(string: glacierScenic.photoURLString) addSubnode(imageNode) }
Layer Backing
在介紹之前,我們再加一個優化。就是AsyncDisplayKit概覽中提到layer backing。它能夠幫助我們通過將視圖層次結構轉成layer層來提升滾動性能。我們的案例中,
view/node的層次結構不太復雜,但是有兩處可以添加Layer Backing。第一處就是image node,實現起來就一行代碼,將layerBacked 屬性設置為true。
func configureImageNode() { let manager = PhotosDataManager.sharedManager imageNode = ASNetworkImageNode(cache: manager, downloader: manager) imageNode.frame = viewFrame() imageNode.delegate = self imageNode.URL = NSURL(string: glacierScenic.photoURLString) imageNode.layerBacked = true addSubnode(imageNode) }
第二處就是 container node 以及caption label subnode。
func configureCaptionContainerNode() { captionContainerNode.frame = captionContainerFrame() captionContainerNode.backgroundColor = UIColor.blackColor().colorWithAlphaComponent(0.5) captionContainerNode.layerBacked = true addSubnode(captionContainerNode) } func configureCaptionLabelNode() { captionLabelNode.configure(glacierScenic.name, size: 16, textAlignment: .Center) let constrainedSize = CGSize(width: nodeSize.width, height: CGFloat.max) let labelNodeHeight: CGFloat = captionLabelNode.attributedString!.boundingRectWithSize(constrainedSize, options: .UsesFontLeading, context: nil).height let labelNodeYValue = captionContainerFrame().height / 2 - labelNodeHeight / 2 captionLabelNode.frame = CGRect(x: 0, y: labelNodeYValue, width: nodeSize.width, height: labelNodeHeight) captionContainerNode.layer.addSublayer(captionLabelNode.layer) }
AsyncDisplayKit通過把圖片解碼、大小調整以及圖像文本的渲染放在子線程,從而提升collectionview 和 tableview的滾動性能。也正如剛才看到的,AsyncDisplayKit默認下載不支持緩存,所以使用前需要考慮到AsyncDisplayKit一些不足的地方。
第一,AsyncDisplayKit不支持Storyboard、Xib以及Autolayout,不過並不意味著你不能在項目中使用這些工具,事實上我們依然在這個項目中使用了storyboard。如果你需要用Interface Builder和Autolayout實現collection view,那就需要另外的方法來提高流暢度。當然,如果不使用Autolayout就用程序寫frame這樣可以減少約束相關的消耗。總的來說,如果項目中一定要用到Autolayout,可能就要自己實現異步圖片解碼了。
第二,UITableView 和 UICollectionView一些重要的方法沒有被AsyncDisplayKit替換或繼承。在寫這篇文章前,他們還處於開發階段,有肯能會有所變化。
總的來說,無論用不用AsyncDisplayKit或者其他第三方庫,這個取決於cell和collection view UI相關細節。雖然有時候你決定自己實現它的一些功能,但是該庫提供
一個很好的處理UITableView 和 UICollectionView性能問題的途徑。
文章中使用的項目源碼存放在"GlacierScenicsAsyncDisplayKit" 文件夾下。