0

Proto DataStore - Lưu các object dễ dàng hơn ở local với Protocol Buffer và DataStore

Như bài viết trước, mình có đề cập là Proto DataStore lưu trữ data theo typed object, tức là bạn có thể lưu trữ các object tùy ý do được hỗ trợ bởi Protocol Buffers. Vậy nay hãy cùng xem cách cài đặt Proto DataStore bằng 1 sample nhỏ sau đây xem có điểm gì khác biệt so với Preference DataStoreShared Preferences nhé.

Tổng quan sample

Link sample cho các bạn tham khảo: https://github.com/hide-your-code/data_store_example/tree/example_proto_datastore. Rất mong các bạn góp ý để mình có thể hoàn thiện sample hơn!

Ở sample này dùng để lưu thông tin danh bạ gồm họ tên và số điện thoại. Cụ thể tính năng:

  • Khi người dùng nhập tên, số điện thoại và bấm nút lưu ⇒ thông tin sẽ được lưu vào Proto DataStore.
  • Hiển thị danh sách những người mà user đã lưu trong danh bạ. Tự động được lấy ra từ Proto DataStore.
  • Clear Data để xóa hết danh bạ đã lưu trong Proto DataStore.

Cấu trúc project

App tuân theo architecture của Android khuyên dùng ở đây. Và sau đây mình sẽ giới thiệu một chút về các package trong project:

  • data: Bao gồm các công việc liên quan đến việc truy cập vào data từ local, remote, ....
    • datastore/serializer: Nơi chứa các Serializer của Proto Buffer.
    • datastore/ProDataStoreHelper: Nơi khởi tạo Proto DataStore, cung cấp các phương thức để ghi và đọc dữ liệu từ Proto DataStore.
    • ProtoDataStoreRepository: chịu trách nhiệm cung cấp danh bạ.
  • model: Bao gồm các model.
  • ext: Bao gồm các extension để sử dụng thuận tiện hơn.
  • ui: Chứa các UI được hiển thị cho người dùng.
    • MainActivity dùng là nơi chứa fragment, drawer, ....
    • ProtoDataStoreFragment là nơi hiển thị UI danh sách những người mình đã lưu danh bạ, và xóa dữ liệu trong Proto DataStore.
    • ProtoDataStoreViewModel là nơi các logic hiển thị view.
  • util
    • AutoClearValue: Class này giúp mình clear các giá trị khi fragment vào onDestroyView(). Mình thường dùng để clear các instance không cần dùng đến khi fragment bị destroy view như các adapter của recycler view, view binding, ....
    • Event: Class này dùng cho việc LiveData bắn observe 1 lần, tránh tình trạng khi fragment khởi tạo lại view khiến LiveData bắn observe.

Thêm các dependency vào project

1. Thêm Protocol Buffers plugin

Việc đầu tiên hãy thêm plugin Protocol Buffers vào build.gradle của app nhé.

plugins {
    id "com.google.protobuf" version "0.8.16"
}

Hoặc build.gradle.kts:

plugins {
    id("com.google.protobuf").version("0.8.16")
}

2. Thêm Protocol Buffers và Proto DataStore dependency

Sau khi thêm plugin Proto Buffers, việc tiếp theo là bạn thêm dependency vào build.gradle của bạn.

dependencies {
    implementation  "androidx.datastore:datastore:1.0.0-rc01"
    implementation  "com.google.protobuf:protobuf-javalite:3.14.0"
}

Hoặc build.gradle.kts:

dependencies {
	implementation("androidx.datastore:datastore:1.0.0-rc01")
    implementation("com.google.protobuf:protobuf-javalite:3.14.0")
}

3. Config Proto Buffer

Bước cuối cùng nếu bạn muốn sử dụng được Proto DataStore chính là config Protocol Buffers. Config dưới đây giúp tạo ra các đoạn code java protobuf-lite cho Protocol Buffers. Bạn muốn tìm hiểu thêm, hãy đọc phần hướng dẫn config của Protocol Buffers ở link này nhé. Ở đây mình chỉ lấy 1 config đơn giản như dưới, các bạn tham khảo nhé:

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.17.3"
    }

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

Hoặc build.gradle.kts:

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.17.3"
    }

    generateProtoTasks {
        all().forEach {
            it.builtins {
                create("java") {
                    option("lite")
                }
            }
        }
    }
}

Xác định và sử dụng proto buffer object

Protocol Buffers là 1 cơ chế để tuần tự hóa structured data. Bạn chỉ cần xác định cách bạn muốn dữ liệu của mình được cấu trúc thế nào và trình biên dịch sẽ tạo các source code để dễ dàng đọc và ghi structured data.

1. Tạo file Protocol Buffers

Việc đầu tiên bạn sẽ phải tạo 1 file .proto. Tạo file .proto giúp bạn xác định được các object để giúp DataStore có thể lưu trữ các object mà bạn muốn thay vì xác định chúng qua file kotlin.

Ở đây mình sẽ define đối tượng PersonDataStore gồm các thông tin như họ tên và số điện thoại, và PeopleDataStore là đối tượng để lưu trữ các PersonDataStore.

syntax = "proto3";

option java_package = "minhdtm.example.exampledatastore";
option java_multiple_files = true;

message PersonDataStore {
  string name = 1;

  string phone = 2;
}

message PeopleDataStore {
  repeated PersonDataStore people = 1;
}

Mình sẽ hướng dẫn thứ cơ bản để bạn có thể tự tạo 1 file .proto cho riêng mình.

Trước tiên là từ khóa syntax. Ở đây bạn có thể hiểu đơn giản là bạn sẽ xác định xem bạn muốn sử dụng version protocol buffer nào. Mình sử dụng version mới nhất là proto3.

Tiếp theo bạn có thể thấy có những tùy chọn dành riêng cho Java. Cụ thể như sau:

  • option java_package: Chỉ định tên package mà các class được generate của bạn sẽ hoạt động. Nếu bạn không chỉ định rõ ràng tên package, nó sẽ khớp với tên gói được đưa ra bởi package, nhưng thường là các tên Java package không phù hợp (vì chúng thường không bắt đầu bằng tên miền).
  • option java_multi_files: Nếu được set bằng true ⇒ bạn cho phép tạo các file .java riêng biệt cho mỗi lớp được tạo. Tức là nó sẽ tạo file Java khi ở compile time cho bạn.

Sau khi bạn làm các bước trên, bạn sẽ bắt đầu define các message mà bạn muốn. message chỉ là một tập hợp chứa các trường của bạn. Hiểu nôm na là message giống với Class trong Java vậy.

Đối với việc khai báo các trường, Protocol Buffers hỗ trợ bạn với các kiểu như sau:

  • string: Tương đương với String trong Java.
  • int32: Tương đương với int trong Java.
  • int64: Tương đương với long trong Java.
  • bool: Tương đương với boolean trong Java.
  • double: Tương đương với double trong Java.
  • float: Tương đương với float trong Java.

Ngoài ra bạn có thể tìm hiểu thêm về chúng theo link này.

Các bạn có thể thấy tiếp sau khi khai báo các trường, mình có khai báo như string name = 1. = 1, = 2 trên mỗi phần tử để xác định thẻ tag duy nhất mà trường đó sử dụng trong mã hóa nhị phân. Số thẻ từ 1 đến 15 yêu cầu mã hóa ít hơn 1 byte so với số cao hơn. Vậy nên, để tối ưu hóa, bạn nên sử dụng các thẻ tag dưới 16. Lưu ý nhé.

Và nếu bạn tự hỏi vậy làm thế nào để tạo 1 list trong Protocol Buffers, thì bạn sử dụng repeated để tạo nhé. Nó như là một mảng động trong Java thôi. Ngoài ra, các bạn muốn tìm hiểu thêm về Protocol Buffers cho Java thì vào link này để tham khảo nhé.

Sau khi bạn đã hiểu cơ bản cấu trúc của 1 file .proto trông sẽ thế nào, tiếp theo bạn tạo mới 1 file gọi là person_ds.proto vào thư mục app/src/main/proto. Nếu bạn không thấy folder này, hãy chuyển sang Project view như hình dưới.

Tiếp theo đó bạn xác định những schema ở trong đó, như đoạn code example phía trên. Sau đó bạn BuildRebuild Project để Protocol Buffers generate ra file Java cho bạn.

2. Tạo serializer

Để Proto DataStore có thể đọc, ghi kiểu dữ liệu mà bạn đã xác định ở file .proto, bạn cần phải implement một Serializer. Serializer cũng xác định giá trị mặc định được trả về nếu không có dữ liệu từ disk. Tạo mới 1 file là PeopleSerializer ở trong thư mục data/datastore/serializer:

object PeopleSerializer : Serializer<PeopleDataStore> {

    override val defaultValue: PeopleDataStore = PeopleDataStore.getDefaultInstance()

    @Suppress("BlockingMethodInNonBlockingContext")
    override suspend fun readFrom(input: InputStream): PeopleDataStore = try {
        PeopleDataStore.parseFrom(input)
    } catch (ex: InvalidProtocolBufferException) {
        Timber.d("Wrong read proto!")
        defaultValue
    }

    @Suppress("BlockingMethodInNonBlockingContext")
    override suspend fun writeTo(t: PeopleDataStore, output: OutputStream) {
        try {
            t.writeTo(output)
        } catch (ex: Exception) {
            Timber.d("Cannot write proto!")
        }
    }
}

Làm việc với data trong Proto DataStore

1. Tạo Proto DataStore

Để tạo instance của Proto DataStore, bạn sử dụng delegate dataStore, toán tử này yêu cầu bạn cần được gọi từ Context. Ngoài ra toán tử này còn yêu cầu thêm 2 tham số bắt buộc:

  • fileName: Tên của tệp mà Proto DataStore sẽ hoạt động.
  • serializer: Serializer cho kiểu được dùng với Proto DataStore. Trong ví dụ này là PeopleSerializer.

Ở đây mình sẽ khởi tạo ProtoDataStoreHelper với Hilt. Nếu bạn chưa biết tạo sao mình khởi tạo ProtoDataStoreHelper như vậy, bạn hãy tham khảo link bài viết về khởi tạo Preferences DataStore này nhé.

@Singleton
class ProtoDataStoreHelper @Inject constructor(@ApplicationContext private val context: Context) {

		// Create instance of DataStore
    private val Context.people: DataStore<PeopleDataStore> by dataStore(
        fileName = DATA_STORE_FILE_NAME,
        serializer = PeopleSerializer
    )

    ...

    companion object {
        private const val DATA_STORE_FILE_NAME = "people_prefs.pb"
    }
}

2. Đọc dữ liệu từ Proto DataStore

Proto DataStore hiển thị các data được lưu trữ bằng Flow<PeopleDataStore>. Bạn sử dụng hàm dataStore.data để lấy dữ liệu từ Proto DataStore.

// Mapper function from Proto message to Kotlin object
private fun PersonDataStore.toPerson() = Person(
    name = name,
    phone = phone
)

// Get data from DataStore
fun getPeople(): Flow<List<Person>> = context.people.data
    .catch { exception ->
        if (exception is IOException) {
            emit(PeopleDataStore.getDefaultInstance())
        } else {
            throw exception
        }
    }
    .map { people ->
        people.peopleList.map {
             it.toPerson()
        }
    }

Ở đây mình lưu ý chút:

  • Proto DataStore đọc dữ liệu từ file, nên IOException có thể bắn ra trong quá trình đọc dữ liệu. Vậy nên bạn nên handle chúng bằng cách sử dụng toán tử trung gian catch của Flow coroutine.
  • Ngoài ra vì khi đọc data, Proto DataStore trả về Flow<PersonDataStore> nên mình sẽ map sang 1 data class khác là Person thông qua toán tử trung gian map của Flow coroutine.

3. Ghi dữ liệu vào Proto DataStore

Để ghi dữ liệu vào Proto DataStore, bạn sử dụng hàm DataStore.updateData(). Để update dữ liệu, bạn cần phải biến đổi preferences của bạn sang builder, set giá trị mới và sau đó build preferences mới đó.

// Mapper function from Kotlin object to Proto message
private fun Person.toPersonDataStore() = PersonDataStore.newBuilder()
        .setName(name)
        .setPhone(phone)
        .build()

// Set data to DataStore
fun setPerson(person: Person): Flow<Boolean> = flow {
        context.people.updateData { people ->
            val mapper = person.toPersonDataStore()
            people.toBuilder()
                .addPeople(mapper)
                .build()
        }
        emit(true)
    }.catch { exception ->
        Timber.e(exception)
        emit(false)
    }

Ở đây mình cũng chỉ là tạo 1 flow. Khi nào ghi thành công thì mình emit ra true, trong trường hợp fail thì mình emit false.

Vì mình muốn thêm phần tử vào list nên trong builder của people.toBuilder(), mình sử dụng toán tử addPeople(). Ở đây builder cung cấp cho bạn các toán tử để làm việc với list như add, addAll, remove, .... Bạn tự mình tham khảo nhé.

Chuyển đổi từ Shared Preferences sang Proto DataStore

Để có thể giúp bạn chuyển đổi từ Shared Preferences sang Proto DataStore, Proto DataStore cung cấp cho bạn lớp SharedPreferencesMigration. Phương thức by dataStore tạo Proto DataStore, cùng với đó là tham số produceMigrations. Trong khối này, bạn tạo ra các DataMigrations sẽ được chạy với instance của Proto DataStore này. Trong ví dụ dưới đây, mình chỉ có 1 mirgration duy nhất.

Khi implement SharedPreferencesMigration, khối migration đưa cho chúng ta 2 tham số:

  • SharedPreferencesView cho phép bạn truy xuất dữ liệu từ Shared Preferences.
  • PeopleDataStore là dữ liệu hiện tại.

Ở đây, hàm sẽ trả về cho bạn đối tượng PeopleDataStore.

private fun Context.sharedPreferencesMigration(): DataMigration<PeopleDataStore> = 
SharedPreferencesMigration(
		this,
    PEOPLE_PREFERENCES_NAME
) { sharedPrefs: SharedPreferencesView, currentData: PeopleDataStore ->
		// Migrate your Shared Preferences to Proto DataStore in here.
		currentData
}

private val Context.peoplePreferences: DataStore<PeopleDataStore> by dataStore(
		fileName = DATA_STORE_FILE_NAME,
		serializer = PeopleSerializer,
		produceMigrations = { context ->
				listOf(context.sharedPreferencesMigration())
		}
)

companion object {
		private const val PEOPLE_PREFERENCES_NAME = "people_preferences_name"
}

Tổng kết

Vậy tổng hợp lại, đây là những lý do bạn mà bạn nên chuyển từ SharePreferences sang DataStore:

  • SharePreferences là 1 API đồng bộ tưởng chừng có thể an toàn khi gọi trên UI Thread, không có bắn error, ....
  • DataStore sinh ra để giải quyết gần như tất cả những yếu điểm của SharePreferences.
  • DataStore là API không đồng bộ bằng cách sử dụng Kotlin coroutine và Flow, đảm bảo tính nhất quán và xử lý error.

Nguồn tài liệu và tham khảo

  1. Android Developer - DataStore
  2. Android Codelab - Proto DataStore
  3. Android Blog - Prefer Storing Data with Jetpack DataStore

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí