红橙Darren视频笔记 手写ButterKnife(Android Studio4.2.2 gradle-6.7.1 )

ButterKnife的github地址

https://github.com/JakeWharton/butterknife

1.ButterKnife的使用

第一步 在moudle的gradle配置butterknife

    // 1 引入Butter knife到module
    implementation 'com.jakewharton:butterknife:10.2.3'
    annotationProcessor 'com.jakewharton:butterknife-compiler:10.2.3'
第二步 初始化ButterKnife
第三步 ButterKnife属性初始化 注意View必须有id
第四步 ButterKnife属性使用
第五步 ButterKnife Event使用
第六步 调用unbind取消ButterKnife的注入
public class MainActivity extends AppCompatActivity {
    // 3.ButterKnife属性初始化 注意View必须有id
    @BindView(R.id.tv1)
    TextView mTextView;

    @BindView(R.id.button1)
    Button mButton;
    Unbinder unbinder = null;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        //2 初始化ButterKnife
        unbinder = ButterKnife.bind(this);
        //4 ButterKnife属性使用
        mTextView.setText("Activity1-butter-tv1");
        mButton.setText("Activity1-butter-button1");
    }

    //5 ButterKnife Event使用
    @OnClick(R.id.button1)
    void buttonClick() {
        Intent intent = new Intent(MainActivity.this,Activity2.class);
        startActivity(intent);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 6.调用unbind取消ButterKnife的注入
        unbinder.unbind();
    }
}

另外一个activity

public class Activity2 extends AppCompatActivity {
    @BindView(R.id.tv1)
    TextView mTextView;

    @BindView(R.id.button1)
    Button mButton;

    Unbinder unbinder = null;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_2);
        unbinder = ButterKnife.bind(this);

        mTextView.setText("Activity2-butter-tv2");
        mButton.setText("Activity2-butter-button2");
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        unbinder.unbind();
    }
}

2.ButterKnife hardcode版

ButterKnife 的主要原理是利用javaPoet自动生成xx_ViewBingding.java代码,比如上面的两个activity会生成下面的两个文件

// Generated code from Butter Knife. Do not modify!
package com.example.selfbutterknife;

import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.CallSuper;
import androidx.annotation.UiThread;
import butterknife.Unbinder;
import butterknife.internal.DebouncingOnClickListener;
import butterknife.internal.Utils;
import java.lang.IllegalStateException;
import java.lang.Override;

public class MainActivity_ViewBinding implements Unbinder {
  private MainActivity target;

  private View view7f080058;

  @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;
    target.mTextView = Utils.findRequiredViewAsType(source, R.id.tv1, "field 'mTextView'", TextView.class);
    view = Utils.findRequiredView(source, R.id.button1, "field 'mButton' and method 'buttonClick'");
    target.mButton = Utils.castView(view, R.id.button1, "field 'mButton'", Button.class);
    view7f080058 = view;
    view.setOnClickListener(new DebouncingOnClickListener() {
      @Override
      public void doClick(View p0) {
        target.buttonClick();
      }
    });
  }

  @Override
  @CallSuper
  public void unbind() {
    MainActivity target = this.target;
    if (target == null) throw new IllegalStateException("Bindings already cleared.");
    this.target = null;

    target.mTextView = null;
    target.mButton = null;

    view7f080058.setOnClickListener(null);
    view7f080058 = null;
  }
}
// Generated code from Butter Knife. Do not modify!
package com.example.selfbutterknife;

import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.CallSuper;
import androidx.annotation.UiThread;
import butterknife.Unbinder;
import butterknife.internal.Utils;
import java.lang.IllegalStateException;
import java.lang.Override;

public class Activity2_ViewBinding implements Unbinder {
  private Activity2 target;

  @UiThread
  public Activity2_ViewBinding(Activity2 target) {
    this(target, target.getWindow().getDecorView());
  }

  @UiThread
  public Activity2_ViewBinding(Activity2 target, View source) {
    this.target = target;

    target.mTextView = Utils.findRequiredViewAsType(source, R.id.tv1, "field 'mTextView'", TextView.class);
    target.mButton = Utils.findRequiredViewAsType(source, R.id.button1, "field 'mButton'", Button.class);
  }

  @Override
  @CallSuper
  public void unbind() {
    Activity2 target = this.target;
    if (target == null) throw new IllegalStateException("Bindings already cleared.");
    this.target = null;

    target.mTextView = null;
    target.mButton = null;
  }
}

其实如果我们不添加ButterKnife的依赖 直接复制以下三个类到activity同级目录

public class MainActivity_ViewBinding {
    private MainActivity target;

    private View view7f080058;

    @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;
        target.mTextView = Utils.findRequiredViewAsType(source, R.id.tv1, "field 'mTextView'", TextView.class);
        view = Utils.findRequiredView(source, R.id.button1, "field 'mButton' and method 'buttonClick'");
        target.mButton = Utils.castView(view, R.id.button1, "field 'mButton'", Button.class);
        view7f080058 = view;
        view.setOnClickListener(new DebouncingOnClickListener() {
            @Override
            public void doClick(View p0) {
                target.buttonClick();
            }
        });
    }

}


public abstract class DebouncingOnClickListener implements View.OnClickListener {
    private static final Runnable ENABLE_AGAIN = () -> enabled = true;
    private static final Handler MAIN = new Handler(Looper.getMainLooper());

    static boolean enabled = true;

    @Override
    public final void onClick(View v) {
        if (enabled) {
            enabled = false;

            // Post to the main looper directly rather than going through the view.
            // Ensure that ENABLE_AGAIN will be executed, avoid static field {@link #enabled}
            // staying in false state.
            MAIN.post(ENABLE_AGAIN);

            doClick(v);
        }
    }

    public abstract void doClick(View v);
}

public final class Utils {

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

    public static <T> T findRequiredViewAsType(View source, @IdRes int id, String who,
                                               Class<T> cls) {
        View view = findRequiredView(source, id, who);
        return castView(view, id, who, cls);
    }

    public static <T> T castView(View view, @IdRes int id, String who, Class<T> cls) {
        try {
            return cls.cast(view);
        } catch (ClassCastException e) {
            String name = getResourceEntryName(view, id);
            throw new IllegalStateException("View '"
                    + name
                    + "' with ID "
                    + id
                    + " for "
                    + who
                    + " was of the wrong type. See cause for more info.", e);
        }
    }

    private static String getResourceEntryName(View view, @IdRes int id) {
        if (view.isInEditMode()) {
            return "<unavailable while editing>";
        }
        return view.getContext().getResources().getResourceEntryName(id);
    }

    private Utils() {
        throw new AssertionError("No instances.");
    }
}

然后在MainActivity稍作修改

public class MainActivity extends AppCompatActivity {
    TextView mTextView;

    Button mButton;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        MainActivity_ViewBinding binding = new MainActivity_ViewBinding(this);//重点
        mTextView.setText("Activity1-butter-tv1");
        mButton.setText("Activity1-butter-button1");
    }

    void buttonClick() {
        Intent intent = new Intent(MainActivity.this, Activity2.class);
        startActivity(intent);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
    }
}

就达到了butterknife的效果 只不过我们是写死的 那么下面就学习如何生成这些代码 让我们有一个新的id 也能自动添加到生成的文件里

3.ButterKnife动态生成版

架构如下

 

3.1 注解模块

build.gradle

plugins {
    id 'java-library'
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_7
    targetCompatibility = JavaVersion.VERSION_1_7
}

// 注释可以使用中文
tasks.withType(JavaCompile){
    options.encoding='UTF-8'
}

注解类

package com.example.self_butterknife_annotations;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * Created by hjcai on 2021/7/19.
 */
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.CLASS)
public @interface BindView {
    int value();
}

3.2 代码生成器模块

build.gradle

plugins {
    id 'java-library'
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_7
    targetCompatibility = JavaVersion.VERSION_1_7
}

// 引入Java Poet 用于生成代码
dependencies {
    // 用于自动为 JAVA Processor 生成 META-INF 信息
    implementation 'com.google.auto.service:auto-service:1.0-rc6'
    // 指定当前module的processor
    annotationProcessor 'com.google.auto.service:auto-service:1.0-rc6'
    //快速生成.java文件的库
    implementation 'com.squareup:javapoet:1.11.1'
//    implementation 'com.google.auto:auto-common:0.10'
    implementation project(path: ':self-butterknife-annotations')
}

// 注释可以使用中文
tasks.withType(JavaCompile){
    options.encoding='UTF-8'
}

具体进行代码生成的类(核心)

需要参考第一节中生成的文件来编写代码

package com.example.self_butterknife_compiler;


import java.io.IOException;
import java.lang.annotation.Annotation;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;

import com.example.self_butterknife_annotations.BindView;
import com.google.auto.service.AutoService;
import com.squareup.javapoet.ClassName;
import com.squareup.javapoet.JavaFile;
import com.squareup.javapoet.MethodSpec;
import com.squareup.javapoet.TypeSpec;

import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.Filer;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.Processor;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.TypeElement;
import javax.lang.model.util.Elements;

@AutoService(Processor.class)
public class ButterKnifeProcessor extends AbstractProcessor {
    private Filer mFiler;
    private Elements mElementUtils;

    // 初始化
    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        mFiler = processingEnv.getFiler();
        mElementUtils = processingEnv.getElementUtils();
    }

    /**
     * 用来指定支持的 SourceVersion
     */
    @Override
    public SourceVersion getSupportedSourceVersion() {
        return SourceVersion.latestSupported();
    }

    // 有注解就都会进process方法 这里是生成代码的核心
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
//        System.out.println("===process===");
//        System.out.println("annotations "+annotations);
//        System.out.println("roundEnv "+roundEnv);

        // 获取所有拥有注释BindView的变量
        Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(BindView.class);
        // 该map 存储的key是Activity的完整路径 value是其中被BindView标记的所有变量的list
        // 例如 {com.example.client.MainActivity=[mTextView,mtv1]}
        Map<Element, List<Element>> elementsMap = new LinkedHashMap<>();
        for (Element element : elements) {
            // 获取拥有注释BindView的变量位于哪个类 例如 com.example.client.MainActivity
            Element enclosingElement = element.getEnclosingElement();
            // 查看map中没有存储过该类
            List<Element> viewBindElements = elementsMap.get(enclosingElement);
            // 如果map中没有存储过该类 创建一个新的list 将Activity作为key list作为value 存储到map
            if (viewBindElements == null) {
                viewBindElements = new ArrayList<>();
                elementsMap.put(enclosingElement, viewBindElements);
            }
            // 将拥有注释BindView的变量存储到list
            viewBindElements.add(element);
        }

        // 遍历添加相关注解的Class 准备生成代码
        for (Map.Entry<Element, List<Element>> entry : elementsMap.entrySet()) {
            // 类的完整报名+类名
            Element enclosingElement = entry.getKey();
            // 类中存储的拥有注释BindView的变量集合
            List<Element> viewBindElements = entry.getValue();

            // 从完整包名+类名获取简短类名 例如 从 com.example.client.MainActivity 得到 MainActivity
            String activityClassNameStr = enclosingElement.getSimpleName().toString();

            ClassName activityClassName = ClassName.bestGuess(activityClassNameStr);
            // 用包名+类名创建接口的className
            // 目前是hardcode的包名
            ClassName unbinderClassName = ClassName.get("com.example.self_butterknife", "Unbinder");

            // 创建的类名为 类名+_ViewBinding
            TypeSpec.Builder classBuilder = TypeSpec.classBuilder(activityClassNameStr + "_ViewBinding")
                    // 该类是public final的
                    .addModifiers(Modifier.FINAL, Modifier.PUBLIC)
                    // 实现了一个接口
                    .addSuperinterface(unbinderClassName)
                    // 创建一个activityClassName的实例 私有对象 变量名为target
                    .addField(activityClassName, "target", Modifier.PRIVATE);


            /*****unbind 方法实现 start *****/
            // 利用包名+类名 构建 android.support.annotation.CallSuper的ClassName
            ClassName callSuperClassName = ClassName.get("androidx.annotation", "CallSuper");
            // 创建unbind方法
            MethodSpec.Builder unbindMethodBuilder = MethodSpec.methodBuilder("unbind")
                    // 给unbind方法加上Override注解
                    .addAnnotation(Override.class)
                    // 给unbind方法加上CallSuper注解
                    .addAnnotation(callSuperClassName)
                    // unbind方法是public final的
                    .addModifiers(Modifier.PUBLIC, Modifier.FINAL);
            // 方法内部添加 一行内容
            unbindMethodBuilder.addStatement("$T target = this.target", activityClassName);
            unbindMethodBuilder.addStatement("if (target == null) throw new IllegalStateException(\"Bindings already cleared. target is null! \");");
            /*****unbind方法实现 end *****/


            // 创建构造函数
            MethodSpec.Builder constructorMethodBuilder = MethodSpec.constructorBuilder()
                    // 给构造方法添加一个参数类型为activityClassName 值为target的参数
                    .addParameter(activityClassName, "target")
                    // 构造方法是public的
                    .addModifiers(Modifier.PUBLIC)
                    // 构造方法添加一行代码 this.target = target;
                    .addStatement("this.target = target");
            // 遍历添加相关注解的变量
            for (Element viewBindElement : viewBindElements) {
                // 获得变量名称
                String filedName = viewBindElement.getSimpleName().toString();
                // 获取Utils的ClassName
                ClassName utilsClassName = ClassName.get("com.example.self_butterknife", "Utils");
                // 获取注解的值 即view的id
                int resId = viewBindElement.getAnnotation(BindView.class).value();
                // TODO 根据id的int值反推其资源id

                // 在构造方法生成 类似 target.textView1 = Utils.findViewById(target, R.id.tv1); 的代码
                constructorMethodBuilder.addStatement("target.$L = $T.findViewById(target, $L)", filedName, utilsClassName, resId);
                // 在unbind方法生成类似 target.textView1 = null;的语句
                unbindMethodBuilder.addStatement("target.$L = null", filedName);
            }


            // 加入两个方法
            classBuilder.addMethod(unbindMethodBuilder.build());
            classBuilder.addMethod(constructorMethodBuilder.build());

            // 生成类,看下效果
            try {
                // 根据完整包名+类名得到包名
                String packageName = mElementUtils.getPackageOf(enclosingElement).getQualifiedName().toString();
                // 文件创建的位置在packageName下
                JavaFile.builder(packageName, classBuilder.build())
                        // Java文件 头部添加注释添加
                        .addFileComment("由ButterKnifeProcessor自动生成 请勿修改")
                        .build()
                        // 写文件
                        .writeTo(mFiler);
            } catch (IOException e) {
                e.printStackTrace();
                System.out.println("发生异常...");
            }
        }
        return false;
    }


    // 哪些注解支持自动生成代码
    @Override
    public Set<String> getSupportedAnnotationTypes() {
        Set<String> types = new LinkedHashSet<>();
        for (Class<? extends Annotation> annotation : getSupportedAnnotations()) {
            types.add(annotation.getCanonicalName());
        }
        return types;
    }

    // 支持自动生成文件的Annotation(需要解析的自定义注解 例如 BindView OnClick)
    private Set<Class<? extends Annotation>> getSupportedAnnotations() {
        Set<Class<? extends Annotation>> annotations = new LinkedHashSet<>();
        // 需要解析的自定义注解 BindView  OnClick
        annotations.add(BindView.class);
        return annotations;
    }

}

3.3 工具类模块

build.gradle

plugins {
    id 'com.android.library'
}

android {
    compileSdkVersion 30
    buildToolsVersion "30.0.3"

    defaultConfig {
        minSdkVersion 24
        targetSdkVersion 30
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        consumerProguardFiles "consumer-rules.pro"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

dependencies {

    implementation 'androidx.appcompat:appcompat:1.3.0'
    implementation 'com.google.android.material:material:1.4.0'
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

findView工具类

package com.example.self_butterknife;

import android.app.Activity;
import android.view.View;


/**
 * Created by hjcai on 2021/7/19.
 */
public class Utils {
    public static <T extends View> T findViewById(Activity activity, int viewId) {
        return activity.findViewById(viewId);
    }
}

反注册接口

package com.example.self_butterknife;

import androidx.annotation.UiThread;

/**
 * Created by hjcai on 2021/7/19.
 */
public interface Unbinder {
    @UiThread
    void unbind();

    Unbinder EMPTY = () -> {
    };
}

ButterKnife Binding类实例化的类

package com.example.self_butterknife;

import android.app.Activity;

import java.lang.reflect.Constructor;

/**
 * Created by hjcai on 2021/7/19.
 */
public class ButterKnife {
    public static Unbinder bind(Activity activity) {
        if (activity == null) {
            throw new IllegalArgumentException(" activity should not be null");
        }
        try {
            Class<? extends Unbinder> bindClassName = (Class<? extends Unbinder>)
                    Class.forName(activity.getClass().getName() + "_ViewBinding");
            // 构造函数
            // 通过反射调用 类似MainActivity_ViewBinding bind = new MainActivity_ViewBinding(this); 的构造方法
            Constructor<? extends Unbinder> bindConstructor = bindClassName.getDeclaredConstructor(activity.getClass());
            // 返回 Unbinder
            return bindConstructor.newInstance(activity);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return Unbinder.EMPTY;
    }
}

3.4 客户端模块

build.gradle

plugins {
    id 'com.android.application'
}

android {
    compileSdkVersion 30
    buildToolsVersion "30.0.3"

    defaultConfig {
        applicationId "com.example.self_butterknife_client"
        minSdkVersion 24
        targetSdkVersion 30
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

dependencies {

    implementation 'androidx.appcompat:appcompat:1.3.0'
    implementation 'com.google.android.material:material:1.4.0'
    implementation project(path: ':self-butterknife')
    implementation project(path: ':self-butterknife-annotations')
    annotationProcessor project(path: ':self-butterknife-compiler')

    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

MainActivity

package com.example.client;

import android.os.Bundle;
import android.widget.Button;
import android.widget.TextView;

import androidx.appcompat.app.AppCompatActivity;

import com.example.self_butterknife.ButterKnife;
import com.example.self_butterknife.Unbinder;
import com.example.self_butterknife_annotations.BindView;
import com.example.self_butterknife_client.R;

public class MainActivity extends AppCompatActivity {
    @BindView(R.id.tv1)
    TextView mTextView;

    @BindView(R.id.button1)
    Button mButton;

    Unbinder unbinder;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        unbinder = ButterKnife.bind(this);
        mTextView.setText("Activity1-butter-tv1");
        mButton.setText("Activity1-butter-button1");
    }

//    void buttonClick() {
//        Intent intent = new Intent(MainActivity.this, Activity2.class);
//        startActivity(intent);
//    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        unbinder.unbind();
    }
}

3.5 最终生成的类

 

// 由ButterKnifeProcessor自动生成 请勿修改
package com.example.client;

import androidx.annotation.CallSuper;
import com.example.self_butterknife.Unbinder;
import com.example.self_butterknife.Utils;
import java.lang.Override;

public final class MainActivity_ViewBinding implements Unbinder {
  private MainActivity target;

  public MainActivity_ViewBinding(MainActivity target) {
    this.target = target;
    target.mTextView = Utils.findViewById(target, 2131231125);
    target.mButton = Utils.findViewById(target, 2131230808);
  }

  @Override
  @CallSuper
  public final void unbind() {
    MainActivity target = this.target;
    if (target == null) throw new IllegalStateException("Bindings already cleared. target is null! ");;
    target.mTextView = null;
    target.mButton = null;
  }
}

后记:

本节难点不多 耗时最长的是一直在查AbstractProcessor的process不执行的原因。因为Android Studio以及Gradle版本的变化 很多gradle的写法都发生了改变 需要注意的是

1.ButterKnifeProcessor头部的注解@AutoService(Processor.class)

2.compiler模块自身com.google.auto.service:auto-service:1.0-rc6要写2次,个人理解一次是引用其中的类 另一次是指定当前module的processor

    // 用于自动为 JAVA Processor 生成 META-INF 信息

    implementation 'com.google.auto.service:auto-service:1.0-rc6'

    // 指定当前module的processor

    annotationProcessor 'com.google.auto.service:auto-service:1.0-rc6'

3.client对于compiler模块也是有依赖的

    annotationProcessor project(path: ':self-butterknife-compiler')

另外当前的code还有很多提升的地方

比如ButterKnifeProcessor中有很多包名是hardcode的

比如生成的代码中id的值是一个很长的int值而不是一个R.id.xxx类似的规范id

比如目前只支持findviewbyid,还不能支持点击事件

不过这个javapoet生成代码的技术确实比较有趣 应该还能做其他很多事情

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 7
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值