Tuesday, March 14, 2006

J2ME 游戏优化探密(三)

循环之外?

for() 循环内的代码,循环多少次,它们就被执行多少次。因此,为了改进性能,我们需要尽可能的把代码放在循环之外。我们在描述器中会发现我们的 paint() 方法被调用了 101 次,并且循环执行了 16 次。我们可以把哪些东西放在循环之外呢?让我们从所有的声明(declaration)开始。每次调用 paint() 方法,我们都声明了一个 Font,一个 String,一个 Image 以及一个 Graphics 对象。我们可以将这些从该方法中挪出来,放到类的顶部。
public static final Font font =
Font.getFont( Font.FACE_PROPORTIONAL,
Font.STYLE_BOLD Font.STYLE_ITALIC,
Font.SIZE_SMALL);
public static final int graphicAnchor =
Graphics.VCENTER Graphics.HCENTER;
public static final int textAnchor =
Graphics.TOP Graphics.LEFT;
private static final String MESSAGE = " ms per frame";
private String msMessage = "000" + MESSAGE;
private Image stringImage;
private Graphics imageGraphics;
private long oldFrameTime;

你会发现我把 Font 对象设置成为共有的常量。这在程序中通常很有用,比如这里你可以把常用到的字体在同一个地方集中定义。我发现锚(anchor)也是一样,因此对文字以及图形的锚也做了同样的处理。预先计算这些东西,保证了计算的结果,虽然微不足道,但是我们将它们从循环中挪了出来。

我同样把 MESSAGE 也设置成了常量。这是因为 Java 总是喜欢到处创建 String 对象。如果不控制好,这些字符串将消耗大量的内存。别不以为然,当你过多的消耗内存,将转而影响整体性能,尤其当垃圾收集器被频繁调用的时候。字符串制造垃圾,而垃圾是糟糕的;而使用字符串常量能缓解这个问题。稍后我们会讲到如何利用 StringBuffer 来完全解决由字符串泛滥带来的内存损耗。

现在我们将这些东西变成了实例变量(instance variables),我们需要在构造方法上添加如下代码:
stringImage = Image.createImage( font.stringWidth( msMessage ),
font.getBaselinePosition() );
imageGraphics = stringImage.getGraphics();
imageGraphics.setFont( font );

Graphics 对象提出来,另一点很爽的地方在于,我们只需要对字体设置一次,然后就可以完全不再去管它;而不用每次循环的时候都来设置它。不过仍然每次都需要调用 fillRect() 方法来擦去图像上的内容。编码狂人们可能会想要这样做,从同一个 Image 创建两个 Graphics 对象,然后将其中一个的颜色预设为 COLOR_BG 用来调用 fillRect(),另一个设置为 COLOR_FG 用来调用 drawString()。很遗憾,J2ME 中,对同一个 Image 多次调用 getGraphics() 方法的行为,定义的并不好,不同的平台会有不同的结果,因此你优化的结果可能对 Motorola 有效但 NOKIA 却不行。当无法确定的时候,不要做任何无根据假设。

还有另一个改进 paint() 方法的途径。动动我们的脑子我们会意识到,仅当 frameTime 的值发生改变的时候我们才需要重绘该字符串。这也就是我们引入新的变量 oldFrameTime 的原因。以下是新的方法:
public void paint(Graphics g) {
g.setColor( COLOR_BG );
g.fillRect( 0, 0, getWidth(), getHeight() );
if ( frameTime != oldFrameTime ) {
msMessage = frameTime + MESSAGE;
imageGraphics.setColor( COLOR_BG );
imageGraphics.fillRect( 0, 0, stringImage.getWidth(),
stringImage.getHeight() );
imageGraphics.setColor( COLOR_FG );
imageGraphics.drawString( msMessage, 0, 0, textAnchor );
}
for ( int i = 0 ; i < DRAW_COUNT ; i ++ ) {
g.drawImage( stringImage, getRandom( getWidth() ),
getRandom( getHeight() ), graphicAnchor );
}
oldFrameTime = frameTime;
}

描述器现在显示 OCanvaspaint() 方法所花费的时间占总时间的百分率已经下降到了 41.02%。相比以前由 drawString()fillRect() 引发的 101 次 paint() 来讲,现在只调用了 69 次。这是相当不错的改进,而且可以继续的空间不多了,这也是我们该认真起来的时候。优化总是越来越难。现在我们要刮掉循环中最后一点冗余的代码。我们可能只能再削减非常少的百分率,但是如果幸运的话,还是可以得到一些显著的改进。

让我们先从简单的开始。相比起调用 getHeight()getWidth() 方法,我们可以只调用它们一次,然后把结果缓存在循环之外。然后我们要停止使用 String,而用 StringBuffer 来手动地处理。我们可以通过调用 Graphics.setClip() 限制绘制区域来减少 drawImage() 调用所花费地时间。最后,我们要避免在循环中调用 java.util.Random.nextInt()

以下是新的变量……
private static final String MESSAGE = "ms per frame:";
private int iw, ih, dw, dh;
private StringBuffer stringBuffer;
private int messageLength;
private int stringLength;
private char[] stringChars;
private static final int RANDOMCOUNT = 256;
private int[] randomNumbersX = new int[RANDOMCOUNT];
private int[] randomNumbersY = new int[RANDOMCOUNT];
private int ri;

以下是新的构造方法……
iw = stringImage.getWidth();
ih = stringImage.getHeight();
dw = getWidth();
dh = getHeight();
for ( int i = 0 ; i < RANDOMCOUNT ; i++ ) {
randomNumbersX[i] = getRandom( dw );
randomNumbersY[i] = getRandom( dh );
}
ri = 0;
stringBuffer = new StringBuffer( MESSAGE+"000" );
messageLength = MESSAGE.length();
stringLength = stringBuffer.length();
stringChars = new char[stringLength];
stringBuffer.getChars( 0, stringLength, stringChars, 0 );

你会发现我们预先计算了 Display 和图像的区域,还缓存了对 getRandom() 方法的 512 次调用,并用 StringBuffer 取代了 msMessage 字符串。当然,最大的目标还是 paint() 方法:
public void paint(Graphics g) {
g.setColor( COLOR_BG );
g.fillRect( 0, 0, dw, dh );
if ( frameTime != oldFrameTime ) {
stringBuffer.delete( messageLength, stringLength );
stringBuffer.append( (int)frameTime );
stringLength = stringBuffer.length();
stringBuffer.getChars( messageLength,
stringLength,
stringChars,
messageLength );
iw = font.charsWidth( stringChars, 0, stringLength );
imageGraphics.setColor( COLOR_BG );
imageGraphics.fillRect( 0, 0, iw, ih );
imageGraphics.setColor( COLOR_FG );
imageGraphics.drawChars( stringChars, 0,
stringLength, 0, 0, textAnchor );
}
for ( int i = 0 ; i < DRAW_COUNT ; i ++ ) {
g.setClip( randomNumbersX[ri], randomNumbersY[ri], iw, ih );
g.drawImage( stringImage, randomNumbersX[ri],
randomNumbersY[ri], textAnchor );
ri = (ri+1) % RANDOMCOUNT;
}
oldFrameTime = frameTime;
}

我们现在用 StringBuffer 来绘制消息的字符。在 StringBuffer 结尾添加字符,比在开头插入要容易,因此我们调整了显示文字,frameTime 放到了消息的结尾,即“ms per frame:120”。这样我们每次只是更改 frameTime 的几个字符,而消息的文字则原封不动。像这样使用 StringBuffer,能够让系统不至于在每次循环的时候都要创建和销毁 String 对象。这是额外的工作,但是值得。注意我把 frame 强转成了 int,因为我发现使用 append(long) 会导致内存泄漏。我不知道为什么,不过这是很好的一个例子说明为什么在使用工具的时候要用眼睛盯好。

我们还用了 font.charsWidth() 来计算消息图像的宽度,这样我们能够尽量少做绘制的工作。我们使用了一个等宽的字体,因此图像“ms per frame:1”会比“ms per frame:888”小,而我们又在使用 Graphics.setClip(),因此我们不需要绘制多余的部分。这也意味着我们需要绘制一个足够大的矩形来清空我们需要的区域。当然,我们希望在绘图上节省的时间比因调用 font.charsWidth() 而多花的时间要多。

可能这里并不会有太大的意义,但是对于在游戏中在屏幕上绘制玩家的分数来讲,这是一项非常重要的技术。在这个情况下,绘制分数 0 和 150,000,000 是很不一样的。这里受到了 font.getBaselinePosition() 实现的错误返回值的阻碍,因为本来应该返回和 font.getHeight() 相同的值。唉~!

最后我们来看一下用两个数组存放的预计算的“随机”坐标,使得我们不用在循环中进行随机调用。注意这里用了一个取模操作来实现闭合的数组。同时注意我们现在使用 textAnchor 来绘制图像和字符串,这样 setClip() 才能正确工作。

根据这个版本的代码所产生的数字,我们目前处于一个比较尴尬的境地。描述器告诉我,现在 paint() 方法比没有做这些改变时,多花大约 7% 的时间。这可能得怪对 font.charsWidth() 的调用,因为它占用了 4.6%。(这并不太多,但是应该可以被减少。注意我们每次都在取得 MESSAGE 字符串的宽度,实际上我们可以在循环体之前就轻易的计算出,然后再加上 frameTime 的宽度。)同时,对 setClip() 的调用标示的是 0.85%,而花在 drawImage 上的时间看起来增加的比较显著(从 27.58% 到 33.94%)。

基于这一点,看起来当然是这些增加的代码把我们的程序变慢了,但是程序生成的值却和这个假设相矛盾。模拟器上的图表很不稳定,因此如果没有更多的测试我们无法得到确定的结论。然而,我的 i85s 却报告说,加上这些代码后,程序变快了一点点。只调用 setClip() 或者 charsWidth(),结果为 37130ms,而两个都调用得话,结果是 36540。在我耐心能够坚持的情况下,我把这个测试运行了很多遍,而结果是相同的。这也再次强调了不同的运行环境会有不同的结果。当你到了某一点无法确定是否做了改进的时候,你可能被迫在硬件上继续所有的测试,这需要对 JAR 文件进行很多安装和卸载工作。

那么看起来我们从常规的图形操作上挤出了不少的性能。现在是时候对我们的 work() 方法实施高端和低端优化的时候了。先让我们来复习一下这个方法:
public synchronized int work( int[] n ) {
r = 0;
for ( int j = 0 ; j < DIVISOR_COUNT ; j++ ) {
for ( int i = 0 ; i < n.length ; i++ ) {
divisor = getDivisor(j);
r += workMore( n, i, divisor );
}
}
return r;
}

每次进入循环的时候,我们都会传入我们的数组。work() 方法中外层的循环计算我们的除数(divisor),然后调用 workMore() 来实施除法。可能已经发现,这里整件事情都有问题。开始的时候,编程的把对 getDivisor() 的调用放到了内层循环。由于 j 的值在内层循环过程中并不发生改变,因此除数也不发生变化,这完全应该放在内层循环之外。

如果我们再多考虑一点。这个调用本身也是完全没有必要的。以下代码可以做同样的事情……
public synchronized int work( int[] n ) {
r = 0;
divisor = 1;
for ( int j = 0 ; j < DIVISOR_COUNT ; j++ ) {
for ( int i = 0 ; i < n.length ; i++ ) {
r += workMore( n, i, divisor );
}
divisor *= 2;
}
return r;
}

现在我们的描述器报告说我们的 run() 方法花费 23.72% 的时间,而我们做这些改进之前,这个值为 38.78%。在陷入低端优化把戏之前,请总是先用头脑来优化。不过,说到这里,还是让我们来看看这些小把戏。

(一) (二) (三) (四) (五)