Jetpack DataStore는 키-값 쌍 또는 형식화된 개체를 프로토콜 버퍼와 함께 저장할 수 있는 데이터 저장 솔루션.
DataStore는 Kotlin 코루틴과 Flow를 사용하여 데이터를 비동기적이고 일관되며 트랜잭션 방식으로 저장함.
현재 SharedPreferences를 사용하여 데이터를 저장하고 있다면 대신 DataStore로 마이그레이션하는 것을 고려할 것.
⭐ 참고: 크거나 복잡한 데이터 세트, 부분 업데이트 또는 참조 무결성을 지원해야 하는 경우 DataStore 대신 Room을 사용하는 것이 좋음. DataStore는 작고 간단한 데이터세트에 이상적이며 부분 업데이트나 참조 무결성을 지원하지 않음.
Datastore는 Preferences Datastore와 Proto Datastore라는 두 가지 구현을 제공함.
Preferences DataStore는 키를 사용하여 데이터를 저장하고 데이터에 액세스. 이 구현은 type safety를 제공하지 않으며 사전 정의된 스키마가 필요하지 않음.
Proto Datastore는 맞춤 데이터 타입의 인스턴스로 데이터를 저장. 이 구현은 type safety를 제공하며 프로토콜 버퍼를 사용하여 스키마를 정의해야함.
규칙
1. 동일한 프로세스에서 특정 파일에 대해 둘 이상의 DataStore 인스턴스를 생성하지 말 것.
모든 DataStore 기능이 중단될 수 있음. 동일한 프로세스의 지정된 파일에 대해 여러 DataStore가 활성화된 경우 DataStore는 데이터를 읽거나 업데이트할 때 IllegalStateException을 발생시킴.
2. DataStore의 generic type은 변경할 수 없어야함. DataStore에서 사용되는 type을 변경하면 DataStore가 제공하는 보장이 무효화되고 잠재적으로 심각하고 잡기 어려운 버그가 생성됨. 불변성 보장, 간단한 API 및 효율적인 직렬화를 제공하는 프로토콜 버퍼를 사용하는 것이 좋음.
3. 동일한 파일에 대해 SingleProcessDataStore와 MultiProcessDataStore를 혼합하여 사용하지 말 것. 둘 이상의 프로세스에서 DataStore에 액세스하려는 경우 항상 MultiProcessDataStore를 사용할 것.
// 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"
}
// Alternatively - use the following artifact without an Android dependency.
dependencies {
implementation "androidx.datastore:datastore-preferences-core:1.0.0"
}
// Typed DataStore (Typed API surface, such as Proto)
dependencies {
implementation "androidx.datastore:datastore:1.0.0"
// optional - RxJava2 support
implementation "androidx.datastore:datastore-rxjava2:1.0.0"
// optional - RxJava3 support
implementation "androidx.datastore:datastore-rxjava3:1.0.0"
}
// Alternatively - use the following artifact without an Android dependency.
dependencies {
implementation "androidx.datastore:datastore-core:1.0.0"
}
Preferences DataStore 구현은 DataStore 및 Preferences 클래스를 사용하여 간단한 키-값 쌍을 디스크에 저장함.
preferenceDataStore에서 생성된 속성 위임을 사용하여 Datastore<Preferences>
의 인스턴스를 만듦.
kotlin 파일의 최상위 수준에서 한 번 호출하고 나머지 애플리케이션 전체에서 이 속성을 통해 액세스함. 이렇게 하면 DataStore를 싱글톤으로 유지하기가 더 쉬워짐.
RxJava를 사용하는 경우 RxPreferenceDataStoreBuilder를 사용. 필수 name 매개변수는 Preferences DataStore의 이름.
// At the top level of your kotlin file:
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
RxDataStore<Preferences> dataStore =
new RxPreferenceDataStoreBuilder(context, /*name=*/ "settings").build();
Preferences DataStore는 사전 정의된 스키마를 사용하지 않으므로 해당 키 타입 함수를 사용하여 DataStore<Preferences>
인스턴스에 저장해야 하는 각 값에 대한 키를 정의해야함.
예를 들어 int 값에 대한 키를 정의하려면 intPreferencesKey()를 사용.
그 후 DataStore.data 속성을 사용하여 Flow를 사용한 적절한 저장 값을 노출.
val EXAMPLE_COUNTER = intPreferencesKey("example_counter")
val exampleCounterFlow: Flow<Int> = context.dataStore.data
.map { preferences ->
// No type safety.
preferences[EXAMPLE_COUNTER] ?: 0
}
Preferences.Key<Integer> EXAMPLE_COUNTER = PreferencesKeys.int("example_counter");
Flowable<Integer> exampleCounterFlow =
dataStore.data().map(prefs -> prefs.get(EXAMPLE_COUNTER));
Preferences DataStore는 DataStore의 데이터를 트랜잭션 방식으로 업데이트하는 edit() 기능을 제공. 함수의 변환 매개변수는 필요에 따라 값을 업데이트할 수 있는 코드 블록을 허용.
변환 블록의 모든 코드는 단일 트랜잭션으로 처리됨.
suspend fun incrementCounter() {
context.dataStore.edit { settings ->
val currentCounterValue = settings[EXAMPLE_COUNTER] ?: 0
settings[EXAMPLE_COUNTER] = currentCounterValue + 1
}
}
Single<Preferences> updateResult = dataStore.updateDataAsync(prefsIn -> {
MutablePreferences mutablePreferences = prefsIn.toMutablePreferences();
Integer currentInt = prefsIn.get(INTEGER_KEY);
mutablePreferences.set(INTEGER_KEY, currentInt != null ? currentInt + 1 : 1);
return Single.just(mutablePreferences);
});
// The update is completed once updateResult is completed.
Proto DataStore 구현은 DataStore와 프로토콜 버퍼를 사용하여 타입이 지정된 객체를 디스크에 저장함.
Proto DataStore에는 app/src/main/proto/ 디렉터리의 proto 파일에 사전 정의된 스키마가 필요함.
이 스키마는 Proto 데이터 저장소에 저장되는 객체의 유형을 정의함.
syntax = "proto3";
option java_package = "com.example.application";
option java_multiple_files = true;
message Settings {
int32 example_counter = 1;
}
⭐ 참고: 저장된 객체의 클래스는 proto 파일에 정의된 message에서 컴파일 시간에 생성됨. 프로젝트를 다시 빌드해야함.
유형이 지정된 객체를 저장할 Proto DataStore를 만드는 작업은 두 단계로 이루어짐.
Serializer<T>
를 구현하는 클래스를 정의함. 여기서 T는 proto 파일에 정의된 유형임. serializer 클래스는 데이터 유형을 읽고 쓰는 방법을 Datastore에 알림. 아직 파일이 생성되지 않은 경우 사용할 serializer의 기본값을 포함해야함.DataStore<T>
의 인스턴스를 만듦. 여기서 T는 proto 파일에 정의된 유형임. kotlin 파일의 최상위 수준에서 인스턴스를 한 번 호출한 후 애플리케이션의 나머지 부분에서는 이 속성을 통해 인스턴스에 액세스함. filename 매개변수는 데이터를 저장하는 데 사용할 파일을 Datastore에 알리고 serializer 매개변수는 1단계에서 정의한 serializer 클래스 이름을 Datastore에 알림.//kotlin
object SettingsSerializer : Serializer<Settings> {
override val defaultValue: Settings = Settings.getDefaultInstance()
override suspend fun readFrom(input: InputStream): Settings {
try {
return Settings.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(
t: Settings,
output: OutputStream) = t.writeTo(output)
}
val Context.settingsDataStore: DataStore<Settings> by dataStore(
fileName = "settings.pb",
serializer = SettingsSerializer
)
//java
private static class SettingsSerializer implements Serializer<Settings> {
@Override
public Settings getDefaultValue() {
Settings.getDefaultInstance();
}
@Override
public Settings readFrom(@NotNull InputStream input) {
try {
return Settings.parseFrom(input);
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException(“Cannot read proto.”, exception);
}
}
@Override
public void writeTo(Settings t, @NotNull OutputStream output) {
t.writeTo(output);
}
}
RxDataStore<Byte> dataStore =
new RxDataStoreBuilder<Byte>(context, /* fileName= */ "settings.pb", new SettingsSerializer()).build();
DataStore.data를 사용하여 저장된 객체에서 적절한 속성의 Flow를 노출함.
//kotlin
val exampleCounterFlow: Flow<Int> = context.settingsDataStore.data
.map { settings ->
// The exampleCounter property is generated from the proto schema.
settings.exampleCounter
}
//java
Flowable<Integer> exampleCounterFlow =
dataStore.data().map(settings -> settings.getExampleCounter());
Proto Datastore는 저장된 객체를 트랜잭션 방식으로 업데이트하는 updateData() 함수를 제공함.
updateData()는 데이터의 현재 상태를 데이터 타입의 인스턴스로 제공하고 분할되지 않는(atomic) 읽기-쓰기-수정 작업을 통해 트랜잭션 방식으로 데이터를 업데이트함.
//kotlin
suspend fun incrementCounter() {
context.settingsDataStore.updateData { currentSettings ->
currentSettings.toBuilder()
.setExampleCounter(currentSettings.exampleCounter + 1)
.build()
}
}
//java
Single<Settings> updateResult =
dataStore.updateDataAsync(currentSettings ->
Single.just(
currentSettings.toBuilder()
.setExampleCounter(currentSettings.getExampleCounter() + 1)
.build()));
❗ 주의: 가능하면 항상 Datastore 데이터의 읽기에서 스레드를 차단하지 말 것. UI 스레드를 차단하면 ANR 또는 UI 버벅거림이 발생할 수 있으며, 다른 스레드를 차단하면 교착 상태가 발생할 수 있음.
DataStore의 주요 이점 중 하나는 비동기 API이지만 주변 코드를 비동기로 변경하는 것이 불가능할 수 있음.(예: 동기 디스크 I/O를 사용하는 기존 코드베이스로 작업하거나 비동기 API를 제공하지 않는 종속 항목이 있다면 이러한 상황이 발생할 수 있음.)
Kotlin 코루틴은 runBlocking() 코루틴 빌더를 제공하여 동기 코드와 비동기 코드 간의 격차를 해소. runBlocking()을 사용하여 Datastore에서 데이터를 동기식으로 읽을 수 있음.
RxJava는 Flowable에서 차단 메서드를 제공함.
아래 코드는 Datastore가 데이터를 반환할 때까지 호출 스레드를 차단함.
//코틀린
val exampleData = runBlocking { context.dataStore.data.first() }
//자바
Settings settings = dataStore.data().blockingFirst();
UI 스레드에서 동기 I/O 작업을 실행하면 ANR 또는 UI 버벅거림이 발생할 수 있음.
Datastore에서 데이터를 비동기식으로 미리 로드하여 이 문제를 완화해야 함.
이렇게 하면 Datastore가 비동기식으로 데이터를 읽고 메모리에 캐시함. 초기 읽기가 완료되면 이후 runBlocking()을 사용한 동기 읽기가 더 빠를 수도 있고 디스크 I/O 작업을 완전히 방지할 수도 있음.
//코틀린
override fun onCreate(savedInstanceState: Bundle?) {
lifecycleScope.launch {
context.dataStore.data.first()
// You should also handle IOExceptions here.
}
}
//자바
dataStore.data().first().subscribe();
📝 참고: DataStore 멀티 프로세스는 현재 1.1.0 alpha 릴리즈에서만 사용 가능
단일 프로세스 내에서와 동일한 데이터 일관성을 보장하며 여러 프로세스에서 동일한 데이터에 액세스하도록 DataStore를 구성할 수 있음.
서비스와 액티비티가 있는 샘플 예시)
⭐ 다른 프로세스에서 서비스를 실행하려면 android:process 속성을 사용할 것. 프로세스 ID는 콜론(':')이 접두사로 붙음. 이렇게 하면 서비스가 애플리케이션 전용 새 프로세스에서 실행됨.
<service
android:name=".MyService"
android:process=":my_process_id" />
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
scope.launch {
while(isActive) {
dataStore.updateData {
Settings(lastUpdate = System.currentTimeMillis())
}
delay(1000)
}
}
}
val settings: Settings by dataStore.data.collectAsState()
Text(
text = "Last updated: $${settings.timestamp}",
)
여러 프로세스에서 DataStore를 사용할 수 있으려면 MultiProcessDataStoreFactory를 사용하여 DataStore 객체를 구성해야함.
val dataStore: DataStore<Settings> = MultiProcessDataStoreFactory.create(
serializer = SettingsSerializer(),
produceFile = {
File("${context.cacheDir.path}/myapp.preferences_pb")
}
)
@Serializable
data class Settings(
val lastUpdate: Long
)
@Singleton
class SettingsSerializer @Inject constructor() : Serializer<Settings> {
override val defaultValue = Settings(lastUpdate = 0)
override suspend fun readFrom(input: InputStream): Timer =
try {
Json.decodeFromString(
Settings.serializer(), input.readBytes().decodeToString()
)
} catch (serialization: SerializationException) {
throw CorruptionException("Unable to read Settings", serialization)
}
override suspend fun writeTo(t: Settings, output: OutputStream) {
output.write(
Json.encodeToString(Settings.serializer(), t)
.encodeToByteArray()
)
}
}
Hilt 종속 항목 삽입을 사용하여 DataStore 인스턴스가 프로세스별로 고유하도록 할 수 있음.
@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Settings> =
MultiProcessDataStoreFactory.create(...)