본문 바로가기

Android/Architecutre

안드로이드 Clean Architecture 구현하기

728x90
반응형

 

Clean Architecture

 

위의 Uncle Bob Clean Architecture(CA)는 오늘날 많은 어플리케이션의 핵심 Architecture가 되었습니다.

 

Dependecy Rule

하위 계층으로 갈수록 상위 계층을 몰라야 합니다. 내부 원의 어떤것도 외부 원의 어떤것에 대해 전혀 알 수 없습니다.

특히, 외부 원에 선언된 이름(class, function, variable 등)은 내부 원에 있는 코드에서 언급되지 않아야 합니다. 

 

CA의 각 계층

  • Entities : 비지니스 규칙(business rule) (예: 근무시간에 따라 급여를 계산하는 공식, 직원에 대한 가장 기본적인 데이터가 들어있는 POJO)
  • Use cases : 단순히 실행 가능한 작업. Intereactor라고도 함. android에서는 UI와 상호작용하여 Repository에서 데이터를 꺼내온다. getRandomFactUseCase등 이름으로 기능을 알수 있어야 한다.
  • Presenters(Interface Adapters) : 데이터를 Entity, UseCase의 편리한 형식에서 데이터베이스 및 웹에 적용 할 수 있는 형식으로 변환한다. MVP의 Presenter, MVVM의 ViewModel으로, UI에서만 사용된다.
  • Frameworks & Drivers  : 데이터베이스나 Web Framework, UI등

 

Android에 적용 : 3-layer Architecture

CA를 안드로이드에 적용하기 위한 일반적인 방식은 프로젝트를 3개의 layer로 분리하는 것 입니다.

https://github.com/igorwojda/Android-Showcase#architecture

  • presentation layer : data를 화면에 표시하고 user 상호작용을 다룸. UI, Presenter, ViewModel등.
  • domain layer : 비지니스 프로세스와 관련된 가장 핵심 계층. 어떤 다른 계층에도 독립적임. Entities, Use Case, Repository Interface등.
  • data layer : 어플리케이션의 데이터 관리(네트워크에서 데이터 검색, 데이터 캐시 관리 등). Repository Implementation, Local & Remote Data source등.

CA를 안드로이드에 적용하기 위해 쉽게 설명한 그림들이 있습니다.

https://github.com/agustiyann/Android-Clean-Architecture
https://github.com/agustiyann/Android-Clean-Architecture
https://proandroiddev.com/how-to-implement-a-clean-architecture-on-android-2e5e8c8e81fe
https://antonioleiva.com/clean-architecture-android/

위의 그림처럼 Domain, Data, UI(presentation) 3 layer에 더해 Android Framework와 관련된 기능(알림, gps, 블루투스, 카메라등)을 포함한 Device또한 추가할 수 있습니다.

 

Data Flow 참고.

더보기
https://github.com/igorwojda/Android-Showcase#data-flow

 

핵심은 (dependency rule에 따르면)domain 계층이 다른 계층에 독립적이라는 것입니다.

domain 계층에서는 다른 외부 계층에서 정의된 클래스에 접근하지 않아야 합니다.

이상적으로는, 도메인 계층은 라이브러리 및 프레임 워크와 독립적이여야 합니다.

Kotlin Standard Library나 몇가지 DI(Dependecy Injection) library는 괜찮지만, 다른 라이브러리나 framework (특히 Android Framework)는 피해야 합니다.

 

실제 어플리케이션에 적용

실제 어플리케이션에 적용하기 위한 여러가지 방법이 있습니다.

single module에서 정의하는것과, layer별로 모듈을 만드는 것.

또는 feature module내부에 CA layer를 만드는 것과, CA layer 별로 feauture module을 만드는것.

총 4가지 방법을 다음 글에서 소개하고 있습니다. 참고해보세요.

 

저는 간단하게 layer별로 모듈을 만들어보았습니다.

Project로 바꾼뒤 프로젝트 오른쪽 클릭>New>Module 후 AndroidLibrary로 추가하였구요

module name은 data, domain 으로 추가해주고

app을 presentation으로 바꾸어 주었습니다.

 

그럼 이렇게 3개가 추가 되었습니다.

 

그다음, app 수준의 build.gradle에 각각 종속성을 추가해 줍니다.

 

  • presentation
dependencies {

    implementation project(':data')
    implementation project(':domain')
    
}

 

  • data
dependencies {

    implementation project(':domain')

}

 

  • domain : 종속성 없음 

 

 

https://fivenyc.medium.com/android-architecture-part-4-applying-clean-architecture-on-android-hands-on-source-code-8da287a0e0a2

 

 

Room Local DB를 Clean Architecture로 구현하기

 

https://github.com/DDANGEUN/LillyCleanArchitecture

위 깃허브 주소에 Room을 적용한 코드를 올려놓았습니다.

위 그림 처럼 Room Local DB에 text와 저장 시간을 저장하고, 뷰로 불러오고, 삭제, 검색 기능까지 구현하였습니다.

 

data와 domain의 구성은 이렇게 됩니다.

 

그리고 presetation 에서 View와 ViewModel을 사용하여 View를 구성합니다.

Koin으로 의존성 주입을 하였습니다.

 

  • data

우선, room을 사용하여 entity, dao와 database를 만듭니다.

@Entity(tableName = "text")
data class TextEntity (
    @PrimaryKey(autoGenerate = true)
    val id: Long?,
    val time: String,
    val content: String
)
@Dao
interface TextDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertTexts(texts: TextEntity): Single<Long>

    @Query("SELECT * FROM text")
    fun getAllTexts(): Single<List<TextEntity>>

    @Query("SELECT * FROM text WHERE content LIKE '%' || :content || '%'")
    fun getTextsByContent(content: String): Single<List<TextEntity>>

    @Delete
    fun delete(texts: TextEntity): Completable

    @Query("DELETE FROM text")
    fun deleteAllTexts(): Completable
}
@Database(
    entities = [TextEntity::class],
    version = 1,
    exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun textDao(): TextDao
}

database는 koin을 통해 주입하였습니다. (글의 하단 부에서 참고)

[Android/이론] - [안드로이드 Kotlin] Room Database, Repository, ViewModel을 Koin으로 의존성 주입(DI)

*참고: @Insert function의 return값은 autogenerate로 할당된 id가 됨.

 

그다음, data를 사용할 DataSource를 만들어 줍니다.

interface TextLocalDataSource {
    fun insertText(text: TextEntity): Single<Long>
    fun getAllTexts(): Single<List<TextEntity>>
    fun getSearchTexts(content: String): Single<List<TextEntity>>
    fun delete(text: TextEntity): Completable
    fun deleteAllTexts(): Completable
}
class TextLocalDataSourceImpl(private val textDao: TextDao): TextLocalDataSource {

    override fun insertText(text: TextEntity): Single<Long> = textDao.insertTexts(text)
    override fun getAllTexts(): Single<List<TextEntity>> = textDao.getAllTexts()
    override fun getSearchTexts(content: String): Single<List<TextEntity>> = textDao.getTextsByContent(content)
    override fun delete(text: TextEntity): Completable = textDao.delete(text)
    override fun deleteAllTexts(): Completable = textDao.deleteAllTexts()

}

 

보시면, RxJava를 사용하였습니다.

rxjava 종속성과 room-rxjava2 종속성을 추가하여야 합니다.

 

그다음 추가적으로 domain의 model과 data의 entity를 서로 변환해줄 수 있는 mapper를 추가합니다.

Mapper.kt

// data -> domain
fun mapperToText(texts: List<TextEntity>): List<TextItem> {
    return texts.toList().map {
        TextItem(
            it.id,
            it.time,
            it.content
        )
    }
}
fun TextEntity.map() = TextItem(
    id,
    time,
    content
)

// domain -> data
fun mapperToTextEntity(textItems: List<TextItem>): List<TextEntity> {
    return textItems.toList().map {
        TextEntity(
            it.id,
            it.time,
            it.content
        )
    }
}
fun TextItem.map() = TextEntity(
    id,
    time,
    content
)

 

그다음, 가지고 있는 로컬 데이터를 변환해서 반환해줄 function들을 구현하는 부분인 RepositoryImpl을 포함합니다.

Repository Interface는 domain에 포함되어 있습니다. 

class TextRepositoryImpl(private val textLocalDataSource: TextLocalDataSource): TextRepository {
    override fun getAllLocalTexts(): Flowable<List<TextItem>> {
        return textLocalDataSource.getAllTexts().flatMapPublisher { localTexts->
            if (localTexts.isEmpty()) {
                Flowable.error(IllegalStateException(NO_DATA_FROM_LOCAL_DB))
            } else {
                Flowable.just(mapperToText(localTexts))
            }
        }
    }

    override fun getLocalSearchTexts(query: String): Flowable<List<TextItem>> {
        return textLocalDataSource.getSearchTexts(query)
            .onErrorReturn { listOf() }
            .flatMapPublisher { localTexts ->
                if (localTexts.isEmpty()) {
                    Flowable.error(IllegalStateException(NO_DATA_FROM_LOCAL_DB))
                } else {
                    Flowable.just(mapperToText(localTexts))
                }
            }
    }

    override fun insertText(textItem: TextItem): Single<Long> = textLocalDataSource.insertText(textItem.map())
    override fun deleteText(textItem: TextItem): Completable = textLocalDataSource.delete(textItem.map())
    override fun deleteAllTexts(): Completable = textLocalDataSource.deleteAllTexts()

}

 

  • domain

domain 에서 가지고 있는 model

data class TextItem(
    var id: Long?,
    val time: String,
    val content: String
)

 

data부분에서 구현될 repository interface를 포함합니다.

interface TextRepository {

    fun getAllLocalTexts(): Flowable<List<TextItem>>
    fun getLocalSearchTexts(query: String): Flowable<List<TextItem>>
    fun insertText(textItem: TextItem): Single<Long>
    fun deleteText(textItem: TextItem): Completable
    fun deleteAllTexts(): Completable

}

 

그다음, domain 부분에서는 UseCase라는것을 가지고 있는데,

UseCase는 단위별로 function을 간단히 가지고 올 수 있어야 합니다.

따라서 view에서 쉽게 domain에 접근해 이 UseCase별로 변환된 data를 가지고 와, view에 띄워주기만 할 수가 있습니다.

repository에 있는 function별로 각각의 UseCase를 구현했습니다.

class GetAllLocalTextsUseCase(private val repository: TextRepository) {
    fun execute(): Flowable<List<TextItem>> = repository.getAllLocalTexts()
}
class GetSearchTextsUseCase(private val repository: TextRepository) {
    fun execute(query: String): Flowable<List<TextItem>> = repository.getLocalSearchTexts(query)
}
class InsertTextUseCase(private val repository: TextRepository) {
    fun execute(textItem: TextItem): Single<Long> = repository.insertText(textItem)
}
class DeleteTextUseCase(private val repository: TextRepository) {
    fun execute(textItem: TextItem): Completable = repository.deleteText(textItem)
}

 

  • presentation

presentaion에서 koin을 사용해 다음과 같이 di 주입을 하였습니다.

DI 주입

val viewModelModule = module {
    viewModel { RoomViewModel(get(),get(),get(),get()) }
}

val repositoryModule: Module = module {
    single<TextRepository> { TextRepositoryImpl(get()) }
}

val localDataModule: Module = module {
    single<TextLocalDataSource> { TextLocalDataSourceImpl(get()) }
    single<TextDao> { get<AppDatabase>().textDao() }
    single<AppDatabase> {
        Room.databaseBuilder(
            get(),
            AppDatabase::class.java, "Text.db"
        )
            .build()
    }
}
val useCaseModule: Module = module {
    single<InsertTextUseCase> { InsertTextUseCase(get()) }
    single<DeleteTextUseCase> { DeleteTextUseCase(get()) }
    single<GetAllLocalTextsUseCase> { GetAllLocalTextsUseCase(get()) }
    single<GetSearchTextsUseCase> { GetSearchTextsUseCase(get()) }
}

 

저장된 데이터를 viewmodel에서 다음과 같이 유즈케이스 별로 불러와 뷰로 띄워줄 수 있습니다.

Viewmodel

class RoomViewModel(
    private val getAllLocalTextsUseCase: GetAllLocalTextsUseCase,
    private val insertTextUseCase: InsertTextUseCase,
    private val deleteTextUseCase: DeleteTextUseCase,
    private val getSearchTextsUseCase: GetSearchTextsUseCase
) : BaseViewModel() {

    val statusText = ObservableField("Hi! Let's put text in Local DB")
    var textList: List<TextItem> = ArrayList()
    val textListObservable: MutableLiveData<List<TextItem>> = MutableLiveData(textList)
    val noDataNotification = ObservableBoolean(false)

    @SuppressLint("SimpleDateFormat")
    fun insertText(content: String) {
        val simpleDate = SimpleDateFormat("yyyy-MM-dd HH:mm")
        val strNow: String = simpleDate.format(Date(System.currentTimeMillis()))
        CoroutineScope(Dispatchers.IO).launch {
            compositeDisposable.add(
                insertTextUseCase.execute(TextItem(null,strNow, content)).subscribe({ id->
                    statusText.set("$strNow `$content` is inserted.")
                    getAllTexts()
                }, {
                    CoroutineScope(Dispatchers.Main).launch {
                        Util.showNotification("error: ${it.message}", "error")
                    }
                })
            )
        }
    }

    fun deleteText(textItem: TextItem){
        CoroutineScope(Dispatchers.IO).launch {
            compositeDisposable.add(
                deleteTextUseCase.execute(textItem).subscribe({
                    statusText.set("`${textItem.content}` is deleted.")
                    getAllTexts()
                }, {
                    CoroutineScope(Dispatchers.Main).launch {
                        Util.showNotification("error: ${it.message}", "error")
                    }
                })
            )
        }
    }

    fun getAllTexts() {
        CoroutineScope(Dispatchers.IO).launch {
            compositeDisposable.add(
                getAllLocalTextsUseCase.execute().subscribe({
                    CoroutineScope(Dispatchers.Main).launch {
                        noDataNotification.set(false)
                        textList = it
                        textListObservable.value = textList
                    }
                }, {
                    CoroutineScope(Dispatchers.Main).launch {
                        if (it.message != NO_DATA_FROM_LOCAL_DB) {
                            Util.showNotification("error: ${it.message}", "error")
                        }else{
                            noDataNotification.set(true)
                        }
                    }
                })
            )
        }
    }

    fun getSearchTexts(query:String){
        CoroutineScope(Dispatchers.IO).launch {
            compositeDisposable.add(
                getSearchTextsUseCase.execute(query).subscribe({
                    CoroutineScope(Dispatchers.Main).launch {
                        noDataNotification.set(false)
                        textList = it
                        textListObservable.value = textList
                    }
                }, {
                    CoroutineScope(Dispatchers.Main).launch {
                        if (it.message != NO_DATA_FROM_LOCAL_DB) {
                            Util.showNotification("error: ${it.message}", "error")
                        }else{
                            noDataNotification.set(true)
                        }

                    }
                })
            )
        }
    }
}

 

다음과 같이 databinding과 viewmodel등을 이용해 view를 구성하였습니다.

예1: editText의 입력 값 변화시 데이터 쿼리 검색

예2: insert 버튼의 클릭 - item 삽입

override fun initListener() {
        binding.apply{
            etPuttext.doOnTextChanged { text, _, _, _ ->
                viewModel?.getSearchTexts(text.toString())
            }
            btnPuttext.setOnClickListener {
                viewModel?.insertText(tlPuttext.editText?.text.toString())
                tlPuttext.editText?.setText("")
                hideKeyboard()
            }
        }
    }

 

 

github 소스를 참조해 주세요 ^^!

 

GitHub - DDANGEUN/LillyCleanArchitecture: Android Clean Architecture Sample.

Android Clean Architecture Sample. Contribute to DDANGEUN/LillyCleanArchitecture development by creating an account on GitHub.

github.com

 

 

참고 문서

728x90
반응형