UIImage 运行时替换imageNamed:方法

iOS开发中,图片长期占用内存空间往往会导致应用内存空间过大,进而引发系统看门狗杀死应用。因而,如何有效地管理应用图片占用内存空间,并在适当的时候将其释放,对于提升程序的稳定性有重要意义。而一个显而易见的做法是,对于一些空间较大或不常显示的图片,使用imageWithContentsOfFile:方法替代imageNamed:方法进行创建。
通常我们创建图片会选择使用imageNamed:方法:

1
2
3
4
5
This method looks in the system caches for an image object with the specified name and returns the variant of that image that is best suited for the main screen. If a matching image object is not already in the cache, this method locates and loads the image data from disk or from an available asset catalog, and then returns the resulting object.

+ (UIImage *)imageNamed:(NSString *)name;

[UIImage imageNamed:@"test.jpg"];

从官方文档对该方法描述,我们可以知道,通过该方法创建的图片,系统会将其缓存于系统缓存内,当我们下次创建时,直接从系统缓存中获取图片。这是种典型的以空间换时间的优化策略,对于一些需频繁使用的小图片,使用该方法进行创建是合适。但是,对于一些比较大的或不常使用的图片,优化本身反而造成了性能上的影响,图片缓存无法及时释放,导致应用占用内存过大。原因主要在于该方法创建的图片所在的缓存区域为系统缓存,而苹果对此是有所保留的。虽然文档内有补充:

1
The system may purge cached image data at any time to free up memory. Purging occurs only for images that are in the cache but are not currently being used.

但是,与此同时,苹果并未提供/开放清除图片缓存的方法,而是将其交于系统进行处理。这样极大限制了方法的灵活性,开发者无法根据自身的需求,对图片缓存进行操作。而苹果或许也意识到了这个问题,在文档内也同样建议:

1
If you have an image file that will only be displayed once and wish to ensure that it does not get added to the system’s cache, you should instead create your image using imageWithContentsOfFile:. This will keep your single-use image out of the system image cache, potentially improving the memory use characteristics of your app.

通过imageWithContentsOfFile:方法创建的图片不会缓存于系统缓存内,开发者可在适当的时机对图片进行处理。因而,对于一些比较大的或不常使用的图片,我们应当使用imageWithContentsOfFile:进行创建:

1
2
3
4
5
Creates and returns an image object by loading the image data from the file at the specified path. This method does not cache the image object.

+ (UIImage *)imageWithContentsOfFile:(NSString *)path;

[UIImage imageWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"test.jpg" ofType:nil]];

当然,为了方便图片的管理及优化接口的调用,我们可以将图片放置于一个文件夹内并通过groups的方式添加到项目的main Bundle内。此时,我们应当能通过以下方法获取图片的完整路径:

1
[[NSBundle mainBundle] pathForResource:@"test.jpg" ofType:nil];

保证图片文件根路径一致后,我们便可通过UIImage分类方法的形式,根据图片的文件名(带扩展名)创建图片(无系统缓存):

1
2
3
4
+ (UIImage *)imageWithContentName:(NSString *)name
{
return [UIImage imageWithContentsOfFile:[[NSBundle mainBundle] pathForResource:name ofType:nil]];
}

至此,我们似乎只需要在创建图片的位置调用即可:

1
[UIImage imageWithContentName:@"test.jpg"];

但是,实际开发中,我们需考虑的情况更多。首先是引入时机的问题。若项目开发已久,代码中多处调用imageNamed:方法创建图片,如需直接改动为imageWithContentName:,替换成本是比较大的;若项目处于初始阶段,我们可以指定比较大的或不常使用的图片使用imageWithContentName:方法创建,一些频繁使用的小图片使用imageNamed:方法创建。但是,在多人开发下,这可能会面临第二个问题,协作开发的不一致。我们无法保证每个开发者都对合适的图片使用合适的创建方法。综合考虑下,我们可能需要牺牲一些性能上的优化以确保开发上的一致,即所有图片实际都是使用imageWithContentName:方法创建的。相对于小图片的频繁创建导致的额外性能开销,因错用方法导致的应用内存占用过大或许更为严重。方向确定后,则是具体操作上的问题。新项目上,我们或许可以强制规定所有图片都使用imageWithContentName:方法创建。但在一些旧有项目中,若替换成本过大,我们可以选择用种更优雅的方式切入,通过SwizzleMethod,在运行时(Runtime)交换imageNamed:imageWithContentName:方法的实现。这样,在外部代码变动不大的情况下,便可实现需求,具体实现如下:
首先,针对imageNamed:对PNG图片的额外支持(在mian bundle下,无需添加扩展名,直接使用图片文件名便可获取),对imageWithContentName:方法进行一些改造以额外支持PNG图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
+ (UIImage *)imageWithContentName:(NSString *)name
{
if (!name || name.length <= 0) return nil;

NSString *path = [[NSBundle mainBundle] pathForResource:name ofType:nil];

if (!path)
{
path = [[NSBundle mainBundle] pathForResource:name ofType:@".png"];

if (!path)
{
path = [[NSBundle mainBundle] pathForResource:name ofType:@".PNG"];
}
}

return [UIImage imageWithContentsOfFile:path];
}

如此,确保mian bundle下,PNG图片都能通过如下方式获取:

1
2
[UIImage imageNamed:@"test_1"];
[UIImage imageWithContentName:@"test_1"];

之后,则是实现方法交换。为了方便管理,我们应当针对SwizzleMethod情况,为UIImage添加一个新的分类,在此分类内进行方法替换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#import <objc/runtime.h>

@implementation UIImage (Swizzle)

+ (void)load
{
Method originalMethod = class_getInstanceMethod(self, @selector(imageNamed:));
Method newMethod = class_getInstanceMethod(self, @selector(imageWithContentName:));

if (!originalMethod || !newMethod) return;

method_exchangeImplementations(originalMethod, newMethod);
}

@end

这里,由于需求是替代编辑而非增量编辑,我们可以选择直接进行方法替换。同时,我们也可以将类方法/静态方法的替换实现抽象为NSObject的分类方法以方便调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#import <objc/runtime.h>

@implementation NSObject (SwizzleMethod)

+ (BOOL)swizzleClassMethod:(SEL)originalSel withNewMethod:(SEL)newSel
{
Class class = object_getClass(self);
Method originalMethod = class_getInstanceMethod(class, originalSel);
Method newMethod = class_getInstanceMethod(class, newSel);

if (!originalMethod || !newMethod) return NO;

method_exchangeImplementations(originalMethod, newMethod);

return YES;
}

@end

如此,UIImage (Swizzle)分类则可简化为:

1
2
3
4
5
6
7
8
9
10
11
@implementation UIImage (Swizzle)

+ (void)load
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[self swizzleClassMethod:@selector(imageNamed:) withNewMethod:@selector(imageWithContentName:)];
});
}

@end

这样,在外部代码基本不变的情况下,便可实现需求:

1
2
[self.topImageView setImage:[UIImage imageNamed:@"test_1"]];
[self.bottomImageView setImage:[UIImage imageNamed:@"test_2.jpg"]];

最后,我们可以通过示例来检测分别通过imageNamed:imageWithContentName:方法创建图片,对应用内存的影响:

示例中的图片分别为2.7MB和1.8MB左右。这里,我们通过InstrumentsAllocations工具来查看真机环境(iPhone 6)下,应用内存的变化。同时,统一通过Modal方式呈现图片显示界面,并在界面销毁时做如下操作:

1
2
3
4
[_topImageView setImage:nil];
[_bottomImageView setImage:nil];
_topImageView = nil;
_bottomImageView = nil;

首先为通过imageNamed:创建图片的内存变化情况:
图片界面呈现前:

图片界面呈现后:

图片界面销毁后:

通过imageWithContentName:创建图片的内存变化情况:
图片界面呈现前:

图片界面呈现后:

图片界面销毁后:

我们可以发现,图片过大时应用所占内存空间是比较大的,而imageNamed:方法创建图片后,系统并不会及时得从系统缓存清除图片。因而,通过imageWithContentName:创建图片,开发者自行管理图片的释放时机,对于优化应用占用内存来说,还是相当必要的。

附,示例代码:https://github.com/ColinHwang/Demo-of-Tutorial-in-Blog/tree/master/Runtime-SwizzleImageNameMethod

参考资料:

[1]Apple Inc.UIImage Reference Class[EB/OL].https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIImage_Class/index.html ,2016-7-16.
[2]南峰子.Objective-C Runtime 运行时之四:Method Swizzling[EB/OL].http://southpeak.github.io/blog/2014/11/06/objective-c-runtime-yun-xing-shi-zhi-si-:method-swizzling ,2014-11-6.