Swift 面向協(xié)議編程遇到的問題

Swift 面向協(xié)議編程

背景

Swift 面向協(xié)議編程在 WWDC 2015 中提出, 業(yè)界已經有很多優(yōu)秀的實踐, 比如 面向協(xié)議編程與 Cocoa 的邂逅. 項目中正在開發(fā)的功能模塊主體依賴 OC 實現的 framework, 但是宿主項目已經遷移到 Swift, 因此不可避免的使用了類 OC 的處理方式, 比如使用單例模式實現模塊的中心化和狀態(tài)管理等. 在使用 Swift 為功能模塊添加單元測試時, 發(fā)現項目中的大量單例實現使單元測試的編寫異常艱難. 因此下文從更容易的為 Swift 編寫單元測試的方向講述.

解決方案

首先我們找了這兩篇 OC 實現的參考: iOS 避免濫用單例, iOS 中的依賴注入. 經過調研之后, 決定對現有項目中已經實現的單例進行面向協(xié)議的方式進行改寫, 然后通過構造器注入的方式將使用到的其他幾個小模塊注入到主功能模塊中.

(1) 分離對象的定義和行為:
class Dog {
    let name: String?
    let birthPlace: String?
    
    func bark() {
        print("barking")
    }
    
    func eat() {
        print("eating")
    }
}

上面的類中, 想要對 Dog 的 bark() 和 eat() 進行測試, Swift 中沒有 OC 中可以驗證方法被調用的宏, 因此要驗證這兩個函數將會很困難. 在面向對象編程方式中, 鼓勵將對象的定義(屬性)和行為(函數)放在一個部分, 一起構成了對象. 在面向協(xié)議的編程方式中, 可以將對象的行為定義為一個抽象接口, 將對象的定義和行為分離, 這樣在編寫測試時可以只測試對象的行為.

protocol DogCategory {
    func bark()
    func eat()
}

class Dog {
    let name: String?
    let birthPlace: String?
}

extension Dog: DogCategory {
    func bark() {
        print("barking")
    }
    
    func eat() {
        print("eating")
    }
}

測試時使用一個 mock 對象來實現 DogCategory 協(xié)議方法中的實現即可配合驗證條件進行測試.

(2) 各種依賴注入方式

1 構造器注入

protocol FileManager {
    ...
}

class MessageLoader {
    private let fileManager: FileManager
    private let cache: Cache

    init(fileManager: FileManager = .default,
         cache: Cache = .init()) {
        self.fileManager = fileManager
        self.cache = cache
    }
}

2 屬性注入

class MessageViewController: UIViewController {
    var loader: MessageLoader = MessageLoader.shared()
    var engine = NetworkEngine()
}

3 方法注入

class MessageManager {
    func loadMessages(matching query: String,
                   completionHandler: @escaping ([Message]) -> Void) {
        DispatchQueue.global(qos: .userInitiated).async {
            let database = self.loadDatabase()
            let messages = database.filter { message in
                return message.matches(query: query)
            }
            completionHandler(messages)
        }
    }
}

(3) 使用工廠模式改造

抽離出來的協(xié)議越來越多, Manager 中的構造器越來越多,一些在子模塊的子模塊中使用的協(xié)議, 也得在 Manager 初始化時傳遞進來, 依賴關系有點混亂, 在編寫單元測試時, 構造 mock Protocol 也十分繁瑣.

1 明確什么方法可以定義在協(xié)議中

這一步主要是為了避免在被依賴對象中調用依賴對象的方法, 如果這個方法最終需要調用主模塊的方法, 則會形成依賴循環(huán), 此時的抽取協(xié)議方式應該不是很好的設計. 可以采取的方法有:
(1) 將控制權交還給 Manager, 使用侵入性低的比如通知的方式讓 Manager 執(zhí)行特定邏輯;
(2) 重新設計, 將這部分放在擴展中實現可能會更好;

2 明確模塊或者協(xié)議之間的依賴關系

在 OC 中, 如果多個模塊共同依賴了一個模塊, 最可能的做法便是將這個共同模塊改寫成單例, 在其他模塊中直接調用. 但是在 Swift 中, 應該盡量避免單例設計.
舉例:

class MessageListViewController: UITableViewController {
    private let loader: MessageLoader

    init(loader: MessageLoader) {
        self.loader = loader
        super.init(nibName: nil, bundle: nil)
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        loader.load { [weak self] messages in
            self?.reloadTableView(with: messages)
        }
    }
}

// need a message sender
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    // options: get a singleton messageSender
    // let sender = MessageSender.shared
    let message = messages[indexPath.row]
    let viewController = MessageViewController(message: message, sender: sender)
    navigationController?.pushViewController(viewController, animated: true)
}

在點擊 MessageListViewController 時, 需要將 messageSender 傳遞給 MessageViewController. 除了使用單例獲取 MessageSender(盡量避免單例設計), 在 MessageListViewController 中使用構造器傳入...

// if just use MessageListViewController for showing, we don't need know sender and others
class MessageListViewController: UITableViewController {
    init(loader: MessageLoader, sender: MessageSender, logger: MessageLogger, ...) {
        ...
    }
}

如果能將這些依賴都歸置在一個地方管理, 讓 Manager 只和一個人(協(xié)議)打交道,只關心對象的使用, 不關心對象是如何生產的, 則模塊之間的依賴將更易懂, 測試將會更加容易.

(1) 將所有依賴挪到一個 Container 中, 讓 Manager 只依賴 Container.

class DependencyContainer {
    private lazy var messageSender = MessageSender(networkManager: networkManager)
    private lazy var messageLoader = MessageLoader(networkManager: networkManager)
}

class MessageListViewController: UITableViewController {
    
    private let factory: DependencyContainer

    init(factory: DependencyContainer) {
        self.factory = factory
        super.init(nibName: nil, bundle: nil)
    }
}

(2) 定義 ViewControllerFactory, LoaderFactory, SenderFactory 協(xié)議, 并在 Container 中實現.

protocol ViewControllerFactory {
    func makeMessageListViewController() -> MessageListViewController
    func makeMessageViewController(for message: Message) -> MessageViewController
}

protocol MessageLoaderFactory {
    func makeMessageLoader() -> MessageLoader
}

extension DependencyContainer: ViewControllerFactory {
    func makeMessageListViewController() -> MessageListViewController {
        return MessageListViewController(factory: self)
    }

    func makeMessageViewController(for message: Message) -> MessageViewController {
        return MessageViewController(message: message, sender: messageSender)
    }
}

extension DependencyContainer: MessageLoaderFactory {
    func makeMessageLoader() -> MessageLoader {
        return MessageLoader(networkManager: networkManager)
    }
}

(3) 使用方法

func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {

    // creating MessageListViewController
    let container = DependencyContainer()
    let listViewController = container.makeMessageListViewController()

    window.rootViewController = UINavigationController(
        rootViewController: listViewController
    )

    return true
}

class MessageListViewController: UITableViewController {
    
    private let container: DependencyContainer
    private lazy var loader = container.makeMessageListViewController()

    init(container: DependencyContainer) {
        self.container = container
        super.init(nibName: nil, bundle: nil)
    }
}

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let message = messages[indexPath.row]
    let viewController = self.container.makeMessageViewController(for: message)
    navigationController?.pushViewController(viewController, animated: true)
}

這里需要使用工廠的對象會擁有對 Container 的引用關系, 不需要使用單例方式優(yōu)化.

總結

回到我們遇到的問題:
(1) 單例設計的好處與壞處
好處: 狀態(tài)共享, 一處定義多處使用
壞處: 披著羊皮的全局變量, 生命周期難以管理
解決方法:
避免單例的設計, 單例對象的的生命周期管理:

// a weak singleton in OC
+ (id)sharedInstance {
    static __weak ASingletonClass *instance;
    ASingletonClass *strongInstance = instance;
    @synchronized(self) {
        if (strongInstance == nil) {
            strongInstance = [[[self class] alloc] init];
            instance = strongInstance;
        }
    }
    return strongInstance;
}

// a weak singleton in Swift
class SharedResource {  
  static weak var weakInstance: Resource?
  static var sharedInstance: Resource {
    get {
      if let instance = weakInstance {
        return instance
      } else {
        let newInstance = Resource()
        weakInstance = newInstance
        return newInstance
      }
    }
  }
}

(2) Swift 中怎么對使用單例設計的對象進行測試
1. 將單例對象行為抽象出一個協(xié)議
2. 在使用的地方使用這個協(xié)議替換原來的單例對象
3. 在測試中, 使用 mock 對象遵守抽象出來的協(xié)議

(3) Swift 中依賴注入的一個最佳實踐
如果被依賴的對象過多, 依賴關系不清晰, 可以嘗試使用工廠模式隔離生產者和消費者.

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末论寨,一起剝皮案震驚了整個濱河市前酿,隨后出現的幾起案子憔恳,更是在濱河造成了極大的恐慌坑匠,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,490評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件救斑,死亡現場離奇詭異稍走,居然都是意外死亡原探,警方通過查閱死者的電腦和手機,發(fā)現死者居然都...
    沈念sama閱讀 93,581評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人蹋半,你說我怎么就攤上這事杉辙。” “怎么了挟冠?”我有些...
    開封第一講書人閱讀 165,830評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我德迹,道長,這世上最難降的妖魔是什么揭芍? 我笑而不...
    開封第一講書人閱讀 58,957評論 1 295
  • 正文 為了忘掉前任胳搞,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘肌毅。我一直安慰自己币厕,他們只是感情好,可當我...
    茶點故事閱讀 67,974評論 6 393
  • 文/花漫 我一把揭開白布芽腾。 她就那樣靜靜地躺著旦装,像睡著了一般。 火紅的嫁衣襯著肌膚如雪摊滔。 梳的紋絲不亂的頭發(fā)上阴绢,一...
    開封第一講書人閱讀 51,754評論 1 307
  • 那天,我揣著相機與錄音艰躺,去河邊找鬼呻袭。 笑死,一個胖子當著我的面吹牛腺兴,可吹牛的內容都是我干的左电。 我是一名探鬼主播,決...
    沈念sama閱讀 40,464評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼页响,長吁一口氣:“原來是場噩夢啊……” “哼篓足!你這毒婦竟也來了?” 一聲冷哼從身側響起闰蚕,我...
    開封第一講書人閱讀 39,357評論 0 276
  • 序言:老撾萬榮一對情侶失蹤栈拖,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后没陡,有當地人在樹林里發(fā)現了一具尸體涩哟,經...
    沈念sama閱讀 45,847評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,995評論 3 338
  • 正文 我和宋清朗相戀三年盼玄,在試婚紗的時候發(fā)現自己被綠了贴彼。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,137評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡埃儿,死狀恐怖器仗,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情蝌箍,我是刑警寧澤青灼,帶...
    沈念sama閱讀 35,819評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站妓盲,受9級特大地震影響杂拨,放射性物質發(fā)生泄漏。R本人自食惡果不足惜悯衬,卻給世界環(huán)境...
    茶點故事閱讀 41,482評論 3 331
  • 文/蒙蒙 一弹沽、第九天 我趴在偏房一處隱蔽的房頂上張望檀夹。 院中可真熱鬧,春花似錦策橘、人聲如沸炸渡。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,023評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽蚌堵。三九已至,卻和暖如春沛婴,著一層夾襖步出監(jiān)牢的瞬間吼畏,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,149評論 1 272
  • 我被黑心中介騙來泰國打工嘁灯, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留泻蚊,地道東北人。 一個月前我還...
    沈念sama閱讀 48,409評論 3 373
  • 正文 我出身青樓丑婿,卻偏偏與公主長得像性雄,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子羹奉,可洞房花燭夜當晚...
    茶點故事閱讀 45,086評論 2 355