Bo2SS

Bo2SS

1 基础篇(下)

今天要分享的是:基础篇—— 上线阶段,主要设计崩溃、卡顿、内存、日志、性能、线程🧵和电量🔋的监控。

image

12 | iOS 崩溃千奇百怪,如何全面监控?#

先看看几个常见的崩溃原因

  • 数组问题:数组越界,或者,给数组添加了 nil 元素。
  • 多线程问题:在子线程中进行 UI 更新可能会发生崩溃,比如有一个线程在置空数据的同时,另一个线程在读取这个数据。
  • 主线程无响应:主线程无响应时长超出系统规定时,会被 Watchdog 杀掉,对应的异常编码是 0x8badf00d。
  • 访问野指针:野指针指向的是一个已删除的对象,它是最常见、却又最难定位的一种崩溃情况。

程序崩溃对用户的伤害是最大的,所以崩溃率(也就是一段时间内崩溃次数与启动次数之比)成为了优先级最高的技术指标。

根据是否可以通过信号捕获,崩溃信息可分为两类:

  1. 可以通过信号捕获:数组越界、野指针、KVO 问题、NSNotification 线程问题等崩溃信息。
  2. 无法通过信号捕获:后台任务超时、内存被打爆、主线程卡顿超阈值等信息。

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

简单粗暴方法:Xcode > Product > Archive,勾选 “Upload your app’s symbols to receive symbolicated reports from Apple”,以后就可以在 Xcode 的 Archive 里看到符号化后的崩溃日志了。

第三方开源库:PLCrashReporterFabricBugly。第一种需要有自己的服务器,后两种则适用于没有服务端开发能力或者对数据不敏感的公司。

  • 监控原理:对各种信号进行注册,捕获到异常信号后,在处理方法 handleSignalException 里通过 backtrace_symbols 方法获取当前的堆栈信息,将堆栈信息先保存在本地,下次启动时就可以上传崩溃日志了。

收集信号捕获不到的崩溃信息#

背景知识:由于系统限制,系统强杀抛出的信号无法被捕获到。

带着 5 个问题:后台容易崩溃的原因是什么呢?如何避免后台崩溃?怎么去收集后台信号捕获不到的那些崩溃信息呢?还有哪些信号捕获不到的崩溃情况?怎样监控其他无法通过信号捕获的崩溃信息?

(一)后台容易崩溃的原因是什么呢

先介绍下 iOS 后台保活的 5 种方式:

  1. Background Mode:通常只有地图、音乐、VoIP 类 App 才能通过审核。
  2. Background Fetch:唤醒时间不稳定,用户可以在系统设置里关闭,所以使用场景较少。
  3. Silent Push:静默推送,会在后台唤起 App 30 秒。它的优先级较低,会调用 application:didReceiveRemoteNotifiacation:fetchCompletionHandler: 这个 delegate,同普通的 remote push notification(远程消息推送)调用的 delegate。
  4. PushKit:会在后台唤起 App 30 秒,主要用于提升 VoIP 应用的体验。
  5. Background Task:App 退后台后,默认使用该方式,所以使用较多。

Background Task 这种方式,系统提供了 beginBackgroundTaskWithExpirationHandler 方法来延长后台执行时间,使用如下:

- (void)applicationDidEnterBackground:(UIApplication *)application {
    self.backgroundTaskIdentifier = [application beginBackgroundTaskWithExpirationHandler:^(void) {
        [self yourTask];
    }];
}

在这段代码中,yourTask 任务最多执行 3 分钟,任务完成后就挂起 App,但如果任务在 3 分钟内没有执行完,系统会强制杀掉进程,从而造成崩溃,这就是 App 退后台容易出现崩溃的原因

(二)如何避免后台崩溃呢

严格控制后台的数据读写操作。比如,先判断需要处理的数据大小,如果数据过大,也就是在后台限制时间内也处理不完的话,可以考虑下次启动或后台唤醒程序时再处理。

(三)怎么去收集后台信号捕获不到的崩溃信息呢

采用 Background Task 方式时,先设置一个计时器,在接近 3 分钟(beginBackgroundTaskWithExpirationHandler 让后台保活 3 分钟)时判断后台程序是否还在执行,如果还在执行,则判定该程序即将后台崩溃,此时马上进行上报、记录。

(四)还有哪些信号捕获不到的崩溃情况

主要是内存被打爆和主线程卡顿超时被 Watchdog 杀掉这两种情况。

(五)怎样监控其他无法通过信号捕获的崩溃信息

和监控后台崩溃类似,在临近阈值时做处理,详见下两课~

采集到崩溃信息后如何分析并解决崩溃问题呢?#

崩溃日志中,主要包含的信息有:

  1. 异常信息:异常类型、异常编码、异常的线程;
  2. 线程回溯:崩溃时的方法调用栈。
  3. 进程信息:如崩溃报告唯一标识符、唯一键值、设备标识;
  4. 基本信息:崩溃发生的日期、iOS 版本;

通常分析过程:

  1. 分析「异常信息」里的异常线程,在「线程回溯」里分析异常线程的方法调用栈。从符号化后的方法调用栈里,可以完整地看到方法调用的过程,方法调用栈顶就是最后导致崩溃的方法调用。
  2. 参考异常编码。这里列出了 44 种异常编码,常见的三种是:0x8badf00d(App 在一定时间内无响应而被 watchdog 杀掉,详见下一课)、0xdeadfa11(App 被用户强制退出)、0xc00010ff(因为 App 运行造成设备温度太高而被杀掉,详见 18 电量优化那一课)。

⚠️:有些问题仅仅通过堆栈还无法分析出来,这时可以借助崩溃前用户相关行为系统环境状况的日志来进一步分析,详见 15 日志监控那一课。

思考:怎样才能够让崩溃信息的收集效率更高,丢失率更低?如何能够收集到更多的崩溃信息?特别是系统强杀带来的崩溃。

13 | 如何利用 RunLoop 原理去监控卡顿?#

卡顿问题,就是在主线程上无法响应用户交互的问题,其原因包括:UI 绘制量过大;在主线程上做网络同步请求,做大量的 IO 操作;运算量过大,CPU 持续高占用;死锁和主子线程抢锁。

NSRunLoop 入手(线程的消息事件依赖于 NSRunLoop),可以知道主线程上调用了哪些方法;通过监听 NSRunLoop 的状态,可以发现调用方法的执行时间是否过长,从而可以监控卡顿情况。

下面先介绍介绍 RunLoop 的原理~

RunLoop 原理#

目的:当有事件要处理时保持线程忙,当没有事件要处理时让线程休眠。

任务:监听输入源,进行调度处理。

接收两种类型的输入源(输入设备、网络、周期性时间、延迟时间、异步回调):

  1. 另一个线程或者不同应用的异步消息;
  2. 预订时间或者重复间隔的同步事件。

应用举例:将繁重、不紧急、会占用大量 CPU 的任务(比如图片加载),放到空闲的 RunLoop 模式里执行,避开 RunLoop 模式是 UITrackingRunLoopMode 时执行。 UITrackingRunLoopMode 模式:

  • 用户进行滚动操作时会切换到的 RunLoop 模式;
  • 避免在该模式下执行繁重的 CPU 任务,可以提升用户操作体验。

工作原理

在 iOS 里,RunLoop 对象由 CFRunLoop 实现,整个过程可以总结为下图,具体可查看 CFRunLoop 源码

RunLoop 过程 ——《极客时间》

loop 的六个状态#

代码定义如下:

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry ,         // 进入 loop
    kCFRunLoopBeforeTimers ,  // 触发 Timer 回调
    kCFRunLoopBeforeSources , // 触发 Source0 回调
    kCFRunLoopBeforeWaiting , // 等待 mach_port 消息
    kCFRunLoopAfterWaiting ,  // 接收 mach_port 消息
    kCFRunLoopExit ,          // 退出 loop
    kCFRunLoopAllActivities   // loop 所有状态改变
}

RunLoop 的线程受阻情况:

  1. 进入睡眠前方法的执行时间过长(而导致无法进入睡眠);
  2. 线程唤醒后接收消息时间过长(而无法进入下一步)。

如果这个线程是主线程,表现出来的就是卡顿。

所以,如果要利用 RunLoop 原理来监控卡顿,就要关注这两个 loop 状态:

  1. kCFRunLoopBeforeSources:进入睡眠之前触发 Source0 回调;
  2. kCFRunLoopAfterWaiting:唤醒后接收 mach_port 消息。

如何检查卡顿?#

三步走:

  1. 创建一个 CFRunLoopObserverContext 观察者;
  2. 将观察者添加到主线程 RunLoop 的 common 模式下观察;
  3. 创建一个持续的子线程监控主线程的 RunLoop 状态。卡顿判断:进入睡眠前的 kCFRunLoopBeforeSources 状态,或者唤醒后的 kCFRunLoopAfterWaiting 状态,在设置的时间阈值内一直没有变化。

接下来,dump 出堆栈信息,即可进一步分析卡顿原因。

⚠️:触发卡顿的时间阈值,可以根据 WatchDog 机制来设置。

  • 启动(Launch):20s;
  • 恢复(Resume):10s;
  • 挂起(Suspend):10s;
  • 退出(Quit):6s;
  • 后台(Background):3min(在 iOS 7 之前,每次申请 10min; 之后改为每次申请 3min,可连续申请,最多申请到 10min)。

PS:触发卡顿的时间阈值要小于 WatchDog 的限制时间

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

1)直接调用系统函数(用 signal 获取错误信息)。优点是性能消耗小;缺点是只能获取简单信息,无法配合 dSYM(符号表文件)来定位出现问题的代码。因为性能较好,它适用于观察大盘统计卡顿情况,不适合找卡顿原因的具体场景。

2)直接用 PLCrashReporter开源库。特点是能够定位到问题代码的具体位置,而且在性能消耗上做了优化。

思考:为什么要将卡顿监控放到线上做呢?主要是为了更大范围的收集问题。总有一些卡顿问题,是由于少数用户的数据异常导致的。

相关资料深入理解 RunLoop——ibireme

14 | 临近 OOM,如何获取详细内存分配信息,分析内存问题?#

OOM(Out of Memory):App 占用内存达到系统对单个 App 占用内存的上限后,被系统强杀的现象。

  • 它是由 iOS 的 Jetsam 机制(操作系统为了控制内存资源过度使用而采用的一种资源管控机制)导致的一种 “另类” 崩溃;
  • 日志无法通过信号捕捉到它。

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

查看手机中以 JetsamEvent 开头的系统日志(手机设置 > 隐私 > 分析与改进 > 分析数据),可以了解不同机器,在不同系统版本下,对 App 的内存限制。

关注上面系统日志中崩溃原因为 per-process-limit 对应的 rpages:

  • per-process-limit:App 占用的内存超过了系统对单个 App 的内存限制;
  • rpages:App 占用的内存页数量。

⚠️:

  • 内存页大小的值,即日志里的 pageSize 值。
  • 被强杀掉的 App 无法获取到系统级日志,只能通过线下设备获取。

iOS 系统监控 Jetsam

  1. 系统开启优先级最高的线程 vm_pressure_monitor 监控系统的内存压力情况,通过一个堆栈维护所有 App 的进程。另外,还维护一个内存快照表,用于保存每个进程内存页的消耗情况。
  2. 当 vm_pressure_monitor 线程发现某 App 内存有压力时,就会发出通知,内存有压力的 App 执行对应的 didReceiveMemoryWarning 代理(这是释放内存的机会,可以避免 App 被系统强杀)。

优先级判断依据(系统在强杀 App 前,会先做优先级判断):

  • 内核用 > 操作系统用 > App 用;
  • 前台 App > 后台运行 App;
  • 线程使用优先级时,CPU 占用多的线程的优先级会被降低。

通过 XNU 获取内存限制值#

通过 XNU 的宏获取 memorystatus_priority_entry 这个结构体,可以得到进程的优先级和内存限制值。

⚠️:通过 XNU 的宏获取内存限制,需要有 root 权限,而 App 内的权限是不够的,所以正常情况下,App 开发者是看不到这个信息的...

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

利用 didReceiveMemoryWarning 这个内存压力代理事件来动态地获取内存限制值,在代理事件里:

  • 先通过 iOS 系统提供的 task_info 函数, 获取当前任务的信息(task_info_t 结构体);
  • 再通过 task_info_t 结构里的 resident_size 字段,即可获取当前 App 占用的内存。

定位内存问题信息收集#

获取到内存占用量还不够,还需要知道是谁分配的内存,这样才可以精确定位到问题的关键。而所有大内存的分配,不管外部函数是怎么包装的,最终都会调用 malloc_logger 函数。

  • 内存分配函数 malloc 和 calloc 等默认使用的是 nano_zone;
  • nano_zone 是 256B 以下小内存的分配,大于 256B 的内存分配会使用 scalable_zone;
  • 使用 scalable_zone 分配内存的函数都会调用 malloc_logger 函数,系统通过它来统计并管理内存的分配情况。

所以,可以使用 fishhook 去 Hook 这个函数,然后加上自己的统计记录,就可以掌握内存的分配情况了。

PS:除内存过大被系统强杀外,还有以下三种内存问题:

  • 访问未分配的内存: XNU 会报 EXC_BAD_ACCESS 错误,信号为 SIGSEGV Signal #11 。
  • 没有遵守权限访问内存:内存页面的权限标准类似 UNIX 文件权限。如果去写只读权限的内存页面会出现错误,XNU 会发出 SIGBUS Signal #7 信号。
  • 访问已分配但未提交的内存:XNU 会拦截分配物理内存,出现问题的线程分配内存页时会被冻结。

前两种问题可以通过崩溃日志获取到,参考 12 崩溃那一课。

15 | 日志监控:怎样获取 App 中的全量日志?#

背景:前面分享了崩溃、卡顿、内存问题的监控,一旦监控到问题,就需要记录下问题的详细信息,形成日志告知开发者,这样开发者才能够从这些日志中定位问题。

全量日志的定义:在 App 里记录的所有日志,如用于记录用户行为和关键操作的日志。

全量日志的作用:便于开发者快速、精准地定位各种复杂问题,提高解决问题的效率。

然而,一个 App 很有可能是由多个团队共同开发维护的,不同团队使用的日志库由于历史原因可能都不一样,要么是自己开发的,要么就是使用了第三方日志库。那么,怎样使用不侵入的方式去获取 App 里的所有日志呢?

下面介绍 NSLogCocoaLumberjack 日志的获取方法,这两种打日志的方式基本覆盖了大部分场景。

获取 NSLog 的日志#

NSLog 其实就是一个 C 函数 void NSLog(NSString *format, ...); ,作用是输出信息到标准的 Error 控制台系统日志中。

** 如何获取 NSLog 的日志呢?** 方法有三个:

1)使用 ASL 提供的接口。

在 iOS 10 之前,NSLog 内部使用的是 ASL(Apple System Logger,苹果公司自己实现的一套输出日志系统)的 API,将日志消息直接存储在磁盘上。

借助第三方库 CocoaLumberjack 的 [DDASLLogCapture start] 命令,捕获所有 NSLog 的日志,记录为 CocoaLumberjack 的日志。

捕获原理

  • 在日志被保存到 ASL 的数据库时,syslogd(系统里用于接收分发日志消息的日志守护进程) 会发出一条通知。
  • 通过 notify_register_dispatch 注册该通知 com.apple.system.logger.message(kNotifyASLDBUpdate 宏),即日志被保存到 ASL 数据库时发出的进程间的系统通知。
  • 在收到通知后,利用 ASL 提供的接口(CocoaLumberjack 封装了 asl_search、asl_next、aslMessageReceived: 等方法)迭代处理所有新日志,最终记录为 CocoaLumberjack 的日志

其主要方法的代码实现如下:

+ (void)captureAslLogs {
    @autoreleasepool {
        ...
        notify_register_dispatch(kNotifyASLDBUpdate, &notifyToken, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0),^(int token) {
            @autoreleasepool {
                ...
                // 利用进程标识兼容在模拟器情况时其他进程日志无效通知
                [self configureAslQuery:query];

                // 迭代处理所有新日志(发过来的这一条通知可能会有多条日志)
                aslmsg msg;
                aslresponse response = asl_search(NULL, query);

                while ((msg = asl_next(response))) {
                    // 记录日志(记录为CocoaLumberjack的日志,默认为Verbose级别)
                    [self aslMessageReceived:msg];

                    lastSeenID = (unsigned long long)atoll(asl_get(msg, ASL_KEY_MSG_ID));
                }
                asl_release(response);
                asl_free(query);

                if (_cancel) {
                    notify_cancel(token);
                    return;
                }
            }
        });

PS:

  • 记录为 CocoaLumberjack 的日志后方便进一步获取,详见下一节。其日志级别包括两类:第一类是 Verbose 和 Debug ,属于调试级;第二类是 Info、Warn、Error ,属于正式级,需要持久化存储,适用于记录更重要的信息。这里默认为 Verbose 级别。
  • 使用 NSLog 调试,会发生 IO 磁盘操作,所以频繁使用 NSLog 不利于性能。
  • 还有很多跨进程通知,如在系统磁盘空间不足时,会发出 com.apple.system.lowdiskspace 通知(kNotifyVFSLowDiskSpace 宏)。

2)通过 fishhook 来 hook NSLog 方法。

为了使日志更高效、更有组织,在 iOS 10 之后,使用了新的统一日志系统(Unified Logging System)来记录日志,全面取代 ASL 的方式。

统一日志系统

  • 把日志集中存放在内存和数据库里,提供单一、高效和高性能的接口去获取系统所有级别的消息传递;
  • 但没有 ASL 那样的接口来取出全部日志。

所以,为了兼容新的统一日志系统,需要对 NSLog 日志的输出进行重定向。又因为 NSLog 本身就是一个 C 函数,而不是 Objective-C 方法,所以使用 fishhook 来完成重定向的工作:

  • 通过 struct rebinding 定义原方法和重定向的方法。
  • 在重定向的方法中:
    • 可以先进行自己的处理,比如将日志的输出重新输出到持久化存储系统里;
    • 接着调用 NSLog 也会调用的 NSLogv 方法进行原 NSLog 方法的调用,也可以使用 fishhook 提供的原方法调用方式。

3)用 dup2 函数重定向 STDERR 句柄。

NSLog 最后写文件时的句柄是 STDERR(standard error,系统的错误日志都会通过 STDERR 句柄来记录),苹果对 NSLog 的定义就是记录错误的信息

dup2 函数是专门进行文件重定向的,如重定向 STDERR 句柄,关键代码如下:

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

其中,path_to_file 就是自定义的重定向输出的文件地址。

好了,各个系统版本的 NSLog 日志都已经能够被获取到了。那通过其他方式打的日志,该如何获取呢?

下面聊聊获取主流的第三方日志库 CocoaLumberjack 日志的思路,其他第三库大多是封装了 CocoaLumberjack,所以思路类似。

获取 CocoaLumberjack 日志#

CocoaLumberjack 组成如下图所示:

image

  • DDLogFormatter:用于格式化日志的格式。
  • DDLogMessage:对日志消息的封装。
  • DDLog:全局的单例类,会保存遵守 DDLogger 协议的 logger。
  • DDLogger 协议:由 DDAbstractLogger 实现的。有 4 种 logger 继承于 DDAbstractLogger:
    • DDTTYLogger:输出日志到控制台。
    • DDASLLogger:捕获 NSLog 记录到 ASL 数据库的日志。
    • DDFileLogger:保存日志到文件。通过[fileLogger.logFileManager logsDirectory]可以获取保存的文件路径,从而可以获取到 CocoaLumberjack 的所有日志。
    • DDAbstractDatabaseLogger:数据库操作的抽象接口。

收集全量日志,可以提高分析和解决问题的效率,赶快去试试吧?

16 | 性能监控:衡量 App 质量的那把尺#

目的:主动、高效地发现性能问题,避免 App 质量进入无人监管的失控状态。

监控方式:线下、线上。

线下性能监控:官方王牌 Instruments#

Instruments 被集成在 Xcode 里,如下图所示,它包含了各种性能检测工具,如耗电量、内存泄漏、网络情况等:

Instruments 提供的各种性能检测工具 ——《极客时间》

从整体架构来看,Instruments 包括 Standard UI(标准界面)和 Analysis Core(分析核心)两个组件,它的所有工具都是基于这两个组件开发的。基于这两个组件,你也可以开发自定义的 Instruments 工具(Instruments 10+):

  1. Xcode > File > New > Project > macOS > Instruments Package,生成一个.instrpkg 文件;
  2. 配置该文件,最主要的是要完成 Standard UI 和 Analysis Core 的配置;
  3. 参考苹果官方提供的大量代码片段,详见 Instruments Developer Help

Analysis Core 的工作原理

主要就是收集和处理数据的过程,分三步:

1)处理我们配置好的 XML 数据表(用于可视化显示),并申请存储空间 store。

2)store 找相应的数据提供者,如果不能直接找到,就会通过其他 store 的输入信号进行合成。

⚠️:使用 os_signpost API,来获取数据,可参考 WWDC 2018 Session 410: Creating Custom Instruments 里的示例。

3)store 获得数据源后,会进行 Binding Solution 工作来优化数据处理过程。

PS:Instruments 通过 XML 标准数据接口解耦展示和分析数据的思路,值得学习。

线上性能监控#

两个原则:

  • 不侵入业务代码;
  • 性能消耗尽可能小。

主要指标:

CPU 使用率#

当前 App CPU 的使用率,即 App 中各个线程 CPU 使用率的总和。所以,定时遍历每个线程,累加每个线程的 cpu_usage 值即可。

⚠️:

  • task_threads(mach_task_self(), &threads, &threadCount) 方法能够取到当前进程中所有线程的数组 threads 和线程总数 threadCount。
  • thread_info((thread_act_t)threads[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount) 方法能够取到线程 threads [i] 的基本信息 threadInfo。
  • cpu_usage 定义在 iOS 系统 > usr/include/mach/thread_info.h > thread_basic_info 结构体中。

内存#

类似 CPU 使用率,内存信息也有一个专门的结构体记录,定义在 iOS 系统 > usr/include/mach/task.info.h > task_vm_info 结构体中,其中 phys_footprint 就是物理内存的使用。

⚠️:

  • task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &vmInfoCount 方法能够取到当前进程的内存信息 vmInfo。
  • 物理内存由 phys_footprint 表示,而不是 resident_size(驻留内存:被映射到进程虚拟内存空间的物理内存)。

FPS#

FPS 低,表示 App 不流畅。

简单实现:在 CADisplayLink 注册的方法中,记录刷新时间和刷新次数,这样就可以得到一秒钟屏幕刷新的次数,即 FPS。

⚠️:每次屏幕刷新都会调用一次 CADisplayLink 注册的方法。

Tips:

  • 第三方监控平台推荐:蚂蚁移动开发平台 mPaaS
  • 多关注苹果公司自己的库和工具,这里面的设计思想和演进有大量可以吸取和学习的知识。

17 | 远超你想象的多线程的那些坑#

现象:AFNetworking 2.0(网络框架)、FMDB(第三方数据库框架)等常用的基础库,使用多线程技术时非常谨慎;特别是 UIKit 没有使用多线程技术,干脆做成了线程不安全的,只能在主线程上操作。

为什么会有这样的现象呢?下面来看看多线程技术常见的两个大坑:常驻线程并发问题

常驻线程#

定义:不会停止、一直存在于内存中的线程。

从何而来

使用 NSRunLoop 的 run 方法给该线程添加了一个 runloop,那么该线程就会一直存在于内存中。

举例: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];
    }
}

AFNetworking 2.0 把每个请求封装到了 NSOperationQueue 中,然后专门创建上面这个常驻线程来接收 NSOperationQueue 的回调。

没有避开常驻线程这个坑,原因在于它网络请求使用的 NSURLConnection 在设计上存在缺陷:NSURLConnection 发起请求后,所在的线程需要一直存活,以等待接收 NSURLConnectionDelegate 回调方法。但是,网络返回的时间不确定,所以就需要常驻线程来处理。而单独创建一个线程,而不使用主线程,则是因为主线程还要处理大量的 UI 和交互工作。

🎉:不过在 AFNetworking 3.0 中,它使用苹果公司新推出的 NSURLSession 替换了 NSURLConnection,NSURLSession 可以指定回调为 NSOperationQueue,这样就不需要常驻线程去等待请求的回调了。

如何避免

常驻线程过多,不但不能提高 CPU 的利用率,反而会降低程序的执行效率。

不创建常驻线程当然最好,但如果你确实需要线程保活一段时间,可以选择:

  1. 使用 NSRunLoop 的另外两个方法 runUntilDate:runMode:beforeDate: 来指定线程的保活时长,让线程存活时间可预期。
  2. 使用 CFRunLoopRef 的 CFRunLoopRun 和 CFRunLoopStop 方法来完成 runloop 的开启和停止,达到将线程保活一段时间的目的。

⚠️:通过 NSRunLoop 添加 runloop 的方法有 runrunUntilDate:runMode:beforeDate: 三种。其中, run 方法添加的 runloop ,会不断地重复调用 runMode:beforeDate: 方法,来保证自己不会停止。

并发#

从何而来?同时创建了多个线程。

在 iOS 并发编程技术中,GCD(Grand Central Dispatch)的使用率是最高的,它是由苹果公司开发的一个多核编程解决方案。

  • 优点:接口简单易用,便于管理复杂线程(创建、释放时机等)。
  • 缺点:资源使用上存在风险。如在数据库读写场景中:
    • 在读写操作等待磁盘响应的时候,通过 GCD 发起一个任务;
    • 本着最大化利用 CPU 的原则,GCD 会在等待磁盘响应的这个空档,再创建一个新线程来充分利用 CPU。
    • 而如果 GCD 发起的这些新任务,又都是需要等待磁盘响应的任务的话,那么随着任务数量的增加,GCD 创建的新线程就会越来越多,从而导致内存资源越来越紧张
    • 等到磁盘开始响应后,再读取数据又会占用更多的内存,最终将导致内存管理失控

如何避免

类似数据库这种需要频繁读写磁盘操作的任务,尽量使用串行队列来管理,避免多线程并发导致内存问题

推荐:开源的第三方数据库框架 FMDB,其核心类 FMDatabaseQueue 就是将与读写数据库相关的磁盘操作都放到一个串行队列里去执行。

⚠️:线程过多时,内存CPU 都会有大量的消耗。

  • 系统需要分配一定的内存作为线程堆栈。在 iOS 开发中,主线程堆栈大小是 1MB,新创建的子线程堆栈大小是 512KB(堆栈大小是 4KB 的倍数)。
  • CPU 在切换线程上下文时,需要通过寻址来更新寄存器,而寻址的过程会有较大的 CPU 消耗。

Tips:多线程技术中锁的问题是最容易查出来的,你更需要关注的,反而是那些藏在背后、会慢慢吃尽系统资源的问题。

18 | 怎么减少 App 电量消耗?#

耗电过多的可能原因:开启了定位;频繁的网络请求;定时任务时间间隔过小...

排除法查找具体位置:把功能一个个都注释掉,观察耗电量变化。

不过话说回来,只有先获取到电量,才能发现电量问题。

如何获取电量?#

使用系统提供的 batteryLevel 属性,代码如下:

- (float)getBatteryLevel {
    // 要想监控电量,必须允许
    [[UIDevice currentDevice] setBatteryMonitoringEnabled:YES];
    // 0.0 (没电)、1.0 (满电)、–1.0 (未开启电池监控)
    float batteryLevel = [[UIDevice currentDevice] batteryLevel];
    
    NSLog(@"电池剩余比例:%@", [NSString stringWithFormat:@"%f", batteryLevel * 100]);
    return batteryLevel;
}

参考 batteryLevel —— Apple 官方文档。

PS:还可以给电量变化通知添加观察者,在电量变化时调用自定义方法,从而监控电量。参考 UIDeviceBatteryLevelDidChangeNotification —— Apple 官方文档。

如何诊断电量问题?#

如果用上述排除法还找不到问题所在,那么这个耗电一定是由其他线程引起的,而这个耗电线程创建的地方可能是在第三方库或者二方库(公司内部其他团队开发的库)。

面对这种情况,我们直接观察哪个线程是有问题的,比如说某个线程的 CPU 使用率长时间比较高,超过了 90%,就能够推断出它是有问题的。此时,将其方法堆栈记录下来,就可以追根溯源了。

  • 观察 CPU 使用率,可参考第 16 课 | 线上性能监控部分。
  • 记录方法堆栈,可参考第 13 课 | 获取卡顿的方法堆栈部分。

优化电量#

CPU 方面#

避免让 CPU 做多余的事情。

  1. 对于大量数据的复杂计算,交给服务器去处理。
  2. 必须要在 App 内处理的复杂计算,可以通过 GCD 的 dispatch_block_create_with_qos_class 方法指定队列的 Qos 为 QOS_CLASS_UTILITY,将计算工作放到这个队列的 block 里。因为,在这种 Qos 模式下,系统针对大量数据的复杂计算专门做了电量优化。

I/O 方面#

任何的 I/O 操作,都会破坏掉低功耗状态。

  • 将碎片化的数据磁盘存储操作延后,先在内存中聚合,然后再进行磁盘存储。
  • 可以使用系统自带的 NSCache 来完成数据在内存中的聚合:
    • 它是线程安全的。
    • 它会在到达预设的缓存空间值时清理缓存,并且触发 cache:willEvictObject: 回调方法,在这个回调里再对数据进行 I/O 操作。

相关案例:SDWebImage 图片加载框架读取缓存图片。

- (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);
        // 并存放到 NSCache 里
        [self.memCache setObject:diskImage forKey:key cost:cost];
    }
    return diskImage;
}
  • 每次读取图片时,会检查 NSCache 是否已经存在图片数据。
    • 如果有,就直接从 NSCache 里读取;
    • 如果没有,才会通过 I/O 读取磁盘缓存图片,并将获取的图片数据存放到 NSCache 里。

苹果公司参考#

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

19 | 热点问题答疑(二):基础模块问题答疑#

RunLoop 原理学习顺序#

  1. 孙源线下分享 | RunLoop:对 RunLoop 的整体有一个大致的了解。
  2. RunLoop 官方文档:全面详细地了解苹果公司设计的 RunLoop 机制,以及如何运用 RunLoop 来解决问题。
  3. ibireme | 深入理解 RunLoop:结合底层 CFRunLoop 的源码,对 RunLoop 机制进行深入分析。

使用 dlopen () 能不能审核通过?#

使用 dlopen() 去读取远程动态库,不能通过苹果公司的审核。

苹果公司在 2018 年 11 月集中下线 718 个 App 时提到,使用 dlopen()dlsym()respondsToSelector:performSelector:method_exchangeImplementations() 这些方法去执行远程脚本,是不被允许的。因为:

  • 这些方法和远程资源相结合,可能加载私有框架和私有方法,使 App 的行为发生重大变化,这就会和审核时的情况不一样。
  • 即使使用的远程资源本身不是恶意的,但是它们也很容易被劫持,使应用程序有安全漏洞,给用户带来不可预计的伤害。

matrix-iOS#

一个微信开源的卡顿监控系统。

matrix-iOS 减小其对 App 性能损耗的四个细节:

  1. 子线程监控检测时间间隔:监控卡顿的子线程通过 NSThread 创建,检测时间间隔正常情况是 1 秒,在出现卡顿情况下,间隔时间会受退火算法影响,按照斐波那契数列递增,直到没有卡顿时恢复为 1 秒。
  2. 子线程监控退火算法:避免同一个卡顿重复获取主线程堆栈的情况。
  3. RunLoop 卡顿时间阈值设置:2 秒。
  4. CPU 使用率阈值设置:当单核 CPU 使用率超过 80%,就判定 CPU 占用过高。

参考:

以教为学,温故而知新~

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。