(4.6.29.2)插件化之代码加载:启动Activity

使用ClassLoader加载外部的Dex或Apk文件,可以加载一些本地APP不存在的类,从而执行一些新的代码逻辑。

但是使用这种方法却不能直接启动插件里的Activity

这是由于,Activity等组件是需要在Manifest中注册后才能以标准Intent的方式启动的,通过ClassLoader加载并实例化的Activity实例只是一个普通的Java对象,能调用对象的方法,但是它没有生命周期,而且Activity等系统组件是需要Android的上下文环境的(Context等资源),没有这些东西Activity根本无法工作。

使用插件APK里的Activity需要解决两个问题:
1. 如何使插件APK里的Activity具有生命周期;(本篇关注重点)
2. 宿主中启动插件Activity后,如何使插件Activity使用R资源,也就是具有对应的上下文环境;(下文会讲到)

一、代理Activity模式

这种模式的特点是:主项目APK注册一个代理Activity(命名为ProxyActivity),ProxyActivity是一个普通的Activity,但只是一个空壳,自身并没有什么业务逻辑。每次打开插件APK里的某一个Activity的时候,都是在主项目里使用标准的方式启动ProxyActivity,再在ProxyActivity的生命周期里同步调用插件中的Activity实例的生命周期方法,从而执行插件APK的业务逻辑。

ProxyActivity + 没注册的Activity = 标准的Activity

一个Activity的启动,如果不采用标准的Intent方式,没有经历过Android系统Framework层级的一系列初始化和注册过程,它的生命周期方法是不会被系统调用的(除非你能够修改Android系统的一些代码,而这已经是另一个领域的话题了,可以看下第三种方式)

那我们可以在

  1. 主项目里创建一个ProxyActivity
  2. 然后,由它去代理调用插件Activity的生命周期方法**(这也是代理模式叫法的由来)。

用ProxyActivity(一个标准的Activity实例)的生命周期同步控制插件Activity(普通类的实例)的生命周期,同步的方式可以有下面两种:

  1. 在ProxyActivity生命周期里用反射调用插件Activity相应生命周期的方法,简单粗暴。
  2. 把插件Activity的生命周期抽象成接口,在ProxyActivity的生命周期里调用。
    • 另外,多了这一层接口,也方便主项目控制插件Activity。

1.1 用反射调用插件Activity相应生命周期

  • 在代理activity中去反射apk中activity的所有生命周期的方法,然后将activity的生命周期和代理activity的生命周期进行同步
    • 还反射了onActivityResult这个方法,尽管它不是典型的生命周期方法,但是它很有用
protected void instantiateLifecircleMethods(Class<?> localClass) {  
      String[] methodNames = new String[] {  
              "onRestart",  
              "onStart",  
              "onResume",  
              "onPause",  
              "onStop",  
              "onDestory"  
      };  
     for (String methodName : methodNames) {  
         Method method = null;  
         try {  
             method = localClass.getDeclaredMethod(methodName, new Class[] { });  
             method.setAccessible(true);  
         } catch (NoSuchMethodException e) {  
             e.printStackTrace();  
         }  
         mActivityLifecircleMethods.put(methodName, method);  
     }  

     Method onCreate = null;  
     try {  
         onCreate = localClass.getDeclaredMethod("onCreate", new Class[] { Bundle.class });  
         onCreate.setAccessible(true);  
     } catch (NoSuchMethodException e) {  
         e.printStackTrace();  
     }  
     mActivityLifecircleMethods.put("onCreate", onCreate);  

     Method onActivityResult = null;  
     try {  
         onActivityResult = localClass.getDeclaredMethod("onActivityResult",  
                 new Class[] { int.class, int.class, Intent.class });  
         onActivityResult.setAccessible(true);  
     } catch (NoSuchMethodException e) {  
         e.printStackTrace();  
     }  
     mActivityLifecircleMethods.put("onActivityResult", onActivityResult);  
 }  
  • 其次,同步生命周期,主要看一下onResume和onPause,其他方法是类似的。
    • 看如下代码,很好理解,就是当系统调用代理activity生命周期方法的时候,就通过反射去显式调用apk中activity的对应方法
    • 首先通过类加载器去加载apk中activity的类并创建一个新对象,然后通过反射去调用这个对象的setProxy方法和onCreate方法,setProxy方法的作用是将activity内部的执行全部交由宿主程序中的proxy(也是一个activity),onCreate方法是activity的入口,setProxy以后就调用onCreate方法,这个时候activity就被调起来了
public class ProxyActivity extends Activity {  

    private static final String TAG = "ProxyActivity";  

    public static final String FROM = "extra.from";  
    public static final int FROM_EXTERNAL = 0;  
    public static final int FROM_INTERNAL = 1;  

    public static final String EXTRA_DEX_PATH = "extra.dex.path";  
    public static final String EXTRA_CLASS = "extra.class";  

    private HashMap<String, Method> mActivityLifecircleMethods; 

    private String mClass;  
    private String mDexPath;  

    @Override  
    protected void onCreate(Bundle savedInstanceState) {  
        super.onCreate(savedInstanceState);  
        mDexPath = getIntent().getStringExtra(EXTRA_DEX_PATH);  
        mClass = getIntent().getStringExtra(EXTRA_CLASS);  

        Log.d(TAG, "mClass=" + mClass + " mDexPath=" + mDexPath);  
        if (mClass == null) {  
            onCreateActivity();  
        } else {  
            onCreateActivity(mClass);  
        }  
    }  

    //采用DexClassLoader去加载apk,然后如果没有指定class,就调起主activity,否则调起指定的class。
    @SuppressLint("NewApi")  
    protected void onCreateActivity() {  
        PackageInfo packageInfo = getPackageManager().getPackageArchiveInfo(  
                mDexPath, 1);  
        if ((packageInfo.activities != null)  
                && (packageInfo.activities.length > 0)) {  
            String activityName = packageInfo.activities[0].name;  
            mClass = activityName;  
            launchTargetActivity(mClass);  
        }  
    }  

    @SuppressLint("NewApi")  
    protected void onCreateActivity(final String className) {  
        Log.d(TAG, "start launchTargetActivity, className=" + className);  
        File dexOutputDir = this.getDir("dex", 0);  
        final String dexOutputPath = dexOutputDir.getAbsolutePath();  
        ClassLoader localClassLoader = ClassLoader.getSystemClassLoader();  
        DexClassLoader dexClassLoader = new DexClassLoader(mDexPath,  
                dexOutputPath, null, localClassLoader);  
        try {  
            Class<?> localClass = dexClassLoader.loadClass(className);  

            //【核心】:初始化各种 反射函数
            instantiateLifecircleMethods(localClass);

            //【核心】:构建 插件Activity实例
            Constructor<?> localConstructor = localClass  
                    .getConstructor(new Class[] {});  
            Object instance = localConstructor.newInstance(new Object[] {});  
            Log.d(TAG, "instance = " + instance);  

            //【核心】:向 插件Activity实例 中注入 真实的Activity实例,以用于调用跳转和setContentView等函数 
            Method setProxy = localClass.getMethod("setProxy",  
                    new Class[] { Activity.class });  
            setProxy.setAccessible(true);  
            setProxy.invoke(instance, new Object[] { this });  

            //【生命周期】:
            Method onCreate = mActivityLifecircleMethods.get("onCreate");   
            onCreate.setAccessible(true);  
            Bundle bundle = new Bundle();  
            bundle.putInt(FROM, FROM_EXTERNAL);  
            onCreate.invoke(instance, new Object[] { bundle });  
        } catch (Exception e) {  
            e.printStackTrace();  
        }  
    }  



    @Override  
    protected void onResume() {  
          super.onResume();  
          //【生命周期】:
          Method onResume = mActivityLifecircleMethods.get("onResume");  
          if (onResume != null) {  
              try {  
                  onResume.invoke(mRemoteActivity, new Object[] { });  
              } catch (Exception e) {  
                  e.printStackTrace();  
             }  
         }  
     }  

     @Override  
     protected void onPause() {  
         //【生命周期】:
         Method onPause = mActivityLifecircleMethods.get("onPause");  
         if (onPause != null) {  
             try {  
                 onPause.invoke(mRemoteActivity, new Object[] { });  
             } catch (Exception e) {  
                 e.printStackTrace();  
             }  
         }  
         super.onPause();  
     } 

} 
  • 插件Activity的注意事项
    • 对是否使用代理进行了判断,如果不使用代理,那么activity的逻辑仍然按照正常的方式执行,也就是说,这个apk既可以按照执行,也可以由宿主程序来执行
    • 可以写在BaseActivity中
    • 其中setProxy方法的作用就是为了让宿主程序能够接管自己的执行,一旦被接管以后,其所有的执行均通过proxy,且Context也变成了宿主程序的Context
public class PlugBaseActivity extends Activity {  

    private static final String TAG = "Client-BaseActivity";  

    public static final String FROM = "extra.from";  
    public static final int FROM_EXTERNAL = 0;  
    public static final int FROM_INTERNAL = 1;  
    public static final String EXTRA_DEX_PATH = "extra.dex.path";  
    public static final String EXTRA_CLASS = "extra.class";  

    public static final String PROXY_VIEW_ACTION = "com.ryg.dynamicloadhost.VIEW";  
    public static final String DEX_PATH = "/mnt/sdcard/DynamicLoadHost/plugin.apk";  

    protected Activity mProxyActivity;  
    protected int mFrom = FROM_INTERNAL;  

    // 【核心】
    public void setProxy(Activity proxyActivity) {  
        Log.d(TAG, "setProxy: proxyActivity= " + proxyActivity);  
        mProxyActivity = proxyActivity;  
    }  

    @Override  
    protected void onCreate(Bundle savedInstanceState) {  
        if (savedInstanceState != null) {  
            mFrom = savedInstanceState.getInt(FROM, FROM_INTERNAL);  
        }  
        if (mFrom == FROM_INTERNAL) {  
            super.onCreate(savedInstanceState);  
            mProxyActivity = this;  
        }  
        Log.d(TAG, "onCreate: from= " + mFrom);  
    }  

    protected void startActivityByProxy(String className) {  
        if (mProxyActivity == this) {  
            Intent intent = new Intent();  
            intent.setClassName(this, className);  
            this.startActivity(intent);  
        } else {  
            Intent intent = new Intent(PROXY_VIEW_ACTION);  
            intent.putExtra(EXTRA_DEX_PATH, DEX_PATH);  
            intent.putExtra(EXTRA_CLASS, className);  
            mProxyActivity.startActivity(intent);  
        }  
    }  

    @Override  
    public void setContentView(View view) {  
        if (mProxyActivity == this) {  
            super.setContentView(view);  
        } else {  
            mProxyActivity.setContentView(view);  
        }  
    }  

    @Override  
    public void setContentView(View view, LayoutParams params) {  
        if (mProxyActivity == this) {  
            super.setContentView(view, params);  
        } else {  
            mProxyActivity.setContentView(view, params);  
        }  
    }  

    @Deprecated  
    @Override  
    public void setContentView(int layoutResID) {  
        if (mProxyActivity == this) {  
            super.setContentView(layoutResID);  
        } else {  
            mProxyActivity.setContentView(layoutResID);  
        }  
    }  

    @Override  
    public void addContentView(View view, LayoutParams params) {  
        if (mProxyActivity == this) {  
            super.addContentView(view, params);  
        } else {  
            mProxyActivity.addContentView(view, params);  
        }  
    }  
}  


public class PlugAcitivty extends PlugBaseActivity {  

    private static final String TAG = "Client-MainActivity";  

    @Override  
    protected void onCreate(Bundle savedInstanceState) {  
        super.onCreate(savedInstanceState);  
        initView(savedInstanceState);  
    }  

    private void initView(Bundle savedInstanceState) {  
        mProxyActivity.setContentView(generateContentView(mProxyActivity));  
    }  

    //即便是 PlugAcitivty, 也不能直接访问 插件中的R资源,因为是使用的主app的上下文,只访问主app的资源,具体解决办法见后续文章
    //暂时可以使用 动态代码生成方式
    private View generateContentView(final Context context) {  
      Button button = new Button(mProxyActivity);  
      button.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT,  
                LayoutParams.MATCH_PARENT));  
      button.setBackgroundColor(Color.YELLOW);  
      button.setText("这是测试页面");  
      setContentView(button);  
    }

}   

1.2 把插件Activity的生命周期抽象成接口,在ProxyActivity的生命周期里调用

用反射去管理activity的生命周期,这样存在一些不便,比如反射代码写起来复杂,并且过多使用反射有一定的性能开销。

针对这个问题,可以采用接口机制,将activity的大部分生命周期方法提取出来作为一个接口(DLPlugin),然后通过代理activity(DLProxyActivity)去调用插件activity实现的生命周期方法,这样就完成了插件activity的生命周期管理,并且没有采用反射

当我们想增加一个新的生命周期方法的时候,只需要在接口中声明一下同时在代理activity中实现一下即可.

public interface DLPlugin {

    public void onStart();
    public void onRestart();
    public void onActivityResult(int requestCode, int resultCode, Intent data);
    public void onResume();
    public void onPause();
    public void onStop();
    public void onDestroy();
    public void onCreate(Bundle savedInstanceState);
    public void setProxy(Activity proxyActivity, String dexPath);
    public void onSaveInstanceState(Bundle outState);
    public void onNewIntent(Intent intent);
    public void onRestoreInstanceState(Bundle savedInstanceState);
    public boolean onTouchEvent(MotionEvent event);
    public boolean onKeyUp(int keyCode, KeyEvent event);
    public void onWindowAttributesChanged(LayoutParams params);
    public void onWindowFocusChanged(boolean hasFocus);
 } 
  • 在主app的代理类DLProxyActivity中,由于插件PlugActivity一定继承了DLPlugin,可以直接调用
    DLPlugin mRemoteActivity;

    @SuppressLint("NewApi")  
    protected void onCreateActivity(final String className, Bundle bundle) {  
        Log.d(TAG, "start launchTargetActivity, className=" + className);  
        File dexOutputDir = this.getDir("dex", 0);  
        final String dexOutputPath = dexOutputDir.getAbsolutePath();  
        ClassLoader localClassLoader = ClassLoader.getSystemClassLoader();  
        DexClassLoader dexClassLoader = new DexClassLoader(mDexPath,  
                dexOutputPath, null, localClassLoader);  
        try {  
            Class<?> localClass = dexClassLoader.loadClass(className);  


            //【核心】:构建 插件Activity实例
            Constructor<?> localConstructor = localClass  
                    .getConstructor(new Class[] {});  
            mRemoteActivity = localConstructor.newInstance(new Object[] {});  
            Log.d(TAG, "instance = " + mRemoteActivity);  

            //【核心】:向 插件Activity实例 中注入 真实的Activity实例,以用于调用跳转和setContentView等函数 
            mRemoteActivity.setProxy(this, dexpath);  

            //【生命周期】:
            mRemoteActivity.onCreate(bundle);  
        } catch (Exception e) {  
            e.printStackTrace();  
        }  
    }  

    @Override
    protected void onStart() {
        mRemoteActivity.onStart();
        super.onStart();
    }

    @Override
    protected void onRestart() {
        mRemoteActivity.onRestart();
        super.onRestart();
    }

    @Override
    protected void onResume() {
        mRemoteActivity.onResume();
        super.onResume();
    }

    @Override
    protected void onPause() {
        mRemoteActivity.onPause();
        super.onPause();
    }

    @Override
    protected void onStop() {
        mRemoteActivity.onStop();
        super.onStop();
    }

二、动态创建Activity

我们在代理Activity模式一文里谈到启动插件APK里的Activity的两个难题吗,由于插件里的Activity没在主项目的Manifest里面注册,所以无法经历系统Framework层级的一系列初始化过程,最终导致获得的Activity实例并没有生命周期和无法使用res资源。

使用代理Activity能够解决这两个问题,但是有一些限制

  1. 实际运行的Activity实例其实都是ProxyActivity,并不是真正想要启动的Activity;
  2. ProxyActivity只能指定一种LaunchMode,所以插件里的Activity无法自定义LaunchMode;
  3. 不支持静态注册的BroadcastReceiver;
  4. 往往不是所有的apk都可作为插件被加载,插件项目需要依赖特定的框架,还有需要遵循一定的”开发规范”;

特别是最后一个,无法直接把一个普通的APK作为插件使用

怎么避开这些限制呢?代理模式需要注册一个代理的ProxyActivity,那么能不能在主项目里注册一个通用的Activity(比如TargetActivity)给插件里所有的Activity用呢?

解决对策就是:

  1. 在需要启动插件的某一个Activity(比如PlugActivity)的时候,动态创建一个TargetActivity

    • 新创建的TargetActivity会继承插件PlugActivity的所有行为
  2. 这个TargetActivity的包名与类名刚好与我们事先在主app中注册的TargetActivity一致,我们就能以标准的方式启动这个Activity

2.1 dexmaker 和 asmdex 动态生成类

运行时动态创建并编译一个Activity类,这种想法不是天方夜谭,动态创建类的工具有asmdexdexmaker

二者均能实现动态字节码操作,最大的区别是前者是创建dex文件,而后者是创建class文件

2.1.1 使用dexmaker动态创建一个类

运行时创建一个编译好并能运行的类叫做“动态字节码操作(runtime bytecode manipulation)”,使用dexmaker工具能创建一个dex文件,之后我们再反编译这个dex看看创建出来的类是什么样子。

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    public void onMakeDex(View view){
        try {
            DexMaker dexMaker = new DexMaker();
            // Generate a HelloWorld class.
            TypeId<?> helloWorld = TypeId.get("LHelloWorld;");
            dexMaker.declare(helloWorld, "HelloWorld.generated", Modifier.PUBLIC, TypeId.OBJECT);
            generateHelloMethod(dexMaker, helloWorld);
            // Create the dex file and load it.
            File outputDir = new File(Environment.getExternalStorageDirectory() + File.separator + "dexmaker");
            if (!outputDir.exists())outputDir.mkdir();
            ClassLoader loader = dexMaker.generateAndLoad(this.getClassLoader(), outputDir);
            Class<?> helloWorldClass = loader.loadClass("HelloWorld");
            // Execute our newly-generated code in-process.
            helloWorldClass.getMethod("hello").invoke(null);
        } catch (Exception e) {
            Log.e("MainActivity","[onMakeDex]",e);
        }
    }

    /**
     * Generates Dalvik bytecode equivalent to the following method.
     *    public static void hello() {
     *        int a = 0xabcd;
     *        int b = 0xaaaa;
     *        int c = a - b;
     *        String s = Integer.toHexString(c);
     *        System.out.println(s);
     *        return;
     *    }
     */
    private static void generateHelloMethod(DexMaker dexMaker, TypeId<?> declaringType) {
        // Lookup some types we'll need along the way.
        TypeId<System> systemType = TypeId.get(System.class);
        TypeId<PrintStream> printStreamType = TypeId.get(PrintStream.class);

        // Identify the 'hello()' method on declaringType.
        MethodId hello = declaringType.getMethod(TypeId.VOID, "hello");

        // Declare that method on the dexMaker. Use the returned Code instance
        // as a builder that we can append instructions to.
        Code code = dexMaker.declare(hello, Modifier.STATIC | Modifier.PUBLIC);

        // Declare all the locals we'll need up front. The API requires this.
        Local<Integer> a = code.newLocal(TypeId.INT);
        Local<Integer> b = code.newLocal(TypeId.INT);
        Local<Integer> c = code.newLocal(TypeId.INT);
        Local<String> s = code.newLocal(TypeId.STRING);
        Local<PrintStream> localSystemOut = code.newLocal(printStreamType);

        // int a = 0xabcd;
        code.loadConstant(a, 0xabcd);

        // int b = 0xaaaa;
        code.loadConstant(b, 0xaaaa);

        // int c = a - b;
        code.op(BinaryOp.SUBTRACT, c, a, b);

        // String s = Integer.toHexString(c);
        MethodId<Integer, String> toHexString
                = TypeId.get(Integer.class).getMethod(TypeId.STRING, "toHexString", TypeId.INT);
        code.invokeStatic(toHexString, s, c);

        // System.out.println(s);
        FieldId<System, PrintStream> systemOutField = systemType.getField(printStreamType, "out");
        code.sget(systemOutField, localSystemOut);
        MethodId<PrintStream, Void> printlnMethod = printStreamType.getMethod(
                TypeId.VOID, "println", TypeId.STRING);
        code.invokeVirtual(printlnMethod, null, localSystemOut, s);

        // return;
        code.returnVoid();
    }

}

点击button触发onMakeDex(),我们会在在SD卡的dexmaker目录下找到刚创建的文件“Generated1.jar”

把里面的“classes.dex”解压出来,然后再用“dex2jar”工具转化成jar文件,最后再用“jd-gui”工具反编译jar的源码

这里写图片描述
【图Generated1.jar】

至此,已经成功在运行时创建一个编译好的类

2.2 修改需要启动的目标Activity

接下来的问题是如何把需要启动的、在Manifest里面没有注册的PlugActivity换成主app中有注册的TargetActivity。

在Android,虚拟机加载类的时候,是通过ClassLoader的loadClass方法,而loadClass方法并不是final类型的,这意味着我们可以创建自己的类去继承ClassLoader,以重载loadClass方法并改写类的加载逻辑,在需要加载PlugActivity的时候,偷偷把其换成TargetActivity。

public class CJClassLoader extends ClassLoader{

    @override
    public Class loadClass(String className){
        if(当前上下文插件不为空) {
            if( className 是 TargetActivity){
                找到当前实际要加载的原始PlugActivity,动态创建类(TargetActivity extends PlugActivity )的dex文件
                return  从dex文件中加载的TargetActivity
            }else{
                return  使用对应的PluginClassLoader加载普通类
            }
        }else{
            return super.loadClass() //使用原来的类加载方法
        }
    }
}

不过还有一个问题,主项目启动插件Activity的时候,我们可以替换Activity.

但是如果在插件Activity(比如 PlugActivity )启动另一个Activity(Plug2Activity)的时候怎么办?插件是普通的第三方APK,我们无法更改里面跳转Activity的逻辑

其实,从主项目启动插件PlugActivity的时候,其实启动的是我们动态创建的TargetActivity(extends PlugActivity),而我们知道Activity启动另一个Activity的时候都是使用其“startActivityForResult”方法,所以我们可以在创建TargetActivity时,重写其“startActivityForResult”方法,让它在启动其他Activity的时候,也采用动态创建Activity的方式

就能实现 插件Activity 到 插件Activity之间的跳转

2.3 动态类创建Activity缺陷

动态类创建的方式,使得注册一个通用的Activity就能给多给Activity使用,对这种做法存在的问题也是明显的

  1. 使用同一个注册的Activity,所以一些需要在Manifest注册的属性无法做到每个Activity都自定义配置;
  2. 插件中的权限,无法动态注册,插件需要的权限都得在宿主中注册,无法动态添加权限;
  3. 插件的Activity无法开启独立进程,因为这需要在Manifest里面注册;
  4. 动态字节码操作涉及到Hack开发,所以相比代理模式起来不稳定;

其中不稳定的问题出现在对Service的支持上,使用动态创建类的方式可以搞定Activity和Broadcast Receiver,但是使用类似的方式处理Service却不行,因为“ContextImpl.getApplicationContext” 期待得到一个非ContextWrapper的context,如果不是则继续下次循环,目前的Context实例都是wrapper,所以会进入死循环。

推荐一个动态代理的开源项目:houkx/android-pluginmgr

三、总结

  • 代理Activity模式与动态创建Activity模式的区别简单地说,最大的不同是代理模式使用了一个代理的Activity,而动态创建Activity模式使用了一个通用的Activity

代理模式中,使用一个代理Activity去完成本应该由插件Activity完成的工作,这个代理Activity是一个标准的Android Activity组件,具有生命周期和上下文环境(ContextWrapper和ContextCompl),但是它自身只是一个空壳,并没有承担什么业务逻辑;而插件Activity其实只是一个普通的Java对象,它没有上下文环境,但是却能正常执行业务逻辑的代码。代理Activity和不同的插件Activity配合起来,就能完成不同的业务逻辑了。所以代理模式其实还是使用常规的Android开发技术,只是在处理插件资源的时候强制调用了系统的隐藏API,因此这种模式还是可以稳定工作和升级的。

动态创建Activity模式,被动态创建出来的Activity类是有在主项目里面注册的,它是一个标准的Activity,它有自己的Context和生命周期,不需要代理的Activity

  • hook伪注册方式则是通过hook framework层的java代码进行实现, 绕过相关校验,但是可能存在一定的风险

参考文献

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值