利用Firebase实现的推送功能系统

  1. 业务背景
  2. 业务实现
    1. 注册推送
    2. 处理通知
      1. 本地通知处理
        1. 未启动App的接收
        2. 前后台接收
      2. 远程通知处理
    3. 单用户推送
    4. 群推送
    5. 公会推送
    6. 架构
    7. 总结
    8. 参考文章:

本文不是一篇介绍如何接入Notification的教程,而主要是关联起应用业务来讲述关于到点的推送模式的实现过程,以及补充一些推送接入时的注意点。接入教程的话,喵神的这篇《活久见的重构 - iOS 10 UserNotifications 框架解析》已经十分有条理地把UserNotification的细节说得非常清晰。

业务背景

业务需求是,利用Firebase Cloud Message(下文简称FCM)这个云消息系统作为中间工具,实现到点的推送,这里说的点并不是传统的指一台设备,而是指一个账号或者一个角色,例如推送给达到指定等级,或者满足了特定条件的用户群,又例如推送给加入了公会的成员,或者会长发送广播通知推送。

基于FCM的实现,会简化了很大一部分的工作,包括:

  • iOS DeviceToken的管理:FCM会生成一个Instance Token来进行关联,它的后台使用这个映射关系找出DeviceToken然后发推送APNS。
  • 服务端跨平台的对接:服务端提供给客户端接入的业务逻辑接口是统一的,而向FCM发消息的接口是单一的,给不同平台发送消息的部分封装在FCM的接口内部,所以不需要考虑区分iOS和Android的差异即能完成跨平台的对接。
  • 分类推送功能:FCM会建立多个设备Instance Token对应Topices(主题)、Event(行为)的关系表,在发消息时可以选择针对某一Topic或者Event,实现特定用户群的推送。而表中的关系有客户端触发增加或删除,而控制客户端这个操作的则是业务需求而定了,下面将会讲述。
  • 设备组推送功能:能自定义捆绑器一堆Instance Token,利用这个Token的捆绑包为单位作为一个推送点。

此处的业务应用是基于手游App上面展开的。

业务实现

注册推送

在合适的位置调用以下方法,来获取推送授权,注册推送。由于被拒绝授权后,重新获取授权会十分麻烦,所以这里应该尽量考虑放在能激起用户接收推送的位置或者时机再触发。

#ifdef __IPHONE_8_0
    if ([application respondsToSelector:@selector(registerForRemoteNotifications)]) {
        [application registerForRemoteNotifications];
        [application registerUserNotificationSettings:[UIUserNotificationSettings settingsForTypes:
                                                       UIUserNotificationTypeNone|
                                                       UIUserNotificationTypeBadge|
                                                       UIUserNotificationTypeSound categories:nil]];
    } else {
        [application registerForRemoteNotificationTypes:UIRemoteNotificationTypeAlert|UIRemoteNotificationTypeBadge|UIRemoteNotificationTypeSound];
    }
#else
    [application registerForRemoteNotificationTypes:UIRemoteNotificationTypeAlert|UIRemoteNotificationTypeBadge|UIRemoteNotificationTypeSound];
#endif

处理通知

通知的接收分为以下几种情况

状态 类型 处理位置(UIApplicationDelegate/UNUserNotificationCenterDelegate)
App在前台 远程通知
App在前台 本地通知
App在后台 远程通知
App在后台 本地通知

然后在推送消息中设计了一些附加字段控制消息的类型、内容、显示方式,一共有三个字段

  • FirebaseContentType:消息的类型,例如url、dialog
  • FirebaseContent:消息的内容,例如对应url的一个地址、或者对应dialog的一段文本
  • FirebaseShowMode:消息展示方式,例如考虑游戏用户可能不想被打断操作,则应使用横幅提示的形式,其它情况可以考虑使用弹提示框询问、或者直接显示、甚至自定义显示形式。

本地通知处理

未启动App的接收

iOS10以前,在application:didFinishLaunchingWithOptions:里利用launchOptions携带的信息处理本地推送启动App的事件,但不做远程推送的处理,因为application:didReceiveRemoteNotification:fetchCompletionHandler:会在当前回调方法执行完之后接着被调用,这也是实现静默推送的方法,而application:didReceiveLocalNotification:不会被调用。

iOS10及以上,应在此回调中初始化UserNotificationCenter的delegate,并实现对应的UNUserNotificationCenterDelegate方法。

- (void)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
···

#ifdef __IPHONE_10_0
    if ([[UIDevice currentDevice] systemVersion].floatValue >= 10.0) {
        EFNFirebaseNontificationController *notificationController = [[[self class] alloc] init];
        [UNUserNotificationCenter currentNotificationCenter].delegate = notificationController;
        return;
    }
#endif
    UILocalNotification *localNotification = launchOptions[UIApplicationLaunchOptionsLocalNotificationKey];
    if (localNotification) {
        NSString *contentType = localNotification.userInfo[EFNFirebaseNotificationUserInfoContentTypeKey];
        NSString *content = localNotification.userInfo[EFNFirebaseNotificationUserInfoContentKey];
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        	//业务处理
            [self private_handleNotificationWithContentType:contentType content:content];
        });
    }
}

这里有三个注意点

  • 由于游戏的主Window初始化可能会比较慢,避免后面的一些在Window或者RootViewController的view上交互的UI出现显示异常,这里做了一个延迟执行的处理。
  • UIApplicationLaunchOptionsLocalNotificationKey已经被抛弃了,所以它应该只作为兼容处理,尽量使用UserNotification,设置[UNUserNotificationCenter currentNotificationCenter].delegate成为必须要实现的部分。

若通过点击本地推送消息启动App的话,launchOptions的结构则是这样的

//launchOptions
{
    UIApplicationLaunchOptionsLocalNotificationKey = "<UIConcreteLocalNotification: 0x17018cd90>{fire date = 2018\134U5e741\134U670815\134U65e5 \134U661f\134U671f\134U4e00 \134U4e2d\134U56fd\134U6807\134U51c6\134U65f6\134U95f4 \134U4e0b\134U53487:25:56, time zone = (null), repeat interval = 0, repeat count = UILocalNotificationInfiniteRepeatCount, next fire date = (null), user info = {\134n    FirebaseMsg = https://www.google.com;\134n    FirebaseType = url;\134n}}";
}

//localNotification.userInfo
{
	FirebaseContent = "https://www.google.com";
	FirebaseContentType = url;
}

而通过点击远程推送消息启动App的话,在application:didFinishLaunchingWithOptions:的launchOptions结构是这样的

{
    UIApplicationLaunchOptionsRemoteNotificationKey =     {
        FirebaseContent = "https://www.google.com";
        FirebaseShowMode = none;
        FirebaseContentType = url;
        aps =         {
            alert = push6;
        };
        "gcm.message_id" = "0:1516016736340703%99c10dc399c10dc3";
        "gcm.n.e" = 1;
        "google.c.a.c_id" = 7885967838754698804;
        "google.c.a.e" = 1;
        "google.c.a.ts" = 1516016735;
        "google.c.a.udt" = 0;
    };
}

//FirebaseMsg、FirebaseShow、FirebaseType这三个附加字段是自定义的,用来控制业务逻辑,其它gcm、google的是Firebase定义的。而aps则是系统定义的,在只设置推送消息的body时,里面的alert的值则会是一个字符串,而把消息的title、subtitle等都设置上的话,alert的值则会是一个字段,里面才区分body、title、subtitle。

更多的Payload格式参考

iOS10及以上时,采用UNUserNotificationCenterDelegate处理推送的话,通过点击推送(不分本地或是远程)启动App就会回调以下代理方法

#ifdef __IPHONE_10_0
- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void(^)(void))completionHandler {
    NSDictionary *userInfo = response.notification.request.content.userInfo;
    NSString *contentType = userInfo[EFNFirebaseNotificationUserInfoContentTypeKey];
    NSString *content = userInfo[EFNFirebaseNotificationUserInfoContentKey];
    [self private_handleNotificationWithContentType:contentType content:content];
    !completionHandler?:completionHandler();
}
#endif

若要知道推送的来源,则可通过判断response.notification.request.trigger的类型来区分,在UNNotificationTrigger的子类里面,除了UNPushNotificationTrigger是代表远程推送的trigger外,其它子类均代表本地推送。

前后台接收

iOS10以下(UIApplicationDelegate)

- (void)application:(UIApplication *)application didReceiveLocalNotification:(nonnull UILocalNotification *)notification {
    NSString *contentType = notification.userInfo[EFNFirebaseNotificationUserInfoContentTypeKey];
    NSString *content = notification.userInfo[EFNFirebaseNotificationUserInfoContentKey];
    [[self class] private_handleNotificationWithContentType:contentType content:content];
}

iOS10及以上(UNUserNotificationCenterDelegate)

- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler {
    NSDictionary *userInfo = notification.request.content.userInfo;
    NSDictionary *apsInfo = userInfo[@"aps"];
    id alertInfo = apsInfo[@"alert"];
    
    NSString *contentType = userInfo[EFNFirebaseNotificationUserInfoContentTypeKey];
    NSString *content = userInfo[EFNFirebaseNotificationUserInfoContentKey];
    NSString *showMode = userInfo[EFNFirebaseNotificationUserInfoPresentModeKey];
    
    if (apsInfo) {
        [[self class] private_handleRemoteNotificationWithContentType:contentType content:content showMode:showMode alertInfo:alertInfo];
    } else {
        !completionHandler?:completionHandler(UNNotificationPresentationOptionAlert|UNNotificationPresentationOptionSound|UNNotificationPresentationOptionBadge);
    }
}

这里采用判断notification.request.content.userInfo是否含有apsInfo来决定是否本地推送,若为本地推送的时候就不再采用系统的横幅再次提示。

远程通知处理

iOS10以下(UIApplicationDelegate)

- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
    NSDictionary *apsInfo = userInfo[@"aps"];
    id alertInfo = apsInfo[@"alert"];
    
    NSString *contentType = userInfo[EFNFirebaseNotificationUserInfoContentTypeKey];
    NSString *content = userInfo[EFNFirebaseNotificationUserInfoContentKey];
    NSString *showMode = userInfo[EFNFirebaseNotificationUserInfoPresentModeKey];
    
    [[self class] private_handleRemoteNotificationWithContentType:contentType content:content showMode:showMode alertInfo:alertInfo];
}

注意

  • 当推送消息体中的aps的content-available为1时,App在后台挂起时方法也会被执行,即是静默推送(Silent-Push)。
  • 在iOS7以上,实现了application:didReceiveRemoteNotification:fetchCompletionHandler:此代理方法的话application:didReceiveRemoteNotification:就不会被调用。
  • 前台或后台的接收以及App通过推送触发启动均通过此方法处理。

iOS10及以上,与本地推送共用UNUserNotificationCenterDelegate的代理方法,前台接收通过userNotificationCenter:willPresentNotification:withCompletionHandler:的回调处理,后台接收或App通过推送触发启动通过userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:的回调处理。

单用户推送

由我们的服务端建立用户与其设备的关系表,即通过客户端把用户每次登陆的user ID与当前设备的FCM Token上传给服务端插入表,这样每个用户就会有一个设备组对应,利用FCM的设备组推送功能,就能针对某一个或一群指定user ID的用户的所有登录中的设备实施推送,例如补偿奖励给特定玩家之后,对特定大R的通知等等。

当用户登出,或使用新账号登录,则上报新登录的user ID与当前设备的FCM Token到服务端,当没有该user ID的记录,则建立初始对应关系,若已有记录则向其已对应的设备组推送新设备的登录通知。FCM会返回推送失败的设备,这些设备可能是更新了FCM Token,旧Token失效所致。然后服务端就可以向FCM申请删除失效的Token和添加新关联的Token到当前user ID的设备组里面。此时设备组关系更新完毕,这就是平时我们经常看到的安全登录通知功能。

群推送

根据账号的操作,决定用户所属的推送群。例如小白用户一直升级,到了5级就是触发进入5级推送群组里面,即当前设备会订阅在FCM预定义的5级主题(level5 Topic),这也会当作当前用户的一项属性存在服务端,当用户在其它设备或者重装应用后能被恢复。这样后台推送给level5 Topic时,所有达到此条件,即是订阅了此主题的用户都会收到通知,其他用户则不会收到。

但只使用单一主题控制群推送仍未能满足需求,由于游戏可能是多语言的,不同用户可能会切换到不同的语言,这样的情况下推送所用的语言也应该一致。但基于不同群主题再针对不同语言生成不同的Topic是不现实的,因为这样会导致产生大量的Topic,而Google对Topic的数量控制又是未知的。

我们利用了FCM Topic的逻辑关系控制推送接收的功能,解决上面这个问题。用户进入游戏都会先订阅一个游戏标识+语言标识命名的Topic,当用户切换语言时就会取消之前语言的Topic订阅,重新订阅游戏标识+新语言标识的Topic。服务端要推送消息时,先获取不同语言版本的消息体,然后将不同语言的消息体通过群Topic && 语言Topic的方式对应推送,这样没有订阅其它语言Topic的,且没符合群条件的用户就不会收到其它语言的推送了。

同时这个游戏语言Topic可作为此游戏的总推送开关,取消订阅的话则所有推送都不再收到,因为其它Topic都需要&&这个游戏语言Topic来发送,不订阅它就无法接收。

当用户登出时,则把当前设备订阅过的Topic全取消订阅,避免与新用户的订阅混淆。然后等新用户登录后重新初始化用户推送群的订阅。

公会推送

公会的推送是针对角色而不是用户的,所以这类推送是独立的(当然可以做成受总推送开关控制)。

当角色进入游戏后,首先会根据游戏当前语言订阅一个公会语言Topic(切换语言时这个订阅也要跟着切换)。

然后当角色加入某一公会时,则订阅游戏标识+公会标识的Topic。而当角色在公会里关闭公会推送功能,或者退出公会,则需要取消订阅游戏标识+公会标识的Topic

登出角色或者登出账号时,也需要取消订阅游戏标识+公会标识的Topic,而公会语言Topic则不用变动,因为它根据游戏语言而定的,不受角色或者账号的变动影响。

发送官方的公会消息时将不同语言的消息体通过游戏公会Topic && 公会语言Topic的方式对应推送。但公会长的消息则直接使用游戏公会Topic发送,因为是以会长的语言为准,这样只需要保持会长的原文进行推送即可,不需游戏公会Topic辅助逻辑。

架构

实现类的结构如下

├── EFNFirebaseNontificationController
├── EFNFirebaseNotification
	├── EFNFirebaseGroupNotification
	├── EFNFirebaseGuildNotification
	└── EFNFirebaseSingleNotification
  • EFNFirebaseNontificationController:负责:

    • 本地和远程、前台和后台的推送接收;
    • 接收推送后的推送展示逻辑及展示实现;
    • 作为一个环境角色类,负责执行策略方法,不关注具体的策略类。
  • EFNFirebaseNotification:抽象策略类,把单推、群推、公会推的功能抽象出来,提供给环境角色直接调用。

  • EFNFirebaseGroupNotification:群推的具体策略类。

  • EFNFirebaseGuildNotification:公会推的具体策略类。

  • EFNFirebaseSingleNotification:单推的具体策略类。

抽象的策略方法主要包括:

  • setupNotificationWithUnitID: - 设定或恢复推送单位订阅并缓存,推送单位是指账号user ID
    或者角色role ID这些;

  • clearLastUnitNotificationStatusInDevice - 清除推送单位的订阅和缓存

  • changeNotificationShouldSubscribe:unitID: - 切换推送单位的订阅开关

总结

写了这么多,感觉写文章的思路还是比较混乱,特别当内容比较多,情况分类多,延伸的子情况
也多的时候,就出现来回修改文章,多次排版的问题,段落划分也不清晰。看来要恶补一下技术文章撰写的技巧和反思一下文章布局的思维,下次写文章不能再出现这种混乱的情况,下键盘前需要思考一下整体的计划,锻炼语言的组织能力。

然后关于推送,还有好一些功能还没使用上,例如推送消息中的Category、图片、铃声音频,日期推送、定位推送等等,期待下次应用到的时候再次总结,从侧面来看,本不应待应用的时候才总结,当Apple有功能更新的时候就该试玩,然后总结,走在最前。

参考文章:

活久见的重构 - iOS 10 UserNotifications 框架解析
UIApplication Delegate launch Options
ios notification (一)
ios 本地和远程通知(二)
iOS开发——iOS静默推送介绍及使用场景
How to handle remote notification with background mode enabled


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 mingfungliu@gmail.com

文章标题:利用Firebase实现的推送功能系统

文章字数:3.6k

本文作者:Mingfung

发布时间:2018-01-17, 19:33:41

最后更新:2018-01-17, 19:33:41

原始链接:http://blog.ifungfay.com/uncategorized/利用Firebase实现的推送功能系统/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录
×

喜欢就点赞,疼爱就打赏

宝贝回家