你好,歡迎來到IOS教程網

 Ios教程網 >> IOS編程開發 >> IOS開發基礎 >> iOS 8 Handoff 開發指南

iOS 8 Handoff 開發指南

編輯:IOS開發基礎

(原文:Working with Handoff in iOS 8 作者:Gabriel Theodoropoulos 譯者:半圓圓)

我想用下面這一個美妙的場景來開始這篇教程:假象一下你正在Mac上用某應用做一件事(比如創建一個演示文稿或創作一幅畫作),然後你打算躺在臥室的床上用iPad繼續做同一件事。過了一會兒,你得出去了,但是你仍然可以在你的iPhone上用同一個應用繼續工作。簡單來說,無論你在哪裡,你都可以不間斷的做你想做的事。這聽起來是不是很酷?而且,這在現在看來完全是可行的,但問題是,如何實現呢?

iOS 8加入了一種全新的功能,叫做Handoff。Handoff的功能簡單明確;我們可以在一部iOS設備的某個應用上開始做一件事,然後在另一台iOS設備上繼續做這件事,只要所有的設備都運行著最新的操作系統。而且最新的Mac OS X Yosemite系統也支持這種功能。

handoff-featured.jpg

Handoff的基本思想就是:用戶在一個應用裡所做的任何操作都包含著一個activity,一個activity可以和一個特定用戶的多台設備關聯起來。用行話來說,抽象出這種activity的類叫做NSUserActivity,大部分時間我們都會和這個類打交道。需要一提的是,所有的設備都必須靠近(靠近是指兩台設備的藍牙能夠彼此連接),這樣Handoff才能正常工作。而且還有兩個先決條件得滿足:第一個條件是得有一個能正常使用的iCloud賬號,而且用戶應該在每台准備使用Handoff的設備上登陸這個iCloud賬號。事實上,當在不同的設備上切換時,為了保證正在進行的activity不被中斷而且被關聯到同一個用戶,應該盡可能地在所有設備上使用同一個iCloud賬號。第二個條件是當兩個或兩個以上不同的應用想要在同一個用戶activity進行Handoff的操作時需要具備的,在這種情況下,所有涉及到的應用必須使用Xcode裡相同的團隊標識(Team ID)簽名。

如果必要的話,一個應用理論上可以擁有任意多個用戶activity,這樣當在另一台設備上接著做一件事時才不會出現中斷和數據的損失。拿一個筆記本應用來舉個例子,撰寫一份筆記是一個activity,而查看筆記是另一個activity。在用戶activity數量上不存在明確的限制。總的來說,為了在應用裡做的每一種事都能在另一台設備上持續下去,應用應該支持盡可能多的activity,只要這些事從理論上來說是不同的。

當編寫一個支持Handoff的應用時,需要關注以下三個交互事件:

  1. 為將在另一台設備上繼續做的事創建一個新的用戶activity。

  2. 當需要時,用新的數據更新已有的用戶activity。

  3. 把一個用戶activity傳遞到另一台設備。

我們將會在這個教程的樣例應用中看到上面的三個交互事件。

每個用戶activity都會用一個activity type來唯一標識,此activity type就是一個用來描述這個activity的字符串。在編寫Handoff代碼之前,必須先在項目的.plist文件裡加入一個指明所有activity type的新項,這樣應用才會知道應該支持怎樣的activity。當實現Handoff特性時,也會在代碼裡使用這些activity type。蘋果公司推薦使用反轉域名風格的字符串來命名activity type, 比如:com.appcoda.handoff.tutorial.view。待會我們將看到如何在.plist文件和代碼裡使用activity type,以及如何給將會支持的activity type取一個合適的名字。

通常來講,使用Handoff在設備之間傳輸的數據必須是小容量的,這樣用戶才能幾乎即時地在另一台設備上繼續。Handoff也支持流傳輸(Stream),當傳輸的數據量相對較大時就應該使用流傳輸。但是在這篇教程中我們不會涉及到它,我們只會涵蓋Handoff的基本功能以及如何實現這些功能。

需要注意的是Handoff相關的測試只能在真實設備上進行,所以你得有至少兩台運行著iOS 8.0或以上系統的設備。不管是多台iPhone,多台iPad或者同時擁有iPhone和iPad都可以。

上面說的這些就是對Handoff這種技術你應該了解的基本知識。我強烈推薦你花些時間來閱讀一下蘋果官方文檔,或者觀看WWDC 2014的219號會議視頻,這樣你會對接下來要講的東西有一個更深的理解。

演示應用概覽

無論是基於文檔(document-based)還是非基於文檔的應用都支持Handoff。為了簡單展示如何實現Handoff功能,我們將會把重點放在第二種情況(非基於文檔的應用)。本教程的樣例應用是一個非常簡單的擁有以下三個不同功能的聯系人應用:

  1. 添加一位新聯系人。

  2. 查看所有的聯系人。

  3. 查看一位聯系人的詳細信息。

當然,這只是一個演示應用,所以應用可以添加的只有聯系人的以下基本信息:

  • 名字(First name)

  • 姓氏(Last name)

  • 電話號碼(Phone number)

  • 電子郵箱(E-mail)

而且,我們也不會加入編輯聯系人的功能。我們的目標不是編寫一個功能完善的聯系人應用,只要能簡單地展示以下Handoff的功能就行啦。你可能想得到,這將會是一個基於導航的應用。下面的圖片大體上展示了這個應用:

Handoff-demo.png

為了節省時間,我會為你提供一個啟動項目。你可以在接下來的部分裡找到關於這個項目的更多細節,但是可以提前告訴你的是這個項目裡只包含了用界面構建器(Interface Builder)設計的用戶界面。我們將會在接下來的小節裡一步一步的加入需要的代碼。

一旦這個樣例應用的基本功能搭建完成了,我們將會接著實現Handoff功能。我們將會在.plist文件裡加入必要的項(activity type),接著我們會在需要的時候創建或者更新用戶activity。最後我很將加入合適的代碼實現來支持activity的可持續性。

先來詳細說一下activity type,我們將會有兩個activity type,相應的我們會支持兩種用戶activity:一種是添加聯系人,另一種是查看某個聯系人的詳細信息。這兩個activity type將會分別命名為com.appcoda.handoffdemo.edit-contact和com.appcoda.handoffdemo.view-contact 。我想你們可能已經猜到這個應用將會取名為HandoffDemo。

現在既然你對我們接下來將會做的事有了一個大體的了解,那麼就讓我們繼續吧。在接下來我們走完這個流程的過程中,我會為你提供關於這個應用的更多詳細信息。

起點

就像我剛才所說的,我們不會從頭編寫一個新的應用。取而代之,我會為你提供一個可以快速入手的啟動工程,你可以點擊下載 (需翻牆)。

下載完成後,用Xcode打開,然後看一下用戶界面文件,注意到除了我為一個view controller過渡到另一個view controller添加的普通轉場(segue),還有返回轉場(unwind segue),我特意的創建了這些返回轉場,因為待會你會發現我們需要在這些view controller退出的時候做一些事。

在工程裡有三個view controller類,在默認創建的ViewController類裡我們將會用列表顯示所有已經添加的聯系人。在EditContactViewController類裡我們將會實現添加一個新聯系人的功能,同時在ViewContactViewController類裡我們會顯示某個選定聯系人的詳細信息。

你會看到在啟動工程裡Xcode顯示了兩個錯誤,一個在ViewController.swift文件裡,另一個在ViewContactViewController.swift文件裡。別擔心,這是因為表格視圖的委托協議和數據源協議裡的方法還沒有添加進去。教程的下一部分將會加入這些方法並實現它們,到時這些錯誤自然會消失。

最後要說的是,這個應用將會是一個通用(Universal)應用,也就是說在iPhone和iPad上都能運行。

當你對這個啟動工程比較熟悉後,那麼就繼續看下一部分吧。

一個輔助類

開始編寫代碼時,我們將會創建一個非常簡單的類。這個類將會用來抽象出一個聯系人來保存他的的詳細信息。它將用一些成員變量來代表這些詳細信息和一些便利方法來保存和載入聯系人以及把一個個的屬性轉換成一個字典對象(dictionary)和從一個字典對象創建一個聯系人。

首先,我們必須創建一個新文件,選擇Xcode的 File > New > File…菜單。在iOS節點的Source類目裡選擇Cocoa Touch Class模板然後點擊下一步。

t23_4_add_class.png

在下一個窗口裡在Subclass of:一欄選擇NSObject,然後給新的類命名為Contact。

t23_5_add_class2.png

最後點擊Create按鈕創建這個類。

接著,打開新創建的文件把以下幾行內容添加進去:

class Contact: NSObject {

    var firstname: NSString?

    var lastname: NSString?

    var phoneNumber: NSString?

    var email: NSString?

    let documentsDirectory: NSString?
}

首先,我們把父類設為NSObject,因為待會我們會用到這個類提供的一些方法。當然,如你所見,我們有四個變量來保存聯系人的詳細信息,還有最後一個變量來表示本應用的文檔目錄(documents directory)。下一步我們會定義初始化方法(init method),並為最後一個變量賦值。如下所示:

override init() {
    let pathsArray = NSSearchPathForDirectoriesInDomains(NSSearchPathDirectory.DocumentDirectory, NSSearchPathDomainMask.UserDomainMask, true)
    documentsDirectory = pathsArray[0] as String
}

我們會需要用到這個文檔目錄的路徑,因為待會兒我們會實現兩個非常有用的方法來保存和載入聯系人的詳細信息。但是在這之前,讓我們先來寫另外兩個方法。第一個就是下面這段代碼所示的把聯系人的詳細信息對應的屬性轉化為一個字典對象的方法,然後返回這個字典對象。

func getDictionaryFromContactData() -> Dictionary {
    var dictionary: [String: String] = ["firstname": firstname!, "lastname": lastname!, "phonenumber": phoneNumber!, "email": email!]

    return dictionary
}

第二個方法正好把這個過程反過來,它會從一個字典對象中提取出聯系人的詳細信息然後分別對各個屬相賦值。

func getContactDataFromDictionary(dictionary: Dictionary) {
    firstname = dictionary["firstname"] as? String
    lastname = dictionary["lastname"] as? String
    phoneNumber = dictionary["phonenumber"] as? String
    email = dictionary["email"] as? String
}

上面的兩個方法不僅對這個類本身,而且在接下來的handoff實現中都會非常有用。

現在,讓我們實現一個方法來把聯系人保存到文檔目錄的一個文件裡。先看一下下面的實現代碼,然後我會解析一下這些代碼:

func saveContact() {
    let contactsFilePath = documentsDirectory?.stringByAppendingPathComponent("contacts")

    var allContacts = loadContacts()

    allContacts.addObject(self)

    var allContactsConverted = NSMutableArray()

    for var i=0; i < allContacts.count; ++i{
        allContactsConverted.addObject(allContacts.objectAtIndex(i).getDictionaryFromContactData())
    }

    allContactsConverted.writeToFile(contactsFilePath!, atomically: false)
}

讓我們看一下在上面這個代碼段裡發生了什麼。一開始我們設定了聯系人文件的路徑。很明顯文件名是 contacts 。接著從這個文件裡把所有的已有聯系人載入到一個變量,所使用到的這個方法我們待會兒就會實現。你會發現, loadContacts 方法將會返回一個包含所有聯系人對象的 NSMutableArray數組或者一個僅僅初始化的(不包含任何聯系人)數組,不管是哪種情況,我們都會添加一個新對象(self)到這個數組。

接下來我們會使用 NSArray類的writeToFile方法把這個數組的聯系人保存到一個文件。這個方法會創建一個屬性列表文件(plist)。但是,數組裡包含的聯系人必須是和屬性列表兼容的對象,比如NSString, NSData, NSDictionary 或者 NSArray。在當下這種情況下allContacts數組裡包含的對象是Contact類型的,這意味著上面提到的把數組裡的對象寫到文件的方法無法正常工作。所以解決方法就是把數組裡包含的對象轉換成一種能夠保存在屬性列表文件裡的對象,這不正好可以使用我們為這個類寫的第一個方法嗎?把所有的已有對象轉換成字典對象。這就是上面的那個循環裡所做的事。最後,一旦allContacts裡的所有聯系人對象轉換完成然後加入allContactsConverted數組後,allContactsConverted裡的對象就會被寫入指定的路徑。

現在,我們也可以從文件裡載入聯系人,只需執行大致相反的步驟就行了:

func loadContacts() -> NSMutableArray {
    let contactsFilePath = documentsDirectory?.stringByAppendingPathComponent("contacts")

    var allContacts = NSMutableArray()

    if NSFileManager.defaultManager().fileExistsAtPath(contactsFilePath!) {
        let savedContactsArray = NSMutableArray(contentsOfFile: contactsFilePath!)

        for var i=0; i < savedContactsArray?.count; ++i{
            let tempContact = Contact()
            tempContact.getContactDataFromDictionary(savedContactsArray?.objectAtIndex(i) as Dictionary)
            allContacts.addObject(tempContact)
        }
    }

    return allContacts
}

在上面的實現代碼裡,先指定這個聯系人文件的路徑,然後初始化一個可變數組對象。如果聯系人文件存在,我們會把文件裡的內容載入另一個臨時數組,然後把這個臨時數組裡的每一個字典對象轉換為一個聯系人對象。然後在一個循環裡把這些聯系人對象加入allContacts數組,最後,返回這個包含著聯系人對象的數組。

最後,我們還會添加一個類方法:

class func updateSavedContacts(contacts: NSMutableArray) {
    let documentsDirectory = NSSearchPathForDirectoriesInDomains(NSSearchPathDirectory.DocumentDirectory, NSSearchPathDomainMask.UserDomainMask, true)[0] as String
    let contactsFilePath = documentsDirectory.stringByAppendingPathComponent("contacts")

    var contactsConverted = NSMutableArray()

    for var i=0; i < contacts.count; ++i{
        contactsConverted.addObject(contacts.objectAtIndex(i).getDictionaryFromContactData())
    }

    contactsConverted.writeToFile(contactsFilePath, atomically: true)
}

在這個方法裡,我們將會用一個包含著參數裡傳入的數組裡的聯系人的文件替換掉已經存在的聯系人文件。這個方法的實現細節和先前的很相似,但這個方法在刪除聯系人時會非常有用。

我們的類已經寫好了,在接下來的部分裡我們將會一直使用它,因為它在處理聯系人數據方面非常方便。

編輯聯系人

我們會接著實現聯系人的編輯功能來進一步完善我們的應用。首先,在項目導航器裡選擇EditContactViewController.swift文件,現在,在啟動工程裡,除了Xcode生成的默認代碼,只加入了一些已連接的IBOutlet屬性和一個叫做saveContact的IBAction方法。為了讓編輯聯系人的功能可以正常工作,我們還必須再做兩件事:

  1. 我們必須讓鍵盤上的Return按鈕在按下時讓鍵盤消失。

  2. 我們必須實現saveContact這個IBAction方法,讓新添加的聯系人能夠保存。

並且,我們還得創建一個新的協議(protocol),以便讓ViewController這個父視圖控制器能夠在新聯系人被保存時收到通知。必須這樣做來把新添加的聯系人加入聯系人列表。

讓我們先把Return按鈕的問題解決了吧。為了讓Return按鈕正常工作,我們得先在文件的頭行添加一個UITextFieldDelegate協議,如下所示:

class EditContactViewController: UIViewController, UITextFieldDelegate

然後在viewDidLoad方法裡把自身(self)設為所有本視圖控制器的視圖裡出現的文本框的委托:

override func viewDidLoad() {
    super.viewDidLoad()

    txtFirstName.delegate = self
    txtLastName.delegate = self
    txtPhoneNumber.delegate = self
    txtEmail.delegate = self
}

最後,實現下面的文本框委托方法:

func textFieldShouldReturn(textField: UITextField) -> Bool {
    textField.resignFirstResponder()

    return true
}

可以看到,我們在這個委托方法裡所做的唯一事情就是讓文本框放棄第一相應對象(first responder),不用管這是哪一個文本框發來的消息。

現在,我先提前告訴你以後我們還會回到這個方法來,因為在實現handoff功能的時候,我們會在這個方法裡添加一條命令,我說的是以後。。。

現在,在我們保存聯系人之前,最好先定義一下剛才說到的自定義委托。在這個委托裡,我們只聲明一個委托方法。通過委托的方式,我們可以在新聯系人保存的時候通知父試圖控制器,也可以把這個聯系人當做參數傳出去。

看一下這個文件的頭幾行,就在導入UIKit命令之後,類聲明之前,加入下面幾行:

protocol EditContactViewControllerDelegate{
    func contactWasSaved(contact: Contact)
}

然後在類主體裡,聲明以下委托變量:

var delegate: EditContactViewControllerDelegate?

好了,這個協議已經寫好了,接著讓我們來實現IBAction方法吧。焦點轉到saveContact方法的方法體裡。我們將第一次用到這個教程的之前小節裡創建的類。首先,我們會創建一個這個類的對象來存儲文本框裡的數據,如下所示,當然,永久地保存這個新聯系人。

@IBAction func saveContact(sender: AnyObject) {
    var editedContact = Contact()

    editedContact.firstname = txtFirstName.text
    editedContact.lastname = txtLastName.text
    editedContact.phoneNumber = txtPhoneNumber.text
    editedContact.email = txtEmail.text

    editedContact.saveContact()
}

接著,調用自定義的委托方法來通知父視圖控制器一個新聯系人已經創建並且已保存:

@IBAction func saveContact(sender: AnyObject) {
    ...    

    self.delegate?.contactWasSaved(editedContact)
}

最後,只需退出(pop)當前試圖控制器。如果你看一下你下載的啟動工程的用戶界面文件,特別是那些轉場(segue),你肯定會發現我為EditContactViewController和ViewContactViewController兩個視圖控制器都創建了返回轉場(unwind segues)(用來退出視圖控制器的轉場)。一開始你可能覺得奇怪,但是我加入這些轉場有一個明確的原因:當這兩個視圖控制器退出時,我想要完全的控制權,因為在那個時候,我們需要停止handoff運行。現在我們還不會討論這些,當保存了新的聯系人之後就讓視圖控制器退出吧:

@IBAction func saveContact(sender: AnyObject) {
    ...

    self.performSegueWithIdentifier("idUnwindSegueEditContact", sender: self)
}

在這個視圖控制器裡需要做的已經暫時結束了,但只是暫時結束。當我們實現handoff功能的時候還會回到這個視圖控制器的,因為還有許多事需要我們去做。除此之外,在下一部分裡我們將會探討如何使用在這裡創建的委托方法。

列表顯示聯系人

當我們的應用啟動的時候我們想要在一個表格視圖裡顯示所有的聯系人。那麼,我們必須從磁盤(文檔目錄)裡載入所有保存的聯系人,然後把它們顯示出來。但是別忘了,我們必須也實現contactWasSaved這個委托方法。

讓我們開始吧。首先打開ViewController.swift文件。在類的開頭加入如下的聲明:

var contactsArray: NSMutableArray!

然後,定義如下用來載入聯系人的方法:

func loadContacts(){
    let contact = Contact()
    contactsArray = contact.loadContacts()
}

當然,別忘了在viewDidLoad方法裡調用這個方法,如下所示:

override func viewDidLoad() {
    super.viewDidLoad()

    loadContacts()
}

現在我們知道每次ViewController的視圖載入後,contactsArray數組或者會包含所有的聯系人(Contact類的實例),或者只是一個簡單初始化的數組。現在我們來重點關注一下必須實現的幾個表格視圖方法。你可能注意到我在啟動工程裡已經加入了UITableViewDelegate和UITableViewDatasource協議,而且把ViewController設為了表格視圖的委托和數據源。那麼我們先從幾個簡單地方法開始吧:

func numberOfSectionsInTableView(tableView: UITableView) -> Int {
    return 1
}


func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return contactsArray.count
}


func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
    return 60.0
}

我敢肯定每個人都明白上面的三個方法裡的代碼寫的什麼,所以我不打算說太多。

唯一有趣的表格視圖方法是接下來的這個,在這個方法裡我們會從隊列裡抽出一個標示符(identifier)為idCellContact的模板單元格(prototype cell),然後相應的從contactsArray數組裡提取出一個Contact對象來得到它的名字和姓氏:

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    var cell = tableView.dequeueReusableCellWithIdentifier("idCellContact") as UITableViewCell

    let contact = contactsArray.objectAtIndex(indexPath.row) as Contact
    cell.textLabel.text = contact.firstname! + " " + contact.lastname!

    return cell
}

如你所見,我們只展示了每個聯系人的名字和姓氏。可以看到,上面所示的把名字和姓氏兩個字符串連接起來已經足夠簡單。

說起表格視圖方法,何不實現另一個便捷方法來加入刪除聯系人的的功能呢?如下所示:

func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
    if editingStyle == UITableViewCellEditingStyle.Delete {
        deleteContactAtIndex(indexPath.row)
    }
}

deleteContactAtIndex方法是我們接下來會實現的一個方法。在這個方法中,我們傳入一個待刪除聯系人的索引(index)作為參數,看一下這個新方法:

func deleteContactAtIndex(index: Int){
    contactsArray.removeObjectAtIndex(index)
    Contact.updateSavedContacts(contactsArray)
    tblContacts.reloadData()
}

首先我們從數組中移除對應的聯系人對象,然後調用Contact類的updateSavedContacts類方法把剩下的聯系人(如果還有的話)存回文件。最後,重載表格視圖數據來更新這些改動。

先前也說過,我們還得實現contactWasSaved這個委托方法。在這個方法裡,我們只是把新聯系人加入聯系人數組,然後重載表格視圖數據。它的實現相當簡單,來看一下:

func contactWasSaved(contact: Contact) {
    contactsArray.addObject(contact)

    self.tblContacts.reloadData()
}

另外,我們可以再次從文件中載入聯系人。

現在在類文件的頭行加入EditContactViewControllerDelegate協議:

class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, EditContactViewControllerDelegate

上面的一步是必須的,但我們還沒有結束。必須還把ViewController設為EditContactViewController實例的委托,當使用轉場時,這件事一般會在prepareForSegue(segue:sender:)方法裡完成。每次EditContactViewController視圖控制器將要出現時這個方法就會被調用一遍,我們的目標就是從轉場裡得到這個視圖控制器的實例然後把自己設為它的委托,如下所示:

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
    if segue.identifier == "idSegueEditContact"{
        var editContactViewController = segue.destinationViewController as EditContactViewController

        editContactViewController.delegate = self
    }
}

我們對ViewController.swift類的最初實現如今已經完成了,現在可以繼續下一部分,我們將會構建查看聯系人詳細信息的功能。

查看聯系人詳細信息

ViewContactViewController視圖控制器只會用來展示選中聯系人的詳細信息。除此之外,就再沒其他功能了,雖然現在只是為了給讀者做個演示,但我還是想說編寫這樣一個視圖控制器可能沒多大意義。但不管怎樣,這個視圖控制器為我們探索Handoff的功能提供了機會,因此在這一章節,我們會簡單地實現它,過後會再加入對Handoff的支持。

當某個聯系人在ViewController視圖控制器裡被選中後,它的詳細信息將會作為一個對象傳給ViewContactViewController視圖控制器,要這麼做的話,我們得先做一些准備,然後傳入一個合適的對象。打開ViewContactViewController.swift文件,在類主體的開頭加入下面的聲明:

var contact: Contact!

本類將會用這個變量來展示(待會還會用來處理)聯系人的詳細信息。

在這個應用的啟動工程裡我已經加入了和表格視圖相關的協議,而且我把這個類設為了表格視圖的委托和數據源。也就是說我們只需要寫少量必要的方法表格視圖就可以正常地顯示聯系人信息了。

在下面的代碼裡,你可以看到沒什麼特別難懂的部分。所以考慮到時間問題,我就一次性把這四個方法都貼出來吧。

func numberOfSectionsInTableView(tableView: UITableView) -> Int {
    return 1
}


func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    if let validContact = contact{
        return 4
    }
    else{
        return 0
    }
}


func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    var cell = tableView.dequeueReusableCellWithIdentifier("idCellContact") as UITableViewCell

    switch indexPath.row{
    case 0:
        cell.textLabel.text = contact.firstname!
        cell.detailTextLabel?.text = "First name"

    case 1:
        cell.textLabel.text = contact.lastname!
        cell.detailTextLabel?.text = "Last name"

    case 2:
        cell.textLabel.text = contact.phoneNumber!
        cell.detailTextLabel?.text = "Phone number"

    default:
        cell.textLabel.text = contact.email!
        cell.detailTextLabel?.text = "E-mail"
    }


    return cell
}


func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
    return 60.0
}

在返回表格視圖需要的行數時,我們必須確保聯系人對象不為空(nil)。因此用了一個自選的綁定(optional binding)來保證將在表格視圖中展示的聯系人是存在的。而且在tableView:cellForRowAtIndexPath這個表格方法中你會發現除了把每個表格單元格的文本標簽內容設為相應的聯系人信息,我們還為這個單元格的二級文本標簽設置了一個具有描述性的子標題。

現在再次回到ViewController.swift文件,讓我們加上把選中的聯系人傳給ViewContactViewController的代碼。首先,我們得弄清楚到底是哪個聯系人被選中了,簡單來說,就是相關聯系人在contactsArray數組中的索引。來到聲明成員變量的地方,再加入下面的一個變量:

var indexOfContactToView: Int!

實際上,這個變量會用來存儲被選中的單元格的行索引。但是要得到這個索引我們就得實現tableView:didSelectRowAtIndexPath這個表格視圖的委托方法。這個方法的代碼也很簡單,得到選中的單元格的索引然後執行相應的轉場(segue)來轉換到ViewContactViewController視圖控制器。

func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
    indexOfContactToView = indexPath.row

    self.performSegueWithIdentifier("idSegueViewContact", sender: self)
}

上面的這個轉場(segue)非常重要,因為在真正轉換到下一個視圖控制器之前,prepareForSegue這個方法會被調用,正好可以在這個方法裡傳入選中的聯系人對象。

來到prepareForSegue方法,加入下面的幾行:

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
    ...

    if segue.identifier == "idSegueViewContact"{
        var viewContactViewController = segue.destinationViewController as ViewContactViewController

        viewContactViewController.contact = contactsArray.objectAtIndex(indexOfContactToView) as Contact
    }
}

這就是我們為當一個聯系人被選中時顯示他的詳細信息所要做的最後一步。現在你可以運行一下這個應用看看它工作的怎麼樣。從這之後,我們將只會關注Handoff功能的實現。

配置Plist文件

我在本教程的開頭說過,handoff的邏輯是基於用戶activity這個概念的,從編程角度上來說,負責處理用戶activity的類叫做NSUserActivity。而且,我也提到過每個activity有一個或多個activity type:一個用來在不同設備和應用之間唯一標識一個特定activity的反轉域名風格的簡單字符串。

最後一點尤其重要,因為通過activity type應用可以判斷哪種activity可以被共享以及在其他的設備上接著進行。作為開發者,在編寫支持Handoff的應用時,你首先得考慮的就是定義你的activity type。你要始終記得一個activity type必須是獨一無二的,所以給每個activity type命名時都要再三考慮。

為了讓應用知道所有的可以被遞交(原文為be handed off,簡單起見譯為被遞交,指可以在另一台設備上繼續做你未完成的事)的activity type,Info.plist文件裡必須加入一個包含所有activity type的列表,在Info.plist文件裡添加一個新項。這個項是一個有特別命名鍵的數組。

在項目導航器裡,打開Supporting Files組,點擊Info.plist文件來打開它。

t23_6_info_plist.png

接著,選擇Editor > Add Item菜單,或者直接點擊如下所示的add小按鈕來添加一個新項:

t23_7_add_plist_item.png

這個項的鍵必須命名為NSUserActivityTypes。檢查一下你有沒有打錯字母,最好直接粘貼復制。項的類型是數組類型的。

在你命好名,選擇了正確地類型後,接下來就給這個它添加兩個子項。兩個都必須是字符串類型的,它們將會代表我們想要支持的用戶activity的activity type。我們會添加一個用戶activity來表示編輯聯系人,和另外一個來表示查看某個聯系人的詳細信息。所以給這兩個子項分別賦值為:

com.appcoda.handoffdemo.edit-contact
com.appcoda.handoffdemo.view-contact

上面的字符串是反轉域名風格的,而且也是獨一無二的。通常你應該遵循下面兩種命名格式中的一種:com.yourcompany.somethingUnique或者com.yourcompany.appName.somethingUnique。如果可能的話,你可以完全不管上面提到的這種格式來命名,但是這種風格的命名是蘋果公司建議的方式。

t23_8_plist_complete.png

保存文件就完成了。為了保證Handoff能正常工作,必須在plist文件裡定義activity type。從現在開始,我們就可以開始 hand off了!!

在編輯聯系人時遞交(Handing Off)

當在應用中實現Handoff功能時,你需要敲的第一句代碼就是創建一個新的用戶activity,換句話說,創建一個NSUserActivity對象,然後對這個對象進行配置。但是在寫代碼之前,我得先說一件很重要的事。

在iOS裡handoff功能是基於UIKit框架的。從8.0版本開始,UIResponder類新加了一個叫做userActivity的屬性用來封裝為響應對象(responder,例如視圖控制器)定義的用戶activity,和幾個我們待會兒就會用到的方法。這就意味著下面兩件事:第一,即使你沒有聲明相關的屬性,你也可以訪問self.userActivity,這樣做完全沒有問題,因為它是從UIResponder繼承下來的。第二,不要再定義一個例如var userActivity: NSUserActivity?這樣的屬性,編譯器會報錯的,因為你不能重復聲明一個相同的屬性,userActivity這個名字也不可用。

說了這麼多,現在讓我們打開EditContactViewController.swift這個文件吧。首先,我們會定義一個用來創建用戶activity的新方法。初始化之後,還可以給這個activity設置一個標題,但最重要的是要設置一個字典對象,這個字典對象將會包含需要遞交出去的數據。在下面的這個方法裡我們並沒用指定這樣一個字典對象,原因很簡單:我們會把它留到視圖載入後再做,但是在那個時候並沒有什麼數據需要傳送,那麼是不是說創建一個用戶activity就沒有意義呢?並不是這樣的,因為不管在什麼情況下,你都必須初始化這個activity。

func createUserActivity() {
    userActivity = NSUserActivity(activityType: "com.appcoda.handoffdemo.edit-contact")

    userActivity?.title = "Edit Contact"

    userActivity?.becomeCurrent()
}

可以看到,在初始化的時候我們指明了對應的activity type。確保在activity初始化時使用的字符串和你在plist文件裡添加的字符串其中的一個匹配。最後一步也很重要,同時也是必須的,正是這一步才讓這個activity可以被遞交。

上面的這個方法必須在某個地方調用,你可能會想很明顯可以在viewDidLoad方法裡調用。但是我們我們不會這麼做,為什麼呢?待會兒你就會知道,因為我們會用UIResponder類一個專門的方法來把需要繼續下去的activity傳給這個視圖控制器,然後就會賦值給視圖控制器的userActivity屬性。這些過程會在視圖控制器被載入之前就發生,那麼問題來了,如果我們在viewDidLoad方法裡調用createUserActivity方法,userActivity這個屬性就會被再次初始化,這會導致我們失去處理傳入的activity的機會。

所以考慮到上面這些,我們會在viewDidAppear方法裡調用createUserActivity這個方法,如下所示:

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

    createUserActivity()
}

注意到如果只是簡單地創建一個activity的話,handoff根本不會工作,因為沒有數據傳遞。

現在的問題是當我們在編輯聯系人的時候,應該選在何時把數據傳送出去。在我們創建的這個演示應用裡,一個簡單可行的解決方法就是在每次用戶按下鍵盤上的Return按鈕之後。如果你記性好的話,我們已經實現了幾個文本框的委托方法(textFieldShouldReturn),所以我們可以直接使用,但不是現在!

UIResponder的和用戶activity相關的方法中其中一個是updateUserActivityState: ,這個方法扮演的角色很簡單,就像方法名暗示的那樣:更新用戶activity的狀態,在這我們用它來把需要遞交的數據傳遞給activity對象。只要有需要,這個方法可以多次被調用,所以我們應該覆蓋這個方法來加入相應的實現代碼。先看一下這個方法,待會會詳細解析一下它:

override func updateUserActivityState(activity: NSUserActivity) {
    let contact = Contact()

    contact.firstname = txtFirstName.text
    contact.lastname = txtLastName.text
    contact.phoneNumber = txtPhoneNumber.text
    contact.email = txtEmail.text

    activity.addUserInfoEntriesFromDictionary(contact.getDictionaryFromContactData())

    super.updateUserActivityState(activity)
}

現在來解析一下吧。之前我說過用戶activity會接收一個包含需要遞交的數據的字典(一個叫做userInfo的字典對象)。我們會用到NSUserActivity類提供的一個便捷方法來做這件事,這個方法是addUserInfoEntriesFromDictionary: 我們會用在Contact類裡實現的getDictionaryFromContactData這個方法返回的值來作為本方法的參數。調用這個方法會返回一個根據聯系人的各個屬性創建的字典對象。

特別注意:要記住每次調用上面方法的時候,activity對象的userInfo字典都是空的。所以別在它上面追加數據,要做的只是把需要遞交的數據傳給activity。

總結一下,在上面的方法中我們創建了一個臨時的新聯系人對象,然後把來聯系人的詳細信息分別賦值給相應的屬性,然後把它轉換為一個字典對象後加入activity,最後遞交出去。也不要忘記調用父類的updateUserActivityState:方法,因為這裡我們覆蓋了這個方法。

現在,在回到Contact類去編寫那些提前調用的還沒實現的方法之前,最好先確定一下應該在哪裡調用updateUserActivityState: 方法。我們已經說過,會在textFieldShouldReturn: 委托方法裡調用,所以到這個方法裡下面幾行:

func textFieldShouldReturn(textField: UITextField) -> Bool {
    ...

    userActivity?.needsSave = true

    return true
}

NSUserActivity類的needsSave屬性實際上是一個用來判斷用戶activity對象的狀態是否需要更新的信號旗(flag),如果它為true的話,就說明狀態需要更新,然後就會遞交新數據。當然,除了使用needsSave屬性,你也可以在上面的方法中加入下面的一行:

updateUserActivityState(self.userActivity!)

這行代碼也是個不錯的選擇。

最後,我們需要對用戶activity做的一件事是當視圖控制器從導航控制器裡出棧時停止掉它,確切的說就是當返回轉場將被執行的時候。來到prepareForSegue方法(如果它默認被注釋掉的話,就刪除注釋),加上下面的幾行代碼:

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject!) {
    if segue.identifier == "idUnwindSegueEditContact"{
        self.userActivity?.invalidate()
    }
}

通過調用activity對象的invalidate方法,實際上我們會停止當前視圖控制器的遞交操作。

那麼在這裡做一個快速的概括,我們實際上做了四件事:

  1. 我們初始化了一個用戶activity。

  2. 我們覆蓋了用戶activity的更新方法以便在我們想要的時候遞交數據。

  3. 我們決定了應用應該在什麼時候遞交數據。

  4. 我們在視圖控制器退出的時候停止了用戶activity。

待會我們還會再次回到ViewContactViewController類的其中一些部分。

在查看聯系人詳細信息時遞交

在上一部分我們介紹了如何在編輯聯系人的時候遞交,以及在上面時候做這些事。現在我們會做幾乎相同的事,但這次會簡單一點。

首先,打開ViewContactViewController.swift文件,我們將會在這個類裡也支持handoff。和EditContactViewController相比,這裡有一個很大的區別:我們不會更新任何數據,因此我們不會更新用戶activity的狀態。我們要做的就是創建一個新的activity然後把聯系人數據告訴它,讓這個視圖控制器一旦呈現後可以被遞交。

與我們前面做的一樣,我們會創建一個createUserActivity方法。這裡我們會用在plist文件裡定義的第二種activity type來初始化這個activity,給它設置一個標題,最後把它賦值給當前的activity。看一下下面的代碼:

func createUserActivity() {
    userActivity = NSUserActivity(activityType: "com.appcoda.handoffdemo.view-contact")
    userActivity?.title = "View Contact"
    userActivity?.becomeCurrent()
}

我們在上面用到的聯系人對象,就是執行轉場時從ViewController傳入的。

現在必須調用createUserActivity方法,我們將會在viewDidAppear方法裡調用。

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

    createUserActivity()
}

接著,必須覆蓋activity的狀態更新方法,在這個方法裡我們會為activity設置包含需要被遞交的數據的字典:

override func updateUserActivityState(activity: NSUserActivity) {
    userActivity?.userInfo = contact.getDictionaryFromContactData()

    super.updateUserActivityState(activity)
}

其實也不是非得把NSUserActivity類的needsSave屬性設為true。默認情況下當activity被創建的時候,這個方法會被調用,activity的狀態會被更新最終會導致遞交過程發生。

最後,別忘了在視圖控制器退出之前停止activity。來到prepareForSegue方法裡,先檢查一下將會執行的轉場是不是退出的那一個,然後加上接下來的幾行代碼:

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject!) {
    if segue.identifier == "idUnwindSegueViewContact" {
        self.userActivity?.invalidate()
    }
}

現在查看聯系人詳細信息的時候遞交也會工作了。

繼續一個用戶Activity

到現在為止我們已經寫了夠多的關於遞交的代碼了,但是卻還是無法測試效果,因為從整個流程來說這還只是一個單方面的過程。我們接下來還要做的就是再加入一些代碼讓應用能夠繼續一個從其他設備遞交過來的activity。

一個activity的持續是一個一分為二的活兒:首先,用來接收activity的兩個應用程序委托方法必須實現,其次,對相應的處理接收數據的視圖控制器還有一些額外工作要做。

我們將會詳細探討一下這兩個部分,首先打開AppDelegate.swift文件。當接收數據到一個被遞交的設備上時,iOS會調用兩個委托方法來通知應用程序,分別當一個activity將會被繼續但是暫時還沒有收到任何數據,和當一個activity已經被遞交而且傳來了數據。

在第一種情況裡,開發人員一般有兩種選擇,要麼告知用戶關於這個activity,要麼為用戶將要繼續的這個activity做一些准備工作。通常第二種選擇會創建一些圖形界面元素來指示一個活動正在進行,例如顯示一個活動指示器(activity indicator,也叫spinner),或其他類似的。在這個演示應用裡我們不會費事去做這些,我們會讓iOS系統去告知用戶這個將要進行的activity。下面的就是在這個委托方法裡我們給你提供的實現代碼:

func application(application: UIApplication, willContinueUserActivityWithType userActivityType: String) -> Bool {
    println(userActivityType)

    return false
}

通過返回false我們把這部分的控制權交給iOS系統。要注意的是當你想自己來處理將會被繼續的用戶activity時,你必須把返回值改為true。println這行代碼只是為了演示一下,讓你可以在輸出控制台看到將會被繼續的activity的activity type。

當收到一個包含數據的activity時,這才是有趣的部分。這個時候應該實現的委托方法叫做application(application:userActivity:restorationHandler:)。這個方法的主要目的是把用戶activity對象傳給相應的視圖控制器對象,這樣應用才能處理並顯示接收到的數據,讓用戶從上一個設備停下來的地方繼續。這聽起來可能很難,但是UIResponder類已經給我們提供好了解決方法,所以在我們實現這個方法之前,先來談一下這到底是怎麼回事。

在剛開始的小節我提到過UIResponder類有幾個非常使用的方法,而且在EditContactViewController和ViewContactViewController類中更新用戶activity時我們已經用到了一個。第二個方法是和持續性相關的(continuity),這個方法叫做 restoreUserActivity(activity:)。這裡我們只需要弄懂一個很重要的細節:相應視圖控制器和其層次結構下的每個視圖控制器都必須實現這個方法,而且每個視圖控制器都必須調用它的子視圖控制器的restoreUserActivity方法,直到到達最外層的視圖控制器(棧頂端的視圖控制器)。在最後一個視圖控制器裡接收到的數據會被處理和顯示,這樣用戶才能繼續工作。

繼續往下看,上面說到的這些就會變得清晰明了。期初,在AppDelegate.swift文件裡加入如下的方法:

func application(application: UIApplication, continueUserActivity userActivity: NSUserActivity, restorationHandler: ([AnyObject]!) -> Void) -> Bool {
    if let win = window {
        let navController = win.rootViewController as UINavigationController
        let viewController = navController.topViewController as ViewController

        viewController.restoreUserActivityState(userActivity)
    }

    return true
}

首先,我們必須確保應用程序的窗口已經被初始化,所以我們用到了上面的可選綁定。接著,在頭兩行代碼裡我們通過導航控制器取得了ViewController視圖控制器。然後,調用了這個類的restoreUserActivity:方法,並傳入接收到的activity。然後根據activity type,讓這個類接著調用EditContactViewController或者ViewContactViewController類的同一個方法。然後返回true告知iOS系統我們已經處理完畢要繼續的activity。

然後來到ViewController.swift文件,我說過,必須向下一個視圖控制器傳遞要繼續的用戶activity。所以我們必須把從restoreUserActivity:方法裡接收到的activity存入一個屬性裡以便在prepareForSegue(segue:sender:) 方法裡使用。那麼,在類的主題開頭加入以下聲明:

var continuedActivity: NSUserActivity?

現在讓我們來實現這個新方法:

override func restoreUserActivityState(activity: NSUserActivity) {
    continuedActivity = activity

    if activity.activityType == "com.appcoda.handoffdemo.edit-contact" {
        self.performSegueWithIdentifier("idSegueEditContact", sender: self)
    }
    else{
        self.performSegueWithIdentifier("idSegueViewContact", sender: self)
    }

    super.restoreUserActivityState(activity)
}

接收到的activity被當做參數傳到了這裡。可以看到,我們把它存到了剛才聲明的屬性裡。然後根據activity type執行相應的專場。最後,由於本方法是覆蓋自父類的,我們調用了相應的父類實現。

現在,來到prepareForSegue(segue:sender:) 方法,在這裡我們會根據需要稍微修稿以下代碼。讓我們先從編輯聯系人的視圖控制器開始吧:

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
    if segue.identifier == "idSegueEditContact"{
        ...        

        if let activity = continuedActivity {
            editContactViewController.restoreUserActivityState(activity)
        }
    }

    ...

}

這裡我們把continuedActivity對象當做編輯聯系人視圖控制器的restoreUserActivity: 方法(待會兒會實現)的參數。

同樣在查看聯系人視圖控制器裡也要相應地修改代碼:

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
    if segue.identifier == "idSegueEditContact"{
        ...
    }


    if segue.identifier == "idSegueViewContact"{
        var viewContactViewController = segue.destinationViewController as ViewContactViewController

        if let activity = continuedActivity {
            viewContactViewController.restoreUserActivityState(activity)
        }
        else{
            viewContactViewController.contact = contactsArray.objectAtIndex(indexOfContactToView) as Contact
        }

    }    
}

現在可以來到EditContactViewController.swift文件。這才是我們真正會用到接收到的數據的地方,我們會取出數據然後在文本框中顯示。恢復activity的實現很簡單,只需把要繼續的activity對象賦給響應對象的userActivity就行了。

override func restoreUserActivityState(activity: NSUserActivity) {
    userActivity = activity

    super.restoreUserActivityState(activity)
}

就是這麼簡單。但是對顯示數據這還不夠。在viewDidAppear方法裡我們會檢查用戶activity的userInfo字典是否包含數據,如果包含的話,就會把它們顯示出來:

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

    if let userInfo = userActivity?.userInfo {
        var contact = Contact()
        contact.getContactDataFromDictionary(userInfo)

        txtFirstName.text = contact.firstname
        txtLastName.text = contact.lastname
        txtPhoneNumber.text = contact.phoneNumber
        txtEmail.text = contact.email
    }

    createUserActivity()
}

在這段實現代碼裡,我們聲明了一個臨時Contact對象,然後使用getContactDataFromDictionary方法從userInfo字典中提取數據。接著把相應的值賦給文本框,就搞定了。通過上面這些,一個能遞交的activity就可以從一台設備繼續到另一台設備。待會我們會看一下實際效果。

最後,讓我們使ViewContactViewController也可以繼續一個activity,打開ViewContactViewController.swift文件,然後加入恢復activity方法:

override func restoreUserActivityState(activity: NSUserActivity) {
    userActivity = activity

    super.restoreUserActivityState(activity)
}

和前面的視圖控制器相似,修改viewDidAppear方法:

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

    if let userInfo = userActivity?.userInfo {
        contact = Contact()
        contact.getContactDataFromDictionary(userInfo)

        tblContactInfo.reloadData()
    }

    createUserActivity()
}

到現在,我們的應用能如預期一樣繼續一個可遞交的activity。繼續一個activity的整個過程開始看起來可能比較奇怪,但是如果你仔細推敲一下我們走過來的每一步,你會發現一切都變得非常明了。

一些收尾

在第一次運行這個應用來測試它之前,有必要在AppDelegate.swift文件裡再添加一個委托方法。回想一下我們之前做的,你會發現我們根本沒有處理任何可能出現的錯誤。

在下一個代碼段裡你會看到有一個你應該添加的新方法。注意實際上我們並沒有處理任何錯誤,我們只是向輸出控制台顯示了一些信息。

除此之外,還有一個委托方法你可以選擇性地實現,如下所示:

func application(application: UIApplication, didFailToContinueUserActivityWithType userActivityType: String, error: NSError) {
    println(error.localizedDescription)
    println(userActivityType)
}

當一個activity的狀態更新時這個方法會被調用,這裡我只是拓展一下。我們顯示了被更新的activity和它的userInfo字典,只是做一下測試。

func application(application: UIApplication, didUpdateUserActivity userActivity: NSUserActivity) {
    println(userActivity)
    println(userActivity.userInfo)
}

現在,我們的應用已經實現了所有和連續性有關的委托方法,准備好了看看它運行得怎麼樣。

編譯運行應用

我在教程的開頭說過,遞交功能只能在真實設備上測試。所以在兩台iPhone,或兩台iPad,或者任意組合的iPhone和iPad上構建並運行這個應用,然後測試一下。添加一個新的聯系人或者查看一個已有聯系人,然後在另外一台設備屏幕的左下角看有沒有一個新的圖標出現。使用它來讓應用程序從你落下的地方繼續這個activity。或者,你也可以解鎖另一台設備,雙擊Home鍵,在應用切換器裡,在最左邊你可以看到由遞交的activity創建的應用程序實例。點擊它應用程序也會啟動,也會顯示相應的接收到數據的視圖控制器。

下面的圖片展示了這個應用程序的功能。在演示中,開始的設備是一台iPhone,繼續的設備是一台iPad mini。

在iPhone上添加一位新聯系人:

t23_9_handoff_1.png

應用圖標出現在iPad鎖屏的左下角:

t23_10_handoff_2.png

向上滑動應用圖標會打開應用並顯示我在iPhone上離開的地方:

t23_11_handoff_3.png

在iPhone上查看聯系人詳細信息(iPad上的同個應用已經被終止了):

t23_12_handoff_4.png

在iPad上連按兩次Home鍵,一個附帶被遞交activity的應用程序實例就會出現在左邊:

t23_13_handoff_5.png

應用載入查看聯系人視圖控制器來顯示聯系人詳細信息:

t23_14_handoff_6.png

總結

在本教程中我們介紹了iOS 8的一種全新的功能,我們走過了好幾個步驟,最終成果構建了一個利用Handoff完美工作的應用。如果你只關注handoff相關的部分,你會發現它實現起來相當簡單,只要你遵循它提供的幾條簡單的規則。要記得在設置你的應用的activity type時要慎重考慮,避免出現命名沖突。最後我要說的是,即時Handoff在我們的演示應用上運行良好,我們並沒有讓繼續的設備上的聯系人列表和初始應用上的列表保持同步。但這並沒有關系,我們的目標是探討一下如何實現Handoff。除此之外,這也是另一個關於Handoff教程的主題。我希望這篇文章對你有幫助,你也可以在評論區留下你的想法。祝你愉快!

供你參考,你可以點擊這裡 (需翻牆)下載完成後的Xcode工程。

(本文為CocoaChina組織翻譯,本譯文權利歸譯者所有,未經允許禁止轉載。)

  1. 上一頁:
  2. 下一頁:
蘋果刷機越獄教程| IOS教程問題解答| IOS技巧綜合| IOS7技巧| IOS8教程
Copyright © Ios教程網 All Rights Reserved