自己封装一个插件化框架

一 概述

研究了一下滴滴开源的插件化框架,感觉功能挺强大的,于是就想自己动手也封装一个,不过相对于滴滴是支持四大组件的,我这里就只对activity做了支持.要想知道怎么加载一个插件的activity,就得对activity的启动过程有所了解,如果不懂的可以看一下Activity的启动过程这篇文章.从这篇文章的分析得知,Activity的检测工作是在WMS中进行的,所以我们只要使用占坑的方法,先在清单文件中注册一个没用的activity取名为A,然后在进入WMS之前将要启动的activity的包名和类名替换成A的信息,这样在WMS中就可以逃避检查了.当在WMS检测完acitivty的信息要创建acitivty时在将信息替换成我们真正要创建的包名和类名.从之前的文章中分析得知,只要通过反射替换Instrumentation的execStartActivity和newActivity方法即可.

二 加载插件

想要启动插件中的activity,首先就要将apk的信息加载进来,通过PackageManagerService服务得知加载一个apk文件是通过PackageParser来完成的,但是PackageParser是一个隐藏的类,在我们的引用程序中是调用不到的,所以只能从源码中拷贝到自己的项目中来.然后调用PackageParser的parsePackage(apk, flags)方法就可以得到这个apk的Package信息了.

PackageParser.Package mPackage = PackageParserManager.parsePackage(mHostContext, apk, PackageParser.PARSE_MUST_BE_APK);
public class PackageParserManager {



    public static final PackageParser.Package parsePackage(final Context context, final File apk, final int flags) throws PackageParser.PackageParserException {
        if (Build.VERSION.SDK_INT >= 24) {
            return PackageParserV24.parsePackage(context, apk, flags);
        } else if (Build.VERSION.SDK_INT >= 21) {
            return PackageParserLollipop.parsePackage(context, apk, flags);
        } else {
            return PackageParserLegacy.parsePackage(context, apk, flags);
        }
    }

    private static final class PackageParserV24 {

        static final PackageParser.Package parsePackage(Context context, File apk, int flags) throws PackageParser.PackageParserException {
            PackageParser parser = new PackageParser();
            PackageParser.Package pkg = parser.parsePackage(apk, flags);
            ReflectUtil.invokeNoException(PackageParser.class, null, "collectCertificates",
                    new Class[]{PackageParser.Package.class, int.class}, pkg, flags);
            return pkg;
        }
    }

    private static final class PackageParserLollipop {

        static final PackageParser.Package parsePackage(final Context context, final File apk, final int flags) throws PackageParser.PackageParserException {
            PackageParser parser = new PackageParser();
            PackageParser.Package pkg = parser.parsePackage(apk, flags);
            try {
                parser.collectCertificates(pkg, flags);
            } catch (Throwable e) {
                // ignored
            }
            return pkg;
        }

    }

    private static final class PackageParserLegacy {

        static final PackageParser.Package parsePackage(Context context, File apk, int flags) {
            PackageParser parser = new PackageParser(apk.getAbsolutePath());
            PackageParser.Package pkg = parser.parsePackage(apk, apk.getAbsolutePath(), context.getResources().getDisplayMetrics(), flags);
            ReflectUtil.invokeNoException(PackageParser.class, parser, "collectCertificates",
                    new Class[]{PackageParser.Package.class, int.class}, pkg, flags);
            return pkg;
        }

    }
}

通过上面PackageParserManager 的parsePackage方法就可以将插件apk加载进来封装成Package,PackageParserManager 只是对PackageParser 在不同版本的封装,PackageParser 的源码太长就不贴了,后面我会将项目的源码提供.

三.封装插件Apk

public class LoadPlugin {
    private final File mNativeLibDir;
    private Context mHostContext;
    private final PackageParser.Package mPackage;
    private final PackageInfo mPackageInfo;
    private AssetManager mAssets;
    private Resources mResources;
    private ClassLoader mClassLoader;
    private Context mPluginContext;
    private Map<ComponentName, ActivityInfo> mActivityInfos;
    private Application mApplication;


    public LoadPlugin(Context context, File apk) throws PackageParser.PackageParserException {
        this.mHostContext = context;
        //加载插件apk
        mPackage = PackageParserManager.parsePackage(mHostContext, apk, PackageParser.PARSE_MUST_BE_APK);
        mPackageInfo = new PackageInfo();
        this.mPackageInfo.applicationInfo = this.mPackage.applicationInfo;
        this.mPackageInfo.applicationInfo.sourceDir = apk.getAbsolutePath();
        this.mPackageInfo.signatures = this.mPackage.mSignatures;
        this.mPackageInfo.packageName = this.mPackage.packageName;
        this.mPackageInfo.versionCode = this.mPackage.mVersionCode;
        this.mPackageInfo.versionName = this.mPackage.mVersionName;
        this.mPackageInfo.permissions = new PermissionInfo[0];
        //封装插件的context
        this.mPluginContext = new PluginContext(this,mHostContext);
        //创建插件的resource
        this.mResources = createResources(context, apk);

        this.mNativeLibDir = context.getDir(Constants.NATIVE_DIR, Context.MODE_PRIVATE);
        //设置插件的assets
        this.mAssets = this.mResources.getAssets();
        //创建插件的类加载器
        this.mClassLoader = createClassLoader(context, apk, this.mNativeLibDir, context.getClassLoader());

        Map<ComponentName, ActivityInfo> activityInfos = new HashMap<ComponentName, ActivityInfo>();
        for (PackageParser.Activity activity : this.mPackage.activities) {
            activityInfos.put(activity.getComponentName(), activity.info);
        }
        this.mActivityInfos = Collections.unmodifiableMap(activityInfos);
        this.mPackageInfo.activities = activityInfos.values().toArray(new ActivityInfo[activityInfos.size()]);

    }

    @WorkerThread
    private static Resources createResources(Context context, File apk) {
        if (Constants.COMBINE_RESOURCES) {
            Resources resources = new ResourcesManager().createResources(context, apk.getAbsolutePath());
            ResourcesManager.hookResources(context, resources);
            return resources;
        } else {
            Resources hostResources = context.getResources();
            AssetManager assetManager = createAssetManager(context, apk);
            return new Resources(assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration());
        }
    }

    private static AssetManager createAssetManager(Context context, File apk) {
        try {
            AssetManager am = AssetManager.class.newInstance();
            ReflectUtil.invoke(AssetManager.class, am, "addAssetPath", apk.getAbsolutePath());
            return am;
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }


    private  ClassLoader createClassLoader(Context context, File apk, File libsDir, ClassLoader parent) {
        File dexOutputDir = context.getDir(Constants.OPTIMIZE_DIR, Context.MODE_PRIVATE);
        String dexOutputPath = dexOutputDir.getAbsolutePath();
        DexClassLoader loader = new DexClassLoader(apk.getAbsolutePath(), dexOutputPath, libsDir.getAbsolutePath(), parent);

        if (Constants.COMBINE_CLASSLOADER) {
            try {
                DexUtil.insertDex(loader,mHostContext);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        return loader;
    }



    public String getPackageName() {
        return this.mPackage.packageName;
    }


    public AssetManager getAssets() {
        return this.mAssets;
    }

    public Resources getResources() {
        return this.mResources;
    }

    public ClassLoader getClassLoader() {
        return this.mClassLoader;
    }


    public Context getHostContext() {
        return this.mHostContext;
    }

    public Context getPluginContext() {
        return this.mPluginContext;
    }

    public Resources.Theme getTheme() {
        Resources.Theme theme = this.mResources.newTheme();
        theme.applyStyle(PluginUtil.selectDefaultTheme(this.mPackage.applicationInfo.theme, Build.VERSION.SDK_INT), false);
        return theme;
    }

    public void setTheme(int resid) {
        try {
            ReflectUtil.setField(Resources.class, this.mResources, "mThemeResId", resid);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public String getPackageResourcePath() {
        int myUid = Process.myUid();
        ApplicationInfo appInfo = this.mPackage.applicationInfo;
        return appInfo.uid == myUid ? appInfo.sourceDir : appInfo.publicSourceDir;
    }

    public String getCodePath() {
        return this.mPackage.applicationInfo.sourceDir;
    }



    public ActivityInfo getActivityInfo(ComponentName componentName) {
        return this.mActivityInfos.get(componentName);
    }



    public Application getApplication() {
        return mApplication;
    }

    public void invokeApplication(Instrumentation instrumentation) {
        if (mApplication != null) {
            return;
        }

        mApplication = makeApplication(false, instrumentation);
    }



    public Intent getLaunchIntent() {
        ContentResolver resolver = this.mPluginContext.getContentResolver();
        Intent launcher = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER);

        for (PackageParser.Activity activity : this.mPackage.activities) {
            for (PackageParser.ActivityIntentInfo intentInfo : activity.intents) {
                if (intentInfo.match(resolver, launcher, false, "") > 0) {
                    return Intent.makeMainActivity(activity.getComponentName());
                }
            }
        }

        return null;
    }

    public Intent getLeanbackLaunchIntent() {
        ContentResolver resolver = this.mPluginContext.getContentResolver();
        Intent launcher = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER);

        for (PackageParser.Activity activity : this.mPackage.activities) {
            for (PackageParser.ActivityIntentInfo intentInfo : activity.intents) {
                if (intentInfo.match(resolver, launcher, false, "") > 0) {
                    Intent intent = new Intent(Intent.ACTION_MAIN);
                    intent.setComponent(activity.getComponentName());
                    intent.addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER);
                    return intent;
                }
            }
        }

        return null;
    }

    public ApplicationInfo getApplicationInfo() {
        return this.mPackage.applicationInfo;
    }

    public PackageInfo getPackageInfo() {
        return this.mPackageInfo;
    }



    private Application makeApplication(boolean forceDefaultAppClass, Instrumentation instrumentation) {
        if (null != this.mApplication) {
            return this.mApplication;
        }

        String appClass = this.mPackage.applicationInfo.className;
        if (forceDefaultAppClass || null == appClass) {
            appClass = "android.app.Application";
        }

        try {
            this.mApplication = instrumentation.newApplication(this.mClassLoader, appClass, this.getPluginContext());
            instrumentation.callApplicationOnCreate(this.mApplication);
            return this.mApplication;
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
}

class ResourcesManager {

    public static synchronized Resources createResources(Context hostContext, String apk) {
        Resources hostResources = hostContext.getResources();
        Resources newResources = null;
        AssetManager assetManager;
        try {
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
                assetManager = AssetManager.class.newInstance();
                ReflectUtil.invoke(AssetManager.class, assetManager, "addAssetPath", hostContext.getApplicationInfo().sourceDir);
            } else {
                assetManager = hostResources.getAssets();
            }
            ReflectUtil.invoke(AssetManager.class, assetManager, "addAssetPath", apk);
        /*    List<LoadedPlugin> pluginList = PluginManager.getInstance(hostContext).getAllLoadedPlugins();
            for (LoadedPlugin plugin : pluginList) {
                ReflectUtil.invoke(AssetManager.class, assetManager, "addAssetPath", plugin.getLocation());
            }*/
            if (isMiUi(hostResources)) {
                newResources = MiUiResourcesCompat.createResources(hostResources, assetManager);
            } else if (isVivo(hostResources)) {
                newResources = VivoResourcesCompat.createResources(hostContext, hostResources, assetManager);
            } else if (isNubia(hostResources)) {
                newResources = NubiaResourcesCompat.createResources(hostResources, assetManager);
            } else if (isNotRawResources(hostResources)) {
                newResources = AdaptationResourcesCompat.createResources(hostResources, assetManager);
            } else {
                // is raw android resources
                newResources = new Resources(assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration());
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        return newResources;

    }

    public static void hookResources(Context base, Resources resources) {
        if (Build.VERSION.SDK_INT >= 24) {
            return;
        }

        try {
            ReflectUtil.setField(base.getClass(), base, "mResources", resources);
            Object loadedApk = ReflectUtil.getPackageInfo(base);
            ReflectUtil.setField(loadedApk.getClass(), loadedApk, "mResources", resources);

            Object activityThread = ReflectUtil.getActivityThread(base);
            Object resManager = ReflectUtil.getField(activityThread.getClass(), activityThread, "mResourcesManager");
            Map<Object, WeakReference<Resources>> map = (Map<Object, WeakReference<Resources>>) ReflectUtil.getField(resManager.getClass(), resManager, "mActiveResources");
            Object key = map.keySet().iterator().next();
            map.put(key, new WeakReference<>(resources));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static boolean isMiUi(Resources resources) {
        return resources.getClass().getName().equals("android.content.res.MiuiResources");
    }

    private static boolean isVivo(Resources resources) {
        return resources.getClass().getName().equals("android.content.res.VivoResources");
    }

    private static boolean isNubia(Resources resources) {
        return resources.getClass().getName().equals("android.content.res.NubiaResources");
    }

    private static boolean isNotRawResources(Resources resources) {
        return !resources.getClass().getName().equals("android.content.res.Resources");
    }

    private static final class MiUiResourcesCompat {
        private static Resources createResources(Resources hostResources, AssetManager assetManager) throws Exception {
            Class resourcesClazz = Class.forName("android.content.res.MiuiResources");
            Resources newResources = (Resources)ReflectUtil.invokeConstructor(resourcesClazz,
                    new Class[]{AssetManager.class, DisplayMetrics.class, Configuration.class},
                    new Object[]{assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration()});
            return newResources;
        }
    }

    private static final class VivoResourcesCompat {
        private static Resources createResources(Context hostContext, Resources hostResources, AssetManager assetManager) throws Exception {
            Class resourcesClazz = Class.forName("android.content.res.VivoResources");
            Resources newResources = (Resources)ReflectUtil.invokeConstructor(resourcesClazz,
                    new Class[]{AssetManager.class, DisplayMetrics.class, Configuration.class},
                    new Object[]{assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration()});
            ReflectUtil.invokeNoException(resourcesClazz, newResources, "init",
                    new Class[]{String.class}, hostContext.getPackageName());
            Object themeValues = ReflectUtil.getFieldNoException(resourcesClazz, hostResources, "mThemeValues");
            ReflectUtil.setFieldNoException(resourcesClazz, newResources, "mThemeValues", themeValues);
            return newResources;
        }
    }

    private static final class NubiaResourcesCompat {
        private static Resources createResources(Resources hostResources, AssetManager assetManager) throws Exception {
            Class resourcesClazz = Class.forName("android.content.res.NubiaResources");
            Resources newResources = (Resources)ReflectUtil.invokeConstructor(resourcesClazz,
                    new Class[]{AssetManager.class, DisplayMetrics.class, Configuration.class},
                    new Object[]{assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration()});
            return newResources;
        }
    }

    private static final class AdaptationResourcesCompat {
        private static Resources createResources(Resources hostResources, AssetManager assetManager) throws Exception {
            Resources newResources;
            try {
                Class resourcesClazz = hostResources.getClass();
                newResources = (Resources) ReflectUtil.invokeConstructor(resourcesClazz,
                        new Class[]{AssetManager.class, DisplayMetrics.class, Configuration.class},
                        new Object[]{assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration()});
            } catch (Exception e) {
                newResources = new Resources(assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration());
            }

            return newResources;
        }
    }

}

因为我只是要替换一下acitivty,所以只封装一下替换activity所需要的resource,assets,context,mClassLoader 等信息,resource和assest分别表示了插件apk的资源信息,context是activity的上下文管理者,用来获取资源,mClassLoader 是用来将加载相应的activity类文件.

四.封装插件activity的Context

class PluginContext extends ContextWrapper {

    private final LoadPlugin mPlugin;

    public PluginContext(LoadPlugin plugin,Context context) {
        super(context);
        this.mPlugin = plugin;
    }
    @Override
    public Context getApplicationContext() {
        return this.mPlugin.getApplication();
    }

    @Override
    public ApplicationInfo getApplicationInfo() {
        return this.mPlugin.getApplicationInfo();
    }


    private Context getHostContext() {
        return getBaseContext();
    }

    @Override
    public ClassLoader getClassLoader() {
        return this.mPlugin.getClassLoader();
    }

    @Override
    public String getPackageName() {
        return this.mPlugin.getPackageName();
    }

    @Override
    public String getPackageResourcePath() {
        return this.mPlugin.getPackageResourcePath();
    }

    @Override
    public String getPackageCodePath() {
        return this.mPlugin.getCodePath();
    }



    @Override
    public Object getSystemService(String name) {
        // intercept CLIPBOARD_SERVICE,NOTIFICATION_SERVICE
        if (name.equals(Context.CLIPBOARD_SERVICE)) {
            return getHostContext().getSystemService(name);
        } else if (name.equals(Context.NOTIFICATION_SERVICE)) {
            return getHostContext().getSystemService(name);
        }

        return super.getSystemService(name);
    }

    @Override
    public Resources getResources() {
        return this.mPlugin.getResources();
    }

    @Override
    public AssetManager getAssets() {
        return this.mPlugin.getAssets();
    }

到时将activity的context替换成我们封装的PluginContext 对象,这样在activity启动的时候获取对应的资源信息都是从我们LoadPlugin 中获取到的资源来获取.

五.定义Instrumentation子类VAInstrumentation


public class VAInstrumentation extends Instrumentation  {
    public static final String TAG = "VAInstrumentation";
    public static final int LAUNCH_ACTIVITY         = 100;

    private Instrumentation mBase;
    private Context mContext;
    private LoadPlugin mLoadPlugin;

    public VAInstrumentation(Instrumentation base,Context mContext,LoadPlugin loadPlugin) {

        this.mBase = base;
        this.mContext = mContext;
        this.mLoadPlugin = loadPlugin;
    }

    public ActivityResult execStartActivity(
            Context who, IBinder contextThread, IBinder token, Activity target,
            Intent intent, int requestCode, Bundle options) {
        拿到要启动的acitivty的包名和类名
        String targetPackageName = intent.getComponent().getPackageName();
        String targetClassName = intent.getComponent().getClassName();
        如果要启动的包名和当前应用的包名不同,表示是要启动插件的activity
        if (!targetPackageName.equals(mContext.getPackageName()) ) {
            将插件的包名和类名,先用其他字段存起来
            intent.putExtra(Constants.KEY_IS_PLUGIN, true);
            intent.putExtra(Constants.KEY_TARGET_PACKAGE, targetPackageName);
            intent.putExtra(Constants.KEY_TARGET_ACTIVITY, targetClassName);
            将intent的包名和类名替换成我们占坑的acitivty信息
            dispatchStubActivity(intent);
        }

        ActivityResult result = null;
        try {
            Class[] parameterTypes = {Context.class, IBinder.class, IBinder.class, Activity.class, Intent.class,
                    int.class, Bundle.class};
                    通过反射调用Instrumentation的execStartActivity方法.
            result = (ActivityResult) ReflectUtil.invoke(Instrumentation.class, mBase,
                    "execStartActivity", parameterTypes,
                    who, contextThread, token, target, intent, requestCode, options);
        } catch (Exception e) {
            e.printStackTrace();
        }

        return result;

    }


    private void dispatchStubActivity(Intent intent) {
        ComponentName component = intent.getComponent();
        String targetClassName = intent.getComponent().getClassName();

        ActivityInfo info = mLoadPlugin.getActivityInfo(component);
        if (info == null) {
            throw new RuntimeException("can not find " + component);
        }

        Resources.Theme themeObj = mLoadPlugin.getResources().newTheme();
        themeObj.applyStyle(info.theme, true);
        //这个就是占坑的类名
        String stubActivity = "com.lyf.pluginapk.SecondeActivity";
        Log.i(TAG, String.format("dispatchStubActivity,[%s -> %s]", targetClassName, stubActivity));
        替换intent中的包名和类名
        intent.setClassName(mContext, stubActivity);
    }

    @Override
    public Activity newActivity(ClassLoader cl, String className, Intent intent) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
        if (intent.getBooleanExtra(Constants.KEY_IS_PLUGIN, false)) {
            //如果是启动插件的acitivty,就拿到真正要启动的包名和类名
            String targetClassName = PluginUtil.getTargetActivity(intent);
            if (targetClassName != null) {
                调用newActivity方法创建acitivty,不过这里传入的是插件acitivty的类加载器,和插件的包名,类名.所以创建的也就是插件的acitivty
                Activity activity = mBase.newActivity(mLoadPlugin.getClassLoader(), targetClassName, intent);
                activity.setIntent(intent);

                try {
                    // for 4.1+
                       ReflectUtil.setField(ContextThemeWrapper.class, activity, "mResources", mLoadPlugin.getResources());
                } catch (Exception ignored) {
                    // ignored.
                }

                return activity;
            }
        }
        //如果不是启动插件,就直接启动对应的acitivty
        return mBase.newActivity(cl, className, intent);

    }

    @Override
    public void callActivityOnCreate(Activity activity, Bundle icicle) {
        final Intent intent = activity.getIntent();
        这个方法,主要是在调用acitivty的onCreate之前,将插件的resrouse,context等信息替换成我们之前加载插件获取的相应信息,避免找不到资源.
        if (PluginUtil.isIntentFromPlugin(intent)) {
            Context base = activity.getBaseContext();
            try {

                ReflectUtil.setField(base.getClass(), base, "mResources", mLoadPlugin.getResources());
                ReflectUtil.setField(ContextWrapper.class, activity, "mBase", mLoadPlugin.getPluginContext());
                ReflectUtil.setField(Activity.class, activity, "mApplication", mLoadPlugin.getApplication());
                ReflectUtil.setFieldNoException(ContextThemeWrapper.class, activity, "mBase", mLoadPlugin.getPluginContext());

                // set screenOrientation
                ActivityInfo activityInfo = mLoadPlugin.getActivityInfo(PluginUtil.getComponent(intent));
                if (activityInfo.screenOrientation != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) {
                    activity.setRequestedOrientation(activityInfo.screenOrientation);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }

        }

        mBase.callActivityOnCreate(activity, icicle);
    }



    @Override
    public Context getContext() {
        return mBase.getContext();
    }

    @Override
    public Context getTargetContext() {
        return mBase.getTargetContext();
    }

    @Override
    public ComponentName getComponentName() {
        return mBase.getComponentName();
    }



}

六.加载插件,替换Instrumentation

  获取插件的路径
  File apk = new File(Environment.getExternalStorageDirectory(), "Test3.apk");
        if (apk.exists()) {
            try {
                 加载插件
                 loadPlugin = new LoadPlugin(this, apk);
                //hook Instrumentation类 Instrumentation在Activitythread中
                Instrumentation instrumentation = ReflectUtil.getInstrumentation(this);
                VAInstrumentation vaInstrumentation = new VAInstrumentation(instrumentation,this,loadPlugin);
                Object activityThread = ReflectUtil.getActivityThread(this);
                替换Instrumentation为我们定义的vaInstrumentation
                ReflectUtil.setInstrumentation(activityThread, vaInstrumentation);
                //为了设置application,否则可以不调用
                loadPlugin.invokeApplication(vaInstrumentation);


            } catch (Exception e) {
                System.out.println("加载失败");
                e.printStackTrace();
            }
        }

到这一步替换完instrumentation类,在启动插件中的acitivty,就可以启动起来了.

七 总结

这只是我自己为了验证滴滴的插件原理而自己实现的一遍过程,想要研究源码的可以去看看滴滴的源码,同时我也将我实现的源码上传到了github,点击下载源码

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值