Notification Optimization

ios10对推送做了很大的优化,我们先来看一下推送展示的逻辑:

如上图所示:在功能上可以划分为四个模块:

A: 服务端推送模块

决定推送的内容,以及推送采取的展示方案(展示方案通过category标识)

B: NotificationExtensionServie 推送到达时展示预处理模块

这个模块的处理是很快的,目前apple给出的推荐demo中的演示是加载附件中的图片。...
在测试中你会发现,一个带有图片的推送到达时做了处理但是没有展示。
但是在第二次推送的时候却展示了。这是因为servieExtension会将已经存在的推送内容直接展示,
需要网络获取的文件有30s的时间可以缓存。所以基本山在第二次推送到达的时候,这个图片已经加载完成,
并缓存了起来

C: NotificationExtensionContent 推送中间层展开界面的处理

主要根据业务需求,自定义功能界面

D: Appdelegate.m 注册按钮样式

在UNUserNotification中注册按钮样式,在推送到达后展开推送中间页时使用。

开发中遇到的问题

开发中存在的最大的问题就是按钮样式的问题,每个按钮组合只能通过一个categoryIdentifier来做相应的匹配,导致的问题有:

  • 每个业务都需要存在一个按钮样式的组合identifier。
  • 每个identifier在注册后按钮样式不能动态变更

同时,由于注册按钮时机的关系(按钮组合样式的注册是在Appdelegate.m文件中注册的),导致的问题有:

  • 每个按钮的功能也是事先决定好了的,导致了按钮的灵活度降低。
  • 业务线开发必须通过更改公共代码来注册按钮,增加了功能耦合

当时是整个框架带着这些诟病一起上线了,实在是个不治之举。这个问题之前一直没有解决,XcodeBeta版本中验证了很多方案也是没有成功,包括下面的方案。

解决方案

最近又开始对这一问题做了详细的跟踪,终于找到了一个满意的解决方案。这里先看图:

产生的构思来自于这个图片,将UNUserNotificationCenter看成独立于App和AppExtension之外的独立结构,所有的app注册category都是向这个结构中添加按钮样式。那么问题就来了,可不可以不在appdelegate.m中注册信息,将category的注册放到extensionServie中,答案是可以的。

这里其实应该是有几个疑问的?

1: servie中注册和app中注册有区别?能生效?

2: 重复注册同一个categoryIdentifier会如何?之前发出的推动展示的按钮样式是先前的还是更改之后的?

3: 每次注册一个categoryIdentifier,那么之前的推送的组合样式还存在?

答案是这样的:

servie中注册和app中注册有区别?能生效?

servie中注册和app中注册没有却别,能生效

重复注册同一个categoryIdentifier会如何?之前发出的推动展示的按钮样式是先前的还是更改之后的?

重复注册同一个categoryIdentifier会将原先的覆盖,这样就可以实现同一个categoryIdentifier变更按钮样式的需求了。下图是发出同一个categoryIdentifier推送后,打印的日志

ExtensionServie中实现的打印代码

@interface
[[UNUserNotificationCenter currentNotificationCenter] getNotificationCategoriesWithCompletionHandler:^(NSSet<UNNotificationCategory *> * _Nonnull categories) {
    NSLog(@"这里打印的是category的信息");
    NSLog(@"%@",categories);
}];
@end

这是推送payload,这里payload标记位payloadOld

{
    "aps":{
        "collapse":"testKey",
        "alert":{
            "title": "支付提醒",
            "body": "您预订的艾丽华酒店您预订的艾丽华酒店您预订的艾丽华酒店您预订的艾丽华酒店您",
        },
        "mutable-content": 1,
        "sound":"default",
        "badge":1,
        "category":"MyZoneNotification",
    },
    "actions":[
        {
            "actionType":0,
            "actionTitle":"查看详情***====",
            "actionOptions":"foreground",
            "actionIdentifier":"checkInfo",
            "actionScheme":"******",
        },
        {
            "actionType":1,
            "actionTitle":"回复***====",
            "actionOptions":"foreground",
            "actionIdentifier":"applyInfo",
            "textInputButtonTitle":"回复的按钮",
            "textInputPlaceholder":"输入你想回复的内容",
            "actionScheme":"******",
        }
    ]
}

这是发送了一个推送后的日志

2016-11-23 11:50:03.345871 RemoteNotificationTestServie[2073:191810] 这里打印的是category的信息
2016-11-23 11:50:03.346584 RemoteNotificationTestServie[2073:191810] {(
    <UNNotificationCategory: 0x127d27090; identifier: MyZoneNotification, actions: (
    "<UNNotificationAction: 0x127d3bd20; identifier: checkInfo, title: \U67e5\U770b\U8be6\U60c5****====, isAuthenticationRequired: NO, isDestructive: NO, isForeground: NO>",
    "<UNTextInputNotificationAction: 0x127d396b0; identifier: applyInfo, title: \U56de\U590d***====, isAuthenticationRequired: NO, isDestructive: NO, isForeground: NO, textInputButtonTitle: \U56de\U590d\U7684\U6309\U94ae, textInputPlaceholder: \U8f93\U5165\U4f60\U60f3\U56de\U590d\U7684\U5185\U5bb9>"
), minimalAction: (
), intentIdentifiers: (
    check
), custom dismiss: NO, CarPlay: NO>
)}

更改了之后的payload actionTitle更改了,actionIdentifier更改了。这里payload标记位payloadNew

{
    "aps":{
        "collapse":"testKey",
        "alert":{
            "title": "支付提醒",
            "body": "您预订的艾丽华酒店您预订的艾丽华酒店您预订的艾丽华酒店您预订的艾丽华酒店您",
        },
        "mutable-content": 1,
        "sound":"default",
        "badge":1,
        "category":"MyZoneNotification",
    },
    "actions":[
        {
            "actionType":0,
            "actionTitle":"查看详情",
            "actionOptions":"foreground",
            "actionIdentifier":"checkInfo***",
            "actionScheme":"******",
        },
        {
            "actionType":1,
            "actionTitle":"回复",
            "actionOptions":"foreground",
            "actionIdentifier":"applyInfo====",
            "textInputButtonTitle":"回复的按钮",
            "textInputPlaceholder":"输入你想回复的内容",
            "actionScheme":"******",
        }
    ]
}

更改了payload中的action之后日志

2016-11-23 11:52:44.074709 RemoteNotificationTestServie[2073:192382] 这里打印的是category的信息
2016-11-23 11:52:44.076397 RemoteNotificationTestServie[2073:192382] {(
    <UNNotificationCategory: 0x127d26a10; identifier: MyZoneNotification, actions: (
    "<UNNotificationAction: 0x127d245f0; identifier: checkInfo***, title: \U67e5\U770b\U8be6\U60c5, isAuthenticationRequired: NO, isDestructive: NO, isForeground: NO>",
    "<UNTextInputNotificationAction: 0x127d265a0; identifier: applyInfo====, title: \U56de\U590d, isAuthenticationRequired: NO, isDestructive: NO, isForeground: NO, textInputButtonTitle: \U56de\U590d\U7684\U6309\U94ae, textInputPlaceholder: \U8f93\U5165\U4f60\U60f3\U56de\U590d\U7684\U5185\U5bb9>"
), minimalAction: (
), intentIdentifiers: (
    check
), custom dismiss: NO, CarPlay: NO>
)}

actionIdentifier已经变了

对于之前发出的推动展示的按钮样式是先前的还是更改之后的?这个问题是这样测试的:

  • 发出5条payloadOld,
  • 发出5条payloadNew
  • 点击查看效果

从日志分析可以得出理论结论,无论点击展开新的覆盖的推送还是点击以前较为旧的推送,都应该展示最新的推送的按钮的样式。但是实际结果是这样的

这个结论与日志打印得出的理论结论不相同,覆盖了之后的category按钮样式应该只有一个啊,之前的按钮难道还在缓存中没有释放?。试想了两种情况解决:

  • 等待足够长时间,等待之前action缓存释放
  • 重启手机

答案是可以的,在等待了2分钟时长时打开payloadOld的推送,发现按钮样式已经是最新的了。重启手机可以打开payloadOld立即就是最新的了。

测试版本

目前测试的手机的系统是ios10.2 beta3. Xcode是8.2beta3。需要在release ios10.1.1上再做一次验证

经测试再已经发布的系统版本中测试版本ios10.1同样存在这样的问题. Xcode8.1 release,ios10.1 release。

每次注册一个categoryIdentifier,那么之前的推送的组合样式还存在?

  • 发出五个不同的category,每个category携带不同的样式
  • 日志打印UNUserNotificationCenter中的category个数以及信息

这里先展示一下推送中注册category的方法

@interface
- (void)registerCategory:(NSDictionary *)userInfo {
    NSParameterAssert(userInfo);
    NSString *categoryIdentifier = userInfo[@"aps"][@"category"];
    NSMutableArray *tempArray = [NSMutableArray new];
    NSArray *actions = userInfo[@"actions"];
    if (actions.count) {
        for (NSDictionary *action in actions) {
            if ([action[actionType] integerValue] == 0) {
                UNNotificationAction *tempAction = [self buildNotificationAction:action];
                if (tempAction) [tempArray addObject:tempAction];
            }else {
                UNTextInputNotificationAction *tempAction = [self buildTextNotification:action];
                if (tempAction) [tempArray addObject:tempAction];
            }
        }
    }
    if (tempArray.count > 0) {
        NSMutableSet *set = [NSMutableSet new];
        UNNotificationCategory *category = [UNNotificationCategory categoryWithIdentifier:categoryIdentifier actions:[tempArray copy] intentIdentifiers:@[@"check"] options:UNNotificationCategoryOptionNone];
        [set addObject:category];
        [[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:set];
    }
}
@end

这个方法是之前的实现,使用这个方法注册。发现当这个新的category推送到达的时候,都会将之前的酒店推送给冲掉,换句话说。推送中心中只能存在最新的推送的category,看了一下api的描述,发现问题所在:setNotificationCategories相当于重置category。所以在推送中心中只能存在最新的推送的样式,针对这个问题有如下两个解决办法:

  • 每次推送达到的时候,将推送所需的所有注册方案都传递过来,一并注册。
  • payload中每次只带这个业务的推送的category。

每次推送达到的时候,将推送所需的所有注册方案都传递过来,一并注册

这个方案下需要考虑的问题是,注册数据的来源是:

  • payload
  • NotificationServieExtension下载

如果使用payload将注册的按钮信息带过来的话,会增加payload的size,ios8之后payload的size要求是在4kb之内。还有一个问题,每次将这么庞大的(相对来说注册Category的信息过于庞大)payload作为推送发出来也是不合理的,会增加用户的流量开销。

如果使用NotificationServieExtension下载的方式如何,那就需要设计一套比较好的方案了,这个接口有很大的安全风险,毕竟action还是可以对app发出相应的操作的,所以需要做安全校验。可以开辟两个后台的接口,一个用于检测更新,一个用于存放推送的category内容。标识符可以是版本号和categoryID来做更新,当用户检测到新的category时,下载最新的资源并注册,将资源存在本地。这么做存在的问题是,可能推送成功,但是没有成功的下载推送文件,注册category没有成功,导致按钮样式没有显示。

payload中每次只带这个业务的推送的category

这个也是很简单的,将代码重新改为这样就好了,对于推送本体来说不论添加还是覆盖,都不应该存在相同的categoryIdentifier,所以需要先检测当前UNUserNotification中的所有的category,如果这个category存在则删除后增加,如果不存在直接增加

代码如下:

@interface
- (void)registerCategory:(NSDictionary *)userInfo {
    NSParameterAssert(userInfo);
    NSString *categoryIdentifier = userInfo[@"aps"][@"category"];
    NSMutableArray *tempArray = [NSMutableArray new];
    NSArray *actions = userInfo[@"actions"];
    if (actions.count) {
        for (NSDictionary *action in actions) {
            if ([action[actionType] integerValue] == 0) {
                UNNotificationAction *tempAction = [self buildNotificationAction:action];
                if (tempAction) [tempArray addObject:tempAction];
            }else {
                UNTextInputNotificationAction *tempAction = [self buildTextNotification:action];
                if (tempAction) [tempArray addObject:tempAction];
            }
        }
    }
    if (tempArray.count > 0) {
        [[UNUserNotificationCenter currentNotificationCenter] getNotificationCategoriesWithCompletionHandler:^(NSSet<UNNotificationCategory *> * _Nonnull categories) {
            NSMutableSet *set = [categories mutableCopy];
            for (NSInteger i = 0; i < set.allObjects.count; i ++) {
                UNNotificationCategory *category = set.allObjects[i];
                if ([category.identifier isEqualToString:categoryIdentifier]) {
                    [set removeObject:category];
                    break;
                }

            }
            UNNotificationCategory *category = [UNNotificationCategory categoryWithIdentifier:categoryIdentifier actions:[tempArray copy] intentIdentifiers:@[@"check"] options:UNNotificationCategoryOptionNone];
            [set addObject:category];
            [[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:set];
        }];
    }
}
@end

在注册的时候将以前存在的category全部复制过来,检测有没有重复的,有则删除,没有则增加即可。再看一下日志打印结果:

2016-11-23 16:21:01.357154 RemoteNotificationTestServie[979:60218] {(
    <UNNotificationCategory: 0x111d0b060; identifier: MyZoneNotification1, actions: (
    "<UNNotificationAction: 0x111d11ca0; identifier: checkInfo1, title: \U67e5\U770b\U8be6\U60c51, isAuthenticationRequired: NO, isDestructive: NO, isForeground: NO>",
    "<UNTextInputNotificationAction: 0x111d07b20; identifier: applyInfo, title: \U56de\U590d1, isAuthenticationRequired: NO, isDestructive: NO, isForeground: NO, textInputButtonTitle: \U56de\U590d\U7684\U6309\U94ae, textInputPlaceholder: \U8f93\U5165\U4f60\U60f3\U56de\U590d\U7684\U5185\U5bb9>"
), minimalAction: (
), intentIdentifiers: (
    check
), custom dismiss: NO, CarPlay: NO>,
    <UNNotificationCategory: 0x111d09cf0; identifier: MyZoneNotification2, actions: (
    "<UNNotificationAction: 0x111d09d20; identifier: checkInfo2, title: \U67e5\U770b\U8be6\U60c52, isAuthenticationRequired: NO, isDestructive: NO, isForeground: NO>",
    "<UNTextInputNotificationAction: 0x111d0b7a0; identifier: applyInfo, title: \U56de\U590d2, isAuthenticationRequired: NO, isDestructive: NO, isForeground: NO, textInputButtonTitle: \U56de\U590d\U7684\U6309\U94ae, textInputPlaceholder: \U8f93\U5165\U4f60\U60f3\U56de\U590d\U7684\U5185\U5bb9>"
), minimalAction: (
), intentIdentifiers: (
    check
), custom dismiss: NO, CarPlay: NO>,
    <UNNotificationCategory: 0x111d0ebf0; identifier: MyZoneNotification3, actions: (
    "<UNNotificationAction: 0x111d0f500; identifier: checkInfo3, title: \U67e5\U770b\U8be6\U60c53, isAuthenticationRequired: NO, isDestructive: NO, isForeground: NO>",
    "<UNTextInputNotificationAction: 0x111d144e0; identifier: applyInfo, title: \U56de\U590d3, isAuthenticationRequired: NO, isDestructive: NO, isForeground: NO, textInputButtonTitle: \U56de\U590d\U7684\U6309\U94ae, textInputPlaceholder: \U8f93\U5165\U4f60\U60f3\U56de\U590d\U7684\U5185\U5bb9>"
), minimalAction: (
), intentIdentifiers: (
    check
), custom dismiss: NO, CarPlay: NO>
)}

这样就会存在三个category的注册了,推送中也能匹配到三个不同的按钮样式了

但是这种方式的好处是业务线可以自己管理自己的推送的样式,与公共模块解耦,但是同样存在弊端,例如一个IM类应用,如果频繁的发出这样的推送,携带这样的资源就没有必要了

建议

  • 推送消息不是很频繁的情况可以使用第二种方案(payload中每次只带这个业务的推送的category)。可以减少接口开发,契合业务需求。
  • 推送消息很频繁的情况可以使用第一种方案的第二个小方案(NotificationServieExtension下载),减少payload内容数量,较少用户流量消耗
  • 在任何情况下不建议使用第一种方案的第一个消防安全,存在的弊端太大

代码展示

如下是NotificationServie中的实现

#import "NotificationService.h"
NSString *const actionTitle = @"actionTitle";
NSString *const actionType = @"actionType";
NSString *const actionOptions = @"actionOptions";
NSString *const actionIdentifier = @"actionIdentifier";
NSString *const textInputButtonTitle = @"textInputButtonTitle";
NSString *const textInputPlaceholder = @"textInputPlaceholder";
NSString *const actionScheme = @"actionScheme";

@interface NotificationService ()
@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;
@property (nonatomic, strong) NSDictionary *userInfo;
@end

@implementation NotificationService

- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
    self.userInfo = request.content.userInfo;
    [self registerCategory:self.userInfo];

    [[UNUserNotificationCenter currentNotificationCenter] getNotificationCategoriesWithCompletionHandler:^(NSSet<UNNotificationCategory *> * _Nonnull categories) {
        NSLog(@"这里打印的是category的信息");
        NSLog(@"%@",categories);
    }];
    self.contentHandler = contentHandler;
    self.bestAttemptContent = [request.content mutableCopy];

    // Modify the notification content here...
    self.bestAttemptContent.title = [NSString stringWithFormat:@"%@ [modified]", self.bestAttemptContent.title];

    self.contentHandler(self.bestAttemptContent);
}

- (void)serviceExtensionTimeWillExpire {
    [self registerCategory:self.userInfo];
    self.contentHandler(self.bestAttemptContent);
}

- (void)registerCategory:(NSDictionary *)userInfo {
    NSParameterAssert(userInfo);
    NSString *categoryIdentifier = userInfo[@"aps"][@"category"];
    NSMutableArray *tempArray = [NSMutableArray new];
    NSArray *actions = userInfo[@"actions"];
    if (actions.count) {
        for (NSDictionary *action in actions) {
            if ([action[actionType] integerValue] == 0) {
                UNNotificationAction *tempAction = [self buildNotificationAction:action];
                if (tempAction) [tempArray addObject:tempAction];
            }else {
                UNTextInputNotificationAction *tempAction = [self buildTextNotification:action];
                if (tempAction) [tempArray addObject:tempAction];
            }
        }
    }
    if (tempArray.count > 0) {
        [[UNUserNotificationCenter currentNotificationCenter] getNotificationCategoriesWithCompletionHandler:^(NSSet<UNNotificationCategory *> * _Nonnull categories) {
            NSMutableSet *set = [categories mutableCopy];
            for (NSInteger i = 0; i < set.allObjects.count; i ++) {
                UNNotificationCategory *category = set.allObjects[i];
                if ([category.identifier isEqualToString:categoryIdentifier]) {
                    [set removeObject:category];
                    break;
                }

            }
            UNNotificationCategory *category = [UNNotificationCategory categoryWithIdentifier:categoryIdentifier actions:[tempArray copy] intentIdentifiers:@[@"check"] options:UNNotificationCategoryOptionNone];
            [set addObject:category];
            [[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:set];
        }];
    }
}

- (UNNotificationAction *)buildNotificationAction:(NSDictionary *)action {
    NSParameterAssert(action);
    NSString *title = action[actionTitle];
    NSString *identifier = action[actionIdentifier];
    NSString *optionsString = action[actionOptions];

    UNNotificationActionOptions options;
    if ([optionsString isEqualToString:@"acptions"]) {
        options = UNNotificationActionOptionForeground;
    }
    return [UNNotificationAction actionWithIdentifier:identifier title:title options:options];
}

- (UNTextInputNotificationAction *)buildTextNotification:(NSDictionary *)action {
    NSParameterAssert(action);

    NSString *title = action[actionTitle];
    NSString *identifier = action[actionIdentifier];
    NSString *buttonTitle = action[textInputButtonTitle];
    NSString *placeholder = action[textInputPlaceholder];

    NSString *optionsString = action[actionOptions];

    UNNotificationActionOptions options;
    if ([optionsString isEqualToString:@"acptions"]) {
        options = UNNotificationActionOptionForeground;
    }
    return [UNTextInputNotificationAction actionWithIdentifier:identifier title:title options:options textInputButtonTitle:buttonTitle textInputPlaceholder:placeholder];
}

@end

payload Example:

{
    "aps":{
        "alert":{
            "title": "支付提醒",
            "body": "您预订的艾丽华酒店您预订的艾丽华酒店您预订的艾丽华酒店您预订的艾丽华酒店您",
        },
        "mutable-content": 1,
        "sound":"default",
        "badge":1,
        "category":"MyZoneNotification",
        "collapse":"testKey"
    },
    "actions":[
        {
            "actionType":0,
            "actionTitle":"查看详情****====",
            "actionOptions":"foreground",
            "actionIdentifier":"checkInfo",
            "actionScheme":"******",
        },
        {
            "actionType":1,
            "actionTitle":"回复***====",
            "actionOptions":"foreground",
            "actionIdentifier":"applyInfo",
            "textInputButtonTitle":"回复的按钮",
            "textInputPlaceholder":"输入你想回复的内容",
            "actionScheme":"******",
        }
    ]
}

总结

在NotificationServieExtension中注册Category能带来什么?首选我们来看一下在appdelegate.m中注册category会有什么弊端:

  • 不能实时更新,需要发版才能更新
  • 业务代码与公共功能耦合。

在NotificationServieExtension中注册的优势

  • 实时变更按钮样式。覆盖指定categoryIdentifier的按钮样式组合
  • 业务功能与公共组件解耦。通过payload带来的信息,实时注册按钮。
  • 实时变更按钮的功能。按钮的功能也可以通过payload载体携带