iOS 动画和优化效率
已发表: 2022-03-11构建一个伟大的应用程序不仅仅关乎外观或功能,还关乎它的性能。 尽管移动设备的硬件规格正在快速改进,但性能不佳、每次屏幕转换时卡顿或像幻灯片放映一样滚动的应用程序可能会破坏用户的体验并成为令人沮丧的原因。 在本文中,我们将了解如何衡量 iOS 应用程序的性能并对其进行优化以提高效率。 出于本文的目的,我们将构建一个包含一长串图像和文本的简单应用程序。
出于测试性能的目的,我建议使用真实设备。 如果您认真地构建应用程序并优化它们以实现流畅的 iOS 动画,那么模拟器根本不会削减它。 模拟有时可能与现实脱节。 例如,模拟器可能在您的 Mac 上运行,这可能意味着 CPU(中央处理单元)比 iPhone 上的 CPU 强大得多。 相反,您的设备和 Mac 之间的 GPU(图形处理单元)是如此不同,以至于您的 Mac 实际上模拟了设备的 GPU。 因此,受 CPU 限制的操作在您的模拟器上往往更快,而受 GPU 限制的操作往往更慢。
以 60 FPS 制作动画
感知性能的一个关键方面是确保您的动画以 60 FPS(每秒帧数)运行,即屏幕的刷新率。 有一些基于计时器的动画,我们不会在这里讨论。 一般来说,如果您以超过 50 FPS 的速度运行,您的应用程序看起来会很流畅且性能良好。 如果您的动画卡在 20 到 40 FPS 之间,则会出现明显的卡顿,并且用户会在过渡中检测到“粗糙度”。 任何低于 20 FPS 的速度都会严重影响您应用的可用性。
在我们开始之前,可能有必要讨论一下 CPU 绑定和 GPU 绑定操作之间的区别。 GPU 是专门为绘制图形而优化的芯片。 虽然 CPU 也可以,但速度要慢得多。 这就是为什么我们希望将尽可能多的图形渲染(从 2D 或 3D 模型生成图像的过程)转移到 GPU 上。 但我们需要小心,因为当 GPU 的处理能力耗尽时,即使 CPU 相对空闲,图形相关的性能也会下降。
Core Animation 是一个强大的框架,可以在应用程序内部和外部处理动画。 它将过程分解为 6 个关键步骤:
布局:您在其中排列图层并设置其属性,例如颜色及其相对位置
显示:这是将背景图像绘制到上下文的位置。 您在
drawRect:
或drawLayer:inContext:
中编写的任何例程都可以在此处访问。准备:在这个阶段,Core Animation 即将发送上下文到渲染器进行绘制,执行一些必要的任务,例如解压缩图像。
提交:Core Animation 将所有这些数据发送到渲染服务器。
反序列化:前面的 4 个步骤都在你的应用程序内,现在动画在你的应用程序外处理,打包的层被反序列化成渲染服务器可以理解的树。 一切都转换为 OpenGL 几何图形。
绘制:渲染形状(实际上是三角形)。
您可能已经猜到,进程 1-4 是 CPU 操作,而 5-6 是 GPU 操作。 实际上,您只能控制前两个步骤。 GPU 的最大杀手是半透明层,其中 GPU 必须每帧多次填充相同的像素。 此外,任何离屏绘制(阴影、遮罩、圆角或图层光栅化等多个图层效果都会强制 Core Animation 离屏绘制)也会影响性能。 太大而无法由 GPU 处理的图像将由速度慢得多的 CPU 处理。 虽然可以通过直接在图层上设置两个属性来轻松实现阴影,但如果屏幕上有许多带有阴影的对象,它们很容易降低性能。 有时值得考虑将这些阴影添加为图像。
测量 iOS 动画性能
我们将从一个包含 5 个 PNG 图像和一个表格视图的简单应用程序开始。 在这个应用程序中,我们将加载 5 张图像,但会重复超过 10,000 行。 我们将为图像和图像旁边的标签添加阴影:
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; cell.customCellImageView.image = image; cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; return cell; }
图像被简单地回收,而标签总是不同的。 结果是:
垂直滑动时,您很可能会在视图滚动时注意到卡顿。 此时您可能会认为我们在主线程中加载图像的事实是问题所在。 可能如果我们把它移到后台线程,我们所有的问题都会得到解决。
与其盲目猜测,不如尝试一下并衡量性能。 是时候使用仪器了。
要使用 Instruments,您需要将“Run”更改为“Profile”。 而且您还应该连接到您的真实设备,并非所有仪器都可以在模拟器上使用(这是您不应该在模拟器上优化性能的另一个原因!)。 我们将主要使用“GPU Driver”、“Core Animation”和“Time Profiler”模板。 一个鲜为人知的事实是,您可以拖放多个仪器并同时运行多个,而不是在不同的仪器上停止和运行。
现在我们已经设置好了仪器,让我们测量一下。 首先让我们看看我们的 FPS 是否真的有问题。
哎呀,我认为我们在这里获得了 18 FPS。 在主线程上从包中加载图像真的那么昂贵和昂贵吗? 请注意,我们的渲染器利用率几乎已达到极限。 我们的瓷砖利用率也是如此。 两者都在 95% 以上。 这与在主线程上从包中加载图像无关,所以我们不要在这里寻找解决方案。
调整效率
有一个名为 shouldRasterize 的属性,人们可能会建议您在这里使用它。 shouldRasterize 到底是做什么的? 它将您的图层缓存为平面图像。 所有这些昂贵的图层绘图都需要进行一次。 如果您的帧经常更改,则缓存没有用处,因为无论如何每次都需要重新生成它。
快速修改我们的代码,我们得到:
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; cell.customCellImageView.image = image; cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; cell.layer.shouldRasterize = YES; cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }
我们再次测量:
仅用两条线,我们就将 FPS 提高了 2 倍。 我们现在的平均速度超过 40 FPS。 但是,如果我们将图像加载移到后台线程会有帮助吗?
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; cell.tag = indexPath.row; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; dispatch_async(dispatch_get_main_queue(), ^{ if (indexPath.row == cell.tag) { cell.customCellImageView.image = image; } }); }); cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; // cell.layer.shouldRasterize = YES; // cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }
经过测量,我们看到性能平均在 18 FPS 左右:

没有什么值得庆祝的。 换句话说,它没有提高我们的帧速率。 那是因为即使阻塞主线程是错误的,这不是我们的瓶颈,渲染是。
回到更好的例子,我们平均高于 40FPS,性能明显更流畅。 但我们实际上可以做得更好。
在 Core Animation Tool 上检查“Color Blended Layers”,我们看到:
“颜色混合层”显示在屏幕上,您的 GPU 正在执行大量渲染。 绿色表示渲染活动最少,而红色表示最多。 但是我们确实将 shouldRasterize 设置为YES
。 值得指出的是,“颜色混合图层”与“颜色命中绿色并错过红色”不同。 后者基本上在重新生成缓存时以红色突出显示栅格化图层(一个很好的工具,可以查看您是否没有正确使用缓存)。 将 shouldRasterize 设置为YES
对非透明图层的初始渲染没有影响。
这是很重要的一点,我们需要停下来思考一下。 无论 shouldRasterize 是否设置为YES
,渲染框架都需要检查所有视图,并根据子视图是透明还是不透明进行混合(或不混合)。 虽然你的 UILabel 不透明可能是有意义的,但它可能毫无价值并且会扼杀你的表现。 例如,白色背景上的透明 UILabel 可能毫无价值。 让我们让它不透明:
这会产生更好的性能,但我们对应用程序的外观和感觉已经改变。 现在,因为我们的标签和图像是不透明的,阴影已经在我们的图像周围移动。 可能没有人会喜欢这种变化,如果我们想以一流的性能保留原始外观和感觉,我们不会失去希望。
为了在保留原始外观的同时挤出一些额外的 FPS,重要的是重新审视我们迄今为止忽略的两个核心动画阶段。
- 准备
- 犯罪
这些似乎完全不受我们控制,但事实并非如此。 我们知道要加载的图像需要解压缩。 解压缩时间根据图像格式而变化。 对于 PNG 的解压缩比 JPEG 快得多(尽管加载时间更长,这也取决于图像大小),所以我们在使用 PNG 的道路上是正确的,但是我们对解压缩过程没有做任何事情,而且这个解压缩正在“绘图点”发生! 这是我们可以消磨时间的最糟糕的地方——在主线程上。
有办法强制解压。 我们可以立即将其设置为 UIImageView 的图像属性。 但这仍然会解压缩主线程上的图像。 有没有更好的办法?
有一个。 将其绘制到CGContext中,图像需要在其中解压缩才能绘制。 我们可以在后台线程中执行此操作(使用 CPU),并根据图像视图的大小对其进行必要的限制。 这将通过在主线程之外完成它来优化我们的图像绘制过程,并使我们免于在主线程上进行不必要的“准备”计算。
当我们这样做的时候,为什么不在我们绘制图像时添加阴影呢? 然后,我们可以将图像捕获(并将其缓存)为一张静态的、不透明的图像。 代码如下:
- (UIImage*)generateImageFromName:(NSString*)imageName { //define a boudns for drawing CGRect imgVwBounds = CGRectMake(0, 0, 48, 48); //get the image NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; //draw in the context UIGraphicsBeginImageContextWithOptions(imgVwBounds.size, NO, 0); { //get context CGContextRef context = UIGraphicsGetCurrentContext(); //shadow CGContextSetShadowWithColor(context, CGSizeMake(0, 3.0f), 3.0f, [UIColor blackColor].CGColor); CGContextBeginTransparencyLayer (context, NULL); [image drawInRect:imgVwBounds blendMode:kCGBlendModeNormal alpha:1.0f]; CGContextSetRGBStrokeColor(context, 1.0, 1.0, 1.0, 1.0); CGContextEndTransparencyLayer(context); } image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return image; }
最后:
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; // NSInteger index = (indexPath.row % [self.images count]); // NSString *imageName = [self.images objectAtIndex:index]; // // cell.tag = indexPath.row; // // dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ // NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; // UIImage *image = [UIImage imageWithContentsOfFile:filePath]; // // dispatch_async(dispatch_get_main_queue(), ^{ // if (indexPath.row == cell.tag) { // cell.customCellImageView.image = image; // } // }); // }); cell.customCellImageView.image = [self getImageByIndexPath:indexPath]; cell.customCellImageView.clipsToBounds = YES; // cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); // cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; cell.layer.shouldRasterize = YES; cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }
结果是:
我们现在的平均速度超过 55 FPS,我们的渲染利用率和平铺利用率几乎是原来的一半。
包起来
万一您想知道我们还能做些什么来每秒增加几帧,请不要再犹豫了。 UILabel 使用 WebKit HTML 来呈现文本。 我们可以直接进入 CATextLayer,也许也可以在那里玩弄阴影。
您可能已经注意到,在我们上面的实现中,我们没有在后台线程中加载图像,而是缓存它。 由于只有 5 张图像,因此运行速度非常快,并且似乎不会影响整体性能(尤其是因为所有 5 张图像在滚动之前都已加载到屏幕上)。 但是您可能想尝试将此逻辑移动到后台线程以获得额外的性能。
优化效率是世界级应用程序和业余应用程序之间的区别。 性能优化,尤其是在 iOS 动画方面,可能是一项艰巨的任务。 但是在 Instruments 的帮助下,可以很容易地诊断出 iOS 上动画性能的瓶颈。