안드로이드에서 설정같은 간단한 값들의 저장은 DB가 아니라 가볍게 파일로 읽고 쓰는 SharedPreferences를 제공했었다. SharedPreferences는 key-value 쌍으로 값을 읽고 쓴다. 큰 문제없이 써오던 것이지만, 오래되다보니 몇가지 문제가 존재한다. 우선 제대로된 비동기 읽기/쓰기를 제공하지 않는다. 또한, 런타임 exception에 대한 처리도 제공하지 않고, UI Thread를 블럭하여 ANR을 발생 시킬 수도 있다.
Kotlin이 도입되고, coroutine, Flow라는 신기술이 일상적으로 쓰이면서 기존에 존재하는 문제점들을 개선한게 나오는건 당연한 흐름이었겠지. 그렇게 해서 나온게 DataStore 이다. DataStore는 두가지 방식이 존재하는데, 첫째로 기존 SharedPreferences와 유사하게 key-value쌍으로 값을 읽고 쓰는 Preferences DataStore가 있고, 둘째로 Proto DataStore라는 것이 있다.
Proto DataStore는 구글에서 개발한 Protocol Buffers라는걸 이용한다. Protocol Buffers는 구글에서 개발안 언어중립, 플랫폼 중립적인 방식으로, 스키마를 정의하고 저장할 값과 이 스키마를 연결해주는 Serializer를 정의해서 사용하는 방식이다. SharedPreferences나 Preferences DataStore와 달리, 타입 안전성까지 보장해주는 가장 뛰어난 기능을 갖고 있지만, 여기서는 다루지 않는다. 이유는, 사용해보니 Protocol Buffers에 대한 외부 라이브러리도 필요하고, 프로젝트 main외의 위치에 스키마 정의 파일도 필요하고, 빌드과정에서 이 스키마 정의 파일을 클래스로 만들어주는 과정이 추가된다. 설정값 또는 간단한 값들을 저장하는데 과잉이라고 판단했다. 라이브러리 의존성 하나하나 늘어갈 때마다 너무 피곤해서 굳이 이렇게 까지 써야할까 싶은 생각이 들고, 복잡한 데이터는 Room등을 이용해 DB로 저장하는게 맞을테니까. 한마디로, 안드로이드에서 진짜 쓰라고 제공하는 느낌이 전혀 아니었다.
Preferences DataStore
우선 다음의 의존성을 gradle파일에 추가한다.
dependencies {
implementation("androidx.datastore:datastore-preferences:1.0.0")
DataStore를 생성하는 코드는 Activity의 create()같은 곳이 아니라 다른 클래스에 속하지 않는 탑레벨에 정의해준다. 생성은 property delegate를 이용하여 preferencesDataStore()에게 위임한다.
// At the top level of your kotlin file:
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
만약 특정 Activity 와 연관이 있다면 해당 Activity kt파일에서 import 다음에 글로벌 변수처럼 추가해주면된다. 이렇게 정의해주면, 인스턴스 관리는 preferencesDataStore()가 해주게 되므로 프로젝트에서 singleton처럼 사용하게 된다. 위의 코드를 보면, Context에 extension property로 datastore를 추가한 걸 확인할 수 있다. 즉, Context만 있다면 어디서든 사용가능하다.
DataStore에 쓰는 방법은 SharedPreferences와 유사하게 edit()를 제공한다. 하지만, apply()가 필요없다.
suspend fun incrementCounter() {
context.dataStore.edit { settings ->
val currentCounterValue = settings[EXAMPLE_COUNTER] ?: 0
settings[EXAMPLE_COUNTER] = currentCounterValue + 1
}
}
코드를 보면 알겠지만, suspend fun으로 제공되고 있다. coroutine을 이용해 호출해야하며, UI 쓰레드를 블럭하여 ANR 발생하는 경우를 피할 수 있다. edit()내에서 값을 참조하고 쓰는건 키값을 이용해 map형태로 간단하게 처리하고 있다. SharedPreferences에서는 조금 지저분한 느낌인데, 여기서는 직관적이고 깔끔한걸 볼 수 있다.
값을 읽어오는 방법은 Flow를 사용한다. DataStore는 data라는 Flow를 제공한다.
val data: Flow<T>
Preferences DataStore는 type safety를 제공하지 않으므로 값을 가져올 때, 값의 타입을 지정해야 하는데, 이는 키값을 생성할 때 정해준다.
val EXAMPLE_COUNTER = intPreferencesKey("example_counter")
val exampleCounterFlow: Flow<Int> = context.dataStore.data
.map { preferences ->
// No type safety.
preferences[EXAMPLE_COUNTER] ?: 0
}
위 코드를 보면, intPreferencesKey()를 사용하여 키값을 생성하는데, integer값을 생성하게 된다. boolean등 다른 타입의 경우 해당하는 xxxPreferencesKey()함수를 사용한다. 또한, Flow의 catch()를 이용하여 exception을 잡아낼 수도 있다.
coroutine을 이용한 Asynchronous I/O가 기본이지만, Synchronous하게도 사용가능하다. 바로 coroutine의 runBlocking()을 이용하면 된다.
val exampleData = runBlocking { context.dataStore.data.first() }
Migrate from SharedPreferences
DataStore는 기존 SharedPreferences에서의 migration도 제공한다. DataStore를 정의할 때, 기존 SharedPreferences 정보를 넘겨주면 알아서 처리해준다.
private val Context.dataStore by preferencesDataStore(
name = USER_PREFERENCES_NAME,
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, USER_PREFERENCES_NAME))
}
)
produceMigrations 인자에 값을 넘겨주는걸 보여주고 있다.
Exception Handling
Flow를 사용하기 때문에 예외처리도 쉽게 가능하다.
val userPreferencesFlow: Flow<UserPreferences> = dataStore.data.catch { exception ->
// dataStore.data throws an IOException when an error is encountered when reading data
if (exception is IOException) {
Log.e(TAG, "Error reading preferences.", exception)
emit(emptyPreferences())
} else {
throw exception
}
}.map { preferences ->
mapUserPreferences(preferences)
}
쓰기중 발생하는 예외는 try-catch 블록을 이용한다.
suspend fun updateShowCompleted(showCompleted: Boolean) {
try {
dataStore.edit { preferences ->
preferences[PreferencesKeys.SHOW_COMPLETED] = showCompleted
}
} catch (e: IOException) {
// Handle error
}
}
이정도면 되겠지?