cover

如何在 iOS 和 macOS 应用中进行数据持久化

本文简单介绍了在 iOS 和 macOS 应用开发中持久化数据的集中方式,包括:UserDefaults、PropertyList、Archiver、SQLite、CoreData 以及 SwiftData。

2024-01-25

具体代码可以在 Github 上查看。

heading

使用 UserDefaults 接口将数据保存到应用程序的 defaults 存储中

在苹果应用开发中,

提供了一个与系统
defaults
存储进行交互的接口。
defaults
存储允许应用程序根据用户的偏好自定义其行为(例如:允许用户指定其首选的测量单位或媒体播放速度)。应用程序可以以键值对的形式存储用户偏好(即
defaults
默认值)。

在应用程序运行时,可以使用

UserDefaults
对象从用户默认数据库中读取应用程序的 defaults 默认值。
UserDefaults
对象会缓存 defaults 默认值,当设置 defaults 默认值时,会同步更改到进程中,然后以异步的方式更新到持久化存储和其他进程中。

UserDefaults
存储的 defaults 对象必须是 PropertyList 所支持的类型,即
NSData
NSString
NSNumber
NSDate
NSArray
NSDictionary
。如果要存储其他类型的对象,需要通过
NSKeyedArchiver
进行归档后以
NSData
实例的形式进行存储。
UserDefaults
类默认提供了读取
Float
,
Double
,
Int
,
Boolean
URL
类型数据的方法。下面是使用
UserDefaults
对象进行数据存取的代码示例:

import Foundation import SwiftUI struct UserSettings { var username: String var notificationsEnabled: Bool } class UserDefaultsManager { static let shared = UserDefaultsManager() private let userDefaults = UserDefaults.standard private let keyUsername = "Username" private let keyNotificationsEnabled = "NotificationsEnabled" func saveUserSettings(_ settings: UserSettings) { userDefaults.set(settings.username, forKey: keyUsername) userDefaults.set(settings.notificationsEnabled, forKey: keyNotificationsEnabled) userDefaults.synchronize() } func loadUserSettings() -> UserSettings? { guard let username = userDefaults.string(forKey: keyUsername) else { return nil } let notificationsEnabled = userDefaults.bool(forKey: keyNotificationsEnabled) return UserSettings(username: username, notificationsEnabled: notificationsEnabled) } } struct UserDefaultsView : View { @State var settings: UserSettings = UserSettings(username: "", notificationsEnabled: false) var body: some View { VStack(alignment: .leading, spacing: 8) { TextField("USERNAME", text: $settings.username).textFieldStyle(RoundedBorderTextFieldStyle()) Toggle(isOn: $settings.notificationsEnabled, label: { Label("NOTIFICATION ENABLED", systemImage: "info.circle") }) HStack { Spacer() Button("UPDATE") { updateUserSettings() } } }.padding().onAppear() { if let settings = UserDefaultsManager.shared.loadUserSettings() { self.settings = settings } else { print("No user settings found.") } } } private func updateUserSettings() { UserDefaultsManager.shared.saveUserSettings(settings) } } #Preview { UserDefaultsView() }
UserDefaults.swift
heading

使用 PropertyList 和 NSKeyedArchiver 将数据持久化到文件系统

在苹果应用中默认会启用 App Sandbox 应用沙盒技术。

应用沙盒是一种访问控制技术,其主要功能是通过限制应用程序在运行时能访问的特权集来减少恶意程序对系统的危害。当用户首次启动应用程序时,系统会在

~/Library/Containers
中创建该应用程序的沙盒容器文件夹,应用程序只能读写该文件夹中的内容,其他应用程序只有在获得了用户的主动授权后才能访问该文件夹。在沙盒文件夹中还会包含一些被解析为常见用户文件夹(如
~/Downloads
~/Pictures
等)的符号链接,但是应用程序在访问这些符号连接之前依旧需要得到用户的许可。

下面是使用 FileManager 获取沙盒文件夹根路径的方法:

import Foundation func getAppDocumentsURL() -> URL { return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! }
Utils.swift
heading

使用 PropertyList 进行数据持久化

PropertyList
属性列表是一种用于存储和表示数据的简单文件格式(类似 XML),常使用
.plist
作为扩展名,其内容以树形结构进行组织。

使用

PropertyList
能够存储 Swift 中的基础数据类型(如:
Array
Dictionary
String
Int
Double
)以及 Foundation 库中的基础数据类型(如
Date
Data
)。如果希望使用
PropertyList
保存自定义类型实例,需要手动实现
Codable
协议,下面是使用 PropertyList 进行持久化的代码示例:

import Foundation import SwiftUI /// Person 和 Address 实现了 Codable 协议 class Address: Codable { var city: String var district: String var id: Int? var line: String var province: String var content: String { get { return "\(province), \(city), \(district), \(line)" } } } class Person: Codable { var name: String var age: Int var address: Address var content: String { get { return "name: \(name), age: \(age), address: \(address.content)" } } } /// 将实现了 Codable 协议的对象数组保存到特定路径 func saveToPropertyList<T: Codable>(data: [T], fileRelativePath: String) -> URL { let documentDirectory = getAppDocumentsURL() let fp = documentDirectory.appendingPathComponent(fileRelativePath) do { let data = try PropertyListEncoder().encode(data) try data.write(to: fp) print("Array/Dictionary successfully written to: \(fp.path)") } catch { print("Error writing array/dictionary to file: \(error.localizedDescription)") } return fp } /// 从特定路径解析实现了 Codable 协议的对象数组 func loadPropertyList<T: Codable>(fileRelativePath: String) -> [T]? { let documentDirectory = getAppDocumentsURL() let fp = documentDirectory.appendingPathComponent(fileRelativePath) do { let data = try Data(contentsOf: fp) let decodedStruct = try PropertyListDecoder().decode([T].self, from: data) print("Array/Dictionary read from file: \(decodedStruct)") return decodedStruct } catch { print("Error reading array/dictionary from file: \(error.localizedDescription)") return nil } } struct PropertyListView: View { var plistDest: String = "example.plist" var archiveDest: String = "example.archive" @State var person = PersonInput() @State var loadedPersons: [Person] = [] var body: some View { VStack { HStack { Button("Save") { let person = Person(data: person); loadedPersons.append(person) let _ = saveToPropertyList(data: loadedPersons, fileRelativePath: plistDest) } Button("Load") { if let persons: [Person] = loadPropertyList(fileRelativePath: plistDest) { loadedPersons = persons } } Spacer() }.padding(8) Divider() VStack { PersonEditorView(person: $person) Divider() Spacer() PersonListView(persons: $loadedPersons) } }.padding() } } #Preview { PropertyListView() }
PropertyList.swift
heading

使用 NSKeyedArchiver 进行数据持久化

NSKeyedArchiver
是 Swift
Foundation
库中提供的一个用于将对象转换为二进制数据的类。当对象被转化为二进制数据后可以保存到文件或者内存中,并在需要时进行还原。

如果要使用

NSKeyedArchiver
转换对象,目标对象需要实现
NSSecureCoding
协议:

  1. func encode(with aCoder: NSCoder)
    :每次编码对象时会调用此方法。在此方法中指定如何归档对象中的每个实例变量;
  2. required convenience init?(coder decoder: NSCoder)
    :每次解码对象时会调用此方法。在此方法中指定如何将二进制数据解码为对象实例。

下面是使用

NSKeyedArchiver
进行数据持久化的代码示例:

import Foundation import SwiftUI class Address: NSObject, NSSecureCoding, Codable { static var supportsSecureCoding: Bool = true var city: String var district: String var line: String var province: String var content: String { get { return "\(province), \(city), \(district), \(line)" } } init(city: String, district: String, line: String, province: String) { self.city = city self.district = district self.line = line self.province = province } // MARK: - NSSecureCoding required convenience init?(coder decoder: NSCoder) { guard let city = decoder.decodeObject(of: NSString.self, forKey: "city") as String? else { return nil } guard let district = decoder.decodeObject(of: NSString.self, forKey: "district") as String? else { return nil } guard let id = decoder.decodeInteger(forKey: "id") as Int? else { return nil } guard let line = decoder.decodeObject(of: NSString.self, forKey: "line") as String? else { return nil } guard let province = decoder.decodeObject(of: NSString.self, forKey: "province") as String? else { return nil } self.init(city: city, district: district, line: line, province: province) } func encode(with aCoder: NSCoder) { aCoder.encode(province, forKey: "province") aCoder.encode(city, forKey: "city") aCoder.encode(district, forKey: "district") aCoder.encode(line, forKey: "line") } } class Person: NSObject, NSSecureCoding { static var supportsSecureCoding: Bool = true var name: String var age: Int var address: Address var content: String { get { return "name: \(name), age: \(age), address: \(address.content)" } } init(address: Address, age: Int, name: String) { self.address = address self.age = age self.name = name } // MARK: - NSSecureCoding required convenience init?(coder decoder: NSCoder) { guard let address = decoder.decodeObject(of: Address.self, forKey: "address") as Address? else { return nil } guard let age = decoder.decodeInteger(forKey: "age") as Int? else { return nil } guard let name = decoder.decodeObject(of: NSString.self, forKey: "name") as String? else { return nil } self.init(address: address, age: age, name: name) } func encode(with aCoder: NSCoder) { aCoder.encode(name, forKey: "name") aCoder.encode(age, forKey: "age") aCoder.encode(address, forKey: "address") } } func archiveData<T: NSObject & NSSecureCoding>(data: [T], fileRelativeURL: String) { let documentsDirectory = getAppDocumentsURL() let destURL = documentsDirectory.appendingPathComponent(fileRelativeURL) do { let data = try NSKeyedArchiver.archivedData(withRootObject: data, requiringSecureCoding: true) try data.write(to: destURL) print("归档成功:\(destURL)") } catch { print("归档失败:\(error)") } } func unarchiveData<T: NSObject & NSSecureCoding>(fileRelativeURL: String) -> [T]? { let documentsDirectory = getAppDocumentsURL() let dataURL = documentsDirectory.appendingPathComponent(fileRelativeURL) if let data = try? Data(contentsOf: dataURL) { do { if let unarchived = try NSKeyedUnarchiver.unarchivedArrayOfObjects(ofClass: T.self, from: data) { return unarchived } else { print("解档失败:未能还原对象") } } catch { print("解档失败:\(error)") } } return nil } struct ArchiverView: View { var plistDest: String = "example.plist" var archiveDest: String = "example.archive" @State var person = PersonInput() @State var loadedPersons: [Person] = [] var body: some View { VStack { HStack { Button("Add") { let person = Person(data: person) loadedPersons.append(person) archiveData(data: loadedPersons, fileRelativeURL: archiveDest) } Button("Load") { if let persons: [Person] = unarchiveData(fileRelativeURL: archiveDest) { loadedPersons = persons } } Spacer() }.padding(8) Divider() VStack { PersonEditorView(person: $person) Divider() Spacer() PersonListView(persons: $loadedPersons) } }.padding() } } #Preview { PropertyListView() }
Archiver.swift
heading

基于数据库(SQLite3)的持久化存储

对于简单的业务逻辑,只需要使用上述方法进行存储即可。但是如果需要处理复杂的对象间关系、或者需要构建复杂的条件查询,最好还是使用数据库的方式进行存储。

heading

使用 SQLite 数据库进行存储

SQLite3 是常见的轻量级数据库。在 XCode 中,可以通过以下两种方式将 SQLite3 导入项目中:

  1. 在项目的
    Frameworks, Libraries and Embeded Content
    中搜索并添加
    libsqlite3.0.tbd
  2. 使用 Swift Package Manager 或其他 Swift 包管理软件添加
    SQLite.swift
    FMDB

以下以

为例演示如何进行数据持久化操作:

import SQLite import SwiftUI // Users 表 struct Users { let db: SQLite.Connection var table: SQLite.Table { get { return Table("persons") } } var age: SQLite.Expression<Int> { get { return Expression<Int>("age") } } var id: SQLite.Expression<Int> { get { return Expression<Int>("id") } } var name: SQLite.Expression<String> { get { return Expression<String>("name") } } var addressId: SQLite.Expression<Int> { get { return Expression<Int>("address_id") } } func createTbl() { do { try db.run( table.create(ifNotExists: true) { table in table.column(id, primaryKey: .autoincrement) table.column(addressId) table.column(age) table.column(name) } ) } catch { print("\(error)") } } func insert(person: Person) -> Int64 { do { return try db.run(table.insert( addressId <- person.address.id!, age <- person.age, name <- person.name )) } catch { print("\(error)") } return -1 } } struct Addresses { let db: SQLite.Connection var table: SQLite.Table { get { return Table("addresses") } } var id: Expression<Int> { get { return Expression<Int>("id") } } var province: Expression<String> { get { return Expression<String>("province") } } var city: Expression<String> { get { return Expression<String>("city") } } var district: Expression<String> { get { return Expression<String>("district") } } var line: Expression<String> { get { return Expression<String>("line") } } func createTbl() { do { try db.run( table.create(ifNotExists: true) { table in table.column(id, primaryKey: .autoincrement) table.column(city) table.column(district) table.column(line) table.column(province) } ) } catch { print("\(error)") } } func insert(address: Address) -> Int64 { do { return try db.run(table.insert( city <- address.city, district <- address.district, line <- address.line, province <- address.province )) } catch { print("\(error)") } return -1 } } class SQLiteManager { private var db: Connection! private var persons: Users! private var addresses: Addresses! init() { let documentsDirectory = getAppDocumentsURL() let path = documentsDirectory.appendingPathComponent("db.sqlite3") do { db = try Connection(path.absoluteString) persons = Users(db: db) addresses = Addresses(db: db) persons.createTbl() addresses.createTbl() } catch { fatalError("Error initializing database: \(error)") } } func insertPerson(person: Person) -> Person { let addressId = addresses.insert(address: person.address) person.address.id = Int(addressId) let personId = persons.insert(person: person) person.id = Int(personId) return person } // 查询所有用户数据 func search(age: Int) -> [Person] { let query = persons.table .join( addresses.table, on: persons.table[persons.addressId] == addresses.table[addresses.id] ) .filter(persons.table[persons.age] >= age) .order(persons.table[persons.name].asc) var result: [Person] = [] do { for row in try db.prepare(query) { let address = Address( id: try row.get(addresses.table[addresses.id]), city: try row.get(addresses.table[addresses.city]), district:try row.get(addresses.table[addresses.district]), line: try row.get(addresses.table[addresses.line]), province: try row.get(addresses.table[addresses.province]) ) let person = Person( id: try row.get(persons.table[persons.id]), address: address, age: try row.get(persons.table[persons.age]), name: try row.get(persons.table[persons.name]) ) result.append(person) } } catch { print("\(error)") } return result } } struct SQLiteView: SwiftUI.View { @State var person: PersonInput = PersonInput() @State var loadedPersons: [Person] = [] let db: SQLiteManager = SQLiteManager() var body: some SwiftUI.View { VStack { HStack { Button("Add") { let person = Person(data: person) let inserted = db.insertPerson(person: person) print("\(inserted.content) \(inserted.id ?? -1)") } Button("Load") { loadedPersons = db.search(age: 0) } Spacer() }.padding(8) Divider() VStack { PersonEditorView(person: $person) Divider() Spacer() PersonListView(persons: $loadedPersons) } } .padding() } } #Preview { SQLiteView() }
SQLite.swift

虽然使用

[SQLite.swift]
能够实现 SQLite3 数据库的常见操作,但是这种使用方式更接近底层的使用方法,如果没有特殊的需求,并不推荐使用。

在使用进行 iOS、macOS 应用开发时,可以使用

CoreData
或者
SwiftData
以面向对象的方式使用 SQLite3 进行存储。

heading

使用 CoreData 进行存储

CoreData 是苹果提供的一套用于管理应用中的数据的 ORM 工具。

相较于前面的持久化方式,CoreData 并不需要开发者手动处理配置存储路径,并且支持使用不同的存储后端,如内存、二进制文件和 sqlite。

相较于使用

SQLite.swift
进行应用数据久化,
CoreData
的抽象着层级更高,可以以面向对象的方式定义数据表结构和关联,以及 CRUD 操作。除此以外,
CoreData
还提供了
Cloudkit
的集成,如果需要使用 iCloud 进行跨设备同步,不需要手动编写相关的代码。

在使用

CoreData
时,首先需要使用图形化的方式配置数据库表结构:

添加 CoreData Model 文件

配置 CoreData Model

在完成配置后,点击

Product > Clean Build Folder
以便加载 Model 定义。

接下来在

App.swift
文件中加载
CoreData
容器并使用
environment
共享:

import CoreData import SwiftUI @main struct playgroundApp: App { let cdContainer = NSPersistentContainer(name: "CoreDataExample") init() { cdContainer.loadPersistentStores { (_, error) in if let error = error { fatalError("Unable to load persistent stores: \(error)") } } } var body: some Scene { WindowGroup { ContentView() } .environment(\.managedObjectContext, cdContainer.viewContext) } }
app_playgroundApp.swift

使用

CoreData
进行数据读写的代码示例如下:

import CoreData import Foundation import SwiftUI /// 向 CoreData 容器中插入一个数据 func addPerson(ctx: NSManagedObjectContext, data: PersonInput) { let address = AddressCD(context: ctx) address.city = data.address.city address.district = data.address.district address.line = data.address.line address.province = data.address.province address.city = data.address.city let person = PersonCD(context: ctx) person.name = data.name person.age = Int64(data.age) person.address = address try? ctx.save() } struct CoreDataView: View { // 从 Environment 中读取 container @Environment(\.managedObjectContext) private var dataController // 使用 FetchRequest 从 container 中读取数据, @FetchRequest(sortDescriptors: []) private var persons: FetchedResults<PersonCD> @State var person = PersonInput() var personsDisplay: [Person] { get { persons.map({ person in let address = Address( city: person.address?.city ?? "N/A", district: person.address?.district ?? "N/A", line: person.address?.line ?? "N/A", province: person.address?.province ?? "N/A" ) let item = Person( address: address, age: Int(person.age), name: person.name ?? "N/A" ) return item }) } } var body: some View { VStack { HStack { Button("Add") { addPerson(ctx: dataController, data: person) } Button("Load") {} Spacer() }.padding(8) Divider() VStack { PersonEditorView(person: $person) Spacer() Divider() PersonListPreview(persons: personsDisplay) } } .padding() } }
CoreData.swift
heading

使用 SwiftData 进行存储

SwiftData 是苹果最新推出的用于管理应用数据的 ORM 框架。

SwiftData
是在
CoreData
的基础上实现的,相较于
CoreData
SwiftData
提供了更易用的接口以及和 SwiftUI 的无缝衔接。但需要注意的是
SwiftData
目前只能在较新的的操作系统上使用(如 iOS 17.0+、iPadOS 17.0+、macOS 14.0+),适用范围受限。

使用

SwiftData
的示例代码如下:

import Foundation import SwiftData import SwiftUI @Model class PersonSD { var age: Int var name: String var address: AddressSD init(age: Int, name: String, address: AddressSD) { self.age = age self.name = name self.address = address } } @Model class AddressSD { var city: String var district: String var line: String var province: String init(data: AddressInput) { self.city = data.city self.district = data.district self.line = data.line self.province = data.province } } struct SwiftDataView: View { @Environment(\.modelContext) private var sdContext @Query() var persons: [PersonSD] @State var person: PersonInput = PersonInput() var personsDisplay: [Person] { get { persons.map({ person in let address = Address( city: person.address.city, district: person.address.district, line: person.address.line, province: person.address.province ) let item = Person( address: address, age: Int(person.age), name: person.name ) return item }) } } var body: some View { VStack { HStack { Button("Add") { addPerson() } Spacer() }.padding(8) Divider() VStack { PersonEditorView(person: $person) Divider() Spacer() PersonListPreview(persons: personsDisplay) } }.padding() } private func addPerson() { let address = AddressSD(data: person.address) sdContext.insert(address) let person = PersonSD(age: person.age, name: person.name, address: address) sdContext.insert(person) do { try sdContext.save() } catch { print("save error \(error)") } } } #Preview { SwiftDataView().modelContainer(for: [PersonSD.self, AddressSD.self]) }
SwiftData.swift