初探 NS_STRING_ENUM

819 阅读7分钟

Objective-C 與 Swift 的那檔事(原文

前言

自從蘋果 WWDC 2014 大會上,我們的銀髮帥哥 Craig Federighi 宣布全新程式語言 Swift 之後,iOS 程式開發就進入了全新的領域(和噩夢XD)。

“The language is called Swift and it totally rules.”

Dcard 在去年 Swift 2.0 發布之後便開始使用 Swift 開發,過程中發現了很多令人興奮的新特性、新寫法、和新的地雷(笑),未來我們會將一些使用 Swift 開發的心路歷程分享給各位。

今天我們將專注在 NS_STRING_ENUM 這個神奇的語法上,但在這之前我們先來談談 Objective-C 的字串常數。

Objective-C 字串常數

過去我們在使用 Objective-C 時,經常會宣告很多 NSString 的常數來當作 NSDictionary 的 key。例如:

// 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 之後的使用方式:

// 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

這樣的寫法雖然是沒有錯的,但卻會有幾個問題。

第一,dict 的 key 的型別是 String,所以我們其實可以用任何的字串當作索引!當工程師第一次看到這個 dictionary 時會需要去查文件看看到底有哪些 key 可以用(很遺憾的,現在 Notification 的 userInfo 就是這樣麻煩)我們甚至可以直接用字串 dict["title"] 來取值,如果不小心拼錯字 compiler 和 IDE 可是不會警告的,增加我們寫程式時發生不可預期的錯誤的風險。

第二個問題比較小,就是這樣的程式碼看起來很冗長,不夠 Swift,我們重複寫了好多次的 DCDictionaryKey,看起來的確是不夠簡潔。

蘋果也發現了這個問題,所以在最新版本的 Xcode 中提供了 NS_STRING_ENUM和 NS_EXTENSIBLE_STRING_ENUM 給我們使用。

NS_STRING_ENUM

在 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 app 中運用 NS_STRING_ENUM

在 Dcard 中,我們找到了一個的方可以來實作 NS_STRING_ENUM,那就是通知。

Dcard 通知頁面

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 資料沒有被顯示出來

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 新版通知頁面:

功能正常的 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 很重要的工作唷。