注:此文只现在已经不能适配iOS10了,iOS10推送采用了新的方法,做iOS9及以下的系统可读此篇文章。
最近公司项目升级重构(重写),除了本来我所负责的模块,最后临危受命接了推送(远程和本地)相关的模块,顺便把推送的相关知识复习了一遍。后期连续工作十几天加上最后一天的通(瞎)宵(熬)达(一)旦(夜),也算是不辱使命。此文除了讲解远程推送相关的基本知识外,也会涉及一些推送相关的奇淫技巧。另外本文主要讲解远程推送,后续会出一篇iOS推送之本地推送(iOS Notification Of Local Notification)的姊妹篇。
此篇文章的逻辑如下图所示:
图0-0 此篇文章的逻辑图远程推送原理
学习一些东西前我认为最好能了解它的原理,这样以后我们遇到问题的时候,就可以很快速的找到错误之所在,如果对原理不感兴趣的同学可直接下翻到应用部分【远程推送应用】。
iOS app大多数都是基于client/server模式开发的,client就是安装在我们设备上的app,server就是远程服务器,主要给我们的app提供数据,因为也被称为Provider。那么问题来了,当App处于Terminate状态的时候,当client与server断开的时候,client如何与server进行通信呢?是的,这时候Remote Notifications很好的解决了这个困境。苹果所提供的一套服务称之为Apple Push Notification service,就是我们所谓的APNs。
推送消息传输路径: Provider-APNs-Client App
图1-1 Pushing a remote notification from a provider to a client app 图1-2 Pushing remote notifications from multiple providers to multiple devices我们的设备联网时(无论是蜂窝联网还是Wi-Fi联网)都会与苹果的APNs服务器建立一个长连接(persistent IP connection),当Provider推送一条通知的时候,这条通知并不是直接推送给了我们的设备,而是先推送到苹果的APNs服务器上面,而苹果的APNs服务器再通过与设备建立的长连接进而把通知推送到我们的设备上(参考图1-1,图1-2)。而当设备处于非联网状态的时候,APNs服务器会保留Provider所推送的最后一条通知,当设备转换为连网状态时,APNs则把其保留的最后一条通知推送给我们的设备;如果设备长时间处于非联网状态下,那么APNs服务器为其保存的最后一条通知也会丢失。Remote Notification必须要求设备连网状态下才能收到,并且太频繁的接收远程推送通知对设备的电池寿命是有一定的影响的。
deviceToken的生成
图1-3 Managing the device token 图1-4 Sharing the device token当一个App注册接收远程通知时,系统会发送请求到APNs服务器,APNs服务器收到此请求会根据请求所带的key值生成一个独一无二的value值也就是所谓的deviceToken,而后APNs服务器会把此deviceToken包装成一个NSData对象发送到对应请求的App上。然后App把此deviceToken发送给我们自己的服务器,就是所谓的Provider。Provider收到deviceToken以后进行储存等相关处理,以后Provider给我们的设备推送通知的时候,必须包含此deviceToken。(参考图1-3,图1-4)
这个时候你可能会问deviceToken到底是什么?有什么用?为什么是独一无二的?
- 是什么:deviceToken其实就是根据注册远程通知的时候向APNs服务器发送的Token key,Token key中包含了设备的UDID和App的Bundle Identifier,然后苹果APNs服务器根据此Token key编码生成一个deviceToken。deviceToken可以简单理解为就是包含了设备信息和应用信息的一串编码。
- 有什么用:上面提到Provider推送消息的时候必须带有此deviceToken,然后此消息就根据deviceToken(UDID + App's Bundle Identifier)找到对应的设备以及该设备上对应的应用,从而把此推送消息推送给此应用。
- 唯一性:苹果APNs的编码技术和deviceToken的独特作用保证了他的唯一性。唯一性并不是说一台设备上的一个应用程序永远只有一个deviceToken,当用户升级系统的时候deviceToken是会变化的。
<a id="远程推送应用"></a>远程推送应用
注册远程通知(获取deviceToken)
注册远程通知的方法
一般都是在App启动完成的时候去注册远程通知注册方法调用一般都在didFinishLaunchingWithOptions:方法中
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// 在iOS8之前注册远程通知的方法,如果项目要支持iOS8以前的版本,必须要写此方法
UIRemoteNotificationType types = UIRemoteNotificationTypeBadge | UIRemoteNotificationTypeSound | UIRemoteNotificationTypeAlert;
[[UIApplication sharedApplication] registerForRemoteNotificationTypes:types];
// iOS8之后注册远程通知的方法
UIUserNotificationType types = UIUserNotificationTypeBadge | UIUserNotificationTypeSound | UIUserNotificationTypeAlert;
UIUserNotificationSettings *mySettings = [UIUserNotificationSettings settingsForTypes:types categories:nil];
[[UIApplication sharedApplication] registerUserNotificationSettings:mySettings];
}
处理注册远程通知的回调方法
// 注册成功回调方法,其中deviceToken即为APNs返回的token
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
[self sendProviderDeviceToken:deviceToken]; // 将此deviceToken发送给Provider
}
// 注册失败回调方法,处理失败情况
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {
}
在iOS8之后增加了可操作通知类型,可操作通知允许开发者添加自定义跳转事件。这些高级功能此篇文章不讲解,有兴趣的同学可自己去了解UIUserNotificationAction
UIMutableUserNotificationAction
UIUserNotificationCategory
UIMutableUserNotificationCategory
这几个类。
处理接收到远程通知消息(会回调以下方法中的某一个)
application: didFinishLaunchingWithOptions:
此方法在程序第一次启动是调用,也就是说App从Terminate状态进入Foreground状态的时候,根据方法内代码判断是否有推送消息。
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// userInfo为收到远程通知的内容
NSDictionary *userInfo = launchOptions[UIApplicationLaunchOptionsRemoteNotificationKey];
if (userInfo) {
// 有推送的消息,处理推送的消息
}
return YES;
}
application: didReceiveRemoteNotification:
如果App处于Background状态时,只用用户点击了通知消息时才会调用该方法;如果App处于Foreground状态,会直接调用该方法。
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo {
}
application: didReceiveRemoteNotification: fetchCompletionHandler:
图2-1 Setting App Background ModesiOS7之前苹果是不支持多任务的,这也是iOS系统对硬件要求低,流畅性好的原因之一。iOS7之后,苹果开始支持多任务,即App可在后台做一些更新UI、下载数据的操作等。若要接收到远程推送的时候要在后台做一些事情则需要把后台远程推送模式打开。不适配iOS7之前系统的项目建议使用此后台模式,充分利用苹果推出的多任务模式,不枉费苹果的一片苦心啊!设置后台模式方法项目对应TARGETS-Capabilities-Background Modes-Remote Notifications具体设置方法如下图(图2-1)。
此方法不论App处于Foreground状态还是处于Background状态,收到远程推送消息的时候都会立即调用此方法。此方法需要配置后台模式并且在推送负载中必须有content-available此key值,对应的value值为1(详细介绍参考下面【远程通知负载内容】)。
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo
fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
// 在此方法中一定要调用completionHandler这个回调,告诉系统是否处理成功
UIBackgroundFetchResultNewData, // 成功接收到数据
UIBackgroundFetchResultNoData, // 没有接收到数据
UIBackgroundFetchResultFailed // 接受失败
if (userInfo) {
completionHandler(UIBackgroundFetchResultNewData);
} else {
completionHandler(UIBackgroundFetchResultNoData);
}
}
可操作通知类型收到推送消息时回调方法
// 此两个回调方法对应可操作通知类型,具体使用方法参考以上方法很容易理解,不在详细叙述
- (void)application:(UIApplication *)application handleActionWithIdentifier:(nullable NSString *)identifier
forRemoteNotification:(NSDictionary *)userInfo
completionHandler:(void(^)())completionHandler {
}
- (void)application:(UIApplication *)application handleActionWithIdentifier:(nullable NSString *)identifier
forRemoteNotification:(NSDictionary *)userInfo withResponseInfo:(NSDictionary *)responseInfo
completionHandler:(void(^)())completionHandler {
}
客户端和服务端的交互
说到这里我就随意吐槽一下推送,做推送个人感觉还是比较费劲的。而第一次启动App时询问用户是否接受推送消息的时候,大部分用户都会点击拒绝推送的吧,反正我是这样的。你辛辛苦苦做好了,想办法保证其推送准时性,想办法保证其推送到达率,结果用户一个拒绝,你所以的努力全都白费了啊,哈哈哈。
我这里主要想说的就是:我们要把对应的.p12(个人信息交换证书)证书给服务端的开发人员就好了。具体可参看我另一篇文章不让苹果开发者账号折磨我中的团队开发证书的管理中的导出.p12章节。
远程推送负载
远程推送负载大小
远程通知负载的大小根据Provider使用的API不同而不同。当使用HTTP/2 provider API时,负载最大为4096bytes,即4kB;当使用legacy binary interface时,负载最大为2048bytes,即2kB。当负载大小超过规定的负载大小时,APNs会拒绝发送此消息。
<a id="远程推送负载内容"></a>远程推送负载内容
内容格式必要要知道的啊,服务端一般会要我们客户端定义好格式给他们的。
每一条通知的消息都会组成一个JSON字典对象,其格式如下所示,示例中的key值为苹果官方所用key。自定义字段的时候要避开这些key值。
{
"aps" : {
"alert" : { // string or dictionary
"title" : "string"
"body" : "string",
"title-loc-key" : "string or null"
"title-loc-args" : "array of strings or null"
"action-loc-key" : "string or null"
"loc-key" : "string"
"loc-args" : "array of strings"
"launch-image" : "string"
},
"badge" : number,
"sound" : "string"
"content-available" : number;
"category" : "string"
},
}
aps:推送消息必须有的key
alert:推送消息包含此key值,系统就会根据用户的设置展示标准的推送信息
badge:在app图标上显示消息数量,缺少此key值,消息数量就不会改变,消除标记时把此key对应的value设置为0
sound:设置推送声音的key值,系统默认提示声音对应的value值为default
content-available:此key值设置为1,系统接收到推送消息时就会调用不同的回调方法,iOS7之后配置后台模式
category:UIMutableUserNotificationCategory's identifier 可操作通知类型的key值
title:简短描述此调推送消息的目的,适用系统iOS8.2之后版本
body:推送的内容
title-loc-key:功能类似title,附加功能是国际化,适用系统iOS8.2之后版本
title-loc-args:配合title-loc-key字段使用,适用系统iOS8.2之后版本
action-loc-key:可操作通知类型key值,不详细叙述
loc-key:参考title-loc-key
loc-args:参考title-loc-args
launch-image:点击推送消息或者移动事件滑块时,显示的图片。如果缺少此key值,会加载app默认的启动图片。
当然以上key值并不是每条推送消息都必带的key值,应当根据需求来选择所需要的key值,除了以上系统所提供的key值外,你还可以自定义自己的key值,来作为消息推送的负载,自定义key值与aps此key值并列。如下格式:
{
"aps" : {
"alert" : "Provider push messag.",
"badge" : 9,
"sound" : "toAlice.aiff"
},
"Id" : 1314, // 自定义key值
"type" : "customType" // 自定义key值
}
指定用户的推送
对于要求用户登录的App,推送是可以指定用户的,同一条推送有些用户可以收到,但是有些用户又不能收到。说起来这个就要提到另外的一个token了,一般称之为userToken,userToken一般都是根据自己公司自定义的规则去生成的。userToken是以用户的账号加对应的密码生成的。这样结合上面提到的deviceToken,就可以做到根据不同的用户推送不同的消息。deviceToken找到对应某台设备和该设备上的应用,而userToken对应找到该用户。客户端在上报deviceToken的时候,要把userToken对应一起上报给服务端也就是Provider。
浅谈推送第三方SDK
关于第三方推送的SDK有很多,常见的有极光推送 百度推送 个推 友盟推送等等。其实推送的原理都是大同小异的,理解了苹果推送的原理,这些第三方SDK还在是基本原理上面进行了扩展。对于用不用第三方SDK其实对我们客户端影响不大,推送第三方SDK主要是方便了服务端开发者。主要表现为服务端开发者不需要去开发维护自己的推送服务器与 APNs 对接,不必自己维护更新 deviceToken。当然了,第三方SDK也会提供一些额外的附属功能例如JPush提供了应用内消息推送,这在类似于聊天的场景里很方便的。看完这段是不是发现集成推送的第三方SDK和客户端没什么关系,我们工作量不仅没有减少,反而增加了一点点啊。至于第三方SDK的其他功能,大家可自行去对应官网学习,这里不再过多描述。
利用runtime实现推送消息万能跳转
此段参考了@汉斯哈哈哈的一篇iOS 万能跳转界面方法万能跳转就是可以跳转到指定的任意一个界面,但是这个和服务端耦合性太强,使用的时候要慎重考虑,而且公司一般都是iOS,Android共用同一套推送规则很难让服务端在给你开一条新的推送规则,不便于维护,而且成本也是需要考虑的。写此段的目的就是当产品有这样的需求的时候还是可以参考一下的。
定义推送规则
// 客户端控制器的属性
@interface YBViewController : UIViewController
/** 频道Id */
@property (nonatomic, copy) NSString *Id;
/** 频道type */
@property (nonatomic, copy) NSString *type;
@end
// 服务端推送数据格式
{
"aps" : { "alert" : "Provider push messag" },
"class" : "YBViewController",
"property" : {
"Id" : 1314,
"type" : "customType"
}
}
跳转逻辑
// 接收到推送后跳转
- (void)didReceiveRemoteNotificationAndPushToViewController:(NSDictionary *)userInfo {
// 创建类
NSString *class = userInfo[@"class"];
const char *className = [class cStringUsingEncoding:NSASCIIStringEncoding];
Class newClass = objc_getClass(className);
if (!newClass) {
Class superClass = [NSObject class];
newClass = objc_allocateClassPair(superClass, className, 0);
objc_registerClassPair(newClass);
}
// 创建跳转控制器对象
id destinationViewController = [[newClass alloc] init];
// 对该对象赋值属性
NSDictionary *propertys = userInfo[@"property"];
[propertys enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
// 检测这个对象是否存在该属性
if ([self checkIsExitPropertyWithdestinationViewController:destinationViewController verifyPropertyName:key]) {
[destinationViewController setValue:obj forKey:key];
}
}];
// 跳转
UITabBarController *tabViewController = (UITabBarController *)self.window.rootViewController;
UINavigationController *sourceViewController = (UINavigationController *)tabViewController.viewControllers[tabViewController.selectedIndex];
[sourceViewController pushViewController:destinationViewController animated:YES];
}
// 检测对象是否存在该属性
- (BOOL)checkIsExitPropertyWithdestinationViewController:(id)destinationViewController verifyPropertyName:(NSString *)verifyPropertyName {
// 获取对象里的属性列表
unsigned int outCount, i;
objc_property_t *properties = class_copyPropertyList([destinationViewController class], &outCount);
for (i = 0; i < outCount; i++) {
objc_property_t property = properties[i];
// 属性名转成字符串
NSString *propertyName = [[NSString alloc] initWithCString:property_getName(property) encoding:NSUTF8StringEncoding];
// 判断该属性是否存在
if ([propertyName isEqualToString:verifyPropertyName]) {
free(properties);
return YES;
}
}
free(properties);
return NO;
}
总结
好好理解远程推送的原理就会发现,其实远程推送并没有那么难做啊。上面的一些图片有些来源于苹果官方文档,有些是自己所截图。一些知识也是参考了苹果的官网文档。其中一些深入的推送相关知识普遍性不是太高,所以也没有提到,例如:可操作通知类型,通知显示国际化,自定义通知声音,Provider-APNs-Device详细连接情况及推送负载的底层数据格式等。如果你对这些知识很感兴趣也很欢迎私密我私下交流,共同进步。敬请期待本篇的姊妹篇iOS推送之本地推送(iOS Notification Of Local Notification)。
网友评论
由于网络或者其他原因不成功,还会继续调用这个方法将此deviceToken发送给Provider吗?
"aps" : {
"alert" : "Provider push messag.",
"badge" : 9,
"sound" : "toAlice.aiff"
},
"Id" : 1314, // 自定义key值
"type" : "customType" // 自定义key值
}
请问推送中是不是通过sound 字段控制声音的,系统会根据sound 字段的内容,在收到推送的时候播放指定的声音,当然前提是本地有这个声音文件。另外,假如sound 字段为空,是播放默认声音还是静音模式?
"aps" : {
"alert" : { // string or dictionary
"title" : "string"
"body" : "string",
"title-loc-key" : "string or null"
"title-loc-args" : "array of strings or null"
"action-loc-key" : "string or null"
"loc-key" : "string"
"loc-args" : "array of strings"
"launch-image" : "string"
},
"badge" : number,
"sound" : "string"
"content-available" : number;
"category" : "string"
},
}
这段配置是要写在哪里?写在AppDelegate.m文件?
这个地方是什么意思?表示不太懂 ~