+5

DataStore - API mới thay thế hoàn toàn SharePreferences

Trong một số trường hợp, bạn muốn lưu trữ các dữ liệu nhỏ hoặc đơn giản. Để làm điều này, thứ bạn nghĩ đến chắc chắn là SharePreferences. Nhưng nó có khá nhiều nhược điểm. Để giải quyết những nhược điểm đã và đang tồn tại ở SharePreferences, Android Jetpack cung cấp cho bạn 1 API hoàn toàn mới, vừa mới được ra mắt ở Google IO 2021 này, đó là DataStore.

DataStore là gì?

DataStore là một phương thức lưu trữ dữ liệu cho phép bạn lưu trữ theo key-value hoặc kiểu đối tượng với protocol buffers.

DataStore sử dụng Kotlin coroutineFlow để lưu trữ dữ liệu bất đồng bộ.

DataStoreSharePreferences

PreferencesDataStoreProtoDataStore

  • PreferencesDataStore
    • Giúp bạn lưu trữ data theo dạng key-value pairs. Nhưng các bạn chỉ có thể lưu trữ data với các kiểu dữ liệu nguyên thủy (string, integer, float, double, boolean, long, ...).
    • Giống như SharePreferences, bạn không có cách nào để xác định 1 schema hoặc đảm bảo rằng các key của bạn được truy cập với đúng kiểu.
  • ProtoDataStore
    • Giúp bạn lưu trữ data theo typed objects, tức là bạn có thể lưu trữ các object tùy ý do được hỗ trợ bởi protocol buffers.
    • Muốn sử dụng ProtoDataStore thì bạn phải tự xác định schema bằng việc sử dụng Protocol Buffers. Chúng nhanh hơn, nhỏ hơn, đơn giản hơn và ít gây hoang mang hơn so với XML và các định dạng tương tự khác. Nhưng bù lại, bạn sẽ phải học một cơ chế serialization mới 😵.

Cách sử dụng DataStore

Trong bài viết này, mình sẽ hướng dẫn các bạn cách để áp dụng PreferencesDataStore. Nếu các bạn có nhã hứng với ProtoDataStore, có thể ghé thăm bài viết tiếp theo của mình, hoặc các bạn có thể vào đường link này để tham khảo. https://developer.android.com/codelabs/android-proto-datastore#0

Giới thiệu đôi chút về example

Link demo: https://github.com/hide-your-code/data_store_example. Nhớ thả star và rất mong các bạn contribute để giúp mình hoàn thiện hơn ❤️

App hiển thị số được lưu vào trong disk và khi bấm vào nút "Count ++" thì sẽ tăng số đó lên 1 và lưu số đó vào trong disk.

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
    • Class DataStoreHelper - Nơi cung cấp DataStore và các action liên quan đến DataStore.
    • Class DataStoreRepository có trách nhiệm cung cấp count từ DataStore để hiển thị. Ngoài ra, count hiện tại cũng sẽ được lưu ở trong DataStore.
  • ui
    • MainActivity dùng làm nơi để chứa các fragment.
    • PreferencesDataStoreFragment là UI để hiển thị ra count và các action cho count.
    • PreferencesDataStoreViewModel thực hiện các business logic cũng như là nơi chứa các data để hiển thị lên UI.

Vậy là mình đã giới thiệu xong, giờ cùng bắt tay vào việc thêm DataStore vào project của bạn.

Thêm các dependency vào project

Thêm dependency này vào build.gradle để có thể sử dụng PreferencesDataStore:

implementation "androidx.datastore:datastore-preferences:1.0.0-beta01"

Hoặc nếu các bạn dùng kts, thì dùng phía dưới nhé:

implementation("androidx.datastore:datastore-preferences:1.0.0-beta01")

Thời điểm bài viết này được viết thì mới có bản beta đầu tiên, bạn nên update phiên bản mới nhất nhé 😆.

Tạo PreferencesDataStore

Để tạo instance của DataStore, mình sử dụng toán tử delegate preferencesDataStore. Toán tử này yêu cầu bạn phải được gọi từ Context.

private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = PREFERENCE_NAME)

companion object {
	private const val PREFERENCE_NAME = "todo"
}

Toán tử preferencesDataStore đảm bảo rằng bạn chỉ có instance duy nhất của DataStore. Vậy nên, mình khuyên bạn cũng nên khởi tạo duy nhất class DataStoreHelper. Ở đây mình sử dụng 1 thư viện dependency inject để khởi tạo, đó là Hilt. Vậy nên các bạn nào chưa biết về Hilt, hay Dagger, hãy tìm hiểu qua về chúng, đảm bảo với bạn rằng nó thú vị lắm đó 😍.

@Singleton
class DataStoreHelper @Inject constructor(@ApplicationContext private val context: Context) {
	
	private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = PREFERENCE_NAME)

	...

	companion object {
		private const val PREFERENCE_NAME = "todo"
	}
}

Ở đây, mình sử dụng ApplicationContext để khởi tạo DataStore.

Đọc dữ liệu từ DataStore

PreferencesDataStore cho phép bạn lấy dữ liệu thông qua Flow<Preferences>. Nó giúp bạn emit preferences mọi lúc miễn là có dữ liệu thay đổi.

Điều đầu tiên trước khi bạn muốn đọc dữ liệu, là phải xác định được key của Preferences. Với Preferences, bạn sẽ phải khai báo key theo những kiểu dưới đây do PreferencesDataStore hỗ trợ:

  • intPreferencesKey: lấy key với value theo kiểu int.
  • doublePreferencesKey: lấy key với value theo kiểu double.
  • stringPreferencesKey: lấy key với value theo kiểu string.
  • booleanPreferencesKey: lấy key với value theo kiểu boolean.
  • floatPreferencesKey: lấy key với value theo kiểu float.
  • longPreferencesKey: lấy key với value theo kiểu long.
  • stringSetPreferencesKey: lấy key với value theo kiểu String Set.

Ở đây mình sẽ khởi tạo 1 key có tên là KEY_COUNTER. Key này mình để lưu trữ giá trị của Count.

private val KEY_COUNTER = intPreferencesKey("EXAMPLE_COUNTER")

Sau khi bạn đã khai báo key xong, bạn có thể lấy dữ liệu của DataStore bằng cách tạo 1 counter: Flow<Int> dựa trên dataStore.data: Flow<Preferences>.

val counter: Flow<Int> = context.dataStore.data.map {
	it[KEY_COUNTER] ?: 0
}

Nhưng khoan, lúc nãy mình có so sánh là DataStore có thể handle được exception. Vậy làm thế nào để handle exception khi lấy dữ liệu từ DataStore?

DataStore đọc dữ liệu từ file, nên IOException có thể bắn ra khi có lỗi trong việc đọc dữ liệu. Bạn có thể handle chúng bằng việc sử dụng catch() của Flow trước khi bạn dùng toán tử map và emit emptyPreferences() trong trường hợp lỗi ở đây là IOException. Nếu các lỗi khác, thì bạn có thể làm gì tùy ý bạn 🥺

val counter = context.dataStore.data
	.catch { exception ->
		if (exception is IOException) {
			emit(emptyPreferences())
		} else {
			throw exception
		}
	}.map {
		it[KEY_COUNTER] ?: 0
	}

hoặc bạn có thể dùng function này để viết cho gọn 😅

private fun <T> getValue(transform: (preferences: Preferences) -> T): Flow<T> = context.dataStore.data
	.catch { exception ->
		if (exception is IOException) {
			emit(emptyPreferences())
		} else {
			throw exception
		}
	}.map {
		transform.invoke(it)
	}

val counter = getValue {
	it[KEY_COUNTER] ?: 0
}

Ghi dữ liệu vào DataStore

Để ghi dữ liệu vào DataStore, bạn cần sử dụng suspend function DataStore.edit(transform: suspend (MutablePreferences) -> Unit). MutablePreferences được truyền từ transform sẽ liên tục cập nhật với bất kì lần edit nào trước đó. Tất cả những thay đổi nào tới MutablePreferences trong transform đều được ghi vào ổ cứng sau khi transform hoàn thành và trước khi edit hoàn thành.

Đây là cách bạn có thể ghi dữ liệu vào DataStore:

suspend fun setCounter(counter: Int) = context.dataStore.edit {
	it[KEY_COUNTER] = counter
}

edit() bắn ra IOException nếu xảy ra lỗi khi đọc hoặc ghi vào đĩa. Nếu có bất kì error xảy ra ở khối transform, nó sẽ bắn exception bởi edit().

Vậy nên, bạn có thể bắt exception như sau:

private suspend fun setValue(transform: (preference: MutablePreferences) -> Unit) = try {
	context.dataStore.edit{
		transform.invoke(it)
	}
} catch (exception: Exception) {
	Timber.d("DataStore: Fail to set value - $exception")
}

suspend fun setCounter(counter: Int) = setValue {
	it[KEY_COUNTER] = counter
}

Và các bạn nên nhớ rằng, Preferences là chỉ có thể lấy dữ liệu thông qua hàm DataStore.data, không có chức năng ghi dữ liệu. Nếu bạn muốn ghi dữ liệu, hãy sử dụng MutablePreferences thông qua hàm DataStore.edit().

Chuyển từ SharePreferences sang PreferencesDataStore

Để có thể thực hiện việc chuyển sang DataStore, bạn cần cập nhật DataStore builder để truyền vào SharePreferencesMigration tới migrate list. DataStore sẽ tự động chuyển từ SharePreferences sang DataStore một cách tự động. Việc di chuyển này chạy khi có bất kì dữ liệu nào truy cập vào DataStore. Điều đó có nghĩa là việc di chuyển đó cần thực hiện trước khi DataStore.data emit bất kì giá trị nào và trước khi DataStore.edit() cập nhật giá trị nào.

Điều quan trọng khi bạn chuyển sang DataStore từ SharePreferences là việc chuyển sang DataStore chỉ thực hiện một lần, nên bạn cần phải ngừng việc sử dụng SharePreferences sau khi việc chuyển đổi sang DataStore.

Và đây là cách để bạn có thể chuyển từ SharePreferences sang PreferencesDataStore:

private val Context.dataStoreMigrate: DataStore<Preferences> by preferencesDataStore(
	name = PREFERENCE_NAME,
	produceMigrations = {
		listOf(SharedPreferencesMigration(it, PREFERENCE_NAME))
	})

Ở đây, PREFERENCES_NAME chính là tên SharePreferences mà bạn đặt.

Việc còn lại của bạn là triển khai xuống Repository để ViewModel có thể lấy hoặc ghi dữ liệu 😄 Các bạn có thể tham khảo project ở link trên nếu không biết cách nhé. 🤤

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 - Preferences DataStore
  3. Android Blog - Prefer Storing Data with Jetpack DataStore

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.