• Spring容器IOC初始化过程


    一、老规矩,先比比点幺蛾子

    作为一个经常使用Spring的后端程序员,小编很早就想彻底弄懂整个Spring框架了!但它整体是非常大的,所有继承图非常复杂,加上小编修行尚浅,显得力不从心。不过,男儿在世当立志,今天就先从Spring IOC容器的初始化开始说起,即使完成不了对整个Spring框架的完全掌握,也不丢人,因为小编动手了,稳住,咱能赢!

    下面说一些阅读前的建议:

    • 1、阅读源码分析是非常无聊的,但既然你进来了,肯定也是对这个东西进行了解,也希望这篇总结能对你有所启发。
    • 2、前方高能,文章可能会非常的长,图文并茂。
    • 3、阅读前建议你对相关设计模式、软件设计6大原则有所了解,小编会在行文中进行穿插。
    • 4、小编在读大四,学识尚浅,喜欢专研,如果你发现文章观点有所错误或者与你见解有差异,欢迎评论区指出和交流!
    • 5、建议你边看文章的时候可以边在IDE中进行调试跟踪
    • 6、文章所有UML图利用idea自动生成,具体生成方法为:选中一个类名,先ctrl+shift+alt+U,再ctrl+alt+B,然后回车即可

    二、文章将围绕什么来进行展开?

    不多,就一行代码,如下图:

    这句是Spring初始化的代码,虽然只有一句代码,但内容贼多!

    三、Spring 容器IOC 有哪些东西组成?

    这样子,小编先理清下思路,一步一步来:

    • 1、上面那句代码有个文件叫applicationContext.xml,这是个资源文件,由于我们的bean都在里边进行配置定义,那Spring总得对这个文件进行读取并解析吧!所以Spring中有个模块叫Resource模块,顾名思义,就是资源嘛!用于对所有资源xml、txt、property等文件资源的抽象。关于对Resource的更多知识,可以参考下边两篇文章:

    谈一谈我对Spring Resource的理解

    Spring资源文件剖析和策略模式应用(李刚)

    下面先贴一张小编生成的类图(图片有点大,不知道会不会不清晰,如果不清晰可以按照上面说的idea生成方法去生成即可)

    可以看到Resource是整个体系的根接口,点进源码可以看到它定义了许多的策略方法,因为它是用了策略模式这种设计模式,运用的好处就是策略接口/类定义了同一的策略,不同的子类有不同的具体策略实现,客户端调用时传入一个具体的实现对象比如UrlResource或者FileSystemResource策略接口/类Resource即可!

    所有策略如下:

    • 2、上面讲了Spring框架对各种资源的抽象采用了策略模式,那么问题来了,现在表示资源的东西有了,那么是怎么把该资源加载进来呢?于是就有了下面的ResourceLoader组件,该组件负责对Spring资源的加载,资源指的是xmlproperties等文件资源,返回一个对应类型的Resource对象。。UML图如下:

    从上面的UML图可以看出,ResourceLoader组件其实跟Resource组件差不多,都是一个根接口,对应有不同的子类实现,比如加载来自文件系统的资源,则可以使用FileSystemResourceLoader,加载来自ServletContext上下文的资源,则可以使用ServletContextResourceLoader。 还有最重要的一点,从上图看出,ApplicationContext,AbstractApplication是实现了ResourceLoader的,这说明什么呢?说明我们的应用上下文ApplicationContext拥有加载资源的能力,这也说明了为什么可以通过传入一个String resource pathClassPathXmlApplicationContext("applicationContext.xml")就能获得xml文件资源的原因了!清晰了吗?nice!

    • 3、上面两点讲到了,好!既然我们拥有了加载器ResourceLoader,也拥有了对资源的描述Resource,但是我们在xml文件中声明的<bean/>标签在Spring又是怎么表示的呢?注意这里只是说对bean的定义,而不是说如何将<bean/>转换为bean对象。我想应该不难理解吧!就像你想表示一个学生Student,那么你在程序中肯定要声明一个类Student吧!至于学生数据是从excel导入,或者程序运行时new出来,或者从xml中加载进来这些都不重要,重要的是你要有一个将现实中的实体表示为程序中的对象的东西,所以<bean/>也需要在Spring中做一个定义!于是就引入一个叫BeanDefinition的组件,UML图如下:

    下面讲解下UML图:

    首先配置文件中的<bean/>标签跟我们的BeanDefinition是一一对应的,<bean>元素标签拥有classscopelazy-init等配置属性,BeanDefinition则提供了相应的beanClassscopelazyInit属性。

    其中RootBeanDefinition是最常用的实现类,它对应一般性的<bean>元素标签,GenericBeanDefinition是自2.5以后新加入的bean文件配置属性定义类,是一站式服务类。在配置文件中可以定义父<bean>和子<bean>,父<bean>RootBeanDefinition表示,而子<bean>ChildBeanDefiniton表示,而没有父<bean><bean>就使用RootBeanDefinition表示。AbstractBeanDefinition对两者共同的类信息进行抽象。 Spring通过BeanDefinition将配置文件中的<bean>配置信息转换为容器的内部表示,并将这些BeanDefiniton注册到BeanDefinitonRegistry中。Spring容器的BeanDefinitionRegistry就像是Spring配置信息的内存数据库,主要是以map的形式保存,后续操作直接从BeanDefinitionRegistry中读取配置信息。一般情况下,BeanDefinition只在容器启动时加载并解析,除非容器刷新或重启,这些信息不会发生变化,当然如果用户有特殊的需求,也可以通过编程的方式在运行期调整BeanDefinition的定义。

    • 4、有了加载器ResourceLoader,也拥有了对资源的描述Resource,也有了对bean的定义,我们不禁要问,我们的Resource资源是怎么转成我们的BeanDefinition的呢?因此就引入了BeanDefinitionReader组件,Reader嘛!就是一种读取机制,UML图如下:

    从上面可以看出,Spring 对reader进行了抽象,具体的功能交给其子类去实现,不同的实现对应不同的类,如PropertiedBeanDefinitionReader,XmlBeanDefinitionReader对应从Property和xml的Resource解析成BeanDefinition

    其实这种读取数据转换成内部对象的,不仅仅是Spring专有的,比如:Dom4j解析器SAXReader reader = new SAXReader(); Document doc = reader.read(url.getFile());//url是一个URLResource对象 严格来说,都是Reader体系吧,就是将统一资源数据对象读取转换成相应内部对象。

    • 5、好了!基本上所有组件都快齐全了!对了,还有一个组件,你有了BeanDefinition后,你还必须将它们注册到工厂中去,所以当你使用getBean()方法时工厂才知道返回什么给你。还有一个问题,既然要保存注册这些bean,那肯定要有个数据结构充当容器吧!没错,就是一个Map,下面贴出BeanDefinitionRegistry的一个实现,叫SimpleBeanDefinitionRegistry的源码图:

    BeanDefinitionRegistry的UML图如下:

    从图中可以看出,BeanDefinitionRegistry有三个默认实现,分别是SimpleBeanDefinitionRegistryDefaultListableBeanFactoryGenericApplicationContext,其中SimpleBeanDefinitionRegistryDefaultListableBeanFactory都持有一个Map,也就是说这两个实现类把保存了bean。而GenericApplicationContext则持有一个DefaultListableBeanFactory对象引用用于获取里边对应的Map。 在DefaultListableBeanFactory

    GenericApplicationContext

    • 6、前面说的5个点基本上可以看出ApplicationContext上下文基本直接或间接贯穿所有的部分,因此我们一般称之为容器,除此之外,ApplicationContext还拥有除了bean容器这种角色外,还包括了获取整个程序运行的环境参数等信息(比如JDK版本,jre等),其实这部分Spring也做了对应的封装,称之为Enviroment,下面就跟着小编的eclipse,一起debug下容器的初始化工程吧!

    四、实践是检验真理的唯一标准

    学生类Student.java如下:

    package com.wokao666;
    
    public class Student {
    
    	private int id;
    	private String name;
    	private int age;
    
    	public int getId() {
    		return id;
    	}
    
    	public void setId(int id) {
    		this.id = id;
    	}
    
    	public String getName() {
    		return name;
    	}
    
    	public void setName(String name) {
    		this.name = name;
    	}
    
    	public int getAge() {
    		return age;
    	}
    
    	public void setAge(int age) {
    		this.age = age;
    	}
    
    	public Student(int id, String name, int age) {
    		super();
    		this.id = id;
    		this.name = name;
    		this.age = age;
    	}
    
    	public Student() {
    		super();
    	}
    
    	@Override
    	public String toString() {
    		return "Student [id=" + id + ", name=" + name + ", age=" + age + "]";
    	}
    
    }
    复制代码

    application.xml中进行配置,两个bean:

    <bean id="stu1" class="com.wokao666.Student">
     	<property name="id" value="1"></property>
     	<property name="name" value="xiaoming"></property>
     	<property name="age" value="21"></property>
     </bean>
      <bean id="stu2" class="com.wokao666.Student">
     	<property name="id" value="2"></property>
     	<property name="name" value="xiaowang"></property>
     	<property name="age" value="22"></property>
     </bean>
    复制代码

    好了,接下来给最开头那段代码打个断点(Breakpoint):

    第一步:急切地加载ContextClosedEvent类,以避免在WebLogic 8.1中的应用程序关闭时出现奇怪的类加载器问题。

    这一步无需太过在意!

    第二步:既然是new ClassPathXmlApplicationContext() 那么就调用构造器嘛!

    第三步:

    第四步:

    好,我们跟着第三步中的super(parent),再结合上面第三节的第6小点UML图一步一步跟踪,然后我们来到AbstractApplicationContext的这个方法:

    那么里边的resourcePatternResolver的类型是什么呢?属于第三节说的6大步骤的哪个部分呢?通过跟踪可以看到它的类型是ResourcePatternResolver类型的,而ResourcePatternResolver又是继承了ResourceLoader接口,因此属于加载资源模块,如果还不清晰,咱们再看看ResourcePatternResolver的源码即可,如下图:

    对吧!不仅继承ResourceLoader接口,而且只定义一个getResources()方法用于返回Resource[]资源集合。再者,这个接口还使用了策略模式,其具体的实现都在实现类当中,好吧!来看看UML图就知道了!

    PathMatchingResourcePatternResolver这个实现类呢!它就是用来解释不同路径资源的,比如你传入的资源路径有可能是一个常规的url,又或者有可能是以classpath*前缀,都交给它处理。

    ServletContextResourcePatternResolver这个实现类顾名思义就是用来加载Servlet上下文的,通常用在web中。

    第五步:

    接着第四步的方法,我们在未进入第四步的方法时,此时会对AbstractApplicationContext进行实例化,此时this对象的某些属性被初始化了(如日志对象),如下图:

    接着进入getResourcePatternResolver()方法:

    第四步说了,PathMatchingResourcePatternResolver用来处理不同的资源路径的,怎么处理,我们先进去看看!

    如果找到,此时控制台会打印找到用于OSGi包URL解析的Equinox FileLocator日志。没打印很明显找不到!

    运行完成返回setParent()方法。

    第六步:

    如果父代是非null,,则该父代与当前this应用上下文环境合并。显然这一步并没有做什么事!parent显然是null的,那么就不合并嘛!还是使用当前this的环境。

    做个总结:前六步基本上做了两件事:

    • 1、初始化相关上下文环境,也就是初始化ClassPathXmlApplicationContext实例
    • 2、获得一个resourcePatternResolver对象,方便第七步的资源解析成Resource对象

    第七步:

    第七步又回到刚开始第三步的代码,因为我们前面6步已经完成对super(parent)的追踪。让我们看看setConfigLocation()方法是怎么一回事~

            /**
    	 * Set the config locations for this application context.//未应用上下文设置资源路径
    	 * <p>If not set, the implementation may use a default as appropriate.//如果未设置,则实现可以根据需要使用默认值。
    	 */
    	public void setConfigLocations(String... locations) {
    		if (locations != null) {//非空
    			Assert.noNullElements(locations, "Config locations must not be null");//断言保证locations的每个元素都不为null
    			this.configLocations = new String[locations.length];
    			for (int i = 0; i < locations.length; i++) {
    				this.configLocations[i] = resolvePath(locations[i]).trim();//去空格,很好奇resolvePath做了什么事情?
    			}
    		}
    		else {
    			this.configLocations = null;
    		}
    	}
    复制代码

    进入resolvePath()方法看看:

    	/**
    	 * 解析给定的资源路径,必要时用相应的环境属性值替换占位符,应用于资源路径配置。
    	 * Resolve the given path, replacing placeholders with corresponding
    	 * environment property values if necessary. Applied to config locations.
    	 * @param path the original file path
    	 * @return the resolved file path
    	 * @see org.springframework.core.env.Environment#resolveRequiredPlaceholders(String)
    	 */
    	protected String resolvePath(String path) {
    		return getEnvironment().resolveRequiredPlaceholders(path);
    	}
    复制代码

    进入getEnvironment()看看:

    	/**
    	 * {@inheritDoc}
    	 * <p>If {@code null}, a new environment will be initialized via
    	 * {@link #createEnvironment()}.
    	 */
    	@Override
    	public ConfigurableEnvironment getEnvironment() {
    		if (this.environment == null) {
    			this.environment = createEnvironment();
    		}
    		return this.environment;
    	}
    复制代码

    进入createEnvironment(),方法,我们看到在这里创建了一个新的StandardEnviroment对象,它是Environment的实现类,表示容器运行的环境,比如JDK环境,Servlet环境,Spring环境等等,每个环境都有自己的配置数据,如System.getProperties()System.getenv()等可以拿到JDK环境数据;ServletContext.getInitParameter()可以拿到Servlet环境配置数据等等,也就是说Spring抽象了一个Environment来表示环境配置。

    生成的StandardEnviroment对象并没有包含什么内容,只是一个标准的环境,所有的属性都是默认值。

    总结:对传入的path进行路径解析

    第八步:这一步是重头戏

    先做个小结:到现在为止,我们拥有了以下实例:

    现在代码运行到如下图的refresh()方法:

    看一下这个方法的内容是什么?

    @Override
    	public void refresh() throws BeansException, IllegalStateException {
    		synchronized (this.startupShutdownMonitor) {
    			// 刷新前准备工作,包括设置启动时间,是否激活标识位,初始化属性源(property source)配置
    			prepareRefresh();
    
    			// 创建beanFactory(过程是根据xml为每个bean生成BeanDefinition并注册到生成的beanFactory
    			ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
    
    			//准备创建好的beanFactory(给beanFactory设置ClassLoader,设置SpEL表达式解析器,设置类型转化器【能将xml String类型转成相应对象】,
    			//增加内置ApplicationContextAwareProcessor对象,忽略各种Aware对象,注册各种内置的对账对象【BeanFactory,ApplicationContext】等,
    			//注册AOP相关的一些东西,注册环境相关的一些bean
    			prepareBeanFactory(beanFactory);
    
    			try {
    				// 模板方法,为容器某些子类扩展功能所用(工厂后处理器)这里可以参考BeanFactoryPostProcessor接口的postProcessBeanFactory方法
    				postProcessBeanFactory(beanFactory);
    
    				// 调用所有BeanFactoryPostProcessor注册为Bean
    				invokeBeanFactoryPostProcessors(beanFactory);
    
    				// 注册所有实现了BeanPostProcessor接口的Bean
    				registerBeanPostProcessors(beanFactory);
    
    				// 初始化MessageSource,和国际化相关
    				initMessageSource();
    
    				// 初始化容器事件传播器
    				initApplicationEventMulticaster();
    
    				// 调用容器子类某些特殊Bean的初始化,模板方法
    				onRefresh();
    
    				// 为事件传播器注册监听器
    				registerListeners();
    
    				// 初始化所有剩余的bean(普通bean)
    				finishBeanFactoryInitialization(beanFactory);
    
    				// 初始化容器的生命周期事件处理器,并发布容器的生命周期事件
    				finishRefresh();
    			}
    			catch (BeansException ex) {
    				if (logger.isWarnEnabled()) {
    					logger.warn("Exception encountered during context initialization - " +
    							"cancelling refresh attempt: " + ex);
    				}
    				// 销毁已创建的bean
    				destroyBeans();
    				// 重置`active`标志
    				cancelRefresh(ex);
    				throw ex;
    			}
    			finally {
                                    //重置一些缓存
    				resetCommonCaches();
    			}
    		}
    	}
    复制代码

    在这里我想说一下,这个refresh()方法其实是一个模板方法,很多方法都让不同的实现类去实现,但该类本身也实现了其中一些方法,并且这些已经实现的方法是不允许子类重写的,比如:prepareRefresh()方法。更多模板方法设计模式,可看我之前的文章 谈一谈我对‘模板方法’设计模式的理解(Template)

    先进入prepareRefresh()方法:

        /**
    	 * Prepare this context for refreshing, setting its startup date and
    	 * active flag as well as performing any initialization of property sources.
    	 */
    	protected void prepareRefresh() {
    		this.startupDate = System.currentTimeMillis();//设置容器启动时间
    		this.closed.set(false);//容器关闭标志,是否关闭?
    		this.active.set(true);//容器激活标志,是否激活?
            
    		if (logger.isInfoEnabled()) {//运行到这里,控制台就会打印当前容器的信息
    			logger.info("Refreshing " + this);
    		}
    
    		// 空方法,由子类覆盖实现,初始化容器上下文中的property文件
    		initPropertySources();
    
    		//验证标记为必需的所有属性均可解析,请参阅ConfigurablePropertyResolver#setRequiredProperties
    		getEnvironment().validateRequiredProperties();
    
    		//允许收集早期的ApplicationEvents,一旦多播器可用,即可发布...
    		this.earlyApplicationEvents = new LinkedHashSet<ApplicationEvent>();
    	}
    复制代码

    控制台输出:

    三月 22, 2018 4:21:13 下午 org.springframework.context.support.ClassPathXmlApplicationContext prepareRefresh
    信息: Refreshing org.springframework.context.support.ClassPathXmlApplicationContext@96532d6: startup date [Thu Mar 22 16:21:09 CST 2018]; root of context hierarchy
    复制代码

    第九步:

    进入obtainFreshBeanFactory()方法:

    	/**
    	 * 告诉子类刷新内部bean工厂(子类是指AbstractApplicationContext的子类,我们使用的是ClassPathXmlApplicationContext)
    	 * Tell the subclass to refresh the internal bean factory.
    	 */
    	protected ConfigurableListableBeanFactory obtainFreshBeanFactory() {
    		refreshBeanFactory();//刷新Bean工厂,如果已经存在Bean工厂,那就关闭并销毁,再创建一个新的bean工厂
    		ConfigurableListableBeanFactory beanFactory = getBeanFactory();//获取新创建的Bean工厂
    		if (logger.isDebugEnabled()) {
    			logger.debug("Bean factory for " + getDisplayName() + ": " + beanFactory);//控制台打印
    		}
    		return beanFactory;
    	}
    复制代码

    进入refreshBeanFactory()方法:

    	/**
    	 * 该实现执行该上下文的基础Bean工厂的实际刷新,关闭以前的Bean工厂(如果有的话)以及为该上下文的生命周期的下一阶段初始化新鲜的Bean工厂。
    	 * This implementation performs an actual refresh of this context's underlying
    	 * bean factory, shutting down the previous bean factory (if any) and
    	 * initializing a fresh bean factory for the next phase of the context's lifecycle.
    	 */
    	@Override
    	protected final void refreshBeanFactory() throws BeansException {
    		if (hasBeanFactory()) {//如果已有bean工厂
    			destroyBeans();//销毁
    			closeBeanFactory();//关闭
    		}
    		try {
    			DefaultListableBeanFactory beanFactory = createBeanFactory();//创建一个新的bean工厂
    			beanFactory.setSerializationId(getId());//为序列化目的指定一个id,如果需要,可以将此BeanFactory从此id反序列化回BeanFactory对象。
    			//定制容器,设置启动参数(bean可覆盖、循环引用),开启注解自动装配
    			customizeBeanFactory(beanFactory);
    			////将所有BeanDefinition载入beanFactory中,此处依旧是模板方法,具体由子类实现
    			loadBeanDefinitions(beanFactory);
    			//beanFactory同步赋值
    			synchronized (this.beanFactoryMonitor) {
    				this.beanFactory = beanFactory;
    			}
    		}
    		catch (IOException ex) {
    			throw new ApplicationContextException("I/O error parsing bean definition source for " + getDisplayName(), ex);
    		}
    	}
    复制代码

    总结:这一步主要的工作就是判断刷新容器前是否已经有beanfactory存在,如果有,那么就销毁旧的beanfactory,那么就销毁掉并且创建一个新的beanfactory返回给容器,同时将xml文件的BeanDefinition注册到beanfactory中。如果不太清楚可以回过头看看我们的第三节第5点内容

    第十步:

    进入第九步的loadBeanDefinitions(beanFactory)方法中去take a look:

    	/**
    	 * 使用XmlBeanDefinitionReader来加载beandefnition,之前说过使用reader机制加载Resource资源变为BeanDefinition对象
    	 * Loads the bean definitions via an XmlBeanDefinitionReader.
    	 * @see org.springframework.beans.factory.xml.XmlBeanDefinitionReader
    	 * @see #initBeanDefinitionReader
    	 * @see #loadBeanDefinitions
    	 */
    	@Override
    	protected void loadBeanDefinitions(DefaultListableBeanFactory beanFactory) throws BeansException, IOException {
    		// 创建XmlBeanDefinitionReader对象
    		XmlBeanDefinitionReader beanDefinitionReader = new XmlBeanDefinitionReader(beanFactory);
    
    		// 使用当前上下文Enviroment中的Resource配置beanDefinitionReader,因为beanDefinitionReader要将Resource解析成BeanDefinition嘛!
    		beanDefinitionReader.setEnvironment(this.getEnvironment());
    		beanDefinitionReader.setResourceLoader(this);
    		beanDefinitionReader.setEntityResolver(new ResourceEntityResolver(this));
    
    		//初始化这个reader
    		initBeanDefinitionReader(beanDefinitionReader);
    		//将beandefinition注册到工厂中(这一步就是将bean保存到Map中)
    		loadBeanDefinitions(beanDefinitionReader);
    	}
    复制代码

    控制台输出:

    三月 22, 2018 5:09:40 下午 org.springframework.beans.factory.xml.XmlBeanDefinitionReader loadBeanDefinitions
    信息: Loading XML bean definitions from class path resource [applicationContext.xml]
    复制代码

    第十一步:

    进入prepareBeanFactory(beanFactory)方法:

    //设置bean类加载器
    //设置Spring语言表达式(SpEL)解析器
    //扫描ApplicationContextAware bean
    //注册类加载期类型切面织入(AOP)LoadTimeWeaver
    //为各种加载进入beanFactory的bean配置默认环境
    复制代码

    第十二步:

    postProcessBeanFactory(beanFactory)方法:

    postProcessBeanFactory同样作为一个模板方法,由子类来提供具体的实现,子类可以有自己的特殊对BeanDefinition后处理方法,即子类可以在这对前面生成的BeanDefinition,即bean的元数据再处理。比如修改某个beanid/name属性、scope属性、lazy-init属性等。

    第十三步:

    invokeBeanFactoryPostProcessors(beanFactory)方法:

    该方法调用所有的BeanFactoryPostProcessor,它是一个接口,实现了此接口的类需重写postProcessBeanFactory()这个方法,可以看出该方法跟第十二步的方法是一样的,只不过作为接口,更多的是提供给开发者来对生成的BeanDefinition做处理,由开发者提供处理逻辑。

    第十四步:

    其余剩下的方法基本都是像初始化消息处理源,初始化容器事件,注册bean监听器到事件传播器上,最后完成容器刷新。

    五、总结

    恭喜我,我终于写完了,同样也恭喜你,你也阅读完了。

    我很佩服我自己能花这么长时间进行总结发布,之所以要进行总结,那是因为小编还是赞同好记性不如烂笔头的说法。

    你不记,你过阵子就会忘记,你若记录,你过阵子也会忘记!区别在于忘记了,可以回过头在很短的时间内进行回忆,查漏补缺,减少学习成本。

    再者,我认为我分析的还不是完美的,缺陷很多,因此我将我写的所有文章发布出来和大家探讨交流,汕头大学有校训说得非常地好,那就是说之知识是用来共享的,因为共享了,知识才能承前启后。

    现在再梳理一下Spring初始化过程:

    • 1、首先初始化上下文,生成ClassPathXmlApplicationContext对象,在获取resourcePatternResolver对象将xml解析成Resource对象。
    • 2、利用1生成的context、resource初始化工厂,并将resource解析成beandefinition,再将beandefinition注册到beanfactory中。

    朋友们,发现毛病,请评论告诉小编,一起交流一起交流!



    本文系转载,原文链接:https://juejin.im/post/5ab30714f265da237b21fbcc
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
  • 相关阅读:
    HTML5 的成长之路
    黑马程序员——JAVA基础之数组
    黑马程序员——JAVA基础之函数,重载,内存结构
    黑马程序员——JAVA基础之程序控制流结构之循环结构,循环嵌套
    黑马程序员——JAVA基础之程序控制流结构之判断结构,选择结构
    黑马程序员——JAVA基础之语法、命名规则
    黑马程序员——JAVA基础之常用DOS命令和环境变量的配置
    Android应用开发高效工具集1---ant构建简单Android项目
    10个你能参与并学习的Java开源项目
    Anroid 异常:is not valid; is your activity running?
  • 原文地址:https://www.cnblogs.com/likui360/p/13142170.html
Copyright © 2020-2023  润新知