深入剖析 iOS 性能优化

百家 作者:iOS开发 2017-07-27 11:46:33

点击上方“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 long
er neededlocationManager.stopMonitoringVisits()


总的来说,不要轻易使用 startUpdatingLocation() 除非万不得已,尽快的使用 stopUpdatingLocation() 来结束定位还用户一个节能设备。


内存对于性能的影响


首先 Reclaiming 内存是需要时间的,突然的大量内存需求是会影响响应的。


如何预防这些性能问题,需要刻意预防么


坚持下面几个原则争取在编码阶段避免一些性能问题。

  • 优化计算的复杂度从而减少 CPU 的使用

  • 在应用响应交互的时候停止没必要的任务处理

  • 设置合适的 QoS

  • 将定时器任务合并,让 CPU 更多时候处于 idle 状态

那么如果写需求时来不及注意这些问题做不到预防的话,可以通过自动化代码检查的方式来避免这些问题吗?


如何检查


根据这些问题在代码里查,写工具或用工具自动化查?虽然可以,但是需要考虑的情况太多,现有工具支持不好,自己写需要考虑的点太多需要花费太长的时间,那么什么方式会比较好呢?


通过监听主线程方式来监察


首先用 CFRunLoopObserverCreate 创建一个观察者里面接受 CFRunLoopActivity 的回调,然后用 CFRunLoopAddObserver 将观察者添加到 CFRunLoopGetMain() 主线程 Runloop 的 kCFRunLoopCommonModes 模式下进行观察。

接下来创建一个子线程来进行监控,使用 dispatch_semaphore_wait 定义区间时间,标准是 16 或 20 微秒一次监控的话基本可以把影响响应的都找出来。监控结果的标准是根据两个 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:
CPU used: %.1f percent user time: %d second ", 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;

}

/*

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));

#ifdef MAIN_THREAD_ONLY

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

#else

cs->file = newFileForThread();

#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] = '';

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] = '';

}

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]! "

"stp q4, q5, [sp, #-32]! "

"stp q2, q3, [sp, #-32]! "

"stp q0, q1, [sp, #-32]! "

// 保存 {x0-x8, lr}

"stp x8, lr, [sp, #-16]! "

"stp x6, x7, [sp, #-16]! "

"stp x4, x5, [sp, #-16]! "

"stp x2, x3, [sp, #-16]! "

"stp x0, x1, [sp, #-16]! "

// 交换参数.

"mov x2, x1 "

"mov x1, lr "

"mov x3, sp "

// 调用 preObjc_msgSend

"bl __Z15preObjc_msgSendP11objc_objectmP13objc_selectorP9RegState_ "

"mov x9, x0 "

"mov x10, x1 "

"tst x10, x10 "

// 读取 {x0-x8, lr}

"ldp x0, x1, [sp], #16 "

"ldp x2, x3, [sp], #16 "

"ldp x4, x5, [sp], #16 "

"ldp x6, x7, [sp], #16 "

"ldp x8, lr, [sp], #16 "

// 读取 {q0-q7}

"ldp q0, q1, [sp], #32 "

"ldp q2, q3, [sp], #32 "

"ldp q4, q5, [sp], #32 "

"ldp q6, q7, [sp], #32 "

"b.eq Lpassthrough "

// 调用原始 objc_msgSend.

"blr x9 "

// 保存 {x0-x9}

"stp x0, x1, [sp, #-16]! "

"stp x2, x3, [sp, #-16]! "

"stp x4, x5, [sp, #-16]! "

"stp x6, x7, [sp, #-16]! "

"stp x8, x9, [sp, #-16]! "

// 保存 {q0-q7}

"stp q0, q1, [sp, #-32]! "

"stp q2, q3, [sp, #-32]! "

"stp q4, q5, [sp, #-32]! "

"stp q6, q7, [sp, #-32]! "

// 调用 postObjc_msgSend hook.

"bl __Z16postObjc_msgSendv "

"mov lr, x0 "

// 读取 {q0-q7}

"ldp q6, q7, [sp], #32 "

"ldp q4, q5, [sp], #32 "

"ldp q2, q3, [sp], #32 "

"ldp q0, q1, [sp], #32 "

// 读取 {x0-x9}

"ldp x8, x9, [sp], #16 "

"ldp x6, x7, [sp], #16 "

"ldp x4, x5, [sp], #16 "

"ldp x2, x3, [sp], #16 "

"ldp x0, x1, [sp], #16 "

"ret "

"Lpassthrough: "

"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);

```第五种: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 就好了。这里还可以设置最大深度和最小耗时检测来过滤不需要看到的信息。

 


  • 本文作者:      戴铭

  • 本文链接:      http://ming1016.github.io/2017/06/20/deeply-ios-performance-optimization/

  • 版权声明:      本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!

  • iOS开发整理发布,转载请联系作者授权

关注公众号:拾黑(shiheibook)了解更多

[广告]赞助链接:

四季很好,只要有你,文娱排行榜:https://www.yaopaiming.com/
让资讯触达的更精准有趣:https://www.0xu.cn/

公众号 关注网络尖刀微信公众号
随时掌握互联网精彩
赞助链接