专栏首页石奈子的Java之路彻底弄懂Spring Schedule加载和执行流程

彻底弄懂Spring Schedule加载和执行流程

Spring Scheduled

Spring定时任务源码分析

入口,启用定时任务注解 @EnableScheduling

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(SchedulingConfiguration.class)
@Documented
public @interface EnableScheduling {

}

org.springframework.scheduling.annotation.SchedulingConfiguration

线程调度配置

@Configuration
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class SchedulingConfiguration {

	@Bean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME)
	@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
	public ScheduledAnnotationBeanPostProcessor scheduledAnnotationProcessor() {
		return new ScheduledAnnotationBeanPostProcessor();
	}
}

org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor

public class ScheduledAnnotationBeanPostProcessor
		implements MergedBeanDefinitionPostProcessor, DestructionAwareBeanPostProcessor,
		Ordered, EmbeddedValueResolverAware, BeanNameAware, BeanFactoryAware, ApplicationContextAware,
		SmartInitializingSingleton, ApplicationListener<ContextRefreshedEvent>, DisposableBean {f

我们重点关注下 MergedBeanDefinitionPostProcessor、SmartInitializingSingleton, ApplicationListener这三个通知器。

ScheduledAnnotationBeanPostProcessor#postProcessAfterInitialization

MergedBeanDefinitionPostProcessor-->BeanPostProcessor-->postProcessAfterInitialization

  • 查找Bean对象中有@Scheduled方法的类,并开启任务
	@Override
	public Object postProcessAfterInitialization(final Object bean, String beanName) {
	// AOP原因,可能被代理,这里通过aop工具获取到真实Class
		Class<?> targetClass = AopProxyUtils.ultimateTargetClass(bean);
		// nonAnnotatedClasses集合中只记录没有Scheduled和Schedules注解的对象Class  
		// !contains如果为true则说明没在集合中,可能是没被加入进来,尝试加入,不符合的话就尝试开启Scheduled对象实例
		if (!this.nonAnnotatedClasses.contains(targetClass)) {
			Map<Method, Set<Scheduled>> annotatedMethods = MethodIntrospector.selectMethods(targetClass,
					new MethodIntrospector.MetadataLookup<Set<Scheduled>>() {
						@Override
						public Set<Scheduled> inspect(Method method) {
							Set<Scheduled> scheduledMethods = AnnotatedElementUtils.getMergedRepeatableAnnotations(
									method, Scheduled.class, Schedules.class);
							return (!scheduledMethods.isEmpty() ? scheduledMethods : null);
						}
					});
			if (annotatedMethods.isEmpty()) {
				this.nonAnnotatedClasses.add(targetClass);
				if (logger.isTraceEnabled()) {
					logger.trace("No @Scheduled annotations found on bean class: " + bean.getClass());
				}
			}
			else {
				//循环组件对象(Bean)中包含的@Scheduled方法,并开启
				// Non-empty set of methods
				for (Map.Entry<Method, Set<Scheduled>> entry : annotatedMethods.entrySet()) {
					Method method = entry.getKey();
					for (Scheduled scheduled : entry.getValue()) {
						processScheduled(scheduled, method, bean);
					}
				}
				if (logger.isDebugEnabled()) {
					logger.debug(annotatedMethods.size() + " @Scheduled methods processed on bean '" + beanName +
							"': " + annotatedMethods);
				}
			}
		}
		return bean;
	}
org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor#processScheduled

生成任务Runnable线程,根据注解属性不同(initialDelay、fixedDelay、cron(我们会以此属性来分析,其他两个相对简单)),设置不同的trigger、task,并交给taskScheduler执行。ps:taskScheduler是在SmartInitializingSingleton, ApplicationListener的方法中完成赋值。我们后边再讲。

protected void processScheduled(Scheduled scheduled, Method method, Object bean) {
		try {
			Assert.isTrue(method.getParameterTypes().length == 0,
					"Only no-arg methods may be annotated with @Scheduled");
			//拿到Bean对象中含有@Scheduled的Method实例 ;bean则是method要invoke时传递的obj  method.invoke(obj,args)  ;一般定时任务的方法入参是Null。
			Method invocableMethod = AopUtils.selectInvocableMethod(method, bean.getClass());
			//创建定时任务Runnable实例(下文中有说明)
			Runnable runnable = new ScheduledMethodRunnable(bean, invocableMethod);
			boolean processedSchedule = false;
			String errorMessage =
					"Exactly one of the 'cron', 'fixedDelay(String)', or 'fixedRate(String)' attributes is required";

			Set<ScheduledTask> tasks = new LinkedHashSet<ScheduledTask>(4);

			// Determine initial delay
			long initialDelay = scheduled.initialDelay();
			String initialDelayString = scheduled.initialDelayString();
			if (StringUtils.hasText(initialDelayString)) {
				Assert.isTrue(initialDelay < 0, "Specify 'initialDelay' or 'initialDelayString', not both");
				if (this.embeddedValueResolver != null) {
					initialDelayString = this.embeddedValueResolver.resolveStringValue(initialDelayString);
				}
				try {
					initialDelay = Long.parseLong(initialDelayString);
				}
				catch (NumberFormatException ex) {
					throw new IllegalArgumentException(
							"Invalid initialDelayString value \"" + initialDelayString + "\" - cannot parse into integer");
				}
			}
			// cron 属性的检查 如果cron有值。且合法,
			// Check cron expression
			String cron = scheduled.cron();
			if (StringUtils.hasText(cron)) {
				Assert.isTrue(initialDelay == -1, "'initialDelay' not supported for cron triggers");
				processedSchedule = true;
				String zone = scheduled.zone();
				if (this.embeddedValueResolver != null) {
					cron = this.embeddedValueResolver.resolveStringValue(cron);
					zone = this.embeddedValueResolver.resolveStringValue(zone);
				}
				TimeZone timeZone;
				if (StringUtils.hasText(zone)) {
					timeZone = StringUtils.parseTimeZoneString(zone);
				}
				else {
					timeZone = TimeZone.getDefault();
				}
				// 添加 到tasks中,并放入到时org.springframework.scheduling.config.ScheduledTaskRegistrar#scheduleCronTask(CronTask task)中,条件适宜的情况下,直接调用runnable执行。
				tasks.add(this.registrar.scheduleCronTask(new CronTask(runnable, new CronTrigger(cron, timeZone))));
			}

			// At this point we don't need to differentiate between initial delay set or not anymore
			if (initialDelay < 0) {
				initialDelay = 0;
			}

			// Check fixed delay
			long fixedDelay = scheduled.fixedDelay();
			if (fixedDelay >= 0) {
				Assert.isTrue(!processedSchedule, errorMessage);
				processedSchedule = true;
				tasks.add(this.registrar.scheduleFixedDelayTask(new IntervalTask(runnable, fixedDelay, initialDelay)));
			}
			String fixedDelayString = scheduled.fixedDelayString();
			if (StringUtils.hasText(fixedDelayString)) {
				Assert.isTrue(!processedSchedule, errorMessage);
				processedSchedule = true;
				if (this.embeddedValueResolver != null) {
					fixedDelayString = this.embeddedValueResolver.resolveStringValue(fixedDelayString);
				}
				try {
					fixedDelay = Long.parseLong(fixedDelayString);
				}
				catch (NumberFormatException ex) {
					throw new IllegalArgumentException(
							"Invalid fixedDelayString value \"" + fixedDelayString + "\" - cannot parse into integer");
				}
				tasks.add(this.registrar.scheduleFixedDelayTask(new IntervalTask(runnable, fixedDelay, initialDelay)));
			}

			// Check fixed rate
			long fixedRate = scheduled.fixedRate();
			if (fixedRate >= 0) {
				Assert.isTrue(!processedSchedule, errorMessage);
				processedSchedule = true;
				tasks.add(this.registrar.scheduleFixedRateTask(new IntervalTask(runnable, fixedRate, initialDelay)));
			}
			String fixedRateString = scheduled.fixedRateString();
			if (StringUtils.hasText(fixedRateString)) {
				Assert.isTrue(!processedSchedule, errorMessage);
				processedSchedule = true;
				if (this.embeddedValueResolver != null) {
					fixedRateString = this.embeddedValueResolver.resolveStringValue(fixedRateString);
				}
				try {
					fixedRate = Long.parseLong(fixedRateString);
				}
				catch (NumberFormatException ex) {
					throw new IllegalArgumentException(
							"Invalid fixedRateString value \"" + fixedRateString + "\" - cannot parse into integer");
				}
				tasks.add(this.registrar.scheduleFixedRateTask(new IntervalTask(runnable, fixedRate, initialDelay)));
			}

			// Check whether we had any attribute set
			Assert.isTrue(processedSchedule, errorMessage);

			// Finally register the scheduled tasks
			synchronized (this.scheduledTasks) {
				Set<ScheduledTask> registeredTasks = this.scheduledTasks.get(bean);
				if (registeredTasks == null) {
					registeredTasks = new LinkedHashSet<ScheduledTask>(4);
					this.scheduledTasks.put(bean, registeredTasks);
				}
				registeredTasks.addAll(tasks);
			}
		}
		catch (IllegalArgumentException ex) {
			throw new IllegalStateException(
					"Encountered invalid @Scheduled method '" + method.getName() + "': " + ex.getMessage());
		}
	}
org.springframework.scheduling.support.ScheduledMethodRunnable

定时方法任务的Runnable对象,传入bean实例(target)和bean中使用@Scheduled注解的方法(method),由taskScheduler开启执行。

public class ScheduledMethodRunnable implements Runnable {

	private final Object target;

	private final Method method;

	//传入bean实例(target)和bean中使用@Scheduled注解的方法(method)
	public ScheduledMethodRunnable(Object target, Method method) {
		this.target = target;
		this.method = method;
	}

	public ScheduledMethodRunnable(Object target, String methodName) throws NoSuchMethodException {
		this.target = target;
		this.method = target.getClass().getMethod(methodName);
	}


	public Object getTarget() {
		return this.target;
	}

	public Method getMethod() {
		return this.method;
	}

	//taskScheduler会调用此处 
	@Override
	public void run() {
		try {
			ReflectionUtils.makeAccessible(this.method);
			this.method.invoke(this.target);
		}
		catch (InvocationTargetException ex) {
			ReflectionUtils.rethrowRuntimeException(ex.getTargetException());
		}
		catch (IllegalAccessException ex) {
			throw new UndeclaredThrowableException(ex);
		}
	}

}
org.springframework.scheduling.config.ScheduledTaskRegistrar#scheduleCronTask

如果未处理的任务集合中有,且taskScheduler不为空,直接执行,否则依旧放入未处理的任务集合中。ps:从postProcessAfterInitialization过来时,taskScheduler为空。

	public ScheduledTask scheduleCronTask(CronTask task) {
		ScheduledTask scheduledTask = this.unresolvedTasks.remove(task);
		boolean newTask = false;
		if (scheduledTask == null) {
			scheduledTask = new ScheduledTask();
			newTask = true;
		}
		if (this.taskScheduler != null) {
			scheduledTask.future = this.taskScheduler.schedule(task.getRunnable(), task.getTrigger());
		}
		else {
			addCronTask(task);
			this.unresolvedTasks.put(task, scheduledTask);
		}
		return (newTask ? scheduledTask : null);
	}

org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor#afterSingletonsInstantiated

一般情况下,因为继承了ApplicationContext接口,applicationContext不为空,不会执行finishRegistration();方法

	public void afterSingletonsInstantiated() {
		// Remove resolved singleton classes from cache
		this.nonAnnotatedClasses.clear();

		if (this.applicationContext == null) {
			// Not running in an ApplicationContext -> register tasks early...
			finishRegistration();
		}
	}

org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor#onApplicationEvent

ApplicationEvent的refresh事件触发这里的操作,执行finishRegistration();方法。

	public void onApplicationEvent(ContextRefreshedEvent event) {
		if (event.getApplicationContext() == this.applicationContext) {
			// Running in an ApplicationContext -> register tasks this late...
			// giving other ContextRefreshedEvent listeners a chance to perform
			// their work at the same time (e.g. Spring Batch's job registration).
			finishRegistration();
		}
	}
org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor#finishRegistration

注入TaskScheduler,并调用ScheduledTaskRegistrar的afterProperties方法开启一些未处理的定时任务。

	private void finishRegistration() {
		//第一次进来this.scheduler为空
		if (this.scheduler != null) {
			this.registrar.setScheduler(this.scheduler);
		}

		if (this.beanFactory instanceof ListableBeanFactory) {
			Map<String, SchedulingConfigurer> beans =
					((ListableBeanFactory) this.beanFactory).getBeansOfType(SchedulingConfigurer.class);
			List<SchedulingConfigurer> configurers = new ArrayList<SchedulingConfigurer>(beans.values());
			AnnotationAwareOrderComparator.sort(configurers);
			for (SchedulingConfigurer configurer : configurers) {
				configurer.configureTasks(this.registrar);
			}
		}
		// 有task(cron、fixed任务等)且scheduler为空时
		if (this.registrar.hasTasks() && this.registrar.getScheduler() == null) {

			try {
				// 如果定义的有TaskScheduler类型的Bean,会直接设置进来(根据名字查),后边会根据类型查,再没有就查找ScheduledExecutorService的name和类型的
				this.registrar.setTaskScheduler(resolveSchedulerBean(TaskScheduler.class, false));
			}
			catch (NoUniqueBeanDefinitionException ex) {
				
				try {
					this.registrar.setTaskScheduler(resolveSchedulerBean(TaskScheduler.class, true));
				}
				catch (NoSuchBeanDefinitionException ex2) {
					
				}
			}
			catch (NoSuchBeanDefinitionException ex) {
				
				// Search for ScheduledExecutorService bean next...
				try {
					this.registrar.setScheduler(resolveSchedulerBean(ScheduledExecutorService.class, false));
				}
				catch (NoUniqueBeanDefinitionException ex2) {
					
					try {
						this.registrar.setScheduler(resolveSchedulerBean(ScheduledExecutorService.class, true));
					}
					catch (NoSuchBeanDefinitionException ex3) {

					}
				}
				catch (NoSuchBeanDefinitionException ex2) {
					
				}
			}
		}
		//最后执行 ScheduledTaskRegistrar的afterPropertiesSet方法
		this.registrar.afterPropertiesSet();
	}
org.springframework.scheduling.config.ScheduledTaskRegistrar#afterPropertiesSet
	public void afterPropertiesSet() {
		scheduleTasks();
	}
org.springframework.scheduling.config.ScheduledTaskRegistrar#scheduleTasks
	protected void scheduleTasks() {
	//如果为空 则自建处理器
		if (this.taskScheduler == null) {
			this.localExecutor = Executors.newSingleThreadScheduledExecutor();
			this.taskScheduler = new ConcurrentTaskScheduler(this.localExecutor);
		}
		if (this.triggerTasks != null) {
			for (TriggerTask task : this.triggerTasks) {
				addScheduledTask(scheduleTriggerTask(task));
			}
		}
		//处理cron类型的任务
		if (this.cronTasks != null) {
			for (CronTask task : this.cronTasks) {
				addScheduledTask(scheduleCronTask(task));
			}
		}
		if (this.fixedRateTasks != null) {
			for (IntervalTask task : this.fixedRateTasks) {
				addScheduledTask(scheduleFixedRateTask(task));
			}
		}
		if (this.fixedDelayTasks != null) {
			for (IntervalTask task : this.fixedDelayTasks) {
				addScheduledTask(scheduleFixedDelayTask(task));
			}
		}
	}
又回到前边的 ScheduledTaskRegistrar#scheduleTriggerTask 方法

又回到了这里,会执行this.taskScheduler.schedule(task.getRunnable(), task.getTrigger())逻辑。开启任务完成!!!

if (this.taskScheduler != null) {
			scheduledTask.future = this.taskScheduler.schedule(task.getRunnable(), task.getTrigger());
		}

小彩蛋Cron的解析

回到 ScheduledAnnotationBeanPostProcessor#processScheduled,我们来看下CronTrigger的逻辑。

tasks.add(this.registrar.scheduleCronTask(new CronTask(runnable, new CronTrigger(cron, timeZone))));
org.springframework.scheduling.support.CronTrigger#CronTrigger(java.lang.String, java.util.TimeZone)

我们看下CronSequenceGenerator是怎么解析cron表达式的

	public CronTrigger(String expression, TimeZone timeZone) {
		this.sequenceGenerator = new CronSequenceGenerator(expression, timeZone);
	}
org.springframework.scheduling.support.CronSequenceGenerator#CronSequenceGenerator(java.lang.String, java.util.TimeZone)

下边我们一层层找解析过程。最后发现,cron的前六位依次为 “秒 分 时 天 月 星期占位”。使用说明如下。

  • 支持?,*,/字符。
  • 秒位0-59,支持0/5这种格式
  • 分位0-59,支持0/5这种格式
  • 时位0-23,支持0/5这种格式
  • 天位0-31
  • 月位 0-12 ,以及"FOO,JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC" 这种格式。
  • 星期位 0-7,以及"SUN,MON,TUE,WED,THU,FRI,SAT" 这种格式。
	private final BitSet months = new BitSet(12);

	private final BitSet daysOfMonth = new BitSet(31);

	private final BitSet daysOfWeek = new BitSet(7);

	private final BitSet hours = new BitSet(24);

	private final BitSet minutes = new BitSet(60);

	private final BitSet seconds = new BitSet(60);
	
	public CronSequenceGenerator(String expression, TimeZone timeZone) {
		this.expression = expression;
		this.timeZone = timeZone;
		parse(expression);
	}
	public CronSequenceGenerator(String expression, TimeZone timeZone) {
		this.expression = expression;
		this.timeZone = timeZone;
		parse(expression);
	}

	// 核心解析过程  注意看 cron的前六位依次对应到时为 “秒 分 时 天 月 星期占位”的bitSet对象中。
	private void doParse(String[] fields) {
		setNumberHits(this.seconds, fields[0], 0, 60);
		setNumberHits(this.minutes, fields[1], 0, 60);
		setNumberHits(this.hours, fields[2], 0, 24);
		setDaysOfMonth(this.daysOfMonth, fields[3]);
		setMonths(this.months, fields[4]);
		setDays(this.daysOfWeek, replaceOrdinals(fields[5], "SUN,MON,TUE,WED,THU,FRI,SAT"), 8);

		if (this.daysOfWeek.get(7)) {
			// Sunday can be represented as 0 or 7
			this.daysOfWeek.set(0);
			this.daysOfWeek.clear(7);
		}
	}
	// 解析星期相关值(支持0-8,?,*等)
	private void setDays(BitSet bits, String field, int max) {
		if (field.contains("?")) {
			field = "*";
		}
		setNumberHits(bits, field, 0, max);
	}
private void setNumberHits(BitSet bits, String value, int min, int max) {
		String[] fields = StringUtils.delimitedListToStringArray(value, ",");
		for (String field : fields) {
			if (!field.contains("/")) {
				// Not an incrementer so it must be a range (possibly empty)
				int[] range = getRange(field, min, max);
				bits.set(range[0], range[1] + 1);
			}
			else {
				String[] split = StringUtils.delimitedListToStringArray(field, "/");
				if (split.length > 2) {
					throw new IllegalArgumentException("Incrementer has more than two fields: '" +
							field + "' in expression \"" + this.expression + "\"");
				}
				int[] range = getRange(split[0], min, max);
				if (!split[0].contains("-")) {
					range[1] = max - 1;
				}
				int delta = Integer.parseInt(split[1]);
				if (delta <= 0) {
					throw new IllegalArgumentException("Incrementer delta must be 1 or higher: '" +
							field + "' in expression \"" + this.expression + "\"");
				}
				for (int i = range[0]; i <= range[1]; i += delta) {
					bits.set(i);
				}
			}
		}
	}
	private int[] getRange(String field, int min, int max) {
		int[] result = new int[2];
		if (field.contains("*")) {
			result[0] = min;
			result[1] = max - 1;
			return result;
		}
		if (!field.contains("-")) {
			result[0] = result[1] = Integer.valueOf(field);
		}
		else {
			String[] split = StringUtils.delimitedListToStringArray(field, "-");
			if (split.length > 2) {
				throw new IllegalArgumentException("Range has more than two fields: '" +
						field + "' in expression \"" + this.expression + "\"");
			}
			result[0] = Integer.valueOf(split[0]);
			result[1] = Integer.valueOf(split[1]);
		}
		if (result[0] >= max || result[1] >= max) {
			throw new IllegalArgumentException("Range exceeds maximum (" + max + "): '" +
					field + "' in expression \"" + this.expression + "\"");
		}
		if (result[0] < min || result[1] < min) {
			throw new IllegalArgumentException("Range less than minimum (" + min + "): '" +
					field + "' in expression \"" + this.expression + "\"");
		}
		if (result[0] > result[1]) {
			throw new IllegalArgumentException("Invalid inverted range: '" + field +
					"' in expression \"" + this.expression + "\"");
		}
		return result;
	}
下次执行time
  • org.springframework.scheduling.support.CronTrigger#nextExecutionTime

this.sequenceGenerator.next(date)

  • org.springframework.scheduling.support.CronSequenceGenerator#next

定时任务最终会调用此处,此处使用的逻辑为你在注解中cron给定的值 。

public Date next(Date date) {
		/*
		The plan:

		1 Start with whole second (rounding up if necessary)

		2 If seconds match move on, otherwise find the next match:
		2.1 If next match is in the next minute then roll forwards

		3 If minute matches move on, otherwise find the next match
		3.1 If next match is in the next hour then roll forwards
		3.2 Reset the seconds and go to 2

		4 If hour matches move on, otherwise find the next match
		4.1 If next match is in the next day then roll forwards,
		4.2 Reset the minutes and seconds and go to 2
		*/

		Calendar calendar = new GregorianCalendar();
		calendar.setTimeZone(this.timeZone);
		calendar.setTime(date);

		// First, just reset the milliseconds and try to calculate from there...
		calendar.set(Calendar.MILLISECOND, 0);
		long originalTimestamp = calendar.getTimeInMillis();
		doNext(calendar, calendar.get(Calendar.YEAR));

		if (calendar.getTimeInMillis() == originalTimestamp) {
			// We arrived at the original timestamp - round up to the next whole second and try again...
			calendar.add(Calendar.SECOND, 1);
			doNext(calendar, calendar.get(Calendar.YEAR));
		}

		return calendar.getTime();
	}

结语

分析使用的Spring版本为4.3.18。代码上如有出入,请自行区分。最后如有不对,欢迎指正。 作者:ricky QQ交流群:244930845。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 原 荐 SpringBoot 2.0 系列0

    石奈子
  • 深入理解EnableAutoConfiguration原理

    源码分析@EnableAutoConfiguration在SpringBoot中的加载和实例化过程

    石奈子
  • 原 荐 Java9之HttpClientAP

    石奈子
  • Joomla V3.7.0 核心组件SQL注入漏洞分析

    风流
  • Cobalt Strike折腾踩坑填坑记录

    最近在做渗透测试相关的工作,因工作需要准备用Cobalt Strike,老早都知道这款神器,早几年也看过官方的视频教程,但英文水平太渣当时很多都没听懂,出于各种...

    FB客服
  • 安排上了!PC人脸识别登录,出乎意料的简单

    之前不是做了个开源项目嘛,在做完GitHub登录后,想着再显得有逼格一点,说要再加个人脸识别登录,就我这佛系的开发进度,过了一周总算是抽时间安排上了。

    程序员内点事
  • PC人脸识别登录,出乎意料的简单

    之前不是做了个开源项目嘛,在做完GitHub登录后,想着再显得有逼格一点,说要再加个人脸识别登录,就我这佛系的开发进度,过了一周总算是抽时间安排上了。

    程序员内点事
  • hbase源码系列(九)StoreFile存储格式

    从这一章开始要讲Region Server这块的了,但是在讲Region Server这块之前得讲一下StoreFile,否则后面的不好讲下去,这块是基础,Re...

    岑玉海
  • vue生命周期钩子函数

    1. created与mounted都常见于ajax请求,前者如果请求响应时间过长,容易白屏

    用户1149564
  • 寿司快卖:实现游戏主流程--制作寿司和客户显示动画特效

    上一节我们搭建了游戏的基本框架。游戏界面被分为若干个板块,其中一个板块显示了各种制作寿司的材料,它的目的是用于玩家根据信息组装各种寿司,本节我们进入游戏的主流程...

    望月从良

扫码关注云+社区

领取腾讯云代金券