iOS底层原理(九)性能优化

iOS中的卡顿优化

iOS中的屏幕成像原理

在讲解卡顿优化之前,我们先来思考一下,在iOS中,屏幕是怎么成像的呢

CPU和GPU

在屏幕成像的过程中,CPUGPU起着至关重要的作用

CPU(Central Processing Unit,中央处理器)CPU的主要任务是进行对象的创建和销毁、对象属性的调整、布局计算、文本的计算和排版、图片的格式转换和解码、图像的绘制(Core Graphics)

GPU(Graphics Processing Unit,图形处理器)GPU的主要任务是对纹理的渲染##### CPU和GPU的关系
我们所看到的成像,都是通过CPUGPU共同协作才能完成的

一般是经过CPU的计算和处理好的数据,交给GPU进行渲染,然后放到帧的缓存区,再被视频控制器读取,才能显示到我们的屏幕上

-w532

在iOS中的帧缓存属于双缓冲机制,有前帧缓存和后帧缓存;GPU会分情况进行选取用哪块缓存,这样执行效率会更高一些

屏幕成像原理

在iOS中的屏幕成像是由许多帧共同组成的。每一帧都会由屏幕先发出一个垂直同步信号,然后再发出很多行水平同步信号,每一行水平同步信号表示处理完一行的数据,直到屏幕发完所有的水平同步信号,表示这一帧的数据全部处理完成了,再会进行下一轮的垂直同步信号的发出,表示即将处理下一帧的数据

-w577

卡顿产生的原因

在图像处理过程中,CPU处理计算数据会消耗一定时间,然后再交由GPU,而GPU进行渲染也会花费时间,所以CPUGPU都完成已经消耗了一定的时间;

而屏幕的垂直同步信号发出的时间如果正好是CPU、GPU处理完的时间,那么就会完好的先该帧图像显示出来;如果CPU、GPU处理的时间过长并且没有完全处理完,而这时垂直同步信号已经发出了,那么就会读取上一帧的数据进行展示,这种现象叫做掉帧;

而该帧没有处理完的数据就只能等下一个垂直同步信号再进行读取显示了,这中间也会花费一定时间进行等待,这也是掉帧;

掉帧的现象就会造成卡顿,所以我们要想解决卡顿,就要尽量减少CPUGPU的资源消耗

人眼感受不到卡顿的刷帧率平均是60FPS,表示每秒要刷60帧;通过计算相当于每隔16ms就会有一次VSync信号,也就是说我们要在16ms内完成CPUGPU对数据的计算和渲染才行

优化卡顿的具体方案

关于CPU的卡顿优化

1.尽量用轻量级的对象

比如用不到事件处理的地方,可以考虑使用CALayer取代UIView

还有能用基本数据类型就不用对象类型等等

2.不要频繁地调用UIView的相关属性

比如frame、bounds、transform等属性,尽量减少不必要的修改

尽量提前计算好布局,在有需要时一次性调整对应的属性,不要多次修改属性尽量减少使用AutolayoutAutolayout会比直接设置frame消耗更多的CPU资源

其他需要设置的属性最后是能确定时再赋值,不要多次更改##### 3.图片的size最好刚好跟UIImageView的size保持一致

如果图片本身的大小和我们给予的大小有出入,CPU会去进行伸缩的处理,也是会消耗资源

4.控制一下线程的最大并发数量

不要过多的创建线程,线程的创建和消耗也是会消耗资源的

尽量保持较少数量的线程,设置好最大并发数

如果需要长期开启线程来执行任务,可以考虑让线程常驻,并再不需要后再进行统一销毁

5.尽量把耗时的操作放到子线程

比如对文本的处理(尺寸计算、绘制),都可以放到异步去做处理,例如下面代码

// 文字计算
[@"text" boundingRectWithSize:CGSizeMake(100, MAXFLOAT) options:NSStringDrawingUsesLineFragmentOrigin attributes:nil context:nil];
    
// 文字绘制
[@"text" drawWithRect:CGRectMake(0, 0, 100, 100) options:NSStringDrawingUsesLineFragmentOrigin attributes:nil context:nil];

还有对图片的处理,对图片的解码和绘制都是会消耗性能的

我们经常使用的给UIImage赋值的方法,其本质是会去进行图片的解码和绘制的,所以我们可以将解码绘制的过程放在子线程来处理,详细代码如下

// imageNamed:底层会进行对图片的解码和绘制
UIImageView *imageView = [[UIImageView alloc] init];
imageView.image = [UIImage imageNamed:@"timg"];

// 换成如下方法
UIImageView *imageView = [[UIImageView alloc] init];
self.imageView = imageView;

dispatch_async(dispatch_get_global_queue(0, 0), ^{
    // 获取CGImage
    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.尽量减少视图数量和层次

比如一个UIView视图我们需要创建三个图层,减少到两个或者一个更利于GPU的渲染性能

2.避免短时间内大量图片的显示

我们在处理多张图片时,尽量避免短时间内大量图片的显示,尽可能将多张图片合成一张进行显示

3.GPU纹理尺寸的控制

GPU能处理的最大纹理尺寸是4096x4096,一旦超过这个尺寸,就会占用CPU资源进行处理,所以纹理尽量不要超过这个尺寸

4.减少透明度

减少透明的视图(alpha<1),不透明的就设置opaque为YES

像多个透明的视图,如果有重叠部分,那么重叠部分需要重新计算展示的颜色是什么的,会消耗GPU资源

5.注意离屏渲染

OpenGL中,GPU有2种渲染方式

  • On-Screen Rendering:当前屏幕渲染,在当前用于显示的屏幕缓冲区进行渲染操作
  • Off-Screen Rendering:离屏渲染,在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作

离屏渲染消耗性能的原因- 本身GPU渲染就会消耗性能

  • 需要创建新的缓冲区,又会消耗性能
  • 离屏渲染的整个过程,需要多次切换上下文环境,先是从当前屏幕(On-Screen)切换到离屏(Off-Screen);等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上,又需要将上下文环境从离屏切换到当前屏幕哪些操作会触发离屏渲染?
  • 光栅化 layer.shouldRasterize = YES
  • 遮罩layer.mask
  • 圆角,同时设置layer.masksToBounds = YES、layer.cornerRadius大于0,只满足其中之一不会触发离屏渲染
    • 考虑通过CoreGraphics绘制裁剪圆角,或者叫美工提供圆角图片- 阴影 layer.shadowXXX
    • 如果设置了layer.shadowPath就不会产生离屏渲染#### 卡顿的检测
      平时所说的“卡顿”主要是因为在主线程执行了比较耗时的操作##### 1.FPS监控
      FPS的监控,参照YYKit中的YYFPSLabel,主要是通过CADisplayLink实现。借助link的时间差,来计算一次刷新刷新所需的时间,然后通过刷新次数 / 时间差得到刷新频次,并判断是否其范围,通过显示不同的文字颜色来表示卡顿严重程度。代码实现如下
class LLFPSLabel: UILabel {

    fileprivate var link: CADisplayLink = {
        let link = CADisplayLink.init()
        return link
    }()
    
    fileprivate var count: Int = 0
    fileprivate var lastTime: TimeInterval = 0.0
    fileprivate var fpsColor: UIColor = {
        return UIColor.green
    }()
    fileprivate var fps: Double = 0.0
    
    override init(frame: CGRect) {
        var f = frame
        if f.size == CGSize.zero {
            f.size = CGSize(width: 80.0, height: 22.0)
        }
        
        super.init(frame: f)
        
        self.textColor = UIColor.white
        self.textAlignment = .center
        self.font = UIFont.init(name: "Menlo", size: 12)
        self.backgroundColor = UIColor.lightGray
        //通过虚拟类
        link = CADisplayLink.init(target: CJLWeakProxy(target:self), selector: #selector(tick(_:)))
        link.add(to: RunLoop.current, forMode: RunLoop.Mode.common)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    deinit {
        link.invalidate()
    }
    
    @objc func tick(_ link: CADisplayLink){
        guard lastTime != 0 else {
            lastTime = link.timestamp
            return
        }
        
        count += 1
        //时间差
        let detla = link.timestamp - lastTime
        guard detla >= 1.0 else {
            return
        }
        
        lastTime = link.timestamp
        //刷新次数 / 时间差 = 刷新频次
        fps = Double(count) / detla
        let fpsText = "\(String.init(format: "%.2f", fps)) FPS"
        count = 0
        
        let attrMStr = NSMutableAttributedString(attributedString: NSAttributedString(string: fpsText))
        if fps > 55.0 {
            //流畅
            fpsColor = UIColor.green
        }else if (fps >= 50.0 && fps <= 55.0){
            //一般
            fpsColor = UIColor.yellow
        }else{
            //卡顿
            fpsColor = UIColor.red
        }
        
        attrMStr.setAttributes([NSAttributedString.Key.foregroundColor: fpsColor], range: NSMakeRange(0, attrMStr.length - 3))
        attrMStr.setAttributes([NSAttributedString.Key.foregroundColor: UIColor.white], range: NSMakeRange(attrMStr.length - 3, 3))
        
        DispatchQueue.main.async {
            self.attributedText = attrMStr
        }
    }

}
2.主线程卡顿监控我们可以添加Observer到主线程RunLoop中,通过监听RunLoop状态切换的耗时,以达到监控卡顿的目的

实现思路:

在整个运行循环中,需要监听的主要就是RunLoop结束休眠到处理Source0的这段时间,如果时间过长,就证明有耗时操作

检测主线程每次执行消息循环的时间,当这个时间大于规定的阈值时,就记为发生了一次卡顿。这个也是微信卡顿三方matrix的原理

以下是一个简易版RunLoop监控的实现

class LLBlockMonitor: NSObject {
    
    static let share = LLBlockMonitor.init()
    
    fileprivate var semaphore: DispatchSemaphore!
    fileprivate var timeoutCount: Int!
    fileprivate var activity: CFRunLoopActivity!
    
    private override init() {
        super.init()
    }

    
    public func start(){
        //监控两个状态
        registerObserver()
        
        //启动监控
        startMonitor()
    }
}

fileprivate extension LLBlockMonitor{
    
    func registerObserver(){
        let controllerPointer = Unmanaged<LLBlockMonitor>.passUnretained(self).toOpaque()
        var context: CFRunLoopObserverContext = CFRunLoopObserverContext(version: 0, info: controllerPointer, retain: nil, release: nil, copyDescription: nil)
        let observer: CFRunLoopObserver = CFRunLoopObserverCreate(nil, CFRunLoopActivity.allActivities.rawValue, true, 0, { (observer, activity, info) in
            
            guard info != nil else{
                return
            }
            
            let monitor: LLBlockMonitor = Unmanaged<LLBlockMonitor>.fromOpaque(info!).takeUnretainedValue()
            monitor.activity = activity
            let sem: DispatchSemaphore = monitor.semaphore
            sem.signal()
            
        }, &context)
        
        CFRunLoopAddObserver(CFRunLoopGetMain(), observer, CFRunLoopMode.commonModes)
    }
    
    func  startMonitor(){
        //创建信号
        semaphore = DispatchSemaphore(value: 0)
        //在子线程监控时长
        DispatchQueue.global().async {
            while(true){
                // 超时时间是 1 秒,没有等到信号量,st 就不等于 0, RunLoop 所有的任务
                let st = self.semaphore.wait(timeout: DispatchTime.now()+1.0)
                if st != DispatchTimeoutResult.success {
                    //监听两种状态kCFRunLoopBeforeSources 、kCFRunLoopAfterWaiting,
                    if self.activity == CFRunLoopActivity.beforeSources || self.activity == CFRunLoopActivity.afterWaiting {
                        
                        self.timeoutCount += 1
                        
                        if self.timeoutCount < 2 {
                            print("timeOutCount = \(self.timeoutCount)")
                            continue
                        }
                        // 一秒左右的衡量尺度 很大可能性连续来 避免大规模打印!
                        print("检测到超过两次连续卡顿")
                    }
                }
                self.timeoutCount = 0
            }
        }
    }
}

使用时,直接调用即可

LLBlockMonitor.share.start()

也可以直接使用三方库

Swift的卡顿检测第三方ANREye,其主要思路是:创建子线程进行循环监测,每次检测时设置标记置为true,然后派发任务到主线程,标记置为false,接着子线程睡眠超过阈值时,判断标记是否为false,如果没有,说明主线程发生了卡顿

OC可以使用微信matrix、滴滴DoraemonKit

iOS中的耗电优化

我们平时造成电量消耗的主要来源有哪些呢?

一般造成耗电来源有以下这些

  • CPU的处理
  • 网络的连接
  • 定位
  • 图像的展示和处理

耗电优化的一些具体方案

1.尽可能降低CPU、GPU功耗

详情参照上面关于卡顿优化的相关处理

2.少用定时器

定时器的使用也会造成一定的电量消耗,因为要一直在程序中监听执行

3.优化I/O操作(文件的读写)

尽量不要频繁写入小数据,最好批量一次性写入

读写大量重要数据时,考虑用dispatch_io,其提供了基于GCD的异步操作文件I/O的API。用dispatch_io系统会优化磁盘访问

数据量比较大的,建议使用数据库(比如SQLite、CoreData),数据库内部对读写已经做了相应的优化处理了#### 4.网络优化
减少、压缩网络数据,可以采用JSONprotobuf这样格式相对较小的传输格式

如果多次请求的结果是相同的,尽量使用缓存,可以利用NSCache来进行缓存使用断点续传,否则网络不稳定时可能多次传输相同的内容

做好网络状态的监控,网络不可用时,不要尝试执行网络请求

让用户可以取消长时间运行或者速度很慢的网络操作,设置合适的超时时间

批量传输,比如,下载视频流时,不要传输很小的数据包,直接下载整个文件或者一大块一大块地下载。如果下载广告,一次性多下载一些,然后再慢慢展示。如果下载电子邮件,一次下载多封,不要一封一封地下载#### 5.定位优化如果只是需要快速确定用户位置,最好用CLLocationManagerrequestLocation方法。定位完成后,会自动让定位硬件断电
如果不是导航应用,尽量不要实时更新位置,定位完毕就关掉定位服务
尽量降低定位精度,比如尽量不要使用精度最高的kCLLocationAccuracyBest
需要后台定位时,尽量设置pausesLocationUpdatesAutomatically为YES,如果用户不太可能移动的时候系统会自动暂停位置更新
尽量不要使用startMonitoringSignificantLocationChanges,优先考虑startMonitoringForRegion:#### 6.硬件检测优化
用户移动、摇晃、倾斜设备时,会产生动作(motion)事件,这些事件由加速度计、陀螺仪、磁力计等硬件检测。在不需要检测的场合,应该及时关闭这些硬件## APP的启动优化

APP的启动

我们先来了解一下APP的启动有哪几种

APP的启动可以分为2种- 冷启动(Cold Launch):从零开始启动APP- 热启动(Warm Launch):APP已经在内存中,在后台存活着,再次点击图标启动APP

我们对APP启动时间的优化,主要是针对冷启动进行优化

通过Xcode打印分析启动过程

我们可以通过Xcode添加环境变量可以打印出APP的启动时间分析

1.找到路径Edit scheme -> Run -> Arguments -> Environment Variables

2.添加DYLD_PRINT_STATISTICS,设置为1

-w933

3.然后运行程序,可以看到控制台的打印如下

-w793
如果需要更详细的信息,那就添加DYLD_PRINT_STATISTICS_DETAILS设置为1

然后查看控制台的打印如下

-w853

上述操作也仅仅是作为一个参考,如果启动时间小于400ms,那就属于正常范围,如果超出该值,就需要考虑一定的启动优化了

APP的启动过程

APP的冷启动可以概括为3大阶段

  • dyld- Runtime- main-w897

启动APP时,dyld所做的事情如下

启动APP时,dyld会先装载APP的可执行文件,同时会递归加载所有依赖的动态库

dyld把可执行文件、动态库都装载完毕后,会通知Runtime进行下一步的处理

Runtime

启动APP时,Runtime所做的事情如下

Runtime会调用map_images进行可执行文件内容的解析和处理

进行各种objc结构的初始化(注册Objc类 、初始化类对象等等)

load_images中调用call_load_methods,调用所有ClassCategory+load方法

调用C++静态初始化器和__attribute__((constructor))修饰的函数

到此为止,可执行文件和动态库中所有的符号(Class,Protocol,Selector,IMP,…)都已经按格式成功加载到内存中,被Runtime所管理

main

整个启动过程可以概述为:

APP的启动由dyld主导,将可执行文件加载到内存,顺便加载所有依赖的动态库

并由Runtime负责加载成objc定义的结构

然后所有初始化工作结束后,dyld就会调用main函数

接下来就是UIApplicationMain函数AppDelegateapplication:didFinishLaunchingWithOptions:方法

APP的启动优化方案

dyld阶段

减少动态库、合并一些动态库(定期清理不必要的动态库)

减少Objc类、分类的数量、减少Selector数量(定期清理不必要的类、分类)

减少C++虚函数数量(C++一旦有虚函数,就会多维护一张虚表)

Swift尽量使用struct

runtime加载阶段用+initialize方法dispatch_once取代所有的__attribute__((constructor))C++静态构造器、ObjC+load#### 执行main函数阶段在不影响用户体验的前提下,尽可能将一些操作延迟,不要全部都放在finishLaunching方法中

按需加载## 安装包瘦身
我们开发的安装包(IPA)主要由可执行文件、资源组成

在我们日常开发中,项目业务会越来越多,慢慢就会积攒下一些不必要的代码和资源,我们可以对其进行一定的瘦身优化

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

我们在使用项目里的资源时,尽量采取无损压缩的,会适当减少包的大小

当项目里的资源太多了,我们可以通过一些工具来清除无用的或者重复的资源

可执行文件瘦身

我们可以对编译器做一定的优化

Strip Linked Product、Make Strings Read-Only、Symbols Hidden by Default这几个配置都改为YES

-w621
-w631
-w573

去掉异常支持,Enable C++ Exceptions、Enable Objective-C Exceptions设置为NO, Other C Flags添加-fno-exceptions-w602
-w592
-w542

我们可以利用一些工具对没有使用的代码进行清除

1.例如AppCode,检测未使用的代码:菜单栏 -> Code -> Inspect Code

2.编写LLVM插件检测出重复代码、未被调用的代码

3.通过生成Link Map文件,可以查看可执行文件的具体组成和大小分析

Link Map File的路径改成我们桌面路径,然后将Write Link Map File改成Yes

-w838
然后会生成这么一个文件

-w682

由于其文件内容过于庞大,不利于我们分析,可借助第三方工具解析LinkMap文件:https://github.com/huanxsd/LinkMap

posted on 2021-04-09 05:06  FunkyRay  阅读(76)  评论(0编辑  收藏  举报