AndroidJava/少し長いHelloWorld(SurfaceView)

少し長いHello World(2018-10-20)


とりあえず、Android Studio を使って、
SurfaceViewでHello Worldします。

アプリで、画面がアニメーションするようなのを作る場合は、
1つの案としてはSurfaceViewに行き着くと思います、ので、
とりあえず「Hello world」をSurfaceViewで実装してみます。

大まかな流れとしては、

  1. まず、MainActivityを作る
  2. SurfaceView で Hellow world を作る
    1. スレッドで、一秒ごとにカウントするHello worldを作る
    2. ついでに、ある程度の画面遷移も対応しておく
  3. MainActivityを直す
    1. ついでに、アプリ復帰時の判定も入れておく



まず、MainActivityをつくる

まず、Android Studio で プロジェクトを新規作成します。

  • 「Target Android Devices」は
    Phone and Tablet
    (Minimum SDK は適当に*1
  • 「Add an Activity to Mobile」は
    Empty Activity を選びます。どのみち使いませんので。

すると、たぶん最初にこんなのができますよね。これは後で差し替えます。
(チェックボックスの有無で AppCompatActivity か Activity になりますね。
 この時点では、どちらでも良いです。)

public class MainActivity extends Activity {

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
   }
}

で、この時点で一旦アプリをビルドしてみた方が良いかもしれません。
私の場合はこれを書くためにWindows上で久々に実行したのですが、
AndroidStudioのアップデートが山ほど発生して、一旦作業が止まりました。

アプリのテーマの変更(アプリ名表示の削除)

まずは、画面のテーマを変えます。
app/src/main/res/values/styles.xml を編集して、画面のバー部分を取っ払います。 (やらなくても構いません)

(変更前)
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
(変更後)
<style name="AppTheme" parent="Theme.NoTitleBar">

これで画面がスッキリします。

SurfaceView で Hello world

MainViewという名前でSurfaceView のクラスを作ります。
左のProjectツリーで、
app/java/自分のパッケージ のところで右クリックして
(つまり先程の MainActivity がある場所で右クリックして)
[New]-[Java Class] を選び、Name は MainViewで、
Superclass にSuerfaceViewを選び、
Visivility は Package Private にします。
(以降も、基本的には Package Priavte しか使いません*2)

長いけれど、やりたいことは最後の「drawView」で Hello world を画面出力することです。

  • Threadを使って、一秒おきに表示内容を変えています。
  • Threadの開始、停止は startThread、stopThreadです。
  • アプリのライフサイクルを漏れなく拾うことができれば、手段は何でもOKですが、
    ここでは surfaceChanged/Destroyed でThreadの再開・停止しています。
  • drawView が描画処理です。
    描画を行う前に、画面を一旦塗りつぶし(canvas.drawColor)
    前回描画分と今回分が混ざらないようにします。
class MainView extends SurfaceView implements SurfaceHolder.Callback {
    private static final long LOOP_INTERVAL = 1000;
  
    MainView(Context context){
        super(context);
        getHolder().addCallback(this);
    }
  
    private class MyThread extends Thread {
        private final AtomicBoolean isEnd = new AtomicBoolean(false);
        void end(){
            isEnd.set(true);
        }
  
        @Override
        public void run(){
            SurfaceHolder holder = getHolder();

            while(!isEnd.get()){
                if(holder.isCreating()) continue;
                Canvas canvas = holder.lockCanvas();
                if(canvas == null) continue;
                drawView(canvas);
                holder.unlockCanvasAndPost(canvas);

                synchronized (this) {
                    try {
                        sleep(LOOP_INTERVAL);
                    } catch (InterruptedException e) {
                    }
                }
            }
        }
    }
 
    private MyThread thread;
    void startThread(){
        stopThread();
        thread = new MyThread();
        thread.start();
    }
  
    void stopThread() {
        if (thread == null) return;
        if(thread.isAlive()) thread.end();
        while(thread.isAlive()){
            try {
                Thread.sleep(LOOP_INTERVAL/5);
            }catch (Exception e){
            }
        }
        thread = null;
    }
   
    @Override
    public void surfaceCreated(SurfaceHolder holder){
    }
    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height){
        startThread();
    }
    @Override
    public void surfaceDestroyed(SurfaceHolder holder){
        stopThread();
    }
  
    void drawView(Canvas canvas){
        canvas.drawColor(Color.BLACK);
        Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
        p.setColor(Color.WHITE);
        p.setTextSize(canvas.getWidth() / 20);
        long sec = System.currentTimeMillis() / 1000 % 60;
        canvas.drawText("Hello World: " + String.valueOf(sec), 100, 100, p);
    }
}

まぁ、試しのコードですし、
赤字と下線でライブラリのインポートを指示が出たら、
Alt + Enter でどんどん import すれば良いと思います。*3

MainActivityの更新

上記のSurfaceViewを踏まえて Hello worldを実装します。

  • PID にプロセスIDを入れています。
    これが生きている間は、アプリがメモリ上に残っているイメージです。
    これが空になったらアプリの初期化をやり直す、のを期待しています。
public class MainActivity extends Activity {
   private MainView mainView;
   RelativeLayout mainLayout;
   private static int PID;
  
   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       if(PID != 0 && PID == android.os.Process.myPid()
               && mainView != null){
           return;
       }
       PID = android.os.Process.myPid();
  
       if(mainView == null ) {
           mainView = new MainView(this);
       }
       mainLayout = new RelativeLayout(this);
       mainLayout.addView(mainView);
       setContentView(mainLayout);
   }
}

とりあえずこれで、
簡単なテンプレートができた、でしょうか。
起動して、「Hello World: 数字のカウント」が表示されればOKです!

...されなければ、Log.d を大量に投入しましょう!

もう少し処理が複雑になった場合に、困ることの例

自分が何度もやらかした(やらかしている)ことの例で

まずはデバッグログ!!

Log.d や、自作のLog.dラッパークラスを入れましょう!!
Java でも C でも shell でも、とりあえずデバッグ文をぶっ込んどけば、わりとどうにかなります。

デバッガを使いこなせば良いのでしょうが、
私は何にも考えずにログを入れるのが、なんかこう、楽で好きです。

ログから該当箇所を探すのが面倒なら、
Log.d("LOG KARA SAGASUNOGA IYA (i=" + i +")"); とか書いておけば、
logcat で検索やフィルターかけて(LOG KARA SAGASUNOGA IYAで)ログが絞り込めます。
(そして Ctl + Shift + f で全検索してコード上から全て消す、完成前に)

ループ部分に入れると、大変な数になりそうですが、
それも例えば int count = 0; みたいなクラス変数を作って、
if(count++ % 100==0) Log.d 〜 みたいに100回に一回だけ出すという手もあります。

Threadについて

上記では「private MyThread thread;」で1つだけ作っているスレッドですが、
実際は、これ以外にも複数のThreadを使い分けて*4
複数のスレッドが並走することになると思います。

スレッドを分けないと、データが増えたときにアプリがダウンします。
具体的に上記の例だと、MyThread上にDB読込の処理や画像取り込みなんかを足すと
canvasのdrawに時間がかかってしまったり、
他の処理も止まったりして「このアプリは応答していません」からのダウンに繋がったりします。

そして、処理を分けるためにスレッドを増やすと、排他制御が困難になります。
たとえば、タップした情報をArrayListにadd/remove する処理を追加して、
そこから画像表示するためにArrayListから読む処理も追加すると、
それらが「同時に」発生したときに、例外発生でアプリが落ちたりします。

最初は単に synchronized で排他すれば良いと思っていたんですが、
結局、synchronized した箇所でリソースが「空くのを待つ」という処理になるだけなので、
上記のような時間超過によるアプリダウンが syncronized した箇所で発生するだけです。
排他する場所や方法は、その都度合った方法を試しましょう。
(それを具体的にどうやる・使い分けるのか、みんな悩んでいるのだと思いますが)

これらの問題が厄介なのは、実機の動作確認で検出できる可能性が低い点、です。
自分で操作して、たまたま問題が発生すれば、むしろラッキーだと思います。

ライフサイクルについて

前述のThreadがいつ開始、停止するのか
おのおののイベントがいつ起こるのか、という話です。
私は混乱するたびに、
Google:Android java Activity ライフサイクル
Google:Android java SurfaceView ライフサイクル 等で検索します。

私の場合に問題発生がより分かり易くなったのは、アプリに音楽を入れた後です。
開始停止のタイミングを間違えた結果、
画面は閉じたのに音楽が止まらなかったり
アプリを再開したのに音楽が流れなかったりしました。
特に画面が自動でスリープするタイミングでアプリ再開したり、何かを連打したりで、
タイミングが被った際に、音楽再生と停止が意図せず逆転することなったりもします。

それが音楽ではなく画像イメージの decode/recycle 場合は、メモリリークでダウンします。
ライフサイクルのフロー図とか見ながら、コードの実行順番を確認しておきましょう。

アプリのプロセスがダウンするタイミングについて

アプリをバックグラウンドに移した後に再開する場合に
(他のアプリに切り替えたりホームボタンを押したり、などした後)
そのアプリがバックグラウンドから復帰した状態なのか、
メモリ上から消えて、改めて起動しなおした状態なのか
それぞれで処理を分ける必要があったりなかったりします。

上記のアプリの例だと、その違いをPIDで判断しようとしており、
これが無いと、アプリを再開したときに前回分の再開分でThreadが二重に動いたりすると思います。

別の例だと、
アプリを作った時に、日本語と英語を切り替える機能を作りました。
その際に、デフォルト値(アプリ起動時の言語設定)を残したかったのですが
起動直後は良いとして、アプリを再開した場合のLocaleが
ユーザ変更後のものかOS設定引継ぎの値なのか区別がつかなくなってしまいました。
そこで一案として、ここでも上記のPIDのように
起動直後の設定を MainActivity に static で持たせる方法を試しています。


忘れる前に、いくつかメモしましたが、
結局、動かしてみると色々問題は起こるので、
まずは動かしてみたほうが手っ取り早い気がします。


*1 バージョンが低いと制約が色々出てくるので、練習なら自分が持っている端末に合わせて、公開するなら低めで良いんじゃないですかね。
*2 スコープは小さくする癖をつけると良いことがあります。何が良いかは秘密です(保守と安全とか)。「Android セキュアコーディングガイド」で検索するとJSSAが発行しているコーディングガイド等が見つかると思います、参考までに。
*3 余談ですが、タイプミスしたときに要らないライブラリが import されたりします。これをCtrl + z で戻せばimport も追加前に戻りますが、タイプミスを再度手動で書き直すと import 部分は増えたままであることに気が付かず、記憶にないimportが大量になったことがあります。
*4 画面タップイベントとか拾うと、それが別スレッドで動くことになります。