アーキテクチャと依存性逆転

2022-03-06
AndroidKotlinアーキテクチャ
    はじめに
    アプリを作る
    層を作る
    Domainを作る
    UseCaseを作る
    Controllerを作る
    一旦つなげる
    Presenterを作る
    依存関係の整理
    依存性逆転の原則
    おわりに

はじめに

このアーキテクチャで下層が上層に依存しないように依存関係の整理をします。
クリーンアーキテクチャを参考にしていますが、知らなくても読めると思います。
完成品はgithubに上げてあります

アプリを作る

ボタンを押すと表示されている数字がインクリメントされるとても簡単なアプリを作ろうと思います。
とりあえずEmptyActivityでプロジェクトを作ります。
DIライブラリはHiltを使うので依存関係を追加してアノテーションを付けたりします。

buildscript {
ext{
kotlin_version = '1.6.10'
hilt_version = '2.40.5'
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:4.2.2"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
}
}
plugins {
id 'com.android.application' version '7.1.1' apply false
id 'com.android.library' version '7.1.1' apply false
id 'org.jetbrains.kotlin.android' version '1.5.30' apply false
}
task clean(type: Delete) {
delete rootProject.buildDir
}
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
android {
compileSdk 31
defaultConfig {
applicationId "com.shirobutton.dependency_inversion"
minSdk 26
targetSdk 31
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
// viewBindingも使います。
viewBinding true
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.5.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
// Hilt
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-compiler:$hilt_version"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
@HiltAndroidApp
class MyApplication: Application()
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.shirobutton.dependency_inversion">
<application
android:name=".MyApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.DependencyInversion">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

そしてMainActivityにButtonと数字を追加します。

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private var number: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
binding.textView.text = number.toString()
binding.button.setOnClickListener {
// TODO
}
setContentView(binding.root)
}
}
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".driver.MainActivity">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Button"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView" />
</androidx.constraintlayout.widget.ConstraintLayout>

TODOの部分にこれを入れてしまえばそれで終わりなのですが、

number += 1
binding.textView.text = number.toString()

この部分が複雑であっても対応できるようにアーキテクチャを使います。

層を作る

層をパッケージで表現するために各パッケージを作ります。

  • driver
  • adapter
  • application
  • entity

すでにあるMainActivityはdriverのパッケージに入れましょう

Domainを作る

Domainはentity層にあります。
ここにはアプリの中核になるロジック、いわゆるビジネスロジックを書きます。
今回は「数値をインクリメントする」がこのアプリのビジネスロジックになります。
それを扱うCalculatorを作ってみます。
interfaceを挟んでいるのは後で共通化するためではなく、
Calculatorを呼び出す場合、interface経由で呼び出せば疎結合になるためです。
基本的に抽象に依存するほうが良いという考えですね。
あとでHiltを使うために@Injectアノテーションを付けておきます。

interface Calculator {
fun increment(number: Int): Int
}
class CalculatorImpl @Inject constructor(): Calculator {
override fun increment(number: Int) = number + 1
}

UseCaseを作る

UseCaseはapplication層にあります。
この層はアプリケーションが何ができるかを表現する層です。
entity層とadapter層の間接層という一面もあります。
UseCaseは一つのプロセスを表す関数のようなものです。
今回は「数値をインクリメントする」UseCaseを書きます。
ここではentity層のオブジェクトを使えるので、Calculator.incrementを呼び出すだけでよいです。
とりあえず戻り値を返すようにします。

interface IncrementUseCase {
operator fun invoke(number: Int): Int
}
class IncrementUseCaseInteractor @Inject constructor(
private val calculator: Calculator
): IncrementUseCase {
override fun invoke(number: Int): Int = calculator.increment(number)
}

Controllerを作る

Controllerはadapter層にあります。
主な役割はViewからの入力を使ってUseCaseを呼び出す事です。
これも一旦戻り値を返すように書いてみます。
ちょっと命名が微妙ですがこんな感じで行きます。

interface MainController {
suspend fun onIncrementButtonClick(number: Int): Int
}
class MainControllerImpl @Inject constructor(
private val incrementUseCase: IncrementUseCase
): MainController {
override fun onIncrementButtonClick(number: Int) = incrementUseCase(number)
}

一旦つなげる

一旦Hiltでつなげてみましょう。
すでに実装したクラスのコンストラクタに@Injectを付けているので@Bindsを使って繋げます。
Moduleはdiパッケージを新しく作ってその中に入れています。

@Module
@InstallIn(ActivityComponent::class)
abstract class AdapterModule {
@ActivityScoped
@Binds
abstract fun bindMainController(mainController: MainControllerImpl): MainController
}
@Module
@InstallIn(ActivityComponent::class)
abstract class ApplicationModule {
@Binds
abstract fun bindIncrementUseCase(
incrementUseCase: IncrementUseCaseInteractor
): IncrementUseCase
}
@Module
@InstallIn(SingletonComponent::class)
abstract class DomainModule {
@Singleton
@Binds
abstract fun bindCalculator(calculator: CalculatorImpl): Calculator
}

必要なものがそろったのでMainActivityでMainControllerを使ってViewを更新してみましょう。
エミュレータを起動してボタンを押すと値がインクリメントされると思います。

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var controller: MainController
private var number: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
binding.textView.text = number.toString()
binding.button.setOnClickListener {
number = controller.onIncrementButtonClick(number)
binding.textView.text = number.toString()
}
setContentView(binding.root)
}
}

実装クラスに@Injectを付けるのが嫌な場合、
@Providesを使うとすべてHilt側で管理することもできます。

現状の構成はこのようになっていますね。

UseCaseとControllerが直接値を返すようになっています。
これは入出力を分離せず簡略した状態ですね。これで満足する場合も多いです。
MVVMでUseCase層を入れるとこのような形に落ち着いてる気がします。

でも今回はUseCaseからの出力をPresenterで受け取るようにしたいと思います。
入出力を分離するとデータの流れがReduxのように回っているように見えます。

Presenterを作る

Presenterはadapter層にあります。
PresenterはUseCaseの出力をViewに伝えるものです。
この時にView用にデータの変換をしたりする場合も多いです。
今回は特にデータの変換などはせずそのままMainActivityに値を渡します。
MainActivityをPresenterに渡すためにDriverModuleも追加します。
HiltではActivityScopeでactivityが取得できますが、
キャストはしてくれないのでModuleでキャストします。

interface MainPresenter {
fun setNumber(number: Int)
}
class MainPresenterImpl @Inject constructor(
private val mainActivity: MainActivity
): MainPresenter {
override fun setNumber(number: Int) {
mainActivity.onReceiveNumber(number)
}
}
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var controller: MainController
lateinit var binding: ActivityMainBinding
var number: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
binding.textView.text = number.toString()
binding.button.setOnClickListener {
controller.onIncrementButtonClick(number)
}
setContentView(binding.root)
}
fun onReceiveNumber(number: Int){
this.number = number
binding.textView.text = number.toString()
}
}
@Module
@InstallIn(ActivityComponent::class)
object DriverModule {
@ActivityScoped
@Provides
fun provideMainActivity(activity: Activity) = activity as MainActivity
}
@Module
@InstallIn(ActivityComponent::class)
abstract class AdapterModule {
@ActivityScoped
@Binds
abstract fun bindMainController(mainController: MainControllerImpl): MainController
@ActivityScoped
@Binds
abstract fun bindMainPresenter(mainPresenterImpl: MainPresenterImpl): MainPresenter
}

PresenterをUseCaseで使うために修正します。
Controllerも修正する必要がありますね。

interface MainController {
fun onIncrementButtonClick(number: Int)
}
interface IncrementUseCase {
operator fun invoke(number: Int)
}
class IncrementUseCaseInteractor @Inject constructor(
private val calculator: Calculator,
private val presenter: MainPresenter
): IncrementUseCase {
override fun invoke(number: Int) = calculator.increment(number).let(presenter::setNumber)
}

これでエミュレーターを起動してボタンを押すとインクリメントすると思います。

依存関係の整理

UseCaseとPresenterがそれぞれ上の層に依存しているのがわかりますか?

図にすると依存の矢印が上向きになっています。
上層に依存するとろくなことがないので依存関係の整理を整理しましょう。

まずは「PresenterがViewに依存している」について解決します。
まずはadapter層にMainReceiverを追加します。
このMainReceiverをMainActivityに実装させます。
DriverModuleでMainActivityではなくMainReceiverにキャストします。
そしてPresenterの実装でMainReceiverを使うように変更します。

interface MainReceiver {
fun onReceiveNumber(number: Int)
}
@AndroidEntryPoint
class MainActivity : AppCompatActivity(), MainReceiver {
@Inject
lateinit var controller: MainController
lateinit var binding: ActivityMainBinding
var number: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
binding.textView.text = number.toString()
binding.button.setOnClickListener {
controller.onIncrementButtonClick(number)
}
setContentView(binding.root)
}
override fun onReceiveNumber(number: Int){
this.number = number
binding.textView.text = number.toString()
}
}
@Module
@InstallIn(ActivityComponent::class)
object DriverModule {
@ActivityScoped
@Provides
fun provideMainReceiver(activity: Activity) = activity as MainReceiver
}
class MainPresenterImpl @Inject constructor(
private val mainReceiver: MainReceiver
): MainPresenter {
override fun setNumber(number: Int) {
mainReceiver.onReceiveNumber(number)
}
}

これで依存の向きが下になりました。
PresenterはViewに依存しなくなりましたね。

図にするとわかりやすいです。
adapter層にあるinterfaceをViewが実装していることで、
層をまたがる矢印が下向きになっています。
Receiverはinterfaceなので実装であるViewのことを知らないんですね。
仮にViewに変更があってもReceiverは影響を受けないということです。

PresenterはReceiverに依存していますが、
これらは同じadapter層にあるため問題ないわけです。

UseCaseのほうも同じようにinterfaceを使って上向きの依存を解決しましょう。
application層にUseCaseのアウトプット用のinterfaceを追加します。
それをMainPresenterに実装させます。
あとはAdapterModuleでbindしてUseCaseで使いましょう。

interface IncrementUseCaseOutputBoundary {
fun incrementOutput(number: Int)
}
interface MainPresenter: IncrementUseCaseOutputBoundary
class MainPresenterImpl @Inject constructor(
private val mainReceiver: MainReceiver
): MainPresenter {
override fun incrementOutput(number: Int) {
mainReceiver.onReceiveNumber(number)
}
}
@Module
@InstallIn(ActivityComponent::class)
abstract class AdapterModule {
@ActivityScoped
@Binds
abstract fun bindMainController(mainController: MainControllerImpl): MainController
@ActivityScoped
@Binds
abstract fun bindMainPresenter(mainPresenterImpl: MainPresenterImpl): MainPresenter
@ActivityScoped
@Binds
abstract fun bindIncrementUseCaseOutputBoundary(
mainPresenter: MainPresenter
): IncrementUseCaseOutputBoundary
}
class IncrementUseCaseInteractor @Inject constructor(
private val calculator: Calculator,
private val outputBoundary: IncrementUseCaseOutputBoundary
): IncrementUseCase {
override fun invoke(number: Int) =
calculator.increment(number).let(outputBoundary::incrementOutput)
}

ということでこんな感じになりました。

実際のプロジェクトをみてもわかりやすいです。
パッケージ内のコードが同じ層か下の層にしか依存していないことが確認できますね。

依存性逆転の原則

下層にinterfaceを置いて上層で実装することで、
下層から上層にデータを渡すことができました。
なんとなく依存関係が逆転してるからこれが依存性逆転な感じがしますね。
もう少し詳しく見てみましょう。

依存性逆転の原則は2点あり一つは抽象に依存しようというものです。

昔は抽象度の高い下層が具象的な上層に依存していることが多かったそうです。

これによってせっかく下層を抽象的に実装したのに、
上層の具象に依存しているため、
上層の変更によって下層に影響が出たり、再利用できなかったりしたそうです。

この問題を純粋な抽象(interface)に依存することによって、
上層の変更による影響を防ぎ、柔軟性や再利用性を上げようと考えました。

これによってどの層も他の層の具象に依存することはありません。
その代わり、抽象に依存します。

依存性逆転の原則の2点目は下層は上層に依存すべきではないというものです。
これによってinterfaceを下層に取り込みます。

これでアプリと同じく層をまたぐ依存関係が逆転しましたね。

抽象 -> 具象 から 抽象 <- 具象 になっていることと、
下層 -> 上層 から 下層 <- 上層 になっているため逆転というらしいです。

原文もおすすめです。
(この記事では最初から上下が逆転しているため、 原文と層の上下が逆である点に注意してください。)

おわりに

ちなみにDIはどういう立ち位置かというと、
CompositionRootという場所にあると言われています。
これは各層を繋げるもので特別視されているのであまり気にしなくてよさそうです。

はじめにでも書きましたが、このアーキテクチャはクリーンアーキテクチャを参考にして作ってます。
興味があったらクリーンアーキテクチャを調べてみると面白いです。

続き書きました。 アーキテクチャを遊ぶ

© 2022 シロボタン - Context Composition