iOS底层-性能优化

CPU和GPU

  1. 在屏幕成像的过程中,CPU和GPU起着至关重要的作用
  2. CPU(Central Processing Unit,中央处理器)
    1. 对象的创建和销毁、对象属性的调整、布局计算、文本的计算和排版、图片的格式转换和解码、图像的绘制(Core Graphics)
  3. GPU(Graphics Processing Unit,图形处理器)
    1. 纹理的渲染

    图4

    1. 手机屏幕上显示的文字和图片都是CPU和GPU配合处理显示出来的
    2. CPU负责计算,比如:文字的大小、颜色、位置,图片的解码等
    3. CPU将计算好的数据提交给GPU,GPU将计算好的数据进行渲染
    4. 只有通过GPU渲染过的才能显示到屏幕上
      1. CPU直接计算出来的数据是无法显示在屏幕上的
      2. 屏幕想要显示数据必须有固定的格式
      3. GPU专门是处理这个的,将CPU计算好的数据处理成屏幕上可以显示的格式
      4. 这个过程叫做渲染
    5. GPU将渲染后的数据放在了帧缓存中(一个缓冲区)
    6. 视屏控制器从帧缓存中读取数据,然后直接将这些数据显示在屏幕上
  4. 在iOS中是双缓冲机制,有前帧缓存、后帧缓存
    1. 意思就是iOS有2块帧缓存,可以相互协调
    2. 这样效率比较高

屏幕成像的原理

图4

  1. 屏幕上的画面都是帧一帧的显示的,就像电影交卷一样
  2. 要显示一帧画面时,先发出垂直同步信号,这就意味着要显示一页的数据
  3. 然后发出一条水平同步信号,填充一行
  4. 接着再次发出一条水平同步信号,填充下一行,直到填满整个帧
  5. 然后在发出一条垂直同步信号,显示下一帧。。。
  6. 每一帧连起来,就是画面了

卡顿产生的原因

  1. 下图 图4

    1. 垂直信号的发送频率是固定的,按一定的周期发送
    2. 每次垂直信号来时水平同步信号就会将GPU计算(渲染)出来的数据填充到当前帧
    3. 这就要保证GPU要在下一次垂直信号发出之前将数据处理(渲染)好
    4. 如果当前帧的垂直信号来了,但是GPU仍然没有处理好当前帧数据,那么当前帧就不能被填充,即刷新界面,那么当前屏幕显示的还是上一帧的画面,GUP处理完了,只能等到下一帧垂直信号来时,才能刷帧,然而当前帧的信号就会丢失了,这就是丢帧。从而也导致了屏幕卡顿
    5. 因此,卡顿主要的原因是因为:CPU、GPU消耗的时间太长,大于垂直同步信号的周期
  2. 总结UI卡顿的原因
    1. iOS设备的硬件时钟会发出Vsync(垂直同步信号)
    2. 然后App的CPU会去计算屏幕要显示的内容,之后将计算好的内容提交到GPU去渲染。
    3. 随后,GPU将渲染结果提交到帧缓冲区,等到下一个VSync到来时将缓冲区的帧显示到屏幕上。
    4. 也就是说,一帧的显示是由CPU和GPU共同决定的。
    5. 一般来说,页面滑动流畅是60fps,也就是1s有60帧更新,即每隔16.7ms就要产生一帧画面,而如果CPU和GPU加起来的处理时间超过了16.7ms,就会造成掉帧甚至卡顿。
      1. 1s = 1000 ms ,每帧需要 1000/60= 16.7ms
      2. 因此,每隔16.7ms就要产生一帧画面
  3. 卡顿解决的主要思路
    1. 尽可能减少CPU、GPU资源消耗
    2. 按照60FPS的刷帧率,每隔16.7ms就会有一次VSync信号

卡顿优化 - CPU

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

       - (void)text{
           // 文字计算
           [@"text" boundingRectWithSize:CGSizeMake(100, MAXFLOAT) options:NSStringDrawingUsesLineFragmentOrigin attributes:nil context:nil];
                  
           // 文字绘制
           [@"text" drawWithRect:CGRectMake(0, 0, 100, 100) options:NSStringDrawingUsesLineFragmentOrigin attributes:nil context:nil];
       }
      
    2. 图片处理(解码、绘制)

      1. 图片的展示其实是有个解码、绘制的流程
      2. [UIImage imageNamed:@"timg"];
        1. 这个方法其实是不能直接显示图片的,这个是压缩后图片的二进制数据
        2. 要想显示还需要解码成屏幕所需要的格式,而且这个解码还是在主线程进行的,这个图片比较大的话也会造成卡顿
      3. 解决办法
        1. 提前对图片进行解码
        2. 解码过程放在子线程,主线程渲染显示。
        3. 网上的很多框架就是这么干的
      4. 举例:

         - (void)image
         {
             UIImageView *imageView = [[UIImageView alloc] init];
             imageView.frame = CGRectMake(100, 100, 100, 56);
             [self.view addSubview:imageView];
             self.imageView = imageView;
                    
             //图片的解码
             dispatch_async(dispatch_get_global_queue(0, 0), ^{
                 // 获取CGImage
                        
         //        [UIImage imageWithData:[NSData dataWithContentsOfURL:<#(nonnull NSURL *)#>]] //网络图片
                 CGImageRef cgImage = [UIImage imageNamed:@"timg"].CGImage;
                    
                 // alphaInfo
                 CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(cgImage) & kCGBitmapAlphaInfoMask;
                 BOOL hasAlpha = NO;
                 if (alphaInfo == kCGImageAlphaPremultipliedLast ||
                     alphaInfo == kCGImageAlphaPremultipliedFirst ||
                     alphaInfo == kCGImageAlphaLast ||
                     alphaInfo == kCGImageAlphaFirst) {
                     hasAlpha = YES;
                 }
                    
                 // bitmapInfo
                 CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
                 bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
                    
                 // size
                 size_t width = CGImageGetWidth(cgImage);
                 size_t height = CGImageGetHeight(cgImage);
                    
                 // context
                 CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, CGColorSpaceCreateDeviceRGB(), bitmapInfo);
                    
                 // draw
                 CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage);
                    
                 // get CGImage
                 cgImage = CGBitmapContextCreateImage(context);
                    
                 // into UIImage  //解码后的图片
                 UIImage *newImage = [UIImage imageWithCGImage:cgImage];
                    
                 // release
                 CGContextRelease(context);
                 CGImageRelease(cgImage);
                    
                 // back to the main thread
                 dispatch_async(dispatch_get_main_queue(), ^{
                     self.imageView.image = newImage;
                 });
             });
         }
        
        

卡顿优化 - GPU

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

离屏渲染

  1. 在OpenGL中,GPU有2种渲染方式
    1. On-Screen Rendering:当前屏幕渲染,在当前用于显示的屏幕缓冲区进行渲染操作
    2. Off-Screen Rendering:离屏渲染,在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作
  2. 离屏渲染消耗性能的原因
    1. 需要创建新的缓冲区
    2. 离屏渲染的整个过程,需要多次切换上下文环境,先是从当前屏幕(On-Screen)切换到离屏(Off-Screen);等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上,又需要将上下文环境从离屏切换到当前屏幕
    3. 另外由于离屏渲染会增加GPU的工作量,可能会导致CPU+GPU的处理时间超出16.7ms,导致掉帧卡顿。
  3. 哪些操作会触发离屏渲染?
    1. 光栅化: layer.shouldRasterize = YES
    2. 遮罩: layer.mask
    3. 圆角,同时设置layer.masksToBounds = YES、layer.cornerRadius大于0
      1. 考虑通过CoreGraphics绘制裁剪圆角,或者叫美工提供圆角图片
    4. 阴影: layer.shadowXXX
      1. 如果设置了layer.shadowPath就不会产生离屏渲染

卡顿检测

  1. 平时所说的“卡顿”主要是因为在主线程执行了比较耗时的操作
  2. 可以添加Observer到主线程RunLoop中,通过监听RunLoop状态切换的耗时,以达到监控卡顿的目的
  3. demo示例:https://github.com/UIControl/LXDAppFluecyMonitor

耗电优化

  1. 耗电的主要来源
    1. CPU处理,Processing
    2. 网络,Networking
    3. 定位,Location
    4. 图像,Graphics
  2. 耗电优化
    1. 尽可能降低CPU、GPU功耗
    2. 少用定时器
    3. 优化I/O操作
      1. 尽量不要频繁写入小数据,最好批量一次性写入
      2. 读写大量重要数据时,考虑用dispatch_io,其提供了基于GCD的异步操作文件I/O的API。用dispatch_io系统会优化磁盘访问
      3. 数据量比较大的,建议使用数据库(比如SQLite、CoreData)
    4. 网络优化
      1. 减少、压缩网络数据
      2. 如果多次请求的结果是相同的,尽量使用缓存
      3. 使用断点续传,否则网络不稳定时可能多次传输相同的内容
      4. 网络不可用时,不要尝试执行网络请求
      5. 让用户可以取消长时间运行或者速度很慢的网络操作,设置合适的超时时间
      6. 批量传输,比如,下载视频流时,不要传输很小的数据包,直接下载整个文件或者一大块一大块地下载。如果下载广告,一次性多下载一些,然后再慢慢展示。如果下载电子邮件,一次下载多封,不要一封一封地下载
  3. 定位优化
    1. 如果只是需要快速确定用户位置,最好用CLLocationManager的requestLocation方法。定位完成后,会自动让定位硬件断电
    2. 如果不是导航应用,尽量不要实时更新位置,定位完毕就关掉定位服务
    3. 尽量降低定位精度,比如尽量不要使用精度最高的kCLLocationAccuracyBest
    4. 需要后台定位时,尽量设置pausesLocationUpdatesAutomatically为YES,如果用户不太可能移动的时候系统会自动暂停位置更新
    5. 尽量不要使用startMonitoringSignificantLocationChanges,优先考虑startMonitoringForRegion:
  4. 硬件检测优化
    1. 用户移动、摇晃、倾斜设备时,会产生动作(motion)事件,这些事件由加速度计、陀螺仪、磁力计等硬件检测。在不需要检测的场合,应该及时关闭这些硬件

启动优化

  1. APP的启动可以分为2种
    1. 冷启动(Cold Launch):从零开始启动APP
    2. 热启动(Warm Launch):APP已经在内存中,在后台存活着,再次点击图标启动APP
  2. APP启动时间的优化,主要是针对冷启动进行优化
    1. WWDC 2016 中首次出现了 App 启动优化的话题,其中提到:
      1. App 启动最佳速度是400ms以内,因为从点击 App 图标启动,然后 Launch Screen 出现再消失的时间就是400ms;
      2. App 启动最慢不得大于20s,否则进程会被系统杀死;(启动时间最好以 App 所支持的最低配置设备为准。)
  3. 通过添加环境变量可以打印出APP的启动时间分析Xcode自带(Edit scheme -> Run -> Arguments->Environment Variables)
    1. 添加字段:DYLD_PRINT_STATISTICS设置为1

       Total pre-main time: 1.7 seconds (100.0%) //pre-main指在main函数之前所消耗的时间,总时间
        dylib loading time: 157.53 milliseconds (8.8%)
       rebase/binding time: 135.50 milliseconds (7.6%)
           ObjC setup time: 610.08 milliseconds (34.4%)
          initializer time: 869.34 milliseconds (49.0%)
          slowest intializers :   //比较慢的加载
            libSystem.B.dylib :  21.53 milliseconds (1.2%)
       libMainThreadChecker.dylib : 239.20 milliseconds (13.4%)
         libglInterpose.dylib : 338.49 milliseconds (19.0%)
        libMTLInterpose.dylib :  79.28 milliseconds (4.4%)
                       VRSHOW : 187.95 milliseconds (10.6%)
      
    2. 如果需要更详细的信息,那就添加字段将DYLD_PRINT_STATISTICS_DETAILS设置为1

  4. pre-main 阶段
    1. pre-main 阶段指的是从用户唤起 App 到 main() 函数执行之前的过程。

APP启动阶段

  1. APP的冷启动可以概括为3大阶段
    1. dyld
    2. runtime
    3. main

    图4

  2. dyld
    1. dyld(dynamic link editor),Apple的动态链接器(ios/MAC都有),可以用来装载Mach-O文件(可执行文件、动态库等)
      1. 其本质也是 Mach-O 文件,一个专门用来加载 dylib 文件的库。
      2. dyld 位于 /usr/lib/dyld,可以在 mac 和越狱机中找到。
      3. dyld 会将 App 依赖的动态库和 App 文件加载到内存后执行。
    2. 启动APP时,dyld所做的事情有
      1. 装载APP的可执行文件(Mach-O),同时会递归加载所有依赖的动态库
      2. 当dyld把可执行文件、动态库都装载完毕后,会通知Runtime进行下一步的处理
  3. runtime
    1. 启动APP时,runtime所做的事情有
      1. 解析可执行文件Mach-O:调用map_images进行可执行文件(Mach-O)内容的解析和处理
      2. 装载类:在load_images中调用call_load_methods,调用所有Class和Category的+load方法
      3. 初始化: 进行各种objc结构的初始化(注册Objc类 、初始化类对象等等)
      4. 调用C++静态初始化器和__attribute__((constructor))修饰的函数
    2. 到此为止,可执行文件和动态库中所有的符号(Class,Protocol,Selector,IMP,…)都已经按格式成功加载到内存中,被runtime 所管理
  4. main
    1. APP的启动由dyld主导,将可执行文件加载到内存,顺便加载所有依赖的动态库
    2. 并由runtime负责加载成objc定义的结构
    3. 所有初始化工作结束后,dyld就会调用main函数
    4. 接下来就是UIApplicationMain函数,AppDelegateapplication:didFinishLaunchingWithOptions:方法
  5. APP的启动优化
    1. 按照不同的阶段
      1. dyld
        1. 减少动态库、合并一些动态库(定期清理不必要的动态库)
        2. 减少Objc类、分类的数量、减少Selector数量(定期清理不必要的类、分类)
        3. 减少C++虚函数数量
        4. Swift尽量使用struct
      2. runtime
        1. 用+initialize方法和dispatch_once取代所有的__attribute__((constructor))、C++静态构造器、ObjC的+load
      3. main
        1. 在不影响用户体验的前提下,尽可能将一些操作延迟,不要全部都放在finishLaunching方法中
        2. 按需加载

知识点

Mach-O
  1. Mach-O(Mach Object File Format)是一种用于记录可执行文件、对象代码、共享库、动态加载代码和内存转储的文件格式。App 编译生成的二进制可执行文件就是 Mach-O 格式的,iOS 工程所有的类编译后会生成对应的目标文件 .o 文件,而这个可执行文件就是这些 .o 文件的集合。
  2. 在 Xcode 的控制台输入以下命令,可以打印出运行时所有加载进应用程序的 Mach-O 文件。

     image list -o -f  
    
  3. Mach-O 文件主要由三部分组成:
    1. Mach header:描述 Mach-O 的 CPU 架构、文件类型以及加载命令等;
    2. Load commands:描述了文件中数据的具体组织结构,不同的数据类型使用不同的加载命令;
    3. Data:Data 中的每个段(segment)的数据都保存在这里,每个段都有一个或多个 Section,它们存放了具体的数据与代码,主要包含这三种类型:

       __TEXT 包含 Mach header,被执行的代码和只读常量(如C 字符串)。只读可执行(r-x)。
       __DATA 包含全局变量,静态变量等。可读写(rw-)。
       __LINKEDIT 包含了加载程序的元数据,比如函数的名称和地址。只读(r–-)。
      
dyld shared cache
  1. dyld shared cache 就是动态库共享缓存。当需要加载的动态库非常多时,相互依赖的符号也更多了,为了节省解析处理符号的时间,OS X 和 iOS 上的动态链接器使用了共享缓存。OS X 的共享缓存位于 /private/var/db/dyld/,iOS 的则在 /System/Library/Caches/com.apple.dyld/。
  2. 当加载一个 Mach-O 文件时,dyld 首先会检查是否存在于共享缓存,存在就直接取出使用。每一个进程都会把这个共享缓存映射到了自己的地址空间中。这种方法大大优化了 OS X 和 iOS 上程序的启动时间。
images
  1. images 在这里不是指图片,而是镜像。每个 App 都是以 images 为单位进行加载的。images 类型包括:
    1. executable:应用的二进制可执行文件;
    2. dylib:动态链接库;
    3. bundle:资源文件,属于不能被链接的 dylib,只能在运行时通过 dlopen() 加载。

安装包优化

  1. 安装包(IPA)主要由可执行文件、资源组成

资源(图片、音频、视频等)优化

  1. 采取无损压缩(ImageOptim、PPDuck)
  2. 去除没有用到的资源: https://github.com/tinymind/LSUnusedResources

可执行文件瘦身

  1. 编译器优化
    1. Strip Linked Product、Make Strings Read-Only、Symbols Hidden by Default设置为YES (当前的Xcode已经打开了)
    2. 去掉异常支持,Enable C++ Exceptions、Enable Objective-C Exceptions设置为NO, Other C Flags添加-fno-exceptions
  2. 利用AppCode(https://www.jetbrains.com/objc/)检测未使用的代码:菜单栏 -> Code -> Inspect Code
    1. AppCode也是一个开发iOS的IDE,下载安装包安装,该软件收费,但是可以试用
    2. 通过该IDE打开项目,然后:菜单栏 -> Code -> Inspect Code,能够检查出当前项目那些类没有使用
  3. 编写LLVM插件检测出重复代码、未被调用的代码
  4. LinkMap
    1. 生成LinkMap文件,可以查看可执行文件的具体组成

      图4

    2. 可借助第三方工具解析LinkMap文件: https://github.com/huanxsd/LinkMap

      1. 这是一个mac项目,然后将第一步生成的LinkMap文件导入即可
      2. 可以查看每个类占用的大小
Table of Contents