[Android] Android Preferences DataStore. ( + RxJava)
목차
- 소개
- Preferences DataStore 및 Proto DataStore
- Protocol Buffers
- DataStore 사용 규칙
- 설정
- add a dependency
- Set a DataStore
- 일반적인 경우
- Read values
- Write values
- Collect values
- RxJava를 사용하는 경우
- Read values
- Set values
- Observe values
- Shared Preferences를 Preferences DataStore로 Migration
- 결론
소개
DataStore는 프로토콜 버퍼를 사용하여 키-값 쌍 또는 유형이 지정된 객체를 저장할 수 있는 데이터 저장소 솔루션이다. DataStore는 Kotlin Coroutine, Flow를 사용하여 비동기적이고 일관된 트랜잭션 방식으로 데이터를 저장할 수 있다. DataStore는 소규모 단순 데이터 세트에 적합하며 부분 업데이트나 참조 무결성은 지원하지 않는다.
Preferences DataStore 및 Proto DataStore
DataStore는 두 가지 구현을 제공한다.
- Preferences DataStore : 키를 사용하여 데이터를 저장하고 데이터에 접근한다. 이 구현은 유형 안전성을 제공하지 않으며 사전 정의된 스키마가 필요하지 않다.
- Proto DataStore : 맞춤 데이터 유형의 인스턴스로 데이터를 저장한다. 이 구현은 유형 안전성을 제공하며 프로토콜 버퍼를 사용하여 스키마를 정의해야 한다. 반드시 proto 파일에 사전 정의된 스키마가 있어야 한다.
Protocol Buffers
프로토콜 버퍼는 구조화된 데이터를 직렬화하기 위한 Google의 언어 중립적이고 플랫폼 중립적이며 확장 가능한 메커니즘이다. XML은 더 작고, 더 빠르고, 더 단순하며, 데이터를 한 번 구성하는 방법을 정의한 다음 특수 생성된 소스 코드를 사용하여 다양한 데이터 스트림과 다양한 언어로 구성된 데이터를 쉽게 쓰고 읽을 수 있다. proto 형태로 저장된다.
한 마디로 구조화된 데이터를 직렬화하는 방식이다.
DataStore 사용 규칙
- 같은 프로세스에서 특정 파일 DataStore 인스턴스를 두 개 이상 생성하지 않는다. 동일한 프로세스에서 특정 파일의 DataStore가 여러 개 활성화되어 있다면 데이터를 읽거나 업데이트할 때 DataStore가 IllegalStateException을 발생시킨다.
- DataStore의 일반 유형은 변경이 불가능해야 한다. DataStore에 사용된 유형을 변경하면 DataStore가 제공하는 모든 보장이 무효화되고 잠재적으로 버그가 발생할 수 있다. 불변성을 보장하고 간단한 API와 효율적인 직렬화를 제공하는 프로토콜 버퍼를 사용하는 것이 좋다.
- 동일한 파일에서 SinglePrecoessDataStore와 MultiProcessDataStore를 함께 사용하지 않는다.
설정
add a dependency
// Preferences DataStore (SharedPreferences like APIs)
dependencies {
implementation "androidx.datastore:datastore-preferences:1.0.0"
// optional - RxJava2 support
implementation "androidx.datastore:datastore-preferences-rxjava2:1.0.0"
// optional - RxJava3 support
implementation "androidx.datastore:datastore-preferences-rxjava3:1.0.0"
}
Set a DataStore
Kotlin 파일 최상위에 dataStore를 정의한다. Preferences의 경우 import 할 때 androidx로 import 함에 주의한다.
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
RxJava를 사용한다면 최상위가 아닌 Class 파일 안(Fragment, Activity 등)에 선언해야 한다.
val dataStore = RxPreferenceDataStoreBuilder(requireContext(), "settings").build()
일반적인 경우
Read values
어떤 키 값을 미리 intPreferencesKey로 가져온 다음 해당 키에 대한 값을 Flow로 가져올 수 있다.
val EXAMPLE = intPreferencesKey("example")
val exampleFlow: Flow<Int> = requireContext().dataStore.data.map { preference ->
preference[EXAMPLE] ?: 0
}
catch 함수를 사용하면 예외 처리를 간단하게 할 수 있다. (이러한 점이 SharedPreferences의 단점을 보완하는 부분이다. 쉬운 에러 처리.)
val exampleFlow: Flow<Int> = requireContext().dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
.map { preference ->
preference[EXAMPLE] ?: 0
}
Write values
Flow 데이터를 다루기 때문에 변수에 새로운 값을 쓰기 위해서는 suspend 함수 안에서 가능하다.
suspend fun updateExample(data: Int) {
requireContext().dataStore.edit { settings ->
settings[EXAMPLE] = data
}
}
Collect values
example이라는 Flow<Int> 변수를 생성하였고, Flow이기 때문에 collect 함수를 사용하여 해당 변수를 Observe 하고 있다가 변경 사항이 생겼을 경우 접근하여 사용할 수 있다. 필요한 곳에 collect 함수를 선언하면 example이라는 변수의 값이 변경될 때 코드 블록 안에 함수가 호출된다.
example.collectLatest { num ->
// Do Something
}
RxJava를 사용하는 경우
Read values (only RxJava)
RxJava를 사용할 때는 Flowable이라는 abstract class를 사용한다. flowable 변수를 mapping 하고 subscribe 한다. (해당 코드는 한 번 결과를 리턴하고 나면 구독을 dispose 한다.
@OptIn(ExperimentalCoroutinesApi::class)
fun readExample() {
val flowable: Flowable<Int> = dataStore.data().map { settings ->
settings[EXAMPLE]
}
flowable.firstOrError().subscribeWith(object : DisposableSingleObserver<Int>() {
override fun onSuccess(t: Int) {
// Do Something
}
override fun onError(e: Thorwable) {
// Do Something
}
}).dispose()
}
Set values (only RxJava)
함수명 DataAsync에서 볼 수 있듯이 내부적으로 비동기처리 되고 있음을 알 수 있다. 함수 내부는 CoroutineScope.async를 사용하고 있다.
fun setExample(data: Int) {
dataStore.updateDataAsync { settings ->
val preferences = settings.toMutablePreferences()
preferences[EXAMPLE] = data
return@updateDataAsync Single.just(preferences)
}.subscribe()
}
Observe values
RxJava의 데이터를 Observe 하기 위해서 subscribe라는 함수를 사용한다. 해당 함수 안에 Subscriber를 선언하여 에러 처리, 완료 처리 등을 간단하게 제어할 수 있다.
fun observeExample() {
val flowable = dataStore.data().map { settings ->
settings[EXAMPLE]
}
flowable.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(object : FlowableSubscriber<Int> {
override fun onSubscribe(s: Subsciption) {
// Do Something
}
override fun onError(t: Throwable?) {
// Do Something
}
override fun onComplete() {
// Do Something
}
override fun onNext(t: Int?) {
// Do Something
}
})
}
SharedPreferences를 Preferences DataStore로 Migration
Preferences DataStore는 SharedPreferences의 단점을 보완하기 위해 나왔다. 감사하게도 Migration까지 지원해 준다.
DataStore로 Migration을 하기 위해서는 dataStore 빌더를 업데이트하여 리스트 형식으로 SharedPreferencesMigration을 전달해야 한다. Migration은 DataStore에서 데이터에 접근하기 전에 완료된다. 키는 SharedPreferences에서 한 번만 이전되므로 DataStore로 Migration 된 후에는 기존 SharedPreferences 사용을 중단해야 한다. 키가 DataStore로 Migration 되고 SharedPreferences에서 삭제된다.
private const val SETTINGS_PREFERENCES = "settings"
private val Context.dataStore by preferencesDataStore(
name = SETTINGS_PREFERENCES,
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, SETTINGS_PREFERENCES))
}
)
결론
개인적으로 두 개의 라이브러리를 사용하면서 느낀 점은, 프로젝트에서 새로운 요구사항을 받아 Preferences를 사용해야 하는 상황이라면 Preferences DataStore를 사용하는 것이 좋아 보인다. 그렇게 생각한 이유는 최신 라이브러리와 사용하기에 큰 어려움도 없고 Shared Preferences보다 좀 더 편하고 가독성 좋은 코드를 구성할 수 있을 것이라고 생각하기 때문이다. 또한 단순히, key-value 형식으로 저장만 지원했던 Shared Preferences라면, Preferences DataStore는 이전 Shared에서 개발자들이 불편하다고 생각했던 부분, 혹은 새로운 라이브러리와 함께 사용하면서 생긴 불편함들을 보완해서 나왔다는 느낌이다. (구글에서 이러한 불편 사항을 접수한 후 개발했다고 예상되기 때문에 어찌 보면 당연한 이야기이다.)