使用 Java 实现无闪烁动画

作者:Paul Buchheit

如果您在过去一年中醒着,那么您可能听说过 Java。如果您使用最新版本的 Netscape 浏览器浏览万维网,那么您甚至可能见过一两个 Java 小程序(applet)。Java 小程序是一种 Java 程序,它与 Web 浏览器 的安全限制交互并受其约束。动画显示和游戏是小程序最早和最流行的用途之一,当然不是唯一的用途。您可能已经注意到,尽管 Java 动画很流行,但许多小程序并没有很好地实现动画效果。

为了说明 Java 编程和动画技术的入门知识,我用 Java 编写了一个非常简单的老虎机,其唯一的移动部件是一个单独的旋转轮。首先,我将展示一个使用最简单(但不幸的是也是最流行)的动画方法的老虎机。这个老虎机看起来不会很好,但让它看起来更好并不难,我将介绍几种改进它的方法。本文中描述的老虎机的源代码、图像和实际版本可在 k2.cwru.edu/~ptb/lslot/ 找到。我建议您在阅读本文时尝试使用它们。

这个老虎机由两个图像组成。第一个图像——老虎机的主体——实际上只是一个装饰,所以我选择了我们的好朋友和潜在的 Linux 吉祥物 Tux。第二个图像实际上是一条图像带,构成了老虎机轮盘的表面(图 2)。我们可以分别加载每个图像,但这可能会减慢加载过程并使代码复杂化。这些图像在 init() 方法中通过调用超类 Applet 中定义的 getImage() 方法加载。

如果您了解 C++(或 Java),您可能会注意到 init() 函数很像构造函数,即它用于初始化新创建对象的变量。但是,一个很大的区别是,构造函数在类被实例化时调用,而 init() 在小程序的主机准备好初始化小程序时调用。将上面讨论的 getImage() 代码从 init() 移动到构造函数可能会导致它崩溃。

当小程序的主机最终决定显示小程序时,它将调用 paint()。查看 paint(),您应该注意到它传递了一个 Graphics 类型的变量 g,您将使用这个变量 g 进行绘图。事实上,小程序所做的所有绘图都是通过 Graphics 的某个实例进行的。paint() 的第一行,

g.drawImage(body, 0, 0, this);

将在位置 0, 0 绘制图像 bodydrawImage() 的第四个参数 this 指定了一个 ImageObserverthis 只是对当前对象的引用。

ImageObserver 是任何实现了 java.awt.image.ImageObserver 接口的类的实例,这意味着它已被声明为实现 ImageObserver 并且具有 imageUpdate() 方法。在以下示例中,类 Foo 的实例将是一个有效的 ImageObserver

class Foo extends Object implements ImageObserver
{        ...
        public boolean imageUpdate(Image img, int flags,
          int x, int y, int w, int h) {
                ...
                return true;
        }
}

接口提供了一种有限但安全且可用的多重继承形式。幸运的是,超类 Applet 的祖先 java.awt.Component 已经是一个 ImageObserver,因此这里不需要编写任何代码。

如果图像尚未完全加载,系统将记录 ImageObserver。稍后,当更多但并非所有数据都准备就绪时,将调用 imageUpdate()。默认情况下,小程序的 imageUpdate() 方法将调用 repaint(),这会导致“视觉加载”效果,即图像在数据加载时被绘制。

paint() 接下来要做的是创建一个新的 Graphics,专门用于在老虎机上绘制轮盘。这是通过调用我添加的 createForWheel() 方法来完成的。如前所述,所有绘图都是使用 Graphics 完成的,并且每个 Graphics 在屏幕上或内存中的某个位置都有一个矩形,它可以绘制到该矩形。如果诸如 drawImage() 之类的命令涉及在该矩形外部进行绘制,则会裁剪超出矩形的部分。如果我们只想显示图像的一部分,这非常有用。

createForWheel() 方法通过调用传递给 paint()Graphicscreate() 方法来创建新的 Graphics。四个整数被传递给 create(),它们指定新 Graphics 的矩形的位置和大小——在本例中,是轮盘的位置和大小。现在我们有了这个新的 Graphics,我们可以做这样的事情:

drawImage(strip, 0, -55, this)

即使 strip 超过 500 像素高,并且指定的坐标将其放在左上角,但在为轮盘指定的位置,也会显示一个漂亮的 55 x 55 像素的正方形(创建 Graphics 时指定的大小),该正方形显示图像 strip 的第 55 行到第 110 行。

一旦不再需要新的 Graphics,就应该调用它的 dispose() 方法。这可能看起来有点奇怪,因为 Java 具有自动垃圾回收(意味着内存不必像在 C 或 C++ 中那样显式释放)。无论如何调用 dispose() 的原因是垃圾回收不是立即的,并且 Graphics 可能占用有限的系统资源。

像大多数老虎机一样,这个老虎机的轮盘不会一直旋转。事实上,它的轮盘仅在响应鼠标按钮单击时旋转,并且仅旋转很短的时间。查看 mouseDown() 方法,您可以看到它只是创建了一个名为“spinning”的新 Thread。当线程启动时,它会调用线程构造函数中指定的 Runnable(另一个接口)对象的 run() 方法。在本例中,对象是这个小程序,this

老虎机令人兴奋的旋转动作在 run() 中实现。run() 首先要做的是询问 getNewItem() 它要去哪里。getNewItem() 方法只是返回一个从 0 到 5 的随机数,指定轮盘上的停止项。然后,run() 方法计算轮盘必须移动多少像素才能到达那里,包括在轮盘停止之前应该旋转过去的项目的数量。之后,run() 只是循环直到轮盘到达目的地。计算新位置,重绘,休眠,重复。完成后,run() 只需将 spinning 设置为 null,以便再次单击鼠标按钮可以启动轮盘旋转,并且 run() 返回。

试试这个小程序!看起来很糟糕,不是吗?可能最明显的缺陷是轮盘旋转时出现的难看的灰色闪烁。幸运的是,这个问题很容易修复——每次调用 repaint() 时,系统都会异步调用 update()update() 是一个继承的方法(一直定义在 java.awt.Component 类中),它使用背景颜色绘制一个与小程序大小相同的矩形,然后调用 paint()。这个矩形绘制是大多数闪烁的根源——因为我们将立即在小程序的整个区域上绘制,所以它不仅令人讨厌而且是不必要的。要修复闪烁,只需在 createForWheel() 方法和 paint 方法之间的空间中插入以下方法(参见 列表 1

public void update(Graphics g) {
        paint(g); }

现在运行这个小程序——看起来是不是好多了?但是,如果您仔细观察,您可能会注意到轮盘有轻微的黑色闪烁,并且动画有点粗糙。这个问题与前一个问题类似——老虎机的主体,在轮盘应该在的位置只有一个黑色正方形,在轮盘图形之前被绘制。因此,在瞬间没有轮盘。解决这个问题的一个流行方法是双缓冲:使用屏幕外缓冲区来保存正在绘制的图像。现在,隐藏在轮盘后面的主体部分将永远不会出现。

要向我们的小程序添加双缓冲,您必须做的第一件事是创建一个缓冲区,称为(非常恰当地)buffer。接下来,在类中添加一个名为 bufferImage 实例变量,并在 init() 方法中插入行

buffer = createImage(size().width, size().height);

现在必须修改 paint(),以便它首先绘制到缓冲区中,然后将缓冲区本身绘制到屏幕上。这个新的 paint() 应该看起来像这样

public void paint(Graphics g) {
       Graphics bufG = buffer.getGraphics();
       bufG.drawImage(body, 0, 0, this);
       Graphics clipG = createForWheel(bufG);
       drawWheel(clipG, currentWheelPos);
       clipG.dispose();

       g.drawImage(buffer, 0, 0, this);
}

现在运行这个小程序——看起来好多了,不再闪烁了!但是,当只有一小部分在变化时,重绘小程序窗口的整个区域似乎非常低效,不是吗?另一个简单的修复方法!只需更改在 run() 中找到的行

repaint();

改为

repaint(wheelPosX, wheelPosY, wheelSize, wheelSize);

这个新的调用告诉 AWT 系统只更新指定的矩形,而让窗口的其余部分保持不变。

似乎像以前一样,我们绘制老虎机主体的频率高于需要,只是这次它是绘制到缓冲区而不是屏幕。可以制定一个方案,只将轮盘重绘到缓冲区,以解决这个抱怨,但是,您可能已经意识到,有一种更好的方法。

这个缓冲区很愚蠢,不仅开始变得复杂,而且在内存中存储一些大的 Image 也是一种巨大的浪费,特别是对于更复杂的小程序(例如一个大的、复杂的老虎机)。缓冲区图像有它们的用途,但缓冲整个小程序很少是一个好主意。为什么不干脆忘记使用 repaint,而是在 run() 内部直接绘制旋转轮盘呢?好主意。回到我们在添加缓冲区之前的代码,按如下方式修改 run()

public void run() {
        // Gets something to spin to.
        int nextItem = getNewItem();
        int pos = currentWheelPos;
        int finalPos = (itemsToSpin + nextItem) *
                wheelSize;
        Graphics g = createForWheel(getGraphics());
        while((spinning != null) && (pos != finalPos))
      {
                pos = findNextPos(pos, finalPos);
                currentWheelPos = pos % stripLen;
                drawWheel(g, currentWheelPos);
                getToolkit().sync();
                try {
                        Thread.sleep(delay);
                } catch(InterruptedException e) { }
        }
        g.dispose();
        spinning = null;
}

现在我们只是获取绘制轮盘所需的 Graphics,并在每次移动轮盘时直接调用 drawWheel()。这里的技巧是在我们希望绘图出现时调用

getToolkit().sync();

sync()。如果没有调用 sync(),系统将等待多个绘图请求到达,从而导致动画跳跃。

最后,老虎机完成了!我想您可以看到,尽管代码与第一个老虎机的代码几乎相同,但生成的动画要流畅得多。

对于更复杂的动画,您可能希望使用此处介绍的方法的组合。例如,假设您想创建一个盒子,里面有两个球在弹跳。在大多数情况下,您只需为每个球使用一个 drawImage(),但是如果两个绘图重叠会发生什么?您可能会在交叉区域中遇到闪烁。一种解决方案是在两个图像相交时对绘制进行双缓冲。

像往常一样,如果您在小程序的某个方面遇到困难,您可以(希望)找到一个执行类似操作的小程序并查看源代码。最大的 Java 小程序集合可以在 Gamelan (http://www.gamelan.com/) 找到,您还应该在那里找到 Java Cup International 获奖者的链接。另一个查找小程序的好地方是 Java Applet Rating Service (JARS),网址为 www.jars.com/。顾名思义,Java Applet Rating Service 根据包括代码质量(如果代码是免费提供的)在内的许多因素对小程序进行评级。

Paul Buchheit (ptb@po.cwru.edu) 是凯斯西储大学的一名“囚犯”。当他不忙于睡觉时,他就醒着。

加载 Disqus 评论