アーキテクチャを遊ぶ

2022-03-13
AndroidKotlinアーキテクチャ
    はじめに
    Observer
    ViewModel
    おわりに

はじめに

アーキテクチャと依存性逆転の続きです。

アーキテクチャをいじって遊ぶ感じの記事です。
完成品はgithubに上げてあります

Observer

Observerパターンと合わせることができそうですね。
やってみましょう。
まずはCoroutineを入れます。RxJavaでもいいですが自分はCoroutineが好きです。
Lifecycle上でCoroutineをうまく使うためにLifeCycleExtensionsも追加しておきます。

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 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'
// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2"
// LifeCycleExtensions
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.1"
// Fragment KTX
implementation "androidx.fragment:fragment-ktx:1.4.1"
// 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'
}

PresenterのFlowをMainActivityでObserveする形にしてみましょう。
まずはPresenterにFlowを追加します。
Presenterの実装ではSharedFlowを使ってFlowを作っています。
SharedFlowに新しい値を流すためには、
suspend関数のemitを使用するのでCoroutineScopeを作ってその中でemitします。
なぜStateFlowでないかというとここに状態を持ちたくないからです。

これでPresenterはMainReceiverすら知らなくなりました。

interface MainPresenter: IncrementUseCaseOutputBoundary {
val numberFlow: Flow<Int>
}
class MainPresenterImpl @Inject constructor(): MainPresenter {
private val mutableNumberFlow = MutableSharedFlow<Int>()
override val numberFlow get() = mutableNumberFlow
private val coroutineScope = CoroutineScope(Dispatchers.Default)
override fun incrementOutput(number: Int) {
coroutineScope.launch {
mutableNumberFlow.emit(number)
}
}
}

MainActivityにPresenterをInjectしてFlowをObserveしてみましょう。
FlowをObserveする方法はcollectなどがあります。

また、ActivityでViewを扱うCoroutineを使う場合、
repeatOnLifecycleを使ってActivityのLifecycleに合わせる必要があります。
この辺りはFragmentならViewLifecycleScopeを使うだけなのでFragmentのほうが楽ですね。

MainReceiverとDriverModuleは必要なくなったので削除しましょう

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var controller: MainController
@Inject
lateinit var presenter: MainPresenter
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)
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
presenter.numberFlow.collect(::onReceiveNumber)
}
}
setContentView(binding.root)
}
private fun onReceiveNumber(number: Int){
this.number = number
binding.textView.text = number.toString()
}
}

UseCaseからPresenterについてもOvserverにできそうですね。
ですが、UseCase自体にFlowを持たせるのは変なので、
application層に新しくFlowを管理するオブジェクトを追加したほうがよさそうです。

Subjectなるものをapplication層に追加してみます。
UseCaseから更新するSubjectと、
Presenterが取得するSubjectが、
別のオブジェクトにならないように@ActivityScopedをつけます。

また、Presenter側からSubjectの値を変更できたら意味ないので、
Subjectの実装にUseCaseOutputBoundaryを付けてUseCaseから値を変更します。
今回はしませんがMutableSubjectみたいに別のInterfaceにしたほうがいいかもしれないですね。

interface NumberSubject {
val flow: Flow<Int>
}
@ActivityScoped
class NumberSubjectImpl @Inject constructor(): NumberSubject, IncrementUseCaseOutputBoundary {
private val mutableNumberFlow = MutableSharedFlow<Int>()
override val flow: Flow<Int> get() = mutableNumberFlow
private val coroutineScope = CoroutineScope(Dispatchers.Default)
override fun incrementOutput(number: Int) {
coroutineScope.launch {
mutableNumberFlow.emit(number)
}
}
}
@Module
@InstallIn(ActivityComponent::class)
abstract class ApplicationModule {
@Binds
abstract fun bindIncrementUseCase(
incrementUseCase: IncrementUseCaseInteractor
): IncrementUseCase
@Binds
abstract fun bindNumberSubject(
numberSubjectImpl: NumberSubjectImpl
): NumberSubject
@Binds
abstract fun bindIncrementUseCaseOutputBoundary(
numberSubjectImpl: NumberSubjectImpl
): IncrementUseCaseOutputBoundary
}
@Module
@InstallIn(ActivityComponent::class)
abstract class AdapterModule {
@ActivityScoped
@Binds
abstract fun bindMainController(mainController: MainControllerImpl): MainController
@ActivityScoped
@Binds
abstract fun bindMainPresenter(mainPresenterImpl: MainPresenterImpl): MainPresenter
}
interface MainPresenter {
val numberFlow: Flow<Int>
}
class MainPresenterImpl @Inject constructor(
private val numberSubject: NumberSubject
): MainPresenter {
override val numberFlow get() = numberSubject.flow
}

Flowを使って下位レイヤーから上位レイヤーにデータを渡すことができました。
この時にinterfaceを使っていたときと同じく、
下位レイヤーは上位レイヤーのことを何も知らないことがポイントですね。

ViewModel

そういえば、Activityが変数を保持していましたね。
AndroidではViewModelを使ってView用のデータを保持します。
ViewModel自体はadapter層の物っぽいですが、
Androidのフレームワークと密接にかかわっているので、
driver層に追加したいと思います。
そして普通にActivityで使います。

@HiltViewModel
class MainViewModel @Inject constructor(presenter: MainPresenter): ViewModel() {
val numberFlow = presenter.numberFlow.stateIn(viewModelScope, SharingStarted.Lazily, 0)
val number get() = numberFlow.value
}
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var controller: MainController
private val viewModel: MainViewModel by viewModels()
lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
binding.textView.text = viewModel.number.toString()
binding.button.setOnClickListener {
controller.onIncrementButtonClick(viewModel.number)
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.numberFlow.collect(::onReceiveNumber)
}
}
setContentView(binding.root)
}
private fun onReceiveNumber(number: Int){
binding.textView.text = number.toString()
}
}

stateInというのはFlowをStateFlowにしてくれるものです。

これで起動するとHilt周りでエラーしてしまいますね。
PresenterがActivityComponentに入っているのでViewModelで使う事ができないというものですね。
なのでPresenterをViewModelComponentに入れます。

しかしこれでもエラーします。
次はSubjectがないというものですね。
これも同じような理由ですが、
SubjectはUseCaseで使うのでActivityComponentにも入っている必要があるので、
SingletonComponentに入れます。

@Module
@InstallIn(ActivityComponent::class)
abstract class InputAdapterModule {
@ActivityScoped
@Binds
abstract fun bindMainController(mainController: MainControllerImpl): MainController
}
@Module
@InstallIn(ViewModelComponent::class)
abstract class OutputAdapterModule {
@ViewModelScoped
@Binds
abstract fun bindMainPresenter(mainPresenterImpl: MainPresenterImpl): MainPresenter
}
@Module
@InstallIn(SingletonComponent::class)
abstract class ApplicationModule {
@Binds
abstract fun bindIncrementUseCase(
incrementUseCase: IncrementUseCaseInteractor
): IncrementUseCase
@Binds
abstract fun bindNumberSubject(
numberSubjectImpl: NumberSubjectImpl
): NumberSubject
@Binds
abstract fun bindIncrementUseCaseOutputBoundary(
numberSubjectImpl: NumberSubjectImpl
): IncrementUseCaseOutputBoundary
}
@Singleton
class NumberSubjectImpl @Inject constructor(): NumberSubject, IncrementUseCaseOutputBoundary {
private val mutableNumberFlow = MutableSharedFlow<Int>()
override val flow: Flow<Int> get() = mutableNumberFlow
private val coroutineScope = CoroutineScope(Dispatchers.Default)
override fun incrementOutput(number: Int) {
coroutineScope.launch {
mutableNumberFlow.emit(number)
}
}
}

これでエラーせず起動できると思います。

図にするとこんな感じです。

おわりに

アーキテクチャをいじって遊ぶのは楽しいですね。
続き書くかわからないですが、もうすこしこれで遊べそうです。
アプリ全体で状態を一つにするRedux的なこともできそうです。
Subject周りが微妙なのでそのあたりがきれいになりそうです。

© 2022 シロボタン - Context Composition