java/kotlin 生成 echarts 图片最优解

一. 方法探索

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

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

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

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

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

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

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

二. 灵感来源

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

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

三. 所需工具

Echarts-Java

<dependency>
  <groupId>org.icepear.echarts</groupId>
  <artifactId>echarts-java</artifactId>
  <version>1.0.7</version>
</dependency>

该工具的作用有两点:

  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<Number>(43.3, 83.1, 86.4, 72.4))
        .addSeries("2016", arrayOf<Number>(85.8, 73.4, 65.2, 53.9))
        .addSeries("2017", arrayOf<Number>(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)<script src.+?/script>"), """<script src="file://echart.min.js"></script>""")

压缩 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 = """
        <script type="text/javascript">
        var chart = echarts.init(document.getElementById("display-container"));
        var option = ${engine.renderJsonOption(bar)};
        chart.on("finished", function () { console.log(chart.getDataURL({type: "jpg", pixelRatio: "10", excludeComponents: ["toolbox"]})) });
        chart.setOption(option);
        </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<Number>(43.3, 83.1, 86.4, 72.4))
        .addSeries("2016", arrayOf<Number>(85.8, 73.4, 65.2, 53.9))
        .addSeries("2017", arrayOf<Number>(93.7, 55.1, 82.5, 39.1))

    bar.option.animation = false

    val engine = Engine()

    val script = """
        <script type="text/javascript">
        var chart = echarts.init(document.getElementById("display-container"));
        var option = ${engine.renderJsonOption(bar)};
        chart.on("finished", function () { console.log(chart.getDataURL({type: "jpg", pixelRatio: "10", excludeComponents: ["toolbox"]})) });
        chart.setOption(option);
        </script>
    """.trimIndent().replace("\n", "")

    val html = engine.renderHtml(bar)
        .replace(Regex("(?is)<script src.+?</script>"), """<script src="file://echart.min.js"></script>""")
        .replace(Regex("(?is)<script type.+?</script>"), script)

    println(html)
    val processBuilder = ProcessBuilder("phantomjs", "generate-images.js", "10000")
    val process = processBuilder.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]

    val compressImageData = compress(Base64.decode(imageData))

    FileUtil.writeBytes(compressImageData, File("test.jpg"))
}

Java 心得分享
分享 Java 使用心得
3.5k 声望
3.4k 粉丝
0 条评论
推荐阅读
麒麟 arm64 V10SP1 编译 phantomjs
目前国内国产化代替需求越来越多,但是类似于 phantomjs 这种较老的软件网上资料比较少,难以移植到 arm64 上,所以这篇文章分享一下 phantomjs 的移植编译,帮助有需求的人。

zxdposter阅读 89

Java8的新特性
Java语言特性系列Java5的新特性Java6的新特性Java7的新特性Java8的新特性Java9的新特性Java10的新特性Java11的新特性Java12的新特性Java13的新特性Java14的新特性Java15的新特性Java16的新特性Java17的新特性Java...

codecraft32阅读 27.5k评论 1

一文彻底搞懂加密、数字签名和数字证书!
微信搜索🔍「编程指北」,关注这个写干货的程序员,回复「资源」,即可获取后台开发学习路线和书籍来源:个人CS学习网站:[链接]前言这本是 2020 年一个平平无奇的周末,小北在家里刷着 B 站,看着喜欢的 up 主视...

编程指北71阅读 33.7k评论 20

Java11的新特性
Java语言特性系列Java5的新特性Java6的新特性Java7的新特性Java8的新特性Java9的新特性Java10的新特性Java11的新特性Java12的新特性Java13的新特性Java14的新特性Java15的新特性Java16的新特性Java17的新特性Java...

codecraft28阅读 19.3k评论 3

Java5的新特性
Java语言特性系列Java5的新特性Java6的新特性Java7的新特性Java8的新特性Java9的新特性Java10的新特性Java11的新特性Java12的新特性Java13的新特性Java14的新特性Java15的新特性Java16的新特性Java17的新特性Java...

codecraft13阅读 21.8k

Java9的新特性
Java语言特性系列Java5的新特性Java6的新特性Java7的新特性Java8的新特性Java9的新特性Java10的新特性Java11的新特性Java12的新特性Java13的新特性Java14的新特性Java15的新特性Java16的新特性Java17的新特性Java...

codecraft20阅读 15.4k

Java13的新特性
Java语言特性系列Java5的新特性Java6的新特性Java7的新特性Java8的新特性Java9的新特性Java10的新特性Java11的新特性Java12的新特性Java13的新特性Java14的新特性Java15的新特性Java16的新特性Java17的新特性Java...

codecraft17阅读 11.2k

3.5k 声望
3.4k 粉丝
宣传栏