Прокрутка таблицы с большими изображениями в iOS

HTML и CSSXSLTJavaScriptИзображенияСофтEtc
Алексей Кузнецов

26 апреля 2011


Задача.

Добиться плавности прокрутки таблицы с большими изображениями.

Представим таблицу UITableView, которая выполняет роль содержания иллюстрированной книги для «Айпада». В каждой ячейке таблицы находится по несколько уменьшенных изображений страниц книги. Эти изображения довольно крупные, потому что каждая страница — это комикс и важно, чтобы люди по уменьшенным картинкам могли легко найти нужную.




Когда изображение достаточно большое, его декодирование занимает довольно много времени. Это наблюдается и с PNG, который еще на этапе сборки проекта переводится в удобный для устройства формат и рекомендован «Эпплом» для использовании в UIKit-программах. Если не предпринимать специальных действий, то не получится добиться плавности прокрутки таблицы, ячейки которой содержат такие большие изображения.

Вначале может показаться, что проблема в чтении файла с накопителя, но профайлер говорит, что это не так. Как -[UIImage imageNamed:], так и -[UIImage imageWithContentsOfFile:] не декодируют изображения сразу, а откладывают это на потом. Декодирование происходит, например, при вызове -[UIImageView setImage:]. Что самое неприятное, этот метод нельзя вызывать в неглавном потоке. До iOS 4 никакие методы UIKit нельзя было вызывать в потоках, отличающихся от главного. В iOS 4 ситуация немного изменилась, но касательно -[UIImageView setImage:] правила остались те же. Получается, находясь на уровне UIKit запустить дорогостоящую операцию декодирования изображения можно только в драгоценном главном потоке, который занят прокруткой таблицы в тот самый момент, когда это изображение как раз нужно.

Иногда, конечно, можно заранее декодировать все изображения, а потом только лишь отдавать их. Но такое решение ненадежно, потому что изображений может быть очень много и они могут не вместиться в память. К тому же время главного потока все равно придется потратить в какой-то момент. Если, например, это сделать на этапе запуска приложения, то время запуска увеличится, а это непозволительная роскошь в iOS.

К счастью, нам доступен не только UIKit с его UIImage, но и Core Graphics с CGImage. И функции CGImage можно вызывать в фоне, в неглавном потоке. Более того, там же изображение можно и декодировать. Вот как это делается:

01 
02 
03 
04 
05 
06 
07 
08 
09 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
25 
26 
27 
UIImage *anImage = ...  // Assume this exists.
CGImageRef originalImage = (CGImageRef)anImage;
assert(originalImage != NULL);

CFDataRef imageData = CGDataProviderCopyData(CGImageGetDataProvider(originalImage));
CGDataProviderRef imageDataProvider = CGDataProviderCreateWithCFData(imageData);
if (imageData != NULL) {
  CFRelease(imageData);
}
CGImageRef image = CGImageCreate(CGImageGetWidth(originalImage),
                                 CGImageGetHeight(originalImage),
                                 CGImageGetBitsPerComponent(originalImage),
                                 CGImageGetBitsPerPixel(originalImage),
                                 CGImageGetBytesPerRow(originalImage),
                                 CGImageGetColorSpace(originalImage),
                                 CGImageGetBitmapInfo(originalImage),
                                 imageDataProvider,
                                 CGImageGetDecode(originalImage),
                                 CGImageGetShouldInterpolate(originalImage),
                                 CGImageGetRenderingIntent(originalImage));
if (imageDataProvider != NULL) {
  CGDataProviderRelease(imageDataProvider);
}

// Do something with the image.

CGImageRelease(image);

Изображение, полученное от CGImageCreate(), уже декодировано, а это то, что нам и нужно. Осталось только завернуть этот код в NSOperation, чтобы выполнить его в фоне, и в конце выполнения передать результаты в главный поток. Из полученного CGImage с помощью +[UIImage imageWithCGImage:] получаем UIImage и назначаем его нужному UIImageView прямо в процессе прокрутки. Анимация при этом больше не страдает.

Пример использования:

01 
02 
03 
04 
05 
06 
07 
08 
09 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
25 
26 
27 
28 
29 
30 
31 
32 
33 
34 
35 
36 
37 
38 
39 
40 
41 
42 
43 
44 
45 
46 
47 
48 
49 
50 
51 
52 
53 
54 
55 
56 
57 
58 
59 
60 
61 
62 
63 
64 
65 
66 
67 
68 
69 
70 
71 
72 
73 
74 
75 
76 
77 
78 
79 
80 
81 
82 
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
  NSUInteger index = ...;  // Page index, assume it exists.
  
  NSNumber *indexNumber = [NSNumber numberWithUnsignedInteger:index];
  UIImage *thumbnailImage = [self thumbnailForPageAtIndex:index];
  CGImageRef imageRef = [thumbnailImage CGImage];
  NSDictionary *thumbnailDict = [NSDictionary dictionaryWithObjectsAndKeys:
                                 indexNumber, kThumbnailIndexKey,
                                 imageRef, kThumbnailImageKey,
                                 nil];
  
  NSInvocationOperation *thumbnailOperation
    = [[[NSInvocationOperation alloc]
        initWithTarget:self
              selector:@selector(decodeThumbnail:)
                object:thumbnailDict]
       autorelease];
  
  [[self operationQueue] addOperation:thumbnailOperation];
}

- (void)decodeThumbnail:(NSDictionary *)thumbnailDict {
  // thumbnailDict contains thumbnail and its index. Thumbnail
  // is represented by a UIImage object with key kThumbnailImageKey,
  // Thumbnail index is represented by an NSNumber object with key
  // kThumbnailIndexKey.
  
  CGImageRef originalImage = (CGImageRef)[thumbnailDict objectForKey:kThumbnailImageKey];
  NSNumber *indexNumber = [thumbnailDict objectForKey:kThumbnailIndexKey];
  if (originalImage == NULL || indexNumber == nil) {
    return;
  }
  
  CFDataRef imageData = CGDataProviderCopyData(CGImageGetDataProvider(originalImage));
  CGDataProviderRef imageDataProvider = CGDataProviderCreateWithCFData(imageData);
  if (imageData != NULL) {
    CFRelease(imageData);
  }
  CGImageRef image = CGImageCreate(CGImageGetWidth(originalImage),
                                   CGImageGetHeight(originalImage),
                                   CGImageGetBitsPerComponent(originalImage),
                                   CGImageGetBitsPerPixel(originalImage),
                                   CGImageGetBytesPerRow(originalImage),
                                   CGImageGetColorSpace(originalImage),
                                   CGImageGetBitmapInfo(originalImage),
                                   imageDataProvider,
                                   CGImageGetDecode(originalImage),
                                   CGImageGetShouldInterpolate(originalImage),
                                   CGImageGetRenderingIntent(originalImage));
  if (imageDataProvider != NULL) {
    CGDataProviderRelease(imageDataProvider);
  }
  
  NSDictionary *dict = [NSDictionary dictionaryWithObjectsAndKeys:
                        indexNumber, kThumbnailIndexKey,
                        image, kThumbnailImageKey,
                        nil];
  CGImageRelease(image);
  
  [self performSelectorOnMainThread:@selector(didDecodeThumbnail:)
                         withObject:dict
                      waitUntilDone:NO];
}

- (void)didDecodeThumbnail:(NSDictionary *)thumbnailDict {
  // Unpack thumbnail and its index.
  NSNumber *indexNumber = [thumbnailDict objectForKey:kThumbnailIndexKey];
  NSUInteger index = [indexNumber unsignedIntegerValue];
  CGImageRef imageRef = (CGImageRef)[thumbnailDict objectForKey:kThumbnailImageKey];
  UIImage *image = [UIImage imageWithCGImage:imageRef];
  
  // Set thumbnail as an image for the button that opens book page at a given index.
  NSUInteger row = index / kThumbnailsInRow;
  NSIndexPath *path = [NSIndexPath indexPathForRow:row inSection:0];
  NSArray *visiblePaths = [[self tableView] indexPathsForVisibleRows];
  if ([visiblePaths containsObject:path]) {
    UITableViewCell *cell = [[self tableView] cellForRowAtIndexPath:path];
    NSInteger tag = index - row * kThumbnailsInRow + 1;
    UIButton *button = (UIButton *)[cell viewWithTag:tag];
    [button setImage:image forState:UIControlStateNormal];
  }
}