配套视频课程已更新完毕,大家可通过以下两种方式观看视频讲解:

关注公众号:爱编程的大丙,或者进入大丙课堂学习。


1. 拳骨陨石

蒙奇·D·卡普,也被称为“铁拳卡普”,是海军中的传奇人物,相传卡普数次将海贼王罗杰逼入绝境,被誉为“海军英雄”。卡普是路飞的爷爷,经常将霸气缠绕在拳头上来打艾斯与路飞的头,因为攻击中充满了爱,所以名为“爱之铁拳”,每次都会在路飞头上留下包。

img

虽然自己是海军,但是儿子却是革命军,孙子是海贼。拳骨陨石·流星群是卡普的战斗招式之一,即连续不断地向敌人投掷炮弹,其效果就如流星砸向敌人一般。卡普曾以此招在水之七都向路飞告别,我们来回顾一下当时的场景:

luffyyeye

关于卡普老爷子的实力是毋庸置疑的,假设我们现在是负责游戏开发的程序猿,要复刻这段场景,其中出现频率最高的就是炮弹。这里有一个很现实的亟待解决的问题:内存的消耗问题

  • 每个炮弹都是一个对象,每个对象都会占用一块内存
  • 炮弹越多,占用的内存就越大,如果炮弹足够多可能会出现内存枯竭问题
  • 假设内存足够大,频繁的创建炮弹对象,会影响游戏的流畅度,性能低

关于游戏中的炮弹,应该有以下一些需要处理的属性:

  1. 炮弹的坐标
  2. 炮弹的速度
  3. 炮弹的颜色渲染
  4. 炮弹的精灵图(就是一张大图上有很多小的图片,通过进行位置的控制,从大图中取出想要的某一张小的图片

炮弹爆炸过程的一张精灵图片

上面就是一张关于炮弹爆炸过程的精灵图片,假设一张炮弹的精灵图片有200k,那么1000个这样的炮弹占用的内存就是200M,这对于内存的消耗是非常惊人的。

游戏中制作一个炮弹需要的数据在上面已经分析出来了,在这四部分数据中有些属性是动态的,有些属性是静态的:

  • 静态资源:精灵图渲染的颜色
  • 动态属性:坐标速度

对应的动态资源肯定是不能被复用,所有炮弹可共享的就是这些静态资源,不论有多少炮弹,它们对应的精灵图渲染颜色数据可以只有一份,这样对于内存的开销就大大降低了。

上面的这种设计思路和设计模式中的享元模式很类似,享元模式就是摒弃了在每个对象中都保存所有的数据的这种方式,通过数据共享(缓存)让有限的内存可以加载更多的对象。

享元设计模式

在上图跷跷板的左侧每个对象使用的都是独立内存,而右侧所有对象都共同使用了某一部分内存,所以左侧重右侧轻,左侧占用内存多,右侧占用内存少。

享元模式和线程池比较类似,线程池可以复用线程,有效避免了线程的频繁创建和销毁,减少了性能的消耗并提高了工作效率。享元模式中的共享内存也可以将其称之为缓存,这种模式中共享的是对象。

对象的常量数据通常被称为内在状态, 其位于对象中, 其他对象只能读取但不能修改其数值。 而对象的其他状态常常能被其他对象 “从外部” 改变, 因此被称为外在状态。使用享元模式一般建议将内在状态和外在状态分离,将内在状态单独放到一个类中,这种类我们可以将其称之为享元类。

2. 设计炮弹

2.1 炸弹弹体

炮弹的共享数据其实就是享元模式中的享元数据,先定义一个享元数据类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 共享数据类
class SharedBombBody
{
public:
SharedBombBody(string sprite) : m_sprite(sprite)
{
cout << "正在创建 <" << m_sprite << ">..." << endl;
}
void move(int x, int y, int speed)
{
cout << "炸弹以每小时" << speed << "速度飞到了("
<< x << ", " << y << ") 的位置..." << endl;
}
void draw(int x, int y)
{
cout << "在 (" << x << ", " << y << ") 的位置重绘炸弹弹体..." << endl;
}

private:
string m_sprite; // 精灵图片
string m_color = string("black"); // 渲染颜色
};

通过构造函数得到精灵图片之后,该类对象中的数据就不会再发生任何变化了。

2.2 炸弹

有了炸弹的弹体,卡普就可以基于这部分静态资源扔一枚炮弹出去了,先定义一个发射炸弹的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 发射炮弹
class LaunchBomb
{
public:
LaunchBomb(SharedBombBody* body) : m_bomb(body) {}
int getX()
{
return m_x;
}
int getY()
{
return m_y;
}
void setSpeed(int speed)
{
m_speed = speed;
}
int getSpeed()
{
return m_speed;
}
void move(int x, int y)
{
m_x = x;
m_y = y;
m_bomb->move(m_x, m_y, m_speed);
draw();
}
void draw()
{
m_bomb->draw(m_x, m_y);
}

private:
int m_x = 0;
int m_y = 0;
int m_speed = 100;
SharedBombBody* m_bomb = nullptr;
};

由于发射出的每一枚型号相同的炮弹它们的外形都是相同的,所以这些炸弹可以共享同一个弹体对象(在类内部没有创建SharedBombBody 类对象)。对于炸弹被发射出去之后它的坐标以及速度肯定是会变化的,所以在上面的LaunchBomb 类中添加了对应的getset方法。

2.3 彩蛋

在很多游戏中,由于玩家触发了某个条件,此时系统会赠送给玩家一个彩蛋,这个彩蛋一般都是独一无二的。假设卡普在投掷炸弹的过程中,路飞通过自己的橡胶果实能力连续接住了10个炸弹,并将其反弹出去,这个时候卡普投出的某一个炸弹就会变成一个彩蛋(卡普最后扔出的超巨型炸弹也可以视为是一个彩蛋),对于彩蛋的处理我们的分析如下:

  • 这个彩蛋拥有和炸弹不一样的外观(使用的精灵图不同)
  • 不论是炸弹还是彩蛋,对于卡普来说对它们的处理动作是一样的
  • 炸弹爆炸会造成伤害,彩蛋爆炸会给玩家提供奖励或者造成非常严重的伤害或开启一段支线剧情等

通过上述的三点分析,我们可以得出结论,彩蛋和炸弹有相同的处理动作,只不过在细节处理上略有不同,对于这种情况,我们一般会提供一个抽象的基类并在这个类中提供一套虚操作函数,这样在子类中就可以重写父类提供的虚函数,提供不同的处理动作了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 享元基类
class FlyweightBody
{
public:
FlyweightBody(string sprite) : m_sprite(sprite) {}
virtual void move(int x, int y, int speed) = 0;
virtual void draw(int x, int y) = 0;
virtual ~FlyweightBody() {}
protected:
string m_sprite; // 精灵图片
string m_color = string("black"); // 渲染颜色
};

// 炸弹弹体
class SharedBombBody : public FlyweightBody
{
public:
using FlyweightBody::FlyweightBody;
void move(int x, int y, int speed) override
{
cout << "炸弹以每小时" << speed << "速度飞到了("
<< x << ", " << y << ") 的位置..." << endl;
}
void draw(int x, int y) override
{
cout << "在 (" << x << ", " << y << ") 的位置重绘炸弹弹体..." << endl;
}
};

// 唯一的炸弹彩蛋
class UniqueBomb : public FlyweightBody
{
public:
using FlyweightBody::FlyweightBody;
void move(int x, int y, int speed) override
{
// 此处省略对参数 x, y, speed的处理
cout << "彩蛋在往指定位置移动, 准备爆炸发放奖励..." << endl;
}
void draw(int x, int y) override
{
cout << "在 (" << x << ", " << y << ") 的位置重绘彩蛋运动轨迹..." << endl;
}
};

一般享元数据都是共享的,但是这里的UniqueBomb 类,它虽然是享元类的子类,但这个类的实例对象却是不共享数据(假设每个彩蛋的外观和用途都是不同的),表面看起来矛盾,但是也合乎常理。尽管我们大部分时间都需要共享对象来降低内存的损耗,但在个别时候也有可能不需要共享的数据,此时 UniqueBomb 子类就有存在的必要了,它可以帮助我们解决那些不需要共享对象场景下的问题,使用这种处理方式对应的操作流程是无需做出任何改变的。 如果有上述的需求,就可以和示例代码中一样给享元类提供一个基类。

2.4 享元工厂

假设炮弹有很多种型号,此时就需要有很多张精灵图,也就是说SharedBombBody 类型的对象对应也应该有很多个,此时我们就可以再添加一个享元工厂类,专门用来生产这些共享的享元类对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 享元工厂类
class BombBodyFactory
{
public:
SharedBombBody* getSharedData(string name)
{
SharedBombBody* data = nullptr;
// 遍历容器
for (auto item : m_bodyMap)
{
if (item.first == name)
{
// 找到了
data = item.second;
cout << "正在复用 <" << name << ">..." << endl;
break;
}
}
if (data == nullptr)
{
data = new SharedBombBody(name);
cout << "正在创建 <" << name << ">..." << endl;
m_bodyMap.insert(make_pair(name, data));
}
return data;
}
~BombBodyFactory()
{
for (auto item : m_bodyMap)
{
delete item.second;
}
}
private:
map<string, SharedBombBody*> m_bodyMap;
};

在享元工厂内部有一个map 容器,用于存储各种型号的炮弹的享元数据,这个享元工厂就相当于一个对象池,当调用了getSharedData(string name)函数之后,如果能够从map 容器找到name对应的享元对象就返回该对象,如果找不到就创建一个新的享元对象并储存起来,这样就可以实现对象的复用了。

2.5 发射炮弹

最后就可以把炮弹制作出来并让其在游戏中按照指定的轨迹运动了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
int main()
{
// 发射炮弹
BombBodyFactory* factory = new BombBodyFactory;
vector<LaunchBomb*> ballList;
vector<string> namelist = { "撒旦-1", "撒旦-1", "撒旦-2", "撒旦-2", "撒旦-2", "撒旦-3"};
for (auto name : namelist)
{
int x = 0, y = 0;
LaunchBomb* ball = new LaunchBomb(factory->getSharedData(name));
for (int i = 0; i < 3; ++i)
{
x += rand() % 100;
y += rand() % 50;
ball->move(x, y);
}
cout << "=========================" << endl;
ballList.push_back(ball);
}
// 彩蛋
UniqueBomb* unique = new UniqueBomb("大彩蛋");
LaunchBomb* bomb = new LaunchBomb(unique);
int x = 0, y = 0;
for (int i = 0; i < 3; ++i)
{
x += rand() % 100;
y += rand() % 50;
bomb->move(x, y);
}

for (auto ball : ballList)
{
delete ball;
}
delete factory;
delete unique;
delete bomb;
return 0;
}

上面的测试程序就相当于在游戏中,卡普扔出了6个炸弹和一个彩蛋,不论是炸弹还是彩蛋都可以通过LaunchBomb 类进行处理,这个类的构造函数在接收实参的时候实际上就是一个多态的应用。

程序的输出的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
正在创建 <撒旦-1>...
炸弹以每小时100速度飞到了(41, 17) 的位置...
在 (41, 17) 的位置重绘炸弹弹体...
炸弹以每小时100速度飞到了(75, 17) 的位置...
在 (75, 17) 的位置重绘炸弹弹体...
炸弹以每小时100速度飞到了(144, 41) 的位置...
在 (144, 41) 的位置重绘炸弹弹体...
=========================
正在复用 <撒旦-1>...
炸弹以每小时100速度飞到了(78, 8) 的位置...
在 (78, 8) 的位置重绘炸弹弹体...
炸弹以每小时100速度飞到了(140, 22) 的位置...
在 (140, 22) 的位置重绘炸弹弹体...
炸弹以每小时100速度飞到了(145, 67) 的位置...
在 (145, 67) 的位置重绘炸弹弹体...
=========================
正在创建 <撒旦-2>...
炸弹以每小时100速度飞到了(81, 27) 的位置...
在 (81, 27) 的位置重绘炸弹弹体...
炸弹以每小时100速度飞到了(142, 68) 的位置...
在 (142, 68) 的位置重绘炸弹弹体...
炸弹以每小时100速度飞到了(237, 110) 的位置...
在 (237, 110) 的位置重绘炸弹弹体...
=========================
正在复用 <撒旦-2>...
炸弹以每小时100速度飞到了(27, 36) 的位置...
在 (27, 36) 的位置重绘炸弹弹体...
炸弹以每小时100速度飞到了(118, 40) 的位置...
在 (118, 40) 的位置重绘炸弹弹体...
炸弹以每小时100速度飞到了(120, 43) 的位置...
在 (120, 43) 的位置重绘炸弹弹体...
=========================
正在复用 <撒旦-2>...
炸弹以每小时100速度飞到了(92, 32) 的位置...
在 (92, 32) 的位置重绘炸弹弹体...
炸弹以每小时100速度飞到了(113, 48) 的位置...
在 (113, 48) 的位置重绘炸弹弹体...
炸弹以每小时100速度飞到了(131, 93) 的位置...
在 (131, 93) 的位置重绘炸弹弹体...
=========================
正在创建 <撒旦-3>...
炸弹以每小时100速度飞到了(47, 26) 的位置...
在 (47, 26) 的位置重绘炸弹弹体...
炸弹以每小时100速度飞到了(118, 64) 的位置...
在 (118, 64) 的位置重绘炸弹弹体...
炸弹以每小时100速度飞到了(187, 76) 的位置...
在 (187, 76) 的位置重绘炸弹弹体...
=========================
彩蛋在往指定位置移动, 准备爆炸发放奖励...
在 (67, 49) 的位置重绘彩蛋运动轨迹...
彩蛋在往指定位置移动, 准备爆炸发放奖励...
在 (102, 93) 的位置重绘彩蛋运动轨迹...
彩蛋在往指定位置移动, 准备爆炸发放奖励...
在 (105, 104) 的位置重绘彩蛋运动轨迹...

3. 结构图

最后根据上面的代码就可以画出享元模式的UML类图了(学会了享元模式之后,应该先画UML类图,然后根据类图写代码。

image-20220913004906842

关于投掷炸弹可能也对应一个类,在上面的测试程序中对应的就是main()函数,在这个UML类图中并没有画出投掷炸弹的这个类。除此之后还有几个知识点需要我们做到心中有数:

  1. 享元模式中的享元类可以有子类也可以没有
  2. 享元模式中可以添加享元工厂也可以不添加
  3. 享元工厂的作用和单例模式类似,但二者的关注点略有不同
    • 单例模式关注的是类的对象有且只有一个
    • 享元工厂关注的是某个实例对象是否可以共享