作者:allenzzhao,腾讯 IEG运营开发工程师
消息推送我们几乎每天都会用到,但你知道iOS中的消息推送是如何实现的吗?本文将从推送权限申请,到本地和远程消息推送,再到App对推送消息的处理等多个步骤,详细介绍iOS中消息推送的工作流程。
1、概述
消息推送是一种App向用户传递信息的重要方式,无论App是否正在运行,只要用户打开了通知权限就能够收到推送消息。开发者通过调用iOS系统方法就可以发起本地消息推送,例如我们最常见的闹钟应用,App能够根据本地存储的闹钟信息直接发起本地通知,因此即使没有网络也能收到闹钟提醒。
远程消息推送则是由业务方服务器将消息内容按照固定格式发送到Apple Push Notitfication service(简称APNs),然后再经由苹果的APNs服务器推送到用户设备上,例如腾讯新闻可以向用户推送时事热点新闻,QQ邮箱可以为用户推送收到新邮件的提醒,游戏App可以通过这种方式通知玩家有新的游戏福利,既能够及时地通知用户重要信息,也能够促使用户通过推送消息打开或唤醒App,提高App的使用率。除了标题、内容、提示音和角标数字等固定推送参数以外,开发者还可以在推送消息中增加自定义参数,让用户在点击推送消息时能够直达相关新闻、邮件或福利页面,提供更好的用户体验和页面的曝光率。
2、XCode配置
在使用消息推送相关功能之前,我们首先需要准备支持推送功能的证书,个人开发者可以参考腾讯云的TPNS文档,在苹果开发者中心中配置和导出推送证书。
此外,还需要在XCode的工程配置Signing & Capabilities配置中增加消息推送权限,在操作完成后Xcode会自动生成或更新工程的entitlements文件,增加如图所示的APS Environment字段。
3、申请消息推送权限
无论是本地推送还是远程推送,在推送前都必须要先向用户申请推送权限,只有用户授权后才能够收到推送消息。
苹果在iOS10中引入了UserNotifications框架,将推送相关功能进行了封装和升级,除了以前UIApplication可以做到的一些基本的本地和远程消息推送功能外,还增加了撤回或修改推送消息、自定义通知UI、推送消息前台显示等功能。在iOS10及以上的版本中,苹果推荐开发者使用requestAuthorizationWithOptions:completionHandler:方法向用户申请消息推送权限,该方法需要指定一个用于描述推送权限的UNAuthorizationOptions类型参数,包括alert(消息的标题、文字等内容)、sound(消息提示音)、badge(App右上角显示的角标);还可以在该方法的completionHandler回调方法中通过granted参数来判断用户是否允许了授权。相关代码如下:
#import <UserNotifications/UserNotifications.h>
……
[[UNUserNotificationCenter currentNotificationCenter]
requestAuthorizationWithOptions:UNAuthorizationOptionSound|UNAuthorizationOptionAlert|UNAuthorizationOptionBadge
completionHandler:^(BOOL granted, NSError * _Nullable error) {if(granted){//用户允许了推送权限申请}else{//用户拒绝了推送权限申请}
}];
在iOS9中,直接使用UIApplication的registerUserNotificationSettings方法即可,该方法同样需要通过配置sound、alert、badge等参数,但是没有提供用于判断用户点击了授权还是拒绝的回调方法。相关代码如下:
[[UIApplication sharedApplication] registerUserNotificationSettings:[UIUserNotificationSettings settingsForTypes:(UIUserNotificationTypeSound | UIUserNotificationTypeAlert | UIUserNotificationTypeBadge)categories:nil]];
要注意无论是UserNotifications还是UIApplication的申请推送权限的方法,上文中的申请用户授权的系统弹窗都只会显示一次,iOS会记录用户对于该App的授权状态,不会向用户重复申请授权。消息推送是App的一项重要功能,同时也是很好的运营手段,因此很多App在启动后会检查消息推送的授权状态,如果用户拒绝了消息推送权限,仍然会以一定的频率弹窗提醒用户,在iOS的设置中心中再去打开App的推送权限。相关代码如下:
if(@available(iOS 10.0,*)){[[UNUserNotificationCenter currentNotificationCenter] getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) {if (UNAuthorizationStatusDenied == settings.authorizationStatus) {//用户拒绝消息推送,弹窗提示引导用户去系统设置中进行授权UIAlertController* alert = [UIAlertController alertControllerWithTitle:@"未打开推送功能" message:@"请在设备的\"设置-App-通知\"选项中,允许通知" preferredStyle:UIAlertControllerStyleAlert];UIAlertAction* cancel = [UIAlertAction actionWithTitle:@"取消" style:UIAlertActionStyleCancel handler:^(UIAlertAction* action){[alert dismissViewControllerAnimated: YES completion: nil];}];UIAlertAction* ok = [UIAlertAction actionWithTitle:@"去设置" style:UIAlertActionStyleDefault handler:^(UIAlertAction* action){[alert dismissViewControllerAnimated: YES completion: nil];NSURL * url = [NSURL URLWithString:UIApplicationOpenSettingsURLString];if([[UIApplication sharedApplication] canOpenURL:url]){NSURL*url =[NSURL URLWithString:UIApplicationOpenSettingsURLString];[[UIApplication sharedApplication] openURL:url];}}];[alert addAction: cancel];[alert addAction: ok];[[UIApplication sharedApplication].keyWindow.rootViewController presentViewController:alert animated: YES completion: nil];}}];
}else{UIUserNotificationSettings *setting = [[UIApplication sharedApplication] currentUserNotificationSettings];if (UIUserNotificationTypeNone == setting.types) {//用户拒绝消息推送,处理方式同上}
}
4、本地推送
在iOS10中,UserNotifications框架为我们提供了UNMutableNotificationContent对象描述消息推送的标题、内容、提示音、角表等内容,UNNotificationTrigger对象描述消息推送的推送时间策略,UNNotificationRequest对象整合推送内容和时间。每个Request对象都需要配置一个id来标识该条推送内容,UNUserNotificationCenter通过该id来管理(包括增加、删除、查询和修改)所有的Request。UNNotificationTrigger有四个子类,分别是UNTimeIntervalNotificationTrigger用于通过时间间隔控制消息推送,UNCalendarNotificationTrigger通过日期控制消息推送,UNLocationNotificationTrigger通过地理位置控制消息推送,UNPushNotificationTrigger远程消息推送对象。相关代码如下:
//推送内容
UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init];
content.title = @"推送标题";
content.body = @"推送内容";
content.sound = [UNNotificationSound defaultSound];//默认提示音
//日期推送,今日15:53:00推送本地消息
NSDateComponents* date = [[NSDateComponents alloc] init];
date.hour = 15;
date.minute = 53;
UNCalendarNotificationTrigger* calendarTrigger = [UNCalendarNotificationTriggertriggerWithDateMatchingComponents:date repeats:NO];
//倒计时推送,2s后推送本地消息
UNTimeIntervalNotificationTrigger *intervalTrigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:2 repeats:NO];
UNNotificationRequest* request = [UNNotificationRequestrequestWithIdentifier:@"testId" content:content trigger:calendarTrigger];
//将推送请求添加到管理中心才会生效
UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
[center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) {if (error != nil) {NSLog(@"%@", error.localizedDescription);}
}];
在iOS9中,UIApplication提供了presentLocalNotificationNow和scheduleLocalNotification两个本地消息推送的方法,分别表示立即推送和按照固定日期推送,UILocalNotification同时描述了消息内容和推送的时机。示例代码是一个2s后推送的本地消息,soundName属性用于描述消息的提示音,用户可以自定义提示音(需要将音频文件打包到安装包中)或者使用默认提示音乐,repeatInterval和repeatCalendar属性分别用于根据时间差和日期进行重复提示的操作。相关代码如下:
UILocalNotification *notification = [[UILocalNotification alloc] init];
notification.fireDate = [NSDate dateWithTimeIntervalSinceNow:2];
notification.alertTitle = @"推送标题";
notification.alertBody = @"推送内容";
//notification.soundName = UILocalNotificationDefaultSoundName;
notification.soundName = @"mysound.wav";
[[UIApplication sharedApplication] scheduleLocalNotification:notification];
5、远程推送
不同于本地消息推送不依赖网络请求,可以直接调用iOS系统方法,远程消息推送的实现涉及到用户设备、我们自己的业务方服务器和苹果的APNs服务的交互。不同于Android系统中远程消息推送的实现,需要App自身通过后台服务与业务服务器维持长链接通信,iOS中的消息推送是操作系统与苹果的APNs服务器直接交互实现的,App自身并不需要维持与服务器的连接。只要用户开启了推送权限,我们的业务服务器就可以随时通过调用APNs服务向用户推送通知,这样既能够为开发者和用户提供安全稳定的推送服务,也够节省系统资源消耗,提高系统流畅度和电池续航能力。
iOS客户端远程消息推送的实现可以分为以下几个流程:
用户的iphone通过iOS的系统方法调用与苹果的APNs服务器通信,获取设备的deviceToken,它是由APNs服务分配的用于唯一标识不同设备上的不同App,可以认为是由deviceID、bundleId和安装时的相关信息生成的,App的升级操作deviceToken不变,卸载重装App、恢复和重装操作系统后的deviceToken会发生变化。
苹果的APNs服务是基于deviceToken实现的,因此需要将设备的deviceToken发送到我们的业务服务器中,用于后续的消息推送。一个设备可能登录过多个用户,一个用户也可能在多个设备中登录过,当我们需要给不同用户推送不同的消息时,除了deviceToken之外,我们还需要保存用户的openid与deviceToken的映射关系。我们可以在用户登录成功后的时机更新openid和deviceToken的映射关系,用户退出后取消映射关系,只保存用户最后登录设备的deviceToken,避免一个设备收到多个重复通知和一个用户在不同设备收到多个通知等情况。
在新闻类App出现事实热点新闻时,后台服务就可以携带消息内容和deviceToken等内容,向苹果的APNs服务发起消息推送请求,推送消息的实现是异步的,只要请求格式和deviceToken检查通过APNs服务就不会报错,但是用户还是可能因为网络异常或者关闭了推送权限等原因收不到推送消息。
APNs服务向用户设备推送消息这一步也是异步的,在用户关机或网络异常收不到推送的情况下,APNs会为每个deviceToken保留最后一条推送消息,待网络恢复后再次推送。
5.1、获取设备deviceToken
在App启动时,我们可以通过UIApplication的registerForRemoteNotifications方法向苹果的APNS服务器请求deviceToken,如果请求成功则didRegisterForRemoteNotificationsWithDeviceToken回调方法会被执行,为了便于业务服务器的调用,我们一般会将二进制的deviceToken转换为16进制的字符串后再进行存储;如果请求失败则didFailToRegisterForRemoteNotificationsWithError方法也会被调用,并附带具体的错误信息。相关代码如下:
//调用系统方法请求deviceToken
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {[[UIApplication sharedApplication] registerForRemoteNotifications];
}
//deviceToken获取成功的回调
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken{NSString *deviceTokenStr;NSUInteger length = deviceToken.length;if (![deviceToken isKindOfClass:[NSData class]] || length == 0) {return;}const unsigned char *bytes = (const unsigned char *)deviceToken.bytes;NSMutableString *hex = [NSMutableString new];for (NSInteger i = 0; i < deviceToken.length; i++) {[hex appendFormat:@"%02x", bytes[i]];}deviceTokenStr = [hex copy];NSLog(@"%@", deviceTokenStr);
}
//deviceToken获取失败的回调
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error{NSLog(@"error,%@",error);
}
5.2、后台调用APNs推送
业务方服务器调用APNs服务时首先要建立安全连接,进行开发者身份的认证,分为基于证书(Certificate-Based)和基于Token(Token-Based)的认证两种方式,比较常用的是基于证书的认证方式。推送证书分为开发环境和生产环境的证书,分别对应不同的APNs推送接口,我们从苹果开发者平台或者第三方平台导出的推送证书一般有p12和pem两种格式的文件,为了便于接口调用我们可以通过以下命令将p12格式的文件转换为pem证书。
openssl pkcs12 -in push_dev.p12 -out push_dev.pem -nodes
基于证书建立TLS连接的流程如下图所示:
业务方服务器(Provider)向APNs服务器发起建立TLS连接的请求。
APNs服务器返回的它的证书,供业务方服务器校验。
业务方服务器提供自己的推送证书,供APNs服务器校验。
APNs服务器验证业务方服务器提供的推送证书无误后,TLS连接就已经建立完成,之后业务方服务器就可以直接向APNs发送消息推送请求了。
业务方与APNs建立请求的简易实现的PHP代码实现如下:
$deviceToken= '22124c450762170ca2ddb32a50381dd2c3026dbdb020f6dddcabefdca724fdd6';
//dev params
$devUrl = 'ssl://gateway.sandbox.push.apple.com:2195';
$devCertificate = 'push_dev.pem';
//product params
$proUrl = 'ssl://gateway.push.apple.com:2195';
$proCertificate = 'push_pro.pem';
// Change 2 : If any
$title = '标题';//消息标题
$content = '消息内容';//内容
$ctx = stream_context_create();
// Change 3 : APNS Cert File name and location.
stream_context_set_option($ctx, 'ssl', 'local_cert', $devCertificate);
// Open a connection to the APNS server
$fp = stream_socket_client($devUrl, $err, $errstr, 60, STREAM_CLIENT_CONNECT|STREAM_CLIENT_PERSISTENT, $ctx);
if (!$fp)exit("Failed to connect: $err $errstr" . PHP_EOL);
echo 'Connected to APNS' . PHP_EOL;
// Create the payload body
$body['aps'] = array('alert' =>array('title'=>$title,'body'=>$content),'sound' => 'default');
//自定义内容
$body['userInfo'] = array('url' => 'https://www.qq.com',
);
// Encode the payload as JSON
$payload = json_encode($body);
// Build the binary notification
$msg = chr(0) . pack('n', 32) . pack('H*', $deviceToken) . pack('n', strlen($payload)) . $payload;
// Send it to the server
$result = fwrite($fp, $msg, strlen($msg));
//发送多个就调用多次fwrite
//$result = fwrite($fp, $msg, strlen($msg));
echo $msg;
if (!$result)echo 'Message not delivered' . PHP_EOL;
elseecho 'Message successfully delivered' . PHP_EOL;
// Close the connection to the server
fclose($fp);
业务方服务器通过证书与APNs建立安全连接后可以进行连续多次的消息推送操作,每次消息推送都要指定deviceToken和Payload参数。Payload是一个json对象,用于配置iOS在收到远程消息推送时的展现形式,aps参数包含了苹果预设的alert、sound、badge等参数,其中alert参数可以是字符串,或者包含title、body等参数的字典类型;badge参数使用整形设置App图标右上角显示的数字,badge设置为0时角标不会显示;sound参数用于设置推送的声音,不传该参数或者传递空字符串则推送不会发出提示音,设置为default时使用系统默认提示音,也可以设置为具体的音频文件名,需要提前音频文件放到项目的bundle目录,且时长不能超过30s。
除了预设参数以外,我们还可以在aps的同级自定义一些参数,这些参数也可以是字典类型,再嵌套其他参数,例如示例代码中我们自定义的userInfo对象,但是一般推送消息的payload不宜过大,应控制在4K以内,建议只透传一些id和url等关键参数,具体的内容由客户端在收到推送时再去通过网络请求获取。
{"aps" : {"alert" : {"title" : "Game Request","subtitle" : "Five Card Draw","body" : "Bob wants to play poker",},"badge" : 9,"sound" : "gameMusic.wav",},"gameID" : "12345678"
}
上述payload包含了常见的推送消息的标题、副标题、内容、消息提示音、App的角标数字等预设参数,以及一个开发者自定义的gameID参数。用户点击推送消息后会自动启动或从后台唤醒App,我们可以在系统的回调方法中获取到自定义参数,并根据gameID自动为用户打开该游戏页面。
5.3、消息推送调试工具
在进行APNs接口调试时,我们可以利用一些优秀的推送调试工具帮助我们验证payload或证书等内容的合法性。本文介绍两款比较流行的开源软件,分别是国外的Knuff和国内开发者维护的smartPush。
Knuff:https://github.com/KnuffApp/Knuff
SmartPush:https://github.com/shaojiankui/SmartPush
6、App推送消息的处理
在iOS10中,UserNotifications框架为开发者提供了UNUserNotificationCenterDelegate协议,开发者可以通过实现协议中的方法,在App接收到推送消息和用户点击推送消息时进行一些业务逻辑的处理。无论是本地推送还是远程推送的消息,App的运行状态都可能处于以下三种状态:
App正在前台运行,此时用户正在使用App,收到推送消息时默认不会弹出消息提示框,willPresentNotification回调方法会被调用,开发者可以从UNNotification对象中获取该推送消息的payload内容,进而获取自定义参数,然后显示一个自定义弹窗提示用户收到了新的消息;也可以在willPresentNotification方法中通过completionHandler函数的调用让推送消息直接在前台显示,用户点击前台显示的推送消息时,didReceiveNotificationResponse回调方法也会被执行。
App在后台运行,此时用户点击推送消息会将App从后台唤醒,didReceiveNotificationResponse回调方法会被执行,开发者可以在该方法中获得payload,解析自定义参数并自动打开对应的页面。
App尚未启动,此时用户点击推送消息会打开App,开发者可以从launchOptions中获取本地或远程推送消息中的自定义参数,待页面初始化完成后进行相关页面的跳转。
#import <UserNotifications/UserNotifications.h>
@interface AppDelegate ()<UNUserNotificationCenterDelegate>
@end@implementation AppDelegate
//在App启动后就将AppDelegate对象配置为NotificationCenter的delegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {[UNUserNotificationCenter currentNotificationCenter].delegate = self;// NSDictionary *localNotification = [launchOptions valueForKey:UIApplicationLaunchOptionsLocalNotificationKey];NSDictionary *remoteNotification = [launchOptions valueForKey:UIApplicationLaunchOptionsRemoteNotificationKey];if(remoteNotification){//app已退出,点击拉起了appNSDictionary *params = userInfo[@"userInfo"];//此时NavigationController还未初始化,可以先暂存参数,稍后跳转[PageSwitch handlePushSwitch:params];}
}
//用户点击推送消息的回调
- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler API_AVAILABLE(ios(10.0)){UNNotification *noti = ((UNNotificationResponse *)response).notification;NSDictionary *userInfo = noti.request.content.userInfo;NSDictionary *params = userInfo[@"userInfo"];//根据消息推送中的参数,在用户点击推送时自动进行跳转[PageSwitch handlePushSwitch:params];
}
//App在前台运行时收到推送消息的回调
- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(nonnull UNNotification *)notification withCompletionHandler:(nonnull void (^)(UNNotificationPresentationOptions))completionHandler API_AVAILABLE(ios(10.0)){//可以让App在前台运行时也能收到推送消息completionHandler(UNNotificationPresentationOptionBadge|UNNotificationPresentationOptionAlert);
}
在iOS9中,UIApplication提供了下面三个消息推送的处理方法,分别是远程消息推送、远程静默推送和本地消息推送的回调处理方法。前两个回调方法都能够用于App远程消息推送的处理,同时使用时只有远程静默推送方法会被调用,当payload包含参数content-available=1时,该推送就是静默推送,静默推送不会显示任何推送消息,当App在后台挂起时,静默推送的回调方法会被执行,开发者有30s的时间内在该回调方法中处理一些业务逻辑,并在处理完成后调用fetchCompletionHandler。
//远程消息推送回调方法,ios(3.0, 10.0)
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo;
//远程静默推送回调方法,ios(7.0, *)
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult result))completionHandler API_AVAILABLE(ios(7.0));
//本地消息推送回调方法,ios(4.0, 10.0)
-(void)application:(UIApplication *)application didReceiveLocalNotification:(UILocalNotification *)notification;
UIApplication中的这三个方法在:①App在前台运行时收到通知,②App在后台运行时用户点击推送消息拉起App,这两种场景下都会被调用,区别是前两种方法对应远程消息推送的接收和点击触发响应,didReceiveLocalNotification用于本地消息推送。我们可以通过UIApplication的applicationState属性来判断App是否在前台运行,然后分别实现:①用户点击消息唤起后台App并打开对应页面,②用户前台使用App时显示自定义弹窗。
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo{if([UIApplication sharedApplication].applicationState == UIApplicationStateActive){NSLog(@"在前台,%@",userInfo);}else{NSLog(@"从后台进入前台,%@",userInfo);NSDictionary *params = userInfo[@"userInfo"];if([Tools isValidString:params[@"url"]]){NSString *routeUrl = params[@"url"];[PageSwitch handlePushSwitch:params];}}
}
7、总结
本文首先介绍了消息推送相关的工程配置和推送权限的申请,然后分别介绍了本地和远程消息推送的不同使用场景和实现方法,最后介绍了App在收到推送消息后的相关回调方法和处理逻辑。在实际的项目开发中,我们往往会选择腾讯云推送或极光推送等更加成熟的第三方消息推送平台,这些平台都提供了相对完善的推送和数据统计服务,通过接口和SDK屏蔽了底层逻辑的实现,通过对iOS消息推送的实现过程的了解也能够帮助我们更好的使用这些平台。
由于时间的关系,自己的研究并不深入,如有疏漏和错误,欢迎留言指正交流~
8、扩展阅读
苹果官方技术文档,https://developer.apple.com/documentation/usernotifications
史上最全iOS Push技术详解,https://cloud.tencent.com/developer/article/1198303
iOS远程推送-APNs详解,https://juejin.im/post/6844903893592178696
iOS静默推送进阶知识,https://www.jianshu.com/p/c211bd295d58
iOS10自定义通知UI,https://www.jianshu.com/p/85ac47bdf387
信鸽文档-推送服务介绍,https://xg.qq.com/docs/ios_access/ios_push_introduction.html
浅谈iOS和Android后台实时消息推送的原理和区别,https://cloud.tencent.com/developer/article/1150967
浅谈基于HTTP2推送消息到APNs,http://www.linkedkeeper.com/167.html
PHP基于socket的ios 推送的实现,https://www.fzb.me/2015-9-7-sockect-implement-for-apns.html
如何构建一套高可用的移动消息推送平台?,https://www.infoq.cn/article/HA-mobile-message-push-platform