Implementasi Game/Animation Loop di Android

Bermula dari pertanyaan mahasiswa, ternyata setelah cukup lama saya mencari belum ada penjelasan yang menurut saya enak mengenai loop untuk animasi atau game berbasis canvas (game loop). Akhirnya saya coba sendiri menggunakan dasar contoh LunarLander yang merupakan bagian dari SDK samples dengan lumayan banyak modifikasi.

Game loop pada intinya adalah:

while (true) {
	updatePosisiObjek();
	gambarObjek();
}

Contoh berikut merupakan implementasi loop untuk animasi sederhana.

Pada app ini terdapat tiga class, pertama GameRunnable yang berfungsi untuk mengatur gerakan dan menggambar di canvas. Kedua adalah GameView yang merupakan turunan dari SurfaceView yang menyediakan canvas yang akan digambar. GameView ini juga bertugas membuat dan mematikan thread (thread ini diisi GameRunnable). Terakhir adalah activity utama (MainActivity). Penjelasan lebih detil dapat dilihat di source codenya langsung.

Source code untuk class GameRunnable:

package edu.upi.cs.yudiwbs.animasicanvas2;

import com.example.animasicanvas2.R;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.Log;
import android.view.SurfaceHolder;

//menggunakan runnable, karena hanya run yang dibutuhkan untuk dioverride
//baca: http://manikandanmv.wordpress.com/tag/extends-thread-vs-implements-runnable/
//tentang bedanya extends thread vs runnable
public class GameRunnable implements Runnable {

	 //jika true, loop di run() berakhir
	 boolean mRun=false;
	 float posX=10;
	 float posY=100;
	 Bitmap bmp;
	 private Paint cat = new Paint();

	 //akses ke surface dan canvas
	 private SurfaceHolder mSurfaceHolder;

	 //akses ke context (untuk ambil resource)
	 Context mContext;

	 private void updatePosisi() {
		 //geser ke kiri dan kalau sudah max, kembali ke awal
		 posX= posX+10;
		 if (posX>200) {
			 posX=10;
		 }

		 //tidur 0.2 detik, agar animasi tdk terlalu cepat
		 try {
				Thread.sleep((long)(1000*0.2));
		 } catch (InterruptedException e) {
				e.printStackTrace();
				Log.e("yw","error saat mencoba sleep"+e.getMessage());

		 }
	 }

	 //siapkan resources berupa bitmap, ambil bitmap icon launcher supaya mudah
	 public GameRunnable(SurfaceHolder vSurfaceHolder, Context vContext) {
		  mSurfaceHolder = vSurfaceHolder;
		  mContext = vContext;
		  Resources res = mContext.getResources();
		  bmp = BitmapFactory.decodeResource(res, R.drawable.ic_launcher);
	 }

	 //bersihkan layar dan gambar objek
	 public void doDraw(Canvas c) {
		 //canvas perlu dicek apakah null, karena pada saat
		 //user pindah ke activity lain (user tekan home), c bisa
		 //berisi null (proses shutdown belum selesai dan
		 //thread masih jalan, tapi canvas sudah didestroy)
		 if (c!=null) {
			//clear screen
			c.drawColor(Color.WHITE);
			//gambar bitmap
			c.drawBitmap(bmp,posX,posY,cat);
		}
	 }

	 @Override
     public void run() {
		 //loop forever selama tidak dishutdown (mRun diset false)
         while (mRun) {
        	 Log.i("yw","looping...."); //hapus log ini kalau tidak mendebug
             Canvas c = null;
             try {
                 c = mSurfaceHolder.lockCanvas(null);
                 //lock surface agar tidak diakses thread lain dulu
                 synchronized (mSurfaceHolder) {
                     updatePosisi();
                     doDraw(c);
                 }
             } finally {
                 //pastikan diunlock
                 if (c != null) {
                     mSurfaceHolder.unlockCanvasAndPost(c);
                 }
             }
         }
     }

	//jika diset false maka akan shutdown (lihat method run())
	public void setRunning(boolean b) {
		mRun = b;
	}
}

Sedangkan code untuk class GameView adalah

package edu.upi.cs.yudiwbs.animasicanvas2;

import android.content.Context;

import android.util.Log;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

//surfaceView digunakan karena GUI akan seriung diupdate
//dan cepat untuk animasi
public class GameView extends SurfaceView implements SurfaceHolder.Callback  {

	public Thread mThread;
	private GameRunnable gr;

	//buat thread dan jalankan
	public void startThread() {
		//objek GameRunnable jadi parameter
		mThread = new Thread(gr);
		gr.setRunning(true);
	    mThread.start();

	}

	public void shutDownThread() {
		Log.i("yw","shutdown thread mulai");
		boolean retry = true;
        gr.setRunning(false);  //matikan,

        //loop terus sampai thread berhenti karena
        //setRunning(false) tidak menghentikan seketika
        //coba lihat method GameThread.run()
        while (retry) {
            try {
                mThread.join();
                retry = false;
            } catch (InterruptedException e) {
            }
        }
        Log.i("yw","shutdown selesai");
	}

	public GameView(Context context) {
		super(context);
		SurfaceHolder holder = getHolder();
		holder.addCallback(this);
		gr = new GameRunnable(holder,context);
	}

	@Override
	public void surfaceCreated(SurfaceHolder holder) {
		Log.i("yw","jalankan thread !!");
		startThread();
	}

	@Override
	public void surfaceDestroyed(SurfaceHolder holder) {
		shutDownThread();
	}

	@Override
	public void surfaceChanged(SurfaceHolder holder, int format, int width,
			int height) {
		// bisa digunakan untuk memberi tahu GameRunnable ada perubahan dimensi
		// surface (misal dirotate)
	}
}

Dan terakhir adalah MainActivity

package edu.upi.cs.yudiwbs.animasicanvas2;

import com.example.animasicanvas2.R;

import android.os.Bundle;
import android.app.Activity;

public class MainActivity extends Activity {

GameView gv;

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);

		gv= new GameView(this);
		setContentView(gv);
	}

	 @Override
	 protected void onPause() {
	    super.onPause();
	    //nantinya perlu diisi dengan penyimpanan state
	    //melalui bundle
	 }

	 @Override
	protected void onResume() {
		super.onResume();
		//nantinya perlu diisi dengan load state
	 }

}

Kode diatas masih memiliki kelemahan, perhatikan penggunaan delay 0.2 ms pada updatePosisi() di GameRunnable. Jika suatu saat app ini diinstall pada smartphone yang lambat dan memiliki ratusan objek yang harus diupdate posisinya maka animasi akan berjalan semakin lambat. Pada kasus sepeert itu seharusnya delay dikurangi. Posting berikutnya akan membahas cara untuk mengatasi hal ini.

Salah satu hal yang perlu ditangani adalah saat pengguna pindah ke app lain dan kembali. Misalnya dengan menekan home, pindah ke app lain lalu kembali ke app animasi ini. Setiap pengguna pindah ke app lain, maka thread harus distop agar tidak memakan resources. Setelah mencoba beberapa kali dan memperhatikan hasil log, saat home ditekan, maka canvas pada doDraw dapat bernilai null. Jadi saat thread masih berjalan canvas dapat bernilai null. Ini sebabnya perlu ada pengecekan kondisi if (c!=null) pada method GameRunnable.doDraw (baris 63). Pada app LunarLander yang asli, tidak ada pengecekan ini dan terjadi error saat user menekan home.

Hal yang lain, saat home ditekan, maka GameView.surfaceDestroyed pasti akan dipanggil. Tadinya saya mengira sistem tidak akan men-destroy kecuali kalau membutuhkan. Ini menyebabkan proses mematikan thread dilakukan bukan di activity.onPause tapi cukup di method surfaceDestroyed. Tentu dalam kondisi sebenarnya onPause dan onResume perlu ditangani untuk menyimpan state. Sebagai contoh, saat app ini dijalankan dan kemudian device dirotasi maka app akan mulai dari awal lagi (nilai GameRunnable.posX hilang).