はじめに
こんにちは。iOSアプリ開発グループの神山です。
今更ながらSwiftUIについて勉強し始めたのですが、@Stateや@Bindingなどの@がついたプロパティを見かけることがあります。
これはProperty Wrapperという仕組みを使用していることを表すものなのですが、私自身は今まで使用する機会があまりありませんでした。
ということで、SwiftUIの理解を深めるためにもProperty Wrapperが実際にどのような処理をしているのかをまとめてみました。
Property Wrapperとは?
THE SWIFT PROGRAMMING LANGUAGEにはこのような記載があります。
A property wrapper adds a layer of separation between code that manages how a property is stored and the code that defines a property.
「Property Wrapperはプロパティの保存方法を管理するコードと、プロパティを定義するコードの間に分離のレイヤーを追加する」とのことです。
これはプロパティの値の保存方法や計算方法をProperty Wrapperとして定義し、同じような振る舞いを行うプロパティをProperty Wrapperにラップすることで同じような処理を書くことなく再利用できるようになる仕組みとも言えます。
例として、格納される値が12以下の値の場合は12が格納され、12より大きい場合はその値が格納されるProperty Wrapperを見てみましょう。
@propertyWrapper
struct TwelveOrLess {
private var number: Int = 0
var wrappedValue: Int {
get { number }
set { number = min(newValue, 12) }
}
}
Property Wrapperを定義するためにはいくつかのルールがあります。
- @propertyWrapperのattributeを記載
- enum、struct、classで定義可能
- wrappedValueの定義が必要(プロパティの振る舞いを記載)
実際に使用する場合は以下のようになります。
struct SmallRectangle {
@TwelveOrLess var height: Int
@TwelveOrLess var width: Int
}
var rectangle = SmallRectangle()
print(rectangle.height) // 0
rectangle.height = 10
print(rectangle.height) // 10
rectangle.height = 24
print(rectangle.height) // 12
Property Wrapperとしての@TwelveOrLessではなく、単純なTwelveOrLess構造体として明示的にラップした場合は以下のように表せます。
struct SmallRectangle {
private var _height = TwelveOrLess()
private var _width = TwelveOrLess()
var height: Int {
get { _height.wrappedValue }
set { _height.wrappedValue = newValue }
}
var width: Int {
get { _width.wrappedValue }
set { _width.wrappedValue = newValue }
}
}
比較して分かるように、Property WrapperはこのようにwrappedValueに対しての処理を簡潔に記載することができます。
初期値を持ったProperty Wrapper
先ほど説明した@TwelveOrLessにはnumberに初期値の0が与えられているため、@TwelveOrLessで定義されたSmallRectangleのwidthやheightのプロパティにはどちらも0が初期値として設定されます。
ただ、widthとheightにそれぞれ違う値の初期値を設定したい場合もあるかと思います。 その場合は、@TwelveOrLessのProperty Wrapperにイニシャライザを作成することで実現することができます。
@propertyWrapper
struct TwelveOrLess {
private var number: Int
init(number: Int) {
self.number = number
}
var wrappedValue: Int {
get { number }
set { number = min(newValue, 12) }
}
}
struct SmallRectangle {
@TwelveOrLess(number: 0) var height: Int
@TwelveOrLess(number: 5) var width: Int
}
var rectangle = SmallRectangle()
print(rectangle.height) // 0
print(rectangle.height) // 5
このように初期値においてもイニシャライザを作成することで、汎用性の高いProperty Wrapperを定義することができます。
ProjectedValue(投影値)
Property Wrapperではプロパティの振る舞いを定義したwrappedValueだけでなく、それを投影したprojectedValueを定義することもできます。投影値にアクセスする場合は$記号が必要になります。
以下は12を超えた値を設定した場合はtrueを返し、それ以外はfalseを返すprojectedValueを定義しています。
@propertyWrapper
struct TwelveOrLess {
private var number: Int
init(number: Int) {
self.number = number
self.projectedValue = false
}
private(set) var projectedValue: Bool
var wrappedValue: Int {
get { number }
set {
if newValue > 12 {
number = 12
projectedValue = true
} else {
number = newValue
projectedValue = false
}
}
}
}
struct SmallRectangle {
@TwelveOrLess(number: 0) var width: Int
}
var rectangle = SmallRectangle()
print(rectangle.width) // 0
print(rectangle.$width) // false
rectangle.width = 20
print(rectangle.width) // 12
print(rectangle.$width) // true
具体的な使用例(UserDefaults)
アプリ内でUserDefaultsを使用する機会は多いかと思いますが、Property Wrapperを使用すると簡潔にまとめることができます。
ここではProperty Wrapperを使わない場合、Property Wrapperを使った場合、デフォルト値を持ったProperty Wrapperを使った場合の3つに分けて見てみましょう。
Property Wrapperを使わない場合
struct UserDefaultsStorage {
static var userId: Int {
get {
UserDefaults.standard.integer(forKey: "userId")
}
set {
UserDefaults.standard.setValue(newValue, forKey: "userId")
}
}
static var isPlaying: Bool {
get {
UserDefaults.standard.bool(forKey: "isPlaying")
}
set {
UserDefaults.standard.setValue(newValue, forKey: "isPlaying")
}
}
}
UserDefaultsStorage.userId = 10
UserDefaultsStorage.isPlaying = true
print(UserDefaultsStorage.userId) // 10
print(UserDefaultsStorage.isPlaying) // true
UserDefaultsStorage.userId = 20
UserDefaultsStorage.isPlaying = false
print(UserDefaultsStorage.userId) // 20
print(UserDefaultsStorage.isPlaying) // false
そこまで多くないのであれば問題ないかもしれませんが、保存する値が増える度にコードが増えてしまって非常に見にくくなりますし、同じような処理をそれぞれのプロパティで書くことになってしまいます。
Property Wrapperを使った場合
@propertyWrapper
struct UserDefaultsWrapper<T: LosslessStringConvertible> {
private let key: String
init(key: String) {
self.key = key
}
var wrappedValue: T? {
get {
UserDefaults.standard.object(forKey: key) as? T
}
set {
UserDefaults.standard.setValue(newValue, forKey: key)
}
}
}
struct UserDefaultsStorage {
@UserDefaultsWrapper(key: "userId")
static var userId: Int?
@UserDefaultsWrapper(key: "isPlaying")
static var isPlaying: Bool?
}
print(UserDefaultsStorage.userId) // nil
print(UserDefaultsStorage.isPlaying) // nil
UserDefaultsStorage.userId = 10
UserDefaultsStorage.isPlaying = true
print(UserDefaultsStorage.userId) // Optional(10)
print(UserDefaultsStorage.isPlaying) // Optional(true)
UserDefaultsStorage.userId = 20
UserDefaultsStorage.isPlaying = false
print(UserDefaultsStorage.userId) // Optional(20)
print(UserDefaultsStorage.isPlaying) // Optional(false)
Property Wrapperに処理をまとめたことでUserDefaultsStorage内の定義がとてもシンプルになりました。しかし、wrappedValueで取得する際に処理をまとめたことでオプショナル型で取得するようになっていしまい、使用する際にnilかどうかの確認をする必要が出てきてしまいました。
デフォルト値を持ったProperty Wrapper使った場合
@propertyWrapper
struct UserDefaultsWrapper<T: LosslessStringConvertible> {
private let key: String
private let defaultValue: T
init(key: String, defaultValue: T) {
self.key = key
self.defaultValue = defaultValue
}
var wrappedValue: T {
get {
UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
UserDefaults.standard.setValue(newValue, forKey: key)
}
}
}
struct UserDefaultsStorage {
@UserDefaultsWrapper(key: "userId", defaultValue: 0)
static var userId: Int
@UserDefaultsWrapper(key: "isPlaying", defaultValue: false)
static var isPlaying: Bool
}
print(UserDefaultsStorage.userId) // 0
print(UserDefaultsStorage.isPlaying) // false
UserDefaultsStorage.userId = 10
UserDefaultsStorage.isPlaying = true
print(UserDefaultsStorage.userId) // 10
print(UserDefaultsStorage.isPlaying) // true
UserDefaultsStorage.userId = 20
UserDefaultsStorage.isPlaying = false
print(UserDefaultsStorage.userId) // 20
print(UserDefaultsStorage.isPlaying) // false
Property Wrapperにデフォルトの値を設定できるようにしたことで、オプショナル型にする必要なく値を取得できるようになりました。これで使用する際にnilのチェックをする必要も無くなりましたね。
おまけ
SwiftUIではUserDefaultsに対する操作に対して@AppStorageというProperty Wrapperが既に用意されており、上記で説明してきたものを行ってくれます。
enum TestEnum: String {
case one
case two
case three
}
struct UserDefaultsStorage {
@AppStorage(wrappedValue: 0, "userId")
static var userId: Int
@AppStorage(wrappedValue: false, "isPlaying")
static var isPlaying: Bool
@AppStorage("testEnum")
static var testEnum: TestEnum = .one
}
print(UserDefaultsStorage.userId) // 0
print(UserDefaultsStorage.isPlaying) // false
print(UserDefaultsStorage.testEnum) // one
UserDefaultsStorage.userId = 10
UserDefaultsStorage.isPlaying = true
UserDefaultsStorage.testEnum = .two
print(UserDefaultsStorage.userId) // 10
print(UserDefaultsStorage.isPlaying) // false
print(UserDefaultsStorage.testEnum) // two
UserDefaultsStorage.userId = 20
UserDefaultsStorage.isPlaying = false
UserDefaultsStorage.testEnum = .three
print(UserDefaultsStorage.userId) // 20
print(UserDefaultsStorage.isPlaying) // false
print(UserDefaultsStorage.testEnum) // three
enumも定義することができるので、SwiftUIの場合はわざわざ自分で定義する必要のない@AppStorageを使用するのが良いですね。
UserDefaultsのテスト
UserDefaultsのテストはアプリ本体にも影響が出る可能性があるため注意が必要です。
上記で説明したAppStorageでは任意のUserDefaultsを設定することができ、任意のUserDefaultsを設定することでアプリ本体に影響を出すことなくテストをすることができます。先ほど作成したUserDefaultsWrapperについてもテストしやすくするために、任意のUserDefaultsを設定できるようにしてテストコードを作成してみましょう。
@propertyWrapper
struct UserDefaultsWrapper<T: LosslessStringConvertible> {
private let key: String
private let defaultValue: T
private let userDefaults: UserDefaults?
init(key: String, defaultValue: T, userDefaults: UserDefaults? = .shared) {
self.key = key
self.defaultValue = defaultValue
self.userDefaults = userDefaults
}
var wrappedValue: T {
get {
userDefaults?.object(forKey: key) as? T ?? defaultValue
}
set {
userDefaults?.setValue(newValue, forKey: key)
}
}
}
extension UserDefaults {
static var shared: UserDefaults? = UserDefaults.standard
static func inject(_ userDefaults: UserDefaults?) {
shared = userDefaults
}
static func removeAll(suiteName: String) {
shared?.removePersistentDomain(forName: suiteName)
}
}
struct UserDefaultsStorage {
@UserDefaultsWrapper(key: "userId", defaultValue: 0)
static var userId: Int
@UserDefaultsWrapper(key: "isPlaying", defaultValue: false)
static var isPlaying: Bool
}
class UserDefaultsTest: XCTestCase {
override func setUp() {
super.setUp()
UserDefaults.inject(UserDefaults(suiteName: "Test"))
}
override func tearDown() {
super.tearDown()
UserDefaults.removeAll(suiteName: "Test")
}
func test_UserDefaults() {
UserDefaultsStorage.userId = 100
XCTAssertEqual(UserDefaultsStorage.userId, 100)
UserDefaultsStorage.isPlaying = true
XCTAssertEqual(UserDefaultsStorage.isPlaying, true)
}
}
外側からUserDefaultsを設定できるようにすることでアプリ本体に影響を出すことなくテストを作成することができました。
さいごに
Property Wrapperはなかなか自分で定義する機会は少ないので、はじめはとっつきにくいものかもしれませんが、プロパティの振る舞いをラップして再利用できる点など、使いこなせれば便利な機能であることが分かりました。
SwiftUIでは必然的に@Stateや@BindingといったProperty Wrapperを使用することになるかと思いますので、少しでもこの記事が理解の助けとなれば幸いです。
最後まで記事を読んで頂き、ありがとうございました。
参考文献
神山義仁
iOSエンジニアです。