使用Java ImageIO,是否可以从InputStream中读取多个图像?

4

我正在尝试创建一个Kotlin线程,它可以从单个InputStream中读取多个图像。

为了测试,我有一个输入流,在另一个线程中接收两个小图像文件的内容。如果将此输入流的内容写入磁盘,结果文件与两个源图像文件的连接相同,这似乎是正确的。

问题出在使用ImageIO从输入流中读取图像时:

import java.awt.image.BufferedImage;
import java.io.File;
import java.io.InputStream;
import javax.imageio.ImageIO;

class ImgReader {

    InputStream input;

    ImgReader(InputStream input) {
        this.input = input;
    }

    public void run() {
        ImageIO.setUseCache(false);
        System.out.println("read start");
        int counter = 1;
        try {
            BufferedImage im = ImageIO.read(input);
            System.out.println("read: " + counter + " " + (im != null));

            if (im != null)
                ImageIO.write(im, "jpg", new File("pics/out/" + (counter++) +".jpeg"));

        } catch (Exception e){
            System.out.println("error while reading stream");
            e.printStackTrace(System.out);
        }

        System.out.println("read done");
    }
}

这适用于第一张图片,该图片正确地接收并保存到文件中。但是,第二个图像没有被读取:ImageIO.read(input)返回null。
是否有可能从InputStream读取多个图像?我做错了什么?
---编辑---
我尝试了一个变化,只从流中解码了一张图片(这是正确的)。在此之后,我尝试将流的其余内容保存到二进制文件中,而不尝试将其解码为图片。第二个二进制文件是空的,这意味着第一个ImageIO.read似乎消耗了整个流。

5
虽然ImageIO.read不会关闭InputStream,但在结束时它并不一定定位于下一张图片的开头。 - Maurice Perry
你能否请用Java重写你的例子?问题不在Kotlin上,而将代码改为Java可以让更多人理解并帮助你。 - talex
1
如果我理解正确的话,您将两个图像写入同一个文件(一个接一个地连接),然后尝试将其读回,就好像这些文件已被保存为单独的文件一样?是否有支持读取这种连接图像的Kotlin库?也许最好的方法是,如果您有时间创建一个poc github项目,以便给我们更好的了解。 - m4gic
也许它也可以适用于更多的图像,但我担心您必须在流中维护图像的边界(我的意思是,如果您知道起始和结束位置,那么可以读取它...) - m4gic
1
严格来说,这里引起问题的并不是ImageIO本身:通过一些调整,可以看出底层的ImageReader才是导致问题的罪魁祸首。例如,JPEGImageReader确实似乎读取了整个流,而PNGImageReader只 (大约) 读取了必要的数据来确定(第一个)图像。由于无法阻止ImageReader这样做,并且无法检测输入字节是否已经被用于图像,我恐怕这是不可能的。不过这是一个有趣的问题,+1。 - Marco13
显示剩余4条评论
3个回答

4
是的,可以从单个InputStream中读取多张图片。 我认为最明显的解决方法是使用已经广泛支持多张图像的文件格式,比如TIFF。尽管ImageIO类没有像读/写单张图像的ImageIO.read(...)/ImageIO.write(...)方法那样方便的方法来读取和写入多张图像,但是,javax.imageio API对于读取和写入多图像文件有很好的支持。这意味着你需要编写更多的代码(下面有代码示例)。 然而,如果输入由第三方在您的控制之外创建,则可能无法使用不同的格式。从评论中可以看出,输入实际上是一系列连接的Exif JPEG流。好消息是,Java的JPEGImageReader/Writer确实允许在同一流中有多个JPEG,尽管这不是非常常见的格式。 要从同一流中读取多个JPEG,您可以使用以下示例(请注意,该代码完全通用,也适用于读取其他多图像文件,如TIFF):
File file = ...; // May also use InputStream here
List<BufferedImage> images = new ArrayList<>();

try (ImageInputStream in = ImageIO.createImageInputStream(file)) {
    Iterator<ImageReader> readers = ImageIO.getImageReaders(in);

    if (!readers.hasNext()) {
        throw new AssertionError("No reader for file " + file);
    }

    ImageReader reader = readers.next();

    reader.setInput(in);

    // It's possible to use reader.getNumImages(true) and a for-loop here.
    // However, for many formats, it is more efficient to just read until there's no more images in the stream.
    try {
        int i = 0;
        while (true) {
            images.add(reader.read(i++));
        }
    }
    catch (IndexOutOfBoundsException expected) {
        // We're done
    }

    reader.dispose();
}   

这条线以下的所有内容都是额外的信息。

以下是使用ImageIO API编写多图像文件的方法(示例代码使用TIFF格式,但它是非常通用的,理论上也适用于其他格式,除了压缩类型参数)。

File file = ...; // May also use OutputStream/InputStream here
List<BufferedImage> images = new ArrayList<>(); // Just add images...

Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("TIFF");

if (!writers.hasNext()) {
    throw new AssertionError("Missing plugin");
}

ImageWriter writer = writers.next();

if (!writer.canWriteSequence()) {
    throw new AssertionError("Plugin doesn't support multi page file");       
}

ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionType("JPEG"); // The allowed compression types may vary from plugin to plugin
// The most common values for TIFF, are NONE, LZW, Deflate or Zip, or JPEG

try (ImageOutputStream out = ImageIO.createImageOutputStream(file)) {
    writer.setOutput(out);

    writer.prepareWriteSequence(null); // No stream metadata needed for TIFF

    for (BufferedImage image : images) {
        writer.writeToSequence(new IIOImage(image, null, null), param);
    }

    writer.endWriteSequence();
}

writer.dispose();

请注意,在Java 9之前,您还需要第三方TIFF插件(如JAI或我的TwelveMonkeys ImageIO)才能使用ImageIO读/写TIFF格式。
另一种选择是将图像封装在自己的最小容器格式中,该格式至少包括每个图像的长度。然后,您可以使用ImageIO.write(...)进行写操作并使用ImageIO.read(...)进行读取,但您需要实现一些简单的流逻辑。当然,主要的反对理由是它将完全成为专有技术。
但是,如果您正在以类似客户端/服务器的方式异步读取/写入(我猜测您是这样做的),那么这可能是完美的选择,并且可以接受这种权衡。
例如:
File file = new File(args[0]);
List<BufferedImage> images = new ArrayList<>();

try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) {
    ByteArrayOutputStream buffer = new ByteArrayOutputStream(1024 * 1024); // Use larger buffer for large images

    for (BufferedImage image : images) {
        buffer.reset();

        ImageIO.write(image, "JPEG", buffer); // Or PNG or any other format you like, really

        out.writeInt(buffer.size());
        buffer.writeTo(out);
        out.flush();
    }

    out.writeInt(-1); // EOF marker (alternatively, catch EOFException while reading)
}

// And, reading back:
try (DataInputStream in = new DataInputStream(new FileInputStream(file))) {
    int size;

    while ((size = in.readInt()) != -1) {
        byte[] buffer = new byte[size];
        in.readFully(buffer); // May be more efficient to create a FilterInputStream that counts bytes read, with local EOF after size

        images.add(ImageIO.read(new ByteArrayInputStream(buffer)));
    }
}

PS:如果你只想将收到的图像写入磁盘,不应该使用ImageIO。相反,使用纯I/O(假设使用前面示例中的格式):

try (DataInputStream in = new DataInputStream(new FileInputStream(file))) {
    int counter = 0;

    int size;        
    while ((size = in.readInt()) != -1) {
        byte[] buffer = new byte[size];
        in.readFully(buffer);

        try (FileOutputStream out = new FileOutputStream(new File("pics/out/" + (counter++) +".jpeg"))) {
            out.write(buffer);
            out.flush();
        }
    }
}

@EloyVillasclaras 好的...那就有点难了。你知道这些图片的格式吗?它们总是JPEG格式吗?还是总是PNG格式?或者是随机的呢? - Harald K
我正在尝试从树莓派相机的定时拍照中读取stdout流。这些图像是EXIF/JPG格式的。所以问题似乎在于JPG阅读器消耗整个流来解码第一张图片 :S。 - Eloy Villasclaras
抱歉,你的代码中哪部分现在可以处理JPG图像?你指的是最后一个吗? - Eloy Villasclaras
在我重写答案之后,第一个代码示例将从单个文件中读取多个JPEG。 - Harald K
1
我可以确认,它也可以使用来自raspistill timelapse的输入流。在您的代码中,您使用 ImageIO.createImageInputStream(file),但也可以使用 ImageIO.createImageInputStream(inputStream)。之所以会这样,是因为在您的代码中,ImageIO要么创建一个MemoryCacheImageInputStream,要么创建一个FileCacheImageInputStream,这允许阅读器寻找下一张图像。 - Eloy Villasclaras
显示剩余3条评论

2

这是输入流的一个众所周知的“特性”。

输入流只能被读取一次(好吧,有mark()和reset()方法,但并非每个实现都支持它(请在Javadoc中检查markSupported()),而且我认为使用起来并不方便)。您应该将图像持久化并将路径作为参数传递,或者应该将其读取到字节数组中,并为每个调用创建一个ByteArrayInputStream以尝试读取它:

// read your original stream once (e.g. with commons IO, just the sake of shortness)
byte[] imageByteArray = IOUtils.toByteArray(input);
...
// and create new input stream every time
InputStream newInput = new ByteArrayInputStream(imageByteArray);
...
// and call your reader in this way:
new ImgReader(newInput);

然后你继续读取同一张图片,问题是是否可以从一个流中读取多个图像。 - Minn
这是他问题的第二部分 :) - m4gic
我无法将整个流持久化到数组中,因为这会导致在第二个图像到达之前无法读取第一个图像(第二个图像可能会延迟),以此类推。 - Eloy Villasclaras

2

更新:

请向下滚动到最后一个代码片段,以获取此答案的更新。

这不是一个令人满意的答案,但是它是对问题的回答:

不,这(几乎肯定)是不可能的。

当将InputStream传递给ImageIO时,它将被内部包装成ImageInputStream。然后将此流传递给ImageReader。具体实现取决于图像数据的类型。(通常从输入数据的前几个字节确定)。

现在,这些ImageReader实现的行为不能被改变或控制得合理。(对于其中一些实现,实际读取甚至发生在本地方法中)。

以下是显示不同行为的示例:

  • 首先,它生成一个包含一个JPG图像和一个PNG图像的输入流。输出显示在返回JPG图像之前完全读取了输入流。

  • 然后,它生成一个包含一个PNG图像和一个JPG图像的输入流。可以看到它只读取了一些字节,直到能够解码第一个PNG图像的结果。

_

import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;

import javax.imageio.ImageIO;

public class MultipleImagesFromSingleStream
{
    public static void main(String[] args) throws IOException
    {
        readJpgAndPng();
        readPngAndJpg();
    }

    private static void readJpgAndPng() throws IOException
    {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ImageIO.write(createDummyImage("Image 0", 50), "jpg", baos);
        ImageIO.write(createDummyImage("Image 1", 60), "png", baos);
        byte data[] = baos.toByteArray();
        InputStream inputStream = createSlowInputStream(data);

        BufferedImage image0 = ImageIO.read(inputStream);
        System.out.println("Read " + image0);
        BufferedImage image1 = ImageIO.read(inputStream);
        System.out.println("Read " + image1);
    }

    private static void readPngAndJpg() throws IOException
    {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ImageIO.write(createDummyImage("Image 0", 50), "png", baos);
        ImageIO.write(createDummyImage("Image 1", 60), "jpg", baos);
        byte data[] = baos.toByteArray();
        InputStream inputStream = createSlowInputStream(data);

        BufferedImage image0 = ImageIO.read(inputStream);
        System.out.println("Read " + image0);
        BufferedImage image1 = ImageIO.read(inputStream);
        System.out.println("Read " + image1);
    }

    private static InputStream createSlowInputStream(byte data[])
    {
        ByteArrayInputStream bais = new ByteArrayInputStream(data);
        return new InputStream()
        {
            private long counter = 0;
            @Override
            public int read() throws IOException
            {
                counter++;
                if (counter % 100 == 0)
                {
                    System.out.println(
                        "Read " + counter + " of " + data.length + " bytes");
                    try
                    {
                        Thread.sleep(50);
                    }
                    catch (InterruptedException e)
                    {
                        e.printStackTrace();
                    }
                }
                return bais.read();
            }
        };
    }

    private static BufferedImage createDummyImage(String text, int h)
    {
        int w = 100;
        BufferedImage image = 
            new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
        Graphics2D g = image.createGraphics();
        g.setColor(Color.BLACK);
        g.fillRect(0, 0, w, h);
        g.setColor(Color.WHITE);
        g.drawString(text, 20, 20);
        g.dispose();
        return image;
    }
}

输出结果如下:
Read 100 of 1519 bytes
Read 200 of 1519 bytes
Read 300 of 1519 bytes
Read 400 of 1519 bytes
Read 500 of 1519 bytes
Read 600 of 1519 bytes
Read 700 of 1519 bytes
Read 800 of 1519 bytes
Read 900 of 1519 bytes
Read 1000 of 1519 bytes
Read 1100 of 1519 bytes
Read 1200 of 1519 bytes
Read 1300 of 1519 bytes
Read 1400 of 1519 bytes
Read 1500 of 1519 bytes
Read BufferedImage@3eb07fd3: type = 0 DirectColorModel: rmask=ff000000 gmask=ff0000 bmask=ff00 amask=ff IntegerInterleavedRaster: width = 100 height = 50 #Bands = 4 xOff = 0 yOff = 0 dataOffset[0] 0
Read null
Read 100 of 1499 bytes
Read 200 of 1499 bytes
Read BufferedImage@42110406: type = 6 ColorModel: #pixelBits = 32 numComponents = 4 color space = java.awt.color.ICC_ColorSpace@531d72ca transparency = 3 has alpha = true isAlphaPre = false ByteInterleavedRaster: width = 100 height = 50 #numDataElements 4 dataOff[0] = 3
Read null

请注意,尽管在第二种情况下它没有读取完整的流,但这仍然不一定意味着输入流位于“JPG数据的开头”。它只是意味着它没有读取完整的流!
我也试图深入研究。如果可以确定图像始终只是PNG图像,则可以尝试手动创建一个PNGImageReader实例并连接到其读取过程,以检查何时实际完成第一个图像。但同样,输入流在内部包装成几个其他(缓冲和解压)输入流,并且没有办法合理地检测某个字节集是否已经被用于图像。
因此,我认为在此唯一明智的解决方案是在读取图像后关闭流,并为下一个图像打开新流。
在评论中讨论的一种解决方法是向流添加长度信息。这意味着图像数据的生产者首先将一个int写入流中,描述图像数据的长度。然后,它使用实际的图像数据写入byte[length]数据。
接收者可以使用此信息加载单个图像。
这里作为示例实现了此功能:
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.GridLayout;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

import javax.imageio.ImageIO;
import javax.swing.ImageIcon;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.SwingUtilities;

public class MultipleImagesFromSingleStreamWorkaround
{
    public static void main(String[] args) throws IOException
    {
        workaround();
    }

    private static void workaround() throws IOException
    {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        write(createDummyImage("Image 0", 50), "jpg", baos);
        write(createDummyImage("Image 1", 60), "png", baos);
        write(createDummyImage("Image 2", 70), "gif", baos);
        byte data[] = baos.toByteArray();
        InputStream inputStream = createSlowInputStream(data);

        BufferedImage image0 = read(inputStream);
        System.out.println("Read " + image0);
        BufferedImage image1 = read(inputStream);
        System.out.println("Read " + image1);
        BufferedImage image2 = read(inputStream);
        System.out.println("Read " + image2);

        showImages(image0, image1, image2);
    }

    private static void write(BufferedImage bufferedImage, 
        String formatName, OutputStream outputStream) throws IOException
    {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ImageIO.write(bufferedImage, formatName, baos);
        byte data[] = baos.toByteArray();
        DataOutputStream dos = new DataOutputStream(outputStream);
        dos.writeInt(data.length);
        dos.write(data);
        dos.flush();
    }

    private static BufferedImage read(
        InputStream inputStream) throws IOException
    {
        DataInputStream dis = new DataInputStream(inputStream);
        int length = dis.readInt();
        byte data[] = new byte[length];
        dis.read(data);
        ByteArrayInputStream bais = new ByteArrayInputStream(data);
        return ImageIO.read(bais);
    }




    private static InputStream createSlowInputStream(byte data[])
    {
        ByteArrayInputStream bais = new ByteArrayInputStream(data);
        return new InputStream()
        {
            private long counter = 0;
            @Override
            public int read() throws IOException
            {
                counter++;
                if (counter % 100 == 0)
                {
                    System.out.println(
                        "Read " + counter + " of " + data.length + " bytes");
                    try
                    {
                        Thread.sleep(50);
                    }
                    catch (InterruptedException e)
                    {
                        e.printStackTrace();
                    }
                }
                return bais.read();
            }
        };
    }

    private static BufferedImage createDummyImage(String text, int h)
    {
        int w = 100;
        BufferedImage image = 
            new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
        Graphics2D g = image.createGraphics();
        g.setColor(Color.BLACK);
        g.fillRect(0, 0, w, h);
        g.setColor(Color.WHITE);
        g.drawString(text, 20, 20);
        g.dispose();
        return image;
    }


    private static void showImages(BufferedImage ... images)
    {
        SwingUtilities.invokeLater(() -> 
        {
            JFrame f = new JFrame();
            f.getContentPane().setLayout(new GridLayout(1,0));
            for (BufferedImage image : images)
            {
                f.getContentPane().add(new JLabel(new ImageIcon(image)));
            }
            f.pack();
            f.setLocationRelativeTo(null);
            f.setVisible(true);
        });
    }
}

更新

这是基于 haraldK 的答案的一个示例实现。它成功地读取了一系列图像,但也有一些限制:

  • 似乎必须读取比严格必要的更多字节才能提供第一张图像。
  • 它不能加载不同类型的图像(即它不能读取一系列混合的PNG和JPG图像)。
  • 具体来说,对我而言,它只适用于JPG图像。对于PNG或GIF,只读取了第一张图片(至少对我而言...)。

然而,我们在此发布它,以便其他人可以轻松测试它:

import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.GridLayout;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import javax.swing.ImageIcon;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.SwingUtilities;


public class MultipleImagesFromSingleStreamWorking
{
    public static void main(String[] args) throws IOException
    {
        readExample();
    }

    private static void readExample() throws IOException
    {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ImageIO.write(createDummyImage("Image 0", 50), "jpg", baos);
        //ImageIO.write(createDummyImage("Image 1", 60), "png", baos);
        ImageIO.write(createDummyImage("Image 2", 70), "jpg", baos);
        ImageIO.write(createDummyImage("Image 3", 80), "jpg", baos);
        ImageIO.write(createDummyImage("Image 4", 90), "jpg", baos);
        ImageIO.write(createDummyImage("Image 5", 100), "jpg", baos);
        ImageIO.write(createDummyImage("Image 6", 110), "jpg", baos);
        ImageIO.write(createDummyImage("Image 7", 120), "jpg", baos);
        byte data[] = baos.toByteArray();
        InputStream inputStream = createSlowInputStream(data);

        List<BufferedImage> images = readImages(inputStream);
        showImages(images);
    }

    private static List<BufferedImage> readImages(InputStream inputStream)
        throws IOException
    {
        // From https://dev59.com/ELDla4cB1Zd3GeqP2Q-D#53501316
        List<BufferedImage> images = new ArrayList<BufferedImage>();
        try (ImageInputStream in = ImageIO.createImageInputStream(inputStream))
        {
            Iterator<ImageReader> readers = ImageIO.getImageReaders(in);

            if (!readers.hasNext())
            {
                throw new AssertionError("No reader for file " + inputStream);
            }

            ImageReader reader = readers.next();

            reader.setInput(in);

            // It's possible to use reader.getNumImages(true) and a for-loop
            // here.
            // However, for many formats, it is more efficient to just read
            // until there's no more images in the stream.
            try
            {
                int i = 0;
                while (true)
                {
                    BufferedImage image = reader.read(i++);
                    System.out.println("Read " + image);
                    images.add(image);
                }
            }
            catch (IndexOutOfBoundsException expected)
            {
                // We're done
            }

            reader.dispose();
        }
        return images;
    }

    private static InputStream createSlowInputStream(byte data[])
    {
        ByteArrayInputStream bais = new ByteArrayInputStream(data);
        return new InputStream()
        {
            private long counter = 0;
            @Override
            public int read() throws IOException
            {
                counter++;
                if (counter % 100 == 0)
                {
                    System.out.println(
                        "Read " + counter + " of " + data.length + " bytes");
                    try
                    {
                        Thread.sleep(50);
                    }
                    catch (InterruptedException e)
                    {
                        e.printStackTrace();
                    }
                }
                return bais.read();
            }
        };
    }

    private static BufferedImage createDummyImage(String text, int h)
    {
        int w = 100;
        BufferedImage image = 
            new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
        Graphics2D g = image.createGraphics();
        g.setColor(Color.BLACK);
        g.fillRect(0, 0, w, h);
        g.setColor(Color.WHITE);
        g.drawString(text, 20, 20);
        g.dispose();
        return image;
    }


    private static void showImages(List<BufferedImage> images)
    {
        SwingUtilities.invokeLater(() -> 
        {
            JFrame f = new JFrame();
            f.getContentPane().setLayout(new GridLayout(1,0));
            for (BufferedImage image : images)
            {
                f.getContentPane().add(new JLabel(new ImageIcon(image)));
            }
            f.pack();
            f.setLocationRelativeTo(null);
            f.setVisible(true);
        });
    }
}

1
我认为这是正确的(即使有点令人失望)答案,所以我会将其标记为已接受。如果以后发布了一个可行的解决方案,我会更改已接受的答案。 - Eloy Villasclaras
1
@EloyVillasclaras 当然可以!以 length(int) + data[length] 的形式发送数据,这样在接收端就可以读取长度,然后只需将 length 个字节读入数组,并将其传递给 ImageIO 以读取图像。这样可以保持流的开放状态。这也应该非常简单。也许我可以分配一些时间来扩展测试,采用这种解决方案。 - Marco13
@Marco13 我认为这是一个很好的解释,说明了为什么问题中的代码不起作用。但我不同意结论,所以我添加了自己的答案。 :-) - Harald K
1
@haraldK 如果可以使用“多图像容器”(如TIFF),那么这将是一种选择。我们讨论过并且你也提到的length+data[length]的使用似乎对我来说更可行。但也许问题的限制尚未被解决。让我们看看你的评论讨论的结果会是什么。 - Marco13
@Marco13 正如我在另一条评论中提到的那样,接受答案代码之所以有效,是因为使用该实现时,ImageIO会创建一个包装原始流的MemoryCacheImageInputStreamFileCacheImageInputStream。因此,即使JPEG阅读器消耗了比读取图像所需更多的字节,缓存的流也可以被回溯。 - Eloy Villasclaras
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接