虽然NioEventLoop追朔到源头是继承了EventExector,但是两者在使用场景上有很大的区别。
NioEventLoop的主要场景是用在Nio的场景下的IO轮询,而EventExecutor则是在事件触发的时候,将事件执行的逻辑交给它去处理。
final EventExecutorGroup handlerGroup = new DefaultEventExecutor();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
...
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
if (sslCtx != null) {
p.addLast(handlerGroup, sslCtx.newHandler(ch.alloc()));
}
}
});
...
}
在ChannelPipeline中添加ChannelHandler时候,可以在第一个参数传递EventExecutor的具体实现,Netty会在事件触发的时候,将ChannelHandler的处理逻辑放在EventExecutor中执行,而不占用NioEventLoop的轮询时间。所以接下来我们来看看EventExecutor的逻辑处理。
其实我们从DefaultEventExecutor出发的话,就可以直到相关EventExecutor的继承关系图。
Netty最顶层的类是EventExecutorGroup,它继承了jdk的Executor、ScheduledExecutorService和Iterable,说明EventExecutorGroup实现了线程池、定时任务线程池以及迭代器的功能。
EventExeutorGroup直接继承的接口是ScheduledExecutorService和Iterable,所以其本身就默认提供了ScheduledExecutorService的调度方法。
// ScheduledExecutorService默认方法,提供了定时执行Runnable的功能
ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit);
// ScheduledExecutorService默认方法,提供了定时执行Callable的功能,会有执行定时任务后的返回值
<V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit);
// ScheduledExecutorService默认方法,按指定频率周期执行某个任务。同样也会确保
// 上一次任务执行再执行,但是如果上一次任务执行的时间超过了下一次任务执行的时间
// 就会在上一次任务执行完后立即执行,相当于连续执行。
ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit);
// 区别scheduleAtFixedRate,必须保证上次任务执行完毕后,才间隔delay时间再执行。
ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit);
在继承迭代器方便,主要是返回EventExecutor,其实可以从名字理解到EventExecutorGroup就是相当于一组EventExecutor。
// 返回一个由EventExecutorGroup管理的EventExecutor
EventExecutor next();
// 迭代器返回下一个EventExecutor
Iterator<EventExecutor> iterator();
EventExecutorGroup也提供了基本的任务提交功能,这里可能因为Netty全部都是异步的,需要Future去更好确定执行的任务结果,所以全部都继承了代返回值Future的submit方法。
Future<?> submit(Runnable task);
<T> Future<T> submit(Runnable task, T result);
<T> Future<T> submit(Callable<T> task);
最后EventExecutorGroup提供了优雅关闭的接口,当执行优雅关闭时,会让所有在其管理的EventExecutor尝试执行shutdown关闭自身。
// shutdownGracefully一旦执行,该方法返回true
boolean isShuttingDown();
// 优雅关闭
Future<?> shutdownGracefully();
// 带超时时间的优雅关闭
Future<?> shutdownGracefully(long quietPeriod, long timeout, TimeUnit unit);
// 当所有的EventExecutor都terminated时,该方法返回true
Future<?> terminationFuture();
EventExecutorGroup负责管理一组EventExecutor,类比jdk的ThreadPoolExecutor和Thread的关系。EventExecutor本身并不包含事件的处理逻辑,而是相当于提供一个执行事件的场所(线程)去执行ChannelHandler中的事件处理逻辑,当然本文依然把EventExecutor及其子类称为事件处理器,因为它
比较奇特的是EventExecutor继承了EventExecutorGroup,相当于Thread继承了ThreadPoolExecutor
public interface EventExecutor extends EventExecutorGroup {...}
其实这点很好理解,EventExecutorGroup相当于提供了多个事件处理器,可以同时提交多个任务给不同的事件处理器执行,而EventExecutor继承了EventExecutorGroup,本身也可以像EventExecutorGroup提交多个任务,但是只会委派给一个事件处理器执行。所以在注释里面你可以看到EventExecutorGroup和EventExecutor在next方法的区别
/**
* Returns a reference to itself.(EventExecutor)
*/
@Override
EventExecutor next();
/**
* Returns one of the {@link EventExecutor}s
* managed by this {@link EventExecutorGroup}.
*/
EventExecutor next();
EventExecutor比较重要的方法就是inEventLoop,因为Channel为了保证线程安全,只会和一个EventExecutor进行绑定,这样就可以保证只会有一个线程可以访问Channel,前提是必须保证Channel的所有操作都必须是在绑定的EventExecutor上,inEventLoop的作用就是体现在这里。
boolean inEventLoop();
boolean inEventLoop(Thread thread);
AbstractEventExecutor作用是实现了EventExecutor的部分公共方法。每个AbstractEventExecutor创建的时候可以指定EventExecutorGroup,表示当前对象是否被管理的,如果是独立的EventExecutor,可以传递null。
private final EventExecutorGroup parent;
protected AbstractEventExecutor() {
this(null);
}
protected AbstractEventExecutor(EventExecutorGroup parent) {
this.parent = parent;
}
// 返回EventExecutorGroup
public EventExecutorGroup parent() {
return parent;
}
如果是独立的EventExecutor,那么next方法返回的必然是自身。
public EventExecutor next() {
return this;
}
同时定义了执行任务的逻辑,也就是给出了在AbstractEventExecutor执行Runnable方法的逻辑,比较简单就是执行后如果抛出异常就打日志。
protected static void safeExecute(Runnable task) {
try {
task.run();
} catch (Throwable t) {
logger.warn("A task raised an exception. Task: {}", task, t);
}
}
但是AbstractEventExecutor并没有给出inEventLoop、shutdownGracefully的具体逻辑。而是交给其子类去负责定义。
这个类就比较有意思了,因为它的功能并不仅仅是执行任务,而是在一定的时候到达再执行任务。它内存存储了一个优先队列,将定时任务按deadline从小到大进行排序。
PriorityQueue<ScheduledFutureTask<?>> scheduledTaskQueue;
我们先来看下定时任务ScheduledFutureTask的定义。
5.1、ScheduledFutureTask
考虑到一个定时任务,处理存储任务的执行逻辑之外,还必须存储任务的具体执行时间。Netty在这里的具体做法是存储JVM的启动时那一刻的时间,并且以纳秒保存deadline。
// 保存JVM启动时间
private static final long START_TIME = System.nanoTime();
// 如果按时间轴来看的话,任何时间减去START_TIME返回的就是相当于0时刻启动经过的纳秒数
static long nanoTime() {
return System.nanoTime() - START_TIME;
}
// 保存的deadline,可以理解为哪一刻需要执行任务
private long deadlineNanos;
如果deadline是以0时刻开始计算的,所以计算距离存储的deadline还有多久的时候,就可以用以下公式计算
deadline-(当前时间 - 系统启动时间)
Netty中的计算也是这样的
public long deadlineNanos() {
return deadlineNanos;
}
public long delayNanos() {
return deadlineToDelayNanos(deadlineNanos());
}
static long deadlineToDelayNanos(long deadlineNanos) {
return Math.max(0, deadlineNanos - nanoTime());
}
// 如果给出某一刻的时间的话,计算也是一样的
public long delayNanos(long currentTimeNanos) {
return Math.max(0, deadlineNanos() - (currentTimeNanos - START_TIME));
}
ScheduledFutureTask本身也是继承了Comparable,所以在塞入优先队列的时候可以保证一定的顺序
public int compareTo(Delayed o) {
if (this == o) {
return 0;
}
ScheduledFutureTask<?> that = (ScheduledFutureTask<?>) o;
// 优先保证最近的deadline
long d = deadlineNanos() - that.deadlineNanos();
if (d < 0) {
return -1;
} else if (d > 0) {
return 1;
} else if (id < that.id) {
return -1;
} else {
assert id != that.id;
return 1;
}
}
ScheduledFutureTask继承的Comparable,有限保证deadline比较小的task可以排在队列的头。如果时间一样,则会根据id进行排序。id是只有在优先队列塞入任务时候才会定义。
// set once when added to priority queue
private long id;
// 具体调用
scheduledTaskQueue().add(task.setId(nextTaskId++));
现在知道了AbstractScheduledEventExecutor在队列里面会塞入SchedulerFutureTask,并且会在task的deadline中执行,那么此时会遇到两个问题
a、如何往队列中塞入ScheduledFutureTask;
b、如何准确在deadline的时候执行任务。
AbstractScheduledEventExecutor内部已经定义了往队列中塞入ScheduledFutureTask的逻辑。
private <V> ScheduledFuture<V> schedule(final ScheduledFutureTask<V> task) {
if (inEventLoop()) {
scheduledTaskQueue().add(task.setId(nextTaskId++));
} else {
executeScheduledRunnable(new Runnable() {
@Override
public void run() {
scheduledTaskQueue().add(task.setId(nextTaskId++));
}
}, true, task.deadlineNanos());
}
return task;
}
首先会判断当前是否处于EventLoop中,如果时直接往队列中塞入任务。否则,就会把这个塞入当作当作一个任务,在后续某一时刻执行。
void executeScheduledRunnable(Runnable runnable,
@SuppressWarnings("unused") boolean isAddition,
@SuppressWarnings("unused") long deadlineNanos) {
execute(runnable);
}
这里有一个问题,executeScheduledRunnable应该是交给ScheduledFutureTask对应的EventLoop中执行才对,所以这里具体要看实现的子类如何将任务转交给对应的ScheduledFutureTask,所以在这点保留。
最后关于AbstractScheduledEventExecutor如何处理定时任务的逻辑交给下面的SingelThreadEvenentExecutor去解析,这个类将是比较重要的,因为基本EventExecutor的实现逻辑都在这里面了。
SingleThreadEventExecutor就是不断塞入任务到队列中,然后另一个线程不断拿任务执行,就是和普通的线程池一样的生产者和消费者模式。
那么既然是生产者和消费者模式,塞入任务这件事交给外部的线程去操作,SingleThreadEventExecutor内部的线程池就是专门负责处理这些任务的。我们来看看内部的线程池是如何初始化的。
SingleThreadEventExecutor内部存有一个Thread变量,所有的任务都会在这个线程执行,但是其本身又不会直接使用这个线程,而是通过线程池的方式传递任务和管理线程。
// 线程池
private final Executor executor;
// 线程
private volatile Thread thread;
SingleThreadEventExecutor会初始化内部的线程池,具体在构造方法中实现。
protected SingleThreadEventExecutor(EventExecutorGroup parent, Executor executor,
boolean addTaskWakesUp, int maxPendingTasks,
RejectedExecutionHandler rejectedHandler) {
super(parent);
this.addTaskWakesUp = addTaskWakesUp;
// 最大塞入队列的任务数
this.maxPendingTasks = Math.max(16, maxPendingTasks);
// 任务执行器
this.executor = ThreadExecutorMap.apply(executor, this);
// 任务队列
taskQueue = newTaskQueue(this.maxPendingTasks);
// 队列满了之后的拒绝策略
rejectedExecutionHandler = ObjectUtil.checkNotNull(rejectedHandler, "rejectedHandler");
}
// ThreadExecutorMap
public static Executor apply(final Executor executor, final EventExecutor eventExecutor) {
ObjectUtil.checkNotNull(executor, "executor");
ObjectUtil.checkNotNull(eventExecutor, "eventExecutor");
return new Executor() {
@Override
public void execute(final Runnable command) {
executor.execute(apply(command, eventExecutor));
}
};
}
// ThreadExecutorMap
public static Runnable apply(final Runnable command, final EventExecutor eventExecutor) {
ObjectUtil.checkNotNull(command, "command");
ObjectUtil.checkNotNull(eventExecutor, "eventExecutor");
return new Runnable() {
@Override
public void run() {
setCurrentEventExecutor(eventExecutor);
try {
command.run();
} finally {
setCurrentEventExecutor(null);
}
}
};
}
第一个apply方法可能嵌套着看不太清楚,可以分成这样看
public static Executor apply(final Executor executor, final EventExecutor eventExecutor) {
ObjectUtil.checkNotNull(executor, "executor");
ObjectUtil.checkNotNull(eventExecutor, "eventExecutor");
return new Executor() {
@Override
public void execute(final Runnable command) {
Runnable runnable = apply(command, eventExecutor);
executor.execute(runnable);
}
};
}
所以初始化SingleThreadEventExecutor.EventExecutor的时候,过程就很清楚了。SingleThreadEventExecutor在初始化的时候,通过调用ThreadExecutorMap.apply返回了一个Executor,而Jdk原生的Executor只需要实现一个方法即可。
void execute(Runnable command);
所以在ThreadExecutorMap.apply会再传递一个Executor负责执行SingleThreadEventExecutor中的Executor的任务,换句话说,就是SingleThreadEventExecutor.Executor内部的任务执行是通过构造函数中传递的另一个Executor执行的,相当于就是一个代理模式。为什么需要这样做呢?重点在于第二个apply这个方法,它在传递任务Runnable的时候,会在任务执行的前后分别将执行此任务的EventExecutor绑定、解绑到自身持有的一个FastThreadLocal中。
private static final FastThreadLocal<EventExecutor> mappings = new FastThreadLocal<EventExecutor>();
private static void setCurrentEventExecutor(EventExecutor executor) {
mappings.set(executor);
}
这样绑定到一个FastThreadLocal的目的是,方便框架和内部任务去获取到执行的EventExecutor,要知道你最终执行任务的是线程Thread,和Runnable。你如果在执行任务的过程中,需要获取EventExecutor的话,如果放在每一个Runnable中其实不太现实,最好还是放在线程本身岁时可以拿到的地方,所以这里就会使线程本地变量。
而且要直到SingleThreadEventExecutor初始化的时候,传递的Executor是不限类型的,所以你可以传递多线程的线程池,但是呢SingleThreadEventExecutor只会和一个线程进行绑定,具体可以看到doStartThread()的逻辑,当你启动线程的时候,其实是通过往线程池丢了一个任务进去
private void doStartThread() {
executor.execute(new Runnable() {
...
// run内部是死循环
SingleThreadEventExecutor.this.run();
...
}
...
}
所以一旦SingleThreadEventExecutor从你通过构造函数传递的Executor获取到线程的时候,就会在run方法内部通过死循环不断获取任务执行,相当于会一直把握住该线程。
作为一个封装了底层的框架,应该尽量和底层的框架API使用方法不要很大的区别,SingleThreadEventExecutor既然继承自JDK底层的Executor,那么使用方法也不应该又很大的区别。当你想直接往里面执行任务的时候,只需要和以往的Executor一样即可。
SingleThreadEventExecutor.executor(Runnable runnable);
所以我们来看executor的逻辑
@Override
public void execute(Runnable task) {
if (task == null) {
throw new NullPointerException("task");
}
boolean inEventLoop = inEventLoop();
addTask(task);
// 因为任务必须丢给EventLoop内部绑定的线程执行,而这个线程是jdk原生的
// Executor的,只有在在任务内部执行过程中才可以获取,所以刚开始EventLoop
// 的thread应该是null,也就是inEventLoop应该是返回false,所以才会有startThread的这个方法
if (!inEventLoop) {
startThread();
if (isShutdown()) {
boolean reject = false;
try {
if (removeTask(task)) {
reject = true;
}
} catch (UnsupportedOperationException e) {
// The task queue does not support removal so the best thing we can do is to just move on and
// hope we will be able to pick-up the task before its completely terminated.
// In worst case we will log on termination.
}
if (reject) {
reject();
}
}
}
if (!addTaskWakesUp && wakesUpForTask(task)) {
wakeup(inEventLoop);
}
}
protected void addTask(Runnable task) {
if (task == null) {
throw new NullPointerException("task");
}
if (!offerTask(task)) {
reject(task);
}
}
final boolean offerTask(Runnable task) {
if (isShutdown()) {
reject();
}
return taskQueue.offer(task);
}
executor内部直接把任务放到队列中去,如果检测到当前线称不是SingleThreadEventExecutor内部存储的Thread变量,那么会直接退出;否则就会检测当前线程的状态是否启动。并且里面启动线程。下面是inEventLoop的逻辑
@Override
public boolean inEventLoop(Thread thread) {
return thread == this.thread;
}
具体任务的处理细节还得深入了解
// SingelTheadEventExecutor
private void startThread() {
// 如果当前SingelTheadEventExecutor还未启动,则尝试通过CAS修改状态
// CAS确保多个线程投递任务时,只有一个修改成功。
if (state == ST_NOT_STARTED) {
if (STATE_UPDATER.compareAndSet(this, ST_NOT_STARTED, ST_STARTED)) {
boolean success = false;
try {
// 最终进入doStartThread逻辑启动任务
doStartThread();
success = true;
} finally {
if (!success) {
STATE_UPDATER.compareAndSet(this, ST_STARTED, ST_NOT_STARTED);
}
}
}
}
}
这里其实可以发现一点,SingleThreadEventExecutor会通过内部的队列不断获取任务出来执行,但是它不是一开始就是启动的,而是类似懒加载那种,只有第一次有任务投递时才开始启动,而且为了避免多个线程投递任务时线程启动导致线程不安全,启动时都用了CAS保证只有一个线程能够修改状态正确。
private void doStartThread() {
assert thread == null;
executor.execute(new Runnable() {
@Override
public void run() {
thread = Thread.currentThread();
if (interrupted) {
thread.interrupt();
}
boolean success = false;
updateLastExecutionTime();
try {
SingleThreadEventExecutor.this.run();
success = true;
} catch (Throwable t) {
logger.warn("Unexpected exception from an event executor: ", t);
} finally {
for (;;) {
int oldState = state;
// 设置状态为ST_SHUTTING_DOWN
if (oldState >= ST_SHUTTING_DOWN || STATE_UPDATER.compareAndSet(
SingleThreadEventExecutor.this, oldState, ST_SHUTTING_DOWN)) {
break;
}
}
// Check if confirmShutdown() was called at the end of the loop.
if (success && gracefulShutdownStartTime == 0) {
if (logger.isErrorEnabled()) {
logger.error("Buggy " + EventExecutor.class.getSimpleName() + " implementation; " +
SingleThreadEventExecutor.class.getSimpleName() + ".confirmShutdown() must " +
"be called before run() implementation terminates.");
}
}
try {
// Run all remaining tasks and shutdown hooks.
for (;;) {
// 将任务和钩子函数执行完
if (confirmShutdown()) {
break;
}
}
} finally {
try {
cleanup();
} finally {
// Lets remove all FastThreadLocals for the Thread as we are about to terminate and notify
// the future. The user may block on the future and once it unblocks the JVM may terminate
// and start unloading classes.
// See https://github.com/netty/netty/issues/6596.
FastThreadLocal.removeAll();
// 设置状态为terminated
STATE_UPDATER.set(SingleThreadEventExecutor.this, ST_TERMINATED);
threadLock.countDown();
if (logger.isWarnEnabled() && !taskQueue.isEmpty()) {
logger.warn("An event executor terminated with " +
"non-empty task queue (" + taskQueue.size() + ')');
}
terminationFuture.setSuccess(null);
}
}
}
}
});
}
SingleThreadEventExecutor更加类似的是JDK原生的SingleThreadExecutor,都是只会有一个线程在不断处理任务,这样可以有效的避免的线程安全,当然效率可能会大大折扣,这个得看具体的场景。
SingleThreadEventExecutor没有定义run的具体实现,而是交给了子类实现。例如DefaultEventExecutor。
// DefaultEventExecutor
@Override
protected void run() {
for (;;) {
Runnable task = takeTask();
if (task != null) {
task.run();
updateLastExecutionTime();
}
if (confirmShutdown()) {
break;
}
}
}
文章浏览阅读2.5k次。vue项目使用乐橙云播放 轻应用直播SDK imouplayer.js_imouplayer
文章浏览阅读7.5k次,点赞2次,收藏7次。一、原理分析浏览器提供了 copy 命令 ,可以复制选中的内容?1document.execCommand("copy")如果是输入框,可以通过 select() 方法,选中输入框的文本,然后调用 copy 命令,将文本复制到剪切板但是 select() 方法只对 <input> 和 <textarea> 有效,对于 <p> 就不好使最后我的解决方案是,在页面..._网页 全文复制 onclick
文章浏览阅读1.6w次,点赞3次,收藏18次。关于VbyOne接口,V-by-One HS是由日本赛恩电子公司(THine Electornics)开发的适用于平板显示器的信号传输接口标准。目前,广泛应用在多功能打印机等办公设备、车载娱乐设备、机器人、安防系统等领域。以往的电视内部配线,传输图像信号都是采用LVDS标准。然而,随着电视画面向着高分辨率和高色彩深度的发展,传输速度的高速化以及传输线之间信号的时滞问题愈发显著。_vbyone接口引脚定义
文章浏览阅读2.5k次,点赞4次,收藏29次。1、导入所需库from sklearn import svmfrom sklearn.datasets import load_irisfrom sklearn.model_selection import train_test_splitfrom sklearn.metrics import confusion_matrixfrom sklearn.metrics import accuracy_score2、加载数据集(以iris为例)# load iris dataset_支持向量机混淆矩阵
文章浏览阅读1.2k次。微信小程序中利用image组件实现图片手势缩放,前端大神严灏的牛文,讲解了微信小程序中image组件的三种是缩放模式,三种之中,只有 aspectFit 模式可以等比例缩放图片,并显示完整的图片。原文:http://www.ifanr.com/technotes/740404微信小程序中,image 组件的 mode 有 12 种,其中只有三种是缩放模式。而在这三种之中,只有 aspectFit ..._小程序image旋转缩放
文章浏览阅读213次。xUnit frameworks 单元测试框架frameworks 框架unittest- python自带的单元测试库,开箱即用unittest2- 加强版的单元测试框架,适用于Python 2.7以及后续版本pytest- 成熟且功能强大的单元测试框架plugincompat- pytest的执行及兼容性插件nosetests- 让pytho..._mamba 测试框架
文章浏览阅读150次。1. 方法的定义Java方法是语句的集合,它们在一起执行一个功能。方法是解决一类问题的步骤的有序组合 方法包含于类或对象中 方法在程序中被创建,在其他地方被引用2. 方法的优点1. 使程序变得更简短而清晰。 2. 有利于程序维护。 3. 可以提高程序开发的效率。 4. 提高了代码的重用性。3. 方法的命名规则1.方法的名字的第一个单词应以小写字母作为开头,后面的单..._java的\方法
文章浏览阅读719次。简介Spark SQL是用于结构化数据处理的Spark模块。与基本的Spark RDD API不同,Spark SQL提供的接口为Spark提供了有关数据结构和正在执行的计算的更多信息。在内部,Spark SQL使用这些额外的信息来执行额外的优化。与Spark SQL交互的方法有多种,包括SQL和Dataset API。计算结果时,将使用相同的执行引擎,而与要用来表达计算的API /语言无关。这种统一意味着开发人员可以轻松地在不同的API之间来回切换,从而提供最自然的方式来表达给定的转换。Spark S_掌握sparksql的基本操作 掌握sparksql数据分析的思路和方法 掌握sparks
文章浏览阅读498次。核心步骤:取到验证码图片的url获得验证码登录代码from random import choiceimport requestsfrom day06.yundama import get_code#获得验证码def get_yzm_code(): img_url = 'http://www.yundama.com/index/captcha?' re...
文章浏览阅读1.3k次。只需在activity的oncreat方法中添加以下代码即可 getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON | WindowManager_activity.getwindow().addflags(windowmanager.layoutparams.flag_secure)
文章浏览阅读733次。我的机器是DELL Precision M6700工作站笔记本,自带显示器,外接了一个挂在墙壁上的宽屏幕AOC显示器(左手边),同时桌子上笔记本右手旁搁了一台DELL显示器(竖屏使用)。习惯使然,希望右手侧的DELL竖屏显示器作为第2显示器,左手侧的AOC宽屏显示器做为第三显示器,装好系统之后,顺序恰恰是反的,即自带显示器为1号,左手边AOC宽屏显示器为2号,而右手边竖屏显示器为3号,显示顺序如..._dell precision m6700 外接两台屏幕
文章浏览阅读2.7k次,点赞9次,收藏35次。C++ EGE 实现坦克大战小游戏因为有过一次用EGE写小游戏的经验,所以这一次写坦克大战快了很多。并且使用对象编程也简化了很多编程时繁琐的步骤。写出坦克大战使我在学习编程的道路上又迈出了一大步。技术环节:编译环境:Windows VS2019需求:控制坦克移动发射炮弹,炮弹可以消灭敌军坦克,且可以消灭砖块。坦克遇到方块会被挡住。敌军消灭我军三次或基地被毁则游戏失败,共摧毁十次敌方坦克...