苹果消息推送服务教程(三步曲)-超详细

 mtrabelsi  (原作者)


第一部分

在iOS系统中,在后台运行的程序能够进行的操作是非常有限的。这种限制是为了节省手机电池。
但是,如果你需要在用户没有使用你的程序的情况下给他们推送消息该怎么办呢?

打个比方,用户收到了一个来自推特的信息,他喜欢的球队在比赛中取胜,或者他的晚饭准备好了。因为现在用户并没有在使用我们的程序,程序本身无法听取这些事件。

幸运的是,苹果系统对此已有了解决办法。你并不需要让程序不停地听取事件或者在后台跑运算。你只需要编写一个服务器组件来完成这个任务。

在一个特定的事件发生时,那个服务器的组件就可以给我们的程序发一个推送信息!推送信息可以做如下三件事:

  • 显示一条信息
  • 播放一小段提示音乐
  • 在程序的小图标边上放置一个数量标志

你可以随意组合这些选项;比如你可以播放提示音并放置数量标志,而不显示任何信息。

在这个有两部分的教程中,你会用苹果推送服务器(APNS)来完成一个有消息推送功能的简单的程序。

在第一部分,你会学习如何接收推送的信息。

这篇教程针对的是中级或者高级的iOS开发者。如果你对iOS还处于入门阶段,你应该先看看这个网站上一些初级教程. 并且,我也建议你可以先看看下面这两篇教程(或者有类似的经验):

  • 如何为iOS程序写简单的PHP/MySQL网络服务
  • 如何在iOS程序中使用网络服务

那就让我们开始吧!

文章概略

为你的app加入信息推送是需要付出很多努力的。这个任务有很多个部分。下面是一个概要:

  1. 程序启用消息推送功能。用户必须确认他希望接受这些推送信息。
  2. 那个程序接收一个“设备标记码”。你可以把这个设备标记码理解为推送信息的地址。
  3. 那个程序将这个设备标记码发送到服务器。
  4. 每当任何关于你的程序的事件发生时,那个服务器会将信息发送到苹果的推送信息服务(APNS)。
  5. APNS 将这个信息再推送到用户的设备上

用户的设备收到这个信息时,会有提示窗口,播放提示音或者更新app的数量标志。用户可以在提示窗口中直接开启这个app。我们的app从这里接过推送信息的内容并能自定义处理这个信息的逻辑。

有人会问,iOS4中已经有了本地提示以及多重任务执行,那推送信息提示还有用吗?答案:“当然啦”!

本地提示仅能用于定时的事件。无限制的后台运算也仅限用于网络通话,导航和背景音乐类的app。如果在程序已经进入后台运行还想给用户信息提示,那我们仍然需要使用推送信息提示。

在这个教程中,我会详细解释推送信息提示是怎么实现的,以及如何在你的app中使用它。需要学的东西很多,让我们现在开始吧!

推送信息提示准备工作

在你的app中加入推送信息提示,你需要:

一台 iPhone 或者 iPad。 你需要真实的设备因为推送信息提示在模拟器中不能用。

你必须是注册的iOS开发者。 每个要加入推送信息的app的需要一个新的App ID,provisioning profile 和SSL证书。你可以在iOS Provisioning Portal来完成这些所需操作。

如果你想跟着这个教程中的例子,那你就需要创建新的provisioning profile和SSL 证书;你不能用我的。因为获取正确的证书很重要,我会一步步解释如何获取这个证书。

一个连接到网上的服务器。 推送信息是由这个服务器发送出来的。开发期间你可以用你的苹果电脑作为服务器(我们在这个教程中就会这样做),但是在app上线后,你至少需要一个VPS(虚拟私人服务器)。

一个简单的共享的服务器账号是不够的。你需要能够在服务器上跑后台线程,安装SSL证书以及对外在特定端口建立TLS链接。

大多数共享服务器的提供者是不会让你这么做的。所以,我强烈建议你用 Linode类型的服务器.

解析消息推送服务/h2>

你的服务器负责创建被推送的消息,所以我们应该来了解一下这个服务器是怎么做到的。

一个推送消息会包含设备标记码,信息负载和一些其他的字节。那个信息负载就是我们要发到设备上的推送消息。

你的服务器提供的这个信息负载应该是JSON字典的格式。一个简单的推送信息负载应该是这样的:

{
	"aps":
	{
		"alert": "Hello, world!",
		"sound": "default"
	}
}

如果你不了解JSON,你只需要知道用“{}”符号分割出来的一个代码块代表了一个键与值对应的字典(和NSDictionary相似)。

我们的信息就是这样的一个字典。这个字典里至少要有一个物件,“aps”。在这里,“aps”本身又是一个字典。“aps”包含了“alert”和“sound”两个键。当设备收到这个消息时,程序应该显示一个弹出消息:“Hello,world!”并且发出标准的提示音。

你还可以在“aps”字典中加入其他物件来设置那个消息。比如:

{
	"aps":
	{
		"alert":
		{
			"action-loc-key": "Open",
			"body": "Hello, world!"
		},
		"badge": 2
	}
}

注意“alert”本身也变成了一个字典。弹出信息栏的查看按钮的标签会变换成“action-lock-key”的值。“badge”键所对应的数字值会成为程序图标的数量标记。这个消息不会发出提示音。

这个JSON格式的信息负载还有很多可以设置的选项。你可以改变提示音的声音,提供翻译过的标签,你也可以加入自定义的键值对。如果有兴趣深入了解,你可以看看苹果公司提供的 本地和推送信息编程指南.

推送信息应该很简洁;那个信息负载不应该超过256个字节。这样一般有足够的空间传送一个SMS信息或者一个推特消息。正确的服务器不会浪费宝贵的负载空间了传送换行符和空格,所以你的服务其应该发送这样的信息:

{"aps":{"alert":"Hello, world!","sound":"default"}}

这个对于人来说比较难读懂,但是却更节省空间。苹果APNS不会接受超过256个字节的推送消息。

推送消息的易错点

推送消息不可靠!

太不可靠了! 就算在APNS服务器接收了信息的情况下,推送消息也不一定会成功地传到用户设备上。

你的服务器在发射推送消息到APNS后,没有任何办法可以获取消息的状态。消息实际被推送的时间也不一定,可能几秒钟,也可能要半个小时。

并且,用户的iPhone并不是随时都能接收推送信息。他们可能在一个无法接收苹果推送服务的无线网络中,原因可能是相应的网络端口被封闭了;或者用户的手机关机了。

苹果推送服务会在手机重新上线后试着重试没有传递的信息,但是这是有时限的。时限一过,我们就永远失去那个信息提示了!

在看到苹果推送服务的账单后

好贵啊! 如果有很多用户,或者所推送的信息需要从其他地方不断获取,那这个服务的费用可能会很高。

打个比方,如果你控制了RSS源。因为你会清楚知道什么时候会有新的RSS,你可以轻松直接地在适当的时候给用户推送信息。
但是,如果你的程序允许用户输入自定义的RSS网址怎么办?在这种情况下你需要有自己的一套方案来探测是否有心的RSS信息。

事实上,你的服务器需要不停地访问这些地址来探测新的RSS信息。如果你有很多用户,那你就需要不断增加新的服务器来探测和发送信息。这很快就会变得非常昂贵。

理论问题讨论完了,那我们就开始实践吧。 但是在做有趣的事情–编程,之前, 我们还需要在iOS开发者门户网站上完成一些无聊的设置。

Provisioning Profiles以及各类证书

苹果推送服务需要一个证书!

为了使你的程序能使用推送服务,我们需要建立一个特别用来进行这个服务的provisioning profile。另外,你的服务器还需要通过SSL证书来和苹果的推送服务器建立联系。

provisioning profile和SSL证书是一一对应的,并且只有在有一个有效的App ID的情况下才能用。这是为了保证你的服务器只能将推送信息发到这一个特定的程序中,而不能发到其他任何程序。

值得注意的是,一个程序在开发时和发布时需要用不同的provisioning profile。同时也有两种服务器证书:

  • 开发时. 如果你的程序是在Debug模式下运行,并且使用的是开发阶段的provisioning profile(Code Signing Identity属性的值是”iPhone Developer”),那你的服务器必须使用开发阶段的证书。
  • 生产时. 程序如果已经在苹果商店上发布(Code Signing Identity属性的值是”iPhone Distribution”),那服务器必须使用生产阶段的证书。如果这两个被弄混了,推送的提示信息就无法到达你的程序。

在这个叫教程里,我们将只会使用开发时的profile和证书。

生成Certificate Signing Request(证书申请)

还记得你如何在iOS开发者网站上注册iOS开发计划并获取开发证书的吗?我们下面要做的和那个类似。但我还是建议你一步一步跟着我来完成这个操作。因为大多数问题都出在证书上。

电子证书都是基于公共和私有密钥加密的。你并不需要知道这个加密的过程,但你需要知道那个证书必须和一个私有密钥一起用才会有效。

那个证书是这组密钥的公共部分。所以把这部分随便给别人是没有问题的,在你用SSL来通讯时就需要这么做。但是那个私有密钥却需要保密。这个秘密不能让任何别人知道。如果你没有这个密钥,那证书就失效了。

在你申请一个电子证书时,你需要提交一份Certificate Signing Request(证书申请),缩写为CSR。在你生成CSR时,会生成一个新的私有密钥并被放到你电脑的keychain中。然后你把这个CSR发到一个证书的认证网站(在我们的情况下,这个网站就是苹果的开发者门户网站)。这个网站会用CSR生成相应的SSL证书。

在你的Mac上打开Keychain Access程序(在Applications/Utilities子目录下),然后在菜单中选择 Request a Certificate from a Certificate Authority…,意思是从证书权威获取证书。

如果你在菜单里找不到需要的选项,或者选项的标签是“Request a Certificate from a Certificate Authority with key”, 那你就需要先下载并且安装WWDR Intermediate Certificate。 还需要注意的是,在Keychain Access主窗口中,千万不要选择任何的私有密钥。

然后你应该看到类似下面的窗口:

输入你的邮箱。我曾听有人说你应该用和你申请iOS开发者计划时一样的邮箱,但是应该任何邮箱都是可以的。

在Common Name(公用名)一栏输入”PushChat”. 你可以输入任何名字作为公用名,但最好选择一些有代表性的名字。这样我们以后才能很快的找到这个私有密钥。

Saved to disk(保存到硬盘)边上打钩。将它保存为”PushChat.certSigningRequest”。

如果你现在来到Keychain Access的Keys部分,你应该看到一个新的,你刚刚生成的私有密钥。右键点击并将它汇出。

将汇出文件命名为“PushChatKey.p12”然后输入一个加密码。

为了方便,我的加密码就是“pushchat”。但为了更好地保护这个p12文件,你应该选择一些更难猜到的加密码。私有密钥必须是秘密的,不是吗?但是你一定要记得住这个加密码,否则私有密钥还是不能用。

创建App ID和获取SSL证书

登陆iOS置备门户网站.

首先,我们需要创建一个新的App ID。每一个用推送信息服务的app都需要一个特有的ID,因为只有这样,被推送的信息提示才能被发送到正确的程序上。(在这里,你不能使用通配符ID。)

在边上的目录栏中选择“App IDs”,然后点击“New App ID”按钮。

我填写内容如下:

  • Description: PushChat
  • Bundle Seed ID: Generate New
  • Bundle Identifier: com.hollance.PushChat

你最好是选择一个你自己的Bundle Identifier,而不是用我的,例如“com.你的网站.PushChat”。你之后在Xcode中还要使用这个Bundle Identifier。

很快,我们会生成一个SSL证书。你的服务器就是用这个证书来和苹果的推送服务器建立安全连接。这个证书和你的App ID绑定在一起,这样你的服务器就只能给这一个app发送推送信息提示了。

在你创建好App ID后,你应该看到类似如下的列表:

在“Apple Push Notification service”(苹果推送信息服务)一栏里,有两个橘黄色的标志分别写着“Configurable for Development”(开发设置)和“Configurable for Production”(发布生产设置)。这意味着我们的App ID已经可以用来进行推送了,只是还需要我们做相应的设置。点击那个写着“Configure”的链接来打开设置App ID的界面。

“Enable for Apple Push Notification service”(启用苹果推送服务)旁打钩然后点击对应“Development Push SSL Certificate”的Configure 按钮。苹果推送服务SSL证书助手界面就出现了:

它首先要你生成一个证书签署请求。我们已经完成这一步了。点击Continue。在下一步你需要上传已经生成的证书签署请求(CSR)。选择那个CSR文件,然后点击Generate

生成SSL证书需要几分钟时间,完成后点击Continue

点击“Download” 来下载证书 – 名字应该叫 “aps_developer_identity.cer”. 点击 “Done” 来关闭助手界面并回到设置App ID的界面。

如你所见,我们现在有一个有效的证书了,可以开始给App推送证书了。如果需要,你可以在刚才的地方重新下载证书。开发证书的有效期是3个月。

当你的App准备可以正式使用时,重复上述步骤来获取生产时用的证书。步骤是一样的。

注意: 生产时用的证书有效期是1年。但你需要提前更新它以确保你的App的推送服务不会中断。

创建PEM文件

现在我们总共有三个文件:

  • CSR
  • 私有密钥(PushChatKey.p12)
  • SSL证书(aps_developer_identity.cer)

将这三个文件好好保存起来。你可以选择不再用那个CSR文件了。但是在你更新证书时,你可以使用同一个CSR或者获取一个新的。如果你获取新的CSR,你同时会生成一个新的私有密钥。如果使用同一个CSR,那就只有SSL证书需要改变了。

我们需要将证书和私有密钥转换成另外一个更方便的格式。我们在服务器的推送部分会用PHP来写,所以我们现在可以把证书和私有密钥合并成一个PEM格式的文件。

PEM文件是怎么运作的并不重要(说实话我也不知道),但是这样我们能更容易地在PHP里使用这个证书。如果你准备用其他语言来写这个服务器部分,那下面的步骤可能会不配套。

我们将使用命令行OpengSSL的工具来完成这项任务。打开一个Terminal并输入以下指令。

“cd”到你下载证书,密钥文件的文件夹,对于我是桌面文件夹:

$ cd /Users/matthijs/Desktop

将那个.cer文件转换成.pem文件:

$ openssl x509 -in aps_developer_identity.cer -inform der 
    -out PushChatCert.pem

将那个密钥.p12文件转换成.pem文件:

$ openssl pkcs12 -nocerts -out PushChatKey.pem -in PushChatKey.p12
Enter Import Password: <输入你导出密钥时用的那个密码>
MAC verified OK
Enter PEM pass phrase: <输入一个新的密码>
Verifying - Enter PEM pass phrase: <重复密码>

你首先需要输入.p12文件的密码,这样openssl才能读取这个文件。然后你需要你个新的密码来对pem文件进行加密。在这个教程中我用的还是“pushchat”。但你自己应该选择一个更加保险的密码。

注意:如果你不输入PEM的密码,openssl不会给你任何的错误信息。但是生成的.pem文件里就不会有那个密钥。

最后,将那个证书和密钥合并为一个文件:

$ cat PushChatCert.pem PushChatKey.pem > ck.pem

我们应该测试一下这个证书是否能用。执行如下指令:

$ telnet gateway.sandbox.push.apple.com 2195
Trying 17.172.232.226...
Connected to gateway.sandbox.push-apple.com.akadns.net.
Escape character is '^]'.

我们试着与APNS服务器建立一个一般的,没有加密的连接。如果你看到类似上面的回复,那说明你的Mac能连上APNS。按Ctrl+C切断连接。如果你得到一个错误信息,那你应该确保你的防火墙允许对外2195端口的连接。

让我们再次试着连接。这次,我们会使用那个SSL证书和密钥来建立一个加密连接:

$ openssl s_client -connect gateway.sandbox.push.apple.com:2195 
    -cert PushChatCert.pem -key PushChatKey.pem
Enter pass phrase for PushChatKey.pem: 

你应该看到一大窜回复内容。那是openssl的运行信息。

如果连接成功建立,你应该可以键入几个字符,然后当你点回车时,服务器就会和你断开连接。如果建立连接过程出现问题,openssl会给出错误信息,但你可能需要在那一大窜信息中找出错误信息。

值得注意的是,APNS其实有两个不同的服务器:那个沙盒服务器使用来测试的。还有一个正式的服务器是在你的程序投入生产后使用的。我们上面用的是测试用的服务器。因为我们的证书是开发时才能用的。

生成Provisioning Profile

我们还需要用Provisioning门户网页来生成一个新的Provisioning Profile。在左边的目录栏中点击“Provisioning”,选择“Development”来生成一个新的profile。

填写表格如下:

  • Profile Name: PushChat Development
  • Certificates: 在你的证书旁打钩
  • App ID: PushChat
  • Devices: 选择你的设备

我们需要一个新的profile因为每个有推送信息的app都需要和它App ID相对应的一个profile。

点击Submit,你的profile就会被生成。那个profile的状态一开始会是“Pending”。刷新页面直到它的状态变成“Active”,这时你就可以下载那个文件了(给它取名为PushChat_Development.mobileprovision)。

双击那个文件或者将这个文件拖到Xcode的图标上,这样就能将这个profile添加到Xcode中了。

在你将程序投入生产时,你需要重复上述步骤来生成Ad Hoc或者distribution profile。

一个非常基础的APP

到现在为止我们做的准备工作都比较枯燥,但却是必要的。我们详细的学习了生成各种证书的过程因为我们不会经常坐这件事,但是推送信息没有这些就无法运作。

通过连接到沙盒服务器,我们确认了证书是可用的。现在我们要测试一下我们是否确实可以推送提示信息!

启动Xcode并建立一个新的项目。 选择View-based Application作为这个项目的模版然后点击进入下一步。

我是这样填写各个空格的:

  • Product Name: PushChat
  • Company Identifier: com.hollance
  • Device Family: iPhone

那个Product Name(产品名字)和Company Identifier(公司代码)合在一起就是Bundle ID。我的是“com.hollance.PushChat”。 你应该选择和你在苹果的Provisioning Portal上注册的App ID相对应的产品名字和公司代码(com.yourname.PushChat)。

完成创建项目并打开PushChatAppDelegate.m文件。将didFinishLaunchingWithOptions改为如下:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
	self.window.rootViewController = self.viewController;
	[self.window makeKeyAndVisible];
 
	// 让手机知道我们想接收推送信息提示。
	[[UIApplication sharedApplication] registerForRemoteNotificationTypes:
		(UIRemoteNotificationTypeBadge | UIRemoteNotificationTypeSound | UIRemoteNotificationTypeAlert)];
 
    return YES;
}

我们调用registerForRemoteNotificationTypes方法来告诉OS我们的程序想接收推送信息。

编译并运行你的程序。你应该在真正的苹果设备上运行因为模拟器不支持推送信息提示。Xcode应该会自动选择那个新的provisioning profile。如果你得到一个关于“code sign”的错误,那你需要确保已经选择了正确的profile。 你可以在“build settings”中的“Code Sign”设置部分对其修改。

当启动你的程序时,系统应该会弹出一个选择菜单来询问用户是否允许当前程序推送提示信息。

你的程序只会向用户询问一次然后记住用户的选择。用户只有选择“OK”之后你的程序才能接收推送提示信息。用户也可以在iPhone系统设置菜单中更改推送信息的设置。

你app的名称会出现在手机的提示设置界面里。用户可以在这里设置是否允许你的app推送信息提示,显示数量图标以及发出提示音。

你的app也可以通过如下的代码来探测用户启用了哪一种提示方式:

UIRemoteNotificationType enabledTypes = [[UIApplication sharedApplication] enabledRemoteNotificationTypes];

要让我们的app开始接收推送提示,我们还需要将下面的代码加入PushChatAppDelegate.m文件中:

- (void)application:(UIApplication*)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData*)deviceToken
{
	NSLog(@"My token is: %@", deviceToken);
}
 
- (void)application:(UIApplication*)application didFailToRegisterForRemoteNotificationsWithError:(NSError*)error
{
	NSLog(@"Failed to get token, error: %@", error);
}

当你的app注册远程推送提示时,它会获取一个设备代码(device toke)。这是一个32字节的独特数字来辨别用户的设备。你可以把这个代码看做是用户设备的地址。APNS用这个地址来推送信息提示。

在你用来测试的苹果设备上运行你的app。你应该在Xcode的控制台窗口中看到:

My token is: 
<740f4707 bebcf74f 9b7c25d4 8e335894 5f6aa01d a5ddb387 462c7eaf 61bb78ad>

那个代码是一个NSDatat对象,拥有某种二进制数据结构。我们不应该对它进行任何修改。我们只用知道它有32个字节,可以用64个十六进制的字符表示。我们会直接用这种表示方式(去掉那括弧和中间的空格)。

如果你在模拟器里运行你的程序,didFailToRegisterForRemoteNotificationsWithError:方法应该会被调用因为模拟器不支持推送信息提示。

我们的app就暂时写完了。但要真的开始推送信息,我们还需要完成最后的一步!

推送第一个信息提示

我曾说过,你需要有一个服务器来把信息推送到你的app上。但为了测试,我们还不需要一个服务器。你可以用下面这个简单的PHP脚本来和APNS建立连接并将信息推送到指定的设备上。你可以直接在你的Mac上运行这个脚本。

下载这个叫“SimplePush”代码包 并解压缩. 你需要对simplepush.php文件进行如下修改.

// 把你的设备代码放在这里(不要空格): 
$deviceToken = '0f744707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bbad78';
 
// 把你密钥的密码放在这里:
$passphrase = 'pushchat';
 
// 把你的提示信息放在这里:
$message = 'My first push notification!';

你应该把设备代码从app里拷贝粘贴到$deviceToken变量。请确保你去掉空格和括弧。把$passphrase的值设为你密钥的密码, $message的值设为你要推送的信息字符串。

把你的ck.pem文件拷贝到SimplePush文件夹中。记住,ck.pem包含你的证书和密钥。

打开终端并打入:

$ php simplepush.php

如果一切顺利,那个脚本应该回应:

Connected to APNS
Message successfully delivered

然后几秒之后,你应该收到app的第一个推送信息提示:

注意,如果你的app是开着的,你就看不到推送的信息。这是因为我们还没有写代码在app里接收推送的信息。你应该关掉app然后重试。

如果simplepush.php脚本因错误退出,那你因该确保你的PEM文件是正确生成的,并且确保你可以连接到苹果的沙盒服务器(请看上面的详细步骤)。

我们并不用急着知道这个脚本到底做了什么,因为这是这个教程的下一部分的内容。在下一部分,我们将学习如何编写一个真正的推送服务器。




第二部分

在第一部分,我们学习了如何让iPhone app接收推送信息,并用一个简单的PHP脚本推送了一个提示信息。

在我们教程的第二部分,你将会学习编写一个运用APNS服务的app,并会写一个简单的PHP Web服务来推送信息!

注意: 这部分教程相对比较长,所以请留足一大段时间(以及零食)。但这么做是值得的,一旦你完成这个教程,你会有一个完整的app以及一个推送信息的Web服务。

PushChat

在这段教程里, 我们将完成一个简单的聊天app叫做PushChat。PushChat将用苹果的推送方式来传递信息。我们的app最后会是这样的:

用户看到的第一个界面是登陆界面。用户要输入他们的昵称和一个密码。用户应该告诉他的朋友这个密码。

所有使用这个密码的用户能看到彼此的信息。所以这个密码其实就像是聊天室的名字一样。当然,如果你成功地猜到了别人的密码你就可以偷看他们的聊天了。所以这是个密码。 ;-)

用户点击“Start!”按钮后,app会给服务器发送信号以便将这个用户与相应的聊天室联系起来。然后会进入聊天界面:

密码会显示在导航栏上。当前用户的信息会出现在屏幕右边,其他人的信息会出现在左边。以上图为例,当前用户和一个叫 SteveJ(猜猜他是谁?)的人都登陆了名为“TopSecretRoom123”的聊天室。

第三个,也是最后一个界面是信息编写界面:

没什么特别的。只是一个文本视图外加一个键盘。信息会被限制在190个字节以内。屏幕顶端会显示剩余的字节。

我加入这条限制的原因是推送的信息有256字节的长度限制,包括信息的格式所需要的字节。

当用户点击“Save”按钮时, 那个信息会被传送到我们的服务器,然后以推送信息的形式发送到所有登陆到这个聊天室的用户那里。

服务器API

刚才我们已经多次提到“我们的服务器”。我们需要一个服务器来将信息传递到不同用户设备上。

我用PHP和MySQL写了一个简单的网络Web服务。我们的iPhone程序将把下面的指令发到服务器上:

JOIN. 当一个用户登陆时,我们会将他的昵称,密码和设备代码发到服务器。服务器将这个用户资料加入到所有在线用户中。从此以后,同一个聊天室的任何一个成员发的信息就会被推送到这个新成员的设备上。

LEAVE. 这是和“JOIN”指令相反的指令。用户可以通过点击聊天界面的“Exit”按钮来离开聊天室。我们这时会把LEAVE指令发到服务器。服务器会把这个用户从在线用户列表中移除。他也不会再说收到这个聊天室的信息了。

MESSAGE. 当用户点击编写信息界面的“Save”按钮时,我们会把当前信息发到服务器。服务器会把这个文字信息转化成一个完整的推送信息,通过APNS发送给聊天室的每一个人。

UPDATE. 这个指令会让服务器知道用户有一个新的设备代码。设备代码有时会变。所以我们需要让服务器及时更新。我们等下会详细解释这是为什么的。

服务器指令的作用可以用下图表示出来:

我们的app会在适当时候将用户的在线情况和发送的信息传递到服务器。服务器再将这个信息通过APNS推送到其他聊天室成员的app上。

当app收到一个新的推送信息,在了聊天界面会出现一个新的信息泡泡。

创建服务器

如果你对Web服务完全不了解,你不妨先看看这个过于PHP和MySQL的教程。

在app的开发初期阶段,我们将会用MAMP在Mac上建立服务器和数据库。MAMP很容易使用,你也不用交付昂贵的独立服务器费用。

你的Mac和iPhone需要在同一个本地网络上,否则你的app是无法和服务器交流的。大多数人家里都有WiFi,所以这应该不成问题。

当然,在你的app要上交App Store审核时,你应该有一个真正的服务器来接收这些指令。

你可以在这里免费下载MAMP。(那个网站上还有一个付费的专业版,但是这个免费的版本对于我们已经够用了。)

MAMP内含一个阿帕奇服务器,PHP语言和一个MySQL数据库。我们三个都会用到。(如果你要在一个非MAMP的服务器上用我们教程中的PHP代码,你需要确保已经安装以下的扩展软件:PDO, pdo_mysql, mbstring, OpenSSL。)

安装MAMP是很容易的。解压并打开下载的DMG文件。接收许可协议然后把MAMP文件夹拖到Applications文件夹中就可以了!

在Applications/MAMP文件夹中点击MAMP图标(那个大象)。你应该看到如下MAMP的界面:

点击“Open start page”按钮。你应该会打开你的默认浏览器并看到如下欢迎界面:

太好了!现在下载PushChatServer服务器的代码并解压。我假设你解压到了桌面上,因为我们需要将相应的路径填入阿帕奇服务器的配置文件中。

打开Applications/MAMP/conf/apache/httpd.conf文件并加入:

Listen 44447
 
<VirtualHost *:44447>
	DocumentRoot "/Users/matthijs/Desktop/PushChatServer/api"
	ServerName 192.168.2.244:44447
	ServerAlias pushchat.local
	CustomLog "/Users/matthijs/Desktop/PushChatServer/log/apache_access.log" combined
	ErrorLog "/Users/matthijs/Desktop/PushChatServer/log/apache_error.log"
 
	SetEnv APPLICATION_ENV development
	php_flag magic_quotes_gpc off
 
	<Directory "/Users/matthijs/Desktop/PushChatServer/api">
		Options Indexes MultiViews FollowSymLinks
		AllowOverride All
		Order allow,deny
		Allow from all
	Directory>
VirtualHost>

有几行还需要进一步改动。我的PushChatServer文件放置在“/Users/matthijs/Desktop”路径上。你需要将它改成你解压至的路径。

将“ServerName”那一行的IP地址改成你Mac的IP地址。如果你不知道怎么找到你Mac的IP地址,你需要打开System Preferences按后打开Network面板。

我用的是我的MacBook的无线网的IP地址,但是以太网端口的IP地址也是可用的。注意,你的端口号应该仍然是44447

ServerName <your IP address>:44447

我们的Web服务使用44447号端口。这是一个任意选择的数字。网站大多使用80号端口。MAMP默认的网页使用的是8888号端口。我们选择的端口不会喝这两个冲突。

我同时用“pushchat.local”作为服务器的别名。这就好比是一个只有在本地网才能使用的域名。我们需要让这个域名与一个IP地址邦定。最简单的办法是修改“/etc/hosts”文件。将下面这行加入到文件的地端并保存:

127.0.0.1       pushchat.local

在MAMP窗口中,点击“Stop Servers”按钮。等指示灯变成红色后,点击“Start Servers”。如果你正确地修改了httpd.conf文件,服务器的两个指示灯都应该重新变成绿色的。

打开你最爱的浏览器,前往 http://pushchat.local:44447。你应该看到如下信息:

If you can see this, it works! 

太好了。这说明阿帕奇和服务器API的PHP代码已经成功地安装上了。现在我们需要设置数据库。

设置数据库

返回MAMP的开始页(在MAMP桌面窗口中点击“Open start page”按钮)并点击“phpMyAdmin”按钮。你的页面应该和下图相似:

在Create Database栏输入“pushchat”并在Collation栏选择“utf8_general_ci”。点击“Create”按钮来创建一个叫做“pushchat”的数据库。

在页面上端找到“Privileges”栏,然后点击“Add a new user”

填写空格如下:

  • User name: pushchat
  • Host: localhost
  • Password: d]682#%yI1nb3
  • Privileges: 在 “Grant all privileges on database “pushchat””旁打钩

点击“Go”按钮来添加这个用户。你可以选择另外一个密码。但是你需要在相应的PHP脚本中更新这个新的密码。

数据库和用户都设置好了。现在我们需要在数据库中加入表格。在屏幕上方选择“SQL”栏然后将下面的指令粘贴到文本栏中:

USE pushchat;
 
SET NAMES utf8;
 
DROP TABLE IF EXISTS active_users;
 
CREATE TABLE active_users
(
	udid varchar(40) NOT NULL PRIMARY KEY,
	device_token varchar(64) NOT NULL,
	nickname varchar(255) NOT NULL,
	secret_code varchar(255) NOT NULL,
	ip_address varchar(32) NOT NULL
)
ENGINE=InnoDB DEFAULT CHARSET=utf8;

(你也可以在PushChatServer/database/api.sql文件中找到这些指令。)

点击“Go”按钮来执行这些指令。我们在数据库中加入了一个叫做“active_users”的表格。我们会在服务器API接收到一个JOIN指令时在这个表格中加入一个新的用户档案。我们也会在服务器API接收到一个LEAVE指令时将相应的用户档案从这个表格中删除。

我们还需要加入另外一个表格。重复上述步骤并粘贴下面指令:

USE pushchat;
 
SET NAMES utf8;
 
DROP TABLE IF EXISTS push_queue;
 
CREATE TABLE push_queue
(
	message_id integer NOT NULL AUTO_INCREMENT,
	device_token varchar(64) NOT NULL,
	payload varchar(256) NOT NULL,
	time_queued datetime NOT NULL,
	time_sent datetime,
	PRIMARY KEY (message_id)
)
ENGINE=InnoDB DEFAULT CHARSET=latin1;

(你也可以在PushChatServer/database/push.sql文件里找到这些指令。)

在服务器收到一个MESSAGE指令时,我们会把要推送的信息加到这个表格里。

设置服务器API

我们在这个教程中没有足够的篇幅来详细描述如何操作服务器的API。但是我在api.php代码中加入了详细的注解,所以就算你对PHP并不熟悉,你应该还是可以理解API是怎么运作的。

我们现在将注意力转到api_config.php文件上。这个文件包含了我们服务器API的设置信息。

当前我们有两组配置选项,一组在开发时用,另一组在生产时用。我们可以随意地交换这两组选项。

但是我们怎么让我们的API知道我们在使用开发配置还是生产配置呢? 记得我们在httpd.conf文件中加入了关于VirtualHost的一块代码:

<VirtualHost *:44447>
	…
 
	SetEnv APPLICATION_ENV development
 
	...
VirtualHost>

“SetEnv APPLICATION_ENV development”设置了一个环境变量。我们的服务器会根据这个变量来选择相应的配置选项。 如果你想使用生产选项,你可以把这快代码删掉或者将“development” 改成 “production”。
如果你用了和我一样的数据库名称,用户名和密码,那你就不需要更改你的api_config.php文件。否则你需要更改相应的值,否则你的API将无法连上数据库。

用你最爱的浏览器打开http://pushchat.local:44447/test/database.php链接。你应该看到如下信息:

Database connection successful!

小贴士: 如果你遇到错误,那就看看Applications/MAMP/logs/php_error.log文件以及PushChatServer/log/apache_error.log文件。这些错误记录文件将提供关于你遇到的错误的有用信息。

我们的服务器API就完成了!

让我们开始写代码吧!

下载PushChatStarter的代码并解压缩。这个PushChat程序的版本不包含任何网络或者推送代码。我会先简单介绍现有的代码,然后解释如何让我们的app和服务器交流并接收推送信息。

因为PushChat还没有任何接收推送信息的功能,你暂时还可以在模拟器中运行这个程序。

首先,在Xcode中打开这个项目并来到“Target Settings”选项栏。你需要将“bundle ID”的值从“com.hollance.PushChat”改为你自己的bundle ID的值。(你在苹果置备门户网站上创建App ID时使用的那个bundle ID)。这是因为你的app需要使用你自己的配置文件。

编译并运行那个app。你最好先卸载之前的那个用于测试的app然后再编译现在的这个app,因为这个新的app使用了一样的bundle ID,iPhone可能会分不清楚这两个app。

打开MainWindow.xib文件,看看这个app的用户界面的架构:

主界面的ChatViewController里还包含了一个导航控制器。这个视图控制器将负责显示发送和接收的信息。正如你在nib文件中看到的,这是一个UITableViewController的子类。

信息泡泡属于MessageTableViewCell的对象。 MessageTableViewCell又是UITableViewCell的子类。SpeechBubbleView 是UIView的子类.这里我们会使用基本的表格视图,你应该已经非常熟悉如何使用这些表格对象了,所以我们就不详细揭示了。

App中的其他两个界面分别是用户登陆界面和编写信息界面。它们都是modal view,并将会出现在ChatViewController上方。登陆界面的代码在LoginViewController中,编写信息界面的代码在ComposeViewController。

现在就剩DataModel和Message这两个数据模型的类了。Message用来储存一个信息的内容。每个信息都会有发送者的名字,时间以及信息的内容数据。

DataModel是用来管理一组Message的对象的。当用户发送或者接收到一则信息的时候,DataModel将它加入自己的列表中并储存到app的文档中。当app启动时,DataModel再将信息从文档中加载到app里。

上面是对这个app非常简短的介绍。我建议你可以浏览一下app的代码,读读代码的注释,加深理解。

连接服务器

我们现在将逐个地在各个界面中加入相应的代码,让app能和服务器交流。我们的服务器能接收app发送的HTTP POST请求,所以我将用ASIFormDataRequest来发送数据到服务器。
如果你没有用过IOS app中的web服务,我建议你先看看Ray写的一篇相关的教程。

在defs.h文件中加入如下:

#define ServerApiURL @"http://192.168.2.244:44447/api.php"

我们将把HTTP POST请求发送到上面的那个URL。你应该把IP地址改为你的服务器的IP地址,并且确保你已经开启了MAMP程序以及你的iPhone和服务器都链接到了同一个网络中。

我们先从登陆界面开始吧。当用户点击了“Start”按钮后,我们将发送一个JION指令到服务器上。让服务器知道一个用户成功登陆了。服务器将把这个用户加入到在线用户表(active_users table)中。

在LoginViewController顶端加入下面代码:

#import "ASIFormDataRequest.h"
#import "MBProgressHUD.h"

在userDidJoin和loginAction方法间加入下面代码:

- (void)postJoinRequest
{
	MBProgressHUD* hud = [MBProgressHUD showHUDAddedTo:self.view animated:YES];
	hud.labelText = NSLocalizedString(@"Connecting", nil);
 
	NSURL* url = [NSURL URLWithString:ServerApiURL];
	__block ASIFormDataRequest* request = [ASIFormDataRequest requestWithURL:url];
	[request setDelegate:self];
 
	[request setPostValue:@"join" forKey:@"cmd"];
	[request setPostValue:[dataModel udid] forKey:@"udid"];
	[request setPostValue:[dataModel deviceToken] forKey:@"token"];
	[request setPostValue:[dataModel nickname] forKey:@"name"];
	[request setPostValue:[dataModel secretCode] forKey:@"code"];
 
	[request setCompletionBlock:^
	{
		if ([self isViewLoaded])
		{
			[MBProgressHUD hideHUDForView:self.view animated:YES];
 
			if ([request responseStatusCode] != 200)
			{
				ShowErrorAlert(NSLocalizedString(@"There was an error communicating with the server", nil));
			}
			else
			{
				[self userDidJoin];
			}
		}
	}];
 
	[request setFailedBlock:^
	{
		if ([self isViewLoaded])
		{
			[MBProgressHUD hideHUDForView:self.view animated:YES];
			ShowErrorAlert([[request error] localizedDescription]);
		}
	}];
 
	[request startAsynchronous];
}

让我们一行一行来解释上面的代码。

	MBProgressHUD* hud = [MBProgressHUD showHUDAddedTo:self.view animated:YES];
	hud.labelText = NSLocalizedString(@"Connecting", nil);

这里我们显示一个全屏的负荷指示。你可以在Ray的其他教程中深入学习MBProgressHUD的使用方法。

	NSURL* url = [NSURL URLWithString:ServerApiURL];
	__block ASIFormDataRequest* request = [ASIFormDataRequest requestWithURL:url];
	[request setDelegate:self];

我们使用服务器的URL来创建了一个ASIFormDataRequest对象。ASIFormDataRequest能方便我们发送POST请求到服务器上。你只需要设置URL地址和POST的内容就行了。我等下会解释“__block”是什么。

	[request setPostValue:@"join" forKey:@"cmd"];
	[request setPostValue:[dataModel udid] forKey:@"udid"];
	[request setPostValue:[dataModel deviceToken] forKey:@"token"];
	[request setPostValue:[dataModel nickname] forKey:@"name"];
	[request setPostValue:[dataModel secretCode] forKey:@"code"];

在这里我们设置了POST请求的数据。我设计的服务器API会在POST的数据里寻找“cmd”选项。这个选项决定了API将会执行哪一项指令。在这里,它将执行“join”指令。

“Join”需要四个参数。用户昵称和密码是当然需要的,用户会在登陆时输入这些数据。但是什么事“udid” 和 “token”呢?“token”其实是当前设备的编码。这样服务器才知道应该将信息推送到哪个设备上。

那个UDID是设备的id号码。我们在数据库中决定用设备的id号码来辨认用户。我们也可以使用用户的昵称,但是这样的话我们就需要保证连个用户不会使用同一个昵称。编写这些逻辑会使app更复杂。
我们还可以使用设备的编码来辨别用户,但是设备的编码有时会改变,所以也不是最好的选择。UDID是不会改变的。我并不是说所有web服务都应该用设备的UDID,但在我们现在的情况下,用UDID就足够了。

	[request setCompletionBlock:^
	{
		...
	}];

我们在用“blocks”!一般有两种办法来处理ASIFormDataRequest返回的结果。一是使用delegate的requestFinished方法和requestedFailed方法– Ray在他的web服务的教程中用了这种方法。

但是在OS 4.0后,使用blocks的方法出现了,并能替代delegates的方法。以学习为目的,我们将在这个教程中使用blocks。这里我们设置了请求完成时执行的block。在“^{ }”符号之间的代码只有在请求成功完成后才会执行。

还记得在创建ASIFormDataRequest对象时我们用了一个奇怪的“__block”关键字吗?这确保blocks不会记住对ASIFormDataRequest对象的引用。因为ASIFormDataRequest对象已经记住了对block的引用,如果block再记住对ASIFormDataRequest对象的引用,就会形成一个环形引用的情况,从而造成内存泄露。如果这听起来很复杂,那只要记住当你用blocks而不是delegate方法时,都要在在ASIFormDataRequest对象前加上“__block”关键字就行了。

在block里,我们有下面的代码:

		if ([self isViewLoaded])
		{
			[MBProgressHUD hideHUDForView:self.view animated:YES];

首先,我们检查那个视图是否仍然活跃。因为请求是在后台线程中异步完成的,理论上说当它完成时,发送请求的视图已经不再活跃了。比如低内存警告可能会导致当前视图被中止。在这种情况下,我们就忽略已经发送了的请求结果。

这样做可能对于我们写的这个简单的app来说是有点夸张了。但是我总喜欢做到万无一失。特别是在有后台线程和异步请求的情况下更要特别小心。

剩下的请求成功后执行的block代码如下:

			if ([request responseStatusCode] != 200)
			{
				ShowErrorAlert(NSLocalizedString(@"There was an error communicating with the server", nil));
			}
			else
			{
				[self userDidJoin];
			}

我们先检查服务器API发的HTTP回复的状态代码。如果一切正常,代码会是“200 OK”。但是,电脑和网络可能会出错。比如我们的MySQL数据库可能会掉线,那服务器API就会回复“500 Server Error”来告诉用户服务器有问题。我们需要显示一个错误视图让用户知道。

ShowErrorAlert()函数会创建一个UIAlertView并在当前用户界面上显示。注意我总是用NSLocalizedString()来创建需要显示的字符串。如果将来我们要把这个app翻译到其他语言,这样创建字符串会使翻译更加简单。如果想了解更多关于翻译app到其他语言,可以读读Sean Berry写的这篇教程。

如果一切正常,我们调用userDidJoin方法。这个方法会更新我们的数据模型,关闭弹出的视图并回到主用户界面。

如果请求因某种原因失败了,我们会执行下面的代码。一般这种失败是因为请求无法到达服务器,或者服务器很长时间都没有回应造成的。

	[request setFailedBlock:^
	{
		if ([self isViewLoaded])
		{
			[MBProgressHUD hideHUDForView:self.view animated:YES];
			ShowErrorAlert([[request error] localizedDescription]);
		}
	}];

这里,我们就隐藏那个负荷指示并显示一个错误信息的视图。

	[request startAsynchronous];

在对这个ASIFormDataRequest对象做了这么多的设置后,我们终于发送了这个请求。如果你将请求发送到服务器,你都应该使用异步请求,因为这个请求可能会需要好几秒钟才能完成。如果你用同步请求,你app的用户界面在这段时间内就会完全无法使用。并且如果用户界面一直反应迟钝,操作系统最终会中止你的程序。

在LoginViewController.m文件里,对loginAction做如下改动:

- (IBAction)loginAction
{
	...
 
	// 将下面这行代码改成:
	[self userDidJoin];
 
	// 这样:
	[self postJoinRequest];
}

之前,我们在用户点击start按钮后直接调用userDidJoin函数来假装用户登陆。现在我们只有在服务器成功将用户登陆后才调用这个函数。

注意我们调用了DataModel类的两个还不存在的函数udid和deviceToken,下面的代码能消除编译器的警告信息。

在DataModel.h文件中,加入:

- (NSString*)udid;
- (NSString*)deviceToken;
- (void)setDeviceToken:(NSString*)token;

在DataModel.m文件中, 在@implementation后加入:

- (NSString*)udid
{
	UIDevice* device = [UIDevice currentDevice];
	return [device.uniqueIdentifier stringByReplacingOccurrencesOfString:@"-" withString:@""];
}

这个函数会帮我们获取用户设备的UDID。UDID一般会有破折号,但是我们将消除这些破折号,只剩下一个40字节长的字符串。(注意,模拟器的设备ID只有32个字节。)

还在DataModel.m文件中,将下面的这行代码加入文件顶端,在@implementation上面:

static NSString* const DeviceTokenKey = @"DeviceToken";

在@implementation后加入下面的函数:

- (NSString*)deviceToken
{
	return [[NSUserDefaults standardUserDefaults] stringForKey:DeviceTokenKey];
}
 
- (void)setDeviceToken:(NSString*)token
{
	[[NSUserDefaults standardUserDefaults] setObject:token forKey:DeviceTokenKey];
}

我们还需要对初始方法做如下修改:

+ (void)initialize
{
	if (self == [DataModel class])
	{
		[[NSUserDefaults standardUserDefaults] registerDefaults:
			[NSDictionary dictionaryWithObjectsAndKeys:
				@"", NicknameKey,
				@"", SecretCodeKey,
				[NSNumber numberWithInt:0], JoinedChatKey,
 
				// 加入下面这行代码:
				@"0", DeviceTokenKey,
 
				nil]];
	}
}

在setDeviceToken函数里,我们把设备的编码储存到了NSUserDefaults字典里。在deviceToken函数中,我们可以从NSUserDefaults中重新获取这个编码。如果你还不太了解什么是NSUserDefaults,它其实是一个方便储存app设置的类。

DataModel在创建一个对象时,会自动调用初始函数。我们在这里将所有存在NSUserDefaults内的属性都设为相应的默认值。

我们加入的那行代码将设备编码属性的值设为字符串@”0″。我们等会会解释为什么这是必要的。但是现在的情况是,当我们把JOIN指令发送到服务器,@”0″会作为设备的编码被发送到服务器。

我们刚才解释了很多代码。现在是时候试试编译并运行这些代码看看到底能不能用。

输入用户昵称和密码然后点击“Start”按钮。这里我们用了“MisterX”和“TopSecret”。写着“Connecting”的符合指示会出现一会。如果和服务器的交流顺利的话,登陆界面会被主用户界面所替代。

但如果你得到一个错误信息的话,可以试试这些贴士:确保MAMP已经启动了。在Apache Server和MySQL Server项旁边应该都有一个绿色的指示灯。确保你可以用浏览器打开你服务器的IP地址。同时确保你在defs.h文件中的服务器IP是正确的。最后,你的iPhone应该和服务器在同一个网络里。

如果我们在服务器这边也能确认请求的接收和处理过程就好了。其实这不难做到。在接收了JOIN请求后,服务器API会在数据库的active_users表中加入这个用户的记录。我们可以同过phpMyAdmin来看这个表的内容来确认这一点。

点击MAMP的“Open start page”按钮然后在浏览器中点击phpMyAdmin。进入pushchat数据库,选择active_users表,然后点击Browse(查看)选项。你应该看到和下图类似的情况:

表格中应该有一行关于MisterX的数据,密码为TopSecret,设备编码是“0”。服务器API还记录了发送请求设备的IP地址。

在真实的服务器中,我们一般会把所有信息全部记录下来,比如app的版本,操作系统版本,用户用的是哪种设备,请求发送的时间等等。这些信息在分析错误原因和用户使用情况时会起到很大的作用。

完成与服务器的交流

如果你理解我们在LoginViewController中做得修改,那下面的部分将会非常简单,因为本质上我们在做同一件事。

将下面几行代码加到ChatViewController.m文件顶端:

#import "ASIFormDataRequest.h"
#import "MBProgressHUD.h"

在userDidLeave函数和exitAction函数之间,加入:

- (void)postLeaveRequest
{
	MBProgressHUD* hud = [MBProgressHUD showHUDAddedTo:self.navigationController.view animated:YES];
	hud.labelText = NSLocalizedString(@"Signing Out", nil);
 
	NSURL* url = [NSURL URLWithString:ServerApiURL];
	__block ASIFormDataRequest* request = [ASIFormDataRequest requestWithURL:url];
	[request setDelegate:self];
 
	[request setPostValue:@"leave" forKey:@"cmd"];
	[request setPostValue:[dataModel udid] forKey:@"udid"];
 
	[request setCompletionBlock:^
	{
		if ([self isViewLoaded])
		{
			[MBProgressHUD hideHUDForView:self.navigationController.view animated:YES];
 
			if ([request responseStatusCode] != 200)
			{
				ShowErrorAlert(NSLocalizedString(@"There was an error communicating with the server", nil));
			}
			else
			{
				[self userDidLeave];
			}
		}
	}];
 
	[request setFailedBlock:^
	{
		if ([self isViewLoaded])
		{
			[MBProgressHUD hideHUDForView:self.navigationController.view animated:YES];
			ShowErrorAlert([[request error] localizedDescription]);
		}
	}];
 
	[request startAsynchronous];
}

上面的代码看起来应该非常熟悉。我们创建一个ASIFormDataRequest对象并将“cmd”的值设为“leave”。我们还加上了UDID,这样服务器才会知道是哪一个用户要登出。剩下的代码和之前我们写的几乎一摸一样,除了在请求成功后,我们调用了userDidLeave函数来做用户登出后的收尾工作。

将exitAction函数修改如下:

- (IBAction)exitAction
{
	[self postLeaveRequest];
}

编译并运行app,在主界面点击“Exit”按钮。现在打开phpMyAdmin然后刷新active_users表。这个表格应该没有任何内容了。在服务器API收到“LEAVE”指令后,它就将那个用户从数据库的表格中移除了。

最后对ComposeViewController.m文件进行一些修改,在文件顶端加入:

#import "ASIFormDataRequest.h"
#import "MBProgressHUD.h"

在userDidCompose函数与cancelAction函数间,加入:

- (void)postMessageRequest
{
	[messageTextView resignFirstResponder];
 
	MBProgressHUD* hud = [MBProgressHUD showHUDAddedTo:self.view animated:YES];
	hud.labelText = NSLocalizedString(@"Sending", nil);
 
	NSString* text = self.messageTextView.text;
 
	NSURL* url = [NSURL URLWithString:ServerApiURL];
	__block ASIFormDataRequest* request = [ASIFormDataRequest requestWithURL:url];
	[request setDelegate:self];
 
	[request setPostValue:@"message" forKey:@"cmd"];
	[request setPostValue:[dataModel udid] forKey:@"udid"];
	[request setPostValue:text forKey:@"text"];
 
	[request setCompletionBlock:^
	{
		if ([self isViewLoaded])
		{
			[MBProgressHUD hideHUDForView:self.view animated:YES];
 
			if ([request responseStatusCode] != 200)
			{
				ShowErrorAlert(NSLocalizedString(@"Could not send the message to the server", nil));
			}
			else
			{
				[self userDidCompose:text];
			}
		}
	}];
 
	[request setFailedBlock:^
	{
		if ([self isViewLoaded])
		{
			[MBProgressHUD hideHUDForView:self.view animated:YES];
			ShowErrorAlert([[request error] localizedDescription]);
		}
	}];
 
	[request startAsynchronous];
}

大部分代码和上面一致。唯一的区别是在这里我们先将键盘隐藏了。否则符合指示会出现在键盘的后面,看起来很难看。然后我们把“message”指令以及用户的UDID和信息内容发送到API。如果一切正常,我们调用userDidCompose函数将一个Message对象加入到DataModel中,让其显示在屏幕上。

修改saveAction函数如下:

- (IBAction)saveAction
{
	[self postMessageRequest];
}

编译并运行程序。登陆然后点击“compose”按钮。键入一则信息然后点击保存按钮。片刻后,你的信息应该在屏幕上显示出来。

推送的准备工作

终于,我们可以在app里加入推送信息的功能了。我们已经介绍过如何为在app中注册推送信息的功能以及如何获取设备编码。我们会在AppDelegate.m文件中重复这一步骤。

在application:didFinishLaunchingWithOptions:函数中,在return语句前加入下面代码:

- (BOOL)application:(UIApplication*)application didFinishLaunchingWithOptions:(NSDictionary*)launchOptions
{
	...
 
	[[UIApplication sharedApplication] registerForRemoteNotificationTypes:
		(UIRemoteNotificationTypeSound | UIRemoteNotificationTypeAlert)];
 
	return YES;
}

我们的app会用提示音和提示信息,但不会显示一个数量小图标。

在AppDelegate.m文件末端,@end上方加入下面的代码:

- (void)application:(UIApplication*)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData*)deviceToken
{
	NSString* oldToken = [dataModel deviceToken];
 
	NSString* newToken = [deviceToken description];
	newToken = [newToken stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"<>"]];
	newToken = [newToken stringByReplacingOccurrencesOfString:@" " withString:@""];
 
	NSLog(@"My token is: %@", newToken);
 
	[dataModel setDeviceToken:newToken];
 
	if ([dataModel joinedChat] && ![newToken isEqualToString:oldToken])
	{
		[self postUpdateRequest];
	}
}
 
- (void)application:(UIApplication*)application didFailToRegisterForRemoteNotificationsWithError:(NSError*)error
{
	NSLog(@"Failed to get token, error: %@", error);
}

上面获取设备编码的方法我们都见过。让我们仔细研究一下didRegisterForRemoteNotificationsWithDeviceToken函数:

	NSString* newToken = [deviceToken description];
	newToken = [newToken stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"<>"]];
	newToken = [newToken stringByReplacingOccurrencesOfString:@" " withString:@""];

前面提到过设备编码的格式:

<0f744707 bebcf74f 9b7c25d4 8e335894 5f6aa01d a5ddb387 462c7eaf 61bbad78>

但是将它转换成下面的格式更容易使用:

0f744707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bbad78

上面的代码就是做这个格式转换的。

接着,让我们解释一下下面的代码:

	if ([dataModel joinedChat] && ![newToken isEqualToString:oldToken])
	{
		[self postUpdateRequest];
	}

didRegisterForRemoteNotificationsWithDeviceToken方法在成功注册信息推送后才会被调用。因为这是异步操作,可能需要好几秒才会完成。特别是在用户第一次试图获取设备代码时所需的时间会长些。

所以理论上说当didRegisterForRemoteNotificationsWithDeviceToken被调用时,用户可能已经点击“Start”按钮并登陆到聊天界面了。在这中情况下,app会把设备编码的默认值,@”0″,先发送到服务器。这不是一个有效的设备编码,所以我们不能用它推送信息。 

如果我们在用户加入聊天后才收到设备编码,我们需要尽快将这个编码更新到服务器。这就是API里“update”指令的用途。我们在获取新的设备编码后发送这个指令来通知服务器。

在didRegisterForRemoteNotificationsWithDeviceToken函数上方加入下面的代码:

- (void)postUpdateRequest
{
	NSURL* url = [NSURL URLWithString:ServerApiURL];
	ASIFormDataRequest* request = [ASIFormDataRequest requestWithURL:url];
	[request setPostValue:@"update" forKey:@"cmd"];
	[request setPostValue:[dataModel udid] forKey:@"udid"];
	[request setPostValue:[dataModel deviceToken] forKey:@"token"];
	[request setDelegate:self];
	[request startAsynchronous];
}

别忘了引入需要的头文件:

#import "ASIFormDataRequest.h"

我们不需要太在意这个ASIFormDataRequest请求的结果。如果请求失败了,我们也不会显示错误信息。

和服务器API的交流就完成了。现在让我们看看服务器接收到新的信息后需要如何处理并将这个信息推送出去。

服务器推送信息

在PushChatServer文件夹中有一个push文件夹,这个文件夹包含推送信息所需要的PHP代码。 你应该把这些文件放到服务器上用户无法直接访问的地方。因为你不能让用户下载你的服务器密钥。
在push文件夹中最重要的文件是push.php。你的服务器应该在后台线程上跑这个脚本。每隔几秒钟这个脚本的代码会将未发送的推送信息发到苹果的推送服务器。

首先我们需要修改push_config.php文件。push.php文件会使用这个文件中的设置选项的值。你可能需要更改密钥的密码和数据库的密码,将它们改为你自己之前设的值。

这个脚本可以在开发和生产两个模式下运行,和服务器API类似。在开发模式下,它会和苹果推送服务的沙盒服务器联系并使用你的开发SSL证书。你服务器的开发模式应该和app的debug模式一起使用。你的app放到app store后才用生产模式。

在push文件夹中有一个叫做ck_development.pem的文件。你应该用教程一开始生成的PEM文件来替代这个文件。

现在打开一个新的终端窗口并执行下面的指令:

$ /Applications/MAMP/bin/php5.2/bin/php push.php development

我们在开发模式下启动了push.php脚本。注意我们上面用的是MAMP版本的PHP,而不是Mac自带的PHP版本。这样做我们才能连接MySQL服务器。

push.php脚本在启动后不应该自动退出,否则某些设置就用错误。看看log文件夹中的push_development.log文件。我的是这样的:

2011-05-06T16:32:19+02:00 Push script started (development mode)
2011-05-06T16:32:19+02:00 Connecting to gateway.sandbox.push.apple.com:2195
2011-05-06T16:32:21+02:00 Connection OK

因为push.php必须在后台线程中运行,所以它不能直接将输出显示在终端窗口里。它会把输出导入到这个log文件中。每当这个脚本发送一个推送信息,这个log文件就会增加一行记录。

注意我们现在并不是在后台线程上运行这个脚本。在开发过程中直接运行会比较方便。(如果你想停止push.php的运行,你只需要按下Ctrl和C键。)但是在你的生产服务器上,你应该这样启动这个脚本:

$ /Applications/MAMP/bin/php5.2/bin/php push.php production &

“&”符号会将脚本放入后台线程中运行。

push.php文件的作用到底是什么呢?首先,它会和苹果推送服务器建立安全连接,并保持这个连接。我看过许多错误的例子。他们每次发送信息到苹果服务器时都会重新建立连接。苹果其实并不鼓励这么做。每次都建立新的连接是非常消耗处理器和网络资源的。保持这个连接相对比较有效。

一旦连接建立后,那个脚本会进入一个无限循环。每一次循环它都会检查push_queue数据库表格。如果有一行的“time_sent”栏的值为NULL,这说明这个信息还没有被推送。那个脚本就会将设备编码和JSON格式的信息数据打包成二进制格式,发送到苹果的推送服务器上(简称APNS)。

如果你对这个二进制格式感兴趣,我建议你看看苹果开发文档中,本地和推送提示编程指南的“The Binary Interface and Notification Formats”(二进制提示信息格式)这一章节。

在push.php发送完新的信息后,它会在time_sent栏填入当时的时间印章。然后这个脚本会休眠几秒钟,之后会重复上述步骤无限循环下去。

这意味着如果你想给用户推送一个信息,你只需要将这个信息输入到push_queue表格中就可以了。而服务器API脚本在从iPhone app接收到MESSAGE指令时恰恰就是这么做的。

我们真的能推送一些信息了吗?!

如果你有两个iPhone,你可以在两个设备上同时登陆并加入一个聊天室(通过使用同一个密码)。当其中一个手机发送信息到服务器时,几秒钟后另外一个手机应该能收到推送的信息。
但是如果你没有两个设备怎么办?那我们只能假装在和另外一个用户聊天了。用你的浏览器打开: http://pushchat.local:44447/test/api_join.html

你应该看到一个简单的HTML表格:

我们可以用这个表格来给服务器发送POST请求,就像我们在真实设备里做的一样。对于服务器来说是一样的,所以这对测试非常方便。
填入40个字符的UDID和64字符的设备编码(device token)以及一个昵称。code应该和你的app登陆的密码一致。UDID和设备编码(device token)的值其实并不重要,只要他们和你的iPhone上的不一样就行。否则服务器无法区分这两个不同的客户端。

点击Submit按钮。你可以通过在phpMyAdmin中查看active_users表格来确认我们的虚拟用户已经成功登陆了。
现在用你的浏览器打开:http://pushchat.local:44447/test/api_message.html
确保UDID和你刚才登陆时用的一样,输入一个信息然后点击Submit按钮。几秒钟后你应该能在手机app上看到这个推送的信息。祝贺你!

如果你没有接收到推送的信息,关闭app然后再试一次。我们在app里还没有代码来处理收到的信息,所以推送的信息只有在app没有在前台运行时才会出现。


第三部分

如果你还是没有收到推送的信息,那看看push.php脚本是否仍在运行。然后看看push_development.log文件,这个文件的内容应该和如下类似:

2011-05-06T23:57:29+02:00 Sending message 1 to
 '0f744707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bbad78', 
payload: '{"aps":{"alert":"SteveJ: Hello, world!","sound":"default"}}'
2011-05-06T23:57:29+02:00 Message successfully delivered

你也应该在phpMyAdmin里的push_queue数据库表格看到一行关于这个信息的记录。
推送信息有时可能需要一段时间才会到达,有时就算到了苹果的服务器后也可能无法发送到设备上。你应该多试几次说不定就可以了。

在app运行的情况下接收推送信息

当你的iPhone收到推送信息后到底会发生什么呢?总共有三种可能性:

  • app在前台运行. 接收到推送信息时屏幕上不会有任何显示,也不会有提示音,但你的app delegate会收到这个推送信息。你可以在这里加入代码来处理接收到的信息。
  • app不在前台运行。iPhone可能停留在主界面或者另一个app正在运行.一个提示窗口会弹出,可能伴随着提示音。用户可以点击Close按钮来关闭这个窗口或者点击View按钮来打开你的app。如果用户点击的时Close按钮,那你的app不会处理这个推送的信息。
  • iPhone在锁屏状态下. 同样一个提示窗口弹出,并伴随着提示音,但是这个窗口不会有Close和View按钮。屏幕解锁后会自动进入你的app。

因为app delegate是接收推送信息的地方,我们对app的最后改动都是在AppDelegate.m文件中。我们需要修改两处:

  1. application:didFinishLaunchingWithOptions:函数. 如果推送信息到达时你的app不在前台运行,而用户在弹出窗口点击了“View”按钮,你的app会重新运行然后这个信息会作为参数注入到application:didFinishLaunchingWithOptions:函数中。
  2. application:didReceiveRemoteNotification:函数. 如果信息到达时你的app正在前台运行,那这个函数就会被调用。在iOS4.0或更新的版本,如果你的app从暂停状态进入前台,这个函数也会被调用。你可以用UIApplication的applicationState属性来检查你的app是否是从暂停状态苏醒。

上述的两个函数都会有一个字典参数其中包含了JSON格式的推送信息内容。OS已经帮我们把JSON格式的信息转换成Objective-C字典了。将下面的代码加到didFinishLaunchingWithOptions:函数的return语句前:

   if (launchOptions != nil)
	{
		NSDictionary* dictionary = [launchOptions objectForKey:UIApplicationLaunchOptionsRemoteNotificationKey];
		if (dictionary != nil)
		{
			NSLog(@"Launched from push notification: %@", dictionary);
			[self addMessageFromRemoteNotification:dictionary updateUI:NO];
		}
	}

我们先确保launchOptions参数不是nil以及launchOptions中包含了推送信息。然后调用addMessageFromRemoteNotification函数来处理这个信息。

把下面的函数加到AppDelegate.m文件中:

  - (void)application:(UIApplication*)application didReceiveRemoteNotification:(NSDictionary*)userInfo
  {
  	NSLog(@"Received notification: %@", userInfo);
	[self addMessageFromRemoteNotification:userInfo updateUI:YES];
  }

这个函数同样依靠addMessageFromRemoteNotification来完成处理信息的工作。
将下面这个函数复制粘贴到didFinishLaunchingWithOptions:函数上面:

- (void)addMessageFromRemoteNotification:(NSDictionary*)userInfo updateUI:(BOOL)updateUI
{
	Message* message = [[Message alloc] init];
	message.date = [NSDate date];
 
	NSString* alertValue = [[userInfo valueForKey:@"aps"] valueForKey:@"alert"];
 
	NSMutableArray* parts = [NSMutableArray arrayWithArray:[alertValue componentsSeparatedByString:@": "]];
	message.senderName = [parts objectAtIndex:0];
	[parts removeObjectAtIndex:0];
	message.text = [parts componentsJoinedByString:@": "];
 
	int index = [dataModel addMessage:message];
 
	if (updateUI)
		[self.chatViewController didSaveMessage:message atIndex:index];
 
	[message release];
}

我保证这是最后一点代码了。然我们解释一下这段代码。

Message* message = [[Message alloc] init];
	message.date = [NSDate date];

首先我们创建一个Message对象。我们会把推送信息的内容提取出来,填入到这个对象中然后将这个对象加入到DataModel中。

	NSString* alertValue = [[userInfo valueForKey:@"aps"] valueForKey:@"alert"];

上面的代码从推送信息中获取了信息的内容。推送信息的JSON的格式看起来是这样的:

{
	"aps":
	{
		"alert": "SENDER_NAME: MESSAGE_TEXT",
		"sound": "default"
	},
}

服务器把信息内容以及信息作者的昵称放到了“alert”栏中。我们对这个字典中的其他内容并不感兴趣。

	NSMutableArray* parts = [NSMutableArray arrayWithArray:[alertValue componentsSeparatedByString:@": "]];
	message.senderName = [parts objectAtIndex:0];
	[parts removeObjectAtIndex:0];
	message.text = [parts componentsJoinedByString:@": "];

上面的代码将发送者的昵称和信息内容分解出来放入到Message对象中。发送者昵称是分号和空格之前的字符串。

	int index = [dataModel addMessage:message];

现在我们可以把这个Message对象加入到DataModel中了。

	if (updateUI)
		[self.chatViewController didSaveMessage:message atIndex:index];

最后,我们让ChatViewController加入这个新的信息。但是,如果推送信息是在didFinishLaunchingWithOptions函数中收到的,那我们就不能刷新这个视图,因为那时ChatViewController的表格还没有加载。视图加入这个信息会导致系统崩溃的。

就这些了。编译并运行现有的程序。用test_message.html中的表格来发送一些信息。你应该在app的聊天视图中看到这些信息气泡出现。

自定义提示信息

你应该还记得我们之前在介绍推送信息时曾说过你可以自定义提示设置。比如你可以在有信息时播放一个自定义的提示音。我在app的resources文件夹中放了一个音频文件叫做beep.caf。

打开api.php文件并在makePayload()函数中将下面这行代码:

$payload = '{"aps":{"alert":"' . $nameJson . ': ' . $textJson . '","sound":"default"}}';

改为:

$payload = '{"aps":{"alert":"' . $nameJson . ': ' . $textJson . '","sound":"beep.caf"}}';

你不需要改变app本身的任何代码,甚至不用重新编译。但你还是应该在设备上关闭打开了的app。因为如果我们的app正在前台运行,那提示音是不会响的。现在用test_message.html给app发一个信息。当提示窗口出现时,提示音是不是不同了?

你也可以实验修改其他的选项。比如提供自定义按钮,或者给app设定数量小图标。(如果你想实验数量小图标,别忘了让app注册接收数量图标。现在我们的app只会有提示音和提示窗口。)

反馈服务器

APNS is happy to give you feedback!

你现在应该已经累得想睡觉了吧,但是我们还是要给服务器再加一些代码,然我们的服务器更加完善。

现象一下下面这个情况:你的数据库中有一个表格记录了很多用户的设备编码。但有的用户会移除你的app。很悲伤的情况但是无可避免。

但是,你的服务器并不会知道这个变化。服务器会继续将信息推送到他们的设备上。

这当然是一个及其不应该的情况,所以我们需要这个反馈服务器。你需要定期联系这个服务器来下载无效的设备编码列表。你应该停止给这些设备推送信息。

在PushChatServer/push文件夹中你应该看到feedback.php这个文件。这个文件中的代码会连接反馈服务器并下载相应的设备编码。你应该还看到了feedback_config.php文件。这个文件含有feedback.php脚本所需要的选项值。这个脚本会使用你的PEM文件,SSL证书和密钥来链接苹果服务器。

与push.php不同的是,你不应该在后台线程中不断运行反馈脚本。你应该建立一个cron job让服务器每小时运行这个脚本一次。
Unlike push.php, the feedback script should not be run continuously as a background process. Instead, you should set up a cron job on the server that launches it every hour or so.

这个脚本会连接到苹果的反馈服务,下载无效的设备编码列表,关闭连接。然后它会到active_users表格中删除列表中的用户。

改进和限制

PushChat用推送信息的方式作为传递信息的唯一渠道。在我们的教程中看起来不错,但却有一个潜在的问题。信息是否能成功推送到用户设备并没有被保障。所以如果推送服务丢失了任何一个信息,用户就永远无法收到这个信息了。还有就是如果用户在弹出提示时点击了“Close”按钮,我们的app就不会接收到这则信息。在我们的app中,就不会有关于这个信息的气泡出现在聊天界面中了。

更好地方法是在服务器上保持一个数据库。我们的API可以把每个信息都放入一个叫“messages”的数据库表格中。当app启动时(因为用户手动开启,或者通过信息提示窗口),我们会从服务器下载这些信息。这样就不会有信息丢失的情况了。

push.php脚本功能齐全,但是如果你的app有很多用户的话就不应该使用了。PHP远没有C或者C++的效率高,速度快。还有就是你可以看看这些PHP脚本:http://code.google.com/p/php-apns/。

下一步做什么?

这是我们的教程所有的代码。

虽然这个教程以及可以赶得上一个短篇小说了,但是关于推送服务,我们还有许多没有提到的。如果你的app非常依赖推送服务,我建议你再看看下面这些资源:

  • 苹果的本地和推送信息编程指南
  • App Store Approval Guidelines的第五章(section 5)
  • Technical Note TN2265
  • WWDC 2010 视频集的第129节. 这些讲座大多是关于本地信息提示的。但一开始也有对推送信息的介绍。

如果你有任何问题,意见或者建议,请登陆下面的论坛发表评论!


你可能感兴趣的:(IOS开发技术,苹果消息推送)