iOS-推送消息扩展

知 识 点 / 超 人


目录

  • 背景
  • UNNotificationServiceExtension 与 UNNotificationContentExtension的关系
  • UNNotificationServiceExtension
  • UNNotificationContentExtension
  • 扩展知识点
  • 示例代码

背景

iOS 10之前,iPhone手机中,通知栏仅能展示 标题和内容文本

iOS 10 之前的通知栏

iOS 10 开始,苹果新增了UserNotifications.framework库用于对通知的扩展。通过UNNotificationService 与 UNNotificationContent来进行通知的 拦截 与 通知界面的自定义。让通知栏变得丰富多彩,既可以展示图文内容,也可以展示音视频。使得用户在不打开App的情况下也能进行App内容的交互。

图文推送

长按推送推送后带有图文的自定义推送
长按推送后带有交互的自定义推送

长按推送后的带有聊天回复功能的自定义推送

iOS 12开始,新增了通知栏的分组,默认会根据Bundle Id自动区分不同应用的通知,也可以通过Thread identifier 精细化控制同一个应用里不同类型的通知。
设置App分组

自动:按照Thread identifier进行区分不同的消息,App如果未设置Thread identifier,则按照Bundle Id区分。
按App:按照App的 Bundle Id 区分通知消息
:关闭App的通知分组


UNNotificationServiceExtension 与 UNNotificationContentExtension的关系

UNNotificationServiceExtension负责拦截通知,对通知内容做中间处理,而UNNotificationContentExtension负责自定义通知界面的。
如果只是想对通知内容进行解析或单纯的系统通知中有小图片,那么使用UNNotificationServiceExtension即可
如果想显示用户长按通知自定义后显示的通知界面,则需要使用UNNotificationContentExtension自定义通知界面
一般都是UNNotificationServiceExtension与UNNotificationContentExtension结合使用,由UNNotificationServiceExtension解密通知,将通知内容进行转换,并下载相关的通知资源文件。然后在UNNotificationContentExtension中拿到UNNotificationServiceExtension处理后的通知内容,将内容赋值在界面上进行展示。


UNNotificationServiceExtension

苹果官方关于UNNotificationServiceExtension的说明文档

UNNotificationServiceExtension是用于拦截远程通知的,在手机收到对应App的通知时,会触发UNNotificationServiceExtension,可以在UNNotificationServiceExtension中对通知内容进行修改,收到可以对通知添加附件,例如图片、音频、视频。

1、创建UNNotificationServiceExtension
在工程中的TARGETS中选择加号,然后在 iOS 模板中选择 Notification Service Extension

创建UNNotificationServiceExtension

创建后自动生成的文件

NotificationService是通知拦截响应的类,Info.plist是NotificationsServiceExtension的配置信息。

在NotificationService.m中,自动生成了contentHandlerbestAttemptContent两个属性。didReceiveNotificationRequest:withContentHandlerserviceExtensionTimeWillExpire方法。

bestAttemptContent

//通知消息的内容对象,里面包含了通知相关的所有信息。一般有title、body、subTitle。
//如果是服务端封装的扩展参数,则一般都在userInfo中。
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;

contentHandler

//用于告知系统已经处理完成,可以将通知内容传给App的回调对象。
//该对象需要返回一个UNMutableNotificationContent对象。一般都是返回bestAttemptContent
@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);

didReceiveNotificationRequest:withContentHandler

//可以通过重写此方法来实现自定义推送通知修改。
//如果要使用修改后的通知内容,则需要在该方法中调用contentHandler传递修改后的通知内容。
//如果在服务时间(30秒)到期之前未调用处理程序contentHandler,则将传递未修改的通知。
//@param request 通知内容
//@param contentHandler 处理结果,需要返回一个UNNotificationContent的通知内容
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
    
    //接收回调对象
    self.contentHandler = contentHandler;
    //copy通知内容
    self.bestAttemptContent = [request.content mutableCopy];
    //回调通知结果
    self.contentHandler(self.bestAttemptContent);
}

serviceExtensionTimeWillExpire

//当didReceiveNotificationRequest的方法执行超过30秒未调用contentHandler时
//系统会自动调用serviceExtensionTimeWillExpire方法,给我们最后一次弥补处理的机会
//可以在serviceExtensionTimeWillExpire方法中设置didReceiveNotificationRequest方法中未完成数据的默认值
- (void)serviceExtensionTimeWillExpire {
    self.contentHandler(self.bestAttemptContent);
}

UNNotificationRequest
远程通知发送给App的通知请求,其中包括通知的内容和交互的触发条件。

@interface UNNotificationRequest : NSObject <NSCopying, NSSecureCoding>

// 该属性为通知请求的唯一标识符。可以用它来替换或删除挂起的通知请求或已传递的通知。
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *identifier;

// 该属性是用于显示的通知内容
@property (NS_NONATOMIC_IOSONLY, readonly, copy) UNNotificationContent *content;

// 通知交互的触发器
@property (NS_NONATOMIC_IOSONLY, readonly, copy, nullable) UNNotificationTrigger *trigger;

UNNotificationTrigger
抽象类,用于表示触发通知传递的事件。不能直接创建此类的实例。具体的触发子类看下面的类

//抽象类,用于表示触发通知传递的事件。不能直接创建此类的实例。具体的触发子类看下面的类
@interface UNNotificationTrigger : NSObject <NSCopying, NSSecureCoding>

/// 通知的触发是否循环执行
@property (NS_NONATOMIC_IOSONLY, readonly) BOOL repeats;

- (instancetype)init NS_UNAVAILABLE;

@end

//远程推送
// 苹果远程推送服务时的推送对象. 
API_AVAILABLE(macos(10.14), ios(10.0), watchos(3.0), tvos(10.0))
@interface UNPushNotificationTrigger : UNNotificationTrigger

@end

// 本地推送
// 基于时间间隔去触发的通知.
API_AVAILABLE(macos(10.14), ios(10.0), watchos(3.0), tvos(10.0))
@interface UNTimeIntervalNotificationTrigger : UNNotificationTrigger

/// 通知延迟触发的时间
@property (NS_NONATOMIC_IOSONLY, readonly) NSTimeInterval timeInterval;


/// 创建延迟触发的通知
/// @param timeInterval 延迟的时间
/// @param repeats 是否重复执行
+ (instancetype)triggerWithTimeInterval:(NSTimeInterval)timeInterval repeats:(BOOL)repeats;

/// 获取通知下一次触发的时间
- (nullable NSDate *)nextTriggerDate;

@end

// 本地推送
// 根据日期和时间触发的通知
API_AVAILABLE(macos(10.14), ios(10.0), watchos(3.0), tvos(10.0))
@interface UNCalendarNotificationTrigger : UNNotificationTrigger

/// 通知触发的时间
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSDateComponents *dateComponents;

// The next date is calculated using matching date components.


/// 创建基于时间触发的通知
/// @param dateComponents 日期
/// @param repeats 是否重复执行
+ (instancetype)triggerWithDateMatchingComponents:(NSDateComponents *)dateComponents repeats:(BOOL)repeats;

/// 获取通知下一次触发的时间
- (nullable NSDate *)nextTriggerDate;

@end

// 根据用户手机定位,当进入某个区域或者离开某个区域时触发通知
API_AVAILABLE(ios(10.0), watchos(3.0)) API_UNAVAILABLE(macos, tvos, macCatalyst)
@interface UNLocationNotificationTrigger : UNNotificationTrigger

/// 触发通知的地理位置信息
@property (NS_NONATOMIC_IOSONLY, readonly, copy) CLRegion *region;

/// 创建基于地理位置触发的通知
/// @param region 地理位置
/// @param repeats 是否重复触发
+ (instancetype)triggerWithRegion:(CLRegion *)region repeats:(BOOL)repeats API_AVAILABLE(watchos(8.0));

@end

UNNotificationContent
通知的具体内容,包括通知类型、自定义参数、附件信息等。不能直接创建该类,如果需要创建自定义的通知内容,应该创建UNMutableNotificationContent,并配置内容

// 附件数组,必须是UNNotificationAttachment对象
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSArray <UNNotificationAttachment *> *attachments API_UNAVAILABLE(tvos);

// 应用的认证号码,图标右上角的数字
@property (NS_NONATOMIC_IOSONLY, readonly, copy, nullable) NSNumber *badge;

// 通知的内容
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *body API_UNAVAILABLE(tvos);

// 已注册的UNNotificationCategory的标识符,用于确定显示哪一个自定义通知的UI。
//该标识是在UNNotificationContentExtension的info.plist中注册的
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *categoryIdentifier API_UNAVAILABLE(tvos);

// 通知栏中应用程序显示App图片,在App中通设置该属性来修改通知栏中的App Icon
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *launchImageName API_UNAVAILABLE(macos, tvos);

// 通知将播放的音频,在App中通过设置该属性来修改App的通知声音
@property (NS_NONATOMIC_IOSONLY, readonly, copy, nullable) UNNotificationSound *sound API_UNAVAILABLE(tvos);

// 通知的副标题
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *subtitle API_UNAVAILABLE(tvos);

// 与当前通知请求相关的线程或对话的唯一标识符。它是通知分组的标识
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *threadIdentifier API_UNAVAILABLE(tvos);

// 通知标题
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *title API_UNAVAILABLE(tvos);

// 通知的详细信息
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSDictionary *userInfo API_UNAVAILABLE(tvos);

// 通知摘要参数
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *summaryArgument API_DEPRECATED("summaryArgument is ignored", ios(12.0, 15.0), watchos(5.0, 8.0), tvos(12.0, 15.0));

// 摘要的数量
@property (NS_NONATOMIC_IOSONLY, readonly, assign) NSUInteger summaryArgumentCount API_DEPRECATED("summaryArgumentCount is ignored", ios(12.0, 15.0), watchos(5.0, 8.0), tvos(12.0, 15.0));

// 点击自定义通知是激活的场景唯一标识,默认为空
@property (NS_NONATOMIC_IOSONLY, readonly, copy, nullable) NSString *targetContentIdentifier API_AVAILABLE(ios(13.0));

// 通知的级别
//UNNotificationInterruptionLevelPassive,添加到通知列表中;不会点亮屏幕或播放声音
//UNNotificationInterruptionLevelActive,立即执行,点亮屏幕并可能播放声音
//UNNotificationInterruptionLevelTimeSensitive,立即执行,点亮屏幕并可能播放声音;在请勿打扰期间都会出现
// Presented immediately; Lights up screen and plays sound; Always presented during Do Not Disturb; Bypasses mute switch; Includes default critical alert sound if no sound provided
//立即执行,点亮屏幕并播放声音,如果处于“请勿打扰”状态会一直显示,不收静音开关影响,如果没有设置附件声音,则会使用默认的严重警报声音
//UNNotificationInterruptionLevelCritical,
@property (NS_NONATOMIC_IOSONLY, readonly, assign) UNNotificationInterruptionLevel interruptionLevel API_AVAILABLE(macos(12.0), ios(15.0), watchos(8.0), tvos(15.0));

// 关联系数,决定了通知在应用程序通知中的排序。其范围在0.0f和1.0f之间。
@property (NS_NONATOMIC_IOSONLY, readonly, assign) double relevanceScore API_AVAILABLE(macos(12.0), ios(15.0), watchos(8.0), tvos(15.0));

UNNotificationSound
通知发出时,通知的声音。如果想手机收到通知时播放特定声音,需要创建UNNotificationSound对象来设置特定的音频文件。

UNNotificationSound对象仅读取以下位置文件:
应用程序容器目录的/Library/Sounds目录。
应用程序的共享组容器目录之一的/Library/Sounds目录。

播放自定义声音,必须采用以下音频数据格式之一:Linear PCM、MA4 (IMA/ADPCM)、µLaw、aLaw
可以将音频数据打包为aiff、wav或caf文件。声音文件的长度必须小于30秒。如果声音文件超过30秒,系统将播放默认声音
可以使用afconvert命令行工具转换声音。例如,将系统声音转换为Submarine.aiff。aiff到IMA4音频在CAF文件中,在终端中使用以下命令:
afconvert /System/Library/Sounds/Submarine.aiff ~/Desktop/sub.caf -d ima4 -f caff -

//通知发出时,通知的声音。如果想手机收到通知时播放特定声音,需要创建UNNotificationSound对象来设置特定的音频文件。
//UNNotificationSound对象仅在以下位置显示:
//应用程序容器目录的/Library/Sounds目录。
//应用程序的共享组容器目录之一的/Library/Sounds目录。
//播放自定义声音,必须采用以下音频数据格式之一:Linear PCM、MA4 (IMA/ADPCM)、µLaw、aLaw
//可以将音频数据打包为aiff、wav或caf文件。声音文件的长度必须小于30秒。如果声音文件超过30秒,系统将播放默认声音
//可以使用afconvert命令行工具转换声音。例如,将系统声音转换为Submarine.aiff。aiff到IMA4音频在CAF文件中,在终端中使用以下命令:
//afconvert /System/Library/Sounds/Submarine.aiff ~/Desktop/sub.caf -d ima4 -f caff -
@interface UNNotificationSound : NSObject <NSCopying, NSSecureCoding>

// 默认的通知声音
@property(class, NS_NONATOMIC_IOSONLY, copy, readonly) UNNotificationSound *defaultSound;

// 用于来电通知的默认声音。播放设置中指定的铃声和触觉,持续30秒。
// 父UNNotificationContent对象必须通过-[UnnotificationContentByUpdateingWithProvider:error:]在通知服务扩展中创建
// 其中提供程序是InstartCallContent,其destinationType为INCallDestinationTypeNormal。
// 如果此用例可用,请使用CallKit而不是UserNotifications。
@property(class, NS_NONATOMIC_IOSONLY, copy, readonly) UNNotificationSound *defaultRingtoneSound API_AVAILABLE(ios(15.2)) API_UNAVAILABLE(macos, watchos, tvos, macCatalyst);

// 用于关键警报的默认声音。严重警报将绕过静音开关,且不会干扰
@property(class, NS_NONATOMIC_IOSONLY, copy, readonly) UNNotificationSound *defaultCriticalSound API_AVAILABLE(ios(12.0), watchos(5.0)) API_UNAVAILABLE(tvos);

// The default sound used for critical alerts with a custom audio volume level. Critical alerts will bypass the mute switch and Do Not Disturb. The audio volume is expected to be between 0.0f and 1.0f.

/// 严重警报将绕过静音开关,且不会干扰。
/// @param volume 音频音量预计在0.0f到1.0f之间。
+ (instancetype)defaultCriticalSoundWithAudioVolume:(float)volume API_AVAILABLE(ios(12.0), watchos(5.0)) API_UNAVAILABLE(tvos);

// 为通知播放的声音文件。声音必须位于应用程序数据容器的Library/Sounds文件夹或应用程序组数据容器的Library/Sounds文件夹中。
// 如果在容器中找不到该文件,系统将在应用程序包中查找。
+ (instancetype)soundNamed:(UNNotificationSoundName)name API_UNAVAILABLE(watchos, tvos);

+ (instancetype)ringtoneSoundNamed:(UNNotificationSoundName)name API_AVAILABLE(ios(15.2)) API_UNAVAILABLE(macos, watchos, tvos, macCatalyst);

+ (instancetype)criticalSoundNamed:(UNNotificationSoundName)name API_AVAILABLE(ios(12.0)) API_UNAVAILABLE(watchos, tvos);

+ (instancetype)criticalSoundNamed:(UNNotificationSoundName)name withAudioVolume:(float)volume API_AVAILABLE(ios(12.0)) API_UNAVAILABLE(watchos, tvos);

- (instancetype)init NS_UNAVAILABLE;

@end

UNNotificationAttachment
通知的附件,可以存放音频、图像或视频内容,创建UNNotificationAttachment对象时,指定的文件必须在磁盘上,并且文件格式必须是受支持的类型之一。不然会创建失败,返回nil。
系统会在显示相关通知之前验证附件。如果是本地通知,请求附加的文件已损坏、无效或文件类型不受支持,则系统不会执行请求。如果是远程通知,系统会在通知服务应用程序扩展完成后验证附件。验证后,系统会将附件移动到附件数据存储中,以便适当的流程可以访问这些文件。系统会复制应用包中的附件。

附件类型 支持的文件类型 文件的最大容量
Audio kUTTypeAudioInterchangeFileFormat
kUTTypeWaveformAudio
kUTTypeMP3
kUTTypeMPEG4Audio
5MB
Image kUTTypeJPEG
kUTTypeGIF
kUTTypePNG
10MB
Movie kUTTypeMPEG
kUTTypeMPEG2Video
kUTTypeMPEG4
kUTTypeAVIMovie
50MB
@interface UNNotificationAttachment : NSObject <NSCopying, NSSecureCoding>
// 附件的唯一标识
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *identifier;

// 附件文件的url
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSURL *URL;

// 附件的类型
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *type;

// 创建一个附件,附件的url必须是有效的文件路径,否则会返回nil。
/// @param identifier 附件唯一标识
/// @param URL 附件url
/// @param options 附件类型详情设置
/// UNNotificationAttachmentOptionsTypeHintKey:附件类型,传入一个string值,如果未设置,则会根据文件的扩展名设置文件类型
/// UNNotificationAttachmentOptionsThumbnailHiddenKey:是否隐藏此附件的缩略图,值是BOOL类型(NSNumber),默认为NO
/// UNNotificationAttachmentOptionsThumbnailClippingRectKey:指定用于附件缩略图的标准化剪裁矩形
/// (上)该值必须是使用CGRectCreateDictionaryRepresentation编码的CGRect
/// UNNotificationAttachmentOptionsThumbnailTimeKey:指定要用作缩略图的动画图像帧数或电影时间。
/// (上)该值动画图像帧编号必须是NSNumber类型
/// 该值电影时间必须是以秒为单位的NSNumber,或者是使用CMTimeCopyAsDictionary编码的CMTime。
/// @param error 创建附件的报错信息
+ (nullable instancetype)attachmentWithIdentifier:(NSString *)identifier URL:(NSURL *)URL options:(nullable NSDictionary *)options error:(NSError *__nullable *__nullable)error;

UNNotificationContentExtension

该扩展是为应用创建自定义的通知界面的。可以在UIViewController里设置界面,在didReceiveNotification方法中获得通知数据赋值界面。

创建NotificationContentExtension

UNNotificationContentExtension 的info.plist配置参数说明

key value
UNNotificationExtensionCategory(必填) string值或Array值,自定义通知界面的标识符,系统会根据通知中category 的名称自动与该值匹配,匹配后会使用该自定义界面。设置为string则表示只有一个,设置为Array则表示有多个
UNNotificationExtensionInitialContentSizeRatio(必填) 浮点值,表示视图控制器视图的初始大小,表示为其高度与宽度的比率。加载自定义通知视图时,系统使用此值设置视图控制器的初始大小,以宽度为基数。例如,值为0.5时,表示 高度 = 宽度 * 0.5。值为2,表示 高度 = 宽度 * 2
UNNotificationExtensionDefaultContentHidden BOOL值,表示打开自定义通知界面的时候,是否隐藏默认通知的导航标题和内容,设置为YES时只显示自定义的内容,默认为NO
UNNotificationExtensionOverridesDefaultTitle BOOL值,设置为YES时,系统将使用视图控制器的title属性作为通知的标题。设置为NO时,系统会将通知的标题设置为应用程序的名称。默认为NO
NSExtensionMainStoryboard 自定义界面对应的SB文件名
NSExtensionPointIdentifier 是扩展的唯一标识,设置为com.apple.usernotifications.content-extension后会被系统识别为苹果通知的Content的扩展
UNNotificationExtensionUserInteractionEnabledYES BOOL类型,设置为YES后,自定义界面运行有交互行为,设置为NO的话点击自定义界面会直接打开App

UNNotificationContentExtension

@protocol UNNotificationContentExtension <NSObject>

//接收即将显示的通知,在这里获取通知内容,然后根据通知信息处理显示通知界面
- (void)didReceiveNotification:(UNNotification *)notification;

@optional

/// 如果设置Action并实现了该方法,当用户点击了按钮的时候就会调用该方法,可以在该方法中处理点击事件
/// @param response 触发的事件响应体
/// @param completion 事件的处理结果,返回指示对通知的首选响应的常量。UNNotificationContentExtensionResponseOption枚举类型
/// UNNotificationContentExtensionResponseOptionDoNotDismiss:不要关闭通知界面
/// UNNotificationContentExtensionResponseOptionDismiss:关闭通知界面
/// UNNotificationContentExtensionResponseOptionDismissAndForwardAction:关闭通知界面,并将通知内容传递给App
- (void)didReceiveNotificationResponse:(UNNotificationResponse *)response completionHandler:(void (^)(UNNotificationContentExtensionResponseOption option))completion;

// Implementing this method and returning a button type other that "None" will
// make the notification attempt to draw a play/pause button correctly styled
// for that type.
//媒体播放按钮的类型
//UNNotificationContentExtensionMediaPlayPauseButtonTypeNone:没有播放按钮
//UNNotificationContentExtensionMediaPlayPauseButtonTypeDefault:播放和暂停的按钮
//部分透明的播放/暂停按钮,位于内容上方。该属性会导致mediaPlayPauseButtonTintColor属性无效。
//UNNotificationContentExtensionMediaPlayPauseButtonTypeOverlay
@property (nonatomic, readonly, assign) UNNotificationContentExtensionMediaPlayPauseButtonType mediaPlayPauseButtonType;

//设置媒体播放按钮的Frame,相对于媒体的尺寸
@property (nonatomic, readonly, assign) CGRect mediaPlayPauseButtonFrame;

// 设置媒体按钮的颜色
#if TARGET_OS_OSX
@property (nonatomic, readonly, copy) NSColor *mediaPlayPauseButtonTintColor;
#else
@property (nonatomic, readonly, copy) UIColor *mediaPlayPauseButtonTintColor;
#endif

// 播放
- (void)mediaPlay;
// 暂停
- (void)mediaPause;

@end

注意:因为UNNotificationServiceExtension与UNNotificationContentExtension是两个不同的target,沙盒路径不同。一般都是在UNNotificationServiceExtension中下载附件资源并保存在UNNotificationServiceExtension的沙盒路径,然后在UNNotificationContentExtension中接收附件的路径。因此要访问UNNotificationServiceExtension的路径需要使用startAccessingSecurityScopedResource与stopAccessingSecurityScopedResource

/* 例 */
- (void)didReceiveNotification:(UNNotification *)notification {
    
    if (notification.request.content.attachments && notification.request.content.attachments.count > 0) {
        UNNotificationAttachment *attachment = [notification.request.content.attachments firstObject];
        
        //通过使用安全作用域解析创建的attachment数据而创建的NSURL,使url引用的资源可供进程访问。
        //startAccessingSecurityScopedResource与stopAccessingSecurityScopedResource需成对出现
        //当不再需要访问此资源时,客户端必须调用stopAccessingSecurityScopedResource
        if ([attachment.URL startAccessingSecurityScopedResource]) {
            NSData *imageData = [NSData dataWithContentsOfURL:attachment.URL];
            self.imageView.image = [UIImage imageWithData:imageData];
            [attachment.URL stopAccessingSecurityScopedResource];
        }
    }
}

UNNotificationAction

使用UNNotificationAction对象,可以定义在响应已发送通知时可以执行的操作。例如,会议App可能会定义会议邀请的接受或拒绝的操作,就可以用UNNotificationAction来设置接受和拒绝的按钮,直接在通知界面完成操作,而不用打开App进行操作,UNNotificationAction最多设置4个。

@interface UNNotificationAction : NSObject <NSCopying, NSSecureCoding>

// 事件的唯一标识
@property (NS_NONATOMIC_IOSONLY, copy, readonly) NSString *identifier;

// 事件的标题文本
@property (NS_NONATOMIC_IOSONLY, copy, readonly) NSString *title;

// 操作的配置
//UNNotificationActionOptionAuthenticationRequired:执行此操作前是否需要解锁
//UNNotificationActionOptionDestructive:该行为是否应被视为具有破坏性,文本会是红色
//UNNotificationActionOptionForeground:此操作是否应导致应用程序在前台启动
@property (NS_NONATOMIC_IOSONLY, readonly) UNNotificationActionOptions options;

// 事件的icon图片对象
@property (NS_NONATOMIC_IOSONLY, readonly, copy, nullable) UNNotificationActionIcon *icon API_AVAILABLE(macos(12.0), ios(15.0), watchos(8.0), tvos(15.0));

// 创建事件

+ (instancetype)actionWithIdentifier:(NSString *)identifier title:(NSString *)title options:(UNNotificationActionOptions)options;

+ (instancetype)actionWithIdentifier:(NSString *)identifier title:(NSString *)title options:(UNNotificationActionOptions)options icon:(nullable UNNotificationActionIcon *)icon API_AVAILABLE(macos(12.0), ios(15.0), watchos(8.0), tvos(15.0));


- (instancetype)init NS_UNAVAILABLE;

@end

UNNotificationActionIcon

//事件的关联图片
@interface UNNotificationActionIcon : NSObject <NSCopying, NSSecureCoding>

//使用应用里的图片作为事件的icon,图片需要放在asset中
+ (instancetype)iconWithTemplateImageName:(NSString *)templateImageName;
//使用系统图片作为icon
+ (instancetype)iconWithSystemImageName:(NSString *)systemImageName;

- (instancetype)init NS_UNAVAILABLE;

UNTextInputNotificationAction

//文本输入框
@interface UNTextInputNotificationAction : UNNotificationAction

// 事件中显示的文本输入按钮标题文本
@property (NS_NONATOMIC_IOSONLY, copy, readonly) NSString *textInputButtonTitle;

// 事件的文本输入框中的提示文本
@property (NS_NONATOMIC_IOSONLY, copy, readonly) NSString *textInputPlaceholder;

//创建文本输入框
+ (instancetype)actionWithIdentifier:(NSString *)identifier title:(NSString *)title options:(UNNotificationActionOptions)options textInputButtonTitle:(NSString *)textInputButtonTitle textInputPlaceholder:(NSString *)textInputPlaceholder;

+ (instancetype)actionWithIdentifier:(NSString *)identifier title:(NSString *)title options:(UNNotificationActionOptions)options icon:(nullable UNNotificationActionIcon *)icon textInputButtonTitle:(NSString *)textInputButtonTitle textInputPlaceholder:(NSString *)textInputPlaceholder API_AVAILABLE(macos(12.0), ios(15.0), watchos(8.0), tvos(15.0));
@end

UNNotificationCategory

@interface UNNotificationCategory : NSObject <NSCopying, NSSecureCoding>

// 当前category的唯一标识符。当UNNotificationCategory的标识符与UNNotificationRequest的categoryIdentifier匹配时,
//UNNotificationCategory的操作将显示在通知上。
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *identifier;

// 具体的操作数组,按数组里Action顺序显示操作
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSArray<UNNotificationAction *> *actions;

//支持的intents的类型
//详情可以查看<Intents/INIntentIdentifiers.h>
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSArray<NSString *> *intentIdentifiers;
//分配的配置,枚举
// 将关闭操作发送给通知代理
//UNNotificationCategoryOptionCustomDismissAction = (1 << 0),
// CarPlay是否支持此类通知,没用过
//UNNotificationCategoryOptionAllowInCarPlay API_UNAVAILABLE(macos) = (1 << 1),
// 如果用户已关闭预览,应显示标题
//UNNotificationCategoryOptionHiddenPreviewsShowTitle API_AVAILABLE(macos(10.14), ios(11.0)) API_UNAVAILABLE(watchos, tvos) = (1 << 2),
// 如果用户已关闭预览,应显示字幕
//UNNotificationCategoryOptionHiddenPreviewsShowSubtitle API_AVAILABLE(macos(10.14), ios(11.0)) API_UNAVAILABLE(watchos, tvos) = (1 << 3),
// 允许当前通知发布通知
//UNNotificationCategoryOptionAllowAnnouncement API_DEPRECATED("Announcement option is ignored", ios(13.0, 15.0), watchos(6.0, 7.0)) API_UNAVAILABLE(macos, tvos) = (1 << 4),
@property (NS_NONATOMIC_IOSONLY, readonly) UNNotificationCategoryOptions options;

// 当预览被隐藏时,会使用该字符串内容替换通知body进行显示
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *hiddenPreviewsBodyPlaceholder API_AVAILABLE(ios(11.0)) API_UNAVAILABLE(watchos, tvos);

///该属性是用于描述,来自此category的通知分组,它应该包含描述性文本和格式参数,这些参数将被替换为信息
///来自此分组的通知。将会把参数被替换为数字,以及通过在每个分组通知中加入参数而创建的列表。
///例如:“%u来自%@的新邮件”。
///参数列表是可选的,“%u条新消息”也被接受。
///格式化中的 %u和 %@,分别对应着NotificationService中的summaryArgumentCount与summaryArgument。
//summaryArgumentCount:该类型的通知摘要条数,一般由系统管理
//summaryArgument:通知摘要文本
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *categorySummaryFormat API_AVAILABLE(ios(12.0)) API_UNAVAILABLE(watchos, tvos);

// 创建通知分类
+ (instancetype)categoryWithIdentifier:(NSString *)identifier
                               actions:(NSArray<UNNotificationAction *> *)actions
                     intentIdentifiers:(NSArray<NSString *> *)intentIdentifiers
                               options:(UNNotificationCategoryOptions)options;

+ (instancetype)categoryWithIdentifier:(NSString *)identifier
                               actions:(NSArray<UNNotificationAction *> *)actions
                     intentIdentifiers:(NSArray<NSString *> *)intentIdentifiers
         hiddenPreviewsBodyPlaceholder:(NSString *)hiddenPreviewsBodyPlaceholder
                               options:(UNNotificationCategoryOptions)options API_AVAILABLE(ios(11.0)) API_UNAVAILABLE(watchos, tvos);

+ (instancetype)categoryWithIdentifier:(NSString *)identifier
                               actions:(NSArray<UNNotificationAction *> *)actions
                     intentIdentifiers:(NSArray<NSString *> *)intentIdentifiers
         hiddenPreviewsBodyPlaceholder:(nullable NSString *)hiddenPreviewsBodyPlaceholder
                 categorySummaryFormat:(nullable NSString *)categorySummaryFormat
                               options:(UNNotificationCategoryOptions)options API_AVAILABLE(ios(12.0)) API_UNAVAILABLE(watchos, tvos);

- (instancetype)init NS_UNAVAILABLE;

@end

扩展知识点

UNUserNotificationCenter

@interface UNUserNotificationCenter : NSObject

// 当前应用的通知代理对象
@property (NS_NONATOMIC_IOSONLY, nullable, weak) id <UNUserNotificationCenterDelegate> delegate;

// 当前设备是否支持内容扩展,YES表示支持
@property (NS_NONATOMIC_IOSONLY, readonly) BOOL supportsContentExtensions;

// 当前应用的通知
+ (UNUserNotificationCenter *)currentNotificationCenter;

- (instancetype)init NS_UNAVAILABLE;

// 应用需要用户授权才能通过本地和远程通知使用UNUserNotificationCenter通知用户
/// @param options 该值是用于想用户请求交互授权的配置
/// UNAuthorizationOptionBadge:授权更新App icon的能力,加角标
/// UNAuthorizationOptionSound:授权播放声音的能力
/// UNAuthorizationOptionAlert:授权显示报警的能力
/// UNAuthorizationOptionCarPlay:授权能在CarPlay中显示通知的能力
/// UNAuthorizationOptionCriticalAlert:授权能够播放警报声音的能力
/// UNAuthorizationOptionProvidesAppNotificationSettings:授权系统显示App通知设置按钮的能力
/// UNAuthorizationOptionProvisional:授权能够将无中断通知临时发布到通知中心的能力
/// UNAuthorizationOptionAnnouncement:不建议使用
/// UNAuthorizationOptionTimeSensitive :不建议使用
/// @param completionHandler granted 表示用户是否授权,error表示授权是发生的错误
- (void)requestAuthorizationWithOptions:(UNAuthorizationOptions)options completionHandler:(void (^)(BOOL granted, NSError *__nullable error))completionHandler;

// 设置当前通知的类别,用于显示哪些操作
- (void)setNotificationCategories:(NSSet<UNNotificationCategory *> *)categories API_UNAVAILABLE(tvos);
//获取通知类别信息
- (void)getNotificationCategoriesWithCompletionHandler:(void(^)(NSSet<UNNotificationCategory *> *categories))completionHandler API_UNAVAILABLE(tvos);
// 获取App通知设置
- (void)getNotificationSettingsWithCompletionHandler:(void(^)(UNNotificationSettings *settings))completionHandler;

// 添加一个通知请求对象,将用相同的标识符的通知请求替换未当前添加的通知请求。
// 如果标识符为现有已送达的通知,通知请求将针对新通知请求发出警报,并在触发时替换现有已送达通知
// app中未送达通知请求的数量受系统限制,具体的限制还未测试过
- (void)addNotificationRequest:(UNNotificationRequest *)request withCompletionHandler:(nullable void(^)(NSError *__nullable error))completionHandler;

// 获取未送达的通知请求
- (void)getPendingNotificationRequestsWithCompletionHandler:(void(^)(NSArray<UNNotificationRequest *> *requests))completionHandler;

// 根据通知请求的唯一标识移除未送达的通知
- (void)removePendingNotificationRequestsWithIdentifiers:(NSArray<NSString *> *)identifiers;
// 移除所有未送达的通知
- (void)removeAllPendingNotificationRequests;

// 获取已送达并保留在通知中心的通知
- (void)getDeliveredNotificationsWithCompletionHandler:(void(^)(NSArray<UNNotification *> *notifications))completionHandler API_UNAVAILABLE(tvos);
// 根据通知的唯一标识移除已送达的通知
- (void)removeDeliveredNotificationsWithIdentifiers:(NSArray<NSString *> *)identifiers API_UNAVAILABLE(tvos);
// 移除所有已送达的通知
- (void)removeAllDeliveredNotifications API_UNAVAILABLE(tvos);

@end

//当前app的通知代理
@protocol UNUserNotificationCenterDelegate <NSObject>

@optional

/// 只有App处于前台的时候才会调用该方法,如果为实现该方法或者为及时执行completionHandler,则不会显示该条通知。
/// App可以通过返回的options决定通知显示的方式。
/// @param center 当前App的通知管理对象
/// @param notification 通知
/// @param completionHandler options:指示如何在前台应用程序中显示通知的常量
/// UNNotificationPresentationOptionBadge:将通知的角标值应用于App的icon
/// UNNotificationPresentationOptionSound:播放与通知相关的声音
/// UNNotificationPresentationOptionAlert:不建议使用
/// UNNotificationPresentationOptionList:在通知中心显示通知
/// UNNotificationPresentationOptionBanner:以横幅形式显示通知
- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler API_AVAILABLE(macos(10.14), ios(10.0), watchos(3.0), tvos(10.0));

// 当用户通过打开App、拒绝通知或选择不通知的操作来响应通知时
// 将对委托调用该方法。通知的代理必须在didFinishLaunchingWithOptions:之前设置
- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void(^)(void))completionHandler API_AVAILABLE(macos(10.14), ios(10.0), watchos(3.0)) API_UNAVAILABLE(tvos);

// 当App响应用户查看应用内通知设置的请求而启动时,代理将调用该方法
// 添加未授权选项将AppNotificationSettings作为requestAuthorizationWithOptions:completionHandler中的选项提供
// 将按钮添加到“设置”中的“内联通知设置”视图和“通知设置”视图中。从设置打开时,通知将为零。
- (void)userNotificationCenter:(UNUserNotificationCenter *)center openSettingsForNotification:(nullable UNNotification *)notification API_AVAILABLE(macos(10.14), ios(12.0)) API_UNAVAILABLE(watchos, tvos);

@end

通知消息体说明

{
    aps =     {//消息推送的主题内容
        alert =         {//推送的弹窗显示内容
            body = "\U5b87\U4f73\U7684\U5185\U5bb9”;//显示的内容文本
            title = "\U5b87\U4f73\U7684\U6807\U9898”;//显示的标题文本
        };
        sound = default;//通知的声音
    category = myImageNotificationCategory;//通知对应的自定义Content标识
    thread-id = 10923;//通知分组的id
    mutable-content = 1;//通知是否走自定义内容的标记,只有该值设置为1的时候才会走扩展内容,否则走系统通知
    };
}

后续会补充一下 如何在Service和Content里引用其他工程资源


示例代码

#import "NotificationService.h"
//语音播放需要
#import <AVFoundation/AVFoundation.h>
@interface NotificationService () <AVSpeechSynthesizerDelegate>

//用于告知系统已经处理完成,可以将通知内容传给App的回调对象。
//该对象需要返回一个UNMutableNotificationContent对象。一般都是返回bestAttemptContent
@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);

//通知消息的内容对象,里面包含了通知相关的所有信息。一般有title、body、subTitle。
//如果是服务端封装的扩展参数,则一般都在userInfo中。
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;

@property (nonatomic, strong) AVSpeechSynthesisVoice *synthesisVoice;

@property (nonatomic, strong) AVSpeechSynthesizer *synthesizer;

//资源文件
@property (nonatomic, strong) NSMutableArray<UNNotificationAttachment *> *attachments;

@end

@implementation NotificationService

/// 可以通过重写此方法来实现自定义推送通知修改。
///如果要使用修改后的通知内容,则需要在该方法中调用contentHandler传递修改后的通知内容。如果在服务时间(30秒)到期之前未调用处理程序contentHandler,则将传递未修改的通知。
/// @param request 通知内容
/// @param contentHandler 处理结果,需要返回一个UNNotificationContent的通知内容
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
    //接收回调对象
    self.contentHandler = contentHandler;
    //将收到的通知内容 copy并赋值到对象属性中,向下传递
    self.bestAttemptContent = [request.content mutableCopy];
    //获取通知信息
    NSDictionary *userInfo = request.content.userInfo;
    if (userInfo) {
        //获取通知中aps信息
        NSDictionary *aps = [userInfo objectForKey:@"aps"];
        NSDictionary *params = [userInfo objectForKey:@"params"];
        if (aps && params) {
            NSString *type = [params objectForKey:@"myNotificationType"];
            if (type) {
                BOOL isActionContentHandler = YES;
                if ([type isEqualToString:@"HYJFileTypeImage"]) {//下载图片
                    [self downFileWithUrl:[aps objectForKey:@"imageUrl"] withFileType:@"HYJFileTypeImage"];
                } else if ([type isEqualToString:@"HYJFileTypeSound"]) {//下载音频文件
                    [self downFileWithUrl:[aps objectForKey:@"imageUrl"] withFileType:@"HYJFileTypeImage"];
                    [self downFileWithUrl:[params objectForKey:@"soundUrl"] withFileType:@"HYJFileTypeSound"];
                } else if ([type isEqualToString:@"HYJFileTypeVideo"]) {//下载视频
                    [self downFileWithUrl:[params objectForKey:@"videoUrl"] withFileType:@"HYJFileTypeVideo"];
                } else if ([type isEqualToString:@"pay"]) {//支付播报
                    if (@available(iOS 12.1,*)) {
                        //背景:12.1以后苹果不允许在Service中合成语音或文字转语音
                        //方案1,使用VOIP,唤醒App,由App完成语音的播报
                        //方案2,收到远程通知后,循环发送本地通知,通知中播放本地拆分开的音频文件,这样可以减少音频文件的数量
                        //方案3,本地预置大量的音频文件,例如:“支付宝收款100元.mp3”。包的体积会很大
                        //方案4,服务端生成tts文件,客户端在Service里下载,然后设置通知的声音为tts文件 (目前采用的方案)
                        NSURL *saveUrl = [self downFile:[params objectForKey:@"soundUrl"] withFileType:@"HYJFileTypeSound"];
                        UNNotificationSound *sound = [UNNotificationSound soundNamed:saveUrl.absoluteString];
                        self.bestAttemptContent.sound = sound;
                    } else {
                        isActionContentHandler = NO;
                        [self playPaySound:[params objectForKey:@"pay"] isPayments:NO];
                    }
                    
                } else if ([type isEqualToString:@"payments"]) {//收款播报
                    isActionContentHandler = NO;
                    [self playPaySound:[params objectForKey:@"payments"] isPayments:NO];
                }
                if (self.attachments.count > 0) {
                    self.bestAttemptContent.attachments = self.attachments;
                }
                if (isActionContentHandler) {
                    self.contentHandler(self.bestAttemptContent);
                }
            } else {
                self.contentHandler(self.bestAttemptContent);
            }
        } else {
            self.contentHandler(self.bestAttemptContent);
        }
    } else {
        self.contentHandler(self.bestAttemptContent);
    }
}


//当didReceiveNotificationRequest的方法执行超过30秒未调用contentHandler时
//系统会自动调用serviceExtensionTimeWillExpire方法,给我们最后一次弥补处理的机会
//可以在serviceExtensionTimeWillExpire方法中设置didReceiveNotificationRequest方法中未完成数据的默认值
- (void)serviceExtensionTimeWillExpire {
    self.contentHandler(self.bestAttemptContent);
}

#pragma mark - Private Method


/// 下载文件
/// @param urlString 文件的url
/// @param type 文件的类型
- (void)downFileWithUrl:(NSString *)urlString withFileType:(NSString *)type
{
    if (!urlString || urlString.length <= 0) {
        return;
    }
    
    //传给自定义通知栏的URL
    NSURL *saveUrl = [self downFile:urlString withFileType:type];
    if (!saveUrl) {
        return;
    }
        
    UNNotificationAttachment *attachment;
    if ([type isEqualToString:@"HYJFileTypeVideo"]) {
        NSDictionary *options = @{@"UNNotificationAttachmentOptionsTypeHintKey":@"kUTTypeMPEG",@"UNNotificationAttachmentOptionsThumbnailHiddenKey":[NSNumber numberWithBool:NO],@"UNNotificationAttachmentOptionsThumbnailTimeKey":[NSNumber numberWithInt:2]};
        attachment = [UNNotificationAttachment attachmentWithIdentifier:@"attachment" URL:saveUrl options:options error:nil];
    } else if ([type isEqualToString:@"HYJFileTypeSound"]) {
        NSDictionary *options = @{@"UNNotificationAttachmentOptionsTypeHintKey":@"kUTTypeMP3"};
        attachment = [UNNotificationAttachment attachmentWithIdentifier:@"attachment" URL:saveUrl options:options error:nil];
    } else {//暂时未考虑动图
        attachment = [UNNotificationAttachment attachmentWithIdentifier:@"attachment" URL:saveUrl options:nil error:nil];
    }

    if (attachment) {
        [self.attachments addObject:attachment];
    }
//    });
}



/// 播放支付声音
/// @param money 价格
- (void)playPaySound:(NSString *)money isPayments:(BOOL)isPayments
{
    if (!money || money.length <=0) {
        self.contentHandler(self.bestAttemptContent);
        return;
    }
    NSString *payStr = @"";
    if (isPayments) {
        payStr = [NSString stringWithFormat:@"您在App中收到了 %@ 元",money];
    } else {
        payStr = [NSString stringWithFormat:@"您在App中支付了 %@ 元",money];
    }
    [self playSoundText:payStr];
}


- (void)playSoundText:(NSString *)text
{
    //文本内容不宜过长,超过30秒会播报不完整,具体的播报字数与播放速度需要自己计算
    AVSpeechUtterance *utterance = [AVSpeechUtterance speechUtteranceWithString:text];
    [self.synthesizer stopSpeakingAtBoundary:(AVSpeechBoundaryImmediate)];
    utterance.rate = 1;
    utterance.voice = self.synthesisVoice;
    [self.synthesizer speakUtterance:utterance];
}


/// 下载文件
/// @param urlString 文件的url路径
/// @param type 文件的类型
- (NSURL *)downFile:(NSString *)urlString withFileType:(NSString *)type
{
    //1. 下载
//    dispatch_queue_t queue = dispatch_queue_create("yujia_notification_queue", DISPATCH_QUEUE_SERIAL);
//    dispatch_async(queue, ^{
        //下载图片数据
        NSURL *url = [NSURL URLWithString:urlString];
        NSError *error;
        /**
         注:
         dataWithContentsOfURL方法是同步方法,一般不建议用来请求基于网络的URL。对于基于网络的URL,此方法可以在慢速网络上阻止当前线程数十秒,会导致用户体验不佳,并且可能会导致应用程序终止。
         */
        NSData *fileData = [NSData dataWithContentsOfURL:url options:NSDataReadingMappedIfSafe error:&error];

        if (error) {
            return nil;
        }
        
        //确定文件保存路径,这里要注意文件是保存在NotificationService这个应用沙盒中,并不是保存在主应用中
        NSString *userDocument = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) objectAtIndex:0];
        //附件保存的路径
        NSString *path = @"";
        if ([type isEqualToString:@"HYJFileTypeImage"]) {
            path = [NSString stringWithFormat:@"%@/notification.jpg", userDocument];
        } else if ([type isEqualToString:@"HYJFileTypeSound"])
        {
            path = [NSString stringWithFormat:@"%@/notification.mp3", userDocument];
        } else if ([type isEqualToString:@"pay"]){
            path = [self getFilePath];
        } else {
            path = [NSString stringWithFormat:@"%@/notification.mp4", userDocument];
        }
        //先删除老的文件
        [[NSFileManager defaultManager] removeItemAtPath:path error:nil];
        //再保存新文件
        [fileData writeToFile:path atomically:YES];

        //传给自定义通知栏的URL
        NSURL *saveUrl = [NSURL fileURLWithPath:path];
    return saveUrl;
}

- (NSString *)getFilePath
{
    NSString *filePath = @"";
    //通过App组,获取主App沙盒路径l
    NSURL *groupURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.com.yujia.mpaas.demo"];
    NSString *groupPath = [groupURL path];
    //获取的文件路径
     filePath = [groupPath stringByAppendingPathComponent:@"Library/Sounds"];
    NSFileManager *fileManager = [NSFileManager defaultManager];
    if (![fileManager fileExistsAtPath:filePath])
    {
        NSError *error;
        [fileManager createDirectoryAtPath:filePath withIntermediateDirectories:NO attributes:nil error:&error];
        if (error) {
            NSLog(@"error:%@",error);
        }
    }
    
    NSString *pathFile = [NSString stringWithFormat:@"%@/%@",filePath,@"pay.wav"];
    
    return pathFile;
}


#pragma mark - AVSpeechSynthesizerDelegate
// 新增语音播放代理函数,在语音播报完成的代理函数中,我们添加下面的一行代码
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer didFinishSpeechUtterance:(AVSpeechUtterance *)utterance {
    // 语音播放完成后调用
    self.contentHandler(self.bestAttemptContent);
}

#pragma mark - LazyLoad
- (AVSpeechSynthesisVoice *)synthesisVoice {
    if (!_synthesisVoice) {
        _synthesisVoice = [AVSpeechSynthesisVoice voiceWithLanguage:@"zh-CN"];
    }
    return _synthesisVoice;
}

- (AVSpeechSynthesizer *)synthesizer {
    if (!_synthesizer) {
        _synthesizer = [[AVSpeechSynthesizer alloc] init];
        _synthesizer.delegate = self;
    }
    return _synthesizer;
}

- (NSMutableArray<UNNotificationAttachment *> *)attachments
{
    if (!_attachments) {
        _attachments = [NSMutableArray new];
    }
    return _attachments;
}

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

#import "HYJImageCell.h"
#import "HYJSoundCell.h"
#import "HYJVideoCell.h"

#import <Masonry/Masonry.h>
#import <AVFoundation/AVFoundation.h>


#define PRAISE @"myImageNotificationCategory_action_praise"

#define STARTAPP @"myImageNotificationCategory_action_startApp"

#define CANCEL @"myImageNotificationCategory_action_cancel"

#define InputText @"myImageNotificationCategory_action_inputText1"


@interface NotificationViewController () <UNNotificationContentExtension, UITableViewDelegate, UITableViewDataSource>

/** tableView */
@property (nonatomic, strong) UITableView *tableView;

/** 数据源 */
@property (nonatomic, strong) NSMutableArray *dataArray;

/** 当前数据类型 */
@property (nonatomic, copy) NSString *type;

//Action的回调
@property (nonatomic, strong) void (^completion)(UNNotificationContentExtensionResponseOption option);

//播放器
@property (nonatomic , strong) AVPlayer *avPlayer;

//
@property (nonatomic , strong) AVAudioSession *audioSession;

//播放器视图
@property (nonatomic , strong) AVPlayerLayer *playerLayer;


@end

@implementation NotificationViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any required interface initialization here.
    
    /** tableView */
    self.tableView = [UITableView new];
    [self.view addSubview:self.tableView];
//    [self.tableView mas_makeConstraints:^(MASConstraintMaker *make) {
//        make.edges.equalTo(self.view);
//    }];
    self.tableView.frame = self.view.frame;
    self.tableView.backgroundColor = [UIColor clearColor];
    self.tableView.delegate = self;
    self.tableView.dataSource = self;
    self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
    [self.tableView registerClass:[HYJImageCell class] forCellReuseIdentifier:@"HYJImageCell"];
    [self.tableView registerClass:[HYJSoundCell class] forCellReuseIdentifier:@"HYJSoundCell"];
    [self.tableView registerClass:[HYJVideoCell class] forCellReuseIdentifier:@"HYJVideoCell"];
    
}


#pragma mark - UNNotificationContentExtension

//当通知中category与当前推送扩展的UNNotificationExtensionCategory相同时
//该方法将被调用,用于接收通知的内容进行展示
- (void)didReceiveNotification:(UNNotification *)notification {
    
    if (notification.request.content.attachments && notification.request.content.attachments.count > 0) {
        
        //获取通知信息
        NSDictionary *userInfo = notification.request.content.userInfo;
        if (userInfo) {
            //获取通知中aps信息
            NSDictionary *aps = [userInfo objectForKey:@"aps"];
            NSDictionary *params = [userInfo objectForKey:@"params"];
            if (aps && params) {
                UNNotificationAttachment *attachment = [notification.request.content.attachments firstObject];
                NSString *type = [params objectForKey:@"myNotificationType"];
                if (type) {
                    self.type = type;
                    if([type isEqualToString:@"HYJFileTypeImage"]) {
                        //通过使用安全作用域解析创建的attachment数据而创建的NSURL,使url引用的资源可供进程访问。
                        //startAccessingSecurityScopedResource与stopAccessingSecurityScopedResource需成对出现
                        //当不再需要访问此资源时,客户端必须调用stopAccessingSecurityScopedResource
                        [self setImageCategory];
                        if ([attachment.URL startAccessingSecurityScopedResource]) {
                            NSData *imageData = [NSData dataWithContentsOfURL:attachment.URL];
                            [self.dataArray removeAllObjects];
                            [self.dataArray addObject:[UIImage imageWithData:imageData]];
                            [attachment.URL stopAccessingSecurityScopedResource];
                        }
                    } else if ([type isEqualToString:@"HYJFileTypeSound"]) {
                        if (notification.request.content.attachments.count >= 2) {
                            [self setSoundCategory];
                            if ([attachment.URL startAccessingSecurityScopedResource]) {
                                NSData *imageData = [NSData dataWithContentsOfURL:attachment.URL];
                                [self.dataArray removeAllObjects];
                                [self.dataArray addObject:[UIImage imageWithData:imageData]];
                                [attachment.URL stopAccessingSecurityScopedResource];
                            }
                            UNNotificationAttachment *soundAttachment = [notification.request.content.attachments lastObject];
                            [self createrMediaPlay:YES withUlr:soundAttachment.URL];
                        }
                    } else if ([type isEqualToString:@"HYJFileTypeVideo"]) {
                        [self setSoundCategory];
                        [self createrMediaPlay:NO withUlr:attachment.URL];
                        [self.dataArray removeAllObjects];
                        [self.dataArray addObject:attachment.URL];
                    }
                    [self.tableView reloadData];
                }
            }
        }
    }
}

/// 当实现了Actions点击事件时,用户点击后将会回调该方法
- (void)didReceiveNotificationResponse:(UNNotificationResponse *)response completionHandler:(void (^)(UNNotificationContentExtensionResponseOption option))completion
{
    self.completion = completion;
    //获取通知信息
    NSDictionary *userInfo = response.notification.request.content.userInfo;
    if (userInfo) {
        //获取通知中aps信息
        NSDictionary *aps = [userInfo objectForKey:@"aps"];
        if (aps && self.type) {
//            NSString *type = [aps objectForKey:@"myNotificationType"];
            NSString *identifier = response.actionIdentifier;
            if ([self.type isEqualToString:@"HYJFileTypeImage"]) {
                [self actionImageWithIdentifier:identifier];
            } else if ([self.type isEqualToString:@"HYJFileTypeSound"]) {
                [self actionSoundWithResponse:response];
            } else if ([self.type isEqualToString:@"HYJFileTypeVideo"]) {
                [self actionVideoWithIdentifier:identifier];
            }
        }
    }
}

- (UNNotificationContentExtensionMediaPlayPauseButtonType)mediaPlayPauseButtonType
{
//    return UNNotificationContentExtensionMediaPlayPauseButtonTypeDefault;
    
    
    //该属性会导致mediaPlayPauseButtonTintColor无效
    return UNNotificationContentExtensionMediaPlayPauseButtonTypeOverlay;
    
    

}

- (CGRect)mediaPlayPauseButtonFrame
{
    CGFloat width = self.view.frame.size.width;
    if (self.view.frame.size.width > self.view.frame.size.height) {
        width = self.view.frame.size.height;
    }

    return CGRectMake((self.view.frame.size.width-width/2)/2, (self.view.frame.size.height-width/2)/2, width/2, width/2);
}

- (UIColor *)mediaPlayPauseButtonTintColor
{
    return [UIColor orangeColor];
}

- (void)mediaPlay
{
    if (self.avPlayer) {
        [self.avPlayer play];
    }
}
- (void)mediaPause
{
    if (self.avPlayer) {
        [self.avPlayer pause];
    }
}


#pragma mark - UITableViewDelegate, UITableViewDataSource
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    if ([self.type isEqualToString:@"HYJFileTypeImage"])
    {
        HYJImageCell *cell = [tableView dequeueReusableCellWithIdentifier:@"HYJImageCell"];
        if (!cell) {
            cell = [[HYJImageCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"HYJImageCell"];
        }
        [cell setCellImage:[self.dataArray firstObject]];
        return cell;
    } else if ([self.type isEqualToString:@"HYJFileTypeSound"]) {
        HYJSoundCell *cell = [tableView dequeueReusableCellWithIdentifier:@"HYJSoundCell"];
        if (!cell) {
            cell = [[HYJSoundCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"HYJSoundCell"];
        }
//        [cell setSoundUrl:[self.dataArray firstObject]];
        cell.coverImageView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height);
        [cell setCellImage:[self.dataArray firstObject]];
        return cell;
    } else {
        HYJVideoCell *cell = [tableView dequeueReusableCellWithIdentifier:@"HYJVideoCell"];
        if (!cell) {
            cell = [[HYJVideoCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"HYJVideoCell"];
        }
        if (self.playerLayer) {
            [cell.layer addSublayer:self.playerLayer];
        }
        return cell;
    }

}
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
    return 1;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return self.dataArray.count;
}

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
    [tableView deselectRowAtIndexPath:indexPath animated:YES];
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    if ([self.type isEqualToString:@"HYJFileTypeImage"]) {
        UIImage *image = [self.dataArray firstObject];
        if (image) {
            CGSize imageSize = image.size;
            if (imageSize.width > viewW) {
                imageSize.height = imageSize.width/viewW*viewH;
            }
            return imageSize.height+10;
        } else {
            return 0;
        }
    } else if ([self.type isEqualToString:@"HYJFileTypeSound"]) {
        return self.view.frame.size.height;
    } else {
        return 250;
    }
}

#pragma mark - NSNotificationCenter

- (void)playDidEnd:(NSNotification*)notification
{
    //重置播放
    AVPlayerItem *item = [notification object];
    //设置从0开始
    [item seekToTime:kCMTimeZero];
    //播放往后,状态设置为暂停
    [self.extensionContext mediaPlayingPaused];
}

#pragma mark - Private Method

- (void)setImageCategory
{
    UNNotificationAction *praiseAction = [UNNotificationAction actionWithIdentifier:PRAISE title:@"点赞" options:UNNotificationActionOptionAuthenticationRequired];
    UNNotificationAction *startAppAction = [UNNotificationAction actionWithIdentifier:STARTAPP title:@"查看详情" options:UNNotificationActionOptionForeground];
    UNNotificationAction *cancelAction = [UNNotificationAction actionWithIdentifier:CANCEL title:@"取消" options:UNNotificationActionOptionDestructive];
    NSArray *actionArray = @[praiseAction,startAppAction,cancelAction];
    
    UNNotificationCategory *category;
    if (@available(iOS 12.0,*)) {
        //myImageNotificationCategory_01
        category = [UNNotificationCategory categoryWithIdentifier:@"myImageNotificationCategory" actions:actionArray intentIdentifiers:@[] hiddenPreviewsBodyPlaceholder:nil categorySummaryFormat:@"宇佳测试,您还有%u条来自%@的消息" options:UNNotificationCategoryOptionCustomDismissAction];
    } else {
        category = [UNNotificationCategory categoryWithIdentifier:@"myImageNotificationCategory_01" actions:actionArray intentIdentifiers:@[] options:UNNotificationCategoryOptionCustomDismissAction];
    }
    NSSet *sets = [NSSet setWithObject:category];
    [[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:sets];
}

- (void)setSoundCategory
{
    UNTextInputNotificationAction *inputTextAction = [UNTextInputNotificationAction actionWithIdentifier:InputText title:@"评论" options:UNNotificationActionOptionAuthenticationRequired textInputButtonTitle:@"发送" textInputPlaceholder:@"请输入评论内容"];
    UNNotificationAction *playAction = [UNNotificationAction actionWithIdentifier:@"play" title:@"播放" options:UNNotificationActionOptionAuthenticationRequired];
    UNNotificationAction *pausedAction = [UNNotificationAction actionWithIdentifier:@"paused" title:@"暂停" options:UNNotificationActionOptionForeground];
    UNNotificationAction *cancelAction = [UNNotificationAction actionWithIdentifier:CANCEL title:@"取消" options:UNNotificationActionOptionDestructive];
    NSArray *actionArray = @[inputTextAction,playAction,pausedAction,cancelAction];
    UNNotificationCategory *category;
    if (@available(iOS 12.0,*)) {
        category = [UNNotificationCategory categoryWithIdentifier:@"myImageNotificationCategory" actions:actionArray intentIdentifiers:@[] hiddenPreviewsBodyPlaceholder:nil categorySummaryFormat:@"宇佳测试,您还有%u条来自%@的消息" options:UNNotificationCategoryOptionCustomDismissAction];
    } else {
        category = [UNNotificationCategory categoryWithIdentifier:@"myImageNotificationCategory_01" actions:actionArray intentIdentifiers:@[] options:UNNotificationCategoryOptionCustomDismissAction];
    }
    NSSet *sets = [NSSet setWithObject:category];
    [[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:sets];
}

- (void)actionImageWithIdentifier:(NSString *)identifier
{
    if ([identifier isEqualToString:PRAISE]) {
        self.completion(UNNotificationContentExtensionResponseOptionDoNotDismiss);
        //dosomething
    } else if ([identifier isEqualToString:STARTAPP]) {
        self.completion(UNNotificationContentExtensionResponseOptionDismissAndForwardAction);
        if (@available(iOS 12.0,*)) {
            [self.extensionContext performNotificationDefaultAction];
        }
    } else if ([identifier isEqualToString:CANCEL]) {
        self.completion(UNNotificationContentExtensionResponseOptionDismiss);
        if (@available(iOS 12.0,*)) {
            [self.extensionContext dismissNotificationContentExtension];
        }
    }
}

- (void)actionSoundWithResponse:(UNNotificationResponse *)response
{
    NSString *identifier = response.actionIdentifier;
    if ([identifier isEqualToString:InputText]) {
        UNTextInputNotificationResponse *inputAction = (UNTextInputNotificationResponse *)response;
        NSLog(@"输入内容:%@",inputAction.userText);
        self.completion(UNNotificationContentExtensionResponseOptionDismiss);
    } else if ([identifier isEqualToString:@"play"]) {
        self.completion(UNNotificationContentExtensionResponseOptionDoNotDismiss);
    } else if ([identifier isEqualToString:@"paused"]) {
        self.completion(UNNotificationContentExtensionResponseOptionDoNotDismiss);
    } else if ([identifier isEqualToString:CANCEL]) {
        self.completion(UNNotificationContentExtensionResponseOptionDismiss);
        if (@available(iOS 12.0,*)) {
            [self.extensionContext dismissNotificationContentExtension];
        }
    }
}

- (void)actionVideoWithIdentifier:(NSString *)identifier
{
    self.completion(UNNotificationContentExtensionResponseOptionDismiss);
}


/*!
 计算文本size
 @param text 文本
 @param size 最大size
 @param font 字体大小
 
 @return 文本size
*/
- (CGSize)autoLabelSize:(NSString *)text maxSize:(CGSize)size font:(CGFloat)font
{
    CGRect rect = [text boundingRectWithSize:size options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName:[UIFont systemFontOfSize:font]} context:nil];
    CGSize lastSize = CGSizeMake(ceilf(rect.size.width), ceilf(rect.size.height));
    return lastSize;
}


/// 创建媒体播放对象
/// @param isSound 是否创建音频播放 YES表示仅音频 NO表示播放音视频
/// @param url 资源文件的本地url
- (void)createrMediaPlay:(BOOL)isSound withUlr:(NSURL *)url
{
    if ([url startAccessingSecurityScopedResource]) {
        AVAsset *asset = [AVAsset assetWithURL:url];
        AVPlayerItem *playerItem = [[AVPlayerItem alloc] initWithAsset:asset];
        if (!self.avPlayer) {
            self.avPlayer = [[AVPlayer alloc] initWithPlayerItem:playerItem];
        } else {
            [self.avPlayer replaceCurrentItemWithPlayerItem:playerItem];
        }

        self.audioSession = [AVAudioSession sharedInstance];
        [self.audioSession setCategory:AVAudioSessionCategoryPlayback withOptions:(AVAudioSessionCategoryOptionDuckOthers) error:nil];
        [self.audioSession setActive:YES error:nil];
        if (!isSound) {
            self.playerLayer = [AVPlayerLayer playerLayerWithPlayer:self.avPlayer];
            self.playerLayer.frame = self.view.bounds;//放置播放器的视图
        }
        //监听音频播放完成
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(playDidEnd:) name:AVPlayerItemDidPlayToEndTimeNotification object:self.avPlayer.currentItem];
        [url stopAccessingSecurityScopedResource];
    }
}

#pragma mark - LazyLoad

- (NSMutableArray *)dataArray
{
    if (!_dataArray) {
        _dataArray = [NSMutableArray new];
    }
    return _dataArray;
}

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

推荐阅读更多精彩内容