完整的 java/kotlin 生成 echarts 图片方法

一. 方法探索

后台生成图片的方法不多,根据我在网上的查找,有如下几种方法:

  1. 前台服务提供接口,结合图表提供的生成图片,请求后返回图片数据。
  2. 搭建服务,与第一点类似,同样是发送数据。
  3. 若有配合的前端服务,可以在前端发起下载时生成图片数据,传送回后台。
  4. 利用 phantomjs,将图表数据整理成 html,再结合对应的 javascript 脚本,即可生成图片,这种方法也是本篇文章要介绍的方法。

这几种方法我经过比较,发现还是使用 phantomjs 这种方式优势比较大。

第一,它不需要依赖额外的服务。

第二,生成的方式自主可控,你能够灵活处理数据,也能够控制生成图片的质量和大小。

第三,适用的范围更加广泛,你不仅可以使用 echarts,也可以使用 highchart,并且包括不限于图表,只要你能找到图片化的方法。

唯一的缺点就是你需要安装它。

二. 灵感来源

本篇文章的灵感来源于 ECharts-Java issuse,在寻找后端如何生成前端图表图片方法的过程中,我找到了这个 issuse,并由作者之一的 incandescentxxc 指引,找到了 Snapshot-PhantomJS 这个项目。

但是我没有直接使用 Snapshot-PhantomJS,由于它本身源码不多,因此我选择吸收其中的核心源码,并针对性的进行了缩减与优化。

三. 所需工具

Echarts-Java


  org.icepear.echarts
  echarts-java
  1.0.7

该工具的作用有两点:

  1. 方便的将数据整理成 echarts 所需的 option。
  2. 能够将 option 转化成所需要的 html,能够直接在浏览器中打开看到图表。

如果你使用了 slf4j,最好移除掉所有的 org.slf4j,否则会有冲突问题。(这问题我认为不应该出现,第三方 jar 本身应该考虑到这个问题)

phantomjs

作用有点相当于一个运行在后台的浏览器,在后台运行 html 界面。

javascript 脚本

var page = require("webpage").create();
var system = require("system");

var file_type = system.args[1];
var delay = system.args[2];
var pixel_ratio = system.args[3];

var snapshot =
    "    function(){" +
    "        var ele = document.querySelector('div[_echarts_instance_]');" +
    "        var mychart = echarts.getInstanceByDom(ele);" +
    "        return mychart.getDataURL({type:'" + file_type + "', pixelRatio: " + pixel_ratio + ", excludeComponents: ['toolbox']});" +
    "    }";
var file_content = system.stdin.read();
page.setContent(file_content, "");

window.setTimeout(function () {
    var content = page.evaluateJavaScript(snapshot);
    phantom.exit();
}, delay);

该脚本的作用是在内部生成一个 webpage,用来加载你传递的含有图表数据的 html,等待一段时间加载完成后,获取图片的 dataURL,实际上也就是图片的 base64 数据。

四. 演示代码

由于我觉得 java 的代码写演示太过繁琐,因此都使用 kotlin 演示。
val bar = Bar()
        .setLegend()
        .setTooltip("item")
        .addXAxis(arrayOf("Matcha Latte", "Milk Tea", "Cheese Cocoa", "Walnut Brownie"))
        .addYAxis()
        .addSeries("2015", arrayOf(43.3, 83.1, 86.4, 72.4))
        .addSeries("2016", arrayOf(85.8, 73.4, 65.2, 53.9))
        .addSeries("2017", arrayOf(93.7, 55.1, 82.5, 39.1))
    val engine = Engine()

    val html = engine.renderHtml(bar)

    val process = ProcessBuilder("phantomjs", "generate-images.js", "jpg", "10000", "10").start()
    BufferedWriter(OutputStreamWriter(process.outputStream)).use {
        it.write(html)
    }
    val result = process.inputStream.use { IoUtil.read(it, Charset.defaultCharset()) }
    val contentArray = result.split(",".toRegex()).dropLastWhile { it.isEmpty() }
    if (contentArray.size != 2) {
        throw RuntimeException("wrong image data")
    }

    val imageData = contentArray[1]

    FileUtil.writeBytes(Base64.decode(imageData), File("test.jpg"))
IoUtil 与 FileUtil 都来源于 hutool

解释一下命令行参数:

  1. phantomjs,phantomjs 的执行路径。
  2. generate-images.js,就是上述提到的 javascript 脚本。
  3. jpg 为你需要的图片格式,svg 需要自己修改 javascript 脚本。
  4. 10000,为延迟时间,这个时间为了留给 html 加载的,耗时包含的有下载 echarts 脚本与图片生成。
  5. 10,图片质量,越大质量越高,图片体积越大。

你可以看到,经过我的精简,整体的代码是比较简单的。

五. 优化过程

上面的演示代码并不能称得上是最终版本。

你要面临两个问题:

  1. 生成的 html,是需要联网下载 echarts 的,这部分耗时不说,有些环境也面临着无法联网的情况。
  2. 质量为 10 的图片,体积能来到 40M 以上,这肯定是无法接受的。

使用本地 echarts 库

这里只需要你下载好文件即可,针对性的做替换。

val html = engine.renderHtml(bar)
        .replace(Regex("(?is)""")

压缩 jpg

由于生成的图片是很简单的,这也意味着压缩的空间非常巨大,经过我自己的测试,40M 左右的图片,经过压缩,体积能缩小到几百 K,并且图片质量基本不会受到影响。

我直接给出实现代码。

fun removeAlpha(img: BufferedImage): BufferedImage {
    if (!img.colorModel.hasAlpha()) {
        return img
    }
    val target = BufferedImage(img.width, img.height, BufferedImage.TYPE_INT_RGB)
    val g = target.createGraphics()
    g.fillRect(0, 0, img.width, img.height)
    g.drawImage(img, 0, 0, null)
    g.dispose()
    return target
}

fun compress(imageData: ByteArray): ByteArray {
    return ByteArrayOutputStream().use { compressed ->
        // 压缩图像,原有图像体积过大,压缩后体积缩小并且质量不会有太大损失
        ImageIO.createImageOutputStream(compressed).use {
            val jpgWriter = ImageIO.getImageWritersByFormatName("JPEG").next()
            jpgWriter.output = it

            val jpgWriteParam = jpgWriter.defaultWriteParam
            jpgWriteParam.compressionMode = ImageWriteParam.MODE_EXPLICIT
            jpgWriteParam.compressionQuality = 0.7f

            val img = ByteArrayInputStream(imageData).use {
                // 移除原来的 alpha 通道
                IIOImage(removeAlpha(ImageIO.read(it)), null, null)
            }
            jpgWriter.write(null, img, jpgWriteParam)
            jpgWriter.dispose()
            compressed.toByteArray()
        }
    }
}

因为压缩图片的前提是要求图片不能含有 alpha 通道,因此我在网上找到了移除通道的办法。

优化耗时

其实原本写到上面就截止了,不过由于灵感来了,顺道就把这个问题也解决了。

如果你完整的理解了上面的例子之后,你会发现在这个例子在耗时处理上有很大的问题:

  1. 耗时不可控,无法知道图表是何时渲染完的。
  2. 耗时只能是固定的,即使图片早于你设定的时间渲染完成,同样需要等很久。
  3. 图表渲染过程有一个动画,若你在上面的基础上,缩短时间,你可能会获得动画运行中间的图片,我们在后端使用,完全可以省掉这部分时间。

因此,我针对这些问题又做了进一步的优化。

在此之前,你需要知道 phantomjs 能够监控 webpage 页面的一些事件,其中一个事件就是 [onConsoleMessage](https://phantomjs.org/api/webpage/handler/on-console-message.html),它能够捕获到 webpage 的打印事件,并获取打印信息。

与此同时,echarts 也提供了渲染结束事件 finished

这样,就能够完全自主的掌控渲染所带来的耗时问题了。

优化后的脚本如下,同时我也对脚本设置了一个最长的超时时间,如果在这个时间内还没渲染完成,就会强制结束,防止卡死,我也舍弃了质量与图片格式的配置,将它们放在 echarts 的 finished 事件中。

var page = require("webpage").create();
var system = require("system");

var delay = system.args[1];

var file_content = system.stdin.read();

page.setContent(file_content, "");
page.onConsoleMessage = function (msg) {
    console.log(msg);
    phantom.exit();
};

window.setTimeout(function () {
    phantom.exit();
}, delay);

此时,我们再使用自定的 script,将之前 html 缺失的 finished 加上。

val script = """
        
    """.trimIndent().replace("\n", "")

最后,再设置取消动画,进一步缩短生成时间。

bar.option.animation = false

至此,原本需要十几秒才能够完成的动作,现在只需要 6 秒即可(MacBook Pro m1上测试)。

kotlin 的完整代码

import cn.hutool.core.codec.Base64
import cn.hutool.core.io.FileUtil
import cn.hutool.core.io.IoUtil
import org.icepear.echarts.Bar
import org.icepear.echarts.render.Engine
import java.awt.image.BufferedImage
import java.io.BufferedWriter
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.OutputStreamWriter
import java.nio.charset.Charset
import javax.imageio.IIOImage
import javax.imageio.ImageIO
import javax.imageio.ImageWriteParam

fun removeAlpha(img: BufferedImage): BufferedImage {
    if (!img.colorModel.hasAlpha()) {
        return img
    }
    val target = BufferedImage(img.width, img.height, BufferedImage.TYPE_INT_RGB)
    val g = target.createGraphics()
    g.fillRect(0, 0, img.width, img.height)
    g.drawImage(img, 0, 0, null)
    g.dispose()
    return target
}

fun compress(imageData: ByteArray): ByteArray {
    return ByteArrayOutputStream().use { compressed ->
        // 压缩图像,原有图像体积过大,压缩后体积缩小并且质量不会有太大损失
        ImageIO.createImageOutputStream(compressed).use {
            val jpgWriter = ImageIO.getImageWritersByFormatName("JPEG").next()
            jpgWriter.output = it

            val jpgWriteParam = jpgWriter.defaultWriteParam
            jpgWriteParam.compressionMode = ImageWriteParam.MODE_EXPLICIT
            jpgWriteParam.compressionQuality = 0.7f

            val img = ByteArrayInputStream(imageData).use {
                // 移除原来的 alpha 通道
                IIOImage(removeAlpha(ImageIO.read(it)), null, null)
            }
            jpgWriter.write(null, img, jpgWriteParam)
            jpgWriter.dispose()
            compressed.toByteArray()
        }
    }
}

fun main() {

    val bar = Bar()
        .setLegend()
        .setTooltip("item")
        .addXAxis(arrayOf("Matcha Latte", "Milk Tea", "Cheese Cocoa", "Walnut Brownie"))
        .addYAxis()
        .addSeries("2015", arrayOf(43.3, 83.1, 86.4, 72.4))
        .addSeries("2016", arrayOf(85.8, 73.4, 65.2, 53.9))
        .addSeries("2017", arrayOf(93.7, 55.1, 82.5, 39.1))

    bar.option.animation = false

    val engine = Engine()

    val script = """
        
    """.trimIndent().replace("\n", "")

    val html = engine.renderHtml(bar)
        .replace(Regex("(?is)""")
        .replace(Regex("(?is)
                    
                    

你可能感兴趣的:(完整的 java/kotlin 生成 echarts 图片方法)