滴滴插件化方案VirtualApk源码分析

滴滴插件化方案 VirtualApk源码分析

概述

插件化的难点在于对四大组件的支持,我们知道在Android中要使用四大组件就必须要在Manifest配置文件中进行注册,而插件apk中的组件是不可能预先知道名字的,我们无法提前把插件apk中的组件注册到宿主apk中。所以现在基本上都是通过hook一些系统的api做一些替换工作来解决,VirtualApk的大致方案如下:

  • Activity:在宿主apk中提前占几个坑,然后两次替换来欺骗AMS对未注册的Activity的检查来启动插件apk中的Activity,因为要支持不同的launchmode以及其他的必要属性,所以我们要提前占用好几个坑。

  • Service:通过代理Service的方式去分发;有两个代理service分别支持主进程和其他进程

  • BroadcastReceiver:通过把静态注册改为动态注册

  • ContentProvider:通过一个代理Provider进行分发

    下面我们来逐一分析一些virtualapk是怎么支持四大组件的。

    分析的版本为:com.didi.virtualapk:core:0.9.8
    

架构图

在这里插入图片描述

对Activity的支持

首先我们看一些在我们的宿主应用中是怎么加载插件的以及怎么跳转页面的。代码如下:

//加载下载好的插件apk     
try {
            String pluginPath = getExternalFilesDir(DIRECTORY_DOWNLOADS).getAbsolutePath().concat("/Test.apk");
            Log.i("guojingbu", "onCreate: pluginPath = " + pluginPath);
            File plugin = new File(pluginPath);
            PluginManager.getInstance(this).addCallback(this);
            PluginManager.getInstance(this).loadPlugin(plugin);
        } catch (Exception e) {
            e.printStackTrace();
        }

//调转到指定页面
   public void jumpPlugin(View view) {
        Intent intent = new Intent();
        intent.setClassName("com.yesway.model_virtual_plugin", "com.yesway.model_virtual_plugin.PluginMainActivity");
        startActivity(intent);
    }

这里我们可以看到,首先需要加载插件apk

   PluginManager.getInstance(this).loadPlugin(plugin)

加载插件

调用loadPlugin方法后会为加载的plugin创建一个对应的LoadedPlugin实例并放入ConcurrentHashMap防止重复加载提高效率。在LoadedPlugin这个类中主要是通过PackageParser.parsePackage来解析apk获得该apk中对应的PackageInfo,并把四大组件相关的信息存储到对应的以ComponentName为key的HashMap集合中(mActivityInfos、mServiceInfos、mReceiverInfos、mProviderInfos),针对于Plugin的PluginContext等一堆信息,封装为LoadedPlugin对象

部分代码如下:

 public LoadedPlugin(PluginManager pluginManager, Context context, File apk) throws Exception {
        this.mPluginManager = pluginManager;
        this.mHostContext = context;
        this.mLocation = apk.getAbsolutePath();
        this.mPackage = PackageParserCompat.parsePackage(context, apk, PackageParser.PARSE_MUST_BE_APK);
        this.mPackage.applicationInfo.metaData = this.mPackage.mAppMetaData;
        this.mPackageInfo = new PackageInfo();
        this.mPackageInfo.applicationInfo = this.mPackage.applicationInfo;
        this.mPackageInfo.applicationInfo.sourceDir = apk.getAbsolutePath();
 		....
        this.mPackageManager = createPluginPackageManager();
        this.mPluginContext = createPluginContext(null);
       ...
    }

省略部分代码,其实主要的逻辑就是对插件apk信息的解析。

(1)将插件中Activity替换为对应的占坑Activity

​ 我们都知道一个Activity如果不在Manifest配置文件中注册是不可能被系统启动的,否则肯定是会报错的。所以目前插件化框架要解决的问题就是怎么才能让系统不报错。

这里就需要对Activity的启动流程有一定的了解。跟进startActivity的调用流程,会发现其最终会进入Instrumentation的execStartActivity方法,然后再通过与AMS进行交互完成Activity的校验和启动。

而Activity是否存在的校验是发生在AMS端,所以我们在于AMS交互前,提前将Activity的ComponentName进行替换为占坑的名字这样不就可以解决系统对未注册Activity的检查了吗?

VirtualAPK选择了hook Instrumentation和Handler

在PluginManager类初始化 的时候我们可以看到如下代码:

    protected void hookInstrumentationAndHandler() {
        try {
            ActivityThread activityThread = ActivityThread.currentActivityThread();
            Instrumentation baseInstrumentation = activityThread.getInstrumentation();
//            if (baseInstrumentation.getClass().getName().contains("lbe")) {
//                // reject executing in paralell space, for example, lbe.
//                System.exit(0);
//            }
    
            final VAInstrumentation instrumentation = createInstrumentation(baseInstrumentation);
            
            Reflector.with(activityThread).field("mInstrumentation").set(instrumentation);
            Handler mainHandler = Reflector.with(activityThread).method("getHandler").call();
            Reflector.with(mainHandler).field("mCallback").set(instrumentation);
            this.mInstrumentation = instrumentation;
            Log.d(TAG, "hookInstrumentationAndHandler succeed : " + mInstrumentation);
        } catch (Exception e) {
            Log.w(TAG, e);
        }
    }

从上面的代码可以看出通过反射可以拿到原本的Instrumentation对象,要拿到Instrumentation首先我们要拿到ActivityThread,ActivityThread可以通过sCurrentActivityThread或者它的成员方法currentActivityThread拿到。

然后创建一个VAInstrumentation对象,然后通过反射把VAInstrumentation对象直接设置给ActivityThread的mInstrumentation属性。

这样就完成了hook Instrumentation,之后调用Instrumentation的任何方法,都可以在VAInstrumentation进行拦截并做一些修改。

上面我们有提到,可以通过Instrumentation的execStartActivity方法进行偷梁换柱,所以我们直接看对应的方法:

 public VAInstrumentation(PluginManager pluginManager, Instrumentation base) {
        this.mPluginManager = pluginManager;
        this.mBase = base;
    }

@Override
    public ActivityResult execStartActivity(Context who, IBinder contextThread, IBinder token, Activity target, Intent intent, int requestCode) {
        injectIntent(intent);
        return mBase.execStartActivity(who, contextThread, token, target, intent, requestCode);
    }

首先调用了injectIntent方法,然后调用了原来Instrumentation的 execStartActivity,接下来我们看一下injectIntent方法做了什么事情

    protected void injectIntent(Intent intent) {
        mPluginManager.getComponentsHandler().transformIntentToExplicitAsNeeded(intent);
        // null component is an implicitly intent
        if (intent.getComponent() != null) {
            Log.i(TAG, String.format("execStartActivity[%s : %s]", intent.getComponent().getPackageName(), intent.getComponent().getClassName()));
            // resolve intent with Stub Activity if needed
            this.mPluginManager.getComponentsHandler().markIntentIfNeeded(intent);
        }
    }

首先调用transformIntentToExplicitAsNeeded 这个主要是当component为null时,根据启动Activity时,配置的action,data,category等去已加载的plugin中匹配到确定的Activity,然后把隐式的意图转化为显示意图。

当ComponentName不为空的时候会调用ComponentsHandler的markIntentIfNeeded()方法:

    public void markIntentIfNeeded(Intent intent) {
        if (intent.getComponent() == null) {
            return;
        }

        String targetPackageName = intent.getComponent().getPackageName();
        String targetClassName = intent.getComponent().getClassName();
        // search map and return specific launchmode stub activity
        if (!targetPackageName.equals(mContext.getPackageName()) && mPluginManager.getLoadedPlugin(targetPackageName) != null) {
            intent.putExtra(Constants.KEY_IS_PLUGIN, true);
            intent.putExtra(Constants.KEY_TARGET_PACKAGE, targetPackageName);
            intent.putExtra(Constants.KEY_TARGET_ACTIVITY, targetClassName);
            dispatchStubActivity(intent);
        }
    }

在该方法中判断如果启动的是插件中类,则将启动的包名和Activity类名存到了intent中。

然后调用dispatchStubActivity(intent)方法:

    private void dispatchStubActivity(Intent intent) {
        ComponentName component = intent.getComponent();
        String targetClassName = intent.getComponent().getClassName();
        LoadedPlugin loadedPlugin = mPluginManager.getLoadedPlugin(intent);
        ActivityInfo info = loadedPlugin.getActivityInfo(component);
        if (info == null) {
            throw new RuntimeException("can not find " + component);
        }
        int launchMode = info.launchMode;
        Resources.Theme themeObj = loadedPlugin.getResources().newTheme();
        themeObj.applyStyle(info.theme, true);
        String stubActivity = mStubActivityInfo.getStubActivity(targetClassName, launchMode, themeObj);
        Log.i(TAG, String.format("dispatchStubActivity,[%s -> %s]", targetClassName, stubActivity));
        intent.setClassName(mContext, stubActivity);
    }

从最后一行代码我们可以看出这里就开始替换启动的目标Activity了,这个stubActivity是由mStubActivityInfo.getStubActivity(targetClassName, launchMode, themeObj)返回的。

通过获取插件中Activity配置的Theme、launchMode来决定选择哪个占坑的Activity。

    public String getStubActivity(String className, int launchMode, Theme theme) {
        String stubActivity= mCachedStubActivity.get(className);
        if (stubActivity != null) {
            return stubActivity;
        }

        TypedArray array = theme.obtainStyledAttributes(new int[]{
                android.R.attr.windowIsTranslucent,
                android.R.attr.windowBackground
        });
        boolean windowIsTranslucent = array.getBoolean(0, false);
        array.recycle();
        if (Constants.DEBUG) {
            Log.d(Constants.TAG_PREFIX + "StubActivityInfo", "getStubActivity, is transparent theme ? " + windowIsTranslucent);
        }
        stubActivity = String.format(STUB_ACTIVITY_STANDARD, corePackage, usedStandardStubActivity);
        switch (launchMode) {
            case ActivityInfo.LAUNCH_MULTIPLE: {
                stubActivity = String.format(STUB_ACTIVITY_STANDARD, corePackage, usedStandardStubActivity);
                if (windowIsTranslucent) {
                    stubActivity = String.format(STUB_ACTIVITY_STANDARD, corePackage, 2);
                }
                break;
            }
            case ActivityInfo.LAUNCH_SINGLE_TOP: {
                usedSingleTopStubActivity = usedSingleTopStubActivity % MAX_COUNT_SINGLETOP + 1;
                stubActivity = String.format(STUB_ACTIVITY_SINGLETOP, corePackage, usedSingleTopStubActivity);
                break;
            }
                //这里省略了LAUNCH_SINGLE_TASK 和LAUNCH_SINGLE_INSTANCE
                ...

            default:break;
        }

        mCachedStubActivity.put(className, stubActivity);
        return stubActivity;
    }

可以看出主要还是通过lanuchMode来选择占坑的Activity的。

stubActivity具体格式如下:

stubActivity = String.format(STUB_ACTIVITY_STANDARD, corePackage, usedStandardStubActivity)

STUB_ACTIVITY_STANDARD值为:"%s.A$%d", corePackage值为com.didi.virtualapk.core,usedStandardStubActivity为数字值。

所以最终类名格式为:com.didi.virtualapk.core.A$1

编译后我们可以在Host module中的manifest中看到如下的内容:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UiUOt9Ot-1621934264194)(C:\Users\guojingbu\AppData\Roaming\Typora\typora-user-images\image-20210315145601450.png)]

到这里就可以看到,替换我们启动的Activity为占坑Activity,将我们原本启动的包名,类名存储到了Intent中

(2)还原activity

​ 因为欺骗过了AMS,AMS执行完成后,最终要启动的不可能是占坑Activity,还应该是我们的启动的目标Activity。

这里需要了解Activity的启动流程,如果这里流程不清楚没关系,暂时理解为最终会回调到Instrumentation的newActivity方法即可

由于Activity的启动最终回调到Instrumentation的newActivity方法,还记得我们在上面已经为VAInstrumentation了,接下来我们就直接看一下VAInstrumentation中的newActivity方法:

    @Override
    public Activity newActivity(ClassLoader cl, String className, Intent intent) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
        try {
            cl.loadClass(className);
            Log.i(TAG, String.format("newActivity[%s]", className));
            
        } catch (ClassNotFoundException e) {
            ComponentName component = PluginUtil.getComponent(intent);
            
            LoadedPlugin plugin = this.mPluginManager.getLoadedPlugin(component);
    
			//省略一些容错的处理
            
            Activity activity = mBase.newActivity(plugin.getClassLoader(), targetClassName, intent);
            activity.setIntent(intent);
    
            // for 4.1+
            Reflector.QuietReflector.with(activity).field("mResources").set(plugin.getResources());
    
            return newActivity(activity);
        }

        return newActivity(mBase.newActivity(cl, className, intent));
    }

核心就是首先从intent中取出我们的目标Activity,然后通过plugin的ClassLoader去加载(还记得在加载插件时,会生成一个LoadedPlugin对象,其中会对应其初始化一个DexClassLoader)

这样就完成了Activity的“偷梁换柱”。

接下来我们看一下回调我们Activity 的onCreate声明周期方法callActivityOnCreate:

@Override
public void callActivityOnCreate(Activity activity, Bundle icicle) {
    injectActivity(activity);
    mBase.callActivityOnCreate(activity, icicle);
}

这里我们主要关注一下injectActivity方法:

    protected void injectActivity(Activity activity) {
        final Intent intent = activity.getIntent();
        if (PluginUtil.isIntentFromPlugin(intent)) {
            Context base = activity.getBaseContext();
            try {
                LoadedPlugin plugin = this.mPluginManager.getLoadedPlugin(intent);
                Reflector.with(base).field("mResources").set(plugin.getResources());
                Reflector reflector = Reflector.with(activity);
                reflector.field("mBase").set(plugin.createPluginContext(activity.getBaseContext()));
                reflector.field("mApplication").set(plugin.getApplication());

                // set screenOrientation
                ActivityInfo activityInfo = plugin.getActivityInfo(PluginUtil.getComponent(intent));
                if (activityInfo.screenOrientation != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) {
                    activity.setRequestedOrientation(activityInfo.screenOrientation);
                }
    
                // for native activity
                ComponentName component = PluginUtil.getComponent(intent);
                Intent wrapperIntent = new Intent(intent);
                wrapperIntent.setClassName(component.getPackageName(), component.getClassName());
                activity.setIntent(wrapperIntent);
                
            } catch (Exception e) {
                Log.w(TAG, e);
            }
        }
    }

这里修改了mResources、mBase(Context)、mApplication对象。以及设置一些可动态设置的属性,这里仅设置了屏幕方向然后包装成wrapperIntent设置给目标Activity。

这里把mBase换成了PluginContext,PluginContext中会把Resources、AssetManage等换成插件中的资源。代码如下:

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

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

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

到这里Activity的启动的工作就算完成了。

接下来我们看一下它是怎么支持Service的启动的。

对service的支持

Service和Activity有点不同,首先我们也会将要启动的Service类替换为占坑的Service类,但是有一点不同,在Standard模式下多次启动同一个占坑Activity会创建多个对象来对应我们的目标类。

而Service多次启动只会调用onStartCommond方法,甚至常规多次调用bindService,seviceConn对象不变,甚至都不会多次回调bindService方法。

还有一点,最明显的差异是,Activity的生命周期是由用户交互决定的,而Service的声明周期是我们主动通过代码调用的

也就是说,start、stop、bind、unbind都是我们显示调用的,所以我们可以拦截这几个方法,做一些事情。

Virtual Apk的做法,即将所有的操作进行拦截,都改为startService,然后统一在onStartCommond中分发。

(1)hook IActivityManager

​ 我们回到PluginManager类中会看到如下代码:

   protected void hookSystemServices() {
        try {
            Singleton<IActivityManager> defaultSingleton;
    
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                defaultSingleton = Reflector.on(ActivityManager.class).field("IActivityManagerSingleton").get();
            } else {
                defaultSingleton = Reflector.on(ActivityManagerNative.class).field("gDefault").get();
            }
            IActivityManager origin = defaultSingleton.get();
            IActivityManager activityManagerProxy = (IActivityManager) Proxy.newProxyInstance(mContext.getClassLoader(), new Class[] { IActivityManager.class },
                createActivityManagerProxy(origin));

            // Hook IActivityManager from ActivityManagerNative
            Reflector.with(defaultSingleton).field("mInstance").set(activityManagerProxy);

            if (defaultSingleton.get() == activityManagerProxy) {
                this.mActivityManager = activityManagerProxy;
                Log.d(TAG, "hookSystemServices succeed : " + mActivityManager);
            }
        } catch (Exception e) {
            Log.w(TAG, e);
        }
    }

首先通过反射拿到ActivityManager 或者ActivityManagerNative类的 Singleton对象defaultSingleton,然后通过动态代理拦截修改IActivityManager中的方法,最后把代理对象设置给defaultSingleton的mInstance属性。

接下来我们应该重点关注ActivityManagerProxy这个代理方法,只要我们调用IActivityManager中的方法就会执行ActivityManagerProxy中的invoke方法:

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if ("startService".equals(method.getName())) {
            try {
                return startService(proxy, method, args);
            } catch (Throwable e) {
                Log.e(TAG, "Start service error", e);
            }
        } else if ("stopService".equals(method.getName())) {
            try {
                return stopService(proxy, method, args);
            } catch (Throwable e) {
                Log.e(TAG, "Stop Service error", e);
            }
        } else if ("stopServiceToken".equals(method.getName())) {
            try {
                return stopServiceToken(proxy, method, args);
            } catch (Throwable e) {
                Log.e(TAG, "Stop service token error", e);
            }
        } else if ("bindService".equals(method.getName())) {
            try {
                return bindService(proxy, method, args);
            } catch (Throwable e) {
                Log.w(TAG, e);
            }
        } else if ("unbindService".equals(method.getName())) {
            try {
                return unbindService(proxy, method, args);
            } catch (Throwable e) {
                Log.w(TAG, e);
            }
        } 
     	...
	
    }

从代码中可以看出startService被拦截住以后会调用ActivityManagerProxy中的startService(),代码如下:

    protected Object startService(Object proxy, Method method, Object[] args) throws Throwable {
        IApplicationThread appThread = (IApplicationThread) args[0];
        Intent target = (Intent) args[1];
        ResolveInfo resolveInfo = this.mPluginManager.resolveService(target, 0);
        if (null == resolveInfo || null == resolveInfo.serviceInfo) {
            // is host service
            return method.invoke(this.mActivityManager, args);
        }

        return startDelegateServiceForTarget(target, resolveInfo.serviceInfo, null, RemoteService.EXTRA_COMMAND_START_SERVICE);
    }

如果插件中没有我们要启动的Service 说明是我们host工程中的service,然后直接执行系统的方法;否则就会调用startDelegateServiceForTarget方法:

    protected ComponentName startDelegateServiceForTarget(Intent target, ServiceInfo serviceInfo, Bundle extras, int command) {
        Intent wrapperIntent = wrapperTargetIntent(target, serviceInfo, extras, command);
        return mPluginManager.getHostContext().startService(wrapperIntent);
    }

    protected Intent wrapperTargetIntent(Intent target, ServiceInfo serviceInfo, Bundle extras, int command) {
        // fill in service with ComponentName
        target.setComponent(new ComponentName(serviceInfo.packageName, serviceInfo.name));
        String pluginLocation = mPluginManager.getLoadedPlugin(target.getComponent()).getLocation();

        // start delegate service to run plugin service inside
        boolean local = PluginUtil.isLocalService(serviceInfo);
        Class<? extends Service> delegate = local ? LocalService.class : RemoteService.class;
        Intent intent = new Intent();
        intent.setClass(mPluginManager.getHostContext(), delegate);
        intent.putExtra(RemoteService.EXTRA_TARGET, target);
        intent.putExtra(RemoteService.EXTRA_COMMAND, command);
        intent.putExtra(RemoteService.EXTRA_PLUGIN_LOCATION, pluginLocation);
        if (extras != null) {
            intent.putExtras(extras);
        }

        return intent;
    }

这里主要是把我们要启动的目标service的一些关键数据保存了起来。然后替换启动的Service类为占坑类,通过EXTRA_COMMAND来区分我们是调用的bindService还是startService等方法。

(2)代理分发

​ 接下来我们就来看一下LocalService是怎么分发的。首先我们看一下onStartCommand中的代码:

 @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        Intent target = intent.getParcelableExtra(EXTRA_TARGET);
        int command = intent.getIntExtra(EXTRA_COMMAND, 0);
      
        ComponentName component = target.getComponent();
        LoadedPlugin plugin = mPluginManager.getLoadedPlugin(component);
        
        // ClassNotFoundException when unmarshalling in Android 5.1
        target.setExtrasClassLoader(plugin.getClassLoader());
        switch (command) {
            case EXTRA_COMMAND_START_SERVICE: {
                ActivityThread mainThread = ActivityThread.currentActivityThread();
                IApplicationThread appThread = mainThread.getApplicationThread();
                Service service;

                if (this.mPluginManager.getComponentsHandler().isServiceAvailable(component)) {
                    service = this.mPluginManager.getComponentsHandler().getService(component);
                } else {
                    try {
                        service = (Service) plugin.getClassLoader().loadClass(component.getClassName()).newInstance();

                        Application app = plugin.getApplication();
                        IBinder token = appThread.asBinder();
                        Method attach = service.getClass().getMethod("attach", Context.class, ActivityThread.class, String.class, IBinder.class, Application.class, Object.class);
                        IActivityManager am = mPluginManager.getActivityManager();

                        attach.invoke(service, plugin.getPluginContext(), mainThread, component.getClassName(), token, app, am);
                        service.onCreate();
                        this.mPluginManager.getComponentsHandler().rememberService(component, service);
                    } catch (Throwable t) {
                        return START_STICKY;
                    }
                }

                service.onStartCommand(target, 0, this.mPluginManager.getComponentsHandler().getServiceCounter(service).getAndIncrement());
                break;
            }
            case EXTRA_COMMAND_BIND_SERVICE: {
                //bindservice
                break;
            }
            case EXTRA_COMMAND_STOP_SERVICE: {
                //stopservice
                break;
            }
            case EXTRA_COMMAND_UNBIND_SERVICE: {
              	//unbindservice
                break;
            }
        }

        return START_STICKY;
    }

这里根据command类型,比如EXTRA_COMMAND_START_SERVICE,直接通过plugin的ClassLoader去load目标Service的class,然后反射创建实例。

Service创建好后,需要调用它的attach方法,然后反射调用即可,最后调用onCreate、onStartCommand。然后将其保存起来,stop的时候取出来调用其onDestroy即可。

以上就是virtualApk对service的支持整体要比activity的支持简单一些,对于BroadcastReceiver的支持更简单,接下来我们就看一下virtualApk框架对BroadcastReceiver的支持

对BroadcastReceiver的支持

这个就比较简单了,virtualApk框架会把在manifest配置文件中注册的BroadcastReceiver都会转换为动态注册,具体的代码在

LoadedPlugin的构造方法中可以看到:

public LoadedPlugin(PluginManager pluginManager, Context context, File apk) throws Exception {
	//省略与广播接收者无关的代码
    // Register broadcast receivers dynamically
    Map<ComponentName, ActivityInfo> receivers = new HashMap<ComponentName, ActivityInfo>();
    for (PackageParser.Activity receiver : this.mPackage.receivers) {
        receivers.put(receiver.getComponentName(), receiver.info);

        BroadcastReceiver br = BroadcastReceiver.class.cast(getClassLoader().loadClass(receiver.getComponentName().getClassName()).newInstance());
        for (PackageParser.ActivityIntentInfo aii : receiver.intents) {
            this.mHostContext.registerReceiver(br, aii);
        }
    }
    this.mReceiverInfos = Collections.unmodifiableMap(receivers);
    this.mPackageInfo.receivers = receivers.values().toArray(new ActivityInfo[receivers.size()]);

    // try to invoke plugin's application
    invokeApplication();
}

对ContentProvider支持

ContentProvider的支持也比较简单基本和Service的支持思想是一致的,大家可以自行分析。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值