-
2021-06-08 12:47:37节点的内容是请求体,请求体的格式为:更多相关内容
-
android DLNA投屏
2021-06-05 03:50:40android投屏技术的基本原理就是根据DLNA以及UPNP来实现,另外还有些黑科技技术便是根据端口号或者通过广播来用adb下载本身相关的apk来间接实现投屏,当然此处不提及黑科技。原理什么的百度一堆。这里主要讲实现方式...android投屏技术的基本原理就是根据DLNA以及UPNP来实现,另外还有些黑科技技术便是根据端口号或者通过广播来用adb下载本身相关的apk来间接实现投屏,当然此处不提及黑科技。原理什么的百度一堆。这里主要讲实现方式和具体实现的代码。
那么,开始开发这玩意的时候肯定要先看看有没有现成的轮子,git上是有轮子的,链接如下:
https://github.com/4thline/cling 最基础的包,main类方法中有基础的调用方式
https://github.com/offbye/DroidDLNA 在cling上改动,代码表现为当前设备的媒资资源DLNA投屏,没涉及到相关的网络媒资投屏
DroidDLNA 中是有bug的,具体表现在api版本为25以后,是不能投屏的,原因出自AndroidNetworkAddressFactory类中的反射问题,api版本25以后,结果与之前完全不一致,改法已经在cling中完善,代码改动如下:
@Override
protected boolean isUsableAddress(NetworkInterface networkInterface, InetAddress address) {
boolean result = super.isUsableAddress(networkInterface, address);
if (result) {
// TODO: Workaround Android DNS reverse lookup issue, still a problem on ICS+?
// http://4thline.org/projects/mailinglists.html#nabble-td3011461
String hostName = address.getHostAddress();
Log.e("gjh test Log", "sdk version=" + Build.VERSION.SDK_INT + "->hostName=" + hostName);
try {
Field field0 = null;
Object target = null;
try {
field0 = InetAddress.class.getDeclaredField("holder");
field0.setAccessible(true);
target = field0.get(address);
field0 = target.getClass().getDeclaredField("hostName");
} catch( NoSuchFieldException e ) {
// Let's try the non-OpenJDK variant
field0 = InetAddress.class.getDeclaredField("hostName");
target = address;
}
if (field0 != null && hostName != null) {
field0.setAccessible(true);
field0.set(target, hostName);
} else {
return false;
}
} catch (Exception ex) {
log.log(Level.SEVERE,
"Failed injecting hostName to work around Android InetAddress DNS bug: " + address,
ex
);
return false;
}
}
return result;
}
DLNA轮子中投屏的方法为广播,代码表现为:
private void jumpToControl(ContentItem localContentItem) {
Intent localIntent = new Intent("com.transport.info");
localIntent.putExtra("name", localContentItem.toString());
localIntent.putExtra("playURI", localContentItem.getItem()
.getFirstResource().getValue());
localIntent.putExtra("currentContentFormatMimeType",
currentContentFormatMimeType);
try {
localIntent.putExtra("metaData",
new GenerateXml().generate(localContentItem));
} catch (Exception e) {
e.printStackTrace();
}
IndexActivity.mTabHost.setCurrentTabByTag(getString(R.string.control));
IndexActivity.setSelect();
this.sendBroadcast(localIntent);
}
当然如果需求为投射本地已经存在的资源的话只要稍微改动下界面和bug即可。
下面说说如何投射网络在线视频。
从上面的DLNA播放本地资源已经大致可以猜到只要修改其中的name、playURI和metaData即可。
但实际上我们只要改动metaData,因为最终解析是根据MetaData来拿到相关的媒资数据,其他大致上没什么用处
具体实现的代码如下:
private AndroidUpnpService upnpService;//DLNA投屏服务
private DeviceListRegistryListener deviceListRegistryListener;//搜索设备的回调
mContext.bindService(
new Intent(mContext, AndroidUpnpServiceImpl.class),
serviceConnection, Context.BIND_AUTO_CREATE);
private ServiceConnection serviceConnection = new ServiceConnection() {
public void onServiceConnected(ComponentName className, IBinder service) {
if (mContext != null) {
upnpService = (AndroidUpnpService) service;//本地设备服务,用于执行投屏命令
upnpService.getRegistry().addListener(deviceListRegistryListener);//搜索设备的回调
ThreadManager.execute(new Runnable() {
@Override
public void run() {
if (mContext != null && upnpService != null) {
upnpService.getControlPoint().search();//搜索相关设备
}
}
});
}
}
public void onServiceDisconnected(ComponentName className) {
upnpService = null;
}
};
public class DeviceListRegistryListener extends DefaultRegistryListener {
@Override
public void remoteDeviceRemoved(Registry registry, RemoteDevice device) {
if (device.getType().getNamespace().equals("schemas-upnp-org")
&& device.getType().getType().equals("MediaRenderer")) {
final DeviceItem dmrDisplay = new DeviceItem(device, device
.getDetails().getFriendlyName(),
device.getDisplayString(), "(REMOTE) "
+ device.getType().getDisplayString());
dmrRemoved(dmrDisplay);
}
}
@Override
public void remoteDeviceAdded(Registry registry, RemoteDevice device) {
if (device.getType().getNamespace().equals("schemas-upnp-org")
&& device.getType().getType().equals("MediaRenderer")) {
final DeviceItem dmrDisplay = new DeviceItem(device, device
.getDetails().getFriendlyName(),
device.getDisplayString(), "(REMOTE) "
+ device.getType().getDisplayString());
dmrAdded(dmrDisplay);
}
}
public void dmrAdded(final DeviceItem di) {
if (mTvDataList == null) {
mTvDataList = new ArrayList<>();
}
mTvDataList.add(di);
}
public void dmrRemoved(final DeviceItem di) {
if (mTvDataList != null && mTvDataList.contains(di)) {
mTvDataList.remove(di);
}
}
}
private void playToTv(DeviceItem deviceItem) {
String url = "网络视频链接";
Service avtService = deviceItem.getDevice()
.findService(new UDAServiceType("AVTransport"));
String metaData = TvUtil.pushMediaToRender(url, "video-item", "DLNA测试视频", "50:00", "ggg");
upnpService.getControlPoint().execute(new SetAVTransportURI(avtService, url, metaData) {
@Override
public void success(ActionInvocation invocation) {
super.success(invocation);
LogUtil.e("playToTv", "-------success");
}
@Override
public void failure(ActionInvocation invocation, UpnpResponse operation, String defaultMsg) {
LogUtil.e("playToTv", "-------failure defaultMsg=" + defaultMsg);
}
});
}
当然上面的网络视频链接最好以最终的播放地址,不然会出现无法播放的情况.
具体代码 https://download.csdn.net/download/gan303/10392625
-
DLNA投屏Demo
2017-01-16 16:26:40通过DLNA,把指定的某个网络视频地址投影到支持DLNA的设备上 -
Android DLNA投屏-基于CyberGarage开发投屏功能
2021-05-27 06:51:39在上一篇博客《Android DLNA投屏-基本原理》中,讲到了DLNA的一些基本原理。了解这些基本原理,对开发是很有帮助的。但仅仅依据原理去进行Android DLNA开发,是比较困难的。我们需要使用一些优秀的开源框架,这样能...在上一篇博客《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自带,不用下载)
选择其中一种解析包添加到项目中,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
Action action = actionList.getAction(n);
String name = action.getName();
if (name == null)
continue;
if (name.equals(actionName) == true)
return action;
}
return null;
}
执行到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请求结果了:
Android Profiler记录.png
返回的内容为空,自然无法获取对应的动作。那个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方法就能正确获取对应的动作了。
-
Android dlna 投屏
2021-02-22 11:45:41android dlna 投屏demo github地址https://github.com/liulei9385/CyberLink4Androidandroid dlna 投屏demo
-
simpledlna:基于cling实现的Android投屏方案
2021-05-10 17:50:25simpledlna 基于cling实现的Android投屏方案 源码解析参考 -
android DLNA代码
2018-05-04 17:46:26android DLNA投屏,支持网络在线视频,具体代码欢迎下载观看 -
android端dlna 实例代码
2012-08-22 10:25:45关于在android端DLNA技术的开发 -
DLNA投屏 iOS
2019-01-25 09:42:01DLNA最新投屏自研代码. 兼容Sony, 华为, 乐视,小米等各种电视 -
基于DLNA实现iOS、Android投屏
2020-08-05 11:22:36由于我司需求,需要在iOS和安卓客户端实现DLNA投屏和控制。经过一番折腾,决定由我来研究DLNA。说起来又兴奋又紧张,兴奋希望自己能够弄出来然后跟安卓组讲解原理,紧张是因为怕自己能力不足做不出来。 DLNA网上的... -
Dlna投屏
2021-08-08 11:04:281.0.0(2020-02-01)初次添加,目前支持android端的dlna投屏。查看更多平台兼容性AndroidiOS√×原生插件通用使用流程:购买插件,选择该插件绑定的项目。在HBuilderX里找到项目,在manifest的app原生插件配... -
基于DLNA实现投屏的思路梳理
2019-08-29 14:30:26基于DLNA实现投屏的思路梳理(依赖开源库cling) 简介 DLAN(Digital Living Network Alliance),数字生活网络联盟。DLNA并不是创造技术,而 是形成一种解决方案,一种大家可以遵守的规范。其中选择的技术和协议... -
DLNA 在自己的APP 中添加投屏功能
2021-06-05 03:52:22demo学习中关键词:clingdemofrom: DROID DLNA关于投屏的原理,协议相关的知识已经在上一篇中说明。关于设备搜索,查找等不在此处说明。投屏系统中分:设备服务控制点。当需要把A设备中的视频投屏到B设备。重点是:... -
DLNA资料、DLNA的demo、DLNA的源码合辑
2014-08-22 17:11:22包括DLNA协议介绍 DLNA的demo android版) 资料说明 绝对超值 -
Android DLNA实现
2015-04-13 16:03:03android dlna实现,可以接收播放器推送过来的音频文件 -
android投屏技术:控制设备源码分析
2021-06-02 22:41:02但我会竭尽所能的把我所了解的东西分享出来,我希望对那些做 DLNA 的童鞋有所帮助。阅读源码的好处,首先就是能够更了解它的原理,这能帮助我们更好的使用它。同时,阅读源码可以能提升我们逻辑思维能力,以及设计... -
Android投屏方案(基于cling)
2019-07-22 10:14:49视频播放的项目,是在73.0.3683.90版本的chrome源码上修改而来,涉及到抓取网页里视频的播放地址、播放视频、视频投屏、视频下载、网页内广告屏蔽等方面,了解到ijkplayer、GSYVideoPlayer、ffmpeg、乐播投屏、cling... -
Android中开发DLNA,Miracast对比
2019-08-29 14:23:22Android中开发DLNA,Miracast对比 需求是在android 9.0上实现投屏,手机,高通平台。 主流的投屏技术:Miracast,DLNA,AirPlay。 Google主推的google cast,或者说Chrome cast,受限于google账号,及需google ... -
DLNA之DMP实现(android)
2013-01-07 16:27:26基于cyberlink库的DLNA-DMP之android实现 代码结构清晰,播放界面整洁,DLNA开发必备资料 内附mirage第三方软件做DMS之用 -
Android应用源码通用DLNA实现手机电视同步播放
2017-02-09 16:50:05本项目是一个家庭互联的项目源码,首先设备需要在一个局域网中,然后就可以通过手机查找到电视的ip进行投屏 可以将照片或者音乐或者视频投放到电视上进行播放 -
DLNA投屏,支持IOS和安卓
2021-03-14 20:05:09更新记录1.0.20200413(2020-04-13)增加获取本机IPV4地址的接口getIpAddress,解决有些小伙伴需要在手机上起http服务来进行投屏无法获取本机IP的问题。目前只在wifi环境下进行过测试。源码已经上传到码云上,有能力的... -
dlna.jar 包
2017-11-10 14:23:51dlna.jar包 主要功能接口:1)搜索当前网络上有没有dlna设备; 2)读取本地音频文件; 3)音频播放、暂停、下一首、上一首、音量控制。 -
基于DLNA实现iOS、Android投屏:基本概念
2016-03-04 09:52:32由于我司需求,需要在iOS和安卓客户端实现DLNA投屏和控制。经过一番折腾,决定由我来研究DLNA。说起来又兴奋又紧张,兴奋希望自己能够弄出来然后跟安卓组讲解原理,紧张是因为怕自己能力不足做不出来。DLNA网上的... -
DLNA DMR源码
2014-04-10 10:42:46DLNA DMRender源码, 可以直接用的 源码哦