iOS开发之进阶篇(2)—— 本地通知和远程通知 (使用APNs)

Notification.png

版本

iOS 10+

目录:

  1. 概述
  2. 通知的管理和配置
  3. 本地通知
  4. 远程通知 (使用APNs)
  5. 修改通知内容和显示界面
  6. demo地址

一. 概述

由于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];
        });
    });
}
local notifacation.png

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, 不然就收不到通知啦.

网上有一张图, 比较完整地诠释这个过程(找不到出处了…):


pushNotification.jpg

对此图稍作说明:

  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

Xcode_Config.png
2. 生成APNs AuthKey

登录开发者账号, keys, +

keys.png

起个名, 选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, 但是最终都不得其姐.

target_version.png

注意! 给这两个扩展都添加推送通知的功能

capabilities

最终左边栏新增如下扩展代码

extension_catalogue

注意! 在调试的时候, 我们需要切换对应的target才会走断点和打印log.

target_change.png

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:

http.png

对了, 顺便提一下附件内容的支持, 摘自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

UI.png

苹果文档
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来布局:

mainInterface.png

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
content_demo.png

demo地址

KKNotificationDemo

参考文档

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345