Swift 程式語言

Swift 教學:如何用 Foursquare API 和 Realm 打造一個咖啡店 App

Swift 教學:如何用 Foursquare API 和 Realm 打造一個咖啡店 App
Swift 教學:如何用 Foursquare API 和 Realm 打造一個咖啡店 App
In: Swift 程式語言

在本教學中,你將學會使用到如下技能:

  • 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 模板。

Four square api demo - 2

將文件命名為 Podfile(沒有擴展名),然后將它保存在 .xcodeproj 文件的同一目錄下。最後,確認一下 Tagets 列表下 Coffee 前面的選擇框已被正確勾選。

four square api demo - 3

然後,在 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 的終端窗口,將目錄切換到你的項目目錄。如果你不知道怎麼做,請遵循如下步驟:

  1. 打開終端程式
  2. 輸入 cd (c、d、空格)
  3. 打開 Finder
  4. 在 Finder 中找到你的項目目錄,但不要進到目錄裡面
  5. 從 Finder 窗口中將項目目錄拖拽到終端窗口
  6. 項目的絕對路徑將自動填寫到 cd 命令之後
  7. 敲回車
  8. 這樣你就將當前工作目錄切換到項目目錄下了

然後在終端窗口中輸入:

pod install

命令的執行大約要個幾分鐘時間,同時屏幕上將有大量信息滾動顯示。Cocoapods 將為你的項目安裝所需的庫。同時將你的項目轉換為工作空間(由多個項目組成)。

然後,在 Finder 中找到新生成的 .xcworkspace 文件並打開它。這個文件就位於你的項目的根目錄下。

注意:當你在 Xcode 中打開這個工作空間時,你的項目很可能是處於折叠狀態。你可以將項目文件恢復到原來的打開狀態 ── 關閉工作空間,然後再打開工作空間。這會導致項目文件不再是折叠狀態。

這就是你需要為本 App 的 Xcode 項目所進行的所有設置。如果一切順利,你應該擁有了一個包含了兩個項目的工作空間。其中 Pods 項目包含了 Realm 和 Das Quadrat 庫的程式碼。

用故事板創建 UI

Coffee 的 UI 非常簡單。它只有兩個 UI 元素:一個地圖視圖和一個表格視圖。

Xcode 已經為我們做了大量的工作。Single View Application 模板包含了一個故事板文件 Main.storyboard,它是 App 的入口。

要創建地圖視圖,需要:

  1. 打開 Main.storyboard
  2. 在 Xcode 右下角的 Object Library 窗口中,找到 Map Kit View (MKMapKitView)
  3. 將它拖到故事板編輯器中,並放到 View Controller 的左上角。讓它的高度大致等於 View Controller 的一半,寬度則完全佔滿。
  4. 在 Object Library 窗口中找到 Table View (UITableView) 並將它拖到故事板編輯器的 View Controller 上。讓它的寬度完全佔滿,高度則佔據 View Controller 的下半部。

接著為這兩個 View 設置自動佈局約束。首先,選中地圖視圖,點擊故事板編輯器右下角的 Pin 按鈕,這個按鈕位於右邊第二個位置,形如星戰中鈦式戰機 …

然後會彈出一個小窗口,接下來你需要做:

  1. 反選「Constrain to margins」。
  2. 點擊左、上、右三個 I 形線,讓它們依次變成紅色。
  3. 每個 I 形線旁邊都有一個輸入框,將它們分別設置為 0。
  4. 點擊「Add 3 constraints」

Setting Layout Constraints

接下來,在表格視圖上重複同樣的動作。但是將上面的 I 形線替換成下面的 I 形線(同時還有左和右)。同樣地,反選「Constrain to margins」選項,然後點擊「Add 3 constraints」按鈕。

我們讓兩個 View 分別相對於上對齊和下對齊,寬度都是父 View 的百分之百。還有一件事情,就是讓兩個 View 的垂直高度都等於整個屏幕高度的 50%。

我們可以用多個約束來實現這點,但這是最簡單的:

  1. 選中地圖視圖和表格視圖(按住 Command 鍵,然後分別點擊這兩個 View)。
  2. 點擊 Pin 按鈕。
  3. 勾選 Equal Heights 選項。
  4. 點擊「Add 1 constraint」。

現在 Xcode 可能會報錯說「有佈局衝突」。別擔心,我們會來修復它。

  1. 點擊地圖視圖,然後點擊 Pin 按鈕。
  2. 反選 「Constrain to margins」 選項,然後點擊下面的 I 形線,將它的值修改為 0。
  3. 點擊「Add 1 constraint」。

現在紅線消失了,IB 又開始顯示黃線。這表示有部份約束當前顯示不正確。所有的約束都是對的,僅僅是 IB 在顯示上不正確。

要解決這個問題,點擊黃色的帶圈的箭頭,這個圖標位於故事板編輯器 Document Outline 窗口的右上角。

點擊這個圖標后,會顯示一個新的界面。點擊黃色的三角,然後點 Update frames,再點擊 Fix misplacement。在每個黃色的三角上重複同樣步驟。當然,Update frames 的辦法並不是每次都有效,因此確保你的約束都創建正確,再讓你的 frame 也正確。

不幸的是,佈局約束經常會出現大量錯誤。如果你搞錯了某些事情,你可以從 Document Outline 窗口中將約束刪除,然後重建。

Fix Layout Issue

編譯 App 並修復錯誤

讓我們來運行一下 App。在開發的時候,我們經常需要運行 App,以檢驗我們的改動是否正確。

當你已經非常熟練的時候,你可以修改很多內容而不用檢查這些修改是否正確。通過本能來判斷自己有沒有做錯。但如果你還是一個新手,則儘量不要步子邁得太大。一次只解決一個問題,然後就檢查 App 是否能正常工作。如果出錯,你就會知道剛才修改的地方出錯了。這個道理很簡單。

要運行 App,這樣做:按下 Command + B 或者 Command + R。一個是編譯,一個是編譯並運行。在 Xcode 的左上角,你可以選擇在什麼樣的 iPhone 上運行 App。如果你將 iPhone 或者 iPad 連上 Mac,同時這些設備已經可以用於開發,則你也可以在這個地方選擇它們。

看一下 App 能運行嗎?

答案是不能運行!讓我們來找出問題並解決它。找到 Debug 窗口,它位於 Xcode 窗口的底部。在 Debug 窗口的右邊你會看到有一個錯誤。

Debug Console

如果你不能看到上圖的畫面,點擊右上角的按鈕和右下角的按鈕讓它顯示。

這個錯誤是:

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% 的錯誤信息不會告訴你怎麼解決問題。它只會告訴你發生了什麼,但問題的原因卻無法看到。

現在怎麼辦?你需要干兩件事情:

  1. 回家洗洗睡吧,再也不做 App 了
  2. 複製錯誤信息,然後貼到 Google 進行搜索 (建議你這樣做)

好吧,拷貝這段錯誤信息,然後在 Google 上搜索。這下你可能會搜索出這樣的結果:

google-search

點擊最上面的超鏈接。它會帶你到 StackOverflow,這是一個關於編程的 Q&A 網站。上面會有這個星球上幾乎所有語言的問題,以及這些問題的答案。

這就是你要做的,在 StackOverflow 上瀏覽答案:

  1. 查看這個問題是否有一個以上的回答。如果一個都沒有,繼續檢查另一個問題(通過 Google)。如果你找到了一個答案,能夠回答還先前沒有答案的問題時,你可以將這個答案順便添加到進去。
  2. 掃一眼最上面的原始的問題。看看標題,瀏覽一下內容以及下面的評論。這些評論經常會包含一些額外的信息。
  3. 挑出已經認可的答案,這些答案會用綠色的勾子進行標記。它的評論還是要看的,有時候評論比答案還有用。很多時候,已經認可的答案並不是最好的答案。檢查左邊的上下箭頭之間的數字,這個數字表示投贊成票的數字(即投「這是一個好答案」票)。已認可的答案不一定是最有用的,因此繼續深挖網頁上的其它內容。
  4. 對於這些答案,建議不要盲從,理解它的原理。學習編碼是一件很費時間的事情,但它對你的未來會有無窮的好處。幾乎每個程序員都會有知識上的盲區或者技術上的缺陷。如果你學會如何避免在未來犯錯誤,以及為什麼犯錯誤,你就會和全世界最頂尖的 1% 的程序員站在一起。

好了,到底是什麼原因出現的問題?答案是 MapKit 框架沒有添加到項目中。顯然,MKMapView 的程式碼是放在某個外部框架中了。這些程式碼必須被加到項目中來,雖然我們沒有直接在程式碼中使用過地圖視圖。

如果你看完了網頁,你會發現还有大量的反面例子,也會引發這個錯誤。

好了,該我們來解決這個問題了:

  1. 回到 Xcode,在项目导航窗口中点击項目屬性。項目屬性位於項目導航窗口的頂部。
  2. 點擊 Build Phases 標籤欄.
  3. 然後,點擊 Link Binary With Libraries 左邊的箭頭。這會顯示一個列表。
  4. 點擊列表底部的 + 號按鈕,會彈出一個窗口。
  5. 在文本框中輸入: mapkit,對列表進行篩選。
  6. 最後,雙擊 MapKit.framework。

這會將這個框架添加到 Link Binary With Libraries 列表和項目中。

Link with MapKit Framework

使用用戶的地理座標

你接下來的任務是 Geolocation,即將用戶的位置顯示到地圖上。

首先,需要將故事板中的地圖視圖連接到程式碼中。在我們新建項目時,會創建一個文件 ViewController.swift,這就是故事板中的 View Controller 放置程式碼的地方。

接下來檢查一下故事板和程式碼之間的這種連接是否存在:

  1. 打開 ViewController.swift 並查找這行程式碼:class …,也就是類的定義。這行程式碼指定了類的名字,它的父類以及它所實現的協議。在這裡,類名顯示為 ViewController。
  2. 然後打開 Main.storyboard 並點擊 Document Outline 窗口最上面的項目。這個項目就是「View Controller Scene」。
  3. 在右上角,點擊 Identity 檢查器。即從左到右第三個圖標。
  4. 最後,找到 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 連接。

  1. 首先, 打開 Main.storyboard。
  2. 然後, 在 Document Outline 窗口中選中 View Controller Scene 。
  3. 打開 Connections 檢查器。
  4. 檢查在列表中是否包含了 mapView 屬性。
  5. 最後,將 mapView 右邊的小圓圈拖到故事板編輯器中的地圖視圖上。

Creating The Outlet Connection

編寫第一個方法

好了,讓我們來編寫使用地圖視圖程式碼。首先,在 ViewController 類中加入:

override func viewWillAppear(animated: Bool)
{
    super.viewWillAppear(animated)

    if let mapView = self.mapView
    {
        mapView.delegate = self
    }
}

寫在什麼地方?任何地方!只要在類聲明的一對大括號 {} 之內。

所有方法都必須寫在類的範圍內。一個類的範圍從第一個左大括號 { 開始,到最後一個右大括號 } 結束。

這必須是平衡的,即每個左大括號 { 都必須配有一個對應的右大括號 }。而且,程序員需要使用縮進來表示範圍的級別。通常,每當一個左括號 { 之後就要使用一個 Tab 符或者四個空格表示一個縮進(在右括號之後則進行反縮進)。

Adding Code To ViewController

我們來看看這個方法:

  • 一個方法是一個程式碼塊,它被類所擁有。它通常由多個單獨的程式碼行構成,每個程式碼行負責完成一個指定的動作。方法既可以在類的內部被調用,也可以在 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()
    }
}

呃,這些程式碼是什麼意思?

  1. 首先,我們用 if 語句判斷 locationManager 是否為空。
  2. 然後實例化一個 CLLocationManager 對象並賦給 locationManager。換句話說:locationManager 現在保存了一個新的 CLLocationManager 對象的引用。我們只是創建了一個 CLLocationManager 對象,這個對象可以用於獲取用戶的位置。
  3. 然後,我們設置了 locationManager 的幾個屬性。delegate 屬性設為當前類,然後設置了 GPS 精度並調用 requestAlwaysAuthorization() 方法。這個方法會顯示一個窗口,用於向用戶獲得訪問 GPS 位置信息的權限。
  4. 最後,調用 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 會顯示這串文本。

要設置這串文本,你需要:

  1. 在項目導航窗口中,打開 Info.plist 文件。
  2. 在列表窗口中點擊右鍵,然後選擇 Add Row。
  3. key 輸入 NSLocationAlwaysUsageDescription。
  4. type 選擇 String。
  5. value 則輸入你在獲取權限時要顯示的文本!

Edit Info.plist

運行 App

現在我們來運行一下 App。確認你的 target 中選擇了一個 iPhone 模擬器,然後按下 Command + R。App 將啟動,並彈出一個需要授權的窗口,點擊「允許」。

Permission Popup

當你點擊「允許」后,地圖視圖並不會刷新。因為模擬器沒有 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

創建 Foursquare API Connector

現在,我們要編寫連接 Foursquare 的程式碼了。你將用單例模式來實現。單例模式適合于我們現在的場景。

一個單例是一個類實例,它是類僅有的實例。使用單例時,我們不能為一個類創建兩個實例。為什麼我們要用單例模式?對於單例的使用,一直有很大的爭議,但它對於這種情況是非常適用的:避免對一個外部資源出現多個併發的連接。

你可以想一下。如果你向一個 web 服務器發送兩個請求,同時這倆個請求都是向一個文件進行寫操作?數據將變得混亂不堪,除非 web 服務器讓其中一個請求比另一個請求優先執行。

一個單例保證只從 App 的一個地方請求外部資源。在單例模式下,大量的實現是為了保證不出現資源衝突。請求隊列和序列就是其中的一種實現,但這卻不在本教程的討論範圍。

你需要這樣做:

  1. 在項目導航窗口中右鍵點擊 Coffee 文件夾。
  2. 選擇 New File …。
  3. 選擇 iOS -> Source 下面的 Swift File 模板,點擊 Next。
  4. 將文件命名為 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()
        }
    }

這段程式碼好多!在這個方法中主要完成了五個任務:

  1. 首先,向 API 配置和發起了一個請求。
  2. 請求完成塊 (即閉包)。
  3. 解析請求返回的數據,並開始 Realm 事務。
  4. 用 for 循環遍歷所有「地標」數據。
  5. 在完成塊的最後,發送一個通知。

讓我們來逐行分析上述程式碼:

構建請求
首先,用一個可空綁定判斷 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 已經增加了一個可擴展的錯誤處理機制。大致流程如下:

  1. 進行某個危險的操作。
  2. 如果錯誤發生,拋出錯誤。
  3. 由調用這個危險操作的調用者俘獲這個錯誤。
  4. 調用者處理錯誤。

在大部份語言中這被稱作 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 類,你需要:

  1. 在 Coffee 文件夾上點擊右鍵,選擇 New File ….
  2. 在 iOS -> Source 下面選擇 Swift File,點擊 Next。
  3. 文件命名為 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,檢驗它是否工作正常。怎麼樣?干得不錯吧!

Foursquare Venue Data In Tokyo

在表格視圖中顯示地標數據

現在地圖已經完成了,如果再在表格視圖中顯示這些數據,整個 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,查看運行結果。如果一切順利,表格視圖中將出現地標數據。

Venue data in the table view

根據座標篩選地標數據

現在有一件奇怪的事情。表格視圖顯示了所有的數據!如果你先到日本,然後又來到舊金山,那麼在表格視圖中仍然會顯示日本的咖啡屋。

你當然不想這樣。因此,讓我們來施展 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 吧。運行起來了嗎?非常好!

Coffeeshops in Chiang Mai

注意: 不幸的是,如果你使用模擬器的 GPS 座標(東京、紐約、火奴魯魯)來抓取 Foursquare 數據,得到的數據會很少。如果你想查看更多的數據,可能需要註釋掉 CoffeeAPI 中的硬編碼的 category 類型或者模擬一個附近有較多咖啡屋的座標!

你覺得這篇教學怎樣?請在下面寫下你的評論和想法。

譯者簡介:楊宏焱,CSDN 博客專家(個人博客 http://blog.csdn.net/kmyhy)。2009 年開始學習蘋果 iOS 開發,精通 O-C/Swift 和 Cocoa Touch 框架,開發有多個商店應用和企業 App。熱愛寫作,著有多本技術專著,包括:《企業級 iOS 應用實戰》、《iPhone & iPad 企業移動應用開發秘笈》、《iOS8 Swift 編程指南》,《寫給大忙人看的 Swift》(合作翻譯)等。

原文Building a Coffee Shop App with Swift, Foursquare API and Realm

作者
De Vries Reinder
Reinder de Vries 是創業家也是手機程式開發員,所開發的Apps超過50個,編寫的程式有多達十萬人次下載使用。Reinder 熱心教學,自設平台 LearnAppMaking.com 培訓更多有志之士成為手機程式開發員。
評論
更多來自 AppCoda 中文版
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。