如何在维护一个旧的 codebase 的时候,以比较低的侵入性加入新的 feature
一个 Objective-C 和 Swift 混编的旧项目里有多种 WKWebView 页面,没有共同的 WebView 基类。这篇讲怎么用 protocol + extension 加入 PDF 下载按钮,每个页面只多一行调用,以及这个方案的不足。
先看一个问题,再往下读。
你在一个旧的 iOS 项目里干活,Objective-C 和 Swift 混着写。项目里有好几种 WebView 页面,是不同时期做的,也没有共同的 WebView 基类。现在要加一个 feature:当页面是 PDF 的时候,导航栏上出现一个下载按钮,点一下就把文件下载下来。
你会怎么做?
最直接的两个选项
我遇到这个问题的时候,项目里有三条独立的 WebView 继承线。其中两条共用一个通用的 view controller 父类,但那个父类和 WebView 没关系。第三条是一个 Swift 类,直接继承 UIViewController。
最先想到的是两个答案:
- 抽一个 WebView 基类,把所有东西都挪到它下面。 这样 feature 只在一个地方,但为了一个小按钮,你改了老页面的继承关系。这几条线上的每个页面都得重新测。
- 在每个 controller 里各写一遍。 老代码不用动,但同一份下载代码出现在好几个地方,每加一个新页面就多一份拷贝。
我想要的是逻辑只有一份,同时对已有的类几乎不改。
Protocol + extension
我选的方案是带默认实现的 protocol。protocol 规定一个页面必须提供什么,extension 里放 feature 本身。
@objc protocol PDFDownloadable where Self: UIViewController {
@objc func showDownloadButton(url: URL)
func handleDownloadStart()
func handleDownloadSuccess()
func handleDownloadFailure(_ error: NSError)
}
extension URL {
var isPDF: Bool { pathExtension == "pdf" }
}
@MainActor
extension PDFDownloadable {
func addDownloadButton(url: URL) {
guard url.isPDF else {
navigationItem.rightBarButtonItem = nil
return
}
let button = UIButton(frame: CGRect(x: 0, y: 0, width: 24, height: 24))
button.setImage(UIImage(systemName: "square.and.arrow.down"), for: .normal)
button.addAction(UIAction { [weak self] _ in
self?.downloadAndShare(url)
}, for: .touchUpInside)
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: button)
}
private func downloadAndShare(_ url: URL) {
Task {
do {
handleDownloadStart()
let file = try await downloadPDF(from: url)
handleDownloadSuccess()
present(UIActivityViewController(activityItems: [file], applicationActivities: nil), animated: true)
} catch {
handleDownloadFailure(error as NSError)
}
}
}
private func downloadPDF(from url: URL) async throws -> URL {
let destination = FileManager.default.temporaryDirectory
.appendingPathComponent(url.lastPathComponent)
return try await withCheckedThrowingContinuation { continuation in
URLSession.shared.downloadTask(with: url) { tempURL, _, error in
do {
if let error { throw error }
guard let tempURL else { throw URLError(.badServerResponse) }
// The system deletes tempURL once this closure returns.
try? FileManager.default.removeItem(at: destination)
try FileManager.default.copyItem(at: tempURL, to: destination)
continuation.resume(returning: destination)
} catch {
continuation.resume(throwing: error)
}
}.resume()
}
}
}
按钮、下载和分享面板都在一个地方,而且这段代码不知道自己跑在哪个页面上。
为什么这对老代码几乎没有侵入
让一个类遵守 protocol,不需要改类本身。单独放一个 Swift 文件,写成已有 controller 的 extension:
extension LegacyWebViewController: PDFDownloadable {
@objc func showDownloadButton(url: URL) {
addDownloadButton(url: url)
}
func handleDownloadStart() { showLoadingView() }
func handleDownloadSuccess() { hideLoadingView() }
func handleDownloadFailure(_ error: NSError) {
hideLoadingView()
reportError(error)
}
}
对原类的唯一改动,是在它的 Objective-C 导航回调里加一行:
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
// ...existing code...
[self showDownloadButtonWithUrl:webView.URL];
}
放在 didFinish 里,是因为 WebView 可能重定向或者跟着链接跳走,最终落在哪个 URL 只有加载结束才知道。
没有改继承,也没有重构任何已有方法。如果这个 feature 要撤掉,每个页面删一行,再删掉那几个 extension 文件就行。
为什么容易扩展
再接一个页面还是同样的两步:一个 extension 文件,加 didFinish 里一行。公共代码不用动。
之所以有三个 handle 方法,是因为每个页面对该发生什么有自己的想法。一个可能用全局的 loading view,另一个用自己的。错误上报也归宿主页面管。公共代码只负责说这些时机到了,不管它们长什么样。
混编项目里的坑
Objective-C 看不见 Swift protocol extension 里的默认实现。所以需要调用它的那个 .m 文件看不到 addDownloadButton(url:)。
这就是 protocol 里要有 showDownloadButton(url:) 的原因。它标了 @objc,每个遵守它的页面自己实现,内容只是转发给默认实现。看起来像样板代码,但它是让 Objective-C 代码够得着 Swift extension 里逻辑的那座桥。
为什么不用一个 helper 对象?
这是个合理的质疑。它才是真正的竞争对手,所以单独讲。不用 protocol,每个页面自己持有一个小的下载 helper,把 loading 和错误的回调以闭包传进去。那些几乎一模一样的 extension 大部分就没了。
代价是每个页面都得持有这个 helper。Swift extension 不能给类加存储属性,所以每个老类又得改一处。而 protocol 不需要宿主存任何状态,改动才能保持在一行。
现在回头看,两者很接近。接两个页面的时候,protocol 的代价是多一点点重复。如果要接的页面多得多,我会先考虑 helper。
我放弃了什么,以及哪里不够好
我确实试过把它抽成一个本地 Swift Package。一天之后又放回主项目的一个文件夹里了,因为团队希望就放在那儿。所以这份代码在设计上是可复用的,但物理上并没有和 app 分开。
有些地方我在 review 里还是会提出来:
- 两个 extension 几乎一样。 我接了两条继承线,它们的 extension 差别很小。第三条线完全没接,所以这并没有覆盖项目里所有的 WebView。
- 下载没有看 HTTP 状态码。
downloadTask不把 404 当成错误,所以服务器的错误页面会被存成一个.pdf并拿出去分享。 isPDF很粗糙。 它只读 URL 的 path extension,而且区分大小写,所以FILE.PDF会被漏掉,URL 没有扩展名但返回 PDF 的情况也会漏掉。- 公共代码依赖主项目。 实际代码里按钮图片来自 app 的 asset catalog,所以这个 feature 想搬出去,得先把这件事处理掉。
URLSession.shared不带 WebView 的 cookie。 登录之后才能拿到的 PDF 还得再做一些工作。
protocol 并没有消除这些问题。它给我的是一种方式:不用先重构,就能把 feature 加到老页面上。