Java——高级Swing和图形化编程(二)

高级Swing和图形化编程(二)

3、高级AWT

3.1、绘图操作流程

在最初的JDK1.0中,用来绘制形状的是一种非常简单的机制,即选择颜色和画图的模式,并调用Graphics类的各种方法,比如drawRect或者fillOval。而Java2DAPI支持更多的功能:

  • 可以很容易地绘制各式各样的形状。
  • 可以控制绘制形状的笔画,即控制跟踪形状边界的绘图笔。
  • 可以用单色、变化的色调和重复的模式来填充各种形状。
  • 可以使用变换法,对各种形状进行移动、缩放、旋转和拉伸。
  • 可以对形状进行剪切,将其限制在任意的区域内。
  • 可以选择各种组合规则,来描述如何将新形状的像素与现有的像素组合起来。
  • 可以提供绘制图形提示,以便在速度与绘图质量之间实现平衡。

如果要绘制一个形状,可以按照如下步骤操作:

  1. 获得一个Graphics2D类的对象,该类是Graphics类的子类。自Java SE 1.2以来,paint和paintComponent等方法就能够自动地接收一个Graphics2D类的对象,这时可以直接使用如下的转型:
public void paintComponent(Graphics g) {
	Graphics2D g2 = (Graphics2D)g;
	...
}
  1. 使用setRenderingHints方法来绘制提示,它提供了速度与绘图质量之间的一种平衡:
RenderingHints hints = ...;
g2.setRenderingHints(hints);
  1. 使用setStroke方法来设置笔画,笔画用于绘制形状的边框。可以选择边框的粗细和线段的虚实:
Stroke stroke = ...;
g2.setStroke(stroke);
  1. 使用setPaint方法来设置着色法,着色法用于填充诸如笔画路径或者形状内部等区域的颜色。可以创建单色、渐变色或者平铺的填充模式:
Paint paint = ...;
g2.setPaint(paint);
  1. 使用clip方法来设置剪切区域:
Shape clip = ...;
g2.clip(clip);
  1. 使用transform方法设置一个从用户空间到设备空间的变换方式。如果使用变换方式比使用像素坐标更容易定义在定制坐标系统中的形状,那么就可以使用变换方式:
AffineTransform transform = ...;
g2.transform(transform);
  1. 使用setComposite方法设置一个组合规则,用来描述如何将新像素与现有的像素组合起来:
Composite composite = ...;
g2.setComposite(composite);
  1. 建立一个形状,Java2D API提供了用来组合各种形状的许多形状对象和方法:
Shape shape = ...;
  1. 绘制或填充该形状。如果要绘制该形状,那么它的边框就会用笔画画出来。如果要填充该形状,那么它的内部就会被着色:
g2.draw(shape);
g2.fill(shape);

当然,在许多实际的环境中,并不需要采用所有这些操作步骤。Java2D图形上下文中有合理的默认设置。只有当你确实想要改变设置时,再去修改这些默认设置。

各种不同的set方法只是用于设置2D图形上下文的状态,它们并不进行任何实际的绘图操作。同样,在构建shape对象时,也不进行任何绘图操作。只有在调用dra或者fill方法时,才会绘制出图形的形状,而就在此刻,这个新的图形由绘图操作流程计算出来:

在这里插入图片描述

在绘图流程中,需要以下这些操作步骤来绘制一个形状:

  1. 用笔画画出形状的线条;
  2. 对形状进行变换操作;
  3. 对形状进行剪切。如果形状与剪切区域之间没有任何相交的地方,那么就不用执行该操作;
  4. 对剪切后的形状进行填充;
  5. 把填充后的形状与已有的形状进行组合圆形是已有像素部分,杯子的形状加在了它的上面);

java.awt.Graphics2D

  • void draw(Shape s):用当前的笔画来绘制给定形状的边框
  • void fill(Shape s):用当前的着色方案来填充给定形状的内部

3.2、形状

下面是Graphics类中绘制形状的若干方法:

  • drawLine
  • drawRectangle
  • drawRoundRect
  • draw3DRect
  • drawPolygon
  • drawPolyline
  • draw0val
  • drawArc

它们还有对应的fill方法,这些方法从JDK1.0起就被纳入到Graphics类中了。Java2D API使用了一套完全不同的面向对象的处理方法,即不再使用方法,而是使用下面的这些类:

  • Line2D
  • Rectangle2D
  • RoundRectangle2D
  • Ellipse2D
  • Arc2D
  • QuadCurve2D
  • CubicCurve2D
  • GeneralPath

这些类全部都实现了Shape接口。

1)、形状类层次结构

Line2D、Rectangle2D、RoundRectangle2D、Ellipse2D和Arc2D等这些类对应于drawLine、drawRectangle、
drawRoundRect、drawOval和drawArc等方法。

Java2DAPI提供了两个补充类,即二次曲线类和三次曲线类。Java2DAPI中没有任何Polygonz2D类。相反,它用GeneralPath类来描述由线条、二次曲线、三次曲线构成的线条路径。可以使用GeneralPath来描述一个多边形;

如果要绘制一个形状,首先要创建一个实现了Shape接口的类的对象,然后调用Graphics2D类的draw方法。

Rectangle2D、RoundRectangle2D、Ellipse2D和Arc2D这些类都是从一个公共超类RectangularShape继承而来的。虽然椭圆形和弧形都不是矩形,但是它们都有一个矩形的边界框。

从内部来讲,所有的图形类使用的都是float类型的坐标,因为float类型的数占用较少的存储空间,而且它们有足够高的几何计算精度。然而,Java编程语言使得对float
类型的数的操作要稍微复杂些。由于这个原因,图形类的大多数方法使用的都是double类型的参数和返回值。只有在创建一个2D对象的时候,才需要选择究竟是使用带有flot类型坐标的构造器,还是使用带有double类型坐标的构造器。例如:

var floatRect = new Rectangle2D.Float(5F,10F,7.5F,15F);
var doubleRect = new Rectangle2D.Double(5,10,7.5,15);

Xxx2D.F1oat和Xxx2D.Double两个类都是Xxx2D类的子类,在对象被构建之后,再记住其确切的子类型实质上已经没有任何额外的好处了,因此可以将刚被构建的对象存储为一个超类变量,正如上面代码示例中所阐释的那样。

最后,还有一个Poit2D类,它用x和y坐标来描述一个点。点对于定义形状非常有用,不过它们本身并不是形状。

下图显示了各个形状类之间的关系。不过图中省略了Double和F1oat子类,并且来自以前的2D类库的遗留类用灰色的填充色标识。

在这里插入图片描述

2)、使用形状类

如果要建立一个RoundRectangle2D形状,应该设定左上角、宽度、高度及应该变成圆角的边角区的x和y的坐标尺寸。例如,调用下面的方法:

var r = new RoundRectangle2D.Double(150,200,100,50,20,20);

便产生了一个带圆角的矩形,每个角的圆半径为20。
在这里插入图片描述

如果要建立一个弧形,首先应该设定边界框,接着设定它的起始角度和弧形跨越的角度,并且设定弧形闭合的类型,即Arc2D.OPENArc2D.PIE或者Arc2D.CHORD这几种类型中的一个:

var a = new Arc2D(x, y, width, height, startAngle, arcAngle, closureType);

在这里插入图片描述

在这里插入图片描述

如果要构建二次曲线和三次曲线,需要给出两个端点和控制点的坐标。例如:

var q = new QuadCurve2D.Double(startX, startY, controlX, controlY, endX, endY);

在这里插入图片描述

var c = new CubicCurve2D.Double(startX, startY, control1X, control1Y, control2X, control2Y, endX, endY);

在这里插入图片描述

可以建立线段、二次曲线和三次曲线的任意序列,并把它们存放到一个GeneralPath对象中去。可以用moveTo方法来指定路径的第一个坐标,例如:

var path = new GeneralPath();
path.moveTo(10,20);

然后,可以通过调用lineToquadTocurveTo三种方法之一来扩展路径,这些方法分别用线条、二次曲线或者三次曲线来扩展路径。如果要调用lineTo方法,需要提供它的端点。

而对两个曲线方法的调用,应该先提供控制点,然后提供端点。例如:

path.lineTo(20,30);
path.curveTo(controllX,controllY,control2X,control2Y,endX,endY);

可以调用closePath方法来闭合路径,它能够绘制一条回到路径起始点的线条。

如果要绘制一个多边形,只需调用moveTo方法,以到达第一个拐角点,然后反复调用lineTo方法,以便到达其他的拐角点。最后调用closePath方法来闭合多边形。

public class ShapeTest {
    public static void main(String[] args) {
        EventQueue.invokeLater(() -> {
            JFrame frame = new ShapeTestFrame();
            frame.setTitle("ShapeTest");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.setVisible(true);
        });
    }
}

class ShapeTestFrame extends JFrame {
    public ShapeTestFrame() {
        ShapeComponent comp = new ShapeComponent();
        add(comp, BorderLayout.CENTER);
        JComboBox<ShapeMaker> comboBox = new JComboBox<>();
        comboBox.addItem(new LineMaker());
        comboBox.addItem(new RectangleMaker());
        comboBox.addItem(new RoundRectangleMaker());
        comboBox.addItem(new EllipseMaker());
        comboBox.addItem(new ArcMaker());
        comboBox.addItem(new PolygonMaker());
        comboBox.addItem(new QuadCurveMaker());
        comboBox.addItem(new CubicCurveMaker());
        comboBox.addActionListener(event -> {
            ShapeMaker shapeMaker = comboBox.getItemAt(comboBox.getSelectedIndex());
            comp.setShapeMaker(shapeMaker);
        });
        add(comboBox, BorderLayout.NORTH);
        comp.setShapeMaker((ShapeMaker)comboBox.getItemAt(0));
        pack();
    }
}

class ShapeComponent extends JComponent {
    private static final Dimension PREFERRED_SIZE = new Dimension(300, 200);
    private Point2D[] points;
    private static Random generator = new Random();
    private static int SIZE = 10;
    private int current;
    private ShapeMaker shapeMaker;

    public ShapeComponent() {
        addMouseListener(new MouseAdapter() {
            @Override
            public void mousePressed(MouseEvent e) {
                Point p = e.getPoint();
                for (int i = 0; i < points.length; i++) {
                    double x = points[i].getX() - SIZE / 2;
                    double y = points[i].getY() - SIZE / 2;
                    Rectangle2D r = new Rectangle2D.Double(x, y, SIZE, SIZE);
                    if (r.contains(p)) {
                        current = i;
                        return;
                    }
                }
            }

            @Override
            public void mouseReleased(MouseEvent e) {
                current = -1;
            }
        });

        addMouseListener(new MouseAdapter() {
            @Override
            public void mouseDragged(MouseEvent e) {
                if (current == -1) {
                    return;
                }
                points[current] = e.getPoint();
                repaint();
            }
        });
        current = -1;
    }

    public void setShapeMaker(ShapeMaker aShapeMaker) {
        shapeMaker = aShapeMaker;
        int n = shapeMaker.getPointCount();
        points = new Point2D[n];
        for (int i = 0; i < n; i++) {
            double x = generator.nextDouble() * getWidth();
            double y = generator.nextDouble() * getHeight();
            points[i] = new Point2D.Double(x, y);
        }
        repaint();
    }

    public void paintComponent(Graphics g) {
        if (points == null) {
            return;
        }
        Graphics2D g2 = (Graphics2D) g;
        for (int i = 0; i < points.length; i++) {
            double x = points[i].getX() - SIZE / 2;
            double y = points[i].getY() - SIZE / 2;
            g2.fill(new Rectangle2D.Double(x, y, SIZE, SIZE));
        }
        g2.draw(shapeMaker.makeShape(points));
    }

    public Dimension getPreferredSize() {
        return PREFERRED_SIZE;
    }

}

abstract class ShapeMaker {
    private int pointCount;

    public ShapeMaker(int pointCount) {
        this.pointCount = pointCount;
    }

    public int getPointCount() {
        return pointCount;
    }

    public abstract Shape makeShape(Point2D[] p);

    public String toString() {
        return getClass().getName();
    }
}

class LineMaker extends ShapeMaker {

    public LineMaker() {
        super(2);
    }

    @Override
    public Shape makeShape(Point2D[] p) {
        return new Line2D.Double(p[0], p[1]);
    }
}

class RectangleMaker extends ShapeMaker {

    public RectangleMaker() {
        super(2);
    }

    @Override
    public Shape makeShape(Point2D[] p) {
        Rectangle2D s = new Rectangle2D.Double();
        s.setFrameFromDiagonal(p[0], p[1]);
        return s;
    }
}

class RoundRectangleMaker extends ShapeMaker {
    public RoundRectangleMaker() {
        super(2);
    }

    @Override
    public Shape makeShape(Point2D[] p) {
        RoundRectangle2D s = new RoundRectangle2D.Double(0, 0, 0, 0, 20, 20);
        s.setFrameFromDiagonal(p[0], p[1]);
        return s;
    }
}


class EllipseMaker extends ShapeMaker {
    public EllipseMaker() {
        super(2);
    }

    @Override
    public Shape makeShape(Point2D[] p) {
        Ellipse2D s = new Ellipse2D.Double();
        s.setFrameFromDiagonal(p[0], p[1]);
        return s;
    }
}


class ArcMaker extends ShapeMaker {
    public ArcMaker() {
        super(4);
    }

    @Override
    public Shape makeShape(Point2D[] p) {
        double centerX = (p[0].getX() + p[1].getX()) / 2;
        double centerY = (p[0].getY() + p[1].getY()) / 2;
        double width = Math.abs(p[1].getX() - p[0].getX());
        double height = Math.abs(p[1].getY() - p[0].getY());

        double skewedStartAngle = Math.toDegrees(Math.atan2(-(p[2].getY() - centerY) * width, (p[2].getX() - centerX) * height));
        double skewedEndAngle = Math.toDegrees(Math.atan2(-(p[3].getY() - centerY) * width, (p[3].getX() - centerX) * height));
        double skewedAngleDifference = skewedEndAngle - skewedStartAngle;
        if (skewedStartAngle < 0) {
            skewedStartAngle += 360;
        }
        if (skewedAngleDifference < 0) {
            skewedAngleDifference += 360;
        }
        Arc2D s = new Arc2D.Double(0, 0, 0, 0, skewedStartAngle, skewedAngleDifference, Arc2D.OPEN);
        s.setFrameFromDiagonal(p[0], p[1]);

        GeneralPath g = new GeneralPath();
        g.append(s, false);
        Rectangle2D r = new Rectangle2D.Double();
        r.setFrameFromDiagonal(p[0], p[1]);
        g.append(r, false);
        Point2D center = new Point2D.Double(centerX, centerY);
        g.append(new Line2D.Double(center, p[2]), false);
        g.append(new Line2D.Double(center, p[3]), false);
        return g;
    }
}

class PolygonMaker extends ShapeMaker {
    public PolygonMaker() {
        super(6);
    }

    @Override
    public Shape makeShape(Point2D[] p) {
        GeneralPath s = new GeneralPath();
        s.moveTo((float)p[0].getX(), (float)p[0].getY());
        for (int i = 1; i < p.length; i++) {
            s.lineTo((float)p[i].getX(), (float)p[i].getY());
        }
        s.closePath();
        return s;
    }
}

class QuadCurveMaker extends ShapeMaker {
    public QuadCurveMaker() {
        super(3);
    }

    @Override
    public Shape makeShape(Point2D[] p) {
        return new QuadCurve2D.Double(p[0].getX(), p[0].getY(), p[1].getX(), p[1].getY(), p[2].getX(), p[2].getY());
    }
}

class CubicCurveMaker extends ShapeMaker {
    public CubicCurveMaker() {
        super(4);
    }

    @Override
    public Shape makeShape(Point2D[] p) {
        return new CubicCurve2D.Double(p[0].getX(), p[0].getY(), p[1].getX(), p[1].getY(),
                p[2].getX(), p[2].getY(), p[3].getX(), p[3].getY());
    }
}

在这里插入图片描述

java.awt.geom.RoundRectangle2D.Double

  • RoundRectangle2D.Double(double x, double y, double width, double height, double arcWidth, double arcHeight):用给定的矩形边框和弧形尺寸构建一个圆角矩形

java.awt.geom.Arc2D.Double

  • Arc2D.Double(double x, double y, double w, double h, double startAngle, double arcAngle, int type):用给定的矩形边框、起始角度、弧形角度和弧形类型构建一个弧形

java.awt.geom.QuadCurve2D.Double

  • QuadCurve2D.Double(double x1, double y1, double ctrlx, double ctrly, double x2, double y2):用起始点、控制点和结束点构建一条二次曲线

java.awt.geom.CubicCurve2D.Double

  • CubicCurve2D.Double(double x1, double y1, double ctrlx1, double ctrly1, double ctrlx2, double ctrly2, double x2, double y2):用起始点、两个控制点和结束点构建一条三次曲线

java.awt.geom.GeneralPath

  • GeneralPath():构建一条空的普通路径

java.awt.geom.Path2D.Float

  • void moveTo(float x, float y):使(x, y)成为当前点,也就是下一个线段的起始点
  • void lineTo(float x, float y)
  • void quadTo(float ctrlx, float ctrly, float x, float y)
  • void curveTo(float ctrl1x, float ctrl1y, float ctrl2x, float ctrl2y, float x, float y):从当前点绘制一个线条、二次曲线或者三次曲线到达结束点(x,y),并且使该结束点
    成为当前点

java.awt.geom.Path2D

  • void append(Shape s, boolean connect):将给定形状的边框添加到普通路径中去。如果布尔型变量connect的值是true,那么该普通路径的当前点与添加进来的形状的起始点之间用一条直线连接起来
  • void closePath():从当前点到路径的第一点之间绘制一条直线,从而使路径闭合

3.3、区域

有时候,使用各种不同形状的区域,比如矩形、多边形和椭圆形来建立形状,可能会更加容易描述。Java2DAPI支持四种区域几何作图(constructive area geometry)操作,用于将两个区域组合成一个区域:

  • add:组合区域包含了所有位于第一个区域或第二个区域内的点。
  • subtract:组合区域包含了所有位于第一个区域内的点,但是不包括任何位于第二个区域内的点。
  • intersect:组合区域包含了所有既位于第一个区域内,又位于第二个区域内的点。
  • exclusive0r:组合区域包含了所有位于第一个区域内,或者是位于第二个区域内的所有点,但是这些点不能同时位于两个区域内。

在这里插入图片描述

如果要构建一个复杂的区域,可以使用下面的方法先创建一个默认的区域对象:

var a = new Area();

然后,将该区域和其他的形状组合起来:

a.add(new Rectangle2D.Double(...));
a.subtract(path);
...

Area类实现了Shape接口。可以用draw方法勾勒出该区域的边界,或者使用Graphics22D类的fill方法给区域的内部着色。

java.awt.geom.Area

  • void add(Area other)
  • void subtract(Area other)
  • void intersect(Area other)
  • void exclusiveOr(Area other):对该区域和othr所代表的另一个区域执行区域几何作图操作,并且将该区域设置为执行后的结果

3.4、笔画

Graphics2D类的draw操作通过使用当前选定的笔画来绘制一个形状的边界。在默认的情况下,笔画是一条宽度为一个像素的实线。可以通过调用setStroke方法来选定不同的笔画,此时要提供一个实现了Stroke接口的类的对象。

Java2DAPI只定义了一个这样的类,即BasicStroke类。你可以构建任意粗细的笔画。例如,下面的方法就绘制了一条粗细为10个像素的线条:

g2.setStroke(new BasicStroke(10.0F));
g2.draw(new Line2D.Double(...))

当一个笔画的粗细大于一个像素的宽度时,笔画的末端可采用不同的样式。

端头样式有下面三种:

  • 平头样式(butt cap)在笔画的末端处就结束了;
  • 圆头样式(round cap)在笔画的末端处加了一个半圆;
  • 方头样式(square cap)在笔画的末端处加了半个方块。

在这里插入图片描述

当两个较粗的笔画相遇时,有三种笔画的连接样式可供选择:

  • 斜连接(bevel join),用一条直线将两个笔画连接起来,该直线与两个笔画之间的夹角的平分线相垂直。
  • 圆连接(round join),延长了每个笔画,并使其带有一个圆头。
  • 斜尖连接(miter join),通过增加一个尖峰,从而同时延长了两个笔画。

在这里插入图片描述

斜尖连接不适合小角度连接的线条。如果两条线连接后的角度小于斜尖连接的最小角度,那么应该使用斜连接。斜连接的使用,可以防止出现太长的尖峰。默认情况下,斜尖连接的最小角度是10度。

可以在BasicStroke构造器中设定这些选择,例如:

g2.setStroke(new BasicStroke(10.0F,BasicStroke.CAP ROUND,BasicStroke.JOIN ROUND));
g2.setStroke(new BasicStroke(10.0F,BasicStroke.CAP BUTT,BasicStroke.JOIN MITER,
15.0F /miter limit */)

最后,通过设置一个虚线模式,可以指定需要使用的虚线。

public class StrokeTest {
    public static void main(String[] args) {
        EventQueue.invokeLater(() -> {
            JFrame frame = new StrokeTestFrame();
            frame.setTitle("StrokeTest");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.setVisible(true);
        });
    }
}

class StrokeTestFrame extends JFrame {
    private StrokeComponent canvas;
    private JPanel buttonPanel;

    public StrokeTestFrame() {
        canvas = new StrokeComponent();
        add(canvas, BorderLayout.CENTER);

        buttonPanel = new JPanel();
        buttonPanel.setLayout(new GridLayout(3, 3));
        add(buttonPanel, BorderLayout.NORTH);

        ButtonGroup group1 = new ButtonGroup();
        makeCapButton("Butt Cap", BasicStroke.CAP_BUTT, group1);
        makeCapButton("Round Cap", BasicStroke.CAP_ROUND, group1);
        makeCapButton("Square Cap", BasicStroke.CAP_SQUARE, group1);

        ButtonGroup group2 = new ButtonGroup();
        makeJoinButton("Miter Join", BasicStroke.JOIN_MITER, group2);
        makeJoinButton("Bevel Join", BasicStroke.JOIN_BEVEL, group2);
        makeJoinButton("Round Join", BasicStroke.JOIN_ROUND, group2);

        ButtonGroup group3 = new ButtonGroup();
        makeDashButton("Solid Line", false, group3);
        makeDashButton("Dashed Line", true, group3);
    }

    private void makeCapButton(String label, final int style, ButtonGroup group) {
        boolean selected = group.getButtonCount() == 0;
        JRadioButton button = new JRadioButton(label, selected);
        buttonPanel.add(button);
        group.add(button);
        button.addActionListener(event -> canvas.setCap(style));
        pack();
    }

    private void makeJoinButton(String label, final int style, ButtonGroup group) {
        boolean selected = group.getButtonCount() == 0;
        JRadioButton button = new JRadioButton(label, selected);
        buttonPanel.add(button);
        group.add(button);
        button.addActionListener(event -> canvas.setJoin(style));
    }

    private void makeDashButton(String label, final boolean style, ButtonGroup group) {
        boolean selected = group.getButtonCount() == 0;
        JRadioButton button = new JRadioButton(label, selected);
        buttonPanel.add(button);
        group.add(button);
        button.addActionListener(event -> canvas.setDash(style));
    }
}

class StrokeComponent extends JComponent {
    private static final Dimension PREFERRED_SIZE = new Dimension(400, 400);
    private static int SIZE = 10;

    private Point2D[] points;
    private int current;
    private float width;
    private int cap;
    private int join;
    private boolean dash;

    public StrokeComponent() {
        addMouseListener(new MouseAdapter() {
            @Override
            public void mousePressed(MouseEvent e) {
                Point p = e.getPoint();
                for (int i = 0; i < points.length; i++) {
                    double x = points[i].getX() - SIZE / 2;
                    double y = points[i].getY() - SIZE / 2;
                    Rectangle2D r = new Rectangle2D.Double(x, y, SIZE, SIZE);
                    if (r.contains(p)) {
                        current = i;
                        return;
                    }
                }
            }

            @Override
            public void mouseReleased(MouseEvent e) {
                if (current == -1) {
                    return;
                }
                points[current] = e.getPoint();
                repaint();
            }
        });

        points = new Point2D[3];
        points[0] = new Point2D.Double(200, 100);
        points[1] = new Point2D.Double(100, 200);
        points[2] = new Point2D.Double(200, 200);
        current = -1;
        width = 8.0f;
    }

    @Override
    protected void paintComponent(Graphics g) {
        Graphics2D g2 = (Graphics2D) g;
        GeneralPath path = new GeneralPath();
        path.moveTo((float)points[0].getX(), (float)points[0].getY());
        for (int i = 1; i < points.length; i++) {
            path.lineTo((float)points[i].getX(), (float)points[i].getY());
        }
        BasicStroke stroke;
        if (dash) {
            float miterLimit = 10.0f;
            float[] dashPattern = {10f, 10f, 10f, 10f, 10f, 10f, 30f, 10f, 30f, 10f, 30f, 10f, 10f, 10f, 10f, 10f, 10f, 30f};
            float dashPhase = 0;
            stroke = new BasicStroke(width, cap, join, miterLimit, dashPattern, dashPhase);
        } else {
            stroke = new BasicStroke(width, cap, join);
            g2.setStroke(stroke);
            g2.draw(path);
        }
    }

    public void setJoin(int j) {
        join = j;
        repaint();
    }

    public void setCap(int c) {
        cap = c;
        repaint();
    }

    public void setDash(boolean d) {
        dash = d;
        repaint();
    }

    public Dimension getPreferredSize() {
        return PREFERRED_SIZE;
    }
}

在这里插入图片描述

java.awt.Graphics2D

  • void setStroke(Stroke s):将该图形上下文的笔画设置为实现了Stroke接口的给定对象

java.awt.BasicStroke

  • BasicStroke(float width)
  • BasicStroke(float width, int cap, int join)
  • BasicStroke(float width, int cap, int join, float miterlimit)
  • BasicStroke(float width, int cap, int join, float miterlimit, float[] dash, float dashPhase):用给定的属性构建一个画笔对象
    • width:画笔的宽度
    • cap:端头样式,它是CAP_BUTT、CAP_ROUND和CAP_SQUARE三种样式中的一个
    • join:连接样式,它是JOIN_BEVEL、JOIN_MITER和JOIN_ROUND三种样式中的一个
    • miterlimit:用度数表示的角度,如果小于这个角度,斜尖连接将呈现为斜连接
    • dash:虚线笔画的填充部分和空白部分交替出现的一组长度
    • dashPhase:虚线模式的“相位”;位于笔画起始点前面的这段长度被假设为已经应用了该虚线模式

3.5、着色

当填充一个形状时,该形状的内部就上了颜色。使用setPaint方法,可以把颜色的样式设定为一个实现了Paint接口的类的对象。Java2DAPI提供了三个这样的类:

  • Color类实现了Paint接口。如果要用单色填充形状,只需要用Color对象调用setPaint方法即可,例如:g2.setPaint(Color.red);
  • GradientPaint类通过在两个给定的颜色值之间进行渐变,从而改变使用的颜色
  • TexturePaint类用一个图像重复地对一个区域进行着色

渐变着色:
在这里插入图片描述

纹理着色:
在这里插入图片描述

可以通过指定两个点以及在这两个点上想使用的颜色来构建一个GradientPaint对象,即:

g2.setPaint(new GradientPaint(pl,Color.RED,p2,Color.YELLOW))

上面语句将沿着连接两个点之间的直线的方向对颜色进行渐变,而沿着与该连接线垂直方向上的线条颜色则是不变的。超过线条端点的各个点被赋予端点上的颜色。

另外,如果调用GradientPaint构造器时cyclic参数的值为true,即:

g2.setPaint(new GradientPaint(p1,Color.RED,p2,Color.YELLOW,true));

那么颜色将循环变换,并且在端点之外仍然保持这种变换。

如果要构建一个TexturePaint对象,需要指定一个BufferedImage和一个锚位矩形。

g2.setPaint(new TexturePaint(bufferedImage,anchorRectangle));
bufferedImage ImageI0.read(new File("blue-ball.gif"));

锚位矩形在x和y方向上将不断地重复延伸,使之平铺到整个坐标平面。图像可以伸缩,以便纳入该锚位,然后复制到每一个平铺显示区中。

java.awt.Graphics2D

  • void setPaint(Paint s):将图形上下文的着色设置为实现了Paint接口的给定对象

java.awt.GradientPaint

  • GradientPaint(float x1, float y1, Color color1, float x2, float y2, Color color2)
  • GradientPaint(float x1, float y1, Color color1, float x2, float y2, Color color2, boolean cyclic)
  • GradientPaint(Point2D p1, Color color1, Point2D p2, Color color2)
  • GradientPaint(Point2D p1, Color color1, Point2D p2, Color color2, boolean cyclic):构建一个渐变着色的对象,以便用颜色来填充各个形状,其中,起始点的颜色为
    color1,结束点的颜色为colo2,而两个点之间的颜色则是以线性的方式渐变。沿着连接起始点和结束点之间的线条相垂直的方向上的线条颜色是恒定不变的。在默认的情况下,渐变着色不是循环变换的。也就是说,起始点和结束点之外的各个点的颜色是分别与起始点和结束点的颜色相同的。如果渐变着色是循环的,那么颜色是连续变换的,首先返回到起始点的颜色,然后在两个方向上无限地重复。

java.awt.TexturePaint

  • TexturePaint(BufferedImage texture, Rectangle2D anchor):建立纹理着色对象。锚位矩形定义了色的平铺空间,该矩形在x和y方向上不断地重复延伸,纹理图像则被缩放,以便填充每个平铺空间。

3.6、坐标变换

Graphics2D类的scale方法可以将图形上下文中的坐标变换设置为一个比例变换。这种变换能够将用户坐标(用户设定的单元)转换成设备坐标(pixl,即像素)。

在这里插入图片描述

坐标变换在实际应用中非常有用,程序员可以使用方便的坐标值进行各种操作,图形上下文则负责执行将坐标值变换成像素的复杂工作。

这里有四种基本的变换:

  • 比例缩放:放大和缩小从一个固定点出发的所有距离。
  • 旋转:环绕着一个固定中心旋转所有点。
  • 平移:将所有的点移动一个固定量。
  • 切变:使一个线条固定不变,再按照与该固定线条之间的距离,成比例地将与该线条平行的各个线条“滑动”一个距离量。

在这里插入图片描述

Graphics2D类的scalerotatetranslateshear等方法用以将图形上下文中的坐标变换设置成为以上这些基本变换中的一种。

可以组合不同的变换操作。例如,你可能想对图形进行旋转和两倍尺寸放大的操作,这时,可以同时提供旋转和比例缩放的变换:

g2.rotate(angle);
g2.scale(2,2)
g2.draw(...)

如果对矩阵论有所了解,那么就会知道所有操作(诸如旋转、平移、缩放、切变)和由这些操作组合起来的操作都能够以如下矩阵变换的形式表示出来:
在这里插入图片描述

这种变换称为仿射变换(affine transformation)。Java2DAPI中的AffineTransform类就是用于描述这种变换的。如果你知道某个特定变换矩阵的组成元素,就可以用下面的方法直接构造它:

var t = new AffineTransform(a,b,c,d,e,f);

另外,工厂方法getRotateInstancegetScaleInstancegetTranslateInstancegetShearInstance能够构建出表示相应变换类型的矩阵。例如,调用下面的方法:

t AffineTransform.getScaleInstance(2.0F,0.5F);

将返回一个与下面这个矩阵相一致的变换。

2	0	0
0  0.5	0
0 	0	1

最后,实例方法setToRotationsetToScalesetToTranslationsetToShear用于将变换对象设置为一个新的类型。下面是一个例子:

t.setToRotation(angle);/sets t to a rotation

可以把图形上下文的坐标变换设置为一个AffineTransform对象:

g2.setTransform(t);/replaces current transformation

不过,在实际运用中,不要调用setTransform操作,因为它会取代图形上下文中可能存在的任何现有的变换。例如,一个用以横向打印的图形上下文已经有了一个90°的旋转变换,如果调用方法setTransfrom,就会删除这样的旋转操作。可以调用transform方法作为替代方案:

g2.transform(t);//composes current transformation with t

它会把现有的变换操作和新的AffineTransform对象组合起来。
如果只想临时应用某个变换操作,那么应该首先获得旧的变换操作,然后和新的变换操作组合起来,最后当你完成操作时,再还原旧的变换操作:

AffineTransform oldTransform = g2.getTransform();//save old transform
g2.transform(t);//apply temporary transform
draw on g2
g2.setTransform(oldTransform);/restore old transform

3.7、剪切

通过在图形上下文中设置一个剪切形状,就可以将所有的绘图操作限制在该剪切形状内部来进行。

g2.setclip(clipShape);
g2.draw(shape);

但是,在实际应用中,不应该调用这个setClip操作,因为它会取代图形上下文中可能存在的任何剪切形状。例如,正如在本章的后面部分所看到的那样,用于打印操作的图形上下文就具有一个剪切矩形,以确保你不会在页边距上绘图。相反,你应该调用clip方法。

g2.clip(clipShape);

clip方法将你所提供的新的剪切形状同现有的剪切形状相交。

如果只想临时地使用一个剪切区域的话,那么应该首先获得旧的剪切形状,然后添加新的剪切形状,最后,在完成操作时,再还原旧的剪切形状:

Shape oldclip = g2.getclip();//save old clip
g2.clip(clipShape);//apply temporary clip
draw on g2
g2.setclip(oldclip);/restore old clip

java.awt.Graphics

  • void setClip(Shape s):将当前的剪切形状设置为形状
  • Shape getClip():返回当前的剪切形状

java.awt.font.TextLayout

  • TextLayout(String s, Font f, FontRenderContext context):根据给定的字符串和字体来构建文本布局对象。方法中使用字体渲染上下文来获取特定设备的字体属性。
  • float getAdvance():返回该文本布局的宽度
  • float getAscent()
  • float getDescent():返回基准线上方和下方该文本布局的高度
  • float getLeading():返回该文本布局使用的字体中相邻两行之间的距离

3.8、透明与组合

在标准的RGB颜色模型中,每种颜色都是由它的红、绿和蓝这三种成分来描述的。但是,用它来描述透明或者部分透明的图像区域也是非常方便的。当你将一个图像置于现有图像的上面时,透明的像素完全不会遮挡它们下面的像素,而部分透明的像素则与它们下面的像素相混合。

在Java2DAPI中,透明是由一个透明度通道
(alpha channel)来描述的。每个像素,除了它的红、绿和蓝色部分外,还有一个介于0(完全透明)和1(部分透明)之间的透明度(alpha)值。

例如,图11-51中的矩形填充了一种淡黄色,透明度为50%:

new Color(0.7F,0.7F,0.0F,0.5F);

现在让我们看一看如果将两个形状重叠在一起时将会出现什么情况。必须把源像素和目标像素的颜色和透明度值混合或者组合起来。从事计算机图形学研究的Porter和Duff已经阐明了在这个混合过程中的12种可能的组合原则,Java2DAPI实现了所有的这些原则。在继续介绍这个问题之前,需要指出的是,这些原则中只有两个原则有实际的意义。如果你发现这些原则晦涩难懂或者难以搞清楚,那么只使用SRC_OVER原则就可以了。它是Graphics2D对象
的默认原则,并且它产生的结果最直接。

Porter-Buff组合规则:

规则解释
CLEAR源像素清除目标像素
SRC源像素覆盖目标像素和空像素
DST源想读不影响目标像素
SRC_OVER源像素和目标像素混合,并且覆盖空像素
DST_OVER源像素不影响目标像素,并且不覆盖空像素
SRC_IN源像素覆盖目标像素
SRC_OUT源像素清除目标像素,并且覆盖空像素
DST_IN源像素的透明度值修改目标像素的透明度值
DST_OUT源像素的透明度值取反修改目标像素的透明度值
SRC_ATOP源像素和目标像素相混合
DST_ATOP源想读的透明度值修改目标像素的透明度值。源像素覆盖空像素
XOR源像素的透明度值取反修改目标像素的透明度值。原像素覆盖空像素

在这里插入图片描述

你可以使用Graphics?2D类的setComposite方法安装一个实现了Composite接口的类的对象。

Java2DAPI提供了这样的一个类,即AlphaComposite它实现了所有的Porter-Duff规则。

AlphaComposite类的工厂方法getInstance用来产生AlphaComposite对象,此时需要提供用于源像素的规则和透明度值。例如,可以考虑使用下面的代码:

int rule = AlphaComposite.SRC_OVER;
float alpha = 0.5f;
g2.setComposite(AlphaComposite.getInstance(rule,alpha));
g2.setPaint(Color.blue);
g2.fill(rectangle);

这时,矩形将使用蓝色和值为0.5的透明度进行着色。因为该组合规则是SRC_OVER,所以它透明地置于现有图像的上面。

public class CompositeTestFrame extends JFrame {
    private static final int DEFAULT_WIDTH = 400;
    private static final int DEFAULT_HEIGHT = 400;

    private CompositeComponent canvas;
    private JComboBox<Rule> ruleCombo;
    private JSlider alphaSlider;
    private JTextField explanation;

    public CompositeTestFrame() {
        setSize(DEFAULT_WIDTH, DEFAULT_HEIGHT);

        canvas = new CompositeComponent();
        add(canvas, BorderLayout.CENTER);

        ruleCombo = new JComboBox<>(new Rule[]{
                new Rule("CLEAR", "  ", "  "),
                new Rule("SRC", " S", " S"),
                new Rule("DST", "  ", "DD"),
                new Rule("SRC_OVER", " S", "DS"),
                new Rule("DST_OVER", " S", "DD"),
                new Rule("SRC_IN", "  ", " S"),
                new Rule("SRC_OUT", " S", "  "),
                new Rule("DST_IN", "  ", " D"),
                new Rule("DST_OUT", "  ", "D "),
                new Rule("SRC_ATOP", "  ", "DS"),
                new Rule("DST_ATOP", " S", " D"),
                new Rule("XOR", " S", "D ")
        });
        ruleCombo.addActionListener(event -> {
            Rule r = (Rule) ruleCombo.getSelectedItem();
            canvas.setRule(r.getValue());
            explanation.setText(r.getExplanation());
        });

        alphaSlider = new JSlider(0, 100, 75);
        alphaSlider.addChangeListener(event -> canvas.setAlpha(alphaSlider.getValue()));
        JPanel panel = new JPanel();
        panel.add(ruleCombo);
        panel.add(new JLabel("Alpha"));
        panel.add(alphaSlider);
        add(panel, BorderLayout.NORTH);

        explanation = new JTextField();
        add(explanation, BorderLayout.SOUTH);

        canvas.setAlpha(alphaSlider.getValue());
        Rule r = ruleCombo.getItemAt(ruleCombo.getSelectedIndex());
        canvas.setRule(r.getValue());
        explanation.setText(r.getExplanation());
    }

    public static void main(String[] args) {
        EventQueue.invokeLater(() -> {
            JFrame frame = new CompositeTestFrame();
            frame.setTitle("CompositeTest");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.setVisible(true);
        });
    }
}
public class CompositeComponent extends JComponent {

    private int rule;
    private Shape shape1;
    private Shape shape2;
    private float alpha;

    public CompositeComponent() {
        shape1 = new Ellipse2D.Double(100, 100, 150, 100);
        shape2 = new Rectangle2D.Double(150, 150, 150, 100);
    }

    @Override
    protected void paintComponent(Graphics g) {
        Graphics2D g2 = (Graphics2D) g;

        BufferedImage image = new BufferedImage(getWidth(), getHeight(), BufferedImage.TYPE_INT_ARGB);
        Graphics2D gImage = image.createGraphics();
        gImage.setPaint(Color.red);
        gImage.fill(shape1);
        AlphaComposite composite = AlphaComposite.getInstance(rule, alpha);
        gImage.setComposite(composite);
        gImage.setPaint(Color.black);
        gImage.fill(shape2);
        g2.drawImage(image, null, 0, 0);
    }

    public void setRule(int rule) {
        this.rule = rule;
        repaint();
    }

    public void setAlpha(int alpha) {
        this.alpha = (float)alpha / 100.0f;
        repaint();
    }
}
public class Rule {
    private String name;
    private String porterDuff1;
    private String porterDuff2;

    public Rule(String name, String porterDuff1, String porterDuff2) {
        this.name = name;
        this.porterDuff1 = porterDuff1;
        this.porterDuff2 = porterDuff2;
    }

    public String getExplanation() {
        StringBuilder r = new StringBuilder("Source ");
        if (porterDuff2.equals("  ")) {
            r.append("clears");
        }
        if (porterDuff2.equals(" S")) {
            r.append("overwrites");
        }
        if (porterDuff2.equals("DS")) {
            r.append("blends with");
        }
        if (porterDuff2.equals(" D")) {
            r.append("alpha modifies");
        }
        if (porterDuff2.equals("D ")) {
            r.append("alpha complement modifies");
        }
        if (porterDuff2.equals("DD")) {
            r.append(" add overwrites empty pixels");
        }
        r.append(".");
        return r.toString();
    }

    public String toString() {
        return name;
    }

    public int getValue() {
        try {
            return (Integer) AlphaComposite.class.getField(name).get(null);
        } catch (Exception e) {
            return  -1;
        }
    }
}

在这里插入图片描述

java.awt.Graphics2D

  • void setComposite(Composite s):把图形上下文的组合方式设置为实现了Composite接口的给定对象

java.awt.AlphaComposite

  • static AlphaComposite getInstance(int rule)
  • static AlphaComposite getInstance(int rule, float sourceAlpha):构建一个透明度值的组合对象

4、像素图

4.1、图像的读取器和写入器

javax.imageio包包含了对读取和写入数种常用文件格式进行支持的“非常方便的”特性。同时还包含了一个框架,使得第三方能够为其他图像格式的文件添加读取器和写入器。GIFJPEGPNGBMP(Windows位图)WBMP(无线位图)等文件格式都得到了支持。

该类库的基本应用是极其直接的。要想装载一个图像,可以使用ImageIO类的静态read方法。

File f =...
BufferedImage image = ImageI0.read(f);

ImageIO类会根据文件的类型,选择一个合适的读取器。它可以参考文件的扩展名和文件开头的专用于此目的的“幻数”(magic number)来选择读取器。如果没有找到合适的读取器或者读取器不能解码文件的内容,那么read方法将返回null。

把图像写入到文件中也是一样地简单:

File f = ...;
String format = ...;
ImageI0.write(image,format,f)

这里,format字符串用来标识图像的格式,比如“JPEG”或者“PNG”。ImageIO类将选择一个合适的写入器以存储文件。

1)、获得适合图像文件类型的读取器和写入器

对于那些超出ImageIO类的静态read和write方法能力范围的高级图像读取和写入操作来说,首先需要获得合适的ImageReaderImageWriter对象。ImageIO类枚举了匹配下列条件之一的读取器和写入器。

  • 图像格式(比如“JPEG”)
  • 文件后缀(比如“jpg”)
  • MME类型(比如“image/jpeg”)

例如,可以用下面的代码来获取一个JPEG格式文件的读取器:

ImageReader reader = null;
Iterator<ImageReader> iter = ImageIO.getImageReadersByFormatName("JPEG");
if(iter.hasNext()) {
	reader = iter.next();
}

getImageReadersBySuffixgetImageReadersByMIMEType这两个方法用于枚举与文件扩展名或MIME类型相匹配的读取器。

ImageIO类可能会找到多个读取器,而它们都能够读取某一特殊类型的图像文件。在这种情况下,必须从中选择一个,但是也许你不清楚怎样才能选择一个最好的。如果要了解更多的关于读取器的信息,就要获取它的服务提供者接口:

ImageReaderSpi spi = reader.getOriginatingProvider();

然后,可以获得供应商的名字和版本号:

String vendor = spi.getVendor();
String version = spi.getVersion();

也许该信息能够帮助你决定选择哪一种读取器,或者你可以为你的程序用户提供一个读取器的列表,让他们做出选择。然而,目前来说,我们假定第一个列出来的读取器就能够满足用户的需求。

2)、读取和写入带有多个图像的文件

有些文件,特别是GIF动画文件,都包含了多个图像。ImageIO类的read方法只能够读取单个图像。为了读取多个图像,应该将输入源(例如,输入流或者输入文件)转换成一个ImageInputStream。

InputStream in = ...;
ImageInputStream imageIn = ImageIO.createImageInputStream(in);

接着把图像输入流作为参数传递给读取器的setInput方法:

reader.setInput(imageIn, true);

方法中的第二个参数值表示输入的方式是“只向前搜索”,否则,就采用随机访问的方式,要么是在读取时缓冲输入流,要么是使用随机文件访问。对于某些操作来说,必须使用随机访问的方法。

例如,为了在一个GF文件中查寻图像的个数,就需要读入整个文件。这时,如果想获取某一图像的话,必须再次读入该输入文件。

只有当从一个流中读取图像,并且输入流中包含多个图像,而且在文件头中的图像格式部分没有所需要的信息(比如图像的个数)时,考虑使用上面的方法才是合适的。如果要从个文件中读取图像信息的话,可直接使用下面的方法:

File f = ...;
ImageInputStream imageIn = ImageIO.createImageInputStream(f);
reader.setInput(imageIn);

一旦拥有了一个读取器后,就可以通过调用下面的方法来读取输入流中的图像。

BufferedImage image = reader.read(index);

其中index是图像的索引,其值从0开始。

如果输入流采用“只向前搜索”的方式,那么应该持续不断地读取图像,直到read方法抛出一个IndexOutOufBoundsException为止。否则,可以调用getNumImages方法:

int n = reader.getNumImages(true);

在该方法中,它的参数表示允许搜索输入流以确定图像的数目。如果输人流采用“只向前搜索”的方式,那么该方法将抛出一个IllegalStateException异常。要不然,可以把是否“允许搜索”参数设置为false。如果getNumImages方法在不搜索输入流的情况下无法确定图像的数目,那么它将返回1。在这种情况下,必须转换到B方案,那就是持续不断地读取图像,直到获得一个IndexOutOfBoundsException异常为止。

有些文件包含一些缩略图,也就是图像用来预览的小版本。可以通过调用下面的方法来获得某个图像的缩略图数量。

int count = reader.getNumThumbnails(index);

然后可以按如下方式得到一个特定索引:

BufferedImage thumbnail = reader.getThumbnail(index,thumbnailIndex);

如果要将多个图像写入到一个文件中,首先需要一个ImageWriter。ImageI0类能够枚举可以写入某种特定图像格式的所有写入器。

String format =...
ImageWriter writer null;
Iterator<ImageWriter> iter = ImageIO.getImageWritersByFormatName(format);
if (iter.hasNext())
	writer = iter.next()

接着,将一个输出流或者输出文件转换成ImageOutputStream,并且将其作为参数传给写入器。例如:

File f = ...;
ImageOutputStream imageOut = ImageIO.createImageOutputStream(f);
writer.setOutput(imageOut);

必须将每一个图像都包装到IIOImage对象中。可以根据情况提供一个缩略图和图像元数据(比如,图像的压缩算法和颜色信息)的列表。在本例中,我们把两者都设置为null;

var iioImage = new IIOImage(images[i],null,null);

使用write方法,可以写出第一个图像:

writer.write(new IIOImage(images[0],null,null));

对于后续的图像,使用下面的方法:

if (writer.canInsertImage(i))
	writer.writeInsert(i,iioImage,null);
3)、示例
public class ImageIOFrame extends JFrame {
    private static final int DEFAULT_WIDTH = 400;
    private static final int DEFAULT_HEIGHT = 400;

    private static Set<String> writerFormats = getWriterFormats();
    private BufferedImage[] images;

    public ImageIOFrame() {
        setSize(DEFAULT_WIDTH, DEFAULT_HEIGHT);

        JMenu fileMenu = new JMenu("File");
        JMenuItem openItem = new JMenuItem("Open");
        openItem.addActionListener(event -> openFile());
        fileMenu.add(openItem);

        JMenu saveMenu = new JMenu("Save");
        fileMenu.add(saveMenu);
        Iterator<String> iter = writerFormats.iterator();
        while (iter.hasNext()) {
            final String formatName = iter.next();
            JMenuItem formatItem = new JMenuItem(formatName);
            saveMenu.add(formatItem);
            formatItem.addActionListener(event -> saveFile(formatName));
        }

        JMenuItem exitItem = new JMenuItem("Exit");
        exitItem.addActionListener(event -> System.exit(0));
        fileMenu.add(exitItem);

        JMenuBar menuBar = new JMenuBar();
        menuBar.add(fileMenu);
        setJMenuBar(menuBar);
    }

    public void openFile() {
        JFileChooser chooser = new JFileChooser();
        chooser.setCurrentDirectory(new File("."));
        String[] extensions = ImageIO.getReaderFileSuffixes();
        chooser.setFileFilter(new FileNameExtensionFilter("Image files", extensions));
        int r = chooser.showOpenDialog(this);
        if (r != JFileChooser.APPROVE_OPTION) {
            return;
        }
        File f = chooser.getSelectedFile();
        Box box = Box.createVerticalBox();
        try {
            String name = f.getName();
            String suffix = name.substring(name.lastIndexOf(".") + 1);
            Iterator<ImageReader> iter = ImageIO.getImageReadersBySuffix(suffix);
            ImageReader reader = iter.next();
            ImageInputStream imageIn = ImageIO.createImageInputStream(f);
            reader.setInput(imageIn);
            int count = reader.getNumImages(true);
            images = new BufferedImage[count];
            for (int i = 0; i < count; i++) {
                images[i] = reader.read(i);
                box.add(new JLabel(new ImageIcon(images[i])));
            }
        } catch (IOException e) {
            JOptionPane.showMessageDialog(this, e);
        }
        setContentPane(new JScrollPane(box));
        validate();
    }

    public void saveFile(final String formatName) {
        if (images == null) {
            return;
        }
        Iterator<ImageWriter> iter = ImageIO.getImageWritersByFormatName(formatName);
        ImageWriter writer = iter.next();
        JFileChooser chooser = new JFileChooser();
        chooser.setCurrentDirectory(new File("."));
        String[] extensions = writer.getOriginatingProvider().getFileSuffixes();
        chooser.setFileFilter(new FileNameExtensionFilter("Image files", extensions));

        int r = chooser.showSaveDialog(this);
        if (r != JFileChooser.APPROVE_OPTION) {
            return;
        }
        File f = chooser.getSelectedFile();
        try {
            ImageOutputStream imageOut = ImageIO.createImageOutputStream(f);
            writer.setOutput(imageOut);
            writer.write(new IIOImage(images[0], null, null));
            for (int i = 1; i < images.length; i++) {
                IIOImage iioImage = new IIOImage(images[i], null, null);
                if (writer.canInsertEmpty(i)) {
                    writer.writeInsert(i, iioImage, null);
                }
            }
        }catch (IOException e) {
            JOptionPane.showMessageDialog(this, e);
        }
    }

    public static Set<String> getWriterFormats() {
        TreeSet<String> writerFormats = new TreeSet<>();
        List<String> formatNames = Arrays.asList(ImageIO.getWriterFormatNames());
        for (String formatName : formatNames) {
            Iterator<ImageWriter> iter = ImageIO.getImageWritersByFormatName(formatName);
            ImageWriter writer = iter.next();
            String[] names = writer.getOriginatingProvider().getFormatNames();
            String format = names[0];
            if (format.equals(format.toLowerCase())) {
                format = format.toUpperCase();
            }
            writerFormats.add(format);
        }
        return writerFormats;
    }

    public static void main(String[] args) {
        EventQueue.invokeLater(() -> {
            JFrame frame = new ImageIOFrame();
            frame.setTitle("ImageIO");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.setVisible(true);
        });
    }
}

在这里插入图片描述

4)、API

javax.imageio.ImageIO

  • static BufferedImage read(File input)
  • static BufferedImage read(InputStream input)
  • static BufferedImage read(URL input):读取一个图像
  • static boolean write(RenderedImage image, String formatName, File output)
  • static boolean write(RenderedImage image, String formatName, OutputStream output):将给定格式的图像写入output中。如果没有找到合适的写入器,则返回false
  • static Iterator<ImageReader> getImageReadersByFormatName(String formatName)
  • static Iterator<ImageReader> getImageReadersBySuffix(String fileSuffix)
  • static Iterator<ImageReader> getIMageReadersByMIMEType(String mimeType)
  • static Iterator<ImageReader> getImageWritersByFormatName(String formatName)
  • static Iterator<ImageReader> getImageWritersBySuffix(String fileSuffix)
  • static Iterator<ImageReader> getImageWritersByMIMEType(String mimeType):获得能够处理给定格式(例如"JPEG")、文件后缀(例如"jpg")或者MIME类型(例如"image/jpeg")的所有读取器和写入器。
  • static String[] getReaderFormatNames()
  • static String[] getReaderMIMETypes()
  • static String[] getWriterFormatNames()
  • static String[] getWriterMIMETypes()
  • static String[] getReaderFileSuffixes()
  • static String[] getWriterFileSuffixes():获取读取器和写入所支持的所有格式名、MIME类型名和文件后缀
  • ImageInputStream createImageInputStream(Object input)
  • ImageOutputStream createImageOutputStream(Object output):根据给定的对象来创建一个图像输入流或者图像输出流。该对象可能是一个文件、一个流、一个RandomAccessFile或者某个服务提供商能够处理的其他类型的对象。如果没有任何注册过的服务提供器能够处理这个对象,那么返回ul值。

javax.imageio.ImageReader

  • void setInput(Object input)
  • void setInput(Object input, boolean seekForwardOnly):设置读取器的输入源
  • BufferedImage read(int index):读取给定索引的图像(索引从0开始)。如果没有这个图像,则抛出一个Index(OutOfBoundsException异常。
  • int getNumImages(boolean allowSearch):获取读取器中图像的数目。如果allowSearch值为false,并且不向前阅读就无法确定图像的数目,那么它将返回。-1如果allowSearch值是true,并且读取器采用了“只向前搜索”方式,那么就会抛出IllegalStateException异常。
  • int getNumThumbnails(int index):获取给定索引的图像的缩率图的数量
  • BufferedImage readThumbnali(int index, int thumbnailIndex):获取给定索引的图像的索引号为thumbnailIndex的缩率图
  • int getWidth(int index)
  • int getHeight(int index):获取图像的宽度和高度。如果没有这样的图像,就抛出一个IndexOutOfBounds-Exception异常
  • ImageReaderSpi getOriginatingProvider():获取构建该读取器的服务提供者

javax.imageio.spi.IIOServiceProvider

  • String getVendorName()
  • String getVersion():获取该服务提供者的名字和版本

javax.imageio.spi.ImageReaderWriterSpi

  • String[] getFormatNames()
  • String[] getFileSuffixes()
  • String[] getMIMETypes():获取由该服务提供者创建的读取器或者写入器所支持的图像格式名、文件的后缀和MIME类型。

javax.imageio.ImageWriter

  • void setOutput(Object output):设置该写入器的输出目标
  • void write(IIOImage image)
  • void write(RenderedImage image):把单一的图像写入到输出流
  • void writeInsert(int index, IIOImage image, ImageWriteParam param):把一个图像写入到一个包含多个图像的文件中
  • boolean canInsertImage(int index):如果在给定的索引处可以插入一个图像的话,则返回true值
  • ImageWriterSpi getOriginatingProvider():获取构建该写入器的服务提供者

javax.imageio.IIOImage

  • IIOImage(RenderedImage image, List thumbnails, IIOMetadata metadata):根据一个图像、可选的缩略图和可选的元数据来构建一个IIOImage对象

4.2、图像处理

假设你有一个图像,并且希望改善图像的外观。这时需要访问该图像的每一个像素,并用其他的像素来取代这些像素。或者,你也许想要从头计算某个图像的像素,例如,你想显示一下物理测量或者数学计算的结果。

BufferedImage类提供了对图像中像素的控制能力,而实现了BufferedImageOP接口的类都可以对图像进行变换操作。

1)、构建像素图

为了创建一个图像,需要以通常的方法构建一个BufferedImage对象:

image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);

现在,调用getRaster方法来获得一个类型为WritableRaster的对象,后面将使用这个对象来访问和修改该图像的各个像素:

WritableRaster raster = image.getRaster();

使用setPixel方法可以设置一个单独的像素。这项操作的复杂性在于不能只是为该像素设置一个Color值,还必须知道存放在缓冲中的图像是如何设定颜色的,这依赖于图像的类型。

如果图像有一个TYPE_INT_ARGB类型,那么每一个像素都用四个值来描述,即:红、绿、蓝和透明度(alpha),每个值的取值范围都介于0和255之间,这需要以包含四个整数值的一个数组的形式给出:

int[] black = {0, 0, 0, 255};
raster.setPexel(i, j, black);

可以使用setPixels方法提供批量的像素。需要设置矩形的起始像素的位置和矩形的宽度和高度。接着,提供一个包含所有像素的样本值的一个数组。

例如,如果你缓冲的图像有一个TYPE_INT_ARGB类型,那么就应该提供第一个像素的红、绿、蓝和透明度的值(alpha),然后,提供第二个像素的红、绿、蓝和透明度的值,以此类推:

var pixels = new int[4 * width * height];
pixels[0]=...;/red value for first pixel
pixels[1]=...;/green value for first pixel
pixels[2]=...;/blue value for first pixel
pixels[3]=...;/alpha value for first pixel
raster.setPixels(x,y,width,height,pixels);

反过来,如果要读入一个像素,可以使用getPixel方法。这需要提供一个含有四个整数的数组,用以存放各个样本值:

var sample = new int[4];
raster.getPixel(x,y,sample);
var color = new Color(sample[0],sample[1],sample[2],sample[3]);

可以使用getPixels方法来读取多个像素:

raster.getpixels(x,y,width,height,samples);

如果使用的图像类型不是TYPE_INT_ARGB,并且已知该类型是如何表示像素值的,那么仍旧可以使用getPixel/setPixel方法。不过,必须要知道该特定图像类型的样本值是如何进行编码的。

如果需要对任意未知类型的图像进行处理,那么你就要费神了。每一个图像类型都有一个颜色模型,它能够在样本值数组和标准的RGB颜色模型之间进行转换。

为了了解一个像素的颜色值,可以调用Raster类的getDataElements方法。这个方法返回了一个Object,它包含了有关该颜色值的与特定颜色模型相关的描述:

Object data = raster.getDataElements(x,y,null);

颜色模型能够将该对象转换成标准的ARGB的值。getRGB方法返回一个int类型的值它把透明度(alpha、红、绿和蓝的值打包成四个块,每块包含8位。也可以使用Color(int argb,boolean hasAlpha)构造器来构建一个颜色的值:

int argb = model.getRGB(data);
var color = new Color(argb,true);

如果要把一个像素设置为某个特定的颜色值,需要按与上述相反的步骤进行操作。

Color类的getRGB方法会产生一个包含透明度、红、绿和蓝值的int型值。把这个值提供给ColorModel类的getDataElements方法,其返回值是一个包含了该颜色值的特定颜色模型描述的Object。再将这个对象传递给WritableRaster类的setDataElements方法:

int argb = color.getRGB();
Object data = model.getDataElements(argb,null);
raster.setDataElements(x,y,data);

为了阐明如何使用这些方法来用各个像素构建图像,我们按照传统,绘制了一个Mandelbrot集。

Mandelbrot集的思想就是把平面上的每一点和一个数字序列关联在一起。如果数字序列是收敛的,该点就被着色。如果数字序列是发散的,该点就处于透明状态

下面就是构建简单Manderbrot集的方法。对于每一个
点(a,b),你都能按照如下的公式得到一个点集序列,其
开始于点(x,y)=(0,0),反复进行迭代:
在这里插入图片描述

结果证明,如果x或者y的值大于2,那么序列就是发散的。仅有那些与导致数字序列收敛的点(a,b)相对应的像素才会被着色。(该数字序列的计算公式基本上是从复杂的数学概念中推导出来的。我们只使用现成的公式。

public class RasterImageFrame extends JFrame {
    private static final double XMIN = -2;
    private static final double XMAX = 2;
    private static final double YMIN = -2;
    private static final double YMAX = 2;
    private static final int MAX_ITERATIONS = 16;
    private static final int IMAGE_WIDTH = 400;
    private static final int IMAGE_HEIGHT = 400;

    public RasterImageFrame() {
        BufferedImage image = makeMandelbrot(IMAGE_WIDTH, IMAGE_HEIGHT);
        add(new JLabel(new ImageIcon(image)));
        pack();
    }

    public BufferedImage makeMandelbrot(int width, int height) {
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
        WritableRaster raster = image.getRaster();
        ColorModel model = image.getColorModel();

        Color fractalColor = Color.RED;
        int argb = fractalColor.getRGB();
        Object colorData = model.getDataElements(argb, null);

        for (int i = 0; i < width; i++) {
            for (int j = 0; j < height; j++) {
                double a = XMIN + i * (XMAX - XMIN) / width;
                double b = YMIN + j * (YMAX - YMIN) / height;
                if (!escapesToInfinity(a, b)) {
                    raster.setDataElements(i, j, colorData);
                }
            }
        }
        return image;
    }

    private boolean escapesToInfinity(double a, double b) {
        double x = 0.0;
        double y = 0.0;
        int iterations = 0;
        while (x <= 2 && y <= 2 && iterations < MAX_ITERATIONS) {
            double xnew = x * x - y * y + a;
            double ynew = 2 * x * y + b;
            x = xnew;
            y = ynew;
            iterations++;
        }
        return x > 2 || y > 2;
    }

    public static void main(String[] args) {
        EventQueue.invokeLater(() -> {
            JFrame frame = new RasterImageFrame();
            frame.setTitle("RasterImage");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.setVisible(true);
        });
    }
}

在这里插入图片描述

java.awt.image.BufferedImage

  • BufferedImage(int width, int height, int imageType):构建一个被缓存的图像对象
  • ColorModel getColorModel():返回被缓存图像的颜色模型
  • WritableRaster getRaster():获得访问和修改该缓存图像的像素栅格

java.awt.image.Raster

  • Object getDataElements(int x, int y, Object data):返回某个栅格点的样本数据,该数据位于一个数组中,而该数组的长度和类型依赖于颜色模型。如果data不为nulL,那么它将被视为是适合于存放样本数据的数组,从而被充填。如果data为null,那么将分配一个新的数组,其元素的类型和长度依赖于颜色模型。
  • int[] getPixel(int x, int y, int[] sampleValues)
  • float[] getPexel(int x, int y, float[] sampleValues)
  • double[] getPexel(int x, int y, double[] sampleValues)
  • int[] getPexel(int x, int y, int width, int height, float[] sampleValues)
  • float[] getPexel(int x, int y, int width, int height, double[] sampleValues)
  • double[] getPexel(int x, int y, int width, int height, double[] sampleValues):返回某个栅格点或者是由栅格点组成的某个矩形的样本值,该数据位于一个数组中,
    数组的长度依赖于颜色模型。如果sampleValues不为null,那么该数组被视为长度足够存放样本值,从而该数组被填充。如果sampleValues为null,就要分配一个新数组。仅当你知道某一颜色模型的样本值的具体含义的时候,这些方法才会有用。

java.awt.image.WritableRaster

  • void setDataElements(int x, int y, Object data):设置栅格点的样本数据
  • void setPexel(int x, int y, int[] sampleValues)
  • void setPexel(int x, int y, float[] sampleValues)
  • void setPexel(int x, int y, double[] sampleValues)
  • void setPexel(int x, int y, int width, int height, int[] sampleValues)
  • void setPexel(int x, int y, int width, int height, float[] sampleValues)
  • void setPexel(int x, int y, int width, int height, double[] sampleValues):设置某个栅格点或由多个栅格点组成的矩形的样本值。只有当你知道颜色模型样本值的编码规则时,这些方法才会有用。

java.awt.image.ColorModel

  • int getRGB(Object data):返回对应于data数组中传递的样本数据的ARGB值。其元素的类型和长度依赖于颜色模型。
  • Object getDataElements(int argb, Object data):返回某个颜色值的样本数据。如果data不为null,那么该数组被视为非常适合于存放样本值,进而该数组被填充。如果data为null,那么将分配一个新的数组。data是一个填充了用于某个像素的样本数据的数组,其元素的类型和长度依赖于该颜色模型。

java.awt.Color

  • Color(int argb, boolean hasAlpha):如果hasAlpha的值是true,则用指定的ARGB组合值创建一种颜色。如果hasAlpha的值是false,则用指定的RGB值创建一种颜色。
  • int getRGB():返回和该颜色相对应ARGB颜色值
2)、图像过滤

图像处理都实现了BufferedImageOp接口。构建了图像处理的操作之后,只需调用filter方法,就可以把该图像转换成另一个图像。

BufferedImageOp op = ...;
BufferedImage filteredImage = new BufferedImage(image.getWidth(),image.getHeight(),image.getType());
op.filter(image,filteredImage);

有些图像操作可以恰当地(通过op.filter(image,image)方法)转换一个图像,但是大多数的图像操作都做不到这一点。

以下五个类实现了BufferedImageOp接口:

  • AffineTransformOp:用于对各个像素执行仿射变换
  • RescaleOp:为图象中的颜色构件执行一个大小变换操作
  • LookupOp:为样本值设定任意的映射操作
  • ColorVonvertOp:色彩空间转换
  • ConvolveOp:卷积变换

AffineTransformOp类用于对各个像素执行仿射变换。例如,下面的代码就说明了如何使个图像围绕着它的中心旋转。

AffineTransform transform = AffineTransform.getRotateInstance(Math.toRadians(angle),image.getwidth()/2,image.getHeight()/2);
var op = new AffineTransformOp(transform,interpolation);
op.filter(image,filteredImage);

RescaleOp用于为图像中的所有的颜色构件执行一个调整其大小的变换操作(透明度构件不受影响):

Xnew = a * x + b

a>1进行调整,那么调整后的效果是使图像变亮。可以通过设定调整大小的参数和可选的绘图提示来构建RescaleOp。

float a 1.1f;
float b=20.0f;
var op = new RescaleOp(a,b,null)

Lookup0p构造器需要一个类型是LookupTable的对象和一个选项提示映射表。LookupTable是抽象类,其有两个实体子类:ByteLookupTable``和ShortLookupTable。因为RGB颜色值是由字节组成的,所以ByteLookupTable类应该就够用了。

//颜色取反
var negative = new short[256];
for (int i 0;i 256;i++)
	negative[i]=(short)(255-i);
var table = new ShortLookupTable(0, negative);
var op = new LookupOp(table, null);
public class ImageProcessingFrame extends JFrame {

    public static void main(String[] args) {
        EventQueue.invokeLater(() -> {
            JFrame frame = new ImageProcessingFrame();
            frame.setTitle("ImageProcessing");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.setVisible(true);
        });
    }

    private static final int DEFAULT_WIDTH = 400;
    private static final int DEFAULT_HEIGHT = 400;
    private BufferedImage image;

    public ImageProcessingFrame() {
        setTitle("ImageProcessingTest");
        setSize(DEFAULT_WIDTH, DEFAULT_HEIGHT);

        add(new JComponent() {
            @Override
            protected void paintComponent(Graphics g) {
                if (image != null) {
                    g.drawImage(image, 0, 0, null);
                }
            }
        });
        JMenu fileMenu = new JMenu("File");
        JMenuItem openItem =  new JMenuItem("Open");
        openItem.addActionListener(event -> openFile());
        fileMenu.add(openItem);

        JMenuItem exitItem = new JMenuItem("Exit");
        exitItem.addActionListener(event -> System.exit(0));
        fileMenu.add(exitItem);

        JMenu editMenu = new JMenu("Edit");
        JMenuItem blurItem = new JMenuItem("Blur");
        blurItem.addActionListener(event -> {
            float weight = 1.0f / 9.0f;
            float[] elements = new float[9];
            for (int i = 0; i < 9; i++) {
                elements[i] = weight;
            }
            convolve(elements);
        });
        editMenu.add(blurItem);

        JMenuItem sharpenItem = new JMenuItem("Sharpen");
        sharpenItem.addActionListener(event -> {
            float[] elements = {0.0f, -1.0f, 0.0f, -1.0f, 5.0f, -1.0f, 0.0f, -1.0f, 0.0f};
            convolve(elements);
        });
        editMenu.add(sharpenItem);

        JMenuItem brightenItem = new JMenuItem("Brighten");
        brightenItem.addActionListener(event -> {
            float a = 1.1f;
            float b = 20.0f;
            RescaleOp op = new RescaleOp(a, b, null);
            filter(op);
        });
        editMenu.add(brightenItem);

        JMenuItem edgeDetectItem = new JMenuItem("Edge detect");
        edgeDetectItem.addActionListener(event -> {
            float[] elements = {0.0f, -1.0f, 0.0f, -1.0f, 4.0f, -1.0f, 0.0f, -1.0f, 0.0f};
            convolve(elements);
        });
        editMenu.add(edgeDetectItem);

        JMenuItem negativeItem = new JMenuItem("Negative");
        negativeItem.addActionListener(event -> {
            short[] negative = new short[256 * 1];
            for (int i = 0; i < 256; i++) {
                negative[i] = (short)(255 - i);
            }
            ShortLookupTable table = new ShortLookupTable(0, negative);
            LookupOp op = new LookupOp(table, null);
            filter(op);
        });
        editMenu.add(negativeItem);

        JMenuItem rotateItem = new JMenuItem("Rotate");
        rotateItem.addActionListener(event -> {
            if (image == null) {
                return;
            }
            AffineTransform transform = AffineTransform.getRotateInstance(Math.toRadians(5), image.getWidth() / 2, image.getHeight() / 2);
            AffineTransformOp op = new AffineTransformOp(transform, AffineTransformOp.TYPE_BICUBIC);
            filter(op);
        });
        editMenu.add(rotateItem);

        JMenuBar menuBar = new JMenuBar();
        menuBar.add(fileMenu);
        menuBar.add(editMenu);
        setJMenuBar(menuBar);
    }

    public void openFile() {
        JFileChooser chooser = new JFileChooser(".");
        chooser.setCurrentDirectory(new File(getClass().getPackage().getName()));
        String[] extensions = ImageIO.getReaderFileSuffixes();
        chooser.setFileFilter(new FileNameExtensionFilter("Image files", extensions));
        int r = chooser.showOpenDialog(this);
        if (r != JFileChooser.APPROVE_OPTION) {
            return;
        }
        try {
            BufferedImage img = ImageIO.read(chooser.getSelectedFile());
            image = new BufferedImage(img.getWidth(null), img.getHeight(null), BufferedImage.TYPE_INT_RGB);
            image.getGraphics().drawImage(img, 0, 0, null);
        } catch (IOException e) {
            JOptionPane.showMessageDialog(this, e);
        }
        repaint();
    }

    private void filter(BufferedImageOp op) {
        if (image == null) {
            return;
        }
        image = op.filter(image, null);
        repaint();
    }

    private void convolve(float[] elements) {
        Kernel kernel = new Kernel(3, 3, elements);
        ConvolveOp op = new ConvolveOp(kernel);
        filter(op);
    }
}

java.awt.image.BufferedImageOp

  • BufferedImage filter(AffineTransform t, int interpolationType):将图像操作应用于源图像,并且将操作的结果存放在目标图像中。如果dest为null,一个新的目标图像将被创建。该目标图像将被返回。

java.awt.image.AffineTransformOp

  • AffineTransformOp(AffineTransform t, int interpolationType):构建一个仿射变换操作符。渐变变换的类型是TYPE_BILINEAR、TYPE_BICUBIC或者TYPE_NEAREST_NEIGHBOR中的一个。

java.awt.image.RescaleOp

  • RescaleOp(float a, float b, RenderingHints hints)
  • RescaleOp(float[] as, float[] bs, RenderingHints hints):构建一个进行尺寸调整的操作符,它会执行缩放操作x = a * x + b。当使用第一个构造器时,所有的颜色构件(但不包括透明度构件)都将按照相同的系数进行缩放。当使用第二个构造器时,可以为每个颜色构件提供单独的值,在这种情况下,透明度构件不受影响,或者为每个颜色构件和透明度构件都提供单独的值。

java.awt.image.LookupOp

  • LookupOp(LookupTable table, RenderingHints hints):为给定的查找表构建一个查找操作符

java.awt.image.ByteLookupTable

  • ByteLookupTable(int offset, byte[] data)
  • ByteLookupTable(int offset, byte[][] data):为转化byte值构建一个字节查找表。在查找之前,从输入中减去偏移量。在第一个构造器中的值将提供给所有的颜色构件,但不包括透明度构件。当使用第二个构造器时,可以为每个颜色构件提供单独的值,在这种情况下,透明度构件不受影响,或者为每个颜色构件和透明度构件都提供单独的值。

java.awt.image.ShortLookupTable

  • ShortLookupTable(int offset, short[] data)
  • ShortLookupTable(int offset, short[][] data):为转化sot值构建一个字节查找表。在查找之前,从输入中减去偏移量。在第一个构造器中的值将提供给所有的颜色构件,但不包括透明度构件。当使用第二个构造器时,可以为每个颜色构件提供单独的值,在这种情况下,透明度构件不受影响,或者为每个颜色构件和透明度构件都提供单独的值。

java.awt.image.ConvolveOp

  • ConvolveOp(Kernel kernel)
  • ConvolveOp(Kernel kernel, int edgeCondition, RenderingHints hints):构建一个卷积变换操作符。边界条件是EDGE_NO_OP和EDGE ZER0FILL两种方式之一。由于边界值没有足够的临近值来进行卷积变换的计算,所以边界值必须被特殊处理,其默认值是EDGE_ZERO_FILL。

java.awt.image.Kernel

  • Kernel(int width, int height, float[] matrixElements):为指定的矩阵构建一个内核
  • 22
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值