iOS内存优化

2,667 阅读13分钟

内存优化的工具

静态分析:Analyze

Analyze 主要分析以下四种问题:

  1. 逻辑错误: 访问空指针或未初始化的变量等

  2. 内存管理错误: 如内存泄漏等

  3. 声明错误: 从未使用的变量

  4. Api调用错误: 未包含使用的库和框架

Instruments和Allocations

这个工具能显示出应用的实际占用,并可以按大小进行排序.我们只要找出哪些占用高的,分析其原因,找到相应的解决办法

  1. 红色箭头处,这种红色X的地方就是内存泄漏的地方

  2. 找到上图有个“田”样式的图案把那个Statictics改成Call Trees你就可以看到底部有一个Call Tree的设置。把系统方法过滤掉

活动检测器- ActivityMonitor   

   执行操作(进入一个页面退出后)-> 筛选你的APP,Memory如果不减退,则出现内存泄漏

僵尸对象-Zombiles

  • 僵尸对象一种用来检测内存错误EXC_BAD_ACCESS的 对象,它可以捕获任何尝试访问坏内存的调用

  • 如果给僵尸对象发送消息时,那么将在运行期间崩溃和输出错误日志.通过日志可以定位到野指针对象调用的方法和类名

如何开启Zombile object检测

1. 在Xcode中设置Edit Scheme -> Diagnostics -> Zombie Objects****

void printClassInfo(id obj)
{
    Class cls = object_getClass(obj);
    Class superCls = class_getSuperclass(cls);
    NSLog(@"self:%s - superClass:%s", class_getName(cls), class_getName(superCls));
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        People *aPeople = [People new];
        
        NSLog(@"before release!");
        printClassInfo(aPeople);
      
        [aPeople release];
        
        NSLog(@"after release!");
        printClassInfo(aPeople);
    }
    return 0;
}

查看打印信息

ZombieObjectDemo[1357:84410] before release!
ZombieObjectDemo[1357:84410] self:People - superClass:NSObject
ZombieObjectDemo[1357:84410] after release!
ZombieObjectDemo[1357:84410] self:_NSZombie_People - superClass:nil

2. 从打印信息可以看到开启僵尸对象检测后,People释放后所属类变成_NSZombie_People,如此可得对象释放后会变成僵尸对象,保存当前释放对象的内存地址,防止被系统回收

3. 接下来打开instruments ->Zombies,查看dealloc究竟做了什么.点击运行,参看Call trees.结果如下,从dealloc的调用知道:Zombie Objects hook 住了对象的dealloc方法,通过调用自己的__dealloc_zombie方法来把对象进行僵尸化。在Runtime源码NSObject.mm文件中dealloc方法注释中也有说明这一点。如下:

// Replaced by NSZombies
- (void)dealloc {
    _objc_rootDealloc(self);
}

看看对象dealloc方法调用栈

模拟僵尸对象的生成

//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);

Zombile Object是如何被触发的

 再次调用[people release]可以看到程序断在 ___forwarding___ ,从此处的汇编代码中可以看到关键字 _NSZombie_ ,在调用abort( ) 函数退出进程时会有对应的信息输出@"*** -[%s %s]: message sent to deallocated instance %p"。所以可以大概猜出系统是在消息转发过程中做了手脚。

CoreFoundation`___forwarding___:
    0x7fff3f90b1cd <+269>:  leaq   0x35a414(%rip), %rsi      ; "_NSZombie_"

那么我们来总结下它的调用过程:

//1、获取对象class
Class cls = object_getClass(self);

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

//3、检测是否带有前缀_NSZombie_
if (string_has_prefix(clsName, "_NSZombie_")) {
//4、获取被野指针对象类名
  const char *originalClsName = substring_from(clsName, 10);
 
 //5、获取当前调用方法名
 const char *selectorName = sel_getName(_cmd);
  
 //6、输出日志
 Log(''*** - [%s %s]: message sent to deallocated instance %p", originalClsName, selectorName, self);

 //7、结束进程
 abort();
}

其实Diagnostics还有一下几个选项可以帮助我们找到app的内存问题

1. Enable Malloc Scribble

申请内存后在申请的内存上填0xAA,内存释放后在释放的内存上填0x55;再就是说如果内存未被初始化就被访问,或者释放后被访问,就会引发异常,这样就可以使问题尽快暴漏出来。

Scribble其实是malloclibsystem_malloc.dylib自身提供的调试方案

2. Enable Malloc Guard Edges

申请大片内存的时候在前后page上加保护,详见保护模式

3. Enable Guard Mallocs

使用libgmalloc捕获常见的内存问题,比如越界、释放之后继续使用。

由于libgmalloc在真机上不存在,因此这个功能只能在模拟器上使用.

4. Enable Zombie Objects

Zombie的原理是用生成僵尸对象来替换dealloc的实现,当对象引用计数为0的时候,将需要dealloc的对象转化为僵尸对象。如果之后再给这个僵尸对象发消息,则抛出异常,并打印出相应的信息,调试者可以很轻松的找到异常发生位置。

5. Enable Address Sanitizer (Xcode7 +)

AddressSanitizer的原理是当程序创建变量分配一段内存时,将此内存后面的一段内存也冻结住,标识为中毒内存。当程序访问到中毒内存时(越界访问),就会抛出异常,并打印出相应log信息。调试者可以根据中断位置和的log信息,识别bug。如果变量释放了,变量所占的内存也会标识为中毒内存,这时候访问这段内存同样会抛出异常(访问已经释放的对象)。

MLeaksFinder

腾讯开源的一款内存泄漏查找工具,可以在使用APP的过程中,即时的提醒发生了内存泄漏

Xcode的Memory Graph

这款工具在查找内存泄漏方面,可以作为MLeaksFinder的补充,用于分析对象之间的循环引用关系。 另外通过分析某个时刻的Live Objects,可以分析出哪些是不合理的

这个时候就进入了断点模式,可以查看issue面板,注意选择右边的Runtime:

有很多叹号说明就有问题了。看内存中object的名字,有一条是Closure captures leaked。展开后点击就可以看到这个issue对应的内存图形展示在中间的面板中

FBMemoryProfiler

是FaceBook出品,具体使用参考git

内存占用高的原因:

使用了不合理的API

1. 对于仅使用一次或是使用频率很低的大图片资源,使用了[UIImage imageNamed:]方法进行加载

图片的加载,有两种方式,一种是[UIImage imageNamed:],加载后系统会进行缓存,且没有API能够进行清理;另一种是[UIImage imageWithContentsOfFile:][[UIImage alloc] initWithContentsOfFile:],系统不会进行缓存处理,当图片没有再被引用时,其占用的内存会被彻底释放掉。

基于以上特点,对于仅使用一次或是使用频率很低的大图片资源,应该使用后者。使用后者时,要注意图片不能放到Assets中。

2.  一些图片本身非常适合用9片图的机制进行拉伸,但没有进行相应的优化

图片的内存占用是很大的,对于适合用9片图机制进行拉伸处理的图片,可以切出一个比实际尺寸小的多的图片,从而大量减少内存占用。比如下面的图片

左右两条竖线之间的部分是纯色,那么设计在切图时,对于这部分只要切出来很小就可以了。然后我们可以利用Xcode的slicing功能,设定图片哪些部分不进行拉伸,哪些部分进行拉伸。在加载图片的时候,还是以正常的方式进行加载。

3.  在没有必要的情况下,使用了-[UIColor colorWithPatternImage:]这个方法

项目中有代码使用了UILabel,将label的背景色设定为一个图片。为了将图片转为颜色,使用了上述方法.这个方法会引用到加载到内存中的图片,然后又会在内存中创建出另一个图像,而图像的内存占用是很大的

解决方法:此种场景下,合理的是使用UIButton,将图片设定为背景图。虽然使用UIButton会比UILabel多生成两个视图,但相比起图像的内存占用,还是完全值得的。

4.  在没有必要的情况下,使用Core Graphics API,修改一个UIImage对象的颜色

使用此API,会导致在内存中额外生成一个图像,内存占用很大。合理的做法是:

  • 设定UIView的tintColor属性
  • 将图片以UIImageRenderingModeAlwaysTemplate的方式进行加载
view.tintColor = theColor;
UIImage *image = [[UIImage imageNamed:name] imageWithRenderingMode: UIImageRenderingModeAlwaysTemplate]

5.  基于颜色创建纯色的图片时,尺寸过大

有时,我们需要基于颜色创建出UIImage,并用做UIButton在不同状态下的背景颜色.由于是纯色的图片,那么我们完全没有必要创建出和视图大小一样的图像,只需要创建出宽和高均为1px大小的图像就够了

//外部应该调用此方法,创建出1px宽高的小图像
+ (UIImage*)createImageWithColor:(UIColor *)color {
    return [self createImageWithColor: color andSize: CGSizeMake(1, 1)];
}

+ (UIImage*)createImageWithColor:(UIColor*)color andSize:(CGSize)size
{
    CGRect rect=CGRectMake(0,0, size.width, size.height);
    UIGraphicsBeginImageContext(rect.size);
    CGContextRef context = UIGraphicsGetCurrentContext();
    CGContextSetFillColorWithColor(context, [color CGColor]);
    CGContextFillRect(context, rect);
    UIImage *theImage = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return theImage;
}

6. 创建水平的渐变图像时,尺寸过大

项目中有些地方基于颜色,利用Core Graphics,在内存中创建了水平方向从左到右的渐变图像.图像的大小为视图的大小,这在某些视图较大的场合,造成了不小的内存开销,以在@3x设备上一个400x60大小的视图为例,其内存开销为:

400 * 3 * 60 * 3 * 4 / 1024 = 210KB。 但是实际上这个图像,如果是400px宽,1px高,完全能达到相同的显示效果,而其内存开销则仅为: 400 * 1 * 4 / 1024 = 1.56KB

7. 在自定义的UIView子类中,利用drawRect:方法进行绘制

自定义drawRect会使APP消耗大量的内存,视图越大,消耗的越多。其消耗内存的计算公式为: 消耗内存 = (width * scale * height * scale * 4 / 1024 / 1024)MB

几乎在所有情况下,绘制需求都可以通过CAShapeLayer这一利器来实现。CAShapeLayer在CPU和内存占用两项指标上都完爆drawRect:。

其有以下优点:

  • 渲染快速。CAShapeLayer使用了硬件加速,绘制同一图形会比用Core Graphics快很多。
  • 高效使用内存。一个CAShapeLayer不需要像普通CALayer一样创建一个寄宿图形,所以无论有多大,都不会占用太多的内存。
  • 不会被图层边界剪裁掉。
  • 不会出现像素化

8. 在自定义的CALayer子类中,利用- (void)drawInContext:方法进行绘制

与上一条类似,请尽量使用CAShapeLayer来做绘制。

9. UILabel尺寸过大

如果一个UILabel的尺寸,大于其intrinsicContentSize,那么会引起不必要的内存消耗。所以,在视图布局的时候,我们应该尽量使UILabel的尺寸等于其intrinsicContentSize。 关于这一点,读者可以写一个简单的示例程序,然后利用Instruments工具进行分析,可以看到Allocations中,Core Animation这一项的占用会明显增加。

10. 为UILabel设定背景色

如果设置的背景色不是clearColor, whiteColor,会引起内存开销。 所以,一旦碰到这种场合,可以将视图结构转变为UIView+UILabel,为UIView设定背景色,而UILabel只是用来显示文字。

这一点也可以通过写示例程序,利用Instruments工具来进行验证。

网络下载的图片过大

几乎所有的iOS应用,都会使用SDWebImage这一框架进行网络图片的加载。有时会遇到加载的图片过大的情况,对于这种情况,还需要根据具体的场景进行分析,采用不同的解决办法。

1. 视图很大,图片不能被缩放

如果图片大是合理的,那么我们做的只能是在视图被释放时,将下载的图片从内存缓存中删除。示例代码如下:

- (void)dealloc {
    for (NSString *imageUrl in self.datas) {
        NSString *key = [[SDWebImageManager sharedManager] cacheKeyForURL: [NSURL URLWithString: imageUrl]];
        [[SDImageCache sharedImageCache] removeImageForKey: key fromDisk: NO withCompletion: nil];
    }
}

上述代码将使得内存占用较高的情况只会出现在某个页面中,一旦从此页面返回,内存将会回归正常值。

2. 视图小,这时图片应该被缩放

如果用于显示图片的视图很小,而下载的图片很大,那么我们应该对图片进行缩放处理,然后将缩放后的图片保存到SDWebImage的内存缓存中。

示例代码如下

//为UIImage添加如下分类方法:
- (UIImage*)aspectFillScaleToSize:(CGSize)newSize scale:(int)scale {
    if (CGSizeEqualToSize(self.size, newSize)) {
        return self;
    }
    
    CGRect scaledImageRect = CGRectZero;
    
    CGFloat aspectWidth = newSize.width / self.size.width;
    CGFloat aspectHeight = newSize.height / self.size.height;
    CGFloat aspectRatio = MAX(aspectWidth, aspectHeight);
    
    scaledImageRect.size.width = self.size.width * aspectRatio;
    scaledImageRect.size.height = self.size.height * aspectRatio;
    scaledImageRect.origin.x = (newSize.width - scaledImageRect.size.width) / 2.0f;
    scaledImageRect.origin.y = (newSize.height - scaledImageRect.size.height) / 2.0f;
    
    int finalScale = (0 == scale) ? [UIScreen mainScreen].scale : scale;
    UIGraphicsBeginImageContextWithOptions(newSize, NO, finalScale);
    [self drawInRect:scaledImageRect];
    UIImage* scaledImage = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    
    return scaledImage;
}

- (UIImage*)aspectFitScaleToSize:(CGSize)newSize scale:(int)scale {
    if (CGSizeEqualToSize(self.size, newSize)) {
        return self;
    }
    
    CGRect scaledImageRect = CGRectZero;
    
    CGFloat aspectWidth = newSize.width / self.size.width;
    CGFloat aspectHeight = newSize.height / self.size.height;
    CGFloat aspectRatio = MIN(aspectWidth, aspectHeight);
    
    scaledImageRect.size.width = self.size.width * aspectRatio;
    scaledImageRect.size.height = self.size.height * aspectRatio;
    scaledImageRect.origin.x = (newSize.width - scaledImageRect.size.width) / 2.0f;
    scaledImageRect.origin.y = (newSize.height - scaledImageRect.size.height) / 2.0f;
    
    int finalScale = (0 == scale) ? [UIScreen mainScreen].scale : scale;
    UIGraphicsBeginImageContextWithOptions(newSize, NO, finalScale);
    [self drawInRect:scaledImageRect];
    UIImage* scaledImage = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    
    return scaledImage;
}

//使用的地方
[self.leftImageView sd_setImageWithURL:[NSURL URLWithString:md.image] placeholderImage:[UIImage imageNamed:@"discover_position"]
                                     completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) {
            if (image) {
                UIImage *scaledImage = [image aspectFillScaleToSize: self.leftImageView.bounds.size scale: 2];
                if (image != scaledImage) {
                    self.leftImageView.image = scaledImage;
                    [[SDWebImageManager sharedManager] saveImageToCache: scaledImage forURL: imageURL];
                }
            }
        }];

第三方库的缓存机制

1.  Lottie动画框架

Lottie框架默认会缓存动画帧等信息,如果一个应用中使用动画的场合很多,那么随着时间的积累,就会存在大量的缓存信息。然而,有些缓存信息可能以后再也不会被用到了,例如闪屏页的动画引起的缓存。

针对Lottie的缓存引起的内存占用,可以根据自己的意愿,选择如下两种处理办法:

  • 禁止缓存
[[LOTAnimationCache sharedCache] disableCaching];
  • 不禁止缓存,但在合适的时机,清除全部缓存,或是某个动画的缓存
//清除所有缓存,例如闪屏页在启动以后不会再次访问,那么可以清除此界面的动画所引起的缓存。
[[LOTAnimationCache sharedCache] clearCache];

//从一个页面返回后,可以删除此页面所用动画引起的缓存。
[[LOTAnimationCache sharedCache] removeAnimationForKey:key];

2. SDWebImage

SDWebImage的缓存机制,分为Disk和Memory两层,Memory这一层使得图片在被访问时可以免去文件IO过程,提高性能。默认情况下,Memory里存储的是解压后的图像数据,这个会导致巨大的内存开销。如果想要优化内存占用,可以选择存储压缩的图像数据,在应用启动的地方加如下代码:

[SDImageCache sharedImageCache].config.shouldDecompressImages = NO;
[SDWebImageDownloader sharedDownloader].shouldDecompressImages = NO;

3. 没必要常驻内存的对象,实现为常驻内存

对于像侧边栏,ActionSheet这样的界面对象,不要实现为常驻内存的,应该在使用到的时候再创建,用完即销毁。

4. 数据模型中冗余的字段

对于从服务端返回的数据,解析为模型时,随着版本的迭代,可能有一些字段已经不再使用了。如果这样的模型对象会生成很多,那么对于模型中的冗余字段进行清理,也可以节省一定数量的内存占用。

5. 内存泄漏

这个我就不做多余的赘述了