正確編寫Designated Initializer的幾個原則

發表於2014-04-22

Designated Initializer(指定初始化器)在Objective-C裡面是很重要的概念,但是在日常開發中我們往往會忽視它的重要性,以至於我們寫出的程式碼具有潛藏的Bug,且不易發現。保證良好的編寫Designated Initializer的風格,可以讓我們節約很多時間。 前段時間@吳發偉Ted分享了一篇Twitter團隊的一篇部落格,裡面講述了Designated Initializer正確的模板以及需要注意的問題。但是裡面關於initWithCoder描述不是很清晰,且隨後@an00na給出了不同的看法。我會在接下來的文章講述驗證他們給出的編寫Designated Initializer的原則,並對initWithCoder的分歧做一個分析,瞭解其背後的機制。

準備工作

為了能夠跟蹤程式碼的實際呼叫順序,在下面的例項分析中,我將會使用Xtrace。這是一個在除錯上非常強大的一個庫,實現原理是通過Hook的方式跟蹤訊息。詳細可以看Github上的說明。 需要注意的是,Xtrace裡面設定了不跟蹤initWithCoder,由於我們後面分析的需要我們需要把Xtrace.h裡面的一段程式碼做一些小改動:

這段程式碼標示了不跟蹤的@selector,我們需要將initWithCoder刪除,才能跟蹤這個方法。
接下來,我們需要在AppDelegate.m裡面加入一段程式碼:

  1. 由於每個類物件呼叫的方法很多,為了不被干擾,宣告我們只跟蹤以init開頭的方法。
  2. 這幾行宣告瞭我們將會跟蹤的類。也就是說,一旦這些類呼叫了我們跟蹤的方法,就會有資訊輸出。

分析程式碼

我會先給出我認為應該遵循的原則,並對每個原則做實際分析。

  • 每個類的正確初始化過程應當是按照從子類到父類的順序,依次呼叫每個類的Designated Initializer。並且用父類的Designated Initializer初始化一個子類物件,也需要遵從這個過程。
  • 如果子類指定了新的初始化器,那麼在這個初始化器內部必須呼叫父類的Designated Initializer。並且需要重寫父類的Designated Initializer,將其指向子類新的初始化器

TestInitView是一個繼承於UIView,它重新指定的初始化器為initWithFrame:andName:。現在,假設這個類的初始化器如下:

可以看到在裡面並沒有呼叫UIView的Designated InitializerinitWithFrame:。那麼會有什麼後果呢?
我們用這個Designated Initializer生成一個TestInitView物件:

執行程式,我們會看到Xtrce的跟蹤記錄如下:

咦,似乎沒有問題啊。整個繼承鏈的初始化器都被呼叫了。等等,如果我們用UIView的Designated Initializer生成一個TestInitView物件會怎樣呢?

執行程式碼後,我們得到的呼叫過程如下:

 

這時會發現TestInitView的初始化器initWithFrame:andName:沒有被呼叫。
我們再修改下程式碼:

我們依然使用UIView的Designated Initializer,然後執行程式得到下面的結果:

 

TestInitView的初始化器依然沒有被呼叫。原因就是沒有我們沒有重寫父類UIView的Designated Initializer。修改後我們的最終程式碼如下:

繼續測試我們的程式碼,得到的結果如下:

 

OK,執行正確!

  • 你可以不自定義Designated Initializer,也可以重寫父類的Designated Initializer,但需要呼叫直接父類的Designated Initializer。

也就是說,上面的程式碼,我們可以不寫Designated Initializer,也可以這麼修改:

但是下面這樣寫就是錯誤的,因為呼叫的不是直接父類的Designated Initializer

  • 如果有多個Secondary initializers(次要初始化器),它們之間可以任意呼叫,但最後必須指向Designated Initializer。在Secondary initializers內不能直接呼叫父類的初始化器。 這句話可能看著有點糊塗,還是讓們繼續看看程式碼:

可以看到方法initWithName2呼叫的順序是initWithName2–>initWithName–>initWithFrame:andName:,最後指向了Designated Initializer。同時需要注意的是,Secondary initializers不僅可以是例項方法,也可以是靜態方法,如程式碼例項中的testInitViewWithName:。

initWithCoder:

文章開頭我就提到兩篇引用文章關於initWithCoder的分歧。 首先,如果直接父類也實現了NSCoding協議,那麼子類的initWithCoder也必須呼叫父類的initWithCoder。
這點大家的觀點都是相同的,蘋果官方文件也是這麼說的。
主要的分歧在於當直接父類不實現NSCoding時,子類的initWithCoder應該呼叫哪個初始化器。

Twitter團隊的那片部落格給出的看法是:

There’s a problem with the example provided in the documentation for initWithCoder:, specifically the call to [super (designated initializer)]. If you’re a direct subclass of NSObject, calling [super (designated initializer)] won’t call your [self (designated initializer)]. If you’re not a direct subclass of NSObject, and one of your ancestors implements a new designated initializer, calling [super (designated initializer)] WILL call your [self (designated initializer)]. This means that apple’s suggestion to call super in initWithCoder encourages non-deterministic initialization behavior, and is not consistent with the solid foundations laid by the designated initializer pattern. Therefore, my recommendation is that you should treat initWithCoder: as a secondary initializer, and call [self (designated initializer)], not [super (designated initializer)], if your superclass does not conform to NSCoding.

直接理解有點困難,我們用程式碼來說話。我們新建一個類SubTestInitView,它繼承於TestInitView,且它實現NSCoding協議。

可以看到,我們在initWithCoder:裡面呼叫的是TestInitView的Designated Initializer。讓我們看看呼叫順序(P.S. 在xib裡面拖入一個View,然後指定其型別為SubTestInitView,這樣就會呼叫其initWithCoder:):

根據引用的內容,其認為會呼叫順序中有[self (designated initializer)],也就是SubTestInitView 的- (instancetype)initWithFrame:(CGRect)frame andName:(NSString *)name,但是實際結果並不是這樣的。子類的Designated Initializer並沒有被呼叫。而最後其建議將initWithCoder:視為Secondary initializers,然後在裡面呼叫[self (designated initializer)]。這樣做的目的是為了保證[self (designated initializer)]會被呼叫,以保證初始化是正確的。
對此,@an00na則提出不一致的看法。
@an00na引入了資料來源的概念,他認為initWithCoder:是用另一個資料來源NSDecoder初始化的。一個類可以由多個資料來源初始化,也就是說可以有多個Designated Initializer。因此,initWithCoder是Designated Initializer,而根據我們前面提到的原則,我們應該呼叫父類的Designated Initializer。
這個時候,我產生了疑惑。按照這個說法,此時應該如程式碼所示,呼叫父類TestInitView的initWithFrame:andName:明顯有問題,因為並非是同個資料來源。可是父類並沒有實現NSCoding,我們可以呼叫他的initWithCoder(實際上是UIView的)嗎?
我注意到UIButton的繼承順序是UIButton->UIControl->UIView…,UIControl同樣沒有實現NSCoding,那麼對於這種情況,apple又是如何做的呢?我跟蹤了UIButton的生成過程:

間隔線上面是以[UIButton buttonWithType:UIButtonTypeCustom]方式初始化的UIButton,下面的則是通過xib初始化的UIButton。我們可以看到,下面的初始化順序並沒由呼叫到initWithFrame:,這也證明了@an00na關於資料來源的看法。而同時可以發現中間呼叫了UIControl的initWithCoder:!
於是,在這裡,我認為如果你的自定義類的其中一個父類實現了NSCoding協議,而且你有擴充套件屬性,雖然你本身沒有宣告會實現NSCoding協議,你也需要實現NSCoding的兩個方法:initWithCoder:和encodeWithCoder:,並對你擴充套件的屬性做coding操作。
給TestInitView補上這兩個方法,SubTestInitView的initWithCoder也呼叫父類的initWithCoder後,讓我們再看看實際的呼叫順序:

 

多資料來源

經過上面的探討,我們瞭解了多資料來源的設計模型。但是這帶來了另外一個問題:如何使得通過不同資料來源初始化動作是一致的?
比如說,我們往往需要再物件初始化緊接著做一些動作。由於多資料來源的存在,並且不同的資料來源提供的資料內容不一樣,我們應當如何處理?
很自然的,我們可能會想到講部分動作重構成一個方法,無論從哪個資料來源初始化,都呼叫這個方法。
如果是自己編寫的不同資料來源的Designated Initializer,具體呼叫這個方法的時機需要自己把握。我在這裡著重講一下UIView或UIViewController的子類在處理多資料來源需要注意的問題。
按前面所述,UIView和UIViewController的子類需要實現NSCoding的兩個方法。但是我們需要注意的是,同樣是呼叫initWithCoder,卻仍然可能是不同的資料來源:UINibDecoder或者NSKeyedUnarchiver。
這兩者分別代表什麼情況呢?UINibDecoder是指xib初始化,而NSKeyedUnarchiver是指通過歸檔檔案(如plist/txt等物理檔案)初始化。它們最大的區別是UINibDecoder僅包含了xib裡面能夠設定的資料,而NSKeyedUnarchiver包括了整個物件的資料,也就是說如果是從NSKeyedUnarchiver初始化的,是不再需要額外的初始化動作了。而如果是從UINibDecoder初始化的,則在物件初始化後,會呼叫其awakeFromNib方法,我們需要把額外的初始化動作寫在這裡。
根據這個,我再次改寫了TestInitView如下:

這樣我們就可以保證無論是從什麼資料來源初始化,得出的結果都是正確的了。事實上,如果在工程中我們沒有對UIView或UIViewController的子類物件有歸檔的需求的話,NSCoding的兩個方法可以省去不寫。

綜述

經過一番長篇大論:),我們可以總結正確編寫Designated Initializer的原則如下:

  1. 每個類的正確初始化過程應當是按照從子類到父類的順序,依次呼叫每個類的Designated Initializer。並且用父類的Designated Initializer初始化一個子類物件,也需要遵從這個過程。
  2. 如果子類指定了新的初始化器,那麼在這個初始化器內部必須呼叫父類的Designated Initializer。並且需要重寫父類的Designated Initializer,將其指向子類新的初始化器。
  3. 你可以不自定義Designated Initializer,也可以重寫父類的Designated Initializer,但需要呼叫直接父類的Designated Initializer。
  4. 如果有多個Secondary initializers(次要初始化器),它們之間可以任意呼叫,但最後必須指向Designated Initializer。在Secondary initializers內不能直接呼叫父類的初始化器。
  5. 如果有多個不同資料來源的Designated Initializer,那麼不同資料來源下的Designated Initializer應該呼叫相應的[super (designated initializer)]。如果父類沒有實現相應的方法,則需要根據實際情況來決定是給父類補充一個新的方法還是呼叫父類其他資料來源的Designated Initializer。比如UIView的initWithCoder呼叫的是NSObject的init。
  6. 需要注意不同資料來源下新增額外初始化動作的時機。

P.S.儘管我做了很多例項分析研究,但經驗所限可能仍有不足或不對之處,歡迎指正。本文內大量內容來源Twitter團隊部落格的那篇文章以及@an00na的文章,我僅僅是用中文做了講述,在此表示十分感謝!

相關文章