在 2011 年,我認識了一位非常聰明的傢伙,叫做 Mike Matas on Ted 。他介紹了在電子書用到的一種增強用戶體驗的新方法,能夠創建令人驚歎的用戶體驗。這個 App 所達到的流暢程度讓人無法相信這是一個手機 app。同年的晚些時候,這個 App 所屬的公司被 Facebook 收購,並將這種技術用在自己的產品中,從而使數億萬用戶獲得這種傑出的體驗。
我對於這個被「大公司」使用並維護著的、需要項目中全體開發者花費大量的時間和一致努力的程式庫,一直感到很好奇。
AsyncDisplayKit 是什麼?
AsyncDisplayKit 是一個 iOS 框架,目的是讓你的 app 的用戶界面是「執行緒安全」(Thread Safe)的,也就是讓你將所有的「代價高昂的」 view 在顯示之前的準備工作都放到後台執行緒中。這會讓你的 app 顯得更加優雅、平滑並具備一個響應式的 UI。
AsyncDisplayKit 提供了如下組件供你創建 app。
- ASDisplayNode – 類似 UIView。
- ASControlNode – 類似 UIControl。
- ASImageNode – 異步加載圖片。
- ASNetworkImageNode – 一個「節點」對象,給它一個 NSURL,它會為你加載圖片。
- ASMultiplexImageNode – 可以異步加載圖片的多個版本。
- ASTextNode – UITextView 的替代品,用於 label 和 button。
- ASCollectionView 和 ASTableView — UICollectionView 和 UITableView 的子類,能夠支持 ASCellNode 的子類。
範例 App 概覽
在本教程中,我們將創建一個簡單的 app,叫「BrowseMeetup」,它會使用 Meetup 的開放 API。如果你不知道 Meetup,那麼你只需要知道它是全球最大的地方人群網络就好了。你可以免費使用 Meetup 發起一個本地社群,或者從成千上萬個已有群中查找一個你想和人們面對面相遇的群。和其他社交網絡一樣,它提供了能夠從 app 中訪問的開放 API。
BrowseMeetup 使用 Meetup 的 web 服務查找最近的群。App 會獲取當前坐標並自動加載附近的 Meetup 群組,並通過 AsyncDisplayKit 來進行優化和響應式設計。我們會介紹 AsyncDisplayKit 的一些基本概念,並用這些概念來對 app 進行輕量化設計。
App 的結構
在開始編寫程式碼之前,我建議你先下載 app 的最終專案。這將有助於你對後續內容的理解。
下圖顯示了 app 的結構:
View Controller、Table 節點、代理和數據源
在 UIKit 中,數據常常用 Table View 來進行顯示。對於 AsyncDisplayKit,基本顯示單位是「節點』。它是位於 UIView
之上的一種抽象。 ASTableNode 則類似于某種 UIView。它的大部分方法都會有一個節點的“版本”。如果你熟悉 UIView 或 Table View,你也就明白怎樣使用「節點」。
Table 節點對性能進行了高度優化,它非常容易使用和實現。我們將在群組列表上用到 Table 節點。
一個 Table 節點通常和 ASViewController 一起使用,後者往往作為前者的數據源和代理。這樣往往會導致 View Controller 膨脹,因為它需要做的事情太多了,負責數據的展現、顯示視圖、導航到其它 View Controller。
顯然,應當將這些工作分給多個類來進行。因此,我們會用一個助手類負責 Table 節點的數據源。在 View Controller 和助手類之間的交互通過一個協議來進行。這是一種良好的實踐,也許後面我們會換一種更好的實現方式。
Table 節點 cell
看一眼我們的 app,群列表中有一張圖片、位置、日期、組織者的頭像,以及組織者的名字。Table 節點的 cell 應該只需要顯示這些數據。我們將用一個自定義的 Table 節點 cell 來實現它。
模型
App 的模型包括群組、組織者、交互對象、數據管理器,後者允許搜索附近的群。同時,控制器會詢問群組的交互對象以用於顯示。數據管理器負責使用 meetup 服務同時將 JSON 對象創建為群組對象。
新手總是在控制器中管理模型對象。這樣,在控制器中會引用到一個群組集合。這是不推薦的,因為如果我們要改變服務,我們就必須在控制器中去修改這些功能。要對這樣的類保持記憶是困難的,因此這是一個導致 Bug 的誘因。
更簡單的做法是在界面和模型對象之間添加一層接口,這樣如果我們需要改變模型對象的管理方式時,控制器可以保持不變。如果接口不需要改變時,甚至可以只替換整個模型層。
我們的開發策略
在本教程中,我們從內到外來創建這個 app。一開是模型,然後編寫網絡和控制器。顯然這不是編寫 app 的唯一方式。我們將 app 按照層的方式進行分隔,而不是按照功能進行分隔,是因為這樣更容易繼續後面的工作並始終記住你將要做的事情是什麼。當在後面需要喚起你的記憶時,你更容易想起需要的信息。
從 Xcode 開始
現在,我們的旅程將從新建專案開始,這個專案我們會用到 AsyncDisplayKit。
打開 Xcode ,新建一個 iOS 專案,採用 Single View Application 模板。在選項窗口,設置 product name 為 BrowseMeetup ,語言選擇 Swift ,Device 選擇 iPhone。
要配置專案使其能夠使用 AsyncDisplayKit,請在專案導航器中選擇 Main.Storyboard 並刪除它。在專案導航器(project navigator)中,打開 AppDelegate.swift
將程式碼替換為:
@UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { let window = UIWindow(frame: UIScreen.main.bounds) window.backgroundColor = UIColor.white let feedVC = MeetupFeedViewController() let feedNavCtrl = UINavigationController(rootViewController: feedVC) window.rootViewController = feedNavCtrl window.makeKeyAndVisible() self.window = window return true } }
在這個方法中,我們簡單地初始化一個 AsyncDisplayKit 容器作為我們的主 View Controller,以免你直接將節點添加到已原有的視圖樹中。這非常重要,因為這才能讓節點在渲染的時候得到刷新。
程式碼現在不能通過編譯,因為 Xcode 還不認識 MeetupFeedController。你需要創建這個文件,首先在專案導航器中點擊 BrowseMeetup 群組。點開菜單 File | New | File…,選擇 iOS | Source | Swift File 模板,然後點 next。在 Save As 欄,填入類名 MeetupFeedViewController.swift
,點擊 Create。
打開 MeetupFeedViewController.swift
編寫如下程式碼:
import AsyncDisplayKit final class MeetupFeedViewController: ASViewController{ var _tableNode: ASTableNode init() { _tableNode = ASTableNode() super.init(node: _tableNode) setupInitialState() } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } }
有時候,初始化 View Controller 的方式簡單一點就好。例如我們的 MeetupFeedViewController
,它使用了一個指定初始化函數,在這個方法中初始化一個 ASViewController,並指定它的節點為一個 ASTableNode
。你可能不知道什麼是 ASTableNode
,只需要把它看成是 UIKit 的 UITableView
就好了,把它當成 UITableView
就是了。
再次編譯。編譯仍然不能通過,因為你的專案中還沒有導入 AsyncDisplayKit。
關閉 Xcode,打開終端。我們用 CocoaPods 來進行安裝。切換到 iOS 專案所在的目錄,執行 pod init 命令,創建一個空的 Podfile 檔案。
$ cd path/to/project $ pod init
現在來編寫 Podfile
,在檔案中寫入下述內容:
target 'BrowseMeetup' do use_frameworks! pod 'AsyncDisplayKit', ' 2.0' end
然後,運行 pod install
以便安裝這個框架。安裝可能會花幾分鐘時間,具體就要看你的網速了。當安裝完畢,打開剛剛生成的BrowseMeetup.xcworkspace
,而不是原先的 BrowseMeetup.xcodeproj
。
$ pod install $ open BrowseMeetup.xcworkspace
使用 Meetup APIs
在使用 Meetup API 之前,需要有一個 Meetup 賬號。進入 APIs Doc ,點擊 “Request to join Meetup group” 按鈕,根據屏幕上的提示進行註冊,並加入一個群。當註冊完後,你就可以用那個群對 API 進行沙盒測試。
為了能夠訪問 Meetup APIs,你需要擁有一個 API key。在 dashboard 中,點擊 API 標籤欄,你可以點擊小鎖圖標以查看你的 API key。
我們會用到一個 Meetup APIs (即 https://api.meetup.com/find/groups) 來搜索某個坐標附近的 Meetup 群組。使用時需要開發者指定一個經緯度坐標。Meetup 的 dashboard 中提供了一個控制台,允許你通過控制台來測試 APIs,點擊 Console,然後輸入 find/groups
試試看。
例如,如果請求 https://api.meetup.com/find/groups?&lat=51.509980&lon=-0.133700&page=1&key=1f5718c16a7fb3a5452f45193232,則得到一個 JSON 格式的響應:
[ { score: 1, id: 10288002, name: "Virtual Java User Group", link: "https://www.meetup.com/virtualJUG/", urlname: "virtualJUG", description: "If you don't live near an active Java User Group, or just yearn for more high quality technical sessions, The Virtual JUG is for you! If you live on planet Earth you can join. Actually even if you don't you can still join! Our aim is to get the greatest minds and speakers of the Java industry giving talks and presentations for this community, in the form of webinars and JUG session streaming from JUG f2f meetups. If you're a Java enthusiast and you want to learn more about Java and surrounding technologies, join and see what we have to offer!", created: 1379344850000, city: "London", country: "GB", localized_country_name: "United Kingdom", state: "17", join_mode: "open", visibility: "public", lat: 51.5, lon: -0.14, members: 10637, organizer: { id: 13374959, name: "Simon Maple", bio: "", photo: { id: 210505562, highres_link: "http://photos2.meetupstatic.com/photos/member/6/3/d/a/highres_210505562.jpeg", photo_link: "http://photos2.meetupstatic.com/photos/member/6/3/d/a/member_210505562.jpeg", thumb_link: "http://photos2.meetupstatic.com/photos/member/6/3/d/a/thumb_210505562.jpeg", type: "member", base_url: "http://photos2.meetupstatic.com" } }, who: "vJUGers", group_photo: { id: 454745514, highres_link: "http://photos4.meetupstatic.com/photos/event/1/5/8/a/highres_454745514.jpeg", photo_link: "http://photos4.meetupstatic.com/photos/event/1/5/8/a/600_454745514.jpeg", thumb_link: "http://photos4.meetupstatic.com/photos/event/1/5/8/a/thumb_454745514.jpeg", type: "event", base_url: "http://photos4.meetupstatic.com" }, key_photo: { id: 454577629, highres_link: "http://photos1.meetupstatic.com/photos/event/4/4/d/d/highres_454577629.jpeg", photo_link: "http://photos1.meetupstatic.com/photos/event/4/4/d/d/600_454577629.jpeg", thumb_link: "http://photos1.meetupstatic.com/photos/event/4/4/d/d/thumb_454577629.jpeg", type: "event", base_url: "http://photos1.meetupstatic.com" }, timezone: "Europe/London", next_event: { id: "235903314", name: "The JavaFX Ecosystem", yes_rsvp_count: 261, time: 1484154000000, utc_offset: 0 }, category: { id: 34, name: "Tech", shortname: "Tech", sort_name: "Tech" }, photos: [ { id: 454577629, highres_link: "http://photos1.meetupstatic.com/photos/event/4/4/d/d/highres_454577629.jpeg", photo_link: "http://photos1.meetupstatic.com/photos/event/4/4/d/d/600_454577629.jpeg", thumb_link: "http://photos1.meetupstatic.com/photos/event/4/4/d/d/thumb_454577629.jpeg", type: "event", base_url: "http://photos1.meetupstatic.com" }, { id: 454577652, highres_link: "http://photos1.meetupstatic.com/photos/event/4/4/f/4/highres_454577652.jpeg", photo_link: "http://photos1.meetupstatic.com/photos/event/4/4/f/4/600_454577652.jpeg", thumb_link: "http://photos1.meetupstatic.com/photos/event/4/4/f/4/thumb_454577652.jpeg", type: "event", base_url: "http://photos1.meetupstatic.com" }, { id: 454577660, highres_link: "http://photos1.meetupstatic.com/photos/event/4/4/f/c/highres_454577660.jpeg", photo_link: "http://photos1.meetupstatic.com/photos/event/4/4/f/c/600_454577660.jpeg", thumb_link: "http://photos1.meetupstatic.com/photos/event/4/4/f/c/thumb_454577660.jpeg", type: "event", base_url: "http://photos1.meetupstatic.com" }, { id: 454579647, highres_link: "http://photos4.meetupstatic.com/photos/event/4/c/b/f/highres_454579647.jpeg", photo_link: "http://photos2.meetupstatic.com/photos/event/4/c/b/f/600_454579647.jpeg", thumb_link: "http://photos2.meetupstatic.com/photos/event/4/c/b/f/thumb_454579647.jpeg", type: "event", base_url: "http://photos2.meetupstatic.com" } ] } ]
實現 Group 結構
我們的 BrowserMeetup app 需要一個模型,用於保存群組信息。這需要新建一個檔案,用於編寫實現程式碼。打開 專案導航器,添加一個 Swift 檔案,名為 Group.swift
。通過之前的 app 截圖,我們知道,它需要存儲創建日期、照片、城市、國家和創建者。
struct Group { let createdAt: Double! let photoUrl: URL! let city: String! let country: String! let organizer: Organizer! var timeInterval: String { let date = Date(timeIntervalSince1970: createdAt) let dateFormatter = DateFormatter() dateFormatter.dateStyle = .medium dateFormatter.timeStyle = .none return dateFormatter.string(from: date) } }
在結構定義中,我們添加了一個助手方法將 time interval 通過 date formatter 轉換成人類可讀的日期類型,只取 date 部分,忽略 time 部分。
這段代碼無法編譯,因為 Organizer
類型未知。要解決這個問題,我們需要再創建一個名為 Organizer.swift
的 Swift 檔案。並編輯它的內容為如下程式碼。
struct Organizer { }
現在,能夠順利編譯通過了。
實現 Organizer 結構
是上一節,我們用一個結構保存創建者的信息。接下來需要為它添加一些屬性。打開 OpenOrganizer.swift
,編輯為如下內容:
struct Organizer { let name: String! let thumbUrl: URL! }
每個創建者都有一個名字和頭像 URL,所以我們創建了兩個屬性來保存它們。
實現 MeetupService 類
前面說過,我們知道怎樣用 Meetup API 來查找附近的群組。即這個 URL https://api.meetup.com/find/groups ,它需要 3 個參數: 緯度、經度和關鍵字(你可以使用這個例子中的關鍵字,也可以用你自己的關鍵字來測試你的程式碼)。這個 API 返回一個 JSON 對象,其中包含了我們 app 需要的信息。
我們會創建一個 MeetupService
類,用於連接 API 並進行 JSON 解析。現在,添加一個新的 Swift 檔案 MeetupService.swift
。在其中編寫如下程式碼:
typealias JSONDictionary = Dictionarylet MEETUP_API_KEY = "1f5718c16a7fb3a5452f45193232" final class MeetupService { var baseUrl: String = "https://api.meetup.com/" lazy var session: URLSession = URLSession.shared func fetchMeetupGroupInLocation(latitude: Double, longitude: Double, completion: @escaping (_ results: [JSONDictionary]?, _ error: Error?) -> ()) { guard let url = URL(string: "\(baseUrl)find/groups?&lat=\(latitude)&lon=\(longitude)&page=10&key=\(MEETUP_API_KEY)") else { fatalError() } session.dataTask(with: url) { (data, response, error) in DispatchQueue.main.async(execute: { do { let results = try JSONSerialization.jsonObject(with: data!) as? [JSONDictionary] completion(results, nil); } catch let underlyingError { completion(nil, underlyingError); } }) }.resume() } }
我假定你十分熟悉 web 服務和 JSON 解析,就不細講這段程式了。簡單來說,我們使用了一個 URLSession
去調用 web API,然後向服務器請求數據。針對所返回的 JSON 數據,我們使用 JSONSerialization
進行剖析,並將結果返回給 completion 塊進行處理。
實現 LocationService 類
要調用 Meetup API 需要我們用 CoreLocation 獲取一個坐標。這部分內容稍微超出了本文的範疇,因此我們不再介紹,關於如何使用 Core Location,你可以參考我們的教程 How To Get the Current User Location 或者 這本書 。現在,新建一個 Swift 檔案,叫做 LocationService.swift
然後加入以下程式碼:
import Foundation import CoreLocation final class LocationService { var coordinate: CLLocationCoordinate2D? = CLLocationCoordinate2D(latitude: 51.509980, longitude: -0.133700) }
這裡,我們宣告了一個 CLLocationCoordinate2D 實例,將 latitude
和 longitude
設置為位於倫敦的某個坐標即我們的興趣點。處於演示的目的,我們硬編碼了這個坐標。
實現 DataManager 類
MeetupBrowse app 會顯示一張列表,列出附近的群組。這個列表受 MeetupFeedDataManager
類管理。再創建一個新的 Swift 檔案,叫做MeetupFeedDataManager.swift
。
編輯這個文件的內容如下:
final class MeetupFeedDataManager { fileprivate var _meetupService: MeetupService? fileprivate var _locationService: LocationService? init(meetupService: MeetupService, locationService: LocationService) { _meetupService = meetupService _locationService = locationService } func searchForGroupNearby(completion: @escaping ( _ groups: [Group]?, _ error: Error?) -> ()) { let coordinate = _locationService?.coordinate _meetupService?.fetchMeetupGroupInLocation(latitude: coordinate!.latitude, longitude: coordinate!.longitude, completion: { (results, error) in guard error == nil else { completion(nil, error); return } let groups = results?.flatMap(self.groupItemFromJSONDictionary) completion(groups, nil) }) } }
在 MeetupFeedDataManager
中,提供一個接受 MeetupService
和 LocationService
對象的初始化方法是一個不錯的做法,這就是所謂的依賴注入,通過這種設計模式可以使我們的類更容易被管理和測試。
從 JSON 中提取數據
searchForGroupNearby
方法調用 MeetupService
的 fetchMeetupGroupInLoaction
方法來獲取最近的群組列表。這些結果需要從 JSON 格式轉換成 app 領域中的某種對象,也就是我們早先宣告的模型類。
要將 JSON 對象轉換成 Group 對象,需要編寫一個 groupItemFromJSONDictionary
方法,這個方法用一個 JSONDictionary
對象作參數,並將 JSON 對象中的值抽取到 Group 對象的屬性中。在 MeetupFeedDataManager.swift
中加入下列程式碼:
func groupItemFromJSONDictionary(_ entry: JSONDictionary) -> Group? { guard let created = entry["created"] as? Double, let city = entry["city"] as? String, let country = entry["country"] as? String, let keyPhoto = entry["key_photo"] as? JSONDictionary, let photoUrl = keyPhoto["photo_link"] as? String, let organizerJSON = entry["organizer"] as? JSONDictionary, let organizer = organizerItemFromJSONDictionary(organizerJSON) else { return nil } return Group(createdAt: created, photoUrl: URL(string: photoUrl), city: city, country: country, organizer: organizer) }
這裡,JSONDictionary 中的每個值都會用可空綁定和 as?
類型轉換操作的方式提取到常量中。然後用這些值創建出 Group
對象。
上面的程式碼無法編譯,因為 organizerItemFromJSONDictionary
方法未知。要解決這個問題,在同一個類中加入以下程式碼:
func organizerItemFromJSONDictionary(_ entry: JSONDictionary) -> Organizer? { guard let name = entry["name"] as? String, let photo = entry["photo"] as? JSONDictionary, let thumbUrl = photo["thumb_link"] as? String else { return nil } return Organizer(name: name, thumbUrl: URL(string: thumbUrl)) }
Swift 內置的語言特性能夠讓我們很容易地使用 Foundation API 就可以對 JSON 數據進行解碼並抽取其中的數值,完全不需要藉助任何第三方框架和程式庫。
實現 Interactor 類
現在,我們已經實現了 JSON 數據的剖析,我們需要創建另一個類來處理和數據(實體)或網絡相關的業務邏輯,比如創建新的實體實例或者從服務器抓取實體。
新建一個 Swift 檔案,叫做 MeetupFeedInteractorIO.swift
,加入以下程式碼:
protocol MeetupFeedInteractorInput { func findGroupItemsNearby () } protocol MeetupFeedInteractorOutput { func foundGroupItems (_ groups: [Group]?, error: Error?) }
這些協議用於處理用戶輸入,以及處理需要顯示的內容。這種分離是基於單一責任原則。在我們的 app 中這主要體現在顯示附近的群組。 下面是在 MeetupFeedInteractor.swift
中的實現:
final class MeetupFeedInteractor: MeetupFeedInteractorInput { var dataManager: MeetupFeedDataManager? var output: MeetupFeedInteractorOutput? func findGroupItemsNearby() { dataManager?.searchForGroupNearby(completion: output!.foundGroupItems) } }
現在,我們的類使用 MeetupFeedInteractorInput
協議從用戶交互中採集輸入,這樣當它通過 findGroupItemsNearby
方法取得結果后會重新繪製 UI。
實現 MeetupFeedViewController
我們繼續來實現 MeetupFeedViewController
類,這個類負責顯示附近的群組。它也是用戶在 app 啟動后看到的第一個視圖。
在 專案導航器 中打開 MeetupFeedViewController.swift
,將 viewDidLoad
方法修改為這樣:
override func viewDidLoad() { super.viewDidLoad() _activityIndicatorView = UIActivityIndicatorView(activityIndicatorStyle: .gray) _activityIndicatorView.hidesWhenStopped = true _activityIndicatorView.sizeToFit() var refreshRect = _activityIndicatorView.frame refreshRect.origin = CGPoint(x: (view.bounds.size.width - _activityIndicatorView.frame.width) / 2.0, y: _activityIndicatorView.frame.midY) _activityIndicatorView.frame = refreshRect view.addSubview(_activityIndicatorView) _tableNode.view.allowsSelection = false _tableNode.view.separatorStyle = UITableViewCellSeparatorStyle.none _activityIndicatorView.startAnimating() handler?.findGroupItemsNearby() }
同時,需要在類中宣告 handler
和 _activityIndicatorView
屬性:
var handler: MeetupFeedInteractorInput? var _activityIndicatorView: UIActivityIndicatorView!
我們宣告了一個新的 UIActivityIndicatorView
物件,並將它添加到 subview 中,以便在加載數據的過程中顯示一個旋轉的小菊花。同時禁用了交互,因為 app 只有一個 View Controller。然後,利用 handler(MeetupFeedInteractor
)來查找附近的群組。
然後,我們會創建一個方法來初始化 View Controller 的狀態。在這個方法中,我們會定義控制器的數據提供者和數據源。在後面,我們會創建一個 MeetupFeedTableDataProvider
的類來處理數據。現在,先來編寫這個方法:
func setupInitialState() { title = "Browse Meetup" _dataProvider = MeetupFeedTableDataProvider() _dataProvider._tableNode = _tableNode _tableNode.dataSource = _dataProvider }
同時需要宣告一個屬性,用做我們的數據提供者:
var _dataProvider: MeetupFeedTableDataProvider!
還記得我們曾經定義過一個 MeetupFeedInteractorOutput
協議嗎?在 MeetupFeedInteractor
中會呼叫這個協議方法:
func findGroupItemsNearby() { dataManager?.searchForGroupNearby(completion: output!.foundGroupItems) }
但是,我們還沒有實現這個方法(即 foundGroupItems
)。我們在 MeetupFeedViewController
類中實現它。因此,將類的定義修改為:
final class MeetupFeedViewController: ASViewController, MeetupFeedInteractorOutput
然後實現這個方法:
func foundGroupItems(_ groups: [Group]?, error: Error?) { guard error == nil else { return } _dataProvider.insertNewGroupsInTableView(groups!) _activityIndicatorView.stopAnimating() }
當這個方法被呼叫時,我們會將處理 groups 並將它插入到 Table View 中。我們使用了數據提供者中實現的 insertNewGroupsInTableView
方法,這個方法將實體對象出入到 Table 節點,後面解釋。同時需要將 Activity Indicator 停止,因為我們不需要它了。
實現 MeetupFeedTableDataProvider
在上一節,我們創建了一個類充當 Table 節點的數據源。在這一節,我們來實現它的屬性和方法。
創建一個新文件 MeetupFeedTableDataProvider.swift
,修改它的內容為:
import Foundation import AsyncDisplayKit class MeetupFeedTableDataProvider: NSObject, ASTableDataSource { var _groups: [Group]? weak var _tableNode: ASTableNode? ///-------------------------------------- // MARK - Table data source ///-------------------------------------- func tableNode(_ tableNode: ASTableNode, numberOfRowsInSection section: Int) -> Int { return _groups?.count ?? 0 } func tableNode(_ tableNode: ASTableNode, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock { let group = _groups![indexPath.row] let cellNodeBlock = { () -> ASCellNode in return GroupCellNode(group: group) } return cellNodeBlock } ///-------------------------------------- // MARK - Helper Methods ///-------------------------------------- func insertNewGroupsInTableView(_ groups: [Group]) { _groups = groups let section = 0 var indexPaths = [IndexPath]() groups.enumerated().forEach { (row, group) in let path = IndexPath(row: row, section: section) indexPaths.append(path) } _tableNode?.insertRows(at: indexPaths, with: .none) } }
就像之前說的,要使用 AsyncDisplayKit 中的 Table View,我們必須實現 ASTableDataSource
協議。這裡,我們創建了一個新類實現這個協議並讓它作為數據提供者。
這些方法和你熟悉的 UITableViewDataSource
協議方法非常像。 在第一個方法中,我們返回了需要顯示在 Table View 中的群組的總數。
在 tableNode(_:nodeForRowAtIndexPath:)
方法中,我們獲取了與 indexPath.row
所指定的行相對應的 Group 物件。然後,創建了一個 GroupCellNode,它是一個 ASCellNode
的抽象。最後,我們將 Group 物件和這個 cell 綁定並返。
建議你使用這些方法的節點塊版本,這樣你的集合節點就能夠同步地準備和顯示它的 cell。這還意味著所有子節點的初始化方法能夠在後台執行。
在 MeetupFeedTableDataProvider
中的 insertNewGroupsInTableView
方法,用於將群組插入到 Table 節點中。這裡,我們呼叫了 Table 節點的 insertRows
方法來插入行。需要注意的是,這個方法必須從主執行緒中呼叫。
實現 GroupCell
如果你曾經自定義過 Table Cell,那麼你就知道需要為 Table Cell 創建一個自定義類。同樣,在使用 AsyncDisplayKit 時,我們需要創建一個自定義的 Cell 節點,並延伸 ASCellNode
以呈現自定義數據。這裡,我們會創建一個 GroupCellNode
類,用於保持住對 Label 和 Image 的引用。
再新建一個檔案,命名為 GroupCellNode
。修改它的內容為:
import AsyncDisplayKit fileprivate let SmallFontSize: CGFloat = 12 fileprivate let FontSize: CGFloat = 12 fileprivate let OrganizerImageSize: CGFloat = 30 fileprivate let HorizontalBuffer: CGFloat = 10 final class GroupCellNode: ASCellNode { fileprivate var _organizerAvatarImageView: ASNetworkImageNode! fileprivate var _organizerNameLabel: ASTextNode! fileprivate var _locationLabel: ASTextNode! fileprivate var _timeIntervalSincePostLabel: ASTextNode! fileprivate var _photoImageView: ASNetworkImageNode! init(group: Group) { super.init() _organizerAvatarImageView = ASNetworkImageNode() _organizerAvatarImageView.cornerRadius = OrganizerImageSize/2 _organizerAvatarImageView.clipsToBounds = true _organizerAvatarImageView?.url = group.organizer.thumbUrl _organizerNameLabel = createLayerBackedTextNode(attributedString: NSAttributedString(string: group.organizer.name, attributes: [NSFontAttributeName: UIFont(name: "Avenir-Medium", size: FontSize)!, NSForegroundColorAttributeName: UIColor.darkGray])) let location = "\(group.city!), \(group.country!)" _locationLabel = createLayerBackedTextNode(attributedString: NSAttributedString(string: location, attributes: [NSFontAttributeName: UIFont(name: "Avenir-Medium", size: SmallFontSize)!, NSForegroundColorAttributeName: UIColor.blue])) _timeIntervalSincePostLabel = createLayerBackedTextNode(attributedString: NSAttributedString(string: group.timeInterval, attributes: [NSFontAttributeName: UIFont(name: "Avenir-Medium", size: FontSize)!, NSForegroundColorAttributeName: UIColor.lightGray])) _photoImageView = ASNetworkImageNode() _photoImageView?.url = group.photoUrl automaticallyManagesSubnodes = true } fileprivate func createLayerBackedTextNode(attributedString: NSAttributedString) -> ASTextNode { let textNode = ASTextNode() textNode.isLayerBacked = true textNode.attributedText = attributedString return textNode } }
這個節點會下載和顯示 Meetup 群組的縮略圖。AsyncDisplay 有一個類,叫做 ASNetworkImageNode
,它會下載並顯示遠程圖片。你所需要做的僅僅是設置圖片的 URL 地址給它的 url
屬性。這個圖片就會異步加載並同步地顯示。
對於文字,我們使用 ASTextNode
來進行顯示。一個文字節點和我們常用的 UILabel
類似。它增加了富文本支持並延伸了 ASControlNode
類。
助手方法(即 createLayerBackedTextNode
方法)用於將創建 Label 時重複的程式碼放在一個方法裡。
AsyncDisplayKit 的自動佈局基於 CSS 盒子模型。和 UIKit 的佈局約束相比,它的效率更高,更容易調試、更清晰、機構化,能構造複雜和可重用的佈局。
現在來編寫佈局方法:
override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { _locationLabel.style.flexShrink = 1.0 _organizerNameLabel.style.flexShrink = 1.0 let headerSubStack = ASStackLayoutSpec.vertical() headerSubStack.children = [_organizerNameLabel, _locationLabel] _organizerAvatarImageView.style.preferredSize = CGSize(width: OrganizerImageSize, height: OrganizerImageSize) let spacer = ASLayoutSpec() spacer.style.flexGrow = 1.0 let avatarInsets = UIEdgeInsets(top: HorizontalBuffer, left: 0, bottom: HorizontalBuffer, right: HorizontalBuffer) let avatarInset = ASInsetLayoutSpec(insets: avatarInsets, child: _organizerAvatarImageView) let headerStack = ASStackLayoutSpec.horizontal() headerStack.alignItems = ASStackLayoutAlignItems.center headerStack.justifyContent = ASStackLayoutJustifyContent.start headerStack.children = [avatarInset, headerSubStack, spacer, _timeIntervalSincePostLabel] let headerInsets = UIEdgeInsets(top: 0, left: HorizontalBuffer, bottom: 0, right: HorizontalBuffer) let headerWithInset = ASInsetLayoutSpec(insets: headerInsets, child: headerStack) let cellWidth = constrainedSize.max.width _photoImageView.style.preferredSize = CGSize(width: cellWidth, height: cellWidth) let photoImageViewAbsolute = ASAbsoluteLayoutSpec(children: [_photoImageView]) //ASStaticLayoutSpec(children: [_photoImageView]) let verticalStack = ASStackLayoutSpec.vertical() verticalStack.alignItems = ASStackLayoutAlignItems.stretch verticalStack.children = [headerWithInset, photoImageViewAbsolute] return verticalStack }
上述程式碼使用了一個非常強大的佈局規範叫做 ASStackLayoutSpec。它包含了大量屬性,你可以用來實現你想要實現的任何效果。同時,我們還使用了 ASInsetLayoutSpec 來添加一些 padding。
簡單說,這段程式碼創建了一個垂直的 Stack 佈局,包含了兩個節點和另一個水平 Stack,這個水平 Stack 位於頂端,自己又包含了另外 3 個節點用於顯示創建者的頭像、創建者名字和群組的位置。最後,我們將整個節點包裝成一個垂直的 ASStackLayoutSpec 返回。
ASLayoutSpec
當成小間隔來用。
總裝
在前面,我們已經分別用 AsyncDisplayKit 實現了 app 的各個部分。現在,我們來將它們組裝成一個完整的 app。
打開專案導航器,選擇 AppDelegate.swift
。修改 application(:didFinishLaunchingWithOptions:)
方法為:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { let window = UIWindow(frame: UIScreen.main.bounds) window.backgroundColor = UIColor.white let feedVC = MeetupFeedViewController() let locationService = LocationService() let meetupService = MeetupService() let dataManager = MeetupFeedDataManager(meetupService: meetupService, locationService: locationService) let interactor = MeetupFeedInteractor() interactor.dataManager = dataManager interactor.output = feedVC feedVC.handler = interactor let feedNavCtrl = UINavigationController(rootViewController: feedVC) window.rootViewController = feedNavCtrl window.makeKeyAndVisible() self.window = window return true }
我們添加了幾句話,包括初始化 Feed View Controller、數據管理器和 Interactor。現在,你可以運行 app 進行測試了。app 會從 meetup.com 下載和顯示 Meetup 群組。但是,圖片無法顯示。如果你看一眼控制台,你會發現報錯了:
App Transport Security has blocked a cleartext HTTP (http://)
App 傳輸安全(ATS) 是從 iOS 9 開始引入的,它規定 app 優先使用 HTTPS 安全網絡連接。如果你的 app 要訪問 HTTP 遠程資源,你就會看到這個錯誤。
關閉 ATS
要解決這個問題,你可以在 Info.plist
檔案中關閉 ATS。在專案導航器中選擇 Info.plist
並編輯它的這個地方:
將 Allow Arbitrary Loads 選項設置為 YES
可以關閉 ATS。然後再次運行 app。這次,你可以看到群組圖片了。
結束
恭喜你,app 完成了!在本文,我帶你了解了 AsyncDisplayKit 的基本知識。你現在知道如何在自己的專案中通過 AsyncDisplayKit 來創建響應式 UI 了。更多補充內容,我建議你閱讀 官方文檔。
你可以 從 GitHub 下載完成的專案 。
你對本文和 AsyncDisplayKit 框架作何感想?如果你想聽我繼續介紹這個令人驚歎的框架,請告訴我。