开篇叨叨 目前推送已经成为了大多数应用的标配,通过推送能够及时地将信息送达到用户手中,可以说极大多数的产品运营都会借助推送功能来提升产品的打开率、使用率、存活率。 该篇博客将从如下几个方面对推送进行比较细致得介绍:
推送的种类
本地推送和远程推送
远程推送的服务扩展和内容扩展
推送的种类 在iOS开发中一般会遇到如下的三种推送类型:
在线推送:在线推送一般指的是消息通过应用自建的网络长链接通道推送到应用本身。这种推送是应用自身的行为与苹果系统无关,与设置中是否打开“通知”无关,是基于自建的长链接来实现的,这个不是这篇文章的重点。后续如果有机会会另外开一篇博客专门介绍在线推送的细节。
本地推送:这种推送是应用自身行为,不需要设备联网,也不经过APNs,但是它需要设备打开”通知”设置,比较常见的比如闹钟通知,待办事项提醒等。
远程推送:远程推送是这篇博客的重心,它需要开启“通知”设置,并且需要联网状态,因为它需要设备与APNs保持着长链接。远程推送又成为离线推送,当APP在离线状体(kill掉进程、切到后台、锁屏)时可以正常收到推送的消息。
远程推送还可以细分为两种类型:普通远程推送和静默远程推送: 普通远程推送在收到推送的时候有文字和声音,点开通知,进入应用后才会执行:
[UNUserNotificationCenterDelegate didReceiveNotificationResponse:withCompletionHandler:]
而静默推送是没有文字和声音的,在不点开通知,不打开应用的情况下就能执行,用户完全感知不到。
[UIApplicationDelegate application:didReceiveRemoteNotification:fetchCompletionHandler:]
下面是几种推送的简要对比:
|推送类型|是否需要打开“通知”|是否需要APNs|是否需要在前台| |-|-|-|-|-| |在线推送|不需要|不需要|需要| |远程推送|需要|需要|不需要| |本地推送|需要|不需要|不需要|
本地推送
- (void)sendLocalNotification { NSString * title = @"本地推送通知-title" ; NSString * subtitle = @"本地推送通知-subtitle" ; NSString * body = @"这是一条本地推送通知,请查收" ; NSInteger badge = 1 ; NSInteger timeInteval = 5 ; NSDictionary * userInfo = @{@"userid" : @"123456" }; if (@available (iOS 10.0 , * )) { UNMutableNotificationContent * content = [[UNMutableNotificationContent alloc] init ]; content.sound = [UNNotificationSound defaultSound]; content.title = title; content.subtitle = subtitle; content.body = body; content.badge = @(badge); content.userInfo = userInfo; NSError * error = nil ; NSString * path = [[NSBundle mainBundle] pathForResource:@"testImage" ofType:@"png" ]; UNNotificationAttachment * attr = [UNNotificationAttachment attachmentWithIdentifier:@"attr" URL :[NSURL fileURLWithPath:path] options:nil error:& error]; if (error) { } content.attachments = @[attr]; content.launchImageName = @"icon_launch" ; UNTimeIntervalNotificationTrigger * trigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:timeInteval repeats:NO ]; UNNotificationRequest * request = [UNNotificationRequest requestWithIdentifier:kLocalNotificationReqestIdentifer content:content trigger:trigger]; [[UNUserNotificationCenter currentNotificationCenter] addNotificationRequest:request withCompletionHandler:^ (NSError * _Nullable error) {}]; } else { UILocalNotification * localNotification = [[UILocalNotification alloc] init ]; localNotification.timeZone = [NSTimeZone defaultTimeZone]; localNotification.fireDate = [NSDate dateWithTimeIntervalSinceNow:5 ]; localNotification.alertBody = title; localNotification.alertAction = @"详情" ; localNotification.soundName = UILocalNotificationDefaultSoundName ; localNotification.userInfo = userInfo; [[UIApplication sharedApplication] scheduleLocalNotification:localNotification]; } }
iOS 10之前:
- (BOOL )application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions; - (void )application:(UIApplication *)application didReceiveLocalNotification:(UILocalNotification *)notification;
iOS 10之后:
- (void) userNotificationCenter:(UNUserNotificationCenter *) center willPresentNotification:(UNNotification *) notification withCompletionHandler:(void (^) (UNNotificationPresentationOptions options) )completionHandler; - (void) userNotificationCenter:(UNUserNotificationCenter *) center didReceiveNotificationResponse:(UNNotificationResponse *) response withCompletionHandler:(void(^)(void) )completionHandler;
远程推送 首先先放上官方远程推送的指导链接APNs Overview
远程推送证书申请 关于证书的申请可以参照下面这篇博客,写得比较详细之前也是参照这篇博客进行配置的。iOS 远程推送证书详细制作流程
远程推送流程 远程推送涉及到如下几个方面:
iOS 设备
APNs
业务推送服务端
简单将整个推送的数据流向可以分成如下三个步骤:
1. 业务推送服务端将消息先发送到苹果的APNs2. 由苹果的APNs将消息推送到客户iOS设备端3. 由iOS系统将接收到的消息传递给相应的App
如下图所示:
APNs是Apple Push Notification service(苹果推送通知服务)的缩写,在Android平台上,推送是靠应用自身在后台维持客户端与业务推送服务器之间的一个长链接来实现的,但是在iOS系统中为了节省手机电池电量的损耗,系统不允许应用在后台进行过多的操作,一旦应用进入后台,系统只会分配少量时间给应用做必要的收尾工作,虽然我们可以再次申请,但是也是难以做到在后台长期驻留的,应用进入后台后虽然还不会从内存中移除,但是已经不运行任何代码了,这就导致了一旦应用进入后台将不能收到实时的信息。为了解决这个问题苹果推出了APNs,iOS系统自己做了个长连接,依托一个或几个系统常驻内存的进程运作,保持与APNs之间的通讯,接管所有应用的消息推送,独立于应用之外,而且这种长连接即使在手机休眠的时候也一直保持,iOS设备上的所有应用共用这一套长链接,包括iOS版本更新提示,手机时钟校准什么的也都是通过这个链接通知到设备上,设备上的应用通过deviceToken进行区分。
下面是远程推送的详细过程:
在我们应用启动的时候会调用向iOS系统请求deviceToken,iOS系统再将请求转发给APNs服务,这时候APNs在对设备进行校验之后就会把当前设备加入到Push服务的设备列表中,同时返给我们一个deviceToken。 下面是请求deviceToken的代码:
- (void)registerForRemoteNotifications:(UIApplication * )application{ if (@available (iOS 10.0 , * )) { UNAuthorizationOptions authOptions = UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge ; [[UNUserNotificationCenter currentNotificationCenter] requestAuthorizationWithOptions:authOptions completionHandler:^ (BOOL granted, NSError * _Nullable error) { if (error) { } if (! granted){ } }]; [UNUserNotificationCenter currentNotificationCenter].delegate = self ; } else { UIUserNotificationType allNotificationTypes = (UIUserNotificationTypeSound | UIUserNotificationTypeAlert | UIUserNotificationTypeBadge ); UIUserNotificationSettings * settings = [UIUserNotificationSettings settingsForTypes:allNotificationTypes categories:nil ]; [application registerUserNotificationSettings:settings]; } [application registerForRemoteNotifications]; }
在注册远程通知后,会通过如下的delegate方法返回deviceToken:
- (void) application:(UIApplication *) application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *) deviceToken;
如果获取失败则会通过下面的方法返回错误信息:
- (void)application :(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error ;
deviceToken在app卸载后重装等情况时会变化,因此为确保deviceToken变化后app仍然能够正常接收服务器端发送的通知,建议每次启动应用都将获取到的deviceToken传给业务推送服务器,业务服务器将会这些deviceToken存到数据库中。当有需要被推送的消息时,业务推送服务器会按照苹果官方的消息格式组织消息内容,并带上目标应用的deviceToken 一并发给APNs服务器,下面是一个消息的内容例子:
{ "aps" :{ "alert" :{ "title" :"这是一条推送测试消息标题" , "subtitle" :"这是一条推送测试消息副标题"", "body" :"这是一条推送测试消息" , "title-loc-key" :"TITLE_LOC_KEY" , "title-loc-args" :[ "idl_01" , "idl_02" ], "loc-key" :"LOC_KEY" , "loc-args" :[ "idl_01" , "idl_02" ] }, "sound" :"sound01.wav" , "badge" :1 , "mutable-content" :1 , "category" :"realtime" }, "msgid" :"123456" }
payload在iOS8之前最大限制是256字节,iOS8之后大小限制为2K字节,目前payload大小可以达到4K,对于VoIP最大尺寸可以达到5K.APN (Apple Push Notification) payload size limit
APNs收到业务推送服务器发来的消息后,会在自己维护的Push设备列表中查找,找到匹配的设备后,由于我们的设备和APNs维持一个基于SSL协议的TCP流通讯长连接,APNs就可以通过这个长链接将新消息推送到我们设备上,然后由系统将消息呈现出来。
但是如果推送的时候deviceToken对应的机器在APNs服务器上处于离线状态,苹果会保存推送信息一段时间,在离线的设备恢复在线状态时,重新将推送信息到该机器。对于连续推送的情况下,APNs永远只存储最新的一条,上一条信息将会被抛弃,如果某个设备长时间不在线,APNs也会将消息丢掉。如果有多条推送任务时,苹果推荐使用单个连接持续发送,而不是重复的开关连接,否则会被苹果认为DOS攻击而断开连接。如果有多台服务器,可以并发连接到APNS,分摊推送任务,可以更高效的执行任务,发送多条推送任务时,如果其中有一条推送使用了错误的deviceToken,那么这条连接就会被断掉,导致后面的推送任务停止执行。
上面介绍的是正常的流程,但是如果应用从设备卸载后推送的消息又如何处理呢?如何让APNs和业务推送服务器都知道不去向这台卸载了应用的设备推送消息呢?这是通过APNs的Feedback service,APNs会持续的更新Feedback service的列表,当我们的业务推送服务器将信息发给APNs推送到我们的设备时,如果这时设备无法将消息推送到指定的应用,就会向APNs服务器报告一个反馈信息,而这个信息就记录在Feedback service中。因此业务推送服务器可以通过定时的去检测Feedback service的列表,然后删除在自己数据库中记录的存在于反馈列表中的 deviceToken,从而不再向这些设备发送推送信息,下面是从Feedback Service中读取到的过期设备的信息结构:
第一部分是设备失效后的时间信息 第二个部分是deviceToken的长度 第三部分就是失效的deviceToken
如果一个消息发送失败呢? 比如通知格式不正确或APNs无法解析,这时候APNs 会在大约 500ms 后断掉链接,在断链前发送的消息还没到达设备的消息也会发送失败,并在断链之前返回一个错误应答,带上发消息时的 Identifier 和一个错误码。如下图所示,这样我们就可以定位到到底那条消息发送失败,以及失败的错误码了,我们就可以根据返回的错误信息,对这部分消息进行重发,重发是通过发送缓存做的,维持一个较小的缓存,当收到APNs的错误信息时,从缓存中去除出错的那一条消息,剩下的进行重发。
下面是可能返回的错误码:
Status codeDescription0 No errors encountered1 Processing error2 Missing device token3 Missing topic4 Missing payload5 Invalid token size 6 Invalid topic size 7 Invalid payload size 8 Invalid token10 Shutdown255 None (unknown)
设备收到消息会回调应用中的方法:
// iOS<10 时,且app被完全杀死 - (BOOL) application:(UIApplication *) application didFinishLaunchingWithOptions:(NSDictionary *) launchOptions; // 支持iOS7及以上系统 - (void) application:(UIApplication *) application didReceiveRemoteNotification:(NSDictionary *) userInfo fetchCompletionHandler:(void (^) (UIBackgroundFetchResult) )completionHandler; // iOS>=10: app在前台获取到通知 - (void) userNotificationCenter:(UNUserNotificationCenter *) center willPresentNotification:(UNNotification *) notification withCompletionHandler:(void (^) (UNNotificationPresentationOptions) )completionHandler; // iOS>=10: 点击通知进入app时触发(杀死/切到后台唤起) - (void) userNotificationCenter:(UNUserNotificationCenter *) center didReceiveNotificationResponse:(UNNotificationResponse *) response withCompletionHandler:(void (^) (void) )completionHandler;
Notification Extension 通知扩展 通知扩展是iOS 10之后添加的功能,它包括两个方面:
扩展
说明
通知服务扩展(UNNotificationServiceExtension)
在收到通知后且展示通知前允许开发者做一些事情,比如添加附件、加载网络请求等
通知内容扩展(UNNotificationContentExtension)
在展示通知时展示一个自定义的用户界面
通知服务扩展
上图是Service Extension的大致流程图,Notification的playload下发到达设备之前会经过Notification Service Extension,因此我们可以用extension修改推送内容,下载推送相关的资源,可以在extension中解密和加密的数据或下载推送相关的图片
在NotificationService.m文件中,有两个回调方法:
- (void )didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent *contentToDeliver))contentHandler; - (void )serviceExtensionTimeWillExpire;
下面是NotificationService的一个模版:
#import "NotificationService.h" @interface NotificationService ()@property (nonatomic , strong ) void (^contentHandler)(UNNotificationContent *contentToDeliver);@property (nonatomic , strong ) UNMutableNotificationContent *bestAttemptContent;@end @implementation NotificationService - (void )didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler { self .contentHandler = contentHandler; self .bestAttemptContent = [request.content mutableCopy]; self .bestAttemptContent.title = [NSString stringWithFormat:@"%@ [title modified]" , self .bestAttemptContent.title]; NSDictionary *userInfo = self .bestAttemptContent.userInfo; NSString *mediaUrl = userInfo[@"media" ][@"url" ]; NSString *mediaType = userInfo[@"media" ][@"type" ]; if (!mediaUrl.length) { self .contentHandler(self .bestAttemptContent); } else { [self loadAttachmentForUrlString:mediaUrl withType:mediaType completionHandle:^(UNNotificationAttachment *attach) { if (attach) { self .bestAttemptContent.attachments = [NSArray arrayWithObject:attach]; } self .contentHandler(self .bestAttemptContent); }]; } } - (void )loadAttachmentForUrlString:(NSString *)urlStr withType:(NSString *)type completionHandle:(void (^)(UNNotificationAttachment *attach))completionHandler { __block UNNotificationAttachment *attachment = nil ; NSURL *attachmentURL = [NSURL URLWithString:urlStr]; NSString *fileExt = [self getfileExtWithMediaType:type]; NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]]; [[session downloadTaskWithURL:attachmentURL completionHandler:^(NSURL *temporaryFileLocation, NSURLResponse *response, NSError *error) { if (error) { NSLog (@"加载多媒体失败 %@" , error.localizedDescription); } else { NSFileManager *fileManager = [NSFileManager defaultManager]; NSURL *localURL = [NSURL fileURLWithPath:[temporaryFileLocation.path stringByAppendingString:fileExt]]; [fileManager moveItemAtURL:temporaryFileLocation toURL:localURL error:&error]; NSMutableDictionary * dict = [self .bestAttemptContent.userInfo mutableCopy]; [dict setObject:[NSData dataWithContentsOfURL:localURL] forKey:@"image" ]; self .bestAttemptContent.userInfo = dict; NSError *attachmentError = nil ; attachment = [UNNotificationAttachment attachmentWithIdentifier:@"attachment_downloaded" URL:localURL options:nil error:&attachmentError]; if (attachmentError) { NSLog (@"%@" , attachmentError.localizedDescription); } } completionHandler(attachment); }] resume]; } - (void )serviceExtensionTimeWillExpire { self .contentHandler(self .bestAttemptContent); } @end
测试消息格式如下:这样就会把图片下载并缓存到本地。
"aps" :{ "alert" :{ "title" :"测试标题" , "subtitle" :"子标题" , "body" :"消息内容" }, "sound" :"default" , "badge" :1 , "mutable-content" :1 , "category" :"CategoryIdentifier" }, "msgid" :"123456" , "media" :{ "type" :"image" , "url" :"https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1565629545485&di=7905ab7e4e3d0f6e4bea6385a481adc5&imgtype=0&src=http%3A%2F%2Fimg4.duitang.com%2Fuploads%2Fitem%2F201409%2F08%2F20140908154922_Gw3h5.jpeg" } }
这里需要注意如下几点:
1. didReceiveNotificationRequest里面加载数据的时间上限为30秒,如果在30秒时间内没有完成内容的加载,通知按系统默认形式弹出 2. UNNotificationAttachment的url接收的是本地文件的url,因此如果图片使用的是网络上的url那么就需要加载到本地并将本地url赋给UNNotificationAttachment 3. aps字符串中的mutable-content字段需要设置为1,才能修改 4. 在对NotificationService进行debug时,需要在Xcode顶栏选择编译运行的target为NotificationService,否则无法进行实时debug。 5. UNNotificationAttachment:attachment支持如下类型: * 音频5M(kUTTypeWaveformAudio/kUTTypeMP3/kUTTypeMPEG4Audio/kUTTypeAudioInterchangeFileFormat) * 图片10M(kUTTypeJPEG/kUTTypeGIF/kUTTypePNG) * 视频50M(kUTTypeMPEG/kUTTypeMPEG2Video/kUTTypeMPEG4/kUTTypeAVIMovie)
通知内容扩展 #import "NotificationService.h" @interface NotificationService ()@property (nonatomic , strong ) void (^contentHandler)(UNNotificationContent *contentToDeliver);@property (nonatomic , strong ) UNMutableNotificationContent *bestAttemptContent;@end @implementation NotificationService - (void )didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler { self .contentHandler = contentHandler; self .bestAttemptContent = [request.content mutableCopy]; UNNotificationAction * actionA =[UNNotificationAction actionWithIdentifier:@"ActionA" title:@"RequiredStyle" options:UNNotificationActionOptionAuthenticationRequired]; UNNotificationAction * actionB = [UNNotificationAction actionWithIdentifier:@"ActionB" title:@"DestructiveStyle" options:UNNotificationActionOptionDestructive]; UNNotificationAction * actionC = [UNNotificationAction actionWithIdentifier:@"ActionC" title:@"ForegroundStyle" options:UNNotificationActionOptionForeground]; UNTextInputNotificationAction * actionD = [UNTextInputNotificationAction actionWithIdentifier:@"ActionD" title:@"InputDestructiveStyle" options:UNNotificationActionOptionDestructive textInputButtonTitle:@"Send" textInputPlaceholder:@"input some words here ..." ]; NSArray *actionArr = [[NSArray alloc] initWithObjects:actionA, actionB, actionC, actionD, nil ]; NSArray *identifierArr = [[NSArray alloc] initWithObjects:@"ActionA" , @"ActionB" , @"ActionC" , @"ActionD" , nil ]; UNNotificationCategory * notficationCategory = [UNNotificationCategory categoryWithIdentifier:@"IDL_CategoryIdentifier" actions:actionArr intentIdentifiers:identifierArr options:UNNotificationCategoryOptionCustomDismissAction]; [[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:[NSSet setWithObject:notficationCategory]]; self .bestAttemptContent.categoryIdentifier = @"IDL_CategoryIdentifier" ; } @end
#import "NotificationViewController.h" #import <UserNotifications/UserNotifications.h> #import <UserNotificationsUI/UserNotificationsUI.h> @interface NotificationViewController () <UNNotificationContentExtension >@end @implementation NotificationViewController - (void )viewDidLoad { [super viewDidLoad]; } - (void )didReceiveNotification:(UNNotification *)notification { NSData *data = notification.request.content.userInfo[@"xxx" ]; } - (void )didReceiveNotificationResponse:(UNNotificationResponse *)response completionHandler:(void (^)(UNNotificationContentExtensionResponseOption))completion { NSString identifier = response.actionIdentifier; if ([identifier isEqualToString:@"ActionA" ]) { } else if ([response.actionIdentifier isEqualToString:@"ActionB" ]) { } else if ([response.actionIdentifier isEqualToString:@"ActionC" ]) { } else if ([response.actionIdentifier isEqualToString:@"ActionD" ]) { } else { completion(UNNotificationContentExtensionResponseOptionDismiss); } completion(UNNotificationContentExtensionResponseOptionDoNotDismiss); } - (void )mediaPlay { } - (void )mediaPause { } @end
需要注意的点:
有如下几处CategoryIdentifier需要保持一致:
*收到消息中的category字段
{ "aps" : { "category" : "IDLCategoryIdentifier" } }
在NotificationService.m didReceiveNotificationRequest中设置category的值:
self.bestAttemptContent.categoryIdentifier = @"IDLCategoryIdentifier"
NSExtension ---> NSExtensionAttributes --> UNNotificationExtensionCategory ---> "IDLCategoryIdentifier"
Notification Service Extension 的 target和Notification Conten Extension 的 target在配置中所支持的系统版本要在iOS10及以上
推送统计 在推送业务服务端在push中加一个类似pushId的字段在Notification Service Extension 中的didReceiveNotificationRequest方法中获取self.bestAttemptContent.userInfo 从中获取到pushId并通过post请求发给推送业务服务端进行push到达率统计。
如何提高消息送达率 1. 可以借助三方推送平台的统计功能,统计出每天送达率较高的时间段,在这个时间段内推送比较重要的,或者量比较大的消息2. 通过APNs Feedback Service 定期清除错误的deviceToken3. 我们在发送累计达到一定数值后sleep几秒,从而降低推送速率4. 借助自建的在线推送渠道,如果用户在线则通过在线推送,只有用户不在线的情况下使用离线推送
各个版本推送演进过程
playload 容量变化
版本
限制
x < iOS8
256字节
x >= iOS8 && x < iOS10
2KB
x >= iOS10
4KB
推送通知呈现样式变化
版本
呈现形式
x < iOS8
只有3个地方展示
x >= iOS8 && x < iOS10
提供Actions功能
x >= iOS10
提供快捷回复TextInput
证书文件的有效期
证书类型
有效期
Development Push SSL Certificate
大概四个月
ProductionPush SSL Certificate
大约一年
Others
免费用户不能使用推送
模拟器不能测试推送
目前比较流行的三方推送平台:个推,极光,信鸽,Firebase 等
推送测试软件:SmartPush,Knuff都一样,随便选一个。