Flux + Kotlin + RxJava + KOIN + Retrofit で作る Android アプリ
このエントリーはCyberAgent 19新卒 エンジニア Advent Calendar 2018の22日目の記事です.
はじめに
みなさんはじめまして, @mzkii です.
CA19新卒で,現在タップル誕生で AndroidEngineer として内定者バイトをしています.
この記事では,タップル誕生でも用いられている Flux Architecture
について軽く知ってもらうために,GithubAPI
を使った簡単なAndroidアプリケーションを開発していきたいと思います.
10分くらいで読める内容にしていますので,気になる方はぜひチャレンジしてみてください.
目標
以下の gif ファイルに表示されているような要件を満たすアプリを作ります.
Flux Architecture
キーワードは 「単一方向のデータフロー」 です.
ユーザーからのイベントやAPI通信後に取得されるデータなど全て同じデータの流れに従い制御します.データの流れが一方向に制限されることによって,状態やロジック管理に関して無駄に悩むことが減ります.詳しくは公式サイトを見てみてください.
View(ボタンやスクロールイベント)
👉ActionCreator(必要に応じてリモート等からデータを取得)
👉Action(取得したデータを詰め込む)
👉Store(状態を保持しViewに適切な形で公開)
👉Viewの状態を更新
この流れを頭の片隅においておくと理解しやすいです.
画面
用意する画面を列挙します.
AuthorizeActivity
MainActivity
AuthorizeActivity
Action
まずAction
を作っていきます.
Action
はActionCreator
とStore
とを結ぶデータの入れ物のような存在です.
sealed class AuthorizeAction { class Authorize(val accessToken: AccessToken) : AuthorizeAction() }
ちなみに,AccessToken
は以下のようなDataClass
です.
data class AccessToken( @field:Json(name = "access_token") val accessToken: String, @field:Json(name = "token_type") val tokenType: String )
この画面では,認証ボタンを押すとAndroid標準のwebブラウザに遷移させるというアクションが必要なので,Authorize
というアクションクラスを用意しています.
後述するActionCreator
はToken
を取得するためのAPIを叩き,結果をこのAuthorize
クラスのaccessToken
に代入し,dispatch
することでStore
に渡します.
ActionCreator
Authorize
アクションクラスを作成するActionCreator
は以下のようになっています.authorize
メソッドは今回Activityから呼ばれます.
class AuthorizeActionCreator( private val repository: AuthorizeRepository, dispatcher: Dispatcher ) : ActionCreator<AuthorizeAction>(dispatcher) { fun authorize(intent: Intent) { val uri = intent.data ?: return if (!uri.toString().startsWith(BuildConfig.REDIRECT_URI)) return repository .getAccessToken(uri.getQueryParameter("code")!!) .subscribeOn(Schedulers.io()) .subscribeBy( onSuccess = { dispatch(AuthorizeAction.Authorize(it)) }, onError = { Timber.e(it) } ) } }
ちなみに,AuthorizeActionCreator
が継承するActionCreator
クラスは以下のように定義されています.
今回マルチモジュールを使っていて, Flux
モジュールに入れています.AuthorizeActionCreator
がこのActionCreator<T>
を継承することで,AuthorizeActionCreator
がdispatch
できるAction
の型を制限しています.
abstract class ActionCreator<Action : Any>( private val dispatcher: Dispatcher ) { fun dispatch(action: Action) = dispatcher.dispatch(action) }
ブラウザで認証させActivity#onResume()
にcallback
して取得したIntent
中に含まれるcode
をrepository.getAccessToken
メソッドに渡すと,アクセストークンを取得することができます.
repository .getAccessToken(uri.getQueryParameter("code")!!) .subscribeOn(Schedulers.io()) .subscribeBy( onSuccess = { dispatch(AuthorizeAction.Authorize(it)) }, onError = { Timber.e(it) } )
AccessToken
は以下のように定義されたDataClass
です.
data class AccessToken( @field:Json(name = "access_token") val accessToken: String, @field:Json(name = "token_type") val tokenType: String)
onSuccess
のタイミングで,取得したAccessToken
クラスをAuthorize
アクションクラスに詰め込みdispatch
します.
dispatch(AuthorizeAction.Authorize(it))
ちなみにDispatcher
は以下のように定義されています.
こちらもFlux
モジュールに入れています.
class Dispatcher { private val bus = EventBus.builder() .throwSubscriberException(true) .build() fun dispatch(event: Any) { Timber.tag(this::class.java.simpleName).d("dispatch event=$event") bus.post(event) } fun register(observer: Any) { Timber.tag(this::class.java.simpleName).d("register observer=$observer") bus.register(observer) } fun unregister(observer: Any) { Timber.tag(this::class.java.simpleName).d("unregister observer=$observer") bus.unregister(observer) } }
また後述しますが,このDispatcher
はKOIN
によってSingleton
で管理されています.
各ActionCreator
でdispatch
すると,EventBus
によって当該Store#on()
メソッドにAction
が流れてくる仕組みになっています.
AuthorizeStore
それではStore
の実装を見ていきましょう.
Store
は状態を保持し,View
に対して保持した状態を適切な形に変換し公開します.
class AuthorizeStore( private val appSetting: AppSetting, dispatcher: Dispatcher ) : Store(dispatcher) { val gotoHomeState = StoreLiveData<Unit>() @Subscribe(threadMode = ThreadMode.MAIN) fun on(action: AuthorizeAction) { 👈 when (action) { is AuthorizeAction.Authorize -> { appSetting.accessToken = action.accessToken.accessToken gotoHomeState.postValue(Unit) 👈 } } } }
AuthorizeActionCreator
内でdispatch(AuthorizeAction.Authorize(it))
すると,このStore#on()
メソッドにAuthorizeAction
が流れていきます.
今回は1つのアクションしか用意していないですが,通常複数のAction
が流れてきますので,when (action)
みたいな感じで分岐させます.当該スコープに入ると勝手にキャストされるので,action.(Actionのメンバ変数)
としてアクセス可能です.
Store 👉 View
については,LiveData
を使っています.StoreLiveData
はStore
クラス内でのみpostValue
が可能なカスタムLiveData
で,ボイラープレートコードを削減できます.Kotlin
のinternal修飾子(他モジュールからは参照できない)
という仕組みを利用して実現しています.StoreLiveData
はもちろんFlux
モジュールに入れています.詳しくはこちらのブログを参考にしてみてください.
次にAuthorizeStore
クラスが継承しているベースのStore
クラスを見ていきます.
abstract class Store( private val dispatcher: Dispatcher ) : ViewModel() { init { register() } private fun register() { dispatcher.register(this) } override fun onCleared() { dispatcher.unregister(this) super.onCleared() } protected fun <T> StoreLiveData<T>.postValue(value: T) { internalPostValue(value) } protected fun <T> StoreLiveData<T>.setValue(value: T) { internalSetValue(value) } }
Android Architecture Component
の ViewModel
クラスを使っています.これによってActivity
とStore
の生存期間を同一に保つことができます.(画面回転などによってActivity
が再生成されてもStore
のインスタンスは破棄されない)
View
最後にActivity
です.DataBinding
を使っています.by viewModel()
とかby inject()
とか書いてあるのはKOIN
で依存注入するための記述です.
AAC ViewModel
をStore
として扱っているので,KOIN
的にはAuthorizeStore by viewModel()
となってしまい個人的に変な感じがします笑
class AuthorizeActivity : AppCompatActivity() { companion object { private const val URI = "https://github.com/login/oauth/authorize?client_id=" + "${BuildConfig.CLIENT_ID}&redirect_uri=${BuildConfig.REDIRECT_URI}" } private val store: AuthorizeStore by viewModel() private val actionCreator: AuthorizeActionCreator by inject() private val binding: ActivityAuthorizeBinding by lazy { DataBindingUtil.setContentView<ActivityAuthorizeBinding>(this, R.layout.activity_authorize) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_authorize) initView() observeState() } override fun onResume() { super.onResume() actionCreator.authorize(intent) 👈2 } private fun initView() { binding.authorizeButton.setOnClickListener { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(URI))) 👈1 } } private fun observeState() { store.gotoHomeState.observe(this) { finish() startActivity(Intent(application, MainActivity::class.java)) 👈4 } } }
流れとしては,
authorizeButton
が押され,Android
標準のWeb
ブラウザが立ち上がる.(User Interaction)- 認証が済んで
callback
してきたタイミングでonResume
が呼ばれ,そのままactionCreator.authorize(intent)
をコールする(View 👉 ActionCreator)
ActionCreator
内部でアクセストークンが取得できればdispath
し,AuthorizeStore#on()
メソッドにトークン情報が入ったActionが流れてくる.(ActionCreator 👉 Store)
Store
はHome
画面に遷移する為のLiveData
にUnit
を渡して,Activity
のgotoHomeState.observe(this)
が呼ばれMainActivity
に遷移する(Store 👉 View)
みたいな感じです.これで,アプリ起動からアクセストークン取得までの流れを掴むことができました.
AuthorizeStore
の方でappSetting.accessToken = action.accessToken.accessToken
と記述しているのは,SharedPreferences
にアクセストークンを保存するためです.今回少し拡張してAppSetting
というクラスを作ってみました.こちらもKOIN
でSingleton
に管理されています.今後認証が必要なAPI
を叩くときはAppSetting
に保存されたaccessToken
を使います.
ここまでが認証画面のデータフローに関する説明です.
MainActivity
この画面は,認証済みユーザーのリポジトリ一覧を取得しリスト表示するための画面になります.
HomeAction
リスト表示するだけなので,基本的にはList<Repository>
をラッパーしたActionを用意すればいいです.今回はローディング表示もさせたかったのでShowLoading
というAction
も作っています.
こんな感じに,この画面ではどんなユーザーインタラクションが発生するだろう,と考えつつ最初にAction
を作ったほうが個人的には楽だと思います.
sealed class HomeAction { class ShowLoading(val isLoading: Boolean) : HomeAction() class LoadRepositoryList(val repositoryList: List<Repository>) : HomeAction() }
HomeActionCreator
基本的には,リポジトリ一覧を読み込んで,LoadRepositoryList
にデータを詰め込んで,dispath
してStore
に送るだけです.
今回ローディングを表示する処理も入れているので,適切なタイミングでShowLoading
もdispath
してやります.
こうすることで,ShowLoading(true)
👉 LoadRepositoryList(リポジトリ一覧データ)
👉 ShowLoading(false)
という順番でHomeStore
にHomeAction
が流れてくることになります.
class HomeActionCreator( private val appSetting: AppSetting, private val repository: GithubRepository, dispatcher: Dispatcher ) : ActionCreator<HomeAction>(dispatcher) { fun getMyRepositoryList(page: Int) = repository .getMyRepositoryList(appSetting.accessToken, page) .subscribeOn(Schedulers.io()) .doOnSubscribe { dispatch(HomeAction.ShowLoading(true)) } .doFinally { dispatch(HomeAction.ShowLoading(false)) } .subscribeBy( onSuccess = { dispatch(HomeAction.LoadRepositoryList(it)) }, onError = { Timber.e(it) } ) }
HomeStore
次にStore
です.AuthorizeStore
と原理は一緒ですが,ページング処理が入っています.
Store#on()
に流れてくるリポジトリデータは断片的なもので今回は10件ずつしか入ってきません.そこでStore
内部で,これまで読み込んだリポジトリデータを保持しておき,毎回データが流れてくるたびにAddAll
します.
後はAddAll
したリストをLiveData
でView
に流すだけです.またHomeAction.LoadRepositoryList
が流れてくるたびに,次のページ設定と,次ページ以降データが存在するかのフラグを立てておきます.View
側でリポジトリをフェッチするActionCreator
を叩く際に必要になってきます.
class HomeStore(dispatcher: Dispatcher) : Store(dispatcher) { companion object { private const val INITIAL_PAGE = 1 } private val repositoryList = mutableListOf<Repository>() var canFetchMore = false private set var pageNum = INITIAL_PAGE private set val loadingState = StoreLiveData<Boolean>() val loadedRepositoryListState = StoreLiveData<List<Repository>>() @Subscribe(threadMode = ThreadMode.MAIN) fun on(action: HomeAction) { when (action) { is HomeAction.ShowLoading -> { loadingState.postValue(action.isLoading) } is HomeAction.LoadRepositoryList -> { canFetchMore = action.repositoryList.isNotEmpty() pageNum++ repositoryList.addAll(action.repositoryList) loadedRepositoryListState.postValue(repositoryList) } } } }
View
最後にActivity
です.initView()
から見ていきましょう.recyclerView
の初期化が中心ですが,リストが最下部に到達したかどうかのタイミングが知りたいのでFetchMoreScrollListener
を自作しています.
最下部に到達し,かつ次ページのデータが存在する場合に,ctionCreator.getMyRepositoryList(store.pageNum)
をして次ページを読み込みます.
そうすると,既存の(ロード済みの)リポジトリリストに,新しくフェッチしたリポジトリリストを加えたリストがloadedRepositoryListState
に流れてきます.
observeState()
ではloadingState
の購読と,loadedRepositoryListState
の購読を行っています.
取得したリポジトリリストはloadedRepositoryListState
に流れてくるので,adapter.submitList(it)
を実行します.リストの追加やアニメーションは内部でよしなにやってくれます.
class MainActivity : AppCompatActivity() { private val store: HomeStore by viewModel() private val actionCreator: HomeActionCreator by inject() private val binding: ActivityMainBinding by lazy { DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main) } private val repositoryAdapter: RepositoryAdapter by lazy { RepositoryAdapter( onCardClick = { repository -> repository.url?.let { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it))) } } ) } private var isLoading = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) initView() observeState() isLoading = true actionCreator.getMyRepositoryList(1) } private fun initView() { title = "My repositories" with(binding.recyclerView) { adapter = repositoryAdapter layoutManager = LinearLayoutManager(this@MainActivity, RecyclerView.VERTICAL, false) addOnScrollListener(object : FetchMoreScrollListener() { override fun onLoadMore() { if (!isLoading && store.canFetchMore) { isLoading = true actionCreator.getMyRepositoryList(store.pageNum) } } }) } } private fun observeState() { store.loadingState.observe(this) { binding.progressBar.visibility = if (it) View.VISIBLE else View.GONE isLoading = it } store.loadedRepositoryListState.observe(this) { repositoryAdapter.submitList(it.toList()) } } }
以上がリポジトリ一覧画面の解説になります.
その他
今回使用したライブラリ一覧を列挙します. KOINとRetrofitに関しては解説も入れておきます.
// EventBus implementation 'org.greenrobot:eventbus:3.1.1' // Timber implementation 'com.jakewharton.timber:timber:4.7.1' // KOIN implementation 'org.koin:koin-android:1.0.2' implementation 'org.koin:koin-android-scope:1.0.2' implementation 'org.koin:koin-androidx-scope:1.0.2' implementation 'org.koin:koin-android-viewmodel:1.0.2' implementation 'org.koin:koin-androidx-viewmodel:1.0.2' // Network implementation 'com.squareup.retrofit2:retrofit:2.4.0' implementation 'com.squareup.retrofit2:adapter-rxjava2:2.4.0' implementation 'com.squareup.retrofit2:converter-moshi:2.4.0' implementation 'com.squareup.okhttp3:okhttp:3.11.0' // JsonParser implementation 'com.squareup.moshi:moshi:1.7.0' implementation 'com.squareup.moshi:moshi-kotlin:1.7.0' // Rx implementation 'io.reactivex.rxjava2:rxjava:2.2.2' implementation 'io.reactivex.rxjava2:rxandroid:2.1.0' implementation 'io.reactivex.rxjava2:rxkotlin:2.3.0' // CardView implementation 'com.android.support:cardview-v7:28.0.0'
KOIN
KOIN は Android 向けのシンプルな Dependency Injection フレームワークで,Kotlinの機能を使ってDIを実現しています.
このアプリでは,Applicationクラス内で以下のようなモジュールを定義しています.
private val appModule = module { single { Dispatcher() } single { AppSetting(getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)!!) } } private val networkModule = module { single { Retrofit .Builder() .client(OkHttpClient.Builder().build()) .baseUrl("https://github.com/") .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .addConverterFactory(MoshiConverterFactory.create()) .build() .create(AuthorizeApi::class.java) } single { Retrofit .Builder() .client(OkHttpClient.Builder().build()) .baseUrl("https://api.github.com/") .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .addConverterFactory(MoshiConverterFactory.create()) .build() .create(GithubApi::class.java) } single { AuthorizeRepository(get()) } single { GithubRepository(get()) } } private val uiModule = module { factory { HomeActionCreator(get(), get(), get()) } viewModel { HomeStore(get()) } factory { AuthorizeActionCreator(get(), get()) } viewModel { AuthorizeStore(get(), get()) } }
Singleton
で扱いたいクラスはsingle
に,factory
を使えばget()
が呼び出されるたびに毎回新しいインスタンスを提供します.
依存関係はget()
で解決します.例えば,HomeActionCreator
はAppSetting
,GithubRepository
,Dispatcher
が必要ですが,AppSetting
はappModule
内でSingleton
として定義され,GithubRepository
はnetworkModule
内でSingleton
として定義され(かつGithubRepository
はGithubApi
が必要ですが同一モジュール内でSingleton
で提供されます),Dispatcher
もappModule
内でSingleton
で提供されます.
AAC ViewModel
の場合はviewmodel
を使います.Dagger
と違ってKOINは標準でAAC ViewModel
をサポートしています.
Field Injectionする場合は,by inject()
を使います.AAC ViewModelの場合はby viewmodel()
なので注意です.
これでmodule
が作れたので,後はApplication
クラス内のonCreate
のタイミングでstartKoin
メソッドをコールし,先程作成したmoduleを指定します.
startKoin(this, listOf(appModule, uiModule, networkModule))
Retrofit
RetrofitはmoshiとRxJavaを組み合わせて使っています.
Retrofit .Builder() .client(OkHttpClient.Builder().build()) .baseUrl("https://api.github.com/") .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .addConverterFactory(MoshiConverterFactory.create()) .build() .create(GithubApi::class.java)
OAuth認証をするにあたって少しハマったところを書きます.
interface AuthorizeApi { @Headers("Accept: application/json") @POST("login/oauth/access_token") @FormUrlEncoded fun getAccessToken( @Field("client_id") clientId: String, @Field("client_secret") clientSecret: String, @Field("code") code: String) : Single<AccessToken> }
RetrofitでAPIごとにヘッダーを設定する場合,@Headers
を付けるみたいです.
今回はjsonで欲しかったのでAccept: application/json
としました.
この設定を忘れてしまいかなり時間を食ってしまいました.
さらに,Retrofitを使用してフォームURLエンコードされたリクエストを実行するために@FormUrlEncoded
も付け加えておきます.
ここら辺のアノテーション周りの情報についてはFuture Studioの動画がとても参考になりました(Thank you!!).また,GithubのOAuth認証周りについては,詳しくはAuthorizing OAuth Appsを参照してください.
終わりに
いくつかモダンなライブラリを使いつつ,Flux ArchitectureでAndroidアプリを作ってみました!10分で読めると言いつつ,気がつけばかなり分量が多くなってしまい反省してます😅
Flux は状態管理がしやすく,初めて見る画面でもActionを見ればどんなことをする画面なのかなんとなく把握できたりします.さらに,単一方向のデータフローに強制することで,誰が書いても同じようなコードになりやすく,責務が分割されやすく,コードの保守性が向上します.
この記事を読んで少しでも興味を持たれた方は, Android/iOSにかかわらずぜひFluxに挑戦してみてください. (※完成形はこちらのリポジトリで公開しています)
おしまい