iOS開發之進階篇(2)—— 本地通知和遠程通知 (使用APNs)

Notification.png

版本

iOS 10+

一. 概述

由於iOS中App並非始終運行, 因此通知提供了一種在App中要顯示新消息時提醒用戶的方法.
其表現形式如下:

  • 屏幕上的警報或橫幅
  • 應用程式圖示上的徽章
  • 警報, 橫幅或徽章隨附的聲音

notificationTypes.png

iOS App中有兩種通知模式: 本地通知遠程通知.
對於用戶而言, 這兩種通知在顯示效果上沒有區別. 兩種類型的通知具有相同的默認外觀, 由系統提供. 當然, 我們也可以自定義通知界面, 詳見後文.

它們之間的區別如下:

  1. 本地通知不需要聯網, 由App指定觸發通知的條件(例如時間或位置), 然後創建內容並傳遞給系統顯示出來. 例如鬧鐘/便籤等.
  2. 遠程通知(也稱爲推送通知)則需要聯網, 由自己的服務器生成通知, 然後通過蘋果推送服務(APNs)將數據傳達給指定的iOS設備. 例如微信/支付寶等.

UN框架
iOS 10之後推出了用戶通知框架(User Notifications), 提供了一種統一方式來調度和處理本地通知, 該框架除了管理本地通知外, 還支持處理遠程通知.
UN框架支持創建UNNotificationServiceExtension擴展, 使我們可以在傳遞遠程通知之前修改其內容。
UN框架還支持創建UNNotificationContentExtension擴展, 使我們可以自定義通知顯式界面.

二. 通知的管理和配置

本小節內容適用於本地通知和遠程推送通知, 特別的, 遠程推送通知所需的額外配置寫在遠程通知小節裏.

Apps must be configured at launch time to support local and remote notifications. Specifically, you must configure your app in advance if it does any of the following:

  • Displays alerts, play sounds, or badges its icon in response to an arriving notification.
  • Displays custom action buttons with a notification.

根據蘋果文檔這段敘述, 一般地, 我們會在App啓動完成之後配置通知, 即在application:didFinishLaunchingWithOptions:方法裏進行配置.

設置代理

如果我們不設置代理, 也可以正常收到通知, 前提是App不在前臺運行.
爲什麼會這樣呢?
原來, 系統給我們提供了App在前臺運行時處理通知的機會, 比如攔截通知, 修改通知內容等等.
這個機制是通過UNUserNotificationCenterDelegate的代理方法實現的, 來看看Apple對這些代理方法的註釋:

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.
僅當App在前臺運行時,纔會調用該委託方法。 如果未實現該方法或未及時調用該處理程序,則不會顯示該通知。 App可以選擇將通知顯示爲聲音,徽章,警報或顯示在通知列表中。

總而言之, 言而總之, 我們應當實現以下兩個步驟:

  1. 在application:didFinishLaunchingWithOptions:裏設置代理:
///  設置通知代理
///  系統爲App提供了內部處理通知的機會(通過user notification代理方法), 比如修改消息內容, 是否顯示消息橫幅或者聲音等
///  當App在前臺運行時, 我們需要實現user notification的代理方法, 否則不顯示通知
- (void)setNotificationDelegate {

    UNUserNotificationCenter* center = [UNUserNotificationCenter  currentNotificationCenter];
    center.delegate = self;
}

在appDelegate.m裏實現代理方法:

#pragma mark - UNUserNotificationCenterDelegate

/// 僅當App在前臺運行時, 準備呈現通知時, 纔會調用該委託方法.
/// 一般在此方法裏選擇將通知顯示爲聲音, 徽章, 橫幅, 或顯示在通知列表中.
/// @param center 用戶通知中心
/// @param notification 當前通知
/// @param completionHandler 回調通知選項: 橫幅, 聲音, 徽章...
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
       willPresentNotification:(UNNotification *)notification
         withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler {
    
    UNNotificationRequest *request = notification.request;
    UNNotificationContent *conten = request.content;
    NSDictionary *userInfo = conten.userInfo;
    
    if ([request.trigger isKindOfClass:[UNPushNotificationTrigger class]]) {
        NSLog(@"即將展示遠程通知");
    }else {
        NSLog(@"即將展示本地通知");
    }
    NSLog(@"title:%@, subtitle:%@, body:%@, categoryIdentifier:%@, sound:%@, badge:%@, userInfo:%@", conten.title, conten.subtitle, conten.body, conten.categoryIdentifier, conten.sound, conten.badge, userInfo);

    // 以下是在App前臺運行時, 仍要顯示的通知選項
    completionHandler(UNNotificationPresentationOptionAlert + UNNotificationPresentationOptionSound + UNNotificationPresentationOptionBadge);
}


/// 當用戶通過點擊通知打開App/關閉通知或點擊通知按鈕時, 調用該方法.
/// (必須在application:didFinishLaunchingWithOptions:裏設置代理)
/// @param center 用戶通知中心
/// @param response 響應事件
/// @param completionHandler 處理完成的回調
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
didReceiveNotificationResponse:(UNNotificationResponse *)response
         withCompletionHandler:(void (^)(void))completionHandler {
        
    UNNotificationRequest *request = response.notification.request;
    UNNotificationContent *conten = request.content;
    NSDictionary *userInfo = conten.userInfo;
    
    if ([request.trigger isKindOfClass:[UNPushNotificationTrigger class]]) {
        NSLog(@"點擊了遠程通知");
    }else {
        NSLog(@"點擊了本地通知");
    }
    NSLog(@"title:%@, subtitle:%@, body:%@, categoryIdentifier:%@, sound:%@, badge:%@, userInfo:%@, actionIdentifier:%@", conten.title, conten.subtitle, conten.body, conten.categoryIdentifier, conten.sound, conten.badge, userInfo, response.actionIdentifier);
    
    completionHandler();
}

請求權限

用戶有可能在任何時候修改App的通知權限, 所以我們有必要在適當的時機查詢通知權限, 以便做出相應處理.
比如說, 在準備添加一個通知的的時候, 檢查通知權限, 如已授權則繼續, 如已拒絕則提示用戶該功能受限不可用.

/// 檢查通知授權狀態
/// 由於用戶可隨時更改通知權限, 所以需要在設置通知前檢查權限
/// @param completion 檢查完成的回調
- (void)checkNotificationAuthorizationWithCompletion:(void (^) (BOOL granted))completion {
    
    UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
    [center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) {
        switch (settings.authorizationStatus) {
                
            // 未詢問
            case UNAuthorizationStatusNotDetermined:
                {
                    // 詢問之 (注意options中要列舉要使用到的權限選項, 不然在設置中將不顯示該權限選項)
                    [center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert + UNAuthorizationOptionSound + UNAuthorizationOptionBadge)
                                          completionHandler:^(BOOL granted, NSError * _Nullable error) {
                        if (granted) {
                            NSLog(@"用戶首次授權通知");
                            if (completion) completion(YES);
                        }else {
                            NSLog(@"用戶首次拒絕通知");
                            if (completion) completion(NO);
                        }
                    }];
                }
                break;
                
            // 已拒絕
            case UNAuthorizationStatusDenied:
                {
                    NSLog(@"用戶已拒絕通知");
                    if (completion) completion(NO);
                }
                break;
                
            // 已授權
            case UNAuthorizationStatusAuthorized:
            default:
                {
                    NSLog(@"用戶已授權通知");
                    if (completion) completion(YES);
                }
                break;
        }
    }];
}

添加通知按鈕

在這裏引入兩個概念: 可操作通知(actionable notifications)和類別(categories).
可操作通知即我們可以在系統默認通知(沒有按鈕)上面添加自定義的按鈕, 用於監測和傳遞按鈕事件供App處理.
而這些可操作通知可以是多樣的(比如說按鈕數量不等/相等數量但具有不同功能), 因此需要類別這個對象用於區分不同的可操作通知.
當我們註冊一個類別時, 都要指定一個categoryIdentifier, 這樣當一個通知生成時, 系統首先會匹配我們自定義的可操作通知的categoryIdentifier, 如果找不到則會顯示系統默認通知.

下面舉個例子:
同樣在application:didFinishLaunchingWithOptions:中註冊通知類別:

/// 註冊通知類別 (可選實現)
/// 不同的類別用於區別不同的可操作通知(actionable notifications), 不同的可操作通知體現爲: 我們可以爲其定義一個或者多個不同的按鈕
/// 如果實現, 系統首先根據categoryIdentifier匹配自定義的可操作通知; 如果沒有, 將顯示系統默認通知(沒有按鈕).
- (void)setNotificationCategories {
    
    /* 類別1(有一個按鈕) */
    UNNotificationAction *closeAction = [UNNotificationAction actionWithIdentifier:@"CLOSE_ACTION" title:@"關閉" options:UNNotificationActionOptionNone];
    UNNotificationCategory *category1 = [UNNotificationCategory categoryWithIdentifier:@"CATEGORY1"
                                                                               actions:@[closeAction]
                                                                     intentIdentifiers:@[]
                                                                               options:UNNotificationCategoryOptionCustomDismissAction];

    /* 類別2(有四個按鈕) */
    UNNotificationAction *action1 = [UNNotificationAction actionWithIdentifier:@"ACTION1" title:@"按鈕1" options:UNNotificationActionOptionNone];
    UNNotificationAction *action2 = [UNNotificationAction actionWithIdentifier:@"ACTION2" title:@"按鈕2" options:UNNotificationActionOptionNone];
    UNNotificationAction *action3 = [UNNotificationAction actionWithIdentifier:@"ACTION3" title:@"按鈕3" options:UNNotificationActionOptionNone];
    UNNotificationAction *action4 = [UNNotificationAction actionWithIdentifier:@"ACTION4" title:@"按鈕4" options:UNNotificationActionOptionNone];
    UNNotificationCategory *category2 = [UNNotificationCategory categoryWithIdentifier:@"CATEGORY2"
                                                                               actions:@[action1, action2, action3, action4]
                                                                     intentIdentifiers:@[]
                                                                               options:UNNotificationCategoryOptionCustomDismissAction];

    // 註冊上面這2個通知類別
    UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
    [center setNotificationCategories:[NSSet setWithObjects:category1, category2, nil]];
}

這就算註冊好了, 那麼什麼時候會調用我們自定義註冊的通知呢?
當我們設置一個通知並在添加該通知到用戶通知中心之前, 要設置對應的categoryIdentifier, 這樣當通知被觸發時, 系統首先去查找我們註冊的通知.
設置通知這部分代碼在下小節的本地通知裏, 爲節省篇幅和避免囉嗦, 這裏先不貼出來.

自定義警報聲音

自定義報警聲音由系統聲音設備播放, 因此只支持一下音頻編碼格式:

  • Linear PCM
  • MA4 (IMA/ADPCM)
  • µLaw
  • aLaw

這些音頻可以封裝成aiff,wav,caf,mp3等音頻封裝格式的文件. 對了, 還有時長的要求, 不能超過30秒, 某則會被系統打回原形——默認聲音.
將音頻文件放入App Bundle或者沙盒的Library/Sounds文件夾下, 然後在新添加通知的UNMutableNotificationContent添加sound屬性:

UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init];
content.sound = [UNNotificationSound soundNamed:@"123.mp3"];
如果App在前臺接收通知, 不要忘了在userNotificationCenter:willPresentNotification:withCompletionHandler:裏回調添加UNNotificationPresentationOptionSound
completionHandler(UNNotificationPresentationOptionAlert + UNNotificationPresentationOptionSound);

PS: 免費音效下載https://www.bangongziyuan.com/music/

管理已發送的通知

當App或用戶未直接處理本地和遠程通知時, 它們會顯示在“通知中心”中, 以便以後查看. 使用getDeliveredNotificationsWithCompletionHandler:方法來獲取仍在通知中心顯示的通知列表. 如果發現已經過時且不應顯示給用戶的任何通知, 則可以使用removeDeliveredNotificationsWithIdentifiers:方法將其刪除.

三. 本地通知

例如, 設置一個本地鬧鐘通知, 基本流程如下:

  1. 設置通知代理, 實現代理方法
  2. 註冊自定義可操作通知 (可選項)
  3. 生成一個通知前檢查通知權限
  4. 生成通知並配置相關選項(通知內容/觸發條件/categoryIdentifier等), 添加到通知中心
  5. 通知觸發時, 如果App在前臺運行, 在代理方法userNotificationCenter:willPresentNotification:withCompletionHandler:裏做相應處理
  6. 用戶點擊可操作通知中的按鈕時, 在代理方法userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:裏做相應處理

這些流程在上小節通知的管理和配置中已經講得差不多了, 只剩下在合適的時機生成本地通知並添加到用戶通知中心中:

// 本地通知
- (IBAction)localNotificationAction:(UIButton *)sender {
    
    // 檢查通知授權狀態
    __weak typeof(self) weakSelf = self;
    [self checkNotificationAuthorizationWithCompletion:^(BOOL granted) {
        if (granted) {
            // 設置一個基於時間的本地通知
            [weakSelf setLocalNotification];
        }else {
            [weakSelf showAlertWithTitle:nil message:@"請於設置中開啓App的通知權限" delay:2.0];
        }
    }];
}


// 基於時間的本地通知
- (void)setLocalNotification {

    // 設置顯示內容
    UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init];
    // 使用localizedUserNotificationStringForKey:arguments:獲取本地化後的字符串
    content.title = [NSString localizedUserNotificationStringForKey:@"title" arguments:nil];
    content.subtitle = [NSString localizedUserNotificationStringForKey:@"subtitle" arguments:nil];
    content.body = [NSString localizedUserNotificationStringForKey:@"body" arguments:nil];
    content.categoryIdentifier = @"CATEGORY2";  // 註釋這行則顯示系統通知樣式
    content.sound = [UNNotificationSound soundNamed:@"123.mp3"];    // 聲音
    content.badge = @(1);   // 徽章數字
    // 附件
    NSString *imagePath = [[NSBundle mainBundle] pathForResource:@"apple" ofType:@"png"];
    UNNotificationAttachment *attachment = [UNNotificationAttachment attachmentWithIdentifier:@"image" URL:[NSURL fileURLWithPath:imagePath] options:nil error:nil];
    content.attachments = @[attachment];
    
    // 設置觸發時間
    NSDateComponents* date = [[NSDateComponents alloc] init];
    date.hour = 13;
    date.minute = 51;
    UNCalendarNotificationTrigger* trigger = [UNCalendarNotificationTrigger triggerWithDateMatchingComponents:date repeats:NO];
     
    // 根據內容和觸發條件生成一個通知請求
    UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:@"KKAlarmID" content:content trigger:nil];    // trigger爲nil則立即觸發通知

    // 將該請求添加到用戶通知中心
    __weak typeof(self) weakSelf = self;
    UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
    [center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) {
       if (error != nil) {
           NSLog(@"%@", error.localizedDescription);
       }else {
           [weakSelf showAlertWithTitle:nil message:@"設置本地通知成功" delay:2.0];
       }
    }];
}

// 提示框
- (void)showAlertWithTitle:(NSString *)title message:(NSString *)message delay:(float)delay {
    
    dispatch_async(dispatch_get_main_queue(), ^{
        UIAlertController *alertC = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert];
        [self presentViewController:alertC animated:YES completion:nil];
        // delay 2s
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay*NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            [alertC dismissViewControllerAnimated:YES completion:nil];
        });
    });
}

img-CMri5LyL-1591613435286

Demo鏈接位於文末.

四. 遠程通知 (使用APNs)

原理

remote_notif_simple.png

與本地通知不同, 遠程通知是由遠程觸發並傳遞的. 如圖, 通知由我們自己提供的服務器(provider)觸發, 然後通過蘋果推送通知服務(APNs)傳遞給iOS設備, 最後到達App.
那麼, 我們的provider是怎樣知道傳達給哪一臺iOS設備(或者說哪一個App)的呢? 答案是device token.

device token
蘋果沒有明確給出device token的定義(至少我沒找到), 但我們可以這樣理解: device token相當於App在APNs中的一個具體地址.
所以, 只要provider告訴APNs一個device token, 那麼就可以準確地把通知傳達給指定設備(或App).
需要注意的是, device token是可能變化的, 比如說刪除重裝App/升級系統等等都會使token發生變化. 當我們的token發生改變時, 我們得想辦法把新的token傳遞給provider, 不然就收不到通知啦.

網上有一張圖, 比較完整地詮釋這個過程(找不到出處了…):
img-lF8qGcb1-1591614123473

對此圖稍作說明:

  1. App向iOS系統發起註冊遠程通知請求 (registerForRemoteNotifications), 由iOS系統向APNs請求device token
  2. APNs生成device token, 然後回傳給系統上的App (application:didRegisterForRemoteNotificationsWithDeviceToken:)
  3. App 把device token傳遞給自己的服務器
  4. 服務器將通知 (包括device token和消息體)傳遞給APNs服務器
  5. APNs根據device token把消息體傳達給指定設備和App.

準備工作

  1. 開發者賬號
  2. 真機 (可聯網)
  3. 服務器 (或者Mac本地模擬)
  4. APNs AuthKey

The other half of the connection for sending notifications—the persistent, secure channel between a provider server and APNs—requires configuration in your online developer account and the use of Apple-supplied cryptographic certificates.

蘋果文檔明確指出需要開發者賬號開啓通知相關配置. 當然, 我們也可在Xcode中登錄賬號進行配置.

device token只支持真機獲取. 如果使用模擬器, 會報錯Error Domain=NSCocoaErrorDomain Code=3010 “remote notifications are not supported in the simulator”

Mac本地模擬服務器, 來源https://stackoverflow.com/questions/39943701/how-to-send-apns-push-messages-using-apns-auth-key-and-standard-cli-tools

APNs支持兩種方式配置遠程通知, 一種是使用證書, 一種是使用APNs AuthKey. 證書方式很是麻煩且已過時, 故本文討論Apple於2016年新推出的AuthKey方式.

流程

1. 開啓推送通知功能

TARGETS —> Singing & Capabilities —> + —> Push Notification

img-b8ZgdbHE-1591614167038

2. 生成APNs AuthKey

登錄開發者賬號, keys, +

img-yBWjsJnN-1591614182531

起個名, 選APNs
APNs.png
生成後, 下載AuthKey.p8文件並保存好, 注意, 只能下載一次.
記下Key ID, 等下用到(當然用到的時候再點進去獲取也是可以的).

3. 代碼部分

application:didFinishLaunchingWithOptions:中註冊遠程通知, checkNotificationAuthorizationWithCompletion:方法前面已有貼出.

/// 註冊遠程通知 (獲取設備令牌)
/// 如果手機可聯網, 將回調
/// 成功 application:didRegisterForRemoteNotificationsWithDeviceToken:
/// 失敗 application:didFailToRegisterForRemoteNotificationsWithError:
- (void)registerRemoteNotifications {
    
    // 檢查權限
    [KKAuthorizationTool checkNotificationAuthorizationWithCompletion:^(BOOL granted) {
        if (granted) {
            dispatch_async(dispatch_get_main_queue(), ^{
                [[UIApplication sharedApplication] registerForRemoteNotifications];
            });
        }
    }];
}

當手機網絡可用, 即可獲得回調:

/// 註冊遠程通知 成功
/// @param application App
/// @param deviceToken 設備令牌
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
    
    NSString *deviceTokenStr = [self deviceTokenStrWithDeviceToken:deviceToken];
    
    NSLog(@"註冊遠程通知 成功 deviceToken:%@, deviceTokenStr:%@", deviceToken, deviceTokenStr);
}


/// 註冊遠程通知 失敗
/// @param application App
/// @param error 錯誤信息
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(nonnull NSError *)error {
    
    NSLog(@"註冊遠程通知 失敗 error:%@", error);
}


// 將deviceToken轉換成字符串
- (NSString *)deviceTokenStrWithDeviceToken:(NSData *)deviceToken {

    NSString *tokenStr;
    
    if (deviceToken) {
        if ([[deviceToken description] containsString:@"length = "]) {  // iOS 13 DeviceToken 適配。
            NSMutableString *deviceTokenString = [NSMutableString string];
            const char *bytes = deviceToken.bytes;
            NSInteger count = deviceToken.length;
            for (int i = 0; i < count; i++) {
                [deviceTokenString appendFormat:@"%02x", bytes[i]&0x000000FF];
            }
            tokenStr = [NSString stringWithString:deviceTokenString];
        }else {
            tokenStr = [[[[deviceToken description]stringByReplacingOccurrencesOfString:@"<" withString:@""]stringByReplacingOccurrencesOfString:@">" withString:@""]stringByReplacingOccurrencesOfString:@" " withString:@""];
        }
    }
    
    return tokenStr;
}

這裏順便提一下, 因爲涉及到App聯網, 國行版第一次運行App時需要獲取網絡權限. 那麼這時候去做一下請求網絡的動作, 纔會彈出網絡權限提示框, 不然連不了網, 在設置裏也沒有聯網選項.
所以, 在所有操作之前, 在App加載完成時添加如下方法:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
        
    // 檢查網絡
    [self checkNetword];
    
    // 設置通知代理
    [self setNotificationDelegate];
    // 註冊通知類別 (可選實現)
    [self setNotificationCategories];
    // 註冊遠程通知 (獲取設備令牌)
    [self registerRemoteNotifications];
  
    return YES;
}


// 檢查聯網狀態 (爲了使國行手機在第一次運行App時彈出網絡權限彈框, 故需要請求網絡連接)
- (void)checkNetword {
    
    NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:@"https://www.baidu.com"] cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:3];
    NSURLSession *session = [NSURLSession sharedSession];
    NSURLSessionDataTask *task = [session dataTaskWithRequest:request];
    [task resume];
}

至此, 代碼部分完畢.

4. 模擬服務器發送通知

https://stackoverflow.com/questions/39943701/how-to-send-apns-push-messages-using-apns-auth-key-and-standard-cli-tools

在地址中拷貝得到如下Python代碼:

#!/bin/bash

deviceToken=a016e229f8fa4dXXXXXXXXXXXXXXXXXXXXXXXXXXXXXf4701a108e86

authKey="/Users/kang/Desktop/AuthKey_C2L2B33XXX.p8"    # p8在Mac上的位置
authKeyId=C2L2B33XXX    # 開發者網站 -> keys -> 點擊剛纔建立的AuthKey -> Key ID
teamId=PTLCDC9XXX       # 開發者網站 -> Membership -> Team ID
bundleId=com.Kang.KKNotificationDemo
endpoint=https://api.development.push.apple.com

# 注意: 在 payload裏 不能加任何註釋, 否則將會導致數據錯誤進而通知失敗
read -r -d '' payload <<-'EOF'
{
   "aps": {
      "badge": 2,
      "category": "mycategory",
      "alert": {
         "title": "my title",
         "subtitle": "my subtitle",
         "body": "my body text message"
      }
   },
   "custom": {
      "mykey": "myvalue"
   }
}
EOF

# --------------------------------------------------------------------------

base64() {
   openssl base64 -e -A | tr -- '+/' '-_' | tr -d =
}

sign() {
   printf "$1" | openssl dgst -binary -sha256 -sign "$authKey" | base64
}

time=$(date +%s)
header=$(printf '{ "alg": "ES256", "kid": "%s" }' "$authKeyId" | base64)
claims=$(printf '{ "iss": "%s", "iat": %d }' "$teamId" "$time" | base64)
jwt="$header.$claims.$(sign $header.$claims)"

curl --verbose \
   --header "content-type: application/json" \
   --header "authorization: bearer $jwt" \
   --header "apns-topic: $bundleId" \
   --data "$payload" \
   $endpoint/3/device/$deviceToken

修改deviceToken, authKey, authKeyId, teamId, bundleId然後保存爲.py文件, 先運行Demo註冊監聽, 再在終端運行py, 順利的話, 就可以看到推送啦!
py代碼中"category": “mycategory"這裏category如果改成我們自定義註冊的CATEGORY2, 下拉通知就會看到我們那四個按鈕, 也可加入字段"sound” : "xxx.aiff"播放自定義聲音等等. 關於修改通知內容和顯示界面, 詳見下節.

APNs_Demo.png

五. 修改通知內容和顯示界面

接下來介紹通知的兩種擴展: UNNotificationServiceExtensionUNNotificationContentExtension

蘋果文檔
You can modify the content or presentation of arriving notifications using app extensions. To modify the content of a remote notification before it is delivered, use a notification service app extension. To change how the notification’s content is presented onscreen, use a notification content app extension.
您可以使用App擴展來修改通知內容或其顯示方式。要在傳遞遠程通知之前修改其內容,請使用UNNotificationServiceExtension擴展。要更改通知內容在屏幕上的顯示方式,請使用UNNotificationContentExtension擴展。

這個擴展用來修改遠程通知的內容, 比如修改title, 語言本地化, 解密信息, 加載附件等等.
如果是本地通知, 直接在設置通知內容UNMutableNotificationContent的時候設定好就行了.

1. 創建以及注意事項 (重要)

這兩個擴展創建過程相似, 故放在一起討論.

新建target

new_target

分別創建service和content的擴展

service_content

注意! 因爲一開始創建target, 系統默認是從最高iOS版本支持的, 所以我們得分別將兩個擴展target的支持版本調到iOS 10.0, 不然當你收不到遠程通知的時候, 你會開始各種baidu/google, 但是最終都不得其姐.

img-VVQy5RMG-1591614201309

注意! 給這兩個擴展都添加推送通知的功能

img-uM5voAmL-1591613435312

最終左邊欄新增如下擴展代碼

extension_catalogue

注意! 在調試的時候, 我們需要切換對應的target纔會走斷點和打印log.

img-SXfuUFVI-1591613435314

2. UNNotificationServiceExtension

首先我們修改Python測試文件, 添加media字段

# 注意: 在 payload裏 不能加任何註釋, 否則將會導致數據錯誤進而通知失敗. 還有, 最後一個鍵值對可加可不加逗號.
# "media":{"type":"video", "url":"http://vfx.mtime.cn/Video/2019/02/04/mp4/190204084208765161.mp4"}
# "media":{"type":"image", "url":"https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png"}
read -r -d '' payload <<-'EOF'
{
    "aps" : {
        "category" : "CATEGORY",
        "mutable-content" : 1,
        "alert" : {
            "title" : "KK title",
            "subtitle" : "KK subtitle",
            "body"  : "KK body"
        }
    },
    "media" : {
        "type" : "video",
        "url" : "http://vfx.mtime.cn/Video/2019/02/04/mp4/190204084208765161.mp4"
    }
}
EOF

由於用到http協議, 所以我們還得在service的擴展裏的info.plist添加App Transport Security Settings, 然後設置Allow Arbitrary Loads爲YES:

img-MWhHmord-1591614280401

對了, 順便提一下附件內容的支持, 摘自UNNotificationAttachment

attachment.png

然後直接擼NotificationService.m的代碼:

#import "NotificationService.h"

@interface NotificationService ()

@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;

@end

@implementation NotificationService

// 收到通知
// 在這進行內容修改, 比如修改title, 語言本地化, 解密信息, 加載附件等等
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
    self.contentHandler = contentHandler;
    self.bestAttemptContent = [request.content mutableCopy];

    // 修改標題
    NSString *title = self.bestAttemptContent.title;
    self.bestAttemptContent.title = [NSString stringWithFormat:@"%@ [modified]", title];
    NSLog(@"%s 原標題:%@, 修改後:%@", __func__, title, self.bestAttemptContent.title);
    
    // 下載附件
    NSDictionary *dict =  self.bestAttemptContent.userInfo;
    NSString *mediaType = dict[@"media"][@"type"];
    NSString *mediaUrl = dict[@"media"][@"url"];
    [self loadAttachmentForUrlString:mediaUrl mediaType:mediaType completionHandle:^(UNNotificationAttachment *attachment) {
        if (attachment) {
            self.bestAttemptContent.attachments = @[attachment];
        }
        // 回調, 如果類別是自定義的, 將會轉到content extension
        self.contentHandler(self.bestAttemptContent);
    }];
}


// 修改超時
// 系統提供大約30秒的時間供內容修改, 如果到期還沒調用contentHandler, 則將會強制終止, 在此方法作最後一次修改
- (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.
    NSLog(@"%s 超時", __func__);
    self.contentHandler(self.bestAttemptContent);
}


#pragma mark - private

// 下載附件
- (void)loadAttachmentForUrlString:(NSString *)urlStr
                         mediaType:(NSString *)type
                  completionHandle:(void(^)(UNNotificationAttachment *attachment))completionHandler {
    NSLog(@"%s 開始下載附件 urlStr:%@", __func__, urlStr);
    
    __block UNNotificationAttachment *attachment = nil;
    
    NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]];
    NSURL *URL = [NSURL URLWithString:urlStr];
    [[session downloadTaskWithURL:URL completionHandler:^(NSURL *temporaryFileLocation, NSURLResponse *response, NSError *error) {
        NSLog(@"%s 下載附件結束", __func__);
        if (error != nil) {
            NSLog(@"error:%@", error.localizedDescription);
        } else {
            // 下載過程中原來的擴展名變成了tmp,所以我們需替換成原先的擴展名
            NSFileManager *fileManager = [NSFileManager defaultManager];
            NSString *path = [temporaryFileLocation.path stringByDeletingPathExtension];    // 去掉.tmp後綴名 (包括.)
            NSString *fileExt = [urlStr pathExtension];                                     // 原先的後綴名 (不包括.)
            NSURL *localURL = [NSURL fileURLWithPath:[path stringByAppendingPathExtension:fileExt]]; // 最終後綴名 (包括.)
            [fileManager moveItemAtURL:temporaryFileLocation toURL:localURL error:&error];  // 替換
            // 附件內容
            NSError *attachmentError = nil;
            attachment = [UNNotificationAttachment attachmentWithIdentifier:@"" URL:localURL options:nil error:&attachmentError];
            if (attachmentError) {
                NSLog(@"error:%@", attachmentError.localizedDescription);
            }
            // 如果是圖片類型, 傳遞給content擴展程序來顯示
            if ([type isEqualToString:@"image"]) {
                NSData *imageData = [NSData dataWithContentsOfURL:localURL];
                NSDictionary *userInfo = @{@"imageData" : imageData};
                self.bestAttemptContent.userInfo = userInfo;
            }
        }
        completionHandler(attachment);
    }] resume];
}


@end

此例中我們用的類別是CATEGORY, 系統遍歷我們自定義註冊的類別, 沒有找到匹配的, 最終顯示系統默認通知. 我們可以看到推送標題和縮略圖, 下拉看到播放按鈕, 可播放之.

push_pullDown_play.png

3. UNNotificationContentExtension

img-Yf2jWxpL-1591613435319

蘋果文檔
When an iOS device receives a notification containing an alert, the system displays the contents of the alert in two stages. Initially, it displays an abbreviated banner with the title, subtitle, and two to four lines of body text from the notification. If the user presses the abbreviated banner, iOS displays the full notification interface, including any notification-related actions. The system provides the interface for the abbreviated banner, but you can customize the full interface using a notification content app extension.
當iOS設備收到包含警報的通知時,系統分兩個階段顯示警報的內容。最初,它顯示帶有標題,副標題和通知中兩到四行正文的縮寫橫幅。如果用戶按下縮寫橫幅,則iOS將顯示完整的通知界面,包括所有與通知相關的操作。系統提供了縮寫橫幅的界面,但是您可以使用UNNotificationContentExtension擴展程序自定義完整界面。

也就是說, 通知界面分兩個階段: Default UI和Custom UI. 一開始彈出的是Default UI, 這個由系統設計, 我們不能修改 (但是可以設置隱藏); 下拉後, 顯示Default UI (完整界面), 我們可以使用UNNotificationContentExtension來設計這部分.

但是, 有個問題, 我們自己設計的這個界面不能顯示視頻, 只能顯示圖片. 當然, 也可能是我沒找到方法…
所以我只能說, 下雨天, service搭配content, 效果更佳.

圖片當然也是在service擴展里加載好的, 然後通過調用回調傳過來顯示. 蘋果文檔也說了, 不要在content擴展裏做類似請求網絡這種耗時操作.

Don’t perform any long-running tasks, like trying to retrieve data over the network.

server擴展中, 我們下載好圖片文件後, 需要傳遞給content擴展程序來顯示:

// 如果是圖片類型, 傳遞給content擴展程序來顯示
if ([type isEqualToString:@"image"]) {
    NSData *imageData = [NSData dataWithContentsOfURL:localURL];
    NSDictionary *userInfo = @{@"imageData" : imageData};
    self.bestAttemptContent.userInfo = userInfo;
}

Python測試文件中, 將附件改爲圖片

# 注意: 在 payload裏 不能加任何註釋, 否則將會導致數據錯誤進而通知失敗. 還有, 最後一個鍵值對可加可不加逗號.
# "media":{"type":"video", "url":"http://vfx.mtime.cn/Video/2019/02/04/mp4/190204084208765161.mp4"}
# "media":{"type":"image", "url":"https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png"}
read -r -d '' payload <<-'EOF'
{
    "aps" : {
        "category" : "CATEGORY3",
        "mutable-content" : 1,
        "alert" : {
            "title" : "KK title",
            "subtitle" : "KK subtitle",
            "body"  : "KK body"
        }
    },
    "media" : {
        "type" : "image",
        "url" : "https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png"
    }
}
EOF

content擴展中info.plist的一些設置:

content_info

設置UNNotificationExtensionCategory的值相當於向系統註冊了這個通知類別, 當通知推送過來時, 系統會匹配Jason文件中"aps"字典中"category"對應的值. 這裏設置了CATEGORY3, 所以Python文件中編輯Jason文件(payload)的時候, 其"category" : “CATEGORY3”.
其他屬性值在UNNotificationContentExtension找吧😃.

OK, 快結束了.
下面進入自定義佈局content擴展部分. 使用MainInterface.storyboard來佈局:

img-NaHyVyjT-1591613435323

NotificationViewController.m

#import "NotificationViewController.h"
#import <UserNotifications/UserNotifications.h>
#import <UserNotificationsUI/UserNotificationsUI.h>

@interface NotificationViewController () <UNNotificationContentExtension>

@property IBOutlet UILabel *label;
@property (weak, nonatomic) IBOutlet UILabel *subLabel;
@property (weak, nonatomic) IBOutlet UIImageView *imageView;
@property (weak, nonatomic) IBOutlet UILabel *bodyLabel;

@end

@implementation NotificationViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any required interface initialization here.
    NSLog(@"%s", __func__);
}

- (void)didReceiveNotification:(UNNotification *)notification {
    NSLog(@"%s", __func__);
    
    self.label.text = notification.request.content.title;
    self.subLabel.text = notification.request.content.subtitle;
    self.bodyLabel.text = notification.request.content.body;
    
    // 如果附件是圖片, 顯示之
    NSDictionary *dict =  notification.request.content.userInfo;
    if (dict.count) {
        NSData *imageData = dict[@"imageData"];
        UIImage *image = [UIImage imageWithData:imageData];
        self.imageView.image = image;
    }
}

@end

img-ZfVtaoMa-1591613435324)

demo地址

KKNotificationDemo

參考文檔

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章