PrefsKit

PrefsKit

Xcode 16 License: MIT

一个现代 Swift 库,用于读取和写入应用首选项

目录

快速开始

  1. 将 PrefsKit 添加到你的应用或包
  2. 创建一个模式,定义后端存储和首选项键/值类型。
    • @PrefsSchema 特性应用于类。
    • 使用相应的 @Storage@StorageMode 特性定义你的 storagestorageMode
    • 使用 @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]?
    }

提示

有关可用存储值类型的列表,请参阅 存储值类型

  1. 在适当的作用域中实例化该类。如果你正在定义应用程序首选项,则 App 结构体是一个很好的存储位置。它可以传递到环境中,以便任何子视图都可以访问它。
    struct MyApp: App {
        @State private var prefs = Prefs()
        
        var body: some Scene {
            WindowGroup {
                ContentView()
                    .environment(prefs)
            }
        }
    }
  2. 该类是隐式 @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])的访问是一项高级功能,应谨慎使用。有关更多详细信息,请参阅 混合值类型集合

存储类型强制转换和基本原子值转换

库中还提供了各种常见的类型转换。

注意

可以使用许多非原子类型,例如固定宽度整数(UInt8Int32 等),但必须提供显式编码策略。这是必需的(而不是自动的),以便对值的底层存储类型没有歧义。

将二进制整数类型存储为原子 Int
@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?
}
将固定宽度整数类型存储为原子 String
@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 溢出并导致无法存储值。

Bool 存储为 Int

将布尔值存储为整数(10

@PrefsSchema final class Prefs {
    @Pref(coding: .boolAsInt()) var foo: Bool?
}
Bool 存储为 String

将布尔值存储为字符串可以对存储语义进行细粒度控制

@PrefsSchema final class Prefs {
    @Pref(coding: .boolAsString(.trueFalse(.lowercase))) var foo: Bool?
}
Date 存储为 ISO-8601 String
@PrefsSchema final class Prefs {
    @Pref(coding: .iso8601DateString) var date: Date?
}
URL 存储为 String
@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

允许使用 RawRepresentable 类型,其 RawValue 是支持的原子存储值类型之一。

enum Fruit: String {
    case apple, banana, orange
}
  1. 使用便利宏

    @PrefsSchema final class Prefs {
        @RawRepresentablePref var fruit: Fruit?
    }
  2. 使用 Pref(coding:) 宏,该宏还允许链式调用编码策略。

    由于 Swift 宏类型系统限制,基本编码策略必须通过在其具体类型上使用静态构造器来指定

    @PrefsSchema final class Prefs {
        @Pref(coding: Fruit.rawRepresentablePrefsCoding) var fruit: Fruit?
    }
[RawRepresentable]

可以存储 RawRepresentable 类型的数组,其 RawValue 是支持的原子存储值类型之一。

由于 Swift 宏类型系统限制,基本编码策略必须通过在其具体类型上使用静态构造器来指定

@PrefsSchema final class Prefs {
    @Pref(coding: [Fruit].rawRepresentableArrayPrefsCoding) var fruit: [Fruit]?
}
[String: RawRepresentable]

可以存储以 String 为键的字典,其值是 RawRepresentable 类型,其 RawValue 是支持的原子存储值类型之一。

由于 Swift 宏类型系统限制,基本编码策略必须通过在其具体类型上使用静态构造器来指定

@PrefsSchema final class Prefs {
    @Pref(coding: [String: Fruit].rawRepresentableDictionaryPrefsCoding)
    var fruit: [String: Fruit]?
}

Codable

有几种语法选项可用于使用 DataString 原始存储将任何 Codable 类型编码和解码为 JSON。

struct Device: Codable {
    var name: String
    var manufacturer: String
}
  1. 使用便利宏

    @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?
    }
  2. 使用 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]

可以存储 Codable 类型的数组,使用 DataString 原始存储将其编码为 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]

可以存储以 String 为键的字典,其值是 Codable 类型,使用 DataString 原始存储将其编码为 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) { /* ... */ }
}
  1. 简单的临时编码逻辑可以内联完成
    @PrefsSchema final class Prefs {
        @Pref(encode: { $0.value }, decode: { MyType(value: $0) })
        var foo: MyType?
    }
  2. 如果编码实现更复杂和/或将在多个首选项键中重用,则可以通过创建符合 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 存储后端时,这一点更为重要,因为如果它们是非原子值类型,则它不会将值类型提交到持久系统存储。

提示

作为直接访问混合类型集合的替代方案,请考虑创建可以通过 @PrefCodable 类型的支持序列化它们的 Codable 类型。

导入和导出存储

所有首选项存储后端都可以从各种文件和数据格式导入和导出。

PrefsKit 附带对两种序列化格式的支持

导入存储

序列化数据可以从三种不同的重载中导入

有两种将数据导入到具体 PrefsStorage 的主要机制

1. 在存储初始化时

某些首选项存储后端支持通过 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())
    }
}

2. 在存储对象的生命周期内

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)

导出存储

序列化数据可以导出到三种不同的重载中的任何一种

可以在存储对象的生命周期内的任何时间导出存储内容。

@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 导入/导出格式,但可以实现自定义格式。

格式采用处理或转换值类型的策略。PrefsKit 提供了几种基本策略,但可以实现自定义策略。

自定义存储后端

虽然 PrefsKit 提供了涵盖绝大多数用例(字典和 UserDefaults)的具体存储后端,但可以通过遵循 PrefsStorage 协议来实现自定义后端。

此外

使用 Actor

由于 @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 的未来版本中更改。

常见问题

Swift 包管理器 (SPM)

使用 https://github.com/orchetect/PrefsKit 作为 URL 将包添加到你的项目或 Swift 包。

请注意,PrefsKit 使用了 Swift 宏,因此,Xcode 将提示你允许此包的宏。每当有新版本的包可用并且你更新到它时,它都会再次询问。

作者

由一群穿着风衣并自称 @orchetect 的 🐹 仓鼠编写。

许可证

在 MIT 许可证下获得许可。有关详细信息,请参阅 LICENSE

贡献

欢迎贡献。欢迎发布 Issue 进行讨论。