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閱讀 230,527評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,687評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,640評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,957評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,682評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,011評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,009評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,183評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,714評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,435評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,665評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,148評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,838評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,251評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,588評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,379評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,627評論 2 380

推薦閱讀更多精彩內容

  • iOS取消網絡請求的正確姿勢
    lyking閱讀 387評論 0 1
  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,829評論 18 139
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,764評論 25 708
  • 任何事物都有它來和去的時間,所以不要著急
    咋了哇哦閱讀 91評論 0 0
  • 每個月到了這個時候都有種想“屎”的感覺!好像拿錢續命,然后你又沒錢,然后你還沒辦法去賺錢!煎熬…… 我突然就明白知...
    諾凡閱讀 240評論 6 0