iOS-推送消息擴展

知 識 點 / 超 人


目錄

  • 背景
  • UNNotificationServiceExtension 與 UNNotificationContentExtension的關系
  • UNNotificationServiceExtension
  • UNNotificationContentExtension
  • 擴展知識點
  • 示例代碼

背景

iOS 10之前,iPhone手機中,通知欄僅能展示 標題和內(nèi)容文本

iOS 10 之前的通知欄

iOS 10 開始,蘋果新增了UserNotifications.framework庫用于對通知的擴展。通過UNNotificationService 與 UNNotificationContent來進行通知的 攔截 與 通知界面的自定義。讓通知欄變得豐富多彩,既可以展示圖文內(nèi)容,也可以展示音視頻。使得用戶在不打開App的情況下也能進行App內(nèi)容的交互。

圖文推送

長按推送推送后帶有圖文的自定義推送
長按推送后帶有交互的自定義推送

長按推送后的帶有聊天回復功能的自定義推送

iOS 12開始,新增了通知欄的分組,默認會根據(jù)Bundle Id自動區(qū)分不同應用的通知,也可以通過Thread identifier 精細化控制同一個應用里不同類型的通知。
設置App分組

自動:按照Thread identifier進行區(qū)分不同的消息,App如果未設置Thread identifier,則按照Bundle Id區(qū)分。
按App:按照App的 Bundle Id 區(qū)分通知消息
:關閉App的通知分組


UNNotificationServiceExtension 與 UNNotificationContentExtension的關系

UNNotificationServiceExtension負責攔截通知,對通知內(nèi)容做中間處理,而UNNotificationContentExtension負責自定義通知界面的。
如果只是想對通知內(nèi)容進行解析或單純的系統(tǒng)通知中有小圖片,那么使用UNNotificationServiceExtension即可
如果想顯示用戶長按通知自定義后顯示的通知界面,則需要使用UNNotificationContentExtension自定義通知界面
一般都是UNNotificationServiceExtension與UNNotificationContentExtension結合使用,由UNNotificationServiceExtension解密通知,將通知內(nèi)容進行轉換,并下載相關的通知資源文件。然后在UNNotificationContentExtension中拿到UNNotificationServiceExtension處理后的通知內(nèi)容,將內(nèi)容賦值在界面上進行展示。


UNNotificationServiceExtension

蘋果官方關于UNNotificationServiceExtension的說明文檔

UNNotificationServiceExtension是用于攔截遠程通知的,在手機收到對應App的通知時,會觸發(fā)UNNotificationServiceExtension,可以在UNNotificationServiceExtension中對通知內(nèi)容進行修改,收到可以對通知添加附件,例如圖片、音頻、視頻。

1、創(chuàng)建UNNotificationServiceExtension
在工程中的TARGETS中選擇加號,然后在 iOS 模板中選擇 Notification Service Extension

創(chuàng)建UNNotificationServiceExtension

創(chuàng)建后自動生成的文件

NotificationService是通知攔截響應的類,Info.plist是NotificationsServiceExtension的配置信息。

在NotificationService.m中,自動生成了contentHandlerbestAttemptContent兩個屬性。didReceiveNotificationRequest:withContentHandlerserviceExtensionTimeWillExpire方法。

bestAttemptContent

//通知消息的內(nèi)容對象,里面包含了通知相關的所有信息。一般有title、body、subTitle。
//如果是服務端封裝的擴展參數(shù),則一般都在userInfo中。
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;

contentHandler

//用于告知系統(tǒng)已經(jīng)處理完成,可以將通知內(nèi)容傳給App的回調(diào)對象。
//該對象需要返回一個UNMutableNotificationContent對象。一般都是返回bestAttemptContent
@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);

didReceiveNotificationRequest:withContentHandler

//可以通過重寫此方法來實現(xiàn)自定義推送通知修改。
//如果要使用修改后的通知內(nèi)容,則需要在該方法中調(diào)用contentHandler傳遞修改后的通知內(nèi)容。
//如果在服務時間(30秒)到期之前未調(diào)用處理程序contentHandler,則將傳遞未修改的通知。
//@param request 通知內(nèi)容
//@param contentHandler 處理結果,需要返回一個UNNotificationContent的通知內(nèi)容
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
    
    //接收回調(diào)對象
    self.contentHandler = contentHandler;
    //copy通知內(nèi)容
    self.bestAttemptContent = [request.content mutableCopy];
    //回調(diào)通知結果
    self.contentHandler(self.bestAttemptContent);
}

serviceExtensionTimeWillExpire

//當didReceiveNotificationRequest的方法執(zhí)行超過30秒未調(diào)用contentHandler時
//系統(tǒng)會自動調(diào)用serviceExtensionTimeWillExpire方法,給我們最后一次彌補處理的機會
//可以在serviceExtensionTimeWillExpire方法中設置didReceiveNotificationRequest方法中未完成數(shù)據(jù)的默認值
- (void)serviceExtensionTimeWillExpire {
    self.contentHandler(self.bestAttemptContent);
}

UNNotificationRequest
遠程通知發(fā)送給App的通知請求,其中包括通知的內(nèi)容和交互的觸發(fā)條件。

@interface UNNotificationRequest : NSObject <NSCopying, NSSecureCoding>

// 該屬性為通知請求的唯一標識符。可以用它來替換或刪除掛起的通知請求或已傳遞的通知。
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *identifier;

// 該屬性是用于顯示的通知內(nèi)容
@property (NS_NONATOMIC_IOSONLY, readonly, copy) UNNotificationContent *content;

// 通知交互的觸發(fā)器
@property (NS_NONATOMIC_IOSONLY, readonly, copy, nullable) UNNotificationTrigger *trigger;

UNNotificationTrigger
抽象類,用于表示觸發(fā)通知傳遞的事件。不能直接創(chuàng)建此類的實例。具體的觸發(fā)子類看下面的類

//抽象類,用于表示觸發(fā)通知傳遞的事件。不能直接創(chuàng)建此類的實例。具體的觸發(fā)子類看下面的類
@interface UNNotificationTrigger : NSObject <NSCopying, NSSecureCoding>

/// 通知的觸發(fā)是否循環(huán)執(zhí)行
@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

// 本地推送
// 基于時間間隔去觸發(fā)的通知.
API_AVAILABLE(macos(10.14), ios(10.0), watchos(3.0), tvos(10.0))
@interface UNTimeIntervalNotificationTrigger : UNNotificationTrigger

/// 通知延遲觸發(fā)的時間
@property (NS_NONATOMIC_IOSONLY, readonly) NSTimeInterval timeInterval;


/// 創(chuàng)建延遲觸發(fā)的通知
/// @param timeInterval 延遲的時間
/// @param repeats 是否重復執(zhí)行
+ (instancetype)triggerWithTimeInterval:(NSTimeInterval)timeInterval repeats:(BOOL)repeats;

/// 獲取通知下一次觸發(fā)的時間
- (nullable NSDate *)nextTriggerDate;

@end

// 本地推送
// 根據(jù)日期和時間觸發(fā)的通知
API_AVAILABLE(macos(10.14), ios(10.0), watchos(3.0), tvos(10.0))
@interface UNCalendarNotificationTrigger : UNNotificationTrigger

/// 通知觸發(fā)的時間
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSDateComponents *dateComponents;

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


/// 創(chuàng)建基于時間觸發(fā)的通知
/// @param dateComponents 日期
/// @param repeats 是否重復執(zhí)行
+ (instancetype)triggerWithDateMatchingComponents:(NSDateComponents *)dateComponents repeats:(BOOL)repeats;

/// 獲取通知下一次觸發(fā)的時間
- (nullable NSDate *)nextTriggerDate;

@end

// 根據(jù)用戶手機定位,當進入某個區(qū)域或者離開某個區(qū)域時觸發(fā)通知
API_AVAILABLE(ios(10.0), watchos(3.0)) API_UNAVAILABLE(macos, tvos, macCatalyst)
@interface UNLocationNotificationTrigger : UNNotificationTrigger

/// 觸發(fā)通知的地理位置信息
@property (NS_NONATOMIC_IOSONLY, readonly, copy) CLRegion *region;

/// 創(chuàng)建基于地理位置觸發(fā)的通知
/// @param region 地理位置
/// @param repeats 是否重復觸發(fā)
+ (instancetype)triggerWithRegion:(CLRegion *)region repeats:(BOOL)repeats API_AVAILABLE(watchos(8.0));

@end

UNNotificationContent
通知的具體內(nèi)容,包括通知類型、自定義參數(shù)、附件信息等。不能直接創(chuàng)建該類,如果需要創(chuàng)建自定義的通知內(nèi)容,應該創(chuàng)建UNMutableNotificationContent,并配置內(nèi)容

// 附件數(shù)組,必須是UNNotificationAttachment對象
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSArray <UNNotificationAttachment *> *attachments API_UNAVAILABLE(tvos);

// 應用的認證號碼,圖標右上角的數(shù)字
@property (NS_NONATOMIC_IOSONLY, readonly, copy, nullable) NSNumber *badge;

// 通知的內(nèi)容
@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);

// 通知摘要參數(shù)
@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));

// 摘要的數(shù)量
@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,立即執(zhí)行,點亮屏幕并可能播放聲音
//UNNotificationInterruptionLevelTimeSensitive,立即執(zhí)行,點亮屏幕并可能播放聲音;在請勿打擾期間都會出現(xiàn)
// 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
//立即執(zhí)行,點亮屏幕并播放聲音,如果處于“請勿打擾”狀態(tài)會一直顯示,不收靜音開關影響,如果沒有設置附件聲音,則會使用默認的嚴重警報聲音
//UNNotificationInterruptionLevelCritical,
@property (NS_NONATOMIC_IOSONLY, readonly, assign) UNNotificationInterruptionLevel interruptionLevel API_AVAILABLE(macos(12.0), ios(15.0), watchos(8.0), tvos(15.0));

// 關聯(lián)系數(shù),決定了通知在應用程序通知中的排序。其范圍在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
通知發(fā)出時,通知的聲音。如果想手機收到通知時播放特定聲音,需要創(chuàng)建UNNotificationSound對象來設置特定的音頻文件。

UNNotificationSound對象僅讀取以下位置文件:
應用程序容器目錄的/Library/Sounds目錄。
應用程序的共享組容器目錄之一的/Library/Sounds目錄。

播放自定義聲音,必須采用以下音頻數(shù)據(jù)格式之一:Linear PCM、MA4 (IMA/ADPCM)、μLaw、aLaw
可以將音頻數(shù)據(jù)打包為aiff、wav或caf文件。聲音文件的長度必須小于30秒。如果聲音文件超過30秒,系統(tǒng)將播放默認聲音
可以使用afconvert命令行工具轉換聲音。例如,將系統(tǒng)聲音轉換為Submarine.aiff。aiff到IMA4音頻在CAF文件中,在終端中使用以下命令:
afconvert /System/Library/Sounds/Submarine.aiff ~/Desktop/sub.caf -d ima4 -f caff -

//通知發(fā)出時,通知的聲音。如果想手機收到通知時播放特定聲音,需要創(chuàng)建UNNotificationSound對象來設置特定的音頻文件。
//UNNotificationSound對象僅在以下位置顯示:
//應用程序容器目錄的/Library/Sounds目錄。
//應用程序的共享組容器目錄之一的/Library/Sounds目錄。
//播放自定義聲音,必須采用以下音頻數(shù)據(jù)格式之一:Linear PCM、MA4 (IMA/ADPCM)、μLaw、aLaw
//可以將音頻數(shù)據(jù)打包為aiff、wav或caf文件。聲音文件的長度必須小于30秒。如果聲音文件超過30秒,系統(tǒng)將播放默認聲音
//可以使用afconvert命令行工具轉換聲音。例如,將系統(tǒng)聲音轉換為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;

// 用于來電通知的默認聲音。播放設置中指定的鈴聲和觸覺,持續(xù)30秒。
// 父UNNotificationContent對象必須通過-[UnnotificationContentByUpdateingWithProvider:error:]在通知服務擴展中創(chuàng)建
// 其中提供程序是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);

// 為通知播放的聲音文件。聲音必須位于應用程序數(shù)據(jù)容器的Library/Sounds文件夾或應用程序組數(shù)據(jù)容器的Library/Sounds文件夾中。
// 如果在容器中找不到該文件,系統(tǒng)將在應用程序包中查找。
+ (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
通知的附件,可以存放音頻、圖像或視頻內(nèi)容,創(chuàng)建UNNotificationAttachment對象時,指定的文件必須在磁盤上,并且文件格式必須是受支持的類型之一。不然會創(chuàng)建失敗,返回nil。
系統(tǒng)會在顯示相關通知之前驗證附件。如果是本地通知,請求附加的文件已損壞、無效或文件類型不受支持,則系統(tǒng)不會執(zhí)行請求。如果是遠程通知,系統(tǒng)會在通知服務應用程序擴展完成后驗證附件。驗證后,系統(tǒng)會將附件移動到附件數(shù)據(jù)存儲中,以便適當?shù)牧鞒炭梢栽L問這些文件。系統(tǒng)會復制應用包中的附件。

附件類型 支持的文件類型 文件的最大容量
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;

// 創(chuàng)建一個附件,附件的url必須是有效的文件路徑,否則會返回nil。
/// @param identifier 附件唯一標識
/// @param URL 附件url
/// @param options 附件類型詳情設置
/// UNNotificationAttachmentOptionsTypeHintKey:附件類型,傳入一個string值,如果未設置,則會根據(jù)文件的擴展名設置文件類型
/// UNNotificationAttachmentOptionsThumbnailHiddenKey:是否隱藏此附件的縮略圖,值是BOOL類型(NSNumber),默認為NO
/// UNNotificationAttachmentOptionsThumbnailClippingRectKey:指定用于附件縮略圖的標準化剪裁矩形
/// (上)該值必須是使用CGRectCreateDictionaryRepresentation編碼的CGRect
/// UNNotificationAttachmentOptionsThumbnailTimeKey:指定要用作縮略圖的動畫圖像幀數(shù)或電影時間。
/// (上)該值動畫圖像幀編號必須是NSNumber類型
/// 該值電影時間必須是以秒為單位的NSNumber,或者是使用CMTimeCopyAsDictionary編碼的CMTime。
/// @param error 創(chuàng)建附件的報錯信息
+ (nullable instancetype)attachmentWithIdentifier:(NSString *)identifier URL:(NSURL *)URL options:(nullable NSDictionary *)options error:(NSError *__nullable *__nullable)error;

UNNotificationContentExtension

該擴展是為應用創(chuàng)建自定義的通知界面的。可以在UIViewController里設置界面,在didReceiveNotification方法中獲得通知數(shù)據(jù)賦值界面。

創(chuàng)建NotificationContentExtension

UNNotificationContentExtension 的info.plist配置參數(shù)說明

key value
UNNotificationExtensionCategory(必填) string值或Array值,自定義通知界面的標識符,系統(tǒng)會根據(jù)通知中category 的名稱自動與該值匹配,匹配后會使用該自定義界面。設置為string則表示只有一個,設置為Array則表示有多個
UNNotificationExtensionInitialContentSizeRatio(必填) 浮點值,表示視圖控制器視圖的初始大小,表示為其高度與寬度的比率。加載自定義通知視圖時,系統(tǒng)使用此值設置視圖控制器的初始大小,以寬度為基數(shù)。例如,值為0.5時,表示 高度 = 寬度 * 0.5。值為2,表示 高度 = 寬度 * 2
UNNotificationExtensionDefaultContentHidden BOOL值,表示打開自定義通知界面的時候,是否隱藏默認通知的導航標題和內(nèi)容,設置為YES時只顯示自定義的內(nèi)容,默認為NO
UNNotificationExtensionOverridesDefaultTitle BOOL值,設置為YES時,系統(tǒng)將使用視圖控制器的title屬性作為通知的標題。設置為NO時,系統(tǒng)會將通知的標題設置為應用程序的名稱。默認為NO
NSExtensionMainStoryboard 自定義界面對應的SB文件名
NSExtensionPointIdentifier 是擴展的唯一標識,設置為com.apple.usernotifications.content-extension后會被系統(tǒng)識別為蘋果通知的Content的擴展
UNNotificationExtensionUserInteractionEnabledYES BOOL類型,設置為YES后,自定義界面運行有交互行為,設置為NO的話點擊自定義界面會直接打開App

UNNotificationContentExtension

@protocol UNNotificationContentExtension <NSObject>

//接收即將顯示的通知,在這里獲取通知內(nèi)容,然后根據(jù)通知信息處理顯示通知界面
- (void)didReceiveNotification:(UNNotification *)notification;

@optional

/// 如果設置Action并實現(xiàn)了該方法,當用戶點擊了按鈕的時候就會調(diào)用該方法,可以在該方法中處理點擊事件
/// @param response 觸發(fā)的事件響應體
/// @param completion 事件的處理結果,返回指示對通知的首選響應的常量。UNNotificationContentExtensionResponseOption枚舉類型
/// UNNotificationContentExtensionResponseOptionDoNotDismiss:不要關閉通知界面
/// UNNotificationContentExtensionResponseOptionDismiss:關閉通知界面
/// UNNotificationContentExtensionResponseOptionDismissAndForwardAction:關閉通知界面,并將通知內(nèi)容傳遞給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:播放和暫停的按鈕
//部分透明的播放/暫停按鈕,位于內(nèi)容上方。該屬性會導致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];
        
        //通過使用安全作用域解析創(chuàng)建的attachment數(shù)據(jù)而創(chuàng)建的NSURL,使url引用的資源可供進程訪問。
        //startAccessingSecurityScopedResource與stopAccessingSecurityScopedResource需成對出現(xiàn)
        //當不再需要訪問此資源時,客戶端必須調(diào)用stopAccessingSecurityScopedResource
        if ([attachment.URL startAccessingSecurityScopedResource]) {
            NSData *imageData = [NSData dataWithContentsOfURL:attachment.URL];
            self.imageView.image = [UIImage imageWithData:imageData];
            [attachment.URL stopAccessingSecurityScopedResource];
        }
    }
}

UNNotificationAction

使用UNNotificationAction對象,可以定義在響應已發(fā)送通知時可以執(zhí)行的操作。例如,會議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:執(zhí)行此操作前是否需要解鎖
//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));

// 創(chuàng)建事件

+ (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

//事件的關聯(lián)圖片
@interface UNNotificationActionIcon : NSObject <NSCopying, NSSecureCoding>

//使用應用里的圖片作為事件的icon,圖片需要放在asset中
+ (instancetype)iconWithTemplateImageName:(NSString *)templateImageName;
//使用系統(tǒng)圖片作為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;

//創(chuàng)建文本輸入框
+ (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;

// 具體的操作數(shù)組,按數(shù)組里Action順序顯示操作
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSArray<UNNotificationAction *> *actions;

//支持的intents的類型
//詳情可以查看<Intents/INIntentIdentifiers.h>
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSArray<NSString *> *intentIdentifiers;
//分配的配置,枚舉
// 將關閉操作發(fā)送給通知代理
//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),
// 允許當前通知發(fā)布通知
//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;

// 當預覽被隱藏時,會使用該字符串內(nèi)容替換通知body進行顯示
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *hiddenPreviewsBodyPlaceholder API_AVAILABLE(ios(11.0)) API_UNAVAILABLE(watchos, tvos);

///該屬性是用于描述,來自此category的通知分組,它應該包含描述性文本和格式參數(shù),這些參數(shù)將被替換為信息
///來自此分組的通知。將會把參數(shù)被替換為數(shù)字,以及通過在每個分組通知中加入?yún)?shù)而創(chuàng)建的列表。
///例如:“%u來自%@的新郵件”。
///參數(shù)列表是可選的,“%u條新消息”也被接受。
///格式化中的 %u和 %@,分別對應著NotificationService中的summaryArgumentCount與summaryArgument。
//summaryArgumentCount:該類型的通知摘要條數(shù),一般由系統(tǒng)管理
//summaryArgument:通知摘要文本
@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSString *categorySummaryFormat API_AVAILABLE(ios(12.0)) API_UNAVAILABLE(watchos, tvos);

// 創(chuàng)建通知分類
+ (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;

// 當前設備是否支持內(nèi)容擴展,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:授權系統(tǒng)顯示App通知設置按鈕的能力
/// UNAuthorizationOptionProvisional:授權能夠將無中斷通知臨時發(fā)布到通知中心的能力
/// UNAuthorizationOptionAnnouncement:不建議使用
/// UNAuthorizationOptionTimeSensitive :不建議使用
/// @param completionHandler granted 表示用戶是否授權,error表示授權是發(fā)生的錯誤
- (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;

// 添加一個通知請求對象,將用相同的標識符的通知請求替換未當前添加的通知請求。
// 如果標識符為現(xiàn)有已送達的通知,通知請求將針對新通知請求發(fā)出警報,并在觸發(fā)時替換現(xiàn)有已送達通知
// app中未送達通知請求的數(shù)量受系統(tǒng)限制,具體的限制還未測試過
- (void)addNotificationRequest:(UNNotificationRequest *)request withCompletionHandler:(nullable void(^)(NSError *__nullable error))completionHandler;

// 獲取未送達的通知請求
- (void)getPendingNotificationRequestsWithCompletionHandler:(void(^)(NSArray<UNNotificationRequest *> *requests))completionHandler;

// 根據(jù)通知請求的唯一標識移除未送達的通知
- (void)removePendingNotificationRequestsWithIdentifiers:(NSArray<NSString *> *)identifiers;
// 移除所有未送達的通知
- (void)removeAllPendingNotificationRequests;

// 獲取已送達并保留在通知中心的通知
- (void)getDeliveredNotificationsWithCompletionHandler:(void(^)(NSArray<UNNotification *> *notifications))completionHandler API_UNAVAILABLE(tvos);
// 根據(jù)通知的唯一標識移除已送達的通知
- (void)removeDeliveredNotificationsWithIdentifiers:(NSArray<NSString *> *)identifiers API_UNAVAILABLE(tvos);
// 移除所有已送達的通知
- (void)removeAllDeliveredNotifications API_UNAVAILABLE(tvos);

@end

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

@optional

/// 只有App處于前臺的時候才會調(diào)用該方法,如果為實現(xiàn)該方法或者為及時執(zhí)行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、拒絕通知或選擇不通知的操作來響應通知時
// 將對委托調(diào)用該方法。通知的代理必須在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響應用戶查看應用內(nèi)通知設置的請求而啟動時,代理將調(diào)用該方法
// 添加未授權選項將AppNotificationSettings作為requestAuthorizationWithOptions:completionHandler中的選項提供
// 將按鈕添加到“設置”中的“內(nèi)聯(lián)通知設置”視圖和“通知設置”視圖中。從設置打開時,通知將為零。
- (void)userNotificationCenter:(UNUserNotificationCenter *)center openSettingsForNotification:(nullable UNNotification *)notification API_AVAILABLE(macos(10.14), ios(12.0)) API_UNAVAILABLE(watchos, tvos);

@end

通知消息體說明

{
    aps =     {//消息推送的主題內(nèi)容
        alert =         {//推送的彈窗顯示內(nèi)容
            body = "\U5b87\U4f73\U7684\U5185\U5bb9”;//顯示的內(nèi)容文本
            title = "\U5b87\U4f73\U7684\U6807\U9898”;//顯示的標題文本
        };
        sound = default;//通知的聲音
    category = myImageNotificationCategory;//通知對應的自定義Content標識
    thread-id = 10923;//通知分組的id
    mutable-content = 1;//通知是否走自定義內(nèi)容的標記,只有該值設置為1的時候才會走擴展內(nèi)容,否則走系統(tǒng)通知
    };
}

后續(xù)會補充一下 如何在Service和Content里引用其他工程資源


示例代碼

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

//用于告知系統(tǒng)已經(jīng)處理完成,可以將通知內(nèi)容傳給App的回調(diào)對象。
//該對象需要返回一個UNMutableNotificationContent對象。一般都是返回bestAttemptContent
@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);

//通知消息的內(nèi)容對象,里面包含了通知相關的所有信息。一般有title、body、subTitle。
//如果是服務端封裝的擴展參數(shù),則一般都在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

/// 可以通過重寫此方法來實現(xiàn)自定義推送通知修改。
///如果要使用修改后的通知內(nèi)容,則需要在該方法中調(diào)用contentHandler傳遞修改后的通知內(nèi)容。如果在服務時間(30秒)到期之前未調(diào)用處理程序contentHandler,則將傳遞未修改的通知。
/// @param request 通知內(nèi)容
/// @param contentHandler 處理結果,需要返回一個UNNotificationContent的通知內(nèi)容
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
    //接收回調(diào)對象
    self.contentHandler = contentHandler;
    //將收到的通知內(nèi)容 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,收到遠程通知后,循環(huán)發(fā)送本地通知,通知中播放本地拆分開的音頻文件,這樣可以減少音頻文件的數(shù)量
                        //方案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的方法執(zhí)行超過30秒未調(diào)用contentHandler時
//系統(tǒng)會自動調(diào)用serviceExtensionTimeWillExpire方法,給我們最后一次彌補處理的機會
//可以在serviceExtensionTimeWillExpire方法中設置didReceiveNotificationRequest方法中未完成數(shù)據(jù)的默認值
- (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
{
    //文本內(nèi)容不宜過長,超過30秒會播報不完整,具體的播報字數(shù)與播放速度需要自己計算
    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, ^{
        //下載圖片數(shù)據(jù)
        NSURL *url = [NSURL URLWithString:urlString];
        NSError *error;
        /**
         注:
         dataWithContentsOfURL方法是同步方法,一般不建議用來請求基于網(wǎng)絡的URL。對于基于網(wǎng)絡的URL,此方法可以在慢速網(wǎng)絡上阻止當前線程數(shù)十秒,會導致用戶體驗不佳,并且可能會導致應用程序終止。
         */
        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
// 新增語音播放代理函數(shù),在語音播報完成的代理函數(shù)中,我們添加下面的一行代碼
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer didFinishSpeechUtterance:(AVSpeechUtterance *)utterance {
    // 語音播放完成后調(diào)用
    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;

/** 數(shù)據(jù)源 */
@property (nonatomic, strong) NSMutableArray *dataArray;

/** 當前數(shù)據(jù)類型 */
@property (nonatomic, copy) NSString *type;

//Action的回調(diào)
@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相同時
//該方法將被調(diào)用,用于接收通知的內(nèi)容進行展示
- (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"]) {
                        //通過使用安全作用域解析創(chuàng)建的attachment數(shù)據(jù)而創(chuàng)建的NSURL,使url引用的資源可供進程訪問。
                        //startAccessingSecurityScopedResource與stopAccessingSecurityScopedResource需成對出現(xiàn)
                        //當不再需要訪問此資源時,客戶端必須調(diào)用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];
                }
            }
        }
    }
}

/// 當實現(xiàn)了Actions點擊事件時,用戶點擊后將會回調(diào)該方法
- (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];
    //播放往后,狀態(tài)設置為暫停
    [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:@"發(fā)送" textInputPlaceholder:@"請輸入評論內(nèi)容"];
    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(@"輸入內(nèi)容:%@",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;
}


/// 創(chuàng)建媒體播放對象
/// @param isSound 是否創(chuàng)建音頻播放 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;//放置播放器的視圖
        }
        //監(jiān)聽音頻播放完成
        [[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
最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,505評論 6 533
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,556評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,463評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,009評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,778評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,218評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,281評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,436評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,969評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,795評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,993評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,537評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,229評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,659評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,917評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,687評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,990評論 2 374

推薦閱讀更多精彩內(nèi)容