1. 程式人生 > >深入剖析 iOS 效能優化

深入剖析 iOS 效能優化

問題種類

時間複雜度

在集合裡資料量小的情況下時間複雜度對於效能的影響看起來微乎其微。但如果某個開發的功能是一個公共功能,無法預料呼叫者傳入資料的量時,這個複雜度的優化顯得非常重要了。

這裡寫圖片描述

上圖列出了各種情況的時間複雜度,比如高效的排序演算法一般都是 O(n log n)。接下來看看下圖:

這裡寫圖片描述

圖中可以看出 O(n) 是個分水嶺,大於它對於效能就具有很大的潛在影響,如果是個公共的介面一定要加上說明,自己呼叫也要做到心中有數。當然最好是通過演算法優化或者使用合適的系統介面方法,權衡記憶體消耗爭取通過空間來換取時間。
下面通過集合裡是否有某個值來舉個例子:

//O(1)
return array[idx] == value;
//O(n)
for (int i = 0; i < count; i++) {
if (array[i] == value) {
return YES;
}
}
return NO;
//O(n2) 找重複的值
for (int i = 0; i < count; i++) {
for (int j = 0; j < count; j++) {
if ( i != j && array[i] == array[j]) {
return YES;
}
}
}
return NO;

那麼 OC 裡幾種常用集合物件提供的介面方法時間複雜度是怎麼樣的。

NSArray / NSMutableArray

首先我們發現他們是有排序,並允許重複元素存在的,那麼這麼設計就表明了集合儲存沒法使用裡面的元素做 hash table 的 key 進行相關的快速操作,。所以不同功能介面方法效能是會有很大的差異。
containsObject:,containsObject:,indexOfObject*,removeObject: 會遍歷裡面元素檢視是否與之匹對,所以複雜度等於或大於 O(n)
objectAtIndex:,firstObject:,lastObject:,addObject:,removeLastObject: 這些只針對棧頂棧底操作的時間複雜度都是 O(1)
indexOfObject:inSortedRange:options:usingComparator: 使用的是二分查詢,時間複雜度是 O(log n)

NSSet / NSMutableSet / NSCountedSet

這些集合型別是無序沒有重複元素。這樣就可以通過 hash table 進行快速的操作。比如 addObject:, removeObject:, containsObject: 都是按照 O(1) 來的。需要注意的是將陣列轉成 Set 時會將重複元素合成一個,同時失去排序。

NSDictionary / NSMutableDictionary

和 Set 差不多,多了鍵值對應。新增刪除和查詢都是 O(1) 的。需要注意的是 Keys 必須是符合 NSCopying。

用 GCD 來做優化

我們可以通過 GCD 提供的方法來將一些需要耗時操作放到非主執行緒上做,使得 App 能夠執行的更加流暢響應更快。但是使用 GCD 時需要注意避免可能引起執行緒爆炸和死鎖的情況,還有非主執行緒處理任務也不是萬能的,如果一個處理需要消耗大量記憶體或者大量CPU操作 GCD 也沒法幫你,只能通過將處理進行拆解分步驟分時間進行處理才比較妥當。

非同步處理事件

這裡寫圖片描述

上圖是最典型的非同步處理事件的方法

需要耗時長的任務

這裡寫圖片描述

將 GCD 的 block 通過 dispatch_block_create_with_qos_class 方法指定佇列的 QoS 為 QOS_CLASS_UTILITY。這種 QoS 系統會針對大的計算,I/O,網路以及複雜資料處理做電量優化。

避免執行緒爆炸
使用序列佇列
使用 NSOperationQueues 的併發限制方法 NSOperationQueue.maxConcurrentOperationCount
舉個例子,下面的寫法就比較危險,可能會造成執行緒爆炸和死鎖

for (int i = 0; i < 999; i++) {
dispatch_async(q, ^{…});
}
dispatch_barrier_sync(q, ^{});

這裡寫圖片描述

那麼怎麼能夠避免呢?首先可以使用 dispatch_apply

dispatch_apply(999, q, ^(size_t i){…});

或者使用 dispatch_semaphore

define CONCURRENT_TASKS 4sema = dispatch_semaphore_create(CONCURRENT_TASKS);for (int i = 0; i < 999; i++){ dispatch_async(q, ^{

    dispatch_semaphore_signal(sema);
});
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);

}

GCD 相關 Crash 日誌

管理執行緒問題

Thread 1:: Dispatch queue: com.apple.libdispatch-manager
0 libsystem_kernel.dylib 0x00007fff8967e08a kevent_qos + 10
1 libdispatch.dylib 0x00007fff8be05811 _dispatch_mgr_invoke + 251
2 libdispatch.dylib 0x00007fff8be05465 _dispatch_mgr_thread + 52

執行緒閒置時

Thread 6:
0 libsystem_kernel.dylib 0x00007fff8967d772 __workq_kernreturn + 10
1 libsystem_pthread.dylib 0x00007fff8fd317d9 _pthread_wqthread + 1283
2 libsystem_pthread.dylib 0x00007fff8fd2ed95 start_wqthread + 13

執行緒活躍時

Thread 3 Crashed:: Dispatch queue:

7 libdispatch.dylib 0x07fff8fcfd323 _dispatch_call_block_and_release
8 libdispatch.dylib 0x07fff8fcf8c13 _dispatch_client_callout + 8
9 libdispatch.dylib 0x07fff8fcfc365 _dispatch_queue_drain + 1100
10 libdispatch.dylib 0x07fff8fcfdecc _dispatch_queue_invoke + 202
11 libdispatch.dylib 0x07fff8fcfb6b7 _dispatch_root_queue_drain + 463
12 libdispatch.dylib 0x07fff8fd09fe4 _dispatch_worker_thread3 + 91
13 libsystem_pthread.dylib 0x07fff93c17637 _pthread_wqthread + 729
14 libsystem_pthread.dylib 0x07fff93c1540d start_wqthread + 13

主執行緒閒置時

Thread 0 Crashed:: Dispatch queue: com.apple.main-thread
0 libsystem_kernel.dylib 0x00007fff906614de mach_msg_trap + 10
1 libsystem_kernel.dylib 0x00007fff9066064f mach_msg + 55
2 com.apple.CoreFoundation 0x00007fff9a8c1eb4 __CFRunLoopServiceMachPort
3 com.apple.CoreFoundation 0x00007fff9a8c137b __CFRunLoopRun + 1371
4 com.apple.CoreFoundation 0x00007fff9a8c0bd8 CFRunLoopRunSpecific + 296

10 com.apple.AppKit 0x00007fff8e823c03 -[NSApplication run] + 594
11 com.apple.AppKit 0x00007fff8e7a0354 NSApplicationMain + 1832
12 com.example 0x00000001000013b4 start + 52

主佇列

Thread 0 Crashed:: Dispatch queue: com.apple.main-thread

12 com.apple.Foundation 0x00007fff931157e8 __NSBLOCKOPERATION_IS_CALLING
OUT_TO_A_BLOCK_ + 7
13 com.apple.Foundation 0x00007fff931155b5 -[NSBlockOperation main] + 9
14 com.apple.Foundation 0x00007fff93114a6c -[_NSOperationInternal
start:] + 653
15 com.apple.Foundation 0x00007fff93114543 __NSOQSchedule_f + 184
16 libdispatch.dylib 0x00007fff935d6c13 _dispatch_client_callout + 8
17 libdispatch.dylib 0x00007fff935e2cbf _dispatch_main_queue_callback
_4CF + 861
18 com.apple.CoreFoundation 0x00007fff8d9223f9 __CFRUNLOOP_IS_SERVICING_THE
MAIN_DISPATCH_QUEUE_
19 com.apple.CoreFoundation 0x00007fff8d8dd68f __CFRunLoopRun + 2159
20 com.apple.CoreFoundation 0x00007fff8d8dcbd8 CFRunLoopRunSpecific + 296

26 com.apple.AppKit 0x00007fff999a1bd3 -[NSApplication run] + 594
27 com.apple.AppKit 0x00007fff9991e324 NSApplicationMain + 1832
28 libdyld.dylib 0x00007fff9480f5c9 start + 1

I/O 效能優化

I/O 是效能消耗大戶,任何的 I/O 操作都會使低功耗狀態被打破,所以減少 I/O 次數是這個效能優化的關鍵點,為了達成這個目下面列出一些方法。
將零碎的內容作為一個整體進行寫入
使用合適的 I/O 操作 API
使用合適的執行緒
使用 NSCache 做快取能夠減少 I/O

這裡寫圖片描述

控制 App 的 Wake 次數

通知,VoIP,定位,藍芽等都會使裝置從 Standby 狀態喚起。喚起這個過程會有比較大的消耗,應該避免頻繁發生。通知方面主要要在產品層面多做考慮。定位方面,下面可以看看定位的一些 API 看看它們對效能的不同影響,便於考慮採用合適的介面。
連續的位置更新

[locationManager startUpdatingLocation]

這個方法會時裝置一直處於活躍狀態。
延時有效定位

[locationManager allowDeferredLocationUpdatesUntilTraveled: timeout:]

高效節能的定位方式,資料會快取在位置硬體上。適合於跑步應用應該都採用這種方式。
重大位置變化
```objc
[locationManager startMonitoringSignificantLocationChanges]

會更節能,對於那些只有在位置有很大變化的才需要回調的應用可以採用這種,比如天氣應用。
區域監測

[locationManager startMonitoringForRegion:(CLRegion *)]

也是一種節能的定位方式,比如在博物館裡按照不同區域監測展示不同資訊之類的應用比較適合這種定位。
經常訪問的地方

// Start monitoringlocationManager.startMonitoringVisits()// Stop 
monitoring when no longer neededlocationManager.stopMonitoringVisits()

總的來說,不要輕易使用 startUpdatingLocation() 除非萬不得已,儘快的使用 stopUpdatingLocation() 來結束定位還使用者一個節能裝置。

記憶體對於效能的影響

首先 Reclaiming 記憶體是需要時間的,突然的大量記憶體需求是會影響響應的。

如何預防這些效能問題,需要刻意預防麼

堅持下面幾個原則爭取在編碼階段避免一些效能問題。
優化計算的複雜度從而減少 CPU 的使用
在應用響應互動的時候停止沒必要的任務處理
設定合適的 QoS
將定時器任務合併,讓 CPU 更多時候處於 idle 狀態
那麼如果寫需求時來不及注意這些問題做不到預防的話,可以通過自動化程式碼檢查的方式來避免這些問題嗎?

如何檢查

根據這些問題在程式碼裡查,寫工具或用工具自動化查?雖然可以,但是需要考慮的情況太多,現有工具支援不好,自己寫需要考慮的點太多需要花費太長的時間,那麼什麼方式會比較好呢?

通過監聽主執行緒方式來監察

首先用 CFRunLoopObserverCreate 建立一個觀察者裡面接受 CFRunLoopActivity 的回撥,然後用 CFRunLoopAddObserver 將觀察者新增到 CFRunLoopGetMain() 主執行緒 Runloop 的 kCFRunLoopCommonModes 模式下進行觀察。
接下來建立一個子執行緒來進行監控,使用 dispatch_semaphore_wait 定義區間時間,標準是 1620 微秒一次監控的話基本可以把影響響應的都找出來。監控結果的標準是根據兩個 Runloop 的狀態 BeforeSources 和 AfterWaiting 在區間時間是否能檢測到來判斷是否卡頓。

如何列印堆疊資訊,儲存現場

列印堆疊整體思路是獲取執行緒的資訊得到執行緒的 state 從而得到執行緒裡所有棧的指標,根據這些指標在 符號表裡找到對應的描述即符號化解析,這樣就能夠展示出可讀的堆疊資訊。具體實現是怎樣的呢?下面詳細說說:

獲取執行緒的資訊

這裡首先是要通過 task_threads 取到所有的執行緒,

thread_act_array_t threads; //int 組成的陣列比如 thread[1] = 5635
mach_msg_type_number_t thread_count = 0; //mach_msg_type_number_t 是 int 型別
const task_t this_task = mach_task_self(); //int
//根據當前 task 獲取所有執行緒
kern_return_t kr = task_threads(this_task, &threads, &thread_count);

遍歷時通過 thread_info 獲取各個執行緒的詳細資訊

SMThreadInfoStruct threadInfoSt = {0};
thread_info_data_t threadInfo;
thread_basic_info_t threadBasicInfo;
mach_msg_type_number_t threadInfoCount = THREAD_INFO_MAX;
if (thread_info((thread_act_t)thread, THREAD_BASIC_INFO, (thread_info_t)thread
Info, &threadInfoCount) == KERN_SUCCESS) {
threadBasicInfo = (thread_basic_info_t)threadInfo;
if (!(threadBasicInfo->flags & TH_FLAGS_IDLE)) {
threadInfoSt.cpuUsage = threadBasicInfo->cpu_usage / 10;
threadInfoSt.userTime = threadBasicInfo->system_time.microseconds;
}
}
uintptr_t buffer[100];
int i = 0;
NSMutableString *reStr = [NSMutableString stringWithFormat:@"Stack of thread: 
%u:\n CPU used: %.1f percent\n user time: %d second\n", thread, threadInfoSt.
cpuUsage, threadInfoSt.userTime];

獲取執行緒裡所有棧的資訊
可以通過 thread_get_state 得到 machine context 裡面包含了執行緒棧裡所有的棧指標。

_STRUCT_MCONTEXT machineContext; //執行緒棧裡所有的棧指標
//通過 thread_get_state 獲取完整的 machineContext 資訊,包含 thread 狀態資訊
mach_msg_type_number_t state_count = smThreadStateCountByCPU();
kern_return_t kr = thread_get_state(thread, smThreadStateByCPU(), (thread_state
_t)&machineContext.__ss, &state_count);

建立一個棧結構體用來儲存棧的資料

//為通用回溯設計結構支援棧地址由小到大,地址裡儲存上個棧指標的地址
typedef struct SMStackFrame {
const struct SMStackFrame *const previous;
const uintptr_t return_address;
} SMStackFrame;
SMStackFrame stackFrame = {0};
//通過棧基址指標獲取當前棧幀地址
const uintptr_t framePointer = smMachStackBasePointerByCPU(&machineContext);
if (framePointer == 0 || smMemCopySafely((void *)framePointer, &stackFrame, 
sizeof(stackFrame)) != KERN_SUCCESS) {
return @"Fail frame pointer";
}
for (; i < 32; i++) {
buffer[i] = stackFrame.return_address;
if (buffer[i] == 0 || stackFrame.previous == 0 || smMemCopySafely(stackFrame
.previous, &stackFrame, sizeof(stackFrame)) != KERN_SUCCESS) {
break;
}
}

符號化

符號化主要思想就是通過棧指標地址減去 Slide 地址得到 ASLR 偏移量,通過這個偏移量可以在 __LINKEDIT segment 查詢到字串和符號表的位置。具體程式碼實現如下:

info->dli_fname = NULL;
info->dli_fbase = NULL;
info->dli_sname = NULL;
info->dli_saddr = NULL;
//根據地址獲取是哪個 image
const uint32_t idx = smDyldImageIndexFromAddress(address);
if (idx == UINT_MAX) {
return false;
}
/*




<div class="se-preview-section-delimiter"></div>

Header
------------------
Load commands
Segment command 1 -------------|
Segment command 2              |
------------------             |
Data                           |
Section 1 data |segment 1 <----|
Section 2 data |          <----|
Section 3 data |          <----|
Section 4 data |segment 2
Section 5 data |
...            |
Section n data |
*/
/*----------Mach Header---------*/
//根據 image 的序號獲取 mach_header
const struct mach_header* machHeader = _dyld_get_image_header(idx);
//返回 image_index 索引的 image 的虛擬記憶體地址 slide 的數量,如果 image_index 
超出範圍返回0
//動態連結器載入 image 時,image 必須對映到未佔用地址的程序的虛擬地址空間。動態連結器
通過新增一個值到 image 的基地址來實現,這個值是虛擬記憶體 slide 數量
const uintptr_t imageVMAddressSlide = (uintptr_t)_dyld_get_image_vmaddr
_slide(idx);
/*-----------ASLR 的偏移量---------*/
//https://en.wikipedia.org/wiki/Address_space_layout_randomization
const uintptr_t addressWithSlide = address - imageVMAddressSlide;
//根據 Image 的 Index 來獲取 segment 的基地址
//段定義Mach-O檔案中的位元組範圍以及動態連結器載入應用程式時這些位元組對映到虛擬記憶體中的地址
和記憶體保護屬性。 因此,段總是虛擬記憶體頁對齊。 片段包含零個或多個節。
const uintptr_t segmentBase = smSegmentBaseOfImageIndex(idx) 
+ imageVMAddressSlide;
if (segmentBase == 0) {
return false;
}
//
info->dli_fname = _dyld_get_image_name(idx);
info->dli_fbase = (void*)machHeader;
/*--------------Mach Segment-------------*/
//地址最匹配的symbol
const nlistByCPU* bestMatch = NULL;
uintptr_t bestDistance = ULONG_MAX;
uintptr_t cmdPointer = smCmdFirstPointerFromMachHeader(machHeader);
if (cmdPointer == 0) {
return false;
}
//遍歷每個 segment 判斷目標地址是否落在該 segment 包含的範圍裡
for (uint32_t iCmd = 0; iCmd < machHeader->ncmds; iCmd++) {
const struct load_command* loadCmd = (struct load_command*)cmdPointer;
/*----------目標 Image 的符號表----------*/
//Segment 除了 __TEXT 和 __DATA 外還有 __LINKEDIT segment,它裡面包含動態連結器的
使用的原始資料,比如符號,字串和重定位表項。
//LC_SYMTAB 描述了 __LINKEDIT segment 內查詢字串和符號表的位置
if (loadCmd->cmd == LC_SYMTAB) {
//獲取字串和符號表的虛擬記憶體偏移量。
const struct symtab_command* symtabCmd = (struct symtab_command*)cmdPointer;
const nlistByCPU* symbolTable = (nlistByCPU*)(segmentBase + symtabCmd->symoff);
const uintptr_t stringTable = segmentBase + symtabCmd->stroff;
for (uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++) {
//如果 n_value 是0,symbol 指向外部物件
if (symbolTable[iSym].n_value != 0) {
//給定的偏移量是檔案偏移量,減去 __LINKEDIT segment 的檔案偏移量獲得字串和符號表的虛
擬記憶體偏移量
uintptr_t symbolBase = symbolTable[iSym].n_value;
uintptr_t currentDistance = addressWithSlide - symbolBase;
//尋找最小的距離 bestDistance,因為 addressWithSlide 是某個方法的指令地址,要大於這個
方法的入口。
//離 addressWithSlide 越近的函式入口越匹配
if ((addressWithSlide >= symbolBase) && (currentDistance <= bestDistance)) {
bestMatch = symbolTable + iSym;
bestDistance = currentDistance;
}
}
}
if (bestMatch != NULL) {
//將虛擬記憶體偏移量新增到 __LINKEDIT segment 的虛擬記憶體地址可以提供字串和符號表的記憶體
 address。
info->dli_saddr = (void*)(bestMatch->n_value + imageVMAddressSlide);
info->dli_sname = (char*)((intptr_t)stringTable + (intptr_t)bestMatch->n_un
.n_strx);
if (*info->dli_sname == '_') {
info->dli_sname++;
}
//所有的 symbols 的已經被處理好了
if (info->dli_saddr == info->dli_fbase && bestMatch->n_type == 3) {
info->dli_sname = NULL;
}
break;
}
}
cmdPointer += loadCmd->cmdsize;
}

需要注意的地方

需要注意的是這個程式有消耗效能的地方 thread get state。這個也會被監控檢查出,所以可以過濾掉這樣的堆疊資訊。

能夠獲取更多資訊的方法

獲取更多資訊比如全層級方法呼叫和每個方法消耗的時間,那麼這樣做的好處在哪呢?
可以更細化的測量時間消耗,找到耗時方法,更快的互動操作能使使用者體驗更好,下面是一些可以去衡量的場景:
響應能力
按鈕點選
手勢操作
Tab 切換
vc 的切換和轉場
可以給優化定個目標,比如滾動和動畫達到 60fps,響應使用者操作在 100ms 內完成。然後逐個檢測出來 fix 掉。
如何獲取到更多資訊呢?
通過 hook objc_msgSend 方法能夠獲取所有被呼叫的方法,記錄深度就能夠得到方法呼叫的樹狀結構,通過執行前後時間的記錄能夠得到每個方法的耗時,這樣就能獲取一份完整的效能消耗資訊了。
hook c 函式可以使用 facebook 的 fishhook, 獲取方法呼叫樹狀結構可以使用 InspectiveC,下面對於他們的實現詳細介紹一下:

獲取方法呼叫樹結構

首先設計兩個結構體,CallRecord 記錄呼叫方法詳細資訊,包括 obj 和 SEL 等,ThreadCallStack 裡面需要用 index 記錄當前呼叫方法樹的深度。有了 SEL 再通過 NSStringFromSelector 就能夠取得方法名,有了 obj 通過 object_getClass 能夠得到 Class 再用 NSStringFromClass 就能夠獲得類名。

// Shared structures.
typedef struct CallRecord_ {
id obj;   //通過 object_getClass 能夠得到 Class 再通過 NSStringFromClass 能夠得
到類名
SEL _cmd; //通過 NSStringFromSelector 方法能夠得到方法名
uintptr_t lr;
int prevHitIndex;
char isWatchHit;
} CallRecord;
typedef struct ThreadCallStack_ {
FILE *file;
char *spacesStr;
CallRecord *stack;
int allocatedLength;
int index;
int numWatchHits;
int lastPrintedIndex;
int lastHitIndex;
char isLoggingEnabled;
char isCompleteLoggingEnabled;
} ThreadCallStack;


儲存讀取 ThreadCallStack

pthread_setspecific() 可以將私有資料設定在指定執行緒上,pthread_getspecific() 用來讀取這個私有資料,利用這個特性可以就可以將 ThreadCallStack 的資料和該執行緒繫結在一起,隨時進行資料的存取。程式碼如下:

static inline ThreadCallStack * getThreadCallStack() {
ThreadCallStack *cs = (ThreadCallStack *)pthread_getspecific(threadKey); 
//讀取
if (cs == NULL) {
cs = (ThreadCallStack *)malloc(sizeof(ThreadCallStack));




<div class="se-preview-section-delimiter"></div>

#ifdef MAIN_THREAD_ONLY
cs->file = (pthread_main_np()) ? newFileForThread() : NULL;




<div class="se-preview-section-delimiter"></div>

#else
cs->file = newFileForThread();




<div class="se-preview-section-delimiter"></div>

#endif
cs->isLoggingEnabled = (cs->file != NULL);
cs->isCompleteLoggingEnabled = 0;
cs->spacesStr = (char *)malloc(DEFAULT_CALLSTACK_DEPTH + 1);
memset(cs->spacesStr, ' ', DEFAULT_CALLSTACK_DEPTH);
cs->spacesStr[DEFAULT_CALLSTACK_DEPTH] = '\0';
cs->stack = (CallRecord *)calloc(DEFAULT_CALLSTACK_DEPTH, sizeof(CallRecord));
 //分配 CallRecord 預設空間
cs->allocatedLength = DEFAULT_CALLSTACK_DEPTH;
cs->index = cs->lastPrintedIndex = cs->lastHitIndex = -1;
cs->numWatchHits = 0;
pthread_setspecific(threadKey, cs); //儲存資料
}
return cs;
}

記錄方法呼叫深度

因為要記錄深度,而一個方法的呼叫裡會有更多的方法呼叫,所以方法的呼叫寫兩個方法分別記錄開始 pushCallRecord 和記錄結束的時刻 popCallRecord,這樣才能夠通過在開始時對深度加一在結束時減一。

//開始時
static inline void pushCallRecord(id obj, uintptr_t lr, SEL _cmd, 
ThreadCallStack *cs) {
int nextIndex = (++cs->index); //增加深度
if (nextIndex >= cs->allocatedLength) {
cs->allocatedLength += CALLSTACK_DEPTH_INCREMENT;
cs->stack = (CallRecord *)realloc(cs->stack, cs->allocatedLength * 
sizeof(CallRecord));
cs->spacesStr = (char *)realloc(cs->spacesStr, cs->allocatedLength + 1);
memset(cs->spacesStr, ' ', cs->allocatedLength);
cs->spacesStr[cs->allocatedLength] = '\0';
}
CallRecord *newRecord = &cs->stack[nextIndex];
newRecord->obj = obj;
newRecord->_cmd = _cmd;
newRecord->lr = lr;
newRecord->isWatchHit = 0;
}
//結束時
static inline CallRecord * popCallRecord(ThreadCallStack *cs) {
return &cs->stack[cs->index--]; //減少深度
}

在 objc_msgSend 前後插入執行方法

最後是 hook objc_msgSend 需要在呼叫前和呼叫後分別加入 pushCallRecord 和 popCallRecord。因為需要在呼叫後這個時機插入一個方法,這就需要用到彙編來做到。下面針對 arm64 進行分析,主要思路就是先入棧引數,引數暫存器是 x0 - x7,syscall 的 number 會放到 x8 裡。然後交換暫存器中,將用於返回的暫存器 lr 移到 x1 裡。先讓 pushCallRecord 能夠執行,再執行原始的 objc_msgSend,儲存返回值,最後讓 popCallRecord 能執行。具體程式碼如下:

static void replacementObjc_msgSend() {
__asm__ volatile (
// 儲存 {q0-q7}
"stp q6, q7, [sp, #-32]!\n"
"stp q4, q5, [sp, #-32]!\n"
"stp q2, q3, [sp, #-32]!\n"
"stp q0, q1, [sp, #-32]!\n"
// 儲存 {x0-x8, lr}
"stp x8, lr, [sp, #-16]!\n"
"stp x6, x7, [sp, #-16]!\n"
"stp x4, x5, [sp, #-16]!\n"
"stp x2, x3, [sp, #-16]!\n"
"stp x0, x1, [sp, #-16]!\n"
// 交換引數.
"mov x2, x1\n"
"mov x1, lr\n"
"mov x3, sp\n"
// 呼叫 preObjc_msgSend
"bl __Z15preObjc_msgSendP11objc_objectmP13objc_selectorP9RegState_\n"
"mov x9, x0\n"
"mov x10, x1\n"
"tst x10, x10\n"
// 讀取 {x0-x8, lr}
"ldp x0, x1, [sp], #16\n"
"ldp x2, x3, [sp], #16\n"
"ldp x4, x5, [sp], #16\n"
"ldp x6, x7, [sp], #16\n"
"ldp x8, lr, [sp], #16\n"
// 讀取 {q0-q7}
"ldp q0, q1, [sp], #32\n"
"ldp q2, q3, [sp], #32\n"
"ldp q4, q5, [sp], #32\n"
"ldp q6, q7, [sp], #32\n"
"b.eq Lpassthrough\n"
// 呼叫原始 objc_msgSend.
"blr x9\n"
// 儲存 {x0-x9}
"stp x0, x1, [sp, #-16]!\n"
"stp x2, x3, [sp, #-16]!\n"
"stp x4, x5, [sp, #-16]!\n"
"stp x6, x7, [sp, #-16]!\n"
"stp x8, x9, [sp, #-16]!\n"
// 儲存 {q0-q7}
"stp q0, q1, [sp, #-32]!\n"
"stp q2, q3, [sp, #-32]!\n"
"stp q4, q5, [sp, #-32]!\n"
"stp q6, q7, [sp, #-32]!\n"
// 呼叫 postObjc_msgSend hook.
"bl __Z16postObjc_msgSendv\n"
"mov lr, x0\n"
// 讀取 {q0-q7}
"ldp q6, q7, [sp], #32\n"
"ldp q4, q5, [sp], #32\n"
"ldp q2, q3, [sp], #32\n"
"ldp q0, q1, [sp], #32\n"
// 讀取 {x0-x9}
"ldp x8, x9, [sp], #16\n"
"ldp x6, x7, [sp], #16\n"
"ldp x4, x5, [sp], #16\n"
"ldp x2, x3, [sp], #16\n"
"ldp x0, x1, [sp], #16\n"
"ret\n"
"Lpassthrough:\n"
"br x9"
);
}

記錄時間的方法

為了記錄耗時,這樣就需要在 pushCallRecord 和 popCallRecord 裡記錄下時間。下面列出一些計算一段程式碼開始到結束的時間的方法
第一種: NSDate 微秒

NSDate* tmpStartData = [NSDate date];
//some code need caculate
double deltaTime = [[NSDate date] timeIntervalSinceDate:tmpStartData];
NSLog(@"cost time: %f s", deltaTime);

第二種:clock_t 微秒clock_t計時所表示的是佔用CPU的時鐘單元

clock_t start = clock();
//some code need caculate
clock_t end = clock();
NSLog(@"cost time: %f s", (double)(end - start)/CLOCKS_PER_SEC);

第三種:CFAbsoluteTime 微秒

CFAbsoluteTime start = CFAbsoluteTimeGetCurrent();
//some code need caculate
CFAbsoluteTime end = CFAbsoluteTimeGetCurrent();
NSLog(@"cost time = %f s", end - start); //s

第四種:CFTimeInterval 納秒

CFTimeInterval start = CACurrentMediaTime();
//some code need caculate
CFTimeInterval end = CACurrentMediaTime();
NSLog(@"cost time: %f s", end - start);




<div class="se-preview-section-delimiter"></div>

第五種:mach_absolute_time 納秒
“`objc
uint64_t start = mach_absolute_time ();
//some code need caculate
uint64_t end = mach_absolute_time ();
uint64_t elapsed = 1e-9 *(end - start);

最後兩種可用,本質區別
NSDate 或 CFAbsoluteTimeGetCurrent() 返回的時鐘時間將會會網路時間同步,從時鐘 偏移量的角度。mach_absolute_time() 和 CACurrentMediaTime() 是基於內建時鐘的。選擇一種,加到 pushCallRecord 和 popCallRecord 裡,相減就能夠獲得耗時。

如何 hook msgsend 方法

那麼 objc_msgSend 這個 c 方法是如何 hook 到的呢。首先了解下 dyld 是通過更新 Mach-O 二進位制的 __DATA segment 特定的部分中的指標來邦定 lazy 和 non-lazy 符號,通過確認傳遞給 rebind_symbol 裡每個符號名稱更新的位置就可以找出對應替換來重新繫結這些符號。下面針對關鍵程式碼進行分析:

遍歷 dyld

首先是遍歷 dyld 裡的所有的 image,取出 image header 和 slide。注意第一次呼叫時主要註冊 callback。

if (!_rebindings_head->next) {
_dyld_register_func_for_add_image(_rebind_symbols_for_image);
} else {
uint32_t c = _dyld_image_count();
for (uint32_t i = 0; i < c; i++) {
_rebind_symbols_for_image(_dyld_get_image_header(i), _dyld_get_image_vmaddr
_slide(i));
}
}

找出符號表相關 Command

接下來需要找到符號表相關的 command,包括 linkedit segment command,symtab command 和 dysymtab command。方法如下:

segment_command_t *cur_seg_cmd;
segment_command_t *linkedit_segment = NULL;
struct symtab_command* symtab_cmd = NULL;
struct dysymtab_command* dysymtab_cmd = NULL;
uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
cur_seg_cmd = (segment_command_t *)cur;
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
linkedit_segment = cur_seg_cmd;
}
} else if (cur_seg_cmd->cmd == LC_SYMTAB) {
symtab_cmd = (struct symtab_command*)cur_seg_cmd;
} else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
}
}

獲得 base 和 indirect 符號表

// Find base symbol/string table addresses
uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr
- linkedit_segment->fileoff;
nlist_t symtab = (nlist_t )(linkedit_base + symtab_cmd->symoff);
char strtab = (char )(linkedit_base + symtab_cmd->stroff);
// Get indirect symbol table (array of uint32_t indices into symbol table)
uint32_t indirect_symtab = (uint32_t )(linkedit_base + dysymtab_cmd
->indirectsymoff);

進行方法替換

有了符號表和傳入的方法替換陣列就可以進行符號表訪問指標地址的替換,具體實現如下:

uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;
void indirect_symbol_bindings = (void )((uintptr_t)slide + section->addr);
for (uint i = 0; i < section->size / sizeof(void *); i++) {
uint32_t symtab_index = indirect_symbol_indices[i];
if (symtab_index == INDIRECT_SYMBOL_ABS || symtab_index == INDIRECT_SYMBOL
_LOCAL ||
symtab_index == (INDIRECT_SYMBOL_LOCAL | INDIRECT_SYMBOL_ABS)) {
continue;
}
uint32_t strtab_offset = symtab[symtab_index].n_un.n_strx;
char *symbol_name = strtab + strtab_offset;
if (strnlen(symbol_name, 2) < 2) {
continue;
}
struct rebindings_entry *cur = rebindings;
while (cur) {
for (uint j = 0; j < cur->rebindings_nel; j++) {
if (strcmp(&symbol_name[1], cur->rebindings[j].name) == 0) {
if (cur->rebindings[j].replaced != NULL &&
indirect_symbol_bindings[i] != cur->rebindings[j].replacement) {
*(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
}
indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
goto symbol_loop;
}
}
cur = cur->next;
}
symbol_loop:;

Demo

工具已整合到先前做的 DecoupleDemo 裡。使用的話在需要開始檢測的地方新增 [[SMLagMonitor shareInstance] beginMonitor]; 即可。需要檢測所有方法呼叫的用法就是在需要檢測的地方呼叫 [SMCallTrace start]; 就可以了,不檢測打印出結果的話呼叫 stop 和 save 就好了。這裡還可以設定最大深度和最小耗時檢測來過濾不需要看到的資訊。
PS:
最近我這需要招一名實習生來滴滴和我一起攻克一個工程開發效率的專案,絕對會成就感滿滿的,有興趣的可以將簡歷發我 [email protected] 或者在微博上聯絡我 @戴銘