类加载机制实现Android热修复

本文通过类加载机制实现Android热修复,Demo实现的功能:检测服务器是否存在补丁,存在即下载补丁,安装补丁,重启APP生效。支持多个补丁包修复:如果已经下载了多个补丁包,重启app对补丁包进行排序,并依次修复。本文比较贴近实际应用。

效果图

这里写图片描述

如果感觉不能一步步自己实现,可以看看本文将的热修复原理,然后直接下载完整代码,多敲几遍,就行了。

什么是Android热修复技术?

PS:本文通过 “类加载机制” 实现代码热修复,资源和so库类修复不在本文介绍范围内,是在想使用的话,请参考下文介绍的当下流行的热修复技术框架。

对于这个弱智的问题,相信不需要过多的解释,就是:在不重新安装apk的情况下,通过补丁,修复bug。盗用阿里的2张图(Thanks阿里)

这里写图片描述


这里写图片描述

目前主流的热修复技术框架

  • 阿里系的: AndfixHotfixSophix

  • 腾讯系的:QQ空间超级补丁技术QfixTinker

  • 美团系的:Robust

  • 饿了么的:Amigo

代码热修复实现原理和优缺点

Ps:上述4大系列框架都很全面,包括了代码修复、资源修复、so库修复等。本文我们只谈如何自己动手实现代码热修复(我觉得日常开发足够了)

代码热修复2种方案

  • 通过类加载机制实现

    优点:适用性强、修复范围广、限制少

    缺点:属于热修复中的冷修复、需要重启App

  • 通过底层替换方法实现

    优点:时效好、不需重启,即使生效

    缺点:受限制较多(需要修改虚拟机字段,如果手机厂商修改了虚拟机…….)

通过类加载机制实现代码热修复(来点干货)

类加载机制有什么是我们可利用的呢?

认识BaseDexClassLoaderPathClassLoaderDexClassLoader
  • PathClassLoader:系统运作,app运行时用于加载app所有需要的类。属于系统层面,正常情况下我们不可操作(二班情况下,就可以了,哈哈,我们接下类要做的就是通过反射机制修改它,达到热修复的目的)。

  • DexClassLoader:程序员运作,可以通过它加载我们想加载的资源,一般包括这么几种:jardexapk等。

  • BaseDexClassLoader:热修复中的大Boss,PathClassLoader和DexClassLoader均继承自BaseDexClassLoader,PathClassLoader和DexClassLoader的重要方法均在其父类BaseDexClassLoader中。(我们反编译就需要从BaseDexClassLoader入手)。

用到xx类的时候,虚拟机是怎么找到它的?

简单描述下:用到xx类的时候,虚拟机会利用PathClassLoader去遍历加载过的所有dex文件,从中查找到xx类,一旦找到就return。

BaseDexClassLoader查找类的源码:

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

通过源码可以看到,BaseDexClassLoader通过pathList.findClass查找类的,这里出现一个 大Boss “PathList

PathList:中保存类所有dex文件和信息,看一下它是怎么查找类的
PathList源码

public Class findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {
            DexFile dex = element.dexFile;

            if (dex != null) {
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }

前方高能:

看到没有,PathList从dexElements中查找类,如果clazz != null直接return class,这就是我们可以利用的地方,从源码看,dexElements应该是个数组或者集合,设想:我们是不是可以把我们修复bug后的xx类,打包成dex,插入到dexElements的最前面,这样,系统通过PathClassLoader,查找bug类的时候,就会下找到我们的修复bug的xx类,然后直接返回,不去管后面有bug的那个xx类,达到热修复的功能。

Ps:不放心,看看dexElements中到底是什么?
贴一部分能说明问题的代码:

private Element[] dexElements;

static class Element {
        private final File dir;
        private final boolean isDirectory;
        private final File zip;
        private final DexFile dexFile;

        private ClassPathURLStreamHandler urlHandler;
        private boolean initialized;

        public Element(File dir, boolean isDirectory, File zip, DexFile dexFile) {
            this.dir = dir;
            this.isDirectory = isDirectory;
            this.zip = zip;
            this.dexFile = dexFile;
        }

这段你代码是从PahtList.java中复制的,dexElements是Element类型的数组,而Element是PahtList的内部类,其中保存了DexFile和路径等信息。证实我们的热修复方案是可行的。

推荐一个可以看在线看Android源码的网站

上面贴的源码是“dalvik”级别的,在studio中是看不到的,看到的是一堆抛异常的代码,我看了studio中的BaseDexClassLoader源码还傻傻的去别人博客留言说我的android版本源码变了,和你的不一样,还能不能实现热修复,现在想想挺搞笑。

* 在线查看Android各版本源码 *

理一下我们热修复的方案

  • 修复有bug的类,生成dex补丁包;

  • 通过反射机制得到PathClassLoader的成员你变量PathList字段(通过上面分析知道,PathList是PathClassLoader父类BaseDexClasLoader中的)

  • 然后再反射PathList获取它的dexElements字段(是一个存放dex的Element数组)

  • 将我们生成的dex补丁包,插入到dexElements的数组的最前端

代码实现Android热修复(代码开撸)

需求

现有一个app,MainActivity中有2个按钮,“跳转Activity2”和“查看People信息”,从服务器下载补丁,把“查看People信息”按钮改为“悄悄修改了代码”,将Acitivity2原来展示的信息,改为“我又来热修复了…”

实现步骤
  1. 编写改变前的app

  2. 编写热修复需要重写生成的类

  3. 通过2步骤的新类生成dex补丁包”001dex”,并放到本地的tomcat服务器,编写配置文件

  4. 编写补丁检测和下载代码

  5. 编写修复补丁代码

Ps:为了更清晰,一步步来,大牛请直接跳到第5部,哈哈~~~

编写原app

一共3个类:MainActivity、Acitivity2、People类,部分代码如下

  1. MainActivity.java

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    // 跳转Activity2按钮点击回调方法
    public void jumpToA2(View v){
        Intent intent = new Intent(this, Activity2.class) ;
        startActivity(intent) ;
    }

   // 展示People信息
   public void showPeopleInfo(View v){
        Toast.makeText(this, new People(20, "小明").toString(), Toast.LENGTH_LONG).show() ;
   }
}
  1. Activity2.java
public class Activity2 extends Activity {

    private TextView view ;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity2);
        view = (TextView) findViewById(R.id.tv_info) ;
        view.setText("Activit2") ;
    }
}
  1. People.java
public class People {
    private int age ;
    private String name ;
    public People(int age, String name) {
        this.age = age;
        this.name = name;
    }
    @Override
    public String toString() {
        return "People{" +
                "age=" + age +
                ", name=" + name +'}';
    }
}

以上3个类特别简单,可以运行一下试试

修改 MainAtvity、Acitivity2类和People类
1. MainActivity类修改如下:
//将展示People信息按钮文字,改为"悄悄修改了代码"
showPeopleInfoButton.setText("悄悄修改了代码") ;

2. Activity2类修改如下:
将view.setText("Activit2") ;
改为:view.setText("我又来热修复啦...") ;

3. People类修改如下:
将 return "People{" +"age=" + age +", name=" + name +'}';
改为:return "People{" +"age=" + age +", name=" + name +'}'+"史上最牛逼人物!!!";

编译生成class文件,修改也非常简单,我们就将这两个类做成”001dex”补丁

用class文件生成”001dex”补丁

android在sdk/build-tools/文件件下提供了”dx”命令工具,帮助我们将class文件生成dex文件

生成方式如下:

dx –dex –output=<要生成的文件> <’class’文件路径>

例如:
dx –dex –output=001.dex …MainAtvity …Actvity2.class …People.class

将”001.dex”放入tomcat服务器并编写配置文件
  1. 将001dex放到tomcat服务器这一步,我们不用写代码,只要能通过局域网访问到这个文件就行,”Tomcat7.0\apache-tomcat-7.0.64\webapps\examples\”新建”hotfix”文件夹,在hotfix文件夹中创建“config”文件夹和“patch”文件夹,将001.dex放入”patch”文件夹
    Ps:我的tomcat是7.0,”Tomcat7.0\apache-tomcat-7.0.64\”是我的安装目录(说白了,就是解压目录,我们都知道tomcat只需解压就能用,不用安装,当然也有安装的),根据自己的实际情况来。

  2. 编写配置文件
    所谓的配置文件,就是app用来检测是否存在补丁的一个文件,可以是.txt文件、xml文件、json文件等等。这里我们用json格式的文件。
    在“Tomcat7.0\apache-tomcat-7.0.64\webapps\examples\hotfix\config”目录创建”config.json”,内容如下:

{"patchCode":"001.dex","patchUrl":"http://192.168.1.106:8080/example/hotfix/patch/001.dex"}

配置文件中有2个字段:一个是补丁代码,一个是补丁下载地址。pathUrl是我的ip地址,只需将ip换成你自己的即可

* 开启tomct服务器,测试是否成功*
* 在浏览器输入config.json的地址”http://192.168.1.106:8080/examples/hotfix/config/config.json

这里写图片描述

  • 在浏览器输入配置文件中”patchUrl”的地址”http://192.168.1.106:8080/example/hotfix/patch/001.dex“,会现在001.dex文件,表示成功

    至此,补丁服务器配置成功,下面可尽情的撸核心代码啦

    编写补丁检测和下载代码

    PS:网络操作,本例用的是Okhttp;json数据解析,用的是FastJson。还不熟悉的朋友可自行研究一下,很简单

检测补丁

/**
 * [检测服务器是否存在补丁和本地是否一下载过该补丁]
 * @type {Request}
 */
public void checkPatch(){
        //检测是否存在补丁包
        Request request = new Request.Builder()
                .get()
                .url(CHECK_URL)
                .build() ;
        Call call = mClient.newCall(request) ;
        call.enqueue(new Callback() {
            @Override
            public void onFailure(Call call, IOException e) {
                Log.d(TAG, "check patch failed....") ;
            }

            @Override
            public void onResponse(Call call, Response response) throws IOException {
                JSONObject jsonObject = JSON.parseObject(response.body().string());
                patchCode = jsonObject.getString("patchCode") ;
                Log.d(TAG, "pathCode:"+patchCode) ;

                //判断是否存在补丁
                if(!"-1".equals(patchCode)){
                    //判断当前补丁是否已经下载过
                    if(isDownLoad()){
                        Log.d(TAG, "this version pathCode is fixed...") ;
                        fixPatch();
                    }else{
                        //获取补丁链接
                        patchUrl = jsonObject.getString("patchUrl") ;
                        //开启补丁下载
                        downLoadPatch(patchUrl) ;
                    }
                }
            }
        });
    }

下载补丁

Ps:这里需要注意,补丁文件要下载到我们的安装目录,只有我们自己可以访问,如果下载到存储卡,很容易被别人替换,影响app的安全。先贴一段初始化下载目录的代码:

//这句代码,的意思是:在我们app的安装目录新建一个叫“patch”的文件夹,
//如果不存在,则创建,路径为: /data/data/app的包名/app_patch,
//在安装目录创建的文件夹,均会被加上"app_"
File fPatchPath = context.getDir("patch", Context.MODE_PRIVATE) ;
//为了保险起见,我们判断一下此路径是否存在,不存在则创建
if(fPatchPath.exists()){
  fPatchPath.mkdirs() ;
}
    /**
     * [下载补丁]
     * @type {Request}
     */
    private void downLoadPatch(String downUrl) {
        Request request = new Request.Builder()
                .get()
                .url(downUrl)
                .build() ;
        Call call = mClient.newCall(request) ;
        call.enqueue(new Callback() {
            @Override
            public void onFailure(Call call, IOException e) {
                Log.d(TAG, "the patch download failed...") ;
                Log.d(TAG, e.getMessage()) ;
            }
            @Override
            public void onResponse(Call call, Response response) throws IOException {

                //请求成功,获取补丁输入流,下面就是文件的读取和保存了
                //相信都是平时大家写烂的东西了,就不备注了
                byte[] buffer = new byte[2048] ;
                int len = 0  ;
                OutputStream os = new FileOutputStream(patchPath+File.separator+patchCode+".dex") ;
                InputStream is = response.body().byteStream() ;
                Log.d(TAG, "start download patch...") ;
                while((len=is.read(buffer,0,buffer.length))!=-1){
                    os.write(buffer,0, len);
                }
                os.close();
                is.close();
                Log.d(TAG, "download patch completion...") ;
                //保存当前补丁编码
                SharedPreferences sp = context.getSharedPreferences(HOTFIX_SP, Context.MODE_PRIVATE) ;
                sp.edit().putString(HOTFIX_CODE, hotfixConfig.getPatchCode()).commit() ;
            }
        });
    }
安装补丁包的2种方案(其实也没多大的意义,可以不看,直接看代码)

对于安装补丁包,我考虑了2种方案

  • 每次生成补丁包xxx.dex的时候,都将以前的补丁包含进去(就是将以前补丁的class文件一起打包成dex补丁包),这样可以保证,无论用户什么时候检测到补丁,都能保证修复所有bug的补丁。这样做:我们每次安装补丁,只需要安装下载的最新补丁包。

  • 每次生成的补丁包都是独立的,不包含之前的补丁,意味着:每次打补丁,都要通过循环的方式,将/data/data/app包名/app_patch/目录下的补丁重新安装一遍。当然,这样做的好处是:用户每次下载的文件大小可以减少一点。

小结:当然,如果采用第二种方式,我们搭建的简易服务器肯定是不行的,因为,如果有的用户没有下载安装上一个补丁,而直接安装了最新的,意味着上一个补丁修复的bug,他将永远带着(除非更新app)。

当然,也不能说第一种方案是完美的,对于用户来讲,dex补丁包越下载越大;对开发人员来说,每次都要保留上次修复bug的class,还要对比,这次有没有对该class做修改,也是比较麻烦的,很容易出错。

这里,虽然我们的服务器暂不完美,暂且使用第二种方式吧,可以多学到一点东西:比如:补丁打包的排序;遍历/app_patch目录安装每一个补丁等。

Ps:既然多个补丁都会安装了,那么,第一种方案的只安装一个补丁,应该是手到擒来的吧~

编写核心代码:安装dex补丁包

在回顾一下我们热修复的原理,核心就一句话:将我们的dex补丁插入到系统加载的dex数组之前,让系统查找类的收,先找到我们补丁中的类,而不再去加载后面的有bug的类。

具体实现步骤

  1. 通过反射机制拿到”PathClassLoader”中的”PathList”对象
  2. 通过反射机制拿到”PathList”对象中的”dexElements”数组
  3. 通过”DexClassLoader” 加载我们的xxx.dex补丁包
  4. 通过反射机制拿到”DexClassLoader”中的”PathList”对象
  5. 通过反射机制拿到”PathList”对象中的”dexElements”数组
  6. 将”DexClassLoader”的”dexElements”插入”PathClassLoader”的dexElements的前面

Ps:上面也说过,PathClassLoader和DexClassLoader均继承自BaseDexClassLoader,重要的方法都在BaseDexClassLoader中,包括”PathList”,所有我们重要反射BaseDexClassLoader就可以了。

安装补丁,就是通过反射机制实现,如果不熟悉反射机制,下面的代码可能会让你像坐过山车一样晕头转向。像了解反射机制的朋友可以看下我的上一篇文章 java/android中的反射机制

代码开撸


/**
 * [修复aap_patch目录下的所有补丁]
 * @type {File}
 */
private void fixPatch() {
        //获取patch文件夹下所有的补丁文件
        File[] files = new File(patchPath).listFiles() ;
        if(files.length>0){
            //补丁按下载日期排序(最新补丁放前面)
            patchSort(files);
            for (File file : files) {
                //判断file是否为补丁
                if(file.isFile() && file.getAbsolutePath().endsWith(".dex")){
                    System.out.println("---:"+file.getName());
                    //开始加载补丁并修复
                    loadPatch(file);
                }
            }
            Log.d(TAG, "fiexd success....") ;
        }
    }

上面一段代码是遍历补丁文件加下所有的补丁,并对补丁排序。为什么要排序?因为,如果上次的补丁001.dex修复了”类A”的一个bug,而这次的002.dex补丁又对”类A”做了其他修复,那么,如果不排序,遍历补丁文件夹的时候,如果把001.dex补丁打在了002.dex补丁的前面,那么系统会先找到001.dex中的”类A”,002.dex补丁将永远不会生效。

按日期排序

/**
 * [按最后修改日期排序],排序方式有很多:冒泡排序、快速排序等等,
 * 这个随便,只要排序后,保证最新的dex补丁在最前面就行
 * @type {[type]}
 */
private void patchSort(File[] files){
        Arrays.sort(files, new Comparator<File>() {
            @Override
            public int compare(File file, File t1) {
                System.out.println(file.getName()+":"+file.lastModified());
                System.out.println(t1.getName()+":"+t1.lastModified());
                long d = t1.lastModified() - file.lastModified() ;
                //从大到小排序
                if(d>0){
                    return -1 ;
                }else if(d<0){
                    return 1 ;
                }else{
                    return 0 ;
                }
            }
            @Override
            public boolean equals(Object obj) {
                return true ;
            }
        });
    }

加载并安装dex补丁

/**
 * 加载并安装补丁
 * @type {[type]}
 */
private void loadPatch(File file){
        Log.d(TAG, file.getAbsolutePath()) ;
        if(file.exists()){
            Log.d(TAG,"文件存在...") ;
        }else{
            Log.d(TAG, "文件不存在...") ;
        }
        //获取系统PathClassLoader
        PathClassLoader pLoader = (PathClassLoader) context.getClassLoader();
        //获取PathClassLoader中的PathList
        Object pPathList = getPathList(pLoader) ;
        if(pPathList == null){
            Log.d(TAG, "get PathClassLoader pathlist failed...") ;
            return ;
        }
        //加载补丁
        DexClassLoader dLoader = new DexClassLoader(file.getAbsolutePath(),optPath, null, pLoader) ;
        //获取DexClassLoader的pathLit,即BaseDexClassLoader中的pathList
        Object dPathList = getPathList(dLoader) ;
        if(dPathList == null){
            Log.d(TAG, "get DexClassLoader pathList failed...") ;
            return ;
        }
        //获取PathList和DexClassLoader的DexElements
        Object pElements = getElements(pPathList) ;
        Object dElements = getElements(dPathList) ;

        //将补丁dElements[]插入系统pElements[]的最前面
        Object newElements = insertElements(pElements, dElements) ;
        if(newElements == null){
            Log.d(TAG, "patch insert failed...") ;
            return ;
        }
        //用插入补丁后的新Elements[]替换系统Elements[]
        try {
            Field fElements = pPathList.getClass().getDeclaredField("dexElements") ;
            fElements.setAccessible(true);
            fElements.set(pPathList, newElements);
        } catch (Exception e) {
            e.printStackTrace();
            Log.d(TAG, "fixed failed....") ;
            return ;
        }
    }

    /**
     * 将补丁插入系统DexElements[]最前端,生成一个新的DexElements[]
     * @param pElements
     * @param dElements
     * @return
     */
    private Object insertElements(Object pElements, Object dElements){
        //判断是否为数组
        if(pElements.getClass().isArray() && dElements.getClass().isArray()){
            //获取数组长度
            int pLen = Array.getLength(pElements) ;
            int dLen = Array.getLength(dElements) ;
            //创建新数组
            Object newElements = Array.newInstance(pElements.getClass().getComponentType(), pLen+dLen) ;
            //循环插入
            for(int i=0; i<pLen+dLen;i++){
                if(i<dLen){
                    Array.set(newElements, i, Array.get(dElements, i));
                }else{
                    Array.set(newElements, i, Array.get(pElements, i-dLen)) ;
                }
            }
            return newElements ;
        }
        return null ;
    }

    /**
     *  获取DexElements
     * @param object
     * @return
     */
    private Object getElements(Object object){
        try {
            Class<?> c = object.getClass() ;
            Field fElements = c.getDeclaredField("dexElements") ;
            fElements.setAccessible(true);
            Object obj = fElements.get(object) ;
            return obj ;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null ;
    }

    /**
     * 通过反射机制获取PathList
     * @param loader
     * @return
     */
    private Object getPathList(BaseDexClassLoader loader){
        try {
            Class<?> c = Class.forName("dalvik.system.BaseDexClassLoader") ;
            //获取成员变量pathList
            Field fPathList = c.getDeclaredField("pathList") ;
            //抑制jvm检测访问权限
            fPathList.setAccessible(true);
            //获取成员变量pathList的值
            Object obj = fPathList.get(loader) ;
            return obj ;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null ;
    }

上面的代码有点长,但是注释也很详细。如果你熟悉了热修复原理和反射机制,我觉得上面的代码对你来说,完全就是力气活。不熟悉就多敲几遍,熟能生巧。

Ps:记住检测和下载dex补丁,可以在程序的任何位置进行,但是安装dex补丁,一定要在代码逻辑未执行之前,即:在Application中的onCreate方法中安装补丁。
到这一步,热修复基本完成。经测试,可正常修复Activity,但是修复其他类,会报错,重复加载xxx类,就是传说中”CLASS_ISPREVERIFIED问题标志”问题。

解决CLASS_ISPREVERIFIED问题

经过测试,如果不修复CLASS_ISPREVERIFIED问,也是可以修复Activity和Service等类的。

CLASS_ISPREVERIFIED是怎么产生的呢?
dilvk虚拟机通过PathClassLoader加载类的时候,如果A类中用到了类B中的方法,而且A类和B类又在同一个Dex包中,那么B将被会被虚拟机打上CLASS_ISPREVERIFIED标志,再查找到我们的B类,则会报错。

解决方案,大致有2种

  1. QQ的提出的,创建一个”AntilazyLoad”类,并打包成hack.dex,让项目中的每个类都引用hack.dex中的”AntilazyLoad”类,就不会被虚拟机打上CLASS_ISPREVERIFIED标志了。有个牛逼的名字叫:代码入侵。

  2. 利用google的dex分包方案,将app中的某个X类打包带另一个dex中,app中的每个类都引用X类。

想深入了解的可自行差资料,这里我们采用QQ的方案,使用QQ的方案,晚上有很多介绍,都是使用gradle插件,我也不是很了解,也是看的别人的,其实也没那么难。我就不误人子弟了,这里推荐2篇文章,本文解决CLASS_ISPREVERIFIED问题,就是参考的这篇文章。我仅仅会用,可能教不好,还是劳烦各位朋友移步,或者自己百度解决方案。

两篇很有技术含量的文章。

http://blog.csdn.net/lmj623565791/article/details/49883661

http://blog.csdn.net/u010386612/article/details/51131642

总结

其实本文实现的热修复仅仅只是皮毛,仅仅做到了代码修复。当下有太多成熟的热修复框架了,能实现资源、代码、so库等的热修复。我们自己实现热修复是为在使用这些框架的时候,而不至于一脸茫然,不知其所以然。对于这些框架,我个人比较细化阿里系的“Sophix”,这里有一本阿里的Sophix热修复的书,推荐给大家: 深入探索Android热修复技术原理6.29b-final.pdf
贴一张对比图:

这里写图片描述

对于热修复技术框架的选型,大家心里应该清楚自己更需要哪个体系,各有有缺,为大家推荐一篇不错的文章:Android热修复技术选型——三大流派解析,希望对大家有帮助。

Demo源码下载

Demo源码下载

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值