MENU

Swift 中单例模式的替换

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)
    }
}

结语

单例模式并不是毫无可取之处,例如在日志服务、外设管理等场景下还是非常适用的。但是大多数时候单例模式由于依赖关系不明确以及全局共享可变状态可能会增加系统的复杂度造成一系列未知问题。如果你当前的代码中使用了大量的单例模式的话,我希望本文能够帮你从中解脱出来构建一个更健壮的系统。