LoginSignup
22
19

More than 3 years have passed since last update.

AndroidXでクリーンアーキテクチャ(LiveData, Room, coroutines)

Last updated at Posted at 2019-12-11

この記事は Android #2 Advent Calendar 2019 12日目の記事です。

はじめに

こんにちは たけのこ です!

早速ですが、業務でゴリゴリと小規模なAndroidアプリを作って溜まった
うっぷんナレッジを共有させてください。

クリーンアーキテクチャと題名で言っていますが、
解釈違いがあるかもしれませんがご了承ください。
(本を少し読んでなんとなく分かった気でいる感じなので、すいません・・・)

そもそも AndroidX って何?

Android開発をモダンな状態にするために元のSupport Libraryに破壊的変更を行わずに刷新した新しいライブラリ群の総称です。
https://developer.android.com/jetpack/androidx/

AndroidX のアーキテクチャでは何を使うの?

Android Developers のアーキテクチャガイドがあるので一読してもらうと良いかと思います。
https://developer.android.com/jetpack/docs/guide?hl=ja

ViewModelLiveDataが使われていたり、
ModelRoomRemote Data Sourcewebserviceが使われていますね。

AndroidXではViewModelのバインディングにLiveData、SQLiteへのアクセスにはRoomを使っていきましょう!

AndroidX でクリーンアーキテクチャ

作成したアプリはこちら
TakenokoTech/CleanArchitectureX - GitHub

使用したライブラリの紹介

ライブラリ 解説
lifecycle ViewModelやLiveData
Room SQLite
Coroutine 軽量スレッド(Rxの代わり)
koin DI(Dagger2の代わり)
fuel 通信(Retrofitの代わり)
Moshi JSONパース用
Navigation iOSのStoryboardのようにGUIで画面遷移が書ける
Mockito-Kotlin モックテスト
Robolectric UnitTestでエミュレータを起動
ktlint フォーマッター

実現したいアーキテクチャ

まず、単方向の依存になるようにする

CleanArchitectureX__1@2x.png

ドメイン駆動設計の原則「複雑なドメインの設計は、モデルベースで行うべき」を
実現するためにザックリと三層に分けてみます。

  • プレゼン層 = Androidアプリ依存のコンポーネント(アプリコンポーネント)+ ViewModel
  • ドメイン層 = ビジネスロジック(UseCase)
  • インフラ層 = 永続化処理の集約(Repository) + ローカルDBやAPIなど

次に、依存性逆転の原則(DIP)を意識して書き直す

CleanArchitectureX__2@2x.png

クリーンアーキテクチャにするためにレイヤーをまたぐ場合は
インターフェースを挟んで依存関係が制御の流れに逆転するようにします。
Dependency Inversion Principle

また、ヘルパークラスなどを作成して再利用しやすい形にしてあげるのが良さそうです。
ネットワークステータスの公開

コーディング(インターフェース)

先に完成形のクラス図を貼っておきます。

CleanArchitecture.png

だいたいのイメージは掴めそうでしょうか?
では、インターフェースから順に作ってみます!

UseCase

まずは、プレゼン層とドメイン層の間に挟むUseCaseのテンプレートを作ってみます。
このクラスを継承することでI/Oが共通化されるので変更の多いプレゼン層からも扱いやすくなります。
また、UseCaseの実装以降のレイヤーはワーカースレッドで動くようにしました。
別途、SupervisorJobでジョブ管理しても良いかもしれません。

usecase/Usecase.kt
abstract class Usecase<Q : Any, P : Any>(private val context: Context, private val scope: CoroutineScope) : KoinComponent {

    protected var result = MediatorLiveData<UsecaseResult<P>>()
    val source: LiveData<UsecaseResult<P>> = result

    @MainThread
    open fun execute(param: Q) {
        result.postValue(UsecaseResult.Pending())
        scope.launch {
            runCatching {
                call(param).await()
            }.fold({
                result.postValue(UsecaseResult.Resolved(it))
            }, {
                result.postValue(UsecaseResult.Rejected(it))
            })
        }
    }

    @WorkerThread
    protected abstract suspend fun call(param: Q): Deferred<P>
}

MediatorLiveDataは変更可能であり、LiveDataは参照のみ可能です。
なので、外部参照可能なプロパティとする場合は上記のような実装が必要なようです。
さらに、UseCaseが返すLiveDataには、データと状態 の両方をカプセル化したものを返すようにします。

entity/UsecaseResult.kt
sealed class UsecaseResult<P> {
    class Pending<P> : UsecaseResult<P>()
    data class Resolved<P>(val value: P) : UsecaseResult<P>()
    data class Rejected<P>(val reason: Throwable) : UsecaseResult<P>()
}

Usecase.png

ViewModelなどからUseCaseを呼ぶ場合はUsecase.sourceをCoroutineScopeでオブザーブ後に
Usecase.execute()で発火することで気持ちいい動きをしてくれます。

Repository

Repositoryでは、下のLocal Data Source と Remote Data Source の処理を集約します。
ただのinterfaceクラスです。

repository/UserRepository.kt
interface UserRepository {
    suspend fun getAllUser(): List<UserLocalDataSource.UserEntity>
    suspend fun addUser(user: UserLocalDataSource.UserEntity)
}

Local Data Source

Room (SQLite)

Roomのヘルパークラスはこんな感じ。
LocalDataSourceとRoomを繋ぐ役割を担います。

AppDatabase.kt
interface AppDatabase {
    fun userDao(): UserDao

    companion object {
        @Volatile private var instance: AppDatabaseImpl? = null
        fun getDatabase(context: Context): AppDatabase = synchronized(this) {
            instance = if (instance == null) Room.databaseBuilder(context, AppDatabaseImpl::class.java, "CleanArchitectureX-DB").build() else instance
            return instance!!
        }
    }
}

@Database(entities = [UserLocalDataSource.UserEntity::class], version = 1)
abstract class AppDatabaseImpl : RoomDatabase(), AppDatabase {
    abstract override fun userDao(): UserDao
}

local.png

Remote Data Source

Fuel (Rest API)

Fuelのヘルパークラスはこんな感じ。
RemoteDataSourceとFuelを繋ぐ役割を担います。

AppRestApi.kt
interface AppRestApi {
    suspend fun <T : Any> execute(param: ApiParameter<T>, clazz: KClass<T>): ApiResult<T>
}
suspend inline fun <reified T : Any> AppRestApi.fetch(param: ApiParameter<T>) = execute(param, T::class)

@Suppress("OVERRIDE_BY_INLINE")
class AppRestApiImpl : AppRestApi {

    @WorkerThread
    override suspend inline fun <T : Any> execute(param: ApiParameter<T>, clazz: KClass<T>): ApiResult<T> {
        val bodyStr = if (param.body != null) OBJECT_MAPPER.adapter(Any::class.java).toJson(param.body) else ""
        val requestBuilder = param.call().header(param.header).jsonBody(bodyStr)
        val adapter = param.adapter ?: planeAdapter(clazz.java)

        val (request, response, result) = runCatching {
            requestBuilder.awaitResponseResult(adapter)
        }.getOrElse {
            Triple(requestBuilder, Response.error(), Result.error(Exception(it)))
        }

        return result.fold({ ApiResult.Success(it) }, { ApiResult.Failed(it, response.statusCode) })
    }
}
ApiResult.kt
sealed class ApiResult<P> {
    data class Success<P>(val value: P) : ApiResult<P>()
    data class Failed<P>(val cause: Exception, val statusCode: Int) : ApiResult<P>()
}

Moshiでリスト型のJSONを変換する場合はlistAdapterのようにデシリアライズの処理を書かないといけないので、AppRestApiのパラメータでMoshiAdapterを渡すような記述をしています。

Moshi+.kt
val OBJECT_MAPPER: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()

/**
 * moshi adapter
 */
inline fun <reified T : Any> planeAdapter() = planeAdapter(T::class.java)
fun <T : Any> planeAdapter(clazz: Class<T>) = object : ResponseDeserializable<T> {
    override fun deserialize(content: String): T? = OBJECT_MAPPER.adapter(clazz).fromJson(content)
}

/**
 * moshi list adapter
 */
inline fun <reified T : Any> listAdapter() = listAdapter(T::class.java)
fun <T : Any> listAdapter(clazz: Class<T>) = object : ResponseDeserializable<List<T>> {
    override fun deserialize(content: String): List<T>? {
        val type = Types.newParameterizedType(List::class.java, clazz)
        val listAdapter = OBJECT_MAPPER.adapter<List<T>>(type)
        return listAdapter.fromJson(content)
    }
}

/**
 * moshi map adapter
 */
inline fun <reified K : Any, reified V : Any> mapAdapter() = mapAdapter(K::class.java, V::class.java)
fun <K : Any, V : Any> mapAdapter(clazzK: Class<K>, clazzV: Class<V>) = object : ResponseDeserializable<Map<K, V>> {
    override fun deserialize(content: String): Map<K, V>? {
        val type = Types.newParameterizedType(Map::class.java, clazzK, clazzV)
        val listAdapter = OBJECT_MAPPER.adapter<Map<K, V>>(type)
        return listAdapter.fromJson(content)
    }
}

remote.png

ベースの実装はここまで!

コーディング(中身)

早速、VIewModelから順番に作ったクラスを使って実装してみます。

Implement ViewModel

usecase/TopViewModel.kt
class TopViewModel : ViewModel(), KoinComponent {

    private val registerUserUsecase: RegisterUserUsecase by inject { parametersOf(viewModelScope) }
    private val loadUserUsecase: LoadUserUsecase by inject { parametersOf(viewModelScope) }

    private val _userNameList: MediatorLiveData<List<String>> = MediatorLiveData()
    val userNameList: LiveData<List<String>> = _userNameList

    private val _isLoading: MediatorLiveData<Boolean> = MediatorLiveData()
    val isLoading: LiveData<Boolean> = _isLoading

    init {
        _userNameList.addSource(loadUserUsecase.source) { loadUserUsecaseHandlerToList(it) }
        _isLoading.addSource(loadUserUsecase.source) { usecaseHandlerIsLoading() }
        _isLoading.addSource(registerUserUsecase.source) { usecaseHandlerIsLoading().run { registerUserUsecaseHandler(it) } }
    }

    fun load() {
        loadUserUsecase.execute(Unit)
    }

    fun register() {
        val user = UserLocalDataSource.UserEntity("UserName", "DisplayName")
        val param = RegisterUserUsecase.Param(user)
        registerUserUsecase.execute(param)
    }

    private fun loadUserUsecaseHandlerToList(result: UsecaseResult<List<String>>): Any = when (result) {
        is UsecaseResult.Pending -> Unit
        is UsecaseResult.Resolved -> _userNameList.value = result.value
        is UsecaseResult.Rejected -> Log.e("TopViewModel", result.reason.localizedMessage)
    }

    private fun registerUserUsecaseHandler(result: UsecaseResult<Unit>): Any = when (result) {
        is UsecaseResult.Pending -> Unit
        is UsecaseResult.Resolved -> load()
        is UsecaseResult.Rejected -> Log.e("TopViewModel", result.reason.localizedMessage)
    }

    private fun usecaseHandlerIsLoading() {
        val registerUserUsecaseIsLoading = registerUserUsecase.source.value.isLoading()
        val loadUserUsecaseLoading = loadUserUsecase.source.value.isLoading()
        _isLoading.value = registerUserUsecaseIsLoading || loadUserUsecaseLoading
    }
}

ViewModelでもActivityまたはLayout resourceから参照するLiveDataや関数はpublicにして、それ以外はprivateにしています。
こちらも破壊的変更可能なMediatorLiveDataは外部から参照できないようにしています。

CleanArchitectureX@2x(1).png

Implement Usecase

usecase/RegisterUserUsecase.kt
class RegisterUserUsecase(context: Context, private val scope: CoroutineScope) : Usecase<RegisterUserUsecase.Param, Unit>(context, scope) {

    private val userRepository: UserRepository by inject()

    @WorkerThread
    override suspend fun call(param: Param): Deferred<Unit> = scope.async(Dispatchers.IO) {
        Thread.sleep(1000)
        return@async userRepository.addUser(param.user)
    }

    data class Param(val user: UserLocalDataSource.UserEntity)
}
usecase/LoadUserUsecase.kt
class LoadUserUsecase(context: Context, private val scope: CoroutineScope) : Usecase<Unit, List<String>>(context, scope) {

    private val userRepository: UserRepository by inject()

    @WorkerThread
    override suspend fun call(param: Unit): Deferred<List<String>> = scope.async(Dispatchers.IO) {
        Thread.sleep(1000)
        return@async userRepository.getAllUser().map { "${it.id}: ${it.displayName}" }
    }
}

Usecaseではプレゼン層の依存を断ち切り、インフラストラクチャー層をブラックボックスとして扱うようにします。
また、UseCaseは永続化しないビジネスロジックを書くと良いかと思います。

Implement Repository

repository/UserRepositoryImpl.kt
class UserRepositoryImpl : UserRepository, KoinComponent {

    private val local: UserLocalDataSource by inject()
    private val network: UserRemoteDataSource by inject()

    @WorkerThread
    override suspend fun getAllUser(): List<UserLocalDataSource.UserEntity> {
        val users = network.getUser()
        local.deleteAll()
        local.insertAll(*users.map { UserLocalDataSource.User(it.user_name, it.display_name) }.toTypedArray())
        return local.getAll()
    }

    @WorkerThread
    override suspend fun addUser(user: UserLocalDataSource.UserEntity) {
        network.postUser(user = UserEntity(user.userName, user.displayName))
        return local.insertAll(user)
    }
}

RepositoryではLocalのデータとRemoteのデータを集約します。
どのくらいの責務を持たせるか謎です。

Implement RemoteDataSource

repository/remote/UserRemoteDataSource.kt
class UserRemoteDataSource : KoinComponent {

    private val restApi: AppRestApi by inject()

    @WorkerThread
    suspend fun getUser(): List<UserEntity> {
        val param = Get<List<UserEntity>>(
            url = getUserUrl,
            adapter = listAdapter()
        )
        return when (val it = restApi.fetch(param)) {
            is ApiResult.Success -> it.value
            is ApiResult.Failed -> when (it.statusCode) {
                else -> throw it.cause
            }
        }
    }

    @WorkerThread
    suspend fun postUser(user: UserEntity): ResultEntity {
        val param = Post<ResultEntity>(
            url = addUserUrl,
            body = user
        )
        return when (val it = restApi.fetch(param)) {
            is ApiResult.Success -> it.value
            is ApiResult.Failed -> when (it.statusCode) {
                else -> throw it.cause
            }
        }
    }

    @JsonClass(generateAdapter = true)
    data class UserEntity(
        @Json(name = "user_name") val userName: String,
        @Json(name = "display_name") val displayName: String
    )

    @JsonClass(generateAdapter = true)
    data class ResultEntity(
        @Json(name = "status") val status: String
    )
}

APIを定義します。
エンティティはパッケージを分けた方が良いかと思います。

Implement LocalDataSource

repository/local/UserLocalDataSource.kt
class UserLocalDataSource : UserDao, KoinComponent {

    private val database: AppDatabase by inject()

    @WorkerThread
    override suspend fun getAll(): List<UserEntity> {
        return database.userDao().getAll()
    }

    @WorkerThread
    override suspend fun insertAll(vararg users: UserEntity) {
        val result = database.userDao().insertAll(*users)
        return result
    }

    @WorkerThread
    override suspend fun deleteAll() {
        val result = database.userDao().deleteAll()
        return result
    }

    @Entity
    data class UserEntity(
        @ColumnInfo(name = "userName") val userName: String,
        @ColumnInfo(name = "displayName") val displayName: String,
        @PrimaryKey(autoGenerate = true) val id: Int = 0
    )
}

Roomを定義します。また、Daoは別で用意します。

entity/room/UserDao.kt
@Dao
interface UserDao {
    @Insert
    suspend fun insertAll(vararg users: UserLocalDataSource.UserEntity)

    @Query(QUERY_GET_ALL)
    suspend fun getAll(): List<UserLocalDataSource.UserEntity>

    @Query(QUERY_DELETE_ALL)
    suspend fun deleteAll()
}

/** ユーザー全件取得 */
const val QUERY_GET_ALL = """
    SELECT *
    FROM user
"""

/** ユーザー全件削除 */
const val QUERY_DELETE_ALL = """
    DELETE FROM user
"""

以上で、ViewModel層以下の実装は完了です!
あとは、TopViewModelのpubliicなプロパティや関数を使ってUIを作ってください。

サンプルコードでは下のような簡単な画面を作りました。
今まで通りUIはdatabindingを使って貰えれば大丈夫かと思います。

+αでやってみたこと

ほかに盛り込んでみたものを紹介します。

koinでDIしてみる

コードの中に by inject() ってのがあったと思うんですが、koinを使ってDIするためにつけてました。
Applicationの中でstartKoinすればオブジェクトが注入されます。

App.kt
class App : Application() {

    override fun onCreate() {
        super.onCreate()

        // Dependency Injection
        startKoin {
            androidContext(this@App)
            modules(diModules)
        }
    }
}

private val viewmodelModules = module {
    factory { TopViewModel() }
}

private val usecaseModules = module {
    factory { RegisterUserUsecase(androidContext() as Application, it.get<CoroutineScope>() as CoroutineScope) }
    factory { LoadUserUsecase(androidContext() as Application, it.get<CoroutineScope>() as CoroutineScope) }
    factory { BackgroundUsecase(androidContext() as Application, it.get<CoroutineScope>() as CoroutineScope) }
}

private val repositoryModules = module {
    factory { UserRepositoryImpl() as UserRepository }
}

private val localModules = module {
    single { AppDatabase.getDatabase(androidContext() as Application) }
    single { UserLocalDataSource() }
}

private val remoteModules = module {
    single { AppRestApiImpl() as AppRestApi }
    single { UserRemoteDataSource() }
}

val diModules = listOf(
    viewmodelModules,
    usecaseModules,
    repositoryModules,
    localModules,
    remoteModules
)

はじめて触ったのですが、Dagger2よりシンプルでした!
これでテストも書きやすくなるので使っていきたいですね。

最後に

今回はLiveDataとRoomを業務で使ってみて上手くいったアーキテクチャを書いてみました。
LiveDataとかコルーチンを使ったアーキテクチャの記事があまりなくて習得するのに苦労しました(汗
この記事が誰かの役に立てば幸いです。

AndroidXに入ってからはRxからコルーチンに移行していくようなので、これからも勉強が必要ですね。
まだ、@ExperimentalがついているAPIが多いので本番で使うのは怖いですが、恐れずに触りましょう!

22
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
19