1. 程式人生 > >靜態類型的 NSUserDefaults

靜態類型的 NSUserDefaults

enable dict 靜態類型語言 wiki 建議 技巧 col led deadline

一年前,在 Swift 推出不久後,我觀察到許多 iOS 開發者仍然以 Objective-C 的開發習慣來寫 Swift。而在我眼中,Swift 是一門全新的語言,有別於 Objective-C 的語法、設計哲學乃至發展潛力,因此我們更應探索出一條屬於 Swift 獨有風格的發展道路。我在之前的文章 Swifty methods 中已經探討過在 Swift 中如何清晰、明確地對方法進行命名,隨後我開始連載 Swifty API 系列文章,同時將這一想法付諸實踐,探索如何設計更加簡單易用的接口 API。

在該系列(Swifty API)第一篇文章中,我們對 NSUserDefaults API 進行了改造:

NSUserDefaults.standardUserDefaults().stringForKey("color") NSUserDefaults.standardUserDefaults().setObject("red", forKey: "color")

改造之後看上去像這樣:

Defaults["color"].string Defaults["color"] = "red"

相較之前的 get 和 set 方法,改造後的結果更加簡單明了,同時也修正了一致性問題,使其更符合 Swift 的使用風格。這看上去是相當大的改進。

但是,隨著我對 Swift 深入學習,以及真正在項目中使用這些由我親手締造的 API 後,我才意識到這些 API 離真正的原生 Swift 風格還有相當大的差距。在之前的 API 設計中,我從 Ruby 和 Swift 的語法中汲取靈感來構建自己的 API,這一點雖然值得肯定,但是我們並沒有將其真正提升到語義學的高度。僅僅是在外表裹了層 Swift 風格的外衣,而內部機制仍然是以 Objective-C 的形式在運作。

缺點

「API 不是那麽 Swift 化」,聽上去並不是一個讓我們從頭開始的好理由,雖然相似的 API 更容易學習,但我們不想這麽教條。我們不僅僅想要設計出來的 API 看上去更加 Swift 化,還希望能在 Swift 的運行機制下更好地工作。這樣看來,我們之前設計的 NSUserDefaults 存在一些小問題:

假設你有一個關於用戶喜好顏色的設置選項:

Defaults["color"] = "red" // App 中的其他一處: Defaults["colour"].string // => nil

啊哦,一旦不小心把鍵名(key name)寫錯了,就會出現 Bug :(

同理我們放一個 date 對象到 defaults 中:

Defaults["deadline"] = NSDate.distantFuture() Defaults["deadline"].data // => nil

這一次在 getter 方法中把 date 拼錯成了 data,結果是 nil,又獲得 bug 一枚。你或許認為這種情況並不會經常發生,但為什麽每次我們要對取回(getter)的對象指定類型呢?這確實有點煩人。

再來一個例子(這個例子我們在賦值的時候就錯了,取回的結果當然是 nil):

Defaults["deadline"] = NSData() Defaults["deadline"].date // => nil

顯然我們想要「現在的時間」的日期而不是一個「空的data值」!

最後觀察下面的代碼:

Defaults["magic"] = 3.14 Defaults["magic"] += 10 Defaults["magic"] // => 13

在第一篇文章中,我們重新定義了 +=,使其能夠在我的新 API 下正常工作,但這兒有個缺陷:只能從傳遞進來的參數進行類型推斷(Int or Double)。也就是說如果你參數傳一個整數(Int)10,運算後的結果是 13.14(Double)類型,但最終返回的結果還是會以上一次傳入的參數類型為基準,決定最終的返回值。這個例子最後返回值為 13,砍掉了小數部分,顯然是個 bug。

你又或許認為以上都是純理論問題,在真實世界裏並不會發生。先別著急下結論,仔細想想,這些拼錯變量名、方法名和傳遞一個錯誤類型的參數其實都可以歸為同一類型的 bug,而這些 bug 在日常開發中是常有之事。如果你正在使用一門需要編譯的靜態類型語言,那麽你更應該依賴編譯器給你的反饋而不是事後去測試,更重要的是,花費精力在編譯期進行檢查也會在將來給你帶來豐厚的紅利,這不僅僅是在首次寫代碼時才能享受這種編譯器檢查帶來的好處,在之後的重構中,你也能減少很多不必要的 bug。這裏提供一些小建議,可以讓未來的你免受 bug 之苦。

靜態類型的力量

導致這些問題的根源在於:沒有定義關於 user defaults 的靜態結構。

在早先的設計中,我意識到這個問題,於是將各種類型封裝在了 NSUserDefaults 內部的 Proxy 對象中。調用時你可以通過下標(subscript)獲得一個 Proxy 對象,然後再通過 Proxy 提供的訪問方法來實現特定類型的訪問。

Defaults["color"].string Defaults["launchCount"].int

采用上面這種方式,比你自己實現 getter 方法或手動對 AnyObject 類型轉換要好很多。

但這是一個 hack,並不算真正的解決方案。為了對 API 實現真正意義上的改進,我們需要收集有關 user default keys 的信息,之後提供給編譯器。

現在回想下那位長者傳授給我們的人生經驗。通常不變的常量字符串,為了避免拼寫錯誤,會在一開始就以 string keys 的形式定義,隨後使用時編譯器也會自動補全:

let colorKey = "color"

讓我們帶上類型信息:

class DefaultsKey<ValueType> { let key: String init(_ key: String) { self.key = key } } let colorKey = DefaultsKey<String?>("color")

我們將 key name 封裝在一個對象中,並且將值類型植入到泛型參數中。現在我們可以定義一個新的 NSUserDefaults 下標,用來接收這些 keys:

extension NSUserDefaults { subscript(key: DefaultsKey<String?>) -> String? { get { return stringForKey(key.key) } set { setObject(newValue, forKey: key.key) } } }

這裏是結果:

let key = DefaultsKey<String?>("color") Defaults[key] = "green" Defaults[key] // => "green", typed as String?

沒錯,就這麽簡單,語法和功能稍後再來完善。我們通過這個小技巧,修復了許多問題。比如沒辦法再輕易拼錯 key name 了,因為他只能定義一次。也不能隨便就賦值一個不匹配的類型了,因為你這麽做編譯器會報錯。最後也不必寫 .string,因為編譯器已經知道我們想要的類型了。

此外,我們或許應該使用泛型來定義 NSUserDefaults 的下標(subscripts),而不是手動輸入所有需要的類型。不過想法總是美好的,現實卻是殘酷的,Swift 的編譯器目前還不支持泛型下標。(╯‵□′)╯︵┻━┻ 方括號可能看上去還不錯,別再糾結語法了,我們讓 setting 和 getting 方法更加泛型化就好了。

等等,你還沒有見識過下標 subscripts 的能耐!

令人振奮的下標

考慮下面這種寫法:

var array = [1, 2, 3] array.first! += 10

完全不能通過編譯!我們嘗試對數組內部的整數進行加法操作,但這對於 Swift 來說是做不到的。整數具有值語義,是不可變的。當他們從某些地方返回時,你不能直接去修改他們的值,這是因為他們並不存在於表達式之外,僅算是瞬時狀態下的一份拷貝罷了。

改換變量來做就沒問題:

var number = 1 number += 10

註意,實際並沒有真正意義上改變 1 這個整數,而只是修改了變量,為其分配了一個新值而已。

再來看看下面這段代碼:

var array = [1, 2, 3] array[0] += 10 array // => [11, 2, 3]

結果終於如你所願了,不是嗎?這和你想象中的一樣,可是為什麽這麽做就可以了呢?

觀察一下,在 Swift 中,下標和裏面的值類型似乎也合作地非常愉快。我們可以通過下標來修改數組裏的值,是因為他在內部實現了 gettersetter 方法。編譯器層面所做的工作是將 array[0] += 10重寫為 array[0] = array[0] + 10。如果你只實現了 getter 下標 subscript,而沒有實現 setter,是不會正常工作的。

這不僅僅是數組(Array)特有的黑魔法,這是下標(subscript)語義精心設計後的結果,我們可以在自己實現的 subscripts 免費獲得這種特性,比如我們還可以這麽玩:

Defaults[launchCountKey]++ Defaults[volumeKey] -= 0.1 Defaults[favoriteColorsKey].append("green") Defaults[stringKey] += "… can easily be extended!"

有意思吧,要知道在 API 1.0 版本,我們僅僅模仿字典那樣使用下標,並沒有利用上面介紹的這種語義。

我們還添加了一些 +=++ 這樣的操作符,但是這種行為比較危險,主要依賴於編譯器的魔法實現。在這裏我們通過將類型信息封裝在 key 中,然後定義了 subscriptgettersetter 方法,現在整個世界看上去運轉正常。

捷徑

在老版本 API 設計中,使用字符串 key 的好處在於你可以按需使用,而不用去創建任何中間對象。

而在目前改進的新版本中,每次使用前都要創建鍵對象key object)好像沒什麽道理,況且這會帶來可怕的重復以及抵消掉靜態類型帶來的好處。所以讓我們再想想如何能夠更好地組織 defaults keys

一種解決方案就是在類層級(class level)定義這些 keys:

class Foo { struct Keys { static let color = DefaultsKey<String>("color") static let counter = DefaultsKey<Int>("counter") } func fooify() { let color = Defaults[Keys.color] let counter = Defaults[Keys.counter] } }

這似乎已經是 Swift 關於字符型 keys 的標準實踐了。

另一種解決方案是利用 Swift 的隱式成員表達式,此功能的最常見用途是枚舉。當一個方法需要一個枚舉類型 Direction 作為參數,你可以傳遞 .Right。編譯器能夠推斷出真正的參數類型Direction.Right。這裏有個冷知識:這種特性(隱式成員表達式)同樣適用於方法參數是靜態成員類型的情形,例如你可以在一個需要 CGRect 類型做參數的方法中,使用 .zeroRect 來代替 CGRect.zeroRect

事實上,我們可以通過把鍵定義為 DefaultsKey 上的靜態常量來做相同的事情。好啦,差不多了,最後為了消除編譯器上的限制,我們需要一個稍微不同的定義:

class DefaultsKeys {} class DefaultsKey<ValueType>: DefaultsKeys { ... } extension DefaultsKeys { static let color = DefaultsKey<String>("color") }

試一下效果,哇,不錯哦!

Defaults[.color] = "red"

是不是很炫酷?站在調用者的角度,現在比之前用傳統字符串的方式顯得不再那麽冗余,開發者的代碼量減少了,讀起來也更直觀。有沒有感到很興奮,如果我告訴你這一切都是免費獲得的,你會不會更開心。

(這項技術的一個缺陷就是沒有命名空間機制,在大工程中還是老實采用鍵結構體 Keys struct 的方式更好一些。)

可選值難題

在前一版設計的 API 中,我們讓所有的 getters 都返回可選值,不過我不大喜歡 NSUserDefaults 處理不同類型時缺乏一致性,對於字符串,缺失值將返回 nil,但是對於數字和布爾值,你將會得到 0false

我很快意識到這種方式缺點是太冗長。大多數時候我們並不關心 nil 的情形,只希望在這種情況下得到一個默認值,僅此而已。而每次我們通過下標(subscripts)獲得一個可選值後,都要先解封包做判斷,再決定返回解包值還是預設的默認值。

Oleg Kokhtenko 針對這個問題提出了解決方案,除了標準的可選返回值的 getter 方法,我們還添加了一組 getter 方法,這些方法都以標誌性的 -Value 結尾,並且結果為 nil 時會返回默認值代替,這樣類型更加明確:

Defaults["color"].stringValue // 默認得到"" Defaults["launchCount"].intValue // 默認得到0 Defaults["loggingEnabled"].boolValue // 默認得到false Defaults["lastPaths"].arrayValue // 默認得到[] Defaults["credentials"].dictionaryValue // 默認得到[:] Defaults["hotkey"].dataValue // 默認得到NSData()

我們可以在靜態類型體制下做同樣的事情,下面為 optional 和非 optional 類型各提供一個 subscript 變體。

extension NSUserDefaults { subscript(key: DefaultsKey<NSData?>) -> NSData? { get { return dataForKey(key.key) } set { setObject(newValue, forKey: key.key) } } subscript(key: DefaultsKey<NSData>) -> NSData { get { return dataForKey(key.key) ?? NSData() } set { setObject(newValue, forKey: key.key) } } }

我喜歡這麽做,因為這樣就不用依賴協定約定(typetypeValue),將空值轉換為各種類型的默認值。而是使用已經在 user defaults key 中定義好的類型,剩下的工作就交給編譯器吧。

更多的類型

我通過添加這些類型的下標來擴大支持的類型範圍:StringIntDoubleBoolNSData[AnyObject][String: AnyObject]NSStringNSArrayNSDictionary(還包含他們的可選變體,註意 NSDate?NSURL?AnyObject? 沒有對應的非可選部分,因為這些類型的默認值沒有意義)。

還要註意一點,字符串(strings)、字典(dictionaries)和數組(arrays)同時存在於 Swift 基本庫和 Cocoa Foundation 框架中。而我們優先考慮 Swift 原生類型,但這些類型並不具備他們在 Cocoa 框架中的一些能力,不過如果真正需要,我會讓事情簡單一些。

提到數組,為什麽把我們只限制沒有類型化的數組?因為在大多數情況下,user defaults 中存儲的數組裏面的元素都是同一類型的,比如 StringIntNSData

因為不能定義泛型下標,我們來創建一對泛型 helper 方法:

extension NSUserDefaults { func getArray<T>(key: DefaultsKey<[T]>) -> [T] { return arrayForKey(key.key) as? [T] ?? [] } func getArray<T>(key: DefaultsKey<[T]?>) -> [T]? { return arrayForKey(key.key) as? [T] } }

復制、粘貼,然後參照下面這段代碼改寫所有我們感興趣的類型:

extension NSUserDefaults { subscript(key: DefaultsKey<[String]?>) -> [String]? { get { return getArray(key) } set { set(key, newValue) } } }

現在可以這樣調用:

let key = DefaultsKey<[String]>("colors") Defaults[key].append("red") let red = Defaults[key][0]

我們通過數組下標返回一個 String,然後為其添加了一個字符串,整個驗證過程發生在了編譯期(編譯器會對進行的操作進行類型檢查),這樣做更加安全便捷。

歸檔

NSUserDefaults 還有一個缺點是支持的類型並不多,如果我們想存儲自定義的類型,通用的解決辦法是用 NSKeyedArchiver 來序列化你的自定義對象。

接下來我們努力把世界變得更美好一點,類似於 getArray 的 helper 方法,我定義了 archive()unarchive() 的泛型方法,這樣我就能很容易地設計一段下標代碼來處理各種自定義類型(前提是這些類型遵循 NSCoding 協議)。

extension NSUserDefaults { subscript(key: DefaultsKey<NSColor?>) -> NSColor? { get { return unarchive(key) } set { archive(key, newValue) } } } extension DefaultsKeys { static let color = DefaultsKey<NSColor?>("color") } Defaults[.color] // => nil Defaults[.color] = NSColor.whiteColor() Defaults[.color] // => w 1.0, a 1.0 Defaults[.color]?.whiteComponent // => 1.0

(譯者註:NSColor 遵循 NSSecureCoding 協議,而該協議繼承自 NSCoding

看上去並不十分完美,但我們僅用了幾行代碼就讓 NSUserDefaults 很好地支持了自定義類型。

結果和結論

萬事俱備,下面有請我們新的 API 登場:

// 提前定義鍵名 extension DefaultsKeys { static let username = DefaultsKey<String?>("username") static let launchCount = DefaultsKey<Int>("launchCount") static let libraries = DefaultsKey<[String]>("libraries") static let color = DefaultsKey<NSColor?>("color") } // 使用點語法來獲取 user defaults Defaults[.username] // 使用非可選的鍵來獲取默認值而非可選值 Defaults[.launchCount] // Int, 默認值是0 // 就地更新 value 的值 Defaults[.launchCount]++ Defaults[.volume] += 0.1 Defaults[.strings] += "… can easily be extended!" // 使用和修改數組類型 Defaults[.libraries].append("SwiftyUserDefaults") Defaults[.libraries][0] += " 2.0" // 方便地使用序列化的自定義類型 Defaults[.color] = NSColor.whiteColor() Defaults[.color]?.whiteComponent // => 1.0

Swift 中使用起來不再痛苦的靜態類型

希望你已經看到這種靜態類型帶來的好處,我們只付出了很小的代價,包括提前定義 DefaultsKey,遵從 Swift 的類型系統。而作為回報,編譯器向我們獻上一份大禮:

  • 編譯期檢查(鍵名,讀、寫的類型檢查)
  • 鍵名(key names)自動補全
  • 類型推斷——不必在末尾輸入 .string 或手動對 AnyObject 進行類型轉換
  • 我們可以直接操作 user defaults 裏面的值,而不需要通過中間步驟或魔法運算符
  • 一致性——拋開怪異的 keys,Defaults 看上去更像是一個定義了類型的字典

這裏還有一個潛在優勢:可以自動享受到今後 Swift 的發展紅利。

真正的 Swift 的 API 也利用了靜態類型特性,這裏不是要教條主義,條條大路通羅馬,肯定還有其他的最佳解決方案。但當你決定回到 Objective-C 或 JavaScript 的編碼習慣時,重新考慮一下靜態類型所帶來的好處,還要明白一點,這種靜態類型不是你前輩所熟悉的靜態類型,Swift 豐富的類型系統允許你創造出極具表現力和易用的 API,而實現這一切的開銷卻可以忽略不計。

試試看

一如既往,我將以上所有的探索整理成了一個庫,放在 GitHub 上,如果感興趣,可以采取下面的方式引用:

# with CocoaPods: pod ‘SwiftyUserDefaults‘ # with Carthage: github "radex/SwiftyUserDefaults"

同樣也鼓勵你去試用我改造的另一個 Swifty API(NSTimer),關於如何清晰命名請看我這篇文章 Swifty methods。

最後如果你對本文有什麽好的想法或建議,請務必聯系我 Twitter 或提出 issue

靜態類型的 NSUserDefaults