[Android游戏开发]游戏框架的搭建
Posted 张柯宇
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[Android游戏开发]游戏框架的搭建相关的知识,希望对你有一定的参考价值。
通常情况下,游戏开发的基本框架中,一般包括以下模块:
窗口管理(Window management):该模块负责在android平台上创建、运行、暂停、恢复游戏界面等功能。
输入模块(Input):该模块和视窗管理模块是密切相关的,用来监测追踪用户的输入(比如触摸事件、按键事件、加速计事件等)。
文件输入输出(File I/O):此模块用来读取assets文件下图片、音频等资源。
图像模块(Graphics):在实际游戏开发中,这个模块或许是最复杂的部分。它负责加载图片并把它们绘制到屏幕上。
音频模块(Audio):这个模块负责在不同的游戏界面加载音各类频。
网络(networking):如果游戏提供多人游戏联网功能,此模块就是必须的。
游戏框架(Game framework):该模块把以上各种模块整合起来,提供一个易用的框架,来轻松地实现我们的游戏。
下面对每一个模块进行详细的描述。
1. 窗口管理
我们可以把游戏的窗口想象成一个可以在它上面绘制内容的画布。窗口管理模块负责定制窗口、添加各种UI组建、接受各类用户的输入事件。这些UI组件或许可以通过GPU等硬件加速(比如使用了OpenGL ES)。
该模设计时不是提供接口,而是和游戏框架整合在一起,之后会有相关的代码贴出。我们需要记住的是应用程序状态和窗口事件是该模块必须处理的事情:
Create: 当窗口被创建时被调用的方法。
Pause: 当应用程序由于默写原因暂停时调用的方法。
Resume: 当应用程序恢复到前台时调用的方法。
2.输入模块
大部分操作系统中,输入事件( 比如触屏事件、按键事件)是通过当前的窗口调度(dispatched)的,窗口再进一步把这些事件派发给当前选中的组件。因此我们只需要关注组件的事件即可。操作系统提供的UI APIs提供了事件分发机制,我们可以很容易地注册和监听事件,这也是输入模块的主要职责。有两种处理事件的做法:
轮询(Polling):在这种机制下,我们仅检查输入设备的当前状态,之前和之后的状态并无保存。这种输入事件处理适合处理诸如触屏按钮事件,而不适合跟踪文本的输入,因为按键事件的顺序并未保存。
基于事件的处理(Event-based handling):这种机制提供了记忆功能的事件处理,比较适合处理文本输入或者其他需要按键次序的操作。
在Android平台中,主要有三种输入事件:触屏事件、按键事件和加速计事件,前两种时间使用轮询机制和基于事件处理的机制都适合,加速计事件通常是轮询机制。
触屏事件有三种:
Touch down: 手机触屏时发生。
Touch drag: 手指拖动时发生,此前有Touch down事件产生。
Touch up: 手指抬起时发生。
每种触摸事件有相关的辅助信息:触屏的位置、指针索引(多点触摸时用来追踪识别不同的触点)
键盘事件包括两种:
Key down: 按下键盘时触发。
Key up: 释放键盘时触发。
每种按键事件也有相关的辅助信息:Key-down事件存储按键码,Key-up事件存储按键码和实际的Unicode字符。
加速计事件,系统不停的轮询加速剂的状态,并以三位坐标标识。
基于以上介绍,下面定义输入模块的一些接口,用来轮询触屏事件、按键事件和加速计事件。代码如下:
Input.java
package com.badlogic.androidgames.framework;
import java.util.List;
public interface Input {
public static class KeyEvent {
public static final int KEY_DOWN = 0;
public static final int KEY_UP = 1;
public int type;
public int keyCode;
public char keyChar;
}
public static class TouchEvent {
public static final int TOUCH_DOWN = 0;
public static final int TOUCH_UP = 1;
public static final int TOUCH_DRAGGED = 2;
public int type;
public int x, y;
public int pointer;
}
public boolean isKeyPressed(int keyCode);
public boolean isTouchDown(int pointer);
public int getTouchX(int pointer);
public int getTouchY(int pointer);
public float getAccelX();
public float getAccelY();
public float getAccelZ();
public List<KeyEvent> getKeyEvents();
public List<TouchEvent> getTouchEvents();
}
上述定义包括两个静态类:KeyEvent 和 TouchEvent。KeyEvent类和TouchEvent类都定义了相关事件的常量。KeyEvent同时定义了几个存储事件信息的变量:类型(type)、按键码(keyCode)和Unicode字符(keyChar)。TouchEvent也一样,定义了位置信息(x,y)、某个出触摸点的ID。比如第一个手指按下,ID是0,第二个手指按下 ID是1;如果两个手指按下,手指0释放,手指1保持,又一个手指按下时,ID为已经释放的0.
下面是输入接口中的轮询方法:Input.isKeyPressed()输入参数是 keyCode,返回相应的按键是否按下的布尔值;Input.isTouchDown(),Input.getTouchX()和Input.getTouchY()返回给定指针索引是否按下、对应的横竖坐标值,注意当对应的指针索引不存在时坐标值是未定义的。Input.getAccelX(), Input.getAccelY(), and Input.getAccelZ() 返回各自的加速计的坐标值;最后两个方法用作基于事件处理机制的,他们返回KeyEvent和TouchEvent实例,用于记录上次事件触发的信息,最新的事件在列表的最后。
通过这些简单的接口和类的,我们构建了我们的输入接口。下节继续分析文件处理的内容(File I/O)。
3. 文件读写(File I/O)
读写文件在游戏开发中是一项十分重要的功能。在Java开发中,我们主要关注InputStream和OutputStream及其实例,它们是Java中读写文件的标准方法。游戏开发中,比较多的是读取资源文件,比如配置文件、图片、音频文件等;写入文件的操作一般在保存用户进度和配置信息时使用。
下面是文件读写的接口:
FileIO.java
package com.badlogic.androidgames.framework;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
public interface FileIO {
public InputStream readAsset(String fileName) throws IOException;
public InputStream readFile(String fileName) throws IOException;
public OutputStream writeFile(String fileName) throws IOException;
}
上述代码中,我们传递一个文件名作为参数,返回一个流,具体的执行会在接口的实现中体现。同时我们会抛出一个IOException异常以防止读写文件出错。当我们完成读写后,需要关闭输入输出流。Asset 文件从应用程序的APK文件中读取,其他文件一般从内置存储器或者SDCard上读取,分别对应上述代码中的三个方法。
4.音频模块(Audio)
音频模块编程从来都是一个复杂的话题。这里不打算用到一些高级复杂的音频处理手段,主要是播放一些背景音乐。在书写代码前,让我们了解一下音频的基础知识。
采样率:定义了每秒从连续信号中提取并组成离散信号的采样个数,采样率越高音质越好,单位用赫兹(Hz)来表示,CD一般是44.1KHz。对于每个采样系统会分配一定存储位(bit数)来表达声波的声波振幅状态,称之为采样分辨率或采样精度,每增加1个bit,表达声波振幅的状态数就翻一翻,并且增加6db的动态范围态,1个2bit的数码音频系统表达千种状态,即12db的动态范围,以此类推。如16bit能够表达65536种状态,24bit可以表达多达16777216种状态。动态范围是指声音从最弱到最强的变化范围,人耳的听觉范围通常是20HZ~20KHZ。高的采样率意味着更多的存储空间。比如60s的声音,采样率8KHz、8bits,大约0.5M,采样率44KHz、16bits,超过5M,普通的3分钟的流行歌曲,将会超过15M。
为了即不降低质量有不太占据空间,很多比较好的压缩方法被提出来。比如MP3s 和OGGs格式就是网络中比较流行的压缩格式。
可以看到3min的歌曲占了不少空间。当我们播放游戏的后台音乐时,我们可以把音频流化而不是预先加载到内存。通常背景音乐只有一个,因此只需要到磁盘加载一次即可。
对于一些短的音效,比如爆炸声和枪的射击声,情况有所不同。这些短的音效经常会同时被调用多次,从磁盘对每个实例流化这些音效不是一个好的办法。幸运的是,短的音效并不占用太多的内存空间,因此只需把这些音效提前读入到内存即可,然后可以直接地同时播放这些音效。
因此我们的代码需要提供如下功能:
我们需要一种方法加载音频文件,用于流化播放(Music)和内存播放(Sound),同时提供控制播放功能。
相应的接口有三个,Audio、Music和Sound,代码如下。
Audio接口Audio.java
package com.badlogic.androidgames.framework;
public interface Audio {
public Music newMusic(String filename);
public Sound newSound(String filename);
}
Audio接口创建新的Music和 Sound实例。一个Music实例表示一个流音频文件,一个Sound实例表示一个保存在内存中的短的音效。方法 Audio.newMusic()和Audio.newSound()都是以文件名作为参数并抛出IOException以防文件加载失败(例如文件不存在或者文件损坏等情况)。
Music接口Music.jva
package com.badlogic.androidgames.framework;
public interface Music {
public void play();
public void stop();
public void pause();
public void setLooping(boolean looping);
public void setVolume(float volume);
public boolean isPlaying();
public boolean isStopped();
public boolean isLooping();
public void dispose();
}
Music接口有点复杂,包含了播放音乐流、暂定和停止、循环播放、音量控制(从0到1的浮点数)方法。当然,里面还有一些getter方法,用来获取当前音乐实例的状态。当我们不再需要Music 实例时, 我们可以销毁它(dispose方法),这会关闭系统资源,即流化的音频文件。
Sound接口Sound.java
package com.badlogic.androidgames.framework;
public interface Sound {
public void play(float volume);
public void dispose();
}
Sound接口比较简单,只包含play()和dispose()方法。前者以指定的音量为输入参数,我们可以在任何我们需要的时候播放音效。后者在我们不许Sound实例时,我们需要销毁它以释放它占用的内存空间。
5. 图像模块(Graphics)
最后一个模块是图像操作模块,用来绘制图像到屏幕上。不过要想高性能的绘制图像,就不得不了解一些基本的图像编程知识。让我们从绘制2D图像开始,首先要了解的一个问题是:图像究竟是如何绘制到屏幕的?答案相当复杂,我们不需要知道所有的细节。
光栅、像素和帧缓冲(Framebuffers)
现在的显示器都是基于光栅的,光栅是一个两维度的格子组成,也就是像素格。光栅格子的长宽,我们一般用像素来表示。如果仔细观察显示器(或者用放大镜),我们就可以发现显示器上面有一个一个的格子,这就是像素格或者光栅格。每个像素的位置可以用坐标表示,于是引入了二维坐标系统,这也意味着坐标值是整数。显示器源源不断地收到从图形处理器传过来的图像流,解码每个像素的颜色(程序或者操作系统设定),然后绘制到屏幕上。每秒钟显示器会进行多次刷新,刷新频率单位是Hz,比如LCD显示器主流刷新率是85Hz。
图形处理器需要从一个特殊的存储区域获取像素信息以便显示在显示器上,这个区域就叫做视频内存区,或者叫VRAM。这个区域一般称作帧缓冲区(framebuffer)。因此一个完整的屏幕图形叫做一个帧。对于每个显示器栅格中的像素,在帧缓冲区都有一个对应的内存地址。当我们需要改变屏幕显示内容时,我们只需要简单地改变帧缓冲区中的内容即可。
下图是显示器栅格和帧缓冲区的简单示意图:
垂直同步和双缓冲
普通的绘图方法,当要绘制的对象太复杂,尤其是含有位图时,这时的画面会显示的很慢,对于运动的画面,会给人“卡”住了的感觉,有时候还会导致画面闪烁。于是我们采用双缓冲技术(采用两个framebuffer)。 双缓冲的原理可以这样形象的理解:把电脑屏幕看作一块黑板。首先我们在内存环境中建立一个“虚拟“的黑板,然后在这块黑板上绘制复杂的图形,等图形全部绘制完毕的时候,再一次性的把内存中绘制好的图形“拷贝”到另一块黑板(屏幕)上。采取这种方法可以提高绘图速度,极大的改善绘图效果。下面是原理图:
要知道什么是垂直同步,必须要先明白显示器的工作原理。显示器上的所有图像都是一线一线的扫描上去的,无论是隔行扫描还是逐行扫描,显示器,都有2种同步参数——水平同步和垂直同步。水平同步信号决定了CRT画出一条横越屏幕线的时间,垂直同步信号决定了CRT从屏幕顶部画到底部,再返回原始位置的时间,而恰恰是垂直同步代表着CRT显示器的刷新率水平!
关闭垂直同步:我们平时运行操作系统一般屏幕刷新率一般都是在85Hz上下,此时显卡就会每按照85Hz的频率时间来发送一个垂直同步信号,信号和信号的时间间隔是85的分辨率所写一屏图像时间。
打开垂直同步:在游戏中,或许强劲的显卡迅速的绘制完一屏的图像,但是没有垂直同步信号的到达,显卡无法绘制下一屏,只有等85单位的信号到达,才可以绘制。这样fps自然要受到操作系统刷新率运行值的制约。也就是说,当然打开后如果你的游戏画面FPS数能达到或超过你显示器的刷新率,这时你的游戏画面FPS数被限制为你显示器的刷新率。如果达不到会出现不同程度的跳帧现象,FPS与刷新率差距越大跳帧越严重。一般对于高性能的显卡建议打卡,游戏画面会更好!打开后能防止游戏画面高速移动时画面撕裂现象,比如实况足球等。
关闭垂直同步,那么游戏中作完一屏画面,显卡和显示器无需等待垂直同步信号,就可以开始下一屏图像的绘制,自然可以完全发挥显卡的实力。
但是,不要忘记,正是因为垂直同步的存在,才能使得游戏进程和显示器刷新率同步,使得画面平滑,使得画面稳定。取消了垂直同步信号,固然可以换来更快的速度,但是在图像的连续性上,性能势必打折扣。这也是关闭垂直同步后发现画面不连续的理论原因!
图像格式
比较流行的两个图形格式是JPEG和 PNG。JPEG是有损压缩格式,PNG是无损压缩格式,因此PNG格式可以百分百重现原始的图像。有损压缩格式通常占用少的磁盘空间。我们采用何总压缩格式取决于我们的磁盘空间。和音频类似,当我们加载到内存中时,我们需要完全地解压一个图像。因此,即使你的压缩图像在磁盘上只有20K,在RAM中你依然需要width×height ×color depth的存储空间。
图像叠加
假定有一个我们可以渲染的帧缓冲区(framebuffer),同时有几个加载到RAM中的图片,我们笑需要把RAM中的图片逐次放入到帧缓冲区,比如一个背景图片和一个前景图片如图所示:
这个过程就叫做图像的合成和叠加,我们需要把不同的图片合成一个最终显示的图片。绘制图片的此项很重要,因为上面的图片总会覆盖下面的图片。
上面图像合成出现了问题:第二张图片的白色背景覆盖了第一张背景图片。我们怎样把第二张图的白色背景消去呢?这就需要alpha混合(alpha blending)。alpha混合是一种把源点的颜色值和目标点的颜色值按照一定的算法进行运算,得到一种透明的效果。
下面是最终合成图像的RGB值,公式如下
red = src.red * src.alpha + dst.red * (1 – src.alpha) blue = src.green * src.alpha + dst.green * (1 – src.alpha) green = src.blue * src.alpha + dst.blue * (1 – src.alpha) |
src和dst分别是我们需要混合的源图像和目标图像(源图像相当于人物,目标图像相当于背景)。下面是一个例子。
src = (1, 0.5, 0.5), src.alpha = 0.5, dst = (0, 1, 0) red = 1 * 0.5 + 0 * (1 – 0.5) = 0.5 blue = 0.5 * 0.5 + 1 * (1 – 0.5) = 0.75 red = 0.5 * 0.5 + 0 * (1 – 0.5) = 0.25 |
效果如下图所示
上述公式用了两次乘法,乘法消耗的时间多,为了提高运算速度,可以进行优化。如
red = (src.red- dst.red) * src.alpha + dst.red
Alpha是一个浮点数,我们可以转换成整数运算,因为一种颜色最多占8Bit,所以Alpha值最多是256,于是我们把Alpha的值乘以256,然后运算的时候再除以256,就得到下面的公式:
red = (src.red- dst.red) * src.alpha /256+ dst.red
这里,Alpha是一个0到256的数值。
具体到这个例子,我们只需要把源文件的白色像素的alpha值设为0即可。最终效果如下图:
图像模块的接口代码
通过以上介绍,我们可以开始设计我们的图像模块的接口。问下需要实现如下功能:
- 从磁盘加载图片到内存中,为以后绘制到屏幕做准备。
- 用特定颜色清除framebuffer
- 用指定颜色在framebuffer指定位置绘制像素。
- 在framebuffer上绘制线条和矩形。
- 绘制上面内存中的图片到framebuffer,能够整个绘制和部分绘制,alpha混合绘制。
- 得到framebuffer的长宽。
这里用两个接口来实现:Graphics和 Pixmap,下面是Graphics接口:
package com.badlogic.androidgames.framework;
public interface Graphics {
public static enum PixmapFormat {
ARGB8888, ARGB4444, RGB565
}
public Pixmap newPixmap(String fileName, PixmapFormat format);
public void clear(int color);
public void drawPixel(int x, int y, int color);
public void drawLine(int x, int y, int x2, int y2, int color);
public void drawRect(int x, int y, int width, int height, int color);
public void drawPixmap(Pixmap pixmap, int x, int y, int srcX, int srcY, int srcWidth, int srcHeight);
public void drawPixmap(Pixmap pixmap, int x, int y);
public int getWidth();
public int getHeight();
}
枚举 PixmapFormat保存了该游戏支持的像素的颜色值(包括透明度)。比如ARGB8888,A表示透明度,R表示红色,G表示绿色,B表示蓝色,他们非别用8位来表示,就是各有256种状态。 接下来看下接口的方法:
- Graphics.newPixmap()方法加载指定格式的图片。
- Graphics.clear()方法用特定颜色清除framebuffer。
- Graphics.drawPixel()方法在framebuffer中指定位置绘制给定颜色的像素。
- Graphics.drawLine()和 Graphics.drawRect()方法在framebuffer绘制线条和矩形。
- Graphics.drawPixmap()方法绘制图像的到framebuffer。(x, y) 坐标指定了framebuffer绘制的起始位置,参数 srcX和srcY指定了图片被绘制部分的起始位置。srcWidth和srcHeight制定了绘制的宽度和高度。
- Graphics.getWidth()和Graphics.getHeight()方法返回framebuffer的宽度和高度。
//Pixmap接口
package com.badlogic.androidgames.framework;
import com.badlogic.androidgames.framework.Graphics.PixmapFormat;
public interface Pixmap {
public int getWidth();
public int getHeight();
public PixmapFormat getFormat();
public void dispose();
}
- Pixmap.getWidth()和Pixmap.getHeight()方法返回图像的宽度和高度。
- Pixmap.getFormat()返回图片的格式。
- Pixmap.dispose()方法。Pixmap实例使用内存资源和其他潜在的系统资源,如果我们不在需要它,我们需要回收资源,这也该方法的作用。
6.游戏框架
所有的基础工作做完后,我们最后来探讨一下游戏框架本身。我们看下为了运行我们的游戏,还需要什么样的工作要做:
- 游戏被分为不同的屏幕(screen),每个屏幕执行着相同的任务:判断用户输入,根据输入渲染屏幕。一些节目或许不需要任何用户输入,但会过段时间后切换到下一屏幕.(如Splash界面)
- 屏幕需要以某种方法被管理(如我们需要跟踪当前的屏幕并且能随时切换的下一屏幕)
- 游戏需要允许屏幕访问不同的模块(比如图像模块、音频模块、输入模块等),这样屏幕才能加载资源,获取用户输入,播放声音,渲染缓冲区等。因为我们的游戏是实时游戏,我们需要当前的屏幕快速的更新。我们因此需要一个主循环来实现。主循环在游戏退出时结束。每次循环迭代成为一帧,每秒帧的次数我们成为帧速(FPS).
- 游戏需要追踪窗口的状态(如是否暂停游戏或者恢复等),并通知产生相应的处理事件。
- 游戏框架需要处理窗口的建立、UI组件的创建等
下面看下一些代码:
createWindowAndUIComponent();
Input input = new Input();
Graphics graphics = new Graphics();
Audio audio = new Audio();
Screen currentScreen = new MainMenu();
Float lastFrameTime = currentTime();
while( !userQuit() ) {
float deltaTime = currentTime() – lastFrameTime;
lastFrameTime = currentTime();
currentScreen.updateState(input, deltaTime);
currentScreen.present(graphics, audio, deltaTime);
}
cleanupResources();
代码首先创建了游戏的窗口和UI组件(createWindowAndUIComponent()方法),接着我们实例化了基本的组件,这些能保证游戏基本功能的实现。我们又实例化了我们的起始屏幕,并把它作为当前的屏幕。然后记下当前的时间。
接着我们进入了主循环,当用户想退出时我们可以结束主循环。在主循环里面,计算上一帧和当前帧的时间差,用来计算FPS。最后,我们更新了当前屏幕的状态并呈现给用户。updateState方法依赖时间差和输入状态,present方法包括渲染屏幕的状态到framebuffer,播放音频等。present方法也需要知道上次调用到现在的时间差。
当主循环结束后,我们就需要清理和释放各种资源了。
这就是游戏工作的流程:处理用户的输入、更新状态、并呈现给用户。
游戏和显示接口
下面是游戏运行时需要的接口:
- 建立窗口进和UI,并建立相应的事件机制
- 开启游戏的主循环
- 跟踪当前的屏幕显示,在每次主循环中让其更新
- 把UI线程中的事件转移到主线程中,并把这些事件传递给当前显示界面,以便同步变化。
- 确保能访问所有的游戏基本模块,如Input, FileIO,Graphics, 和 Audio.
下面是游戏接口的代码:
package com.badlogic.androidgames.framework;
public interface Game {
public Input getInput();
public FileIO getFileIO();
public Graphics getGraphics();
public Audio getAudio();
public void setScreen(Screen screen);
public Screen getCurrentScreen();
public Screen getStartScreen();
}
如上述所示,代码中有一些getter方法,用来返回模块的实例。
The Game.getCurrentScreen()方法返回当前激活的屏幕,之后我们会用一个抽象的类AndroidGame来实现这个接口,这个方法会实现除了Game.getStartScreen()之外所有的方法。实际游戏中如果我们创建AndroidGame的实例,我们需要继承AndroidGame并且重载Game.getStartScreen()方法,返回初次显示屏幕的一个实例。
为了让大家了解到通过上述方法构建一个游戏是如何简单,下面是一个例子(假定我们已经实现了AndroidGame类):
public class MyAwesomeGame extends AndroidGame {
public Screen getStartScreen () {
return new MySuperAwesomeStartScreen(this);
}
}
很简单是吧?所有我们要做的就是执行我们游戏显示的起始屏幕。我们继承的AndroidGame类来做其他工作。从这点来看,AndroidGame 类会要求MySuperAwesomeStartScreen在主循环中更新和重新渲染自己。注意我们把MyAwesomeGame的实例传递给了MySuperAwesomeStartScreen。
下面是抽象类Screen,之所是抽象类而不是接口,是因为我们可以提前在里面写一些子类都用到的方法,减轻子类的实现。代码如下:
//The Screen Class
package com.badlogic.androidgames.framework;
public abstract class Screen {
protected final Game game;
public Screen(Game game) {
this.game = game;
}
public abstract void update(float deltaTime);
public abstract void present(float deltaTime);
public abstract void pause();
public abstract void resume();
public abstract void dispose();
}
构造函数接收Game实例,并把它存到一个所有子类可以访问的final变量中。通过这种机制我们可以完成达成两件事情:
我们可以通过Game类的实例播放音频、绘制平面、获取用户输入和读写文件。
在合适时候我们可以通过调用Game.setScreen()设置一个新的当前平面显示。
方法 Screen.update() 和 Screen.present():它们会更新平面并同步地显示。Game实例会在主循环中调用它们。
方法 Screen.pause() 和 Screen.resume()在游戏暂停和恢复时被调用,同样这两个方法也是被Game的实例调用的,并通知给当前的平面显示。
方法Screen.dispose(),当Game.setScreen()方法被调用时,Screen.dispose()被Game的实例调用。通过这个方法Game的实例会销毁当前的显示屏幕,同时让其释放所有相关的系统资源,以便为新的屏幕窗口提供最大的内存。Screen.dispose()也是内容持久化的最后一个方法。
以上是关于[Android游戏开发]游戏框架的搭建的主要内容,如果未能解决你的问题,请参考以下文章
C/C++游戏开发丨Dungeon丨游戏框架搭建丨设计模式丨可扩展性高
C/C++游戏开发丨Dungeon丨游戏框架搭建丨设计模式丨可扩展性高
C/C++游戏开发丨Dungeon丨游戏框架搭建丨设计模式丨可扩展性高