iOSのBrowserEngineKitで独自ブラウザアプリを作る方法を調べた
BrowserEngineKitはiOS 17.4以降に含まれる新しいフレームワーク。WebKit以外の独自ブラウザエンジンを使ったアプリを開発できるように追加された
BrowserExample
以下のページでBrowserEngineKit と XPC を使用して、独自のブラウザエンジンを実装するWebブラウザアプリのコードが公開されている
このアプリはヘッダにあるテキストフィールドにURLを入力するとそのサイトのコンテンツを読み込みんでViewに表示する
BrowserKit対応アプリは
- ブラウザエンジンのコアモジュール
- ブラウザエンジンを利用する本体アプリ
- NetworkingExtension
- WebContentExtension
- RenderingExtension
で構成されていて各ExtensionとXPCでメッセージングして連携する
なのでBrowserExampleの実装を順番に読んでBrowserEngineKitの使い方を理解した
※プロジェクトのターゲット一覧
BrowserApp
メインのSwiftUI App部分のソースコード
タブを作成して、そのタブの中にブラウザのViewができるという構成
ここは一般的なアプリ実装なので所見なし
CustomBrowserEngine
BrowserAppとは独立したブラウザエンジンのコアモジュール。各Extensionからリンクされる
アプリに組込みカスタムWebView(UIView)の実装がここにある
サンプルのカスタムWebViewでは単にUITextViewをaddSubview()して画面に表示するという機能を持つ
※実行例。TextViewなので画像やCSSは描画されていない
ポイントとしては*Process
ディレクトリにあるProxyクラスが以降の各Extensionに対応しているという知識があると読みやすい
BrowserProcessPoolでページごとにこの3つのProccessが管理されているのが分かる
public class BrowserProcessPool {
// ...
private(set) var renderingProcess: RenderingProcess? = nil
private(set) var networkProcess: NetworkingProcess? = nil
private(set) var webContentProcesses: [PageID: WebContentProcess] = [:]
// ...
}
WebContentExtension
NetworkingExtensionとRenderingExtensionを仲介するような役割を持つExtension
読み込んだページごとにプロセス化して並列に処理する
ExtensionごとにXPCのメッセージングtypeがTask化されていてそれに応じた処理が実行される
public func launchProcesses(id: PageID) async throws -> WebContentExtensionProxy {
// 1. Launch a new web content process instance.
let contentProcess = try await getOrLaunchContentProcess(pageID: id)
let contentConnection = try contentProcess.makeLibXPCConnection()
let contentProxy = WebContentExtensionProxy(connection: contentConnection)
try contentProxy.applyRestrictedSandbox(version: lockdownVersion)
// 2. Get the shared rendering process.
let renderingProcess = try await getOrLaunchRenderingProcess()
let renderingConnection = try renderingProcess.makeLibXPCConnection()
let renderingProxy = RenderingExtensionProxy(connection: renderingConnection)
let renderingEndpoint = try await renderingProxy.getEndpoint()
try renderingProxy.applyRestrictedSandbox(version: lockdownVersion)
// 3. Get the shared networking process.
let networkProcess = try await getOrLaunchNetworkProcess()
let networkConnection = try networkProcess.makeLibXPCConnection()
let networkProxy = NetworkingExtensionProxy(connection: networkConnection)
let networkEndpoint = try await networkProxy.getEndpoint()
try networkProxy.applyRestrictedSandbox(version: lockdownVersion)
// 4. Perform the bootstrap process.
try await contentProxy.bootstrap(renderingExtension: renderingEndpoint, networkExtension: networkEndpoint)
webContentProcesses[id] = contentProcess
return contentProxy
}
keyword: Anonymous Endpoint
EndpointというのはXPCのエンドポイント。メッセージ送信先
サンプルではBrowserExtensionというExtensionの基盤コードを定義している場所に出てくる
Extension AがAnonymousなEndpointを作ってそこにExtension Bがメッセージを送るという設計にすることでExtension間の依存をなくしている
public func makeAnonymousEndpoint(label: String, handler: @escaping XPCConnectionEventHandler) -> xpc_endpoint_t {
let emptyConnection = xpc_connection_create(nil, nil)
emptyConnection.setEventHandler(label: label, handler)
emptyConnection.activate()
return xpc_endpoint_create(emptyConnection)
}
public func handle(event: xpc_object_t, from connection: xpc_connection_t) {
log.log("handling xpc event: \(String(describing: event))")
guard let rawMessageType = xpc_dictionary_get_string(event, XPCMessageType) else { return }
let messageType = String(cString: rawMessageType)
handleMessage(type: messageType, with: event, from: connection)
}
func handleMessage(type: String, with event: xpc_object_t, from connection: xpc_connection_t) {
switch type {
case BrowserExtensionTask.messageType:
handleBrowserExtensionTask(event, from: connection)
case WebContentExtensionBootstrapCommand.messageType:
handleBootstrapCommand(with: event, from: connection)
case WebContentExtensionTask.messageType:
handleWebContentExtensionTask(event, from: connection)
case GetXPCEndpointMessage.messageType:
let endpoint = makeAnonymousEndpoint(label: "content-ext", handler: handle(event:from:))
sendEndpoint(endpoint, to: connection, replyingTo: event)
default:
log.error("unrecognized message type: \(type)")
}
}
NetworkingExtension
ブラウザアプリのネットワーク処理を実装するExtension。通信結果をXPCで送信することで独立している
サンプルではNetworkSession内でURLSessionを使ってページの内容をダウンロードする
HTTPプロキシなどブラウザ側に持つ機能を作る時に活用することになる
RenderingExtension
ブラウザアプリの描画処理を実装するExtension。通信と同じように、ここでは描画処理の結果をXPCで送信する
ここが一般的に想像する「独自ブラウザアプリ」の役割となる。開発者は自分のレンダリングエンジンを呼び出す
サンプルではHTMLをテキストブラウザとしてパースして装飾付きテキストとして表示する
カスタムWebViewの実装はWebContentViewで、ダウンロードしたHTMLをパースして変換する実装がある
public func load(_ destination: WebViewDestination) async throws {
let result = try await webContentProxy.load(destination: destination, pageID: self.id)
let displayString = parse(result: result)
textView.attributedText = displayString
textView.scrollRangeToVisible(.init(location: 0, length: 0)) // scroll to top
}
private func parse(result: NetworkTaskResult) -> NSAttributedString {
if let data = result.data {
do {
return try NSAttributedString(data: data, options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
], documentAttributes: nil)
} catch let error {
return .init(string: "Failed to parse result: \(String(describing: error))")
}
} else if let error = result.error {
return .init(string: String(describing: error))
} else {
return .init(string: "?")
}
}
Discussion