360andev chet haase romain guy cover

我和你

在���次 360|AnDev 的演讲中,Romain 和 Chet 分享了构建更好的 UI 的信息和技术。


介绍 (0:00)

今天,我们将讨论一下我们特别痴迷的话题。本次演讲有两部分,从中间分开。这是 Chet-taught 的部分,他写过许多关于图像、动画和性能方面的代码,现在他负责 Android UI Toolkit 小组。然后是 Romain 的部分,他负责 Android 图像小组。在第一部分里面,我们将讨论编写动画的规则,第二部分我们将讨论颜色和色彩空间

动画原则 (1:45)

首先,让我们讨论一些动画的原则。有一本 Disney 写的书,书名叫做 Illusion of Life。我推荐你们读一下。特别的,它里面有一个章节叫做动画的原则,动画的 12 条原则。这是一本差不多咖啡桌大小的咖啡桌书籍。这本书非常详尽地描述了 Disney 在他的动画中使用的原则,这些原则他们都在很久很久以前的那些电影和短片中使用过。

这些原则中的一大部分都能运用到今天的动画中来,也能运用到生活中去,也能运用到人机交互中。我们之前讨论过这些。在 2013 年的 Google I/O 中,我们有过一次演讲,讨论过许多动画的原则,包括卡通动画原则。如果你看过,你可能不知道在 Devoxx 会议的 4 年前,我们就发表过这个演���。我们逐一讨论了这 12 个原则,所以今天我们不会做同样的事情。

今天我想专注于那些你们能在 Android 平台上直接看到的那些原则,这些是当你们创建动画或者使用的时候需要应用的原则。有一些原则,当然它们是为手工动画而创建的,不是很容易就能应用上,我会在最后串讲一下这些原则,然后聊聊它们背后的想法。我想花多些时间在你能在 Android 平台上看到的那些原则上,特别是 material design,而且现在平台的动画能力已经使得运用这些原则比几年前容易许多了。

出场 (3:27)

让我们从 出场 开始,这是许多原则的核心。

这里的想法是 与用户连接 或者是与屏幕上当前发生的你期望他们能理解的动作 的观察者的连接。你可以在电影里面看到这样的动画效果。如果你留心下,在许多电影里面,你会有一个身着特殊衣服的角色,那样他就能鹤立鸡群,你知道那个带着绿色帽子的人是主角而且你会注意他的动作。屏幕上可能有 300 个人,但是你却知道主角在哪里。我认为在有特技替身的时候,他们就运用这个原则成功地使你把替身认作主角。

但是还有一些别的原因使你的注意力都在那个人身上,因为如果给你呈现一片城市嘈杂的景象,当前发生的事情太多,你不知道哪些是你需要注意的事情和从那个场景收集信息。所以他们给了你一些暗示。他们会关注主角然后推远镜头。他们还会做些别的事情来确保你理解了你应用注意的地方。

同样的事情发生在动画上。你只有很短暂的时间来了解他们想表述的东西。这些角色很快就会消失,或者会在空间角落里面偷偷摸摸的做些事情。他们想引起你对那些角色的注意 这样你获得了从那个场景需要得知的东西,而且你理解他们接下来在动画里面将要发生的动作。

在我们的介绍中,我们有一个简单的图标。屏幕上有许多对象,这是一个有着许多东西的场景,然后屏幕的某个地方会有一个对象引起你的注意,或者通过动画或者通过色彩,这都是和周围的事物不一样的方法。我想向你们展示一下安卓平台上的一些例子,这样你们对当今这些原则是如何集成到应用中就会有一个更好的理解。

第一个例子是播放音乐。播放音乐是一个有着许多 activity 切换的应用。它尽其所能来实现这样一个想法,在 activity 切换的时候共享元素,因为它有着沉浸式体验,你是在反复操作同一个媒体内容。你会从一个专辑歌曲列表转到歌曲详情,或者回到专辑列表,这些就是这些 activity 的公共元素。他们想保证用户能有一致的体验。当你点击专辑的时候,你会有 ripple 动画,然后它被启动起来。它所做的事情就是及时把你从一个 activity 带到另一个 activity,而不是擦除当前屏幕然后在同样的地方重新绘制一个,这样你的大脑就需要解析新的信息,它们帮助你专注于关键元素,这就是专辑。它会让你从一个屏幕到另外一个,这样你知道这是那个特别的专辑的详情。

我们也能在一些新的启动动画里面看到这个想法。我们点击一个图标,然后我们启动应用,差不多是在屏幕的中间会出现一个和应用图标一样的图标。这样你就知道这不是一个随机出现的白屏,或者你也不会看到你点击的那个应用的全屏 UI 自动地出现。相反的,它通过一直展示图标来帮助你进入应用。

安卓开发小窍门: 让你的窗口背景使用 Drawable。不要做些特别花哨的屏幕,比如一个需要自己加载的新的启动动画的 activity。为窗口使用 drawables。它们十分有效,而且完成任务不会有过大的开销。

渐进渐出 (7:42)

让我们来看看下一个原则,渐进渐出。这是关于运动的。特别的地方是,这是关于定时的,为了给用户提供自然的时间来理解,因为动画是 戏剧化的生活。这是你想给现实生活增添的特征,这样你的用户能容易理解它们而且会对这些特征有同理心和移情作用,而不是仅仅认为它们只是屏幕上发生的动画而已。它不是一组帧序列,它是实实在在的特征,屏幕上的真实生活的特征。你希望这个运动能给用户带来屏幕上都是真实的对象的感觉。

强调一次,我们在表示层有一个简单的动画。我们在最上层有这样非线性的东西。缓慢进入,缓慢退出。运动中加速,然后减速退出。在底部,你有这个线性运动。你可以从最上层来看这个问题,这样更自然些,因为这就是我们人类运动的方式,不是吗?

如果你有一个电脑,如果你有一个运动的机器人,它们会线性运动。如果我们在屏幕上看到动画,我们也会这么认为。如果线性运动,看起来就会非常机械,因为活的生物不会那样移动。我们加速进入,减速退出。这篇演讲的主旨就是顶层的动画好一些,线性移动,本质上,很差。

规则也有例外,一个例外就是当缓慢消失某些东西的时候,当你慢慢改变视图的透明度的时候,你不需要渐进渐出的效果。你可以使用线性插值。还有些别的场景你可以使用线性运动。例如,当你在屏幕上有大对象的时候。有些时候它们采用线性运动反而会好些。不要把它作为你放之四海皆准的原则。试试看,看看当前使用的动画是不是有意义。

最后一点是在 VR 里,线性运动是你想要的东西。当你在 VR 环境里面使用第一人称视角的时候,如果摄像头有渐进渐出的运动,感觉就会非常的糟糕。因为身体并不会感到加速和减速,所以人会感到恶心。所以在这种情况下,你需要线性运动。这看起来不太好,但是不会让你呕吐,这非常重要。

Receive news and updates from Realm straight to your inbox

在开发 Lollipop 过程中,有些很有意思的东西,我们和 UX 小组一起设计了 material 设计背后的动画原则。它们采用了和我们完全相反的渐进的概念。我肯定他们是从更传统的思想里面继承而来的。我认为如果我们回到从前,然后再读一遍 Disney 的观点,就会形成他们的世界观,我不敢苟同。

当我们说渐入渐出的时候,我展示的小球从左到右的运动,它加速进入然后减速退出。它渐渐进入间隔,然后渐渐退出间隔。我不知道人们是不是处理过 Flash 平台和 Penner Easing 方程。这是编程动画里面的通用语言。在软件开发工程师里面流行好多年了。同时,设计师们因为某些原因,还没有注意它们,所以他们使用同样的语言但是表达相反的意思。如果你说渐入,他们实际上意味着以结束的节奏入位,如果你说渐出,他们意味着以开始的节奏退出位置。当他们说渐入渐出的时候,意思是开始的节奏退出位置,然后以最后的节奏进入位置。这意味着减速退出然后加速进入。每次这都会让我有些迷惑。他们错了,但是除非你和你的设计师开始了一段迷惑的谈话,你才可能会试着指出他们不对的地方。

有的时候因为别的原因,这会变得更迷惑。我记得当我和 UX 设计师一起工作的时候,他们有他们自己美妙的插值。然后我看了看动画的持续时间。做了点数学运算,发现整个动画过程只有三到四帧。我试着说服他们我们试着加速或减速都不重要。因为你只有三帧,你没有办法看到加速或者减速的效果。这也是另外一个使用线性插值的原因。如果你的动画真的,真的很快,这些都不重要了。

幸运的是,现在非线性定时和你想要的任意时间都更容易实现了。我想在演讲中展示一个快速实例,来向你们展示这是如何工作的。我们有线性插值。有人在 Google I/O 上和我说,他说 “我有一个动画,我想让滑入屏幕的文字能用这个动画。我需要使用的一种最好的插值是什么? 怎么做才是正确的?” 这没有对错之分。回答永远是,看情况。这取决于你的当前状态。取决于你的应用的感觉。取决于你个人选择或者你的用户在你的应用中的感觉是否更真实了。我推荐他应该写一个 demo 应用,采用不同的插值。采用不同的时长。采用我们已有的不同的时间曲线,然后决定那种在他的上下文里是有意义的。然后我意识到我们应该让这样的事情成为可能。写一个那样简单的 demo 不是一件困难的事情。

你在这个 demo 里面看到的事情就是你期望的。我们选择了一个线性插值。我们运行动画,然后你可以看到动画以不同的方式运行着。它会沿着我们在下面的图形上画的曲线上运行。然后底部会有一些随机的元素,这样你可以看到运动是如何进行的,从左到右,从上到下。你可以运行几次。改变时长,改变重复次数。如果你想对它有种感觉的话,也可以不断地运行,但是让我们看看更有意思的曲线吧。我们可以使用减速曲线,你可以看到图表曲线上的时间表示。你可以运行它,看到它开始非快,然后不断地加速。这里的参数是你能在构造函数里面使用的参数。我们可以改变它,看看它对时间曲线的影响,然后再次运行动画来得到相应的感觉。

弹跳是很酷的。你可能不太想广泛地使用它,但是因为它很有趣就使用了它。我们有 path 插值,这是一个你能使用的更通用的东西。你可以提供一个仲裁的路径,然后得到所有的古怪的行为。我们也有一些方法来创建标准的,二次方的和三次方的路径。你可以通过拖拽控制点来改变曲线,从而得到对于时间的感觉。使用路径可以帮助你重建所有你想要的其他效果。Lollipop 里引入的 Path 插值意味着一个更加通用的目的性插值,通过它你可以获得任何曲线。

我将跳到 Cubic。它有一些拐点。你能得到更复杂的曲线,特别是当你把它拖拽到错误的位置的时候。这不是特别有意思。它们本质上是些不同的插值的构造函数,而且插值仅仅返回不同时间上的浮点数值。

Arc (16:47)

Arc 和时间相关。我们在时间和空间上都不线性移动。我们每处都不遵循一条直线。相反的,一般都是非线性移动。你的屏幕上的对象都不一样,这不好吗?强调一次,这是为了避免机械的感觉。

如果你从一个屏幕的角落移动一个对象到屏幕的其他地方,它沿途需要遵循精妙的路径。它看起来更有机,更自然。所以,不是沿着一条直线,我们像做些更自然的事情。再一次,快速 demo。

我们在 demo 里可以看到三种运动。我们有线性移动,按钮是从左到右。我们能线性移动它或者采用 path 运动。有许多不同的方法可以构造 path 运动。最后两个看起来很像,但是创建代码完全不同,我想展现这两种创建方法。中间的采用了 ObjectAnimators,手工创建 path,然后采用 ObjectAnimator 在那个 path 上构建动画。最后一个事情是过渡,代码少但是做的事情相同。

final float oldX = arcMotionButton.getX();
final float oldY = arcMotionButton.getY();

使用任何动画的第一个步骤就是你需要知道现在按钮在哪。

LayoutParams params = (LayoutParams) arcMotionButton.getLayoutParams();
if (mTopLeft) {
	params.rightToRight = R.id.parentContainer;
	params.bottomToBottom = R.id.parentContainer;
	params.leftToLeft = -1;
	params.topToBottom = -1;
} else {
	params.leftToLeft = R.id.parentContainer;
	params.topToBottom = R.id.trajectoryGroup;
	params.rightToRight = -1;
	params.bottomToBottom = -1;
}
arcMotionButton.setLayoutParams(params);
mTopLeft = !mTopLeft;

得到当前的位置,X 和 Y,然后重定位视图。接下来改变布局。在这个例子里面,我使用了 new ConstraintLayout,所以我把按钮设置为或者左上固定或者右下固定。然后我设置布局的参数,这会触发一个 requestLayout。这样我们完成了布局,并且一会就回来。当这些发生的时候,我们将需要 PreDrawListener

final ViewTreeObserver observer = arcMotionButton.getViewTreeObserver();
observer.addOnPreDrawListener(
	new ViewTreeObserver.OnPreDrawListener() {
		@Override
		public boolean onPreDraw() {
			observer.removeOnPreDrawListener(this);

			// ...

			return true;
		}
	}
);

这是一个常用的动画技术。在我们之前展示的东西里,这个技术出现了很多次。这也是我们过渡效果的核心。我们使用视图树监控工具。我们增加一个 PreDrawListener,然后在 PreDrawListener 里面,我们知道布局会发生。现在我们能进行些神奇的事情,然后计算出它结束的状态,之后动画就会从开始放到结束。

final ViewTreeObserver observer = arcMotionButton.getViewTreeObserver();
observer.addOnPreDrawListener(
	new ViewTreeObserver.OnPreDrawListener() {
		@Override
		public boolean onPreDraw() {
			observer.removeOnPreDrawListener(this);
			float deltaX = arcMotionButton.getX() - oldX;
			float deltaY = arcMotionButton.getY() - oldY;
			PropertyValuesHolder pvhX = PropertyValuesHolder.ofFloat("translationX", -deltaX, 0);
			PropertyValuesHolder pvhY = PropertyValuesHolder.ofFloat("translationY", -deltaY, 0);
			ObjectAnimator.ofPropertyValuesHolder(arcMotionButton,
						pvhX, pvhY).start();
			return true;
		}
	}
);

这就是我们拥有的线性方法,我们能找到新的位置:

  1. 我们计算出 ΔX 和 ΔY。按钮会到何处?
  2. 我们给 ObjectAnimator 同时设置两个动画属性。我们有一个 PropertyValuesHolder 给 X 还有另外一个给 Y。
  3. 我们让 ObjectAnimator 同时完成两个动画属性。

启动动画,然后一切就发生了,然后它就无聊地直线进入到角落。你想做的事情就是作出替代曲线。你可以在使用你之前看到的同样的东西,除了 ΔX 和 ΔY,我们现在构建一个 path 运动,而且我们将使用一组控制点来实现它。我们移动到顶处,然后我们做一个单点控制的二次方程,这个方程会作为它们之间的曲线。中间会有一个控制点,但是是远离那条线的,这会导致曲线移动。启动动画,它会随着 path 动画,和你想要的差不多。一个简单的实现方法是使用过渡。

首先,你甚至不需要知道老的位置,因为过渡将会帮你计算出来。第一步是重新定位你的视图,和你以前做的一样。设置合适的布局参数,触发一次布局,然后 beginDelayedTransition。过渡管理模块将会帮你设置 PreDrawListener,计算出对象原来在什么地方,现在在什么地方,然后运行一个动画。

ChangeBounds arcTransition = new ChangeBounds();
arcTransition.setPathMotion(new ArcMotion());
TransitionManager.beginDelayedTransition(parentContainer, arcTransition);

默认是线性的,但是你可以使用 ArcMotion 来容易地改变它。在你将使用的 ChangeBounds 的过渡里,你可以说,使用一个 ArcMotion。它将自动地计算出你使用的那个 path,对象能够遵循的合适的曲线。这里的代码就是你需要运行一个曲线过渡效果的所有代码。

二级运动 (21:17)

二级运动是帮助你唤起某些动画的全部运动的动画。一个简单的场景是,我们有一个弹跳球在左边,你会注意到右边的球在做一些事情来强调左边的运动。当左边的球碰到底部的时候,会有一个撞击来帮助强调整体动画的感觉。

我们在 UI 上能看到这样的动画。效果一样,有些难缠,屏幕记录了我们刚刚进行的动画。特别的是,我想把你的注意力引到刚才动画进入的播放按钮上。你会扩大专辑视图成为第二个 activity,然后你给播放按钮动画,用户的图标会到左边。正是这些动画协同工作提示你在专辑视图上,同时你有播放专辑的选择。

二级运动是一个伟大的技术,但是需要小心使用。非常容易滥用。播放音乐做的正确。他们只在一个元素上使用了。但是我看到过许多应用,也许是因为我们在谷歌做了许多不好的示范,我们使用了许多二次动画,这对用户来说太重了。试着只专注在你的 UI 上的一到两个关键元素。不要因为增加动画有趣就四处使用。如果你使用了许多动画,屏幕上就会变得很乱,因为它们总是重叠。曲线动画,除了能使得事情更加真实以外,也是一个确保你的元素在屏幕上不冲突,不重叠的有效方法。

我想展示一个快速的例子。这是一个通知栏。如果你下拉通知栏,你会发现顶部的齿轮图标是一个漂亮的二次动画。当你下拉的时候,齿轮图标会渐入,然后在某个时间显示,引起你对这个设置的注意,强调你将进入的通知栏有可扩展的属性。这里还有许多其他的二级动画,但是齿轮图标是我喜欢的一个重要动画。

定时和实体绘图 (23:23)

许多动画都是关于定时的。这个关于定时的原则是想展示真实的感觉和物理上实体物件的感觉。

如果我们有一个小的对象移动很短的距离,它应该移动的非常快。你能把这种感觉对应成小物体和光。另一方面,如果你有很长距离移动的对象,那么多花点时间是正常的。因为它是物理的对象,应该多花点时间到达目的地。另外,如果你有一个很大的对象,而且你想实现同样的感觉,动画应该尽可能的快速播发。如果这个对象是有重量的,那么时间上就需要传达这个感觉。因为重的东西花的时间较长,所以它也需要多花点时间。

当你工作在这种动画上的时候,你会注意到这是和视图感觉是相关的。当你处理全屏元素的时候,你会有大的过渡,这很有趣。比如,你可能想用一个短的持续时间,因为这个对象看起来太大了,所以动画看起来比实际执行时间要长。重申一次,如果你处理大的对象,比如围绕着你应用转圈的东西,你可以试着定时运行一下,看看那我说的这些内容。

实体绘画也是传递物理感的一种方式。我本以为这个原则是关于实体绘画技巧的。事实上不是。它传递了你的对象的实体感,一种物理上的真实感,来确保用户能理解和强调屏幕上的东西是真实的物体。

这里有一个 material design 标准里面的例子,你可以看到 UI 实例。这是我们采用阴影的部分原因。我们想要屏幕上的卡片或者纸质物体具备真实感,这就是你给它们海拔的原因,它们会自动地具备阴影的属性,这会帮助你理解它们的物理属性。

其他卡通动画的原则 (25:43)

我将会快速浏览其他的原则,这些原则不太适合 UI。

挤压和拉伸是关于物理特性的。当物体下落的时候,它们会在重力的作用下拉伸。这很酷,但是在 UI 里面不太有用,因为这太卡通了。令人吃惊的是,这也是日���生活里能见到的东西。如果你从来都没有看见过高尔夫球撞墙或者网球撞墙的慢动作视频,你可以在 YouTube 上看看。你可以看到当撞上墙壁的时候,物体挤压和扭曲的情况会令你吃惊。我们看不见是因为现实生活中这一切都太快了,但是它是实实在在发生的。

下一个原则是预知。当你想要帮助用户理解屏幕上将要发生什么的时候,这很有用。你有着很少的帧可以完成这件事情,特别是在传统的动画里面。如果你的对象将要从右往左猛烈撞击的话,而且它们开始的时候缓慢反向运行,那么我们就会有一个预期,它们将会向相反的方向反向猛烈运动。重申一次,这对卡通是很关键的,但是对许多 UI 相关的元素来说不是很适用。

径直向前,摆好姿势。这是关于差异的。它帮助营造一种动画里面激烈能量的感觉。传统的动画方式是你自己实现这些关键的帧。它们会渲染这些主要的姿势,然后一些初级的动画师会加入,花时间填补中间的东西。他们能做的事情是分开渲染每个单独的帧,而不是传递有能量的感觉。和准备好姿势不一样。它仅仅是创建了额外的能量,因为帧之间的这些噪音。重申一次,和 UI 不太相关。

惯性和重叠是关于物理对象的。如果你撞击一面墙,你的骨头马上就会停下来,你身体的肉会继续。高尔夫球也是一个好例子。撞击墙壁的时候,物体的一部分停止了,但是一部分没有强制停止的会继续。这就是惯性原则,它能帮助人们理解这是一个物理对象。这不适合 UI。

夸张。这在卡通里非常有用,因为这很有趣。你想要它的物理性,但是你也想它能超现实。这也不适合 UI, 虽然对于卡通很适合。

吸引。如果人们对你的角色有同情心不是更好吗?你想让它们吸引人。赋予它们魔力。这对于你的 UI 来说也是适用的。你想让你的 UI 具备吸引力。

颜色 (28:56)

让我谈谈颜色。有两个原因让我想讨论这个话题。

首先,这是我当前着迷的地方。有种观点是,许多应用,大部分应用都做错了。即使一些特别花哨的应用比如你电脑上的 Photoshop 有时候都不正确。我想聊聊这个,来帮助你们解决你们应用中的问题,如果这些问题对你重要的话。

我也想聊聊色彩空间,因为过去的几年里,我们看到广 gamma 显示屏被运用了起来。我们有了 UltraHD TV 的新标准,比如 4K 和 8K 显示屏。它们真的有很大的色彩空间。我们也将讨论一下 HDR。Apple 已经开始在他们的 iMacs 上使用了广 gamma 显示屏了。未来几年这是个有机会的地方,我们会看到移动设备也会采用这个技术的。然后你就会担心色彩空间的问题了。下面会是这个异常复杂的问题的粗浅入门。

第一个观点是 gamma 和线性空间的问题。为了表示清楚,我会使用一些术语,它们是色彩科学的一些简称。如果房间里面有色彩科学家,你可能会感到震惊并且讨厌我将说的话题,但是我将尽可能的保持事情简单。Gamma 和 线性的关键问题是,你现在在你的应用里做的事情是错的。为了理解原因,我们将回到早期计算机科学开始的时候,那个时候使用的是 CRT 显示器。

CRT 显示器是如何工作的呢,它有一个电子枪,这听起来很酷。但是实际上去十分无聊。它把一组电子发射到银光屏上,哪里有一个产生 RGB 数组的罩子。

为了更好的了解 CRT 显示器里面到底发生了什么,让我们想象我们将显示一个梯度。这是我们显示器的输入。水平轴是像素坐标,纵轴是颜色。这是一个从黑色到白色的梯度。我们把这个曲线发射到显示器,然后说,”请显示我们美丽的黑色到白色的梯度。” 方程式是 X。显示器真正做的事情就是显示曲线,称作 gamma 曲线,它的 X 被提高到 2.2 次方。副作用就是你的美丽的由黑到白的梯度比你预期的要黑了。

你没有做错任何事情。你编写你的应用。你做的事情都是有原因的,但是它会在屏幕上看起来会暗些。这是因为电子枪的工作原理导致的。它是物理的。我们不能改变它。物理有时候很讨厌,这就是一个例子。

我们能做的事情是调整 gamma 曲线。这叫做 gamma 修正,我不知道你听说过没有。你可能在你的操作系统的 UI 里看过它。你所要做的事情就是给你的输入应用反转曲线。这里 X 被提升到 1/2.2 次方。如果我们输出这个反转曲线到屏幕上,输出就会是我们的线性梯度了,这是我们开始时候想要的东西。

实际中,事情会更复杂一点。某些显示器的 gamma 实际上接近 2.5。���们使用 2.2 的原因是在标准的灯光照明条件下,你的视窗会降低你的显示器的人为对比度感知。我们通过错误的 gamma 曲线了修正它。

当我们有 LCD 屏幕的时候,我们仍然需要担心 gamma 修正吗?我不确定你能猜到答案。答案是我们需要,原因是我们的眼睛。当光线进入我们的眼睛的时候,我们眼睛的反应时间是非线性的。我们的视觉系统遵循一个数学原则叫做 Steven’s Power 原则。这里是公式。输入是 I,在我们的例子里,I 是光线。它是进入我们眼球的亮度,这和主观感觉有关。亮度是你在大脑里感知的主观感觉,是幂次方的关系。在 Steven’s Power 法则里面幂次用 gamma (γ) 表示,这就是我们讨论 gamma 曲线的原因。gamma 值由变量的类型决定。抵达眼球的光线,在普遍光照的情况下,恰好是 0.5。这里我做了一些我称作图标算数的计算。0.5 和 1 除以 2.2 相近。它实际上是 1 除以 2,但是在图表中,这没有关系。这几乎就是正确的了,是正确的。

通过侥幸的混合和一些工程,我们的 CRT 显示器有了和我们的眼球相反的反应,这使得一切工作正常。现在,如果我们回到我们的 LCD 显示屏,他们没有 gamma 响应曲线。它们中的一些更加线性。还有一些有 S 曲线。我们在 LCD 控制器里面的硬件会使的它们具备 gamma 响应曲线。你可能想这是为什么呢?一个原因是为了和现有的 CRT 显示器兼容。不需要因为有人引入了一个新显示设备,就要重写所有的应用,这很棒。但是我们这样做的真正原因是 gamma 编码。

我们谈到了 gamma 修正。今天,这不仅仅是修正了。它是压缩。gamma 曲线是图像压缩的方法。你知道 JPEG。你知道 PNG 的原理。你能够 ZIP 你的图像。它最终,也会使用 gamma 来压缩我们的图像。这样做的原因是,如果你回到我们眼球的曲线,它显示了我们的眼球对暗色调更加敏感,同时对于中间灰度比对高亮敏感些。这意味着如果我们线性编码图片,如果你的值是从零到一毫无处理,你只是在编写你的 for-loop,我们将会失去编码中比特的精确度。

这里有个例子。我们有 32 个值来编码灰度。在线性编码方案里你能看到,我们花了许多的 bit 来编码高亮,这却是我们眼球不敏感的区域。这样,如果我们像那样编码我们的图像,看起来是个我们应该做的标准方式,我们每个色彩通道都需要 12 个比特来编码。如果我们用过图片处理工具,或者你过去使用过 bitmaps,你会注意到我们实际上只用了 8 比特。我们能使用 8 比特的原因就是我们使用了 gamma 压缩算法。

当你使用 gamma 曲线编码图像的时候,你十分高效地把比特分配到了暗区域。然后我们能用 8 比特来编码我们能看到的所有事情,这就是为什么它是编码算法的原因。Gamma 压缩不是必须的,如果你有高精度的格式的话。如果精度足够,它们能够线性编码。一些格式现在广泛运用着,特别是在电影工业或者游戏工业的生产线上。例如,关于 HDR 图像有 OpenEXR。还有 PNG-16。Photoshop 也使你能存储 16 和 32 比特。如果你有一个摄像机,摄像机能拍摄 RAW 图像,然后 RAW 文件会高效地线性存储下来,每个通道 16 比特。

这意味着当你有照片的时候,你在屏幕上看到的颜色不是它们在你的硬盘里面存储的样子。同样的照片,用 gamma 曲线编码后,看起来更亮一些。这样,你在暗区域会有更强的精确度。重申一遍,你永远不会看到那样的图片,但是它是实际存储的方式。 照相机创建 JPEG 也是用这种方法。你的图片看起来漂亮一些的原因是你的硬件补偿它了。

在线性空间里完成数学 (39:38)

现在应用里面使用的色彩选择器是个有趣的例子,因为你根据你在屏幕上的所见来选择颜色。你有这个漂亮的色彩轮盘然后说,”我想要那个红色。” 你看到的色彩选择器是通过 gamma 曲线显示出来的。这样你能有效地选中一个颜色,这个颜色是被 gamma 编码或者 gamma 压缩的。

不幸的是,有些颜色选择器出错了。我不会深入谈到这个问题,但是我注意到当我使用底部可见的小滑块来选择一个灰度值的时候,Mac OS X 的颜色选择器出错了。但是对于所有的意图和目的,你可以假设你在 Photoshop 或者 Sketch 或者任何应用里选择的颜色都是经过 gamma 编码的,而不是线性颜色。这也意味着每次你在你的应用里面编写的颜色值,在你的代码里或者 XML 资源文件里,都是 gamma 编码后的值。

这很重要,因为你不是在一个线性空间里,所以如果你要对那些值做些数学操作的话,比如计算两个颜色的平均值,结果就会出错,因为你不是在线性空间中。你是在 gamma 曲线上。我们将看一个和图像相关的例子。

让我们假设你在线性曲线上。我们有一个黑色。值为零。我们有一个白色。值为一。让我们假设我们想找到均值。零和一的均值是什么?显然是 0.5。不幸的是,这是错的。因为如果你给显示器输入 0.5 的话,记住显示屏将会应用它自己的 gamma 曲线,所以值会变成 0.2,这会看起来比零和一的中间灰度值暗很多。零和一,这两个值在 gamma 曲线上是存在的。在你处理它们之前,你必须为那个 gamma 曲线做补偿。我们将看看代码的实例,这样你就知道该怎么做了。

这里是一个我们有一组梯度的例子。你可以想象这是你的应用正在产生的梯度。你创建了你的 bitmaps,然后有一个简单的 for-loop,你不时地插入颜色。让我们假设你会从红色变为绿色。如果你看那些梯度,它们看起来很棒,但是越到中间,颜色越暗。我们从第一个亮红开始。我们有亮绿。然后在中间的某点,我们变得暗了一些。这不对,因为,在亮红和亮绿之间,你只能看到亮色。原因就是我们在错误的空间里面做了线性操作。如果我们对它做些补偿,梯度看起来就会好很多。你能看到红绿之间,我们经过了一个亮紫,不再变暗了,而是绿蓝之间的某种颜色。关键是在线性空间中完成所有的数学。这么做是非常简单的。

// Gamma encoded colors
int color1 = getColor1();
int color2 = getColor2();

// Extract red and convert to linear
float r1 = ((color2 >> 16) & 0xff) / 255.0f;
r1 = (float) Math.pow(r1, 2.2);
float r2 = // …

// Do the math and gamma-encode
float r = r1 * 0.5f + r2 * 0.5f;
r = (float) Math.pow(r, 1.0 / 2.2);

这里是一段代码。我们有一组颜色。在 Android 里,它们常常被存为 int。我们将做的第一件事情就是为每个颜色抽取红色。这里有一些移位和位操作。我们想得到零和一之间的某个浮点类型。到目前为止,都很容易。现在我们有一个变量,我们要做的事情就是应用 gamma 曲线。记住颜色是编码了的,gamma 压缩了的。我们只需要应用 2.2 gamma 曲线来把它带回线性空间。我们给第二个颜色做同样的事情。然后我们能做线性数学了。这里我们仅仅找到了两个颜色的均值。当我们有结果的时候,我们重新 gamma 编码它。这是你所要做的所有的事情。你在你的应用里需要对颜色做数学操作的地方,都请这么做。结果就会好很多。一个关键点是,不要 gamma 编码 alpha 通道。

Alpha 是线性的。不幸的是,Photoshop 默认做错了,它 gamma 编码了 alpha 通道。确保你去到 Photoshop 的颜色设置,哪里有一个灰度的颜色设置。默认是设为 .20%。 选择 sGray,问题就解决了。

如果你使用 3D,你应该使用 OpenGUI。OpenGUI 有许多扩展自动解决了问题。还有一个 GPU 里的专用硬件而且是免费的。你不需要在着色器里面写任何东西。只要把你的纹理设置好就可以了。

记住这问题影响所有的事情。我们看到梯度。我们看到颜色插入。但是在你做模糊的时候,在你缩小图片的时候,在你放大图片的时候,这都起作用,缩小图片的时候计算每个像素和周围的像素的平均值是非常有效的。如果你在错误的空间里面做了那些事情,你会使得图片更暗些。动画,我们刚刚看了一个例子,3D 亮度,你用 OpenGUI 来显示,而且你来处理亮度。你应该也在线性空间里面完成这个事情。否则,你会有大的偏移,而且颜色也会看起来不正确。

如果你想知道你使用的应用是否正确处理了这个问题,你可以使用这个模式。这是一系列的条纹。他们是黑色和白色。在中间,在顶部,我们有灰色。灰度值是 128 到 255,底部是 187。 128 是 gamma 空间里面黑色和白色的平均值,187,是黑色和白色在线性空间里面的平均值。如果你把图像放到 Photoshop 或者 Chrome 或者任何你想尝试的应用里,你可以缩小图片的比例到 50%。黑色和白色的条纹应该是底部的颜色。如果它们变成顶部的颜色,那么意味着应用处理错误了。

颜色空间 (45:38)

我们说了 gamma 曲线,它们实现起来非常容易,但是事情实际上要复杂一些。问题是,颜色是什么?我们常常认为是 RGB,而且我们在代码里到处都能看到 RGB。红色是 25500,对吗?这是由用户决定的。开发者的决定是,颜色就是一组颜色模型中的多元数字,而且它们和颜色空间相关。RGB 是一个颜色模型,多元数据就是用来定义 R,G,B的三个数值。打印使用的 CMYK 也是一个色彩模型。它有四元数值。

我们差点遗忘所有 RGB 的知识。我们将谈谈 RGB。问题是,如果我告诉你我有一个 RGB 值 100 代表红色,我们谈论的是一个怎么样的红色?这是个很重要的问题,因为你看看可见光谱,这就是它的样子。颜色科学非常有意思。

光谱的出现在 1920s 年代,一组科学家随机地选择了些人,据我所知,向他们询问颜色。他们问他,如果他们是否能够区别不同的颜色,在询问了足够多的人之后,他们决定这就是我们看到的样子。问题是我们没有显示。我们没有硬件能够记录或者显示整个可见光谱。这就是为什么说你有一个 100 的红色是毫无意义的,除非你知道我们在谈论可见光谱的哪段。这就是颜色空间的内容。

你能在屏幕上看到有一些三角形在可见光谱上重叠,那些就是普通的颜色空间。你可能见过它们或者听说过它们。这叫做 sRGB。这就是典型的你的笔记本屏幕或者桌面屏幕。我们有 Adobe RGB 和 ProPhoto RGB 被广泛运用于高端摄像机或者图像处理应用,比如 Adobe Lightroom。它们比 sRGB 更宽泛。在这里 sRGB 的红色值和 ProPhot RGB 的红色值不是一个值。也不是我们眼中看到的同样的红色。

定义一个颜色空间,我们需要三个基元,一个白色点和我们最感兴趣的,转换函数。基元定义了三角的三个顶点。它们是红色,绿色和蓝色,和它们在可见光谱里面的位置。白色点简单定义了自然的颜色。你可能见过屏幕显示些感觉偏蓝或者偏黄的颜色,就是因为白色点的定义和你习惯的定义不同导致的。

颜色空间实际上不在 2D 里面。它们存在于 3D 里,所以你看到的切片是颜色空间在最小亮度下的足迹,但是如果我们引入亮度作为第三个坐标轴,你能看到色彩空间是怎样的。在 3D 空间里是非常有趣的,因为正如之前描述的那样,我们的眼睛对暗区域更加敏感。你可以通过 3D 图像来看它们。我们在暗区域有更多的数据,而在亮区域数据较少。

转换函数。它们是什么?它们和我们之前看到的 gamma 函数类似。记得住 2.2 示例和 1 除以 2.2 吗?不幸的是,它们会比实践中还要复杂一些。它们有很复杂的名字。我们将讨论光电转换函数 或者 OECF,而不是 gamma 曲线。这和我们提到 1 除以 2.2 的幂次方是等价的。它是用来把线性空间转换到 gamma 压缩空间的。转换函数也被称为电、光转换函数,或者 EOCF。每一个颜色空间都定义了这两个函数。

那么哪个颜色空间会被使用呢?你能假设的一个,特别是在安卓手机上使用的是 sRGB。这也是你桌面上每个应用默认使用的方式,除非你自己做了些事情。这也是 Web 工作的方式。他们试着修正它,因为它们开始注意到广 gamma 显示器。除非你知道你在做什么,除非你知道,否则总是假设你使用的颜色是 sRGB 空间。

这是一个真实的 sRGB 空间的方程。它是精确函数。开始的时候有些少量的线性步骤,X 乘以 12.92。这很重要,因为如果你想要最高质量的转换,记住我说的我们的眼睛对暗区域非常敏感,这就是我们在开始的时候,在非常、非常、非常暗的区域做线性函数的原因,函数的第二部分看起来很复杂,能够和 2.2 幂次表示对应起来。这就是转换函数了。

float OECF_sRGB(float linear) {
	float low = linear * 12.92f;
	float high = (pow(linear, 1.0f / 2.4f) * 1.055f) - 0.055f;
	return linear <= 0.0031308f ? low : high;
}

这是 OECF 函数的 Java 实现。你需要新写一段代码,而不是提出 1 除以 2.2 的幂次。我们今天的硬件非常高效,但是当它们处理真正的大图片或者你想它们变快的时候会非常缓慢。你有许多优化转换函数的方法。第一个方法是使用查询表。你可以提前计算。比如,如果你使用 sRGB,8 比特, 你有 256 个值。你可以提前计算一个表,可运用于 gamma 函数。当你需要解码的时候,你使用 16 bit 作为精度,因为我们在这个 gamma 解码空间中。或者你可以采用图像数学,如我之前提到的那样,这样这个巨大的,复杂的函数可以基本上比拟之前看到的 2.2 次幂。或者你想再深入一点,优化更深些,X 的 2.2 次幂差不多是 X 平方。你可以使用 X 平方来替代根平方。不要担心做些时髦的数学。

这是使用不同的近似方法计算 sRGB 转换函数的比较。蓝色的是正确的。它和 2.2 gamma 次幂几乎一样。如果我们在原来的坐标轴开始的位置就开始放大,我们就可以看到由 sRGB 函数的线性部分带来的巨大不同。你可以看到平方根近似的差距很大,但是在你的屏幕上看起来却很好。

现在,事情变得更加复杂,Android TVs 工作方式有所不同。他们不用 sRGB。他们使用 HDTV 的 Rec. 709。所以 720p 和 1080p 的内容,标准是 Rec. 709。Rec. 709 使用与 sRGB 相同的基元和白色点,所以颜色差不多是一样的。唯一不同的是转换函数。它们也是非常复杂的,但是一个近似就是 2.4 的 gamma 曲线。如果你给 Android TV 编写应用程序,而且你做了图像处理相关的事情或者你插入了颜色,你可能希望能更进一步,使用稍微不同的 gamma 曲线来计算,这样可以使你的颜色在 TV 上看起来更棒些。

UltraHD TV, 4k 显示屏,和 8k 显示屏采用了另外一个颜色空间叫做 Rec. 2020,它和 Rec. 709 有着类似的转换函数。你使用起来很容易,但是色彩空间是不一样的。这里是一个不同色彩空间的比较。在屏幕上,你可以看到 sRGB 和 Rec. 709。唯一不同就是 gamma 曲线。这就是为什么它们相比而言,其中一个看起来却反过来了。这里的大三角是 Rec. 2020。这真的是一个很大很大的空间。这意味着我们需要做的事情不只是正确地应用 gamma 曲线。事实上,它太大了,以至于用来画图的数学应用在右下侧不正确了。这就是数学常说的,”数学很难。” 重要的是 Rec. 2020 颜色空间,所有的颜色,都是在可见光谱里的。

结论 (54:38)

所以我之前说你所做的是错误的。好消息是 Android 现在所做的也是错误的。每一处,全部地方。我可以给你一些理由,比如我们开始的时候设备很差,它们的 CPU 很差,我们不可能完成所有的转换。

所以,我们哪做错了?

  • 梯度。它们在 Android 上是错误的。
  • 动画。我们在 N 上修正了它,所以比以前好多了。
  • 改变 bitmaps 的大小。是错的。
  • 混合式错的。
  • 平滑是错误的。

我希望有一天我能够看一遍所有的代码,然后修正所有的我们在 bitmaps 调整大小时候的错误,然后让它看起来好点。

让我们回顾下。你应该做什么?你有一个输入的颜色。假设它是 sRGB。应用相反的转换函数;X 升高到 2.2 的幂次方。你就会有一个线性空间。然后你完成数据操作。当你结束数学运算的时候,你做 gamma 编码。你重新压缩回 gamma 空间,然后你可以把它发送到显示屏上,所有的一切就正常了。

About the content

This talk was delivered live in July 2016 at 360 AnDev. The video was recorded, produced, and transcribed by Realm, and is published here with the permission of the conference organizers.

Chet Haase

Chet is also an engineer at Google. He is the lead of the Android UI Toolkit team, where he works on animation, graphics, UI widgets, and anything else that helps create better Android user interfaces. He’s also been known to write and perform comedy.

Romain Guy

Romain is an engineer at Google. He worked on the Android Framework team from Android 1.0 to 5.0 before joining the world of Robotics. He is now back to Android, working on new UI and graphics related projects.

4 design patterns for a RESTless mobile integration »

close