개발/안드로이드

DataStore를 사용한 로컬에 환경설정 저장

스몰스테핑 2024. 5. 30. 14:59

관계형 데이터를 저장할 필요가 없는 경우 SQL, Room이 아닌 DataStore를 사용하는 것이 간단한 솔루션이 될 수 있다. DataStore Jetpack 구성요소는 오버헤드가 낮은 작고 간단한 데이터 세트를 저장하는 좋은 방법이다. DataStore에는 서로 다른 두 가지 구현(Preferences DataStore, Proto DataStore)이 있다.

  • Perferences DataStore는 키-값 쌍으로 저장한다. 값은 String, Boolean, Integer와 같은 Kotlin의 기본 데이터 유형일 수 있다. 복잡합 데이터 세트는 저장하지 않으며, 사전 정의된 스키마도 필요 없다. 기본적으로 사용자 환경설정을 기기에 저장하는 식으로 쓰인다.
  • Proto DataStore는 맞춤 데이터 유형을 저장한다. proto 정의를 객체 구조로 매핑하는 사전 정의된 스키마가 필요하다.

 

 

Proto DataStore 사용법

https://developer.android.com/topic/libraries/architecture/datastore?hl=ko#proto-datastore

 

앱 아키텍처: 데이터 영역 - Datastore - Android 개발자  |  Android Developers

데이터 영역 라이브러리에 관한 이 앱 아키텍처 가이드를 통해 Preferences DataStore 및 Proto DataStore, 설정 등을 알아보세요.

developer.android.com

 

 

Perferences DataStore 사용법

목록 보기 그리드로 보기

 

안드로이드 출시 목록을 보여주는 애플리케이션이 존재할 때, 우측 상단의 버튼을 누르면 목록 보기 레이아웃과 그리드로 보기 레이아웃을 전환할 수 있다고 가정하자.

 

화면 구성 및 레이아웃 전환 자체는 이전에 배운 것들을 활용하여(뷰모델과 상태 함수 관리) 구현 가능하다. 하지만 앱을 종료하고 껐다 켰을때도 변경된 상태를 유지하고 싶을 경우, DataStore를 사용한다.

 

위의 예시를 토대로 사용법을 익혀보자.

 

 

1. app/build.gradle.kts에 종속 항목 설정 추가.

implementation("androidx.datastore:datastore-preferences:1.0.0")

 

 

2. 사용자 환경설정 저장소 구현

import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException

class UserPreferencesRepository(
    private val dataStore: DataStore<Preferences>
) {
    private companion object {
        val IS_LINEAR_LAYOUT = booleanPreferencesKey("is_linear_layout")
        const val TAG = "UserPreferencesRepo"
    }

    val isLinearLayout: Flow<Boolean> = dataStore.data
        .catch {
            if (it is IOException) {
                Log.e(TAG, "Error reading preferences.", it)
                emit(emptyPreferences())
            } else {
                throw it
            }
        }
        .map { preferences ->
            preferences[IS_LINEAR_LAYOUT] ?: true
        }

    suspend fun saveLayoutPreference(isLinearLayout: Boolean) {
        dataStore.edit { preferences ->
            preferences[IS_LINEAR_LAYOUT] = isLinearLayout
        }
    }
}

 

DataStore는 키-값 쌍을 저장하고, 값에 액세스하기 위해 키를 정의해야 한다.

 

저장소 클래스 내에 companion object를 구현한다. booleanPreferencesKey() 함수를 사용해 키를 정의하고 is_linear_layout 이라는 이름을 전달한다. SQL 테이블 이름과 마찬가지로 키도 밑줄 형식을 사용하며, 이 키는 선형 레이아웃의 표시 여부를 나타내는 불리언 값에 액세스하는 데 사용한다.

 

dataStore.edit() 메서드에 람다를 전달하여 DataStore 내에서 값을 만들고 수정한다. 람다에는 DataStore의 값을 업데이트하는 데 사용할 수 있는 MutablePreferences 인스턴스가 전달된다. 이 람다 내의 모든 업데이트는 단일 트랜잭션으로 실행된다. 즉, 업데이트가 원자적으로 이뤄져 한 번에 모두 실행된다. 이 유형의 업데이트는 일부 값은 업데이트되고 다른 값은 업데이트 되지 않는 상황을 방지한다.

 

참고: 값은 이 함수가 호출되고 값이 설정될 때까지 DataStore에 존재하지 않는다. edit() 메서드에서 키-값 쌍을 설정하면 앱의 캐시나 데이터가 삭제될 때까지 값이 정의되고 초기화된다.

 

 

companion object로 키를 정의하고, saveLayoutPreference로 isLinearLayout을 dataStore에 쓰기를 실행했으니, 읽기를 진행해야 한다.

 

읽기를 하기 직전에, 기기에서 파일 시스템과 상호작용 시 문제가 발생할 수 있다. 파일이 존재하지 않거나, 디스크가 꽉 찼거나 마운트가 해제되었거나. DataStore는 파일에서 데이터를 읽고 쓰기 때문에 액세스할 때 IOExceptions이 발생할 수 있다. catch 연산자를 사용해 예외를 포착하고 이러한 실패를 처리한다.

 

data 속성은 Prefrences 객체의 Flow이다. Preferences 객체는 DataStore의 모든 키-값 쌍이 포함되어 있다. DataStore의 데이터가 업데이트될 때마다 새 Preferences 객체가 Flow로 내보내진다. 환경설정이 정의되고 초기화될 때까지 환경설정은 위에서 쓴대로 DataStore에 존재하지 않는다. 따라서 환경설정이 존재하는지 프로그래매틱 방식으로 확인하고 존재하지 않는 경우 기본값을 제공해야한다.

 

읽기 쓰기를 전부 끝냈으니, 앱에 DataStore를 제공하고 초기화해야 한다.

해당 과정은 앱이 MainActivity를 실행하기 전에 이뤄져야하므로 Application 클래스를 만든 뒤, Manifest를 수정해주어야 한다.

import android.app.Application
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.example.dessertrelease.data.UserPreferencesRepository

private const val LAYOUT_PREFERENCE_NAME = "layout_preferences"
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
    name = LAYOUT_PREFERENCE_NAME
)

class DessertReleaseApplication: Application() {
    lateinit var userPreferencesRepository: UserPreferencesRepository

    override fun onCreate() {
        super.onCreate()
        userPreferencesRepository = UserPreferencesRepository(dataStore)
    }
}

 

이 클래스는 DataStore의 컨테이너로, Application()을 상속받으며, Preferences Datastore를 인스턴스화 시키고, 저장소를 인스턴스화 시킨 Datastore로 초기화 시킨다.

 

<application
    android:name=".DessertReleaseApplication"
    ...
</application>

 

Manifest의 application 태그 내에 name을 추가한다.

위에서 작성한 Application 클래스를 앱의 진입점으로 초기화 시키며, 정의된 종속 항목을 초기화 시킨다.

이후, ViewModel에 초기화된 저장소를 제공하고 저장소에서 구현한 saveLayoutPreference를 사용하여 DataStore에 레이아웃 변경 상태를 저장할 수 있다.

 

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.dessertrelease.DessertReleaseApplication
import com.example.dessertrelease.R
import com.example.dessertrelease.data.UserPreferencesRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

class DessertReleaseViewModel(
    private val userPreferencesRepository: UserPreferencesRepository
) : ViewModel() {
    val uiState: StateFlow<DessertReleaseUiState> =
        userPreferencesRepository.isLinearLayout.map { isLinearLayout ->
            DessertReleaseUiState(isLinearLayout)
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = DessertReleaseUiState()
        )

    fun selectLayout(isLinearLayout: Boolean) {
        viewModelScope.launch {
            userPreferencesRepository.saveLayoutPreference(isLinearLayout)
        }
    }

    companion object {
        val Factory: ViewModelProvider.Factory = viewModelFactory {
            initializer {
                val application = (this[APPLICATION_KEY] as DessertReleaseApplication)
                DessertReleaseViewModel(application.userPreferencesRepository)
            }
        }
    }
}

data class DessertReleaseUiState(
    val isLinearLayout: Boolean = true,
    val toggleContentDescription: Int =
        if (isLinearLayout) R.string.grid_layout_toggle else R.string.linear_layout_toggle,
    val toggleIcon: Int =
        if (isLinearLayout) R.drawable.ic_grid_layout else R.drawable.ic_linear_layout
)

 

viewModel의 생성자 매개변수에 저장소를 추가하고, 컴패니언 객체 내에서 초기화 블록을 사용하여 위에서 생성한 Application 클래스의 인스턴스를 가져와, 초기화한 저장소를 viewModel에 전달한다.

 

이렇게 viewModel이 저장소에 접근 가능하게 되고, viewModel에 구현한 selecctLayout 함수에 저장소에 접근시키고 레이아웃 환경설정을 업데이트 시키는 코드를 작성한다.

 

fun selectLayout(isLinearLayout: Boolean) {
    viewModelScope.launch {
        userPreferencesRepository.saveLayoutPreference(isLinearLayout)
    }
}

 

환경설정 상태를 읽고, ui를 변경시키기 위해 viewModel의 uiState 인스턴스에 저장소에서 읽어온 isLinearLayout Boolean 값을 전달한다.

 

참고: stateIn()의 started에 SharingStarted.WhileSubscribed(5_000)이 전달되는 이유는 애플리케이션의 리소스 최적화를 위함이라고 보면 된다. SharingStarted.WhileSubscribed(5_000)은 수집기가 없으면 업스트림 흐름을 취소하는 것으로. 예를 들어 데이터베이스 연결, 하드웨어 센서 등과 같은 다른 소스에서 데이터를 계속 읽는 경우 이러한 흐름 활성화 상태를 유지하는 것은 리소스 낭비로 이어질 수 있다. 앱이 백그라운드로 전환되면 이러한 코루틴을 중지시켜 최적화를 시킴이 마땅하다.

기본적으로 WhileSubscribed는 즉시 중단한다. 그렇다면 불만이 생길 수 있다. 예를 들어 사용자가 장치를 회전하면 뷰가 빠르게 연속적으로 파괴되고 다시 생성된다. 이러한 경우, 즉시 중단하고 재생성하면 오히려 리소스가 더욱 낭비될 수 있기 때문에 TIME_OUT을 추가하여 뷰가 몇 초 동안 수신을 중지했을 경우 업스트림 흐름을 취소하는 것으로 제어하는 것이다. 


class MyViewModel(...) : ViewModel() {
  val result = userId.mapLatest { newUserId ->
    repository.observeItem(newUserId)
  }.stateIn(
    scope = viewModelScope,
    started = WhileSubscribed(5000),
    initialValue = Result.Loading
  )
}

위 코드의 흐름은 다음과 같다.
1. 앱을 백그라운드로 보내면 다른 레이어에서 들어오는 업데이트가 5초 후에 중지되어 배터리가 절약된다.
2. 최신 값은 계속 캐시되므로 사용자가 해당 값으로 돌아올 때 뷰에 즉시 일부 데이터가 포함된다.
3. 구독이 다시 시작되고 새 값이 입력되어 사용 가능한 경우 화면을 새로고침한다.

더 자세한 정보는 다음 링크를 참고하자.
LiveData에서 Kotlin의 Flow로 이전

 

https://developer.android.com/codelabs/basic-android-kotlin-compose-datastore?hl=ko&continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fandroid-basics-compose-unit-6-pathway-3%3Fhl%3Dko%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fbasic-android-kotlin-compose-datastore#0

 

DataStore를 사용하여 로컬에 환경설정 저장  |  Android Developers

DataStore를 사용하여 환경설정을 로컬에 저장하는 방법을 알아보세요.

developer.android.com

 

https://github.com/google-developer-training/basic-android-kotlin-compose-training-dessert-release/tree/main

 

GitHub - google-developer-training/basic-android-kotlin-compose-training-dessert-release

Contribute to google-developer-training/basic-android-kotlin-compose-training-dessert-release development by creating an account on GitHub.

github.com