IOS 开发高手课 学习笔记(第二部分)

第二部分主要是性能监控相关

Part 7. 包大小:如何从资源和代码层面实现全方位瘦身?

官方 App Thinning

App Thinning 是由苹果公司推出的一项可以改善 App 下载进程的新技术,主要是为了解决用户下载 App 耗费过高流量的问题,同时还可以节省用户 iOS 设备的存储空间。App Thinning 会专门针对不同的设备来选择只适用于当前设备的内容以供下载。
App Thinning 有三种方式,包括:App Slicing、Bitcode、On-Demand Resources。App Slicing,会在你向 iTunes Connect 上传 App 后,对 App 做切割,创建不同的变体,这样就可以适用到不同的设备。On-Demand Resources,主要是为游戏多关卡场景服务的。它会根据用户的关卡进度下载随后几个关卡的资源,并且已经过关的资源也会被删掉,这样就可以减少初装 App 的包大小。Bitcode ,是针对特定设备进行包大小优化,优化不明显。那么,如何在你项目里使用 App Thinning 呢?其实,这里的大部分工作都是由 Xcode 和 App Store 来帮你完成的,你只需要通过 Xcode 添加 xcassets 目录,然后将图片添加进来即可。

无用图片资源

删除无用图片的过程,可以概括为下面这 6 大步。通过

  1. find 命令获取 App 安装包中的所有资源文件,比如 find /Users/daiming/Project/ -name。
  2. 设置用到的资源的类型,比如 jpg、gif、png、webp。
  3. 使用正则匹配在源码中找出使用到的资源名,比如 pattern = @"@"(.+?)""。
  4. 使用 find 命令找到的所有资源文件,再去掉代码中使用到的资源文件,剩下的就是无用资源了。
  5. 对于按照规则设置的资源名,我们需要在匹配使用资源的正则表达式里添加相应的规则,比如 @“image_%d”。
  6. 确认无用资源后,就可以对这些无用资源执行删除操作了。这个删除操作,你可以使用 NSFileManger 系统类提供的功能来完成。

如果你不想自己重新写一个工具的话,可以选择开源的工具直接使用。我觉得目前最好用的是 LSUnusedResources,特别是对于使用编号规则的图片来说,可以通过直接添加规则来处理。
https://github.com/tinymind/LSUnusedResources.git

图片资源压缩

目前比较好的压缩方案是,将图片转成 WebP。WebP 是 Google 公司的一个开源项目。首先,我们一起看看选择 WebP 的理由:WebP 压缩率高,而且肉眼看不出差异,同时支持有损和无损两种压缩模式。比如,将 Gif 图转为 Animated WebP ,有损压缩模式下可减少 64% 大小,无损压缩模式下可减少 19% 大小。WebP 支持 Alpha 透明和 24-bit 颜色数,不会像 PNG8 那样因为色彩不够而出现毛边。
Google 公司在开源 WebP 的同时,还提供了一个图片压缩工具 cwebp来将其他图片转成 WebP。
cwebp 语法如下:

cwebp [options] input_file -o output_file.webp

比如,你要选择无损压缩模式的话,可以使用如下所示的命令:

cwebp -lossless original.png -o new.webp

在 cwebp 语法中,还有一个比较关键的参数 -q float。图片色值在不同情况下,可以选择用 -q 参数来进行设置,在不损失图片质量情况下进行最大化压缩:小于 256 色适合无损压缩,压缩率高,参数使用 -lossless -q 100;大于 256 色使用 75% 有损压缩,参数使用 -q 75;远大于 256 色使用 75% 以下压缩率,参数 -q 50 -m 6。
除了 cwebp 工具外,你还可以选择由腾讯公司开发的iSparta。

图片压缩完了并不是结束,我们还需要在显示图片时使用 libwebp 进行解析。这里有一个 iOS 工程使用 libwebp 的范例,你可以点击这个链接查看。https://github.com/carsonmcdonald/WebP-iOS-example.git
不过,WebP 在 CPU 消耗和解码时间上会比 PNG 高两倍。所以,我们有时候还需要在性能和体积上做取舍。我的建议是,如果图片大小超过了 100KB,你可以考虑使用 WebP;而小于 100KB 时,你可以使用网页工具 TinyPng或者 GUI 工具ImageOptim进行图片压缩。这两个工具的压缩率没有 WebP 那么高,不会改变图片压缩方式,所以解析时对性能损耗也不会增加。

代码瘦身
常情况下,对可执行文件进行瘦身,就是找到并删除无用代码的过程。而查找无用代码时,我们可以按照找无用图片的思路,即:首先,找出方法和类的全集;然后,找到使用过的方法和类;接下来,取二者的差集得到无用代码;最后,由人工确认无用代码可删除后,进行删除即可。

  1. LinkMap 结合 Mach-O 找无用代码
  2. 通过 AppCode 找出无用代码
    如果工程量不是很大的话,我还是建议你直接使用 AppCode 来做分析。使用 AppCode 检查出来的无用代码,还需要人工二次确认才能够安全删除掉。

运行时检查类是否真正被使用过:

#define RW_INITIALIZED (1<<29)
bool isInitialized() { 
    return getMeta()->data()->flags & RW_INITIALIZED;
}
  • 扩展问题:苹果公司为什么要设计元类

一些有意义的评论:
1) 苹果设备有针对png图片的显示进行优化,所以并不建议将图片转换为webp,并且使用tinypng工具已经可以将Png图片很好的压缩了~

Part 8. iOS 崩溃千奇百怪,如何全面监控?

KVO 问题、NSNotification 线程问题、数组越界、野指针等崩溃信息,是可以通过信号捕获的。但是,像后台任务超时、内存被打爆、主线程卡顿超阈值等信息,是无法通过信号捕捉到的。
目前很多公司的崩溃日志监控系统,都是通过PLCrashReporter 这样的第三方开源库捕获崩溃日志,然后上传到自己服务器上进行整体监控的。而没有服务端开发能力,或者对数据不敏感的公司,则会直接使用 Fabric或者Bugly来监控崩溃。

信号可捕获的崩溃日志收集:

#include 

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

void handleSignalException(int signal) {
    NSMutableString *crashString = [[NSMutableString alloc]init];
    void* callstack[128];
    int i, frames = backtrace(callstack, 128);
    char** traceChar = backtrace_symbols(callstack, frames);
    for (i = 0; i 

信号捕获不到的崩溃信息怎么收集?
采用 Background Task 方式时,我们可以根据 beginBackgroundTaskWithExpirationHandler 会让后台保活 3 分钟这个阈值,先设置一个计时器,在接近 3 分钟时判断后台程序是否还在执行。如果还在执行的话,我们就可以判断该程序即将后台崩溃,进行上报、记录,以达到监控的效果。

  • 实践代码
- (void)applicationDidEnterBackground:(UIApplication *)application {
     
    self.bgTaskId = [application beginBackgroundTaskWithExpirationHandler:^{
     
        [application endBackgroundTask:self.bgTaskId];
        self.bgTaskId = UIBackgroundTaskInvalid;
    }];
    
    NSDate* st = [NSDate dateWithTimeIntervalSinceNow:0];
    while(1){
     
        [NSThread sleepForTimeInterval:5.0];
        NSDate* date = [NSDate dateWithTimeIntervalSinceNow:0];
        NSTimeInterval durring = [date timeIntervalSinceDate:st];
        NSLog(@"backgroudtask runing times=%.2f", durring);
        if(durring > 290.0){
     
            printStack(0);
            break;
        }
    }
}

其他捕获不到的崩溃情况还有很多,主要就是内存打爆和主线程卡顿时间超过阈值被 watchdog 杀掉这两种情况。其实,监控这两类崩溃的思路和监控后台崩溃类似,我们都先要找到它们的阈值,然后在临近阈值时还在执行的后台程序,判断为将要崩溃,收集信息并上报。
对于内存打爆信息的收集,你可以采用内存映射(mmap)的方式来保存现场。主线程卡顿时间超过阈值这种情况,你只要收集当前线程的堆栈信息就可以了。

采集到崩溃信息后如何分析并解决崩溃问题呢?
通过上面的内容,我们已经解决了崩溃信息采集的问题。现在,我们需要对这些信息进行分析,进而解决 App 的崩溃问题。我们采集到的崩溃日志,主要包含的信息为:进程信息、基本信息、异常信息、线程回溯。进程信息:崩溃进程的相关信息,比如崩溃报告唯一标识符、唯一键值、设备标识;基本信息:崩溃发生的日期、iOS 版本;异常信息:异常类型、异常编码、异常的线程;线程回溯:崩溃时的方法调用栈。通常情况下,我们分析崩溃日志时最先看的是异常信息,分析出问题的是哪个线程,在线程回溯里找到那个线程;然后,分析方法调用栈,符号化后的方法调用栈可以完整地看到方法调用的过程,从而知道问题发生在哪个方法的调用上。

一些被系统杀掉的情况,我们可以通过异常编码来分析。你可以在维基百科上,查看完整的异常编码。这里列出了 44 种异常编码,但常见的就是如下三种:0x8badf00d,表示 App 在一定时间内无响应而被 watchdog 杀掉的情况。0xdeadfa11,表示 App 被用户强制退出。0xc00010ff,表示 App 因为运行造成设备温度太高而被杀掉。

Part 9. 如何利用 RunLoop 原理去监控卡顿

我们先来看一下导致卡顿问题的几种原因:
复杂 UI 、图文混排的绘制量过大;
在主线程上做网络同步请求;
在主线程做大量的 IO 操作;
运算量过大,CPU 持续高占用;
死锁和主子线程抢锁。

RunLoop 原理 (网上很多介绍,这里不记录)
我们都知道,线程的消息事件是依赖于 NSRunLoop 的,所以从 NSRunLoop 入手,就可以知道主线程上都调用了哪些方法。我们通过监听 NSRunLoop 的状态,就能够发现调用方法是否执行时间过长,从而判断出是否会出现卡顿。所以,我推荐的监控卡顿的方案是:通过监控 RunLoop 的状态来判断是否会出现卡顿。

通过对 RunLoop 原理的分析,我们可以看出在整个过程中,loop 的状态包括 6 个,其代码定义如下:typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) { kCFRunLoopEntry , // 进入 loop kCFRunLoopBeforeTimers , // 触发 Timer 回调 kCFRunLoopBeforeSources , // 触发 Source0 回调 kCFRunLoopBeforeWaiting , // 等待 mach_port 消息 kCFRunLoopAfterWaiting ), // 接收 mach_port 消息 kCFRunLoopExit , // 退出 loop kCFRunLoopAllActivities // loop 所有状态改变}如果 RunLoop 的线程,进入睡眠前方法的执行时间过长而导致无法进入睡眠,或者线程唤醒后接收消息时间过长而无法进入下一步的话,就可以认为是线程受阻了。如果这个线程是主线程的话,表现出来的就是出现了卡顿。所以,如果我们要利用 RunLoop 原理来监控卡顿的话,就是要关注这两个阶段。RunLoop 在进入睡眠之前和唤醒后的两个 loop 状态定义的值,分别是 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting ,也就是要触发 Source0 回调和接收 mach_port 消息两个状态。

如何检查卡顿?

要想监听 RunLoop,你就首先需要创建一个 CFRunLoopObserverContext 观察者,代码如下:

CFRunLoopObserverContext context = {
     0,(__bridge void*)self,NULL,NULL};
runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,kCFRunLoopAllActivities,YES,0,&runLoopObserverCallBack,&context);

将创建好的观察者 runLoopObserver 添加到主线程 RunLoop 的 common 模式下观察。然后,创建一个持续的子线程专门用来监控主线程的 RunLoop 状态。一旦发现进入睡眠前的 kCFRunLoopBeforeSources 状态,或者唤醒后的状态 kCFRunLoopAfterWaiting,在设置的时间阈值内一直没有变化,即可判定为卡顿。接下来,我们就可以 dump 出堆栈的信息,从而进一步分析出具体是哪个方法的执行时间过长。开启一个子线程监控的代码如下:

dispatch_async(dispatch_get_global_queue(0, 0), ^{
     
    //子线程开启一个持续的 loop 用来进行监控
    while (YES) {
     
        long semaphoreWait = dispatch_semaphore_wait(dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC));
        if (semaphoreWait != 0) {
     
            if (!runLoopObserver) {
     
                timeoutCount = 0;
                dispatchSemaphore = 0;
                runLoopActivity = 0;
                return;
            }
            //BeforeSources 和 AfterWaiting 这两个状态能够检测到是否卡顿
            if (runLoopActivity == kCFRunLoopBeforeSources || runLoopActivity == kCFRunLoopAfterWaiting) {
     
                //将堆栈信息上报服务器的代码放到这里
            } //end activity
        }// end semaphore wait
        timeoutCount = 0;
    }// end while
});

如何获取卡顿的方法堆栈信息?

获取堆栈信息的一种方法是直接调用系统函数。这种方法的优点在于,性能消耗小。但是,它只能够获取简单的信息,也没有办法配合 dSYM 来获取具体是哪行代码出了问题,而且能够获取的信息类型也有限。这种方法,因为性能比较好,所以适用于观察大盘统计卡顿情况,而不是想要找到卡顿原因的场景。直接调用系统函数方法的主要思路是:用 signal 进行错误信息的获取。具体代码如下:

static int s_fatal_signals[] = {
     
    SIGABRT,
    SIGBUS,
    SIGFPE,
    SIGILL,
    SIGSEGV,
    SIGTRAP,
    SIGTERM,
    SIGKILL,
};

static int s_fatal_signal_num = sizeof(s_fatal_signals) / sizeof(s_fatal_signals[0]);

void UncaughtExceptionHandler(NSException *exception) {
     
    NSArray *exceptionArray = [exception callStackSymbols]; //得到当前调用栈信息
    NSString *exceptionReason = [exception reason];       //非常重要,就是崩溃的原因
    NSString *exceptionName = [exception name];           //异常类型
}

void SignalHandler(int code)
{
     
    NSLog(@"signal handler = %d",code);
}

void InitCrashReport()
{
     
    //系统错误信号捕获
    for (int i = 0; i < s_fatal_signal_num; ++i) {
     
        signal(s_fatal_signals[i], SignalHandler);
    }
    
    //oc未捕获异常的捕获
    NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler);
}

int main(int argc, char * argv[]) {
     
    @autoreleasepool {
     
        InitCrashReport();
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));

另一种方法是,直接用 PLCrashReporter这个开源的第三方库来获取堆栈信息。这种方法的特点是,能够定位到问题代码的具体位置,而且性能消耗也不大。所以,也是我推荐的获取堆栈信息的方法。具体如何使用 PLCrashReporter 来获取堆栈信息,代码如下所示:

@import  CrashReporter;

-(void)printStack{
     
    
    // 获取数据
    NSData *lagData = [[[PLCrashReporter alloc]
                                              initWithConfiguration:[[PLCrashReporterConfig alloc] initWithSignalHandlerType:PLCrashReporterSignalHandlerTypeBSD symbolicationStrategy:PLCrashReporterSymbolicationStrategyAll]] generateLiveReport];
    // 转换成 PLCrashReport 对象
    PLCrashReport *lagReport = [[PLCrashReport alloc] initWithData:lagData error:NULL];
    // 进行字符串格式化处理
    NSString *lagReportString = [PLCrashReportTextFormatter stringValueForCrashReport:lagReport withTextFormat:PLCrashReportTextFormatiOS];
    //将字符串上传服务器
    NSLog(@"lag happen, detail below: \n %@",lagReportString);
}

卡顿监控完整代码:

@import  CrashReporter;

#define STUCKMONITORRATE 88

@interface SWLagMonitor(){
     
    int timeoutCount;
    CFRunLoopObserverRef runLoopObserver;

@public
    dispatch_semaphore_t dispatchSemaphore;
    CFRunLoopActivity runLoopActivity;
}
@end

@implementation SWLagMonitor

+(instancetype)shareInstance {
     
    static SWLagMonitor* instance;
    static dispatch_once_t once_token;
    dispatch_once(&once_token, ^{
     
        instance = [[SWLagMonitor alloc]init];
    });
    return instance;
}

-(void)beginMonitor{
     
    self.isMonitoring = YES;  
    if(runLoopObserver){
     
        return;
    }
    
    dispatchSemaphore = dispatch_semaphore_create(0);
    //创建一个观察者
    CFRunLoopObserverContext context = {
     0,(__bridge void*)self,NULL,NULL};
    runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, &runLoopObserverCallBack, &context);
    
    //将观察者添加到主线程runloop的common模式下的观察中
    CFRunLoopAddObserver(CFRunLoopGetMain(), runLoopObserver, kCFRunLoopCommonModes);
    
    //创建子线程监控
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
     
        //子线程开启一个持续的loop用来进行监控
        while (YES) {
     
            long semaphoreWait = dispatch_semaphore_wait(dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, STUCKMONITORRATE * NSEC_PER_MSEC));
            if (semaphoreWait != 0) {
     
                if (!runLoopObserver) {
     
                    timeoutCount = 0;
                    dispatchSemaphore = 0;
                    runLoopActivity = 0;
                    return;
                }
                //两个runloop的状态,BeforeSources和AfterWaiting这两个状态区间时间能够检测到是否卡顿
                if (runLoopActivity == kCFRunLoopBeforeSources || runLoopActivity == kCFRunLoopAfterWaiting) {
     
                    //出现三次出结果
                    if (++timeoutCount < 3) {
     
                        continue;
                    }
//                    NSLog(@"monitor trigger");
                    //将堆栈信息上报服务器的代码放到这里
                    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
     
                        [self printStack];
                    });
                } //end activity
            }// end semaphore wait
            timeoutCount = 0;
        }// end while
    });
}

-(void)endMonitor{
     
    self.isMonitoring = NO;
    if (!runLoopObserver) {
     
        return;
    }
    CFRunLoopRemoveObserver(CFRunLoopGetMain(), runLoopObserver, kCFRunLoopCommonModes);
    CFRelease(runLoopObserver);
    runLoopObserver = NULL;
}

-(void)printStack{
     
   ....
}

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

Part 10. 临近 OOM,如何获取详细内存分配信息,分析内存问题?

OOM是由 iOS 的 Jetsam 机制导致的一种“另类”崩溃,并且日志无法通过信号捕捉到。JetSam 机制,指的就是操作系统为了控制内存资源过度使用而采用的一种资源管控机制。

通过 JetsamEvent 日志计算内存限制值

想要了解不同机器在不同系统版本的情况下,对 App 的内存限制是怎样的,有一种方法就是查看手机中以 JetsamEvent 开头的系统日志(我们可以从设置 -> 隐私 -> 分析中看到这些日志)。
在这些系统日志中,查找崩溃原因时我们需要关注 per-process-limit 部分的 rpages。

“rpages” : 89600,
“reason” : “per-process-limit”,

内存页大小的值,我们也可以在 JetsamEvent 开头的系统日志里找到,也就是 pageSize 的值。如下图红框部分所示:
可以看到,内存页大小 pageSize 的值是 16384。接下来,我们就可以计算出当前 App 的内存限制值:pageSize * rpages / 1024 /1024 =16384 * 89600 / 1024 / 1024 得到的值是 1400 MB,即 1.4G
iOS 系统是怎么发现 Jetsam 的呢?
iOS 系统会开启优先级最高的线程 vm_pressure_monitor 来监控系统的内存压力情况,并通过一个堆栈来维护所有 App 的进程。另外,iOS 系统还会维护一个内存快照表,用于保存每个进程内存页的消耗情况。
当监控系统内存的线程发现某 App 内存有压力了,就发出通知,内存有压力的 App 就会去执行对应的代理,也就是你所熟悉的 didReceiveMemoryWarning 代理。通过这个代理,你可以获得最后一个编写逻辑代码释放内存的机会。这段代码的执行,就有可能会避免你的 App 被系统强杀。
系统在强杀 App 前,会先做优先级判断。那么,这个优先级判断的依据是什么呢?iOS 系统内核里有一个数组,专门用于维护线程的优先级。这个优先级规定就是:内核用线程的优先级是最高的,操作系统的优先级其次,App 的优先级排在最后。并且,前台 App 程序的优先级是高于后台运行 App 的;线程使用优先级时,CPU 占用多的线程的优先级会被降低

除了 JetSamEvent 日志外,我们还可以通过 XNU 来获取内存的限制值。

通过 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 就是我们想要的进程内存限制值。

通过内存警告获取内存限制值

通过 XNU 的宏获取内存限制,需要有 root 权限,而 App 内的权限是不够的,所以正常情况下,作为 App 开发者你是看不到这个信息的。那么,如果你不想越狱去获取这个权限的话,还可以利用 didReceiveMemoryWarning 这个内存压力代理事件来动态地获取内存限制值。
OS 系统在强杀掉 App 之前还有 6 秒钟的时间,足够你去获取记录内存信息了。那么,如何获取当前内存使用情况呢?iOS 系统提供了一个函数 task_info, 可以帮助我们获取到当前任务的信息。关键代码如下:


struct mach_task_basic_info info;
mach_msg_type_number_t size = sizeof(info);
kern_return_t kl = task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)&info, &size);

代码中,task_info_t 结构里包含了一个 resident_size 字段,用于表示使用了多少内存。这样,我们就可以获取到发生内存警告时,当前 App 占用了多少内存。代码如下:


float used_mem = info.resident_size;
NSLog(@"使用了 %f MB 内存", used_mem / 1024.0f / 1024.0f)

定位内存问题信息收集

在这里,我主要是针对大内存的分配监控,所以只针对 scalable_zone 进行分析,同时也可以过滤掉很多小内存分配监控。
使用 scalable_zone 分配内存的函数都会调用 malloc_logger 函数,因为系统总是需要有一个地方来统计并管理内存的分配情况。
其他使用 scalable_zone 分配内存的函数的方法也类似,所有大内存的分配,不管外部函数是怎么包装的,最终都会调用 malloc_logger 函数。这样的话,问题就好解决了,你可以使用 fishhook 去 Hook 这个函数,加上自己的统计记录就能够通盘掌握内存的分配情况。出现问题时,将内存分配记录的日志捞上来,你就能够跟踪到导致内存不合理增大的原因了。

  • 实践:hook malloc_logger 的方法,来实现一个记录内存分配的小工具吧。
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* malloc_logger;

void malloc_logger_hook(uint32_t type,
                        uintptr_t zone,
                        uintptr_t size,
                        uintptr_t arg,
                        uintptr_t result,
                        uint32_t num_hot_frames_to_skip) {
     
    if(size < 1024)
    printf("type=%d, address=%lu, size=%lu, arg=%lu, result=%lu, num_hot_frames_to_skip=%d);", type, zone, size, arg, result, num_hot_frames_to_skip);
}

int main(int argc, char * argv[]) {
     
    @autoreleasepool {
     
        malloc_logger = malloc_logger_hook;
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

备注:根据IOS malloc的源码malloc_logger其实是个函数指针,所以不需要fishhook去hook。

Part 11. 日志监控:怎样获取 App 中的全量日志?

获取 NSLog 的日志

我们都知道,NSLog 其实就是一个 C 函数,函数声明是:
void NSLog(NSString *format, …);
它的作用是,输出信息到标准的 Error 控制台和系统日志(syslog)中。在内部实现上,它其实使用的是 ASL(Apple System Logger,是苹果公司自己实现的一套输出日志的接口)的 API,将日志消息直接存储在磁盘上。
ASL 会提供接口去查找所有的日志,通过 CocoaLumberjack 这个第三方日志库里的 DDASLLogCapture 这个类,我们可以找到实时捕获 NSLog 的方法。DDASLLogCapture 会在 start 方法里开启一个异步全局队列去捕获 ASL 存储的日志。
到这里,通过 ASL 获取 NSLog 日志的过程你就应该很清楚了。你可以直接使用 CocoaLumberjack 这个库通过 [DDASLLogCapture start] 捕获所有 NSLog 的日志。

为了使日志更高效,更有组织,在 iOS 10 之后,使用了新的统一日志系统(Unified Logging System)来记录日志,全面取代 ASL 的方式。
macOS 10.12 开始使用了统一日志系统,我们通过控制台应用程序或日志命令行工具,就可以查看到日志消息。但是,新的统一日志系统没有 ASL 那样的接口可以让我们取出全部日志,所以为了兼容新的统一日志系统,你就需要对 NSLog 日志的输出进行重定向。
因为 NSLog 本身就是一个 C 函数,而不是 Objective-C 方法,所以我们就可以使用 fishhook 来完成重定向的工作。具体的实现代码如下所示:

static void (*ori_nslog)(NSString * _Nonnull format, ...);

void hook_nslog(NSString * _Nonnull format, ...){
     
    // 可以在这里先进行自己的处理
    // 继续执行原
    va_list va;
    va_start(va, format);
    NSLogv(format, va);
    va_end(va);
}


int main(int argc, char * argv[]) {
     
    @autoreleasepool {
     
        struct rebinding nslog_rebinding = {
     "NSLog", hook_nslog, (void **)&ori_nslog};
        rebind_symbols(&nslog_rebinding, 1);
        
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

NSLog 最后写文件时的句柄是 STDERR,所以 NSLog 最终将错误日志进行写操作的时候也会使用 STDERR 句柄,而 dup2 函数是专门进行文件重定向的,那么也就有了另一个不使用 fishhook 还可以捕获 NSLog 日志的方法。你可以使用 dup2 重定向 STDERR 句柄,使得重定向的位置可以由你来控制,关键代码如下:

int fd = open(path, (O_RDWR | O_CREAT), 0644);
dup2(fd, STDERR_FILENO);

这样,我们就能够获取到各个系统版本的 NSLog 了。那么,通过其他方式打的日志,我们怎么才能获取到呢?现在与日志相关的第三方库里面,使用最多的就是 CocoaLumberjack。
DDFileLogger 是用来保存日志到文件的,还提供了返回 CocoaLumberjack 日志保存文件路径的方法,使用方法如下:


DDFileLogger *fileLogger = [[DDFileLogger alloc] init];
NSString *logDirectory = [fileLogger.logFileManager logsDirectory];

其中,logDirectory 方法可以获取日志文件的目录路径。有了目录以后,我们就可以获取到目录下所有的 CocoaLumberjack 的日志了,也就达到了我们要获取 CocoaLumberjack 所有日志的目的。

Part 12. 性能监控:衡量 App 质量的那把尺

线下性能监控王牌 Instruments

关于线下性能监控,苹果公司官方就有一个性能监控工具 Instruments。它是一款被集成在 Xcode 里,专门用来在线下进行性能分析的工具。如下图:
IOS 开发高手课 学习笔记(第二部分)_第1张图片

从整体架构来看,Instruments 包括 Standard UI 和 Analysis Core 两个组件,它的所有工具都是基于这两个组件开发的。而且,你如果要开发自定义的性能分析工具的话,完全基于这两个组件就可以实现。
开发一款自定义 Instruments 工具,主要包括以下这几个步骤:
在 Xcode 中,点击 File > New > Project;
在弹出的 Project 模板选择界面,将其设置为 macOS;
选择 Instruments Package,点击后即可开始自定义工具的开发了

在线下性能监控中,Instruments 可以说是王者,但却对线上监控无能为力。那么,对于线上的性能监控,我们应该怎么实现呢?

线上性能监控
对于线上性能监控,我们需要先明白两个原则:监控代码不要侵入到业务代码中;采用性能消耗最小的监控方案。
线上性能监控,主要集中在 CPU 使用率、FPS 的帧率和内存这三个方面。接下来,我们就分别从这三个方面展开讨论吧。

CPU 使用率的线上监控方法
在 iOS 系统中,你可以在 usr/include/mach/thread_info.h 里看到线程基本信息的结构体,其中的 cpu_usage 就是 CPU 使用率。结构体的完整代码如下所示:

struct thread_basic_info {
     
  time_value_t    user_time;     // 用户运行时长
  time_value_t    system_time;   // 系统运行时长
  integer_t       cpu_usage;     // CPU 使用率
  policy_t        policy;        // 调度策略
  integer_t       run_state;     // 运行状态
  integer_t       flags;         // 各种标记
  integer_t       suspend_count; // 暂停线程的计数
  integer_t       sleep_time;    // 休眠的时间
};

因为每个线程都会有这个 thread_basic_info 结构体,所以接下来的事情就好办了,你只需要定时(比如,将定时间隔设置为 2s)去遍历每个线程,累加每个线程的 cpu_usage 字段的值,就能够得到当前 App 所在进程的 CPU 使用率了。实现代码如下:

#import 
#import 
#import 
+(integer_t)cpuUsage{
     
    thread_act_array_t threads;
    mach_msg_type_number_t threadCount;
    
    const task_t thisTask = mach_task_self();
    kern_return_t kr = task_threads(thisTask, &threads, &threadCount);
    if (kr != KERN_SUCCESS) {
     
        return 0;
    }
    
    integer_t totalUsage = 0;
    for(int i=0; i<threadCount; ++i){
     
        thread_info_data_t threadInfo;
        thread_basic_info_t threadBaseInfo;
        mach_msg_type_number_t threadInfoCount = THREAD_INFO_MAX;
        if(thread_info((thread_act_t)threads[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount) == KERN_SUCCESS){
            // 获取 CPU 使用率
            threadBaseInfo = (thread_basic_info_t)threadInfo;
            if (!(threadBaseInfo->flags & TH_FLAGS_IDLE)) {
     
                integer_t cpuUsage = threadBaseInfo->cpu_usage / 10;
                if (cpuUsage > 80) {
     
                    //cup 消耗大于设置值时打印和记录堆栈
                }
                totalUsage += cpuUsage;
            }
        }
    }
    
    NSLog(@"cpuUsage=%d", totalUsage);
    
    return totalUsage;
}

FPS 线上监控方法

和前面对 CPU 使用率和内存使用量的监控不同,iOS 系统中没有一个专门的结构体,用来记录与 FPS 相关的数据。但是,对 FPS 的监控也可以比较简单的实现:通过注册 CADisplayLink 得到屏幕的同步刷新率,记录每次刷新时间,然后就可以得到 FPS。具体的实现代码如下:

#import 

- (void)start {
     
    lastTimeStamp = 0;
    total = 0;
    
    self.dLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(fpsCount:)];
    [self.dLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}

-(void)stop{
     
    [self.dLink removeFromRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
    self.dLink = nil;
}

// 方法执行帧率和屏幕刷新率保持一致
- (void)fpsCount:(CADisplayLink *)displayLink {
     
    if (lastTimeStamp == 0) {
     
        lastTimeStamp = self.dLink.timestamp;
    } else {
     
        total++;
        // 开始渲染时间与上次渲染时间差值
        NSTimeInterval useTime = self.dLink.timestamp - lastTimeStamp;
        if (useTime < 1) return;
        lastTimeStamp = self.dLink.timestamp;
        // fps 计算
        self.fps = total / useTime;
        total = 0;
    }
}

内存使用量的线上监控方法

苹果公司介绍说 phys_footprint 才是实际使用的物理内存。内存信息存在 task_info.h (完整路径 usr/include/mach/task.info.h)文件的 task_vm_info 结构体中,其中 phys_footprint 就是物理内存的使用,而不是驻留内存 resident_size。结构体里和内存相关的代码如下:


struct task_vm_info {
     
  mach_vm_size_t  virtual_size;       // 虚拟内存大小
  integer_t region_count;             // 内存区域的数量
  integer_t page_size;
  mach_vm_size_t  resident_size;      // 驻留内存大小
  mach_vm_size_t  resident_size_peak; // 驻留内存峰值

  ...

  /* added for rev1 */
  mach_vm_size_t  phys_footprint;     // 物理内存

  ...

OK,类似于对 CPU 使用率的监控,我们只要从这个结构体里取出 phys_footprint 字段的值,就能够监控到实际物理内存的使用情况了。具体实现代码如下:

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

Part 13. 远超你想象的多线程的那些坑

目前,在 iOS 开发中,我们经常会用到系统提供的方法来使用多线程技术开发 App,期望可以充分利用硬件资源来提高 App 的运行效率。但是,我们不禁会想到,像 UIKit 这样的前端框架并没有使用多线程技术。而 AFNetworking 2.0(网络框架)、FMDB(第三方数据库框架)这些用得最多的基础库,使用多线程技术时也非常谨慎。那么,你有没有想过为什么 UIKit 不是线程安全的,UI 都要在主线程上操作。在 AFNetworking 2.0 中,把每个请求都封装成了单独的 NSOperationQueue,再由 NSOperationQueue 根据当前的 CPU 数量和系统负载来控制并发。那么,为什么 AFNetworking 2.0 没有为每个请求创建一个线程,而只是创建了一个线程,用来接收 NSOperationQueue 的回调呢?FMDB 只通过 FMDatabaseQueue 开启了一个线程队列,来串行地操作数据库。这,又是为什么呢?让我说,这就是因为多线程技术有坑。特别是 UIKit 干脆就做成了线程不安全,只能在主线程上操作。
接下来,我们就一起来看看多线程技术常见的两个大坑,常驻线程和并发问题,分别是从何而来,以及如何避免吧。

常驻线程

我们先说说多线程技术的第一个坑:常驻线程。常驻线程,指的就是那些不会停止,一直存在于内存中的线程。我们在文章开始部分,说到的 AFNetworking 2.0 专门创建了一个线程来接收 NSOperationQueue 的回调,这个线程其实就是一个常驻线程。接下来,我们就看看常驻线程这个问题是如何引起的,以及是否有对应的解决方案。我们先通过 AFNetworking 2.0 创建常驻线程的代码,来看一下这个线程是怎么创建的。

+ (void)networkRequestThreadEntryPoint:(id)__unused object {
     
    @autoreleasepool {
     
        // 先用 NSThread 创建了一个线程
        [[NSThread currentThread] setName:@"AFNetworking"];
        // 使用 run 方法添加 runloop
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}

这里我需要先和你说明白一个问题,通过 NSRunLoop 添加 runloop 的方法有三个:
run 方法。通过 run 方法添加的 runloop ,会不断地重复调用 runMode:beforeDate: 方法,来保证自己不会停止。
runUntilDate: 和 runMode:beforeDate 方法。这两个方法添加的 runloop,可以通过指定时间来停止 runloop。

如果你有 30 个库,每个库都常驻一个线程。那这样做,不但不能提高 CPU 的利用率,反而会降低程序的执行效率。也就是说,这样做的话,就不是充分利用而是浪费 CPU 资源了。

说到这里,既然常线程是个坑,那为什么 AFNetworking 2.0 库还要这么做呢?
其实,这个问题的根源在于 AFNetworking 2.0 使用的是 NSURLConnection,而 NSURLConnection 的设计上存在些缺陷。NSURLConnection 发起请求后,所在的线程需要一直存活,以等待接收 NSURLConnectionDelegate 回调方法。但是,网络返回的时间不确定,所以这个线程就需要一直常驻在内存中。
但是,AFNetworking 在 3.0 版本时,使用苹果公司新推出的 NSURLSession 替换了 NSURLConnection,从而避免了常驻线程这个坑。NSURLSession 可以指定回调 NSOperationQueue,这样请求就不需要让线程一直常驻在内存里去等待回调了。实现代码如下:

self.operationQueue = [[NSOperationQueue alloc] init];
self.operationQueue.maxConcurrentOperationCount = 1;
self.session = [NSURLSession sessionWithConfiguration:self.sessionConfiguration delegate:self delegateQueue:self.operationQueue];

如果你需要确实需要保活线程一段时间的话,可以选择使用 NSRunLoop 的另外两个方法 runUntilDate: 和 runMode:beforeDate,来指定线程的保活时长。让线程存活时间可预期,总比让线程常驻,至少在硬件资源利用率这点上要更加合理。或者,你还可以使用 CFRunLoopRef 的 CFRunLoopRun 和 CFRunLoopStop 方法来完成 runloop 的开启和停止,达到将线程保活一段时间的目的。

并发

并发是多线程技术的第二个大坑。
例如,在进行数据读写操作时,总是需要一段时间来等待磁盘响应的,如果在这个时候通过 GCD 发起了一个任务,那么 GCD 就会本着最大化利用 CPU 的原则,会在等待磁盘响应的这个空档,再创建一个新线程来保证能够充分利用 CPU。而如果 GCD 发起的这些新任务,都是类似于数据存储这样需要等待磁盘响应的任务的话,那么随着任务数量的增加,GCD 创建的新线程就会越来越多,从而导致内存资源越来越紧张,等到磁盘开始响应后,再读取数据又会占用更多的内存。结果就是,失控的内存占用会引起更多的内存问题。这种情况最典型的场景就是数据库读写操作。FMDB是一个开源的第三方数据库框架,通过 FMDatabaseQueue 这个核心类,将与读写数据库相关的磁盘操作都放到一个串行队列里执行,从而避免了线程创建过多导致系统资源紧张的情况。
总结来讲,类似数据库这种需要频繁读写磁盘操作的任务,尽量使用串行队列来管理,避免因为多线程并发而出现内存问题。

内存问题

在并发这部分,我一直在和你说线程开多了会有内存问题,那到底是什么内存问题呢?为什么会有内存问题呢?我们知道,创建线程的过程,需要用到物理内存,CPU 也会消耗时间。而且,新建一个线程,系统还需要为这个进程空间分配一定的内存作为线程堆栈。堆栈大小是 4KB 的倍数。在 iOS 开发中,主线程堆栈大小是 1MB,新创建的子线程堆栈大小是 512KB。除了内存开销外,线程创建得多了,CPU 在切换线程上下文时,还会更新寄存器,更新寄存器的时候需要寻址,而寻址的过程还会有较大的 CPU 消耗。
所以,线程过多时内存和 CPU 都会有大量的消耗,从而导致 App 整体性能降低,使得用户体验变成差。CPU 和内存的使用超出系统限制时,甚至会造成系统强杀。这种情况对用户和 App 的伤害就更大了。

Part 14 怎么减少 App 电量消耗?

如何获取电量?

在 iOS 中,IOKit framework 是专门用于跟硬件或内核服务通信的。所以,我们可以通过 IOKit framework 来获取硬件信息,进而获取到电量消耗信息。在使用 IOKit framework 时,你需要:首先,把 IOPowerSources.h、IOPSKeys.h 和 IOKit 这三个文件导入到工程中;然后,把 batteryMonitoringEnabled 置为 true;最后,通过如下代码获取 1% 精确度的电量信息。

#import "IOPSKeys.h"
#import "IOPowerSources.h"

-(double) getBatteryLevel{
     
    // 返回电量信息
    CFTypeRef blob = IOPSCopyPowerSourcesInfo();
    // 返回电量句柄列表数据
    CFArrayRef sources = IOPSCopyPowerSourcesList(blob);
    CFDictionaryRef pSource = NULL;
    const void *psValue;
    // 返回数组大小
    int numOfSources = CFArrayGetCount(sources);
    // 计算大小出错处理
    if (numOfSources == 0) {
     
        NSLog(@"Error in CFArrayGetCount");
        return -1.0f;
    }

    // 计算所剩电量
    for (int i=0; i<numOfSources; i++) {
     
        // 返回电源可读信息的字典
        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

如何诊断电量问题?

CPU 是耗电的大头,通过监控CPU使用率,这部分可参考性能监控中cpu使用率部分:

integer_t cpuUsage = threadBaseInfo->cpu_usage / 10;                
if (cpuUsage > 90) {
                         
//cup 消耗大于 90 时打印和记录堆栈

优化电量

对于大量数据的复杂计算,应该把数据传到服务器去处理,如果必须要在 App 内处理复杂数据计算,可以通过 GCD 的 dispatch_block_create_with_qos_class 方法指定队列的 Qos 为 QOS_CLASS_UTILITY,将计算工作放到这个队列的 block 里。在 QOS_CLASS_UTILITY 这种 Qos 模式下,系统针对大量数据的计算,以及复杂数据处理专门做了电量优化。
除了 CPU 会影响耗电,对电量影响较大的因素还有哪些呢?
除了 CPU,I/O 操作也是耗电大户。任何的 I/O 操作,都会破坏掉低功耗状态。那么,针对 I/O 操作要怎么优化呢?业内的普遍做法是,将碎片化的数据磁盘存储操作延后,先在内存中聚合,然后再进行磁盘存储。碎片化的数据进行聚合,在内存中进行存储的机制,可以使用系统自带的 NSCache 来完成。
SDWebImage 图片加载框架,在图片的读取缓存处理时没有直接使用 I/O,而是使用了 NSCache。使用 NSCache 的相关代码如下:

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

- (UIImage *)imageFromDiskCacheForKey:(NSString *)key {
     
    // 检查 NSCache 里是否有
    UIImage *image = [self imageFromMemoryCacheForKey:key];
    if (image) {
     
        return image;
    }
    // 从磁盘里读
    UIImage *diskImage = [self diskImageForKey:key];
    if (diskImage && self.shouldCacheImagesInMemory) {
     
        NSUInteger cost = SDCacheCostForImage(diskImage);
        [self.memCache setObject:diskImage forKey:key cost:cost];
    }
    return diskImage;
}

CPU 和 I/O 这两大耗电问题都解决后,还有什么要注意的呢?

苹果公司专门维护了一个电量优化指南“Energy Efficiency Guide for iOS Apps”,分别从 CPU、设备唤醒、网络、图形、动画、视频、定位、加速度计、陀螺仪、磁力计、蓝牙等多方面因素提出了电量优化方面的建议。
同时,苹果公司在 2017 年 WWDC 的 Session 238 也分享了一个关于如何编写节能 App 的主题“Writing Energy Efficient Apps”。

未完待续,第三部分将学习其它内容。

你可能感兴趣的:(IOS开发,ios,objective-c)