阅读此文需要一些关于 Chromecast 的前置知识,可以参考 developers.google.com/cast
背景
我们的产品是一个海外音乐播放器,之前接入过 Chromecast,可以将音视频投屏到连接了 chromecast 设备的电视或者显示器上。之前我们接入的交互方式是使用官方文档里的 MediaRouteButton,投屏入口仅仅只是一个按钮,我们点击这个按钮之后的交互,都交给了 SDK。在按钮点击之后弹出的设备选择弹窗也是集成在 SDK 内部的,我们并没有自定义弹窗样式。
这个交互当然是符合 google 的 chromecast 的规范的。但是这个按钮和弹窗有三个缺点
- UI 样式不能定制,看起来丑,和应用风格不符
- SDK 对外只暴露了一个按钮,特别不灵活,不适合高度定制的需求
- 当设备仍处在 connecting 的状态时,点击按钮也会直接弹出媒体控制弹窗,而不是展示 connecting 的状态。这是 SDK 的一个 bug。
在我们产品集成 chromecast 需求中,PM 要求自定义的 UI 和交互:
- 需要自定义设备列表的样式
- 需要有连接中的过渡状态
在经过一番对 cast SDK 的探索,我们做出了想要的效果。在这里将中间的思考分享出来,可以给需要集成 Chromecast 的业务做参考。
如何拿到设备列表
要定制设备列表,首先要拿到设备列表。既然原生 SDK 能提供一个设备选择弹窗,那就说明设备列表数据是可以拿到的。
MediaRouteChooserDialog 就是原生 SDK 中点击按钮后弹出的设备弹窗。它使用 ListView 展示设备列表。
private RouteAdapter mAdapter;
private ListView mListView;
在构造 RouteAdapter 的时候,会传入设备列表 mRoutes。每一个设备就是一个 RouteInfo。
private ArrayList<MediaRouter.RouteInfo> mRoutes;
mAdapter = new RouteAdapter(getContext(), mRoutes);
mRoutes 是在 refreshRoutes() 方法中赋值的。
private final MediaRouter mRouter;
mRouter = MediaRouter.getInstance(context);
public void refreshRoutes() {
if (mAttachedToWindow) {
ArrayList<MediaRouter.RouteInfo> routes = new ArrayList<>(mRouter.getRoutes()); // 拿到所有的设备
onFilterRoutes(routes);// 过滤设备
Collections.sort(routes, RouteComparator.sInstance); // 排序
......
}
}
从 refreshRoutes() 方法中我们可以看到,所有的设备都是从 MediaRouter.getInstance(context).getRoutes() 这个单例中的方法里拿到的。
RouteInfo 的各个字段
一个 Chromecast 设备对应的 RouteInfo 信息如下:
MediaRouter.RouteInfo{
uniqueId=com.google.android.gms/.cast.media.CastMediaRouteProviderService_Persistent:3007f38de73046e2ebf5e9d6da33b2e3,
name=Bathroom TV,
description=Chromecast,
iconUri=null,
enabled=true,
connectionState=0,
canDisconnect=false,
playbackType=1,
playbackStream=-1,
deviceType=1,
volumeHandling=0,
volume=9,
volumeMax=20,
presentationDisplayId=-1,
extras=Bundle[mParcelledData.dataSize=808],
settingsIntent=null,
providerPackageName=com.google.android.gms
}
我们需要重点关注的字段如下:
-
uniqueId
- 设备的唯一标识 id。这个 id 可以用来比较两个 RouteInfo 是否是同一台设备。
-
name
- 设备名,在设备列表中展示
-
enabled
- 代表设备是否可用
-
connectionState
- 设备当前的链接状态。这个值有坑,后面会说到
-
deviceType
- 这个值代表当前这台设备的类型。支持 Chromecast 的设备有电视、音响等不同的类型,可以用这个值来区分并且显示对应类型的 icon。这个值存在以下枚举
- DEVICE_TYPE_UNKNOWN = 0
- DEVICE_TYPE_TV = 1
- DEVICE_TYPE_SPEAKER = 2
- DEVICE_TYPE_BLUETOOTH = 3
-
providerPackageName
- 表示当前设备的 IPC 是由哪个应用提供的。这个值有时候可以用来判断设备类型。
- 表示当前设备的 IPC 是由哪个应用提供的。这个值有时候可以用来判断设备类型。
设备列表过滤
当我们通过 MediaRouter.getInstance(context).getRoutes() 后,还不能直接拿来用。因为这些 RouteInfo 列表里面包括了当前手机、chromecast 设备、以及蓝牙设备等三类设备。我们只需要展示 chromecast 设备,所以我们要对设备列表进行过滤。
手机设备的 `RouteInfo` 信息如下:
蓝牙设备的 RouteInfo 信息如下:
-
首先过滤掉当前手机以及蓝牙
RouteInfo.isDefault()方法可以判断是否是手机,可以用于过滤当前手机设备- 使用上面提到的
deviceType == 3来过滤掉蓝牙设备
-
然后过滤掉同一台设备的多个
RouteInfo- 这是 SDK 的一个坑点。一台 Chromecast 硬件设备可能会在
Routes列表里对应多个 uid 不同的RouteInfo,如下。因为 uid 不同,也没办法简单根据 uid 来去重。
- 这是 SDK 的一个坑点。一台 Chromecast 硬件设备可能会在
- 还记得之前的
refreshRoutes()中调用了onFilterRoutes(routes)来过滤设备吗?这个方法就是用来过滤掉重复RouteInfo的。仿照这个方法的逻辑,我们可以使用下面的代码来过滤:
val selector = CastContext.getSharedInstance()?.mergedSelector
if (selector != null && routeInfo.matchesSelector(selector)){
// 过滤设备的逻辑
......
}
设备状态展示
RouteInfo 的连接状态
每一台设备都有三个状态,
- 未连接 NOT_CONNECTED
- 连接中 CONNECTING
- 已连接 CONNECTED
之所以有连接中的状态,是因为连接 chromcast 设备是一个耗时的过程,可能会花费10多秒。在我们点击设备列表中的某台设备之后,我们希望这台设备显示连接中的状态。
之前说到,RouteInfo 内部有一个整型变量 connectionState 来标识该设备的状态,这个变量同样存在一下枚举:
- CONNECTION_STATE_DISCONNECTED = 0
- CONNECTION_STATE_CONNECTING = 1
- CONNECTION_STATE_CONNECTED = 2
当我们点击设备时,通过这个变量的值来刷新设备的连接状态,一切看起来都非常合理。
但是,经过我反复的测试,connectionState 这个值其实非常不靠谱,几乎没有出现过 CONNECTION_STATE_CONNECTING 的状态。导致连接中的状态根本展示不出来。这也是 SDK 里面的又一个坑,应该也算一个 bug。
如何获取连接中的状态
那么我们如何获取连接中的状态呢?
可以给 CastContext 添加监听,代码如下:
val mCastStateListener = CastStateListener { newState ->
// 监听逻辑
......
}
CastContext.getSharedInstance().addCastStateListener(mCastStateListener)
CastContext 本身既可以用来监听连接状态 CastState,也可以用来监听设备和手机建连的 session 状态。session 状态监听如下:
private val mSessionManagerListener = object : SessionManagerListener<CastSession> {
override fun onSessionStarting(p0: CastSession) {
LazyLogger.i(TAG) {
// 标志设备连接中的状态
"onSessionStarting ${p0.logStr()}"
}
}
override fun onSessionStarted(p0: CastSession, p1: String) {
LazyLogger.i(TAG) {
// 标志设备已连接的状态
"onSessionStarted ${p0.logStr()}, sessionId: $p1"
}
}
override fun onSessionStartFailed(p0: CastSession, p1: Int) {
LazyLogger.i(TAG) {
// 设备连接失败
"onSessionStartFailed ${p0.logStr()}, reason: ${
CastStatusCodes.getStatusCodeString(p1)
}"
}
}
override fun onSessionEnding(p0: CastSession) {
LazyLogger.i(TAG) {
// 设备连接断开中
"onSessionEnding ${p0.logStr()}"
}
}
override fun onSessionEnded(p0: CastSession, p1: Int) {
LazyLogger.i(TAG) {
// 设备连接断开
"onSessionEnded ${p0.logStr()}, reason: ${
CastStatusCodes.getStatusCodeString(p1)
}"
}
}
override fun onSessionResuming(p0: CastSession, p1: String) {
LazyLogger.i(TAG) {
// 设备连接恢复中,通常是连接中应用重启的场景
"onSessionResuming ${p0.logStr()}, sessionId: $p1"
}
}
override fun onSessionResumed(p0: CastSession, p1: Boolean) {
LazyLogger.i(TAG) {
// 设备连接已恢复
"onSessionResumed ${p0.logStr()}, fromSuspension: $p1"
}
}
override fun onSessionResumeFailed(p0: CastSession, p1: Int) {
LazyLogger.i(TAG) {
// 设备连接恢复失败
"onSessionResumeFailed ${p0.logStr()}, reason: ${
CastStatusCodes.getStatusCodeString(p1)
}"
}
}
override fun onSessionSuspended(p0: CastSession, p1: Int) {
LazyLogger.i(TAG) {
// 设备连接挂起,通常是连接中的时候应用被杀死
"onSessionSuspended ${p0.logStr()}, reason: ${
CastStatusCodes.getStatusCodeString(p1)
}"
}
}
}
}
// 监听
mCastContext?.sessionManager?.addSessionManagerListener(
mSessionManagerListener,
CastSession::class.java
)
综上所述,连接状态有两个监听的方法:
- 监听
CastState
CastContext.getSharedInstance().addCastStateListener(mCastStateListener)
- 监听
SessionManager
CastContext.getSharedInstance().sessionManager?.addSessionManagerListener(
mSessionManagerListener,
CastSession::class.java
)
回到我们的标题,我们监听连接状态只是想获得设备的连接中 CONNECTING 状态,其余状态已经可以在 RouteInfo.connectionState 中拿到了。接下来我们分别分析这两个监听。
监听 CastState
通过 onCastStateChanged(int var1) 方法一共可以监听到4个状态:
public interface CastStateListener {
void onCastStateChanged(int state);
}
public static final int NO_DEVICES_AVAILABLE = 1; // 无设备
public static final int NOT_CONNECTED = 2;// 有设备,但没连接
public static final int CONNECTING = 3;// 连接中
public static final int CONNECTED = 4;// 已连接
每一个状态的回调都是准确的。回调的连接中状态也是准确的。
但是 onCastStateChanged(int var1) 这个方法传递的只有状态常量,如果有多台设备,开始连接其中一台,该方法会正常回调到 CONNECTING 的状态,但是我们并不知道是哪个设备在连接,所以这个监听不适合区分多台设备的状态。
当然,在业务层记录下开始连接的设备,比如点击设备时记录下来,那么就知道是哪台设备在连接中了。这个 workaround 貌似可以绕过这个限制,但是据我亲测,只要稍微复杂一点的场景就无能为力了。比如我们想做自定义的设备列表,并且多台设备在列表弹窗展示/关闭等跨生命周期的场景下,这个方法就行不通了,具体就不展开说了,可以亲自试下。
监听 SessionManager
- 要获取连接中的状态,我们只要关注
onSessionStarting这个回调即可
-
该回调中会传入一个
CastSession的对象,该对象中可以拿到设备信息,如下-
override fun onSessionStarting(session: CastSession) { LazyLogger.i(TAG) { "ChromeCastController -> onSessionStarting ${session.logStr()}" } // deviceId val deviceId = session.castDevice.deviceId // device name val deviceName = session.castDevice.friendlyName }
-
上面拿到的 deviceId 和 routeInfo 的 id 有些区别,如下:
| deviceId | deviceName | |
|---|---|---|
| RouteInfo | com.google.android.gms/.cast.media.CastMediaRouteProviderService_Persistent:f46691d2ad1846250150a9e81cd120fc | Bathroom TV |
| session.castDevice | f46691d2ad1846250150a9e81cd120fc | Bathroom TV |
session.castDevice 拿到的 deviceName 和 RouteInfo 的 deviceName 相同,但是多台设备名称可能会重复,所以更优的方式是使用 ID 来匹配。
session.castDevice 可以拿到当前正在连接的设备 ID,这样我们的目标就达成了。但是这个 ID 少了前缀,所以在匹配的时候记得要把 RouteInfo 的 ID 的前缀去掉。
SessionManager还有其他的一些回调,这里补充一下它们的场景
| override fun onSessionStarted(p0: CastSession, p1: String) | 设备连接上触发该回调,同时 CastState 的 CONNECTED 触发 | |
|---|---|---|
| override fun onSessionStartFailed(p0: CastSession, p1: Int) | 设备连接失败触发该回调,同时 CastState 的 NOT_CONNECTED 触发 | |
| override fun onSessionEnding(p0: CastSession) | 设备连接断开中,这个回调基本没用,断开连接很快,用户基本无耗时感知 | |
| override fun onSessionEnded(p0: CastSession, p1: Int) | 设备连接已断开,同时 CastState 的 NOT_CONNECTED 触发 | |
| override fun onSessionSuspended(p0: CastSession, p1: Int) | 设备连接挂起,通常是已连接的应用进程被杀掉了触发 | CastSession resume 的时候,CastState 会经历 NOT_CONNECTED 和 CONNECTED 两个状态,并不会经历 CONNECTING 的状态。这里是个坑点。另外亲测 其他的一些音乐播放器,比如 Spotify 也是没有考虑 resume 场景的。在重启后,设备列表会直接从未连接变成已连接,没有连接中的状态。 |
| override fun onSessionResuming(p0: CastSession, p1: String) | 设备连接恢复中,通常是suspend之后应用重启触发。 | |
| override fun onSessionResumed(p0: CastSession, p1: Boolean) | 设备连接已恢复 | |
| override fun onSessionResumeFailed(p0: CastSession, p1: Int) | 设备连接恢复失败 |
从上面可以看出,监听 SessionManager 可以覆盖的场景非常全面,似乎我们只需要这个监听就可以了,不需要 CastState 的监听。
然而并非如此。SessionManager 只对手机和设备的 session 进行监听,如果手机仅仅是扫描到设备并未连接,此时没有sessionSessionManager 压根就不会回调任何方法。这时候就需要 CastState 的监听了,它会返回一个 NOT_CONNECTED 的回调,标识扫描到设备,但是没有连接。
综上所述,RoutesInfo 拿到设备列表,监听 CastState 获取设备的连接和断连状态,监听 SessionManager 监听设备的连接中的状态,就可以打造完美的定制化 ChromeCast 交互。
总结
- ChromeCast 的官方文档没有提供自定义交互,但是从原生交互来看,既然原生可以做,那我们就应该也可以做自己的设备列表交互。深入分析了源码之后,是完全可以做的,也并没有很难。相比于直接集成的竞品,定制化集成带来的收益也很明显。当然整个过程需要一些技术判断力和源码的分析能力的。
- ChromeCast 作为三方的 SDK,集成过程中非常容易踩坑。我在开发过程中就不止发现了一处 Chromecast 的 bug。这也是集成各种三方组件的通病,bug 多。并且三方组件没有 onCall,有时候遇到棘手的 bug 会很难推进。这时候就需要有足够的源码分析和设计的能力,绕过这些坑,或者直接通过修改代码的方式解决。