在不断发展的代码基础上工作时,最大的挑战之一是保持良好的封装。 随着新特性的添加,对象通常会有新的职责,或者需要以它们最初设计时没有考虑到的方式与其他对象一起工作。在不泄漏抽象的情况下添加这种新功能可能非常棘手。
在很多方面,这都归结到API设计上。为我们的类型清晰地定义api可以真正帮助我们封装代码,并避免与其他类型共享不必要的实现细节。本周,我们来看看一些技巧,让我们在不同的情况下做到这一点。
Hiding implementation details
那么,为什么对其他类型隐藏实现细节如此重要呢? 让我们看一个示例,在该示例中,我们正在构建一个ProfileViewController,用于显示当前登录的用户的配置文件。它有一个头视图,在viewDidLoad中设置它的代理为self,像这样:
class ProfileViewController: UIViewController, ProfileHeaderViewDelegate {
lazy var headerView = ProfileHeaderView()
override func viewDidLoad() {
super.viewDidLoad()
headerView.delegate = self
view.addSubview(headerView)
}
}
上面的内容可能看起来非常直接,但是我们最终还是公开了实现细节。由于headerView属性不是私有的(或filprivate),它应该被认为是ProfileViewController的API的一部分。 虽然这看起来像是一个挑剔的细节,但它实际上会增加引入bug的风险。
假设我们在我们的应用中引入了一个新功能,让我们的用户通过执行应用内购买来解锁一个付费模式。 当用户这样做时,我们想要自定义profile头视图,使其看起来更花哨一点(作为一个小的感谢),所以我们以这样的东西结束:
func userDidUnlockPremiumSubscription() {
profileViewController.headerView = PremiumHeaderView()
}
问题是通过运行上面的函数,我们的概要视图控制器和它的头视图之间的委托关系就丢失了(因为我们完全替换了实例)。这很可能会导致漏洞和无响应的UI,而且由于它是由外部条件触发的,我们可能不会总是测试(在这种情况下是应用程序内购买)-风险是这些漏洞一段时间内未被发现
为了隐藏这个实现细节并防止此类bug的发生,让我们将headerView属性设为私有
class ProfileViewController: UIViewController, ProfileHeaderViewDelegate {
private lazy var headerView = ProfileHeaderView()
}
这是很好的第一步,但是我们还需要提供某种形式的API,允许其他类型为我们刚刚介绍的premium模式定制ProfileViewController
我们要做的不是公开头视图本身,而是简单地添加一个专用的API,让我们定制profile视图控制器当前应该处于什么模式,就像这样
extension ProfileViewController {
enum Mode {
case standard
case premium
}
func enterMode(_ mode: Mode) {
switch mode {
case .standard:
headerView.applyStandardAppearance()
case .premium:
headerView.applyPremiumAppearance()
}
}
}
上面值得注意的是,我们没有向头部视图公开Mode类型。相反,我们实现了不同的方法来对它应用不同的外观(这反过来会调整颜色、图像等内容)。这样我们就不会在视图控制器和它的头部视图之间创建强耦合。
我们在响应应用内部购买时运行的代码现在看起来像这样:
func userDidUnlockPremiumSubscription() {
profileViewController.enterMode(.premium)
}
通过执行这些更改,我们现在封装了ProfileHeaderView(这减少了它被“错误的方式”使用的风险),并且添加了一个显式的API,随着我们的应用不断发展,我们可以更容易地维护和扩展👍。
Protocols and private types
封装代码的另一种好方法是将协议与私有实现结合使用。比如我们使用协议只允许特定类型的访问就像在" Swift协议分离关注"中写到数据库, 让我们看看如何使用协议向外界展示一个简单的API,同时隐藏其复杂性。
假设我们正在构建一个需要在各种视图控制器中加载很多图像的应用。 首先,我们创建了一个协议,它定义了ImageLoader的API,每个视图控制器都可以使用这个API:
protocol ImageLoader {
typealias Handler = (Result<UIImage>) -> Void
func loadImage(from url: URL, then handler: @escaping Handler)
}
因为我们会有很多不同的视图控制器都需要加载图像,我们希望它们每个都使用自己的图像加载器实例。 这将允许我们执行优化,比如在视图控制器被释放时取消未完成的请求。
为了更好地处理这个问题,让我们使用工厂模式,并构建一个ImageLoaderFactory,我们可以用它来为每个视图控制器创建单独的实例。 这里的技巧是,我们不会透露工厂返回的具体图像加载器类型——相反,它只返回符合ImageLoader的任何类型,同时在底层创建一个SessionImageLoader的实例,就像这样
class ImageLoaderFactory {
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func makeImageLoader() -> ImageLoader {
return SessionImageLoader(session: session)
}
}
因为我们没有在API中包含任何具体的类型,我们可以自由地保持SessionImageLoader的整个实现为私有的:
private extension ImageLoaderFactory {
class SessionImageLoader: ImageLoader {
let session: URLSession
private var ongoingRequests = Set<Request>()
init(session: URLSession) {
self.session = session
}
deinit {
cancelAllRequests()
}
func loadImage(from url: URL,
then handler: @escaping (Result<UIImage>) -> Void) {
let request = Request(url: url, handler: handler)
perform(request)
}
}
}
做上述事情的好处是,它为我们提供了很大的灵活性,同时也保持了API的简单性。我们的应用程序所知道的就是它能够加载图像——我们消除了开始过度依赖基于urlsession的实现的风险-我们甚至可以在开发期间用mock替换SessionImageLoader(如果我们还没有启动并运行服务器,这是非常有用的),而不需要改变应用程序的任何其他部分
Third party dependencies
同样基于协议的方法对于封装第三方依赖也非常有用。 使用开源框架和第三方sdk的一个危险是,它们很容易开始在整个代码库中蔓延,可能会使升级到新的主要版本或切换到替代解决方案等事情变得非常困难。
就像上面我们使用了一个协议来隐藏我们自己的SessionImageLoader类,让它不被应用程序的其他部分看到一样,如果我们要使用第三方框架来加载图像,我们也可以做同样的事情。 下面是一个例子,如果我们使用一个想象的名为AmazingImages的框架,我们的ImageLoaderFactory会变成什么样:
import AmazingImages
class ImageLoaderFactory {
func makeImageLoader() -> ImageLoader {
return AmazingImageLoader()
}
}
因为我们现在只有一个文件与AmazingImages框架进行交互,所以我们有了一个更灵活的设置,我们不会让我们的代码库“锁定”在一个特定框架的特定版本上。
经常需要审计的一件有趣的事情是,检查代码库中有多少文件导入了正在使用的每个框架。如果这个数字对于任何依赖来说都太大了,那么这通常是一个非常明确的信号,表明某种形式的附加封装是有序的。
Conclusion
不断地封装代码——即使添加了新的特性,引入了新的依赖项,或者需求发生了变化——对于保持良好的架构、健康的灵活性和对象之间清晰的关系来说都是非常重要的。
这并不是说所有的代码都应该被非常紧密地封装(这实际上也会导致非常不灵活和过于复杂的解决方案)。和大多数事情一样,平衡是关键,定期使用重构是保持事物整洁的好方法。