一、概述
在很久很久之前,自从朋友推荐我用butterknife后, 从此的项目再也离不开butterknife了。问butterknife的原理,估计很多人都会回答注解加反射。我一开始也是以为是注解加反射,然而看了源码之后发现不单单用的注解加反射。那么下面我们就来分析一下ButterKnife的实现原理吧。本文基于ButterKnife8.6.0.
二、ButterKnife用法
做android开发应该大部分都用过ButterKnife(官方文档),没用过可以点击链接进去看看。就算没用过应该也听说过,毕竟是Jake Wharton出品的东西(话说现在Jake Wharton大神现在已经入职谷歌了)。要是没用过的话可以看看这篇文章,这是我自己用注解加反射实现的类似ButterKnife功能的代码。
三、ButterKnife原理
Butterknife用的APT(Annotation Processing Tool)编译时解析技术(现在已经改成了谷歌的更强大的annotationProcessor,APT已经停止更新了)。大概原理就是你声明的注解的生命周期为CLASS,然后继承AbstractProcessor类。继承这个类后,在编译的时候,编译器会扫描所有带有你要处理的注解的类,然后再调用AbstractProcessor的process方法,对注解进行处理,那么我们就可以在处理的时候,动态生成绑定事件或者控件的java代码,然后在运行的时候,直接调用方法完成绑定。
四、ButterKnife源码解析
说了这么多,我们还是直接从源码入手吧。
public class MainActivity extends AppCompatActivity {
@BindView(R.id.tv)
TextView tv;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ButterKnife.bind(this);
tv.setText("点击");
}
@OnClick(R.id.tv)
public void onViewClicked() {
Toast.makeText(this,"111",Toast.LENGTH_SHORT).show();
}
}
上边是我们在使用时候的代码,我们直接就从 ButterKnife.bind(this)入手吧,点进来看看:
@NonNull @UiThread
public static Unbinder bind(@NonNull Activity target) {
View sourceView = target.getWindow().getDecorView();
return createBinding(target,sourceView);
}
再点到createBinding(target,sourceView)里面看看:
private static Unbinder createBinding(@NonNull Object target,@NonNull View source) {
Class<?> targetClass = target.getClass();
if (debug) Log.d(TAG,"Looking up binding for " + targetClass.getName());
Constructor<? extends Unbinder> constructor = findBindingConstructorForClass(targetClass);
if (constructor == null) {
return Unbinder.EMPTY;
}
//noinspection TryWithIdenticalCatches Resolves to API 19+ only type.
try {
return constructor.newInstance(target,source);
} catch (IllegalAccessException e) {
throw new RuntimeException("Unable to invoke " + constructor,e);
} catch (InstantiationException e) {
throw new RuntimeException("Unable to invoke " + constructor,e);
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException) {
throw (RuntimeException) cause;
}
if (cause instanceof Error) {
throw (Error) cause;
}
throw new RuntimeException("Unable to create binding instance.",cause);
}
}
createBinding()方法主要就是拿到我们绑定的Activity的Class,然后通过Constructor构造器获取一个Unbinder子类的构造方法,然后在调用newInstance(target,source)通过构造方法获取到Unbinder子类的一个实例,这里传入两个参数,说明构造方法里需要两个参数。我们打开findBindingConstructorForClass()方法。
@Nullable @CheckResult @UiThread private static Constructor<? extends Unbinder> findBindingConstructorForClass(Class<?> cls) { Constructor<? extends Unbinder> bindingCtor = BINDINGS.get(cls); if (bindingCtor != null) { if (debug) Log.d(TAG,"HIT: Cached in binding map."); return bindingCtor; } String clsName = cls.getName(); if (clsName.startsWith("android.") || clsName.startsWith("java.")) { if (debug) Log.d(TAG,"MISS: Reached framework class. Abandoning search."); return null; } try { Class<?> bindingClass = cls.getClassLoader().loadClass(clsName + "_ViewBinding"); //noinspection unchecked bindingCtor = (Constructor<? extends Unbinder>) bindingClass.getConstructor(cls,View.class); if (debug) Log.d(TAG,"HIT: Loaded binding class and constructor."); } catch (ClassNotFoundException e) { if (debug) Log.d(TAG,"Not found. Trying superclass " + cls.getSuperclass().getName()); bindingCtor = findBindingConstructorForClass(cls.getSuperclass()); } catch (NoSuchMethodException e) { throw new RuntimeException("Unable to find binding constructor for " + clsName,e); } BINDINGS.put(cls,bindingCtor); return bindingCtor; }
@VisibleForTesting static final Map<Class<?>,Constructor<? extends Unbinder>> BINDINGS = new LinkedHashMap<>();
上面的BINDINGS是一个保存了Class为key,Class_ViewBinding为Value的一个LinkedHashMap,主要是做一下缓存,提高下次再来bind的性能。
第14行clsName 是我们传入要绑定的Activity类名,第16行通过反射调用构造方法,这里相当于拿到了Activity_ViewBinding这个类的实例。其实从类名可以看出来,相当于Activity的一个辅助类,这时候我们就要问了,我们在用的时候没有声明这个类啊?从哪里来的? 不要方,其实它就是我们在之前讲原理的时候说到的AbstractProcessor在编译的时候生成的一个类,我们后面再来看它,现在我们继续往下面分析。
前面我们说到,这个方法里面用linkhashMap做了下缓存,所以在下边,就把刚刚反射的bindingCtor作为value,Class作为key加入这个LinkedHashMap,下次再绑定这个类的时候,就直接在方法的开始的时候取出来用。
下边我们就来找找Activity_ViewBinding这个类。
在app里面的build的classes文件夹下找到了它,点开看看:
public class MainActivity_ViewBinding implements Unbinder {
private MainActivity target;
private View view2131427422;
@UiThread
public MainActivity_ViewBinding(MainActivity target) {
this(target,target.getWindow().getDecorView());
}
@UiThread
public MainActivity_ViewBinding(final MainActivity target,View source) {
this.target = target;
View view = Utils.findrequiredView(source,2131427422,"field \'tv\' and method \'onViewClicked\'");
target.tv = (TextView)Utils.castView(view,"field \'tv\'",TextView.class);
this.view2131427422 = view;
view.setOnClickListener(new DebouncingOnClickListener() {
public void doClick(View p0) {
target.onViewClicked();
}
});
}
@CallSuper
public void unbind() {
MainActivity target = this.target;
if(target == null) {
throw new IllegalStateException("Bindings already cleared.");
} else {
this.target = null;
target.tv = null;
this.view2131427422.setOnClickListener((OnClickListener)null);
this.view2131427422 = null;
}
}
}
刚才说的Unbinder的一个子类,看这里的类名。现在应该懂了。它刚好是实现了Unbinder接口。之前说了通过反射拿到了Activity_ViewBinding这个类的构造方法即通过调用getConstructor(cls,View.class)方法,然后通过newInstance(target,source)方法创建实例,这里传入的两个参数就是我们MainActivity_ViewBinding(final MainActivity target,View source)里面的两个参数。因为我们可以在Activity中使用butterknife,也可以在Fragment和Adapter等中使用butterknife,那么在不同的地方使用butterknife,这个target也就不同。我们接着看里面的findrequiredView方法:
public static View findrequiredView(View source,@IdRes int id,String who) {
View view = source.findViewById(id);
if (view != null) {
return view;
}
String name = getResourceEntryName(source,id);
throw new IllegalStateException("required view '"
+ name
+ "' with ID "
+ id
+ " for "
+ who
+ " was not found. If this view is optional add '@Nullable' (fields) or '@Optional'"
+ " (methods) annotation.");
}
还是使用的findViewById。
返回上面的MainActivity_ViewBinding代码,首先调用了findrequiredView方法,其实这个方法最后经过处理就是调用了findViewById方法,拿到相应的view,然后再赋值给target.tv,刚说了target就是那个要绑定的Activity,这里通过 target.tv 这样的调用方式,说明了Activity中不能把TextView设置为private,不然会报错,其实这里可以用反射来拿到textView的,这里应该也是为了性能着想。最后setOnClickListener,DebouncingOnClickListener这个Listener其实也是实现了View.OnClickListener 方法,然后在OnClick里面调用了doClick方法。
五、Butterknife在编译的时候生成代码的原理
com.jakewharton:butterknife-compiler 就是自定义的注解处理器,我们在 Gradle 中注册使用它。
然而我在项目结构中找了很久也没有找到这个库的文件,有可能是在编译时才去访问的,如果需要可以在 GitHub 中找到:
butterknife-compiler
我们看看ButterKnifeProcessor这个类:
@Override public synchronized void init(ProcessingEnvironment env) {
super.init(env);
String sdk = env.getOptions().get(OPTION_SDK_INT);
if (sdk != null) {
try {
this.sdk = Integer.parseInt(sdk);
} catch (NumberFormatException e) {
env.getMessager()
.printMessage(Kind.WARNING,"Unable to parse supplied minSdk option '"
+ sdk
+ "'. Falling back to API 1 support.");
}
}
debuggable = !"false".equals(env.getOptions().get(OPTION_DEBUGGABLE));
elementUtils = env.getElementUtils();
typeUtils = env.getTypeUtils();
filer = env.getFiler();
try {
trees = Trees.instance(processingEnv);
} catch (IllegalArgumentException ignored) {
}
}
int()方法里面进来判断了最低的支持的sdk版本。ProcessingEnviroment参数提供很多有用的工具类Elements,Types和Filer。Types是用来处理TypeMirror的工具类,Filer用来创建生成辅助文件。至于ElementUtils嘛,其实ButterKnifeProcessor在运行的时候,会扫描所有的Java源文件,然后每一个Java源文件的每一个部分都是一个Element,比如一个包、类或者方法。
@Override public Set<String> getSupportedAnnotationTypes() { Set<String> types = new LinkedHashSet<>(); for (Class<? extends Annotation> annotation : getSupportedAnnotations()) { types.add(annotation.getCanonicalName()); } return types; } private Set<Class<? extends Annotation>> getSupportedAnnotations() { Set<Class<? extends Annotation>> annotations = new LinkedHashSet<>(); annotations.add(BindAnim.class); annotations.add(BindArray.class); annotations.add(BindBitmap.class); annotations.add(BindBool.class); annotations.add(BindColor.class); annotations.add(BindDimen.class); annotations.add(BindDrawable.class); annotations.add(BindFloat.class); annotations.add(BindFont.class); annotations.add(BindInt.class); annotations.add(BindString.class); annotations.add(BindView.class); annotations.add(BindViews.class); annotations.addAll(LISTENERS); return annotations; }
getSupportedAnnotationTypes()方法主要是指定ButterknifeProcessor是注册给哪些注解的。我们可以看到,在源代码里面,作者一个一个地把Class文件加到那个LinkedHashSet里面,然后再把LISTENERS也全部加进去。
其实整个类最重要的是process方法:
@Override public boolean process(Set<? extends TypeElement> elements,RoundEnvironment env) { Map<TypeElement,BindingSet> bindingMap = findAndParseTargets(env); for (Map.Entry<TypeElement,BindingSet> entry : bindingMap.entrySet()) { TypeElement typeElement = entry.getKey(); BindingSet binding = entry.getValue(); JavaFile javaFile = binding.brewJava(sdk,debuggable); try { javaFile.writeTo(filer); } catch (IOException e) { error(typeElement,"Unable to write binding for type %s: %s",typeElement,e.getMessage()); } } return false; }
这个方法的作用主要是扫描、评估和处理我们程序中的注解,然后生成Java文件,也就是前面说的MainActivity_ViewBinding。首先一进这个函数就调用了findAndParseTargets方法,我们就去看看findAndParseTargets方法到底做了什么:
private Map<TypeElement,BindingSet> findAndParseTargets(RoundEnvironment env) {
Map<TypeElement,BindingSet.Builder> builderMap = new LinkedHashMap<>();
Set<TypeElement> erasedTargetNames = new LinkedHashSet<>();
scanForRClasses(env);
// Process each @BindAnim element.
for (Element element : env.getElementsAnnotatedWith(BindAnim.class)) {
if (!SuperficialValidation.validateElement(element)) continue;
try {
parseResourceAnimation(element,builderMap,erasedTargetNames);
} catch (Exception e) {
logParsingError(element,BindAnim.class,e);
}
}
........
........
........
// Associate superclass binders with their subclass binders. This is a queue-based tree walk
// which starts at the roots (superclasses) and walks to the leafs (subclasses).
Deque<Map.Entry<TypeElement,BindingSet.Builder>> entries =
new ArrayDeque<>(builderMap.entrySet());
Map<TypeElement,BindingSet> bindingMap = new LinkedHashMap<>();
while (!entries.isEmpty()) {
Map.Entry<TypeElement,BindingSet.Builder> entry = entries.removeFirst();
TypeElement type = entry.getKey();
BindingSet.Builder builder = entry.getValue();
TypeElement parentType = findParentType(type,erasedTargetNames);
if (parentType == null) {
bindingMap.put(type,builder.build());
} else {
BindingSet parentBinding = bindingMap.get(parentType);
if (parentBinding != null) {
builder.setParent(parentBinding);
bindingMap.put(type,builder.build());
} else {
// Has a superclass binding but we haven't built it yet. Re-enqueue for later.
entries.addLast(entry);
}
}
}
return bindingMap;
这个方法的代码非常多,这里只贴出一部分,这个方法的主要的流程如下:
扫描所有具有注解的类,然后根据这些类的信息生成BindingSet,最后生成以TypeElement为键,BindingSet为值的键值对。
循环遍历这个键值对,根据TypeElement和BindingSet里面的信息生成对应的java类。例如AnnotationActivity生成的类即为MainActivity_ViewBinding类。
这里我们可以看看BindingSet里面的代码:
final class BindingSet {
static final ClassName UTILS = ClassName.get("butterknife.internal","Utils");
private static final ClassName VIEW = ClassName.get("android.view","View");
private static final ClassName CONTEXT = ClassName.get("android.content","Context");
private static final ClassName RESOURCES = ClassName.get("android.content.res","Resources");
private static final ClassName UI_THREAD =
ClassName.get("android.support.annotation","UiThread");
private static final ClassName CALL_SUPER =
ClassName.get("android.support.annotation","CallSuper");
private static final ClassName SUPPRESS_LINT =
ClassName.get("android.annotation","SuppressLint");
private static final ClassName UNBINDER = ClassName.get("butterknife","Unbinder");
static final ClassName BITMAP_FACTORY = ClassName.get("android.graphics","BitmapFactory");
static final ClassName CONTEXT_COMPAT =
ClassName.get("android.support.v4.content","ContextCompat");
static final ClassName ANIMATION_UTILS =
ClassName.get("android.view.animation","AnimationUtils");
private final TypeName targetTypeName;
private final ClassName bindingClassName;
private final boolean isFinal;
private final boolean isView;
private final boolean isActivity;
private final boolean isDialog;
private final ImmutableList<ViewBinding> viewBindings;
private final ImmutableList<FieldCollectionViewBinding> collectionBindings;
private final ImmutableList<ResourceBinding> resourceBindings;
private final BindingSet parentBinding;
private BindingSet(TypeName targetTypeName,ClassName bindingClassName,boolean isFinal,boolean isView,boolean isActivity,boolean isDialog,ImmutableList<ViewBinding> viewBindings,ImmutableList<FieldCollectionViewBinding> collectionBindings,ImmutableList<ResourceBinding> resourceBindings,BindingSet parentBinding) {
this.isFinal = isFinal;
this.targetTypeName = targetTypeName;
this.bindingClassName = bindingClassName;
this.isView = isView;
this.isActivity = isActivity;
this.isDialog = isDialog;
this.viewBindings = viewBindings;
this.collectionBindings = collectionBindings;
this.resourceBindings = resourceBindings;
this.parentBinding = parentBinding;
}
JavaFile brewJava(int sdk,boolean debuggable) {
return JavaFile.builder(bindingClassName.packageName(),createType(sdk,debuggable))
.addFileComment("Generated code from Butter Knife. Do not modify!")
.build();
}
private TypeSpec createType(int sdk,boolean debuggable) {
TypeSpec.Builder result = TypeSpec.classBuilder(bindingClassName.simpleName())
.addModifiers(PUBLIC);
if (isFinal) {
result.addModifiers(FINAL);
}
........
........
........
static final class Builder {
private final TypeName targetTypeName;
private final ClassName bindingClassName;
private final boolean isFinal;
private final boolean isView;
private final boolean isActivity;
private final boolean isDialog;
private BindingSet parentBinding;
private final Map<Id,ViewBinding.Builder> viewIdMap = new LinkedHashMap<>();
private final ImmutableList.Builder<FieldCollectionViewBinding> collectionBindings =
ImmutableList.builder();
private final ImmutableList.Builder<ResourceBinding> resourceBindings = ImmutableList.builder();
private Builder(TypeName targetTypeName,boolean isDialog) {
this.targetTypeName = targetTypeName;
this.bindingClassName = bindingClassName;
this.isFinal = isFinal;
this.isView = isView;
this.isActivity = isActivity;
this.isDialog = isDialog;
}
void addField(Id id,FieldViewBinding binding) {
getOrCreateViewBindings(id).setFieldBinding(binding);
}
void addFieldCollection(FieldCollectionViewBinding binding) {
collectionBindings.add(binding);
}
boolean addMethod(
Id id,ListenerClass listener,ListenerMethod method,MethodViewBinding binding) {
ViewBinding.Builder viewBinding = getOrCreateViewBindings(id);
if (viewBinding.hasMethodBinding(listener,method) && !"void".equals(method.returnType())) {
return false;
}
viewBinding.addMethodBinding(listener,method,binding);
return true;
}
void addResource(ResourceBinding binding) {
resourceBindings.add(binding);
}
void setParent(BindingSet parent) {
this.parentBinding = parent;
}
String findExistingBindingName(Id id) {
ViewBinding.Builder builder = viewIdMap.get(id);
if (builder == null) {
return null;
}
FieldViewBinding fieldBinding = builder.fieldBinding;
if (fieldBinding == null) {
return null;
}
return fieldBinding.getName();
}
private ViewBinding.Builder getOrCreateViewBindings(Id id) {
ViewBinding.Builder viewId = viewIdMap.get(id);
if (viewId == null) {
viewId = new ViewBinding.Builder(id);
viewIdMap.put(id,viewId);
}
return viewId;
}
BindingSet build() {
ImmutableList.Builder<ViewBinding> viewBindings = ImmutableList.builder();
for (ViewBinding.Builder builder : viewIdMap.values()) {
viewBindings.add(builder.build());
}
return new BindingSet(targetTypeName,bindingClassName,isFinal,isView,isActivity,isDialog,viewBindings.build(),collectionBindings.build(),resourceBindings.build(),parentBinding);
}
}
}
这个类的代码也非常多,所以我们也只贴一部分,可以自己去看看源码,这个BindingSet是管理了所有关于这个注解的一些信息还有实例本身的信息。
因为我们之前用的例子是绑定的一个View,所以我们就只贴了解析View的代码。好吧,这里遍历了所有带有@BindView的Element,然后对每一个Element进行解析,也就进入了parseBindView这个方法中:
private void parseBindView(Element element,Map<TypeElement,BindingSet.Builder> builderMap,Set<TypeElement> erasedTargetNames) {
TypeElement enclosingElement = (TypeElement) element.getEnclosingElement();
// Start by verifying common generated code restrictions.
boolean hasError = isInaccessibleViaGeneratedCode(BindView.class,"fields",element)
|| isBindingInWrongPackage(BindView.class,element);
// Verify that the target type extends from View.
TypeMirror elementType = element.asType();
if (elementType.getKind() == TypeKind.TYPEVAR) {
TypeVariable typeVariable = (TypeVariable) elementType;
elementType = typeVariable.getUpperBound();
}
Name qualifiedName = enclosingElement.getQualifiedName();
Name simpleName = element.getSimpleName();
if (!isSubtypeOfType(elementType,VIEW_TYPE) && !isInterface(elementType)) {
if (elementType.getKind() == TypeKind.ERROR) {
note(element,"@%s field with unresolved type (%s) "
+ "must elsewhere be generated as a View or interface. (%s.%s)",BindView.class.getSimpleName(),elementType,qualifiedName,simpleName);
} else {
error(element,"@%s fields must extend from View or be an interface. (%s.%s)",simpleName);
hasError = true;
}
}
if (hasError) {
return;
}
// Assemble information on the field.
int id = element.getAnnotation(BindView.class).value();
BindingSet.Builder builder = builderMap.get(enclosingElement);
QualifiedId qualifiedId = elementToQualifiedId(element,id);
if (builder != null) {
String existingBindingName = builder.findExistingBindingName(getId(qualifiedId));
if (existingBindingName != null) {
error(element,"Attempt to use @%s for an already bound ID %d on '%s'. (%s.%s)",id,existingBindingName,enclosingElement.getQualifiedName(),element.getSimpleName());
return;
}
} else {
builder = getOrCreateBindingBuilder(builderMap,enclosingElement);
}
String name = simpleName.toString();
TypeName type = TypeName.get(elementType);
boolean required = isFieldrequired(element);
builder.addField(getId(qualifiedId),new FieldViewBinding(name,type,required));
// Add the type-erased version to the valid binding targets set.
erasedTargetNames.add(enclosingElement);
}
然后这里从一进入这个方法到
int id = element.getAnnotation(BindView.class).value();
都是在拿到注解信息,然后验证注解的target的类型是否继承自view,然后上面这一行代码获得我们要绑定的View的id,再从builderMap里面取出BindingSet.Builder对象(这个BindingSet是管理了所有关于这个注解的一些信息还有实例本身的信息,其实最后是通过BindingSet来生成java代码的,上面也已经看了BindingSet的代码),如果builderMap里面不存在的话,就在
builder = getOrCreateBindingBuilder(builderMap,enclosingElement);
这里生成一个,我们进去看一下getOrCreateBindingBuilder:
private BindingSet.Builder getOrCreateBindingBuilder(
Map<TypeElement,BindingSet.Builder> builderMap,TypeElement enclosingElement) {
BindingSet.Builder builder = builderMap.get(enclosingElement);
if (builder == null) {
builder = BindingSet.newBuilder(enclosingElement);
builderMap.put(enclosingElement,builder);
}
return builder;
}
这里面其实很简单,就是获取一些这个注解所修饰的变量的一些信息,然后把这个解析后的builder加入到builderMap里面。
返回刚刚的parseBindView中,根据view的信息生成一个FieldViewBinding,最后添加到上边生成的builder实例中。这里基本完成了解析工作。最后回到findAndParseTargets中:
// Associate superclass binders with their subclass binders. This is a queue-based tree walk
// which starts at the roots (superclasses) and walks to the leafs (subclasses).
Deque<Map.Entry<TypeElement,BindingSet.Builder>> entries =
new ArrayDeque<>(builderMap.entrySet());
Map<TypeElement,BindingSet> bindingMap = new LinkedHashMap<>();
while (!entries.isEmpty()) {
Map.Entry<TypeElement,BindingSet.Builder> entry = entries.removeFirst();
TypeElement type = entry.getKey();
BindingSet.Builder builder = entry.getValue();
TypeElement parentType = findParentType(type,erasedTargetNames);
if (parentType == null) {
bindingMap.put(type,builder.build());
} else {
BindingSet parentBinding = bindingMap.get(parentType);
if (parentBinding != null) {
builder.setParent(parentBinding);
bindingMap.put(type,builder.build());
} else {
// Has a superclass binding but we haven't built it yet. Re-enqueue for later.
entries.addLast(entry);
}
}
}
这里主要的工作是建立上面的绑定的所有的实例的解绑的关系,因为我们绑定了,最后在代码中还是会解绑的。这里预先处理好了这些关系。
回到我们的process中, 现在解析完了annotation,该生成java文件了,再看看代码:
@Override public boolean process(Set<? extends TypeElement> elements,RoundEnvironment env) {
Map<TypeElement,BindingSet> bindingMap = findAndParseTargets(env);
for (Map.Entry<TypeElement,BindingSet> entry : bindingMap.entrySet()) {
TypeElement typeElement = entry.getKey();
BindingSet binding = entry.getValue();
JavaFile javaFile = binding.brewJava(sdk,debuggable);
try {
javaFile.writeTo(filer);
} catch (IOException e) {
error(typeElement,e.getMessage());
}
}
return false;
}
遍历刚刚得到的bindingMap,然后再一个一个地通过
javaFile.writeTo(filer);
来生成java文件。然而生成的java文件也是根据上面的信息来用字符串拼接起来的,然而这个工作在brewJava()中完成了:
JavaFile brewJava(int sdk,boolean debuggable) {
return JavaFile.builder(bindingClassName.packageName(),debuggable))
.addFileComment("Generated code from Butter Knife. Do not modify!")
.build();
}
原文链接:https://www.f2er.com/javaschema/282734.html