人們常說,程序員能將咖啡變成電腦程式。接下來,我們將編寫一個 App,列出距離你最近的咖啡屋!
在本教程中,你將使用到如下技能:
這個 App 會以你當前位置為中心,從 Foursquare 抓取以此為中心 500*500 米範圍內的地標信息。然後用一個地圖視圖(MKMapView)和表格視圖(UITableView)來顯示這些數據。并使用 Realm 來篩選數據,并在閉包中對數據進行排序。
你可以從 GitHub 上下載完整的的源代碼和 Xcode 項目:reinderdevries/CoffeeGuide
如此簡單?讓我們快快進入正題吧!
首先,我們需要在 Xcode 進行一些設置。打開 Xcode,選擇 File\New\Project…
在 iOS\Application 類別下選擇 Single View Application。然後,填写這些内容:
然後選擇項目存放路徑,“創建本地庫”(create a local Git repository)一項可選可不選。
接下來,需要創建一個 Podfile 文件。在項目導航窗口,在項目名稱上右鍵,選擇 New File…,如下圖所示,選擇 iOS\Other 下的 Empty 模板。
將文件命名為 Podfile(沒有擴展名),然后將它保存在 .xcodeproj 文件的同一目錄下。最後,確認一下 Tagets 列表下 Coffee 前面的選擇框已被正確勾選。
然後,在 Podfile 文件中輸入以下內容:
source 'https://github.com/CocoaPods/Specs.git' platform :ios, '8.0' use_frameworks! pod 'QuadratTouch', '>= 1.0' pod 'RealmSwift'
在本項目中,我們要用到兩個外部庫:Realm 和一個用於 Foursquare REST API 的 Swift 庫 Das Quadrat。
然後,關閉項目,關閉 Xcode。打開 OS X 的終端窗口,將目錄切換到你的項目目錄。如果你不知道怎麼做,請遵循如下步驟:
然後在終端窗口中輸入:
pod install
命令的執行大約要個幾分鐘時間,同時屏幕上將有大量信息滾動顯示。Cocoapods 將為你的項目安裝所需的庫。同時將你的項目轉換為工作空間(由多個項目組成)。
然後,在 Finder 中找到新生成的 .xcworkspace 文件并打開它。這個文件就位於你的項目的根目錄下。
注意:當你在 Xcode 中打開這個工作空間時,你的項目很可能是處於折叠狀態。你可以將項目文件恢復到原來的打開狀態 ── 關閉工作空間,然後再打開工作空間。這會導致項目文件不再是折叠狀態。
這就是你需要為本 App 的 Xcode 項目所進行的所有設置。如果一切順利,你應該擁有了一個包含了兩個項目的工作空間。其中 Pods 項目包含了 Realm 和 Das Quadrat 庫的代碼。
Coffee 的 UI 非常簡單。它只有兩個 UI 元素:一個地圖視圖和一個表格視圖。
Xcode 已經為我們做了大量的工作。Single View Application 模板包含了一個故事板文件 Main.storyboard,它是 App 的入口。
要創建地圖視圖,需要:
接著為這兩個 View 設置自動佈局約束。首先,選中地圖視圖,點擊故事板編輯器右下角的 Pin 按鈕,這個按鈕位於右邊第二個位置,形如星戰中鈦式戰機 …
然後會彈出一個小窗口,接下來你需要做:
接下來,在表格視圖上重複同樣的動作。但是將上面的 I 形線替換成下面的 I 形線(同時還有左和右)。同樣地,反選“Constrain to margins”選項,然後點擊“Add 3 constraints”按鈕。
我們讓兩個 View 分別相對於上對齊和下對齊,寬度都是父 View 的百分之百。還有一件事情,就是讓兩個 View 的垂直高度都等於整個屏幕高度的 50%。
我們可以用多個約束來實現這點,但這是最簡單的:
現在 Xcode 可能會報錯說“有佈局衝突”。別擔心,我們會來修復它。
現在紅線消失了,IB 又開始顯示黃線。這表示有部份約束當前顯示不正確。所有的約束都是對的,僅僅是 IB 在顯示上不正確。
要解決這個問題,點擊黃色的帶圈的箭頭,這個圖標位於故事板編輯器 Document Outline 窗口的右上角。
點擊這個圖標后,會顯示一個新的界面。點擊黃色的三角,然後點 Update frames,再點擊 Fix misplacement。在每個黃色的三角上重複同樣步驟。當然,Update frames 的辦法并不是每次都有效,因此確保你的約束都創建正確,再讓你的 frame 也正確。
不幸的是,佈局約束經常會出現大量錯誤。如果你搞錯了某些事情,你可以從 Document Outline 窗口中將約束刪除,然後重建。
讓我們來運行一下 App。在開發的時候,我們經常需要運行 App,以檢驗我們的改動是否正確。
當你已經非常熟練的時候,你可以修改很多內容而不用檢查這些修改是否正確。通過本能來判斷自己有沒有做錯。但如果你還是一個新手,則儘量不要步子邁得太大。一次只解決一個問題,然後就檢查 App 是否能正常工作。如果出錯,你就會知道剛才修改的地方出錯了。這個道理很簡單。
要運行 App,這樣做:按下 Command + B 或者 Command + R。一個是編譯,一個是編譯并運行。在 Xcode 的左上角,你可以選擇在什麼樣的 iPhone 上運行 App。如果你將 iPhone 或者 iPad 連上 Mac,同時這些設備已經可以用於開發,則你也可以在這個地方選擇它們。
看一下 App 能運行嗎?
答案是不能運行!讓我們來找出問題并解決它。找到 Debug 窗口,它位於 Xcode 窗口的底部。在 Debug 窗口的右邊你會看到有一個錯誤。
如果你不能看到上圖的畫面,點擊右上角的按鈕和右下角的按鈕讓它顯示。
這個錯誤是:
2015-11-04 14:37:56.353 Coffee[85299:6341066] *** Terminating app due to uncaught exception 'NSInvalidUnarchiveOperationException', reason: 'Could not instantiate class named MKMapView' *** First throw call stack: ( 0 CoreFoundation 0x0000000109fdff65 exceptionPreprocess + 165
編程中錯誤信息經常不是很直觀,甚至有時候根本就算不上是錯誤信息。大部份運行時錯誤都會由一個異常,一條消息和一個調用堆棧構成。
這三個部份都有利於你找出錯誤的原因。例如,你可以用這個異常去定位拋出該錯誤的代碼片段。而調用堆棧會列出在錯誤出現時的類以及方法的調用列表。也就是所謂的“回溯”,以倒序列出錯誤發生之前執行過的代碼。
現在,先看一下錯誤信息,因為這是最容易利用的部份。它說的是:
不能實例化 MKMapView 類
MKMapView 這個詞我們是知道的。我們曾經在 IB 中使用了它。它是一個地圖視圖,位於 UI 的上半部份。初始化這個詞有點程序員的意思,它是說編譯器(Xcode 的一部份,用於將代碼轉換成 App 的二進制)無法創建 MKMapView 的副本。總之就是說:我無法創建地圖視圖。
不幸的是,99% 的錯誤信息不會告訴你怎麼解決問題。它只會告訴你發生了什麼,但問題的原因卻無法看到。
現在怎麼辦?你需要干兩件事情:
好吧,拷貝這段錯誤信息,然後在 Google 上搜索。這下你可能會搜索出這樣的結果:
點擊最上面的超鏈接。它會帶你到 StackOverflow,這是一個關於編程的 Q&A 網站。上面會有這個星球上幾乎所有語言的問題,以及這些問題的答案。
這就是你要做的,在 StackOverflow 上瀏覽答案:
好了,到底是什麼原因出現的問題?答案是 MapKit 框架沒有添加到項目中。顯然,MKMapView 的代碼是放在某個外部框架中了。這些代碼必須被加到項目中來,雖然我們沒有直接在代碼中使用過地圖視圖。
如果你看完了網頁,你會發現还有大量的反面例子,也會引發這個錯誤。
好了,該我們來解決這個問題了:
這會將這個框架添加到 Link Binary With Libraries 列表和項目中。
你接下來的任務是 Geolocation,即將用戶的位置顯示到地圖上。
首先,需要將故事板中的地圖視圖連接到代碼中。在我們新建項目時,會創建一個文件 ViewController.swift,這就是故事板中的 View Controller 放置代碼的地方。
接下來檢查一下故事板和代碼之間的這種連接是否存在:
這表明故事板和 ViewController 的代碼是有聯繫的,幸虧我們有 class 關鍵字可用。如果你向故事板中加入另一個 View Controller,你可以為它指定另一個類名。
現在我們知道 ViewController 和代碼其實是連接在一起,我們需要創建一個出口連接到地圖視圖。在你用自己的代碼引用這個地圖視圖之前,需要為地圖視圖的創建一個連接。
打開 ViewController.swift 在第一行的 { 括號之後加入以下代碼:
@IBOutlet var mapView:MKMapView?
這是什麼意思?
是否分不清變量和屬性的概念?一個變量是一個簡單的容器,用於存放數據。屬性也是一種變量,但它附屬於某個類。有兩種屬性:實例屬性和類屬性。
是否分不清類、實例和類型?可以把類看成是一個“死的”(就像是壓鑄出來的)模板,用於創建一堆複製品。這些複製品就是實例。而類型的概念有點模糊,但你可以簡單地把它看成和類的概念差不多。
是否分不清聲明、初始化和實例化?好吧,首先來說聲明:聲明僅僅是告訴編譯器,我要使用某個變量,它的名字是什麼,它的類型是什麼。初始化的意思則是我要給這個變量一個初始值。也就是在聲明一個變量的同時,將一個值賦給這個變量。如果你不初始化變量,則變量的值就是 nil。實例化的意思則是你將一個實例(類的一個複製品)賦給這個變量。專業點講,一個變量就是一個實例化對象的引用。
回到項目。在添加上面的代碼后,Xcode 會拋出另一個錯誤。這個錯誤說的是:
MKMapView 類型未聲明
這是因為你還沒有將 MapKit 引入到當前文件!因此,在類定義之上添加一個 import 語句。在 import UIKit 之後加入:
import MapKit
現在,我們再來創建 outlet 連接。
好了,讓我們來編寫使用地圖視圖代碼。首先,在 ViewController 類中加入:
override func viewWillAppear(animated: Bool) { super.viewWillAppear(animated) if let mapView = self.mapView { mapView.delegate = self } }
寫在什麼地方?任何地方!只要在類聲明的一對大括號 {} 之內。
所有方法都必須寫在類的範圍內。一個類的範圍從第一個左大括號 { 開始,到最後一個右大括號 } 結束。
這必須是平衡的,即每個左大括號 { 都必須配有一個對應的右大括號 }。而且,程序員需要使用縮進來表示範圍的級別。通常,每當一個左括號 { 之後就要使用一個 Tab 符或者四個空格表示一個縮進(在右括號之後則進行反縮進)。
我們來看看這個方法:
現在 Xcode 又向我們拋出了一個錯誤。這次它說的是“無法將 self 賦給 delegate 屬性,因為 ViewController 並不是一個 MKMapViewDelegate 類型”。我們來解決這個問題。
修改類的聲明如下:
class ViewController: UIViewController, MKMapViewDelegate
現在地圖視圖已經創建,我們可以來獲取用戶的位置了。
為 ViewController 類加入下列屬性:
var locationManager:CLLocationManager? let distanceSpan:Double = 500
第一個變量是 locationManager,它是一個 CLLocationManager 類型。它是可空的,因此它可以保存 nil。第二個屬性是一個 Double 類型的常量,它的值是 500。Double 類型是一種雙精度浮點數(即長度是 Float 的兩倍)。
在類中加入下列方法。可以在 viewWillAppear 方法后添加這個方法:
override func viewDidAppear(animated: Bool) { if locationManager == nil { locationManager = CLLocationManager() locationManager!.delegate = self locationManager!.desiredAccuracy = kCLLocationAccuracyBestForNavigation locationManager!.requestAlwaysAuthorization() locationManager!.distanceFilter = 50 // Don't send location updates with a distance smaller than 50 meters between them locationManager!.startUpdatingLocation() } }
呃,這些代碼是什麼意思?
class ViewController: UIViewController, MKMapViewDelegate, CLLocationManagerDelegate現在,在 ViewController 中增加如下方法。這個方法是一個委託方法,你可以將它放在前面的方法之後。
func locationManager(manager: CLLocationManager, didUpdateToLocation newLocation: CLLocation, fromLocation oldLocation: CLLocation) { if let mapView = self.mapView { let region = MKCoordinateRegionMakeWithDistance(newLocation.coordinate, distanceSpan, distanceSpan) mapView.setRegion(region, animated: true) } }這些代碼是什麼意思?
現在我們來運行一下 App。確認你的 target 中選擇了一個 iPhone 模擬器,然後按下 Command + R。App 將啟動,并彈出一個需要授權的窗口,點擊“允許”。
當你點擊“允許”后,地圖視圖並不會刷新。因為模擬器沒有 GPS,我們需要模擬一個。
當 App 在模擬器中運行時,請使用以下倆個方式之一來模擬 GPS 位置數據:
當你選擇了一個位置后,地圖視圖將刷新并將指定的位置放大顯示。
看到了嗎?干得漂亮!
接下來 App 會變得更有趣!你將使用 Das Quadrat 庫從 Foursquare 獲取數據,然後用 Realm 將數據保存到本地。
在使用 Foursquare API 之前,你需要在它的開發者網站上註冊 App。這個步驟其實很簡單。
現在,我們要編寫連接 Foursquare 的代碼了。你將用單例模式來實現。單例模式適合于我們現在的場景。
一個單例是一個類實例,它是類僅有的實例。使用單例時,我們不能為一個類創建兩個實例。為什麼我們要用單例模式?對於單例的使用,一直有很大的爭議,但它對於這種情況是非常適用的:避免對一個外部資源出現多個併發的連接。
你可以想一下。如果你向一個 web 服務器發送兩個請求,同時這倆個請求都是向一個文件進行寫操作?數據將變得混亂不堪,除非 web 服務器讓其中一個請求比另一個請求優先執行。
一個單例保證只從 App 的一個地方請求外部資源。在單例模式下,大量的實現是為了保證不出現資源衝突。請求隊列和序列就是其中的一種實現,但這卻不在本教程的討論範圍。
你需要這樣做:
哇,創建了一個空文件!讓我們來編輯它。在文件開頭的第一個 import 語句后,加入:
import QuadratTouch import MapKit import RealmSwift
然後加入代碼:
struct API { struct notifications { static let venuesUpdated = "venues updated" } }
這段代碼很簡單,不是嗎?首先我們導入了所需的庫(Quadrat, MapKit, Realm),然後定義了一個“結構的結構”,叫做 venuesUpdated,它裡面有一個靜態常量。然後,我們就可以像這樣調用這個結構:
API.notifications.venuesUpdated
然後,繼續編寫代碼:
class CoffeeAPI { static let sharedInstance = CoffeeAPI() var session:Session? }
這段代碼負責完成這些事情:
然後,我們將通過 CoffeeAPI.sharedInstance 的方式來訪問 Coffee API。無論在任何地方,你都可以這樣調用,你都會引用到同一個對象,這就是單例的特點。
然後是構造函數。在上述屬性聲明后,在類的大括號之內輸入:
init() { // 初始化 Foursquare client let client = Client(clientID: "...", clientSecret: "...", redirectURL: "") let configuration = Configuration(client:client) Session.setupSharedSessionWithConfiguration(configuration) self.session = Session.sharedSession() }
構造函數是一個方法,當類實例被實例化之後這個方法就會被調用。這是在一個實例被創建之後會自動調用的第一個方法。
還記得你在 Foursquare 開發者網站上拷貝的 Client ID 和 Client Secret嗎?將它們粘貼到構造函數的 … 中,然後將 redirectURL 參數設為空白。變成這樣:
let client = Client(clientID: "X4I3CFADAN4MEB2TEVYUZSQ4SHSTXSZL34VNP4CJHSJGLKPV", clientSecret: "EDOLJK3AGCOQDRKVT2GK5E4GECU42UJUCGGWLTUFNEF1ZXHB", redirectURL: "")
好了,還要做一件事情。將下列代碼拷貝到 CoffeeAPI.swift,記得粘貼在 CoffeeAPI 類之外。也就是說放在文件最後的一個大括號 } 之後。
extension CLLocation { func parameters() -> Parameters { let ll = "\(self.coordinate.latitude),\(self.coordinate.longitude)" let llAcc = "\(self.horizontalAccuracy)" let alt = "\(self.altitude)" let altAcc = "\(self.verticalAccuracy)" let parameters = [ Parameter.ll:ll, Parameter.llAcc:llAcc, Parameter.alt:alt, Parameter.altAcc:altAcc ] return parameters } }
這段代碼是什麽意思?這是一個擴展,擴展會為某個基類增加一些額外的功能。它不用創建新的類,你可以擴展基類 CLLocation,讓它增加一個新的方法 parameters()。每當代碼中用到 CLLocation 對象的時候,就會加載你的擴展,你都可以調用這個對象的 parameters 方法。哪怕這個方法在原來的類中根本不存在。
注意:不要將 Swift 中的擴展和編程術語“擴展”(即繼承)相混淆。前者是用新的功能增強某個基類,而後者則表示在父類基礎上創建一個子類。
parameters 方法返回一個 Parameters 對象,Parameters 可以簡單地看成是一種鍵值類型的字典,它用於包含參數化的信息(GPS 座標和精度)。
好,讓我們來從 Foursquare 獲取數據吧。Foursquare 有一個 HTTP REST 風格的 API,返回的數據是 JSON 格式。幸好我們不用和它們打交道,它們都已經封裝到了 Das Quadrat 庫中。
向 Foursquare 請求數據只需要用到 session (就是我們剛剛創建的那個)的一個屬性,并調用該屬性的一個方法。這個方法返回一個 Task 對象,這個對象引用了一個異步的後臺任務。我們可以為該方法提供一個完成閉包;這樣當任務完成時就可以執行閉包中的代碼:
let searchTask = session.venues.search(parameters) { (result) -> Void in // 對 "result" 進行處理 }
session 的 venues 屬性包含了所有從 Foursquare API 返回的與“地標”有關的信息。你需要向 search 方法傳遞一個 parameters 參數,第二個參數則是一個閉包,這個閉包在任務完成時會被調用。這個方法會返回一個引用,這個引用是一個耗時的後臺任務。通過這個引用,你可以在任務完成之前停止該任務,或者在其它地方用它來了解任務進度。
接下來是下面的這段代碼。將它複製粘貼到你的代碼中,放到 init 構造方法下面,類的右大括號 } 前面。然後我們再細細講解這些代碼有些什麽作用。
func getCoffeeShopsWithLocation(location:CLLocation) { if let session = self.session { var parameters = location.parameters() parameters += [Parameter.categoryId: "4bf58dd8d48988d1e0931735"] parameters += [Parameter.radius: "2000"] parameters += [Parameter.limit: "50"] // 開始搜索,即異步調用 Foursquare,并返回地標數據 let searchTask = session.venues.search(parameters) { (result) -> Void in if let response = result.response { if let venues = response["venues"] as? [[String: AnyObject]] { autoreleasepool { let realm = try! Realm() realm.beginWrite() for venue:[String: AnyObject] in venues { let venueObject:Venue = Venue() if let id = venue["id"] as? String { venueObject.id = id } if let name = venue["name"] as? String { venueObject.name = name } if let location = venue["location"] as? [String: AnyObject] { if let longitude = location["lng"] as? Float { venueObject.longitude = longitude } if let latitude = location["lat"] as? Float { venueObject.latitude = latitude } if let formattedAddress = location["formattedAddress"] as? [String] { venueObject.address = formattedAddress.joinWithSeparator(" ") } } realm.add(venueObject, update: true) } do { try realm.commitWrite() print("Committing write...") } catch (let e) { print("Y U NO REALM ? \(e)") } } NSNotificationCenter.defaultCenter().postNotificationName(API.notifications.venuesUpdated, object: nil, userInfo: nil) } } } searchTask.start() } }
這段代碼好多!在這個方法中主要完成了五個任務:
讓我們來逐行分析上述代碼:
構建請求
首先,用一個可空綁定判斷 self.session 是否為空。如果不為空,將 self.session 解包到 session 中。
然後,調用 location 的 parameters 方法。這個 location 是哪來的?在 getCoffeeShopsWithLocation 方法中有一個參數 location。每當調用這個方法時,都需要向 location 參數傳遞一個位置參數。另外,parameters 方法則來自於我們前面創建的擴展。
然後,向 parameters 字典中加入一個新對象,鍵名設為 Parameter.CategoryId,值設為字符串 “4bf58dd8d48988d1e0931735”。這個字符串是 Foursquare 中的類別 ID,表示“Coffeeshops”的意思。
設置請求
然後來創建請求。調用 session.venues.search() 方法。這個方法需要兩個參數(并不是一個參數):我們創建的 parameters 對象,以及尾隨其後的閉包。這種寫法是典型的尾隨閉包的寫法。如果閉包是方法的最後一個參數,則可以不把它寫在調用方法的圓括號內,而放到圓括號()之後并用一對大括號{}包裹住塊。search 方法返回一個引用,指向耗時的搜索線程。搜索線程創建后並不會自動開始,我們需要在後面啟動它(就在方法的最後一句)。
編寫完成閉包
然後,我們進入到閉包內部。需要強調一點,儘管這些代碼是順序書寫的,但它們並不會順序執行。閉包只會在搜索任務完成之後執行。App 的執行順序將從 let searchTask … 一句跳到 searchTask.start() 一句,當 HTTP API 向 App 返回數據時,又會跳到 if let response = … 一句開始執行。
這個閉包的簽名(又叫做閉包表達式語法)是:(result)->Void in。意思是這個閉包有一個參數 result,閉包返回值為空(Void)。這和我們常見的方法是一樣的。
解析返回結果
然後是兩個可空綁定 if let:
這種方式能夠確保數據類型是我們期望的。如果转换失败,或者可空绑定失败,if 语句就不會被執行。這就像是一塊石頭上站了兩隻鳥:判斷值不為空,同時嘗試將值轉換為預期的類型。
你現在知道 venues 是什麼類型麼?它是一個字典的數組,字典的類型則是 String:AnyObject 鍵值對。
自動釋放內存
然後,是一件很有趣的事情:我們創建了一個自動釋放池。當然,這是一個很大題目。你了解 iPhone 的內存管理機制嗎?
基本上,當內存中的對象不再被任何對象引用時,它會被刪除。類似垃圾回收機制,但又不完全相同。當自動釋放池中的對象被釋放時,它被交給自動釋放池處理。當自動釋放池被釋放時,池里的所有內存才被釋放。它就像是批量的內存釋放。
為什麼要這樣做?自己創建自動釋放池,有助於提高 iPhone 系統的內存管理效率。因為我們需要在自動釋放池中處理數百個 venue 對象,如果不清理內存的話,這會導致內存緊張。對於一般的自動釋放池來說,釋放內存的最早時機是方法結束的時候。因此,這就有可能導致內存耗盡,因為自動回收機制無法及時、迅速地回收內存。通過創建自己的自動釋放池,我們可以干預內存的回收,避免內存空間不足。
使用 Realm
然後是“let realm = try! Realm()”一句,這實例化了一個 Realm 對象。在使用 Realm 的數據之前我們需要一個 Realm 對象。 try! 關鍵字是 Swift 的異常處理機制。通過它,我們表明:我們不處理來自於 Realm 的錯誤。在真實項目中,我們不建議這麼做,但在這裡我們這樣做是為了讓代碼更簡潔。
開始事務
接下來,調用 realm 對象的 beginWrite 方法。這句代碼開始了一個事務。我們簡單討論一下什麼是效率。看一下下面的例子,你覺得哪個方法更有效率?
答案是第二個方法。Realm 將數據(就像其他數據庫系統一樣)保存到普通的文本文件。要使用文本文件就意味著 OS 需要打開這個文件,為 App 分配讀寫權限,然後將數據一字節一字節地從 App 寫到文件中。
與一次寫入一個 Realm 對象相反,我們打開文件后一次性寫入了 50 個對象。因為這些對象的數據都是類似的,它們可以一個接一個地成功寫入,這種方法──打開一次文件,寫 50 次,然後關閉文件 ── 是比較快的方法。這就是事務的概念。
最後一點:如果在事務中有一次寫入失敗,則所有的寫入都會失敗。這就類似于銀行和賬戶:如果你在賬本中記入了 50 筆交易,其中一筆出錯了(賬戶餘額不足),你想取消那筆交易。最終你不得不銷燬整個賬本!事務確保“要麼全部成功,要麼全部失敗”,以此來降低了數據損壞的可能。
遍歷地標數據
現在來看看 for-in 循環。在上面的可空綁定語句中,你已經確保 venues 是有效的。用一個 for-in 循環遍歷 venues 數組,在循環中將數組中的元素依次取出放到 venue 變量。
首先是創建一個 Venue 對象 venueObject。這句語句將拋出一個錯誤,因為到目前為止我們還沒有一個叫做 Venue 的類。我們將這個任務留到稍後解決,現在先不管這個錯誤。
然後是幾個可空綁定語句。每個可空綁定都用於訪問 venue 變量的一個鍵值對,并將它們轉換為預期的類型。例如,如果 venue 中有一個鍵名為 id 的鍵值對,則將它的值轉換為 String,如果成功,將它賦給 venueObject 的 id 屬性。
location 的可空綁定看起來要麻煩一些,但其實不然。注意,lat、lng 和 formattedAddress 組成了 location (而不是 venue)。從數據結構來說,它們之間相差了一個層級。
接下來是 for-in 循環的最後一句:realm.add(venueObject,update:true)。這將 venueObject 對象加入到 Realm中,并寫到數據庫(在事務中)。update 參數表明當同一對象存在的情況下,Realm 會用新數據覆蓋舊對象。後面,我們會為每個 Venue 對象指定一個唯一的 ID,以便 Realm 能夠識別哪個對象是已經存在的。
錯誤處理
現在 Realm 將所有的寫入操作放到了事務中,并準備將它們寫到數據庫中去。這個過程中,會出現錯誤。幸運的是,Swift 已經增加了一個可擴展的錯誤處理機制。大致流程如下:
在大部份語言中這被稱作 try-catch 機制,但 swift 的創造者們把它稱作 do-catch 機制(是的,他們還將 do-while 循環改成了 repeat-while 循環…)。在你的代碼中,它看起來是這樣:
do { try realm.commitWrite() print("Committing write...") } catch (let e) { print("Y U NO REALM ? \(e)") }
危險操作 realm.commitWrite() 方法放在了 do 後面的一對大括號 {} 中。同時在語句前增加了一個 try 關鍵字。往前滾動代碼,找到 try!(有一個感嘆號),這里感嘆號的使用會導致錯誤直接被忽略。
如果 do{} 語句塊中有錯誤拋出,catch 塊將被執行。catch 塊有一個參數,即 let e,這個 e 中就包含了具體的錯誤。在這個代碼塊中,我們引入了 e 并打印了錯誤信息。當 App 運行并有錯誤拋出時,打印出來的信息會讓我們知道是什麼導致了錯誤。
你在上面的代碼塊中看到的錯誤處理是非常簡單的。設想類似這樣的固定的錯誤處理系統,你不僅能抓取錯誤,還能使用它們。例如,你向一個文件中寫入數據時,如果磁盤已滿,你可以彈出一個窗口告訴用戶磁盤已滿。如果是老的 Swift 版本,錯誤的處理非常麻煩,稍微搞不好就會讓 App 崩潰。
Swift 的錯誤處理有一定的強制性。錯誤要麼必須被處理,要麼被忽略,總之不能無緣無故地讓它溜走。處理錯誤使你的代碼更健壯,將使用 do-catch 進行錯誤處理形成一種習慣,而不要使用 try! 來忽略錯誤。
好,進入這個方法的最後兩句代碼。首先是第一句:
NSNotificationCenter.defaultCenter().postNotificationName(API.notifications.venuesUpdated, object: nil, userInfo: nil)
這句代碼會發送一個通知,給所有 App 中監聽了該通知的對象。這是 de facto 的通知機制,如果 App 有多個地方需要接收這個通知,這種方法非常有效。設想你剛剛從 Foursquare 收到新的數據。你可能會刷新表格視圖,以顯示新數據,也可能會觸發其它代碼。這時,只消一個通知就可解決所有問題。
注意發送通知的那個線程,即上面代碼所在的線程。如果你在主線程之外即發送通知的那個線程中進行更新 UI 操作,則 App 會崩潰并拋出一個致命的錯誤。
注意到 API.notificatoins.venuesUPdated 這個字符串嗎?這是一個硬編碼的字符串,我們也可以用 “venuesUpdated”替代。但使用硬編碼的編譯時常量可以使你的代碼更安全。如果你代碼寫錯了,編譯器會警告你。如果你把字符串“venuesUpdated”寫錯了,則編譯無論如何都不會告訴你!
在閉包之後,是最後一句代碼:
searchTask.start()
注意這句代碼在 let searchTask … 之後執行,無論它前面的閉包執行與否。這句代碼什麼意思?我們已經創建了一個請求,設置了它所需的參數,這句代碼的作用就是啟動搜索任務。
Das Quadrat 庫向 Foursquare 發送了一條消息,并等待返回,然後調用你編寫的閉包對返回的數據進行處理。非常簡單,不是嗎?
暫時離開這段代碼,因為我們還有一個 Venue 類沒有編寫。
你知道 Realm 最厲害的是什麼嗎?整個代碼結構都非常精幹。要使用 Realm,你只需要一個類文件。你可以用這個類創建一堆的對象,將他們寫到 Realm 文件,然後嘣的一下,你的本地數據庫就實現了。
此外,Realm 還包含了大量有用的特性,諸如排序、篩選,以及使用 Swift 原生數據類型。它非常快,你不需要用 NSFetchedResultsController(Core Data 中的)加載成千上萬的對象到表格視圖。Realm 有它自己的基本的數據瀏覽器。
好了,來看看 Venue 類。你需要:
這會創建一個空的 Swift 文件。這個文件中將包含 Realm 對象的代碼,即 Venue 類的代碼。
導入正確的庫。在 import Foundation 一句下加入:
import RealmSwift import MapKit
繼續在下邊加入:
class Venue: Object { }
這是 Venue 類的簽名。冒號用於表示你將繼承 Object 類。 在面向對象編程中,你可以為類之間創建“父﹣子”關係,即繼承的概念。在上面的代碼中,你繼承了 Object 類,這個類在 Realm 庫中定義。
也就是說,你將父類的所有的屬性和方法複製到了子類中。注意,繼承和創建擴展不同,後者僅僅是用新的功能修飾已有的類(不用創建任何新的類)。
接著,將下列代碼拷貝到這個類。將它放到类的一对大括號之間。
dynamic var id:String = "" dynamic var name:String = "" dynamic var latitude:Float = 0 dynamic var longitude:Float = 0 dynamic var address:String = ""
這是什麼意思?很簡單:為這個類定義了五個屬性。你可以利用這些屬性,將數據賦給這個類的實例,就像我們使用 CoffeeAPI 代碼時所作的一樣。
dynamic 屬性確保 O-C 運行時能夠訪問這些屬性。這又是另外一個話題了,但我們可以想像成 Swift 代碼和 O-C 代碼分別運行在各自的“沙盒”中。在 Swift 2.0 以前,所有的 Swift 代碼都運行在 O-C 運行時中,但現在,Swift 擁有自己的運行時。將一個屬性標明為 dynamic 之後,O-C 運行時就可以訪問這個屬性了,這是必須的,因為 Realm 底層依賴於 O-C 運行時。
每個屬性都有一個類型:String 或者 Float。Realm 支持幾種 Swift 原生類型,比如 NSData,NSDate(精度為秒),Int,Float,String 等等。
然後,在 address 屬性下加入:
var coordinate:CLLocation { return CLLocation(latitude: Double(latitude), longitude: Double(longitude)); }
這是一個計算屬性。它不會保存到 Realm 中,因為計算屬性是不會被保存的。所謂的計算屬性,名副其實,是說這個屬性其實是来自于某個表達式計算的結果。它就像一個方法,但是以屬性的形式來調用。上面的這個計算屬性中,我們將緯度和精度轉換成一個 CLLocation 對象。
經過這樣的轉換后會方便許多,因為我們可以通過 venueObject.coordinate 來訪問正確類型的對象,而不需要再臨時創建一個。
然後在上面的代碼後面加入:
override static func primaryKey() -> String? { return "id"; }
這是個新方法,我們覆蓋了來自於父類 Object 的同名方法。通過這個方法你可以告訴 Realm 用什麼來做主鍵。主鍵的概念類似于唯一標識。在 Realm 數據庫中,每個對象都必須擁有一個唯一的主鍵,就像鎮子里的每棟房屋都必須有一個唯一的門牌號。
Realm 通過主鍵來區分不同的對象,並以此來判斷一個對象是否和另一個對象相同。
這個方法的返回值是 String,因此我們可以返回一個屬性名,并以該屬性來作為主鍵。如果不想使用主鍵,則可以返回一個 nil。
你可以將 Realm 對象的屬性(比如 id、name)想像成表格中的列。primaryKey 的返回值就是這些列中的某一列,這裡就是 id。
最後,按下 Command + B,編譯 App,查看是否一切正常。這裡我們不運行 App,因為我們還沒有修改 UI 代碼。我們編譯只是為了測試我們的代碼是否有錯誤。如果你檢查一下 CoffeeAPI.swift 中的代碼,你會發現 venueObject 旁邊的錯誤提示消失了。
現在,讓我們用下載的數據做一些事情。我們將數據以大頭釘的形式顯示到地圖上。
首先,回到 ViewController.swift 文件。看一下將用戶位置顯示到地圖上的代碼。
然後,在文件頭部,加入 import 語句:
import RealmSwift
在類中聲明幾個屬性(在 distanceSpan 下面):
var lastLocation:CLLocation? var venues:Results?
要讓 RealmSwift 庫能夠使用 Realm,我們需要用這兩個屬性存放座標和地標數據。
接著,找到 locationManager:didUpdateToLocation:fromLocation 方法。然後找到這個方法的右大括號 }。在這下面加入下列代碼。
func refreshVenues(location: CLLocation?, getDataFromFoursquare:Bool = false) { if location != nil { lastLocation = location } if let location = lastLocation { if getDataFromFoursquare == true { CoffeeAPI.sharedInstance.getCoffeeShopsWithLocation(location) } let realm = try! Realm() venues = realm.objects(Venue) for venue in venues! { let annotation = CoffeeAnnotation(title: venue.name, subtitle: venue.address, coordinate: CLLocationCoordinate2D(latitude: Double(venue.latitude), longitude: Double(venue.longitude))) mapView?.addAnnotation(annotation) } } }
哇,這個方法有好多代碼!它們是什麼意思?
先來看檢查兩個座標的 if 語句。第一個 if 語句檢查 location 是否為空,第二個 if 語句檢查 lastLocation 屬性是否為空(用一個可空綁定)。
這兩行代碼非常類似,雖然它們干的是不同的事情。先讓我們暫停一下。思考一下下列描述是否正確:
最後一點非常重要。很顯然,因為我們想讓 refreshVenues 方法在 locationManager:didUpdateToLocation:fromLocation 方法之外也能被調用,因此我們要將座標數據保存到某個地方。
每當 refreshVenues 方法被調用,我們都在 location 參數不為空時將它保存到 lastLocation 參數。然後,我們用可空綁定檢查 lastLocation 參數是否為空。只有不為空 if 語句才會被執行,因此我們能夠保證 if 語句中的代碼 100% 的有一個有效的 GPS 座標可用。
這讓 refreshVenues 方法真正能夠讀取到真正的座標數據。這是毫無疑問的。如果你還不明白,請再次閱讀上一段內容。代碼非常簡單的,這樣的寫法也讓你的 App 在保證數據安全的同時保持解耦。
然後是 refreshVenues 方法的下一行。它又是什麼意思?它通過 CoffeeAPI 的共享實例從 Foursquare 請求數據。
if getDataFromFoursquare == true { CoffeeAPI.sharedInstance.getCoffeeShopsWithLocation(location) }
它只會在 getDataFromFoursquare 參數為 true 時進行請求。讓 CoffeeAPI 請求數據是件簡單的事情。記住,如果我們想在數據抓取完畢的時候獲得消息,我們需要監聽 CoffeeAPI 的通知。這個步驟我們稍後進行。
然後是下面的代碼:
let realm = try! Realm() venues = realm.objects(Venue)
這個代碼我們已經熟悉了,但這就是重要的地方。首先,獲取了一個 Realm 的引用。然後 Realm 讀取所有的 Venue 對象并保存到 venues 屬性。這個屬性的類型為 Result?,類似于一個 Venue 對象數組(會有輕微的不同)。
最後,是一個 for-in 循環,遍歷了 venues 中的所有 Venue 對象,然後以大頭釘形式添加到地圖上。這裡很可能會拋出一個錯誤,我們會解決它。
要創建一個 Annotation 類,你需要:
編輯文件內容為:
import MapKit class CoffeeAnnotation: NSObject, MKAnnotation { let title:String? let subtitle:String? let coordinate: CLLocationCoordinate2D init(title: String?, subtitle:String?, coordinate: CLLocationCoordinate2D) { self.title = title self.subtitle = subtitle self.coordinate = coordinate super.init() } }
代碼很簡單:
回到 ViewController.swift,在看一下 CoffeeAnnotation 旁邊的錯誤提示是否消失了。
下一步,在 ViewController 類中添加如下方法。這個方法的代碼很常見,它確保你加到地圖的大頭釘能夠得到顯示。
func mapView(mapView: MKMapView, viewForAnnotation annotation: MKAnnotation) -> MKAnnotationView? { if annotation.isKindOfClass(MKUserLocation) { return nil } var view = mapView.dequeueReusableAnnotationViewWithIdentifier("annotationIdentifier") if view == nil { view = MKPinAnnotationView(annotation: annotation, reuseIdentifier: "annotationIdentifier") } view?.canShowCallout = true return view }
就像表格視圖一樣,地圖視圖會重用對象以使大頭釘能夠順暢地顯示到地圖上。在上面的代碼中,發生了這些事情:
注意這個方法是協議中定義的方法。前面我們已經將地圖視圖的 delegate 設置為 self。如果地圖視圖設置了 delegate 屬性,則當地圖準備顯示大頭釘的時候,它會調用 mapView:viewForAnnotation: 方法,這個方法就是上面的這段代碼。
委託是一種很好的自定義代碼的方法,它避免了重寫整個類。
讓我們將所有珠子串起來。在 ViewController.swift 的 viewDidLoad 方法中,添加下列語句,就在 super… 的下方:
NSNotificationCenter.defaultCenter().addObserver(self, selector: Selector("onVenuesUpdated:"), name: API.notifications.venuesUpdated, object: nil)
這一句將告訴通知中心當前類(self)將監聽 API.notification.venuesUpdated 通知。當出現這個通知時,請調用 ViewController 的 onVenuesUpdated: 方法。簡單吧?
在 ViewController 類中新加一個方法:
func onVenuesUpdated(notification:NSNotification) { refreshVenues(nil) }
這又是幹什麼意思?
還有一個至關重要的部份。在 locationManager:didUpdateToLocation:fromLocation: 方法的 if 語句內部的最後添加如下代碼:
refreshVenues(newLocation, getDataFromFoursquare: true)
這個方法現在變成了這樣:
if let mapView = self.mapView { let region = MKCoordinateRegionMakeWithDistance(newLocation.coordinate, distanceSpan, distanceSpan) mapView.setRegion(region, animated: true) refreshVenues(newLocation, getDataFromFoursquare: true) }
這段代碼什麼意思?很簡單:它以用戶的 GPS 座標來調用 refreshVenues 方法。另外,它告訴 API 從 Foursquare 抓取數據。也就是說,每當用戶的位置發生變化,它就會從 Foursquare 抓取數據。當然我們設置了每移動 50 米才會觸發這個方法。幸虧有通知中心,地圖才會刷新!
運行 App,檢驗它是否工作正常。怎麼樣?干得不錯吧!
現在地圖已經完成了,如果再在表格視圖中顯示這些數據,整個 App 就完成了。這個實現起來是非常簡單的。
首先,在 ViewController 中增加一個 IBOutlet 屬性,就放在類的頭部,mapView 屬性的下面。
@IBOutlet var tableView:UITableView?
打開 Main.storyboard,然後選擇 View Controller Scene。打開 Connections 面板,找到 tableView 并拖一條線到故事板編輯器的表格視圖上。這就創建了一個出口連接。
在 ViewController.swift 的 viewWillAppear 中添加下列代碼,就像對 self.mapView 所做的一樣,使用一個可空綁定:
if let tableView = self.tableView { tableView.delegate = self tableView.dataSource = self }
為 ViewController 增加兩個協議的聲明:
UITableViewDataSource, UITableViewDelegate
接下來,添加兩個方法:
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return venues?.count ?? 0 } func numberOfSectionsInTableView(tableView: UITableView) -> Int { return 1 }
這兩個方法屬於表格視圖的 delegate 協議。第一個方法用於指定表格視圖中要顯示的 cell 的行數,第二個方法用於指定要在表格視圖中顯示幾個 section。注意,?? 是一個“空合併”操作。意思是說:當 venues 為空的時候,用 0 來作為默認值。
然後,添加這個方法:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { var cell = tableView.dequeueReusableCellWithIdentifier("cellIdentifier"); if cell == nil { cell = UITableViewCell(style: UITableViewCellStyle.Subtitle, reuseIdentifier: "cellIdentifier") } if let venue = venues?[indexPath.row] { cell!.textLabel?.text = venue.name cell!.detailTextLabel?.text = venue.address } return cell! }
這些代碼大部份都是千篇一律的:
跟地圖視圖差不多,tableView:cellForRowAtIndexPath: 方法在表格視圖需要渲染 cell 的時候被調用。你可以利用這個方法對表格視圖的 cell 進行定製化。這比子類化 cell 要簡單!
下一步,是最後一個表視圖相關的方法。在 ViewController 中添加這個方法:
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) { if let venue = venues?[indexPath.row] { let region = MKCoordinateRegionMakeWithDistance(CLLocationCoordinate2D(latitude: Double(venue.latitude), longitude: Double(venue.longitude)), distanceSpan, distanceSpan) mapView?.setRegion(region, animated: true) } }
這個委託方法會在用戶點擊 cell 的時候調用。這段代碼很簡單:如果在 venues 中找到下標與 indexPath.row 對應的 Venue 對象,則用它去設置地圖視圖的 region 屬性。也就是說:將地圖的中心設置到所點擊的地方!
最後只剩下一件事情,根據通知來刷新表格視圖的數據。當通知出現,我們就顯示新的數據。
在 refreshVenues 方法中添加代碼,就在第二個 if 語句之後。找到 if let location = lastLocation 一行,然後找到它的右大括號 }(就在 for-in 循環之後),加入代碼:
tableView?.reloadData()
好了,現在來看看 App 是否運行正常。用 Command + R 運行 App,查看運行結果。如果一切順利,表格視圖中將出現地標數據。
現在有一件奇怪的事情。表格視圖顯示了所有的數據!如果你先到日本,然後又來到舊金山,那麼在表格視圖中仍然會顯示日本的咖啡屋。
你當然不想這樣。因此,讓我們來施展 Realm 大法,獲得正確的數據。
首先,修改 ViewController 的 venues 屬性,將 Results? 修改為這樣:
var venues:[Venue]?
這又有何不同?僅僅是類型不同而已。之前是用一個包含了 Venue 對象的 Results 對象,這是屬於 Realm 的類型。後面則變成了 Venue 數組類型。
最大的不同是延遲加載。Realm 加載數據的效率非常高,它只會加載要用到的數據,也就是在代碼中被訪問的數據。不幸的是,Realm 不支持我們想要的一個特性(對計算屬性排序)。因此,我們只能從 Realm 加載所有數據,然後自己來做過濾。正常情況下我們都是讓 Realm 為我們負責數據的讀取(通過延遲加載)和簡單的過濾。但現在不行了。
還記得這兩行嗎?
let realm = try! Realm() venues = realm.objects(Venue)
用下面的代碼替換它們:
let (start, stop) = calculateCoordinatesWithRegion(location) let predicate = NSPredicate(format: "latitude