阅读时长35分钟,Swift5.0
开始支持Property warppers
属性包装器,顾名思义就是对属性进行包装,给属性附加一段逻辑,同时对这段逻辑操作进行了封装,在背后透明的运行,这样的好处是能大大的增加代码的重用率。目前Apple
的SwiftUI
框架就是用了很多属性包装器如常用的@State
,@Binding
,@Appstroe
,@StateObject
,@ObserveObjec
t和@EnvironmentObject
等来封装属性逻辑并进行监听,当属性发生改变时同步的刷新UI
,通过本文你将会学习到一下内容:
- 了解
SwiftUI
原生Property warppers
属性包装器的实现逻辑。 - 理解
Property warppers
的本质。 - 用
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
属性,这样涉及firstName
和lastName
属性的读写操作其实是操作对应的属性包装器中的wrappedValue
属性,这样就实现了在给firstName
和lastName
属性赋值后,背后的逻辑已经进行了首字母大写的操作。
// John Appleseed
var user = User(firstName: "john", lastName: "appleseed")
// John Sundell
user.lastName = "sundell"
注意:
- 当属性包装器的
init
方法定义为init(wrappedValue:)
时,则可以直接给包装的属性赋默认值,例如@Capitalized var name = "Untitled document"
。 -
Swfit
中的属性观察在所有属性完成初始化之前是不会触发的,所以需要显示的定义初始化方法,以便让属性初始化完后能触发属性。
观察属性包装器由于其定义是struct
和class
,所有也能拥有自己的属性,能实现依赖注入,可以实现复杂的封装逻辑。
@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
内定义了key
和storage
属性,其中storage
可以让属性包装器实现依赖注入。
虽然上面autoMarkMessagesAsRead
和numberOfSearchResultsPerPage
二个属性都是非可选的,但是经过@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_REALM
在swift
无法通过宏定义,需要在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
来配置我们FeatureFlags
,Dic
可以是通过后台返回,也可以是本地保存的,下面的代码采用了自动闭包来设置默认值,当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
在开发中的使用的一个介绍,因为下面的例子会用到其中的知识,下面我们利用flag
和Property 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
解码,解码的key
是Flag
的name
属性,同时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
中提供了SceneStorage
和AppStorage
二个属性封装器来从scene memory
和user 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
的初始化,这里是调用了StateObject
的init(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
总结:
本文从wrappedValue
到projectedValue
,再到enclosing instance
逐步的阐述了Property warppers
的内部实现原理,并结合实际Demo
进行了相关演示,利用Property warppers
的特性可以提高代码的复用率,也可以帮助我们更好的理解SwiftUI
中例如State
,Binding
等关键词的工作原理,在实际开发中合理的运用Property warppers
能达到事半功倍的效果。
Demo1地址 Demo2地址