はじめに
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クラスがオプショナル非対応です。
@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クラスがオプショナル対応
@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を使って変数を定義していきます。
(長くなるので全部は記載しません)
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")
}
}