iOS項目技術還債之路《一》後臺下載趟坑

前言

去年末我在公司開始接手幾個迭代了五六年的iOS老項目的技術優化工做。互聯網公司的閉源N手業務老代碼,通過了若干年和若干波人的輪番洗禮,再加上若干個deadline的趕工加持,已是千瘡百孔,改點東西如履薄冰。往好處想一想,前人埋的坑越多,後人纔有發揮空間不是。因而我愉快的開始了趟坑之旅。html

從提升效率和質量入手,從無數的問題裏最早識別出最大的痛點——打包時間過長,開始着手優化編譯時間,到梳理團隊編碼規範及分支管理,再到老模塊重構、IAP掉單優化、網絡優化等等,一路到剛完成的後臺下載優化,有一些想記錄和沉澱的東西。ios

這個系列所涉及的一些技術,大多都不是什麼新技術,你能夠在網上輕鬆搜到各類文章。這個系列更關注的是技術怎麼幫助業務解決用戶的問題,畢竟工做中最讓我興奮的就是本身的方案能落地並真正爲用戶和公司帶來價值。所以這個系列的文章大體都會按照下面的思路來寫:git

  • 業務側有什麼樣的痛點
  • 當前的技術方案是怎樣的
  • 技術側如何分析並識別出問題的根源
  • 技術側如何在給定的資源下,選擇評估合適的方案
  • 技術側如何將方案最終落地
  • 線上效果如何

做爲系列的第一篇,打算先講下剛剛完成的後臺下載優化。github

目錄

  • 背景和痛點
  • iOS後臺機制概述
  • 趟坑過程
  • 小結

一. 背景和痛點

就像全部視頻網站都提供移動端視頻緩存服務同樣,我所在公司的移動端產品也有相似的資源離線緩存服務。緩存服務基本已是每一個提供內容服務App的標配了,有很成熟的技術和各類參考文檔。按理來說照着文檔敲一遍代碼,這塊應該沒什麼疑問的。但恰恰最近業務側梳理的用戶反饋中,文件下載類反饋成了用戶最大的槽點。用戶給的反饋廣泛比較含糊,"下着下着就停了"一般是最多的說辭。要根治問題,首先須要挖掘出真正的問題所在,從而對症下藥。json

回想資源緩存服務也已經上線好久,之前反饋並沒這麼頻繁,應該跟最近的兩個改動相關:swift

  1. 產品側增長了批量緩存功能
  2. 技術側對資源自己作了優化和技術方案更新

產品側改動

批量緩存使得下載任務的完成時間延長,出錯概率更高,確實可能形成用戶報障增多。緩存

技術側改動

這裏須要簡單再介紹下背景,除了資源的緩存服務,咱們還提供資源的在線播放服務。因此在此次技術改動以前,資源在服務端一直是存在兩份的:bash

  1. 一份是原始的資源文件夾,裏面包含各類小文件,有pngtxtjsontswav等各類格式的一個富媒體合集,供在線播放使用。播放時直接在線加載小文件地址。
  2. 另外一份是原始資源的壓縮包,供離線緩存使用。移動端經過壓縮包地址緩存完成後再解壓。

此次調整目的是爲了節省服務端資源,若是能將冗餘的壓縮包去掉,僅保留原始資源文件夾的話,能夠節省接近50%的空間。不過客戶端也須要作一次大重構,離線緩存方式從壓縮包下載改成分片下載,即分別下載資源包裏面的每一個小文件。其實之前的壓縮包下載方式對客戶端來說是更友好一些,實現難度更低,下載速度理論上更快。最終全盤考慮和調研下來,分片下載壓縮包下載實測下載速度差很少,所以決定客戶端先作一次讓步,採用分片下載的方式,這裏就很少展開了。網絡

結合產品和技術側的改動,用戶在批量下載多個資源時,每一個資源裏面又有一系列小文件須要下載,複雜度已經遠高於單文件下載,這塊若是處理的有問題,尤爲在後臺運行限制那麼多的iOS設備上,確實會很影響用戶體驗。session

二. iOS後臺機制概述

先來捋一下iOS後臺機制相關的內容,畢竟是iOS7時代的產物,到如今已經比較生疏了。

這麼多年你可能接觸過不少後臺相關的技術,這些名詞或API對你來講必定不陌生:

  1. Background Fetch, Background Task, Background Modes, Background Execution, Background Download
  2. beginBackgroundTaskWithName:expirationHandler:, endBackgroundTask:
  3. application:performFetchWithCompletionHandler:
  4. application:didReceiveRemoteNotification:fetchCompletionHandler:
  5. application:handleEventsForBackgroundURLSession:completionHandler:

後臺下載(Background Download)是什麼,後臺運行(Bakcground Execution)又是什麼,本文要解決的問題又和哪幾塊內容有關。帶着這些問題,咱們先把概念理一理。

照着官方文檔畫了下後臺相關的技術全景圖,以下所示:

後臺運行

後臺運行 (Bakcground Execution)

全部這些概念都隸屬於Bakcground Execution的範疇,它是App在後臺運行任務的統稱。

後臺任務 (Background Task)

這個概念聽着像是全部後臺相關任務的統稱(要和Background Execution區分開),實際它是特指某一類任務:有時App在進入後臺後還有任務沒執行完,還須要運行一小段時間,那麼能夠用Background Task相關API向系統申請運行權限,運行完了再通知系統能夠掛起App了。

後臺模式 (Background Modes)

須要後臺長時間運行任務的App都須要顯式向系統申請權限。這裏以Background AudioBackground Fetch爲例。前者容許App在後臺播放音頻,像QQ音樂這種;後者容許App時不時被喚醒來更新一些數據。

後臺下載 (Background Download)

專指由配置了backgroundSessionConfigurationNSURLSession管理的下載過程。由系統進程接管App數據的下載,所以即使App被系統掛起,甚至殺死或崩潰了,也能繼續下載。下載完成後App會被喚醒,處理一些狀態更新和回調。

簡單理完相關概念,而後回到咱們要解決的問題,先鎖定Background TaskBackground Download,初步懷疑致使後臺下載效果很差的緣由以下:

  1. App中有其餘Background Task執行超時致使App過早被殺,後臺活躍下載時間太短
  2. 後臺下載沒有正確實現
  3. 咱們的下載場景比較特殊,後臺下載hold不住

腦子裏還有一些別的疑問,好比:

  1. App進入後臺之後到底還能活多久
  2. App被系統強殺後還能後臺下載麼
  3. App被喚醒是一種什麼體驗
  4. 保證系統內存充足的話,讓App活更久,後臺下載是否是就能更持久

帶着這麼多吃不許的問題,咱們直接來看實際效果。

三. 趟坑過程

我理想中的後臺下載體驗是這樣的:

  1. 批量下載一批資源
  2. 睡一覺
  3. 醒來發現都下載好了

然鵝現實是骨感的:

  1. 批量下載一批資源
  2. 洗個澡
  3. 發現App被殺,第一個資源都只下了不到一半

後臺下載基本處於無效狀態,這種體驗用戶能不吐槽麼。

第一階段:確保後臺下載可用

後臺相關問題調試起來比較麻煩,由於Xcode的調試器會阻止App被系統掛起,無法模擬真正的App後臺行爲,所以不能用Debug。同時因爲模擬器也不必定能準確模擬App行爲,最好能在真機上測試。

能夠選擇打日誌,真機連上Mac,用Mac上的Console程序看日誌。

爲了讓日誌看起來方便,我在BackgroundDownloader裏的以下位置加了一些帶特徵值(hello開頭)的日誌:

@implementation BackgroundDownloader

- (void)addURLs:(NSArray<NSURL *> *)urls {
	[urls enumerateObjectsUsingBlock:^(NSURL *url, NSUInteger idx, BOOL * _Nonnull stop) {
        NSURLSessionTask *task = [self.urlSession downloadTaskWithURL:url];
        [task resume];
        
        NSLog(@"hello 添加下載任務: %@", url.absoluteString);
    }];
}

- (void)handleEventsForBackgroundURLSession:(NSString *)aBackgroundURLSessionIdentifier completionHandler:(void (^)())aCompletionHandler {
    NSLog(@"hello handleEventsForBackgroundURLSession:completionHandler: 調用");
    // Do other stuff
}

- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location {    
	NSLog(@"hello 分片下載完成: %@", downloadTask.originalRequest.URL.absoluteString);
}

- (void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session {
    NSLog(@"hello URLSessionDidFinishEventsForBackgroundURLSession: 調用");
    // Do other stuff
}

@end

複製代碼

這樣在Console中經過篩選信息 -> hello就能排除各類干擾日誌,只保留你想看的:

console_hello.png

爲了能更好地理解日誌裏方法的調用順序,再簡單補充下資源的下載邏輯:

上文有提到過公司資源走的都是分片下載,即每一個資源都是由一組分片組成的,一個分片就是一個待下載的小文件。對於分片採用併發下載,最大併發量是4,同一個資源裏的全部分片下載完成會開始下一個資源裏分片的下載。

假設批量開啓了3個資源A、B、C的下載任務,分別包含100、200、300個分片。一開始資源A裏的四個分片下載任務同時被啓動,每當收到一個下載完成回調,就開啓一個新分片的下載。直到A中全部分片都下載完成,再從B中同時開啓四個分片的下載,如此循環,直到全部資源都下載完成。從上圖的日誌裏也能夠觀察到相似的過程。

咱們看看App在下載中退到後臺會發生什麼,爲了能同時看到系統日誌,Console中篩選任一 -> BackgroundDemo,而且在18:02:20進入後臺:

console_enter_background_wrong.png

隨後App繼續下載了將近三分鐘,於18:05:17中止了下載:

console_enter_background_end.png

咱們看到,這裏同時還調用了URLSessionDidFinishEventsForBackgroundURLSession:,表示url session裏的事件都處理完了。

代碼中BackgroundDownloader在進入後臺後確實開啓了Background Task,這裏和Background Task進入後臺能執行180s的說法是一致的:

- (void)didEnterBackground:(NSNotification *)notification {
    self.backgroundTask = [[UIApplication sharedApplication] beginBackgroundTaskWithName:NSStringFromClass([self class])
                                                                       expirationHandler:^{
                                                                           [[UIApplication sharedApplication] endBackgroundTask:self.backgroundTask];
                                                                           self.backgroundTask = UIBackgroundTaskInvalid;
                                                                       }];
}
複製代碼

題外話,同時試了下在不開啓Background Task的狀況下,退到後臺下載任務幾秒後就停了

三分鐘的後臺執行時間對於批量下載是遠遠不夠的,三分鐘後發生了什麼,咱們看下日誌,這裏摘取了一些關鍵日誌以下:

18:05:17	assertiond	[BackgroundDemo:12636] Setting up BG permission check timer for 30s
18:05:17	BackgroundDemo	hello 下載進度: 0.0751051
18:05:17	BackgroundDemo	hello 添加下載任務: https://test.com/class_ocs/slice/923373023660117946/raw/ts/669b14bdd49d7b1704b5c2241c492b1b/a5c175b811175d2005c9cf96cd2f830b.ts
18:05:17	BackgroundDemo	hello URLSessionDidFinishEventsForBackgroundURLSession: 調用
18:05:43	assertiond	[BackgroundDemo:12636] Sending background permission expiration warning!
18:05:48	assertiond	[BackgroundDemo:12636] Forcing crash report with description: BackgroundDemo:12636 has active assertions beyond permitted time: 
<BKProcessAssertion: 0x104273920; "com.apple.nsurlsessiond.handlesession com.mycompany.backgrounddemo.background1" (backgroundDownload:30s); id:…AEB425907B3C> (owner: nsurlsessiond:94)
18:05:48	assertiond	[BackgroundDemo:12636] Finished crash reporting.

複製代碼

能夠看到:

  1. 18:05:17系統發出了後臺執行最後通牒,30秒內不結束就要給顏色看了!
  2. 18:05:43系統發出最後一次警告
  3. 18:05:48系統強制殺死App,並生成了App崩潰報告

咱們打開崩潰日誌看下:

crash_18.05.png

看到了著名的錯誤碼0x8badf00d,"ate bad food":

The exception code 0x8badf00d indicates that an application has been terminated by iOS because a watchdog timeout occurred. The application took too long to launch, terminate, or respond to system events. One common cause of this is doing synchronous networking on the main thread. Whatever operation is on Thread 0 needs to be moved to a background thread, or processed differently, so that it does not block the main thread.

官網 提到是因爲主線程卡住時間太長,系統的watchdog將App強殺了。MrPeak的這篇和官網論壇裏的這篇也提到了另外的可能性,即App中出現了leaked Background Task,後臺任務沒有被正確end。到底是哪一種狀況,能夠進一步分析下崩潰時主線程的Stack:

Thread 0 Crashed:
0   libsystem_kernel.dylib        	0x0000000196505c60 mach_msg_trap + 8
1   CoreFoundation                	0x000000019690de10 __CFRunLoopServiceMachPort + 240
2   CoreFoundation                	0x0000000196908ab4 __CFRunLoopRun + 1344
3   CoreFoundation                	0x0000000196908254 CFRunLoopRunSpecific + 452
4   GraphicsServices              	0x0000000198b47d8c GSEventRunModal + 108
5   UIKitCore                     	0x00000001c3c504c0 UIApplicationMain + 216
6   BackgroundDemo                	0x00000001047025c4 0x104688000 + 501188
7   libdyld.dylib                 	0x00000001963c4fd8 start + 4
複製代碼

和MrPeak文中提到的Stack很是相似:

這個 stack 很經典,常常會看到,不須要 symbolicate 也能知道是幹啥,這是 UI 線程 runloop 處於 idle 狀態的 stack,在等待 kernel 的 message。表示 UI 線程此時處於閒置狀態,這種狀態下的系統強殺大機率是因爲 leaked Background Task 致使的。

基本能夠判定18:05:48的此次崩潰是由leaked Background Task致使。

因此罪魁禍首是後臺崩潰麼?並不見得,由於後臺下載在App崩潰、被掛起或殺死的狀況下仍然有效。只有當用戶手動殺死App,後臺下載纔會失效。能夠參考官方回覆

The behaviour of background sessions after a force quit has changed over time:

  • In iOS 7 the background session and all its tasks just ‘disappear’.
  • In iOS 8 and later the background session persists but all of the tasks are cancelled.

When you terminate an app via the multitasking UI (force quit), the system interprets that as a strong indication that the app should do no more work in the background, and that includes NSURLSession background tasks.

objc.io上的這篇也提到了:

Tasks added to a background session are run in an external process and continue even if your app is suspended, crashes, or is killed.

(經驗證,App崩潰或者在後臺被強殺之後確實還能繼續下載,不過有一些注意點和坑,能夠參考iOS原生級別後臺下載詳解,寫的很是詳細。)

那麼咱們再次從接下來的日誌裏找找後臺下載的痕跡。繼續截取一些關鍵日誌:

18:05:48	symptomsd	Entry, display name com.mycompany.backgrounddemo uuid (null) pid 12636 isFront 0
18:11:28	nsurlsessiond	[C3794 Hostname#aca88929:443 tcp, bundle id: com.mycompany.backgrounddemo, url hash: 8f857432, traffic class: 100, tls, indefinite] start
18:11:30	nsurlsessiond	[C3794 Hostname#aca88929:443 tcp, bundle id: com.mycompany.backgrounddemo, url hash: 8f857432, traffic class: 100, tls, indefinite] cancel
18:11:30	nsurlsessiond	[C3794 Hostname#aca88929:443 tcp, bundle id: com.mycompany.backgrounddemo, url hash: 8f857432, traffic class: 100, tls, indefinite] cancelled
複製代碼

18:05:48是以前後臺崩潰完了之後的最後一條,隨後有大概5分30秒的時間內沒有一條日誌,直到18:11:28一個名爲nsurlsessiond的進程打印了幾條日誌。nsurlsessiond實際上就是接管了App後臺下載的系統daemon進程。奇怪的是,它start後立馬就cancel了,很是詭異的行爲。暫時無論,接着往下看日誌:

18:14:29	nsurlsessiond	[C3795 Hostname#aca88929:443 tcp, bundle id: com.mycompany.backgrounddemo, url hash: fae4ed2d, traffic class: 100, tls, indefinite] start
18:14:31	nsurlsessiond	[FBSSystemService][0xc652] Sending request to open "com.mycompany.backgrounddemo"
18:14:31	SpringBoard	[FBSystemService][0xc652] Received request to open "com.mycompany.backgrounddemo" from nsurlsessiond:94.
18:14:31	SpringBoard	Received trusted open application request for "com.mycompany.backgrounddemo" from <FBProcess: 0x28396e370; nsurlsessiond; pid: 94>.
18:14:31	SpringBoard	Executing request: <SBMainWorkspaceTransitionRequest: 0x28245b380; eventLabel: OpenApplication(com.mycompany.backgrounddemo)ForRequester(nsurlsessiond.94); display: Main; source: FBSystemService>
18:14:31	SpringBoard	Executing suspended-activation immediately: OpenApplication(com.mycompany.backgrounddemo)ForRequester(nsurlsessiond.94)
18:14:31	SpringBoard	Bootstrapping com.mycompany.backgrounddemo with intent background
18:14:31	SpringBoard	Application process state changed for com.mycompany.backgrounddemo: <SBApplicationProcessState: 0x280ba7ae0; pid: 12648; taskState: Running; visibility: Unknown>
複製代碼

又過了三分鐘,到18:14:29nsurlsessiond又一次start,此次它挺住了,向系統申請打開咱們的App。隨後SpringBoard贊成並幫它從後臺成功啓動了App,將App狀態置爲running。接下來:

18:14:31	assertiond	[BackgroundDemo:12648] Add assertion: <BKProcessAssertion: 0x1046029b0; id: 94-679EF71C-A8E3-42E4-B679-DE0D01BDBB0F; name: "com.apple.nsurlsessiond.handlesession com.mycompany.backgrounddemo.background1"; state: active; reason: backgroundDownload; duration: 30.0s> 
複製代碼

系統宣佈,nsurlsessiond中任務都處理完了,即將調用App中的application:handleEventsForBackgroundURLSession:completionHandler:方法了,而且傳入sessionIdentifiercom.mycompany.backgrounddemo.background1,而且給了App 最多30秒的後臺執行時間。接下來:

18:14:31	assertiond	[BackgroundDemo:12648] Mutating assertion reason from finishTask to finishTaskAfterBackgroundDownload
18:14:31	assertiond	[BackgroundDemo:12648] Add assertion: <BKProcessAssertion: 0x1042246a0; id: 12648-B0511F94-D84E-479C-AD8F-CA13B7CA55F6; name: "Shared Background Assertion 1 for com.mycompany.backgrounddemo"; state: active; reason: finishTaskAfterBackgroundDownload; duration: 40.0s> 
複製代碼

緊接着系統改主意了,給了App最多40秒的後臺執行時間。具體緣由不明。接下來系統果不其然,在大約40秒後又一次警告並殺死了App:

18:15:07	assertiond	[BackgroundDemo:12648] Sending background permission expiration warning!
18:15:12	assertiond	[BackgroundDemo:12648] Forcing crash report with description: BackgroundDemo:12648 has active assertions beyond permitted time
複製代碼

崩潰錯誤碼一樣是0x8badf00d。再而後,App就再不產生任何日誌了,能夠認爲這一波的App後臺行爲所有結束了。

打開App後發現下載進度還停留在App後臺滿三分鐘被殺死那一刻的進度,說明由nsurlsessiond接管的後臺下載根本沒有生效。

hmm,必定是哪裏有問題,通過review代碼,發現一個低級錯誤,多是不一樣團隊間溝通問題致使:在AppDelegate中雖然實現了application:handleEventsForBackgroundURLSession:completionHandler:,可是沒有正確把參數傳入BackgroundDownloader,致使後臺下載實現不完整。上面日誌中一些奇怪的點說不定就是這個引發的。OK,修復完bug再跑一遍,看看還有沒有別的坑。

爲了能更直觀地看清整個過程,我手擼了下面這張圖,能夠看到App進入後臺後,隨着時間推移,都發生了些什麼事。橫軸是時間,單位爲秒:

sequence_4.png

大體總結一下就是:進入後臺 -> 系統進程幹活 -> 系統喚醒App -> App添加新任務 -> App通知系統處理完成並掛起 -> 系統進程幹活 -> 系統喚醒App -> App添加新任務 -> App通知系統處理完成並掛起 -> ....... 如此循環往復直到系統以爲累了並再也不喚醒App(1290秒之後就沒有任何動靜了,再也不喚醒的緣由會在後面解釋)。

這裏簡單提一下,當系統在後臺下載這一批添加到四個分片的時候,App已經被suspend了,甚至已經掛了,所以這時候是不會執行App裏任何回調的。只有當系統完成了這一批添加的全部四個分片任務時,纔會喚醒App,而後App趁這個機會再添加新一批的四個分片任務。

Anyway,後臺下載算是生效了。

第二階段:提升後臺下載效率

讓咱們看看能不能繼續優化。

從上面的時序圖裏咱們能夠看到:

  1. 每次系統起來幹活就只能完成4個分片的下載。緣由是每次App被喚醒後就添加了4個分片下載任務,而後被掛起。隨後系統抽空開始新一輪的幹活。
  2. 每次系統起來幹活時間跨度很長,大概都須要三五分鐘才能幹完。緣由是後臺下載何時開始是不固定的,是系統根據運行環境和可用資源動態調配的,系統可能隔好幾分鐘纔開始分片的下載,也可能下一會停一會,或者在蜂窩網絡下中止下載。
  3. 系統在經歷五六次循環之後,就再也不工做了,致使後臺下載僅僅完成了20+個分片的下載,連一個完整資源都不能下完,更別提批量下多個資源了。緣由在官方文檔裏有解釋:

When the system resumes or relaunches your app, it uses a rate limiter to prevent abuse of background downloads. When your app starts a new download task while in the background, the task doesn't begin until the delay expires. The delay increases each time the system resumes or relaunches your app. As a result, if your app starts a single background download, gets resumed when the download completes, and then starts a new download, it will greatly increase the delay.

簡言之,後臺下載會隨着循環次數增長而推遲,直到最後系統罷工。解決方案官方文檔裏也提到了:

Instead, use a small number of background sessions — ideally just one — and use these sessions to start many download tasks at once. This allows the system to perform multiple downloads at once, and resume your app when they have completed.

也就是說用一個NSURLSession開啓儘量多的任務數,說不定能夠有所改善。不過文檔後面也補了一句:

Keep in mind, though, that each task has its own overhead. If you find you need to launch thousands of download tasks, change your design to perform fewer, larger transfers.

每個小分片任務的開啓都是須要代價的,一個資源裏有幾百個小分片,同時都開啓官方並不推薦。官方也更支持壓縮包下載的方式。以前也提過,分片下載是客戶端和服務端權衡下來的結果,米已成粥,咱們只能想辦法優化。

先試試看一個資源裏全部的分片同時開啓下載會怎麼樣。以前控制併發的方式是一個時間段內只容許最多resume4個task,每下好一個分片再resume一個新的。如今改成一次性把全部的taskresume。同時控制併發數爲4:

sessionConfiguration.HTTPMaximumConnectionsPerHost = 4;
複製代碼

這樣一來,能夠避免併發量過大,也能夠保證全部的分片任務都在NSURLSession的隊列中。又跑了一遍,整個過程以下:

sequence_max.png

和以前的過程相似,區別在於後臺下載每一個循環從完成4個分片增長到了完成資源中全部分片的下載。最終總共完成了4個完整資源的下載,比以前提升了很多。

雖然不是官方推薦的方式,但畢竟離目標又近了一步。

順帶提下,能夠經過看Console中是否有相似日誌來判斷後臺下載是否正在進行:

console_nsurlsessiond_working.png

第三階段:業界方案調研

咱們固然不知足於此,畢竟批量下載30個資源,才完成4個,是不管如何都說不過去的。而且整個時間跨度比較長,受限於系統的後臺策略,無法快馬加鞭地進行下載。

從以前的測試咱們能夠得出結論:要儘可能一次性把任務都添加到NSURLSession裏,這樣後臺下載才能持久。

咱們大膽的想一下,若是咱們在批量添加資源的時候,把全部資源的全部分片一次性給NSURLSession,那理論上應該是能夠所有都下完的。

不過這麼作有兩個問題:

  1. 更進一步違背了官方所倡導的最佳實踐,有可能帶來性能問題和一些意想不到的其餘問題
  2. 現有代碼改形成本很大

感受沒了方向的時候,看看業界有沒有相似的問題,以及他們是怎麼作的。

同事以前研究過騰訊視頻mac版,發現下載的都是ts小文件,所以推測騰訊視頻iOS端應該也是採用相似分片下載的方式。

我試了試騰訊視頻和愛奇藝的後臺下載,發現效果都很好,批量添加的任務均可以順利所有下完。對於批量下載大文件來講不稀奇,但騰訊視頻對於分片下載也能有這麼好的效果,我決定研究一番。

手機連上mac,用Console觀察運行日誌,發現:

  1. 不管騰訊視頻在前臺仍是後臺,Console日誌都差很少
  2. 不管在後臺放多久,Console日誌幾乎和以前沒區別
  3. 找不到一些後臺下載的特徵log,好比本文前面列出的一些log

難道蘋果跟鵝廠關係比較好因此... 打住,這麼想就太low了。(看了下愛奇藝的運行日誌,也相似)

種種跡象彷佛都代表,騰訊視頻並無真正被系統掛起,而是在後臺仍然處於active狀態。那麼,它是怎麼作到的。

有沒有一些種類的App是能夠在後臺保活的。有,相似導航App和音樂類App。

若是一個App擁有Background Audio權限,在後臺播放音樂,系統確定沒理由掛起它。

那麼若是這段音樂是一段沒有聲音的空音頻,系統應該也沒有辦法知道。

那麼騰訊視頻和愛奇藝是否是經過相似手段來實現後臺下載的呢,咱們來探一探。

咱們先用PP助手獲取到IPA,解開看看。

騰訊視頻

咱們發現資源包裏有一個不同凡響的音頻文件sound_drag_refresh.wav

tengxun_ipa.png

用QuickTimePlayer打開,選擇編輯 -> 修剪,能夠看到其波形圖:

tengxun_sound_compare.png

上面是正常音頻的波形圖,下面是sound_drag_refresh.wav的,能夠看到這應該是一段空音頻。爲何要放一個空音頻在bundle裏,而且起一個看似不相關的名字,使人浮想聯翩。

再用Hopper打開,搜索backgroundaudio,能夠找到一個名爲startInfiniteBackgroundAudioTask:的方法:

hopper_tengxun_backgroundaudio.png

這個方法名字比較可疑,不過看實現沒有直接證據代表和剛剛的空音頻有關係。

繼續搜索sound_drag_refresh,能夠看到有個叫restartPlayer的方法引用了它:

hopper_tengxun_sound.png

看看restartPlayer方法的實現:

hopper_tengxun_restartplayer.png

基本和網上這篇講iOS保活機制的文章裏的代碼有些相似之處:

//靜音文件
NSString *filePath = [[NSBundle mainBundle] pathForResource:@"音頻文件+文件名" ofType:@"mp3"];
NSURL *fileURL = [[NSURL alloc] initFileURLWithPath:filePath];
self.playerBack = [[AVAudioPlayer alloc] initWithContentsOfURL:fileURL error:nil];
[self.playerBack prepareToPlay];
// 0.0~1.0,默認爲1.0
self.playerBack.volume = 0.01;
// 循環播放
self.playerBack.numberOfLoops = -1;
複製代碼

注意這段:

mov.w      r2, #0xffffffff
movt       r0, #0x1e9 ; @selector(setNumberOfLoops:), :upper16:(0x21f2f88 - 0x361cee)
add        r0, pc       ; @selector(setNumberOfLoops:)
ldr        r1, [r0]     ; @selector(setNumberOfLoops:),"setNumberOfLoops:"
複製代碼

0xffffffff應該就是-1,放在r2寄存器中,做爲參數傳遞給[setNumberOfLoops:],表示循環播放。

題外話:armv7體系下,函數前四參數用r0r3來傳遞,r0selfr1_cmd

基本上破案了。至於內部是怎麼調用的,startInfiniteBackgroundAudioTask:restartPlayer怎麼串聯起來的,調研起來太費精力,且不是本文重點,這裏就忽略了。有興趣的讀者能夠研究下。

愛奇藝

用一樣的方法看下愛奇藝。

咱們也發現資源包裏有一個空音頻文件JM.wav(後續同事猜想爲靜默的拼音 2333):

iqiyi_sound.png

接着用Hopper打開,搜索jm,能夠找到下面幾個方法:

hopper_iqiyi_search_jm.png

咱們看下[QYOfflineBaseModelUtil isOpenJMAudio]的實現:

hopper_iqiyi_isopenjm.png

大概意思用僞代碼描述下:

+ (BOOL)isOpenJMAudio {
	if ([self isUseURLSession]) {
		// 若是用URLSession,則不要開啓後臺保活(這裏多是作了開關,能夠在原生後臺下載方式和播放靜默音頻保活間切換)
		return NO;
	} else {
		// 不然,若是有任務在下載,則開啓後臺保活,否則不開啓
		return [[QYDownloadTaskManager sharedInstance] isAnyTaskCanBeDownloaded];
	}
}
複製代碼

再看下[AppDelegate turnOnJM]的實現:

hopper_iqiyi_turnonjm.png

又看到了熟悉的[setNumberOfLoops:]0xffffffff,以及剛剛的JM.wav

好,又破案了。至此,業界作法基本搞清楚了:簡單粗暴,經過後臺保活機制,使得App在後臺的行爲像在前臺同樣。

幸運的是咱們的App原來已經有了Background Audio的權限,能夠依葫蘆畫瓢增長後臺保活機制,同時把NSURLSession相關後臺實現去掉。

實測下來耗電量並無增長多少,批量下載也和騰訊視頻同樣順暢了,均可以順利所有下完。

上線一個月以來,收到的相關用戶報障基本沒了。至此,趟坑之旅告一段落。

四. 小結

簡單對比下NSURLSession和後臺保活兩種機制:

指標 NSURLSession 後臺保活
耗電量 少,系統會作優化 多,但實測下來增長有限
速度
大文件批量下載 能夠所有下完 能夠所有下完
分片批量下載 實現成本高,官方不推薦,不必定能所有下完 實現成本低,能夠所有下完
遇到崩潰 能夠繼續下載 下載過程中止
權限 無須申請額外權限 須要申請Background Audio權限

綜上,能夠根據本身公司的業務訴求,能夠採用不一樣的策略實現iOS後臺下載,或者嘗試下二者的結合,應對崩潰的狀況。(以前測了下兩種機制都開啓好像會有問題,有興趣的能夠調研下)

這個系列下一篇準備講一講IAP掉單的優化。

完。

參考連接:

相關文章
相關標籤/搜索