0°

ButterKnife你真的了解吗?

内容预览:
  • 点击上方 &;程序员小乐&; ,选择“置顶公众号”,第一时间送达! 每日英文...~
  • 那么读完这篇文章,让你脱离Out的领域~
  • 难以理解吗? 很简单的实现吧~

始发于微信公众号: 程序员小乐

专注于编程、互联网动态。最终将总结的技术、心得、经验(数据结构与算法、源码分析等)分享给大家,这里不只限于技术!还有职场心得、生活感悟、以及面经点击上方 “程序员小乐” ,选择“置顶公众号”,第一时间送达!


每日英文
Never get stuck with the thing that ruins your day. Stay upbeat and be happy; for life is too short to be wasted on crap.
不要让不好的事情毁了你这一天,乐观一点,开心一点,生命如此短暂,别浪费时间在不值一提的事情上。

乐乐有话说
“不要去听别人的忽悠,你人生的每一步都必须靠自己的能力完成。自己肚子里没有料,手上没本事,认识再多人也没用。人脉只会给你机会,但抓住机会还是要靠真本事。所以啊,修炼自己,比到处逢迎别人重要的多。 ”

阅读目录

  • 解读ButterKnife之准备篇

  • ButterKnife的使用

  • ButterKnife的实现思想

源自JakeWharton的经典制作ButterKnife, 相信大多数人都用的很熟练, 它一定程度上简化了我们的findViewById以及OnClick等操作,给平时开发带来了很大的便利。此处对是否应该使用它,我不作评论,也不是本篇的目的。

这个使用了多年的框架, 你真的知道是什么原理吗?它的实现是怎样的,你有产生过好奇心吗?那么就让我们来解读一下。

一、解读ButterKnife之准备篇

反射

这是一个多数框架都会使用的技术,只要知道类或对象,我们可以解析出关于此类的一切信息,为所欲为。一提反射,很多人会觉得性能消耗不会很大吗?肯定你也带着同样的想法吧?那么我来声明一下,反射存在性能消耗是有的,但是没有想象中的夸张;如果结合缓存使用的话,可以极大的抵消带来的性能的消耗,从而更好的享受它所带来的便利。作为一个Java开发者,不会反射真的有些过分了..

AnnotationProcessor

大家都知道,ButterKnifed 便利来自于注解。那么既然存在注解,注解处理器技术的使用是必然的。现在的框架不同于以往;以前的框架类似XUtils的注解很大程度上是使用反射来解析的,上面说过,反射带来性能消耗还是有的;但是现在, 大多数的注解框架都是基于Apt或AnnotationProcessor的编译时解析实现的。试想一下,在程序编译时就完成了注解解析的工作,又会给性能带来什么影响呢?答案当然是没影响。

早期的注解处理器大多使用开源的Apt, 然而一是Apt已不再被作者所维护;二则Google推出了AnnotationProcessor来替代它,更是集成到了API中,所以怎么看,使用AnnotationProcessor都是一个很好的选择。

Javapoet

这是一个很神奇的技术,借助它,我们可以便捷的生成我们想要的代码。至少目前来看,GreenDao生成的Bean与Dao, ButterKnife生成的Binding以及ARouter生成的Router&&Group&&xx等,都是使用了Poet技术。

以上3项技术被大多数框架所使用,如果你还不会,你已经Out了。那么读完这篇文章,让你脱离Out的领域。

二、ButterKnife的使用

这里不着重说怎么用ButterKnife, 或者说作为一个如此简单的框架,压根就不用多说。这里引入它的使用,为ButterKnife的解析作为一个引导。

// 初始化
ButterKnife.bind(Object target, View source);
// 加入注解
@BindView(id)
Button

以上就是ButterKnife的基本调用。初始化,然后添加各种注解,如BindView, OnClick, BindBitmap。 如果你看过它的源码,你就会知道,ButterKnife支持的注解主要分为3类: BindView之绑定View, BindBitmap/ BindColor等绑定资源, OnClick等绑定事件。至于为什么不添加绑定setContentView, 大神说只能替换一行代码而已,没意思。大神就是这么任性…

三、ButterKnife的实现思想

从注解, 从我列举的准备技术,我想聪明的你已经猜到了ButterKnife是如何实现了吧。是的,它所使用的核心技术可以称之为依赖注入。俗话说就是,我们不在Activity中初始化这些View, 这堆资源, 这系列的点击事件;我们通过生成自定义类来偷偷的初始化,偷偷的绑定监听。对,偷偷的…

1)添加注解
这个没什么多说的, ButterKnife的核心即是通过注解来简化操作。

2)解析注解,生成注入类
加注解的目的是什么?就是偷偷生成一个类,在这个类里面悄悄的消化你注解的内容,用我们最熟悉的方式。这样我们看起来只要加个注解就实现了,好厉害;如果你看了生成的Binding类,你就知道,切,这不是我们整天写的吗?

现在看一下生成的类

public class MainActivity_ViewBinding implements Unbinder {
 private MainActivity target;
 private View view2131165245;
 @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;
   view = Utils.findRequiredView(source, R.id.id_button, "method 'doClick'");
   view2131165245 = view;
   view.setOnClickListener(new DebouncingOnClickListener() {
     @Override
     public void doClick(View p0) {
       target.doClick(p0);
     }
   });
 }
 @Override
 @CallSuper
 public void unbind() {
   if (target == null) throw new IllegalStateException("Bindings already cleared.");
   target = null;
   view2131165245.setOnClickListener(null);
   view2131165245 = null;
 }
}

上面的类就是ButterKnife自动生成的类。里面包含的@OnClick的处理,我来解析一下,你就明白了。

首先看类名, 类名的形式你一看就明白了,就是@注解的类 + 一个后缀ViewBinding。

其次,看一下变量, MainActivity不用解释了吧,你是在MainActivity的方法上添加的@OnClick; View即是你点击的控件。

再者看一下构造,构造传入了MainActivity, 然后findRequiredView,其实这里面就是findViewById。后面的实现看一眼就明白了,在生成类里面设置监听,然后调用你添加注解的Method。难以理解吗? 很简单的实现吧。这就是回调的使用吧,相信我们都经常使用,在View点击的时候,调用指定的方法。

最后看unbind()方法,在页面执行结束后,是会自动释放资源的。

再来看下BindView生成的Binding类

public class MainActivity_ViewBinding implements Unbinder {
 private MainActivity target;
 @UiThread
 public MainActivity_ViewBinding(MainActivity target) {
   this(target, target.getWindow().getDecorView());
 }
 @UiThread
 public MainActivity_ViewBinding(MainActivity target, View source) {
   this.target = target;
   target.btn = Utils.findRequiredViewAsType(source, R.id.id_button, "field 'btn'", Button.class);
   target.tv = Utils.findRequiredViewAsType(source, R.id.id_textview, "field 'tv'", TextView.class);
 }
 @Override
 @CallSuper
 public void unbind() {
   MainActivity target = this.target;
   if (target == null) throw new IllegalStateException("Bindings already cleared.");
   this.target = null;
   target.btn = null;
   target.tv = null;
 }
}

和OnClick生成的类很相似,只是改成了findRequiredViewAsType。 然而这个方法的实现不过是findViewById加类型转换,也就是类似的

Button btn = (Button)findViewById(R.id.btn);

看到这里,或许有人就奇怪了,你是怎么生成的? 答:技术就是使用JavaPoet。 至于这些属性怎么得到的, 如类名, id值, Activity与View, 控件是什么, 绑定的onClick方法是什么? 那你得去问AnnotationProcessor了,就是这么神通广大。

我们来捋一下思路,

1 添加注解
2)在AnnotationProcessor中解析注解(至于怎么解析,下面说)
3)根据解析的信息,自动生成Binding类
4)在ButterKnife.bind时,一般是onCreate, 得到对应的Binding类,通过反射,生成Binding类的对象,执行Binding类的构造函数。在构造中偷偷的findViewById, setOnClickListener等。这时候在对应的Activity中添加注解的控件就有值了。添加OnClick的控件就设置了监听了。

看了以上的思路,是不是恍然大悟?所以我才说这是一个很简单的框架。那么接下来,解读一下注解处理的源码与生成类的源码

首先看配置

// 导入生成代码的库
api deps.javapoet
// 导入自动注册的库
 compileOnly deps.auto.service
// 上面的库是什么
javapoet: 'com.squareup:javapoet:1.10.0',
'auto': [
        'service': 'com.google.auto.service:auto-service:1.0-rc4',
        'common': 'com.google.auto:auto-common:0.10',
     ],

然后看下注解处理器的代码, ButterKnifeProcessor, 其中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;
 }
######################################
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);
     }
   }
   // Process each @BindArray element.
   for (Element element : env.getElementsAnnotatedWith(BindArray.class)) {
     if (!SuperficialValidation.validateElement(element)) continue;
     try {
       parseResourceArray(element, builderMap, erasedTargetNames);
     } catch (Exception e) {
       logParsingError(element, BindArray.class, e);
     }
   }
   // Process each @BindBitmap element.
   for (Element element : env.getElementsAnnotatedWith(BindBitmap.class)) {
     if (!SuperficialValidation.validateElement(element)) continue;
     try {
       parseResourceBitmap(element, builderMap, erasedTargetNames);
     } catch (Exception e) {
       logParsingError(element, BindBitmap.class, e);
     }
   }
   // Process each @BindBool element.
   for (Element element : env.getElementsAnnotatedWith(BindBool.class)) {
     if (!SuperficialValidation.validateElement(element)) continue;
     try {
       parseResourceBool(element, builderMap, erasedTargetNames);
     } catch (Exception e) {
       logParsingError(element, BindBool.class, e);
     }
   }
   // Process each @BindColor element.
   for (Element element : env.getElementsAnnotatedWith(BindColor.class)) {
     if (!SuperficialValidation.validateElement(element)) continue;
     try {
       parseResourceColor(element, builderMap, erasedTargetNames);
     } catch (Exception e) {
       logParsingError(element, BindColor.class, e);
     }
   }
   // Process each @BindDimen element.
   for (Element element : env.getElementsAnnotatedWith(BindDimen.class)) {
     if (!SuperficialValidation.validateElement(element)) continue;
     try {
       parseResourceDimen(element, builderMap, erasedTargetNames);
     } catch (Exception e) {
       logParsingError(element, BindDimen.class, e);
     }
   }
   // Process each @BindDrawable element.
   for (Element element : env.getElementsAnnotatedWith(BindDrawable.class)) {
     if (!SuperficialValidation.validateElement(element)) continue;
     try {
       parseResourceDrawable(element, builderMap, erasedTargetNames);
     } catch (Exception e) {
       logParsingError(element, BindDrawable.class, e);
     }
   }
   // Process each @BindFloat element.
   for (Element element : env.getElementsAnnotatedWith(BindFloat.class)) {
     if (!SuperficialValidation.validateElement(element)) continue;
     try {
       parseResourceFloat(element, builderMap, erasedTargetNames);
     } catch (Exception e) {
       logParsingError(element, BindFloat.class, e);
     }
   }
   // Process each @BindFont element.
   for (Element element : env.getElementsAnnotatedWith(BindFont.class)) {
     if (!SuperficialValidation.validateElement(element)) continue;
     try {
       parseResourceFont(element, builderMap, erasedTargetNames);
     } catch (Exception e) {
       logParsingError(element, BindFont.class, e);
     }
   }
   // Process each @BindInt element.
   for (Element element : env.getElementsAnnotatedWith(BindInt.class)) {
     if (!SuperficialValidation.validateElement(element)) continue;
     try {
       parseResourceInt(element, builderMap, erasedTargetNames);
     } catch (Exception e) {
       logParsingError(element, BindInt.class, e);
     }
   }
   // Process each @BindString element.
   for (Element element : env.getElementsAnnotatedWith(BindString.class)) {
     if (!SuperficialValidation.validateElement(element)) continue;
     try {
       parseResourceString(element, builderMap, erasedTargetNames);
     } catch (Exception e) {
       logParsingError(element, BindString.class, e);
     }
   }
   // Process each @BindView element.
   for (Element element : env.getElementsAnnotatedWith(BindView.class)) {
     // 遍历带有{@interface: BindView}的元素
     // we don't SuperficialValidation.validateElement(element)
     // so that an unresolved View type can be generated by later processing rounds
     try {
       parseBindView(element, builderMap, erasedTargetNames);
     } catch (Exception e) {
       logParsingError(element, BindView.class, e);
     }
   }
   // Process each @BindViews element.
   for (Element element : env.getElementsAnnotatedWith(BindViews.class)) {
     // we don't SuperficialValidation.validateElement(element)
     // so that an unresolved View type can be generated by later processing rounds
     try {
       parseBindViews(element, builderMap, erasedTargetNames);
     } catch (Exception e) {
       logParsingError(element, BindViews.class, e);
     }
   }
   // Process each annotation that corresponds to a listener.
   for (Class<? extends Annotation> listener : LISTENERS) {
     findAndParseListener(env, listener, builderMap, erasedTargetNames);
   }
   // 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;
 }
############################################
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)",
           BindView.class.getSimpleName(), qualifiedName, 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)",
           BindView.class.getSimpleName(), 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);
 }

以上代码片段分为3部分。

部分1为process执行的部分,从代码可以得知,主要有两部实现: 1) 找到所有的注解标签与信息 2)生成代码

部分2为查找所有的注解信息,如下以BindView为例

  // Process each @BindView element.
   for (Element element : env.getElementsAnnotatedWith(BindView.class)) {
     // 遍历带有{@interface: BindView}的元素
     // we don't SuperficialValidation.validateElement(element)
     // so that an unresolved View type can be generated by later processing rounds
     try {
       parseBindView(element, builderMap, erasedTargetNames);
     } catch (Exception e) {
       logParsingError(element, BindView.class, e);
     }
   }

遍历所有带BindView的标签,然后解析注解信息。

部分3为解析注解信息, 我们可以得到注解的类的包名,类名, 注解的值是什么等等。

得到以上信息, 我们有了生成Binding类的所有条件。那么看一下怎么生成类的

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);
   }
   if (parentBinding != null) {
     result.superclass(parentBinding.bindingClassName);
   } else {
     result.addSuperinterface(UNBINDER);
   }
   if (hasTargetField()) {
     result.addField(targetTypeName, "target", PRIVATE);
   }
   if (isView) {
     result.addMethod(createBindingConstructorForView());
   } else if (isActivity) {
     result.addMethod(createBindingConstructorForActivity());
   } else if (isDialog) {
     result.addMethod(createBindingConstructorForDialog());
   }
   if (!constructorNeedsView()) {
     // Add a delegating constructor with a target type + view signature for reflective use.
     result.addMethod(createBindingViewDelegateConstructor());
   }
   result.addMethod(createBindingConstructor(sdk, debuggable));
   if (hasViewBindings() || parentBinding == null) {
     result.addMethod(createBindingUnbindMethod(result));
   }
   return result.build();
 }
private MethodSpec createBindingConstructorForView() {
   MethodSpec.Builder builder = MethodSpec.constructorBuilder()
       .addAnnotation(UI_THREAD)
       .addModifiers(PUBLIC)
       .addParameter(targetTypeName, "target");
   if (constructorNeedsView()) {
     builder.addStatement("this(target, target)");
   } else {
     builder.addStatement("this(target, target.getContext())");
   }
   return builder.build();
 }

看了吗? 根据MethodSpec可以生成任意的方法,根据TypeSpec可以生成任意的类;只要我们取得生成需要的相关信息。

整个流程都顺畅了,那么怎么调用的Binding类实现注入的呢? 答案是在ButterKnife.bind时。

注意:在编译的时候已经生成对应的Binding类, 在bind时只需要查找对应的类
看一下源码

 @NonNull @UiThread
 public static Unbinder bind(@NonNull Object target, @NonNull Dialog source) {
   View sourceView = source.getWindow().getDecorView();
   return createBinding(target, sourceView);
 }

核心方法是在createBinding中,继续往下看

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);
   }
 }

你看到了什么? findBindingConstructorForClass很明显是根据targetClass(MainActivity)得到构造函数。然后 return constructor.newInstance(target, source); 生成实例,这时候就调用了构造函数。而在构造函数中,它执行了findViewById与setOnClickListener, 并完成了赋值。这时候, 添加注解的Button 、View等就完成findViewById的赋值了。Button.setOnClickListener也完成了。

如果你不甘心,继续看一下如何得到构造函数的吧,这就用到了反射了。

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().(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;
 }

没骗你吧, 加载Binding类,然后getConstructure,得到构造函数了。

通过以上的分析,你还迷茫吗?如果你把我列举的3个技术准备,那么你就可以完成自己的ButterKnife了。是不是很简单呢? 毕竟是最简单的框架之一了。好了不早了,拜拜了~!

还是那句话,如果你觉得这篇文章对你有帮助,请帮我打Call, 如有疑问,请留言,谢谢!!!

如果您觉得不错,请别忘了转发、分享、点赞让更多的人去学习, 您的举手之劳,就是对小乐最好的支持,非常感谢!

如何您想进技术群交流,关注公众号在后台回复 “加群”,或者 “学习” 即可

来自:亓春杰

链接:https://www.jianshu.com/p/2967ff971177

著作权归作者所有,欢迎投稿。

ButterKnife你真的了解吗?


推荐阅读






看完本文有收获?请转发分享给更多人
关注「程序员小乐」,提升技能

以上就是:ButterKnife你真的了解吗? 的全部内容。

本站部分内容来源于互联网和用户投稿,如有侵权请联系我们删除,谢谢。
Email:[email protected]


系统运维
0 条回复 A 作者 M 管理员
    所有的伟大,都源于一个勇敢的开始!
欢迎您,新朋友,感谢参与互动!欢迎您 {{author}},您在本站有{{commentsCount}}条评论