基于Proxy思想的Android插件框架

意义

研究插件框架的意义在于以下几点:

  • 减小安装包的体积,通过网络选择性地进行插件下发
  • 模块化升级,减小网络流量
  • 静默升级,用户无感知情况下进行升级
  • 解决低版本机型方法数超限导致无法安装的问题
  • 代码解耦

现状

Android中关于插件框架的技术已经有过不少讨论和实现,插件通常打包成apk或者dex的形式。

dex形式的插件往往提供了一些功能性的接口,这种方式类似于java中的jar形式,只是由于Android的Dalvik VM无法直接动态加载Java的Byte Code,所以需要我们提供Dalvik Byte Code,而dex就是Dalvik Byte Code形式的jar。

apk形式的插件提供了比dex形式更多的功能,例如可以将资源打包进apk,也可实现插件内的Activity或者Service等系统组件。

本文主要讨论apk形式的插件框架,对于apk形式又存在安装和不安装两种方式

  • 安装apk的方式实现相对简单,主要原理是通过将插件apk和主程序共享一个UserId,主程序通过createPackageContext构造插件的context,通过context即可访问插件apk中的资源,很多app的主题框架就是通过安装插件apk的形式实现,例如Go主题。这种方式的缺点就是需要用户手动安装,体验并不是很好。

  • 不安装apk的方式解决了用户手动安装的缺点,但实现起来比较复杂,主要通过DexClassloader的方式实现,同时要解决如何启动插件中Activity等Android系统组件,为了保证插件框架的灵活性,这些系统组件不太好在主程序中提前声明,实现插件框架真正的难点在此。

DexClassloader

这里引用《深入理解Java虚拟机:JVM高级特性与最佳实践》第二版里对java类加载器的一段描述:

虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。

Android虚拟机的实现参考了java的JVM,因此在Android中加载类也用到了类加载器的概念,只是相对于JVM中加载器加载class文件而言,Android的Dalvik虚拟机加载的是Dex格式,而具体完成Dex加载的主要是PathClassloaderDexclassloader

PathClassloader默认会读取/data/dalvik-cache中缓存的dex文件,未安装的apk如果用PathClassloader来加载,那么在/data/dalvik-cache目录下找不到对应的dex,因此会抛出ClassNotFoundException

DexClassloader可以加载任意路径下包含dex和apk文件,通过指定odex生成的路径,可加载未安装的apk文件。下面一段代码展示了DexClassloader的使用方法:

final File optimizedDexOutputPath = context.getDir("odex", Context.MODE_PRIVATE);
try{
    DexClassLoader classloader = new DexClassLoader("apkPath",
            optimizedDexOutputPath.getAbsolutePath(),
            null, context.getClassLoader());
    Class<?> clazz = classloader.loadClass("com.plugindemo.test");
    Object obj = clazz.newInstance();
    Class[] param = new Class[2];
    param[0] = Integer.TYPE;
    param[1] = Integer.TYPE;
    Method method = clazz.getMethod("add", param);
    method.invoke(obj, 1, 2);
}catch(InvocationTargetException e){
    e.printStackTrace();
}catch(NoSuchMethodException e){
    e.printStackTrace();
}catch(IllegalAccessException e){
    e.printStackTrace();
}catch(ClassNotFoundException e){
    e.printStackTrace();
}catch (InstantiationException e){
    e.printStackTrace();
}

DexClassloader解决了类的加载问题,如果插件apk里只是一些简单的API调用,那么上面的代码已经能满足需求,不过这里讨论的插件框架还需要解决资源访问和Android系统组件的调用。

插件内系统组件的调用

Android Framework中包含ActivityServiceContent Provider以及BroadcastReceiver等四大系统组件,这里主要讨论如何在主程序中启动插件中的Activity,其它3种组件的调用方式类似。

大家都知道Activity需要在AndroidManifest.xml中进行声明,apk在安装的时候PackageManagerService会解析apk中的AndroidManifest.xml文件,这时候就决定了程序包含的哪些Activity,启动未声明的Activity会报ActivityNotFound异常,相信大部分Android开发者曾经都遇到过这个异常。

启动插件里的Activity必然会面对如何在主程序中的AndroidManifest.xml中声明这个Activity,然而为了保证插件框架的灵活性,我们是无法预知插件中有哪些Activity,所以也无法提前声明。

为了解决上述问题,这里介绍一种基于Proxy思想的解决方法,大致原理是在主程序的AndroidManifest.xml中声明一些ProxyActivity,启动插件中的Activity会转为启动主程序中的一个ProxyActivityProxyActivity中所有系统回调都会调用插件Activity中对应的实现,最后的效果就是启动的这个Activity实际上是主程序中已经声明的一个Activity,但是相关代码执行的却是插件Activity中的代码。这就解决了插件Activity未声明情况下无法启动的问题,从上层来看启动的就是插件中的Activity。下面具体分析整个过程。

PluginSDK

所有的插件和主程序需要依赖PluginSDK进行开发,所有插件中的Activity继承自PluginSDK中的PluginBaseActivityPluginBaseActivity继承自Activity并实现了IActivity接口。

public interface IActivity {
    public void IOnCreate(Bundle savedInstanceState);

    public void IOnResume();

    public void IOnStart();

    public void IOnPause();

    public void IOnStop();

    public void IOnDestroy();

    public void IOnRestart();

    public void IInit(String path, Activity context, ClassLoader classLoader, PackageInfo packageInfo);
}
public class PluginBaseActivity extends Activity implements IActivity {
    ...
    private Activity mProxyActivity;
    ...
    
    @Override
    public void IInit(String path, Activity context, ClassLoader classLoader) {
        mProxy = true;
        mProxyActivity = context;

        mPluginContext = new PluginContext(context, 0, path, classLoader);
        attachBaseContext(mPluginContext);
    }
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        if (mProxy) {
            mRealActivity = mProxyActivity;
        } else {
            super.onCreate(savedInstanceState);
            mRealActivity = this;
        }
    }

    @Override
    public void setContentView(int layoutResID) {
        if (mProxy) {
            mContentView = LayoutInflater.from(mPluginContext).inflate(layoutResID, null);
            mRealActivity.setContentView(mContentView);
        } else {
            super.setContentView(layoutResID);
        }
    }

    ...

    @Override
    public void IOnCreate(Bundle savedInstanceState) {
        onCreate(savedInstanceState);
    }

    @Override
    public void IOnResume() {
        onResume();
    }

    @Override
    public void IOnStart() {
        onStart();
    }

    @Override
    public void IOnPause() {
        onPause();
    }

    @Override
    public void IOnStop() {
        onStop();
    }

    @Override
    public void IOnDestroy() {
        onDestroy();
    }

    @Override
    public void IOnRestart() {
        onRestart();
    }
}
public class ProxyActivity extends Activity {
    IActivity mPluginActivity;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Bundle bundle = getIntent().getExtras();
        if(bundle == null){
            return;
        }
        mPluginName = bundle.getString(PluginConstants.PLUGIN_NAME);
        mLaunchActivity = bundle.getString(PluginConstants.LAUNCH_ACTIVITY);
        File pluginFile = PluginUtils.getInstallPath(ProxyActivity.this, mPluginName);
        if(!pluginFile.exists()){
            return;
        }
        mPluginApkFilePath = pluginFile.getAbsolutePath();
        try {
            initPlugin();
            super.onCreate(savedInstanceState);
            mPluginActivity.IOnCreate(savedInstanceState);
        } catch (Exception e) {
            mPluginActivity = null;
            e.printStackTrace();
        }
    }
    
    @Override
    protected void onResume() {
        super.onResume();
        if(mPluginActivity != null){
            mPluginActivity.IOnResume();
        }
    }

    @Override
    protected void onStart() {
        super.onStart();
        if(mPluginActivity != null) {
            mPluginActivity.IOnStart();
        }
    }
    
    ...
    
    private void initPlugin() throws Exception {
        PackageInfo packageInfo = PluginUtils.getPackgeInfo(this, mPluginApkFilePath);

        if (mLaunchActivity == null || mLaunchActivity.length() == 0) {
            mLaunchActivity = packageInfo.activities[0].name;
        }

        ClassLoader classLoader = PluginUtils.getClassLoader(this, mPluginName, mPluginApkFilePath);

        if (mLaunchActivity == null || mLaunchActivity.length() == 0) {
            if (packageInfo == null || (packageInfo.activities == null) || (packageInfo.activities.length == 0)) {
                throw new ClassNotFoundException("Launch Activity not found");
            }
            mLaunchActivity = packageInfo.activities[0].name;
        }
        Class<?> mClassLaunchActivity = classLoader.loadClass(mLaunchActivity);

        getIntent().setExtrasClassLoader(classLoader);
        mPluginActivity = (IActivity) mClassLaunchActivity.newInstance();
        mPluginActivity.IInit(mPluginApkFilePath, this, classLoader);
    }
    
    ...
    
    @Override
    public void startActivityForResult(Intent intent, int requestCode) {
        boolean pluginActivity = intent.getBooleanExtra(PluginConstants.IS_IN_PLUGIN, false);
        if (pluginActivity) {
            String launchActivity = null;
            ComponentName componentName = intent.getComponent();
            if(null != componentName) {
                launchActivity = componentName.getClassName();
            }
            intent.putExtra(PluginConstants.IS_IN_PLUGIN, false);
            if (launchActivity != null && launchActivity.length() > 0) {
                Intent pluginIntent = new Intent(this, getProxyActivity(launchActivity));

                pluginIntent.putExtra(PluginConstants.PLUGIN_NAME, mPluginName);
                pluginIntent.putExtra(PluginConstants.PLUGIN_PATH, mPluginApkFilePath);
                pluginIntent.putExtra(PluginConstants.LAUNCH_ACTIVITY, launchActivity);
                startActivityForResult(pluginIntent, requestCode);
            }
        } else {
            super.startActivityForResult(intent, requestCode);
        }
    }

PluginBaseActivityProxyActivity在整个插件框架的核心,下面简单分析一下代码:

首先看一下ProxyActivity#onResume

@Override
protected void onResume() {
    super.onResume();
    if(mPluginActivity != null){
        mPluginActivity.IOnResume();
    }
}

变量mPluginActivity的类型是IActivity,由于插件Activity实现了IActivity接口,因此可以猜测mPluginActivity.IOnResume()最终执行的是插件Activity的onResume中的代码,下面我们来证实这种猜测。

PluginBaseActivity实现了IActivity接口,那么这些接口具体是怎么实现的呢?看代码:

@Override
public void IOnCreate(Bundle savedInstanceState) {
    onCreate(savedInstanceState);
}

@Override
public void IOnResume() {
    onResume();
}

@Override
public void IOnStart() {
    onStart();
}

@Override
public void IOnPause() {
    onPause();
}

...

接口实现非常简单,只是调用了和接口对应的回调函数,那这里的回调函数最终会调到哪里呢?前面提到过所有插件Activity都会继承自PluginBaseActivity,也就是说这里的回调函数最终会调到插件Activity中对应的回调,比如IOnResume执行的是插件Activity中的onResume中的代码,这也证实了之前的猜测。

上面的一些代码片段揭示了插件框架的核心逻辑,其它的代码更多的是为实现这种逻辑服务的,后面会提供整个工程的源码,大家可自行分析理解。

插件内资源获取

实现加载插件apk中的资源的一种思路是将插件apk的路径加入主程序资源查找的路径中,下面的代码展示了这种方法:

private AssetManager getSelfAssets(String apkPath) {
    AssetManager instance = null;
    try {
        instance = AssetManager.class.newInstance();
        Method addAssetPathMethod = AssetManager.class.getDeclaredMethod("addAssetPath", String.class);
        addAssetPathMethod.invoke(instance, apkPath);
    } catch (Throwable e) {
        e.printStackTrace();
    }
    return instance;
}

为了让插件Activity访问资源时使用我们自定义的Context,我们需要在PluginBaseActivity的初始化中做一些处理:

public void IInit(String path, Activity context, ClassLoader classLoader, PackageInfo packageInfo) {
    mProxy = true;
    mProxyActivity = context;

    mContext = new PluginContext(context, 0, mApkFilePath, mDexClassLoader);
    attachBaseContext(mContext);
}

PluginContext中通过重载getAssets来实现包含插件apk查找路径的Context:

public PluginContext(Context base, int themeres, String apkPath, ClassLoader classLoader) {
    super(base, themeres);
    mClassLoader = classLoader;
    mAsset = getPluginAssets(pluginFilePath);
    mResources = getPluginResources(base, mAsset);
    mTheme = getPluginTheme(mResources);
}

private AssetManager getPluginAssets(String apkPath) {
    AssetManager instance = null;
    try {
        instance = AssetManager.class.newInstance();
        Method addAssetPathMethod = AssetManager.class.getDeclaredMethod("addAssetPath", String.class);
        addAssetPathMethod.invoke(instance, apkPath);
    } catch (Throwable e) {
        e.printStackTrace();
    }
    return instance;
}

private Resources getPluginAssets(Context ctx, AssetManager selfAsset)  {
    DisplayMetrics metrics = ctx.getResources().getDisplayMetrics();
    Configuration con = ctx.getResources().getConfiguration();
    return new Resources(selfAsset, metrics, con);
}

private Theme getPluginTheme(Resources selfResources) {
    Theme theme = selfResources.newTheme();
    mThemeResId = getInnerRIdValue("com.android.internal.R.style.Theme");
    theme.applyStyle(mThemeResId, true);
    return theme;
}

@Override
public Resources getResources() {
    return mResources;
}

@Override
public AssetManager getAssets() {
    return mAsset;
}

...

总结

本文介绍了一种基于Proxy思想的插件框架,所有的代码都在Github中,代码只是抽取了整个框架的核心部分,如果要用在生产环境中还需要完善,比如Content ProviderBroadcastReceiver组件的Proxy类未实现,Activity的Proxy实现也是不完整的,包括不少回调都没有处理。同时我也无法保证这套框架没有致命缺陷,本文主要是以总结、学习和交流为目的,欢迎大家一起交流。

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
Android-Plugin-Framework 此项目是Android框架完整源码以及实例。用来开发Android件APK,并通过动态加载的方式在宿主程序中运行。 若件APK是完全独立的APK,那么件apk也可独立安装运行。 若件APK不是完全独立的apk,比如和件宿主程序共用一些依赖库,那么件apk只能在宿主程序中运行。不可独立运行。 因为此时件apk的代码是不完整的。 目录结构说明: PluginCore工程是件库核心工程。用于提供对件功能的支持。 PluginMain是用来测试的件宿主程序Demo工程。 PluginShareLib是用来测试的件宿主程序的依赖库Demo工程 PluginTest是用来测试的件Demo工程。此工程下有用来编译件的ant脚本。 宿主程序工程可以通过ant编译或者导入eclipse后直接点击Run菜单进行安装。 件Demo工程需要通过件ant脚本编译。编译命令为 “ant clean debug” 原因是Demo中引用了宿主程序的依赖库。需要在编译时对共享库进行排除。 件编译出来以后,可以将件复制到sdcard,然后在宿主程序中调用PluginLoader.installPlugin("件apk绝对路径")进行安装 还有一种简易的安装方式,是使用编译命令为 “ant clean debug install” 直接将件apk安装到系统中,PluginMain工程会监听系统的应用安装广播,监听到件apk安装广播后, 再自动调用PluginLoader.installPlugin("/data/app/件apk文件.apk")进行件安装。免去复制到sdcard的过程。 (虽然没有用过apkplug、以及另外一个框架作者singwhatiwanna写的DL框架,但是看过他们的一些介绍文档,感觉自己的这份实现应该是更简单易用更完善。。。哈哈,是不是有王婆卖瓜的嫌疑。) 已支持的功能: 1、件apk无需安装,由宿主程序动态加载运行。 2、件形式支持fragment和activity代理。 这两种形式是件开发中的两种主要形式。 3、件支持activity非代理模式,真正实现Activity无需注册,既不用反射,也不用代理,实现Activity完整生命周期。 4、件库apk可无任何特殊代码。如件中的fragment,activity等无需继承任何特定基类或接口。完全和普通app代码没有区别. 5、支持件共用宿主程序依赖库提供的自定义控件 6、支持件中使用自定义控件 7、支持件资源和宿主资源混合使用。意即支持如下场景: 件程序和宿主程序共用依赖库时件中的布局文件中使用了宿主程序中的自定义控件,而宿主程序中的自定义控件中又使用 了宿主程序中的布局文件。 件代码中无需做任何特殊处理,即可支持这种布局文件。 8、件中的各种资源、布局、R、以及宿主程序中的各种资源、布局、R等可随意使用、也无任何特殊代码。 10、支持件使用宿主程序主题和系统主题 11、解决资源id冲突问题。尝试做过件开发的同学应该都遇到,件资源id和宿主程序资源id可能相同,导致获取的资源不是想要的资源。 此问题其实在android提供的编译器aapt中早已提供了支持。 12、需要关注PluginTest工程的ant.properties文件和project.properties文件以及custom_rules.xml文件,件使用宿主程序共享库,以及共享库R引用,和编译时排除的功能,都在这3个配置文件中体现 暂不支持的功能: 1、暂不支持使用件中自定义主题, 2、支持在件中通过R文件使用宿主程序中的资源,暂不支持件资源文件中直接使用宿主程序中的资源。但是支持间接使用。 例如在上述“已支持的功能”6中描述的,实际就是间接使用。 后续需要解决的问题: 1、使支持件自定义主题 2、使件中对activity的支持更稳定。 由于此框架没有实际的项目应用,目前对activity的提供标准API的测试还不够完全,可能在其他开发场景中,activity的部分标准API可能会出现问题。毕竟这里使用了很多反射,会涉及到多机型多系统版本的兼容问题。后续还需要持续测试和完善 上述第2点问题已解决、请看已支持的功能第3条。 3、使支持件资源文件中直接引用依赖库中的资源。目测可能需要重写android自带的aapt程序。 实现原理简介: 1、件apk的class 通过构造件apk的Dexclassloader来加载件apk中的类。DexClassLoader的parent设置为宿主程序的classloader,即可将主程序和件程序的class贯通 2、件apk的资源 通过构造件apk的AssetManager和Resouce类即可。 本项目最关键一点功能、也是和网上其他件项目不同的地方之一,在于 通过addAssetsPath方法添加资源的时候,同时添加了件程序的资源文件和宿主程序的资源。这样就 可以做到件资源合并。很多资源文件都迎刃而解。 3、件apk中的资源id 完成上述第二点以后,还有需要解决的难题,是宿主程序资源id和件程序id重复的问题。 这个问题解决办法也很简单 我们知道,资源id是在编译时生成的,其生成的规则是0xPPTTNNNN PP段,是用来标记apk的,默认情况下系统资源PP是01,应用程序的PP是07 TT段,是用来标记资源类型的,比如图标、布局等,相同的类型TT值相同,但是同一个TT值不代表同一种资源,例如这次编译的时候可能使用03作为layout的TT,那下次编译的时候可能会使用06作为TT的值,具体使用那个值,实际上和当前APP使用的资源类型的个数是相关联的。 NNNN则是某种资源类型的资源id,默认从1开始,依次累加。 那么我们要解决资源id问题,就可从TT的值开始入手,只要将每次编译时的TT值固定,即可是资源id达到分组的效果,从而避免重复。 例如将宿主程序的layout资源的TT固定为03,将件程序资源的layout的TT值固定为23,即可解决资源id重复的问题了。 固定资源idTT值的版本也非常简单,提供一份public.xml,在public.xml中指定什么资源类型以什么TT值开头即可。 具体public.xml如何编写,可参考PluginMain/res/values/public.xml 以及 PluginTest/res/values/public.xml俩个文件,它们是分别用来固定宿主程序和件程序资源id的范围的。 4、件apk的Context 构造一个Context即可,具体的Context实现请参考PluginCore/src/com/plugin/core/PluginContextTheme.java 关键是要重写几个获取资源、主题的方法,以及重写getClassLoader方法 5、件中的LayoutInfalter 通过第4步构造出来的Context获取LayoutInfater即可 6、如何实现件代码不依赖任何特殊代码,如继承特定的基类、接口等。 要做到这一点,最主要的是实现上述第4步,构造出件的Context后,所有问题都可以得到解决。 7、件中Activity无需注册,拥有完整生命周期是如何实现的。 众所周知、Activity是系统组件,必须在manifest中注册才能被系统唤起并拥有完整生命周期,通过Activity代理和放射的方式实现的 实际是伪生命周期。并非完整生命周期。那么如果才能实现activity免注册,而且拥有完整的生命周期呢,这要从activity的启动流程中 入手。 App安装时,系统会扫描app的Manifest并缓存到一个xml中,activity启动时,系统会现在查找缓存的xml,如果查到了,再通过classLoad去load这个class,并构造一个activity实例。那么我们只需要将classload加载这个class的时候做一个简单的映射,让系统以为加载的是A class,而实际上加载的是B class,达到挂羊头买狗肉的效果,即可将预注册的Aclass替换为未注册的activity,从而实现件中的Activity 完全被系统接管,而拥有完整生命周期。 在PluginMain和PluginTest已经添加了这种实现方式的测试实例。 8、通过activity代理方式实现加载件中的activity是如何实现的 要实现这一点,同样是基于上述第4点,构造出件的Context后,通过attachBaseContext的方式,替换代理Activiyt的context即可。 另外还需要在获得件Activity对象后,通过反射给Activity的attach()方法中attach的成员变量赋值。 这样可解决另外一个框架作者singwhatiwanna实现的代码中所谓this和that的问题。也是可以使件Activity不需要继承任何特定基类或者接口的原因。 9、activity代理实现后,其他组件,如service等,如法炮制即可。 10、件编译问题。 如果件和宿主共享依赖库,那边编译件的时候不可将共享库编译到件当中,包括共享库的代码以及R文件,但是需要在编译时添加到classpath中,且件中如果要使用共享依赖库中的资源,需要使用共享库的R文件来进行引用。这几点在PluginTest示例工程中有体现。 11、件开发模式 件UI可通过fragment或者activity来实现 如果是activity实现的件,则最终会在PluginProxyActivity中运行 如果是fragment实现的件,又分为两种 1种是fragment运行在任意支持fragment的activity中,这种方式,在开发fragment的时候,fragmeng中凡是要使用context的地方,都需要使用通过PluginLoader.getPluginContext()获取的context,那么这种fragment对其运行容器没有特殊要求 还有1种是,fragment运行在PluginCore提供的PluginSpecDisplayer中,这种方式,由于其运行容器PluginSpecDisplayer的Context已经被PluginLoader.getPluginContext获取的context替换,因此这种fragment的代码和普通非件开发时开发的fragment的代码没有任何区别。 需要注意的问题 项目件开发后,特别需要注意的是宿主程序混淆问题。宿主程序混淆后,可能会导致件程序运行时出现classnotfound 标签:Android
CJFrameForAndroid 是一个实现android件化开发的框架。使用CJFrameForAndroid,apk动态加载不再是难题,更重要的是可以轻松实现件与APP项目之间的解耦。 原理描述 CJFrameForAndroid的实现原理是通过类加载器,动态加载存在于SD卡上的apk包中的Activity。通过使用一个托管所,件Activity全部事务(包括声明周期与交互事件)将交由托管所来处理,间接实现件的运行。一句话描述:CJFrameForAndroid中的托管所,复制了件中的Activity,来替代件中的Activity与用户交互。 框架使用 ●使用 CJFrameForAndroid 件开发框架需要在你项目的AndroidManifest.xml文件中加入托管所的声明。<activity android:name="org.kymjs.aframe.plugin.CJProxy" /> ●让件应用中的Activity继承CJActivity,并且一切使用this调用的方法都使用that替代。例如this.setContentView();需要改为that.setContentView();●件中涉及到的Android权限,须在APP项目清单中具有声明。●件Activity跳转时,推荐使用CJActivityUtils类来辅助跳转。若一定要startActivity或 startActivityForResult,在跳转过程中的Intent不能自己new,必须使用 CJActivityUtils.getPluginIntent();●在件和APP两个工程中不能引用相同的jar包。解决办法是:在件工程的项目中添加一个/cjlibs的文件夹,将需要调用的jar包放到这个文件夹中,并在件项目目录下的.classpath中加入如下语句,系统会自动处理相关细节<classpathentry kind="lib" path="cjlibs"/>

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值