洋仔的博客 洋仔的博客
首页
  • 个人心法总结

    • 价值心法
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • iOS基础知识
  • 前端
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 投资体系
  • 毛选
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

洋仔

奋斗的小青年
首页
  • 个人心法总结

    • 价值心法
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • iOS基础知识
  • 前端
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 投资体系
  • 毛选
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • 技术文档

  • GitHub技巧

  • Nodejs

  • 博客搭建

  • iOS基础知识

    • iOS底层相关

    • Runloop系列

    • Runtime系列

    • 内存管理系列

    • Block系列

    • 线程系列

    • KVC跟KVO系列以及通知中心

    • UI系列

    • 离屏渲染系列

    • 组件化系列跟架构

    • OC跟webview交互系列

    • 持久化系列

    • APP编译系列

    • APP性能优化系列

      • APP性能优化系列
        • APP 启动优化
          • 冷启动
          • 计算各个流程启动所消耗的时间
          • 优化启动时间
          • 总结
          • main()函数执行之后
        • APP启动优化之二进制重排
          • 内存加载原理
          • 二进制重排技术原理
          • 如何查看 page fault
          • 二进制重排具体如何操作
          • Link Map
        • 应用防止崩溃系列
          • 容错处理你们一般是注意哪些?
          • 项目开始容错处理没做?如何防止拦截潜在的崩溃?
          • 一个上线的项目,知道这个方法可能会出问题,在不破坏改方法前提下,怎么搞?
          • @try @catch异常机制
        • 应用瘦身系列
          • iOS App包的组成
        • 提升APP流畅度
          • 如何提升 tableview 的流畅度
          • iOS 保持界面流畅的技巧
          • 卡顿监控
        • 如何优化 APP 的电量?
          • 优化的途径主要体现在以下几个方面:
      • APP启动流程
      • 货拉拉启动优化
      • 货拉拉包体积优化
      • 货拉拉iOS弹窗调度方案设计与实践
    • cocoapods系列

    • swift系列

    • Git系列

    • 网络相关

    • 三方库系列

    • 系统原理

    • 总结系列

    • 算法系列

    • 数据结构系列

  • 前端

  • 技术
  • iOS基础知识
  • APP性能优化系列
洋仔
2023-08-11
目录

APP性能优化系列

# APP 启动优化

我们将 App 启动方式分为:

冷启动: App 启动时,应用进程不在系统中(初次打开或程序被杀死),需要系统分配新的进程来启动应用。

热启动: App 退回后台后,对应的进程还在系统中,启动则将应用返回前台展示。

# 冷启动

冷启动1

APP冷启动可以概括为三个阶段

  1. dyld:加载镜像,动态库
  2. RunTime方法
  3. main函数初始化

1、dyld

dyld(dynamic link editor),app的动态链接器,可以用来装在Mach-O文件(可执行文件、动态库等)

启动APP时,dyld所做的事情有 冷启动2

真正的加载过程从exec()函数开始,exec()是一个系统调用。操作系统首先为进程分配一段内存空间,然后执行如下操作:

  • 1、把App对应的可执行文件加载到内存。
  • 2、把Dyld加载到内存。
  • 3、Dyld进行动态链接。

具体内容

  • 1、加载动态库
    • Dyld从主执行文件的header获取到需要加载的所依赖动态库列表,然后它需要找到每个 dylib,而应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以所需要加载的是动态库列表一个递归依赖的集合
      • 2、Rebase和Bind
    • 1、Rebase在Image内部调整指针的指向。在过去,会把动态库加载到指定地址,所有指针和数据对于代码都是对的,而现在地址空间布局是随机化,所以需要在原来的地址根据随机的偏移量做一下修正
    • 2、Bind是把指针正确地指向Image外部的内容。这些指向外部的指针被符号(symbol)名称绑定,dyld需要去符号表里查找,找到symbol对应的实现

2、RunTime方法

冷启动3

在Dyld阶段加载结束以后就进入了RunTime阶段

  • 1、Objc setup
    • 1、注册Objc类 (class registration)
    • 2、把category的定义插入方法列表 (category registration)
    • 3、保证每一个selector唯一 (selector uniquing)
  • 2、Initializers
    • 1、Objc的+load()函数
    • 2、C++的构造函数属性函数
    • 3、非基本类型的C++静态全局变量的创建(通常是类或结构体)

3、main函数初始化

APP的启动由dyld主导,将可执行文件加载到内存,顺便加载所有依赖的动态库 并由runtime负责加载成objc定义的结构 所有初始化工作结束后,dyld就会调用main函数 接下来就是UIApplicationMain函数,AppDelegate的application:didFinishLaunchingWithOptions:方法

这个里面往往是最占用启动时间的地方,同时也是我们最为可控的地方。

冷启动4

早期由于业务比较简单,所有启动项都是不加以区分,简单地堆积到didFinishLaunchingWithOptions方法中,但随着业务的增加,越来越多的启动项代码堆积在一起,性能越来越差,启动也越来越占用时间。

# 计算各个流程启动所消耗的时间

一般而言,大家把iOS冷启动的过程定义为:从用户点击App图标开始到appDelegate didFinishLaunching方法执行完成为止。这个过程主要分为两个阶段:

  • T1:main()函数之前,即操作系统加载App可执行文件到内存,然后执行一系列的加载&链接等工作,最后执行至App的main()函数。
  • T2:main()函数之后,即从main()开始,到appDelegate的didFinishLaunchingWithOptions方法执行完毕。

冷启动6

main()函数之前

对于如何测试启动时间,Xcode 提供了一个很赞的方法,只需要在 Edit scheme -> Run -> Arguments 中将环境变量 DYLD_PRINT_STATISTICS 设为 1,就可以看到 main 之前各个阶段的时间消耗。

冷启动5

main()函数之前

总共使用了1.1s

加载动态库:242.77ms

指针重定位:671.81ms

objc类初始化:52.86ms

各种其他初始化:171.33ms

main()函数之后

main 到 didFinishLaunching 结束或者第一个 ViewController 的viewDidAppear 都是作为 main 之后启动时间的一个度量指标。这个时间统计直接打点计算就可以,不过当遇到时间较长需要排查问题时,只统计两个点的时间其实不方便排查,目前见到比较好用的方式就是为把启动任务规范化、粒子化,针对每个任务都有打点统计,这样方便后期问题的定位和优化。

我们也可以借助一些图形化工具来动态分析

  • Time Profiler Time Profiler是Xcode自带的时间性能分析工具,它按照固定的时间间隔来跟踪每一个线程的堆栈信息,通过统计比较时间间隔之间的堆栈状态,来推算某个方法执行了多久,并获得一个近似值

  • 火焰图 除了Time Profiler,火焰图也是一个分析CPU耗时的利器,相比于Time Profiler,火焰图更加清晰。火焰图分析的产物是一张调用栈耗时图片,之所以称为火焰图,是因为整个图形看起来就像一团跳动的火焰,火焰尖部是调用栈的栈顶,底部是栈底,纵向表示调用栈的深度,横向表示消耗的时间。一个格子的宽度越大,越说明其可能是瓶颈。分析火焰图主要就是看那些比较宽大的火苗。具体可以参考如何读懂火焰图? (opens new window)

# 优化启动时间

启动时间优化分成两部分

  • T1:main()函数之前,即操作系统加载App可执行文件到内存,然后执行一系列的加载&链接等工作,最后执行至App的main()函数。
  • T2:main()函数之后,即从main()开始,到appDelegate的didFinishLaunchingWithOptions方法执行完毕。

冷启动6

# main()函数之前

冷启动1

App开始启动后,系统首先加载可执行文件(自身App的所有.o文件的集合),然后加载动态链接库dyld,dyld是一个专门用来加载动态链接库的库。 执行从dyld开始,dyld从可执行文件的依赖开始, 递归加载所有的依赖动态链接库。

动态链接库包括:iOS 中用到的所有系统 framework,加载OC runtime方法的libobjc,系统级别的libSystem,例如libdispatch(GCD)和libsystem_blocks (Block)。

其实无论对于系统的动态链接库还是对于App本身的可执行文件而言,他们都算是image(镜像),而每个App都是以image(镜像)为单位进行加载的

什么是image

  • 1.executable可执行文件 比如.o文件。

  • 2.dylib 动态链接库 framework就是动态链接库和相应资源包含在一起的一个文件夹结构。

  • 3.bundle 资源文件 只能用dlopen加载,不推荐使用这种方式加载。

    除了我们App本身的可行性文件,系统中所有的framework比如UIKit、Foundation等都是以动态链接库的方式集成进App中的。

    系统使用动态链接有几点好处

    • 1、代码共用:很多程序都动态链接了这些 lib,但它们在内存和磁盘中中只有一份
    • 2、易于维护:由于被依赖的 lib 是程序执行时才链接的,所以这些 lib 很容易做更新,比如libSystem.dylib 是 libSystem.B.dylib 的替身,哪天想升级直接换成libSystem.C.dylib 然后再替换替身就行了
    • 3、减少可执行文件体积:相比静态链接,动态链接在编译时不需要打进去,所以可执行文件的体积要小很多。

    什么是ImageLoader

    image 表示一个二进制文件(可执行文件或 so 文件),里面是被编译过的符号、代码等,所以 ImageLoader 作用是将这些文件加载进内存,且每一个文件对应一个ImageLoader实例来负责加载

    分两步

    • 1、在程序运行时它先将动态链接的 image 递归加载 (也就是上面测试栈中一串的递归调用的时刻)
    • 2、再从可执行文件 image 递归加载所有符号。

    动态链接库加载的具体流程

    动态链接库的加载步骤具体分为5步:

  • 1、load dylibs image 读取库镜像文件

  • 2、Rebase image

  • 3、Bind image

  • 4、Objc setup

  • 5、 initializers

load dylibs image

在每个动态库的加载过程中, dyld需要:

  • 1、分析所依赖的动态库
  • 2、找到动态库的mach-o文件
  • 3、打开文件
  • 4、验证文件
  • 5、在系统核心注册文件签名
  • 6、对动态库的每一个segment调用mmap()

通常的,一个App需要加载100到400个dylibs, 但是其中的系统库被优化,可以很快的加载。 针对这一步骤的优化有:

  • 1、减少非系统库的依赖
  • 2、合并非系统库
  • 3、使用静态资源,比如把代码加入主程序

rebase/bind

由于ASLR(address space layout randomization)的存在,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要这2步来修复镜像中的资源指针,来指向正确的地址。 rebase修复的是指向当前镜像内部的资源指针; 而bind指向的是镜像外部的资源指针。 rebase步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO。bind在其后进行,由于要查询符号表,来指向跨镜像的资源,加上在rebase阶段,镜像已被读入和加密验证,所以这一步的瓶颈在于CPU计算。 通过命令行可以查看相关的资源指针:

xcrun dyldinfo -rebase -bind -lazy_bind myApp.App/myApp

优化该阶段的关键在于减少__DATA segment中的指针数量。我们可以优化的点有:

  • 1、减少Objc类数量, 减少selector数量
  • 2、减少C++虚函数数量
  • 3、转而使用swift stuct(其实本质上就是为了减少符号的数量)

Objc setup

这一步主要工作是:

  • 1、注册Objc类 (class registration)
  • 2、把category的定义插入方法列表 (category registration)
  • 3、保证每一个selector唯一 (selctor uniquing)

initializers

以上三步属于静态调整(fix-up),都是在修改__DATA segment中的内容,而这里则开始动态调整,开始在堆和堆栈中写入内容。 在这里的工作有:

  • 1、Objc的+load()函数
  • 2、C++的构造函数属性函数 形如attribute((constructor)) void DoSomeInitializationWork()
  • 3、非基本类型的C++静态全局变量的创建(通常是类或结构体)(non-trivial initializer) 比如一个全局静态结构体的构建,如果在构造函数中有繁重的工作,那么会拖慢启动速度

对于main()调用之前的耗时我们可以优化的点有

  • 1、减少不必要的framework,因为动态链接比较耗时
  • 2、check framework应当设为optional和required,如果该framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为optional,因为optional会有些额外的检查
  • 3、合并或者删减一些OC类,关于清理项目中没用到的类
  • 4、删减没有被调用到或者已经废弃的方法
  • 5、将不必须在+load方法中做的事情延迟到+initialize中
  • 6、尽量不要用C++虚函数(创建虚函数表有开销)

这个介绍一些工具

  • 1、去除没有用到的资源: https://github.com/tinymind/LSUnusedResources
  • 2、利用AppCode:https://www.jetbrains.com/objc/ 检测未使用的代码:菜单栏 -> Code -> Inspect Code
  • 3、可借助第三方工具解析LinkMap文件: https://github.com/huanxsd/LinkMap

生成LinkMap文件,可以查看可执行文件的具体组成

冷启动7

# main()函数之后

其实在main()函数之前我们能够进行优化的部分并没有多少,而且操作性也不大,更多的优化其实还是我们对我们代码的优化,很多启动时间的占用更多的是因为我们代码布局的问题。

冷启动4

相当一部分的项目都是所有的启动项不加分类一股脑的堆积在了didFinishLaunch中,其实这是相当不合理的。

通过对SDK的梳理和分析,我们发现启动项也需要根据所完成的任务被分类,有些启动项是需要刚启动就执行的操作,如Crash监控、统计上报等,否则会导致信息收集的缺失;有些启动项需要在较早的时间节点完成,例如一些提供用户信息的SDK、定位功能的初始化、网络初始化等;有些启动项则可以被延迟执行,如一些自定义配置,一些业务服务的调用、支付SDK、地图SDK等。我们所做的分阶段启动,首先就是把启动流程合理地划分为若干个启动阶段,然后依据每个启动项所做的事情的优先级把它们分配到相应的启动阶段,优先级高的放在靠前的阶段,优先级低的放在靠后的阶段。

冷启动8

我们虽然把各个功能的优先级给整理了出来,但是我们启动还是需要很长时间,我们并没有减少启动时间。这个时候我们需要充分利用串行队列

  • 1、启动页的利用

  • 2、广告页的利用

  • 3、rootViewController的viewController中viewDidAppear的利用

  • 4、缓存&首页预请求

    闪屏页的使用

    现在许多App在启动时并不直接进入首页,而是会向用户展示一个持续一小段时间的闪屏页,如果使用恰当,这个闪屏页就能帮我们节省一些启动时间。因为当一个App比较复杂的时候,启动时首次构建App的UI就是一个比较耗时的过程,假定这个时间是0.2秒,如果我们是先构建首页UI,然后再在Window上加上这个闪屏页,那么冷启动时,App就会实实在在地卡住0.2秒,但是如果我们是先把闪屏页作为App的RootViewController,那么这个构建过程就会很快。因为闪屏页只有一个简单的ImageView,而这个ImageView则会向用户展示一小段时间,这时我们就可以利用这一段时间来构建首页UI了,一举两得

冷启动9

广告页的利用

广告页好多也是需要等几秒的,我们也可以在加载广告页的时候,先立马展示一个空壳的 UI 给用户,然后在 viewDidAppear 方法里进行数据加载解析渲染等一系列操作,这样一来,用户已经看到界面了,就不会觉得是启动慢,这个时候的等待就变成等待数据请求了,这样就把这部分时间转嫁出去了。

viewDidAppear的利用

我们一些比较靠后的SDK的初始化我们可以在首页已经加载出来以后在初始化

缓存&首页预请求

使用缓存

# 总结

为此,我专门建了一个类来负责启动事件,为什么呢?如果不这么做,那么此次优化以后,以后再引入第三方的时候,别的同事可能很直觉的就把第三方的初始化放到了 didFinishLaunchingWithOptions 方法里,这样久而久之, didFinishLaunchingWithOptions 又变得不堪重负,到时候又要专门花时间来做重复的优化。

/**
* 注意: 这个类负责所有的 didFinishLaunchingWithOptions 延迟事件的加载.
* 以后引入第三方需要在 didFinishLaunchingWithOptions 里初始化或者我们自己的类需要在 didFinishLaunchingWithOptions 初始化的时候,
* 要考虑尽量少的启动时间带来好的用户体验, 所以应该根据需要减少 didFinishLaunchingWithOptions 里耗时的操作.
* 第一类: 比如日志 / 统计等需要第一时间启动的, 仍然放在 didFinishLaunchingWithOptions 中.
* 第二类: 比如用户数据需要在广告显示完成以后使用, 所以需要伴随广告页启动, 只需要将启动代码放到 startupEventsOnADTimeWithAppDelegate 方法里.
* 第三类: 比如直播和分享等业务, 肯定是用户能看到真正的主界面以后才需要启动, 所以推迟到主界面加载完成以后启动, 只需要将代码放到 startupEventsOnDidAppearAppContent 方法里.
*/

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface BLDelayStartupTool : NSObject

/**
* 启动伴随 didFinishLaunchingWithOptions 启动的事件.
* 启动类型为:日志 / 统计等需要第一时间启动的.
*/
+ (void)startupEventsOnAppDidFinishLaunchingWithOptions;

/**
* 启动可以在展示广告的时候初始化的事件.
* 启动类型为: 用户数据需要在广告显示完成以后使用, 所以需要伴随广告页启动.
*/
+ (void)startupEventsOnADTime;

/**
* 启动在第一个界面显示完(用户已经进入主界面)以后可以加载的事件.
* 启动类型为: 比如直播和分享等业务, 肯定是用户能看到真正的主界面以后才需要启动, 所以推迟到主界面加载完成以后启动.
*/
+ (void)startupEventsOnDidAppearAppContent;

@end

NS_ASSUME_NONNULL_END
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

提示

合并动态库,减少不必要的framework,特别是第三方的,因为动态链接比较耗时;

check framework应设为optional和required,如果该framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为optional,因为optional会有些额外的检查;

合并或者删减一些OC类,关于清理项目中没用到的类,可以借助AppCode代码检查工具:

删减一些无用的静态变量

删减没有被调用到或者已经废弃的方法

将不必在+load方法中做的事情延迟到+initialize中

尽量不要用C++虚函数(创建虚函数表有开销)

避免使用 attribute((constructor)),可将要实现的内容放在初始化方法中配合 dispatch_once 使用。

减少非基本类型的 C++ 静态全局变量的个数。(因为这类全局变量通常是类或者结构体,如果在构造函数中有繁重的工作,就会拖慢启动速度)

我们可以从原理上分析main函数执行之前做了一些什么事情:

加载可执行文件: 加载 Mach-O 格式文件,既 App 中所有类编译后生成的格式为 .o 的目标文件集合。

加载动态库 : dyld 加载 dylib 会完成如下步骤:

提示

分析 App 依赖的所有 dylib。

找到 dylib 对应的 Mach-O 文件。

打开、读取这些 Mach-O 文件,并验证其有效性。

在系统内核中注册代码签名

对 dylib 的每一个 segment 调用 mmap()。

系统依赖的动态库由于被优化过,可以较快的加载完成,而开发者引入的动态库需要耗时较久。

Rebase和Bind操作: 由于使用了ASLR 技术,在 dylib 加载过程中,需要计算指针偏移得到正确的资源地址。 Rebase 将镜像读入内存,修正镜像内部的指针,消耗 IO 性能;Bind 查询符号表,进行外部镜像的绑定,需要大量 CPU 计算。

Objc setup : 进行 Objc 的初始化,包括注册 Objc 类、检测 selector 唯一性、插入分类方法等。

Initializers : 往应用的堆栈中写入内容,包括执行 +load 方法、调用 C/C++ 中的构造器函数(用 attribute((constructor)) 修饰的函数)、创建非基本类型的 C++ 静态全局变量等。

# main()函数执行之后

衡量main()函数执行之后的耗时 第二阶段的耗时统计,我们认为是从main ()执行之后到applicationDidFinishLaunching:withOptions:方法最后,那么我们可以通过打点的方式进行统计。 Objective-C项目因为有main文件,所以我么直接可以通过添加代码获取:

// 1. 在 main.m 添加如下代码:
CFAbsoluteTime AppStartLaunchTime;

int main(int argc, char * argv[]) {
    AppStartLaunchTime = CFAbsoluteTimeGetCurrent();
  .....
}

// 2. 在 AppDelegate.m 的开头声明
extern CFAbsoluteTime AppStartLaunchTime;

// 3. 最后在AppDelegate.m 的 didFinishLaunchingWithOptions 中添加
dispatch_async(dispatch_get_main_queue(), ^{
  NSLog(@"App启动时间--%f",(CFAbsoluteTimeGetCurrent()-AppStartLaunchTime));
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

总的说来,main函数之后的优化有以下方式:

注意

尽量使用纯代码编写,减少xib的使用;

启动阶段的网络请求,是否都放到异步请求;

一些耗时的操作是否可以放到后面去执行,或异步执行等。

使用简单的广告页作为过渡,将首页的计算操作及网络请求放在广告页展示时异步进行。

涉及活动需变更页面展示时(例如双十一),提前下发数据缓存

首页控制器用纯代码方式来构建,而不是 xib/Storyboard,避免布局转换耗时。

避免在主线程进行大量的计算,将与首屏无关的计算内容放在页面展示后进行,缩短 CPU 计算时间。

避免使用大图片,减少视图数量及层级,减轻 GPU 的负担。

做好网络请求接口优化(DNS 策略等),只请求与首屏相关数据。

本地缓存首屏数据,待渲染完成后再去请求新数据。

# APP启动优化之二进制重排

上面1.1.1和1.1.2将的App启动相关的优化,都是基于一些代码层面,设计方面尽量做到好的优化减少启动时间。我们还有一种重操作系统底层原理方面的优化,也是属于main函数执行之前阶段的优化。

学过操作系统原理,我们就会知道我们操作系统加载内存的时候有分页和分段两种方式,由于手机的实际内存的限制,一般操作系统给我们的内存都是虚拟内存,也就是说内存需要做映射。如分页存储的方式,如果我们App需要的内存很大,App一次只能加载有限的内存页数,不能一次性将App所有的内存全部加载到内存中。 如果在APP启动过程中发现开始加载的页面没有在内存中,会发生缺页中断,去从磁盘找到缺少的页,从新加入内存。而缺页中断是很耗时的,虽然是毫秒级别的,但是,如果连续发生了多次这样的中断,则用户会明显感觉到启动延迟的问题。

知道了这个原理,我们就需要从分页这个方面来解决。我们用二进制重排的思想就是要将我们APP启动所需要的相关类,在编译阶段都重新排列,排到最前面,尽量避免,减少缺页中断发生的次数,从而达到启动优化的目的。 下面我们来详细分析一下内存加载的原理

# 内存加载原理

在早期的计算机中 , 并没有虚拟内存的概念 , 任何应用被从磁盘中加载到运行内存中时 , 都是完整加载和按序排列的 . 但是这样直接使用物理内存会存在一些问题:

提示

安全问题 : 由于在内存条中使用的都是真实物理地址 , 而且内存条中各个应用进程都是按顺序依次排列的 . 那么在 进程1 中通过地址偏移就可以访问到 其他进程 的内存 .

效率问题 : 随着软件的发展 , 一个软件运行时需要占用的内存越来越多 , 但往往用户并不会用到这个应用的所有功能 , 造成很大的内存浪费 , 而后面打开的进程往往需要排队等待 .

为了解决上面物理内存存在的问题,引入了虚拟内存的概念。引用了虚拟内存后 , 在我们进程中认为自己有一大片连续的内存空间实际上是虚拟的 , 也就是说从 0x000000 ~ 0xffffff 我们是都可以访问的 . 但是实际上这个内存地址只是一个虚拟地址 , 而这个虚拟地址通过一张映射表映射后才可以获取到真实的物理地址 .

整个虚拟内存的工作原理这里用一张图来展示 :

Screenshot-2023-08-15-at-7

引用虚拟内存后就不存在通过偏移可以访问到其他进程的地址空间的问题了 。因为每个进程的映射表是单独的 , 在你的进程中随便你怎么访问 , 这些地址都是受映射表限制的 , 其真实物理地址永远在规定范围内 , 也就不存在通过偏移获取到其他进程的内存空间的问题了 . 引入虚拟内存后 , cpu 在通过虚拟内存地址访问数据需要通过映射来找到真实的物理地址。过程如下:

提示

通过虚拟内存地址 , 找到对应进程的映射表 .

通过映射表找到其对应的真实物理地址 , 进而找到数据 .

学过操作系统,我们知道cpu内存寻址有两种方式:分页和分段两种方式。 虚拟内存和物理内存通过映射表进行映射 , 但是这个映射并不可能是一一对应的 , 那样就太过浪费内存了 ,我们知道物理内存实际就是一段连续的空间,如果全部分配给一个应用程序使用,这样会导致其他应用得不到响应. 为了解决效率问题 , 操作系统为了高效使用内存采用了分页和分段两种方式来管理内存。

对于我们这种多用户多进程的大部分都是采用分页的方式,操作系统将内存一段连续的内存分成很多页,每一页的大小都相同,如在 linux 系统中 , 一页内存大小为 4KB , 在不同平台可能各有不同 . Mac OS 系统内核也是基于linux的, 因此也是一页为 4KB。但是在iOS 系统中 , 一页为 16KB 。

内存被分成很多页后,就像我们的一本很厚的书本,有很多页,但是这么多页,如果没有目录,我们很难找到我们真正需要的那一页。而操作系统采用一个高速缓存来存放需要提前加载的页数。由于CPU的时间片很宝贵,CPU要负责做很多重要的事情,而直接从磁盘读取数据到内存的IO操作非常耗时,为了提高效率,采用了高速缓存模式,就是先将一部分需要的分页加载到高速缓存中,CPU需要读取的时候直接从高速缓存读取,而不去直接方法磁盘,这样就大大提高了CPU的使用效率,但是我们高速缓存大小也是很有限的,加载的页数是有限的,如果CPU需要读取的分页不在高速缓存中,则会发生缺页中断,从磁盘将需要的页加载到高速缓存。

如下图,是两个进程的虚拟页表映射关系: Screenshot-2023-08-15-at-9

如上图所示,映射表左侧的 0 和 1 代表当前地址有没有在物理内存中 当应用被加载到内存中时 , 并不会将整个应用加载到内存中 . 只会放用到的那一部分 . 也就是懒加载的概念 , 换句话说就是应用使用多少 , 实际物理内存就实际存储多少 .

当应用访问到某个地址 , 映射表中为 0 , 也就是说并没有被加载到物理内存中时 , 系统就会立刻阻塞整个进程 , 触发一个我们所熟知的 缺页中断 - Page Fault .

当一个缺页中断被触发 , 操作系统会从磁盘中重新读取这页数据到物理内存上 , 然后将映射表中虚拟内存指向对应 ( 如果当前内存已满 , 操作系统会通过置换页算法 找一页数据进行覆盖 , 这也是为什么开再多的应用也不会崩掉 , 但是之前开的应用再打开时 , 就重新启动了的根本原因 ). 操作系统通过这种分页和覆盖机制 , 就完美的解决了内存浪费和效率问题,但是由于采用了虚拟内存 , 那么其中一个函数无论如何运行 , 运行多少次 , 都会是虚拟内存中的固定地址 . 这样就会有漏洞,黑客可以很轻易的提前写好程序获取固定函数的实现进行修改 hook 操作 . 所以产生这个非常严重的安全性问题

提示

例如:假设应用有一个函数 , 基于首地址偏移量为 0x00a000 , 那么虚拟地址从 0x000000 ~ 0xffffff , 基于这个 , 那么这个函数我无论如何只需要通过 0x00a000 这个虚拟地址就可以拿到其真实实现地址 .

Android 4.0 , Apple iOS4.3 , OS X Mountain Lion10.8 开始全民引入 ASLR 技术 , 而实际上自从引入 ASLR 后 , 黑客的门槛也自此被拉高 . 不再是人人都可做黑客的年代

为了解决上面安全问题,引入了ASLR 技术 . 其原理就是 每次 虚拟地址在映射真实地址之前 , 增加一个随机偏移值。

通过上面对内存加载原理的讲解,我们了解了分页和缺页中断。而我们接下来要讲解的启动优化--二进制重排技术 就是基于上面的原理,尽量减少缺页中断发生的次数,从而达到减少启动时间的损耗,最终达到启动时间优化的目的。

# 二进制重排技术原理

在了解了内存分页会触发中断异常 Page Fault 会阻塞进程后 , 我们就知道了这个问题是会对性能产生影响的 . 实际上在 iOS 系统中 , 对于生产环境的应用 , 当产生缺页中断进行重新加载时 , iOS 系统还会对其做一次签名验证 . 因此 iOS 生产环境的应用 page fault 所产生的耗时要更多 .

提示

抖音团队分享的一个 Page Fault,开销在 0.6 ~ 0.8ms , 实际测试发现不同页会有所不同 , 也跟 cpu 负荷状态有关 , 在 0.1 ~ 1.0 ms 之间 。

当用户使用应用时 , 第一个直接印象就是启动 app 耗时 , 而恰巧由于启动时期有大量的类 , 分类 , 三方 等等需要加载和执行 , 多个 page fault 所产生的的耗时往往是不能小觑的 . 这也是二进制重排进行启动优化的必要性 .

假设在启动时期我们需要调用两个函数 method1 与 method4 . 函数编译在 mach-o 中的位置是根据 ld ( Xcode 的链接器) 的编译顺序并非调用顺序来的 . 因此很可能这两个函数分布在不同的内存页上 .

Screenshot-2023-08-15-at-9

那么启动时 , page1 与 page2 则都需要从无到有加载到物理内存中 , 从而触发两次 page fault . 而二进制重排的做法就是将 method1 与 method4 放到一个内存页中 , 那么启动时则只需要加载 page1 即可 , 也就是只触发一次 page fault , 达到优化目的 .

实际项目中的做法是将启动时需要调用的函数放到一起 ( 比如 前10页中 ) 以尽可能减少 page fault , 达到优化目的 . 而这个做法就叫做 : 二进制重排 .

# 如何查看 page fault

如果想查看真实 page fault 次数 , 应该将应用卸载 , 查看第一次应用安装后的效果 , 或者先打开很多个其他应用 . 因为之前运行过 app , 应用其中一部分已经被加载到物理内存并做好映射表映射 , 这时再启动就会少触发一部分缺页中断 , 并且杀掉应用再打开也是如此 .

其实就是希望将物理内存中之前加载的覆盖/清理掉 , 减少误差 . 查看步骤如下:

打开 Instruments , 选择 System Trace .

Screenshot-2023-08-15-at-9

选择真机 , 选择工程 , 点击启动 , 当首个页面加载出来点击停止 . 这里注意 , 最好是将应用杀掉重新安装 , 因为冷热启动的界定其实由于进程的原因并不一定后台杀掉应用重新打开就是冷启动 .

等待分析完成 , 查看缺页次数

如下图是后台杀掉重启应用的情况:

Screenshot-2023-08-15-at-9

如下图是第一次安装启动应用的情况:

Screenshot-2023-08-15-at-9

此外,你还可以通过添加 DYLD_PRINT_STATISTICS 来查看 pre-main 阶段总耗时来做一个侧面辅证

# 二进制重排具体如何操作

二进制重排具体操作,其实很简单 , Xcode 已经提供好这个机制 , 并且 libobjc 实际上也是用了二进制重排进行优化 .

在objc4-750源码中提供了libobjc.order 如下图:

Screenshot-2023-08-15-at-9

我们在Xcode中通过如下步骤来进行二进制重排:

首先 , Xcode 是用的链接器叫做 ld , ld 有一个参数叫 Order File , 我们可以通过这个参数配置一个 order 文件的路径 .

在这个 order 文件中 , 将你需要的符号按顺序写在里面

当工程 build 的时候 , Xcode 会读取这个文件 , 打的二进制包就会按照这个文件中的符号顺序进行生成对应的 mach-O .

Screenshot-2023-08-15-at-9

如何查看自己工程的符号顺序

重排前后我们需要查看自己的符号顺序有没有修改成功 , 这时候就用到了 Link Map .

# Link Map

Link Map 是编译期间产生的产物 , ( ld 的读取二进制文件顺序默认是按照 Compile Sources - GUI 里的顺序 ) , 它记录了二进制文件的布局 . 通过设置 Write Link Map File 来设置输出与否 , 默认是 no . Screenshot-2023-08-15-at-9

修改完毕后 clean 一下 , 运行工程 , Products - show in finder, 找到 macho 的上上层目录.

Screenshot-2023-08-15-at-9

按下图依次找到最新的一个 .txt 文件并打开. Screenshot-2023-08-15-at-9

这个文件中就存储了所有符号的顺序 , 在 # Symbols: 部分:

Screenshot-2023-08-15-at-9

上述文件中最左侧地址就是 实际代码地址而并非符号地址 , 因此我们二进制重排并非只是修改符号地址 , 而是利用符号顺序 , 重新排列整个代码在文件的偏移地址 , 将启动需要加载的方法地址放到前面内存页中 , 以此达到减少 page fault 的次数从而实现时间上的优化. 可以看到 , 这个符号顺序明显是按照 Compile Sources 的文件顺序来排列的 . Screenshot-2023-08-15-at-9

# 应用防止崩溃系列

# 容错处理你们一般是注意哪些?

在团队协作开发当中,由于每个团队成员的水平不一,很难控制代码的质量,保证代码的健壮性,经常会发生由于后台返回异常数据造成app崩溃闪退的情况,为了避免这样的情况项目中做一些容错处理,显得格外重要,极大程度上降低了因为数据容错不到位产生崩溃闪退的概率。

例如:
1.字典
2.数组;
3.野指针;
4.NSNull
等~
// AvoidCrash github 三方不错
1
2
3
4
5
6
7

# 项目开始容错处理没做?如何防止拦截潜在的崩溃?

1、category给类添加方法用来替换掉原本存在潜在崩溃的方法。 2、利用runtime方法交换技术,将系统方法替换成类添加的新方法。 3、利用异常的捕获来防止程序的崩溃,并且进行相应的处理。 4、使用 @try__Catch__方法进行拦截

总结: 1、不要过分相信服务器返回的数据会永远的正确。 2、在对数据处理上,要进行容错处理,进行相应判断之后再处理数据,这是一个良好的编程习惯。

# 一个上线的项目,知道这个方法可能会出问题,在不破坏改方法前提下,怎么搞?

  • 做一些容错处理,防止崩溃
  • 加一些日志收集,收集问题再具体分析
  • try_catch

# @try @catch异常机制

Objective-C 异常机制 : -- 作用 : 开发者将引发异常的代码放在 @try 代码块中, 程序出现异常 使用 @catch 代码块进行捕捉; -- 每个代码块作用 : @try 代码块存放可能出现异常的代码, @catch 代码块 异常处理逻辑, @finally 代码块回收资源; -- 语法示例 :

try{
   //..执行的代码,其中可能有异常。一旦发现异常,则立即跳到catch执行。否则不会执行catch里面的内容
}catch(){
  //...除非try里面执行代码发生了异常,否则这里的代码不会执行
}finally{
  //..不管什么情况都会执行,包括try catch 里面用了return ,可以理解为只要执行了try或者catch,就一定会执行 finally
}
可以用于查找 bug,或者调试,防止崩溃使用
1
2
3
4
5
6
7
8

# 应用瘦身系列

APP瘦身 (opens new window) App Thinning“应用瘦身”,iOS9之后发布的新特性。它能对App store 和操作系统在安装iOS app 的时候通过一些列的优化,尽可能减少安装包的大小,使得 app 以最小的合适的大小被安装到你的设备上。而这个过程包括了三个过程: slicing, bitcode, on-demand resources,

* slicing
appStore 会根据用户的设备型号创建相应的应用变体,这些变体只包含可执行的结构和资源必要部分,不需要用户下载完整的安装包

* bitcode
bitcode系统会对编译后的二进制文件进行二次优化, 使用最新的编译器自动编译app并且针对特定架构进行优化。不会下载应用针对不同架构的优化,而仅下载与特定设备相关的优化,使得下载量更小,

* On Demand Resources
按需加载资源是在 app 第一次安装后可下载的文件。举例说明,当玩家解锁游戏的特定关卡后可以下载新关卡(和这个关卡相关的特定内容)。此外,玩家已经通过的关卡可以被移除以便节约设备上的存储空间。
1
2
3
4
5
6
7
8

# iOS App包的组成

上传AppStore的包是一个ipa文件,解压后得到.app文件,可以通过查看包内容的方式看到,一个应用程序安装包主要包括以下几个部分。分别是:应用程序二进制文件本身、应用程序扩展、嵌入的各种Framework,以及大量、各种类型的资源文件。一个完整的安装包,涵盖的内容,大体上可以如下图所示。 modb_20220720_866da34a-0813-11ed-80f2-fa163eb4f6be

Assets

废弃图片删除 大图片治理 重复图片合并 合适图片云端管理。

可执行文件

废弃文件治理 废弃代码治理 编译优化

# 提升APP流畅度

# 如何提升 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
  • 尽量避免出现离屏渲染

# iOS 保持界面流畅的技巧

  • 1.预排版,提前计算 在接收到服务端返回的数据后,尽量将 CoreText 排版的结果、单个控件的高度、cell 整体的高度提前计算好,将其存储在模型的属性中。需要使用时,直接从模型中往外取,避免了计算的过程。

尽量少用 UILabel,可以使用 CALayer 。避免使用 AutoLayout 的自动布局技术,采取纯代码的方式

  • 2.预渲染,提前绘制 例如圆形的图标可以提前在,在接收到网络返回数据时,在后台线程进行处理,直接存储在模型数据里,回到主线程后直接调用就可以了

避免使用 CALayer 的 Border、corner、shadow、mask 等技术,这些都会触发离屏渲染。

  • 3.异步绘制
  • 4.全局并发线程
  • 5.高效的图片异步加载

# 卡顿监控

卡顿监控一般有两种实现方案:

提示

主线程卡顿监控。通过子线程监测主线程的runLoop,判断两个状态区域之间的耗时是否达到一定阈值。

FPS监控。要保持流畅的UI交互,App 刷新率应该当努力保持在 60fps。FPS的监控实现原理,上面已经探讨过这里略过。

在使用FPS监控性能的实践过程中,发现 FPS 值抖动较大,造成侦测卡顿比较困难。为了解决这个问题,通过采用检测主线程每次执行消息循环的时间,当这一时间大于规定的阈值时,就记为发生了一次卡顿的方式来监控。

这也是美团的移动端采用的性能监控Hertz 方案,微信团队也在实践过程中提出来类似的方案--微信读书 iOS 性能优化总结。

# 如何优化 APP 的电量?

程序的耗电主要在以下四个方面: CPU 处理 定位 网络 图像

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

  • 尽可能降低 CPU、GPU 的功耗。
  • 尽量少用 定时器。
  • 优化 I/O 操作。

不要频繁写入小数据,而是积攒到一定数量再写入
数据量比较大时,建议使用数据库
读写大量的数据可以使用 Dispatch_io ,GCD 内部已经做了优化。
  • 网络方面的优化

减少压缩网络数据 (XML -> JSON -> ProtoBuf),如果可能建议使用 ProtoBuf。
如果请求的返回数据相同,可以使用 NSCache 进行缓存
使用断点续传,避免因网络失败后要重新下载。
网络不可用的时候,不尝试进行网络请求
长时间的网络请求,要提供可以取消的操作
采取批量传输。下载视频流的时候,尽量一大块一大块的进行下载,广告可以一次下载多个
  • 定位层面的优化

如果只是需要快速确定用户位置,最好用 CLLocationManager 的 requestLocation 方法。定位完成后,会自动让定位硬件断电
如果不是导航应用,尽量不要实时更新位置,定位完毕就关掉定位服务
尽量降低定位精度,比如尽量不要使用精度最高的 kCLLocationAccuracyBest
需要后台定位时,尽量设置 pausesLocationUpdatesAutomatically 为 YES,如果用户不太可能移动的时候系统会自动暂停位置更新
尽量不要使用 startMonitoringSignificantLocationChanges,优先考虑 startMonitoringForRegion:
  • 硬件检测优化

用户移动、摇晃、倾斜设备时,会产生动作(motion)事件,这些事件由加速度计、陀螺仪、磁力计等硬件检测。在不需要检测的场合,应该及时关闭这些硬件

编辑 (opens new window)
上次更新: 2024/10/23, 23:26:17
二进制插桩
APP启动流程

← 二进制插桩 APP启动流程→

最近更新
01
数组
10-25
02
数组双指针系列之对撞指针
10-25
03
数组双指针系列之快慢指针
10-25
更多文章>
Theme by Vdoing | Copyright © 2019-2024 Evan Xu | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式