AnnotationでStateパターンっぽいことができるライブラリを作って公開してみた。

More than 1 year has passed since last update.

前提

@mokemokechickenさんのスライドの知らないと損するアプリ開発におけるStateMachineの活用法を読んで、StateMachineを使う機会を伺っていました。
ただ、結局そのままズルズル行き、isLoadingとかisListとかを駆使してアプリを作ってきました。

で、ふとButterknifeAutoValue/auto-parcelのコードを読み漁っていて、俺も自作annotation作りてー!!!となり、ちょうどStateMachineを使えるような機会になったので、せっかくだからと欲張ってState MachineっぽいのをAnnotationライブラリで表現してみました。(結局このライブラリ間に合わなかったので、使っていない。)

Stateパターン自体はGoFの一つなので、Javaには本当にいくつものライブラリが存在しています。StackOverFlowの返答にまとまっていますので、ガチなStateパターンライブラリを使いたい場合はそちらを活用下さい。

今回作ったのはifやswitch文、flagなどを使わずに特定のstate移動時の挙動をメソッドで分けるだけのライブラリです。

なので、これがstateパターンだ!という誤解をしないようにして頂ければと思います。
(元々Stateパターンの本来の動きとかほとんど理解出来ていません。Stateパターン使えば条件分岐など書かなくてよくなるっぽい!くらいな知識量です。当時上記スライドで紹介されているコードを読んでうん。わかんね!と投げました。)

Ken-Ken-Pa

さっそくですが、作ったライブラリはこちらです。Ken-Ken-Paという名前にしました。道にチョークで描いた丸に子供が飛び移る遊びです。「丸」が「定義されているState」で「子供がいる位置」が「現在のState」というイメージで作ってあります。

使い方

Androidの開発を楽にすることが目的だったので、Gradleで利用できるようになっています。

build.gradle
buildscript {
    repositories {
      mavenCentral()
    }
    dependencies {
        classpath 'com.neenbedankt.gradle.plugins:android-apt:1.7'
    }
}

apply plugin: 'com.neenbedankt.android-apt'

android {
    packagingOptions {
        exclude 'META-INF/services/javax.annotation.processing.Processor'
    }
}

dependencies {
    compile 'com.github.shiraji:kenkenpa:0.0.5'
    apt 'com.github.shiraji:kenkenpa-compiler:0.0.5'
}

最近流行りのandroid-aptプラグインを使っています。
これ無しでも動作確認しておりますが、こんなクラスないけど?とAndroidStudioさんに言われ続けるのが嫌な方はandroid-aptをご利用下さい。また、Java8?Retrolambda?なにそれおいしいの?状態なAndroiderなので、Java7でビルドしています。

2015/09/16現在はversion0.0.5ですが、バグを発見したり、やっぱこっちのがいいや!ってガッツリ処理変えたりしていますので、すぐに次バージョンが出来ている可能性あります。最新のREADMEをご確認下さい。現在まだalpha扱いです。ご利用には十分ご注意ください。

自作Annotationの紹介

今回やりたかったことのAnnotationの説明です。
Ken-Ken-Paには5つのAnnotationが存在しています。

  • @KenKenPa
  • @Hop
  • @Hops
  • @TakeOff
  • @Land

それぞれのAnnotationの紹介の前に以下の画像のように、CIRCLE1からCIRCLE2への動きをしようとした場合についてコードを交えて紹介しようと思います。

Kobito.KHBtZo.png

@KenKenPa

@KenKenPaはそのクラスがStateMachineのようなクラスであるという定義をしています。また、引数にデフォルトのstateを定義する必要があります。

今回はCIRCLE1をデフォルトのstateとするとこのように書きます。

SimpleFSM.java
@KenKenPa("CIRCLE1")
public abstract class SimpleFSM {
}

なぜabstractクラスなのかは後述します。現在はStateを表すのにStringを利用しています。後々classでの表現を可にしたいと考えています。(PRお待ちしております!)

@Hop

@Hopはstateの遷移を表します。子供がどういう経路でホップするのか?というところからHopという名前が来てます。
今回はCIRCLE1からCIRCLE2への遷移を表したいので、以下のように記載します。fromとtoというパラメータを設定する必要があります。fromとtoは同じ値を渡しても問題ありません。

SimpleFSM.java
@KenKenPa("CIRCLE1")
public abstract class SimpleFSM {
    @Hop(from = "CIRCLE1", to = "CIRCLE2")
    public void jump() {
      System.out.println("FOO");
    }
}

@Hops

Java7までの言語仕様上、一つのメソッドに対し、同じAnnotationは指定出来ません。
同じメソッドで状態遷移を複数定義したい場合、@Hopsを利用します。例えば、CIRCLE2からCIRCLE1への遷移もjumpメソッドで定義したとすると

SimpleFSM.java
@KenKenPa("CIRCLE1")
public abstract class SimpleFSM {
    @Hops({@Hop(from = "CIRCLE1", to = "CIRCLE2"),
            @Hop(from = "CIRCLE2", to = "CIRCLE1")})
    public void jump() {
      System.out.println("FOO");
    }
}

ライブラリの仕様上、@Hops内でfromで指定したstateは重複出来ません。どのStateへ遷移するのか判別できないためです。toに関しては重複することが可能です。@Hopは配列で定義されていますので、好きな数だけ指定できます。(言語上の制限まで。詳しく知りません!)

@TakeOff

子供がホップする直前の処理は@TakeOffで定義できます。@TakeOffはそのStateを表すStringを引数に渡す必要があります。@TakeOffは一つのStateで一つしか指定できません。CIRCLE1からどこかのStateへ遷移する場合xxするを定義したとすると

SimpleFSM.java
@KenKenPa("CIRCLE1")
public abstract class SimpleFSM {
    @Hop(from = "CIRCLE1", to = "CIRCLE2")
    public void jump() {
      System.out.println("FOO");
    }

    @TakeOff("CIRCLE1")
    void endCircle1() {
      System.out.println("Exit from CIRCLE1");
    }
}

@Land

子供がホップして着地するときの処理は@Landで定義できます。@Land@TakeOffと同様に、一つのStateで一つしか指定できません。

SimpleFSM.java
@KenKenPa("CIRCLE1")
public abstract class SimpleFSM {
    @Hop(from = "CIRCLE1", to = "CIRCLE2")
    public void jump() {
      System.out.println("FOO");
    }

    @TakeOff("CIRCLE1")
    void endCircle1() {
      System.out.println("Exit from CIRCLE1");
    }

    @Land("CIRCLE2")
    void startCircle2() {
      System.out.println("Now CIRCLE2");
    }
}

なぜabstractクラスなのか?

このクラスはabstractクラスなので、インスタンス化することは出来ません。実際の動きはこのSimpleFSMを継承したsubclassが実装します。Ken-Ken-Paはこのsubclassを自動生成するライブラリです。この概念は先に紹介したAnnotationライブラリでフル活用されている概念です。詳しく知りたい人はAutoValueのREADMEを確認して下さい。

AutoValue/auto-parcel同様、KenKenPaも子クラスはprefixをつけます。KenKenPaはKenKenPa_がprefixです。

それを踏まえ、createメソッドを実装すると

SimpleFSM.java
@KenKenPa("CIRCLE1")
public abstract class SimpleFSM {

    public static SimpleFSM create() {
        return new KenKenPa_SimpleFSM(); // buildフォルダの同じパッケージに自動生成されるため、import文などは特に指定しなくて良い。
    }

    @Hop(from = "CIRCLE1", to = "CIRCLE2")
    public void jump() {
      System.out.println("FOO");
    }

    @TakeOff("CIRCLE1")
    void endCircle1() {
      System.out.println("Exit from CIRCLE1");
    }

    @Land("CIRCLE2")
    void startCircle2() {
      System.out.println("Now CIRCLE2");
    }
}

現在のStateを取得する

個人的にこのライブラリを作る中で一番悩んだのがこの機能を提供するかどうかでした。
本来であれば、Stateが何であるかはStateMachineクラスを呼んでいる先では認識してあるべきで、そのStateに合わせた状態になっているべきだと考えているためです。
ただ、それは完全に個人の考えで、現在のStateを取得出来れば便利なのは確実です。コストがあまり掛からないので、一旦作ってみることにしました。

GetCurrentStateというinterfaceがあり、これをStateMachineっぽいクラスのimplementsとして指定します。

SimpleFSM.java
@KenKenPa("CIRCLE1")
public abstract class SimpleFSM implements GetCurrentState {

    public static SimpleFSM create() {
        return new KenKenPa_SimpleFSM();
    }

    @Hop(from = "CIRCLE1", to = "CIRCLE2")
    public void jump() {
      System.out.println("FOO");
    }

    @TakeOff("CIRCLE1")
    void endCircle1() {
      System.out.println("Exit from CIRCLE1");
    }

    @Land("CIRCLE2")
    void startCircle2() {
      System.out.println("Now CIRCLE2");
    }
}

interfaceは以下のような定義になっています。

GetCurrentState.java
public interface GetCurrentState {
    String getCurrentState();
}

getCurrentStateというメソッドの実装は自動生成されたクラス内で行われるため、StateMachineっぽいクラスでは他に何か処理を書いたりする必要はありません。

実際に使ってみる

Main.java
SimpleFSM simpleFSM = SimpleFSM.create();
simpleFSM.jump();
simpleFSM.getCurrentState();

これでコンソールに表示されるのは

Exit from CIRCLE1
FOO
Now CIRCLE2

となり、simpleFSM.getCurrentState();の戻り値は"CIRCLE2"となります。

もっと具体的な感じのサンプル

これだけ見て何がいいのかわからないと思うので、GitHubで公開したsampleコードも紹介します。Android向けのコードです。

MainActivity.java
public class MainActivity extends ActionBarActivity {

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

        final Handler handler = new Handler();
        final Button button = (Button) findViewById(R.id.network_launch_button);
        final Button restartButton = (Button) findViewById(R.id.reset_button);
        final LoadingSM loadingSM = LoadingSM.create();
        loadingSM.init();

        loadingSM.setListener(new LoadingSM.NetworkDoneListener() {
            @Override
            public void done() {
                handler.post(new Runnable() {
                    @Override
                    public void run() {
                        button.setEnabled(false);
                        button.setVisibility(View.GONE);

                        Toast.makeText(MainActivity.this, "Network loading completed!", Toast.LENGTH_LONG).show();

                        restartButton.setEnabled(true);
                        restartButton.setVisibility(View.VISIBLE);

                        loadingSM.close()
                    }
                });
            }
        });

        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                loadingSM.load();
            }
        });

        restartButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                restartButton.setEnabled(false);
                restartButton.setVisibility(View.GONE);

                button.setEnabled(true);
                button.setVisibility(View.VISIBLE);

                loadingSM.reset();
            }
        });
    }
}
LoadingSM.java
@KenKenPa("INIT")
public abstract class LoadingSM {

    public static LoadingSM create() {
        return new KenKenPa_LoadingSM();
    }

    @Hop(from = "INIT", to = "READY")
    public void init() {
        Log.i("LoadingSM", "initializing network...");
    }

    @Hops({@Hop(from = "READY", to = "LOADING"), @Hop(from = "LOADING", to = "LOADING")})
    public void load() {
        Log.i("LoadingSM", "loading...");
    }

    @Hop(from = "LOADING", to = "CLOSE")
    public void close() {
        Log.i("LoadingSM", "clear network...");
    }

    @Hop(from = "CLOSE", to = "READY")
    public void reset() {
        Log.i("LoadingSM", "reset...");
    }

    @TakeOff("READY")
    public void launch() {
        // Emulating network access.
        new Thread() {
            @Override
            public void run() {
                try {
                    Thread.sleep(3000);

                    // Done. let's notify!
                    if (mListener != null) {
                        mListener.done();
                    }
                } catch (InterruptedException e) {
                }
            }
        }.start();
    }

    interface NetworkDoneListener {
        void done();
    }

    private NetworkDoneListener mListener;

    public void setListener(NetworkDoneListener listener) {
        mListener = listener;
    }
}
activity_main.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              xmlns:tools="http://schemas.android.com/tools"
              android:layout_width="match_parent"
              android:layout_height="match_parent"
              android:orientation="vertical"
              android:paddingBottom="@dimen/activity_vertical_margin"
              android:paddingLeft="@dimen/activity_horizontal_margin"
              android:paddingRight="@dimen/activity_horizontal_margin"
              android:paddingTop="@dimen/activity_vertical_margin"
              tools:context=".MainActivity">

    <Button
        android:id="@+id/network_launch_button"
        android:layout_width="100dp"
        android:layout_height="48dp"
        android:text="@string/call"/>

    <Button
        android:id="@+id/reset_button"
        android:layout_width="100dp"
        android:layout_height="48dp"
        android:text="@string/reset"
        android:visibility="gone"/>


</LinearLayout>
strings.xml
<resources>
    <string name="app_name">KenKenPaSample</string>
    <string name="action_settings">Settings</string>
    <string name="call">Call!!!</string>
    <string name="reset">Reset?</string>
</resources>

よくある、ネットワーク通信処理(Thread.sleepで代替)をKen-Ken-Paを使ってやってみました。
sampleをrunして確認してしてもらうのが一番手っ取り早いのですが・・・

最初にCall!!!ボタンが表示されていて、このボタンをタップするとネットワーク通信が開始されます。
通信が完了するまでCall!!!ボタンをタップしても"loading..."というログが出力されるだけで特に何もしません。
通信が完了する(3秒経過する)とCall!!!ボタンが消え、Reset?ボタンが表示されます。
Reset?ボタンをタップするとCall!!!ボタンがまた表示され、またそれをタップしたら通信処理が開始される。という流れです。

Untitled.png

この処理をif/switch/flagなしで実装出来ました。
もちろん、READYからCLOSEへの処理も必要じゃない?と思ったらclose()メソッドに@Hopを追加するだけだったり、READYの前にトークン取得フローの追加も容易に出来ます。

LoadingSM.java
@KenKenPa("INIT")
public abstract class LoadingSM {

    public static LoadingSM create() {
        return new KenKenPa_LoadingSM();
    }

    @Hop(from = "INIT", to = "GET_TOKEN")  // --- 変更
    public void init() {
        Log.i("LoadingSM", "initializing network...");
    }

    @Hop(from = "GET_TOKEN", to = "READY") // --- 追加
    public void getToken() {
    }

    @Hops({@Hop(from = "READY", to = "LOADING"), @Hop(from = "LOADING", to = "LOADING")})
    public void load() {
        Log.i("LoadingSM", "loading...");
    }

    @Hops({@Hop(from = "LOADING", to = "CLOSE"), @Hop(from = "READY", to = "CLOSE")})  // --- 変更
    public void close() {
        Log.i("LoadingSM", "clear network...");
    }

    @Hop(from = "CLOSE", to = "READY")
    public void reset() {
        Log.i("LoadingSM", "reset...");
    }

    @TakeOff("READY")
    public void launch() {
        // Emulating network access.
        new Thread() {
            @Override
            public void run() {
                try {
                    Thread.sleep(3000);

                    // Done. let's notify!
                    if (mListener != null) {
                        mListener.done();
                    }
                } catch (InterruptedException e) {
                }
            }
        }.start();
    }

    interface NetworkDoneListener {
        void done();
    }

    private NetworkDoneListener mListener;

    public void setListener(NetworkDoneListener listener) {
        mListener = listener;
    }
}

という感じです。
ちなみに、このStateMachineっぽいクラスをコンパイルすると以下のようなクラスが生成されます。

KenKenPa_LoadingSM.java
public final class KenKenPa_LoadingSM extends LoadingSM {
  private String mCurrentState;

  KenKenPa_LoadingSM() {
    super();
    this.mCurrentState = "INIT";
  }

  @Override
  @Hop(
      from = "INIT",
      to = "READY"
  )
  public final void init() {
    String newState = takeOff$$init();
    super.init();
    land$$init(mCurrentState);
    mCurrentState = newState;
  }

  @Override
  @Hops({
      @Hop(from = "READY", to = "LOADING"),
      @Hop(from = "LOADING", to = "LOADING")
  })
  public final void load() {
    String newState = takeOff$$load();
    super.load();
    land$$load(mCurrentState);
    mCurrentState = newState;
  }

  @Override
  @Hop(
      from = "LOADING",
      to = "CLOSE"
  )
  public final void close() {
    String newState = takeOff$$close();
    super.close();
    land$$close(mCurrentState);
    mCurrentState = newState;
  }

  @Override
  @Hop(
      from = "CLOSE",
      to = "READY"
  )
  public final void reset() {
    String newState = takeOff$$reset();
    super.reset();
    land$$reset(mCurrentState);
    mCurrentState = newState;
  }

  private final String takeOff$$load() {
    switch(mCurrentState) {
      case "READY":
      launch();
      return "LOADING";
      case "LOADING":
      return "LOADING";
    }
    // No definition! Return the default state
    return "INIT";
  }

  private final void land$$load(String newState) {
    switch(newState) {
      case "READY":
      break;
      case "LOADING":
      break;
    }
  }

  private final String takeOff$$init() {
    switch(mCurrentState) {
      case "INIT":
      return "READY";
    }
    // No definition! Return the default state
    return "INIT";
  }

  private final void land$$init(String newState) {
    switch(newState) {
      case "INIT":
      break;
    }
  }

  private final String takeOff$$reset() {
    switch(mCurrentState) {
      case "CLOSE":
      return "READY";
    }
    // No definition! Return the default state
    return "INIT";
  }

  private final void land$$reset(String newState) {
    switch(newState) {
      case "CLOSE":
      break;
    }
  }

  private final String takeOff$$close() {
    switch(mCurrentState) {
      case "LOADING":
      return "CLOSE";
    }
    // No definition! Return the default state
    return "INIT";
  }

  private final void land$$close(String newState) {
    switch(newState) {
      case "LOADING":
      break;
    }
  }
}

また、他にもいろいろ使えそうな場面はあって、例えば、

  • Fragmentの切り替え
  • Viewの出し分け
  • OAuthなどのフローがある処理
  • GPS情報取得

です。MVP/MVCなどと共存出来るのも旨味だと考えています。
他にも使えそうなユースケースありましたら教えて頂けると助かります。

公開

jcenterにあげるまでがライブラリ作成!ということで、Bintrayにあげて、jcenterにひも付けてもらい、無事公開出来ました。初めてJavaのライブラリ公開をすることが出来ました。
この辺りはいろんなところに記載されていますので省略します。需要があれば記載します。

公開後、すでに何回もバージョン更新しています。。。ふわっとした状態でやるもんじゃないですね。

# TODO

今後はテストしっかり書いて1.0.0として公開したいと思います。
他にも、上記に出てきたとおり、Stateを何かObjectで表現出来るようにしたり、それに伴って、Java6ついでに対応しちゃったりといくつかあります。
リファクタリングは特にProcessorクラスをなんとかしたいです。こいつだけで無理やり完結させてしまっているためひどい状態です。
Android以外でのサンプルコードの作成してみたいです。

あと、これを機会にもう少しStateパターンの理解度をあげたいです。

最後に二言

Annotationはすごい楽しいです!
コードでコードを作成するのは気持ち良いです!