技术分享 | 在 IDE 插件开发中接入 JCEF 框架

项目背景

当前的开发环境存在多种不同语言的 IDE,如 JetBrains 全家桶、Eclipse、Android Studio 和 VS Code 等等。由于每个 IDE 各有其特定的语言和平台要求,因此开发 IDE 插件时,需要投入大量资源才能尽可能覆盖大部分工具。同时,代码难复用、用户体验难统一等问题又会进一步加重资源负担。

在调研过程中,我们发现如今的大多数开发工具都支持集成 CEF,而 CEF 提供的跨平台解决方案正可以有效解决上述问题。

关于 CEF 和 JCEF

CEF(Chromium Embedded Framework)是一个开源项目,它基于 Google Chromium 浏览器引擎,允许开发人员将完整的浏览器功能嵌入到自己的应用程序中。

通过 CEF,开发者可以利用现代 Web 技术来创建强大的桌面应用程序,并实现与 Web 内容的无缝集成。如此一来,开发者便可以利用 CEF 的功能和灵活性,为各种开发工具提供统一的、高质量的插件体验。

JCEF(Java Chromium Embedded Framework)是基于 CEF 的一个特定版本,专门为 Java 应用程序而生。本文内容也主要围绕 JCEF 展开。

JCEF 和其他产品的对比

  • JCEF vs JxBrowser

JxBrowser 和 JCEF 都允许将 Chromium 浏览器功能嵌入到 Java 应用程序中。其中,JxBrowser 是商业产品,而 JCEF 是开源框架,且商业授权非常友好。

此外,JxBrowser 在独立的本地进程中启动 Chromium,而 JCEF 则是在 Java 进程内启动。JCEF 会快速初始化 Chromium,同时消耗 Java 进程的内存和 CPU;创建多个 Chromium 实例也会占用更多资源。

  • JCEF vs JavaFX

JavaFX 使用的内置浏览器组件是 WebView,其在不同平台上的实现有所不同。例如,在 macOS 上使用 WebKit,在 Windows 上默认为 Internet Explorer,而新版本的 JavaFX 则默认使用 JCEF。

这种不一致性会增加插件适配的难度,降低整体开发效率。

Java 进程与 JCEF 交互

技术分享 | 在 IDE 插件开发中接入 JCEF 框架_第1张图片

如何在 IDE 插件中接入 JCEF?

下面以 LigaAI Jetbrains 插件为例,介绍集成 JCEF 的过程。

  1. 在 Java 代码里创建相应的 JcefBrowser
static JBCefBrowser createBrowser(Project project) {
    JBCefClient client = JBCefApp.getInstance().createClient();
    //CefMessageRouter 用于处理来自 Chromium 浏览器的消息和事件,
    //前端代码可以通过innerCefQuery和innerCefQueryCancel发起消息给插件进行处理
    CefMessageRouter.CefMessageRouterConfig routerConfig =
            new CefMessageRouter.CefMessageRouterConfig("innerCefQuery", "innerCefQueryCancel");
    CefMessageRouter messageRouter = CefMessageRouter.create(routerConfig, new MessageRouterHandler());
    client.getCefClient().addMessageRouter(messageRouter);
    //用于处理以http://inner/开头的请求。 用于拦截特定请求,转发请求到本地以获取本地资源
    CefApp.getInstance()
            .registerSchemeHandlerFactory("http", "inner", new DataSchemeHandlerFactory());
    return new JBCefBrowser(client, "");
}
  1. 加载对应的 URL,渲染页面。
public static void loadURL(JBCefBrowser browser, String url) {
    //如果不需要设置和浏览器显示相关的,可忽略
    browser.getJBCefClient()
            .addDisplayHandler(settingsDisplayHandler, browser.getCefBrowser());
    browser.loadURL(url);                 
}
  1. Java 进程拦截前端发起的获取静态资源的请求。如果直接访问外部资源,则不需要做拦截,这一步可忽略。
import com.intellij.liga.web.WebviewClosedConnection;
import com.intellij.liga.web.WebviewOpenedConnection;
import com.intellij.liga.web.WebviewResourceState;
import com.intellij.openapi.vfs.VfsUtil;
import com.intellij.openapi.vfs.VirtualFile;
import org.apache.commons.lang.StringUtils;
import org.cef.callback.CefCallback;
import org.cef.handler.CefResourceHandler;
import org.cef.misc.IntRef;
import org.cef.misc.StringRef;
import org.cef.network.CefRequest;
import org.cef.network.CefResponse;

import java.io.File;
import java.net.URL;


//继承 CefResourceHandler 接口,自定义处理 Chromium 浏览器加载的资源(如网页、图像、样式表等)。
//通过实现该接口,可以覆盖默认的资源加载行为,并提供自定义的资源加载逻辑。
public class DataResourceHandler implements CefResourceHandler {

    private WebviewResourceState state;

    /**
    * 用于处理资源请求,你可以通过该方法获取请求的 URL、请求头部信息,并返回相应的响应结果。
    */
    public boolean processRequest(CefRequest cefRequest, CefCallback cefCallback) {
        String url = cefRequest.getURL();
        //判断请求是否是用于获取内部静态资源的,如果是则拦截请求,并从项目里对应配置获取对应文件返回
        //如果是请求外部资源,则跳过
        if (StringUtils.isNotBlank(url) && url.startsWith("http://inner")) {
            String pathToResource = url.replace("http://inner", "/front/inner");
            pathToResource = pathToResource.split("\\?")[0];
            URL resourceUrl = getClass().getResource(pathToResource);
            VirtualFile f = VfsUtil.findFileByURL(resourceUrl);
            resourceUrl = VfsUtil.convertToURL(f.getUrl());
            try {
                this.state = (WebviewResourceState) new WebviewOpenedConnection(resourceUrl.openConnection());
            } catch (Exception exception) {
                //log output
            }
            cefCallback.Continue();
            return true;
        }
        return false;
    }

    /**
    * 用于设置资源响应的头部信息,例如 Content-Type、Cache-Control 等。
    */
    public void getResponseHeaders(CefResponse cefResponse, IntRef responseLength, StringRef redirectUrl) {
        this.state.getResponseHeaders(cefResponse, responseLength, redirectUrl);
    }

    /**
    * 用于读取资源的内容,可以从这个方法中读取资源的数据并将其传递给浏览器
    */
    public boolean readResponse(byte[] dataOut, int designedBytesToRead, IntRef bytesRead, CefCallback callback) {
        return this.state.readResponse(dataOut, designedBytesToRead, bytesRead, callback);
    }

    /**
    * 请求取消
    */
    public void cancel() {
        this.state.close();
        this.state = (WebviewResourceState) new WebviewClosedConnection();
    }

}

//定义处理 Chromium Embedded Framework (CEF) 中的 Scheme(协议)请求
public class DataSchemeHandlerFactory implements CefSchemeHandlerFactory {
    public CefResourceHandler create(CefBrowser cefBrowser, CefFrame cefFrame, String s, CefRequest cefRequest) {
        return new DataResourceHandler();
    }
}



import org.cef.callback.CefCallback;
import org.cef.handler.CefLoadHandler;
import org.cef.misc.IntRef;
import org.cef.misc.StringRef;
import org.cef.network.CefResponse;

import java.io.InputStream;
import java.net.URLConnection;

public class WebviewOpenedConnection implements WebviewResourceState {
    private URLConnection connection;

    private InputStream inputStream;

    public WebviewOpenedConnection(URLConnection connection) {
        this.connection = connection;
        try {
            this.inputStream = connection.getInputStream();
        } catch (Exception exception) {
            System.out.println(exception);
        }
    }

    public void getResponseHeaders(CefResponse cefResponse, IntRef responseLength, StringRef redirectUrl) {
        try {
            String url = this.connection.getURL().toString();
            cefResponse.setMimeType(this.connection.getContentType());
            try {
                responseLength.set(this.inputStream.available());
                cefResponse.setStatus(200);
            } catch (Exception e) {
                cefResponse.setError(CefLoadHandler.ErrorCode.ERR_FILE_NOT_FOUND);
                cefResponse.setStatusText(e.getLocalizedMessage());
                cefResponse.setStatus(404);
            }
        } catch (Exception e) {
            cefResponse.setError(CefLoadHandler.ErrorCode.ERR_FILE_NOT_FOUND);
            cefResponse.setStatusText(e.getLocalizedMessage());
            cefResponse.setStatus(404);
        }
    }

    public boolean readResponse(byte[] dataOut, int designedBytesToRead, IntRef bytesRead, CefCallback callback) {
        try {
            int availableSize = this.inputStream.available();
            if (availableSize > 0) {
                int maxBytesToRead = Math.min(availableSize, designedBytesToRead);
                int realNumberOfReadBytes = this.inputStream.read(dataOut, 0, maxBytesToRead);
                bytesRead.set(realNumberOfReadBytes);
                return true;
            }
        } catch (Exception exception) {
            //log output
        } finally {
            this.close();
        }
        return false;
    }

    public void close() {
        try {
            if (this.inputStream != null)
                this.inputStream.close();
        } catch (Exception exception) {
            //log output
        }
    }
}
  1. 前端发送请求调用插件,Java 进程接收并处理。
//前端示例代码
<button onclick="callBrowser()">调用浏览器代码</button>

<script>
function callBrowser() {
  var parameter = "example parameter";
  window.location.href = "innerCefQuery://" + parameter;
}
</script>


//插件示例代码
import org.cef.browser.CefBrowser;
import org.cef.browser.CefFrame;
import org.cef.callback.CefQueryCallback;
import org.cef.handler.CefMessageRouterHandlerAdapter;

public class MessageRouterHandler extends CefMessageRouterHandlerAdapter {
    @Override
    public boolean onQuery(CefBrowser browser, CefFrame frame, long query_id, String request,
                           boolean persistent, CefQueryCallback callback) {
        try {
            System.out.println(request);
            callback.success("");
            return true;
        } catch (Exception e) {
            //log output
        }
        return false;
    }
}
  1. 插件前端代码。
// java进程调用前端代码
String script = "window.postMessage('" + JSONObject.toJSONString(scriptObj) + "');";
browser.executeJavaScript(script, "", 0);

// 前端代码
function postMessage(data) {
  // 处理从后端传递过来的数据
  console.log('Received message from backend:', data);
  // 在这里进行你希望执行的其他操作
}

实现效果

通过使用 LigaAI IDE 插件,开发者们无需跳转或登录外部系统,在 IDE 内就能查看任务详情、完成工作、更新和同步任务状态、记录并提报完成信息;在享受沉浸式工作的同时,零负担地实现个人目标管理。

技术分享 | 在 IDE 插件开发中接入 JCEF 框架_第2张图片

此外,JCEF 为插件开发者提供了一个强大的工具,可以利用 Chromium 浏览器的各种功能和扩展性,以更丰富、更高级的方式提供信息和功能,使编码过程变得容易。

因此,利用 LigaAI IDE 插件提供的可视化图表,研发团队还可以了解整体编码情况、不同任务类型的耗时分布等,更有针对性地制定优化方案,或调整规划排期。

技术分享 | 在 IDE 插件开发中接入 JCEF 框架_第3张图片

常见问题及避坑指南

1:集成 JCEF,如何使 Web 样式与 IDE 插件整体样式保持统一?

通过下述方法获取 IDE 的主题模式;

public static String getGlobalStyle() {
    if (EditorColorsManager.getInstance().isDarkEditor())
        return "dark";
    return "light";
}

获取 IDE 内的样式。

//主要可以查看com.intellij.util.ui.UIUtil和com.intellij.ui.JBColor这两个类
//获取字体大小
Font font = UIUtil.getLabelFont();
//获取背景颜色
Color bg = JBColor.background();
//获取字体颜色
Color labelFontColor = UIUtil.getLabelFontColor(UIUtil.FontColor.NORMAL);
//获取按钮的背景颜色
JBColor buttonBg = JBColor.namedColor("Button.default.startBackground",JBUI.CurrentTheme.Focus.defaultButtonColor());
//获取边框的颜色
Color border = JBColor.border();

2:Java 和浏览器之间的交互路由名称不能设置为 cefQuerycefQueryCancel

这两个为 JCEF 的内置路由,同名会干扰甚至覆盖 JCEF 的内部处理逻辑,有一定概率会导致系统白屏等意外行为和异常情况。

CefMessageRouter.CefMessageRouterConfig routerConfig =
            new CefMessageRouter.CefMessageRouterConfig("innerCefQuery", "innerCefQueryCancel");

3:于 JetBrains 插件而言,如果浏览器加载的静态页面数据是打包在插件包内的本地数据,加载过程中获取目标 URL 需要先把目标文件转化为 JetBrains 的虚拟文件,再获取虚拟文件的 URL 作为结果,不然会加载不到目标文件。

public boolean processRequest(CefRequest cefRequest, CefCallback cefCallback) {
    String url = cefRequest.getURL();
    if (StringUtils.isNotBlank(url) && url.startsWith("http://inner")) {
        String pathToResource = url.replace("http://inner", "/front/inner");
        pathToResource = pathToResource.split("\\?")[0];
        // 这里先获取目标文件,转成虚拟文件,再获取对应URL
        URL resourceUrl = getClass().getResource(pathToResource);
        VirtualFile f = VfsUtil.findFileByURL(resourceUrl);
        resourceUrl = VfsUtil.convertToURL(f.getUrl());
        //
        try {
            this.state = (WebviewResourceState) new WebviewOpenedConnection(resourceUrl.openConnection());
        } catch (Exception exception) {
        }
        cefCallback.Continue();
        return true;
    }
    return false;
}

4:插件初始化时,如果浏览器请求 java 的接口较多,或接口速度较慢时,可能会出现白屏。这是因为 onQuery 里复杂的逻辑需要异步处理,不然多个请求会阻塞导致浏览器白屏。

public class MessageRouterHandler extends CefMessageRouterHandlerAdapter {
    @Override
    public boolean onQuery(CefBrowser browser, CefFrame frame, long query_id, String request,
                           boolean persistent, CefQueryCallback callback) {
        try {
            ApplicationManager.getApplication().invokeLater(() -> {
                //进行复杂的逻辑
            });
            callback.success("");
            return true;
        } catch (Exception e) {
            //log output
        }
        return false;
    }
}

参考资料

[1] CEF 相关文档:https://github.com/chromiumembedded/cef

[2] JCEF 源码位置: https://github.com/chromiumembedded/java-cef

[3] Jetbrains 插件开发文档:https://plugins.jetbrains.com/docs/intellij/welcome.html

[4] JxBrowser 和 JCEF 的对比:https://dzone.com/articles/jxbrowser-and-jcef


了解更多技术干货、研发管理实践等分享,请关注 LigaAI。

欢迎试用 LigaAI-智能研发协作平台,体验智能研发协作,一起变大变强!

你可能感兴趣的:(技术分享,ide,前端,后端,java,开源软件)