一个现代 Swift 库,用于读取和写入应用首选项
@Observable
和 @Bindable
的,可轻松集成到现代 SwiftUI 应用中@PrefsSchema
特性应用于类。@Storage
和 @StorageMode
特性定义你的 storage
和 storageMode
。@Pref
特性声明单个首选项键及其值类型。值类型可以是 Optional
或具有默认值。import Foundation
import PrefsKit
@PrefsSchema final class Prefs {
@Storage var storage = .userDefaults
@StorageMode var storageMode = .cachedReadStorageWrite
@Pref var foo: String?
@Pref var bar: Int = 123
@Pref var bool: Bool = false
@Pref var stringArray: [String]?
@Pref var intDictionary: [String: Int]?
}
提示
有关可用存储值类型的列表,请参阅 存储值类型。
App
结构体是一个很好的存储位置。它可以传递到环境中,以便任何子视图都可以访问它。struct MyApp: App {
@State private var prefs = Prefs()
var body: some Scene {
WindowGroup {
ContentView()
.environment(prefs)
}
}
}
@Observable
的,因此其属性可以触发 SwiftUI 视图更新,并可以用作绑定。struct ContentView: View {
@Environment(Prefs.self) private var prefs
var body: some View {
Text("String: \(prefs.foo ?? "Not yet set.")")
Text("Int: \(prefs.bar)")
@Bindable var prefs = prefs
Toggle("State", isOn: $prefs.bool)
}
}
以下是支持的原子值类型
原子类型 | 用法 | 描述 |
---|---|---|
String |
@Pref var x: String = "" |
一个原子 String 值 |
Bool |
@Pref var x: Bool = true |
一个原子 Bool 值 |
Int |
@Pref var x: Int = 1 |
一个原子 Int 值 |
Double |
@Pref var x: Double = 1.0 |
一个原子 Double 值 |
Float |
@Pref var x: Float = 1.0 |
一个原子 Float 值 |
Data |
@Pref var x: Data = Data() |
一个原子 Data 值 |
Date |
@Pref var x: Date = Date() |
默认使用 NSDate 编码的原子 Date 值 |
Array | @Pref var x: [Int] = [] |
单一原子值类型的数组 |
Dictionary | @Pref var x: [String: Int] = [:] |
以 String 为键,具有单一原子值类型 |
原始数组 | @RawPref var x: [Any] = [] |
不安全(无类型)元素的数组 |
原始字典 | @RawPref var x: [String: Any] = [:] |
不安全(无类型)元素的字典 |
注意
对原始数组(混合值类型)([Any]
)或字典([String: Any]
)的访问是一项高级功能,应谨慎使用。有关更多详细信息,请参阅 混合值类型集合。
库中还提供了各种常见的类型转换。
注意
可以使用许多非原子类型,例如固定宽度整数(UInt8
、Int32
等),但必须提供显式编码策略。这是必需的(而不是自动的),以便对值的底层存储类型没有歧义。
@PrefsSchema final class Prefs {
@Pref(coding: .uIntAsInt) var foo: UInt?
@Pref(coding: .int8AsInt) var foo: Int8?
@Pref(coding: .uInt8AsInt) var foo: UInt8?
@Pref(coding: .int16AsInt) var foo: Int16?
@Pref(coding: .uInt16AsInt) var foo: UInt16?
@Pref(coding: .int32AsInt) var foo: Int32?
@Pref(coding: .uInt32AsInt) var foo: UInt32?
@Pref(coding: .int64AsInt) var foo: Int64?
@Pref(coding: .uInt64AsInt) var foo: UInt64?
}
@PrefsSchema final class Prefs {
@Pref(coding: .intAsString) var foo: Int?
@Pref(coding: .uIntAsString) var foo: UInt?
@Pref(coding: .int8AsString) var foo: Int8?
@Pref(coding: .uInt8AsString) var foo: UInt8?
@Pref(coding: .int16AsString) var foo: Int16?
@Pref(coding: .uInt16AsString) var foo: UInt16?
@Pref(coding: .int32AsString) var foo: Int32?
@Pref(coding: .uInt32AsString) var foo: UInt32?
@Pref(coding: .int64AsString) var foo: Int64?
@Pref(coding: .uInt64AsString) var foo: UInt64?
}
提示
有时,为了与某些存储要求兼容,可能需要将整数存储为字符串。
出于技术原因,这也可能是更可取的,例如,当 Int
作为存储类型可能溢出时。例如,当使用 UInt64
作为标称类型(或 Swift 6 的新 Int128
/UInt128
)时,当存储为 String
时,它可以避免潜在的 Int
溢出并导致无法存储值。
将布尔值存储为整数(1
或 0
)
@PrefsSchema final class Prefs {
@Pref(coding: .boolAsInt()) var foo: Bool?
}
将布尔值存储为字符串可以对存储语义进行细粒度控制
true
/false
、yes
/no
或自定义字符串TRUE
)、小写 (true
) 或首字母大写 (True
)@PrefsSchema final class Prefs {
@Pref(coding: .boolAsString(.trueFalse(.lowercase))) var foo: Bool?
}
@PrefsSchema final class Prefs {
@Pref(coding: .iso8601DateString) var date: Date?
}
@PrefsSchema final class Prefs {
@Pref(coding: .urlString) var foo: URL?
}
对于更复杂的场景,可以在类初始化时设置 @PrefsSchema
类的存储后端和/或存储模式。
一种方法是通过类型擦除,使用具体类型 AnyPrefsStorage
并传入你选择的存储。
@PrefsSchema final class Prefs {
@Storage var storage: AnyPrefsStorage
@StorageMode var storageMode: PrefsStorageMode
init(storage: any PrefsStorage, storageMode: PrefsStorageMode) {
self.storage = AnyPrefsStorage(storage)
self.storageMode = storageMode
}
}
另一种方法是通过泛型,例如,如果你知道存储后端将始终是一个字典。
这种方法的好处在于,它可以访问具体存储类型的特定于类型的成员,而不仅仅是协议化的 PrefsStorage
成员。例如,键入为 DictionaryPrefsStorage
会添加将存储内容加载和保存到/从 plist 文件的方法。
@PrefsSchema final class Prefs {
@Storage var storage: DictionaryPrefsStorage
@StorageMode var storageMode: PrefsStorageMode
init(storage: DictionaryPrefsStorage, mode: PrefsStorageMode) {
self.storage = storage
storageMode = mode
}
}
除非另有说明,否则键名是从变量名合成的
// storage key name is "foo"
@Pref var foo: String?
// storage key name is "bar"
@Pref(key: "bar") var foo: String?
允许使用 RawRepresentable
类型,其 RawValue
是支持的原子存储值类型之一。
enum Fruit: String {
case apple, banana, orange
}
使用便利宏
@PrefsSchema final class Prefs {
@RawRepresentablePref var fruit: Fruit?
}
使用 Pref(coding:)
宏,该宏还允许链式调用编码策略。
由于 Swift 宏类型系统限制,基本编码策略必须通过在其具体类型上使用静态构造器来指定
@PrefsSchema final class Prefs {
@Pref(coding: Fruit.rawRepresentablePrefsCoding) var fruit: Fruit?
}
可以存储 RawRepresentable
类型的数组,其 RawValue
是支持的原子存储值类型之一。
由于 Swift 宏类型系统限制,基本编码策略必须通过在其具体类型上使用静态构造器来指定
@PrefsSchema final class Prefs {
@Pref(coding: [Fruit].rawRepresentableArrayPrefsCoding) var fruit: [Fruit]?
}
可以存储以 String
为键的字典,其值是 RawRepresentable
类型,其 RawValue
是支持的原子存储值类型之一。
由于 Swift 宏类型系统限制,基本编码策略必须通过在其具体类型上使用静态构造器来指定
@PrefsSchema final class Prefs {
@Pref(coding: [String: Fruit].rawRepresentableDictionaryPrefsCoding)
var fruit: [String: Fruit]?
}
有几种语法选项可用于使用 Data
或 String
原始存储将任何 Codable
类型编码和解码为 JSON。
struct Device: Codable {
var name: String
var manufacturer: String
}
使用便利宏
@PrefsSchema final class Prefs {
// encode Device as JSON using Data storage
@JSONDataCodablePref var device: Device?
// encode Device as JSON using String storage
@JSONStringCodablePref var device: Device?
}
使用 Pref(coding:)
宏,该宏还允许链式调用编码策略。
由于 Swift 宏类型系统限制,基本编码策略必须通过在其具体类型上使用静态构造器来指定
@PrefsSchema final class Prefs {
// encode Device as JSON using Data storage
@Pref(coding: Device.jsonDataPrefsCoding) var device: Device?
// encode Device as JSON using String storage
@Pref(coding: Device.jsonStringPrefsCoding) var device: Device?
}
可以存储 Codable
类型的数组,使用 Data
或 String
原始存储将其编码为 JSON。
由于 Swift 宏类型系统限制,基本编码策略必须通过在其具体类型上使用静态构造器来指定
// option #1:
// collection stored in storage as an array of encoded Data or String elements
@PrefsSchema final class Prefs {
// encode a Device array as an array of JSON elements using Data storage
@Pref(coding: [Device].jsonDataArrayPrefsCoding) var device: [Device]?
// encode a Device array as an array of JSON elements using String storage
@Pref(coding: [Device].jsonStringArrayPrefsCoding) var device: [Device]?
}
// option #2:
// collection stored in storage as a single encoded Data blob or String
@PrefsSchema final class Prefs {
// encode a Device array as an array of JSON elements using Data storage
@Pref(coding: [Device].jsonDataPrefsCoding) var device: [Device]?
// encode a Device array as an array of JSON elements using String storage
@Pref(coding: [Device].jsonStringPrefsCoding) var device: [Device]?
}
可以存储以 String
为键的字典,其值是 Codable
类型,使用 Data
或 String
原始存储将其编码为 JSON。
由于 Swift 宏类型系统限制,基本编码策略必须通过在其具体类型上使用静态构造器来指定
@PrefsSchema final class Prefs {
// encode a Device array as an array of JSON elements using Data storage
@Pref(coding: [String: Device].jsonDataDictionaryPrefsCoding)
var device: [String: Device]?
// encode a Device array as an array of JSON elements using String storage
@Pref(coding: [String: Device].jsonStringDictionaryPrefsCoding)
var device: [String: Device]?
}
对于更高级的编码要求,PrefsKit 支持定义自定义的值 ←→ 存储值编码实现。
struct MyType {
var value: String
init?(value: String) { /* ... */ }
}
@PrefsSchema final class Prefs {
@Pref(encode: { $0.value }, decode: { MyType(value: $0) })
var foo: MyType?
}
PrefsCodable
的新类型来定义一次,然后使用 coding
参数将其提供给每个 @Pref
声明@PrefsSchema final class Prefs {
// coding instance may be supplied directly, or...
@Pref(coding: MyTypePrefsCoding()) var foo: MyType?
// ...static constructor may be used, if defined (see extension below)
@Pref(coding: .myType) var bar: MyType?
}
// Type defining coding logic
struct MyTypePrefsCoding: PrefsCodable {
func encode(prefsValue: MyType) -> Data? {
prefsValue.value.data(using: .utf8)
}
func decode(prefsValue: Data) -> MyType? {
guard let string = String(data: prefsValue, encoding: .utf8) else { return nil }
return MyType(value: string)
}
}
// Static constructor (for syntactic sugar / convenience)
extension PrefsCodable where Self == MyTypePrefsCoding {
static var myType: MyTypePrefsCoding { MyTypePrefsCoding() }
}
注意
当要编码的类型是以下情况时,定义自定义 PrefsCodable
实现的方法是理想的
如果它是你拥有的自定义类型,并且只有一种编码格式,则另一种方法是使其遵循 Swift 的 Codable
协议,并使用 @JSONDataCodablePref
或 @JSONStringCodablePref
存储它。
对于更复杂的首选项值编码场景,可以串联两个或多个编码策略,以便组合编码/解码过程中的多个步骤。
例如,可以将符合 Codable
的自定义类型首先编码为其数据表示形式,然后压缩,然后编码为 base-64 编码的 String
作为其最终首选项存储格式。当从存储中读取回值时,解码过程自然会以相反的顺序发生。
@PrefsSchema final class Prefs {
@Pref(
coding: MyType
.jsonDataPrefsCoding
.compressedData(algorithm: .lzfse)
.base64DataString()
) var foo: MyType?
}
struct MyType: Codable {
var id: Int
var content: String
}
在复杂的项目中,可能需要使用仅在运行时才已知的首选项键直接访问首选项存储。
可以使用 value(forKey:)
和 setValue(forKey:to:)
方法直接访问 storage 属性。
@PrefsSchema final class Prefs {
// @Pref vars are Observable and Bindable in SwiftUI views
@Pref var foo: Int?
// `storage` access is NOT Observable or Bindable in SwiftUI views
func fruit(name: String) -> String? {
storage.storageValue(forKey: "fruit-\(name)")
}
func setFruit(name: String, to newValue: String?) {
storage.setStorageValue(forKey: "fruit-\(name)", to: newValue)
}
}
注意
直接修改存储不会继承 @Pref
定义的键的 @Observable
行为,这本质上意味着这种类型的访问不能在 SwiftUI Binding 中使用。出于这些原因,理想情况是首选项模式包含在编译时已知的根级别首选项键。
为了确保类型安全,数组(又名 [Any]
)或字典(又名 [String: Any]
)中的混合值类型在首选项模式中以不同的方式处理,使用 @RawPref
类型声明。
PrefsKit 通常将类型限制为安全原子类型,以确保不同存储后端(UserDefaults、plist 等)之间的兼容性,但在需要更大灵活性和更低安全性的边缘情况下可以使用这些原始类型。
对首选项键的原始访问可以用作从旧的首选项存储格式迁移到与 PrefsKit 的 @PrefsSchema
兼容的格式的权宜之计。
@PrefsSchema final class Prefs {
@RawPref var array: [Any]?
@RawPref var defaultedArray: [Any] = ["foo", 123, true]
@RawPref var dict: [String: Any]?
@RawPref var defaultedDict: [String: Any] = ["foo": "string", "bar": 123]
}
重要提示
虽然在使用字典首选项存储后端时可以容纳任何类型,但通常建议将 @RawPref
的使用限制为包含有效原子值类型的数组和字典,以便这些首选项稍后仍然可以序列化。
当使用 UserDefaults 存储后端时,这一点更为重要,因为如果它们是非原子值类型,则它不会将值类型提交到持久系统存储。
提示
作为直接访问混合类型集合的替代方案,请考虑创建可以通过 @Pref
对 Codable
类型的支持序列化它们的 Codable
类型。
所有首选项存储后端都可以从各种文件和数据格式导入和导出。
PrefsKit 附带对两种序列化格式的支持
PrefsStorage
原子值类型进行 1:1 转换PrefsStorage
转换大多数值类型,但需要自定义实现才能转换某些值类型序列化数据可以从三种不同的重载中导入
URL
Data
String
有两种将数据导入到具体 PrefsStorage
的主要机制
某些首选项存储后端支持通过 init(from:format:)
导入。
注意
字典首选项存储支持从初始化器导入,但 UserDefaults 首选项存储不支持。
@PrefsSchema final class Prefs {
@Storage var storage: DictionaryPrefsStorage
@StorageMode var storageMode = .cachedReadStorageWrite
init(plist file: URL) throws {
storage = try DictionaryPrefsStorage(from: file, format: .plist())
}
init(plist data: Data) throws {
storage = try DictionaryPrefsStorage(from: data, format: .plist())
}
init(plist xmlString: String) throws {
storage = try DictionaryPrefsStorage(from: xmlString, format: .plist())
}
}
load(from:format:by:)
方法允许替换现有存储或将其内容合并到现有存储中。
警告
使用这些方法加载数据不会主动更新首选项模式的缓存,因此需要使用 storageOnly
存储模式。
@PrefsSchema final class Prefs {
@Storage var storage = .dictionary
@StorageMode var storageMode = .storageOnly // ⚠️ important for load(...) methods
}
let prefs = Prefs()
// load plist file content from a file on disk
try prefs.storage.load(from: URL(/* ... */), format: .plist(), by: .reinitializing)
// load raw plist file content
try prefs.storage.load(from: Data(/* ... */), format: .plist(), by: .reinitializing)
// load plist content in the form of XML string
try prefs.storage.load(from: /* plist XML string */), format: .plist(), by: .reinitializing)
load(from: [String: any PrefsStorageValue])
或 load(unsafe: [String: Any])
方法允许将字典内容加载到现有存储中。
这些方法允许替换现有存储或将其内容合并到存储中。
可以在存储对象的生命周期内的任何时间从字典导入存储内容。
警告
使用这些方法加载数据不会主动更新首选项模式的缓存,因此需要使用 storageOnly
存储模式。
@PrefsSchema final class Prefs {
@Storage var storage = .dictionary
@StorageMode var storageMode = .storageOnly // ⚠️ important for load(...) methods
}
let prefs = Prefs()
// load stongly-typed content (safe)
let newContent: [String: any PrefsStorageValue] = /* ... */
try prefs.storage.load(from: newContent, by: .updating)
// load dictionary content with values typed as Any if you have
// prior knowledge they are valid prefs storage atomic value types,
// such as if your codebase has an API that gives you plist dictionary contents
// which is 1:1 compatible with `PrefsStorage`
let plistContent: [String: Any] = /* ... */
try prefs.storage.load(unsafe: plistContent, by: .updating)
序列化数据可以导出到三种不同的重载中的任何一种
export(format:, to: URL)
:写入到磁盘上的文件 URL
exportData(format:)
以 Data
形式返回文件内容exportString(format:)
以 String
形式返回文件内容可以在存储对象的生命周期内的任何时间导出存储内容。
@PrefsSchema final class Prefs {
@Storage var storage = .dictionary
@StorageMode var storageMode = .cachedReadStorageWrite
}
let prefs = Prefs()
// export storage as plist file on disk
try prefs.storage.export(format: .plist(), to: URL(/* ... */))
// export storage as raw plist file data
let data = try prefs.storage.exportData(format: .plist())
// export storage as plist file content in the form of XML string
let xmlString = try prefs.storage.exportString(format: .plist())
PrefsKit 提供 plist(属性列表)和 JSON 导入/导出格式,但可以实现自定义格式。
PrefsStorageImportFormat
协议的导入类型PrefsStorageExportFormat
协议的导出类型格式采用处理或转换值类型的策略。PrefsKit 提供了几种基本策略,但可以实现自定义策略。
PrefsStorageImportStrategy
协议的导入策略类型PrefsStorageExportStrategy
协议的导出策略类型虽然 PrefsKit 提供了涵盖绝大多数用例(字典和 UserDefaults)的具体存储后端,但可以通过遵循 PrefsStorage
协议来实现自定义后端。
此外
PrefsStorageInitializable
允许存储从其他存储格式初始化PrefsStorageImportable
允许从其他存储格式导入或合并存储内容PrefsStorageExportable
允许将存储内容导出到其他存储格式由于 @PrefsSchema
的内部协议要求,actor(例如 @MainActor
)无法直接附加到类声明。
@MainActor // <-- ❌ not possible
@PrefsSchema final class Prefs { /* ... */ }
但是,actor 可以附加到单个 @Pref
首选项声明。
@PrefsSchema final class Prefs {
@Storage var storage = .userDefaults
@StorageMode var storageMode = .cachedReadStorageWrite
@MainActor // <-- ✅ possible
@Pref var foo: Int?
@Pref var bar: String?
}
注意
这可能会在 PrefsKit 的未来版本中更改。
为什么使用 PrefsKit?
在你的软件中为用户提供自定义点是提供出色用户体验的基础方式 — 但这不是主要事件。你宁愿将时间和资源投入到开发用户正在自定义的实际功能,而不是处理如何存储和处理这些选项的开销。因此,问题通常通过阻力最小的路径来处理,这通常类似于“只需使用 UserDefaults
并继续”或“@AppStorage
足够好,对吧?”
这种方便的唾手可得的成果的危险在于它随着时间的推移不可避免地产生的技术债务。随着项目增长和变化形态,其需求增加,其自动化测试要求扩大。到那时,代码库的大部分与实现细节(即:UserDefaults
访问)紧密耦合,并且重构以允许模块化首选项变得越来越昂贵。
因此,厌倦了每个项目都采用随意的方法来处理首选项 — 并受到第一方 Apple 包(如 SwiftData)中模式的启发 — 构建了一个一站式解决方案。它简单、强大,并使用现代 Swift 语言特性来允许首选项是声明式的,同时隐藏实现细节,因此你可以继续做重要的事情 - 例如构建用户关心的功能。它可以是极简的,因此易于为小型项目设置,但它也可以扩展以满足需求更大的项目。
为什么不直接使用 @AppStorage
?
第一方提供的 @AppStorage
属性包装器对于不需要强大的存储灵活性或用于集成测试或单元测试的首选项隔离/模拟的小型应用来说是方便且完全可以的。
它在其支持的值类型方面也相当有限。PrefsKit 提供了一个易于使用、可扩展的蓝图,用于定义和使用任何值类型的编码策略。
为什么不直接使用 SwiftData?
SwiftData 更侧重于数据模型和用户文档内容。它需要一些适配和样板代码才能硬塞到应用程序首选项存储的角色中。它还具有一些陡峭的学习曲线,并且可能包含比必要的更多功能。
PrefsKit 专为首选项存储而构建。
为什么不直接使用 UserDefaults
?
对于小型应用,这种方法可能足够。但是,它形成了与 UserDefaults
作为存储后端的紧密耦合。这意味着无法使用隔离/模拟的首选项轻松执行自动化集成测试。即使采用使用单独的 UserDefaults
套件进行测试的方法,耦合也使得将来更改存储后端更加耗时。
除了易于使用、可扩展的蓝图,用于定义和使用值类型的编码策略之外,PrefsKit 还增加了将来随时替换存储后端的能力。
使用 https://github.com/orchetect/PrefsKit
作为 URL 将包添加到你的项目或 Swift 包。
请注意,PrefsKit 使用了 Swift 宏,因此,Xcode 将提示你允许此包的宏。每当有新版本的包可用并且你更新到它时,它都会再次询问。
由一群穿着风衣并自称 @orchetect 的 🐹 仓鼠编写。
在 MIT 许可证下获得许可。有关详细信息,请参阅 LICENSE。
欢迎贡献。欢迎发布 Issue 进行讨论。