iOS推送小结

文章的Demo地址:iOS-Push
Demo中的推送测试可以使用类似 Easy APNs Provider的工具,结合自己的证书进行测试。

00WX20170713-095411.png

1.普通推送基本设置

1.1 创建项目,开启远程推送功能

在Cababilities中打开Push Notification开关


PushNotificationSwitch
PushNotificationSwitch

1.2 编码

注册通知

#if __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_10_0
#import <UserNotifications/UserNotifications.h>
#endif

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    CGFloat sysVersion = [UIDevice currentDevice].systemVersion.floatValue;
    if (sysVersion >= 10.0) {
    UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
    center.delegate = self;
    [center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) {
        UNAuthorizationStatus status = settings.authorizationStatus;
        if (status == UNAuthorizationStatusNotDetermined) {
        UNAuthorizationOptions options = UNAuthorizationOptionBadge | UNAuthorizationOptionAlert | UNAuthorizationOptionSound;
        [center requestAuthorizationWithOptions:options completionHandler:^(BOOL granted, NSError * _Nullable error) {
            if (granted) {
            NSLog(@"Auth suc");
            [application registerForRemoteNotifications];
            } else {
            NSLog(@"Auth fail:%@",error.localizedDescription);
            }
        }];
        }
        else if (status == UNAuthorizationStatusDenied) {
        NSLog(@"用户关闭了通知,请求用户跳转设置开启通知");
        [application openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString]];
        }
        else {
        NSLog(@"已经开启了通知");
        NSLog(@"Auth settings:%@",settings);
        [application registerForRemoteNotifications];
        }
    }];
    }
    else if (sysVersion >= 8.0) {
    UIUserNotificationType type = UIUserNotificationTypeAlert|UIUserNotificationTypeBadge|UIUserNotificationTypeSound;
    UIUserNotificationSettings *settings = [UIUserNotificationSettings settingsForTypes:type categories:nil];
    [application registerUserNotificationSettings:settings];
    }
    else {
    [application registerForRemoteNotificationTypes:UIRemoteNotificationTypeBadge | UIRemoteNotificationTypeAlert | UIRemoteNotificationTypeSound];
    }



    return YES;
}
- (void)application:(UIApplication *)application didRegisterUserNotificationSettings:(UIUserNotificationSettings *)notificationSettings {
    // 此代理方法iOS8及以上会调用,iOS10 使用UNNotification.framewrok不会调用
    [application registerForRemoteNotifications];
}

注册通知失败

- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {
  // 处理注册通知失败
}

获取token

- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
  // 上报token给服务端
}

接收通知

- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo {
 // 收到通知
}

到此,初步完成了推送功能,后端可以使用客户端上报的token给客户端推送消息了。
此时,客户端接收推送的情况是:

1. 客户端在前台运行,屏幕/通知中心不会出现推送Banner,程序会执行`application:didReceiveRemoteNotification:` 方法
2. 客户端不在前台,屏幕/通知中心出现推送Banner,程序不执行`application:didReceiveRemoteNotification:` 方法

此时,点击推送启动App的情况是:
1. application:didFinishLaunchingWithOptions:launchOptions中会包含UIApplicationLaunchOptionsRemoteNotificationKey,内容是通知的UserInfo
2. application:didReceiveRemoteNotification: 在启动过程中不会被调用

</br>

2. 静默推送

有一些场景下,我们希望App在后台收到推送时,能知道收到了推送,并做出一些反应(比如UI上的变动)。这就需要开启静默推送。

<h3 id="2.1">2.1 工程配置</h3>
在Cababilities中打开Background Modes的Remote Notifications(静默推送),Info中会有对应的KeyValue自动添加。


BackgroundMode
BackgroundMode

<h4 id="2.2">2.2 编码</h4>

实现后台获取的对应方法

/*! This delegate method offers an opportunity for applications with the "remote-notification" background mode to fetch appropriate new data in response to an incoming remote notification. You should call the fetchCompletionHandler as soon as you're finished performing that operation, so the system can accurately estimate its power and data cost.
 
 This method will be invoked even if the application was launched or resumed because of the remote notification. The respective delegate methods will be invoked first. Note that this behavior is in contrast to application:didReceiveRemoteNotification:, which is not called in those cases, and which will not be invoked if this method is implemented. !*/
 
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult result))completionHandler {
    // 处理获取到的通知
    ...
    completionHandler(UIBackgroundFetchResultNewData);
}

实现了此方法,则application:didReceiveRemoteNotification: 不会被调用。而且这个方法在App因为通知启动或者resumed的时候也会被调用。

2.3 推送内容设置

{
  "aps" : {
    "alert" : {
      "title" : "Message",
      "body" : "Your message Here"
    },
    "badge" : 1,
    "content-available" : 1
  }
}

aps 字段中需要包含有"content-available" : 1,否则App在后台无法感知收到推送,也就是上面的方法application:didReceiveRemoteNotification:fetchCompletionHandler:不会调用

完成以上,程序可以在后台通过上面的方法获取到通知的内容了。

3 前台展示推送

以上,代码中并没有实现UNUserNotificationCenterDelegate 协议中的方法。当我们实现协议中userNotificationCenter:willPresentNotification:withCompletionHandler: 方法时,程序在前台收到推送也会展示Banner。

// 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. This decision should be based on whether the information in the notification is otherwise visible to the user.

-(void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler {
    completionHandler(UNNotificationPresentationOptionBadge|
                      UNNotificationPresentationOptionSound|
                      UNNotificationPresentationOptionAlert);
}

4 Notification Service Extension

iOS 10后新增了Notification Service Extension,开发者可以对推送进行预处理,以展示更丰富的推送内容,比如附加图片,或者根据当前用户来修改推送消息,甚至向支付宝收款码收款那样播放一条语音等。

效果示例:

showImageInNotification
showImageInNotification

4.1 创建Notification Service Extension

在工程中原开发工程中新建一个Target,选择Notification Service Extension ,并根据Xcode提示激活此Target。新Target的Bundle Id应该在原工程Bundle Id的命名空间下,如原工程Bundle Id为com.demo.push,新Target的Bundle Id应为com.demo.push.xxx,如com.demo.push.notificationServiceExtension

CreateServiceExtension
CreateServiceExtension

完成后工程中会生成对应的文件,在.m中有两个方法:
一是对收到的推送进行处理的方法,在这个方法中主要对UNNotificationContent 进行修改,最后必须调用contentHandler 。下面是默认的实现,只是对推送的title 进行了修改。

- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler{
    // Modify the notification content here...
    self.contentHandler = contentHandler;
    self.bestAttemptContent = [request.content mutableCopy];
    self.bestAttemptContent.title = [NSString stringWithFormat:@"%@ [modified]", self.bestAttemptContent.title];
    self.contentHandler(self.bestAttemptContent);
}

为了能在Notification Service Extension中去下载其他附件,我们必须去按照如下的要求去设置推送通知,使推送通知是动态可变的。

{
    aps: {
        alert : {……}
        mutable-content : 1
    }
    my-attachment : https://example.com/example.jpg"
}

必须在aps 中包含mutable-content : 1 的内容,推送才会进入Service Extension中被处理,my-attachment 是自定义字段。这样我们就可以在Notification Service Extension 中,下载my-attachment 中URL的图片,添加到推送内容中再展示。

- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler{
    ...
    // UNNotificationAttachment中的URL为文件URL,形如 file://xxx/xxx/x.png
    [self downloadImageFinished:^(NSURL *fileURL){
        UNNotificationAttachment *atm = [UNNotificationAttachment attachmentWithIdentifier:@"" 
                     URL:url 
                 options:nil error:&error];
        self.bestAttemptContent.attachments = @[atm];
    }];
    ...
}

开发者总共有30秒的时间来对推送内容进行处理,可以在这个过程中下载图片、小视频等。如果超过时间还没有在上面方法中调用contentHandler ,系统会在另一个线程调用下面的方法给开发者最后调用contentHandler 的机会,如果在这个方法中contentHandler还是 没有被调用,推送会以原来的内容被展示到手机上。

- (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.
    self.contentHandler(self.bestAttemptContent);
}

4.2 调试Notification Service Extension

运行的Scheme选择新建的Service Extension,选择关联的App运行,这样断点可以在Service Extension生效。


RunServiceExtension
RunServiceExtension
RunServiceExtensionWithApp
RunServiceExtensionWithApp

4.3 打包

打包时,选择App对应的Scheme即可,与正常打包流程没有差别(CI打包也无差别)。使用Xcode打包过程中可以看到Extension已经被包含在其中:

Archive
Archive

</br>

4.4 支付宝收款码语音推送

  1. 在收到推送时,使用AVFoundation框架内的API读出收款相关的内容:
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
    NSLog(@"%s",__func__);
    self.contentHandler = contentHandler;
    self.bestAttemptContent = [request.content mutableCopy];
    // Modify the notification content here...
    self.bestAttemptContent.title = [NSString stringWithFormat:@"支付宝到账两千元"];
    AVSpeechSynthesizer *speechSynthesizer = [[AVSpeechSynthesizer alloc]init];
    AVSpeechUtterance *utterance = [AVSpeechUtterance speechUtteranceWithString:self.bestAttemptContent.title];
    AVSpeechSynthesisVoice *voice = [AVSpeechSynthesisVoice voiceWithLanguage:@"zh-CN"];
    utterance.voice = voice;
    utterance.rate =  AVSpeechUtteranceDefaultSpeechRate;
    utterance.volume = 1.0;
    [speechSynthesizer speakUtterance:utterance];
    self.contentHandler(self.bestAttemptContent);
}

这种实现的缺点是如果用户手机静音,则不会有任何语音。我们知道AVAudioSession中的AVAudioSessionCategoryPlayback是可以不遵循手机静音的,因此另一种实现可能是在收到的推送内容包含一个语音的URL,然后像播放音乐文件一样播放收款信息:设置AVAudioSessioncategoryAVAudioSessionCategoryPlayback,然后使用类似AVAudioPlayer的工具播放。

两种实现都需要在项目的Cababilities中开启权限:

WX20170815-184013.png

5. Notification Content Extension

Notification Content Extension 是一个定制化展示本地和远程通知的插件,开发者可以自定义其中展示的内容,常常会结合上面的Notification Service Extension插件和UNNotificationCategoryUNNotificationAction 使用做成带有交互的推送内容。

Example
Example

整体流程为:

  1. 注册Notification Category ,其中包含Action.
  2. 推送Mutable-Content的通知,在Service Extension中下载对应的多媒体消息,重新生成通知内容,并指定通知的categoryIdentifier
  3. 用户3D-Touch推送会启动Notification Content Extension,在其中进行通知的定制化展示。
  4. 用户触发交互(即UNNotificationAction)后,在UNUserNotificationCenter 代理方法中进行处理。在Notification Content Extension中也可以进行初步处理,并决定是否将Action转发到UNUserNotificationCenter

整体效果:

Example
Example


Demo推送内容:

{
  "aps" : {
    "alert" : {
      "title" : "Message",
      "body" : "Your message Here"
    },
    "badge" : 1,
    "content-available" : 1,
    "mutable-content" : 1,
    "catId" : "action1" // 自定义字段,
    }
}

</br>

5.1 创建Notification Content Extension

新建一个Target,选择Notification Content Extension,其BundleId应该在原项目BundleId的命名空间下。

CreateContentExtension
CreateContentExtension

创建后会增加Target的文件:


ContentExtensionFiles
ContentExtensionFiles

.h中可以看到其实这是一个UIViewController子类,我们可以添加各种视图。

// NotificationViewController.h
#import <UIKit/UIKit.h>

@interface NotificationViewController : UIViewController

@end
// NotificationViewController.m
@interface NotificationViewController () <UNNotificationContentExtension>

.m中可以看到这个控制器遵守UNNotificationContentExtension协议,协议中有如下方法和属性:

@protocol UNNotificationContentExtension <NSObject>

// This will be called to send the notification to be displayed by
// the extension. If the extension is being displayed and more related
// notifications arrive (eg. more messages for the same conversation)
// the same method will be called for each new notification.
- (void)didReceiveNotification:(UNNotification *)notification;

@optional

// If implemented, the method will be called when the user taps on one
// of the notification actions. The completion handler can be called
// after handling the action to dismiss the notification and forward the
// action to the app if necessary.
- (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.
@property (nonatomic, readonly, assign) UNNotificationContentExtensionMediaPlayPauseButtonType mediaPlayPauseButtonType;

// Implementing this method and returning a non-empty frame will make
// the notification draw a button that allows the user to play and pause
// media content embedded in the notification.
@property (nonatomic, readonly, assign) CGRect mediaPlayPauseButtonFrame;

// The tint color to use for the button.
@property (nonatomic, readonly, copy) UIColor *mediaPlayPauseButtonTintColor;

// Called when the user taps the play or pause button.
- (void)mediaPlay;
- (void)mediaPause;

@end


@interface NSExtensionContext (UNNotificationContentExtension)

// Call these methods when the playback state changes in the content
// extension to update the state of the media control button.
- (void)mediaPlayingStarted __IOS_AVAILABLE(10_0) __TVOS_UNAVAILABLE __WATCHOS_UNAVAILABLE __OSX_UNAVAILABLE;
- (void)mediaPlayingPaused __IOS_AVAILABLE(10_0) __TVOS_UNAVAILABLE __WATCHOS_UNAVAILABLE __OSX_UNAVAILABLE;

@end

除了Require的方法之外,didReceiveNotificationResponse:completionHandler:负责处理推送Action交互,而其他的用来控制视频的播放。下面的示例中会使用到。

最下方还有一个NSExtesnsionContext类,暂时不清楚它怎么使用。

Content Extension的Info.plist中的内容:


Info
Info

UNNotificationExtensionDefaultContentHidden,插件默认会展示推送的内容(Title、subtitle、body,不展示Attachment),通过这对键值来控制是否隐藏原始内容。

UNNotificationExtensionCategory ,值类型可以为String/Array,通知的类别,只有类别ID在此之中的通知才会进入Notification Content Extension中被处理。

UNNotificationExtensionInitialContentSizeRatio , 视图的宽高比。视图的最终大小(主要是高度),会受VC的preferredContentSize 、sb中的约束和视图高度、这个比例3者的影响。优先级从前到后下降。

5.2 编码

首先在申请通知权限成功后,设置通知的类别和Action

UNNotificationAction *action1 = [UNNotificationAction actionWithIdentifier:@"checkoutAction" title:@"查看" options:UNNotificationActionOptionAuthenticationRequired|UNNotificationActionOptionForeground];

UNTextInputNotificationAction *action2 = [UNTextInputNotificationAction actionWithIdentifier:@"replyAction" title:@"回复" options:0 textInputButtonTitle:@"发送" textInputPlaceholder:@"回复消息"];

// 此处categoryIdentifier应该是上面Info.plist中UNNotificationExtensionCategory包含的值
UNNotificationCategory *cat = [UNNotificationCategory categoryWithIdentifier:@"action1" actions:@[action1,action2] intentIdentifiers:@[] options:UNNotificationCategoryOptionNone];
                        
[center setNotificationCategories:[NSSet setWithObjects:cat, nil]];
                        
[center getNotificationCategoriesWithCompletionHandler:^(NSSet<UNNotificationCategory *> * _Nonnull categories) {
    NSLog(@"get cat:%@",categories);
}];

Notification Service Extension 中设置推送的categoryIdentifier,如果应用采用了多种Category,一般应该这个标识符包含在推送内容中。

- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
    self.contentHandler = contentHandler;
    self.bestAttemptContent = [request.content mutableCopy];
    // 此处省略推送内容的其他修改和附件的下载
    // 下载完成后,使用fileUrl创建附件
    UNNotificationAttachment *atm = [UNNotificationAttachment attachmentWithIdentifier:@"" URL:url options:options error:&error];
    self.bestAttemptContent.attachments = @[atm];
    // 设置categoryIdentifier
    self.bestAttemptContent.categoryIdentifier = request.content.userInfo[@"aps"][@"catId"];
    self.contentHandler(self.bestAttemptContent);
}

Notification Content Extension 中定制视图,展示推送内容,此处以视频附件为例。

声明协议中与视频播放相关的属性,实现对应的方法。

@interface NotificationViewController () <UNNotificationContentExtension>

@property IBOutlet UILabel *label;

@property (weak, nonatomic) IBOutlet UIImageView *imageView;

@property (nonatomic, strong) AVPlayerLayer *layer;

@property (nonatomic, strong) AVPlayer *player;

@property (nonatomic, assign) UNNotificationContentExtensionMediaPlayPauseButtonType mediaPlayPauseButtonType;

@property (nonatomic, assign) CGRect mediaPlayPauseButtonFrame;

@property (nonatomic, copy) UIColor *mediaPlayPauseButtonTintColor;

@end

@implementation NotificationViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // 设置ContentSize
    self.preferredContentSize = CGSizeMake([UIScreen mainScreen].bounds.size.width, 300);
}

- (void)didReceiveNotification:(UNNotification *)notification {
    // 
    self.label.text = notification.request.content.body;
    UNNotificationAttachment *atm = notification.request.content.attachments.firstObject;
    if ([atm.URL startAccessingSecurityScopedResource]) {
        self.player = [AVPlayer playerWithURL:atm.URL];
        self.layer = [AVPlayerLayer playerLayerWithPlayer:self.player];
        self.layer.frame = SomeRect;// frame自行计算,此处仅为示例
        self.layer.videoGravity = AVLayerVideoGravityResizeAspectFill;
        [self.view.layer addSublayer:self.layer];
        [atm.URL stopAccessingSecurityScopedResource];
    }
}

- (UNNotificationContentExtensionMediaPlayPauseButtonType)mediaPlayPauseButtonType {
    return UNNotificationContentExtensionMediaPlayPauseButtonTypeOverlay;
}

- (CGRect)mediaPlayPauseButtonFrame {
    CGPoint center = self.imageView.center;
    return CGRectMake(center.x - 25, center.y - 25, 50, 50);
}

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

- (void)mediaPlay {
    [self.player play];
}

- (void)mediaPause {
    [self.player pause];
}

@end

由于在视图初始化时,还不能知道推送内容的最终高度,因此最好以一个固定的高度呈现。上面在代码中使用preferredContentSize来设置。
代码中使用AVPlayer和AVPlayerLayer来展示视频附件,其中获取视频URL时,由于Attachment是由系统管理,在沙盒之外,我们访问URL内容时候需要先获取使用权限:

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

推荐阅读更多精彩内容