iOS 基于WKWebView的JS交互、路由、资源本地化

JS交互的基础知识

系统的交互代理WKUIDelegate

我们可以通过设置WKUIDelegate的代理来截获JS调用alert、confirm、prompt函数,来使用原生控件实现样式及操作,并将用户操作回调给JS。

部分代理方法

// 在JS端调用alert函数时,会触发此代理方法。
// JS端调用alert时所传的数据可以通过message拿到
// 在原生得到结果后,需要回调JS,是通过completionHandler回调
-(void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler;

// JS端调用confirm函数时,会触发此方法
// 通过message可以拿到JS端所传的数据
// 在iOS端显示原生alert得到YES/NO后
// 通过completionHandler回调给JS端
-(void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler;

// JS端调用prompt函数时,会触发此方法
// 要求输入一段文本
// 在原生输入得到文本内容后,通过completionHandler回调给JS
- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(nullable NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * __nullable result))completionHandler;

示例:

JS中调用的函数:

<input type="button" value="confirm" onclick="confirm('确定提交吗?');" />

原生中代理方法处理:

-(void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler{
    NSLog(@"%s", __FUNCTION__);
    NSString *contentString = [NSString stringWithFormat:@"内容:%@",message];
    UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"JS调用confirm" message:contentString preferredStyle:UIAlertControllerStyleAlert];
    [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
        completionHandler(YES); // 回传用户操作
    }]];
    [alert addAction:[UIAlertAction actionWithTitle:@"取消" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
        completionHandler(NO);  // 回传用户操作
    }]];
    [self presentViewController:alert animated:YES completion:NULL];
    NSLog(@"%@", message);
}

另外两个做类似的处理即可。

演示:

演示

自定义方法

在进行web和原生的交互时,系统自带的WKUIDelegate肯定是远远不够的,此时我们需要自定义交互的方法。WKWebView中存在一个configuration属性,该属性有一个userContentController,此实例可以关联web视图,添加我们自定义Script脚本消息以及相对应的处理对象。

添加脚本信息:

- (void)addScriptMessageHandler:(id <WKScriptMessageHandler>)scriptMessageHandler name:(NSString *)name;

接收脚本回调信息:

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message;

示例:

原生中添加Script脚本消息以及相对应的处理对象:

[webView.configuration.userContentController addScriptMessageHandler:self name:@"copyWeiXinHao"];
[webView.configuration.userContentController addScriptMessageHandler:self name:@"goToWeiXinApp"];
[webView.configuration.userContentController addScriptMessageHandler:self name:@"getCurrentContent"];

紧接着在WKScriptMessageHandler代理方法中实现相对应的处理。

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{
    if ([message.name isEqualToString:@"copyWeiXinHao"]) {
        NSString *wxh = message.body;
        // do something
    }
    else if ([message.name isEqualToString:@"getCurrentContent"]) {
        // do something
        // 可回传数据给web
    }
    else if ([message.name isEqualToString:@"goToWeiXinApp"]) {
        // do something
    }
}

移除监听

-(void)dealloc{
    // 根据name移除
    [self.webView.webView.configuration.userContentController removeScriptMessageHandlerForName:@"copyWeiXinHao"];
//    [self.webView.webView.configuration.userContentController removeAllUserScripts]; // 移除所有
}

注:

web传递数据给OC

function 方法名() {
    window.webkit.messageHandlers.方法名.postMessage('数据');
}

OC传递数据给web

// 实际上是一种代码注入
[webView evaluateJavaScript:@"方法名(参数)" completionHandler:^(id _Nullable response, NSError * _Nullable error) {
}];

演示:

演示

代码注入

我们可以通过多种方式来进行代码注入。
WKUserContentController中除了注册自定义方法外,还有一个方法

- (void)addUserScript:(WKUserScript *)userScript

该方法可以让我们将js代码注入到web中去,以实现修改web的一些功能,如修改内容,监听滚动等等。

示例:为了方便的看到效果,我们这里直接进行 alert进行弹窗(需要完成WKUIDelegate)

NSString *javaScriptSource =  @"alert(\"WKUserScript注入js\");";
WKUserScript *userScript = [[WKUserScript alloc] initWithSource:javaScriptSource injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];// forMainFrameOnly:NO(全局窗口),yes(只限主窗口)
[_webView.configuration.userContentController addUserScript:userScript];

另外一种代码注入的方式就是在之前OC传递数据给web的方法

- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))completionHandler;

这种JS注入的方式可以在回调拿到执行的结果。

有关于更多JS代码,则需要读者自行学习相关的知识。


demo演示地址


可能遇到的问题

1、WKWebView执行js代码,要先被加载到父视图上

2、关于不能释放问题的解决方案

思路:另外创建一个弱引用代理对象,然后通过代理对象回调指定的self

.h 文件

#import <Foundation/Foundation.h>
#import <WebKit/WebKit.h>
@interface WeakScriptMessageDelegate : NSObject<WKScriptMessageHandler>
@property (nonatomic,weak)id<WKScriptMessageHandler> scriptDelegate;
- (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate;
@end

.m 文件

#import "WeakScriptMessageDelegate.h"
@implementation WeakScriptMessageDelegate
- (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate{
    self = [super init];
    if (self) {
        _scriptDelegate = scriptDelegate;
    }
    return self;
}
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{
    [self.scriptDelegate userContentController:userContentController didReceiveScriptMessage:message];
}
@end

使用

[_webView.configuration.userContentController addScriptMessageHandler:[[WeakScriptMessageDelegate alloc] initWithDelegate:self] name:@"xxxx"];

3、注册的方法过多

在通常的web交互中,可能仅需几个交互方法即可,但是在复杂的web交互时,可能存在注册的方法过多问题,这样增加了web调用的复杂性,因此后期又分门别类的讲方法分为了功能性类和模块跳转类等几大类,注册的方法仅需统一几种方法,相应的,为了区分不同的功能,需要web在方法postMessage('参数')中的参数传递Json时,使用一个code来进行区分。


路由

在应用中,我们可以使用路由的思想去传递信息,实现跳转指定页面。

举例子

打开微信: 在手机safari中输入"weixin://"并前往,系统浏览器则会提示你是否在微信中打开。其中"weixin://"是微信App的URL Scheme,标识了该应用的身份,一个应用可以拥有多个URL Scheme,你也可以给自己的应用添加URL Scheme,设置路径为:TARGETS->info->URL Types,然后就可以通过手机safari唤起你的应用了。

更多的例子:分享、三方支付等等都会使用到URL Scheme来跳转传递参数。

路由协议的体验

https://ds.alipay.com/i/index.htm?iframeSrc=alipays://platformapi/startapp?appId=10000007

将上面的地址复制到手机safari中,你会跳转到支付宝的扫一扫界面,当然你首先看到的是

https://ds.alipay.com/i/index.htm

支付宝web下载首页,并且是由该页面识别参数并进行跳转。

alipays://platformapi/startapp?appId=10000007

则是支付宝的路由协议,格式为:

私有协议头://功能释义/功能?参数名1=值1&参数名2=值2

appId可能表示为应用中的功能编号,你可以修改编号跳转到其他功能页面,如果未找到对应的功能模块,则会出现默认的错误提示页面。

定义路由协议

那么我们同样参考支付宝的例子来实现路由,协议你可以自定义,也可以照搬。
例如我们设置我们的应用URL Scheme为myroute,那么我们自己应用的私有协议头为:"myroute://"。

设置URL Scheme

这样重新编译你的应用,然后在safari中输入私有协议头,系统浏览器会自动打开你的应用。

- (BOOL)openURL:(NSURL*)url;
- (void)openURL:(NSURL*)url options:(NSDictionary<UIApplicationOpenExternalURLOptionsKey, id> *)options completionHandler:(void (^ __nullable)(BOOL success))completion;

在原生应用中,你也可以使用上述两个方法打开其他应用。

[UIApplication.sharedApplication openURL:[NSURL URLWithString:@"myroute://"]];

解析路由协议

那么既然我们通过系统浏览器打开了自己的应用,那么再哪里可以接收并解析参数呢?实际上,项目的AppDelegate中有代理方法可以将系统丢过来的路由数据进行回调,你可以在下面的代理方法中进行路由协议的解析。

- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options;

假如你如此定义了你的路由协议:

// 首页
myroute://platformapi/openPage?code=homepage&channelId=10
// 个人中心
myroute://platformapi/openPage?code=myself&userId=63662

在代理方法中的解析:

- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options{
    NSString* method = url.lastPathComponent;
    // 将URL中的参数转换为字典
    NSMutableDictionary* dic = [NSMutableDictionary dictionary];
    NSURLComponents *components = [[NSURLComponents alloc] initWithURL:url resolvingAgainstBaseURL:NO];
    [components.queryItems enumerateObjectsUsingBlock:^(NSURLQueryItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        NSURLQueryItem *item = (NSURLQueryItem *)obj;
        if (item.name.length && item.value.length) {
            [dic setValue:item.value forKey:item.name];
        }
    }];
    // 打开页面
    if ([method isEqualToString:@"openPage"]) {
        NSString* code = dic[@"code"];
        if ([code isEqualToString:@"homepage"]) {
            // do something
        }
        else if ([code isEqualToString:@"homepage"]) {
            // do something
        }
        else {
            // do something
        }
    }
    else {
        // do something
    }
}

至此,你已经实现了一个具有简单功能的路由了。通常你可以将解析路由的代码解藕,统一由一个类去实现,这样你的应用所有页面都可以使用该路由。

为什么这里要引入路由的概念,因为我们需要使用借助路由实现我们web中的交互功能,最直观的就是实现功能页面的跳转,统一采用路由协议,我们仅需注册一个方法,便可以实现跳转多种页面,无需在多写多余代码。

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{
    /// 跳转路由
    if ([message.name isEqualToString:AppData.share.webMethods.openURL]) {
        NSString* urlString = message.body;
        if (![urlString isKindOfClass:NSString.class]||urlString.length==0) { return; }
        // 将web传递过来的路由协议数据传递给路由对象解析
        [AppManager.share.router handleURL:[NSURL URLWithString:urlString]];
    }
}

资源本地化

资源本地化实际上就是web的离线机制,目的是解决web页面访问慢的问题。但是资源本地化并不是简单的将web页面打包到项目中去,我们需要动态更新机制,并且不是所有的web页面,js、css等资源文件需要离线到本地,那些公用的js框架,一些更新频率较小的静态页面可以离线到本地,而那些经常变化的活动页面没有必要离线占手机存储空间。

由于我们需要使用部分离线资源,因此我们需要拦截web中资源请求,解析其中的静态资源,如果本地已经存在该资源,则读取该资源返回给web,如果没有且需要更新或者本地化,则下载该资源返回给web并进行本地化操作,否则(如动态的活动页面等)继续正常网络获取。

引用别人的图

网络请求的拦截:NSURLProtocol协议

#import "NSURLProtocolCustom.h"
#import <CoreFoundation/CoreFoundation.h>
#import <MobileCoreServices/MobileCoreServices.h>

static NSString* const FilteredKey = @"FilteredKey";

@implementation NSURLProtocolCustom

// 决定是否需要发起请求
+(BOOL)canInitWithRequest:(NSURLRequest *)request{
    // 获取到请求资源的扩展名
    NSString* extension = request.URL.pathExtension;
    BOOL isSource = [@[@"png", @"jpeg", @"gif", @"jpg", @"js", @"css"] indexOfObjectPassingTest:^BOOL(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        return [extension compare:obj options:NSCaseInsensitiveSearch] == NSOrderedSame;
    }] != NSNotFound;
    // 根据某些映射查找本地到资源路径,如果没有则可能需要下载或者继续发起请求
    NSString *fileName = [request.URL.absoluteString componentsSeparatedByString:@"/"].lastObject;
    NSString *path = [[NSBundle mainBundle] pathForResource:fileName ofType:nil];
    return [NSURLProtocol propertyForKey:FilteredKey inRequest:request] == nil && isSource && path.length;
}

+(NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request{
    return request;
}

// 开始加载资源
-(void)startLoading{
    // 在加载时,查找本地资源,如果存在则返回该资源数据
    NSString* fileName = [super.request.URL.absoluteString componentsSeparatedByString:@"/"].lastObject;
    NSLog(@"fileName is %@",fileName);
    // 根据某些映射查找本地到资源路径,如果没有则可能需要下载,如:png,js等
    NSString* path = [NSBundle.mainBundle pathForResource:fileName ofType:nil];
    if (!path) {
        // 判断是否需要下载并缓存资源文件,这里配合`canInitWithRequest`来使用,走到这里则表示需要下载并缓存资源文件
        // 下载并缓存资源文件
        [self downloadResourcesWithRequest:super.request.mutableCopy];
        return;
    }
    
    // 加载本地资源
    NSData* data = [NSData dataWithContentsOfFile:path];
    [self sendResponseWithData:data mimeType:[self getMimeType:path]];
}

// 资源加载结束
-(void)stopLoading{
}

// 构建NSURLResponse,返回给web
-(void)sendResponseWithData:(NSData*)data mimeType:(nullable NSString*)mimeType{
    NSURLResponse* response = [[NSURLResponse alloc] initWithURL:super.request.URL
                                                        MIMEType:mimeType
                                           expectedContentLength:-1
                                                textEncodingName:nil];
    // 硬编码,开始嵌入本地资源到web中
    [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
    [self.client URLProtocol:self didLoadData:data];
    [self.client URLProtocolDidFinishLoading:self];
}

// 下载资源文件
- (void)downloadResourcesWithRequest:(NSURLRequest *)request{
    AFURLSessionManager* manager = [[AFURLSessionManager alloc] initWithSessionConfiguration:NSURLSessionConfiguration.defaultSessionConfiguration];
    // 开始下载
    NSURLSessionDownloadTask* downloadTask = [manager downloadTaskWithRequest:request progress:^(NSProgress * _Nonnull downloadProgress) {
        NSLog(@"下载进度:%.0f%", downloadProgress.fractionCompleted * 100);
    } destination:^NSURL * _Nonnull(NSURL * _Nonnull targetPath, NSURLResponse * _Nonnull response) {
        // 指定下载的位置
        return [NSURL fileURLWithPath:@"临时资源的路径"];
    } completionHandler:^(NSURLResponse * _Nonnull response, NSURL * _Nullable filePath, NSError * _Nullable error) {
        // 本地化下载的资源,并发送资源给web
        [NSFileManager.defaultManager moveItemAtURL:filePath toURL:[NSURL fileURLWithPath:@"资源目标地址"] error:nil];
        NSData *data = [NSData dataWithContentsOfFile:@"资源目标地址"];
        [self sendResponseWithData:data mimeType:[self getMimeType:@"资源目标地址"]];
    }];
    [downloadTask resume];
    
}

// 根据路径获取MIMEType
-(NSString*)getMimeType:(NSString*)filePath{
    CFStringRef pathExtension = (__bridge_retained CFStringRef)[filePath pathExtension];
    CFStringRef type = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, pathExtension, NULL);
    CFRelease(pathExtension);
    NSString* mimeType = (__bridge_transfer NSString*)UTTypeCopyPreferredTagWithClass(type, kUTTagClassMIMEType);
    if (type != NULL) {
        CFRelease(type);
    }
    return mimeType;
}
@end

在实现自定义NSURLProtocol协议之后,我们需要在使用web之前注册该协议。对于UIWebView视图,使用如下方法即可实现拦截:

// 注册自定义类
[NSURLProtocol registerClass:[NSURLProtocolCustom class]];

然而在WKWebView中,Apple似乎并不支持这样的拦截方式,可能更换了其他的形式,google了一下,添加如下代码实现了拦截:

// 注册自定义类
[NSURLProtocol registerClass:[NSURLProtocolCustom class]];
// 实现拦截功能,这个是核心
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
    [(id)cls performSelector:sel withObject:@"http"];
    [(id)cls performSelector:sel withObject:@"https"];
#pragma clang diagnostic pop
}

WKWebView使用该方法据说有一定的被拒风险。

资源的更新

web的离线机制中,最复杂的部分可能就是更新问题了,如何保持更新,需要后端、前端、移动端的三方配合。在上一节的自定义协议中,我们可以删去资源下载的部分逻辑代码,将更新机制放在指定类中完成,在应用接收到服务端更新本地资源信息后,开始更新资源包。

web离线更新

在开发阶段,web通过手机设置HTTP代理方法访问开发机,完成开发后,web将代码构建打包发送到后台,后台通过socket长链接将最新的包信息发送给移动端,移动端收到更新信息之后开始下载新包,对包进行完整性校验、合并,完成更新。配合api完成未在线的客户的及时更新。

本地资源映射:Local Url Router

在自定义 NSURLProtocol 协议一节中,我们很模糊的写到:根据某种映射找到本地资源路径。对于web的资源请求,我们可以采用线上和离线相同url访问方式。Local Url Router 映射规则可能比较复杂,参考阿里的去啊app,将映射规则交给web生成:web开发完成之后扫描web项目生成一份线上资源和离线资源的映射标(souce-router.json),原生端只需要根据这个映射表来查找本地资源即可。

web资源包解压之后在本地的目录结构类似:

$ cd h5 && tree
.
├── js/
├── css/
├── img/
├── pages
│   ├── index.html
│   └── list.html
└── souce-router.json

souce-router.json的数据结构类似:

{
    "protocol": "http",
    "host": "o2o.xx.com",
    "localRoot": "[/storage/0/data/h5/o2o/]",
    "localFolder": "o2o.xx.com",
    "rules": {
        "/index.html": "pages/index.html",
        "/js/": "js/"
    }
}

原生端拦截到静态资源请求时,如果本地有对应的文件则直接读取本地文件返回,否则发起HTTP请求获取线上资源,下次就走离线了。

资源本地化的整体架构图

整体架构图

其中的 Synchronize Service 模块表示和服务器的长连接通信模块,用于接受服务器端各种推送。 Source Merge Service 模块表示对解压后的web资源进行更新,包括增加文件、以旧换新以及删除过期文件等。


参考地址

关于资源本地化,参考文章:Hybrid APP架构设计思路

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

推荐阅读更多精彩内容