我們在 2013 年 11 月份開始寫這本書,最初的目標是提供一份如何編寫干淨漂亮的 Objective-C 代碼的指南:現在雖然有很多指南,但是它們都是有一些問題的。我們不想介紹一些死板的規定,我們想提供一個在開發者們之間寫更一致的代碼的途徑。隨時間的推移,這本書開始轉向介紹如何設計和構建優秀的代碼。
這本書的觀點是代碼不僅是可以編譯的,同時應該是 “有效” 的。好的代碼有一些特性:簡明,自我解釋,優秀的組織,良好的文檔,良好的命名,優秀的設計以及可以被久經考驗。
本書的一個理念是是代碼的清晰性優先於性能,同時闡述為什麼應該這麼做。
雖然所有的代碼都是 Objective-C 寫的,但是一些主題是通用的,並且獨立於編程語言。
條件語句
條件語句體應該總是被大括號包圍。盡管有時候你可以不使用大括號(比如,條件語句體只有一行內容),但是這樣做會帶來問題隱患。比如,增加一行代碼時,你可能會誤以為它是 if 語句體裡面的。此外,更危險的是,如果把 if 後面的那行代碼注釋掉,之後的一行代碼會成為 if 語句裡的代碼。
推薦:
if (!error) {
return success;
}
不推薦:
if (!error)
return success;
和
if (!error) return success;
在 2014年2月 蘋果的 SSL/TLS 實現裡面發現了知名的 goto fail 錯誤。
代碼在這裡:
static OSStatus
SSLVerifySignedServerKeyExchange(SSLContext *ctx, bool isRsa, SSLBuffer signedParams,
uint8_t *signature, UInt16 signatureLen)
{
OSStatus err;
...
if ((err = SSLHashSHA1.update(&hashCtx, &serverRandom)) != 0)
goto fail;
if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)
goto fail;
goto fail;
if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0)
goto fail;
...
fail:
SSLFreeBuffer(&signedHashes);
SSLFreeBuffer(&hashCtx);
return err;
}
顯而易見,這裡有沒有括號包圍的2行連續的 goto fail;
。我們當然不希望寫出上面的代碼導致錯誤。
此外,在其他條件語句裡面也應該按照這種風格統一,這樣更便於檢查。
不要使用尤達表達式。尤達表達式是指,拿一個常量去和變量比較而不是拿變量去和常量比較。它就像是在表達 “藍色是不是天空的顏色” 或者 “高個是不是這個男人的屬性” 而不是 “天空是不是藍的” 或者 “這個男人是不是高個子的”
推薦:
if ([myValue isEqual:@42]) { ...
不推薦:
if ([@42 isEqual:myValue]) { ...
類似於 Yoda 表達式,nil 檢查的方式也是存在爭議的。一些 notous 庫像這樣檢查對象是否為 nil:
if (nil == myValue) { ...
或許有人會提出這是錯的,因為在 nil 作為一個常量的情況下,這樣做就像 Yoda 表達式了。 但是一些程序員這麼做的原因是為了避免調試的困難,看下面的代碼:
if (myValue == nil) { ...
如果程序員敲錯成這樣:
if (myValue = nil) { ...
這是合法的語句,但是即使你是一個豐富經驗的程序員,即使盯著眼睛瞧上好多遍也很難調試出錯誤。但是如果把 nil 放在左邊,因為它不能被賦值,所以就不會發生這樣的錯誤。 如果程序員這樣做,他/她就可以輕松檢查出可能的原因,比一遍遍檢查敲下的代碼要好很多。
為了避免這些奇怪的問題,可以用感歎號來作為運算符。因為 nil 是 解釋到 NO,所以沒必要在條件語句裡面把它和其他值比較。同時,不要直接把它和 YES
比較,因為 YES
的定義是 1, 而 BOOL
是 8 bit的,實際上是 char 類型。
推薦:
if (someObject) { ...
if (![someObject boolValue]) { ...
if (!someObject) { ...
不推薦:
if (someObject == YES) { ... // Wrong
if (myRawValue == YES) { ... // Never do this.
if ([someObject boolValue] == NO) { ...
同時這樣也能提高一致性,以及提升可讀性。
在使用條件語句編程時,代碼的左邊距應該是一條“黃金”或者“快樂”的大道。 也就是說,不要嵌套 if
語句。使用多個 return 可以避免增加循環的復雜度,並提高代碼的可讀性。因為方法的重要部分沒有嵌套在分支裡面,並且你可以很清楚地找到相關的代碼。
推薦:
- (void)someMethod {
if (![someOther boolValue]) {
return;
}
//Do something important
}
不推薦:
- (void)someMethod {
if ([someOther boolValue]) {
//Do something important
}
}
當你有一個復雜的 if 子句的時候,你應該把它們提取出來賦給一個 BOOL 變量,這樣可以讓邏輯更清楚,而且讓每個子句的意義體現出來。
BOOL nameContainsSwift = [sessionName containsString:@"Swift"];
BOOL isCurrentYear = [sessionDateCompontents year] == 2014;
BOOL isSwiftSession = nameContainsSwift && isCurrentYear;
if (isSwiftSession) {
// Do something very cool
}
三元運算符 ? 應該只用在它能讓代碼更加清楚的地方。 一個條件語句的所有的變量應該是已經被求值了的。類似 if 語句,計算多個條件子句通常會讓語句更加難以理解。或者可以把它們重構到實例變量裡面。
推薦:
result = a > b ? x : y;
不推薦:
result = a > b ? x = c > d ? c : d : y;
當三元運算符的第二個參數(if 分支)返回和條件語句中已經檢查的對象一樣的對象的時候,下面的表達方式更靈巧:
推薦:
result = object ? : [self createObject];
不推薦:
result = object ? object : [self createObject];
有些方法通過參數返回 error 的引用,使用這樣的方法時應當檢查方法的返回值,而非 error 的引用。
推薦:
NSError *error = nil;
if (![self trySomethingWithError:&error]) {
// Handle Error
}
此外,一些蘋果的 API 在成功的情況下會對 error 參數(如果它非 NULL)寫入垃圾值(garbage values),所以如果檢查 error 的值可能導致錯誤 (甚至崩潰)。
除非編譯器強制要求,括號在 case 語句裡面是不必要的。但是當一個 case 包含了多行語句的時候,需要加上括號。
switch (condition) {
case 1:
// ...
break;
case 2: {
// ...
// Multi-line example using braces
break;
}
case 3:
// ...
break;
default:
// ...
break;
}
有時候可以使用 fall-through 在不同的 case 裡面執行同一段代碼。一個 fall-through 是指移除 case 語句的 “break” 然後讓下面的 case 繼續執行。
switch (condition) {
case 1:
case 2:
// code executed for values 1 and 2
break;
default:
// ...
break;
}
當在 switch 語句裡面使用一個可枚舉的變量的時候,default
是不必要的。比如:
switch (menuType) {
case ZOCEnumNone:
// ...
break;
case ZOCEnumValue1:
// ...
break;
case ZOCEnumValue2:
// ...
break;
}
此外,為了避免使用默認的 case,如果新的值加入到 enum,程序員會馬上收到一個 warning 通知
Enumeration value 'ZOCEnumValue3' not handled in switch.(枚舉類型 'ZOCEnumValue3' 沒有被 switch 處理)
當使用 enum
的時候,建議使用新的固定的基礎類型定義,因為它有更強大的類型檢查和代碼補全。 SDK 現在有一個 宏來鼓勵和促進使用固定類型定義 - NS_ENUM()
例子:
typedef NS_ENUM(NSUInteger, ZOCMachineState) {
ZOCMachineStateNone,
ZOCMachineStateIdle,
ZOCMachineStateRunning,
ZOCMachineStatePaused
};
盡可能遵守 Apple 的命名約定,尤其是和 內存管理規則 (NARC) 相關的地方。
推薦使用長的、描述性的方法和變量名。
推薦:
UIButton *settingsButton;
不推薦:
UIButton *setBut;
常量應該以駝峰法命名,並以相關類名作為前綴。
推薦:
static const NSTimeInterval ZOCSignInViewControllerFadeOutAnimationDuration = 0.4;
不推薦:
static const NSTimeInterval fadeOutTime = 0.4;
推薦使用常量來代替字符串字面值和數字,這樣能夠方便復用,而且可以快速修改而不需要查找和替換。常量應該用 static
聲明為靜態常量,而不要用 #define
,除非它明確的作為一個宏來使用。
推薦:
static NSString * const ZOCCacheControllerDidClearCacheNotification = @"ZOCCacheControllerDidClearCacheNotification";
static const CGFloat ZOCImageThumbnailHeight = 50.0f;
不推薦:
#define CompanyName @"Apple Inc."
#define magicNumber 42
常量應該在頭文件中以這樣的形式暴露給外部:
extern NSString *const ZOCCacheControllerDidClearCacheNotification;
並在實現文件中為它賦值。
只有公有的常量才需要添加命名空間作為前綴。盡管實現文件中私有常量的命名可以遵循另外一種模式,你仍舊可以遵循這個規則。
方法名與方法類型 (-
/+
符號)之間應該以空格間隔。方法段之間也應該以空格間隔(以符合 Apple 風格)。參數前應該總是有一個描述性的關鍵詞。
盡可能少用 “and” 這個詞。它不應該用來闡明有多個參數,比如下面的 initWithWidth:height:
這個例子:
推薦:
- (void)setExampleText:(NSString *)text image:(UIImage *)image;
- (void)sendAction:(SEL)aSelector to:(id)anObject forAllCells:(BOOL)flag;
- (id)viewWithTag:(NSInteger)tag;
- (instancetype)initWithWidth:(CGFloat)width height:(CGFloat)height;
不推薦:
- (void)setT:(NSString *)text i:(UIImage *)image;
- (void)sendAction:(SEL)aSelector :(id)anObject :(BOOL)flag;
- (id)taggedView:(NSInteger)tag;
- (instancetype)initWithWidth:(CGFloat)width andHeight:(CGFloat)height;
- (instancetype)initWith:(int)width and:(int)height; // Never do this.
使用字面值來創建不可變的 NSString
, NSDictionary
, NSArray
, 和 NSNumber
對象。注意不要將 nil
傳進 NSArray
和 NSDictionary
裡,因為這樣會導致崩潰。
例子:
NSArray *names = @[@"Brian", @"Matt", @"Chris", @"Alex", @"Steve", @"Paul"];
NSDictionary *productManagers = @{@"iPhone" : @"Kate", @"iPad" : @"Kamal", @"Mobile Web" : @"Bill"};
NSNumber *shouldUseLiterals = @YES;
NSNumber *buildingZIPCode = @10018;
不要這樣:
NSArray *names = [NSArray arrayWithObjects:@"Brian", @"Matt", @"Chris", @"Alex", @"Steve", @"Paul", nil];
NSDictionary *productManagers = [NSDictionary dictionaryWithObjectsAndKeys: @"Kate", @"iPhone", @"Kamal", @"iPad", @"Bill", @"Mobile Web", nil];
NSNumber *shouldUseLiterals = [NSNumber numberWithBool:YES];
NSNumber *buildingZIPCode = [NSNumber numberWithInteger:10018];
如果要用到這些類的可變副本,我們推薦使用 NSMutableArray
, NSMutableString
這樣的類。
應該避免下面這樣:
NSMutableArray *aMutableArray = [@[] mutableCopy];
上面這種書寫方式的效率和可讀性的都存在問題。
效率方面,一個不必要的不可變對象被創建後立馬被廢棄了;雖然這並不會讓你的 App 變慢(除非這個方法被頻繁調用),但是確實沒必要為了少打幾個字而這樣做。
可讀性方面,存在兩個問題:第一個問題是當你浏覽代碼並看見 @[]
的時候,你首先聯想到的是 NSArray
實例,但是在這種情形下你需要停下來深思熟慮的檢查;另一個問題是,一些新手以他的水平看到你的代碼後可能會對這是一個可變對象還是一個不可變對象產生分歧。他/她可能不熟悉可變拷貝構造的含義(這並不是說這個知識不重要)。當然,不存在絕對的錯誤,我們只是討論代碼的可用性(包括可讀性)。
類名應該以三個大寫字母作為前綴(雙字母前綴為 Apple 的類預留)。盡管這個規范看起來有些古怪,但是這樣做可以減少 Objective-C 沒有命名空間所帶來的問題。
一些開發者在定義模型對象時並不遵循這個規范(對於 Core Data 對象,我們更應該遵循這個規范)。我們建議在定義 Core Data 對象時嚴格遵循這個約定,因為最終你可能需要把你的 Managed Object Model(托管對象模型)與其他(第三方庫)的 MOMs(Managed Object Model)合並。
你可能注意到了,這本書裡類的前綴(不僅僅是類,也包括公開的常量、Protocol 等的前綴)是ZOC
。
另一個好的類的命名規范:當你創建一個子類的時候,你應該把說明性的部分放在前綴和父類名的在中間。
舉個例子:如果你有一個 ZOCNetworkClient
類,子類的名字會是ZOCTwitterNetworkClient
(注意 “Twitter” 在 “ZOC” 和 “NetworkClient” 之間); 按照這個約定, 一個UIViewController
的子類會是 ZOCTimelineViewController
.
推薦的代碼組織方式是將 dealloc
方法放在實現文件的最前面(直接在 @synthesize
以及 @dynamic
之後),init
應該跟在 dealloc
方法後面。
如果有多個初始化方法, 指定初始化方法 (designated initializer) 應該放在最前面,間接初始化方法 (secondary initializer) 跟在後面,這樣更有邏輯性。如今有了 ARC,dealloc 方法幾乎不需要實現,不過把 init 和 dealloc 放在一起可以從視覺上強調它們是一對的。通常,在 init 方法中做的事情需要在 dealloc 方法中撤銷。
init
方法應該是這樣的結構:
- (instancetype)init
{
self = [super init]; // call the designated initializer
if (self) {
// Custom initialization
}
return self;
}
為什麼設置 self
為 [super init]
的返回值,以及中間發生了什麼呢?這是一個十分有趣的話題。
我們退一步講:我們常常寫 [[NSObject alloc] init]
這樣的代碼,從而淡化了 alloc
和 init
的區別。Objective-C 的這個特性叫做 兩步創建 。
這意味著申請分配內存和初始化被分離成兩步,alloc
和 init
。
- alloc
負責創建對象,這個過程包括分配足夠的內存來保存對象,寫入 isa
指針,初始化引用計數,以及重置所有實例變量。
- init
負責初始化對象,這意味著使對象處於可用狀態。這通常意味著為對象的實例變量賦予合理有用的值。
alloc
方法將返回一個有效的未初始化的對象實例。每一個對這個實例發送的消息會被轉換成一次 objc_msgSend()
函數的調用,形參 self
的實參是 alloc
返回的指針;這樣 self
在所有方法的作用域內都能夠被訪問。
按照慣例,為了完成兩步創建,新創建的實例第一個被調用的方法將是 init
方法。注意,NSObject
在實現 init
時,只是簡單的返回了 self
。
關於 init
的約定還有一個重要部分:這個方法可以(並且應該)通過返回 nil
來告訴調用者,初始化失敗了;初始化可能會因為各種原因失敗,比如一個輸入的格式錯誤了,或者另一個需要的對象初始化失敗了。
這樣我們就能理解為什麼總是需要調用 self = [super init]
。如果你的父類說初始化自己的時候失敗了,那麼你必須假定你正處於一個不穩定的狀態,因此在你的實現裡不要繼續你自己的初始化並且也返回 nil
。如果不這樣做,你可能會操作一個不可用的對象,它的行為是不可預測的,最終可能會導致你的程序崩潰。
init
方法在被調用的時候可以通過重新給 self
重新賦值來返回另一個實例,而非調用的那個實例。例如類簇,還有一些 Cocoa 類為相等的(不可變的)對象返回同一個實例。
Objective-C 有指定初始化方法(designated initializer)和間接(secondary initializer)初始化方法的觀念。
designated 初始化方法是提供所有的參數,secondary 初始化方法是一個或多個,並且提供一個或者更多的默認參數來調用 designated 初始化的初始化方法。
@implementation ZOCEvent
- (instancetype)initWithTitle:(NSString *)title
date:(NSDate *)date
location:(CLLocation *)location
{
self = [super init];
if (self) {
_title = title;
_date = date;
_location = location;
}
return self;
}
- (instancetype)initWithTitle:(NSString *)title
date:(NSDate *)date
{
return [self initWithTitle:title date:date location:nil];
}
- (instancetype)initWithTitle:(NSString *)title
{
return [self initWithTitle:title date:[NSDate date] location:nil];
}
@end
initWithTitle:date:location:
就是 designated 初始化方法,另外的兩個是 secondary 初始化方法。因為它們僅僅是調用類實現的 designated 初始化方法
一個類應該有且只有一個 designated 初始化方法,其他的初始化方法應該調用這個 designated 的初始化方法(雖然這個情況有一個例外)
這個分歧沒有要求那個初始化函數需要被調用。
在類繼承中調用任何 designated 初始化方法都是合法的,而且應該保證 所有的 designated initializer 在類繼承中是從祖先(通常是 NSObject
)到你的類向下調用的。
實際上這意味著第一個執行的初始化代碼是最遠的祖先,然後從頂向下的類繼承,所有類都有機會執行他們特定初始化代碼。這樣,你在做特定初始化工作前,所有從超類繼承的東西都是不可用的狀態。 雖然這沒有明確的規定,但是所有 Apple 的框架都保證遵守這個約定,你的類也應該這樣做。
當定義一個新類的時候有三個不同的方式:
不需要重載任何初始化函數重載 designated initializer 定義一個新的 designated initializer第一個方案是最簡單的:你不需要增加類的任何初始化邏輯,只需要依照父類的designated initializer。
當你希望提供額外的初始化邏輯的時候,你可以重載 designated initializer。你只需要重載直接超類的 designated initializer 並且確認你的實現調用了超類的方法。
一個典型的例子是你創造UIViewController
子類的時候重載initWithNibName:bundle:
方法。
@implementation ZOCViewController
- (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil
{
// call to the superclass designated initializer
self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
if (self) {
// Custom initialization (自定義的初始化過程)
}
return self;
}
@end
在 UIViewController
子類的例子裡面如果重載 init
會是一個錯誤,這個情況下調用者會嘗試調用 initWithNib:bundle
初始化你的類,你的類實現不會被調用。這同樣違背了它應該是合法調用任何 designated initializer 的規則。
在你希望提供你自己的初始化函數的時候,你應該遵守這三個步驟來保證獲得正確的行為:
定義你的 designated initializer,確保調用了直接超類的 designated initializer。重載直接超類的 designated initializer。調用你的新的 designated initializer。為新的 designated initializer 寫文檔。很多開發者忽略了後兩步,這不僅僅是一個粗心的問題,而且這樣違反了框架的規則,可能導致不確定的行為和bug。
讓我們看看正確的實現的例子:
@implementation ZOCNewsViewController
- (id)initWithNews:(ZOCNews *)news
{
// call to the immediate superclass's designated initializer (調用直接超類的 designated initializer)
self = [super initWithNibName:nil bundle:nil];
if (self) {
_news = news;
}
return self;
}
// Override the immediate superclass's designated initializer (重載直接父類的 designated initializer)
- (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil
{
// call the new designated initializer
return [self initWithNews:nil];
}
@end
如果你沒重載 initWithNibName:bundle:
,而且調用者決定用這個方法初始化你的類(這是完全合法的)。 initWithNews:
永遠不會被調用,所以導致了不正確的初始化流程,你的類的特定初始化邏輯沒有被執行。
即使可以推斷那個方法是 designated initializer,也最好清晰地明確它(未來的你或者其他開發者在改代碼的時候會感謝你的)。
你應該考慮來用這兩個策略(不是互斥的):第一個是你在文檔中明確哪一個初始化方法是 designated 的,你可以用編譯器的指令 __attribute__((objc_designated_initializer))
來標記你的意圖。
用這個編譯指令的時候,編譯器會來幫你。如果你的新的 designated initializer 沒有調用超類的 designated initializer,那麼編譯器會發出警告。
然而,當沒有調用類的 designated initializer 的時候(並且依次提供必要的參數),並且調用其他父類中的 designated initialize 的時候,會變成一個不可用的狀態。參考之前的例子,當實例化一個 ZOCNewsViewController
展示一個新聞而那條新聞沒有展示的話,就會毫無意義。這個情況下你應該只需要讓其他的 designated initializer 失效,來強制調用一個非常特別的 designated initializer。通過使用另外一個編譯器指令 __attribute__((unavailable("Invoke the designated initializer")))
來修飾一個方法,通過這個屬性,會讓你在試圖調用這個方法的時候產生一個編譯錯誤。
這是之前的例子相關的實現的頭文件(這裡使用宏來讓代碼沒有那麼啰嗦)
@interface ZOCNewsViewController : UIViewController
- (instancetype)initWithNews:(ZOCNews *)news ZOC_DESIGNATED_INITIALIZER;
- (instancetype)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil ZOC_UNAVAILABLE_INSTEAD(initWithNews:);
- (instancetype)init ZOC_UNAVAILABLE_INSTEAD(initWithNews:);
@end
上述的一個推論是:你應該永遠不從 designated initializer 裡面調用一個 secondary initializer (如果secondary initializer 遵守約定,它會調用 designated initializer)。如果這樣,調用很可能會調用一個子類重寫的 init 方法並且陷入無限遞歸之中。
不過一個例外是一個對象是否遵守 NSCoding
協議,並且它通過方法 initWithCoder:
初始化。
我們應該看超類是否符合 NSCoding
協議來區別對待。
符合的時候,如果你只是調用 [super initWithCoder:]
,你可能需要在 designated initializer 裡面寫一些通用的初始化代碼,處理這種情況的一個好方法是把這些代碼放在私有方法裡面(比如 p_commonInit
)。
當你的超類不符合 NSCoding
協議的時候,推薦把 initWithCoder:
作為 secondary initializer 來對待,並且調用 self
的 designated initializer。 注意這違反了 Apple 寫在 Archives and Serializations Programming Guide 上面的規定:
the object should first invoke its superclass’s designated initializer to initialize inherited state(對象總是應該首先調用超類的 designated initializer 來初始化繼承的狀態)
如果你的類不是 NSObject
的直接子類,這樣做的話,會導致不可預測的行為。
正如之前的描述,secondary initializer 是一種提供默認值、行為到 designated initializer的方法。也就是說,在這樣的方法裡面你不應該有初始化實例變量的操作,並且你應該一直假設這個方法不會得到調用。我們保證的是唯一被調用的方法是 designated initializer。
這意味著你的 secondary initializer 總是應該調用 Designated initializer 或者你自定義(上面的第三種情況:自定義Designated initializer)的 self
的 designated initializer。有時候,因為錯誤,可能打成了 super
,這樣會導致不符合上面提及的初始化順序(在這個特別的例子裡面,是跳過當前類的初始化)
我們經常忽略 Cocoa 充滿了約定,並且這些約定可以幫助編譯器變得更加聰明。無論編譯器是否遭遇 alloc
或者 init
方法,他會知道,即使返回類型都是 id
,這些方法總是返回接受到的類類型的實例。因此,它允許編譯器進行類型檢查。(比如,檢查方法返回的類型是否合法)。Clang的這個好處來自於 related result type, 意味著:
messages sent to one of alloc and init methods will have the same static type as the instance of the receiver class (發送到 alloc 或者 init 方法的消息會有同樣的靜態類型檢查是否為接受類的實例。)
更多的關於這個自動定義相關返回類型的約定請查看 Clang Language Extensions guide 的appropriate section
一個相關的返回類型可以明確地規定用 instancetype
關鍵字作為返回類型,並且它可以在一些工廠方法或者構造器方法的場景下很有用。它可以提示編譯器正確地檢查類型,並且更加重要的是,這同時適用於它的子類。
@interface ZOCPerson
+ (instancetype)personWithName:(NSString *)name;
@end
雖然如此,根據 clang 的定義,id
可以被編譯器提升到 instancetype
。在 alloc
或者 init
中,我們強烈建議對所有返回類的實例的類方法和實例方法使用 instancetype
類型。
在你的 API 中要構成習慣以及保持始終如一的,此外,通過對你代碼的小調整你可以提高可讀性:在簡單的浏覽的時候你可以區分哪些方法是返回你類的實例的。你以後會感謝這些注意過的小細節的。
類簇在Apple的文檔中這樣描述:
an architecture that groups a number of private, concrete subclasses under a public, abstract superclass. (一個在共有的抽象超類下設置一組私有子類的架構)
如果這個描述聽起來很熟悉,說明你的直覺是對的。 Class cluster 是 Apple 對抽象工廠設計模式的稱呼。
class cluster 的想法很簡單: 使用信息進行(類的)初始化處理期間,會使用一個抽象類(通常作為初始化方法的參數或者判定環境的可用性參數)來完成特定的邏輯或者實例化一個具體的子類。而這個”Public Facing(面向公眾的)”類,必須非常清楚他的私有子類,以便在面對具體任務的時候有能力返回一個恰當的私有子類實例。對調用者來說只需知道對象的各種API的作用即可。這個模式隱藏了他背後復雜的初始化邏輯,調用者也不需要關心背後的實現。
Class clusters 在 Apple 的Framework 中廣泛使用:一些明顯的例子比如 NSNumber
可以返回不同類型給你的子類,取決於 數字類型如何提供 (Integer, Float, etc…) 或者 NSArray
返回不同的最優存儲策略的子類。
這個模式的精妙的地方在於,調用者可以完全不管子類,事實上,這可以用在設計一個庫,可以用來交換實際的返回的類,而不用去管相關的細節,因為它們都遵從抽象超類的方法。
我們的經驗是使用類簇可以幫助移除很多條件語句。
一個經典的例子是如果你有為 iPad 和 iPhone 寫的一樣的 UIViewController 子類,但是在不同的設備上有不同的行為。
比較基礎的實現是用條件語句檢查設備,然後執行不同的邏輯。雖然剛開始可能不錯,但是隨著代碼的增長,運行邏輯也會趨於復雜。
一個更好的實現的設計是創建一個抽象而且寬泛的 view controller 來包含所有的共享邏輯,並且對於不同設備有兩個特別的子例。
通用的 view controller 會檢查當前設備並且返回適當的子類。
@implementation ZOCKintsugiPhotoViewController
- (id)initWithPhotos:(NSArray *)photos
{
if ([self isMemberOfClass:ZOCKintsugiPhotoViewController.class]) {
self = nil;
if ([UIDevice isPad]) {
self = [[ZOCKintsugiPhotoViewController_iPad alloc] initWithPhotos:photos];
}
else {
self = [[ZOCKintsugiPhotoViewController_iPhone alloc] initWithPhotos:photos];
}
return self;
}
return [super initWithNibName:nil bundle:nil];
}
@end
這個子例程展示了如何創建一個類簇。
使用[self isMemberOfClass:ZOCKintsugiPhotoViewController.class]
防止子類中重載初始化方法,避免無限遞歸。當[[ZOCKintsugiPhotoViewController alloc] initWithPhotos:photos]
被調用時,上面條件表達式的結果將會是True。
self = nil
的目的是移除ZOCKintsugiPhotoViewController
實例上的所有引用,實例(抽象類的實例)本身將會解除分配( 當然ARC也好MRC也好dealloc都會發生在Main Runloop這一次的結束時)。
接下來的邏輯就是判斷哪一個私有子類將被初始化。我們假設在iPhone上運行這段代碼並且ZOCKintsugiPhotoViewController_iPhone
沒有重載initWithPhotos:
方法。這種情況下,當執行self = [[ZOCKintsugiPhotoViewController_iPhone alloc] initWithPhotos:photos];
,ZOCKintsugiPhotoViewController
將會被調用,第一次檢查將會在這裡發生,鑒於ZOCKintsugiPhotoViewController_iPhone
不完全是ZOCKintsugiPhotoViewController
,表達式[self isMemberOfClass:ZOCKintsugiPhotoViewController.class]
將會是False,於是就會調用[super initWithNibName:nil bundle:nil]
,於是就會進入ZOCKintsugiPhotoViewController
的初始化過程,這時候因為調用者就是ZOCKintsugiPhotoViewController
本身,這一次的檢查必定為True,接下來就會進行正確的初始化過程。(NOTE:這裡必須是完全遵循Designated initializer 以及Secondary initializer的設計規范的前提下才會其效果的!不明白這個規范的可以後退一步熟悉這種規范在回頭來看這個說明)
NOTE: 這裡的意思是,代碼是在iPhone上調試的,程序員使用了
self = [[ZOCKintsugiPhotoViewController_iPhone alloc] initWithPhotos:photos];
來初始化某個view controller的對象,當代碼運行在iPad上時,這個初始化過程也是正確的,因為無論程序員的代碼中使用self = [[ZOCKintsugiPhotoViewController_iPhone alloc] initWithPhotos:photos];
來初始化viewController(iPhone上編寫運行在iPad上),還是使用self = [[ZOCKintsugiPhotoViewController_iPad alloc] initWithPhotos:photos];
來初始化viewController(iPad上編寫,運行在iPhone上),都會因為ZOCKintsugiPhotoViewController的initWithPhotos:
方法的存在而變得通用起來。
如果可能,請盡量避免使用單例而是依賴注入。
然而,如果一定要用,請使用一個線程安全的模式來創建共享的實例。對於 GCD,用 dispatch_once()
函數就可以咯。
+ (instancetype)sharedInstance
{
static id sharedInstance = nil;
static dispatch_once_t onceToken = 0;
dispatch_once(&onceToken, ^{
sharedInstance = [[self alloc] init];
});
return sharedInstance;
}
使用 dispatch_once(),來控制代碼同步,取代了原來的約定俗成的用法。
+ (instancetype)sharedInstance
{
static id sharedInstance;
@synchronized(self) {
if (sharedInstance == nil) {
sharedInstance = [[MyClass alloc] init];
}
}
return sharedInstance;
}
dispatch_once()
的優點是,它更快,而且語法上更干淨,因為dispatch_once()的意思就是 “把一些東西執行一次”,就像我們做的一樣。 這樣同時可以避免 possible and sometimes prolific crashes.
經典的單例對象是:一個設備的GPS以及它的加速度傳感器(也稱動作感應器)。
雖然單例對象可以子類化,但這種方式能夠有用的情況非常少見。
必須有證據表明,給定類的接口趨向於作為單例來使用。
所以,單例通常公開一個sharedInstance
的類方法就已經足夠了,沒有任何的可寫屬性需要被暴露出來。
嘗試著把單例作為一個對象的容器,在代碼或者應用層面上共享,是一個糟糕和丑陋的設計。
NOTE:單例模式應該運用於類及類的接口趨向於作為單例來使用的情況 (譯者注)
屬性應該盡可能描述性地命名,避免縮寫,並且是小寫字母開頭的駝峰命名。我們的工具可以很方便地幫我們自動補全所有東西(嗯。。幾乎所有的,Xcode 的Derived Data 會索引這些命名)。所以沒理由少打幾個字符了,並且最好盡可能在你源碼裡表達更多東西。
例子 :
NSString *text;
不要這樣 :
NSString* text;
NSString * text;
(注意:這個習慣和常量不同,這是主要從常用和可讀性考慮。 C++ 的開發者偏好從變量名中分離類型,作為類型它應該是
NSString*
(對於從堆中分配的對象,對於C++是能從棧上分配的)格式。)
使用屬性的自動同步 (synthesize) 而不是手動的 @synthesize
語句,除非你的屬性是 protocol 的一部分而不是一個完整的類。如果 Xcode 可以自動同步這些變量,就讓它來做吧。否則只會讓你拋開 Xcode 的優點,維護更冗長的代碼。
你應該總是使用 setter 和 getter 方法訪問屬性,除了 init
和 dealloc
方法。通常,使用屬性讓你增加了在當前作用域之外的代碼塊的可能所以可能帶來更多副作用。
你總應該用 getter 和 setter ,因為:
使用 setter 會遵守定義的內存管理語義(strong
, weak
, copy
etc…) ,這個在 ARC 之前就是相關的內容。舉個例子,copy
屬性定義了每個時候你用 setter 並且傳送數據的時候,它會復制數據而不用額外的操作。 KVO 通知(willChangeValueForKey
, didChangeValueForKey
) 會被自動執行。更容易debug:你可以設置一個斷點在屬性聲明上並且斷點會在每次 getter / setter 方法調用的時候執行,或者你可以在自己的自定義 setter/getter 設置斷點。允許在一個單獨的地方為設置值添加額外的邏輯。
你應該傾向於用 getter:
它是對未來的變化有擴展能力的(比如,屬性是自動生成的)。它允許子類化。更簡單的debug(比如,允許拿出一個斷點在 getter 方法裡面,並且看誰訪問了特別的 getter 它讓意圖更加清晰和明確:通過訪問 ivar_anIvar
你可以明確的訪問 self->_anIvar
.這可能導致問題。在 block 裡面訪問 ivar (你捕捉並且 retain 了 self,即使你沒有明確的看到 self 關鍵詞)。它自動產生KVO 通知。在消息發送的時候增加的開銷是微不足道的。
有一個例外:永遠不要在 init 方法(以及其他初始化方法)裡面用 getter 和 setter 方法,你應當直接訪問實例變量。這樣做是為了防止有子類時,出現這樣的情況:它的子類最終重載了其 setter 或者 getter 方法,因此導致該子類去調用其他的方法、訪問那些處於不穩定狀態,或者稱為沒有初始化完成的屬性或者 ivar 。記住一個對象僅僅在 init 返回的時候,才會被認為是達到了初始化完成的狀態。
同樣在 dealloc 方法中(在 dealloc 方法中,一個對象可以在一個 不確定的狀態中)這是同樣需要被注意的。
Advanced Memory Management Programming Guide under the self-explanatory section “Don’t Use Accessor Methods in Initializer Methods and dealloc”; Migrating to Modern Objective-C at WWDC 2012 at slide 27; in a pull request form Dave DeLong’s.此外,在 init 中使用 setter 不會很好執行 UIAppearence
代理
點符號
當使用 setter getter 方法的時候盡量使用點符號。應該總是用點符號來訪問以及設置屬性。
例子:
view.backgroundColor = [UIColor orangeColor];
[UIApplication sharedApplication].delegate;
不要這樣:
[view setBackgroundColor:[UIColor orangeColor]];
UIApplication.sharedApplication.delegate;
使用點符號會讓表達更加清晰並且幫助區分屬性訪問和方法調用
推薦按照下面的格式來定義屬性
@property (nonatomic, readwrite, copy) NSString *name;
屬性的參數應該按照下面的順序排列: 原子性,讀寫 和 內存管理。 這樣做你的屬性更容易修改正確,並且更好閱讀。(譯者注:習慣上修改某個屬性的修飾符時,一般從屬性名從右向左搜索需要修動的修飾符。最可能從最右邊開始修改這些屬性的修飾符,根據經驗這些修飾符被修改的可能性從高到底應為:內存管理 > 讀寫權限 >原子操作)
你必須使用 nonatomic
,除非特別需要的情況。在iOS中,atomic
帶來的鎖特別影響性能。
屬性可以存儲一個代碼塊。為了讓它存活到定義的塊的結束,必須使用 copy
(block 最早在棧裡面創建,使用 copy
讓 block 拷貝到堆裡面去)
為了完成一個共有的 getter 和一個私有的 setter,你應該聲明公開的屬性為 readonly
並且在類擴展中重新定義通用的屬性為 readwrite
的。
//.h文件中
@interface MyClass : NSObject
@property (nonatomic, readonly, strong) NSObject *object;
@end
//.m文件中
@interface MyClass ()
@property (nonatomic, readwrite, strong) NSObject *object;
@end
@implementation MyClass
//Do Something cool
@end
描述BOOL
屬性的詞如果是形容詞,那麼setter不應該帶is
前綴,但它對應的 getter 訪問器應該帶上這個前綴,如:
@property (assign, getter=isEditable) BOOL editable;
在實現文件中應避免使用@synthesize
,因為Xcode已經自動為你添加了。
私有屬性應該定義在類的實現文件的類的擴展 (匿名的 category) 中。不允許在有名字的 category(如 ZOCPrivate
)中定義私有屬性,除非你擴展其他類。
例子:
@interface ZOCViewController ()
@property (nonatomic, strong) UIView *bannerView;
@end
任何可以用一個可變的對象設置的((比如 NSString
,NSArray
,NSURLRequest
))屬性的內存管理類型必須是 copy
的。
這是為了確保防止在不明確的情況下修改被封裝好的對象的值(譯者注:比如執行 array(定義為 copy 的 NSArray 實例) = mutableArray,copy 屬性會讓 array 的 setter 方法為 array = [mutableArray copy], [mutableArray copy] 返回的是不可變的 NSArray 實例,就保證了正確性。用其他屬性修飾符修飾,容易在直接賦值的時候,array 指向的是 NSMuatbleArray 的實例,在之後可以隨意改變它的值,就容易出錯)。
你應該同時避免暴露在公開的接口中可變的對象,因為這允許你的類的使用者改變類自己的內部表示並且破壞類的封裝。你可以提供可以只讀的屬性來返回你對象的不可變的副本。
/* .h */
@property (nonatomic, readonly) NSArray *elements
/* .m */
- (NSArray *)elements {
return [self.mutableElements copy];
}
當實例化一個對象需要耗費很多資源,或者配置一次就要調用很多配置相關的方法而你又不想弄亂這些方法時,我們需要重寫 getter 方法以延遲實例化,而不是在 init 方法裡給對象分配內存。通常這種操作使用下面這樣的模板:
- (NSDateFormatter *)dateFormatter {
if (!_dateFormatter) {
_dateFormatter = [[NSDateFormatter alloc] init];
NSLocale *enUSPOSIXLocale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"];
[_dateFormatter setLocale:enUSPOSIXLocale];
[_dateFormatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ss.SSS"];//毫秒是SSS,而非SSSSS
}
return _dateFormatter;
}
即使這樣做在某些情況下很不錯,但是在實際這樣做之前應當深思熟慮。事實上,這樣的做法是可以避免的。下面是使用延遲實例化的爭議。
getter 方法應該避免副作用。看到 getter 方法的時候,你不會想到會因此創建一個對象或導致副作用,實際上如果調用 getter 方法而不使用其返回值編譯器會報警告 “Getter 不應該僅因它產生的副作用而被調用”。你在第一次訪問的時候改變了初始化的消耗,產生了副作用,這會讓優化性能變得困難(以及測試)這個初始化可能是不確定的:比如你期望屬性第一次被一個方法訪問,但是你改變了類的實現,訪問器在你預期之前就得到了調用,這樣可以導致問題,特別是初始化邏輯可能依賴於類的其他不同狀態的時候。總的來說最好明確依賴關系。這個行為不是 KVO 友好的。如果 getter 改變了引用,他應該通過一個 KVO 通知來通知改變。當訪問 getter 的時候收到一個改變的通知很奇怪。副作用指當調用函數時,除了返回函數值之外,還對主調用函數產生附加的影響。例如修改全局變量(函數外的變量)或修改參數。函數副作用會給程序設計帶來不必要的麻煩,給程序帶來十分難以查找的錯誤,並且降低程序的可讀性。(譯者注)
你的方法可能要求一些參數來滿足特定的條件(比如不能為nil),在這種情況下最好使用 NSParameterAssert()
來斷言條件是否成立或是拋出一個異常。
永遠不要在你的私有方法前加上 _
前綴。這個前綴是 Apple 保留的。不要冒重載蘋果的私有方法的險。
當你要實現相等性的時候記住這個約定:你需要同時實現isEqual
和 hash
方法。如果兩個對象是被isEqual
認為相等的,它們的 hash
方法需要返回一樣的值。但是如果 hash
返回一樣的值,並不能確保他們相等。
這個約定當對象被存儲在集合中(如 NSDictionary
和 NSSet
在底層使用 hash 表數據的數據結構)的時候,用來查找這些對象的。
@implementation ZOCPerson
- (BOOL)isEqual:(id)object {
if (self == object) {
return YES;
}
if (![object isKindOfClass:[ZOCPerson class]]) {
return NO;
}
// check objects properties (name and birthday) for equality (檢查對象屬性(名字和生日)的相等性
...
return propertiesMatch;
}
- (NSUInteger)hash {
return [self.name hash] ^ [self.birthday hash];
}
@end
一定要注意 hash 方法不能返回一個常量。這是一個典型的錯誤並且會導致嚴重的問題,因為實際上hash
方法的返回值會作為對象在 hash 散列表中的 key,這會導致 hash 表 100% 的碰撞。
你總是應該用 isEqualTo<#class-name-without-prefix#>:
這樣的格式實現一個相等性檢查方法。如果你這樣做,會優先調用這個方法來避免上面的類型檢查。
一個完整的 isEqual 方法應該是這樣的:
- (BOOL)isEqual:(id)object {
if (self == object) {
return YES;
}
if (![object isKindOfClass:[ZOCPerson class]]) {
return NO;
}
return [self isEqualToPerson:(ZOCPerson *)object];
}
- (BOOL)isEqualToPerson:(Person *)person {
if (!person) {
return NO;
}
BOOL namesMatch = (!self.name && !person.name) ||
[self.name isEqualToString:person.name];
BOOL birthdaysMatch = (!self.birthday && !person.birthday) ||
[self.birthday isEqualToDate:person.birthday];
return haveEqualNames && haveEqualBirthdays;
}
譯者注:
一般而言我們會直接調用自定義的isEqualTo__ClassName__:
方法,對類的實例判等。像相等性的開篇已經提到的那樣,這裡應該復寫
isEqual:
方法,因為NSObject的isEqual:
方法顯然不會考慮我們自定義類的類型判斷及屬性的相等性。當我們自定義的類的對象處在無序集合中被查找時,會自動調用isEqual:
。同樣的該類的hash
方法,也會在集合查找對象的時候被使用,我們也可以通過復寫hash
方法以達到用自己的標准來判定對象是否hash
等同。我們實現的
hash
方法應該建立在系統提供的各種對象的hash
方法之上(像開篇的例程那樣)。不推薦自己去實現某種hash
算法來替代系統提供的hash
算法,這一般而言會大大影響性能或者准確性,系統提供的hash
算法已經經過無數次修繕,足以滿足你的要求。
一個對象實例的 hash
計算結果應該是確定的。當它被加入到一個容器對象(比如 NSArray
, NSSet
, 或者 NSDictionary
)的時候這是很重要的,否則行為會無法預測(所有的容器對象使用對象的 hash 來查找或者實施特別的行為,如確定唯一性)這也就是說,應該用不可變的屬性來計算 hash 值,或者,最好保證對象是不可變的。
雖然我們知道這樣寫很丑, 但是我們應該要在我們的 category 方法前加上自己的小寫前綴以及下劃線,比如- (id)zoc_myCategoryMethod
。 這種實踐同樣被蘋果推薦。
這是非常必要的。因為如果在擴展的 category 或者其他 category 裡面已經使用了同樣的方法名,會導致不可預計的後果。實際上,實際被調用的是最後被加載的那個 category 中方法的實現(譯者注:如果導入的多個 category 中有一些同名的方法導入到類裡時,最終調用哪個是由編譯時的加載順序來決定的,最後一個加載進來的方法會覆蓋之前的方法)。
如果想要確認你的分類方法沒有覆蓋其他實現的話,可以把環境變量 OBJC_PRINT_REPLACED_METHODS 設置為 YES,這樣那些被取代的方法名字會打印到 Console 中。現在 LLVM 5.1 不會為此發出任何警告和錯誤提示,所以自己小心不要在分類中重載方法。
一個好的實踐是在 category 名中使用前綴。
* 例子 *
@interface NSDate (ZOCTimeExtensions)
- (NSString *)zoc_timeAgoShort;
@end
* 不要這樣 *
@interface NSDate (ZOCTimeExtensions)
- (NSString *)timeAgoShort;
@end
分類可以用來在頭文件中定義一組功能相似的方法。這是在 Apple的 Framework 也很常見的一個實踐(下面例子的取自NSDate
頭文件)。我們也強烈建議在自己的代碼中這樣使用。
我們的經驗是,創建一組分類對以後的重構十分有幫助。一個類的接口增加的時候,可能意味著你的類做了太多事情,違背了類的單一功能原則。
之前創造的方法分組可以用來更好地進行不同功能的表示,並且把類打破在更多自我包含的組成部分裡。
@interface NSDate : NSObject
@property (readonly) NSTimeInterval timeIntervalSinceReferenceDate;
@end
@interface NSDate (NSDateCreation)
+ (instancetype)date;
+ (instancetype)dateWithTimeIntervalSinceNow:(NSTimeInterval)secs;
+ (instancetype)dateWithTimeIntervalSinceReferenceDate:(NSTimeInterval)ti;
+ (instancetype)dateWithTimeIntervalSince1970:(NSTimeInterval)secs;
+ (instancetype)dateWithTimeInterval:(NSTimeInterval)secsToBeAdded sinceDate:(NSDate *)date;
// ...
@end
在 Objective-C 的世界裡面經常錯過的一個東西是抽象接口。接口(interface)這個詞通常指一個類的 .h
文件,但是它在 Java 程序員眼裡有另外的含義: 一系列不依賴具體實現的方法的定義。(譯者注:在OC中,類的接口對應在.m文件中都會有具體的實現,但Java中接口更接近於OC中的抽象接口或者說協議(protocol))
在 Objective-C 裡是通過 protocol 來實現抽象接口的。因為歷史原因,protocol (使用方法類似java的接口)並沒有大量地在Objective-C的代碼中使用也沒有在社區中普及(指的是那種像Java程序員使用接口那樣來使用protocol的方式)。一個主要原因是大多數的 Apple 開發的代碼沒有采用這種的方式,而幾乎所有的開發者都是遵從 Apple 的模式以及指南。Apple 幾乎只是在委托模式下使用 protocol。
但是抽象接口的概念很強大,在計算機科學的歷史中頗有淵源,沒有理由不在 Objective-C 中使用。
這裡通過一個具體的例子來解釋 protocol 的強大力量(用作抽象接口):把非常糟糕的設計的架構改造為一個良好的可復用的代碼。
這個例子是在實現一個 RSS 閱讀器(它可是經常在技術面試中作為一個測試題呢)。
要求很簡單:在TableView中展示一個遠程的RSS訂閱。
一個幼稚的方法是創建一個 UITableViewController
的子類,並且把所有的檢索訂閱數據,解析以及展示的邏輯放在一起,或者說是一個 MVC (Massive View Controller)。這可以跑起來,但是它的設計非常糟糕,不過它足夠過一些要求不高的面試了。
最小的步驟是遵從單一功能原則,創建至少兩個組成部分來完成這個任務:
一個 feed 解析器來解析搜集到的結果一個 feed 閱讀器來顯示結果這些類的接口可以是這樣的:
@interface ZOCFeedParser : NSObject
@property (nonatomic, weak) id delegate;
@property (nonatomic, strong) NSURL *url;
- (id)initWithURL:(NSURL *)url;
- (BOOL)start;
- (void)stop;
@end
@interface ZOCTableViewController : UITableViewController
- (instancetype)initWithFeedParser:(ZOCFeedParser *)feedParser;
@end
ZOCFeedParser
用 NSURL
進行初始化,來獲取 RSS 訂閱(在這之下可能會使用 NSXMLParser 和 NSXMLParserDelegate 創建有意義的數據),ZOCTableViewController
會用這個 parser 來進行初始化。 我們希望它顯示 parser 接受到的值並且我們用下面的 protocol 實現委托:
@protocol ZOCFeedParserDelegate
@optional
- (void)feedParserDidStart:(ZOCFeedParser *)parser;
- (void)feedParser:(ZOCFeedParser *)parser didParseFeedInfo:(ZOCFeedInfoDTO *)info;
- (void)feedParser:(ZOCFeedParser *)parser didParseFeedItem:(ZOCFeedItemDTO *)item;
- (void)feedParserDidFinish:(ZOCFeedParser *)parser;
- (void)feedParser:(ZOCFeedParser *)parser didFailWithError:(NSError *)error;
@end
我要說,這是一個處理RSS業務的完全合理而恰當的protocol。這個ViewController在Public接口中將遵循這個protocol:
@interface ZOCTableViewController : UITableViewController
最後創建的代碼是這樣子的:
NSURL *feedURL = [NSURL URLWithString:@"http://www.bbc.co.uk/feed.rss"];
ZOCFeedParser *feedParser = [[ZOCFeedParser alloc] initWithURL:feedURL];
ZOCTableViewController *tableViewController = [[ZOCTableViewController alloc] initWithFeedParser:feedParser];
feedParser.delegate = tableViewController;
到目前你可能覺得你的代碼還是不錯的,但是有多少代碼是可以有效復用的呢?view controller 只能處理 ZOCFeedParser
類型的對象: 從這點來看我們只是把代碼分離成了兩個組成部分,而沒有做任何其他有價值的事情。
view controller 的職責應該是“顯示某些東西提供的內容”,但是如果我們只允許傳遞ZOCFeedParser
的話,就不是這樣的了。這就體現了需要傳遞給 view controller 一個更泛型的對象的需求。
我們使用 ZOCFeedParserProtocol
這個 protocol (在 ZOCFeedParserProtocol.h 文件裡面,同時文件裡也有 ZOCFeedParserDelegate
)。
@protocol ZOCFeedParserProtocol
@property (nonatomic, weak) id delegate;
@property (nonatomic, strong) NSURL *url;
- (BOOL)start;
- (void)stop;
@end
@protocol ZOCFeedParserDelegate
@optional
- (void)feedParserDidStart:(id)parser;
- (void)feedParser:(id)parser didParseFeedInfo:(ZOCFeedInfoDTO *)info;
- (void)feedParser:(id)parser didParseFeedItem:(ZOCFeedItemDTO *)item;
- (void)feedParserDidFinish:(id)parser;
- (void)feedParser:(id)parser didFailWithError:(NSError *)error;
@end
注意這個代理 protocol 現在處理響應我們新的 protocol, 而且 ZOCFeedParser 的接口文件更加精煉了:
@interface ZOCFeedParser : NSObject
- (id)initWithURL:(NSURL *)url;
@end
因為 ZOCFeedParser
實現了 ZOCFeedParserProtocol
,它需要實現所有的required
方法。
從這點來看 viewController能接受任何遵循該協議的對象,只要確保所有的對象都會響應start
和stop
方法並通過delegate
屬性提供信息(譯者注:因為protocol默認情況下所有的方法定義都是required
的)。對指定的對象而言,這就是viewController所要知道的一切,且不需要知道其實現的細節。
@interface ZOCTableViewController : UITableViewController
- (instancetype)initWithFeedParser:(id)feedParser;
@end
上面的代碼片段的改變看起來不多,但是有了一個巨大的提升。view controller 將基於協議而不是具體的實現來工作。這帶來了以下的優點:
view controller 現在可以接收通過delegate
屬性提供信息的任意對象:可以是 RSS 遠程解析器,或者本地解析器,或是一個讀取其他遠程或者本地數據的服務 ZOCFeedParser
和 ZOCFeedParserDelegate
可以被其他組成部分復用 ZOCViewController
(UI邏輯部分)可以被復用測試更簡單了,因為可以用 mock 對象來達到 protocol 預期的效果
當實現一個 protocol 你總應該堅持 裡氏替換原則。這個原則是:你應該可以取代任意接口(也就是Objective-C裡的”protocol”)實現,而不用改變客戶端或者相關實現。
此外,這也意味著protocol
不該關心類的實現細節;設計protocol的抽象表述時應非常用心,並且要牢記它和它背後的實現是不相干的,真正重要的是協議(這個暴露給使用者的抽象表述)。
任何在未來可復用的設計,無形當中可以提高代碼質量,這也應該一直是程序員的追求。是否這樣設計代碼,就是大師和菜鳥的區別。
最後的代碼可以在這裡 找到。
當你定義你自己的 NSNotification
的時候你應該把你的通知的名字定義為一個字符串常量,就像你暴露給其他類的其他字符串常量一樣。你應該在公開的接口文件中將其聲明為 extern
的, 並且在對應的實現文件裡面定義。
因為你在頭文件中暴露了符號,所以你應該按照統一的命名空間前綴法則,用類名前綴作為這個通知名字的前綴。
同時,用一個 Did/Will 這樣的動詞以及用 “Notifications” 後綴來命名這個通知也是一個好的實踐。
// Foo.h
extern NSString * const ZOCFooDidBecomeBarNotification
// Foo.m
NSString * const ZOCFooDidBecomeBarNotification = @"ZOCFooDidBecomeBarNotification";
if
/else
/switch
/while
等) 總是在同一行開始,在新起一行結束。
推薦:
if (user.isHappy) {
//Do something
}
else {
//Do something else
}
不推薦:
if (user.isHappy)
{
//Do something
} else {
//Do something else
}
方法之間應該要有一個空行來幫助代碼看起來清晰且有組織。 方法內的空格應該用來分離功能,但是通常不同的功能應該用新的方法來定義。優先使用 auto-synthesis。但是如果必要的話, @synthesize
and @dynamic
在實現文件中的聲明應該新起一行。應該總是讓冒號對齊。有一些方法簽名可能超過三個冒號,用冒號對齊可以讓代碼更具有可讀性。即使有代碼塊存在,也應該用冒號對齊方法。
推薦:
[UIView animateWithDuration:1.0
animations:^{
// something
}
completion:^(BOOL finished) {
// something
}];
不推薦:
[UIView animateWithDuration:1.0 animations:^{
// something
} completion:^(BOOL finished) {
// something
}];
如果自動對齊讓可讀性變得糟糕,那麼應該在之前把 block 定義為變量,或者重新考慮你的代碼簽名設計。
本指南關注代碼顯示效果以及在線浏覽的可讀性,所以換行是一個重要的主題。
舉個例子:
self.productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:productIdentifiers];
一個像上面的長行的代碼在第二行以一個間隔(2個空格)延續
self.productsRequest = [[SKProductsRequest alloc]
initWithProductIdentifiers:productIdentifiers];
在以下的地方使用 Egyptian風格 括號 (譯者注:又稱 K&R 風格,代碼段括號的開始位於一行的末尾,而不是另外起一行的風格。
控制語句 (if-else, for, switch)
非 Egyptian 括號可以用在:
類的實現(如果存在) 方法的實現
code organization is a matter of hygiene (代碼組織是衛生問題)
我們十分贊成這句話。清晰地組織代碼和規范地進行定義, 是你對自己以及其他閱讀代碼的人的尊重。
一個 GCC 非常模糊的特性,以及 Clang 也有的特性是,代碼塊如果在閉合的圓括號內的話,會返回最後語句的值
NSURL *url = ({
NSString *urlString = [NSString stringWithFormat:@"%@/%@", baseURLString, endpoint];
[NSURL URLWithString:urlString];
});
#pragma mark -
是一個在類內部組織代碼並且幫助你分組方法實現的好辦法。 我們建議使用 #pragma mark -
來分離:
- (void)dealloc { /* ... */ }
- (instancetype)init { /* ... */ }
#pragma mark - View Lifecycle (View 的生命周期)
- (void)viewDidLoad { /* ... */ }
- (void)viewWillAppear:(BOOL)animated { /* ... */ }
- (void)didReceiveMemoryWarning { /* ... */ }
#pragma mark - Custom Accessors (自定義訪問器)
- (void)setCustomProperty:(id)value { /* ... */ }
- (id)customProperty { /* ... */ }
#pragma mark - IBActions
- (IBAction)submitData:(id)sender { /* ... */ }
#pragma mark - Public
- (void)publicMethod { /* ... */ }
#pragma mark - Private
- (void)zoc_privateMethod { /* ... */ }
#pragma mark - UITableViewDataSource
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { /* ... */ }
#pragma mark - ZOCSuperclass
// ... 重載來自 ZOCSuperclass 的方法
#pragma mark - NSObject
- (NSString *)description { /* ... */ }
上面的標記能明顯分離和組織代碼。你還可以用 cmd+Click 來快速跳轉到符號定義地方。
但是小心,即使 paragma mark 是一門手藝,但是它不是讓你類裡面方法數量增加的一個理由:類裡面有太多方法說明類做了太多事情,需要考慮重構了。
大多數 iOS 開發者平時並沒有和很多編譯器選項打交道。一些選項是對控制嚴格檢查(或者不檢查)你的代碼或者錯誤的。有時候,你想要用 pragma 直接產生一個異常,臨時打斷編譯器的行為。
當你使用ARC的時候,編譯器幫你插入了內存管理相關的調用。但是這樣可能產生一些煩人的事情。比如你使用 NSSelectorFromString
來動態地產生一個 selector 調用的時候,ARC不知道這個方法是哪個並且不知道應該用那種內存管理方法,你會被提示 performSelector may cause a leak because its selector is unknown(執行 selector 可能導致洩漏,因為這個 selector 是未知的)
.
如果你知道你的代碼不會導致內存洩露,你可以通過加入這些代碼忽略這些警告
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[myObj performSelector:mySelector withObject:name];
#pragma clang diagnostic pop
注意我們是如何在相關代碼上下文中用 pragma 停用 -Warc-performSelector-leaks 檢查的。這確保我們沒有全局禁用。如果全局禁用,可能會導致錯誤。
告訴你申明的變量它將不會被使用,這種做法很有用。大多數情況下,你希望移除這些引用來(稍微地)提高性能,但是有時候你希望保留它們。為什麼?或許它們以後有用,或者有些特性只是暫時移除。無論如何,一個消除這些警告的好方法是用相關語句進行注解,使用 #pragma unused()
:
- (NSInteger)giveMeFive
{
NSString *foo;
#pragma unused (foo)
return 5;
}
現在你的代碼不用任何編譯警告了。注意你的 pragma 需要標記到問題代碼之下。
編譯器是一個機器人,它會標記你代碼中被 Clang 規則定義為錯誤的地方。但是,你總是比 Clang 更聰明。通常,你會發現一些討厭的代碼會導致這個問題,但是暫時卻解決不了。你可以這樣明確一個錯誤:
- (NSInteger)divide:(NSInteger)dividend by:(NSInteger)divisor
{
#error Whoa, buddy, you need to check for zero here!
return (dividend / divisor);
}
類似的,你可以這樣標明一個警告
- (float)divide:(float)dividend by:(float)divisor
{
#warning Dude, don't compare floating point numbers like this!
if (divisor != 0.0) {
return (dividend / divisor);
}
else {
return NAN;
}
}
所有重要的方法,接口,分類以及協議定義應該有伴隨的注釋來解釋它們的用途以及如何使用。更多的例子可以看 Google 代碼風格指南中的 File and Declaration Comments。
簡而言之:有長的和短的兩種字符串文檔。
短文檔適用於單行的文件,包括注釋斜槓。它適合簡短的函數,特別是(但不僅僅是)非 public 的 API:
// Return a user-readable form of a Frobnozz, html-escaped.
文本應該用一個動詞 (“return”) 而不是 “returns” 這樣的描述。
如果描述超過一行,應改用長字符串文檔:
以/**
開始換行寫一句總結的話,以?或者!或者.
結尾。空一行在與第一行對齊的位置開始寫剩下的注釋最後用*/
結束。
/**
This comment serves to demonstrate the format of a docstring.
Note that the summary line is always at most one line long, and
after the opening block comment, and each line of text is preceded
by a single space.
*/
一個函數必須有一個字符串文檔,除非它符合下面的所有條件:
非公開很短顯而易見字符串文檔應該描述函數的調用符號和語義,而不是它如何實現。
當它需要的時候,注釋應該用來解釋特定的代碼做了什麼。所有的注釋必須被持續維護或者干脆就刪掉。
塊注釋應該被避免,代碼本身應該盡可能就像文檔一樣表示意圖,只需要很少的打斷注釋。 例外: 這不能適用於用來產生文檔的注釋
一個類的文檔應該只在 .h 文件裡用 Doxygen/AppleDoc 的語法書寫。 方法和屬性都應該提供文檔。
例子:
/**
* Designated initializer.
*
* @param store The store for CRUD operations.
* @param searchService The search service used to query the store.
*
* @return A ZOCCRUDOperationsStore object.
*/
- (instancetype)initWithOperationsStore:(id)store
searchService:(id)searchService;
對象之間需要通信,這也是所有軟件的基礎。再非凡的軟件也需要通過對象通信來完成復雜的目標。本章將深入討論一些設計概念,以及如何依據這些概念來設計出良好的架構。
Block 是 Objective-C 版本的 lambda 或者 closure(閉包)。
使用 block 定義異步接口:
- (void)downloadObjectsAtPath:(NSString *)path
completion:(void(^)(NSArray *objects, NSError *error))completion;
當你定義一個類似上面的接口的時候,盡量使用一個單獨的 block 作為接口的最後一個參數。把需要提供的數據和錯誤信息整合到一個單獨 block 中,比分別提供成功和失敗的 block 要好。
以下是你應該這樣做的原因:
通常這成功處理和失敗處理會共享一些代碼(比如讓一個進度條或者提示消失); Apple 也是這樣做的,與平台一致能夠帶來一些潛在的好處; block 通常會有多行代碼,如果不作為最後一個參數放在後面的話,會打破調用點;使用多個 block 作為參數可能會讓調用看起來顯得很笨拙,並且增加了復雜性。看上面的方法,完成處理的 block 的參數很常見:第一個參數是調用者希望獲取的數據,第二個是錯誤相關的信息。這裡需要遵循以下兩點:
若objects
不為 nil,則 error
必須為 nil 若 objects
為 nil,則 error
必須不為 nil
因為調用者更關心的是實際的數據,就像這樣:
- (void)downloadObjectsAtPath:(NSString *)path
completion:(void(^)(NSArray *objects, NSError *error))completion {
if (objects) {
// do something with the data
}
else {
// some error occurred, 'error' variable should not be nil by contract
}
}
此外,Apple 提供的一些同步接口在成功狀態下向 error 參數(如果非 NULL) 寫入了垃圾值,所以檢查 error 的值可能出現問題。
一些關鍵點:
block 是在棧上創建的 block 可以復制到堆上 Block會捕獲棧上的變量(或指針),將其復制為自己私有的const(變量)。 (如果在Block中修改Block塊外的)棧上的變量和指針,那麼這些變量和指針必須用__block
關鍵字申明(譯者注:否則就會跟上面的情況一樣只是捕獲他們的瞬時值)。
如果 block 沒有在其他地方被保持,那麼它會隨著棧生存並且當棧幀(stack frame)返回的時候消失。僅存在於棧上時,block對對象訪問的內存管理和生命周期沒有任何影響。
如果 block 需要在棧幀返回的時候存在,它們需要明確地被復制到堆上,這樣,block 會像其他 Cocoa 對象一樣增加引用計數。當它們被復制的時候,它會帶著它們的捕獲作用域一起,retain 他們所有引用的對象。
如果一個 block引用了一個棧變量或指針,那麼這個block初始化的時候會擁有這個變量或指針的const副本,所以(被捕獲之後再在棧中改變這個變量或指針的值)是不起作用的。(譯者注:所以這時候我們在block中對這種變量進行賦值會編譯報錯:Variable is not assignable(missing __block type specifier)
,因為他們是副本而且是const的.具體見下面的例程)。
當一個 block 被復制後,__block
聲明的棧變量的引用被復制到了堆裡,復制完成之後,無論是棧上的block還是剛剛產生在堆上的block(棧上block的副本)都會引用該變量在堆上的副本。
(下面代碼是譯者加的)
...
CGFloat blockInt = 10;
void (^playblock)(void) = ^{
NSLog(@"blockInt = %zd", blockInt);
};
blockInt ++;
playblock();
...
//結果為:blockInt = 10
用 LLDB 來展示 block 是這樣子的:
最重要的事情是 __block
聲明的變量和指針在 block 裡面是作為顯示操作真實值/對象的結構來對待的。
block 在 Objective-C 的 runtime(運行時) 裡面被當作一等公民對待:他們有一個 isa
指針,一個類也是用 isa
指針在Objective-C 運行時來訪問方法和存儲數據的。在非 ARC 環境肯定會把它搞得很糟糕,並且懸掛指針會導致 crash。__block
僅僅對 block 內的變量起作用,它只是簡單地告訴 block:
嗨,這個指針或者原始的類型依賴它們在的棧。請用一個棧上的新變量來引用它。我是說,請對它進行雙重解引用,不要 retain 它。
謝謝,哥們。
如果在定義之後但是 block 沒有被調用前,對象被釋放了,那麼 block 的執行會導致 crash。 __block
變量不會在 block 中被持有,最後… 指針、引用、解引用以及引用計數變得一團糟。
當使用代碼塊和異步分發的時候,要注意避免引用循環。 總是使用 weak
來引用對象,避免引用循環。 此外,把持有 block 的屬性設置為 nil (比如 self.completionBlock = nil
) 是一個好的實踐。它會打破 block 捕獲的作用域帶來的引用循環。
例子:
__weak __typeof(self) weakSelf = self;
[self executeBlock:^(NSData *data, NSError *error) {
[weakSelf doSomethingWithData:data];
}];
不要這樣:
[self executeBlock:^(NSData *data, NSError *error) {
[self doSomethingWithData:data];
}];
多個語句的例子:
__weak __typeof(self)weakSelf = self;
[self executeBlock:^(NSData *data, NSError *error) {
__strong __typeof(weakSelf) strongSelf = weakSelf;
if (strongSelf) {
[strongSelf doSomethingWithData:data];
[strongSelf doSomethingWithData:data];
}
}];
不要這樣:
__weak __typeof(self)weakSelf = self;
[self executeBlock:^(NSData *data, NSError *error) {
[weakSelf doSomethingWithData:data];
[weakSelf doSomethingWithData:data];
}];
你應該把這兩行代碼作為 snippet 加到 Xcode 裡面並且總是這樣使用它們。
__weak __typeof(self)weakSelf = self;
__strong __typeof(weakSelf)strongSelf = weakSelf;
這裡我們來討論下 block 裡面的 self 的 __weak
和 __strong
限定詞的一些微妙的地方。簡而言之,我們可以參考 self 在 block 裡面的三種不同情況。
__weak
的 引用到 self,並且在 block 裡面使用這個弱引用在 block 外定義一個 __weak
的 引用到 self,並在在 block 內部通過這個弱引用定義一個 __strong
的引用。
方案 1. 直接在 block 裡面使用關鍵詞 self
如果我們直接在 block 裡面用 self 關鍵字,對象會在 block 的定義時候被 retain,(實際上 block 是 copied 但是為了簡單我們可以忽略這個)。一個 const 的對 self 的引用在 block 裡面有自己的位置並且它會影響對象的引用計數。如果這個block被其他的類使用並且(或者)彼此間傳來傳去,我們可能想要在 block 中保留 self,就像其他在 block 中使用的對象一樣. 因為他們是block執行所需要的.
dispatch_block_t completionBlock = ^{
NSLog(@"%@", self);
}
MyViewController *myController = [[MyViewController alloc] init...];
[self presentViewController:myController
animated:YES
completion:completionHandler];
沒啥大不了。但是如果通過一個屬性中的 self
保留 了這個 block(就像下面的例程一樣),對象( self )保留了 block 會怎麼樣呢?
self.completionHandler = ^{
NSLog(@"%@", self);
}
MyViewController *myController = [[MyViewController alloc] init...];
[self presentViewController:myController
animated:YES
completion:self.completionHandler];
這就是有名的 retain cycle, 並且我們通常應該避免它。這種情況下我們收到 CLANG 的警告:
Capturing 'self' strongly in this block is likely to lead to a retain cycle (在 block 裡面發現了 `self` 的強引用,可能會導致循環引用)
所以 __weak
就有用武之地了。
方案 2. 在 block 外定義一個 __weak
的 引用到 self,並且在 block 裡面使用這個弱引用
這樣會避免循壞引用,也是通常情況下我們的block作為類的屬性被self retain 的時候會做的。
__weak typeof(self) weakSelf = self;
self.completionHandler = ^{
NSLog(@"%@", weakSelf);
};
MyViewController *myController = [[MyViewController alloc] init...];
[self presentViewController:myController
animated:YES
completion:self.completionHandler];
這個情況下 block 沒有 retain 對象並且對象在屬性裡面 retain 了 block 。所以這樣我們能保證了安全的訪問 self。 不過糟糕的是,它可能被設置成 nil 的。問題是:如何讓 self 在 block 裡面安全地被銷毀。
考慮這麼個情況:block 作為屬性(property)賦值的結果,從一個對象被復制到另一個對象(如 myController),在這個復制的 block 執行之前,前者(即之前的那個對象)已經被解除分配。
下面的更有意思。
方案 3. 在 block 外定義一個 __weak
的 引用到 self,並在在 block 內部通過這個弱引用定義一個 __strong
的引用
你可能會想,首先,這是避免 retain cycle 警告的一個技巧。
這不是重點,這個 self 的強引用是在block 執行時 被創建的,但是否使用 self 在 block 定義時就已經定下來了, 因此self (在block執行時) 會被 retain.
Apple 文檔 中表示 “為了 non-trivial cycles ,你應該這樣” :
MyViewController *myController = [[MyViewController alloc] init...];
// ...
MyViewController * __weak weakMyController = myController;
myController.completionHandler = ^(NSInteger result) {
MyViewController *strongMyController = weakMyController;
if (strongMyController) {
// ...
[strongMyController dismissViewControllerAnimated:YES completion:nil];
// ...
}
else {
// Probably nothing...
}
};
首先,我覺得這個例子看起來是錯誤的。如果 block 本身在 completionHandler 屬性中被 retain 了,那麼 self 如何被 delloc 和在 block 之外賦值為 nil 呢? completionHandler 屬性可以被聲明為 assign
或者 unsafe_unretained
的,來允許對象在 block 被傳遞之後被銷毀。
我不能理解這樣做的理由,如果其他對象需要這個對象(self),block 被傳遞的時候應該 retain 對象,所以 block 應該不被作為屬性存儲。這種情況下不應該用 __weak
/__strong
總之,其他情況下,希望 weakSelf 變成 nil 的話,就像第二種情況解釋那麼寫(在 block 之外定義一個弱應用並且在 block 裡面使用)。
還有,Apple的 “trivial block” 是什麼呢。我們的理解是 trivial block 是一個不被傳送的 block ,它在一個良好定義和控制的作用域裡面,weak 修飾只是為了避免循環引用。
雖然有 Kazuki Sakamoto 和 Tomohiko Furumoto) 討論的 一 些 的 在線 參考, Matt Galloway 的 (Effective Objective-C 2.0 和 Pro Multithreading and Memory Management for iOS and OS X ,大多數開發者始終沒有弄清楚概念。
在 block 內用強引用的優點是,搶占執行的時候的魯棒性。在 block 執行的時候, 再次溫故下上面的三個例子:
方案 1. 直接在 block 裡面使用關鍵詞 self
如果 block 被屬性 retain,self 和 block 之間會有一個循環引用並且它們不會再被釋放。如果 block 被傳送並且被其他的對象 copy 了,self 在每一個 copy 裡面被 retain
方案 2. 在 block 外定義一個 __weak
的 引用到 self,並且在 block 裡面使用這個弱引用
不管 block 是否通過屬性被 retain ,這裡都不會發生循環引用。如果 block 被傳遞或者 copy 了,在執行的時候,weakSelf 可能已經變成 nil。
block 的執行可以搶占,而且對 weakSelf 指針的調用時序不同可以導致不同的結果(如:在一個特定的時序下 weakSelf 可能會變成nil)。
__weak typeof(self) weakSelf = self;
dispatch_block_t block = ^{
[weakSelf doSomething]; // weakSelf != nil
// preemption, weakSelf turned nil
[weakSelf doSomethingElse]; // weakSelf == nil
};
方案 3. 在 block 外定義一個 __weak
的 引用到 self,並在在 block 內部通過這個弱引用定義一個 __strong
的引用。
不管 block 是否通過屬性被 retain ,這裡也不會發生循環引用。如果 block 被傳遞到其他對象並且被復制了,執行的時候,weakSelf 可能被nil,因為強引用被賦值並且不會變成nil的時候,我們確保對象 在 block 調用的完整周期裡面被 retain了,如果搶占發生了,隨後的對 strongSelf 的執行會繼續並且會產生一樣的值。如果 strongSelf 的執行到 nil,那麼在 block 不能正確執行前已經返回了。
__weak typeof(self) weakSelf = self;
myObj.myBlock = ^{
__strong typeof(self) strongSelf = weakSelf;
if (strongSelf) {
[strongSelf doSomething]; // strongSelf != nil
// preemption, strongSelf still not nil(搶占的時候,strongSelf 還是非 nil 的)
[strongSelf doSomethingElse]; // strongSelf != nil
}
else {
// Probably nothing...
return;
}
};
在ARC條件中,如果嘗試用 ->
符號訪問一個實例變量,編譯器會給出非常清晰的錯誤信息:
Dereferencing a __weak pointer is not allowed due to possible null value caused by race condition, assign it to a strong variable first. (對一個 __weak 指針的解引用不允許的,因為可能在競態條件裡面變成 null, 所以先把他定義成 strong 的屬性)
可以用下面的代碼展示
__weak typeof(self) weakSelf = self;
myObj.myBlock = ^{
id localVal = weakSelf->someIVar;
};
在最後
方案 1: 只能在 block 不是作為一個 property 的時候使用,否則會導致 retain cycle。
方案 2: 當 block 被聲明為一個 property 的時候使用。
方案 3: 和並發執行有關。當涉及異步的服務的時候,block 可以在之後被執行,並且不會發生關於 self 是否存在的問題。
委托模式 是 Apple 的框架裡面使用廣泛的模式,同時它是四人幫的書“設計模式”中的重要模式之一。委托代理模式是單向的,消息的發送方(委托方)需要知道接收方(代理方)是誰,反過來就沒有必要了。對象之間耦合較松,發送方僅需知道它的代理方是否遵守相關 protocol 即可。
本質上,委托代理模式僅需要代理方提供一些回調方法,即代理方需要實現一系列空返回值的方法。
不幸的是 Apple 的 API 並沒有遵守這個原則,開發者也效仿 Apple 進入了一個誤區。典型的例子就是 UITableViewDelegate 協議。
它的一些方法返回 void 類型,就像我們所說的回調:
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath;
- (void)tableView:(UITableView *)tableView didHighlightRowAtIndexPath:(NSIndexPath *)indexPath;
但是其他的就不是那麼回事:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath;
- (BOOL)tableView:(UITableView *)tableView canPerformAction:(SEL)action forRowAtIndexPath:(NSIndexPath *)indexPath withSender:(id)sender;
當委托者詢問代理者一些信息的時候,這就暗示著信息是從代理者流向委托者而非相反的過程。 這(譯者注:委托者 ==Data==> 代理者)是概念性的不同,須用另一個新的名字來描述這種模式:數據源模式。
可能有人會說 Apple 有一個 UITableViewDataSouce protocol 來做這個(雖然使用委托模式的名字),但是實際上它的方法是用來提供真實的數據應該如何被展示的信息的。
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath;
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView;
此外,以上兩個方法 Apple 混合了展示層和數據層,這顯的非常糟糕,但是很少的開發者感到糟糕。而且我們在這裡把空返回值和非空返回值的方法都天真地叫做委托方法。
為了分離概念,我們應該這樣做:
委托模式(delegate pattern):事件發生的時候,委托者需要通知代理者。數據源模式(datasource pattern): 委托者需要從數據源對象拉取數據。這個是實際的例子:
@class ZOCSignUpViewController;
@protocol ZOCSignUpViewControllerDelegate
- (void)signUpViewControllerDidPressSignUpButton:(ZOCSignUpViewController *)controller;
@end
@protocol ZOCSignUpViewControllerDataSource
- (ZOCUserCredentials *)credentialsForSignUpViewController:(ZOCSignUpViewController *)controller;
@end
@interface ZOCSignUpViewController : UIViewController
@property (nonatomic, weak) id delegate;
@property (nonatomic, weak) id dataSource;
@end
代理方法必須以調用者(即委托者)作為第一個參數,就像上面的例子一樣。否則代理者無法區分不同的委托者實例。換句話說,調用者(委托者)沒有被傳遞給代理,那就沒有方法讓代理處理兩個不同的委托者,所以下面這種寫法人神共怒:
- (void)calculatorDidCalculateValue:(CGFloat)value;
默認情況下,代理者需要實現 protocol 的方法。可以用@required
和 @optional
關鍵字來標記方法是否是必要的還是可選的(默認是 @required
: 必需的)。
@protocol ZOCSignUpViewControllerDelegate
@required
- (void)signUpViewController:(ZOCSignUpViewController *)controller didProvideSignUpInfo:(NSDictionary *)dict;
@optional
- (void)signUpViewControllerDidPressSignUpButton:(ZOCSignUpViewController *)controller;
@end
對於可選的方法,委托者必須在發送消息前檢查代理是否確實實現了特定的方法(否則會 crash):
if ([self.delegate respondsToSelector:@selector(signUpViewControllerDidPressSignUpButton:)]) {
[self.delegate signUpViewControllerDidPressSignUpButton:self];
}
有時候你可能需要重載代理方法。考慮有兩個 UIViewController 子類的情況:UIViewControllerA 和 UIViewControllerB,有下面的類繼承關系。
UIViewControllerB < UIViewControllerA < UIViewController
UIViewControllerA
遵從 UITableViewDelegate
並且實現了 - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
.
你可能會想要在 UIViewControllerB
中提供一個不同的實現,這個實現可能是這樣子的:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
CGFloat retVal = 0;
if ([super respondsToSelector:@selector(tableView:heightForRowAtIndexPath:)]) {
retVal = [super tableView:self.tableView heightForRowAtIndexPath:indexPath];
}
return retVal + 10.0f;
}
但是如果超類(UIViewControllerA
)沒有實現這個方法呢?此時調用[super respondsToSelector:@selector(tableView:heightForRowAtIndexPath:)]
方法,將使用 NSObject 的實現,在 self
上下文深入查找並且明確 self
實現了這個方法(因為 UITableViewControllerA
遵從 UITableViewDelegate
),但是應用將在下一行發生崩潰,並提示如下錯誤信息:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[UIViewControllerB tableView:heightForRowAtIndexPath:]: unrecognized selector sent to instance 0x8d82820'
*** 由於未捕獲異常 `NSInvalidArgumentException(無效的參數異常)`導致應用終止,理由是:向實例 ox8d82820 發送了無法識別的 selector `- [UIViewControllerB tableView:heightForRowAtIndexPath:]`
這種情況下我們需要來詢問特定的類實例是否可以響應對應的 selector。下面的代碼提供了一個小技巧:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
CGFloat retVal = 0;
if ([[UIViewControllerA class] instancesRespondToSelector:@selector(tableView:heightForRowAtIndexPath:)]) {
retVal = [super tableView:self.tableView heightForRowAtIndexPath:indexPath];
}
return retVal + 10.0f;
}
就像上面丑陋的代碼,通常它會是更好的設計架構的方式,因為這種方式代理方法不需要被重寫。
多重委托是一個非常基礎的概念,但是,大多數開發者對此非常不熟悉而使用 NSNotifications。就像你可能注意到的,委托和數據源是對象之間的通訊模式,但是只涉及兩個對象:委托者和委托。
數據源模式強制一對一的關系,當發送者請求信息時有且只能有一個對象來響應。對於代理模式而言這會有些不同,我們有足夠的理由要去實現很多代理者等待(唯一委托者的)回調的場景。
一些情況下至少有兩個對象對特定委托者的回調感興趣,而後者(即委托者)需要知道他的所有代理。這種方法在分布式系統下更為適用並且廣泛使用於大型軟件的復雜信息流程中。
多重委托可以用很多方式實現,但讀者更在乎找到適合自己的個人實現。Luca Bernardi 在他的 LBDelegateMatrioska中提供了上述范式的一個非常簡潔的實現。
這裡給出一個基本的實現,方便你更好地理解這個概念。即使在Cocoa中也有一些在數據結構中保存 weak 引用來避免 引用循環的方法, 這裡我們使用一個類來保留代理對象的 weak 引用(就像單一代理那樣):
@interface ZOCWeakObject : NSObject
@property (nonatomic, readonly, weak) id object;
//譯者注:這裡原文並沒有很好地實踐自己在本書之前章節所討論的關於property屬性修飾符的
//人體工程學法則: 從左到右: 原子性 ===》 讀寫權限 (別名) ===》 內存管理權限符
+ (instancetype)weakObjectWithObject:(id)object;
- (instancetype)initWithObject:(id)object;
@end
@interface ZOCWeakObject ()
@property (nonatomic, weak) id object;
@end
@implementation ZOCWeakObject
+ (instancetype)weakObjectWithObject:(id)object {
return [[[self class] alloc] initWithObject:object];
}
- (instancetype)initWithObject:(id)object {
if ((self = [super init])) {
_object = object;
}
return self;
}
- (BOOL)isEqual:(id)object {
if (self == object) {
return YES;
}
if (![object isKindOfClass:[object class]]) {
return NO;
}
return [self isEqualToWeakObject:(ZOCWeakObject *)object];
}
- (BOOL)isEqualToWeakObject:(ZOCWeakObject *)object {
if (!object) {
return NO;
}
BOOL objectsMatch = [self.object isEqual:object.object];
return objectsMatch;
}
- (NSUInteger)hash {
return [self.object hash];
}
@end
使用 weak 對象來實現多重代理的簡單組件:
@protocol ZOCServiceDelegate
@optional
- (void)generalService:(ZOCGeneralService *)service didRetrieveEntries:(NSArray *)entries;
@end
@interface ZOCGeneralService : NSObject
- (void)registerDelegate:(id)delegate;
- (void)deregisterDelegate:(id)delegate;
@end
@interface ZOCGeneralService ()
@property (nonatomic, strong) NSMutableSet *delegates;
@end
@implementation ZOCGeneralService
- (void)registerDelegate:(id)delegate {
if ([delegate conformsToProtocol:@protocol(ZOCServiceDelegate)]) {
[self.delegates addObject:[[ZOCWeakObject alloc] initWithObject:delegate]];
}
}
- (void)deregisterDelegate:(id)delegate {
if ([delegate conformsToProtocol:@protocol(ZOCServiceDelegate)]) {
[self.delegates removeObject:[[ZOCWeakObject alloc] initWithObject:delegate]];
}
}
- (void)_notifyDelegates {
...
for (ZOCWeakObject *object in self.delegates) {
if (object.object) {
if ([object.object respondsToSelector:@selector(generalService:didRetrieveEntries:)]) {
[object.object generalService:self didRetrieveEntries:entries];
}
}
}
}
@end
在 registerDelegate:
和 deregisterDelegate:
方法的幫助下,連接/解除組件之間的聯系很簡單:在某些時間點上,如果代理不需要接收委托者的回調,僅僅需要’unsubscribe’.
當不同的 view 等待同一個回調來更新界面展示的時候,這很有用:如果 view 只是暫時隱藏(但是仍然存在),它僅僅需要取消對回調的訂閱。
Aspect Oriented Programming (AOP,面向切面編程) 在 Objective-C 社區內沒有那麼有名,但是 AOP 在運行時可以有巨大威力。 但是因為沒有事實上的標准,Apple 也沒有開箱即用的提供,也顯得不重要,開發者都不怎麼考慮它。
引用 Aspect Oriented Programming 維基頁面:
An aspect can alter the behavior of the base code (the non-aspect part of a program) by applying advice (additional behavior) at various join points (points in a program) specified in a quantification or query called a pointcut (that detects whether a given join point matches). (一個切面可以通過在多個 join points 中附加的行為來改變基礎代碼的行為(程序的非切面的部分) )
在 Objective-C 的世界裡,這意味著使用運行時的特性來為指定的方法追加 切面 。切面所附加的行為可以是這樣的:
在類的特定方法調用前運行特定的代碼在類的特定方法調用後運行特定的代碼增加代碼來替代原來的類的方法的實現有很多方法可以達成這些目的,但是我們沒有深入挖掘,不過它們主要都是利用了運行時。 Peter Steinberger 寫了一個庫,Aspects 完美地適配了 AOP 的思路。我們發現它值得信賴以及設計得非常優秀,所以我們就在這邊作為一個簡單的例子。
對於所有的 AOP庫,這個庫用運行時做了一些非常酷的魔法,可以替換或者增加一些方法(比 method swizzling 技術更有技巧性)
Aspect 的 API 有趣並且非常強大:
+ (id)aspect_hookSelector:(SEL)selector
withOptions:(AspectOptions)options
usingBlock:(id)block
error:(NSError **)error;
- (id)aspect_hookSelector:(SEL)selector
withOptions:(AspectOptions)options
usingBlock:(id)block
error:(NSError **)error;
比如,下面的代碼會對於執行 MyClass
類的 myMethod:
(實例或者類的方法) 執行塊參數。
[MyClass aspect_hookSelector:@selector(myMethod:)
withOptions:AspectPositionAfter
usingBlock:^(id aspectInfo) {
...
}
error:nil];
換一句話說:任意的 MyClass
類型的對象(或者是類型本身當這個 @selector 方法為類方法時)的 @selector
方法執行完後,就會執行這個代碼中塊參數所提供的代碼。
我們為 MyClass
類的 myMethod:
方法增加了切面。
通常 AOP 被用來實現橫向切面。統計與日志就是一個完美的例子。
下面的例子裡面,我們會用AOP用來進行統計。統計是iOS項目裡面一個熱門的特性,有很多選擇比如 Google Analytics, Flurry, MixPanel, 等等.
大部分統計框架都有教程來指導如何追蹤特定的界面和事件,包括在每一個類裡寫幾行代碼。
在 Ray Wenderlich 的博客裡有 文章 和一些示例代碼,通過在你的 view controller 裡面加入 Google Analytics 進行統計。
- (void)logButtonPress:(UIButton *)button {
id tracker = [[GAI sharedInstance] defaultTracker];
[tracker send:[[GAIDictionaryBuilder createEventWithCategory:@"UX"
action:@"touch"
label:[button.titleLabel text]
value:nil] build]];
}
上面的代碼在按鈕點擊的時候發送了特定的上下文事件。但是當你想追蹤屏幕的時候會變得很糟。
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
id tracker = [[GAI sharedInstance] defaultTracker];
[tracker set:kGAIScreenName value:@"Stopwatch"];
[tracker send:[[GAIDictionaryBuilder createAppView] build]];
}
對於大部分有經驗的iOS工程師,這看起來不是很好的代碼。我們讓 view controller 變得更糟糕了。因為我們加入了統計事件的代碼,但是它不是 view controller 的職能。你可以反駁,因為你通常有特定的對象來負責統計追蹤,並且你將代碼注入了 view controller ,但是無論你隱藏邏輯,問題仍然存在 :你最後還是在viewDidAppear:
後插入了代碼。
我們可以在類的 viewDidAppear:
方法上使用 AOP 來追蹤屏幕,並且我們可以使用同樣的方法在其他我們感興趣的方法上添加事件追蹤。比如當用戶點擊某個按鈕時(比如:一般調用對應的 IBAction).
方法很簡潔且不具侵入性:
view controller 不會被不屬於它的代碼污染為所有加入到我們代碼的切面指定一個 SPOC 文件 (single point of customization)提供了可能 SPOC 應該在 App 剛開始啟動的時候用來添加切面如果SPOC文件異常,至少有一個 selector 或者 類 識別不出來,應用將會在啟動時崩潰(對我們來說這很酷). 公司負責統計的團隊通常會提供統計文檔,羅列出需要追蹤的事件。這個文檔可以很容易映射到一個 SPOC 文件。追蹤邏輯抽象化之後,擴展到很多其他統計框架會很方便對於屏幕視圖,對於需要定義 selector 的方法,只需要在 SPOC 文件修改相關的類(相關的切面會加入到viewDidAppear:
方法)。如果要同時發送屏幕視圖和事件,需要(依靠統計提供方)提供一個追蹤的標示或者可能還需要提供其他的元信息。
我們可能希望一個 SPOC 文件類似下面的(同樣的一個 .plist 文件會適配)
NSDictionary *analyticsConfiguration()
{
return @{
@"trackedScreens" : @[
@{
@"class" : @"ZOCMainViewController",
@"label" : @"Main screen"
}
],
@"trackedEvents" : @[
@{
@"class" : @"ZOCMainViewController",
@"selector" : @"loginViewFetchedUserInfo:user:",
@"label" : @"Login with Facebook"
},
@{
@"class" : @"ZOCMainViewController",
@"selector" : @"loginViewShowingLoggedOutUser:",
@"label" : @"Logout with Facebook"
},
@{
@"class" : @"ZOCMainViewController",
@"selector" : @"loginView:handleError:",
@"label" : @"Login error with Facebook"
},
@{
@"class" : @"ZOCMainViewController",
@"selector" : @"shareButtonPressed:",
@"label" : @"Share button"
}
]
};
}
提及的架構托管 在 Github 的EF Education First 中.
- (void)setupWithConfiguration:(NSDictionary *)configuration
{
// screen views tracking
for (NSDictionary *trackedScreen in configuration[@"trackedScreens"]) {
Class clazz = NSClassFromString(trackedScreen[@"class"]);
[clazz aspect_hookSelector:@selector(viewDidAppear:)
withOptions:AspectPositionAfter
usingBlock:^(id aspectInfo) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0),
^{
NSString *viewName = trackedScreen[@"label"];
[tracker trackScreenHitWithName:viewName];
});
}
error:nil];
}
// events tracking
for (NSDictionary *trackedEvents in configuration[@"trackedEvents"]) {
Class clazz = NSClassFromString(trackedEvents[@"class"]);
SEL selektor = NSSelectorFromString(trackedEvents[@"selector"]);
[clazz aspect_hookSelector:selektor
withOptions:AspectPositionAfter
usingBlock:^(id aspectInfo) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0),
^{
UserActivityButtonPressedEvent *buttonPressEvent = \
[UserActivityButtonPressedEvent \
eventWithLabel:trackedEvents[@"label"]];
[tracker trackEvent:buttonPressEvent];
});
}
error:nil];
}
}