如何在iOS应用程序中最佳地缓存图片?

50

简单来说,我有一个包含图片url的NSDictionary,需要在我的UITableView中显示这些图片。每个单元格都有一个标题和一张图片。虽然我成功实现了这一点,但滚动时会出现延迟,因为似乎每次单元格进入屏幕时都会下载它们的图片。

我搜索了一下,在github上找到了SDWebImage。这使得滚动延迟消失了。我不完全确定它做了什么,但我相信它进行了一些缓存。

但是!每次我第一次打开应用程序时,我看不到任何图片,我必须向下滚动,再向上滚动才能看到它们。如果我按home键退出应用程序,然后再次打开,那么似乎缓存正在工作,因为屏幕上的图像是可见的,但是,如果我向下滚动一个单元格,那么下一个单元格就没有图片。除非我向下滚动并向上滚动,或者点击它。这是缓存应该工作的方式吗?或者从web下载图片的最佳方式是什么?这些图像很少更新,所以我几乎要将它们导入项目中,但我希望有更新图像的可能性而无需上传更新...

在启动时从缓存中加载整个tableview的所有图片是不可能的吗?这就是为什么有时我会看到没有图片的单元格吗?

是的,我很难理解什么是缓存。

--编辑--

我尝试了只使用相同大小的图像(500x150),方面错误消失了,但是当我向上或向下滚动时,所有单元格都有图片,但一开始它们是错误的。在单元格在视图中停留了一些毫秒后,正确的图像出现了。这非常令人恼火,但也许这就是应该的方式?..它似乎在一开始从缓存中选择了错误的索引。如果我滚动得很慢,那么我可以看到图像从错误的图像闪烁到正确的图像。如果我快速滚动,那么我相信错误的图像在任何时候都可见,但由于快速滚动,我无法确定。当快速滚动减慢并最终停止时,错误的图像仍然出现,但在停止滚动后立即更新为正确的图像。我还有一个自定义的UITableViewCell类,但我没有做出任何重大更改...我还没有仔细查看我的代码,但我想不出可能出错的地方...也许我有些东西放错了顺序..我在java、c#、php等方面编程很多,但我很难理解Objective-c,因为它涉及到 .h.m ...我还有...

@interface FirstViewController : UITableViewController{

/**/
NSCache *_imageCache;
}

FirstViewController.h中,包括其他变量。这不正确吗?

这是我的cellForRowAtIndexPath

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *CellIdentifier = @"hallo";
CustomCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];

if (cell == nil)
{
    cell = [[CustomCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier];
}

NSMutableArray *marr = [hallo objectAtIndex:indexPath.section];
NSDictionary *dict = [marr objectAtIndex:indexPath.row];

NSString* imageName = [dict objectForKey:@"Image"];
//NSLog(@"url: %@", imageURL);

UIImage *image = [_imageCache objectForKey:imageName];

if(image)
{
    cell.imageView.image = image;
}
else
{
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{

        NSString* imageURLString = [NSString stringWithFormat:@"example.com/%@", imageName];
        NSURL *imageURL = [NSURL URLWithString:imageURLString];
        UIImage *image = [[UIImage alloc] initWithData:[NSData dataWithContentsOfURL:imageURL]];

        if(image)
        {
            dispatch_async(dispatch_get_main_queue(), ^{
                CustomCell *cell =(CustomCell*)[self.tableView cellForRowAtIndexPath:indexPath];
                if(cell)
                {
                    cell.imageView.image = image;
                }
            });
            [_imageCache setObject:image forKey:imageName];
        }
    });
}

cell.textLabel.text = [dict objectForKey:@"Name"];

return cell;
}

1
你能在 cellRowRowAtIndexPath 中发布你的代码以及其他相关内容吗?缓存只是将你希望再次访问的东西存储在更容易到达的地方,以加快后续访问速度。在这种情况下,缓存意味着你的应用程序应该在第一次下载图像数据后实际存储每个单元格的图像数据,然后在任何需要显示特定单元格的时候重复使用它(这就是 SDWebImage 应该为你完成的工作)。 - Dima
已经更换了SDWebImage,但是在滚动时仍然出现一些奇怪的结果。有时会显示错误的图像。 - Sti
1
只需在第一个 dispatch_async 之前初始化 cell.imageView.image - Rob
我知道有点晚了,但我们已经发布了一个开源库,完美地解决了这个任务 - https://github.com/Alterplay/APSmartStorage。它可以从互联网获取文件,并智能地将其存储在磁盘和内存中。 - slatvick
查看UIImageLoader https://github.com/gngrwzrd/UIImageLoader - 这有助于处理这种情况。 - gngrwzrd
7个回答

78
缓存只是指保留所需数据的副本,以便您无需从某些速度较慢的源加载它。例如,微处理器通常具有高速缓存存储器,其中它们保存数据的副本,以便它们无需访问RAM,后者速度要慢得多。硬盘通常具有内存缓存,文件系统可以从中获得最近访问的数据块,从而更快地访问数据块。
同样地,如果您的应用程序从网络加载大量图像,则将它们缓存在设备上可能比每次需要时下载它们更加有利。有很多方法可以做到这一点 - 听起来您已经找到了其中一种方法。如果您不希望图像发生变化,您可能希望将下载的图像存储在应用程序的/Library/Caches目录中。从辅助存储加载图像将比通过网络加载图像快得多。

您可能也对不太为人知的NSCache类感兴趣,它可以将您需要的图像保存在内存中。NSCache的工作原理类似于字典,但当内存紧张时,它会开始释放一些内容。您可以首先检查给定图像的缓存,如果在那里找不到,然后可以查看您的缓存目录,如果在那里找不到,那么就可以下载它。这些操作都无法加速应用程序第一次运行时的图像加载,但一旦应用程序下载了大部分所需内容,它将更加响应迅速。


1
哇,回答得太好了。我会查看NSCache的。但是我无法想象它是如何工作的...如果我在viewDidLoad中创建一个NSCache对象,那么每次启动应用程序时,它不是空的吗?还是它以其他方式工作...我如何访问我的先前缓存对象..?嗯,我明天会读一下相关内容的。谢谢! - Sti
1
是的,它可以。不过,如果你仔细阅读,就会发现它有一个委托方法,在删除对象之前被调用--你可以利用它将对象保存到辅助存储器中。或者你可以实现自己的基于磁盘的缓存,让NSCache不参与其中。 - Caleb
嗨,Caleb,非常好的答案,我有一点注释。RAM访问如何比硬盘访问更慢?据我所知,相反,RAM地址访问比任何其他存储方式都要快。谢谢。 - Malloc
@Malloc 你好像误解了我的意思;我并没有说磁盘比内存快。处理器通常有缓存,这样它们就不必访问内存,而这些缓存是使用比主存储器更快的内存构建的。同样,磁盘通常包括固态缓存,这些缓存比磁盘本身更快。这可以节省时间,因为存在引用局部性,即如果处理器请求一段数据,那么它很可能会再次请求该数据或者在附近请求某些数据。 - Caleb
@Malloc 此外,由于 OP 是针对 iOS 提问,值得注意的是实际上没有涉及到磁盘——全部都是固态存储器。不过,速度仍然有一定范围... 以速度递减的顺序排列:寄存器、处理器缓存、主内存、二级存储、网络。 - Caleb
显示剩余2条评论

25

我觉得Caleb对缓存问题的回答很好。我只是想简单谈谈在检索图像时更新UI的流程,例如假设您有一个名为_imageCache的图像NSCache

首先,为表格视图定义一个操作队列属性:

@property (nonatomic, strong) NSOperationQueue *queue;

然后在 viewDidLoad 中,初始化这个东西:

self.queue = [[NSOperationQueue alloc] init];
self.queue.maxConcurrentOperationCount = 4;

然后在 cellForRowAtIndexPath 方法中,你可以这样做:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    static NSString *CellIdentifier = @"ilvcCell";
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];

    // set the various cell properties

    // now update the cell image

    NSString *imagename = [self imageFilename:indexPath]; // the name of the image being retrieved

    UIImage *image = [_imageCache objectForKey:imagename];

    if (image)
    {
        // if we have an cachedImage sitting in memory already, then use it

        cell.imageView.image = image;
    }
    else
    {
        cell.imageView.image = [UIImage imageNamed:@"blank_cell_image.png"];

        // the get the image in the background

        [self.queue addOperationWithBlock:^{

            // get the UIImage

            UIImage *image = [self getImage:imagename];

            // if we found it, then update UI

            if (image)
            {
                [[NSOperationQueue mainQueue] addOperationWithBlock:^{
                    // if the cell is visible, then set the image

                    UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
                    if (cell)
                        cell.imageView.image = image;
                }];

                [_imageCache setObject:image forKey:imagename];
            }
        }];
    }

    return cell;
}

最近在stackoverflow上看到一些代码示例使用GCD更新相应的 UIImageView image 属性,但在将UI更新分派回主队列的过程中,它们采用了奇怪的技术(例如重新加载单元格或表格,仅更新返回在tableView:cellForRowAtIndexPath顶部的现有cell对象的图像属性(如果该行已滚动到屏幕外,并且该单元格已被出队并正在重用于新行,则会出现问题),等等)。通过使用cellForRowAtIndexPath(不要与tableView:cellForRowAtIndexPath混淆),您可以确定单元格是否仍然可见和/或是否可能已滚动并被出队并重用。


非常感谢您的答案和代码!我尝试了这段代码,但出现了一些奇怪的问题。当我快速滚动时,某些图像上的某些方面是错误的。似乎下面图像的方面被转移到了上面的图像上,或者反过来。这是可以避免的吗?还是我必须使所有图像具有相同的大小(或至少相同的方面比例)? - Sti
@StianFlatby 这很奇怪。当你慢慢滚动和快速滚动时,你看到了不同的行为吗?并且在主队列之外,你是否对你的 imageview 进行任何配置?我已经做了很多测试,但我必须承认,我所有的 tableviews 图片大小都是一致的(小缩略图图片被优化用于 tableviews …… 在 tableviews 中使用大图像将始终拥有可怕的性能)。也许你可以更新你的问题,并提供你的 cellForRowAtIndexPath 的完整源代码。 - Rob
@StianFlatby 我刚刚对一组大小不同的图像库进行了测试,我觉得(a)单元格渲染的差异取决于图像是从缓存中加载(在主队列上同步)还是从后台队列异步调度,而不是滚动速度;(b)如果您同步执行它,那么默认单元格将根据图像的大小/存在重新格式化,因此,您要么需要创建自己的自定义单元格,这样您可以按照自己的方式配置imageView,要么只需将图像调整为某个一致的大小。 - Rob
谢谢您的努力!我已经更新了我的问题,并附上了一些新信息和代码。如果您能看一下并检查是否有任何错误,我将不胜感激。 - Sti
那么这意味着它不会显示错误的图像,而是不显示任何图像?我认为这并不能解决这个问题...即使所有正确的图像都已经正确加载,这种情况也会发生。我缓慢滚动整个表格视图,并且可以确认所有单元格上都出现了正确的图像,但是当我快速向上滚动时,它会显示错误的图像。在半快速滚动时,图像会明显更新。这意味着图像存在于缓存中,因此不应该是空白单元格的问题..?这必须在if(image){}中,而不是else{}中。也许我错了。我现在会尝试一下。 - Sti
显示剩余3条评论

14

最简单的解决方案是选择已经经受过压力测试并被广泛使用的工具。

SDWebImage 是一个强大的工具,可以帮助我解决类似的问题,并且可以轻松地通过 CocoaPods 安装。在 podfile 中:

platform :ios, '6.1'
pod 'SDWebImage', '~>3.6'

设置缓存:

SDImageCache *imageCache = [[SDImageCache alloc] initWithNamespace:@"myNamespace"];
[imageCache queryDiskCacheForKey:myCacheKey done:^(UIImage *image)
{
    // image is not nil if image was found
}];

缓存图像:

[[SDImageCache sharedImageCache] storeImage:myImage forKey:myCacheKey];

https://github.com/rs/SDWebImage


1

我认为对于你来说,使用DLImageLoader会更好。 更多信息 -> https://github.com/AndreyLunevich/DLImageLoader-iOS

[[DLImageLoader sharedInstance] loadImageFromUrl:@"image_url_here"
                                   completed:^(NSError *error, UIImage *image) {
                                        if (error == nil) {
                                            imageView.image = image;
                                        } else {
                                            // if we got an error when load an image
                                        }
                                   }];

1
关于错误图像的问题,原因是单元格的重复使用。单元格的重复使用意味着现有的单元格会消失在视野之外(例如,向下滚动时从屏幕顶部消失的单元格将再次从底部出现)。因此,您会得到不正确的图像。但是一旦单元格显示出来,获取正确图像的代码就会执行,您就可以得到正确的图像。
您可以在单元格的“prepareForReuse”方法中使用占位符。当需要重新使用单元格时,此函数通常用于重置值。在这里设置占位符将确保您不会获得任何不正确的图像。

0

缓存图像可以像这样简单地完成。

ImageService.m

@implementation ImageService{
    NSCache * Cache;
}

const NSString * imageCacheKeyPrefix = @"Image-";

-(id) init {
    self = [super init];
    if(self) {
        Cache = [[NSCache alloc] init];
    }
    return self;
}

/** 
 * Get Image from cache first and if not then get from server
 * 
 **/

- (void) getImage: (NSString *) key
        imagePath: (NSString *) imagePath
       completion: (void (^)(UIImage * image)) handler
    {
    UIImage * image = [Cache objectForKey: key];

    if( ! image || imagePath == nil || ! [imagePath length])
    {
        image = NOIMAGE;  // Macro (UIImage*) for no image 

        [Cache setObject:image forKey: key];

        dispatch_async(dispatch_get_main_queue(), ^(void){
            handler(image);
        });
    }
    else
    {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH,0ul ),^(void){

            UIImage * image = [UIImage imageWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:[imagePath stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding]]]];

            if( !image)
            {
                image = NOIMAGE;
            }

            [Cache setObject:image forKey: key];

            dispatch_async(dispatch_get_main_queue(), ^(void){
                handler(image);
            });
        });
    }
}


- (void) getUserImage: (NSString *) userId
           completion: (void (^)(UIImage * image)) handler
{
    [self getImage: [NSString stringWithFormat: @"%@user-%@", imageCacheKeyPrefix, userId]
         imagePath: [NSString stringWithFormat: @"http://graph.facebook.com/%@/picture?type=square", userId]
        completion: handler];
}

SomeViewController.m

    [imageService getUserImage: userId
                    completion: ^(UIImage *image) {
            annotationImage.image = image;
    }];

你测试过这段代码并且它能正常工作吗?仅仅查看这段代码似乎不会正常工作,因为你的主方法进行了一个检查 if( ! image || imagePath == nil || ! [imagePath length]) 如果图片不存在,那么将一个不存在的图片设置到缓存中(不好的实践)。否则(else条件),如果图片存在,你正在同步获取它??? - Stunner

0
////.h file

#import <UIKit/UIKit.h>

@interface UIImageView (KJ_Imageview_WebCache)


-(void)loadImageUsingUrlString :(NSString *)urlString placeholder :(UIImage *)placeholder_image;

@end

//.m file


#import "UIImageView+KJ_Imageview_WebCache.h"


@implementation UIImageView (KJ_Imageview_WebCache)




-(void)loadImageUsingUrlString :(NSString *)urlString placeholder :(UIImage *)placeholder_image
{




    NSString *imageUrlString = urlString;
    NSURL *url = [NSURL URLWithString:urlString];



    NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,     NSUserDomainMask, YES);
    NSString *documentsDirectory = [paths objectAtIndex:0];
    NSString *getImagePath = [documentsDirectory stringByAppendingPathComponent:[self tream_char:urlString]];

    NSLog(@"getImagePath--->%@",getImagePath);

    UIImage *customImage = [UIImage imageWithContentsOfFile:getImagePath];




    if (customImage)
    {
        self.image = customImage;


        return;
    }
    else
    {
         self.image=placeholder_image;
    }


    NSURLSession *session = [NSURLSession sharedSession];
    NSURLSessionDataTask *uploadTask = [session dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {


        if (error)
        {
            NSLog(@"%@",[error localizedDescription]);

           self.image=placeholder_image;

            return ;
        }

        dispatch_async(dispatch_get_main_queue(), ^{


            UIImage *imageToCache = [UIImage imageWithData:data];



            if (imageUrlString == urlString)
            {

                self.image = imageToCache;
            }



            [self saveImage:data ImageString:[self tream_char:urlString]];

        });




    }];

    [uploadTask resume];



}


-(NSString *)tream_char :(NSString *)string
{
    NSString *unfilteredString =string;

    NSCharacterSet *notAllowedChars = [[NSCharacterSet characterSetWithCharactersInString:@"!@#$%^&*()_+|abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"] invertedSet];
    NSString *resultString = [[unfilteredString componentsSeparatedByCharactersInSet:notAllowedChars] componentsJoinedByString:@""];
    NSLog (@"Result: %@", resultString);



    return resultString;

}

-(void)saveImage : (NSData *)Imagedata ImageString : (NSString *)imageString
{

    NSArray* documentDirectories = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask,YES);

    NSString* documentDirectory = [documentDirectories objectAtIndex:0];
    NSString* documentDirectoryFilename = [documentDirectory stringByAppendingPathComponent:imageString];




    if (![Imagedata writeToFile:documentDirectoryFilename atomically:NO])
    {
        NSLog((@"Failed to cache image data to disk"));
    }
    else
    {
        NSLog(@"the cachedImagedPath is %@",documentDirectoryFilename);
    }

}

@end


/// call 

 [cell.ProductImage loadImageUsingUrlString:[[ArrProductList objectAtIndex:indexPath.row] valueForKey:@"product_image"] placeholder:[UIImage imageNamed:@"app_placeholder"]];

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接