在上一篇博客《Android DLNA投屏-基本原理》中,讲到了DLNA的一些基本原理。了解这些基本原理,对开发是很有帮助的。但仅仅依据原理去进行Android DLNA开发,是比较困难的。我们需要使用一些优秀的开源框架,这样能很大程度上提高开发效率,使得开发变得更简单。Android有如下几个用于DLNA开发的主流框架:
1. Cling. Cling是一个Java开源项目,开发者可直接编译源码生成jar包导入到Android项目中。目前Cling已停止维护,但这并不影响它的热度。
2. Platinum. Platinum是一个C库,它支持编译成多个平台的库,如Windows、Mac、IOS和Android等。但其编译流程相对来说比较复杂,Android使用Platinum开发需要用到jni。
3. CyberGarage. CyberGarage是一个Java Upnp开发包,开发者将其项目源码添加到Android工程当中,作为Android Library或者 Java Library直接使用。CyberGarage提供了jar包下载地址,但CyberGarage源码存在一些bug,需要对源码进行修改,因此不建议直接下载jar包。
由于原理相同,这些框架的使用方式都十分类似。本篇博客将介绍如何使用CyberGarage,进行Android DLNA投屏开发。使用Platinum和Cling的朋友,请参照github项目的文档指引进行开发。
1. 准备
由于Upnp是基于xml格式通信的,因此需要先下载xml解析包以获取xml解析支持, CyberGarage支持以下几种xml解析包:
- jaxp (java自带,不用下载)
- XmlPullParser (Android自带,不用下载)
- xerces2
- kxml2
选择其中一种解析包添加到项目中,CyberGarage会在解析xml时使用该解析包,上述解析包在CyberGarage中的使用优先级是从 4 到 1。
添加完xml解析包后,再将CyberGarage项目添加到Android工程中,就可以开始进行开发了。
2. ControlPoint
在上一篇博客《Android DLNA投屏-基本原理》中已提到,Android设备在投屏过程中主要扮演着控制点
的角色。在CyberGarage项目中,与控制点
相对应的类为ControlPoint类。只要创建并使用该类的实例,就能实现控制点
的功能。
(1)初始化
实现初始化,只需要调用start方法即可,注意该方法要在子线程中调用:
ControlPoint controlPoint = new ControlPoint();
// 初始化
new Thread(new Runnable() {
public void run() {
controlPoint.start();
}
}).start();
(2)搜索设备
搜索设备的方法为search方法,但与start方法一样,需要在子线程中调用:
new Thread(new Runnable() {
public void run() {
controlPoint.start();
controlPoint.search();
}
}).start();
(3)设备通知监听
添加设备通知监听,只需实例化一个NotifyListener并实现其deviceNotifyReceived方法,然后与ControlPoint实例绑定:
controlPoint.addNotifyListener(new NotifyListener() {
@Override
public void deviceNotifyReceived(SSDPPacket packet) {
Log.i(TAG, "Got Notification from device, remoteAddress is" + packet.remoteAddress);
}
})
(4)搜索结果监听
添加设备通知监听,则需要实例化一个SearchResponseListener并实现其deviceSearchResponseReceived方法,然后与ControlPoint实例绑定:
controlPoint.addSearchResponseListener(new SearchResponseListener() {
@Override
public void deviceSearchResponseReceived(SSDPPacket packet) {
Log.i(TAG, "A new device was searched, remoteAddress is" + packet.remoteAddress);
}
});
(5)设备变化监听
如果需要在设备被移除/添加的时候,做一些操作,则需要实例化一个DeviceChangeListener并实现其deviceRemoved和deviceAdded方法,然后与ControlPoint实例绑定:
controlPoint.addDeviceChangeListener(new DeviceChangeListener() {
@Override
public void deviceRemoved(Device device) {
Log.i(TAG, "Device was removed, device name: " + device.friendlyName});
}
@Override
public void deviceAdded(Device device) {
Log.i(TAG, "Device was added, device name:" + device.friendlyName);
}
})
(6)发送动作请求
要向设备发送动作请求,以实现对设备的控制,首先得获取已添加的设备(Device类)的实例。而支持投屏播放的设备的设备类型主要为DMR,deviceType
的值为urn:schemas-upnp-org:device:MediaRenderer:x
。因此,添加设备前要做一个对设备类型的判断:
controlPoint.addDeviceChangeListener(new DeviceChangeListener() {
@Override
public void deviceRemoved(Device device) {
if ("urn:schemas-upnp-org:device:MediaRenderer:1".equals(device.getDeviceType())) {
deviceList.remove(device);
}
}
@Override
public void deviceAdded(Device device) {
// 判断是否为DMR
if ("urn:schemas-upnp-org:device:MediaRenderer:1".equals(device.getDeviceType())) {
deviceList.add(device);
}
}
})
这里用一个列表缓存已添加的设备,当要使用某个设备时,再从列表中获取对应实例。
获取设备实例后,需要从设备实例中根据serviceType
获取Service类的实例,再从Service类实例中根据动作名
获取Action类的实例,最后调用postControlAction方法发送请求。
DLNA投屏播放的服务的serviceType
值为:urn:schemas-upnp-org:service:AVTransport:x
;
实现播放需要发送两个动作请求:
-
SetAVTransportURI
。设置播放URI。需要转入两个参数: 1.InstanceID
实例ID, 2.CurrentURI
要设置的URI。 -
Play
。播放视频。需要传入一个参数: 1.InstanceID
实例ID.
因此,整个投屏播放的动作请求代码如下:
// 实例ID
String instanceID = "0";
// 播放视频地址
String currentURI = "http://hc.yinyuetai.com/uploads/videos/common/026E01578953FD0EF0E47204247B5D13.flv?sc=2d17ae37a9186da6&br=780&vid=2693509&aid=623&area=US&vst=2";
Device device = deviceList.get(0);
// 获取服务
Service service = device.getService("urn:schemas-upnp-org:service:AVTransport:1");
// 获取动作
Action transportAction = service.getAction("SetAVTransportURI");
// 设置参数
transportAction.setArgumentValue("InstanceID", instanceID);
transportAction.setArgumentValue("CurrentURI", transportURI);
// SetAVTransportURI
if(transportAction.postControlAction()) {
// 成功
Action playAction = service.getAction("Play");
playAction.setArgumentValue("InstanceID", instanceID);
// Play
if (!playAction.postControlAction()) {
Log.e("upnpErr", playAction.getStatus().getDescription());
}
} else {
// 失败
Log.e("upnpErr", transportAction.getStatus().getDescription());
}
如果不清楚某个设备的服务
和动作
,则可以查看其设备描述文档
和SDD
,通过如下代码可以获取设备描述文档
和SDD
的链接地址:
// 设备描述文档
String locationUrl = device.getLocation();
// 获取服务
Service service = device.getService("urn:schemas-upnp-org:service:AVTransport:1");
URL url = new URL(locationUrl);
// SDD
String sddUrl = locationUrl的ip地址和端口号 + service.getSCPDURL();
(7)事件订阅
如果设备在发生某些事件时,控制点
需要跟着发生变化,如设备暂停播放,那么控制点
的播放按钮理应变为暂停状态;则需要对设备进行事件订阅,订阅方法如下:
Device device = deviceList.get(0);
// 获取服务
Service service = device.getService("urn:schemas-upnp-org:service:AVTransport:1");
boolean ret = controlPoint.subscribe(service);
if (ret) {
// 订阅成功
} else {
// 订阅失败
}
要监听事件回调,则需要创建一个EventListener与ControlPoint实例绑定,当设备发生事件时,会执行EventListener中的eventNotifyReceived方法:
controlPoint.addEventListener(new EventListener() {
@Override
public void eventNotifyReceived(String uuid, long seq, String name, String value) {
// 事件回调
...
}
});
3. CyberGarage源码中的Bug
上文已提到,CyberGarage源码是存在Bug的,所以需要对源码进行一些修改,下面列出开发时遇到的一些 Bug:
(1)getAction方法返回一直为空
在获取到Service类实例后,发现调用Service类实例的getAction方法获取Action类实例时,返回的结果一直为空。
// 获取服务
Service service = device.getService("urn:schemas-upnp-org:service:AVTransport:1");
// 返回一直为null
Action action = service.getAction("SetAVTransportURI");
考虑到可能是设备服务中没有此动作
,因此通过浏览器查看设备的sdd文档,发现文档中是有该SetAVTransportURI
的动作描述,对此可以断定,设备是可以进行SetAVTransportURI
的动作请求的。
为了找出问题,对getAction方法进行断点,并分析其源码执行情况,getAction的源码如下:
public Action getAction(String actionName)
{
ActionList actionList = getActionList();
int nActions = actionList.size();
for (int n=0; n
执行到getActionList方法时发现该方法直接返回一个空的列表。而根据文档描述,这里应该返回多个节点才对,因此我们看看getActionList这个方法的源码是否存在问题:
public ActionList getActionList()
{
ActionList actionList = new ActionList();
Node scdpNode = getSCPDNode();
if (scdpNode == null)
return actionList;
...
}
执行到getSCPNode这个方法时,该方法返回为空了,导致getActionList这个方法返回了一个空的列表。这是什么原因呢? 我们再继续看看getSCPNode方法的源码:
private Node getSCPDNode()
{
...
try {
URL scpdUrl = new URL(rootDev.getAbsoluteURL(scpdURLStr));
System.out.println("SPCDURL: " + scpdURLStr);
scpdNode = getSCPDNode(scpdUrl);
if (scpdNode != null) {
data.setSCPDNode(scpdNode);
return scpdNode;
}
} catch (Exception e) {}
...
}
当执行到URL scpdUrl = new URL(rootDev.getAbsoluteURL(scpdURLStr))这句代码的时候,出现很奇怪的现象,在调试工具中查看rootDev.getAbsoluteURL(scpdURLStr)的返回时,发现它的值时这样的:
http://192.168.42.37:2869/upnphost/udhisapi.dll?content=uuid:79884bb3-3148-433f-b140-e790b6ec22ed/upnphost/udhisapi.dll?content=uuid:fe18f6aa-02fc-4e53-891c-48ef5d5b6957
终于找出原因了,这是因为rootDev.getAbsoluteURL(scpdURLStr)方法拼接SCDPURL出错了,导致无法获取并解析SDD
文档中xml节点,从Android Profiler的记录中就可以看到SDD请求结果了:
返回的内容为空,自然无法获取对应的
动作
。那个rootDev.getAbsoluteURL(scpdURLStr)这个方法究竟错在哪里呢?我们继续看源码:
public String getAbsoluteURL(String urlString) {
String baseURLStr = null;
String locationURLStr = null;
Device rootDev = getRootDevice();
if (rootDev != null) {
baseURLStr = rootDev.getURLBase();
locationURLStr = rootDev.getLocation();
}
return getAbsoluteURL(urlString, baseURLStr, locationURLStr);
}
这里依旧看不出什么问题,让我们看一下它的重载方法:
public String getAbsoluteURL(String urlString, String baseURLStr, String locationURLStr) {
if ((urlString == null) || (urlString.length() <= 0)) return "";
try {
URL url = new URL(urlString); return url.toString();
} catch (Exception e) {}
if (baseURLStr == null || baseURLStr.length() <= 0) {
if ((locationURLStr != null) && (0 < locationURLStr.length())) {
if (!locationURLStr.endsWith("/") || !urlString.startsWith("/")) {
String absUrl = locationURLStr + urlString;
try {
URL url = new URL(absUrl);
return url.toString();
} catch (Exception e) {}
}
}
...
}
...
}
程序执行到了if (!locationURLStr.endsWith("/") || !urlString.startsWith("/"))
这个判断中,问题就出现在下面这句代码中:
String absUrl = locationURLStr + urlString;
这里直接拿locationURLStr和urlString拼接,这明显是不正确的,因为某些url可能在url后附带一些参数,如上例的locationURLStr是这样的:
http://192.168.42.37:2869/upnphost/udhisapi.dll?content=uuid:79884bb3-3148-433f-b140-e790b6ec22ed
于是跟urlString拼接起来就出问题了,要解决这个问题,便是通过URL类实例,获取字符串的协议、ip地址和端口号,再与urlString拼接,如下:
public String getAbsoluteURL(String urlString, String baseURLStr, String locationURLStr) {
if ((urlString == null) || (urlString.length() <= 0)) return "";
try {
URL url = new URL(urlString); return url.toString();
} catch (Exception e) {}
if (baseURLStr == null || baseURLStr.length() <= 0) {
if ((locationURLStr != null) && (0 < locationURLStr.length())) {
if (!locationURLStr.endsWith("/") || !urlString.startsWith("/")) {
try {
URL locationURL = new URL(locationURL);
// 重新拼接url
String absUrl = locationURL.getProtocol() + "://" + locationURL.getHost() + ":" + locationURL.getPort() + urlString;
URL url = new URL(absUrl);
return url.toString();
} catch (Exception e) {}
}
}
...
}
...
}
修改以后,getAction方法就能正确获取对应的动作
了。