WWDC18 What’s New in LLVM

前言

LLVM 作为 Apple 御用的编译基础设施其重要性不言而喻,Apple 从未停止对 LLVM 的维护和更新,并且几乎在每年的 WWDC 中都有专门的 Session 来针对 LLVM 的新特性做介绍和讲解,刚刚过去的 WWDC18 也不例外。

WWDC18 Session 409 What’s New in LLVM 中 Apple 的工程师们又为我们介绍了 LLVM 最新的特性,这篇文章将会结合 WWDC18 Session 409 给出的 官方演示文稿 分享一下 LLVM 的新特性并谈谈笔者自己个人对这些特性的拙见。

Note: 本文不会对官方演示文稿做逐字逐句的翻译工作,亦不会去过多介绍 LLVM 的基本常识。

索引

  • ARC 更新
  • Xcode 10 新增诊断
  • Clang 静态分析
  • 增加安全性
  • 新指令集扩展
  • 总结

ARC 更新

本次 ARC 更新的亮点在于 C struct 中允许使用 ARC Objective-C 对象。

在之前版本的 Xcode 中尝试在 C struct 的定义中使用 Obj—C 对象,编译器会抛出 Error: ARC forbids Objective-C objects in struct,如下图所示:

嘛~ 这是因为之前 LLVM 不支持,如果在 Xcode 10 中书写同样的代码则不会有任何 Warning 与 Error:

那么直接在 C struct 中使用 Objective-C 对象的话难道就没有内存上的问题吗?Objective-C 所占用的内存空间是何时被销毁的呢?

1
2
3
4
5
6
7
// ARC Object Pointers in C Structs!
typedef struct {
NSString *name;
NSNumber *price;
} MenuItem;
void orderFreeFood(NSString *name) { MenuItem item = { name, [NSNumber numberWithInt:0] }; // [item.name retain]; // [item.price retain]; orderMenuItem(item); // [item.name release];
// [item.price release]; }

如上述代码所示,编译器会在 C struct MenuItem 创建后 retain 其中的 ARC Objective-C 对象,并在 orderMenuItem(item); 语句之后,即其他使用 MenuItem item 的函数调用结束之后 release 掉相关 ARC Objective-C 对象。

思考,在动态内存管理时,ARC Objective-C 对象的内存管理会有什么不同呢?

Note: 动态内存管理(Dynamic Memory Management),指非 int a[100];MenuItem item = {name, [NSNumber numberWithInt:0]}; 这种在决定了使用哪一存储结构之后,就自动决定了作用域和存储时期的代码,这种代码必须服从预先制定的内存管理规则。

我们知道 C 语言中如果想要灵活的创建一个动态大小的数组需要自己手动开辟、管理、释放相关的内存,示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void foo() {
int max;
double *ptd;

puts("What is the maximum number of type double entries?");
scanf("%d", &max);
ptd = malloc(max * sizeof(double));
if (ptd == NULL) {
// memory allocation failed
...
}

// some logic
...

free(ptd);
}

那么 C struct 中 ARC Objective-C 的动态内存管理是否应该这么写呢?

1
2
3
4
5
6
// Structs with ARC Fields Need Care for Dynamic Memory Management
typedef struct {
NSString *name;
NSNumber *price;
} MenuItem;
void testMenuItems() { // Allocate an array of 10 menu items MenuItem *items = malloc(10 * sizeof(MenuItem)); orderMenuItems(items, 10); free(items); }

答案是否定的!

可以看到通过 malloc 开辟内存初始化带有 ARC Objective-C 的 C struct 中 ARC Objective-C 指针不会 zero-initialized

嘛~ 这个时候自然而然的会想起使用 calloc ^_^

Note: callocmalloc 均可完成内存分配,不同之处在于 calloc 会将分配过来的内存块中全部位置都置 0(然而要注意,在某些硬件系统中,浮点值 0 不是全部位为 0 来表示的)。

另一个问题就是 free(items); 语句执行之前,ARC Objective-C 并没有被清理。

Emmmmm… 官方推荐的写法是在 free(items); 之前将 items 内的所有 struct 中使用到的 ARC Objective-C 指针手动职位 nil

所以在动态内存管理时,上面的代码应该这么写:

1
2
3
4
5
6
// Structs with ARC Fields Need Care for Dynamic Memory Management
typedef struct {
NSString *name;
NSNumber *price;
} MenuItem;
void testMenuItems() { // Allocate an array of 10 menu items MenuItem *items = calloc(10, sizeof(MenuItem)); orderMenuItems(items, 10); // ARC Object Pointer Fields Must be Cleared Before Deallocation for (size_t i = 0; i < 10; ++i) { items[i].name = nil; items[i].price = nil; } free(items); }

瞬间有种日了狗的感觉有木有?

个人观点

嘛~ 在 C struct 中增加对 ARC Objective-C 对象字段的支持意味着我们今后 Objective-C 可以构建跨语言模式的交互操作

Note: 官方声明为了统一 ARC 与 manual retain/release (MRR) 下部分 function 按值传递、返回 struct 对 Objective-C++ ABI 做出了些许调整。

值得一提的是 Swift 并不支持这一特性(2333~ 谁说 Objective-C 的更新都是为了迎合 Swift 的变化)。

Xcode 10 新增诊断

Swift 与 Objective-C 互通性

我们都知道 Swift 与 Objective-C 具有一定程度的互通性,即 Swift 与 Objective-C 可以混编,在混编时 Xcode 生成一个头文件将 Swift 可以转化为 Objective-C 的部分接口暴露出来。

不过由于 Swift 与 Objective-C 的兼容性导致用 Swift 实现的部分代码无法转换给 Objective-C 使用。

近些年来 LLVM 一致都在尝试让这两种语言可以更好的互通(这也就是上文中提到 Objective-C 的更新都是为了迎合 Swift 说法的由来),本次 LLVM 支持将 Swift 中的闭包(Closures)导入 Objective-C

1
2
3
@objc protocol Executor {
func performOperation(handler: () -> Void)
}
1
2
3
4
#import “Executor-Swift.h”
@interface DispatchExecutor : NSObject<Executor>
- (void)performOperation:(void (^)(void))handler;
@end

Note: 在 Swift 中闭包默认都是非逃逸闭包(non-escaping closures),即闭包不应该在函数返回之后执行。

Objective-C 中与 Swift 闭包对应的就是 Block 了,但是 Objective-C 中的 Block 并没有诸如 Swift 中逃逸与否的限制,那么我们这样将 Swift 的非逃逸闭包转为 Objective-C 中无限制的 Block 岂不是会有问题?

别担心,转换过来的闭包(非逃逸)会有 Warnning 提示,而且我们说过一般这种情况下 Apple 的工程师都会在 LLVM 为 Objective-C 加一个宏来迎合 Swift…

1
2
// Warning for Missing Noescape Annotations for Method Overrides
#import “Executor-Swift.h”
@interface DispatchExecutor : NSObject<Executor>
- (void)performOperation:(NS_NOESCAPE void (^)(void))handler;
@end
@implementation DispatchExecutor
- (void)performOperation:(NS_NOESCAPE void (^)(void))handler {
}
// Programmer must ensure that handler is not called after performOperation returns @end

个人观点

如果 Swift 5 真的可以做到 ABI 稳定,那么 Swift 与 Objective-C 混编的 App 包大小也应该回归正常,相信很多公司的项目都会慢慢从 Objective-C 转向 Swift。在 Swift 中闭包(Closures)作为一等公民的存在奠定了 Swift 作为函数式语言的根基,本次 LLVM 提供了将 Swift 中的 Closures 与 Objective-C 中的 Block 互通转换的支持无疑是很有必要的。

使用 #pragma pack 打包 Struct 成员

Emmmmm… 老实说这一节的内容更底层,所以可能会比较晦涩,希望自己可以表述清楚吧。在 C 语言中 struct 有 内存布局(memory layout) 的概念,C 语言允许编译器为每个基本类型指定一些对齐方式,通常情况下是以类型的大小为标准对齐,但是它是特定于实现的。

嘛~ 还是举个例子吧,就拿 WWDC18 官方演示文稿中的吧:

1
2
3
4
5
struct Struct { 
uint8_t a, b;
// 2 byte padding
uint32_t c;
};

在上述例子中,编译器为了对齐内存布局不得不在 Struct 的第二字段与第三字段之间插入 2 个 byte。

1
2
3
|   1   |   2   |   3   |   4   |
| a | b | pad.......... |
| c(1) | c(2) | c(3) | c(4) |

这样本该占用 6 byte 的 struct 就占用了 8 byte,尽管其中只有 6 byte 的数据。

C 语言允许每个远程现代编译器实现 #pragma pack,它允许程序猿对填充进行控制来依从 ABI。

From C99 §6.7.2.1:

12 Each non-bit-field member of a structure or union object is aligned in an implementation- defined manner appropriate to its type.

13 Within a structure object, the non-bit-field members and the units in which bit-fields reside have addresses that increase in the order in which they are declared. A pointer to a structure object, suitably converted, points to its initial member (or if that member is a bit-field, then to the unit in which it resides), and vice versa. There may be unnamed padding within a structure object, but not at its beginning.

实际上关于 #pragma pack 的相关信息可以在 MSDN page 中找到。

LLVM 本次也加入了对 #pragma pack 的支持,使用方式如下:

1
2
3
4
5
6
#pragma pack (push, 1) 
struct PackedStruct {
uint8_t a, b;
uint32_t c;
};
#pragma pack (pop)

经过 #pragma pack 之后我们的 struct 对齐方式如下:

1
2
3
4
5
6
7
|   1   |
| a |
| b |
| c(1) |
| c(2) |
| c(3) |
| c(4) |

其实 #pragma pack (push, 1) 中的 1 就是对齐字节数,如果设置为 4 那么对齐方式又会变回到最初的状态:

1
2
3
|   1   |   2   |   3   |   4   |
| a | b | pad.......... |
| c(1) | c(2) | c(3) | c(4) |

值得一提的是,如果你使用了 #pragma pack (push, n) 之后忘记写 #pragma pack (pop) 的话,Xcode 10 会抛出 warning:

个人观点

嘛~ 当在网络层面传输 struct 时,通过 #pragma pack 自定义内存布局的对齐方式可以为用户节约更多流量。

Clang 静态分析

Xcode 一直都提供静态分析器(Static Analyzer),使用 Clang Static Analyzer 可以帮助我们找出边界情况以及难以发觉的 Bug。

点击 Product -> Analyze 或者使用快捷键 Shift+Command+B 就可以静态分析当前构建的项目了,当然也可以在项目的 Build Settings 中设置构建项目时自动执行静态分析(个人不推荐):

本地静态分析器有以下提升:

  • GCD 性能反模式
  • 自动释放变量超出自动释放池
  • 性能和可视化报告的提升

GCD 性能反模式

在之前某些迫不得已的情况下,我们可能需要使用 GCD 信号(dispatch_semaphore_t)来阻塞某些异步操作,并将阻塞后得到的最终的结果同步返回:

1
2
3
4
5
6
7
8
__block NSString *taskName = nil;
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
[self.connection.remoteObjectProxy requestCurrentTaskName:^(NSString *task) {
taskName = task;
dispatch_semaphore_signal(sema);
}];
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
return taskName;

嘛~ 这样写有什么问题呢?

上述代码存在通过使用异步线程执行任务来阻塞当前线程,而 Task 队列通常优先级较低,所以会导致优先级反转

那么 Xcode 10 之后我们应该怎么写呢?

1
2
3
4
5
6
7
__block NSString *taskName = nil;
id remoteObjectProxy = [self.connection synchronousRemoteObjectProxyWithErrorHandler:
^(NSError *error) { NSLog(@"Error: %@", error); }];
[remoteObjectProxy requestCurrentTaskName:^(NSString *task) {
taskName = task;
}];
return taskName;

如果可能的话,尽量使用 synchronous 版本的 API。或者,使用 asynchronous 方式的 API:

1
2
3
[self.connection.remoteObjectProxy requestCurrentTaskName:^(NSString *task) { 
completionHandler(task);
}];

可以在 build settings 下启用 GCD 性能反模式的静态分析检查:

自动释放变量超出自动释放池

众所周知,使用 __autoreleasing 修饰符修饰的变量会在自动释放池离开时被释放(release):

1
2
3
@autoreleasepool {
__autoreleasing NSError *err = [NSError errorWithDomain:@"domain" code:1 userInfo:nil];
}

这种看似不需要我们注意的点往往就是引起程序 Crash 的隐患:

1
2
3
4
5
6
7
8
9

- (void)findProblems:(NSArray *)arr error:(NSError **)error {
[arr enumerateObjectsUsingBlock:^(id value, NSUInteger idx, BOOL *stop) {
if ([value isEqualToString:@"problem"]) {
if (error) {
*error = [NSError errorWithDomain:@"domain" code:1 userInfo:nil];
}
}
}];
}

嘛~ 上述代码是会引起 Crash 的,你可以指出为什么吗?

Objective-C 在 ARC(Automatic Reference Counting)下会隐式使用 __autoreleasing 修饰 error,即 NSError *__autoreleasing*。而 -enumerateObjectsUsingBlock: 内部会在迭代 block 时使用 @autoreleasepool,在迭代逻辑中这样做有助于减少内存峰值。

于是 *error-enumerateObjectsUsingBlock: 中被提前 release 掉了,这样在随后读取 *error 时会出现 crash。

Xcode 10 中会给出具有针对性的静态分析警告:

正确的书写方式应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
- (void)findProblems:(NSArray *)arr error:(NSError *__autoreleasing*)error { 
__block NSError *localError;
[arr enumerateObjectsUsingBlock:^(id value, NSUInteger idx, BOOL *stop) {
if ([value isEqualToString:@"problem"]) {
localError = [NSError errorWithDomain:@"domain" code:1 userInfo:nil];
}
}];
if (error) {
*error = localError;
}
}

Note: 其实早在去年的 WWDC17 Session 411 What’s New in LLVM 中 Xcode 9 就引入了一个需要显示书写 __autoreleasing 的警告。

性能和可视化报告的提升

Xcode 10 中静态分析器可以以更高效的方式工作,在相同的分析时间内平均可以发现比之前增加 15% 的 Bug 数量。

不仅仅是性能的提升,Xcode 10 在报告的可视化方面也有所进步。在 Xcode 9 的静态分析器报告页面有着非必要且冗长的 Error Path:

Xcode 10 中则对其进行了优化:

个人观点

嘛~ 对于 Xcode 的静态分析,个人认为还是聊胜于无的。不过不建议每次构建项目时都去做静态分析,这样大大增加了构建项目的成本。

个人建议在开发流程中自测完毕提交代码给组内小伙伴们 Code Review 之前做静态分析,可以避免一些 issue 的出现,也可以发现一些代码隐患。有些问题是可以使用静态分析器在提交代码之前就暴露出来的,没必要消耗组内 Code Review 的宝贵人力资源。

还可以在 CI 设置每隔固定是时间间隔去跑一次静态分析,生成报表发到组内小群,根据问题指派责任人去检查是否需要修复(静态分析在比较复杂的代码结构下并不一定准确),这样定期维护从某种角度讲可以保持项目代码的健康状况。

增加安全性

Stack Protector

Apple 工程师在介绍 Stack Protector 之前很贴心的带领着在场的开发者们复习了一遍栈 Stack 相关的基础知识:

如上图,其实就是简单的讲了一下 Stack 的工作方式,如栈帧结构以及函数调用时栈的展开等。每一级的方法调用,都对应了一张相关的活动记录,也被称为活动帧。函数的调用栈是由一张张帧结构组成的,所以也称之为栈帧

我们可以看到,栈帧中包含着 Return Address,也就是当前活动记录执行结束后要返回的地址。

那么会有什么安全性问题呢?Apple 工程师接着介绍了通过不正当手段修改栈帧 Return Address 从而实现的一些权限提升。嘛~ 也就是历史悠久的 缓冲区溢出攻击

当使用 C 语言中一些不太安全的函数时(比如上图的 strcpy()),就有可能造成缓冲区溢出。

Note: strcpy() 函数将源字符串复制到指定缓冲区中。但是丫没有指定要复制字符的具体数目!如果源字符串碰巧来自用户输入,且没有专门限制其大小,则有可能会造成缓冲区溢出

针对缓冲区溢出攻击,LLVM 引入了一块额外的区域(下图绿色区域)来作为栈帧 Return Address 的护城河,叫做 Stack Canary,已默认启用:

Note: Canary 译为 “金丝雀”,Stack Canary 的命名源于早期煤矿工人下矿坑时会携带金丝雀来检测矿坑内一氧化碳是否达到危险值,从而判断是否需要逃生。

根据我们上面对缓冲区溢出攻击的原理分析,大家应该很容易发现 Stack Canary 的防御原理,即缓冲区溢出攻击旨在利用缓冲区溢出来篡改栈帧的 Return Address,加入了 Stack Canary 之后想要篡改 Return Address 就必然会经过 Stack Canary,在当前栈帧执行结束后要使用 Return Address 回溯时先检测 Stack Canary 是否有变动,如果有就调用 abort() 强制退出。

嘛~ 是不是和矿坑中的金丝雀很像呢?

不过 Stack Canary 存在一些局限性:

  • 可以在缓冲区溢出攻击时计算 Canary 的区域并伪装 Canary 区域的值,使得 Return Address 被篡改的同时 Canary 区域内容无变化,绕过检测。
  • 再粗暴一点的话,可以通过双重 strcpy() 覆写任意不受内存保护的数据,通过构建合适的溢出字符串,可以达到修改 ELF(Executable and Linking Format)映射的 GOT(Global Offset Table),只要修改了 GOT 中的 _exit() 入口,即便 Canary 检测到了篡改,函数返回前调用 abort() 退出还是会走已经被篡改了的 _exit()

Stack Checking

Stack Protector 是 Xcode 既有的、且默认开启的特性,而 Stack Checking 是 Xcode 10 引入的新特性,主要针对的是 Stack Clash 问题。

Stack Clash 问题的产生源于 Stack 和 Heap,Stack 是从上向下增长的,Heap 则是自下而上增长的,两者相向扩展而内存又是有限的。

Stack Checking 的工作原理是在 Stack 区域规定合理的分界线(上图红线),在可变长度缓冲区的函数内部对将要分配的缓冲区大小做校验,如果缓冲区超出分界线则调用 abort() 强制退出。

Note: LLVM 团队在本次 WWDC18 加入 Stack Checking,大概率是因为去年年中 Qualys 公布的一份 关于 Stack Clash 的报告

新指令集扩展

Emmmmm… 这一节的内容是针对于 iMac Pro 以及 iPhone X 使用的 指令集架构(ISA - Instruction set architecture) 所做的扩展。坦白说,我对这块并不是很感兴趣,也没有深入的研究,所以就不献丑了…

总结

本文梳理了 WWDC18 Session 409 What’s New in LLVM 中的内容,并分享了我个人对这些内容的拙见,希望能够对各位因为种种原因还没有来得及看 WWDC18 Session 409 的同学有所帮助。

文章写得比较用心(是我个人的原创文章,转载请注明 https://lision.me/),如果发现错误会优先在我的个人博客中更新。如果有任何问题欢迎在我的微博 @Lision 联系我~

希望我的文章可以为你带来价值~

深入理解 iOS Rendering Process

前言

iOS 最早名为 iPhone OS,是 Apple 公司专门为其硬件设备开发的操作系统,最初于 2007 年随第一代 iPhone 推出,后扩展为支持 Apple 公司旗下的其他硬件设备,如 iPod、iPad 等。

作为一名 iOS Developer,相信大多数人都有写出过造成 iOS 设备卡顿的代码经历,相应的也有过想方设法优化卡顿代码的经验。

本文将从 OpenGL 的角度结合 Apple 官方给出的部分资料,介绍 iOS Rendering Process 的概念及其整个底层渲染管道的各个流程。

相信在理解了 iOS Rendering Process 的底层各个阶段之后,我们可以在平日的开发工作之中写出性能更高的代码,在解决帧率不足的显示卡顿问题时也可以多一些思路~

索引

  • iOS Rendering Process 概念
  • iOS Rendering 技术框架
  • OpenGL 主要渲染步骤
  • OpenGL Render Pipeline
  • Core Animation Pipeline
  • Commit Transaction
  • Animation
  • 全文总结
  • 扩展阅读

iOS Rendering Process 概念

iOS Rendering Process 译为 iOS 渲染流程,本文特指 iOS 设备从设置将要显示的图元数据到最终在设备屏幕成像的整个过程。

在开始剖析 iOS Rendering Process 之前,我们需要对 iOS 的渲染概念有一个基本的认知:

基于平铺的渲染

iOS 设备的屏幕分为 N * N 像素的图块,每个图块都适合于 SoC 缓存,几何体在图块内被大量拆分,只有在所有几何体全部提交之后才可以进行光栅化(Rasterization)。

Note: 这里的光栅化指将屏幕上面被大量拆分出来的几何体渲染为像素点的过程。

iOS Rendering 技术框架

事实上 iOS 渲染相关的层级划分大概如下:

UIKit

嘛~ 作为一名 iOS Developer 来说,应该对 UIKit 都不陌生,我们日常开发中使用的用户交互组件都来自于 UIKit Framework,我们通过设置 UIKit 组件的 Layout 以及 BackgroundColor 等属性来完成日常的界面绘画工作。

其实 UIKit Framework 自身并不具备在屏幕成像的能力,它主要负责对用户操作事件的响应,事件响应的传递大体是经过逐层的视图树遍历实现的。

那么我们日常写的 UIKit 组件为什么可以呈现在 iOS 设备的屏幕上呢?

Core Animation

Core Animation 其实是一个令人误解的命名。你可能认为它只是用来做动画的,但实际上它是从一个叫做 Layer Kit 这么一个不怎么和动画有关的名字演变而来的,所以做动画仅仅是 Core Animation 特性的冰山一角。

Core Animation 本质上可以理解为是一个复合引擎,旨在尽可能快的组合屏幕上不同的显示内容。这些显示内容被分解成独立的图层,即 CALayer,CALayer 才是你所能在屏幕上看见的一切的基础。

其实很多同学都应该知道 CALayer,UIKit 中需要在屏幕呈现的组件内部都有一个对应的 CALayer,也就是所谓的 Backing Layer。正是因为一一对应,所以 CALayer 也是树形结构的,我们称之为图层树

视图的职责就是创建并管理这个图层,以确保当子视图在层级关系中添加或者被移除的时候,他们关联的图层同样对应在层级关系树当中有相同的操作

但是为什么 iOS 要基于 UIView 和 CALayer 提供两个平行的层级关系呢?为什么不用一个简单的层级关系来处理所有事情呢?

原因在于要做职责分离,这样也能避免很多重复代码。在 iOS 和 Mac OS X 两个平台上,事件和用户交互有很多地方的不同,基于多点触控的用户界面和基于鼠标键盘的交互有着本质的区别,这就是为什么 iOS 有 UIKit 和 UIView,而 Mac OS X 有 AppKit 和 NSView 的原因。他们功能上很相似,但是在实现上有着显著的区别。

Note: 实际上,这里并不是两个层级关系,而是四个,每一个都扮演不同的角色,除了视图树图层树之外,还存在呈现树渲染树

OpenGL ES & Core Graphics

OpenGL ES

OpenGL ES 简称 GLES,即 OpenGL for Embedded Systems,是 OpenGL 的子集,通常面向图形硬件加速处理单元(GPU)渲染 2D 和 3D 计算机图形,例如视频游戏使用的计算机图形。

OpenGL ES 专为智能手机,平板电脑,视频游戏机和 PDA 等嵌入式系统而设计 。OpenGL ES 是“历史上应用最广泛的 3D 图形 API”。

Core Graphics

Core Graphics Framework 基于 Quartz 高级绘图引擎。它提供了具有无与伦比的输出保真度的低级别轻量级 2D 渲染。您可以使用此框架来处理基于路径的绘图,转换,颜色管理,离屏渲染,图案,渐变和阴影,图像数据管理,图像创建和图像遮罩以及 PDF 文档创建,显示和分析。

Note: 在 Mac OS X 中,Core Graphics 还包括用于处理显示硬件,低级用户输入事件和窗口系统的服务。

Graphics Hardware

Graphics Hardware 译为图形硬件,iOS 设备中也有自己的图形硬件设备,也就是我们经常提及的 GPU。

图形处理单元(GPU)是一种专用电子电路,旨在快速操作和改变存储器,以加速在用于输出到显示设备的帧缓冲器中创建图像。GPU 被用于嵌入式系统,手机,个人电脑,工作站和游戏控制台。现代 GPU 在处理计算机图形和图像方面非常高效,并且 GPU 的高度并行结构使其在大块数据并行处理的算法中比通用 CPU 更有效。

OpenGL 主要渲染步骤

OpenGL 全称 Open Graphics Library,译为开放图形库,是用于渲染 2D 和 3D 矢量图形的跨语言,跨平台应用程序编程接口(API)。OpenGL 可以直接访问 GPU,以实现硬件加速渲染。

一个用来渲染图像的 OpenGL 程序主要可以大致分为以下几个步骤:

  • 设置图元数据
  • 着色器-shader 计算图元数据(位置·颜色·其他)
  • 光栅化-rasterization 渲染为像素
  • fragment shader,决定最终成像
  • 其他操作(显示·隐藏·融合)

Note: 其实还有一些非必要的步骤,与本文主题不相关,这里点到为止。

我们日常开发时使用 UIKit 布局视图控件,设置透明度等等都属于设置图元数据这步,这也是我们日常开发中可以影响 OpenGL 渲染的主要步骤。

OpenGL Render Pipeline

如果有同学看过 WWDC 的一些演讲稿或者接触过一些 OpenGL 知识,应该对 Render Pipeline 这个专业术语并不陌生。

不过 Render Pipeline 实在是一个初次见面不太容易理解的词,它译为渲染管道,也有译为渲染管线的…

其实 Render Pipeline 指的是从应用程序数据转换到最终渲染的图像之间的一系列数据处理过程

好比我们上文中提到的 OpenGL 主要渲染步骤一样,我们开发应用程序时在设置图元数据这步为视图控件的设定布局,背景颜色,透明度以及阴影等等数据。

下面以 OpenGL 4.5 的 Render Pipeline 为例介绍一下:

这些图元数据流入 OpenGL 中,传入顶点着色器(vetex shader),然后顶点着色器对其进行着色器内部的处理后流出。之后可能进入细分着色阶段(tessellation shading stage),其中又有可能分为细分控制着色器和细分赋值着色器两部分处理,还可能会进入几何着色阶段(geometry shading stage),数据从中传递。最后都会走片元着色阶段(fragment shading stage)

Note: 图元数据是以 copy 的形式流入 shader 的,shader 一般会以特殊的类似全局变量的形式接收数据。

OpenGL 在最终成像之前还会经历一个阶段名为计算着色阶段(compute shaing stage),这个阶段 OpenGL 会计算最终要在屏幕中成像的像素位置以及颜色,如果在之前提交代码时用到了 CALayer 会引起 blending 的显示效果(例如 Shadow)或者视图颜色或内容图片的 alpha 通道开启,都将会加大这个阶段 OpenGL 的工作量。

Core Animation Pipeline

上文说到了 iOS 设备之所以可以成像不是因为 UIKit 而是因为 LayerKit,即 Core Animation。

Core Animation 图层,即 CALayer 中包含一个属性 contents,我们可以通过给这个属性赋值来控制 CALayer 成像的内容。这个属性的类型定义为 id,在程序编译时不论我们给 contents 赋予任何类型的值,都是可以编译通过的。但实践中,如果 contents 赋值类型不是 CGImage,那么你将会得到一个空白图层

Note: 造成 contents 属性的奇怪表现的原因是 Mac OS X 的历史包袱,它之所以被定义为 id 类型是因为在 Mac OS X 中这个属性对 CGImage 和 NSImage 类型的值都起作用。但是在 iOS 中,如果你赋予一个 UIImage 属性的值,仅仅会得到一个空白图层。

说完 Core Animation 的 contents 属性,下面介绍一下 iOS 中 Core Animation Pipeline:

  • 在 Application 中布局 UIKit 视图控件间接的关联 Core Animation 图层
  • Core Animation 图层相关的数据提交到 iOS Render Server,即 OpenGL ES & Core Graphics
  • Render Server 将与 GPU 通信把数据经过处理之后传递给 GPU
  • GPU 调用 iOS 当前设备渲染相关的图形设备 Display

Note: 由于 iOS 设备目前的显示屏最大支持 60 FPS 的刷新率,所以每个处理间隔为 16.67 ms。

可以看到从 Commit Transaction 之后我们的图元数据就将会在下一次 RunLoop 时被 Application 发送给底层的 Render Server,底层 Render Server 直接面向 GPU 经过一系列的数据处理将处理完毕的数据传递给 GPU,然后 GPU 负责渲染工作,根据当前 iOS 设备的屏幕计算图像像素位置以及像素 alpha 通道混色计算等等最终在当前 iOS 设备的显示屏中呈现图像。

嘛~ 由于 Core Animation Pipeline 中 Render Server 包含 OpenGL ES & Core Graphics,其中 OpenGL ES 的渲染可以参考上文 OpenGL Render Pipeline 理解。

Commit Transaction

Core Animation Pipeline 的整个管线中 iOS 常规开发一般可以影响到的范围也就仅仅是在 Application 中布局 UIKit 视图控件间接的关联 Core Animation 图层这一级,即 Commit Transaction 之前的一些操作

那么在 Commit Transaction 之前我们一般要做的事情有哪些?

  • Layout,构建视图
  • Display,绘制视图
  • Prepare,额外的 Core Animation 工作
  • Commit,打包图层并将它们发送到 Render Server

Layout

在 Layout 阶段我们能做的是把 constraint 写的尽量高效,iOS 的 Layout Constraint 类似于 Android 的 Relative Layout。

Note: Emmmmm… 据观察 iOS 的 Layout Constraint 在书写时应该尽量少的依赖于视图树中同层级的兄弟视图节点,它会拖慢整个视图树的 Layout 计算过程。

这个阶段的 Layout 计算工作是在 CPU 完成的,包括 layoutSubviews 方法的重载,addSubview: 方法填充子视图等

Display

其实这里的 Display 仅仅是我们设置 iOS 设备要最终成像的图元数据而已,重载视图 drawRect: 方法可以自定义 UIView 的显示,其原理是在 drawRect: 方法内部绘制 bitmap。

Note: 重载 drawRect: 方法绘制 bitmap 过程使用 CPU 和 内存

所以重载 drawRect: 使用不当会造成 CPU 负载过重,App 内存飙升等问题。

Prepare

这个步骤属于附加步骤,一般处理图像的解码 & 转换等操作。

Commit

Commit 步骤指打包图层并将它们发送到 Render Server。

Note: Commit 操作会递归执行,由于图层和视图一样是以树形结构存在的,当图层树过于复杂时 Commit 操作的开销也会非常大。

CATransaction

CATransaction 是 Core Animation 中用于将多个图层树操作分配到渲染树的原子更新中的机制,对图层树的每个修改都必须是事务的一部分。

CATransaction 类没有属性或者实例方法,并且也不能用 +alloc-init 方法创建它,我们只能用类方法 +begin+commit 分别来入栈或者出栈。

事实上任何可动画化的图层属性都会被添加到栈顶的事务,你可以通过 +setAnimationDuration: 方法设置当前事务的动画时间,或者通过 +animationDuration 方法来获取时长值(默认 0.25 秒)。

Core Animation 在每个 RunLoop 周期中自动开始一次新的事务,即使你不显式地使用 [CATransaction begin] 开始一次事务,在一个特定 RunLoop 循环中的任何属性的变化都会被收集起来,然后做一次 0.25 秒的动画(CALayer 隐式动画)。

Note: CATransaction 支持嵌套

Animation

对于 App 用户交互体验提升最明显的工作莫过于使用动画了,那么 iOS 是如何处理动画的渲染过程的呢?

日常开发中如果不是特别复杂的动画我们一般会使用 UIView Animation 实现,iOS 将 UIView Animation 的处理过程分为以下三个阶段:

  • 调用 animateWithDuration:animations: 方法
  • 在 Animation Block 中进行 Layout,Display,Prepare,Commit
  • Render Server 根据 Animation 逐帧渲染

Note: 原理是 animateWithDuration:animations: 内部使用了 CATransaction 来将整个 Animation Block 中的代码作为原子操作 commit 给了 RunLoop。

基于 CATransaction 实现链式动画

事实上大多数的动画交互都是有动画执行顺序的,尽管 UIView Animation 很强大,但是在写一些顺序动画时使用 UIView Animation 只能在 + (void)animateWithDuration:delay:options:animations:completion: 方法的 completion block 中层级嵌套,写成一坨一坨 block 堆砌而成的代码,实在是难以阅读更别提后期维护了。

在得知 UIView Animation 使用了 CATransaction 时,我们不禁会想到这个 completion block 是不是也是基于 CATransaction 实现的呢?

Bingo!CATransaction 中有 +completionBlock 以及 +setCompletionBlock: 方法可以对应于 UIView Animation 的 completion block 的书写。

Note: 我的一个开源库 LSAnimator - 可多链式动画库动画顺序链接时也用到了 CATransaction

全文总结

结合上下文不难梳理出一个 iOS 最基本的完整渲染经过(Rendering pass)

性能检测思路

基于整篇文章的内容归纳一下我们在日常的开发工作中遇到性能问题时检测问题代码的思路:

问题 建议 检测工具
目标帧率 60 FPS Core Animation instrument
CPU or GPU 降低使用率节约能耗 Time Profiler instrument
不必要的 CPU 渲染 GPU 渲染更理想,但要清楚 CPU 渲染在何时有意义 Time Profiler instrument
过多的 offscreen passes 越少越好 Core Animation instrument
过多的 blending 越少越好 Core Animation instrument
奇怪的图片格式或大小 避免实时转换或调整大小 Core Animation instrument
开销昂贵的视图或特效 理解当前方案的开销成本 Xcode View Debugger
想象不到的层次结构 了解实际的视图层次结构 Xcode View Debugger

文章写得比较用心(是我个人的原创文章,转载请注明 https://lision.me/),如果发现错误会优先在我的个人博客中更新。如果有任何问题欢迎在我的微博 @Lision 联系我~

希望我的文章可以为你带来价值~

扩展阅读