この記事は 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
ViewModel
にLiveData
が使われていたり、
Model
にRoom
、Remote Data Source
にwebservice
が使われていますね。
AndroidXではViewModelのバインディングにLiveData、SQLiteへのアクセスにはRoomを使っていきましょう!
AndroidX でクリーンアーキテクチャ
使用したライブラリの紹介
ライブラリ | 解説 |
---|---|
lifecycle | ViewModelやLiveData |
Room | SQLite |
Coroutine | 軽量スレッド(Rxの代わり) |
koin | DI(Dagger2の代わり) |
fuel | 通信(Retrofitの代わり) |
Moshi | JSONパース用 |
Navigation | iOSのStoryboardのようにGUIで画面遷移が書ける |
Mockito-Kotlin | モックテスト |
Robolectric | UnitTestでエミュレータを起動 |
ktlint | フォーマッター |
実現したいアーキテクチャ
まず、単方向の依存になるようにする
ドメイン駆動設計の原則「複雑なドメインの設計は、モデルベースで行うべき」を
実現するためにザックリと三層に分けてみます。
- プレゼン層 = Androidアプリ依存のコンポーネント(アプリコンポーネント)+ ViewModel
- ドメイン層 = ビジネスロジック(UseCase)
- インフラ層 = 永続化処理の集約(Repository) + ローカルDBやAPIなど
次に、依存性逆転の原則(DIP)を意識して書き直す
クリーンアーキテクチャにするためにレイヤーをまたぐ場合は
インターフェースを挟んで依存関係が制御の流れに逆転するようにします。
→ Dependency Inversion Principle
また、ヘルパークラスなどを作成して再利用しやすい形にしてあげるのが良さそうです。
→ ネットワークステータスの公開
コーディング(インターフェース)
先に完成形のクラス図を貼っておきます。
だいたいのイメージは掴めそうでしょうか?
では、インターフェースから順に作ってみます!
UseCase
まずは、プレゼン層とドメイン層の間に挟むUseCaseのテンプレートを作ってみます。
このクラスを継承することでI/Oが共通化されるので変更の多いプレゼン層からも扱いやすくなります。
また、UseCaseの実装以降のレイヤーはワーカースレッドで動くようにしました。
別途、SupervisorJobでジョブ管理しても良いかもしれません。
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には、データと状態
の両方をカプセル化したものを返すようにします。
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>()
}
ViewModelなどからUseCaseを呼ぶ場合はUsecase.source
をCoroutineScopeでオブザーブ後に
Usecase.execute()
で発火することで気持ちいい動きをしてくれます。
Repository
Repositoryでは、下のLocal Data Source と Remote Data Source の処理を集約します。
ただのinterfaceクラスです。
interface UserRepository {
suspend fun getAllUser(): List<UserLocalDataSource.UserEntity>
suspend fun addUser(user: UserLocalDataSource.UserEntity)
}
Local Data Source
Room (SQLite)
Roomのヘルパークラスはこんな感じ。
LocalDataSourceとRoomを繋ぐ役割を担います。
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
}
Remote Data Source
Fuel (Rest API)
Fuelのヘルパークラスはこんな感じ。
RemoteDataSourceとFuelを繋ぐ役割を担います。
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) })
}
}
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を渡すような記述をしています。
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)
}
}
ベースの実装はここまで!
コーディング(中身)
早速、VIewModelから順番に作ったクラスを使って実装してみます。
Implement ViewModel
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は外部から参照できないようにしています。
Implement Usecase
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)
}
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
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
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
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は別で用意します。
@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
すればオブジェクトが注入されます。
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が多いので本番で使うのは怖いですが、恐れずに触りましょう!