类加载与Java主类加载机制解析

管理员账号

2017-08-23

小编说:类的加载机制与生命周期等概念,在各种书籍与各种网络博客里随处可见,然而对于一个想要真正了解其内部实现的人而言,那些都涉入过浅。本文从JVM源码的角度,还原出Java类加载的真实机制。
本文选自《揭秘Java虚拟机:JVM设计原理与实现》,了解本书详情请点击阅读原文。

1 类加载——镜像类与静态字段

类加载的最终结果便是在JVM的方法区创建一个与Java类对等的instanceKlass实例对象,但是在JVM创建完instanceKlass之后,又创建了与之对等的另一个镜像类——java.lang.Class。在JDK 6中,创建镜像类的逻辑被包含在instanceKlassKlass::allocate_instance_klass()函数中,在该函数的末尾执行 java_lang_Class::create_mirror()调用,该接口实现逻辑如下:

清单:/src/share/vm/classfile/javaClasses
功能:创建镜像类
oop java_lang_Class::create_mirror(KlassHandle k, TRAPS) {
  int computed_modifiers = k->compute_modifier_flags(CHECK_0);
  k->set_modifier_flags(computed_modifiers);
  if (SystemDictionary::Class_klass_loaded()) {
    Handle mirror = instanceKlass::cast(SystemDictionary::Class_klass())->allocate_permanent_instance(CHECK_0);
    mirror->obj_field_put(klass_offset,  k());
    k->set_java_mirror(mirror());

    // ...
    return mirror();
  } else {
    return NULL;
  }
}

通过观察这段源码可知,所谓的mirror镜像类,其实也是instanceKlass的一个实例对象,SystemDictionary::Classklass()返回的便是java_lang_Class类型,因此instanceMirrorKlass:: cast(SystemDictionary::Class_klass())->allocate_instance(k, CHECK_0)这行代码就是用来创建java.lang.Class这个Java类型在JVM内部对等的instanceKlass实例的。接着通过k->set_java mirror(mirror())调用,让当前所创建的klassOop引用刚刚实例化的java.lang.Class对象。JVM之所以在instanceKlass之外再创建一个mirror,是有用意的,总体而言,java.lang.Class是为了被Java程序调用,而instanceKlass则是为了被JVM内部访问。所以,JVM直接暴露给Java的是 java_mirror, 而不是 InstanceKlass。

事实上,JDK类库中所提供的反射等工具类,其实都基于java.lang.Class这个内部镜像实现。例如下面这个Java程序:

清单:/Test.java
功能:演示Java的反射功能
public class Test {
    public Integer i;
    public String s;

    public int add(int a, int b){
        return a + b;
    }

    public static void  main(String[] args) throws Exception {
        Class klass = Class.forName("Test");
        System.out.println(klass.getFields().length);
        for(int i = 0; i < klass.getFields().length; i++){
            System.out.println(klass.getFields()[i]);
        }
    }
}

该示例Java类很简单,Test类中包含2个公开的字段和一个公开的方法,在main()方法中通过java.lang.Class.for(String)接口反射获取Test类型,反射之后通过java.lang.Class.getFields()接口获取Test类中所包含的全部公开字段数组,并遍历字段数组,打印出字段名。运行该程序,输出如下:

2
public java.lang.Integer Test.i
public java.lang.String Test.s

打印结果显示Test类中一共包含2个公开字段,与定义的完全一致。在这里,重点研究的是,java.lang.Class.getFields()接口究竟如何知道Test类中有两个公开的字段。源码面前无秘密。首先看java.lang.Class.getFields()接口,该接口最终会调用java.lang.Class.getDeclaredFields0 (boolean publicOnly)接口,该接口是一个native接口,其最终调用的接口位于HotSpot内部的函数中,该函数如下:

清单:/src/share/vm/prims/jvm.cpp
功能:获取类中声明的字段
JVM_ENTRY(jobjectArray, JVM_GetClassDeclaredFields(JNIEnv *env, jclass ofClass, jboolean publicOnly))
{
  JVMWrapper("JVM_GetClassDeclaredFields");
  JvmtiVMObjectAllocEventCollector oam;

  instanceKlassHandle k(THREAD, java_lang_Class::as_klassOop(JNIHandles::resolve_non_null(ofClass)));
  constantPoolHandle cp(THREAD, k->constants());

  // 执行类的链接阶段
  k->link_class(CHECK_NULL);

  // 通过k->fields()获取类中的全部字段
  typeArrayHandle fields(THREAD, k->fields());
  int fields_len = fields->length();

  // ...

  return (jobjectArray) JNIHandles::make_local(env, result());
}
JVM_END

上面这个JVM_GetClassDeclaredFields()函数便是java.lang.Class.getDeclaredFields0 (boolean publicOnly)这个Java类方法所对应的内部实现。由于java.lang.Class.getDeclaredFields0 (boolean publicOnly)方法是类的成员方法,因此该方法包含一个隐藏的入参this,this指向java.lang.Class类型实例自己,所以调用的JVM_GetClassDeclaredFields()函数的第2个入参ofClass便是java.lang.Class类型实例。同时,在执行上面这个JVM_GetClassDeclaredFields()函数调用时,说明其前面的一个步骤——Class klass = Class.forName(“Test”)已经执行完了,此时在JVM内部的klass实例,实际上是Test类型在JVM内部的镜像类,虽然java.lang.Class仅仅是一个镜像类,但是也保存了Test这个Java类中的全部信息,所以在JVM_GetClassDeclaredFields()函数中能够获取Test类中的全部字段。这便是Java反射的原理。通过本示例也可以知道,Java的反射是离不开java.lang.Class这个镜像类的。

如果思维再放得开阔一点,可以这样认为,即使JVM内部没有安排java.lang.Class这么一个媒介作为面向对象反射的基础,那么JVM也必然要定义另外类,假设这个类就叫作Reflection,这个类能够直接被Java程序开发者使用,那么Reflection这个类也必然需要在JVM内部与所要反射的目标Java类所对应的instanceKlass之间建立联系,能够让Java开发者通过这个Reflection类反射出目标Java类的字段、方法等全部信息。从这个意义上而言,java.lang.Class并非是偶然有的,而是必然,是Java这种面向对象的语言与虚拟机实现机制这两种规范下的必然技术实现,如果非要说有巧合的话,那便是恰好叫了“java.lang.Class”这个类名。

既然java.lang.Class是一个必然的存在,所以每次JVM在内部为Java类创建一个对等的instanceKlass时,都要再创建一个对应的Class镜像类,作为反射的基础。

刚才讲过,在JDK 6中,静态字段会存储在instanceKlass的预留空间里,在JVM为instanceKlass申请内存空间时已经为静态字段预留了空间,而在创建完instanceKlass之后,JVM在ClassFileParser::parseClassFile()函数中调用thisklass->do_local_static_fields(&initialize staticfield, CHECK(nullHandle))对这部分内存空间进行初始化,do_local_static_fields()函数的实现如下:

清单:/src/share/vm/classfile/classFileParser.cpp
功能:为Java类中静态字段分配空间
void instanceKlass::do_local_static_fields(void f(fieldDescriptor*, TRAPS), TRAPS) {
  instanceKlassHandle h_this(THREAD, as_klassOop());
  do_local_static_fields_impl(h_this, f, CHECK);
}

void instanceKlass::do_local_static_fields_impl(instanceKlassHandle this_oop, void f(fieldDescriptor* fd, TRAPS), TRAPS) {
  fieldDescriptor fd;
  int length = this_oop->fields()->length();
  for (int i = 0; i < length; i += next_offset) {
    fd.initialize(this_oop(), i);
    if (fd.is_static()) { f(&fd, CHECK); } // Do NOT remove {}! (CHECK macro expands into several statements)
  }
}

这段逻辑遍历Java类中的全部静态字段并逐个将其塞进instanceKlass的预留空间中。在这段逻辑中,需要注意,instanceKlass::dolocal_static_fields(void f(fieldDescriptor, TRAPS), TRAPS)函数的第一个入参是函数指针,看上面这段逻辑,instanceKlass::do_local_static_fields(void f(fieldDescriptor, TRAPS), TRAPS)内部调用了instanceKlass::do_local_static_fields impl(instanceKlassHandle this_oop, void f(fieldDescriptor* fd, TRAPS), TRAPS),而在后者内部则通过函数指针f调用其指向的函数。那么指针f指向哪个函数呢?

在ClassFileParser::parseClassFile()函数中调用instanceKlass::do_local_static_fields(void f(fieldDescriptor*, TRAPS), TRAPS)时,所传入的函数指针是&initialize_static_field,所以该指针指向的函数如下:

清单:/src/share/vm/classfile/classFileParser.cpp
功能:初始化静态字段
static void initialize_static_field(fieldDescriptor* fd, TRAPS) {
  KlassHandle h_k (THREAD, fd->field_holder());
  if (fd->has_initial_value()) {
    BasicType t = fd->field_type();
    switch (t) {
      case T_BYTE:
        h_k()->byte_field_put(fd->offset(), fd->int_initial_value());
              break;
      case T_BOOLEAN:
        h_k()->bool_field_put(fd->offset(), fd->int_initial_value());
              break;
      case // ...
    }
  }
}

在该函数中,通过调用h_k()->**_field_put()系列接口,将不同类型的静态字段存储到instanceKlass对象实例的预留内存空间中,如此便完成了Java类中静态字段的存储。而在JDK 8中,静态字段不再存储于instanceKlass预留空间,而是转移到instanceKlass的镜像类——java. lang.Class的预留空间里去,因此在JDK 8的源码中,上面的这个initialize_static_field()函数定义到javaClasses.cpp中了。同时,创建mirror镜像类的接口也不再在java_lang_Class::create_mirror()函数中调用,而是在ClassFileParser::parseClassFile()函数中调用。虽然调用的地方不同了,但是函数实现的内部机制并没有从根本上发生变化,因此从这一点上看,JDK 6和JDK 8并没有做很大的变更。JDK 8之所以要将静态字段从instanceKlass迁移到mirror中,也不是没有道理,毕竟静态字段并非Java类的成员变量,如果从数据结构这个角度看,静态字段不能算作Java类这个数据结构的一部分,因此JDK 8将静态字段转移到mirror中。从反射的角度看,静态字段放在mirror中是合理的,毕竟在进行反射时,需要给出Java类中所定义的全部字段,无论字段是不是静态类型。例如,将上面的Test类做个修改,在里面增加一个static类型的公开字段,则最终的打印结果会包含该字段。

综上所述,对于JDK 6而言,类加载阶段所产出的最终结果便是如图10.3所示的这两个实例对象。

图10.3 java类加载阶段所产生的结果

在JDK 6中,由于mirror也是一个instanceKlass,因此其包含了instanceKlass所包含的一切字段。

2 Java主类加载机制

到上一节为止,Java类加载的过程终于全部讲完了。在前面章节详细讲解了常量池解析、字段解析、方法解析、instanceKlass创建及镜像类的创建。之所以要逐个详细讲解,一方面是因为JVM使用C/C++编写而成,而C/C++语言本身就比Java语言更具难度,相信只要不是直接从事JVM开发的道友,阅读起来都会比较吃力,里面有太多的内存分配、回收、指针、类型转换的内容,笔者作为Java开发者,阅读过程中也费了无数脑筋,相当不轻松,因此笔者感同身受,将一些比较关键的源代码和算法详细描述出来,这是自己辛苦阅读的一种沉淀,相信也会帮助很多对C/C++语言不够熟悉的道友。另一方面是因为JVM作为虚拟机,里面涉及的计算机基础知识多而杂,几乎覆盖了方方面面,其实现也复杂,然而其过程也精彩,所以虽然阅读的过程痛苦,但是结果却是快乐的,理解了原理之后再次面对Java程序,会有一种“一览众山小”之快感,你就是JVM世界里的神,做神的感觉,其美妙不足为外人道也,而这种享受也是支持笔者这两年里一直坚持写下去的最大动力。有苦有乐,生活才能丰富多彩。

牛皮吹完,我们应该总结一下类加载的整体过程了。虚拟机在得到一个Java class 文件流之后,接下来要完成的主要步骤如下:

(1)读取魔数与版本号。

(2)解析常量池,parse_constant_pool()。

(3)解析字段信息,parse_fields()。

(4)解析方法,parse_methods()。

(5)创建与Java类对等的内部对象instanceKlass,new_instanceKlass()。

(6)创建Java镜像类,create_mirror()。

以上便是一个Java类加载的核心流程。了解了类加载的核心流程之后,也许聪明的你会忍不住想,Java类的加载到底何时才会被触发呢?Java类加载的触发条件比较多,其中比较特殊的便是Java程序中包含main()主函数的类——这种类一般也被称作Java程序的主类。Java主类的加载由JVM自动触发——JVM执行完自身的若干初始化逻辑之后,第一个加载的便是Java程序的主类。总体上而言,Java主类加载的链路如下:

java.c::JavaMain():执行mainClass = LoadClass(env, classname)
  java.c::LoadClass():执行cls = (*env)->FindClass(env, buf)
    jni.cpp::JNI_ENTRY(jclass, jni_FindClass(JNIEnv *env, const char *name)):执行loader = Handle(THREAD, SystemDictionary::java_system_loader())
    jni.cpp::JNI_ENTRY(jclass, jni_FindClass(JNIEnv *env, const char *name)):执行result = find_class_from_class_loader(env, sym, true, loader, protection_domain, true, thread)加载主类
      jvm.cpp::find_class_from_class_loader():执行klassOop klass = SystemDictionary::resolve_or_fail(name, loader, protection_domain, throwError != 0, CHECK_NULL)
        SystemDictionary::resolve_or_fail()
          SystemDictionary::resolve_or_null()
            SystemDictionary::resolve_instance_class_or_null():执行k = load_instance_class(name, class_loader, THREAD)(Do actual loading)
              SystemDictionary::load_instance_class()
                JavaCalls::call_virtual();
                  java.lang.ClassLoader.loadClass(String)
                    sun.misc.AppClassLoader.loadClass(String, boolean)
                      java.lang.ClassLoader.loadClass(String, boolean)
                        java.net.URLClassLoader.findClass(final String)
                          java.net.URLClassLoader.defineClass(String, Resource)
                            java.lang.ClassLoader.defineClass(String, java.io.ByteBuffer, ProtectionDomain)
                              native java.lang.ClassLoader.defineClass0()
                                ClassLoader.c::Java_java_lang_ClassLoader_defineClass1()
                                  jvm.cpp::JVM_DefineClassWithSource()
                                    jvm.cpp::jvm_define_class_common()
                                      SystemDictionary.cpp::resolve_from_stream()
                                        ClassFileParser.cpp::parseClassFile()

上面是Java程序main主类加载的整体链路,该调用链路的核心逻辑如下:

(1)JVM启动后,操作系统会调用java.c::main()主函数,从而进入JVM的世界。java.c::main()方法调用java.c::JavaMain()方法,java.c::JavaMain()方法主要执行JVM的初始化逻辑,初始化完毕之后,便会搜索Java程序的main()主函数所在的类,也即“主类”,找到主类的类名之后,便会调用mainClass = LoadClass(env, classname)对主类进行加载。

(2)LoadClass(env, classname)方法是java.c::LoadClass()方法,而后者执行cls = (*env)->FindClass(env, buf)来寻找主类。

(3)(env)->FindClass(env, buf)函数首先跳转到jni.cpp::JNIENTRY(jclass, jni FindClass(JNIEnv env, const char name)),JNI_ENTRY是一个宏,在预编译阶段便已展开,这个宏作用的结果是:(env)->FindClass(env, buf)最终会调用jni.cpp::jni_FindClass(JNIEnv env, const char name)函数。

jni.cpp::jni_FindClass(JNIEnv env, const char name)函数先调用loader = Handle(THREAD, SystemDictionary::java_system_loader())获取类加载器。Java程序主类的类加载器默认是系统加载器,该加载器是JDK类库中定义的sun.misc.AppClassLoader,关于该加载器的细节会在后文详述。JVM体系中加载器的继承关系如图下图所示。

JVM系统加载器的继承关系

由图上图可知,系统加载器所继承的顶级父类是java.lang.ClassLoader,这是JDK类库所提供的核心加载器。事实上,无论Java程序内部有没有自定义类加载器,最终都会调用java.lang.ClassLoader所提供的几个native接口完成类的加载,这些接口主要包括如下3种:

private native Class<?> defineClass0(String name, byte[] b, int off, int len, ProtectionDomain pd);

private native Class<?> defineClass1(String name, byte[] b, int off, int len, ProtectionDomain pd, String source);      

private native Class<?> defineClass2(String name, java.nio.ByteBuffer b,   int off, int len, ProtectionDomain pd,   String source);

Java主类的加载也无法绕过这3个接口。

jni.cpp::JNI_ENTRY(jclass, jni_FindClass(JNIEnv env, const char name))函数内部获取到系统加载器之后,接着便开始调用find_class_from_class_loader()接口加载主类,而后者则调用SystemDictionary::resolve_or_fail()接口。

(4)SystemDictionary::resolve_or_fail()接口经过一系列调用,最终调用SystemDictionary:: resolve_instance_class_or_null()接口,该接口内部逻辑比较冗长,会经过层层判断,确认同一个加载器没有别的线程在加载同一个类,则最终会执行真正的加载,调用SystemDictionary::load_instance_class()接口,该接口内部执行如下调用:

JavaCalls::call_virtual(&result,
                          class_loader,
                          spec_klass,
                          vmSymbols::loadClass_name(),
                          vmSymbols::string_class_signature(),
                          string,
                          CHECK_(nh));

JavaCalls::call_virtual()接口的主要功能是根据输入的参数,调用指定的Java类中的指定方法。该接口的第2个入参(入参从位置1开始计数)指明所调用的Java类对应的instance,第4个入参指明所调用的特定方法,第5个入参指明所调用的Java类的签名信息。当JVM执行Java程序主类加载时,向JavaCalls::call_virtual()接口传入的第2和第4个入参分别是class_loader和vmSymbols::loadClass_name(),vmSymbols::loadClass_name()返回的方法名是loadClass(),而class_loader则是前置流程中实例化好的系统加载器——AppClassLoader,在JVM内部对等的实例对象。同时,JavaCalls::call_virtual()接口的第5个入参是vmSymbols::string_class_signature(),其返回的字符串是(Ljava/lang/String;)Ljava/lang/Class,该字符串表示所调用的Java方法的入参是Ljava/lang/String,而返回值则是Ljava/lang/Class。由此可知,当JVM加载Java程序的主类时,最终会调用AppClassLoader.loadClass(String)这个方法。由此,JVM的流程便转移到了Java的世界,进入到了Java类的逻辑流之中。

JavaCalls::call_virtual()接口的第6个入参则包含所调用的Java方法所需要的全部入参信息,在JVM加载Java应用程序主类时,向JavaCalls::call_virtual()接口所传入的第6个入参是string,在SystemDictionary::load_instance_class()函数中,该入参封装了所需要加载的Java类的全限定名称,最终这个全限定名称将作为java.lang.AppClassLoader.loadClass(String)接口的入参,系统加载器据此加载目标Java类。

JavaCalls::callvirtual()接口最终会调用JavaCalls::call()接口,JavaCalls::call()接口调用JavaCalls::call_helper(),而后者则会调用StubRoutines::call_stub()例程,对于该例程,阅读过全书的小伙伴一定不会陌生,该例程在本书前面专门花了一章去讲解,有不清楚的小伙伴可以回过去仔细阅读。总体而言,该例程在运行期对应着一段机器码,其作用是辅佐JVM执行Java类方法。这里不得不提一句,JVM作为一款虚拟机,其本身由C/C++语言写成,但是JVM是为执行Java字节码文件而生的,因此JVM内部必然有一套机制能够从C/C++程序调用Java类中的方法,这套机制便通过JavaCalls类来实现,该类中定义了各种call*()接口,这些接口最终都要调用StubRoutines::call_stub()例程,从而辅佐JVM执行Java方法。

事实上,JavaCalls::call_virtual()接口在JVM内部是一个很常用的接口,大凡涉及Java类成员方法的调用,最终都会经过该接口。

(5)经过上一个步骤,JVM最终会调用sun.misc.AppClassLoader.loadClass(String)接口加载Java应用程序的主类。AppClassLoader继承自java.lang.ClassLoader这个基类,java.lang. ClassLoader.loadClass(String)方法调用loadClass(String, boolean)方法,由于继承的关系,实际调用的是sun.misc.AppClassLoader.loadClass(String, boolean)方法,该方法的实现逻辑如下:

清单:/src/sun/misc/Launcher.java
功能:系统加载器加载类的逻辑
public Class loadClass(String name, boolean resolve) throws ClassNotFoundException{
            int i = name.lastIndexOf('.');
            if (i != -1) {
                SecurityManager sm = System.getSecurityManager();
                if (sm != null) {
                    sm.checkPackageAccess(name.substring(0, i));
                }
            }
            return (super.loadClass(name, resolve));
}

这段代码逻辑是,先判断所加载的类名中是否包含点号“.”,如果包含则说明传入的一定是类的全限定名,包含了包名,则JVM调用SecurityManager模块检查包的访问权限。通过访问权限验证之后,则调用super.loadClass(name, resolve)方法。由于继承关系,super.loadClass(name, resolve)方法其实调用的是java.lang.ClassLoader.loadClass(String name, boolean resolve)方法,该方法的主要逻辑如下:

清单:/src/java/lang/ClassLoader
功能:java.lang.ClassLoader.loadClass(String name, boolean resolve)方法逻辑
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    Class c = this.findLoadedClass(name);
    if(c == null) {
        try {
            if(this.parent != null) {
                c = this.parent.loadClass(name, false);
            } else {
                c = this.findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException var5) { ; }

        if(c == null) {
            c = this.findClass(name);
        }
    }

    if(resolve) {
        this.resolveClass(c);
    }

    return c;
}

在java.lang.ClassLoader.loadClass(String name, boolean resolve)方法中,首先通过findLoadedClass(name)方法判断当前加载器是否加载过指定的类,如果没有加载,则判断当前加载器的parent是否为null,如果不为null,则调用parent.loadClass(name, false)方法,通过父加载器加载指定的Java类。AppClassLoader的父加载器是ExtClassLoader,这是扩展类加载器,用于加载JDK中指定路径下的扩展类,这种加载器不会加载Java应用程序的主类,所以程序流会进入if(this.parent != null){}代码块,但是parent.loadClass(name, false)返回null。接着java.lang.ClassLoader.loadClass(String name, boolean resolve)方法只能通过调用this. findClass(name)来加载Java主类。

java.lang.ClassLoader.findClass(String)方法直接抛出异常,因此该类注定要由子类来实现。对于系统类加载器AppClassLoader,其继承自URLClassLoader,因此java.lang.ClassLoader. findClass(String)方法实际指向java.net.URLClassLoader.findClass(String)。java.net.URLClassLoader. findClass(String)方法最终调用java.lang.ClassLoader.defineClass1()这一native接口,这是一个本地接口,由本地类库实现。openjdk项目包含了JDK核心Java类库中的全部本地实现,java.lang. ClassLoader.defineClass1()所对应的本地实现是ClassLoader.c::Javajava_lang_ClassLoader defineClass1(),有兴趣的道友可自行查看下其实现,这里就不贴代码了,以免占用过多篇幅。通过调用java.lang.ClassLoader.defineClass1()接口,Java程序流又转移到JVM内部,因此Java类的加载最终仍然是通过JVM本地类库得以实现。

ClassLoader.c::Java_java_lang_ClassLoader_defineClass1()调用jvm.cpp::JVM_DefineClass WithSource(),jvm.cpp::JVM_DefineClassWithSource()调用jvm.cpp::jvm_define_class_common(),而后者则调用SystemDictionary.cpp::resolve_from_stream()接口来加载Java主类。在SystemDictionary.cpp::resolve_from_stream()接口中,终于开始调用ClassFileParser.cpp:: parseClassFile()这个函数来解析Java主类,并最终创建Java主类在JVM内部的对等体——klassInstance,由此完成Java主类的加载。

读者评论

相关专题

相关博文

  • Kotlin 初体验:主要特征与应用

    Kotlin 初体验:主要特征与应用

    管理员账号 2017-08-15

    小编说:Kotlin 是一种针对 Java 平台的新编程语言。它简洁、安全、务实,并且专注于与 Java 代码的互操作性。它几乎可以用在现在 Java 使用的任何地方 :服务器端开发、Android 应用,等等。本文我们将详细地探讨 ...

    管理员账号 2017-08-15
    567 0 0 0
  • 6种常用View 的滑动方法

    6种常用View 的滑动方法

    管理员账号 2017-08-01

    小编说:View 的滑动是Android 实现自定义控件的基础,实现View 滑动有很多种方法,在这里主要讲解6 种滑动方法,分别是layout()、offsetLeftAndRight()与offsetTopAndBottom()、...

    管理员账号 2017-08-01
    107 0 0 0
  • #小编推书#快速高效地展炫酷动画效果

    管理员账号 2017-02-13

    小编说 目前,APP Store上的应用已经超过150万个,而纵观排名较为靠前的应用,无一例外都有着一个共同的特点,那就是良好的用户体验。动画作为用户体验中最复杂、最绚丽的技术已经备受开发人员和产品设计人员的重视。而如何将炫酷的动画...

    管理员账号 2017-02-13
    62 0 0 0