像素在屏幕显示那点事

像素是如何显示在屏幕上的呢?

当然这里有很多种方式将某些东西显示到显示器上面,并且它们可能涉及到许多不同的framework框架以及功能方法的组合。但是在这里我们将介绍一些屏幕后面发生的事情, 而且我们希望当你需要去决定如何去调试和解决性能问题的的时候,这将会帮助你更好的了解哪些API最有效果,对你最有帮助。在这里我们仅用iOS作为代表,当然讨论的大多数内容同样适用于 OS X

Graphics Stack(图形堆栈)

当像素要进入到屏幕的时候,往往伴随着很多事情的发生。但是一旦它们出现在屏幕上面,每一个像素由三种颜色组成:红色、绿色、蓝色(也就是我们所说的RGB)。三个独立的、特定强度的颜色单元点亮使用特定的颜色的单个像素。在您的iPhone 5 上面,一块液晶显示屏展现出来 1,136 x 640 = 727,040像素,因此有2,181,120个颜色单元格。在带有Retina的显示器的15英寸的MacBook Pro上面,这个数字刚好超过了1550万。这个就需要整个图形堆栈一起工作确保每一个颜色单元格都以正确的亮度点亮,当你全屏滚动的时候,所有的这些上百万的单元格需要做到每秒60的更新,这是一个多么大的工作量。

The Software Components(软件组件)

在一个简化的视图当中,软件堆栈可能看起来是这样的:

软件堆栈

显示器的上一级就是GPU一个图像处理单元GPU是高度并发的处理单元,其被特别的定制是用于图形的并发计算。它的工作就是如何更新所有的像素,并且将结果推送到显示器上面。它的平行的特点还允许它非常有效地讲纹理合成到彼此上。我们将稍后讨论合成的更多细节。关键点是GPU是非常专业的,因此在某些类型的工作中是高效的,它比CPU更加快速,使用更少的功率工作,普通的CPU都有一个非常一般的目的,它也可以做很多的不同的事情,但是在合成上面,会比GPU逊色一点。

GPU驱动程序是直接与GPU交互的代码片段。不同的GPU是不同的猛兽,驱动程序使它们表现起来更加一致在面对下一层的时候(通常是OpenGL / OpenGL ES

OpenGL (Open Graphics Library)是一种用于渲染2D和3D图形的API。由于GPU是一个非常专业的软件,OpenGLGPU非常密切的协作以促进GPU的功能并且实现硬件的加速渲染。对于许多人来说,OpenGL可能看起来非常低级,但是当它在1992年(20多年前)首次发布的时候,它是与图形硬件(GPU)交互的第一个主要的标准化方式,因此这是一个重大的跨越,程序员们不再需要为每一个GPU重写他们的应用程序。

上面对于OpenGL的介绍到此为此,我们言归正传。在iOS上几乎所有的内容都通过核心动画,但是在 OS X上,Core Graphics绕过核心动画并不罕见。对于一些特殊的应用,特别是游戏,应用程序需要直接和OpenGL /OpenGL ES打交道。事情变得更加混乱。因为Core Animation 使用 Core Graphics进行渲染,框架例如AVFoundation,Core Image和其他的可以访问的混合。

要记住的一件事情是:虽然GPU是一个非常强大的图形硬件,它在显示您的像素中发挥核心作用,它连接到CPU。在硬件方面,两者之间有一条总线:并且有一些框架例如OpenGLCore AnimationCore Graphics协调GPU和CPU之间的数据传输。为了让你的像素显示在屏幕上,一些处理将会在CPU上完成,然后在将数据传输到GPU,反过来,也会进行处理,最后像素都会显示在屏幕上。

每一个旅途的每一部分都有它的挑战,而且还有一些折中。

硬件层(The HardWare Players)

硬件层

首要挑战:一个非常简化的视图应该是GPU具有针对每个帧(每秒60次)合成在一起的纹理(位图)。每一个纹理都占用VRAM(视频RAM),因此GPU可以保持多少纹理都有一个限制。GPU在合成时是高效的,但是在某些合成任务比其他的合成任务更加复杂,并且GPU在16.7ms(1/60秒)内可以做多少工作是有限制的。

下一个挑战就是数据传输到GPU。为了GPU访问数据,需要将其从RAM移动到VRAM。这被称为上传到GPU。这可能看起来是微不足道的,但是对于大纹理来说,这可能是非常耗时的。

最后,CPU运行您的程序。它可能会告诉你CPU从你的bundle加载PNG并解压缩。所有都发生在CPU上。当你想显示那个解压缩的图像时,它不知道何故竟然传到了GPU。

像显示文本一样平凡的事情,对于CPU来说是一个非常复杂的任务,它有助于Core TextCore Graphics框架之间的紧密集成,从文本中生成位图。一旦准备完成,它将作为纹理上传到GPU,准备显示。当你滚动屏幕或者以其他的方式移动文本时,可以重复使用相同的纹理,并且CPU将简单地告诉GPU新的位置是什么,因此GPU可以重用现有的纹理,CPU不必重新渲染文本,并且位图不必重新上传。

上述说明了图形堆栈所涉及的一些复杂性。有了上面的概述,我们将深入了解一些涉及的技术。

合成(Compositing)

在图形的世界里面,合成是专业术语用语描述不同位图放置在一起去创造最后的你在屏幕上能够看见的图像。是的,显而易见,我们经常容易去忽悠里面所包含的复杂性以及计算。

让我们忽略一些更深奥的情况,仅仅假设所有在屏幕上的事物都是纹理。纹理是RGBA值的矩形区域。例如,对于每一个像素我们都要红、绿、蓝的颜色色值和一个透明值。在Core Animation 的世界里这些的基础就是CALayer

在略微简化的设置中吗,每一layer都是一个纹理,所有的这些纹理以某种方式堆砌在彼此的顶部。对于屏幕上的像素,GPU需要计算出如何去混合这些纹理以获得该像素的RGB值。这就是合成。

如果我们所有的一个单一的纹理是屏幕的大小,并且与屏幕的像素对其,屏幕上的每一个像素对应于该纹理的单个像素。纹理的像素就是最终屏幕的像素。

如果我们有第二个纹理放置在第一个纹理的上方,那么GPU将会混合这两个纹理到第一个上面。这是不同的混合的方式。但是如果我们假设两个纹理是像素对其的,那么我们就使用一般的混合的方式,使用每个像素计算得到的颜色的公式:

R = S + D * (1 - Sa)

结果颜色是源颜色(顶部纹理)加上目标颜色(低层纹理)乘以一减去源颜色的alpha。该公式中的所有颜色都假定为用它们的透明值预乘。

显然我们可以从公式中得到很多东西。首先让我们来假设所有的纹理是完全不透明的,也就是alpha = 1 。如果目的的(低层)纹理都是蓝色(RGB = 0,0,1),源(顶部)纹理是红色(RGB = 1,0,0),因为Sa为1,结果就是R = S

那么这个结果就是源的红色。这也是你所期待的。

如果这个源(顶部)的layer 5�0%的透明度,也就是aplha是0.5,那么S的RGB的值就是(0.5,0,0),因为透明成分预乘到了RGB值里面。那么公式就看起来是这样:

                       0.5   0               0.5
R = S + D * (1 - Sa) = 0   + 0 * (1 - 0.5) = 0
                       0     1               0.5

我们最终得到的RGB的值是(0.5,0,0.5)。它是饱和的梅花或者紫色。这时自然的你会直觉的认为那就是在蓝色背景上面混合了透明的红色。

我们需要明白的一点是:我们刚刚做的只是将一个纹理的一个像素合成到另外一个纹理的另外一个像素。GPU需要对两个纹理覆盖的所有的像素执行此操作。正如我们所知道的,大多数应用程序有大量的图层,因此需要合成的纹理也非常多。即使一个硬件的高度优化去做这样的事情也会让GPU一直处于忙碌的状态。

不透明 VS 透明 (Opaque vs. Transparent)

当源纹理完全不透明的时候,所得到的像素与源纹理是相同的。这可以节省GPU很多工作,因为它可以简单地复制源纹理,而不是混合所有的像素值。但是GPU是没有办法告诉纹理中的所有像素是否透明。只有你作为一个程序员知道你的CALayer是否是这样。这也就是为什么CALayer有一个称为opaque的属性,如果它被设置为YES,那么GPU就不会做任何混合,只是从这个层复制,而忽略它下面的任何东西。它节省了GPU相当多的工作。

这是关于仪器选项颜色混合层的所有(也可在模拟器的调试菜单中。它允许您查看哪些图层(纹理)被标记为非不透明,即GPU正在混合哪些图层。 合成不透明层更便宜,因为涉及的数学较少。

如果你知道你的图层是透明的,确保设置opaque为YES。如果你正在加载一张没有透明值通道的图片并将它展示在UIImageView里面,这将会经常发生。但是请注意:这里有很大的不同关于一张图片有没有透明值通道。在稍后的例子中,Core Animation 将不得不假设像素不是100%透明的。在Finder中,你可以使用Get Info和选中More Info部分查看。它会说明如果一个图像有无透明值通道。

像素对其以及非对其(Piexl Alignment and Misalignment)

到目前为止,我们已经研究了具有与显示器完全对其的像素的层。当一切像素对齐的时候,我们只需要进行相对简单的数学运算。每当GPU需要弄清楚屏幕上的像素应该是什么颜色时,它只需要查看在该屏幕像素上方的层中的单个像素并将它们合成在一起。 或者,如果顶部纹理是不透明的,则GPU可以简单地复制该顶部纹理的像素。

当所有像素与屏幕像素完全对齐时,图层是像素对齐的。什么情况下将不会产生这种情况呢? 主要有两个原因。 第一个是缩放; 当纹理向上或向下放大时,纹理的像素将不会与屏幕对齐。 另一个原因是当纹理的原点不在像素边界里。

在这两种情况下,GPU再次需要做额外的数学运算。 它必须将来自源纹理的多个像素混合在一起,以创建用于合成的值。 当一切都像素对齐时,GPU只需要做很少的合作。

同样, 核心动画Instruments和模拟器都有一个叫做颜色不对其图像的选项,将显示CALayer实例发生这种情况的时间

遮罩(Masks)

每一个图层都可以与一个遮罩关联起来。遮罩是一个alpha值的位图,在将图层合成到其下面的内容之前,该值将应用于图层的像素。 当您设置图层的圆角半径时,您可以有效地在该图层上设置蒙版。 但是也可以指定任意遮罩,例如。 有一个遮罩是字母A的形状。只有作为该遮罩的一部分的图层内容的部分才会被渲染。

离屏渲染(Offscreen Rendering)

离屏渲染就会被Core Animation自动地触发或者被应用强制触发。离屏渲染合成一部分图层树呈现到新的缓冲器(其是屏幕外的,即不在屏幕上),然后将该缓冲器呈现在屏幕上。

当合成计算量很大时,您可能需要强制屏幕外渲染。 这是一种缓存合成纹理/图层的方法。 如果你的渲染树(所有的纹理和它们如何协调在一起)是复杂的,你可以强制屏幕外渲染缓存这些层,然后使用缓存合成到屏幕上。

如果你的应用程序结合了许多图层,并且想要将它们一起动画,GPU通常必须将所有这些图层重新组合到每帧(1/60秒)的下面。 当使用离屏渲染时,GPU首先将这些层组合成基于新纹理的位图高速缓存,然后使用该纹理绘制到屏幕上。 现在,当这些层一起移动时,GPU可以重新使用这个位图缓存,并且做更少的工作。 注意,这只有当这些层不改变时才有效。 如果图层改变了,GPU必须重新创建位图缓存。 您可以通过将shouldRasterize设置为YES来触发此行为。

这是一个折衷的处理办法,但有一点它可能会导致程序变卡。 创建额外的屏幕外缓冲区是GPU必须执行的额外步骤,并且特别是如果它不能重复使用该位图,这将会浪费时间。 然而,如果位图可以被无限重新使用的话,那么GPU就可以被卸载。 你必须度量GPU利使用率和帧速率,看它是否有帮助。

离屏渲染可能作为副作用发生。 如果你直接或间接地将遮罩应用到图层,Core Animation被迫做屏幕外渲染,以便应用该遮罩。 这给GPU带来了负担。 通常它只能够直接渲染到帧缓冲区(屏幕)。

Instruments的核心动画工具有一个称为颜色离屏渲染 - 渲染黄色的选项,将使用屏幕外缓冲区渲染的黄色区域(此选项也可在模拟器的调试菜单中使用)。 一定要检查绿色和红色。 绿色用于每当屏幕外缓冲区被重用时,而红色用于当需要重新创建时。

一般来说,你应该避免屏幕外渲染,因为它的代价昂贵。 直接到帧缓冲器(在显示器上)的合成层比先创建屏幕外缓冲器,渲染到其中,然后将结果重新渲染到帧缓冲器中代价更小。 存在两个昂贵的上下文切换(将上下文切换到屏幕外缓冲器,然后将上下文切换回帧缓冲器)。

所以当你看到黄色后,打开颜色屏幕 - 渲染黄色,这应该是一个警告标志。 但它不一定是有问题的。 如果Core Animation能够重用场外渲染的结果,并且Core Animation可以重用缓冲区,它可以提高性能。 它可以重用,当用于屏幕外缓冲区的图层没有改变。

还请注意,光栅化图层的空间有限。 苹果暗示,光栅化层/屏幕外缓冲区的屏幕大小大约是屏幕的两倍。

如果你使用图层的方式导致屏幕外渲染传递,你最好是试图摆脱和屏幕外渲染一起。 使用遮罩或在图层上设置角半径会导致离屏渲染,因此阴影也是。

对于遮罩,使用圆角半径(这只是一个特殊的遮罩)和clipsToBounds/maskToBounds你可以简单的创建已经刻录的遮罩。例如通过使用已经应用的右遮罩的图片,和往常一样,这是一个折中的方式。如果要讲矩形遮罩应用于设置其内容的图层,则可以使用contentsRect而不是遮罩。

如果你最终设置shouldRasterizeYES,记得去设置rasterizationScalecontentsScale

更多的关于合成(More about Compositing)

是的,在维基百科上面有很多的关于alpha合成的数学毕竟资料。稍后我们将更加深入的讨论像素关于红色、绿色、蓝色以及透明值在内存中表现。

OS X

如果你在OS X上工作,你会发现大多数调试选项是一个单独的应用程序,称为“Quartz Debug”,而不是内置的Instruments。Quartz Debug 是“图形工具”的一部分,它是需要在开发人员网站里面单独下载

Core Animation & OpenGL ES

如同名字所暗示的,核心动画就是让你在屏幕上面做动画。我们总是跳过谈论动画,而是把精力放在了绘图上面。有一点你需要注意的是,核心动画允许你做非常高效的渲染。这也就是你为什么能在每秒60帧的时间内使用Core Animation做动画

Core Animation,其核心是在OpenGL ES上的抽象。简单来说它可以让你使用OpenGL ES的力量,而不必处理它的所有复杂性。当我们谈论上面的合成时,术语图层和纹理是可以相互替换的,但是他们并不是同一件事情,但是很相似。

核心动画层可以有子层,所以你最终得到的是一个层树。 Core Animation需要做的费力的事情是:确定需要(重新)绘制哪些图层,以及需要进行哪些OpenGL ES调用以将图层复合到屏幕上。

例如,当您将图层的内容设置为CGImageRef时,Core Animation会创建一个OpenGl纹理,确保该图像中的位图被上传到相应的纹理等。如果你覆盖-drawInContextCore Animation就会将分配纹理确保您所做的Core Graphics调用将会转换为该纹理的位图数据。图片的属性和CALayer子类影响执行OpenGL渲染的方式,许多较低级别的OpenGL ES行为很好的封装在易于理解的CALayer概念中

Core Animation orchestrates CPU-based bitmap drawing through Core Graphics on one end with OpenGL ES on the other end. And because Core Animation sits at this crucial place in the rendering pipeline, how you use Core Animation can dramatically impact performance.

CPU bound vs. GPU bound

当您在屏幕上显示某些内容时,有许多组件正在工作。 两个主要的硬件播放器是CPU和GPU。 P和U在他们的名字代表处理单元,并且当事情必须在屏幕上绘制时,这两个都将做处理。 两者也有有限的资源。

为了实现每秒60帧,您必须确保CPU和GPU都不会超载工作。 除此之外,即使你是60 fps,你想把尽可能多的工作在GPU上。 您希望CPU可以自由运行应用程序代码,而不是忙于绘图。 并且GPU在渲染时比CPU更有效,这样的使用将会转化为系统的较低的总负载和功率消耗。

由于绘图性能取决于CPU和GPU,您需要确定哪一个限制了您的绘图性能。 如果您使用了所有GPU资源,即GPU是限制您的性能,您的绘图被称为是GPU绑定。 同样,如果你超出了CPU界限,那么CPU限制了您的性能。

如果你是GPU绑定,你需要减轻GPU的负担(也许在CPU做更多的工作)。 如果你是CPU绑定,你需要减轻CPU的负担。

要检查是否是GPU限制了你性能,使用OpenGL ES驱动程序仪器。 单击小i按钮,然后配置,并确保选中设备利用率%。 现在,当你运行你的应用程序,你会看到如何加载的GPU。 如果这个数字接近100%,那么就是你在GPU上做了很多工作。
被CPU限制是你的应用程序在更多的传统方面做了许多工作。 Time Profiler仪器可帮助您检测。

Core Graphics / Quartz 2D

Quartz 2D 更常见的是包含它的框架的名称: Core Graphics

Quartz 2D有更多的技巧超过了我们所能够覆盖的地方。我们不打算谈论与PDF创建、渲染、解析或打印相关的巨大部分。只要注意,打印和创建PDF在很大程度上等同于在屏幕绘制位图,因为它都是基于Quartz 2D

让我们简单的谈谈Quartz 2D的主要概念。有关详细的信息,请务必去查看AppleQuartz 2D编程指南

请放心,当涉及到2D绘图时,Quartz 2D是非常强大的。仅仅列举几个功能: 基于路径的绘图,抗锯齿渲染,透明层,以及分辨率和设备独立性。 这是相当令人生畏的,更是因为它是一个低级和基于C的API。

主要的概念是比较简单的,但 UIKit和AppKit只是简单地使用API来封装一些Quartz 2D,甚至一旦你习惯了它,即使是简单的C API也是可以访问的。 你最终得到一个绘图引擎,可以做你能够做的大部分的PhotoshopIllustrator。 苹果提到了iOS上的股票应用程序作为Quartz 2D用法的例子,因为图形是使用Quartz 2D在代码中动态呈现的图形的简单示例。

当你的应用程序做位图绘图时,它会 - 以某种方式 - 基于Quartz 2D。 也就是说,绘图的CPU部分将由Quartz 2D执行。 虽然Quartz可以做其他事情,但是我们将专注于位图绘制,即在包含RGBA数据的缓冲区(一块内存)上绘制结果。

让我们来画一个八边形 Octagon. 我们可以使用UIKit

UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(16.72, 7.22)];
[path addLineToPoint:CGPointMake(3.29, 20.83)];
[path addLineToPoint:CGPointMake(0.4, 18.05)];
[path addLineToPoint:CGPointMake(18.8, -0.47)];
[path addLineToPoint:CGPointMake(37.21, 18.05)];
[path addLineToPoint:CGPointMake(34.31, 20.83)];
[path addLineToPoint:CGPointMake(20.88, 7.22)];
[path addLineToPoint:CGPointMake(20.88, 42.18)];
[path addLineToPoint:CGPointMake(16.72, 42.18)];
[path addLineToPoint:CGPointMake(16.72, 7.22)];
[path closePath];
path.lineWidth = 1;
[[UIColor redColor] setStroke];
[path stroke];

或多或少的对应的Core Graphics代码是:

CGContextBeginPath(ctx);
CGContextMoveToPoint(ctx, 16.72, 7.22);
CGContextAddLineToPoint(ctx, 3.29, 20.83);
CGContextAddLineToPoint(ctx, 0.4, 18.05);
CGContextAddLineToPoint(ctx, 18.8, -0.47);
CGContextAddLineToPoint(ctx, 37.21, 18.05);
CGContextAddLineToPoint(ctx, 34.31, 20.83);
CGContextAddLineToPoint(ctx, 20.88, 7.22);
CGContextAddLineToPoint(ctx, 20.88, 42.18);
CGContextAddLineToPoint(ctx, 16.72, 42.18);
CGContextAddLineToPoint(ctx, 16.72, 7.22);
CGContextClosePath(ctx);
CGContextSetLineWidth(ctx, 1);
CGContextSetStrokeColorWithColor(ctx, [UIColor redColor].CGColor);
CGContextStrokePath(ctx);

要问的问题是:这个绘图在哪里? 这就是所谓的CGContext的作用。 我们传递的ctx参数是在这种情况下。 上下文定义了我们要绘制的位置。 如果我们实现CALayer的-drawInContext:我们被传递一个上下文。 绘制到该上下文将绘制到图层的后备存储(其缓冲区)。 但是我们也可以创建我们自己的上下文,即基于位图的上下文。 CGBitmapContextCreate()。 这个函数返回一个上下文,然后我们可以传递给CGContext函数来绘制上下文等

注意UIKit版本的代码如何不将上下文传递给方法。 这是因为当使用UIKit或AppKit时,上下文是隐式的。 UIKit维护着一堆上下文,UIKit方法总是绘制到顶层上下文中。 你可以使用UIGraphicsGetCurrentContext()来获取上下文。 你可以使用UIGraphicsPushContext()UIGraphicsPopContext()来推入和弹出上下文到UIKit的堆栈。

最值得注意的是,UIKit有方便的方法UIGraphicsBeginImageContextWithOptions()和UIGraphicsEndImageContext()创建一个位图上下文类似于CGBitmapContextCreate()。 混合UIKit和Core Graphics调用非常简单:

UIGraphicsBeginImageContextWithOptions(CGSizeMake(45, 45), YES, 2);
CGContextRef ctx = UIGraphicsGetCurrentContext();
CGContextBeginPath(ctx);
CGContextMoveToPoint(ctx, 16.72, 7.22);
CGContextAddLineToPoint(ctx, 3.29, 20.83);
...
CGContextStrokePath(ctx);
UIGraphicsEndImageContext();

或者其他方式

CGContextRef ctx = CGBitmapContextCreate(NULL, 90, 90, 8, 90 * 4, space, bitmapInfo);
CGContextScaleCTM(ctx, 0.5, 0.5);
UIGraphicsPushContext(ctx);
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(16.72, 7.22)];
[path addLineToPoint:CGPointMake(3.29, 20.83)];
...
[path stroke];
UIGraphicsPopContext(ctx);
CGContextRelease(ctx);

Core Graphics有很多非常酷的东西可以做。 有一个很好的理由,苹果文档调用其无与伦比的输出保真度。 我们不能进入所有的细节,但是:Core Graphics有一个图形模型(由于历史原因)是非常接近的Adobe IllustratorAdobe Photoshop的工作原理。 大多数工具的概念转换为Core Graphics。 毕竟,它的起源是在NeXTSTEP,它使用显示PostScript

CGLayer

我们最初表示CGLayer可以用于加速重复绘制相同的元素。 正如Dave Hayden所指出的,传言已经说明这不再是真的。

像素(Pixels)

屏幕上的像素由三个颜色分量组成:红色,绿色,蓝色。 因此,位图数据有时也被称为RGB数据。 您可能想知道如何在内存中组织这些数据。 但事实是,有很多很多不同的方式用于RGB位图数据在内存中的表示。
稍后我们将讨论压缩数据,这是完全不同的。 现在,让我们来看看RGB位图数据,其中每个颜色分量都有一个值:红色,绿色和蓝色。 通常我们会有第四个组份:alpha。 我们最终得到每个像素的四个单独的值。

此为未完结版...

小弟第一次在简书上面发表文章,纯属抛砖引玉,仅供大家参考。不喜勿喷~~😁😆

最后贴上原文链接供各位小伙伴们学习猛戳这里

推荐阅读更多精彩内容

  • 绘制像素到屏幕上 answer-huang22 Mar 2014 分享文章 一个像素是如何绘制到屏幕上去的?有很多...
    阿狸旅途T恤阅读 1,055评论 0 7
  • 卷首语 欢迎来到 objc.io 的第三期! 这一期都是关于视图层的。当然视图层有很多方面,我们需要把它们缩小到几...
    评评分分阅读 1,352评论 0 18
  • 绘制像素到屏幕上 软件组成 从简单的角度来看, 软件堆栈看起来有点像这样: Display的上一层便是图形处理单元...
    VanChan阅读 549评论 0 1
  • 图层树 在UIKit中所有的视图都是基于UIView派生而来,UIView支持触摸时间,可以支持基于CoreGra...
    maguns阅读 830评论 1 3
  • Iios启动图片的添加是十分的简单,网上有许多教程,在这里本人就不教大家了.那么为什么我们需要启动图片?不需要启动...
    iosPBB阅读 65评论 0 0