이 글은 안드로이드 공식문서를 참고하여 작성했다.

https://developer.android.com/codelabs/basic-android-kotlin-training-repository-pattern?hl=ko#0 

 

저장소 패턴  |  Android Developers

저장소 패턴을 사용하여 기존 앱에서 캐싱을 구현합니다.

developer.android.com

 

Repository Pattern 이란?

데이터 레이어를 앱의 나머지 부분에서 분리하는 디자인 패턴이다.

- 데이터 레이어는 UI와 별도로 앱의 데이터와 비지니스 로직을 처리하는 앱 부분을 나타내며 앱의 나머지 부분에서 이 데이터에 액세스할 수 있도록 일관된 API를 노출한다.

- UI가 사용자에게 정보를 제공하는 동안 데이터 레이어에는 네트워킹 코드, Room 데이터베이스, 오류 처리, 데이터를 읽거나 조작하는 코드를 포함한다.

 

뭔 말이냐... 그림을 보며 이해해보자!

데이터는 Room Data, Retorfit, Firebase 등 다양한 곳에서 데이터를 받을 수 있다. 그래서 어떤 형태로 데이터가 접근을 해도 Repository 클래스를 통해 캡슐화해서 깔끔하게 접근하겠다는 거지!!

 

즉, 데이터가 어디서 왔든지 동일한 Repository 인터페이스로 접근하겠다는 것이다!!!


코드 예제

작업 1. 저장소 만들기

- 오프라인 캐시를 관리할 저장소를 만들어보자!!

- 베이스 코드는 위의 안드로이드 공홈에서 다운로드 받자.

 

저장소 추가 및 데이터베이스에서 데이터 검색

VideosRepository.kt

class VideosRepository(private val database:VideosDatabase){

    // LiveData 객체를 만들어 데이터베이스에서 동영상 재생목록을 읽는다.
    val videos: LiveData<List<DevByteVideo>> = Transformations.map(database.videoDao.getVideos()){
        it.asDomainModel()
    }
    suspend fun refreshVideos(){
        // 코루틴 컨텍스트를 Dispatchers.IO로 전환하여 네트워크 및 데이터베이스 작업을 실행
        withContext(Dispatchers.IO){
            // 네트워크에서 재생목록을 가져온 후 재생목록을 RoomDB에 저장
            val playlist = DevByteNetwork.devbytes.getPlaylist()
            // insertAll() DAO 메소드를 호출하여 네트워크에서 검색된 재생목록을 전달
            // asDatabaseModel() 확장 함수를 사용하여 재생목록을 데이터베이스 객체에 매핑
            database.videoDao.insertAll(playlist.asDatabaseModel())
        }
    }
}

- Retrofit 서비스 인스턴스인 DevByteNetwork를 사용해 playlist를 네트워크에서 가져온다.

- 재생목록을 데이터베이스에 저장한다.

- videos라고 LiveData 객체를 만들어 데이터베이스에서 재생목록을 읽는다.

 

 

작업 2. 새로고침 전략을 사용하여 저장소 통합

- 저장소를 ViewModel과 통합

- 네트워크에서 직접 가져오지 않고 Room 데이터베이스의 동영상 재생목록을 표시

 

DevByteViewModel.kt

private val videosRepository = VideosRepository(getDatabase(application))

// Network에서 부터 데이터를 가져오던 것에서 Repository에서 가져오게 변경
private fun refreshDataFromRepository() = viewModelScope.launch {
    viewModelScope.launch {
        try{
            videosRepository.refreshVideos()
            _eventNetworkError.value = false
            _isNetworkErrorShown.value = false
        }catch (networkError:IOException){
            if(playlist.value.isNullOrEmpty())
                _eventNetworkError.value=true
        }
    }
}

 

- 네트워크에서 데이터를 받아오던 코드를 위와 같이 Repository에서 받아오게끔 수정한다.

- 한 번 실행 후 비행기 모드로 설정해서 앱을 다시 실행하면 오프라인 캐시에서 가져와 에러 없이 재생목록을 가져올 수 있다.

이 글은 안드로이드 공식 문서와 냉동 코딩님의 안드로이드 강의를 보고 작성하였다.

https://developer.android.com/topic/libraries/architecture/viewmodel?hl=ko 

 

ViewModel 개요  |  Android 개발자  |  Android Developers

ViewModel을 사용하면 수명 주기를 인식하는 방식으로 UI 데이터를 관리할 수 있습니다.

developer.android.com

 


ViewModel

" ViewModel 클래스는 비즈니스 로직 또는 화면 수준 상태 홀더입니다. UI에 상태를 노출하고 관련 비즈니스 로직을 캡슐화합니다. 주요 이점은 상태를 캐시하여 구성 변경에도 이를 유지한다는 것입니다. 즉, 활동 간에 이동하거나 구성 변경(예: 화면 회전 시)을 따를 때 UI가 데이터를 다시 가져올 필요가 없습니다. "    - 안드로이드 공식 문서 왈-

공식 문서는 참 말을 어렵게 하는 것 같다...  코드로 예시를 보면서 살펴보자.

 

class MainActivity : AppCompatActivity() {
    private val binding: ActivityMainBinding by lazy {
        ActivityMainBinding.inflate(layoutInflater)
    }

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

        var counter = 100
        binding.textView.text = counter.toString()

        binding.button.setOnClickListener {
            counter += 1
            binding.textView.text = counter.toString()
        }

    }
}

 

위 코드는 초기값(counter)을 100으로 설정하고 버튼을 누를 때 마다 1씩 증가하도록 만든 코드이다. 5번 눌러 105를 만들었고 스마트폰을 회전 시켜 가로 모드로 만들었더니 100으로 초기화 된다.

 

이유가 뭘까? 아래의 그림을 살펴보자

스마트폰을 회전시킬 때 앱의 생명주기이다. 액티비티가 처음 실행되었을 때 onCreate()에 진입하면서 100으로 초기화 되었고 버튼을 누르면서 1을 증가시켜 105로 만들었다. 

 

화면을 회전 시키게 되면 다시 onCreate()에 진입하면서 counter 값이 다시 초기화된 것이다. 

 

그러면 우리는 증가된 counter 값이 다시 초기화되지 않게끔 생명주기에 관계없이 값을 가지고 있어야 한다. 우리는 onSavedInstanceState()로 bundle 형태로 값을 저장하였다. 하지만 bundle은 50k로 적은 용량을 저장해야 하고 우리는 생명주기에 관계 없이 데이터를 보존하기 위해 ViewModel을 사용하게 된다.

 

즉, ViewModel에서 생명 주기와 상관없이 데이터를 저장하고 가져올 수 있게 된다는 것이다.

 


ViewModel 

 

ViewModel을 적용하여 코드를 작성해보자.

 

MainActivity.kt

class MainActivity : AppCompatActivity() {
    private val binding: ActivityMainBinding by lazy {
        ActivityMainBinding.inflate(layoutInflater)
    }

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

        // 뷰모델 적용
        val myViewModel = ViewModelProvider(this).get(MyViewModel::class.java) // 싱글턴 패턴
        myViewModel.counter = 100
        binding.textView.text = myViewModel.counter.toString()

        binding.button.setOnClickListener {
            myViewModel.counter += 1
            binding.textView.text = myViewModel.counter.toString()
        }

    }
}

 

MyViewModel.kt

class MyViewModel : ViewModel() {
    var counter : Int = 0
}

- MyViewModel 클래스를 생성하고 counter 값을 가져오도록 코드를 수정했다.

- 하지만 역시 onCreate()에서 myViewModel.counter = 100 에서 보듯 초기화는 onCreate()에서 발생하므로 여전히 회전 시 숫자가 100으로 돌아오는 문제점이 남아있다.

- ViewModelProvider에서 값을 초기화하는 것은 불가능하므로 팩토리 패턴을 사용하여 문제를 해결해보겠다.

 


 

팩토리 패턴 적용

 

MyViewModelFactoy.kt

class MyViewModelFactory(private val counter:Int):ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if(modelClass.isAssignableFrom(MyViewModel::class.java)){
            return MyViewModel(counter) as T
        }
        throw IllegalArgumentException("ViewModel class not found")
    }

}

- MyViewModelFactory를 클래스를 추가한다.

- 팩토리 패턴을 사용하는 이유는 ViewModelProvider 에서 counter 값을 초기화하지 못하기 때문이다.

- 팩토리 패턴에 대해서는 글 작성 후 링크 남기겠다.

 

MyViewModel.kt

class MyViewModel(_counter : Int) : ViewModel() {
    var counter = _counter
}

- 객체 생성 시 counter 값을 매개변수로 받을 수 있게 수정하였다.

 

MainActivity.kt

class MainActivity : AppCompatActivity() {
    private val binding: ActivityMainBinding by lazy {
        ActivityMainBinding.inflate(layoutInflater)
    }

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

        val factory = MyViewModelFactory(100, this)
        val myViewModel : MyViewModel by viewModels { factory }
        binding.textView.text = myViewModel.counter.toString()

        binding.button.setOnClickListener {
            myViewModel.counter += 1
            myViewModel.saveState()
            binding.textView.text = myViewModel.counter.toString()
        }
    }
}

- 팩토리를 추가하면서 생명주기에 상관없이 counter 값을 유지할 수 있게 되었다!!!!

- 하지만 앱이 강제 종료가 된 경우 ViewModel에서 counter의 값이  유지되지 못하는 문제가 생긴다.

- SavedStateHandle 을 사용하여 문제를 해결해보자!

 


SavedStateHandle

 

MyViewModelFactoy.kt

class MyViewModelFactory(
    private val counter:Int,
    owner:SavedStateRegistryOwner,
    defaultArgs: Bundle? = null,
    ):AbstractSavedStateViewModelFactory(owner, defaultArgs){
    override fun <T : ViewModel> create(
        key: String,
        modelClass: Class<T>,
        handle: SavedStateHandle
    ): T {
        if(modelClass.isAssignableFrom(MyViewModel::class.java)){
            return MyViewModel(counter, handle) as T
        }
        throw IllegalArgumentException("Viewmodel class not found")
    }
    
}

 

MyViewModel.kt

class MyViewModel(
    _counter : Int,
    private val savedStateHandle:SavedStateHandle
) : ViewModel() {
    var counter = savedStateHandle.get<Int>(SAVED_STATE_KEY)?: _counter

    fun saveState(){
        savedStateHandle.set(SAVED_STATE_KEY, counter)
    }
    companion object{
        private const val SAVED_STATE_KEY = "counter"
    }
}

 

MainActivity.kt

class MainActivity : AppCompatActivity() {
    private val binding: ActivityMainBinding by lazy {
        ActivityMainBinding.inflate(layoutInflater)
    }

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

         //리셋되는 카운터
//        var counter = 100
//        binding.textView.text = counter.toString()
//
//        binding.button.setOnClickListener {
//            counter += 1
//            binding.textView.text = counter.toString()
//        }

//        val myViewModel = ViewModelProvider(this).get(MyViewModel::class.java)
//        myViewModel.counter = 100
//        binding.textView.text = myViewModel.counter.toString()
//
//        binding.button.setOnClickListener{
//            myViewModel.counter += 1
//            binding.textView.text = myViewModel.counter.toString()
//        }

        val factory = MyViewModelFactory(100,this)
//        val myViewModel = ViewModelProvider(this,factory).get(MyViewModel::class.java)
        // 위임
        val myViewModel by viewModels<MyViewModel>(){ factory}
        binding.textView.text = myViewModel.counter.toString()

        binding.button.setOnClickListener{
            myViewModel.counter += 1
            binding.textView.text = myViewModel.counter.toString()
            myViewModel.saveState()
        }
    }
}

- 앱 강제 종료 시에도 값이 유지됨을 확인할 수 있다.

 


정리

- 생명주기에 따라 변수의 값이 바뀔 수 있다. 우리는 생명주기에 상관없이 일정한 변수값을 가지기 위해 ViewModel을 사용한다.

- ViewModelProvider에서 변수값을 초기화할 수 없기 때문에 팩토리 패턴을 사용한다.

- 앱 강제 종료 시에도 값을 유지하고 싶으면 SaveStateBundle을 활용한다.

싱글턴 패턴에 대한 설명은 아래 링크를 참고하길 바란다.

https://codingjobrice.tistory.com/155

 

[디자인 패턴 기초] 싱글턴 패턴 (Singleton Pattern)

싱글턴 패턴이란? "클래스 인스턴스를 하나만 만들고, 그 인스턴스로의 전역 접근을 제공한다." 말 그대로 하나의 클래스로 하나의 객체만 만들겠다는 이야기다. 이해를 돕기위해 코드를 살펴보

codingjobrice.tistory.com

 

안드로이드에서 싱글턴 패턴 사용하기

안드로이드에서 단일 객체를 생성하기 위해서는 간단하게 object 키워드를 사용하면 된다.

object OneSingletonKt{
    var uniqueInstance:Int = 10
}

fun main(){
    println(OneSingletonKt.uniqueInstance)
}

 

uniqueInstance는 단일 객체가 된 것이다. 저렇게만 사용하면 간단하지만 이를 클래스 안에 담아보자.

class SingletonKt{
    object OneSingletonKt{
        var uniqueInstance:Int = 10
    }
}

fun main(){
    println(SingletonKt.OneSingletonKt.uniqueInstance)
}

이 것도 막 불편하지는 않지만 SingletonKt.OneSingletonKt.uniqueInstance 이 부분에서 보기 불편하다... 코틀린은 불편한 것 못참는 언어이다. 그래서 "companion" 이라는 키워드를 사용하면서 이 문제를 해결하였다. 코드를 살펴보자.

class SingletonKt{
    object OneSingletonKt{
        var uniqueInstance:Int = 10
    }
    
    companion object{
        var uniqueInstance2:Int = 20
    }
}

fun main(){
    println(SingletonKt.uniqueInstance2)
}

불편함을 해결한 것을 볼 수 있다.

 

그러면 안드로이드에서는 언제 사용될까? 

전역 변수, Retrofit 객체 생성 시, 데이터베이스 접근 시 등 다양한 경우에 사용되며 이는 하나하나 계속 추가해가겠다..

일단 firebase 객체 선언 시 사용한 코드를 예로 들겠다.

class MyApplication : MultiDexApplication(){
    companion object {
        lateinit var auth: FirebaseAuth
        var email: String? = null
      
        var user:User? = null
        lateinit var db: FirebaseFirestore
       
        fun checkAuth(): Boolean {
            var currentUser = auth.currentUser
            return currentUser?.let {
                email = currentUser.email
                currentUser.isEmailVerified
            } ?: let {
                false
            }
        }
    }
    override fun onCreate() {
        super.onCreate()
        auth = Firebase.auth
        db = FirebaseFirestore.getInstance()
    }
}

 

일단 옵저버 패턴을 알아야 한다. 잘 정리해놓은 사이트가 있으니 참고하길 바란다.

https://codingjobrice.tistory.com/153

 

[디자인 패턴 기초] 옵저버 패턴(observer pattern)

옵저버 패턴 (observer pattern) 정의를 살펴보기 전에 클래스 다이어그램부터 살펴보자! 당연히 이해가 되지 않을 것이다. 옵저버 패턴이란? 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체

codingjobrice.tistory.com

 

setOnClickerListener는 옵저버 패턴

우리가 안드로이드 프로그래밍을 하면서 버튼 클릭 수행 시 

binding.button.setOnClickListener{
            // 버튼 클릭 시 수행할 로직 구현
}

위와 같은 코드를 사용한다.

이 곳에는 옵저버 패턴이 사용된다. 어떻게 사용되는지 뜯어보자.

 

저 위의 코드는 너무 축약되었다. 이해하기 좋게 원시적으로 돌아가보자.

 

val button = binding.button

button.setOnClickListener(View.OnClickListener { 
            // 로직 구현
})

 

우선 Subject를 찾아보자. 

Subject는 이벤트를 발생시키는 UI 요소 즉, 버튼 UI이다.  여기선 button 이겠지.

btn은 클릭 이벤트를 감지하고, 등록된 Observer에게 클릭 이벤트가 발생했다고 알려준다.

 

그럼 여기서 Observer는 무엇인가?

OnClickListener가 Observer 역할을 한다. button에서 클릭 이벤트를 감지했음을 OnClickListener에게 알려주고 OnClickListener는 구현한 로직을 수행하게 된다.

 

그냥 생각없이 써왔는데 옵저버 패턴이였던 것이었다... 소름

+ Recent posts