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、およびそのAndroidへの導入はizumin5210さんによる記事が参考になると思います。
Droidux: ReduxをAndroidに持ち込んで状態管理から解放されよう!
http://qiita.com/izumin5210/items/549fd15d97e9fc3b1ef7
上記のサンプルアプリ内でもPublishSubject
がDispatcherとして使用されていますが、今回の設計はそれらを拡充したものになります。
どうしても解決したい問題
現在作っているアプリには30個近いActivity
が存在します。
その中には別のActivity
で起きたアクションによって、画面内の表示を変えないといけないものがいくつか存在します。例えば、「フォロー」がそれに当たります。「フォロー」ボタンがいくつかのActivity
に存在していたら、どこかでそれが押された場合に全てのActivity
で「フォロー済」となってて欲しい、そのActivity
がたとえシステムによってdestroyされていたとしても。これがどうしても解決したい問題でした。
他にもログイン情報や、設定など「状態」をActivity
を跨いだアプリ全体で管理したいものは必ずといって存在すると思います。
EventBus、Otto、LocalBroadcastなどは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するのに向いています。
val errorEventObservable = SerializedSubject(PublishSubject.create<String>())
fun errorEvents() = errorDispatcher.errorEventObservable
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するので状態を保持出来ます。
画面間で連携する必要のない状態や、画面間で連携する必要があってその状態へのアクセスが多いものに向いています。
val userObservable = SerializedSubject(BehaviorSubject.create<User>())
fun user() = userDispatcher.userObservable
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.javapublic 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; }
上記はinsert時のBriteDatabase
内のコードですが、sendTableTrigger
によってそのテーブルに対しての変更が他のSubscriberに伝わります。これをDispatcherとして使います。
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
を割り振ってそれらを区別するようにしています。
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として使います。
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) }
上の例でclickMenuEvents
はObservable<Intent>
を返します。これはStoreから流れてくるDrawerのアイテムが押されたイベントを受けてDrawerを閉じてからstartActivityをする例です。
RxBindingのDrawerLayout::drawerOpen(Int)
は初期値をemitするのでskip(1)
をつけています。
RxJavaのzipWith
を使うことでActivityやFragmentのプロパティ(Javaにおけるフィールド)に状態を持つことなく待ち合わせをすることが出来ます。zipWith
の行を消すだけで待ち合わせのみがなくなる点がポイントです。
Sample
上記の実装の例は以下にあります。
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文は都度書く
- テストについては(まだ)考えられていない