Android开发 如何使用差分算法实现diffpatch增量更新依赖库支持新建四大组件

在当代App应用大小不断增大的情况下增量更新代替全量更新已是趋势,可以节省许多用户流量,还是老样子先上效果图,由于上传图片限制压缩有点严重凑合看吧:

如果大家想直接使用,我这里已经为大家封装成library库了,大家直接依赖库就可以直接使用了非常简单:

allprojects {
    repositories {
        
        maven { url('https://oranges.bintray.com/DiffPatch') }
    }
}
    implementation 'com.xhiston.diffpatch:diffpatch:1.0.0'
DiffPatchUtil diffPatchUtil = new DiffPatchUtil(context);

diffPatchUtil.setOnDiffClickListener(new DiffPatchUtil.OnDiffClickListener() {
            @Override
            public void onStart() {

            }

            @Override
            public void onSuccess() {
                diffPatchUtil.showMessage("Success");
            }

            @Override
            public void onError(String msg) {
                diffPatchUtil.showMessage(msg);
            }
        });        

 diffPatchUtil.setOnPatchClickListener(new DiffPatchUtil.OnPatchClickListener() {
            @Override
            public void onStart() {

            }

            @Override
            public void onSuccess() {
                diffPatchUtil.showMessage("Success");
            }

            @Override
            public void onError(String msg) {
                diffPatchUtil.showMessage(msg);
            }
        });       


File file = new File(newApk);
//打出差分补丁包路径为patch
diffPatchUtil.diff(getApplicationInfo().sourceDir, newApk, patch);        

File file = new File(patch);       
//根据补丁包路径patch合成新的apk路径为patchApk       
diffPatchUtil.patch(getApplicationInfo().sourceDir, patchApk, patch);                        
                

当然还有不能忘记加入权限

    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

最后别忘了下载so库导入项目,如果项目中发现有问题随时欢迎告知我及时修正。

如果准备自己手写一个增量更新注意你的Android Api Level 为29以上的话请在manifest中加入以下一行代码,否则就算打开了读写权限也会无法读写本地文件,如果依赖的是我的lib就不用写了因为我已经加入了本行代码:

<application android:requestLegacyExternalStorage="true" />

如果大家想了解原理准备自己撸一个就往下看吧具体实现方式吧:

首先使用差分算法bsdiff计算出差分包,感兴趣的可以自己点击进去下载源码,然后就是使用bzip2压缩工具打包生成补丁差分包文件和合并补丁包文件;由于这里提供的都是C语言程序所以我们需要借助NDK/JNI实现增量更新了。

我们先去bsdiff地址下载bsdiff.c和bspatch.c这两个文件,然后去bzip2下载源码包解压复制粘贴出我们需要的文件:

                        bzip2/blocksort.c\
                   		bzip2/bzip2.c\
                    	bzip2/bzip2recover.c\
                    	bzip2/bzlib.c\
                    	bzip2/bzlib.h\
                    	bzip2/bzlib_private.h\
                    	bzip2/compress.c\
                    	bzip2/crctable.c\
                        bzip2/decompress.c\
                        bzip2/huffman.c\
                        bzip2/randtable.c

好了准备工作我们都做好了就开始创建JNI文件吧:

1.创建一个java文件例如:DiffPatchUtil,然后使用命令进入该文件目录下用命令编译“javac DiffPatchUtil.java”生成DiffPatchUtil.class文件,再执行“javah com.xhiston.diffpatch

.DiffPatchUtil”生成com_xhiston_diffpatch_DiffPatchUtil.h文件这一步大家需要注意一下命令目录回退一下到DiffPatchUtil的最外层包名下面不然命令提示找不包名下文件。DiffPatchUtil.java创建的时候可以简单的只放JNI回调的相关方法,生成com_xhiston_diffpatch_DiffPatchUtil.h文件后再修改添加其他方法。

package com.xhiston.diffpatch;

import android.content.Context;
import android.os.Looper;
import android.widget.Toast;

/**
 * Created by xie on 2020/10/20.
 */
public class DiffPatchUtil {

    static {
        System.loadLibrary("diffpatch");
    }
   
    /**
     * 采用差分算法将当前包与新包打patch补丁包,生成xxx.patch文件
     **/
    public native int diff(String oldApk, String newApk, String patch);

    /**
     * 采用差分算法将patch补丁包与当前包合并生成新包,生成apk文件
     **/
    public native int patch(String oldApk, String newApk, String patch);

}

com_xhiston_diffpatch_DiffPatchUtil.h文件:

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_xhiston_diffpatch_DiffPatchUtil */

#ifndef _Included_com_xhiston_diffpatch_DiffPatchUtil
#define _Included_com_xhiston_diffpatch_DiffPatchUtil
#ifdef __cplusplus
extern "C" {
#endif
    int mybspatch(JNIEnv *env, jobject clazz,int argc, char *argv[]);

    int mybsdiff(JNIEnv *env, jobject clazz, int argc,char *argv[]);

    JNIEXPORT jint JNICALL Java_com_xhiston_diffpatch_DiffPatchUtil_patch
      (JNIEnv *, jobject, jstring, jstring, jstring);

    JNIEXPORT jint JNICALL Java_com_xhiston_diffpatch_DiffPatchUtil_diff
      (JNIEnv *, jobject, jstring, jstring, jstring);

#ifdef __cplusplus
}
#endif
#endif

2.创建com_xhiston_diffpatch_DiffPatchUtil.c以及JNI配置文件Android.mk:

LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LOCAL_LDLIBS += -L$(SYSROOT)/usr/lib -llog
APP_ABI := All
APP_PLATFORM := android-16
LOCAL_C_INCLUDES :=bzip2
LOCAL_MODULE    := diffpatch
LOCAL_SRC_FILES := com_xhiston_diffpatch_DiffPatchUtil.h\
				   	com_xhiston_diffpatch_DiffPatchUtil.c\
					bspatch.c\
					bsdiff.c\
					myerr.h\
                    myerr.c\
                    	bzip2/blocksort.c\
                   		bzip2/bzip2.c\
                    	bzip2/bzip2recover.c\
                    	bzip2/bzlib.c\
                    	bzip2/bzlib.h\
                    	bzip2/bzlib_private.h\
                    	bzip2/compress.c\
                    	bzip2/crctable.c\
                        bzip2/decompress.c\
                        bzip2/huffman.c\
                        bzip2/randtable.c\

include $(BUILD_SHARED_LIBRARY)

修改一下bspatch.c、bsdiff.c里的main方法名然后com_xhiston_diffpatch_DiffPatchUtil中就可以重新调用了,可以参考我的源码进行修改,确保代码无误后便可以ndk-buildd编译生成so库了,当然编译的时候也会检查代码报错的需要自行修改,不过这个就要求大家有一定的C语言基础了,没基础的话可以现学一下不是多难。

com_xhiston_diffpatch_DiffPatchUtil.c:


#include <com_xhiston_diffpatch_DiffPatchUtil.h>

jint
Java_com_xhiston_diffpatch_DiffPatchUtil_patch (JNIEnv *env, jclass clazz, jstring old, jstring new, jstring patch) {
    int args=4;
    int resutlt = args;
    char *argv[args];
    argv[0] = "bspatch";
    argv[1] = (char*)((*env)->GetStringUTFChars(env, old, 0));
    argv[2] = (char*)((*env)->GetStringUTFChars(env, new, 0));
    argv[3] = (char*)((*env)->GetStringUTFChars(env, patch, 0));
    resutlt = mybspatch(env,clazz,args,argv);
    (*env)->ReleaseStringUTFChars(env,old, argv[1]);
    (*env)->ReleaseStringUTFChars(env,new, argv[2]);
    (*env)->ReleaseStringUTFChars(env,patch,argv[3]);
    return resutlt;
}


jint
Java_com_xhiston_diffpatch_DiffPatchUtil_diff (JNIEnv *env, jobject clazz, jstring old, jstring new, jstring patch) {
       int args=4;
       int resutlt = args;
       char *argv[args];
       argv[0] = "bsdiff";
       argv[1] = (char*)((*env)->GetStringUTFChars(env, old, 0));
       argv[2] = (char*)((*env)->GetStringUTFChars(env, new, 0));
       argv[3] = (char*)((*env)->GetStringUTFChars(env, patch, 0));
       resutlt= mybsdiff(env,clazz,args,argv);
       (*env)->ReleaseStringUTFChars(env,old, argv[1]);
       (*env)->ReleaseStringUTFChars(env,new, argv[2]);
       (*env)->ReleaseStringUTFChars(env,patch,argv[3]);
 return resutlt;
}

bspatch.c:

int mybspatch(JNIEnv *env, jobject clazz,int argc,char * argv[])
{
    onPatchStart(env,clazz);
	FILE * f, * cpf, * dpf, * epf;
	BZFILE * cpfbz2, * dpfbz2, * epfbz2;
	int cbz2err, dbz2err, ebz2err;
	int fd;
	ssize_t oldsize,newsize;
	ssize_t bzctrllen,bzdatalen;
	u_char header[32],buf[8];
	u_char *old, *new;
	off_t oldpos,newpos;
	off_t ctrl[3];
	off_t lenread;
	off_t i ;
    errno =0;
	if(argc!=4) errx(1,"usage: %s oldfile newfile patchfile\n",argv[0]);

	/* Open patch file */
	if ((f = fopen(argv[3], "r")) == NULL){
		errx(1, "fopen(%s)", argv[3]);
	    LOG_E("errno is %d,strerror is %s\n",errno,strerror(errno));
		onPatchError(env,clazz,strerror(errno));
		return -1;
    }

	/* Read header */
	if (fread(header, 1, 32, f) < 32) {
		if (feof(f))
			err(1, "Corrupt patch\n");
		errx(1, "fread(%s)", argv[3]);
		LOG_E("errno is %d,strerror is %s\n",errno,strerror(errno));
        onPatchError(env,clazz,strerror(errno));
        return -1;
	}

	/* Check for appropriate magic */
	if (memcmp(header, "BSDIFF40", 8) != 0)
		err(1, "Corrupt patch\n");

	/* Read lengths from header */
	bzctrllen=offtin(header+8);
	bzdatalen=offtin(header+16);
	newsize=offtin(header+24);
	if((bzctrllen<0) || (bzdatalen<0) || (newsize<0))
		err(1,"Corrupt patch\n");

	/* Close patch file and re-open it via libbzip2 at the right places */
	if (fclose(f))
		errx(1, "fclose(%s)", argv[3]);
	if ((cpf = fopen(argv[3], "r")) == NULL){
		errx(1, "fopen(%s)", argv[3]);
		LOG_E("errno is %d,strerror is %s\n",errno,strerror(errno));
		onPatchError(env,clazz,strerror(errno));
		return -1;
	}
	if (fseeko(cpf, 32, SEEK_SET))
		errx(1, "fseeko( %lld)",  (long long)32);
	if ((cpfbz2 = BZ2_bzReadOpen(&cbz2err, cpf, 0, 0, NULL, 0)) == NULL)
		errx(1, "BZ2_bzReadOpen, bz2err = %d", cbz2err);
	if ((dpf = fopen(argv[3], "r")) == NULL){
		errx(1, "fopen(%s)", argv[3]);
		LOG_E("errno is %d,strerror is %s\n",errno,strerror(errno));
		onPatchError(env,clazz,strerror(errno));
		return -1;
	}
	if (fseeko(dpf, 32 + bzctrllen, SEEK_SET))
		errx(1, "fseeko( %lld)",  (long long)(32 + bzctrllen));
	if ((dpfbz2 = BZ2_bzReadOpen(&dbz2err, dpf, 0, 0, NULL, 0)) == NULL)
		errx(1, "BZ2_bzReadOpen, bz2err = %d", dbz2err);
	if ((epf = fopen(argv[3], "r")) == NULL){
		errx(1, "fopen(%s)", argv[3]);
		LOG_E("errno is %d,strerror is %s\n",errno,strerror(errno));
        onPatchError(env,clazz,strerror(errno));
        return -1;
	}
	if (fseeko(epf, 32 + bzctrllen + bzdatalen, SEEK_SET))
		errx(1, "fseeko(%lld)",  (long long)(32 + bzctrllen + bzdatalen));
	if ((epfbz2 = BZ2_bzReadOpen(&ebz2err, epf, 0, 0, NULL, 0)) == NULL)
		errx(1, "BZ2_bzReadOpen, bz2err = %d", ebz2err);

	if(((fd=open(argv[1],O_RDONLY,0))<0) ||
		((oldsize=lseek(fd,0,SEEK_END))==-1) ||
		((old=malloc(oldsize+1))==NULL) ||
		(lseek(fd,0,SEEK_SET)!=0) ||
		(read(fd,old,oldsize)!=oldsize) ||
		(close(fd)==-1)) errx(1,"当前文件 %s",argv[1]);
	if((new=malloc(newsize+1))==NULL) err(1,NULL);
	oldpos=0;newpos=0;
	while(newpos<newsize) {
		/* Read control data */
		for(i=0;i<=2;i++) {
			lenread = BZ2_bzRead(&cbz2err, cpfbz2, buf, 8);
			if ((lenread < 8) || ((cbz2err != BZ_OK) &&
			    (cbz2err != BZ_STREAM_END)))
				err(1, "Corrupt patch\n");
			ctrl[i]=offtin(buf);
		};

		/* Sanity-check */
		if(newpos+ctrl[0]>newsize)
			err(1,"Corrupt patch\n");

		/* Read diff string */
		lenread = BZ2_bzRead(&dbz2err, dpfbz2, new + newpos, ctrl[0]);
		if ((lenread < ctrl[0]) ||
		    ((dbz2err != BZ_OK) && (dbz2err != BZ_STREAM_END)))
			err(1, "Corrupt patch\n");

		/* Add old data to diff string */
		for(i=0;i<ctrl[0];i++)
			if((oldpos+i>=0) && (oldpos+i<oldsize))
				new[newpos+i]+=old[oldpos+i];

		/* Adjust pointers */
		newpos+=ctrl[0];
		oldpos+=ctrl[0];

		/* Sanity-check */
		if(newpos+ctrl[1]>newsize)
			err(1,"Corrupt patch\n");

		/* Read extra string */
		lenread = BZ2_bzRead(&ebz2err, epfbz2, new + newpos, ctrl[1]);
		if ((lenread < ctrl[1]) ||
		    ((ebz2err != BZ_OK) && (ebz2err != BZ_STREAM_END)))
			err(1, "Corrupt patch\n");

		/* Adjust pointers */
		newpos+=ctrl[1];
		oldpos+=ctrl[2];
	};

	/* Clean up the bzip2 reads */
	BZ2_bzReadClose(&cbz2err, cpfbz2);
	BZ2_bzReadClose(&dbz2err, dpfbz2);
	BZ2_bzReadClose(&ebz2err, epfbz2);
	if (fclose(cpf) || fclose(dpf) || fclose(epf))
		errx(1, "fclose(%s)", argv[3]);

	/* Write the new file */
	if(((fd=open(argv[2],O_CREAT|O_TRUNC|O_WRONLY,0666))<0) ||
		(write(fd,new,newsize)!=newsize) || (close(fd)==-1))
		errx(1,"%s",argv[2]);

	free(new);
	free(old);
    LOG_E("errno is %d,strerror is %s\n",errno,strerror(errno));
    if(errno == 0){
        onPatchSuccess(env,clazz);
    }else{
        onPatchError(env,clazz,strerror(errno));
        return -1;
    }
	return 0;
}

bsdiff.c:

int mybsdiff(JNIEnv *env,jobject clazz,int argc,char *argv[])
{
    onDiffStart(env,clazz);
	int fd;
	u_char *old,*new;
	off_t oldsize,newsize;
	off_t *I,*V;
	off_t scan,pos,len;
	off_t lastscan,lastpos,lastoffset;
	off_t oldscore,scsc;
	off_t s,Sf,lenf,Sb,lenb;
	off_t overlap,Ss,lens;
	off_t i;
	off_t dblen,eblen;
	u_char *db,*eb;
	u_char buf[8];
	u_char header[32];
	FILE * pf;
	BZFILE * pfbz2;
	int bz2err;
    errno =0;
	if(((fd=open(argv[1],O_RDONLY,0))<0) ||
    		((oldsize=lseek(fd,0,SEEK_END))==-1) ||
    		((old=malloc(oldsize+1))==NULL) ||
    		(lseek(fd,0,SEEK_SET)!=0) ||
    		(read(fd,old,oldsize)!=oldsize) ||
    		(close(fd)==-1)) errx(1,"%s 非设备文件",argv[1]);
    LOG_E("设备文件 %s",argv[1]);
	if(((I=malloc((oldsize+1)*sizeof(off_t)))==NULL) ||
		((V=malloc((oldsize+1)*sizeof(off_t)))==NULL)) err(1,"设备文件长度为0");
	qsufsort(I,V,old,oldsize);
    free(V);

 	if(((fd=open(argv[2],O_RDONLY,0))<0) ||
 		((newsize=lseek(fd,0,SEEK_END))==-1) ||
 		((new=malloc(newsize+1))==NULL) ||
 		(lseek(fd,0,SEEK_SET)!=0) ||
 		(read(fd,new,newsize)!=newsize) ||
 		(close(fd)==-1)) errx(1,"%s 非存储卡文件",argv[2]);
 	if(((db=malloc(newsize+1))==NULL) ||
 		((eb=malloc(newsize+1))==NULL)) err(1,"存储卡文件长度为0");
 	LOG_E("存储卡文件 %s",argv[2]);
 	dblen=0;
 	eblen=0;
    if ((pf = fopen(argv[3], "w")) == NULL){
    	errx(1, "创建 %s 失败", argv[3]);
        LOG_E("errno is %d,strerror is %s\n",errno,strerror(errno));
    	onPatchError(env,clazz,strerror(errno));
    	return -1;
    }
    memcpy(header,"BSDIFF40",8);
	offtout(0, header + 8);
	offtout(0, header + 16);
	offtout(newsize, header + 24);
    if (fwrite(header, 32, 1, pf) != 1)
		errx(1, "fwrite(%s) 写入失败", argv[3]);
    if ((pfbz2 = BZ2_bzWriteOpen(&bz2err, pf, 9, 0, 0)) == NULL)
		errx(1, "BZ2_bzWriteOpen, bz2err = %d", bz2err);
	scan=0;len=0;
	lastscan=0;lastpos=0;lastoffset=0;
    while(scan<newsize) {
		oldscore=0;
		for(scsc=scan+=len;scan<newsize;scan++) {
			len=search(I,old,oldsize,new+scan,newsize-scan,
					0,oldsize,&pos);

			for(;scsc<scan+len;scsc++)
			if((scsc+lastoffset<oldsize) &&
				(old[scsc+lastoffset] == new[scsc]))
				oldscore++;

			if(((len==oldscore) && (len!=0)) ||
				(len>oldscore+8)) break;

			if((scan+lastoffset<oldsize) &&
				(old[scan+lastoffset] == new[scan]))
				oldscore--;
		};

		if((len!=oldscore) || (scan==newsize)) {
			s=0;Sf=0;lenf=0;
			for(i=0;(lastscan+i<scan)&&(lastpos+i<oldsize);) {
				if(old[lastpos+i]==new[lastscan+i]) s++;
				i++;
				if(s*2-i>Sf*2-lenf) { Sf=s; lenf=i; };
			};

			lenb=0;
			if(scan<newsize) {
				s=0;Sb=0;
				for(i=1;(scan>=lastscan+i)&&(pos>=i);i++) {
					if(old[pos-i]==new[scan-i]) s++;
					if(s*2-i>Sb*2-lenb) { Sb=s; lenb=i; };
				};
			};

			if(lastscan+lenf>scan-lenb) {
				overlap=(lastscan+lenf)-(scan-lenb);
				s=0;Ss=0;lens=0;
				for(i=0;i<overlap;i++) {
					if(new[lastscan+lenf-overlap+i]==
					   old[lastpos+lenf-overlap+i]) s++;
					if(new[scan-lenb+i]==
					   old[pos-lenb+i]) s--;
					if(s>Ss) { Ss=s; lens=i+1; };
				};

				lenf+=lens-overlap;
				lenb-=lens;
			};

			for(i=0;i<lenf;i++)
				db[dblen+i]=new[lastscan+i]-old[lastpos+i];
			for(i=0;i<(scan-lenb)-(lastscan+lenf);i++)
				eb[eblen+i]=new[lastscan+lenf+i];

			dblen+=lenf;
			eblen+=(scan-lenb)-(lastscan+lenf);

			offtout(lenf,buf);
			BZ2_bzWrite(&bz2err, pfbz2, buf, 8);
			if (bz2err != BZ_OK)
				errx(1, "BZ2_bzWrite, bz2err = %d", bz2err);

			offtout((scan-lenb)-(lastscan+lenf),buf);
			BZ2_bzWrite(&bz2err, pfbz2, buf, 8);
			if (bz2err != BZ_OK)
				errx(1, "BZ2_bzWrite, bz2err = %d", bz2err);

			offtout((pos-lenb)-(lastpos+lenf),buf);
			BZ2_bzWrite(&bz2err, pfbz2, buf, 8);
			if (bz2err != BZ_OK)
				errx(1, "BZ2_bzWrite, bz2err = %d", bz2err);

			lastscan=scan-lenb;
			lastpos=pos-lenb;
			lastoffset=pos-scan;
		};
	};

	BZ2_bzWriteClose(&bz2err, pfbz2, 0, NULL, NULL);
	if (bz2err != BZ_OK)
    		errx(1, "BZ2_bzWriteClose, bz2err = %d", bz2err);

    /* Compute size of compressed ctrl data */
    if ((len = ftello(pf)) == -1)
    		err(1, "ftello");
    offtout(len-32, header + 8);

    /* Write compressed diff data */
    if ((pfbz2 = BZ2_bzWriteOpen(&bz2err, pf, 9, 0, 0)) == NULL)
    		errx(1, "BZ2_bzWriteOpen, bz2err = %d", bz2err);
    BZ2_bzWrite(&bz2err, pfbz2, db, dblen);
    if (bz2err != BZ_OK)
    		errx(1, "BZ2_bzWrite, bz2err = %d", bz2err);
    	BZ2_bzWriteClose(&bz2err, pfbz2, 0, NULL, NULL);
    if (bz2err != BZ_OK)
    		errx(1, "BZ2_bzWriteClose, bz2err = %d", bz2err);

    /* Compute size of compressed diff data */
   if ((newsize = ftello(pf)) == -1)
    		err(1, "ftello");
   offtout(newsize - len, header + 16);

   /* Write compressed extra data */
   if ((pfbz2 = BZ2_bzWriteOpen(&bz2err, pf, 9, 0, 0)) == NULL)
    		errx(1, "BZ2_bzWriteOpen, bz2err = %d", bz2err);
   BZ2_bzWrite(&bz2err, pfbz2, eb, eblen);
    if (bz2err != BZ_OK)
    		errx(1, "BZ2_bzWrite, bz2err = %d", bz2err);
    BZ2_bzWriteClose(&bz2err, pfbz2, 0, NULL, NULL);
    if (bz2err != BZ_OK)
    		errx(1, "BZ2_bzWriteClose, bz2err = %d", bz2err);

    /* Seek to the beginning, write the header, and close the file */
    if (fseeko(pf, 0, SEEK_SET))
    		err(1, "fseeko");
    if (fwrite(header, 32, 1, pf) != 1)
    		errx(1, "fwrite(%s)", argv[3]);
    if (fclose(pf))
    		err(1, "fclose");

    /* Free the memory we used */
    free(db);
    free(eb);
    free(I);
    free(old);
    free(new);
    LOG_E("errno is %d,strerror is %s\n",errno,strerror(errno));
    if(errno == 0){
        onPatchSuccess(env,clazz);
    }else{
        onPatchError(env,clazz,strerror(errno));
        return -1;
    }
	return 0;
}

如果我的文章对你有帮助,"一键三连"给个鼓励,让我更新文章更有动力,“关注”我会有更多干货不定时的更新哦!

源码

欢迎关注微信公众号!你的每个赞和在看,都是对我的支持!👍在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值