第2章:使用 UICollectionView 顯示內(nèi)容

注:
本文翻譯自 《iOS UICollectionView The Complete Guide 2nd Edition》
使用的翻譯工具:https://www.deepl.com/translator

現(xiàn)在,你已經(jīng)了解了如何在遵循 Model-View-Control (MVC) 設(shè)計模式的前提下,在 iOS 應(yīng)用中使用集合視圖。是時候嘗試新東西了:代碼。本章一開始很簡單,展示了如何使用 storyboards.xibs 來設(shè)置集合視圖,然后告訴你如何在代碼中設(shè)置它們。集合視圖擴展了它的父類 UIScrollView,所以本章簡單的繞了一圈,展示如何使用 UIScrollViewDelegate 來發(fā)揮你的優(yōu)勢。在結(jié)束關(guān)于性能的案例研究之前,你將開始使用單元格重用來定制實際的內(nèi)容以顯示給你的用戶。

使用代碼和 Storyboards 進行設(shè)置

傳統(tǒng)意義上,.xib 文件用于為 OS X 和 iOS 應(yīng)用設(shè)置并布局 UI 代碼。這些文件是你的界面的 "凍結(jié) "版本,會在運行時解凍。.xibs 文件的好處是,它們很容易用來創(chuàng)建基本的接口;通常每個.xib 都有一個 UIViewController 的實例。

Storyboards 首次被引入是在 2011年的 iOS 5 中,它使開發(fā)者能夠直觀地布局視圖控制器之間的交互。開發(fā)者不僅可以可視化視圖控制器之間的連接,還可以定義整個應(yīng)用如何從一個視圖控制器過渡到另一個視圖控制器。Storyboards 的關(guān)鍵之處在于它們的效率;一個巨大的 .xib 文件,必須完全加載到內(nèi)存中,會延遲你的應(yīng)用程序啟動的時間。Storyboards 可以有效地只延遲加載(Lazy Loading)必要的視圖控制器。

當(dāng)然,任何可以在 .xib 文件或 storyboard 中做的事情都可以完全使用手寫代碼來實現(xiàn)。如果你正在將集合視圖集成到你現(xiàn)有的應(yīng)用程序中,該應(yīng)用程序使用 .xib 文件或 storyboard,繼續(xù)使用它們可能會很方便。然而,由于集合視圖需要使用代碼來進行布局,因此完全避免使用 .xib 和 storyboard 往往更容易。盡管如此,本章還是闡述了如何使用 storyboard 設(shè)置上一章的集合視圖,然后再演示完全使用代碼進行設(shè)置。

使用 "Single View template" 模板創(chuàng)建一個新的 Xcode 項目。確保 "Use Storyboards" 被選中。打開 Main.storyboard 文件,刪除已經(jīng)存在的視圖控制器。從右側(cè)窗格的對象庫中拖動一個集合視圖控制器到空畫布上,如圖2.1 所示。

(略)

你現(xiàn)在就可以運行這個應(yīng)用,它可以正常工作,但會很無聊。storyboard 文件中已經(jīng)默認(rèn)設(shè)置了集合視圖的委托和數(shù)據(jù)源插座連接,指向你的集合視圖控制器。下一步是自定義該視圖控制器的實際功能。這部分很簡單,因為你只需要復(fù)制第1章 "理解 iOS 中的 Model-View-Controller "中的現(xiàn)有代碼。

打開你的視圖控制器的頭文件,改變它繼承自哪個類(將 UIViewController 改為 UICollectionViewController)。然后把上一章的實現(xiàn)文件完整地復(fù)制過來。最后,重要的一步是告訴你的 storyboard 應(yīng)該使用哪個視圖控制器。點擊 storyboard 中的集合視圖控制器,打開 "Identity Inspector"。在 Class 的地方,你會看到默認(rèn)的占位符是 UICollectionViewController。無聊! 用你的視圖控制器的名字來代替--在我的例子中,它是 AFViewController。

這一步至關(guān)重要,它是 storyboard 如何知道在布局集合視圖時要執(zhí)行什么代碼的原因。運行你的應(yīng)用程序,你會看到和第1章一樣的輸出。

使用 storyboard 或 .xibs 文件時,你可以在不編寫任何代碼的情況下更改集合視圖的視覺顯示樣式。在 storyboards 中選擇集合視圖,然后打開 "Attributes Inspector"。在這里,你可以將集合視圖的滾動方向從默認(rèn)的垂直方向改為水平方向。你還可以更改集合視圖屬于其父類 UIScrollView 的屬性。將滾動指示器 "樣式 "改為白色,使?jié)L動指示器在黑色背景下可見。

打開 "Size Inspector",你可以改變集合視圖布局的屬性,如圖2.2所示(集合視圖將這些屬性抽象到它們的布局對象中,更多內(nèi)容請閱讀第3章 "內(nèi)容的上下文")。在這里,你可以改變單元格的大小,默認(rèn)情況下是 50*50。將寬度降為 20,并保持高度設(shè)置為 50。頁眉和頁腳大小還不能用,因為你還沒有使用頁眉或頁腳。

圖2.2

你可以在 "Size Inspector" 中的 "Min Spacing " 部分更改集合視圖中單元格之間的距離。這只是最小距離;默認(rèn)布局(稱為 "流")確保單元格之間的距離最小。通過 "Size Inspector" 中的 "Section Insets" 區(qū)域,你可以指定整個 Section 段周圍的距離。(請記住,到目前為止,你只有一個 Section)。我們將在第 3 章中更仔細(xì)地了解 "section insets" 屬性,所以現(xiàn)在不用擔(dān)心具體細(xì)節(jié)。我個人最討厭內(nèi)容周圍的邊距太小,所以把 "section insets" 各個方向上的值設(shè)置為 10。

上圖示例中,我們設(shè)置了該集合視圖的 section 的邊緣插入量為 {10,10,10,10},默認(rèn)值為 {0,0,0,0}。

運行應(yīng)用程序,看看集合視圖中的視覺差異。它應(yīng)該類似于圖 2.3。

圖 2.3

一點都不賴。不要擔(dān)心系統(tǒng)狀態(tài)欄在我們的內(nèi)容頁面上是可見的,這是 iOS 7 的默認(rèn)值。我們稍后會通過將集合視圖控制器放在導(dǎo)航控制器里面來解決這個問題。圖 2.3 的問題是,只有集合視圖布局的部分屬性可以通過 storyboards 或 .xib 文件訪問。此外,如果你在代碼中覆蓋了你在 storyboard 中設(shè)置的屬性,或者你忘記了你在 storyboards 中設(shè)置了一些東西,這可能會導(dǎo)致調(diào)試上的困難。出于這個原因,我強烈建議對集合視圖使用純代碼的方法。

你也可以通過只使用代碼的方式重新創(chuàng)建你的界面。使用空應(yīng)用程序模板創(chuàng)建一個新的 Xcode 項目。(對于從未從空模板創(chuàng)建過應(yīng)用程序的人來說,這可能是一個很大的步驟)。使用 File、New、File 或 ?N 創(chuàng)建一個新文件。選擇 Objective-C 類,并將其命名為 AFViewController 之類。在 Subclass 的字段中,輸入UICollectionViewController。確保不要選擇 User Interface With XIB。

打開 AppDelegate.m 文件,并添加一個 #import 語句來導(dǎo)入新視圖控制器的頭文件。將實現(xiàn)改為清單2.1中的代碼。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { 
    self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];

    UICollectionViewFlowLayout *collectionViewLayout = [[UICollectionViewFlowLayout alloc] init];
    collectionViewLayout.scrollDirection = UICollectionViewScrollDirectionHorizontal;
    collectionViewLayout.sectionInset = UIEdgeInsetsMake(10, 10, 10, 10);  
    collectionViewLayout.itemSize = CGSizeMake(20, 50); 
    self.window.rootViewController = [[AFViewController alloc] initWithCollectionViewLayout:collectionViewLayout];
    self.window.backgroundColor = [UIColor whiteColor]; 
    [self.window makeKeyAndVisible];

    return YES;
}

下一步,打開視圖控制器的實現(xiàn)文件,并在 viewDidLoad 方法中添加下面代碼:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.collectionView.indicatorStyle = UIScrollViewIndicatorStyleWhite;
}

(其他設(shè)置與上一個示例相同)

構(gòu)建并運行應(yīng)用程序,你會發(fā)現(xiàn)你使用 storyboards 定制的所有東西都已經(jīng)用代碼復(fù)制了。擊掌!

在你深入了解集合視圖和布局內(nèi)容之前,下面的部分將帶你快速轉(zhuǎn)移討論 UIScrollView。

UIScrollView:簡要概述

UICollectionViewUIScrollView 的子類,和 UITableView 很類似。與UICollectionView 的繼承關(guān)系類似,UICollectionViewDelegate 協(xié)議也遵守 UIScrollViewDelegate 協(xié)議。在實際操作中,這意味著如果一個對象是集合視圖的委托者,它就會收到回調(diào)通知,觸發(fā) UICollectionViewDelegate 事件以及 UIScrollViewDelegate 事件。

注:也就是說,如果一個對象是 UICollectionView 實例對象的委托對象,那么這個委托對象既遵守 UICollectionViewDelegate 協(xié)議中的方法,同時也自動遵守 UIScrollViewDelegate 協(xié)議中的方法。因為 UICollectionViewDelegate 協(xié)議繼承自 UIScrollViewDelegate 協(xié)議,它是 UIScrollViewDelegate 協(xié)議的子協(xié)議。

UIScrollView 是 UIKit 中的一個多功能類,從 iOS 2.0 的時候就已經(jīng)存在了。它為開發(fā)人員提供了一種友好的方式來滾動內(nèi)容,無論是電子郵件列表、應(yīng)用程序的網(wǎng)格,還是一張照片。如果你可以在任何給定的應(yīng)用程序中滾動一些東西,那么該應(yīng)用程序有可能使用了 UIScrollView 滾動視圖。

滾動視圖給用戶一種熟悉的感覺,讓任何使用滾動視圖的應(yīng)用看起來更像是屬于 iOS 系統(tǒng),而不像其開發(fā)者自己寫的滾動視圖。滾動視圖以極少的工作為開發(fā)者提供了很大的權(quán)力,開發(fā)者需要做的就是設(shè)置滾動視圖并為其添加子視圖。此外,你還可以依靠蘋果已經(jīng)為你做的工作,比如模擬物理學(xué)和減速。看看一個例子,在這個例子中,用戶可以滾動查看比屏幕上能同時容納的更多內(nèi)容。

使用 "Single View template" 模板創(chuàng)建一個新的 Xcode 項目。將一張大圖片復(fù)制到項目中,打開主視圖控制器的實現(xiàn)文件。用清單2.3中的實現(xiàn)替換 viewDidLoad。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    UIImage *image = [UIImage imageNamed:@"cat.jpg"];
    UIImageView *imageView = [[UIImageView alloc] initWithImage:image];
    imageView.frame = CGRectMake(0, 0, image.size.width, image.size.height);
    
    UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds];
    scrollView.contentSize = image.size;
    scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
  
    [scrollView addSubview:imageView];
    [self.view addSubview:scrollView];
}

運行應(yīng)用程序,你會看到類似于圖 2.4 的顯示效果;由于圖片太大了,一次無法在屏幕上完整顯示,但用戶可以圍繞圖像滾動以查看全部內(nèi)容。(請注意滾動指示器。) 使這一切發(fā)揮作用的魔法是 contentSize 屬性。這是一個 CGSize 值,表示可滾動區(qū)域的尺寸大?。ㄒ渣c為單位)。它的默認(rèn)值為零,而且使用任何滾動視圖時必須設(shè)置這個屬性的值,即使內(nèi)容尺寸小于滾動視圖自身的尺寸。

圖 2.4

當(dāng)滾動視圖知道它所顯示的內(nèi)容的尺寸大小時,它就會滾動。內(nèi)容尺寸(contentSize)可以隨時改變。

圖2.5 展示了內(nèi)容尺寸的概念。照片左上角的亮色矩形區(qū)域,定義了應(yīng)用程序第一次啟動時圖像的可見部分,這就是滾動視圖的尺寸(the size of the scroll view ),用虛線表示。實線代表滾動視圖的內(nèi)容大小(the content size of the scroll view)。

圖 2.5

當(dāng)用戶滾動 scrollView 時,用戶可見的內(nèi)容區(qū)域會發(fā)生變化。內(nèi)容視圖在滾動視圖中的位置稱為內(nèi)容偏移,由 contentOffset 屬性表示,是一個 CGPoint 值。該屬性由可見區(qū)域的原點(左上角)到內(nèi)容原點的距離定義。圖2.6 用白色箭頭演示了內(nèi)容偏移。內(nèi)容大小保持不變,但內(nèi)容偏移量會改變,以響應(yīng)用戶的交互。

圖 2.6

內(nèi)容偏移可以通過編程方式改變,contentOffset 屬性是可讀可寫的。更有趣的是,你可以使用 setContentOffset:animated: 方法以動畫方式對內(nèi)容偏移的變化進行處理。這將 "移動 "滾動視圖,就像用戶自己移動它一樣。內(nèi)容偏移也可以用 scrollRectToVisible:animated: 方法來改變,但這更多的是用于縮放而不是簡單的滾動。

關(guān)于 scrollView,我想說的最后一件事是 contentInset 屬性。這是一個 UIEdgeInset 值,表示滾動視圖內(nèi)容周圍應(yīng)該 "填充" 的區(qū)域。將contentInset 屬性設(shè)置為 UIEdgeInsetsMake(10, 10, 10, 10),將在 scrollView 的內(nèi)容周圍創(chuàng)建一個10pt 的邊距。邊緣插入值也可以是負(fù)值;這將代表滾動視圖內(nèi)容周圍不能被用戶看到的區(qū)域(除非她滾動過滾動視圖的邊緣)。試著玩玩 contentInset,看看它是如何工作的。

contentInset 屬性是一個廣泛使用的屬性,經(jīng)常被用于 UITableView 和自定義下拉刷新控件中。如果你在視圖控制器的頂部有一個導(dǎo)航欄,并且 wantsFullScreenLayout 設(shè)置為 YES,那么它也很有用。邊緣插入量的上邊界的值等于狀態(tài)欄和導(dǎo)航欄的高度。

這就是 UIScrollView 的三個主要組件:contentSizecontentOffsetcontentInset。

  • contentSize 用來標(biāo)識 UIScrollView 的可滾動范圍;
  • contentOffset 用來設(shè)置 UIScrollView 的視圖原點與當(dāng)前可視區(qū)域左上角的距離;
  • contentInset 用于設(shè)置邊緣插入量,或者說,額外的視圖內(nèi)邊距;

現(xiàn)在,在本章繼續(xù)討論更多的集合視圖之前,是時候?qū)L動視圖委托(UIScrollViewDelegate)進行快速討論了。

UIScrollViewDelegate 中有三組方法:響應(yīng)拖動和滾動的方法,響應(yīng)縮放的方法,以及響應(yīng)由代碼顯式啟動的滾動動畫的方法(見表2.1)。你將只處理第一組和最后一組,因為集合視圖不使用 UIScrollView 的縮放功能。

表 2.1 有用的 UIScrollViewDelegate 方法

方法名 描述
scrollViewDidScroll: 當(dāng) scrollView 的內(nèi)容偏移(contentOffset)發(fā)生變化時,就會被調(diào)用,可以是代碼觸發(fā)的變化,也可以是響應(yīng)用戶交互觸發(fā)的變化??捎糜谧远x的下拉刷新控件中。
scrollViewWillBeginDragging: 當(dāng) scrollView 即將被用戶拖動時調(diào)用。可能的用途是禁止?jié)L動視圖的更新(暫停一些復(fù)雜操作),因為這可能會影響滾動的流暢性。
scrollViewWillEndDragging: withVelocity: targetContentOffset: 當(dāng)用戶在拖動后從 scrollView 上抬起手指時,就會被調(diào)用。第二個參數(shù)表示結(jié)束拖動時的滾動速度,以點/秒為單位,表示當(dāng)用戶抬起手指時,滾動視圖的速度。第三個參數(shù)是一個 CGPoint 類型的指針類型,代表 scrollView 將滾動到的位置。修改該 CGPoint 值就會改變滾動視圖的滾動位置??赡艿挠猛臼怯嬎惝?dāng)滾動動畫結(jié)束時什么內(nèi)容將是可見的,并從應(yīng)用程序編程接口(API)中預(yù)取。
scrollViewDidEndDragging: willDecelerate: 當(dāng)用戶在 scrollView 上拖動后抬起手指時,就會被調(diào)用。第二個參數(shù)表示 scrollView 是否以動畫形式停止減速,或者當(dāng)用戶抬起手指時是否已經(jīng)停止。只要第二個參數(shù)是 NO,可能的用途包括重啟在scrollViewWillBeginDragging: 中停止的任何暫停的計算。(注:這個 decelerate 參數(shù)表示滾動視圖是緩慢減速的還是戛然而止立即減速的)
scrollViewShouldScrollToTop: 當(dāng)操作系統(tǒng)需要確定當(dāng)用戶點擊系統(tǒng)狀態(tài)欄時,是否應(yīng)該將滾動視圖以動畫方式自動滾動到頂部時,就會調(diào)用該方法。每次只有一個可見的滾動視圖應(yīng)該從這個方法返回 YES。
scrollViewDidScrollToTop: 在滾動視圖因用戶點擊狀態(tài)欄而滾動到頂部后調(diào)用。
scrollViewWillBeginDecelerating: 當(dāng)滾動視圖即將開始減速動畫時調(diào)用。
scrollViewDidEndDecelerating: 在滾動視圖的減速動畫完成后調(diào)用??赡艿挠猛景ㄖ匦聠釉?scrollViewWillBeginDragging: 方法中暫停的復(fù)雜計算。
scrollViewDidEndScrollingAnimation: 當(dāng)滾動視圖的內(nèi)容偏移量(contentOffset)以動畫方式變化完成后調(diào)用。只有當(dāng)內(nèi)容偏移量是以編程方式改變并且啟用了顯式動畫時,才會在委托者上調(diào)用該方法。

在本書以后更進階的章節(jié)和一些案例研究中,你會用到一些滾動視圖的委托方法。它們是解決許多問題的有用工具,你應(yīng)該了解它們。

UICollectionViewCell 的重用:如何以及為何重用

UICollectionView 使用一種節(jié)省內(nèi)存的方案來配置各個單元格的顯示。正如蘋果公司的一位軟件工程師所說的那樣,"創(chuàng)建并分配內(nèi)存十分昂貴"。他的意思是,如果你做了很多為新的變量創(chuàng)建并分配內(nèi)存的操作,就非常消耗系統(tǒng)內(nèi)存。UICollectionView 的做法非常聰明:它重用不再顯示的單元格。

Note

對于熟悉 UITableView 的人來說,這應(yīng)該聽起來很熟悉。在 iOS 6 中,蘋果將 UITableView 最好的部分做成了 UICollectionView。許多東西看起來很熟悉,但你可能會對很多新東西感到驚訝。

這和列表中 UITableViewCell 的重用原理類似。

UICollectionView 依靠它的 dataSource 告訴它要顯示多少個單元格,并在向用戶展示之前對每個單元格進行配置。在滾動時,這需要非??斓乃俣?,這就是為什么單元格需要重用的原因。下面解釋一下具體發(fā)生了什么。

對于不同的單元格顯示類型,你應(yīng)該使用不同的單元格重用標(biāo)識符。重用標(biāo)識符是一個 NSString 類型的字符串,你通常將其存儲為一個靜態(tài)變量。在具有該重用標(biāo)識符的任何單元格能夠顯示之前,它需要在集合視圖中注冊。這與 UITableView 有很大的不同。你通常會在 viewDidLoad 中注冊單元格,而不會在以后重新注冊它們。

當(dāng)注冊一個單元格時,你可以提供一個 UINib 實例或一個 Class 類。我更喜歡 Class 類而不是 nib,因為它能讓我對布局和性能有更多的控制。

使用 registerClass:forCellWithReuseIdentifier:registerNib:forCellWithReuseIdentifier: 方法注冊單元格。自此,每當(dāng)調(diào)用 dequeueReusableCellWithReuseIdentifier:forIndexPath: 方法時。系統(tǒng)會保證你有一個與你的重用標(biāo)識符相對應(yīng)的已分配和初始化好的單元格(見圖2.7)。

圖 2.7

這與 UITableView 稍有不同,UITableView 在歷史上要求開發(fā)人員在嘗試去序列化一個單元格時首先檢查返回值是否為 nil(盡管現(xiàn)在它也支持這種先注冊再使用的新方法了)。在集合視圖中,系統(tǒng)會保證為你返回一個有效可使用的單元格。

如果你的集合視圖只有 20 個單元格同時在屏幕上可見,那么你的集合視圖只分配了 20 個單元格;當(dāng)一個單元格滾動到屏幕外時,它將被添加到重用隊列中,以便再次重用。這種技術(shù)可以讓應(yīng)用程序在滾動瀏覽具有數(shù)百或數(shù)千個單元格的集合視圖時,保持極低的內(nèi)存占用和極高的幀率。

本書中的大多數(shù)例子,以及集合視圖的大多數(shù)實際用途,都只顯示一種類型的單元格,因此只有一個重用標(biāo)識符。如果你要顯示不止一種類型的單元格,那么擁有不止一種類型的標(biāo)識符是完全合理的。

向用戶展示內(nèi)容

好吧!你已經(jīng)完成了 MVC 的一章和 UICollectionView 的半章基礎(chǔ)知識?,F(xiàn)在是時候看一些代碼了。

你將創(chuàng)建一個基本的應(yīng)用程序,向用戶顯示一些自定義內(nèi)容。這將是一個 iPad 應(yīng)用,所以你可以使用非常大的單元格。一開始你要做的是建立一個基本的集合視圖,讓用戶可以通過加號按鈕添加新的單元格,單元格會顯示它們被添加的時間。這只是為后面的內(nèi)容做熱身。

使用 Empty Application 模板創(chuàng)建一個新的 Xcode 項目。創(chuàng)建一個新的文件,一個父類為 UICollectionViewController 的 Objective-C 類,并給它一個合適的名字。在你的應(yīng)用程序委托的實現(xiàn)文件中,#import 視圖控制器的頭,并創(chuàng)建一個視圖控制器的實例,作為導(dǎo)航控制器的根視圖控制器,即窗口的根視圖控制器。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
    self.window.backgroundColor = [UIColor whiteColor];
    
    UICollectionViewFlowLayout *flowLayout = [[UICollectionViewFlowLayout alloc] init];
    AFViewController *viewController = [[AFViewController alloc] initWithCollectionViewLayout:flowLayout];
    
    UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:viewController];
    navigationController.navigationBar.barStyle = UIBarStyleBlack;
    self.window.rootViewController = navigationController;
    
    [self.window makeKeyAndVisible];
    return YES;
}

你會使用到 UINavigationController,因為它免費提供了很多好東西。在本例中,你得到了一個很酷的導(dǎo)航欄,你可以在上面加入按鈕。這個 applicationDidFinishLaunchingWithOptions: 的實現(xiàn)比本章前面的例子更輕量級;這次你將更接近遵循一些 "最佳實踐"。app delegate 只是為視圖控制器創(chuàng)建了基本的東西,它還進一步定制了自己。

創(chuàng)建一個新的 Objective-C 類,它繼承 UICollectionViewCell。你還不打算給它添加任何代碼。你只需要在視圖控制器的實現(xiàn)文件中#import 導(dǎo)入即可。

打開視圖控制器的實現(xiàn)文件,用一些指示性的值創(chuàng)建一個靜態(tài)的NSString 實例;你將用它作為你的重用標(biāo)識符。添加兩個實例變量。一個是代表模型的 NSMutableArray,另一個是 NSDateFormatter,你將用它來格式化內(nèi)容給用戶。

#import "AFCollectionViewCell.h"

@interface AFViewController ()

@end

static NSString *CellIdentifier = @"Cell Identifier";

@implementation AFViewController
{
    // This is our model
    NSMutableArray *datesArray;
    NSDateFormatter *dateFormatter;
}

接下來,在 viewDidLoad 方法中創(chuàng)建并初始化一個空模型(你的 datesArray)和一個日期格式化對象的實例。同時將你的布局和集合視圖配置成漂亮的樣子,通過重用標(biāo)識符注冊你的 UICollectionViewCell 子類,并為你的導(dǎo)航欄添加一個按鈕。

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    // 實例化模型
    datesArray = [NSMutableArray array];
    dateFormatter = [[NSDateFormatter alloc] init];
    [dateFormatter setDateFormat:[NSDateFormatter dateFormatFromTemplate:@"h:mm:ss a" options:0 locale:[NSLocale currentLocale]]];
    
    // 初始化集合視圖布局
    UICollectionViewFlowLayout *flowLayout = (UICollectionViewFlowLayout *)self.collectionView.collectionViewLayout;
    flowLayout.minimumInteritemSpacing = 40.0f;
    flowLayout.minimumLineSpacing = 40.0f;
    flowLayout.sectionInset = UIEdgeInsetsMake(10, 10, 10, 10);
    flowLayout.itemSize = CGSizeMake(200, 200);
    
    // 配置集合視圖
    [self.collectionView registerClass:[AFCollectionViewCell class] forCellWithReuseIdentifier:CellIdentifier];
    self.collectionView.indicatorStyle = UIScrollViewIndicatorStyleWhite;
    
    // 配置導(dǎo)航欄按鈕
    UIBarButtonItem *addButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAdd target:self action:@selector(userTappedAddButton:)];
    self.navigationItem.rightBarButtonItem = addButton;
    self.navigationItem.title = @"Our Time Machine";
}

真棒,你現(xiàn)在就可以運行這個應(yīng)用程序,但你看到的只是一個空屏幕,上面有一個加號按鈕和一個標(biāo)題。所以,在編寫你的集合視圖單元子類之前,先完成視圖控制器的代碼。你需要實現(xiàn)你的UICollectionViewDataSource 方法。

#pragma mark - UICollectionViewDataSource Methods

-(NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
    return datesArray.count;
}

-(UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    AFCollectionViewCell *cell = (AFCollectionViewCell *)[collectionView dequeueReusableCellWithReuseIdentifier:CellIdentifier forIndexPath:indexPath];
    
    cell.text = [dateFormatter stringFromDate:datesArray[indexPath.row]];
    
    return cell;
}

現(xiàn)在,這將引發(fā)一個編譯器錯誤。不過不要擔(dān)心。在你寫完剩下的代碼后,它將會工作。你需要一個方法來響應(yīng)你的 add 按鈕。創(chuàng)建兩個方法:一個用于設(shè)置你在 viewDidLoad 中給導(dǎo)航欄按鈕的選擇器名稱,另一個是你可以在代碼中的任何地方調(diào)用的方法,以便向 datesArray 中添加新的日期。

#pragma mark - User Interface Interaction Methods

-(void)userTappedAddButton:(id)sender {
    [self addNewDate];
}

#pragma mark - Private, Custom methods

-(void)addNewDate {
    // performBatchUpdates: 批量更新集合視圖
    [self.collectionView performBatchUpdates:^{
        //create a new date object and update our model
        NSDate *newDate = [NSDate date];
        [datesArray insertObject:newDate atIndex:0];
        
        //update our collection view
        [self.collectionView insertItemsAtIndexPaths:@[[NSIndexPath indexPathForItem:0 inSection:0]]];
    } completion:nil];
}

你在 UICollectionView 上調(diào)用 performBatchUpdates:completion: 執(zhí)行批量更新。這可以讓你免費獲得動畫(由你的布局類定義;更多內(nèi)容在第3章)。

現(xiàn)在你要做的就是編寫你的 UICollectionViewCell 子類。轉(zhuǎn)到你之前創(chuàng)建的頭文件。你要給它一個單一的 NSString 屬性。

@interface AFCollectionViewCell : UICollectionViewCell

@property (nonatomic, copy) NSString *text;

@end

現(xiàn)在你的編譯器將停止抱怨,但如果你運行應(yīng)用程序,將不會發(fā)生任何真正有趣的事情。打開單元格的實現(xiàn)文件,添加一個 UILabel 實例變量。用以下代碼實現(xiàn)覆蓋 initWithFrame: 方法。

@implementation AFCollectionViewCell
{
    UILabel *textLabel;
}

#pragma mark - Initialization

- (id)initWithFrame:(CGRect)frame
{
    if (!(self = [super initWithFrame:frame])) return nil;
    
    self.backgroundColor = [UIColor whiteColor];
    
    textLabel = [[UILabel alloc] initWithFrame:self.bounds];
    textLabel.textAlignment = NSTextAlignmentCenter;
    textLabel.font = [UIFont boldSystemFontOfSize:20];
    [self.contentView addSubview:textLabel];
    
    return self;
}

接下來,覆蓋文本屬性來更新標(biāo)簽。你還將覆蓋 UICollectionViewCell 的一個重要方法,稱為prepareForReuse。

#pragma mark - Overriden UICollectionViewCell methods

-(void)prepareForReuse
{
    [super prepareForReuse];
    
    self.text = @"";
}

#pragma mark - Overriden properties

-(void)setText:(NSString *)text
{
    _text = [text copy];
    
    textLabel.text = self.text;
}

這里使用名為 text 屬性的字符串更新單元格的標(biāo)簽。在 prepareForReuse 方法中,你調(diào)用 super 關(guān)鍵字(非常重要?。?,然后將你的文本設(shè)置為空字符串。這一點真的很重要,你需要盡可能地將你的單元格重置到它的起始或中性狀態(tài)。否則,集合視圖的數(shù)據(jù)源可能會忘記重置部分?jǐn)?shù)據(jù),你可能最終會得到一個不一致和混亂的用戶界面。

運行應(yīng)用程序,你會看到一個空屏幕。點擊 "加號 "按鈕,在收集視圖中添加一個新單元格。請注意,當(dāng)一個新單元格被添加到集合視圖的頂部時,你會得到一個動畫(見圖 2.8)。很好!該應(yīng)用還自適應(yīng)屏幕旋轉(zhuǎn)。

圖 2.8

我不想讓這個教程聽起來像個關(guān)于 MVC 的破紀(jì)錄,但重要的是要注意,單元格并不知道它在顯示什么;它傳遞了一個字符串,而這個字符串恰好包含了一個與模型對應(yīng)的日期。重要的是,你并沒有向它傳遞 NSDate 對象本身。

現(xiàn)在,你有了一個基本的集合視圖示例,再仔細(xì)看看 UICollectionView類本身。單元格有兩個重要的布爾屬性:選中高亮。高亮狀態(tài)完全取決于用戶的交互;當(dāng)用戶的手指按住一個單元格時,它就會自動變成高亮狀態(tài)。單元格的選中則不那么短暫;當(dāng)用戶抬起手指時,單元格就會被選中(如果集合視圖支持選擇)。單元格會一直被選中,直到你寫的一些代碼將其取消,或者直到用戶再次點擊它們。當(dāng)被點擊成為選中或取消選中時,單元格會暫時高亮。這些屬性的設(shè)置器可以(并且經(jīng)常)從動畫塊中調(diào)用。在覆蓋它們的實現(xiàn)時要注意,你所做的改變很可能會被隱式動畫化。

選中和高亮可能會讓人感到困惑。不過不用擔(dān)心,因為下一個例子將對其進行更多的探討。同時,圖 2.9 應(yīng)該會有所幫助。

圖 2.9

在上一次練習(xí)中的自定義子類中,你將 UILabel 子視圖添加到 self.contentView 中,而不是 self 中。一般來說,你不應(yīng)該直接將子視圖添加到集合視圖單元中。這就是為什么你應(yīng)該總是將它們添加到其 contentView 中。

UICollectionViewCell 有三個子視圖,在圖 2.10 中表示。后面的黑色矩形是集合視圖單元格本身,前面的綠色視圖是 contentView,你可以在那里添加子視圖。中間的兩個視圖是 selectedBackgroundViewbackgroundView。這兩個視圖都是可選的,可以在任何時候設(shè)置。backgroundView 如果設(shè)置了,就會永久存在。

圖 2.10

現(xiàn)在你對 UICollectionViewCell 中的視圖層次結(jié)構(gòu)有了更好的理解,你可以繼續(xù)看另一個例子,它有助于說明這些屬性、contentView 和圖像的用途。

您將創(chuàng)建一個應(yīng)用程序,在 10 個不同的 section 區(qū)域中重復(fù)顯示 12 張圖片,每個部分都將有自己的背景顏色,除非它被選中,演示如何使用 selectedBackgroundView。你使用 12 張圖片是因為它們正好可以在一個 section 中充滿屏幕顯示。

基于 Empty 模板創(chuàng)建一個新的 Xcode 項目。創(chuàng)建一個UICollectionViewController 的子類和一個 UICollectionViewCell 的子類,就像上次一樣。在應(yīng)用程序委托中設(shè)置一個視圖控制器的實例作為窗口的根視圖控制器--這次不需要使用導(dǎo)航控制器。

使用兩個數(shù)組來存儲你的模型:一個用于圖像,一個用于存儲背景色。你將調(diào)整上一個例子中的單元格大小、單元格之間的間距和行間距。此外,你將在集合視圖上啟用多重選擇功能;這將使用戶能夠同時選擇多個單元格,也使用戶能夠通過點擊它們來取消選擇單元格。以下示例代碼中, viewDidLoad 方法中的所有內(nèi)容應(yīng)該看起來很熟悉。我創(chuàng)建了一系列 JPEG 格式的圖片,命名為 0.jpg 到 11.jpg,共12張。

static NSString *CellIdentifier = @"Cell Identifier";

@implementation AFViewController
{
    // models
    NSArray *imageArray;
    NSArray *colorArray;
}

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    // 初始化模型
    NSMutableArray *mutableImageArray = [NSMutableArray arrayWithCapacity:12];
    for (NSInteger i = 0; i < 12; i++)
    {
        NSString *imageName = [NSString stringWithFormat:@"%ld.jpg", (long)i];
        [mutableImageArray addObject:[UIImage imageNamed:imageName]];
    }
    imageArray = [NSArray arrayWithArray:mutableImageArray];
    
    NSMutableArray *mutableColorArray = [NSMutableArray arrayWithCapacity:10];
    for (NSInteger i = 0; i < 10; i++)
    {
        CGFloat redValue = (arc4random() % 255) / 255.0f;
        CGFloat blueValue = (arc4random() % 255) / 255.0f;
        CGFloat greenValue = (arc4random() % 255) / 255.0f;
        
        [mutableColorArray addObject:[UIColor colorWithRed:redValue green:greenValue blue:blueValue alpha:1.0f]];
    }
    colorArray = [NSArray arrayWithArray:mutableColorArray];
    
    // 初始化集合視圖布局對象
    UICollectionViewFlowLayout *flowLayout = (UICollectionViewFlowLayout *)self.collectionView.collectionViewLayout;
    flowLayout.minimumInteritemSpacing = 20.0f;
    flowLayout.minimumLineSpacing = 20.0f;
    flowLayout.sectionInset = UIEdgeInsetsMake(10, 10, 10, 10);
    flowLayout.itemSize = CGSizeMake(220, 220);
    
    // 設(shè)置集合視圖
    [self.collectionView registerClass:[AFCollectionViewCell class] forCellWithReuseIdentifier:CellIdentifier];
    self.collectionView.indicatorStyle = UIScrollViewIndicatorStyleWhite;
    self.collectionView.allowsMultipleSelection = YES;
    self.collectionView.canCancelContentTouches = NO;
    self.collectionView.delaysContentTouches = NO;  
}

因為你要顯示多個 section 組,所以你需要實現(xiàn)一個新的、可選的UICollectionViewDelegate 方法,稱為numberOfSectionsInCollectionView:。以下示例代碼中所示的collectionView:cellForItemAtIndexPath: 實現(xiàn)也看起來與之前很熟悉。

#pragma mark - UICollectionViewDataSource Methods

-(NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView
{
    return colorArray.count;
}

-(NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
    return imageArray.count;
}

-(UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    AFCollectionViewCell *cell = (AFCollectionViewCell *)[collectionView dequeueReusableCellWithReuseIdentifier:CellIdentifier forIndexPath:indexPath];
    
    cell.image = imageArray[indexPath.item];
    cell.backgroundColor = colorArray[indexPath.section];
    
    return cell;
}

打開集合視圖單元格子類,在該類中添加一個 UIImageView 實例變量。同時添加一個名為 imageUIImage 屬性。寫一個新的初始化器來實例化實例變量。

@implementation AFCollectionViewCell
{
    UIImageView *imageView;
}

- (id)initWithFrame:(CGRect)frame
{
    if (!(self = [super initWithFrame:frame])) return nil;
    
    self.backgroundColor = [UIColor whiteColor];
    
    imageView = [[UIImageView alloc] initWithFrame:CGRectInset(self.bounds, 10, 10)];
    [self.contentView addSubview:imageView];
    
    UIView *selectedBackgroundView = [[UIView alloc] initWithFrame:CGRectZero];
    selectedBackgroundView.backgroundColor = [UIColor colorWithWhite:1.0f alpha:0.8f];
    self.selectedBackgroundView = selectedBackgroundView;
    
    return self;
}

你在圖像視圖的框架中移動了 10 個點,在圖像周圍創(chuàng)建一個邊框。覆蓋 setImage: 方法來設(shè)置 imageView 的圖像。你還要重寫prepareForReuse 方法,并包含一個 setHighlighted 的實現(xiàn)。還請注意,你已經(jīng)將選定的 BackgroundView設(shè)置為一個純白色的視圖。當(dāng)單元格被選中時,這個白色視圖將被放置在單元格的前面(以及任何背景視圖的前面,在本例中沒有)。

#pragma mark - Overriden UICollectionViewCell methods

-(void)prepareForReuse {
    [super prepareForReuse];
    
    self.backgroundColor = [UIColor whiteColor];
    self.image = nil; // also resets imageView’s image
}

-(void)setHighlighted:(BOOL)highlighted {
    [super setHighlighted:highlighted];
    
    if (self.highlighted) {
        imageView.alpha = 0.8f;
    } else {
        imageView.alpha = 1.0f;
    }
}

#pragma mark - Overridden Properties

-(void)setImage:(UIImage *)image {
    _image = image;
    
    imageView.image = image;
}

記住在覆蓋屬性的實現(xiàn)方法時,總是調(diào)用 super 關(guān)鍵字(除非你故意不想調(diào)用,并且有一個非常好的理由)。在實現(xiàn)中,當(dāng)圖像被高亮?xí)r,你將圖像視圖的 alpha 降低到 80%。運行應(yīng)用程序。

玩轉(zhuǎn)應(yīng)用程序。點擊單元格,使它們被選中,然后再點擊它們。請注意,如果你點擊并拖動,集合視圖會取消你的點擊并滾動。這是因為 UIScrollView 屬性 canCancelContentTouches 被設(shè)置為 YES。另外,請注意集合視圖如何延遲高亮顯示單元格,直到你按住觸摸幾十分之一秒。這是因為 UIScrollView 屬性delaysContentTouches 被設(shè)置為 YES。在 viewDidLoad 的實現(xiàn)中,可以玩玩這兩個方法來試驗它們?nèi)绾斡绊懠弦晥D的用戶體驗(事實上,所有滾動視圖都是如此,因為這些都是默認(rèn)值)。

注意關(guān)于 UICollectionViewCellselectedBackgroundViewbackgroundView 屬性的幾件事。首先,它們將被拉伸以適應(yīng)它們被分配的任何單元格。這就是為什么在這個例子中,你能夠用一個 CGRectZero 的邊框來初始化選定的背景視圖。接下來,一些屬性,如 alpha 屬性,將被集合視圖重置為默認(rèn)值(在 alpha的示例中,為 1.0f)。在排除單元格背景的顯示問題時要注意這些問題。

如果你想證明 selectedBackgroundView 被放置在視圖層次結(jié)構(gòu)中,您可以將其設(shè)置為稍微透明的顏色。將 selectedBackgroundView 的背景色改為類似 [UIColor colorWithWhite:1.0f alpha:0.8f] 的顏色?,F(xiàn)在你將能夠通過 selectedBackgroundView 看到它的上層視圖,即集合視圖本身。

在本章結(jié)束關(guān)于性能的案例研究之前,我想做一個快速的轉(zhuǎn)移,重新審視一下 storyboard 和 .xib 文件?,F(xiàn)在你已經(jīng)了解了集合視圖單元的工作原理,并且你可以創(chuàng)建子類來定制它們的外觀,看看如何使用故事板和 .xibs 來處理前面的練習(xí)。

使用 .xibs 與代碼最相似,所以先從這個開始。打開上一次練習(xí)中的 Xcode 項目(如果你沒有使用源碼控制,先復(fù)制它),然后添加一個新文件。

在新建文件對話框的左側(cè)窗格下,選擇 User Interface,然后雙擊 "Empty" 以創(chuàng)建一個新的、空的 .xib。給它起一個與你的集合視圖單元格子類相同的名字。打開該 .xib 文件,在對象庫中,找到 "Collection View Cell" 并將其拖到空畫布上。

選擇新的單元格,打開"Size Inspector",將尺寸設(shè)置為 220 寬和 220高。反正這些都會被集合視圖重新配置,所以這里設(shè)置只是為了幫助我們直觀地了解單元格的樣子。打開 "Attributes Inspector",將背景色設(shè)為白色。打開 "Identity Inspector",將 “Custom Class” 類型,即集合視圖單元格的類型設(shè)置為你的子類。

在子類中,你需要刪除很多代碼。initWithFrame: 初始化器將不再被調(diào)用。創(chuàng)建一個名為 awakeFromNib 的新方法。當(dāng)一個類的實例從 nib 中 "解凍 "時,就會調(diào)用這個方法。在這個方法中,你放置了你的自定義selectedBackgroundView 初始化??吹接行┦虑闊o論如何都需要用代碼來完成嗎?

.xib 文件中,將 UIImageView 對象拖到單元格上。設(shè)置 springs 和 struts(或 Autolayout 約束),使圖像視圖四面嵌入 10 點。將實例變量移動到頭文件中,并將其前綴為關(guān)鍵字 IBOutlet,以便 .xib 可以看到它。命令單擊并從集合視圖單元格拖動到其內(nèi)部的圖像視圖;從出現(xiàn)的菜單中選擇圖像視圖出口。

最后,你需要告訴集合視圖使用這個 nib,而不是自己初始化集合視圖單元格子類本身的副本。在 viewDidLoad中,改變集合視圖的設(shè)置,如以下示例代碼所示。

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    // ...略去其他的初始化工作
    
    [self.collectionView registerNib:[UINib nibWithNibName:@"AFCollectionViewCell" bundle:nil] forCellWithReuseIdentifier:CellIdentifier];
}

運行應(yīng)用程序,看看它的行為是否和代碼一樣。注意,即使你使用 UINib,你仍然被迫使用子類實現(xiàn)文件。

最后,你要使用故事板來產(chǎn)生同樣的效果。清空 applicationDidFinishLaunchingWithOptions: 實現(xiàn),只返回 YES。刪除 .xib 文件。將 viewDidLoad 實現(xiàn)縮減為以下示例代碼的樣子。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 初始化模型,創(chuàng)建并加載圖片數(shù)據(jù)
    NSMutableArray *mutableImageArray = [NSMutableArray arrayWithCapacity:12];
    for (NSInteger i = 0; i < 12; i++) {
        NSString *imageName = [NSString stringWithFormat:@"%ld.jpg",(long)i];
        [mutableImageArray addObject:[UIImage imageNamed:imageName]];
    }
    self.imageArray = [NSArray arrayWithArray:mutableImageArray];
    
    // 初始化模型,創(chuàng)建并加載顏色數(shù)據(jù),作為一組 cell 的背景顏色
    NSMutableArray *mutableColorArray = [NSMutableArray arrayWithCapacity:10];
    for (NSInteger i = 0; i < 10; i++) {
        CGFloat redValue = (arc4random() % 255) / 255.0f;
        CGFloat blueValue = (arc4random() % 255) / 255.0f;
        CGFloat greenValue = (arc4random() % 255) / 255.0f;
        
        [mutableColorArray addObject:[UIColor colorWithRed:redValue green:greenValue blue:blueValue alpha:1.0f]];
    }
    self.colorArray = [NSArray arrayWithArray:mutableColorArray];
    
    // 配置集合視圖布局
    UICollectionViewFlowLayout *flowLayout = (UICollectionViewFlowLayout *)self.collectionView.collectionViewLayout;
    flowLayout.minimumInteritemSpacing = 20.0f;
    flowLayout.minimumLineSpacing = 20.0f;
    flowLayout.sectionInset = UIEdgeInsetsMake(10, 10, 10, 10);
    flowLayout.itemSize = CGSizeMake(220, 220);
    
    // 配置集合視圖
    self.collectionView.allowsMultipleSelection = YES;
}

你的 viewDidLoad 現(xiàn)在只設(shè)置了模型,并在集合視圖上設(shè)置了一個不能用故事板的屬性檢查器設(shè)置的屬性。

創(chuàng)建一個名為 MainStoryboard 的新故事板文件。打開 Xcode 項目設(shè)置并將 MainStoryboard 設(shè)置為主故事板。(你沒看錯,伙計們。)將一個UICollectionViewController 拖到空的故事板上,并將其自定義類設(shè)置為你的代碼所在的類。展開集合視圖的視圖層次結(jié)構(gòu),直到你到達集合視圖單元。將其自定義類設(shè)置為你的 UICollectionViewCell 子類,并在屬性檢查器中將其重用標(biāo)識符設(shè)置為單元格標(biāo)識符。打開 "尺寸 "檢查器,將其設(shè)置為寬220×高220。

添加一個圖像視圖作為單元格的子視圖;命令點擊并從單元格拖動到圖像視圖,設(shè)置單元格的圖像視圖出口。將圖像視圖設(shè)置為200寬200高,并設(shè)置 springs 和 struts(或 Autolayout 約束),使其尺寸與單元格一起變化。

最后,單擊視圖層次結(jié)構(gòu)中的集合視圖流布局對象。將 " Min Spacing "和 "Section Insets"設(shè)置為你之前在代碼中使用的值(見圖2.11)。運行應(yīng)用程序。

使用 storyboards 或 .xib 文件的好處是,你可以直觀地布局你的界面。當(dāng)你與設(shè)計師一起工作時,或者當(dāng)你第一次學(xué)習(xí) Cocoa Touch 中的視圖層次結(jié)構(gòu)時,這將會有很大的幫助。然而,故事板和 .xib 文件除了它們的視覺性質(zhì)外,并沒有提供很多引人注目的優(yōu)勢。故事板和集合視圖有兩個問題:集合視圖單元(它的重用標(biāo)識符)和代碼之間的緊密耦合,以及當(dāng)你在運行時在代碼中修改故事板的設(shè)置時,一些棘手的調(diào)試。你不能依賴編譯時視覺上看到的東西,因為無論如何,它很可能會在運行時被代碼改變。

從這一點出發(fā),我不會再關(guān)注 .xib 文件或故事板。你已經(jīng)看到了它們是如何工作的,所以如果你正在將它們整合到一個使用它們的現(xiàn)有項目中,你將能夠應(yīng)用本書中的技術(shù)。即使你仍然習(xí)慣于用代碼而不是視覺方式來布置界面,我也鼓勵你使用 .xib文件而不是故事板。記住使用自定義的 UICollectionViewCell 子類來保持你的代碼松散耦合;你的視圖控制器不應(yīng)該知道單元格的視圖層次結(jié)構(gòu)的內(nèi)部情況。

現(xiàn)在你已經(jīng)很好地理解了 UICollectionViewCell 以及如何向用戶顯示內(nèi)容,我們來看看性能。

案例研究:評估 UICollectionView 的性能

當(dāng)你需要評估一款 iOS 應(yīng)用的性能時,你必須在真機設(shè)備上進行測量。使用真實而具體的設(shè)備很重要,但最重要的是不要依賴模擬器。雖然它對很多事情都很有用,比如 NSZombies,但模擬器環(huán)境擁有一整臺 PC 的性能作為動力,然而大多數(shù)用戶的 iPhone 并不是這樣的。

選擇一個測試設(shè)備可能有點棘手。很明顯,像 iPhone 5 這樣真正的新東西不會是測試你的應(yīng)用在緊張時的性能的理想選擇。然而,也不要依賴使用最老或最慢的硬件。雖然 iPhone 3GS 只有一個核心,但它的實際表現(xiàn)可以比 iPhone 4 好得多,雖然 iPhone 4 有更多的隨機訪問內(nèi)存(RAM)和多核中央處理單元(CPU),但必須向其 Retina 屏幕推出四倍的像素。

除了 iPhone,你還必須考慮 iPhone touch。如果你正在編寫一款挑戰(zhàn)設(shè)備極限的應(yīng)用,你應(yīng)該在所有硬件/軟件組合上進行測試。然而,許多 iOS 開發(fā)人員只是單人操作,鞭策一些很酷的應(yīng)用程序,他們沒有數(shù)千美元用于測試硬件(或理解哪些愿意沉迷于蘋果產(chǎn)品的很重要的人)。如果你沒有老舊的 iPhone 可供隨時使用,iPod touch 很好用,而且價格便宜。

在集合視圖和其他滾動視圖中,性能最重要的評估參數(shù)是感知滾動響應(yīng)。請注意,我說的是感知的響應(yīng)。你可以通過測量屏幕刷新率來衡量。理想的情況下,這個速度應(yīng)該是每秒 60 幀(fps),也就是本機刷新率。這意味著在每次調(diào)用主運行循環(huán)(Main Run Loop)時,你的應(yīng)用程序只有16 毫秒的時間來執(zhí)行相關(guān)任務(wù)。好吧,這么多時間并不算非常充裕。這個案例研究將強調(diào)低效代碼嚴(yán)重影響性能的地方,并告訴你如何重構(gòu)你的代碼以保持精簡。同樣的代碼打開性能問題示例項目。解決方案也在那里,前綴為 "Solved")。

在進入實際的剖析之前,這里還有一個提示。當(dāng)你在測量你的應(yīng)用程序的性能時,CPU 的性能會受到 Instruments 的影響(有點像觀察者效應(yīng))。為了避免這種情況,請打開 Instruments 中的 Preferences,并選中 Always Use Deferred Mode (一律使用延遲模式)復(fù)選框。這將在設(shè)備上本地收集數(shù)據(jù),直到運行完成后才將數(shù)據(jù)發(fā)送到電腦上。

當(dāng)你擁有了設(shè)備,并通過努力在 Apple 設(shè)備上面運行你的應(yīng)用程序后,將它連接到你的電腦上。確保你的設(shè)備是從 Scheme-下拉菜單中選擇的。在 Xcode 中,打開 Product 菜單,選擇 Profile(Command-I)。這將用 Release 構(gòu)建設(shè)置(如編譯器優(yōu)化)構(gòu)建你的應(yīng)用程序,并打開Instruments 模板選擇器(見圖 2.12)。

圖 2.12

記住,根據(jù)你使用模擬器還是實際設(shè)備,你會得到不同的模板。選擇 “Core Animation” 模板。這將為您提供屏幕刷新率以及 CPU 使用率,這將告訴您 CPU 在哪里花費了大部分時間執(zhí)行代碼。點擊 Profile 并滾動應(yīng)用程序。使用滾動并注意到響應(yīng)速度有多糟糕。當(dāng)你意識到這真的是非常非常糟糕的代碼時,點擊停止按鈕,在儀器中查看結(jié)果(見圖2.13)。

圖 2.13

糟糕的屏幕刷新率! 峰值只有 37fps,太可怕了。選擇 "Time Profiler(時間剖析器)",打開 "Extended Detail(展開細(xì)節(jié))"窗格(見圖2.14)

圖2.14

你可以看到,在主線程上,從網(wǎng)上下載圖片的時間是最多的。這絕不是一個好主意! 此外,你沒有在任何地方緩存下載的數(shù)據(jù)。在你的視圖控制器中添加一個 NSCache 實例來保存你緩存的數(shù)據(jù)結(jié)果。這個類是一個方便的小鍵/值存儲,當(dāng)內(nèi)存變低時,它會自動釋放內(nèi)存。在 loadView中初始化它(見清單2.18)。

-(void)configureCell:(AFCollectionViewCell *)cell atIndexPath:(NSIndexPath *)indexPath withURLString:(NSString *)urlString
{
    // 嘗試從緩存中調(diào)出一個 NSData 的緩存實例。
    id data = [photoDataCache objectForKey:urlString];
    
    if (data) {
        // 如果 objectForKey:是非 nil,也就是說我們之前下載了圖片,這個分支就會執(zhí)行。
        if ([data isKindOfClass:[NSNull class]]) {
            // 這表明該實例是 NSNull,所以我們不應(yīng)該使用它。
        } else {
            // 我們可以成功解壓我們的 JPEG 數(shù)據(jù)
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                [data af_decompressedImageFromJPEGDataWithCallback:^(UIImage *decompressedImage) {
                    [cell setImage:decompressedImage];
                }];
            });
        }
    } else {
        // 在后臺隊列中下載圖片
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            NSData *data = [self downloadImageDataWithURLString:urlString];
            
            //Now that we have the data, dispatch back to the main queue
            //to use it. UIImage is part of UIKit and can *only* be accessed on
            //the main thread
            dispatch_async(dispatch_get_main_queue(), ^{
                
                UIImage *image = [UIImage imageWithData:data];
                
                if (image) {
                    // 這個作為參數(shù)傳入的單元格實例現(xiàn)在可能已經(jīng)被重用了。
                    // 調(diào)用 reloadItemsAtIndexPaths: 代替。
                    [photoDataCache setObject:data forKey:urlString];
                    [photoCollectionView reloadItemsAtIndexPaths:@[indexPath]];
                } else {
                    // 這表明 JPEG 解壓失敗。在我們的緩存中設(shè)置NSNull
                    [photoDataCache setObject:[NSNull null] forKey:urlString];
                }
            });
        });
    }
}

這段代碼非常直觀。注意你在方法簽名中添加了一個新的參數(shù),一個索引路徑。這是用來在該索引路徑處重載項目的;直接引用單元格是不安全的,因為它可能已經(jīng)被重用了。比如說,如果單元格已經(jīng)被刪除,直接重載項目就會遇到問題。這個簡單的例子適合本章的需要。如果你要做更復(fù)雜的事情,我建議依靠獲取結(jié)果控制器來更新集合視圖。

注:

有更好的方法來緩存照片,比如 Core Data。也有更好的方法從互聯(lián)網(wǎng)上下載數(shù)據(jù),但這個案例研究的重點是研究收集視圖性能的問題,而不是一般的軟件架構(gòu)。

用修改后的代碼重新運行剖析器。你可以看到,性能有了顯著的提高。這很好,但仔細(xì)看看你是否還能進一步改進。如果你看一下擴展細(xì)節(jié)窗格,占用時間最多的方法還是 configureCell:atIndexPath:withURLString:??磥?imageWithData: 占用了大量的 CPU 時間。

你可以采取一些方法來處理這個問題。你可以緩存解壓后的 JPEG 文件,這是一個好主意,但它有其缺點。最大的問題是,它消耗了大量的內(nèi)存。你的圖像是145×145 像素,有 3 個通道,一個通道 8 位。這意味著每張圖片解壓后,要占用1451453=63KB。這聽起來并不是很多,但應(yīng)用程序是在內(nèi)存受限的設(shè)備上運行的,如果它使用了太多的內(nèi)存,操作系統(tǒng)會殺死應(yīng)用程序。

而是在后臺隊列上解壓 JPEG 數(shù)據(jù)。"哈!"你說,"UIImage 是 UIKit 框架的一部分,叫我在后臺隊列上使用它是癡人說夢!" 你沒的說錯,但存在一個替代方案。UIImage 是一個方便的類,但在告訴我們它是否已經(jīng)解壓了圖像方面是相當(dāng)不透明的。例如,使用 Core Graphics 框架來解壓圖像(見清單2.19)。

typedef void (^JPEGWasDecompressedCallback)(UIImage *decompressedImage);

// Just a utility class to round numbers up
int roundUp(int numToRound, int multiple)
{
    if(multiple == 0)
    {
        return numToRound;
    }
    
    int remainder = numToRound % multiple;
    if (remainder == 0)
        return numToRound;
    return numToRound + multiple - remainder;
}

@implementation NSData (AFDecompression)

-(void)af_decompressedImageFromJPEGDataWithCallback:(JPEGWasDecompressedCallback)callback
{
    uint8_t character;
    [self getBytes:&character length:1];
    
    if (character != 0xFF)
    {
        //This is not a valid JPEG.
        
        callback(nil);
        
        return;
    }
    
    // get a data provider referencing the relevant file
    CGDataProviderRef dataProvider = CGDataProviderCreateWithCFData((__bridge CFDataRef)self);
    
    // use the data provider to get a CGImage; release the data provider
    CGImageRef image = CGImageCreateWithJPEGDataProvider(dataProvider, NULL, NO, kCGRenderingIntentDefault);
    CGDataProviderRelease(dataProvider);
    
    // make a bitmap context of a suitable size to draw to, forcing decode
    size_t width = CGImageGetWidth(image);
    size_t height = CGImageGetHeight(image);
    size_t bytesPerRow = roundUp(width * 4, 16);
    size_t byteCount = roundUp(height * bytesPerRow, 16);
    
    void *imageBuffer = malloc(byteCount);
    
    if (width == 0 || height == 0)
    {
        dispatch_async(dispatch_get_main_queue(), ^{
            callback(nil);
        });
    }
    
    CGColorSpaceRef colourSpace = CGColorSpaceCreateDeviceRGB();
    
    CGContextRef imageContext =
    CGBitmapContextCreate(imageBuffer, width, height, 8, bytesPerRow, colourSpace,
                          kCGImageAlphaNone | kCGImageAlphaNoneSkipLast); //Depsite what the docs say these are not the same thing
    
    CGColorSpaceRelease(colourSpace);
    
    // draw the image to the context, release it
    CGContextDrawImage(imageContext, CGRectMake(0, 0, width, height), image);
    CGImageRelease(image);
    
    // now get an image ref from the context
    CGImageRef outputImage = CGBitmapContextCreateImage(imageContext);
    
    CGContextRelease(imageContext);
    free(imageBuffer);
    
    dispatch_async(dispatch_get_main_queue(), ^{
        UIImage *decompressedImage = [UIImage imageWithCGImage:outputImage];
        callback(decompressedImage);
        CGImageRelease(outputImage);
    });
}

這個范疇類是非常有用的。在后臺隊列上調(diào)用解壓方法,一切都會為你處理好。NSData 實例被解壓,如果它實際上是一個 JPEG,就在該方法被調(diào)用的隊列上。當(dāng)解壓完成后,它會調(diào)用一個回調(diào)塊,并負(fù)責(zé)清理 CGImageRef 內(nèi)存。

現(xiàn)在你可以在后臺隊列中安全地解壓 JPEG,將其納入到你的代碼中,如清單 2.20 所示。

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    [data af_decompressedImageFromJPEGDataWithCallback:^(UIImage *decompressedImage) {
        [cell setImage:decompressedImage];
    }];
});

因為 JPEG 解壓只需要幾毫秒,所以我在清單 2.20 中直接更新單元格。如果你要解壓的 JPEG 文件是兆字節(jié)大的,這對你來說是行不通的,但是在集合視圖中顯示這么大的圖像,一般來說是個壞主意。

如果你重新運行 Instruments,你會發(fā)現(xiàn),總體來說,最昂貴的操作,是從 UICollectionViewCellinitWithFrame: 中分配空間。這真的很好。從軼事上看,應(yīng)用程序運行得更流暢了。

擊掌! 但是看看代碼庫中還有兩個地方可以改進。

圖片是145×145 像素,但你的單元格是145×100 邏輯像素。UIImageView 是把圖片縮小到合適的位置。你可以改變 content mode,將它們居中,而不是讓它們不被縮放。

理想情況下,你的圖像大小和單元格大小應(yīng)該是一樣的,這樣操作系統(tǒng)就不需要調(diào)整任何大小,從而提高性能。然而,如果你使用的是第三方 API,你將無法控制圖像大小。

我可以推薦的唯一一件事是打開單元格層的 masksToBounds 屬性來提高這個例子的性能;你將這個屬性與 cornerRadius 一起使用。這對 CPU 造成了壓力,因為它可能會產(chǎn)生離屏渲染,可能會導(dǎo)致很多問題。如果你搞不清楚為什么你的集合視圖速度很慢,可以檢查一下。

如果集合視圖背景不透明,你可以在 contentViewUIImageView 子視圖中使用 PNG 來遮擋角落。這也是一個很好的方法,但是如果可以避免的話,不要使用可調(diào)整大小的圖片。如果你的所有單元格都是一樣大小,就使用不可調(diào)整大小的 UIImage 來遮擋角落,因為它的渲染速度更快。

第一個性能示例就到此為止??纯聪乱粋€例子,叫做 "性能問題實例二",在相同的代碼中。構(gòu)建并運行該應(yīng)用,以了解它的工作原理。

這個應(yīng)用程序的用例如下。你受雇于一家剛剛獲得天使資金的新興創(chuàng)業(yè)公司,他們正在建立一個貓咪的社交網(wǎng)絡(luò)(見圖 2.15)。你要為他們未來的移動應(yīng)用做一個相當(dāng)于 "Facebook墻 "的原型,這樣他們就可以搶到數(shù)百萬的風(fēng)險投資資金。

圖 2.15

該應(yīng)用程序在具有不同背景顏色的單元格中顯示注釋。模型是在 setupModel 中設(shè)置的。只需忽略這個方法;它與本案例研究無關(guān)。另外,請注意 Xcode 如何讓你在 Objective-C 源代碼中使用 emoji。這有多酷?

配置應(yīng)用程序,并使用與上一個例子相同的 Core Animation 模板。峰值幀率為 48fps,這并不可怕,但并不理想。當(dāng)你打開 "擴展細(xì)節(jié) "窗格時,你看到的應(yīng)該會引起一些警覺(見圖2.16)。

圖2.16

最昂貴的操作性能是卸載 .xib 文件。那是怎么回事?打開 AFCollectionViewCell.xib,沉浸在視圖層次結(jié)構(gòu)的純粹存在感的恐怖中。

很明顯,圖 2.17 代表了一個教學(xué)實例。你永遠(yuǎn)不會在一個 nib 中擁有一個相當(dāng)糟糕的視圖層次結(jié)構(gòu)。即使你有一個相當(dāng)復(fù)雜的視圖層次結(jié)構(gòu),你真正要做的就是在彩色背景上顯示一些文本。你可以在 drawRect: 中更快地繪制這個視圖,以及相當(dāng)于所有這些無用的視圖。你可以在你自己的單元格子類中應(yīng)用同樣的邏輯;如果你有一個復(fù)雜的視圖層次結(jié)構(gòu),需要花費太長的時間來繪制,就實現(xiàn) drawRect: 并拋棄視圖層次結(jié)構(gòu)。 drawRect: 也可以是一個性能緩慢的東西,然而,在這樣一個簡單的例子中使用它只是為了說明它是如何完成的。只有當(dāng)手動繪制視圖的組件比渲染一個必然復(fù)雜的視圖層次結(jié)構(gòu)更快時,你才應(yīng)該使用它。

刪除單元格頭文件中的.xib和兩個屬性。不在視圖控制器的 viewDidLoad 中注冊一個 UINib,而是注冊一個 Class。不為顏色使用單獨的背景視圖,而只是在 drawRect: 中繪制它。為單元格的文本創(chuàng)建一個新的字符串屬性。你將覆蓋 backgroundColorgettersetter 來進行一些巧妙的繪制。

static inline void addRoundedRectToPath(CGContextRef context, CGRect rect, float ovalWidth, float ovalHeight)
{
    float fw, fh;
    if (ovalWidth == 0 || ovalHeight == 0) {
        CGContextAddRect(context, rect);
        return;
    }
    CGContextSaveGState(context);
    CGContextTranslateCTM (context, CGRectGetMinX(rect), CGRectGetMinY(rect));
    CGContextScaleCTM (context, ovalWidth, ovalHeight);
    fw = CGRectGetWidth (rect) / ovalWidth;
    fh = CGRectGetHeight (rect) / ovalHeight;
    CGContextMoveToPoint(context, fw, fh/2);
    CGContextAddArcToPoint(context, fw, fh, fw/2, fh, 1);
    CGContextAddArcToPoint(context, 0, fh, 0, fh/2, 1);
    CGContextAddArcToPoint(context, 0, 0, fw/2, 0, 1);
    CGContextAddArcToPoint(context, fw, 0, fw, fh/2, 1);
    CGContextClosePath(context);
    CGContextRestoreGState(context);
}

@implementation AFCollectionViewCell
{
    UIColor *realBackgroundColor;
}

-(id)initWithFrame:(CGRect)frame
{
    if (!(self = [super initWithFrame:frame])) return nil;
    
    self.opaque = NO;
    self.backgroundColor = [UIColor clearColor];
    
    return self;
}

-(void)drawRect:(CGRect)rect
{
    CGContextRef context = UIGraphicsGetCurrentContext();
    
    CGContextSaveGState(context);
    
    [realBackgroundColor set];

    addRoundedRectToPath(context, self.bounds, 10, 10);
    CGContextClip(context);

    CGContextFillRect(context, self.bounds);
    
    CGContextRestoreGState(context);
    
    [[UIColor whiteColor] set];
    
    [self.text drawInRect:CGRectInset(self.bounds, 10, 10) withFont:[UIFont boldSystemFontOfSize:20] lineBreakMode:NSLineBreakByWordWrapping alignment:NSTextAlignmentCenter];
}

#pragma mark - Overridden Properties

-(void)setBackgroundColor:(UIColor *)backgroundColor
{
    [super setBackgroundColor:[UIColor clearColor]];
    
    realBackgroundColor = backgroundColor;
    
    [self setNeedsDisplay];
}

-(UIColor *)backgroundColor
{
    return realBackgroundColor;
}

-(void)setText:(NSString *)text
{
    _text = [text copy];
    
    [self setNeedsDisplay];
}

優(yōu)化原理:通過重寫 drawRect: 方法通過 Core Graphics 框架繪制視圖。

addRoundedRectToPath 的 C 方法很方便,可以很容易地修改成只對某些角進行圓角處理。這些方法通常應(yīng)該放在一個單獨的源文件中,以便可以重復(fù)使用。請看清單2.22。

-(void)configureCell:(AFCollectionViewCell *)cell withModel:(AFModel *)model
{
    cell.backgroundColor = [model.color colorWithAlphaComponent:0.6f];
    cell.text = model.comment;
}

清單 2.22是一個高效的實現(xiàn)。繪圖代碼很直接,使用單元格的代碼在MVC 架構(gòu)中也能很好地工作。重新編譯應(yīng)用程序。圖 2.18 在Instruments中第一次剖析運行情況

圖 2.18

圖 2.18顯示,當(dāng)你去掉一個單元格的時候,有一個叫做 performLongRunningTask 的方法被調(diào)用。它嚴(yán)重阻礙了幀刷新率。你不應(yīng)該在主線程上執(zhí)行長運行任務(wù),而且如果你看代碼,它是由 prepareForReuse 調(diào)用的。開發(fā)者把為重用做準(zhǔn)備和已經(jīng)向用戶展示了單元格混為一談。這確實是不可接受的。將這個邏輯重構(gòu)到視圖控制器中(見清單2.23)。

有個小技巧。我在 performLongRunningTask 方法中設(shè)置了一個空的 for 循環(huán),故意造成性能問題,但為了讓這個例子正常工作,我不得不禁用編譯器優(yōu)化。LLVM 太聰明了,如果啟用了編譯器優(yōu)化,它就會把空循環(huán)剝離出來。

#pragma mark - UICollectionViewDataSource & UICollectionViewDelegate Methods

-(void)collectionView:(UICollectionView *)collectionView didEndDisplayingCell:(UICollectionViewCell *)cell forItemAtIndexPath:(NSIndexPath *)indexPath
{
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        [self performLongRunningTask];
    });
}

-(void)performLongRunningTask
{
    /*
     Let's run some long-running task. Maybe it's some complicated view
     hierarchy math that could be simplified with Autolayout.
     */
    for (int i = 0; i < 5000000; i++);
}

現(xiàn)在,調(diào)用長期運行的任務(wù)的代碼在適當(dāng)?shù)牡胤?,任?wù)在后臺隊列上執(zhí)行。很好。重新提交應(yīng)用程序。幀率大約在 55fps左右,這是相當(dāng)不錯的。代碼中最慢的部分是 drawRect:,這會導(dǎo)致一些性能問題。如前所述,只有當(dāng)你需要實現(xiàn)一個非常復(fù)雜的視圖層次結(jié)構(gòu)時,使用 drawRect: 才是一個更好的選擇

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容