持续创作,加速成长!这是我参与「掘金日新计划 · 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 键字所触发的实例化操作。
参考文档
- 《秒懂设计模式》—— 刘韬