Android车载多媒体开发MediaSession框架示例详解
作者:Coolbreeze
一、多媒体应用架构
1.1 音视频传统应用架构
通常,传统的播放音频或视频的多媒体应用由两部分组成:
播放器:用于吸收数字媒体并将其呈现为视频和/或音频;
界面:带有用于运行播放器并显示播放器状态(可选)的传输控件;
在 Android 应用开发中,从零开始构建自己的播放器还可以考虑以下选项:
- MediaPlayer :提供准系统播放器的基本功能,支持最常见的音频/视频格式和数据源。
- ExoPlayer :一个提供低层级 Android 音频 API 的开放源代码库。ExoPlayer 支持 DASH 和 HLS 流等高性能功能,这些功能在 MediaPlayer 中未提供。 众所周知,如果要在应用的后台继续播放音频,最常见的方式就是把 Player 放置在 Service 中,Service 提供一个 Binder 来实现界面与播放器之间的通信。但是,如果遇到锁屏时,如果要与 Service 之间进行通信就不得不用到 AIDL 接口/广播/ContentProvider 来完成与其它应用之间的通信,而这些通信手段既增加了应用开发者之间的沟通成本,也增加了应用之间的耦合度。为了解决上面的问题,Android 官方从 Android5.0 开始提供了 MediaSession 框架。
1.2 MediaSession 框架
MediaSession 框架规范了音视频应用中界面与播放器之间的通信接口,实现界面与播放器之间的完全解耦。MediaSession 框架定义了媒体会话和媒体控制器两个重要的类,它们为构建多媒体播放器应用提供了一个完善的技术架构。
媒体会话和媒体控制器通过以下方式相互通信:使用与标准播放器操作(播放、暂停、停止等)相对应的预定义回调,以及用于定义应用独有的特殊行为的可扩展自定义调用。
媒体会话
媒体会话负责与播放器的所有通信。它会对应用的其他部分隐藏播放器的 API。系统只能从控制播放器的媒体会话中调用播放器。
会话会维护播放器状态(播放/暂停)的表示形式以及播放内容的相关信息。会话可以接收来自一个或多个媒体控制器的 回调 。这样,应用的界面以及运行 Wear OS 和 Android Auto 的配套设备便可以控制您的播放器。响应回调的逻辑必须保持一致。无论哪个客户端应用发起了回调,对 MediaSession 回调的响应都是相同的。
媒体控制器
媒体控制器的作用是隔离界面,界面的代码只与媒体控制器(而非播放器本身)通信,媒体控制器会将传输控制操作转换为对媒体会话的回调。每当会话状态发生变化时,它也会接收来自媒体会话的回调,这为自动更新关联界面提供了一种机制,媒体控制器一次只能连接到一个媒体会话。
当您使用媒体控制器和媒体会话时,就可以在运行时部署不同的接口和/或播放器。这样一来,您可以根据运行应用的设备的功能单独更改该应用的外观和/或性能。
二、MediaSession
2.1 概述
MediaSession 框架主要是用来解决音乐界面和服务之间的通信问题,属于典型的 C/S 架构,有四个常用的成员类,分别是 MediaBrowser、MediaBrowserService、MediaController 和 MediaSession,是整个 MediaSession 框架流程控制的核心。
- MediaBrowser:媒体浏览器,用来连接媒体服务 MediaBrowserService 和订阅数据,在注册的回调接口中可以获取到 Service 的连接状态、获取音乐数据,一般在客户端中创建。
- MediaBrowserService:媒体服务,它有两个关键的回调函数,onGetRoot(控制客户端媒体浏览器的连接请求,返回值中决定是否允许连接),onLoadChildren(媒体浏览器向服务器发送数据订阅请求时会被调用,一般在这里执行异步获取数据的操作,然后在将数据发送回媒体浏览器注册的接口中)。
- MediaController:媒体控制器,在客户端中工作,通过控制器向媒体服务器发送指令,然后通过 MediaControllerCompat.Callback 设置回调函数来接受服务端的状态。MediaController 创建时需要受控端的配对令牌,因此需要在浏览器连接成功后才进行 MediaController 的创建。
- MediaSession:媒体会话,受控端,通过设置 MediaSessionCompat.Callback 回调来接收 MediaController 发送的指令,收到指令后会触发 Callback 中的回调方法,比如播放暂停等。Session 一般在 Service.onCreate 方法中创建,最后需调用 setSessionToken 方法设置用于和控制器配对的令牌并通知浏览器连接服务成功。 其中,MediaBrowser 和 MediaController 是客户端使用的,MediaBrowserService 和 MediaSession 是服务端使用的。由于客户端和服务端是异步通信,所以采用的大量的回调,因此有大量的回调类,框架示意图如下。
2.2 MediaBrowser
MediaBrowser 是媒体浏览器,用来连接 MediaBrowserService 和订阅数据,通过它的回调接口我们可以获取与 Service的连接状态以及获取在 Service中的音乐库数据。
在客户端(也就是前面提到的界面,或者说是控制端)中创建。媒体浏览器不是线程安全的,所有调用都应在构造 MediaBrowser 的线程上进行。
@RequiresApi(Build.VERSION_CODES.M) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val component = ComponentName(this, MediaService::class.java) mMediaBrowser = MediaBrowser(this, component, connectionCallback, null); mMediaBrowser.connect() }
2.2.1 MediaBrowser.ConnectionCallback
连接状态回调,当 MediaBrowser 向 service 发起连接请求后,请求结果将在这个 callback 中返回,获取到的 meidaId 对应服务端在 onGetRoot 函数中设置的 mediaId,如果连接成功那么就可以做创建媒体控制器之类的操作了。
@RequiresApi(Build.VERSION_CODES.M) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val component = ComponentName(this, MediaService::class.java) mMediaBrowser = MediaBrowser(this, component, connectionCallback, null); mMediaBrowser.connect() } private val connectionCallback = object : MediaBrowser.ConnectionCallback() { override fun onConnected() { super.onConnected() ... //连接成功后我们才可以创建媒体控制器 } override fun onConnectionFailed() { super.onConnectionFailed() } override fun onConnectionSuspended() { super.onConnectionSuspended() } }
2.2.2 MediaBrowser.ItemCallback
媒体控制器是负责向 service 发送例如播放暂停之类的指令的,这些指令的执行结果将在这个回调中返回,可重写的函数有很多,比如播放状态的改变,音乐信息的改变等。
private val connectionCallback = object : MediaBrowser.ConnectionCallback() { override fun onConnected() { super.onConnected() ... //返回执行结果 if(mMediaBrowser.isConnected) { val mediaId = mMediaBrowser.root mMediaBrowser.getItem(mediaId, itemCallback) } } } @RequiresApi(Build.VERSION_CODES.M) private val itemCallback = object : MediaBrowser.ItemCallback(){ override fun onItemLoaded(item: MediaBrowser.MediaItem?) { super.onItemLoaded(item) } override fun onError(mediaId: String) { super.onError(mediaId) } }
2.2.3 MediaBrowser.MediaItem
包含有关单个媒体项的信息,用于浏览/搜索媒体。MediaItem依赖于服务端提供,因此框架本身无法保证它包含的值都是正确的。
2.2.4 MediaBrowser.SubscriptionCallback
连接成功后,首先需要的是订阅服务,同样还需要注册订阅回调,订阅成功的话服务端可以返回一个音乐信息的序列,可以在客户端展示获取的音乐列表数据。例如,下面是订阅 MediaBrowserService 中 MediaBrowser.MediaItem 列表变化的回调。
private val connectionCallback = object : MediaBrowser.ConnectionCallback() { override fun onConnected() { super.onConnected() // ... if(mMediaBrowser.isConnected) { val mediaId = mMediaBrowser.root //需要先取消订阅 mMediaBrowser.unsubscribe(mediaId) //服务端会调用 onLoadChildren mMediaBrowser.subscribe(mediaId, subscribeCallback) } } } private val subscribeCallback = object : MediaBrowser.SubscriptionCallback(){ override fun onChildrenLoaded( parentId: String, children: MutableList<MediaBrowser.MediaItem> ) { super.onChildrenLoaded(parentId, children) } override fun onChildrenLoaded( parentId: String, children: MutableList<MediaBrowser.MediaItem>, options: Bundle ) { super.onChildrenLoaded(parentId, children, options) } override fun onError(parentId: String) { super.onError(parentId) } override fun onError(parentId: String, options: Bundle) { super.onError(parentId, options) } }
2.3 MediaController
媒体控制器,用来向服务端发送控制指令,例如:播放、暂停等等,在客户端中创建。媒体控制器是线程安全的,MediaController 还有一个关联的权限 android.permission.MEDIA_CONTENT_CONTROL(不是必须加的权限)必须是系统级应用才可以获取,幸运的是车载应用一般都是系统级应用。
同时,MediaController必须在 MediaBrowser 连接成功后才可以创建。 所以,创建 MediaController 的代码如下:
private val connectionCallback = object : MediaBrowser.ConnectionCallback() { override fun onConnected() { super.onConnected() // ... if(mMediaBrowser.isConnected) { val sessionToken = mMediaBrowser.sessionToken mMediaController = MediaController(applicationContext,sessionToken) } } }
2.3.1 MediaController.Callback
用于从 MediaSession 接收回调,所以使用的时候需要将 MediaController.Callback 注册到 MediaSession 中,如下:
private val connectionCallback = object : MediaBrowser.ConnectionCallback() { override fun onConnected() { super.onConnected() // ... if(mMediaBrowser.isConnected) { val sessionToken = mMediaBrowser.sessionToken mMediaController = MediaController(applicationContext,sessionToken) mMediaController.registerCallback(controllerCallback) } } } private val controllerCallback = object : MediaController.Callback() { override fun onAudioInfoChanged(info: MediaController.PlaybackInfo?) { super.onAudioInfoChanged(info) ... //回调方法接收受控端的状态,从而根据相应的状态刷新界面 UI } override fun onExtrasChanged(extras: Bundle?) { super.onExtrasChanged(extras) } // ... }
2.3.2 MediaController.PlaybackInfo
获取当前播放的音频信息,包含播放的进度、时长等。
2.3.3 MediaController.TransportControls
用于控制会话中媒体播放的接口。客户端可以通过 Session 发送媒体控制命令,使用方式如下:
private val connectionCallback = object : MediaBrowser.ConnectionCallback() { override fun onConnected() { super.onConnected() // ... if(mMediaBrowser.isConnected) { val sessionToken = mMediaBrowser.sessionToken mMediaController = MediaController(applicationContext,sessionToken) // 播放媒体 mMediaController.transportControls.play() // 暂停媒体 mMediaController.transportControls.pause() } } }
2.4 MediaBrowserService
媒体浏览器服务,继承自 Service,MediaBrowserService 属于服务端,也是承载播放器(如 MediaPlayer、ExoPlayer 等)和 MediaSession 的容器。 继承 MediaBrowserService 后,我们需要复写 onGetRoot和 onLoadChildren两个方法。onGetRoot 通过的返回值决定是否允许客户端的 MediaBrowser 连接到 MediaBrowserService。 当客户端调用 MediaBrowser.subscribe时会触发 onLoadChildren 方法。下面是使用事例:
const val FOLDERS_ID = "__FOLDERS__" const val ARTISTS_ID = "__ARTISTS__" const val ALBUMS_ID = "__ALBUMS__" const val GENRES_ID = "__GENRES__" const val ROOT_ID = "__ROOT__" class MediaService : MediaBrowserService() { override fun onGetRoot( clientPackageName: String, clientUid: Int, rootHints: Bundle? ): BrowserRoot? { // 由 MediaBrowser.connect 触发,可以通过返回 null 拒绝客户端的连接。 return BrowserRoot(ROOT_ID, null) } override fun onLoadChildren( parentId: String, result: Result<MutableList<MediaBrowser.MediaItem>> ) { //由 MediaBrowser.subscribe 触发 when (parentId) { ROOT_ID -> { // 查询本地媒体库 result.detach() result.sendResult() } FOLDERS_ID -> { } ALBUMS_ID -> { } ARTISTS_ID -> { } GENRES_ID -> { } else -> { } } } }
最后,还需要在 manifest 中注册这个 Service。
<service android:name=".MediaService" android:label="@string/service_name"> <intent-filter> <action android:name="android.media.browse.MediaBrowserService" /> </intent-filter> </service>
2.4.1 MediaBrowserService.BrowserRoot
返回包含浏览器服务首次连接时需要发送给客户端的信息。构造函数如下:
MediaBrowserService.BrowserRoot(String rootId, Bundle extras)
除此之外,还有两个方法:
- getExtras():获取有关浏览器服务的附加信息
- getRootId():获取用于浏览的根 ID 2.4.2 MediaBrowserService.Result
包含浏览器服务返回给客户端的结果集。通过调用 sendResult()将结果返回给调用方,但是在此之前需要调用 detach()。
- detach():将此消息与当前线程分离,并允许稍后进行调用 sendResult(T)
- sendResult():将结果发送回调用方。 2.5 MediaSession
媒体会话,即**受控端。**通过设定 MediaSession.Callback回调来接收媒体控制器 MediaController发送的指令,如控制音乐的【上一曲】、【下一曲】等。
创建 MediaSession后还需要调用 setSessionToken()方法设置用于和**控制器配对的令牌,使用方式如下:
const val FOLDERS_ID = "__FOLDERS__" const val ARTISTS_ID = "__ARTISTS__" const val ALBUMS_ID = "__ALBUMS__" const val GENRES_ID = "__GENRES__" const val ROOT_ID = "__ROOT__" class MediaService : MediaBrowserService() { private lateinit var mediaSession: MediaSession; override fun onCreate() { super.onCreate() mediaSession = MediaSession(this, "TAG") mediaSession.setCallback(callback) sessionToken = mediaSession.sessionToken } // 与 MediaController.transportControls 中的大部分方法都是一一对应的 // 在该方法中实现对 播放器 的控制, private val callback = object : MediaSession.Callback() { override fun onPlay() { super.onPlay() // 处理 播放器 的播放逻辑。 // 车载应用的话,别忘了处理音频焦点 } override fun onPause() { super.onPause() } } override fun onGetRoot( clientPackageName: String, clientUid: Int, rootHints: Bundle? ): BrowserRoot? { Log.e("TAG", "onGetRoot: $rootHints") return BrowserRoot(ROOT_ID, null) } override fun onLoadChildren( parentId: String, result: Result<MutableList<MediaBrowser.MediaItem>> ) { result.detach() when (parentId) { ROOT_ID -> { result.sendResult(null) } FOLDERS_ID -> { } ALBUMS_ID -> { } ARTISTS_ID -> { } GENRES_ID -> { } else -> { } } } override fun onLoadItem(itemId: String?, result: Result<MediaBrowser.MediaItem>?) { super.onLoadItem(itemId, result) Log.e("TAG", "onLoadItem: $itemId") } }
2.5 MediaSession.Callback
接收来自客户端或系统的媒体按钮、传输控件和命令,入【上一曲】、【下一曲】。与 MediaController.transportControls 中的大部分方法都是一一对应的。
private val callback = object : MediaSession.Callback() { override fun onPlay() { super.onPlay() if (!mediaSession.isActive) { mediaSession.isActive = true } //更新播放状态. val state = PlaybackState.Builder() .setState( PlaybackState.STATE_PLAYING,1,1f ) .build() mediaSession.setPlaybackState(state) } override fun onPause() { super.onPause() } override fun onStop() { super.onStop() } }
2.5.1 MediaSession.QueueItem
播放队列一部分的单个项目,相比 MediaMetadata,多了一个 ID 属性。常用的方法有:
- getDescription():返回介质的说明,包含媒体的基础信息,如标题、封面等。
- getQueueId():获取此项目的队列 ID。
MediaSession.Token
表示正在进行的会话,可以通过会话所有者传递给客户端,以允许客户端与服务端之间建立通信。
2.6 PlaybackState
用于承载播放状态的类。如当前播放位置和当前控制功能。在 MediaSession.Callback更改状态后需要调用 MediaSession.setPlaybackState把状态同步给客户端。
private val callback = object : MediaSession.Callback() { override fun onPlay() { super.onPlay() // 更新状态 val state = PlaybackState.Builder() .setState( PlaybackState.STATE_PLAYING,1,1f ) .build() mediaSession.setPlaybackState(state) } }
2.6.1 PlaybackState.Builder
PlaybackState.Builder 主要用来创建 PlaybackState 对象,创建它使用的是建造者模式,如下。
PlaybackState state = new PlaybackState.Builder() .setState(PlaybackState.STATE_PLAYING, mMediaPlayer.getCurrentPosition(), PLAYBACK_SPEED) .setActions(PLAYING_ACTIONS) .addCustomAction(mShuffle) .setActiveQueueItemId(mQueue.get(mCurrentQueueIdx).getQueueId()) .build();
2.6.2 PlaybackState.CustomAction
CustomActions可用于通过将特定于应用程序的操作发送给 MediaControllers,这样就可以扩展标准传输控件的功能。
CustomAction action = new CustomAction .Builder("android.car.media.localmediaplayer.shuffle", mContext.getString(R.string.shuffle), R.drawable.shuffle) .build(); PlaybackState state = new PlaybackState.Builder() .setState(PlaybackState.STATE_PLAYING, mMediaPlayer.getCurrentPosition(), PLAYBACK_SPEED) .setActions(PLAYING_ACTIONS) .addCustomAction(action) .setActiveQueueItemId(mQueue.get(mCurrentQueueIdx).getQueueId()) .build();
常见的 API,有如下一些:
- getAction():返回 CustomAction 的 action
- getExtras():返回附加项,这些附加项提供有关操作的其他特定于应用程序的信息
- getIcon():返回 package 中图标的资源 ID
- getName():返回此操作的显示名称 2.7 MediaMetadata
包含有关项目的基础数据,例如标题、艺术家等。一般需要服务端从本地数据库或远端查询出原始数据在封装成 MediaMetadata 再通过 MediaSession.setMetadata(metadata)返回到客户端的 MediaController.Callback.onMetadataChanged中。
常见的 API 有如下:
- containsKey():如果给定的 key 包含在元数据中,则返回 true。
- describeContents():描述此可打包实例的封送处理表示中包含的特殊对象的种类。
- getBitmap():返回给定的 key 的 Bitmap,如果给定 key 不存在位图,则返回 null。
- getBitmapDimensionLimit():获取创建此元数据时位图的宽度/高度限制
- getDescription():获取此元数据的简单说明以进行显示。
- keySet():返回一个 Set,其中包含在此元数据中用作 key 的字符串。 三、示例
下图是 MediaSession 框架核心类的通信过程。
可以看到,在 MediaSession 框架中,首先客户端通过 MediaBrowserService 连接到 MediaBrowserService,MediaBrowserService 接受到请求之后处理相关的请求,MediaSession 控制播放状态,并将状态同步给客户端,客户端最后 MediaController 进行相应的操作。
客户端示例代码:
class MainActivity : AppCompatActivity() { private lateinit var mMediaBrowser: MediaBrowser private lateinit var mMediaController: MediaController @RequiresApi(Build.VERSION_CODES.M) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val component = ComponentName(this, MediaService::class.java) mMediaBrowser = MediaBrowser(this, component, connectionCallback, null); // 连接到 MediaBrowserService,会触发 MediaBrowserService 的 onGetRoot 方法。 mMediaBrowser.connect() findViewById<Button>(R.id.btn_play).setOnClickListener { mMediaController.transportControls.play() } } private val connectionCallback = object : MediaBrowser.ConnectionCallback() { override fun onConnected() { super.onConnected() if (mMediaBrowser.isConnected) { val sessionToken = mMediaBrowser.sessionToken mMediaController = MediaController(applicationContext, sessionToken) mMediaController.registerCallback(controllerCallback) // 获取根 mediaId val rootMediaId = mMediaBrowser.root // 获取根 mediaId 的 item 列表,会触发 MediaBrowserService.onLoadItem 方法 mMediaBrowser.getItem(rootMediaId,itemCallback) mMediaBrowser.unsubscribe(rootMediaId) // 订阅服务端 media item 的改变,会触发 MediaBrowserService.onLoadChildren 方法 mMediaBrowser.subscribe(rootMediaId, subscribeCallback) } } } private val controllerCallback = object : MediaController.Callback() { override fun onPlaybackStateChanged(state: PlaybackState?) { super.onPlaybackStateChanged(state) Log.d("TAG", "onPlaybackStateChanged: $state") when(state?.state){ PlaybackState.STATE_PLAYING ->{ // 处理 UI } PlaybackState.STATE_PAUSED ->{ // 处理 UI } // 还有其它状态需要处理 } } //音频信息,音量 override fun onAudioInfoChanged(info: MediaController.PlaybackInfo?) { super.onAudioInfoChanged(info) val currentVolume = info?.currentVolume // 显示在 UI 上 } override fun onMetadataChanged(metadata: MediaMetadata?) { super.onMetadataChanged(metadata) val artUri = metadata?.getString(MediaMetadata.METADATA_KEY_ALBUM_ART_URI) // 显示 UI 上 } override fun onSessionEvent(event: String, extras: Bundle?) { super.onSessionEvent(event, extras) Log.d("TAG", "onSessionEvent: $event") } // ... } private val subscribeCallback = object : MediaBrowser.SubscriptionCallback() { override fun onChildrenLoaded( parentId: String, children: MutableList<MediaBrowser.MediaItem> ) { super.onChildrenLoaded(parentId, children) } override fun onChildrenLoaded( parentId: String, children: MutableList<MediaBrowser.MediaItem>, options: Bundle ) { super.onChildrenLoaded(parentId, children, options) } override fun onError(parentId: String) { super.onError(parentId) } } private val itemCallback = object : MediaBrowser.ItemCallback() { override fun onItemLoaded(item: MediaBrowser.MediaItem?) { super.onItemLoaded(item) } override fun onError(mediaId: String) { super.onError(mediaId) } } }
下面是服务端的示例源码,主要用于处理客户端的请求,并将结果返回给客户端。
const val FOLDERS_ID = "__FOLDERS__" const val ARTISTS_ID = "__ARTISTS__" const val ALBUMS_ID = "__ALBUMS__" const val GENRES_ID = "__GENRES__" const val ROOT_ID = "__ROOT__" class MediaService : MediaBrowserService() { // 控制是否允许客户端连接,并返回 root media id 给客户端 override fun onGetRoot( clientPackageName: String, clientUid: Int, rootHints: Bundle? ): BrowserRoot? { Log.e("TAG", "onGetRoot: $rootHints") return BrowserRoot(ROOT_ID, null) } // 处理客户端的订阅信息 override fun onLoadChildren( parentId: String, result: Result<MutableList<MediaBrowser.MediaItem>> ) { Log.e("TAG", "onLoadChildren: $parentId") result.detach() when (parentId) { ROOT_ID -> { result.sendResult(null) } FOLDERS_ID -> { } ALBUMS_ID -> { } ARTISTS_ID -> { } GENRES_ID -> { } else -> { } } } override fun onLoadItem(itemId: String?, result: Result<MediaBrowser.MediaItem>?) { super.onLoadItem(itemId, result) Log.e("TAG", "onLoadItem: $itemId") // 根据 itemId,返回对用 MediaItem result?.detach() result?.sendResult(null) } private lateinit var mediaSession: MediaSession; override fun onCreate() { super.onCreate() mediaSession = MediaSession(this, "TAG") mediaSession.setCallback(callback) // 设置 token sessionToken = mediaSession.sessionToken } // 与 MediaController.transportControls 中的方法是一一对应的。 // 在该方法中实现对 播放器 的控制, private val callback = object : MediaSession.Callback() { override fun onPlay() { super.onPlay() // 处理 播放器 的播放逻辑。 // 车载应用的话,别忘了处理音频焦点 Log.e("TAG", "onPlay:") if (!mediaSession.isActive) { mediaSession.isActive = true } // 更新状态 val state = PlaybackState.Builder() .setState( PlaybackState.STATE_PLAYING, 1, 1f ) .build() mediaSession.setPlaybackState(state) } override fun onPause() { super.onPause() } override fun onStop() { super.onStop() } //省略其他方法 } }
上文主要介绍车载自媒体开发与MediaSession框架解析;这只是其中一小部分;更多的Android车载技术可以前往[私信]领取,里面内容如下脑图所示:(需要可以参考,当做辅导资料)
文末
车载系统开发,在这几年岗位逐渐增多。Android转岗车载开发是个很好的发展方向。汽车的普及同比10年增长300%以上;近几年的新能源汽车逐渐普及,车载开发人员更是需求很大;普遍缺少人才。
感觉Android市场下滑的厉害,淘汰人员逐渐增多。一定要眼看未来,才没有近忧。
以上就是Android车载多媒体开发MediaSession框架示例详解的详细内容,更多关于Android车载多媒体MediaSession的资料请关注脚本之家其它相关文章!