막무가내 삽질 블로그

MVVM + Clean Architecture를 활용한 영화검색 앱 본문

Android

MVVM + Clean Architecture를 활용한 영화검색 앱

joong~ 2021. 5. 10. 21:32
728x90

 

 

 

이 프로젝트는 약 1년전 쯤 했던 프로젝트였다. 그 때는 클린아키텍처는 적용하지 않았다.

이번에는 갓수아님 소스코드를 참고해 클린아키텍처까지 적용해봤다.

 

스플레시, 로그인, 검색화면 총 3가지 화면으로 구성되어 있고

마지막 검색결과와 캐싱까지 적용했다. (이번에는 페이징은 하지않았음)

 

MVVM 과 클린아키텍처에 대해 정보를 얻고자 하시는 분들은 다른 블로그를 참고하길 바란다.(공부한 내용을 다시 재정리하는 것이므로 내용이 빠져있을 것이다)

 

 

전체 패키지구조

4가지의 패키지로 구성했다.

 

 

1. [data 계층]

data계층에는 Local에 접근할 수 있는 local과, 네트워크 사용을 위한 Remote와 Repository의 구현체인 RepositoryImpl이 있다.

remote먼저 살펴보자

remote에는 api, interceptor, mapper, model, source 패키지가 있다.

어떤 역할을 하는지 바로 파악될 것 이다.

data-model

네트워크에 성공한 응답모델을 JayMovieDataSourceImpl에서 mapper를 통해 data layer모델에 맞게 가공해주었다.

data layer모델은 local과 remote에서 같이 사용될 공통 모델이다. (local과 remote안에도 그에 맞는 모델이 들어있다.)

 

 

 

2. [domain 계층]

domain계층에는 비지니스 로직을 처리하는 곳이다. 이 계층에는 data계층에 접근하기 위한 interface를 가지고 있다.

JayMovieRepository의 구현체인 MovieRepositoryImpl의 코드를 살펴보자

저장된 영화목록이 없을 떄 코드를 살펴보자(if문)

저장된 목록이 없을 때 remote의 getMovies를 통해 데이터를 가져와 local database에 영화목록을 먼저 저장을 하고 받아온 데이터들을 내려준다. 위에 JayMovieRepository의 구현체인 MovieRepositoryImpl의 코드를 보면 네트워크 응답모델을 data layer에 맞게 가공을 해주는 모습을 볼 수 있다.

 

 

3. [ui 계층]

presentation 계층은 사용자에게 보이는 ui를 처리를 한다. (여기서 화면에 보여줄 아이템모델로 사용했다)

 

 

뷰모델에서 repository의 getMovies를 호출해(리턴 domain의 아이템) presentation의 모델의 맞게 가공해주었다.

 

 

4. [utils]

 

 

ㅡㅡㅡㅡㅡㅡㅡ 1차 완성 대충 정리 ㅡㅡㅡㅡㅡㅡㅡ

 

 

 

 

 

 

 

 

 

프로젝트 구조에 따라 계층을 나눠봤다.

Presentation Layer에는 화면의 표시, 애니메이션, 사용자 입력 처리 등 UI에 관련된 모든 처리를 갖는다.

Domain Layer에는 앱의 실질적인 데이터가 여기에 구현된다.

Data Layer에는 실제 데이터의 입출력이 여기서 실행된다.

 

안드로이드 단톡방에 어떤분이 Repository와 DatSource(Data)의 구분에 대해 질문을 올리셨는데 박상권님이 알기 쉽게 답변해주셨다.

"짜장면집에 짜장면을 시켰습니다. 저는 한국산 짜장소스든, 중국산 짜장소스든 알필요 없고 맛있는 짜장면만 오면 됩니다" => Repository

"짜장면집 사장님은 상황에 따라서 어떨 땐 중국산 짜장 소스 쓰고, 어떨 땐 한국산 짜장 소스를 씁니다. 어느 소스를 쓰든 사장님은 맛있는 짜장면만 만들어 주면 됩니다" => DataSource(local, remote)

 

 

이번에는 좀 더 깊숙하게 알아보자.

[data layer]

source -> mapper -> data model

interface JayMovieRemoteDataSource : JayRemoteDataSource {
    fun getMovies(query: String): Single<List<JayMovieData>>
}
class JayMovieRemoteDataSourceImpl(
    private val naverApi: NaverApi
) : JayMovieRemoteDataSource {
    override fun getMovies(query: String): Single<List<JayMovieData>> {
        return naverApi.getSearchMovie(query)
            .map { it.items.map(JayMovieRemoteMapper::mapToData) }
    }
}
data class JayMovieData(
    val title: String,
    val link: String,
    val image: String,
    val subtitle: String,
    val pubDate: Date,
    val director: String,
    val actor: String,
    val userRating: Float
) : JayData

DataSource에 getMovies함수는 Single<List<JayMovieData>>를 반환한다. 여기서 JayMovieData는 local와 remote를 조합하는 공통 data model 이다. source의 구현체인 impl에서 api를 통해 받아온 데이터(List<NaverSearchResponse<MovieModel>>)를 mapper를 통해 data model에 맞게 가공해준다.

interface JayRemoteMapper<R : Model, D : JayData> {

    fun mapToData(from: R): D
}
object JayMovieRemoteMapper : JayRemoteMapper<RemoteModel, JayMovieData> {
    private const val DATE_FORMAT_YEAR = "yyyy"

    override fun mapToData(from: RemoteModel): JayMovieData {
        return JayMovieData(
            title = from.title.toPlainFromHtml(),
            link = from.link,
            image = from.image,
            subtitle = from.subtitle.toPlainFromHtml(),
            pubDate = from.pubDate.toDateWith(DATE_FORMAT_YEAR) ?: Date(0),
            director = from.director.toPlainFromHtml(),
            actor = from.actor.toPlainFromHtml(),
            userRating = from.userRating.toFloatOrNull() ?: 0f
        )
    }
}

 

[domain layer]

data model -> mapper -> domain model

interface JayMovieRepository : JayRepository {
    val latestMovieQuery: String

    fun getMovies(query: String): Flowable<List<JayMovieModel>>
}
class MovieRepositoryImpl(
    private val movieLocalDataSource: JayMovieLocalDataSource,
    private val movieRemoteDataSource: JayMovieRemoteDataSource
) : JayMovieRepository {
    override val latestMovieQuery: String
        get() = movieLocalDataSource.latestMovieQuery

    override fun getMovies(query: String): Flowable<List<JayMovieModel>> {
        movieLocalDataSource.latestMovieQuery = query
        return movieLocalDataSource.getMovies()
            .onErrorReturn { listOf() }
            .flatMapPublisher { cachedMovies ->
                if (cachedMovies.isEmpty()) {
                    getRemoteMovies(query)
                        .map { it.map(JayMovieDataMapper::mapToModel) }
                        .toFlowable()
                        .onErrorReturn { listOf() }
                } else {
                    val local = Single.just(cachedMovies)
                        .map { it.map(JayMovieDataMapper::mapToModel) }
                    val remote = getRemoteMovies(query)
                        .map { it.map(JayMovieDataMapper::mapToModel) }
                        .onErrorResumeNext { local }
                    Single.concat(local, remote)
                }
            }
    }

    private fun getRemoteMovies(query: String): Single<List<JayMovieData>> {
        return movieRemoteDataSource.getMovies(query)
            .flatMap { remoteMovies ->
                movieLocalDataSource.saveMovies(remoteMovies)
                    .andThen(Single.just(remoteMovies))
            }
    }
}
interface JayMovieLocalDataSource : JayLocalDataSource {
    var latestMovieQuery: String

    fun getMovies(): Single<List<JayMovieData>>

    fun saveMovies(movies: List<JayMovieData>): Completable
}
class JayMovieLocalDataSourceImpl(
    private val prefs: PreferencesHelper,
    private val movieDao: JayMovieDao
) : JayMovieLocalDataSource {
    override var latestMovieQuery: String
        get() = prefs.latestMovieQuery
        set(value) {
            prefs.latestMovieQuery = value
        }

    override fun getMovies(): Single<List<JayMovieData>> {
        return movieDao.getMovies()
            .map { it.map(JayMovieLocalMapper::mapToData) }
            .subscribeOn(Schedulers.io())
    }

    override fun saveMovies(movies: List<JayMovieData>): Completable {
        return movieDao.deleteAll()
            .andThen(Single.just(movies))
            .map { it.map(JayMovieLocalMapper::mapToLocal) }
            .flatMapCompletable(movieDao::insertMovies)
            .subscribeOn(Schedulers.io())
    }
}
interface JayDataMapper<D : JayData, M : JayModel> {
    fun mapToModel(from: D): M
}
object JayMovieDataMapper : JayDataMapper<JayMovieData, JayMovieModel> {
    override fun mapToModel(from: JayMovieData): JayMovieModel {
        return JayMovieModel(
            title = from.title,
            link = from.link,
            image = from.image,
            subtitle = from.subtitle,
            pubDate = from.pubDate,
            director = from.director,
            actor = from.actor,
            userRating = from.userRating
        )
    }

}

MovieRepositoryImpl에서 보는 거와 같이

1. 저장된 영화목록이 없을 경우

remote에서 가져온 모델을 data model에 맞게 가공해서 local db에 먼저 저장한 후 데이터를 내려준다.(가공한 데이터(domain model))

저장될 영화목록들도 local model에 맞게(data model -> local model) 가공한 후 저장한다.

 

2. 저장된 영화목록이 있을 경우

local과 remote모두 데이터를 가져온다. (remote에서 실패했을 경우 대체아이템으로 local를 내려준다)

concat으로 local과 remote를 합쳐준다. concat은 여러 함수가 있는데 보통 첫번째 스트림이 다 끝나고 그 뒤에 스트림이 진행되고 합쳐준다. 여기 코드에서 의문점이 들었던 점은 만약 저장되어 있던 데이터랑 remote가 틀리면 어떻게 될까라는 생각을 했다. 그럼 다른 데이터가 나오는 것이 아닐까라는 생각을 했었다(잘못된 생각).

Single의 concat을 보면 Flowable로 변환을 한다. 리턴값으로는 concat(Flowable.fromArray(())이다. fromArray를 타고 들어가보면 length == 1일때 0번째 인덱스를 반환한다. 고로 fromArray의 파라미터로 들어온 첫번째 인자가 반환된다.

 

 

[presentation layer]

domain model -> mapper -> presentation model

interface MovieViewModelType : ViewModelType<MovieViewModelType.Input, MovieViewModelType.Output> {
    interface Input {
        fun searchClick()
        fun debounceQuery(query: String)
    }

    interface Output {
        val searchText: MutableLiveData<String>
        val movieList: LiveData<List<JayMoviePresentation>>
        val isLoading: LiveData<Boolean>
    }
}

class MovieViewModel(
    private val movieRepository: JayMovieRepository
) : JayViewModel<MovieState>(), MovieViewModelType, MovieViewModelType.Input, MovieViewModelType.Output {

    override val input: MovieViewModelType.Input
        get() = this

    override val output: MovieViewModelType.Output
        get() = this

    private val _searchClick: Subject<Unit> = PublishSubject.create()
    private val _movieClick: PublishSubject<JayMoviePresentation> = PublishSubject.create()

    private val _searchText: NotNullMutableLiveData<String> = NotNullMutableLiveData(movieRepository.latestMovieQuery)
    private val _movieList: NotNullMutableLiveData<List<JayMoviePresentation>> = NotNullMutableLiveData(emptyList())
    private val _isLoading: NotNullMutableLiveData<Boolean> = NotNullMutableLiveData(false)

    override val searchText: MutableLiveData<String>
        get() = _searchText

    override val movieList: LiveData<List<JayMoviePresentation>>
        get() = _movieList

    override val isLoading: LiveData<Boolean>
        get() = _isLoading

    override fun searchClick() = _searchClick.onNext(Unit)

    override fun debounceQuery(query: String) = _searchText.postValue(query)

    init {
        val query = _searchText.rx
            .debounce(700, TimeUnit.MILLISECONDS)
            .toFlowable(BackpressureStrategy.DROP)
        val button = _searchClick.throttleFirst(1, TimeUnit.SECONDS)
            .map { _searchText.value }
            .toFlowable(BackpressureStrategy.DROP)

        compositeDisposable.addAll(
            Flowable.merge(query, button)
                .filter { it.length >= 2 }
                .distinctUntilChanged()
                .observeOn(AndroidSchedulers.mainThread())
                .doOnNext { showLoading() }
                .switchMap(movieRepository::getMovies)
                .map {
                    it.map(JayMoviePresentationMapper::mapToPresentation).map { movie ->
                        movie.apply {
                            onClick = _movieClick
                        }
                    }
                }
                .onErrorReturn { listOf() }
                .observeOn(AndroidSchedulers.mainThread())
                .doOnNext { hideLoading() }
                .subscribe { _movieList.value = it },

            _movieClick.map(JayMoviePresentation::link)
                .subscribe { url ->
                    runState(MovieState.ShowMovieLink(url))
                }
        )
    }

    private fun showLoading() = _isLoading.postValue(true)

    private fun hideLoading() = _isLoading.postValue(false)
}
interface JayPresentationMapper<M : JayModel, P : JayPresentation> {
    fun mapToPresentation(from: M): P
}
object JayMoviePresentationMapper : JayPresentationMapper<JayMovieModel, JayMoviePresentation> {
    private const val DATE_FORMAT_YEAR = "yyyy"

    override fun mapToPresentation(from: JayMovieModel): JayMoviePresentation {
        return JayMoviePresentation(
            title = from.title,
            link = from.link,
            image = from.image,
            subtitle = from.subtitle,
            pubDate = from.pubDate.formatWith(DATE_FORMAT_YEAR),
            director = from.director,
            actor = from.actor,
            userRating = from.userRating
        )
    }

}

domain model을 최종적으로 view에 뿌려주기 위해 presentation에 맞게 가공했다.

 

class MovieActivity : BaseActivity<ActivityMovieBinding, MovieViewModel, MovieState>(
    R.layout.activity_movie
) {
    override val viewModel: MovieViewModel by viewModels {
        object : ViewModelProvider.Factory {
            override fun <T : ViewModel?> create(modelClass: Class<T>): T {
                return MovieViewModel(
                    movieRepository = requireApplication().movieRepository
                ) as T
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        initAdapter()
    }

    override fun onState(newState: MovieState) {
        when (newState) {
            is MovieState.ShowMovieLink -> {
                startActivity(Intent(Intent.ACTION_VIEW).apply {
                    data = newState.movieLink.toUri()
                })
            }
        }
    }

    private fun initAdapter() {
        binding.rvSearchResult.adapter = MovieAdapter()
    }

}

 

 

이번에 clean architecture를 처음으로 적용했다. 클린 아키텍처에 관해 책도 읽어보고 많은 블로그도 봤다.

솔직히 아직도 내가 잘 이해 하고 있는건지 잘 모르겠다. 앞으로 계속 공부하면서 적용해보고 리뷰받아서 수정도 해보고 해봐야겠다.

이 다음챕터는 di와 multi module로 나눌건데 그건 포스팅하지 않을 예정이다. (di는 포스팅을 했었다)

 

 

 

ㅡㅡㅡㅡㅡㅡㅡ 2차 완성 대충 정리 ㅡㅡㅡㅡㅡㅡㅡ

 

 

 

3차 진행 중(utils, ext)

 

 

전체소스코드

github.com/wj1227/StudyMovie

 

wj1227/StudyMovie

Contribute to wj1227/StudyMovie development by creating an account on GitHub.

github.com

 

Comments