5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

@propertyWrapperで作るUserDefaultsユーティリティ

Last updated at Posted at 2020-03-04

はじめに

Swift5.1から導入されたProperty Wrappersを使ってUserDefaultクラス(またはstruct)を紹介する記事はたくさんあります。
下記の記事は大変参考になりました。ありがとうございます。

Swift5.1 PropetyWrappersを使ったUserDefaultsの例
Swift5.1のProperty Wrappersでより安全なUser Defaults参照を実現する

ただ自分の勉強のためと、少し仕様的に違和感があったので自分なりに実装を検討してみました。
下記のコードがその結果です。

目標

  • UserDefaultに渡す型は、オプショナルと非オプショナルを選びたい
  • UserDefaultsでサポートされるプリミティブ型以外のclass(またはstruct)に対応したい
  • 上記で、プリミティブ型はそのまま、それ以外はData型を使う
  • 独自(公開)プロトコルは導入したくない(Codableのみ使用)
  • できれば単一の定義でオプショナルと非オプショナルの両方に対応したい
  • 初期値に対応する(非オプショナルでは必須)
  • オプショナル型を使う場合は、nilを使ってUserDefaults変数の削除を行う

採用した実装方式

UserDefaultに渡す型は、オプショナルと非オプショナルを選びたい

結論として単一の定義でオプショナルと非オプショナルの両方に対応できませんでした。
オプショナル用、非オプショナル用に別々のクラスを実装しました。

これは、独自(公開)プロトコルは導入したくない という目標と 単一の定義でオプショナルと非オプショナルの両方に対応したいという目標がバッティングしていて、どちらかを選ばざるを得なかったという感じです。独自のプロトコルを導入するならば、
Swift5.1のProperty Wrappersでより安全なUser Defaults参照を実現する
やはり↑が素晴らしいと思います。

初期値に対応する


private let defaultValue: T?

をイニシャライザで設定し、get {}内で戻り値がnilの時に差し替えます。

オプショナルと非オプショナルでの実装の差異

オプショナルと非オプショナルでUserDefaultを動作させるポイントは、型Tがオプショナル型ではないようにvar wrappedValueの型を決めることです。
非オプショナル型では、


var wrappedValue: T

オプショナル型では、


var wrappedValue: T?

とします。それにしたがって初期値を格納するdefaultValue変数も

非オプショナル型では、


let defaultValue: T

オプショナル型では、


let defaultValue: T?

とします。

propertyWrapperを使ったUserDefaultの実装

@propertyWrapperの定義

UserDefaultCommonから共通部分を継承しています。
isSupported: Boolをprotocol extensionに置き、それ以外は直接記述することで、classからstructに変更しました。

UserDefaultクラスがオプショナル非対応です。

UserDefault(オプショナル非対応)

@propertyWrapper
public struct UserDefault<T: Codable>: UserDefaultSupported {
    typealias Result = T
    private let key: String
    private let defaultValue: T
    public init(_ key: String, defaultValue: T) {
        self.key = key
        self.defaultValue = defaultValue
    }
    public var wrappedValue: T {
        get {
            getValue() ?? defaultValue
        }
        set {
            setValue(newValue)
        }
    }
    private func getValue() -> T? {
        let object = UserDefaults.standard.object(forKey: self.key)
        if let data = object as? Data, let value = try? JSONDecoder().decode(T.self, from: data) {
            return value
        }
        return object as? T
    }
    private func setValue(_ value: T) {
        if isSupported {
            UserDefaults.standard.set(value, forKey: self.key)
        } else {
            if let data = try? JSONEncoder().encode(value) {
                UserDefaults.standard.set(data, forKey: self.key)
            } else {
                log.error("Couldn't encode \(value)")
            }
        }
    }
}

OptionalUserDefaultクラスがオプショナル対応

OptionalUserDefault(オプショナル対応)

@propertyWrapper
public struct OptionalUserDefault<T: Codable>: UserDefaultSupported {
    typealias Result = T
    private let key: String
    private let defaultValue: T?
    public init(_ key: String, defaultValue: T? = nil) {
        self.key = key
        self.defaultValue = defaultValue
    }
    public var wrappedValue: T? {
        get {
            getValue() ?? defaultValue
        }
        set {
            setValue(newValue)
        }
    }
    private func getValue() -> T? {
        let object = UserDefaults.standard.object(forKey: self.key)
        if let data = object as? Data, let value = try? JSONDecoder().decode(T.self, from: data) {
            return value
        }
        return object as? T
    }
    private func setValue(_ value: T?) {
        guard let value = value else {
            UserDefaults.standard.removeObject(forKey: self.key)
            return
        }
        if isSupported {
            UserDefaults.standard.set(value, forKey: self.key)
        } else {
            if let data = try? JSONEncoder().encode(value) {
                UserDefaults.standard.set(data, forKey: self.key)
            } else {
                log.error("Couldn't encode \(value)")
            }
        }
    }
}

プリミティブ型はそのまま、それ以外はData型を使う

判定をするにあたっては、直接的に判定することにしました。型を調べてサポートしている型ならばtrueを返す関数var isSupported: Boolを使います。これは、AppleのUserDefaultsの仕様を明確に記述するという意味で見た目が悪いが意味はあると考えています。(一部対応をとばしているものもあります)
protocol UserDefaultSupported のextensionに変更しました。)


protocol UserDefaultSupported {
    associatedtype Result
    var isSupported: Bool { get }
}

extension UserDefaultSupported {
    var isSupported: Bool {
        // Primitive Type
        if Result.self is Bool.Type {
            return true
        }
        if Result.self is Int.Type {
            return true
        }
        if Result.self is Float.Type {
            return true
        }
        if Result.self is Double.Type {
            return true
        }
        if Result.self is String.Type {
            return true
        }
        // Array Type
        if Result.self is [Bool].Type {
            return true
        }
        if Result.self is [Int].Type {
            return true
        }
        if Result.self is [Float].Type {
            return true
        }
        if Result.self is [Double].Type {
            return true
        }
        if Result.self is [String].Type {
            return true
        }
        // Dictionary Type
        if Result.self is [String: Bool].Type {
            return true
        }
        if Result.self is [String: Int].Type {
            return true
        }
        if Result.self is [String: Float].Type {
            return true
        }
        if Result.self is [String: Double].Type {
            return true
        }
        if Result.self is [String: String].Type {
            return true
        }
        return false
    }
}

テストコード

テストコードを記載しておきます。

テストコード(キーの宣言部)

import XCTest
import XXXX

private enum UserDefaultTestsKeys: String, CaseIterable {
    case boolValue = "boolValue"
    case boolOptionalValue = "boolValue.Optional"

    case intValue = "intValue"
    case intOptionalValue = "intValue.Optional"

    case stringValue = "stringValue"
    case stringOptionalValue = "stringValue.Optional"

    case doubleValue = "doubleValue"
    case doubleOptionalValue = "doubleValue.Optional"

    case floatValue = "floatValue"
    case floatOptionalValue = "floatValue.Optional"

    case stringArrayValue = "stringArrayValue"
    case stringArrayOptionalValue = "stringArrayValue.Optional"

    case dicValue = "dicValue"
    case dicOptionalValue = "dicValue.Optional"

    case urlValue = "urlValue"
    case urlOptionalValue = "urlValue.Optional"

    case testClassValue = "testClassValue"
    case testClassOptionalValue = "testClassValue.Optional"
}


class TestClass: Codable {
    let p1: Int
    let p2: String
    init(p1: Int, p2: String) {
        self.p1 = p1
        self.p2 = p2
    }
}

以下の記述を見てもらうことで、使い方がわかると思います。
まずは@UserDefault/@UserDefaultOptionalを使って変数を定義していきます。
(長くなるので全部は記載しません)

テストコード(UserDefault宣言部)

private extension UserDefaults {
    @UserDefault(UserDefaultTestsKeys.boolValue.rawValue, defaultValue: true)
    static var boolValue: Bool

    @UserDefault(UserDefaultTestsKeys.intValue.rawValue, defaultValue: 9)
    static var intValue: Int

    @UserDefault(UserDefaultTestsKeys.stringValue.rawValue, defaultValue: "991")
    static var stringValue: String

    @UserDefault(UserDefaultTestsKeys.doubleValue.rawValue, defaultValue: 1.999)
    static var doubleValue: Double

    @UserDefault(UserDefaultTestsKeys.floatValue.rawValue, defaultValue: 9.111)
    static var floatValue: Float

    @UserDefault(UserDefaultTestsKeys.stringArrayValue.rawValue, defaultValue: ["up", "down", "charm"])
    static var stringArrayValue: [String]

    @UserDefault(UserDefaultTestsKeys.dicValue.rawValue, defaultValue: ["up": Int(1), "down": Int(3), "charm": Int(45)])
    static var dicValue: [String: Int]

    @UserDefault(UserDefaultTestsKeys.urlValue.rawValue, defaultValue: URL(string: "www.yahoo.co.jp")!)
    static var urlValue: URL

    @UserDefault(UserDefaultTestsKeys.testClassValue.rawValue, defaultValue: TestClass(p1: 1, p2: "first"))
    static var testClassValue: TestClass

    // -------------------------------------------------------

    @UserDefaultOptional(UserDefaultTestsKeys.boolOptionalValue.rawValue)
    static var boolOptionalValue: Bool?

    @UserDefaultOptional(UserDefaultTestsKeys.intOptionalValue.rawValue, defaultValue: 9)
    static var intOptionalValue: Int?

    @UserDefaultOptional(UserDefaultTestsKeys.stringOptionalValue.rawValue, defaultValue: "991")
    static var stringOptionalValue: String?

    @UserDefaultOptional(UserDefaultTestsKeys.doubleOptionalValue.rawValue, defaultValue: 1.999)
    static var doubleOptionalValue: Double?

    @UserDefaultOptional(UserDefaultTestsKeys.floatOptionalValue.rawValue, defaultValue: 9.111)
    static var floatOptionalValue: Float?

    @UserDefaultOptional(UserDefaultTestsKeys.stringArrayOptionalValue.rawValue, defaultValue: ["up", "down", "charm"])
    static var stringArrayOptionalValue: [String]?

    @UserDefaultOptional(UserDefaultTestsKeys.dicOptionalValue.rawValue, defaultValue: ["up": Int(1), "down": Int(3), "charm": Int(45)])
    static var dicOptionalValue: [String: Int]?

    @UserDefault(UserDefaultTestsKeys.urlOptionalValue.rawValue, defaultValue: URL(string: "www.yahoo.co.jp")!)
    static var urlOptionalValue: URL

    @UserDefaultOptional(UserDefaultTestsKeys.testClassOptionalValue.rawValue, defaultValue: TestClass(p1: 1, p2: "first"))
    static var testClassOptionalValue: TestClass?
}

テストの本体部分です。
初期値のテストをしたあと、値を書き込んでそれが正しく読み取れるかどうかを検証しています。
(長くなるので全部は記載しません)

テストコード(本体)

class UserDefaultsPlusPropertyWrapperTests: XCTestCase {

    override func setUp() {
        for key in UserDefaultTestsKeys.allCases {
            UserDefaults.standard.removeObject(forKey: key.rawValue)
        }
    }

    override func tearDown() {
        //
    }

    func testBoolValue() {
        XCTAssertEqual(UserDefaults.boolValue, true)
        UserDefaults.boolValue = false
        XCTAssertEqual(UserDefaults.boolValue, false)
    }

    func testBoolOptionalValue() {
        XCTAssertEqual(UserDefaults.boolOptionalValue, nil)
        UserDefaults.boolOptionalValue = false
        XCTAssertEqual(UserDefaults.boolOptionalValue, false)
    }

    func testIntValue() {
        XCTAssertEqual(UserDefaults.intValue, 9)
        UserDefaults.intValue = 19
        XCTAssertEqual(UserDefaults.intValue, 19)
    }

    func testIntOptionalValue() {
        XCTAssertEqual(UserDefaults.intOptionalValue, 9)
        UserDefaults.intOptionalValue = 19
        XCTAssertEqual(UserDefaults.intOptionalValue, 19)
    }

    func testStringValue() {
        XCTAssertEqual(UserDefaults.stringValue, "991")
        UserDefaults.stringValue = "morning"
        XCTAssertEqual(UserDefaults.stringValue, "morning")
    }

    func testStringOptionalValue() {
        XCTAssertEqual(UserDefaults.stringOptionalValue, "991")
        UserDefaults.stringOptionalValue = "morning"
        XCTAssertEqual(UserDefaults.stringOptionalValue, "morning")
    }

    func testStringArrayValue() {
        XCTAssertEqual(UserDefaults.stringArrayValue, ["up", "down", "charm"])
        UserDefaults.stringArrayValue = ["morning", "evening"]
        XCTAssertEqual(UserDefaults.stringArrayValue, ["morning", "evening"])
    }

    func testStringArrayOptionalValue() {
        XCTAssertEqual(UserDefaults.stringArrayOptionalValue, ["up", "down", "charm"])
        UserDefaults.stringArrayOptionalValue = ["morning", "evening"]
        XCTAssertEqual(UserDefaults.stringArrayOptionalValue, ["morning", "evening"])
    }

    func testDicValue() {
        XCTAssertEqual(UserDefaults.dicValue, ["up": Int(1), "down": Int(3), "charm": Int(45)])
        UserDefaults.dicValue = ["up": Int(33), "down": Int(9), "charm": Int(15)]
        XCTAssertEqual(UserDefaults.dicValue, ["up": Int(33), "down": Int(9), "charm": Int(15)])
    }

    func testDicOptionalValue() {
        XCTAssertEqual(UserDefaults.dicOptionalValue, ["up": Int(1), "down": Int(3), "charm": Int(45)])
        UserDefaults.dicOptionalValue = ["up": Int(33), "down": Int(9), "charm": Int(15)]
        XCTAssertEqual(UserDefaults.dicOptionalValue, ["up": Int(33), "down": Int(9), "charm": Int(15)])
    }

    func testURLValue() {
        XCTAssertEqual(UserDefaults.urlValue, URL(string: "www.yahoo.co.jp")!)
        UserDefaults.urlValue = URL(string: "www.google.co.jp")!
        XCTAssertEqual(UserDefaults.urlValue, URL(string: "www.google.co.jp")!)
    }

    func testURLOptionalValue() {
        XCTAssertEqual(UserDefaults.urlOptionalValue, URL(string: "www.yahoo.co.jp")!)
        UserDefaults.urlOptionalValue = URL(string: "www.google.co.jp")!
        XCTAssertEqual(UserDefaults.urlOptionalValue, URL(string: "www.google.co.jp")!)
    }

    func testTestClassValue() {
        XCTAssertEqual(UserDefaults.testClassValue.p1, 1)
        XCTAssertEqual(UserDefaults.testClassValue.p2, "first")
        let t = TestClass(p1: 33, p2: "second")
        UserDefaults.testClassValue = t
        XCTAssertEqual(UserDefaults.testClassValue.p1, 33)
        XCTAssertEqual(UserDefaults.testClassValue.p2, "second")
    }

    func testTestClassOptionalValue() {
        XCTAssertEqual(UserDefaults.testClassOptionalValue?.p1, 1)
        XCTAssertEqual(UserDefaults.testClassOptionalValue?.p2, "first")
        let t = TestClass(p1: 33, p2: "second")
        UserDefaults.testClassOptionalValue = t
        XCTAssertEqual(UserDefaults.testClassOptionalValue?.p1, 33)
        XCTAssertEqual(UserDefaults.testClassOptionalValue?.p2, "second")
    }
}
5
4
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?