鍍金池/ 教程/ iOS/ 依賴注入
與四軸無人機的通訊
在沙盒中編寫腳本
結(jié)構(gòu)體和值類型
深入理解 CocoaPods
UICollectionView + UIKit 力學(xué)
NSString 與 Unicode
代碼簽名探析
測試
架構(gòu)
第二期-并發(fā)編程
Metal
自定義控件
iOS 中的行為
行為驅(qū)動開發(fā)
Collection View 動畫
截圖測試
MVVM 介紹
使 Mac 應(yīng)用數(shù)據(jù)腳本化
一個完整的 Core Data 應(yīng)用
插件
字符串
為 iOS 建立 Travis CI
先進的自動布局工具箱
動畫
為 iOS 7 重新設(shè)計 App
XPC
從 NSURLConnection 到 NSURLSession
Core Data 網(wǎng)絡(luò)應(yīng)用實例
GPU 加速下的圖像處理
自定義 Core Data 遷移
子類
與調(diào)試器共舞 - LLDB 的華爾茲
圖片格式
并發(fā)編程:API 及挑戰(zhàn)
IP,TCP 和 HTTP
動畫解釋
響應(yīng)式 Android 應(yīng)用
初識 TextKit
客戶端
View-Layer 協(xié)作
回到 Mac
Android
Core Image 介紹
自定義 Formatters
Scene Kit
調(diào)試
項目介紹
Swift 的強大之處
測試并發(fā)程序
Android 通知中心
調(diào)試:案例學(xué)習(xí)
從 UIKit 到 AppKit
iOS 7 : 隱藏技巧和變通之道
安全
底層并發(fā) API
消息傳遞機制
更輕量的 View Controllers
用 SQLite 和 FMDB 替代 Core Data
字符串解析
終身學(xué)習(xí)的一代人
視頻
Playground 快速原型制作
Omni 內(nèi)部
同步數(shù)據(jù)
設(shè)計優(yōu)雅的移動游戲
繪制像素到屏幕上
相機與照片
音頻 API 一覽
交互式動畫
常見的后臺實踐
糟糕的測試
避免濫用單例
數(shù)據(jù)模型和模型對象
Core Data
字符串本地化
View Controller 轉(zhuǎn)場
照片框架
響應(yīng)式視圖
Square Register 中的擴張
DTrace
基礎(chǔ)集合類
視頻工具箱和硬件加速
字符串渲染
讓東西變得不那么糟
游戲中的多點互聯(lián)
iCloud 和 Core Data
Views
虛擬音域 - 聲音設(shè)計的藝術(shù)
導(dǎo)航應(yīng)用
線程安全類的設(shè)計
置換測試: Mock, Stub 和其他
Build 工具
KVC 和 KVO
Core Image 和視頻
Android Intents
在 iOS 上捕獲視頻
四軸無人機項目
Mach-O 可執(zhí)行文件
UI 測試
值對象
活動追蹤
依賴注入
Swift
項目管理
整潔的 Table View 代碼
Swift 方法的多面性
為什么今天安全仍然重要
Core Data 概述
Foundation
Swift 的函數(shù)式 API
iOS 7 的多任務(wù)
自定義 Collection View 布局
測試 View Controllers
訪談
收據(jù)驗證
數(shù)據(jù)同步
自定義 ViewController 容器轉(zhuǎn)場
游戲
調(diào)試核對清單
View Controller 容器
學(xué)無止境
XCTest 測試實戰(zhàn)
iOS 7
Layer 中自定義屬性的動畫
第一期-更輕量的 View Controllers
精通 iCloud 文檔存儲
代碼審查的藝術(shù):Dropbox 的故事
GPU 加速下的圖像視覺
Artsy
照片擴展
理解 Scroll Views
使用 VIPER 構(gòu)建 iOS 應(yīng)用
Android 中的 SQLite 數(shù)據(jù)庫支持
Fetch 請求
導(dǎo)入大數(shù)據(jù)集
iOS 開發(fā)者的 Android 第一課
iOS 上的相機捕捉
語言標簽
同步案例學(xué)習(xí)
依賴注入和注解,為什么 Java 比你想象的要好
編譯器
基于 OpenCV 的人臉識別
玩轉(zhuǎn)字符串
相機工作原理
Build 過程

依賴注入

從一個例子開始,比如說寫了這樣一個方法:

- (NSNumber *)nextReminderId
{
    NSNumber *currentReminderId = [[NSUserDefaults standardUserDefaults] objectForKey:@"currentReminderId"];
    if (currentReminderId) {
      // 增加前一個 reminderId
      currentReminderId = @([currentReminderId intValue] + 1);
    } else {
      // 如果還沒有,設(shè)為 0
      currentReminderId = @0;
    }
    // 將 currentReminderId 更新到 model 中
    [[NSUserDefaults standardUserDefaults] setObject:currentReminderId forKey:@"currentReminderId"];
    return currentReminderId;
}

如何針對這個方法編寫單元測試呢?這里需要注意一點,該方法中操作了一個不屬于其控制的對象NSUserDefaults。

容我贅述,就這個例子展開說,雖然這里我使用了 NSUserDefaults,但這背后顯然有一個更大的范疇。這個問題不僅僅是 “如何去測試一個操作了 NSUserDefaults 的方法?”,而可以演化為 “若一個對象對于快速且可重復(fù)的測試有著直接影響,如何對一個依賴這種對象的方法進行單元測試呢?”。

目前此類單元測試的最大障礙是,如何在你想要測試的代碼之外的地方處理這種依賴關(guān)系。依賴注入 (dependency injection,簡稱 DI) 這一范疇內(nèi)就有一系列方法專門用于解決此類問題。

依賴注入的幾種形式

其實一提到 DI,很多人會直接想到依賴注入框架或者是控制反轉(zhuǎn) (Inversion of Control 簡稱 IoC) 容器。請把這些概念都暫且擱置,我會在后面的 FAQ (常見問題) 中做說明。

現(xiàn)行有很多技術(shù)可以處理在依賴中注入某些東西這件事情。比如說 Objective-C runtime 中的 swizzling 就是其一,swizzling 可以在運行時動態(tài)地將方法進行替換。當然也有人提出質(zhì)疑,他們覺得 swizzling 的存在讓 DI 變得無關(guān)緊要,甚至應(yīng)盡量避免使用 DI。但是我更傾向于那些使依賴關(guān)系能夠清晰化的代碼,因為這樣更便于觀察它們 (并且促使我們?nèi)ヌ幚砟切┯捎谝蕾囘^于復(fù)雜而導(dǎo)致的變壞或者錯誤的代碼)。

接下來我們快速了解一下 DI 的形式。其中除一個以外,其他的例子都來自于 Mark Seemann 的 Dependency Injection in .Net

構(gòu)造器注入

注意:盡管 Objective-C 本身沒有所謂的構(gòu)造器而是使用初始化方法,但因為構(gòu)造器注入是 DI 的標準概念,放到各種語言中也是普遍適用的,所以我還是準備用構(gòu)造器注入這個詞來代指初始化注入。

構(gòu)造器注入,即將某個依賴對象傳入到構(gòu)造器中 (在 Objective- C中指 designated 初始化方法) 并存儲起來,以便在后續(xù)過程中使用:

@interface Example ()
@property (nonatomic, strong, readonly) NSUserDefaults *userDefaults;
@end

@implementation Example
- (instancetype)initWithUserDefaults:(NSUserDefaults *userDefaults)
{
    self = [super init];
    if (self) {
        _userDefaults = userDefaults;
    }
    return self;
}
@end

可以用實例變量或者是屬性來存儲依賴對象。上面的例子中用一個只讀的屬性來存儲,防止依賴對象被篡改。

NSUserDefaults 進行注入看起來會比較怪,這可能也是這個例子的不足之處。注意,NSUserDefaults 作為依賴對象,臉上就寫著 “麻煩制造者” 這幾個字。其實被注入的更應(yīng)該是一個抽象類型的對象 (像 id 這種) 來作為依賴可能會比指定某個具體類型要更好一些。但本文就不做更多展開了,還是繼續(xù)以 NSUserDefaults 來說明。

至此,這個類中每一處要使用單例 [NSUserDefaults standardUserDefaults] 的地方,都應(yīng)該用 self.userDefaults 來替代:

- (NSNumber *)nextReminderId
{
    NSNumber *currentReminderId = [self.userDefaults objectForKey:@"currentReminderId"];
    if (currentReminderId) {
        currentReminderId = @([currentReminderId intValue] + 1);
    } else {
        currentReminderId = @0;
    }
    [self.userDefaults setObject:currentReminderId forKey:@"currentReminderId"];
    return currentReminderId;
}

屬性注入

對于屬性注入,nextReminderId 的代碼看起來和 self.userDefaults 的做法是一致的。只是這次不是將依賴對象傳遞給初始化方法,而是采用屬性賦值方式:

@interface Example
@property (nonatomic, strong) NSUserDefaults *userDefaults;
- (NSNumber *)nextReminderId;
@end

現(xiàn)在可以在單元測試中創(chuàng)建一個對象,然后將需要的東西通過對 userDefaults 屬性進行賦值。但是要是這個屬性沒有被預(yù)先設(shè)定的話要怎么辦呢?這時,我們可以使用 lazy 加載的方法為其設(shè)置一個適當?shù)哪J值,這能保證始終可以通過 getter 拿到一個確切的值:

- (NSUserDefaults *)userDefaults
{
    if (!_userDefaults) {
         _userDefaults = [NSUserDefaults standardUserDefaults];
    }
return _userDefaults;
}

這樣的話,對 userDefaults 來說,如果在使用者取值之前做過賦值操作,那么從 self.userDefaults 得到的就是通過 setter 賦的值。如果這個屬性在使用前未被賦值,從 self.userDefaults 得到的就是 [NSUserDefaults standardUserDefaults]。

方法注入

如果依賴對象只在某一個方法中被使用,則可以利用方法參數(shù)做注入:

- (NSNumber *)nextReminderIdWithUserDefaults:(NSUserDefaults *)userDefaults
{
    NSNumber *currentReminderId = [userDefaults objectForKey:@"currentReminderId"];
    if (currentReminderId) {
        currentReminderId = @([currentReminderId intValue] + 1);
    } else {
        currentReminderId = @0;
    }
    [userDefaults setObject:currentReminderId forKey:@"currentReminderId"];
    return currentReminderId;
}

再一次說明,這樣看起來可能會很奇怪,并不是所有的例子中 NSUserDefaults 作為依賴都顯得恰如其分。比如說這個例子中,如果使用 NSDate 做注入?yún)?shù)傳入可能更會彰顯其特點 (后面對每種注入方式的優(yōu)點做闡述的時候會有更深入的探討)。

環(huán)境上下文

當通過一個類方法 (例如單例) 來訪問依賴對象時,在單元測試中可以通過兩種方式來控制依賴對象:

  • 如果可以控制單例本身,則可以通過公開其屬性來控制其狀態(tài)。

  • 如果上述方式無效或者所操作的單例不歸自己管理,此時就該運用swizzle了:直接替換類方法,讓其返回你所期望的返回值。

這里不會給出具體的 swizzling 的例子;相關(guān)的資源有很多,感興趣的讀者可以自行查找。這邊要說明的就是 swizzling 確實可以用于 DI。在以上的對 DI 形式的簡單介紹后,我們會對它們各自的優(yōu)缺點做進一步的對比分析,請大家繼續(xù)閱讀。

抽取和重寫調(diào)用

最后要說的這個技術(shù)點不在 Seemann 書中所涉及的 DI 形式討論的范疇。關(guān)于抽取和重寫調(diào)用來自于 Michael Feathers 的 Working Effectively With Legacy Code。下面介紹一下如何將這個概念應(yīng)用到我們的 NSUserDefaults 例子中去,具體分為三步:

步驟 1:隨便找一處對 [NSUserDefaults standardUserDefaults] 的調(diào)用。利用 IDE (Xcode 或者 AppCode) 的自動重構(gòu)功能將其抽取成一個新的方法。

步驟 2:將其他所有對 [NSUserDefaults standardUserDefaults] 的調(diào)用均替換成步驟 1 中抽取的方法 (注意不要把已抽取的方法中的 [NSUserDefaults standardUserDefaults] 替換成方法自身,囧)。

修改后的代碼如下:

- (NSNumber *)nextReminderId
{
    NSNumber *currentReminderId = [[self userDefaults] objectForKey:@"currentReminderId"];
    if (currentReminderId) {
        currentReminderId = @([currentReminderId intValue] + 1);
    } else {
        currentReminderId = @0;
    }
    [[self userDefaults] setObject:currentReminderId forKey:@"currentReminderId"];
    return currentReminderId;
}

- (NSUserDefaults *)userDefaults
{
    return [NSUserDefaults standardUserDefaults];
}

妥當完成后,進入最后一步:

步驟 3:創(chuàng)建一個專門的測試子類,重寫剛剛抽取的方法:

@interface TestingExample : Example
@end

@implementation TestingExample

- (NSUserDefaults *)userDefaults
{
    // Do whatever you want!
}

@end

這樣就不再初始化 Example,而是利用創(chuàng)建 TestingExample 來進行測試,至此就可以全權(quán)掌控任何對 [self userDefaults] 的調(diào)用結(jié)果了。

“究竟該選擇使用哪種形式?”

現(xiàn)在,一共提到了五種 DI 的形式。每一種都有其自身的優(yōu)缺點和適用場景。

構(gòu)造器注入

基本上,構(gòu)造器注入應(yīng)該作為首選武器存在。其優(yōu)勢就是讓所涉及的依賴非常清晰。

其缺點便是,乍一看會給人一種非常笨重的感覺。當初始化方法包含了一大堆依賴對象作為參數(shù)的時候尤甚。但這恰巧揭示了前文所提及的腐爛代碼味道的問題:這個類的依賴對象是否也太多了些?它可能已經(jīng)違背了單一職能原則。

屬性注入

屬性注入的長處是將初始化與注入分離,這在不能改變調(diào)用者部分的時候非常有用。那么它的劣勢又是什么呢?還是將初始化與注入分離!是的,你沒看錯。屬性注入使得初始化不充分。它的最佳應(yīng)用場景是在依賴對象有默認值時,換句話說就是確知依賴對象可以在某個時點被 DI 框架賦值。

屬性注入看似容易,但實則不然,特別是如果我們想將其實現(xiàn)得可靠的話:

  • 必須防范屬性被任意重設(shè)值。這需要復(fù)寫系統(tǒng)默認為屬性生成的 setter,要保證相應(yīng)的實例變量為 nil 以及傳入的參數(shù)不是 nil。
  • getter 是否需要線程安全?如果需要,那么與實現(xiàn)需要兼顧效率和線程安全的 getter 相比,使用構(gòu)造器注入就顯得容易多了。

由于人們經(jīng)常會對特定實例存在著固有認識,所以還應(yīng)盡量避免潛意識中對使用屬性注入的傾向性。另外,請確定默認值不會引用到其他庫的代碼。否則,當前的類的使用者還必須得去引用對應(yīng)的庫,這樣的設(shè)計就違背了松耦合原則 (用 Seemann 的概念來解釋就是,這屬于內(nèi)部默認和外部默認的區(qū)別)。

方法注入

假如所依賴的對象針對每次調(diào)用都會有所不同的話,使用方法注入會比較好。一個例子是對調(diào)用點來說,可能會涉及到特定應(yīng)用上下文條件的時候,比如基于一個隨機數(shù),或者是當前時間等。

好比一個方法依賴于當前時間。不建議直接調(diào)用 [NSDate date],最好在這個方法中增加一個 NSDate 參數(shù)。這么做也許會增加一點點的調(diào)用復(fù)雜度,但是方法的靈活性得到了增強。

(雖然對 Objective-C 來說,不需要使用 procotols 也能很好的利用測試置換來做重復(fù)性測試,但我還是推薦大家閱讀一下 J.B. Rainsberger 的"Beyond Mock Objects"。這篇文章從一個有趣的應(yīng)用場景出發(fā),由一個日期注入問題引發(fā)了一系列關(guān)于設(shè)計和重用的很詳實的討論。)

環(huán)境上下文

如果依賴對象在底層有多處應(yīng)用,這就極有可能產(chǎn)生橫切問題。若繼續(xù)將依賴對象向上層傳遞,尤其是還無法預(yù)知這個對象將會在什么時候使用的話,便會促生干擾代碼。舉幾個可能產(chǎn)生這樣問題的例子:

  • 日志處理(Logging)
  • [NSUserDefaults standardUserDefaults]
  • [NSDate date]

這類場景下推薦適用環(huán)境上下文方式。由于是影響全局的上下文,使用完畢后,別忘了要將其還原。比如你用 swizzle 替換了一個方法,需要在 tearDown 或者 afterEach (取決于所使用的測試框架) 中對被替換的原始方法進行還原。

盡量不要自己去 swizzling,推薦使用那些現(xiàn)成的、專注于解決與你要處理的問題類似的環(huán)境上下文的庫。比如:

抽取和重寫調(diào)用

鑒于抽取和重寫調(diào)用的方法使用簡單,效果強大,你可能會采取能用則用的態(tài)度。但是由于這種方式需要配備特定的測試子類,這樣就會相應(yīng)的增加了測試的脆弱性。

因為可以避免對依賴對象的調(diào)用點進行修改,通常來說,這種方式對有點年頭的代碼非常有效。

FAQ

“該用哪種 DI 框架?”

我對那些剛開始使用 mock 對象的朋友們的建議是應(yīng)盡量避免使用 mock 框架,這樣你會對各個步驟和細節(jié)有更好的理解。同樣地,我建議那些剛開始使用 DI 的朋友也不要使用任何 DI 框架。在不依靠 DI 框架的情況下,對 DI 的理解會更純粹,會更明白自己該做什么,怎么做。

事實上,很可能不知不覺中你已經(jīng)在使用 DI 框架了!它就是Interface Builder。IB其實不僅僅是UI設(shè)計器,任何屬性都可以通過將其聲明為 IBOutlets 來賦值。當通過 IB 創(chuàng)建的 View 初始化的時候,可以一并創(chuàng)建通過 IB 聲明的 Object (因為利用 IB 不僅可以創(chuàng)建 UI 對象還可以創(chuàng)建 “Object” (NSObject 類型,代表 icon 是個純黃色立方體),那么當 IB 文件初始化的時候,IB 上定義的對象也會隨之初始化)。2009年,Eric Smith 在其文章 “Dependency Inversion Principle and iPhone” 中將 Interface Builder 稱為是自己 “一直以來最偏愛的 DI 框架”,文中同時給出了如何用 Interface Builder 做依賴注入的例子。

如果你覺得 Interface Builder 還不足以應(yīng)付 DI 工作,還需要使用其他 DI 框架,怎么選擇才合適呢?我的建議是:慎選那些需要改變你自己的代碼才能使用的框架。比如說繼承某個東西,實現(xiàn)某個接口,或者添加什么注解等等,這些都會將你的代碼和某種特定實現(xiàn)捆綁在一起 (這與 DI 的根本設(shè)計哲學(xué)相悖)。反過來,應(yīng)盡量去用那些不需要浸染你的類即可以從外部連接的框架,至于說是 DSL 方式還是代碼方式都無所謂。

“不想公開全部的 Hooks”

對于以上幾種公開注入點的注入方式,比如說初始化注入,屬性注入,以及方法參數(shù)注入等都會讓人有一種破壞程序封裝性的感覺。我們總有一種想要掩蓋依賴注入銜接點的想法,這是可以理解的,因為我們知道這些銜接點是專門為單元測試準備的,它們并不屬于 API 業(yè)務(wù)范疇。所以可以將它們聲明在 category 中,并且放到一個單獨的頭文件里。以上面的 Example.h 為例,再添加一個單獨的頭文件 ExampleInternal.h。這個頭文件只會被 Example.m 和相應(yīng)的測試代碼引用。

在采納這個方法去實踐之前,我還要討論一下關(guān)于 DI 會導(dǎo)致破壞程序封裝原則的這個問題。其實 DI 的目標就是讓依賴更加明顯。我們界定了組件的邊界和它們之間的組裝方式。舉例來說,如果一個類的某個初始化方法中含有一個類型為 id<foo> 的參數(shù),也就是說需要提供一個滿足 Foo 接口的對象方可初始化這個類。好比說如果你在某個類中定義了一組插座,同時也要為其匹配相應(yīng)的插頭。

如果覺得公開依賴會很繁冗,先看看是否符合以下的場景:

  • 公開對 Apple 對象的依賴是不是不太好?Apple 提供的東西不就等于暗示是可用的嗎,對于其他的代碼是不是也應(yīng)同等對待?其實不然!還是拿我們的 NSUserDefaults 例子來說:假如說基于某種原因,你決定避免使用 NSUserDefaults 會怎么樣?若將其作為顯式依賴對象而不是某個內(nèi)部的實現(xiàn)細節(jié),你是否會覺得這是一個需要檢查整個組件的信號?你可以想想使用 NSUserDefaults 是否真的違反了你在設(shè)計上的約束。

  • 是不是覺得為了達到測試目標不得已公開了許多不應(yīng)公開的內(nèi)部實現(xiàn)?首先,需要判斷一下基于你的代碼中現(xiàn)行所公開的 API 是否能夠支撐完成測試代碼編寫 (快速且確切)。如果不可以,并且你需要去操作那些本來是隱式的依賴對象,這說明很可能有其他的類也需要操作它。所以應(yīng)該大膽將其抽象,把它當作依賴對象使用并進行單獨測試。

DI 不僅僅是測試

我最初決定鉆研DI是因為在執(zhí)行測試驅(qū)動開發(fā) (TDD),而在 TDD 的過程中有一個很糾結(jié)的問題會時常跳出來:“對于這個實現(xiàn),如何編寫單元測試?”。后來我發(fā)現(xiàn)其實 DI 本身是在彰顯一個更高層面的概念:代碼組成了模塊,模塊拼接構(gòu)建成了應(yīng)用本身。

使用這種方法有很多益處。Graham Lee 在文章 "Dependency Injection, iOS and You" 中這樣描述:“適應(yīng)新需求,解決bug,增加新功能,單獨測試組件?!?/p>

所以當我們在編寫單元測試的時候用到 DI,應(yīng)該回想一下上文所提到的更高層面的概念。請將可插拔模塊牢記吧。它會影響你的很多設(shè)計決策,并且引導(dǎo)你去理解更多的 DI 模式和原則。

上一篇:IP,TCP 和 HTTP下一篇:Fetch 請求