
在开发视频监控系统应用软件时,大家往往把关注的焦点集中于数字音/视频的编解码的实现上,而忽略了视频监控系统应用软件的整体架构。当然视频监控的核心也是在于 音视频编解码上,佰锐的Anychat SDK 主要就是在音视频领域这块,长期研究音视频即时通讯,对于音视频处理模块(采集、编解码)、流媒体管理模块(丢包重传、抖动平滑、动态缓冲)、流媒体播放模块(多路混音、音视频同步)以及P2P网络模块(NAT穿透、UPnP支持)等多个子模块,封装了底层的硬件 *** 作(音视频采集、播放)、封装了流媒体处理(编解码、网络传输)等非常专业和复杂的技术,为上层应用提供简单的API控制接口,可以在极短的开发周期,以及极少的人力资源投入下为客户的现有平台增加音视频即时通讯、多方会议的功能。AnyChat SDK可以让企业越过复杂的底层技术实现,而把主要精力投入项目的业务逻辑处理上,加快项目开发进展,从而为企业赢得市场先机。
视频监控系统中,一个优秀的音/视频编解码算法固然很重要,但其中是整个视频监控系统应用软件的一个重要组成部分。视频监控系统应用程序还涉及到如何搞笑地输入/输出数字音/视频数据,这些数据又如何与音/视频编解码算法协调、配合,以及视频监控系统应用软件各个模块之间如何协调工作。本文主要阐述软件开发方法,说明层次化软件开发方法优越性。
传统的软件开发方法
传统的软件开发方法是一种线性的程序流程,首先以功能模块对整个程序进行模块化,然后选择ASM或C语言,从零开始编写各个子模块,最后编写一个主循环,将这些子模块线性地顺序循环执行。
互联网是个神奇的大网,软件定制也是一种模式,这里提供最详细的报价,如果你真的想做,可以来这里,这个手技的开始数字是一八七中间的是三儿零最后的是一四二五零,按照顺序组合起来就可以找到,我想说的是,除非你想做或者了解这方面的内容,如果只是凑热闹的话,就不要来了。
传统的软件开发方法的优点是:整个程序的控制流、数据流完全由编程者掌握,程序直观、易理解。但其缺点是:各个子模块之间紧密耦合,修改某一子模块,将可能影响整个程序,也即其代码的重复使用率不高,导致相似系统之间程序的移植性差;由于程序顺序、循环执行,在算法对数据进行处理前,需要花大量时间来等待输入/输出数据就绪,导致CPU的利用率低,同样,简单的顺序、循环执行,只能管理和调度单一任务,不能实现多任务的管理和调度。
倡导的DSP软件开发方法
为了加速DSP软件开发,一套完善的、规范的、标准化的DSP软件开发方法称之为DSP软件技术。它是以DSP/BIOS实时多任务 *** 作系统为核心,以层次化结构为基础的一种软件开发方法,其优点是
软件结构层次化:各层之间均采用标准的API,修改某一层不会影响其它层,提高了代码的重复使用率,改善和提高相似系统之间的程序移植性;
应用层;
设备驱动层;
硬件设备层;
以DSP/BIOS实时多任务内核为主控,使CPU得利用率最大化;
DSP/BIOS负责程序的管理和调度;
DSP/BIOS可对程序的控制流、数据流及程序执行效率进行实时分析。
缺点是:整个程序的控制流、数据流由DSP/BIOS来管理,程序将不再直观和易理解。豪宅DSP/BIOS提供了实时分析模块,可全程实时分析控制流、数据流及程序执行效率。
层次化的设备驱动程序模型
一个设备驱动程序开发包,为设备驱动程序设计一个层次化的模型,称为IOM模型,IOM模型将设备驱动程序分为2层,上层为与硬件无关的层称为类设备驱动程序,负责管理设备实例、同步和I/Q请求串行化等 *** 作。与硬件五官的下层称为迷你设备驱动程序,负责对实际的设备进行初始化或必要的控制 *** 作。
类设备驱动程序
类设备驱动程序是设备驱动程序的上层抽象,时期与特定设备无关,DDK为每一类的类设备驱动程序定义了一组标准的API函数,应用程序均只能通过此组API函数来调用设备驱动程序,从而使应用程序与设备驱动程序分离。
DDK定义了3大类的类驱动程序:SIO、PIP和GIO。
SIO:流I/O接口,由SIO和DIO组成,PIO负责缓冲器管理、信号同步以及底层迷你驱动程序接口。
GIO:通用I/O,允许进行块读块写,设备驱动程序开发者可以用其来实现新的、专用的类设备驱动程序。
DDK中已完整地实现了SIO和PIP类设备驱动程序,使用SIO和PIP类设备驱动程序的设备驱动程序开发者无需再编写任何类设备驱动程序代码。而对于使用GIO类设备驱动程序的设备驱动程序开发者来说,DDK已为GIO实现了一组基本的API函数,所以开发者只需通过宏定义来调用此组API函数,实现自己专用的类设备驱动程序。
迷你设备驱动程序
迷你设备驱动程序时设备驱动程序的底层抽象,与特定设备有关,对硬件设备进行实际 *** 作,DDK为迷你设备驱动程序规定一组标准的API函数,类设备驱动程序通过这些标准化了的API函数来调用迷你设备驱动程序,而对设备驱动程序开发者来说,只需为特定的函数体。在此组API函数的特定的函数体中,用户则可以通过调用CLS/BSL库来对设备的具体硬件进行初始化和相关的控制 *** 作。
为什么要用IOM设备驱动程序模型
IOM设备驱动程序模型是层次化了的设备驱动程序,层次化设计通过使层之间的接口标准化,并且只有相邻层之间才可相互调用,来有效地将上层应用程序与下层具体的硬件设备的 *** 作细节分离。所以,当更换其中的某些硬件外设时,通常只需修改底层的迷你驱动程序,而上层应用程序的修改则可最小化,从而提高上层应用程序的通用性、可重复使用性和可移植性。
IOM模型的设备驱动程序中包含什么
在程序设备是用来完成数据输入/输出的、完整的数据链路,有时单个外设并不一定称为设备,如:音频输入/输出设备。它是由DSP片商McBSP+IIC+DMA+中断+片外Codec等片上/片外外设器件构成。在这样一个数据链路中,单独的一个片上/片外外设并不能完成数据真正的输入/输出,不能称为设备。那么设备室如何来完成相应的数据输入/输出?
首先,需要对构成设备的各外设进行初始化,设置它们的工作方式,这些外设才能正确 *** 作。另外,外设的某些功能需要外设 *** 作过程中动态调节,如:A/D转换器的采样率可能需要应用程序动态地调整;UART器件的波特率可能需要应用程序动态地调整;外设所对应的中断、DMA/EDMA通道等也可能要由应用程序根据需要动态来修改。所以设备驱动程序必定有设备初始化函数、和某些相关的设置函数。
其次,需要对其进行读/写 *** 作,即完成外设最基本的输入/输出功能。应用程序一般是成批地处理数据,而外设往往一个接一个地输入/输出数据,二者之间需要缓冲器来进行缓存,设备驱动程序的输入/输出函数完成外设的时间读/写 *** 作,将数据存入/读出缓冲器,应用程序则在缓冲器可用时,进行相应的处理。由此可见,缓冲器是在应用程序与设备驱动程序之间来回切换的,不同的应用所需的缓冲器的大小不同,而且为了避免数据的覆盖,可能需要用多个缓冲器来进行切换。缓冲器的大小、缓冲器的个数、缓冲器由驱动程序管理还是由应用程序管理可根据应用的需要灵活安排。外设的读/写 *** 作并非随时可以进行,必须满足一定条件,此条件一般用于作为中断信号或标志信号,另外,为了提升输入/输出的效率,往往需要用DMA/EDMA配合工作,驱动程序往往会中断、DMA/EDMA相关联。
最后,驱动程序输入/输出的数据必须由应用程序来处理,应用程序只有在数据就绪时,才能对缓冲器进行读/写 *** 作,就存在驱动程序与应用程序同步的问题,同步一般有二种方式,一种是“阻塞”,另一种带回调函数的非“阻塞”。二种不同的同步方式,实际对应“阻塞”方式时,选用软件中断型线程。
结语
采用IOM模型来开发底层设备驱动程序,要比传统的软件开发更复杂,整个程序的控制流和数据流更不直观和不易理解,但掌握这样的软件开发方法,那么在下一个项目中已开发完的程序的继承性和可移植性将得到充分发挥,在我们今后的软件开发中,将起到事半功倍的作用。如今市场竞争越来越激烈,如何在有限的时间内完成项目,满足客户的需求成为企业决策者所需要面对的现实。Anychat可以为您节约开发时间,缩短项目开发周期;节省开发费用,减少人力资源投入;平台自主开发,提升企业综合竞争力;产品跨平台,应用领域广阔;API接口丰富,方便与第三方业务集成;专业技术支持,性能稳定可靠。
后台监控软件 为了达到隐蔽监控的目的 应该满足正常运行时 不显示在任务栏上 在按Ctrl+Alt+Del出现的任务列表中也不显示 管理员可以通过热键调出隐藏的运行界面 要作到这些 必须把当前进程变为一个系统服务 并且定义全局热键
一 把当前进程变为一个系统服务 目的是在任务列表中把程序隐藏起来 调用API函数RegisterServiceProcess实现
二 定义全局热键(本例中定义热键Ctrl+Del+R) 步骤 定义捕获Windows消息WM_HOTKEY的钩子函数 即 procedure WMHotKey(var Msg : MHotKey)message WM_HOTKEY 向Windows加入一个全局原子 Myhotkey: GlobalAddAtom( MyHotkey ) 并保留其句柄 向Windows登记热键 调用API函数RegisterHotKey实现
三 设计界面和源程序 unit Unit interface uses Windows Messages Forms Dialogs Classes Controls StdCtrlstype TForm = class(TForm) Button : TButtonButton : TButtonprocedure FormCreate(Sender: TObject)procedure Button Click(Sender: TObject)procedure Button Click(Sender: TObject)procedure FormClose(Sender: TObjectvar Action: TCloseAction)private {热键标识ID} id: Integerprocedure WMHotKey(var Msg : MHotKey)message WM_HOTKEY{ Privat Declarations} public { Public Declarations} endvar Form : TForm implementation const RSP_SIMPLE_SERVICE= function RegisterServiceProcess (dwProcessID dwType: DWord) : DWordstdcallexternal KERNEL DLL {$R * DFM}
{捕获热键消息} procedure TForm WMHotKey (var Msg : MHotKey)begin if msg HotKey = id then ShowMessage( Ctrl+Alt+R键被按下! )form Visible :=trueend
procedure TForm FormCreate(Sender: TObject)Const {ALT CTRL和R键的虚拟键值} MOD_ALT = MOD_CONTROL = VK_R = begin {首先判断程序是否已经运行} if GlobalFindAtom( MyHotkey ) = then begin {注册全局热键Ctrl + Alt + R} id:=GlobalAddAtom( MyHotkey )RegisterHotKey(handle id MOD_CONTROL+MOD_Alt VK_R)end else haltend
{把当前进程变为一个系统服务 从而在任务列表中把程序隐藏起来} procedure TForm Button Click(Sender: TObject)begin RegisterServiceProcess(GetCurrentProcessID RSP_SIMPLE_SERVICE)form Hideend
procedure TForm Button Click(Sender: TObject)begin closeend{退出时释放全局热键} procedure TForm FormClose(Sender: TObjectvar Action: TCloseAction)begin UnRegisterHotKey(handle id)GlobalDeleteAtom(id)endend
lishixinzhi/Article/program/Delphi/201311/250391、 配置OPC服务器
对于服务器的配置与同步通讯的配置一样,这里不需再讲解,若有不清楚的,可以参阅之前发布的<运用VC#编程通过OPC方式实现PC机与西门子PLC通讯>
2、 OPC编程
变量组、项的命名规则与同步通讯的一样,这里不再描叙,下面主要就开发一个异步通讯类 AsynServer来讲解如何编程。
<1>、引用
在VC#开发环境中添加对OpcRcw.Da库以及OpcRcw.Comn库的引用,该库属于.NET库,不属于COM库,西门子虽然编写了类库,以提供对.NET平台的支持,但这些类库仍然难于编程,里面包含了大量的在托管和非托管区传输数据,因此我们需要在它的基础上再开发一个类库,以简化以后的编程,首先在类的开头使用命名空间:
using OpcRcw.Comn
using OpcRcw.Da
using System.Runtime.InteropServices
using System.Collections
<2>、编程
异步编程的原理就是在OPC服务器那边检测当前活动的变量组,一但检测到某一个变量,譬如变量Q0.0从1变成0,就会执行一个回调函数,以实现针对变量发生变化时需要实现的动作,在这里可以采用委托来实现该功能。
1、 在命名空间的内部、类 AsynServer声明之前添加委托的申明:
// 定义用于返回发生变化的项的值和其对应的客户句柄
public delegate void DataChange(object[] values,int[] itemsID)
2、 该类继承于西门子提供的库接口IOPCDataCallback
public class AsynServer:IOPCDataCallback
在类的开头部分声明变量:
struct groupStru
{
public int groupID
public object groupObj
}
internal const int LOCALE_ID = 0x407//本地语言
private Guid iidRequiredInterface
private string serverType=""
private int hClientGroup = 0//客户组号
private int nSvrGroupID// server group handle for the added group
private Hashtable hashGroup//用于把组收集到一起
private int hClientItem=0//Item号
3、编写构造函数,接收委托参数已确定当数据发生变化时需要执行的方法入口点:
//创建服务器
//svrType 服务器类型的枚举
//dataChange 提供用于在数据发生变化时需要执行的函数入口
public AsynServer(ServerType svrType,DataChange dataChange)
{
switch(svrType)
{
case ServerType.OPC_SimaticHMI_PTPRO:
serverType="OPC.SimaticHMI.PTPro"break
case ServerType.OPC_SimaticNET:
serverType="OPC.SimaticNET"break
case ServerType.OPC_SimaticNET_DP:
serverType="OPC.SimaticNET.DP"break
case ServerType.OPC_SimaticNET_PD:
serverType="OPC.SimaticNET.PD"break
case ServerType.OPCServer_WinCC:
serverType="OPCServer.WinCC"break
}
hashGroup=new Hashtable(11)
dtChange=dataChange
}
4、创建服务器
// 创建一个OPC Server接口
//error 返回错误信息
//若为true,创建成功,否则创建失败
public bool Open(out string error)
{
error=""bool success=true
Type svrComponenttyp
//获取 OPC Server COM 接口
iidRequiredInterface = typeof(IOPCItemMgt).GUID
svrComponenttyp = System.Type.GetTypeFromProgID(serverType)
try
{
//创建接口
pIOPCServer =(IOPCServer)System.Activator.CreateInstance(svrComponenttyp)
error=""
}
catch (System.Exception err) //捕捉失败信息
{
error="错误信息:"+err.Messagesuccess=false
}
return success
}
5、 编写添加Group的函数
///
/// 添加组
///
///
///
///
///
/// 若为true,添加成功,否则添加失败
public bool AddGroup(string groupName,int bActive,int updateRate,out string error)
{
error=""bool success=true
int dwLCID = 0x407//本地语言为英语
int pRevUpdateRate
float deadband = 0
// 处理非托管COM内存
GCHandle hDeadband
IntPtr pTimeBias = IntPtr.Zero
hDeadband = GCHandle.Alloc(deadband,GCHandleType.Pinned)
try
{
pIOPCServer.AddGroup(groupName, //组名
bActive, //创建时,组是否被激活
updateRate, //组的刷新频率,以ms为单位
hClientGroup, //客户号
pTimeBias, //这里不使用
(IntPtr)hDeadband,
dwLCID, //本地语言
out nSvrGroupID, //移去组时,用到的组ID号
out pRevUpdateRate, //返回组中的变量改变时的最短通知时间间隔
ref iidRequiredInterface,
out pobjGroup1)//指向要求的接口
hClientGroup=hClientGroup+1
groupStru grp=new groupStru()
grp.groupID=nSvrGroupIDgrp.groupObj=pobjGroup1
this.hashGroup.Add(groupName,grp)//储存组信息
// 对异步 *** 作设置回调,初始化接口
pIConnectionPointContainer = (IConnectionPointContainer)pobjGroup1
Guid iid = typeof(IOPCDataCallback).GUID
pIConnectionPointContainer.FindConnectionPoint(ref iid,out pIConnectionPoint)
pIConnectionPoint.Advise(this,out dwCookie)
}
catch (System.Exception err) //捕捉失败信息
{
error="错误信息:"+err.Messagesuccess=false
}
finally
{
if (hDeadband.IsAllocated) hDeadband.Free()
}
return success
}
6、 编写激活、或者取消激活组的函数
在同步编程中对于组的激活或者取消激活没有实质的意义,但在异步通讯编程中却异常重要,这是因为OPC服务器只对当前处于活动状态的组中的变量进行监控,同时这也是很有必要的,因为我们可以把不同界面中的变量编程不同的组,即同一界面中的变量规成一个组,而在某一时刻提供给用户的只有一个界面,让该界面中用到的组处于活动状态,这样执行委托调用时只会执行于该界面中有关的变量检测,而如果让所有的组处于活动状态,则当前没有显示给用户的界面用到的变量若发生变化也会触发对委托函数的调用,这根本是没有必要的,同时会大大降低程序的性能,请严格控制组的激活。
///
/// 激活或者取消激活组
///
///
///
///
/// 若为true,添加成功,否则添加失败
public bool AciveGroup(string groupName,bool toActive,out string error)
{
error=""bool success=true
//通过名称获取组
object grp=((groupStru)hashGroup[groupName]).groupObj
IOPCGroupStateMgt groupStateMgt=(IOPCGroupStateMgt)grp
//初始化传递参数
IntPtr pRequestedUpdateRate = IntPtr.Zero//由客户指定的Item更新间隔时间
int nRevUpdateRate = 0//由服务器返回的能够更新的最短时间间隔
IntPtr hClientGroup = IntPtr.Zero//客户组
IntPtr pTimeBias = IntPtr.Zero
IntPtr pDeadband = IntPtr.Zero
IntPtr pLCID = IntPtr.Zero
// 激活或者取消激活组
int nActive = 0
GCHandle hActive = GCHandle.Alloc(nActive,GCHandleType.Pinned)
if(toActive)
hActive.Target = 1
else
hActive.Target = 0
try
{
groupStateMgt.SetState(pRequestedUpdateRate,out nRevUpdateRate,hActive.AddrOfPinnedObject(),pTimeBias,pDeadband,pLCID,hClientGroup)
}
catch(System.Exception err)
{
error="错误信息:"+err.Messagesuccess=false
}
finally
{
hActive.Free()
}
return success
}
7、 向指定的组中添加变量的函数
///
/// 向指定的组添加一系列项
///
///
///
///
/// 无错误,返回true,否则返回false
public bool AddItems(string groupName,string[] itemsName,int[] itemsID)
{
bool success=true
OPCITEMDEF[] ItemDefArray=new OPCITEMDEF[itemsName.Length]
for(int i=0i {
hClientItem=hClientItem+1//客户项自动加1
ItemDefArray[i].szAccessPath = ""// 可选的通道路径,对于Simatiic Net不需要。
ItemDefArray[i].szItemID = itemsName[i]// ItemID, see above
ItemDefArray[i].bActive = 1// item is active
ItemDefArray[i].hClient = hClientItem// client handle ,在OnDataChange中会用到
ItemDefArray[i].dwBlobSize = 0// blob size
ItemDefArray[i].pBlob = IntPtr.Zero// pointer to blob
ItemDefArray[i].vtRequestedDataType = 4//DWord数据类型
}
//初始化输出参数
IntPtr pResults = IntPtr.Zero
IntPtr pErrors = IntPtr.Zero
try
{
// 添加项到组
object grp=((groupStru)hashGroup[groupName]).groupObj
((IOPCItemMgt)grp).AddItems(itemsName.Length,ItemDefArray,out pResults,out pErrors)
int[] errors = new int[itemsName.Length]
IntPtr pos = pResults
Marshal.Copy(pErrors, errors, 0,itemsName.Length)
for(int i=0i<itemsname.lengthi++) 循环检查错误 {
if (errors[i] == 0)
{
OPCITEMRESULT result = (OPCITEMRESULT)Marshal.PtrToStructure(pos, typeof(OPCITEMRESULT))
itemsID[i] = result.hServer
pos = new IntPtr(pos.ToInt32() + Marshal.SizeOf(typeof(OPCITEMRESULT)))
}
else
{
String pstrError
pIOPCServer.GetErrorString(errors[0],0x407,out pstrError)
success=false
break
}
}
SetItenClient(groupName,itemsID,itemsID)//要求始终只有一个组被激活,才不会引起冲突。
}
catch (System.Exception err) // catch for error in adding items.
{
success=false
//error="错误信息:"+error+err.Message
}
finally
{
// 释放非托管内存
if(pResults != IntPtr.Zero)
{
Marshal.FreeCoTaskMem(pResults)
pResults = IntPtr.Zero
}
if(pErrors != IntPtr.Zero)
{
Marshal.FreeCoTaskMem(pErrors)
pErrors = IntPtr.Zero
}
}
return success
}
说明:使用该函数时,在类的开头,应该先声明整数数据,以用于保存由本函数返回的服务器对每一项分配的Item ID号:
8、 下面编写的是一个最重要的重载函数,当检测到当前活动组中的某个变量发生变化时,就会调用委托。
//数据变化时处理的问题
public virtual void OnDataChange ( Int32 dwTransid ,
Int32 hGroup ,
Int32 hrMasterquality ,
Int32 hrMastererror ,
Int32 dwCount ,
int[] phClientItems ,
object[] pvValues ,
short[] pwQualities ,
OpcRcw.Da.FILETIME[] pftTimeStamps ,
int[] pErrors )
{
dtChange(pvValues,phClientItems)
}
该函数的代码只有一句,即调用委托函数。
以上编写的是需要实现监控的最重要的方法,当然不完善,还有许多方法和重载函数可以编写,这里就不详细介绍。
9、 编写基本的测试程序,用于检测上面编写的异步类AsynServer
<1>、 重新创建一个工程,添加对上面编写的异步类的引用,并在类的开头部分添加变量声明:
//声明委托
private S7Connection.DataChange dt
//声明服务器
S7Connection.AsynServer server
<2>、初始化服务器数据
dt=new S7Connection.DataChange(DataChange)
server =new AsynServer(S7Connection.ServerType.OPC_SimaticNET,dt)
string err
server.Open(out err)
server.AddGroup("maiker",1,300,out err)
server.AddItems("maiker",m1,nt1)
server.AddGroup("maiker1",1,300,out err)
server.AddItems("maiker1",m2,nt2)
nt[0]=nt1[0]nt[1]=nt1[1]
<3>、添加两个单选按钮,用于选择某个组,并编写相应的程序
string err,err1
if(server==null) return
if(radioButton1.Checked)
{ nt[0]=nt1[0]nt[1]=nt1[1]
server.AciveGroup("maiker",true,out err)
server.AciveGroup("maiker1",false,out err1)
}
else
{
nt[0]=nt2[0]nt[1]=nt2[1]
server.AciveGroup("maiker1",true,out err)
server.AciveGroup("maiker",false,out err1)
}
<4>、添加文本框、按钮等,并编写委托执行函数:
private void DataChange(object[] obj,int[] itemsID)
{
for(int j=0j {
if(itemsID[j]==nt[0])
this.textBox1.Text=obj[j].ToString()
if(itemsID[j]==nt[1])
this.textBox4.Text=obj[j].ToString()
}
}
其中参数obj用于返回当前发生变化的变量的结果值,而itemsID返回当前发生变化的变量的ID号,其与添加变量时服务器返回的ID号对应。以上就是一个基本的测试函数,其相对同步编程来说,应该还简单一些。
3、 同步编程与异步编程的使用场合
一般来讲,同步编程需要使用定时器来循环检测变量,而异步编程则不需要,当服务器检测到数据发生变化时,可以直接调用传入的函数,从这方面来讲,使用异步编程更简单一些,但同步编程使用外部的定时器控制,编程则会更加灵活,一般只监控变量时可以使用异步编程,而当需要写入数据时可以使用同步编程,但这也不是绝对的,我曾编写了一个标准监控程序,没有使用异步编程。
4、 关于开发监控界面的说明
毫无疑问,我们应该开发一系列控件,用于简化界面的设计,否则工作量会异常大。设计一个标准模块,用于第一次运行监控软件时添加变量,并可以设定当前已经组态的界面中的各控件元素与之关联,这样在以后再运行该软件时,不需要再设定,就可以直接连接变量,并进行相应的变化。否则若在编程时编写代码进行关联,其工作量将会异常大。
其实该类我早已经开发了,但一直没有时间写成文章,本来想开发一系列标准控件和标准模块,但由于换到上海工作,可能不会再有时间搞这方面的研究了。
欢迎分享,转载请注明来源:内存溢出
微信扫一扫
支付宝扫一扫
评论列表(0条)