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

zxdposter發表於2023-01-29

一. 方法探索

後臺生成圖片的方法不多,根據我在網上的查詢,有如下幾種方法:

  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"))
}

相關文章