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

四、 OOM 問題

1. 基礎知識準備

硬盤:也叫做磁盤,用於存儲數據。你存儲的歌曲、圖片、視頻都是在硬盤裏。

內存:由於硬盤讀取速度較慢,如果 CPU 運行程序期間,所有的數據都直接從硬盤中讀取,則非常影響效率。所以 CPU 會將程序運行所需要的數據從硬盤中讀取到內存中。然後 CPU 與內存中的數據進行計算、交換。內存是易失性存儲器(斷電後,數據消失)。內存條區是計算機內部(在主板上)的一些存儲器,用來保存 CPU 運算的中間數據和結果。內存是程序與 CPU 之間的橋樑。從硬盤讀取出數據或者運行程序提供給 CPU。

虛擬內存 是計算機系統內存管理的一種技術。它使得程序認爲它擁有連續的可用內存,而實際上,它通常被分割成多個物理內存碎片,可能部分暫時存儲在外部磁盤(硬盤)存儲器上(當需要使用時則用硬盤中數據交換到內存中)。Windows 系統中稱爲 “虛擬內存”,Linux/Unix 系統中稱爲 ”交換空間“。

iOS 不支持交換空間?不只是 iOS 不支持交換空間,大多數手機系統都不支持。因爲移動設備的大量存儲器是閃存,它的讀寫速度遠遠小電腦所使用的硬盤,也就是說手機即使使用了交換空間技術,也因爲閃存慢的問題,不能提升性能,所以索性就沒有交換空間技術。

2. iOS 內存知識

內存(RAM)與 CPU 一樣都是系統中最稀少的資源,也很容易發生競爭,應用內存與性能直接相關。iOS 沒有交換空間作爲備選資源,所以內存資源尤爲重要。

什麼是 OOM?是 out-of-memory 的縮寫,字面意思是超過了內存限制。分爲 FOOM(foreground OOM)和 BOOM(background OOM)。它是由 iOS 的 Jetsam 機制造成的一種非主流 Crash,它不能通過 Signal 這種監控方案所捕獲。

什麼是 Jetsam 機制?Jetsam 機制可以理解爲系統爲了控制內存資源過度使用而採用的一種管理機制。Jetsam 機制是運行在一個獨立的進程中,每個進程都有一個內存閾值,一旦超過這個內存閾值,Jetsam 會立即殺掉這個進程。

爲什麼設計 Jetsam 機制?因爲設備的內存是有限的,所以內存資源非常重要。系統進程以及其他使用的 App 都會搶佔這個資源。由於 iOS 不支持交換空間,一旦觸發低內存事件,Jetsam 就會盡可能多的釋放 App 所在內存,這樣 iOS 系統上出現內存不足時,App 就會被系統殺掉,變現爲 crash。

2種情況觸發 OOM:系統由於整體內存使用過高,會基於優先級策略殺死優先級較低的 App;當前 App 達到了 “highg water mark” ,系統也會強殺當前 App(超過系統對當前單個 App 的內存限制值)。

讀了源碼(xnu/bsd/kern/kern_memorystatus.c)會發現內存被殺也有2種機制,如下

highwater 處理 -> 我們的 App 佔用內存不能超過單個限制

  1. 從優先級列表裏循環尋找線程
  2. 判斷是否滿足 p_memstat_memlimit 的限制條件
  3. DiagonoseActive、FREEZE 過濾
  4. 殺進程,成功則 exit,否則循環

memorystatus_act_aggressive 處理 -> 內存佔用高,按照優先級殺死

  1. 根據 policy 家在 jld_bucket_count,用來判斷是否被殺
  2. 從 JETSAM_PRIORITY_ELEVATED_INACTIVE 開始殺
  3. Old_bucket_count 和 memorystatus_jld_eval_period_msecs 判斷是否開殺
  4. 根據優先級從低到高開始殺,直到 memorystatus_avail_pages_below_pressure

內存過大的幾種情況

  • App 內存消耗較低,同時其他 App 內存管理也很棒,那麼即使切換到其他 App,我們自己的 App 依舊是“活着”的,保留了用戶狀態。體驗好
  • App 內存消耗較低,但其他 App 內存消耗太大(可能是內存管理糟糕,也可能是本身就耗費資源,比如遊戲),那麼除了在前臺的線程,其他 App 都會被系統殺死,回收內存資源,用來給活躍的進程提供內存。
  • App 內存消耗較大,切換到其他 App 後,即使其他 App 向系統申請的內存不大,系統也會因爲內存資源緊張,優先把內存消耗大的 App 殺死。表現爲用戶將 App 退出到後臺,過會兒再次打開會發現 App 重新加載啓動。
  • App 內存消耗非常大,在前臺運行時就被系統殺死,造成閃退。

App 內存不足時,系統會按照一定策略來騰出更多的空間供使用。比較常見的做法是將一部分優先級低的數據挪到磁盤上,該操作爲稱爲 page out。之後再次訪問這塊數據的時候,系統會負責將它重新搬回到內存中,該操作被稱爲 page in

Memory page** 是內存管理中的最小單位,是系統分配的,可能一個 page 持有多個對象,也可能一個大的對象跨越多個 page。通常它是 16KB 大小,且有3種類型的 page。
內存page種類

  • Clean Memory
    Clean memory 包括3類:可以 page out 的內存、內存映射文件、App 使用到的 framework(每個 framework 都有 _DATA_CONST 段,通常都是 clean 狀態,但使用 runtime swizling,那麼變爲 dirty)。

    一開始分配的 page 都是乾淨的(堆裏面的對象分配除外),我們 App 數據寫入時候變爲 dirty。從硬盤讀進內存的文件,也是隻讀的、clean page。

    Clean memory

  • Dirty Memory

    Dirty memory 包括4類:被 App 寫入過數據的內存、所有堆區分配的對象、圖像解碼緩衝區、framework(framework 都有 _DATA 段和 _DATA_DIRTY 段,它們的內存都是 dirty)。

    在使用 framework 的過程中會產生 Dirty memory,使用單例或者全局初始化方法有助於幫助減少 Dirty memory(因爲單例一旦創建就不銷燬,一直在內存中,系統不認爲是 Dirty memory)。

Dirty memory

  • Compressed Memory

    由於閃存容量和讀寫限制,iOS 沒有交換空間機制,而是在 iOS7 引入了 memory compressor。它是在內存緊張時候能夠將最近一段時間未使用過的內存對象,內存壓縮器會把對象壓縮,釋放出更多的 page。在需要時內存壓縮器對其解壓複用。在節省內存的同時提高了響應速度。

    比如 App 使用某 Framework,內部有個 NSDictionary 屬性存儲數據,使用了 3 pages 內存,在近期未被訪問的時候 memory compressor 將其壓縮爲 1 page,再次使用的時候還原爲 3 pages。

App 運行內存 = pageNumbers * pageSize。因爲 Compressed Memory 屬於 Dirty memory。所以 Memory footprint = dirtySize + CompressedSize

設備不同,內存佔用上限不同,App 上限較高,extension 上限較低,超過上限 crash 到 EXC_RESOURCE_EXCEPTION
Memory footprint

接下來談一下如何獲取內存上限,以及如何監控 App 因爲佔用內存過大而被強殺。

3. 獲取內存信息

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

當 App 被 Jetsam 機制殺死時,手機會生成系統日誌。查看路徑:Settings-Privacy-Analytics & Improvements- Analytics Data(設置-隱私- 分析與改進-分析數據),可以看到 JetsamEvent-2020-03-14-161828.ips 形式的日誌,以 JetsamEvent 開頭。這些 JetsamEvent 日誌都是 iOS 系統內核強殺掉那些優先級不高(idle、frontmost、suspended)且佔用內存超過系統內存限制的 App 留下的。

日誌包含了 App 的內存信息。可以查看到 日誌最頂部有 pageSize 字段,查找到 per-process-limit,該節點所在結構裏的 rpages ,將 rpages * pageSize 即可得到 OOM 的閾值。

日誌中 largestProcess 字段代表 App 名稱;reason 字段代表內存原因;states 字段代表奔潰時 App 的狀態( idle、suspended、frontmost…)。

爲了測試數據的準確性,我將測試2臺設備(iPhone 6s plus/13.3.1,iPhone 11 Pro/13.3.1)的所有 App 徹底退出,只跑了一個爲了測試內存臨界值的 Demo App。 循環申請內存,ViewController 代碼如下

- (void)viewDidLoad {
    [super viewDidLoad];
    NSMutableArray *array = [NSMutableArray array];
    for (NSInteger index = 0; index < 10000000; index++) {
        UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
        UIImage *image = [UIImage imageNamed:@"AppIcon"];
        imageView.image = image;
        [array addObject:imageView];
    }
}

iPhone 6s plus/13.3.1 數據如下:

{"bug_type":"298","timestamp":"2020-03-19 17:23:45.94 +0800","os_version":"iPhone OS 13.3.1 (17D50)","incident_id":"DA8AF66D-24E8-458C-8734-981866942168"}
{
  "crashReporterKey" : "fc9b659ce486df1ed1b8062d5c7c977a7eb8c851",
  "kernel" : "Darwin Kernel Version 19.3.0: Thu Jan  9 21:10:44 PST 2020; root:xnu-6153.82.3~1\/RELEASE_ARM64_S8000",
  "product" : "iPhone8,2",
  "incident" : "DA8AF66D-24E8-458C-8734-981866942168",
  "date" : "2020-03-19 17:23:45.93 +0800",
  "build" : "iPhone OS 13.3.1 (17D50)",
  "timeDelta" : 332,
  "memoryStatus" : {
  "compressorSize" : 48499,
  "compressions" : 7458651,
  "decompressions" : 5190200,
  "zoneMapCap" : 744407040,
  "largestZone" : "APFS_4K_OBJS",
  "largestZoneSize" : 41402368,
  "pageSize" : 16384,
  "uncompressed" : 104065,
  "zoneMapSize" : 141606912,
  "memoryPages" : {
    "active" : 26214,
    "throttled" : 0,
    "fileBacked" : 14903,
    "wired" : 20019,
    "anonymous" : 37140,
    "purgeable" : 142,
    "inactive" : 23669,
    "free" : 2967,
    "speculative" : 2160
  }
},
  "largestProcess" : "Test",
  "genCounter" : 0,
  "processes" : [
  {
    "uuid" : "39c5738b-b321-3865-a731-68064c4f7a6f",
    "states" : [
      "daemon",
      "idle"
    ],
    "lifetimeMax" : 188,
    "age" : 948223699030,
    "purgeable" : 0,
    "fds" : 25,
    "coalition" : 422,
    "rpages" : 177,
    "pid" : 282,
    "idleDelta" : 824711280,
    "name" : "com.apple.Safari.SafeBrowsing.Se",
    "cpuTime" : 10.275422000000001
  },
  // ...
  {
    "uuid" : "83dbf121-7c0c-3ab5-9b66-77ee926e1561",
    "states" : [
      "frontmost"
    ],
    "killDelta" : 2592,
    "genCount" : 0,
    "age" : 1531004794,
    "purgeable" : 0,
    "fds" : 50,
    "coalition" : 1047,
    "rpages" : 92806,
    "reason" : "per-process-limit",
    "pid" : 2384,
    "cpuTime" : 59.464373999999999,
    "name" : "Test",
    "lifetimeMax" : 92806
  },
  // ...
 ]
}

iPhone 6s plus/13.3.1 手機 OOM 臨界值爲:(16384*92806)/(1024*1024)=1450.09375M

iPhone 11 Pro/13.3.1 數據如下:

{"bug_type":"298","timestamp":"2020-03-19 17:30:28.39 +0800","os_version":"iPhone OS 13.3.1 (17D50)","incident_id":"7F111601-BC7A-4BD7-A468-CE3370053057"}
{
  "crashReporterKey" : "bc2445adc164c399b330f812a48248e029e26276",
  "kernel" : "Darwin Kernel Version 19.3.0: Thu Jan  9 21:11:10 PST 2020; root:xnu-6153.82.3~1\/RELEASE_ARM64_T8030",
  "product" : "iPhone12,3",
  "incident" : "7F111601-BC7A-4BD7-A468-CE3370053057",
  "date" : "2020-03-19 17:30:28.39 +0800",
  "build" : "iPhone OS 13.3.1 (17D50)",
  "timeDelta" : 189,
  "memoryStatus" : {
  "compressorSize" : 66443,
  "compressions" : 25498129,
  "decompressions" : 15532621,
  "zoneMapCap" : 1395015680,
  "largestZone" : "APFS_4K_OBJS",
  "largestZoneSize" : 41222144,
  "pageSize" : 16384,
  "uncompressed" : 127027,
  "zoneMapSize" : 169639936,
  "memoryPages" : {
    "active" : 58652,
    "throttled" : 0,
    "fileBacked" : 20291,
    "wired" : 45838,
    "anonymous" : 96445,
    "purgeable" : 4,
    "inactive" : 54368,
    "free" : 5461,
    "speculative" : 3716
  }
},
  "largestProcess" : "杭城小劉",
  "genCounter" : 0,
  "processes" : [
  {
    "uuid" : "2dd5eb1e-fd31-36c2-99d9-bcbff44efbb7",
    "states" : [
      "daemon",
      "idle"
    ],
    "lifetimeMax" : 171,
    "age" : 5151034269954,
    "purgeable" : 0,
    "fds" : 50,
    "coalition" : 66,
    "rpages" : 164,
    "pid" : 11276,
    "idleDelta" : 3801132318,
    "name" : "wcd",
    "cpuTime" : 3.430787
  },
  // ...
  {
    "uuid" : "63158edc-915f-3a2b-975c-0e0ac4ed44c0",
    "states" : [
      "frontmost"
    ],
    "killDelta" : 4345,
    "genCount" : 0,
    "age" : 654480778,
    "purgeable" : 0,
    "fds" : 50,
    "coalition" : 1718,
    "rpages" : 134278,
    "reason" : "per-process-limit",
    "pid" : 14206,
    "cpuTime" : 23.955463999999999,
    "name" : "杭城小劉",
    "lifetimeMax" : 134278
  },
  // ...
 ]
}

iPhone 11 Pro/13.3.1 手機 OOM 臨界值爲:(16384*134278)/(1024*1024)=2098.09375M

iOS 系統如何發現 Jetsam ?

MacOS/iOS 是一個 BSD 衍生而來的系統,其內核是 Mach,但是對於上層暴露的接口一般是基於 BSD 層對 Mach 的包裝後的。Mach 是一個微內核的架構,真正的虛擬內存管理也是在其中進行的,BSD 對內存管理提供了上層接口。Jetsam 事件也是由 BSD 產生的。bsd_init 函數是入口,其中基本都是在初始化各個子系統,比如虛擬內存管理等。

// 1. Initialize the kernel memory allocator, 初始化 BSD 內存 Zone,這個 Zone 是基於 Mach 內核的zone 構建
kmeminit();

// 2. Initialise background freezing, iOS 上獨有的特性,內存和進程的休眠的常駐監控線程
#if CONFIG_FREEZE
#ifndef CONFIG_MEMORYSTATUS
    #error "CONFIG_FREEZE defined without matching CONFIG_MEMORYSTATUS"
#endif
    /* Initialise background freezing */
    bsd_init_kprintf("calling memorystatus_freeze_init\n");
    memorystatus_freeze_init();
#endif>

// 3. iOS 獨有,JetSAM(即低內存事件的常駐監控線程)
#if CONFIG_MEMORYSTATUS
    /* Initialize kernel memory status notifications */
    bsd_init_kprintf("calling memorystatus_init\n");
    memorystatus_init();
#endif /* CONFIG_MEMORYSTATUS */

主要作用就是開啓了2個優先級最高的線程,來監控整個系統的內存情況。

CONFIG_FREEZE 開啓時,內核對進程進行冷凍而不是殺死。冷凍功能是由內核中啓動一個 memorystatus_freeze_thread 進行,這個進程在收到信號後調用 memorystatus_freeze_top_process 進行冷凍。

iOS 系統會開啓優先級最高的線程 vm_pressure_monitor 來監控系統的內存壓力情況,並通過一個堆棧來維護所有 App 進程。iOS 系統還會維護一個內存快照表,用於保存每個進程內存頁的消耗情況。有關 Jetsam 也就是 memorystatus 相關的邏輯,可以在 XNU 項目中的 kern_memorystatus.h 和 **kern_memorystatus.c **源碼中查看。

iOS 系統因內存佔用過高會強殺 App 前,至少有 6秒鐘可以用來做優先級判斷,JetsamEvent 日誌也是在這6秒內生成的。

上文提到了 iOS 系統沒有交換空間,於是引入了 MemoryStatus 機制(也稱爲 Jetsam)。也就是說在 iOS 系統上釋放盡可能多的內存供當前 App 使用。這個機制表現在優先級上,就是先強殺後臺應用;如果內存還是不夠多,就強殺掉當前應用。在 MacOS 中,MemoryStatus 只會強殺掉標記爲空閒退出的進程。

MemoryStatus 機制會開啓一個 memorystatus_jetsam_thread 的線程,它負責強殺 App 和記錄日誌,不會發送消息,所以內存壓力檢測線程無法獲取到強殺 App 的消息。

當監控線程發現某 App 有內存壓力時,就發出通知,此時有內存的 App 就去執行 didReceiveMemoryWarning 代理方法。在這個時機,我們還有機會做一些內存資源釋放的邏輯,也許會避免 App 被系統殺死。

源碼角度查看問題

iOS 系統內核有一個數組,專門維護線程的優先級。數組的每一項是一個包含進程鏈表的結構體。結構體如下:

#define MEMSTAT_BUCKET_COUNT (JETSAM_PRIORITY_MAX + 1)

typedef struct memstat_bucket {
    TAILQ_HEAD(, proc) list;
    int count;
} memstat_bucket_t;

memstat_bucket_t memstat_bucket[MEMSTAT_BUCKET_COUNT];

在 kern_memorystatus.h 中可以看到進行優先級信息

#define JETSAM_PRIORITY_IDLE_HEAD                -2
/* The value -1 is an alias to JETSAM_PRIORITY_DEFAULT */
#define JETSAM_PRIORITY_IDLE                      0
#define JETSAM_PRIORITY_IDLE_DEFERRED		  1 /* Keeping this around till all xnu_quick_tests can be moved away from it.*/
#define JETSAM_PRIORITY_AGING_BAND1		  JETSAM_PRIORITY_IDLE_DEFERRED
#define JETSAM_PRIORITY_BACKGROUND_OPPORTUNISTIC  2
#define JETSAM_PRIORITY_AGING_BAND2		  JETSAM_PRIORITY_BACKGROUND_OPPORTUNISTIC
#define JETSAM_PRIORITY_BACKGROUND                3
#define JETSAM_PRIORITY_ELEVATED_INACTIVE	  JETSAM_PRIORITY_BACKGROUND
#define JETSAM_PRIORITY_MAIL                      4
#define JETSAM_PRIORITY_PHONE                     5
#define JETSAM_PRIORITY_UI_SUPPORT                8
#define JETSAM_PRIORITY_FOREGROUND_SUPPORT        9
#define JETSAM_PRIORITY_FOREGROUND               10
#define JETSAM_PRIORITY_AUDIO_AND_ACCESSORY      12
#define JETSAM_PRIORITY_CONDUCTOR                13
#define JETSAM_PRIORITY_HOME                     16
#define JETSAM_PRIORITY_EXECUTIVE                17
#define JETSAM_PRIORITY_IMPORTANT                18
#define JETSAM_PRIORITY_CRITICAL                 19

#define JETSAM_PRIORITY_MAX                      21

可以明顯的看到,後臺 App 優先級 JETSAM_PRIORITY_BACKGROUND 爲3,前臺 App 優先級 JETSAM_PRIORITY_FOREGROUND 爲10。

優先級規則是:內核線程優先級 > 操作系統優先級 > App 優先級。且前臺 App 優先級高於後臺運行的 App;當線程的優先級相同時, CPU 佔用多的線程的優先級會被降低。

在 kern_memorystatus.c 中可以看到 OOM 可能的原因:

/* For logging clarity */
static const char *memorystatus_kill_cause_name[] = {
	""								,		/* kMemorystatusInvalid							*/
	"jettisoned"					,		/* kMemorystatusKilled							*/
	"highwater"						,		/* kMemorystatusKilledHiwat						*/
	"vnode-limit"					,		/* kMemorystatusKilledVnodes					*/
	"vm-pageshortage"				,		/* kMemorystatusKilledVMPageShortage			*/
	"proc-thrashing"				,		/* kMemorystatusKilledProcThrashing				*/
	"fc-thrashing"					,		/* kMemorystatusKilledFCThrashing				*/
	"per-process-limit"				,		/* kMemorystatusKilledPerProcessLimit			*/
	"disk-space-shortage"			,		/* kMemorystatusKilledDiskSpaceShortage			*/
	"idle-exit"						,		/* kMemorystatusKilledIdleExit					*/
	"zone-map-exhaustion"			,		/* kMemorystatusKilledZoneMapExhaustion			*/
	"vm-compressor-thrashing"		,		/* kMemorystatusKilledVMCompressorThrashing		*/
	"vm-compressor-space-shortage"	,		/* kMemorystatusKilledVMCompressorSpaceShortage	*/
};

查看 memorystatus_init 這個函數中初始化 Jetsam 線程的關鍵代碼

__private_extern__ void
memorystatus_init(void)
{
	// ...
  /* Initialize the jetsam_threads state array */
	jetsam_threads = kalloc(sizeof(struct jetsam_thread_state) * max_jetsam_threads);
  
	/* Initialize all the jetsam threads */
	for (i = 0; i < max_jetsam_threads; i++) {

		result = kernel_thread_start_priority(memorystatus_thread, NULL, 95 /* MAXPRI_KERNEL */, &jetsam_threads[i].thread);
		if (result == KERN_SUCCESS) {
			jetsam_threads[i].inited = FALSE;
			jetsam_threads[i].index = i;
			thread_deallocate(jetsam_threads[i].thread);
		} else {
			panic("Could not create memorystatus_thread %d", i);
		}
	}
}
/*
 *	High-level priority assignments
 *
 *************************************************************************
 * 127		Reserved (real-time)
 *				A
 *				+
 *			(32 levels)
 *				+
 *				V
 * 96		Reserved (real-time)
 * 95		Kernel mode only
 *				A
 *				+
 *			(16 levels)
 *				+
 *				V
 * 80		Kernel mode only
 * 79		System high priority
 *				A
 *				+
 *			(16 levels)
 *				+
 *				V
 * 64		System high priority
 * 63		Elevated priorities
 *				A
 *				+
 *			(12 levels)
 *				+
 *				V
 * 52		Elevated priorities
 * 51		Elevated priorities (incl. BSD +nice)
 *				A
 *				+
 *			(20 levels)
 *				+
 *				V
 * 32		Elevated priorities (incl. BSD +nice)
 * 31		Default (default base for threads)
 * 30		Lowered priorities (incl. BSD -nice)
 *				A
 *				+
 *			(20 levels)
 *				+
 *				V
 * 11		Lowered priorities (incl. BSD -nice)
 * 10		Lowered priorities (aged pri's)
 *				A
 *				+
 *			(11 levels)
 *				+
 *				V
 * 0		Lowered priorities (aged pri's / idle)
 *************************************************************************
 */

可以看出:用戶態的應用程序的線程不可能高於操作系統和內核。而且,用戶態的應用程序間的線程優先級分配也有區別,比如處於前臺的應用程序優先級高於處於後臺的應用程序優先級。iOS 上應用程序優先級最高的是 SpringBoard;此外線程的優先級不是一成不變的。Mach 會根據線程的利用率和系統整體負載動態調整線程優先級。如果耗費 CPU 太多就降低線程優先級,如果線程過度捱餓,則會提升線程優先級。但是無論怎麼變,程序都不能超過其所在線程的優先級區間範圍。

可以看出,系統會根據內核啓動參數和設備性能,開啓 max_jetsam_threads 個(一般情況爲1,特殊情況下可能爲3)jetsam 線程,且這些線程的優先級爲 95,也就是 MAXPRI_KERNEL(注意這裏的 95 是線程的優先級,XNU 的線程優先級區間爲:0~127。上文的宏定義是進程優先級,區間爲:-2~19)。

緊接着,分析下 memorystatus_thread 函數,主要負責線程啓動的初始化

static void
memorystatus_thread(void *param __unused, wait_result_t wr __unused)
{
  //...
  while (memorystatus_action_needed()) {
		boolean_t killed;
		int32_t priority;
		uint32_t cause;
		uint64_t jetsam_reason_code = JETSAM_REASON_INVALID;
		os_reason_t jetsam_reason = OS_REASON_NULL;

		cause = kill_under_pressure_cause;
		switch (cause) {
			case kMemorystatusKilledFCThrashing:
				jetsam_reason_code = JETSAM_REASON_MEMORY_FCTHRASHING;
				break;
			case kMemorystatusKilledVMCompressorThrashing:
				jetsam_reason_code = JETSAM_REASON_MEMORY_VMCOMPRESSOR_THRASHING;
				break;
			case kMemorystatusKilledVMCompressorSpaceShortage:
				jetsam_reason_code = JETSAM_REASON_MEMORY_VMCOMPRESSOR_SPACE_SHORTAGE;
				break;
			case kMemorystatusKilledZoneMapExhaustion:
				jetsam_reason_code = JETSAM_REASON_ZONE_MAP_EXHAUSTION;
				break;
			case kMemorystatusKilledVMPageShortage:
				/* falls through */
			default:
				jetsam_reason_code = JETSAM_REASON_MEMORY_VMPAGESHORTAGE;
				cause = kMemorystatusKilledVMPageShortage;
				break;
		}

		/* Highwater */
		boolean_t is_critical = TRUE;
		if (memorystatus_act_on_hiwat_processes(&errors, &hwm_kill, &post_snapshot, &is_critical)) {
			if (is_critical == FALSE) {
				/*
				 * For now, don't kill any other processes.
				 */
				break;
			} else {
				goto done;
			}
		}

		jetsam_reason = os_reason_create(OS_REASON_JETSAM, jetsam_reason_code);
		if (jetsam_reason == OS_REASON_NULL) {
			printf("memorystatus_thread: failed to allocate jetsam reason\n");
		}

		if (memorystatus_act_aggressive(cause, jetsam_reason, &jld_idle_kills, &corpse_list_purged, &post_snapshot)) {
			goto done;
		}

		/*
		 * memorystatus_kill_top_process() drops a reference,
		 * so take another one so we can continue to use this exit reason
		 * even after it returns
		 */
		os_reason_ref(jetsam_reason);

		/* LRU */
		killed = memorystatus_kill_top_process(TRUE, sort_flag, cause, jetsam_reason, &priority, &errors);
		sort_flag = FALSE;

		if (killed) {
			if (memorystatus_post_snapshot(priority, cause) == TRUE) {

        			post_snapshot = TRUE;
			}

			/* Jetsam Loop Detection */
			if (memorystatus_jld_enabled == TRUE) {
				if ((priority == JETSAM_PRIORITY_IDLE) || (priority == system_procs_aging_band) || (priority == applications_aging_band)) {
					jld_idle_kills++;
				} else {
					/*
					 * We've reached into bands beyond idle deferred.
					 * We make no attempt to monitor them
					 */
				}
			}

			if ((priority >= JETSAM_PRIORITY_UI_SUPPORT) && (total_corpses_count() > 0) && (corpse_list_purged == FALSE)) {
				/*
				 * If we have jetsammed a process in or above JETSAM_PRIORITY_UI_SUPPORT
				 * then we attempt to relieve pressure by purging corpse memory.
				 */
				task_purge_all_corpses();
				corpse_list_purged = TRUE;
			}
			goto done;
		}
		
		if (memorystatus_avail_pages_below_critical()) {
			/*
			 * Still under pressure and unable to kill a process - purge corpse memory
			 */
			if (total_corpses_count() > 0) {
				task_purge_all_corpses();
				corpse_list_purged = TRUE;
			}

			if (memorystatus_avail_pages_below_critical()) {
				/*
				 * Still under pressure and unable to kill a process - panic
				 */
				panic("memorystatus_jetsam_thread: no victim! available pages:%llu\n", (uint64_t)memorystatus_available_pages);
			}
		}
			
done:	

}

可以看到它開啓了一個 循環,memorystatus_action_needed() 來作爲循環條件,持續釋放內存。

static boolean_t
memorystatus_action_needed(void)
{
#if CONFIG_EMBEDDED
	return (is_reason_thrashing(kill_under_pressure_cause) ||
			is_reason_zone_map_exhaustion(kill_under_pressure_cause) ||
	       memorystatus_available_pages <= memorystatus_available_pages_pressure);
#else /* CONFIG_EMBEDDED */
	return (is_reason_thrashing(kill_under_pressure_cause) ||
			is_reason_zone_map_exhaustion(kill_under_pressure_cause));
#endif /* CONFIG_EMBEDDED */
}

它通過 vm_pagepout 發送的內存壓力來判斷當前內存資源是否緊張。幾種情況:頻繁的頁面換出換進 is_reason_thrashing, Mach Zone 耗盡了 is_reason_zone_map_exhaustion、以及可用的頁低於了 memory status_available_pages 這個門檻。

繼續看 memorystatus_thread,會發現內存緊張時,將先觸發 High-water 類型的 OOM,也就是說假如某個進程使用過程中超過了其使用內存的最高限制 hight water mark 時會發生 OOM。在 memorystatus_act_on_hiwat_processes() 中,通過 memorystatus_kill_hiwat_proc() 在優先級數組 memstat_bucket 中查找優先級最低的進程,如果進程的內存小於閾值(footprint_in_bytes <= memlimit_in_bytes)則繼續尋找次優先級較低的進程,直到找到佔用內存超過閾值的進程並殺死。

通常來說單個 App 很難觸碰到 high water mark,如果不能結束任何進程,最終走到 memorystatus_act_aggressive,也就是大多數 OOM 發生的地方。

static boolean_t
memorystatus_act_aggressive(uint32_t cause, os_reason_t jetsam_reason, int *jld_idle_kills, boolean_t *corpse_list_purged, boolean_t *post_snapshot)
{
	// ...
  if ( (jld_bucket_count == 0) || 
		     (jld_now_msecs > (jld_timestamp_msecs + memorystatus_jld_eval_period_msecs))) {

			/* 
			 * Refresh evaluation parameters 
			 */
			jld_timestamp_msecs	 = jld_now_msecs;
			jld_idle_kill_candidates = jld_bucket_count;
			*jld_idle_kills		 = 0;
			jld_eval_aggressive_count = 0;
			jld_priority_band_max	= JETSAM_PRIORITY_UI_SUPPORT;
		}
  //...
}

上述代碼看到,判斷要不要真正執行 kill 是根據一定的時間間判斷的,條件是 jld_now_msecs > (jld_timestamp_msecs + memorystatus_jld_eval_period_msecs。 也就是在 memorystatus_jld_eval_period_msecs 後才發生條件裏面的 kill。

/* Jetsam Loop Detection */
if (max_mem <= (512 * 1024 * 1024)) {
	/* 512 MB devices */
memorystatus_jld_eval_period_msecs = 8000;	/* 8000 msecs == 8 second window */
} else {
	/* 1GB and larger devices */
memorystatus_jld_eval_period_msecs = 6000;	/* 6000 msecs == 6 second window */
}

其中 memorystatus_jld_eval_period_msecs 取值最小6秒。所以我們可以在6秒內做些處理。

3.2 開發者們整理所得

stackoverflow 上有一份數據,整理了各種設備的 OOM 臨界值

device crash amount:MB total amount:MB percentage of total
iPad1 127 256 49%
iPad2 275 512 53%
iPad3 645 1024 62%
iPad4(iOS 8.1) 585 1024 57%
Pad Mini 1st Generation 297 512 58%
iPad Mini retina(iOS 7.1) 696 1024 68%
iPad Air 697 1024 68%
iPad Air 2(iOS 10.2.1) 1383 2048 68%
iPad Pro 9.7"(iOS 10.0.2 (14A456)) 1395 1971 71%
iPad Pro 10.5”(iOS 11 beta4) 3057 4000 76%
iPad Pro 12.9” (2015)(iOS 11.2.1) 3058 3999 76%
iPad 10.2(iOS 13.2.3) 1844 2998 62%
iPod touch 4th gen(iOS 6.1.1) 130 256 51%
iPod touch 5th gen 286 512 56%
iPhone4 325 512 63%
iPhone4s 286 512 56%
iPhone5 645 1024 62%
iPhone5s 646 1024 63%
iPhone6(iOS 8.x) 645 1024 62%
iPhone6 Plus(iOS 8.x) 645 1024 62%
iPhone6s(iOS 9.2) 1396 2048 68%
iPhone6s Plus(iOS 10.2.1) 1396 2048 68%
iPhoneSE(iOS 9.3) 1395 2048 68%
iPhone7(iOS 10.2) 1395 2048 68%
iPhone7 Plus(iOS 10.2.1) 2040 3072 66%
iPhone8(iOS 12.1) 1364 1990 70%
iPhoneX(iOS 11.2.1) 1392 2785 50%
iPhoneXS(iOS 12.1) 2040 3754 54%
iPhoneXS Max(iOS 12.1) 2039 3735 55%
iPhoneXR(iOS 12.1) 1792 2813 63%
iPhone11(iOS 13.1.3) 2068 3844 54%
iPhone11 Pro Max(iOS 13.2.3) 2067 3740 55%

3.3 觸發當前 App 的 high water mark

我們可以寫定時器,不斷的申請內存,之後再通過 phys_footprint 打印當前佔用內存,按道理來說不斷申請內存即可觸發 Jetsam 機制,強殺 App,那麼最後一次打印的內存佔用也就是當前設備的內存上限值

timer = [NSTimer scheduledTimerWithTimeInterval:0.01 target:self selector:@selector(allocateMemory) userInfo:nil repeats:YES];

- (void)allocateMemory {
    UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
    UIImage *image = [UIImage imageNamed:@"AppIcon"];
    imageView.image = image;
    [array addObject:imageView];
    
    memoryLimitSizeMB = [self usedSizeOfMemory];
    if (memoryWarningSizeMB && memoryLimitSizeMB) {
        NSLog(@"----- memory warnning:%dMB, memory limit:%dMB", memoryWarningSizeMB, memoryLimitSizeMB);
    }
}

- (int)usedSizeOfMemory {
    task_vm_info_data_t taskInfo;
    mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT;
    kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount);

    if (kernReturn != KERN_SUCCESS) {
        return 0;
    }
    return (int)(taskInfo.phys_footprint/1024.0/1024.0);
}

3.4 適用於 iOS13 系統的獲取方式

iOS13 開始 <os/proc.h> 中 size_t os_proc_available_memory(void); 可以查看當前可用內存。

Return Value

The number of bytes that the app may allocate before it hits its memory limit. If the calling process isn’t an app, or if the process has already exceeded its memory limit, this function returns 0.

Discussion

Call this function to determine the amount of memory available to your app. The returned value corresponds to the current memory limit minus the memory footprint of your app at the time of the function call. Your app’s memory footprint consists of the data that you allocated in RAM, and that must stay in RAM (or the equivalent) at all times. Memory limits can change during the app life cycle and don’t necessarily correspond to the amount of physical memory available on the device.

Use the returned value as advisory information only and don’t cache it. The precise value changes when your app does any work that affects memory, which can happen frequently.

Although this function lets you determine the amount of memory your app may safely consume, don’t use it to maximize your app’s memory usage. Significant memory use, even when under the current memory limit, affects system performance. For example, when your app consumes all of its available memory, the system may need to terminate other apps and system processes to accommodate your app’s requests. Instead, always consume the smallest amount of memory you need to be responsive to the user’s needs.

If you need more detailed information about the available memory resources, you can call task_info. However, be aware that task_info is an expensive call, whereas this function is much more efficient.

if (@available(iOS 13.0, *)) {
	return os_proc_available_memory() / 1024.0 / 1024.0;
}

App 內存信息的 API 可以在 Mach 層找到,mach_task_basic_info 結構體存儲了 Mach task 的內存使用信息,其中 phys_footprint 就是應用使用的物理內存大小,virtual_size 是虛擬內存大小。

#define MACH_TASK_BASIC_INFO     20         /* always 64-bit basic info */
struct mach_task_basic_info {
    mach_vm_size_t  virtual_size;       /* virtual memory size (bytes) */
    mach_vm_size_t  resident_size;      /* resident memory size (bytes) */
    mach_vm_size_t  resident_size_max;  /* maximum resident memory size (bytes) */
    time_value_t    user_time;          /* total user run time for
                                            terminated threads */
    time_value_t    system_time;        /* total system run time for
                                            terminated threads */
    policy_t        policy;             /* default policy for new threads */
    integer_t       suspend_count;      /* suspend count for task */
};

所以獲取代碼爲

task_vm_info_data_t vmInfo;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&vmInfo, &count);

if (kr != KERN_SUCCESS) {
    return ;
}
CGFloat memoryUsed = (CGFloat)(vmInfo.phys_footprint/1024.0/1024.0);

可能有人好奇不應該是 resident_size 這個字段獲取內存的使用情況嗎?一開始測試後發現 resident_size 和 Xcode 測量結果差距較大。而使用 phys_footprint 則接近於 Xcode 給出的結果。且可以從 WebKit 源碼中得到印證。

所以在 iOS13 上,我們可以通過 os_proc_available_memory 獲取到當前可以用內存,通過 phys_footprint 獲取到當前 App 佔用內存,2者的和也就是當前設備的內存上限,超過即觸發 Jetsam 機制。

- (CGFloat)limitSizeOfMemory {
    if (@available(iOS 13.0, *)) {
        task_vm_info_data_t taskInfo;
        mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT;
        kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount);

        if (kernReturn != KERN_SUCCESS) {
            return 0;
        }
        return (CGFloat)((taskInfo.phys_footprint + os_proc_available_memory()) / (1024.0 * 1024.0);
    }
    return 0;
}

當前可以使用內存:1435.936752MB;當前 App 已佔用內存:14.5MB,臨界值:1435.936752MB + 14.5MB= 1450.436MB, 和 3.1 方法中獲取到的內存臨界值一樣「iPhone 6s plus/13.3.1 手機 OOM 臨界值爲:(16384*92806)/(1024*1024)=1450.09375M」。

3.5 通過 XNU 獲取內存限制值

在 XNU 中,有專門用於獲取內存上限值的函數和宏,可以通過 memorystatus_priority_entry 這個結構體得到所有進程的優先級和內存限制值。

typedef struct memorystatus_priority_entry {
  pid_t pid;
  int32_t priority;
  uint64_t user_data;
  int32_t limit;
  uint32_t state;
} memorystatus_priority_entry_t;

其中,priority 代表進程優先級,limit 代表進程的內存限制值。但是這種方式需要 root 權限,由於沒有越獄設備,我沒有嘗試過。

相關代碼可查閱 kern_memorystatus.h 文件。需要用到函數 int memorystatus_control(uint32_t command, int32_t pid, uint32_t flags, void *buffer, size_t buffersize);

/* Commands */
#define MEMORYSTATUS_CMD_GET_PRIORITY_LIST            1
#define MEMORYSTATUS_CMD_SET_PRIORITY_PROPERTIES      2
#define MEMORYSTATUS_CMD_GET_JETSAM_SNAPSHOT          3
#define MEMORYSTATUS_CMD_GET_PRESSURE_STATUS          4
#define MEMORYSTATUS_CMD_SET_JETSAM_HIGH_WATER_MARK   5    /* Set active memory limit = inactive memory limit, both non-fatal	*/
#define MEMORYSTATUS_CMD_SET_JETSAM_TASK_LIMIT	      6    /* Set active memory limit = inactive memory limit, both fatal	*/
#define MEMORYSTATUS_CMD_SET_MEMLIMIT_PROPERTIES      7    /* Set memory limits plus attributes independently			*/
#define MEMORYSTATUS_CMD_GET_MEMLIMIT_PROPERTIES      8    /* Get memory limits plus attributes					*/
#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_ENABLE   9    /* Set the task's status as a privileged listener w.r.t memory notifications  */
#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_DISABLE  10   /* Reset the task's status as a privileged listener w.r.t memory notifications  */
#define MEMORYSTATUS_CMD_AGGRESSIVE_JETSAM_LENIENT_MODE_ENABLE  11   /* Enable the 'lenient' mode for aggressive jetsam. See comments in kern_memorystatus.c near the top. */
#define MEMORYSTATUS_CMD_AGGRESSIVE_JETSAM_LENIENT_MODE_DISABLE 12   /* Disable the 'lenient' mode for aggressive jetsam. */
#define MEMORYSTATUS_CMD_GET_MEMLIMIT_EXCESS          13   /* Compute how much a process's phys_footprint exceeds inactive memory limit */
#define MEMORYSTATUS_CMD_ELEVATED_INACTIVEJETSAMPRIORITY_ENABLE 	14 /* Set the inactive jetsam band for a process to JETSAM_PRIORITY_ELEVATED_INACTIVE */
#define MEMORYSTATUS_CMD_ELEVATED_INACTIVEJETSAMPRIORITY_DISABLE 	15 /* Reset the inactive jetsam band for a process to the default band (0)*/
#define MEMORYSTATUS_CMD_SET_PROCESS_IS_MANAGED       16   /* (Re-)Set state on a process that marks it as (un-)managed by a system entity e.g. assertiond */
#define MEMORYSTATUS_CMD_GET_PROCESS_IS_MANAGED       17   /* Return the 'managed' status of a process */
#define MEMORYSTATUS_CMD_SET_PROCESS_IS_FREEZABLE     18   /* Is the process eligible for freezing? Apps and extensions can pass in FALSE to opt out of freezing, i.e.,

僞代碼

struct memorystatus_priority_entry memStatus[NUM_ENTRIES];
size_t count = sizeof(struct memorystatus_priority_entry) * NUM_ENTRIES;
int kernResult = memorystatus_control(MEMORYSTATUS_CMD_GET_PRIORITY_LIST, 0, 0, memStatus, count);
if (rc < 0) {
  NSLog(@"memorystatus_control"); 
	return ;
}

int entry = 0;
for (; rc > 0; rc -= sizeof(struct memorystatus_priority_entry)){
  printf ("PID: %5d\tPriority:%2d\tUser Data: %llx\tLimit:%2d\tState:%s\n",
          memstatus[entry].pid,
          memstatus[entry].priority,
          memstatus[entry].user_data,
          memstatus[entry].limit,
          state_to_text(memstatus[entry].state));
  entry++;
}

for 循環打印出每個進程(也就是 App)的 pid、Priority、User Data、Limit、State 信息。從 log 中找出優先級爲10的進程,即我們前臺運行的 App。爲什麼是10? 因爲 #define JETSAM_PRIORITY_FOREGROUND 10 我們的目的就是獲取前臺 App 的內存上限值。

4. 如何判定發生了 OOM

OOM 導致 crash 前,app 一定會收到低內存警告嗎?

做2組對比實驗:

// 實驗1
NSMutableArray *array = [NSMutableArray array];
for (NSInteger index = 0; index < 10000000; index++) {
  NSString *filePath = [[NSBundle mainBundle] pathForResource:@"Info" ofType:@"plist"];
  NSData *data = [NSData dataWithContentsOfFile:filePath];
  [array addObject:data];
}
// 實驗2
// ViewController.m
- (void)viewDidLoad {
    [super viewDidLoad];
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        NSMutableArray *array = [NSMutableArray array];
        for (NSInteger index = 0; index < 10000000; index++) {
            NSString *filePath = [[NSBundle mainBundle] pathForResource:@"Info" ofType:@"plist"];
            NSData *data = [NSData dataWithContentsOfFile:filePath];
            [array addObject:data];
        }
    });
}
- (void)didReceiveMemoryWarning
{
    NSLog(@"2");
}

// AppDelegate.m
- (void)applicationDidReceiveMemoryWarning:(UIApplication *)application
{
    NSLog(@"1");
}

現象:

  1. 在 viewDidLoad 也就是主線程中內存消耗過大,系統並不會發出低內存警告,直接 Crash。因爲內存增長過快,主線程很忙。
  2. 多線程的情況下,App 因內存增長過快,會收到低內存警告,AppDelegate 中的applicationDidReceiveMemoryWarning 先執行,隨後是當前 VC 的 didReceiveMemoryWarning

結論:

收到低內存警告不一定會 Crash,因爲有6秒鐘的系統判斷時間,6秒內內存下降了則不會 crash。發生 OOM 也不一定會收到低內存警告。

5. 內存信息收集

要想精確的定位問題,就需要 dump 所有對象及其內存信息。當內存接近系統內存上限的時候,收集並記錄所需信息,結合一定的數據上報機制,上傳到服務器,分析並修復。

還需要知道每個對象具體是在哪個函數裏創建出來的,以便還原“案發現場”。

源代碼(libmalloc/malloc),內存分配函數 malloc 和 calloc 等默認使用 nano_zone,nano_zone 是小於 256B 以下的內存分配,大於 256B 則使用 scalable_zone 來分配。

主要針對大內存的分配監控。malloc 函數用的是 malloc_zone_malloc, calloc 用的是 malloc_zone_calloc。

使用 scalable_zone 分配內存的函數都會調用 malloc_logger 函數,因爲系統爲了有個地方專門統計並管理內存分配情況。這樣的設計也滿足「收口原則」。

void *
malloc(size_t size)
{
	void *retval;
	retval = malloc_zone_malloc(default_zone, size);
	if (retval == NULL) {
		errno = ENOMEM;
	}
	return retval;
}

void *
calloc(size_t num_items, size_t size)
{
	void *retval;
	retval = malloc_zone_calloc(default_zone, num_items, size);
	if (retval == NULL) {
		errno = ENOMEM;
	}
	return retval;
}

首先來看看這個 default_zone 是什麼東西, 代碼如下

typedef struct {
	malloc_zone_t malloc_zone;
	uint8_t pad[PAGE_MAX_SIZE - sizeof(malloc_zone_t)];
} virtual_default_zone_t;

static virtual_default_zone_t virtual_default_zone
__attribute__((section("__DATA,__v_zone")))
__attribute__((aligned(PAGE_MAX_SIZE))) = {
	NULL,
	NULL,
	default_zone_size,
	default_zone_malloc,
	default_zone_calloc,
	default_zone_valloc,
	default_zone_free,
	default_zone_realloc,
	default_zone_destroy,
	DEFAULT_MALLOC_ZONE_STRING,
	default_zone_batch_malloc,
	default_zone_batch_free,
	&default_zone_introspect,
	10,
	default_zone_memalign,
	default_zone_free_definite_size,
	default_zone_pressure_relief,
	default_zone_malloc_claimed_address,
};

static malloc_zone_t *default_zone = &virtual_default_zone.malloc_zone;

static void *
default_zone_malloc(malloc_zone_t *zone, size_t size)
{
	zone = runtime_default_zone();
	
	return zone->malloc(zone, size);
}


MALLOC_ALWAYS_INLINE
static inline malloc_zone_t *
runtime_default_zone() {
	return (lite_zone) ? lite_zone : inline_malloc_default_zone();
}

可以看到 default_zone 通過這種方式來初始化

static inline malloc_zone_t *
inline_malloc_default_zone(void)
{
	_malloc_initialize_once();
	// malloc_report(ASL_LEVEL_INFO, "In inline_malloc_default_zone with %d %d\n", malloc_num_zones, malloc_has_debug_zone);
	return malloc_zones[0];
}

隨後的調用如下
_malloc_initialize -> create_scalable_zone -> create_scalable_szone 最終我們創建了 szone_t 類型的對象,通過類型轉換,得到了我們的 default_zone。

malloc_zone_t *
create_scalable_zone(size_t initial_size, unsigned debug_flags) {
	return (malloc_zone_t *) create_scalable_szone(initial_size, debug_flags);
}
void *malloc_zone_malloc(malloc_zone_t *zone, size_t size)
{
  MALLOC_TRACE(TRACE_malloc | DBG_FUNC_START, (uintptr_t)zone, size, 0, 0);
  void *ptr;
  if (malloc_check_start && (malloc_check_counter++ >= malloc_check_start)) {
    internal_check();
  }
  if (size > MALLOC_ABSOLUTE_MAX_SIZE) {
    return NULL;
  }
  ptr = zone->malloc(zone, size);
  // 在 zone 分配完內存後就開始使用 malloc_logger 進行進行記錄
  if (malloc_logger) {
    malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE, (uintptr_t)zone, (uintptr_t)size, 0, (uintptr_t)ptr, 0);
  }
  MALLOC_TRACE(TRACE_malloc | DBG_FUNC_END, (uintptr_t)zone, size, (uintptr_t)ptr, 0);
  return ptr;
}

其分配實現是 zone->malloc 根據之前的分析,就是szone_t結構體對象中對應的malloc實現。

在創建szone之後,做了一系列如下的初始化操作。

// Initialize the security token.
szone->cookie = (uintptr_t)malloc_entropy[0];

szone->basic_zone.version = 12;
szone->basic_zone.size = (void *)szone_size;
szone->basic_zone.malloc = (void *)szone_malloc;
szone->basic_zone.calloc = (void *)szone_calloc;
szone->basic_zone.valloc = (void *)szone_valloc;
szone->basic_zone.free = (void *)szone_free;
szone->basic_zone.realloc = (void *)szone_realloc;
szone->basic_zone.destroy = (void *)szone_destroy;
szone->basic_zone.batch_malloc = (void *)szone_batch_malloc;
szone->basic_zone.batch_free = (void *)szone_batch_free;
szone->basic_zone.introspect = (struct malloc_introspection_t *)&szone_introspect;
szone->basic_zone.memalign = (void *)szone_memalign;
szone->basic_zone.free_definite_size = (void *)szone_free_definite_size;
szone->basic_zone.pressure_relief = (void *)szone_pressure_relief;
szone->basic_zone.claimed_address = (void *)szone_claimed_address;

其他使用 scalable_zone 分配內存的函數的方法也類似,所以大內存的分配,不管外部函數如何封裝,最終都會調用到 malloc_logger 函數。所以我們可以用 fishhook 去 hook 這個函數,然後記錄內存分配情況,結合一定的數據上報機制,上傳到服務器,分析並修復。

// For logging VM allocation and deallocation, arg1 here
// is the mach_port_name_t of the target task in which the
// alloc or dealloc is occurring. For example, for mmap()
// that would be mach_task_self(), but for a cross-task-capable
// call such as mach_vm_map(), it is the target task.

typedef void (malloc_logger_t)(uint32_t type, uintptr_t arg1, uintptr_t arg2, uintptr_t arg3, uintptr_t result, uint32_t num_hot_frames_to_skip);

extern malloc_logger_t *__syscall_logger;

當 malloc_logger 和 __syscall_logger 函數指針不爲空時,malloc/free、vm_allocate/vm_deallocate 等內存分配/釋放通過這兩個指針通知上層,這也是內存調試工具 malloc stack 的實現原理。有了這兩個函數指針,我們很容易記錄當前存活對象的內存分配信息(包括分配大小和分配堆棧)。分配堆棧可以用 backtrace 函數捕獲,但捕獲到的地址是虛擬內存地址,不能從符號表 dsym 解析符號。所以還要記錄每個 image 加載時的偏移 slide,這樣 符號表地址 = 堆棧地址 - slide。

小 tips:

ASLR(Address space layout randomization):常見稱呼爲位址空間隨機載入、位址空間配置隨機化、位址空間佈局隨機化,是一種防止內存損壞漏洞被利用的計算機安全技術,通過隨機放置進程關鍵數據區域的定址空間來放置攻擊者能可靠地跳轉到內存的特定位置來操作函數。現代作業系統一般都具備該機制。

函數地址 add: 函數真實的實現地址;

函數虛擬地址:vm_add;

ASLR: slide 函數虛擬地址加載到進程內存的隨機偏移量,每個 mach-o 的 slide 各不相同。vm_add + slide = add。也就是:*(base +offset)= imp

由於騰訊也開源了自己的 OOM 定位方案- OOMDetector ,有了現成的輪子,那麼用好就可以了,所以對於內存的監控思路就是找到系統給 App 的內存上限,然後當接近內存上限值的時候,dump 內存情況,組裝基礎數據信息成一個合格的上報數據,經過一定的數據上報策略到服務端,服務端消費數據,分析產生報表,客戶端工程師根據報表分析問題。不同工程的數據以郵件、短信、企業微信等形式通知到該項目的 owner、開發者。(情況嚴重的會直接電話給開發者,並給主管跟進每一步的處理結果)。
問題分析處理後要麼發佈新版本,要麼 hot fix。

6. 開發階段針對內存我們能做些什麼

  1. 圖片縮放

    WWDC 2018 Session 416 - iOS Memory Deep Dive,處理圖片縮放的時候直接使用 UIImage 會在解碼時讀取文件而佔用一部分內存,還會生成中間位圖 bitmap 消耗大量內存。而 ImageIO 不存在上述2種弊端,只會佔用最終圖片大小的內存

    做了2組對比實驗:給 App 顯示一張圖片

    // 方法1: 19.6M
    UIImage *imageResult = [self scaleImage:[UIImage imageNamed:@"test"]                                                  newSize:CGSizeMake(self.view.frame.size.width, self.view.frame.size.height)];
    self.imageView.image = imageResult;
    
    // 方法2: 14M
    NSData *data = UIImagePNGRepresentation([UIImage imageNamed:@"test"]);
    UIImage *imageResult = [self scaledImageWithData:data 				    withSize:CGSizeMake(self.view.frame.size.width, self.view.frame.size.height) scale:3 orientation:UIImageOrientationUp];
    self.imageView.image = imageResult;
    
    - (UIImage *)scaleImage:(UIImage *)image newSize:(CGSize)newSize
    {
        UIGraphicsBeginImageContextWithOptions(newSize, NO, 0);
        [image drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)];
        UIImage *newImage = UIGraphicsGetImageFromCurrentImageContext();
        UIGraphicsEndImageContext();
        return newImage;
    }
    
    - (UIImage *)scaledImageWithData:(NSData *)data withSize:(CGSize)size scale:(CGFloat)scale orientation:(UIImageOrientation)orientation
    {
        CGFloat maxPixelSize = MAX(size.width, size.height);
        CGImageSourceRef sourceRef = CGImageSourceCreateWithData((__bridge CFDataRef)data, nil);
        NSDictionary *options = @{(__bridge id)kCGImageSourceCreateThumbnailFromImageAlways : (__bridge id)kCFBooleanTrue,
                                  (__bridge id)kCGImageSourceThumbnailMaxPixelSize : [NSNumber numberWithFloat:maxPixelSize]};
        CGImageRef imageRef = CGImageSourceCreateThumbnailAtIndex(sourceRef, 0, (__bridge CFDictionaryRef)options);
        UIImage *resultImage = [UIImage imageWithCGImage:imageRef scale:scale orientation:orientation];
        CGImageRelease(imageRef);
        CFRelease(sourceRef);
        return resultImage;
    }
    

    可以看出使用 ImageIO 比使用 UIImage 直接縮放佔用內存更低。

  2. 合理使用 autoreleasepool

我們知道 autoreleasepool 對象是在 RunLoop 結束時才釋放。在 ARC 下,我們如果在不斷申請內存,比如各種循環,那麼我們就需要手動添加 autoreleasepool,避免短時間內內存猛漲發生 OOM。

對比實驗

// 實驗1
NSMutableArray *array = [NSMutableArray array];
for (NSInteger index = 0; index < 10000000; index++) {
  NSString *indexStrng = [NSString stringWithFormat:@"%zd", index];
  NSString *resultString = [NSString stringWithFormat:@"%zd-%@", index, indexStrng];
  [array addObject:resultString];
}

// 實驗2
NSMutableArray *array = [NSMutableArray array];
for (NSInteger index = 0; index < 10000000; index++) {
  @autoreleasepool {
    NSString *indexStrng = [NSString stringWithFormat:@"%zd", index];
    NSString *resultString = [NSString stringWithFormat:@"%zd-%@", index, indexStrng];
    [array addObject:resultString];
  }
}

實驗1消耗內存 739.6M,實驗2消耗內存 587M。

  1. UIGraphicsBeginImageContext 和 UIGraphicsEndImageContext 必須成雙出現,不然會造成 context 泄漏。另外 XCode 的 Analyze 也能掃出這類問題。

  2. 不管是打開網頁,還是執行 js,都應該使用 WKWebView。UIWebView 會佔用大量內存,從而導致 App 發生 OOM 的機率增加,而 WKWebView 是一個多進程組件,Network Loading 以及 UI Rendering 在其它進程中執行,比 UIWebView 佔用更低的內存開銷。

  3. 在做 SDK 或者 App,如果場景是緩存相關,儘量使用 NSCache 而不是 NSMutableDictionary。它是系統提供的專門處理緩存的類,NSCache 分配的內存是 Purgeable Memory,可以由系統自動釋放。NSCache 與 NSPureableData 的結合使用可以讓系統根據情況回收內存,也可以在內存清理時移除對象。

    其他的開發習慣就不一一描述了,良好的開發習慣和代碼意識是需要平時注意修煉的。

五、 App 網絡監控

移動網絡環境一直很複雜,WIFI、2G、3G、4G、5G 等,用戶使用 App 的過程中可能在這幾種類型之間切換,這也是移動網絡和傳統網絡間的一個區別,被稱爲「Connection Migration」。此外還存在 DNS 解析緩慢、失敗率高、運營商劫持等問題。用戶在使用 App 時因爲某些原因導致體驗很差,要想針對網絡情況進行改善,必須有清晰的監控手段。

1. App 網絡請求過程

網絡請求各階段

App 發送一次網絡請求一般會經歷下面幾個關鍵步驟:

  • DNS 解析

    Domain Name system,網絡域名名稱系統,本質上就是將域名IP 地址 相互映射的一個分佈式數據庫,使人們更方便的訪問互聯網。首先會查詢本地的 DNS 緩存,查找失敗就去 DNS 服務器查詢,這其中可能會經過非常多的節點,涉及到遞歸查詢和迭代查詢的過程。運營商可能不幹人事:一種情況就是出現運營商劫持的現象,表現爲你在 App 內訪問某個網頁的時候會看到和內容不相關的廣告;另一種可能的情況就是把你的請求丟給非常遠的基站去做 DNS 解析,導致我們 App 的 DNS 解析時間較長,App 網絡效率低。一般做 HTTPDNS 方案去自行解決 DNS 的問題。

  • TCP 3次握手

    關於 TCP 握手過程中爲什麼是3次握手而不是2次、4次,可以查看這篇文章

  • TLS 握手

    對於 HTTPS 請求還需要做 TLS 握手,也就是密鑰協商的過程。

  • 發送請求

    連接建立好之後就可以發送 request,此時可以記錄下 request start 時間

  • 等待迴應

    等待服務器返回響應。這個時間主要取決於資源大小,也是網絡請求過程中最爲耗時的一個階段。

  • 返回響應

    服務端返回響應給客戶端,根據 HTTP header 信息中的狀態碼判斷本次請求是否成功、是否走緩存、是否需要重定向。

2. 監控原理

名稱 說明
NSURLConnection 已經被廢棄。用法簡單
NSURLSession iOS7.0 推出,功能更強大
CFNetwork NSURL 的底層,純 C 實現

iOS 網絡框架層級關係如下:

Network Level

iOS 網絡現狀是由4層組成的:最底層的 BSD Sockets、SecureTransport;次級底層是 CFNetwork、NSURLSession、NSURLConnection、WebView 是用 Objective-C 實現的,且調用 CFNetwork;應用層框架 AFNetworking 基於 NSURLSession、NSURLConnection 實現。

目前業界對於網絡監控主要有2種:一種是通過 NSURLProtocol 監控、一種是通過 Hook 來監控。下面介紹幾種辦法來監控網絡請求,各有優缺點。

2.1 方案一:NSURLProtocol 監控 App 網絡請求

NSURLProtocol 作爲上層接口,使用較爲簡單,但 NSURLProtocol 屬於 URL Loading System 體系中。應用協議的支持程度有限,支持 FTP、HTTP、HTTPS 等幾個應用層協議,對於其他的協議則無法監控,存在一定的侷限性。如果監控底層網絡庫 CFNetwork 則沒有這個限制。

對於 NSURLProtocol 的具體做法在這篇文章中講過,繼承抽象類並實現相應的方法,自定義去發起網絡請求來實現監控的目的。

iOS 10 之後,NSURLSessionTaskDelegate 中增加了一個新的代理方法:

/*
 * Sent when complete statistics information has been collected for the task.
 */
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));

可以從 NSURLSessionTaskMetrics 中獲取到網絡情況的各項指標。各項參數如下

@interface NSURLSessionTaskMetrics : NSObject

/*
 * transactionMetrics array contains the metrics collected for every request/response transaction created during the task execution.
 */
@property (copy, readonly) NSArray<NSURLSessionTaskTransactionMetrics *> *transactionMetrics;

/*
 * Interval from the task creation time to the task completion time.
 * Task creation time is the time when the task was instantiated.
 * Task completion time is the time when the task is about to change its internal state to completed.
 */
@property (copy, readonly) NSDateInterval *taskInterval;

/*
 * redirectCount is the number of redirects that were recorded.
 */
@property (assign, readonly) NSUInteger redirectCount;

- (instancetype)init API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0));
+ (instancetype)new API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0));

@end

其中:taskInterval 表示任務從創建到完成話費的總時間,任務的創建時間是任務被實例化時的時間,任務完成時間是任務的內部狀態將要變爲完成的時間;redirectCount 表示被重定向的次數;transactionMetrics 數組包含了任務執行過程中每個請求/響應事務中收集的指標,各項參數如下:

/*
 * This class defines the performance metrics collected for a request/response transaction during the task execution.
 */
API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0))
@interface NSURLSessionTaskTransactionMetrics : NSObject

/*
 * Represents the transaction request. 請求事務
 */
@property (copy, readonly) NSURLRequest *request;

/*
 * Represents the transaction response. Can be nil if error occurred and no response was generated. 響應事務
 */
@property (nullable, copy, readonly) NSURLResponse *response;

/*
 * For all NSDate metrics below, if that aspect of the task could not be completed, then the corresponding “EndDate” metric will be nil.
 * For example, if a name lookup was started but the name lookup timed out, failed, or the client canceled the task before the name could be resolved -- then while domainLookupStartDate may be set, domainLookupEndDate will be nil along with all later metrics.
 */

/*
 * 客戶端開始請求的時間,無論是從服務器還是從本地緩存中獲取
 * fetchStartDate returns the time when the user agent started fetching the resource, whether or not the resource was retrieved from the server or local resources.
 *
 * The following metrics will be set to nil, if a persistent connection was used or the resource was retrieved from local resources:
 *
 *   domainLookupStartDate
 *   domainLookupEndDate
 *   connectStartDate
 *   connectEndDate
 *   secureConnectionStartDate
 *   secureConnectionEndDate
 */
@property (nullable, copy, readonly) NSDate *fetchStartDate;

/*
 * domainLookupStartDate returns the time immediately before the user agent started the name lookup for the resource. DNS 開始解析的時間
 */
@property (nullable, copy, readonly) NSDate *domainLookupStartDate;

/*
 * domainLookupEndDate returns the time after the name lookup was completed. DNS 解析完成的時間
 */
@property (nullable, copy, readonly) NSDate *domainLookupEndDate;

/*
 * connectStartDate is the time immediately before the user agent started establishing the connection to the server.
 *
 * For example, this would correspond to the time immediately before the user agent started trying to establish the TCP connection. 客戶端與服務端開始建立 TCP 連接的時間
 */
@property (nullable, copy, readonly) NSDate *connectStartDate;

/*
 * If an encrypted connection was used, secureConnectionStartDate is the time immediately before the user agent started the security handshake to secure the current connection. HTTPS 的 TLS 握手開始的時間
 *
 * For example, this would correspond to the time immediately before the user agent started the TLS handshake. 
 *
 * If an encrypted connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSDate *secureConnectionStartDate;

/*
 * If an encrypted connection was used, secureConnectionEndDate is the time immediately after the security handshake completed. HTTPS 的 TLS 握手結束的時間
 *
 * If an encrypted connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSDate *secureConnectionEndDate;

/*
 * connectEndDate is the time immediately after the user agent finished establishing the connection to the server, including completion of security-related and other handshakes. 客戶端與服務器建立 TCP 連接完成的時間,包括 TLS 握手時間
 */
@property (nullable, copy, readonly) NSDate *connectEndDate;

/*
 * requestStartDate is the time immediately before the user agent started requesting the source, regardless of whether the resource was retrieved from the server or local resources.
 客戶端請求開始的時間,可以理解爲開始傳輸 HTTP 請求的 header 的第一個字節時間
 *
 * For example, this would correspond to the time immediately before the user agent sent an HTTP GET request.
 */
@property (nullable, copy, readonly) NSDate *requestStartDate;

/*
 * requestEndDate is the time immediately after the user agent finished requesting the source, regardless of whether the resource was retrieved from the server or local resources.
 客戶端請求結束的時間,可以理解爲 HTTP 請求的最後一個字節傳輸完成的時間
 *
 * For example, this would correspond to the time immediately after the user agent finished sending the last byte of the request.
 */
@property (nullable, copy, readonly) NSDate *requestEndDate;

/*
 * responseStartDate is the time immediately after the user agent received the first byte of the response from the server or from local resources.
 客戶端從服務端接收響應的第一個字節的時間
 *
 * For example, this would correspond to the time immediately after the user agent received the first byte of an HTTP response.
 */
@property (nullable, copy, readonly) NSDate *responseStartDate;

/*
 * responseEndDate is the time immediately after the user agent received the last byte of the resource. 客戶端從服務端接收到最後一個請求的時間
 */
@property (nullable, copy, readonly) NSDate *responseEndDate;

/*
 * The network protocol used to fetch the resource, as identified by the ALPN Protocol ID Identification Sequence [RFC7301].
 * E.g., h2, http/1.1, spdy/3.1.
 網絡協議名,比如 http/1.1, spdy/3.1
 *
 * When a proxy is configured AND a tunnel connection is established, then this attribute returns the value for the tunneled protocol.
 *
 * For example:
 * If no proxy were used, and HTTP/2 was negotiated, then h2 would be returned.
 * If HTTP/1.1 were used to the proxy, and the tunneled connection was HTTP/2, then h2 would be returned.
 * If HTTP/1.1 were used to the proxy, and there were no tunnel, then http/1.1 would be returned.
 *
 */
@property (nullable, copy, readonly) NSString *networkProtocolName;

/*
 * This property is set to YES if a proxy connection was used to fetch the resource.
	該連接是否使用了代理
 */
@property (assign, readonly, getter=isProxyConnection) BOOL proxyConnection;

/*
 * This property is set to YES if a persistent connection was used to fetch the resource.
 是否複用了現有連接
 */
@property (assign, readonly, getter=isReusedConnection) BOOL reusedConnection;

/*
 * Indicates whether the resource was loaded, pushed or retrieved from the local cache.
 獲取資源來源
 */
@property (assign, readonly) NSURLSessionTaskMetricsResourceFetchType resourceFetchType;

/*
 * countOfRequestHeaderBytesSent is the number of bytes transferred for request header.
 請求頭的字節數
 */
@property (readonly) int64_t countOfRequestHeaderBytesSent API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * countOfRequestBodyBytesSent is the number of bytes transferred for request body.
 請求體的字節數
 * It includes protocol-specific framing, transfer encoding, and content encoding.
 */
@property (readonly) int64_t countOfRequestBodyBytesSent API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * countOfRequestBodyBytesBeforeEncoding is the size of upload body data, file, or stream.
 上傳體數據、文件、流的大小
 */
@property (readonly) int64_t countOfRequestBodyBytesBeforeEncoding API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * countOfResponseHeaderBytesReceived is the number of bytes transferred for response header.
 響應頭的字節數
 */
@property (readonly) int64_t countOfResponseHeaderBytesReceived API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * countOfResponseBodyBytesReceived is the number of bytes transferred for response body.
 響應體的字節數
 * It includes protocol-specific framing, transfer encoding, and content encoding.
 */
@property (readonly) int64_t countOfResponseBodyBytesReceived API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * countOfResponseBodyBytesAfterDecoding is the size of data delivered to your delegate or completion handler.
給代理方法或者完成後處理的回調的數據大小
 
 */
@property (readonly) int64_t countOfResponseBodyBytesAfterDecoding API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * localAddress is the IP address string of the local interface for the connection.
  當前連接下的本地接口 IP 地址
 *
 * For multipath protocols, this is the local address of the initial flow.
 *
 * If a connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSString *localAddress API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * localPort is the port number of the local interface for the connection.
 當前連接下的本地端口號
 
 *
 * For multipath protocols, this is the local port of the initial flow.
 *
 * If a connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSNumber *localPort API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * remoteAddress is the IP address string of the remote interface for the connection.
 當前連接下的遠端 IP 地址
 *
 * For multipath protocols, this is the remote address of the initial flow.
 *
 * If a connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSString *remoteAddress API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * remotePort is the port number of the remote interface for the connection.
  當前連接下的遠端端口號
 *
 * For multipath protocols, this is the remote port of the initial flow.
 *
 * If a connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSNumber *remotePort API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * negotiatedTLSProtocolVersion is the TLS protocol version negotiated for the connection.
  連接協商用的 TLS 協議版本號
 * It is a 2-byte sequence in host byte order.
 *
 * Please refer to tls_protocol_version_t enum in Security/SecProtocolTypes.h
 *
 * If an encrypted connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSNumber *negotiatedTLSProtocolVersion API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * negotiatedTLSCipherSuite is the TLS cipher suite negotiated for the connection.
 連接協商用的 TLS 密碼套件
 * It is a 2-byte sequence in host byte order.
 *
 * Please refer to tls_ciphersuite_t enum in Security/SecProtocolTypes.h
 *
 * If an encrypted connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSNumber *negotiatedTLSCipherSuite API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * Whether the connection is established over a cellular interface.
 是否是通過蜂窩網絡建立的連接
 */
@property (readonly, getter=isCellular) BOOL cellular API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * Whether the connection is established over an expensive interface.
 是否通過昂貴的接口建立的連接
 */
@property (readonly, getter=isExpensive) BOOL expensive API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * Whether the connection is established over a constrained interface.
 是否通過受限接口建立的連接
 */
@property (readonly, getter=isConstrained) BOOL constrained API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * Whether a multipath protocol is successfully negotiated for the connection.
 是否爲了連接成功協商了多路徑協議
 */
@property (readonly, getter=isMultipath) BOOL multipath API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));


- (instancetype)init API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0));
+ (instancetype)new API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0));

@end

網絡監控簡單代碼

// 監控基礎信息
@interface  NetworkMonitorBaseDataModel : NSObject
// 請求的 URL 地址
@property (nonatomic, strong) NSString *requestUrl;
//請求頭
@property (nonatomic, strong) NSArray *requestHeaders;
//響應頭
@property (nonatomic, strong) NSArray *responseHeaders;
//GET方法 的請求參數
@property (nonatomic, strong) NSString *getRequestParams;
//HTTP 方法, 比如 POST
@property (nonatomic, strong) NSString *httpMethod;
//協議名,如http1.0 / http1.1 / http2.0
@property (nonatomic, strong) NSString *httpProtocol;
//是否使用代理
@property (nonatomic, assign) BOOL useProxy;
//DNS解析後的 IP 地址
@property (nonatomic, strong) NSString *ip;
@end

// 監控信息模型
@interface  NetworkMonitorDataModel : NetworkMonitorBaseDataModel
//客戶端發起請求的時間
@property (nonatomic, assign) UInt64 requestDate;
//客戶端開始請求到開始dns解析的等待時間,單位ms 
@property (nonatomic, assign) int waitDNSTime;
//DNS 解析耗時
@property (nonatomic, assign) int dnsLookupTime;
//tcp 三次握手耗時,單位ms
@property (nonatomic, assign) int tcpTime;
//ssl 握手耗時
@property (nonatomic, assign) int sslTime;
//一個完整請求的耗時,單位ms
@property (nonatomic, assign) int requestTime;
//http 響應碼
@property (nonatomic, assign) NSUInteger httpCode;
//發送的字節數
@property (nonatomic, assign) UInt64 sendBytes;
//接收的字節數
@property (nonatomic, assign) UInt64 receiveBytes;


// 錯誤信息模型
@interface  NetworkMonitorErrorModel : NetworkMonitorBaseDataModel
//錯誤碼
@property (nonatomic, assign) NSInteger errorCode;
//錯誤次數
@property (nonatomic, assign) NSUInteger errCount;
//異常名
@property (nonatomic, strong) NSString *exceptionName;
//異常詳情
@property (nonatomic, strong) NSString *exceptionDetail;
//異常堆棧
@property (nonatomic, strong) NSString *stackTrace;
@end

  
// 繼承自 NSURLProtocol 抽象類,實現響應方法,代理網絡請求
@interface CustomURLProtocol () <NSURLSessionTaskDelegate>

@property (nonatomic, strong) NSURLSessionDataTask *dataTask;
@property (nonatomic, strong) NSOperationQueue *sessionDelegateQueue;
@property (nonatomic, strong) NetworkMonitorDataModel *dataModel;
@property (nonatomic, strong) NetworkMonitorErrorModel *errModel;

@end

//使用NSURLSessionDataTask請求網絡
- (void)startLoading {
    NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
  	NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration
                                                          delegate:self
                                                     delegateQueue:nil];
    NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
  	self.sessionDelegateQueue = [[NSOperationQueue alloc] init];
    self.sessionDelegateQueue.maxConcurrentOperationCount = 1;
    self.sessionDelegateQueue.name = @"com.networkMonitor.session.queue";
    self.dataTask = [session dataTaskWithRequest:self.request];
    [self.dataTask resume];
}

#pragma mark - NSURLSessionTaskDelegate
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
    if (error) {
        [self.client URLProtocol:self didFailWithError:error];
    } else {
        [self.client URLProtocolDidFinishLoading:self];
    }
    if (error) {
        NSURLRequest *request = task.currentRequest;
        if (request) {
            self.errModel.requestUrl  = request.URL.absoluteString;        
            self.errModel.httpMethod = request.HTTPMethod;
            self.errModel.requestParams = request.URL.query;
        }
        self.errModel.errorCode = error.code;
        self.errModel.exceptionName = error.domain;
        self.errModel.exceptionDetail = error.description;
      // 上傳 Network 數據到數據上報組件,數據上報會在 [打造功能強大、靈活可配置的數據上報組件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 講
    }
    self.dataTask = nil;
}


- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics {
       if (@available(iOS 10.0, *) && [metrics.transactionMetrics count] > 0) {
        [metrics.transactionMetrics enumerateObjectsUsingBlock:^(NSURLSessionTaskTransactionMetrics *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
            if (obj.resourceFetchType == NSURLSessionTaskMetricsResourceFetchTypeNetworkLoad) {
                if (obj.fetchStartDate) {
                    self.dataModel.requestDate = [obj.fetchStartDate timeIntervalSince1970] * 1000;
                }
                if (obj.domainLookupStartDate && obj.domainLookupEndDate) {
                    self.dataModel. waitDNSTime = ceil([obj.domainLookupStartDate timeIntervalSinceDate:obj.fetchStartDate] * 1000);
                    self.dataModel. dnsLookupTime = ceil([obj.domainLookupEndDate timeIntervalSinceDate:obj.domainLookupStartDate] * 1000);
                }
                if (obj.connectStartDate) {
                    if (obj.secureConnectionStartDate) {
                        self.dataModel. waitDNSTime = ceil([obj.secureConnectionStartDate timeIntervalSinceDate:obj.connectStartDate] * 1000);
                    } else if (obj.connectEndDate) {
                        self.dataModel.tcpTime = ceil([obj.connectEndDate timeIntervalSinceDate:obj.connectStartDate] * 1000);
                    }
                }
                if (obj.secureConnectionEndDate && obj.secureConnectionStartDate) {
                    self.dataModel.sslTime = ceil([obj.secureConnectionEndDate timeIntervalSinceDate:obj.secureConnectionStartDate] * 1000);
                }

                if (obj.fetchStartDate && obj.responseEndDate) {
                    self.dataModel.requestTime = ceil([obj.responseEndDate timeIntervalSinceDate:obj.fetchStartDate] * 1000);
                }

                self.dataModel.httpProtocol = obj.networkProtocolName;

                NSHTTPURLResponse *response = (NSHTTPURLResponse *)obj.response;
                if ([response isKindOfClass:NSHTTPURLResponse.class]) {
                    self.dataModel.receiveBytes = response.expectedContentLength;
                }

                if ([obj respondsToSelector:@selector(_remoteAddressAndPort)]) {
                    self.dataModel.ip = [obj valueForKey:@"_remoteAddressAndPort"];
                }

                if ([obj respondsToSelector:@selector(_requestHeaderBytesSent)]) {
                    self.dataModel.sendBytes = [[obj valueForKey:@"_requestHeaderBytesSent"] unsignedIntegerValue];
                }
                if ([obj respondsToSelector:@selector(_responseHeaderBytesReceived)]) {
                    self.dataModel.receiveBytes = [[obj valueForKey:@"_responseHeaderBytesReceived"] unsignedIntegerValue];
                }

               self.dataModel.requestUrl = [obj.request.URL absoluteString];
                self.dataModel.httpMethod = obj.request.HTTPMethod;
                self.dataModel.useProxy = obj.isProxyConnection;
            }
        }];
				// 上傳 Network 數據到數據上報組件,數據上報會在 [打造功能強大、靈活可配置的數據上報組件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 講
    }
}

2.2 方案二:NSURLProtocol 監控 App 網絡請求之黑魔法篇

文章上面 2.1 分析到了 NSURLSessionTaskMetrics 由於兼容性問題,對於網絡監控來說似乎不太完美,但是自後在搜資料的時候看到了一篇文章。文章在分析 WebView 的網絡監控的時候分析 Webkit 源碼的時候發現了下面代碼

#if !HAVE(TIMINGDATAOPTIONS)
void setCollectsTimingData()
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [NSURLConnection _setCollectsTimingData:YES];
        ...
    });
}
#endif

也就是說明 NSURLConnection 本身有一套 TimingData 的收集 API,只是沒有暴露給開發者,蘋果自己在用而已。在 runtime header 中找到了 NSURLConnection 的 _setCollectsTimingData:_timingData 2個 api(iOS8 以後可以使用)。

NSURLSession 在 iOS9 之前使用 _setCollectsTimingData: 就可以使用 TimingData 了。

注意:

  • 因爲是私有 API,所以在使用的時候注意混淆。比如 [[@"_setC" stringByAppendingString:@"ollectsT"] stringByAppendingString:@"imingData:"]
  • 不推薦私有 API,一般做 APM 的屬於公共團隊,你想想看雖然你做的 SDK 達到網絡監控的目的了,但是萬一給業務線的 App 上架造成了問題,那就得不償失了。一般這種投機取巧,不是百分百確定的事情可以在玩具階段使用。
@interface _NSURLConnectionProxy : DelegateProxy

@end

@implementation _NSURLConnectionProxy

- (BOOL)respondsToSelector:(SEL)aSelector
{
    if ([NSStringFromSelector(aSelector) isEqualToString:@"connectionDidFinishLoading:"]) {
        return YES;
    }
    return [self.target respondsToSelector:aSelector];
}

- (void)forwardInvocation:(NSInvocation *)invocation
{
    [super forwardInvocation:invocation];
    if ([NSStringFromSelector(invocation.selector) isEqualToString:@"connectionDidFinishLoading:"]) {
        __unsafe_unretained NSURLConnection *conn;
        [invocation getArgument:&conn atIndex:2];
        SEL selector = NSSelectorFromString([@"_timin" stringByAppendingString:@"gData"]);
        NSDictionary *timingData = [conn performSelector:selector];
        [[NTDataKeeper shareInstance] trackTimingData:timingData request:conn.currentRequest];
    }
}

@end

@implementation NSURLConnection(tracker)

+ (void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [self class];
        
        SEL originalSelector = @selector(initWithRequest:delegate:);
        SEL swizzledSelector = @selector(swizzledInitWithRequest:delegate:);
        
        Method originalMethod = class_getInstanceMethod(class, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
        method_exchangeImplementations(originalMethod, swizzledMethod);
        
        NSString *selectorName = [[@"_setC" stringByAppendingString:@"ollectsT"] stringByAppendingString:@"imingData:"];
        SEL selector = NSSelectorFromString(selectorName);
        [NSURLConnection performSelector:selector withObject:@(YES)];
    });
}

- (instancetype)swizzledInitWithRequest:(NSURLRequest *)request delegate:(id<NSURLConnectionDelegate>)delegate
{
    if (delegate) {
        _NSURLConnectionProxy *proxy = [[_NSURLConnectionProxy alloc] initWithTarget:delegate];
        objc_setAssociatedObject(delegate ,@"_NSURLConnectionProxy" ,proxy, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
        return [self swizzledInitWithRequest:request delegate:(id<NSURLConnectionDelegate>)proxy];
    }else{
        return [self swizzledInitWithRequest:request delegate:delegate];
    }
}

@end

2.3 方案三:Hook

iOS 中 hook 技術有2類,一種是 NSProxy,一種是 method swizzling(isa swizzling)

2.3.1 方法一

寫 SDK 肯定不可能手動侵入業務代碼(你沒那個權限提交到線上代碼 😂),所以不管是 APM 還是無痕埋點都是通過 Hook 的方式。

面向切面程序設計(Aspect-oriented Programming,AOP)是計算機科學中的一種程序設計範型,將橫切關注點與業務主體進一步分離,以提高程序代碼的模塊化程度。在不修改源代碼的情況下給程序動態增加功能。其核心思想是將業務邏輯(核心關注點,系統主要功能)與公共功能(橫切關注點,比如日誌系統)進行分離,降低複雜性,保持系統模塊化程度、可維護性、可重用性。常被用在日誌系統、性能統計、安全控制、事務處理、異常處理等場景下。

在 iOS 中 AOP 的實現是基於 Runtime 機制,目前由3種方式:Method Swizzling、NSProxy、FishHook(主要用用於 hook c 代碼)。

文章上面 2.1 討論了滿足大多數的需求的場景,NSURLProtocol 監控了 NSURLConnection、NSURLSession 的網絡請求,自身代理後可以發起網絡請求並得到諸如請求開始時間、請求結束時間、header 信息等,但是無法得到非常詳細的網絡性能數據,比如 DNS 開始解析時間、DNS 解析用了多久、reponse 開始返回的時間、返回了多久等。 iOS10 之後 NSURLSessionTaskDelegate 增加了一個代理方法 - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));,可以獲取到精確的各項網絡數據。但是具有兼容性。文章上面 2.2 討論了從 Webkit 源碼中得到的信息,通過私有方法 _setCollectsTimingData:_timingData 可以獲取到 TimingData。

但是如果需要監全部的網絡請求就不能滿足需求了,查閱資料後發現了阿里百川有 APM 的解決方案,於是有了方案3,對於網絡監控需要做如下的處理

network hook

可能對於 CFNetwork 比較陌生,可以看一下 CFNetwork 的層級和簡單用法

CFNetwork Structure

CFNetwork 的基礎是 CFSocket 和 CFStream。

CFSocket:Socket 是網絡通信的底層基礎,可以讓2個 socket 端口互發數據,iOS 中最常用的 socket 抽象是 BSD socket。而 CFSocket 是 BSD socket 的 OC 包裝,幾乎實現了所有的 BSD 功能,此外加入了 RunLoop。

CFStream:提供了與設備無關的讀寫數據方法,使用它可以爲內存、文件、網絡(使用 socket)的數據建立流,使用 stream 可以不必將所有數據寫入到內存中。CFStream 提供 API 對2種 CFType 對象提供抽象:CFReadStream、CFWriteStream。同時也是 CFHTTP、CFFTP 的基礎。

簡單 Demo

- (void)testCFNetwork
{
    CFURLRef urlRef = CFURLCreateWithString(kCFAllocatorDefault, CFSTR("https://httpbin.org/get"), NULL);
    CFHTTPMessageRef httpMessageRef = CFHTTPMessageCreateRequest(kCFAllocatorDefault, CFSTR("GET"), urlRef, kCFHTTPVersion1_1);
    CFRelease(urlRef);
    
    CFReadStreamRef readStream = CFReadStreamCreateForHTTPRequest(kCFAllocatorDefault, httpMessageRef);
    CFRelease(httpMessageRef);
    
    CFReadStreamScheduleWithRunLoop(readStream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes);
    
    CFOptionFlags eventFlags = (kCFStreamEventHasBytesAvailable | kCFStreamEventErrorOccurred | kCFStreamEventEndEncountered);
    CFStreamClientContext context = {
        0,
        NULL,
        NULL,
        NULL,
       NULL
    } ;
    // Assigns a client to a stream, which receives callbacks when certain events occur.
    CFReadStreamSetClient(readStream, eventFlags, CFNetworkRequestCallback, &context);
    // Opens a stream for reading.
    CFReadStreamOpen(readStream);
}
// callback
void CFNetworkRequestCallback (CFReadStreamRef _Null_unspecified stream, CFStreamEventType type, void * _Null_unspecified clientCallBackInfo) {
    CFMutableDataRef responseBytes = CFDataCreateMutable(kCFAllocatorDefault, 0);
    CFIndex numberOfBytesRead = 0;
    do {
        UInt8 buffer[2014];
        numberOfBytesRead = CFReadStreamRead(stream, buffer, sizeof(buffer));
        if (numberOfBytesRead > 0) {
            CFDataAppendBytes(responseBytes, buffer, numberOfBytesRead);
        }
    } while (numberOfBytesRead > 0);
    
    
    CFHTTPMessageRef response = (CFHTTPMessageRef)CFReadStreamCopyProperty(stream, kCFStreamPropertyHTTPResponseHeader);
    if (responseBytes) {
        if (response) {
            CFHTTPMessageSetBody(response, responseBytes);
        }
        CFRelease(responseBytes);
    }
    
    // close and cleanup
    CFReadStreamClose(stream);
    CFReadStreamUnscheduleFromRunLoop(stream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes);
    CFRelease(stream);
    
    // print response
    if (response) {
        CFDataRef reponseBodyData = CFHTTPMessageCopyBody(response);
        CFRelease(response);
        
        printResponseData(reponseBodyData);
        CFRelease(reponseBodyData);
    }
}

void printResponseData (CFDataRef responseData) {
    CFIndex dataLength = CFDataGetLength(responseData);
    UInt8 *bytes = (UInt8 *)malloc(dataLength);
    CFDataGetBytes(responseData, CFRangeMake(0, CFDataGetLength(responseData)), bytes);
    CFStringRef responseString = CFStringCreateWithBytes(kCFAllocatorDefault, bytes, dataLength, kCFStringEncodingUTF8, TRUE);
    CFShow(responseString);
    CFRelease(responseString);
    free(bytes);
}
// console
{
  "args": {}, 
  "headers": {
    "Host": "httpbin.org", 
    "User-Agent": "Test/1 CFNetwork/1125.2 Darwin/19.3.0", 
    "X-Amzn-Trace-Id": "Root=1-5e8980d0-581f3f44724c7140614c2564"
  }, 
  "origin": "183.159.122.102", 
  "url": "https://httpbin.org/get"
}

我們知道 NSURLSession、NSURLConnection、CFNetwork 的使用都需要調用一堆方法進行設置然後需要設置代理對象,實現代理方法。所以針對這種情況進行監控首先想到的是使用 runtime hook 掉方法層級。但是針對設置的代理對象的代理方法沒辦法 hook,因爲不知道代理對象是哪個類。所以想辦法可以 hook 設置代理對象這個步驟,將代理對象替換成我們設計好的某個類,然後讓這個類去實現 NSURLConnection、NSURLSession、CFNetwork 相關的代理方法。然後在這些方法的內部都去調用一下原代理對象的方法實現。所以我們的需求得以滿足,我們在相應的方法裏面可以拿到監控數據,比如請求開始時間、結束時間、狀態碼、內容大小等。

NSURLSession、NSURLConnection hook 如下。

NSURLSession Hook

NSURLConnection Hook

業界有 APM 針對 CFNetwork 的方案,整理描述下:

CFNetwork 是 c 語言實現的,要對 c 代碼進行 hook 需要使用 Dynamic Loader Hook 庫 - fishhook

Dynamic Loader(dyld)通過更新 Mach-O 文件中保存的指針的方法來綁定符號。借用它可以在 Runtime 修改 C 函數調用的函數指針。fishhook 的實現原理:遍歷 __DATA segment 裏面 __nl_symbol_ptr__la_symbol_ptr 兩個 section 裏面的符號,通過 Indirect Symbol Table、Symbol Table 和 String Table 的配合,找到自己要替換的函數,達到 hook 的目的。

/* Returns the number of bytes read, or -1 if an error occurs preventing any

bytes from being read, or 0 if the stream’s end was encountered.

It is an error to try and read from a stream that hasn’t been opened first.

This call will block until at least one byte is available; it will NOT block

until the entire buffer can be filled. To avoid blocking, either poll using

CFReadStreamHasBytesAvailable() or use the run loop and listen for the

kCFStreamEventHasBytesAvailable event for notification of data available. */

CF_EXPORT

CFIndex CFReadStreamRead(CFReadStreamRef _Null_unspecified stream, UInt8 * _Null_unspecified buffer, CFIndex bufferLength);

CFNetwork 使用 CFReadStreamRef 來傳遞數據,使用回調函數的形式來接受服務器的響應。當回調函數受到

具體步驟及其關鍵代碼如下,以 NSURLConnection 舉例

  • 因爲要 Hook 挺多地方,所以寫一個 method swizzling 的工具類

    #import <Foundation/Foundation.h>
    
    NS_ASSUME_NONNULL_BEGIN
    
    @interface NSObject (hook)
    
    /**
     hook對象方法
    
     @param originalSelector 需要hook的原始對象方法
     @param swizzledSelector 需要替換的對象方法
     */
    + (void)apm_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector;
    
    /**
     hook類方法
    
     @param originalSelector 需要hook的原始類方法
     @param swizzledSelector 需要替換的類方法
     */
    + (void)apm_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector;
    
    @end
    
    NS_ASSUME_NONNULL_END
      
    + (void)apm_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector
    {
        class_swizzleInstanceMethod(self, originalSelector, swizzledSelector);
    }
    
    + (void)apm_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector
    {
        //類方法實際上是儲存在類對象的類(即元類)中,即類方法相當於元類的實例方法,所以只需要把元類傳入,其他邏輯和交互實例方法一樣。
        Class class2 = object_getClass(self);
        class_swizzleInstanceMethod(class2, originalSelector, swizzledSelector);
    }
    
    void class_swizzleInstanceMethod(Class class, SEL originalSEL, SEL replacementSEL)
    {
        Method originMethod = class_getInstanceMethod(class, originalSEL);
        Method replaceMethod = class_getInstanceMethod(class, replacementSEL);
        
        if(class_addMethod(class, originalSEL, method_getImplementation(replaceMethod),method_getTypeEncoding(replaceMethod)))
        {
            class_replaceMethod(class,replacementSEL, method_getImplementation(originMethod), method_getTypeEncoding(originMethod));
        }else {
            method_exchangeImplementations(originMethod, replaceMethod);
        }
    }
    
  • 建立一個繼承自 NSProxy 抽象類的類,實現相應方法。

    #import <Foundation/Foundation.h>
    
    NS_ASSUME_NONNULL_BEGIN
    
    // 爲 NSURLConnection、NSURLSession、CFNetwork 代理設置代理轉發
    @interface NetworkDelegateProxy : NSProxy
    
    + (instancetype)setProxyForObject:(id)originalTarget withNewDelegate:(id)newDelegate;
    
    @end
    
    NS_ASSUME_NONNULL_END
      
    // .m
    @interface NetworkDelegateProxy () {
        id _originalTarget;
        id _NewDelegate;
    }
    
    @end
    
    
    @implementation NetworkDelegateProxy
    
    #pragma mark - life cycle
    
    + (instancetype)sharedInstance {
        static NetworkDelegateProxy *_sharedInstance = nil;
        
        static dispatch_once_t onceToken;
        
        dispatch_once(&onceToken, ^{
            _sharedInstance = [NetworkDelegateProxy alloc];
        });
        
        return _sharedInstance;
    }
    
    
    #pragma mark - public Method
    
    + (instancetype)setProxyForObject:(id)originalTarget withNewDelegate:(id)newDelegate
    {
        NetworkDelegateProxy *instance = [NetworkDelegateProxy sharedInstance];
        instance->_originalTarget = originalTarget;
        instance->_NewDelegate = newDelegate;
        return instance;
    }
    
    - (void)forwardInvocation:(NSInvocation *)invocation
    {
        if ([_originalTarget respondsToSelector:invocation.selector]) {
            [invocation invokeWithTarget:_originalTarget];
            [((NSURLSessionAndConnectionImplementor *)_NewDelegate) invoke:invocation];
        }
    }
    
    - (nullable NSMethodSignature *)methodSignatureForSelector:(SEL)sel
    {
        return [_originalTarget methodSignatureForSelector:sel];
    }
    
    @end
    
  • 創建一個對象,實現 NSURLConnection、NSURLSession、NSIuputStream 代理方法

    // NetworkImplementor.m
    
    #pragma mark-NSURLConnectionDelegate
    - (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
        NSLog(@"%s", __func__);
    }
    
    - (nullable NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(nullable NSURLResponse *)response {
        NSLog(@"%s", __func__);
        return request;
    }
    
    #pragma mark-NSURLConnectionDataDelegate
    - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
        NSLog(@"%s", __func__);
    }
    
    - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
       NSLog(@"%s", __func__);
    }
    
    - (void)connection:(NSURLConnection *)connection   didSendBodyData:(NSInteger)bytesWritten
     totalBytesWritten:(NSInteger)totalBytesWritten
    totalBytesExpectedToWrite:(NSInteger)totalBytesExpectedToWrite {
        NSLog(@"%s", __func__);
    }
    
    - (void)connectionDidFinishLoading:(NSURLConnection *)connection {
        NSLog(@"%s", __func__);
    }
    
    #pragma mark-NSURLConnectionDownloadDelegate
    - (void)connection:(NSURLConnection *)connection didWriteData:(long long)bytesWritten totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes {
        NSLog(@"%s", __func__);
    }
    
    - (void)connectionDidResumeDownloading:(NSURLConnection *)connection totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes {
        NSLog(@"%s", __func__);
    }
    
    - (void)connectionDidFinishDownloading:(NSURLConnection *)connection destinationURL:(NSURL *) destinationURL {
        NSLog(@"%s", __func__);
    }
    // 根據需求自己去寫需要監控的數據項
    
  • 給 NSURLConnection 添加 Category,專門設置 hook 代理對象、hook NSURLConnection 對象方法

    // NSURLConnection+Monitor.m
    @implementation NSURLConnection (Monitor)
    
    + (void)load
    {
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            @autoreleasepool {
                [[self class] apm_swizzleMethod:@selector(apm_initWithRequest:delegate:) swizzledSelector:@selector(initWithRequest: delegate:)];
            }
        });
    }
    
    - (_Nonnull instancetype)apm_initWithRequest:(NSURLRequest *)request delegate:(nullable id)delegate
    {
        /*
         1. 在設置 Delegate 的時候替換 delegate。
         2. 因爲要在每個代理方法裏面,監控數據,所以需要將代理方法都 hook 下
         3. 在原代理方法執行的時候,讓新的代理對象裏面,去執行方法的轉發,
         */
        NSString *traceId = @"traceId";
        NSMutableURLRequest *rq = [request mutableCopy];
        NSString *preTraceId = [request.allHTTPHeaderFields valueForKey:@"head_key_traceid"];
        if (preTraceId) {
            // 調用 hook 之前的初始化方法,返回 NSURLConnection
            return [self apm_initWithRequest:rq delegate:delegate];
        } else {
            [rq setValue:traceId forHTTPHeaderField:@"head_key_traceid"];
               
            NSURLSessionAndConnectionImplementor *mockDelegate = [NSURLSessionAndConnectionImplementor new];
            [self registerDelegateMethod:@"connection:didFailWithError:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"];
    
            [self registerDelegateMethod:@"connection:didReceiveResponse:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"];
            [self registerDelegateMethod:@"connection:didReceiveData:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"];
            [self registerDelegateMethod:@"connection:didFailWithError:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"];
    
            [self registerDelegateMethod:@"connectionDidFinishLoading:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@"];
            [self registerDelegateMethod:@"connection:willSendRequest:redirectResponse:" originalDelegate:delegate newDelegate:mockDelegate flag:"@@:@@"];
            delegate = [NetworkDelegateProxy setProxyForObject:delegate withNewDelegate:mockDelegate];
    
            // 調用 hook 之前的初始化方法,返回 NSURLConnection
            return [self apm_initWithRequest:rq delegate:delegate];
        }
    }
    
    - (void)registerDelegateMethod:(NSString *)methodName originalDelegate:(id<NSURLConnectionDelegate>)originalDelegate newDelegate:(NSURLSessionAndConnectionImplementor *)newDelegate flag:(const char *)flag
    {
        if ([originalDelegate respondsToSelector:NSSelectorFromString(methodName)]) {
            IMP originalMethodImp = class_getMethodImplementation([originalDelegate class], NSSelectorFromString(methodName));
            IMP newMethodImp = class_getMethodImplementation([newDelegate class], NSSelectorFromString(methodName));
            if (originalMethodImp != newMethodImp) {
                [newDelegate registerSelector: methodName];
                NSLog(@"");
            }
        } else {
            class_addMethod([originalDelegate class], NSSelectorFromString(methodName), class_getMethodImplementation([newDelegate class], NSSelectorFromString(methodName)), flag);
        }
    }
    
    @end
    

這樣下來就是可以監控到網絡信息了,然後將數據交給數據上報 SDK,按照下發的數據上報策略去上報數據。

2.3.2 方法二

其實,針對上述的需求還有另一種方法一樣可以達到目的,那就是 isa swizzling

順道說一句,上面針對 NSURLConnection、NSURLSession、NSInputStream 代理對象的 hook 之後,利用 NSProxy 實現代理對象方法的轉發,有另一種方法可以實現,那就是 isa swizzling

  • Method swizzling 原理

    struct old_method {
        SEL method_name;
        char *method_types;
        IMP method_imp;
    };
    

method swizzling

method swizzling 改進版如下

Method originalMethod = class_getInstanceMethod(aClass, aSEL);
IMP originalIMP = method_getImplementation(originalMethod);
char *cd = method_getTypeEncoding(originalMethod);
IMP newIMP = imp_implementationWithBlock(^(id self) {
  void (*tmp)(id self, SEL _cmd) = originalIMP;
  tmp(self, aSEL);
});
class_replaceMethod(aClass, aSEL, newIMP, cd);
  • isa swizzling

    /// Represents an instance of a class.
    struct objc_object {
        Class _Nonnull isa  OBJC_ISA_AVAILABILITY;
    };
    
    /// A pointer to an instance of a class.
    typedef struct objc_object *id;
    
    

isa swizzling

我們來分析一下爲什麼修改 isa 可以實現目的呢?

  1. 寫 APM 監控的人沒辦法確定業務代碼
  2. 不可能爲了方便監控 APM,寫某些類,讓業務線開發者別使用系統 NSURLSession、NSURLConnection 類

想想 KVO 的實現原理?結合上面的圖

  • 創建監控對象子類
  • 重寫子類中屬性的 getter、seeter
  • 將監控對象的 isa 指針指向新創建的子類
  • 在子類的 getter、setter 中攔截值的變化,通知監控對象值的變化
  • 監控完之後將監控對象的 isa 還原回去

按照這個思路,我們也可以對 NSURLConnection、NSURLSession 的 load 方法中動態創建子類,在子類中重寫方法,比如 - (**nullable** **instancetype**)initWithRequest:(NSURLRequest *)request delegate:(**nullable** **id**)delegate startImmediately:(**BOOL**)startImmediately; ,然後將 NSURLSession、NSURLConnection 的 isa 指向動態創建的子類。在這些方法處理完之後還原本身的 isa 指針。

不過 isa swizzling 針對的還是 method swizzling,代理對象不確定,還是需要 NSProxy 進行動態處理。

至於如何修改 isa,我寫一個簡單的 Demo 來模擬 KVO

- (void)lbpKVO_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context {
    //生成自定義的名稱
    NSString *className = NSStringFromClass(self.class);
    NSString *currentClassName = [@"LBPKVONotifying_" stringByAppendingString:className];
    //1. runtime 生成類
    Class myclass = objc_allocateClassPair(self.class, [currentClassName UTF8String], 0);
    // 生成後不能馬上使用,必須先註冊
    objc_registerClassPair(myclass);
    
    //2. 重寫 setter 方法
    class_addMethod(myclass,@selector(say) , (IMP)say, "v@:@");
    
//    class_addMethod(myclass,@selector(setName:) , (IMP)setName, "v@:@");
    //3. 修改 isa
    object_setClass(self, myclass);
    
    //4. 將觀察者保存到當前對象裏面
    objc_setAssociatedObject(self, "observer", observer, OBJC_ASSOCIATION_ASSIGN);
    
    //5. 將傳遞的上下文綁定到當前對象裏面
    objc_setAssociatedObject(self, "context", (__bridge id _Nullable)(context), OBJC_ASSOCIATION_RETAIN);
}


void say(id self, SEL _cmd)
{
   // 調用父類方法一
    struct objc_super superclass = {self, [self superclass]};
    ((void(*)(struct objc_super *,SEL))objc_msgSendSuper)(&superclass,@selector(say));
    NSLog(@"%s", __func__);
// 調用父類方法二
//    Class class = [self class];
//    object_setClass(self, class_getSuperclass(class));
//    objc_msgSend(self, @selector(say));
}

void setName (id self, SEL _cmd, NSString *name) {
    NSLog(@"come here");
    //先切換到當前類的父類,然後發送消息 setName,然後切換當前子類
    //1. 切換到父類
    Class class = [self class];
    object_setClass(self, class_getSuperclass(class));
    //2. 調用父類的 setName 方法
    objc_msgSend(self, @selector(setName:), name);
    
    //3. 調用觀察
    id observer = objc_getAssociatedObject(self, "observer");
    id context = objc_getAssociatedObject(self, "context");
    if (observer) {
        objc_msgSend(observer, @selector(observeValueForKeyPath:ofObject:change:context:), @"name", self, @{@"new": name, @"kind": @1 } , context);
    }
    //4. 改回子類
    object_setClass(self, class);
}

@end

2.4 方案四:監控 App 常見網絡請求

本着成本的原因,由於現在大多數的項目的網絡能力都是通過 AFNetworking 完成的,所以本文的網絡監控可以快速完成。

AFNetworking 在發起網絡的時候會有相應的通知。AFNetworkingTaskDidResumeNotificationAFNetworkingTaskDidCompleteNotification。通過監聽通知攜帶的參數獲取網絡情況信息。

 self.didResumeObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AFNetworkingTaskDidResumeNotification object:nil queue:self.queue usingBlock:^(NSNotification * _Nonnull note) {
    // 開始
    __strong __typeof(weakSelf)strongSelf = weakSelf;
    NSURLSessionTask *task = note.object;
    NSString *requestId = [[NSUUID UUID] UUIDString];
    task.apm_requestId = requestId;
    [strongSelf.networkRecoder recordStartRequestWithRequestID:requestId task:task];
}];

self.didCompleteObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AFNetworkingTaskDidCompleteNotification object:nil queue:self.queue usingBlock:^(NSNotification * _Nonnull note) {
    
    __strong __typeof(weakSelf)strongSelf = weakSelf;
    
    NSError *error = note.userInfo[AFNetworkingTaskDidCompleteErrorKey];
    NSURLSessionTask *task = note.object;
    if (!error) {
        // 成功
        [strongSelf.networkRecoder recordFinishRequestWithRequestID:task.cmn_requestId task:task];
    } else {
        // 失敗
        [strongSelf.networkRecoder recordResponseErrorWithRequestID:task.cmn_requestId task:task error:error];
    }
}];

在 networkRecoder 的方法裏面去組裝數據,交給數據上報組件,等到合適的時機策略去上報。

因爲網絡是一個異步的過程,所以當網絡請求開始的時候需要爲每個網絡設置唯一標識,等到網絡請求完成後再根據每個請求的標識,判斷該網絡耗時多久、是否成功等。所以措施是爲 NSURLSessionTask 添加分類,通過 runtime 增加一個屬性,也就是唯一標識。

這裏插一嘴,爲 Category 命名、以及內部的屬性和方法命名的時候需要注意下。假如不注意會怎麼樣呢?假如你要爲 NSString 類增加身份證號碼中間位數隱藏的功能,那麼寫代碼久了的老司機 A,爲 NSString 增加了一個方法名,叫做 getMaskedIdCardNumber,但是他的需求是從 [9, 12] 這4位字符串隱藏掉。過了幾天同事 B 也遇到了類似的需求,他也是一位老司機,爲 NSString 增加了一個也叫 getMaskedIdCardNumber 的方法,但是他的需求是從 [8, 11] 這4位字符串隱藏,但是他引入工程後發現輸出並不符合預期,爲該方法寫的單測沒通過,他以爲自己寫錯了截取方法,檢查了幾遍才發現工程引入了另一個 NSString 分類,裏面的方法同名 😂 真坑。

下面的例子是 SDK,但是日常開發也是一樣。

  • Category 類名:建議按照當前 SDK 名稱的簡寫作爲前綴,再加下劃線,再加當前分類的功能,也就是類名+SDK名稱簡寫_功能名稱。比如當前 SDK 叫 JuhuaSuanAPM,那麼該 NSURLSessionTask Category 名稱就叫做 NSURLSessionTask+JuHuaSuanAPM_NetworkMonitor.h
  • Category 屬性名:建議按照當前 SDK 名稱的簡寫作爲前綴,再加下劃線,再加屬性名,也就是SDK名稱簡寫_屬性名稱。比如 JuhuaSuanAPM_requestId`
  • Category 方法名:建議按照當前 SDK 名稱的簡寫作爲前綴,再加下劃線,再加方法名,也就是SDK名稱簡寫_方法名稱。比如 -(BOOL)JuhuaSuanAPM__isGzippedData

例子如下:

#import <Foundation/Foundation.h>

@interface NSURLSessionTask (JuhuaSuanAPM_NetworkMonitor)

@property (nonatomic, copy) NSString* JuhuaSuanAPM_requestId;

@end

#import "NSURLSessionTask+JuHuaSuanAPM_NetworkMonitor.h"
#import <objc/runtime.h>

@implementation NSURLSessionTask (JuHuaSuanAPM_NetworkMonitor)

- (NSString*)JuhuaSuanAPM_requestId
{
    return objc_getAssociatedObject(self, _cmd);
}

- (void)setJuhuaSuanAPM_requestId:(NSString*)requestId
{
    objc_setAssociatedObject(self, @selector(JuhuaSuanAPM_requestId), requestId, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
@end

2.5 iOS 流量監控

2.5.1 HTTP 請求、響應數據結構

HTTP 請求報文結構
請求報文結構

響應報文的結構

響應報文結構

  1. HTTP 報文是格式化的數據塊,每條報文由三部分組成:對報文進行描述的起始行、包含屬性的首部塊、以及可選的包含數據的主體部分。
  2. 起始行和手部就是由行分隔符的 ASCII 文本,每行都以一個由2個字符組成的行終止序列作爲結束(包括一個回車符、一個換行符)
  3. 實體的主體或者報文的主體是一個可選的數據塊。與起始行和首部不同的是,主體中可以包含文本或者二進制數據,也可以爲空。
  4. HTTP 首部(也就是 Headers)總是應該以一個空行結束,即使沒有實體部分。瀏覽器發送了一個空白行來通知服務器,它已經結束了該頭信息的發送。

請求報文的格式

<method> <request-URI> <version>
<headers>

<entity-body>

響應報文的格式

<version> <status> <reason-phrase>
<headers>

<entity-body>

下圖是打開 Chrome 查看極課時間網頁的請求信息。包括響應行、響應頭、響應體等信息。

請求數據結構

下圖是在終端使用 curl 查看一個完整的請求和響應數據

curl查看HTTP響應

我們都知道在 HTTP 通信中,響應數據會使用 gzip 或其他壓縮方式壓縮,用 NSURLProtocol 等方案監聽,用 NSData 類型去計算分析流量等會造成數據的不精確,因爲正常一個 HTTP 響應體的內容是使用 gzip 或其他壓縮方式壓縮的,所以使用 NSData 會偏大。

2.5.2 問題
  1. Request 和 Response 不一定成對存在

    比如網絡斷開、App 突然 Crash 等,所以 Request 和 Response 監控後不應該記錄在一條記錄裏

  2. 請求流量計算方式不精確

    主要原因有:

    • 監控技術方案忽略了請求頭和請求行部分的數據大小
    • 監控技術方案忽略了 Cookie 部分的數據大小
    • 監控技術方案在對請求體大小計算的時候直接使用 HTTPBody.length,導致不夠精確
  3. 響應流量計算方式不精確

    主要原因有:

    • 監控技術方案忽略了響應頭和響應行部分的數據大小
    • 監控技術方案在對 body 部分的字節大小計算,因採用 exceptedContentLength 導致不夠準確
    • 監控技術方案忽略了響應體使用 gzip 壓縮。真正的網絡通信過程中,客戶端在發起請求的請求頭中 Accept-Encoding 字段代表客戶端支持的數據壓縮方式(表明客戶端可以正常使用數據時支持的壓縮方法),同樣服務端根據客戶端想要的壓縮方式、服務端當前支持的壓縮方式,最後處理數據,在響應頭中Content-Encoding 字段表示當前服務器採用了什麼壓縮方式。
2.5.3 技術實現

第五部分講了網絡攔截的各種原理和技術方案,這裏拿 NSURLProtocol 來說實現流量監控(Hook 的方式)。從上述知道了我們需要什麼樣的,那麼就逐步實現吧。

2.5.3.1 Request 部分
  1. 先利用網絡監控方案將 NSURLProtocol 管理 App 的各種網絡請求

  2. 在各個方法內部記錄各項所需參數(NSURLProtocol 不能分析請求握手、揮手等數據大小和時間消耗,不過對於正常情況的接口流量分析足夠了,最底層需要 Socket 層)

    @property(nonatomic, strong) NSURLConnection *internalConnection;
    @property(nonatomic, strong) NSURLResponse *internalResponse;
    @property(nonatomic, strong) NSMutableData *responseData;
    @property (nonatomic, strong) NSURLRequest *internalRequest;
    
    - (void)startLoading
    {
        NSMutableURLRequest *mutableRequest = [[self request] mutableCopy];
        self.internalConnection = [[NSURLConnection alloc] initWithRequest:mutableRequest delegate:self];
        self.internalRequest = self.request;
    }
    
    - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response
    {
        [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        self.internalResponse = response;
    }
    
    - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data 
    {
        [self.responseData appendData:data];
        [self.client URLProtocol:self didLoadData:data];
    }
    
  3. Status Line 部分

NSURLResponse 沒有 Status Line 等屬性或者接口,HTTP Version 信息也沒有,所以要想獲取 Status Line 想辦法轉換到 CFNetwork 層試試看。發現有私有 API 可以實現。

思路:將 NSURLResponse 通過 _CFURLResponse 轉換爲 CFTypeRef,然後再將 CFTypeRef 轉換爲 CFHTTPMessageRef,再通過 CFHTTPMessageCopyResponseStatusLine 獲取 CFHTTPMessageRef 的 Status Line 信息。

將讀取 Status Line 的功能添加一個 NSURLResponse 的分類。

// NSURLResponse+cm_FetchStatusLineFromCFNetwork.h
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface NSURLResponse (cm_FetchStatusLineFromCFNetwork)

- (NSString *)cm_fetchStatusLineFromCFNetwork;

@end

NS_ASSUME_NONNULL_END

// NSURLResponse+cm_FetchStatusLineFromCFNetwork.m
#import "NSURLResponse+cm_FetchStatusLineFromCFNetwork.h"
#import <dlfcn.h>


#define SuppressPerformSelectorLeakWarning(Stuff) \
do { \
    _Pragma("clang diagnostic push") \
    _Pragma("clang diagnostic ignored \"-Warc-performSelector-leaks\"") \
    Stuff; \
    _Pragma("clang diagnostic pop") \
} while (0)

typedef CFHTTPMessageRef (*CMURLResponseFetchHTTPResponse)(CFURLRef response);

@implementation NSURLResponse (cm_FetchStatusLineFromCFNetwork)

- (NSString *)cm_fetchStatusLineFromCFNetwork
{
    NSString *statusLine = @"";
    NSString *funcName = @"CFURLResponseGetHTTPResponse";
    CMURLResponseFetchHTTPResponse originalURLResponseFetchHTTPResponse = dlsym(RTLD_DEFAULT, [funcName UTF8String]);
    
    SEL getSelector = NSSelectorFromString(@"_CFURLResponse");
    if ([self respondsToSelector:getSelector] && NULL != originalURLResponseFetchHTTPResponse) {
        CFTypeRef cfResponse;
        SuppressPerformSelectorLeakWarning(
            cfResponse = CFBridgingRetain([self performSelector:getSelector]);
        );
        if (NULL != cfResponse) {
            CFHTTPMessageRef messageRef = originalURLResponseFetchHTTPResponse(cfResponse);
            statusLine = (__bridge_transfer NSString *)CFHTTPMessageCopyResponseStatusLine(messageRef);
            CFRelease(cfResponse);
        }
    }
    return statusLine;
}

@end
  1. 將獲取到的 Status Line 轉換爲 NSData,再計算大小

    - (NSUInteger)cm_getLineLength {
        NSString *statusLineString = @"";
        if ([self isKindOfClass:[NSHTTPURLResponse class]]) {
            NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self;
            statusLineString = [self cm_fetchStatusLineFromCFNetwork];
        }
        NSData *lineData = [statusLineString dataUsingEncoding:NSUTF8StringEncoding];
        return lineData.length;
    }
    
  2. Header 部分

    allHeaderFields 獲取到 NSDictionary,然後按照 key: value 拼接成字符串,然後轉換成 NSData 計算大小

    注意:key: value key 後是有空格的,curl 或者 chrome Network 面板可以查看印證下。

    - (NSUInteger)cm_getHeadersLength
    {
        NSUInteger headersLength = 0;
        if ([self isKindOfClass:[NSHTTPURLResponse class]]) {
            NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self;
            NSDictionary *headerFields = httpResponse.allHeaderFields;
            NSString *headerString = @"";
            for (NSString *key in headerFields.allKeys) {
                headerString = [headerStr stringByAppendingString:key];
                headheaderStringerStr = [headerString stringByAppendingString:@": "];
                if ([headerFields objectForKey:key]) {
                    headerString = [headerString stringByAppendingString:headerFields[key]];
                }
                headerString = [headerString stringByAppendingString:@"\n"];
            }
            NSData *headerData = [headerString dataUsingEncoding:NSUTF8StringEncoding];
            headersLength = headerData.length;
        }
        return headersLength;
    }
    
  3. Body 部分

    Body 大小的計算不能直接使用 excepectedContentLength,官方文檔說明了其不準確性,只可以作爲參考。或者 allHeaderFields 中的 Content-Length 值也是不夠準確的。

    /*!

    @abstract Returns the expected content length of the receiver.

    @discussion Some protocol implementations report a content length

    as part of delivering load metadata, but not all protocols

    guarantee the amount of data that will be delivered in actuality.

    Hence, this method returns an expected amount. Clients should use

    this value as an advisory, and should be prepared to deal with

    either more or less data.

    @result The expected content length of the receiver, or -1 if

    there is no expectation that can be arrived at regarding expected

    content length.

    */

    @property (readonly) long long expectedContentLength;

    • HTTP 1.1 版本規定,如果存在 Transfer-Encoding: chunked,則在 header 中不能有 Content-Length,有也會被忽視。
    • 在 HTTP 1.0及之前版本中,content-length 字段可有可無
    • 在 HTTP 1.1及之後版本。如果是 keep alive,則 Content-Lengthchunked 必然是二選一。若是非keep alive,則和 HTTP 1.0一樣。Content-Length 可有可無。

    什麼是 Transfer-Encoding: chunked

    數據以一系列分塊的形式進行發送 Content-Length 首部在這種情況下不被髮送. 在每一個分塊的開頭需要添加當前分塊的長度, 以十六進制的形式表示,後面緊跟着 \r\n , 之後是分塊本身, 後面也是 \r\n ,終止塊是一個常規的分塊, 不同之處在於其長度爲0.

    我們之前拿 NSMutableData 記錄了數據,所以我們可以在 stopLoading方法中計算出 Body 大小。步驟如下:

    • didReceiveData 中不斷添加 data

      - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data
      {
          [self.responseData appendData:data];
          [self.client URLProtocol:self didLoadData:data];
      }
      
    • stopLoading 方法中拿到 allHeaderFields 字典,獲取 Content-Encoding key 的值,如果是 gzip,則在 stopLoading 中將 NSData 處理爲 gzip 壓縮後的數據,再計算大小。(gzip 相關功能可以使用這個工具

      需要額外計算一個空白行的長度

      - (void)stopLoadi
      {
          [self.internalConnection cancel];
      
          PCTNetworkTrafficModel *model = [[PCTNetworkTrafficModel alloc] init];
          model.path = self.request.URL.path;
          model.host = self.request.URL.host;
          model.type = DMNetworkTrafficDataTypeResponse;
          model.lineLength = [self.internalResponse cm_getStatusLineLength];
          model.headerLength = [self.internalResponse cm_getHeadersLength];
          model.emptyLineLength = [self.internalResponse cm_getEmptyLineLength];
          if ([self.dm_response isKindOfClass:[NSHTTPURLResponse class]]) {
              NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self.dm_response;
              NSData *data = self.dm_data;
              if ([[httpResponse.allHeaderFields objectForKey:@"Content-Encoding"] isEqualToString:@"gzip"]) {
                  data = [self.dm_data gzippedData];
              }
              model.bodyLength = data.length;
          }
          model.length = model.lineLength + model.headerLength + model.bodyLength + model.emptyLineLength;
          NSDictionary *networkTrafficDictionary = [model convertToDictionary];
          [[PrismClient sharedInstance] sendWithType:CMMonitorNetworkTrafficType meta:networkTrafficDictionary payload:nil];
      }
      
2.5.3.2 Resquest 部分
  1. 先利用網絡監控方案將 NSURLProtocol 管理 App 的各種網絡請求

  2. 在各個方法內部記錄各項所需參數(NSURLProtocol 不能分析請求握手、揮手等數據大小和時間消耗,不過對於正常情況的接口流量分析足夠了,最底層需要 Socket 層)

    @property(nonatomic, strong) NSURLConnection *internalConnection;
    @property(nonatomic, strong) NSURLResponse *internalResponse;
    @property(nonatomic, strong) NSMutableData *responseData;
    @property (nonatomic, strong) NSURLRequest *internalRequest;
    
    - (void)startLoading
    {
        NSMutableURLRequest *mutableRequest = [[self request] mutableCopy];
        self.internalConnection = [[NSURLConnection alloc] initWithRequest:mutableRequest delegate:self];
        self.internalRequest = self.request;
    }
    
    - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response
    {
        [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        self.internalResponse = response;
    }
    
    - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data 
    {
        [self.responseData appendData:data];
        [self.client URLProtocol:self didLoadData:data];
    }
    
  3. Status Line 部分

    對於 NSURLRequest 沒有像 NSURLResponse 一樣的方法找到 StatusLine。所以兜底方案是自己根據 Status Line 的結構,自己手動構造一個。結構爲:協議版本號+空格+狀態碼+空格+狀態文本+換行

    爲 NSURLRequest 添加一個專門獲取 Status Line 的分類。

    // NSURLResquest+cm_FetchStatusLineFromCFNetwork.m
    - (NSUInteger)cm_fetchStatusLineLength
    {
      NSString *statusLineString = [NSString stringWithFormat:@"%@ %@ %@\n", self.HTTPMethod, self.URL.path, @"HTTP/1.1"];
      NSData *statusLineData = [statusLineString dataUsingEncoding:NSUTF8StringEncoding];
      return statusLineData.length;
    }
    
  4. Header 部分

    一個 HTTP 請求會先構建判斷是否存在緩存,然後進行 DNS 域名解析以獲取請求域名的服務器 IP 地址。如果請求協議是 HTTPS,那麼還需要建立 TLS 連接。接下來就是利用 IP 地址和服務器建立 TCP 連接。連接建立之後,瀏覽器端會構建請求行、請求頭等信息,並把和該域名相關的 Cookie 等數據附加到請求頭中,然後向服務器發送構建的請求信息。

    所以一個網絡監控不考慮 cookie 😂,借用王多魚的一句話「那不完犢子了嗎」。

    看過一些文章說 NSURLRequest 不能完整獲取到請求頭信息。其實問題不大, 幾個信息獲取不完全也沒辦法。衡量監控方案本身就是看接口在不同版本或者某些情況下數據消耗是否異常,WebView 資源請求是否過大,類似於控制變量法的思想。

    所以獲取到 NSURLRequest 的 allHeaderFields 後,加上 cookie 信息,計算完整的 Header 大小

    // NSURLResquest+cm_FetchHeaderWithCookies.m
    - (NSUInteger)cm_fetchHeaderLengthWithCookie
    {
        NSDictionary *headerFields = self.allHTTPHeaderFields;
        NSDictionary *cookiesHeader = [self cm_fetchCookies];
    
        if (cookiesHeader.count) {
            NSMutableDictionary *headerDictionaryWithCookies = [NSMutableDictionary dictionaryWithDictionary:headerFields];
            [headerDictionaryWithCookies addEntriesFromDictionary:cookiesHeader];
            headerFields = [headerDictionaryWithCookies copy];
        }
        
        NSString *headerString = @"";
    
        for (NSString *key in headerFields.allKeys) {
            headerString = [headerString stringByAppendingString:key];
            headerString = [headerString stringByAppendingString:@": "];
            if ([headerFields objectForKey:key]) {
                headerString = [headerString stringByAppendingString:headerFields[key]];
            }
            headerString = [headerString stringByAppendingString:@"\n"];
        }
        NSData *headerData = [headerString dataUsingEncoding:NSUTF8StringEncoding];
        headersLength = headerData.length;
        return headerString;
    }
    
    - (NSDictionary *)cm_fetchCookies
    {
        NSDictionary *cookiesHeaderDictionary;
        NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
        NSArray<NSHTTPCookie *> *cookies = [cookieStorage cookiesForURL:self.URL];
        if (cookies.count) {
            cookiesHeaderDictionary = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies];
        }
        return cookiesHeaderDictionary;
    }
    
  5. Body 部分

    NSURLConnection 的 HTTPBody 有可能獲取不到,問題類似於 WebView 上 ajax 等情況。所以可以通過 HTTPBodyStream 讀取 stream 來計算 body 大小.

    - (NSUInteger)cm_fetchRequestBody
    {
        NSDictionary *headerFields = self.allHTTPHeaderFields;
        NSUInteger bodyLength = [self.HTTPBody length];
    
        if ([headerFields objectForKey:@"Content-Encoding"]) {
            NSData *bodyData;
            if (self.HTTPBody == nil) {
                uint8_t d[1024] = {0};
                NSInputStream *stream = self.HTTPBodyStream;
                NSMutableData *data = [[NSMutableData alloc] init];
                [stream open];
                while ([stream hasBytesAvailable]) {
                    NSInteger len = [stream read:d maxLength:1024];
                    if (len > 0 && stream.streamError == nil) {
                        [data appendBytes:(void *)d length:len];
                    }
                }
                bodyData = [data copy];
                [stream close];
            } else {
                bodyData = self.HTTPBody;
            }
            bodyLength = [[bodyData gzippedData] length];
        }
        return bodyLength;
    }
    
  6. - (NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response 方法中將數據上報會在 打造功能強大、靈活可配置的數據上報組件

    -(NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response
    {
        if (response != nil) {
            self.internalResponse = response;
            [self.client URLProtocol:self wasRedirectedToRequest:request redirectResponse:response];
        }
    
        PCTNetworkTrafficModel *model = [[PCTNetworkTrafficModel alloc] init];
        model.path = request.URL.path;
        model.host = request.URL.host;
        model.type = DMNetworkTrafficDataTypeRequest;
        model.lineLength = [connection.currentRequest dgm_getLineLength];
        model.headerLength = [connection.currentRequest dgm_getHeadersLengthWithCookie];
        model.bodyLength = [connection.currentRequest dgm_getBodyLength];
        model.emptyLineLength = [self.internalResponse cm_getEmptyLineLength];
        model.length = model.lineLength + model.headerLength + model.bodyLength + model.emptyLineLength;
        
        NSDictionary *networkTrafficDictionary = [model convertToDictionary];
        [[PrismClient sharedInstance] sendWithType:CMMonitorNetworkTrafficType meta:networkTrafficDictionary payload:nil];
        return request;
    }
    

六、 電量消耗

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

一般來說遇到耗電量較大,我們立馬會想到是不是使用了定位、是不是使用了頻繁網絡請求、是不是不斷循環做某件事情?

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

1. 如何獲取電量

在 iOS 中,IOKit 是一個私有框架,用來獲取硬件和設備的詳細信息,也是硬件和內核服務通信的底層框架。所以我們可以通過 IOKit來獲取硬件信息,從而獲取到電量信息。步驟如下:

  • 首先在蘋果開放源代碼 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 來解決了。耗電地方可能是二方庫、三方庫,也可能是某個同事的代碼。

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

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

- (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 模式下,系統針對大量數據的計算,做了電量優化

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

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; 方法用來設置緩存條件。所以我們寫磁盤、內存的文件操作時可以借鑑該策略,以優化耗電量。

七、 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 文件。

流程圖如下:

KSCrash流程圖

對於 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 文件。

系統架構圖如下

符號化服務架構圖

八、 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。

參考資料


文章較長,拆分爲。Github 上完整文章閱讀體驗更佳,請點擊訪問 Github

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