Swift 中單例模式的替換

BigNerdCoding發表於2019-02-20

Cover
Cover

除了 MVC、MVVM 之外,單例模式可以說是 iOS 開發中另一常見的設計模式。無論是 UIKit 或是一些流行的三方庫,我們都能看到單例的身影。而我們開發者本身也會潛意識地將這些類庫中的程式碼當作最佳實踐並將其帶入日常工作中,哪怕很多人都知道單例存在一些明顯的缺陷。

針對單例的缺陷,本文將介紹一些替換或改造單例模式的方法來提升程式碼質量。

單例的優點

除了上面提到的模仿最佳實踐之外,單例的流行肯定也有內在的原因和理由。例如:單例物件保證了只有一個例項的存在,這樣有利於我們協調系統整體的行為。比如在某個伺服器程式中,該伺服器的配置資訊存放在一個檔案中,這些配置資料由一個單例物件統一讀取,然後服務程式中的其他物件再通過這個單例物件獲取這些配置資訊。這種方式簡化了在複雜環境下的配置管理。 另一方面,全域性單一物件也減少了不必要的物件建立和銷燬動作提高了效率。下面是一個典型的單例模式程式碼:

class UserManager {
    static let shared = UserManager()

    private init() {
        // 單例模式,防止出現多個例項
    }

    ....
}

extension UserManager {
    func logOut( ) {
        ...
    }

    func logIn( ) {
        ...
    }
}

class ProfileViewController: UIViewController {
    private lazy var nameLabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        nameLabel.text = UserManager.shared.currentUser?.name
    }

    private func handleLogOutButtonTap() {
        UserManager.shared.logOut()
    }
}複製程式碼

單例的缺陷

雖然上面提到了單例的一些優點,但是這不能掩蓋單例模式一些明顯的缺陷:

  1. 全域性共享可修改的狀態:單例模式的副作用之一就是那些共享狀態量在 app 的生命週期內都可能發生修改,而這些修改可能造成一些位置錯誤。更為糟糕的是因為作用域和生命週期的特性,這些問題還非常難定位。
  2. 依賴關係不明確:因為單例在全域性都非常容易進行訪問,這將是我們的程式碼變成所謂的 義大利麵條 式的程式碼。單例與使用者的關係界限不明確,後期維護也非常麻煩。
  3. 難以追蹤測試:因為單例模式與 app 擁有同樣的生命週期而生命週期內進行的任意修改,所以無法確保一個乾淨的例項用於測試。
  4. 由於單利模式中沒有抽象層,因此單例類的擴充套件有很大的困難。
  5. 單例類的職責過重,在一定程度上違背了“單一職責原則”。

依賴注入

與之間之間使用單例物件不同,這裡我們可以在初始化是進行依賴注入。

class ProfileViewController: UIViewController {
    private let user: User
    private let logOutService: LogOutService
    private lazy var nameLabel = UILabel()

    init(user: User, logOutService: LogOutService) {
        self.user = user
        self.logOutService = logOutService
        super.init(nibName: nil, bundle: nil)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        nameLabel.text = user.name
    }

    private func handleLogOutButtonTap() {
        logOutService.logOut()
    }
}

class LogOutService {
    private let user: User
    private let networkService: NetworkService
    private let navigationService: NavigationService

    init(user: User,
         networkService: NetworkService,
         navigationService: NavigationService) {
        self.user = user
        self.networkService = networkService
        self.navigationService = navigationService
    }

    func logOut() {
        networkService.request(.logout(user)) { [weak self] in
            self?.navigationService.showLoginScreen()
        }
    }
}複製程式碼

上面程式碼中的依賴關係明顯比之前更為清晰,而且也更方便後期維護和編寫測試例項。另外,通過 LogOutService 物件我們將某些特定服務抽離了出來,避免了單例中常見的臃腫狀態。

協議化改造

將一個單例濫用的應用一次性全面改寫為上面那樣的依賴注入和服務化顯然是一件非常耗時且不合理的事情。所以下面將會介紹通過協議對單例進行逐步改造的方法,這裡主要的做法就是將上面 LogOutService 提供的服務改寫為協議:

protocol LogOutService {
    func logOut()
}

protocol NetworkService {
    func request(_ endpoint: Endpoint, completionHandler: @escaping () -> Void)
}

protocol NavigationService {
    func showLoginScreen()
    func showProfile(for user: User)
    ...
}複製程式碼

定義好協議服務之後,我們讓原有的單例遵循該協議。此時我們可以在不修改原有程式碼實現的同時將單例物件當作服務進行依賴注入。

extension UserManager: LoginService, LogOutService {}

extension AppDelegate: NavigationService {
    func showLoginScreen() {
        navigationController.viewControllers = [
            LoginViewController(
                loginService: UserManager.shared,
                navigationService: self
            )
        ]
    }

    func showProfile(for user: User) {
        let viewController = ProfileViewController(
            user: user,
            logOutService: UserManager.shared
        )

        navigationController.pushViewController(viewController, animated: true)
    }
}複製程式碼

結語

單例模式並不是毫無可取之處,例如在日誌服務、外設管理等場景下還是非常適用的。但是大多數時候單例模式由於依賴關係不明確以及全域性共享可變狀態可能會增加系統的複雜度造成一系列未知問題。如果你當前的程式碼中使用了大量的單例模式的話,我希望本文能夠幫你從中解脫出來構建一個更健壯的系統。

原文地址

相關文章