iOS開發(fā)之進(jìn)階篇(2)—— 本地通知和遠(yuǎn)程通知 (使用APNs)

Notification.png

版本

iOS 10+

目錄:

  1. 概述
  2. 通知的管理和配置
  3. 本地通知
  4. 遠(yuǎn)程通知 (使用APNs)
  5. 修改通知內(nèi)容和顯示界面
  6. demo地址

一. 概述

由于iOS中App并非始終運(yùn)行, 因此通知提供了一種在App中要顯示新消息時(shí)提醒用戶的方法.
其表現(xiàn)形式如下:

  • 屏幕上的警報(bào)或橫幅
  • 應(yīng)用程式圖示上的徽章
  • 警報(bào), 橫幅或徽章隨附的聲音
notificationTypes.png

iOS App中有兩種通知模式: 本地通知遠(yuǎn)程通知.
對(duì)于用戶而言, 這兩種通知在顯示效果上沒有區(qū)別. 兩種類型的通知具有相同的默認(rèn)外觀, 由系統(tǒng)提供. 當(dāng)然, 我們也可以自定義通知界面, 詳見后文.

它們之間的區(qū)別如下:

  1. 本地通知不需要聯(lián)網(wǎng), 由App指定觸發(fā)通知的條件(例如時(shí)間或位置), 然后創(chuàng)建內(nèi)容并傳遞給系統(tǒng)顯示出來(lái). 例如鬧鐘/便簽等.
  2. 遠(yuǎn)程通知(也稱為推送通知)則需要聯(lián)網(wǎng), 由自己的服務(wù)器生成通知, 然后通過(guò)蘋果推送服務(wù)(APNs)將數(shù)據(jù)傳達(dá)給指定的iOS設(shè)備. 例如微信/支付寶等.

UN框架
iOS 10之后推出了用戶通知框架(User Notifications), 提供了一種統(tǒng)一方式來(lái)調(diào)度和處理本地通知, 該框架除了管理本地通知外, 還支持處理遠(yuǎn)程通知.
UN框架支持創(chuàng)建UNNotificationServiceExtension擴(kuò)展, 使我們可以在傳遞遠(yuǎn)程通知之前修改其內(nèi)容。
UN框架還支持創(chuàng)建UNNotificationContentExtension擴(kuò)展, 使我們可以自定義通知顯式界面.

二. 通知的管理和配置

本小節(jié)內(nèi)容適用于本地通知和遠(yuǎn)程推送通知, 特別的, 遠(yuǎn)程推送通知所需的額外配置寫在遠(yuǎn)程通知小節(jié)里.

Apps must be configured at launch time to support local and remote notifications. Specifically, you must configure your app in advance if it does any of the following:

  • Displays alerts, play sounds, or badges its icon in response to an arriving notification.
  • Displays custom action buttons with a notification.

根據(jù)蘋果文檔這段敘述, 一般地, 我們會(huì)在App啟動(dòng)完成之后配置通知, 即在application:didFinishLaunchingWithOptions:方法里進(jìn)行配置.

設(shè)置代理

如果我們不設(shè)置代理, 也可以正常收到通知, 前提是App不在前臺(tái)運(yùn)行.
為什么會(huì)這樣呢?
原來(lái), 系統(tǒng)給我們提供了App在前臺(tái)運(yùn)行時(shí)處理通知的機(jī)會(huì), 比如攔截通知, 修改通知內(nèi)容等等.
這個(gè)機(jī)制是通過(guò)UNUserNotificationCenterDelegate的代理方法實(shí)現(xiàn)的, 來(lái)看看Apple對(duì)這些代理方法的注釋:

The method will be called on the delegate only if the application is in the foreground. If the method is not implemented or the handler is not called in a timely manner then the notification will not be presented. The application can choose to have the notification presented as a sound, badge, alert and/or in the notification list.
僅當(dāng)App在前臺(tái)運(yùn)行時(shí),才會(huì)調(diào)用該委托方法普泡。 如果未實(shí)現(xiàn)該方法或未及時(shí)調(diào)用該處理程序,則不會(huì)顯示該通知铅忿。 App可以選擇將通知顯示為聲音,徽章洋只,警報(bào)或顯示在通知列表中辆沦。

總而言之, 言而總之, 我們應(yīng)當(dāng)實(shí)現(xiàn)以下兩個(gè)步驟:

  1. 在application:didFinishLaunchingWithOptions:里設(shè)置代理:
///  設(shè)置通知代理
///  系統(tǒng)為App提供了內(nèi)部處理通知的機(jī)會(huì)(通過(guò)user notification代理方法), 比如修改消息內(nèi)容, 是否顯示消息橫幅或者聲音等
///  當(dāng)App在前臺(tái)運(yùn)行時(shí), 我們需要實(shí)現(xiàn)user notification的代理方法, 否則不顯示通知
- (void)setNotificationDelegate {

    UNUserNotificationCenter* center = [UNUserNotificationCenter  currentNotificationCenter];
    center.delegate = self;
}

在appDelegate.m里實(shí)現(xiàn)代理方法:

#pragma mark - UNUserNotificationCenterDelegate

/// 僅當(dāng)App在前臺(tái)運(yùn)行時(shí), 準(zhǔn)備呈現(xiàn)通知時(shí), 才會(huì)調(diào)用該委托方法.
/// 一般在此方法里選擇將通知顯示為聲音, 徽章, 橫幅, 或顯示在通知列表中.
/// @param center 用戶通知中心
/// @param notification 當(dāng)前通知
/// @param completionHandler 回調(diào)通知選項(xiàng): 橫幅, 聲音, 徽章...
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
       willPresentNotification:(UNNotification *)notification
         withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler {
    
    UNNotificationRequest *request = notification.request;
    UNNotificationContent *conten = request.content;
    NSDictionary *userInfo = conten.userInfo;
    
    if ([request.trigger isKindOfClass:[UNPushNotificationTrigger class]]) {
        NSLog(@"即將展示遠(yuǎn)程通知");
    }else {
        NSLog(@"即將展示本地通知");
    }
    NSLog(@"title:%@, subtitle:%@, body:%@, categoryIdentifier:%@, sound:%@, badge:%@, userInfo:%@", conten.title, conten.subtitle, conten.body, conten.categoryIdentifier, conten.sound, conten.badge, userInfo);

    // 以下是在App前臺(tái)運(yùn)行時(shí), 仍要顯示的通知選項(xiàng)
    completionHandler(UNNotificationPresentationOptionAlert + UNNotificationPresentationOptionSound + UNNotificationPresentationOptionBadge);
}


/// 當(dāng)用戶通過(guò)點(diǎn)擊通知打開App/關(guān)閉通知或點(diǎn)擊通知按鈕時(shí), 調(diào)用該方法.
/// (必須在application:didFinishLaunchingWithOptions:里設(shè)置代理)
/// @param center 用戶通知中心
/// @param response 響應(yīng)事件
/// @param completionHandler 處理完成的回調(diào)
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
didReceiveNotificationResponse:(UNNotificationResponse *)response
         withCompletionHandler:(void (^)(void))completionHandler {
        
    UNNotificationRequest *request = response.notification.request;
    UNNotificationContent *conten = request.content;
    NSDictionary *userInfo = conten.userInfo;
    
    if ([request.trigger isKindOfClass:[UNPushNotificationTrigger class]]) {
        NSLog(@"點(diǎn)擊了遠(yuǎn)程通知");
    }else {
        NSLog(@"點(diǎn)擊了本地通知");
    }
    NSLog(@"title:%@, subtitle:%@, body:%@, categoryIdentifier:%@, sound:%@, badge:%@, userInfo:%@, actionIdentifier:%@", conten.title, conten.subtitle, conten.body, conten.categoryIdentifier, conten.sound, conten.badge, userInfo, response.actionIdentifier);
    
    completionHandler();
}

請(qǐng)求權(quán)限

用戶有可能在任何時(shí)候修改App的通知權(quán)限, 所以我們有必要在適當(dāng)?shù)臅r(shí)機(jī)查詢通知權(quán)限, 以便做出相應(yīng)處理.
比如說(shuō), 在準(zhǔn)備添加一個(gè)通知的的時(shí)候, 檢查通知權(quán)限, 如已授權(quán)則繼續(xù), 如已拒絕則提示用戶該功能受限不可用.

/// 檢查通知授權(quán)狀態(tài)
/// 由于用戶可隨時(shí)更改通知權(quán)限, 所以需要在設(shè)置通知前檢查權(quán)限
/// @param completion 檢查完成的回調(diào)
- (void)checkNotificationAuthorizationWithCompletion:(void (^) (BOOL granted))completion {
    
    UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
    [center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) {
        switch (settings.authorizationStatus) {
                
            // 未詢問(wèn)
            case UNAuthorizationStatusNotDetermined:
                {
                    // 詢問(wèn)之 (注意options中要列舉要使用到的權(quán)限選項(xiàng), 不然在設(shè)置中將不顯示該權(quán)限選項(xiàng))
                    [center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert + UNAuthorizationOptionSound + UNAuthorizationOptionBadge)
                                          completionHandler:^(BOOL granted, NSError * _Nullable error) {
                        if (granted) {
                            NSLog(@"用戶首次授權(quán)通知");
                            if (completion) completion(YES);
                        }else {
                            NSLog(@"用戶首次拒絕通知");
                            if (completion) completion(NO);
                        }
                    }];
                }
                break;
                
            // 已拒絕
            case UNAuthorizationStatusDenied:
                {
                    NSLog(@"用戶已拒絕通知");
                    if (completion) completion(NO);
                }
                break;
                
            // 已授權(quán)
            case UNAuthorizationStatusAuthorized:
            default:
                {
                    NSLog(@"用戶已授權(quán)通知");
                    if (completion) completion(YES);
                }
                break;
        }
    }];
}

添加通知按鈕

在這里引入兩個(gè)概念: 可操作通知(actionable notifications)和類別(categories).
可操作通知即我們可以在系統(tǒng)默認(rèn)通知(沒有按鈕)上面添加自定義的按鈕, 用于監(jiān)測(cè)和傳遞按鈕事件供App處理.
而這些可操作通知可以是多樣的(比如說(shuō)按鈕數(shù)量不等/相等數(shù)量但具有不同功能), 因此需要類別這個(gè)對(duì)象用于區(qū)分不同的可操作通知.
當(dāng)我們注冊(cè)一個(gè)類別時(shí), 都要指定一個(gè)categoryIdentifier, 這樣當(dāng)一個(gè)通知生成時(shí), 系統(tǒng)首先會(huì)匹配我們自定義的可操作通知的categoryIdentifier, 如果找不到則會(huì)顯示系統(tǒng)默認(rèn)通知.

下面舉個(gè)例子:
同樣在application:didFinishLaunchingWithOptions:中注冊(cè)通知類別:

/// 注冊(cè)通知類別 (可選實(shí)現(xiàn))
/// 不同的類別用于區(qū)別不同的可操作通知(actionable notifications), 不同的可操作通知體現(xiàn)為: 我們可以為其定義一個(gè)或者多個(gè)不同的按鈕
/// 如果實(shí)現(xiàn), 系統(tǒng)首先根據(jù)categoryIdentifier匹配自定義的可操作通知; 如果沒有, 將顯示系統(tǒng)默認(rèn)通知(沒有按鈕).
- (void)setNotificationCategories {
    
    /* 類別1(有一個(gè)按鈕) */
    UNNotificationAction *closeAction = [UNNotificationAction actionWithIdentifier:@"CLOSE_ACTION" title:@"關(guān)閉" options:UNNotificationActionOptionNone];
    UNNotificationCategory *category1 = [UNNotificationCategory categoryWithIdentifier:@"CATEGORY1"
                                                                               actions:@[closeAction]
                                                                     intentIdentifiers:@[]
                                                                               options:UNNotificationCategoryOptionCustomDismissAction];

    /* 類別2(有四個(gè)按鈕) */
    UNNotificationAction *action1 = [UNNotificationAction actionWithIdentifier:@"ACTION1" title:@"按鈕1" options:UNNotificationActionOptionNone];
    UNNotificationAction *action2 = [UNNotificationAction actionWithIdentifier:@"ACTION2" title:@"按鈕2" options:UNNotificationActionOptionNone];
    UNNotificationAction *action3 = [UNNotificationAction actionWithIdentifier:@"ACTION3" title:@"按鈕3" options:UNNotificationActionOptionNone];
    UNNotificationAction *action4 = [UNNotificationAction actionWithIdentifier:@"ACTION4" title:@"按鈕4" options:UNNotificationActionOptionNone];
    UNNotificationCategory *category2 = [UNNotificationCategory categoryWithIdentifier:@"CATEGORY2"
                                                                               actions:@[action1, action2, action3, action4]
                                                                     intentIdentifiers:@[]
                                                                               options:UNNotificationCategoryOptionCustomDismissAction];

    // 注冊(cè)上面這2個(gè)通知類別
    UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
    [center setNotificationCategories:[NSSet setWithObjects:category1, category2, nil]];
}

這就算注冊(cè)好了, 那么什么時(shí)候會(huì)調(diào)用我們自定義注冊(cè)的通知呢?
當(dāng)我們?cè)O(shè)置一個(gè)通知并在添加該通知到用戶通知中心之前, 要設(shè)置對(duì)應(yīng)的categoryIdentifier, 這樣當(dāng)通知被觸發(fā)時(shí), 系統(tǒng)首先去查找我們注冊(cè)的通知.
設(shè)置通知這部分代碼在下小節(jié)的本地通知里, 為節(jié)省篇幅和避免啰嗦, 這里先不貼出來(lái).

自定義警報(bào)聲音

自定義報(bào)警聲音由系統(tǒng)聲音設(shè)備播放, 因此只支持一下音頻編碼格式:

  • Linear PCM
  • MA4 (IMA/ADPCM)
  • μLaw
  • aLaw

這些音頻可以封裝成aiff,wav,caf,mp3等音頻封裝格式的文件. 對(duì)了, 還有時(shí)長(zhǎng)的要求, 不能超過(guò)30秒, 某則會(huì)被系統(tǒng)打回原形——默認(rèn)聲音.
將音頻文件放入App Bundle或者沙盒的Library/Sounds文件夾下, 然后在新添加通知的UNMutableNotificationContent添加sound屬性:

UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init];
content.sound = [UNNotificationSound soundNamed:@"123.mp3"];
如果App在前臺(tái)接收通知, 不要忘了在userNotificationCenter:willPresentNotification:withCompletionHandler:里回調(diào)添加UNNotificationPresentationOptionSound
completionHandler(UNNotificationPresentationOptionAlert + UNNotificationPresentationOptionSound);

PS: 免費(fèi)音效下載https://www.bangongziyuan.com/music/

管理已發(fā)送的通知

當(dāng)App或用戶未直接處理本地和遠(yuǎn)程通知時(shí), 它們會(huì)顯示在“通知中心”中, 以便以后查看. 使用getDeliveredNotificationsWithCompletionHandler:方法來(lái)獲取仍在通知中心顯示的通知列表. 如果發(fā)現(xiàn)已經(jīng)過(guò)時(shí)且不應(yīng)顯示給用戶的任何通知, 則可以使用removeDeliveredNotificationsWithIdentifiers:方法將其刪除.

三. 本地通知

例如, 設(shè)置一個(gè)本地鬧鐘通知, 基本流程如下:

  1. 設(shè)置通知代理, 實(shí)現(xiàn)代理方法
  2. 注冊(cè)自定義可操作通知 (可選項(xiàng))
  3. 生成一個(gè)通知前檢查通知權(quán)限
  4. 生成通知并配置相關(guān)選項(xiàng)(通知內(nèi)容/觸發(fā)條件/categoryIdentifier等), 添加到通知中心
  5. 通知觸發(fā)時(shí), 如果App在前臺(tái)運(yùn)行, 在代理方法userNotificationCenter:willPresentNotification:withCompletionHandler:里做相應(yīng)處理
  6. 用戶點(diǎn)擊可操作通知中的按鈕時(shí), 在代理方法userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:里做相應(yīng)處理

這些流程在上小節(jié)通知的管理和配置中已經(jīng)講得差不多了, 只剩下在合適的時(shí)機(jī)生成本地通知并添加到用戶通知中心中:

// 本地通知
- (IBAction)localNotificationAction:(UIButton *)sender {
    
    // 檢查通知授權(quán)狀態(tài)
    __weak typeof(self) weakSelf = self;
    [self checkNotificationAuthorizationWithCompletion:^(BOOL granted) {
        if (granted) {
            // 設(shè)置一個(gè)基于時(shí)間的本地通知
            [weakSelf setLocalNotification];
        }else {
            [weakSelf showAlertWithTitle:nil message:@"請(qǐng)于設(shè)置中開啟App的通知權(quán)限" delay:2.0];
        }
    }];
}


// 基于時(shí)間的本地通知
- (void)setLocalNotification {

    // 設(shè)置顯示內(nèi)容
    UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init];
    // 使用localizedUserNotificationStringForKey:arguments:獲取本地化后的字符串
    content.title = [NSString localizedUserNotificationStringForKey:@"title" arguments:nil];
    content.subtitle = [NSString localizedUserNotificationStringForKey:@"subtitle" arguments:nil];
    content.body = [NSString localizedUserNotificationStringForKey:@"body" arguments:nil];
    content.categoryIdentifier = @"CATEGORY2";  // 注釋這行則顯示系統(tǒng)通知樣式
    content.sound = [UNNotificationSound soundNamed:@"123.mp3"];    // 聲音
    content.badge = @(1);   // 徽章數(shù)字
    // 附件
    NSString *imagePath = [[NSBundle mainBundle] pathForResource:@"apple" ofType:@"png"];
    UNNotificationAttachment *attachment = [UNNotificationAttachment attachmentWithIdentifier:@"image" URL:[NSURL fileURLWithPath:imagePath] options:nil error:nil];
    content.attachments = @[attachment];
    
    // 設(shè)置觸發(fā)時(shí)間
    NSDateComponents* date = [[NSDateComponents alloc] init];
    date.hour = 13;
    date.minute = 51;
    UNCalendarNotificationTrigger* trigger = [UNCalendarNotificationTrigger triggerWithDateMatchingComponents:date repeats:NO];
     
    // 根據(jù)內(nèi)容和觸發(fā)條件生成一個(gè)通知請(qǐng)求
    UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:@"KKAlarmID" content:content trigger:nil];    // trigger為nil則立即觸發(fā)通知

    // 將該請(qǐng)求添加到用戶通知中心
    __weak typeof(self) weakSelf = self;
    UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
    [center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) {
       if (error != nil) {
           NSLog(@"%@", error.localizedDescription);
       }else {
           [weakSelf showAlertWithTitle:nil message:@"設(shè)置本地通知成功" delay:2.0];
       }
    }];
}

// 提示框
- (void)showAlertWithTitle:(NSString *)title message:(NSString *)message delay:(float)delay {
    
    dispatch_async(dispatch_get_main_queue(), ^{
        UIAlertController *alertC = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert];
        [self presentViewController:alertC animated:YES completion:nil];
        // delay 2s
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay*NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            [alertC dismissViewControllerAnimated:YES completion:nil];
        });
    });
}
local notifacation.png

Demo鏈接位于文末.

四. 遠(yuǎn)程通知 (使用APNs)

原理

remote_notif_simple.png

與本地通知不同, 遠(yuǎn)程通知是由遠(yuǎn)程觸發(fā)并傳遞的. 如圖, 通知由我們自己提供的服務(wù)器(provider)觸發(fā), 然后通過(guò)蘋果推送通知服務(wù)(APNs)傳遞給iOS設(shè)備, 最后到達(dá)App.
那么, 我們的provider是怎樣知道傳達(dá)給哪一臺(tái)iOS設(shè)備(或者說(shuō)哪一個(gè)App)的呢? 答案是device token.

device token
蘋果沒有明確給出device token的定義(至少我沒找到), 但我們可以這樣理解: device token相當(dāng)于App在APNs中的一個(gè)具體地址.
所以, 只要provider告訴APNs一個(gè)device token, 那么就可以準(zhǔn)確地把通知傳達(dá)給指定設(shè)備(或App).
需要注意的是, device token是可能變化的, 比如說(shuō)刪除重裝App/升級(jí)系統(tǒng)等等都會(huì)使token發(fā)生變化. 當(dāng)我們的token發(fā)生改變時(shí), 我們得想辦法把新的token傳遞給provider, 不然就收不到通知啦.

網(wǎng)上有一張圖, 比較完整地詮釋這個(gè)過(guò)程(找不到出處了…):


pushNotification.jpg

對(duì)此圖稍作說(shuō)明:

  1. App向iOS系統(tǒng)發(fā)起注冊(cè)遠(yuǎn)程通知請(qǐng)求 (registerForRemoteNotifications), 由iOS系統(tǒng)向APNs請(qǐng)求device token
  2. APNs生成device token, 然后回傳給系統(tǒng)上的App (application:didRegisterForRemoteNotificationsWithDeviceToken:)
  3. App 把device token傳遞給自己的服務(wù)器
  4. 服務(wù)器將通知 (包括device token和消息體)傳遞給APNs服務(wù)器
  5. APNs根據(jù)device token把消息體傳達(dá)給指定設(shè)備和App.

準(zhǔn)備工作

  1. 開發(fā)者賬號(hào)
  2. 真機(jī) (可聯(lián)網(wǎng))
  3. 服務(wù)器 (或者M(jìn)ac本地模擬)
  4. APNs AuthKey

The other half of the connection for sending notifications—the persistent, secure channel between a provider server and APNs—requires configuration in your online developer account and the use of Apple-supplied cryptographic certificates.

蘋果文檔明確指出需要開發(fā)者賬號(hào)開啟通知相關(guān)配置. 當(dāng)然, 我們也可在Xcode中登錄賬號(hào)進(jìn)行配置.

device token只支持真機(jī)獲取. 如果使用模擬器, 會(huì)報(bào)錯(cuò)Error Domain=NSCocoaErrorDomain Code=3010 "remote notifications are not supported in the simulator"

Mac本地模擬服務(wù)器, 來(lái)源https://stackoverflow.com/questions/39943701/how-to-send-apns-push-messages-using-apns-auth-key-and-standard-cli-tools

APNs支持兩種方式配置遠(yuǎn)程通知, 一種是使用證書, 一種是使用APNs AuthKey. 證書方式很是麻煩且已過(guò)時(shí), 故本文討論Apple于2016年新推出的AuthKey方式.

流程

1. 開啟推送通知功能

TARGETS —> Singing & Capabilities —> + —> Push Notification

Xcode_Config.png
2. 生成APNs AuthKey

登錄開發(fā)者賬號(hào), keys, +

keys.png

起個(gè)名, 選APNs
APNs.png

生成后, 下載AuthKey.p8文件并保存好, 注意, 只能下載一次.
記下Key ID, 等下用到(當(dāng)然用到的時(shí)候再點(diǎn)進(jìn)去獲取也是可以的).

3. 代碼部分

application:didFinishLaunchingWithOptions:中注冊(cè)遠(yuǎn)程通知, checkNotificationAuthorizationWithCompletion:方法前面已有貼出.

/// 注冊(cè)遠(yuǎn)程通知 (獲取設(shè)備令牌)
/// 如果手機(jī)可聯(lián)網(wǎng), 將回調(diào)
/// 成功 application:didRegisterForRemoteNotificationsWithDeviceToken:
/// 失敗 application:didFailToRegisterForRemoteNotificationsWithError:
- (void)registerRemoteNotifications {
    
    // 檢查權(quán)限
    [KKAuthorizationTool checkNotificationAuthorizationWithCompletion:^(BOOL granted) {
        if (granted) {
            dispatch_async(dispatch_get_main_queue(), ^{
                [[UIApplication sharedApplication] registerForRemoteNotifications];
            });
        }
    }];
}

當(dāng)手機(jī)網(wǎng)絡(luò)可用, 即可獲得回調(diào):

/// 注冊(cè)遠(yuǎn)程通知 成功
/// @param application App
/// @param deviceToken 設(shè)備令牌
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
    
    NSString *deviceTokenStr = [self deviceTokenStrWithDeviceToken:deviceToken];
    
    NSLog(@"注冊(cè)遠(yuǎn)程通知 成功 deviceToken:%@, deviceTokenStr:%@", deviceToken, deviceTokenStr);
}


/// 注冊(cè)遠(yuǎn)程通知 失敗
/// @param application App
/// @param error 錯(cuò)誤信息
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(nonnull NSError *)error {
    
    NSLog(@"注冊(cè)遠(yuǎn)程通知 失敗 error:%@", error);
}


// 將deviceToken轉(zhuǎn)換成字符串
- (NSString *)deviceTokenStrWithDeviceToken:(NSData *)deviceToken {

    NSString *tokenStr;
    
    if (deviceToken) {
        if ([[deviceToken description] containsString:@"length = "]) {  // iOS 13 DeviceToken 適配。
            NSMutableString *deviceTokenString = [NSMutableString string];
            const char *bytes = deviceToken.bytes;
            NSInteger count = deviceToken.length;
            for (int i = 0; i < count; i++) {
                [deviceTokenString appendFormat:@"%02x", bytes[i]&0x000000FF];
            }
            tokenStr = [NSString stringWithString:deviceTokenString];
        }else {
            tokenStr = [[[[deviceToken description]stringByReplacingOccurrencesOfString:@"<" withString:@""]stringByReplacingOccurrencesOfString:@">" withString:@""]stringByReplacingOccurrencesOfString:@" " withString:@""];
        }
    }
    
    return tokenStr;
}

這里順便提一下, 因?yàn)樯婕暗紸pp聯(lián)網(wǎng), 國(guó)行版第一次運(yùn)行App時(shí)需要獲取網(wǎng)絡(luò)權(quán)限. 那么這時(shí)候去做一下請(qǐng)求網(wǎng)絡(luò)的動(dòng)作, 才會(huì)彈出網(wǎng)絡(luò)權(quán)限提示框, 不然連不了網(wǎng), 在設(shè)置里也沒有聯(lián)網(wǎng)選項(xiàng).
所以, 在所有操作之前, 在App加載完成時(shí)添加如下方法:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
        
    // 檢查網(wǎng)絡(luò)
    [self checkNetword];
    
    // 設(shè)置通知代理
    [self setNotificationDelegate];
    // 注冊(cè)通知類別 (可選實(shí)現(xiàn))
    [self setNotificationCategories];
    // 注冊(cè)遠(yuǎn)程通知 (獲取設(shè)備令牌)
    [self registerRemoteNotifications];
  
    return YES;
}


// 檢查聯(lián)網(wǎng)狀態(tài) (為了使國(guó)行手機(jī)在第一次運(yùn)行App時(shí)彈出網(wǎng)絡(luò)權(quán)限彈框, 故需要請(qǐng)求網(wǎng)絡(luò)連接)
- (void)checkNetword {
    
    NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:@"https://www.baidu.com"] cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:3];
    NSURLSession *session = [NSURLSession sharedSession];
    NSURLSessionDataTask *task = [session dataTaskWithRequest:request];
    [task resume];
}

至此, 代碼部分完畢.

4. 模擬服務(wù)器發(fā)送通知

https://stackoverflow.com/questions/39943701/how-to-send-apns-push-messages-using-apns-auth-key-and-standard-cli-tools

在地址中拷貝得到如下Python代碼:

#!/bin/bash

deviceToken=a016e229f8fa4dXXXXXXXXXXXXXXXXXXXXXXXXXXXXXf4701a108e86

authKey="/Users/kang/Desktop/AuthKey_C2L2B33XXX.p8"    # p8在Mac上的位置
authKeyId=C2L2B33XXX    # 開發(fā)者網(wǎng)站 -> keys -> 點(diǎn)擊剛才建立的AuthKey -> Key ID
teamId=PTLCDC9XXX       # 開發(fā)者網(wǎng)站 -> Membership -> Team ID
bundleId=com.Kang.KKNotificationDemo
endpoint=https://api.development.push.apple.com

# 注意: 在 payload里 不能加任何注釋, 否則將會(huì)導(dǎo)致數(shù)據(jù)錯(cuò)誤進(jìn)而通知失敗
read -r -d '' payload <<-'EOF'
{
   "aps": {
      "badge": 2,
      "category": "mycategory",
      "alert": {
         "title": "my title",
         "subtitle": "my subtitle",
         "body": "my body text message"
      }
   },
   "custom": {
      "mykey": "myvalue"
   }
}
EOF

# --------------------------------------------------------------------------

base64() {
   openssl base64 -e -A | tr -- '+/' '-_' | tr -d =
}

sign() {
   printf "$1" | openssl dgst -binary -sha256 -sign "$authKey" | base64
}

time=$(date +%s)
header=$(printf '{ "alg": "ES256", "kid": "%s" }' "$authKeyId" | base64)
claims=$(printf '{ "iss": "%s", "iat": %d }' "$teamId" "$time" | base64)
jwt="$header.$claims.$(sign $header.$claims)"

curl --verbose \
   --header "content-type: application/json" \
   --header "authorization: bearer $jwt" \
   --header "apns-topic: $bundleId" \
   --data "$payload" \
   $endpoint/3/device/$deviceToken

修改deviceToken, authKey, authKeyId, teamId, bundleId然后保存為.py文件, 先運(yùn)行Demo注冊(cè)監(jiān)聽, 再在終端運(yùn)行py, 順利的話, 就可以看到推送啦!
py代碼中"category": "mycategory"這里category如果改成我們自定義注冊(cè)的CATEGORY2, 下拉通知就會(huì)看到我們那四個(gè)按鈕, 也可加入字段"sound" : "xxx.aiff"播放自定義聲音等等. 關(guān)于修改通知內(nèi)容和顯示界面, 詳見下節(jié).

APNs_Demo.png

五. 修改通知內(nèi)容和顯示界面

接下來(lái)介紹通知的兩種擴(kuò)展: UNNotificationServiceExtensionUNNotificationContentExtension

蘋果文檔
You can modify the content or presentation of arriving notifications using app extensions. To modify the content of a remote notification before it is delivered, use a notification service app extension. To change how the notification’s content is presented onscreen, use a notification content app extension.
您可以使用App擴(kuò)展來(lái)修改通知內(nèi)容或其顯示方式识虚。要在傳遞遠(yuǎn)程通知之前修改其內(nèi)容肢扯,請(qǐng)使用UNNotificationServiceExtension擴(kuò)展。要更改通知內(nèi)容在屏幕上的顯示方式担锤,請(qǐng)使用UNNotificationContentExtension擴(kuò)展蔚晨。

這個(gè)擴(kuò)展用來(lái)修改遠(yuǎn)程通知的內(nèi)容, 比如修改title, 語(yǔ)言本地化, 解密信息, 加載附件等等.
如果是本地通知, 直接在設(shè)置通知內(nèi)容UNMutableNotificationContent的時(shí)候設(shè)定好就行了.

1. 創(chuàng)建以及注意事項(xiàng) (重要)

這兩個(gè)擴(kuò)展創(chuàng)建過(guò)程相似, 故放在一起討論.

新建target

new_target

分別創(chuàng)建service和content的擴(kuò)展

service_content

注意! 因?yàn)橐婚_始創(chuàng)建target, 系統(tǒng)默認(rèn)是從最高iOS版本支持的, 所以我們得分別將兩個(gè)擴(kuò)展target的支持版本調(diào)到iOS 10.0, 不然當(dāng)你收不到遠(yuǎn)程通知的時(shí)候, 你會(huì)開始各種baidu/google, 但是最終都不得其姐.

target_version.png

注意! 給這兩個(gè)擴(kuò)展都添加推送通知的功能

capabilities

最終左邊欄新增如下擴(kuò)展代碼

extension_catalogue

注意! 在調(diào)試的時(shí)候, 我們需要切換對(duì)應(yīng)的target才會(huì)走斷點(diǎn)和打印log.

target_change.png

2. UNNotificationServiceExtension

首先我們修改Python測(cè)試文件, 添加media字段

# 注意: 在 payload里 不能加任何注釋, 否則將會(huì)導(dǎo)致數(shù)據(jù)錯(cuò)誤進(jìn)而通知失敗. 還有, 最后一個(gè)鍵值對(duì)可加可不加逗號(hào).
# "media":{"type":"video", "url":"http://vfx.mtime.cn/Video/2019/02/04/mp4/190204084208765161.mp4"}
# "media":{"type":"image", "url":"https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png"}
read -r -d '' payload <<-'EOF'
{
    "aps" : {
        "category" : "CATEGORY",
        "mutable-content" : 1,
        "alert" : {
            "title" : "KK title",
            "subtitle" : "KK subtitle",
            "body"  : "KK body"
        }
    },
    "media" : {
        "type" : "video",
        "url" : "http://vfx.mtime.cn/Video/2019/02/04/mp4/190204084208765161.mp4"
    }
}
EOF

由于用到http協(xié)議, 所以我們還得在service的擴(kuò)展里的info.plist添加App Transport Security Settings, 然后設(shè)置Allow Arbitrary Loads為YES:

http.png

對(duì)了, 順便提一下附件內(nèi)容的支持, 摘自UNNotificationAttachment

attachment.png

然后直接擼NotificationService.m的代碼:

#import "NotificationService.h"

@interface NotificationService ()

@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;

@end

@implementation NotificationService

// 收到通知
// 在這進(jìn)行內(nèi)容修改, 比如修改title, 語(yǔ)言本地化, 解密信息, 加載附件等等
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
    self.contentHandler = contentHandler;
    self.bestAttemptContent = [request.content mutableCopy];

    // 修改標(biāo)題
    NSString *title = self.bestAttemptContent.title;
    self.bestAttemptContent.title = [NSString stringWithFormat:@"%@ [modified]", title];
    NSLog(@"%s 原標(biāo)題:%@, 修改后:%@", __func__, title, self.bestAttemptContent.title);
    
    // 下載附件
    NSDictionary *dict =  self.bestAttemptContent.userInfo;
    NSString *mediaType = dict[@"media"][@"type"];
    NSString *mediaUrl = dict[@"media"][@"url"];
    [self loadAttachmentForUrlString:mediaUrl mediaType:mediaType completionHandle:^(UNNotificationAttachment *attachment) {
        if (attachment) {
            self.bestAttemptContent.attachments = @[attachment];
        }
        // 回調(diào), 如果類別是自定義的, 將會(huì)轉(zhuǎn)到content extension
        self.contentHandler(self.bestAttemptContent);
    }];
}


// 修改超時(shí)
// 系統(tǒng)提供大約30秒的時(shí)間供內(nèi)容修改, 如果到期還沒調(diào)用contentHandler, 則將會(huì)強(qiáng)制終止, 在此方法作最后一次修改
- (void)serviceExtensionTimeWillExpire {
    // Called just before the extension will be terminated by the system.
    // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
    NSLog(@"%s 超時(shí)", __func__);
    self.contentHandler(self.bestAttemptContent);
}


#pragma mark - private

// 下載附件
- (void)loadAttachmentForUrlString:(NSString *)urlStr
                         mediaType:(NSString *)type
                  completionHandle:(void(^)(UNNotificationAttachment *attachment))completionHandler {
    NSLog(@"%s 開始下載附件 urlStr:%@", __func__, urlStr);
    
    __block UNNotificationAttachment *attachment = nil;
    
    NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]];
    NSURL *URL = [NSURL URLWithString:urlStr];
    [[session downloadTaskWithURL:URL completionHandler:^(NSURL *temporaryFileLocation, NSURLResponse *response, NSError *error) {
        NSLog(@"%s 下載附件結(jié)束", __func__);
        if (error != nil) {
            NSLog(@"error:%@", error.localizedDescription);
        } else {
            // 下載過(guò)程中原來(lái)的擴(kuò)展名變成了tmp,所以我們需替換成原先的擴(kuò)展名
            NSFileManager *fileManager = [NSFileManager defaultManager];
            NSString *path = [temporaryFileLocation.path stringByDeletingPathExtension];    // 去掉.tmp后綴名 (包括.)
            NSString *fileExt = [urlStr pathExtension];                                     // 原先的后綴名 (不包括.)
            NSURL *localURL = [NSURL fileURLWithPath:[path stringByAppendingPathExtension:fileExt]]; // 最終后綴名 (包括.)
            [fileManager moveItemAtURL:temporaryFileLocation toURL:localURL error:&error];  // 替換
            // 附件內(nèi)容
            NSError *attachmentError = nil;
            attachment = [UNNotificationAttachment attachmentWithIdentifier:@"" URL:localURL options:nil error:&attachmentError];
            if (attachmentError) {
                NSLog(@"error:%@", attachmentError.localizedDescription);
            }
            // 如果是圖片類型, 傳遞給content擴(kuò)展程序來(lái)顯示
            if ([type isEqualToString:@"image"]) {
                NSData *imageData = [NSData dataWithContentsOfURL:localURL];
                NSDictionary *userInfo = @{@"imageData" : imageData};
                self.bestAttemptContent.userInfo = userInfo;
            }
        }
        completionHandler(attachment);
    }] resume];
}


@end

此例中我們用的類別是CATEGORY, 系統(tǒng)遍歷我們自定義注冊(cè)的類別, 沒有找到匹配的, 最終顯示系統(tǒng)默認(rèn)通知. 我們可以看到推送標(biāo)題和縮略圖, 下拉看到播放按鈕, 可播放之.

push_pullDown_play.png

3. UNNotificationContentExtension

UI.png

蘋果文檔
When an iOS device receives a notification containing an alert, the system displays the contents of the alert in two stages. Initially, it displays an abbreviated banner with the title, subtitle, and two to four lines of body text from the notification. If the user presses the abbreviated banner, iOS displays the full notification interface, including any notification-related actions. The system provides the interface for the abbreviated banner, but you can customize the full interface using a notification content app extension.
當(dāng)iOS設(shè)備收到包含警報(bào)的通知時(shí),系統(tǒng)分兩個(gè)階段顯示警報(bào)的內(nèi)容肛循。最初铭腕,它顯示帶有標(biāo)題,副標(biāo)題和通知中兩到四行正文的縮寫橫幅多糠。如果用戶按下縮寫橫幅累舷,則iOS將顯示完整的通知界面,包括所有與通知相關(guān)的操作夹孔。系統(tǒng)提供了縮寫橫幅的界面被盈,但是您可以使用UNNotificationContentExtension擴(kuò)展程序自定義完整界面。

也就是說(shuō), 通知界面分兩個(gè)階段: Default UI和Custom UI. 一開始彈出的是Default UI, 這個(gè)由系統(tǒng)設(shè)計(jì), 我們不能修改 (但是可以設(shè)置隱藏); 下拉后, 顯示Default UI (完整界面), 我們可以使用UNNotificationContentExtension來(lái)設(shè)計(jì)這部分.

但是, 有個(gè)問(wèn)題, 我們自己設(shè)計(jì)的這個(gè)界面不能顯示視頻, 只能顯示圖片. 當(dāng)然, 也可能是我沒找到方法...
所以我只能說(shuō), 下雨天, service搭配content, 效果更佳.

圖片當(dāng)然也是在service擴(kuò)展里加載好的, 然后通過(guò)調(diào)用回調(diào)傳過(guò)來(lái)顯示. 蘋果文檔也說(shuō)了, 不要在content擴(kuò)展里做類似請(qǐng)求網(wǎng)絡(luò)這種耗時(shí)操作.

Don’t perform any long-running tasks, like trying to retrieve data over the network.

server擴(kuò)展中, 我們下載好圖片文件后, 需要傳遞給content擴(kuò)展程序來(lái)顯示:

// 如果是圖片類型, 傳遞給content擴(kuò)展程序來(lái)顯示
if ([type isEqualToString:@"image"]) {
    NSData *imageData = [NSData dataWithContentsOfURL:localURL];
    NSDictionary *userInfo = @{@"imageData" : imageData};
    self.bestAttemptContent.userInfo = userInfo;
}

Python測(cè)試文件中, 將附件改為圖片

# 注意: 在 payload里 不能加任何注釋, 否則將會(huì)導(dǎo)致數(shù)據(jù)錯(cuò)誤進(jìn)而通知失敗. 還有, 最后一個(gè)鍵值對(duì)可加可不加逗號(hào).
# "media":{"type":"video", "url":"http://vfx.mtime.cn/Video/2019/02/04/mp4/190204084208765161.mp4"}
# "media":{"type":"image", "url":"https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png"}
read -r -d '' payload <<-'EOF'
{
    "aps" : {
        "category" : "CATEGORY3",
        "mutable-content" : 1,
        "alert" : {
            "title" : "KK title",
            "subtitle" : "KK subtitle",
            "body"  : "KK body"
        }
    },
    "media" : {
        "type" : "image",
        "url" : "https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png"
    }
}
EOF

content擴(kuò)展中info.plist的一些設(shè)置:

content_info

設(shè)置UNNotificationExtensionCategory的值相當(dāng)于向系統(tǒng)注冊(cè)了這個(gè)通知類別, 當(dāng)通知推送過(guò)來(lái)時(shí), 系統(tǒng)會(huì)匹配Jason文件中"aps"字典中"category"對(duì)應(yīng)的值. 這里設(shè)置了CATEGORY3, 所以Python文件中編輯Jason文件(payload)的時(shí)候, 其"category" : "CATEGORY3".
其他屬性值在UNNotificationContentExtension找吧??.

OK, 快結(jié)束了.
下面進(jìn)入自定義布局content擴(kuò)展部分. 使用MainInterface.storyboard來(lái)布局:

mainInterface.png

NotificationViewController.m

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

@interface NotificationViewController () <UNNotificationContentExtension>

@property IBOutlet UILabel *label;
@property (weak, nonatomic) IBOutlet UILabel *subLabel;
@property (weak, nonatomic) IBOutlet UIImageView *imageView;
@property (weak, nonatomic) IBOutlet UILabel *bodyLabel;

@end

@implementation NotificationViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any required interface initialization here.
    NSLog(@"%s", __func__);
}

- (void)didReceiveNotification:(UNNotification *)notification {
    NSLog(@"%s", __func__);
    
    self.label.text = notification.request.content.title;
    self.subLabel.text = notification.request.content.subtitle;
    self.bodyLabel.text = notification.request.content.body;
    
    // 如果附件是圖片, 顯示之
    NSDictionary *dict =  notification.request.content.userInfo;
    if (dict.count) {
        NSData *imageData = dict[@"imageData"];
        UIImage *image = [UIImage imageWithData:imageData];
        self.imageView.image = image;
    }
}

@end
content_demo.png

demo地址

KKNotificationDemo

參考文檔

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末搭伤,一起剝皮案震驚了整個(gè)濱河市只怎,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌怜俐,老刑警劉巖身堡,帶你破解...
    沈念sama閱讀 217,185評(píng)論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異拍鲤,居然都是意外死亡贴谎,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,652評(píng)論 3 393
  • 文/潘曉璐 我一進(jìn)店門殿漠,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)赴精,“玉大人,你說(shuō)我怎么就攤上這事绞幌±儆矗” “怎么了?”我有些...
    開封第一講書人閱讀 163,524評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵莲蜘,是天一觀的道長(zhǎng)谭确。 經(jīng)常有香客問(wèn)我,道長(zhǎng)票渠,這世上最難降的妖魔是什么逐哈? 我笑而不...
    開封第一講書人閱讀 58,339評(píng)論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮问顷,結(jié)果婚禮上昂秃,老公的妹妹穿的比我還像新娘禀梳。我一直安慰自己,他們只是感情好肠骆,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,387評(píng)論 6 391
  • 文/花漫 我一把揭開白布算途。 她就那樣靜靜地躺著,像睡著了一般蚀腿。 火紅的嫁衣襯著肌膚如雪嘴瓤。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,287評(píng)論 1 301
  • 那天莉钙,我揣著相機(jī)與錄音廓脆,去河邊找鬼。 笑死磁玉,一個(gè)胖子當(dāng)著我的面吹牛停忿,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播蚊伞,決...
    沈念sama閱讀 40,130評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼瞎嬉,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了厚柳?” 一聲冷哼從身側(cè)響起氧枣,我...
    開封第一講書人閱讀 38,985評(píng)論 0 275
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎别垮,沒想到半個(gè)月后便监,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,420評(píng)論 1 313
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡碳想,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,617評(píng)論 3 334
  • 正文 我和宋清朗相戀三年烧董,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片胧奔。...
    茶點(diǎn)故事閱讀 39,779評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡逊移,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出龙填,到底是詐尸還是另有隱情胳泉,我是刑警寧澤,帶...
    沈念sama閱讀 35,477評(píng)論 5 345
  • 正文 年R本政府宣布岩遗,位于F島的核電站扇商,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏宿礁。R本人自食惡果不足惜案铺,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,088評(píng)論 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望梆靖。 院中可真熱鬧控汉,春花似錦笔诵、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,716評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至壁酬,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間恨课,已是汗流浹背舆乔。 一陣腳步聲響...
    開封第一講書人閱讀 32,857評(píng)論 1 269
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留剂公,地道東北人希俩。 一個(gè)月前我還...
    沈念sama閱讀 47,876評(píng)論 2 370
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像纲辽,于是被迫代替她去往敵國(guó)和親颜武。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,700評(píng)論 2 354