【Android IPC】Binder浅析

一、Binder上层原理

1、Binder是啥?

从不同的角度理解有不同的说法

(1)Binder是Android中的一个类,它实现了IBinder接口

public class Binder implements IBinder {
...
}

(2)从IPC角度来说,Binder是Android中特有的一种跨进程通信方式。Aidl、Messenger、ContentProvider 底层就是基于Binder实现的。

(3)Binder还可以理解为一种虚拟的物理设备,它的设备驱动是/dev/binder,该通信方式在Linux中没有。

(4)从AndroidFramework角度来说,Binder是ServiceManager连接各种Manager(如ActivityManager、WindowManager,等等)和相应ManagerService的桥梁。

(5)从Android应用层来说,Binder是客户端和服务端进行通信的媒介。

当bindService的时候,服务端会返回一个包含了服务端业务调用的Binder对象,通过这个Binder对象,客户端就可以获取服务端提供的服务或者数据。

2、简单的AIDL栗子

Sever端App提供服务:


const val tag = "MyService"
class MyService : Service() {

    override fun onBind(intent: Intent): IBinder {
        return MyBinder()
    }

    class MyBinder : MusicManager.Stub() {
        override fun playMusic() {
          Log.i(tag,"playMusic()")
        }

        override fun stopMusic() {
            Log.i(tag,"stopMusic()")
        }

        override fun pauseMusic() {
            Log.i(tag,"pauseMusic()")
        }

    }
}

Client App调用服务功能

public class MainActivity extends AppCompatActivity {
    MusicManager musicManager;
    private ServiceConnection conn;
    private Intent intent;

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

         //隐式启动
        intent = new Intent();
        intent.setAction("com.sunnyday.administrator.servicedemo.services.MusicService");
        intent.setPackage("com.sunnyday.administrator.servicedemo");

        conn = new MyServiceConnection();
    }

    // 绑定按钮
    public void bindRemoteService(View view) {
        bindService(intent, conn, BIND_AUTO_CREATE);
    }

     // 播放按钮
    public void PlayMusic(View view) {

        try {
            if (null != musicManager) {
                Log.i("233", "test: ");
                musicManager.playMusic();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private class MyServiceConnection implements ServiceConnection {

        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            // 获得实例
            musicManager = MusicManager.Stub.asInterface(service);
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {

        }
    }
}

3、AIDl底层Binder实现

项目工程buidl文件下找AIDL 文件对应的java文件进行分析

(1)MusicManager接口很简单。

1、它继承了IInterface这个接口,同时它自己也还是个接口,所有可以在Binder中传输的接口都需要继承IInterface接口。
2、声明了几个接口方法,这几个方法都是我们在Aidl文件中声明过的。
3、定义了个内部类Stub作为接口成员变量。

public interface MusicManager extends android.os.IInterface {

    public static abstract class Stub extends android.os.Binder implements com.example.myservice.MusicManager {
   ...
 }   
    public void playMusic() throws android.os.RemoteException;

    public void stopMusic() throws android.os.RemoteException;

    public void pauseMusic() throws android.os.RemoteException;
}

扩展

public interface IInterface{
    public IBinder asBinder();
}
public class Binder implements IBinder {
...
}

(2)MusicManager#Stub 内部类

    //1、
    public static abstract class Stub extends android.os.Binder implements com.example.myservice.MusicManager {
      // 2、三个int 常量值:标识TRANSACTION (trænˈzækʃn)过程中客户端请求的是哪个方法。
        static final int TRANSACTION_playMusic = (android.os.IBinder.FIRST_CALL_TRANSACTION + 0);
        static final int TRANSACTION_stopMusic = (android.os.IBinder.FIRST_CALL_TRANSACTION + 1);
        static final int TRANSACTION_pauseMusic = (android.os.IBinder.FIRST_CALL_TRANSACTION + 2);
        
		//3、DESCRIPTOR Binder的唯一标识。
		//一般用当前类的全限定名标识(包名+类名)
        private static final java.lang.String DESCRIPTOR = "com.example.myservice.MusicManager";

        public Stub() {
            this.attachInterface(this, DESCRIPTOR);
        }

        /*
		 4、将服务端的IBnid对象转换为客户端所需要的aidl接口对象。
		 这个方法区分进程:
		 1、客户端、服务端同一进程:此方法反回服务端Stub对象本身。
		 2、客户端、服务端不同进程:此方法返回系统封装后的Stub.Proxy对象。
         */
        public static com.example.myservice.MusicManager asInterface(android.os.IBinder obj) {
            if ((obj == null)) {
                return null;
            }
            android.os.IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
            if (((iin != null) && (iin instanceof com.example.myservice.MusicManager))) {
                return ((com.example.myservice.MusicManager) iin);
            }
            return new com.example.myservice.MusicManager.Stub.Proxy(obj);
        }

        //5、返回当前Binder对象(Stub)
        @Override
        public android.os.IBinder asBinder() {
            return this;
        }

        /*
           6、onTransact
		   (1)、此方法运行在服务端的Binder线程池中
		   
		   (2)、当客户端发起夸进程请求时远程请求会交给系统底层封装后交由此方法处理。
		   
		   方法参数:
		   
		   code:服务端通过code可以知道客户端要请求的方法是哪个
		   
		   data:目标方法所需的参数(如果目标方法有参数的话)
		   
		   reply:目标方法执行后的返回值(如果目标方法有返回值的话)
		   
		   onTransact返回值:返回false客户端请求会失败,即无客户端要执行的目标方法。

          ps:因此我们可以利用这个特性来做权限验证,毕竟我们也不希望随便一个进程都能
              远程调用我们的服务
		
		*/
        @Override
        public boolean onTransact(int code, android.os.Parcel data, android.os.Parcel reply, int flags) throws android.os.RemoteException {
            java.lang.String descriptor = DESCRIPTOR;
            switch (code) {
                case INTERFACE_TRANSACTION: {
                    reply.writeString(descriptor);
                    return true;
                }
                case TRANSACTION_playMusic: {
                    data.enforceInterface(descriptor);
                    this.playMusic();
                    reply.writeNoException();
                    return true;
                }
                case TRANSACTION_stopMusic: {
                    data.enforceInterface(descriptor);
                    this.stopMusic();
                    reply.writeNoException();
                    return true;
                }
                case TRANSACTION_pauseMusic: {
                    data.enforceInterface(descriptor);
                    this.pauseMusic();
                    reply.writeNoException();
                    return true;
                }
                default: {
                    return super.onTransact(code, data, reply, flags);
                }
            }
        }

      
      //Stub的内部类,代理类。
	  private static class Proxy implements com.example.myservice.MusicManager {
         ...
      }

        
   }
}

1、这个Stub就是一个Binder类,当客户端和服务端都位于同一个进程时,方法调用不会走跨进程的transact过程,而当两者位于不同进程时,方法调用需要走transact过程,这个逻辑由Stub的内部代理类Proxy来完成.。

2、Stub类中声明了一些int类型常量字段,这些字段用于标识Aidl文件对应的接口方法。
3、DESCRIPTOR:Binder的唯一标识,一般用当前Binder的类名表示。

(3)Stub#Proxy 代理类

     //1、代理类也实现了被代理类的MusicManager接口
	  private static class Proxy implements com.example.myservice.MusicManager {
           //2、持有被代理类对象(Binder)
            private android.os.IBinder mRemote;

            Proxy(android.os.IBinder remote) {
                mRemote = remote;
            }

            @Override
            public android.os.IBinder asBinder() {
                return mRemote;
            }

            public java.lang.String getInterfaceDescriptor() {
                return DESCRIPTOR;
            }

            /*
			  3、方法运行在客户端,当客户端远程调用此方法时,具体的工作流程如下:
			  (1)、创建Parcel 类型输入对象_data输出对象_reply
			  (方法如有返回值还会创建返回值对象)
			  (2)、把方法信息写入_data
			  (3)、调用transact发起IPC(同时当前线程挂起)
			  (4)、接着服务端的onTransact会被调用直到IPC返回数据。
			  (5)、服务端onTransact返回数据后客户端的_reply开始读取RPC返回结果
			  
			  需要注意:
			    (1)、尽量不要在UI线程中发起IPC 因为发起IPC时会挂起当前线程,
			      且当远程服务的方法有耗时的逻辑是UI线程会ANR
				
				(2)、由于服务端的Binder方法运行在Binder线程池中。
				       所以Binder中的方法尽量采取同步实现(加锁)。
				       因为他已经运行在一个线程中了。
			
			*/ 
            @Override
            public void playMusic() throws android.os.RemoteException {
                android.os.Parcel _data = android.os.Parcel.obtain();
                android.os.Parcel _reply = android.os.Parcel.obtain();
                try {
                    _data.writeInterfaceToken(DESCRIPTOR);
                    mRemote.transact(Stub.TRANSACTION_playMusic, _data, _reply, 0);
                    _reply.readException();
                } finally {
                    _reply.recycle();
                    _data.recycle();
                }
            }

            @Override
            public void stopMusic() throws android.os.RemoteException {
                android.os.Parcel _data = android.os.Parcel.obtain();
                android.os.Parcel _reply = android.os.Parcel.obtain();
                try {
                    _data.writeInterfaceToken(DESCRIPTOR);
                    mRemote.transact(Stub.TRANSACTION_stopMusic, _data, _reply, 0);
                    _reply.readException();
                } finally {
                    _reply.recycle();
                    _data.recycle();
                }
            }

            @Override
            public void pauseMusic() throws android.os.RemoteException {
                android.os.Parcel _data = android.os.Parcel.obtain();
                android.os.Parcel _reply = android.os.Parcel.obtain();
                try {
                    _data.writeInterfaceToken(DESCRIPTOR);
                    mRemote.transact(Stub.TRANSACTION_pauseMusic, _data, _reply, 0);
                    _reply.readException();
                } finally {
                    _reply.recycle();
                    _data.recycle();
                }
            }
        }

(4)流程小结

在这里插入图片描述

4、Binder死亡

Binder运行在服务端进程,如果服务端进程由于某种原因异常终止,这个时候我们到服务端的Binder连接断裂称之为Binder死亡。

Binder死亡会导致我们的远程调用失败。如果我们不知道Binder连接已经断裂,那么客户端的功能就会受到影响。

为了解决Binder死亡问题,Binder中提供了两个配对的方法linkToDeath和unlinkToDeath,通过linkToDeath我们可以给Binder设置一个死亡代理,当Binder死亡时,我们就会收到通知,这个时候我们就可以重新发起连接请求从而恢复连接。

(1)做法

声明一个DeathRecipient对象。DeathRecipient是一个接口,其内部只有一个方法binderDied,我们需要实现这个方法,当Binder死亡的时候,系统就会回调binderDied方法,然后我们就可以移出之前绑定的binder代理并重新绑定远程服务。

    private IBinder.DeathRecipient mDeathRecipient = new IBinder.DeathRecipient(){

        @Override
        public void binderDied() {
            if (musicManager==null)return;
            //1、binder 死亡时 客户端unlinkToDeath
            musicManager.asBinder().unlinkToDeath(mDeathRecipient, 0);
            //置空处理
            musicManager =null;
            //2、todo 客户端可请求重新远程绑定服务
        }
    };

其次,在客户端绑定远程服务成功后,给binder设置死亡代理

        override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
            musicManager = IMusicManager.Stub.asInterface(service)
            // 设置死亡代理            
            service.linkToDeath(mDeathRecipient,0)
            
        }

其中linkToDeath的第二个参数是个标记位,我们直接设为0即可。经过上面两个步骤,就给我们的Binder设置了死亡代理,当Binder死亡的时候我们就可以收到通知了。另外,通过Binder的方法isBinderAlive也可以判断Binder是否死亡。

二、Binder上层原理加深理解-手写Binder

其实观察上文的AIDl生成的文件还是很有规律的,在上文中我的讲解顺序也是按照规律来讲解的即结构上由外到内。

1、声明个接口继承IInterface
public interface MusicManager extends android.os.IInterface {
    // 调整下DESCRIPTOR位置
    public static final java.lang.String DESCRIPTOR = "com.example.myservice.MusicManager";
   
    public void playMusic() throws android.os.RemoteException;

    public void stopMusic() throws android.os.RemoteException;

    public void pauseMusic() throws android.os.RemoteException;
}
2、仿写Stub类

定义MusicManager 接口实现类,继承Binder类

    public static abstract class MusicManagerImpl extends android.os.Binder implements com.example.myservice.MusicManager {

        static final int TRANSACTION_playMusic = (android.os.IBinder.FIRST_CALL_TRANSACTION + 0);
        static final int TRANSACTION_stopMusic = (android.os.IBinder.FIRST_CALL_TRANSACTION + 1);
        static final int TRANSACTION_pauseMusic = (android.os.IBinder.FIRST_CALL_TRANSACTION + 2);
        

        public MusicManagerImpl () {
            this.attachInterface(this, DESCRIPTOR);
        }

        public static com.example.myservice.MusicManager asInterface(android.os.IBinder obj) {
            if ((obj == null)) {
                return null;
            }
            android.os.IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
            if (((iin != null) && (iin instanceof com.example.myservice.MusicManager))) {
                return ((com.example.myservice.MusicManager) iin);
            }
            return new com.example.myservice.MusicManager.MusicManagerImpl .Proxy(obj);
        }

        @Override
        public android.os.IBinder asBinder() {
            return this;
        }
        @Override
        public boolean onTransact(int code, android.os.Parcel data, android.os.Parcel reply, int flags) throws android.os.RemoteException {
            java.lang.String descriptor = DESCRIPTOR;
            switch (code) {
                case INTERFACE_TRANSACTION: {
                    reply.writeString(descriptor);
                    return true;
                }
                case TRANSACTION_playMusic: {
                    data.enforceInterface(descriptor);
                    this.playMusic();
                    reply.writeNoException();
                    return true;
                }
                case TRANSACTION_stopMusic: {
                    data.enforceInterface(descriptor);
                    this.stopMusic();
                    reply.writeNoException();
                    return true;
                }
                case TRANSACTION_pauseMusic: {
                    data.enforceInterface(descriptor);
                    this.pauseMusic();
                    reply.writeNoException();
                    return true;
                }
                default: {
                    return super.onTransact(code, data, reply, flags);
                }
            }
        }     
        
   }
}
3、仿写Stub代理类

	  private static class Proxy implements com.example.myservice.MusicManager {
	  
            private android.os.IBinder mRemote;

            Proxy(android.os.IBinder remote) {
                mRemote = remote;
            }

            @Override
            public android.os.IBinder asBinder() {
                return mRemote;
            }

            public java.lang.String getInterfaceDescriptor() {
                return DESCRIPTOR;
            }

            @Override
            public void playMusic() throws android.os.RemoteException {
                android.os.Parcel _data = android.os.Parcel.obtain();
                android.os.Parcel _reply = android.os.Parcel.obtain();
                try {
                    _data.writeInterfaceToken(DESCRIPTOR);
                    mRemote.transact(MusicManagerImpl .TRANSACTION_playMusic, _data, _reply, 0);
                    _reply.readException();
                } finally {
                    _reply.recycle();
                    _data.recycle();
                }
            }

            @Override
            public void stopMusic() throws android.os.RemoteException {
                android.os.Parcel _data = android.os.Parcel.obtain();
                android.os.Parcel _reply = android.os.Parcel.obtain();
                try {
                    _data.writeInterfaceToken(DESCRIPTOR);
                    mRemote.transact(MusicManagerImpl .TRANSACTION_stopMusic, _data, _reply, 0);
                    _reply.readException();
                } finally {
                    _reply.recycle();
                    _data.recycle();
                }
            }

            @Override
            public void pauseMusic() throws android.os.RemoteException {
                android.os.Parcel _data = android.os.Parcel.obtain();
                android.os.Parcel _reply = android.os.Parcel.obtain();
                try {
                    _data.writeInterfaceToken(DESCRIPTOR);
                    mRemote.transact(MusicManagerImpl .TRANSACTION_pauseMusic, _data, _reply, 0);
                    _reply.readException();
                } finally {
                    _reply.recycle();
                    _data.recycle();
                }
            }
        }

总结:

1、上述写法和Aidl生成方式几乎没啥区别
2、可见实现IPC并不一定使用aidl,我们完全可以手写。只是使用aidl方式系统可快速帮我们生成相应文件。

三、Binder连接池

1、不同的业务模块都需要使用AIDL来进行进程间通信,那该怎么处理呢?

随着项目的迭代,公司的项目越来越庞大了。现在有n个不同的业务模块都需要使用AIDL来进行进程间通信,那我们该怎么处理呢?也许你会说:“就按照AIDL的实现方式一个个来吧”,这是可以的。

如果用这种方法,首先我们需要创建n个Service,这好像有点多啊!如果有100个地方需要用到AIDL呢,先创建100个Service?好吧问题出来了:随着AIDL数量的增加,我们不能无限制地增加Service,Service是四大组件之一,本身就是一种系统资源。而且太多的Service会使得我们的应用看起来很重量级,因为正在运行的Service可以在应用详情页看到,当我们的应用详情显示有10个服务正在运行时,这看起来并不是什么好事。

2、问题解决:Binder连接池的设计

我们需要减少Service的数量,将所有的AIDL放在同一个Service中去管理,基于这个思想该如何做呢?

(1)服务端每个业务模块创建自己的AIDL接口并实现此接口,这个时候不同业务模块之间是不能有耦合的,所有实现细节我们要单独开来,然后提供自己的唯一标识和其对应的Binder对象。
(2)服务端只需要一个Service就可以了。但是服务端需额外提供一个queryBinder接口,这个接口能够根据客户端的不同请求返回相应的Binder对象给它们,客户端对应业务模块块拿到所需的Binder对象后就可以进行远程方法调用了。

Binder连接池的主要作用就是将客户端每个业务模块的Binder请求统一转发到远程Service中去执行,从而避免了重复创建Service的过程。流程图如下:

请添加图片描述

3、服务端实现

(1)概览图
在这里插入图片描述
(2)Aidl 接口

// IAudioManager.aidl
package com.sunnyday.binderpool;

// Declare any non-default types here with import statements

interface IAudioManager {
  void playAudio();
}
// IMusicManager.aidl
package com.sunnyday.binderpool;

// Declare any non-default types here with import statements

interface IMusicManager {
  void playMusic();
}
// IBinderPool.aidl
package com.sunnyday.binderpool;

// Declare any non-default types here with import statements

interface IBinderPool {
 /**
  * @param binderType: the unique token of specific Binder
  * @return specific Binder who's token is binderType
  */
  IBinder getBinderByType(int binderType);
}

(3)Service&Aidl对应实现类

class BinderPoolService : Service() {

    companion object{
        const val TAG = "MusicManagerImpl"
    }

    override fun onBind(intent: Intent): IBinder {
        Log.i(TAG,"onBind")
        return BinderPoolImpl()
    }

}


/**
 * Create by SunnyDay on 09:47 2021/08/07
 */
class AudioManagerImpl:IAudioManager.Stub() {
    companion object{
        const val TAG = "AudioManagerImpl"
    }
    override fun playAudio() {
        Log.i(TAG,"playAudio")
    }
}
/**
 * Create by SunnyDay on 09:44 2021/08/07
 */
class MusicManagerImpl:IMusicManager.Stub() {
    companion object{
        const val TAG = "MusicManagerImpl"
    }
    override fun playMusic() {
       Log.i(TAG,"playMusic")
    }

}


/**
 * Create by SunnyDay on 11:53 2021/08/07
 */

class BinderPoolImpl : IBinderPool.Stub() {

    companion object {
        const val TAG = "BinderPoolImpl"
        const val BINDER_NONE = -1
        const val BINDER_MUSIC_MANAGER = 0
        const val BINDER_AUDIO_MANAGER = 1
    }

    override fun getBinderByType(binderType: Int): IBinder? {
        return when (binderType) {
            BINDER_MUSIC_MANAGER -> MusicManagerImpl()
            BINDER_AUDIO_MANAGER -> AudioManagerImpl()
            else -> null
        }
    }
}
4、客户端实现

(1) 概览图
在这里插入图片描述

(2)连接池

package com.sunnyday.client;

/**
 * Create by SunnyDay on 11:56 2021/08/07
 */
class BinderPool {

    private static final String TAG = "BinderPool";
    private static volatile BinderPool INSTANCE = null;

    public static final int BINDER_NONE = -1;
    public static final int BINDER_MUSIC_MANAGER = 0;
    public static final int BINDER_AUDIO_MANAGER = 1;

    private final Context mContext;
    //使用BlockingQueue也可实现同样功能
    private CountDownLatch mCountDownLatch;
    private IBinderPool mIBinderPool;


    private BinderPool(Context context) {
        //use application context avoid memory leak.
        this.mContext = context.getApplicationContext();
        connectBinderPoolService();
    }

    public static BinderPool getInstance(Context context) {
        if (INSTANCE == null) {
            synchronized (BinderPool.class) {
                if (INSTANCE == null) {
                    INSTANCE = new BinderPool(context);
                }
            }
        }
        return INSTANCE;
    }

    private final IBinder.DeathRecipient mDeathRecipient = new IBinder.DeathRecipient() {
        //死亡回调
        @Override
        public void binderDied() {
            if (mIBinderPool!=null){
                mIBinderPool.asBinder().unlinkToDeath(mDeathRecipient,0);
                mIBinderPool=null;
                connectBinderPoolService();
            }
        }
    };

    private final ServiceConnection conn = new ServiceConnection() {

        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            Log.i(TAG,"onServiceConnected");
            mIBinderPool = IBinderPool.Stub.asInterface(service);
            try {
                //设置死亡代理
                mIBinderPool.asBinder().linkToDeath(mDeathRecipient,0);
            } catch (RemoteException e) {
                e.printStackTrace();
            }
            mCountDownLatch.countDown();
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {
            Log.i(TAG,"onServiceDisconnected");
            mIBinderPool = null;
        }
    };



    public IBinder getBinder(int bindType) {
        Log.i(TAG, "getBinder");
        IBinder binder = null;

        try {
            if (mIBinderPool != null) {
                binder = mIBinderPool.getBinderByType(bindType);
            }
        } catch (RemoteException e) {
            e.printStackTrace();
        }
        return binder;
    }

    private void connectBinderPoolService() {
        Log.i(TAG, "connectBinderPoolService");
        mCountDownLatch = new CountDownLatch(1);
        Intent intent = new Intent();
        intent.setAction("com.sunnyday.binderpool.BinderPoolService");
        intent.setPackage("com.sunnyday.binderpool");
        mContext.bindService(intent, conn, Context.BIND_AUTO_CREATE);
        try {
            mCountDownLatch.await();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

(3)业务调用模拟


class MainActivity : AppCompatActivity() {


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        // notice here ,in new Thread not main.
        // if in main can lead to ANR. 
        Thread { test() }.start()

    }

    private fun test() {
        Log.i("MainActivity","client:test")
        // test get MusicBinder
        val musicBinder = BinderPool.getInstance(this).getBinder(BinderPool.BINDER_MUSIC_MANAGER)
        val mIMusicManager = IMusicManager.Stub.asInterface(musicBinder)
        mIMusicManager.playMusic()

        // test get AudioBinder
        val audioBinder = BinderPool.getInstance(this).getBinder(BinderPool.BINDER_AUDIO_MANAGER)
        val mAudioManager = IAudioManager.Stub.asInterface(audioBinder)
        mAudioManager.playAudio()
    }
}

在这里插入图片描述

5、小结

Binder 连接池的设计其实也没牵涉额外多的知识点,主要如下

(1)单例大家都会吧emmmmm

(2)CountDownLatch 这个主要是解决服务绑定结果异步回调的弊端。也即结果不能立即获得,我们又要使用相应对象这时导致空指针。

使用CountDownLatch 可以等待相应线程执行完结果,当前线程在执行逻辑。其实BlockingQueue也可完成这个需求。大家可以自己实践下。

服务端完整代码

客户端完整代码

The end

参考1:Android 开发艺术探索微信读书版

参考2

参考3

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值