iOS 8 Handoff 开发指南

(原文: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。

现在既然你对我们接下来将会做的事有了一个大体的了解,那么就让我们继续吧。在接下来我们走完这个流程的过程中,我会为你提供关于这个应用的更多详细信息。

起点

就像我刚才所说的,我们不会从头编写一个新的应用。取而代之,我会为你提供一个可以快速入手的启动工程,你可以点击下载 (需FQ)。

下载完成后,用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按钮创建这个类。

接着,打开新创建的文件把以下几行内容添加进去:

1
2
3
4
5
6
7
8
9
10
11
12
class Contact: NSObject {
 
    var firstname: NSString?
 
    var lastname: NSString?
 
    var phoneNumber: NSString?
 
    var email: NSString?
 
    let documentsDirectory: NSString?
}

首先,我们把父类设为NSObject,因为待会我们会用到这个类提供的一些方法。当然,如你所见,我们有四个变量来保存联系人的详细信息,还有最后一个变量来表示本应用的文档目录(documents directory)。下一步我们会定义初始化方法(init method),并为最后一个变量赋值。如下所示:

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

我们会需要用到这个文档目录的路径,因为待会儿我们会实现两个非常有用的方法来保存和载入联系人的详细信息。但是在这之前,让我们先来写另外两个方法。第一个就是下面这段代码所示的把联系人的详细信息对应的属性转化为一个字典对象的方法,然后返回这个字典对象。

1
2
3
4
5
func getDictionaryFromContactData() -> Dictionary {
    var dictionary: [String: String] = ["firstname": firstname!, "lastname": lastname!, "phonenumber": phoneNumber!, "email": email!]
 
    return dictionary
}

第二个方法正好把这个过程反过来,它会从一个字典对象中提取出联系人的详细信息然后分别对各个属相赋值。

1
2
3
4
5
6
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实现中都会非常有用。

现在,让我们实现一个方法来把联系人保存到文档目录的一个文件里。先看一下下面的实现代码,然后我会解析一下这些代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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里的对象就会被写入指定的路径。

现在,我们也可以从文件里载入联系人,只需执行大致相反的步骤就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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数组,最后,返回这个包含着联系人对象的数组。

最后,我们还会添加一个类方法:

1
2
3
4
5
6
7
8
9
10
11
12
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协议,如下所示:

1
class EditContactViewController: UIViewController, UITextFieldDelegate

然后在viewDidLoad方法里把自身(self)设为所有本视图控制器的视图里出现的文本框的委托:

1
2
3
4
5
6
7
8
override func viewDidLoad() {
    super.viewDidLoad()
 
    txtFirstName.delegate = self
    txtLastName.delegate = self
    txtPhoneNumber.delegate = self
    txtEmail.delegate = self
}

最后,实现下面的文本框委托方法:

1
2
3
4
5
func textFieldShouldReturn(textField: UITextField) -> Bool {
    textField.resignFirstResponder()
 
    return true
}

可以看到,我们在这个委托方法里所做的唯一事情就是让文本框放弃第一相应对象(first responder),不用管这是哪一个文本框发来的消息。

现在,我先提前告诉你以后我们还会回到这个方法来,因为在实现handoff功能的时候,我们会在这个方法里添加一条命令,我说的是以后。。。

现在,在我们保存联系人之前,最好先定义一下刚才说到的自定义委托。在这个委托里,我们只声明一个委托方法。通过委托的方式,我们可以在新联系人保存的时候通知父试图控制器,也可以把这个联系人当做参数传出去。

看一下这个文件的头几行,就在导入UIKit命令之后,类声明之前,加入下面几行:

1
2
3
protocol EditContactViewControllerDelegate{
    func contactWasSaved(contact: Contact)
}

然后在类主体里,声明以下委托变量:

1
var delegate: EditContactViewControllerDelegate?

好了,这个协议已经写好了,接着让我们来实现IBAction方法吧。焦点转到saveContact方法的方法体里。我们将第一次用到这个教程的之前小节里创建的类。首先,我们会创建一个这个类的对象来存储文本框里的数据,如下所示,当然,永久地保存这个新联系人。

1
2
3
4
5
6
7
8
9
10
@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()
}

接着,调用自定义的委托方法来通知父视图控制器一个新联系人已经创建并且已保存:

1
2
3
4
5
@IBAction func saveContact(sender: AnyObject) {
    ...    
 
    self.delegate?.contactWasSaved(editedContact)
}

最后,只需退出(pop)当前试图控制器。如果你看一下你下载的启动工程的用户界面文件,特别是那些转场(segue),你肯定会发现我为EditContactViewController和ViewContactViewController两个视图控制器都创建了返回转场(unwind segues)(用来退出视图控制器的转场)。一开始你可能觉得奇怪,但是我加入这些转场有一个明确的原因:当这两个视图控制器退出时,我想要完全的控制权,因为在那个时候,我们需要停止handoff运行。现在我们还不会讨论这些,当保存了新的联系人之后就让视图控制器退出吧:

1
2
3
4
5
@IBAction func saveContact(sender: AnyObject) {
    ...
 
    self.performSegueWithIdentifier("idUnwindSegueEditContact", sender: self)
}

在这个视图控制器里需要做的已经暂时结束了,但只是暂时结束。当我们实现handoff功能的时候还会回到这个视图控制器的,因为还有许多事需要我们去做。除此之外,在下一部分里我们将会探讨如何使用在这里创建的委托方法。

列表显示联系人

当我们的应用启动的时候我们想要在一个表格视图里显示所有的联系人。那么,我们必须从磁盘(文档目录)里载入所有保存的联系人,然后把它们显示出来。但是别忘了,我们必须也实现contactWasSaved这个委托方法。

让我们开始吧。首先打开ViewController.swift文件。在类的开头加入如下的声明:

1
var contactsArray: NSMutableArray!

然后,定义如下用来载入联系人的方法:

1
2
3
4
func loadContacts(){
    let contact = Contact()
    contactsArray = contact.loadContacts()
}

当然,别忘了在viewDidLoad方法里调用这个方法,如下所示:

1
2
3
4
5
override func viewDidLoad() {
    super.viewDidLoad()
 
    loadContacts()
}

现在我们知道每次ViewController的视图载入后,contactsArray数组或者会包含所有的联系人(Contact类的实例),或者只是一个简单初始化的数组。现在我们来重点关注一下必须实现的几个表格视图方法。你可能注意到我在启动工程里已经加入了UITableViewDelegate和UITableViewDatasource协议,而且把ViewController设为了表格视图的委托和数据源。那么我们先从几个简单地方法开始吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
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对象来得到它的名字和姓氏:

1
2
3
4
5
6
7
8
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
}

如你所见,我们只展示了每个联系人的名字和姓氏。可以看到,上面所示的把名字和姓氏两个字符串连接起来已经足够简单。

说起表格视图方法,何不实现另一个便捷方法来加入删除联系人的的功能呢?如下所示:

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

deleteContactAtIndex方法是我们接下来会实现的一个方法。在这个方法中,我们传入一个待删除联系人的索引(index)作为参数,看一下这个新方法:

1
2
3
4
5
func deleteContactAtIndex(index: Int){
    contactsArray.removeObjectAtIndex(index)
    Contact.updateSavedContacts(contactsArray)
    tblContacts.reloadData()
}

首先我们从数组中移除对应的联系人对象,然后调用Contact类的updateSavedContacts类方法把剩下的联系人(如果还有的话)存回文件。最后,重载表格视图数据来更新这些改动。

先前也说过,我们还得实现contactWasSaved这个委托方法。在这个方法里,我们只是把新联系人加入联系人数组,然后重载表格视图数据。它的实现相当简单,来看一下:

1
2
3
4
5
func contactWasSaved(contact: Contact) {
    contactsArray.addObject(contact)
 
    self.tblContacts.reloadData()
}

另外,我们可以再次从文件中载入联系人。

现在在类文件的头行加入EditContactViewControllerDelegate协议:

1
class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, EditContactViewControllerDelegate

上面的一步是必须的,但我们还没有结束。必须还把ViewController设为EditContactViewController实例的委托,当使用转场时,这件事一般会在prepareForSegue(segue:sender:)方法里完成。每次EditContactViewController视图控制器将要出现时这个方法就会被调用一遍,我们的目标就是从转场里得到这个视图控制器的实例然后把自己设为它的委托,如下所示:

1
2
3
4
5
6
7
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文件,在类主体的开头加入下面的声明:

1
var contact: Contact!

本类将会用这个变量来展示(待会还会用来处理)联系人的详细信息。

在这个应用的启动工程里我已经加入了和表格视图相关的协议,而且我把这个类设为了表格视图的委托和数据源。也就是说我们只需要写少量必要的方法表格视图就可以正常地显示联系人信息了。

在下面的代码里,你可以看到没什么特别难懂的部分。所以考虑到时间问题,我就一次性把这四个方法都贴出来吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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数组中的索引。来到声明成员变量的地方,再加入下面的一个变量:

1
var indexOfContactToView: Int!

实际上,这个变量会用来存储被选中的单元格的行索引。但是要得到这个索引我们就得实现tableView:didSelectRowAtIndexPath这个表格视图的委托方法。这个方法的代码也很简单,得到选中的单元格的索引然后执行相应的转场(segue)来转换到ViewContactViewController视图控制器。

1
2
3
4
5
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
    indexOfContactToView = indexPath.row
 
    self.performSegueWithIdentifier("idSegueViewContact", sender: self)
}

上面的这个转场(segue)非常重要,因为在真正转换到下一个视图控制器之前,prepareForSegue这个方法会被调用,正好可以在这个方法里传入选中的联系人对象。

来到prepareForSegue方法,加入下面的几行:

1
2
3
4
5
6
7
8
9
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来表示编辑联系人,和另外一个来表示查看某个联系人的详细信息。所以给这两个子项分别赋值为:

1
2
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。

1
2
3
4
5
6
7
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这个方法,如下所示:

1
2
3
4
5
override func viewDidAppear(animated: Bool) {
    super.viewDidAppear(animated)
 
    createUserActivity()
}

注意到如果只是简单地创建一个activity的话,handoff根本不会工作,因为没有数据传递。

现在的问题是当我们在编辑联系人的时候,应该选在何时把数据传送出去。在我们创建的这个演示应用里,一个简单可行的解决方法就是在每次用户按下键盘上的Return按钮之后。如果你记性好的话,我们已经实现了几个文本框的委托方法(textFieldShouldReturn),所以我们可以直接使用,但不是现在!

UIResponder的和用户activity相关的方法中其中一个是updateUserActivityState: ,这个方法扮演的角色很简单,就像方法名暗示的那样:更新用户activity的状态,在这我们用它来把需要递交的数据传递给activity对象。只要有需要,这个方法可以多次被调用,所以我们应该覆盖这个方法来加入相应的实现代码。先看一下这个方法,待会会详细解析一下它:

1
2
3
4
5
6
7
8
9
10
11
12
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: 委托方法里调用,所以到这个方法里下面几行:

1
2
3
4
5
6
7
func textFieldShouldReturn(textField: UITextField) -> Bool {
    ...
 
    userActivity?.needsSave = true
 
    return true
}

NSUserActivity类的needsSave属性实际上是一个用来判断用户activity对象的状态是否需要更新的信号旗(flag),如果它为true的话,就说明状态需要更新,然后就会递交新数据。当然,除了使用needsSave属性,你也可以在上面的方法中加入下面的一行:

1
updateUserActivityState(self.userActivity!)

这行代码也是个不错的选择。

最后,我们需要对用户activity做的一件事是当视图控制器从导航控制器里出栈时停止掉它,确切的说就是当返回转场将被执行的时候。来到prepareForSegue方法(如果它默认被注释掉的话,就删除注释),加上下面的几行代码:

1
2
3
4
5
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。看一下下面的代码:

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

我们在上面用到的联系人对象,就是执行转场时从ViewController传入的。

现在必须调用createUserActivity方法,我们将会在viewDidAppear方法里调用。

1
2
3
4
5
override func viewDidAppear(animated: Bool) {
    super.viewDidAppear(animated)
 
    createUserActivity()
}

接着,必须覆盖activity的状态更新方法,在这个方法里我们会为activity设置包含需要被递交的数据的字典:

1
2
3
4
5
override func updateUserActivityState(activity: NSUserActivity) {
    userActivity?.userInfo = contact.getDictionaryFromContactData()
 
    super.updateUserActivityState(activity)
}

其实也不是非得把NSUserActivity类的needsSave属性设为true。默认情况下当activity被创建的时候,这个方法会被调用,activity的状态会被更新最终会导致递交过程发生。

最后,别忘了在视图控制器退出之前停止activity。来到prepareForSegue方法里,先检查一下将会执行的转场是不是退出的那一个,然后加上接下来的几行代码:

1
2
3
4
5
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。下面的就是在这个委托方法里我们给你提供的实现代码:

1
2
3
4
5
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文件里加入如下的方法:

1
2
3
4
5
6
7
8
9
10
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:) 方法里使用。那么,在类的主题开头加入以下声明:

1
var continuedActivity: NSUserActivity?

现在让我们来实现这个新方法:

1
2
3
4
5
6
7
8
9
10
11
12
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:) 方法,在这里我们会根据需要稍微修稿以下代码。让我们先从编辑联系人的视图控制器开始吧:

1
2
3
4
5
6
7
8
9
10
11
12
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
    if segue.identifier == "idSegueEditContact"{
        ...        
 
        if let activity = continuedActivity {
            editContactViewController.restoreUserActivityState(activity)
        }
    }
 
    ...
 
}

这里我们把continuedActivity对象当做编辑联系人视图控制器的restoreUserActivity: 方法(待会儿会实现)的参数。

同样在查看联系人视图控制器里也要相应地修改代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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就行了。

1
2
3
4
5
override func restoreUserActivityState(activity: NSUserActivity) {
    userActivity = activity
 
    super.restoreUserActivityState(activity)
}

就是这么简单。但是对显示数据这还不够。在viewDidAppear方法里我们会检查用户activity的userInfo字典是否包含数据,如果包含的话,就会把它们显示出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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方法:

1
2
3
4
5
override func restoreUserActivityState(activity: NSUserActivity) {
    userActivity = activity
 
    super.restoreUserActivityState(activity)
}

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

1
2
3
4
5
6
7
8
9
10
11
12
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文件里再添加一个委托方法。回想一下我们之前做的,你会发现我们根本没有处理任何可能出现的错误。

在下一个代码段里你会看到有一个你应该添加的新方法。注意实际上我们并没有处理任何错误,我们只是向输出控制台显示了一些信息。

除此之外,还有一个委托方法你可以选择性地实现,如下所示:

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

当一个activity的状态更新时这个方法会被调用,这里我只是拓展一下。我们显示了被更新的activity和它的userInfo字典,只是做一下测试。

1
2
3
4
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教程的主题。我希望这篇文章对你有帮助,你也可以在评论区留下你的想法。祝你愉快!

供你参考,你可以点击这里 (需FQ)下载完成后的Xcode工程。

posted @ 2015-08-24 11:52  小明MR  阅读(341)  评论(0编辑  收藏  举报