2013年12月21日土曜日

POJOをActivityにしちゃうDIコンテナ「Transfuse」の話。

このエントリーをブックマークに追加 このエントリーを含むはてなブックマーク
 こんにちは。Androidライブラリおじさんです。Android Advent Calendar 2013 21日目の担当です。TransfuseというAndroid向けの凶悪なDIコンテナがあったので人柱がてら触ってみました。使い方や出来る事などを簡単にまとめます。



サンプルコードはhttps://github.com/sys1yagi/TransfuseSampleで公開してます。Android Studio用です。

宣伝


 本出たり出そうだったりしてます。どっちもいい感じです。年末年始に是非!

Androidオープンソースライブラリ徹底活用 Effective Android
aa bb


結論


 TransfuseはPOJOにアノテーションベースでDIするので設計がだいぶ縛られるけど、これこそDIの正しい設計なのかもしれないと思いました。Transfuseすごい。こわい。ただアノテーションと普通のクラス名が被るのがめんどい。 @Activityとか@Fragmentとか@Viewとか。どっちかをFQDNにしないといけない。だるい。あと他のライブラリとのインテグレーションが出来るのかよくわからない。多分問題なく出来ると思うが・・・。ボイラープレートなコードは大分減るので楽。

環境構築


 Transfuseは元々はmaven用の様です。Android Studioで使う場合はいくつか手順が必要です。Android Studioで新規プロジェクトを作成した状態とします。

build.gradleの設定


 まずbuild.gradleを色々いじります。Transfuseに必要な依存性を管理するために、configurationsブロックにtransfuseを追加します。

configurations {
    compile
    transfuse.extendsFrom(compile)
}

 次にdependenciesを追加します。

dependencies {
    compile 'com.android.support:appcompat-v7:+'
    transfuse 'org.androidtransfuse:transfuse:0.2.2'
    transfuse 'com.google.android:android:2.1_r1'
    transfuse 'com.google.android:support-v4:r7'
    transfuse 'org.parceler:parceler:0.1.1'
    compile 'org.androidtransfuse:transfuse-api:0.2.2'
    compile 'org.parceler:parceler-api:0.1.1'
}

 androidブロックにsourceSets.mainブロックを追加してAndroidManifest.xmlの場所を指定します。

android {
    sourceSets.main {
        manifest.srcFile 'AndroidManifest.xml'
    }
}

 build.gradleの最下部辺りに全variantでtransfuseを実行する設定を書きます。

android.applicationVariants.all { variant ->

  def aptOutput = file("${project.buildDir}/source/r/${variant.dirName}")
  aptOutput.mkdirs()
  aptOutput.eachFileRecurse groovy.io.FileType.FILES, {
    if (it.name.equals('R.java')) {
      return
    }
    it.delete()
  }
  variant.javaCompile.options.compilerArgs += [
      '-processorpath', configurations.transfuse.asPath,
      '-s', aptOutput
  ]

}

 全体はコチラで参照できます。→TransfuseSample/TransfuseSample/build.gradle

AndroidManifest.xmlを移動する


 Transfuseのタスクを実行する際にAndroidManifest.xmlを読みにいくのですが、以下のようにデフォルトの場所に置いているとうまく読んでくれません。

$MODULE_NAME/src/main/AndroidManifest.xml

 以下のパスに移動して下さい。

$MODULE_NAME/src/AndroidManifest.xml

 これで利用可能になるはずです。

基本的な使い方


 TransfuseはPOJOに対して必要なアノテーションを付加していくスタイルになります。例えば簡単なActivityだと以下の様になります。

@Activity(label = "@string/app_name")
@Layout(R.layout.activity_main)
@IntentFilter({
  @Intent(type = IntentType.ACTION, name = android.content.Intent.ACTION_MAIN),
  @Intent(type = IntentType.CATEGORY, name = android.content.Intent.CATEGORY_LAUNCHER)
})
public class Main {
    @OnCreate
    public void create(Bundle saved) {
      // initialize
    }
}

 このコードをコンパイルすると、MainActivity.javaやインジェクタなどが生成されます。また、AndroidManifest.xmlを自動生成してくれます。@Activityや@IntentFilterなどAndroidManifest.xmlに関連するアノテーションを付加しておくとAndroidManifest.xmlに反映されます。

 最初AndroidManifest.xmlを自動生成する機能が動きませんでした。色々いじっていたら出来るようになったんですが条件がいまいちわかりません。自動生成が動かない場合は手でAndroidManifest.xmlにActivity等の定義を追加しましょう。

<?xml version="1.0" encoding="utf-8"?>
<manifest
  xmlns:android="http://schemas.android.com/apk/res/android" 
  package="jp.mydns.sys1yagi.android.transfuse" 
  android:versionCode="1" android:versionName="1.0">

    <uses-sdk android:minSdkVersion="7" android:targetSdkVersion="19"/>

    <application android:name="android.app.Application">
        <activity android:label="@string/app_name" android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
                <category android:name="android.intent.category.DEFAULT"/>
            </intent-filter>
        </activity>
    </application>

</manifest>

 あとはビルド&実行。

./gradlew --daemon installDebug
adb shell am start -n jp.mydns.sys1yagi.android.transfuse/.MainActivity --activity-clear-top

Transfuseの機能


 Transfuseは沢山のアノテーションや機能を提供しています。ざっくり各種機能を説明します。

Activity Lifecycle Method


 POJOでActivityを実現するので、ライフサイクルメソッドはすべてアノテーションで指定します。

@Activity
@Layout(R.layout.activity_lifecycle_methods)
public class ActivityLifecycleMethods {
  @OnCreate
  public void create() {

  }
  @OnResume
  public void resume() {

  }

  @OnPause
  public void pause() {

  }
}

 大体以下のアノテーションが使えます。@OnNewIntentが無いのが気になります。

@OnCreate
@OnStart
@OnPause
@OnResume
@OnStop
@OnDestroy
@OnBackPressed
@OnSaveInstanceState
@OnRestoreInstanceState
@OnConfigurationChanged

View Injection


 Viewのインジェクションは@Viewを使います。RoboGuiceとかだと@InjectViewですが、Transfuseだと@Injectと@Viewの組み合わせになります。

@Activity
@Layout(R.layout.fragment_view_injection)
public class ViewInjection {

  @Inject
  @View(R.id.text)
  TextView mTextView;

  @Inject
  @View(tag="hello_button")
  Button mButton;

  @OnCreate
  private void init(){
    mTextView.setText("Hello Transfuse!!");
    mButton.setText("Tag Injection!!");
  }
}

 面白いのはViewのID指定だけでなく、tagでの指定もできる点でしょうか。上記コードで利用しているレイアウトは以下の通りです。

<LinearLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="vertical">

  <TextView
    android:id="@+id/text"
    android:text="@string/hello_world"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"/>

  <Button
    android:id="@+id/button"
    android:tag="hello_button"
    android:text="@string/hello_world"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"/>
</LinearLayout>

Extra Injection


 Activityで受け取るIntentのパラメータもインジェクションできます。@Extraでキー名を指定できるほか、パラメータがオプショナルかどうかも指定できます。

@Activity
public class ExtraInjection {

  @Inject
  @Extra("title")
  String mTitle;

  @Inject
  @Extra(value = "message")
  String mMessage;

  @Inject
  @Extra(value = "count", optional = false)
  Integer mCount;

}

 上記コードをコンパイルすると、「ExtraInjectionActivityStrategy.java」が生成されます。このクラスは対象のActivityを起動するIntentを生成するクラスです。ExtraInjectionActivityStrategyはコンストラクタで必須パラメータを受け取り、オプショナルなパラメータについてはsetterメソッドが生やされます。

@Inject
IntentFactory mIntentFactory;

private void start(){
  mIntentFactory.start(
    new ExtraInjectionActivityStrategy("Transfuse!", "hello")
      .setCount(10)
  );
}

 Transfuseが提供するIntentFactoryクラスのstartメソッドにインスタンスを渡すことでActivityを起動できます。

Resource Injection


 リソースのインジェクションは @Resourceを使います。対応する型が間違っているとコンパイル時にエラーになるか、運良く通っても実行時にクラッシュします。

@Activity
public class ResourceInjection {

  @Inject
  @Resource(R.string.injection_text)
  String mInjectionText;

  @Inject
  @Resource(R.drawable.ic_launcher)
  Drawable mIcLauncher;

}

Preference Injection


 @Preferenceを使ってSharedPreferenceの値をフィールドにインジェクションできます。

@Fragment
public class PreferenceInjection {
    @Inject
    @Preference(value = "name")
    String mName;

    @Inject
    @Preference(value = "is_notification", defaultValue = "false")
    Boolean mIsNotification;
}

 @Preferenceを持つクラスをビルドすると以下の様なコードで値が解決されます。任意に名前を付けたSharedPreferenceを利用する事はできないようです。

PreferenceManager.getDefaultSharedPreferences(this).getString("name")

SystemService Injection


 SystemServiceは@SystemServiceを使います。アノテーションにSystemServiceの名前をセットすれば対応したクラスがインジェクションされます。

@Activity
public class SystemServiceInjection {

  @Inject
  @SystemService(Context.LAYOUT_INFLATER_SERVICE)
  LayoutInflater mLayoutInflater;

}

Application Lifecycle Method


 ApplicationクラスもPOJOで書けちゃいます。

@Application(name = "TransfuseApplication")
public class TransfuseApplicationBase {

  @OnCreate
  public void create() {

  }

}

 以下のアノテーションが使えます。

@OnCreate
@OnTerminate
@OnLowMemory
@OnConfigurationChanged

Fragment Lifecycle Method


 FragmentももちろんPOJOです。Activityと同じ様に、@Fragmentで作成できます。

@Fragment
@Layout(R.layout.fragment_fragment_lifecycle_method)
public class FragmentLifecycleMethod {

  @OnActivityCreated
  void activityCreated() {

  }

  @OnDestroyView
  void destroyView(){

  }
}

 以下のLifecycle Methodのアノテーションが使えます。@OnCreateは何故かonCreateView()にバインドされます。onAttachやonDetachのアノテーションもありません。まぁあんまり使わないしいいんじゃないでしょうか。

@OnCreate
@OnActivityCreated
@OnStart
@OnResume
@OnPause
@OnStop
@OnDestroyView
@OnDestroy
@OnLowMemory
@OnConfigurationChanged

 @Fragmentにはnameとtypeの属性をセットできます。name属性は生成するFragmentの名前を指定できます。以下の例だとLifecycleMethodFragment.javaが生成されます。デフォルトではクラス名のSuffixに"Fragment"が付きます。

@Fragment(name="LifecycleMethodFragment")
public class FragmentLifecycleMethod {

}

 type属性を設定するとextendsするクラスを指定できます。但しClass<? extends android.support.v4.app.Fragment>である必要があります。

import android.support.v4.app.ListFragment;

@Fragment(type = ListFragment.class)
public class FragmentList{

}

 Fragmentを使う場合、Activity側はFragmentActivityを利用する必要があります。Activity側でもtype属性でFragmentActivityを指定しておきましょう。

@Activity(type = FragmentActivity.class)
public class FragmentContainer {

}

 <fragment>でFragmentを埋める分には特に問題出ないですが、動的にFragmentを切り替える場合ちょっと面倒です。サンプルではこうしてますがもうちょいいい感じに出来るんじゃないかと思っています。

BroadcastReceiver Lifecycle Method


 BroadcastReceiverもPOJO(ry。@BroadcastReceiverと@Intentで定義できます。

@BroadcastReceiver
@Intent(type = IntentType.ACTION, name = Action.ACTION)
public class Action {
  public final static String ACTION = "jp.mydns.sys1yagi.android.transfuse.ACTION";

  @OnReceive
  public void boot(Context context, android.content.Intent intent) {
    //do something
  }
}

 投げる方はこんな感じ。

@Fragment
@Layout(R.layout.fragment_broadcast)
public class Broadcast {

  @Inject
  Context mContext;

  @RegisterListener(R.id.button)
  View.OnClickListener onClickListener = new View.OnClickListener() {
    @Override
    public void onClick(View v) {
      Intent intent = new Intent(Action.ACTION);
      mContext.sendBroadcast(intent);
    }
  };
}

 ActivityやFragmentとなるクラスはPOJOなのでsendBroadcast()とか持ってません。なのでフィールドにContextを@Injectしておいて、それを使います。以下の場合だとmContextにはgetActivity()の値がインジェクションされます。

Service Lifecycle Method


 ServiceもPOJO(ry。ただOnBindが無かったりでまだ未完成感が・・・。こういうIssueもありますが進んでない感。一応@Serviceで定義できます。でも@OnCreateと@OnDestroyしかサポートしてません。

@Service
@IntentFilter(@Intent(type= IntentType.ACTION, name="transfuse"))
public class Calc{
  @OnCreate
  public void create(){
    Log.d(TAG, "onCreate");
  }

  @OnDestroy
  public void destroy(){
    Log.d(TAG, "onDestroy");
  }

}

Listener Registration


 BroadcastReceiver Lifecycle Methodの所のコードに出てますが、@RegisterListenerでリスナをViewにバインドできます。

@RegisterListener(R.id.button)
View.OnClickListener mListener = new View.OnClickListener() {
  @Override
  public void onClick(View v) {
    mTextView.setText("Pushed!!!!!!!");
  }
};

@RegisterListener(value = R.id.long_click_text)
View.OnLongClickListener mLongClickListener = new View.OnLongClickListener() {
  @Override
  public boolean onLongClick(View v) {
    mTextView.setText("Long Pushed!!!!!!!");
    return false;
  }
};

 以下のViewクラスの持つリスナをサポートしています。API Level 11以降に追加されたものはサポートしてません。

View.OnLongClickListener
View.OnClickListener
View.OnCreateContextMenuListener
View.OnFocusChangeListener
View.OnKeyListener
View.OnTouchListener

Call-Through Events


 Call-Through EventsはPOJO側で定義したメソッドを生成コード側に生やす機能です。イベント用のインタフェースがあるので、それをPOJO側で実装します。例えばActivityのonKeyDownを実装するには、POJO側でActivityOnKeyDownListenerインタフェースを実装しておきます。

@Activity
@Layout(R.layout.fragment_call_through_events)
@RegisterListener
public class CallThroughEvents implements ActivityOnKeyDownListener {

  public boolean onKeyDown(int keyCode, android.view.KeyEvent event) {
    return false;
  }

}

 上記コードをコンパイルすると、以下のコードが生成されます。CallThroughEventsActivityされたCallThroughEventsActivity側にonKeyDownメソッドが生え、CallThroughEventsのonKeyDown()を呼び出している事がわかります。

public class CallThroughEventsActivity
  extends Activity
  implements ContextScopeHolder
{

  private jp.mydns.sys1yagi.android.transfuse.CallThroughEvents callThroughEvents$0;

//略

  @Override
  public boolean onKeyDown(int int$1, KeyEvent keyEvent$0) {
    return callThroughEvents$0 .onKeyDown(int$1, keyEvent$0);
  }
//略
}

 以下のCall-Through用のインタフェースが用意されています。Service用のもありましたがうまく動きませんでした。

ActivityMenuComponent
ActivityOnKeyDownListener
ActivityOnKeyLongPressListener
ActivityOnKeyMultipleListener
ActivityOnKeyUpListener
ActivityOnTouchEventListener
ActivityOnTrackballEventListener

Method Intercepters


 @Asynchronous、@UIThreadをメソッドに付加する事で、メソッドを実行するスレッドを設定できます。以下の様に通信処理などをするメソッドに@Asynchronousを付けておき、結果を反映するメソッドに@UIThreadを付けておけば、非同期処理とUIスレッドの連携を簡単に書けます。

@Fragment
public class MethodInterceptors {

  @Asynchronous
  public void loadAsynchronous() {
    try {
      URL url = new URL(LOAD_URL);
      String result = IOUtils.toString(url);
      setResult(result);
    } catch (Exception e) {
      e.printStackTrace();
      setResult(e.getMessage());
    }
  }

  @UIThread
  public void setResult(String text) {
    mTextView.setText(text);
  }

}

Events Bus


 TransfuseはEvent Busの機能も提供しています。以下の様に任意のイベントクラスを定義しておきます。

public class MessageEvent {

  private String mMessage;

  public MessageEvent(String message) {
    mMessage = message;
  }

  public String getMessage() {
    return mMessage;
  }
}

 イベントの送信はEventManagerを使います。以下の様に@InjectでEventManagerのインスタンスを持っておき、任意のタイミングでtriggerメソッドでイベントを送信します。

@Inject
EventManager mEventManager;

@RegisterListener(R.id.button)
View.OnClickListener mListener = new View.OnClickListener() {
  @Override
  public void onClick(android.view.View v) {
    mEventManager.trigger(new MessageEvent("send event!!"));
  }
};

 イベントを受け取りたいクラスで@Observesを付加してメソッドを定義しておきます。EventManagerのtriggerメソッドでイベントを送信すると、@Observesが付加されていて引数がイベントの種類に一致するメソッドが呼び出されます。

@Observes
public void onMessage(MessageEvent event) {
  mTextView.setText("receive event = " + event.getMessage());
}

 この仕組みを使うとリスナなどの登録無しでイベントのやりとりが出来ます。例えばログイン状態が変化した時や、Push通知を受け取った時などにEvent Busを使ってイベントを投げる事で、関連した画面やクラスで処理を行えます。

Parcel


 モデルクラスに@Parcelを付加するとParcelableになります。IntelliJ Plugin for Android Parcelable boilerplate code generationなんかいらんかったんや!

@Parcel
public class SampleData {

  private int mId;

  private String mName;

  private String mDescription;

}

ImplementedBy


 @ImplementedByはインタフェースのデフォルト実装を宣言するアノテーションです。以下のインタフェースがあるとします。

public interface ICalculator {
  public int add(int a, int b);
}

 これを実装したCalculatorImplクラスがあるとします。

public class CalculatorImpl implements ICalculator {
  @Override
  public int add(int a, int b) {
    return a + b;
  }
}

 以下の様にICalculatorをインジェクションしたいクラスがあった時、Transfuse側にこの依存性を解決する為の情報を教えてあげる必要があります。依存性を解決する為の情報を教える方法の1つが@ImplementedByです。

@Fragment
public class ImplementedBy {

  @Inject
  ICalculator mCalculator;

}

 インタフェース側で以下の様に@ImplementedByでデフォルトの実装クラスを定義しておくと、@Inject ICalculatorに指定したクラスがインジェクションされます。

@ImplementedBy(CalculatorImpl.class)
public interface ICalculator {
  public int add(int a, int b);
}

Module


 @TransfuseModuleで依存性を解決するモジュールを定義できます。モジュールに@Injectを解決する際に利用する情報を定義していきます。

@TransfuseModule
public class ApplicationModules {

}

@Provider


 モジュール内で@Providesを付加したメソッドを定義しておくと、@Injectを解決する際に利用されます。以下の例だとArrayAdapterを解決できます。

@TransfuseModule
public class ApplicationModules {
 @Provides
  public ArrayAdapter getAdapter(Context context) {
      return new ArrayAdapter<String>(context, android.R.layout.simple_list_item_1);
  }
}

 ArrayAdapterなど汎用的なクラスの場合、@Inject ArrayAdapterと書いてある全ての部分に上記のプロバイダが適用されます。@Namedを使うと特定のプロバイダを指定できます。

@TransfuseModule
public class ApplicationModules {
  @Provides
 @Named("TopMenuList")
  public ArrayAdapter getAdapter(Context context) {
    return new ArrayAdapter<String>(context, android.R.layout.simple_list_item_1);
  }
  @Provides
 @Named("SubMenuList")
  public ArrayAdapter getSubMenuAdapter(Context context) {
    return new ArrayAdapter<String>(context, R.layout.sub_menu);
  }
}

 以下の様に@Inject側でインジェクションするプロバイダの名前を指定します。

@Inject
@Named("TopMenuList")
ArrayAdapter mAdapter;

Provider


 プロバイダはProviderインタフェースを実装すればモジュールの外部に定義できます。

public class SampleDataProvider implements Provider<SampleData>{
  @Override
  public SampleData get() {
    SampleData sampleData = new SampleData();
    sampleData.setId(-1);
    sampleData.setName("provided data");
    sampleData.setDescription("provided data");
    return sampleData;
  }
}

 定義したプロバイダはモジュール側の@BindProvidersで設定できます。

@TransfuseModule 
@BindProviders({
  @BindProvider(type = SampleData.class, provider = SampleDataProvider.class)
})
public class ApplicationModules {
}

Bind


 直接モジュールに解決するクラスを定義する場合は@Bindingsを使います。@ImplementedByに似ていますが、@ImplementedByはインタフェース側に書くのに対し、@Bindingsはモジュール側に書きます。モジュール側でデフォルトの実装を任意に切り替えられるわけです。

@TransfuseModule
@Bindings({
  @Bind(type = ICalculator.class, to = CalculatorImpl.class)
})
public class ApplicationModules {
}

Factory


 @Injectはしないけど色々解決したい場合は@Factoryを使います。以下の様に@Factoryを付加したクラスを定義します。

@Factory
public interface SampleDataFactory {
  SampleData getSampleData();
}

 以下の様にFactories.get()でFactoryクラスを指定して取り出せます。getSampleData()の戻り値は、コンパイル時にモジュール内の定義に基いて解決されます(@BindProviderや@Bindや@ImplementedByや@Provides)。

public class UseFactory {

  public void getData() {
    SampleData sampleData = 
      Factories.get(SampleDataFactory.class).getSampleData();
      //do something.
  }

}

Scope


 スコープは依存性を解決する時にたどるオブジェクトマップの範囲を定義できます。例えば@Singletonをクラス宣言に付けておくと、そのクラスはシングルトンになります。

@Singleton
public class SingletonCalculator {
  public int multi(int a, int b) {
    return a * b;
  }
}

 例えば以下の様に二つのフィールドに@Injectを書くと、通常は二つのインスタンスがそれぞれのフィールドにインジェクションされます。@Singletonが付いている場合、両方同じインスタンスがインジェクションされます。

@Fragment
public class Scope {
  @Inject
  SingletonCalculator mSingletonCalculator1;

  @Inject
  SingletonCalculator mSingletonCalculator2;
}

 デフォルトでは@Singletonの他にも@ContextScopeがあります。@ContextScopeはActivity単位でインスタンスが分かれます。この他カスタムスコープを自分で定義したり、@Injectをどのスコープで解決するかを指定できる@ScopeReferenceなどがあります。

おわりに


 Transfuseすごい。一個これでアプリ作ってみたい。テスト周りには手が回らなかったのでテストも書きたい。本家ではRobolectricでやっているみたい。

0 件のコメント:

コメントを投稿