The power of UserDefaults in Swift

Có sẵn kể từ lần phát hành iOS SDK đầu tiên, UserDefaults API thoạt nhìn có vẻ vừa đơn giản vừa có phần hạn chế. Mặc dù nó cung cấp một cách dễ sử dụng để lưu trữ các giá trị, cách nó lưu trữ các giá trị đó trong một tệp plist có thể khiến nó không thực tế đối với các bộ dữ liệu lớn hơn - dẫn đến nhiều nhà phát triển loại bỏ nó để ủng hộ cơ sở dữ liệu đầy đủ tính năng hơn và có nhiều giải pháp tùy chỉnh hơn. Tuy nhiên thực tế UserDefaults vượt xa hơn cả việc lưu trữ và load các dữ liệu cơ bản. Trong bài viết này, ta sẽ tìm hiểu sức mạnh của UserDefaults và làm thế nào để ta sử dụng nó một cách hợp lý trong các ứng dụng của chúng ta.

Database or not?

Thông thường UserDefaults được sử dụng để thay thế cho giải pháp cơ sở dữ liệu - chẳng hạn như CoreData hoặc SQLite. Mặc dù đúng là UserDefaults API có thể hoạt động như một cơ sở dữ liệu, nhưng trường hợp sử dụng chính của nó tập trung nhiều hơn vào các giá trị liên quan đến user preferences - Khi nhìn kỹ hơn vào chức năng khác nhau của nó và cách nó tích hợp với hệ thống, làm cho nó trông giống như một cơ sở dữ liệu hạn chế và giống như một focused API giúp hệ thống hoạt động tốt. Ta hãy bắt đầu với một ví dụ , trong đó chúng ta xây dựng một ThemeController chịu trách nhiệm lưu trữ theme hiện tại để app có thể sử dụng để hiển thị. Vì đây là thứ mà người dùng có thể tự chọn, nên rất có ý nghĩa khi coi nó là user preferences và lưu trữ giá trị của nó trong UserDefaults. Để làm điều đó, chúng tôi sẽ đưa một instance vào ThemeController và sử dụng nó để lưu và load các giá trị Theme - như thế này:

enum Theme: String {
    case light
    case dark
    case black
}

class ThemeController {
    private(set) lazy var currentTheme = loadTheme()
    private let defaults: UserDefaults
    private let defaultsKey = "theme"

    init(defaults: UserDefaults = .standard) {
        self.defaults = defaults
    }

    func changeTheme(to theme: Theme) {
        currentTheme = theme
        defaults.setValue(theme.rawValue, forKey: defaultsKey)
    }

    private func loadTheme() -> Theme {
        let rawValue = defaults.string(forKey: defaultsKey)
        return rawValue.flatMap(Theme.init) ?? .light
    }
}

Việc triển khai ở trên có vẻ đơn giản, nhưng cách chúng ta sử dụng UserDefaults cho loại cài đặt này sẽ sớm mở ra khá nhiều tính năng thú vị - cho cả người dùng và nhà phát triển.

Sharing data within an app group

Điều đầu tiên mà việc sử dụng UserDefaults sẽ cho phép chúng tôi làm, là dễ dàng chia sẻ dữ liệu giữa nhiều ứng dụng và tiện ích mở rộng ứng dụng. Ví dụ: giả sử rằng chúng ta xây dựng phần mềm shipping hai ứng dụng (nếu chúng tôi đang xây dựng ride sharing service đó có thể là một ứng dụng cho tài xế và một ứng dụng cho khách hàng) hoặc một ứng dụng duy nhất chúng tôi xây dựng cũng bao gồm một ứng dụng phần mở rộng với các UI khác nhau. Để cho phép users chỉ phải chọn theme ưa thích của họ một lần - và sau đó làm cho giá trị đó lan truyền trong tất cả giao diện của users - chúng ta có thể thiết lập một bộ mặc định. Ví dụ: nếu chúng ta xây dựng hai ứng dụng nằm trong cùng một nhóm ứng dụng, thì chúng tôi có thể tạo một instance UserDefaults với tên bộ phù hợp với app group’s identifier của chúng ta:

extension UserDefaults {
    static var shared: UserDefaults {
        return UserDefaults(suiteName: "group.johnsundell.app")!
    }
}

Một tùy chọn khác là chỉ cần thêm bộ nhóm ứng dụng của chúng ta vào standard defaults - tạo một instance UserDefaults kết hợp, như thế này:

extension UserDefaults {
    static var shared: UserDefaults {
        let combined = UserDefaults.standard
        combined.addSuite(named: "group.johnsundell.app")
        return combined
    }
}

Sự khác biệt giữa hai tùy chọn ở trên là khi dựa vào đối tượng UserDefaults trên bộ mặc định tiêu chuẩn (như chúng ta làm trong ví dụ  cuối), các giá trị trong mặc định tiêu chuẩn sẽ luôn ghi đè lên các giá trị từ bộ chia sẻ của chúng ta. Điều đó vừa có thể hữu ích (trong trường hợp chúng tôi muốn kích hoạt local overrides trên cơ sở mỗi ứng dụng), nhưng cũng có thể khiến việc truyền các shared settings trở nên khó khăn hơn. Bất kể cách tiếp cận nào chúng ta thực hiện ở trên, tất cả những gì chúng ta phải làm là thay thế .standard bằng. Shared trong trình khởi tạo ThemeController của chúng ta:

class ThemeController {
    ...

    init(defaults: UserDefaults = .shared) {
        self.defaults = defaults
    }

    ...
}

UserDefaults cung cấp cho chúng ta API đồng bộ 100%, mặc dù các thay đổi được truyền đến nhiều ứng dụng hoặc extension không đồng bộ ở background. Điều đó cho phép mã cục bộ của chúng ta tiếp tục ngay lập tức sau khi cập nhật giá trị mà không làm giảm hiệu suất trong khi chờ tất cả các instances được cập nhật. Các giá trị được lưu trữ theo cách này được duy trì cho đến khi tất cả các ứng dụng trong nhóm ứng dụng đã bị xóa khỏi thiết bị người dùng.

Overriding values at launch

UserDefaults tự động parse mọi arguments (bao gồm cả launch arguments) được truyền vào ứng dụng, và sử dụng các giá trị đó như local overrides. Ta sử dụng Xcode’s scheme editor (Product > Scheme > Edit Scheme...) để dễ dàng customize theme mà bạn sử dụng trong app bằng cách thêm -theme argument, ta cũng có thể enable nó trong UI test. Ví dụ: Chúng ta muốn viết test để xác minh rằng settings screen’s theme picker của chúng ta hiển thị chính xác theme hiện tại mà ta đã chọn. Để làm điều đó, tất cả những gì chúng ta phải làm là chuyển theme làm launch argument cho XCUIApplication, và sau đó xác minh rằng theme chúng ta đã thực sự được đánh dấu là theme đã chọn trong UI - bằng cách sử dụng accessibility identifiers, như thế này :

class ThemingUITests: XCTestCase {
    func testThemePickerShowingCurrentTheme() {
        let app = XCUIApplication()
        app.launchArguments = ["-theme", "dark"]
        app.launch()

        // Querying our theme picker table view by its
        // accessibility identifier.
        let picker = app.tables["Theme.Picker"]

        // Here we give each cell a different accessibility
        // identifier both depending on what theme it represents,
        // and also whether or not it's selected.
        let cells = (
            light: picker.cells["Theme.Light"],
            dark: picker.cells["Theme.Dark.Selected"],
            black: picker.cells["Theme.Black"]
        )

        XCTAssertTrue(cells.light.exists)
        XCTAssertTrue(cells.dark.exists)
        XCTAssertTrue(cells.black.exists)
    }
}

Khả năng dễ dàng cấu hình các khía cạnh khác nhau của ứng dụng thực sự có thể là một công cụ tăng năng suất lớn cho cả việc viết test và trong khi debug - và launch arguments có thể là một cách tuyệt vời để đạt được điều đó, đặc biệt là cách UserDefaults tự động thực hiện tất cả các phân tích và overide giá trị. Các giá trị được overide theo cách này sẽ không ảnh hưởng đến các giá trị đã được duy trì trước đó, giúp dễ dàng quay lại trạng thái trước đó trong ứng dụng của chúng ta - bằng cách xóa các launch arguments.

Mock-free tests

Tiếp tục chủ đề test, một cách hữu ích khác để sử dụng bộ UserDefaults tùy chỉnh có thể dễ dàng đơn vị mã kiểm tra vẫn tồn tại dữ liệu, mà không gây ra tình trạng lộn xộn hoặc kết quả không thể đoán trước.

Giả sử chúng ta muốn viết một đoạn test xác minh rằng việc gọi phương thức changeTheme trên ThemeController của chúng ta cập nhật chính xác theme hiện tại. Một ý tưởng ban đầu có thể chỉ đơn giản là tạo một instance của ThemeController, gọi phương thức được đề cập và sau đó xác minh kết quả - như thế này:

class ThemeControllerTests: XCTestCase {
    func testChangingTheme() {
        let controller = ThemeController()
        controller.changeTheme(to: .dark)
        XCTAssertEqual(controller.currentTheme, .dark)
    }

Đoạn test trên hoạt động và sẽ vượt qua thành công. Tuy nhiên, sau lần đầu tiên nó chạy - nó không thực sự kiểm tra bất cứ thứ gì. Vì chúng tôi vẫn duy trì theme đã chọn, đoạn test trên tiếp tục vượt qua ngay cả khi chúng ta xóa cuộc gọi đến ChangeTheme. Xác minh trạng thái ban đầu của chúng ta thực sự quan trọng để viết các bài test mạnh mẽ hơn, dễ bảo trì hơn. Ta sẽ thêm một xác nhận thứ hai xác minh rằng theme ban đầu là những gì chúng ta mong đợi, ngay sau khi chúng ta tạo phiên bản ThemeController của mình.

class ThemeControllerTests: XCTestCase {
    func testChangingTheme() {
        let controller = ThemeController()
        XCTAssertEqual(controller.currentTheme, .light)

        controller.changeTheme(to: .dark)
        XCTAssertEqual(controller.currentTheme, .dark)
    }
}

Với đoạn test bổ sung, test của chúng ta sẽ bắt đầu thất bại - đó là một điều tốt, vì nó sẽ nhắc chúng ta cải thiện nó. Điều chúng ta cần làm là đảm bảo rằng mọi giá trị theme vẫn tồn tại sẽ bị xóa trước khi chạy test của chúng ta, nhưng vì chúng ta đang sử dụng UserDefaults, chúng ta sẽ có thể giữ test của chúng mock-free trong khi vẫn giải quyết được vấn đề tồn tại. Để làm điều đó, trước tiên, chúng ta sẽ thêm một extension trên UserDefaults, điều này sẽ cho phép chúng ta dễ dàng tạo một instance mà các giá trị còn tồn tại của nó đã hoàn toàn bị xoá. Chúng ta sẽ sử dụng cùng một trình khởi tạo dựa trên tên bộ mà trước đây chúng tôi đã sử dụng để chia sẻ giá trị giữa các ứng dụng khác nhau. Nhưng thay vì sử dụng mã định danh của nhóm ứng dụng, chúng tôi sẽ dựa vào mã định danh của tên file và test function mà instance sẽ được sử dụng. Cuối cùng, chúng tôi gọi removePersistentDomain trên đối tượng mặc định của chúng tôi để xóa sự tồn tại của nó - như thế này:

extension UserDefaults {
    static func makeClearedInstance(
        for functionName: StaticString = #function,
        inFile fileName: StaticString = #file
    ) -> UserDefaults {
        let className = "\(fileName)".split(separator: ".")[0]
        let testName = "\(functionName)".split(separator: "(")[0]
        let suiteName = "com.johnsundell.test.\(className).\(testName)"

        let defaults = self.init(suiteName: suiteName)!
        defaults.removePersistentDomain(forName: suiteName)
        return defaults
    }
}

Với các cách trên, hiện tại chúng tôi có thể cập nhật test của mình để pass qua lần nữa, nhưng lần này bằng cách thực sự xác minh chức năng của chúng ta, thay vì dựa vào bất kỳ giá trị nào được duy trì trước đó:

class ThemeControllerTests: XCTestCase {
    func testChangingTheme() {
        let controller = ThemeController(defaults: .makeClearedInstance())
        XCTAssertEqual(controller.currentTheme, .light)

        controller.changeTheme(to: .dark)
        XCTAssertEqual(controller.currentTheme, .dark)
    }
}

Conclusion

UserDefaults khá mạnh mặc dù nó không nên được coi là một giải pháp cơ sở dữ liệu hoàn chỉnh, nên sử dụng nó cho các giá trị cài đặt đơn giản - như theme và các tùy chọn khác - có thể mở khóa một số tính năng khác nhau có thể chứng minh nó cực kỳ hữu ích khi testing và debug. Vậy câu hỏi đặt ra là khi nào thì một trường hợp sử dụng đã vượt quá giới hạn của UserDefaults? Đối với ta, tất cả đều thuộc về kích thước dự kiến của dữ liệu được đề cập. Đây có thể là một Bool, Int hoặc String đơn giản - hay chúng ta đang nói về một mảng các đối tượng có thể dễ dàng tăng một số bậc độ lớn tùy thuộc vào người dùng? UserDefaults là tuyệt vời, miễn là kích thước của tập dữ liệu có thể được giới hạn, và khi điều đó không còn đúng nữa thì đó là thời gian để ta có thể tìm một giải pháp khác. Hy vọng bài viết sẽ có ích với các bạn.

Reference: https://www.swiftbysundell.com/posts/the-power-of-userdefaults-in-swift