本文描述了代码优化在为移动设备写运行起来速度快的游戏中扮演的角色。我会用例子说明如何、什么时候和为什么要优化你的代码,来榨干兼容MIDP的手机的每一滴性能。我们将要讨论为什么优化是必要的和为什么有时候最好不要优化。我将解释高级优化和低级优化的差别,然后我们会知道如何使用J2ME无线开发包(WTK)自带的Profile程序来发现到哪里去优化你的代码。这篇文章最后揭示了很多让你的MIDlet运行的技术。
为什么优化?
计算机游戏可以分为两大类: 实时的和输入驱动的。 输入驱动的游戏显示游戏的当前运行状态,并在继续之前无限地等待用户的输入。
扑克牌游戏就属于这一类,同样,大多数的猜谜游戏、过关游戏和文字冒险游戏都属于这一类。实时游戏,有时候被称为技能或动作游戏,不等待用户,他们不停地运行直到游戏结束。
技能和动作游戏经常以大量的屏幕上运东为特征(想想Galaga游戏和Robotron游戏)。刷新率必须至少有10fps(每秒的帧数)并且要有足够的动作来保持玩家的挑战性。它们需要玩家快速的反应和好的手眼配合,所以就强迫S&A(技能和动作)游戏必须对玩家的输入有很强的响应能力。在快速响应玩家案件的同时提供高帧数的图形动作,这是实时游戏的代码必须运行起来快的原因。在用J2ME开发的时候,挑战性就更大了。
Java 2 Micro Edition(J2ME)是java的一个分解版本。 适用于有限功能的小型设备,比如手机和PDA.J2ME设备有:*有限的输入能力(没有键盘!) *小的显示尺寸*有限的内存容量和堆大小*慢速的CPU在J2ME平台上写出快的游戏――写出在比桌面电脑里的慢得多的CPU上运行的代码更是挑战了开发者。
什么时候不优化如果你不是在写一个技能或者动作游戏,那么可能不需要优化。如果玩家已经为自己的下一步考虑了几秒钟抑或几分钟,她可能不会介意如果你的游戏响应花掉了几百微秒。这个规则的一个例外是,如果这个游戏在决定下一步如何运行的时候有大量的工作要处理,比如搜索一百万个可能的象棋片组合。这种情况下,你可能想要优化你的代码,从而在几秒钟内计算出电脑的下一步,而不是几分钟。
就算你正在写这种类型的游戏,优化也可能是危险的。许多这样的技术伴随着一个代价――他们表示着好“的程序设计这个通常概念飞过来的时候,同时使你的代码更难读懂。有些是一个权衡,需要开发者大大增加程序的大小来得到性能上一点点的改进。J2ME开发者们对于保持他们的JAR尽可能的小这个挑战再熟悉不过了。这里是一些不优化的理由:*优化是一个增加bug的好手*有些技术会降低你的代码的移植性*你可能要花费大量的努力来得到微小的或者没有改进*优化是困难的最后一点需要一些阐述。优化是一个活动目标,在Java平台上更是这样,而且在J2ME上就更加突出,因为其运行环境是那样的多变。
你优化后的代码可能在一个模拟器上运行得更快,但却在实际设备上更慢,或者相反。为一部手机优化可能会降低其在另一部上的性能
不过还是有希望。有两条路径你可以做优化,高层的和底层的。第一条基本上会在所有的平台上增加执行性能,甚至会改进你代码的整个质量。第二条是可能会让你头疼的,但是那些底层技术是很容易创造的,而且更加容易消去如果你不想使用它们。最起码,他们看起来很有趣。
我们将用系统的timer在实际设备上剖析你的代码,这可以帮助你测量出那些技术在你所开发的硬件上到底有多有效。
最后一点:*优化是有趣的
一个反面例子:让我们来看一看这个包含两个类的简单的应用程序,首先,是Midlet……
import javax.microedition.midlet.*; import javax.microedition.lcdui.*; public class OptimizeMe extends MIDlet implements CommandListener { private static final boolean debug = false; private Display display; private OCanvas oCanvas; private Form form; private StringItem timeItem = new StringItem( "Time: ", "Unknown" ); private StringItem resultItem = new StringItem( "Result: ", "No results" ); private Command cmdStart = new Command( "Start", Command.SCREEN, 1 ); private Command cmdExit = new Command( "Exit", Command.EXIT, 2 ); public boolean running = true; public OptimizeMe() { display = Display.getDisplay(this); form = new Form( "Optimize" ); form.append( timeItem ); form.append( resultItem ); form.addCommand( cmdStart ); form.addCommand( cmdExit ); form.setCommandListener( this ); oCanvas = new OCanvas( this ); } public void startApp() throws MIDletStateChangeException { running = true; display.setCurrent( form ); } public void pauseApp() { running = false; } public void exitCanvas(int status) { debug( "exitCanvas - status = " + status ); switch (status) { case OCanvas.USER_EXIT: timeItem.setText( "Aborted" ); resultItem.setText( "Unknown" ); break; case OCanvas.EXIT_DONE: timeItem.setText( oCanvas.elapsed+"ms" ); resultItem.setText( String.valueOf( oCanvas.result ) ); break; } display.setCurrent( form ); } public void destroyApp(boolean unconditional) throws MIDletStateChangeException { oCanvas = null; display.setCurrent ( null ); display = null; } public void commandAction(Command c, Displayable d) { if ( c == cmdExit ) { oCanvas = null; display.setCurrent ( null ); display = null; notifyDestroyed(); } else { running = true; display.setCurrent( oCanvas ); oCanvas.start(); } } public static final void debug( String s ) { if (debug) System.out.println( s ); } } Second, the OCanvas class that does most of the work in this example... import javax.microedition.midlet.*; import javax.microedition.lcdui.*; import java.util.Random; public class OCanvas extends Canvas implements Runnable { public static final int USER_EXIT = 1; public static final int EXIT_DONE = 2; public static final int LOOP_COUNT = 100; public static final int DRAW_COUNT = 16; public static final int NUMBER_COUNT = 64; public static final int DIVISOR_COUNT = 8; public static final int WAIT_TIME = 50; public static final int COLOR_BG = 0x00FFFFFF; public static final int COLOR_FG = 0x00000000; public long elapsed = 0l; public int exitStatus; public int result; private Thread animationThread; private OptimizeMe midlet; private boolean finished; private long started; private long frameStarted; private long frameTime; private int[] numbers; private int loopCounter; private Random random = new Random( System.currentTimeMillis() ); public OCanvas( OptimizeMe _o ) { midlet = _o; numbers = new int[ NUMBER_COUNT ]; for ( int i = 0 ; i < numbers.length ; i++ ) { numbers[i] = i+1; } } public synchronized void start() { started = frameStarted = System.currentTimeMillis(); loopCounter = result = 0; finished = false; exitStatus = EXIT_DONE; animationThread = new Thread( this ); animationThread.start(); } public void run() { Thread currentThread = Thread.currentThread(); try { while ( animationThread == currentThread && midlet.running && !finished ) { frameTime = System.currentTimeMillis() - frameStarted; frameStarted = System.currentTimeMillis(); result += work( numbers ); repaint(); synchronized(this) { wait( WAIT_TIME ); } loopCounter++; finished = ( loopCounter > LOOP_COUNT ); } } catch ( InterruptedException ie ) { OptimizeMe.debug( "interrupted" ); } elapsed = System.currentTimeMillis() - started; midlet.exitCanvas( exitStatus ); } public void paint(Graphics g) { g.setColor( COLOR_BG ); g.fillRect( 0, 0, getWidth(), getHeight() ); g.setColor( COLOR_FG ); g.setFont( Font.getFont( Font.FACE_PROPORTIONAL, Font.STYLE_BOLD | Font.STYLE_ITALIC, Font.SIZE_SMALL ) ); for ( int i = 0 ; i < DRAW_COUNT ; i ++ ) { g.drawString( frameTime + " ms per frame", getRandom( getWidth() ), getRandom( getHeight() ), Graphics.TOP | Graphics.HCENTER ); } } private int divisor; private int r; 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; } private int a; public synchronized int getDivisor( int n ) { if ( n == 0 ) return 1; a = 1; for ( int i = 0 ; i < n ; i++ ) { a *= 2; } return a; } public synchronized int workMore( int[] n, int _i, int _d ) { return n[_i] * n[_i] / _d + n[_i]; } public void keyReleased(int keyCode) { if ( System.currentTimeMillis() - started > 1000l ) { exitStatus = USER_EXIT; midlet.running = false; } } private int getRandom( int bound ) { // return a random, positive integer less than bound return Math.abs( random.nextInt() % bound ); } } |
这个程序是一个模拟一个简单游戏循环的MIDlet:
*work 执行
*draw 绘制
*poll for user input 等待用户输入
*repeat 重复对于快速游戏
这个循环一定要尽可能的紧凑和快速。我们的循环持续一个有限的次数(LOOP_COUNT=100),并且用系统timer来计算整个作业花费了多少毫秒,我们就可以测量并改善它的性能。时间和执行的结果会显示在一个简单的窗口上。用Start命令来开启测试。按任意键会提前退出循环,退出按钮用来结束程序。 在大多数游戏里面,主游戏循环中的作业会更新整个游戏状态――移动所有的角色,检测并处理冲突,更新分数,等等。在这个例子里面,我们并没有做什么特别有用的事。程序仅仅是在一个数组之间做一些算数运算,然后把这些结果加起来。 run()函数计算了每次循环所花费的时间。每一帧,OCanvas.paint()方法都在屏幕上的16个随机的地方显示这个数。一般的,你可以用这个方法在你的游戏里面画出你的图像元素,我们的代码在该过程中作了一些有用的摹写。
不管这些代码看起来有多么的无意义,它给了我们足够的机会去优化它的性能。
哪里去优化 ―― 90/10规则在苛求性能的游戏里面,有90%的时间是在执行其中%10的代码。我们的优化努力就应该针对这10%的代码。我们用一个Profier来定位这 10%. 要运行J2ME无线开发包中的profier工具,选择edit菜单下的preferences选项。 这将会显示preferences窗口。选择Monitoring这一栏,将"Enable Profiling"悬赏,然后点ok按钮。什么也没有出现。这是对的,在Profier窗口显示之前,我们需要在模拟器中运行我们的程序然后退出。现在就做。
我的模拟器(运行在Windows XP下,Inter P4 2.4GHz的CPU)报告我100次这个循环用了6,407毫秒,或者说6又1/2秒。这个程序报告说62或者63毫秒每帧。在硬件(一个 motorola的i85s)上运行会慢得多。 一帧的时间大约是500毫秒,整个循环用了52460毫秒。
当你退出这个程序时,profiler窗口就会出现,然后你会看见一个文件夹浏览器中有一些东西,在左边的面板上会有一个熟悉的树形部件。方法间的联系会在这个结构列表中显示。每一个文件夹是一个方法,打开一个文件夹会显示它所调用过的方法。在该树中选择一个方法会显示那个方法的profiling信息并在右边的面板显示所有被它调用过的方法。注意在每一个元素旁边显示了一个百分数。这就是该方法在整个执行过程中所占的执行时间的百分比。我们必须翻遍这棵树,来寻找时间都到哪里去了,并对占用百分比最高的方法进行优化,如果可能的话。
对这个profiler,有几点需要说明。首先你的百分比多半会和我的不一样,但是他们的比例会比较相似――总是在最大的数之后。我的数据在被次运行的时候都会改变。为了保持情况一致,你可能希望关掉所有的后台程序,像Email客户端,并在你测试的时候保持你正在进行的任务最少。还有,不要在用profiler之前混淆(obfuscate)你的代码,不然你的方法会被神秘的标示为b或者a或者ff.最后profiler不会因为你运行模拟器的设备的差别而改变,它和硬件是完全独立的。
打开最高百分比的那个文件夹,我们看到有66.8%的时间在执行一个被称为 "com.sun.kvem.midp.lcdui.EmulEventHandler$EventLoop.run"的方法,这个对我们并没有什么帮助。用类似的方法,再往下寻找更深层次的方法,持续下去,你就会找到一个大的百分比停留在serviceRepaints()上,最后到了我们的 OCanvas.paint()方法。另外有30%的时间在OCanvas.run()方法里。这两个方法都在我们的主程序循环中,这并不奇怪。我们不会在我们的MIDlet类中花任何时间做优化,同样地我们不会对游戏的主循环外的任何代码做优化。
在我们的例子程序中的百分比的划分在真实的游戏中并不是完全的没有特性。 你多半会在一个真实的视觉游戏中发现这个大的执行时间的比例是在paint()方法中。 相比于非图形化程序,图形化程序总是要花很长的时间。 不幸的是,我们的图形程序已经被写在了J2ME API这一层下,对于改善它们的性能,我们没有多少可以做的。我们可以做的是在用哪个和如何用它们之间做出聪明的决定。