Lộ trình học Lập trình viên iOS 2025: Bắt Đầu Với SQLite

Chào mừng các bạn quay trở lại với loạt bài viết về Lộ trình học Lập trình viên iOS 2025! Sau khi khám phá các tùy chọn lưu trữ dữ liệu đơn giản như UserDefaults và Hệ Thống Tệp Tin, và làm quen với khả năng mạnh mẽ của Core Data, hôm nay chúng ta sẽ lặn sâu vào một giải pháp lưu trữ dữ liệu bền vững phổ biến và linh hoạt khác trên iOS: SQLite.

Nếu ứng dụng của bạn cần quản lý một lượng lớn dữ liệu có cấu trúc, thực hiện các truy vấn phức tạp, hoặc đơn giản là bạn muốn làm việc với một cơ sở dữ liệu quan hệ (relational database) quen thuộc, SQLite là một lựa chọn tuyệt vời đáng để cân nhắc. Đối với một lập trình viên iOS, việc hiểu và sử dụng SQLite là một kỹ năng quý báu, mở ra nhiều khả năng cho ứng dụng của bạn.

Trong bài viết này, chúng ta sẽ cùng nhau tìm hiểu SQLite là gì, tại sao nó phù hợp với iOS, và quan trọng nhất là làm thế nào để bắt đầu sử dụng nó trong các dự án Swift của bạn.

SQLite Là Gì?

SQLite là một thư viện C triển khai công cụ cơ sở dữ liệu SQL nhỏ, nhanh, khép kín, có độ tin cậy cao và đầy đủ tính năng. Điểm đặc biệt của SQLite là nó “serverless” (không máy chủ) và “self-contained” (khép kín). Điều này có nghĩa là không cần cài đặt máy chủ cơ sở dữ liệu riêng biệt (như MySQL, PostgreSQL), mà toàn bộ cơ sở dữ liệu được lưu trữ trong một tệp tin duy nhất trên thiết bị.

Nó là công cụ cơ sở dữ liệu được triển khai rộng rãi nhất trên thế giới, xuất hiện trong vô số ứng dụng, từ trình duyệt web đến điện thoại thông minh (bao gồm cả iOS và Android), và thậm chí cả trong các hệ thống nhúng.

SQLite tuân thủ tiêu chuẩn SQL, cho phép bạn sử dụng các câu lệnh quen thuộc như CREATE TABLE, INSERT, SELECT, UPDATE, DELETE, JOIN, v.v., để quản lý dữ liệu của mình. Nó cũng hỗ trợ đầy đủ các tính năng giao dịch (transactions) với thuộc tính ACID (Atomicity, Consistency, Isolation, Durability), đảm bảo tính toàn vẹn và độ tin cậy của dữ liệu.

Tại Sao Nên Dùng SQLite Trên iOS?

Có nhiều lý do khiến SQLite trở thành lựa chọn hấp dẫn cho việc lưu trữ dữ liệu trên các thiết bị di động:

  • Nhẹ và Hiệu quả: Thư viện SQLite có kích thước rất nhỏ, tiêu thụ ít bộ nhớ và CPU, rất phù hợp với môi trường tài nguyên hạn chế như thiết bị di động.
  • Độ tin cậy cao: Thiết kế đơn giản, mã nguồn được kiểm thử kỹ lưỡng và thuộc tính ACID giúp đảm bảo dữ liệu của bạn an toàn ngay cả khi ứng dụng gặp sự cố hoặc thiết bị tắt nguồn đột ngột.
  • Truy cập Offline: Vì dữ liệu được lưu trữ cục bộ trong một tệp tin, ứng dụng của bạn có thể truy cập và thao tác với dữ liệu ngay cả khi không có kết nối mạng.
  • Quản lý dữ liệu có cấu trúc: SQLite lý tưởng cho việc lưu trữ dữ liệu có mối quan hệ phức tạp hơn so với việc sử dụng tệp tin đơn giản hoặc UserDefaults. Bạn có thể định nghĩa schema rõ ràng và sử dụng các truy vấn SQL mạnh mẽ.
  • Miễn phí và Mã nguồn mở: SQLite là phần mềm thuộc phạm vi công cộng, hoàn toàn miễn phí để sử dụng cho bất kỳ mục đích nào.
  • Tích hợp sẵn trong iOS: Hệ điều hành iOS đã bao gồm thư viện libsqlite3, do đó bạn không cần phải nhúng thêm thư viện C vào dự án của mình (mặc dù bạn sẽ cần một wrapper cho Swift/Objective-C).

Làm Việc Với SQLite Trên iOS: Raw API vs. Wrappers

Như đã đề cập, iOS cung cấp thư viện C libsqlite3 cho phép bạn làm việc trực tiếp với cơ sở dữ liệu SQLite. Tuy nhiên, sử dụng trực tiếp API C này trong mã Swift hoặc Objective-C khá phức tạp, đòi hỏi quản lý con trỏ, xử lý mã lỗi trả về và giải phóng tài nguyên thủ công. Điều này dễ dẫn đến lỗi, đặc biệt là về quản lý bộ nhớ và xử lý đa luồng.

Ví dụ nhỏ về việc mở database bằng C API:


import Foundation
import SQLite3

func openDatabase(path: String) -> OpaquePointer? {
    var db: OpaquePointer?
    if sqlite3_open(path, &db) == SQLITE_OK {
        print("Successfully opened database at \(path)")
        return db
    } else {
        print("Unable to open database.")
        // Handle error: sqlite3_errmsg(db)
        return nil
    }
}

// ... later in your code
// let dbPointer = openDatabase(path: yourDatabaseFilePath)
// ... use the pointer ...
// sqlite3_close(dbPointer) // Remember to close!

Bạn có thể thấy rằng việc xử lý mã lỗi, con trỏ và giải phóng bộ nhớ khá cồng kềnh.

Để đơn giản hóa việc tương tác với SQLite từ Swift (hoặc Objective-C), cộng đồng đã phát triển nhiều thư viện “wrapper”. Các wrapper này đóng gói API C thô bằng các API thân thiện với Swift, giúp bạn làm việc với cơ sở dữ liệu bằng cú pháp hiện đại hơn, dễ đọc, dễ bảo trì và an toàn hơn, đồng thời thường xử lý các vấn đề phức tạp như quản lý bộ nhớ và luồng.

Một số wrapper phổ biến bao gồm:

  • FMDB: Một thư viện Objective-C với hỗ trợ tốt cho Swift thông qua bridging header. Nó là một wrapper mỏng, cung cấp các lớp quanh các hàm C của SQLite. Nó rất phổ biến và được sử dụng rộng rãi.
  • SQLite.swift: Một wrapper được viết hoàn toàn bằng Swift, cung cấp một API type-safe, hiện đại hơn.
  • GRDB.swift: Một wrapper khác được viết bằng Swift, cung cấp nhiều tính năng hơn và hiệu suất tốt.

Trong phạm vi bài viết “Bắt đầu”, chúng ta sẽ tập trung vào FMDB vì nó đơn giản, dễ hiểu và là một trong những lựa chọn wrapper lâu đời nhất trên iOS.

Bắt Đầu Sử Dụng FMDB Với Swift

Để sử dụng FMDB trong dự án Swift của bạn, cách phổ biến nhất hiện nay là sử dụng Swift Package Manager hoặc CocoaPods.

Cài đặt FMDB

Sử dụng Swift Package Manager (khuyến nghị):

Trong Xcode, vào File > Add Packages… Dán URL repository của FMDB: https://github.com/ccgus/fmdb. Chọn phiên bản hoặc rule dependency mong muốn và thêm package vào target ứng dụng của bạn.

Sử dụng CocoaPods:

Nếu bạn đang sử dụng CocoaPods, thêm dòng sau vào Podfile của dự án:


pod 'FMDB'

Sau đó chạy pod install trong terminal.

Sau khi cài đặt, bạn cần import FMDB vào các file Swift mà bạn muốn sử dụng nó:


import FMDB

Lưu ý: Nếu sử dụng CocoaPods và dự án là Swift thuần, đôi khi bạn cần tạo một bridging header để Objective-C code của FMDB có thể được truy cập từ Swift. Tuy nhiên, các phiên bản FMDB gần đây và cách tích hợp qua Pods/SPM thường xử lý điều này tự động.

Các Thao Tác Cơ Bản Với FMDB

Chúng ta sẽ xem xét các bước cơ bản để làm việc với SQLite thông qua FMDB: mở/tạo database, tạo bảng, thêm dữ liệu, truy vấn dữ liệu, cập nhật dữ liệu và xóa dữ liệu.

1. Xác định đường dẫn Database

Bạn nên lưu tệp database trong thư mục Documents của ứng dụng, vì đây là nơi an toàn để lưu trữ dữ liệu người dùng và sẽ được sao lưu bởi iCloud (nếu người dùng bật). Bạn có thể lấy đường dẫn này như sau:


func getDatabasePath() -> String {
    let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
    let databasePath = documentsDirectory.appendingPathComponent("my_database.sqlite").path
    return databasePath
}

2. Mở hoặc Tạo Database

Sử dụng đường dẫn lấy được ở trên để tạo instance FMDatabase và mở kết nối:


func openDatabase() -> FMDatabase? {
    let databasePath = getDatabasePath()
    let db = FMDatabase(path: databasePath)

    if db.open() {
        print("Database opened successfully.")
        return db
    } else {
        print("Unable to open database.")
        print("Error: \(db.lastErrorMessage())")
        return nil
    }
}

Nếu tệp database chưa tồn tại tại đường dẫn này, FMDB sẽ tự động tạo mới khi bạn gọi db.open().

3. Tạo Bảng (Schema Definition)

Sau khi mở database, bạn cần tạo các bảng để lưu trữ dữ liệu. Sử dụng câu lệnh SQL CREATE TABLE. Nên sử dụng CREATE TABLE IF NOT EXISTS để tránh lỗi nếu bảng đã tồn tại.


func createTable(db: FMDatabase) {
    let createTableSQL = """
    CREATE TABLE IF NOT EXISTS tasks (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT,
        is_completed INTEGER
    );
    """ // INTEGER trong SQLite có thể lưu trữ boolean (0 hoặc 1)

    if db.executeStatements(createTableSQL) {
        print("Table 'tasks' created or already exists.")
    } else {
        print("Failed to create table.")
        print("Error: \(db.lastErrorMessage())")
    }
}

Bạn có thể gọi hàm này ngay sau khi mở database.

4. Thêm Dữ liệu (INSERT)

Sử dụng câu lệnh SQL INSERT INTO. Rất quan trọng: Luôn sử dụng placeholders (?) cho các giá trị và truyền các giá trị đó vào các tham số của phương thức executeUpdate. Điều này giúp ngăn chặn các cuộc tấn công SQL Injection và xử lý các ký tự đặc biệt trong dữ liệu.


func addTask(db: FMDatabase, title: String, isCompleted: Bool) {
    let insertSQL = "INSERT INTO tasks (title, is_completed) VALUES (?, ?);"
    let isCompletedInt = isCompleted ? 1 : 0 // SQLite stores boolean as INTEGER (0 or 1)

    if db.executeUpdate(insertSQL, withArgumentsIn: [title, isCompletedInt]) {
        print("Task added successfully.")
    } else {
        print("Failed to add task.")
        print("Error: \(db.lastErrorMessage())")
    }
}

5. Truy vấn Dữ liệu (SELECT)

Sử dụng câu lệnh SQL SELECT. Phương thức executeQuery trả về một FMResultSet, cho phép bạn duyệt qua các hàng kết quả. Sử dụng các phương thức của FMResultSet (như string(forColumn:), int(forColumn:), v.v.) để lấy dữ liệu từ các cột theo tên hoặc chỉ số.


struct Task {
    let id: Int
    let title: String
    let isCompleted: Bool
}

func getAllTasks(db: FMDatabase) -> [Task] {
    var tasks = [Task]()
    let selectSQL = "SELECT id, title, is_completed FROM tasks;"

    if let resultSet = db.executeQuery(selectSQL, withArgumentsIn: []) {
        while resultSet.next() {
            let id = Int(resultSet.int(forColumn: "id"))
            let title = resultSet.string(forColumn: "title") ?? ""
            let isCompleted = resultSet.int(forColumn: "is_completed") == 1

            let task = Task(id: id, title: title, isCompleted: isCompleted)
            tasks.append(task)
        }
        print("Fetched \(tasks.count) tasks.")
    } else {
        print("Failed to fetch tasks.")
        print("Error: \(db.lastErrorMessage())")
    }

    return tasks
}

6. Cập nhật Dữ liệu (UPDATE)

Sử dụng câu lệnh SQL UPDATE với mệnh đề WHERE để chỉ định hàng cần cập nhật. Lại sử dụng placeholders cho giá trị và điều kiện.


func updateTaskCompletedStatus(db: FMDatabase, taskId: Int, isCompleted: Bool) {
    let updateSQL = "UPDATE tasks SET is_completed = ? WHERE id = ?;"
    let isCompletedInt = isCompleted ? 1 : 0

    if db.executeUpdate(updateSQL, withArgumentsIn: [isCompletedInt, taskId]) {
        print("Task with ID \(taskId) updated successfully.")
    } else {
        print("Failed to update task.")
        print("Error: \(db.lastErrorMessage())")
    }
}

7. Xóa Dữ liệu (DELETE)

Sử dụng câu lệnh SQL DELETE FROM với mệnh đề WHERE để chỉ định hàng cần xóa. Sử dụng placeholders cho điều kiện.


func deleteTask(db: FMDatabase, taskId: Int) {
    let deleteSQL = "DELETE FROM tasks WHERE id = ?;"

    if db.executeUpdate(deleteSQL, withArgumentsIn: [taskId]) {
        print("Task with ID \(taskId) deleted successfully.")
    } else {
        print("Failed to delete task.")
        print("Error: \(db.lastErrorMessage())")
    }
}

8. Đóng Database

Sau khi hoàn thành các thao tác, bạn nên đóng kết nối database để giải phóng tài nguyên. Điều này đặc biệt quan trọng khi ứng dụng của bạn chuyển sang trạng thái nền hoặc bị chấm dứt.


func closeDatabase(db: FMDatabase) {
    if db.close() {
        print("Database closed successfully.")
    } else {
        print("Failed to close database.")
        print("Error: \(db.lastErrorMessage())")
    }
}

Quan trọng: Việc quản lý vòng đời của kết nối database (mở khi cần, đóng khi không dùng) và xử lý đồng thời (concurrency) là rất quan trọng. Mở và đóng liên tục có thể gây tốn tài nguyên, trong khi sử dụng một kết nối duy nhất từ nhiều luồng có thể gây ra vấn đề. FMDB cung cấp FMDatabaseQueue để giúp quản lý an toàn các truy cập database từ nhiều luồng.

Sử Dụng FMDatabaseQueue (Quản lý Đồng thời)

SQLite chỉ cho phép một thao tác ghi (write) tại một thời điểm. Nếu nhiều luồng cố gắng truy cập database cùng lúc (đọc hoặc ghi), bạn có thể gặp lỗi hoặc deadlock. FMDatabaseQueue là lớp được FMDB cung cấp để giải quyết vấn đề này. Nó tạo một hàng đợi (queue) cho các thao tác database và thực thi chúng trên một luồng riêng biệt, đảm bảo chỉ có một thao tác được chạy tại một thời điểm, an toàn cho môi trường đa luồng của iOS (xem thêm về Đa luồng trong Swift).

Thay vì sử dụng FMDatabase trực tiếp, bạn nên sử dụng FMDatabaseQueue:


// Tạo queue (chỉ cần làm một lần, giữ tham chiếu đến nó)
lazy var databaseQueue: FMDatabaseQueue? = {
    let databasePath = getDatabasePath()
    let queue = FMDatabaseQueue(path: databasePath)
    return queue
}()

// Thực hiện các thao tác bên trong block của queue
func performDatabaseOperations() {
    databaseQueue?.inDatabase { db in
        // Thực hiện mở/tạo database (không cần gọi open() ở đây, queue tự quản lý)
        createTable(db: db)

        // Thực hiện INSERT, UPDATE, DELETE, SELECT
        addTask(db: db, title: "Learn SQLite", isCompleted: false)
        let tasks = getAllTasks(db: db)
        print(tasks)
        updateTaskCompletedStatus(db: db, taskId: 1, isCompleted: true)
        deleteTask(db: db, taskId: 1)
    }
}

// Khi không cần queue nữa, giải phóng nó
// databaseQueue?.close() // Thường không cần gọi thủ công nếu queue là lazy var của object có vòng đời phù hợp

Sử dụng FMDatabaseQueue giúp đơn giản hóa đáng kể việc quản lý kết nối và đảm bảo an toàn khi làm việc với database từ các luồng khác nhau.

So Sánh Nhanh SQLite và Core Data

SQLite và Core Data đều là các framework/thư viện để lưu trữ dữ liệu bền vững trên iOS, nhưng chúng hoạt động ở các cấp độ khác nhau và có mục đích hơi khác nhau. Dưới đây là bảng so sánh đơn giản:

Đặc điểm SQLite (qua Wrapper như FMDB) Core Data
Cấp độ abstraction Thấp hơn, làm việc trực tiếp với SQL Cao hơn, quản lý biểu đồ đối tượng (object graph)
Yêu cầu kiến thức Hiểu biết về SQL, wrapper library Hiểu biết về các khái niệm Core Data (Context, Entity, Fetch Request, etc.)
Kiểu dữ liệu & Quan hệ Ánh xạ kiểu dữ liệu SQL thủ công, quản lý quan hệ bằng khóa ngoại và JOIN SQL Tích hợp sâu với kiểu dữ liệu Swift/Objective-C, quản lý quan hệ đối tượng tự động
Migration (Thay đổi Schema) Thực hiện thủ công bằng SQL ALTER TABLE Framework hỗ trợ migration tự động hoặc thủ công có cấu trúc
Caching & Performance Phụ thuộc vào wrapper, thường không có caching đối tượng tự động Có hệ thống caching đối tượng mạnh mẽ (Managed Object Context)
Boilerplate Code Có thể có nhiều boilerplate cho mapping từ kết quả SQL sang object Có thể cần setup ban đầu, nhưng ít boilerplate hơn cho CRUD cơ bản sau khi setup
Phức tạp cho trường hợp đơn giản Khá đơn giản nếu chỉ cần CRUD cơ bản Có thể cảm thấy hơi quá mức cho những nhu cầu rất đơn giản
Phức tạp cho trường hợp phức tạp Các truy vấn phức tạp hoặc quản lý quan hệ lớn có thể khó quản lý với SQL thuần Xử lý tốt các biểu đồ đối tượng phức tạp, quan hệ nhiều-nhiều, v.v.

Core Data không phải là một cơ sở dữ liệu, mà là một framework để quản lý biểu đồ đối tượng. Nó có thể sử dụng SQLite làm persistent store ngầm định, nhưng bạn không làm việc trực tiếp với SQL (trừ khi có nhu cầu rất đặc biệt). Nếu bạn đã quen với SQL và cần kiểm soát chi tiết database ở cấp độ thấp, hoặc ứng dụng có nhu cầu rất đơn giản, SQLite có thể là lựa chọn tốt. Nếu bạn cần quản lý một lượng lớn đối tượng lồng nhau, quan hệ phức tạp và cần các tính năng như Undo/Redo, Core Data thường là lựa chọn mạnh mẽ hơn.

Thực Hành Tốt Nhất Khi Sử Dụng SQLite

  • Sử dụng Wrapper: Luôn sử dụng một thư viện wrapper như FMDB hoặc SQLite.swift thay vì làm việc trực tiếp với C API.
  • Sử dụng FMDatabaseQueue: Để xử lý an toàn các truy cập database từ các luồng khác nhau.
  • Sử dụng Placeholders: Luôn sử dụng ? trong câu lệnh SQL cho các giá trị và truyền chúng qua tham số của phương thức execute. Không bao giờ nối chuỗi trực tiếp các giá trị vào câu lệnh SQL.
  • Quản lý vòng đời: Mở database khi cần và đóng nó khi không còn sử dụng. Với FMDatabaseQueue, queue sẽ quản lý kết nối cho bạn.
  • Xử lý lỗi: Luôn kiểm tra kết quả trả về từ các phương thức execute và kiểm tra lastError() hoặc lastErrorMessage() của database khi có lỗi. Điều này liên quan đến việc Xử lý Lỗi Một Cách Duyên dáng trong Swift.
  • Cấu trúc mã: Tổ chức code database của bạn. Một lớp DatabaseManager hoặc UserRepository/TaskRepository có thể giúp tập trung logic database, tách biệt nó khỏi các View Controller hoặc View Models của bạn. Điều này cũng liên quan đến việc lựa chọn kiến trúc ứng dụng.
  • Schema Migration: Khi phiên bản ứng dụng của bạn thay đổi và cần thay đổi cấu trúc bảng, bạn sẽ cần thực hiện migration. Các wrapper phức tạp hơn có thể hỗ trợ điều này, hoặc bạn sẽ phải tự quản lý bằng các câu lệnh SQL ALTER TABLE và theo dõi phiên bản database.

Kết Luận

SQLite là một công cụ mạnh mẽ và linh hoạt để lưu trữ dữ liệu có cấu trúc trên iOS. Với tính chất nhẹ, đáng tin cậy và khả năng truy vấn mạnh mẽ bằng SQL quen thuộc, nó là lựa chọn tuyệt vời cho nhiều loại ứng dụng.

Việc sử dụng các thư viện wrapper như FMDB giúp đơn giản hóa đáng kể quá trình làm việc với SQLite trong Swift, giúp bạn tập trung vào logic ứng dụng thay vì đối phó với sự phức tạp của C API thô. Bằng cách làm theo các bước cơ bản và thực hành tốt nhất đã trình bày, bạn có thể tự tin tích hợp SQLite vào các dự án iOS của mình.

Nắm vững cách làm việc với SQLite không chỉ bổ sung vào bộ kỹ năng lưu trữ dữ liệu của bạn (cùng với UserDefaults, File System, và Core Data) mà còn củng cố sự hiểu biết của bạn về cách các ứng dụng di động quản lý dữ liệu bền vững. Đây là một bước tiến quan trọng trên Lộ trình học Lập trình viên iOS của bạn.

Hãy thử thêm SQLite vào dự án tiếp theo của bạn, tạo một bảng đơn giản, thêm vài dòng dữ liệu và thực hiện một truy vấn. Việc thực hành là cách tốt nhất để nắm vững kỹ năng này.

Hẹn gặp lại các bạn trong các bài viết tiếp theo của loạt bài Lộ trình học Lập trình viên iOS 2025!

Chỉ mục