iOS開發-App應用崩潰卡頓分析

App崩潰問題

app經常會遇見崩潰問題,比如下

  • 數據越界
  • 多線程操作同一指針,當指針爲空時崩潰
  • 野指針問題
  • KVO問題
  • NSNotification線程問題

以及不可捕獲的崩潰問題:

  • 後臺任務超時
  • App超過系統限制的內存大小被殺死
  • 主線程卡頓被殺死

可捕獲的崩潰信息收集

對於可以捕獲的崩潰問題,我們可以通過崩潰日誌分析,例如PLCrashReporterbugly這些三方崩潰分析工具。


PLCrashReporter實現

在崩潰日誌中,Exception信息頭部經常有
Exception Type: EXC_CRASH (SIGABRT)

常見的崩潰信息

SIGABRT就代表着一種崩潰信息,類似還有SIGSEGVSIGBUS

PLCrashReporter 通過註冊這些崩潰signalHandler回調就可以獲得崩潰信息。

PLCrashReporter 源碼中

/**
 * Register a new signal @a callback for @a signo.
 *
 * @param signo The signal for which a signal handler should be registered. Note that multiple callbacks may be registered
 * for a single signal, with chaining handled appropriately by the receiver. If multiple callbacks are registered, they may
 * <em>optionally</em> forward the signal to the next callback (and the original signal handler, if any was registered) via PLCrashSignalHandlerForward.
 * @param callback Callback to be issued upon receipt of a signal. The callback will execute on the crashed thread.
 * @param context Context to be passed to the callback. May be NULL.
 * @param outError A pointer to an NSError object variable. If an error occurs, this pointer will contain an error object indicating why
 * the signal handlers could not be registered. If no error occurs, this parameter will be left unmodified. You may specify
 * NULL for this parameter, and no error information will be provided.
 *
 * @warning Once registered, a callback may not be deregistered. This restriction may be removed in a future release.
 * @warning Callers must ensure that the PLCrashSignalHandler instance is not released and deallocated while callbacks remain active; in
 * a future release, this may result in the callbacks also being deregistered.
 */
- (BOOL) registerHandlerForSignal: (int) signo
                         callback: (PLCrashSignalHandlerCallbackFunc) callback
                          context: (void *) context
                            error: (NSError **) outError
{
    /* Register the actual signal handler, if necessary */
    if (![self registerHandlerWithSignal: signo error: outError])
        return NO;
    
    /* Add the new callback to the shared state list. */
    plcrash_signal_user_callback reg = {
        .callback = callback,
        .context = context
    };
    shared_handler_context.callbacks.nasync_prepend(reg);
    
    return YES;
}

可以查看 registerHandlerWithSignal 來查看實現。

PLCrashReporter 將這些崩潰堆棧信息存儲後,App就崩潰了,等待下次重啓時,就可以取到這些日誌,進行上傳分析。

系統接口

系統函數的優點在於性能好,但是隻能獲取簡單的信息,無法通過dSYM文件來了解是哪行代碼出的問題。

void InstallSignalHandler(void)
{
    signal(SIGHUP, SignalExceptionHandler);
    signal(SIGINT, SignalExceptionHandler);
    signal(SIGQUIT, SignalExceptionHandler);
    
    signal(SIGABRT, SignalExceptionHandler);
    signal(SIGILL, SignalExceptionHandler);
    signal(SIGSEGV, SignalExceptionHandler);
    signal(SIGFPE, SignalExceptionHandler);
    signal(SIGBUS, SignalExceptionHandler);
    signal(SIGPIPE, SignalExceptionHandler);
}

void SignalExceptionHandler(int signal)
{
    NSMutableString *mstr = [[NSMutableString alloc] init];
    [mstr appendString:@"Stack:\n"];
    void* callstack[128];
    int i, frames = backtrace(callstack, 128);
    char** strs = backtrace_symbols(callstack, frames);
    for (i = 0; i <frames; ++i) {
        [mstr appendFormat:@"%s\n", strs[i]];
    }
    //這裏寫入mstr到你的日誌文件

}

不可捕獲的崩潰

iOS後臺模式

  • 後臺模式,例如音樂播放VOIP地圖類app
  • Background Fetch設置一個間隔來每隔一段時間請求網絡數據。由於用戶可以在設置中關閉這種模式,導致它的使用場景很少
  • Slience Push 是推送的一種,會在後臺喚醒30秒,優先級很低。
  • Push Kit 後臺喚醒app後會保活30秒,主要用於VOIP應用提升體驗。
  • Background Task 是最常用也是使用最多的模式,會在進入後臺時請求3分鐘進行額外的操作。

對於 Background Task 你可以使用UIApplicationbeginBackgroundTaskWithName:expirationHandler: 或者beginBackgroundTaskWithExpirationHandler:方法來申請一些額外的時間。

官方文檔

任務最多執行3分鐘,超過時間,你的app將會被殺死,造成崩潰。這也是app退到後臺容易產生崩潰的原因。

對於這種崩潰,我們一般是在申請的3分鐘的Task中,開啓一個定時器,當快到3分鐘時檢測app還在運行就意味着App即將被系統殺死,這個時候進行記錄日誌上傳來達到監控的效果。


Runloop 卡頓

對於Runloop的卡頓分析,我們可以通過通過添加runloopCFRunLoopAddObserver添加觀察者來觀察主線程Runloop的狀態,通過回調返回,然後我們開啓子線程一個loop循環來檢測單位允許時間內(例如 3秒)是否收到了該狀態,如果超過了該值,則說明這個狀態轉變的時間過長了

注意:這個3秒時間,我們可以通過看門狗每個階段允許的事件來判斷,我們可通過下面的Watch Dog各個階段允許的超過時間來設置,不要大於看門狗殺死app的時間

CFRunLoopObserverRef 是觀察者,每個 Observer 都包含了一個回調(函數指針),當 RunLoop 的狀態發生變化時,觀察者就能通過回調接受到這個變化。可以觀測的時間點有以下幾個:

/* Run Loop Observer Activities */
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry         = (1UL << 0), // 即將進入Loop
    kCFRunLoopBeforeTimers  = (1UL << 1), // 即將處理 Timer
    kCFRunLoopBeforeSources = (1UL << 2), // 即將處理 Source
    kCFRunLoopBeforeWaiting = (1UL << 5), // 即將進入休眠
    kCFRunLoopAfterWaiting  = (1UL << 6), // 剛從休眠中喚醒
    kCFRunLoopExit          = (1UL << 7), // 即將退出Loop
    kCFRunLoopAllActivities = 0x0FFFFFFFU // 所有的狀態監聽
};

我們可以通過添加runloopCFRunLoopAddObserver添加觀察者來觀察kCFRunLoopBeforeSourceskCFRunLoopAfterWaiting來檢測卡頓。

爲什麼選擇這兩個時間段呢?系統事件(例如觸摸)大多以source1觸發,所以在kCFRunLoopBeforeSources之後執行,有人說那我們監聽kCFRunLoopBeforeSourceskCFRunLoopBeforeWaiting的時間不就好啦?但是runloop沒有事件時,是會停止在kCFRunLoopBeforeWaiting的,所以我們就監聽kCFRunLoopAfterWaiting,當兩個狀態回調時,將一個flag置爲NO(這裏是檢測3次來實現的),然後每隔幾秒查看這個flag,這樣監聽是否卡頓。


dispatchSemaphore = dispatch_semaphore_create(0); //Dispatch Semaphore保證同步
//創建一個觀察者
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                          kCFRunLoopAllActivities,
                                          YES,
                                          0,
                                          &runLoopObserverCallBack,
                                          &context);
//將觀察者添加到主線程runloop的common模式下的觀察中
CFRunLoopAddObserver(CFRunLoopGetMain(), runLoopObserver, kCFRunLoopCommonModes);

//回調
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
    SMLagMonitor *lagMonitor = (__bridge SMLagMonitor*)info;
    lagMonitor->runLoopActivity = activity;
    
    dispatch_semaphore_t semaphore = lagMonitor->dispatchSemaphore;
    dispatch_semaphore_signal(semaphore);
}

再進行創建子線程

//創建子線程監控
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        //子線程開啓一個持續的loop用來進行監控
        while (YES) {
            long semaphoreWait = dispatch_semaphore_wait(dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, STUCKMONITORRATE * NSEC_PER_MSEC));
            if (semaphoreWait != 0) {
                if (!runLoopObserver) {
                    timeoutCount = 0;
                    dispatchSemaphore = 0;
                    runLoopActivity = 0;
                    return;
                }
                //兩個runloop的狀態,BeforeSources和AfterWaiting這兩個狀態區間時間能夠檢測到是否卡頓
                if (runLoopActivity == kCFRunLoopBeforeSources || runLoopActivity == kCFRunLoopAfterWaiting) {
                    //出現三次出結果
                    if (++timeoutCount < 3) {
                        continue;
                    }
                    // TODO:寫入堆棧信息到日誌
                } //end activity
            }// end semaphore wait
            timeoutCount = 0;
        }// end while
    });

戴明老師的 GCDFetchFeed 中就包含了這個處理

關於Runloop卡頓檢測的詳細說明,我會在另一篇博文中說明。


Watch Dog

內存打爆主線程卡頓時間超過閾值都會被Watch Dog殺死

Watchdog 在app各個階段所允許的超時時間

launch(啓動)	20s
resume(恢復)	10s
suspend(掛起)	10s
quit(退出)	6s
background(後臺) 3min(iOS7之前,申請10min,之後改爲每次申請3min,可連續申請直到10min)

看門狗Watch Dog的崩潰異常代碼通常是“0x8badf00d”,即“ate bad food”。


一些被系統殺掉的情況,我們可以通過異常編碼來分析。你可以在維基百科上,查看完整的異常編碼。這裏列出了 44 種異常編碼,但常見的就是如下三種:

0x8badf00d,表示 App 在一定時間內無響應而被 watchdog 殺掉的情況。
0xdeadfa11,表示 App 被用戶強制退出。
0xc00010ff,表示 App 因爲運行造成設備溫度太高而被殺掉。


內存達到單個App上限被殺死

App達到iOS系統對單個app設置的內存佔用上限後,被系統殺死,我們稱之爲 out of memory(OOM)

我們如何獲取app的內存上限大小呢?


JetsamEvent 分析內存大小

對於內存問題殺死的問題,我們可以分析JetsamEvent日誌。

我們可以通過手機的設置->隱私->分析->分析數據中 找到JetsamEvent開頭的相關日誌信息。在這裏插入圖片描述
先找到per-process-limit內容

{
    "uuid" : "092ff2cc-0290-3310-a375-cc69c192b94d",
    "states" : [
      "daemon",
      "idle"
    ],
    "killDelta" : 6781,
    "lifetimeMax" : 8241,
    "age" : 4581135570382,
    "purgeable" : 485,
    "fds" : 50,
    "genCount" : 0,
    "coalition" : 832,
    "rpages" : 7736,
    "reason" : "per-process-limit",
    "pid" : 8326,
    "idleDelta" : 174877955757,
    "name" : "photoanalysisd",
    "cpuTime" : 111.697557
  },

"rpages" : 7736 表示內存頁數爲 7736 ,再從文件中找到pageSize字段

  "largestZoneSize" : 15192064,
  "pageSize" : 4096,
  "uncompressed" : 77779,
  "zoneMapSize" : 70713344,

則可以計算當前app內存限額 7736*4096/1024/1024 = 30MB

  • iOS是如何發現JetsamEvent
    iOS會開啓優先級最高級的線程vm_pressure_monitor來監聽系統內存情況,通過一個堆棧來維護所有app的進程。此外iOS還會維護一個內存快照表,來保存每個進程的內存頁消耗情況。

    iOS覺得內存有壓力,就會發出通知,告訴那些內存有壓力的app去釋放內存,didReceiveMemoryWarning就是這裏產生,在這裏釋放內存能夠可能避免你的app被系統殺死。

  • iOS殺死線程的優先級
    iOS內核有個數組,專門維護線程的優先級。
    內核使用的線程的優先級最高,操作系統其次,App最後。
    App在前臺的優先級高於後臺,且佔用線程多的App優先級將會降低

App在被iOS系統殺死前,系統大概有6s時間來判斷優先級,Jetsam日誌也是這個時間生成的.


XNU獲取內存限值

XNU獲取需要Root權限,通過memorystatus_priority_entry結構體獲取

// 獲取進程的 pid、優先級、狀態、內存閾值等信息
typedef struct memorystatus_priority_entry {
    pid_t pid;
    int32_t priority;
    uint64_t user_data;
    int32_t limit;
    uint32_t state;
} memorystatus_priority_entry_t;

參考文章 : https://www.jianshu.com/p/2a283df2e839


task_info接口

根據XNU源碼,我們可以知道apple獲取app使用信息的函數。

源碼鏈接

系統提供了一個函數獲取當前任務的信息,我們可以通過phys_footprint獲取app的使用內存

#import <mach/mach.h>

uint64_t memoryFootprint() {
    task_vm_info_data_t vmInfo;
    mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
    kern_return_t result = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
    if (result != KERN_SUCCESS)
        return 0;
    return vmInfo.phys_footprint;
}

內存分配監聽

內存分配函數malloccalloc等默認使用的是nano_zonenano_zone256B以下內存的分配,大於256B 的時候會使用 scalable_zone 來分配。

使用 scalable_zone 分配內存的函數都會調用malloc_logger 函數,因爲系統需要有一個地方統計並管理內存的分配情況。

我們可以通過 fishhook 進行hook內存分配的 mallloc_logger ,就可以掌握內存分配的大小。


參考文章 :
https://www.jianshu.com/p/302ed945e9cf
https://www.jianshu.com/p/2a283df2e839

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章