存储图形对象是个好主意吗?

44

我正在编写一个Java绘图程序,旨在具有灵活和综合的功能。它源自于我的期末项目,那是我前一天晚上通宵写的。因此,它有很多很多BUG,我正在逐个解决(例如,我只能保存将为空的文件,我的矩形绘制不正确,但我的圆却可以...)。

这次,我一直在试图向我的程序中添加撤销/重做功能。然而,我无法“撤销”已经完成的操作。因此,我想到了一个方法,每次触发mouseReleased事件时,保存BufferedImage的副本。然而,由于某些图片要达到1920x1080的分辨率,我认为这样做并不高效:储存这些副本可能会占用几个G的内存。

我不能简单地用背景颜色重新绘制相同的内容来撤销,原因是我有许多不同的画笔,基于Math.random()进行绘制,并且有许多不同的层(在单个层中)。

然后,我考虑克隆我用于绘制BufferedImageGraphics对象。像这样:

ArrayList<Graphics> revisions = new ArrayList<Graphics>();

@Override
public void mouseReleased(MouseEvent event) {
    Graphics g = image.createGraphics();
    revisions.add(g);
}

我以前没有做过这个,所以我有几个问题:

  • 如果我这样做,像克隆我的BufferedImages,我仍然会浪费无意义的内存吗?
  • 是否一定有其他方法可以做到这一点?
4个回答

46
不,存储一个 Graphics 对象通常不是个好主意。 :-)
原因如下:通常,Graphics 实例的生命周期很短,用于在某种表面上绘制或绘画(通常是 (J)Component 或 BufferedImage)。它保存这些绘图操作的状态,例如颜色,描边,缩放,旋转等。但是,它不保存绘图操作或像素的结果。
由于这个原因,它不能帮助您实现撤销功能。像素属于组件或图像。因此,回滚到“先前”的 Graphics 对象不会将像素修改回先前的状态。
以下是我知道可行的一些方法:
- 使用“命令”链(命令模式)来修改图像。命令模式非常适合撤消/重做(并在 Swing/AWT 中实现在 Action 中)。按顺序呈现所有命令,从原始命令开始。利:每个命令中的状态通常不是很大,允许您在内存中拥有许多步骤的撤消缓冲区。缺点:经过很多操作后,速度变慢... - 对于每个操作,请存储整个 BufferedImage(就像最初所做的那样)。利:易于实现。缺点:您会很快用尽内存。提示:您可以对图像进行序列化,使撤消/重做占用更少的内存,代价是更多的处理时间。 - 上述两者的结合,使用命令模式/链思想,但在合理时使用“快照”(作为 BufferedImage)进行优化渲染。这意味着您不需要为每个新操作从头开始渲染所有内容(更快)。还要将这些快照刷新/序列化到磁盘中,以避免用尽内存(但如果可以,请将它们保留在内存中,以加快速度)。您还可以将命令序列化到磁盘上,以实现几乎无限的撤消。利:做得好时效果很好。缺点:需要一些时间来完成。

提示:对于上述所有内容,您需要使用后台线程(如SwingWorker或类似工具)来更新显示的图像、在后台存储命令/图像等,以保持响应性的用户界面。

祝你好运! :-)


@Zizouz212:首先从AbstractAction类开始(如果需要,可以阅读有关命令模式的内容!)。至于图像序列化,您可以使用ImageIO和无损格式(如BMP、PNG或TIFF)。或者,您可以将BufferedImagebyteint后备数组存储到磁盘上,假设您的程序使用标准化的颜色模型。 - Harald K
哦,我的天啊,我完全忘记了赏金,而我本来是打算把它授予你的。我希望75分可以,但是记录上,我会给予150分。 :) 现在我感到非常难过... - Zizouz212
当你在我的应用程序中真正拯救了我的生命并让我重新拥有希望时,我并不是在开玩笑:D - Zizouz212
1
还有另一种选择。任何两个版本的图像都非常相似。您可以存储两个图像之间的差异,而不是整个图像,类似于无损视频压缩算法。 - Eric J.
1
我不确定这是否会更加计算密集。这可能取决于命令的操作。重放一个简单的线条绘制命令将很快,但是泛洪填充操作会花费更多时间,而对图像的部分应用复杂滤镜则需要更多时间。如果您想支持非常长的撤消队列,重放数十个命令可能比存储“关键帧”(完整图像,可选择无损压缩)每N个撤消步骤并从上一个关键帧应用差异更加耗费资源。不过,您也可以将关键帧的想法与命令重放一起使用。有趣的问题。 - Eric J.
显示剩余6条评论

9
Idea #1,仅存储Graphics对象是不行的。 Graphics不应被视为“持有”某些显示内存,而应被视为访问显示内存区域的句柄。在BufferedImage的情况下,每个Graphics对象总是将是访问相同给定图像内存缓冲区的句柄,因此它们都代表相同的图像。更重要的是,您实际上无法对存储的Graphics进行任何操作:因为他们不存储任何内容,所以根本没有任何方法可以“重新存储”任何内容。
Idea #2,复制BufferedImage是一个更好的想法,但您确实会浪费内存并很快用完它。仅存储受绘制影响的图像部分(例如使用矩形区域)可以帮助减少内存消耗,但仍然需要大量内存。将这些撤消图像缓冲到磁盘可能有所帮助,但这将使您的用户界面变慢且不响应,这是不好的;此外,这会使您的应用程序更加复杂和容易出错
我提出的替代方案是将图像修改存储在列表中,从首次渲染到最后渲染在图像上。撤消操作仅由列表中的修改被移除。
这需要您"实现"图像修改,即创建一个实现单个修改的类,通过提供一个执行实际绘制的void draw(Graphics gfx)方法来实现。
正如您所说,随机修改会带来额外的问题。但是,关键问题在于您使用Math.random()创建随机数。相反,使用固定种子值创建Random来执行每个随机修改,以使(伪)随机数序列在每次调用draw()时相同,即每次绘制具有完全相同的效果。(这就是它们被称为“伪随机”的原因——生成的数字看起来随机,但它们和任何其他函数一样具有确定性。)
与存储技术相比,这种技术的问题不在于内存问题,而在于许多修改可能会使GUI变慢,特别是如果修改计算密集型。为了防止这种情况,最简单的方法是固定适当的可撤销修改列表的最大大小。如果添加新修改会超过此限制,则从列表中删除最旧的修改并将其应用于支持BufferedImage本身。
下面的简单演示应用程序展示了这些内容(以及如何将它们组合在一起)。还包括一个不错的“重做”功能,用于重做已撤消的操作。
package stackoverflow;

import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.image.BufferedImage;
import java.util.LinkedList;
import java.util.Random;
import javax.swing.*;

public final class UndoableDrawDemo
        implements Runnable
{
    public static void main(String[] args) {
        EventQueue.invokeLater(new UndoableDrawDemo()); // execute on EDT
    }

    // holds the list of drawn modifications, rendered back to front
    private final LinkedList<ImageModification> undoable = new LinkedList<>();
    // holds the list of undone modifications for redo, last undone at end
    private final LinkedList<ImageModification> undone = new LinkedList<>();

    // maximum # of undoable modifications
    private static final int MAX_UNDO_COUNT = 4;

    private BufferedImage image;

    public UndoableDrawDemo() {
        image = new BufferedImage(600, 600, BufferedImage.TYPE_INT_RGB);
    }

    public void run() {
        // create display area
        final JPanel drawPanel = new JPanel() {
            @Override
            public void paintComponent(Graphics gfx) {
                super.paintComponent(gfx);

                // display backing image
                gfx.drawImage(image, 0, 0, null);

                // and render all undoable modification
                for (ImageModification action: undoable) {
                    action.draw(gfx, image.getWidth(), image.getHeight());
                }
            }

            @Override
            public Dimension getPreferredSize() {
                return new Dimension(image.getWidth(), image.getHeight());
            }
        };

        // create buttons for drawing new stuff, undoing and redoing it
        JButton drawButton = new JButton("Draw");
        JButton undoButton = new JButton("Undo");
        JButton redoButton = new JButton("Redo");

        drawButton.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                // maximum number of undo's reached?
                if (undoable.size() == MAX_UNDO_COUNT) {
                    // remove oldest undoable action and apply it to backing image
                    ImageModification first = undoable.removeFirst();

                    Graphics imageGfx = image.getGraphics();
                    first.draw(imageGfx, image.getWidth(), image.getHeight());
                    imageGfx.dispose();
                }

                // add new modification
                undoable.addLast(new ExampleRandomModification());

                // we shouldn't "redo" the undone actions
                undone.clear();

                drawPanel.repaint();
            }
        });

        undoButton.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                if (!undoable.isEmpty()) {
                    // remove last drawn modification, and append it to undone list
                    ImageModification lastDrawn = undoable.removeLast();
                    undone.addLast(lastDrawn);

                    drawPanel.repaint();
                }
            }
        });

        redoButton.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                if (!undone.isEmpty()) {
                    // remove last undone modification, and append it to drawn list again
                    ImageModification lastUndone = undone.removeLast();
                    undoable.addLast(lastUndone);

                    drawPanel.repaint();
                }
            }
        });

        JPanel buttonPanel = new JPanel(new FlowLayout());
        buttonPanel.add(drawButton);
        buttonPanel.add(undoButton);
        buttonPanel.add(redoButton);

        // create frame, add all content, and open it
        JFrame frame = new JFrame("Undoable Draw Demo");
        frame.getContentPane().add(drawPanel);
        frame.getContentPane().add(buttonPanel, BorderLayout.NORTH);
        frame.pack();
        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    }

    //--- draw actions ---

    // provides the seeds for the random modifications -- not for drawing itself
    private static final Random SEEDS = new Random();

    // interface for draw modifications
    private interface ImageModification
    {
        void draw(Graphics gfx, int width, int height);
    }

    // example random modification, draws bunch of random lines in random color
    private static class ExampleRandomModification implements ImageModification
    {
        private final long seed;

        public ExampleRandomModification() {
            // create some random seed for this modification
            this.seed = SEEDS.nextLong();
        }

        @Override
        public void draw(Graphics gfx, int width, int height) {
            // create a new pseudo-random number generator with our seed...
            Random random = new Random(seed);

            // so that the random numbers generated are the same each time.
            gfx.setColor(new Color(
                    random.nextInt(256), random.nextInt(256), random.nextInt(256)));

            for (int i = 0; i < 16; i++) {
                gfx.drawLine(
                        random.nextInt(width), random.nextInt(height),
                        random.nextInt(width), random.nextInt(height));
            }
        }
    }
}

2
你可以在每 X 步骤记录完整图像,并存储采取的行动,而不是使用最大撤消深度。这将允许快速绘画(因为您只需重播自上次快照以来的操作),同时又能实现无限撤消。 - tucuxi
1
你实际上为此实现了一个演示应用,干得好!不过,你可能可以使用Swing的“Edit”和“UndoManager”类来完成其中的一些工作。 :-) - Harald K

7
大多数游戏(或程序)仅保存必要的部分,你也应该这样做。
一个矩形可以用宽度、高度、背景颜色、描边、轮廓等来表示。因此,你只需保存这些参数而不是实际的矩形。例如,“矩形颜色:红色 宽度:100 高度:100”。
对于程序中的随机方面(例如画笔上的随机颜色),你可以保存种子或结果。例如,“随机种子:1023920”。
如果程序允许用户导入图像,则应复制并保存图像。
滤镜和效果(缩放/变换/发光)都可以像形状一样用参数表示。例如,“缩放比例:2”、“旋转角度:30”。
因此,你可以将所有这些参数保存在列表中,当需要撤销时,你可以将参数标记为已删除(但实际上不要删除它们,因为你还想能够重做)。然后,你可以擦除整个画布,并根据减去被标记为删除的参数的参数重新创建图像。
对于线条之类的东西,你可以只在列表中存储它们的位置。

我喜欢这个答案,因为它详细阐述了如何在每个绘图命令中存储参数。这基本上是我描述的“命令链”相同的想法。 - Harald K

4
您需要尝试压缩您的图片(使用PNG是一个好的开始,它有一些很好的滤镜以及zlib压缩,这真的有很大帮助)。我认为最好的方法是:
  • 在修改图像之前复制一份
  • 修改它
  • 将副本与新修改的图像进行比较
  • 对于每个您没有更改的像素,将该像素变为黑色透明像素。
这样应该在PNG中压缩得非常好。尝试使用黑白并查看是否有差异(我认为不会有,但请确保将RGB值设置为相同的内容,而不仅仅是alpha值,这样会使压缩效果更好)。
裁剪图像到更改部分可能会获得更好的性能,但考虑到压缩率(以及您现在必须保存和记住偏移量),我不确定可以获得多少收益。
然后,由于您有一个Alpha通道,如果他们撤消操作,您只需将撤消图像放回当前图像的顶部即可。

2
在压缩之前,您可以将图像与先前的图像进行异或运算,以消除所有未发生变化的像素。 - Peter Tillemans

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