前言
前段时间,有两个以前的同事碰巧都问了我有关取消网络请求的问题。这个问题我之前没怎么在意,我通常不会特意在APP中做取消请求的处理,因为从我的直觉来说,网络请求一旦发出去,应该就无法取消。所谓的取消,无非就是中断和服务端的连接,不接收服务端的回应。这样的取消,也无非是为了APP取消请求时,能有一些额外的处理罢了。但直觉归直觉,实践才是检验真理的唯一标准,本文就通过一系列的实验来印证梳理取消网络请求的知识要点。
准备工作
网络请求是一种应答机制,APP端向服务端发送请求,服务端接收请求后进行处理,并将处理后的结果返回给APP端。这里就涉及两个端问题,APP端如何取消请求?APP端取消请求后,会有什么结果?此时服务端又会怎么样?
为了验证取消网络请求的各种情况及结果,我们先要准备好相关的基础代码。
首先是服务端(PHP)的代码:
<?php
file_put_contents('log.txt', 'request start time:' . time() . "\n");
sleep(3);
file_put_contents('log.txt', 'request end time:' . time(), FILE_APPEND);
echo 'hello world';
将以上代码保存成cancelTest.php
,参考极速配置PHP环境来配置运行PHP。
上面的代码,首先会创建log.txt
文件(如果存在文件,则会先删除再创建),然后写入request start time...
信息。接着,停顿3秒,然后再往log.txt
写入request end time...
信息。
接着是APP端的代码:
AFHTTPSessionManager *sessionManager = [AFHTTPSessionManager manager];
sessionManager.requestSerializer = [AFHTTPRequestSerializer serializer];
sessionManager.responseSerializer = [AFHTTPResponseSerializer serializer];
NSURLSessionTask *task = [sessionManager GET:@"http://localhost:8080/cancelTest.php"
parameters:nil
progress:nil
success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) {
NSLog(@"responseObject:%@", [[NSString alloc] initWithData:responseObject encoding:NSUTF8StringEncoding]);
}
failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
NSLog(@"error:%@", error.localizedDescription);
}];
相信大部分人都是用AFNetworking
来做网络请求,所以,这里也使用AFNetworking
相关的代码来做实验。
上面的代码会访问localhost
本地Web服务器,如果之前保存好cancelTest.php
并配置好PHP环境,那么在iOS模拟器中运行这段代码就能获取到服务端的响应(只是响应会比较慢,因为PHP代码中加了sleep
)。
APP端的取消
取消请求
要取消请求,可以调用NSURLSessionDataTask
的cancel
方法。由于AFNetworking
发起请求的方法返回的也是NSURLSessionDataTask
实例,我们可以直接调用:
NSURLSessionTask *task = ...
[task cancel];
此时,会进入failure
回调,输出:
error:cancelled
在调用cancel
方法后,代码会立即返回,并不会等待请求取消:
NSURLSessionTask *task = ...
[task cancel];
NSLog(@"continue...");
以上代码输出:
continue...
error:cancelled
可以看到,调用cancel
后,代码继续往下执行,然后再执行failure
回调。
不同情况下取消请求的结果
- 请求未发出,
cancel
后则不发送请求
NSURLSessionTask *task = ...
[task cancel];
上面的代码在创建task后立即取消,此时请求还未发出,cancel
后请求就真正被取消,不会往服务端发送。
由于cancelTest.php
会写文件,此时查看cancelTest.php
所在目录,也会发现没有生成log.txt
,这说明请求是没有发出来的。
- 请求已发出,但还没有完成,
cancel
会立即回调failure
NSURLSessionTask *task = ...
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[task cancel];
});
上面的代码在0.01秒后再取消task,此时请求已经发出,由于cancelTest.php
中sleep
了3秒,请求并未完成。cancel
后会进入failure
回调方法,这也相当于不读取服务端的响应,直接中断请求。
- 请求已完成,此时
cancel
没有任何效果
NSURLSessionTask *task = ...
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[task cancel];
});
上面的代码在4秒后再取消task,此时请求已经完成,再调用cancel
就没有任何效果(不会进入failure
回调方法)
如何判断取消
请求cancel
后会进入failure
回调方法,而像网络不通、服务器宕机无法连接等错误也会进入failure
方法,那么如何区分是否是因为cancel
进入的?可以通过判断task的错误码来进行区分:
failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
if (task.error.code == NSURLErrorCancelled) {
// 取消了请求
} else {
// 其他错误
}
}];
取消请求对服务端的影响
APP端取消请求后,对服务端会有什么影响呢?服务端正在执行的操作是否也会中断取消?
从上面的实践中,我们已经知道,在APP端请求未发出时进行取消,则不会发出请求,这种情况显而易见对服务端没什么影响。而APP端请求完成后再取消,显然也不会有什么影响,这时服务端已经完成了操作给出了响应,取不取消结果都一样。因此,我们主要看的是,在APP端发出请求后,还未获取到服务端的响应,这时取消请求会对服务端造成什么影响。
初步验证
APP端代码:
NSURLSessionTask *task = ...
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[task cancel];
});
服务端cancelTest.php
代码:
<?php
file_put_contents('log.txt', 'request start time:' . time() . "\n");
sleep(3);
file_put_contents('log.txt', 'request end time:' . time(), FILE_APPEND);
我们用上面的代码来先验证一下,验证之前,请先删除cancelTest.php
目录下的log.txt
文件(如果存在的话),以确保验证结果。
APP端在发出请求后0.01秒即进行了取消请求的操作,此时请求已发出,如果从直觉上来说,这时PHP最多执行到sleep(3);
这条语句。APP端取消请求后,PHP端会不会继续执行后面的file_put_contents...
语句呢?
只要静待3秒,然后打开log.txt
文件,会看到类似下面的信息:
request start time:1490950750
request end time:1490950751
这说明PHP会继续往下执行代码,而不会受APP端取消的影响中断操作。
那么事实真的是这样吗?
第二次验证
我们保持APP端的代码不变,将cancelTest.php
的代码修改为:
<?php
file_put_contents('log.txt', 'request start time:' . time() . "\n");
sleep(3);
for ($i = 0; $i < 3; $i++) {
echo 'something output ';
}
file_put_contents('log.txt', 'request end time:' . time(), FILE_APPEND);
在sleep
后,我们加了个循环,输出了一些响应信息。
删除之前产生的log.txt
,再重新运行APP,会看到新生成的log.txt
和之前的也是差不多,没什么变化。
可能有些人会觉得奇怪,为什么要在中间加个echo
输出呢?而且这对结果也没什么影响啊。
我们接着来。
第三次验证
我们将PHP代码中的$i < 3
改成$i < 3000
,即由循环输出3次,变成循环输出3000次。删除之前产生的log.txt
,再重新运行APP,然后去看新生成的log.txt
。我们会看到log.txt
只包含request start time...
信息:
request start time:1490951217
这样的结果让人感觉很奇怪,为什么从循环输出3次改为循环输出3000次,PHP就好像不继续执行后面的代码了呢?
这是因为PHP只有往外输出内容时,才会去检测客户端的连接是否断开,如果断开,就不往下执行代码了。
那既然这样,为什么循环输出3次的时候还是会往下执行呢?这时不是也应该检测到客户端连接断开了吗?实际上,PHP在输出内容时,并不是echo
一下输出一下的,而是有一个缓存。输出内容先放到缓存中,只有输出的内容超过缓存大小,或者代码执行结束时,才会往外输出内容(并进行下一轮的缓存&输出)。在循环3次的时候,由于输出内容的量很小,没有超过缓存,所以,只有等到代码执行结束时才输出。而代码都已经执行结束了,检测客户端是否断开也没有多少意义。在循环输出3000次的时候,由于输出内容超出了缓存,所以,会先将缓存中的内容输出,这时检测到了客户端断开,PHP也就不继续执行代码了。
进一步讨论
看到这里,有些人可能就会想,那这样PHP也太坑了吧。PHP开发人员难不成要时刻注意输出内容的问题,否则客户端取消请求,代码就不继续执行了,这对PHP开发来说,不是太麻烦了吗?
其实PHP提供了一个ignore_user_abort
方法,可以确保执行过程不受客户端取消的影响继续执行代码直至结束。
我们在代码最前面加上ignore_user_abort(true);
,使PHP代码变为:
<?php
ignore_user_abort(true);
file_put_contents('log.txt', 'request start time:' . time() . "\n");
sleep(3);
for ($i = 0; $i < 3000; $i++) {
echo 'something output';
}
file_put_contents('log.txt', 'request end time:' . time(), FILE_APPEND);
删除之前产生的log.txt
,再重新运行APP,然后去看新生成的log.txt
,我们就会看到log.txt
包含request end time...
信息,说明即使因为输出了内容检测到了客户端断开,PHP也依然会往下执行代码。
以下是PHP有关客户端断开的一些说明:
PHP will not detect that the user has aborted the connection until an attempt is made to send information to the client. Simply using an echo statement does not guarantee that information is sent, see flush().
结论
APP端取消请求对服务端的影响是“视情况而定”,这里是以PHP为例,对于Java、.Net、Python等是否也有类似的机制,会有什么影响不得而知,这可能跟所使用的Web服务器(Apache、Nginx、Tomcat等)也有关系。所以,如果在意这个影响,还是跟服务端开发联合测试一下比较好。
扩展示例
我们来看一个搜索提示的例子:
搜索提示这样的功能需要监听用户输入,然后去发起请求:
- (void)viewDidLoad {
[super viewDidLoad];
[self.searchTextField addTarget:self action:@selector(startSearch:) forControlEvents:UIControlEventEditingChanged];
}
- (void)startSearch:(UITextField *)textField {
// 发起请求,请求完成后刷新tableView显示结果
}
有的开发人员会在用户输入新的字符后,将之前搜索提示请求取消(因为这时之前的请求已经没有用了),他们认为如果取消了,可以减少一些服务端的请求。
- (void)startSearch:(UITextField *)textField {
// 取消之前的请求
// 发起请求,请求完成后刷新tableView显示结果
}
但是,我们从之前的论述中可以看到,网络请求发出是很快的(即使我们之前是0.01秒后就取消,网络请求也还是发出去了),所以基本上输了几个字符就会发几次请求。而对于发出的请求,即使请求还没完成调用了cancel
方法取消,这个请求还是会被服务端接收处理。所以,这种方式并不能有效的减少服务端的请求。
正确的做法是设置一个时间间隔,当用户输入停顿的时间超过间隔时,再发出请求,代码如下:
- (void)startSearch:(UITextField *)textField {
NSLog(@"%s", __PRETTY_FUNCTION__);
[NSObject cancelPreviousPerformRequestsWithTarget:self];
[self performSelector:@selector(loadSearchSuggestionsWithSearchWord:) withObject:textField.text afterDelay:0.5];
}
- (void)loadSearchSuggestionsWithSearchWord:(NSString *)searchWord {
NSLog(@"%s", __PRETTY_FUNCTION__);
// 发起网络请求,成功后刷新tableView显示数据
}
这时,如果用户输入字符之间的停顿不超过0.5秒是不会发请求的,我们快速输入两个字符后停顿,只会发出一个网络请求,以下是console的输出:
[CancelTestViewController startSearch:]
[CancelTestViewController startSearch:]
[CancelTestViewController loadSearchSuggestionsWithSearchWord:]
可以看到loadSearchSuggestionsWithSearchWord
只被调用了一次,这里主要是利用了NSObject
的cancelPreviousPerformRequestsWithTarget
方法和延迟执行方法performSelector:withObject:afterDelay:
来实现。
确切的说,这两个方法是关于调用方法的取消和延迟执行的,我们只不过将网络请求放到调用方法中,以此来达到减少网络请求的目的。
当然,这样做可能会影响一些用户体验。这时,只能靠自己的需求和经验去调节延迟值(0.5秒)的大小,在用户体验和减少服务端请求之间做一个平衡。
另外,再补充一下,这种搜索提示会有返回结果乱序的问题。比如输入了ap
,理想情况下,应该是先返回a
的搜索提示结果,再返回ap
的结果。但因为网络是不可控的,有可能ap
的搜索提示结果先返回了,而后再返回a
的结果,这时就会导致页面上显示的数据不正确。这个比较简单便捷的解决方法是,服务端返回搜索提示结果的同时,也把当前搜索的关键字返回回来,APP端比对返回的关键字跟当前搜索框的关键字是否一致,如果一致再显示结果。
后记
一个简单的取消网络请求问题,也是隐藏了许多的猫腻,希望这篇文章能给大家一些启示,为大家扫清障碍,更好的掌控网络请求的取消。同时,这篇文章也印证我另外一篇文章为什么移动开发人员应该学习PHP?的一个观点,学习后端开发可以辅助APP开发。试想,如果你不会编写后端代码,那么就无法像本文一样,去验证各种结果,只能求助于后端人员和你配合,而这总归是没有自己动手来得灵活自在,不是吗?
网友评论