🏛️

Plaidの設計を参考にした, Coroutines + Retrofit2 でAPI通信をするアプリの実装

2020/09/25に公開

Qrunchのサービス終了に伴い、移行してきた記事です。
元記事の公開日は、2018/11/25です。

モチベーション

KotlinConf 2018の「Shaping Your App's Architecture with Kotlin and Architecture Components by Florina」というセッションで、KotlinとArchitecture Componentsを使った設計の話がありました。
Shaping Your App's Architecture with Kotlin and Architecture Components by Florina

Plaidというマテリアルデザイン実装のショーケースとなっているアプリを上記設計でリファクタリングする話でした。
Plaid

以下の理由から、Plaidの設計に沿って簡単なサンプルアプリを作ってみることにしました。

  • コルーチンを使った設計になっておりこれから勉強しようと思っていたところだった
  • 設計に関してコルーチンを使った部分以外も参考になりそうな部分が多そうだった
  • Plaidがリファクタリング中で箇所によって実装差分があったりしたので、シンプルにセッションの設計を理解したかった

サンプルアプリについて

undefined.jpg
undefined.jpg

ビールの情報を表示するアプリです。
一覧画面と詳細画面があります。

Punk API (https://punkapi.com/) という、Brewdogが出しているビールの情報が取得できるAPIを使いました。

コードはGitHubで公開しています
https://github.com/iiinaiii/Punks

本題

ここから、セッションの流れと同じ流れで設計の説明をしていきます。

全体設計

MVVM + レイヤードアーキテクチャ

レイヤーを以下のように分けたレイヤードアーキテクチャになっています。

  • Data
  • Domain
  • UI

UIレイヤーに関しては, ViewModelとLiveDataを使ったView/ViewModel設計になっています。
AACのViewModelでユーザーアクションの検知、データの取得などを行っていますが、View反映用のデータはUiModelとしてView側へ公開するようになっています。

コルーチンの使い方

undefined.jpg

できるだけUIに近いレイヤーまでコルーチンで扱えるようにします。launchをしたクラスと同じクラスでcancelできるようにするためです。
具体的には、Data、Domainレイヤーまではsuspend関数にしておき、ViewModelでコルーチンのlanch/cancelを行うようにします。

ViewModelでは、コルーチンを使って取得した結果をLiveDataに変換してViewに公開します。

Plaidとの違い

undefined.jpg

Plaidはデータソースが3つあり(Designer News/Dribbble/Product Hunt)、個別の処理を別モジュールに分けたマルチモジュール設計になっています。

サンプルアプリでは、データソースが1つでシンプルなアプリのため、マルチモジュールにはしていません。

Dataレイヤーの実装

undefined.jpg

登場クラス/インターフェース

  • API Service
  • RemoteDataSource
  • Repository

API Service

interface BeersSearchService {

    @GET("beers")
    fun search(
        @Query("page") page: Int
    ): Deferred<Response<List<BeerResponse>>>

}

API呼び出しを行い、結果を返却します。

retrofit2-kotlin-coroutines-adapterを使ってRetrofit2で取得した結果をDefferdで返却します。
https://github.com/JakeWharton/retrofit2-kotlin-coroutines-adapter

実装コード

BeersSearchService.kt

RemoteDataSource

undefined.jpg

  • 役割
    • リクエストデータを構築し、API Serviceからデータを取得する
  • 依存関係
    • API Serviceに依存
  • 入力
    • リクエスト情報
  • 出力
    • Result<TResponse>
class BeersRemoteDataSource @Inject constructor(private val service: BeersSearchService) {

    suspend fun search(
        page: Int
    ): Result<List<BeerResponse>> = safeApiCall(
        call = { requestSearch(page) },
        errorMessage = "Error getting Breweries data"
    )

    private suspend fun requestSearch(
        page: Int
    ): Result<List<BeerResponse>> {
        val response = service.search(page).await()
        if (response.isSuccessful) {
             return Result.Success(response.body())
        }
        return Result.Error(IOException("Error"))
    }

API Serviceを用いてAPI通信を行い、Resultに包んで返却します。

Result

RemoteDataSourceの結果はResult型で返すようにします。

sealed class Result<out T : Any> {

    data class Success<out T : Any>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()
		
}

Result.kt

成功または失敗を、Success/Errorで表現します。
Successの場合は、取得した任意の型のデータを持ちます。
Errorの場合は理由となるExceptionを持ちます。

Resultをwhenで分岐する際に漏れがないようにする

Resultをwhenで分岐してハンドリングする際、必ずSuccess/Errorのどちらもハンドリングするように強制したいところですが、コンパイラがチェックできない場合もあります。

val <T> T.exhaustive: T
    get() = this

それを可能にするため、上記のような拡張関数を用意し、when式の最後に追加することですべての分岐が網羅されていない場合にエラーとして表示してくれます。
undefined.jpg

Kotlin1.3で追加されたkotlin.Resultについて

kotlin-stdlibには1.3から成功または失敗を表すResultクラスが追加されています。 (https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-result/index.html)

自作のResultクラスを使わずこちらに置き換えることも考えましたが、関数の戻り値に使えないという制限があり、Result型で返却できなかったため、Plaidと同じく自作のResult型を使うことにしました。

関数の戻り値に使えないことに関しては、以下に記述があります。
https://github.com/Kotlin/KEEP/blob/master/proposals/stdlib/result.md#limitations

The rationale behind these limitations is that future versions of Kotlin may expand and/or change semantics of functions that return Result type and null-safety operators may change their semantics when used on values of Result type.

Kotlinの将来のバージョンで、以下に関してのセマンティクス(意図)を変更する可能性があるため、ということかと思います。

  • Result型を返す関数について
  • Result型の値に対してnull-safetyなオペレータが使われた場合について

safeApiCall

suspend fun <T : Any> safeApiCall(call: suspend () -> Result<T>, errorMessage: String): Result<T> {
    return try {
        call()
    } catch (e: Exception) {
        // An exception was thrown when calling the API so we're converting this to an IOException
        Result.Error(IOException(errorMessage, e))
    }
}

通信して結果を取得する際のエラーハンドリングをするトップレベル関数です。
成功の場合は第一引数callの結果をそのまま返し、エラーの場合はResult.Errorに変換して返します。

    suspend fun search(
        page: Int
    ): Result<List<BeerResponse>> = safeApiCall(
        call = { requestSearch(page) },
        errorMessage = "Error getting Breweries data"
    )

    private suspend fun requestSearch(
        page: Int
    ): Result<List<BeerResponse>> {
		val response = service.search(page).await()
        ...
    }

使用する際はこのようにcallに通信処理を、errorMessageにエラー時のメッセージを指定します。

実装コード

BeersRemoteDataSource.kt

Repository

undefined.jpg

  • 役割
    • データの取得と保存
    • In-memoryキャッシュ
  • 依存関係
    • RemoteDataSource and/or LocalDataSource に依存
    • LocalDatasourceはサンプルアプリでは使用していません
  • 入力
    • データ取得/保存に必要な情報 (idなど)
  • 出力
    • Result<TResponse>
class BeersRepository(private val remoteDataSource: BeersRemoteDataSource) {

    private val beerCache = mutableMapOf<Int, BeerResponse>()

    suspend fun search(
        page: Int
    ): Result<List<BeerResponse>> {
        val result = remoteDataSource.search(page)
        if (result is Result.Success) {
            cache(result.data)
        }
        return result
    }

DataSourceを用いてデータの取得を行い、返却します。

キャッシュの利用

class BeersRepository(private val remoteDataSource: BeersRemoteDataSource) {

    private val beerCache = mutableMapOf<Int, BeerResponse>()

    suspend fun search(
        page: Int
    ): Result<List<BeerResponse>> {
        val result = remoteDataSource.search(page)
        if (result is Result.Success) {
            cache(result.data)
        }
        return result
    }

    suspend fun getBeer(
        id: Int
    ): Result<BeerResponse> {
        val beer = beerCache[id]
        return if (beer != null) {
            Result.Success(beer)
        } else {
            Result.Error(IllegalStateException("Beer $id not cached"))
        }
    }

    private fun cache(data: List<BeerResponse>) {
        data.associateTo(beerCache) { it.id to it }
    }
...

サンプルアプリでは、一覧取得時のデータを詳細でも使い回すため、Repositoryでデータのキャッシュをするようにしました。
searchで一覧データを取得した際に、key=id, value=BeerResponseの形でキャッシュします。
詳細画面ではid指定でキャッシュからデータを取得します。

実装コード

BeersRepository.kt

Dataレイヤーの役割/依存関係/入出力のまとめ

undefined.jpg

Domainレイヤーの実装

undefined.jpg

登場クラス

  • UseCase

UseCase

undefined.jpg

  • 役割
    • ビジネスロジックに基づき、データの処理を行う
    • 単一のタスクの責任を負う
  • 依存関係
    • Repositories and/or 他のUseCases に依存
  • 入力
    • データ取得/保存に必要な情報 (idなど)
  • 出力
    • Result<T>
class SearchBeersUseCase @Inject constructor(
    private val beersRepository: BeersRepository
) {

    suspend operator fun invoke(page: Int): Result<List<Beer>> {
        val result = beersRepository.search(page)
        return when (result) {
            is Result.Success -> {
                Result.Success(result.data.map { it.toBeer() })
            }
            is Result.Error -> result
        }.exhaustive
    }
}

Reoisitoryを用いてデータの取得、加工を行って返却します。

単一タスクの責任を負う

セッションでは詳しく理由が述べられていませんでしたが、利用者(アクター)から見たときの役割を1UseCaseの役割とすることでシステムが何をできるかを利用者視点で明確化する、ということだと思います。

(例) 自動販売機で商品を買う時のPurchaseDrinkUseCaseの役割
悪い例

  • お金を受け取る
  • 指定された商品を排出する
  • お釣りを返却する

良い例

  • 商品を購入する

参考

operatoe修飾子付きのinvokeで単一タスク責任のUseCaseを表す

class SearchBeersUseCase @Inject constructor(
    private val beersRepository: BeersRepository
) {

    suspend operator fun invoke(page: Int): Result<List<Beer>> {
        val result = beersRepository.search(page)
        return when (result) {
            is Result.Success -> {
                Result.Success(result.data.map { it.toBeer() })
            }
            is Result.Error -> result
        }.exhaustive
    }

}

operator修飾子付きのinvokeメソッドを持ったオブジェクトは関数のように呼び出せます。
1つの役割のみを持ったUseCaseは、それ自体が1メソッドのようなものであるため、この方法で記述してあげると見やすく記述することができます。

class HomeViewModel(
    private val searchBeers: SearchBeersUseCase,
    private val dispatcherProvider: CoroutinesDispatcherProvider
) : ViewModel(), DataLoadingSubject {
     private fun getBeers(page: Int) = scope.launch(dispatcherProvider.computation) {
        ...
     val result = searchBeers(page)
}

使用する際は上記のように、searchBeersインスタンスに対して、searchBeers(page)のように記述すると、searchBeersUseCase.invoke(page)が呼び出されます。 変数名をsearchBeersUseCaseでなく、searchBeersにしているのも関数のように使用しているためです。

ResponseModel(TResponse)からDomainModel(T)への変換

        return when (result) {
            is Result.Success -> {
                Result.Success(result.data.map { it.toBeer() })
            }

APIから取得した形をアプリのdomainとして使いやすい形に変換する処理をUseCaseで行うようにしました。

fun BeerResponse.toBeer() = Beer(
    id = id,
    firstBrewed = first_brewed.run { "${month.getDisplayName(TextStyle.FULL, Locale.US).toUpperCase()} $year" },
    description = description,
    imageUrl = image_url,
    abv = "${abv.toStringOrDefault()}%",
    ibu = ibu.toStringOrDefault(),
    targetOg = target_og.toStringOrDefault(),
    targetFg = target_fg.toStringOrDefault(),
    ebc = ebc.toStringOrDefault(),
    srm = srm.toStringOrDefault(),
    volume = volume.toVolumeText(),
		...
)

private fun Float?.toStringOrDefault(): String {
    return this?.toString() ?: "-"
}

FloatからStringへの変換や、nullの際にデフォルト表示に変換しておく、などの変換です。
後にDomainModel→UiModelへの変換も出てくるのですが、DomainModel/UiModelでどの形にするかはアプリやチームの決めによるところかと思います。

実装コード

SearchBeersUseCase.kt
GetBeerUseCase.kt

UIレイヤーの実装

undefined.jpg

登場クラス/ファイル

  • ViewModel
  • UiModel
  • Activity
  • XML

ViewModel

undefined.jpg

  • 役割
    • データをUIに表示する形で公開する
    • ユーザーアクションに基づいて、UseCaseのアクションを実行する
    • コルーチンのlaunch/cancelを行う
  • 依存関係
    • UseCaseに依存
  • 入力
    • 情報取得に必要なid等(options)
    • ユーザーアクション
  • 出力
    • LiveData<UIModel>

ViewModelFactory

class BeerDetailViewModel(
    beerId: Int,
    private val getBeer: GetBeerUseCase,
    private val dispatcherProvider: CoroutinesDispatcherProvider
) : ViewModel() {

ViewModelはこのようにコンストラクタで必要なidやUseCaseを入れるようにします。デフォルトのViewModel生成には引数が取れないため、ViewModelProvider.Factoryを実装したFactoryクラスをViewModel毎に作成します。

class BeerDetailViewModelFactory(
    private val beerId: Int,
    private val getBeerUseCase: GetBeerUseCase,
    private val dispatcherProvider: CoroutinesDispatcherProvider
) : ViewModelProvider.Factory {

    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        if (modelClass != BeerDetailViewModel::class.java) {
            throw IllegalArgumentException("Unknown ViewModel class")
        }
        return BeerDetailViewModel(
            beerId,
            getBeerUseCase,
            dispatcherProvider
        ) as T
    }
}

コルーチンの起動、データ取得

class HomeViewModel(
    private val searchBeers: SearchBeersUseCase,
    private val dispatcherProvider: CoroutinesDispatcherProvider
) : ViewModel(), DataLoadingSubject {

    private val parentJob = Job()
    private val scope = CoroutineScope(dispatcherProvider.main + parentJob)

job, scopeをプロパティとして持っておき、

private fun getBeers(page: Int) = scope.launch(dispatcherProvider.computation) {
        withContext(dispatcherProvider.main) { showLoading() }
        isDataLoading = true

        val result = searchBeers(page)
        withContext(dispatcherProvider.main) {
            if (result is Result.Success) {
                pageNum++
                emitUiModel(showSuccess = Event(BeerResultUiModel(result.data.toItemUiModels())))
            } else {
                emitUiModel(showError = Event(Unit))
                isLastPageLoaded = true
            }
            isDataLoading = false
        }
    }

取得のタイミングで、scope.launchでコルーチンを起動、コルーチンスコープ内でUseCaseのsuspend関数の呼び出しを行います。
結果が取得できたらデータをUiModelに変換し、Viewへの通知を行います。

LiveDataを使ったUiModelの公開

data class BeerItemUiModel(
    val id: Int,
    val name: String,
    val firstBrewed: String,
    val imageUrl: String,
    val abv: String
)

DomainModelからUiに表示に適したUiModelへ変換します。

data class HomeUiModel(
    val showProgress: Boolean,
    val showSuccess: Event<BeerResultUiModel>?,
    val showError: Event<Unit>?
)

data class BeerResultUiModel(
    val beers: List<BeerItemUiModel>
)

リスト表示部分では表示状態も含めHomeUiModelというUiModelにまとめています。

    private val _uiModel = MutableLiveData<HomeUiModel>()
    val uiModel: LiveData<HomeUiModel>
        get() = _uiModel

UiModelはpostValueをするためMutableLiveDataである必要がありますが、購読する側のViewが値を変更できないようにするために、バッキングプロパティを使ってLiveDataとして公開します。

一度しか行いたくない処理にEventを使う

LiveData with SnackBar, Navigation and other events (the SingleLiveEvent case)

こちらで紹介されている方法ですが、画面遷移やトースト表示など、その画面で一度しか行いたくない処理をEventクラスのLiveDataとして公開します。

open class Event<out T>(private val content: T) {

    var consumed = false
        private set // Allow external read but not write

    /**
     * Consumes the content if it's not been consumed yet.
     * @return The unconsumed content or `null` if it was consumed already.
     */
    fun consume(): T? {
        return if (consumed) {
            null
        } else {
            consumed = true
            content
        }
    }

Eventクラスはイベントが消費されたかどうかのフラグを持ち、一度値が使われたあとに値を取得しようとするとnullを返します。

if (uiModel.showSuccess != null && !uiModel.showSuccess.consumed) {
     uiModel.showSuccess.consume()?.let { beerResult ->
          updateBeers(beerResult.beers)
     }
 }

利用側はこのようにEventから値を取得して処理を実行します。

Activity/XMLからViewModelを利用

Activity

class HomeActivity : AppCompatActivity() {

    @Inject
    internal lateinit var viewModel: HomeViewModel
      
	private val binding by contentView<HomeActivity, ActivityHomeBinding>(
        R.layout.activity_home
    )

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        inject()

        binding.setLifecycleOwner(this)
        binding.viewModel = viewModel

        setupRecyclerView()
        viewModel.uiModel.observe(this, Observer {
            val uiModel = it ?: return@Observer

            if (uiModel.showSuccess != null && !uiModel.showSuccess.consumed) {
                uiModel.showSuccess.consume()?.let { beerResult ->
                    updateBeers(beerResult.beers)
                }
            }

            if (uiModel.showError != null && !uiModel.showError.consumed) {
                uiModel.showError.consume()?.let {
                    showError()
                }
            }
        })
        viewModel.loadBeers()
    }

おおまかな流れは以下のようになります。

  • ViewModelをDagger2を使ってinject
  • DataBindingのBindingクラスにViewModelを受け渡し
  • ViewModelが公開しているuiModelの購読
  • uiModelの変更を検知したらuiの更新

XML

  <data>
    <variable
      name="viewModel"
      type="com.iiinaiii.punks.ui.home.HomeViewModel" />
  </data>

    <ProgressBar
      android:id="@+id/loading"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
	  ...
      app:visibleUnless="@{viewModel.uiModel.showProgress}" />

viewModel.uiModelがLiveDataとなっているため、値が変更されたタイミングでViewに反映することができます。

実装コード

HomeViewModel.kt
HomeViewModelFactory.kt
HomeActivity.kt
activity_home.xml

さいごに

Plaidを参考に、API通信でデータ取得し一覧/詳細画面を表示する簡単なサンプルアプリを作成し、その設計を紹介しました。 KotlinConfのセッション動画やPlaidリポジトリを見るとより理解が深まるかと思います。

サンプルアプリのリポジトリ

Punks

参考

Discussion