앱과 관련된 데이터를 램이 아닌 낸드와 같은 영구저장장치(보조저장장치)에 저장한 후 이를 재사용하고 싶을 때가 있다.
이를테면 앱의 테마 색깔을 계속 유지하고 싶다거나, 아니면 세이브 파일 부터 이어서 게임을 하고 싶다거나 등의 경우가 생길 수 있다.
이렇게 앱이 종료된 이후에도 계속하여 데이터를 보조저장장치에 저장한 후에 앱이 재사용될 때에 불러오는 것은 세가지 방법을 떠올릴 수 있을 것이다.
전통적인 방법은 파일을 직접 제어하여 문자열을 저장한 후에, 이를 다시 불러오는 것이다.
단, 파일로 저장할 때에 파일의 저장 폴더를 내부저장소로 지정해 주어야 외부에서 접근하지 못할 것이다.
이렇게 파일을 저장할 때에는 JSON을 이용하여 정보를 체계화하여 저장하는 것도 가능할 것이다.
참고로 이렇게 별도의 파일로 저장하는 방법으로 오랫동안 사용된 것은 ini파일이다. 베데스다의 폴아웃 프랜차이즈나 엘더스크롤 프랜차이즈는 모두 개인 설정 파일을 ini로 저장하였다.
심지어 베데스다 소프트웍스는 가장 최신의 게임인 스타필드마저 게임 설정에 ini파일을 이용하였다.
데이터베이스를 이용하여 데이터를 영구적으로 저장할 수도 있을 것이다. 안드로이드에서는 룸데이터베이스를 사용하면 된다.
그런데 데이터베이스는 레코드가 수없이 많이 쌓이므로 그 중에 어느 레코드를 설정파일로 사용할 것인지 지정해주어야 한다.
이를테면 isUser라는 필드를 하나 만들어서 'SELECT * From (USERSETTING) WHERE isUser = true'라는 식으로 테이블 중에서 단 하나의 레코드만 선택할 수 있도록 트릭을 설정해야 한다.
따라서 간단한 설정 저장에 데이터베이스를 쓰는 것은 닭잡는 데에 소잡는 칼을 쓰는 것과 같다.
[키-키값] 쌍으로 구성되어 있어서 설정파일만 간단하게 불러오는 것을 안드로이드에서는 Datastore라고 한다.
구글은 다음과 같이 설명하고 있다.
Jetpack Datastore는 프로토콜 버퍼를 사용하여 키-값 쌍 또는 유형이 지정된 객체를 저장할 수 있는 데이터 저장소 솔루션입니다. Datastore는 Kotlin 코루틴 및 Flow를 사용하여 비동기적이고 일관된 트랜잭션 방식으로 데이터를 저장합니다.
어찌되었건 대충 이해하자면 Android에서 설정 등을 기록하고 불러오는 기능을 말한다. 과거에 shared preference라는 이름으로 제공하던 api가 현재는 Datastore라는 이름으로 변경되다고 보면 된다.
datastore api와, lifecycle api가 필요하다. 다음과 같다.
모듈단계의 그래들 파일에 다음의 의존성을 추가한다.
// DataStore // Alternatively - use the following artifact without an Android dependency. implementation("androidx.datastore:datastore-preferences:1.2.0-alpha01") val lifecycle_version = "2.5.1" implementation("androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version")
다음과 같이 헬퍼 클래스를 만들어서 읽기와 쓰기를 한다.
package com.dklaw.memorize.database import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.core.IOException import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map class DataStoreMemorize(private val context : Context) { private val Context.dataStore : DataStore<Preferences> by preferencesDataStore(name = "appPreferences") private val FILTER_ON = booleanPreferencesKey("FilterOn") // string 저장 키값 // Datastore 읽기 // Flow : coroutines.flow import 해야됨 val valueOfFilter : Flow<Boolean> = context.dataStore.data .map {preferences -> preferences[FILTER_ON] ?: false } // Datastore 쓰기 suspend fun setFilterSwitch(switch : Boolean){ context.dataStore.edit { preferences-> preferences[FILTER_ON] = switch } } }
“appPreferences”란 이름의 Datastore 인스턴스를 다음과 같이 생성한다. Datastore는 한 앱당 하나의 인스턴스만 싱글톤으로 사용되어야 하므로 호출에 있어서는 굳이 이름이 필요 없다고 언뜻 생각할 수도 있다. 다만, 유지보수 관점에서 기존의 Datastore와는 다른 새로운 Datastore 인스턴스를 만들어야 할 필요도 있을 것이므로 이름은 필요할 것이다.
private val Context.dataStore : DataStore<Preferences> by preferencesDataStore(name = "appPreferences")
Datastore는 [키, 밸류]의 쌍으로 데이터를 저장한다. 이를테면 C#의 Dictionary와 매우 비슷한 구조인 것이다.
즉, 각 키별로 형식이 존재한다. 이를테면 int값을 저장할 때에는 intPreferencesKey라고 하고, boolean값을 저장할 때에는 booleanPreferencesKey라고 한다. 자세한 것은 Datastore에 대한 Top-Level functions를 참조하라.
우리는 boolean값을 “FILTER_ON” 이란 이름으로 저장할 것이다. 따라서 다음과 같이 키를 정의하게 된다.
private val FILTER_ON = booleanPreferencesKey("FilterOn") // string 저장 키값
키값은 context.datastore.data.map으로 읽는다.
// Datastore 읽기 // Flow : coroutines.flow import 해야됨 val valueOfFilter : Flow<Boolean> = context.dataStore.data .map {preferences -> preferences[FILTER_ON] ?: false }
반환값이 Flow임을 유의하자
context.datastore.edit로 키 값을 쓴다.
// Datastore 쓰기 suspend fun setFilterSwitch(switch : Boolean){ context.dataStore.edit { preferences-> preferences[FILTER_ON] = switch } }
자동적으로 다음이 임포트 되지 않을 수가 있다. 만약 안드로이드 스튜디오가 컴플레인을 하면 다음을 임포트하자
import androidx.datastore.preferences.core.edit
위의 datastore 인스턴스를 싱글톤으로 단 하나만 존재하게 하기 위하여 MainApplication을 다음과 같이 만들어준다.
다음 예제는 database도 생성하는 코드가 같이 있는 것이니 이를 염두에 두고 살펴보자
import android.annotation.SuppressLint 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 androidx.room.Room.databaseBuilder import com.dklaw.memorize.database.DataStoreMemorize import com.dklaw.memorize.database.ScoreDatabase class MainApplication : Application() { init { instance = this } companion object { private var instance: MainApplication? = null fun applicationContext() : Context { return instance!!.applicationContext } lateinit var scoreDatabase: ScoreDatabase // Datastore 변수 @SuppressLint("StaticFieldLeak") private lateinit var dataStore: DataStoreMemorize fun getThis() : MainApplication { return instance!! } fun getDateStore() : DataStoreMemorize = dataStore } override fun onCreate() { super.onCreate() // initialize for any // Use ApplicationContext. val context: Context = MainApplication.applicationContext() // Datastore single Instance dataStore = DataStoreMemorize(this) // Database Initialize scoreDatabase = databaseBuilder( applicationContext, ScoreDatabase::class.java, ScoreDatabase.NAME ).build() } }
다음과 같이 싱글톤으로 불러온 다음 헬퍼클래스의 읽이 함수를 호출한다.
// 데이터 스토어 세팅 값 val isFiltered by MainApplication.getDateStore().valueOfFilter.collectAsState(initial = false)
Flow로 받아오므로 colletAsState로 값을 받아온 것을 알 수 있다.
다음과 같이 코루틴을 통해 저장한다.
// 스위치 버튼, 코루틴 @Composable fun switchButton(modifier: Modifier) { val coroutineScope = rememberCoroutineScope() val isFiltered by MainApplication.getDateStore().valueOfFilter.collectAsStateWithLifecycle(initialValue = false) Row(modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text(text = if (isFiltered) {"필터"} else {"모두"}, style = MaterialTheme.typography.titleMedium, modifier = Modifier .padding(vertical = 4.dp) .weight(0.5f), textAlign = TextAlign.Center) Switch( checked = isFiltered, onCheckedChange = { coroutineScope.launch { MainApplication.getDateStore().setFilterSwitch(!isFiltered) } }, modifier = Modifier .weight(0.5f) .semantics { this.contentDescription = "Filter by completion" }, thumbContent = if (isFiltered) { { Icon( imageVector = Icons.Filled.Check, contentDescription = "Checked", modifier = Modifier.size(SwitchDefaults.IconSize), ) } } else { { Icon( imageVector = Icons.Filled.Close, contentDescription = "UnChecked", modifier = Modifier.size(SwitchDefaults.IconSize), )} } ) } }