0%

性能优化

内存优化

卡顿优化

电量优化

启动优化

体积优化

网络优化

编译优化

APM

调试 & Crash

收集中

性能优化

性能优化,架构先行

  • 组件化
    • 共享基础设施
    • 下沉公共服务
    • 隔离业务模块
  • 标准化
    • 统一的基础服务
    • 统一的应用框架
    • 统一的开发范式
    • 统一的用户体验

收益

  • 共享公共服务,业务统一模板,降低项目复杂度
  • 代码复用率 0 -> 80%
  • 线上性能问题种类,减少3倍以上
  • Crash率,减少3倍以上

端上性能监控

  • 可用性
    • Crash
    • Abort
    • 业务异常
  • 基础性能
    • 启动时间
    • 包体积
  • 流畅性
    • 页面测速
    • 卡炖检测
    • 关键路径
    • 渲染检测
  • 资源消耗
    • CPU
    • 内存
    • 流量
    • 储存

      解决问题

  • 服务端开关
  • 热修复
  • 新版本

验证效果,AB实验

网络优化

利用http2.0

  • 多路复用;二进制分桢;头部压缩;服务端推送
  • 页面加载时间缩短 ~7.5%
  • 数据

参考

汇总

https://images.apple.com/media/us/osx/2013/docs/OSX_Mavericks_Core_Technology_Overview.pdf

组件化

问题

由于我们的工程代码越来越多,文件原来越多,编译速度直线下降,工作效率直线下降,甚至会影响情绪(误 )

所以计划将所有pod进行静态库构建,这样编译的时候就不会再重编了,再加上我们组件化也在持续推进,最终可能只需要编我修改的库就行了(理想状态)

静态库构建

  1. 静态库构建最初方案与尝试
    最初指定的方案是在pod更新的时候进行打包,基于cocoapods-packager(后简称为packager)进行魔改。魔改后packger整体的方案是传入Podfile,在指定位置重新创建工程,根据Podfile与依赖进行libtool编译(动态库是使用xcodebuild,但我们不用动态库),然后将编译好的静态库拷贝出来。为什么要传入Podfile呢,在之前的packager是使用podspec进行工程生成,这会导致生成的静态库所依赖的其他pod版本号会和主工程中的Podfile对不上。所以我们需要维护一个和主工程Podfile相同版本号的Podfile(是不是有点绕)

其实packger内部也是使用Podspec生成Podfile的,我们只是做了直接传入Podfile,省去了生成的步骤。原理很简单,但实现还是需要摸索了一段时间。在扒了cocoapods源码后,发现cocoapods对Podfile对象提供了一个from_ruby的方法,这使得我们可以直接使用Podfile ruby文件就可以获取一个Podfile对象,但我们还需要对Podfile进行一些修改。我们将Podfile转化为yaml,然后将库中Podfile写的../ ./ 这种相对路径修改为工程地址(这是因为使用相对路径会导致生成的工程引用的代码文件不会被拷贝到工程下,源码调试会有问题),关闭uses_frameworks。

我们对packger还做了一些其他的修改。使用Podfile;修改生成工程的地址;对生成的工程Build Setting中的GCC_GENERATE_DEBUGGING_SYMBOLS打开(后面会介绍这个字段是干什么用的);修改了编译架构,只保留了arm64;修改了一些默认参数;增加了将生成的工程拷贝出来的参数;

这个魔改后的cocoapods plugin会镶嵌在自动更新pod脚本当中,使用的时候会询问是否要构建静态库。

但这个方案有两个问题,首先是需要维护一个和主工程版本号相同的Podfile比较麻烦,第二个是当依赖的库有变动的时候这个库的静态库也需要重新构建(包括依赖的依赖的依赖… 老套娃了)。这个其实可以通过分析Podfile.lock文件来寻找影响的所有库,但未免有些麻烦了。

  1. 最终方案
    ​我们既然不想维护一份单独的Podfile的话,那我们直接使用主工程不就好了吗?

所以最终我们选择了使用主工程编译后的产物来当做我们使用的静态库。

我们在工程内使用

1
xcodebuild CONFIGURATION_BUILD_DIR="#{library_path}" clean build -configuration Debug -scheme Pods-XXX -workspace "#{project_path}/XXX.xcworkspace" -arch arm64 -sdk iphoneos
  • 在 library_path 的位置生成所有静态库与bundle。这是我们的主体思路。
  • 在library_pods.json填入我们需要更新的库名(所有库名在工程pod update时候会在工程文件夹中生成一个current_mmpods.json当中)。
  • 分析 ~/.cocoapods/repos 中都有什么repo,获取repo的Git地址。
  • 分析Podfile.lock获取Pod都在那个repo中、版本号、是否被当成subspec引入。
  • 使用xcodebuild构建。
  • 遍历library_pods.json。
    • 从工程中拷贝源码。
    • 拷贝构建完成的静态库。
    • 从bundle中拷贝metalllib(.metal文件特有)生成对应的bundle。
    • 拷贝Podfile.lock(为了查询是什么情况下构建的静态库)。
    • repo中拷贝Podspec。
    • 如果Podspec没有被转化为json,就把它转成json。
    • 处理Podspec
      • 将静态库加入vendored_libraries。
      • 将源码路径加入Podfile(稍后讲到源码调试的时候会讲)。
      • 将使用的subspec中的一些字段比如source_files加入到最外面,这里是因为如果工程只引入了subspec的话,外层的对应字段会失效。
    • 将bundle路径加入到resources。
  • 构建源码的zip。
  • 构建完整的zip(包含源码zip)。
  • 上传完整的zip(这里踩了很多坑我们稍后再说)。
  • 提交Podspec至对应的静态库单独的repo仓库中。

好了,至此我们已经成功构建了一个使用静态库的Pod版本,版本号为为当前版本号.a.debug eg 1.0.0.a.debug。

我们每个版本之后进行统一回扫也不会存在有某些依赖的库更新上层库没更新的问题了。

源码调试

https://tech.meituan.com/2019/08/08/the-things-behind-the-ios-project-zsource-command.html

  1. 源码调试原理与过程
    切换完静态库之后直接带来了一个问题就是大家无法看到源码,没办法根据crash寻找代码,没法打断点了。

​为了解决这个问题,我们查阅了资料发现使用dwarfdump可以分析出静态库中AT_comp_dir的这个字段,AT_comp_dir代表编译地址,只要源码在这个位置,xcode就会根据它去寻找代码,当然了只是显示,编译还是使用静态库来编译的。(前文说的GCC_GENERATE_DEBUGGING_SYMBOLS就是为了生成这个字段用的,不过我们统一构建后就不需要再去用脚本设置了。)

​换句话说就是只要我们其他电脑上在当时构建静态库的源码路径上还是有源代码的,xcode就可以自动寻找到代码并显示。不过在我们尝试了软链接、硬链接和文件拷贝后,软链接和硬链接都有相应的问题(找不到源码或者无法进行断点调试)。最终我们还是选择了文件拷贝(又为大家岌岌可危的硬盘空间火上浇油了,不过没有很大啦。)

​现在问题来了我们需要找到一个大家都有的且有权限的路径下面进行静态库构建在经过大量尝试之后我们选择了 /private/var/folders/cocoapods/MOMO_iOS_Binary 这个路径来进行构建所以打包机上的工程师在这个路径下面的。

同时我们也支持的在工程中直接对源码进行断点,这个我们等下再说。

我们先来介绍一下我们为此做了什么工作:

  • 上文提到的源码zip就是为了一起打到zip包中方便我们pod update的时候获取源码的(考虑到再去下载可能会更麻烦)。
  • 需要把源码的zip包 xxx.binary 加入到podspec的Podfile字段中,否则update后工程中是没有它的。
  • 在我们进行pod update的时候使用Podfile提供的hook pre_install(在cocoapods拷贝文件完成之后)调用解压脚本将我们所有存在 xxx.binaryzip的文件全部解压到指定路径也就是编译时候的的路径。
  • 然后使用Podfile的另外一个hook post_integrate (在Integrating client project之后)(v1.10版本才提供,当前可调用工程/Binary/handle_add_source_file_to_project.sh)。
    这个路径其实可以通过dwarfdump来获取,但是执行的有点慢,不过我们都自己制定了路径了,那不如还是把路径写死吧!

dwarfdump xxx | grep “AT_comp_dir” | head -1 | cut -d \“ -f2

这一步我们分析了xcodeproj文件,可以将目标路径的整个文件夹当成一个Group加入到我们在Pod的xcodeproj文件中的SourceCode路径下。这些文件是不会被编译的,因为没有被加入到Build Phases中。

至此,我们就可以在工程目录中看到源码,可以直接打断点,当crash或者assert时也会直接跳转到对应位置啦(如果不是为了看源码,其实可以不操作第四步的)。

  1. 我有多个工程对应不同的源码版本怎么办呢?
    首先我们先重申一个概念,只要静态库中的AT_comp_dir对应的地方有源码,xcode会自动找到代码。
    最初我们的做法是不管什么版本的代码,只要是同名pod都会在默认的位置进行编译,如 Pods/AFNetworking。
    这样对于我们只有一个工程的情况确实没问题。但众所周知,大家的电脑里最少都有两个以上的工程副本。当处于不同的分支的时候有可能同一pod对应的就是不同的版本。这就会出现,我并不信任我当前调试的代码是不是和我当前的pod版本所对应。

我们为了解决这个问题,分为编译和解压两个部分。

编译
首先在打包的时候根据版本在不同的文件路径下编译。是不是有点没看懂,我先举个例子。假如我现在要给
AFNetworking,’1.0’版本进行静态库构建,它的原始代码路径为’工程/Pods/AFNetworking/xxx’。我们只需要把它改为工程/Pods/AFNetworking/1.0/xxx,这样在不同版本的情况下打包不就可以区分了吗?我们最终采用的是软链接,在’工程/Pods/AFNetworking/‘中创建一个名为1.0的软链接,链接’工程/Pods/AFNetworking/‘,这里有点绕,就是在目录下创建了一个指向自己的软链接,类似祖传套娃。

然后我们还需要修改project的group path,不改的话还是使用原来的路径编译。还是用AFNetworking来举例。原始的project group为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
9F144D000000D0 /* AFNetworking */ = {
isa = PBXGroup;
children = (
9F144D00000100 /* AFNetworking.h */,
9F144D000000E0 /* AFURLRequestSerialization.h */,
9F144D000000F0 /* AFURLResponseSerialization.h */,
9F144D00000250 /* Frameworks */,
9F144D00000110 /* NSURLSession */,
9F144D00000150 /* Reachability */,
9F144D00000170 /* Security */,
9F144D00000190 /* Serialization */,
9F144D000002D0 /* Support Files */,
9F144D000001A0 /* UIKit */,
);
name = AFNetworking;
path = AFNetworking;
sourceTree = "<group>";
};

我们需要把path修改为AFNetworking/1.0,然后就会发现编译不过了。因为有一个地方有些问题

1
2
3
4
5
6
7
8
9
10
9F144D000002D0 /* Support Files */ = {
isa = PBXGroup;
children = (
9F144D000002E0 /* AFNetworking.debug.xcconfig */,
9F144D000002F0 /* AFNetworking.release.xcconfig */,
);
name = "Support Files";
path = "../Target Support Files/AFNetworking";
sourceTree = "<group>";
};

发现问题所在了没?Support Files的path使用的是相对路径,因为我们相当于在上面多建立了一层文件结构所以这里要改为

  • path = “../../Target Support Files/AFNetworking”;
    以上修改完成之后就可以进行编译了。

解压
解压就比较简明易懂了,就是解压到和编译时相同的路径就可以了。这样我们就可以同时存在不同版本的代码了!

源码与静态库切换

先判断是否有二进制 git cat-file -e origin/master:#{first_name}/#{version + library_version_suffix}/#{first_name}.podspec.json

大文件存储

现在流程都跑通的,但我们每个版本都会构建新的静态库,那么我们的git仓库岂不是爆炸了吗?

我们最初有讨论将所有.a文件忽略掉,但这需要每次拉代码后进行一次pod update将静态库拉回来,有些麻烦。

经过调研和验证后我们选择了Git提供的Git LFS进行大文件存储,它实质上是讲所有文件传到Git LFS的仓库中而不是我们的工程仓库,在我们的工程仓库中会有一个类似指针的文件指向Git LFS仓库中的文件,所以我们仓库不会变大(理论上是会变大因为有一个指针文件)。当我们进行git pull的时候同时会进行git lfs pull将文件下载下来,这样解决了我们大文件存储的问题。

具体就是在.gitattribute中将*.a加入到Git LFS中。同时我们也加入了*.binaryzip

其实理论上可以进行工程回扫,将之前的所有commit都进行重建,这样我们的仓库就会从现在的十几个G减少到3个G左右,但需要SA配合,我们的电脑向Git服务器推因为过大,有三万多个commit,会在传输完成后被Git服务器中断连接,所以目前还没搞。

Q&A

Q:Podfile.lock是一个什么样的文件

A:Podfile.lock实际上是一个yaml文件,类似于json

Q:lottie-ios为什么没有切为静态库

A:有些库在spec中dependency这个库的时候限制了版本号 ~> 2.0,但cocoapods没有识别 2.0.a.debug 为2.x版本内

Q:ReactiveCocoa为什么和其他切换静态库的方式不一样

A:我们自动打出来的静态库,头文件会被增加RAC前缀,导致文件找不到。于是用单一构建的方式来构建了一版。

Q:为啥Jenkins打带有Git-LFS的包会报错?

A:因为Jenkins寻找的路径是xcode的目录,我们需要把Git-LFS拷贝到xcode目录中

cp $(which git-lfs) /Applications/Xcode.app/Contents/Developer/usr/libexec/git-core

启动速度优化

  • 静态库
  • 二进制重排,clang插桩

Total pre-main time: 1.2 seconds (100.0%)
dylib loading time: 567.72 milliseconds (45.5%)
rebase/binding time: 105.14 milliseconds (8.4%)
ObjC setup time: 40.01 milliseconds (3.2%)
initializer time: 532.47 milliseconds (42.7%)
slowest intializers :
libSystem.B.dylib : 4.70 milliseconds (0.3%)
libglInterpose.dylib : 295.89 milliseconds (23.7%)
AFNetworking : 48.75 milliseconds (3.9%)
Oasis : 285.94 milliseconds (22.9%)

参考

https://tech.meituan.com/2019/08/08/the-things-behind-the-ios-project-zsource-command.html

卡顿的发生通常有以下几个原因:

  • UI过于复杂,图文混排的绘制量过大;
  • 在主线程上进行同步的网络请求;
  • 在主线程上进行大量的IO操作;
  • 函数运算量过大,持续占用较高的CPU;
  • 死锁和主子线程抢锁;

dump线程

iOS 的线程技术与Mac OS X类似,也是基于 Mach 线程技术实现的,在 Mach 层中thread_basic_info 结构体封装了单个线程的基本信息:

1
2
3
4
5
6
7
8
9
10
struct thread_basic_info {
time_value_t user_time; /* user run time */
time_value_t system_time; /* system run time */
integer_t cpu_usage; /* scaled cpu usage percentage */
policy_t policy; /* scheduling policy in effect */
integer_t run_state; /* run state (see below) */
integer_t flags; /* various flags (see below) */
integer_t suspend_count; /* suspend count for thread */
integer_t sleep_time; /* number of seconds that thread has been sleeping */
}

一个Mach Task包含它的线程列表。内核提供了task_threads API 调用获取指定 task 的线程列表,然后可以通过thread_info API调用来查询指定线程的信息,在 thread_act.h 中有相关定义。

task_threads 将target_task 任务中的所有线程保存在act_list数组中,act_listCnt表示线程个数:

1
2
3
4
5
6
kern_return_t task_threads
(
task_t target_task,
thread_act_array_t *act_list,
mach_msg_type_number_t *act_listCnt
);

thread_info结构如下:

1
2
3
4
5
6
7
kern_return_t thread_info
(
thread_act_t target_act,
thread_flavor_t flavor, // 传入不同的宏定义获取不同的线程信息
thread_info_t thread_info_out, // 查询到的线程信息
mach_msg_type_number_t *thread_info_outCnt // 信息的大小
);

所以我们如下来获取CPU的占有率:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#import "LSLCpuUsage.h"
#import <mach/task.h>
#import <mach/vm_map.h>
#import <mach/mach_init.h>
#import <mach/thread_act.h>
#import <mach/thread_info.h>

@implementation LSLCpuUsage

+ (double)getCpuUsage {
kern_return_t kr;
thread_array_t threadList; // 保存当前Mach task的线程列表
mach_msg_type_number_t threadCount; // 保存当前Mach task的线程个数
thread_info_data_t threadInfo; // 保存单个线程的信息列表
mach_msg_type_number_t threadInfoCount; // 保存当前线程的信息列表大小
thread_basic_info_t threadBasicInfo; // 线程的基本信息

// 通过“task_threads”API调用获取指定 task 的线程列表
// mach_task_self_,表示获取当前的 Mach task
kr = task_threads(mach_task_self(), &threadList, &threadCount);
if (kr != KERN_SUCCESS) {
return -1;
}
double cpuUsage = 0;
for (int i = 0; i < threadCount; i++) {
threadInfoCount = THREAD_INFO_MAX;
// 通过“thread_info”API调用来查询指定线程的信息
// flavor参数传的是THREAD_BASIC_INFO,使用这个类型会返回线程的基本信息,
// 定义在 thread_basic_info_t 结构体,包含了用户和系统的运行时间、运行状态和调度优先级等
kr = thread_info(threadList[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount);
if (kr != KERN_SUCCESS) {
return -1;
}

threadBasicInfo = (thread_basic_info_t)threadInfo;
if (!(threadBasicInfo->flags & TH_FLAGS_IDLE)) {
cpuUsage += threadBasicInfo->cpu_usage;
}
}

// 回收内存,防止内存泄漏
vm_deallocate(mach_task_self(), (vm_offset_t)threadList, threadCount * sizeof(thread_t));

return cpuUsage / (double)TH_USAGE_SCALE * 100.0;
}
@end

博主文中提到:关于 phys_footprint 的定义可以在 XNU 源码中,找到 osfmk/kern/task.c 里对于 phys_footprint 的注释,博主认为注释里提到的公式计算的应该才是应用实际使用的物理内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/*
* phys_footprint
* Physical footprint: This is the sum of:
* + (internal - alternate_accounting)
* + (internal_compressed - alternate_accounting_compressed)
* + iokit_mapped
* + purgeable_nonvolatile
* + purgeable_nonvolatile_compressed
* + page_table
*
* internal
* The task's anonymous memory, which on iOS is always resident.
*
* internal_compressed
* Amount of this task's internal memory which is held by the compressor.
* Such memory is no longer actually resident for the task [i.e., resident in its pmap],
* and could be either decompressed back into memory, or paged out to storage, depending
* on our implementation.
*
* iokit_mapped
* IOKit mappings: The total size of all IOKit mappings in this task, regardless of
clean/dirty or internal/external state].
*
* alternate_accounting
* The number of internal dirty pages which are part of IOKit mappings. By definition, these pages
* are counted in both internal *and* iokit_mapped, so we must subtract them from the total to avoid
* double counting.
*/

如何判断发生了OOM

解决

  • oom instsrument测试工具
  • 开发过程检测泄露
  • 大对象, 图片等资源的缓存
  • hook alloc dealloc

参考

https://engineering.fb.com/2015/08/24/ios/reducing-fooms-in-the-facebook-ios-app/

Momo 跨平台的crash-report解决方案

  • 最近负责公司的质量平台建设,参与了陌陌的crash-report方案设计和开发。
  • 经过优化封装后,已对外使用,组件地址
  • 使用方式请联系陌陌企业服务
  • iOS程序崩溃和抓取原理

Crash 背景

对于所有崩溃场景,仅25%的崩溃可通过信号量捕获,实施相应改进;另有75%的崩溃则难以识别,从而对App的用户体验,造成了巨大的潜在影响。

Facebook的工程师将App退出分为以下6个类别:
1.App内部主动调用exit()或abort()退出;
2.App升级过程中,用户进程被杀死;
3.系统升级过程中,用户进程被杀死;
4.App在后台被杀死;
5.App在前台被杀死,且可获取堆栈;
6.App在前台被杀死,且无法获取堆栈。

对于第1~4类退出,属于App的正常退出,对用户体验没有太大影响,无需进行相应处理;对于第5类退出,可通过堆栈代码级定位崩溃原因,对此业界已形成比较成熟的解决方案,;对于第6类退出,可能的原因很多,包括但不限于:系统内存不足时继续申请内存、主线程卡死20s以上、CPU使用率过高Stack Overflow等,在此我们统一称之为iOS客户端的“Abort问题”。
Abort问题无法被堆栈捕获,且发生频次远高于可被捕获的崩溃(下称“堆栈崩溃”)。从历史数据来看,手淘(电商类超级App代表)的Abort问题数量一般是堆栈崩溃数量的3倍左右;优酷Pad(视频类超级App代表)的Abort问题数量一般是堆栈崩溃数量的5倍左右。可见,Abort问题对用户的使用体验造成巨大影响。
本文将针对iOS客户端的Abort问题,进行根因定位分析,并提出系统性解决方案。

对于oom,除了facebook的排除法,还有字条抖音的 Memory Graph主动分析方式,参看iOS性能优化实践:头条抖音如何实现OOM崩溃率下降50%+
微信 https://wetest.qq.com/lab/view/367.html

task_vm_info.phys_footprint: 47.4MB
task_info.resident_size: 80MB

Abort问题的原因分类

形成Abort问题的原因主要包括以下4个。
1 内存 Jetsam
移动端设备的物理内存资源紧张,但App仍不断申请内存。因此系统signal 9杀死进程,造成异常退出。

1
{   "memoryPages" : {     "active" : 24493,     "throttled" : 0,     "fileBacked" : 24113,     "wired" : 13007,     "anonymous" : 12915,     "purgeable" : 127,     "inactive" : 10955,     "free" : 2290,     "speculative" : 1580  },  "uncompressed" : 125795,  "decompressions" : 143684  },  "largestProcess" : "Taobao4iPhone",  "processes" : [  {  ...  {     "rpages" : 2050,     "states" : [       "frontmost",       "resume"     ],     "name" : "Taobao4iPhone",     "pid" : 1518,     "reason" : "vm-thrashing",     "fds" : 50,     "uuid" : "5103a88a-917f-319e-8553-c0189dd1abac",     "purgeable" : 127,     "cpuTime" : 4.619693,     "lifetimeMax" : 3557  },  ...  }

2 主线程死锁
A/B两个线程同时等待对方完成某些操作,因而无法继续执行,形成死锁,造成异常退出。

1
Exception Type:  00000020Exception Codes: 0x000000008badf00dHighlighted Thread:  0 Application Specific Information:com.myapp.myapp failed to scene-create in time Elapsed total CPU time (seconds): 4.230 (user 4.230, system 0.000), 10% CPU Elapsed application CPU time (seconds): 1.039, 3% CPU Thread 0 name:  Dispatch queue: com.apple.main-threadThread 0:0   libsystem_kernel.dylib          0x36360540 semaphore_wait_trap + 81   libdispatch.dylib               0x36297eee _dispatch_semaphore_wait_slow + 1862   libxpc.dylib                    0x364077b8 xpc_connection_send_message_with_reply_sync + 1523   Security                        0x2b8dd310 securityd_message_with_reply_sync + 644   Security                        0x2b8dd48c securityd_send_sync_and_do + 445   Security                        0x2b8ea452 __SecItemCopyMatching_block_invoke + 1666   Security                        0x2b8e96f6 SecOSStatusWith + 147   Security                        0x2b8ea36e SecItemCopyMatching + 174

3 启动/重启超时
App由于启动/重启的时间超过系统允许的时间限制,造成异常退出。

scene-create watchdog transgression: app exhausted real (wall clock) time allowance of 19.93 seconds, Elapsed total CPU time (seconds): 21.050 (user 21.050, system 0.000)

4 CPU打爆
主线程死锁、启动/重启超时,都可能间接导致CPU打爆,造成异常退出。

1.Abort问题发生的场景:例如,哪个页面、什么操作。
2.Abort问题发生的原因:例如,内存Jetsam、主线程死锁、启动/重启超时、CPU打爆。
3.对于内存Jetsam,需进一步定位到是否发生了内存泄露以及泄露的循环引用(Retain Cycle)。
4.对于主线程死锁,需进一步定位到卡死的堆栈。
5.对于启动/重启超时,以及CPU打爆,需进一步定位到堆栈。

abort问题的系统性解决方案

  1. 现场捕获
    为实现Abort问题的系统性解决方案,需充分考虑以下问题:
    1.通过signal 9杀死进程造成的Abort问题,往往难以通过信号量捕获至堆栈。在这种情况下,应如何尽可能完整地捕获崩溃现场的关键信息?具体包含哪些信息?
    2.App崩溃时系统处于极不稳定的状态,应如何保证崩溃现数据稳定落盘?
    3.在信息采集、数据捕获的过程中,需对大量数据进行写入操作,应如何保证日志高性能写入?
    4.在数据量较大的情况下,数据的存储、上传可能对系统造成较大压力,应如何保证数据的高压缩率?

    基于以上考虑,我们提出并设计了一套基于mmap的高性能、高压缩率、高一致性、可自解释的trace文件协议,作为iOS端高可用体系的数据载体。

  1. mmap数据存储层保证数据写入的高性能和高一致性

    • 通过mmap将一个文件或者其它对象映射到进程的地址空间,对内存的操作会由内核将数据写到对应的磁盘文件上;数据写入的性能与内存操作相当(略比内存操作高)
    • 用户进程崩溃之后,这块映射区仍由内核管理,可以保证数据的一致性
  2. 二进制编码协议保证数据压缩率最高

    • 具体编码协议
    • 实测编码在压缩率能达到80%以上,或者直观一点说,使用50k的内存可以记录下用户二十分钟内详细的使用记录,包括页面访问记录、系统事件、秒级别的内存、CPU数据。
  3. 尽可能多的记录系统多维度指标及异常事件包括:

    • 性能数据,包括CPU、内存数据,用于判断应用当前是不是处理overload状态
    • 大内存申请
    • Retain Cycle,用于定位Jetsam Event
    • 卡顿,用于定位watch dog kill
    • 当前存活VC实例数量

BreakPad 跨平台的crash-report解决方案

官方地址
Github地址

介绍breakpad

大致流程

breakpad原理图

主要包括三个部分

  • dumpSyms 负责 读取 用户开发应用中的debug信息 并生成特定的符号文件参考
  • client 在崩溃系统中也就是指的崩溃上报的sdk 负责抓取当前线程和当前载入的库 生成 minidump文件
  • processor 也就是崩溃系统中的mnidump_stackwalk 读取minidump文件 找到合适的符号文件 产生一个人类可读的c/c++调用栈

MiniDump文件格式

  • minidump文件格式是由微软开发的用于崩溃上传,它包括:
    • 当dump生成时进程中一系列executable和shared libraries, 包括这些文件的文件名和版本号。
    • 进程中的线程列表,对于每个线程,minidump包含它在寄存器中的状态,线程的stack memory内容。这些数据都是未解析的字节流,Breakpad client通常没有调试信息(debugging information)能生成函数名,行号,甚至无法确定stack frame的边界。
    • 其他收集关于系统的信息,如:处理器,操作系统高版本,dump的原因等等。
  • breakpad在所有平台上(windows/linux等)都统一使用minidump文件格式,而不使用core files,原因是因为:
    • core files可能很大,而minidump比较小。
    • core files文档不全
    • 很难说服windows机器去生成core files,但可以说服其他机器来生成minidump文件。
    • breakpad只支持一种统一的格式会比较简单,而不是同时支持多种格式。

Symbols文件格式

symbols文件是基于纯文本的,每一行一条记录,每条记录中的字段以一个空格作为分隔符,每条记录的第一个字段表示这一行是什么类型的记录。

记录类型:

  • 模块记录:MODULE operatingsystem architecture id name
  • 文件记录:FILE number name
  • 函数记录:FUNC address size parameter_size name
  • 行号记录:address size line filenum
  • PUBLIC记录:PUBLIC address parameter_size name
  • STACK WIN
  • STACK CFI

不同平台的实现原理

默认情况下,当崩溃时breakpad会生成一个minidump文件,在不同平台上的实现机制不一样:

在windows平台上,使用微软提供的 SetUnhandledExceptionFilter() 方法来实现。
在OS X平台上,通过创建一个线程来监听 Mach Exception port 来实现。
在Linux平台上,通过设置一个信号处理器来监听 SIGILL SIGSEGV 等异常信号。

发送minidump文件

在真实环境中,你通常需要以某种方式来处理minidump文件,例如把它发送给服务器来进行分析,Breakpad源码提供了一些HTTP上传的代码,并提供一个minidump上传工具( 详见minidump_upload.cc)。

生成symbols文件

为了生成可读的stack trace, breakpad需要你将binaries里的调试符号(debugging symbols)转换成基于文本格式的symbol files。

首先确保你在编译代码的时候加上 -g 参数来生成带调试符号的。

然后使用 configure && make breakpad源码来生成 dump_syms 工具。

生成Stack Trace

breakpad包含一个叫做 minidump_stackwalk 的工具来将 minidump 文件,外加symbol files来生成一个人可读的stack trace。在编译breakpad后,这个工具一般在 google-breakpad/src/processor 目录下, 通过将 minidump 和 symbol files 传入给它即可:

客户端功能

每次crash都会生成三部分文件,目录结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
└── 根目录
├── basic.json //基础信息,展示在crash详情页

├── logs //业务方自定义日志,在展示在跟踪日志中
│ ├── log_1.txt
│ ├── log_2.txt
│ └── log_3.txt

└── stack.dmp(Android Native Crash / iOS Crash生成次文件)//堆栈信息,用于定位问题

└── logcat.log (控制台日志文件,2020110914:52:07添加,暂时只有android)

└── jvm.dmp (Android Java 堆栈信息) //安卓独有

接下来写一篇KSCrash读后感

kscm_handleException

参考

https://www.jianshu.com/p/295ebf42b05b
https://juejin.cn/post/6868230552571346951
https://engineering.fb.com/2015/08/24/ios/reducing-fooms-in-the-facebook-ios-app/
https://wetest.qq.com/lab/view/367.html

Autoreleasepool

  • 孙源大神的博客 Autoreleasepool
  • 网上搜的

与runloop的关系

第一个 Observer :
监视的事件是 Entry (即将进入Loop),其回调内会调用 _objc_autoreleasePoolPush() 创建自动释放池。

第二个 Observer :
监视了两个事件:BeforeWaiting(准备进入休眠)
会调用_objc_autoreleasePoolPop() 和 _objc_autoreleasePoolPush() 释放旧的池并创建新池;

Exit(即将退出Loop)
会调用 _objc_autoreleasePoolPop( ) 来释放自动释放池。

  1. 子线程在使用autorelease对象时,如果没有autoreleasepool会在autoreleaseNoPage中懒加载一个出来。
  2. 在runloop的run:beforeDate,以及一些source的callback中,有autoreleasepool的push和pop操作,总结就是系统在很多地方都差不多autorelease的管理操作。
  3. 就算插入没有pop也没关系,在线程exit的时候会释放资源,执行AutoreleasePoolPage::tls_dealloc,在这里面会清空autoreleasepool。

注意点

  • 当产生了autorelease对象时,此对象又特别大, 例如图片、大字符串、大数据等等时,此时加上 autoreleasepool ,会更好,避免oom产生
  • 特别是在循环任务中执行代码,优先使用系统迭代器方法,因为系统迭代器帮我们写好了autoreleasepool

库文件地址

lua官方地址
luajit

执行原理

这里先卖个关子,有一次面试,面试官问我, objc_msgsend 可以使用C语言写吗? 当时没回答好,理论上是可以的。

我们都知道lua是解释执行语言,C是编译执行语言,最大的区别,就是C可以充分利用编译器,制作成汇编指令,效率最高。

lua解释性语言只能解释成字节码,模拟汇编,进行操作。

lua是C写的,所以 objc_msgsend 理论上也可以C实现,动态的执行方法(不同参数,不同返回值),但是效率就降低为解释执行语言的效率了。

待补充。。。。。。。。。

lua指令集

参看lopcodes.h文件,里面定义了很多指令,
还有一个重要函数,我就不贴代码了,一个方法400多行,在lvm.C文件中的luaV_execute,可以看下

1
2
3
4
/* main loop of interpreter */
for (;;) {
...这里会对不不同指令进行操作
}

一些零散的源码阅读

Lua虚拟机并不能执行我们的ifStateMent这种东西。Lua源码里的实现也是类似这种TokenType 和 结构化的 if Statement whileStatement等等,并且Lua没有生成完整的语法树。Lua源码的实现里面,它是解析一些语句,生成临时的语法树,然后翻译成指令集的。并不会等所有的语句都解析完了再翻译的。语义解析和翻译成指令集是并行的一个过程。贴一个源码里面关于语义解析的部分实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void body (LexState *ls, expdesc *e, int needself, int line) {
/* body -> `(' parlist `)' chunk END */
FuncState new_fs;
open_func(ls, &new_fs);
new_fs.f->linedefined = line;
checknext(ls, '(');
if (needself) {
new_localvarliteral(ls, "self", 0);
adjustlocalvars(ls, 1);
}
parlist(ls);
checknext(ls, ')');
chunk(ls);
new_fs.f->lastlinedefined = ls->linenumber;
check_match(ls, TK_END, TK_FUNCTION, line);
close_func(ls);
pushclosure(ls, &new_fs, e);
}

LuaJit

iOS的luajit

  1. LuaJIT分为JIT模式和Interpreter模式
  • JIT模式: 高效的机器码级别执行, 然而不幸的是这个模式在iOS下是无法开启的,因为iOS为了安全,从系统设计上禁止了用户进程自行申请有执行权限的内存空间,因此你没有办法在运行时编译出一段代码到内存然后执行,所以JIT模式在iOS以及其他有权限管制的平台(例如PS4,XBox)都不能使用。
  • Interpreter模式:编译成中间态的字节码(bytecode), 相比之下当然比JIT慢。但好处是这个模式不需要运行时生成可执行机器码字节码是不需要申请可执行内存空间的
  1. JIT模式一定更快?不一定!
    参考下图,这里可以看到,第一,Interpreter模式是必须的,无论平台是否允许JIT,都必须先使用Interpreter执行;第二,并非所有代码都会JIT执行,仅仅是部分代码会这样,并且是运行过程中决定的。

优化

  1. Reduce number of unbiased/unpredictable branches.减少不可预测的分支代码
  2. Use FFI data structures. 如果可以,将你的数据结构用ffi实现,而不是用lua table实现
  3. Call C functions only via the FFI.尽可能用ffi来调用c函数。
  4. Use plain ‘for i=start,stop,step do … end’ loops.实现循环时,最好使用简单的for i = start, stop, step do这样的写法,或者使用ipairs,而尽量避免使用for k,v in pairs(x) do
  5. Find the right balance for unrolling.循环展开,有利有弊,需要自己去平衡
  6. Define and call only ‘local’ (!) functions within a module.
  7. Cache often-used functions from other modules in upvalues.
  8. Avoid inventing your own dispatch mechanisms.避免使用你自己实现的分发调用机制,而尽量使用內建的例如metatable这样的机制
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    -- 编程的时候为了结构优雅,常常会引入像消息分发这样的机制,然后在消息来的时候根据我们给消息定义的枚举来调用对应的实现,过去我们也习惯写成:

    if opcode == OP_1 then

    elesif opcode == OP_2 then

    ...

    -- 但在luajit下,更建议将上面实现成table或者metatable

    local callbacks = {}

    callbacks[OP_1] = function() ... end

    callbacks[OP_2] = function() ... end

    -- 这是因为表查找和metatable查找都是可以参与jit优化的,而自行实现的消息分发机制,往往会用到分支代码或者其他更复杂的代码结构,性能上反而不如纯粹的表查找+jit优化来得快
  9. Do not try to second-guess the JIT compiler.无需过多去帮jit编译器做手工优化。
  10. Be careful with aliasing, esp. when using multiple arrays.变量的别名可能会阻止jit优化掉子表达式,尤其是在使用多个数组的时候。
  11. Reduce the number of live temporary variables.减少存活着的临时变量的数量
  12. Do not intersperse expensive or uncompiled operations.减少使用高消耗或者不支持jit的操作

Lua的API

参考api文档

#define lua_open() luaL_newstate() 开启一个lua状态机

1
2
3
4
5
6
7
8
9
void lua_settable (lua_State *L, int index);
// Does the equivalent to t[k] = v,
// where t is the value at the given valid index index,
// v is the value at the top of the stack,
// and k is the value just below the top.

// This function pops both the key and the value from the stack.
// As in Lua, this function may trigger a metamethod
// for the “newindex” event (see §2.8).
  1. lua_pop(L,num)函数从栈顶开始移除。
    当num>0时从栈顶移除指定个数 。
    当num=0时栈不受影响
    当num=-1时栈中元素全部移除

  2. lua_settop函数说明
    该函数用于指定栈的高度,栈只能从栈顶压栈,不能从栈底添加数据。所以栈底的数据会保持不变。
    当新的高度大于原来的高度时,会从栈顶压入数据,压入的数据不可用(因为是随机的)。
    当新的高度小于原来的高度时,会从栈顶移除多余的元素。
    当输入参数为负数时,表示从栈顶开始的索引(最栈顶元素为-1)。该函数会移除栈顶到该元素之间的所以元素。-1则无,-2 则移除-1 。-3则移除-1,-2。以此类推。但是负数编号不能超出栈底的负数索引,超出会抛出异常。lua_pop函数及是使用了该特性。

  3. int luaL_getmetatable (lua_State L,constchartname);
    将注册表中 tname 对应的元表(参见 luaL_newmetatable)压栈。如果没有 tname 对应的元表,则将 nil 压栈并返回假。

参考

https://blog.uwa4d.com/archives/usparkle_luajit.html

http://wiki.luajit.org/Numerical-Computing-Performance-Guide

深入浅出

入门级

  1. 用 ARC 管理内存

  2. 在正确的地方使用 reuseIdentifier

  3. 尽量把 views 设置为透明

  4. 避免过于庞大的 XIB

  5. 不要阻塞主线程

  6. 在 ImageViews 中调整图片大小。如果要在 UIImageView 中显示一个来自 bundle 的图片,你应保证图片 的大小和 UIImageView 的大小相同。在运行中缩放图片是很耗费资源的,特别是 UIImageView 嵌套在 UIScrollView 中的情况下。如果图片是从远端服务加载的你不能控制图片大小,比如在下载前调整到合适大 小的话,你可以在下载完成后,最好是用 background thread,缩放一次,然后在 UIImageView 中使用缩放后的图片。

  7. 选择正确的 Collection。

    • Arrays: 有序的一组值。使用 index 来 lookup 很快,使用 value lookup 很慢, 插入/删除很慢。
    • Dictionaries: 存储键值对。 用键来查找比较快。
    • Sets: 无序的一组值。用值来查找很快,插入/删除很快。
  8. 打开 gzip 压缩。app 可能大量依赖于服务器资源,问题是我们的目标是移动设备,因此你就不能指望网 络状况有多好。减小文档的一个方式就是在服务端和你的 app 中打开 gzip。这对于文字这种能有更高压缩 率的数据来说会有更显著的效用。
    iOS 已经在 NSURLConnection 中默认支持了 gzip 压缩,当然 AFNetworking 这些基于它的框架亦然。

中级

  1. 重用和延迟加载(lazy load) Views

    • 更多的view意味着更多的渲染,也就是更多的CPU和内存消耗,对于那种嵌套了很多view在 UIScrollView 里边的 app 更是如此。
    • 这里我们用到的技巧就是模仿UITableView和UICollectionView的操作:不要一次创建所有的subview, 而是当需要时才创建,当它们完成了使命,把他们放进一个可重用的队列中。这样的话你就只需要在 滚动发生时创建你的 views,避免了不划算的内存分配。
  2. Cache, Cache, 还是 Cache!

    • 一个极好的原则就是,缓存所需要的,也就是那些不大可能改变但是需要经常读取的东西。
    • 我们能缓存些什么呢?一些选项是,远端服务器的响应,图片,甚至计算结果,比如UITableView的行高。
    • NSCache 和 NSDictionary 类似,不同的是系统回收内存的时候它会自动删掉它的内容。
  3. 权衡渲染方法.性能能还是要 bundle 保持合适的大小。

  4. 处理内存警告.移除对缓存,图片 object 和其他一些可以重创建的 objects 的 strong references.

  5. 重用大开销对象

  6. 一些 objects 的初始化很慢,比如 NSDateFormatter 和 NSCalendar。然而,你又不可避免地需要使用它们, 比如从 JSON 或者 XML 中解析数据。想要避免使用这个对象的瓶颈你就需要重用他们,可以通过添加属性 到你的 class 里或者创建静态变量来实现。

  7. 避免反复处理数据.在服务器端和客户端使用相同的数据结构很重要。

  8. 选择正确的数据格式.解析 JSON 会比 XML 更快一些,JSON 也通常更小更便于传输。从 iOS5 起有了官方 内建的 JSON deserialization 就更加方便使用了。但是 XML 也有 XML 的好处,比如使用 SAX 来解析 XML 就 像解析本地文件一样,你不需像解析 json 一样等到整个文档下载完成才开始解析。当你处理很大的数据的 时候就会极大地减低内存消耗和增加性能。

  9. 正确设定背景图片

    • 全屏背景图,在view中添加一个UIImageView作为一个子View
    • 只是某个小的view的背景图,你就需要用UIColor的colorWithPatternImage来做了,它会更快地渲染也不会花费很多内存:
  10. 减少使用 Web 特性。想要更高的性能你就要调整下你的 HTML 了。第一件要做的事就是尽可能移除不 必要的 javascript,避免使用过大的框架。能只用原生 js 就更好了。尽可能异步加载例如用户行为统计 script 这种不影响页面表达的 javascript。注意你使用的图片,保证图片的符合你使用的大小。

  11. Shadow Path 。CoreAnimation 不得不先在后台得出你的图形并加好阴影然后才渲染,这开销是很大的。 使用 shadowPath 的话就避免了这个问题。使用 shadow path 的话 iOS 就不必每次都计算如何渲染,它使用 一个预先计算好的路径。但问题是自己计算 path 的话可能在某些 View 中比较困难,且每当 view 的 frame 变化的时候你都需要去 update shadow path.

  12. 优化 Table View

    • 正确使用reuseIdentifier来重用cells
    • 尽量使所有的viewopaque,包括cell自身
    • 避免渐变,图片缩放,后台选人
    • 缓存行高
    • 如果cell内现实的内容来自web,使用异步加载,缓存请求结果
    • 使用shadowPath来画阴影
    • 减少subviews的数量
    • 尽量不适用cellForRowAtIndexPath:,如果你需要用到它,只用-一次然后缓存结果
    • 使用正确的数据结构来存储数据
    • 使用 rowHeight, sectionFooterHeight 和 sectionHeaderHeight 来设定固定的高,不要请求 delegate
  13. 选择正确的数据存储选项

    • NSUserDefaults 的问题是什么?虽然它很 nice 也很便捷,但是它只适用于小数据,比如一些简单的布 尔型的设置选项,再大点你就要考虑其它方式了
    • XML 这种结构化档案呢?总体来说,你需要读取整个文件到内存里去解析,这样是很不经济的。使用 SAX 又是一个很麻烦的事情。
    • NSCoding?不幸的是,它也需要读写文件,所以也有以上问题。
    • 在这种应用场景下,使用 SQLite 或者 Core Data 比较好。使用这些技术你用特定的查询语句就能只加载你需要的对象。
    • 在性能层面来讲,SQLite和CoreData是很相似的。他们的不同在于具体使用方法。
    • Core Data 代表一个对象的 graph model,但 SQLite 就是一个 DBMS。
    • Apple 在一般情况下建议使用 Core Data,但是如果你有理由不使用它,那么就去使用更加底层的 SQLite吧。
    • 如果你使用SQLite,你可以用FMDB这个库来简化SQLite的操作,这样你就不用花很多经历了解SQLite的C API了。

高级

  1. 加速启动时间。快速打开 app 是很重要的,特别是用户第一次打开它时,对 app 来讲,第一印象太太太 重要了。你能做的就是使它尽可能做更多的异步任务,比如加载远端或者数据库数据,解析数据。避免过 于庞大的 XIB,因为他们是在主线程上加载的。所以尽量使用没有这个问题的 Storyboards 吧!一定要把设 备从 Xcode 断开来测试启动速度
  2. 使用 Autorelease Pool。NSAutoreleasePool`负责释放 block 中的 autoreleased objects。一般情况下它会自 动被 UIKit 调用。但是有些状况下你也需要手动去创建它。假如你创建很多临时对象,你会发现内存一直在 减少直到这些对象被 release 的时候。这是因为只有当 UIKit 用光了 autorelease pool 的时候 memory 才会被 释放。消息是你可以在你自己的@autoreleasepool 里创建临时的对象来避免这个行为。
  3. 选择是否缓存图片。常见的从 bundle 中加载图片的方式有两种,一个是用 imageNamed,二是用 imageWithContentsOfFile,第一种比较常见一点。
  4. 避免日期格式转换。如果你要用 NSDateFormatter 来处理很多日期格式,应该小心以待。就像先前提到 的,任何时候重用 NSDateFormatters 都是一个好的实践。如果你可以控制你所处理的日期格式,尽量选择 Unix 时间戳。你可以方便地从时间戳转换到 NSDate:

如何提升 tableview 的流畅度?

本质上是降低 CPU、GPU 的工作,从这两个大的方面去提升性能。

  • CPU:对象的创建和销毁、对象属性的调整、布局计算、文本的计算和排版、图片的格式转换和解码、 图像的绘制
  • GPU:纹理的渲染

卡顿优化在 CPU 层面

  • 尽量用轻量级的对象,比如用不到事件处理的地方,可以考虑使用 CALayer 取代 UIView
  • 不要频繁地调用 UIView 的相关属性,比如 frame、bounds、transform 等属性,尽量减少不必要的
    修改
  • 尽量提前计算好布局,在有需要时一次性调整对应的属性,不要多次修改属性
  • Autolayout 会比直接设置 frame 消耗更多的 CPU 资源
  • 图片的 size 最好刚好跟 UIImageView 的 size 保持一致
  • 控制一下线程的最大并发数量
  • 尽量把耗时的操作放到子线程
  • 文本处理(尺寸计算、绘制) - 图片处理(解码、绘制)

卡顿优化在 GPU 层面

  • 尽量避免短时间内大量图片的显示,尽可能将多张图片合成一张进行显示
  • GPU 能处理的最大纹理尺寸是 4096x4096,一旦超过这个尺寸,就会占用 CPU 资源进行处理,所以
    纹理尽量不要超过这个尺寸
  • 尽量减少视图数量和层次
  • 减少透明的视图(alpha<1),不透明的就设置 opaque 为 YES
  • 尽量避免出现离屏渲染

1.预排版,提前计算
在接收到服务端返回的数据后,尽量将 CoreText 排版的结果、单个控件的高度、cell 整体的高度提 前计算好,将其存储在模型的属性中。需要使用时,直接从模型中往外取,避免了计算的过程。
尽量少用 UILabel,可以使用 CALayer 。避免使用 AutoLayout 的自动布局技术,采取纯代码的方 式

2.预渲染,提前绘制
例如圆形的图标可以提前在,在接收到网络返回数据时,在后台线程进行处理,直接存储在模型数据里,
回到主线程后直接调用就可以了
避免使用 CALayer 的 Border、corner、shadow、mask 等技术,这些都会触发离屏渲染。
3.异步绘制
4.全局并发线程 5.高效的图片异步加载

如何优化 APP 的电量?

程序的耗电主要在以下四个方面:

  • CPU 处理
  • 定位
  • 网络
  • 图像

优化的途径主要体现在以下几个方面:

  • 尽可能降低 CPU、GPU 的功耗。
  • 尽量少用 定时器。
  • 优化 I/O 操作。
    o 不要频繁写入小数据,而是积攒到一定数量再写入
    o 读写大量的数据可以使用 Dispatch_io ,GCD 内部已经做了优化。 o 数据量比较大时,建议使用数据库
  • 网络方面的优化
    o 减少压缩网络数据 (XML -> JSON -> ProtoBuf),如果可能建议使用 ProtoBuf。
    o 如果请求的返回数据相同,可以使用 NSCache 进行缓存
    o 使用断点续传,避免因网络失败后要重新下载。
    o 网络不可用的时候,不尝试进行网络请求
    o 长时间的网络请求,要提供可以取消的操作
    o 采取批量传输。下载视频流的时候,尽量一大块一大块的进行下载,广告可以一次下载
    多个 - 定位层面的优化
    o 如果只是需要快速确定用户位置,最好用 CLLocationManager 的 requestLocation 方法。 定位完成后,会自动让定位硬件断电
    o 如果不是导航应用,尽量不要实时更新位置,定位完毕就关掉定位服务
    o 尽量降低定位精度,比如尽量不要使用精度最高的 kCLLocationAccuracyBest
    o 需要后台定位时,尽量设置 pausesLocationUpdatesAutomatically 为 YES,如果用户不太
    可能移动的时候系统会自动暂停位置更新
    o 尽 量 不 要 使 用 startMonitoringSignificantLocationChanges , 优 先 考 虑
    startMonitoringForRegion:
  • 硬件检测优化
    用户移动、摇晃、倾斜设备时,会产生动作(motion)事件,这些事件由加速度计、陀螺仪、 磁力计等硬件检测。在不需要检测的场合,应该及时关闭这些硬件

如何有效降低 APP 包的大小?

可执行文件

  • 编译器优化
  • Strip Linked Product、Make Strings Read-Only、Symbols Hidden by Default 设置为 YES
  • 去掉异常支持,Enable C++ Exceptions、Enable Objective-C Exceptions 设置为 NO, Other C Flags 添加 -fno-exceptions
  • 利用 AppCode 检测未使用的代码:菜单栏 -> Code -> Inspect Code
  • 编写LLVM插件检测出重复代码、未被调用的代码
    资源
    资源包括 图片、音频、视频 等
  • 优化的方式可以对资源进行无损的压缩
  • 去除没有用到的资源

什么是 离屏渲染?什么情况下会触发?该如何应对?

离屏渲染出发的场景有以下:

  • 圆角(maskToBounds并用才会触发) - 图层蒙版
  • 阴影
  • 光栅化
    为什么要避免离屏渲染?
    CPUGPU 在绘制渲染视图时做了大量的工作。离屏渲染发生在 GPU 层面上,会创建新的渲染缓冲区,会 触发 OpenGL 的多通道渲染管线,图形上下文的切换会造成额外的开销,增加 GPU 工作量。如果 CPU GPU 累计耗时 16.67 毫秒还没有完成,就会造成卡顿掉帧。
    圆角属性、蒙层遮罩 都会触发离屏渲染。指定了以上属性,标记了它在新的图形上下文中,在未愈合之前, 不可以用于显示的时候就出发了离屏渲染。
  • 在OpenGL中,GPU有2种渲染方式
  • On-Screen Rendering:当前屏幕渲染,在当前用于显示的屏幕缓冲区进行渲染操作
  • Off-Screen Rendering:离屏渲染,在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作
  • 离屏渲染消耗性能的原因
  • 需要创建新的缓冲区
  • 离屏渲染的整个过程,需要多次切换上下文环境,先是从当前屏幕(On-Screen)切换到离屏 (Off-Screen);等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上,又需要将上下文 环境从离屏切换到当前屏幕
  • 哪些操作会触发离屏渲染?
  • 光栅化,layer.shouldRasterize = YES
  • 遮罩,layer.mask
  • 圆角,同时设置layer.masksToBounds=YES、layer.cornerRadius大于0
  • 考虑通过 CoreGraphics 绘制裁剪圆角,或者叫美工提供圆角图片
  • 阴影,layer.shadowXXX,如果设置了 layer.shadowPath 就不会产生离屏渲染

在 Obj-C 中,如何检测内存泄漏?你知道哪些方式?

  • Memory Leaks
  • Alloctions
  • Analyse
  • Debug Memory Graph
  • MLeaksFinder

泄露的内存主要有以下两种:

  • Laek Memory 这种是忘记 Release 操作所泄露的内存。
  • Abandon Memory 这种是循环引用,无法释放掉的内存。

什么是 悬垂指针?什么是 野指针?

  • 悬垂指针
    指针指向的内存已经被释放了,但是指针还存在,这就是一个 悬垂指针 或者说 迷途指针
  • 野指针
    没有进行初始化的指针,其实都是 野指针

retain,copy,assign,weak,_Unsafe_Unretain 关键字的理解

深拷贝 和 浅拷贝 的概念,集合类深拷贝如何实现

自动引用计数应遵循的原则

Dealloc

  1. Dealloc调用流程

    1. 首先调用 _objc_rootDealloc()
    2. 接下来调用 rootDealloc()
    3. 这时候会判断是否可以被释放,判断的依据主要有 5 个,判断是否有以上五种情况
      • NONPointer_ISA
      • weakly_reference
      • has_assoc
      • has_cxx_dtor
      • has_sidetable_rc
    4. 如果有以上五中任意一种,将会调用 object_dispose()方法,做下一步的处理。
    5. 如果没有之前五种情况的任意一种,则可以执行释放操作,C 函数的 free()。 5.执行完毕。
  2. object_dispose() 调用流程。

    1. 直接调用 objc_destructInstance()。
    2. 之后调用 C 函数的 free()。
  3. objc_destructInstance() 调用流程

    1. 先判断 hasCxxDtor,如果有 C++ 的相关内容,要调用 object_cxxDestruct() ,销毁 C++ 相关的内容。
    2. 再判断hasAssocitatedObjects,如果有的话,要调用object_remove_associations(), 销毁关联对象的一系列操作。
    3. 然后调用 clearDeallocating()。
    4. 执行完毕。
  4. clearDeallocating() 调用流程。

    1. 先执行 sideTable_clearDellocating()。
    2. 再执行 weak_clear_no_lock,在这一步骤中,会将指向该对象的弱引用指针置为 nil。
    3. 接下来执行 table.refcnts.eraser(),从引用计数表中擦除该对象的引用计数。
    4. 至此为止,Dealloc 的执行流程结束。

内存管理方案

  • taggedPointer :存储小对象如 NSNumber。深入理解 Tagged Pointer
  • NONPOINTER_ISA(非指针型的 isa):在 64 位架构下,isa 指针是占 64 比特位的,实际上只有 30 多位就
    已经够用了,为了提高利用率,剩余的比特位存储了内存管理的相关数据内容。
  • 散列表:复杂的数据结构,包括了引用计数表和弱引用表
    通过 SideTables()结构来实现的,SideTables()结构下,有很多 SideTable 的数据结构。
    而 sideTable 当中包含了自旋锁,引用计数表,弱引用表。 SideTables()实际上是一个哈希表,通过对象的地址来计算该对象的引用计数在哪个 sideTable 中。

@autoreleasePool 的数据结构?

简单说是双向链表,每张链表头尾相接,有 parent、child 指针 每创建一个池子,会在首部创建一个 哨兵 对象,作为标记
最外层池子的顶端会有一个 next 指针。当链表容量满了,就会在链表的顶端,并指向下一张表。

BAD_ACCESS 在什么情况下出现?

访问了已经被销毁的内存空间,就会报出这个错误。
根本原因是有 悬垂指针 没有被释放。

autoReleasePool 什么时候释放?

App 启动后,苹果在主线程 RunLoop 里注册了两个 Observer,其回调都是
_wrapRunLoopWithAutoreleasePoolHandler()。

  • 第一个 Observer 监视的事件是 Entry(即将进入 Loop),其回调内会调用 _objc_autoreleasePoolPush() 创建自动释放池。其 order 是 -2147483647,优先级最高,保证创 建释放池发生在其他所有回调之前。

  • 第二个 Observer 监视了两个事件: BeforeWaiting(准备进入休眠) 时调用 _objc_autoreleasePoolPop() 和 _objc_autoreleasePoolPush() 释放旧的池并创建新池;Exit(即 将退出 Loop) 时调用 _objc_autoreleasePoolPop() 来释放自动释放池。这个 Observer 的 order 是 2147483647,优先级最低,保证其释放池子发生在其他所有回调之后。

  • 线程结束 Tls_dealloc 释放

  • runloop进入任务,结束任务时 释放

先看我画的一张图

野指针探测实现1

1、通过fishhook替换C函数的free方法为自定义的safe_free,类似于Method Swizzling
2、在safe_free方法中对已经释放变量的内存,填充0x55,使已经释放变量不能访问,从而使某些野指针的crash从不必现安变成必现。

  • 为了防止填充0x55的内存被新的数据内容填充,使野指针crash变成不必现,在这里采用的策略是,safe_free不释放这片内存,而是自己保留着,即safe_free方法中不会真的调用free。
  • 同时为了防止系统内存过快消耗(因为要保留内存),需要在保留的内存大于一定值时释放一部分,防止被系统杀死,同时,在收到系统内存警告时,也需要释放一部分内存

3、发生crash时,得到的崩溃信息有限,不利于问题排查,所以这里采用代理类(即继承自NSProxy的子类),重写消息转发的三个方法,以及NSObject的实例方法,来获取异常信息。但是这的话,还有一个问题,就是NSProxy只能做OC对象的代理,所以需要在safe_free中增加对象类型的判断

Zombie Objects

僵尸对象

可以用来检测内存错误(EXC_BAD_ACCESS),它可以捕获任何阐释访问坏内存的调用
给僵尸对象发送消息的话,它仍然是可以响应的,然后会发生崩溃,并输出错误日志来显示野指针对象调用的类名和方法

苹果的僵尸对象检测原理
从dealloc的源码中,我们可以看到“Replaced by NSZombie”,即对象释放时, NSZombie 将在 dealloc 里做替换,如下所示
所以僵尸对象的生成过程伪代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//1、获取到即将deallocted对象所属类(Class)
Class cls = object_getClass(self);

//2、获取类名
const char *clsName = class_getName(cls)

//3、生成僵尸对象类名
const char *zombieClsName = "_NSZombie_" + clsName;

//4、查看是否存在相同的僵尸对象类名,不存在则创建
Class zombieCls = objc_lookUpClass(zombieClsName);
if (!zombieCls) {
//5、获取僵尸对象类 _NSZombie_
Class baseZombieCls = objc_lookUpClass(“_NSZombie_");

//6、创建 zombieClsName 类
zombieCls = objc_duplicateClass(baseZombieCls, zombieClsName, 0);
}
//7、在对象内存未被释放的情况下销毁对象的成员变量及关联引用。
objc_destructInstance(self);

//8、修改对象的 isa 指针,令其指向特殊的僵尸类
objc_setClass(self, zombieCls);

野指针探测实现2

野指针检测流程
1、开启野指针检测
2、设置监控到野指针时的回调block,在block中打印信息,或者存储堆栈
3、检测到野指针是否crash
4、最大内存占用空间
5、是否记录dealloc调用栈
6、监控策略
1)只监控自定义对象
2)白名单策略
3)黑名单策略
4)监控所有对象
7、交换NSObject的dealloc方法

触发野指针
1、开始处理对象
2、是否达到替换条件
1)根据监控策略,是否属于要检测的类
2)空间是否足够
3、如果符合条件,则获取对象,并解除引用,如果不符合则正常释放,即调用原来的dealloc方法
4、向对象内填充数据
5、赋值僵尸对象的类指针替换isa
6、对象+dealloc调用栈,保存在僵尸对象中
7、根据情况是否清理内存和对象

通过僵尸对象检测的实现思路
1、通过OC中Mehod Swizzling,交换根类NSObject和NSProxy的dealloc方法为自定义的dealloc方法
2、为了避免内存空间释放后被重写造成野指针的问题,通过字典存储被释放的对象,同时设置在30s后调用dealloc方法将字典中存储的对象释放,避免内存增大
3、为了获取更多的崩溃信息,这里同样需要创建NSProxy的子类

具体实现

1、创建NSProxy的子类
2、hook dealloc函数的具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
@interface MIZombieSniffer : NSObject

/*!
* @method installSniffer
* 启动zombie检测
*/
+ (void)installSniffer;

/*!
* @method uninstallSnifier
* 停止zombie检测
*/
+ (void)uninstallSnifier;

/*!
* @method appendIgnoreClass
* 添加白名单类
*/
+ (void)appendIgnoreClass: (Class)cls;

@end

<!--2、MIZombieSniffer.m-->
#import "MIZombieSniffer.h"
#import "MIZombieProxy.h"
#import <objc/runtime.h>

//
typedef void (*MIDeallocPointer) (id objc);
//野指针探测器是否开启
static BOOL _enabled = NO;
//根类
static NSArray *_rootClasses = nil;
//用于存储被释放的对象
static NSDictionary<id, NSValue*> *_rootClassDeallocImps = nil;

//白名单
static inline NSMutableSet *__mi_sniffer_white_lists(){
//创建白名单集合
static NSMutableSet *mi_sniffer_white_lists;
//单例初始化白名单集合
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
mi_sniffer_white_lists = [[NSMutableSet alloc] init];
});
return mi_sniffer_white_lists;
}


static inline void __mi_dealloc(__unsafe_unretained id obj){
//获取对象的类
Class currentCls = [obj class];
Class rootCls = currentCls;

//获取非NSObject和NSProxy的类
while (rootCls != [NSObject class] && rootCls != [NSProxy class]) {
//获取rootCls的父类,并赋值
rootCls = class_getSuperclass(rootCls);
}
//获取类名
NSString *clsName = NSStringFromClass(rootCls);
//根据类名获取dealloc的imp指针
MIDeallocPointer deallocImp = NULL;
[[_rootClassDeallocImps objectForKey:clsName] getValue:&deallocImp];

if (deallocImp != NULL) {
deallocImp(obj);
}
}

//hook交换dealloc
static inline IMP __mi_swizzleMethodWithBlock(Method method, void *block){
/*
imp_implementationWithBlock :接收一个block参数,将其拷贝到堆中,返回一个trampoline
可以让block当做任何一个类的方法的实现,即当做类的方法的IMP来使用
*/
IMP blockImp = imp_implementationWithBlock((__bridge id _Nonnull)(block));
//method_setImplementation 替换掉method的IMP
return method_setImplementation(method, blockImp);
}

@implementation MIZombieSniffer

//初始化根类
+ (void)initialize
{
_rootClasses = [@[[NSObject class], [NSProxy class]] retain];
}

#pragma mark - public
+ (void)installSniffer{
@synchronized (self) {
if (!_enabled) {
//hook根类的dealloc方法
[self _swizzleDealloc];
_enabled = YES;
}
}
}

+ (void)uninstallSnifier{
@synchronized (self) {
if (_enabled) {
//还原dealloc方法
[self _unswizzleDealloc];
_enabled = NO;
}
}
}

//添加百名单
+ (void)appendIgnoreClass:(Class)cls{
@synchronized (self) {
NSMutableSet *whiteList = __mi_sniffer_white_lists();
NSString *clsName = NSStringFromClass(cls);
[clsName retain];
[whiteList addObject:clsName];
}
}

#pragma mark - private
+ (void)_swizzleDealloc{
static void *swizzledDeallocBlock = NULL;

//定义block,作为方法的IMP
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
swizzledDeallocBlock = (__bridge void *)[^void(id obj) {
//获取对象的类
Class currentClass = [obj class];
//获取类名
NSString *clsName = NSStringFromClass(currentClass);
//判断该类是否在白名单类
if ([__mi_sniffer_white_lists() containsObject: clsName]) {
//如果在白名单内,则直接释放对象
__mi_dealloc(obj);
} else {
//修改对象的isa指针,指向MIZombieProxy
/*
valueWithBytes:objCType 创建并返回一个包含给定值的NSValue对象,该值会被解释为一个给定的NSObject类型
- 参数1:NSValue对象的值
- 参数2:给定值的对应的OC类型,需要使用编译器指令@encode来创建
*/
NSValue *objVal = [NSValue valueWithBytes: &obj objCType: @encode(typeof(obj))];
//为obj设置指定的类
object_setClass(obj, [MIZombieProxy class]);
//保留对象原本的类
((MIZombieProxy *)obj).originClass = currentClass;

//设置在30s后调用dealloc将存储的对象释放,避免内存空间的增大
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
__unsafe_unretained id deallocObj = nil;
//获取需要dealloc的对象
[objVal getValue: &deallocObj];
//设置对象的类为原本的类
object_setClass(deallocObj, currentClass);
//释放
__mi_dealloc(deallocObj);
});
}
} copy];
});

//交换了根类NSObject和NSProxy的dealloc方法为originalDeallocImp
NSMutableDictionary *deallocImps = [NSMutableDictionary dictionary];
//遍历根类
for (Class rootClass in _rootClasses) {
//获取指定类中dealloc方法
Method oriMethod = class_getInstanceMethod([rootClass class], NSSelectorFromString(@"dealloc"));
//hook - 交换dealloc方法的IMP实现
IMP originalDeallocImp = __mi_swizzleMethodWithBlock(oriMethod, swizzledDeallocBlock);
//设置IMP的具体实现
[deallocImps setObject: [NSValue valueWithBytes: &originalDeallocImp objCType: @encode(typeof(IMP))] forKey: NSStringFromClass(rootClass)];
}
//_rootClassDeallocImps字典存储交换后的IMP实现
_rootClassDeallocImps = [deallocImps copy];
}

+ (void)_unswizzleDealloc{
//还原dealloc交换的IMP
[_rootClasses enumerateObjectsUsingBlock:^(Class rootClass, NSUInteger idx, BOOL * _Nonnull stop) {
IMP originDeallocImp = NULL;
//获取根类类名
NSString *clsName = NSStringFromClass(rootClass);
//获取hook后的dealloc实现
[[_rootClassDeallocImps objectForKey:clsName] getValue:&originDeallocImp];

NSParameterAssert(originDeallocImp);
//获取原本的dealloc实现
Method oriMethod = class_getInstanceMethod([rootClass class], NSSelectorFromString(@"dealloc"));
//还原dealloc的实现
method_setImplementation(oriMethod, originDeallocImp);
}];
//释放
[_rootClassDeallocImps release];
_rootClassDeallocImps = nil;
}

@end