Objc中国 #3 Views
1. 绘制像素到屏幕上
Display 的上一层便是图形处理单元 GPU,GPU 是一个专门为图形高并发计算而量身定做的处理单元。这也是为什么它能同时更新所有的像素,并呈现到显示器上。它并发的本性让它能高效的将不同纹理合成起来。我们将有一小块内容来更详细的讨论图形合成。关键的是,GPU 是非常专业的,因此在某些工作上非常高效。比如,GPU 非常快,并且比 CPU 使用更少的电来完成工作。通常 CPU 都有一个普遍的目的,它可以做很多不同的事情,但是合成图像在 CPU 上却显得比较慢。
GPU Driver 是直接和 GPU 交流的代码块。不同的GPU是不同的性能怪兽,但是驱动使他们在下一个层级上显示的更为统一,典型的下一层级有 OpenGL/OpenGL ES.
OpenGL(Open Graphics Library) 是一个提供了 2D 和 3D 图形渲染的 API。GPU 是一块非常特殊的硬件,OpenGL 和 GPU 密切的工作以提高GPU的能力,并实现硬件加速渲染。对大多数人来说,OpenGL 看起来非常底层,但是当它在1992年第一次发布的时候(20多年前的事了)是第一个和图形硬件(GPU)交流的标准化方式,这是一个重大的飞跃,程序员不再需要为每个GPU重写他们的应用了。
OpenGL 之上扩展出很多东西。在 iOS 上,几乎所有的东西都是通过 Core Animation 绘制出来,然而在 OS X 上,绕过 Core Animation 直接使用 Core Graphics 绘制的情况并不少见。对于一些专门的应用,尤其是游戏,程序可能直接和 OpenGL/OpenGL ES 交流。事情变得使人更加困惑,因为 Core Animation 使用 Core Graphics 来做一些渲染。像 AVFoundation,Core Image 框架,和其他一些混合的入口。
要记住一件事情,GPU 是一个非常强大的图形硬件,并且在显示像素方面起着核心作用。它连接到 CPU。从硬件上讲两者之间存在某种类型的总线,并且有像 OpenGL,Core Animation 和 Core Graphics 这样的框架来在 GPU 和 CPU 之间精心安排数据的传输。为了将像素显示到屏幕上,一些处理将在 CPU 上进行。然后数据将会传送到 GPU,这也需要做一些相应的操作,最终像素显示到屏幕上。
JPEG
每个人都知道 JPEG。它是相机的产物。它代表着照片如何存储在电脑上。甚至你妈妈都听说过 JPEG。
一个很好的理由,很多人都认为 JPEG 文件仅是另一种像素数据的格式,就像我们刚刚谈到的 RGB 像素布局那样。这样理解离真相真是差十万八千里了。
将 JPEG 数据转换成像素数据是一个非常复杂的过程,你通过一个周末的计划都不能完成,甚至是一个非常漫长的周末(原文的意思好像就是为了表达这个过程非常复杂,不过老外的比喻总让人拎不清)。对于每一个二维颜色,JPEG 使用一种基于离散余弦变换(简称 DCT 变换)的算法,将空间信息转变到频域.这个信息然后被量子化,排好序,并且用一种哈夫曼编码的变种来压缩。很多时候,首先数据会被从 RGB 转换到二维 YCbCr,当解码 JPEG 的时候,这一切都将变得可逆。
这也是为什么当你通过 JPEG 文件创建一个 UIImage 并且绘制到屏幕上时,将会有一个延时,因为 CPU 这时候忙于解压这个 JPEG。如果你需要为每一个 tableviewcell 解压 JPEG,那么你的滚动当然不会平滑(原来 tableviewcell 里面最要不要用 JPEG 的图片)。
那究竟为什么我们还要用 JPEG 呢?答案就是 JPEG 可以非常非常好的压缩图片。一个通过 iPhone5 拍摄的,未经压缩的图片占用接近 24M。但是通过默认压缩设置,你的照片通常只会在 2-3M 左右。JPEG 压缩这么好是因为它是失真的,它去除了人眼很难察觉的信息,并且这样做可以超出像 gzip 这样压缩算法的限制。但这仅仅在图片上有效的,因为 JPEG 依赖于图片上有很多人类不能察觉出的数据。如果你从一个基本显示文本的网页上截取一张图,JPEG 将不会这么高效。压缩效率将会变得低下,你甚至能看出来图片已经压缩变形了。
PNG
PNG读作”ping”。和 JPEG 相反,它的压缩对格式是无损的。当你将一张图片保存为 PNG,并且打开它(或解压),所有的像素数据会和最初一模一样,因为这个限制,PNG 不能像 JPEG 一样压缩图片,但是对于像程序中的原图(如buttons,icons),它工作的非常好。更重要的是,解码 PNG 数据比解码 JPEG 简单的多。
在现实世界中,事情从来没有那么简单,目前存在了大量不同的 PNG 格式。可以通过维基百科查看详情。但是简言之,PNG 支持压缩带或不带 alpha 通道的颜色像素(RGB),这也是为什么它在程序原图中表现良好的另一个原因。
可变尺寸的图像
类似的,你可以使用可变尺寸的图像来降低绘图系统的压力。让我们假设你需要一个 300×50 点的按钮插图,这将是 600×100=60k 像素或者 60kx4=240kB 内存大小需要上传到 GPU,并且占用 VRAM。如果我们使用所谓的可变尺寸的图像,我们只需要一个 54×12 点的图像,这将占用低于 2.6k 的像素或者 10kB 的内存,这样就变得更快了。
Core Animation 可以通过 CALayer 的 contentsCenter 属性来改变图像,大多数情况下,你可能更倾向于使用,-[UIImage resizableImageWithCapInsets:resizingMode:]。
同时注意,在第一次渲染这个按钮之前,我们并不需要从文件系统读取一个 60k 像素的 PNG 并解码,解码一个小的 PNG 将会更快。通过这种方式,你的程序在每一步的调用中都将做更少的工作,并且你的视图将会加载的更快。
2. 理解 Scroll Views
现在,回想一下,每个视图都有一个 bounds 和 frame。当布局一个界面时,我们需要处理视图的 frame。这允许我们放置并设置视图的大小。视图的 frame 和 bounds 的大小总是一样的,但是他们的 origin 有可能不同。弄懂这两个工作原理是理解 UIScrollView 的关键。
在光栅化步骤中,视图并不关心即将发生的组合步骤。也就是说,它并不关心自己的 frame (这是用来放置视图的图像)或自己在视图层级中的位置(这是决定组合的顺序)。这时视图只关心一件事就是绘制它自己的 content。这个绘制发生在每个视图的 drawRect: 方法中。
在 drawRect: 方法被调用前,会为视图创建一个空白的图片来绘制 content。这个图片的坐标系统是视图的 bounds。几乎每个视图 bounds 的 origin 都是 {0,0}。因此,当在光栅化图片左上角绘制一些东西的时候,你都会在 bounds 的 origin {x:0, y:0} 处绘制。在一个图片右下角的地方绘制东西的时候,你都会绘制在 {x:width, y:height} 处。如果你的绘制超出了视图的 bounds,那么超出的部分就不属于光栅化图片的部分了,并且会被丢弃。
在组合的步骤中,每个视图将自己光栅化图片组合到自己父视图的光栅化图片上面。视图的 frame 决定了自己在父视图中绘制的位置,frame 的 origin 表明了视图光栅化图片左上角相对父视图光栅化图片左上角的偏移量。所以,一个 origin 为 {x:20, y:15} 的 frame 所绘制的图片左边距其父视图 20 点,上边距父视图 15 点。因为视图的 frame 和 bounds 矩形的大小总是一样的,所以光栅化图片组合的时候是像素对齐的。这确保了光栅化图片不会被拉伸或缩小。
软键盘遮挡问题:
如何在自己的代码中使用 content inset?当键盘在屏幕上时,有一个很好的用途:你想要设置一个紧贴屏幕的用户界面。当键盘出现在屏幕上时,你损失了几百个像素的空间,键盘下面的东西全都被挡住了。
现在,scroll view 的 bounds 并没有改变,content size 也并没有改变(也不需要改变)。但是用户不能滚动 scroll view。考虑一下之前一个公式:content offset 的最大值是 content size 和 bounds 的差。如果他们相等,现在 content offset 的最大值是 {x:0, y:0}.
现在开始出绝招,将界面放入一个 scroll view。scroll view 的 content size 仍然和 scroll view 的 bounds 一样大。当键盘出现在屏幕上时,你设置 content inset 的底部等于键盘的高度。
这允许在 content offset 的最大值下显示滚动区域外的区域。可视区域的顶部在 scroll view bounds 的外面,因此被截取了(虽然它在屏幕之外了,但这并没有什么)。
3. 自定义控件
视图层次概览
如果你观察一下 UIView 的子类,可以发现 3 个基类: reponders (响应者),views (视图)和 controls (控件)。我们快速重温一下它们之间发生了什么。
UIResponder
UIResponder 是 UIView 的父类。responder 能够处理触摸、手势、远程控制等事件。之所以它是一个单独的类而没有合并到 UIView 中,是因为 UIResponder 有更多的子类,最明显的就是 UIApplication 和 UIViewController。通过重写 UIResponder 的方法,可以决定一个类是否可以成为第一响应者 (first responder),即当前输入焦点元素。
当 touches (触摸) 或 motion (指一系列运动传感器) 等交互行为发生时,它们被发送给第一响应者 (通常是一个视图)。如果第一响应者没有处理,则该行为沿着响应链到达视图控制器,如果行为仍然没有被处理,则继续传递给应用。如果想监测晃动手势,可以根据需要在这3层中的任意位置处理。
UIResponder 还允许自定义输入方法,从 inputAccessoryView 向键盘添加辅助视图到使用 inputView 提供一个完全自定义的键盘。
UIView
UIView 子类处理所有跟内容绘制有关的事情以及触摸时间。只要写过 "Hello, World" 应用的人都知道视图,但我们重申一些技巧点:
一个普遍错误的概念:视图的区域是由它的 frame 定义的。实际上 frame 是一个派生属性,是由 center 和 bounds 合成而来。不使用 Auto Layout 时,大多数人使用 frame 来改变视图的位置和大小。小心些,官方文档特别详细说明了一个注意事项:
如果 transform 属性不是 identity transform 的话,那么这个属性的值是未定义的,因此应该将其忽略
另一个允许向视图添加交互的方法是使用手势识别。注意它们对 responders 并不起作用,而只对视图及其子类奏效。
UIControl
UIControl 建立在视图上,增加了更多的交互支持。最重要的是,它增加了 target / action 模式。看一下具体的子类,我们可以看一下按钮,日期选择器 (Date pickers),文本框等等。创建交互控件时,你通常想要子类化一个 UIControl。一些常见的像 bar buttons (虽然也支持 target / action) 和 text view (这里需要你使用代理来获得通知) 的类其实并不是 UIControl。
使用 Block
另一个选择是使用 block。再一次用饼状图举例,代码看起来大概是这样:
@interface PieChart : UIControl
@property (nonatomic,copy) void(^selectionHandler)(PieChartSection* selectedSection);
@end
在选取行为的代码中,你只需要执行它。在此之前检查一下block是否被赋值非常重要,因为执行一个未被赋值的 block 会使程序崩溃。
if (self.selectionHandler != NULL) {
self.selectionHandler(self.selectedSection);
}
这种方法的好处是可以把相关的代码整合在视图控制器中:
- (void)setupPieChart
{
self.pieChart.selectionHandler = ^(PieChartSection* section) {
// 处理区块
}
}
就像代理,每个动作通常只有一个 block。另一个重要的限制是不要形成引用循环。如果你的视图控制器持有饼状图的强引用,饼状图持有 block,block 又持有视图控制器,就形成了一个引用循环。只要在 block 中引用 self 就会造成这个错误。所以通常代码会写成这个样子:
__weak id weakSelf = self;
self.pieChart.selectionHandler = ^(PieChartSection* section) {
MyViewController* strongSelf = weakSelf;
[strongSelf handleSectionChange:section];
}
一旦 block 中的代码要失去控制 (比如 block 中要处理的事情太多,导致 block 中的代码过多),你还应该将它们抽离成独立的方法,这种情况的话可能用代理会更好一些。