DHFastImageCache 1.5.0

DHFastImageCache 1.5.0

测试已测试
语言语言 Obj-CObjective C
许可 MIT
发布最后发布2016年1月

Dominik Hofer维护。



  • Mallory Paine 和 Michael Potter

Fast Image Cache 是一种高效、持久且最主要的是快速的方法,用于在 iOS 应用程序中存储和检索图像。任何好的 iOS 应用程序的用户体验都包括快速、顺畅的滚动,而 Fast Image Cache 就是帮助实现这一点的工具。

Path 这样以图像丰富的应用程序的性能瓶颈很大程度上是图像加载。传统地从磁盘加载单个图像的方法速度太慢,尤其是在滚动时。Fast Image Cache 就是专门为解决这个问题而创建的。

目录

版本历史

  • 1.0 (2013年10月18日):初始版本
  • 1.1 (2013年10月22日):添加了 ARC 支持,并且增强了基于 Core Animation 的字节对齐
  • 1.2 (2013年10月30日):添加了对图像格式样式和取消图像请求的支持
  • 1.3 (2014年3月30日):重要的错误修复和性能改进

Fast Image Cache 做了什么

  • 将相似大小和样式的图像一起存储
  • 将图像数据持久化到磁盘
  • 将图像返回给用户的速度比传统方法快得多
  • 根据使用时间最近性自动管理缓存到期
  • 采用基于模型的存储和检索图像方法
  • 允许在存储到缓存之前按模型处理图像

Fast Image Cache 的工作原理

为了理解 Fast Image Cache 的工作原理,理解许多处理图像的应用程序遇到的典型场景是有帮助的。

场景

在iOS应用中,尤其是社交网络的领域,通常会有许多需要在同一时间显示的图像,例如用户照片。直观的传统方法是向API请求数据,对原始图像进行处理以创建所需的尺寸和样式,并将这些处理后的图像存储在设备上。

之后,当应用需要显示这些图像时,它们将从磁盘加载到内存中,并显示在图像视图或通过其他方式渲染到屏幕上。

问题

从压缩的磁盘图像数据到用户实际可以看到的绘制Core Animation层的过程中是非常昂贵的。随着要显示的图像数量的增加,这种成本很容易累积,导致帧率明显下降。可滚动的视图进一步加剧了这种情况,因为内容可以迅速改变,需要快速的处理时间以保持流畅的60FPS。1

考虑从磁盘加载图像并在屏幕上显示时发生的流程

  1. +[UIImage imageWithContentsOfFile:]使用Image I/O从内存映射数据中创建CGImageRef。此时,图像尚未解码。
  2. 返回的图像被分配给UIImageView
  3. 隐式的CATransaction捕获这些图层树修改。
  4. 在主运行循环的下一个迭代中,Core Animation提交了隐式事务,这可能涉及创建任何设置为图层内容的图像的副本。根据图像,复制它可能涉及以下步骤之一或全部:2
    1. 分配缓冲区以管理文件I/O和解压缩操作。
    2. 从磁盘读取文件数据到内存。
    3. 将压缩的图像数据解码为其未压缩的位图形式,这通常是一个非常占用CPU的操作。3
    4. 然后使用未压缩的位图数据通过Core Animation来绘制图层。

这些成本很容易累积并摧毁用户感知的应用程序性能。尤其是滚动时,用户会得到一个与整体iOS体验不一致的令人不满意的用户体验。


1 60FPS0.01667秒每帧 = 每帧16.7毫秒。这意味着任何主线程工作如果超过16毫秒都可能导致您的应用程序丢失动画帧。

2 CALayer 对象的 contents 属性的文档中指出,"将值分配给此属性会导致图层使用您的图像而不是[创建]单独的后备存储"。然而,“使用您的图像”的含义仍然模糊。使用 Instruments 分析应用程序通常发现调用了 CA::Render::copy_image,即使 Core Animation 仪器指示没有任何图像被复制。Core Animation 需要图像副本的一个原因是字节对齐不正确。

3 截至 iOS 7,Apple 并没有将他们的硬件JPEG解码器提供给第三方应用程序使用。因此,此步骤只能使用更慢的软件解码器。

解决方案

使用各种技术,Fast Image Cache 最小化(或完全避免)了上述大部分工作

映射内存

Fast Image Cache 工作原理的核心是图像表。图像表类似于精灵表,常用于 2D 游戏。图像表将相同尺寸的图像打包到一个文件中。此文件只打开一次,并在应用程序保持在内存中的时间内保持打开状态,以便读取和写入。

图像表使用 mmap 系统调用来直接将文件数据映射到内存。不会发生 memcpy。这个系统调用只是在磁盘上的数据和内存区域之间创建映射。

当请求从图像缓存返回一个特定图像时,图像表会在常数时间内找到它维护的文件中所需图像数据的地址。这个文件数据区域被映射到内存,并且创建了一个新的拥有映射文件数据的底层存储的 CGImageRef

当返回的 CGImageRef(包裹成 UIImage)准备好绘制到屏幕时,iOS 的虚拟内存系统将实际的文件数据分页到内存中。这是使用映射内存的另一个好处;VM 系统将自动为我们处理内存管理。此外,映射内存不计入应用程序的实际内存使用。

同样地,当图像数据被存储在图像表中时,会创建一个内存映射位图上下文。与原始图像一起,这个上下文传递给图像表的相应实体对象。该对象负责将图像绘制到当前上下文,可以进一步配置上下文(例如,裁剪为圆角矩形)或进行任何其他绘制(例如,在原始图像上绘制叠加图像)。mmap将绘制的图像数据映射到磁盘,因此不需要在内存中分配图像缓冲区。

非压缩图像数据

为了避免昂贵的图像解压缩操作,图像表将未压缩的图像数据存储在其文件中。如果源图像已压缩,必须首先将其解压缩,以便图像表可以使用它。这是一个一次性成本。此外,还可以使用图像格式族来执行此解压缩,对于一系列相似的图像格式只需执行一次。

但是,这种方法有明显的后果。非压缩图像数据需要更多的磁盘空间,压缩和非压缩文件大小的差异可能很大,尤其是在像JPEG这样的图像格式中。因此,高速图像缓存最适合使用较小图像,尽管没有API限制强制执行这一点。

字节对齐

为了高性能的滚动,Core Animation能够不创建副本就能使用图像至关重要。Core Animation会创建图像副本的原因之一是图像底层CGImageRef的字节对齐不正确。每行字节值必须是8像素 × 每像素字节的倍数。对于典型的ARGB图像,对齐后的每行字节值必须是64的倍数。每个图像表都配置为从开始就对每个图像进行适当的字节对齐,以便Core Animation可以不用创建副本直接处理图像。

考虑事项

图像表大小

图像表由图像格式配置,它指定(包括其他方面)图像表可以拥有的最大条目数(即单个图像)。这是为了防止图像表文件的大小任意增长。

图像表为每个像素分配4字节,所以图像表文件占用的最大空间可以这样计算

每像素4字节 × 像素宽度 × 像素高度 × 最大条目数

使用Fast Image Cache的应用程序应仔细考虑每个图像表应该包含多少图像。当新的图像存储在已满的图像表中时,它将替换最不经常访问的图像。

图像表暂时性

图像表文件存储在用户的缓存目录下的一个名为 ImageTables 的子目录中。iOS 可以随时删除缓存文件以释放磁盘空间,因此使用快速图像缓存的应用程序必须能够重新创建任何已存储的图像,并且不应依赖于图像表文件永久保留。

注意:提醒一下,存储在用户缓存目录中的数据不会备份到 iTunes 或 iCloud。

源图像的持久性

快速图像缓存不会将实体处理的原生图像持久化,用于创建图像表中存储的图像数据。

例如,如果一个原始图像被实体调整大小以创建一个缩略图存储到图像表,则应用程序负责持久化原始图像或能够再次检索或重建它。

可以指定图像格式族以有效地使用单个源图像。有关更多信息,请参阅 使用图像格式族

数据保护

在 iOS 4 中,苹果引入了数据保护。当用户的设备被锁定或关闭时,磁盘会加密。默认情况下,写入磁盘的文件会受到保护,尽管应用程序可以为它管理的每个文件手动指定数据保护模式。随着 iOS 7 中新后台模式的引入,即使在设备锁定的情况下,应用程序现在也可以在后台短暂执行。因此,如果应用程序尝试访问加密的文件,数据保护可能会引起问题。

快速图像缓存允许为创建其支持图像表文件的每种图像格式指定所使用的数据保护模式。请注意,为图像表文件启用数据保护意味着当磁盘加密时,快速图像缓存可能无法从中读取或写入图像数据。

要求

快速图像缓存需要 iOS 6.0 或更高版本,并且依赖于以下框架:

  • Foundation
  • Core Graphics
  • UIKit

注意:截至版本 1.1,快速图像缓存 确实 使用 ARC。


FastImageCacheDemo Xcode 项目需要 Xcode 5.0 或更高版本,并且配置为针对 iOS 6.0 部署。

开始使用

集成快速图像缓存

手动

初始配置

在可以使用图像缓存之前,需要对其进行配置。这必须在每次启动时发生,因此应用程序代理可能是一个很好的地方来做此操作。

创建图像格式

每种图像格式对应一个图像缓存将使用的图像表。可以使用相同源图像渲染其图像表中存储的图像的图像格式应该属于同一图像格式家族。请参阅图像表大小获取有关如何确定适当的最大数量的更多信息。

static NSString *XXImageFormatNameUserThumbnailSmall = @"com.mycompany.myapp.XXImageFormatNameUserThumbnailSmall";
static NSString *XXImageFormatNameUserThumbnailMedium = @"com.mycompany.myapp.XXImageFormatNameUserThumbnailMedium";
static NSString *XXImageFormatFamilyUserThumbnails = @"com.mycompany.myapp.XXImageFormatFamilyUserThumbnails";

FICImageFormat *smallUserThumbnailImageFormat = [[FICImageFormat alloc] init];
smallUserThumbnailImageFormat.name = XXImageFormatNameUserThumbnailSmall;
smallUserThumbnailImageFormat.family = XXImageFormatFamilyUserThumbnails;
smallUserThumbnailImageFormat.style = FICImageFormatStyle16BitBGR;
smallUserThumbnailImageFormat.imageSize = CGSizeMake(50, 50);
smallUserThumbnailImageFormat.maximumCount = 250;
smallUserThumbnailImageFormat.devices = FICImageFormatDevicePhone;
smallUserThumbnailImageFormat.protectionMode = FICImageFormatProtectionModeNone;

FICImageFormat *mediumUserThumbnailImageFormat = [[FICImageFormat alloc] init];
mediumUserThumbnailImageFormat.name = XXImageFormatNameUserThumbnailMedium;
mediumUserThumbnailImageFormat.family = XXImageFormatFamilyUserThumbnails;
mediumUserThumbnailImageFormat.style = FICImageFormatStyle32BitBGRA;
mediumUserThumbnailImageFormat.imageSize = CGSizeMake(100, 100);
mediumUserThumbnailImageFormat.maximumCount = 250;
mediumUserThumbnailImageFormat.devices = FICImageFormatDevicePhone;
mediumUserThumbnailImageFormat.protectionMode = FICImageFormatProtectionModeNone;

NSArray *imageFormats = @[smallUserThumbnailImageFormat, mediumUserThumbnailImageFormat];

图像格式的样式实际上决定了图像表中存储的图像的位深度。目前,有以下可供使用的样式

  • 32位颜色加一个alpha成分(默认)
  • 32位颜色,没有alpha成分
  • 16位颜色,没有alpha成分
  • 8位灰度,没有alpha成分

如果源图像没有透明度(例如,JPEG图像),则无需alpha成分的32位颜色可以实现更好的Core Animation性能。如果源图像的颜色细节较少,或者图像格式的图像大小相对较小,使用16位颜色就足够了,几乎没有可感知的质量损失。这导致磁盘上存储的图像表文件较小。

配置图像缓存

一旦定义了一个或多个图像格式,它们需要分配给图像缓存。除了分配图像缓存的代理外,图像缓存本身无法进行其他配置。

FICImageCache *sharedImageCache = [FICImageCache sharedImageCache];
sharedImageCache.delegate = self;
sharedImageCache.formats = imageFormats;

创建实体

实体是符合FICEntity协议的对象。实体唯一标识图像表中的条目,并负责绘制它们希望在图像缓存中存储的图像。通常,已定义模型对象的应用程序(可能由Core Data管理)通常是合适的实体候选者。

@interface XXUser : NSObject <FICEntity>

@property (nonatomic, assign, getter = isActive) BOOL active;
@property (nonatomic, copy) NSString *userID;
@property (nonatomic, copy) NSURL *userPhotoURL;

@end

以下是FICEntity协议的一个示例实现。

- (NSString *)UUID {
    CFUUIDBytes UUIDBytes = FICUUIDBytesFromMD5HashOfString(_userID);
    NSString *UUID = FICStringWithUUIDBytes(UUIDBytes);

    return UUID;
}

- (NSString *)sourceImageUUID {
    CFUUIDBytes sourceImageUUIDBytes = FICUUIDBytesFromMD5HashOfString([_userPhotoURL absoluteString]);
    NSString *sourceImageUUID = FICStringWithUUIDBytes(sourceImageUUIDBytes);

    return sourceImageUUID;
}

- (NSURL *)sourceImageURLWithFormatName:(NSString *)formatName {
    return _sourceImageURL;
}

- (FICEntityImageDrawingBlock)drawingBlockForImage:(UIImage *)image withFormatName:(NSString *)formatName {
    FICEntityImageDrawingBlock drawingBlock = ^(CGContextRef context, CGSize contextSize) {
        CGRect contextBounds = CGRectZero;
        contextBounds.size = contextSize;
        CGContextClearRect(context, contextBounds);

        // Clip medium thumbnails so they have rounded corners
        if ([formatName isEqualToString:XXImageFormatNameUserThumbnailMedium]) {
            UIBezierPath clippingPath = [self _clippingPath];
            [clippingPath addClip];
        }

        UIGraphicsPushContext(context);
        [image drawInRect:contextBounds];
        UIGraphicsPopContext();
    };

    return drawingBlock;
}

理想情况下,实体的UUID永远不会改变。这就是为什么在应用程序处理从API检索的资源时,它与模型对象的由服务器生成的ID很好地对应。

实体的sourceImageUUID 可以更改。例如,如果用户更新了他们的个人资料照片,该照片的URL也应相应更改。UUID保持不变并标识同一用户,但更改的个人资料照片URL将表明有一个新的源图像。

注意:通常,最好是对用于定义UUIDsourceImageUUID的任何标识符进行哈希处理。Fast Image Cache提供了进行此操作的实用函数。由于哈希计算可能很昂贵,建议只计算一次(或仅在标识符更改时)并存储在实例变量中。

当图像缓存被要求为特定实体和格式名称提供图像时,实体负责提供URL。URL不需要指向实际资源——例如,URL可能是自定义URL-架构的组合——,但它必须是一个有效的URL。

图像缓存只使用这些URL来跟踪哪些图像请求正在传输中;对于同一图像的多次请求图像缓存可以正确处理,无需浪费任何努力。使用URL作为关键字请求图像缓存的基础,实际上补充了许多现实世界的应用设计,其中URL包含在服务器提供的模型数据中,而不是图像本身。

注意:快速图像缓存不提供任何执行网络请求的机制。这是图像缓存代理的责任。

最后,一旦源图像可用,就会要求实体提供绘图块。存储最终图像的图像表设置了一个文件映射位图上下文,并调用了实体的绘制块。这使得每个实体都能够决定如何处理特定图像格式的源图像。

从图像缓存请求图像

快速图像缓存遵循Cocoa常见的按需、懒加载设计模式。

XXUser *user = [self _currentUser];
NSString *formatName = XXImageFormatNameUserThumbnailSmall;
FICImageCacheCompletionBlock completionBlock = ^(id <FICEntity> entity, NSString *formatName, UIImage *image) {
    _imageView.image = image;
    [_imageView.layer addAnimation:[CATransition animation] forKey:kCATransition];
};

BOOL imageExists = [sharedImageCache retrieveImageForEntity:user withFormatName:formatName completionBlock:completionBlock];

if (imageExists == NO) {
    _imageView.image = [self _userPlaceholderImage];
}

这里有几个需要注意的地方。

  1. 请注意,实体和图像格式名称唯一标识了图像缓存中期望的图像。作为一个格式名称唯一标识了一个图像表,因此仅实体本身可以唯一标识图像表中期望的图像数据。
  2. 图像缓存永远不会直接返回一个UIImage。请求的图像包含在完成块中。返回值将指示图像是否已存在于图像缓存中。
  3. -retrieveImageForEntity:withFormatName:completionBlock:是一个同步方法。如果请求的图像已存在于图像缓存中,则立即调用完成块。这个方法有一个异步版本,称为-asynchronouslyRetrieveImageForEntity:withFormatName:completionBlock:
  4. 如果请求的图像尚未存在于图像缓存中,则图像缓存将调用必要操作以请求其代理的源图像。之后,可能过一段时间,将调用完成块。

注意:同步和异步的区别仅适用于检索图像缓存中已存在的图像。在同步图像请求用于图像缓存中不存在的图像的情况下,图像缓存不会阻塞调用线程,直到得到图像。检索方法将立即返回NO,并在稍后调用完成块。

请参阅FICImageCache类头文件,以详细了解图像检索的执行生命周期,特别是与处理完成块的关系。

向图像缓存提供源图像

向图像缓存提供源图像有两种方法。

  1. 按需:这是首选方法。图像缓存的代理负责向图像缓存提供源图像。

    - (void)imageCache:(FICImageCache *)imageCache wantsSourceImageForEntity:(id<FICEntity>)entity withFormatName:(NSString *)formatName completionBlock:(FICImageRequestCompletionBlock)completionBlock {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            // Fetch the desired source image by making a network request
            NSURL *requestURL = [entity sourceImageURLWithFormatName:formatName];
            UIImage *sourceImage = [self _sourceImageForURL:requestURL];
    
            dispatch_async(dispatch_get_main_queue(), ^{
                completionBlock(sourceImage);
            });
        });
    }    

    这正是基于URL如何管理图像请求的缓存图像请求方便之处。首先,对于一个图像缓存正在处理的图像的检索请求(例如,等待下载大图像),只需要将其添加到第一个请求的完成块数组中。其次,如果源图像是从互联网上下载的(这种情况很常见),那么此类网络请求的URL也容易获得。

    注意:完成块必须在主线程上调用。快速图像缓存的设计使得这个调用不会阻塞主线程,因为处理源图像是在图像缓存的串行分发队列中完成的。

  2. 手动:可以将图像数据手动插入图像缓存。

    // Just finished downloading new user photo
    
    XXUser *user = [self _currentUser];
    NSString *formatName = XXImageFormatNameUserThumbnailSmall;
    FICImageCacheCompletionBlock completionBlock = ^(id <FICEntity> entity, NSString *formatName, UIImage *image) {
        NSLog(@"Processed and stored image for entity: %@", entity);
    };
    
    [sharedImageCache setImage:newUserPhoto forEntity:user withFormatName:formatName completionBlock:completionBlock];

注意:快速图像缓存不会持久化源图像。有关更多信息,请参阅源图像持久化

取消源图像请求

如果一个图像请求已经开始,则可以取消它

// We scrolled up far enough that the image we requested in no longer visible; cancel the request
XXUser *user = [self _currentUser];
NSString *formatName = XXImageFormatNameUserThumbnailSmall;
[sharedImageCache cancelImageRetrievalForEntity:user withFormatName:formatName];

在这种情况下,快速图像缓存会清理它的内部记录,并且相应图像请求的任何完成块在此点将不会做任何事情。然而,图像缓存的代理仍然负责确保任何未完成的源图像请求(例如,网络请求)被取消

- (void)imageCache:(FICImageCache *)imageCache cancelImageLoadingForEntity:(id <FICEntity>)entity withFormatName:(NSString *)formatName {
    [self _cancelNetworkRequestForSourceImageForEntity:entity withFormatName:formatName];
}

处理图像格式家族

将图像格式分类到家族中的优点是,当处理家族中任何一种图像格式时,图像缓存的代理可以告诉图像缓存处理家族中所有图像格式的源图像。默认情况下,针对给定的家族将处理所有图像格式,除非你实现了此代理并返回其他结果。

- (BOOL)imageCache:(FICImageCache *)imageCache shouldProcessAllFormatsInFamily:(NSString *)formatFamily forEntity:(id<FICEntity>)entity {
    BOOL shouldProcessAllFormats = NO;

    if ([formatFamily isEqualToString:XXImageFormatFamilyUserThumbnails]) {
        XXUser *user = (XXUser *)entity;
        shouldProcessAllFormats = user.active;
    }

    return shouldProcessAllFormats;
}

一次处理一个家族中的所有图像格式的优点是,不需要重复下载源图像(或者如果在磁盘上缓存,则需要重复加载到内存中)。

例如,如果一个用户更改了他们的个人资料图片,那么在处理第一个图像格式的同时处理所有变体的新源图片可能是合理的。也就是说,如果图像缓存正在处理称为“XXImageFormatNameUserThumbnailSmall”的图像格式的用户新个人资料图片,那么也有意义同时处理和存储同一用户名为“XXImageFormatNameUserThumbnailMedium”的图像格式的新的图像数据。

文档

快速图像缓存的头文件已经完全文档化,可以使用appledoc生成各种形式的文档,包括HTML和Xcode DocSet。

HTML文档可以在这里找到

示例应用程序

包含这个仓库的示例应用程序是一个Xcode项目。它展示了传统加载和显示图像方法与快速图像缓存方法的区别。有关运行示例应用程序的要求,请参阅要求

注意:示例应用程序必须提供JPEG图像,或者必须运行包含在FastImageCacheDemo目录中的fetch_demo_images.sh脚本的FastImageCacheDemo

视频

Fast Image Cache Demo App Video

注意:在本演示视频中,首先演示的是传统方法。第二种方法是使用图像表格。

统计

以下统计是从演示应用程序的一次运行中测量的

方法 滚动性能 磁盘使用率 RPRVT1
传统 ~35FPS 568KB 2.40MB1.06MB + 1.34MB
快速图像缓存 ~59FPS 2.2MB 1.15MB1.06MB + 0.09MB

可以这样理解:快速图像缓存牺牲了一定的磁盘使用率以实现更快的帧率和更低的内存使用量。


1 第一个值是一个方法用于显示一屏幕的JPEG缩略图所使用的总RPRVT。第二个值是基准RPRVT,其中所有表格视图单元格和图像视图都显示在屏幕上,但没有任何图像视图设置了图像。第三个值是每种方法使用的额外RPRVT。

贡献者

Mallory Paine
玛拉丽·佩恩 — 作者和原始API设计
@mallorypaine


Michael Potter
迈克尔·波特 — 文档和API重构
@LucasTizma

鸣谢

许可证

快速图像缓存根据 MIT 许可证 提供。

The MIT License (MIT)

Copyright (c) 2013 Path, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.