CATiledLayer

有些时候你可能需要绘制一个很大的图片,常见的例子就是一个高像素的照片或者是地球表面的详细地图。iOS应用通畅运行在内存受限的设备上,所以读取整个图片到内存中是不明智的。载入大图可能会相当地慢,那些对你看上去比较方便的做法(在主线程调用UIImage-imageNamed:方法或者-imageWithContentsOfFile:方法)将会阻塞你的用户界面,至少会引起动画卡顿现象。

能高效绘制在iOS上的图片也有一个大小限制。所有显示在屏幕上的图片最终都会被转化为OpenGL纹理,同时OpenGL有一个最大的纹理尺寸(通常是2048*2048,或4096*4096,这个取决于设备型号)。如果你想在单个纹理中显示一个比这大的图,即便图片已经存在于内存中了,你仍然会遇到很大的性能问题,因为Core Animation强制用CPU处理图片而不是更快的GPU(见第12章『速度的曲调』,和第13章『高效绘图』,它更加详细地解释了软件绘制和硬件绘制)。

CATiledLayer为载入大图造成的性能问题提供了一个解决方案:将大图分解成小片然后将他们单独按需载入。让我们用实验来证明一下。

小片裁剪

这个示例中,我们将会从一个2048*2048分辨率的雪人图片入手。为了能够从CATiledLayer中获益,我们需要把这个图片裁切成许多小一些的图片。你可以通过代码来完成这件事情,但是如果你在运行时读入整个图片并裁切,那CATiledLayer这些所有的性能优点就损失殆尽了。理想情况下来说,最好能够逐个步骤来实现。

清单6.11 演示了一个简单的Mac OS命令行程序,它用CATiledLayer将一个图片裁剪成小图并存储到不同的文件中。

清单6.11 裁剪图片成小图的终端程序

  1. #import <AppKit/AppKit.h>
  2. int main(int argc, const char * argv[])
  3. {
  4. @autoreleasepool{
  5. //handle incorrect arguments
  6. if (argc < 2) {
  7. NSLog(@"TileCutter arguments: inputfile");
  8. return 0;
  9. }
  10. //input file
  11. NSString *inputFile = [NSString stringWithCString:argv[1] encoding:NSUTF8StringEncoding];
  12. //tile size
  13. CGFloat tileSize = 256; //output path
  14. NSString *outputPath = [inputFile stringByDeletingPathExtension];
  15. //load image
  16. NSImage *image = [[NSImage alloc] initWithContentsOfFile:inputFile];
  17. NSSize size = [image size];
  18. NSArray *representations = [image representations];
  19. if ([representations count]){
  20. NSBitmapImageRep *representation = representations[0];
  21. size.width = [representation pixelsWide];
  22. size.height = [representation pixelsHigh];
  23. }
  24. NSRect rect = NSMakeRect(0.0, 0.0, size.width, size.height);
  25. CGImageRef imageRef = [image CGImageForProposedRect:&rect context:NULL hints:nil];
  26. //calculate rows and columns
  27. NSInteger rows = ceil(size.height / tileSize);
  28. NSInteger cols = ceil(size.width / tileSize);
  29. //generate tiles
  30. for (int y = 0; y < rows; ++y) {
  31. for (int x = 0; x < cols; ++x) {
  32. //extract tile image
  33. CGRect tileRect = CGRectMake(x*tileSize, y*tileSize, tileSize, tileSize);
  34. CGImageRef tileImage = CGImageCreateWithImageInRect(imageRef, tileRect);
  35. //convert to jpeg data
  36. NSBitmapImageRep *imageRep = [[NSBitmapImageRep alloc] initWithCGImage:tileImage];
  37. NSData *data = [imageRep representationUsingType: NSJPEGFileType properties:nil];
  38. CGImageRelease(tileImage);
  39. //save file
  40. NSString *path = [outputPath stringByAppendingFormat: @"_%02i_%02i.jpg", x, y];
  41. [data writeToFile:path atomically:NO];
  42. }
  43. }
  44. }
  45. return 0;
  46. }

这个程序将2048*2048分辨率的雪人图案裁剪成了64个不同的256*256的小图。(256*256是CATiledLayer的默认小图大小,默认大小可以通过tileSize属性更改)。程序接受一个图片路径作为命令行的第一个参数。我们可以在编译的scheme将路径参数硬编码然后就可以在Xcode中运行了,但是以后作用在另一个图片上就不方便了。所以,我们编译了这个程序并把它保存到敏感的地方,然后从终端调用,如下面所示:

  1. > path/to/TileCutterApp path/to/Snowman.jpg

The app is very basic, but could easily be extended to support additional arguments such as tile size, or to export images in formats other than JPEG. The result of running it is a sequence of 64 new images, named as follows:

这个程序相当基础,但是能够轻易地扩展支持额外的参数比如小图大小,或者导出格式等等。运行结果是64个新图的序列,如下面命名:

  1. Snowman_00_00.jpg
  2. Snowman_00_01.jpg
  3. Snowman_00_02.jpg
  4. ...
  5. Snowman_07_07.jpg

既然我们有了裁切后的小图,我们就要让iOS程序用到他们。CATiledLayer很好地和UIScrollView集成在一起。除了设置图层和滑动视图边界以适配整个图片大小,我们真正要做的就是实现-drawLayer:inContext:方法,当需要载入新的小图时,CATiledLayer就会调用到这个方法。

清单6.12演示了代码。图6.12是代码运行结果。

清单6.12 一个简单的滚动CATiledLayer实现

  1. #import "ViewController.h"
  2. #import <QuartzCore/QuartzCore.h>
  3. @interface ViewController ()
  4. @property (nonatomic, weak) IBOutlet UIScrollView *scrollView;
  5. @end
  6. @implementation ViewController
  7. - (void)viewDidLoad
  8. {
  9. [super viewDidLoad];
  10. //add the tiled layer
  11. CATiledLayer *tileLayer = [CATiledLayer layer];
  12. tileLayer.frame = CGRectMake(0, 0, 2048, 2048);
  13. tileLayer.delegate = self; [self.scrollView.layer addSublayer:tileLayer];
  14. //configure the scroll view
  15. self.scrollView.contentSize = tileLayer.frame.size;
  16. //draw layer
  17. [tileLayer setNeedsDisplay];
  18. }
  19. - (void)drawLayer:(CATiledLayer *)layer inContext:(CGContextRef)ctx
  20. {
  21. //determine tile coordinate
  22. CGRect bounds = CGContextGetClipBoundingBox(ctx);
  23. NSInteger x = floor(bounds.origin.x / layer.tileSize.width);
  24. NSInteger y = floor(bounds.origin.y / layer.tileSize.height);
  25. //load tile image
  26. NSString *imageName = [NSString stringWithFormat: @"Snowman_%02i_%02i", x, y];
  27. NSString *imagePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"jpg"];
  28. UIImage *tileImage = [UIImage imageWithContentsOfFile:imagePath];
  29. //draw tile
  30. UIGraphicsPushContext(ctx);
  31. [tileImage drawInRect:bounds];
  32. UIGraphicsPopContext();
  33. }
  34. @end

图6.12

图6.12 用UIScrollView滚动CATiledLayer

当你滑动这个图片,你会发现当CATiledLayer载入小图的时候,他们会淡入到界面中。这是CATiledLayer的默认行为。(你可能已经在iOS 6之前的苹果地图程序中见过这个效果)你可以用fadeDuration属性改变淡入时长或直接禁用掉。CATiledLayer(不同于大部分的UIKit和Core Animation方法)支持多线程绘制,-drawLayer:inContext:方法可以在多个线程中同时地并发调用,所以请小心谨慎地确保你在这个方法中实现的绘制代码是线程安全的。

Retina小图

你也许已经注意到了这些小图并不是以Retina的分辨率显示的。为了以屏幕的原生分辨率来渲染CATiledLayer,我们需要设置图层的contentsScale来匹配UIScreenscale属性:

  1. tileLayer.contentsScale = [UIScreen mainScreen].scale;

有趣的是,tileSize是以像素为单位,而不是点,所以增大了contentsScale就自动有了默认的小图尺寸(现在它是128*128的点而不是256*256).所以,我们不需要手工更新小图的尺寸或是在Retina分辨率下指定一个不同的小图。我们需要做的是适应小图渲染代码以对应安排scale的变化,然而:

  1. //determine tile coordinate
  2. CGRect bounds = CGContextGetClipBoundingBox(ctx);
  3. CGFloat scale = [UIScreen mainScreen].scale;
  4. NSInteger x = floor(bounds.origin.x / layer.tileSize.width * scale);
  5. NSInteger y = floor(bounds.origin.y / layer.tileSize.height * scale);

通过这个方法纠正scale也意味着我们的雪人图将以一半的大小渲染在Retina设备上(总尺寸是1024*1024,而不是2048*2048)。这个通常都不会影响到用CATiledLayer正常显示的图片类型(比如照片和地图,他们在设计上就是要支持放大缩小,能够在不同的缩放条件下显示),但是也需要在心里明白。