🤖

アプリの「変更追跡」を PropertyWrapper で安全かつ効率的に管理する

に公開

アプリの要件において、1つのプロパティに対して複数の値を保持しないといけない場面があるかと思います。
場面場面で最適な値の持ち方は異なるかと思いますが、今回はPropertyWrapperを活用する方法を共有したいと考えています。

どのような場面で使えそうなtipsなのか、ケース例を使いながら確認していこうと思います。

ケース例:ユーザー情報画面にて

開発するのはユーザー情報の画面。アプリを通してユーザー側から編集できるような機能があります。

挙動の流れとしては下記になります。
データを一覧で表示し、編集したものを更新する。よくあるフローだと思います。
例えばあなたが電話番号を変更したとき、アプリの設定で「電話番号」のプロパティを変更しなければならないでしょう。

ですが、ここに要件を追加して、値の変更ステータスを持つようにする、そしてそれは動的な計算が必要なものである場合はどうでしょうか。

きっとどこかに値を保持しておいて、取り出す時に随時計算処理を走らせることになると思います。

また、このフローの中にも(例えばバリデーションや編集した後に取りやめる場合など)変更前と後の値の比較を行う要件が追加されることもあります。
値の表示や更新時に変更前・変更後の2つの値を保存することが必要のように感じます。

以上のように、値の編集・更新を行う画面では、どのようにして値を持つかを考えなければいけません。

今回はこのような背景を受け、つまり編集中の画面において、1つのプロパティに対して「変更前の値」「変更後の値」「変更フラグ」をメモリ上で効率的かつ安全に管理できる仕組みを模索し始めました。

今回使用するUserDefaultとPropertyWrapperの説明

そんなこんなでどのように値を持たせるかを考えた結果、UserDefaultとPropertyWrapperが候補になりました。

そもそもUserDefaultとは

UserDefaultsとは、アプリ内で小規模なデータを永続的に保存・取得できる仕組みです。
UserDefaultsは、Key-Valueのペアでデータを管理します。

開発者は下記のようにset(_:forKey:)メソッドで値を保存し、object(forKey:)などで値を取得します。

// 値の保存
UserDefaults.standard.set(true, forKey: "isDarkMode")

// 値の取得
let darkModeEnabled = UserDefaults.standard.bool(forKey: "isDarkMode")

これらのデータはアプリごとに分離され、端末のストレージ上(実体は.plistファイル)に永続保存されます。アプリが終了しても、次回起動時に自動的に読み込まれます。
ただし、暗号化や保護機構は備わっていないため、セキュリティが必要な個人情報や認証情報の保存には不向きです。ダークモード、フォントサイズなどのユーザー設定、あとは軽量なアプリ設定情報の保存に適していると考えています。

そもそもpropertyWrapperとは

公式ドキュメントには下記のように説明されています。

A property wrapper adds a layer of separation between code that manages how a property is stored and the code that defines a property.

直訳すると、「プロパティラッパーは、プロパティの保存方法を管理するコードと、プロパティを定義するコードとの間に、分離の層を追加します」と記載されています。

上記の説明を理解するために、公式ドキュメントで説明されている例を確認していきましょう。

プロパティラッパーを定義する

@propertyWrapper
struct TwelveOrLess {
    private var number = 0
    var wrappedValue: Int {
        get { return number }
        set { number = min(newValue, 12) }
    }
}

プロパティラッパーを定義するには、wrappedValueプロパティを定義する構造体、列挙型、またはクラスを作成します。上記の TwelveOrLess構造体は、ラップする値に常に12以下の数値が含まれるようにします。より大きな数値を格納するように要求された場合、代わりに12を格納します。セッターは新しい値が12以下であることを保証し、ゲッターは格納された値を返します。

ラッパーをプロパティに適用する

struct SmallRectangle {
    @TwelveOrLess var height: Int
    @TwelveOrLess var width: Int
}

var rectangle = SmallRectangle()
print(rectangle.height)
// Prints "0".

rectangle.height = 10
print(rectangle.height)
// Prints "10".

rectangle.height = 24
print(rectangle.height)
// Prints "12".

ラッパーをプロパティに適用するには、ラッパーの名前をプロパティの前に属性(@)として記述します。heightプロパティとwidthプロパティは、TwelveOrLessの定義(number = 0)から初期値を取得します。rectangle.height = 10の代入は有効な値として処理されます。しかし、rectangle.height = 24と代入しようとすると、TwelveOrLessのセッターが働き、許可される最大値である12が格納されます。

@[構造体]を使わずに、プロパティを明示的にラップする

struct SmallRectangle {
    private var _height = TwelveOrLess()
    private var _width = TwelveOrLess()
    var height: Int {
        get { return _height.wrappedValue }
        set { _height.wrappedValue = newValue }
    }
    var width: Int {
        get { return _width.wrappedValue }
        set { _width.wrappedValue = newValue }
    }
}

プロパティにラッパーを適用すると、コンパイラはラッパー(TwelveOrLess)のストレージを提供するコードと、ラッパーを介してプロパティ(height, width)へのアクセスを提供するコードを合成します。上記は、@TwelveOrLessという属性構文を使わずに、プロパティをTwelveOrLess構造体で明示的にラップした場合のコードです。

_heightおよび_widthプロパティは、プロパティラッパーである TwelveOrLessのインスタンスを格納します。heightwidthのゲッターとセッターは、ラッパーインスタンスのwrappedValueプロパティへのアクセスをラップします。

また、プロパティに初期値がある場合、例えば@TwelveOrLess var height: Int = 0のように初期値0を設定している場合、コンパイラでinit(wrappedValue:)を呼び、取得したwrappedValueをイニシャライザに設定します。

private var _height = TwelveOrLess(wrappedValue: 0)
var height: Int {
    get { _height.wrappedValue }
    set { _height.wrappedValue = newValue }
}

また、Swift Evolution提案書 SE-0258 Property Wrappers でも使い方や構文展開ルールが定義されています。

メイン実装:@Tracked による変更追跡

では、PropertyWrapperを使って今回のケースを解決する実装を作っていこうと思います。 今回の実装では、下記の3つのポイントに着目していきます。

  1. フォーム編集で“表示値”“変更検知”“差分比較”を1つの宣言で完結させる
  2. (メモリ上で)変更前後の値を安全にカプセル化する
  3. 項目が増えてもコードが膨らまない拡張性

propertyWrapperの実装と解説

実装は下記になります。

@propertyWrapper
struct Tracked<Value: Equatable> {
    private var currentValue: Value
    private(set) var hasChanges: Bool = false
    private var initialValueProvider: () -> Value
    
    var wrappedValue: Value {
        get { currentValue }
        set {
            hasChanges = (newValue != initialValueProvider())
            currentValue = newValue
        }
    }
    
    init(wrappedValue: @autoclosure @escaping () -> Value) {
        let v = wrappedValue()
        self.currentValue = v
        self.hasChanges = false
        self.initialValueProvider = wrappedValue
    }
    
    var projectedValue: Self {
        get { self }
        mutating set { self = newValue }
    }
    
    mutating func updateInitialValueProvider(_ provider: @autoclosure @escaping () -> Value) {
            self.initialValueProvider = provider
    }
    ・・・・・
}

簡単に、中でなにをしているかを解説します。

変数の宣言時

@Trackedを宣言すると、下記の3つの値を持たすようにしています。

  1. currentValue:値を保持するための構造
    この変数は実際に保持されている値です。wrappedValueがこの値を参照・更新します。
  2. hasChanges:変更があったかを表すフラグ
    値が「初期値」と異なる場合に true になります。(set)がついているため、外部からは読み取り専用にしています。
  3. initialValueProvider:初期値を供給するクロージャ
    「この値が初期状態では何だったか」を動的に取得するためのメソッドです。後から updateInitialValueProviderによって更新できます。

初期化処理時(init時)

初期値をクロージャとして受け取り、それを現在値 (currentValue) と初期値の供給元 (initialValueProvider) の両方に設定します。
初期化直後なので、変更フラグは「変更なし」状態(false)で開始します。

初期値プロバイダの更新(updateInitialValueProvider

API通信などで取得した値を初期値に設定するプロバイダの役割を持たせています。

projectedValue の利用

projectedValueは、プロパティの前に$を付けた際にアクセスされる値を定義します。今回はprojectedValueSelf(Tracked インスタンス自身)を設定しました。

これにより、ラッパーが持つ追加の機能や情報(hasChangesフラグや updateInitialValueProviderメソッド)に対して、$nameSeiという簡潔な構文でアクセスできるようになります。

// $nameSei は Tracked<String> インスタンス(projectedValue)を指す
print($nameSei.hasChanges) // true or false

// ラッパーが持つメソッドも呼び出せる
$nameSei.updateInitialValueProvider("新しい初期値")

【補足】テーマ:プレフィックス(_$)の違い
一言で言うと、「_は裏で値を保持する“バックストア”、$は外部に見せる“投影された値”」です。

プレフィックス 名称 役割 アクセス範囲 生成タイミング 使用例 備考
_property バックストア(Backing Storage) PropertyWrapperの実体(インスタンス)。プロパティの実データを保持 型内部のみ(private相当) コンパイラが自動生成 _nameSei.revertToBaseline() 内部処理・初期化・リセット時に使用
$property 投影値(Projected Value) projectedValueで定義されるラッパーが外部に見せたいインターフェース 型外からもアクセス可(ラッパーの設計による) projectedValue定義時に生成 $nameSei.hasChanges SwiftUIの@State@Bindingもこの仕組み

下記の記事がとても参考になりましたので、詳しい内容はこちらで確認ください😊
https://qiita.com/startaiyo/items/64e9d82e799be6f8da78

UserProfile クラスとの連携

@Tracked を利用して、ユーザー情報の各項目を一元管理しています。
実装は下記です。ポイントとしては、実装者の関心を「値の格納」のみにしたことです。

final class UserProfile {
    @Tracked var nameSei: String = ""
    ・・・・
    
    enum CodingKeys: String, CodingKey {
        case nameSei,・・・・
    }
    
    func updateInitialValueProviders(nameSei: String, ・・・) {
        _nameSei.updateInitialValueProvider(nameSei)
        ・・・・
    }
    
    func setValue(_ value: Any?, for key: CodingKeys) {
        switch key {
        case .nameSei, ・・・・:
            guard let v = value as? String else { return }
            switch key {
            case .nameSei: nameSei = v
            ・・・・
            default: break
            }
        }
    }
    
    func revertAllToBaseline() {
        _nameSei.revertToBaseline()
        ・・・・
    }
    ・・・・
}

使用タイミング例も記載しておきます。

  • updateInitialValueProviders()
    → サーバーから最新データを取得した時などに利用。各項目の初期値基準をまとめて更新。
  • setValue()
    → 初期値を編集したタイミング。編集した項目のキーを指定して値を更新する。
  • revertAllToBaseline()
    → 編集操作の取り消し。全項目を初期値へ戻す。

UserProfileSessionData の役割

これは シングルトンとして全体の状態を保持する管理クラスです。
どこからでも同じ UserProfile インスタンスを参照できます。
実装は下記です。

import Foundation

final class UserProfileSessionData {
    static let shared = UserProfileSessionData()
    
    var userProfile: UserProfile
    
    private init() {
        self.userProfile = UserProfile()
    }
}

補足Tips:端末でのデータ管理が必要な要件が入った時

@Trackedはメモリ上での値の管理なので、一度アプリをキルするとデータは初期化されたものに戻ります。そのため、端末で管理しなければいけないデータを扱う場合は別の物が必要です。

例として。「更新から1日経過するまでUIを変更しなければいけない」という要件が入った時、どのようにして値を管理するかを考える必要があります。

まず考えなければいけないのは、保持しなければいけない値がセキュリティリスクを含むものであるかどうかだと考えています。
リスクを含む場合は別途Keychain暗号化DBを使用して安全に永続化する必要があります。今回のケースのような個人情報に当たらない情報の場合はUserDefaultsが活用できるかと考えています。

今回のケースで言うと、更新ボタンを押した時に編集内容が確定するため、そこでkeyをプロパティ名item1、valueに編集日時をsetします。

// 編集確定時
let now = Date()
UserDefaults.standard.set(now, forKey: "item1LastUpdated")

// 一覧画面表示時に経過日数をチェック
if let lastUpdated = UserDefaults.standard.object(forKey: "item1LastUpdated") as? Date {
    let now = Date()
    let oneDay: TimeInterval = 60 * 60 * 24 // 1日(秒数換算)

    if now.timeIntervalSince(lastUpdated) >= oneDay {
        ・・・・・
    } else {
        ・・・・
    }
} else {
    ・・・・
}

まとめ

値の管理でとても悩むことが多かったので、PropertyWrapperと出会った時はとても感動しました😊
便利とは言いつつ、できること・できないことが存在し、そしてセキュリティの観点を意識するなど、考えることはまだたくさんあります。。。
今回をきっかけに、どう値を持つか、どのような持ち方がより安全で効率的かなどをより意識していこうと思います💪

参考

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/properties/
https://github.com/apple/swift-evolution/blob/main/proposals/0258-property-wrappers.md
https://qiita.com/nkmrh/items/96f08b1915b85367e7ff
https://qiita.com/startaiyo/items/64e9d82e799be6f8da78

Discussion