- 데이터 레이어는 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에서 받아오게끔 수정한다.
- 한 번 실행 후 비행기 모드로 설정해서 앱을 다시 실행하면 오프라인 캐시에서 가져와 에러 없이 재생목록을 가져올 수 있다.
" 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"
}
}
안드로이드에서 단일 객체를 생성하기 위해서는 간단하게 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()
}
}