Android App的插件化和动态加载框

               

本文整理自:

http://www.infoq.com/cn/articles/ctrip-android-dynamic-loading

http://blog.csdn.net/lzyzsd/article/details/49768283

一、简介

携程Android App的插件化和动态加载框架已上线半年多,经历了初期的探索和持续的打磨优化,新框架和工程配置经受住了生产实践的考验。本文将详细介绍Android平台插件式开发和动态加载技术的原理和实现细节,回顾携程Android App的架构演化过程,期望我们的经验能帮助到更多的Android工程师。


2014年,随着业务发展需要和携程无线部门的拆分,各业务产品模块归属到各业务BU,原有携程无线App开发团队被分为基础框架、酒店、机票、火车票等多个开发团队,从此携程App的开发和发布进入了一个全新模式。在这种模式下,开发沟通成本大大提高,之前的协作模式难以为继,需要新的开发模式和技术解决需求问题。


另一方面,从技术上来说,携程早在2012年就触到Android平台史上最坑天花板(没有之一):65535方法数问题。旧方案是把所有第三方库放到第二个dex中,并且利用Facebook当年发现的hack方法扩大点LinearAllocHdr分配空间(5M提升到8M),但随着代码的膨胀,旧方案也逐渐捉襟见肘。拆or不拆,根本不是可考虑问题,继续拆分dex是我们的唯一出路。问题在于:怎么拆才比较聪明?


其次,随着组织架构调整的影响,给我们的App质量控制带来极高的挑战,这种紧张和压力让我们的开发团队心力憔悴。此时除了流着口水羡慕前端同事们的在线更新持续发布能力之外,难道就没有办法解决Native架构这一根本性缺陷了吗?NO!插件化动态加载带来的额外好处就是客户端的热部署能力。


从以上几点根本性需求可以看出,插件化动态加载架构方案会为我们带来多么巨大的收益,除此之外还有诸多好处:

A、编译速度提升
工程被拆分为十来个子工程之后,Android Studio编译流程繁冗的缺点被迅速放大,在Win7机械硬盘开发机上编译时间曾突破1小时,令人发指的龟速编译让开发人员叫苦不迭(当然现在换成Mac+SSD快太多)。这个由于携程本身架构的复杂化,暂时还没实现,但是已经在计划中。

B、启动速度提升
Google提供的MultiDex方案,会在主线程中执行所有dex的解压、dexopt、加载操作,这是一个非常漫长的过程,用户会明显的看到长久的黑屏,更容易造成主线程的ANR,导致首次启动初始化失败。这个由于携程对各产品线的dex文件采用了按需分批加载,启动速度已经非常明显。

C、A/B Testing
可以独立开发A/B版本的模块,而不是将A/B版本代码写在同一个模块中。

这个只是一个理想,而且实际操作起来,对于小的A/B Test相对来说,直接把代码写在一个模块,一个dex中。
D、可选模块按需下载

例如用于调试功能的模块可以在需要时进行下载后进行加载,减少App Size

这个只是一个理想,暂时没有这么做,实际操作起来要复杂些。

二、基本原理

关于插件化思想,软件业已经有足够多的用户教育。无论是日常使用的浏览器,还是陪伴程序员无数日夜的Eclipse,甚至连QQ背后,都有插件化技术的支持。对于携程来说以前机票,酒店,火车票,旅行日程这些BU虽然已经拆分为了各自的工程,但是他们的Native代码都是直接集成到携程旅行这个APP的apk中,并且在启动时全部加载。插件化和动态加载就是把这些BU的Native代码分别打包成apk文件,放在携程旅行这个APP的apk的asset目录中。用户在启动时,主要加载框架的dex,其他的在需要时再加载。

三、主要难题

我们要在Android上实现插件化,主要需要考虑2个问题:

编译期:资源和代码的编译。特别是各插件包资源索引文件R中id的冲突
运行时:资源和代码的加载
解决了以上2个关键问题,之后如何实现插件化的具体接口,就变成个人技术喜好或者具体需求场景差异而已。现在我们就针对以上关键问题逐一破解,其中最麻烦的还是资源的编译和加载问题。

四、Android资源和代码的编译


首先来回顾下Android是如何进行编译的。请看下图:





整个流程庞大而复杂,我们主要关注几个重点环节:aapt、javac、proguard、dex。相关环节涉及到的输入输出都在图上重点标粗。

1、资源的编译

Android的资源编译依赖一个强大的命令行工具:aapt,它位于<SDK>/build-tools/<buildToolsVersion>/aapt,有着众多的 命令行参数,其中有几个值得我们特别关注:
-I add an existing package to base include set

这个参数可以在依赖路径中追加一个已经存在的package。在Android中,资源的编译也需要依赖,最常用的依赖就是SDK自带的android.jar本身。打开android.jar可以看到,其实不是一个普通的jar包,其中不但包含了已有SDK类库class,还包含了SDK自带的已编译资源以及资源索引表resources.arsc文件。在日常的开发中,我们也经常通过@android:color/opaque_red形式来引用SDK自带资源。这一切都来自于编译过程中aapt对android.jar的依赖引用。同理,我们也可以使用这个参数引用一个已存在的apk包作为依赖资源参与编译。

-G A file to output proguard options into.


资源编译中,对组件的类名、方法引用会导致运行期反射调用,所以这一类符号量是不能在代码混淆阶段被混淆或者被裁减掉的,否则等到运行时会找不到布局文件中引用到的类和方法。-G方法会导出在资源编译过程中发现的必须keep的类和接口,它将作为追加配置文件参与到后期的混淆阶段中。

-J specify where to output R.java resource constant definitions

在Android中,所有资源会在Java源码层面生成对应的常量ID,这些ID会记录到R.java文件中,参与到之后的代码编译阶段中。在R.java文件中,Android资源在编译过程中会生成所有资源的ID,作为常量统一存放在R类中供其他代码引用。在R类中生成的每一个int型四字节资源ID,实际上都由三个字段组成。第一字节代表了Package,第二字节为分类,三四字节为类内ID。例如:
//android.jar中的资源,其PackageID为0x01public static final int cancel = 0x01040000;//用户app中的资源,PackageID总是0x7Fpublic static final int zip_code = 0x7f090f2e;

我们修改aapt后,是可以给每个子apk中的资源分配不同头字节PackageID,这样就不会再互相冲突。

appt相关的源码都在framework/base/tools/aapt目录下。


首先查看ResourceTable.cpp中的构造函数

ResourceTable::ResourceTable(Bundle* bundle, const String16& assetsPackage, ResourceTable::PackageType type)ssize_t packageId = -1;switch (mPackageType) {    case App:    case AppFeature:        packageId = 0x7f;        break;    case System:        packageId = 0x01;        break;    case SharedLibrary:        packageId = 0x00;        break;    default:        assert(0);        break;}

资源的packageId就是在这里根据packageType来确定的,其中0x01是给系统资源使用的,在R.java中可以看到系统资源的id都是以0x01开头的,0x7f是给我们的应用程序资源使用的, 
同样在R.java中,你可以看到,自己的app的资源id都是以0x7f开头的。这也就是说0x01到0x7f之间的的值我们都可以作为packageId来用。


接下来我们看看是在哪里创建了ResourceTable对象。


打开Resource.cpp,buildResources方法,就是在这个方法中构造了resourceTable对象。

status_t buildResources(Bundle* bundle, const sp<AaptAssets>& assets, sp<ApkBuilder>& builder)ResourceTable::PackageType packageType = ResourceTable::App;if (bundle->getBuildSharedLibrary()) {    packageType = ResourceTable::SharedLibrary;} else if (bundle->getExtending()) {    packageType = ResourceTable::System;} else if (!bundle->getFeatureOfPackage().isEmpty()) {    packageType = ResourceTable::AppFeature;}


ResourceTable table(bundle, String16(assets->getPackage()), packageType);

可以看到这里是根据bundle的几个方法的返回值,来确定生成的资源的packageId的。


那么,我们就可以在这里做一些手脚,来生成我们自己想要的packageId。我的做法就是给bundle对象加上一个getPackageId方法, 
该方法会返回我们在命令行传入的packageId。代码类似下面

else if (!bundle->getFeatureOfPackage().isEmpty()) {    packageType = ResourceTable::AppFeature;} else if (bundle->getPackageId() != 0) {  packageType = bundle.getPackageId();}

bundle类上还会定义一个setPackageId方法,用来保存packageId信息。 
bundle对象是在Main.cpp的main方法,也就是appt程序的入口中构造出来的,下面列出一个代码片段
switch (*cp) {case 'v':    bundle.setVerbose(true);    break;case 'a':    bundle.setAndroidList(true);    break;

bundle根据命令行传入的各种参数,来设置自己的一些状态,这里我们要加入自己的逻辑,来出来–apk-module参数,同时调用setPackageId方法就好了。


为aapt增加–public-R-path参数。
这里无非就是在Main.cpp中检测到这个参数的时候,也保存到bundle中,然后在Resource.cpp的writeResourceSymbols方法中, 
在生成 R.java的时候,把传入的参数所指定的R.java中的变量插入到这个要生成的 R.java文件中就可以了

2、代码的编译

大家对Java代码的编译应该相当熟悉,只需要注意以下几个问题即可:

classpath

Java源码编译中需要找齐所有依赖项,classpath就是用来指定去哪些目录、文件、jar包中寻找依赖。

混淆

为了安全需要,绝大部分Android工程都会被混淆。混淆的原理和配置可参考 Proguard手册
有了以上背景知识,我们就可以思考并设计插件化动态加载框架的基本原理和主要流程了。

3、资源和代码的编译总结和实现

实现分为两类:1.针对插件子工程做的编译流程改造,2. 运行时动态加载改造(宿主程序动态加载插件,有两个壁垒需要突破:资源如何访问,代码如何访问)。

(1)、插件资源编译

,针对插件的资源编译,我们需要考虑到以下几点:

使用-I参数对宿主的apk进行引用。

据此,插件的资源、xml布局中就可以使用宿主的资源和控件、布局类了。

为aapt增加--apk-module参数。

如前所述,资源ID其实有一个PackageID的内部字段。我们为每个插件工程指定独特的PackageID字段,这样根据资源ID就很容易判明,此资源需要从哪个插件apk中去查找并加载了。在后文的资源加载部分会有进一步阐述。

为aapt增加--public-R-path参数。

按照对android.jar包中资源使用的常规手段,引用系统资源可使用它的R类的全限定名android.R来引用具体ID,以便和当前项目中的R类区分。插件对于宿主的资源引用,当然也可以使用base.package.name.R来完成。但由于历史原因,各子BU的“插件”代码是从主app中解耦独立出去的,资源引用还是直接使用当前工程的R。如果改为标准模式,则当前大量遗留代码中R都需要酌情改为base.R,工程量大并且容易出错,未来对bu开发人员的使用也有点不够“透明”。因此我们在设计上做了让步,额外增加--public-R-path参数,为aapt指明了base.R的位置,让它在编译期间把base的资源ID定义在插件的R类中完整复制一份,这样插件工程即可和之前一样,完全不用在乎资源来自于宿主或者自身,直接使用即可。当然这样做带来的副作用就是宿主和插件的资源不应有重名,这点我们通过开发规范来约束,相对比较容易理解一些。

(2)、插件代码编译

针对插件的代码编译,需要考虑以下几点:
classpath
对于插件的编译来说,除了对android.jar以及自己需要的第三方库进行依赖之外,还需要依赖宿主导出的base.jar类库。同时对宿主的混淆也提出了要求:宿主的所有public/protected都可能被插件依赖,所以这些接口都不允许被混淆。
混淆
插件工程在混淆的时候,当然也要把宿主的混淆后jar包作为参考库导入。


自此,编译期所有重要步骤的技术方案都已经确定,剩下的工作就只是把插件apk导入到先一步生成好的base.apk中并重新进行签名对齐而已。


五、运行时资源和代码的加载

1、运行时资源的加载

平常我们使用资源,都是通过AssetManager类和Resources类来访问的。获取它们的方法位于Context类中。


Context.java

/** Return an AssetManager instance for your application's package. */public abstract AssetManager getAssets();/** Return a Resources instance for your application's package. */public abstract Resources getResources();

它们是两个抽象方法,具体的实现在ContextImpl类中。ContextImpl类中初始化Resources对象后,后续Context各子类包括Activity、Service等组件就都可以通过这两个方法读取资源了。


ContextImpl.java

private final Resources mResources;@Overridepublic AssetManager getAssets() {   return getResources().getAssets();}@Overridepublic Resources getResources() {   return mResources;}


既然我们已经知道一个资源ID应该从哪个apk去读取(前面在编译期我们已经在资源ID第一个字节标记了资源所属的package),那么只要我们重写这两个抽象方法,即可指导应用程序去正确的地方读取资源。


至于读取资源,AssetManager有一个隐藏方法addAssetPath,可以为AssetManager添加资源路径。


/*** Add an additional set of assets to the asset manager.  This can be* either a directory or ZIP file.  Not for use by applications.  Returns* the cookie of the added asset, or 0 on failure.* {@hide}*/public final int addAssetPath(String path) {   synchronized (this) {       int res = addAssetPathNative(path);       makeStringBlocks(mStringBlocks);       return res;   }}

我们只需反射调用这个方法,然后把插件apk的位置告诉AssetManager类,它就会根据apk内的resources.arsc和已编译资源完成资源加载的任务了。

以上我们已经可以做到加载插件资源了,但使用了一大堆定制类实现。要做到“无缝”体验,还需要一步:使用Instrumentation来接管所有Activity、Service等组件的创建(当然也就包含了它们使用到的Resources类)。


话说Activity、Service等系统组件,都会经由android.app.ActivityThread类在主线程中执行。ActivityThread类有一个成员叫mInstrumentation,它会负责创建Activity等操作,这正是注入我们的修改资源类的最佳时机。通过篡改mInstrumentation为我们自己的InstrumentationHook,每次创建Activity的时候顺手把它的mResources类偷天换日为我们的DelegateResources,以后创建的每个Activity都拥有一个懂得插件、懂得委托的资源加载类啦!


当然,上述替换都会针对Application的Context来操作。

把Resources反注到普通Context

BundleCore.java中Resources本身的反注呢?

    public void runDelegateResources() {        try {            DelegateResources.newDelegateResources(RuntimeArgs.androidApplication, RuntimeArgs.delegateResources);        } catch (Exception ex) {            ex.printStackTrace();            Map dic = new HashMap();            dic.put("error", ex.getMessage());            CtripActionLogUtil.logTrace("o_resource_error", dic);        }    }

DelegateResources的源码如下:

/** * Created by yb.wang on 15/1/5. * 挂载载系统资源中,处理框架资源加载 */public class DelegateResources extends Resources {    static final Logger log;    static {        log = LoggerFactory.getLogcatLogger("DelegateResources");    }    public DelegateResources(AssetManager assets, Resources resources) {        super(assets, resources.getDisplayMetrics(), resources.getConfiguration());    }    public static void newDelegateResources(Application application, Resources resources) throws Exception {        List<Bundle> bundles = Framework.getBundles();        if (bundles != null && !bundles.isEmpty()) {            Resources delegateResources;            List<String> arrayList = new ArrayList();            arrayList.add(application.getApplicationInfo().sourceDir);            for (Bundle bundle : bundles) {                //if (BundleCore.DELAYED_PACKAGES.containsValue(bundle.getLocation())) continue;                arrayList.add(((BundleImpl) bundle).getArchive().getArchiveFile().getAbsolutePath());            }            AssetManager assetManager = AssetManager.class.newInstance();            for (String str : arrayList) {                SysHacks.AssetManager_addAssetPath.invoke(assetManager, str);            }            //处理小米UI资源            if (resources == null || !resources.getClass().getName().equals("android.content.res.MiuiResources")) {                delegateResources = new DelegateResources(assetManager, resources);            } else {                Constructor declaredConstructor = Class.forName("android.content.res.MiuiResources").getDeclaredConstructor(new Class[]{AssetManager.class, DisplayMetrics.class, Configuration.class});                declaredConstructor.setAccessible(true);                delegateResources = (Resources) declaredConstructor.newInstance(new Object[]{assetManager, resources.getDisplayMetrics(), resources.getConfiguration()});            }            RuntimeArgs.delegateResources = delegateResources;            AndroidHack.injectResources(application, delegateResources);            StringBuffer stringBuffer = new StringBuffer();            stringBuffer.append("newDelegateResources [");            for (int i = 0; i < arrayList.size(); i++) {                if (i > 0) {                    stringBuffer.append(",");                }                stringBuffer.append(arrayList.get(i));            }            stringBuffer.append("]");            log.log(stringBuffer.toString(), Logger.LogLevel.DBUG);        }    }}

AndroidHack.java的方法如下:

    public static void injectResources(Application application, Resources resources) throws Exception {        Object activityThread = getActivityThread();        if (activityThread == null) {            throw new Exception("Failed to get ActivityThread.sCurrentActivityThread");        }        Object loadedApk = getLoadedApk(activityThread, application.getPackageName());        if (loadedApk == null) {            throw new Exception("Failed to get ActivityThread.mLoadedApk");        }        SysHacks.LoadedApk_mResources.set(loadedApk, resources);        SysHacks.ContextImpl_mResources.set(application.getBaseContext(), resources);        SysHacks.ContextImpl_mTheme.set(application.getBaseContext(), null);    }

Resources反注到Activity中

Activity是通过ActivityThread创建的。我们可以通过一些代码,让Acitivity在创建时,把Resources反注到Activity中。

首先要得到当前应用程序的ActivityThread。

ActivityThread.java的源码中有一个隐藏的如下方法

    public static ActivityThread currentActivityThread() {        return sCurrentActivityThread;    }
在GitHub开源项目 DynamicAPK中是通过SysHacks.java的如下方法来初始化ActivityThread.java的 currentActivityThread方法和AssetManager.java的addAssetPath()方法的。

    public static void allMethods() throws HackAssertionException {        ActivityThread_currentActivityThread = ActivityThread.method("currentActivityThread", new Class[0]);        AssetManager_addAssetPath = AssetManager.method("addAssetPath", String.class);        Application_attach = Application.method("attach", Context.class);    }

 AndroidHack.java中取得ActivityThread实例

 public static Object getActivityThread() throws Exception {        if (_sActivityThread == null) {            if (Thread.currentThread().getId() == Looper.getMainLooper().getThread().getId()) {                _sActivityThread = SysHacks.ActivityThread_currentActivityThread.invoke(null, new Object[0]);            } else {                Handler handler = new Handler(Looper.getMainLooper());                synchronized (SysHacks.ActivityThread_currentActivityThread) {                    handler.post(new ActivityThreadGetter());                    SysHacks.ActivityThread_currentActivityThread.wait();                }            }        }        return _sActivityThread;    }

BundleCore.java中初始化
   public void init(Application application) throws Exception {        SysHacks.defineAndVerify();        RuntimeArgs.androidApplication = application;        RuntimeArgs.delegateResources = application.getResources();        AndroidHack.injectInstrumentationHook(new InstrumentationHook(AndroidHack.getInstrumentation(), application.getBaseContext()));    }

这里调用AndroidHack的injectInstrumentationHook方法把自己定制的InstrumentationHook通过反射反注到ActivityThread中

    public static void injectInstrumentationHook(Instrumentation instrumentation) throws Exception {        Object activityThread = getActivityThread();        if (activityThread == null) {            throw new Exception("Failed to get ActivityThread.sCurrentActivityThread");        }        SysHacks.ActivityThread_mInstrumentation.set(activityThread, instrumentation);    }
InstrumentationHook继承自系统的Instrumentation,其核心代码如下:

   public Activity newActivity(Class<?> cls, Context context, IBinder iBinder, Application application, Intent intent, ActivityInfo activityInfo, CharSequence charSequence, Activity activity, String str, Object obj) throws InstantiationException, IllegalAccessException {        Activity newActivity = this.mBase.newActivity(cls, context, iBinder, application, intent, activityInfo, charSequence, activity, str, obj);        if (RuntimeArgs.androidApplication.getPackageName().equals(activityInfo.packageName) && SysHacks.ContextThemeWrapper_mResources != null) {            SysHacks.ContextThemeWrapper_mResources.set(newActivity, RuntimeArgs.delegateResources);        }        return newActivity;    }


public void callActivityOnCreate(Activity activity, Bundle bundle) {        if (RuntimeArgs.androidApplication.getPackageName().equals(activity.getPackageName())) {            ContextImplHook contextImplHook = new ContextImplHook(activity.getBaseContext());            if (!(SysHacks.ContextThemeWrapper_mBase == null || SysHacks.ContextThemeWrapper_mBase.getField() == null)) {                SysHacks.ContextThemeWrapper_mBase.set(activity, contextImplHook);            }            SysHacks.ContextWrapper_mBase.set(activity, contextImplHook);        }        this.mBase.callActivityOnCreate(activity, bundle);    }

ContextImplHook.java源码如下:

/** * Created by yb.wang on 15/1/6. * Android Context Hook 挂载载系统的Context中,拦截相应的方法 */public class ContextImplHook extends ContextWrapper {    static final Logger log;    static {        log = LoggerFactory.getLogcatLogger("ContextImplHook");    }    public ContextImplHook(Context context) {        super(context);    }    @Override    public Resources getResources() {        log.log("getResources is invoke", Logger.LogLevel.INFO);        return RuntimeArgs.delegateResources;    }    @Override    public AssetManager getAssets() {        log.log("getAssets is invoke", Logger.LogLevel.INFO);        return RuntimeArgs.delegateResources.getAssets();    }}


小结

简单点说是这样

创建一个自己的DelegateResources,她继承于Resources。这个自己的DelegateResources使用了通过如下方式,使用了自己定制的AssetManager。

            AssetManager assetManager = AssetManager.class.newInstance();            for (String str : arrayList) {                SysHacks.AssetManager_addAssetPath.invoke(assetManager, str);            }

delegateResources = new DelegateResources(assetManager, resources);

然后把他反注到Contex中

    public static void injectResources(Application application, Resources resources) throws Exception {        Object activityThread = getActivityThread();        if (activityThread == null) {            throw new Exception("Failed to get ActivityThread.sCurrentActivityThread");        }        Object loadedApk = getLoadedApk(activityThread, application.getPackageName());        if (loadedApk == null) {            throw new Exception("Failed to get ActivityThread.mLoadedApk");        }        SysHacks.LoadedApk_mResources.set(loadedApk, resources);        SysHacks.ContextImpl_mResources.set(application.getBaseContext(), resources);        SysHacks.ContextImpl_mTheme.set(application.getBaseContext(), null);    }


通过  ActivityThread的currentActivityThread()方法得到其实例,创建一个自己的InstrumentationHook,把它反注到ActivityThread,这里的InstrumentationHook把系统本身的Instrumentation包了一层,其回调函数newActivity()和callActivityOnCreate,即Activity本创建的时候,创建一个我们自己的ContextImplHook,将它反注到Activity中的。

而ContextImplHook本身只是把Context包了一下,重写了 getResources(),返回了我们前面创建的自己的delegateResources;同时也重写了getAssets,返回了我们前面创建的自己的delegateResources的AssetManager。




2、运行时类的加载


类的加载相对比较简单。与Java程序的运行时classpath概念类似,Android的系统默认类加载器PathClassLoader也有一个成员pathList,顾名思义它从本质来说是一个List,运行时会从其间的每一个dex路径中查找需要加载的类。既然是个List,一定就会想到,给它追加一堆dex路径不就得了?实际上,Google官方推出的MultiDex库就是用以上原理实现的。下面代码片段展示了修改pathList路径的细节:


MultiDex.java


private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
     File optimizedDirectory)
             throws IllegalArgumentException, IllegalAccessException,
             NoSuchFieldException, InvocationTargetException, NoSuchMethodException {
    /* The patched class loader is expected to be a descendant of
    * dalvik.system.BaseDexClassLoader. We modify its
    * dalvik.system.DexPathList pathList field to append additional DEX
    * file entries.
    */
    Field pathListField = findField(loader, "pathList");
    Object dexPathList = pathListField.get(loader);
    expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
         new ArrayList<File>(additionalClassPathEntries), optimizedDirectory));
}

注:这里的ClassLoader loader可以通过Application来获得。

当然,针对不同Android版本,类加载方式略有不同,可以参考MultiDex源码做具体的区别处理。


至此,之前提出的四个根本性问题,都已经有了具体的解决方案。剩下的就是编码!

六、编码实现

具体编码实现主要分为三部分:

对aapt工具的修改。
gradle打包脚本的实现。

运行时加载代码的实现。

具体实现细节请参考GitHub开源项目DynamicAPK

           

再分享一下我老师大神的人工智能教程吧。零基础!通俗易懂!风趣幽默!还带黄段子!希望你也加入到我们人工智能的队伍中来!https://blog.csdn.net/jiangjunshow

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值