帶你打造一套 APM 監控系統(二)

文章將近50000字,拆分爲。Github 上完整文章閱讀體驗更佳,請點擊訪問 Githubphp

6、 電量消耗

移動設備上電量一直是比較敏感的問題,若是用戶在某款 App 的時候發現耗電量嚴重、手機發熱嚴重,那麼用戶很大可能會立刻卸載這款 App。因此須要在開發階段關心耗電量問題。html

通常來講遇到耗電量較大,咱們立馬會想到是否是使用了定位、是否是使用了頻繁網絡請求、是否是不斷循環作某件事情?前端

開發階段基本沒啥問題,咱們能夠結合 Instrucments 裏的 Energy Log 工具來定位問題。可是線上問題就須要代碼去監控耗電量,能夠做爲 APM 的能力之一。node

1. 如何獲取電量

在 iOS 中,IOKit 是一個私有框架,用來獲取硬件和設備的詳細信息,也是硬件和內核服務通訊的底層框架。因此咱們能夠經過 IOKit來獲取硬件信息,從而獲取到電量信息。步驟以下:react

  • 首先在蘋果開放源代碼 opensource 中找到 IOPowerSources.hIOPSKeys.h。在 Xcode 的 Package Contents 裏面找到 IOKit.framework。 路徑爲 /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/IOKit.framework
  • 而後將 IOPowerSources.h、IOPSKeys.h、IOKit.framework 導入項目工程
  • 設置 UIDevice 的 batteryMonitoringEnabled 爲 true
  • 獲取到的耗電量精確度爲 1%

2. 定位問題

一般咱們經過 Instrucments 裏的 Energy Log 解決了不少問題後,App 上線了,線上的耗電量解決就須要使用 APM 來解決了。耗電地方多是二方庫、三方庫,也多是某個同事的代碼。android

思路是:在檢測到耗電後,先找到有問題的線程,而後堆棧 dump,還原案發現場。webpack

在上面部分咱們知道了線程信息的結構, thread_basic_info 中有個記錄 CPU 使用率百分比的字段 cpu_usage。因此咱們能夠經過遍歷當前線程,判斷哪一個線程的 CPU 使用率較高,從而找出有問題的線程。而後再 dump 堆棧,從而定位到發生耗電量的代碼。詳細請看 3.2 部分。ios

- (double)fetchBatteryCostUsage
{
  // returns a blob of power source information in an opaque CFTypeRef
    CFTypeRef blob = IOPSCopyPowerSourcesInfo();
    // returns a CFArray of power source handles, each of type CFTypeRef
    CFArrayRef sources = IOPSCopyPowerSourcesList(blob);
    CFDictionaryRef pSource = NULL;
    const void *psValue;
    // returns the number of values currently in an array
    int numOfSources = CFArrayGetCount(sources);
    // error in CFArrayGetCount
    if (numOfSources == 0) {
        NSLog(@"Error in CFArrayGetCount");
        return -1.0f;
    }

    // calculating the remaining energy
    for (int i=0; i<numOfSources; i++) {
        // returns a CFDictionary with readable information about the specific power source
        pSource = IOPSGetPowerSourceDescription(blob, CFArrayGetValueAtIndex(sources, i));
        if (!pSource) {
            NSLog(@"Error in IOPSGetPowerSourceDescription");
            return -1.0f;
        }
        psValue = (CFStringRef) CFDictionaryGetValue(pSource, CFSTR(kIOPSNameKey));

        int curCapacity = 0;
        int maxCapacity = 0;
        double percentage;

        psValue = CFDictionaryGetValue(pSource, CFSTR(kIOPSCurrentCapacityKey));
        CFNumberGetValue((CFNumberRef)psValue, kCFNumberSInt32Type, &curCapacity);

        psValue = CFDictionaryGetValue(pSource, CFSTR(kIOPSMaxCapacityKey));
        CFNumberGetValue((CFNumberRef)psValue, kCFNumberSInt32Type, &maxCapacity);

        percentage = ((double) curCapacity / (double) maxCapacity * 100.0f);
        NSLog(@"curCapacity : %d / maxCapacity: %d , percentage: %.1f ", curCapacity, maxCapacity, percentage);
        return percentage;
    }
    return -1.0f;
}
複製代碼

3. 開發階段針對電量消耗咱們能作什麼

CPU 密集運算是耗電量主要緣由。因此咱們對 CPU 的使用須要精打細算。儘可能避免讓 CPU 作無用功。對於大量數據的複雜運算,能夠藉助服務器的能力、GPU 的能力。若是方案設計必須是在 CPU 上完成數據的運算,則能夠利用 GCD 技術,使用 dispatch_block_create_with_qos_class(<#dispatch_block_flags_t flags#>, dispatch_qos_class_t qos_class, <#int relative_priority#>, <#^(void)block#>)() 並指定 隊列的 qos 爲 QOS_CLASS_UTILITY。將任務提交到這個隊列的 block 中,在 QOS_CLASS_UTILITY 模式下,系統針對大量數據的計算,作了電量優化c++

除了 CPU 大量運算,I/O 操做也是耗電主要緣由。業界常見方案都是將「碎片化的數據寫入磁盤存儲」這個操做延後,先在內存中聚合嗎,而後再進行磁盤存儲。碎片化數據先聚合,在內存中進行存儲的機制,iOS 提供 NSCache 這個對象。git

NSCache 是線程安全的,NSCache 會在達到達預設的緩存空間的條件時清理緩存,此時會觸發 - (**void**)cache:(NSCache *)cache willEvictObject:(**id**)obj; 方法回調,在該方法內部對數據進行 I/O 操做,達到將聚合的數據 I/O 延後的目的。I/O 次數少了,對電量的消耗也就減小了。

NSCache 的使用能夠查看 SDWebImage 這個圖片加載框架。在圖片讀取緩存處理時,沒直接讀取硬盤文件(I/O),而是使用系統的 NSCache。

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

- (nullable UIImage *)imageFromDiskCacheForKey:(nullable NSString *)key {
    UIImage *diskImage = [self diskImageForKey:key];
    if (diskImage && self.config.shouldCacheImagesInMemory) {
        NSUInteger cost = diskImage.sd_memoryCost;
        [self.memoryCache setObject:diskImage forKey:key cost:cost];
    }

    return diskImage;
}
複製代碼

能夠看到主要邏輯是先從磁盤中讀取圖片,若是配置容許開啓內存緩存,則將圖片保存到 NSCache 中,使用的時候也是從 NSCache 中讀取圖片。NSCache 的 totalCostLimit、countLimit 屬性,

- (void)setObject:(ObjectType)obj forKey:(KeyType)key cost:(NSUInteger)g; 方法用來設置緩存條件。因此咱們寫磁盤、內存的文件操做時能夠借鑑該策略,以優化耗電量。

7、 Crash 監控

1. 異常相關知識回顧

1.1 Mach 層對異常的處理

Mach 在消息傳遞基礎上實現了一套獨特的異常處理方法。Mach 異常處理在設計時考慮到:

  • 帶有一致的語義的單一異常處理設施:Mach 只提供一個異常處理機制用於處理全部類型的異常(包括用戶定義的異常、平臺無關的異常以及平臺特定的異常)。根據異常類型進行分組,具體的平臺能夠定義具體的子類型。
  • 清晰和簡潔:異常處理的接口依賴於 Mach 已有的具備良好定義的消息和端口架構,所以很是優雅(不會影響效率)。這就容許調試器和外部處理程序的拓展-甚至在理論上還支持拓展基於網絡的異常處理。

在 Mach 中,異常是經過內核中的基礎設施-消息傳遞機制處理的。一個異常並不比一條消息複雜多少,異常由出錯的線程或者任務(經過 msg_send()) 拋出,而後由一個處理程序經過 msg_recv())捕捉。處理程序能夠處理異常,也能夠清楚異常(將異常標記爲已完成並繼續),還能夠決定終止線程。

Mach 的異常處理模型和其餘的異常處理模型不一樣,其餘模型的異常處理程序運行在出錯的線程上下文中,而 Mach 的異常處理程序在不一樣的上下文中運行異常處理程序,出錯的線程向預先指定好的異常端口發送消息,而後等待應答。每個任務均可以註冊一個異常處理端口,這個異常處理端口會對該任務中的全部線程生效。此外,每一個線程均可以經過 thread_set_exception_ports(<#thread_act_t thread#>, <#exception_mask_t exception_mask#>, <#mach_port_t new_port#>, <#exception_behavior_t behavior#>, <#thread_state_flavor_t new_flavor#>) 註冊本身的異常處理端口。一般狀況下,任務和線程的異常端口都是 NULL,也就是異常不會被處理,而一旦建立異常端口,這些端口就像系統中的其餘端口同樣,能夠轉交給其餘任務或者其餘主機。(有了端口,就可使用 UDP 協議,經過網絡能力讓其餘的主機上應用程序處理異常)。

發生異常時,首先嚐試將異常拋給線程的異常端口,而後嘗試拋給任務的異常端口,最後再拋給主機的異常端口(即主機註冊的默認端口)。若是沒有一個端口返回 KERN_SUCCESS,那麼整個任務將被終止。也就是 Mach 不提供異常處理邏輯,只提供傳遞異常通知的框架。

異常首先是由處理器陷阱引起的。爲了處理陷阱,每個現代的內核都會安插陷阱處理程序。這些底層函數是由內核的彙編部分安插的。

1.2 BSD 層對異常的處理

BSD 層是用戶態主要使用的 XUN 接口,這一層展現了一個符合 POSIX 標準的接口。開發者可使用 UNIX 系統的一切功能,但不須要了解 Mach 層的細節實現。

Mach 已經經過異常機制提供了底層的陷進處理,而 BSD 則在異常機制之上構建了信號處理機制。硬件產生的信號被 Mach 層捕捉,而後轉換爲對應的 UNIX 信號,爲了維護一個統一的機制,操做系統和用戶產生的信號首先被轉換爲 Mach 異常,而後再轉換爲信號。

Mach 異常都在 host 層被 ux_exception 轉換爲相應的 unix 信號,並經過 threadsignal 將信號投遞到出錯的線程。

Mach 異常處理以及轉換爲 Unix 信號的流程

2. Crash 收集方式

iOS 系統自帶的 Apples`s Crash Reporter 在設置中記錄 Crash 日誌,咱們先觀察下 Crash 日誌

Incident Identifier: 7FA6736D-09E8-47A1-95EC-76C4522BDE1A
CrashReporter Key:   4e2d36419259f14413c3229e8b7235bcc74847f3
Hardware Model:      iPhone7,1
Process:         CMMonitorExample [3608]
Path:            /var/containers/Bundle/Application/9518A4F4-59B7-44E9-BDDA-9FBEE8CA18E5/CMMonitorExample.app/CMMonitorExample
Identifier:      com.Wacai.CMMonitorExample
Version:         1.0 (1)
Code Type:       ARM-64
Parent Process:  ? [1]

Date/Time:       2017-01-03 11:43:03.000 +0800
OS Version:      iOS 10.2 (14C92)
Report Version:  104

Exception Type:  EXC_CRASH (SIGABRT)
Exception Codes: 0x00000000 at 0x0000000000000000
Crashed Thread:  0

Application Specific Information:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[__NSSingleObjectArrayI objectForKey:]: unrecognized selector sent to instance 0x174015060'

Thread 0 Crashed:
0   CoreFoundation                  0x0000000188f291b8 0x188df9000 + 1245624 (<redacted> + 124)
1   libobjc.A.dylib                 0x000000018796055c 0x187958000 + 34140 (objc_exception_throw + 56)
2   CoreFoundation                  0x0000000188f30268 0x188df9000 + 1274472 (<redacted> + 140)
3   CoreFoundation                  0x0000000188f2d270 0x188df9000 + 1262192 (<redacted> + 916)
4   CoreFoundation                  0x0000000188e2680c 0x188df9000 + 186380 (_CF_forwarding_prep_0 + 92)
5   CMMonitorExample                0x000000010004c618 0x100044000 + 34328 (-[MakeCrashHandler throwUncaughtNSException] + 80)
複製代碼

會發現,Crash 日誌中 Exception Type 項由2部分組成:Mach 異常 + Unix 信號。

因此 Exception Type: EXC_CRASH (SIGABRT) 表示:Mach 層發生了 EXC_CRASH 異常,在 host 層被轉換爲 SIGABRT 信號投遞到出錯的線程。

問題: 捕獲 Mach 層異常、註冊 Unix 信號處理均可以捕獲 Crash,這兩種方式如何選擇?

答: 優選 Mach 層異常攔截。根據上面 1.2 中的描述咱們知道 Mach 層異常處理時機更早些,假如 Mach 層異常處理程序讓進程退出,這樣 Unix 信號永遠不會發生了。

業界關於崩潰日誌的收集開源項目不少,著名的有: KSCrash、plcrashreporter,提供一條龍服務的 Bugly、友盟等。咱們通常使用開源項目在此基礎上開發成符合公司內部需求的 bug 收集工具。一番對比後選擇 KSCrash。爲何選擇 KSCrash 不在本文重點。

KSCrash 功能齊全,能夠捕獲以下類型的 Crash

  • Mach kernel exceptions
  • Fatal signals
  • C++ exceptions
  • Objective-C exceptions
  • Main thread deadlock (experimental)
  • Custom crashes (e.g. from scripting languages)

因此分析 iOS 端的 Crash 收集方案也就是分析 KSCrash 的 Crash 監控實現原理。

2.1. Mach 層異常處理

大致思路是:先建立一個異常處理端口,爲該端口申請權限,再設置異常端口、新建一個內核線程,在該線程內循環等待異常。可是爲了防止本身註冊的 Mach 層異常處理搶佔了其餘 SDK、或者業務線開發者設置的邏輯,咱們須要在最開始保存其餘的異常處理端口,等邏輯執行完後將異常處理交給其餘的端口內的邏輯處理。收集到 Crash 信息後組裝數據,寫入 json 文件。

流程圖以下:

KSCrasg流程圖

對於 Mach 異常捕獲,能夠註冊一個異常端口,該端口負責對當前任務的全部線程進行監聽。

下面來看看關鍵代碼:

註冊 Mach 層異常監聽代碼

static bool installExceptionHandler()
{
    KSLOG_DEBUG("Installing mach exception handler.");

    bool attributes_created = false;
    pthread_attr_t attr;

    kern_return_t kr;
    int error;
    // 拿到當前進程
    const task_t thisTask = mach_task_self();
    exception_mask_t mask = EXC_MASK_BAD_ACCESS |
    EXC_MASK_BAD_INSTRUCTION |
    EXC_MASK_ARITHMETIC |
    EXC_MASK_SOFTWARE |
    EXC_MASK_BREAKPOINT;

    KSLOG_DEBUG("Backing up original exception ports.");
    // 獲取該 Task 上的註冊好的異常端口
    kr = task_get_exception_ports(thisTask,
                                  mask,
                                  g_previousExceptionPorts.masks,
                                  &g_previousExceptionPorts.count,
                                  g_previousExceptionPorts.ports,
                                  g_previousExceptionPorts.behaviors,
                                  g_previousExceptionPorts.flavors);
    // 獲取失敗走 failed 邏輯
    if(kr != KERN_SUCCESS)
    {
        KSLOG_ERROR("task_get_exception_ports: %s", mach_error_string(kr));
        goto failed;
    }
    // KSCrash 的異常爲空則走執行邏輯
    if(g_exceptionPort == MACH_PORT_NULL)
    {
        KSLOG_DEBUG("Allocating new port with receive rights.");
        // 申請異常處理端口
        kr = mach_port_allocate(thisTask,
                                MACH_PORT_RIGHT_RECEIVE,
                                &g_exceptionPort);
        if(kr != KERN_SUCCESS)
        {
            KSLOG_ERROR("mach_port_allocate: %s", mach_error_string(kr));
            goto failed;
        }

        KSLOG_DEBUG("Adding send rights to port.");
        // 爲異常處理端口申請權限:MACH_MSG_TYPE_MAKE_SEND
        kr = mach_port_insert_right(thisTask,
                                    g_exceptionPort,
                                    g_exceptionPort,
                                    MACH_MSG_TYPE_MAKE_SEND);
        if(kr != KERN_SUCCESS)
        {
            KSLOG_ERROR("mach_port_insert_right: %s", mach_error_string(kr));
            goto failed;
        }
    }

    KSLOG_DEBUG("Installing port as exception handler.");
    // 爲該 Task 設置異常處理端口
    kr = task_set_exception_ports(thisTask,
                                  mask,
                                  g_exceptionPort,
                                  EXCEPTION_DEFAULT,
                                  THREAD_STATE_NONE);
    if(kr != KERN_SUCCESS)
    {
        KSLOG_ERROR("task_set_exception_ports: %s", mach_error_string(kr));
        goto failed;
    }

    KSLOG_DEBUG("Creating secondary exception thread (suspended).");
    pthread_attr_init(&attr);
    attributes_created = true;
    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
    // 設置監控線程
    error = pthread_create(&g_secondaryPThread,
                           &attr,
                           &handleExceptions,
                           kThreadSecondary);
    if(error != 0)
    {
        KSLOG_ERROR("pthread_create_suspended_np: %s", strerror(error));
        goto failed;
    }
    // 轉換爲 Mach 內核線程
    g_secondaryMachThread = pthread_mach_thread_np(g_secondaryPThread);
    ksmc_addReservedThread(g_secondaryMachThread);

    KSLOG_DEBUG("Creating primary exception thread.");
    error = pthread_create(&g_primaryPThread,
                           &attr,
                           &handleExceptions,
                           kThreadPrimary);
    if(error != 0)
    {
        KSLOG_ERROR("pthread_create: %s", strerror(error));
        goto failed;
    }
    pthread_attr_destroy(&attr);
    g_primaryMachThread = pthread_mach_thread_np(g_primaryPThread);
    ksmc_addReservedThread(g_primaryMachThread);
    
    KSLOG_DEBUG("Mach exception handler installed.");
    return true;


failed:
    KSLOG_DEBUG("Failed to install mach exception handler.");
    if(attributes_created)
    {
        pthread_attr_destroy(&attr);
    }
    // 還原以前的異常註冊端口,將控制權還原
    uninstallExceptionHandler();
    return false;
}
複製代碼

處理異常的邏輯、組裝崩潰信息

/** Our exception handler thread routine.
 * Wait for an exception message, uninstall our exception port, record the
 * exception information, and write a report.
 */
static void* handleExceptions(void* const userData)
{
    MachExceptionMessage exceptionMessage = {{0}};
    MachReplyMessage replyMessage = {{0}};
    char* eventID = g_primaryEventID;

    const char* threadName = (const char*) userData;
    pthread_setname_np(threadName);
    if(threadName == kThreadSecondary)
    {
        KSLOG_DEBUG("This is the secondary thread. Suspending.");
        thread_suspend((thread_t)ksthread_self());
        eventID = g_secondaryEventID;
    }
    // 循環讀取註冊好的異常端口信息
    for(;;)
    {
        KSLOG_DEBUG("Waiting for mach exception");

        // Wait for a message.
        kern_return_t kr = mach_msg(&exceptionMessage.header,
                                    MACH_RCV_MSG,
                                    0,
                                    sizeof(exceptionMessage),
                                    g_exceptionPort,
                                    MACH_MSG_TIMEOUT_NONE,
                                    MACH_PORT_NULL);
        // 獲取到信息後則表明發生了 Mach 層異常,跳出 for 循環,組裝數據
        if(kr == KERN_SUCCESS)
        {
            break;
        }

        // Loop and try again on failure.
        KSLOG_ERROR("mach_msg: %s", mach_error_string(kr));
    }

    KSLOG_DEBUG("Trapped mach exception code 0x%x, subcode 0x%x",
                exceptionMessage.code[0], exceptionMessage.code[1]);
    if(g_isEnabled)
    {
        // 掛起全部線程
        ksmc_suspendEnvironment();
        g_isHandlingCrash = true;
        // 通知發生了異常
        kscm_notifyFatalExceptionCaptured(true);

        KSLOG_DEBUG("Exception handler is installed. Continuing exception handling.");


        // Switch to the secondary thread if necessary, or uninstall the handler
        // to avoid a death loop.
        if(ksthread_self() == g_primaryMachThread)
        {
            KSLOG_DEBUG("This is the primary exception thread. Activating secondary thread.");
// TODO: This was put here to avoid a freeze. Does secondary thread ever fire?
            restoreExceptionPorts();
            if(thread_resume(g_secondaryMachThread) != KERN_SUCCESS)
            {
                KSLOG_DEBUG("Could not activate secondary thread. Restoring original exception ports.");
            }
        }
        else
        {
            KSLOG_DEBUG("This is the secondary exception thread. Restoring original exception ports.");
//            restoreExceptionPorts();
        }

        // Fill out crash information
        // 組裝異常所須要的方案現場信息
        KSLOG_DEBUG("Fetching machine state.");
        KSMC_NEW_CONTEXT(machineContext);
        KSCrash_MonitorContext* crashContext = &g_monitorContext;
        crashContext->offendingMachineContext = machineContext;
        kssc_initCursor(&g_stackCursor, NULL, NULL);
        if(ksmc_getContextForThread(exceptionMessage.thread.name, machineContext, true))
        {
            kssc_initWithMachineContext(&g_stackCursor, 100, machineContext);
            KSLOG_TRACE("Fault address 0x%x, instruction address 0x%x", kscpu_faultAddress(machineContext), kscpu_instructionAddress(machineContext));
            if(exceptionMessage.exception == EXC_BAD_ACCESS)
            {
                crashContext->faultAddress = kscpu_faultAddress(machineContext);
            }
            else
            {
                crashContext->faultAddress = kscpu_instructionAddress(machineContext);
            }
        }

        KSLOG_DEBUG("Filling out context.");
        crashContext->crashType = KSCrashMonitorTypeMachException;
        crashContext->eventID = eventID;
        crashContext->registersAreValid = true;
        crashContext->mach.type = exceptionMessage.exception;
        crashContext->mach.code = exceptionMessage.code[0];
        crashContext->mach.subcode = exceptionMessage.code[1];
        if(crashContext->mach.code == KERN_PROTECTION_FAILURE && crashContext->isStackOverflow)
        {
            // A stack overflow should return KERN_INVALID_ADDRESS, but
            // when a stack blasts through the guard pages at the top of the stack,
            // it generates KERN_PROTECTION_FAILURE. Correct for this.
            crashContext->mach.code = KERN_INVALID_ADDRESS;
        }
        crashContext->signal.signum = signalForMachException(crashContext->mach.type, crashContext->mach.code);
        crashContext->stackCursor = &g_stackCursor;

        kscm_handleException(crashContext);

        KSLOG_DEBUG("Crash handling complete. Restoring original handlers.");
        g_isHandlingCrash = false;
        ksmc_resumeEnvironment();
    }

    KSLOG_DEBUG("Replying to mach exception message.");
    // Send a reply saying "I didn't handle this exception".
    replyMessage.header = exceptionMessage.header;
    replyMessage.NDR = exceptionMessage.NDR;
    replyMessage.returnCode = KERN_FAILURE;

    mach_msg(&replyMessage.header,
             MACH_SEND_MSG,
             sizeof(replyMessage),
             0,
             MACH_PORT_NULL,
             MACH_MSG_TIMEOUT_NONE,
             MACH_PORT_NULL);

    return NULL;
}
複製代碼

還原異常處理端口,轉移控制權

/** Restore the original mach exception ports.
 */
static void restoreExceptionPorts(void)
{
    KSLOG_DEBUG("Restoring original exception ports.");
    if(g_previousExceptionPorts.count == 0)
    {
        KSLOG_DEBUG("Original exception ports were already restored.");
        return;
    }

    const task_t thisTask = mach_task_self();
    kern_return_t kr;

    // Reinstall old exception ports.
    // for 循環去除保存好的在 KSCrash 以前註冊好的異常端口,將每一個端口註冊回去
    for(mach_msg_type_number_t i = 0; i < g_previousExceptionPorts.count; i++)
    {
        KSLOG_TRACE("Restoring port index %d", i);
        kr = task_set_exception_ports(thisTask,
                                      g_previousExceptionPorts.masks[i],
                                      g_previousExceptionPorts.ports[i],
                                      g_previousExceptionPorts.behaviors[i],
                                      g_previousExceptionPorts.flavors[i]);
        if(kr != KERN_SUCCESS)
        {
            KSLOG_ERROR("task_set_exception_ports: %s",
                        mach_error_string(kr));
        }
    }
    KSLOG_DEBUG("Exception ports restored.");
    g_previousExceptionPorts.count = 0;
}
複製代碼

2.2. Signal 異常處理

對於 Mach 異常,操做系統會將其轉換爲對應的 Unix 信號,因此開發者能夠經過註冊 signanHandler 的方式來處理。

KSCrash 在這裏的處理邏輯以下圖:

signal 處理步驟

看一下關鍵代碼:

設置信號處理函數

static bool installSignalHandler()
{
    KSLOG_DEBUG("Installing signal handler.");

#if KSCRASH_HAS_SIGNAL_STACK
    // 在堆上分配一塊內存,
    if(g_signalStack.ss_size == 0)
    {
        KSLOG_DEBUG("Allocating signal stack area.");
        g_signalStack.ss_size = SIGSTKSZ;
        g_signalStack.ss_sp = malloc(g_signalStack.ss_size);
    }
    // 信號處理函數的棧挪到堆中,而不和進程共用一塊棧區
    // sigaltstack() 函數,該函數的第 1 個參數 sigstack 是一個 stack_t 結構的指針,該結構存儲了一個「可替換信號棧」 的位置及屬性信息。第 2 個參數 old_sigstack 也是一個 stack_t 類型指針,它用來返回上一次創建的「可替換信號棧」的信息(若是有的話)
    KSLOG_DEBUG("Setting signal stack area.");
    // sigaltstack 第一個參數爲建立的新的可替換信號棧,第二個參數能夠設置爲NULL,若是不爲NULL的話,將會將舊的可替換信號棧的信息保存在裏面。函數成功返回0,失敗返回-1.
    if(sigaltstack(&g_signalStack, NULL) != 0)
    {
        KSLOG_ERROR("signalstack: %s", strerror(errno));
        goto failed;
    }
#endif

    const int* fatalSignals = kssignal_fatalSignals();
    int fatalSignalsCount = kssignal_numFatalSignals();

    if(g_previousSignalHandlers == NULL)
    {
        KSLOG_DEBUG("Allocating memory to store previous signal handlers.");
        g_previousSignalHandlers = malloc(sizeof(*g_previousSignalHandlers)
                                          * (unsigned)fatalSignalsCount);
    }

    // 設置信號處理函數 sigaction 的第二個參數,類型爲 sigaction 結構體
    struct sigaction action = {{0}};
    // sa_flags 成員設立 SA_ONSTACK 標誌,該標誌告訴內核信號處理函數的棧幀就在「可替換信號棧」上創建。
    action.sa_flags = SA_SIGINFO | SA_ONSTACK;
#if KSCRASH_HOST_APPLE && defined(__LP64__)
    action.sa_flags |= SA_64REGSET;
#endif
    sigemptyset(&action.sa_mask);
    action.sa_sigaction = &handleSignal;

    // 遍歷須要處理的信號數組
    for(int i = 0; i < fatalSignalsCount; i++)
    {
        // 將每一個信號的處理函數綁定到上面聲明的 action 去,另外用 g_previousSignalHandlers 保存當前信號的處理函數
        KSLOG_DEBUG("Assigning handler for signal %d", fatalSignals[i]);
        if(sigaction(fatalSignals[i], &action, &g_previousSignalHandlers[i]) != 0)
        {
            char sigNameBuff[30];
            const char* sigName = kssignal_signalName(fatalSignals[i]);
            if(sigName == NULL)
            {
                snprintf(sigNameBuff, sizeof(sigNameBuff), "%d", fatalSignals[i]);
                sigName = sigNameBuff;
            }
            KSLOG_ERROR("sigaction (%s): %s", sigName, strerror(errno));
            // Try to reverse the damage
            for(i--;i >= 0; i--)
            {
                sigaction(fatalSignals[i], &g_previousSignalHandlers[i], NULL);
            }
            goto failed;
        }
    }
    KSLOG_DEBUG("Signal handlers installed.");
    return true;

failed:
    KSLOG_DEBUG("Failed to install signal handlers.");
    return false;
}
複製代碼

信號處理時記錄線程等上下文信息

static void handleSignal(int sigNum, siginfo_t* signalInfo, void* userContext)
{
    KSLOG_DEBUG("Trapped signal %d", sigNum);
    if(g_isEnabled)
    {
        ksmc_suspendEnvironment();
        kscm_notifyFatalExceptionCaptured(false);
        
        KSLOG_DEBUG("Filling out context.");
        KSMC_NEW_CONTEXT(machineContext);
        ksmc_getContextForSignal(userContext, machineContext);
        kssc_initWithMachineContext(&g_stackCursor, 100, machineContext);
        // 記錄信號處理時的上下文信息
        KSCrash_MonitorContext* crashContext = &g_monitorContext;
        memset(crashContext, 0, sizeof(*crashContext));
        crashContext->crashType = KSCrashMonitorTypeSignal;
        crashContext->eventID = g_eventID;
        crashContext->offendingMachineContext = machineContext;
        crashContext->registersAreValid = true;
        crashContext->faultAddress = (uintptr_t)signalInfo->si_addr;
        crashContext->signal.userContext = userContext;
        crashContext->signal.signum = signalInfo->si_signo;
        crashContext->signal.sigcode = signalInfo->si_code;
        crashContext->stackCursor = &g_stackCursor;

        kscm_handleException(crashContext);
        ksmc_resumeEnvironment();
    }

    KSLOG_DEBUG("Re-raising signal for regular handlers to catch.");
    // This is technically not allowed, but it works in OSX and iOS.
    raise(sigNum);
}
複製代碼

KSCrash 信號處理後還原以前的信號處理權限

static void uninstallSignalHandler(void)
{
    KSLOG_DEBUG("Uninstalling signal handlers.");

    const int* fatalSignals = kssignal_fatalSignals();
    int fatalSignalsCount = kssignal_numFatalSignals();
    // 遍歷須要處理信號數組,將以前的信號處理函數還原
    for(int i = 0; i < fatalSignalsCount; i++)
    {
        KSLOG_DEBUG("Restoring original handler for signal %d", fatalSignals[i]);
        sigaction(fatalSignals[i], &g_previousSignalHandlers[i], NULL);
    }
    
    KSLOG_DEBUG("Signal handlers uninstalled.");
}
複製代碼

說明:

  1. 先從堆上分配一塊內存區域,被稱爲「可替換信號棧」,目的是將信號處理函數的棧幹掉,用堆上的內存區域代替,而不和進程共用一塊棧區。

    爲何這麼作?一個進程可能有 n 個線程,每一個線程都有本身的任務,假如某個線程執行出錯,這樣就會致使整個進程的崩潰。因此爲了信號處理函數正常運行,須要爲信號處理函數設置單獨的運行空間。另外一種狀況是遞歸函數將系統默認的棧空間用盡了,可是信號處理函數使用的棧是它實如今堆中分配的空間,而不是系統默認的棧,因此它仍舊能夠正常工做。

  2. int sigaltstack(const stack_t * __restrict, stack_t * __restrict) 函數的二個參數都是 stack_t 結構的指針,存儲了可替換信號棧的信息(棧的起始地址、棧的長度、狀態)。第1個參數該結構存儲了一個「可替換信號棧」 的位置及屬性信息。第 2 個參數用來返回上一次創建的「可替換信號棧」的信息(若是有的話)。

    _STRUCT_SIGALTSTACK
    {
    	void            *ss_sp;         /* signal stack base */
    	__darwin_size_t ss_size;        /* signal stack length */
    	int             ss_flags;       /* SA_DISABLE and/or SA_ONSTACK */
    };
    typedef _STRUCT_SIGALTSTACK     stack_t; /* [???] signal stack */
    複製代碼

    新建立的可替換信號棧,ss_flags 必須設置爲 0。系統定義了 SIGSTKSZ 常量,可知足絕大多可替換信號棧的需求。

    /* * Structure used in sigaltstack call. */
    
    #define SS_ONSTACK 0x0001 /* take signal on signal stack */
    #define SS_DISABLE 0x0004 /* disable taking signals on alternate stack */
    #define MINSIGSTKSZ 32768 /* (32K)minimum allowable stack */
    #define SIGSTKSZ 131072 /* (128K)recommended stack size */
    複製代碼

    sigaltstack 系統調用通知內核「可替換信號棧」已經創建。

    ss_flagsSS_ONSTACK 時,表示進程當前正在「可替換信號棧」中執行,若是此時試圖去創建一個新的「可替換信號棧」,那麼會遇到 EPERM (禁止該動做) 的錯誤;爲 SS_DISABLE 說明當前沒有已創建的「可替換信號棧」,禁止創建「可替換信號棧」。

  3. int sigaction(int, const struct sigaction * __restrict, struct sigaction * __restrict);

    第一個函數表示須要處理的信號值,但不能是 SIGKILLSIGSTOP ,這兩個信號的處理函數不容許用戶重寫,由於它們給超級用戶提供了終止程序的方法( SIGKILL and SIGSTOP cannot be caught, blocked, or ignored);

    第二個和第三個參數是一個 sigaction 結構體。若是第二個參數不爲空則表明將其指向信號處理函數,第三個參數不爲空,則將以前的信號處理函數保存到該指針中。若是第二個參數爲空,第三個參數不爲空,則能夠獲取當前的信號處理函數。

    /* * Signal vector "template" used in sigaction call. */
    struct sigaction {
    	union __sigaction_u __sigaction_u;  /* signal handler */
    	sigset_t sa_mask;               /* signal mask to apply */
    	int     sa_flags;               /* see signal options below */
    };
    複製代碼

    sigaction 函數的 sa_flags 參數須要設置 SA_ONSTACK 標誌,告訴內核信號處理函數的棧幀就在「可替換信號棧」上創建。

2.3. C++ 異常處理

c++ 異常處理的實現是依靠了標準庫的 std::set_terminate(CPPExceptionTerminate) 函數。

iOS 工程中某些功能的實現可能使用了C、C++等。假如拋出 C++ 異常,若是該異常能夠被轉換爲 NSException,則走 OC 異常捕獲機制,若是不能轉換,則繼續走 C++ 異常流程,也就是 default_terminate_handler。這個 C++ 異常的默認 terminate 函數內部調用 abort_message 函數,最後觸發了一個 abort 調用,系統產生一個 SIGABRT 信號。

在系統拋出 C++ 異常後,加一層 try...catch... 來判斷該異常是否能夠轉換爲 NSException,再從新拋出的C++異常。此時異常的現場堆棧已經消失,因此上層經過捕獲 SIGABRT 信號是沒法還原發生異常時的場景,即異常堆棧缺失。

爲何?try...catch... 語句內部會調用 __cxa_rethrow() 拋出異常,__cxa_rethrow() 內部又會調用 unwindunwind 能夠簡單理解爲函數調用的逆調用,主要用來清理函數調用過程當中每一個函數生成的局部變量,一直到最外層的 catch 語句所在的函數,並把控制移交給 catch 語句,這就是C++異常的堆棧消失緣由。

static void setEnabled(bool isEnabled)
{
    if(isEnabled != g_isEnabled)
    {
        g_isEnabled = isEnabled;
        if(isEnabled)
        {
            initialize();

            ksid_generate(g_eventID);
            g_originalTerminateHandler = std::set_terminate(CPPExceptionTerminate);
        }
        else
        {
            std::set_terminate(g_originalTerminateHandler);
        }
        g_captureNextStackTrace = isEnabled;
    }
}

static void initialize()
{
    static bool isInitialized = false;
    if(!isInitialized)
    {
        isInitialized = true;
        kssc_initCursor(&g_stackCursor, NULL, NULL);
    }
}

void kssc_initCursor(KSStackCursor *cursor,
                     void (*resetCursor)(KSStackCursor*),
                     bool (*advanceCursor)(KSStackCursor*))
{
    cursor->symbolicate = kssymbolicator_symbolicate;
    cursor->advanceCursor = advanceCursor != NULL ? advanceCursor : g_advanceCursor;
    cursor->resetCursor = resetCursor != NULL ? resetCursor : kssc_resetCursor;
    cursor->resetCursor(cursor);
}
複製代碼
static void CPPExceptionTerminate(void) {
    ksmc_suspendEnvironment();
    KSLOG_DEBUG("Trapped c++ exception");
    const char* name = NULL;
    std::type_info* tinfo = __cxxabiv1::__cxa_current_exception_type();
    if(tinfo != NULL)
    {
        name = tinfo->name();
    }
    
    if(name == NULL || strcmp(name, "NSException") != 0)
    {
        kscm_notifyFatalExceptionCaptured(false);
        KSCrash_MonitorContext* crashContext = &g_monitorContext;
        memset(crashContext, 0, sizeof(*crashContext));

        char descriptionBuff[DESCRIPTION_BUFFER_LENGTH];
        const char* description = descriptionBuff;
        descriptionBuff[0] = 0;

        KSLOG_DEBUG("Discovering what kind of exception was thrown.");
        g_captureNextStackTrace = false;
        try
        {
            throw;
        }
        catch(std::exception& exc)
        {
            strncpy(descriptionBuff, exc.what(), sizeof(descriptionBuff));
        }
#define CATCH_VALUE(TYPE, PRINTFTYPE) \ catch(TYPE value)\ { \ snprintf(descriptionBuff, sizeof(descriptionBuff), "%" #PRINTFTYPE, value); \ }
        CATCH_VALUE(char,                 d)
        CATCH_VALUE(short,                d)
        CATCH_VALUE(int,                  d)
        CATCH_VALUE(long,                ld)
        CATCH_VALUE(long long,          lld)
        CATCH_VALUE(unsigned char,        u)
        CATCH_VALUE(unsigned short,       u)
        CATCH_VALUE(unsigned int,         u)
        CATCH_VALUE(unsigned long,       lu)
        CATCH_VALUE(unsigned long long, llu)
        CATCH_VALUE(float,                f)
        CATCH_VALUE(double,               f)
        CATCH_VALUE(long double,         Lf)
        CATCH_VALUE(char*,                s)
        catch(...)
        {
            description = NULL;
        }
        g_captureNextStackTrace = g_isEnabled;

        // TODO: Should this be done here? Maybe better in the exception handler?
        KSMC_NEW_CONTEXT(machineContext);
        ksmc_getContextForThread(ksthread_self(), machineContext, true);

        KSLOG_DEBUG("Filling out context.");
        crashContext->crashType = KSCrashMonitorTypeCPPException;
        crashContext->eventID = g_eventID;
        crashContext->registersAreValid = false;
        crashContext->stackCursor = &g_stackCursor;
        crashContext->CPPException.name = name;
        crashContext->exceptionName = name;
        crashContext->crashReason = description;
        crashContext->offendingMachineContext = machineContext;

        kscm_handleException(crashContext);
    }
    else
    {
        KSLOG_DEBUG("Detected NSException. Letting the current NSException handler deal with it.");
    }
    ksmc_resumeEnvironment();

    KSLOG_DEBUG("Calling original terminate handler.");
    g_originalTerminateHandler();
}
複製代碼

2.4. Objective-C 異常處理

對於 OC 層面的 NSException 異常處理較爲容易,能夠經過註冊 NSUncaughtExceptionHandler 來捕獲異常信息,經過 NSException 參數來作 Crash 信息的收集,交給數據上報組件。

static void setEnabled(bool isEnabled) {
    if(isEnabled != g_isEnabled)
    {
        g_isEnabled = isEnabled;
        if(isEnabled)
        {
            KSLOG_DEBUG(@"Backing up original handler.");
            // 記錄以前的 OC 異常處理函數
            g_previousUncaughtExceptionHandler = NSGetUncaughtExceptionHandler();
            
            KSLOG_DEBUG(@"Setting new handler.");
            // 設置新的 OC 異常處理函數
            NSSetUncaughtExceptionHandler(&handleException);
            KSCrash.sharedInstance.uncaughtExceptionHandler = &handleException;
        }
        else
        {
            KSLOG_DEBUG(@"Restoring original handler.");
            NSSetUncaughtExceptionHandler(g_previousUncaughtExceptionHandler);
        }
    }
}
複製代碼

2.5. 主線程死鎖

主線程死鎖的檢測和 ANR 的檢測有些相似

  • 建立一個線程,在線程運行方法中用 do...while... 循環處理邏輯,加了 autorelease 避免內存太高

  • 有一個 awaitingResponse 屬性和 watchdogPulse 方法。watchdogPulse 主要邏輯爲設置 awaitingResponse 爲 YES,切換到主線程中,設置 awaitingResponse 爲 NO,

    - (void) watchdogPulse
    {
        __block id blockSelf = self;
        self.awaitingResponse = YES;
        dispatch_async(dispatch_get_main_queue(), ^
                       {
                           [blockSelf watchdogAnswer];
                       });
    }
    複製代碼
  • 線程的執行方法裏面不斷循環,等待設置的 g_watchdogInterval 後判斷 awaitingResponse 的屬性值是否是初始狀態的值,不然判斷爲死鎖

    - (void) runMonitor
    {
        BOOL cancelled = NO;
        do
        {
            // Only do a watchdog check if the watchdog interval is > 0.
            // If the interval is <= 0, just idle until the user changes it.
            @autoreleasepool {
                NSTimeInterval sleepInterval = g_watchdogInterval;
                BOOL runWatchdogCheck = sleepInterval > 0;
                if(!runWatchdogCheck)
                {
                    sleepInterval = kIdleInterval;
                }
                [NSThread sleepForTimeInterval:sleepInterval];
                cancelled = self.monitorThread.isCancelled;
                if(!cancelled && runWatchdogCheck)
                {
                    if(self.awaitingResponse)
                    {
                        [self handleDeadlock];
                    }
                    else
                    {
                        [self watchdogPulse];
                    }
                }
            }
        } while (!cancelled);
    }
    複製代碼

2.6 Crash 的生成與保存

2.6.1 Crash 日誌的生成邏輯

上面的部分講過了 iOS 應用開發中的各類 crash 監控邏輯,接下來就應該分析下 crash 捕獲後如何將 crash 信息記錄下來,也就是保存到應用沙盒中。

拿主線程死鎖這種 crash 舉例子,看看 KSCrash 是如何記錄 crash 信息的。

// KSCrashMonitor_Deadlock.m
- (void) handleDeadlock
{
    ksmc_suspendEnvironment();
    kscm_notifyFatalExceptionCaptured(false);

    KSMC_NEW_CONTEXT(machineContext);
    ksmc_getContextForThread(g_mainQueueThread, machineContext, false);
    KSStackCursor stackCursor;
    kssc_initWithMachineContext(&stackCursor, 100, machineContext);
    char eventID[37];
    ksid_generate(eventID);

    KSLOG_DEBUG(@"Filling out context.");
    KSCrash_MonitorContext* crashContext = &g_monitorContext;
    memset(crashContext, 0, sizeof(*crashContext));
    crashContext->crashType = KSCrashMonitorTypeMainThreadDeadlock;
    crashContext->eventID = eventID;
    crashContext->registersAreValid = false;
    crashContext->offendingMachineContext = machineContext;
    crashContext->stackCursor = &stackCursor;
    
    kscm_handleException(crashContext);
    ksmc_resumeEnvironment();

    KSLOG_DEBUG(@"Calling abort()");
    abort();
}
複製代碼

其餘幾個 crash 也是同樣,異常信息通過包裝交給 kscm_handleException() 函數處理。能夠看到這個函數被其餘幾種 crash 捕獲後所調用。

caller

/** Start general exception processing. * * @oaram context Contextual information about the exception. */
void kscm_handleException(struct KSCrash_MonitorContext* context) {
    context->requiresAsyncSafety = g_requiresAsyncSafety;
    if(g_crashedDuringExceptionHandling)
    {
        context->crashedDuringCrashHandling = true;
    }
    for(int i = 0; i < g_monitorsCount; i++)
    {
        Monitor* monitor = &g_monitors[i];
        // 判斷當前的 crash 監控是開啓狀態
        if(isMonitorEnabled(monitor))
        {
            // 針對每種 crash 類型作一些額外的補充信息
            addContextualInfoToEvent(monitor, context);
        }
    }
    // 真正處理 crash 信息,保存 json 格式的 crash 信息
    g_onExceptionEvent(context);

    
    if(g_handlingFatalException && !g_crashedDuringExceptionHandling)
    {
        KSLOG_DEBUG("Exception is fatal. Restoring original handlers.");
        kscm_setActiveMonitors(KSCrashMonitorTypeNone);
    }
}
複製代碼

g_onExceptionEvent 是一個 block,聲明爲 static void (*g_onExceptionEvent)(struct KSCrash_MonitorContext* monitorContext);KSCrashMonitor.c 中被賦值

void kscm_setEventCallback(void (*onEvent)(struct KSCrash_MonitorContext* monitorContext))
{
    g_onExceptionEvent = onEvent;
}
複製代碼

kscm_setEventCallback() 函數在 KSCrashC.c 文件中被調用

KSCrashMonitorType kscrash_install(const char* appName, const char* const installPath) {
    KSLOG_DEBUG("Installing crash reporter.");

    if(g_installed)
    {
        KSLOG_DEBUG("Crash reporter already installed.");
        return g_monitoring;
    }
    g_installed = 1;

    char path[KSFU_MAX_PATH_LENGTH];
    snprintf(path, sizeof(path), "%s/Reports", installPath);
    ksfu_makePath(path);
    kscrs_initialize(appName, path);

    snprintf(path, sizeof(path), "%s/Data", installPath);
    ksfu_makePath(path);
    snprintf(path, sizeof(path), "%s/Data/CrashState.json", installPath);
    kscrashstate_initialize(path);

    snprintf(g_consoleLogPath, sizeof(g_consoleLogPath), "%s/Data/ConsoleLog.txt", installPath);
    if(g_shouldPrintPreviousLog)
    {
        printPreviousLog(g_consoleLogPath);
    }
    kslog_setLogFilename(g_consoleLogPath, true);
    
    ksccd_init(60);
    // 設置 crash 發生時的 callback 函數
    kscm_setEventCallback(onCrash);
    KSCrashMonitorType monitors = kscrash_setMonitoring(g_monitoring);

    KSLOG_DEBUG("Installation complete.");
    return monitors;
}

/** Called when a crash occurs. * * This function gets passed as a callback to a crash handler. */
static void onCrash(struct KSCrash_MonitorContext* monitorContext) {
    KSLOG_DEBUG("Updating application state to note crash.");
    kscrashstate_notifyAppCrash();
    monitorContext->consoleLogPath = g_shouldAddConsoleLogToReport ? g_consoleLogPath : NULL;

    // 正在處理 crash 的時候,發生了再次 crash
    if(monitorContext->crashedDuringCrashHandling)
    {
        kscrashreport_writeRecrashReport(monitorContext, g_lastCrashReportFilePath);
    }
    else
    {
        // 1. 先根據當前時間建立新的 crash 的文件路徑
        char crashReportFilePath[KSFU_MAX_PATH_LENGTH];
        kscrs_getNextCrashReportPath(crashReportFilePath);
        // 2. 將新生成的文件路徑保存到 g_lastCrashReportFilePath
        strncpy(g_lastCrashReportFilePath, crashReportFilePath, sizeof(g_lastCrashReportFilePath));
        // 3. 將新生成的文件路徑傳入函數進行 crash 寫入
        kscrashreport_writeStandardReport(monitorContext, crashReportFilePath);
    }
}
複製代碼

接下來的函數就是具體的日誌寫入文件的實現。2個函數作的事情類似,都是格式化爲 json 形式並寫入文件。區別在於 crash 寫入時若是再次發生 crash, 則走簡易版的寫入邏輯 kscrashreport_writeRecrashReport(),不然走標準的寫入邏輯 kscrashreport_writeStandardReport()

bool ksfu_openBufferedWriter(KSBufferedWriter* writer, const char* const path, char* writeBuffer, int writeBufferLength) {
    writer->buffer = writeBuffer;
    writer->bufferLength = writeBufferLength;
    writer->position = 0;
    /* open() 的第二個參數描述的是文件操做的權限 #define O_RDONLY 0x0000 open for reading only #define O_WRONLY 0x0001 open for writing only #define O_RDWR 0x0002 open for reading and writing #define O_ACCMODE 0x0003 mask for above mode #define O_CREAT 0x0200 create if nonexistant #define O_TRUNC 0x0400 truncate to zero length #define O_EXCL 0x0800 error if already exists 0755:即用戶具備讀/寫/執行權限,組用戶和其它用戶具備讀寫權限; 0644:即用戶具備讀寫權限,組用戶和其它用戶具備只讀權限; 成功則返回文件描述符,若出現則返回 -1 */
    writer->fd = open(path, O_RDWR | O_CREAT | O_EXCL, 0644);
    if(writer->fd < 0)
    {
        KSLOG_ERROR("Could not open crash report file %s: %s", path, strerror(errno));
        return false;
    }
    return true;
}
複製代碼
/** * Write a standard crash report to a file. * * @param monitorContext Contextual information about the crash and environment. * The caller must fill this out before passing it in. * * @param path The file to write to. */
void kscrashreport_writeStandardReport(const struct KSCrash_MonitorContext* const monitorContext, const char* path) {
		KSLOG_INFO("Writing crash report to %s", path);
    char writeBuffer[1024];
    KSBufferedWriter bufferedWriter;

    if(!ksfu_openBufferedWriter(&bufferedWriter, path, writeBuffer, sizeof(writeBuffer)))
    {
        return;
    }

    ksccd_freeze();
    
    KSJSONEncodeContext jsonContext;
    jsonContext.userData = &bufferedWriter;
    KSCrashReportWriter concreteWriter;
    KSCrashReportWriter* writer = &concreteWriter;
    prepareReportWriter(writer, &jsonContext);

    ksjson_beginEncode(getJsonContext(writer), true, addJSONData, &bufferedWriter);

    writer->beginObject(writer, KSCrashField_Report);
    {
        writeReportInfo(writer,
                        KSCrashField_Report,
                        KSCrashReportType_Standard,
                        monitorContext->eventID,
                        monitorContext->System.processName);
        ksfu_flushBufferedWriter(&bufferedWriter);

        writeBinaryImages(writer, KSCrashField_BinaryImages);
        ksfu_flushBufferedWriter(&bufferedWriter);

        writeProcessState(writer, KSCrashField_ProcessState, monitorContext);
        ksfu_flushBufferedWriter(&bufferedWriter);

        writeSystemInfo(writer, KSCrashField_System, monitorContext);
        ksfu_flushBufferedWriter(&bufferedWriter);

        writer->beginObject(writer, KSCrashField_Crash);
        {
            writeError(writer, KSCrashField_Error, monitorContext);
            ksfu_flushBufferedWriter(&bufferedWriter);
            writeAllThreads(writer,
                            KSCrashField_Threads,
                            monitorContext,
                            g_introspectionRules.enabled);
            ksfu_flushBufferedWriter(&bufferedWriter);
        }
        writer->endContainer(writer);

        if(g_userInfoJSON != NULL)
        {
            addJSONElement(writer, KSCrashField_User, g_userInfoJSON, false);
            ksfu_flushBufferedWriter(&bufferedWriter);
        }
        else
        {
            writer->beginObject(writer, KSCrashField_User);
        }
        if(g_userSectionWriteCallback != NULL)
        {
            ksfu_flushBufferedWriter(&bufferedWriter);
            g_userSectionWriteCallback(writer);
        }
        writer->endContainer(writer);
        ksfu_flushBufferedWriter(&bufferedWriter);

        writeDebugInfo(writer, KSCrashField_Debug, monitorContext);
    }
    writer->endContainer(writer);
    
    ksjson_endEncode(getJsonContext(writer));
    ksfu_closeBufferedWriter(&bufferedWriter);
    ksccd_unfreeze();
}

/** Write a minimal crash report to a file. * * @param monitorContext Contextual information about the crash and environment. * The caller must fill this out before passing it in. * * @param path The file to write to. */
void kscrashreport_writeRecrashReport(const struct KSCrash_MonitorContext* const monitorContext, const char* path) {
  char writeBuffer[1024];
    KSBufferedWriter bufferedWriter;
    static char tempPath[KSFU_MAX_PATH_LENGTH];
    // 將傳遞過來的上份 crash report 文件名路徑(/var/mobile/Containers/Data/Application/******/Library/Caches/KSCrash/Test/Reports/Test-report-******.json)修改成去掉 .json ,加上 .old 成爲新的文件路徑 /var/mobile/Containers/Data/Application/******/Library/Caches/KSCrash/Test/Reports/Test-report-******.old

    strncpy(tempPath, path, sizeof(tempPath) - 10);
    strncpy(tempPath + strlen(tempPath) - 5, ".old", 5);
    KSLOG_INFO("Writing recrash report to %s", path);

    if(rename(path, tempPath) < 0)
    {
        KSLOG_ERROR("Could not rename %s to %s: %s", path, tempPath, strerror(errno));
    }
    // 根據傳入路徑來打開內存寫入須要的文件
    if(!ksfu_openBufferedWriter(&bufferedWriter, path, writeBuffer, sizeof(writeBuffer)))
    {
        return;
    }

    ksccd_freeze();
    // json 解析的 c 代碼
    KSJSONEncodeContext jsonContext;
    jsonContext.userData = &bufferedWriter;
    KSCrashReportWriter concreteWriter;
    KSCrashReportWriter* writer = &concreteWriter;
    prepareReportWriter(writer, &jsonContext);

    ksjson_beginEncode(getJsonContext(writer), true, addJSONData, &bufferedWriter);

    writer->beginObject(writer, KSCrashField_Report);
    {
        writeRecrash(writer, KSCrashField_RecrashReport, tempPath);
        ksfu_flushBufferedWriter(&bufferedWriter);
        if(remove(tempPath) < 0)
        {
            KSLOG_ERROR("Could not remove %s: %s", tempPath, strerror(errno));
        }
        writeReportInfo(writer,
                        KSCrashField_Report,
                        KSCrashReportType_Minimal,
                        monitorContext->eventID,
                        monitorContext->System.processName);
        ksfu_flushBufferedWriter(&bufferedWriter);

        writer->beginObject(writer, KSCrashField_Crash);
        {
            writeError(writer, KSCrashField_Error, monitorContext);
            ksfu_flushBufferedWriter(&bufferedWriter);
            int threadIndex = ksmc_indexOfThread(monitorContext->offendingMachineContext,
                                                 ksmc_getThreadFromContext(monitorContext->offendingMachineContext));
            writeThread(writer,
                        KSCrashField_CrashedThread,
                        monitorContext,
                        monitorContext->offendingMachineContext,
                        threadIndex,
                        false);
            ksfu_flushBufferedWriter(&bufferedWriter);
        }
        writer->endContainer(writer);
    }
    writer->endContainer(writer);

    ksjson_endEncode(getJsonContext(writer));
    ksfu_closeBufferedWriter(&bufferedWriter);
    ksccd_unfreeze();
}
複製代碼
2.6.2 Crash 日誌的讀取邏輯

當前 App 在 Crash 以後,KSCrash 將數據保存到 App 沙盒目錄下,App 下次啓動後咱們讀取存儲的 crash 文件,而後處理數據並上傳。

App 啓動後函數調用:

[KSCrashInstallation sendAllReportsWithCompletion:] -> [KSCrash sendAllReportsWithCompletion:] -> [KSCrash allReports] -> [KSCrash reportWithIntID:] ->[KSCrash loadCrashReportJSONWithID:] -> kscrs_readReport

sendAllReportsWithCompletion 裏讀取沙盒裏的Crash 數據。

// 先經過讀取文件夾,遍歷文件夾內的文件數量來判斷 crash 報告的個數
static int getReportCount()
{
    int count = 0;
    DIR* dir = opendir(g_reportsPath);
    if(dir == NULL)
    {
        KSLOG_ERROR("Could not open directory %s", g_reportsPath);
        goto done;
    }
    struct dirent* ent;
    while((ent = readdir(dir)) != NULL)
    {
        if(getReportIDFromFilename(ent->d_name) > 0)
        {
            count++;
        }
    }

done:
    if(dir != NULL)
    {
        closedir(dir);
    }
    return count;
}

// 經過 crash 文件個數、文件夾信息去遍歷,一次獲取到文件名(文件名的最後一部分就是 reportID),拿到 reportID 再去讀取 crash 報告內的文件內容,寫入數組
- (NSArray*) allReports
{
    int reportCount = kscrash_getReportCount();
    int64_t reportIDs[reportCount];
    reportCount = kscrash_getReportIDs(reportIDs, reportCount);
    NSMutableArray* reports = [NSMutableArray arrayWithCapacity:(NSUInteger)reportCount];
    for(int i = 0; i < reportCount; i++)
    {
        NSDictionary* report = [self reportWithIntID:reportIDs[i]];
        if(report != nil)
        {
            [reports addObject:report];
        }
    }
    
    return reports;
}

//  根據 reportID 找到 crash 信息
- (NSDictionary*) reportWithIntID:(int64_t) reportID
{
    NSData* jsonData = [self loadCrashReportJSONWithID:reportID];
    if(jsonData == nil)
    {
        return nil;
    }

    NSError* error = nil;
    NSMutableDictionary* crashReport = [KSJSONCodec decode:jsonData
                                                   options:KSJSONDecodeOptionIgnoreNullInArray |
                                                           KSJSONDecodeOptionIgnoreNullInObject |
                                                           KSJSONDecodeOptionKeepPartialObject
                                                     error:&error];
    if(error != nil)
    {
        KSLOG_ERROR(@"Encountered error loading crash report %" PRIx64 ": %@", reportID, error);
    }
    if(crashReport == nil)
    {
        KSLOG_ERROR(@"Could not load crash report");
        return nil;
    }
    [self doctorReport:crashReport];

    return crashReport;
}

//  reportID 讀取 crash 內容並轉換爲 NSData 類型
- (NSData*) loadCrashReportJSONWithID:(int64_t) reportID
{
    char* report = kscrash_readReport(reportID);
    if(report != NULL)
    {
        return [NSData dataWithBytesNoCopy:report length:strlen(report) freeWhenDone:YES];
    }
    return nil;
}

// reportID 讀取 crash 數據到 char 類型
char* kscrash_readReport(int64_t reportID)
{
    if(reportID <= 0)
    {
        KSLOG_ERROR("Report ID was %" PRIx64, reportID);
        return NULL;
    }

    char* rawReport = kscrs_readReport(reportID);
    if(rawReport == NULL)
    {
        KSLOG_ERROR("Failed to load report ID %" PRIx64, reportID);
        return NULL;
    }

    char* fixedReport = kscrf_fixupCrashReport(rawReport);
    if(fixedReport == NULL)
    {
        KSLOG_ERROR("Failed to fixup report ID %" PRIx64, reportID);
    }

    free(rawReport);
    return fixedReport;
}

// 多線程加鎖,經過 reportID 執行 c 函數 getCrashReportPathByID,將路徑設置到 path 上。而後執行 ksfu_readEntireFile 讀取 crash 信息到 result
char* kscrs_readReport(int64_t reportID)
{
    pthread_mutex_lock(&g_mutex);
    char path[KSCRS_MAX_PATH_LENGTH];
    getCrashReportPathByID(reportID, path);
    char* result;
    ksfu_readEntireFile(path, &result, NULL, 2000000);
    pthread_mutex_unlock(&g_mutex);
    return result;
}

int kscrash_getReportIDs(int64_t* reportIDs, int count)
{
    return kscrs_getReportIDs(reportIDs, count);
}

int kscrs_getReportIDs(int64_t* reportIDs, int count)
{
    pthread_mutex_lock(&g_mutex);
    count = getReportIDs(reportIDs, count);
    pthread_mutex_unlock(&g_mutex);
    return count;
}
// 循環讀取文件夾內容,根據 ent->d_name 調用 getReportIDFromFilename 函數,來獲取 reportID,循環內部填充數組
static int getReportIDs(int64_t* reportIDs, int count)
{
    int index = 0;
    DIR* dir = opendir(g_reportsPath);
    if(dir == NULL)
    {
        KSLOG_ERROR("Could not open directory %s", g_reportsPath);
        goto done;
    }

    struct dirent* ent;
    while((ent = readdir(dir)) != NULL && index < count)
    {
        int64_t reportID = getReportIDFromFilename(ent->d_name);
        if(reportID > 0)
        {
            reportIDs[index++] = reportID;
        }
    }

    qsort(reportIDs, (unsigned)count, sizeof(reportIDs[0]), compareInt64);

done:
    if(dir != NULL)
    {
        closedir(dir);
    }
    return index;
}

// sprintf(參數1, 格式2) 函數將格式2的值返回到參數1上,而後執行 sscanf(參數1, 參數2, 參數3),函數將字符串參數1的內容,按照參數2的格式,寫入到參數3上。crash 文件命名爲 "App名稱-report-reportID.json"
static int64_t getReportIDFromFilename(const char* filename)
{
    char scanFormat[100];
    sprintf(scanFormat, "%s-report-%%" PRIx64 ".json", g_appName);
    
    int64_t reportID = 0;
    sscanf(filename, scanFormat, &reportID);
    return reportID;
}
複製代碼

KSCrash 存儲 Crash 數據位置

2.7 前端 js 相關的 Crash 的監控

2.7.1 JavascriptCore 異常監控

這部分簡單粗暴,直接經過 JSContext 對象的 exceptionHandler 屬性來監控,好比下面的代碼

jsContext.exceptionHandler = ^(JSContext *context, JSValue *exception) {
    // 處理 jscore 相關的異常信息    
};
複製代碼
2.7.2 h5 頁面異常監控

當 h5 頁面內的 Javascript 運行異常時會 window 對象會觸發 ErrorEvent 接口的 error 事件,並執行 window.onerror()

window.onerror = function (msg, url, lineNumber, columnNumber, error) {
   // 處理異常信息
};
複製代碼

h5 異常監控

2.7.3 React Native 異常監控

小實驗:下圖是寫了一個 RN Demo 工程,在 Debug Text 控件上加了事件監聽代碼,內部人爲觸發 crash

<Text style={styles.sectionTitle} onPress={()=>{1+qw;}}>Debug</Text>
複製代碼

對比組1:

條件: iOS 項目 debug 模式。在 RN 端增長了異常處理的代碼。

模擬器點擊 command + d 調出面板,選擇 Debug,打開 Chrome 瀏覽器, Mac 下快捷鍵 Command + Option + J 打開調試面板,就能夠像調試 React 同樣調試 RN 代碼了。

React Native Crash Monitor

查看到 crash stack 後點擊能夠跳轉到 sourceMap 的地方。

Tips:RN 項目打 Release 包

  • 在項目根目錄下建立文件夾( release_iOS),做爲資源的輸出文件夾

  • 在終端切換到工程目錄,而後執行下面的代碼

    react-native bundle --entry-file index.js --platform ios --dev false --bundle-output release_ios/main.jsbundle --assets-dest release_iOS --sourcemap-output release_ios/index.ios.map;
    複製代碼
  • 將 release_iOS 文件夾內的 .jsbundleassets 文件夾內容拖入到 iOS 工程中便可

對比組2:

條件:iOS 項目 release 模式。在 RN 端不增長異常處理代碼

操做:運行 iOS 工程,點擊按鈕模擬 crash

現象:iOS 項目奔潰。截圖以及日誌以下

RN crash

2020-06-22 22:26:03.318 [info][tid:main][RCTRootView.m:294] Running application todos ({
    initialProps =     {
    };
    rootTag = 1;
})
2020-06-22 22:26:03.490 [info][tid:com.facebook.react.JavaScript] Running "todos" with {"rootTag":1,"initialProps":{}}
2020-06-22 22:27:38.673 [error][tid:com.facebook.react.JavaScript] ReferenceError: Can't find variable: qw
2020-06-22 22:27:38.675 [fatal][tid:com.facebook.react.ExceptionsManagerQueue] Unhandled JS Exception: ReferenceError: Can't find variable: qw
2020-06-22 22:27:38.691300+0800 todos[16790:314161] *** Terminating app due to uncaught exception 'RCTFatalException: Unhandled JS Exception: ReferenceError: Can't find variable: qw', reason: 'Unhandled JS Exception: ReferenceError: Can't find variable: qw, stack:
onPress@397:1821
<unknown>@203:3896
_performSideEffectsForTransition@210:9689
_performSideEffectsForTransition@(null):(null)
_receiveSignal@210:8425
_receiveSignal@(null):(null)
touchableHandleResponderRelease@210:5671
touchableHandleResponderRelease@(null):(null)
onResponderRelease@203:3006
b@97:1125
S@97:1268
w@97:1322
R@97:1617
M@97:2401
forEach@(null):(null)
U@97:2201
<unknown>@97:13818
Pe@97:90199
Re@97:13478
Ie@97:13664
receiveTouches@97:14448
value@27:3544
<unknown>@27:840
value@27:2798
value@27:812
value@(null):(null)
'
*** First throw call stack:
(
	0   CoreFoundation                      0x00007fff23e3cf0e __exceptionPreprocess + 350
	1   libobjc.A.dylib                     0x00007fff50ba89b2 objc_exception_throw + 48
	2   todos                               0x00000001017b0510 RCTFormatError + 0
	3   todos                               0x000000010182d8ca -[RCTExceptionsManager reportFatal:stack:exceptionId:suppressRedBox:] + 503
	4   todos                               0x000000010182e34e -[RCTExceptionsManager reportException:] + 1658
	5   CoreFoundation                      0x00007fff23e43e8c __invoking___ + 140
	6   CoreFoundation                      0x00007fff23e41071 -[NSInvocation invoke] + 321
	7   CoreFoundation                      0x00007fff23e41344 -[NSInvocation invokeWithTarget:] + 68
	8   todos                               0x00000001017e07fa -[RCTModuleMethod invokeWithBridge:module:arguments:] + 578
	9   todos                               0x00000001017e2a84 _ZN8facebook5reactL11invokeInnerEP9RCTBridgeP13RCTModuleDatajRKN5folly7dynamicE + 246
	10  todos                               0x00000001017e280c ___ZN8facebook5react15RCTNativeModule6invokeEjON5folly7dynamicEi_block_invoke + 78
	11  libdispatch.dylib                   0x00000001025b5f11 _dispatch_call_block_and_release + 12
	12  libdispatch.dylib                   0x00000001025b6e8e _dispatch_client_callout + 8
	13  libdispatch.dylib                   0x00000001025bd6fd _dispatch_lane_serial_drain + 788
	14  libdispatch.dylib                   0x00000001025be28f _dispatch_lane_invoke + 422
	15  libdispatch.dylib                   0x00000001025c9b65 _dispatch_workloop_worker_thread + 719
	16  libsystem_pthread.dylib             0x00007fff51c08a3d _pthread_wqthread + 290
	17  libsystem_pthread.dylib             0x00007fff51c07b77 start_wqthread + 15
)
libc++abi.dylib: terminating with uncaught exception of type NSException
(lldb) 
複製代碼

Tips:如何在 RN release 模式下調試(看到 js 側的 console 信息)

  • AppDelegate.m 中引入 #import <React/RCTLog.h>
  • - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions 中加入 RCTSetLogThreshold(RCTLogLevelTrace);

對比組3:

條件:iOS 項目 release 模式。在 RN 端增長異常處理代碼。

global.ErrorUtils.setGlobalHandler((e) => {
  console.log(e);
  let message = { name: e.name,
                message: e.message,
                stack: e.stack
  };
  axios.get('http://192.168.1.100:8888/test.php', {
  	params: { 'message': JSON.stringify(message) }
  }).then(function (response) {
  		console.log(response)
  }).catch(function (error) {
  console.log(error)
  });
}, true)
複製代碼

操做:運行 iOS 工程,點擊按鈕模擬 crash。

現象:iOS 項目不奔潰。日誌信息以下,對比 bundle 包中的 js。

RN release log

結論:

在 RN 項目中,若是發生了 crash 則會在 Native 側有相應體現。若是 RN 側寫了 crash 捕獲的代碼,則 Native 側不會奔潰。若是 RN 側的 crash 沒有捕獲,則 Native 直接奔潰。

RN 項目寫了 crash 監控,監控後將堆棧信息打印出來發現對應的 js 信息是通過 webpack 處理的,crash 分析難度很大。因此咱們針對 RN 的 crash 須要在 RN 側寫監控代碼,監控後須要上報,此外針對監控後的信息須要寫專門的 crash 信息還原給你,也就是 sourceMap 解析。

2.7.3.1 js 邏輯錯誤

寫過 RN 的人都知道在 DEBUG 模式下 js 代碼有問題則會產生紅屏,在 RELEASE 模式下則會白屏或者閃退,爲了體驗和質量把控須要作異常監控。

在看 RN 源碼時候發現了 ErrorUtils,看代碼能夠設置處理錯誤信息。

/** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @format * @flow strict * @polyfill */

let _inGuard = 0;

type ErrorHandler = (error: mixed, isFatal: boolean) => void;
type Fn<Args, Return> = (...Args) => Return;

/** * This is the error handler that is called when we encounter an exception * when loading a module. This will report any errors encountered before * ExceptionsManager is configured. */
let _globalHandler: ErrorHandler = function onError( e: mixed, isFatal: boolean, ) {
  throw e;
};

/** * The particular require runtime that we are using looks for a global * `ErrorUtils` object and if it exists, then it requires modules with the * error handler specified via ErrorUtils.setGlobalHandler by calling the * require function with applyWithGuard. Since the require module is loaded * before any of the modules, this ErrorUtils must be defined (and the handler * set) globally before requiring anything. */
const ErrorUtils = {
  setGlobalHandler(fun: ErrorHandler): void {
    _globalHandler = fun;
  },
  getGlobalHandler(): ErrorHandler {
    return _globalHandler;
  },
  reportError(error: mixed): void {
    _globalHandler && _globalHandler(error, false);
  },
  reportFatalError(error: mixed): void {
    // NOTE: This has an untyped call site in Metro.
    _globalHandler && _globalHandler(error, true);
  },
  applyWithGuard<TArgs: $ReadOnlyArray<mixed>, TOut>(
    fun: Fn<TArgs, TOut>,
    context?: ?mixed,
    args?: ?TArgs,
    // Unused, but some code synced from www sets it to null.
    unused_onError?: null,
    // Some callers pass a name here, which we ignore.
    unused_name?: ?string,
  ): ?TOut {
    try {
      _inGuard++;
      // $FlowFixMe: TODO T48204745 (1) apply(context, null) is fine. (2) array -> rest array should work
      return fun.apply(context, args);
    } catch (e) {
      ErrorUtils.reportError(e);
    } finally {
      _inGuard--;
    }
    return null;
  },
  applyWithGuardIfNeeded<TArgs: $ReadOnlyArray<mixed>, TOut>(
    fun: Fn<TArgs, TOut>,
    context?: ?mixed,
    args?: ?TArgs,
  ): ?TOut {
    if (ErrorUtils.inGuard()) {
      // $FlowFixMe: TODO T48204745 (1) apply(context, null) is fine. (2) array -> rest array should work
      return fun.apply(context, args);
    } else {
      ErrorUtils.applyWithGuard(fun, context, args);
    }
    return null;
  },
  inGuard(): boolean {
    return !!_inGuard;
  },
  guard<TArgs: $ReadOnlyArray<mixed>, TOut>(
    fun: Fn<TArgs, TOut>,
    name?: ?string,
    context?: ?mixed,
  ): ?(...TArgs) => ?TOut {
    // TODO: (moti) T48204753 Make sure this warning is never hit and remove it - types
    // should be sufficient.
    if (typeof fun !== 'function') {
      console.warn('A function must be passed to ErrorUtils.guard, got ', fun);
      return null;
    }
    const guardName = name ?? fun.name ?? '<generated guard>';
    function guarded(...args: TArgs): ?TOut {
      return ErrorUtils.applyWithGuard(
        fun,
        context ?? this,
        args,
        null,
        guardName,
      );
    }

    return guarded;
  },
};

global.ErrorUtils = ErrorUtils;

export type ErrorUtilsT = typeof ErrorUtils;
複製代碼

因此 RN 的異常可使用 global.ErrorUtils 來設置錯誤處理。舉個例子

global.ErrorUtils.setGlobalHandler(e => {
   // e.name e.message e.stack
}, true);
複製代碼
2.7.3.2 組件問題

其實對於 RN 的 crash 處理還有個須要注意的就是 React Error Boundaries詳細資料

過去,組件內的 JavaScript 錯誤會致使 React 的內部狀態被破壞,而且在下一次渲染時 產生 可能沒法追蹤的 錯誤。這些錯誤基本上是由較早的其餘代碼(非 React 組件代碼)錯誤引發的,但 React 並無提供一種在組件中優雅處理這些錯誤的方式,也沒法從錯誤中恢復。

部分 UI 的 JavaScript 錯誤不該該致使整個應用崩潰,爲了解決這個問題,React 16 引入了一個新的概念 —— 錯誤邊界。

錯誤邊界是一種 React 組件,這種組件能夠捕獲並打印發生在其子組件樹任何位置的 JavaScript 錯誤,而且,它會渲染出備用 UI,而不是渲染那些崩潰了的子組件樹。錯誤邊界在渲染期間、生命週期方法和整個組件樹的構造函數中捕獲錯誤。

它能捕獲子組件生命週期函數中的異常,包括構造函數(constructor)和 render 函數

而不能捕獲如下異常:

  • Event handlers(事件處理函數)
  • Asynchronous code(異步代碼,如setTimeout、promise等)
  • Server side rendering(服務端渲染)
  • Errors thrown in the error boundary itself (rather than its children)(異常邊界組件自己拋出的異常)

因此能夠經過異常邊界組件捕獲組件生命週期內的全部異常而後渲染兜底組件 ,防止 App crash,提升用戶體驗。也可引導用戶反饋問題,方便問題的排查和修復

至此 RN 的 crash 分爲2種,分別是 js 邏輯錯誤、組件 js 錯誤,都已經被監控處理了。接下來就看看如何從工程化層面解決這些問題

2.7.4 RN Crash 還原

SourceMap 文件對於前端日誌的解析相當重要,SourceMap 文件中各個參數和如何計算的步驟都在裏面有寫,能夠查看這篇文章

有了 SourceMap 文件,藉助於 mozilla source-map 項目,能夠很好的還原 RN 的 crash 日誌。

我寫了個 NodeJS 腳本,代碼以下

var fs = require('fs');
var sourceMap = require('source-map');
var arguments = process.argv.splice(2);

function parseJSError(aLine, aColumn) {
    fs.readFile('./index.ios.map', 'utf8', function (err, data) {
        const whatever =  sourceMap.SourceMapConsumer.with(data, null, consumer => {
            // 讀取 crash 日誌的行號、列號
            let parseData = consumer.originalPositionFor({
                line: parseInt(aLine),
                column: parseInt(aColumn)
            });
            // 輸出到控制檯
            console.log(parseData);
            // 輸出到文件中
            fs.writeFileSync('./parsed.txt', JSON.stringify(parseData) + '\n', 'utf8', function(err) {  
                if(err) {  
                    console.log(err);
                }
            });
        });
    });
}

var line = arguments[0];
var column = arguments[1];
parseJSError(line, column);
複製代碼

接下來作個實驗,仍是上述的 todos 項目。

  1. 在 Text 的點擊事件上模擬 crash

    <Text style={styles.sectionTitle} onPress={()=>{1+qw;}}>Debug</Text>
    複製代碼
  2. 將 RN 項目打 bundle 包、產出 sourceMap 文件。執行命令,

    react-native bundle --entry-file index.js --platform android --dev false --bundle-output release_ios/main.jsbundle --assets-dest release_iOS --sourcemap-output release_ios/index.android.map;
    複製代碼

    由於高頻使用,因此給 iterm2 增長 alias 別名設置,修改 .zshrc 文件

    alias RNRelease='react-native bundle --entry-file index.js --platform ios --dev false --bundle-output release_ios/main.jsbundle --assets-dest release_iOS --sourcemap-output release_ios/index.ios.map;' # RN 打 Release 包
    複製代碼
  3. 將 js bundle 和圖片資源拷貝到 Xcode 工程中

  4. 點擊模擬 crash,將日誌下面的行號和列號拷貝,在 Node 項目下,執行下面命令

    node index.js 397 1822
    複製代碼
  5. 拿腳本解析好的行號、列號、文件信息去和源代碼文件比較,結果很正確。

RN Log analysis

2.7.5 SourceMap 解析系統設計

目的:經過平臺能夠將 RN 項目線上 crash 能夠還原到具體的文件、代碼行數、代碼列數。能夠看到具體的代碼,能夠看到 RN stack trace、提供源文件下載功能。

  1. 打包系統下管理的服務器:
    • 生產環境下打包才生成 source map 文件
    • 存儲打包前的全部文件(install)
  2. 開發產品側 RN 分析界面。點擊收集到的 RN crash,在詳情頁能夠看到具體的文件、代碼行數、代碼列數。能夠看到具體的代碼,能夠看到 RN stack trace、Native stack trace。(具體技術實現上面講過了)
  3. 因爲 souece map 文件較大,RN 解析過長雖然不久,可是是對計算資源的消耗,因此須要設計高效讀取方式
  4. SourceMap 在 iOS、Android 模式下不同,因此 SoureceMap 存儲須要區分 os。

3. KSCrash 的使用包裝

而後再封裝本身的 Crash 處理邏輯。好比要作的事情就是:

  • 繼承自 KSCrashInstallation 這個抽象類,設置初始化工做(抽象類好比 NSURLProtocol 必須繼承後使用),實現抽象類中的 sink 方法。

    /** * Crash system installation which handles backend-specific details. * * Only one installation can be installed at a time. * * This is an abstract class. */
    @interface KSCrashInstallation : NSObject
    複製代碼
    #import "CMCrashInstallation.h"
    #import <KSCrash/KSCrashInstallation+Private.h>
    #import "CMCrashReporterSink.h"
    
    @implementation CMCrashInstallation
    
    + (instancetype)sharedInstance {
        static CMCrashInstallation *sharedInstance = nil;
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            sharedInstance = [[CMCrashInstallation alloc] init];
        });
        return sharedInstance;
    }
    
    - (id)init {
        return [super initWithRequiredProperties: nil];
    }
    
    - (id<KSCrashReportFilter>)sink {
        CMCrashReporterSink *sink = [[CMCrashReporterSink alloc] init];
        return [sink defaultCrashReportFilterSetAppleFmt];
    }
    
    @end
    複製代碼
  • sink 方法內部的 CMCrashReporterSink 類,遵循了 KSCrashReportFilter 協議,聲明瞭公有方法 defaultCrashReportFilterSetAppleFmt

    // .h
    #import <Foundation/Foundation.h>
    #import <KSCrash/KSCrashReportFilter.h>
    
    @interface CMCrashReporterSink : NSObject<KSCrashReportFilter>
    
    - (id <KSCrashReportFilter>) defaultCrashReportFilterSetAppleFmt;
    
    @end
    
    // .m
    #pragma mark - public Method
    
    - (id <KSCrashReportFilter>) defaultCrashReportFilterSetAppleFmt
    {
        return [KSCrashReportFilterPipeline filterWithFilters:
                [CMCrashReportFilterAppleFmt filterWithReportStyle:KSAppleReportStyleSymbolicatedSideBySide],
                self,
                nil];
    }
    複製代碼

    其中 defaultCrashReportFilterSetAppleFmt 方法內部返回了一個 KSCrashReportFilterPipeline 類方法 filterWithFilters 的結果。

    CMCrashReportFilterAppleFmt 是一個繼承自 KSCrashReportFilterAppleFmt 的類,遵循了 KSCrashReportFilter 協議。協議方法容許開發者處理 Crash 的數據格式。

    /** Filter the specified reports.
     *
     * @param reports The reports to process.
     * @param onCompletion Block to call when processing is complete.
     */
    - (void) filterReports:(NSArray*) reports
              onCompletion:(KSCrashReportFilterCompletion) onCompletion;
    複製代碼
    #import <KSCrash/KSCrashReportFilterAppleFmt.h>
    
    @interface CMCrashReportFilterAppleFmt : KSCrashReportFilterAppleFmt<KSCrashReportFilter>
    
    @end
      
    // .m
    - (void) filterReports:(NSArray*)reports onCompletion:(KSCrashReportFilterCompletion)onCompletion
      {
        NSMutableArray* filteredReports = [NSMutableArray arrayWithCapacity:[reports count]];
        for(NSDictionary *report in reports){
          if([self majorVersion:report] == kExpectedMajorVersion){
            id monitorInfo = [self generateMonitorInfoFromCrashReport:report];
            if(monitorInfo != nil){
              [filteredReports addObject:monitorInfo];
            }
          }
        }
        kscrash_callCompletion(onCompletion, filteredReports, YES, nil);
    }
    
    /**
     @brief 獲取Crash JSON中的crash時間、mach name、signal name和apple report
     */
    - (NSDictionary *)generateMonitorInfoFromCrashReport:(NSDictionary *)crashReport
    {
        NSDictionary *infoReport = [crashReport objectForKey:@"report"];
        // ...
        id appleReport = [self toAppleFormat:crashReport];
        
        NSMutableDictionary *info = [NSMutableDictionary dictionary];
        [info setValue:crashTime forKey:@"crashTime"];
        [info setValue:appleReport forKey:@"appleReport"];
        [info setValue:userException forKey:@"userException"];
        [info setValue:userInfo forKey:@"custom"];
        
        return [info copy];
    }
    複製代碼
    /**
     * A pipeline of filters. Reports get passed through each subfilter in order.
     *
     * Input: Depends on what's in the pipeline.
     * Output: Depends on what's in the pipeline.
     */
    @interface KSCrashReportFilterPipeline : NSObject <KSCrashReportFilter>
    複製代碼
  • APM 能力中爲 Crash 模塊設置一個啓動器。啓動器內部設置 KSCrash 的初始化工做,以及觸發 Crash 時候監控所需數據的組裝。好比:SESSION_ID、App 啓動時間、App 名稱、崩潰時間、App 版本號、當前頁面信息等基礎信息。

    /** C Function to call during a crash report to give the callee an opportunity to
     * add to the report. NULL = ignore.
     *
     * WARNING: Only call async-safe functions from this function! DO NOT call
     * Objective-C methods!!!
     */
    @property(atomic,readwrite,assign) KSReportWriteCallback onCrash;
    複製代碼
    + (instancetype)sharedInstance
    {
        static CMCrashMonitor *_sharedManager = nil;
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            _sharedManager = [[CMCrashMonitor alloc] init];
        });
        return _sharedManager;
    }
    
    
    #pragma mark - public Method
    
    - (void)startMonitor
    {
        CMMLog(@"crash monitor started");
    
    #ifdef DEBUG
        BOOL _trackingCrashOnDebug = [CMMonitorConfig sharedInstance].trackingCrashOnDebug;
        if (_trackingCrashOnDebug) {
            [self installKSCrash];
        }
    #else
        [self installKSCrash];
    #endif
    }
    
    #pragma mark - private method
    
    static void onCrash(const KSCrashReportWriter* writer)
    {
        NSString *sessionId = [NSString stringWithFormat:@"\"%@\"", ***]];
        writer->addJSONElement(writer, "SESSION_ID", [sessionId UTF8String], true);
        
        NSString *appLaunchTime = ***;
        writer->addJSONElement(writer, "USER_APP_START_DATE", [[NSString stringWithFormat:@"\"%@\"", appLaunchTime] UTF8String], true);
        // ...
    }
    
    - (void)installKSCrash
    {
        [[CMCrashInstallation sharedInstance] install];
        [[CMCrashInstallation sharedInstance] sendAllReportsWithCompletion:nil];
        [CMCrashInstallation sharedInstance].onCrash = onCrash;
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.f * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            _isCanAddCrashCount = NO;
        });
    }
    複製代碼

    installKSCrash 方法中調用了 [[CMCrashInstallation sharedInstance] sendAllReportsWithCompletion: nil],內部實現以下

    - (void) sendAllReportsWithCompletion:(KSCrashReportFilterCompletion) onCompletion
    {
        NSError* error = [self validateProperties];
        if(error != nil)
        {
            if(onCompletion != nil)
            {
                onCompletion(nil, NO, error);
            }
            return;
        }
    
        id<KSCrashReportFilter> sink = [self sink];
        if(sink == nil)
        {
            onCompletion(nil, NO, [NSError errorWithDomain:[[self class] description]
                                                      code:0
                                               description:@"Sink was nil (subclasses must implement method \"sink\")"]);
            return;
        }
        
        sink = [KSCrashReportFilterPipeline filterWithFilters:self.prependedFilters, sink, nil];
    
        KSCrash* handler = [KSCrash sharedInstance];
        handler.sink = sink;
        [handler sendAllReportsWithCompletion:onCompletion];
    }
    複製代碼

    方法內部將 KSCrashInstallationsink 賦值給 KSCrash 對象。 內部仍是調用了 KSCrashsendAllReportsWithCompletion 方法,實現以下

    - (void) sendAllReportsWithCompletion:(KSCrashReportFilterCompletion) onCompletion
    {
        NSArray* reports = [self allReports];
        
        KSLOG_INFO(@"Sending %d crash reports", [reports count]);
        
        [self sendReports:reports
             onCompletion:^(NSArray* filteredReports, BOOL completed, NSError* error)
         {
             KSLOG_DEBUG(@"Process finished with completion: %d", completed);
             if(error != nil)
             {
                 KSLOG_ERROR(@"Failed to send reports: %@", error);
             }
             if((self.deleteBehaviorAfterSendAll == KSCDeleteOnSucess && completed) ||
                self.deleteBehaviorAfterSendAll == KSCDeleteAlways)
             {
                 kscrash_deleteAllReports();
             }
             kscrash_callCompletion(onCompletion, filteredReports, completed, error);
         }];
    }
    複製代碼

    該方法內部調用了對象方法 sendReports: onCompletion:,以下所示

    - (void) sendReports:(NSArray*) reports onCompletion:(KSCrashReportFilterCompletion) onCompletion
    {
        if([reports count] == 0)
        {
            kscrash_callCompletion(onCompletion, reports, YES, nil);
            return;
        }
        
        if(self.sink == nil)
        {
            kscrash_callCompletion(onCompletion, reports, NO,
                                     [NSError errorWithDomain:[[self class] description]
                                                         code:0
                                                  description:@"No sink set. Crash reports not sent."]);
            return;
        }
        
        [self.sink filterReports:reports
                    onCompletion:^(NSArray* filteredReports, BOOL completed, NSError* error)
         {
             kscrash_callCompletion(onCompletion, filteredReports, completed, error);
         }];
    }
    複製代碼

    方法內部的 [self.sink filterReports: onCompletion: ] 實現其實就是 CMCrashInstallation 中設置的 sink getter 方法,內部返回了 CMCrashReporterSink 對象的 defaultCrashReportFilterSetAppleFmt 方法的返回值。內部實現以下

    - (id <KSCrashReportFilter>) defaultCrashReportFilterSetAppleFmt
    {
        return [KSCrashReportFilterPipeline filterWithFilters:
                [CMCrashReportFilterAppleFmt filterWithReportStyle:KSAppleReportStyleSymbolicatedSideBySide],
                self,
                nil];
    }
    複製代碼

    能夠看到這個函數內部設置了多個 filters,其中一個就是 self,也就是 CMCrashReporterSink 對象,因此上面的 [self.sink filterReports: onCompletion:] ,也就是調用 CMCrashReporterSink 內的數據處理方法。完了以後經過 kscrash_callCompletion(onCompletion, reports, YES, nil); 告訴 KSCrash 本地保存的 Crash 日誌已經處理完畢,能夠刪除了。

    - (void)filterReports:(NSArray *)reports onCompletion:(KSCrashReportFilterCompletion)onCompletion
    {
        for (NSDictionary *report in reports) {
            // 處理 Crash 數據,將數據交給統一的數據上報組件處理...
        }
        kscrash_callCompletion(onCompletion, reports, YES, nil);
    }
    複製代碼

    至此,歸納下 KSCrash 作的事情,提供各類 crash 的監控能力,在 crash 後將進程信息、基本信息、異常信息、線程信息等用 c 高效轉換爲 json 寫入文件,App 下次啓動後讀取本地的 crash 文件夾中的 crash 日誌,讓開發者能夠自定義 key、value 而後去上報日誌到 APM 系統,而後刪除本地 crash 文件夾中的日誌。

4. 符號化

應用 crash 以後,系統會生成一份崩潰日誌,存儲在設置中,應用的運行狀態、調用堆棧、所處線程等信息會記錄在日誌中。可是這些日誌是地址,並不可讀,因此須要進行符號化還原。

4.1 .dSYM 文件

.dSYM (debugging symbol)文件是保存十六進制函數地址映射信息的中轉文件,調試信息(symbols)都包含在該文件中。Xcode 工程每次編譯運行都會生成新的 .dSYM 文件。默認狀況下 debug 模式時不生成 .dSYM ,能夠在 Build Settings -> Build Options -> Debug Information Format 後將值 DWARF 修改成 DWARF with dSYM File,這樣再次編譯運行就能夠生成 .dSYM 文件。

因此每次 App 打包的時候都須要保存每一個版本的 .dSYM 文件。

.dSYM 文件中包含 DWARF 信息,打開文件的包內容 Test.app.dSYM/Contents/Resources/DWARF/Test 保存的就是 DWARF 文件。

.dSYM 文件是從 Mach-O 文件中抽取調試信息而獲得的文件目錄,發佈的時候爲了安全,會把調試信息存儲在單獨的文件,.dSYM 實際上是一個文件目錄,結構以下:

.dSYM文件結構

4.2 DWARF 文件

DWARF is a debugging file format used by many compilers and debuggers to support source level debugging. It addresses the requirements of a number of procedural languages, such as C, C++, and Fortran, and is designed to be extensible to other languages. DWARF is architecture independent and applicable to any processor or operating system. It is widely used on Unix, Linux and other operating systems, as well as in stand-alone environments.

DWARF 是一種調試文件格式,它被許多編譯器和調試器所普遍使用以支持源代碼級別的調試。它知足許多過程語言(C、C++、Fortran)的需求,它被設計爲支持拓展到其餘語言。DWARF 是架構獨立的,適用於其餘任何的處理器和操做系統。被普遍使用在 Unix、Linux 和其餘的操做系統上,以及獨立環境上。

DWARF 全稱是 Debugging With Arbitrary Record Formats,是一種使用屬性化記錄格式的調試文件。

DWARF 是可執行程序與源代碼關係的一個緊湊表示。

大多數現代編程語言都是塊結構:每一個實體(一個類、一個函數)被包含在另外一個實體中。一個 c 程序,每一個文件可能包含多個數據定義、多個變量、多個函數,因此 DWARF 遵循這個模型,也是塊結構。DWARF 裏基本的描述項是調試信息項 DIE(Debugging Information Entry)。一個 DIE 有一個標籤,表示這個 DIE 描述了什麼以及一個填入了細節並進一步描述該項的屬性列表(類比 html、xml 結構)。一個 DIE(除了最頂層的)被一個父 DIE 包含,可能存在兄弟 DIE 或者子 DIE,屬性可能包含各類值:常量(好比一個函數名),變量(好比一個函數的起始地址),或對另外一個DIE的引用(好比一個函數的返回值類型)。

DWARF 文件中的數據以下:

數據列 信息說明
.debug_loc 在 DW_AT_location 屬性中使用的位置列表
.debug_macinfo 宏信息
.debug_pubnames 全局對象和函數的查找表
.debug_pubtypes 全局類型的查找表
.debug_ranges 在 DW_AT_ranges 屬性中使用的地址範圍
.debug_str 在 .debug_info 中使用的字符串表
.debug_types 類型描述

經常使用的標記與屬性以下:

數據列 信息說明
DW_TAG_class_type 表示類名稱和類型信息
DW_TAG_structure_type 表示結構名稱和類型信息
DW_TAG_union_type 表示聯合名稱和類型信息
DW_TAG_enumeration_type 表示枚舉名稱和類型信息
DW_TAG_typedef 表示 typedef 的名稱和類型信息
DW_TAG_array_type 表示數組名稱和類型信息
DW_TAG_subrange_type 表示數組的大小信息
DW_TAG_inheritance 表示繼承的類名稱和類型信息
DW_TAG_member 表示類的成員
DW_TAG_subprogram 表示函數的名稱信息
DW_TAG_formal_parameter 表示函數的參數信息
DW_TAG_name 表示名稱字符串
DW_TAG_type 表示類型信息
DW_TAG_artifical 在建立時由編譯程序設置
DW_TAG_sibling 表示兄弟位置信息
DW_TAG_data_memver_location 表示位置信息
DW_TAG_virtuality 在虛擬時設置

簡單看一個 DWARF 的例子:將測試工程的 .dSYM 文件夾下的 DWARF 文件用下面命令解析

dwarfdump -F --debug-info Test.app.dSYM/Contents/Resources/DWARF/Test > debug-info.txt
複製代碼

打開以下

Test.app.dSYM/Contents/Resources/DWARF/Test:	file format Mach-O arm64

.debug_info contents:
0x00000000: Compile Unit: length = 0x0000004f version = 0x0004 abbr_offset = 0x0000 addr_size = 0x08 (next unit at 0x00000053)

0x0000000b: DW_TAG_compile_unit
              DW_AT_producer [DW_FORM_strp]	("Apple clang version 11.0.3 (clang-1103.0.32.62)")
              DW_AT_language [DW_FORM_data2]	(DW_LANG_ObjC)
              DW_AT_name [DW_FORM_strp]	("_Builtin_stddef_max_align_t")
              DW_AT_stmt_list [DW_FORM_sec_offset]	(0x00000000)
              DW_AT_comp_dir [DW_FORM_strp]	("/Users/lbp/Desktop/Test")
              DW_AT_APPLE_major_runtime_vers [DW_FORM_data1]	(0x02)
              DW_AT_GNU_dwo_id [DW_FORM_data8]	(0x392b5344d415340c)

0x00000027:   DW_TAG_module
                DW_AT_name [DW_FORM_strp]	("_Builtin_stddef_max_align_t")
                DW_AT_LLVM_config_macros [DW_FORM_strp]	("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"")
                DW_AT_LLVM_include_path [DW_FORM_strp]	("/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include")
                DW_AT_LLVM_isysroot [DW_FORM_strp]	("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk")

0x00000038:     DW_TAG_typedef
                  DW_AT_type [DW_FORM_ref4]	(0x0000004b "long double")
                  DW_AT_name [DW_FORM_strp]	("max_align_t")
                  DW_AT_decl_file [DW_FORM_data1]	("/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include/__stddef_max_align_t.h")
                  DW_AT_decl_line [DW_FORM_data1]	(16)

0x00000043:     DW_TAG_imported_declaration
                  DW_AT_decl_file [DW_FORM_data1]	("/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include/__stddef_max_align_t.h")
                  DW_AT_decl_line [DW_FORM_data1]	(27)
                  DW_AT_import [DW_FORM_ref_addr]	(0x0000000000000027)

0x0000004a:     NULL

0x0000004b:   DW_TAG_base_type
                DW_AT_name [DW_FORM_strp]	("long double")
                DW_AT_encoding [DW_FORM_data1]	(DW_ATE_float)
                DW_AT_byte_size [DW_FORM_data1]	(0x08)

0x00000052:   NULL
0x00000053: Compile Unit: length = 0x000183dc version = 0x0004 abbr_offset = 0x0000 addr_size = 0x08 (next unit at 0x00018433)

0x0000005e: DW_TAG_compile_unit
              DW_AT_producer [DW_FORM_strp]	("Apple clang version 11.0.3 (clang-1103.0.32.62)")
              DW_AT_language [DW_FORM_data2]	(DW_LANG_ObjC)
              DW_AT_name [DW_FORM_strp]	("Darwin")
              DW_AT_stmt_list [DW_FORM_sec_offset]	(0x000000a7)
              DW_AT_comp_dir [DW_FORM_strp]	("/Users/lbp/Desktop/Test")
              DW_AT_APPLE_major_runtime_vers [DW_FORM_data1]	(0x02)
              DW_AT_GNU_dwo_id [DW_FORM_data8]	(0xa4a1d339379e18a5)

0x0000007a:   DW_TAG_module
                DW_AT_name [DW_FORM_strp]	("Darwin")
                DW_AT_LLVM_config_macros [DW_FORM_strp]	("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"")
                DW_AT_LLVM_include_path [DW_FORM_strp]	("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include")
                DW_AT_LLVM_isysroot [DW_FORM_strp]	("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk")

0x0000008b:     DW_TAG_module
                  DW_AT_name [DW_FORM_strp]	("C")
                  DW_AT_LLVM_config_macros [DW_FORM_strp]	("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"")
                  DW_AT_LLVM_include_path [DW_FORM_strp]	("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include")
                  DW_AT_LLVM_isysroot [DW_FORM_strp]	("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk")

0x0000009c:       DW_TAG_module
                    DW_AT_name [DW_FORM_strp]	("fenv")
                    DW_AT_LLVM_config_macros [DW_FORM_strp]	("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"")
                    DW_AT_LLVM_include_path [DW_FORM_strp]	("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include")
                    DW_AT_LLVM_isysroot [DW_FORM_strp]	("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk")

0x000000ad:         DW_TAG_enumeration_type
                      DW_AT_type [DW_FORM_ref4]	(0x00017276 "unsigned int")
                      DW_AT_byte_size [DW_FORM_data1]	(0x04)
                      DW_AT_decl_file [DW_FORM_data1]	("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/fenv.h")
                      DW_AT_decl_line [DW_FORM_data1]	(154)

0x000000b5:           DW_TAG_enumerator
                        DW_AT_name [DW_FORM_strp]	("__fpcr_trap_invalid")
                        DW_AT_const_value [DW_FORM_udata]	(256)

0x000000bc:           DW_TAG_enumerator
                        DW_AT_name [DW_FORM_strp]	("__fpcr_trap_divbyzero")
                        DW_AT_const_value [DW_FORM_udata]	(512)

0x000000c3:           DW_TAG_enumerator
                        DW_AT_name [DW_FORM_strp]	("__fpcr_trap_overflow")
                        DW_AT_const_value [DW_FORM_udata]	(1024)

0x000000ca:           DW_TAG_enumerator
                        DW_AT_name [DW_FORM_strp]	("__fpcr_trap_underflow")
// ......
0x000466ee:   DW_TAG_subprogram
                DW_AT_name [DW_FORM_strp]	("CFBridgingRetain")
                DW_AT_decl_file [DW_FORM_data1]	("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/System/Library/Frameworks/Foundation.framework/Headers/NSObject.h")
                DW_AT_decl_line [DW_FORM_data1]	(105)
                DW_AT_prototyped [DW_FORM_flag_present]	(true)
                DW_AT_type [DW_FORM_ref_addr]	(0x0000000000019155 "CFTypeRef")
                DW_AT_inline [DW_FORM_data1]	(DW_INL_inlined)

0x000466fa:     DW_TAG_formal_parameter
                  DW_AT_name [DW_FORM_strp]	("X")
                  DW_AT_decl_file [DW_FORM_data1]	("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/System/Library/Frameworks/Foundation.framework/Headers/NSObject.h")
                  DW_AT_decl_line [DW_FORM_data1]	(105)
                  DW_AT_type [DW_FORM_ref4]	(0x00046706 "id")

0x00046705:     NULL

0x00046706:   DW_TAG_typedef
                DW_AT_type [DW_FORM_ref4]	(0x00046711 "objc_object*")
                DW_AT_name [DW_FORM_strp]	("id")
                DW_AT_decl_file [DW_FORM_data1]	("/Users/lbp/Desktop/Test/Test/NetworkAPM/NSURLResponse+cm_FetchStatusLineFromCFNetwork.m")
                DW_AT_decl_line [DW_FORM_data1]	(44)

0x00046711:   DW_TAG_pointer_type
                DW_AT_type [DW_FORM_ref4]	(0x00046716 "objc_object")

0x00046716:   DW_TAG_structure_type
                DW_AT_name [DW_FORM_strp]	("objc_object")
                DW_AT_byte_size [DW_FORM_data1]	(0x00)

0x0004671c:     DW_TAG_member
                  DW_AT_name [DW_FORM_strp]	("isa")
                  DW_AT_type [DW_FORM_ref4]	(0x00046727 "objc_class*")
                  DW_AT_data_member_location [DW_FORM_data1]	(0x00)
// ......
複製代碼

這裏就不粘貼所有內容了(太長了)。能夠看到 DIE 包含了函數開始地址、結束地址、函數名、文件名、所在行數,對於給定的地址,找到函數開始地址、結束地址之間包含該抵制的 DIE,則能夠還原函數名和文件名信息。

debug_line 能夠還原文件行數等信息

dwarfdump -F --debug-line Test.app.dSYM/Contents/Resources/DWARF/Test > debug-inline.txt
複製代碼

貼部分信息

Test.app.dSYM/Contents/Resources/DWARF/Test:	file format Mach-O arm64

.debug_line contents:
debug_line[0x00000000]
Line table prologue:
    total_length: 0x000000a3
         version: 4
 prologue_length: 0x0000009a
 min_inst_length: 1
max_ops_per_inst: 1
 default_is_stmt: 1
       line_base: -5
      line_range: 14
     opcode_base: 13
standard_opcode_lengths[DW_LNS_copy] = 0
standard_opcode_lengths[DW_LNS_advance_pc] = 1
standard_opcode_lengths[DW_LNS_advance_line] = 1
standard_opcode_lengths[DW_LNS_set_file] = 1
standard_opcode_lengths[DW_LNS_set_column] = 1
standard_opcode_lengths[DW_LNS_negate_stmt] = 0
standard_opcode_lengths[DW_LNS_set_basic_block] = 0
standard_opcode_lengths[DW_LNS_const_add_pc] = 0
standard_opcode_lengths[DW_LNS_fixed_advance_pc] = 1
standard_opcode_lengths[DW_LNS_set_prologue_end] = 0
standard_opcode_lengths[DW_LNS_set_epilogue_begin] = 0
standard_opcode_lengths[DW_LNS_set_isa] = 1
include_directories[  1] = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include"
file_names[  1]:
           name: "__stddef_max_align_t.h"
      dir_index: 1
       mod_time: 0x00000000
         length: 0x00000000

Address            Line   Column File   ISA Discriminator Flags
------------------ ------ ------ ------ --- ------------- -------------
0x0000000000000000      1      0      1   0             0  is_stmt end_sequence
debug_line[0x000000a7]
Line table prologue:
    total_length: 0x0000230a
         version: 4
 prologue_length: 0x00002301
 min_inst_length: 1
max_ops_per_inst: 1
 default_is_stmt: 1
       line_base: -5
      line_range: 14
     opcode_base: 13
standard_opcode_lengths[DW_LNS_copy] = 0
standard_opcode_lengths[DW_LNS_advance_pc] = 1
standard_opcode_lengths[DW_LNS_advance_line] = 1
standard_opcode_lengths[DW_LNS_set_file] = 1
standard_opcode_lengths[DW_LNS_set_column] = 1
standard_opcode_lengths[DW_LNS_negate_stmt] = 0
standard_opcode_lengths[DW_LNS_set_basic_block] = 0
standard_opcode_lengths[DW_LNS_const_add_pc] = 0
standard_opcode_lengths[DW_LNS_fixed_advance_pc] = 1
standard_opcode_lengths[DW_LNS_set_prologue_end] = 0
standard_opcode_lengths[DW_LNS_set_epilogue_begin] = 0
standard_opcode_lengths[DW_LNS_set_isa] = 1
include_directories[  1] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include"
include_directories[  2] = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include"
include_directories[  3] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/sys"
include_directories[  4] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach"
include_directories[  5] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/libkern"
include_directories[  6] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/architecture"
include_directories[  7] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/sys/_types"
include_directories[  8] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/_types"
include_directories[  9] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/arm"
include_directories[ 10] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/sys/_pthread"
include_directories[ 11] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach/arm"
include_directories[ 12] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/libkern/arm"
include_directories[ 13] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/uuid"
include_directories[ 14] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/netinet"
include_directories[ 15] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/netinet6"
include_directories[ 16] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/net"
include_directories[ 17] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/pthread"
include_directories[ 18] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach_debug"
include_directories[ 19] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/os"
include_directories[ 20] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/malloc"
include_directories[ 21] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/bsm"
include_directories[ 22] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/machine"
include_directories[ 23] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach/machine"
include_directories[ 24] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/secure"
include_directories[ 25] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/xlocale"
include_directories[ 26] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/arpa"
file_names[  1]:
           name: "fenv.h"
      dir_index: 1
       mod_time: 0x00000000
         length: 0x00000000
file_names[  2]:
           name: "stdatomic.h"
      dir_index: 2
       mod_time: 0x00000000
         length: 0x00000000
file_names[  3]:
           name: "wait.h"
      dir_index: 3
       mod_time: 0x00000000
         length: 0x00000000
// ......
Address            Line   Column File   ISA Discriminator Flags
------------------ ------ ------ ------ --- ------------- -------------
0x000000010000b588     14      0      2   0             0  is_stmt
0x000000010000b5b4     16      5      2   0             0  is_stmt prologue_end
0x000000010000b5d0     17     11      2   0             0  is_stmt
0x000000010000b5d4      0      0      2   0             0 
0x000000010000b5d8     17      5      2   0             0 
0x000000010000b5dc     17     11      2   0             0 
0x000000010000b5e8     18      1      2   0             0  is_stmt
0x000000010000b608     20      0      2   0             0  is_stmt
0x000000010000b61c     22      5      2   0             0  is_stmt prologue_end
0x000000010000b628     23      5      2   0             0  is_stmt
0x000000010000b644     24      1      2   0             0  is_stmt
0x000000010000b650     15      0      1   0             0  is_stmt
0x000000010000b65c     15     41      1   0             0  is_stmt prologue_end
0x000000010000b66c     11      0      2   0             0  is_stmt
0x000000010000b680     11     17      2   0             0  is_stmt prologue_end
0x000000010000b6a4     11     17      2   0             0  is_stmt end_sequence
debug_line[0x0000def9]
Line table prologue:
    total_length: 0x0000015a
         version: 4
 prologue_length: 0x000000eb
 min_inst_length: 1
max_ops_per_inst: 1
 default_is_stmt: 1
       line_base: -5
      line_range: 14
     opcode_base: 13
standard_opcode_lengths[DW_LNS_copy] = 0
standard_opcode_lengths[DW_LNS_advance_pc] = 1
standard_opcode_lengths[DW_LNS_advance_line] = 1
standard_opcode_lengths[DW_LNS_set_file] = 1
standard_opcode_lengths[DW_LNS_set_column] = 1
standard_opcode_lengths[DW_LNS_negate_stmt] = 0
standard_opcode_lengths[DW_LNS_set_basic_block] = 0
standard_opcode_lengths[DW_LNS_const_add_pc] = 0
standard_opcode_lengths[DW_LNS_fixed_advance_pc] = 1
standard_opcode_lengths[DW_LNS_set_prologue_end] = 0
standard_opcode_lengths[DW_LNS_set_epilogue_begin] = 0
standard_opcode_lengths[DW_LNS_set_isa] = 1
include_directories[  1] = "Test"
include_directories[  2] = "Test/NetworkAPM"
include_directories[  3] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/objc"
file_names[  1]:
           name: "AppDelegate.h"
      dir_index: 1
       mod_time: 0x00000000
         length: 0x00000000
file_names[  2]:
           name: "JMWebResourceURLProtocol.h"
      dir_index: 2
       mod_time: 0x00000000
         length: 0x00000000
file_names[  3]:
           name: "AppDelegate.m"
      dir_index: 1
       mod_time: 0x00000000
         length: 0x00000000
file_names[  4]:
           name: "objc.h"
      dir_index: 3
       mod_time: 0x00000000
         length: 0x00000000
// ......
複製代碼

能夠看到 debug_line 裏包含了每一個代碼地址對應的行數。上面貼了 AppDelegate 的部分。

4.3 symbols

在連接中,咱們將函數和變量統稱爲符合(Symbol),函數名或變量名就是符號名(Symbol Name),咱們能夠將符號當作是連接中的粘合劑,整個連接過程正是基於符號才能正確完成的。

上述文字來自《程序員的自我修養》。因此符號就是函數、變量、類的統稱。

按照類型劃分,符號能夠分爲三類:

  • 全局符號:目標文件外可見的符號,能夠被其餘目標文件所引用,或者須要其餘目標文件定義
  • 局部符號:只在目標文件內可見的符號,指只在目標文件內可見的函數和變量
  • 調試符號:包括行號信息的調試符號信息,行號信息記錄了函數和變量對應的文件和文件行號。

符號表(Symbol Table):是內存地址與函數名、文件名、行號的映射表。每一個定義的符號都有一個對應的值得,叫作符號值(Symbol Value),對於變量和函數來講,符號值就是地址,符號表組成以下

<起始地址> <結束地址> <函數> [<文件名:行號>]
複製代碼

4.4 如何獲取地址?

image 加載的時候會進行相對基地址進行重定位,而且每次加載的基地址都不同,函數棧 frame 的地址是重定位後的絕對地址,咱們要的是重定位前的相對地址。

Binary Images

拿測試工程的 crash 日誌舉例子,打開貼部分 Binary Images 內容

// ...
Binary Images:
0x102fe0000 - 0x102ff3fff Test arm64  <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test
0x1030e0000 - 0x1030ebfff libobjc-trampolines.dylib arm64  <181f3aa866d93165ac54344385ac6e1d> /usr/lib/libobjc-trampolines.dylib
0x103204000 - 0x103267fff dyld arm64  <6f1c86b640a3352a8529bca213946dd5> /usr/lib/dyld
0x189a78000 - 0x189a8efff libsystem_trace.dylib arm64  <b7477df8f6ab3b2b9275ad23c6cc0b75> /usr/lib/system/libsystem_trace.dylib
// ...
複製代碼

能夠看到 Crash 日誌的 Binary Images 包含每一個 Image 的加載開始地址、結束地址、image 名稱、arm 架構、uuid、image 路徑。

crash 日誌中的信息

Last Exception Backtrace:
// ...
5   Test                          	0x102fe592c -[ViewController testMonitorCrash] + 22828 (ViewController.mm:58)
複製代碼
Binary Images:
0x102fe0000 - 0x102ff3fff Test arm64  <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test
複製代碼

因此 frame 5 的相對地址爲 0x102fe592c - 0x102fe0000。再使用 命令能夠還原符號信息。

使用 atos 來解析,0x102fe0000 爲 image 加載的開始地址,0x102fe592c 爲 frame 須要還原的地址。

atos -o Test.app.dSYM/Contents/Resources/DWARF/Test-arch arm64 -l 0x102fe0000 0x102fe592c
複製代碼

4.5 UUID

  • crash 文件的 UUID

    grep --after-context=2 "Binary Images:" *.crash
    複製代碼
    Test  5-28-20, 7-47 PM.crash:Binary Images:
    Test  5-28-20, 7-47 PM.crash-0x102fe0000 - 0x102ff3fff Test arm64  <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test
    Test  5-28-20, 7-47 PM.crash-0x1030e0000 - 0x1030ebfff libobjc-trampolines.dylib arm64  <181f3aa866d93165ac54344385ac6e1d> /usr/lib/libobjc-trampolines.dylib
    --
    Test.crash:Binary Images:
    Test.crash-0x102fe0000 - 0x102ff3fff Test arm64  <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test
    Test.crash-0x1030e0000 - 0x1030ebfff libobjc-trampolines.dylib arm64  <181f3aa866d93165ac54344385ac6e1d> /usr/lib/libobjc-trampolines.dylib
    複製代碼

    Test App 的 UUID 爲 37eaa57df2523d95969e47a9a1d69ce5.

  • .dSYM 文件的 UUID

    dwarfdump --uuid Test.app.dSYM
    複製代碼

    結果爲

    UUID: 37EAA57D-F252-3D95-969E-47A9A1D69CE5 (arm64) Test.app.dSYM/Contents/Resources/DWARF/Test
    複製代碼
  • app 的 UUID

    dwarfdump --uuid Test.app/Test
    複製代碼

    結果爲

    UUID: 37EAA57D-F252-3D95-969E-47A9A1D69CE5 (arm64) Test.app/Test
    複製代碼

4.6 符號化(解析 Crash 日誌)

上述篇幅分析瞭如何捕獲各類類型的 crash,App 在用戶手中咱們經過技術手段能夠獲取 crash 案發現場信息並結合必定的機制去上報,可是這種堆棧是十六進制的地址,沒法定位問題,因此須要作符號化處理。

上面也說明了.dSYM 文件 的做用,經過符號地址結合 dSYM 文件來還原文件名、所在行、函數名,這個過程叫符號化。可是 .dSYM 文件必須和 crash log 文件的 bundle id、version 嚴格對應。

獲取 Crash 日誌能夠經過 Xcode -> Window -> Devices and Simulators 選擇對應設備,找到 Crash 日誌文件,根據時間和 App 名稱定位。

app 和 .dSYM 文件能夠經過打包的產物獲得,路徑爲 ~/Library/Developer/Xcode/Archives

解析方法通常有2種:

  • 使用 symbolicatecrash

    symbolicatecrash 是 Xcode 自帶的 crash 日誌分析工具,先肯定所在路徑,在終端執行下面的命令

    find /Applications/Xcode.app -name symbolicatecrash -type f
    複製代碼

    會返回幾個路徑,找到 iPhoneSimulator.platform 所在那一行

    /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/Library/PrivateFrameworks/DVTFoundation.framework/symbolicatecrash
    複製代碼

    將 symbolicatecrash 拷貝到指定文件夾下(保存了 app、dSYM、crash 文件的文件夾)

    執行命令

    ./symbolicatecrash Test.crash Test.dSYM > Test.crash
    複製代碼

    第一次作這事兒應該會報錯 Error: "DEVELOPER_DIR" is not defined at ./symbolicatecrash line 69.,解決方案:在終端執行下面命令

    export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
    複製代碼
  • 使用 atos

    區別於 symbolicatecrash,atos 較爲靈活,只要 .crash.dSYM 或者 .crash.app 文件對應便可。

    用法以下,-l 最後跟得是符號地址

    xcrun atos -o Test.app.dSYM/Contents/Resources/DWARF/Test -arch armv7 -l 0x1023c592c
    複製代碼

    也能夠解析 .app 文件(不存在 .dSYM 文件),其中xxx爲段地址,xx爲偏移地址

    atos -arch architecture -o binary -l xxx xx
    複製代碼

由於咱們的 App 可能有不少,每一個 App 在用戶手中多是不一樣的版本,因此在 APM 攔截以後須要符號化的時候須要將 crash 文件和 .dSYM 文件一一對應,才能正確符號化,對應的原則就是 UUID 一致。

4.7 系統庫符號化解析

咱們每次真機鏈接 Xcode 運行程序,會提示等待,其實系統爲了堆棧解析,都會把當前版本的系統符號庫自動導入到 /Users/你本身的用戶名/Library/Developer/Xcode/iOS DeviceSupport 目錄下安裝了一大堆系統庫的符號化文件。你能夠訪問下面目錄看看

/Users/你本身的用戶名/Library/Developer/Xcode/iOS DeviceSupport/
複製代碼

系統符號化文件

5. 服務端處理

5.1 ELK 日誌系統

業界設計日誌監控系統通常會採用基於 ELK 技術。ELK 是 Elasticsearch、Logstash、Kibana 三個開源框架縮寫。Elasticsearch 是一個分佈式、經過 Restful 方式進行交互的近實時搜索的平臺框架。Logstash 是一箇中央數據流引擎,用於從不一樣目標(文件/數據存儲/MQ)收集不一樣格式的數據,通過過濾後支持輸出到不一樣目的地(文件/MQ/Redis/ElasticsSearch/Kafka)。Kibana 能夠將 Elasticserarch 的數據經過友好的頁面展現出來,提供可視化分析功能。因此 ELK 能夠搭建一個高效、企業級的日誌分析系統。

早期單體應用時代,幾乎應用的全部功能都在一臺機器上運行,出了問題,運維人員打開終端輸入命令直接查看系統日誌,進而定位問題、解決問題。隨着系統的功能愈來愈複雜,用戶體量愈來愈大,單體應用幾乎很難知足需求,因此技術架構迭代了,經過水平拓展來支持龐大的用戶量,將單體應用進行拆分爲多個應用,每一個應用採用集羣方式部署,負載均衡控制調度,假如某個子模塊發生問題,去找這臺服務器上終端找日誌分析嗎?顯然臺落後,因此日誌管理平臺便應運而生。經過 Logstash 去收集分析每臺服務器的日誌文件,而後按照定義的正則模版過濾後傳輸到 Kafka 或 Redis,而後由另外一個 Logstash 從 Kafka 或 Redis 上讀取日誌存儲到 ES 中建立索引,最後經過 Kibana 進行可視化分析。此外能夠將收集到的數據進行數據分析,作更進一步的維護和決策。

ELK架構圖

上圖展現了一個 ELK 的日誌架構圖。簡單說明下:

  • Logstash 和 ES 以前存在一個 Kafka 層,由於 Logstash 是架設在數據資源服務器上,將收集到的數據進行實時過濾,過濾須要消耗時間和內存,因此存在 Kafka,起到了數據緩衝存儲做用,由於 Kafka 具有很是出色的讀寫性能。
  • 再一步就是 Logstash 從 Kafka 裏面進行讀取數據,將數據過濾、處理,將結果傳輸到 ES
  • 這個設計不但性能好、耦合低,還具有可拓展性。好比能夠從 n 個不一樣的 Logstash 上讀取傳輸到 n 個 Kafka 上,再由 n 個 Logstash 過濾處理。日誌來源能夠是 m 個,好比 App 日誌、Tomcat 日誌、Nginx 日誌等等

下圖貼一個 Elasticsearch 社區分享的一個 「Elastic APM 動手實戰」主題的內容截圖。

Elasticsearch & APM

5.2 服務側

Crash log 統一入庫 Kibana 時是沒有符號化的,因此須要符號化處理,以方便定位問題、crash 產生報表和後續處理。

crash log 處理流程

因此整個流程就是:客戶端 APM SDK 收集 crash log -> Kafka 存儲 -> Mac 機執行定時任務符號化 -> 數據回傳 Kafka -> 產品側(顯示端)對數據進行分類、報表、報警等操做。

由於公司的產品線有多條,相應的 App 有多個,用戶使用的 App 版本也各不相同,因此 crash 日誌分析必需要有正確的 .dSYM 文件,那麼多 App 的不一樣版本,自動化就變得很是重要了。

自動化有2種手段,規模小一點的公司或者圖省事,能夠在 Xcode中 添加 runScript 腳本代碼來自動在 release 模式下上傳dSYM)。

由於咱們公司有本身的一套體系,wax-cli,能夠同時管理 iOS SDK、iOS App、Android SDK、Android App、Node、React、React Native 工程項目的初始化、依賴管理、構建(持續集成、Unit Test、Lint、統跳檢測)、測試、打包、部署、動態能力(熱更新、統跳路由下發)等能力於一身。能夠基於各個階段作能力的插入,因此能夠在調用打包後在打包機上傳 .dSYM 文件到七牛雲存儲(規則能夠是以 AppName + Version 爲 key,value 爲 .dSYM 文件)。

如今不少架構設計都是微服務,至於爲何選微服務,不在本文範疇。因此 crash 日誌的符號化被設計爲一個微服務。架構圖以下

crash 符號化流程圖

說明:

  • Symbolication Service 做爲整個監控系統 Prism 的一個組成部分,是專一於 crash report 符號化的微服務。

  • 接收來自 mass 的包含預處理過的 crash report 和 dsym index 的請求,從七牛拉取對應的 dsym,對 crash report 作符號化解析,計算 hash,並將 hash 響應給 mass。

  • 接收來自 Prism 管理系統的包含原始 crash report 和 dsym index 的請求,從七牛拉取對應的 dsym,對crash report 作符號化解析,並將符號化的 crash report 響應給 Prism 管理系統。

  • Mass 是一個通用的數據處理(流式/批式)和任務調度框架

  • candle 是一個打包系統,上面說的 wax-cli 有個能力就是打包,其實就是調用的 candle 系統的打包構建能力。會根據項目的特色,選擇合適的打包機(打包平臺是維護了多個打包任務,不一樣任務根據特色被派發到不一樣的打包機上,任務詳情頁能夠看到依賴的下載、編譯、運行過程等,打包好的產物包括二進制包、下載二維碼等等)

符號化流程圖

其中符號化服務是大前端背景下大前端團隊的產物,因此是 NodeJS 實現的。iOS 的符號化機器是 雙核的 Mac mini,這就須要作實驗測評到底須要開啓幾個 worker 進程作符號化服務。結果是雙進程處理 crash log,比單進程效率高近一倍,而四進程比雙進程效率提高不明顯,符合雙核 mac mini 的特色。因此開啓兩個 worker 進程作符號化處理。

下圖是完整設計圖

符號化技術設計圖

簡單說明下,符號化流程是一個主從模式,一臺 master 機,多個 slave 機,master 機讀取 .dSYM 和 crash 結果的 cache。mass 調度符號化服務(內部2個 symbolocate worker)同時從七牛雲上獲取 .dSYM 文件。

系統架構圖以下

符號化服務架構圖

8、 APM 小結

  1. 一般來講各個端的監控能力是不太一致的,技術實現細節也不統一。因此在技術方案評審的時候須要將監控能力對齊統一。每一個能力在各個端的數據字段必須對齊(字段個數、名稱、數據類型和精度),由於 APM 自己是一個閉環,監控了以後需符號化解析、數據整理,進行產品化開發、最後須要監控大盤展現等

  2. 一些 crash 或者 ANR 等根據等級須要郵件、短信、企業內容通訊工具告知干係人,以後快速發佈版本、hot fix 等。

  3. 監控的各個能力須要作成可配置,靈活開啓關閉。

  4. 監控數據須要作內存到文件的寫入處理,須要注意策略。監控數據須要存儲數據庫,數據庫大小、設計規則等。存入數據庫後如何上報,上報機制等會在另外一篇文章講:打造一個通用、可配置的數據上報 SDK

  5. 儘可能在技術評審後,將各端的技術實現寫進文檔中,同步給相關人員。好比 ANR 的實現

    /*
    android 端
    
    根據設備分級,通常超過 300ms 視爲一次卡頓
    hook 系統 loop,在消息處理先後插樁,用以計算每條消息的時長
    開啓另外線程 dump 堆棧,處理結束後關閉
    */
    new ExceptionProcessor().init(this, new Runnable() {
                @Override
                public void run() {
                    //監測卡頓
                    try {
                        ProxyPrinter proxyPrinter = new ProxyPrinter(PerformanceMonitor.this);
                        Looper.getMainLooper().setMessageLogging(proxyPrinter);
                        mWeakPrinter = new WeakReference<ProxyPrinter>(proxyPrinter);
                    } catch (FileNotFoundException e) {
                    }
                }
            })
            
    /*
    iOS 端
    
    子線程經過 ping 主線程來確認主線程當前是否卡頓。
    卡頓閾值設置爲 300ms,超過閾值時認爲卡頓。
    卡頓時獲取主線程的堆棧,並存儲上傳。
    */ 
    - (void) main() {
        while (self.cancle == NO) {
            self.isMainThreadBlocked = YES;
            dispatch_async(dispatch_get_main_queue(), ^{
                self.isMainThreadBlocked = YES;
                [self.semaphore singal];
            });
            [Thread sleep:300];
            if (self.isMainThreadBlocked) {
                [self handleMainThreadBlock];
            }
            [self.semaphore wait];
        }
    }
    複製代碼
  6. 整個 APM 的架構圖以下

    APM Structure

    說明:

    • 埋點 SDK,經過 sessionId 來關聯日誌數據
    • wax 上面介紹過了,是一種多端項目管理模式,每一個 wax 項目都具備基礎信息
  7. APM 技術方案自己是隨着技術手段、分析需求不斷調整升級的。上圖的幾個結構示意圖是早期幾個版本的,目前使用的是在此基礎上進行了升級和結構調整,提幾個關鍵詞:Hermes、Flink SQL、InfluxDB。

參考資料

相關文章
相關標籤/搜索