1. 개요
디바이스 로컬 내 데이터 저장하는 작업을 진행하다 보면, 보통 SharedPreferences나 RoomDB, SQLite DB를 사용한다. 키-값을 저장해야하는 경우는 대체로 SharedPreferences를 사용하곤 한다.
최근 JetPack Compose에 대해 공부하면서 DataStore라는 것을 발견하면서 DataStore에 대해서 학습을 시작했다.
공식문서를 살펴보면 구글에서는 다음과 같은 문구가 적혀있다.
현재 SharedPreferences를 사용하여 데이터를 저장하고 있다면 대신 DataStore로 이전하는 것이 좋습니다.
Why....?
2. SharedPrefernces의 한계와 문제점
- SharedPreferences 에서는 읽기(Read)에 대해 기존에 값을 읽어오는 것은 동기(Sync) API 만을 제공하고, 값에 변화가 있을 때마다 비동기적으로 값을 가져오는 방법으로는 오직 OnSharedPreferenceChangeListener 를 통해서만 콜백을 받을 수 있게 지원하고 있었습니다. 자칫 많은 데이터를 다루거나 저사양 디바이스에서는 ANR을 발생시키거나 앱이 버벅거릴수 있다.
- SharedPrefernces는 기본적으로 Exception에 대한 에러 핸들링을 제공하고 있지 않는 문제가 있어, apply()를 통해 pending 된 작업을 처리하다가 에러가 발생한다면 예외를 잡을 방법 없이 Crash를 맞이할 수 밖에 없다
- SharedPrefernces 내부를 보면, commit() 함수에서는 별도 Thread가 아닌 호출된 Thread엣 바로 File Write 하고 있다. 이는 파일을 쓰는 데이터가 많지 않으면 문제가 되진 않지만, 저사양 디바이스나 데이터 양이 많아진다면 Main Thread에서 오랫동안 자원을 잡아먹어 앱이 버벅거리거나 ANR까지 이어질 수도 있다.
- 데이터 마이그레이션을 지원하지 않음, 데이터 타입을 변경하려면 전체 로직 수정이 필요하다.
3. DataStore
코루틴 및 Flow를 사용하여 비동기적이고, 일관된 트랜잭션 방식으로 데이터 저장 소규모 단순 데이터에 적합한 솔루션으로, 복잡한 데이터나 참조 무결성 등을 필요로 할 때는 여전히 Room을 사용하는 것이 더 적합하다. key-value 방식의 Preferences DataStore와 Protocol buffer를 사용한 타입이 지정된 객체를 저장할 수 있는 방식인 Proto DataStore 솔루션을 제공
Preferences DataStore
key-value 방식으로 데이터를 읽고 쓰는 방식 유형 안전성을 제공하지 않으며 사전 정의된 스키마가 필요하지 않음
Proto DataStore
구조화된 데이터 유형의 인스턴스로 데이터를 저장 유형 안전성을 제공하며 프로토콜 버퍼를 사용하여 스키마를 정의해야 한다.
SharedPreferences는 XML을 사용하여 데이터를 저장한다. 따라서, 데이터 양이 증가함에 따라 파일 크기가 급격하게 증가하고, 위에서 언급한 대로 ANR을 발생시키거나 앱이 버벅거릴수 있다.
SharedPreferences에 비해서 프로토콜 버퍼(Protocol buffers)은 XML보다 빠르고 크기가 작은 구조화된 데이터를 표현하는 새로운 방법이라고 소개한다. 저장된 데이터의 읽기 시간이 앱의 성능에 영향을 미칠때 유용하다.
이를 사용하기 위해서 .proto 파일을 사용하여 데이터 스키마를 정의한다. 그런 다음 플러그인 클래스를 생성한다.
이 글에서는 Preferences DataStore 예제만 소개하도록 한다.
아래 예제에서는 DataBinding와 livedata를 같이 사용한다.
4. Preferences DataStore Example
build.gradle.kts (:app)
android {
dataBinding {
enable = true
}
}
dependencies {
.
.
implementation("androidx.datastore:datastore:1.0.0")
implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation("androidx.datastore:datastore-preferences-core:1.0.0")
// optional - RxJava2 support
implementation("androidx.datastore:datastore-rxjava2:1.0.0")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
// optional - RxJava3 support
implementation("androidx.datastore:datastore-rxjava3:1.0.0")
.
.
}
User.kt
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_prefs")
UserManager.kt
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.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class UserManager (
private val dataStore: DataStore<Preferences>
) {
companion object {
val USER_AGE_KEY = intPreferencesKey("USER_AGE") // int
val USER_FIRST_NAME_KEY = stringPreferencesKey("USER_FIRST_NAME") // string
val USER_LAST_NAME_KEY = stringPreferencesKey("USER_LAST_NAME")
val USER_GENDER_KEY = booleanPreferencesKey("USER_GENDER") // boolean
}
suspend fun storeUser(
age: Int,
frontName: String,
lastName: String,
isMale: Boolean
) {
dataStore.edit {
it[USER_AGE_KEY] = age
it[USER_FIRST_NAME_KEY] = frontName
it[USER_LAST_NAME_KEY] = lastName
it[USER_GENDER_KEY] = isMale
}
}
val userAgeFlow: Flow<Int?> = dataStore.data.map {
it[USER_AGE_KEY]
}
val userFirstNameFlow: Flow<String?> = dataStore.data.map {
it[USER_FIRST_NAME_KEY]
}
val userLastNameFlow: Flow<String?> = dataStore.data.map {
it[USER_LAST_NAME_KEY]
}
val userGenderFlow: Flow<Boolean?> = dataStore.data.map {
it[USER_GENDER_KEY]
}
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<Button
android:id="@+id/btn_save"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="60dp"
android:padding="18dp"
android:text="Save user"
android:textColor="@android:color/white"
android:textSize="15sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="@+id/switch_gender"
app:layout_constraintStart_toStartOf="@+id/switch_gender"
app:layout_constraintTop_toBottomOf="@+id/switch_gender" />
<EditText
android:id="@+id/et_lname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:ems="10"
android:hint="이름"
app:layout_constraintEnd_toEndOf="@+id/et_fname"
app:layout_constraintStart_toStartOf="@+id/et_fname"
app:layout_constraintTop_toBottomOf="@+id/et_fname" />
<EditText
android:id="@+id/et_age"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:ems="10"
android:hint="나이"
android:inputType="number"
app:layout_constraintEnd_toEndOf="@+id/et_lname"
app:layout_constraintStart_toStartOf="@+id/et_lname"
app:layout_constraintTop_toBottomOf="@+id/et_lname"
tools:layout_editor_absoluteY="317dp" />
<EditText
android:id="@+id/et_fname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:ems="10"
android:hint="나머지 이름"
app:layout_constraintEnd_toEndOf="@+id/tv_gender"
app:layout_constraintStart_toStartOf="@+id/tv_gender"
app:layout_constraintTop_toBottomOf="@+id/tv_gender"
tools:layout_editor_absoluteY="178dp" />
<TextView
android:id="@+id/tv_fname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:textStyle="bold"
android:textColor="#000000"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_lname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textStyle="bold"
android:textColor="#000000"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="@+id/tv_fname"
app:layout_constraintStart_toStartOf="@+id/tv_fname"
app:layout_constraintTop_toBottomOf="@+id/tv_fname" />
<TextView
android:id="@+id/tv_age"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textStyle="bold"
android:textColor="#000000"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="@+id/tv_lname"
app:layout_constraintStart_toStartOf="@+id/tv_lname"
app:layout_constraintTop_toBottomOf="@+id/tv_lname" />
<TextView
android:id="@+id/tv_gender"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textStyle="bold"
android:textColor="#000000"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="@+id/tv_age"
app:layout_constraintStart_toStartOf="@+id/tv_age"
app:layout_constraintTop_toBottomOf="@+id/tv_age" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switch_gender"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="성별"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="@+id/et_age"
app:layout_constraintStart_toStartOf="@+id/et_age"
app:layout_constraintTop_toBottomOf="@+id/et_age" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
MainActivity.kt
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.asLiveData
import com.example.datastore.databinding.ActivityMainBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
private lateinit var userManager: UserManager
private var age = -1
private var frontName = ""
private var lastName = ""
private var gender = ""
private lateinit var binding : ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
userManager = UserManager(dataStore)
binding.run {
buttonSave()
observeData()
}
}
private fun ActivityMainBinding.buttonSave() {
btnSave.setOnClickListener {
frontName = etFname.text.toString()
lastName = etLname.text.toString()
age = etAge.text.toString().toInt()
val isMale = switchGender.isChecked
CoroutineScope(IO).launch {
userManager.storeUser(age, frontName, lastName, isMale)
}
}
}
private fun observeData() {
userManager.userAgeFlow.asLiveData().observe(this) {
if (it != null) {
age = it
binding.tvAge.text = it.toString()
}
}
userManager.userFirstNameFlow.asLiveData().observe(this) {
if (it != null) {
frontName = it
binding.tvFname.text = it
}
}
userManager.userLastNameFlow.asLiveData().observe(this) {
if (it != null) {
lastName = it
binding.tvLname.text = it
}
}
userManager.userGenderFlow.asLiveData().observe(this) {
if (it != null) {
gender = if (it) "남성" else "여성"
binding.tvGender.text = gender
}
}
}
}
5. 출처 및 참조
1. 앱 아키텍처: 데이터 영역 - DataStore | 안드로이드 공식 개발자 홈페이지
2. Preferences Datastore를 사용하여 작업하기 | 안드로이드 공식 개발자 홈페이지
4. [Android] Why DataStore? (부제: Good-bye SharedPreferences)