Android Developer Roadmap: Lưu Trữ Dữ Liệu trong Android: SharedPreferences, DataStore và Room

Chào mừng các bạn quay trở lại với loạt bài viết Android Developer Roadmap! Trên hành trình trở thành một Lập trình viên Android chuyên nghiệp, sau khi đã làm quen với cấu trúc dự án, quản lý phiên bản, vòng đời Activity, và xây dựng giao diện người dùng với các Layout, View cơ bản, và Fragments, chúng ta sẽ đối mặt với một vấn đề cốt lõi: làm thế nào để lưu trữ dữ liệu để ứng dụng có thể truy cập lại ngay cả sau khi bị đóng và mở lại?

Dữ liệu là trái tim của hầu hết các ứng dụng hiện đại. Từ cài đặt người dùng, thông tin đăng nhập, danh sách mục yêu thích, cho đến dữ liệu phức tạp hơn như hồ sơ người dùng hay nội dung ứng dụng, tất cả đều cần được lưu giữ một cách bền vững (persistent storage). Android cung cấp nhiều cơ chế khác nhau để giải quyết vấn đề này. Trong bài viết hôm nay, chúng ta sẽ đi sâu vào ba lựa chọn phổ biến và mạnh mẽ nhất: SharedPreferences, DataStore, và Room Database. Chúng ta sẽ tìm hiểu từng cơ chế là gì, ưu nhược điểm của chúng, và quan trọng nhất là khi nào nên sử dụng cái nào.

SharedPreferences: Đơn giản và nhanh chóng cho dữ liệu nhỏ

Hãy bắt đầu với công cụ có lẽ là quen thuộc nhất với nhiều lập trình viên Android: SharedPreferences.

SharedPreferences là gì?

SharedPreferences là một API được Android cung cấp để lưu trữ và lấy lại dữ liệu kiểu key-value (cặp khóa-giá trị). Nó được thiết kế để lưu trữ một lượng nhỏ dữ liệu nguyên thủy (primitive data types) như boolean, float, int, long, và string. Nó thường được dùng để lưu các cài đặt cấu hình đơn giản của ứng dụng, trạng thái người dùng (ví dụ: đã đăng nhập lần đầu chưa), hoặc các tùy chọn nhỏ.

Ưu điểm của SharedPreferences:

  • Đơn giản: API của nó rất dễ sử dụng. Chỉ cần lấy một instance của SharedPreferences, lấy Editor để put dữ liệu, apply/commit thay đổi, và dùng các phương thức get để lấy dữ liệu ra.
  • Nhanh chóng: Đối với lượng dữ liệu nhỏ, việc đọc/ghi rất nhanh.
  • Dễ triển khai: Không yêu cầu cấu hình phức tạp hay các thư viện bổ sung (nó có sẵn trong Android SDK).

Nhược điểm của SharedPreferences:

  • Đồng bộ (Synchronous) API: Đây là nhược điểm lớn nhất. Các thao tác ghi dữ liệu (`apply()` ghi bất đồng bộ nhưng không có thông báo hoàn thành, `commit()` ghi đồng bộ) có thể chặn luồng chính (UI Thread) nếu dữ liệu lớn hoặc quá nhiều thao tác cùng lúc, dẫn đến hiện tượng giật, lag ứng dụng (Application Not Responding – ANR).
  • Không có kiểm tra kiểu dữ liệu lúc biên dịch: Mọi thứ đều dựa vào key string. Lỗi chính tả hoặc sai kiểu dữ liệu lúc get chỉ được phát hiện lúc runtime.
  • Giới hạn loại dữ liệu: Chỉ hỗ trợ các kiểu dữ liệu nguyên thủy và Set<String>. Không thể lưu trữ các đối tượng phức tạp trực tiếp.
  • Không phù hợp cho dữ liệu lớn: Performance sẽ giảm đáng kể khi lưu trữ lượng lớn dữ liệu.
  • Thiếu tính toàn vẹn (Atomicity): Nhiều thao tác ghi có thể không đảm bảo tính toàn vẹn nếu ứng dụng bị kill đột ngột.

Cách sử dụng SharedPreferences (Ví dụ đơn giản):

// Lấy instance của SharedPreferences
// Tên file có thể tùy chọn, MODE_PRIVATE chỉ cho phép ứng dụng này truy cập
val sharedPref = activity?.getSharedPreferences(
    "MyAppPreferences", Context.MODE_PRIVATE) ?: return

// Lưu dữ liệu
with (sharedPref.edit()) {
    putString("user_name", "Alice")
    putBoolean("is_logged_in", true)
    apply() // Ghi bất đồng bộ
    // hoặc commit() để ghi đồng bộ (nên tránh trên luồng chính)
}

// Lấy dữ liệu
val userName = sharedPref.getString("user_name", "Guest") // "Guest" là giá trị mặc định
val isLoggedIn = sharedPref.getBoolean("is_logged_in", false) // false là giá trị mặc định

Log.d("Prefs", "User: $userName, Logged in: $isLoggedIn")

SharedPreferences là một công cụ hữu ích cho những nhu cầu lưu trữ đơn giản, nhưng những hạn chế về tính đồng bộ và khả năng mở rộng đã thúc đẩy Google giới thiệu một giải pháp hiện đại hơn.

DataStore: Sự thay thế hiện đại và bất đồng bộ

Để khắc phục những nhược điểm của SharedPreferences, đặc biệt là vấn đề đồng bộ trên luồng chính, Android Jetpack đã giới thiệu DataStore.

DataStore là gì?

DataStore là một API lưu trữ dữ liệu mới được xây dựng trên Kotlin Coroutines và Flow, cung cấp một cách an toàn và bất đồng bộ để lưu trữ dữ liệu nhỏ và trung bình.

DataStore có hai cài đặt (implementations) chính:

  1. Preference DataStore: Lưu trữ dữ liệu dạng key-value, tương tự SharedPreferences nhưng API bất đồng bộ. Không có schema được định nghĩa trước.
  2. Proto DataStore: Lưu trữ dữ liệu dạng đối tượng tùy chỉnh (custom objects) được định nghĩa bằng Protocol Buffers. Cung cấp tính an toàn kiểu dữ liệu (type safety) và yêu cầu schema rõ ràng.

Ưu điểm của DataStore:

  • Hoàn toàn bất đồng bộ (Asynchronous): Được xây dựng trên Kotlin Coroutines và Flow, tất cả các thao tác đọc/ghi đều không chặn luồng chính, giúp ứng dụng mượt mà hơn. Đây là cải tiến lớn nhất so với SharedPreferences. Khả năng làm việc với Flow (Lập trình phản ứng) cho phép bạn dễ dàng quan sát (observe) các thay đổi dữ liệu.
  • An toàn hơn: Xử lý các ngoại lệ khi đọc/ghi dữ liệu một cách an toàn hơn.
  • Hỗ trợ luồng (Flow): Việc đọc dữ liệu từ DataStore trả về một Flow, cho phép bạn xử lý các thay đổi dữ liệu một cách hiệu quả và phản ứng (reactive).
  • Kiểm tra kiểu dữ liệu lúc biên dịch (với Proto DataStore): Sử dụng Protocol Buffers giúp đảm bảo dữ liệu bạn đọc ra đúng kiểu dữ tả trong schema.
  • Xử lý dữ liệu nhất quán (Transactional API): Thao tác ghi dữ liệu được thực hiện theo kiểu transaction, đảm bảo tính toàn vẹn ngay cả khi bị gián đoạn.

Nhược điểm của DataStore:

  • Thiết lập phức tạp hơn: So với SharedPreferences, việc thiết lập ban đầu yêu cầu thêm một chút cấu hình (thêm dependency, tạo instance).
  • Yêu cầu kiến thức về Coroutines/Flow: Để sử dụng hiệu quả DataStore, bạn cần làm quen với Kotlin Coroutines và Flow. Đây là một phần quan trọng trong roadmap của bạn.
  • Proto DataStore yêu cầu định nghĩa schema: Cần tạo các file .proto và sử dụng protobuf compiler.
  • Không phải là database quan hệ: DataStore vẫn chỉ là giải pháp lưu trữ cho dữ liệu không cấu trúc hoặc cấu trúc đơn giản. Không phù hợp cho dữ liệu có mối quan hệ phức tạp hoặc cần truy vấn mạnh mẽ.

Cách sử dụng Preference DataStore (Ví dụ đơn giản):

// Bước 1: Thêm dependency (trong build.gradle - app module)
// dependencies {
//    implementation("androidx.datastore:datastore-preferences:1.0.0")
//    // Cho Proto DataStore cần thêm các dependency khác
// }

// Bước 2: Tạo DataStore instance (ví dụ trong Singleton hoặc ViewModel)
// Sử dụng delegated property để tạo DataStore
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

// Bước 3: Định nghĩa keys
object PreferencesKeys {
    val USER_NAME = stringPreferencesKey("user_name")
    val IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
}

// Bước 4: Ghi dữ liệu (trong Coroutine Scope)
suspend fun savePreferences(context: Context, userName: String, isLoggedIn: Boolean) {
    context.dataStore.edit { settings ->
        settings[PreferencesKeys.USER_NAME] = userName
        settings[PreferencesKeys.IS_LOGGED_IN] = isLoggedIn
    }
}

// Bước 5: Đọc dữ liệu (qua Flow)
val readPreferences: Flow<Pair<String?, Boolean>> = context.dataStore.data
    .catch { exception ->
        // log exception or rethrow
        if (exception is IOException) {
            emit(emptyPreferences())
        } else {
            throw exception
        }
    }
    .map { preferences ->
        val userName = preferences[PreferencesKeys.USER_NAME]
        val isLoggedIn = preferences[PreferencesKeys.IS_LOGGED_IN] ?: false
        Pair(userName, isLoggedIn)
    }

// Sử dụng trong ViewModel hoặc Activity/Fragment với lifecycleScope
/*
lifecycleScope.launch {
    readPreferences.collect { (userName, isLoggedIn) ->
        Log.d("DataStore", "User: $userName, Logged in: $isLoggedIn")
    }
}
*/

DataStore rõ ràng là lựa chọn tốt hơn SharedPreferences cho hầu hết các trường hợp cấu hình và dữ liệu key-value nhỏ trong ứng dụng hiện đại, nhờ vào tính bất đồng bộ và khả năng quan sát.

Room Database: Sức mạnh của cơ sở dữ liệu quan hệ

Khi dữ liệu trở nên phức tạp hơn, có cấu trúc rõ ràng, có mối quan hệ giữa các phần tử, và cần khả năng truy vấn mạnh mẽ, bạn sẽ cần đến cơ sở dữ liệu. Room là giải pháp được Google khuyến khích sử dụng trên Android.

Room Database là gì?

Room là một lớp trừu tượng (abstraction layer) trên SQLite database, là một phần của Android Architecture Components. Nó giúp đơn giản hóa việc làm việc với cơ sở dữ liệu SQLite trên Android, giảm thiểu boilerplate code và cung cấp kiểm tra SQL lúc biên dịch.

Ưu điểm của Room:

  • Hỗ trợ dữ liệu có cấu trúc và quan hệ: Hoàn hảo cho việc lưu trữ các đối tượng phức tạp và các mối quan hệ giữa chúng (ví dụ: danh sách người dùng và các bài viết của họ).
  • Kiểm tra SQL lúc biên dịch: Room kiểm tra cú pháp SQL của bạn ngay tại thời điểm biên dịch, giúp phát hiện lỗi sớm hơn so với việc sử dụng SQLite API thuần túy (vốn chỉ phát hiện lỗi lúc runtime).
  • Giảm boilerplate code: Room tạo ra hầu hết các code cần thiết để tương tác với database dựa trên các annotations bạn định nghĩa.
  • Tích hợp với LiveData/Flow: Data Access Objects (DAOs) trong Room có thể trả về LiveData hoặc Flow, cho phép bạn dễ dàng quan sát các thay đổi trong database và cập nhật UI một cách phản ứng. Điều này rất hữu ích khi kết hợp với kiến trúc MVVM.
  • Hỗ trợ Coroutines: Room có native support cho Coroutines, giúp các thao tác database (thường là tốn thời gian) được thực hiện bất đồng bộ một cách dễ dàng.
  • Hỗ trợ Migration: Room cung cấp cơ chế để xử lý việc thay đổi schema database theo thời gian.
  • Phù hợp cho dữ liệu lớn: SQLite và Room được thiết kế để xử lý lượng dữ liệu đáng kể.

Nhược điểm của Room:

  • Thiết lập ban đầu phức tạp hơn: Cần định nghĩa Entities (bảng), DAOs (phương thức truy vấn), và Database class.
  • Yêu cầu kiến thức về SQL: Mặc dù Room giúp trừu tượng hóa một phần, bạn vẫn cần viết các câu lệnh truy vấn bằng SQL (hoặc các annotations tương đương).
  • Không phù hợp cho dữ liệu key-value đơn giản: Việc sử dụng Room chỉ để lưu trữ vài cài đặt đơn giản là quá mức cần thiết (overkill).

Cách sử dụng Room (Ví dụ cấu trúc cơ bản):

// Bước 1: Thêm dependency (trong build.gradle - app module)
// dependencies {
//    implementation("androidx.room:room-runtime:2.5.2") // Use the latest version
//    kapt("androidx.room:room-compiler:2.5.2") // For Kotlin. Use annotationProcessor for Java
//    implementation("androidx.room:room-ktx:2.5.2") // For Coroutines/Flow extensions
// }

// Bước 2: Định nghĩa Entity (biểu diễn một bảng trong database)
@Entity(tableName = "users")
data class User(
    @PrimaryKey(autoGenerate = true) val uid: Int = 0,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?
)

// Bước 3: Định nghĩa DAO (Data Access Object - chứa các phương thức truy vấn)
@Dao
interface UserDao {
    @Query("SELECT * FROM users")
    fun getAll(): Flow<List<User>> // Hoặc LiveData<List<User>> hoặc suspend fun List<User>

    @Query("SELECT * FROM users WHERE uid IN (:userIds)")
    suspend fun loadAllByIds(userIds: IntArray): List<User>

    @Query("SELECT * FROM users WHERE first_name LIKE :first AND " +
           "last_name LIKE :last LIMIT 1")
    suspend fun findByName(first: String, last: String): User

    @Insert(onConflict = OnConflictStrategy.IGNORE) // Bỏ qua nếu có conflict
    suspend fun insert(user: User)

    @Delete
    suspend fun delete(user: User)
}

// Bước 4: Định nghĩa Database class
@Database(entities = [User::class], version = 1) // version cần tăng khi thay đổi schema
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao

    // Singleton pattern để lấy instance của database
    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getDatabase(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "app_database" // Tên file database
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

// Sử dụng trong Coroutine Scope (ví dụ trong ViewModel)
/*
val db = AppDatabase.getDatabase(context)
val userDao = db.userDao()

// Insert user
viewModelScope.launch {
    userDao.insert(User(firstName = "John", lastName = "Doe"))
}

// Observe users (if using Flow/LiveData)
userDao.getAll().collect { users ->
    // Update UI with users list
}
*/

Room là lựa chọn lý tưởng khi bạn cần lưu trữ dữ liệu có cấu trúc, cần các mối quan hệ giữa dữ liệu, và cần khả năng truy vấn phức tạp. Sự tích hợp với Coroutines/Flow và kiểm tra lúc biên dịch làm cho nó trở thành nền tảng vững chắc cho phần lưu trữ dữ liệu của ứng dụng.

So sánh và Khi nào sử dụng cái nào?

Sau khi tìm hiểu về từng cơ chế, hãy tổng hợp lại những điểm khác biệt chính và đưa ra lời khuyên về việc lựa chọn.

Bảng So sánh SharedPreferences, DataStore và Room:

Tiêu chí SharedPreferences DataStore Room
Mục đích chính Lưu trữ cài đặt cấu hình, dữ liệu key-value nhỏ Lưu trữ cài đặt cấu hình, dữ liệu key-value hoặc đối tượng nhỏ/trung bình Lưu trữ dữ liệu có cấu trúc, quan hệ, bộ dữ liệu lớn
Loại dữ liệu Nguyên thủy (boolean, int, string, float, long, Set<String>) Preference: Nguyên thủy; Proto: Đối tượng tùy chỉnh (Protocol Buffers) Đối tượng (Entities)
Luồng (Threading) Đồng bộ (gây chặn UI thread) Bất đồng bộ (sử dụng Coroutines/Flow) Bất đồng bộ (hỗ trợ Coroutines/Flow), có thể dùng đồng bộ nhưng không khuyến khích trên UI thread
Khả năng quan sát (Observability) Không có API hỗ trợ trực tiếp (phải listener hoặc poll) Có (qua Flow) Có (qua LiveData hoặc Flow từ DAO)
Kiểm tra kiểu dữ liệu/Cú pháp Không (runtime error) Proto DataStore: Có (biên dịch); Preference DataStore: Không (runtime error) Có (biên dịch cho cả Entity, DAO, SQL query)
Độ phức tạp thiết lập Rất đơn giản Đơn giản đến trung bình (tùy Preference hay Proto) Trung bình
Dữ liệu Rất nhỏ Nhỏ đến trung bình Lớn
Tính toàn vẹn (Atomicity) Kém Tốt (transactional write) Tốt (database transaction)
Tốt nhất cho Ứng dụng cũ hoặc nhu cầu lưu trữ cực kỳ đơn giản mà không cần bất đồng bộ Cài đặt người dùng, feature flags, dữ liệu cấu hình, các trạng thái nhỏ của ứng dụng (ưu tiên hơn SharedPreferences trong ứng dụng mới) Danh sách dữ liệu, thông tin chi tiết, dữ liệu quan hệ, bộ dữ liệu lớn cần truy vấn

Lời khuyên khi lựa chọn:

  • Khi nào dùng SharedPreferences? Thẳng thắn mà nói, trong các ứng dụng mới viết bằng Kotlin và sử dụng Jetpack libraries, hầu hết các trường hợp trước đây dùng SharedPreferences đều nên chuyển sang Preference DataStore. SharedPreferences chỉ còn phù hợp cho các ứng dụng legacy hoặc khi bạn cần một giải pháp cực kỳ đơn giản và không quan tâm đến vấn đề đồng bộ hay khả năng quan sát.
  • Khi nào dùng DataStore?
    • Sử dụng Preference DataStore cho cài đặt người dùng, tùy chọn cấu hình đơn giản, cờ tính năng (feature flags), hoặc bất kỳ dữ liệu key-value nhỏ nào mà bạn cần lưu trữ bất đồng bộ và có khả năng quan sát sự thay đổi.
    • Sử dụng Proto DataStore khi bạn có một cấu trúc dữ liệu nhỏ đến trung bình cần lưu trữ (không phải dạng quan hệ database) và muốn đảm bảo tính an toàn kiểu dữ liệu thông qua một schema rõ ràng (ví dụ: lưu thông tin session người dùng có cấu trúc).
  • Khi nào dùng Room?
    • Khi bạn cần lưu trữ danh sách các đối tượng (ví dụ: danh sách việc cần làm, danh sách sản phẩm, danh sách người dùng).
    • Khi dữ liệu của bạn có cấu trúc phức tạp hoặc có mối quan hệ giữa các đối tượng.
    • Khi bạn cần thực hiện các truy vấn phức tạp, sắp xếp, lọc, hoặc join dữ liệu.
    • Khi bạn xử lý lượng dữ liệu lớn mà SharedPreferences hoặc DataStore không hiệu quả.
    • Khi bạn muốn tận dụng lợi ích của kiểm tra SQL lúc biên dịch và tích hợp dễ dàng với LiveData/Flow trong kiến trúc ứng dụng của mình. Room thường được kết hợp với Repository Pattern để tách biệt logic truy cập dữ liệu khỏi UI.

Kết luận

Việc lựa chọn cơ chế lưu trữ dữ liệu phù hợp là một quyết định quan trọng trong quá trình phát triển ứng dụng Android. SharedPreferences đơn giản nhưng có hạn chế lớn về tính đồng bộ. DataStore là sự thay thế hiện đại, bất đồng bộ và an toàn hơn cho dữ liệu nhỏ và cấu hình. Room là giải pháp mạnh mẽ cho dữ liệu có cấu trúc và lớn, cung cấp đầy đủ tính năng của cơ sở dữ liệu quan hệ với nhiều tiện ích từ Android Jetpack.

Đối với các dự án Android mới, xu hướng chung là ưu tiên sử dụng DataStore cho dữ liệu cấu hình và Room cho dữ liệu có cấu trúc lớn, thay thế cho SharedPreferences và SQLite API thuần túy. Việc nắm vững cả ba cơ chế này và hiểu rõ ưu nhược điểm của từng loại sẽ giúp bạn đưa ra lựa chọn sáng suốt, xây dựng những ứng dụng hiệu quả, mượt mà và dễ bảo trì hơn.

Trên hành trình Android Developer Roadmap, việc hiểu và áp dụng đúng các công cụ lưu trữ dữ liệu là một bước tiến quan trọng. Hãy tiếp tục khám phá và thực hành để làm chủ các kỹ năng này nhé!

Hẹn gặp lại các bạn trong các bài viết tiếp theo của series!

Chỉ mục