관리 메뉴

막내의 막무가내 프로그래밍 & 일상

[RxJava] MVVM - Model Layer 네트워크 통신에 적용 본문

안드로이드/RxJava

[RxJava] MVVM - Model Layer 네트워크 통신에 적용

막무가내막내 2020. 5. 12. 15:59
728x90

https://github.com/mtjin/android-architecture-study-movieapp/tree/master/BACK_UP/9-RxJava/AndroidArchitectureStudy

 

mtjin/android-architecture-study-movieapp

안드로이드 아키텍처 스터디 정리. Contribute to mtjin/android-architecture-study-movieapp development by creating an account on GitHub.

github.com

 

RxJava 를 공부하면서 기존 코틀린 고차함수와 레트로핏의 콜백(enqueue...)형식 으로 이루어져있던 Model Layer 부분을 RxJava로 변경하는 작업을 해보았습니다. (네트워크 통신 쪽을 중점적으로 수정했습니다.)

 

아직 RxJava가 많이 미숙한 초보자이고 처음 적용하는거라 이상한 부분이 많을 수 도 있습니다.

 

간략하게 정리하고 나중에 참고하고 발전시킬려고 코드형식으로 기록합니다.

 

MVVM 형식입니다.

 

[관련 디펜던시]

//retrofit2
    implementation 'com.squareup.retrofit2:retrofit:2.8.1'
    implementation 'com.squareup.retrofit2:converter-gson:2.8.1'
    implementation "com.squareup.retrofit2:adapter-rxjava2:2.8.1"
    //glide
    implementation 'com.github.bumptech.glide:glide:4.11.0'
    kapt 'com.github.bumptech.glide:compiler:4.11.0'
    //rxjava
    implementation "io.reactivex.rxjava2:rxjava:2.2.17"
    implementation "io.reactivex.rxjava2:rxandroid:2.1.1"
    implementation 'com.squareup.retrofit2:adapter-rxjava2:2.8.1'
    implementation "io.reactivex.rxjava2:rxkotlin:2.3.0"
    // Room
    implementation "androidx.room:room-runtime:2.2.5"
    implementation "androidx.room:room-rxjava2:2.2.5"
    testImplementation "androidx.room:room-testing:2.2.5"
    kapt "androidx.room:room-compiler:2.2.5"
    implementation 'androidx.room:room-ktx:2.2.5'

 

 

[VIewModel]

BaseViewModel 을 상속받아 compositeDisposable 로 쉽게 메모리누수가 생기지 않게 관리를 해줍니다.

package com.mtjin.androidarchitecturestudy.ui.search

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.mtjin.androidarchitecturestudy.base.BaseViewModel
import com.mtjin.androidarchitecturestudy.data.search.Movie
import com.mtjin.androidarchitecturestudy.data.search.source.MovieRepository
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers

open class MovieSearchViewModel(private val movieRepository: MovieRepository) : BaseViewModel() {

    private var currentQuery: String = ""
    val query = MutableLiveData<String>()
    private val _movieList = MutableLiveData<ArrayList<Movie>>()
    private val _toastMsg = MutableLiveData<MessageSet>()
    private val _isLoading = MutableLiveData<Boolean>(false)

    val movieList: LiveData<ArrayList<Movie>> get() = _movieList
    val toastMsg: LiveData<MessageSet> get() = _toastMsg
    val isLoading: LiveData<Boolean> get() = _isLoading


    fun requestMovie() {
        currentQuery = query.value.toString().trim()
        if (currentQuery.isEmpty()) {
            _toastMsg.value = MessageSet.EMPTY_QUERY
        } else {
            compositeDisposable.add(
                movieRepository.getSearchMovies(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.NETWORK_ERROR
                    })
            )
        }
    }

    fun requestPagingMovie(offset: Int) {
        compositeDisposable.add(
            movieRepository.getRemotePagingMovies(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) {
                        "Network Error" -> _toastMsg.value = MessageSet.NETWORK_ERROR
                        else -> _toastMsg.value = MessageSet.LAST_PAGE
                    }
                })
        )
    }

    private fun showProgress() {
        _isLoading.value = true
    }

    private fun hideProgress() {
        _isLoading.value = false
    }

    enum class MessageSet {
        LAST_PAGE,
        EMPTY_QUERY,
        NETWORK_ERROR,
        SUCCESS,
        NO_RESULT
    }
}

 

 

 

[Repository]

인터넷연결 상태에 따른 분기처리, 데이터 캐싱 등을 구현했습니다.

참고로 map과 flatmap 의 가장 큰 차이점은 map은 그 데이터를 가공한 그대로 리턴해주지만 flatmap은 Observbale 형태로 데이터를 감싸서 리턴해줍니다.

그리고 flatmapPublisher 라는 것도 사용했는데 다음 링크와 공식문서를 참고하면, Returns a Flowable that emits items based on applying a specified function to the item emitted by the source Single, where that function returns a Publisher.

Publisher를 반환하는 곳의 Single에서 방출한 데이터에 특정한 기능을 적용(가공)하여 데이터를 내보내는 Flowable을 반환합니다. 

https://stackoverflow.com/questions/49399368/what-is-flatmappublisher-in-rxjava2

 

What is flatMapPublisher in RxJava2?

What is the purpose of flatMapPublisher ? return factory.retrieveDiskDataStore().isCached() .flatMapPublisher { factory.retrieveDataStore(it).getData(token) ...

stackoverflow.com

package com.mtjin.androidarchitecturestudy.data.search.source

import com.mtjin.androidarchitecturestudy.data.search.Movie
import com.mtjin.androidarchitecturestudy.data.search.source.local.MovieLocalDataSource
import com.mtjin.androidarchitecturestudy.data.search.source.remote.MovieRemoteDataSource
import com.mtjin.androidarchitecturestudy.utils.NetworkManager
import io.reactivex.Flowable
import io.reactivex.Single

class MovieRepositoryImpl(
    private val movieRemoteDataSource: MovieRemoteDataSource,
    private val movieLocalDataSource: MovieLocalDataSource,
    private val networkManager: NetworkManager
) : MovieRepository {
    override fun getSearchMovies(query: String): Flowable<List<Movie>> {
        if (networkManager.checkNetworkState()) {
            return movieLocalDataSource.getSearchMovies(query)
                .onErrorReturn { listOf() }
                .flatMapPublisher { cachedMovies ->
                    if (cachedMovies.isEmpty()) {
                        getRemoteSearchMovies(query)
                            .toFlowable()
                            .onErrorReturn { listOf() }
                    } else {
                        val local = Single.just(cachedMovies)
                        val remote = getRemoteSearchMovies(query)
                            .onErrorResumeNext { local }
                        Single.concat(local, remote)
                    }
                }
        } else {
            return movieLocalDataSource.getSearchMovies(query)
                .onErrorReturn { listOf() }
                .flatMapPublisher { cachedMovies ->
                    if (cachedMovies.isEmpty()) {
                        Flowable.error(IllegalStateException("Network Error"))
                    } else {
                        Flowable.just(cachedMovies)
                    }
                }
        }
    }

    override fun getRemoteSearchMovies(
        query: String
    ): Single<List<Movie>> {
        return movieRemoteDataSource.getSearchMovies(query)
            .flatMap {
                movieLocalDataSource.insertMovies(it.movies)
                    .andThen(Single.just(it.movies))
            }
    }

    override fun getRemotePagingMovies(
        query: String,
        start: Int
    ): Single<List<Movie>> {
        return if (networkManager.checkNetworkState()) {
            movieRemoteDataSource.getSearchMovies(query, start).flatMap {
                if (it.movies.isEmpty()) {
                    Single.error(IllegalStateException("Last Page"))
                } else {
                    if (start != it.start) {
                        Single.error(IllegalStateException("Last Page"))
                    } else {
                        movieLocalDataSource.insertMovies(it.movies)
                            .andThen(Single.just(it.movies))
                    }
                }
            }
        } else {
            Single.error(IllegalStateException("Network Error"))
        }
    }
}

 

 

 

[Local]

Select 에는 Single을 insert와 delete 는 성공 OR 실패만 전달해주면 되기 때문에 Completable을 리턴타입으로 사용했습니다.

package com.mtjin.androidarchitecturestudy.data.search.source.local

import com.mtjin.androidarchitecturestudy.data.search.Movie
import io.reactivex.Completable
import io.reactivex.Single

class MovieLocalDataSourceImpl(private val movieDao: MovieDao) : MovieLocalDataSource {
    override fun insertMovies(movies: List<Movie>): Completable {
        return movieDao
            .insertMovies(movies)
    }

    override fun getAllMovies(): Single<List<Movie>> {
        return movieDao
            .getAllMovies()
            .map {
                it
            }
    }

    override fun getSearchMovies(title: String): Single<List<Movie>> {
        return movieDao.getMoviesByTitle(title)
            .map {
                it
            }
    }

    override fun deleteAllMovies(): Completable {
        return movieDao.deleteAllMovies()
    }
}
package com.mtjin.androidarchitecturestudy.data.search.source.local

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy.REPLACE
import androidx.room.Query
import com.mtjin.androidarchitecturestudy.data.search.Movie
import io.reactivex.Completable
import io.reactivex.Single

@Dao
interface MovieDao {

    @Insert(onConflict = REPLACE)
    fun insertMovies(movies: List<Movie>): Completable

    @Query("SELECT * FROM movie")
    fun getAllMovies(): Single<List<Movie>>

    @Query("SELECT * FROM movie WHERE title LIKE '%' || :title || '%'")
    fun getMoviesByTitle(title: String): Single<List<Movie>>

    @Query("DELETE FROM movie")
    fun deleteAllMovies(): Completable
}

 

 

[Remote]

네트워크 통신의 데이터 결과를 보내주기위해 Single을 사용했습니다. map은 딱히 가공하는 로직이 없으므로 생략해도 됩니다. 

package com.mtjin.androidarchitecturestudy.data.search.source.remote

import com.mtjin.androidarchitecturestudy.api.ApiInterface
import com.mtjin.androidarchitecturestudy.data.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)
            .map {
                it
            }
    }
}

 

 

 

[Base]

그 밖에 상속해서 사용하는 BaseActivity 와 BaseViewModel 은 다음과 같습니다.

참고로 BaseViewModel 에서 onDestory() 가 아닌 onCleared() 에서 CompositeDispolsable()을 해제해주어야합니다. (CompositeDisaposable 클래스를 이용하면 생성된 모든 Observable을 안드로이드 라이프사이클에 맞춰 한 번에 모두 해체할 수 있습니다._

이유는 밑 그림의 수명주기를 보면 알 수 있습니다.
액티비티가 완전히 종료될때 뷰모델이 종료되야하기 때문에 onDestory() 시점에 일어나는 onCleared()에서 옵저버블들의 메모리누수 방지를 위해 해제해주는 작업을 합니다.

이에대한 더 자세한 내용은 다음을 참고해주세요
https://beomseok95.tistory.com/m/60

package com.mtjin.androidarchitecturestudy.base

import android.os.Bundle
import android.widget.Toast
import androidx.annotation.LayoutRes
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import io.reactivex.disposables.CompositeDisposable

abstract class BaseActivity<B : ViewDataBinding>(
    @LayoutRes val layoutId: Int
) : AppCompatActivity() {
    lateinit var binding: B
    private val compositeDisposable = CompositeDisposable()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, layoutId)
        binding.lifecycleOwner = this
    }

    protected fun showToast(msg: String) {
        Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
    }

    override fun onDestroy() {
        super.onDestroy()
        compositeDisposable.clear()
    }
}

package com.mtjin.androidarchitecturestudy.base

import androidx.lifecycle.ViewModel
import io.reactivex.disposables.CompositeDisposable

abstract class BaseViewModel : ViewModel() {
    protected val compositeDisposable = CompositeDisposable()

    override fun onCleared() {
        compositeDisposable.dispose()
        super.onCleared()
    }
}

 

 

 

액티비티 사용 예

class MovieSearchActivity :
    BaseActivity<ActivityMovieSearchBinding>(R.layout.activity_movie_search) {

 

 

 

댓글과 공감은 큰 힘이 됩니다. 감사합니다!

728x90
Comments