基于Proxy思想的Android插件框架

Android开发的艺术:插件化框架Virtual APK实现原理解析 如果我们启动的是插件APK里的Activity,这个方法的Catch语句块是一定会被执行的,因为入参className已经被替换为插桩Activity的,但是我们只是在宿主App的AndroidManifest.xml中定义了这些Actiivty,并没有真正的实现。如果启动的Activity是插件APK中的,则根据该Actiivty的启动模式选择合适的插桩Activity, AMS在启动阶段对插桩Activity处理后,在创建Activity实例阶段,实际创建插件APK中要启动的Activity。 阅读详情
22 July 2014

本文所有代码托管在Github:android-plugin

意义

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

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

现状

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加载的主要是PathClassloader和Dexclassloader。

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中包含Activity,Service,Content 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会转为启动主程序中的一个ProxyActivity,ProxyActivity中所有系统回调都会调用插件Activity中对应的实现,最后的效果就是启动的这个Activity实际上是主程序中已经声明的一个Activity,但是相关代码执行的却是插件Activity中的代码。这就解决了插件Activity未声明情况下无法启动的问题,从上层来看启动的就是插件中的Activity。下面具体分析整个过程。

PluginSDK

所有的插件和主程序需要依赖PluginSDK进行开发,所有插件中的Activity继承自PluginSDK中的PluginBaseActivity,PluginBaseActivity继承自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);
        }
    }

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

首先看一下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 Provider和BroadcastReceiver组件的Proxy类未实现,Activity的Proxy实现也是不完整的,包括不少回调都没有处理。同时我也无法保证这套框架没有致命缺陷,本文主要是以总结、学习和交流为目的,欢迎大家一起交流。

ProxyPin-免费开源Http、Https抓包工具 跨平台 一款跨平台免费开源Http、Https抓包工具,支持Windows、Mac、Android平台系统,工具使用flutter框架开发,支持手机扫码连接,不用手动配置Wifi代理,包括配置同步。所有终端都可以互相扫码连接转发流量,i感兴趣的可以下载体验。 立即下载

相关推荐

goproxy-android snail007/goproxy全能代理服务器安卓版

goproxy-android snail007/goproxy全能代理服务器安卓版。项目使用androidstudio开发,基于snail007/goproxy官方安卓SDK开发

Android N数据业务总结

本文旨在总结Android N中数据业务在框架侧的流程, 主要包括APN加载、数据卡选择、长连接拨号、短连接拨号等。

橡果生长记的博客 1万+

基于Android的goproxy全能代理服务器设计源码

本项目是一个基于Android平台的goproxy全能代理服务器,包含46个文件,主要文件类型包括图片、XML配置文件、Gradle构建文件、属性文件、项目变更日志、LICENSE文件、Markdown文档、项目配置文件、Java源代码和JAR包。系统设计旨在为用户提供一个稳定、高效的代理服务,支持多协议代理和各种代理功能,以满足用户在网络访问控制和数据传输安全方面的需求。

gomobile打包android第三方包踩坑集

1.需要设置GOROOT、GOPATH、ANDROID_NDK_HOME、ANDROID_HOME的路径,path添加java\bin执行路径 linux: export ANDROID_NDK_HOME=/Users/chang/Library/Android/sdk/ndk/19.2.5345600 export ANDROID_HOME=/Users/chang/Library/Android/sdk 2. 设置代理:go env -w GOPROXY=https://goproxy.cn

jiangxieming的专栏 2439

TSMC40nmLP版图实战:从Design Rule解读到Cadence高效布局布线

本文详细解析了TSMC40nmLP工艺的版图设计实战,从Design Rule解读到Cadence高效布局布线技巧。重点介绍了40nm工艺中的关键设计规则,如最小线宽、间距规则和深N阱规则,并分享了Cadence Virtuoso中的高效工作流和射频模块设计技巧,帮助工程师提升版图设计效率和质量。

weixin_29250403的博客 378

写出GD32L233/235在读写flash的区别(用于OTA功能)

在单片机里面表示 大概是 address += sizeof(uint32_t) address += sizeof(uint64_t)1.说到OTA功能,实际上 往flash 指定区域写 数据, 拷贝数据,然后 跳转, 必要时 注意单片机的偏移量。在写本文时,L233支持单字写入,而L235只有双字写入. 一个写入地址+4 一个写入地址+8。这个是 自己写的擦除一页 数据,当然可以参考 提供的demo去实现,看自己需求。接下来是 读取 双字,也就是在L235上的读取。

teleger的博客 908

使用Burp Suite做http proxy抓取android请求

本文的原文连接是: http://blog.youkuaiyun.com/freewebsys/article/details/71261179 未经博主允许不得转载。 博主地址是:http://blog.youkuaiyun.com/freewebsys1,使用Burp suite代理抓数据Burp suite 是一个免费的http代理工具,和fiddler , charles比较起来的话。 就是有free 版本和

freewebsys的专栏 1907

别再花屏了!手把手教你用STM32CubeIDE搞定LVGL 8.3移植(附完整源码)

本文详细介绍了如何在STM32CubeIDE环境下成功移植LVGL 8.3,解决常见的花屏问题。从环境配置、显示驱动优化到编译警告处理,提供完整的实战方案和源码示例,帮助开发者快速实现流畅的嵌入式GUI界面。特别针对STM32平台的DMA2D加速和内存管理进行了深度优化。

weixin_33739646的博客 243

Android源码之DeskClock (三) Proxy/Delegate Application 框架应用

1.概述 当项目有加壳子的需求的时候,可以使用Proxy/Delegate Application框架的方式,在正常的模式中,一个程序一般只有一个Application入口,而Proxy/Delegate模式中需要有两个Application,原程序的Application改为Delegate Application,再新加一个Proxy Application

Jesse的专栏 1万+

从调节阀到步进电机:图解位置型PID与增量式PID的核心区别

本文深入解析了位置型PID与增量式PID的核心区别。通过调节阀和步进电机的实例,阐明位置式PID输出绝对位置,适用于直接控制执行机构;而增量式PID输出位置变化量,天然支持无扰动切换,更适合步进电机等场景。文章重点剖析了增量式PID在系统安全与平滑切换方面的关键优势。

weixin_29051811的博客 357

STM32CubeMX、keil、simulink联合开发MCU教程

一、整体思路 利用Matlab的simulink搭建模型生成C代码,通过stm32cubemx生成工程,最后在KEIL或者IAR等工具里面编译生成代码下载至MCU中执行。 本文以流水灯建立模型和简单使用定时器为例进行演示。 二、开发环境 1、安装Matlab Matlab版本建议在2013B以上,本文使用Matlab 2016a。 2、安装STM32硬件支持包 下载地址(https://www.stmcu.org.cn/document/detail/index/id-216053) 安装完成后重启MAT

tzlaa的博客 7736

【Android 插件化】“ 插桩式 “ 插件化框架 ( 注入上下文的使用 )

一、BaseActivity 注入上下文的使用、 二、BaseActivity 完整代码、 三、博客资源、

让 学习 成为一种 习惯 ( 韩曙亮 の 技术博客 ) 1428

跟着团子学SAP CO:揭秘结果分析码在项目结算中的实战应用(一)KKA2/CJ88

本文深入解析了SAP CO模块中结果分析码在项目结算中的核心作用。以KKA2(方法10)和事务代码CJ88为例,详细阐述了如何在项目完工后一次性确认收入的场景下,通过结果分析功能实现收入与成本的精准匹配,将项目成本暂估为在制品,确保各期利润的准确反映。

gamma的博客 685

自己动手写Android插件化框架

最近在工作中接触到了Android插件内的开发,发现自己这种技术还缺乏最基本的了解,以至于在一些基本问题上浪费不少时间,如插件Context和主工程Context的区别,权限必须在主工程申明等,因此花了点时间了解了一下插件的历史,并写了两个Demo作为总结。本文旨在通过两个实例直观的说明插件的实现原理以加深对插件内开发的理解,因此不会深入探讨背景和原理,代码也尽量专注于核心逻辑。 原理与背景 A...

戎码半生 579

AE 与 ISP 参数联动机制解析:自动曝光系统与图像信号处理模块协同设计实践

AE(自动曝光)并不是一个独立的图像控制模块,而是与 ISP(Image Signal Processor)深度耦合运作的系统协同体。其输出的曝光参数直接决定了图像处理链路中 Gamma 曲线选择、黑电平补偿、去噪级别、边缘增强等一系列关键参数的取值。本文基于主流平台(如高通、MTK、海思)的工程实战经验,详细剖析 AE 与 ISP 在曝光驱动、调节优先级、图像还原逻辑等环节的联动机制,并结合拍照与视频等不同场景,给出可落地的联调方法。

努力分享一些人工智能、计算机视觉、影像等相关的知识干货! 1528

从服务器到桌面超算:当3999美元的NVIDIA DGX Spark装上Ubuntu,普通开发者能玩出什么花?

DGX Spark的出现,标志着AI算力正在经历从"数据中心集中供给"到"个人桌面按需拥有"的范式转移。它让开发者可以在本地完成从数据预处理、模型微调到推理验证的全流程,只有在需要大规模训练时才调用云端资源。迭代速度:从按小时计费的云端调试,变成随时随地的本地实验数据主权:敏感信息永不离开本地成本可控:一次投入,长期使用,不受API调用次数限制AI算力民主化的时代,真的来了。你的下一台超级计算机,不必在云端。它就在你的桌面上,安静地等待着你的下一个创意。附录:实用资源。

QQ_778132974的博客 616

Android的Proxy/Delegate Application框架

转载:http://blogs.360.cn/blog/proxydelegate-application/ 有的时候,为了实现一些特殊需求,如界面换肤、插件化等,我们希望改变应用的运行环境(surrounding)。例如,我们希望某个应用在运行时,所有Class(包括自定义Application,下面假设它叫MyApplication)都被一个自定义的ClassLoader加载。 要

fibanaci的专栏 813

jre9下载 jdk9

(Java SE Runtime Environment 9.0) 9.0u175 官方最新版 【x86|x64】

Android的Proxy/Delegate Application框架 (主要介绍插件化开发)

1. 插件化的原理 是 Java ClassLoader 的原理:Java ClassLoader基础   常用的其他解决方法还包括:Google Multidex,用 H5 代替部分逻辑,删无用代码,买付费版的 Proguard 当插件化作用包括:(1) 模块解耦,(2) 动态升级,(3) 高效并行开发(编译速度更快) (4) 按需加载,内存占用更低 (5) 节省升级流量   2. 插件...

weixin_34161029的博客 199

IMSL CNL 7.0.0 (x86-64)Crack

IMSL CNL 7.0.0 (x86-64)Crack intel的算法库破解文件,这是C++版本的破解文件

迈瑞BS系列全自动生化分析仪LIS协议接口手册(内含HL7和ASTM)

迈瑞BS系列全自动生化分析仪LIS协议接口手册,内含HL7和ASTM)

Android动态加载框架终极指南:DLProxyActivity如何完美管理插件生命周期

Dynamic-Load-Apk是Android平台上功能强大的动态加载框架,它通过巧妙的代理机制实现了插件化架构。在这个框架中,DLProxyActivity扮演着核心角色,负责协调宿主应用与插件之间的生命周期管理,让插件能够像原生应用一样运行。🚀 ## 什么是动态加载框架? 动态加载框架允许Android应用在不安装APK的情况下加载和运行外部模块。这种技术带来了诸多好处:**模块化开发

gitblog_00683的博客 919

Camera2Demo

在Camera2 的基础上进行封装,使得Camera2一堆初始化逻辑与Activity分离,降低耦合度,完成预览拍照并保存的操作

下一篇: 模仿新浪微博@某人/#话题的效果
ht_android
博客等级 码龄12年 33粉丝 19原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值