WebKit WebPage API 的引入尝试与自研实现

3 阅读5分钟

背景

现有架构的问题

工作中的 iOS 应用内浏览器一直使用 WKWebView 直接实现,但存在架构层面的担忧:

  • 基础设施职责load(_:)goBack() 等页面操作
  • UI 职责:作为 UIView 在屏幕上显示

这两种职责都集中在 WKWebView 这一个类型中。

工作中采用 UI / 表现层 / 业务逻辑 / 基础设施 四层架构,强调各层之间不应直接依赖具体实现。直接使用 WKWebView 会导致 UI 显示和网页操作都通过同一个类型完成,与架构理念不符。

WebPage API 的出现

WebKit 在 iOS 26 推出了新的 Swift API WebPage,设计理念与 WKWebView 截然不同:

职责承担者
Web 内容状态管理WebPage
导航控制WebPage
JavaScript 执行WebPage
UI 显示WebView(SwiftUI View)

WebPage 遵循 @Observable,在 SwiftUI 中可以自然地订阅状态变化:

@State var webPage = WebPage()

var body: some View {
    WebView(webPage)
        .toolbar {
            Button("Back") {
                webPage.goBack()
            }
            .disabled(!webPage.canGoBack)
        }
}

这种设计有效解决了之前的架构担忧。


为何无法直接引入 WebPage

尽管 WebPage 设计理想,但由于以下原因无法直接在生产环境使用:

1. 操作系统版本限制

flowchart LR
    subgraph 版本对比
        WP[WebPage API<br/>iOS 26+]
        APP[应用支持<br/>iOS 18+]
    end
    WP -->|不兼容| APP

WebPage 需要 iOS 26,而应用目前仍支持 iOS 18,无法直接在生产代码中使用。

2. 与现有 UIKit 实现的兼容性

WebPage 内部持有 WKWebView,但并未将其作为属性公开,应用无法取出使用。

现有浏览器实现大量依赖 WKNavigationDelegate 和 KVO,从质量保证角度,无法一次性全部替换。需要在继续使用 WKWebView 的同时逐步迁移,这成为直接引入 WebPage 的障碍。

3. 与架构的不一致

flowchart TB
    subgraph 目标架构
        UI[UI 层]
        P[表现层]
        BL[业务逻辑层]
        INF[基础设施层]
    end
    
    UI --> P --> BL --> INF
    
    style UI fill:#e1f5fe
    style INF fill:#f3e5f5

四层架构要求业务逻辑层和表现层不直接依赖 UI 层的具体类型。但如果在业务逻辑层 import WebKitWKWebView 等 UI 相关类型也会变得可用。虽然 WebPage 本身是抽象 API,但最终依赖 WebKit 模块,无法在类型层面强制分层边界。

4. 与测试策略的兼容性

大规模应用需要保持 UI 无关逻辑的可测试性。WebPage 并非为替换和模拟而设计,难以融入现有的依赖注入(DI)测试策略。

5. 无法满足现有功能需求

应用内浏览器有一些特殊需求:

URL 变化检测

  • 需要可靠检测 URL 变化并保存历史记录
  • SwiftUI 的 onChange(依赖 UI 渲染周期)或 Observation Framework(依赖事务边界)可能合并短时间内多次变化
  • 传统 UIKit 使用 KVO 在更低层面检测变化
  • WebPage 目前没有提供同等钩子

window.open 处理

  • 需要拦截 JavaScript 的 window.open(本应新开标签页)并在同一页面内打开
  • 当前 WebPage 没有提供实现此行为的机制

自研实现设计

为满足现有应用需求同时实现职责分离,参考 WebKit 官方 WebPage 的设计理念,设计了自定义 API。

核心抽象层

classDiagram
    class WebPageRepresentable {
        <<protocol>>
        +url: URL?
        +canGoBack: Bool
        +estimatedProgress: Double
        +load(request: URLRequest)
        +reload()
        +goBack()
    }
    
    class WebPage {
        <<class>>
        -backingWebView: WKWebView
        +url: URL?
        +canGoBack: Bool
        +estimatedProgress: Double
    }
    
    class WebPageNavigationHandling {
        <<protocol>>
        +handleNavigationCommit()
    }
    
    class InAppBrowserNavigationHandler {
        -owner: InAppBrowserViewModel?
        +handleNavigationCommit()
    }
    
    WebPageRepresentable <|.. WebPage
    WebPageNavigationHandling <|.. InAppBrowserNavigationHandler
    WebPage --> WKNavigationDelegateAdapter

定义最小化的网页操作接口 WebPageRepresentable

@MainActor
protocol WebPageRepresentable: Observable {
    var url: URL? { get }
    var canGoBack: Bool { get }
    var estimatedProgress: Double { get }
    
    func load(_ request: URLRequest)
    func reload()
    func goBack()
    // ...
}

这种抽象实现了:

  • 支持依赖注入和模拟替换
  • 各层无需直接依赖 WebKit

将 WKWebView 封装在实现内部

在 UI 层定义自定义 WebPage,内部持有 WKWebView

import WebKit

@Observable
@MainActor
final class WebPage: WebPageRepresentable {
    let backingWebView: WKWebView
    
    var url: URL? {
        backingWebView.url
    }
    
    func load(_ request: URLRequest) {
        backingWebView.load(request)
    }
    // ...
}

关键约束:只有 UI 层 import WebKit,WebKit 类型不会泄露到其他层。

KVO 与 Observation 的桥接

参考 WebKit 官方实现,构建了 KVO 与 Observation 的桥接机制:

sequenceDiagram
    participant KVO as WKWebView (KVO)
    participant Bridge as Observation Bridge
    participant Obs as Observation Registrar
    
    KVO->>Bridge: 属性变化通知
    Bridge->>Obs: willSet(keyPath)
    Bridge->>KVO: 更新值
    Bridge->>Obs: didSet(keyPath)
    Obs->>SwiftUI: 触发视图更新
private func createObservation<Value, BackingValue>(
    for keyPath: KeyPath<WebPage, Value>,
    backedBy backingKeyPath: KeyPath<WKWebView, BackingValue>
) -> NSKeyValueObservation {
    return backingWebView.observe(
        backingKeyPath,
        options: [.prior, .old, .new]
    ) { [_$observationRegistrar, unowned self] _, change in
        if change.isPrior {
            _$observationRegistrar.willSet(self, keyPath: keyPath)
        } else {
            _$observationRegistrar.didSet(self, keyPath: keyPath)
        }
    }
}

这样 SwiftUI(或使用 Observation 的层)看到的是普通的 Observable 类型,而实际追踪的是 WKWebView 的状态变化。

另外,为 URL 变化添加了专门的通知逻辑,防止历史记录遗漏。

WebKit 类型的重定义

WKFrameInfo 等 WebKit 类型虽然是数据结构却被定义为 class,导致值语义和引用语义模糊。因此重新定义了只包含必要信息的 struct 类型(如 WebPageFrameInfo):

flowchart LR
    subgraph 类型语义明确化
        WK[WKFrameInfo<br/>class - 语义模糊]
        WP[WebPageFrameInfo<br/>struct - 值语义明确]
    end
    WK -->|重定义| WP

收益:

  • 明确可作为值处理
  • 不会意外引入引用语义
  • 不向层外暴露 WebKit 类型

委托类型的隐藏

参考 WebKit 官方实现,在内部持有委托适配器:

业务逻辑层

@MainActor
protocol WebPageNavigationHandling {
    func handleNavigationCommit()
    // ...
}

UI 层

@MainActor
@Observable
final class WebPage: WebPageRepresentable {
    private let backingNavigationDelegate: WKNavigationDelegateAdapter
    
    init(navigationHandler: some WebPageNavigationHandling) {
        backingNavigationDelegate = WKNavigationDelegateAdapter(navigationHandler)
        backingWebView.navigationDelegate = backingNavigationDelegate
    }
    // ...
}

@MainActor
final class WKNavigationDelegateAdapter: NSObject, WKNavigationDelegate {
    private let navigationHandler: any WebPageNavigationHandling
    
    func webView(_ webView: WKWebView, didCommit navigation: WKNavigation) {
        navigationHandler.handleNavigationCommit()
    }
    // ...
}
flowchart TB
    subgraph 委托隐藏
        UI[UI 层<br/>WebPage]
        Adapter[WKNavigationDelegateAdapter<br/>内部类]
        Handler[WebPageNavigationHandling<br/>协议]
        VM[业务逻辑层<br/>NavigationHandler]
    end
    
    UI --> Adapter --> Handler
    VM ..> Handler
    
    style Adapter fill:#fff3e0

这样隐藏了 NSObject 等功能过剩的类型和 WebKit 特有类型,只向外部公开必要的职责。

事件处理专用类

传统做法常扩展 UIViewControllerUIView 来遵循各种委托,但这容易导致 ViewController 臃肿,导航和安全判断与 UI 紧密耦合。即使改为扩展 ViewModel,也只是 ViewModel 的扩展,职责边界仍然模糊。

因此参考 WebKit 官方实现,创建了专门处理导航相关事件并操作 ViewModel 的类:

@MainActor
final class InAppBrowserNavigationHandler: WebPageNavigationHandling {
    weak var owner: InAppBrowserViewModel?
    
    func handleNavigationCommit() {
        // 操作 owner
    }
}
flowchart LR
    subgraph 职责分离
        VM[InAppBrowserViewModel]
        Handler[InAppBrowserNavigationHandler]
        WebP[WebPage]
    end
    
    Handler -->|持有弱引用| VM
    Handler -->|处理导航事件| WebP
    VM -->|使用| WebP
    
    style Handler fill:#e8f5e9

这样将网页相关事件处理从 ViewModel 中分离,明确了各自的职责。


未来展望

flowchart TB
    subgraph 演进路线
        A[当前: 功能模块内实现]
        B[中期: 独立 Package]
        C[长期: SwiftUI 化]
    end
    
    A --> B --> C
    
    subgraph Package 结构
        P1[UI 模块<br/>public]
        P2[逻辑模块<br/>package 访问级别]
    end
    
    B --> P1
    B --> P2

目前实现封闭在应用内浏览器功能模块内,未来计划:

  1. 提取为独立 Package:使用 package 访问修饰符分离 UI 和非 UI(逻辑)的库结构
  2. 长期 SwiftUI 化:逐步迁移到 SwiftUI 基础实现

总结

WebKit 作为 Apple 官方开源库,罕见地体现了现代设计理念:

  • SwiftUI 优先的设计
  • Observation 支持
  • 积极隐藏 legacy API

即使由于产品限制无法直接采用最新 API,也可以从中提取设计精髓,根据自研上下文重新构建,为未来的迁移打下基础。

flowchart TB
    subgraph 核心收获
        A[WebPage 设计理念]
        B[职责分离架构]
        C[现代 Swift 特性应用]
        D[可测试性保障]
    end
    
    A --> B --> C --> D

通过这种方式,既能满足当前版本兼容性要求,又能为未来向官方 API 迁移做好准备。