Swift和SwiftUI中Property warppers的使用

阅读时长35分钟Swift5.0开始支持Property warppers属性包装器,顾名思义就是对属性进行包装,给属性附加一段逻辑,同时对这段逻辑操作进行了封装,在背后透明的运行,这样的好处是能大大的增加代码的重用率。目前AppleSwiftUI框架就是用了很多属性包装器如常用的@State@Binding@Appstroe@StateObject@ObserveObject和@EnvironmentObject等来封装属性逻辑并进行监听,当属性发生改变时同步的刷新UI,通过本文你将会学习到一下内容:

  1. 了解SwiftUI原生Property warppers属性包装器的实现逻辑。
  2. 理解Property warppers的本质。
  3. Demo自定义实现Property warppers

初步认识Property warppers

在上面我们已经反复提到property warppers属性包装器是一段附加在属性上的逻辑,这样的好处是能极大的利用代码的复用率,下面我们通过一个例子来感性的认识下。

struct ContentView: View {
    @AppStorage("username") var username: String = "Anonymous"
    var body: some View {
        VStack {
            Text("Welcome, \(username)!")
            Button("Log in") {
                username = "@twostraws"
            }
        }
    }
}

上面的代码中使用了一个SwiftUI中的@AppStorage属性包装器,利用Button来修改属性userName的值,同时@AppStorage附加在属性上的逻辑会将"@twostraws"这个value通过key"username"保存到UserDefaults中,并监听这个value值的改变,当value值改变时,SwiftUI会通知所有持有这个属性值的view进行刷新,故当点击Button时,Text的显示内容会从"Welcome, Anonymous !"变为"Welcome, @twostraws !"

假如没有属性包装器,要实现上面的代码,大致的逻辑则是需要重写uesrName属性的set方法,并在set方法里发出改变的通知,view在收到通知时进行刷新,但是一旦要多个属性需要有上面相同的实现时,就要对每个属性都要进行set方法的重写,并发出通知,这样会导致很多重复的逻辑代码,而通过Property warppers属性包装器则只需在属性前面加上关键字就能对同样的代码在背后进行透明的封装,大大提高代码的重复利用率。

自定义property warppers

主要步骤

  • 自定义一个struct结构体或者class类,并在前面用上@propertyWrapper关键字。
  • 必须有名为wrappedValue的属性,用来告诉swift被附加逻辑包装后的值。
@propertyWrapper struct Capitalized {
    var wrappedValue: String {
        didSet { wrappedValue = wrappedValue.capitalized }
    }
    init(wrappedValue: String) {
        self.wrappedValue = wrappedValue.capitalized
    }
}

代码解读:

这里我们想自定义一个将所有String值进行首字符大写的属性包装器,用struct结构体定义了一个名为Capitalized的属性包装器,定义了一个init初始化方法和wrappedValue属性,并对set方法添加了属性观察,初始化方法中设置wrappedValue为首字符大写,当设置wrappedValue属性值时同样也进行首字符大写操作。

使用:

struct User {
    @Capitalized var firstName: String
    @Capitalized var lastName: String
}

利用@Capitalized在标记firstName属性,这样涉及firstNamelastName属性的读写操作其实是操作对应的属性包装器中的wrappedValue属性,这样就实现了在给firstNamelastName属性赋值后,背后的逻辑已经进行了首字母大写的操作。

// John Appleseed
var user = User(firstName: "john", lastName: "appleseed")
// John Sundell
user.lastName = "sundell"

注意:

  • 当属性包装器的init方法定义为init(wrappedValue:)时,则可以直接给包装的属性赋默认值,例如@Capitalized var name = "Untitled document"
  • Swfit中的属性观察在所有属性完成初始化之前是不会触发的,所以需要显示的定义初始化方法,以便让属性初始化完后能触发属性。

观察属性包装器由于其定义是structclass,所有也能拥有自己的属性,能实现依赖注入,可以实现复杂的封装逻辑

@propertyWrapper struct UserDefaultsBacked {
    let key: String // 内部的属性
    var storage: UserDefaults = .standard // 内部的属性
    var wrappedValue: Value? { // 必须要实现的属性,注意这里是可选项
        get { storage.value(forKey: key) as? Value }
        set { storage.setValue(newValue, forKey: key) }
    }
}

由于Swift中结构体是有一个默认的初始化器,所以初始化时只需初始化key这个属性。

struct SettingsViewModel {
    @UserDefaultsBacked(key: "mark-as-read")  var autoMarkMessagesAsRead
}
    var setModelOne = SettingsViewModel() // key:mark-as-read
    var setModelTwo = SettingsViewModel(autoMarkMessagesAsRead: UserDefaultsBacked(key: "mark-as-blue")) // key:mark-as-blue
    // 由于没有init(wrappedValue:)初始化方法
    // var setModelThree = SettingsViewModel(autoMarkMessagesAsRead:"Mamba")这样的是不行的
    setModelOne.autoMarkMessagesAsRead = "8888"
    setModelTwo.autoMarkMessagesAsRead = "9999"
    print(setModelOne.autoMarkMessagesAsRead) //Optional("8888")
    print(setModelTwo.autoMarkMessagesAsRead) //Optional("9999")

代码解读:

  • 通多对UserDefaultsBacked属性包装器进行泛型定义来存储不同类型的值。
  • UserDefaultsBacked内定义了keystorage属性,其中storage可以让属性包装器实现依赖注入。

虽然上面autoMarkMessagesAsReadnumberOfSearchResultsPerPage二个属性都是非可选的,但是经过@UserDefaultsBacked进行属性包装包装后,其值其实是可选的,因为背后的wrappedValue是可选的,当对应的key没值时取到的是nil,在使用的时候还需要进行解包的操作,这样会很麻烦,解决办法是可以返回一个定义defaultValue的属性值,当key取到的值为时返回这个默认值。

@propertyWrapper struct UserDefaultsBacked {
    var wrappedValue: Value { // 非可选项
        get {
            let value = storage.value(forKey: key) as? Value // 进行可选项绑定判断,为nil则但会defaultValue
            return value ?? defaultValue
        }
        set {
            storage.setValue(newValue, forKey: key)
        }
    }
    private let key: String
    private let defaultValue: Value // 默认值
    private let storage: UserDefaults
    init(wrappedValue defaultValue: Value,
         key: String,
         storage: UserDefaults = .standard) {
        self.defaultValue = defaultValue
        self.key = key
        self.storage = storage
    }
}

使用如下:

struct SettingsViewModel {
    @UserDefaultsBacked(key: "mark-as-read") var autoMarkMessagesAsRead = true // 默认值为true
    @UserDefaultsBacked(key: "search-page-size") var numberOfSearchResultsPerPage = 20 // 默认值为20
}
    var setModelOne = SettingsViewModel() // 因为没有设置值,取到的未nil, 返回默默认值
    var setModelTwo = SettingsViewModel() // 因为没有设置值,取到的未nil, 返回默默认值
    print(setModelOne.autoMarkMessagesAsRead) // true 由于wrappedValue是非可选,打印不需要解包
    print(setModelTwo.numberOfSearchResultsPerPage) // 20 由于wrappedValue是非可选,打印不需要解包

上面的虽然利用默认值解决了取值为可选项时为nil的特殊情况,但是赋值时依然只能是非可选的,但是实际使用的过程中UserDefaults存储的值类型很有可能是可选的,解决办法是让范型value遵守ExpressibleByNilLiteral协议(可以赋值为nil的字面量协议)。

extension UserDefaultsBacked where Value: ExpressibleByNilLiteral {
    init(key: String, storage: UserDefaults = .standard) {
        self.init(wrappedValue: nil, key: key, storage: storage)
    }
}
private protocol AnyOptional {
    var isNil: Bool { get }
}
extension Optional: AnyOptional {
    var isNil: Bool { self == nil }
}
@propertyWrapper struct UserDefaultsBacked {
    var wrappedValue: Value {
        get { ... }
        set {
            if let optional = newValue as? AnyOptional, optional.isNil {
                storage.removeObject(forKey: key)
            } else {
                storage.setValue(newValue, forKey: key)
            }
        }
    }
    ...
}
struct SettingsViewModel {
    @UserDefaultsBacked(key: "mark-as-read") var autoMarkMessagesAsRead = true
    @UserDefaultsBacked(key: "search-page-size")  var numberOfSearchResultsPerPage = 20
    @UserDefaultsBacked(key: "signature") var messageSignature: String?
}

代码解读:

  • 通过让value遵守ExpressibleByNilLiteral协议,可以实现可选项赋值。
  • 当赋值为nil时,则是从UserDefaults删除key所对应的键值,取到的为nil

Projected values

有时候我们不需要直接拿到封装后的值,而是需要拿到属性本身,简而言之就是要引用被封装的属性本身,下面我们通过二个实际开发中使用的例子来进行说明。

Swift中由于没有了像OC那样的宏定义,无法直接通过宏定义配合pch文件来迭代版本,但是Swift提供了条件编译,利用条件编译可以实现版本的控制,例如下面代码在标记为DATABASE_REALM时使用RealmDatabase数据库,否则使用CoreDataDatabase数据库,DATABASE_REALMswift无法通过宏定义,需要在Swift Compiler - Custom Flags > Active Compilation Conditions中进行添加。

class DataBaseFactory {
    func makeDatabase() -> Database {
        #if DATABASE_REALM
        return RealmDatabase()
        #else
        return CoreDataDatabase()
        #endif
    }
}

但是当标记很多时,添加和删除会很麻烦,实际在开发中通常使用静态标记Static flags,即自定义结构体的形式来存储我们的标记flags

struct FeatureFlags {
    let searchEnabled: Bool
    let maximumNumberOfFavorites: Int
    let allowLandscapeMode: Bool
}

通常采用Dic来配置我们FeatureFlagsDic可以是通过后台返回,也可以是本地保存的,下面的代码采用了自动闭包来设置默认值,当Dic中取值失败时,利用自动闭包返回default参数的默认值。

extension FeatureFlags {
    init(dictionary: [String : Any]) {
        searchEnabled = dictionary.value(for: "search", default: false)
        maximumNumberOfFavorites = dictionary.value(for: "favorites", default: 10)
        allowLandscapeMode = dictionary.value(for: "landscape", default: true)
    }
}
private extension Dictionary where Key == String {
    func value(for key: Key,
                  default defaultExpression: @autoclosure () -> V) -> V {
        return (self[key] as? V) ?? defaultExpression()
    }
}

利用FeatureFlags来进行代码的控制。

class FavoritesManager {
    private let featureFlags: FeatureFlags
    init(featureFlags: FeatureFlags) {
        self.featureFlags = featureFlags
    }
    func canUserAddMoreFavorites(_ user: User) -> Bool {
        let maxCount = featureFlags.maximumNumberOfFavorites
        return user.favorites.count < maxCount
    }
}

上面的代码是对flag在开发中的使用的一个介绍,因为下面的例子会用到其中的知识,下面我们利用flagProperty warppers来进行一个实际例子的演练,其中会利用到。

定义一个名为Flag的属性封装器,这里我们用到了projectedValue这个属性,返回的是Flag属性封装器本身,可以理解为对属性封装器的引用,在swfitUI中经常使用,要拿到引用的指针地址需要利用$符号,当然同样可以用在UIKit中。

@propertyWrapper final class Flag {
    var wrappedValue: Value
    let name: String
    fileprivate init(wrappedValue: Value, name: String) {
        self.wrappedValue = wrappedValue
        self.name = name
    }
    var projectedValue: Flag { self }
}

在实际开发中Decodable Flag的值一般来自网络请求服务器返回的数据,让Flag遵循Decodable协议以便直接将后台返回的数据转换成Flag模型,也就是所说的反序列化。

// 定义keys
private struct FlagCodingKey: CodingKey {
    var stringValue: String
    var intValue: Int?

    init(name: String) {
        stringValue = name
    }
    // 下面的初始化器是CodingKey协议必须实现的
    init?(stringValue: String) {
        self.stringValue = stringValue
    }
    init?(intValue: Int) {
        self.intValue = intValue
        self.stringValue = String(intValue)
    }
}
private protocol DecodableFlag {
    typealias Container = KeyedDecodingContainer
    func decodeValue(from container: Container) throws
}
extension FeatureFlags: Decodable {
  // 这个初始化方法之前是编译器默认生成的
    init(from decoder: Decoder) throws {
      // container它本质上是一个特殊的字典,只允许你访问具有特定键的值,把key放进这个dic中
        let container = try decoder.container(keyedBy: FlagCodingKey.self)
     // Mirror可以映射一个对象,方便观察这个对象的属性和值
        for child in Mirror(reflecting: self).children {
            // child.value是映像对象的属性值
            guard let flag = child.value as? DecodableFlag else {
                continue
            }
            try flag.decodeValue(from: container)
        }
    }
}
extension Flag: DecodableFlag where Value: Decodable {
    fileprivate func decodeValue(from container: Container) throws {
        // 注入key值
        let key = FlagCodingKey(name: name)
        if let value = try container.decodeIfPresent(Value.self, forKey: key) {
            wrappedValue = value
        }
    }
}

代码解读:

  • 利用Codekey协议自定义了Decode解码,解码的keyFlagname属性,同时FlagCodingKey采用结构体的方式定义而非枚举的方式。
  • 采用decodeIfPresent方法进行解码,放对应的key不存在或者key对应的值为空时解码返回的是nil对象。
  • 解码的container容器采用的是KeyedEncodingContainer普通的容器。

使用:

首先创建FeatureFlags的静态Static flags,里面的属性采用了@Flag进行封装。

struct FeatureFlags{
    @Flag(name: "feature-search")
    var isSearchEnabled = false
    @Flag(name: "experiment-note-limit")
    var maximumNumberOfNotes = 999
}

创建一个button按钮,点击后跳转页面,并利用$符号将isSearchEnabled这个封装的属性传给了下个页面的flag属性。

import UIKit
class ViewController: UIViewController {
    var flag:FeatureFlags?
    override func viewDidLoad() {
        super.viewDidLoad()
       // swfit中“”“包裹可以保留所有格式,这里是模拟服务器返回的flag数据
        let jsonString = """ { "feature-search": false,"experiment-note-limit": 3 } """
        if let data = jsonString.data(using: .utf8) {
            let decoder = JSONDecoder()
      // 直接进行解码,解码类型为FeatureFlags类型
            if let flags = try? decoder.decode(FeatureFlags.self, from: data) {
                flag = flags
            }
        }
        let button = UIButton.init(type: UIButton.ButtonType.system)
        button.tintColor = UIColor.black
        button.setTitle("点击", for: UIControl.State.normal)
        button.frame = CGRect.init(x: 100, y: 100, width: 150, height: 100)
        self.view.addSubview(button)
        button.addTarget(self, action: #selector(goToFlagVC), for: .touchUpInside)
    }
    @objc func goToFlagVC() {
        let searchToggleVC = FlagToggleViewController(
            // 将封装器的实例传给了VC,这会使得VC可以更改封装器中属性的值
            flag: flag!.$isSearchEnabled
        )
        self.present(searchToggleVC, animated: true, completion:nil)
    }
}

代码中用jsonString字符串模拟了后台返回的数据,"feature-search"字段为false,当点击按钮后,跳转页面并将isSearchEnabled属性传递过去。

import UIKit
import Foundation
class FlagToggleViewController: UIViewController {
    private let flag: Flag
    private lazy var label = UILabel()
    private lazy var toggle = UISwitch()
    init(flag: Flag) {
        self.flag = flag
        super.init(nibName: nil, bundle: nil)
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.backgroundColor = UIColor.white
        label.text = flag.name
        label.frame = CGRect.init(x: 100, y: 100, width: 150, height: 150)
        toggle.frame = CGRect.init(x: 100, y: 200, width: 150, height: 150)
        toggle.isOn = flag.wrappedValue
        self.view.addSubview(label)
        self.view.addSubview(toggle)
        toggle.addTarget(self,
            action: #selector(toggleFlag),
            for: .valueChanged
        )
    }
    @objc private func toggleFlag() {
        flag.wrappedValue = toggle.isOn
    }
}

点击toggle后,会改变flag.wrappedValue的值,由于使用的是$符号进行传递,是引用传递了属性本身,这会使得ViewController中的flag属性中的wrappedValue的值也会发生改变,当FlagToggleViewController页面disappear后,再次appear时,会显示上次toggle操作后的结果。

自定义Keychain的 property wrapper

SwfitUI中提供了SceneStorageAppStorage二个属性封装器来从scene memoryuser defaults中获取数据,在上文中我们也演示了AppStorage的使用,但是Swift没有从Keychain钥匙串中获取数据的属性封装器,接下来我们自定义一个实现所需的功能。

  • 定义propertyWrapper
@propertyWrapper struct SecureStorage: DynamicProperty {
    @StateObject private var storage: KeychainStorage
    var wrappedValue: Value {
        get { storage.value }
        nonmutating set {
            storage.value = newValue
        }
    }
    init(wrappedValue: Value, _ key: String) {
    // 注意这里对storage的初始化,由于storage是StateObject类型,所以初始化需要采用StateObject封装器 
    // 内部的初始化方法
            self._storage = StateObject(
            wrappedValue: KeychainStorage(
                defaultValue: wrappedValue,
                for: key
            )
        )
    }
    var projectedValue: Binding {
        .init(
            get: { wrappedValue },
            set: { wrappedValue = $0 }
        )
    }
}

代码解读;

  • 属性封装器内依然可以使用原生的属性封装器,里面用@StateObject定义了一个storage属性,其功能是用来在钥匙串中存取数据。
  • 需注意storage的初始化,这里是调用了StateObjectinit(wrappedValue:)方法。
  • projectedValue可以遵循Binding等协议,这里遵守Binging协议并实现了init方法,通过准守Binging协议,则可用$符号将属性封装的引用传递给子View使用,子View则使用@Binding进行绑定来同步修改封装的属性值。
import Foundation
import KeychainAccess
import Combine
private final class KeychainStorage: ObservableObject {
    var value: Value {
        set {
            objectWillChange.send()
            save(newValue)
        }
        get { fetch() }
    }
    let objectWillChange = PassthroughSubject()
    private let key: String
    private let defaultValue: Value
    private let decoder = JSONDecoder()
    private let encoder = JSONEncoder()

    private let keychain = Keychain(
        service: "com.mamba.444444",
        accessGroup: "7123456BR56.mambaGroup"
    )
        .synchronizable(true)
        .accessibility(.always)
    init(defaultValue: Value, for key: String) {
        self.defaultValue = defaultValue
        self.key = key
    }
    private func save(_ newValue: Value) {
        guard let data = try? encoder.encode(newValue) else {
            return
        }
        try? keychain.set(data, key: key)
    }
    private func fetch() -> Value {
        guard
            let data = try? keychain.getData(key),
            let freshValue = try? decoder.decode(Value.self, from: data)
        else {
            return defaultValue
        }
        return freshValue
    }
}

代码解读

  • 使用了第三方库KeychainAccess来管理钥匙串。
  • KeychainStorage遵守了ObservableObject协议,并采用PassthroughSubject来当value进行调用set方法时publish事件,这会触发SecureStorage中的@StateObject修饰的storage属性接受事件,并告知SwiftUI对使用了storage属性值相关联的一切界面进行界面的刷新。

使用:

import SwiftUI
struct ContentView: View {
    @SecureStorage("mamba")
    var goal: Int = 150
    var body: some View {
        NavigationView {
            VStack {
                Section(header: Text("heartMinutesGoal")) {
                    Stepper(value: $goal, in: 150...900, step: 10) {
                        Text("\(goal) heartMinutesGoalValue")
                    }
                }
                NavigationLink(destination: subPageView(goal: $goal)){
                    Text("点击跳转")
                }
            }
        }
    }
}

goal属性采用SecureStorage进行了修饰,并设置初始值为150,当点击Steppe进行增加操作后,当前页面的Text会同步更新,点击跳转按钮后会进入subPageView页面。

import SwiftUI
struct subPageView: View {
    @Binding var goal: Int;
    var body: some View {
        Section(header: Text("subMinutesGoal")) {
            Stepper(value: $goal, in: 150...900, step: 10) {
                Text("\(goal) subMinutesGoalValue")
            }
        }
    }
}

subPageView页面利用Binging对上个页面传来的goal进行了绑定,这样就可以修改上个页面的值,当点击当前页面的Stepper进行goal值的加减操作后,值会因为SecureStorage封装器背后的逻辑存储到钥匙串中,当回退到上个页面时,goal的值会保持最新状态,关掉App重新进入时,值也会显示为最新状态。

这里需要在KeychainStorage类中填入自己开发者账号的accessGroup,一般为开发者AppIdentifierPrefix加上工程中配置的group名字,类似为"7123456BR56.mambaGroup"

获取property wrapper’s enclosing instance

在开发中有时不进需要获取property wrapper包装后的wrappedValue和包装属性的本身projectedValue,还需要能获取拥有这个包装属性的实例本身,从而能使用这个实例的其他属性,swift默认是将property wrapper和实例进行隔开的,但是还是隐藏的开放了相关API供开发者使用。上文中我们多次提到,在使用property wrapper时,我们需要定义一个wrappedValue属性。但其实还能通过static subscript来处理wrappedValue,像下面这样:

@propertyWrapper
struct EnclosingTypeReferencingWrapper {
    static subscript(
        _enclosingInstance instance: T,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath,
        storage storageKeyPath: ReferenceWritableKeyPath
    ) -> Value {
        ...
    }
    ...
}

利用Swift’s key paths feature可以同时获取实例本身 wrapper itself和包装后的值wrappedValue,但是实例本身必须是class类型的,因为上面的subscript使用的是可读可写的ReferenceWritableKeyPath,这是引用类型。为了避免使用struct类型的也通过ReferenceWritableKeyPath来修改属性,同样在开发中会这样:

@propertyWrapper
struct EnclosingTypeReferencingWrapper {
    static subscript(
        _enclosingInstance instance: T,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath,
        storage storageKeyPath: ReferenceWritableKeyPath
    ) -> Value {
        ...
    }
    @available(*, unavailable,
    message: "This property wrapper can only be applied to classes"
)
var wrappedValue: Value {
    get { fatalError() }
    set { fatalError() }
}
}

假如想让下面的text属性和label进行绑定,即只要设置text属性,lable显示的内容也会发生改变,该如何做?当然可以利用传统的通知实现,但是每当有一个新的text属性,就要重新写一份逻辑代码,重用率不高,这里就能利用property wrapper’s enclosing instance进行实现。

class Parent: UIView {
    private let label = UILabel()
    @Derived(\Parent.label.text) var text: String?
}

定义PropertyWrapper

@propertyWrapper
struct AnyDerived {
    var wrappedValue: Value {
        get { fatalError() }
        set { fatalError() }
    }
    private let getter: (Instance) -> Value
    private let setter: (Instance, Value) -> Void
    init(_ keyPath: ReferenceWritableKeyPath) {
        getter = { $0[keyPath: keyPath] }
        setter = { $0[keyPath: keyPath] = $1 }
    }
    static subscript(
        _enclosingInstance instance: Instance,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath,
        storage storageKeyPath: ReferenceWritableKeyPath
    ) -> Value {
        get { instance[keyPath: storageKeyPath].getter(instance) }
        set { instance[keyPath: storageKeyPath].setter(instance, newValue) }
    }
 @available(*, unavailable,
    message: "This property wrapper can only be applied to classes"
}
protocol ProxyContainer {
    typealias Derived = AnyDerived
}
extension NSObject: ProxyContainer {}

使用如下:

class Parent: UIView {
    private let label = UILabel()
    @Derived(\.label.text) var text: String?
}
let instance = Parent()
instance.text // nil
instance.text = "foo"
label.text // foo

总结:

本文从wrappedValueprojectedValue,再到enclosing instance逐步的阐述了Property warppers的内部实现原理,并结合实际Demo进行了相关演示,利用Property warppers的特性可以提高代码的复用率,也可以帮助我们更好的理解SwiftUI中例如StateBinding等关键词的工作原理,在实际开发中合理的运用Property warppers能达到事半功倍的效果。
Demo1地址 Demo2地址

你可能感兴趣的:(Swift和SwiftUI中Property warppers的使用)