Proto Datastore 활용하기 - Jetpack Compose

Shawn Kang·2023년 1월 8일
0

Jetpack Compose

목록 보기
1/5
post-thumbnail

개요

안드로이드 환경에서 앱 설정 값을 기억해야 한다거나... 하여 로컬 저장소가 필요할 때가 있다. 그때 쓰는 게 Preference Datastore 또는 Proto Datastore인데, 나는 얼리어답터 + 남들 안 쓰는 거 쓰는 사람이라 Proto Datastore(이하 Proto)을 써 보기로 했다.

Proto의 장점으로는,

  • 단순히 Key-Value가 아니라 멤버 변수의 자료형이 구체적으로 정의된 클래스 형태로 데이터 저장 가능
  • Kotlin 외에도 C++, C#, Java, Python 등 다양한 환경에서 사용 가능

뭐 대충 이 정도가 있단다. 나머지는 잘 기억 안 남. 아무튼 Kotlin + Compose 환경에서 ViewModel과 함께 Proto를 찍먹해보도록 하자.


To-do

Compose에서 Proto를 쓰기 위해서 해야 할 일은 아래와 같다:

  • build.gradle 설정
  • .proto 파일 작성
  • Serializer 작성
  • Repository 작성
  • ViewModel에 DataStore 적용하기
  • MainActivity에 반영


구현

build.gradle 설정

모듈 수준 build.gradle 파일을 열고 아래 내용들을 추가하자:

plugins {
    // 주요 내용 외 생략
    id "com.android.protobuf" version "0.9.1"
}
android {
    kotlinOptions {
        // 주요 내용 외 생략
        freeCompilerArgs = [
                "-Xjvm-default=all"
        ]  
    }
}
dependencies {
    // 주요 내용 외 생략
    implementation "androidx.datastore:datastore:1.0.0"
    implementation "com.google.protobuf:protobuf-javalite:3.21.12"
}
protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.11.0"
    }

    generateProtoTasks {
        all().each { task ->
            task.builtins {
                java {
                    option 'lite'
                }
            }
        }
    }
}

이렇게 네 가지 수정 사항을 반영해주고 Gradle Sync를 진행해주면 된다.

.proto 파일 작성

다음으로 .proto 파일을 작성해서 DataStore에서 다룰 자료형을 정의해주어야 한다. Proto는 다양한 언어 환경에서 사용할 수 있기 때문에, JSON처럼 특정 언어에 구애받지 않는 별도의 공통된 형식으로 파일을 작성한다.

.proto 파일은 /app/src/main 경로 안에 /proto 디렉토리를 새로 생성하고 그 안에서 작성한다. Android Studio에서 해당 디렉토리를 찾을 수 없는 분들은 아래 사진을 참고해서 Project 탭의 보기 모드를 'Project'로 바꿔주자. 그럼 경로를 찾을 수 있을 거다:
사진 - Project 보기 방식

/proto 디렉토리를 생성했으면 적절한 이름의 .proto 파일을 생성하고 편집기에서 연다. 그리고 아래와 같이 내용을 작성해준다:

syntax = "proto3";

option java_package = "com.imeanttobe.compose";
option java_multiple_files = true;

message Sample {
  int32 counter = 1;
}
  • syntax 항목은 사용할 Protobuf 버전을 지정하는 부분이다. proto2도 있긴 한데 최신 버전인 proto3을 쓰도록 하자.
  • java_package 항목에는 개발 중인 앱의 전체 패키지 이름을 써 주면 된다. (e.g. com.example.app)
  • java_multiple_files 항목은 최상위 수준인 클래스, enum에 해당하는 자바 클래스, enum 파일 등을 별도의 파일로 분리할 지를 결정하는 항목이다. 자세한 건 아래 링크의 '옵션' 부분을 참고하기 바란다. 레퍼런스에서는 다들 true로 지정해서 쓰더라.
  • message 항목은 클래스랑 비슷한 개념이다. message <이름> 코드를 통해 자료형을 선언하고, 그 안에 적절한 멤버 변수를 정의해준다.
  • 각 멤버 변수에 붙은 1, 2, ...에 해당하는 값은 해당 멤버 변수에 부여된 고유 값이라고 한다.

자료형 등 그 외 자세한 내용은 Protobuf 3 공식 문서를 참고하도록 하자. 그래서 이런저런 것들을 고려한 후 앱에 적절한 형태의 자료형을 정의했다면, 프로젝트를 다시 빌드해주어야 한다. 그래야 .proto 파일에서 정의한 자료형이 Java 클래스로 빌드되고, 이를 활용해 Android Studio 내에서 DataStore를 구현할 수 있게 된다. 빌드된 Java 클래스는 아래 사진처럼 편하게 Import하여 사용할 수 있게 된다:
사진 - 자동 완성

프로젝트를 다시 빌드했는데 생성한 자료형의 Import가 불가능할 경우, Android Studio를 껐다가 다시 키고 빌드를 다시 진행하자. 그러면 보통 해결되더라.

Serializer 작성

Proto는 JSON처럼 직렬화된 데이터를 쓰기 때문에, 이를 활용하려면 직렬화/역직렬화 과정을 거쳐야 한다. 앱 패키지 내에서 적절한 경로에 Serializer.kt를 생성하고, 아래와 같이 내용을 작성해주도록 하자:

object SampleSerializer : Serializer<Sample> {
    override val defaultValue: Sample
        get() = Sample.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): Sample {
        try {
            return Sample.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override suspend fun writeTo(t: Sample, output: OutputStream) {
        t.writeTo(output)
    }
}

이 코드는 수정하지 않고 그대로 써도 무방한 듯하다. 필요한 경우, Proto에서 정의한 자료형 이름에 따라 Serializer 이름도 맞춰주면 된다. 그리고 ViewModel을 싱글톤으로 쓰는 것과 유사한 이유로 DataStore도 단일 객체로 사용하기 때문에, Serializer 역시 object로 선언한다.

Repository 작성

다음으로 Repository를 작성해야 한다. ViewModel은 Repository에 딸린 Flow를 읽거나 메소드를 호출하는 식으로 간접적으로 데이터를 읽고 쓸 수 있으며, 직접적인 데이터 조작은 지금 작성하는 Repository에서 전담하게 된다. 다음 코드를 보자:

class SampleRepository(private val sampleDataStore: DataStore<Sample>) {
    val flow: Flow<Sample> = sampleDataStore.data

    suspend fun increaseCounter() {
        sampleDataStore.updateData { sample ->
            sample
                .toBuilder()
                .setCounter(sample.counter + 1)
                .build()
        }
    }
    suspend fun decreaseCounter() {
        sampleDataStore.updateData { sample ->
            sample
                .toBuilder()
                .setCounter(sample.counter - 1)
                .build()
        }
    }
}

Repository는 생성자 매개 변수로 DataStore<T>를 받는다. DataStore<T>는 .proto에서 정의한 데이터를 직접 읽고 쓸 수 있는 메소드를 제공해주는 클래스다. 이걸 매개 변수로 받아 Repository에서 데이터 조작을 진행하며, 이후에 MainActivity.kt에서 선언해 줄 예정이다.

읽기

읽기는 flow 변수로 한다. 간단하게 매개 변수로 가져온 sampleDataStore.data로 받아올 수 있다.

쓰기 및 수정

쓰기 및 수정은 아래 형태의 함수로 한다:

suspend fun setData() {
	dataStore.updateData { message ->
    	message
        	.toBuilder()
            // To do
            .build()    
    }
}

Proto는 .proto에서 지정한 멤버 변수별로 Getter와 Setter를 제공해준다. 예를 들어, .proto 파일에서 정의한 특정 sampleint32로 정의된 intValue라는 이름의 멤버 변수가 있다고 가정해보자. Proto는 프로젝트의 재빌드 단계에서 .proto 파일을 Java로 컴파일하고, sample.intValue라는 이름의 Getter와 sample.setIntValue()라는 이름의 Setter를 자동으로 생성하게 된다. 우리는 // To do 부분에서 제공받은 Getter와 Setter를 적절히 조합하여 데이터를 조작하면 된다. 말이 어려울 수 있는데, 아래 예시를 보면 무슨 말인지 이해가 갈 거다:

class SampleRepository(private val sampleDataStore: DataStore<Sample>) {
	// 읽기
    val flow: Flow<Sample> = sampleDataStore.data

	// 쓰기 및 수정
    suspend fun increaseCounter() {
        sampleDataStore.updateData { sample ->
            sample
                .toBuilder()
                .setCounter(sample.counter + 1)
                .build()
        }
    }
    suspend fun decreaseCounter() {
        sampleDataStore.updateData { sample ->
            sample
                .toBuilder()
                .setCounter(sample.counter - 1)
                .build()
        }
    }
}

ViewModel에 Repository 적용하기

Repository를 구현했으니, 이걸 활용할 ViewModel에 Repository를 생성자 매개 변수로 넣어주고, Repository에서 구현한 데이터 조작 함수를 실행하도록 코드를 작성해야 한다. 아래 코드를 보자:

class MainViewModel(private val sampleRepository: SampleRepository) : ViewModel() {
    val flow: Flow<Sample> = sampleRepository.flow

    fun increaseCounter() {
        viewModelScope.launch { sampleRepository.increaseCounter() }
    }
    fun decreaseCounter() {
        viewModelScope.launch { sampleRepository.decreaseCounter() }
    }
}

생성자 매개 변수에 private val sampleRepository: SampleRepository을 추가해 Repository를 끌어다 쓸 수 있게 했다. 그리고 increaseCounter()decreaseCounter()를 구현해 Repository의 데이터 조작 함수를 활용할 수 있게 했다. 주목할 점으로, Repository의 데이터 조작 함수는 모두 suspend fun이다. 따라서 ViewModel에서 이를 끌어다 쓸 때는 viewModelScope.launch {}로 Coroutine 스코프를 걸어주어야 한다.

그리고 당연하지만 ViewModelFactory에도 Repository를 매개 변수로 넣어주어야 한다. 까먹지 말자.

class MainViewModelFactory(private val sampleRepository: SampleRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return MainViewModel(sampleRepository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

MainActivity에 반영

가장 먼저 MainActivity.kt의 최상단에 아래 코드를 추가한다:

private val Context.sampleDataStore: DataStore<Sample> by dataStore(
    fileName = "sample.pb",
    serializer = SampleSerializer
)

얘는 이후 ViewModel에 붙은 Repository의 매개 변수인 DataStore<T>에 들어갈 변수다.

  • fileName 매개 변수는 로컬에 저장될 Protobuf 파일의 이름이다. .pb 확장자로 끝내는 게 관례인 듯 하며, 직접 String으로 박아줘도 되고 private const val로 선언해서 박아도 된다.
  • serializer 매개 변수는 Serializer다. 아까 구현해 둔 Serializer를 넣어주면 된다.

그리고 MainActivity 클래스의 setContent {} 함수 안에 ViewModel을 추가해준다:

private val Context.sampleDataStore: DataStore<Sample> by dataStore(
    fileName = "sample.pb",
    serializer = SampleSerializer
)

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val mainViewModel = ViewModelProvider(
                this,
                MainViewModelFactory(SampleRepository(sampleDataStore))
            )[MainViewModel::class.java]

            // 여기에 UI 코드 구현
        }
    }
}

이 코드로 ViewModel을 생성하면 인스턴스 개수를 1개로 유지할 수 있는 듯하다. 여기까지 했으면 끝이다. UI를 짜면서 필요한 Composable에 ViewModel을 매개 변수로 넘겨주면 된다. Composable에서는 아래 코드처럼 ViewModel을 활용해 데이터를 읽고 쓸 수 있다:

@Composable
fun MainView(viewModel: MainViewModel) {
	// 읽기
    val data: Sample = viewModel.flow.collectAsState(initial = Sample.getDefaultInstance()).value

    Column(
        verticalArrangement = Arrangement.spacedBy(12.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier
            .fillMaxSize()
            .padding(20.dp)
    ) {
        Text(
        	// 위에서 선언한 data 변수를 통해 counter 값을 가져오는 코드
            text = data.counter.toString(),
            style = MaterialTheme.typography.titleMedium
        )
        // onClick 매개 변수를 통해 viewModel에 딸린 데이터 조작 메소드를 호출하는 코드 
        Button(onClick = { viewModel.increaseCounter() }) {
            Icon(
                imageVector = Icons.Default.KeyboardArrowUp,
                contentDescription = "Increase counter"
            )
        }
        // onClick 매개 변수를 통해 viewModel에 딸린 데이터 조작 메소드를 호출하는 코드
        Button(onClick = { viewModel.decreaseCounter() }) {
            Icon(
                imageVector = Icons.Default.KeyboardArrowDown,
                contentDescription = "Decrease counter"
            )
        }
    }
}


결과

사진 - 스크린샷

잘 돌아간다. 카운터를 조작하고 앱을 껐다가 다시 켜도 끄기 전 값이 그대로 잘 남아있는 것을 볼 수 있다.

profile
i meant to be

0개의 댓글