插件化基础知识(反射,动态代理,类加载器)

Java 反射

获得Class对象

获取类的构造函数

调用类的私有方法

获取类的私有字段并修改值

代理

静态代理

动态代理

动态代理的简单应用

类加载器

类加载器分类

双亲委派模型

几个重要函数

自定义ClassLoader

Android 类加载器

PathClassLoader

DexClassLoader


Java 反射

Java 反射机制在程序运行时,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性。这种 动态的获取信息 以及 动态调用对象的方法 的功能称为 Java 的反射机制

例如用反射可以操作Test 类,

 Test类定义是两个个私有变量,两个公有构造方法和一个私有的构造方法,以及一个私有方法。

package demo;

public class Test {
    private int age;
    private String name;

    public Test() {}

    public Test(int age, String name) {
        this.age = age;
        this.name = name;
        System.out.println("public -> " + name + " " + age);
    }

    private Test(String name) {
        this.name = name;
        System.out.println("private -> " + name);
    }

    private void welcome(String tips) {
        System.out.println(tips);
    }
}

获得Class对象

每个类被加载之后,系统就会为该类生成一个对应的Class对象。通过该Class对象就可以访问到JVM 中的这个类。

//第一种方式 通过Class类的静态方法——forName()来实现
Class class1 = Class.forName("demo.Test");
//第二种方式 通过类的class属性
class1 = Test.class;
//第三种方式 通过对象getClass方法
Test test = new Test();
class1 = test.getClass();
System.out.println(class1.getName());

打印结果:

获取类的构造函数

获取类的所有构造方法

通过getDeclaredConstructors可以返回类的所有构造方法,返回的是一个数组因为构造方法可能不止一个,

通过getModifiers可以得到构造方法的类型,

getParameterTypes可以得到构造方法的所有参数,

所以如果想获取所有构造方法以及每个构造方法的参数类型,可以有如下代码:

Class clz = Test.class;
        Constructor[] constructors = clz.getDeclaredConstructors();

        for (Constructor c : constructors) {
            System.out.print(Modifier.toString(c.getModifiers()) + " - ");
            Class[] ptypes = c.getParameterTypes();
            for (Class pclz : ptypes) {
                System.out.print(pclz.getName());
            }
            System.out.println();
        }

打印结果:

获取类中特定的构造方法

例如想获取有两个参数分别为int和String类型的构造方法,代码如下:

Class tclz = Test.class;
Class[] ps = {int.class,String.class};
Constructor constructor = tclz.getDeclaredConstructor(ps);
System.out.print(Modifier.toString(constructor.getModifiers()) + " - ");
Class[] ptypes = constructor.getParameterTypes();
for (Class pclz : ptypes) {
    System.out.print(pclz.getName() + " ");
}

打印结果:

调用构造方法

通过Constructor 的newInstance方法,创建实例,调用构造方法;

Object o = constructor.newInstance(22,"Sam");

那么调用私有构造方法与上面一样,只是我们要设置constructors.setAccessible(true);代码如下:

Class tclz = Test.class;
Constructor constructor = tclz.getDeclaredConstructor(String.class);
constructor.setAccessible(true);
Object o = constructor.newInstance("Sam");

调用类的私有方法

例如调用那个,welcome 方法;

// 获取class 对象的名为welcome ,有一个String 参数的方法
Method method = tclz.getDeclaredMethod("welcome",String.class);
method.setAccessible(true);
// 然后通过invoke方法执行,invoke需要两个参数一个是类的实例,一个是方法参数
method.invoke(o,"test");

获取类的私有字段并修改值

感觉这个很重要,因为我们一个对象的字段可能是某个关键或者是某个对象,如果我们可以修改这个字段,就意味着,可以创建一个自己的对象,替换该对象的这个字段值;

Class tclz = Test.class;
Field field = tclz.getDeclaredField("name");
field.setAccessible(true);
Test o = new Test(11,"aaa");
field.set(o,"test");
System.out.println(field.get(o).toString());

修改了这个对象的name 这个私有字段的值;

代理

静态代理

在说动态代理之前可以先了解一下静态代理,相对较简单,例子如下:

public class ProxyDemo {
    public static void main(String args[]){
        RealSubject subject = new RealSubject();
        Proxy p = new Proxy(subject);
        p.request();
    }
}

interface Subject{
    void request();
}

class RealSubject implements Subject{
    public void request(){
        System.out.println("request");
    }
}

class Proxy implements Subject{
    private Subject subject;
    public Proxy(Subject subject){
        this.subject = subject;
    }
    public void request(){
        System.out.println("PreProcess");
        subject.request();
        System.out.println("PostProcess");
    }
}

可以看到在代理对象(Proxy)里,通过构造函数传入目标对象(RealSubject ),然后重写主题接口(Subject)的request()方法,在该方法调用目标对象(RealSubject )的request()方法,并可以添加一些额外的处理工作;关于代理也可以参考理解java的三种代理模式 ;

动态代理

先体会动态代理的创建过程;

先创建一个主题类,如下:

interface Subject{
    void request();
}

实现目标对象;

class RealSubject implements Subject{
    public void request(){
        System.out.println("====RealSubject Request====");
    }
}

实现代理类调用处理器InvocationHandler ;

class ProxyHandler implements InvocationHandler{
    private Subject subject;
    public ProxyHandler(Subject subject){
        this.subject = subject;
    }
    @Override
    public Object invoke(Object proxy, Method method, Object[] args)
            throws Throwable {
        //定义预处理的工作,当然你也可以根据 method 的不同进行不同的预处理工作
        System.out.println("====before====");
       //调用RealSubject中的方法
        Object result = method.invoke(subject, args);
        System.out.println("====after====");
        return result;
    }
}

最后通过java.lang.reflect.Proxy 动态生成代理对象,然后就可以调用方法啦;

//1.创建目标对象
RealSubject realSubject = new RealSubject();
//2.创建调用处理器对象
ProxyHandler handler = new ProxyHandler(realSubject);
//3.动态生成代理对象
Subject proxySubject = (Subject)Proxy.newProxyInstance(
        RealSubject.class.getClassLoader(),
        RealSubject.class.getInterfaces(),
        handler);
//4.通过代理对象调用方法
proxySubject.request();

可以看到,通过newProxyInstance就产生了一个Subject 的实例,即代理类的实例,

通过Subject .request(),就会调用InvocationHandler 的invoke()方法,

传入方法Method对象,以及调用方法的参数,通过Method.invoke调用RealSubject中的方法的request()方法,

也可以在InvocationHandler 的invoke()方法加入其他执行逻辑。

动态代理的简单应用

在插件化学习过程,涉及到了一个Hook 的概念,可以看我的这篇博客插件化学习之Hook 是怎么回事 ,在示例中用了代理Hook 处理了IActivityManager ,这里就是用了动态代理,我们简单的看一下;

// 原始的 IActivityManager对象
        Object rawIActivityManager = mInstanceField.get(iamSingletonObj);

        // 用动态代理,这里在执行相应方法执行我们的一些逻辑
        // (这里指的是修改Intent 使用坑位Activity ,从而可以越过AMS)
        // 创建一个这个对象的代理对象, 然后替换这个字段, 让我们的代理对象帮忙干活
        Class<?> iActivityManagerInterface = Class.forName("android.app.IActivityManager");
        Object proxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(),
                new Class<?>[] { iActivityManagerInterface }, new IActivityManagerHandler(rawIActivityManager));
        mInstanceField.set(iamSingletonObj, proxy);

 代理类调用处理器为IActivityManagerHandler ,思路一样,就是在调用原始对象的方法之前或之后加入我们新的需求逻辑,然后把相应字段的值,设置成了我们的代理对象,这样系统在调用相应方法的时候,会调用我们代理对象的方法,而代理对象的那额方法会调用原始对象的方法,所以系统不会出问题,只不多还执行了新的需求逻辑而已。

类加载器

类加载机制

把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。

在Java语言里,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点来实现的。

类的生命周期

加载,连接,初始化,使用和卸载。

其中验证,准备,解析3个部分统称为连接。

类加载器

其中加载部分的功能是将类的class文件读入内存,并为之创建一个java.lang.Class对象。这部分功能就是由类加载器来实现的。

类加载器分类

不同的类加载器负责加载不同的类。主要分为两类。

1.启动类加载器(Bootstrap ClassLoader): 由C++语言实现(针对HotSpot),负责将存放在\lib目录或-Xbootclasspath参数指定的路径中的类库加载到内存中,即负责加载Java的核心类。

2.其他类加载器: 由Java语言实现,继承自抽象类ClassLoader。如:

         扩展类加载器(Extension ClassLoader): 负责加载\lib\ext目录或java.ext.dirs系统变量指定的路径中的所有类库,即负责加载Java扩展的核心类之外的类。

         应用程序类加载器(Application ClassLoader): 负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器,通过ClassLoader.getSystemClassLoader()方法直接获取。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。

双亲委派模型

双亲委派模型工作过程是:如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时(即ClassNotFoundException),子加载器才会尝试自己去加载。

这样的好处是不同层次的类加载器具有不同优先级,比如所有Java对象的超级父类java.lang.Object,位于rt.jar,无论哪个类加载器加载该类,最终都是由启动类加载器进行加载,保证安全。即使用户自己编写一个java.lang.Object类并放入程序中,虽能正常编译,但不会被加载运行,保证不会出现混乱。

JVM判断一个对象是否是某个类型时,如果该对象的实际类型与待比较的类型的类加载器不同,那么会返回false。

如何实现双亲委派模型呢?每次通过先委托父类加载器加载,当父类加载器无法加载时,再自己加载。

几个重要函数

 loadClass

public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        Class c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }

            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                long t1 = System.nanoTime();
                c = findClass(name);

                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

可以看出其大致过程,1.检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回。

2.如果此类没有加载过,那么,再判断一下是否有父加载器;如果有父加载器,则由父加载器加载(即调用parent.loadClass(name, false);).或者是调用bootstrap类加载器来加载。

3.如果父加载器及bootstrap类加载器都没有找到指定的类,那么调用当前类加载器的findClass方法来完成类加载。

自定义类加载器要重写findClass方法

protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
}

 loadClass在父加载器无法加载类的时候,就会调用我们自定义的类加载器中的findeClass函数,所以需要在findClass 实现根据指定类名返回相应的Class 对象;比较幸运的是Java 为我们提供了一个defineClass 方法;

defineClass

protected final Class<?> defineClass(String name, byte[] b, int off, int len)
        throws ClassFormatError  {
        return defineClass(name, b, off, len, null);
}

defineClass 可将一个字节数组转为Class 对象;

自定义ClassLoader

1.创建一个用来加载的Java 类:Test.java ,放在com.ccc包下,

package com.ccc;

public class Test {
    public void hello() {
        System.out.println("Test  -->  " + getClass().getClassLoader().getClass()
                + " 加载进来的");
    }
}

把编译生成的class 文件在相应目录放好,如图,

import java.io.FileInputStream;
import java.lang.reflect.Method;

public class Main {
    static class MyClassLoader extends ClassLoader {

        private String classPath;

        public MyClassLoader(String classPath) {
            this.classPath = classPath;
        }

        private byte[] loadByte(String name) throws Exception {
            name = name.replaceAll("\\.", "/");
            FileInputStream fis = new FileInputStream(classPath + "/" + name
                    + ".class");
            int len = fis.available();
            byte[] data = new byte[len];
            fis.read(data);
            fis.close();
            return data;
        }

        @Override
        protected Class<?> findClass(String name) throws ClassNotFoundException {
            try {
                byte[] data = loadByte(name);
                return defineClass(name, data, 0, data.length);
            } catch (Exception e) {
                e.printStackTrace();
                throw new ClassNotFoundException();
            }
        }
    }

    public static void main(String[] args) throws Exception {
        MyClassLoader classLoader = new MyClassLoader("D:/test");
        Class clazz = classLoader.loadClass("com.ccc.Test");
        Object obj = clazz.newInstance();
        Method helloMethod = clazz.getDeclaredMethod("hello", null);
        helloMethod.invoke(obj, null);
    }

}

打印结果:

Test  -->  class Main$MyClassLoader 加载进来的

Android 类加载器

Android 提供了两个Classloader,PathClassLoader 和 DexClassLoader ,它们都继承自BaseDexClassLoader,BaseDexClassLoader继承自ClassLoader ;

Android系统通过PathClassLoader来加载系统类与主dex 的类。

而DexClassLoader则用于加载其他dex文件中的类。

PathClassLoader

为什么说PathClassLoader 只能加载系统类和主dex的类呢,看一下这个类的源代码(看这个类的源码可以点这里);

    /**
     * Provides a simple {@link ClassLoader} implementation that operates on a list
     * of files and directories in the local file system, but does not attempt to
     * load classes from the network. Android uses this class for its system class
     * loader and for its application class loader(s).
     */
    public class PathClassLoader extends BaseDexClassLoader {
        /**
         * Creates a {@code PathClassLoader} that operates on a given list of files
         * and directories. This method is equivalent to calling
         * {@link #PathClassLoader(String, String, ClassLoader)} with a
         * {@code null} value for the second argument (see description there).
         *
         * @param dexPath the list of jar/apk files containing classes and
         * resources, delimited by {@code File.pathSeparator}, which
         * defaults to {@code ":"} on Android
         * @param parent the parent class loader
         */
        public PathClassLoader(String dexPath, ClassLoader parent) {
            super(dexPath, null, null, parent);
        }

        /**
         * Creates a {@code PathClassLoader} that operates on two given
         * lists of files and directories. The entries of the first list
         * should be one of the following:
         *
         * <ul>
         * <li>JAR/ZIP/APK files, possibly containing a "classes.dex" file as
         * well as arbitrary resources.
         * <li>Raw ".dex" files (not inside a zip file).
         * </ul>
         *
         * The entries of the second list should be directories containing
         * native library files.
         *
         * @param dexPath the list of jar/apk files containing classes and
         * resources, delimited by {@code File.pathSeparator}, which
         * defaults to {@code ":"} on Android
         * @param libraryPath the list of directories containing native
         * libraries, delimited by {@code File.pathSeparator}; may be
         * {@code null}
         * @param parent the parent class loader
         */
        public PathClassLoader(String dexPath, String libraryPath,
                ClassLoader parent) {
            super(dexPath, null, libraryPath, parent);
        }
    }

从注释上也可以看到,PathClassLoader被用来加载本地文件系统上的文件或目录,但不能从网络上加载;

从代码上可以看到super(dexPath, null, libraryPath, parent); 第二个参数传的空,为什么传空使它只能加载主apk 的类呢,这里对比一下DexClassLoader 没有传空就知道了;

DexClassLoader

    /**
     * A class loader that loads classes from {@code .jar} and {@code .apk} files
     * containing a {@code classes.dex} entry. This can be used to execute code not
     * installed as part of an application.
     *
     * <p>This class loader requires an application-private, writable directory to
     * cache optimized classes. Use {@code Context.getDir(String, int)} to create
     * such a directory: <pre>   {@code
     *   File dexOutputDir = context.getDir("dex", 0);
     * }</pre>
     *
     * <p><strong>Do not cache optimized classes on external storage.</strong>
     * External storage does not provide access controls necessary to protect your
     * application from code injection attacks.
     */
    public class DexClassLoader extends BaseDexClassLoader {
        /**
         * Creates a {@code DexClassLoader} that finds interpreted and native
         * code.  Interpreted classes are found in a set of DEX files contained
         * in Jar or APK files.
         *
         * <p>The path lists are separated using the character specified by the
         * {@code path.separator} system property, which defaults to {@code :}.
         *
         * @param dexPath the list of jar/apk files containing classes and
         *     resources, delimited by {@code File.pathSeparator}, which
         *     defaults to {@code ":"} on Android
         * @param optimizedDirectory directory where optimized dex files
         *     should be written; must not be {@code null}
         * @param libraryPath the list of directories containing native
         *     libraries, delimited by {@code File.pathSeparator}; may be
         *     {@code null}
         * @param parent the parent class loader
         */
        public DexClassLoader(String dexPath, String optimizedDirectory,
                String libraryPath, ClassLoader parent) {
            super(dexPath, new File(optimizedDirectory), libraryPath, parent);
        }
    }

optimizedDirectory :dex 文件被加载后会被编译器优化之后的dex存放路径,不可以为null。

在DexClassLoader 构造函数参数的注释上说明了,optimizedDirectory  不能传空,传空应该就不具备DexClassLoader 加载其他dex的功能了;

继续跟踪看一下DexClassLoader 的父类BaseDexClassLoader

    public class BaseDexClassLoader extends ClassLoader {
        private final DexPathList pathList;

        public BaseDexClassLoader(String dexPath, File optimizedDirectory,
                String libraryPath, ClassLoader parent) {
            super(parent);
            this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
        }

        @Override
        protected Class<?> findClass(String name) throws ClassNotFoundException {
            List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
            Class c = pathList.findClass(name, suppressedExceptions);
            if (c == null) {
                ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
                for (Throwable t : suppressedExceptions) {
                    cnfe.addSuppressed(t);
                }
                throw cnfe;
            }
            return c;
        }
    }

可以看到它的findClass 方法调用了Class c = pathList.findClass(name, suppressedExceptions); ,pathList 是DexPathList 类型,去看一下源码,

    /**
     * Constructs an instance.
     *
     * @param definingContext the context in which any as-yet unresolved
     * classes should be defined
     * @param dexPath list of dex/resource path elements, separated by
     * {@code File.pathSeparator}
     * @param libraryPath list of native library directory path elements,
     * separated by {@code File.pathSeparator}
     * @param optimizedDirectory directory where optimized {@code .dex} files
     * should be found and written to, or {@code null} to use the default
     * system directory for same
     */
    public DexPathList(ClassLoader definingContext, String dexPath,
            String libraryPath, File optimizedDirectory) {
        if (definingContext == null) {
            throw new NullPointerException("definingContext == null");
        }
        if (dexPath == null) {
            throw new NullPointerException("dexPath == null");
        }
        if (optimizedDirectory != null) {
            if (!optimizedDirectory.exists())  {
                throw new IllegalArgumentException(
                        "optimizedDirectory doesn't exist: "
                        + optimizedDirectory);
            }
            if (!(optimizedDirectory.canRead()
                            && optimizedDirectory.canWrite())) {
                throw new IllegalArgumentException(
                        "optimizedDirectory not readable/writable: "
                        + optimizedDirectory);
            }
        }
        this.definingContext = definingContext;

        ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
        // save dexPath for BaseDexClassLoader
        this.dexElements = makePathElements(splitDexPath(dexPath), optimizedDirectory,
                                            suppressedExceptions);
        // ...
    }

注释上说 optimizedDirectory 如果为空,则使用默认的系统目录,所以PathClassLoader 使用的目录,因为应用已经安装并优化了,优化后的dex 存在于默认的系统目录;

DexClassLoader 可以指定一个目录,所以使用DexClassLoader 加载其他的dex ;

在DexClassLoader 的构造函数可以看到这句,

this.dexElements = makePathElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions);

dexElements 是一个Element 类型的数组,存放的这些Element 就对应 dex文件;

/**
211     * Makes an array of dex/resource path elements, one per element of
212     * the given array.
213     */
214    private static Element[] makePathElements(List<File> files, File optimizedDirectory,
215                                              List<IOException> suppressedExceptions) {
216        List<Element> elements = new ArrayList<>();
217        /*
218         * Open all files and load the (direct or contained) dex files
219         * up front.
220         */
221        for (File file : files) {
222            File zip = null;
223            File dir = new File("");
224            DexFile dex = null;
225            String path = file.getPath();
226            String name = file.getName();
227
228            if (path.contains(zipSeparator)) {
229                String split[] = path.split(zipSeparator, 2);
230                zip = new File(split[0]);
231                dir = new File(split[1]);
232            } else if (file.isDirectory()) {
233                // We support directories for looking up resources and native libraries.
234                // Looking up resources in directories is useful for running libcore tests.
235                elements.add(new Element(file, true, null, null));
236            } else if (file.isFile()) {
237                if (name.endsWith(DEX_SUFFIX)) {
238                    // Raw dex file (not inside a zip/jar).
239                    try {
240                        dex = loadDexFile(file, optimizedDirectory);
241                    } catch (IOException ex) {
242                        System.logE("Unable to load dex file: " + file, ex);
243                    }
244                } else {
245                    zip = file;
246
247                    try {
248                        dex = loadDexFile(file, optimizedDirectory);
249                    } catch (IOException suppressed) {
250                        /*
251                         * IOException might get thrown "legitimately" by the DexFile constructor if
252                         * the zip file turns out to be resource-only (that is, no classes.dex file
253                         * in it).
254                         * Let dex == null and hang on to the exception to add to the tea-leaves for
255                         * when findClass returns null.
256                         */
257                        suppressedExceptions.add(suppressed);
258                    }
259                }
260            } else {
261                System.logW("ClassLoader referenced unknown path: " + file);
262            }
263
264            if ((zip != null) || (dex != null)) {
265                elements.add(new Element(dir, false, zip, dex));
266            }
267        }
268
269        return elements.toArray(new Element[elements.size()]);
270    }

加载dex 文件,创建并添加对应的Element ,返回Element 数组;

我们已经知道一个Element 对应着一个dex 文件,这个Element 数组也会在findClass 方法用到;

前面也提到BaseDexClassLoader 的findClass 方法调用了DexPathList 的findClass 方法;来看一下findClass 方法DexPathList 的findClass 方法;

 public Class findClass(String name, List<Throwable> suppressed) {
334        for (Element element : dexElements) {
335            DexFile dex = element.dexFile;
336
337            if (dex != null) {
338                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
339                if (clazz != null) {
340                    return clazz;
341                }
342            }
343        }
344        if (dexElementsSuppressedExceptions != null) {
345            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
346        }
347        return null;
348    }

可以看到遍历dexElements ,执行Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed); 如果clazz 不为空,则跳出循环,说明目标类加载成功了;

通过分析知道要加载其他的dex 的类,还是要用DexClassLoader 这个类。

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值