ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JxBrowser 7.19:Swing 嵌入 Chromium 的通信与避坑

JxBrowser 7.19:Swing 嵌入 Chromium 的通信与避坑

简介:JxBrowser 7.19 是面向 Java 桌面应用开发者的嵌入式浏览器组件资源,适合在 Swing、SWT、JavaFX 等界面中集成网页渲染、HTML 交互、登录授权与跨平台浏览能力的项目使用,也可为旧版本升级、兼容性验证和多端部署提供参考。压缩包共 1359 个文件,以 1345 个 HTML 文档为主,辅以 10 个 JAR 运行库及少量脚本、样式与索引文件,整体约 437.21MB;内容覆盖 Windows、Linux、macOS 等桌面与 ARM 平台依赖,并附带完整 Javadoc 与 Browser.java 示例,方便按容器类型选择依赖并完成接入。已有 2599 人学习下载。读者可获得全平台运行库、较完整的接口文档和最小接入示例,用于核对版本差异、搭建开发环境、验证容器嵌入方式,并对照平台分包排查常见集成问题;文档目录也便于按类名与接口快速检索,减少反复试错,对需要维护多端一致体验的团队较为实用,并降低环境配置成本。

1. 用 jxbrowser java 把 Chromium 塞进 Swing:它真正解决的是哪一类问题

Java 桌面端要嵌一个现代内核的浏览器,这事儿的坑比想象中深。JavaFX 自带的 WebView 引擎版本老,H5 报表里的 ES6 语法一上来就白屏;JCEF 能跑但编译链得自己维护,换个 JDK 就得重来一遍。所以我一般会把 jxbrowser java 这条路线重新捡起来——JxBrowser 7.19 属于 7.x 线上比较稳的一个点,本质是把整个 Chromium 内核封装成Engine/Browser两层 JVM 对象,Swing、JavaFX、SWT 都能挂上去。它值钱的地方不是“能显示网页”,而是网页里的 JS 能回调 Java、Java 能拦请求改响应、离屏模式下还能截图和打印。适合谁:做桌面客户端、界面里要嵌 H5 模块、又不想碰 CEF 编译链的 Java 团队。它是商业库,这点要有心理准备。

2. 依赖坐标与内核目录:jxbrowser 7.19 的包结构拆开看

把 7.19 的构件下下来,第一反应往往是“怎么这么多 jar”。它不是一个单体包,而是按宿主 UI 框架切的:核心 API 一个 jar,Swing / JavaFX / SWT 各自的视图实现各一个 jar,再叠一个装 Chromium 原生库的 classifier 包。搞不清这个分层,最容易出的问题就是“代码编译过了,一跑就NoClassDefFoundError”,或者打出来的安装包莫名其妙 800 MB。

2.1 四个构件各管什么,别一股脑全引进来

先看分工,这张表建议存下来,排查依赖冲突时对着看:

构件里面是什么什么时候引
jxbrowserEngine、Browser、Frame、Network等核心 API必引
jxbrowser-swingBrowserView的 Swing 实现界面是 Swing / AWT
jxbrowser-javafxBrowserView的 JavaFX 实现界面是 JavaFX
jxbrowser-swtSWT 宿主适配老 RCP 项目

核心包只提供“引擎 + 浏览器实例 + DOM 操作 + 网络事件”,它不知道你要把画面贴到哪个组件上。视图包才是把渲染结果落到JComponent或Node上的那一层。所以只引jxbrowser不引jxbrowser-swing,BrowserView.newInstance(browser)会直接找不到类。

真正的体积大头在 classifier 上。7.x 的坐标长这样:

<!-- 核心 API,必引 --> <dependency> <groupId>com.teamdev.jxbrowser</groupId> <artifactId>jxbrowser</artifactId> <version>7.19</version> <!-- cross-desktop 把 win64/linux64/mac64/mac64arm 的原生库全塞进一个 jar, 开发期省事,发布期建议换成单平台 classifier --> <classifier>cross-desktop</classifier> </dependency> <!-- Swing 宿主视图 --> <dependency> <groupId>com.teamdev.jxbrowser</groupId> <artifactId>jxbrowser-swing</artifactId> <version>7.19</version> </dependency>

Gradle 写法等价:

dependencies { implementation("com.teamdev.jxbrowser:jxbrowser:7.19:cross-desktop") implementation("com.teamdev.jxbrowser:jxbrowser-swing:7.19") }

这里的关键参数是classifier。cross-desktop是“全平台 fat jar”,好处是同一份依赖在 Windows 开发机、Linux 构建机、Mac 上都能直接跑;坏处是每个平台的chrome可执行文件和.dll/.so/.dylib都在里面,包体直接翻几倍。发布阶段我一般会改成单平台 classifier——win64、linux64、mac64、mac64arm这几个,按目标平台分别构建,安装包能瘦一大圈。

JDK 版本上,7.x 的编译目标是 Java 8,所以 8、11、17 都能跑;我这边的 11 和 17 环境都验证过。至于 21 及以上,得看构件是否带了对应的模块化描述,动手前先写个Engine.newInstance的 ten 行 demo 试一下,比看文档快。

2.2 渲染模式选型:HARDWARE_ACCELERATED 和 OFF_SCREEN 差在哪

这一步选错,后面所有界面问题都是它引起的。7.x 的RenderingMode实际上就两条路:

模式渲染路径优点代价
HARDWARE_ACCELERATED内核开原生窗口,直接叠在宿主窗口上性能接近独立浏览器,视频、Canvas 都流畅z-order 由操作系统管,Swing 的弹窗、JPopupMenu会被浏览器视图盖住
OFF_SCREEN渲染到离屏缓冲,再合成到 Swing 组件上能被 Swing 正常遮挡、能截图、能透明叠加必须手动resize,滚动和输入事件得自己转发,滚动帧率明显低一档

判断标准很朴素:如果你的界面里浏览器区域是“一整块不跟别的组件重叠的矩形”,选HARDWARE_ACCELERATED;如果需要在这个区域上浮一层自定义的 Swing 控件、或者要对着页面做截图和录像、或者要嵌进带透明背景的面板里,选OFF_SCREEN。

OFF_SCREEN有一个几乎人人都会踩的点:不调resize就是一片白。它不是自动跟随容器尺寸的,必须显式告诉内核画布多大:

// 离屏模式下必须先给出画布尺寸,否则内核不知道渲染多大,表现为纯白 browser.resize(1280, 800); BrowserView view = BrowserView.newInstance(browser); // 组件尺寸变化时要同步回去,否则拉伸窗口后页面还是旧尺寸 view.addComponentListener(new ComponentAdapter() { @Override public void componentResized(ComponentEvent e) { browser.resize(Math.max(1, view.getWidth()), Math.max(1, view.getHeight())); } });

resize的宽高必须大于 0,最小化窗口时如果传 0,内核那边会抛参数异常,所以上面加了Math.max(1, ...)兜底。这一行是血泪经验,不加的话用户一最小化再还原就崩。

2.3 内核落地目录:别让首次启动去解压临时目录

Engine.newInstance第一次执行时会把 Chromium 内核解压出来。默认落到系统临时目录下的一个带版本号的子目录里。开发机上无所谓,生产环境就有两个隐患:一是临时目录可能被安全策略限制写入,二是某些清理工具会把临时目录当垃圾扫掉,用户第二天打开软件就报内核加载失败。

所以我一般会显式指定内核目录:

Path base = Paths.get(System.getProperty("user.home"), ".myapp"); Path chromiumDir = base.resolve("chromium"); EngineOptions options = EngineOptions.newBuilder(RenderingMode.HARDWARE_ACCELERATED) // license 单独封装,别硬编码在源码里,也别提交到仓库 .licenseKey(LicenseStore.load()) // 内核落地目录:要求可写,预留至少 500MB 空间 .chromiumDir(chromiumDir) // 虚拟机和远程桌面环境下关掉着色器缓存,能少一类花屏问题 .addSwitch("--disable-gpu-shader-disk-cache") .build(); Engine engine = Engine.newInstance(options);

三个参数逐个说。licenseKey是必填的,7.x 没有合法 license 会在newInstance阶段直接抛异常,不是运行到一半才拦。chromiumDir指定解压目标,一旦指定,后续启动会复用这份内核,不再重复解压,冷启动能从几十秒降到几百毫秒。addSwitch是往 Chromium 命令行透传开关,等价于你在启动 chrome 时加参数,排障时特别有用——花屏加--disable-gpu,字体渲染异常试--disable-font-subpixel-positioning。

如果chromiumDir这个构建器方法报“找不到符号”,先别怀疑自己写错了,多半是拿到的构件不是标准 7.19 的官方构建,被重新打包过。版本对不上的包,后面还会在内核版本、license 校验上出各种玄学问题。

指定完目录要验证:进到~/.myapp/chromium下,应该能看到一个chrome(Windows 上是chrome.exe)加一堆动态库,总大小几百 MB。如果目录是空的,说明参数没生效或者写入被拦了,去看日志里有没有被吞掉的IOException。

3. 最小闭环:Engine 单例、Browser 多开、BrowserView 挂载

依赖理顺了,接下来是把一个能跑的窗口搭出来。这三层对象的生命周期差别很大,混着用是后面卡顿和进程泄漏的根源:Engine是重对象,一个进程只能有一个;Browser是轻对象,可以开很多个;BrowserView只是把某个Browser的画面贴到某个组件上,不持有内核状态。

3.1 一个线程安全的 Engine 持有类

Engine.newInstance是阻塞的,而且内部会拉起一整套 Chromium 子进程。放在 EDT 上执行,界面会直接冻住十几秒——这不是性能问题,是“用户以为程序挂了”的问题。所以我把引擎初始化单独抽出来:

public final class BrowserEngineHolder { private static volatile Engine instance; public static Engine get() { Engine local = instance; if (local == null) { synchronized (BrowserEngineHolder.class) { local = instance; if (local == null) { EngineOptions options = EngineOptions.newBuilder(RenderingMode.HARDWARE_ACCELERATED) .licenseKey(LicenseStore.load()) .chromiumDir(Paths.get(System.getProperty("user.home"), ".myapp", "chromium")) .build(); local = Engine.newInstance(options); instance = local; // 兜底关闭,防止用户直接关进程留下僵尸子进程 Runtime.getRuntime().addShutdownHook(new Thread(local::close, "jxbrowser-shutdown")); } } } return local; } private BrowserEngineHolder() { } }

双检锁是为了保证并发调用时只创建一个引擎。很多项目启动时会并发初始化多个业务面板,如果每个面板都newInstance,你会看到任务管理器里瞬间冒出几组 Chromium 进程,每个都占几百 MB,还互相抢 GPU 进程。

addShutdownHook那一行是后悔药。用户在任务管理器里结束进程时,JVM 的关闭钩子仍会执行,能把这批 Chromium 子进程一起收掉;没有它,异常退出后下次启动可能出现端口/锁文件冲突。

这个get()必须在启动阶段、非 EDT 线程上预热一次。我一般放在 splash 界面加载时用SwingWorker里跑,界面先显示“正在初始化浏览器内核…”。

3.2 Browser 的创建与 URL 加载时机

Browser很便宜,engine.newBrowser()通常在几十毫秒内返回。真正耗时的是页面导航。所以创建和加载要分开看:

Engine engine = BrowserEngineHolder.get(); Browser browser = engine.newBrowser(); BrowserView view = BrowserView.newInstance(browser); panel.setLayout(new BorderLayout()); panel.add(view, BorderLayout.CENTER); // 导航是异步的,这里返回不代表页面已经加载完 browser.navigation().loadUrl("https://report.internal/dashboard"); // 加载完成事件里再做 DOM 注入、JS 通信这类事 browser.navigation().on(FinishLoading.class, event -> { LOG.info("loaded: {}", event.url()); });

loadUrl是异步的。紧接着在下一行去executeJavaScript,经常拿到的是null或者空对象,因为 DOM 还没建完。所有依赖页面结构的操作,都得挂在FinishLoading(或DocumentLoaded)回调里。

同一个Browser可以重复用,切换页面用loadUrl或loadHtml,不需要关掉重建。但换业务模块时建议browser.close()再新建一个——不是必须,只是把可能泄漏的 JS 定时器和事件监听一起清掉。

BrowserView.newInstance(browser)这个方法名在HARDWARE_ACCELERATED和OFF_SCREEN下是同一个,差异在Engine那边就定了。所以渲染模式必须在建引擎时就定死,中途改不了。

3.3 关闭顺序:先 browser 后 engine

关闭顺序错了,控制台会刷一堆IllegalStateException,严重的时候进程退不干净。正确顺序是自下而上:

// 关闭单个浏览器视图 browser.close(); // 应用退出时再关引擎,顺序不能反 engine.close();

browser.close()会释放这个页面的渲染进程和所有 JS 上下文;engine.close()会终止整个 Chromium 进程组。如果先engine.close()再browser.close(),后者会对一个已经失效的引擎发指令,抛异常;如果只关窗口不close浏览器,渲染进程会一直挂着,用户反复打开关闭同一个模块,进程数会一路涨上去。

一个常见的错误写法是在windowClosing里先调engine.close(),再dispose()界面。改成先关所有Browser,最后统一关Engine,这类问题就没了。

4. 双向通信:JS 调 Java、Java 注 JS、请求拦截

页面能显示只是第一步。真正让嵌入方案有价值的是双向通信:网页里的按钮要触发 Java 侧的下载、Java 侧要把登录态塞给页面、某些请求要在网络层被改写。JxBrowser 7.x 给了三层机制,从轻到重依次是注入执行、对象桥接、网络拦截。

4.1 @JsAccessible 加 JsObject:把 Java 对象挂到 window 上

最省事的做法是把一个普通 Java 对象挂到window上,页面里直接当 JS 对象调用。前提是方法必须标@JsAccessible,不标的一律不可见——这既是安全边界,也避免把整个对象图暴露出去:

public final class HostBridge { @JsAccessible public String readToken() { return TokenStore.current(); } @JsAccessible public void log(String message) { LOG.info("[web] {}", message); } }

挂载的时机必须等页面加载完:

browser.navigation().on(FinishLoading.class, event -> { browser.mainFrame().ifPresent(frame -> { // executeJavaScript 返回 JsObject 时,是页面里的 window 本身 JsObject window = frame.executeJavaScript("window"); // 挂上去之后,页面里可以直接 hostBridge.readToken() window.putProperty("hostBridge", new HostBridge()); }); });

frame.executeJavaScript("window")这里看起来有点绕,但它返回的就是页面window的JsObject引用,putProperty相当于在页面里执行window.hostBridge = {...}。参数名(这里是hostBridge)就是页面侧访问用的全局变量名,别跟业务里已有的变量撞。

注意两点。第一,@JsAccessible方法是在哪个线程被调用的,取决于内核回调线程,不是 EDT;方法体里如果要改 Swing 组件,必须SwingUtilities.invokeLater包一层,否则就是经典的随机崩溃。第二,参数和返回值只支持基础类型和字符串,传自定义对象会静默变成undefined,这个坑很隐蔽。

4.2 executeJavaScript 的返回值边界:Promise 不是值

executeJavaScript看着像个万能入口,实际上它只能同步拿到表达式的即时结果。页面里fetch(...)、async函数返回的是一个 Promise 对象,你拿到的是JsObject,不是数据:

// 错误示范:拿到的不是数据,是 Promise 的包装对象 Object wrong = frame.executeJavaScript("fetch('/api/profile').then(r => r.json())"); // 可行做法:让页面同步返回序列化好的字符串 String json = frame.executeJavaScript("JSON.stringify(window.__profile || null)");

所以我的习惯是:不指望从executeJavaScript直接取异步结果,而是在页面侧挂一个回调函数,Java 侧先注册桥接对象,页面拿到数据后主动调hostBridge.onProfileLoaded(json)。这样数据流向是单向的、可追踪的,出问题也容易定位。

JSON.stringify那一步别省。JxBrowser 对 JS 对象的自动转换只覆盖基础类型,直接返回Object或者数组,经常得到一个看起来像数组、实际取不到值的怪东西,也不好断点。序列化成字符串,Java 侧用任意 JSON 库解析,行为最确定。

4.3 网络拦截:BeforeUrlRequest 拦请求、BeforeSendHeaders 改头

网络层是这套方案里最容易被低估的能力。engine.network()上可以订阅一系列事件,用来做请求阻断、请求头注入、mock 响应:

// 阻断某个路径下的所有请求,常用于屏蔽页面里的第三方统计脚本 engine.network().on(BeforeUrlRequest.class, event -> { if (event.url().contains("/legacy/")) { event.preventDefault(); // 拦掉,页面侧表现为请求失败 } }); // 给所有出站请求补一个客户端标识,后端按这个标识下发不同版本 engine.network().on(BeforeSendHeaders.class, event -> { HttpHeaders headers = event.headers().toBuilder() .add("X-Client", "desktop/1.0") .build(); event.setHeaders(headers); });

BeforeUrlRequest触发在请求发出之前,preventDefault()就是阻断,页面里对应的fetch会走 reject 分支,所以页面侧要有兜底处理,否则用户看到的是一个一直转圈的加载态。BeforeSendHeaders触发在请求头组装完成之后,headers().toBuilder()拷一份再改,不要直接改原对象。

这两个接口在不同小版本之间签名略有出入,写的时候靠 IDE 补全确认参数类型,别照抄网上的老代码。另外,网络事件回调不保证在 EDT 上,回调体里做重活(比如写数据库、读大文件)会拖慢整个网络层,建议只做判断和转发。

5. 避坑与排查:许可证、首次启动、EDT、进程残留、打包五个翻车点

前面四章都是“怎么跑起来”,这一章讲“为什么跑不起来”。下面五个问题是我在不同项目里反复遇到的,按出现频率排。每个都按现象、原因、解决三段写,遇到了直接对号入座。

5.1 许可证与内核来源:两个最容易被忽略的前置条件

现象一:本地开发一切正常,换到测试机或者打包后运行,启动即抛异常,提示 license 缺失或无效。

原因:7.x 的 license 校验发生在Engine.newInstance,而且它是跟签发时的应用标识绑定的,不是随便一个字符串都能过。另外把 license 硬编码进源码、或者放在没被打进安装包的资源目录里,都会导致运行期读不到。

解决:把 license 的读取抽成单独一层,优先读外部配置文件,其次读环境变量,最后才回落到打包内的资源。启动时把 key 的前 8 位打进日志(不要打全量),一眼就能看出读到的是不是预期那一份。生产环境更稳的做法是把 license 放在安装目录的conf/下,随配置一起下发,方便替换。

现象二:chromiumDir指定了,目录却始终是空的;或者目录里有文件,但程序报内核版本不匹配。

原因:拿到的构件被重新打包过,classifier 里的原生库和jxbrowser核心包的版本对不上。这种情况在来源不明的整合包里特别常见。

解决:核对 jar 的清单文件里的版本号,和依赖声明里的7.19是否一致。对不上就换回标准构件。生产项目不要用来源不明的整合包,出了问题没有任何线索可查,纯属给自己埋雷。

5.2 线程与卡死:在 EDT 上碰 Engine 的代价

现象:双击图标后窗口白屏十几秒到一分钟,进度条不动,Windows 直接标“未响应”。

原因:Engine.newInstance在 EDT 上执行。首次运行要解压几百 MB 的内核文件,这段时间 EDT 被完全占住,Swing 连重绘都做不了。

解决:把引擎初始化放到启动线程或者SwingWorker#doInBackground里,界面先显示一个 splash 或者“初始化中”的占位面板,初始化完成后再切到主界面。第二次及以后的启动因为内核已解压,通常几百毫秒就够,但代码不能假设“一定是第二次”。

现象:点击页面按钮后整个界面卡住几秒,然后恢复,日志里能看到 JS 调用记录。

原因:在 EDT 上同步等待executeJavaScript的结果,或者@JsAccessible方法体里做了阻塞 IO。

解决:桥接方法里只做轻量转换,把耗时逻辑丢到线程池;需要的返回值通过回调再送回页面。判断标准很简单——桥接方法体超过 10 行,就该拆了。

5.3 进程与打包:任务管理器里的进程数是最好的体检指标

现象:反复打开关闭同一个业务模块,任务管理器里 Chromium 相关进程数一路往上涨,内存也跟着涨,最后 OOM。

原因:只把BrowserView从容器里remove了,没有browser.close()。视图没了,但渲染进程和 JS 上下文还活着。

解决:把browser.close()和界面销毁绑在一起,写在一个统一的dispose()方法里,模块卸载时必调。上线前做一次“打开关闭 20 次”的压测,看进程数是否回到初始值,这是最直接的验证手段。

现象:开发机能跑,打成安装包后安装到干净机器上,界面一片空白,日志里没有任何异常。

原因:内核二进制没进安装包;或者进了包但被安全软件拦在临时目录外;或者目标机器的用户目录没有写权限。

解决:显式指定chromiumDir到安装目录下的子目录,并在安装包里预置好这份内核,跳过首次解压。安装完成后加一步校验:检查目录下chrome可执行文件是否存在、动态库数量是否与预期一致。这一步能挡掉大部分“换台机器就不行”的问题。

6. 上线前的自检:把内核版本、线程归属和关闭行为都打出来

调到后期,问题往往不是“跑不起来”,而是“跑起来但行为不对”。我习惯在动手写业务代码之前,先跑一段自检,把三个不确定因素量化出来:内核到底是什么版本、回调落在哪个线程、关闭之后进程有没有真的退干净。

public final class BrowserSelfCheck { public static void main(String[] args) throws Exception { Engine engine = BrowserEngineHolder.get(); System.out.println("engine created on: " + Thread.currentThread().getName()); Browser browser = engine.newBrowser(); browser.navigation().loadUrl("about:blank"); browser.navigation().on(FinishLoading.class, event -> { // UA 里 Chrome/xx 那段就是内核真实版本,比看文档靠谱 System.out.println("userAgent = " + browser.userAgent()); // 回调线程名能直接告诉你:这里能不能碰 Swing System.out.println("callback thread = " + Thread.currentThread().getName()); // 离屏模式下这一行必须 > 0,否则就是白屏 System.out.println("viewport = " + browser.mainFrame().isPresent()); }); Thread.sleep(3000); browser.close(); engine.close(); // 正常情况:这行打印后 1~2 秒内,进程列表里的 Chromium 子进程应全部消失 System.out.println("closed, check process list now"); } }

三个输出各有用途。userAgent里的Chrome/后面那段数字是内核真实版本,比你翻构件元数据可靠——如果它和你预期的代际差得远,说明内核目录里躺着一份旧的解压结果,删掉chromiumDir重来。callback thread决定了你的回调体能不能直接操作界面,名字不是AWT-EventQueue-0的一律得包invokeLater。最后那句closed打印完之后,去任务管理器看一眼,子进程如果还在,就是某个Browser没关。

这段代码我一般会做成一个隐藏的“诊断”菜单项,让现场实施的人也能点一下,把三行输出贴回来。比让用户描述“有时候会卡”有用得多。

从那以后我每次把 jxbrowser 塞进新项目,都强制先跑一遍这段自检,再谈业务逻辑——省下来的排查时间,比写这段代码的时间多得多。手上这份 7.19 的包,建议也先按这个顺序跑通再加功能。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进