iOS / 擦饭碗 · 2024年 6月 2日 0

push仿微信语音来电

0 推送的分类

在介绍方案之前,先简单过一下push的类型:

参考:sending-notification-requests-to-apns?language=objc

远程推送的分类很多,设备接受到一条推送消息后,如何分发,取决于消息头,以及payload的内容。在这些当中,符合我们通过push模拟来电的,仅有voip(采用PushKit+CallKit,参考如流),以及alert(参考微信),而由于政策原因,我们只能选取后者。

一些参考文献

1 alert消息处理流程回顾

鉴于Voip(PushKit + CallKit)方案审核被拒,现设计微信的语音来电方案。经过调用,远程推送中的alert模式的推送,符合我们的场景(其他模式参考:),在方案设计前,先回顾一下一条alert推送消息的流转过程:


在这个过程中,有三点需要注意:

  1. 每条push最大的处理时间约为30s
  2. 若主动调用Complete推出横幅后,最多还有5s的时间处理其他事物
  3. 超过30s后,会触发系统函数-serviceExtensionTimeWillExpire,该函数会隐式调用Complete,推出横幅。

2 设计目标及方案

我们的设计目标如下:

  1. APP收到【语音电话类型】的推送消息后,设备顶部展示横幅,并持续震动&响铃(若开了静音则无法响铃)
  2. 震动&响铃期间,锁屏&通知中心显示来电消息
  3. APP进前台,停止震动&响铃,唤起端内来电弹窗。并移除锁屏&通知中心的通知
  4. 超时未接听,停止震动&响铃,并移除锁屏&通知中心的通知(可选)
  5. 推送期间,服务端终止呼叫,设备停止震动&响铃,并移除锁屏&通知中心的通知(可选)

下面针对目标,分别进行方案设计:

目标1:收到推送后,持续震动、响铃

首先,后端推的push消息结构中,一定要有一个字段,用于标识来电,结合目标5,我们可以设计结构如下(alert类型支持的完整参数:https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification?language=objc):

{
    "pushType": 1, // 1为来电 2为挂断
    "aps":
    {
        "alert":
        {
            "title": "推送通知标题", 
            "subtitle": "推送通知副标题",
            "body": "这是推送通知的正文内容"//最多4行字
        },
        "mutable-content": 1 //这里必须设置为1
    }
}

静态效果如图:

当收到推送后,若pushType为1时,就是我们处理来电语音的时机。由于消息体中mutabe-content字段的设置,使得Push Service插件能够收到消息进行处理,即执行了-didReceiveNotificationRequest:withContentHandler:方法

在这个方法中我们可以推出横幅,然后在新线程循环播放震动&铃声,同时挂起当前线程,防止由于横幅被推出后,整个流程只能执行5s:

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

    NSDictionary *pushDictionary = request.content.userInfo;
    __weak typeof(self) weakSelf = self;
    if ([pushDictionary.allKeys containsObject:@"pushType"]) {
        [[DXMPushPhoneManager shareManager] callingWithRequest:request complete:^(UNNotificationContent * _Nonnull contentToDeliver) {        
            weakSelf.contentHandler(contentToDeliver);
        }];
    }
}

这里我们看下在Push Service进程中的默认线程 endpoint:

而我们的震动&响铃任务执行在主线程:

可以确认挂起endpoint线程,不会影响震动&响铃的任务

然后我们还要做一些辅助性的事务:

  • 在沙盒中存储来电push的消息。(注意使用appgroup跨进程沙盒存储)

目标2 震动&响铃期间,锁屏&通知中心显示来电消息

由于在上一步中,在启动震动&铃声循环任务后,就主动推出了横幅,故本目标可以满足

目标3 APP启动/回到前台的处理

1.停掉震动&响铃: 当APP进入前台后,沙盒更新对应标记位(注意使用appgroup跨进程沙盒存储),Push Service进程中,震动&铃声定时器每次循环都根据标记位(注意使用appgroup跨进程读取沙盒),判断是否停止循环。因此本目标达成。

  1. 在目标1的方案中,我们在沙盒中存储了push的消息,那么无论是用户点击桌面图标,还是点击Push将APP拉起,我们都只需要从沙盒中读取push的数据即可(为统一处理,放弃掉对scheme拉起的处理)

目标4 超时处理

用户超时未点击推送条,或主动打开APP,导致触发-serviceExtensionTimeWillExpire函数,那么在这个时机:

  1. 起一条本地push,提示用户未接听(可选)
  2. 调用 [[UNUserNotificationCenter currentNotificationCenter] removeDeliveredNotificationsWithIdentifiers:identifers]; 清楚掉通知栏中的通知(可选)
  3. 清楚沙盒中的push信息

目标5 中断呼叫处理

后端下发一条 pushType为2的push消息,端上在处理时:

  1. 停掉震动&铃声循环任务
  2. 调用 [[UNUserNotificationCenter currentNotificationCenter] removeDeliveredNotificationsWithIdentifiers:identifers]; 清除掉通知栏中的通知
  3. 移除沙盒中的push消息
  4. 推出挂断推送横幅,播放挂断音效,清除掉通知栏中的通知(可选)

完整的Demo效果

  1. 未接听

  2. 接听

3.终止呼叫

3 还能做哪些事情?

3.1 定制化Push通知完整页面(iOS10+)

当用户长按push条时,苹果提供了展示一个完整push内容页面的能力(不需要启动app),

这个需要我们引入 Notification Content Extension的能力:

步骤 1:创建通知内容扩展目标

在 Xcode 中,选择你的项目文件。
点击左下角的加号按钮,选择 “New Target”。
在模板选择界面,选择 “Notification Content Extension”。
点击 “Next”,然后填写扩展的名称和其他信息。
点击 “Finish”。
Xcode 会为你创建一个新的扩展,并包含一些示例代码。

步骤 2:配置扩展的 Info.plist
打开新创建的扩展目标中的 Info.plist 文件。
确保 NSExtension 字典中包含以下键值对:(UNNotificationExtensionDefaultContentHidden 控制的是是否在头部展示推送条的内容)

<key>NSExtension</key>
<dict>
    <key>NSExtensionAttributes</key>
    <dict>
        <key>UNNotificationExtensionCategory</key>
        <string>your_category_identifier</string>
        <key>UNNotificationExtensionInitialContentSizeRatio</key>
        <real>0.5</real>
        <key>UNNotificationExtensionDefaultContentHidden</key> 
        <true/>
    </dict>
    <key>NSExtensionPointIdentifier</key>
    <string>com.apple.usernotifications.content-extension</string>
</dict>

步骤 3:实现扩展的主界面
在扩展目标中,Xcode 会生成一个包含 NotificationViewController示例视图控制器。这个视图控制器需要遵循 UNNotificationContentExtension 协议。我随便搞了下布局

步骤4:注册。注意这里的your_category_identifier要和plist中保持一致。

- (void)registerNotificationCategories {
    UNNotificationCategory *category = [UNNotificationCategory categoryWithIdentifier:@"your_category_identifier"
                                                                              actions:@[]
                                                                    intentIdentifiers:@[]
                                                                              options:UNNotificationCategoryOptionCustomDismissAction];
    [[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:[NSSet setWithObject:category]];
}

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [self registerNotificationCategories];
    return YES;
}

步骤5:发送通知。要注意通知中的category字段要对应:

{
  "aps": {
    "alert": {
      "title": "紧急通知",
      "subtitle": "请立即查看",
      "body": "这是一个重要的推送通知内容。",
    },
    "sound": "default",
    "badge": 1,
    "category": "your_category_identifier",
    "mutable-content": 1,
    "content-available": 1
  }
}

3.2 定制化Push通知快捷按钮

这个其实和3.1一样,也是借助于Notification Content Extension的能力,只需要在注册的时候,加上按钮即可:

- (void)registerNotificationCategories {

    // 定义回复操作
      UNTextInputNotificationAction *replyAction = [UNTextInputNotificationAction actionWithIdentifier:@"REPLY_ACTION"
                                                                                                title:@"回复"
                                                                                              options:UNNotificationActionOptionAuthenticationRequired
                                                                                textInputButtonTitle:@"Send"
                                                                                textInputPlaceholder:@"Type your message"];

      // 定义查看对话操作
      UNNotificationAction *viewAction = [UNNotificationAction actionWithIdentifier:@"VIEW_ACTION"
                                                                              title:@"查看"
                                                                            options:UNNotificationActionOptionForeground];

    UNNotificationCategory *category = [UNNotificationCategory categoryWithIdentifier:@"hexiaoyang_category_identifier"
                                                                              actions:@[replyAction, viewAction]
                                                                    intentIdentifiers:@[]
                                                                              options:UNNotificationCategoryOptionCustomDismissAction];
    [[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:[NSSet setWithObject:category]];
}

效果:

自定义按压界面参考 customizing-the-appearance-of-notifications?language=objc

3.3 定制化左侧logo(iOS15+)

这个默认是app的icon,那么如果想换,需要额外借助 Communication Notification 以及 Siri Intent的能力。

这里有篇文章写的很细,7448153 不过多赘述

4 Q&A

Q: push通知条的样式能够定制吗?
A:不能定制,最多展示左侧logo、主标题、副标题、和最多4行内容区。右侧可以展示附件的缩略图。
另外可以定制长按Push条后展示的一个自定义的页面,参考3.1节。也可以定制长按后的快捷按钮,参考3.2节

Q: push本身的铃声,会和push来电冲突吗?
A: 如果通知内容中含有 mutable-content:1字段,这意味着端上可以定制化处理,以本场景为例,只需要在推出横幅前,删掉横幅数据中的sound字段:

//清空声音
self.bestAttemptContent.sound = nil;
//推出横幅
self.contentHandler(self.bestAttemptContent);

Q:设置中,推送展示位都关了,还能震动/响铃吗
A: iOS17经测试是不能的,当三个展示位都关闭后,上级菜单就显示通知关了。需要再看看别的系统

Q: 还存在哪些棘手的问题
A: 勿扰模式阻挡不住震动&响铃! 对于推送横幅本身的声音,勿扰模式是可以进行阻挡的,但这个方案中的震动、响铃走的是主线程。且系统没有暴露出api来判断当前设备的模式。看了下微信语音来电,也有这个问题...