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

前言

前段时间,有两个以前的同事碰巧都问了我有关取消网络请求的问题。这个问题我之前没怎么在意,我通常不会特意在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开发。试想,如果你不会编写后端代码,那么就无法像本文一样,去验证各种结果,只能求助于后端人员和你配合,而这总归是没有自己动手来得灵活自在,不是吗?

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

推荐阅读更多精彩内容

  • iOS取消网络请求的正确姿势
    lyking阅读 343评论 0 1
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,100评论 18 139
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,568评论 25 707
  • 任何事物都有它来和去的时间,所以不要着急
    咋了哇哦阅读 88评论 0 0
  • 每个月到了这个时候都有种想“屎”的感觉!好像拿钱续命,然后你又没钱,然后你还没办法去赚钱!煎熬…… 我突然就明白知...
    诺凡阅读 210评论 6 0