文章的Demo地址:iOS-Push。
Demo中的推送測試可以使用類似 Easy APNs Provider的工具,結(jié)合自己的證書進(jìn)行測試。

- 1. 普通推送基本設(shè)置
- 2. 靜默推送
- 3. 前臺展示推送
- 4. Notification Service Extension
- 5. Notification Content Extension
1.普通推送基本設(shè)置
1.1 創(chuàng)建項目,開啟遠(yuǎn)程推送功能
在Cababilities中打開Push Notification開關(guān)

1.2 編碼
注冊通知
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_10_0
#import <UserNotifications/UserNotifications.h>
#endif
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
CGFloat sysVersion = [UIDevice currentDevice].systemVersion.floatValue;
if (sysVersion >= 10.0) {
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
center.delegate = self;
[center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) {
UNAuthorizationStatus status = settings.authorizationStatus;
if (status == UNAuthorizationStatusNotDetermined) {
UNAuthorizationOptions options = UNAuthorizationOptionBadge | UNAuthorizationOptionAlert | UNAuthorizationOptionSound;
[center requestAuthorizationWithOptions:options completionHandler:^(BOOL granted, NSError * _Nullable error) {
if (granted) {
NSLog(@"Auth suc");
[application registerForRemoteNotifications];
} else {
NSLog(@"Auth fail:%@",error.localizedDescription);
}
}];
}
else if (status == UNAuthorizationStatusDenied) {
NSLog(@"用戶關(guān)閉了通知,請求用戶跳轉(zhuǎn)設(shè)置開啟通知");
[application openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString]];
}
else {
NSLog(@"已經(jīng)開啟了通知");
NSLog(@"Auth settings:%@",settings);
[application registerForRemoteNotifications];
}
}];
}
else if (sysVersion >= 8.0) {
UIUserNotificationType type = UIUserNotificationTypeAlert|UIUserNotificationTypeBadge|UIUserNotificationTypeSound;
UIUserNotificationSettings *settings = [UIUserNotificationSettings settingsForTypes:type categories:nil];
[application registerUserNotificationSettings:settings];
}
else {
[application registerForRemoteNotificationTypes:UIRemoteNotificationTypeBadge | UIRemoteNotificationTypeAlert | UIRemoteNotificationTypeSound];
}
return YES;
}
- (void)application:(UIApplication *)application didRegisterUserNotificationSettings:(UIUserNotificationSettings *)notificationSettings {
// 此代理方法iOS8及以上會調(diào)用,iOS10 使用UNNotification.framewrok不會調(diào)用
[application registerForRemoteNotifications];
}
注冊通知失敗
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {
// 處理注冊通知失敗
}
獲取token
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
// 上報token給服務(wù)端
}
接收通知
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo {
// 收到通知
}
到此,初步完成了推送功能,后端可以使用客戶端上報的token給客戶端推送消息了。
此時,客戶端接收推送的情況是:
1. 客戶端在前臺運行,屏幕/通知中心不會出現(xiàn)推送Banner,程序會執(zhí)行`application:didReceiveRemoteNotification:` 方法
2. 客戶端不在前臺,屏幕/通知中心出現(xiàn)推送Banner,程序不執(zhí)行`application:didReceiveRemoteNotification:` 方法
此時,點擊推送啟動App的情況是:
1. application:didFinishLaunchingWithOptions:的launchOptions中會包含UIApplicationLaunchOptionsRemoteNotificationKey,內(nèi)容是通知的UserInfo
2. application:didReceiveRemoteNotification: 在啟動過程中不會被調(diào)用
</br>
2. 靜默推送
有一些場景下,我們希望App在后臺收到推送時,能知道收到了推送,并做出一些反應(yīng)(比如UI上的變動)。這就需要開啟靜默推送。
<h3 id="2.1">2.1 工程配置</h3>
在Cababilities中打開Background Modes的Remote Notifications(靜默推送),Info中會有對應(yīng)的KeyValue自動添加。

<h4 id="2.2">2.2 編碼</h4>
實現(xiàn)后臺獲取的對應(yīng)方法
/*! This delegate method offers an opportunity for applications with the "remote-notification" background mode to fetch appropriate new data in response to an incoming remote notification. You should call the fetchCompletionHandler as soon as you're finished performing that operation, so the system can accurately estimate its power and data cost.
This method will be invoked even if the application was launched or resumed because of the remote notification. The respective delegate methods will be invoked first. Note that this behavior is in contrast to application:didReceiveRemoteNotification:, which is not called in those cases, and which will not be invoked if this method is implemented. !*/
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult result))completionHandler {
// 處理獲取到的通知
...
completionHandler(UIBackgroundFetchResultNewData);
}
實現(xiàn)了此方法,則application:didReceiveRemoteNotification: 不會被調(diào)用。而且這個方法在App因為通知啟動或者resumed的時候也會被調(diào)用。
2.3 推送內(nèi)容設(shè)置
{
"aps" : {
"alert" : {
"title" : "Message",
"body" : "Your message Here"
},
"badge" : 1,
"content-available" : 1
}
}
aps 字段中需要包含有"content-available" : 1,否則App在后臺無法感知收到推送,也就是上面的方法application:didReceiveRemoteNotification:fetchCompletionHandler:不會調(diào)用
完成以上,程序可以在后臺通過上面的方法獲取到通知的內(nèi)容了。
3 前臺展示推送
以上,代碼中并沒有實現(xiàn)UNUserNotificationCenterDelegate 協(xié)議中的方法。當(dāng)我們實現(xiàn)協(xié)議中userNotificationCenter:willPresentNotification:withCompletionHandler: 方法時,程序在前臺收到推送也會展示Banner。
// The method will be called on the delegate only if the application is in the foreground. If the method is not implemented or the handler is not called in a timely manner then the notification will not be presented. The application can choose to have the notification presented as a sound, badge, alert and/or in the notification list. This decision should be based on whether the information in the notification is otherwise visible to the user.
-(void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler {
completionHandler(UNNotificationPresentationOptionBadge|
UNNotificationPresentationOptionSound|
UNNotificationPresentationOptionAlert);
}
4 Notification Service Extension
iOS 10后新增了Notification Service Extension,開發(fā)者可以對推送進(jìn)行預(yù)處理,以展示更豐富的推送內(nèi)容,比如附加圖片,或者根據(jù)當(dāng)前用戶來修改推送消息,甚至向支付寶收款碼收款那樣播放一條語音等。
效果示例:

4.1 創(chuàng)建Notification Service Extension
在工程中原開發(fā)工程中新建一個Target,選擇Notification Service Extension ,并根據(jù)Xcode提示激活此Target。新Target的Bundle Id應(yīng)該在原工程Bundle Id的命名空間下,如原工程Bundle Id為com.demo.push,新Target的Bundle Id應(yīng)為com.demo.push.xxx,如com.demo.push.notificationServiceExtension

完成后工程中會生成對應(yīng)的文件,在.m中有兩個方法:
一是對收到的推送進(jìn)行處理的方法,在這個方法中主要對UNNotificationContent 進(jìn)行修改,最后必須調(diào)用contentHandler 。下面是默認(rèn)的實現(xiàn),只是對推送的title 進(jìn)行了修改。
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler{
// Modify the notification content here...
self.contentHandler = contentHandler;
self.bestAttemptContent = [request.content mutableCopy];
self.bestAttemptContent.title = [NSString stringWithFormat:@"%@ [modified]", self.bestAttemptContent.title];
self.contentHandler(self.bestAttemptContent);
}
為了能在Notification Service Extension中去下載其他附件,我們必須去按照如下的要求去設(shè)置推送通知,使推送通知是動態(tài)可變的。
{
aps: {
alert : {……}
mutable-content : 1
}
my-attachment : https://example.com/example.jpg"
}
必須在aps 中包含mutable-content : 1 的內(nèi)容,推送才會進(jìn)入Service Extension中被處理,my-attachment 是自定義字段。這樣我們就可以在Notification Service Extension 中,下載my-attachment 中URL的圖片,添加到推送內(nèi)容中再展示。
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler{
...
// UNNotificationAttachment中的URL為文件URL,形如 file://xxx/xxx/x.png
[self downloadImageFinished:^(NSURL *fileURL){
UNNotificationAttachment *atm = [UNNotificationAttachment attachmentWithIdentifier:@""
URL:url
options:nil error:&error];
self.bestAttemptContent.attachments = @[atm];
}];
...
}
開發(fā)者總共有30秒的時間來對推送內(nèi)容進(jìn)行處理,可以在這個過程中下載圖片、小視頻等。如果超過時間還沒有在上面方法中調(diào)用contentHandler ,系統(tǒng)會在另一個線程調(diào)用下面的方法給開發(fā)者最后調(diào)用contentHandler 的機會,如果在這個方法中contentHandler還是 沒有被調(diào)用,推送會以原來的內(nèi)容被展示到手機上。
- (void)serviceExtensionTimeWillExpire {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
self.contentHandler(self.bestAttemptContent);
}
4.2 調(diào)試Notification Service Extension
運行的Scheme選擇新建的Service Extension,選擇關(guān)聯(lián)的App運行,這樣斷點可以在Service Extension生效。


4.3 打包
打包時,選擇App對應(yīng)的Scheme即可,與正常打包流程沒有差別(CI打包也無差別)。使用Xcode打包過程中可以看到Extension已經(jīng)被包含在其中:

</br>
4.4 支付寶收款碼語音推送
- 在收到推送時,使用AVFoundation框架內(nèi)的API讀出收款相關(guān)的內(nèi)容:
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
NSLog(@"%s",__func__);
self.contentHandler = contentHandler;
self.bestAttemptContent = [request.content mutableCopy];
// Modify the notification content here...
self.bestAttemptContent.title = [NSString stringWithFormat:@"支付寶到賬兩千元"];
AVSpeechSynthesizer *speechSynthesizer = [[AVSpeechSynthesizer alloc]init];
AVSpeechUtterance *utterance = [AVSpeechUtterance speechUtteranceWithString:self.bestAttemptContent.title];
AVSpeechSynthesisVoice *voice = [AVSpeechSynthesisVoice voiceWithLanguage:@"zh-CN"];
utterance.voice = voice;
utterance.rate = AVSpeechUtteranceDefaultSpeechRate;
utterance.volume = 1.0;
[speechSynthesizer speakUtterance:utterance];
self.contentHandler(self.bestAttemptContent);
}
這種實現(xiàn)的缺點是如果用戶手機靜音,則不會有任何語音。我們知道AVAudioSession中的AVAudioSessionCategoryPlayback是可以不遵循手機靜音的,因此另一種實現(xiàn)可能是在收到的推送內(nèi)容包含一個語音的URL,然后像播放音樂文件一樣播放收款信息:設(shè)置AVAudioSession的category為AVAudioSessionCategoryPlayback,然后使用類似AVAudioPlayer的工具播放。
兩種實現(xiàn)都需要在項目的Cababilities中開啟權(quán)限:

5. Notification Content Extension
Notification Content Extension 是一個定制化展示本地和遠(yuǎn)程通知的插件,開發(fā)者可以自定義其中展示的內(nèi)容,常常會結(jié)合上面的Notification Service Extension插件和UNNotificationCategory 、 UNNotificationAction 使用做成帶有交互的推送內(nèi)容。

整體流程為:
- 注冊
Notification Category,其中包含Action. - 推送Mutable-Content的通知,在Service Extension中下載對應(yīng)的多媒體消息,重新生成通知內(nèi)容,并指定通知的
categoryIdentifier。 - 用戶3D-Touch推送會啟動
Notification Content Extension,在其中進(jìn)行通知的定制化展示。 - 用戶觸發(fā)交互(即
UNNotificationAction)后,在UNUserNotificationCenter代理方法中進(jìn)行處理。在Notification Content Extension中也可以進(jìn)行初步處理,并決定是否將Action轉(zhuǎn)發(fā)到UNUserNotificationCenter。
整體效果:

Demo推送內(nèi)容:
{
"aps" : {
"alert" : {
"title" : "Message",
"body" : "Your message Here"
},
"badge" : 1,
"content-available" : 1,
"mutable-content" : 1,
"catId" : "action1" // 自定義字段,
}
}
</br>
5.1 創(chuàng)建Notification Content Extension
新建一個Target,選擇Notification Content Extension,其BundleId應(yīng)該在原項目BundleId的命名空間下。

創(chuàng)建后會增加Target的文件:

在.h中可以看到其實這是一個UIViewController子類,我們可以添加各種視圖。
// NotificationViewController.h
#import <UIKit/UIKit.h>
@interface NotificationViewController : UIViewController
@end
// NotificationViewController.m
@interface NotificationViewController () <UNNotificationContentExtension>
在.m中可以看到這個控制器遵守UNNotificationContentExtension協(xié)議,協(xié)議中有如下方法和屬性:
@protocol UNNotificationContentExtension <NSObject>
// This will be called to send the notification to be displayed by
// the extension. If the extension is being displayed and more related
// notifications arrive (eg. more messages for the same conversation)
// the same method will be called for each new notification.
- (void)didReceiveNotification:(UNNotification *)notification;
@optional
// If implemented, the method will be called when the user taps on one
// of the notification actions. The completion handler can be called
// after handling the action to dismiss the notification and forward the
// action to the app if necessary.
- (void)didReceiveNotificationResponse:(UNNotificationResponse *)response completionHandler:(void (^)(UNNotificationContentExtensionResponseOption option))completion;
// Implementing this method and returning a button type other that "None" will
// make the notification attempt to draw a play/pause button correctly styled
// for that type.
@property (nonatomic, readonly, assign) UNNotificationContentExtensionMediaPlayPauseButtonType mediaPlayPauseButtonType;
// Implementing this method and returning a non-empty frame will make
// the notification draw a button that allows the user to play and pause
// media content embedded in the notification.
@property (nonatomic, readonly, assign) CGRect mediaPlayPauseButtonFrame;
// The tint color to use for the button.
@property (nonatomic, readonly, copy) UIColor *mediaPlayPauseButtonTintColor;
// Called when the user taps the play or pause button.
- (void)mediaPlay;
- (void)mediaPause;
@end
@interface NSExtensionContext (UNNotificationContentExtension)
// Call these methods when the playback state changes in the content
// extension to update the state of the media control button.
- (void)mediaPlayingStarted __IOS_AVAILABLE(10_0) __TVOS_UNAVAILABLE __WATCHOS_UNAVAILABLE __OSX_UNAVAILABLE;
- (void)mediaPlayingPaused __IOS_AVAILABLE(10_0) __TVOS_UNAVAILABLE __WATCHOS_UNAVAILABLE __OSX_UNAVAILABLE;
@end
除了Require的方法之外,didReceiveNotificationResponse:completionHandler:負(fù)責(zé)處理推送Action交互,而其他的用來控制視頻的播放。下面的示例中會使用到。
最下方還有一個NSExtesnsionContext類,暫時不清楚它怎么使用。
Content Extension的Info.plist中的內(nèi)容:

UNNotificationExtensionDefaultContentHidden,插件默認(rèn)會展示推送的內(nèi)容(Title、subtitle、body,不展示Attachment),通過這對鍵值來控制是否隱藏原始內(nèi)容。
UNNotificationExtensionCategory ,值類型可以為String/Array,通知的類別,只有類別ID在此之中的通知才會進(jìn)入Notification Content Extension中被處理。
UNNotificationExtensionInitialContentSizeRatio , 視圖的寬高比。視圖的最終大小(主要是高度),會受VC的preferredContentSize 、sb中的約束和視圖高度、這個比例3者的影響。優(yōu)先級從前到后下降。
5.2 編碼
首先在申請通知權(quán)限成功后,設(shè)置通知的類別和Action
UNNotificationAction *action1 = [UNNotificationAction actionWithIdentifier:@"checkoutAction" title:@"查看" options:UNNotificationActionOptionAuthenticationRequired|UNNotificationActionOptionForeground];
UNTextInputNotificationAction *action2 = [UNTextInputNotificationAction actionWithIdentifier:@"replyAction" title:@"回復(fù)" options:0 textInputButtonTitle:@"發(fā)送" textInputPlaceholder:@"回復(fù)消息"];
// 此處categoryIdentifier應(yīng)該是上面Info.plist中UNNotificationExtensionCategory包含的值
UNNotificationCategory *cat = [UNNotificationCategory categoryWithIdentifier:@"action1" actions:@[action1,action2] intentIdentifiers:@[] options:UNNotificationCategoryOptionNone];
[center setNotificationCategories:[NSSet setWithObjects:cat, nil]];
[center getNotificationCategoriesWithCompletionHandler:^(NSSet<UNNotificationCategory *> * _Nonnull categories) {
NSLog(@"get cat:%@",categories);
}];
在Notification Service Extension 中設(shè)置推送的categoryIdentifier,如果應(yīng)用采用了多種Category,一般應(yīng)該這個標(biāo)識符包含在推送內(nèi)容中。
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
self.contentHandler = contentHandler;
self.bestAttemptContent = [request.content mutableCopy];
// 此處省略推送內(nèi)容的其他修改和附件的下載
// 下載完成后,使用fileUrl創(chuàng)建附件
UNNotificationAttachment *atm = [UNNotificationAttachment attachmentWithIdentifier:@"" URL:url options:options error:&error];
self.bestAttemptContent.attachments = @[atm];
// 設(shè)置categoryIdentifier
self.bestAttemptContent.categoryIdentifier = request.content.userInfo[@"aps"][@"catId"];
self.contentHandler(self.bestAttemptContent);
}
在Notification Content Extension 中定制視圖,展示推送內(nèi)容,此處以視頻附件為例。
聲明協(xié)議中與視頻播放相關(guān)的屬性,實現(xiàn)對應(yīng)的方法。
@interface NotificationViewController () <UNNotificationContentExtension>
@property IBOutlet UILabel *label;
@property (weak, nonatomic) IBOutlet UIImageView *imageView;
@property (nonatomic, strong) AVPlayerLayer *layer;
@property (nonatomic, strong) AVPlayer *player;
@property (nonatomic, assign) UNNotificationContentExtensionMediaPlayPauseButtonType mediaPlayPauseButtonType;
@property (nonatomic, assign) CGRect mediaPlayPauseButtonFrame;
@property (nonatomic, copy) UIColor *mediaPlayPauseButtonTintColor;
@end
@implementation NotificationViewController
- (void)viewDidLoad {
[super viewDidLoad];
// 設(shè)置ContentSize
self.preferredContentSize = CGSizeMake([UIScreen mainScreen].bounds.size.width, 300);
}
- (void)didReceiveNotification:(UNNotification *)notification {
//
self.label.text = notification.request.content.body;
UNNotificationAttachment *atm = notification.request.content.attachments.firstObject;
if ([atm.URL startAccessingSecurityScopedResource]) {
self.player = [AVPlayer playerWithURL:atm.URL];
self.layer = [AVPlayerLayer playerLayerWithPlayer:self.player];
self.layer.frame = SomeRect;// frame自行計算,此處僅為示例
self.layer.videoGravity = AVLayerVideoGravityResizeAspectFill;
[self.view.layer addSublayer:self.layer];
[atm.URL stopAccessingSecurityScopedResource];
}
}
- (UNNotificationContentExtensionMediaPlayPauseButtonType)mediaPlayPauseButtonType {
return UNNotificationContentExtensionMediaPlayPauseButtonTypeOverlay;
}
- (CGRect)mediaPlayPauseButtonFrame {
CGPoint center = self.imageView.center;
return CGRectMake(center.x - 25, center.y - 25, 50, 50);
}
- (UIColor *)mediaPlayPauseButtonTintColor {
return [UIColor lightGrayColor];
}
- (void)mediaPlay {
[self.player play];
}
- (void)mediaPause {
[self.player pause];
}
@end
由于在視圖初始化時,還不能知道推送內(nèi)容的最終高度,因此最好以一個固定的高度呈現(xiàn)。上面在代碼中使用preferredContentSize來設(shè)置。
代碼中使用AVPlayer和AVPlayerLayer來展示視頻附件,其中獲取視頻URL時,由于Attachment是由系統(tǒng)管理,在沙盒之外,我們訪問URL內(nèi)容時候需要先獲取使用權(quán)限:
if ([atm.URL startAccessingSecurityScopedResource]) {
...
[atm.URL stopAccessingSecurityScopedResource];
}