「スレッドとアニメーション」
今まで作成してきたプログラムは、全て「クリックしたら何かの処理を実行し、全て終わったら元に戻る」という形のものでした。つまり、常に動いているのは1つの処理だけだったわけです。Javaでは全ての処理はイベントによって呼び出されますが、メソッド実行中は何か操作をしてもイベントは発生しません。実行中のメソッドが終了して、初めて次のイベントが認識されるようになるのです。つまり、同時に複数のメソッドを平行して実行することは(今のところ)できないわけです。
けれど、時には「何かをしながら別の作業を行なう」というようなことが必要となる場合もあります。そのよい例が、アニメーションです。
画面にちょっとしたアニメーションを表示したい、という場合、「アニメーション表示中は他の作業は何もできない」というのではあんまり意味がありません。普通に作業を行えて、それ以外に常にアニメーションが動いている、というようになるのが理想でしょう。となると、「アニメーションを動かす」というメソッドが常に動いていながら、同時にユーザの操作などに対応したイベント処理がきちんと機能していなければいけないことになります。
このような場合に用いるのが「スレッド」というものです。スレッドとは、プログラムの内部で実行される処理の最小単位、といったものをイメージすればよいでしょうか。――Javaでプログラムがスタートすると、まず1つの「スレッド」が生成され、その中でプログラムが実行されます。これは、 一般に「メインスレッド」と呼ばれます。この中で全ての処理は行なわれ、全処理が終了するとスレッドは終了します。
このメインスレッドで実行している中から、新しいスレッドを作成してスタートすれば、2つのスレッドの中でそれぞれメソッドが実行されるようになります。すなわち、「同時に複数の処理が平行して行える」というわけです。――イメージ的には、Javaの仮想マシンでは、CPUを使う割り当てを各スレッドごとに割り振って動いている、というようなイメージで考えるとよいでしょう。普通はメインスレッドしかありませんから、CPUはメインスレッドが独占しています。ここでもう1つスレッドが作成されスタートされると、CPUはメインスレッドの処理をしたら次に新スレッドの処理を、次にまたメインスレッドの処理を、次に新スレッドを…というように、交互にスレッド内で実行されているメソッドの処理を行なっていく、というように考えるのです。そう考えれば、スレッドを複数持つことの意味が理解しやすいのではないでしょうか。
このスレッドは、「Thread」というクラスとして用意されています。ですから、スレッドの機能を使いたければこのThreadクラスを継承したクラスを作成すればいいことになります。――が、現実問題として、こういう作り方はしないことも多いようです。なぜなら、スレッドが必要となるのは、何かのクラスを継承したクラス内でのことが多いからです。例えば、AWT利用のプログラムはFrameを継承して作りますね? となると、(継承は常に1つのクラスだけしかできないので)同時にThreadを継承させることはできなくなります。
こうした場合のために、Threadクラスの他に「Runnable」というインターフェイスクラスも用意されています。インターフェイスというのは、例のimplementsで組み込むやつですね。イベントリスナーなんかで利用したやつです。これなら、Frameでも使えますね。
では、ここではRunnableを使った簡単な例をあげておきましょう。ボタンをクリックすると、ランダムに円が動き回るというものです。
import java.awt.*; import java.awt.event.*; public class Test12 extends Frame implements ActionListener,Runnable { MyCanvas c1; Button b1; Thread t1; int lastX,lastY,moveX,moveY; public Test12() { super(); setTitle("Hello"); setSize(300,250); setLayout(null); c1 = new MyCanvas(); c1.setBounds(25,25,250,150); this.add(c1); b1 = new Button("GO"); b1.setBounds(25,200,100,25); b1.addActionListener(this); this.add(b1); } public static void main (String args []) { new Test12().show(); } public void actionPerformed(ActionEvent ev) { if (ev.getSource() == b1) { moveX = (int)(Math.random() * 21 - 11); moveY = (int)(Math.random() * 21 - 11); if (t1 == null){ t1 = new Thread(this); t1.start(); } } } public void run(){ try { lastX = 0; lastY = 0; while(true){ if (lastX + moveX < 0 || lastX + moveX > c1.getWidth()){ moveX *= -1; } if (lastY + moveY < 0 || lastY + moveY > c1.getHeight()){ moveY *= -1; } lastX += moveX; lastY += moveY; c1.repaint(); t1.sleep(100); } } catch(Exception e){ System.out.println(e); } } class MyCanvas extends Canvas{ public void paint(Graphics g){ g.setColor(Color.blue); g.fillOval(lastX - 10,lastY - 10,20,20); } } }
実行したら、「GO」ボタンをクリックしてみましょう。ランダムな方向に円が動きだします。動いている最中にボタンをクリックすれば、動く向きがランダムに変わります。常に円が動いていながら、同時にボタンをクリックした時のイベントやそのメソッドもちゃんと機能していることがわかりますね?
では、順にコードを見ていきましょう。まず、クラスの定義部分です。これは、以下のようになっていますね。
public class Test12 extends Frame implements ActionListener,Runnable
implementsでRunnableが組み込まれていることがわかります。インターフェイスは、このようにカンマで区切って記述することで複数のものを同時に利用することができます。
その後に、変数の宣言が並んでいます。その中に「Thread t1」というものがありますね。このThreadがスレッドのクラスですね。スレッドを使用する時は、このようにThreadクラスのインスタンスを作ります。
そして、実際にスレッドを作成しスタートしているのは、ボタンをクリックした時の処理を行なうactionPerformedメソッドです。ここでは変数にランダムな値をおさめた後、以下のような処理をしています。
if (t1 == null){ t1 = new Thread(this); t1.start(); }
これがスレッド生成とスタートをしている部分です。Threadインスタンスは、他のクラスと同様にnewで作成します。このときパラメータに、そのスレッドで実行するインスタンスを設定します。これは、スレッドで実行可能な形で作られているもの、すなわちextends ThreadしているかimplementsThreadしたものでなければいけません。ここでは自身にimplements Runnableしていますからthisを指定します。
その後にある「start」というメソッドが、スレッドをスタートしているものです。――スレッド内で実行できるクラスは、その内部に「run」というメソッドを持っていなければいけません。Threadインスタンスを作成し、startすると、Java仮想マシンはスレッドに設定されたインスタンス内のrunメソッドを実行するようになっています。これは、Javaコマンドでクラスを実行するとmainメソッドが実行されるのと同じような感覚で考えるとよいでしょう。すなわち、このrunメソッド内に、スレッドで実行する処理を書いておけば、それがメインスレッドと平行して実行されるようになるというわけです。
public void run(){ try { lastX = 0; lastY = 0; while(true){ if (lastX + moveX < 0 || lastX + moveX > c1.getWidth()){ moveX *= -1; } if (lastY + moveY < 0 || lastY + moveY > c1.getHeight()){ moveY *= -1; } lastX += moveX; lastY += moveY; c1.repaint(); t1.sleep(100); } } catch(Exception e){ System.out.println(e); } }
これが、このクラスに用意されているrunメソッドです。まず目を引くのは、try内で処理が実行されているということです。これは、後述しますが処理を一時的に停止するsleepというメソッドがエラー(Javaでは「例外」と呼びます)を発生させる可能性があるから、その予防措置です。
そして第2の疑問が、「while(true)」でしょう。どうやら円を動かしている部分は、この繰り返し構文の中で処理を行なっているようです。が、while(true)というのは、whileの終了条件がtrueである、ということです。ということは「死ぬまで回ってろ」ということになりますね。終了しない、無限ループなわけです。こんなこと、普通は許されません。が、スレッド内で処理されるものについてはこれが許されるのです。なぜなら、メインスレッドが終了すると、このように新たに作成されたスレッドは自動的に消えてしまうからです。
その中では、まずlastXとlastYがMyCanvasの外側に出てしまった時の処理があります。ここでは||という見なれない記号が使われていますね。これは、記号の前後にある条件のいずれか片方が成立すればtrueとみなす、という働きをします。例えば、「if (A == 0 || B == 0) { …」なんて具合にすれば、AとBのどちらかが0であれば処理を実行するようなものが作れるわけです。――この反対に、「両方とも正しくないとtrueにならない」というものもあります。「&&」というのがそれで、「if (A == 0 && B == 0) { …」なんてすれば、AとBの両方が0でないと実行しないという処理が書けます。
最後に、MyCanvas(c1)をrepaintした後で、「t1.sleep(100);」というのを実行していますね。これは、一定時間、スレッドの処理を停止するものです。パラメータにはミリ秒(1000分の1秒)の値が入ります。つまりsleep(100)ならば、0.1秒停止することになるわけです。これで、スピードの調整を行なっていたわけですね。
さて、これで一応スレッドの基本はわかりましたが、これだけでは、いろいろと問題もあります。なにより「一度スタートしたらプログラムを終了するまで動きっぱなし」というんじゃ、ちょっと辛いものがありますね。
Threadクラスには、スレッドを一時停止したり再開したりするメソッドが備わっていたんですが、Java2になってそれらのメソッドは全て「推奨しない」とされてしまいました。つまり「あるけど、使わないでくれ」ということですね。これらのメソッドは、「デッドロック」といってプログラムがフリーズしたりする可能性を含んでいるためです。 では一体どうすればいいか?というと、スレッドを終了する必要があった場合にはrunメソッドを最後まで実行し終了させてしまうのです。こうすればスレッドは自然に消滅します。そして再開する必要が生じたら、再びThreadを作って実行するわけです。プログラムの状態などは変数などにおさめて保存しておけば、再開した時も前の状態を再現できるでしょう。
そこで、先のサンプルを修正してみましょう。
import java.awt.*; import java.awt.event.*; public class Test12 extends Frame implements ActionListener,Runnable { MyCanvas c1; Button b1,b2; Thread t1; int lastX,lastY,moveX,moveY; boolean KissOfDeath; public Test12() { super(); setTitle("Hello"); setSize(300,250); setLayout(null); lastX = 0; lastY = 0; KissOfDeath = false; c1 = new MyCanvas(); c1.setBounds(25,25,250,150); this.add(c1); b1 = new Button("GO"); b1.setBounds(25,200,100,25); b1.addActionListener(this); this.add(b1); b2 = new Button("change"); b2.setBounds(175,200,100,25); b2.addActionListener(this); this.add(b2); } public static void main (String args []) { new Test12().show(); } public void actionPerformed(ActionEvent ev) { if (ev.getSource() == b1) { KissOfDeath = ! KissOfDeath; if (KissOfDeath){ t1 = new Thread(this); t1.start(); } } if (ev.getSource() == b2) { moveX = (int)(Math.random() * 21 - 11); moveY = (int)(Math.random() * 21 - 11); } } public void run(){ try { while(KissOfDeath){ if (lastX + moveX < 0 || lastX + moveX > c1.getWidth()){ moveX *= -1; } if (lastY + moveY < 0 || lastY + moveY > c1.getHeight()){ moveY *= -1; } lastX += moveX; lastY += moveY; c1.repaint(); t1.sleep(100); } } catch(Exception e){ System.out.println(e); } } class MyCanvas extends Canvas{ public void paint(Graphics g){ g.setColor(Color.blue); g.fillOval(lastX - 10,lastY - 10,20,20); } } }
今回はボタンが2つあります。「GO」ボタンをクリックすると、円が動き出し、もう1度クリックすると、止まります。またクリックすれば動きだし、またまたクリックすれば止まる…というわけ。「change」ボタンは、動く方向をランダムに変更します。つまり、「実行/停止」と「方向変更」を分けてみたわけですね。一番最初は、動く方向は縦横どちらも0ですから、「GO」しても円は動きません。GOした後に「change」で方向を設定すると動くようになります。
ここでは、どうやってスタートと停止を行なっているか見てみましょう。「GO」ボタンの処理を行なっているのはactionPerformedメソッドですね。ここでの処理は以下のようなものです。
if (ev.getSource() == b1) { KissOfDeath = ! KissOfDeath; if (KissOfDeath){ t1 = new Thread(this); t1.start(); } }
スレッドの開始/終了は、KissOfDeathという変数をチェックして行なうようにしています。まずこの値を真偽反対にして、trueに変わった時には、スレッドを作成してスタートしています。――そして、肝心のrunメソッドでの処理ですが、このように変わっています。
public void run(){ try { while(KissOfDeath){ ……以下略……
見ると、while(KissOfDeath)という形でループを行なっていますね。ということは、このKissOfDeathがtrueの間は処理をくり返し続けますが、この値がfalseに変わると繰り返しを抜けてメソッドを終了してしまうのです。スレッドの中止と再開は、このような形で「runメソッドを終了し、また新たにnew Threadする」という方法で行なうのが、今のところ一番確実のようです。
ここまでは描画メソッドを使って図形を描いてきましたが、グラフィックファイルからイメージを読み込んでも同様のことができるはずです。今度は、それについて考えてみましょう。
イメージを使って描く時、問題となるのは「描画の時間差」です。 先の例では、ごく単純に「円を描く」というだけのものでしたから、なめらかに表示されました。が、イメージを使った場合、どうなるでしょう。特に「背景に何かのイメージを表示し、その上に重ねて何かを表示する」なんて場合は?
画面をクリアし、それから背景を描き、その後で重ね合わせるイメージを描き…という処理を順番に行なっていくと、画面が妙にちらちらとしてしまうことがあります。これは「描きなおしている処理が見えてしまう」からです。例えば背景を描いてから重ねるイメージを描く場合、ほんの一瞬ですが背景だけで重ねるイメージが描かれていない状態になるわけです。これによりちらつき感が出てしまうことはあります。
また、通常のコンポーネントは、画面更新をする際にまず全体を背景色で塗りつぶしてコンポーネントの描画を行なってからpaintメソッドを呼び出します。ここでもちらつきが生じる可能性はあります。
そこで、「一度に全部表示を更新する」という処理が必要となるのです。これは、実はそう難しいものではありません。要するに、複数のイメージを順番に描くのが悪いのです。ですから、まずからっぽ(?)のイメージを1つ用意し、その中に順番に表示するものを描いていって、全部描き終えたところでこれを画面に描くようにすれば、ちらつきはなくなります。
このような「画面に表示されないイメージ」を「オフスクリーン」と呼びます。オフスクリーンを使えば、画面のちらつきをおさえてきれいなアニメーションができます。このオフスクリーンというのは、特別なオブジェクトではありません。イメージに使っていたImageクラスのインスタンスをそのまま利用するのが一般的です。先にImageを使った時は、getImgeというのでイメージを読み込んでいましたが、この他にcreateImageというメソッドで、何もグラフィックのないからっぽなImageインスタンスを作ることもできるようになっています。
整理すると、オフスクリーンの考え方は以下のようになります。
1.表示するイメージをgetImageでそれぞれ読み込む。
2.それとは別に、オフスクリーン用のImageをcreateImageで作る。
3. runメソッドでは、位置を動かした後、オフスクリーンのImageにdrawImageで表示イメージを描いてから、repaintを呼び出す。
4.MyCanvasのpaintメソッドでは、オフスクリーンのImageを丸ごとdrawImageする。
この他に、MyCanvasでupdateメソッドを修正するという作業も必要となるでしょう。前回触れたように、AWTのコンポーネントでは、repaintされた後、updateでコンポーネントを更新し、それからpaintを呼び出します。ですから、updateを、直接paintを呼び出すような形に修正すれば、更にちらつきの原因はなくなります。
では、サンプルを作ってみましょう。ここでは背景に「haikei.gif」(横250×縦150)、その上に重ねて表示するイメージに「chara.gif」(横縦共に50)というイメージファイルをクラスファイルと同じ場所に用意し、これを読み込んで動かすことにします。
import java.awt.*; import java.awt.event.*; public class Test12 extends Frame implements ActionListener,Runnable { MyCanvas c1; Button b1,b2; Image offImg,img1,img2; Thread t1; int lastX,lastY,moveX,moveY; boolean KissOfDeath; public Test12() { super(); setTitle("Hello"); setSize(300,250); setLayout(null); lastX = 0; lastY = 0; KissOfDeath = false; Toolkit tk = Toolkit.getDefaultToolkit(); img1 = tk.getImage("haikei.gif"); img2 = tk.getImage("chara.gif"); c1 = new MyCanvas(); c1.setBounds(25,25,250,150); this.add(c1); b1 = new Button("GO"); b1.setBounds(25,200,100,25); b1.addActionListener(this); this.add(b1); b2 = new Button("change"); b2.setBounds(175,200,100,25); b2.addActionListener(this); this.add(b2); } public static void main (String args []) { new Test12().show(); } public void actionPerformed(ActionEvent ev) { if (ev.getSource() == b1) { if (img1!= null && img2 != null && offImg != null){ KissOfDeath = ! KissOfDeath; if (KissOfDeath){ t1 = new Thread(this); t1.start(); } } } if (ev.getSource() == b2) { moveX = (int)(Math.random() * 21 - 11); moveY = (int)(Math.random() * 21 - 11); } } public void run(){ Graphics g = offImg.getGraphics(); try { while(KissOfDeath){ if (lastX + moveX < 0 || (lastX + moveX) > (c1.getWidth() - 50)){ moveX *= -1; } if (lastY + moveY < 0 || (lastY + moveY) > (c1.getHeight() - 50)){ moveY *= -1; } lastX += moveX; lastY += moveY; g.drawImage(img1,0,0,this); g.drawImage(img2,lastX,lastY,this); c1.repaint(); t1.sleep(100); } } catch(Exception e){ System.out.println(e); } g.dispose(); } class MyCanvas extends Canvas{ public void update(Graphics g){ paint(g); } public void paint(Graphics g){ if (offImg == null){ offImg = createImage(250,150); } g.drawImage(offImg,0,0,this); } } }
だいたいこんな感じになりました。先の例と同様、「GO」した後に「change」すれば動く方向がランダムに設定されます。
ここでは、まずMyCanvasのほうを先に見ましょう。まず、updateメソッドが用意されていますね。
public void update(Graphics g){ paint(g); }
このように、updateが呼び出されたら何もしないでpaintするようにしておきます。これで、よけいな描画処理はされなくなり、ちらつきもおさえられます。そして肝心のpaintメソッドはこうなっています。
public void paint(Graphics g){ if (offImg == null){ offImg = createImage(250,150); } g.drawImage(offImg,0,0,this); }
このように、まずオフスクリーン用のoffImgというImageインスタンスがnullかどうかをチェックします。そしてnullだった場合にはcreateImageというメソッドを使ってImageインスタンスを作成します。これはパラメータに作成するイメージの縦横の大きさを指定します。そしてその後で、drawImageを使って、オフスクリーンのoffImgを描画しています。
なんでこんなところでImageインスタンスを作るんだ? 普通、汎用的なインスタンスはコンストラクタの中で作るんじゃないのか? …と思った人もいるでしょう。実は、このcreateImageは、コンストラクタの中ではうまく動かないのです。理由はちょっとややこしくなるので省略しますが、「createImageは、paintメソッド内でnullかどうかをチェックして作成する」というのが常套手段である、と考えて下さい。
このようにpaintメソッドは、単にオフスクリーンを表示するだけの働きしかしていません。オフスクリーンに、必要に応じて描画を行なっているのはrunメソッドです。
public void run(){ Graphics g = offImg.getGraphics(); try { while(KissOfDeath){ if (lastX + moveX < 0 || (lastX + moveX) > (c1.getWidth() - 50)){ moveX *= -1; } if (lastY + moveY < 0 || (lastY + moveY) > (c1.getHeight() - 50)){ moveY *= -1; } lastX += moveX; lastY += moveY; g.drawImage(img1,0,0,this); g.drawImage(img2,lastX,lastY,this); c1.repaint(); t1.sleep(100); } } catch(Exception e){ System.out.println(e); } g.dispose(); }
まず最初に、getGraphicsでGraphicsインスタンスを取り出してから、繰り返し処理へと入っていきます。そして移動方向や描画場所などの変数を処理した後、drawImageで背景を描き、それから重ね合わせるイメージを描いています。そして終わったところでrepaintを呼び出して画面を更新します。
ここではImageインスタンスがnullかどうかチェックしていませんが、これはactionPerformedメソッドのほうで、既にチェックしてからスレッドをスタートするからです。
スレッドを使ってイメージを動かすのは、意外に注意しないといけないポイントが多いので大変なのです。createImageの問題、updateの修正、描画するImageがnullでないかどうかの確認、オフスクリーンへの描画と、オフスクリーンから画面への描画の分離、ざっと考えただけでもこれだけ注意しないといけません。ちょっとややこしくなってきたので、それぞれでよくコードを読んで、メソッドの役割分担についてよく考えてみるとよいでしょう。