设计模式(二)—— 原型

99 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第22天,点击查看活动详情

概念

原型模式(Prototype),在制造业中通常是指大批量生产开始之前研发出的概念模型,并基于各种参数指标对其进行检验,如果达到了质量要求,即可参照这个原型进行批量生产。原型模式达到以原型实例创建副本实例的目的即可,并不需要知道其原始类,也就是说,原型模式可以用对象创建对象,而不是用类创建对象,以此达到效率的提升。

实例演示

我们以一个常见的游戏为例——飞机大战。在飞机大战中我们需要很多很多的敌机,这个时候就会用到原型模式,下面就让我们一起来看看。

1. 敌机类

首先我们先来看看敌机类,代码如下所示:

敌机类 EnemyPlane 的构造器方法中对飞机的横坐标 x 进行了初始化,而纵坐标则固定为 0,这是由于敌机一开始是从顶部飞出的。所以其纵坐标 y 必然为 0(屏幕左上角坐标为 [0, 0])。

继续往下看,敌机类只提供了 getter 方法而没有提供 setter 方法,也就是说我们只能在初始化时确定好敌机的横坐标 x,之后则不允许再更改坐标了。当游戏运行时,我们只要连续调用行方法 fly(),便可以让飞机像雨点一样不断下落。

public class EnemyPlane {

    private int x;//敌机横坐标
    private int y = 0;//敌机纵坐标

    public EnemyPlane(int x) {//构造器
        this.x = x;
    }

    public int getX() {
        return x;
    }
  
    public int getY() {
        return y;
    }
  
    public void fly(){//让敌机飞
        y++;//每调用一次,敌机飞行时纵坐标+1
    }
  
}

2. 初始化敌机

在开始绘制敌机动画之前,我们首先得实例化一些架敌机,我们假设初始化 500 个。

public class Client {
  
     public static void main(String[] args) {
     List<EnemyPlane> enemyPlanes = new ArrayList<EnemyPlane>();
 
         for (int i = 0; i < 500; i++) {
             //此处于随机纵坐标处出现敌机
             EnemyPlane ep = new EnemyPlane(new Random().nextInt(200));
             enemyPlanes.add(ep);
        }  
    } 
}

在上面的代码中,我们使用了 new 关键字来实例化敌机,这种方法看似没有啥问题,然而效率确实非常低的。

我们知道在游戏刚开始时是没有必要同时展示这么多敌机,而在游戏还未开始之前,也就是游戏的加载阶段我们就实例化了这一关卡的所有 500 架敌机,这不但使加载速度变慢,而且是对有限内存资源的一种浪费。

3. 懒加载

那么到底什么时候去构造敌机?答案当然是 懒加载 了,也就是按照地图坐标,屏幕滚动到某一点时才实时构造敌机,这样一来问题就解决了。

然而遗憾的是,懒加载依然会有性能问题,主要原因在于我们使用的 new 关键字进行的基于类的实例化过程,因为每架敌机都进行全新构造的做法是不合适的,其代价是耗费更多的 CPU 资源,尤其在一些大型游戏中,很多个线程在不停地运转着,CPU 资源本身就非常宝贵,此时若进行大量的类构造与复杂的初始化工作,必然会造成游戏卡顿,甚至有可能会造成系统无响应,使游戏体验大打折扣。

4. 原型拷贝

那么该如何解决这个问题呢?答案是克隆拷贝。

既然循环第一次后已经实例化好了一个敌机原型,那么之后我们就不必去重复这个构造过程。我们只需要去克隆这个对象即可。

在下面的代码中,我们通过 clone() 方法来实现对本类的实例的克隆操作,省去了由类而生的再造方法。

同时,我们加入了 setX() 方法,使被实例化后的敌机对象依然可以支持坐标位置的变更,这样我们就可以保证克隆飞机的坐标位置个性化。

public class EnemyPlane implements Cloneable{

    private int x;//敌机横坐标
    private int y = 0;//敌机纵坐标

    public EnemyPlane(int x) {//构造器
        this.x = x;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    public void fly(){//让敌机飞
        y++;//每调用一次,敌机飞行时纵坐标+1
    }

    //此处开放setX,是为了让克隆后的实例重新修改横坐标
    public void setX(int x) {
        this.x = x;
    }

    //重写克隆方法
    @Override
    public EnemyPlane clone() throws CloneNotSupportedException {
        return (EnemyPlane)super.clone();
    }
}

5. 克隆工厂

至此,克隆模式其实已经实现了,我们只需简单调用克隆方法便能更高效地得到一个全新的实例副本。为了更方便地生产飞机,我们决定定义一个敌机克隆工厂类,代码如下:

public class EnemyPlaneFactory {

    //此处用单例饿汉模式造一个敌机原型
    private static EnemyPlane protoType = new EnemyPlane(200);

    //获取敌机克隆实例
    public static EnemyPlane getInstance(int x){
        EnemyPlane clone = protoType.clone();//复制原型机
        clone.setX(x);//重新设置克隆机的x坐标
        return clone;
    }
 
}

上面的代码中,我们在敌机克隆工厂类EnemyPlaneFactory 中使用了一个静态的敌机对象作为原型,并提供了一个获取敌机实例的方法 getInstance(),其中简单地调用克隆方法得到一个新的克隆对象,并将其横坐标重设为传入的参数,最后返回此克隆对象,这样我们便可轻松获取一架敌机的克隆实例了。

6. 深拷贝和浅拷贝

接下来我们再来看看深拷贝和浅拷贝的区别。

假设敌机类有个可以击杀玩家飞机的子弹,那么敌机则包含一颗实例化好的子弹对象。代码如下所示:

public class EnemyPlane implements Cloneable{

    private Bullet bullet = new Bullet();
    private int x;//敌机横坐标
    private int y = 0;//敌机纵坐标
}

// 其他代码省略

对于这种复杂一些的敌机类,此时如果进行克隆操作,我们是否能将子弹对象一同成功克隆呢?答案是否定的。

原因是:Java 中的变量分为原始类型和引用类型,所谓浅拷贝是指只复制原始类型的值,比如横坐标 x 与纵坐标 y 这种以原始类型 int 定义的值,它们会被复制到新克隆出的对象中。而引用类型 bullet 同样会被拷贝,但是这个操作只是拷贝了地址引用(指针),也就是说副本敌机与原型敌机中的子弹是同一颗,因为两个同样的地址实际指向的内存对象是同一个 bullet 对象。

因此,当我们调用父类 Object 的 clone 方法进行的是浅拷贝,此时 bullet 并没有被真正克隆。然而,每架敌机携带的子弹必须要发射出不同的弹道,这就必然是不同的子弹对象了,所以此时原型模式的浅拷贝实现是无法满足需求的,因此我们需要对代码做一下修改。

public class EnemyPlane implements Cloneable{

    private Bullet bullet;
    private int x;//敌机横坐标
    private int y = 0;//敌机纵坐标

    public EnemyPlane(int x, Bullet bullet) {
        this.x = x;
        this.bullet = bullet;
    }
 
    @Override
    protected EnemyPlane clone() throws CloneNotSupportedException {
        EnemyPlane clonePlane = (EnemyPlane) super.clone();//克隆出敌机
        clonePlane.setBullet(this.bullet.clone());//对子弹进行深拷贝
        return clonePlane;
    }
 
    //之后代码省略……
 
}

克隆的本质

从类到对象叫作“创建”,而由本体对象至副本对象则叫作“克隆”。

当需要创建多个类似的复杂对象时,我们就可以考虑用原型模式。究其本质,克隆操作时 Java 虚拟机会进行内存操作,直接拷贝原型对象数据流生成新的副本对象,绝不会拖泥带水地触发一些多余的复杂操作(如类加载、实例化、初始化等),所以其效率远远高于 new 键字所触发的实例化操作。

参考文档

  • 《秒懂设计模式》—— 刘韬