如何在维护一个旧的 codebase 的时候,以比较低的侵入性加入新的 feature

一个 Objective-C 和 Swift 混编的旧项目里有多种 WKWebView 页面,没有共同的 WebView 基类。这篇讲怎么用 protocol + extension 加入 PDF 下载按钮,每个页面只多一行调用,以及这个方案的不足。

文章目录
  1. 最直接的两个选项
  2. Protocol + extension
  3. 为什么这对老代码几乎没有侵入
  4. 为什么容易扩展
  5. 混编项目里的坑
  6. 为什么不用一个 helper 对象?
  7. 我放弃了什么,以及哪里不够好

先看一个问题,再往下读。

你在一个旧的 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 加到老页面上。