Media Session框架的简单介绍

1、介绍

Media Session框架是google在android5之后引入的一个音乐播放框架,用来解决音乐界面和服务之间的通信问题,特别注意的是,我们现在都是在比较高的版本上开发,因此要用比较高的兼容包,一般都以Compat结尾。
Media Session框架中有四个常用的成员类,它们是整个流程控制的核心,下面我们一一介绍一下。

  • MediaBrowser

    媒体浏览器,用来连接媒体服务MediaBrowserService和订阅数据,在注册的回调接口中我们就可以获取到Service的连接状态、获取音乐数据。一般在客户端中创建。

  • MediaBrowserService

    媒体服务,它有两个关键的回调函数,onGetRoot(控制客户端媒体浏览器的连接请求,返回值中决定是否允许连接),onLoadChildren(媒体浏览器向服务器发送数据订阅请求时会被调用,一般在这里执行异步获取数据的操作,然后在将数据发送回媒体浏览器注册的接口中)。

  • MediaController

    媒体控制器,在客户端中工作,通过控制器向媒体服务器发送指令,然后通过MediaControllerCompat.Callback设置回调函数来接受服务端的状态。MediaController创建时需要受控端的配对令牌,因此需要在浏览器连接成功后才进行MediaController的创建

  • MediaSession

    媒体会话,受控端,通过设置MediaSessionCompat.Callback回调来接收MediaController发送的指令,收到指令后会触发Callback中的回调方法,比如播放暂停等。Session一般在Service.onCreate方法中创建,最后需调用setSessionToken方法设置用于和控制器配对的令牌并通知浏览器连接服务成功。

上面的4个关键类中,MediaBrowser和MediaController是客户端使用的,MediaBrowserService和MediaSession是服务端使用的。

由于客户端和服务端是异步通信,所以采用的大量的回调,因此有大量的回调类,下面我们大概介绍一些关键的。

  • MediaBrowserCompat.ConnectionCallback

    连接状态回调,当MediaBrowser向service发起连接请求后,请求结果将在这个callback中返回,获取到的meidaId对应服务端在onGetRoot函数中设置的mediaId,如果连接成功那么就可以做创建媒体控制器之类的操作了。

    BrowserConnectionCallback=new MediaBrowserCompat.ConnectionCallback(){
        @Override
        public void onConnected() {
            if (mBrowser.isConnected()){
                String mediaId = mBrowser.getRoot();
                //Browser通过订阅的方式向Service请求数据,发起订阅请求需要两个参数,其一为mediaId
                //而如果该mediaId已经被其他Browser实例订阅,则需要在订阅之前取消mediaId的订阅者
                //虽然订阅一个 已被订阅的mediaId 时会取代原Browser的订阅回调,但却无法触发onChildrenLoaded回调
                mBrowser.unsubscribe(mediaId);
                mBrowser.subscribe(mediaId,BrowserSubscriptionCallback);
                try{
                    // 连接成功后我们才可以创建媒体控制器
                    mController = new MediaControllerCompat(MainActivity.this, mBrowser.getSessionToken());
                    mController.registerCallback(controllerCallback);
                } catch (RemoteException e) {
                    e.printStackTrace();
                }
            }
        }
    };
    
  • MediaBrowserCompat.SubscriptionCallback

    连接成功后进一步是订阅,同样需要注册订阅回调,订阅成功的话服务端可以返回一个音乐信息的序列,我们可以保存起来作为音乐列表展示。

    BrowserSubscriptionCallback = new MediaBrowserCompat.SubscriptionCallback() {
        @Override
        public void onChildrenLoaded(@NonNull String parentId, @NonNull List<MediaBrowserCompat.MediaItem> children) {
            // children为service发送回来时的媒体数据集合
            for (MediaBrowserCompat.MediaItem item:children){
    			// 可以将数据保存起来
            }
        }
    };
    
  • MediaControllerCompat.Callback

    媒体控制器是负责向service发送例如播放暂停之类的指令的,这些指令的执行结果将在这个回调中返回,可重写的函数有很多,比如播放状态改变了,音乐信息改变,列表变化等,按需要自己实现。

    controllerCallback = new MediaControllerCompat.Callback() {
        @Override
        public void onPlaybackStateChanged(PlaybackStateCompat state) {
    
            switch(state.getState()){
                case PlaybackStateCompat.STATE_NONE:
                case PlaybackStateCompat.STATE_PAUSED:
                case PlaybackStateCompat.STATE_PLAYING:
                case PlaybackStateCompat.STATE_SKIPPING_TO_NEXT:
                case PlaybackStateCompat.STATE_SKIPPING_TO_PREVIOUS:
                    // ...
                default:break;
            }
    
        }
    
        @Override
        public void onMetadataChanged(MediaMetadataCompat metadata) {
            // ...
        }
    };
    

    在这里插入图片描述

  • MediaSessionCompat.Callback

    在客户端发送过来的指令会在这个callback中内调用,如上一曲下一曲之类的

    sessionCallback = new MediaSessionCompat.Callback() {
        // client click to skip next sing
        @Override
        public void onSkipToNext() {
            super.onSkipToNext();
    		// ...
        }
    
        @Override
        public void onSkipToPrevious() {
            super.onSkipToPrevious();
    		// ...
        }
    
        @Override
        public void onPlay() {
    		// ...
        }
    
        @Override
        public void onPause() {
    		// ...
        }
    
        @Override
        public void onPlayFromUri(Uri uri, Bundle extras) {
    		// ...
        }
    
        @Override
        public void onPlayFromMediaId(String mediaId, Bundle extras) {
    		// ...
        }
    };
    

此外我们播放音乐借助的是MediaPlayer, 它也有很多的回调,具体这里就不展示了,但是要写好应用也是必须掌握的。

简单使用

客户端

创建一个MediaBrowserCompat对象,参数中需要传入服务的包名和类名,以及回调对象等。

mBrowser = new MediaBrowserCompat(MainActivity.this,
                new ComponentName("com.demo.cdmusic","com.demo.cdmusic.MusicService"),
                connectionCallback,null);

然后通过connect 函数进行连接

mBrowser.connect();

连接的结果会返回到connectionCallback中

        connectionCallback = new MediaBrowserCompat.ConnectionCallback(){
            @Override
            public void onConnected() {
                if (mBrowser.isConnected()){
                    Log.d(TAG, "onConnected: "+"连接成功了");
                    String mediaId = mBrowser.getRoot();
                    // 必须先解除订阅再重新订阅
                    mBrowser.unsubscribe(mediaId);
                    mBrowser.subscribe(mediaId,subscriptionCallback);
                    try{
                        mController = new MediaControllerCompat(MainActivity.this,mBrowser.getSessionToken());
                        mController.registerCallback(controllerCallback);
                    } catch (RemoteException e) {
                        e.printStackTrace();
                    }
                }
            }

            @Override
            public void onConnectionSuspended() {
                super.onConnectionSuspended();
                // 连接中断
            }

            @Override
            public void onConnectionFailed() {
                super.onConnectionFailed();
                // 连接失败
            }
        };

在程序退出时也别忘了断开连接

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

连接成功后,我们就接着进行订阅操作和创建controller的操作,最后订阅的结果会返回到subscriptionCallback中

subscriptionCallback = new MediaBrowserCompat.SubscriptionCallback() {
    @Override
    public void onChildrenLoaded(@NonNull String parentId, @NonNull List<MediaBrowserCompat.MediaItem> children) {
        super.onChildrenLoaded(parentId, children);
        Log.d(TAG, "onChildrenLoaded: ");
        if (children!=null && children.size()!=0){
            for (int i = 0; i < children.size(); i++) {
                MediaBrowserCompat.MediaItem mediaItem = children.get(i);
                Log.d(TAG, ""+mediaItem.getDescription().getTitle().toString());
                // 将返回的音乐列表保存起来
            }
        }
    }
};

订阅成功会返回一个列表,其中是一些音乐数据,我们可以将它保存起来,通过ListView或者其他方式展示在UI上。总结一下,流程就是connect->onConnected->subscribe->onSubscribed

控制器创建成功后我们便可以通过它向服务端发送一些命令了,比如播放列表中音乐,暂停下一曲等操作。

mController.getTransportControls().play();

控制器也需要注册自己的回调,发送指令后服务端会将执行结果回调到这些函数中

MediaControllerCompat.Callback controllerCallback =

    new MediaControllerCompat.Callback() {
          public void onSessionDestroyed() {
         //Session销毁
        }

        @Override
        public void onRepeatModeChanged(int repeatMode) {
          //循环模式发生变化
        }

        @Override
        public void onShuffleModeChanged(int shuffleMode) {
          //随机模式发生变化
        }

        @Override
        public void onMetadataChanged(MediaMetadataCompat metadata) {
        //数据变化
        }

        @Override
        public void onPlaybackStateChanged(PlaybackStateCompat state) {
        //播放状态变化
        }
};

这里我们只需要重点处理onPlaybackStateChanged和onMetadataChanged函数就可以了,PlaybackStateCompat对象中包含了音乐的各个播放状态,我们对每一种情况相应处理,比如音乐播放状态变为了暂停,我们需要将界面上播放按钮设置为暂停。

@Override
public void onPlaybackStateChanged(PlaybackStateCompat state) {
    MediaMetadataCompat metadata = mController.getMetadata();
    switch(state.getState()){
        case PlaybackStateCompat.STATE_NONE:
        case PlaybackStateCompat.STATE_PAUSED:
     //imageView.setImageBitmap(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ART));
            btnPlay.setText("开始");
            break;
        case PlaybackStateCompat.STATE_PLAYING:        //imageView.setImageBitmap(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ART));
            btnPlay.setText("暂停");
            break;
        case PlaybackStateCompat.STATE_SKIPPING_TO_NEXT:
        case PlaybackStateCompat.STATE_SKIPPING_TO_PREVIOUS:
            btnPlay.setText("暂停");
        default:break;
    }
}

@Override
public void onMetadataChanged(MediaMetadataCompat metadata) {
	// 音乐数据改变,我们对UI也同步更新
    textTitle.setText(metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE));
    imageView.setImageBitmap(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ART));
}

好了,客户端的重点函数和流程就到这里了,下面我们看一下服务端怎么实现。

服务端

实现一个类继承MediaBrowserService

public class MediaService extends MediaBrowserService {
    @Nullable
    @Override
    public BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) {
        return null;
    }

    @Override
    public void onLoadChildren(@NonNull String parentId, @NonNull Result<List<MediaBrowser.MediaItem>> result) {

    }
}

MediaBrowserService是继承自Service的,继承MediaBrowserService后需要重写两个方法,onGetRoot会在客户端发起连接时被调用,而onLoadchildren会在客户端发起订阅请求时被调用。onGetRoot方法的参数是clientPackageName和客户端的UID,我们可以针对这两个参数做一些限制,比如允许哪些客户端连接之类的,如果不允许就直接返回一个null就行了,否则就返回一个新的BrowserRoot对象。

return new BrowserRoot(MEDIA_ID_ROOT,null);

参数中MEDIA_ID_ROOT是我自定义的一个字符串常量,随便传一个都行。

函数onLoadChildren则是在客户端发起订阅请求时被调用的,在这个函数中,我们扫描音乐文件,然后将其打包到一个list中,再返回给客户端。扫描时我们一般借助MediaStore查询系统的多媒体数据库,我这里只是简单的构造了一个 MediaMetadataCompat对象, 实际项目时不是这样的。

@Override
public void onLoadChildren(@NonNull String parentId, @NonNull Result<List<MediaBrowserCompat.MediaItem>> result) {

    result.detach();
    // 创建一个音乐信息列表
    mediaItems = new ArrayList<>();

    // 模拟数据的读取过程,每一个metadata对象就是一首音乐的信息,一首音乐的描述信息是很多的,这里
    // 这是加入的id,图片,歌名几个信息
    MediaMetadataCompat metadata = new MediaMetadataCompat.Builder()
        .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID,"penghuwan")
        .putBitmap(MediaMetadataCompat.METADATA_KEY_ART,BitmapFactory.decodeResource(this.getResources(),R.drawable.penghuwan))
        .putString(MediaMetadataCompat.METADATA_KEY_TITLE,"澎湖湾")
        .build();

    mediaItems.add(createMediaItem(metadata));
    // 省略了其他歌曲的创建
    // 向mediaBrower发送歌曲数据
    result.sendResult(mediaItems);
}

接下来创建MediaSession, 它是服务端控制音乐播放的核心

mSession = new MediaSessionCompat(this,"MusicService");

然后还需要调用一个重要的方法

setSessionToken(mSession.getSessionToken());

这个方法会触发客户端MediaBrowser.ConnectionCallback的回调方法,表示连接成功。

接下来我们为MediaSession注册回调函数,当客户端MediaController发送指令时会回调到这里。

// 处理来自客户端的命令
        sessionCallback = new MediaSessionCompat.Callback() {
            // client click to skip next sing
            @Override
            public void onSkipToNext() {
                super.onSkipToNext();
                Log.d(TAG, "onSkipToNext: 跳转下一首");
                MediaMetadataCompat next = mMusicLib.getNext();
                if (next!=null){
                    String id = next.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID);
                    AssetFileDescriptor descriptor = null;
                    try {
                        descriptor = manager.openFd(id+".mp3");
                        mMediaPlayer.reset();
                        mMediaPlayer.setDataSource(descriptor);
                        mMediaPlayer.prepare();
                        mMediaPlayer.start();

                        mPlaybackState =new PlaybackStateCompat.Builder()
                                .setState(PlaybackStateCompat.STATE_PLAYING,0,1.0f)
                                .build();
                        mSession.setPlaybackState(mPlaybackState);
                        // 更新UI
                        mSession.setMetadata(next);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }

                }
            }

            @Override
            public void onSkipToPrevious() {
                super.onSkipToPrevious();
                Log.d(TAG, "onSkipToPrevious: 跳转上一曲");
                MediaMetadataCompat previous = mMusicLib.getPrevious();
                if (previous==null){
                    return;
                }
                String id = previous.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID);
                try {
                    AssetFileDescriptor fd = manager.openFd(id+".mp3");
                    mMediaPlayer.reset();
                    mMediaPlayer.setDataSource(fd);
                    mMediaPlayer.prepare();
                    mMediaPlayer.start();

                    mPlaybackState =new PlaybackStateCompat.Builder()
                            .setState(PlaybackStateCompat.STATE_SKIPPING_TO_PREVIOUS,0,1.0f)
                            .build();
                    // 更新UI
                    Log.d(TAG, "onSkipToPrevious: 准备更新封面");
                    mSession.setMetadata(previous);
                    Log.d(TAG, "onSkipToPrevious: 更新播放状态");
                    mSession.setPlaybackState(mPlaybackState);


                } catch (IOException e) {
                    e.printStackTrace();
                }

            }
            // 响应MediaController.getTranportControllers().play()
            @RequiresApi(api = Build.VERSION_CODES.N)
            @Override
            public void onPlay() {
                
                Log.d(TAG, "onPlay");
                if (mPlaybackState.getState()==PlaybackStateCompat.STATE_NONE){
                    try {
                        mMediaPlayer.prepare();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                    mMediaPlayer.start();
                    mPlaybackState = new PlaybackStateCompat.Builder()
                            .setState(PlaybackStateCompat.STATE_PLAYING,0,1.0f)
                            .build();
                    mSession.setPlaybackState(mPlaybackState);

                }
                // 如果当前处于暂停状态,那么播放它,客户端通过调用play会到达这里
                if (mPlaybackState.getState()==PlaybackStateCompat.STATE_PAUSED){
                    Log.d(TAG, "onPlay: 将处于暂停状态的音乐开始播放");
                    mMediaPlayer.start();
                    mPlaybackState = new PlaybackStateCompat.Builder()
                            .setState(PlaybackStateCompat.STATE_PLAYING,0,1.0f)
                            .build();
                    mSession.setPlaybackState(mPlaybackState);
                }


            }

            @Override
            public void onPause() {
                mMediaPlayer.pause();
                mPlaybackState = new PlaybackStateCompat.Builder()
                        .setState(PlaybackStateCompat.STATE_PAUSED,0,1.0f)
                        .build();
                mSession.setPlaybackState(mPlaybackState);
            }

            @Override
            public void onPlayFromUri(Uri uri, Bundle extras) {
                Log.d(TAG, "onPlayFromUri");
                try {
                    switch (mPlaybackState.getState()) {
                        case PlaybackStateCompat.STATE_PLAYING:
                        case PlaybackStateCompat.STATE_PAUSED:
                        case PlaybackStateCompat.STATE_NONE: {
                            mMediaPlayer.reset();
                            mMediaPlayer.setDataSource(MusicService.this, uri);
                            mMediaPlayer.prepare();
                            mPlaybackState = new PlaybackStateCompat.Builder()
                                    .setState(PlaybackStateCompat.STATE_CONNECTING, 0, 1.0f)
                                    .build();
                            mSession.setPlaybackState(mPlaybackState);
                            // 保存当前的音乐的信息,以便客户端刷新UI
                            mSession.setMetadata(new MediaMetadataCompat.Builder()
                                    .putString(MediaMetadataCompat.METADATA_KEY_TITLE, extras.getString("title"))
                                    .build());
                        };
                        break;
                    }
                }catch (IOException e){
                    e.printStackTrace();
                }
            }

            @Override
            public void onPlayFromMediaId(String mediaId, Bundle extras) {
                Log.d(TAG, "onPlayFromMediaId: "+mediaId);
                mMediaPlayer.reset();
                try {
                    AssetFileDescriptor fd = manager.openFd(mediaId);
                    mMediaPlayer.setDataSource(fd);
                    mMediaPlayer.prepare();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                mPlaybackState = new PlaybackStateCompat.Builder()
                        .setState(PlaybackStateCompat.STATE_CONNECTING,0,1.0f)
                        .build();
                mSession.setMetadata(new MediaMetadataCompat.Builder()
                .putString(MediaMetadataCompat.METADATA_KEY_TITLE,extras.getString("title"))
                        .putBitmap(MediaMetadataCompat.METADATA_KEY_ART,BitmapFactory.decodeResource(MusicService.this.getResources(),R.drawable.penghuwan))
                .build());

            }
        };

不要看到上面的代码比较多,其实就是对播放暂停、上一曲下一曲… 的一个处理而已,从上面也可以看出,其实音乐的播放还是借助一个MediaPlayer来进行控制的。mediaPlayer自身也需要设置几个回调函数,比如

// 监听mediaplayer.prepare
private MediaPlayer.OnPreparedListener preparedListener;
// 监听播放结束事件
private MediaPlayer.OnCompletionListener completionListener;

对Mediaplayer的使用和原理这里就不介绍了,自己去网络查阅。

那么当服务端的数据改变时,如何通知客户端呢?比如当前音乐播放完毕,服务端自动切换到下一首,仍然是通过MediaSession来完成的

setMetadata(android.media.MediaMetadata));  // 设置当前的歌曲信息
setPlaybackState(android.media.session.PlaybackState)); // 设置当前的音乐播放状态

服务端也差不多就是这么多了,自己动手才能发现更多的错误,也会探索到更多的方法,如果对上面的例子感兴趣,我已经放在这里了。

Media Session中还有一个我认为很强的功能,就是计算歌曲的进度以便及时的更新进度条,先贴计算方法

    public long getCurrentPosition(){
        // playState 类型是laybackStateCompat
        return (long) (((SystemClock.elapsedRealtime() - playSate.getLastPositionUpdateTime())*playSate.getPlaybackSpeed())
                        + playSate.getPosition());
    }

SystemClock.elapsedRealtime() - playSate.getLastPositionUpdateTime()得到的是歌曲上次更新的时间到现在的时间,乘以播放速度就计算出到现在为止播放进度。

2、音频焦点

音频焦点是一个内容比较多的东西,我这里简单的介绍一下,我们在平时听音乐时,如果来电话了,那音乐铃声就会自动关掉,这就是因为音乐播放器失去了音频焦点,音频焦点机制规定了在任一时刻只能有一个应用能获得音频焦点。因此我们也要实际的考虑到如何综合音频焦点来实习我们的音乐播放器。这里我简单的介绍一下使用音频焦点的步骤

  1. 获取AudioManager服务

    AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
    
  2. 播放音乐前先获取音频焦点

    private boolean requetFocus(){
        int result = audioManager.requestAudioFocus(audioFocusChangeListener,AudioManager.STREAM_MUSIC,AudioManager.AUDIOFOCUS_GAIN);
        return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
    }
    
  3. 获取成功后开始播放

  4. 在监听器中处理音频丢失的情况

    audioFocusChangeListener = new AudioManager.OnAudioFocusChangeListener() {
        @Override
        public void onAudioFocusChange(int focusChange) {
            Log.d(TAG, "onAudioFocusChange: 焦点改变了");
            switch(focusChange){
                case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:{
                    // 暂时失去焦点,调低音量,先暂停掉
    				// ...
                }break;
                case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:{
                    // 暂时失去焦点,暂停播放
                    Log.d(TAG, "onAudioFocusChange: 暂时失去焦点");
                    if (mMediaPlayer.isPlaying()){
                        mMediaPlayer.pause();
                        // 通知客户端暂停
                        PlaybackStateCompat state = new PlaybackStateCompat.Builder()
                                .setState(PlaybackStateCompat.STATE_PAUSED,mMediaPlayer.getCurrentPosition(),1.0f)
                                .build();
                        mSession.setPlaybackState(state);
                        // 记录下这个状态,因为失去焦点而暂停,方便恢复
                        mediaStateBeforeRequestAudio = CurrentMusic.LOSSFOCUSPAUSE;
                    }
                }break;
                case AudioManager.AUDIOFOCUS_LOSS:{
                    // 永久失去焦点,停止服务
                    audioManager.abandonAudioFocus(audioFocusChangeListener);
                    stopSelf();
                }break;
                case AudioManager.AUDIOFOCUS_GAIN:{
                    // 再次获取焦点,根据失去焦点前的状态进行恢复
                    Log.d(TAG, "onAudioFocusChange: 获取焦点");
                    // 判断失去焦点前音乐播放器的状态
                    if (mediaStateBeforeRequestAudio == CurrentMusic.LOSSFOCUSPAUSE || mediaStateBeforeRequestAudio == CurrentMusic.REQUESTFOCUS){
                        // 失去焦点前正在播放,因此暂停播放
                        mMediaPlayer.start();
                        mediaStateBeforeRequestAudio = CurrentMusic.FOCUSNOMATTER;
                        // 通知客户端恢复
                        PlaybackStateCompat stateCompat = new PlaybackStateCompat.Builder()
                                .setState(PlaybackStateCompat.STATE_PLAYING,mMediaPlayer.getCurrentPosition(),1.0f).build();
                        mSession.setPlaybackState(stateCompat);
                    }
                }break;
                default:break;
            }
    
        }
    
  5. 播放完毕后取消焦点

    audioManager.abandonAudioFocus(audioFocusChangeListener);
    

    这里音频焦点介绍很少,具体还有查阅更加详细的资料。

3、AVRCP协议

AVRCP协议的全称是音视频远端控制协议,结合MediaSession框架能够很容易的实现音视频的播放控制,使用时我们只需要实现好客户端的代码,服务端由这个协议控制,不需要我们操心。如何使用呢?只需要在MediaBrowser连接服务的地方设置就好了

mBrowser = new MediaBrowserCompat(MainActivity.this,
                new ComponentName(packageName,className),
                connectionCallback,null);

将上面的packageName和className改成avrcp协议对应的服务就行了,但是不同的android版本对应的协议包名类名不一样
android7-9

String package = "com.android.bluetooth"String class = "com.android.bluetooth.a2dpsink.mbs.A2dpMediaBrowserService"

android10以后

String package = "com.android.bluetooth"String class = "com.android.bluetooth.avrcpcontroller.BluetoothMediaBrowserService"
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值