コンテンツにスキップ
  • 中級者向け

Android architecture guide に従った3層アーキテクチャ

Android developers で公開されているアプリアーキテクチャガイドは、Android アプリケーションのベースとなるアーキテクチャとして広く知られています。

以下は Domain レイヤーを採用する場合のアーキテクチャ定義の例です。

  • ディレクトリapp/src/main/kotlin/com/example/project/
    • ディレクトリui/
      • ディレクトリhome/
        • HomeUiState.kt
        • HomeViewModel.kt
        • HomeScreen.kt
    • ディレクトリdomain/
      • ディレクトリuser/
        • GetUserUseCase.kt
      • ディレクトリmodel/
        • User.kt
    • ディレクトリdata/
      • ディレクトリuser/
        • UserRepository.kt
        • UserRepositoryImpl.kt
        • UserRemoteDataSource.kt
ProjectArchitecture.kt
@file:OptIn(ExperimentalKatachiApi::class)
import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.architecture
import me.tbsten.katachi.dsl.gradle.gradle
/**
* See https://developer.android.com/topic/architecture
*/
val projectArchitecture = architecture {
gradle() // wrapper・settings・build スクリプトなど Gradle のファイル
// https://developer.android.com/topic/architecture/ui-layer
"ui".group {
uiState()
viewModel()
screen()
}
// https://developer.android.com/topic/architecture/domain-layer
"domain".group {
useCase()
domainModel()
}
// https://developer.android.com/topic/architecture/data-layer
"data".group {
repositoryInterface()
repositoryImplementation()
dataSource()
}
}
// Constants.kt
const val appPackageDir = "com/example/project"
// roles/UiState.kt
@file:OptIn(ExperimentalKatachiApi::class)
import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.RoleScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile
import me.tbsten.katachi.konsist.konsist
fun DeclarationContainerScope.uiState() = "UiState" {
summary = "画面が描画に使う状態を、変更されない値として持つ"
layout {
"app" / mainSourceSet / kotlin / appPackageDir / "ui/*" / "*UiState".ktFile()
}
shouldBeImmutable()
}
private fun RoleScope.shouldBeImmutable() =
"プロパティはすべて val であること".konsist {
classes().must { klass -> klass.properties().all { it.isVal } }
}
// roles/ViewModel.kt
@file:OptIn(ExperimentalKatachiApi::class)
import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.RoleScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile
import me.tbsten.katachi.konsist.konsist
fun DeclarationContainerScope.viewModel() = "ViewModel" {
summary = "画面の状態を StateFlow で公開し、画面からのイベントを受け取る"
layout {
"app" / mainSourceSet / kotlin / appPackageDir / "ui/*" / "*ViewModel".ktFile()
}
shouldContainsViewModelClass()
shouldExtendsViewModel()
shouldUiStateFlowBePublic()
}
private fun RoleScope.shouldContainsViewModelClass() =
"ファイル名と同じ名前のクラスを1つだけ宣言すること".konsist {
files.must { file -> file.classes().map { it.name } == listOf(file.name) }
}
private fun RoleScope.shouldExtendsViewModel() =
"ViewModel を継承すること".konsist {
classes().must { klass -> klass.parents().any { it.name == "ViewModel" } }
}
private fun RoleScope.shouldUiStateFlowBePublic() =
"UiState の Flow を public で公開すること".konsist {
classes().must { klass ->
klass.properties().any {
it.hasPublicOrDefaultModifier && it.type?.name?.startsWith("StateFlow") == true
}
}
}
// roles/Screen.kt
@file:OptIn(ExperimentalKatachiApi::class)
import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.RoleScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile
import me.tbsten.katachi.konsist.konsist
fun DeclarationContainerScope.screen() = "Screen" {
summary = "画面を描く public な Composable"
layout {
"app" / mainSourceSet / kotlin / appPackageDir / "ui/*" / "*Screen".ktFile()
}
shouldContainsPublicScreenComposable()
}
private fun RoleScope.shouldContainsPublicScreenComposable() =
"ファイル名と同じ名前の public な Composable を宣言すること".konsist {
files.must { file ->
file.functions().any {
it.name == file.name && it.hasPublicOrDefaultModifier &&
it.hasAnnotationWithName("Composable")
}
}
}
// roles/UseCase.kt
@file:OptIn(ExperimentalKatachiApi::class)
import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.RoleScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile
import me.tbsten.katachi.konsist.konsist
fun DeclarationContainerScope.useCase() = "UseCase" {
summary = "アプリ固有の1つの振る舞いを invoke で実行する"
layout {
"app" / mainSourceSet / kotlin / appPackageDir / "domain/*" / "*UseCase".ktFile()
}
shouldContainsUseCaseClass()
shouldSingleInvokePublicMethod()
}
private fun RoleScope.shouldContainsUseCaseClass() =
"ファイル名と同じ名前のクラスを1つだけ宣言すること".konsist {
files.must { file -> file.classes().map { it.name } == listOf(file.name) }
}
private fun RoleScope.shouldSingleInvokePublicMethod() =
"public な invoke メソッドを1つだけ持つこと".konsist {
classes().must { klass ->
klass.functions().filter { it.hasPublicOrDefaultModifier }.map { it.name } == listOf("invoke")
}
}
// roles/DomainModel.kt
@file:OptIn(ExperimentalKatachiApi::class)
import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile
fun DeclarationContainerScope.domainModel() = "DomainModel" {
summary = "ドメイン層で扱う、アプリの概念を表すモデル"
layout {
"app" / mainSourceSet / kotlin / appPackageDir / "domain/model" / "*".ktFile()
}
}
// roles/RepositoryInterface.kt
@file:OptIn(ExperimentalKatachiApi::class)
import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.RoleScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile
import me.tbsten.katachi.konsist.konsist
fun DeclarationContainerScope.repositoryInterface() = "RepositoryInterface" {
summary = "データの取得と保存の窓口となる interface"
layout {
"app" / mainSourceSet / kotlin / appPackageDir / "data/*" / "*Repository".ktFile()
}
shouldContainsRepositoryInterface()
}
private fun RoleScope.shouldContainsRepositoryInterface() =
"ファイル名と同じ名前の interface を1つだけ宣言すること".konsist {
files.must { file -> file.interfaces().map { it.name } == listOf(file.name) }
}
// roles/RepositoryImplementation.kt
@file:OptIn(ExperimentalKatachiApi::class)
import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.RoleScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile
import me.tbsten.katachi.konsist.konsist
fun DeclarationContainerScope.repositoryImplementation() = "RepositoryImplementation" {
summary = "RepositoryInterface の実装"
layout {
"app" / mainSourceSet / kotlin / appPackageDir / "data/*" / "*RepositoryImpl".ktFile()
}
shouldContainsRepositoryImplementationClass()
}
private fun RoleScope.shouldContainsRepositoryImplementationClass() =
"ファイル名と同じ名前のクラスを1つだけ宣言すること".konsist {
files.must { file -> file.classes().map { it.name } == listOf(file.name) }
}
// roles/DataSource.kt
@file:OptIn(ExperimentalKatachiApi::class)
import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.RoleScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile
import me.tbsten.katachi.konsist.konsist
fun DeclarationContainerScope.dataSource() = "DataSource" {
summary = "Repository が使う、データの取得元・保存先へのアクセス"
layout {
"app" / mainSourceSet / kotlin / appPackageDir / "data/*" / "*DataSource".ktFile()
}
shouldContainsDataSourceClass()
}
private fun RoleScope.shouldContainsDataSourceClass() =
"ファイル名と同じ名前のクラスを1つだけ宣言すること".konsist {
files.must { file -> file.classes().map { it.name } == listOf(file.name) }
}

konsist { } を使うので、:architecture-test に katachi-konsist への依存を足し、テストでは assert(FileConstraintCheck()) を呼んでください。assert() だけでは制約が評価されず、[UncheckedFileConstraint] で落ちます。詳しくは Konsist との統合 を参照してください。

層を group に、ファイルの種類を Role に分けた実際の定義は、sample/android の ProjectArchitecture.kt にあります。サンプルはドメイン層を持たず、画面ごとにモジュールを分けた構成なので、このページの定義とは形が違います。