一. 办法摸索
后盾生成图片的办法不多,依据我在网上的查找,有如下几种办法:
- 前台服务提供接口,联合图表提供的生成图片,申请后返回图片数据。
- 搭建服务,与第一点相似,同样是发送数据。
- 若有配合的前端服务,能够在前端发动下载时生成图片数据,传送回后盾。
- 利用
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>
该工具的作用有两点:
- 不便的将数据整顿成 echarts 所需的 option。
- 可能将 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
解释一下命令行参数:
- phantomjs,phantomjs 的执行门路。
- generate-images.js,就是上述提到的 javascript 脚本。
- jpg 为你须要的图片格式,svg 须要本人批改 javascript 脚本。
- 10000,为延迟时间,这个工夫为了留给 html 加载的,耗时蕴含的有下载 echarts 脚本与图片生成。
- 10,图片品质,越大品质越高,图片体积越大。
你能够看到,通过我的精简,整体的代码是比较简单的。
五. 优化过程
下面的演示代码并不能称得上是最终版本。
你要面临两个问题:
- 生成的 html,是须要联网下载 echarts 的,这部分耗时不说,有些环境也面临着无奈联网的状况。
- 品质为 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 通道,因而我在网上找到了移除通道的方法。
优化耗时
其实本来写到下面就截止了,不过因为灵感来了,顺道就把这个问题也解决了。
如果你残缺的了解了下面的例子之后,你会发现在这个例子在耗时解决上有很大的问题:
- 耗时不可控,无奈晓得图表是何时渲染完的。
- 耗时只能是固定的,即便图片早于你设定的工夫渲染实现,同样须要等很久。
- 图表渲染过程有一个动画,若你在下面的根底上,缩短工夫,你可能会取得动画运行两头的图片,咱们在后端应用,齐全能够省掉这部分工夫。
因而,我针对这些问题又做了进一步的优化。
在此之前,你须要晓得 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.Base64import cn.hutool.core.io.FileUtilimport cn.hutool.core.io.IoUtilimport org.icepear.echarts.Barimport org.icepear.echarts.render.Engineimport java.awt.image.BufferedImageimport java.io.BufferedWriterimport java.io.ByteArrayInputStreamimport java.io.ByteArrayOutputStreamimport java.io.Fileimport java.io.OutputStreamWriterimport java.nio.charset.Charsetimport javax.imageio.IIOImageimport javax.imageio.ImageIOimport javax.imageio.ImageWriteParamfun 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"))}