如何使用 Swift、Foursquare API 及 Realm 構建一款 Coffee Shop 應用
我們經常說,程序員喝進去的是咖啡,吐出來的是代碼。讓我們換一個角度去思考,來做一個顯示附近咖啡店的 App。
這篇文章中,用到了以下技能:
- Swift,Xcode 和 Interface Builder(Auto Layout, Constraints 和 Storyboards)
- Realm,一種本地存儲方案,輕量級的 Core Data
- 使用 Foursquare 和 Das Quadrat 庫訪問 REST API
- CocoaPods 和 Geolocation
這個 App 可以檢測當前用戶的 500 平方米的范圍,并從 Foursquare 拿到附近咖啡店的地理信息。我們將使用 map view( MKMapView )和一個 table view( UITableView )來展示數據。當然,還要使用 Realm 來過濾數據,并使用閉包來對數據進行排序。
你可以從 GitHub reinderdevries/CoffeeGuide 上下載所有源代碼和 Xcode 項目。
讓我們開始碼代碼吧!
設置 Xcode
第一步,創建工程。打開 Xcode,選擇 File -> New -> Project…
在分類中,選擇 iOS -> Application -> Single View Application,然后填寫一下信息:
- Product Name:Coffee
- Organisation Name:隨便寫一個
- Organisation Identifier:也隨便寫一個,使用的格式如:com.mycompanyname
- Language:Swift(當然是 Swift 了)
- Devices:iPhone
- 取消 Core Data,勾選 Unit Tests 和 UI Tests
選擇工程的存儲路徑,不用勾選 create a local Git repository。
接著創建 Podfile。在項目名稱上(工程目錄選項卡)點擊右鍵,選擇 New File … 如下圖所示,選擇 iOS -> Other -> Empty。
文件命名為 Podfile(不要文件擴展名)并 確保 它和 .xcodeproj 文件在同一級目錄下!還要勾選 Target 欄里的 Coffee 選框。
然后復制下面的代碼到 Podfile里:(譯者注:以下是原文的代碼,但是有個地方錯了: useframeworks! 要改為 use_frameworks! )
source 'https://github.com/CocoaPods/Specs.git' platform :ios, '8.0' useframeworks! pod 'QuadratTouch', '>= 1.0' pod 'RealmSwift'
項目集成了兩個第三方類庫:Realm 和 Das Quadrat(一個 Foursquare REST API 的 Swift 庫)。
然后,退出工程并關閉 Xcode(最好完全關閉)。打開 OS X 終端,cd 到你的工程目錄下。詳細步驟如下:
- 打開終端
- 鍵入 cd (c-d-空格)
- 打開文件夾
- 定位到你工程目錄的那個文件夾,但是不要點進去
- 把文件夾拖到終端里
- 這樣,工程的絕對路徑會顯示在 cd 的后面
- 回車
- 這樣,就進入正確的工程目錄了
現在,在終端里輸入:
pod install
稍等一會,會出現幾行信息,這表示 Cocoapods 已按照之前設置的要求把需要的第三方庫裝進 Xcode 了,同時,我們的工程已經變成 workspace (編輯模式)了。
這步完成后,找到新生成的 .xcworkspace 文件并將其打開。以后都用它打開工程。
注意:如果打開 workspace 以后發現工程目錄里面是空的,那就重新用 .xcodeproj 文件打開工程。然后關閉它,也關閉 workspace,然后再重新用 .xcworkspace 打開工程。這樣應該就沒什么問題了。
好了,這就是 Xcode 所需的全部設置。如果每個步驟都設置正確,那么現在工程目錄中會有兩個 Project。Pods 的 project 中包含 Realm 和 Das Quadrat 的庫文件。
在 Storyboards 中構建 UI 元素
這個 App 的 UI 極其簡單,一共就兩個 UI 控件:map view 和 table view 。
Xcode 已經為你完成了大部分工作, Single View Application 模板包含了一個 Main.storyboard ,它是程序入口。
接下來,配置 map view ,步驟如下:
- 打開 Main.storyboard
- 在 Xcode 右下部分的 Object Library 里,找到 Map Kit View ( MKMapKitView )
- 把它拖到 View Controller 里面,左上角頂格,寬度和 View Controller 一樣,高度是 View Controller 的一半。(譯者注:其實是 View Controller 的 View ,大家能理解就好)
- 接著,再從 Object Library 里找到 Table View ( UITableView ),并把它拖到 View Controller 里面,寬度與 View Controller 一樣,高度填滿屏幕的剩余部分。
然后,給兩個 View 設置右邊距約束。首先,選中 map view ,點擊 Pin 按鈕(編輯區右下角的倒數第二個按鈕,看起來像星球大戰里面的戰機…(譯者注:感覺作者也被自己的比喻無語到了…))
點擊以后,會有一個彈出框,操作步驟如下:
- 取消 Constrain to margins 的選中狀態
- 選中左、上、右的線,選中后會變成紅色的高亮狀態
- 每條線旁邊都有一個輸入框,確保輸入框中的值都是 0
- 最后,點擊 Add 3 constraints 按鈕
接著,也給 table view 添加約束。步驟和之前一樣,但是 table view 添加的是 左、下、右三個約束。同樣需要注意 Constrain to margins 是未選中狀態,然后點擊 Add 3 constraints 按鈕。
現在已經給兩個 View 添加了以下這些約束:各自上下邊距的約束,寬度和父容器相同。還差最后一個步驟,需要確保兩個 View 的高度各占父容器的一半。
你可以通過給約束設置一個倍數來達到效果,但是以下是一個更簡單的方法:
- 同時選中 table view 和 map view(按住 Command 鍵并選中兩者)
- 點擊 Pin 按鈕
- 選中 Equal Heights 選框
- 點擊 Add 1 constraint 按鈕
OK,這個時候 Xcode 可能會有報錯,別擔心,照下面的步驟來解決:
- 選中 map view,點擊 Pin 按鈕
- 取消 Constrain to margins,選中下邊距約束,并在輸入框中鍵入0
- 點擊 Add 1 constraint
現在,紅色的線(報錯)消失了,但是有可能會出現黃色的線(警告)。意思是說,展示的 frame 可能和添加的約束不一致。其實這個時候所有的約束都加了,只是 Interface Builder 沒有正確顯示更新而已。
解決方式:在 Document Outline 中,點擊有小箭頭的黃色按鈕。
點擊黃色的小箭頭以后,會跳到一個新的界面。然后,在新的界面中點擊黃色的三角形 -> Update frames -> Fix misplacement 。如果還有黃色三角,重復上一個步驟。有可能,更新后的 frame 不是你想要的,所以添加約束的時候就一定要注意,一定要添加對。(譯者注:作者這里解決警告的方式太麻煩了,其實可以在 Document Outline 中選中 View ,點擊 Pin 按鈕右邊的 Resolve Auto Layout Issues 按鈕,然后選擇下面的那個 Update frames 就行了)。
在添加約束的過程中很容易出錯,最簡單的解決方式是,在 Document Outline 中刪除所有約束,重新來一遍。(譯者注:同樣,選中要刪除約束的 View ,點擊 Resolve Auto Layout Issues ,點擊 Clear Constraints 就行。植入硬廣一則:@saitjr 的 Autolayout 案例講解 )
構建 App 并解決錯誤
在開發過程中,應該時不時跑一下程序,這樣可以及時發現錯誤并解決。
在有了一定的開發經驗以后,寫一點代碼就運行程序的現象會越來越少。但如果你是新手,那就盡量將開發步驟細分,每改動一點,就跑起來看看效果。這樣就可以將代碼錯誤定位到最小范圍。
運行程序有兩個快捷鍵:Command + B 或者 Command + R。前者是編譯,后者是編譯并運行。在 Xcode 的左上角可以選擇 iPhone 型號和版本。這里也可以選擇使用真機測試,那需要加入蘋果開發者計劃。
剛好我們的程序有個錯誤,來看一下怎么解決。運行程序,先找到控制臺(在 Xcode 底部窗口的右欄)。如圖:
如果沒找到底欄,可以在 Xcode 右上角打開底欄,然后點擊底欄右邊的按鈕,打開右欄。(譯者注:一圖勝千言,如上圖)
然后控制臺上可以看到如下錯誤:
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
苦逼的是,控制臺顯示的錯誤信息太復雜,而且,有些時候甚至連錯誤信息都沒有顯示。大多數運行時錯誤由以下三種組成:異常信息、崩潰原因和堆棧信息。
以上三個信息可以幫助你定位錯誤。舉個例子,你可以通過異常信息找到拋出異常的代碼段。堆棧信息顯示的是報錯前程序調用的類與方法。這個過程一般被稱為回溯,可追溯到報錯的代碼。
現在來看看錯誤信息,其實很好理解:
Could not instantiate class named MKMapView
咦, MKMapView 看起來很眼熟吧。對,剛剛才在 Interface Builder 里面見過,拖到界面上半部分的那個 View 就是。報錯中出現的 “instantiate” 是實例化的意思,這是一個術語。錯誤含義是:編譯器(Xcode 中,把代碼轉成二進制目標文件的工具)不能創建一個 MKMapView 給你。簡單點理解就是:創建 map view 失敗了。
其實,99%的錯誤信息都不告訴你怎么去解決問題,它們只是告訴你這里出錯,卻連錯誤原因都沒寫。
你能做的就兩點:
- 甩手不做了,劇終;
- 去 Google (度娘就算了,對英文支持太差)
把錯誤信息復制下來,去 Google 吧,搜索結果一般是這樣的:
點擊第一個鏈接就行,這是個 Stack Overflow 的鏈接(一個專為程序員設計的問答網站)。這網站上的問題幾乎涵蓋了所有的編程語言,而且都解決得相當完美。
在 StackOverFlow 上解求問題的答案,你應該按照以下步驟:
- 查看問題是否有答案,如果沒有,就到 Google 里繼續找。如果有些問題還沒有答案,你也可以去回答。
- 回到我們的問題上來,撇開標題不看,答案通常隱藏在下面的評論當中。
- 找到被采納的回答(回答下面有綠色的對勾),然后看看下面的評論(評論通常比回答有效)。左邊的數字,是這個回答收到的贊。有時候被采用的回答不是最好的,所以也要留意評論和其他回答。
- 找到解決方案以后,不要盲目的照著做,要知其所以然。初學時,這可能會耗費大量時間,但是這些都是知識儲備,以后肯定能派上用場。幾乎每個程序員都有他們的知識缺口,這會削弱他們的技能。假如你能做到既知道錯誤出現原因,又能有效避免,那么你就是世上前 1% 的程序員了。
那這個問題的原因到底是什么呢?其實是 MapKit.framework 沒有導入到工程里面。看框架名字就知道, MKMapView 是被包含在這個外部框架里的。即使我們還沒有直接顯式的去調用 map view ,但也必須要導入框架到我們的工程里。
如果你通讀了 StackOverflow 的解決方案,你會發現報錯這種錯的原因有很多。
根據以下步驟來解決我們的問題:
- 在 Xcode 左導航欄上點擊項目配置(左欄頂部藍色的那一欄)
- 選擇 Build Phases 選項卡
- 點擊 Link Binary With Libraries ,展開列表
- 點擊下面的 +- 按鈕,會出來一個彈出框(這里選 + )
- 搜索 mapkit
- 最后,雙擊 MapKit.framework
這樣就把一個庫導入到了工程中。
處理地理位置
現在的工程沒有報錯了,接下來來看看下一個需求:地理位置。我們需要將用戶的位置標記在 map view 上。
首先,需要將 Storyboard 中的 map view 和代碼關聯。在創建工程的時候,Xcode 就自動生成了 ViewController.swift 文件。這也是 Storyboard 中的 view controller 所關聯的文件。
下面來做一個小測試,看看文件是否成功關聯:
- 打開 ViewController.swift 文件,看到 class 開頭的那一行。這是在類的定義。包含的信息有:類名、父類、遵循的協議。在這個類中,類名是 ViewController 。
- 打開 Main.storyboard 文件,在 Document Outline 中,找到頂上的一欄,這里應該標注的是 View Controller Scene 。
- 在右上角點擊 Identity Inpector (左起第三個按鈕)
- 檢查 Class 那一欄寫得什么
這樣,就完成了 ViewController 與 Storyboard 的關聯檢查,如果你今后在 Storyboard 中創建了其他 view controller ,也可以在 Storyboard 中設置類名來進行關聯。
建立 Map View Outlet
現在,你已經知道 Storyboard 和代碼是有關聯的了,讓我們為 Map View 添加 Outlet 吧。在你用自己的代碼擴展 Map View 之前,需要將 Map View 的實例連接起來。
打開 ViewController.swift ,在第一個 { 下面添加以下代碼:
@IBOutlet var mapView:MKMapView?
這行代碼含義如下:
- 在 Swift 中,使用變量前需要先定義。在變量定義的同時,也可以進行初始化。在上面的代碼中,并沒有進行初始化,默認是 nil (空)。
- 上面代碼給 ViewController 類的對象聲明了一個實例屬性,并且該屬性在該類的每個實例對象中,都是唯一的。與實例屬性相對的是類屬性,類屬性在每個實例對象中都是相同的。
- 屬性名稱為 mapView ,類型為 MKMapView 。 MKMapView 是 MapKit 框架里的一個類。
- @IBOutlet 告訴 Xcode 這個屬性將會作為 outlet 。outlet 會與 Storyboard (或 xib) 中的 UI 元素相關聯。
- var 表示這個屬性是可變的,與之相對的是 let ,表示常量,不可變。(譯者注:可參照 NSMutableArray 與 NSArray )
- 關于 ? 標識,是表明變量是個可選類型。這是 Swift 的一大特點,表示對象可以是 nil (空),與之相對的是 non-nil 。 可選類型提高了程序的安全性和可讀性,之后也會用到很多可選類型。
- 為什么這行代碼要放在這(class 的大括號內的頂部)呢?這表示變量的作用域是當前類。還有一種作用域是方法作用域,即在方法中定義的變量,只在當前方法中可用,當然,如果是全局作用域(全局變量),那就是在全局都可用了。
是不是覺得變量、屬性有點搞不清楚?變量就是用來存儲數據的;而屬性,它其實也是一個變量,但是他屬于一個類。同時,屬性分為兩種:實例屬性和類屬性。
是不是覺得類、實例、類型有點搞不清楚?類就是具有同種屬性的對象,它可以創建該對象的很多副本。類創建后的一個個副本就是實例。這里所說的“類型”其實是有歧義的,你可以想象成和“類”差不多的東西。
是不是覺得定義(聲明)、初始化、實例有點搞不清楚?OK,首先,定義(聲明):即告訴編譯器,要用的變量的名稱與類型。初始化:給變量一個初始值。初始值可以寫在聲明之后,如果沒有賦值,那默認為 nil 。實例:表示這個變量是一個實例(類的“副本”)。嚴格意義上來講,應該解釋為該變量是一個實例化對象。
好了,現在回到項目中來。這時,Xcode 應該會在當前行報錯,錯誤信息是:
Use of undeclared type MKMapView
這是因為 MapKit 還沒有導入到當前文件。因此,在類定義的上面,引入 UIKit 代碼的下面,添加這句話:
import MapKit
現在,來關聯一下 outlet:
- 打開 Main.Storyboard 。
- 顯示 Document Outline,點擊 View Controller Scene 。
- 打開左邊欄的 Connections Inspector 。
- 在列表中找到 mapView 屬性。(譯者注:如果沒找到,也可以通過 Show the Assistant editor 直接在代碼中關聯)
- 然后,把這個屬性右邊的小圓點拖拽到編輯器上的 map view 中。
添加第一個方法
OK,現在來做下 map view 的相關實現。在 ViewController 類中添加以下代碼:
override func viewWillAppear(animated: Bool) { super.viewWillAppear(animated) if let mapView = self.mapView { mapView.delegate = self } }
你是不是在問添加到哪里?你想放哪放哪,只要在類的大括號結束前就行,就是這么任性。
所有方法都必須在類作用域之內。類作用域即類定義之后的 { 到與之匹配的 } 之間。
你可以說這是平衡之美,每個 { 都有與之對應的 } 。同時,程序員也會使用縮進來突出作用域層級。一般來說,使用的是 1 個 tab 或 4 個空格來進行縮進。
下面來解釋下剛才寫的方法:
- 方法,是類中的一塊代碼整體。這些代碼相對獨立,并實現某些特定的功能。方法能在當前類中調用,也可以在當前工程的其他地方被調用。
- 這個方法叫 viewWillAppear ,帶一個參數。這個參數是一個變量,在方法被調用的時候會傳進來的。參數作用域在整個方法范圍內。在父類調用的 viewWillAppear 方法中,參數名為 animated ,類型為 Bool (布爾值,真或假)。
- 所有方法都以 func 關鍵字開頭,這是 function 的縮寫。在這個例子中, viewWillAppear 是重寫的父類方法,所以要加上 override 關鍵字。將父類的同名方法實現并替換成當前類的實現。父類與重寫概念都屬于面向對象編程范式范疇( Object Oriented Programming )。這個概念在本文中不做講解。
- 該方法的主體:先將可選綁定的 self.mapView 賦值給了常量 mapView 。使用可選綁定可以驗證可選變量是否為 nil 。如果有值, if 中的代碼才會執行。同時,常量 mapView 只在 if 作用域內有效。
- 在 if 條件語句中,將 mapView 的 delegate 屬性設置給當前類 self 。換句話說,當 self.mapView 不為 nil 的時候, mapView 的 delegate 就是 self 。再簡單點說:如果當前類實例不為空,那就是 mapView 的 delegate (譯者注:這里作者解釋了N多遍,代碼勝千言…)。之后還會用到其他 delegate 。
完成 delegate 的設置之后,Xcode 又報錯了。告訴我們, self 不能作為 delegate ,因為當前類 ViewController 沒有遵循 MKMapViewDelegate 。現在進行修正:
改一下類定義的那行代碼:
class ViewController: UIViewController, MKMapViewDelegate
獲取用戶地理位置
現在 map view 已經配置好了,你可以將注意力集中在獲取地理位置上了。
在 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() } }
Whoah,這段代碼是啥意思?
- 首先,用 if 條件語句判斷 locationManager 變量的值是否為空。
- 然后,實例化 CLLocationManager ,并賦值給 locationManager 。換句話說: locationManager 變量指向的就是 CLLocationManager 的實例對象。 location manager 對象能用來獲取用戶地址。
- 接著,我們給 locationManager 設置了一些屬性。將 delegate 設為當前類,并設置了 GPS 精度。還調用了 requestAlwaysAuthorization() 方法,這個方法在 app 中彈出提示框,提示用戶 app 會用到 GPS ,并征得用戶授權。
- 最后,調用 startUpdatingLocation 方法,location manager 就會開始輪詢 GPS 坐標,并將最新的坐標通過代理方法傳回。如果實現了代理方法,我們就能拿到用戶的地理位置信息了。
你是否注意到 locationManager 代碼后面的感嘆號?這是因為 locationManager 是可選值,所以有可能是 nil 。當我們要訪問這個變量時,就需要先解包,確保非空。根據這個訪問約定,解包有兩種方式:
- 可選綁定 。使用 if let definitiveValue = optionalValue { … 這樣的結構(譯者注:關于 if let 的使用,可以參考 SwiftGG 翻譯組的另一篇文章:if-let賦值運算符)
- 強制解包 。使用感嘆號,如 optionalValue! 。
在寫第一個方法的時候,我們用的就是可選綁定的方式。當可選變量不為 nil 時,使用 if let 來定義一個新的變量。
強制解包不是一個很好的方案。要在需要解包的變量后面加上感嘆號,那么它就會從可選狀態 “強制轉換” 為不可選狀態。不幸的是,當你強制解包一個值為 nil 的可選變量時,程序會直接崩潰。
所以不能對值為 nil 的可選變量強制解包。在上面的代碼中,強制解包就不存在這個問題。為什么呢?因為在強制解包之前,我們先將 CLLocationManager 的實例變量賦給了 locationManager ,所以可以保證 locationManager 不是 nil 。
OK,回到代碼部分。當我們添加了上面方法以后,Xcode 又報錯了…讓我們繼續來解決問題吧!
錯誤之處:我們想讓 self 作為 locationManager 的委托( delegate ),但是并沒有遵循相應的協議。在類定義的地方,添加以下代碼來遵循協議:
class ViewController: UIViewController, MKMapViewDelegate, CLLocationManagerDelegate
OK,給 ViewController 類添加以下代理方法。放在上一個方法的后面就行。(譯者注:添加的這個方法已經被棄用了。取而代之的是 func locationManager(manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) 方法。)
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 不為 nil 時, mapView 變量就是它解包以后的值,然后執行 if 條件中的對應語句。
- 在 if 語句中,根據新的 GPS 坐標與之前定義的 distanceSpan 兩個值,計算得到 region 值。這句代碼創建了一個以 newLocation 為中心,500 * 500 的一個矩形區域(500 就是 distanceSpan 的值)。
- 最后,調用 map view 的 setRegion 方法。 animation 參數設為 true ,這樣 region 改變就會有動畫。換句話說:地圖可能會有平移或縮放操作,所以要保證他每次都能顯示 500 * 500 的區域。
最后一件事,為了讓用戶同意地理位置授權,你需要在 Xcode 中設置一個特別的授權請求。這個請求要用一句話涵蓋為什么要獲取用戶地理位置。iPhone 會在申請授權時,彈框顯示這句話(即在調用 requestAlwaysAuthorization() 方法時)。
配置請求的步驟如下:
- 在工程目錄中打開 info.plist 文件。
- 右鍵點擊列表,選擇 Add Row。
- 在 key 列,填入 NSLocationAlwaysUsageDescription 。
- 在 type 列,將類型改為 String 。
- 在 value 列,填入 We need to get your location! (譯者注:這個 value 就是申請授權并彈框時,顯示給用戶的文本)
運行程序
現在,讓我們運行一下程序。確保你選擇了相應的 iPhone Simulator,運行快捷鍵 Command - R 。第一次運行 App ,會彈出是否允許獲取地理位置的授權框,選擇 Allow,如下圖。
當我們點擊了 Allow 以后,map view 好像并沒有更新位置。這是因為模擬器沒有 GPS ,所以…我們需要模擬一下:
當 app 在模擬器上跑起來以后,在以下兩種方式中,選擇一種進行配置:
- iPhone Simulator: Debug -> Location -> Apple.
- Xcode: Debug -> Simulate Location -> [隨便選一個]
當你選擇了一個地理位置后,map view 就會定位到對應位置,并縮放到合適的大小。(譯者注:可能定位這一步會有點慢,map 半天沒有更新或沒有圖像出來,等等就好了)
搞定了嗎?完美!
從 Foursquare 上讀取地理信息
你以為到這一步就完了嗎?其實并沒有,還會有更有趣的事情!我們還需要使用 Das Quadrat 來讀取 Foursquare 上的數據,然后用 Realm 將數據存入本地。
在使用 Foursquare 的 API 之前,首先需要到開發者中心注冊這個 app 。這個步驟很簡單。(譯者注:如果只是練習,沒必要去注冊,直接使用作者提供的 Client ID 和 Client Secret 即可)
- 首先,確保你有一個 Foursquare 賬號,沒有可以去注冊一個: foursquare.com 。
- 然后,進入 developer.foursquare.com ,點擊頂部藍色菜單欄中的 My Apps 。
- 接著,點擊右邊綠色的 new app 按鈕。
- 接著,填寫以下信息:
- App Name: Coffee
- Download / Welcome page URL: http://example.com
- 最后,點擊保存
保存以后,網頁自動跳轉到了你創建的 app 頁面。記錄下 Client ID 和 Client Secret ,之后會用到。(譯者注:作者提供的 Client ID 和 Client Secret 在后面的代碼里有提供)
構建 Foursquare API 連接
OK,下面開始連接 Foursquare API 。這里我們會用到單例模式。我們要做的部分用單例模式簡直是完美。
單例是一個類的實例,它在整個 app 生命周期中,只允許有一份拷貝。所以你不能去創建第二個實例。為什么要使用單例呢?雖然單例的使用飽受爭議,但是它有一個明顯的優勢:可以避免對外部資源發起并發連接。
設想一下。如果對網站同時發起兩個請求,并且他們會寫入同一個特定的文件,會發送什么呢?這樣就很有可能讀到臟數據,除非網站知道這兩個請求發起的先后順序。
而單例就能確保只有 app 的一部分能訪問外部資源。在單例中,有很多種實現方式能保證沒有請求沖突存在。將請求加入隊列并添加依賴就是其中一種解決方案。這又是一個很大的主題,本文不進行講解。
不扯了,繼續實現:
- 在工程目錄中,右鍵點擊 Coffee 文件夾。
- 選擇 New File。
- 選擇 iOS -> Source 中的 Swift File ,點擊繼續。
- 文件命名為 CoffeeAPI.swift ,確認 target 中的 Coffee 是選中狀態,選擇和其他 swift 文件統計目錄,點擊 Create ,保存文件。
Whoah,新文件里面空空如也!讓我們來加點料吧。在 import 代碼的后面,添加以下代碼:
import QuadratTouch import MapKit import RealmSwift
然后,添加:
struct API { struct notifications { static let venuesUpdated = "venues updated" } }
代碼很簡潔對吧。首先,你正確地引入了一些需要的庫(Quadrat, MapKit, Realm),然后使用 struct 創建了一個名為 venuesUpdated 的靜態常量。之后,通過以下方式訪問該變量:
API.notifications.venuesUpdated
接著,鍵入:
class CoffeeAPI { static let sharedInstance = CoffeeAPI() var session:Session? }
以上代碼的作用:
- 告訴 Xcode 編譯器,當前類名為 CoffeeAPI 。這是一個單獨的 Swift 類,沒有繼承 NSObject 。
- 聲明一個靜態常量 sharedInstance ,類型為 CoffeeAPI 。這個 sharedInstance 只有 CoffeeAPI 類才能訪問,并且在 app 啟動的時候就已經被初始化了。
- 聲明一個類型為 Session? 的可選變量 session (該類型包含在 Das Quadrat 中)。
之后,我們訪問 Coffee API 單例的方式都將是 CoffeeAPI.sharedInstance 。你可以在任何地方,通過這種方式訪問單例,并且,訪問的都是同一個對象,這也正是單例的一大特點。
接著,需要寫一個構造器。給當前類添加以下代碼:
init() { // Initialize the 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: "")
OK,繼續。復制下面的代碼,粘貼在 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 } }
這又是什么呢?這是一個 extension ,可以給當前類擴展其他的方法(譯者注:關于 extension 的知識點,可以查看 SwiftGG 翻譯組的其他文章: 擴展基礎知識 。延伸到程序結構設計方面,還有進階的Mixins 比繼承更好)。無需創建新的類,就可以給 CLLocation 類擴展一個名為 parameters() 的方法。每次使用 CLLocation 的實例時,這個 extension 就會被加載,你可以通過實例來調用 parameters 方法,即使這個方法沒包含在原生的 MapKit 中。
注意:不要混淆 Swift 中 extension 和編程術語 extend 。前者是給基類添加新的方法,后者意思是父類與子類間的繼承關系。
parameter 方法返回一個 Parameters 的實例對象。 Parameters 是一個字典,里面包含了一些參數信息( GPS 坐標和精度)。(譯者注: Parameters 是在 Session.swift 中定義的 typealias ,完整定義為: public typealias Parameters = [String:String] )。
給 Foursquare 發送請求
接下來,讓我們從 Foursquare 獲取數據吧。Foursquare 內部有一個 HTTP REST API 可以返回 JSON 數據。幸運的是,我們不需要知道這些,因為 Das Quadrat 庫已經幫我們搞定了一切。
從 Foursquare 請求數據就和調用 session 里的屬性一樣簡單,同時請求數據使用的是該屬性里很多方法中的一個。這個方法返回一個 Task 的實例對象,即異步后臺任務的引用。我們可以用閉包的形式實現,代碼大致如下:
let searchTask = session.venues.search(parameters) { (result) -> Void in // Do something with "result" }
session 里的地理屬性包含了與 Foursquare API 通訊的所有 venues 信息。你提供的這個 search 方法是帶有參數(上段代碼中的 parameters )的,還有第二個閉包作為參數,該閉包會在 search 方法完成后執行。同時,該方法會返回一個耗時的后臺 Task 引用。你可以在任務完成之前用它來停止,或著在你代碼的其他地方用它檢查進度。
OK,現在來看看下面這個方法。復制并粘貼到你的代碼里,即放在初始構造函數的后面,但在 CoffeeAPI 這個類的右括號前面。接下來,我們會看到這個方法的用途。
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"] // Start a "search", i.e. an async call to Foursquare that should return venue data 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() } }
這么多代碼,你能從里面分辨出它完成的 5 個主要的任務嗎?
- 配置并啟動 API 請求。
- 使用閉包實現請求的 Completion handler。
- 解析請求返回的數據,并開啟 Realm 事務來處理。
- 使用 for-in 循環遍歷所有的地理數據。
- 在 Completion handler 的最后發送通知。
接下來,讓我們一行行的來解釋一下:
設置請求的準備動作
首先,使用可選綁定檢查 self.session 是否為空。如果非空的話,常量 session 會被賦值解包后的值。
接著, location 的 parameters() 方法被調用。你問這個 location 是從哪里來的?你可以看下 getCoffeeShopsWithLocation 方法后面的那個參數。每次你調用這個方法,你也必須傳入一個 location 參數,并檢查傳入的參數是不是你之前寫的。
最后,我們添加了一個新的數據項到 parameters 字典。該數據項使用 Parameter.categoryId 作為 key ,字符串 4bf58dd8d48988d1e0931735 作為 value 。這個字符串就是之前 Foursquare 上 Coffeeshops 目錄的編號,因此,沒什么特殊的。
配置請求
接著,讓我們來配置真正的請求。獲取 session 的 venues ,并開始搜尋這個 venues 。該方法有兩個參數:你剛才創建的 parameters 字典和閉包。現在使用的閉包的形式叫尾隨閉包( trailing closure )。它作為該方法的最后一個參數,沒有采用圓括號括起來的形式,而是將它寫在方法外部并用大括號括起來。這是個很耗時的方法,因此,我們并沒有讓它自動開始執行,而是在本方法的末尾再執行。
書寫閉包
接著,我們進到閉包里去看看。值得注意的一點是,盡管這些代碼看上去連續的,但是它們不會一個一個按你看到的順序執行。該閉包會在搜尋任務完成后執行。當數據從 HTTP API 返回到應用中時,代碼會從 let searchTask … 這行執行到 searchTask.start() 這行,接著會跳到 if let response = … 這行。
閉包的格式是這樣的: (result) -> Void in 。 result 作為閉包里的參數是可以拿到值的,并且該閉包沒有返回值( Void )。這一點和普通的方法有點相似。
解析數據
接著,我們使用了 if 可選綁定:
- 如果 result.response 非空,就將其賦值給常量 response ,并繼續執行 if 條件內的語句。
- 如果 response[“venues”] 非空,并且可以轉換成 [[String: AnyObject]] 類型。
這個類型轉換可以確保我們拿到的是正確的類型。如果轉換失敗,即可選綁定失敗,就不會執行 if 條件內的語句。這個方法有一石二鳥的效果:檢查對應的值是否為空,同時嘗試將數據轉換成合適的類型。
你能說一下 venues 的類型是什么嗎?首先它是一個數組,每個元素是字典類型,每個字典是以 String 類型為 key , AnyObject 類型為 value 。
自動釋放內存
接著,我們開啟了一個自動釋放池。自動釋放池本身就是一個很大的話題。你知道 iPhone 是如果進行內存管理的嗎?
本質上來說,內存里的對象在沒有被使用時,會在某個時間點從內存里被移除。有點類似垃圾回收,但還是有點區別的。當自動釋放池里的一個變量被釋放時,這個變量就和這個自動釋放池緊緊聯系在一起了。當這個自動釋放池自己要被釋放時,在內的所有變量的內存也會一起被釋放。這個有點像,對內存釋放的批處理。
為什么要這么做呢?因為,可以通過創建自己的自動釋放池,來幫助 iPhone 系統管理內存。我們在處理數以百計的地理對象時,如果沒有放在自己的自動釋放池里,內存就會被未釋放的內存擁塞了。而,能釋放這些內存的時間點是在該方法結束的時候。因此,你在冒著用光內存的風險操作(自動釋放的機理導致不會立馬釋放無用的內存)。使用自己創建的自動釋放池,你就可以影響內存釋放的時間點并能避免被內存不足困擾。(譯者注:ARC 下,在方法內創建的臨時變量,系統都會自動加上 __strong 修飾符,并在出該變量作用域時,進行 release 。所以,一般在處理有大量的臨時變量的方法時,會自己加上 autoreleasepool ,提前釋放已經不用的臨時變量,及時釋放內存。)
開啟 Realm
接著,你用 let realm = try! Realm() 這樣一行代碼初始化了一個 Realm 對象。你在從 Realm 獲取數據之前肯定需要有一個 Realm 對象。 try! 關鍵字是 Swift 的一種錯誤處理。用了這個關鍵字,我們其實聲明了:當前不會處理來自 Realm 的錯誤。雖然這樣的做法對生產環境來說并不推薦,但是可以讓我們的代碼變得相當簡單。
開啟事務處理
接下來,調用 Realm 實例方法 beginWrite 。其實這代碼開啟了一個事務。讓我們先來談談效率的問題。以下哪種方式更高效:
- 創建一個文件指針,打開文件,寫入 1x 數據到文件里,關閉文件,再重復之前的步驟直到寫入 50x 數據。
- 創建一個文件指針,打開文件,寫入 50x 數據到文件里,關閉文件。
確切地說,當然是后者更高效。和其他數據庫系統一樣, Realm 也是把數據存儲在文本文件里的。文件處理就意味著:操作系統( operation system , OS )需要打開著文件,賦予程序寫入權限,并讓程序可以一個字節一個字節的向文件里寫入數據。
你需要使用打開一次文件,一次寫入 50 個 Realm 對象的方式,而不是一次次的寫入文件。因為,每個對象之間非常相似,它們可以被連續地寫入。這種方式更快一點,其實這就是事務。
為了完整性,如果事務中的一次寫入失敗了,那么所有的寫入都會失敗。這種機制其實來源于銀行和賬戶:如果你寫入了 50 個事務到一個分類賬簿,而其中的一個(比如,賬上沒有錢)被證明是錯誤的,但是你又不能找出來。你必須阻止這種“污染”整個賬簿的行為。這時候使用事務就再好不過了,成功都寫入,失敗都回滾,這樣的方式也能減少數據出錯的風險。
遍歷地理數據
OK,現在來看看 for-in 循環。你已經可選綁定上面創建了 venues 變量。在 for-in 循環遍歷整個數組時,每次循環里都是數組中的一個元素: venue 。
首先,創建了一個 Venue 類型的 venueObject 變量。這行代碼暫時會報錯,因為現在還沒有一個類叫 Venue 。你等會就會添加這個類的,因此先放一邊吧。
接著,一系列的可選綁定來了。每個可選綁定都嘗試去訪問 venue 的鍵值對( key-value pair ),同時嘗試將其轉換成合適的類型。舉個例子,當 venue 包含一個鍵 id ,并嘗試轉換成 String 類型,假如成功的話,會將 venueObject 的 id 屬性賦值給它。
location 的可選綁定看上去復雜一點,但是其實一點也不復雜。仔細看,你會發現 lat 、 lng 、 formattedAddress 這些都是 location 的一部分 key (并不是 venue 的)。它們其實在數據結構中是屬于同一層的。
接下來,是 for-in 循環最后一行代碼: realm.add(venueObject, update: true) 。這行代碼會把 venueObject 添加到 Realm,并寫入到數據庫(仍然是以事務的形式寫入)。方法中的第二個參數 update 表示:當對應傳入的對象已經存在,就用新數據覆蓋掉之前寫入的數據。之后,你會發現每個 Venue 對象都有一個唯一的編號,所以 Realm 可以根據編號知道對象已經存在。
錯誤處理
OK,現在 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() 這行代碼就是在嘗試執行可能出錯的操作。同時,這行代碼前面寫了 try 。回到你之前寫 try! 的地方, try! 會摒棄錯誤。(譯者注: try! 表示禁用錯誤傳遞,如果拋出錯誤,那么程序崩潰。一般用于,你知道這個步驟不會出錯的情況。關于錯誤處理,可以看 SwiftGG 翻譯組翻譯的 Swift 官方文檔: 錯誤處理 )。
當在 do { } 代碼塊里產生錯誤的時候, catch 代碼塊就會執行。它只有一個參數, let e ,它會包含異常的具體信息。在后面的代碼塊里,我們將具體的錯誤信息打印出來。當程序運行過程中出現錯誤了,打印的信息就會告訴我們錯誤到底是由什么異常引起的。
這里的這個錯誤處理是很基礎的。設想一下,一個錯誤處理很完善的系統,不僅僅需要抓住出錯信息,還要對錯誤信息進行一下處理。舉個例子,當你寫數據到文件,而磁盤滿了的時候,你就需要彈窗讓用戶知道磁盤已經滿了。在較早版本的 Swift 中,處理錯誤比現在更艱難,而且如果你不處理得當,程序就崩潰了。
Swift 的錯誤處理或多或少還是加強了。你要不處理錯誤,要不摒棄掉錯誤,但是不管怎么樣也不能忽視錯誤。處理錯誤可以讓你的代碼更健壯,因此,養成多使用 do-catch 處理錯誤的習慣,而不是使用 try! 來摒棄錯誤。
OK,該方法中還有最后兩行代碼,第一行如下:
NSNotificationCenter.defaultCenter().postNotificationName(API.notifications.venuesUpdated, object: nil, userInfo: nil)
這行代碼會給整個應用中監聽它的地方發送一個通知。這實際上是應用中的通知機制,可以高效的將事件傳遞到應用中的不同位置。考慮到你剛從 Foursquare 獲取到新數據,你可能要去更新顯示數據的 table view ,也可能要更新代碼的其他部分。通知是完成這個操作最好的方式了。
請牢記,通知會一直保留在發送它的那個線程上。如果你在主線程外(比如,發送通知的線程)更新你的 UI ,你的應用就會崩潰并拋出錯誤。
注意到這行代碼里的硬編碼 API.notifications.venuesUpdated 了嗎?本來我們可以寫成 "venuesUpdated" 的字符串, 而不是 API.notifications.venuesUpdated 。使用硬編碼的編譯時常量能讓你的代碼更安全。如果你出錯,編譯器會報錯。但是,如果你使用字符串的方式,拼寫錯 "venuesUpdated" ,編譯器就不會報錯了。
最后,閉包外的這行代碼:
searchTask.start()
再次注意,這行代碼會在 let searchTask … 后執行,且和上面一大段閉包是獨立的。這行代碼到底是干什么的呢?現在,我們已經設置好請求,配置好所有需要的參數,這行代碼就是讓這個搜尋任務啟動起來。
Das Quadrat 發送一條消息到 Foursquare ,等待數據的返回,然后就執行了處理數據的閉包。懂了吧?
暫時把這些代碼放一邊,因為接下來我們要寫 Venue 對象了。
編寫 Realm Venue 對象
你知道 Realm 酷在什么地方嗎?它整個代碼結構是很簡短的。本質上來說,你只需要一個類文件就可以寫 Realm 了。你創建了一系列的實例對象,把它們寫到 Realm 文件中,然后 BAM!你已經完成了你自己的本地數據庫。
Realm 有一系列很贊的特性,比如排序、過濾以及支持 Swift 數據類型。你再也不需要在 table view 里使用 Core Data 的 NSFetchedResultsController 來加載成千上萬的對象。Realm 也有它自己的數據瀏覽器。
OK,接下來該寫 Realm Venue 對象了。步驟如下:
- 右擊 Xcode 中 Project Navigator 的 Coffee 這個文件夾。
- 點擊 New File … ,從 iOS -> Source 目錄選擇 Swift 文件,并點擊 Next 。
- 將新建的文件命名為 Venue.swift ,并確保選中了 Coffee 這個 target 。
- 最后,點擊 Create 完成創建。
好吧,又是一個無內容的文件。這個文件將會包含 Realm 的 Venue 對象的代碼。
首先導入正確的庫。在 Foundation 的導入代碼添加如下代碼:
import RealmSwift import MapKit
接著,鍵入如下代碼:
class Venue: Object { }
這就為 Venue 新建了一個類。其中,這個冒號表示當前類繼承自 Object 類。這其實是面向對象編程( Object Oriented Programming )中父類和子類之間的繼承關系。此處代碼就是將 Venue 類繼承自 Object 類。
簡單來說,作為一個子類會自動將父類的所有方法和屬性拷貝到自己的類中。值得注意的是,這和我們之前使用的 extension 是不一樣的,它是為現有的類添加新的方法,而沒有創建一個獨立的新類。
接著,將以下代碼拷貝到該類中,記得要添加在大括號的范圍內:
dynamic var id:String = "" dynamic var name:String = "" dynamic var latitude:Float = 0 dynamic var longitude:Float = 0 dynamic var address:String = ""
這些句子是什么意思呢?就是為這個類添加了 5 個屬性。你可以像使用 CoffeeAPI 的代碼那樣,使用這些屬性為類實例添加數據。
屬性中的 dynamic 關鍵字可以確保該屬性能被 Objective-C 運行時訪問。這本身是另外一個主題,但是我們先假設 Swift 的代碼和 Objective-C 代碼在各自的 “沙盒” 里運行。在 Swift 2.0 之前,所有的 Swift 代碼都是運行在 Objective-C 運行時里,但是現在 Swift 已經有自己的運行時了。我們用 dynamic 關鍵字修飾屬性,就可以讓 Objective-C 運行時訪問到這個屬性,因為 Realm 需要在內部用到該屬性。
每個屬性都是 String 或 Float 類型。 Realm 本身支持一些變量類型,包括 NSData 、 NSDate 、 Int 、 Float 、 String 等等。
接下來,在 address 屬性下面添加以下代碼:
var coordinate:CLLocation { return CLLocation(latitude: Double(latitude), longitude: Double(longitude)); }
這個屬性的值要計算后才會有。它不能保存到 Realm 里,因為它的類型沒有包含在 Realm 本身支持的類型中。這個屬性保存的是表達式的結果值。它就像一個方法,但是接著它就可以用屬性來訪問了。以上屬性返回的是一個 CLLocation 實例對象,它有 latitude 和 longitude 兩個屬性。
這種使用方法很便利,因為我們只需要訪問 venueObject.coordinate 就能獲得對應類型的實例,而不用我們自己創建。
OK,接下來,粘貼以下代碼到最后的代碼塊下面:
override static func primaryKey() -> String? { return "id"; }
這是一個新出現的方法,它重寫了父類 Object 的方法。這個自定義方法可以返回一個 Realm 的主鍵( primary key )。主鍵就是唯一標識。每個 Realm 數據庫中的對象有且僅有一個唯一的值作為主鍵,就像一個村莊里的房子必須有且僅有一個唯一的地址一樣。
Realm 會用主鍵去區分一個個不同的對象,并確定當前這個對象是否唯一。
該方法的返回值類型為 String,因此我們就可以返回主鍵對應的屬性名或者返回 nil (不使用主鍵的情況)。
Realm 對象的屬性(比如, id 和 name )類似于電子表格里的列。方法返回的主鍵返回值即是每一列的名字,其實就是 id 。
現在,我們需要按 Command-B 來編譯當前應用,并確保沒有報錯。我們沒必要運行當前的應用,因為我們沒有改變前端的展示代碼。取而代之的是,我們只要檢查編譯應用時候是否有報錯。如果你這時候去查看 CoffeeAPI.swift 文件,之前關于 venueObject 的錯誤已經不存在了。
在 Map View 中展示地理數據
OK,現在讓我們來處理下載下來的數據吧。你將要把它們放入之前創建的 map view 里以注釋(annotation)的形式展示。
首先,切換到 ViewController.swift 文件。檢查用來在 map view 上顯示用戶位置的代碼。
接著,在文件的最上部,添加如下的導入語句:
import RealmSwift
接著,在類的最上部,添加以下這些屬性:
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) } } }
Whoah,好長的方法,它是怎么執行的呢?
讓我們從 location 的兩行判斷語句說起吧。第一行檢查了 location 是否非空,第二行使用可選綁定檢查了 lastLocation 屬性是否非空。
雖然這兩行代碼看上去很相似,但是其實干的事情是不同的。讓我們退一步想想。檢查以下陳述是否是真實的:
- 應用中的所有位置數據都來源于 locationManager:didUpdateToLocation:fromLocation 方法。即,該方法是唯一一個能獲取到 CLLocation 實例(數據來自 GPS 硬件數據)的地方。
- refreshVenues 方法需要一個位置作為參數,該參數可能為空。
- refreshVenues 方法可能會在沒有可用的位置時被調用。比如,在代碼里,一個與位置數據方法沒有聯系的地方就調用 refreshVenues 方法。
最后一句陳述很重要。其實也很簡單:因為我們不一定要在獲取到最新地理位置( locationManager:didUpdateToLocation:fromLocation )時,才進行位置保存,所以,我們需要將保存位置的功能單獨封裝出來(封裝為 refreshVenues 方法)。
因此,每次調用 refreshVenues 方法時,如果 lastLocation 屬性非空的話,我們會將 location 參數保存起來。然后,我們會用可選綁定檢查 lastLocation 是否為空。 if 語句只會在有值的時候執行,因此我們可以 100% 確定 if 語句里的代碼塊肯定會包含一個有效的 GPS 位置信息!
當然,前提是 refreshVenues 方法確實獲取到了位置數據。你肯定要確保它是非空的。如果你還是不太理解的話,可以重新讀一下上一段內容。這樣的代碼非常優雅,而且這樣的編碼還可以確保你的應用數據是安全的且仍然是解耦的。
OK, 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 這個屬性里。該屬性的類型是 Results? ,該類型是以 Venue 實例為元素的數組。
最后,for-in 循環遍歷 venues ,并將每個元素以注釋(annotation)的樣式添加到 map view 里。這段代碼很可能會報出錯誤,但我們將會解決掉它的。
創建注釋(Annotation)類
創建注釋類,需要以下步驟:
- 右擊 Coffee 文件夾,選擇 New File … 。
- 從 iOS -> Source 目錄里選擇 Swift 文件并點擊 Next 。
- 將該 Swift 文件命名為 CoffeeAnnotation ,并點擊 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 }
類似于 table view,map view 也用可重用的實例來讓地圖上的 pin 顯示更平滑。以上代碼大概是以以下的步驟展開:
- 首先,檢查 annotation 是不是用戶的當前位置。
- 接著,在重用隊列中取出 pin (并賦值給 view 變量)。
- 然后,如果沒有 pin 在重用隊列中,就創建一個新的。
- 接著,設置 pin 允許顯示 callout(一塊小小的用來顯示信息的簡介)。
- 最后,返回 view 。
值得注意的是,這方法是代理模式的一部分。你之前設置了 map view 的代理為 self 。因此,當 map view 準備顯示 pin 時,都會調用代理中的 mapView:viewForAnnotation: 方法,應用才能執行到你剛定義的代碼。
代理是一種很不錯的自定義代碼的方式,它不用重載整個類。
回應地理數據的通知
好的,現在讓我們把這一切都整理一下。在之前,我們在 ViewController.swift 的 viewDidLoad 方法里添加了以下這行代碼:
NSNotificationCenter.defaultCenter().addObserver(self, selector: Selector("onVenuesUpdated:"), name: API.notifications.venuesUpdated, object: nil)
這行代碼會告訴通知中心( notification center ), self (當前類)正在監聽名為 API.notifications.venuesUpdated 的通知。當發出通知的時候, ViewController 類的 onVenuesUpdated: 方法就會被調用。
添加以下方法到 ViewController 類里:
func onVenuesUpdated(notification:NSNotification) { refreshVenues(nil) }
看看這里到底發生了什么吧:
- 當從 Foursquare 接收到返回的位置數據時, refreshVenues 方法就會被調用。
- 該方法沒有包含位置數據,也沒有提供 getDataFromFoursquare 參數。如果沒有傳入參數,就默認是 false ,因此沒有向 Foursquare 請求數據。如果傳入參數,那么就會再次發起請求,請求結束又會調用該方法,這樣就會導致死循環。
- 本質上來說,從 Foursquare 返回的數據會觸發相應方法,從而將注釋畫到 map view 上去。
關于代碼,還有一個很重要的部分。添加如下代碼到 locationManager:didUpdateToLocation:fromLocation: 方法里。
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) }
這些代碼是怎么回事呢?簡單來說:調用 refreshVenues 方法獲取用戶的 GPS 位置。同時,也用 API 向 Foursquare 請求數據。本質上來說,用戶每次移動到新的位置都會向 Foursquare 請求數據。由于設置了間隔 50m 就更新,并且注冊了通知,所以地圖能正常更新。
運行應用并驗證一下。是不是很酷?
在 Table View 里顯示地理數據
現在,map view 已經能正常顯示了。接著我們將會把同樣的地理數據顯示到 table view 中。實現起來也是很簡單直接的。
首先,添加實例屬性和 outlet 到 ViewController 。在 mapView 屬性下面添加如下的定義:
@IBOutlet var tableView:UITableView?
接著,切換到 Main.storyboard ,選中 View Controller Scene。將 table view 與 IBOutlet 關聯。
與以 self.mapView 可選綁定相同的方法,添加如下的代碼到 ViewController.swift 的 viewWillAppear: 方法里。
if let tableView = self.tableView { tableView.delegate = self tableView.dataSource = self }
并將當前的類遵循以下的協議:
UITableViewDataSource, UITableViewDelegate
接著,再添加兩個代理中的方法:
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return venues?.count ?? 0 } func numberOfSectionsInTableView(tableView: UITableView) -> Int { return 1 }
這兩個方法是 table view delegate 協議中方法的一部分。第一個方法確定了 table view 有多少個 cell,而第二個方法確定了 table view 有多少個 section。注意到代碼中的 ?? 了嗎?它是空和運算符(nil-coalescing operator)(譯者注:如果對空和運算符有什么不理解的話,可以查看 中文版官方文檔 的說明)。即,如果 venues 是空的話,使用 0 作為默認值。
接著,添加以下方法到 ViewController 類:
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 的樣式創建一個新的 cell 。
- 如果 venues 數組的第 indexPath.row 個元素存在,就賦值給常量 venue 。使用該數據去填充 cell 的 textLabel 和 detailTextLabel 。
- 返回 cell 。
和 map view 的類似, 當 table view 需要一個 table cell 的時候,就會調用 tableView:cellForRowAtIndexPath: 方法。你可以使用該方法來自定義你的 table view cell。這比寫個子類簡單多了。
接下來,是 table view 的最后一個方法。把一些方法添加到 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 個元素存在時,使用它去填充該數據項所在區域的 map view。換句話說,把點擊的項顯示到 map view 的中心。
現在唯一剩下的事情就是,當通知事件發生時,及時地刷新 table view 數據。當數據更新時,你就想要把它們顯示出來。
在第二個 if 條件判斷的末尾,添加以下這行代碼到 refreshVenues: 方法。定位到 if let location = lastLocation 這行代碼,在該語句的有括號后面,添加如下代碼:
tableView?.reloadData()
OK,現在檢查一下應用能否運行。使用 Command-R 編譯并運行后驗證結果。如果所有的設置都正確的話,地理數據會在 table view 中顯示出來。
基于位置過濾地理數據
OK,現在有個奇怪的現象,即 table view 顯示了所有的數據。如果你在應用中點擊過日本,然后點了舊金山,仍然會將日本的咖啡店顯示在 table view 里。
我們當然不想要這樣。因此,讓我們使用一些 Realm 的小魔法只讓準確的數據顯示。
首先,把 ViewController 類中的 venues 屬性改變一下。不再使用 Results? ,而是設置為:
var venues:[Venue]?
兩者之間的區別,只是類型不同而已。之前那種是包含 Venue 對象的 Results 實例。它是 Realm 的一部分。而第二種新的類型是 Venue 實例的數組。
最大的區別是懶加載。Realm 在加載需要使用的數據時很高效,比如你的代碼訪問 Realm 數據。不幸的是,Realm 并不支持對屬性計算后排序的特性。因此,我們需要加載所有從 Realm 獲取的數據,并執行自己定義的過濾操作。通常你會使用 Realm 來處理數據檢索(使用延遲加載),并給它一個過濾器。這次暫不考慮使用這種方法。
OK,還記得這兩行代碼嗎?
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) }
OK,這方法也沒什么特別的。只是一些基本的數學計算,把 CLLocation 實例基于區域的距離轉換成左上和右下兩個坐標。
第一行代碼創建了基于位置和距離的區域。接著,設置好位置和它們的經緯度。這些值是根據中心的坐標計算出來的。最后,該方法返回一個元組:兩個有序的變量。
可以把任意順序的類型組合成元組(譯者注:如果對元組有什么不理解的話,可以查看 中文版官方文檔 的相應說明)。圓括號里的變量有特定的順序,且是不可變的數組。
OK,回到我們的過濾器代碼(譯者注:位于上上段代碼)。讓我們一行一行來解讀。
- 首先,創建了兩個常量, start 和 stop 。它們是 calculateCoordinatesWithRegion: 方法的返回結果。該方法返回的是一個元組,由 start 和 stop 組成。 calculateCoordinatesWithRegion: 方法的功能就是返回當前用戶的地理位置。
- 接著,創建了一個 predicate 變量。 NSPredicate 是一個過濾器,它可以適用于數組,序列(譯者注:也可以理解為元組)等等。 predicate 變量定義了一個范圍, venues 數組里的 GPS 坐標必須落在該范圍內。它主要是用于過濾 Realm 的數據(下一行代碼會過濾)。值得注意的是,該 predicate 變量假設 GPS 數據是平面的,雖然地球明顯是球體的。現在暫時這樣假設是沒事的,但是當你在南極點或北極點附近使用本應用去尋找咖啡店時就會出問題。
- 接下來,讓我們來剖析一下 realm 對象獲取數據的那部分內容。所有方法都是有關聯的,也就意味著每次方法調用都用到了前一個方法調用的結果。
- 首先,創建了一個 realm 變量來保存 Realm 的引用對象。
- 接著, Venue 的所有對象都被懶加載: objects(Venue) 。
- 接著,過濾器( predicate )來過濾這些對象。Realm 可以快速的處理過濾,而且它并不是所有的對象都過濾,而只是過濾能訪問到的對象。
- 接著,調用 Swift 本地的排序算法。這里的 sort 并不是 Realm 的那部分,Realm 的排序算法叫 sorted 。換句話說,這部分沒用上 Realm。該排序算法會訪問所有的 Realm 對象,也就意味著它們都會被加載進內存,這里也沒用上 Realm 的懶加載特性。該排序算法只有一個參數:一個確定兩個無序對象順序的閉包。通過返回 true 或 false ,來標識閉包里兩個對象比較后的關系。在上面那段代碼里,前后順序是基于離用戶位置的距離的。這也是坐標屬性派上用場的地方。其中, $0 和 $1 是兩個無序對象的縮寫。從根本上來說,該方法將地理數據以用戶位置距離遠近進行排序(距離越近,排在越前面)。
就說到這里吧。以上是的代碼量比較大,但是效率很高。 Realm 優化的特性,方法鏈(method chaining)以及 Swift 本地的排序算法可以讓一大票地理數據按特定的順序保存。而且,還有一個很炫酷的事情:隨著你的移動,它會隨時更新。
就這樣了!用 Command-R 來看看應用的效果吧。干的漂亮!
注意:不幸的是,當你在 Xcode 里模擬 GPS 坐標時,從 Foursquare 獲取的數據可能會少的可憐。假如你想要獲得更多數據的話,你可以去除 CoffeeAPI 中硬編碼的部分,或者把地點模擬到有更多咖啡店的位置。
你對本教程有什么想法呢?留下你的留言和想法吧。
最后再安利一波。你可以從 GitHub reinderdevries/CoffeeGuide 上下載所有源代碼和 Xcode 項目。
本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問http://swift.gg。