みずきち日記

ひらすらプログラミング

Flux + Kotlin + RxJava + KOIN + Retrofit で作る Android アプリ

このエントリーはCyberAgent 19新卒 エンジニア Advent Calendar 2018の22日目の記事です.

はじめに

みなさんはじめまして, @mzkii です.

CA19新卒で,現在タップル誕生で AndroidEngineer として内定者バイトをしています.

この記事では,タップル誕生でも用いられている Flux Architecture について軽く知ってもらうために,GithubAPIを使った簡単なAndroidアプリケーションを開発していきたいと思います.

10分くらいで読める内容にしていますので,気になる方はぜひチャレンジしてみてください.

ちなみに完成形はこちらリポジトリで公開しています.

目標

以下の gif ファイルに表示されているような要件を満たすアプリを作ります.

captcha.gif

  1. login/oauth/access_token を叩きトークン取得
  2. /user/repos を叩き認証済みユーザーのRepository一覧を取得
  3. よしなにページング処理
  4. リポジトリ詳細に遷移可能

Flux Architecture

キーワードは 「単一方向のデータフロー」 です.

ユーザーからのイベントやAPI通信後に取得されるデータなど全て同じデータの流れに従い制御します.データの流れが一方向に制限されることによって,状態やロジック管理に関して無駄に悩むことが減ります.詳しくは公式サイトを見てみてください.

View(ボタンやスクロールイベント)👉ActionCreator(必要に応じてリモート等からデータを取得)👉Action(取得したデータを詰め込む)👉Store(状態を保持しViewに適切な形で公開)👉Viewの状態を更新

この流れを頭の片隅においておくと理解しやすいです.

画面

用意する画面を列挙します.

AuthorizeActivity

Action

まずActionを作っていきます.

ActionActionCreatorStoreとを結ぶデータの入れ物のような存在です.

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というアクションクラスを用意しています.

後述するActionCreatorTokenを取得するための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>を継承することで,AuthorizeActionCreatordispatchできるActionの型を制限しています.

abstract class ActionCreator<Action : Any>(
    private val dispatcher: Dispatcher
) {
    fun dispatch(action: Action) = dispatcher.dispatch(action)
}

ブラウザで認証させActivity#onResume()callbackして取得したIntent中に含まれるcoderepository.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)
    }
}

また後述しますが,このDispatcherKOINによってSingletonで管理されています.

ActionCreatordispatchすると,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を使っています.StoreLiveDataStoreクラス内でのみpostValueが可能なカスタムLiveDataで,ボイラープレートコードを削減できます.Kotlininternal修飾子(他モジュールからは参照できない)という仕組みを利用して実現しています.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 ComponentViewModel クラスを使っています.これによってActivityStoreの生存期間を同一に保つことができます.(画面回転などによってActivityが再生成されてもStoreインスタンスは破棄されない)

View

最後にActivityです.DataBindingを使っています.by viewModel()とかby inject()とか書いてあるのはKOINで依存注入するための記述です.

AAC ViewModelStoreとして扱っているので,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
        }
    }
}

流れとしては,

  1. authorizeButtonが押され,Android標準のWebブラウザが立ち上がる.(User Interaction)
  2. 認証が済んでcallbackしてきたタイミングでonResumeが呼ばれ,そのまま actionCreator.authorize(intent) をコールする(View 👉 ActionCreator)
  3. ActionCreator内部でアクセストークンが取得できればdispathし,AuthorizeStore#on()メソッドにトークン情報が入ったActionが流れてくる.(ActionCreator 👉 Store)
  4. StoreHome画面に遷移する為のLiveDataUnitを渡して,ActivitygotoHomeState.observe(this)が呼ばれMainActivityに遷移する(Store 👉 View)

みたいな感じです.これで,アプリ起動からアクセストークン取得までの流れを掴むことができました.

AuthorizeStoreの方でappSetting.accessToken = action.accessToken.accessTokenと記述しているのは,SharedPreferencesにアクセストークンを保存するためです.今回少し拡張してAppSettingというクラスを作ってみました.こちらもKOINSingletonに管理されています.今後認証が必要な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に送るだけです.

今回ローディングを表示する処理も入れているので,適切なタイミングでShowLoadingdispathしてやります.

こうすることで,ShowLoading(true) 👉 LoadRepositoryList(リポジトリ一覧データ) 👉 ShowLoading(false) という順番でHomeStoreHomeActionが流れてくることになります.

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したリストをLiveDataViewに流すだけです.また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()で解決します.例えば,HomeActionCreatorAppSettingGithubRepositoryDispatcherが必要ですが,AppSettingappModule内でSingletonとして定義され,GithubRepositorynetworkModule内でSingletonとして定義され(かつGithubRepositoryGithubApiが必要ですが同一モジュール内でSingletonで提供されます),DispatcherappModule内で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!!).また,GithubOAuth認証周りについては,詳しくはAuthorizing OAuth Appsを参照してください.

終わりに

いくつかモダンなライブラリを使いつつ,Flux ArchitectureでAndroidアプリを作ってみました!10分で読めると言いつつ,気がつけばかなり分量が多くなってしまい反省してます😅

Flux は状態管理がしやすく,初めて見る画面でもActionを見ればどんなことをする画面なのかなんとなく把握できたりします.さらに,単一方向のデータフローに強制することで,誰が書いても同じようなコードになりやすく,責務が分割されやすく,コードの保守性が向上します.

この記事を読んで少しでも興味を持たれた方は, Android/iOSにかかわらずぜひFluxに挑戦してみてください. (※完成形はこちらリポジトリで公開しています)

おしまい