Bo2SS

Bo2SS

1 基礎篇(下)

今天要分享的是:基礎篇—— 上線階段,主要設計崩潰、卡頓、內存、日誌、性能、線程🧵和電量🔋的監控。

image

12 | iOS 崩潰千奇百怪,如何全面監控?#

先看看幾個常見的崩潰原因

  • 陣列問題:陣列越界,或者,給陣列添加了 nil 元素。
  • 多線程問題:在子線程中進行 UI 更新可能會發生崩潰,比如有一個線程在置空數據的同時,另一個線程在讀取這個數據。
  • 主線程無響應:主線程無響應時長超出系統規定時,會被 Watchdog 殺掉,對應的異常編碼是 0x8badf00d。
  • 訪問野指針:野指針指向的是一個已刪除的對象,它是最常見、卻又最難定位的一種崩潰情況。

程序崩潰對用戶的傷害是最大的,所以崩潰率(也就是一段時間內崩潰次數與啟動次數之比)成為了優先級最高的技術指標。

根據是否可以通過信號捕獲,崩潰信息可分為兩類:

  1. 可以通過信號捕獲:陣列越界、野指針、KVO 問題、NSNotification 線程問題等崩潰信息。
  2. 無法通過信號捕獲:後台任務超時、內存被打爆、主線程卡頓超閾值等信息。

收集信號可捕獲的崩潰日誌#

簡單粗暴方法:Xcode > Product > Archive,勾選 “Upload your app’s symbols to receive symbolicated reports from Apple”,以後就可以在 Xcode 的 Archive 裡看到符號化後的崩潰日誌了。

第三方開源庫:PLCrashReporterFabricBugly。第一種需要有自己的服務器,後兩種則適用於沒有服務端開發能力或者對數據不敏感的公司。

  • 監控原理:對各種信號進行註冊,捕獲到異常信號後,在處理方法 handleSignalException 裡通過 backtrace_symbols 方法獲取當前的堆棧信息,將堆棧信息先保存在本地,下次啟動時就可以上傳崩潰日誌了。

收集信號捕獲不到的崩潰信息#

背景知識:由於系統限制,系統強殺拋出的信號無法被捕獲到。

帶著 5 個問題:後台容易崩潰的原因是什麼呢?如何避免後台崩潰?怎麼去收集後台信號捕獲不到的那些崩潰信息呢?還有哪些信號捕獲不到的崩潰情況?怎樣監控其他無法通過信號捕獲的崩潰信息?

(一)後台容易崩潰的原因是什麼呢

先介紹下 iOS 後台保活的 5 種方式:

  1. Background Mode:通常只有地圖、音樂、VoIP 類 App 才能通過審核。
  2. Background Fetch:喚醒時間不穩定,用戶可以在系統設置裡關閉,所以使用場景較少。
  3. Silent Push:靜默推送,會在後台喚起 App 30 秒。它的優先級較低,會調用 application:didReceiveRemoteNotifiacation:fetchCompletionHandler: 這個 delegate,同普通的 remote push notification(遠程消息推送)調用的 delegate。
  4. PushKit:會在後台喚起 App 30 秒,主要用於提升 VoIP 應用的體驗。
  5. Background Task:App 退後台後,默認使用該方式,所以使用較多。

Background Task 這種方式,系統提供了 beginBackgroundTaskWithExpirationHandler 方法來延長後台執行時間,使用如下:

- (void)applicationDidEnterBackground:(UIApplication *)application {
    self.backgroundTaskIdentifier = [application beginBackgroundTaskWithExpirationHandler:^(void) {
        [self yourTask];
    }];
}

在這段代碼中,yourTask 任務最多執行 3 分鐘,任務完成後就掛起 App,但如果任務在 3 分鐘內沒有執行完,系統會強制殺掉進程,從而造成崩潰,這就是 App 退後台容易出現崩潰的原因

(二)如何避免後台崩潰呢

嚴格控制後台的數據讀寫操作。比如,先判斷需要處理的數據大小,如果數據過大,也就是在後台限制時間內也處理不完的話,可以考慮下次啟動或後台喚醒程序時再處理。

(三)怎麼去收集後台信號捕獲不到的崩潰信息呢

採用 Background Task 方式時,先設置一個計時器,在接近 3 分鐘(beginBackgroundTaskWithExpirationHandler 讓後台保活 3 分鐘)時判斷後台程序是否還在執行,如果還在執行,則判定該程序即將後台崩潰,此時馬上進行上報、記錄。

(四)還有哪些信號捕獲不到的崩潰情況

主要是內存被打爆和主線程卡頓超時被 Watchdog 殺掉這兩種情況。

(五)怎樣監控其他無法通過信號捕獲的崩潰信息

和監控後台崩潰類似,在臨近閾值時做處理,詳見下兩課~

采集到崩潰信息後如何分析並解決崩潰問題呢?#

崩潰日誌中,主要包含的信息有:

  1. 異常信息:異常類型、異常編碼、異常的線程;
  2. 線程回溯:崩潰時的方法調用棧。
  3. 進程信息:如崩潰報告唯一標識符、唯一鍵值、設備標識;
  4. 基本信息:崩潰發生的日期、iOS 版本;

通常分析過程:

  1. 分析「異常信息」裡的異常線程,在「線程回溯」裡分析異常線程的方法調用棧。從符號化後的方法調用棧裡,可以完整地看到方法調用的過程,方法調用棧頂就是最後導致崩潰的方法調用。
  2. 參考異常編碼。這裡列出了 44 種異常編碼,常見的三種是:0x8badf00d(App 在一定時間內無響應而被 watchdog 殺掉,詳見下一課)、0xdeadfa11(App 被用戶強制退出)、0xc00010ff(因為 App 運行造成設備溫度太高而被殺掉,詳見 18 電量優化那一課)。

⚠️:有些問題僅僅通過堆棧還無法分析出來,這時可以借助崩潰前用戶相關行為系統環境狀況的日誌來進一步分析,詳見 15 日誌監控那一課。

思考:怎樣才能夠讓崩潰信息的收集效率更高,丟失率更低?如何能夠收集到更多的崩潰信息?特別是系統強殺帶來的崩潰。

13 | 如何利用 RunLoop 原理去監控卡頓?#

卡頓問題,就是在主線程上無法響應用戶交互的問題,其原因包括:UI 繪製量過大;在主線程上做網絡同步請求,做大量的 IO 操作;運算量過大,CPU 持續高佔用;死鎖和主子線程搶鎖。

NSRunLoop 入手(線程的消息事件依賴於 NSRunLoop),可以知道主線程上調用了哪些方法;通過監聽 NSRunLoop 的狀態,可以發現調用方法的執行時間是否過長,從而可以監控卡頓情況。

下面先介紹介紹 RunLoop 的原理~

RunLoop 原理#

目的:當有事件要處理時保持線程忙,當沒有事件要處理時讓線程休眠。

任務:監聽輸入源,進行調度處理。

接收兩種類型的輸入源(輸入設備、網絡、周期性時間、延遲時間、異步回調):

  1. 另一個線程或者不同應用的異步消息;
  2. 預訂時間或者重複間隔的同步事件。

應用舉例:將繁重、不緊急、會佔用大量 CPU 的任務(比如圖片加載),放到空閒的 RunLoop 模式裡執行,避開 RunLoop 模式是 UITrackingRunLoopMode 時執行。 UITrackingRunLoopMode 模式:

  • 用戶進行滾動操作時會切換到的 RunLoop 模式;
  • 避免在該模式下執行繁重的 CPU 任務,可以提升用戶操作體驗。

工作原理

在 iOS 裡,RunLoop 對象由 CFRunLoop 實現,整個過程可以總結為下圖,具體可查看 CFRunLoop 源碼

RunLoop 過程 ——《極客時間》

loop 的六個狀態#

代碼定義如下:

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry ,         // 進入 loop
    kCFRunLoopBeforeTimers ,  // 觸發 Timer 回調
    kCFRunLoopBeforeSources , // 觸發 Source0 回調
    kCFRunLoopBeforeWaiting , // 等待 mach_port 消息
    kCFRunLoopAfterWaiting ,  // 接收 mach_port 消息
    kCFRunLoopExit ,          // 退出 loop
    kCFRunLoopAllActivities   // loop 所有狀態改變
}

RunLoop 的線程受阻情況:

  1. 進入睡眠前方法的執行時間過長(而導致無法進入睡眠);
  2. 線程喚醒後接收消息時間過長(而無法進入下一步)。

如果這個線程是主線程,表現出來的就是卡頓。

所以,如果要利用 RunLoop 原理來監控卡頓,就要關注這兩個 loop 狀態:

  1. kCFRunLoopBeforeSources:進入睡眠之前觸發 Source0 回調;
  2. kCFRunLoopAfterWaiting:喚醒後接收 mach_port 消息。

如何檢查卡頓?#

三步走:

  1. 創建一個 CFRunLoopObserverContext 觀察者;
  2. 將觀察者添加到主線程 RunLoop 的 common 模式下觀察;
  3. 創建一個持續的子線程監控主線程的 RunLoop 狀態。卡頓判斷:進入睡眠前的 kCFRunLoopBeforeSources 狀態,或者喚醒後的 kCFRunLoopAfterWaiting 狀態,在設置的時間閾值內一直沒有變化。

接下來,dump 出堆棧信息,即可進一步分析卡頓原因。

⚠️:觸發卡頓的時間閾值,可以根據 WatchDog 機制來設置。

  • 啟動(Launch):20s;
  • 恢復(Resume):10s;
  • 掛起(Suspend):10s;
  • 退出(Quit):6s;
  • 後台(Background):3min(在 iOS 7 之前,每次申請 10min; 之後改為每次申請 3min,可連續申請,最多申請到 10min)。

PS:觸發卡頓的時間閾值要小於 WatchDog 的限制時間

如何獲取卡頓的方法堆棧信息?#

1)直接調用系統函數(用 signal 獲取錯誤信息)。優點是性能消耗小;缺點是只能獲取簡單信息,無法配合 dSYM(符號表文件)來定位出現問題的代碼。因為性能較好,它適用於觀察大盤統計卡頓情況,不適合找卡頓原因的具體場景。

2)直接用 PLCrashReporter開源庫。特點是能夠定位到問題代碼的具體位置,而且在性能消耗上做了優化。

思考:為什麼要將卡頓監控放到線上做呢?主要是為了更大範圍的收集問題。總有一些卡頓問題,是由少數用戶的數據異常導致的。

相關資料深入理解 RunLoop——ibireme

14 | 臨近 OOM,如何獲取詳細內存分配信息,分析內存問題?#

OOM(Out of Memory):App 占用內存達到系統對單個 App 占用內存的上限後,被系統強殺的現象。

  • 它是由 iOS 的 Jetsam 機制(操作系統為了控制內存資源過度使用而採用的一種資源管控機制)導致的一種 “另类” 崩潰;
  • 日誌無法通過信號捕捉到它。

通過 JetsamEvent 日誌計算內存限制值#

查看手機中以 JetsamEvent 開頭的系統日誌(手機設置 > 隱私 > 分析與改進 > 分析數據),可以了解不同機器,在不同系統版本下,對 App 的內存限制。

關注上面系統日誌中崩潰原因為 per-process-limit 對應的 rpages:

  • per-process-limit:App 占用的內存超過了系統對單個 App 的內存限制;
  • rpages:App 占用的內存頁數量。

⚠️:

  • 內存頁大小的值,即日誌裡的 pageSize 值。
  • 被強殺掉的 App 無法獲取到系統級日誌,只能通過線下設備獲取。

iOS 系統監控 Jetsam

  1. 系統開啟優先級最高的線程 vm_pressure_monitor 監控系統的內存壓力情況,通過一個堆棧維護所有 App 的進程。另外,還維護一個內存快照表,用於保存每個進程內存頁的消耗情況。
  2. 當 vm_pressure_monitor 線程發現某 App 內存有壓力時,就會發出通知,內存有壓力的 App 執行對應的 didReceiveMemoryWarning 代理(這是釋放內存的機會,可以避免 App 被系統強殺)。

優先級判斷依據(系統在強殺 App 前,會先做優先級判斷):

  • 內核用 > 操作系統用 > App 用;
  • 前台 App > 後台運行 App;
  • 線程使用優先級時,CPU 占用多的線程的優先級會被降低。

通過 XNU 獲取內存限制值#

通過 XNU 的宏獲取 memorystatus_priority_entry 這個結構體,可以得到進程的優先級和內存限制值。

⚠️:通過 XNU 的宏獲取內存限制,需要有 root 權限,而 App 內的權限是不夠的,所以正常情況下,App 開發者是看不到這個信息的...

通過內存警告獲取內存限制值#

利用 didReceiveMemoryWarning 這個內存壓力代理事件來動態地獲取內存限制值,在代理事件裡:

  • 先通過 iOS 系統提供的 task_info 函數, 獲取當前任務的信息(task_info_t 結構體);
  • 再通過 task_info_t 結構裡的 resident_size 字段,即可獲取當前 App 占用的內存。

定位內存問題信息收集#

獲取到內存占用量還不夠,還需要知道是誰分配的內存,這樣才可以精確定位到問題的關鍵。而所有大內存的分配,不管外部函數是怎麼包裝的,最終都會調用 malloc_logger 函數。

  • 內存分配函數 malloc 和 calloc 等默認使用的是 nano_zone;
  • nano_zone 是 256B 以下小內存的分配,大於 256B 的內存分配會使用 scalable_zone;
  • 使用 scalable_zone 分配內存的函數都會調用 malloc_logger 函數,系統通過它來統計並管理內存的分配情況。

所以,可以使用 fishhook 去 Hook 這個函數,然後加上自己的統計記錄,就可以掌握內存的分配情況了。

PS:除內存過大被系統強殺外,還有以下三種內存問題:

  • 訪問未分配的內存: XNU 會報 EXC_BAD_ACCESS 錯誤,信號為 SIGSEGV Signal #11 。
  • 沒有遵守權限訪問內存:內存頁面的權限標準類似 UNIX 文件權限。如果去寫只讀權限的內存頁面會出現錯誤,XNU 會發出 SIGBUS Signal #7 信號。
  • 訪問已分配但未提交的內存:XNU 會攔截分配物理內存,出現問題的線程分配內存頁時會被冻结。

前兩種問題可以通過崩潰日誌獲取到,參考 12 崩潰那一課。

15 | 日誌監控:怎樣獲取 App 中的全量日誌?#

背景:前面分享了崩潰、卡頓、內存問題的監控,一旦監控到問題,就需要記錄下問題的詳細信息,形成日誌告知開發者,這樣開發者才能夠從這些日誌中定位問題。

全量日誌的定義:在 App 裡記錄的所有日誌,如用於記錄用戶行為和關鍵操作的日誌。

全量日誌的作用:便於開發者快速、精確地定位各種複雜問題,提高解決問題的效率。

然而,一個 App 很有可能是由多個團隊共同開發維護的,不同團隊使用的日誌庫由於歷史原因可能都不一樣,要麼是自己開發的,要麼就是使用了第三方日誌庫。那么,怎樣使用不侵入的方式去獲取 App 裡的所有日誌呢?

下面介紹 NSLogCocoaLumberjack 日誌的獲取方法,這兩種打日誌的方式基本覆蓋了大部分場景。

獲取 NSLog 的日誌#

NSLog 其實就是一個 C 函數 void NSLog(NSString *format, ...); ,作用是輸出信息到標準的 Error 控制台系統日誌中。

** 如何獲取 NSLog 的日誌呢?** 方法有三個:

1)使用 ASL 提供的接口。

在 iOS 10 之前,NSLog 內部使用的是 ASL(Apple System Logger,蘋果公司自己實現的一套輸出日誌系統)的 API,將日誌消息直接存儲在磁盤上。

借助第三方庫 CocoaLumberjack 的 [DDASLLogCapture start] 命令,捕獲所有 NSLog 的日誌,記錄為 CocoaLumberjack 的日誌。

捕獲原理

  • 在日誌被保存到 ASL 的數據庫時,syslogd(系統裡用於接收分發日誌消息的日誌守護進程) 會發出一條通知。
  • 通過 notify_register_dispatch 註冊該通知 com.apple.system.logger.message(kNotifyASLDBUpdate 宏),即日誌被保存到 ASL 數據庫時發出的進程間的系統通知。
  • 在收到通知後,利用 ASL 提供的接口(CocoaLumberjack 封裝了 asl_search、asl_next、aslMessageReceived: 等方法)迭代處理所有新日誌,最終記錄為 CocoaLumberjack 的日誌

其主要方法的代碼實現如下:

+ (void)captureAslLogs {
    @autoreleasepool {
        ...
        notify_register_dispatch(kNotifyASLDBUpdate, &notifyToken, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0),^(int token) {
            @autoreleasepool {
                ...
                // 利用進程標識兼容在模擬器情況時其他進程日誌無效通知
                [self configureAslQuery:query];

                // 迭代處理所有新日誌(發過來的這一條通知可能會有多條日誌)
                aslmsg msg;
                aslresponse response = asl_search(NULL, query);

                while ((msg = asl_next(response))) {
                    // 記錄日誌(記錄為CocoaLumberjack的日誌,默認為Verbose級別)
                    [self aslMessageReceived:msg];

                    lastSeenID = (unsigned long long)atoll(asl_get(msg, ASL_KEY_MSG_ID));
                }
                asl_release(response);
                asl_free(query);

                if (_cancel) {
                    notify_cancel(token);
                    return;
                }
            }
        });

PS:

  • 記錄為 CocoaLumberjack 的日誌後方便進一步獲取,詳見下一節。其日誌級別包括兩類:第一類是 Verbose 和 Debug ,屬於調試級;第二類是 Info、Warn、Error ,屬於正式級,需要持久化存儲,適用於記錄更重要的信息。這裡默認為 Verbose 級別。
  • 使用 NSLog 調試,會發生 IO 磁盤操作,所以頻繁使用 NSLog 不利於性能。
  • 還有很多跨進程通知,如在系統磁盤空間不足時,會發出 com.apple.system.lowdiskspace 通知(kNotifyVFSLowDiskSpace 宏)。

2)通過 fishhook 來 hook NSLog 方法。

為了使日誌更高效、更有組織,在 iOS 10 之後,使用了新的統一日誌系統(Unified Logging System)來記錄日誌,全面取代 ASL 的方式。

統一日誌系統

  • 把日誌集中存放在內存和數據庫裡,提供單一、高效和高性能的接口去獲取系統所有級別的消息傳遞;
  • 但沒有 ASL 那樣的接口來取出全部日誌。

所以,為了兼容新的統一日誌系統,需要對 NSLog 日誌的輸出進行重定向。又因為 NSLog 本身就是一個 C 函數,而不是 Objective-C 方法,所以使用 fishhook 來完成重定向的工作:

  • 通過 struct rebinding 定義原方法和重定向的方法。
  • 在重定向的方法中:
    • 可以先進行自己的處理,比如將日誌的輸出重新輸出到持久化存儲系統裡;
    • 接著調用 NSLog 也會調用的 NSLogv 方法進行原 NSLog 方法的調用,也可以使用 fishhook 提供的原方法調用方式。

3)用 dup2 函數重定向 STDERR 句柄。

NSLog 最後寫文件時的句柄是 STDERR(standard error,系統的錯誤日誌都會通過 STDERR 句柄來記錄),蘋果對 NSLog 的定義就是記錄錯誤的信息

dup2 函數是專門進行文件重定向的,如重定向 STDERR 句柄,關鍵代碼如下:

int fd = open(path_to_file, (O_RDWR | O_CREAT), 0644);
dup2(fd, STDERR_FILENO);

其中,path_to_file 就是自定義的重定向輸出的文件地址。

好了,各個系統版本的 NSLog 日誌都已經能夠被獲取到了。那通過其他方式打的日誌,該如何獲取呢?

下面聊聊獲取主流的第三方日誌庫 CocoaLumberjack 日誌的思路,其他第三庫大多是封裝了 CocoaLumberjack,所以思路類似。

獲取 CocoaLumberjack 日誌#

CocoaLumberjack 組成如下圖所示:

image

  • DDLogFormatter:用於格式化日誌的格式。
  • DDLogMessage:對日誌消息的封裝。
  • DDLog:全局的單例類,會保存遵守 DDLogger 協議的 logger。
  • DDLogger 協議:由 DDAbstractLogger 實現的。有 4 種 logger 繼承於 DDAbstractLogger:
    • DDTTYLogger:輸出日誌到控制台。
    • DDASLLogger:捕獲 NSLog 記錄到 ASL 數據庫的日誌。
    • DDFileLogger:保存日誌到文件。通過[fileLogger.logFileManager logsDirectory]可以獲取保存的文件路徑,從而可以獲取到 CocoaLumberjack 的所有日誌。
    • DDAbstractDatabaseLogger:數據庫操作的抽象接口。

收集全量日誌,可以提高分析和解決問題的效率,趕快去試試吧?

16 | 性能監控:衡量 App 質量的那把尺#

目的:主動、高效地發現性能問題,避免 App 質量進入無人監管的失控狀態。

監控方式:線下、線上。

線下性能監控:官方王牌 Instruments#

Instruments 被集成在 Xcode 裡,如下圖所示,它包含了各種性能檢測工具,如耗電量、內存泄漏、網絡情況等:

Instruments 提供的各種性能檢測工具 ——《極客時間》

從整體架構來看,Instruments 包括 Standard UI(標準界面)和 Analysis Core(分析核心)兩個組件,它的所有工具都是基於這兩個組件開發的。基於這兩個組件,你也可以開發自定義的 Instruments 工具(Instruments 10+):

  1. Xcode > File > New > Project > macOS > Instruments Package,生成一個.instrpkg 文件;
  2. 配置該文件,最主要的是要完成 Standard UI 和 Analysis Core 的配置;
  3. 參考蘋果官方提供的大量代碼片段,詳見 Instruments Developer Help

Analysis Core 的工作原理

主要就是收集和處理數據的過程,分三步:

1)處理我們配置好的 XML 數據表(用於可視化顯示),並申請存儲空間 store。

2)store 找相應的數據提供者,如果不能直接找到,就會通過其他 store 的輸入信號進行合成。

⚠️:使用 os_signpost API,來獲取數據,可參考 WWDC 2018 Session 410: Creating Custom Instruments 裡的示例。

3)store 獲得數據源後,會進行 Binding Solution 工作來優化數據處理過程。

PS:Instruments 通過 XML 標準數據接口解耦展示和分析數據的思路,值得學習。

線上性能監控#

兩個原則:

  • 不侵入業務代碼;
  • 性能消耗盡可能小。

主要指標:

CPU 使用率#

當前 App CPU 的使用率,即 App 中各個線程 CPU 使用率的總和。所以,定時遍歷每個線程,累加每個線程的 cpu_usage 值即可。

⚠️:

  • task_threads(mach_task_self(), &threads, &threadCount) 方法能夠取到當前進程中所有線程的數組 threads 和線程總數 threadCount。
  • thread_info((thread_act_t)threads[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount) 方法能夠取到線程 threads [i] 的基本信息 threadInfo。
  • cpu_usage 定義在 iOS 系統 > usr/include/mach/thread_info.h > thread_basic_info 結構體中。

內存#

類似 CPU 使用率,內存信息也有一個專門的結構體記錄,定義在 iOS 系統 > usr/include/mach/task.info.h > task_vm_info 結構體中,其中 phys_footprint 就是物理內存的使用。

⚠️:

  • task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &vmInfoCount 方法能夠取到當前進程的內存信息 vmInfo。
  • 物理內存由 phys_footprint 表示,而不是 resident_size(駐留內存:被映射到進程虛擬內存空間的物理內存)。

FPS#

FPS 低,表示 App 不流暢。

簡單實現:在 CADisplayLink 註冊的方法中,記錄刷新時間和刷新次數,這樣就可以得到一秒鐘屏幕刷新的次數,即 FPS。

⚠️:每次屏幕刷新都會調用一次 CADisplayLink 註冊的方法。

Tips:

  • 第三方監控平台推薦:螞蟻移動開發平台 mPaaS
  • 多關注蘋果公司自己的庫和工具,這裡面的設計思想和演進有大量可以吸取和學習的知識。

17 | 遠超你想象的多線程的那些坑#

現象:AFNetworking 2.0(網絡框架)、FMDB(第三方數據庫框架)等常用的基礎庫,使用多線程技術時非常謹慎;特別是 UIKit 沒有使用多線程技術,幹脆做成了線程不安全的,只能在主線程上操作。

為什麼會有這樣的現象呢?下面來看看多線程技術常見的兩個大坑:常駐線程並發問題

常駐線程#

定義:不會停止、一直存在於內存中的線程。

從何而來

使用 NSRunLoop 的 run 方法給該線程添加了一个 runloop,那麼該線程就會一直存在於內存中。

舉例:AFNetworking 2.0 創建常駐線程的代碼如下:

+ (void)networkRequestThreadEntryPoint:(id)__unused object {
    @autoreleasepool {
        // 先用 NSThread 創建了一個線程
        [[NSThread currentThread] setName:@"AFNetworking"];
        // 使用 run 方法添加 runloop
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}

AFNetworking 2.0 把每個請求封裝到了 NSOperationQueue 中,然後專門創建上面這個常駐線程來接收 NSOperationQueue 的回調。

沒有避開常駐線程這個坑,原因在於它網絡請求使用的 NSURLConnection 在設計上存在缺陷:NSURLConnection 發起請求後,所在的線程需要一直存活,以等待接收 NSURLConnectionDelegate 回調方法。但是,網絡返回的時間不確定,所以就需要常駐線程來處理。而單獨創建一個線程,而不使用主線程,則是因為主線程還要處理大量的 UI 和交互工作。

🎉:不過在 AFNetworking 3.0 中,它使用蘋果公司新推出的 NSURLSession 替換了 NSURLConnection,NSURLSession 可以指定回調為 NSOperationQueue,這樣就不需要常駐線程去等待請求的回調了。

如何避免

常駐線程過多,不但不能提高 CPU 的利用率,反而會降低程序的執行效率。

不創建常駐線程當然最好,但如果你確實需要線程保活一段時間,可以選擇:

  1. 使用 NSRunLoop 的另外兩個方法 runUntilDate:runMode:beforeDate: 來指定線程的保活時長,讓線程存活時間可預期。
  2. 使用 CFRunLoopRef 的 CFRunLoopRun 和 CFRunLoopStop 方法來完成 runloop 的開啟和停止,達到將線程保活一段時間的目的。

⚠️:通過 NSRunLoop 添加 runloop 的方法有 runrunUntilDate:runMode:beforeDate: 三種。其中, run 方法添加的 runloop ,會不斷地重複調用 runMode:beforeDate: 方法,來保證自己不會停止。

並發#

從何而來?同時創建了多個線程。

在 iOS 並發編程技術中,GCD(Grand Central Dispatch)的使用率是最高的,它是由蘋果公司開發的一個多核編程解決方案。

  • 優點:接口簡單易用,便於管理複雜線程(創建、釋放時機等)。
  • 缺點:資源使用上存在風險。如在數據庫讀寫場景中:
    • 在讀寫操作等待磁盤響應的時候,通過 GCD 發起一個任務;
    • 本著最大化利用 CPU 的原則,GCD 會在等待磁盤響應的這個空檔,再創建一個新線程來充分利用 CPU。
    • 而如果 GCD 發起的這些新任務,又都是需要等待磁盤響應的任務的話,那麼隨著任務數量的增加,GCD 創建的新線程就會越來越多,從而導致內存資源越來越緊張
    • 等到磁盤開始響應後,再讀取數據又會佔用更多的內存,最終將導致內存管理失控

如何避免

類似數據庫這種需要頻繁讀寫磁盤操作的任務,盡量使用串行隊列來管理,避免多線程並發導致內存問題

推薦:開源的第三方數據庫框架 FMDB,其核心類 FMDatabaseQueue 就是將與讀寫數據庫相關的磁盤操作都放到一個串行隊列裡去執行。

⚠️:線程過多時,內存CPU 都會有大量的消耗。

  • 系統需要分配一定的內存作為線程堆棧。在 iOS 開發中,主線程堆棧大小是 1MB,新創建的子線程堆棧大小是 512KB(堆棧大小是 4KB 的倍數)。
  • CPU 在切換線程上下文時,需要通過尋址來更新寄存器,而尋址的過程會有較大的 CPU 消耗。

Tips:多線程技術中鎖的問題是最容易查出來的,你更需要關注的,反而是那些藏在背後、會慢慢吃盡系統資源的問題。

18 | 怎麼減少 App 電量消耗?#

耗電過多的可能原因:開啟了定位;頻繁的網絡請求;定時任務時間間隔過小...

排除法查找具體位置:把功能一個個都註釋掉,觀察耗電量變化。

不過話說回來,只有先獲取到電量,才能發現電量問題。

如何獲取電量?#

使用系統提供的 batteryLevel 屬性,代碼如下:

- (float)getBatteryLevel {
    // 要想監控電量,必須允許
    [[UIDevice currentDevice] setBatteryMonitoringEnabled:YES];
    // 0.0 (沒電)、1.0 (滿電)、–1.0 (未開啟電池監控)
    float batteryLevel = [[UIDevice currentDevice] batteryLevel];
    
    NSLog(@"電池剩餘比例:%@", [NSString stringWithFormat:@"%f", batteryLevel * 100]);
    return batteryLevel;
}

參考 batteryLevel —— Apple 官方文檔。

PS:還可以給電量變化通知添加觀察者,在電量變化時調用自定義方法,從而監控電量。參考 UIDeviceBatteryLevelDidChangeNotification —— Apple 官方文檔。

如何診斷電量問題?#

如果用上述排除法還找不到問題所在,那麼這個耗電一定是由其他線程引起的,而這個耗電線程創建的地方可能是在第三方庫或者二方庫(公司內部其他團隊開發的庫)。

面對這種情況,我們直接觀察哪個線程是有問題的,比如說某個線程的 CPU 使用率長時間比較高,超過了 90%,就能夠推斷出它是有問題的。此時,將其方法堆棧記錄下來,就可以追根溯源了。

  • 觀察 CPU 使用率,可參考第 16 課 | 線上性能監控部分。
  • 記錄方法堆棧,可參考第 13 課 | 獲取卡頓的方法堆棧部分。

優化電量#

CPU 方面#

避免讓 CPU 做多餘的事情。

  1. 對於大量數據的複雜計算,交給服務器去處理。
  2. 必須要在 App 內處理的複雜計算,可以通過 GCD 的 dispatch_block_create_with_qos_class 方法指定隊列的 Qos 為 QOS_CLASS_UTILITY,將計算工作放到這個隊列的 block 裡。因為,在這種 Qos 模式下,系統針對大量數據的複雜計算專門做了電量優化。

I/O 方面#

任何的 I/O 操作,都会破坏掉低功耗状态。

  • 將碎片化的數據磁盤存儲操作延後,先在內存中聚合,然後再進行磁盤存儲。
  • 可以使用系統自帶的 NSCache 來完成數據在內存中的聚合:
    • 它是線程安全的。
    • 它會在到達預設的緩存空間值時清理緩存,並且觸發 cache:willEvictObject: 回調方法,在這個回調裡再對數據進行 I/O 操作。

相關案例:SDWebImage 圖片加載框架讀取緩存圖片。

- (UIImage *)imageFromMemoryCacheForKey:(NSString *)key {
    return [self.memCache objectForKey:key];
}

- (UIImage *)imageFromDiskCacheForKey:(NSString *)key {
    // 檢查 NSCache 裡是否存在圖片數據
    UIImage *image = [self imageFromMemoryCacheForKey:key];
    if (image) {
        // 如果有
        return image;
    }
    // 如果沒有,從磁盤裡讀
    UIImage *diskImage = [self diskImageForKey:key];
    if (diskImage && self.shouldCacheImagesInMemory) {
        NSUInteger cost = SDCacheCostForImage(diskImage);
        // 並存放到 NSCache 裡
        [self.memCache setObject:diskImage forKey:key cost:cost];
    }
    return diskImage;
}
  • 每次讀取圖片時,會檢查 NSCache 是否已經存在圖片數據。
    • 如果有,就直接從 NSCache 裡讀取;
    • 如果沒有,才會通過 I/O 讀取磁盤緩存圖片,並將獲取的圖片數據存放到 NSCache 裡。

蘋果公司參考#

  • Energy Efficiency Guide for iOS Apps”:蘋果公司專門維護的一個電量優化指南,分別從 CPU、設備喚醒、網絡、圖形、動畫、視頻、定位、加速度計、陀螺儀、磁力計、藍牙等多方面因素提出了電量優化方面的建議。
  • Writing Energy Efficient Apps”:蘋果公司在 2017 年 WWDC 的 Session 238 分享的一個關於如何編寫節能 App 的主題。

19 | 熱點問題答疑(二):基礎模塊問題答疑#

RunLoop 原理學習順序#

  1. 孫源線下分享 | RunLoop:對 RunLoop 的整體有一個大致的了解。
  2. RunLoop 官方文檔:全面詳細地了解蘋果公司設計的 RunLoop 機制,以及如何運用 RunLoop 來解決問題。
  3. ibireme | 深入理解 RunLoop:結合底層 CFRunLoop 的源碼,對 RunLoop 機制進行深入分析。

使用 dlopen () 能不能審核通過?#

使用 dlopen() 去讀取遠程動態庫,不能通過蘋果公司的審核。

蘋果公司在 2018 年 11 月集中下線 718 個 App 時提到,使用 dlopen()dlsym()respondsToSelector:performSelector:method_exchangeImplementations() 這些方法去執行遠程腳本,是不被允許的。因為:

  • 這些方法和遠程資源相結合,可能加載私有框架和私有方法,使 App 的行為發生重大變化,這就會和審核時的情況不一樣。
  • 即使使用的遠程資源本身不是惡意的,但是它們也很容易被劫持,使應用程序有安全漏洞,給用戶帶來不可預期的傷害。

matrix-iOS#

一個微信開源的卡頓監控系統。

matrix-iOS 減小其對 App 性能損耗的四個細節:

  1. 子線程監控檢測時間間隔:監控卡頓的子線程通過 NSThread 創建,檢測時間間隔正常情況是 1 秒,在出現卡頓情況下,間隔時間會受退火算法影響,按照斐波那契數列遞增,直到沒有卡頓時恢復為 1 秒。
  2. 子線程監控退火算法:避免同一個卡頓重複獲取主線程堆棧的情況。
  3. RunLoop 卡頓時間閾值設置:2 秒。
  4. CPU 使用率閾值設置:當單核 CPU 使用率超過 80%,就判定 CPU 占用過高。

參考:

以教為學,溫故而知新~

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。