cover

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

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

2024-01-25

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

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

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

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

UserDefaults 存储的 defaults 对象必须是 PropertyList 所支持的类型,即 NSDataNSStringNSNumberNSDateNSArrayNSDictionary。如果要存储其他类型的对象,需要通过 NSKeyedArchiver 进行归档后以 NSData 实例的形式进行存储。UserDefaults 类默认提供了读取 Float, Double, Int, BooleanURL 类型数据的方法。下面是使用 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()
}

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

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

应用沙盒是一种访问控制技术,其主要功能是通过限制应用程序在运行时能访问的特权集来减少恶意程序对系统的危害。当用户首次启动应用程序时,系统会在 ~/Library/Containers 中创建该应用程序的沙盒容器文件夹,应用程序只能读写该文件夹中的内容,其他应用程序只有在获得了用户的主动授权后才能访问该文件夹。在沙盒文件夹中还会包含一些被解析为常见用户文件夹(如 ~/Downloads~/Pictures 等)的符号链接,但是应用程序在访问这些符号连接之前依旧需要得到用户的许可。

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

import Foundation

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

使用 PropertyList 进行数据持久化

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

使用 PropertyList 能够存储 Swift 中的基础数据类型(如:ArrayDictionaryStringIntDouble)以及 Foundation 库中的基础数据类型(如 DateData)。如果希望使用 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()
}

使用 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()
}

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

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

使用 SQLite 数据库进行存储

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

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

以下以 SQLite.swift 为例演示如何进行数据持久化操作:

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] 能够实现 SQLite3 数据库的常见操作,但是这种使用方式更接近底层的使用方法,如果没有特殊的需求,并不推荐使用。

在使用进行 iOS、macOS 应用开发时,可以使用 CoreData 或者 SwiftData 以面向对象的方式使用 SQLite3 进行存储。

使用 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)
  }
}

使用 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()
  }
}

使用 SwiftData 进行存储

SwiftData 是苹果最新推出的用于管理应用数据的 ORM 框架。SwiftData 是在 CoreData 的基础上实现的,相较于 CoreDataSwiftData 提供了更易用的接口以及和 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])
}