일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- 막내의막무가내 SQL
- 막내의막무가내 안드로이드 코틀린
- 주엽역 생활맥주
- 막내의막무가내 프로그래밍
- 막내의 막무가내
- 막내의막무가내 rxjava
- 막내의막무가내 알고리즘
- 막내의막무가내
- 안드로이드
- 막내의막무가내 코볼 COBOL
- 막내의 막무가내 알고리즘
- flutter network call
- 막내의막무가내 안드로이드
- 부스트코스
- 막내의막무가내 코틀린
- 막내의막무가내 플러터
- 막내의막무가내 일상
- 부스트코스에이스
- 막무가내
- 막내의막무가내 플러터 flutter
- 2022년 6월 일상
- 막내의막무가내 목표 및 회고
- 막내의막무가내 안드로이드 에러 해결
- 프래그먼트
- Fragment
- 안드로이드 Sunflower 스터디
- 막내의막무가내 코틀린 안드로이드
- 주택가 잠실새내
- 안드로이드 sunflower
- 프로그래머스 알고리즘
- Today
- Total
막내의 막무가내 프로그래밍 & 일상
[안드로이드] 클린 아키텍처(Clean Architecture) 정리 및 구현 본문
[2021-04-28 업데이트]
[2022-02-01 업데이트]
Hilt 사용한 프로젝트 링크 하단에 추가
[프로젝트]
github.com/mtjin/mtjin-android-clean-architecture-movieapp
시작하기 앞서 처음 학습한거라 미숙한 점이 많은 점 양해부탁드립니다. 공부 더 하고 나중에 프로젝트 및 내용을 수정할 예정입니다 :)
P.S 이 포스팅의 샘플 토이 프로젝트에서는 에러코드를 그냥 kt파일 const val 로 코드를 나누어 해결했는데 원래 같으면 result나 code 같은 값을 가진 BaseResponse 같은 data class 파일을 만들어 깔끔하게 분기를 나누거나 Exception 클래스를 상속받은 클래스들을 받아서 통신 응답 및 예외 처리를 하면 좋으나 토이프로젝트이기 때문에 간단히 상수로 해결한점 양해부탁드립니다.
1. 클린 아키텍처의 두둥등장
특정 수준 혹은 복잡도를 가진 애플리케이션을위한 고품질 코드를 작성하려면 상당한 노력과 경험이 필요합니다. 애플리케이션은 고객의 요구 사항을 충족 할뿐만 아니라 유연하고 테스트 가능하며 유지 관리가 가능해야합니다.
이러한 문제에 대한 해결책으로 Bob 삼촌으로도 알려진 Robert C. Martin은 2012 년에 Clean Architecture 개념을 제시했습니다.
2-1. 클린 아키텍처의 구조 및 특징
클린 아키텍처의 구조는 위와 같이 총 4가지 계층으로 되어 있습니다.
이렇게 계층을 나누는 이유는 계층을 분리하여 관심사를 분리시키기 위해서이며 이런 아키텍처가 동작하기 위해서는 의존성 규칙을 지켜야 합니다. 한마디로 각 분리된 클래스가 한가지 역할만 하고 서로 의존을 어떻게 할지 규칙이 정해져있고 지켜야한다는 말입니다.
(+ 의존성이란? "의존성"은 예를 들어 서비스로 사용할 수 있는 객체이다. 클라이언트가 어떤 서비스를 사용할 것인지 지정하는 대신, 클라이언트에게 무슨 서비스를 사용할 것인지를 말해주는 것이다. "주입"은 의존성(서비스)을 사용하려는 객체(클라이언트)로 전달하는 것을 의미한다.)
여기서 의존성 규칙은 모든 소스코드 의존성은 반드시 외부에서 내부로, 고수준 정책을 향해야 합니다. (위 그림에서는 원 안쪽으로 갈수록 의존성이 낮아집니다.) 즉 안드로이드를 예로들면 비즈니스 로직을 담당하는 ViewModel과 같은 코드들이 DB 또는 Web 같이 구체적인 세부 사항에 의존하지 않아야 합니다. 이를 통해 비즈니스 로직(고수준 정책)은 세부 사항들(저수준 정책)의 변경에 영향을 받지 않도록 할 수 있습니다.
이렇게 나눔으로써 얻는 이점들은 다음과 같습니다. 가장 중요한건 Testable과 유지보수 및 협업이라 볼 수 있겠습니다.
- 코트 테스트 커버리지 증대
- 쉽게 패키지 구조 탐색 가능
- 집중화된 클래스에 따른 프로젝트 유지 관리 증대
- 새 기능을 빠르게 적용 가능
- 이후의 개발에도 안정적인 구현
- 명확한 규율로 전반적으로 따라야 할 베스트 프랙티스
안드로이드 개발을 위한 더 이해하기 쉬운 클린아키텍처 구조 그림도가 있지만 뒤에서 살펴보도록 하고 먼저 오리지널 클린아키텍처의 각 계층의 역할에 대해 설명하면 다음과 같습니다.
1) Entities : 엔티티는 가장 일반적인 비즈니스 규칙을 캡슐화하고 DTO(Data Transfer Object)도 포함하는 전사적 비즈니스 규칙입니다. 외부가 변경되면 이러한 규칙이 변경 될 가능성이 가장 적습니다.
2) Use cases : 유스케이스는 Intereactor라고도 하며 소프트웨어의 애플리케이션 별 비즈니스 규칙을 나타냅니다.이 계층은 데이터베이스, 공통 프레임 워크 및 UI에 대한 변경으로부터 격리됩니다.
3) Interface Adapters (Presenters) : 인터페이스 어댑터는 데이터를 Entity 및 UseCase의 편리한 형식(Format) 에서 데이터베이스 및 웹에 적용 할 수있는 형식으로 변환합니다. 이 계층에는 MVP의 Presenter, MVVM의 ViewModel 및 게이트웨이 (= Repositories)가 포함됩니다. 즉 순수한 비즈니스 로직만을 담당하는 역할을 하게 됩니다.
4) Frameworks & Drivers (Web, DB) : 프레임워크와 드라이버는 웹 프레임 워크, 데이터베이스, UI, HTTP 클라이언트 등으로 구성된 가장 바깥 쪽 계층입니다.
2-2. 안드로이드에서의 클린 아키텍처의 구조 및 특징
위에서의 그림과 설명을 보고도 실제 안드로이드에서 사용하는 아키텍처 구조와 좀 다른 용어와 레이어 구조 때문에 햇갈릴 수 도 있을 겁니다. 예를들어 MVVM, MVP 같은 아키텍처를 주로 사용하는 안드로이드에서는 대부분 Entity 레이어 나누지 않고 Controller(인터페이스 어댑터) 등 직접적으로 접하지 않는 용어들이 사용되기 때문입니다. 또한 가장 바깥계층인 Frameworks & Drivers 에 DB, Web과 함께 UI 도 포함되어 있으므로 혼란을 일으킬 수 있습니다.
그래서 다음과 같이 안드로이드에 맞춘 이해하기 쉽게 그린 클린아키텍처 구조 그림들이 있습니다.
MVC, MVP, MVVM 과 비교해서 그림을 보면 더 효과적일 것 같아 다른 아키텍처 사진과 입맛게 맞게 수정한 사진을 첨부했습니다 :)
네 제가 봤을때는 처음 그림보다 한층 더 이해하기 쉬워진 것 같습니다. (저만 그럴걸수도...)
안드로이드용으로 이해하기 쉽게 만들어진 클린아키텍처 구조는 Entity 레이러를 따로 두지않고 일반적으로 Presentation, Domain, Data 총 3개의 계층으로 크게 나눠지게 됩니다. 그리고 바로 위 그림을 보면 알 수 있듯이 Presentation -> Domain 방향으로 의존성이 있습니다.
1) Presentation : UI(Activity, Fragment), Presenter 및 ViewModel을 포함합니다. 즉 화면과 입력에 대한 처리 등 UI와 직접적으로 관련된 부분을 담당합니다. 또한 Presentation 레이어는 Domain과 Data 레이어를 포함하고 있다는 특징이 있습니다.
2) Domain : 애플리케이션의 비즈니스 로직을 포함하고 비즈니스 로직에서 필요한 Model 과 UseCase를 포함하고 있습니다. 기존 MVVM을 하고 클린아키텍처를 공부한다면 UseCase를 처음보실텐데 한번만 더 짚고 넘어가자면 각 개별 기능 또는 비즈니스 논리 단위라고 보시면 됩니다. 그래서 UseCase는 보통 한 개의 행동을 담당하고 UseCase의 이름만 보고 이게 무슨 기능을 가졌을지 짐작하고 구분할 수 있어야합니다. 추가로 Domain 레이어는 Presentation, Data 레이어와 어떤 의존성도 맺지 않고 독립적이다는 특징이 있습니다.
3) Data : Repositoy 구현체, Cache, Room DB, Dao, Model 서버API(Retrofit2) 을 포함하고 있으며 로컬 또는 서버 API와 통신하여 데이터를 CRUD 하는 역할을 합니다. 또한 Mapper 클래스도 포함하고 있는데 DB로 부터 받아온 데이터모델과 UI에 맞는 데이터모델간의 변환을 해주는 역할을 합니다. 추가로 Domain 레이어를 포함하고있다는 특징이 있습니다.
이상 클린아키텍처의 각 계층이 어떻게 이루어지는지에 대해 알아봤습니다. !
3. 코드로 보자 (구현)
- 미흡한 부분이 많습니다. 양해부탁드립니다 :) 추후 공부후 업데이트 하겠습니다.
영화검색하는 토이프로젝트 앱에 공부한 내용들을 적용시켜 봤습니다.
1) 프로젝트 패키지 구조
클린아키텍처에서 말한대로 data, domain, presentation 3개의 계층으로 모듈화 했습니다.
그리고 계층간 의존성도 Presentation -> Data -> Domain 이 되기 위해 다음과 같이 app 수준의 build.gradle에 디펜던시를 추가해주었습니다.
[Presentation]
implementation project(':data')
implementation project(':domain')
[Data]
implementation project(':domain')
[Domain]
X
그리고 디펜던시에서는 각 계층에서 필요한 라이브러리만 디펜던시에 추가해주었습니다.
2) Presentation Layer
화면 조작 또는 사용자의 입력을 처리하기 위한 관심사를 모아 놓은 레이어 입니다.
Koin 모듈과 Application 그리고 Activity, ViewModel 같은 뷰단의 코드로 구성되어 있으며 UI 갱신이나 사용자의 UI 입력과 관련된 로직만 처리하게 됩니다.
뷰와 관련된 코드인 Activity, VIewModel, Adapter 코드는 다음과 같으며 영화를 검색하는 화면입니다.
[Activity]
package com.mtjin.presentation.views.search
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.lifecycle.Observer
import com.mtjin.presentation.base.BaseActivity
import com.mtjin.presentation.R
import com.mtjin.presentation.databinding.ActivityMovieSearchBinding
import org.koin.androidx.viewmodel.ext.android.viewModel
class MovieSearchActivity :
BaseActivity<ActivityMovieSearchBinding>(R.layout.activity_movie_search) {
private lateinit var movieAdapter: MovieAdapter
private val viewModel: MovieSearchViewModel by viewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.vm = viewModel
initViewModelCallback()
initAdapter()
}
private fun initAdapter() {
movieAdapter = MovieAdapter { movie ->
Intent(Intent.ACTION_VIEW, Uri.parse(movie.link)).takeIf {
it.resolveActivity(packageManager) != null
}?.run(this::startActivity)
}
binding.rvMovies.adapter = movieAdapter
}
private fun initViewModelCallback() {
with(viewModel) {
toastMsg.observe(this@MovieSearchActivity, Observer {
when (toastMsg.value) {
MovieSearchViewModel.MessageSet.LAST_PAGE -> showToast(getString(R.string.last_page_msg))
MovieSearchViewModel.MessageSet.EMPTY_QUERY -> showToast(getString(R.string.search_input_query_msg))
MovieSearchViewModel.MessageSet.NETWORK_NOT_CONNECTED -> showToast(getString(R.string.network_error_msg))
MovieSearchViewModel.MessageSet.SUCCESS -> showToast(getString(R.string.load_movie_success_msg))
MovieSearchViewModel.MessageSet.NO_RESULT -> showToast(getString(R.string.no_movie_error_msg))
MovieSearchViewModel.MessageSet.ERROR -> showToast(getString(R.string.error_msg))
MovieSearchViewModel.MessageSet.LOCAL_SUCCESS -> showToast(getString(R.string.local_db_msg))
}
})
}
}
}
[Adapter]
package com.mtjin.presentation.views.search
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.mtjin.domain.model.search.Movie
import com.mtjin.presentation.R
import com.mtjin.presentation.databinding.ItemMovieBinding
class MovieAdapter(private val itemClick: (Movie) -> Unit) :
ListAdapter<Movie, MovieAdapter.ViewHolder>(
diffUtil
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding: ItemMovieBinding = DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.item_movie,
parent,
false
)
return ViewHolder(binding).apply {
binding.root.setOnClickListener { view ->
val position = adapterPosition.takeIf { it != RecyclerView.NO_POSITION }
?: return@setOnClickListener
itemClick(getItem(position))
}
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
class ViewHolder(private val binding: ItemMovieBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(movie: Movie) {
binding.movie = movie
binding.executePendingBindings()
}
}
companion object {
val diffUtil = object : DiffUtil.ItemCallback<Movie>() {
override fun areContentsTheSame(oldItem: Movie, newItem: Movie) =
oldItem == newItem
override fun areItemsTheSame(oldItem: Movie, newItem: Movie) =
oldItem.title == newItem.title
}
}
}
[ViewModel]
뷰모델에서는 뒤에서 살펴볼 Domain 계층의 UseCase를 사용합니다. UI와 사용자의 입력에 따른 비즈니스 로직 하나당 UseCase가 존재한다고 보시면 됩니다.
ViewModel 에서 UseCase 참조해야하기 때문에 Presentation Layer는 Domain Layer 에 의존성이 생기게 됩니다.
package com.mtjin.presentation.views.search
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.mtjin.domain.model.search.Movie
import com.mtjin.domain.usecase.GetLocalMoviesUseCase
import com.mtjin.domain.usecase.GetMoviesUseCase
import com.mtjin.domain.usecase.GetPagingMoviesUseCase
import com.mtjin.presentation.base.BaseViewModel
import com.mtjin.data.utils.LAST_PAGE
import com.mtjin.presentation.utils.NetworkManager
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
open class MovieSearchViewModel(
private val getMoviesUseCase: GetMoviesUseCase,
private val getPagingMoviesUseCase: GetPagingMoviesUseCase,
private val getLocalMoviesUseCase: GetLocalMoviesUseCase,
private val networkManager: NetworkManager
) : BaseViewModel() {
private var currentQuery: String = "" // 현재 검색어
val query = MutableLiveData<String>() // 검색어(EditText two-way binding)
private val _movieList = MutableLiveData<MutableList<Movie>>() // 영화리스트
private val _toastMsg = MutableLiveData<MessageSet>() //검색결과 토스트 메시지
val movieList: LiveData<MutableList<Movie>> get() = _movieList
val toastMsg: LiveData<MessageSet> get() = _toastMsg
// 영화검색 (15개)
fun requestMovie() {
currentQuery = query.value.toString().trim()
if (currentQuery.isEmpty()) {
_toastMsg.value = MessageSet.EMPTY_QUERY
return
}
if (!checkNetworkState()) return //네트워크연결 유무
compositeDisposable.add(
getMoviesUseCase.execute(currentQuery)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe { showProgress() }
.doAfterTerminate { hideProgress() }
.subscribe({ movies ->
if (movies.isEmpty()) {
_toastMsg.value = MessageSet.NO_RESULT
} else {
_movieList.value = movies as ArrayList<Movie>
_toastMsg.value = MessageSet.SUCCESS
}
}, {
_toastMsg.value = MessageSet.ERROR
})
)
}
// 검색한 영화 더 불러오기(페이징, 무한스크롤)
fun requestPagingMovie(offset: Int) {
if (!checkNetworkState()) return //네트워크연결 유무
compositeDisposable.add(
getPagingMoviesUseCase.execute(currentQuery, offset)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe { showProgress() }
.doAfterTerminate { hideProgress() }
.subscribe({ movies ->
val pagingMovieList = _movieList.value
pagingMovieList?.addAll(movies)
_movieList.value = pagingMovieList
_toastMsg.value = MessageSet.SUCCESS
}, {
when (it.message) {
LAST_PAGE -> _toastMsg.value = MessageSet.LAST_PAGE
else -> _toastMsg.value = MessageSet.ERROR
}
})
)
}
private fun checkNetworkState(): Boolean {
return if (networkManager.checkNetworkState()) {
true
} else {
requestLocalMovies()
false
}
}
private fun requestLocalMovies() {
compositeDisposable.add(
getLocalMoviesUseCase.execute(currentQuery)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe { showProgress() }
.doAfterTerminate { hideProgress() }
.subscribe({ movies ->
if (movies.isEmpty()) {
_toastMsg.value = MessageSet.NETWORK_NOT_CONNECTED
} else {
_movieList.value = movies as ArrayList<Movie>
_toastMsg.value = MessageSet.LOCAL_SUCCESS
}
}, {
_toastMsg.value = MessageSet.NETWORK_NOT_CONNECTED
})
)
}
enum class MessageSet {
LAST_PAGE,
EMPTY_QUERY,
NETWORK_NOT_CONNECTED,
ERROR,
SUCCESS,
NO_RESULT,
LOCAL_SUCCESS
}
}
3) Data Layer
Domain에서 요청하거나 원하는 데이터를 서버 API, Local DB 와 통신하여 처리해주고 알맞게 반환해주는 역할을 합니다. 레포지토리 패턴으로 구현되어 있습니다.
서버 API, 로컬 DB와의 통신을 담당하는 역할을 합니다. 그래서 Retrofit 관련 코드인 api 패키지와 Repository 패키지를 가지고 있고 Local DB(Room) 관련된 db 패키지도 가지고 있습니다. 또한 통신에 사용될 Entity Model 패키지를 가지고 있으며 Data <-> Domain 간의 Model 데이터 변환을 담당하는 Mapper 도 가지고 있습니다.
Data Layer는 Domain의 Model을 알아야할 Mapper와 Domain의 Repository 인터페이스 구현을 위해 Domain Layer에 의존성이 생기게 됩니다.
Mapper 코드 부터 살펴보면 다음과 같습니다. Data 모델과 Domain, Presentation에 맞는 모델간의 변환을 담당합니다.
package com.mtjin.data.mapper
import com.mtjin.data.model.search.MovieEntity
import com.mtjin.domain.model.search.Movie
fun mapperToMovie(movies: List<MovieEntity>): List<Movie> {
return movies.toList().map {
Movie(
it.actor,
it.director,
it.image,
it.link,
it.pubDate,
it.subtitle,
it.title,
it.userRating
)
}
}
// 이 프로젝트에서는 Domain -> Data 레이어로 모델클래스를 매개변수로 전송하는 일이 없어서 사용은안한다
fun mapperToMovieEntity(movies: List<Movie>): List<MovieEntity> {
return movies.toList().map {
MovieEntity(
it.actor,
it.director,
it.image,
it.link,
it.pubDate,
it.subtitle,
it.title,
it.userRating
)
}
}
이제 Repository 부분을 살펴보겠습니다.
저는 Repositoy 쪽을 Local(Room) , Remote(Sever API) 로 하였는데 Cache 까지 있기도 합니다. 처음에 다음과 같이 Cache도 사용하도록 구현했는데 로직만 더 어려워지고 이 프로젝트에서는 득보다 실이 더 많다고 생각하여 제거하게 되었습니다.
// 이 프로젝트에서는 득보다 실이 더많은 것 같아 결국 제거했다
class MovieCacheDataSourceImpl : MovieCacheDataSource {
private var movieList = ArrayList<Movie>()
override fun getMoviesFromCache(): List<Movie> {
return movieList
}
override fun saveMoviesToCache(movies: List<Movie>) {
movieList.clear()
movieList = ArrayList(movies)
}
}
Repository 쪽 코드는 다음과 같습니다.
[Repository]
local 과 remote를 분리 및 조합시켜 알맞게 로직을 처리합니다. Local 과 Remote 로 부터 concat으로 순서대로 데이터를 가져오게 됩니다.
package com.mtjin.data.repository.search
import com.mtjin.data.mapper.mapperToMovie
import com.mtjin.data.repository.search.local.MovieLocalDataSource
import com.mtjin.data.repository.search.remote.MovieRemoteDataSource
import com.mtjin.data.utils.LAST_PAGE
import com.mtjin.data.utils.NO_DATA_FROM_LOCAL_DB
import com.mtjin.domain.model.search.Movie
import com.mtjin.domain.repository.MovieRepository
import io.reactivex.Flowable
import io.reactivex.Single
class MovieRepositoryImpl(
private val movieRemoteDataSource: MovieRemoteDataSource,
private val movieLocalDataSource: MovieLocalDataSource
) : MovieRepository {
//첫 영화검색
override fun getSearchMovies(query: String): Flowable<List<Movie>> {
return movieLocalDataSource.getSearchMovies(query)
.onErrorReturn { listOf() }
.flatMapPublisher { localMovies ->
if (localMovies.isEmpty()) {
getRemoteSearchMovies(query)
.toFlowable()
.onErrorReturn { listOf() }
} else {
val local = Single.just(mapperToMovie(localMovies)) // 로컬 DB
val remote = getRemoteSearchMovies(query) // 서버 API
.onErrorResumeNext { local }
Single.concat(local, remote) // 순서대로 불러옴
}
}
}
//인터넷이 끊킨 경우 로컬디비에서 검색
override fun getLocalSearchMovies(query: String): Flowable<List<Movie>> {
return movieLocalDataSource.getSearchMovies(query)
.onErrorReturn { listOf() }
.flatMapPublisher { cachedMovies ->
if (cachedMovies.isEmpty()) {
Flowable.error(IllegalStateException(NO_DATA_FROM_LOCAL_DB))
} else {
Flowable.just(mapperToMovie(cachedMovies))
}
}
}
// 서버 DB 영화검색 요청
override fun getRemoteSearchMovies(
query: String
): Single<List<Movie>> {
return movieRemoteDataSource.getSearchMovies(query)
.flatMap {
movieLocalDataSource.insertMovies(it.movies)
.andThen(Single.just(mapperToMovie(it.movies)))
}
}
//영화 검색 후 스크롤 내리면 영화 더 불러오기
override fun getPagingMovies(
query: String,
offset: Int
): Single<List<Movie>> {
return movieRemoteDataSource.getSearchMovies(query, offset).flatMap {
if (it.movies.isEmpty()) {
Single.error(IllegalStateException(LAST_PAGE))
} else {
if (offset != it.start) {
Single.error(IllegalStateException(LAST_PAGE))
} else {
movieLocalDataSource.insertMovies(it.movies)
.andThen(Single.just(mapperToMovie(it.movies)))
}
}
}
}
}
[Local]
Room DB와의 로직 담당
package com.mtjin.data.repository.search.local
import com.mtjin.data.db.MovieDao
import com.mtjin.data.model.search.MovieEntity
import io.reactivex.Completable
import io.reactivex.Single
class MovieLocalDataSourceImpl(private val movieDao: MovieDao) : MovieLocalDataSource {
override fun insertMovies(movies: List<MovieEntity>): Completable =
movieDao.insertMovies(movies)
override fun getAllMovies(): Single<List<MovieEntity>> = movieDao.getAllMovies()
override fun getSearchMovies(title: String): Single<List<MovieEntity>> =
movieDao.getMoviesByTitle(title)
override fun deleteAllMovies(): Completable = movieDao.deleteAllMovies()
}
[Remote]
서버 API와의 통신담당
package com.mtjin.data.repository.search.remote
import com.mtjin.data.api.ApiInterface
import com.mtjin.data.model.search.MovieResponse
import io.reactivex.Single
class MovieRemoteDataSourceImpl(private val apiInterface: ApiInterface) :
MovieRemoteDataSource {
override fun getSearchMovies(query: String, start: Int): Single<MovieResponse> {
return apiInterface.getSearchMovie(query, start)
}
}
[Model]
마지막으로 Model 은 다음과 같으며 API, Room 과 엮여있습니다
@Entity(tableName = "movie")
data class MovieEntity(
@SerializedName("actor")
val actor: String,
@SerializedName("director")
val director: String,
@SerializedName("image")
val image: String,
@SerializedName("link")
val link: String,
@SerializedName("pubDate")
val pubDate: String,
@SerializedName("subtitle")
val subtitle: String,
@PrimaryKey(autoGenerate = false)
@SerializedName("title")
val title: String,
@SerializedName("userRating")
val userRating: String
)
package com.mtjin.data.model.search
import com.google.gson.annotations.SerializedName
data class MovieResponse(
@SerializedName("display")
val display: Int,
@SerializedName("items")
val movies: List<MovieEntity>,
@SerializedName("lastBuildDate")
val lastBuildDate: String,
@SerializedName("start")
val start: Int,
@SerializedName("total")
val total: Int
)
3) Domain Layer
마지막으로 누구와도 의존성을 이루지 않는 독립적인 Domain Layer 입니다. 비즈니스와 관련된 로직을 담당합니다.
앱에서 사용할 Model과 각각의 비즈니스 로직 단위를 나타내는 UseCase, UseCase의 실질적인 구현을 담당하게 할 Repository 인터페이스로 구성이 되어있습니다.
코드와 함께 살펴보겠습니다.
[UseCase]
Usecase란 1개 이상의 Repository를 받아 비즈니스 로직을 처리하며 하나의 유저 행동에 대한 비즈니스 로직을 가지고 있는 객체입니다. 보통 하나의 UseCase 는 하나의 로직을 담당하게끔 한다고 합니다. 그래서 UseCase의 이름만 보고도 무엇을 할지 알게끔 네이밍을 잘 지어야하며 이는 코드를 이해하는데 큰 도움이 됩니다. 하지만 이로 인해 많은 UseCase 클래스가 생긴다는 단점도 있긴 합니다.
공부하면서 이 하나의 로직당 UseCase를 지어야하는지 아니면 좀 비슷한건 묶어야되는지에 대한것과 네이밍을 어떻게할지에 대해 혼란스러운 점이 많았던 것 같습니다. 일단 하나의 로직만 갖게끔 구현했습니다.
package com.mtjin.domain.usecase
import com.mtjin.domain.model.search.Movie
import com.mtjin.domain.repository.MovieRepository
import io.reactivex.Flowable
//로컬DB에서 영화검색목록 가져오기
class GetLocalMoviesUseCase(private val repository: MovieRepository) {
fun execute(
query: String
): Flowable<List<Movie>> = repository.getSearchMovies(query)
}
package com.mtjin.domain.usecase
import com.mtjin.domain.model.search.Movie
import com.mtjin.domain.repository.MovieRepository
import io.reactivex.Flowable
//검색영화목록 가져오기 (Local + Remote)
class GetMoviesUseCase(private val repository: MovieRepository) {
fun execute(
query: String
): Flowable<List<Movie>> = repository.getSearchMovies(query)
}
package com.mtjin.domain.usecase
import com.mtjin.domain.model.search.Movie
import com.mtjin.domain.repository.MovieRepository
import io.reactivex.Single
// 검색영화목록 더 가져오기 (페이징)
class GetPagingMoviesUseCase(private val repository: MovieRepository) {
fun execute(
query: String,
offset: Int
): Single<List<Movie>> = repository.getPagingMovies(query, offset)
}
[Model]
Data Layer와 Model과의 차이점은 Room, Retrofit 등과 연관되지 않는 순수 data class 이며 실제 필요한 데이터만 가지고 있습니다.
package com.mtjin.domain.model.search
data class Movie(
val actor: String,
val director: String,
val image: String,
val link: String,
val pubDate: String,
val subtitle: String,
val title: String,
val userRating: String
)
[Repository 인터페이스]
UseCase에서 사용할 레포지토리를 가지고 있습니다. 실제 구현체는 Data Layer에 있으며 Domain Layer는 모릅니다.
package com.mtjin.domain.repository
import com.mtjin.domain.model.search.Movie
import io.reactivex.Flowable
import io.reactivex.Single
interface MovieRepository {
fun getSearchMovies(
query: String
): Flowable<List<Movie>>
fun getLocalSearchMovies(
query: String
): Flowable<List<Movie>>
fun getRemoteSearchMovies(
query: String
): Single<List<Movie>>
fun getPagingMovies(
query: String,
offset: Int
): Single<List<Movie>>
}
이상 안드로이드 클린 아키텍처에 대해 간략히 정리해봤고 3일 동안 많은 블로그들을 참고하면서 공부하고 구현해봤는데 여전히 어렵네요. 구현도 해보긴 했지만 부족하고 틀린부분이 많다고 생각합니다. 양해부탁드립니다.ㅇㅅㅇ
글에서 부족한 부분들은 참고 및 출처나 구글링에서 자세하게 알 수 있으니 참고해주시면 감사하겠습니다. :)
당분간은 Udemy 강의를 들으면서 기본기를 A부터 Z까지 쌓을 예정이고 이 강의에서도 나중에 클린아키텍처 내용이 들어있기 때문에 이해와 구현하는데 큰 도움이 될 것 같습니다. 이후에 실력 더 쌓고 내용과 코드를 수정해보도록 하겠습니다.
P.S 이 토이 프로젝트에서는 에러코드를 그냥 kt파일 const val 로 코드를 나누어 해결했는데 원래 같으면 result나 code 같은 값을 가진 BaseResponse 같은 data class 파일을 만들어 깔끔하게 분기를 나누거나 Exception 클래스를 상속받은 클래스들을 받아서 통신 응답 및 예외 처리를 하면 좋으나 토이프로젝트이기 때문에 간단히 상수로 해결한점 양해부탁드립니다.
댓글과 공감은 큰 힘이 됩니다. 감사합니다. !!
[시연영상]
[2022-02-01]
Hilt를 추가한 MVVM + Hilt + RxJava2 + Multi Module 클린아키텍처 프로젝트 예시를 추가로 만들었습니다. 예전에 한 프로젝트를 구조만 바꾼거라 부족한 면이 많은 점 양해부탁드립니다. ㅎㅎ
(Main , clean-mvvm-multi-module-rxjava-hilt 브랜치 참고)
참고 및 출처:
namget.tistory.com/entry/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-Clean-Architecture
academy.realm.io/kr/posts/converting-an-app-to-use-clean-architecture/
rubygarage.org/blog/clean-android-architecture
woowabros.github.io/tools/2019/10/02/clean-architecture-experience.html
woowabros.github.io/experience/2019/01/17/baeminapp-clean-architecture.html
medium.com/android-dev-hacks/detailed-guide-on-android-clean-architecture-9eab262a9011
등 많이 참고하였습니다. 감사합니다
'안드로이드 > 코틀린 & 아키텍처 & Recent' 카테고리의 다른 글
[안드로이드] Notification(노티피케이션) 정리 및 예제 (4) | 2021.02.14 |
---|---|
[코틀린] 코루틴(coroutine) 학습 정리 (0) | 2021.02.13 |
[안드로이드] Android Jetpack Navigation Animation Transition (젯팩 네비게이션 애니메이션 트랜지션) (2) | 2021.01.23 |
[안드로이드] 프래그먼트 액션바(ActionBar) 메뉴 샘플 코드 기록 (4) | 2021.01.23 |
[안드로이드] 코틀린 유용한 확장함수(let, with, apply, run) 예제 정리 (0) | 2021.01.20 |