在本教學中,你將學會使用到如下技能:
- Swift、Xcode 和 Interface Builder (自動佈局、約束和storyboard)
- Realm,一個輕量級的 Core Data 封裝,用於本地數據庫。
- Foursquare,用 「Das Quadrat」 庫來訪問 Foursquare 的 REST API。
- CocoaPods 和 Geolocation
這個 App 會以你當前位置為中心,從 Foursquare 抓取以此為中心 500*500 米範圍內的地標信息。然後用一個地圖視圖(MKMapView)和表格視圖(UITableView)來顯示這些數據。並使用 Realm 來篩選數據,並在閉包中對數據進行排序。
你可以從 GitHub 上下載完整的的源程式碼和 Xcode 項目:reinderdevries/CoffeeGuide
如此簡單?讓我們快快進入正題吧!
設置 Xcode 項目
首先,我們需要在 Xcode 進行一些設置。打開 Xcode,選擇 File\New\Project…
在 iOS\Application 類別下選擇 Single View Application。然後,填写這些内容:
- Product Name: Coffee
- Organization Name: 任意
- Organization Identifier: 任意,用反域名格式:com.mycompanyname
- Language: Swift
- Devices: iPhone
- 反選 Use Core Data、Include Unit Tests 以及 Incluse UI Tests
然後選擇項目存放路徑,「創建本地庫」(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 的終端窗口,將目錄切換到你的項目目錄。如果你不知道怎麼做,請遵循如下步驟:
- 打開終端程式
- 輸入 cd (c、d、空格)
- 打開 Finder
- 在 Finder 中找到你的項目目錄,但不要進到目錄裡面
- 從 Finder 窗口中將項目目錄拖拽到終端窗口
- 項目的絕對路徑將自動填寫到 cd 命令之後
- 敲回車
- 這樣你就將當前工作目錄切換到項目目錄下了
然後在終端窗口中輸入:
pod install
命令的執行大約要個幾分鐘時間,同時屏幕上將有大量信息滾動顯示。Cocoapods 將為你的項目安裝所需的庫。同時將你的項目轉換為工作空間(由多個項目組成)。
然後,在 Finder 中找到新生成的 .xcworkspace 文件並打開它。這個文件就位於你的項目的根目錄下。
注意:當你在 Xcode 中打開這個工作空間時,你的項目很可能是處於折叠狀態。你可以將項目文件恢復到原來的打開狀態 ── 關閉工作空間,然後再打開工作空間。這會導致項目文件不再是折叠狀態。
這就是你需要為本 App 的 Xcode 項目所進行的所有設置。如果一切順利,你應該擁有了一個包含了兩個項目的工作空間。其中 Pods 項目包含了 Realm 和 Das Quadrat 庫的程式碼。
用故事板創建 UI
Coffee 的 UI 非常簡單。它只有兩個 UI 元素:一個地圖視圖和一個表格視圖。
Xcode 已經為我們做了大量的工作。Single View Application 模板包含了一個故事板文件 Main.storyboard,它是 App 的入口。
要創建地圖視圖,需要:
- 打開 Main.storyboard
- 在 Xcode 右下角的 Object Library 窗口中,找到 Map Kit View (MKMapKitView)
- 將它拖到故事板編輯器中,並放到 View Controller 的左上角。讓它的高度大致等於 View Controller 的一半,寬度則完全佔滿。
- 在 Object Library 窗口中找到 Table View (UITableView) 並將它拖到故事板編輯器的 View Controller 上。讓它的寬度完全佔滿,高度則佔據 View Controller 的下半部。
接著為這兩個 View 設置自動佈局約束。首先,選中地圖視圖,點擊故事板編輯器右下角的 Pin 按鈕,這個按鈕位於右邊第二個位置,形如星戰中鈦式戰機 …
然後會彈出一個小窗口,接下來你需要做:
- 反選「Constrain to margins」。
- 點擊左、上、右三個 I 形線,讓它們依次變成紅色。
- 每個 I 形線旁邊都有一個輸入框,將它們分別設置為 0。
- 點擊「Add 3 constraints」
接下來,在表格視圖上重複同樣的動作。但是將上面的 I 形線替換成下面的 I 形線(同時還有左和右)。同樣地,反選「Constrain to margins」選項,然後點擊「Add 3 constraints」按鈕。
我們讓兩個 View 分別相對於上對齊和下對齊,寬度都是父 View 的百分之百。還有一件事情,就是讓兩個 View 的垂直高度都等於整個屏幕高度的 50%。
我們可以用多個約束來實現這點,但這是最簡單的:
- 選中地圖視圖和表格視圖(按住 Command 鍵,然後分別點擊這兩個 View)。
- 點擊 Pin 按鈕。
- 勾選 Equal Heights 選項。
- 點擊「Add 1 constraint」。
現在 Xcode 可能會報錯說「有佈局衝突」。別擔心,我們會來修復它。
- 點擊地圖視圖,然後點擊 Pin 按鈕。
- 反選 「Constrain to margins」 選項,然後點擊下面的 I 形線,將它的值修改為 0。
- 點擊「Add 1 constraint」。
現在紅線消失了,IB 又開始顯示黃線。這表示有部份約束當前顯示不正確。所有的約束都是對的,僅僅是 IB 在顯示上不正確。
要解決這個問題,點擊黃色的帶圈的箭頭,這個圖標位於故事板編輯器 Document Outline 窗口的右上角。
點擊這個圖標后,會顯示一個新的界面。點擊黃色的三角,然後點 Update frames,再點擊 Fix misplacement。在每個黃色的三角上重複同樣步驟。當然,Update frames 的辦法並不是每次都有效,因此確保你的約束都創建正確,再讓你的 frame 也正確。
不幸的是,佈局約束經常會出現大量錯誤。如果你搞錯了某些事情,你可以從 Document Outline 窗口中將約束刪除,然後重建。
編譯 App 並修復錯誤
讓我們來運行一下 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% 的錯誤信息不會告訴你怎麼解決問題。它只會告訴你發生了什麼,但問題的原因卻無法看到。
現在怎麼辦?你需要干兩件事情:
- 回家洗洗睡吧,再也不做 App 了
- 複製錯誤信息,然後貼到 Google 進行搜索 (建議你這樣做)
好吧,拷貝這段錯誤信息,然後在 Google 上搜索。這下你可能會搜索出這樣的結果:
點擊最上面的超鏈接。它會帶你到 StackOverflow,這是一個關於編程的 Q&A 網站。上面會有這個星球上幾乎所有語言的問題,以及這些問題的答案。
這就是你要做的,在 StackOverflow 上瀏覽答案:
- 查看這個問題是否有一個以上的回答。如果一個都沒有,繼續檢查另一個問題(通過 Google)。如果你找到了一個答案,能夠回答還先前沒有答案的問題時,你可以將這個答案順便添加到進去。
- 掃一眼最上面的原始的問題。看看標題,瀏覽一下內容以及下面的評論。這些評論經常會包含一些額外的信息。
- 挑出已經認可的答案,這些答案會用綠色的勾子進行標記。它的評論還是要看的,有時候評論比答案還有用。很多時候,已經認可的答案並不是最好的答案。檢查左邊的上下箭頭之間的數字,這個數字表示投贊成票的數字(即投「這是一個好答案」票)。已認可的答案不一定是最有用的,因此繼續深挖網頁上的其它內容。
- 對於這些答案,建議不要盲從,理解它的原理。學習編碼是一件很費時間的事情,但它對你的未來會有無窮的好處。幾乎每個程序員都會有知識上的盲區或者技術上的缺陷。如果你學會如何避免在未來犯錯誤,以及為什麼犯錯誤,你就會和全世界最頂尖的 1% 的程序員站在一起。
好了,到底是什麼原因出現的問題?答案是 MapKit 框架沒有添加到項目中。顯然,MKMapView 的程式碼是放在某個外部框架中了。這些程式碼必須被加到項目中來,雖然我們沒有直接在程式碼中使用過地圖視圖。
如果你看完了網頁,你會發現还有大量的反面例子,也會引發這個錯誤。
好了,該我們來解決這個問題了:
- 回到 Xcode,在项目导航窗口中点击項目屬性。項目屬性位於項目導航窗口的頂部。
- 點擊 Build Phases 標籤欄.
- 然後,點擊 Link Binary With Libraries 左邊的箭頭。這會顯示一個列表。
- 點擊列表底部的 + 號按鈕,會彈出一個窗口。
- 在文本框中輸入: mapkit,對列表進行篩選。
- 最後,雙擊 MapKit.framework。
這會將這個框架添加到 Link Binary With Libraries 列表和項目中。
使用用戶的地理座標
你接下來的任務是 Geolocation,即將用戶的位置顯示到地圖上。
首先,需要將故事板中的地圖視圖連接到程式碼中。在我們新建項目時,會創建一個文件 ViewController.swift,這就是故事板中的 View Controller 放置程式碼的地方。
接下來檢查一下故事板和程式碼之間的這種連接是否存在:
- 打開 ViewController.swift 並查找這行程式碼:class …,也就是類的定義。這行程式碼指定了類的名字,它的父類以及它所實現的協議。在這裡,類名顯示為 ViewController。
- 然後打開 Main.storyboard 並點擊 Document Outline 窗口最上面的項目。這個項目就是「View Controller Scene」。
- 在右上角,點擊 Identity 檢查器。即從左到右第三個圖標。
- 最後,找到 Class 欄,查看它的內容
這表明故事板和 ViewController 的程式碼是有聯繫的,幸虧我們有 class 關鍵字可用。如果你向故事板中加入另一個 View Controller,你可以為它指定另一個類名。
為地圖視圖創建一個出口
現在我們知道 ViewController 和程式碼其實是連接在一起,我們需要創建一個出口連接到地圖視圖。在你用自己的程式碼引用這個地圖視圖之前,需要為地圖視圖的創建一個連接。
打開 ViewController.swift 在第一行的 { 括號之後加入以下程式碼:
@IBOutlet var mapView:MKMapView?
這是什麼意思?
- 在 Swift 中,變數在使用之前需要事先聲明。在聲明變數的同時,你可以初始化它。上面的這句程式碼,變數並沒有初始化,因此它的值為空(
nil
)。 - 剛剛這行程式碼也創建了一個實例屬性。這個屬性是和 ViewController 的每個實例綁定的,並且對每個實例來說,這個屬性都是單獨的。
- 這個屬性的名字叫做 mapView,類型是 MKMapView。這個類來自於 MapKit 庫。
- 關鍵字 @IBOutlet 用於告訴 Xcode,我們想把這個屬性當成一個出口。出口的概念其實是一個連接,用於表示程式碼和故事板(或者 xib)中的UI對象之間的連接。
- var 關鍵字表明這是一個變數,它的值可以被修改。與之相反的是
let
關鍵字,表示一個常量,值無法改變。 - 類型名后面的符號 ? 表示這個變數是可空的。可空是 Swift 中特有的概念。它表示變數除了存放非空的值,也可以存儲空值。nil 表示空,什麼都沒有。可空在 Swift 中出現,目的是為了讓程式碼更安全和更易於使用。 關於可空,更多內容見後。
- 為什麼要將程式碼寫在這一行?這行程式碼寫在了類的頂部。即類聲明的內部,這表明變數在整個類中都是可用的。如果放在方法的內部,則表示變數是一個局部變數,只能在聲明變數的方法內使用這個變數。如果放在類的外部聲明,則表明變數是全局的,在(幾乎)任何地方都能使用這個變數。
是否分不清變數和屬性的概念?一個變數是一個簡單的容器,用於存放數據。屬性也是一種變數,但它附屬於某個類。有兩種屬性:實例屬性和類屬性。
是否分不清類、實例和類型?可以把類看成是一個「死的」(就像是壓鑄出來的)模板,用於創建一堆複製品。這些複製品就是實例。而類型的概念有點模糊,但你可以簡單地把它看成和類的概念差不多。
是否分不清聲明、初始化和實例化?好吧,首先來說聲明:聲明僅僅是告訴編譯器,我要使用某個變數,它的名字是什麼,它的類型是什麼。初始化的意思則是我要給這個變數一個初始值。也就是在聲明一個變數的同時,將一個值賦給這個變數。如果你不初始化變數,則變數的值就是 nil。實例化的意思則是你將一個實例(類的一個複製品)賦給這個變數。專業點講,一個變數就是一個實例化對象的引用。
回到項目。在添加上面的程式碼后,Xcode 會拋出另一個錯誤。這個錯誤說的是:
MKMapView 類型未聲明
這是因為你還沒有將 MapKit 引入到當前文件!因此,在類定義之上添加一個 import 語句。在 import UIKit 之後加入:
import MapKit
現在,我們再來創建 outlet 連接。
- 首先, 打開 Main.storyboard。
- 然後, 在 Document Outline 窗口中選中 View Controller Scene 。
- 打開 Connections 檢查器。
- 檢查在列表中是否包含了 mapView 屬性。
- 最後,將 mapView 右邊的小圓圈拖到故事板編輯器中的地圖視圖上。
編寫第一個方法
好了,讓我們來編寫使用地圖視圖程式碼。首先,在 ViewController 類中加入:
override func viewWillAppear(animated: Bool) { super.viewWillAppear(animated) if let mapView = self.mapView { mapView.delegate = self } }
寫在什麼地方?任何地方!只要在類聲明的一對大括號 {} 之內。
所有方法都必須寫在類的範圍內。一個類的範圍從第一個左大括號 { 開始,到最後一個右大括號 } 結束。
這必須是平衡的,即每個左大括號 { 都必須配有一個對應的右大括號 }。而且,程序員需要使用縮進來表示範圍的級別。通常,每當一個左括號 { 之後就要使用一個 Tab 符或者四個空格表示一個縮進(在右括號之後則進行反縮進)。
我們來看看這個方法:
- 一個方法是一個程式碼塊,它被類所擁有。它通常由多個單獨的程式碼行構成,每個程式碼行負責完成一個指定的動作。方法既可以在類的內部被調用,也可以在 App 的任何程式碼中調用。
- 方法的名字叫做 viewWillAppear,它帶有一個參數。一個參數是一個變數,用於在調用方法時傳遞數據給這個方法。參數能夠在方法的範圍內即這個方法的任何地方使用。在上面的方法中,這個參數 animated 是一個 Bool 類型(布爾類型,yes 或 no),它會在調用父類的 viewWillAppear 方法時用到。
- 所有的方法用關鍵字 func 開頭,func 是 “function” 的縮寫。在這裡,方法應當被覆蓋,因此使用了 overriden 關鍵字修飾。overriden 表示用自己寫的方法替換父類的同名方法。父類和覆蓋的概念屬於面向對象編程中的範疇。這裡我們不展開討論,因為這實在是太枯燥乏味了。
- 在接下來的程式碼中,我們首先將 self.mapView 「可空綁定」到一個常量 mapView。「可空綁定」可以讓你檢查某個可空對象是否為 nil,如果不是,if 語句才會被執行。同時,mapView 變數只會在 if 語句的範圍內有效。
- 在 if 語句中,mapView 的 delegate 屬性被設置為 self。也就是說,如果 self.mapView 不為空,mapView 的 delegate 就會被設置為 self。簡而言之,如果 mapView 不為空,這個類將充當 mapView 的委託。關於委託,後面再論。
現在 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() } }
呃,這些程式碼是什麼意思?
- 首先,我們用 if 語句判斷 locationManager 是否為空。
- 然後實例化一個 CLLocationManager 對象並賦給 locationManager。換句話說:locationManager 現在保存了一個新的 CLLocationManager 對象的引用。我們只是創建了一個 CLLocationManager 對象,這個對象可以用於獲取用戶的位置。
- 然後,我們設置了 locationManager 的幾個屬性。delegate 屬性設為當前類,然後設置了 GPS 精度並調用 requestAlwaysAuthorization() 方法。這個方法會顯示一個窗口,用於向用戶獲得訪問 GPS 位置信息的權限。
- 最後,調用 startUpdatingLocation 方法。這會導致 locationManager 去獲取 GPS 位置,並調用委託對象的方法來通知 GPS 位置所發生的變化。這樣,在委託方法中,我們就可以拿到用戶的位置數據了!
有沒有注意到在 locationManager 後面的感嘆號?我們知道 locationManager 是一個可空,它的值有可能是一個 nil。當我們想用這個對象的時候,我們需要對它進行解包操作。這是一個基本準則。解包有兩種方式:
- 可空綁定. 也就是 if let definitiveValue = optionalValue { ….這樣的形式
- 強制解包. 也就是 optionalValue! 這樣的形式
之前我們見到過可空綁定。它是用一個 if let 語句在可空對象不為空的時候將它的值賦給一個新的變數。
強制解包是一種更粗暴的方法。簡單地在變數名后加一個感嘆號,即可將變數從一個可空對象變成非可空對象。但是,在你將一個可空對象強制解包時,如果該對象為空,則 App 會崩潰。
當一個可空對象為 nil 時,你不能對它強制解包。在上面的程式碼里,強制解包是可行的。為什麼?因為我們在強制解包之前,locationManager 已經被明確地賦值了。也就是說,這時我們已經能夠肯定 locationManager 對象不可能為空。
回到程式碼里來。我們添加了一個新方法,然後 Xcode 報了一個錯…。讓我們來搞定它!
這個錯誤說,我們想讓 self 成為 locationManager 的委託,但我們沒有實現正確的協議。修改類的聲明,讓它遵循這個協議:
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) } }
這些程式碼是什麼意思?
- 首先,這個方法的簽名是
locationManager:didUpdateToLocation:fromLocation
。這個方法使用了命名參數,也就是說參數的名字(在方法內部的變數)與調用時的參數名字不同。長話短說,這個方法有三個參數:調用這個方法的 location manager 對象,新的 GPS 座標,以及老的 GPS 座標。 - 在這個方法中,首先將 self.mapView 用可空綁定進行解包。如果 self.mapView 不為空,則將解包后的值賦給 mapView,然後執行 if 語句。
- 在 if 語句中,根據新的 GPS 座標和前面指定的跨度劃定一個範圍。 具體的說,我們創建了一個以新座標為中心的 500*500 大小的矩形範圍。
- 最終,調用 mapView 的
setRegion
方法。animated 參數設置為 true,讓區域的改變呈現為動畫式的。也就是說,地圖會進行平移和縮放,並顯示用戶位置周邊 500*500 方塊內的地圖。
還有一件事情。為了訪問用戶的位置,我們需要在 Xcode 中設置一個特殊的選項。這個選項是一串文本,用於描述你為什麼要使用用戶的位置。當向用戶獲取授權的時候(也就是我們調用 requestAlwaysAuthorization() 方法的時候),iPhone 會顯示這串文本。
要設置這串文本,你需要:
- 在項目導航窗口中,打開 Info.plist 文件。
- 在列表窗口中點擊右鍵,然後選擇 Add Row。
- key 輸入 NSLocationAlwaysUsageDescription。
- type 選擇 String。
- value 則輸入你在獲取權限時要顯示的文本!
運行 App
現在我們來運行一下 App。確認你的 target 中選擇了一個 iPhone 模擬器,然後按下 Command + R。App 將啟動,並彈出一個需要授權的窗口,點擊「允許」。
當你點擊「允許」后,地圖視圖並不會刷新。因為模擬器沒有 GPS,我們需要模擬一個。
當 App 在模擬器中運行時,請使用以下倆個方式之一來模擬 GPS 位置數據:
- 在 iPhone Simulator 窗口,點擊菜單: Debug -> Location -> Apple。
- 在 Xcode 窗口,點擊菜單: Debug -> Simulate Location,然後從列表中選擇一個位置。
當你選擇了一個位置后,地圖視圖將刷新並將指定的位置放大顯示。
看到了嗎?干得漂亮!
從 Foursquare 獲取地標數據
接下來 App 會變得更有趣!你將使用 Das Quadrat 庫從 Foursquare 獲取數據,然後用 Realm 將數據保存到本地。
在使用 Foursquare API 之前,你需要在它的開發者網站上註冊 App。這個步驟其實很簡單。
- 首先,你需要有一個 Foursquare賬號,你可以在 foursquare.com 註冊一個。
- 接著,跳到 developer.foursquare.com ,點擊上方的藍色菜單條中的 My Apps。
- 然後,點擊右邊綠色的 Create a new app 按鈕。
- 然後,填寫如下內容:
- App Name: Coffee
- Download / Welcome page URL: http://example.com
- 最後,點擊 Save Changes。
保存完成后,網頁將跳轉到 App 頁面。注意 Client ID 和 Client Secret,我們將在後面用到它們。
創建 Foursquare API Connector
現在,我們要編寫連接 Foursquare 的程式碼了。你將用單例模式來實現。單例模式適合于我們現在的場景。
一個單例是一個類實例,它是類僅有的實例。使用單例時,我們不能為一個類創建兩個實例。為什麼我們要用單例模式?對於單例的使用,一直有很大的爭議,但它對於這種情況是非常適用的:避免對一個外部資源出現多個併發的連接。
你可以想一下。如果你向一個 web 服務器發送兩個請求,同時這倆個請求都是向一個文件進行寫操作?數據將變得混亂不堪,除非 web 服務器讓其中一個請求比另一個請求優先執行。
一個單例保證只從 App 的一個地方請求外部資源。在單例模式下,大量的實現是為了保證不出現資源衝突。請求隊列和序列就是其中的一種實現,但這卻不在本教程的討論範圍。
你需要這樣做:
- 在項目導航窗口中右鍵點擊 Coffee 文件夾。
- 選擇 New File …。
- 選擇 iOS -> Source 下面的 Swift File 模板,點擊 Next。
- 將文件命名為 CoffeeAPI.swift,選中 Target 列表中的 Coffee,點擊 Create,保存文件。
哇,創建了一個空文件!讓我們來編輯它。在文件開頭的第一個 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? }
這段程式碼負責完成這些事情:
- 告訴 Xcode 編譯器,我們定義了一個類 CoffeeAPI,它是一個純粹的 Swift 類,因此不需要子類化 NSObject。
- 聲明一個靜態的類常量,叫做 sharedInstance,類型指定為 CoffeeAPI。這個「共享的實例」只能通過 CoffeeAPI 類來訪問,當 App 一啟動這個實例就會被實例化(預先加載)。
- 聲明一個類屬性 session, 類型為 Session? (來自 Das Quadrat 庫).
然後,我們將通過 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 獲取數據吧。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() } }
這段程式碼好多!在這個方法中主要完成了五個任務:
- 首先,向 API 配置和發起了一個請求。
- 請求完成塊 (即閉包)。
- 解析請求返回的數據,並開始 Realm 事務。
- 用 for 循環遍歷所有「地標」數據。
- 在完成塊的最後,發送一個通知。
讓我們來逐行分析上述程式碼:
構建請求
首先,用一個可空綁定判斷 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:
- 檢查 result.response 是否為空的,如果它不為空,將它解包並付給 response 常量(同時執行 if 語句)。
- 檢查 response[“venues”] 是否為空,同時它是否能夠轉換為 [[String: AnyObject]] 類型。
這種方式能夠確保數據類型是我們期望的。如果转换失败,或者可空绑定失败,if 语句就不會被執行。這就像是一塊石頭上站了兩隻鳥:判斷值不為空,同時嘗試將值轉換為預期的類型。
你現在知道 venues 是什麼類型麼?它是一個字典的數組,字典的類型則是 String:AnyObject 鍵值對。
自動釋放內存
然後,是一件很有趣的事情:我們創建了一個自動釋放池。當然,這是一個很大題目。你了解 iPhone 的內存管理機制嗎?
基本上,當內存中的對象不再被任何對象引用時,它會被刪除。類似垃圾回收機制,但又不完全相同。當自動釋放池中的對象被釋放時,它被交給自動釋放池處理。當自動釋放池被釋放時,池里的所有內存才被釋放。它就像是批量的內存釋放。
為什麼要這樣做?自己創建自動釋放池,有助於提高 iPhone 系統的內存管理效率。因為我們需要在自動釋放池中處理數百個 venue 對象,如果不清理內存的話,這會導致內存緊張。對於一般的自動釋放池來說,釋放內存的最早時機是方法結束的時候。因此,這就有可能導致內存耗盡,因為自動回收機制無法及時、迅速地回收內存。通過創建自己的自動釋放池,我們可以干預內存的回收,避免內存空間不足。
使用 Realm
然後是let realm = try! Realm()
一句,這實例化了一個 Realm 對象。在使用 Realm 的數據之前我們需要一個 Realm 對象。 try! 關鍵字是 Swift 的異常處理機制。通過它,我們表明:我們不處理來自於 Realm 的錯誤。在真實項目中,我們不建議這麼做,但在這裡我們這樣做是為了讓程式碼更簡潔。
開始事務
接下來,調用 realm 對象的 beginWrite 方法。這句程式碼開始了一個事務。我們簡單討論一下什麼是效率。看一下下面的例子,你覺得哪個方法更有效率?
- 創建一個文件指針,打開文件,寫 1 個字節到文件,關閉文件,然後重複這個動作 50 遍。
- 創建一個文件指針,打開文件,寫 50 個字節到文件,關閉文件。
答案是第二個方法。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 類沒有編寫。
編寫 Venue 類
你知道 Realm 最厲害的是什麼嗎?整個程式碼結構都非常精幹。要使用 Realm,你只需要一個類文件。你可以用這個類創建一堆的對象,將他們寫到 Realm 文件,然後嘣的一下,你的本地數據庫就實現了。
此外,Realm 還包含了大量有用的特性,諸如排序、篩選,以及使用 Swift 原生數據類型。它非常快,你不需要用 NSFetchedResultsController(Core Data 中的)加載成千上萬的對象到表格視圖。Realm 有它自己的基本的數據瀏覽器。
好了,來看看 Venue 類。你需要:
- 在項目導航窗口中,右鍵點擊 Coffee 文件夾。
- 選擇 New File … 然後從iOS -> Source 下選擇 Swift File,然後點擊 Next。
- 文件命名為 Venue.swift ,在 target 列表中選中 Coffee。
- 點擊 Create。
這會創建一個空的 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 屬性是否為空(用一個可空綁定)。
這兩行程式碼非常類似,雖然它們干的是不同的事情。先讓我們暫停一下。思考一下下列描述是否正確:
- App 中的所有座標必須都來自于 locationManager:didUpdateToLocation:fromLocation 方法。只有這個方法才會向 App 傳入 CLLocation 對象,而這個對象的數據來自於 GPS 硬件。
- refreshVenues 方法使用一個 location 參數,這個參數是可空的。
- refreshVenues 方法可以在 location 為空的時候調用,也就是說,會在 locationManager:didUpdateToLocation:fromLocation 方法之外的程式碼中調用。
最後一點非常重要。很顯然,因為我們想讓 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 類
要創建一個 Annotation 類,你需要:
- 在 Coffee 文件夾上點擊右鍵,選擇 New File ….
- 在 iOS -> Source 下面選擇 Swift File,點擊 Next。
- 文件命名為 CoffeeAnnotation.swift 然後點擊 Create。
編輯文件內容為:
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() } }
程式碼很簡單:
- 我們創建了一個名為 CoffeeAnnotation 的類,繼承自 NSObject 並實現了 MKAnnotation 協議。後者很主要:如果你需要讓一個類作為大頭釘顯示,你必須讓它遵循 MKAnnotation 協議。
- 然後,聲明了幾個屬性。這些屬性都是必須的,這是協議中規定的。
- 最後是一個構造函數,用方法參數對屬性進行了賦值。
回到 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 }
就像表格視圖一樣,地圖視圖會重用對象以使大頭釘能夠順暢地顯示到地圖上。在上面的程式碼中,發生了這些事情:
- 首先,檢查大頭釘不是用戶的標誌。
- 從緩存中取出一個現成的大頭釘。
- 如果取出的是一個空對象,則創建一個新的對象。
- 設置大頭釘的是否顯示標註屬性(一個帶小框的信息)。
- 最後,返回 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) }
這又是幹什麼意思?
- 當從 Foursquare 收到位置數據后,調用 refreshVenues 方法。
- 調用時沒有提供座標數據,也沒有提供 getDataFromFoursquare 參數,這個參數默認為 false,因此不需要從 Foursquare 請求數據。如果不這樣的話,會導致一個無限循環,因為當數據返回后又會創建一個 Foursquare 請求。
- 這樣,當 Foursquare 數據返回,地圖上就會畫出大頭釘。
還有一個至關重要的部份。在 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! }
這些程式碼大部份都是千篇一律的:
- 試圖從緩存中重用(獲取)一個 cell。
- 如果重用不成功,則創建一個新 cell,風格為 Subtitle。
- 如果 venues 中能夠索引到 indexPath.row 的對象,則用這個對象來渲染 cell 的 textLabel 和 detailTextLabel。
- 返回 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 < %f AND latitude > %f AND longitude > %f AND longitude < %f", start.latitude, stop.latitude, start.longitude, stop.longitude) let realm = try! Realm() venues = realm.objects(Venue).filter(predicate).sort { location.distanceFromLocation($0.coordinate) < location.distanceFromLocation($1.coordinate) }
在繼續後面的步驟之前,在 ViewController 中添加一個方法。
func calculateCoordinatesWithRegion(location:CLLocation) -> (CLLocationCoordinate2D, CLLocationCoordinate2D) { let region = MKCoordinateRegionMakeWithDistance(location.coordinate, distanceSpan, distanceSpan) var start:CLLocationCoordinate2D = CLLocationCoordinate2D() var stop:CLLocationCoordinate2D = CLLocationCoordinate2D() start.latitude = region.center.latitude + (region.span.latitudeDelta / 2.0) start.longitude = region.center.longitude - (region.span.longitudeDelta / 2.0) stop.latitude = region.center.latitude - (region.span.latitudeDelta / 2.0) stop.longitude = region.center.longitude + (region.span.longitudeDelta / 2.0) return (start, stop) }
這個方法的程式碼没什麼稀奇的地方。通過簡單的計算,基於 distanceSpan,將一個 CLLocation 對象轉換成一個左上角和右下角座標。
第一行,用 location 參數和 distanceSpan 創建了一個區域。然後創建兩個座標,設置它們的經緯度。經緯度通過中心点坐标加上垂直水平方向上的偏移量來計算。最終,方法返回了一個元組:將兩個變數以先後次序封裝到一起。
元組是有序的多個變數組成的序列。它以小括號包裹,能夠「解包」到命名變數中。它是一種以固定順序排列的不可變數組。
回到過濾程式碼中。讓我們逐行討論。
- 首先,我們創建了兩個常量:start、stop。用它們來保存 calculateCoordinatesWithRegion 方法的調用結果。這個方法返回一個元組,用 let (start,stop) 方法將元組解包到兩個單獨的局部變數。calculateCoordinatesWithRegion 方法需要一個參數:即 App 用戶的座標。
- 然後創建了一個謂詞。謂詞是一種動詞,用於過濾數組、序列等對象。這裡的這個謂詞定義了一個簡單的區域,所有的 venue 都必須位於這個區域內。我們用這個謂詞來過濾 Realm 中的數據(在下一行)。注意,這個謂詞假設 GPS 座標是平面的,但地球顯然是球面的。這裡暫時不會有什麼問題,但如果你要搜索南北極附近的咖啡屋時,就不行了。
- 接著,讓我們來仔細看一下抓取 Realm 對象的每個步驟。所有方法都被用「鏈式調用」的寫法串在了一起,因此一個方法的調用會基於上一個方法的調用結果進行。
- 首先是 realm:這個對象引用了一個 Realm 對象。
- 然後是所有的 Venue 對象以延遲加載的方式加載:objects(Venue)。
- 然後用 filter(predicate) 過濾對象。Realm 以極其高效的方式進行過濾,它不會粗暴地直接對所有對象進行過濾,它只在對象被訪問到的時候才進行過濾。
- 然後調用 Swift 函數 sort。這個方法不屬於 Realm,Realm 的排序方法叫做 sorted。也就是說:這裡沒有使用到 Realm 的方法。sort 方法會訪問所有的 Realm 對象,這意味著所有的對象都會被加載到內存,你無法使用 Realm 的延遲加載特性。sort 方法只有一個參數:一個閉包,用於對兩個未經排序的對象進行排序。閉包返回 true 或 false,用於表明兩個對象中哪個在前。在我們的程式碼中,我們將根據距離用戶位置的遠近來進行排序。這裡用到了 Venue 對象的計算屬性 coordinate。$0 和 $1 是兩個未排序的對象。也就是說,sorts 方法通過 venue 對象距離用戶座標的遠近來進行排序(越近的對象越在前面)。
就是這樣!程式碼非常緊湊和高效。向 Realm 的優化特性、方法鏈致敬,通過 Swift 內置的 sort 方法,我們將一大堆 venue 對象縮減為少量的附近的 venue 列表。然後最酷的一件事情是:在你的 GPS 位置改變的同時,這些數據也會隨之改變!
好了,按下 Command + R 鍵,試一試你的 App 吧。運行起來了嗎?非常好!
你覺得這篇教學怎樣?請在下面寫下你的評論和想法。
原文:Building a Coffee Shop App with Swift, Foursquare API and Realm