Lưu trữ dữ liệu trong iOS: UserDefaults, Core Data, và Keychain

Chào mừng bạn quay trở lại với chuỗi bài viết “Lộ trình học Lập trình viên iOS 2025“. Sau khi đã làm quen với nền tảng ngôn ngữ SwiftObjective-C, hiểu về các khái niệm cơ bản của Swift như OOP và Lập trình Hàm, Quản Lý Bộ Nhớ, Xử lý Lỗi, Đa luồng, Closures, và cách quản lý luồng công việc hiệu quả để tránh Callback Hell; khám phá môi trường Xcode và cách Debug hiệu quả; cũng như bắt đầu với việc tạo project mới, xây dựng UI với UIKit hoặc SwiftUI, quản lý giao diện bằng Auto Layout, hiểu về Điều hướngVòng đời của ViewController, lựa chọn kiến trúc phù hợp (như MVVM với Combine hoặc RxSwift) và hiểu các Design Pattern cơ bản như Delegate; bước tiếp theo trên con đường trở thành nhà phát triển iOS chuyên nghiệp chính là làm chủ việc lưu trữ dữ liệu cục bộ.

Một ứng dụng di động hiếm khi chỉ hoạt động với dữ liệu tạm thời. Để cung cấp trải nghiệm liên tục và cá nhân hóa cho người dùng, ứng dụng cần lưu lại cài đặt, trạng thái, hoặc dữ liệu quan trọng ngay cả khi ứng dụng đóng. Nền tảng iOS cung cấp nhiều tùy chọn để thực hiện điều này, mỗi tùy chọn có mục đích và ưu nhược điểm riêng. Ba cơ chế phổ biến và cơ bản nhất mà mọi nhà phát triển iOS cần biết là UserDefaults, Core DataKeychain. Chúng ta cùng đi sâu vào từng loại nhé!

UserDefaults: Sự lựa chọn đơn giản cho cài đặt nhỏ

UserDefaults là cách dễ nhất để lưu trữ một lượng nhỏ dữ liệu đơn giản. Nó hoạt động như một kho lưu trữ key-value (khóa-giá trị), cho phép bạn lưu các kiểu dữ liệu cơ bản như String, Int, Bool, Float, Double, Array, Dictionary, Data, URL, và Date.

Khi nào sử dụng UserDefaults?

  • Lưu trữ cài đặt (settings) của ứng dụng, ví dụ: chế độ tối/sáng, ngôn ngữ, âm lượng.
  • Lưu trữ trạng thái ứng dụng đơn giản, ví dụ: lần cuối người dùng mở ứng dụng, điểm số cao nhất trong game.
  • Lưu trữ các cờ (flags) boolean, ví dụ: lần đầu tiên mở ứng dụng sau cài đặt.

Cách sử dụng UserDefaults

Sử dụng UserDefaults rất trực quan. Bạn truy cập đối tượng standard và gọi các phương thức set(_:forKey:) để lưu và các phương thức value(forKey:) hoặc các phương thức tiện ích theo kiểu dữ liệu cụ thể (như string(forKey:), integer(forKey:), bool(forKey:), v.v.) để đọc dữ liệu.

Lưu dữ liệu:

let defaults = UserDefaults.standard

// Lưu String
defaults.set("Nguyễn Văn A", forKey: "userName")

// Lưu Int
defaults.set(25, forKey: "userAge")

// Lưu Bool
defaults.set(true, forKey: "isLoggedIn")

// Lưu Array
let items = ["Apple", "Banana", "Orange"]
defaults.set(items, forKey: "favoriteFruits")

// Lưu Dictionary
let settings: [String: Any] = ["volume": 0.8, "notificationsEnabled": true]
defaults.set(settings, forKey: "appSettings")

// Lưu Data (Ví dụ: encode một đối tượng Codable)
struct User: Codable {
    let name: String
    let id: Int
}
let user = User(name: "Tran Thi B", id: 101)
if let encodedUser = try? JSONEncoder().encode(user) {
    defaults.set(encodedUser, forKey: "encodedUser")
}

// (Tùy chọn) Đồng bộ hóa ngay lập tức - thường không cần thiết vì hệ thống tự động thực hiện
// defaults.synchronize()

Đọc dữ liệu:

let defaults = UserDefaults.standard

// Đọc String
let userName = defaults.string(forKey: "userName")
print("User Name: \(userName ?? "N/A")") // Sử dụng ?? để xử lý trường hợp nil

// Đọc Int
let userAge = defaults.integer(forKey: "userAge") // integer(forKey:) trả về 0 nếu không có key
print("User Age: \(userAge)")

// Đọc Bool
let isLoggedIn = defaults.bool(forKey: "isLoggedIn") // bool(forKey:) trả về false nếu không có key
print("Is Logged In: \(isLoggedIn)")

// Đọc Array
let favoriteFruits = defaults.array(forKey: "favoriteFruits") as? [String]
print("Favorite Fruits: \(favoriteFruits ?? [])")

// Đọc Dictionary
let appSettings = defaults.dictionary(forKey: "appSettings") as? [String: Any]
print("App Settings: \(appSettings ?? [:])")

// Đọc Data và decode
if let encodedUser = defaults.data(forKey: "encodedUser") {
    let decodedUser = try? JSONDecoder().decode(User.self, from: encodedUser)
    print("Decoded User Name: \(decodedUser?.name ?? "N/A")")
}

Ưu và nhược điểm của UserDefaults

  • Ưu điểm:
    • Cực kỳ đơn giản và dễ sử dụng.
    • Phù hợp cho dữ liệu cấu hình, cài đặt.
    • Tự động đồng bộ hóa (mặc dù không đảm bảo ngay lập tức).
  • Nhược điểm:
    • Không phù hợp cho dữ liệu lớn hoặc dữ liệu có cấu trúc phức tạp (quan hệ giữa các đối tượng).
    • Không an toàn cho dữ liệu nhạy cảm (passwords, tokens) vì dữ liệu được lưu trữ dưới dạng plain text trong file plist.
    • Không hiệu quả cho việc truy vấn, lọc hoặc sắp xếp dữ liệu phức tạp.

Nói tóm lại, UserDefaults là người bạn đồng hành tốt nhất cho những nhu cầu lưu trữ dữ liệu nhỏ và không yêu cầu bảo mật cao.

Core Data: Khung quản lý đồ thị đối tượng mạnh mẽ

Core Data là một framework mạnh mẽ của Apple, không phải là một hệ quản trị cơ sở dữ liệu (database) mà là một framework quản lý đồ thị đối tượng (object graph management framework). Nó giúp bạn quản lý vòng đời của các đối tượng (object lifecycle) trong ứng dụng, bao gồm việc lưu trữ, lấy, quản lý và truyền dữ liệu đó một cách hiệu quả.

Mặc dù thường được cấu hình để sử dụng SQLite làm persistent store (lớp lưu trữ vật lý), Core Data có thể sử dụng các định dạng khác như XML, binary, hoặc in-memory. Core Data cung cấp một lớp abstraction phía trên persistent store, cho phép bạn làm việc với dữ liệu dưới dạng các đối tượng Swift/Objective-C thay vì các hàng và cột trong cơ sở dữ liệu truyền thống.

Khi nào sử dụng Core Data?

  • Ứng dụng cần lưu trữ một lượng lớn dữ liệu có cấu trúc.
  • Dữ liệu có các mối quan hệ phức tạp giữa các đối tượng (ví dụ: một người dùng có nhiều đơn hàng, một đơn hàng có nhiều sản phẩm).
  • Cần các chức năng truy vấn, lọc, sắp xếp dữ liệu nâng cao.
  • Cần quản lý phiên bản (migration) của dữ liệu khi cấu trúc thay đổi.

Các thành phần chính của Core Data

Hiểu các thành phần này là chìa khóa để làm việc với Core Data:

  • Managed Object Model (NSManagedObjectModel): Đây là schema (lược đồ) của dữ liệu của bạn. Nó định nghĩa các Entities (thực thể – tương tự bảng trong SQL), Attributes (thuộc tính – cột) và Relationships (mối quan hệ – khóa ngoại). Bạn thường thiết kế model này trong file .xcdatamodeld trong Xcode.
  • Persistent Store Coordinator (NSPersistentStoreCoordinator): Đây là cầu nối giữa Managed Object Model và Persistent Store. Nó chịu trách nhiệm quản lý các persistent store (như file SQLite) và trình bày dữ liệu từ chúng cho Managed Object Context.
  • Managed Object Context (NSManagedObjectContext): Đây là “vùng làm việc” (scratchpad) của bạn. Bạn tạo, sửa đổi và xóa các Managed Objects (các đối tượng dữ liệu của bạn) trong Context này. Các thay đổi chỉ được lưu vào persistent store khi bạn gọi phương thức save() trên Context. Context cũng chịu trách nhiệm theo dõi các thay đổi, quản lý undo/redo.
  • Persistent Container (NSPersistentContainer): Được giới thiệu từ iOS 10, đây là một đối tượng tiện ích giúp đóng gói và quản lý Persistent Store Coordinator, Managed Object Model và Managed Object Context chính (main context), làm cho việc thiết lập Core Data stack dễ dàng hơn nhiều.
  • Managed Object (NSManagedObject): Đây là các đối tượng dữ liệu thực tế mà bạn làm việc. Chúng là các instance của các Entity được định nghĩa trong Managed Object Model.

Cách sử dụng Core Data (Cơ bản)

Thiết lập Core Data ban đầu có thể hơi phức tạp, nhưng với NSPersistentContainer, nó đã đơn giản hơn nhiều. Dưới đây là luồng làm việc cơ bản:

  1. Thiết lập NSPersistentContainer (thường trong AppDelegate hoặc SceneDelegate, hoặc một lớp quản lý riêng).
  2. Lấy Managed Object Context (thường là viewContext của container).
  3. Tạo, tìm kiếm, sửa đổi, hoặc xóa Managed Objects trong Context.
  4. Lưu các thay đổi bằng cách gọi context.save().

Ví dụ thiết lập Persistent Container:

// Trong một lớp quản lý Core Data hoặc AppDelegate/SceneDelegate
import CoreData

class CoreDataManager {
    static let shared = CoreDataManager()

    lazy var persistentContainer: NSPersistentContainer = {
        // Tên của model file (.xcdatamodeld)
        let container = NSPersistentContainer(name: "YourDataModelName") // Thay YourDataModelName bằng tên file model của bạn
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                // Xử lý lỗi tại đây. Trong ứng dụng thực tế, bạn nên log lỗi
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        return container
    }()

    // Context chính trên main queue, thường dùng cho UI
    var mainContext: NSManagedObjectContext {
        return persistentContainer.viewContext
    }

    // Phương thức lưu Context
    func saveContext () {
        let context = persistentContainer.viewContext
        if context.hasChanges {
            do {
                try context.save()
            } catch {
                // Xử lý lỗi tại đây
                let nserror = error as NSError
                fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
            }
        }
    }
}

Ví dụ tạo và lưu một đối tượng (Entity tên là “Item” với attribute “name”):

import CoreData

func createAndSaveItem(name: String) {
    let context = CoreDataManager.shared.mainContext

    // Tạo một đối tượng Managed Object mới
    guard let entity = NSEntityDescription.entity(forEntityName: "Item", in: context) else {
        print("Could not find entity description for Item")
        return
    }
    let newItem = NSManagedObject(entity: entity, insertInto: context)

    // Gán giá trị cho thuộc tính
    newItem.setValue(name, forKey: "name")

    // Lưu Context
    CoreDataManager.shared.saveContext()
    print("Saved new item: \(name)")
}

// Gọi hàm
// createAndSaveItem(name: "Mua sữa")

Ví dụ lấy dữ liệu (Fetch Request):

import CoreData

func fetchAllItems() -> [NSManagedObject] {
    let context = CoreDataManager.shared.mainContext

    // Tạo Fetch Request cho Entity "Item"
    let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "Item")

    // Tùy chọn: thêm Predicate để lọc, Sort Descriptors để sắp xếp
    // fetchRequest.predicate = NSPredicate(format: "name CONTAINS %@", "sữa")
    // fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]

    do {
        let items = try context.fetch(fetchRequest)
        return items
    } catch let error as NSError {
        print("Could not fetch. \(error), \(error.userInfo)")
        return []
    }
}

// Gọi hàm và in kết quả
// let itemsList = fetchAllItems()
// for item in itemsList {
//     if let name = item.value(forKey: "name") as? String {
//         print("- \(name)")
//     }
// }

Ưu và nhược điểm của Core Data

  • Ưu điểm:
    • Lý tưởng cho dữ liệu có cấu trúc, số lượng lớn và có quan hệ phức tạp.
    • Cung cấp các công cụ mạnh mẽ để truy vấn, lọc, sắp xếp dữ liệu.
    • Hỗ trợ quản lý vòng đời đối tượng và xử lý thay đổi (change tracking).
    • Tích hợp tốt với UI thông qua NSFetchedResultsController (trong UIKit) hoặc `@FetchRequest` (trong SwiftUI).
    • Hỗ trợ Migration khi thay đổi model.
  • Nhược điểm:
    • Đường cong học tập khá dốc so với UserDefaults.
    • Thiết lập ban đầu và quản lý Context, luồng (threading) có thể phức tạp.
    • Không phù hợp cho dữ liệu nhạy cảm cần bảo mật cực cao như mật khẩu (sử dụng Keychain).

Core Data là giải pháp tiêu chuẩn của Apple cho việc quản lý dữ liệu cấu trúc trên thiết bị, là kỹ năng cần thiết cho mọi ứng dụng phức tạp.

Keychain: An toàn cho dữ liệu nhạy cảm

Trong khi UserDefaults dễ sử dụng và Core Data mạnh mẽ cho dữ liệu cấu trúc, cả hai đều không phải là nơi an toàn để lưu trữ thông tin nhạy cảm như mật khẩu, token truy cập API, khóa cá nhân (private keys), v.v. Đây là lúc Keychain Services ra đời.

Keychain Services là một API cấp thấp, cung cấp một kho lưu trữ an toàn được mã hóa trên thiết bị. Hệ điều hành quản lý Keychain và đảm bảo rằng chỉ có ứng dụng đã lưu thông tin đó mới có thể truy cập lại nó (trừ khi được cấu hình để chia sẻ giữa các ứng dụng của cùng một nhà phát triển). Dữ liệu trong Keychain được bảo vệ bởi mã hóa cấp hệ thống và liên kết với mã khóa của người dùng, thường được bảo vệ thêm bằng Touch ID hoặc Face ID trên các thiết bị hiện đại.

Khi nào sử dụng Keychain?

  • Lưu trữ mật khẩu người dùng cho các dịch vụ bên ngoài.
  • Lưu trữ token xác thực (OAuth tokens, API keys).
  • Lưu trữ các chứng chỉ và khóa mã hóa nhạy cảm.

Cách sử dụng Keychain

Làm việc trực tiếp với Keychain Services (trong `Security.framework`) có thể khá phức tạp vì nó sử dụng các API theo kiểu C và Core Foundation. Bạn cần xây dựng các dictionary truy vấn để thêm, lấy, cập nhật hoặc xóa các mục trong Keychain.

Các thao tác cơ bản:

  1. Xây dựng một dictionary các thuộc tính mô tả mục dữ liệu bạn muốn thao tác (kSecClass, kSecAttrService, kSecAttrAccount, kSecValueData, v.v.).
  2. Sử dụng các hàm như SecItemAdd để thêm, SecItemCopyMatching để lấy, SecItemUpdate để cập nhật, và SecItemDelete để xóa.
  3. Xử lý mã lỗi trả về từ các hàm này.

Do sự phức tạp của API gốc, cộng đồng đã phát triển nhiều thư viện wrapper để đơn giản hóa việc sử dụng Keychain, ví dụ như KeychainAccess của hluk.

Ví dụ khái niệm về việc thêm mật khẩu vào Keychain:

import Security
import Foundation

// Ví dụ khái niệm - sử dụng thư viện wrapper được khuyến khích trong thực tế

func savePassword(service: String, account: String, password: String) -> OSStatus {
    // Chuyển mật khẩu sang Data
    guard let passwordData = password.data(using: .utf8) else { return errSecInvalidData }

    // Xây dựng dictionary truy vấn
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword, // Lưu mật khẩu chung
        kSecAttrService as String: service,
        kSecAttrAccount as String: account,
        kSecValueData as String: passwordData // Dữ liệu cần lưu
    ]

    // Xóa mục cũ nếu tồn tại để tránh lỗi trùng lặp
    SecItemDelete(query as CFDictionary)

    // Thêm mục mới vào Keychain
    return SecItemAdd(query as CFDictionary, nil)
}

// Ví dụ khái niệm về việc lấy mật khẩu từ Keychain:
func getPassword(service: String, account: String) -> String? {
    // Xây dựng dictionary truy vấn
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: service,
        kSecAttrAccount as String: account,
        kSecReturnData as String: kCFBooleanTrue!, // Yêu cầu trả về dữ liệu
        kSecMatchLimit as String: kSecMatchLimitOne // Chỉ trả về mục đầu tiên tìm thấy
    ]

    var dataTypeRef: AnyObject? = nil
    let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)

    if status == errSecSuccess, let passwordData = dataTypeRef as? Data {
        return String(data: passwordData, encoding: .utf8)
    } else {
        print("Failed to retrieve password, status: \(status)")
        return nil
    }
}

// Gọi hàm (ví dụ)
// let saveStatus = savePassword(service: "MyAwesomeAppAPI", account: "[email protected]", password: "secure_password_123")
// print("Save status: \(saveStatus)") // errSecSuccess là 0
//
// let retrievedPassword = getPassword(service: "MyAwesomeAppAPI", account: "[email protected]")
// print("Retrieved Password: \(retrievedPassword ?? "Not found")")

Như bạn thấy, API gốc khá “dài dòng”. Đó là lý do các thư viện wrapper như KeychainAccess được ưa chuộng. Sử dụng một thư viện bên ngoài có thể giúp bạn tiết kiệm đáng kể thời gian và giảm thiểu lỗi.

Ưu và nhược điểm của Keychain

  • Ưu điểm:
    • Mức độ bảo mật cao nhất cho dữ liệu nhạy cảm trên thiết bị.
    • Dữ liệu được mã hóa bởi hệ thống.
    • Có thể đồng bộ hóa qua iCloud Keychain (nếu người dùng cho phép).
    • Có thể chia sẻ giữa các ứng dụng của cùng một nhà phát triển.
  • Nhược điểm:
    • API gốc rất phức tạp và khó sử dụng trực tiếp.
    • Chỉ phù hợp cho việc lưu trữ lượng nhỏ dữ liệu.
    • Không dùng cho dữ liệu ứng dụng thông thường.

Keychain là công cụ bắt buộc khi ứng dụng của bạn cần xử lý thông tin nhạy cảm.

So sánh tổng quan: UserDefaults, Core Data, và Keychain

Để dễ hình dung, hãy cùng xem bảng so sánh nhanh các đặc điểm chính của ba cơ chế lưu trữ này:

Đặc điểm UserDefaults Core Data Keychain Services
Mục đích chính Lưu trữ cài đặt, tùy chọn, trạng thái đơn giản Quản lý đồ thị đối tượng phức tạp, dữ liệu có cấu trúc Lưu trữ dữ liệu nhạy cảm (mật khẩu, token) một cách an toàn
Loại & Kích thước dữ liệu Dữ liệu đơn giản (String, Int, Bool, Data, Array, Dictionary), lượng nhỏ Đối tượng có cấu trúc, quan hệ phức tạp, lượng lớn Dữ liệu nhạy cảm, lượng rất nhỏ
Độ phức tạp Rất thấp Cao (cần hiểu về Context, Model, Fetching) API gốc phức tạp (đơn giản hơn với các thư viện wrapper)
Độ bảo mật Thấp (lưu plain text/plist) Trung bình (thường dùng SQLite, cần mã hóa nếu muốn bảo mật cao hơn) Cao (được mã hóa bởi hệ thống)
Truy vấn & Quan hệ Không hỗ trợ quan hệ, truy vấn chỉ theo key Mạnh mẽ cho truy vấn phức tạp, hỗ trợ quan hệ Truy vấn theo thuộc tính (service, account), không hỗ trợ quan hệ phức tạp
Thường sử dụng với Cài đặt màn hình Settings, các tùy chọn UI Ứng dụng quản lý dữ liệu (ghi chú, danh sách việc cần làm, cửa hàng), cache ngoại tuyến Lưu thông tin đăng nhập, API keys

Chọn lựa công cụ phù hợp

Việc lựa chọn cơ chế lưu trữ phụ thuộc hoàn toàn vào loại dữ liệu bạn cần lưu và mục đích sử dụng:

  • Đối với các cài đặt ứng dụng đơn giản, tùy chọn người dùng, hoặc một vài giá trị nhỏ không cần bảo mật cao, hãy sử dụng UserDefaults vì sự tiện lợi của nó.
  • Đối với dữ liệu có cấu trúc phức tạp, cần quản lý quan hệ giữa các đối tượng, truy vấn mạnh mẽ, hoặc lưu trữ lượng dữ liệu lớn, Core Data là lựa chọn hàng đầu được Apple khuyến nghị.
  • Đối với bất kỳ thông tin nào cần được bảo vệ khỏi truy cập trái phép, như mật khẩu, token xác thực, hãy luôn sử dụng Keychain Services.

Trong nhiều ứng dụng thực tế, bạn sẽ cần sử dụng kết hợp cả ba công cụ này. Ví dụ: lưu trạng thái “đã giới thiệu tính năng X” bằng UserDefaults, lưu danh sách sản phẩm yêu thích bằng Core Data, và lưu token đăng nhập API bằng Keychain.

Lời kết

Hiểu rõ và sử dụng thành thạo UserDefaults, Core Data, và Keychain là những kỹ năng nền tảng cực kỳ quan trọng cho mọi nhà phát triển iOS. Mỗi công cụ giải quyết một nhu cầu cụ thể và việc lựa chọn đúng sẽ giúp ứng dụng của bạn hiệu quả, an toàn và dễ bảo trì hơn.

Đây là một bước tiến quan trọng trên lộ trình của bạn. Ở các bài viết tiếp theo, chúng ta sẽ tiếp tục khám phá những khía cạnh sâu hơn trong phát triển ứng dụng iOS. Hẹn gặp lại!

Chỉ mục