タッチ入力とマルチスレッド(1)

Androidでゲーム開発をする場合、スレッドを作成しSurfaceViewを使用するのが一般的です。
ゲームの動作などの処理はすべてこの作成したスレッド(以降メインスレッド)で行うことができますが、タッチイベントなどUIに関する処理は、最初のスレッド(以降UIスレッド)でしか行うことができません。
2つのスレッド(マルチスレッド)が存在することにより、1つのスレッドでは発生しない問題が起こります。

まずは以下のサンプルコードを見てください。

public class CActivityMain extends Activity implements Runnable, View.OnTouchListener
{
	private SurfaceView mView;
	private float mX;
	private float mY;
	
	@Override
	protected void onCreate(Bundle savedInstanceState)
	{
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);
		mView = (SurfaceView)findViewById(R.id.surfaceView1);
		mView.setOnTouchListener(this);
		new Thread(this).start();
	}
	
	@Override
	public void run()
	{
		SurfaceHolder aHolder = mView.getHolder();
		Paint aPaint = new Paint();
		aPaint.setColor(Color.RED);
		while(true)
		{
			if(aHolder.getSurface().isValid())
			{
				Canvas aCanvas = aHolder.lockCanvas();
				aCanvas.drawColor(Color.WHITE);
				aCanvas.drawCircle(mX, mY, 50.0f, aPaint);
				aHolder.unlockCanvasAndPost(aCanvas);
			}
		}
	}

	@Override
	public boolean onTouch(View iView, MotionEvent iEvent)
	{
		if(iEvent.getAction() == MotionEvent.ACTION_DOWN)
		{
			mX = iEvent.getX();
			mY = iEvent.getY();
			return true;
		}
		return false;
	}
}

タッチした場所に赤い丸が移動するだけのシンプルなプログラムです。
よくあるサンプルではSurfaceViewを継承したクラスを作成しますが、ここではレイアウトにSurfaceViewを作成し、それを使用する方法をとっています。個人的にはこちらの方がゲームは作りやすいかと思ってます。

さて、これを実行すると特に問題なく、タッチした場所に赤い丸が移動します。
しかし実はこのプログラムは大きな問題を含んでいます。
それを確認するため、onTouchを以下のように変更します。

@Override
public boolean onTouch(View iView, MotionEvent iEvent)
{
	if(iEvent.getAction() == MotionEvent.ACTION_DOWN)
	{
		mX = iEvent.getX();
		for(int aLoop = 0; aLoop <= 1000000; ++aLoop)
		{
		}
		mY = iEvent.getY();
		return true;
	}
	return false;
}

まったく意味のない空のループを入れています。
この状態で実行し画面をタッチすると、先ほどとは挙動が変わります。
赤い丸がタッチした場所に直接移動せず、まず横に移動した後、少しして縦に移動します。
これがマルチスレッドで起こる問題です。

マルチスレッドの状態というのは、それぞれのスレッドが完全に独立して動作している状態です。
つまりrun関数内での描画処理と、onTouch関数内での入力処理は同時に起こります。
先ほどの赤い丸の挙動は、以下のような処理の結果起こっています。
1.タッチ入力でイベント発生
2.mX = iEvent.getX() でmXがタッチした場所になる
3.空のループ処理中にrunのaCanvas.drawCircleが実行される
4.mY = iEvent.getX() でmYがタッチした場所になる
5.runはループ中なので、次のaCanvas.drawCircleが実行される

こんな空のループなんか入れない、と思うかもしれませんが、このサンプルでは現象を起こしやすくするために空のループを入れただけで、ループがなくてもmXとmYの更新の間でdrawCircleが呼ばれてしまうことがあります。
またループでなくても色々な処理をonTouch関数に書けば、その処理の最中で描画が始まってしまい、最悪ハングなどを引き起こします。

マルチスレッドでのこうした同時更新の問題を回避するために、javaにはsynchronizedという機能があります。synchronizedで囲まれた範囲は、同時に実行されることがなくなります。
ということでonTouchとrunを以下のように変更します。

@Override
public void run()
{
	~略~
			Canvas aCanvas = aHolder.lockCanvas();
			synchronized (this)
			{
				aCanvas.drawColor(Color.WHITE);
				aCanvas.drawCircle(mX, mY, 50.0f, aPaint);
			}
			aHolder.unlockCanvasAndPost(aCanvas);
	~略~
}

@Override
public boolean onTouch(View iView, MotionEvent iEvent)
{
	if(iEvent.getAction() == MotionEvent.ACTION_DOWN)
	{
		synchronized (this)
		{
			mX = iEvent.getX();
			for(int aLoop = 0; aLoop <= 1000000; ++aLoop)
			{
			}
			mY = iEvent.getY();
		}
		return true;
	}
	return false;
}

これで実行すると、赤い丸はまたタッチした場所に直接移動するようになります。
ようやくこれで解決かと思いきや、まだ大きな問題があります。
この状態では描画処理を行っている間、入力処理は動きません。(そのためにsynchronizedを入れたのだから当たり前ですが)
このくらいのプログラムならよいですが、実際のゲームでは処理はもっと長く複雑です。
そうするとその長い処理をしている間、onTouchは処理されなくなります。
せっかくマルチスレッドにしてUIスレッドからゲーム処理を分離したのに、ゲーム処理でUIスレッドが待たされたのでは本末転倒です。
synchronizedの範囲を全体ではなく必要な範囲だけにすればよいのですが、それを考えながらプログラムをするのは非常に困難です。

ということで長くなったので次回に続きます。
次回はこの問題の解決方法です。

コメントを残す

メールアドレスが公開されることはありません。