Edited at

RxJava + Flux (+ Kotlin)によるAndroidアプリ設計

More than 3 years have passed since last update.


Introduction

Androidにとってアプリ設計は永遠の課題だと思います。

Activityが独特のライフサイクルを持っているのがさらに課題を複雑化します。

設計についてはMVCやMVP、MVVM、Clean-Architecture、DDDなどが最近の主流でしょうか。

上記にあがった設計手法に関しては素晴らしい記事がいくつもあるのでとても参考になると思います。


AndroidではMVCよりMVPの方がいいかもしれない

http://konifar.hatenablog.com/entry/2015/04/17/010606

[ Android ] - これからの「設計」の話をしよう

https://tech.recruit-mp.co.jp/mobile/android-architecture/

AndroidオールスターズでClean Architectureについて発表してきた&参考リンク集

http://tomoima525.hatenablog.com/entry/2015/08/13/190731


設計に絶対の正解はなく、チームやアプリの規模により様々な設計が考えられると思います。

私も比較的大規模なアプリを作るにあたり設計を試行錯誤しながら作っていたのでですが、日々開発していく中で、どうしても解決したい問題をSQLBriteというすばらしいライブラリで解決し、隣のチームがEventBus+Fluxを採用してるのを見ているうちに、UIレイヤーのみRxJava+Fluxでアプリを設計するという形に行き着きました。

本記事では備忘録的にその設計の現在の指針をまとめました。また、現在進行系で開発しているので、何か間違っている点やご意見等あればぜひ、コメントやTwitterなどでお願いします。

また、肝となるライブラリが違いますが、設計についてはizumin5210さんの考えている設計に非常に近いんではないかな?と思っています。


Clean Architecture + DDD + Redux + RxJavaをAndroidでやるときにどこまで分割するか問題

http://izumin.hateblo.jp/entry/2016/01/24/221943



Kotlin

Kotlinについては前回の記事をみていただければと思います。

該当アプリをほぼKotlinで書いているのでサンプルソースなどはKotlinになりますが、設計上Kotlinである必要はありません。

しかし、ラムダやエクステンションなどKotlinで記述したほうが、すっきりと書けると思います。また、RxJavaのライブラリはだいたいKotlin向けにエクステンションが出ているのであわせて使うのをお勧めします。


RxJava

hydrakecatさんによる以下の記事が参考になると思います。


RxJava は Subscriber を中心に捉えると理解しやすいんじゃないかという話

http://hydrakecat.hatenablog.jp/entry/2015/12/22/RxJava_%E3%81%AF_Subscriber_%E3%82%92%E4%B8%AD%E5%BF%83%E3%81%AB%E6%8D%89%E3%81%88%E3%82%8B%E3%81%A8%E7%90%86%E8%A7%A3%E3%81%97%E3%82%84%E3%81%99%E3%81%84%E3%82%93%E3%81%98%E3%82%83%E3%81%AA%E3%81%84


上記記事では「Everything is Subscriber.」となっていますが、今回の設計は元々の「Everything is a stream.」の考えに則った設計とも言えます。

またSubjectを多用するので以下の記事も参考になります。


Rxで知っておくと便利なSubjectたち

http://qiita.com/hide92795/items/f7205c8171826cc2153b#serializedsubject



Flux

以下は公式サイトにあるにデータフロー図です。

「Action->Dispatcher->Store->View」というフローを必ず守ります。


flux-simple-f8-diagram-explained-1300w.png

https://facebook.github.io/flux/docs/overview.html#content


flux、およびそのAndroidへの導入はizumin5210さんによる記事が参考になると思います。


Droidux: ReduxをAndroidに持ち込んで状態管理から解放されよう!

http://qiita.com/izumin5210/items/549fd15d97e9fc3b1ef7


上記のサンプルアプリ内でもPublishSubjectがDispatcherとして使用されていますが、今回の設計はそれらを拡充したものになります。


どうしても解決したい問題

現在作っているアプリには30個近いActivityが存在します。

その中には別のActivityで起きたアクションによって、画面内の表示を変えないといけないものがいくつか存在します。例えば、「フォロー」がそれに当たります。「フォロー」ボタンがいくつかのActivityに存在していたら、どこかでそれが押された場合に全てのActivityで「フォロー済」となってて欲しい、そのActivityがたとえシステムによってdestroyされていたとしても。これがどうしても解決したい問題でした。

他にもログイン情報や、設定など「状態」をActivityを跨いだアプリ全体で管理したいものは必ずといって存在すると思います。

EventBusOttoLocalBroadcastなどはActivityのインスタンスが生きていることが前提なので、この問題を解決するには至りませんでした。全ての画面のデータのみをシングルトン的に一箇所に持つという手もありますが、大規模アプリには向かないでしょう。

今回の設計ではSQLBriteを用いてこれを解決しています。


RxJava + Flux

前置きが長くなりましたが、今回の設計の話をします。

要点は以下の3つです。


  • FluxのDispatcher/StoreとしてRxJavaおよびその他のRxJava関連ライブラリを使う

  • Storeからは全てObservableとして状態が流れてきて、それを必要なViewがそれをsubscribeする。

  • Viewは状態を持たず、Storeが状態を持つ。


必要なライブラリ


Dispatcher/Storeとして使うライブラリ


併用したいライブラリ



  • Dagger2


    • DispatcherやStore,Actionのインスタンス管理に



  • RxAndroid

  • RxLifecycle


  • RxBinding


    • ユーザのアクションがObservableとして流れてくるので、全てをStream化することが出来ます。



RxBindingのようにユーザの入力イベントやセンサーなどのイベントをObservableに変えるものは積極的に導入した方が良いと思います。


最小設計

fluxではDispatcherを1つ用意することになっていますが、そのルールに則らずDispatcherは「アプリ全体で共有されるもの」と、特定の「Activity」内でのみ使われるものにScopeを分けて複数用意することとします。

これらのインスタンスの管理はDagger2を用いて行います。

├ data

├ ui
│ ├ main
│ │ ├ MainAction
│ │ ├ MainActivity
│ │ ├ MainComponent
│ │ ├ MainDispatcher
│ │ ├ MainFragment
│ │ ├ MainModule
│ │ ├ MainScope
│ │ └ MainStore
│ ├ AppAction
│ ├ AppDispatcher
│ ├ AppComponent
│ ├ AppModule
│ ├ AppScope
│ └ AppStore


Dispatcher/Storeの種類

dispatchしたいイベントや保存したい状態によって使い分けます。


PublishSubject

subscribeした後のemitをSubscriberに伝えるSubjectです。

状態を保持する必要のない一時的なEventなどをdispatchするのに向いています。


ErrorDispatcher.kt

val errorEventObservable = SerializedSubject(PublishSubject.create<String>())



ErrorStore.kt

fun errorEvents() = errorDispatcher.errorEventObservable



MainActivity.kt

class MainActivity : RxAppCompatActivity() {

@Inject lateinit var mainStore: MainStore

override fun onResume() {
super.onResume()
mainStore.errors()
.observeOn(AndroidSchedulers.mainThread())
.bindToLifecycle(this)
.subscribe { showToast(R.string.error) }
}


onResume内でsubscribeすればRxLifecycleによってonPause内でunsubcribeされるので、Activityが最前面に表示されている時に特定のActionを行いたい場合などに使えます。


BehaviorSubject

PublishSubjectと似ていますが、subscribeした際に直前の値をemitするので状態を保持出来ます。

画面間で連携する必要のない状態や、画面間で連携する必要があってその状態へのアクセスが多いものに向いています。


UserDispatcher.kt

val userObservable = SerializedSubject(BehaviorSubject.create<User>())



UserStore.kt

fun user() = userDispatcher.userObservable



MainActivity.kt

class MainActivity : RxAppCompatActivity() {

@Inject lateinit var userStore: UserStore

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity)
userStore.user()
.observeOn(AndroidSchedulers.mainThread())
.bindToLifecycle(this)
.subscribe { userNameTextView.text = it.name }
}



BriteDatabase

SQLBriteはSQLiteのqueryをstreamに変換するライブラリです。subscribeしているqueryがある場合に、同じテーブルに変更があると再度同一queryをSQLiteに適応し、結果がSubscriberへemitされます。



BriteDatabase.java

  public long insert(@NonNull String table, @NonNull ContentValues values,

@ConflictAlgorithm int conflictAlgorithm) {
SQLiteDatabase db = getWriteableDatabase();
if (logging) {
log("INSERT\n table: %s\n values: %s\n conflictAlgorithm: %s", table, values,
conflictString(conflictAlgorithm));
}
long rowId = db.insertWithOnConflict(table, null, values, conflictAlgorithm);
if (logging) log("INSERT id: %s", rowId);
if (rowId != -1) {
// Only send a table trigger if the insert was successful.
sendTableTrigger(Collections.singleton(table));
}
return rowId;
}

https://github.com/square/sqlbrite/blob/0.6.2/sqlbrite/src/main/java/com/squareup/sqlbrite/BriteDatabase.java#L385


上記はinsert時のBriteDatabase内のコードですが、sendTableTriggerによってそのテーブルに対しての変更が他のSubscriberに伝わります。これをDispatcherとして使います。


MainDispatcher.kt

class MainDispatcher(val db: BriteDatabase) {

private val emptyObservable = SerializedSubject(PublishSubject.create<String>())

fun repoObservable(screenId: String) =
db.createQuery(TABLE_NAME, "SELECT * FROM $TABLE_NAME WHERE $SCREEN_ID=?", screenId)
.mapToList { Repo(it.getStringByName(REPO_ID), it.getStringByName(REPO_NAME)) }
.mergeWith(emptyObservable.filter { it == screenId }.map { emptyList<Repo>() })

fun dispatch(screenId: String, list: List<Repo>) = db.newTransaction().run {
var rows = 0
try {
db.delete(TABLE_NAME, "$SCREEN_ID=?", screenId)
list.filterNotNull().map { repo ->
ContentValues().apply {
put(SCREEN_ID, screenId)
put(REPO_ID, repo.id)
put(REPO_NAME, repo.name)
}
}.forEach {
db.insert(TABLE_NAME, it)
rows++
}
markSuccessful()
} finally {
end()
}
if (list.size == 0) emptyObservable.onNext(screenId)
rows
}
}


BriteDatabase::createQueryで欲しいデータのSELECT文を書いておけば同テーブルへの変更をsubscribeすることが出来ます。mapToListを使えばCursorを欲しいオブジェクトへmap出来ます。

上記の例ではdispatchする時にdeleteしてinsert、すなわちreplaceをしています。0件から0件になった場合のみ、SQLBriteは内部でdispatchしません。この挙動が他のDispatcherと違うので、その場合のみ別のSubject(上記の例ではemptyObservable)から値を送ることにしています。

テーブルは同じActivityの別インスタンスでも共有しているので、インスタンスごとにscreenIdを割り振ってそれらを区別するようにしています。


MainActivity.kt

private var screenId: String by Delegates.notNull()

override fun onSaveInstanceState(outState: Bundle?) {
super.onSaveInstanceState(outState)
outState?.putString(KEY_SCREEN_ID, screenId)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
screenId = savedInstanceState?.getString(KEY_SCREEN_ID) ?: hashCode().toString()
}


SQLを書く煩雑さはありますが、前述のフォローのように他のStoreに変更を伝えたい場合はテーブルを変更しておけば、それがStoreを通じてViewに反映されます。画面間でデータを共有したいがサイズが大きいもの、リストなどで順番が意味を持つものを保持するのに向いているDispatcherです。


RxSharedPreferences

rx-preferencesもSQLBriteと同様にSharedPreferencesの特定のkeyに対する変更を他のSubscriberにemitします。これをDispatcherとして使います。


UserDispatcher.kt

class UserDispatcher(val context: Context) {

fun user() = context.getRxPrefs("user").getString("id").asObservable()

fun dispatchUser(id: String) = context.getRxPrefs("user").getString("id").set(id)
}

fun Context.getRxPrefs(name: String): RxSharedPreferences = RxSharedPreferences.create(
getSharedPreferences(BuildConfig.APPLICATION_ID + '.' + name, Context.MODE_PRIVATE))


画面間でデータを共有したいが順番は関係なく、かつ常に保持しておく必要のないものに向いているDispatcherです。


RxBindingとの併用

Storeからはstreamとしてデータが流れてきますので、RxBindingと組み合わせることによってRxJavaの恩恵を受けることができます。

view.globalLayouts()

.withLatestFrom(store.data()) { view, data -> data }
.first()
.bindToLifecycle(this)
.subscribe { // Bind data to views. }

上の例ではViewTreeObserver.OnGlobalLayoutListenerを用いてViewの高さなどが決まってからStoreのデータをViewに反映させています。

drawerStore.clickMenuEvents()

.observeOn(AndroidSchedulers.mainThread())
.doOnNext { activity.drawerLayout.closeDrawers() }
.zipWith(drawerLayout.drawerOpen(Gravity.LEFT).skip(1).filter { !it }) { intent, drawer -> intent }
.bindToLifecycle(this)
.subscribe { startActivity(it) }

上の例でclickMenuEventsObservable<Intent>を返します。これはStoreから流れてくるDrawerのアイテムが押されたイベントを受けてDrawerを閉じてからstartActivityをする例です。

RxBindingのDrawerLayout::drawerOpen(Int)は初期値をemitするのでskip(1)をつけています。

RxJavaのzipWithを使うことでActivityやFragmentのプロパティ(Javaにおけるフィールド)に状態を持つことなく待ち合わせをすることが出来ます。zipWithの行を消すだけで待ち合わせのみがなくなる点がポイントです。


Sample

上記の実装の例は以下にあります。

https://github.com/satorufujiwara/kotlin-android-flux


Discussion

Fluxを意識しながら設計・実装していますが、複数のDispatcherが存在する点やAction内でデータレイヤーであるRepositoryにアクセスしているあたりがFluxとは違うのかなと考えています。

また、実際にまとめてみるとFluxというよりかはMVVMに近い実装かもしれません。StoreがMVVMのViewModelにあたるような実装ですが、以下のような点がMVVMと違っていると考えています。


  • Storeへの変更は必ず「Action->Dispatcher」を経由する

  • Viewからのイベントを伝えるActionとViewへ影響があるStoreが分離されている。

  • Storeのどの変更を、どの期間、どのタイミングでregisterするかはViewに委ねられている (RxLifecycleが便利)

  • Viewで起きるイベントがどのActionを呼ぶかはViewに委ねらている

現状考えているメリットデメリットは以下のような感じです。


Pros


  • Acitonに対するコールバックが無くなる


    • コールバックループを排除出来る

    • 無名クラスがActivity参照することによるメモリリークを意識する必要がない



  • ActivityやFragmentが自身のライフサイクルに合わせてStoreをsubscribeすることが出来る


    • Storeはライフサイクルを意識する必要がない



  • SQLiteやSharedPreferencesを変更しておけば画面間の状態連携が可能となる

  • 全ての状態変化をStream化出来る


    • RxJava好きな人にはたまらない




Cons


  • Disaptcherのインスタンスに関して気を配る必要がある


    • Dagger2のScopeごとにインスタンス化されるのでSubjectはインスタンスごとに伝わる

    • DispatcherがSQLBrite/rx-pereferencesである場合はScopeに関係なくアプリ全体に伝わる



  • SQLiteの管理に気を配る必要がある


    • ActivityごとのSQLiteではscreenIdで管理する必要がある

    • アプリの起動時に全て消す

    • テーブル間の同期のSELECT文は都度書く



  • テストについては(まだ)考えられていない