在 TeamDev,我们开发 JxBrowser——一款将 Chromium 嵌入到 Java 桌面应用中的商业库。多年来,我们已经为经典 Java UI 工具包(如 Swing、JavaFX 和 SWT)实现了 JxBrowser 的 BrowserView 控件。随后,我们决定迈出下一步,将支持范围扩展到桌面端的 Compose Multiplatform。

在本文中,我将带您了解我们是如何为 Compose Desktop 构建 BrowserView 控件的,以及在此过程中解决的技术挑战——涵盖渲染、输入处理、生命周期管理以及原生窗口管理等方面。

在应用中嵌入浏览器 

WebView 是一种 UI 组件,允许应用显示和交互网页内容。可以将其视为嵌入到你的应用中的浏览器,通过代码进行控制。在屏幕上,它看起来像一个包含网页的矩形:

嵌入在桌面应用中的 Web 视图

嵌入在桌面应用中的 Web 视图。

在底层,Web 视图嵌入了一个完整的浏览器引擎。例如,在 Android 上,内置的 WebView 组件使用 Chromium。当您的应用创建 WebView 时,它会连接到 Chromium 引擎,使其能够在应用的生命周期内渲染 HTML、运行 JavaScript 以及处理 Cookie 或网络请求。

Web 视图是实现传统桌面应用现代化的绝佳方式。您可以嵌入使用您喜欢的 JavaScript 前端框架构建的新 UI 部分,并逐步替换旧 UI,最终将整个应用逐步迁移到 Web。

为什么选择 Compose 

Jetpack Compose 在 Android 平台的 Kotlin 开发者中迅速流行起来。随着 Compose Multiplatform 的出现,这种声明式 UI 模型如今也能用于桌面端,为现代 Java 和 Kotlin 应用打开了大门。

我们希望选择一项正在积极发展、并且拥有庞大且不断壮大的社区支持的技术。由于 Compose 在桌面端并不包含内置的 Web 视图,我们看到了填补这一空白的机会。

一种截然不同的 UI 

如果您有传统 Java 开发背景,可能会觉得 Compose 有些陌生。像 Swing、JavaFX 或 SWT 这样的传统 UI 工具包采用的是命令式模型:您需要手动创建按钮、标签、面板等组件,然后编写代码去更新它们。

例如,如果您想在按钮被点击时更新标签,您会编写如下代码:

private int count = 0;
private JLabel label = new JLabel("Clicked 0 times");
private JButton button = new JButton("Click me");
...
button.addActionListener(e -> label.setText("Clicked " + ++count + " times"));

JPanel panel = new JPanel();
panel.add(label);
panel.add(button);

在这种模式中,您需要明确告诉系统做什么以及什么时候做。

而 Compose 走的是另一条路。它最初是为 Android 构建的,采用的是声明式模型,您只需描述在给定状态下 UI 应该呈现的样子:

var count by remember { mutableStateOf(0) }
Column {
    Text("Clicked $count times")
    Button(onClick = { count++ }) {
        Text("Click me")
    }
}

在这里,您无需手动更新标签。您只需声明文本依赖于 count,当 count 发生变化时,Compose 会自动更新 UI。

多年来,我们一直在调整 Chromium 以适应命令式框架。而转向声明式框架则意味着我们需要从根本上重新思考这种集成方式。

在接下来的部分,我将带领您了解我们所构建的内容以及在此过程中的所学所得。

渲染 

当您打开一个网页时,浏览器会将 HTML、CSS 和 JavaScript 转换为屏幕上的像素。Chromium 在一个与您的应用程序完全隔离的独立进程中完成此操作。而我们的任务是获取这些像素,并将它们呈现在 UI 中。

这正是难点所在——网页内容的渲染本身就非常耗费资源,更何况它完全发生在 Compose 的渲染管线之外。

为解决这一问题,我们采用了两种方案:像素拷贝(Pixel copying)渲染到原生表面(Native surface)

像素复制 

在这种方法中,Chromium 渲染页面,并将生成的像素复制到 Java 内存中的缓冲区。我们可以从该缓冲区读取像素,创建图像并在 Compose 组件中显示。

val image = mutableStateOf<Image?>(null)
...
private fun handlePaintRequest(request: PaintRequest) {
    val newImage = Image.makeRaster(bytes = request.pixels, ...)
    scope.launch {
        // 释放上一帧图像防止内存泄漏。
        image.value?.close()
        image.value = newImage
    }
}

当图像准备好后,我们使用 Canvas 组合项进行绘制:

Canvas(modifier = Modifier.fillMaxSize()) {
    drawIntoCanvas {
        it.nativeCanvas.drawImage(image.value, left = 0f, top = 0f)
    }
}

每当 Chromium 发送新帧时,图像状态就会更新。由于我们监听着这个状态,Compose 会自动使用最新的浏览器内容重绘 UI。

原生表面 

在这种方式中,Chromium 会将像素直接渲染到一个原生系统窗口中,然后我们将这个窗口嵌入到 Compose 组件里。您可以把它想象成:在 UI 的空白区域放置了一个实时的浏览器窗口,它会随着应用的其他部分一起移动、调整大小和最小化。

应用顶部的原生窗口

应用顶部的原生窗口。

JxBrowser 在每个平台上以不同的方式实现此方法。在 Windows 和 Linux 上,我们将 Chromium 窗口直接嵌入到应用的窗口中。在 macOS 上,由于不允许跨进程窗口嵌入,我们使用 CALayer 共享方法。我们的想法是,Chromium 在其 GPU 进程中渲染成一个层,然后我们使用原生 macOS API 与应用进程共享该层并进行显示。

性能 

在选择 Pixel copying(像素拷贝) 和 Native surface rendering(原生表面渲染) 之间时,性能是一个关键因素。下面我们简要比较一下这两种方法。

像素复制 

像素复制的性能开销较高。每一帧数据都需要从 Chromium 传输、转换为图像格式,并在 Canvas 中绘制。这一过程会占用 CPU 和内存资源,且分辨率与帧率越高,性能损耗越显著。

跨平台像素复制的性能表现

跨平台像素复制的性能表现。

通常,这种方法可以通过脏矩形(dirty rectangles) 优化。当只有部分渲染内容发生变化时,我们可以只重绘那个区域。但 Compose 并不提供这种精细化的控制。Canvas 组合项会在每一帧重绘整个区域,即使只有几个像素发生了变化。

原生表面 

在这里,渲染性能几乎与 Chromium 或 Chrome 本身相同(4K 60FPS),因为 Chromium 直接在嵌入组件的原生窗口上渲染像素。

如何选择? 

这本质上取决于一个关键的权衡:原生窗口与 Compose 组件的兼容性较差。您无法在网页视图上方绘制 Compose UI,因为原生窗口总会处于顶层。这是因为原生窗口属于 “重量级” 元素,由操作系统单独渲染;而 Compose 组件是 “轻量级” 的,会被绘制到单个图层中。

唯一的解决方法,是将提示框(tooltip)或弹窗(popup)等覆盖元素移动到单独的 Compose 窗口中,并将该窗口置于 Web 视图之上。例如,要显示浏览器的模态对话框,您可以使用标准的 DialogWindow 组合项,它会在顶部打开一个新窗口。

像素拷贝(Pixel copying) 则没有这一限制。因为所有内容都绘制在同一 Compose 层中,您可以自由地在浏览器内容上方放置 Compose UI。

总结如下:

  • 像素拷贝:提供完全的灵活性,但会占用更多的 CPU 和内存。
  • 原生表面:提供性能更佳,但叠加 Compose UI 的能力有限。

您可以选择最适合您应用的方案,或在需要时切换。

调整浏览器大小 

当您在 Google Chrome 中调整窗口大小时,操作系统会向 Chrome 发送一个通知,提示它根据新的尺寸重新布局并重绘内容。但是,当 Chromium 被嵌入到其他应用中时,它并不会自动收到这个消息。

因此,我们需要手动处理:从 Compose 中读取新的尺寸,告诉 Chromium 调整浏览器大小,并让它开始以新的尺寸渲染画面。

通过 onGloballyPositioned,我们可以获取某个 Composable 的尺寸和位置,然后将这些数值传递给 Chromium,告知窗口已调整大小,需要开始渲染更大或更小的画面。

val density = LocalDensity.current
Box(
    modifier = Modifier
        .fillMaxSize()
        .onGloballyPositioned { coords ->
            chromium.updateBounds(
                positionInWindow = coords.scaledPositionInWindow(density),
                size = coords.scaledSize(density)
            )
        }
)

请注意,这个修饰符提供的是原始像素坐标,但 Chromium 期望接收到经过显示器缩放调整后的坐标。因此,在将 Compose 的数值传递给 Chromium 之前,我们需要对它们进行适当的缩放转换。

输入同步 

在桌面端,浏览器会处理鼠标、键盘和触摸等输入。当 Chromium 以常规浏览器运行或渲染到原生表面(Native surface) 时,操作系统会把输入事件直接发送给它。但在 像素复制方案下,这些事件会先被 Compose 拦截,Chromium 不会自动接收到。

为确保 Chromium 能对用户交互做出响应,您需要:

  1. 在 Compose 中捕获输入事件;
  2. 将这些事件转换为 Chromium 能理解的格式;
  3. 把转换后的事件转发给 Chromium。

随后,Chromium 会像往常一样对这些输入作出反应并重新渲染变更。这可以通过 pointerInput 修饰符实现:

.pointerInput(Unit) {
    awaitPointerEventScope {
        while (true) {
            val event = awaitPointerEvent()
            when (event.type) {
                Press -> chromium.forwardMousePressed(
                    location = event.location,
                    locationOnScreen = event.locationOnScreen,
                    button = event.button,
                    modifiers = event.modifiers,
                    clickCount = event.clickCount
                )
                Release -> chromium.forwardMouseReleased(event)
                Move -> chromium.forwardMouseMoved(event)
                // 滚动、输入、退出
            }
        }
    }
}

对于键盘输入,我们可以使用 onKeyEvent。流程是相同的:捕获、转换、转发。

.onKeyEvent { event ->
    when {
        event.type == KeyDown -> chromium.forwardKeyPressed(
            code = event.keyCode,
            location = event.location,
            modifiers = event.modifiers
        )
        event.type == KeyUp -> chromium.forwardKeyReleased(event)
        event.isTypedEvent -> chromium.forwardKeyTyped(event)
    }
    STOP_PROPAGATION
}

输入法支持 

键盘输入功能尚未完全实现,因为我们还需要支持中文、日文或韩文等国际文本的输入。这些语言使用输入法编辑器(IME),让用户能够输入标准键盘上没有的复杂字符。

其工作原理如下:

  1. 当用户输入时,应用会接收到包含未确认文本(uncommitted text)的组合事件(composition event)。随着每次按键,这段文本都会更新。
  2. IME 会显示一个建议弹窗(由操作系统原生渲染),供用户选择可能的字符。
  3. 当用户选定字符后,应用会收到一个提交事件(commit event),其中包含最终文本,并将其插入到输入框中。

IME 建议弹窗

IME 建议弹窗。

Compose 在标准可组合项中支持 IME,但在自定义可组合项中使其工作并不简单。通常情况下,您会使用 TextField,但在我们的例子中,实际的输入字段位于网页内部,是 Compose 的一块画布(canvas)。

为了解决这个问题,我们需要像 TextField 一样接收 IME 事件。当 TextField 获得焦点时,它会连接到平台的输入系统,并开始接收 IME 事件,例如组合文本更新(composition updates)或字符提交(character commits)。Compose 会将这些事件转换为我们可以在代码中处理的命令对象(command objects)。实际实现如下:

.onFocusChanged {
    if (it.isFocused) {
        // 当组件获得焦点时,开始监听 IME 输入。
        inputSession = textInputService.startInput(
            value = TextFieldValue(),
            imeOptions = ImeOptions.Default,
            onImeActionPerformed = { /* no-op */ },
            onEditCommand = ::onEditCommands,
        )
        // 定位 IME 弹窗位置。
        inputSession?.notifyFocusedRect(imePopupRect)
    } else {
        inputSession?.dispose()
    }
}

请注意,这里我们传入了一个空的 TextFieldValue,并将 onImeActionPerformed 留空,因为所有文本输入和动作都会直接由 Chromium 处理。我们唯一需要转发的是编辑命令(edit commands),这些命令描述了输入应如何变化,以便 Chromium 能正确渲染组合文本:

private fun onEditCommands(commands: List<EditCommand>) {
    commands.forEach { command ->
        when (command) {
            is CommitTextCommand -> chromium.commitText(command.text)
            is SetComposingTextCommand -> chromium.setComposition(command.text)
        }
    }
}

一旦 Chromium 收到这些事件,它就会更新网页中的输入框,并渲染用户选择的字符。

状态与生命周期管理 

在 JxBrowser 中,当对象不再需要时,您需要显式关闭它们。因此,当 Web 视图出现时,我们需要进行初始化设置;而当它消失时,则必须进行资源清理。听起来很简单,对吧?

在 Compose 中,组件会随着状态变化被自动创建和销毁。要在这个生命周期中插入我们的逻辑,可以使用 DisposableEffect。它允许在可组合项进入组合时执行初始化代码,并在其离开组合时执行清理代码:

DisposableEffect(Unit) {
    // 初始化资源:像素缓冲区、监听器、服务对象等
    onDispose {
        // 清理资源。
    }
}

这适用于 Web 视图可组合对象(BrowserView)然而,让我们考虑一个更复杂的例子:一个标签式 UI,每个标签都应显示不同的 BrowserView

val engine = remember { Engine(renderingMode = OFF_SCREEN) }
val browser1 = remember { engine.newBrowser() }
val browser2 = remember { engine.newBrowser() }
val browser3 = remember { engine.newBrowser() }
val tabs = listOf("Tab 1", "Tab 2", "Tab 3")
val selectedTabIndex = remember { mutableStateOf(0) }

Column {
    TabRow(selectedTabIndex = selectedTabIndex.value) {
        tabs.forEachIndexed { index, title ->
            Tab(
                selected = selectedTabIndex.value == index,
                onClick = { selectedTabIndex.value = index },
                text = { Text(title) }
            )
        }
    }
    when (selectedTabIndex.value) {
        0 -> BrowserView(browser1)
        1 -> BrowserView(browser2)
        2 -> BrowserView(browser3)
    }
}

当您切换标签页时,之前的 BrowserView 会从组合中移除,并添加一个新的。在 Compose 中,除非在外部记住状态,否则移除可组合项也会销毁其状态。这意味着 BrowserView 会销毁所有资源并从头重新创建,从而导致已渲染的内容短暂丢失并重新初始化,屏幕上可能会出现可见的闪烁。

为了避免这种情况,我们将状态与可组合项分离。不再在 BrowserView 内管理资源,而是引入 BrowserViewState —— 该对象持有浏览器视图所需的一切:包括 Browser 实例、最后渲染的帧、监听器以及服务对象。现在,BrowserView 变为无状态:

val state1 = rememberBrowserViewState(browser1)
val state2 = rememberBrowserViewState(browser2)
val state3 = rememberBrowserViewState(browser3)

Column {
    TabRow(selectedTabIndex = selectedTabIndex.value) {
        tabs.forEachIndexed { index, title ->
            Tab(
                selected = selectedTabIndex.value == index,
                onClick = { selectedTabIndex.value = index },
                text = { Text(title) }
            )
        }
    }
    when (selectedTabIndex.value) {
        0 -> BrowserView(state1)
        1 -> BrowserView(state2)
        2 -> BrowserView(state3)
    }
}

rememberBrowserViewState 会在组合之间保存状态。当此函数离开组合时,它会自动关闭状态并进行清理,除非您手动使用 state.close()browser.close() 执行此操作。请注意,它会关闭浏览器视图状态,但不会关闭浏览器对象本身。

使用 AWT 填补空白 

在底层,Compose for Desktop 构建在 Java AWT 和 Swing 之上。AWT(Abstract Window Toolkit)是 Java 的基础 UI 框架,提供对原生窗口、事件以及低层输入处理的访问——而这些正是 Compose 所做抽象隐藏起来的部分。但在集成 Chromium 时,我们需要这种低层访问能力。

在我们的集成过程中,有几个地方直接使用了 AWT 类型。以下是一些示例。

原始输入事件 

某些输入详细信息,例如屏幕位置、点击计数或某些键码,并未由 Compose 公开。但它们仍然可以通过原始 AWT 事件获取,您可以通过 event.awtEventOrNull 访问该事件。

例如,当用户输入一个字符时,我们需要将该字符传递给 Chromium。但输入的字符仅在 AWT 的 keyTyped 事件中可用。

val keyChar = event.awtEventOrNull!!.keyChar

由于 JxBrowser 已经支持 Swing(它也基于 AWT),我们可以复用我们在 AWT 事件处理方面的经验。某些部分,例如滚动,需要我们根据经验进行调整。为了使其正常工作,我们使用平台特定的常量手动缩放滚动增量:

// 平台特定的滚动缩放。
private val POINTS_PER_UNIT = if (isMac()) 10F else 100F / 3
// 反转方向以符合 Chromium 的预期。
private const val DIRECTION_FIX = -1F
...
val awtEvent = event.awtEventOrNull as MouseWheelEvent
val delta = awtEvent.unitsToScroll * POINTS_PER_UNIT * DIRECTION_FIX
val deltaX = if (awtEvent.isShiftDown) delta else 0F
val deltaY = if (!awtEvent.isShiftDown) delta else 0F

窗口监听器 

为了在正确的位置处理输入,或者在合适的位置显示工具提示(tooltip)或 IME 弹窗,Chromium 需要知道渲染内容的精确位置——既包括相对于窗口的位置,也包括相对于屏幕的位置。

Compose 提供了 onGloballyPositioned 来跟踪布局变化,但它不会在窗口移动、最小化或恢复时通知我们。为此,我们依赖于 AWT 窗口事件。

这些事件由托管 Compose 窗口的底层 AWT 窗口调度。如果您的可组合项位于 WindowScope 内,您可以访问该窗口并注册监听器:

val onWindowMoved = object : ComponentAdapter() {
    override fun componentMoved(e: ComponentEvent) {
        val window = e.component as Window
        chromium.updateBounds(
            positionInScreen = positionInWindow + window.position
        )
    }
}
val onWindowIconified = object : WindowAdapter() {
    override fun windowIconified(e: WindowEvent) = chromium.minimize()
    override fun windowDeiconified(e: WindowEvent) = chromium.restore()
}

window.addComponentListener(onWindowMoved)
window.addWindowListener(onWindowIconified)

文件选择器 

当网页触发文件上传时,Chromium 会请求应用显示一个文件选择器对话框。由于 Compose 不包含内置文件对话框组件,我们可以使用 AWT 的 FileDialog 或 Swing 的 JFileChooser 来处理这一功能。

目前进展 

经过数月的开发、测试和打磨,BrowserView 现在已经能够在 Compose 中自然运行。它同时支持两种渲染模式,并可在 Windows、macOS 和 Linux 上正常工作。

在此过程中,Compose Multiplatform 证明了它是一个稳健且拥有强大社区支持的框架。开发期间,我们向 Kotlin Slack 报告了多个错误,并经常提出问题。社区的回复非常及时且有帮助,我们甚至常常在下一个版本中就能看到相关修复。

我们为最终成果感到自豪,也很期待看到 Kotlin 社区用它构建出怎样的作品。欢迎在您的 Compose Desktop 应用中使用 JxBrowser,并随时向我们反馈您的意见或建议。

Spinner

发送中。。。

抱歉,发送中断

请再次尝试,如果问题仍然存在,请联系我们 info@teamdev.com.

阅读并同意条款以继续。

您的个人 JxBrowser 试用密钥和快速入门指南将在几分钟内发送至您的电子邮箱。