美文网首页网络iOS 网络相关iOS Developer
iOS取消网络请求的正确姿势

iOS取消网络请求的正确姿势

作者: 飘游人 | 来源:发表于2017-04-01 11:42 被阅读4278次

    前言

    前段时间,有两个以前的同事碰巧都问了我有关取消网络请求的问题。这个问题我之前没怎么在意,我通常不会特意在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端的取消

    取消请求

    要取消请求,可以调用NSURLSessionDataTaskcancel方法。由于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.phpsleep了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只被调用了一次,这里主要是利用了NSObjectcancelPreviousPerformRequestsWithTarget方法和延迟执行方法performSelector:withObject:afterDelay:来实现。
    确切的说,这两个方法是关于调用方法的取消和延迟执行的,我们只不过将网络请求放到调用方法中,以此来达到减少网络请求的目的。

    当然,这样做可能会影响一些用户体验。这时,只能靠自己的需求和经验去调节延迟值(0.5秒)的大小,在用户体验和减少服务端请求之间做一个平衡。

    另外,再补充一下,这种搜索提示会有返回结果乱序的问题。比如输入了ap,理想情况下,应该是先返回a的搜索提示结果,再返回ap的结果。但因为网络是不可控的,有可能ap的搜索提示结果先返回了,而后再返回a的结果,这时就会导致页面上显示的数据不正确。这个比较简单便捷的解决方法是,服务端返回搜索提示结果的同时,也把当前搜索的关键字返回回来,APP端比对返回的关键字跟当前搜索框的关键字是否一致,如果一致再显示结果。

    后记

    一个简单的取消网络请求问题,也是隐藏了许多的猫腻,希望这篇文章能给大家一些启示,为大家扫清障碍,更好的掌控网络请求的取消。同时,这篇文章也印证我另外一篇文章为什么移动开发人员应该学习PHP?的一个观点,学习后端开发可以辅助APP开发。试想,如果你不会编写后端代码,那么就无法像本文一样,去验证各种结果,只能求助于后端人员和你配合,而这总归是没有自己动手来得灵活自在,不是吗?

    相关文章

      网友评论

      • 闭家锁:为什么一定要学php呢,会Java不可以吗?还记得以前看过的一个段子,一个人上非诚勿扰,当他说自己是写php的时候,24盏灯都灭了:joy:
      • astring:大神,如果是socket呢?如果在a界面发了socket请求包,在a界面销毁时不想让其继续如何中断?如果某个http请求在a界面发送,在a界面销毁后数据回应了会造成leaks吗?
      • 横穿撒哈拉的骆驼:楼主,我问个问题, for 循环里面是3, 不是也会调用`request end time:`嘛, 这个时候还会给客户端发送回调数据吗?
        bluesea哈哈哈:@海棠花开 楼主,但是上传大文件,sesstionTask cancel,但是还在传呢
        横穿撒哈拉的骆驼:懂了,调用cancle后会切断当前HTTP链接, 服务端不会下发数据, 可以为用户省点一丢丢流量
      • 王_胖胖:问个问题,网络请求里task calcel 只有这种取消吗?
        王_胖胖:@飘游人 不是我的意思是除了cancel这个他提供的方法 还有其他途径 比如发送一个取消的网络,或者在网络请求没发出去的时候强制干掉之类的
        飘游人:这跟你使用的底层网络库有关,AFNetworking使用的是NSURLSessionTask,提供了cancel方法来取消请求。如果你用了像CFNetwork这样更底层的网络请求库,那做取消就是另外的方式了
      • 令__狐冲:使用单例封装的请求类吗?所以才能获取到task?
        令__狐冲:@飘游人 我目前是用静态变量封装的请求类,然后在控制器基类里面重写dealloc方法,在dealloc方法里面获取正在执行或者被挂起的dataTask,然后取消。如果在每个控制器里面去取消网络请求,感觉太麻烦。
        飘游人:这跟单不单例无关,文中的示例就没有用什么单例封装,只要调用网络请求方法能返回相应的task即可
      • 耍酷110代:iOS端请求已经发出还未收到回调时候,然后cancel,此时Php正常处理,那么iOS端还是和正常非cancel一样,收到成功或者失败回调么?
        飘游人:请求已发出还未收到回调就说明请求已经发给服务端,但还没有收到(或者说完整收到)服务端的响应,这时cancel的话,会进入failure回调
      • iOS_小胜:茅塞顿开,谢谢
      • Shawn_Locke:涨姿势了,感谢分享!
      • 5831487e4a23:虾神,期待你作品不断!下次分享一个网络请求解析完整版?
      • Dolphii:厉害了

      本文标题:iOS取消网络请求的正确姿势

      本文链接:https://www.haomeiwen.com/subject/hwmvottx.html