Objective-C 與 Swift 的那檔事(原文)git
自從蘋果 WWDC 2014 大會上,我們的銀髮帥哥 Craig Federighi 宣佈全新程式語言 Swift 之後,iOS 程式開發就進入了全新的領域(和噩夢XD)。github
Dcard 在去年 Swift 2.0 發布之後便開始使用 Swift 開發,過程中發現了不少使人興奮的新特性、新寫法、和新的地雷(笑),未來我們會將一些使用 Swift 開發的心路歷程分享給各位。swift
今天我們將專注在 NS_STRING_ENUM 這個神奇的語法上,但在這以前我們先來談談 Objective-C 的字串常數。bash
過去我們在使用 Objective-C 時,經常會宣告不少 NSString 的常數來當做 NSDictionary 的 key。例如:app
// Dicitonary keys
NSString * const DCDictionaryKeyTitle = @"title";
NSString * const DCDictionaryKeySubtitle = @"subtitle";
NSString * const DCDictionaryKeyCount = @"count";
// 在使用上則是這樣:
NSDictionary<NSString *, id> *dict = @{......};
NSString *title = dict[DCDictionaryKeyTitle];
NSString *subtitle = dict[DCDictionaryKeySubtitle];
NSInteger count = [dict[DCDictionaryKeyCount] integerValue];
複製代碼
這樣的寫法在 Objective-C 時代沒什麼問題,畢竟過去十幾年來 Objective-C 都是這樣寫的,但現在 Swift 出現了,我們就應該考慮 Objecitv-C 與 Swift 混用的情況。來看一下上面的常數宣告在 import 到 Swift 之後的使用方式:post
// Objective-C 的常數被自動轉換成
let DCDictionaryKeyTitle: String = "title"
let DCDictionaryKeySubtitle: String = "subtitle"
let DCDictionaryKeyCount: String= "count"
// dict 的型別
let dict: [String: Any] = [......]
// 使用
let title = dict[DCDictionaryKeyTitle] as! String
let subtitle = dict[DCDictionaryKeySubtitle] as! String
let count = dict[DCDictionaryKeyCount] as! Int
複製代碼
這樣的寫法雖然是沒有錯的,但卻會有幾個問題。ui
第一,dict 的 key 的型別是 String,因此我們其實能夠用任何的字串當做索引!當工程師第一次看到這個 dictionary 時會須要去查文件看看到底有哪些 key 能夠用(很遺憾的,現在 Notification 的 userInfo 就是這樣麻煩)我們甚至能夠直接用字串 dict["title"] 來取值,若是不當心拼錯字 compiler 和 IDE 但是不會警告的,增長我們寫程式時發生不可預期的錯誤的風險。atom
第二個問題比較小,就是這樣的程式碼看起來很冗長,不夠 Swift,我們重複寫了好屢次的 DCDictionaryKey,看起來的確是不夠簡潔。spa
蘋果也發現了這個問題,因此在最新版本的 Xcode 中提供了 NS_STRING_ENUM和 NS_EXTENSIBLE_STRING_ENUM 給我們使用。code
在 Xcode 8 中,蘋果為 Objective-C 提供了全新的 Macro NS_STRING_ENUM 和 NS_EXTENSIBLE_STRING_ENUM,讓這些字串常數在 Swift 中使用起來更像是 Swift 原生的 string enum,在這邊我們只先討論 NS_STRING_ENUM(至於 NS_EXTENSIBLE_STRING_ENUM ,有興趣的朋友能夠先去看看蘋果官方的 Swift 教學書裡面的解釋)。
NS_STRING_ENUM 的宣告須要搭配 typedef 使用。我們用先前提到的例子來修改,首先在常數以前加上一行宣告,用 DCDicitonaryKey 的做為 NSString* 的別名,並標記為 NS_STRING_ENUM,然後將常數的型別改爲剛剛宣告的 DCDictionaryKey,最後我們的 dictionary 型別也調整一下就大功告成了。在 Objective-C 中使用起來其實沒有什麼差別:
typedef NSString * DCDictionaryKey NS_STRING_ENUM;
DCDictionaryKey const DCDictionaryKeyTitle = @"title";
DCDictionaryKey const DCDictionaryKeySubtitle = @"subtitle";
DCDictionaryKey const DCDictionaryKeyCount = @"count";
// 使用
NSDictionary<DCDictionaryKey, id> *dict = @{......};
NSString *title = dict[DCDictionaryKeyTitle];
NSString *subtitle = dict[DCDictionaryKeySubtitle];
NSInteger count = [dict[DCDictionaryKeyCount] integerValue];
複製代碼
雖然在 Objective-C 使用起來是一樣的,但在自動 import 到 Swift 之後但是徹底不一樣的東西了唷!(畢竟蘋果現在在大力發展和推廣 Swift 嘛!)
// DCDictionaryKey 變成了 enum
enum DCDictionaryKey: String {
case title
case subtitle
case count
}
// dict 的型別是 [DCDictionaryKey: Any]
// 但用起來簡短多了,可使用 Swift 的 enum 短語法
let dict: [DCDictionaryKey: Any] = [......]
let title = dict[.title] as! String
let subtitle = dict[.subtitle] as! String
let count = dict[.count] as! Int
// 這時若是我們直接用 "title" 當做 key 的話,compiler 會報錯
let title = dict["title"] as! String // Ambiguous reference to member 'subscript'
複製代碼
到這邊有沒有稍微瞭解 NS_STRING_ENUM 的用處了呢?其實上面所提到的例子均可以在蘋果官方的 Swift 教學書裡頭找到,有興趣的朋友能夠去讀一下原文。接下來我們來講一下 NS_STRING_ENUM 在 Dcard 中的運用。
在 Dcard 中,我們找到了一個的方能夠來實做 NS_STRING_ENUM,那就是通知。
Dcard 通知頁面
這個部分每一筆通知的物件 Model 是用 Objective-C 寫成的,而畫面中 View 和 Controller 用 Swift 寫成的,正好符合兩個語言混用的情境。而我們要實驗的對象,是通知物件的payload屬性。
在 Dcard 的 API 中,payload 是一個 JSON,這個 JSON 裡面可能的資訊有:
文章標題
文章 ID
喜歡數
新回應數
…以及不少其餘資訊
在修改以前,我們的通知 Model 和 payload key 的一部分以下(JSON 與 Model 映射使用Mantle)
/* DCNotification.h */
extern NSString * const DCNotificationPayloadKeyPostTitle;
extern NSString * const DCNotificationPayloadKeyCount;
@interface DCNotification : MTLModel <MTLJSONSerializing>
...
@property (nonatomic, readonly) NSDictionary<NSString *, id> *payload;
...
@end
複製代碼
在此我們只專注在文章標題和新回應數就好,DCNotificationPayloadKeyPostTitle 是文章標題的 key、DCNotificationPayloadKeyCount 則是新回應數的 key。
在以 Swift 寫成的 DCNotificationCell 中是長這樣子的:
/* DCNotificationCell.swift */
...
let title = notification.payload?[DCNotificationPayloadKeyPostTitle] as? String
let count = notification.payload?[DCNotificationPayloadKeyCount] as? Int
let text = "你追蹤的文章「\(title ?? "")」有 \(count ?? 0) 個新回應"
label.attributedText = NSAttributedString(string: text, attributes: textAttributes())
...
複製代碼
再來看我們以 NS_STRING_ENUM 改寫之後的程式碼。
/* DCNotification.h */
typedef NSString * DCNotificationPayloadKey NS_STRING_ENUM;
extern DCNotificationPayloadKey const DCNotificationPayloadKeyPostTitle;
extern DCNotificationPayloadKey const DCNotificationPayloadKeyCount;
@interface DCNotification : MTLModel <MTLJSONSerializing>
...
@property (nonatomic, readonly) NSDictionary<DCNotificationPayloadKey, id> *payload;
...
@end
複製代碼
/* DCNotificationCell.swift */
...
let title = notification.payload?[.postTitle] as? String
let count = notification.payload?[.count] as? Int
let text = "你追蹤的文章「\(title ?? "")」有 \(count ?? 0) 個新回應"
label.attributedText = NSAttributedString(string: text, attributes: textAttributes())
複製代碼
是否是簡短了些,也更不會不當心寫錯發生低級錯誤了呢?
到目前為止改寫都很順利,但我們想了一下,若是說 NS_STRING_ENUM 會自動轉成 Swift 的 enum,那何不來試試直接用 Swift 重寫通知的 Model 呢?於是乎有了這段程式碼(JSON 與 Model 的映射使用ObjectMapper)
/* DCNotification.swift */
enum DCNotificationPayloadKey: String {
case postTitle = "postTitle"
case count = "count"
...
}
class DCNotification: Mappable {
...
private(set) var payload: [DCNotificationPayloadKey: Any] = [:]
...
複製代碼
看起來沒什麼問題,那麼執行起來的結果是:
payload 資料沒有被顯示出來,是空白的!很顯然我們踩到了一個雷。經過一番檢查我們發現了問題的緣由:在這次嘗試中,我們直接將 API 回傳的 JSON dictionary 映射給 payload,但我們其實不能直接將 [String: Any] 指派給 [DCNotificationPayloadKey: Any],所以 payload 始終是空字典 [:],我們當然沒辦法取得 payload 資訊,事實上整個 payload 的資訊都丟失掉了!
第二次嘗試,我們將 DCNotification.payload 的型別改回 [String: Any],但如此一來不僅我們就能夠直接用字串當索引,在使用 DCNotificationPayloadKey 時又必須加上 rawValue 這樣不直覺的寫法:
let title = notification.payload?[DCNotificationPayloadKey.postTitle.rawValue] as? String
複製代碼
第三次嘗試,我們在 JSON Mapping 的時候轉換型別,我們為 payload 加入了一個Transform:
struct PayloadTransform: TransformType {
typealias Object = [DCNotificationPayloadKey: Any]
typealias JSON = [String: Any]
func transformFromJSON(_ value: Any?) -> [DCNotificationPayloadKey: Any]? {
guard let value = value as? [String: Any] else {
return [:]
}
var result = [AppNotificationPayloadKey: Any]()
for (key, value) in value {
if let payloadKey = DCNotificationPayloadKey(rawValue: key) {
result[payloadKey] = value
}
}
return result
}
func transformToJSON(_ value: [DCNotificationPayloadKey: Any]?) -> [String: Any]? {
...
}
}
複製代碼
如此一來我們成功將 [String: Any] 映射成 [DCNotificationPayloadKey: Any],使用上也跟本來設想的一樣簡潔,所以這是我們比較推薦的作法。
最終的結果長這樣,雖然外觀看起來和用 Objective-C 寫的程式一模一樣,但內在但是大改變呢!
功能正常的 Swift 新版通知頁面:
雖然 NS_STRING_ENUM 這個功能會讓 Objective-C 的程式碼看起來更長一些也更複雜一些,但未來 Swift 勢必會成為主流,各位也必定會遇到 Objective-C 與 Swift 混合使用的情況(以 Dcard 來說,根據 Github 自動統計,目前 Swift 程式碼已經佔整個 app 的 40% 了),屆時在 Objective-C 中使用這些蘋果提供的新功能(nullability, lightweight generic...,etc)來讓程式碼 import 到 Swift 時更易用、更現代感,將會是 iOS Developer 很重要的工做唷。