
从事软件开发行业十多年,专注于网络通信技术和网络语音视频技术,擅长系统架构设计、系统性能优化等。zhuweisky.cnblogs.com
如果是.NET开发人员,想学习手机应用开发(Android和iOS),Xamarin 无疑是最好的选择,编写一次,即可发布到Android和iOS平台,真是利器中的利器啊!而且,Xamarin已经被微软收购并被大力推广,.NET开发人员将时间投资在Xamarin上,以应对移动开发的热潮,应该是值得的。 好了,废话不多说,就开始吧。本系列文章将详细介绍如何使用Xamarin开发出一个移动端的即时通信系统(手机聊天程序)(文末有源码下载,可先睹为快),本文作为第一篇基础篇,将着重介绍Xamarin Android和Xamarin iOS环境的搭建,包括安装、设置、模拟器、部署、运行调试等。本系列后面的文章将详细介绍手机聊天系统结构原理和具体代码实现。 一.搭建环境 1. 安装 Xamarin。 VS2017已经集成了Xamarin,只要在安装的时候,将“使用.NET的移动开发”选项勾选上,即可。 2.设置Xamarin Android。 (1)启动VS 2017之后,打开菜单 工具->选项->Xamarin->Android设置,在设置面板上做如下设置: (2)使用 Genymotion 作为安卓模拟器。 3.设置Xamarin iOS。 (1)在我的MacBook笔记本上安装 Visual Studio for Mac。 (2)在MacBook的系统偏好设置 中找到“共享”选项,打开“共享”界面如下。 开启远程管理和远程登录。 (3)在PC端VS “工具” 选择卡中依次选择 IOS ->Xamarin.Mac代理,点击左下方的“Add Server”按钮,输入对应远程Macbook机器的IP,并进行登录。 (4)登录成功后,界面上会显示如下链接的图标表示远程成功。 (5)连接成功后,在VS中就可以进行调试MAC机器上的模拟器或者真机了。 (6)在PC端VS中,打开菜单 工具->选项->Xamarin->iOS设置,在设置面板上做如下设置: 二. 新建Xamarin.Forms项目、编译 Xamarin.Forms 是Xamarin提供的一个套件,用于跨移动平台的Form应用开发,所以,如果是使用Xamarin开发App,那么,Xamarin.Forms 将是很好的选择。 1.新建一个Cross-Platform跨平台项目,选择Cross Platform App(Xamarin)。 2.项目新建成功后,会在解决方案管理器中,生成三个项目。 XamarinDemo 项目是可移植的类库,App的绝大部分逻辑和UI都是在其中完成。 XamarinDemo.Droid 项目对应了安卓版本,XamarinDemo.iOS 项目对应了iOS版本,它们都引用了 ESFramework.XamarinDemo 项目。 对于一般简单的应用而言,只需要在XamarinDemo中编写代码就可以了,XamarinDemo.Droid 和 XamarinDemo.iOS中的代码只需要做少量修改。 3.编译 XamarinDemo.Droid 项目 在解决方案管理器中选中 XamarinDemo.Droid 项目,右键->属性,打开设置面板。 在项目属性面板中,要选择编译所使用的安卓SDK的版本号,我选择的是最新6.0。 4.编译 XamarinDemo.iOS 项目 在解决方案管理器中选中 XamarinDemo.iOS项目,右键->属性,打开设置面板。 在项目属性面板中,选择编译所支持的CPU体系结构,由于现在是使用iOS模拟器,所以选择x86_64。 如果是使用真机调试,则应该选择 ARMv7+ARMv7s+ARM64。 三.部署、调试 编译成功后,就可以尝试部署到虚拟机,并运行调试了。 1. 安卓版本 (1)启动安卓虚拟机。 运行上述的Genymotion。 选择6.0的虚拟机,点击Start按钮运行起来。 (2)部署 在VS上的工具栏,选择刚才启动的虚拟机实例Genymotion Custom Phone - 6.0.0,点击调试按钮(绿色的三角形),即可开始部署、运行的流程。(注意,要选择Debug模式) (3)调试 部署运行成功后,模拟器就会显示demo App 的UI界面: 此时,可以在源码中加入断点开始调试程序了。 2.iOS版本 (1)启动虚拟机 (2)在VS上的工具栏,选择iPhone 6 Plus iOS 11.2,点击调试按钮(绿色的三角形),即可开始部署、运行的流程。 (3)调试 部署运行成功后,模拟器就会显示如下Demo的登录界面: 四.源码下载 虽然还未正式开始介绍聊天程序的代码实现,但是还是先将demo的源码分享给大家,基于以上介绍的内容,大家已经可以将demo运行起来看效果了。并且,源码中除了Xamarin移动端外,还包含了聊天服务端和PC客户端以及WebSocket客户端,而且,Xamarin移动端和PC客户端以及Web端之间都可以相互聊天哦! 下面是手机端运行的效果图: (1)源码:服务端+PC客户端 (基于VS 2010) (2)源码:Xamarin 移动端(包括Android 和 iOS) (基于VS 2017) 最后,在使用Xamarin开发本Demo的过程中,踩过了很多很多的“坑”,对这些坑的解决方案我们也会在本系列的文章中分享出来,如此能为后来者节省一些时间。敬请期待!
自从HTML5出来以后,使用WebSocket通信就变得火热起来,基于WebSocket开发的手机APP和手机游戏也越来越多。我的一些开发APP的朋友,开始使用WebSocket通信,后来觉得通信不够安全,想要对通信进行加密,于是自然而然地就想从ws升级到wss。在升级的过程中,就会存在旧的ws客户端与新的wss客户端同时连接到同一个服务器的情况。所以,如果同一个服务端,能同时支持ws和wss,那就太方便了。 一. 实现方案 但是,要服务端同时支持ws与wss并不太容易,其难点主要在于:wss通道必须在TCP连接刚建立时(收发消息前)就要先进行SSL加密,否则,后续的通信将无法正常进行。如此一来,当同时存在ws和wss客户端时,服务器在尚未通信之前就无法具体分辨哪个是ws哪个是wss。那怎么办了?我们的解决方案,是采用试探法,该方案已经在 ESFramework 通信框架中实现。 (1)由于wss通道必须在TCP连接刚建立时(收发消息前)就要先进行SSL加密,否则,后续的通信将无法正常进行。 (2)基于(1),在没有收发任何消息时,服务端就无法将wss客户端与其它客户端区分开来。 (3)为此采用的办法是:对于任何刚建立的TCP连接,先都不加密它,等收到的第一个消息来判断其消息的头标志。 (4)如果头标志不是ESFramework所规定的标志,则表示这第一个消息是密文,无法被解析,从而说明这个客户端是wss。于是将该客户端的ip放到cache中,并断开该连接。 (5)wss客户端会重新连上来,此时服务端从cache中发现已经存在目标ip,则判定其为wss客户端,于是立即使用SSL加密该通道,之后,该wss客户端就可以正常通信了。 (6)由于wss 客户端 IP在cache中的过期时间是 6秒左右,所以,如果一个客户端IP刚登录了wss客户端,那么在同一个IP上登录第二个客户端(任何客户端类型),就需要相隔6秒之后。 基于以上方案实现服务端后,我们接下来基于 ESFramework入门demo 来具体讲解一下如何在实际应用中同时支持ws和wss。 二. 服务端实现 1. 数字证书 为测试方便,我们可以使用 CertificateCreator 制作一个用于本地测试的数字证书。 运行 CertificateCreator.exe, 然后输入Common Name(比如Test)、密码、保存路径(比如D:\server.pfx),我们就可以得到包含私钥的证书server.pfx 。双击server.pfx ,即可安装证书。 2. 服务端引擎设置 在服务端RapidServerEngine初始化之前,添加如下代码设置其 WssOptions 属性: WssOptions wssOptions = new WssOptions( new X509Certificate2("D:\\server.pfx", "password") ,SslProtocols.Default ,false); rapidServerEngine.WssOptions = wssOptions; 设置完成后,启动服务端。 三. 客户端实现 1. 信任测试用的数字证书 由于上述生成的数字证书仅仅是用于测试的,而是不被正式认可的,所以,需要在浏览器设置中,将目标数字证书加入到信任列表。 比如,在360浏览器中,可如下设置: 在FireFox中,设置如下: 将服务器的地址(https://127.0.0.1:4530)添加到例外中。 2. 客户端引擎设置 打开入门demo的Web端源码中的index.js文件,找到engine的Initialize方法,将 useWss 参数由false修改为true。 然后将Web端的 index.html 文件拖入浏览器中运行即可。 四. 运行效果 登录一个wss客户端,一个ws客户端和一个.NET客户端,服务端的UI显示如下: 下载 Demo源码 。
在使用Unity开发游戏以支持热更新的方案中,使用ULua是比较成熟的一种方案。那么,在使用ULua之前,我们必须先搞清楚,C#与Lua是怎样交互的了? 一.基本原理 简单地说,c#调用lua, 是c# 通过Pinvoke方式调用了lua的dll(一个C库),然后这个dll执行了lua脚本。 ULua = Lua + LuaJit(解析器、解释器) +LuaInterface。 其中,LuaInterface中的核心就是C#通过Pinvoke对Lua C库调用的封装,所以,在Unity中,LuaInterface就是C#与Lua进行交互的接口。 下面我们以一个简单的例子来演示C#与Lua的相互调用。 二.入门例子 如下是构建这个例子的步骤。 (1)下载ULua源码。 (2)在Unity中新建一个项目,并将ULua源码拷贝到Assets目录下。 (3)将ulua.dll(就是上面提到的C库)放到Assets下的Plugins文件夹中。(没有Plugins文件夹就新建一个) (4)在Assets下的Script文件夹中新建一个脚本CSharpLuaTest.cs,并将该脚本绑定到Main Camera上。 (5)在CSharpLuaTest.cs中编辑以下内容: public class CSharpLuaTest : MonoBehaviour { private LuaState luaState = new LuaState(); // 创建lua虚拟机 void Start () { // 在lua虚拟机(全局)中注册自定义函数 this.luaState.RegisterFunction("CSharpMethod", this, this.GetType().GetMethod("CSharpMethod")); // 加载lua文件(绝对路径) this.luaState.DoFile(Application.streamingAssetsPath + "/Test.lua"); // 加载完文件后,使用GetFunction获取lua脚本中的函数,再调用Call执行。 object[] objs = luaState.GetFunction("LuaMethod").Call(999); Debug.Log(string.Format("{0} - {1}" ,objs[0], objs[1])); } //自定义功能函数,将被注册到lua虚拟机中 public string CSharpMethod(int num) { return string.Format("Hello World {0} !" , num+1); } void Update () { } } (6)在Assets下的StreamingAssets文件夹中新建一个Lua脚本文件Test.lua,打开Test.lua文件,并编辑如下内容: function LuaMethod(i) s = CSharpMethod(i); --调用C#方法 return i,s; end (7)运行Unity项目,则可以看到输出:999 - Hello World 1000 ! 三.要点说明 最后简单说一下上面代码的要点: 1.如果一个C#方法要被Lua调用,则首先要将其注册到Lua虚拟机中(LuaState.RegisterFunction)。之后,在Lua中就可以通过注册的名称来调用这个C#方法。 2.如果C#要调用Lua中的函数,则 (1)首先要在Lua虚拟机中加载该函数(LuaState.DoFile)。 (2)拿到目标函数(LuaState.GetFunction)。 (3)执行目标函数(LuaFunction.Call)。
1.什么是进程守护系统? 进程守护系统,用于监控指定的进程,当发现目标进程不再正常工作时,就关闭该进程,并重启它。 在什么情况下使用进程守护系统了?比如说,我们的某个服务器软件,在上线后出现一个严重的bug,该bug虽然很难出现,但是只要一出现,整个服务都会停掉(进程没有崩溃,只是不再提供服务)。此时,重启服务软件,又会开始正常工作。 对于这样严重的bug,必须要查清楚并解决掉的。但是,基于以下两个原因: (1)系统已经对用户开放,服务不能停。不可能说系统先下线,直到bug被解决掉后再重新上线。 (2)bug很难重现,可能需要加日志,不断地跟踪排查,这很可能是一场持久战。 为了让系统继续线上运行,在bug解决之前,必须要保证系统停止服务之后,能迅速重新启动恢复服务。此时,使用进程守护系统是最恰当不过的了。 OrayGuard就是为达到这一目的,实现了一个进程守护系统。一个守护者程序,可以守护同一台机器上的多个进程。 2.进程守护系统的实现及使用 OrayGuard守护者对被守护进程的管理使用的是心跳机制,其原理描述如下: (1)被守护进程定时向守护者报告(发送心跳),以表明自己是在正常提供服务。 (2)如果守护者发现某个被守护进程连续一段时间都没有心跳过来,就关闭对应的进程,然后再启动对应的程序。 在OrayGuard系统中,为了方便使用,已经做了很多工作,直接提供如下设施给使用者。 (1)在守护者这一方:提供了可直接运行的exe,双击即可运行起来。 (2)在被守护进程这一方:提供了OrayGuard.Core.dll,使用者只要调用其中的GuardianProxy静态类的几个方法,即可完成所有工作。 /// <summary> /// 与守护服务进行通信的Proxy,提供给被守护进程直接使用。 /// </summary> public static class GuardianProxy { /// <summary> /// 初始化Proxy,并向守护服务注册当前进程。 /// </summary> /// <param name="guardServerPort">守护进程提供服务的Port</param> /// <param name="timeoutInSecs">超时间隔。单位:秒</param> public static void Initialize(int guardServerPort, int timeoutInSecs); /// <summary> /// 向守护服务激活当前进程一次。 /// </summary> public static void Activate(); /// <summary> /// 向守护服务注销当前进程。 /// </summary> public static void Dispose(); } 在被守护方: (1)进程启动时,调用GuardianProxy的Initialize方法,即可向守护者注册当前进程。(端口号就填守护者配置文件中设定的端口) (2)进程内需要定时(比如10秒一次)检测自己是否仍在正常提供服务,如果是,则调用GuardianProxy的Activate方法,向守护者发送心跳。 (3)当进程正常退出时,调用GuardianProxy的Dispose方法向守护者注销。 3.Demo以及下载 最后,我们编写了一个用于演示的被守护进程的项目,整个系统运行起来后,效果如下: (注意:实际测试时,不要调试,而是要双击演示项目debug目录下的TestProcess.exe运行演示,否则,模拟故障后,演示进程会被关闭,但是无法被重启。因为,调试时,检测到的是TestProcess.vshost.exe) 下载 OrayGuard。压缩包中包含如下内容: (1)OrayGuard守护者:可直接运行的守护者程序。 (2)SDK:供被守护进程使用的SDK。 (3)TestProcess:用于演示的被守护进程的项目源码。 更多分享:打通B/S与C/S !让HTML5 WebSocket与.NET Socket公用同一个服务端!
如果是.NET开发人员,想学习手机应用开发(Android和iOS),Xamarin 无疑是最好的选择,编写一次,即可发布到Android和iOS平台,真是利器中的利器啊!好了,废话不多说,就开始吧,本文将描述基础环境的搭建,以及将应用在android环境中调试、发布、运行,iOS部分将在后面的文章中专门讲解。 一.搭建环境 1. 安装 VS2015。 2. 安装 Xamarin。我安装的是目前的最新版本:Xamarin.VisualStudio_4.2.0.703.msi。 3. 安装JDK:我安装的是目前的最新版本:jdk1.7.0_71。 4. 下载 Android SDK。 (1)下载完成后, Android SDK 目录下有两个exe:AVD Manager.exe 和 SDK Manager.exe。 (2)SDK Manager 用于管理不同版本的SDK,在其管理界面中可以下载新版本的SDK或升级旧版本的SDK。 我下载了版本号的5.1.1,6.0,7.0的SDK(其文件目录在子文件夹platforms下)。这些版本的SDK会在项目编译时用到,以及安卓模拟器也会用到它们。 (3)AVD Manager.exe 用于管理安卓模拟器。 与SDK的版本号对应,我创建了三个安卓模拟器,供调试时使用。 5. 在VS中配置Xamarin选项。 由于我暂时没有用到原生的安卓开发,所以,我没有配置NDK。 二. 新建Xamarin.Forms项目、编译、部署、调试 Xamarin.Forms 是Xamarin提供的一个套件,用于跨移动平台的Form应用开发,所以,如果是使用Xamarin开发App,那么,Xamarin.Forms 将是很好的选择。 1.新建一个Cross-Platform跨平台项目,选择Xaml App(Portable)。 Xaml App 与 普通的App的区别在于, Xaml App是使用Xaml文件来控制Form上的控件的布局的。 2.项目新建成功后,会在解决方案管理器中,生成三个项目。 ESFramework.XamarinDemo 是可移植的类库,App的绝大部分逻辑和UI都是在其中完成。 ESFramework.XamarinDemo.Droid 对应了安卓版本,ESFramework.XamarinDemo.iOS 对应了iOS版本,它们都引用了 ESFramework.XamarinDemo 项目。 对于一般简单的应用而言,只需要在ESFramework.XamarinDemo中编写代码就可以了,ESFramework.XamarinDemo.Droid 和 ESFramework.XamarinDemo.iOS中的代码几乎不用动。 3.编译 ESFramework.XamarinDemo.Droid 项目 在项目属性面板中,要选择编译所使用的安卓SDK的版本号,我选择的是6.0。 4.部署、调试 编译成功后,就可以尝试部署到虚拟机,并运行调试了。 (1)启动安卓虚拟机。 运行上述的AVD Manager.exe ,我选择了自己创建的and6虚拟机,点击界面右侧的Start按钮,启动一个虚拟机实例。 (2)部署并运行 在VS上的工具栏,选择刚才启动的虚拟机实例and6,点击调试按钮(绿色的三角形),即可开始部署、运行的流程。(注意,要选择Debug模式) (3)调试 部署运行成功后,模拟器就会显示demo App 的UI界面: 此时,可以在源码中加入断点开始调试程序了。 三. 发布 apk 1.将项目切换到Release。 2.设置清单信息Manifest 包括:应用的名称、apk包的名称、图标、版本号、权限等。 3.设置Linker Linking 设置为none,意味着要将所有的SDK打包到apk中,这样,一个apk至少是40多M。 Linking 一般设置为 SDK Assemblies Only,表示 apk 使用手机中自带的SDK。 4.Archive - 存档apk 在发布apk之前,先要Archive得到apk。注意:Archive得到的apk不能直接部署到手机真机,运行会闪退。还需要经过最后的Distribute正式发布。 5.Distribute 在Archive成功后,会出现如下界面: 点击Distribute按钮,进入发布apk的流程。首先,要对apk进行签名: 如果是第一次走这个流程,先要点击绿色的“+”,创建一个密钥文件,然后选择它,再点击“Save As”按钮,即可保存签名后的apk。 6.拷贝到真机 将上述得到的ESFramework.XamarinDemo.apk,拷贝到真机,就可以安装了。晚装完毕,点击图标,即可运行我们的demo App了。 四.结语 以上的整个流程是我们在为ESFramework通信框架的Xamrain版本实现一个入门Demo时,完整的记录,希望对刚刚开始研究Xamrain开发的朋友有所帮助。 后续的文章,我们将介绍这个Demo的后续开发过程,基于Xamarin实现手机与PC互通的的IM 聊天小程序。
一个是程序的世界,一个是禅的世界,似乎风马牛不相及。可是程序即是生活,生活即是禅,谁说又没有联系了? 作为一个写了十多年代码的程序员,在3年前突然发现,在代码逻辑之外,居然可以从这些以前从来没有意识到的角度来看待人生和世界,真是奇妙。 2002年至2012年,这十年我的精力主要放在研究软件技术上,从2013年开始,我的一部分精力转移到了研究并实践“如何生活得快乐”这个事情上,于是,开始各种找方向。 粗浅研究了哲学、(超个人)心理学、佛学、第四道、钻石途径、身心灵等,禅修也是其中的一个方向。 现在总结起来,对我而言,研究这些的主要驱力有两个:一是想减少生活中的痛苦和烦恼;二是对真相的好奇,自己的真相、人生的真相、宇宙的真相。 有朋友问:参禅,能帮你赚更多的钱吗? 我:不能。 朋友:那有屁用啊。 是啊,如果你只是对赚钱感兴趣,那赚钱之外的东西,对你而言就是屁而已。 多年前,曾经有个声音问我:现在你是醒着的吗? 我回答:我当然是醒着的啊,这不废话吗。 那个声音继续问:真是醒着的吗? 后来我才明白,那时是在梦中,现在仍在半梦半醒之间。觉醒始于质疑自己的观念、探索自己的真相,始于睁开好奇双眼的那一刹。 以下对话,纯属我个人杜撰,如有雷同,十分荣幸。 1.生活与修行 甲:我把修行当作了生命中最重要的事,可是,如果修行与生活发生了冲突,该怎么办了? 乙:真正的修行不会与生活冲突,生活中的一切都是修行的素材。生活即道场。 2.关于死亡 甲:我特别恐惧死亡。 乙:你死过吗? 甲:没有。但是,死了自己就不存在了啊,多恐怖啊! 乙:你没死过,那你是怎么知道的了? 另外,我们之所以怕死,是因为我们没有真正的活过。 甲:那如何才能真正的活了? 乙:在你的“自我”死去之后。 3.关于勇敢 甲:当我爱上一个人时,我可以为其牺牲自己一切。我这是勇敢吧? 乙:嗯,这是一般意义上的勇敢。然而,真正的勇敢是,在最负面的情况中(比如,与你爱的人正在激烈地争吵时),你依然对一切敞开、依然满怀着爱。 4.好心做坏事 甲:为什么会经常出现好心做坏事的情况了? 乙:因为好心的那个人缺少智慧。表现在两个方面:一是不知道什么是真正的好,二是不知道正确的方法。 5.再谈修行 甲:听说你在修行啊? 乙:是啊。 甲:修行就要看淡钱财,那把你的钱都给我吧。 乙:不行。你误解了我修行的目的,我修行是为了更舒服地生活,赚更多的钱。 结束之前: 有个朋友跟我讲,说做程序的,到了一定阶段开始思考人生意义之类的问题时,很有可能走上类似你走的这条路。 我问为什么?他回答说,因为求真(探索真相)是程序员的特质啊。 哦,是哦。
当我屡次不经意地凝视自己的博客时,边栏上的园龄一项总会不揣冒昧地提醒我 —— 某人已躬耕十年。当年万里觅封侯,匹马戍梁州。如今听雨客舟中,江阔云低,断雁叫西风 —— 十年,注定是一场生长收藏的轮回。 十年转灯,摒却金貂美酒、出离快马宝刀,繁华洗尽之时,我常想,作为一名技术人,这本身何尝不是一种莫大的慰藉。 一.正心诚意 毋庸置疑,做技术是清苦的。一个人,一台机器,相对无言,代码纷飞,bug无情。须梦里挑灯,冥思苦想,肝血暗耗,板凳坐穿。世界繁华竞逐,而你独钓寒江,看尽千山暮雪,听彻寒更雨歇。 如此技术,众人视其为徭役,避之犹恐不及。而你却不辞艰苦,一炷心香,毅然踏上了风鸣马楚的征途。于你而言,这是一场修行。为此你摇动经筒,升起风马,转山转水转佛塔。此路甚为修远,非弘毅之士不能往。玩世不恭之流,投机取巧之徒,都不会摘到技术的金苹果。 因此,一名技术人,必是一位正心诚意之人。其心正,其意成,方能精进技艺。其心正,其意诚,方能进德修业。唯其心正意诚,行何处、居何位、理何事、待何人,皆能允执厥中。 二.格物致知 荀子曰:学不可以已。技术人不仅敏而好学,而且泠然善学。常言道,不学无术。从来没有一个不爱学习的人、不善学习的人、不坚持学习的人能够成为一名合格的技术人。 技术人的学习不是纸上谈兵,不是坐而论道,不是白发死章句,不是袖手谈心性。《大学》中讲:“致知在格物,物格而后知至”。技术人的学习正是格物致知。从来没有现成的技术写在哪本书上,读之便可尽得。任何知识的积累,技术的精进,无不是在每一次代码的编写、bug的调试、博客的总结中获得。技术人并没有多少“学问”,“学问”留给“做学问”的人做去吧,技术人只掌握自己格致之后的有限知识。 试问,一名好学、善学、格物致知的技术人,即便不做技术,放到各行各业,又何尝不是人才呢?一名好学、善学、格物致知的技术人,涉足任何领域,又何恐不能取得成就呢? 三.知行合一 技术人不仅格物致知,而且知行合一。知而不行是学院的做派,而不是技术人的品格。一名技术人,本身就是知和行的统一。 “问题在于改变世界”——从青年黑格尔派初次提出这样的命题,到马克思在《关于费尔巴哈提纲》中正式确立全新的世界观,从来没有一群人像技术人这样深刻的改变着我们的世界。常言道:无农不稳, 无工不富,无商不活。试问无技术人则何如?遑论海德格尔将技术称为现代社会的座架,假使没有技术人的身体力行,我们今天的世界将会是怎样?倘若没有技术人的知行合一,士、农、工、商能否给我们带来如今这般的美好生活? 工人阶级没有思想的劳动是盲目的,知识分子没有劳动的思想是空洞的。一名知行合一的技术人,于人类进步的事业是问心无愧的。 四. 道器不二 技术人不仅仅只是技术工具的熟练使用者,若然,则无非只是一个工具高级些的熟练工人罢了。《庄子·养生主》中借庖丁之口道出了技术人的真谛——文惠君曰:“嘻!善哉!技盖至此乎?”庖丁释刀对曰:“臣之所好者道也,进乎技矣。" 一名真正的技术人当是技进乎道,道器合一。《周易》中讲"形而上者谓之道,形而下者谓之器。”技术人追求的不是以技术手段来实现单纯的功利目的,而是在精益求精,止于至善中执道而行,追求天地人神的协奏。因此,一名真正的技术人绝不会因技术而枉死,绝不会因技术而病弱,绝不会将技术作为疯狂敛财的工具,更不会将技术用于行不义之事。过劳死、体弱多病、不近人情、一心向钱、毁灭世界——这都是科幻片中大反派:“技术怪人”。众人将其误以为是技术人的本色,而事实绝非如此。现实中的确不乏技术从业者误入这些歧途,那皆是始于对“技”的狂乱,对“器”的迷浊。道器不二,方才是技术人的本色。 不伤害他人,不伤害自己,技术的精进带来的是品物咸亨的圆满,技术人在执道而行的途中不改其乐。 五. 结语 十年技术生涯,我时常省思自己是否是一名合格的技术人。如若如此,那便是我莫大的荣幸;如若不然,那将是我下一个十年的不懈追求。 因为身为一名技术人,这本身就是莫大的慰藉! ———————————————————————————————————————————————— 版权声明,欢迎转载。 优秀的作品是技术人最好的名片。欢迎大家关注 我的作品 !
1.ESFramework通信框架 ESFramework 是一套性能卓越、稳定可靠、强大易用的跨平台通信框架,支持应用服务器集群。其内置了消息的收发与自定义处理(支持同步/异步模型)、消息广播、P2P通道、文件传送(支持断点续传)、心跳检测、断线重连、登录验证、在线用户管理、好友与群组管理、性能诊断等功能。基于ESFramework,您可以方便快捷地开发出各种优秀的网络通信应用。此外,我们在长期实践中所积累的丰富经验,更将成为您强大的技术保障,从开发到上线直至后续运维,全程为您保驾护航,让您高枕无忧。 典型应用场景:即时通讯系统、视频聊天系统、视频会议系统、网络监控系统、远程协助系统、远程教育系统等等网络通信应用。 2.OMCS网络语音视频框架 OMCS 网络语音视频框架是集成了语音、视频、远程桌面、电子白板等多种媒体于一身的网络多媒体框架,实现了多媒体设备【麦克风、摄像头、桌面、电子白板】的采集、编码、网络传送、解码、播放(或显示)等相关的一整套流程,且可智能地根据网络状况实时调整帧频、清晰度、并优先保证语音通话效果。您只要连接到OMCS服务器,就可像访问本地设备一样访问任何一个在线用户的多媒体设备。超简单的编程模型为您的系统开发节省大量的人力成本、时间成本。 典型应用场景:视频聊天系统、视频会议系统、网络监控系统、远程协助系统、远程教育系统等等基于网络多媒体的应用系统。 3.MFile语音视频录制组件 在很多语音视频软件系统中,经常有将实时的音频或视频录制为文件保存到磁盘的需求,比如,视频监控系统中录制监控到的视频、视频会议系统中录制整个会议的过程、语音通话系统中录制完整的对话内容、电脑桌面录制、等等。MFile 可以将原始的语音数据和视频数据按照指定的格式进行编码,并将它们写入到视频文件中。MFile有三种实用方式:生成音频文件(如.mp3)、生成无声的视频文件(如.h264)、生成普通视频的文件(如.mp4)。 典型应用场景:监控视频录制、视频会议/视频聊天录制、语音通话录制、电脑屏幕录制等。 4.MCapture语音视频采集组件 在多媒体系统中,一般都会涉及到语音、视频、桌面的数据采集问题,采集得到的数据可以用来传输、播放、或存储。所以,对于像课件录制系统、语音视频录制系统、录屏系统等,多媒体数据的采集就是最基础的功能之一。MCapture可用于采集本地摄像头拍摄到的图像、麦克风输入的声音、声卡播放的声音、以及当前电脑桌面的图像,并提供了混音器功能。 典型应用场景:语音视频会话、远程桌面、屏幕采集、语音视频采集。 5.StriveEngine轻量级通信引擎 StriveEngine是一个单纯高效的通信引擎类库。支持Unity3D,可以被打包到pc、web、android、ios等平台;支持HTML5 Web Sockets,可与web集成。 如果ESFramework对您的项目来说,太庞大、太重量级;如果您的项目不需要P2P、不需要传文件、不需要群集等功能,那么,可以考虑使用轻量级的通信引擎StriveEngine。StriveEngine使用了与ESFramework相同的内核,同样高效稳定。相比较而言,StriveEngine更单纯、更容易上手,也更容易与已存在的遗留系统进行协作。 典型应用场景:高性能的数据通信、MMORPG底层通信、消息转发系统、数据采集系统、与遗留系统互通、与异构平台互通等。 6.OAUS 自动升级系统 目前主流的程序自动升级策略是,重新下载最新的安装包,然后重新安装整个客户端。这种方式虽然简单直观,但是缺陷也很明显。OAUS自动升级系统可以对被分发的客户端程序中的每个文件进行版本管理,每次升级的基础单元不再是整个客户端程序,而是其中的单个文件。针对单个文件的更新,包括三种形式:文件被修改、文件被删除、新增加某个文件。OAUS对这三种形式的文件更新都是支持的。OAUS自动升级系统克服了传统升级方式耗时费力的弊端,而且可以作为一个独立的系统在您的各种项目中得到复用。 典型应用场景:对于需要有自动升级功能的PC桌面应用程序。
一.缘起 之前已经写了两篇关于自动升级系统OAUS的设计与实现的文章(第一篇、第二篇),在为OAUS服务端增加自动检测文件变更的功能(这样每次部署版本升级时,可以节省很多时间,而且可以避免手动修改带来的错误)后,有部分使用者又提出了一个很好的建议:为OAUS增加断点续传功能。因为如果网络状态不是很好,就经常会在升级到一半的时候,由于OAUS客户端掉线而导致升级失败,这个时候,就必须重新开始整个升级过程。即使升级中断的时候,已经完成了99%,也必须重头再来。所以,为OAUS增加断点续传功能是非常必要的。 现在,最新版本的OAUS已经增加了这个重要特性,当升级因为掉线而中断的时候,OAUS客户端并不会退出,而是一直尝试断线重连,重连成功后,就会从上次中断的地方继续升级。如下图所示: 在网络状态极差时,可能在一次升级的过程中,会出现多次断线重连的情况,这都没关系,OAUS客户端会一直正常工作,直到整个升级过程完成为止。 二.源码实现 下面简单说明一下代码实现的具体过程,OAUS断点续传功能是在客户端实现的,服务端不需要做任何修改。 1.预定网络连接断开的事件,得到掉线通知。此时,需要记录是在升级第几个文件的时候,升级中断的。 2.预定重连成功时间,得到网络链接恢复的通知。此时,开始重新下载下一个需要升级的文件。 void rapidPassiveEngine_RelogonCompleted(LogonResponse res) { if (res.LogonResult == LogonResult.Succeed) { this.DownloadNextFile(); this.logger.LogWithTime("重连成功,开始续传!"); if (this.UpdateContinued != null) { this.UpdateContinued(); } return; } } private void DownloadNextFile() { if (this.haveUpgradeCount >= this.fileCount) { return; } DownloadFileContract downLoadFileContract = new DownloadFileContract(); downLoadFileContract.FileName = this.downLoadFileRelativeList[this.haveUpgradeCount]; //请求下载下一个文件 this.rapidPassiveEngine.CustomizeOutter.Send(InformationTypes.DownloadFiles, CompactPropertySerializer.Default.Serialize(downLoadFileContract)); } 加上以上的逻辑处理之后,OAUS就已经具备了断点续传的功能了。代码看起来非常简单,那是因为内部核心的文件传送功能、断点续传功能都由ESFramework封装好了。在为OAUS增加断点续传功能时,就不需要再次实现与断点续传相关的繁琐的业务逻辑了。 3. 如何使用OAUS升级机制的说明 一般而言,如果最新客户端程序与老版本兼容,不升级也影响不大,则可以交由用户决定是否升级;如果最新客户端程序不兼容老版本,或者是有重大更新,则将启动强制升级。如果流程要进入启动升级,那么只要启动AutoUpdater的文件夹下AutoUpdater.exe就可以了。要注意的是,启动AutoUpdater.exe进程后,要退出当前的客户端进程,否则,有些文件会因为无法被覆盖而导致更新失败。代码大致如下所示: if (VersionHelper.HasNewVersion(oausServerIP,oausServerPort)) { string updateExePath = AppDomain.CurrentDomain.BaseDirectory + "AutoUpdater\\AutoUpdater.exe"; System.Diagnostics.Process myProcess = System.Diagnostics.Process.Start(updateExePath); ......//退出当前进程 } 三.相关下载 1.自动升级系统OAUS - 源码 2.自动升级系统OAUS(可直接部署) 3.自动升级系统OAUS - 使用手册 如果有任何建议或问题,请留言给我。
office word文档、pdf文档、powerpoint幻灯片是非常常用的文档类型,在现实中经常有需求需要将它们转换成图片 -- 即将word、pdf、ppt文档的每一页转换成一张对应的图片,就像先把这些文档打印出来,然后再扫描成图片一样。所以,类似这种将word、pdf、ppt转换为图片的工具,一般又称之为“电子扫描器”,很高端的名字! 一.那些场合需要将word、pdf、ppt转换为图片? 在我了解的情况中,通常有如下三种场景,有将word、pdf、ppt文档转换成图片的需求。 1. 防止文档被别人剽窃 如果直接将word、pdf、ppt文档提供给他人,那么他人就可以很容易的Copy整个文档的内容,然后稍作修改,就成为了他自己的东西。 如果我们将文档转换为图片之后,再提供给他人,那么,剽窃就不仅仅是Copy一下那么容易了。 2. 节省纸张 以前为了更好的做到第1点,都是将文档打印出来给别人看,很多文档看一遍就不用了,所以会浪费很多纸张、浪费墨水、消耗打印机和电力。 在倡导低碳节能的今天,使用电子扫描器的意义就更大了。 3. 电子白板课件 类似在线教学、远程培训这样的系统中,老师使用课件(word、pdf、ppt等类型的文档)是基本的需求,课件与电子白板的结合方案一般是这样的:将课件转换成图片,文档的每一页对应着电子白板的每一页,而得到的图片,正是作为白板页的背景图片。这样,老师就可以在课件图片上进行标注、板书了。我们前段时间研究word、pdf、ppt文档转图片的技术,就是为了给OMCS的电子白板功能做一个扩展课件类型的Demo示例,让其方便地支持word、pdf、ppt类型的课件。 二. 如何转换? 问一下度娘,可以找到很多很多类似将word转换为图片的文章,但是,真正好用的并不多,筛选是个花时间的过程。在这里,我们直接把筛选的结果呈现出来,并且将其封装成可以直接复用的Class,为以后有同样需要的人节省时间。 1. 方案一:使用Office COM组件 (该方案不支持PDF文档,关于PDF转图片的方法,这里有个很好的汇总,推荐给大家:PDF转换成图片的13种方案) 该方案的要求是用户的电脑上必须安装有微软的Office,我们可以通过.NET与Office COM组件的互操作(Interop)来操作Office文档。 该方案的原理是这样的:通过COM互操作可以在内存中打开Office文档,然后可以访问文档的每一页,并且支持将任意一页的内容复制到粘贴板(以图的形式),这样,我们再将粘贴板上的内容保存为图片就搞定了。 原理很简单,实现代码稍微有点麻烦,如下所示: private Bitmap[] Scan4Word(string filePath) { //复制目标文件,后续将操作副本 string tmpFilePath = AppDomain.CurrentDomain.BaseDirectory + "\\" + Path.GetFileName(filePath) + ".tmp"; File.Copy(filePath, tmpFilePath); List<Bitmap> bmList = new List<Bitmap>(); MSWord.ApplicationClass wordApplicationClass = new MSWord.ApplicationClass(); wordApplicationClass.Visible = false; object missing = System.Reflection.Missing.Value; try { object readOnly = false; object filePathObject = tmpFilePath; MSWord.Document document = wordApplicationClass.Documents.Open(ref filePathObject, ref missing, ref readOnly, ref missing, ref missing, ref missing, ref missing, ref missing, ref missing, ref missing, ref missing, ref missing, ref missing, ref missing, ref missing, ref missing); bool finished = false; while (!finished) { document.Content.CopyAsPicture(); //拷贝到粘贴板 System.Windows.Forms.IDataObject data = Clipboard.GetDataObject(); if (data.GetDataPresent(DataFormats.MetafilePict)) { object obj = data.GetData(DataFormats.MetafilePict); Metafile metafile = MetafileHelper.GetEnhMetafileOnClipboard(IntPtr.Zero); //从粘贴板获取数据 Bitmap bm = new Bitmap(metafile.Width, metafile.Height); using (Graphics g = Graphics.FromImage(bm)) { g.Clear(Color.White); g.DrawImage(metafile, 0, 0, bm.Width, bm.Height); } bmList.Add(bm); Clipboard.Clear(); } object What = MSWord.WdGoToItem.wdGoToPage; object Which = MSWord.WdGoToDirection.wdGoToFirst; object startIndex = "1"; document.ActiveWindow.Selection.GoTo(ref What, ref Which, ref missing, ref startIndex); // 转到下一页 MSWord.Range start = document.ActiveWindow.Selection.Paragraphs[1].Range; MSWord.Range end = start.GoToNext(MSWord.WdGoToItem.wdGoToPage); finished = (start.Start == end.Start); if (finished) //最后一页 { end.Start = document.Content.End; } object oStart = start.Start; object oEnd = end.Start; document.Range(ref oStart, ref oEnd).Delete(ref missing, ref missing); //处理完一页,就删除一页。 } ((MSWord._Document)document).Close(ref missing, ref missing, ref missing); System.Runtime.InteropServices.Marshal.ReleaseComObject(document); return bmList.ToArray(); } catch (Exception ex) { throw ex; } finally { wordApplicationClass.Quit(ref missing, ref missing, ref missing); System.Runtime.InteropServices.Marshal.ReleaseComObject(wordApplicationClass); File.Delete(tmpFilePath); //删除临时文件 } } COM方案 上述的实现对于小的word文档很好用,但是,如果word文档很大,有很多页,那么,上述调用就会占用很大的内存。 如果是这种情况,那么,可以将上面的实现改写一下,没得到一页的图片就将其保存到硬盘,而不用在内存中保存了。 PPT转为图片也是用同样的COM方式,文末会给出word和ppt转图片的COM实现的class下载。 2. 方案二:使用Aspose组件 使用Aspose组件的好处是,不需要用户的机器上安装Office,也可以完成我们想要的功能。这个优势实在是太明显了,所以,这是最推荐的方案。而且,Aspose完全支持word、ppt、和pdf,甚至excel也没问题。 我们在演示如何扩展OMCS电子白板课件类型的示范Demo中,采用的就是Aspose组件,感觉很稳定很好用。下面的代码就摘自示范Demo中。 public class Word2ImageConverter : IImageConverter { private bool cancelled = false; public event CbGeneric<int, int> ProgressChanged; public event CbGeneric ConvertSucceed; public event CbGeneric<string> ConvertFailed; public void Cancel() { if (this.cancelled) { return; } this.cancelled = true; } public void ConvertToImage(string originFilePath, string imageOutputDirPath) { this.cancelled = false; ConvertToImage(originFilePath, imageOutputDirPath, 0, 0, null, 200); } /// <summary> /// 将Word文档转换为图片 /// </summary> /// <param name="wordInputPath">Word文件路径</param> /// <param name="imageOutputDirPath">图片输出路径,如果为空,默认值为Word所在路径</param> /// <param name="startPageNum">从PDF文档的第几页开始转换,如果为0,默认值为1</param> /// <param name="endPageNum">从PDF文档的第几页开始停止转换,如果为0,默认值为Word总页数</param> /// <param name="imageFormat">设置所需图片格式,如果为null,默认格式为PNG</param> /// <param name="resolution">设置图片的像素,数字越大越清晰,如果为0,默认值为128,建议最大值不要超过1024</param> private void ConvertToImage(string wordInputPath, string imageOutputDirPath, int startPageNum, int endPageNum, ImageFormat imageFormat, int resolution) { try { Aspose.Words.Document doc = new Aspose.Words.Document(wordInputPath); if (doc == null) { throw new Exception("Word文件无效或者Word文件被加密!"); } if (imageOutputDirPath.Trim().Length == 0) { imageOutputDirPath = Path.GetDirectoryName(wordInputPath); } if (!Directory.Exists(imageOutputDirPath)) { Directory.CreateDirectory(imageOutputDirPath); } if (startPageNum <= 0) { startPageNum = 1; } if (endPageNum > doc.PageCount || endPageNum <= 0) { endPageNum = doc.PageCount; } if (startPageNum > endPageNum) { int tempPageNum = startPageNum; startPageNum = endPageNum; endPageNum = startPageNum; } if (imageFormat == null) { imageFormat = ImageFormat.Png; } if (resolution <= 0) { resolution = 128; } string imageName = Path.GetFileNameWithoutExtension(wordInputPath); ImageSaveOptions imageSaveOptions = new ImageSaveOptions(SaveFormat.Png); imageSaveOptions.Resolution = resolution; for (int i = startPageNum; i <= endPageNum; i++) { if (this.cancelled) { break; } MemoryStream stream = new MemoryStream(); imageSaveOptions.PageIndex = i - 1; string imgPath = Path.Combine(imageOutputDirPath, imageName) + "_" + i.ToString("000") + "." + imageFormat.ToString(); doc.Save(stream, imageSaveOptions); Image img = Image.FromStream(stream); Bitmap bm = ESBasic.Helpers.ImageHelper.Zoom(img, 0.6f); bm.Save(imgPath, imageFormat); img.Dispose(); stream.Dispose(); bm.Dispose(); System.Threading.Thread.Sleep(200); if (this.ProgressChanged != null) { this.ProgressChanged(i - 1, endPageNum); } } if (this.cancelled) { return; } if (this.ConvertSucceed != null) { this.ConvertSucceed(); } } catch (Exception ex) { if (this.ConvertFailed != null) { this.ConvertFailed(ex.Message); } } } } 代码相当简洁。在源码中,我们提供了Word2ImageConverter 、Pdf2ImageConverter 、Ppt2ImageConverter来分别用于word文档、pdf文档、ppt幻灯片到图片的转换。 有一点要注意的是,Aspose没有直接提供ppt转图片的API,但是,它提供了将ppt转为pdf的功能,所以,源码中实现ppt转图片是经过了pdf中转的,即:先将ppt文档转换为pdf文档,然后,在将pdf文档转换成图 三. 代码下载 1.方案一代码下载 方案一使用的Office COM互操作实现的,支持将word文档和ppt文档转成图片,class源码下载: OfficeScanner.cs 2.方案二代码下载 方案二的源码可以从我们的示范demo中提取(客户端项目中的ImageConverters.cs文件)。我们的示范demo用于模拟在线教育系统的场景:一个老师和N个学生进入同一个教室,所以,它们将看到同一个电子白板。老师可以上传课件、打开课件、在白板课件上标注、板书等。该Demo在打开课件的时候,就用到了上面的将word、pdf、ppt转换为图片的功能。大家可以运行demo,看看具体的效果。 白板课件Demo 运行Demo进行测试时,可按如下流程: (1)启动OMCS服务端。 (2)启动第一个客户端,选择“老师”角色,登录进默认教室。 (3)再启动多个客户端,选择“学生”角色,登录进默认教室。 (4)老师即可进行上传课件、打开课件、删除课件、课件翻页,在课件上标注、书写,等等操作。 老师端运行界面截图:
在网络聊天系统中,采集麦克风的声音并将其播放出来,是最基础的模块之一。本文我们就介绍如何快速地实现这个基础模块。 一. 基础知识 有几个与声音采集和播放相关的专业术语必须要先了解一下,否则,后面的介绍将无法展开。语音采集指的是从麦克风采集音频数据,即声音样本转换成数字信号。其涉及到几个重要的参数:采样率、采样位数、声道数。 简单的来说: 采样率:即采样频率,就是在1秒内进行采集动作的次数。 采样位数:又叫采样深度,就是每次采集动作得到的数据长度,即使用多少个bit来记录一个样本。 声道数:一般是单声道或双声道(立体声)。普通的麦克风采集几乎都是单声道的。 这样,1秒钟采集得到的声音数据的大小为(单位byte):(采样频率×采样位数×声道数×时间)/8。 音频帧:通常一个音频帧的时长为10ms,即每10ms的数据构成一个音频帧。假设:采样率16k、采样位数16bit、声道数1,那么一个10ms的音频帧的大小为:(16000*16*1*0.01)/8 = 320 字节。计算式中的0.01为秒,即10ms 二. 如何采集、播放? 如果直接基于底层的DirectX来进行麦克风的采集与播放,那将是十分繁琐的。好在我们有现成的组件来完成这个工作,MCapture用于采集硬件设备(如麦克风、摄像头、声卡、屏幕等),MPlayer用于播放采集到的数据。 1.采集麦克风 MCapture提供了IMicrophoneCapturer,用于采集麦克风输入的声音。其每隔20ms触发一次AudioCaptured事件,通过事件的参数byte[]暴露这20ms采集得到的数据。 IMicrophoneCapturer 相关采集参数的值是这样的: 采样频率:16000,采样位数:16bit,声道数:1。 所以,按照上面的公式进行计算,我们可以得到AudioCaptured事件的参数byte[]的长度为640。 2. 播放声音数据 MPlayer提供了IAudioPlayer,用于播放声音数据。在创建IAudioPlayer实例时,要正确的设置采样频率、采样位数、声道数这些参数的值,如果它们与即将要播放的声音数据的特征不一致,播放将出现错误。 我们在拿到MCapture采集的声音数据后,将其提交给IAudioPlayer的Play方法进行播放即可。 三.Demo实现 在有了前面的介绍作为基础后,接下来实现麦克风的采集和播放就相当简单了。在接下来的demo中,不仅演示了播放从麦克风采集到的声音,而且多加了一个功能,就是直接播放wav声音文件,这些实现都是相当简单的。 public partial class Form1 : Form { private IAudioPlayer audioPlayer; private IMicrophoneCapturer microphoneCapturer; public Form1() { InitializeComponent(); } private void button_mic_Click(object sender, EventArgs e) { try { this.microphoneCapturer = CapturerFactory.CreateMicrophoneCapturer(int.Parse(this.textBox_mic.Text)); this.microphoneCapturer.AudioCaptured += new ESBasic.CbGeneric<byte[]>(microphoneCapturer_AudioCaptured); this.audioPlayer = PlayerFactory.CreateAudioPlayer(int.Parse(this.textBox_speaker.Text), 16000, 1, 16, 2); this.microphoneCapturer.Start(); this.label_msg.Text = "正在采集麦克风,并播放 . . ."; this.label_msg.Visible = true; this.button_wav.Enabled = false; this.button_mic.Enabled = false; this.button_stop.Enabled = true; } catch (Exception ee) { MessageBox.Show(ee.Message); } } void microphoneCapturer_AudioCaptured(byte[] audioData) { if (this.audioPlayer != null) { this.audioPlayer.Play(audioData); } } private void button_wav_Click(object sender, EventArgs e) { try { string path = ESBasic.Helpers.FileHelper.GetFileToOpen2("请选择要播放的wav文件", AppDomain.CurrentDomain.BaseDirectory, ".wav"); if (path == null) { return; } AudioInformation info = PlayerFactory.ParseWaveFile(path); if (info.FormatTag != (int)WaveFormats.Pcm) { MessageBox.Show("仅仅支持PCM编码方式的语音数据!"); return; } int secs = info.GetTimeInMsecs() / 1000; //声音数据的播放时长 this.audioPlayer = PlayerFactory.CreateAudioPlayer(int.Parse(this.textBox_speaker.Text), info.SampleRate, info.ChannelCount, info.BitsNumber, secs + 1); this.audioPlayer.Play(info.AudioData); this.label_msg.Text = "正在播放wav文件 . . ."; this.label_msg.Visible = true; this.button_wav.Enabled = false; this.button_mic.Enabled = false; this.button_stop.Enabled = true; } catch (Exception ee) { MessageBox.Show(ee.Message); } } private void Form1_FormClosing(object sender, FormClosingEventArgs e) { if (this.microphoneCapturer != null) { this.microphoneCapturer.Stop(); this.microphoneCapturer.Dispose(); this.microphoneCapturer = null; } if (this.audioPlayer != null) { this.audioPlayer.Dispose(); this.audioPlayer = null; } } private void button_stop_Click(object sender, EventArgs e) { if (this.audioPlayer == null) { return; } if (this.microphoneCapturer != null) { this.microphoneCapturer.Stop(); this.microphoneCapturer.Dispose(); this.microphoneCapturer = null; } this.audioPlayer.Clear(); this.audioPlayer.Dispose(); this.audioPlayer = null; this.label_msg.Visible = false; this.button_wav.Enabled = true; this.button_mic.Enabled = true; this.button_stop.Enabled = false; } } 看看demo运行的效果图: 麦克风采集与播放Demo源码下载
对一个实时的网络语音视频系统而言,网络的品质对该系统的用户的体验具有决定性的作用,所以,在正式部署系统之前,进行较全面的网络测试和网络调优工作是非常必要的。这将是一个复杂的系统工程,如果有专业的团队来做这件事情是最好的。然而,一般的公司都是由开发人员或实施人员来做这些事情。比如需要进行如下分析:目标用户主要分布在哪些城市?在哪个地方或哪些地方(分布式方案)部署服务器对整体目标用户而言综合效果最为理想?如何部署?带宽需要多大?是否需要支持双线或多线(电信、联通、移动、铁通等)?等等。 本文不打算全面系统地介绍这些内容,而是只把其中最重要的部分拿出来,没有专业网络调优团队的中小公司可以按照下面给出的信息,进行一些必要的测试和分析。在做完这些后,对网络的基本情况就大致心中有数了。 一. 带宽占用大小 在语音视频聊天系统或视频会议系统中,语音、视频、电子白板、远程桌面等功能对网络带宽的要求分别如何了? 我们先假设一种常见的场景:假设N个在线用户同时进行1对1的多媒体沟通(即分为N/2组),在不考虑P2P通道的情况下,带宽的大致占用如下表所示(以OMCS语音视频框架为例,与QQ流量要求接近): 对于视频和远程桌面而言 -- 帧 频: 8~10 fps 。 普通质量:对应EncodeQuality取值为 8 左右。 高 质 量:对应EncodeQuality取值为 3 左右。 说明: 1.流量对称 对服务器而言,上行、下行的流量是对称的;对客户端而言,进、出的流量几乎也是对称的。上表中列出的只是单向的流量。 2.正比推算 以视频为例,如果视频的尺寸不是320x240,那么可以按比例推算带宽的占用。假设视频大小为640x480,那么,带宽的占用将增加4倍((640x480)/(320x240))。 3.考虑P2P 如果启用了P2P通道,那么,服务端带宽占用会减小,但客户端带宽占用保持不变。假设P2P的成功率为70%,则服务端的带宽占用将减少至原来的30%。 4.视频会议 上面的数据是基于1对1的多媒体沟通,如果是类似视频会议的场景,则沟通就是多对多的,这时,带宽的占用就会增加,服务器的上下行的流量也不再对称。 比如,有M个用户在一个视频会议室聊天,每个用户的视频都要广播给其它的(M-1)个用户,而且,每个用户都要接收其它(M-1)个用户的视频数据,所以带宽的占用就会增加很多。 二.服务器共享带宽与独享带宽 语音视频数据都是实时采集、实时播放的数据,除了对服务器带宽的速度有要求外,更要求服务器带宽通信质量的稳定性,即网络延时小、网络抖动小。很容易理解,如果网络抖动较大,听到的声音就是断断续续的(OMCS内置了抖动缓冲区JitterBuffer,但也只能在一定程度内减轻这个问题)。 所以,服务器的带宽要求必须是独享带宽,共享带宽无法满足实时语音视频的要求。对实时语音视频而言,100M的共享带宽,还不如5M的独享。这也就是为什么通常租服务器时,IDC会免费送你100M的共享带宽,而租5M的独享带宽,却一年要花几千块钱。 另外,要注意: (1)IDC服务器带宽的单位是bits/s,而我们通常说的网速的单位是bytes/s。它们之间是8倍的关系 -- 比如,服务器的带宽是1M的,说明下载的速度最多可以达到120kB/s左右。 (2)IDC服务器带宽指上行和下行的总和。比如,服务器的带宽是1M,说明在同一时刻,下载的速度和上传的速度加起来不会超过120kB/s。 三.带宽计算示例 1.即时通讯:我有1000个客户端同时在线,同时进行视讯的人数为100,请问服务端大概需要租多少带宽? 解:假设摄像头视频尺寸为640*480,音、视频为普通质量,P2P成功率为75%。 则 640*480尺寸的视频一路带宽占用是:20*((640x480)/(320x240))= 80KB/s 一路音频由表中数据得知为5KB/s 故总共需要 100*(80+5)*8/1000*25% =17Mbit/s 服务器带宽。 2.视频教学:我有100个客户端,其中1个人是老师,老师将自己的桌面和声音广播给99个学生,这种情况需要多少服务器带宽? 解:假设老师桌面分辨率为1024*768,音频为高质量 则一路音、视频所占带宽为100 + 8 = 108KB/s 故总共需要 100*108*8/1000 = 86.4Mbit/s服务器带宽 3.视频会议:我有10个人进行视频会议,每个人将自己的视频广播给其他的9个人,服务端需要多少带宽? 解:假设摄像头视频尺寸为320*240,视频质量为高质量。 则每个人上行1路下行9路,10个人则上行10路下行90路。下行合起来是100路,即10*10路。 则总共需要 100*35*8/1000 = 28Mbit/s服务器带宽 四.网络品质测试与监控 1.客户端网络抖动 在服务器的带宽质量得到保证后,参与语音视频会话的各个客户端,如果希望都能达到比较流畅的体验,则需要达到以下亮点: (1)客户端到服务器的ping延时低于100ms。 (2)ping的最大抖动范围不超过20ms。 其中,网络抖动对流畅性的影响更大。在测试时,建议将到服务器的ping打开,如此可以观察ping对语音视频流畅性的影响。 注:ping命令加上 -t 就可以连续不断地 ping。如 ping 192.168.0.123 -t 2.观察网络流量 测试时,推荐在各个客户端机器上安装 NetLimiter 网络监控软件,可以实时查看客户端和服务器之间的上下行流量、以及客户端与客户端之间的P2P通道上的网络流量。 通过将网络流量监控与ping结合起来,就能很容易地测试网络的实时状态。 3.测试客户端与服务器之间的网络速度 通过windows自带的远程桌面的拷贝文件功能,结合上面的NetLimiter监控,我们可以很容易地测试出客户端电脑与服务器之间的网络速度。 (1)在客户端电脑上,使用windows自带的远程桌面功能(如win7下,开始菜单->所有程序->附件->远程桌面连接),连接到目标服务器上。 (2)上行拷贝:从当前电脑拷贝一个50M以上的文件到服务器上。 (3)下行拷贝:从服务器上拷贝一个50M以上的文件到当前电脑。 (4)在拷贝正在进行过程中,打开NetLimiter的界面,持续观察客户端与服务器之间传递的网络速度。 (5)测试时,建议持续观察5分钟以上,观察时请特别注意:(1)上下行速度分别是多少?(2)速度是否稳定? (6)如果是类似视频会议这样的系统,假设需求一般是4个人在同一个会议室,那么,可选择4个代表性(所在的地理区域具有代表性)的用户,然后在这4个人的电脑上同时进行这一测试,分别记录这4个测试结果。 (7)进行此测试时,可以同时观察到服务器的持续的ping值。 然后逐一分析每一个结果看其是否能满足OMCS的带宽要求。 NetLimiter 截图如下所示:
telnet命令的主要作用是与目标端口进行TCP连接(即完成TCP三次握手)。 当服务端启动后,但是telnet其监听的端口,却失败了。或者,当服务端运行了一段时间后,突然其监听的端口telnet不通了。当类似这样的telnet失败的情况出现时,都可以按照如下方面进行排查: 1.观察一下服务端进程的CPU和内存是否有异常。 比如,当CPU持续在100%时,就有可能导致来自客户端的TCP连接请求被丢弃或无暇处理。 2.端口监听器是否运行正常? 如果服务端是基于ESFramework开发的,则可以通过IRapidServerEngine的Advanced属性的GetPortListenerState方法来获取端口监听器的状态,该方法返回一个PortListenerState对象,其包含3个属性: (1)IsMaxConnection:是否达到了最大连接数的限制。 (2)IsListening:是否正在监听端口。如果未授权,或达到了最大连接数限制,则将会停止监听端口。 (3)LastDetectTime:最后一次检测TCP连接队列(已完成OS底层的三次握手,但尚未被ESFramework提取的TCP连接存放于该队列中)的时间。 如果上述两点都正常,则接下来,需要专业的运维人员或网管人当员参与进来协助排查。 3.在当前服务器上执行telnet命令,看能否连接成功? 如果能连接成功,至少表明本机的TCP握手请求是能正常地被接收和处理的。 4.在服务器上执行netstat命令 netstat是一个非常有用的查看端口状态的命令,执行netstat命令后,请注意查看以下信息: (1)目标端口是否处于监听状态? (2)目标端口上是否存在已成功建立的TCP连接(ESTABLISHED)?其数量是多少? (3)是否存在半开连接(SYN_RECV)?其数量是多少? (4)是否存在等待关闭的连接(TIME_WAIT)?其数量是多少? 这里,最有可能的原因是半开连接数达到最大限制,导致windows系统丢弃后续的TCP连接请求。 5.TCP三次握手是否正常? 对于一些奇怪现象的跟踪与分析,数据抓包工具是不可缺少的。 在服务器上将抓包工具运行起来,然后在其他的电脑上telnet该服务器的目标端口,通过抓包工具观察目标端口上TCP三次握手的过程是否正常: (1)目标端口是否收到了来自客户端的SYN请求? (2)目标端口有回复SYN_ACK给客户端? (3)目标端口有收到来自客户端的第三次握手? 只有当TCP三次握手顺利完成后,windows底层才会将建立好的TCP连接放入队列中,提交给上层的应用程序。 6.服务器网络拓扑结构、防火墙、路由器、网络安全监控等相关软硬件 在抓包分析的同时,结合服务器的网络拓扑接口进行考虑是很有必要的。很可能来自客户端的三次握手请求被防火墙、路由器、或某些网络完全监控的相关软硬件给挡住了。 此时,需要专业的运维人员或网管人员参与进来,协助排查问题,比如: (1)在服务器上执行netstat命令,查看目标端口的相关状态信息。 (2)在服务器上执行抓包工具,监测目标端口上是否有数据从客户端过来。 (3)分析服务器的网络拓扑结构,并以服务器为中心,依次向外检查防火墙、路由器、网络安全监控等相关软硬件等的设定,并进行针对性的排查测试。 经过以上的排查分析,应该都可以找到问题的根源所在,如果还是没有结果,可以给我留言,我们一起讨论下啊。
心跳超时指的是:针对某个在线的客户端(TCP连接),ESFramework服务端在指定的时间内,没有收到来自该客户端的任何消息,则认为该客户端已经掉线。 为什么需要心跳机制了?因为针对某些客户端掉线(可能是因为网络断开、或客户端程序退出),服务端不能立即感受到(有的可能需要过很长的时间才能感受到),所以,需要引入心跳机制,让服务端尽可能早地发现客户端已经不在线了。关于心跳机制,更详细的介绍可以参见这里。 如果发生了很多客户端批量心跳超时掉线的情况,就说明服务端在过去的某段时间内,从未收到来自这些客户端的任何心跳消息。通常有3种可能性导致该情况发生: 1.CPU或内存使用率过高 在该情况发生时,观察一下服务端进程的CPU和内存是否有异常。 比如,当CPU持续在100%时,就有可能导致接收数据的操作被停止。 2.处理某些信息所花费的时间过长 如果服务端的信息处理模型设定的是IocpDirectly,那么依据IocpDirectly的原理,当处理某个信息所花费的时间超过了服务端设定的心跳超时的时间,服务端就会将对应的客户端误判为心跳超时掉线。 假设是该原因导致的心跳超时,则对应的解决方案有: (1)找出那些处理非常耗时的信息,进行优化理,加快处理速度。 (2)将超时时间间隔设定位一个更大的值或关闭心跳检测。 (3)将信息处理修改为异步模式。 (4)将服务端信息处理模型修改为TaskQueue模式,这样就完全避免了由于信息处理时间过长导致误判的情况。 很显然,方案(1)是最好的也是根本性的解决方案。 3.服务器网络拓扑结构、防火墙、路由器、网络安全监控等相关软硬件 如果排除了前面的可能性(比如,即使改成了TaskQueue模式,批量掉线仍然发生),那么,几乎就只剩下一个可能:服务端在心跳超时时间间隔内未收到来自这些客户端的任何消息。很可能来自客户端的消息被防火墙、路由器、或某些网络完全监控的相关软硬件给挡住了。 此时,需要专业的运维人员或网管人员参与进来,协助排查问题,比如: (1)在服务器上执行netstat命令,查看目标端口的相关状态信息。 (2)在服务器上执行抓包工具,监测目标端口上是否有数据从客户端过来。 (3)分析服务器的网络拓扑结构,并以服务器为中心,依次向外检查防火墙、路由器、网络安全监控等相关软硬件等的设定,并进行针对性的排查测试。 经过以上的排查分析,应该都可以找到问题的根源所在,如果还是没有结果,可以给我留言,我们一起讨论下啊。
在 《实现一个简单的语音聊天室》一文发布后,很多朋友建议我也实现一个视频聊天室给他们参考一下,其实,视频聊天室与语音聊天室的原理是差不多的,由于加入了摄像头、视频的处理,逻辑会繁杂一些,本文就实现一个简单的多人视频聊天系统,让多个人可以进入同一个房间进行语音视频沟通。先看看3个人进行视频聊天的运行效果截图: 上面两张截图分别是:登录界面、标注了各个控件的视频聊天室的主界面。 一. C/S结构 很明显,我这个语音聊天室采用的是C/S结构,整个项目结构相对比较简单,如下所示: 同语音聊天室一样,该项目的底层也是基于OMCS构建的。这样,服务端就基本没写代码,直接把OMCS服务端拿过来用;客户端就比较麻烦些,下面我就重点讲客户端的开发。 二. 客户端控件式开发 客户端开发了多个自定义控件,然后将它们组装到一起,以完成视频聊天室的功能。为了便于讲解,我主界面的图做了标注,以指示出各个自定义控件。 现在我们分别介绍各个控件: 1. 分贝显示器 分贝显示器用于显示声音的大小,比如麦克风采集到的声音的大小,或扬声器播放的声音的大小。如上图中2标注的。 (1)傅立叶变换 将声音数据转换成分贝强度使用的是傅立叶变换。其对应的是客户端项目中的FourierTransformer静态类。源码比较简单,就不贴出来了,大家自己去看。 (2)声音强度显示控件 DecibelDisplayer DecibelDisplayer 使用的是PrograssBar来显示声音强度的大小。 每当有声音数据交给DecibelDisplayer显示时,首先,DecibelDisplayer会调用上面的傅立叶变换将其转换为分贝,然后,将其映射为PrograssBar的对应的Value。 2.视频显示控件 VideoPanel VideoPanel用于表示聊天室中的一个成员,如上图中1所示。它显示了成员的ID,成员的声音的强度(使用DecibelDisplayer控件),以及其麦克风的状态(启用、禁用)、摄像头的状态(不可用、正常、禁用)、成员的视频等。 这个控件很重要,我将其源码贴出来: public partial class VideoPanel : UserControl { private IChatUnit chatUnit; private bool isMySelf = false; public VideoPanel() { InitializeComponent(); } /// <summary> /// 初始化成员视频显示控件。 /// </summary> public void Initialize(IChatUnit unit ,bool myself) { this.chatUnit = unit; this.isMySelf = myself; this.toolStripLabel_displayName.Text = unit.MemberID; this.decibelDisplayer1.Visible = !myself; //初始化麦克风连接器 this.chatUnit.MicrophoneConnector.Mute = myself; this.chatUnit.MicrophoneConnector.SpringReceivedEventWhenMute = myself; this.chatUnit.MicrophoneConnector.ConnectEnded += new CbGeneric<ConnectResult>(MicrophoneConnector_ConnectEnded); this.chatUnit.MicrophoneConnector.OwnerOutputChanged += new CbGeneric(MicrophoneConnector_OwnerOutputChanged); this.chatUnit.MicrophoneConnector.AudioDataReceived += new CbGeneric<byte[]>(MicrophoneConnector_AudioDataReceived); this.chatUnit.MicrophoneConnector.BeginConnect(unit.MemberID); //初始化摄像头连接器 this.chatUnit.DynamicCameraConnector.SetViewer(this.skinPanel1); this.chatUnit.DynamicCameraConnector.ConnectEnded += new CbGeneric<ConnectResult>(DynamicCameraConnector_ConnectEnded); this.chatUnit.DynamicCameraConnector.OwnerOutputChanged += new CbGeneric(DynamicCameraConnector_OwnerOutputChanged); this.chatUnit.DynamicCameraConnector.BeginConnect(unit.MemberID); } //好友启用或禁用摄像头 void DynamicCameraConnector_OwnerOutputChanged() { if (this.InvokeRequired) { this.BeginInvoke(new CbGeneric(this.DynamicCameraConnector_OwnerOutputChanged)); } else { this.ShowCameraState(); } } private ConnectResult connectCameraResult; //摄像头连接器尝试连接的结果 void DynamicCameraConnector_ConnectEnded(ConnectResult res) { if (this.InvokeRequired) { this.BeginInvoke(new CbGeneric<ConnectResult>(this.DynamicCameraConnector_ConnectEnded), res); } else { this.label_tip.Visible = false; this.connectCameraResult = res; this.ShowCameraState(); } } /// <summary> /// 综合显示摄像头的状态。 /// </summary> private void ShowCameraState() { if (this.connectCameraResult != OMCS.Passive.ConnectResult.Succeed) { this.pictureBox_Camera.BackgroundImage = null; this.pictureBox_Camera.BackgroundImage = this.imageList2.Images[2]; this.pictureBox_Camera.Visible = true; this.toolTip1.SetToolTip(this.pictureBox_Camera, this.connectCameraResult.ToString()); } else { this.pictureBox_Camera.Visible = !this.chatUnit.DynamicCameraConnector.OwnerOutput; if (!this.chatUnit.DynamicCameraConnector.OwnerOutput) { this.pictureBox_Camera.BackgroundImage = this.imageList2.Images[1]; this.toolTip1.SetToolTip(this.pictureBox_Camera, "摄像头被主人禁用!"); return; } } } //将接收到的声音数据交给分贝显示器显示 void MicrophoneConnector_AudioDataReceived(byte[] data) { this.decibelDisplayer1.DisplayAudioData(data); } //好友启用或禁用麦克风 void MicrophoneConnector_OwnerOutputChanged() { if (this.InvokeRequired) { this.BeginInvoke(new CbGeneric(this.MicrophoneConnector_OwnerOutputChanged)); } else { this.ShowMicState(); } } private ConnectResult connectMicResult; //麦克风连接器尝试连接的结果 void MicrophoneConnector_ConnectEnded(ConnectResult res) { if (this.InvokeRequired) { this.BeginInvoke(new CbGeneric<ConnectResult>(this.MicrophoneConnector_ConnectEnded), res); } else { this.connectMicResult = res; this.ShowMicState(); } } /// <summary> /// 综合显示麦克风的状态。 /// </summary> private void ShowMicState() { if (this.connectMicResult != OMCS.Passive.ConnectResult.Succeed) { this.pictureBox_Mic.Visible = true; this.toolTip1.SetToolTip(this.pictureBox_Mic, this.connectMicResult.ToString()); } else { this.decibelDisplayer1.Working = false; this.pictureBox_Mic.Visible = !this.chatUnit.MicrophoneConnector.OwnerOutput; this.decibelDisplayer1.Visible = this.chatUnit.MicrophoneConnector.OwnerOutput && !this.isMySelf; if (!this.chatUnit.MicrophoneConnector.OwnerOutput) { this.pictureBox_Mic.BackgroundImage = this.imageList1.Images[1]; this.toolTip1.SetToolTip(this.pictureBox_Mic, "麦克风被主人禁用!"); return; } this.pictureBox_Mic.Visible = !isMySelf; if (this.chatUnit.MicrophoneConnector.Mute) { this.pictureBox_Mic.BackgroundImage = this.imageList1.Images[1]; this.toolTip1.SetToolTip(this.pictureBox_Mic, "静音"); } else { this.pictureBox_Mic.Visible = false; this.pictureBox_Mic.BackgroundImage = this.imageList1.Images[0]; this.toolTip1.SetToolTip(this.pictureBox_Mic, "正常"); this.decibelDisplayer1.Working = true; } } } /// <summary> /// 展开或收起视频面板。 /// </summary> private void toolStripButton1_Click(object sender, EventArgs e) { try { if (this.Height > this.toolStrip1.Height) { this.toolStripButton1.Text = "展开"; this.toolStripButton1.Image = Resources.Hor; this.chatUnit.DynamicCameraConnector.SetViewer(null); this.Height = this.toolStrip1.Height; } else { this.toolStripButton1.Text = "收起"; this.toolStripButton1.Image = Resources.Ver; this.chatUnit.DynamicCameraConnector.SetViewer(this.skinPanel1); } } catch (Exception ee) { MessageBox.Show(ee.Message); } } } (1)在代码中,IChatUnit就代表当前这个聊天室中的成员。我们使用其MicrophoneConnector连接到目标成员的麦克风、使用其DynamicCameraConnector连接到目标成员的摄像头。 (2)预定MicrophoneConnector的AudioDataReceived事件,当收到语音数据时,将其交给DecibelDisplayer去显示声音的大小。 (3)预定MicrophoneConnector的ConnectEnded和OwnerOutputChanged事件,根据其结果来显示VideoPanel控件上麦克风图标的状态(对应ShowMicState方法)。 (4)预定DynamicCameraConnector的ConnectEnded和OwnerOutputChanged事件,根据其结果来显示VideoPanel控件上摄像头图标的状态(对应ShowCameraState方法)。 3. MultiVideoChatContainer 控件 MultiAudioChatContainer对应上图中3标注的控件,它主要做了以下几件事情: (1)在初始化时,加入聊天室:通过调用IMultimediaManager的ChatGroupEntrance属性的Join方法。 (2)使用FlowLayoutPanel将聊天室中每个成员对应的VideoPanel罗列出来。 (3)当有成员加入或退出聊天室时(对应ChatGroup的SomeoneJoin和SomeoneExit事件),动态添加或移除对应的VideoPanel实例。 (4)通过CheckBox将自己设备(摄像头、麦克风、扬声器)的控制权暴露出来。我们可以启用或禁用我们自己的麦克风或扬声器。 (5)注意,其提供了Close方法,这意味着,在关闭包含了该控件的宿主窗体时,要调用其Close方法以释放其内部持有的麦克风连接器、摄像头连接器等资源。 在完成MultiAudioChatContainer后,我们这个聊天室的核心就差不多了。接下来就是弄个主窗体,然后把MultiVideoChatContainer拖上去,初始化IMultimediaManager,并传递给MultiVideoChatContainer就大功告成了。 三. 源码下载 上面只是讲了实现多人视频聊天室中的几个重点,并不全面,大家下载下面的源码可以更深入的研究。 VideoChatRoom.rar 最后,跟大家说说部署的步骤: (1)将服务端部署在一台机器上,启动服务端。 (2)修改客户端配置文件中的ServerIP为刚才服务器的IP。 (3)在多台机器上运行客户端,以不同的帐号登录到同一个房间(如默认的R1000)。 (4)如此,多个用户就处于同一个聊天室进行视频聊天了。
(最新OAUS版本请参见:自动升级系统的设计与实现(续2) -- 增加断点续传功能) 一.缘起 自从 自动升级系统的设计与实现(源码) 发布以后,收到了很多使用者的反馈,其中最多的要求就是希望OAUS服务端增加自动检测文件变更的功能,这样每次部署版本升级时,可以节省很多时间,而且可以避免手动修改带来的错误。 现在,我就简单介绍一下最新版本的OAUS中关于这个功能的实现。在上一个版本中,我们是这样操作的: 每次有版本更新时,我们需要把更新的文件拷贝到服务端的FileFolder文件夹下覆盖掉旧的文件,然后通过上述的操作界面,来手动修改每个文件的版本号。这个过程很繁琐,而且容易出错。于是,新版本就增加了自动扫描的功能,一键就可以搞定。 新版的操作界面截图如下所示: 点击“自动扫描”按钮,服务端就会检索FileFolder文件夹下文件的名称、大小、最后更新时间,然后得出本次更新结果:变化了几个文件、新增了几个文件、删除了几个文件。 二.源码实现 下面简单说明一下代码实现的具体过程。 1.FileUnit类增加 FileSize 和 LastUpdateTime 属性:这两个属性用于作为比对文件是否发生变化的最根本依据。 2.具体实现代码如下: private void button1_Click(object sender, EventArgs e) { int changedCount = 0; int addedCount = 0; List<FileUnit> deleted = new List<FileUnit>(); List<string> files = ESBasic.Helpers.FileHelper.GetOffspringFiles(AppDomain.CurrentDomain.BaseDirectory + "FileFolder\\"); //第一轮:检测发生变化和新增的文件 foreach (string fileRelativePath in files) { FileInfo info = new FileInfo(AppDomain.CurrentDomain.BaseDirectory + "FileFolder\\" + fileRelativePath); FileUnit unit = this.GetFileUnit(fileRelativePath); if (unit == null) //新增的文件 { unit = new FileUnit(fileRelativePath, 1, (int)info.Length, info.LastWriteTime); this.fileConfig.FileList.Add(unit); ++addedCount; } else { //发生变化的文件 if (unit.FileSize != info.Length || unit.LastUpdateTime.ToString() != info.LastWriteTime.ToString()) { unit.Version += 1; unit.FileSize = (int)info.Length; unit.LastUpdateTime = info.LastWriteTime; ++changedCount; } } } //第二轮:检测被删除的文件 foreach (FileUnit unit in this.fileConfig.FileList) { bool found = false; foreach (string fileRelativePath in files) { if (fileRelativePath == unit.FileRelativePath) { found = true; break; } } if (!found) { deleted.Add(unit); } } foreach (FileUnit unit in deleted) { this.fileConfig.FileList.Remove(unit); } this.fileConfig.Save(); if (changedCount > 0 || addedCount > 0 || deleted.Count > 0) { this.changed = true; this.dataGridView1.DataSource = null; this.dataGridView1.DataSource = this.fileConfig.FileList; string msg = string.Format("更新:{0},新增:{1},删除:{2}", changedCount, addedCount, deleted.Count); MessageBox.Show(msg); } else { MessageBox.Show("没有检测到变化。"); } } (1)首先,第一轮检测发生变化的或新增的文件。 (2)然后,第二轮检测被删除的文件。 (3)每次检测完毕后,都更新维护的版本号。 最后,我保留了原始的手动更新版本号的功能,以备不时之需。 3. 关于客户端如何使用升级机制的说明 一般而言,如果最新客户端程序与老版本兼容,不升级也影响不大,则可以交由用户决定是否升级;如果最新客户端程序不兼容老版本,或者是有重大更新,则将启动强制升级。如果流程要进入启动升级,那么只要启动AutoUpdater的文件夹下AutoUpdater.exe就可以了。要注意的是,启动AutoUpdater.exe进程后,要退出当前的客户端进程,否则,有些文件会因为无法被覆盖而导致更新失败。代码大致如下所示: if (VersionHelper.HasNewVersion(oausServerIP,oausServerPort)) { string updateExePath = AppDomain.CurrentDomain.BaseDirectory + "AutoUpdater\\AutoUpdater.exe"; System.Diagnostics.Process myProcess = System.Diagnostics.Process.Start(updateExePath); ......//退出当前进程 } 客户端运行后,升级过程截图如下: 三.相关下载 1.自动升级系统OAUS - 源码 2.自动升级系统OAUS(可直接部署) 3.自动升级系统OAUS - 使用手册 如果有任何建议或问题,请留言给我。
语音聊天室,或多人语音聊天,是即时通信应用中常见的功能之一,比如,QQ的语音讨论组就是我们用得比较多的。 这篇文章将实现一个简单的语音聊天室,让多个人可以进入同一个房间进行语音沟通。先看运行效果截图: 从左到右的三张图分别是:登录界面、语音聊天室的主界面、标注了各个控件的主界面。 (如果觉得界面太丑,没关系,后面下载源码后,你可以自己美化~~) 一. C/S结构 很明显,我这个语音聊天室采用的是C/S结构,整个项目结构相对比较简单,如下所示: 该项目的底层是基于OMCS构建的。这样,服务端就基本没写代码,直接把OMCS服务端拿过来用;客户端就比较麻烦些,下面我就重点讲客户端的开发。 二. 客户端控件式开发 客户端开发了多个自定义控件,然后将它们组装到一起,以完成语音聊天室的功能。为了便于讲解,我主界面的图做了标注,以指示出各个自定义控件。 现在我们分别介绍各个控件: 1. 分贝显示器 分贝显示器用于显示声音的大小,比如麦克风采集到的声音的大小,或扬声器播放的声音的大小。如上图中3标注的。 (1)傅立叶变换 将声音数据转换成分贝强度使用的是傅立叶变换。其对应的是客户端项目中的FourierTransformer静态类。源码比较简单,就不贴出来了,大家自己去看。 (2)声音强度显示控件 DecibelDisplayer DecibelDisplayer 使用的是PrograssBar来显示声音强度的大小。 每当有声音数据交给DecibelDisplayer显示时,首先,DecibelDisplayer会调用上面的傅立叶变换将其转换为分贝,然后,将其映射为PrograssBar的对应的Value。 2.发言者控件 SpeakerPanel SpeakerPanel 用于表示聊天室中的一个成员,如上图中1所示。它显示了成员的ID,成员的声音的强度(使用DecibelDisplayer控件),以及其麦克风的状态(启用、引用)。 这个控件很重要,我将其源码贴出来: public partial class SpeakerPanel : UserControl ,IDisposable { private ChatUnit chatUnit; public SpeakerPanel() { InitializeComponent(); this.SetStyle(ControlStyles.ResizeRedraw, true);//调整大小时重绘 this.SetStyle(ControlStyles.OptimizedDoubleBuffer, true);// 双缓冲 this.SetStyle(ControlStyles.AllPaintingInWmPaint, true);// 禁止擦除背景. this.SetStyle(ControlStyles.UserPaint, true);//自行绘制 this.UpdateStyles(); } public string MemberID { get { if (this.chatUnit == null) { return null; } return this.chatUnit.MemberID; } } public void Initialize(ChatUnit unit) { this.chatUnit = unit; this.skinLabel_name.Text = unit.MemberID; this.chatUnit.MicrophoneConnector.ConnectEnded += new CbGeneric<OMCS.Passive.ConnectResult>(MicrophoneConnector_ConnectEnded); this.chatUnit.MicrophoneConnector.OwnerOutputChanged += new CbGeneric(MicrophoneConnector_OwnerOutputChanged); this.chatUnit.MicrophoneConnector.AudioDataReceived += new CbGeneric<byte[]>(MicrophoneConnector_AudioDataReceived); this.chatUnit.MicrophoneConnector.BeginConnect(unit.MemberID); } public void Initialize(string curUserID) { this.skinLabel_name.Text = curUserID; this.skinLabel_name.ForeColor = Color.Red; this.pictureBox_Mic.Visible = false; this.decibelDisplayer1.Visible = false; } void MicrophoneConnector_AudioDataReceived(byte[] data) { this.decibelDisplayer1.DisplayAudioData(data); } void MicrophoneConnector_OwnerOutputChanged() { if (this.InvokeRequired) { this.BeginInvoke(new CbGeneric(this.MicrophoneConnector_OwnerOutputChanged)); } else { this.ShowMicState(); } } private ConnectResult connectResult; void MicrophoneConnector_ConnectEnded(ConnectResult res) { if (this.InvokeRequired) { this.BeginInvoke(new CbGeneric<ConnectResult>(this.MicrophoneConnector_ConnectEnded), res); } else { this.connectResult = res; this.ShowMicState(); } } public void Dispose() { this.chatUnit.Close(); } private void ShowMicState() { if (this.connectResult != OMCS.Passive.ConnectResult.Succeed) { this.pictureBox_Mic.BackgroundImage = this.imageList1.Images[2]; this.toolTip1.SetToolTip(this.pictureBox_Mic, this.connectResult.ToString()); } else { this.decibelDisplayer1.Working = false; if (!this.chatUnit.MicrophoneConnector.OwnerOutput) { this.pictureBox_Mic.BackgroundImage = this.imageList1.Images[1]; this.toolTip1.SetToolTip(this.pictureBox_Mic, "好友禁用了麦克风"); return; } if (this.chatUnit.MicrophoneConnector.Mute) { this.pictureBox_Mic.BackgroundImage = this.imageList1.Images[1]; this.toolTip1.SetToolTip(this.pictureBox_Mic, "静音"); } else { this.pictureBox_Mic.BackgroundImage = this.imageList1.Images[0]; this.toolTip1.SetToolTip(this.pictureBox_Mic, "正常"); this.decibelDisplayer1.Working = true; } } } private void pictureBox_Mic_Click(object sender, EventArgs e) { if (!this.chatUnit.MicrophoneConnector.OwnerOutput) { return; } this.chatUnit.MicrophoneConnector.Mute = !this.chatUnit.MicrophoneConnector.Mute; this.ShowMicState(); } } (1)在代码中,ChatUnit就代表当前这个聊天室中的成员。我们使用其MicrophoneConnector连接到目标成员的麦克风。 (2)预定MicrophoneConnector的AudioDataReceived事件,当收到语音数据时,将其交给DecibelDisplayer去显示声音的大小。 (3)预定MicrophoneConnector的ConnectEnded和OwnerOutputChanged事件,根据其结果来显示SpeakerPanel空间上麦克风图标的状态(对应ShowMicState方法)。 3. MultiAudioChatContainer 控件 MultiAudioChatContainer对应上图中2标注的控件,它主要做了以下几件事情: (1)在初始化时,加入聊天室:通过调用IMultimediaManager的ChatGroupEntrance属性的Join方法。 (2)使用FlowLayoutPanel将聊天室中每个成员对应的SpeakerPanel罗列出来。 (3)当有成员加入或退出聊天室时(对应ChatGroup的SomeoneJoin和SomeoneExit事件),动态添加或移除对应的SpeakerPanel实例。 (4)通过CheckBox将自己设备(麦克风和扬声器)的控制权暴露出来。我们可以启用或禁用我们自己的麦克风或扬声器。 (5)注意,其提供了Close方法,这意味着,在关闭包含了该控件的宿主窗体时,要调用其Close方法以释放其内部持有的麦克风连接器等资源。 在完成MultiAudioChatContainer后,我们这个聊天室的核心就差不多了。接下来就是弄个主窗体,然后把MultiAudioChatContainer拖上去,初始化IMultimediaManager,并传递给MultiAudioChatContainer就大功告成了。 三. 源码下载 上面只是讲了实现多人语音聊天室中的几个重点,并不全面,大家下载下面的源码可以更深入的研究。 AudioChatRoom.rar 最后,跟大家说说部署的步骤: (1)将服务端部署在一台机器上,启动服务端。 (2)修改客户端配置文件中的ServerIP为刚才服务器的IP。 (3)在多台机器上运行客户端,以不同的帐号登录到同一个房间(如默认的R1000)。 (4)如此,多个用户就处于同一个聊天室进行语音聊天了。
我碰到这个问题比较奇怪,我写的一个方法(基于.NET 2.0)在win7、win2003下运行没有问题,在winxp下运行就抛异常:“公共语言运行时检测到无效的程序”,对应英文为:common language runtime detected an invalid program. 抛异常的方法代码摘抄如下: private Control control = ...; public void ActionOnUI<T1>(bool showMessageBoxOnException, bool beginInvoke, CbGeneric<T1> method, params object[] args) { if (this.control.InvokeRequired) { if (beginInvoke) { this.control.BeginInvoke(new CbGeneric<bool, bool, CbGeneric<T1>, object[]>(this.ActionOnUI), showMessageBoxOnException, beginInvoke, method, args); } else { this.control.Invoke(new CbGeneric<bool, bool, CbGeneric<T1>, object[]>(this.ActionOnUI), showMessageBoxOnException, beginInvoke, method, args); } } else { try { method((T1)args[0]); } catch (Exception ee) { if (showMessageBoxOnException) { MessageBox.Show(ee.Message); } } } } 方法的目的是对UI调用转发做一个封装,让使用者更方便的将调用转发到UI线程。 但是,这个方法在执行时,异常在xp下发生了: Common Language Runtime detected an invalid program. at ESBasic.Helpers.UiSafeInvoker.ActionOnUI[T1](Boolean showMessageBoxOnException, Boolean beginInvoke, CbGeneric`1 method, Object[] args) 我在网上搜了一些相关问题的解答,比较靠谱的一点是这样说的: “这种错误非常少见,是一个编译器错误,通常产生在将C#等托管语言生成为MSIL时候出的错,没有什么好的解决办法,现在可行的方法好像就是修改现在的程序结构,这样根据新的结构生成新的MSIL时不会出错就基本可以避免这个问题。” 根据这个提示,我对方法的代码进行了各种修改尝试,最后终于得到了一种在xp下也不抛异常的结构,粘贴如下: private Control control = ...; public void ActionOnUI<T1>(bool showMessageBoxOnException, bool beginInvoke, CbGeneric<T1> method, T1 args) { if (this.control.InvokeRequired) { if (beginInvoke) { this.control.BeginInvoke(new CbGeneric<bool, CbGeneric<T1>, T1>(this.Do_ActionOnUI<T1>), showMessageBoxOnException, method, args); return; } this.control.Invoke(new CbGeneric<bool, CbGeneric<T1>, T1>(this.Do_ActionOnUI<T1>), showMessageBoxOnException, method, args); return; } this.Do_ActionOnUI<T1>(showMessageBoxOnException, method, args); } private void Do_ActionOnUI<T1>(bool showMessageBoxOnException, CbGeneric<T1> method, T1 args) { try { method(args); } catch (Exception ee) { if (showMessageBoxOnException) { MessageBox.Show(ee.Message); } } } 总结起来,改变的几点如下: (1)将真正执行的部分重构为一个方法Do_ActionOnUI,然后,转发调用Invoke都指向这个方法。 (2)Invoke转发调用时,为指向的方法加上泛型参数,避免编译器自动去匹配。 (3)将弱类型的参数object[]修改为强类型的参数T1。 好吧,现在问题总算是解决了,好好折腾了一番啊~~
现在很多下载客户端程序都需要设定自己头像的功能,而设定头像一般有两种方式:使用摄像头自拍头像,或者选择一个图片的某部分区域作为自己的头像。 一.相关技术 若要实现上述的自拍头像和上传头像的功能,会碰到以下要解决的问题: (1)调用摄像头,捕获摄像头采集的视频,并将采集的视频绘制到UI上。 (2)从图片文件读取Image,并显示在控件上(这个相当easy)。 (3)在显示的视频或图片上,能够拖动一个正方形,以选择指定部分的区域作为自己的头像。 (4)从视频中截获一帧保存为图片。 (5)从图片中截取某个区域作为自己的头像。 为了解决这些问题,就需要涉及到的技术有DirectX Show、GDI+、drawdib(位图绘制)、图像截取等。 二.Demo实现 当然这篇文章不是要告诉大家这些技术的详细细节,相关的资料网上有很多,如果需要从头到尾自己实现,可以从了解这些技术入手。在这里,我将傲瑞通(OrayTalk)中的设定头像的功能拆分出来做成一个demo,供大家参考和使用,避免大家浪费时间重复发明轮子。我们先看看demo的运行效果。 自拍头像: 上传头像: 在demo中,点击窗体上的确定按钮,就会自动将所选择区域的图像保存为自己的头像了。这是怎么做到的了?实际上,我们是使用了OMCS提供的两个控件:HeadImagePanel和ImagePartSelecter。 1.HeadImagePanel 控件 先看看HeadImagePanel控件的定义吧: public class HeadImagePanel : UserControl { // 当选择的头像区域发生改变时,会触发此事件。参数为头像位图。 public event CbGeneric<Bitmap> HeadImageSelected;// 获取结果头像。 public Bitmap GetHeadImage();// 初始化摄像头,并启动它。 // cameraDeviceIndex: 摄像头的索引 // cameraSize: 摄像头采集分辨率 // outputImageLen: 输出的正方形头像的边长 public void Start(int cameraDeviceIndex, Size cameraSize, int outputImageLen);// 停止摄像头。 public void Stop(); } (1)将HeadImagePanel拖到窗体上,然后调用其Start方法,它就会自动启动摄像头,并将捕捉的视频绘制带该控件的表面上,而且,同时会在视频的上面绘制蓝边的正方形,我们可以通过拖动或改变这个正方形的大小,来指定选择的区域。 (2)当区域指定好后,可以调用其GetHeadImage方法,其就会返回最终的结果图像(即指定区域内的视频图像)。 (3)使用完毕后,调用HeadImagePanel的Stop方法以释放摄像头及相关的其它资源。 (4)要特别注意的是,请将HeadImagePanel控件的Size设置为与摄像头采集分辨率一样的大小。否则,结果图像将是有偏差的。 2.ImagePartSelecter 控件 图像区域选择控件ImagePartSelecter的定义如下: public class ImagePartSelecter : UserControl { // 当选择的区域发生改变时,会触发此事件。事件参数为原始图片的选择区域截图。 public event CbGeneric<Bitmap> ImagePartSelected; // 获取结果图片(原始图片的选择区域截图)。 public Bitmap GetResultImage();// 初始化。 // outputImgLen: 最终要输出的正方形图片的边长。 public void Initialize(int outputImgLen);// 指定要被选取的图片。 public void SetSourceImage(Image image); } (1)将ImagePartSelecter控件拖到窗体上,调用Initialize方法初始化。 (2)调用SetSourceImage方法设置原始的头像图片,这样,图片会显示在控件的表面,而且ImagePartSelecter会在图像的上面绘制蓝边的正方形,我们可以通过拖动或改变这个正方形的大小,来指定选择的区域。 (3)当区域指定好后,可以调用其GetResultImage方法,其就会返回最终的结果图像(即指定区域内的图像)。 (4)与HeadImagePanel控件不一样的是,不需要将ImagePartSelecter控件的Size设置为与图片一样的大小,ImagePartSelecter内部会自动缩放并适应。 三.源码下载 自拍头像Demo(源码) 源码就不贴出来了,大家下载自己看吧:) 如果觉得这篇文章对你有帮助,请顶一下,并粉我啊,嘿嘿
随着HTML5 WebSocket技术的日益成熟与普及,我们可以借助WebSocket来更加方便地打通BS与CS -- 因为B/S中的WebSocket可以直接连接到C/S的服务端,并进行双向通信。如下图所示: 一.对Socket Server的要求 我们可以尝试让Socket Server透明地支持WebSocket客户端,所谓透明的意思是,服务端开发人员不用关心客户端究竟是什么类型,而是可以统一的接收数据、处理数据、发送数据。为了做到这一点,我们可以构建一个服务端框架,让框架完成透明化的工作,这就要求这个框架做到以下几点: (1)根据客户端TCP连接请求成功后的第一个消息中是否含有“websocket”标记,来判断其是否为WebSocket客户端。如果客户端的类型是WebSocket,则自动完成以下事情。 (2)自动完成Web Sokects 握手协议。 (3)针对接收到的每个WebSokect数据帧,都能自动将其解析,并从中分离出真正的消息内容。 (4)当您发送消息给WebSokect客户端时,服务端引擎会自动将该消息封装成WebSokect数据帧,然后再发送出去。 我们在StriveEngine中实现了对上述WebSocket的透明化支持,至于具体如何实现的,大家可以参考一下WebSokect的标准协议。下面我们就来做一个Demo,让.NET Socket客户端和WebSocket客户端能同时与一个StriveEngine服务端进行双向通信。 二.打通B/S与C/S的Demo 准备 基于WebSokect,我们在绝大多数情况下,使用的都是文本消息,OK,那我们就基于文本消息来构建这个Demo。 (1)虽然WebSokect可以借助其HTML5协议来自动完成一个消息的独立识别,但是对于我们的普通socket来说,必须有一个方法来识别一个完整的消息。 (2)常用的方法是使用特殊的消息结束标识符,那在这个demo中,我们就以'\0'作为消息的结束符吧。 (3)基于(2),那么WebSokect在发送消息给服务端时,也必须在消息结尾加上'\0'。 三.Demo实现 我们先看看Demo运行起来的效果: 在Demo中,WebSocket客户端和.NET Socket客户端都可以与同一个服务端进行互通消息。 1.源码结构说明 该Demo源码总共包括三个项目和一个HTML文件: (1)StriveEngine.SimpleDemoServer:基于StriveEngine开发的服务端。 (2)StriveEngine.SimpleDemoClient:基于StriveEngine开发的客户端。 (3)StriveEngine.SimpleDemo:直接基于.NET的Socket开发的客户端。 (4)WebSocketClient.html:基于HTML5 WebSocket的客户端。与前两种客户端公用同一个StriveEngine服务端。 接下来,我们着重看一下WebSocket客户端实现,其它的.NET代码,大家直接去看Demo源码就好了。 2.WebSocket客户端实现 (1)HTML 页面布局 <body> <h3>WebSocketTest</h3> <div id="login"> <div> <input id="serverIP" type="text" placeholder="服务器IP" value="127.0.0.1" autofocus="autofocus" /> <input id="serverPort" type="text" placeholder="服务器端口" value="9000" /> <input id="btnConnect" type="button" value="连接" onclick="connect()" /> </div> <div> <input id="sendText" type="text" placeholder="发送文本" value="I'm WebSocket Client!" /> <input id="btnSend" type="button" value="发送" onclick="send()" /> </div> <div> <div> 来自服务端的消息 </div> <textarea id="txtContent" cols="50" rows="10" readonly="readonly"></textarea> </div> </div> </body> (2)js方法实现 <script> var socket; function connect() { var host = "ws://" + $("serverIP").value + ":" + $("serverPort").value + "/" socket = new WebSocket(host); try { socket.onopen = function (msg) { $("btnConnect").disabled = true; alert("连接成功!"); }; socket.onmessage = function (msg) { if (typeof msg.data == "string") { displayContent(msg.data); } else { alert("非文本消息"); } }; socket.onclose = function (msg) { alert("socket closed!") }; } catch (ex) { log(ex); } } function send() { var msg = $("sendText").value + '\0' socket.send(msg); } window.onbeforeunload = function () { try { socket.close(); socket = null; } catch (ex) { } }; function $(id) { return document.getElementById(id); } Date.prototype.Format = function (fmt) { //author: meizz var o = { "M+": this.getMonth() + 1, //月份 "d+": this.getDate(), //日 "h+": this.getHours(), //小时 "m+": this.getMinutes(), //分 "s+": this.getSeconds(), //秒 "q+": Math.floor((this.getMonth() + 3) / 3), //季度 "S": this.getMilliseconds() //毫秒 }; if (/(y+)/.test(fmt)) fmt = fmt.replace(RegExp.$1, (this.getFullYear() + "").substr(4 - RegExp.$1.length)); for (var k in o) if (new RegExp("(" + k + ")").test(fmt)) fmt = fmt.replace(RegExp.$1, (RegExp.$1.length == 1) ? (o[k]) : (("00" + o[k]).substr(("" + o[k]).length))); return fmt; } function displayContent(msg) { $("txtContent").value += "\r\n" +new Date().Format("yyyy/MM/dd hh:mm:ss")+ ": " + msg; } function onkey(event) { if (event.keyCode == 13) { send(); } } </script> js代码中的重点都通过红色字体标记出来了,要特别注意,send方法在发送消息时,会自动在消息的末尾添加一个我们约定好的结束符'\0'。 四.源码下载 打通BS与CS的Demo源码 如果有任何建议或问题,请留言给我。 附相关系列:文本协议通信demo源码及说明文档 二进制协议通信demo源码及说明文档 另附:简单即时通讯Demo源码及说明
(最新OAUS版本请参见:自动升级系统OAUS的设计与实现(续)) 对于PC桌面应用程序而言,自动升级功能往往是必不可少的。而自动升级可以作为一个独立的C/S系统来开发,这样,就可以在不同的桌面应用中进行复用。基于ESFramework的文件传送功能,我实现了一个可直接复用的自动升级系统OAUS,现在将其分享给大家。这篇文章将着重介绍OAUS的相关背景、使用方法,至于详细的实现细节,大家可以直接下载源码研究。如果了解了OAUS的使用,源码的理解就非常容易了。如果需要直接部署使用自动升级系统,那么,可下载文末的可执行程序压缩包。 一.OAUS的主要功能 目前主流的程序自动升级策略是,重新下载最新的安装包,然后重新安装整个客户端。这种方式虽然简单直观,但是缺陷也很明显。比如,即使整个客户端有100M,而本次更新仅仅只是修改了一个1k大小的dll,那也意味着要重新下载100M的全部内容。这对带宽是极大的浪费,而且延长了升级了时间,相应地也增加了客户茫然等待的时间。 在上述的场景中,自动升级时,我们能否只更新那个被修改了的1k的dll了?当然,使用OAUS自动升级系统可以轻松地做到这一点。OAUS自动升级系统可以对被分发的客户端程序中的每个文件进行版本管理,每次升级的基础单元不再是整个客户端程序,而是其中的单个文件。针对单个文件的更新,包括三种形式: (1)文件被修改。 (2)文件被删除。 (3)新增加某个文件。 OAUS对这三种形式的文件更新都是支持的。每次自动升级,都可以更改N个文件、删除M个文件、新增加L个文件。 二.OAUS的使用 1.OAUS的结构 OAUS提供了可直接执行的服务端程序和客户端程序:AutoUpdaterSystem.Server.exe 和 AutoUpdater.exe。 OAUS服务端的目录结构如下所示: OAUS的客户端与服务器之间通过TCP通信,可以在AutoUpdaterSystem.Server.exe.config配置文件中配置服务器通过哪个TCP端口提供自动升级服务。 FileFolder文件夹初始是空的,其用于部署被分发的程序的各个文件的最新版本。注意,其下的文件结构一定要与被分发的程序正常部署后的结构完全一致 -- 即相当于在FileFolder文件夹下部署一个被分发的程序。 OAUS客户端的目录结构如下: 可以在AutoUpdater.exe.config配置文件中配置OAUS服务器的IP、端口等信息,其内容如下所示: <configuration> <appSettings> <!--服务器IP --> <add key="ServerIP" value="127.0.0.1"/> <!--服务器端口--> <add key="ServerPort" value="4530"/> <!--升级完成后,将被回调的可执行程序的名称--> <add key="CallbackExeName" value="Demo.exe"/> <!--主窗体的Title--> <add key="Title" value="文件更新"/> </appSettings> </configuration> 请注意配置的CallbackExeName,其表示当升级完成之后,将被启动的分发程序的exe的名称。这个CallbackExeName配置的为什么是名称而不是路径了?这是因为使用和部署OAUS客户端时是有要求的: (1)被分发的程序的可执行文件exe必须位于部署目录的根目录。 (2)OAUS的客户端(即整个AutoUpdater文件夹)也必须位于这个根目录。 如此,AutoUpdater就知道分发程序的exe相对自己的路径,如此就可以确定分发程序的exe的绝对路径,所以就可以在升级完成后启动目标exe了。另外,根据上述的两个约定,再结合前面讲到的服务端的FileFolder文件夹的结构约定,当服务端更新一个文件时,AutoUpdater便可以确定该文件在客户端机器上的绝对路径了。 2.OAUS自动升级流程 下面我们就详细讲讲如何使用OAUS来构建自动升级系统,大概的步骤如下。 (1)运行OAUS服务端。 服务端主界面将显示所有正在自动升级的OAUS客户端信息。 (2)将被分发的客户端程序的所有内容放到OAUS服务端的FileFolder文件夹下,其结构与客户端程序正常部署后的结构要完全一致。我们以部署VideoChatSystem为例。 (3)使用OAUS服务端为被分发的客户端程序的每个文件生成默认版本号,并创建版本信息配置文件UpdateConfiguration.xml。这个配置文件也将被客户端使用。 点击服务端【工具】菜单栏下的【版本管理】子菜单,将弹出用于管理各个文件版本的【文件版本信息】窗体。 双击列表中的任意一行,可以修改其对应文件的版本的值(float类型的数值)。注意,此列表中的版本信息与文件的真实版本属性(比如dll的版本属性X.X.X.X)可以是没有任何联系的,列表中版本的值只是用于标记文件是否被修改,所以,文件每被修改一次,其列表中对应的版本的值就应该有所增大。 当关闭【文件版本信息】窗体时,只要有某个文件版本变化,则“最后综合版本”的值(int类型)会递增1。通过比较OAUS的客户端保存的“最后综合版本”的值与OAUS的服务端最新的“最后综合版本”的值,就可以快速地识别客户端是否已经是最新版本了。 另外,初次打开这个窗口时,将在OAUS服务端的目录下,自动生成一个版本信息配置文件UpdateConfiguration.xml。而且,每当通过该窗体来设置某个文件的新版本时,UpdateConfiguration.xml会自动同步更新。 (4)将UpdateConfiguration.xml添加到OAUS的客户端程序(即上述的AutoUpdater的文件夹)中。 (5)在创建被分发的客户端的安装程序时,将OAUS的客户端(即AutoUpdater的文件夹)也打包进去,并且像前面说的一样,要将其直接部署在运行目录(BaseDirectory)下(与分发的exe同一目录)。 如此,准备工作就完成了,当客户端通过安装包安装好了VideoChatSystem之后,其目录结构像下面这样: (6)当我们有新的版本要发布时,比如要更新某个文件(因为文件被修改),那么可以这样做: a.将修改后的文件拷贝到OAUS服务端的FileFolder文件夹下的正确位置(或覆盖旧的文件)。 b.在OAUS服务端打开【文件版本信息】窗体,双击被修改文件所对应的Row,在弹出的窗体上修改对应文件的版本号,将版本号的数值增加。(如果是删除旧文件或添加新文件,此处也可进行相应的操作) (7)如此,当客户端再启动AutoUpdater.exe时,就会自动升级,更新那些发生变化的文件。 以下是AutoUpdater.exe运行起来后的截图。 (8)当升级完成后,将启动前述的OAUS客户端配置文件中配置的回调exe。(在本例中就是VideoChatSystem.exe) (9)OAUS客户端会在日志文件UpdateLog.txt(位于AutoUpdater的文件夹下,在OAUS客户端首次运行时自动生成该文件)中,记录每次自动升级的情况。 3.何时启动自动升级客户端? 假设某个系统是下载客户端形式的,那么客户端该如何知道是否有新版本了?然后又该何时启动AutoUpdater.exe了? 我们的经验是这样的:客户端登录成功之后,从服务器获取“最后综合版本”的值,然后与本地的“最后综合版本”的值相比较,如果本地的值较小,则表示客户端需要更新。这个过程可以这样做到: (1)当在OAUS服务端的FileFolder文件夹下放置了新的文件,并通过【文件版本信息】窗体正确的更新了版本号,在关闭【文件版本信息】窗体时,“最后综合版本”的值会自动加1。 (2)系统客户端可以通过调用AutoUpdater.VersionHelper类的静态方法HasNewVersion()来判断是否有新版本。 (3)如果HasNewVersion方法返回true,则通常有两种模式:由用户选择是否升级,或者是强制升级。 一般而言,如果最新客户端程序与老版本兼容,不升级也影响不大,则可以交由用户决定是否升级;如果最新客户端程序不兼容老版本,或者是有重大更新,则将启动强制升级。如果流程要进入启动升级,那么只要启动AutoUpdater的文件夹下AutoUpdater.exe就可以了。要注意的是,启动AutoUpdater.exe进程后,要退出当前的客户端进程,否则,有些文件会因为无法被覆盖而导致更新失败。代码大致如下所示: if (VersionHelper.HasNewVersion(oausServerIP,oausServerPort)) { string updateExePath = AppDomain.CurrentDomain.BaseDirectory + "AutoUpdater\\AutoUpdater.exe"; System.Diagnostics.Process myProcess = System.Diagnostics.Process.Start(updateExePath); ......//退出当前进程 } 三.相关下载 1.自动升级系统OAUS - 源码 2.自动升级系统OAUS(可直接部署) 如果有任何建议或问题,请留言给我。
看到很多开发IM系统的朋友都想实现聊天记录存储和查询这一不可或缺的功能,这里我就把自己前段时间为傲瑞通(OrayTalk)开发聊天记录模块的经验分享出来,供需要的朋友参考下。 一.总体设计 1.存储位置 从一开始我们就打算在服务端和客户端本地同时存储聊天记录,而且,在客户端查看聊天记录时,可以选择是从本地加载、还是从服务器加载。这样做的好处有两个: (1)从本地加载聊天记录速度非常快。 (2)当更换了登录的机器,在任何地方任何时刻都可以从服务器加载完整的聊天记录,记录永远不会丢失。 2.存储方案 (1)在服务端存储聊天记录当然使用我们主流的数据库SqlServer或Mysql等。 (2)在客户端,我们开始选择的是使用序列化技术,但是,考虑到当聊天记录数据量庞大时,序列化方案就不够灵活了,而且性能也跟不上。所以,最后决定使用轻量级的数据库Sqlite。 3.ORM框架 DataRabbit的最新版本增加了对Sqlite的支持,并且对不同数据库的操作API是完全一致的,所以我们使用DataRabbit写了一个小组件来完成聊天记录的存储与查询等数据库访问操作。而无论是客户端还是服务端的聊天记录存储相关的工作,都交给这个组件来完成。 二.具体实现 1.ChatMessageRecord类 一条聊天记录基本上包含了以下几个内容:发送人、接收人、内容、时间等。并且,我们想将两人聊天及群聊天抽象成同一个模型,于是,聊天记录的Entity类ChatMessageRecord设计成如下模样: public class ChatMessageRecord { #region AutoID private long autoID = 0; /// <summary> /// 自增ID,编号。 /// </summary> public long AutoID { get { return autoID; } set { autoID = value; } } #endregion #region SpeakerID private string speakerID = ""; /// <summary> /// 发言人的ID。 /// </summary> public string SpeakerID { get { return speakerID; } set { speakerID = value; } } #endregion #region AudienceID private string audienceID = ""; /// <summary> /// 听众ID,可以为GroupID。 /// </summary> public string AudienceID { get { return audienceID; } set { audienceID = value; } } #endregion #region OccureTime private DateTime occureTime = DateTime.Now; /// <summary> /// 聊天记录发生的时间。 /// </summary> public DateTime OccureTime { get { return occureTime; } set { occureTime = value; } } #endregion #region ContentRtf private string contentRtf = ""; /// <summary> /// 聊天的内容。 /// </summary> public string ContentRtf { get { return contentRtf; } set { contentRtf = value; } } #endregion #region IsGroupChat private bool isGroupChat = false; /// <summary> /// 是否为群聊记录。 /// </summary> public bool IsGroupChat { get { return isGroupChat; } set { isGroupChat = value; } } #endregion } 在ChatMessageRecord的定义中,聊天内容字段被设计为string类型,这是因为在OrayTalk中,聊天内容是富文本RTF格式的。如果需要,可以更改为byte[]类型,这样通过自定义的序列化操作就可以承载更复杂的聊天格式。 最后一个字段IsGroupChat表明当前记录是否为群聊记录,如果是群聊记录,那么,AudienceID就不是好友的ID了,而是目标群组的ID。 最后请注意:ChatMessageRecord实体与数据库中的ChatMessageRecord表是完全映射的关系,这才使得DataRabbit的ORM数据访问成为可能。 2.ChatRecordPage类 当我们请求聊天记录时,由于记录数量可能非常庞大,所以,采用分页是不可避免的。我们用ChatRecordPage来封装查询返回的一页聊天记录: 根据ChatRecordPage中的TotalCount字段,查询者可以知道符合条件的记录数是多少,如此,就可以知道总共有多少页。 3.IChatRecordPersister接口 无论是客户端还是服务端存储与查询聊天记录,我们都使用同一个接口IChatRecordPersister来进行抽象: public interface IChatRecordPersister { /// <summary> /// 插入一条聊天记录(包括群聊天记录)。 /// </summary> void InsertChatMessageRecord(ChatMessageRecord record); /// <summary> /// 获取一页与好友的聊天记录。 /// </summary> /// <param name="timeScope">日期范围</param> /// <param name="myID">自己的UserID</param> /// <param name="friendID">好友的ID</param> /// <param name="pageSize">页大小</param> /// <param name="pageIndex">页索引</param> /// <returns>聊天记录页</returns> ChatRecordPage GetChatRecordPage(DateTimeScope timeScope, string myID, string friendID, int pageSize, int pageIndex); /// <summary> /// 获取一页群聊天记录。 /// </summary> /// <param name="timeScope">日期范围</param> /// <param name="groupID">群ID</param> /// <param name="pageSize">页大小</param> /// <param name="pageIndex">页索引</param> /// <returns>聊天记录页</returns> ChatRecordPage GetGroupChatRecordPage(DateTimeScope timeScope, string groupID, int pageSize, int pageIndex); } (1)插入游戏记录时,与好友聊天记录以及群聊天记录使用同一个InsertChatMessageRecord方法即可,只是在构造ChatMessageRecord对象时,字段的赋值有所区别。 (2)使用DataRabbit实现该接口时(如ChatRecordPersister类),通过属性DataBaseType来控制访问的是否为Sqlite数据库。然后在服务端使用ChatRecordPersister存取聊天记录时,就将DataBaseType设置为SqlServer;客户端则设置为Sqlite。 三.可能的Remoting的接口 当我们从服务器加载聊天记录时,可以考虑使用Remoting技术来实现,如果是这样,只需要在服务端把IChatRecordPersister接口暴露为Remoting服务,然后客户端使用这一Remoting服务进行聊天记录查询。这样一来,客户端在切换从本地加载和从服务器加载时,只需要切换IChatRecordPersister为本地ChatRecordPersister对象的引用或remoting远程引用即可。整个的代码实现将会非常简洁一致。 到这里,关于聊天记录模块的设计与实现就介绍得差不多了,依照这样的思路,大家在自己的IM系统中增加聊天记录的功能应该是很简单的了。最后,上一张OrayTalk客户端查询聊天记录界面的截图: 就到这里了,还有疑问的朋友,请给我留言,我会及时回复的。
前段时间在开发OrayTalk(傲瑞通)的聊天记录模块时用到了Sqlite,这是我第一次接触和使用Sqlite,总体感觉还是非常不错的。这里把我使用Sqlite的经验跟大家分享一下。 一.关于Sqlite Sqlite是一款开源的、适合在客户端和嵌入式设备中使用的轻量级数据库,支持标准的SQL。 不像SqlServer或Oracle的引擎是一个独立的进程、通过TCP或命名管道等与程序进行通信,SQLite却是作为程序的一个部件、一个构成部分,使用Sqlite的方式就是直接在程序中进行API调用。 原始的Sqlite是没有一个向SqlServer企业管理器的可视化操作程序的,但是有个第三方开发的应用SqliteStudio非常不错,基本的建库、建表、编辑数据、导出数据等功能都支持得很好。SqliteStudio运行截图如下所示: Sqlite资源链接: (1)Sqlite官网:可以从官网下载源码、或下载已经编译好的二进制版本。支持的系统包括:Linux、MacOS、Windows、.NET。 (2)SqliteStudio:好用的Sqlite可视化管理器。 二.在.NET中使用Sqlite 从官网下载.NET版本的Sqlite,其主要包括两个dll:SQLite.Interop.dll、System.Data.SQLite.dll。 (1)System.Data.SQLite.dll是一个标准的托管dll,我们可以直接在.NET项目中引用并使用它,就像使用.NET自带的System.Data命名空间中的各个对象一样。 (2)SQLite.Interop.dll是一个非托管的dll,是Sqlite引擎核心,我们需要将其拷贝到运行目录下,在运行时,它会被System.Data.SQLite.dll调用。 三.让Sqlite脱离VC++运行时 我们在项目开发完毕后测试的过程中发现,使用了Sqlite的客户端程序在某些机器上运行时会报错,如下所示: 无法加载 DLL"SQLite.Interop.DLL";由于应用程序配置不正确,应用程序未能启动。重新安装应用程序可能会纠正这个问题。(异常来自 HRESULT:0x800736B1) 经过一番折腾,才发现是这些机器上没有安装VC++运行时(Visual C++ 2005 SP1 runtime),而SQLite.Interop.dll的运行是需要VC++运行时支持的。这点太不友好了。我们的项目是基于.NET 2.0开发的,windows xp sp1 及以上版本都自带了这个Framework,而这些机器不一定安装了VC++运行时。所以我第一反应就是,尝试让Sqlite在没有安装VC++运行时的机器上也能正常运行。 1.方案一 我baidu了一下,有个似乎可行的方案是这样的:将msvcm80.dll、msvcp80.dll、msvcr80.dll这几个动态库也放到运行目录下。这个方案我不太喜欢,于是我尝试自己动手解决问题。 2.方案二 凭借我还未完全忘记的一点VC++基础,我知道VC++程序在编译时可以选择是动态链接到依赖的库还是静态链接,如果是静态连接,编译生成的二进制程序中就相当于包含了一份依赖库的拷贝。所以,我的想法是,重新编译SQLite.Interop.dll,使其静态链接到VC++运行库。我下载了Sqlite的源码,用VS2010打开,截图如下: SQLite.Interop.2010这个项目是核心,我们需要对它的一些设置稍微做些修改,这些小修改我花了一些时间摸索才成功,这里就略去具体的摸索过程,直接给出摸索成果: (1)打开SQLite.Interop.2010项目属性页面,配置属性 -> C/C++ -> 代码生成 -> 运行库,该项设置为 多线程调试 (/MTd)。 (2)继续 配置属性 -> 清单工具 -> 输入和输出 -> 嵌入清单,该项原来是“是”,改成“否”。 (3)显示所有项目文件,然后找到SQLite.Interop.2010.props文件,并打开。删掉其中的<INTEROP_MIXED_NAME>配置节点。 (4)从项目中移除“Resource Files”文件夹。 (5)重新编译项目,生成的SQLite.Interop.dll便是我们所需要的。 四.下载成果 除非特别需求,否则大家没有必要重复这一过程,我把生成的Sqlite二进制版本直接提供给大家下载使用。 能脱离VC++运行时运行的Sqlite (v1.0.93.0)
基于.NET开发分布式系统,经常用到Remoting技术。在测试驱动开发流行的今天,如果针对分布式系统中的每个Remoting接口的每个方法都要写详细的测试脚本,无疑非常浪费时间。所以,我想写一个能自动测试remoting接口的小工具InterfaceTester。而且,当分布式系统中的某个remoting接口出现bug时,该小工具可以提交需要模拟的数据,以便在调试remoting服务的环境中,快速定位和解决bug。 InterfaceTester运行起来后的效果如下图: 1.如何使用 (1)首先,填上要测试的并且是已经发布的Remoting服务的地址信息。 (2)选取要测试的remoting接口所在的程序集,一般是一个dll。选定程序集后,InterfaceTester会自动搜索该程序集中定义的所有接口,并将其绑定到“接口类型”的下拉列表。 (3)从 “接口类型”的下拉列表中选择要测试的接口。选定接口后,InterfaceTester会自动搜索该接口中定义的所有方法,并将其绑定到“目标方法”的下拉列表。 (4)从 “目标方法”的下拉列表中选择要测试的方法,InterfaceTester会根据该方法所要求的参数,自动生成参数录入界面。 (5)在参数录入界面上,输入用于测试的参数的值,然后,点击“调用”按钮, InterfaceTester便会调用上述指定地址的remtoing服务的指定接口的指定方法,如果调用的方法有返回值,则会在“调用返回”的panel上显示该值。如果返回的不是一个简单类型,而是一个对象,则“调用返回”的panel上将会以xml的形式显示这个对象的各个属性值。 2.实现原理 就这个小工具的实现而言,主要用到的技术就是反射(reflection)。另外,需要注意的就是,根据参数的类型,生成录入界面。具体细节大家可以参见源码。目前,InterfaceTester支持的被测试方法的参数类型是有限制的: (1)支持简单的数据类型,像string、int、bool等。 (2)支持List<>、I List<>、IDictionary<,>、Dictionary<,>等集合类型。 (3)支持简单的数据结构的class(如像Point、自定义的Entity等)。 3.源码解决方案 下载源码并用VS打开后,解决方案下有三个项目:InterfaceTester、DemoInterface、DemoService。 (1)InterfaceTester项目是我们本文的主角:用于remoting接口测试的小工具。 (2)DemoInterface和 DemoService则是为了试试小工具而构建的一个小demo。 DemoInterface定义了发布的remoting服务的接口, DemoService则是发布的remoting服务。 在试用时,先启动 DemoService项目,再启动InterfaceTester,就可以试试我们的小工具功能了。 4.源码下载 对于这个remoting接口测试小工具,大家如果有什么好的建议,请留言告诉我:)
有些OMCS用户在他的系统使用了特殊的视频采集卡作为视频源(如AV-878采集卡),虽然这些采集卡可以虚拟为一个摄像头,但有些视频采集卡需要依赖于自带了sdk才能正常地完成视频采集工作。在这种情况下,OMCS是不直接支持这些采集卡的。我们的思路是使OMCS具有自定义扩展的能力:我们让OMCS提供了扩展接口,让使用者可以向OMCS框架中注入其自己的视频采集程序。使用者要达到这种自定义的扩展相当简单,只需实现两个接口即可。 1.IVideoCapturer接口 OMCS.Engine.Video.IVideoCapturer定义了视频采集器的基本功能,其用于采集RGB24格式的图像,其定义如下: public interface IVideoCapturer :IDisposable { /// <summary> /// 要采集的视频大小(分辨率) /// </summary> Size VideoSize { get; } /// <summary> /// 采集的帧频 /// </summary> int FrameRate { get; } /// <summary> /// 是否正在采集? /// </summary> bool IsCapturing { get; } /// <summary> /// 开始采集 /// </summary> void Start(); /// <summary> /// 停止采集 /// </summary> void Stop(); /// <summary> /// 当采集完一帧时,触发此事件。事件参数为图像数据。 /// </summary> event CbGeneric<byte[]> VideoCaptured; /// <summary> /// 当采集发生错误时,触发此事件。 /// </summary> event CbGeneric<Exception> VideoError; /// <summary> /// 当采集的分辨率发生变化时,触发此事件。 /// </summary> event CbGeneric<Size> VideoSizeChanged; } (1)在定义实现该接口的类时,可以通过类的构造函数传入三个参数:设备的Index、采集的分辨率、采集的视频帧率。 (2)OMCS会在合适的时候调用Start方法启动注入的采集器,采集器启动后,当每采集到一帧视频时,就触发VideoCaptured事件。OMCS内部预定了该事件,以获取采集到的图像数据。 (3)如果在采集的过程中,采集器发生了任何异常,请通过触发VideoError事件来通知OMCS框架。 (4)如果在采集的过程中,更改了采集器采集的分辨率,请通过触发VideoSizeChanged事件来通知OMCS。 请特别注意VideoCaptured事件参数的含义:它并不是一个Bitmap的完整数据,而是不包含位图header的核心数据(OMCS通过设定的采集的分辨率来推断位图header的信息)。从Bitmap转为不包含头的核心数据的代码如下所示: public byte[] GetBitmapCoreData(Bitmap bm) { int buffSize =bm.Width * bm.Height * 24 / 8; byte[] destBuff = new byte[buffSize]; Rectangle bmRect = new Rectangle(new Point(0, 0), new Size(bm.Width, bm.Height)); BitmapData data = bm.LockBits(bmRect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); Marshal.Copy(data.Scan0, destBuff, 0, destBuff.Length); bm.UnlockBits(data); return destBuff; } 当然,如果视频采集器采集到的本来就是位图核心数据(通常情况下都是如此),就不需要这层转换了。 2.IVideoCapturerFactory接口 在实现完IVideoCapturer接口后,我们还需要实现简单的OMCS.Passive.IVideoCapturerFactory接口。 public interface IVideoCapturerFactory { /// <summary> /// 创建一个新的视频采集器实例。 /// 如果返回null,则表示使用框架内置的视频采集器。 /// </summary> /// <param name="deviceIndex">摄像头索引</param> /// <param name="videoSize">要采集的视频大小(分辨率)</param> /// <param name="frameRate">采集的帧频</param> IVideoCapturer CreateVideoCapturer(int deviceIndex, Size videoSize, int frameRate); /// <summary> /// 获取视频采集器支持的采集分辨率。 /// 如果返回null,则表示指示框架自己去获取这些信息。 /// </summary> /// <param name="deviceIndex">摄像头索引</param> List<CameraCapability> GetCameraCapability(int deviceIndex); } 可以按如下逻辑来实现IVideoCapturerFactory的两个方法: (1)实现CreateVideoCapturer方法:判断deviceIndex所对应的视频设备是否是特殊的类型,如果是,则new一个我们刚实现的视频采集类的实例返回;如果不是,则返回null,表示使用框架内置的视频采集程序。 (2)实现GetCameraCapability方法:判断deviceIndex所对应的视频设备是否是特殊的类型,如果是,则把该设备支持的所有分辨率放入列表中返回;如果不是,则返回null,以指示框架自己获取目标设备的分辨率信息。 3.注入到OMCS 在调用IMultimediaManager的Initialize方法之前,先new一个上面实现的Factory类,然后将其赋值给IMultimediaManager的VideoCapturerFactory属性。这样,就完成对OMCS视频设备的扩展。
以前写过两篇录音和录像的文章(实现语音视频录制、在服务器端录制语音视频),最近有朋友问,如果要实现屏幕录制这样的功能,该怎么做了?实际上录屏的原理跟录音、录像是差不多的,如果了解了我前面两篇文章中介绍的内容,只要在它们的基础上做一些修改就可以了。 一.录屏原理 录制屏幕的实现方案仍然基于OMCS+MFile构建,原理与实现语音视频录制差不多,我这里只列出其中的主要差异: (1)使用DynamicDesktopConnector连接到屏幕桌面。 (2)使用定时器(比如10fps,则每隔100ms一次)定时调用DynamicDesktopConnector的GetCurrentImage方法,把得到的图像使用MFile写入视频文件。 (3)源码演示的是不需要同时录制麦克风的声音,所以使用了MFile提供的SilenceVideoFileMaker组件(而非原来的VideoFileMaker组件),仅仅录制视频数据。 (4)通过MultimediaManager的DesktopEncodeQuality属性,控制屏幕图像的清晰度。 二.录屏源码 源码如下所示,如果不想下载源码,可以直接通过下面的代码了解详细的实现思路。 public partial class Form1 : Form { private MultimediaServer server; //在本地内嵌OMCS服务器 private IMultimediaManager multimediaManager; private SilenceVideoFileMaker maker = new SilenceVideoFileMaker(); //录制无声视频 private DynamicDesktopConnector dynamicDesktopConnector = new DynamicDesktopConnector(); //远程桌面连接器 public Form1() { InitializeComponent(); int port = 9900; OMCSConfiguration config = new OMCSConfiguration(10,8, EncodingQuality.High,16000,640,480,"") ; this.server = new MultimediaServer(port, new DefaultUserVerifier(), config, false, null); this.multimediaManager = MultimediaManagerFactory.GetSingleton(); this.multimediaManager.DesktopEncodeQuality = 1; //通过此参数控制清晰度 this.multimediaManager.Initialize("aa01", "", "127.0.0.1", port); this.dynamicDesktopConnector.ConnectEnded += new ESBasic.CbGeneric<ConnectResult>(dynamicDesktopConnector_ConnectEnded); this.dynamicDesktopConnector.BeginConnect("aa01"); //连接本地桌面 this.Cursor = Cursors.WaitCursor; } void dynamicDesktopConnector_ConnectEnded(ConnectResult obj) { System.Threading.Thread.Sleep(500); this.Ready(); } private void Ready() { if (this.InvokeRequired) { this.BeginInvoke(new CbGeneric(this.Ready)); } else { this.Cursor = Cursors.Default; this.button1.Enabled = true; this.label1.Visible = false; } } private System.Threading.Timer timer; private void button1_Click(object sender, EventArgs e) { try { Oraycn.MFile.GlobalUtil.SetAuthorizedUser("FreeUser", ""); //初始化H264视频文件 this.maker.Initialize("test.mp4", VideoCodecType.H264, this.dynamicDesktopConnector.DesktopSize.Width, this.dynamicDesktopConnector.DesktopSize.Height, 10); this.timer = new System.Threading.Timer(new System.Threading.TimerCallback(this.Callback), null ,0, 100); this.label1.Text = "正在录制......"; this.label1.Visible = true; this.button1.Enabled = false; this.button2.Enabled = true; } catch (Exception ee) { MessageBox.Show(ee.Message); } } //定时获取屏幕图像,并使用MFile写入视频文件 private void Callback(object state) { Bitmap bm = this.dynamicDesktopConnector.GetCurrentImage(); this.maker.AddVideoFrame(bm); } private void button2_Click(object sender, EventArgs e) { this.timer.Dispose(); this.button1.Enabled = false; this.button2.Enabled = false; this.label1.Visible = false; this.maker.Close(true); MessageBox.Show("生成视频文件成功!"); } } 三.源码开源下载 2015.01.06 现在更好的方案是 MCapture + MFile,将声卡/麦克风/摄像头/屏幕的采集与录制集中在一个源码中,截图运行如下: 2014.11.26 现在录制本地的语音、视频、屏幕的最好的方案是MCapture + MFile,而不是通过OMCS绕一大圈,相应的源码源码下载:Oraycn.Record源码.rar 。 当然,如果是远程录制语音、视频、屏幕,最好的方案是OMCS + MFile。 2015.6.18 整理全部相关开源源码如下: (声卡/麦克风/摄像头/屏幕)采集&录制源码源码:WinForm版本 、WPF版本。 声卡录制源码、 混音&录制源码、 同时录制(桌面+麦克风+声卡)源码、 麦克风摄像头录制(可预览) 录制画中画(桌面+摄像头+麦克风/声卡)。 远程录制或在服务器端录制语音视频屏幕 版权声明:本文为博主原创文章,未经博主允许不得转载。
在我以前的一篇博文《实现语音视频录制(demo源码)》中,详细介绍了在网络视频聊天系统中的客户端如何实现语音视频的录制,而近段时间了,有几个朋友问起,如果想在服务端实现录制功能,该怎么做了?其中有个朋友的需求是这样的:他的系统是一个在线培训系统,需要在服务端将指定老师的讲课(包括语音和视频)录制下来,并保存为.mp4文件,以便随时可以查阅这些文件。 本文我们就做一个demo实现类似的功能,演示如何在服务端录制某个指定在线用户的语音视频,并提供三种录制模式:录制语音视频、仅录制语音、仅录制视频。 一.实现原理 要实现这个demo,需涉及到以下几个技术: (1)在服务端采集指定用户的语音、视频数据。 (2)在服务端将图像使用H264编码,语音数据使用AAC编码。 (3)将编码后的数据按MP4格式的要求,保存为MP4文件。 同实现语音视频录制(demo源码)一样,我们仍然基于OMCS和MFile来实现上述功能,下面是对应的原理。 (1)在OMCS的结构中,客户端之间可以相互获取到对方的摄像头和麦克风的数据,所以,服务端可以作为一个虚拟的客户端用户(比如ID为“_Server”),连接到同一个进程中的OMCS多媒体服务器。 (2)在服务端动态创建DynamicCameraConnector组件,连接到指定用户的摄像头。 (3)在服务端动态创建两个MicrophoneConnector组件,接到指定用户的麦克风。 (4)调用DynamicCameraConnector的GetCurrentImage方法,即可获得所连接的摄像头采集的视频帧。 (5)预定MicrophoneConnector的AudioDataReceived事件,即可获得所连接的麦克风采集的音频数据。 (6)使用MFile将上述结果进行编码并写入mp4文件。 二.实现代码 public partial class RecordForm : Form { private MultimediaServer multimediaServer; private OMCS.Passive.Audio.MicrophoneConnector microphoneConnector; private OMCS.Passive.Video.DynamicCameraConnector dynamicCameraConnector; private IMultimediaManager multimediaManager; private BaseMaker maker; private System.Threading.Timer videoTimer; private RecordMode recordMode = RecordMode.AudioAndVideo; public RecordForm(MultimediaServer server) { InitializeComponent(); this.comboBox_mode.SelectedIndex = 0; this.multimediaServer = server; this.label_port.Text = this.multimediaServer.Port.ToString(); //将服务端虚拟为一个OMCS客户端,并连接上OMCS服务器。 this.multimediaManager = MultimediaManagerFactory.GetSingleton(); this.multimediaManager.Initialize("_server", "", "127.0.0.1", this.multimediaServer.Port);//服务端以虚拟用户登录 } //在线用户列表 private void comboBox1_DropDown(object sender, EventArgs e) { List<string> list = this.multimediaServer.GetOnlineUserList(); list.Remove("_server"); //将虚拟用户排除在外 this.comboBox1.DataSource = list; } //开始录制视频 private void button1_Click(object sender, EventArgs e) { if (this.comboBox1.SelectedItem == null) { MessageBox.Show("没有选中目标用户!"); return; } string destUserID = this.comboBox1.SelectedItem.ToString(); this.recordMode = (RecordMode)this.comboBox_mode.SelectedIndex; //摄像头连接器 if (this.recordMode != RecordMode.JustAudio) { this.dynamicCameraConnector = new Passive.Video.DynamicCameraConnector(); this.dynamicCameraConnector.MaxIdleSpan4BlackScreen = 0; this.dynamicCameraConnector.ConnectEnded += new ESBasic.CbGeneric<ConnectResult>(cameraConnector1_ConnectEnded); this.dynamicCameraConnector.BeginConnect(destUserID); } //麦克风连接器 if (this.recordMode != RecordMode.JustVideo) { this.microphoneConnector = new Passive.Audio.MicrophoneConnector(); this.microphoneConnector.Mute = true; //在服务器上不播放出正在录制的声音 this.microphoneConnector.ConnectEnded += new ESBasic.CbGeneric<ConnectResult>(microphoneConnector1_ConnectEnded); this.microphoneConnector.AudioDataReceived += new CbGeneric<List<byte[]>>(microphoneConnector_AudioDataReceived); this.microphoneConnector.BeginConnect(destUserID); } this.label1.Text = string.Format("正在连接{0}的设备......" ,destUserID); this.Cursor = Cursors.WaitCursor; this.button1.Enabled = false; this.comboBox1.Enabled = false; this.comboBox_mode.Enabled = false; } //录制接收到的语音数据 void microphoneConnector_AudioDataReceived(List<byte[]> dataList) { if (this.maker != null) { foreach (byte[] audio in dataList) { if (this.recordMode == RecordMode.AudioAndVideo) { ((VideoFileMaker)this.maker).AddAudioFrame(audio); } else if (this.recordMode == RecordMode.JustAudio) { ((AudioFileMaker)this.maker).AddAudioFrame(audio); } else { } } } } void microphoneConnector1_ConnectEnded(ConnectResult obj) { this.ConnectComplete(); } void cameraConnector1_ConnectEnded(ConnectResult obj) { this.ConnectComplete(); } private int connectCompleteCount = 0; private void ConnectComplete() { ++this.connectCompleteCount; if (this.recordMode == RecordMode.AudioAndVideo) { if (this.connectCompleteCount == 2)//当语音、视频 都连接完成后,才正式启动录制。 { System.Threading.Thread.Sleep(500); this.Ready(); } } else { System.Threading.Thread.Sleep(500); this.Ready(); } } //初始化用于录制的FileMaker private void Ready() { if (this.InvokeRequired) { this.BeginInvoke(new CbGeneric(this.Ready)); } else { try { this.Cursor = Cursors.Default; if (this.recordMode == RecordMode.AudioAndVideo) { this.maker = new VideoFileMaker(); ((VideoFileMaker)this.maker).Initialize(this.dynamicCameraConnector.OwnerID + ".mp4", VideoCodecType.H264, this.dynamicCameraConnector.VideoSize.Width, this.dynamicCameraConnector.VideoSize.Height, 10, AudioCodecType.AAC, 16000, 1, true); this.videoTimer = new System.Threading.Timer(new System.Threading.TimerCallback(this.Callback), null, 0, 100); } else if (this.recordMode == RecordMode.JustAudio) { this.maker = new AudioFileMaker(); ((AudioFileMaker)this.maker).Initialize(this.microphoneConnector.OwnerID + ".mp3", AudioCodecType.MP3, 16000, 1); } else { this.maker = new SilenceVideoFileMaker(); ((SilenceVideoFileMaker)this.maker).Initialize(this.dynamicCameraConnector.OwnerID + ".mp4", VideoCodecType.H264, this.dynamicCameraConnector.VideoSize.Width, this.dynamicCameraConnector.VideoSize.Height, 10); this.videoTimer = new System.Threading.Timer(new System.Threading.TimerCallback(this.Callback), null, 0, 100); } this.label1.Text = "正在录制......"; this.label1.Visible = true; this.button1.Enabled = false; this.button2.Enabled = true; } catch (Exception ee) { MessageBox.Show(ee.Message); } } } private int callBackCount = -1; //定时获取视频帧,并录制 private void Callback(object state) { if (this.maker != null) { Bitmap bm = this.dynamicCameraConnector.GetCurrentImage(); if (bm != null) { ++this.callBackCount; if (this.recordMode == RecordMode.AudioAndVideo) { ((VideoFileMaker)this.maker).AddVideoFrame(bm); } else if (this.recordMode == RecordMode.JustVideo) { ((SilenceVideoFileMaker)this.maker).AddVideoFrame(bm); } else { } } else { } } } //停止录制 private void button2_Click(object sender, EventArgs e) { try { this.callBackCount = -1; if (this.videoTimer != null) { this.videoTimer.Dispose(); this.videoTimer = null; } this.connectCompleteCount = 0; if (this.recordMode != RecordMode.JustAudio) { this.dynamicCameraConnector.Disconnect(); this.dynamicCameraConnector = null; } if (this.recordMode != RecordMode.JustVideo) { this.microphoneConnector.Disconnect(); this.microphoneConnector = null; } this.button1.Enabled = true; this.button2.Enabled = false; this.label1.Visible = false; this.comboBox1.Enabled = true; this.comboBox_mode.Enabled = true; this.maker.Close(true); this.maker = null; MessageBox.Show("生成视频文件成功!"); } catch (Exception ee) { MessageBox.Show("生成视频文件失败!"+ ee.Message); } } } View Code 如果熟悉OMCS和MFile的使用,理解上面的代码是非常容易的,而且本文这个Demo就是在语音视频入门Demo的基础上改写而成的,只是有几点是需要注意: (1)由于在服务端录制时,不需要显示被录制用户的视频,所以不用设置DynamicCameraConnector的Viewer(即不用调用其SetViewer方法来设置绘制视频的面板)。 (2)同样,在服务端录制时,不需要播放被录制用户的语音,所以,将MicrophoneConnector的Mute属性设置为true即可。 (3)如果需要录制视频,则通过一个定时器(videoTimer)每隔100毫秒(即10fps)从DynamicCameraConnector采集一帧图片,并写入录制文件。 (4)如果录制的仅仅是图像视频(不包括音频),采用的视频编码仍然为H264,但生成的录制文件也是.mp4文件,而非.h264文件,否则,生成的视频文件将无法正常播放。 三.Demo下载 RecordOnServerDemo.rar 服务端运行起来的截图如下所示: 测试时,可按如下步骤: (1)启动demo的服务端。 (2)修改客户端配置文件中的服务器IP,然后,用不同的帐号在不同的机器上登录多个demo的客户端。 (3)在服务端界面上,选择一个在线的用户,点击“开始录制”按钮,即可进行录制。录制结束后,将在服务端的运行目录下,生成以用户ID为名称的mp3/mp4文件。 当然,在运行该demo时,仍然可以像语音视频入门Demo一样,两个客户端之间相互视频对话,而且同时,在服务端录制其中一个客户端的视频。 如你所想,我们可以将这个demo稍微做些改进,就可以支持在服务端同时录制多个用户的语音视频。 然而,就像本文开头所说的,本Demo所展示的功能非常适合在类似网络培训的系统中,用于录制老师的语音/视频。但如果是在视频聊天系统中,需要将聊天双方的语音视频录制到一个文件中,那么,就要复杂一些了,那需要涉及到图像拼接技术和混音技术了。我会在下篇文章中介绍另一个Demo,它就实现了这样的目的。
Windows CE 是微软早期推出的嵌入式设备和移动设备的开发运行平台,虽然目前移动端几乎都是android和ios的天下,但是,在嵌入式设备领域,Windows CE仍然占有一块地盘。很多用户希望让ESFramework通信框架和轻量级的通信引擎StriveEngine能支持Windows CE 客户端,基于这个需求,前几个月,我将ESFramework和StriveEngine的客户端引擎移植到了WinCE平台。 在WinCE平台下,可以使用.NET(Compact Framework),这是个好消息,但是移植的过程还是碰到了很多麻烦,特别是部署WinCE的开发环境就摸索了很久。 一.部署WinCE开发环境 1.安装VS2005以及SP1 更高的VS版本已经不直接支持WinCE了,VS2005对WinCE开发的支持应该说是最方便的。 2.安装Windows Embedded CE 6.0 这个比较坑爹,在MS官网居然找不到一个6.0的完整安装包,可能是因为太老了。转折N久之后,还是从这个地方批量下载一个个安装文件,才算凑成了一个完整的安装程序。 3.安装ActiveSync 4.5 ActiveSync在baidu搜索就很容易找到下载地址,下载后安装也还是很顺利的。 二.使用WinCE进行开发、调试 在上述的环境准备就绪后,我们就可以开始创建WinCE项目并进行调试了。 1.创建WinCE项目。 使用VS2005创建项目,在左侧菜单中,可以选择“智能设备->WinCE”,其是基于.NET 2.0 Compact Framework的。然后,就可以像开发普通的.NET应用一样来编写代码了。 2.开始调试 (1)启动ActiveSync。 并点击“文件”—>“连接设置”,在“允许连接到以下其中一个端口”下选择“DMA”。 (2)打开仿真设备管理器(VS2005->tools->仿真设备管理器)。 (3)让模拟器可以联网。 在仿真设备管理器界面上,可以看到仿真程序列表,在某个列表项上(比如 Pocket PC 2003 SE 仿真程序)右键->Connect,连接成功后,再右键->cradle。 如此,模拟器相当于与当前电脑位于同一个局域网,如果,服务端程序在当前电脑上运行,那么,模拟器上运行的客户端程序要连接的是当前电脑的局网IP地址,而不能是“127.0.0.1”。我们需要把模拟器当作一个独立的电脑设备。 (4)接下来,我们就可以在WinCE程序中设置断点,进行跟踪和调试了。 三.关于反射(Reflection)在WinCE上的运行 将ESFramework的某些功能移植到WinCE时,需要使用Reflection来动态获取和设置object的某个属性的值,就像这样: object val = type.InvokeMember(propertyName, BindingFlags.Default | BindingFlags.GetProperty, null, targetObj, null); 代码的编写和编译都是没有问题的,但是运行到这句时,会抛出NotSupportException。于是,我换了一个反射的方式: PropertyInfo pro = type.GetProperty(propertyName); object val = pro.GetValue(targetObj, null); 这样,居然就能正常运行了,这是个有点奇怪的事情。
对于一些基于TCP Socket的大型C/S应用来说,能进行跨服务器通信可能是一个绕不开的功能性需求。出现这种需求的场景类似于下面描述的这种情况。 假设,我们一台TCP应用服务器能同时承载10000人同时在线,而同时在线用户数量通常为5万多,那可想而知,我们需要部署6台TCP应用服务器来分担这些负载。再假设,我们的应用中,任意的两个客户端都有可能需要互发消息(比如,传送文件),这时问题就来了 -- 因为要互发消息的这两个客户端连接的可能是不同的服务器。 如何解决了?这就需要引入群集平台的概念。群集平台中有一个应用群集管理服务器ACMS可以将所有的TCP应用服务器管理起来,并且能在它们之间转发消息。这样,即使位于不同的TCP应用服务器上的客户端之间也可以相互发送消息了。结构模型简化后如下所示: 以上图为例,两个客户端Client01与Client02分别连上不同的应用服务器AS01和AS02,我们假设由于路由器的原因(比如两个路由器的NAT类型都是Symmetric),Client01与Client02之间的P2P通道没有建立成功。此时,如果Client01与Client02之间要相互沟通信息,那么信息就会经过ACMS中转。比如Client01要发信息给Client02,信息经过的路线将会是:Client01 => AS01 => ACMS => AS02 => Client02。 能简单地实现这种模型吗?并且让这种跨服务器通信对于客户端而言是透明的?当然,基于ESPlatform群集平台,我们很容易做到这一点。 本文我们就实现一个这样的demo。我们之前有个老的简单的IM的Demo,它演示了客户端与服务器、以及客户端与客户端之间的基本通信功能。只不过,在那个Demo中,相互通信的客户端连上的是同一个服务端。本文的Demo就是在那个老Demo的基础上来进行升级,使得位于不同服务器上的两个客户端之间也可以相互通信。 一.Demo项目结构 本Demo总共包含4个项目。 1.ESPlatform.ACMServer:这个是基于ESPlatform的应用群集服务器ACMS。 2.ESPlatform.SimpleDemo.Core:用于定义公共的信息类型、通信协议。 3.ESPlatform.SimpleDemo.Server:Demo的服务端。 4.ESPlatform.SimpleDemo.Client:Demo的客户端。 二.应用群集管理服务器ACMS 我们不需要对ACMS进行任何修改,只需要关注配置文件中TransferPort和Remoting端口的值。 <configuration> <appSettings> <!--应用群集中的服务器分配策略--> <add key="ServerAssignedPolicy" value="MinUserCount"/> <!--用于在AS之间转发消息的Port--> <add key="TransferPort" value="12000"/> </appSettings> <system.runtime.remoting> <application> <channels> <!--提供IPlatformCustomizeService和IClusterControlService Remoting服务的Port--> <channel ref="tcp" port="11000" > <serverProviders> <provider ref="wsdl" /> <formatter ref="soap" typeFilterLevel="Full" /> <formatter ref="binary" typeFilterLevel="Full" /> </serverProviders> <clientProviders> <formatter ref="binary" /> </clientProviders> </channel> </channels> </application> </system.runtime.remoting> </configuration> 三.Demo服务端 在升级老的Demo时,首先需要添加ESPlatform.dll的引用,然后,使用ESPlatform.dll程序集中的ESPlatform.Rapid.RapidServerEngine替代ESPlus.Rapid.RapidServerEngine,并在构造函数中指定:当前服务端实例的ID、ACMS的IP地址及其TransferPort和Remoting端口。 //使用简单的好友管理器,假设所有在线用户都是好友。(仅仅用于demo) ESPlatform.Server.DefaultFriendsManager friendManager = new ESPlatform.Server.DefaultFriendsManager(); this.engine = new ESPlatform.Rapid.RapidServerEngine(int.Parse(this.textBox_serverID.Text), this.textBox_acmsIP.Text, int.Parse(this.textBox_acmsPort.Text) ,int.Parse(this.textBox_transferPort.Text)); this.engine.FriendsManager = friendManager; this.engine.Initialize(int.Parse(this.textBox_serverPort.Text), new CustomizeHandler(), new BasicHandler()); friendManager.PlatformUserManager = this.engine.PlatformUserManager; 其它的部分与老Demo完全一致。 四.Demo客户端 相对于老的Demo而言,客户端的修改非常小,只是将配置文件中的服务器的IP和端口移到了登录界面上,这样方便指定要连接的服务端的地址。除此之外,没有其它变化,甚至,客户端的项目都不需要引用ESPlatform.dll。 五.运行Demo 1.启动应用群集管理服务器ACMS。 2.启动第一个服务端,ServerID指定为0,监听6000端口。 3.启动第二个服务端,ServerID指定为1,监听6001端口。 4.启动第一个客户端,连接ServerID为0的服务端。 5.启动第二个客户端,连接ServerID为1的服务端。 6.两个客户端之间可以相互对话了。 (在正式的应用场景中,ACMS、两个服务端、两个客户端 可以部署在不同的机器器上) 下图是Demo运行起来的效果: 题外话:从上图中可以看到,ACMS实时知道每台应用服务器的在线人数、CPU利用率、内存利用率等信息,基于这些信息,我们可以轻松实现简单的负载均衡的机制 -- 比如,党有一个新的客户端要登录时,我们可以指派它去连接那个在线人数最少的应用服务器,或者,CPU利用率最低的应用服务器。 六.Demo下载 群集、跨服务器通信Demo源码
前段时间,有几个研究ESFramework通信框架的朋友对我说,ESFramework有点庞大,对于他们目前的项目来说有点“杀鸡用牛刀”的意思,因为他们的项目不需要文件传送、不需要P2P、不存在好友关系、也不存在组广播、不需要服务器均衡、不需要跨服务器通信、甚至都不需要使用UserID,只要客户端能与服务端进行简单的稳定高效的通信就可以了。于是,他们建议我,整一个轻量级的C#通讯组件来满足类似他们这种项目的需求。我觉得这个建议是有道理的,于是,花了几天时间,我将ESFramework的内核抽离出来,经过修改封装后,形成了StriveEngine通讯组件,其最大的特点就是稳定高效、易于使用。 在网络上,交互的双方基于TCP或UDP进行通信,通信协议的格式通常分为两类:文本消息、二进制消息。 文本协议相对简单,通常使用一个特殊的标记符作为一个消息的结束。 二进制协议,通常是由消息头(Header)和消息体(Body)构成的,消息头的长度固定,而且,通过解析消息头,可以知道消息体的长度。如此,我们便可以从网络流中解析出一个个完整的二进制消息。 两种类型的协议格式各有优劣:文本协议直观、容易理解,但是在文本消息中很难嵌入二进制数据,比如嵌入一张图片;而二进制协议的优缺点刚刚相反。 在 轻量级通信引擎StriveEngine —— C/S通信demo(附源码)一文中,我们演示了如何使用了相对简单的文本协议,这篇文章我们将构建一个使用二进制消息进行通信的Demo。本Demo所做的事情是:客户端提交运算请求给服务端,服务端处理后,将结果返回给客户端。demo中定义消息头固定为8个字节:前四个字节为一个int,其值表示消息体的长度;后四个字节也是一个int,其值表示消息的类型。 1.StriveEngine通讯组件Demo简介 该Demo总共包括三个项目: (1)StriveEngine.BinaryDemoServer:基于StriveEngine开发的二进制通信服务端,处理来自客户端的请求并返回结果。 (2)StriveEngine.BinaryDemo:基于StriveEngine开发的二进制通信客户端,提交用户请求,并显示处理结果。 (3)StriveEngine.BinaryDemoCore:用于定义客户端和服务端都要用到的公共的消息类型和消息协议的基础程序集。 Demo运行起来后的截图如下所示: 2.消息头 首先,我们按照前面的约定,定义消息头MessageHead。 public class MessageHead { public const int HeadLength = 8; public MessageHead() { } public MessageHead(int bodyLen, int msgType) { this.bodyLength = bodyLen; this.messageType = msgType; } private int bodyLength; /// <summary> /// 消息体长度 /// </summary> public int BodyLength { get { return bodyLength; } set { bodyLength = value; } } private int messageType; /// <summary> /// 消息类型 /// </summary> public int MessageType { get { return messageType; } set { messageType = value; } } public byte[] ToStream() { byte[] buff = new byte[MessageHead.HeadLength]; byte[] bodyLenBuff = BitConverter.GetBytes(this.bodyLength) ; byte[] msgTypeBuff = BitConverter.GetBytes(this.messageType) ; Buffer.BlockCopy(bodyLenBuff,0,buff,0,bodyLenBuff.Length) ; Buffer.BlockCopy(msgTypeBuff,0,buff,4,msgTypeBuff.Length) ; return buff; } } 消息头由两个int构成,正好是8个字节。而且在消息头的定义中增加了ToStream方法,用于将消息头序列化为字节数组。 通过ToStream方法,我们已经可以对消息转化为流(即所谓的序列化)的过程窥见一斑了,基本就是操作分配空间、设置偏移、拷贝字节等。 3.消息类型 根据业务需求,需要定义客户端与服务器之间通信消息的类型MessageType。 public static class MessageType { /// <summary> /// 加法请求 /// </summary> public const int Add = 0; /// <summary> /// 乘法请求 /// </summary public const int Multiple = 1; /// <summary> /// 运算结果回复 /// </summary public const int Result = 2; } 消息类型有两个请求类型,一个回复类型。请注意消息的方向,Add和Multiple类型的消息是由客户端发给服务器的,而Result类型的消息则是服务器发给客户端的。 4.消息体 一般的消息都由消息体(MessageBody),用于封装具体的业务数据。当然,也有些消息只有消息头,没有消息体的。比如,心跳消息,设计时,我们只需要使用一个消息类型来表示它是一个心跳就可以了,不需要使用消息体。 本demo中,三种类型的消息都需要消息体来封装业务数据,所以,demo中本应该定义了3个消息体,但demo中实际上只定义了两个:RequestContract、ResponseContract。这是因为Add和Multiple类型的消息公用的是同一个消息体RequestContract。 [Serializable] public class RequestContract { public RequestContract() { } public RequestContract(int num1, int num2) { this.number1 = num1; this.number2 = num2; } private int number1; /// <summary> /// 运算的第一个数。 /// </summary> public int Number1 { get { return number1; } set { number1 = value; } } private int number2; /// <summary> /// 运算的第二个数。 /// </summary> public int Number2 { get { return number2; } set { number2 = value; } } } [Serializable] public class ResponseContract { public ResponseContract() { } public ResponseContract(int num1, int num2 ,string opType,int res) { this.number1 = num1; this.number2 = num2; this.operationType = opType; this.result = res; } private int number1; /// <summary> /// 运算的第一个数。 /// </summary> public int Number1 { get { return number1; } set { number1 = value; } } private int number2; /// <summary> /// 运算的第二个数。 /// </summary> public int Number2 { get { return number2; } set { number2 = value; } } private string operationType; /// <summary> /// 运算类型。 /// </summary> public string OperationType { get { return operationType; } set { operationType = value; } } private int result; /// <summary> /// 运算结果。 /// </summary> public int Result { get { return result; } set { result = value; } } } 关于消息体的序列化,demo采用了.NET自带的序列化器的简单封装(即SerializeHelper类)。当然,如果客户端不是.NET平台,序列化器不一样,那就必须像消息头那样一个字段一个字段就构造消息体了。 5.StriveEngine通讯组件Demo服务端 关于StriveEngine使用的部分,在 轻量级通信引擎StriveEngine —— C/S通信demo(附源码)一文中已有说明,我们这里就不重复了。我们直接关注业务处理部分: void tcpServerEngine_MessageReceived(IPEndPoint client, byte[] bMsg) { //获取消息类型 int msgType = BitConverter.ToInt32(bMsg, 4);//消息类型是 从offset=4处开始 的一个整数 //解析消息体 RequestContract request = (RequestContract)SerializeHelper.DeserializeBytes(bMsg, MessageHead.HeadLength, bMsg.Length - MessageHead.HeadLength); int result = 0; string operationType = ""; if (msgType == MessageType.Add) { result = request.Number1 + request.Number2; operationType = "加法"; } else if (msgType == MessageType.Multiple) { result = request.Number1 * request.Number2; operationType = "乘法"; } else { operationType = "错误的操作类型"; } //显示请求 string record = string.Format("请求类型:{0},操作数1:{1},操作数2:{2}", operationType, request.Number1 , request.Number2); this.ShowClientMsg(client, record); //回复消息体 ResponseContract response = new ResponseContract(request.Number1, request.Number2, operationType, result); byte[] bReponse = SerializeHelper.SerializeObject(response); //回复消息头 MessageHead head = new MessageHead(bReponse.Length, MessageType.Result); byte[] bHead = head.ToStream(); //构建回复消息 byte[] resMessage = new byte[bHead.Length + bReponse.Length]; Buffer.BlockCopy(bHead, 0, resMessage, 0, bHead.Length); Buffer.BlockCopy(bReponse, 0, resMessage, bHead.Length, bReponse.Length); //发送回复消息 this.tcpServerEngine.PostMessageToClient(client, resMessage); } 其主要流程为: (1)解析消息头,获取消息类型和消息体的长度。 (2)根据消息类型,解析消息体,并构造协议对象。 (3)业务处理运算。(如 加法或乘法) (4)根据业务处理结果,构造回复消息。 (5)发送回复消息给客户端。 6.StriveEngine通讯组件Demo客户端 (1)提交请求 private void button1_Click(object sender, EventArgs e) { this.label_result.Text = "-"; int msgType = this.comboBox1.SelectedIndex == 0 ? MessageType.Add : MessageType.Multiple; //请求消息体 RequestContract contract = new RequestContract(int.Parse(this.textBox1.Text), int.Parse(this.textBox2.Text)); byte[] bBody = SerializeHelper.SerializeObject(contract); //消息头 MessageHead head = new MessageHead(bBody.Length,msgType) ; byte[] bHead = head.ToStream(); //构建请求消息 byte[] reqMessage = new byte[bHead.Length + bBody.Length]; Buffer.BlockCopy(bHead, 0, reqMessage, 0, bHead.Length); Buffer.BlockCopy(bBody, 0, reqMessage, bHead.Length, bBody.Length); //发送请求消息 this.tcpPassiveEngine.PostMessageToServer(reqMessage); } 其流程为:构造消息体、构造消息头、拼接为一个完整的消息、发送消息给服务器。 注意:必须将消息头和消息体拼接为一个完整的byte[],然后通过一次PostMessageToServer调用发送出去,而不能连续两次调用PostMessageToServer来分别发送消息头、再发送消息体,这在多线程的情况下,是非常有可能在消息头和消息体之间插入其它的消息的,如果这样的情况发生,那么,接收方就无法正确地解析消息了。 (2)显示处理结果 void tcpPassiveEngine_MessageReceived(System.Net.IPEndPoint serverIPE, byte[] bMsg) { //获取消息类型 int msgType = BitConverter.ToInt32(bMsg, 4);//消息类型是 从offset=4处开始 的一个整数 if (msgType != MessageType.Result) { return; } //解析消息体 ResponseContract response = (ResponseContract)SerializeHelper.DeserializeBytes(bMsg, MessageHead.HeadLength, bMsg.Length - MessageHead.HeadLength); string result = string.Format("{0}与{1}{2}的答案是 {3}" ,response.Number1,response.Number2,response.OperationType,response.Result); this.ShowResult(result); } 过程与服务端处理接收到的消息是类似的:从接收到的消息中解析出消息头、再根据消息类型解析出消息体,然后,将运算结果从消息体中取出并显示在UI上。 7.StriveEngine通讯组件Demo源码下载 二进制通信demo源码 附相关系列:文本协议通信demo源码及 说明文档 打通B/S与C/S通信demo源码与说明文档 另附:简单即时通讯Demo源码及说明 版权声明:本文为博主原创文章,未经博主允许不得转载。
使用 摄像头、麦克风、扬声器测试程序 一文中提到的技术,我们可以基本实现QQ的语音视频测试向导的功能了。但是,我觉得语音测试这块的体验还可以做得更好一点,就像QQ语音测试一样,实时显示麦克风采集到的声音的强度: 接下来,我们做个小demo,来实现类似的功能。先上demo运行起来的截图: (界面确实比较丑,我们这里的重点在于技术方面如何实现,如果你愿意花点时间,可以将其美化得跟QQ的那个一样漂亮^_^) 1.实现思路 实现这个小例子的主要思路如下: (1)使用OMCS采集和播放从麦克风的输入数据(PCM)。 (2)对采集到的数据进行傅立叶变换,变换的结果就可以反应声音的强度。 (3)使用ProgressBar控件来实时显示声音的强度信息。 2.具体实现 (1)傅立叶变换算法 public static class FourierTransformer { public static double[] FFTDb(double[] source) { int sourceLen = source.Length; int nu = (int)(Math.Log(sourceLen) / Math.Log(2)); int halfSourceLen = sourceLen / 2; int nu1 = nu - 1; double[] xre = new double[sourceLen]; double[] xim = new double[sourceLen]; double[] decibel = new double[halfSourceLen]; double tr, ti, p, arg, c, s; for (int i = 0; i < sourceLen; i++) { xre[i] = source[i]; xim[i] = 0.0f; } int k = 0; for (int l = 1; l <= nu; l++) { while (k < sourceLen) { for (int i = 1; i <= halfSourceLen; i++) { p = BitReverse(k >> nu1, nu); arg = 2 * (double)Math.PI * p / sourceLen; c = (double)Math.Cos(arg); s = (double)Math.Sin(arg); tr = xre[k + halfSourceLen] * c + xim[k + halfSourceLen] * s; ti = xim[k + halfSourceLen] * c - xre[k + halfSourceLen] * s; xre[k + halfSourceLen] = xre[k] - tr; xim[k + halfSourceLen] = xim[k] - ti; xre[k] += tr; xim[k] += ti; k++; } k += halfSourceLen; } k = 0; nu1--; halfSourceLen = halfSourceLen / 2; } k = 0; int r; while (k < sourceLen) { r = BitReverse(k, nu); if (r > k) { tr = xre[k]; ti = xim[k]; xre[k] = xre[r]; xim[k] = xim[r]; xre[r] = tr; xim[r] = ti; } k++; } for (int i = 0; i < sourceLen / 2; i++) { decibel[i] = 10.0 * Math.Log10((float)(Math.Sqrt((xre[i] * xre[i]) + (xim[i] * xim[i])))); } return decibel; } private static int BitReverse(int j, int nu) { int j2; int j1 = j; int k = 0; for (int i = 1; i <= nu; i++) { j2 = j1 / 2; k = 2 * k + j1 - 2 * j2; j1 = j2; } return k; } } 至于傅立叶变换与分贝有什么关系,网上有很多相关的资料,可以baidu一下。对有兴趣的童鞋,强烈推荐阅读这篇文章 -- 分贝是个什么东西?。 (2)初始化OMCS服务器、设备管理器、麦克风设备 //获取麦克风列表 IList<MicrophoneInformation> microphones = SoundDevice.GetMicrophones(); this.comboBox2.DataSource = microphones; if (microphones.Count > 0) { this.comboBox2.SelectedIndex = 0; } //初始化OMCS服务器 OMCSConfiguration configuration = new OMCSConfiguration(10, 1, EncodingQuality.High, 16000, 800, 600); this.multimediaServer = new MultimediaServer(9000, new DefaultUserVerifier(), configuration, false, null); this.multimediaManager.DeviceErrorOccurred += new CbGeneric<MultimediaDeviceType, string>(multimediaManager_DeviceErrorOccurred); this.multimediaManager.AudioCaptured += new CbGeneric<byte[]>(multimediaManager_AudioCaptured); this.microphoneConnector1.ConnectEnded += new CbGeneric<ConnectResult>(microphoneConnector1_ConnectEnded); (3)连接麦克风,开始采集 if (!SoundDevice.IsSoundCardInstalled()) { this.label_error.Visible = true; this.label_error.Text = "声卡没有安装"; } //初始化多媒体管理器 this.multimediaManager.MicrophoneDeviceIndex = this.comboBox2.SelectedIndex; this.multimediaManager.Initialize("tester", "", "127.0.0.1", 9000); //与OMCS服务器建立连接,并登录 //尝试连接麦克风 this.microphoneConnector1.BeginConnect("tester"); 首先,初始化本地多媒体设备管理器,然后使用麦克风连接器连接到当前登录用户“tester”(即“自己”)麦克风设备。如果连接成功,多媒体管理器将会触发AudioCaptured事件,我们通过这个事件来截获音频数据。 (4)处理采集到的音频数据,并显示结果 void multimediaManager_AudioCaptured(byte[] data) { double[] wave = new double[data.Length / 2]; int h = 0; for (int i = 0; i < wave.Length; i += 2) { wave[h] = (double)BitConverter.ToInt16(data, i); //采样位数为16bit ++h; } double[] res = FourierTransformer.FFTDb(wave); double kk = 0; foreach (double dd in res) { kk += dd; } if (kk < 0) { kk = 0; } this.showResult(kk / res.Length); } private void showResult(double rs) { if (this.InvokeRequired) { this.BeginInvoke(new CbGeneric<double>(this.showResult), rs); } else { int rss = (int)(rs * 2); if (rss < 40) { rss = 40; } if (rss > 100) { rss = 100; } this.progressBar1.Value = rss; } } 注意:由于OMCS音频采样的位数为16bit,这样,一个单位的语音样本的字节数为2个字节。所以,傅立叶变换前,先要将原始的PCM数据(byte[])转为Int16的数组。 在显示分贝强度时,我偷了下懒,直接使用了ProgressBar控件,体验不是很好,勉强能表达出意思吧。 3.Demo程序 源码下载。
在开发类似语音视频聊天或视频会议这样的系统时,它们通常都包含一个测试音视频设备的功能 -- 通过该测试,用户可以选择要使用的音视频设备(对于程序内部而言,就是确定要使用设备的Index),就像QQ的语音测试向导和视频设置。这里,我介绍一下如何使用OMCS来实现类似的功能,只需少量代码即可搞定。先上测试程序运行起来后的截图: 如果声卡没有安装,或设备无效,会给出相应的提示,就像下面这样: 1.实现思路 (1)由于OMCS是基于网络的语音视频框架,是标准的C/S结构,所以必须要有服务端的存在。 (2)虽然OMCS服务端可以部署在有网络连接的任何地方,但是,为了方便起见,我们直接在测试程序中集成它(只需要new一个MultimediaServer对象就OK)。 (3)以随便一个ID(如“tester”)作为OMCS客户端用户,连接到集成的服务端。然后,使用OMCS提供的连接器连接自己的摄像头、麦克风,便可看到效果。 (4)程序启动时,我们可以使用OMCS工具类,来枚举所有的摄像头设备、麦克风设备、扬声器设备,并检测声卡是否安装。 2.具体实现 (1)初始化OMCS服务器 private MultimediaServer multimediaServer; ... OMCSConfiguration configuration = new OMCSConfiguration(10, 1, EncodingQuality.High, 16000, 800, 600); this.multimediaServer = new MultimediaServer(9000, new DefaultUserVerifier(), configuration, false, null); (2)枚举音视频设备 //获取摄像头列表 IList<CameraInformation> cameras = Camera.GetCameras(); this.comboBox1.DataSource = cameras; if (cameras.Count > 0) { this.comboBox1.SelectedIndex = 0; } //获取麦克风列表 IList<MicrophoneInformation> microphones = SoundDevice.GetMicrophones(); this.comboBox2.DataSource = microphones; if (microphones.Count > 0) { this.comboBox2.SelectedIndex = 0; } //获取扬声器列表 IList<SpeakerInformation> speakers = SoundDevice.GetSpeakers(); this.comboBox3.DataSource = speakers; if (speakers.Count > 0) { this.comboBox3.SelectedIndex = 0; } (3)点击开始按钮,测试设备 if (!SoundDevice.IsSoundCardInstalled()) { this.label_error3.Visible = true; this.label_error3.Text = "声卡没有安装"; } //初始化多媒体管理器 this.multimediaManager.CameraDeviceIndex = this.comboBox1.SelectedIndex; this.multimediaManager.MicrophoneDeviceIndex = this.comboBox2.SelectedIndex; this.multimediaManager.SpeakerIndex = this.comboBox3.SelectedIndex; this.multimediaManager.ChannelMode = ChannelMode.P2PDisabled; this.multimediaManager.CameraVideoSize = new System.Drawing.Size(320, 240); this.multimediaManager.Initialize("tester", "", "127.0.0.1", 9000); //与OMCS服务器建立连接,并登录 //尝试连接设备 this.cameraConnector1.BeginConnect("tester"); this.microphoneConnector1.BeginConnect("tester"); 根据用户选择的设备索引,设置设备管理器的CameraDeviceIndex、MicrophoneDeviceIndex、SpeakerIndex 属性,初始化管理器之后,使用连接器对象(cameraConnector1、microphoneConnector1)连接自己的摄像头和麦克风。 如果一切正常,窗口将会显示摄像头采集到的视频,扬声器将会播放麦克风采集到的声音。 3.测试程序 源码下载。
在Windows Server 2003 下安装好Unity3D,启动时报错--“Failed to initialize unity graphics.”,截图如下: 在网上搜了一下,说是要启用D3D加速,于是dxdiag打开DX诊断工具,发现D3D加速不可用: 继续google,有说可能是显卡没有装好,于是,将显卡驱动升级到最新版本。但是,问题依然没有解决。 经过一番折腾,终于找到解决方案: (1)在桌面空白处点击右键,进入属性-设置-高级-疑难解答,开启完全的硬件加速,这时会出现短暂的黑屏,然后恢复正常。 (2)接下来才能开启DirectX加速:开始-运行-dxdiag,在显示选项卡,把DirectDraw、Direct3D、AGP纹理加速都启用。 (3)开启声音加速:开始-运行-dxdiag,在声音选项卡,把“硬件的声音加速级别”拉到“完全加速”。 现在再次启动Unity3D,则可以正常启动了。
今年我们开始使用Unity3D开发MMORPG,脚本语言使用C#,这样我们就可以使用以往积累的许多类库。但是,在U3D中使用.NET dll的过程并不是那么顺利,比如我们今天遇到的这种问题。 一.问题出现 我们在当前的一个U3D项目中使用了StriveEngine作为通信组件与服务端进行通信,在U3D环境中,编译运行一切正常,但在打包发布(Build)为PC版本可执行文件时,却出现错误:“ArgumentException: The Assembly System.Management is referenced by StriveEngine. But the dll is not allowed to be included or could not be found.” 最初,我以为是签名或者是加密混淆的问题,于是我使用原始编译生成的StriveEngine.dll,问题一样存在。 接着,我再猜测可能是StriveEngine.dll编译时选择平台的问题,于是把目标平台由anycpu更改为x86,重新生成StriveEngine.dll,并且在u3d打包发布也选择x86,如下图所示: 但是,问题依然存在。 二.解决方案 经过一番折腾,终于发现需要设置一下U3D所使用的.NET版本 -- 点击Player Settings按钮,找到Api Compatibility Level选项,选择".Net 2.0",而非".Net 2.0 Subset",如下图所示: 这个选项的意思是说,要使用.NET 2.0的完整版本,而非其子集。经过此设置,终于可以打包发布成功。 究其原因,看来是因为StriveEngine所使用的是完整版本.NET 2.0。 三.又现困境 由于我们的游戏会打算发布一个轻量级的Web版本,于是,我们尝试将其打包发布为Web版,god,同样的问题又出现了,而且,在发布Web版本的情况下,Api Compatibility Level是不可选择的。 猜测发布Web版本只能使用.Net 2.0 Subset。 四.如何走出困境? 如果发布Web版本就只能使用.Net 2.0 Subset这个猜想是正确的,那么,我想基于.Net 2.0 Subset开发一个StriveEngine.U3D.dll,使其可以被打包发布到各种不通类型的平台。可是,.Net 2.0 Subset 具体指的是哪个子集了?是.NET Compact Framework?还是SilverLight提供的.NET Framework?抑或是其它?望知道的童鞋能留言告诉一下。
前段时间,有几个研究ESFramework网络通讯框架的朋友对我说,ESFramework有点庞大,对于他们目前的项目来说有点“杀鸡用牛刀”的意思,因为他们的项目不需要文件传送、不需要P2P、不存在好友关系、也不存在组广播、不需要服务器均衡、不需要跨服务器网络通讯、甚至都不需要使用UserID,只要一个客户端能与服务端进行简单的稳定高效的C#网络通信组件就可以了。于是,他们建议我,整一个轻量级的C#网络通信组件来满足类似他们这种项目的需求。我觉得这个建议是有道理的,于是,花了几天时间,我将ESFramework的内核抽离出来,经过修改封装后,形成了StriveEngineC#网络通信组件,其最大的特点就是稳定高效、易于使用。通过下面这个简单的demo,我们应该就能上手了。文末有demo源码下载,我们先上Demo截图: 1.StriveEngineC#网络通信组件Demo简介 该Demo总共包括三个项目: 1.StriveEngine.SimpleDemoServer:基于StriveEngine开发的服务端。 2.StriveEngine.SimpleDemoClient:基于StriveEngine开发的客户端。 3.StriveEngine.SimpleDemo:直接基于.NET的Socket开发的客户端,其目的是为了演示:在客户端不使用StriveEngine的情况下,如何与基于StriveEngine的服务端进行网络通讯。 StriveEngine 内置支持TCP/UDP、文本协议/二进制协议,该Demo我们使用TCP、文本格式的消息协议,消息的结束符为"\0"。 2.StriveEngineC#网络通信组件Demo服务端 private ITcpServerEngine tcpServerEngine; private void button1_Click(object sender, EventArgs e) { try { //初始化并启动服务端引擎(TCP、文本协议) this.tcpServerEngine = NetworkEngineFactory.CreateTextTcpServerEngine(int.Parse(this.textBox_port.Text), new DefaultTextContractHelper("\0")); this.tcpServerEngine.ClientCountChanged += new CbDelegate<int>(tcpServerEngine_ClientCountChanged); this.tcpServerEngine.ClientConnected += new CbDelegate<System.Net.IPEndPoint>(tcpServerEngine_ClientConnected); this.tcpServerEngine.ClientDisconnected += new CbDelegate<System.Net.IPEndPoint>(tcpServerEngine_ClientDisconnected); this.tcpServerEngine.MessageReceived += new CbDelegate<IPEndPoint, byte[]>(tcpServerEngine_MessageReceived); this.tcpServerEngine.Initialize(); this.button1.Enabled = false; this.textBox_port.ReadOnly = true; this.button2.Enabled = true; } catch (Exception ee) { MessageBox.Show(ee.Message); } } void tcpServerEngine_MessageReceived(IPEndPoint client, byte[] bMsg) { string msg = System.Text.Encoding.UTF8.GetString(bMsg); //消息使用UTF-8编码 msg = msg.Substring(0, msg.Length - 1); //将结束标记"\0"剔除 this.ShowClientMsg(client, msg); } void tcpServerEngine_ClientDisconnected(System.Net.IPEndPoint ipe) { string msg = string.Format("{0} 下线", ipe); this.ShowEvent(msg); } void tcpServerEngine_ClientConnected(System.Net.IPEndPoint ipe) { string msg = string.Format("{0} 上线" ,ipe); this.ShowEvent(msg); } void tcpServerEngine_ClientCountChanged(int count) { this.ShowConnectionCount(count); } private void ShowEvent(string msg) { if (this.InvokeRequired) { this.BeginInvoke(new CbDelegate<string>(this.ShowEvent), msg); } else { this.toolStripLabel_event.Text = msg; } } private void ShowClientMsg(IPEndPoint client, string msg) { if (this.InvokeRequired) { this.BeginInvoke(new CbDelegate<IPEndPoint,string>(this.ShowClientMsg),client, msg); } else { ListViewItem item = new ListViewItem(new string[] { DateTime.Now.ToString(), client.ToString(), msg }); this.listView1.Items.Insert(0, item); } } private void ShowConnectionCount(int clientCount) { if (this.InvokeRequired) { this.BeginInvoke(new CbDelegate<int>(this.ShowConnectionCount), clientCount); } else { this.toolStripLabel_clientCount.Text = "在线数量: " + clientCount.ToString(); } } private void comboBox1_DropDown(object sender, EventArgs e) { List<IPEndPoint> list = this.tcpServerEngine.GetClientList(); this.comboBox1.DataSource = list; } private void button2_Click(object sender, EventArgs e) { try { IPEndPoint client = (IPEndPoint)this.comboBox1.SelectedItem; if (client == null) { MessageBox.Show("没有选中任何在线客户端!"); return; } if (!this.tcpServerEngine.IsClientOnline(client)) { MessageBox.Show("目标客户端不在线!"); return; } string msg = this.textBox_msg.Text + "\0";// "\0" 表示一个消息的结尾 byte[] bMsg = System.Text.Encoding.UTF8.GetBytes(msg);//消息使用UTF-8编码 this.tcpServerEngine.SendMessageToClient(client, bMsg); } catch (Exception ee) { MessageBox.Show(ee.Message); } } 关于服务端引擎的使用,主要就以下几点: (1)首先调用NetworkEngineFactory的CreateTextTcpServerEngine方法创建引擎(服务端、TCP、Text协议)。 (2)根据需要,预定引擎实例的某些事件(如MessageReceived事件)。 (3)调用引擎实例的Initialize方法启动网络通讯引擎。 (4)调用服务端引擎的SendMessageToClient方法,发送消息给客户端。 3.StriveEngine C#网络通信组件Demo客户端 private ITcpPassiveEngine tcpPassiveEngine; private void button3_Click(object sender, EventArgs e) { try { //初始化并启动客户端引擎(TCP、文本协议) this.tcpPassiveEngine = NetworkEngineFactory.CreateTextTcpPassiveEngine(this.textBox_IP.Text, int.Parse(this.textBox_port.Text), new DefaultTextContractHelper("\0")); this.tcpPassiveEngine.MessageReceived += new CbDelegate<System.Net.IPEndPoint, byte[]>(tcpPassiveEngine_MessageReceived); this.tcpPassiveEngine.AutoReconnect = true;//启动掉线自动重连 this.tcpPassiveEngine.ConnectionInterrupted += new CbDelegate(tcpPassiveEngine_ConnectionInterrupted); this.tcpPassiveEngine.ConnectionRebuildSucceed += new CbDelegate(tcpPassiveEngine_ConnectionRebuildSucceed); this.tcpPassiveEngine.Initialize(); this.button2.Enabled = true; this.button3.Enabled = false; MessageBox.Show("连接成功!"); } catch (Exception ee) { MessageBox.Show(ee.Message); } } void tcpPassiveEngine_ConnectionRebuildSucceed() { if (this.InvokeRequired) { this.BeginInvoke(new CbDelegate(this.tcpPassiveEngine_ConnectionInterrupted)); } else { this.button2.Enabled = true; MessageBox.Show("重连成功。"); } } void tcpPassiveEngine_ConnectionInterrupted() { if (this.InvokeRequired) { this.BeginInvoke(new CbDelegate(this.tcpPassiveEngine_ConnectionInterrupted)); } else { this.button2.Enabled = false; MessageBox.Show("您已经掉线。"); } } void tcpPassiveEngine_MessageReceived(System.Net.IPEndPoint serverIPE, byte[] bMsg) { string msg = System.Text.Encoding.UTF8.GetString(bMsg); //消息使用UTF-8编码 msg = msg.Substring(0, msg.Length - 1); //将结束标记"\0"剔除 this.ShowMessage(msg); } private void ShowMessage(string msg) { if (this.InvokeRequired) { this.BeginInvoke(new CbDelegate<string>(this.ShowMessage), msg); } else { ListViewItem item = new ListViewItem(new string[] { DateTime.Now.ToString(), msg }); this.listView1.Items.Insert(0, item); } } private void button2_Click(object sender, EventArgs e) { string msg = this.textBox_msg.Text + "\0";// "\0" 表示一个消息的结尾 byte[] bMsg = System.Text.Encoding.UTF8.GetBytes(msg);//消息使用UTF-8编码 this.tcpPassiveEngine.SendMessageToServer(bMsg); } 关于客户端引擎的使用,与服务端类似: (1)首先调用NetworkEngineFactory的CreateTextTcpPassiveEngine方法创建引擎(客户端、TCP、Text协议)。 (2)根据需要,预定引擎实例的某些事件(如MessageReceived、ConnectionInterrupted 事件)。 (3)根据需要,设置引擎实例的某些属性(如AutoReconnect属性)。 (4)调用引擎实例的Initialize方法启动网络通讯引擎。 (5)调用客户端引擎的SendMessageToServer方法,发送消息给服务端。 4.基于Socket的客户端 这个客户端直接基于.NET的Socket进行开发,其目演示了:在客户端不使用StriveEngineC#网络通信组件的情况下(比如客户端是异构系统),如何与基于StriveEngine的服务端进行网络通信。该客户端只是粗糙地实现了基本目的,很多细节问题都被忽略,像粘包问题、消息重组、掉线检测等等。而这些问题在实际的应用中,是必需要处理的。(StriveEngineC#网络通信组件中的客户端和服务端引擎都内置解决了这些问题)。该客户端的代码就不贴了,大家可以在源码中看到。 5.StriveEngine C#网络通信组件Demo源码下载 文本协议网络通讯demo源码 附相关系列: C#网络通信组件二进制网络通讯demo源码及说明文档 C#网络通信组件打通B/S与C/S网络通讯demo源码与说明文档 另附:简单即时通讯Demo源码及说明 版权声明:本文为博主原创文章,未经博主允许不得转载。
当我们需要从一个字符串(主串)中寻找一个模式串(子串)时,使用KMP算法可以极大地提升效率。KMP是一个高效的字符串匹配算法,它巧妙的消除了在匹配的过程中指针回溯的问题,关于KMP算法的更多介绍,可以参考这里。 原始的KMP算法适用的对象是字符串的匹配搜索,其实针对任意类型的串(实际上就是一个数组)的子串搜索,都可以使用KMP算法。比如,我们可能需要在byte[]中查找一个特定的字节数组,这同样可以使用KMP算法来提升匹配性能。为此,我实现了泛型的KMP算法,使之可以应用于任意类型的串匹配。下面是该算法的完整实现。 /// <summary> /// 泛型KMP算法。 /// zhuweisky 2013.06.06 /// </summary> public static class GenericKMP { /// <summary> /// Next函数。 /// </summary> /// <param name="pattern">模式串</param> /// <returns>回溯函数</returns> public static int[] Next<T>(T[] pattern) where T : IEquatable<T> { int[] nextFunction = new int[pattern.Length]; nextFunction[0] = -1; if (pattern.Length < 2) { return nextFunction; } nextFunction[1] = 0; int computingIndex = 2; int tempIndex = 0; while (computingIndex < pattern.Length) { if (pattern[computingIndex - 1].Equals(pattern[tempIndex])) { nextFunction[computingIndex++] = ++tempIndex; } else { tempIndex = nextFunction[tempIndex]; if (tempIndex == -1) { nextFunction[computingIndex++] = ++tempIndex; } } } return nextFunction; } /// <summary> /// KMP计算 /// </summary> /// <param name="source">主串</param> /// <param name="pattern">模式串</param> /// <returns>匹配的第一个元素的索引。-1表示没有匹配</returns> public static int ExecuteKMP<T>(T[] source, T[] pattern) where T : IEquatable<T> { int[] next = Next(pattern); return ExecuteKMP(source, 0, source.Length, pattern, next); } /// <summary> /// KMP计算 /// </summary> /// <param name="source">主串</param> /// <param name="sourceOffset">主串起始偏移</param> /// <param name="sourceCount">被查找的主串的元素个数</param> /// <param name="pattern">模式串</param> /// <returns>匹配的第一个元素的索引。-1表示没有匹配</returns> public static int ExecuteKMP<T>(T[] source, int sourceOffset, int sourceCount, T[] pattern) where T : IEquatable<T> { int[] next = Next(pattern); return ExecuteKMP(source, sourceOffset, sourceCount, pattern, next); } /// <summary> /// KMP计算 /// </summary> /// <param name="source">主串</param> /// <param name="pattern">模式串</param> /// <param name="next">回溯函数</param> /// <returns>匹配的第一个元素的索引。-1表示没有匹配</returns> public static int ExecuteKMP<T>(T[] source, T[] pattern, int[] next) where T : IEquatable<T> { return ExecuteKMP(source, 0, source.Length, pattern, next); } /// <summary> /// KMP计算 /// </summary> /// <param name="source">主串</param> /// <param name="sourceOffset">主串起始偏移</param> /// <param name="sourceCount">被查找的主串的元素个数</param> /// <param name="pattern">模式串</param> /// <param name="next">回溯函数</param> /// <returns>匹配的第一个元素的索引。-1表示没有匹配</returns> public static int ExecuteKMP<T>(T[] source, int sourceOffset, int sourceCount, T[] pattern, int[] next) where T : IEquatable<T> { int sourceIndex = sourceOffset; int patternIndex = 0; while (patternIndex < pattern.Length && sourceIndex < sourceOffset + sourceCount) { if (source[sourceIndex].Equals(pattern[patternIndex])) { sourceIndex++; patternIndex++; } else { patternIndex = next[patternIndex]; if (patternIndex == -1) { sourceIndex++; patternIndex++; } } } return patternIndex < pattern.Length ? -1 : sourceIndex - patternIndex; } } 说明: (1)串中的每个元素必须能够被比较是否相等,所以,泛型T必须实现IEquatable接口。 (2)之所以将Next函数暴露为public,是为了在外部可以缓存回溯函数,以供多次使用。因为,我们可能经常会在不同的主串中搜索同一个模式串。 (3)如果要将GenericKMP应用于字符串的匹配搜索,可以先将字符串转换为字符数组,再调用GenericKMP算法。就像下面这样: string source = ".............."; string pattern = "*****"; int index = GenericKMP.ExecuteKMP<char>(source.ToCharArray(),pattern.ToCharArray()) ;
就现在经济大环境而言,很不乐观,程序员的日子也很不好过,无论是还在找工作的、还是已经入职多年、哪怕做到技术经理的,压力都异常巨大,似乎处处充满危机。我们不禁会问:程序员的出路在哪里?但是,仔细分析一下,出路还是有的,甚至解决温饱、过上有房有车没贷款的生活也是很可能的。首先,在如今这个浮躁的社会,大多数人的心态也是浮躁的,只要你能潜下心来,深入研究某个技术,有了一技之长,温饱问题肯定就可以先解决了。 程序员的出路之一:一技之长 新技术层出不穷,而内核的精髓的东西却变化不大,就像.NET,从VS2003到VS2012,已经有10个年头,VS的版本不断更新,而.NET内核的最新版本也才4.0,所以,作为程序员,我们要多掌握内核的东西,精髓的东西。 我们的学习积累毛病在于:贪多、贪全、而不够深入。对于很多技术,我们都很有兴趣,对于刚兴起的技术,也紧紧跟随。但是,几乎都是蜻蜓点水、一知半解。回头想想,我们似乎什么都会一点,什么类型的项目都可以做,B/S的、C/S的、数据库的、分布式的,等等,但是,却不敢说,在某某方面,我的水平已经超越了圈中同类型的80%的人。只是我能做的,大家都会做,而且,我也没有把握比别人做得更好。 必须要让自己有价值,而自己的价值在于不可替代性或是难以替代性。如果,随便找个程序员就能把你replace掉,你的价值就很低廉了。如果在你负责的某个方面,只有20%的人超越你,那你的价值、你的重要性就凸显出来了,你与雇主的关系就从被动转向了主动,你就有了谈判的筹码。 在专业化高度分工的今天,一技之长并不是说需要你掌握某个很大的方面,而只需要你能掌握其中的某一个小的领域,并不断地深入下去。就这个小的领域来说,你花个3、5年的时间挤进前20%是非常可能的。比如,有人专门研究SqlServer数据库优化、有人专攻TCP通信、有人深入研究IIS、有人深入钻研WCF,等等。 程序员的出路之二:打造自己的精品 当你在某个小领域钻研了3、5年后,你一定会有很多心得,积累了很多经验,其中有些经验是异常宝贵的,为什么了?因为在钻研这个领域一段时间后,会陆续碰到很多问题,而那些80%的人,在碰到某个问题时就停止向前了,在这个小领域的水平就到此为止了,而你却不断地解决这些问题,不断地超越那80%的人。 而且,很可能的一个情况是,作为几年钻研的一个副产品,你积累了一套类库或框架,而基于该类库或框架来开发该领域的项目,不仅开发速度更快,效率更高,而且项目的质量更有保证。然后,你可以把积累的这套类库/框架打造成一个精品,不断的打磨,直到某一天,可以让更多的人来用它。 当你在某一领域有了丰富的经验,或者有了自己的精品类库/框架之后,你便可以面向更广阔的市场。 程序员的出路之三:更广阔的市场 在公司做个小白领,你的生死荣禄几乎就完全掌握在你的上司手中,你不得不关注他,被他的情绪所左右,很可能因为他的一句批评,你就整夜难眠。你觉得自己做得很好,可是他不认可。但是现在,你不需要再过分的关注他,你可以将眼光转向更广阔的市场。 互联网时代的一个好处就是,任何人都可以以非常低廉的成本来向大众市场展示自己或自己的产品,评判你价值的不再(仅仅)是你上司,而是整个市场,相比于你的上司,市场的评判会更客观、更公正。你可以把自己的经验能力说明放到自己的博客上、写专业的技术文章来分享知识、顺便推广自己,或者把积累的框架放到网上去销售,或者去项目交易平台接那些与你精通的领域对口的项目,由于在这个领域你超越了80%的人,所以,成功接到项目的可能性是非常之大的。有了这些基础,以后就算是靠技术创业也是有可能的。 如果做到了这三点,那一个程序员就不愁出路了。 就我个人经历而言,我花了10年的时间积累了ESFramework通信框架和OMCS语音视频框架,单靠它们的收入,满足家庭的生活开销已经足够了。作为一个普通的程序员,我能够找到程序员的出路,我相信后来人也可以做到,甚至做得比我更好。祝福大家。 类似文章分享:程序员养生 -- 心态 版权声明:本文为博主原创文章,未经博主允许不得转载。
在广播与P2P通道(上) -- 问题与方案 一文中,我们已经找到了最优的模型,即将广播与P2P通道相结合的方案,这样能使服务器的带宽消耗降到最低,最大节省服务器的宽带支出。当然,如果从零开始实现这种方案无疑是非常艰巨的,但基于ESFramework提供的通信功能和P2P功能来做,就不再那么遥不可及了。 1.P2P通道状态 根据上文模型3的讨论,要实现该模型,每个客户端需要知道自己与哪些用户创建了P2P通道,服务器也要知道每个客户端已建立的P2P通道的状态。 使用ESFramework,在客户端已经可以通过IRapidPassiveEngine.P2PController接口知道当前客户端与哪些其它客户端成功建立了P2P通道,并且可以通过P2PController接口发起与新的客户端建立新的P2P通道的尝试。但在服务端,对于每个客户端建立了哪些P2P通道,服务端是一无所知的。所以,基于ESFramework实现模型3的第一件事情,就是客户端要实时把自己的P2P状态变化报告给服务端,而服务端也要管理每个客户端的P2P通道状态。(注意。下面的所有实现,需要引用ESFramework.dll、ESPlus.dll、ESBasic.dll) (1)P2PChannelManager 我们在服务端设计P2PChannelManager类来管理每个在线客户端已成功创建的所有P2P通道。 public class P2PChannelManager { //key 表示P2P通道的起始点用户ID,value 表示P2P通道的目的点用户列表。(单向,因为某些P2P通道就是单向的) private SortedArray<string, SortedArray<string>> channels = new SortedArray<string, SortedArray<string>>(); public void Initialize(IUserManager userManager) { userManager.SomeOneDisconnected += new ESBasic.CbGeneric<UserData, ESFramework.Server.DisconnectedType>(userManager_SomeOneDisconnected); } void userManager_SomeOneDisconnected(UserData user, ESFramework.Server.DisconnectedType obj2) { this.channels.RemoveByKey(user.UserID); } public void Register(string startUserID, string destUserID) { if (!this.channels.ContainsKey(startUserID)) { this.channels.Add(startUserID, new SortedArray<string>()); } this.channels[startUserID].Add(destUserID); } public void Unregister(string startUserID, string destUserID) { if (this.channels.ContainsKey(startUserID)) { this.channels[startUserID].Remove(destUserID); } } public bool IsP2PChannelExist(string startUserID, string destUserID) { if (!this.channels.ContainsKey(startUserID)) { return false; } return this.channels[startUserID].Contains(destUserID); } } P2PChannelManager提供了注册P2P通道、注销P2P通道、以及查询P2P通道是否存在的方法。其内部使用类似字典的SortedArray来管理每个用户的已经成功建立的P2P通道(即与哪些其它用户打通了P2P)。另外,P2PChannelManager预定了IUserManager的SomeOneDisconnected事件,这样,当某个用户掉线时,就可以清除其所有的P2P状态。因为,在ESFramework中,当客户端与服务器的TCP连接断开时,客户端会自动关闭所有的P2P通道。 (2)客户端实时报告自己的P2P状态变化给服务端 当客户端每次成功创建一个P2P通道、或者已有P2P通道中断时,客户端要发消息告诉服务端。这样,我们就需要定义这个消息的类型: public static class MyInfoTypes { public const int P2PChannelOpen = 1; public const int P2PChannelClose = 2; } 再定义消息协议: public class P2PChannelReportContract { public P2PChannelReportContract() { } public P2PChannelReportContract(string dest) { this.destUserID = dest; } #region DestUserID private string destUserID; public string DestUserID { get { return destUserID; } set { destUserID = value; } } #endregion } 定好了消息类型和contract类,我们在客户端预定P2P通道的状态变化,并报告给服务端: public void Initialize(IRapidPassiveEngine rapidPassiveEngine) { rapidPassiveEngine.P2PController.P2PChannelOpened += new CbGeneric<P2PChannelState>(P2PController_P2PChannelOpened); rapidPassiveEngine.P2PController.P2PChannelClosed += new CbGeneric<P2PChannelState>(P2PController_P2PChannelClosed); } void P2PController_P2PChannelClosed(P2PChannelState state) { this.P2PChannelReport(false, state.DestUserID); } void P2PController_P2PChannelOpened(P2PChannelState state) { this.P2PChannelReport(true, state.DestUserID); } private void P2PChannelReport(bool open, string destUserID) { P2PChannelReportContract contract = new P2PChannelReportContract(destUserID); int messageType = open ? MyInfoTypes.P2PChannelOpen : MyInfoTypes.P2PChannelClose; this.rapidPassiveEngine.CustomizeOutter.Send(messageType, CompactPropertySerializer.Default.Serialize(contract)); } 在服务端,我们需要处理这两种类型的消息(实现ICustomizeHandler接口的HandleInformation方法): private P2PChannelManager p2PChannelManager = new P2PChannelManager(); public void HandleInformation(string sourceUserID, int informationType, byte[] information) { if (informationType == MyInfoTypes.P2PChannelOpen) { P2PChannelReportContract contract = CompactPropertySerializer.Default.Deserialize<P2PChannelReportContract>(information, 0); this.p2PChannelManager.Register(sourceUserID, contract.DestUserID); return ; } if (informationType == MyInfoTypes.P2PChannelClose) { P2PChannelReportContract contract = CompactPropertySerializer.Default.Deserialize<P2PChannelReportContract>(information, 0); this.p2PChannelManager.Unregister(sourceUserID, contract.DestUserID); return ; } } 这样,服务端就实时地知道每个客户端的P2P状态了。 2.与广播结合 同样的,我们首先为广播消息定义一个消息类型: public static class MyInfoTypes { public const int P2PChannelOpen = 1; public const int P2PChannelClose = 2; public const int Broadcast = 3; //广播消息 } 再定义对应的协议类: public class BroadcastContract { #region Ctor public BroadcastContract() { } public BroadcastContract(string _broadcasterID, string _groupID, int infoType ,byte[] info ) { this.broadcasterID = _broadcasterID; this.groupID = _groupID; this.content = info; this.informationType = infoType; this.actionTypeOnChannelIsBusy = action; } #endregion #region BroadcasterID private string broadcasterID = null; /// <summary> /// 发出广播的用户ID。 /// </summary> public string BroadcasterID { get { return broadcasterID; } set { broadcasterID = value; } } #endregion #region GroupID private string groupID = ""; /// <summary> /// 接收广播的组ID /// </summary> public string GroupID { get { return groupID; } set { groupID = value; } } #endregion #region InformationType private int informationType = 0; /// <summary> /// 广播信息的类型。 /// </summary> public int InformationType { get { return informationType; } set { informationType = value; } } #endregion #region Content private byte[] content; public byte[] Content { get { return content; } set { content = value; } } #endregion } (1)在客户端发送广播消息 在客户端,我们根据与组内成员的P2P通道的状态,来判断发送的方案,就像依据上文提到的,可细分为三种情况: a.当某个客户端发现自己和组内的所有其它成员都建立了P2P通道时,那么,它就不用把广播消息发送给服务器了。 b.如果客户端与组内的所有其它成员的P2P通道都没有建立成功,那么,它只需要将广播消息发送给服务器。 c.如果客户端与部分组内的成员建立了P2P通道,那么,它不仅需要将广播消息发送给服务器,还需要将该广播消息经过每个P2P通道发送一次。 public void Broadcast(string currentUserID, string groupID, int broadcastType, byte[] broadcastContent) { BroadcastContract contract = new BroadcastContract(currentUserID, groupID, broadcastType, broadcastContent); byte[] info = CompactPropertySerializer.Default.Serialize(contract); List<string> members = this.groupManager.GetGroupMembers(groupID); if (members == null) { return; } bool allP2P = true; foreach (string memberID in members) { if (memberID == this.currentUserID) { continue; } if (rapidPassiveEngine.P2PController.IsP2PChannelExist(memberID)) { rapidPassiveEngine.CustomizeOutter.SendByP2PChannel(memberID, MyInfoTypes.Broadcast, info, ActionTypeOnNoP2PChannel.Discard, true, ActionTypeOnChannelIsBusy.Continue); } else { allP2P = false; } } if (!allP2P) //只要有一个组成员没有成功建立P2P,就要发给服务端。 { this.rapidPassiveEngine.CustomizeOutter.Send(null, this.groupInfoTypes.Broadcast, info, true, action); } } (2)服务端转发广播 当服务器收到一个广播消息时,首先,查看目标组中的用户,然后,根据广播消息的发送者的P2P通道状态,来综合决定该广播消息需要转发给哪些客户端。我们只需在上面的HandleInformation方法中增加代码就可以了: if (informationType == MyInfoTypes.Broadcast) { BroadcastContract contract = CompactPropertySerializer.Default.Deserialize<BroadcastContract>(information, 0); string groupID = contract.GroupID; List<string> members = this.groupManager.GetGroupMembers(groupID); if (members != null) { foreach (string memberID in members) { bool useP2PChannel = this.p2PChannelManager.IsP2PChannelExist(sourceUserID, memberID); if (memberID != sourceUserID && !useP2PChannel) { this.customizeController.Send(memberID, MyInfoTypes.Broadcast, information, true, ActionTypeOnChannelIsBusy.Continue); } } } return; } (3)客户端处理接收到的广播消息 客户端也只要实现ICustomizeHandler接口的HandleInformation方法,就可以处理来自P2P通道或者转发自服务端的广播消息了(即处理MyInfoTypes.Broadcast类型的消息),这里就不赘述了。 实际上,本文的实现还可以进一步优化,特别是在高频的广播消息时(如前文举的视频会议的例子),这种优化效果是很明显的。那就是,比如,我们在客户端可以将组内的成员分成两类管理起来,一类是P2P已经打通的,一类是没有通的,并根据实际的P2P状态变化而调整。这样,客户端每次发送广播消息时,就不用遍历自己与每个组员的P2P通道的状态,这可以节省不少的cpu时间。同理,服务端也可以如此处理。
我们设想一下网络视频会议的场景:在一个视频会议虚拟房间中,每个人都需要将自己的视频数据发送给房间中的其它人,从而实现在同一个地方进行实时会议的效果。为了简单起见,我们假设,这个虚拟的视频会议房间中只有三个人,其结构可以简化描绘如下: 客户端A需要将自己的视频数据发送给B和C,客户端B需要发给A和C,客户端C需要发给A和B。有了这个场景基础,接下来,我们将从数据传送通道的角度来分析在这个模型中可以采用的通道方式,以及进行对比并找出最优的通道模型。所谓最优,就是服务器所占用的带宽(包括上行和下行)最小,因为在现实的视频会议案例中,带宽是不可忽视的成本之一。为了下面能进行更具体的数据分析和比较,我们假设视频的大小为320*240,帧频为10fps,这样,一路视频流经过编码后,所需的码率大概为30KByte/s。 1.模型1:最简单的通道模型 对于上述场景,我们第一反应能想到的解决方案的要点: (1)所有的视频数据都经过服务器中转。 (2)以客户端A为例,对于采集到的每一帧视频,调用两次发送方法,一次发送给B,一次发送给C,都经过服务器中转。客户端B和C也是同理。 (3)服务器仅仅转发数据,不需加入任何其它的逻辑。 现在,我们计算一下服务器的带宽占用。 上行:30*2*3 = 180KByte/s。(每个客户端需要上传两份相同的视频流,3个客户端) 下行:30*2*3 = 180KByte/s。(每个客户端需要下载另外两个用户的视频流,3个客户端) 在这种模型下,上下行是完全对称的。这个模型有个很明显的缺陷:每个客户端采集的每一帧视频都需要上传两次。修正这个缺陷也很容易,在服务端引入广播功能。 2.模型2:在服务端进行广播 在第一个模型的基础上,我们在服务端增加广播功能,将视频帧的接收者的决定权由客户端转交给服务器: (1)客户端将采集的每一帧发送给服务器,服务器收到该帧后,根据当前会议的参与者,决定需要将视频帧转发给哪几个客户端。 (2)以客户端A为例,对于采集到的每一帧视频,只需调用一次发送给服务器的方法。 我们再来计算一下服务器的带宽占用。 上行:30*3 = 90KByte/s。(每个客户端上传自己的视频流,3个客户端) 下行:30*2*3 = 180KByte/s。(每个客户端需要下载另外两个用户的视频流,3个客户端) 在这种模型下,上下行不再对称了,上行的流量减小了一半,效果还是很明显的。 3.模型3:结合P2P通道 为了进一步减少服务器的带宽占用,我们还有一个杀手锏,那就是在模型2的基础上使用P2P通道。理想的情况下,A和B以及C相互之间的P2P通道都可以创建成功,这样,就没有任何数据需要经过服务器中转了,所以,服务器的上行和下行的带宽占用都是0。但是,现实中常见的情况很复杂。比如,假设客户端C的路由器的NAT是对称型的,那么,C和A以及C和B之间的P2P通道无法创建成功,但A和B之间的P2P是成功的。基于此假设,我们希望,A和B之间的数据经过P2P通道直接传送,A和C以及B和C之间的数据只有经过服务器转发: (1)A通过P2P通道将视频帧发送给B,并通过服务器中转给C。 (2)B通过P2P通道将视频帧发送给A,并通过服务器中转给C。 (3)C通过通过服务器将视频帧中转给A和B。 我们再来计算一下服务器的带宽占用。 上行:30*3 = 90KByte/s。(每个客户端都要上传自己的视频流,3个客户端) 下行:30 + 30 + 30*2 = 120KByte/s。(A需要下载C,B需要下载C,C需要下载A和B) 可见,由于P2P通道的存在,降低了下行带宽的占用量。 4.服务器广播与P2P通道结合深入分析 现在,我们将参与视频会议的人数扩展到N(N>=2),情况依然是复杂的,某些客户端之间的P2P通道成功建立,而某些客户端之间的P2P通道无法创建成功。在这种情况下,如何才能将服务器广播模型与P2P结合起来了?需要做到以下几点: (1)每个客户端需要记录自己与哪些用户创建了P2P通道。 (2)服务器也要知道每个客户端的P2P通道的状态。(客户端的P2P通道状态发生变化时,及时报告给服务器) (3)当服务器收到一个视频帧时,首先,查看当前参与会议的用户,然后,根据视频帧的发送者的P2P通道状态,来综合决定该视频帧需要转发给哪些客户端。 (比如本文的例子,服务器收到A的视频帧,发现A和B之间的P2P是通的,所以,它就只将该帧转发给C。) (4)在客户端发送视频帧时,又可细分为三种情况: a.当某个客户端发现自己和所有的与会者都建立了P2P通道时,那么,它就不用把视频帧发送给服务器了。 b.如果客户端与所有与会者的P2P通道都没有建立成功,那么,它只需要将视频帧发送给服务器。 c.如果客户端与部分与会者建立了P2P通道,那么,它不仅需要将视频帧发送给服务器,还需要将该帧经过每个P2P通道发送一次。 本篇我们提出了广播消息与经服务器中转、P2P通道传送等方案结合时,可能发生的各种情况,以及在每种情况下服务器消耗的上行与下行的带宽。综合看来,模型3对服务器带宽的占用是最少的,但是,其实现也是最复杂的。我们将在下篇详细介绍基于ESFramework通信框架对上述模型3的完整实现,敬请期待。
C和C++有很多好的类库的沉淀,在.NET中,完全抛弃它们而重头再来是非常不明智的、也是不现实的,所以,我们经常需要通过Pinvoke来使用以前遗留下来的非托管的dll。就.NET中使用非托管的dll经验而言,经常碰到的问题至少有两个,它们都是通过在运行时抛出异常来体现的。 1.试图加载格式不正确的程序 出现这种异常,通常是.NET应用程序的“目标平台”与非托管dll的平台不一样。 一般,在使用VS开发.NET的应用程序和类库时,默认的目标平台为“Any CPU”,即会在运行时可根据CPU类型自动选择X86或X64,拥有这样的能力是因为.NET编译后的程序集是基于IL的,在运行时,CLR才会将其JIT发射为X86或X64的机器码。 而C或C++编译生成的dll就是机器码,所以,其平台的决策是在编译时决定的。通过编译选项的设置,我们可以将C/C++项目编译为X86的dll或者X64的dll。 所以,在调用了非托管dll的.NET项目中,也需要将其目标平台属性设为与非托管的dll的运行平台完全一致。通常遗留下来的非托管dll都是基于x86的,所以,在调用了这类非托管dll的.NET项目中,就将其目标平台属性设为“X86”。 可根据“项目->属性->生成->目标平台”找到该设置: 2.无法加载dll,找不到指定的模块。 运行调用了非托管的.NET应用程序有时会出现这种异常,可是比较郁闷的是,这种异常并不是在所有的电脑上都会出现,就经验来说,它只是在少部分电脑上出现,而在绝大部分电脑上运行都是正常的。我们在开发语音视频录制组件MFile时,就遇到过这个问题,当时很是头疼。 如果出现这种情况,很大的可能就是那少部分电脑上没有安装VC++运行时(CRT),或者是CRT安装不正确导致的。好用的解决方案有两种: (1)在C盘下找到了下列文件:msvcm80d.dll、msvcp80d.dll、msvcr80d.dll、Microsoft.VC80.DebugCRT.manifest。把这几个文件拷贝到目标机器上,放到运行目录下或放到system32下,就可以了。 注意,一般这几个文件都有多个版本,位于不同的文件夹下,要观察其文件夹的名称:是x86还是x64的、是debug的还是release的、以及是否要MFC的,这些选择要与非托管dll的信息一致。 (2)如果有非托管dll的源码,那就修改编译选项,重新编译一下:将/MD或/MDd 改为 /MT或/MTd,这样就实现了对VC运行时库的静态链接,在运行时就不再需要上述几个dll了。
在很多语音视频软件系统中,经常有将实时的音频或视频录制为文件保存到磁盘的需求,比如,视频监控系统中录制监控到的视频、视频会议系统中录制整个会议的过程、语音通话系统中录制完整的对话内容、等等。 一.缘起 最近正在做的一个网络招聘平台的项目,其中有一个模块是这样的,应聘者可以通过该系统的客户端录制自己的视频(自我介绍)上传到服务器,而后,招聘者会在合适的时候浏览这些应聘者的视频。该模块涉及到的主要技术就是语音视频录制技术,它需要把从麦克风采集到的语音数据和从摄像头采集到的视频数据编码并写到.mp4文件中。要完成这些功能,具体来说,需要解决如下几个技术问题: (1)麦克风数据采集 (2)摄像头数据采集 (3)音频数据编码 (4)视频数据编码 (5)将编码后的数据按.mp4文件格式写入到文件容器中。 (6)保证音频视频播放的同步。 二.Demo实现 如果要从头开始一步步解决这些问题,将是非常艰难的挑战。幸运的是,我们可以通过已有组件的组合来实现这些功能,语音视频数据的采集我们可以借助OMCS框架完成,后续的语音视频编码并生成mp4文件,我们可以借助MFile组件完成。为了更方便地讲解,这里我们将给出一个具体的demo,它可以录制从本地摄像头和本地麦克风采集的数据并生成mp4文件。demo运行的截图如下所示: 接下来,我们来说说在这个demo中是如何一个个解决上述问题的。 1.语音数据采集 我们可以使用OMCS的MicrophoneConnector组件连接到自己的麦克风设备,这样,扬声器就会播放采集到的语音,而且,我们可以通过通过IMultimediaManager暴露的AudioPlayed事件,来捕获正在播放的语音数据。 2.视频数据采集 同样的,我们可以使用CameraConnector控件连接到自己的摄像头设备,然后,定时器每隔100ms(假设帧频为10fps)调用其GetCurrentImage方法获得正在绘制的Bitmap。 3.后续步骤 后续的4步都可以交由MFile组件搞定,我们大概看一下MFile组件中VideoFileMaker类的签名,就知道怎么做了: public class VideoFileMaker :IDisposable { /// <summary> /// 初始化视频文件。 /// </summary> /// <param name="filePath">文件路径</param> /// <param name="videoCodec">视频编码格式</param> /// <param name="videoWidth">视频宽度</param> /// <param name="videoHeight">视频高度</param> /// <param name="videoFrameRate">帧频</param> /// <param name="audioCodec">音频编码格式</param> /// <param name="audioSampleRate">音频采样率。【注:采样位数必须为16位】</param> /// <param name="audioChannelCount">声道数</param> /// <param name="autoSyncToAudio">如果是实时录制,则可传入true,以音频为基准进行同步。</param> void Initialize(string filePath, VideoCodecType videoCodec, int videoWidth, int videoHeight, int videoFrameRate, AudioCodecType audioCodec, int audioSampleRate, int audioChannelCount, bool autoSyncToAudio); /// <summary> /// 添加音频帧。 /// </summary> void AddAudioFrame(byte[] audioframe); /// <summary> /// 添加视频帧。如果autoSyncToAudio开启,则自动同步到音频。 /// </summary> void AddVideoFrame(Bitmap frame); /// <summary> /// 添加视频帧。 /// </summary> /// <param name="frame">视频帧</param> /// <param name="timeStamp">离开始时的时间长度</param> void AddVideoFrame(Bitmap frame, TimeSpan timeStamp); /// <summary> /// 关闭视频文件。 /// </summary> /// <param name="waitFinished">如果还有帧等待写入文件,是否等待它们全部写入文件。</param> void Close(bool waitFinished); } 首先调用Initialize方法完成初始化,然后,循环调用AddAudioFrame和AddVideoFrame方法,当完成视频录制时,则调用Close方法,即可。很简单,不是吗? 4.主要代码 首先,我们以aa01用户登录到OMCS服务器,然后,在拖拽一个CameraConnector控件和一个MicrophoneConnector组件到主窗体上,然后,让它们都连到自己的摄像头和麦克风。 this.multimediaManager = MultimediaManagerFactory.GetSingleton(); this.multimediaManager.Initialize("aa01", "", "127.0.0.1", 9900); this.cameraConnector1.BeginConnect("aa01"); this.microphoneConnector1.BeginConnect("aa01"); 接下来,我们初始化VideoFileMaker组件: this.videoFileMaker.Initialize("test.mp4", VideoCodecType.H264, this.multimediaManager.CameraVideoSize.Width, this.multimediaManager.CameraVideoSize.Height, 10, AudioCodecType.AAC, 16000, 1, true); this.timer = new System.Threading.Timer(new System.Threading.TimerCallback(this.Callback), null ,0, 100); this.multimediaManager.AudioPlayed += new ESBasic.CbGeneric<byte[]>(multimediaManager_AudioPlayed); 参数中设定,使用h.264对视频进行编码,使用aac对音频进行编码,并生成mp4格式的文件。然后,我们可以通过OMCS获取实时的音频数据和视频数据,并将它们写到文件中。 void multimediaManager_AudioPlayed(byte[] audio) { this.videoFileMaker.AddAudioFrame(audio); } private void Callback(object state) { Bitmap bm = this.cameraConnector1.GetCurrentImage(); this.videoFileMaker.AddVideoFrame(bm); } 当想结束录制时,则调用Close方法: this.videoFileMaker.Close(true); 这样录制生成的test.mp4文件就可以直接用我们的QQ影音或暴风影音来播放了。 更多细节,请查看demo源码。 三.Demo下载 Demo源码:Oraycn.RecordDemo.rar 2014.11.26 现在录制本地的语音、视频、屏幕的最好的方案是MCapture + MFile,而不是通过OMCS绕一大圈,相应的Demo源码下载:Oraycn.RecordDemo.rar 。 当然,如果是远程录制语音、视频、屏幕,最好的方案是OMCS + MFile。 2015.6.18 整理全部相关demo如下: (声卡/麦克风/摄像头/屏幕)采集&录制Demo:WinForm版本 、WPF版本。 声卡录制Demo、 混音&录制Demo、 同时录制(桌面+麦克风+声卡)Demo、 麦克风摄像头录制(可预览)、 录制画中画(桌面+摄像头+麦克风/声卡)。 远程录制或在服务器端录制语音视频屏幕
当同一个系统的两个客户端A、B相互发送消息给对方时,如果它们之间存在P2P通道,那么消息传送的路径就有两种:直接经P2P通道传送、或者经服务器中转。如下图所示: 通常就一般应用而言,如果P2P通道能够成功创建(即所谓的打洞成功),A和B之间的所有消息将直接走P2P通道,这样可以有效节省服务器的带宽和降低服务器的负载。这种模型即是所谓的“P2P通道优先”模型,也是最简单的通道选择模型。 一.通道质量优先模型 然而,有些系统可能不能就如此简单的处理,最简单的例子,如果A和B之间传递的某些类型的消息必需让服务器监控到,那么,这样的消息就必需经过服务器中转。接下来,我们讨论一种较为复杂的情况。比如,在网络语音对话系统中,通道的质量直接决定着用户体验的好坏。我们希望,在这种系统中,语音数据需要始终经由两条通道中的那个质量较高的通道进行传送。这种策略就是所谓的“通道质量优先”模型。 “通道质量优先”模型理解起来很简单,但是在实际中实现时,却还是很有难度的。通常有两种实现方式: (1)定时检测、比较通道延时,并自动切换通道。 (2)由上层应用决定何时切换通道。一般而言,是当应用发现当前使用的通道不满足要求时,就主动要求切换到另外一条通道。 二.模型实现 下面,我们就基于ESFramework提供的通信功能,来实现这两种方式。我们使用AgileP2PCustomizeOutter类来封装它,并可通过属性控制来启用哪种方式。 public class AgileP2PCustomizeOutter:IEngineActor { //字典。userID - 当前选择的通道(如果为true,表示P2P通道;否则是经服务器中转)? private ObjectManager<string, bool> channelStateManager = new ObjectManager<string, bool>(); private ICustomizeOutter customizeOutter; //消息发送器 private IP2PController p2PController;//P2P控制器 private IBasicOutter basicOutter; //心跳发送器 private AgileCycleEngine agileCycleEngine; //定时检测引擎 #region PingTestSpanInSecs private int pingTestSpanInSecs = 60; /// <summary> /// 定时进行ping测试以及自动切换通道的时间间隔,单位:秒。默认值为60。 /// 如果设置为小于等于0,则表示不进行定时ping测试,也不会自动切换通道。 /// </summary> public int PingTestSpanInSecs { get { return pingTestSpanInSecs; } set { pingTestSpanInSecs = value; } } #endregion #region Initialize public void Initialize(ICustomizeOutter _customizeOutter, IP2PController _p2PController, IBasicOutter _basicOutter) { this.customizeOutter = _customizeOutter; this.p2PController = _p2PController; this.basicOutter = _basicOutter; this.p2PController.P2PChannelOpened += new ESBasic.CbGeneric<P2PChannelState>(p2PController_P2PChannelOpened); this.p2PController.P2PChannelClosed += new ESBasic.CbGeneric<P2PChannelState>(p2PController_P2PChannelClosed); this.p2PController.AllP2PChannelClosed += new ESBasic.CbGeneric(p2PController_AllP2PChannelClosed); Dictionary<string, P2PChannelState> dic = this.p2PController.GetP2PChannelState(); foreach (P2PChannelState state in dic.Values) { bool p2pFaster = this.TestSpeed(state.DestUserID); this.channelStateManager.Add(state.DestUserID, p2pFaster); } if (this.pingTestSpanInSecs > 0) { this.agileCycleEngine = new AgileCycleEngine(this); this.agileCycleEngine.DetectSpanInSecs = this.pingTestSpanInSecs; this.agileCycleEngine.Start(); } } //定时执行,当前客户端到其它客户端之间的通道选择 public bool EngineAction() { foreach (string userID in this.channelStateManager.GetKeyList()) { bool p2pFaster = this.TestSpeed(userID); this.channelStateManager.Add(userID, p2pFaster); } return true; } void p2PController_AllP2PChannelClosed() { this.channelStateManager.Clear(); } void p2PController_P2PChannelClosed(P2PChannelState state) { this.channelStateManager.Remove(state.DestUserID); } void p2PController_P2PChannelOpened(P2PChannelState state) { bool p2pFaster = this.TestSpeed(state.DestUserID); this.channelStateManager.Add(state.DestUserID, p2pFaster); } #endregion #region TestSpeed /// <summary> /// 定时测试 /// </summary> private bool TestSpeed(string userID) { try { int transfer = this.basicOutter.PingByServer(userID); int p2p = this.basicOutter.PingByP2PChannel(userID); return p2p <= transfer; } catch (Exception ee) { return false; } } #endregion /// <summary> /// 手动切换通道。 /// </summary> public void SwitchChannel(string destUserID) { if (this.channelStateManager.Contains(destUserID)) { bool p2p = this.channelStateManager.Get(destUserID); this.channelStateManager.Add(destUserID, !p2p); } } /// <summary> /// 到目标用户是否使用的是P2P通道。 /// </summary> public bool IsUsingP2PChannel(string destUserID) { return this.channelStateManager.Get(destUserID); } public bool IsExistP2PChannel(string destUserID) { return this.channelStateManager.Contains(destUserID); } /// <summary> /// 向在线用户发送信息。 /// </summary> /// <param name="targetUserID">接收消息的目标用户ID。</param> /// <param name="informationType">自定义信息类型</param> /// <param name="post">是否采用Post模式发送消息</param> /// <param name="action">当通道繁忙时所采取的动作</param> public void Send(string targetUserID, int informationType, byte[] info, bool post, ActionTypeOnChannelIsBusy action) { bool p2pFaster = this.channelStateManager.Get(targetUserID); ChannelMode mode = p2pFaster ? ChannelMode.ByP2PChannel : ChannelMode.TransferByServer; this.customizeOutter.Send(targetUserID, informationType, info, post, action, mode); } /// <summary> /// 向在线用户或服务器发送大的数据块信息。直到数据发送完毕,该方法才会返回。如果担心长时间阻塞调用线程,可考虑异步调用本方法。 /// </summary> /// <param name="targetUserID">接收消息的目标用户ID。如果为null,表示接收者为服务器。</param> /// <param name="informationType">自定义信息类型</param> /// <param name="blobInfo">大的数据块信息</param> /// <param name="fragmentSize">分片传递时,片段的大小</param> public void SendBlob(string targetUserID, int informationType, byte[] blobInfo, int fragmentSize) { bool p2pFaster = this.channelStateManager.Get(targetUserID); ChannelMode mode = p2pFaster ? ChannelMode.ByP2PChannel : ChannelMode.TransferByServer; this.customizeOutter.SendBlob(targetUserID, informationType, blobInfo, fragmentSize, mode); } } 现在,我们对上面的实现简单解释一下。 (1)由于当前客户端可能会与多个其它的客户端进行通信,而与每一个其它的客户端之间的通信都有通道选择的问题,所以需要一个字典ObjectManager将它们管理起来。 (2)当某个P2P通道创建成功时,将进行首次ping比较,并将结果记录到字典中。 (3)定时引擎每隔60秒,分别针对每个其它客户端进行通道检测比较,自动选择ping值小的那个通道。 (4)当我们将PingTestSpanInSecs设为0时,就可以使用SwitchChannel方法来手动切换通道,即实现了上述的方式2。 (5)我们最终的目的是实现Send方法和SendBlob方法,之后,就可以使用AgileP2PCustomizeOutter类来替换ICustomizeOutter发送消息。 三.方式选择 上面讲到“通道质量优先”模型的两种实现方式,那么在实际的应用中,如何进行选择了? 1.ping检测比较,自动切换 就这种方式而言,其缺陷在于,在客户端之间需要进行高频通信的系统中,ping检测可能是非常不准确的,甚至是错误的。 比如,在实时视频对话系统中,其对带宽的要求是比较高的,假设,现在所有的视频数据走的都是P2P通道,那么P2P通道就非常忙碌,而经服务器中转的通道几乎就是空闲的。所以,当下一次定时ping检测到来时,P2P通道的ping值就会比实际的大。从而导致判断失误,而发生错误的自动切换。 2.手动切换 对于刚才视频对话的例子,使用手动切换可能是更好的选择,由应用根据上层的实际效果来决定是否需要切换通道。比如,还以视频对话系统为例,应用可以根据信息接收方的定时反馈(在一段时间内,缺少音/视频包的个数,音/视频包的总延时等统计信息)来决定是否要切换到另外一个通道。这种方式更简洁描述可以表达为:如果当前通道质量已达到应用需求,即使另一个通道更快更稳定,也不进行切换;如果当前通道质量达不到应用需求,则切换到另一个通道(有可能另一个通道的质量更糟糕)。 本文只是简单地引出通道选择模型的问题,实际上,这个问题是相当复杂的,特别是在一些通信要求很高的项目中,而且,如果将广播消息的通道模型考虑进来就更麻烦了,有兴趣的朋友可以留言进行更深入的讨论。
在一些软件系统中,需要用到手写涂鸦的功能,然后可以将涂鸦的结果保存为图片,并可以将“真迹”通过网络发送给对方。这种手写涂鸦功能是如何实现的了?最直接的,我们可以使用Windows提供的GDI技术或GDI+技术来实现绘图功能。但是,要实现一个如此简单的涂鸦板,也不是那么容易的事情。幸运的是,我们可以直接使用OMCS提供的内置集成了这种功能的一个WinForm控件HandwritingPanel。 HandwritingPanel控件的主要接口如下图所示: /// <summary> /// 设置画笔颜色。 /// </summary> Color PenColor {set;} /// <summary> /// 设置画笔粗细。 /// </summary> float PenWidth {set;} /// <summary> /// 清空画板。 /// </summary> void Clear(); /// <summary> /// 获取涂鸦的结果(位图)。 /// </summary> Bitmap GetHandWriting(); 将HandwritingPanel控件从工具箱拖到你的UI上,可以通过PenColor和PenWidth属性设置画笔的颜色和粗细。运行起来后,就可以在控件的表面进行涂鸦和手写了。 如果需要清空手写板,则调用Clear方法。 当手写结束的时候,则调用GetHandWriting方法得到手写的结果,并保存为位图。位图的大小即是HandwritingPanel控件的尺寸。 OK,下面我们就写了一个使用HandwritingPanel来实现手写涂鸦板的demo,demo的主要代码如下所示: public partial class HandwritingForm : Form { private Color currentColor = Color.Red; private List<float> penWidthList = new List<float>(); private Bitmap currentImage; public Bitmap CurrentImage { get { return currentImage; } } public HandwritingForm() { InitializeComponent(); this.handwritingPanel1.PenColor = this.currentColor; //设置画笔颜色 this.penWidthList.Add(2); this.penWidthList.Add(4); this.penWidthList.Add(6); this.penWidthList.Add(8); this.penWidthList.Add(10); this.comboBox_brushWidth.DataSource = this.penWidthList; this.comboBox_brushWidth.SelectedIndex = 1; } private void button_color_Click(object sender, EventArgs e) { try { this.colorDialog1.Color = this.currentColor; DialogResult result = this.colorDialog1.ShowDialog(); if (result == DialogResult.OK) { this.currentColor = this.colorDialog1.Color; this.handwritingPanel1.PenColor = this.currentColor; //设置画笔颜色 } } catch (Exception ee) { MessageBox.Show(ee.Message); } } //设置画笔宽度 private void comboBox_brushWidth_SelectedIndexChanged(object sender, EventArgs e) { if (this.comboBox_brushWidth.SelectedIndex > 0) { this.handwritingPanel1.PenWidth = this.penWidthList[this.comboBox_brushWidth.SelectedIndex]; } else { this.handwritingPanel1.PenWidth = this.penWidthList[0]; } } private void Button_clear_Click(object sender, EventArgs e) { this.handwritingPanel1.Clear(); //清空手写板 } private void button_Ok_Click(object sender, EventArgs e) { this.currentImage = this.handwritingPanel1.GetHandWriting(); //获取手写图片 this.DialogResult = System.Windows.Forms.DialogResult.OK; } private void Button_cancel_Click(object sender, EventArgs e) { this.DialogResult = System.Windows.Forms.DialogResult.Cancel; } } 其运行效果如下图所示: 下载demo源码。
在很多软件系统中,都允许用户设置自己的头像,甚至可以直接使用摄像头照相作为自己的头像,就像QQ的自拍头像功能一样。 这种功能是如何实现的了?最直接的,我们可以使用Windows提供的VFW技术或DirectX技术来捕获摄像头采集到的视频和图片。但是,无论使用这两种技术中的哪一个,要实现一个兼容所有摄像头而又运行稳定的拍照功能,都不是那么容易。幸运的是,OMCS已经内置集成了这种功能的一个WinForm控件PhotoPanel,我们可以直接拿来使用。 PhotoPanel控件的主要接口如下图所示: /// <summary> /// 初始化摄像头,并启动它。 /// </summary> void Start(); /// <summary> /// 停止摄像头。 /// </summary> void Stop(); /// <summary> /// 照相。返回当前帧。 /// </summary> Bitmap GetCurrentImage(); 将PhotoPanel控件从工具箱拖到你的UI上,调用其Start方法,将初始化摄像头,并启动它,然后PhotoPanel控件表面将绘制摄像头采集到的视频。 当要拍照的时候,则调用GetCurrentImage方法得到当前帧,并保存为位图。 当拍照结束后,则调用Stop方法停止并释放摄像头设备。 还有两个问题: (1)如何设置要使用的摄像头的索引了?这个可以通过PhotoPanel控件暴露的CameraIndex属性来指定。 (2)如何设置拍照的尺寸了?拍照的尺寸即是PhotoPanel的尺寸,其默认值为160*120。当然这个尺寸并不是任意的,必须是当前摄像头所支持的分辨率才可以。比如,160*120、320*240、640*480等,一般摄像头都是支持的。 OK,下面我们就写了一个使用PhotoPanel来实现自拍头像功能的demo,demo的主要代码如下所示: public partial class TakePhotoForm : Form { public TakePhotoForm() { InitializeComponent(); this.photoPanel1.CameraIndex = 0;//设置摄像头 this.photoPanel1.Start();//启动摄像头 } private Bitmap photo = null; /// <summary> /// 拍照结果 /// </summary> public Bitmap Photo { get { return photo; } set { photo = value; } } //拍照 private void button1_Click(object sender, EventArgs e) { this.photo = this.photoPanel1.GetCurrentImage(); this.photoPanel1.Stop(); this.DialogResult = System.Windows.Forms.DialogResult.OK; } private void TakePhotoForm_FormClosing(object sender, FormClosingEventArgs e) { this.photoPanel1.Stop(); } } 其运行效果如下图所示: 下载demo源码。
最近做的一个Web版的视频会议项目,需要在网页中播放来自远程摄像头采集的实时视频,我们已经有了播放远程实时视频的使用C#编写的windows控件,如何将其嵌入到网页中去了?这需要使用一种古老的技术,ActiveX。 1.将.Net控件转化为ActiveX控件 首先要做的就是将我们的windows视频播放控件转化为ActiveX控件。先看看我们视频播放控件的定义,其基于OMCS实现,相当简单: public partial class CameraVideoPlayer : UserControl { private IMultimediaManager multimediaManager; public CameraVideoPlayer() { InitializeComponent(); } public void Test() { Random ran = new Random(); string userID = "bb" + ran.Next(1001,9999).ToString(); this.Initialize("223.4.*.*", 9900, userID, "aa01"); } public void Initialize(string serverIP, int port, string userID, string targetUserID) { try { this.multimediaManager = MultimediaManagerFactory.GetSingleton(); this.multimediaManager.Initialize(userID, "", serverIP, port); this.cameraConnector1.BeginConnect(targetUserID); } catch (Exception ee) { MessageBox.Show(ee.Message); } } } 当调用其Initialize方法时,将连接到目标用户的摄像头,并在其内含的cameraConnector1控件上播放视频。这个控件在Windows Form应用程序中工作良好,现在我们一步步来将其转换为ActiveX控件。 (1)GUID ActiveX控件首先是COM组件,COM组件有唯一的GUID。后面我们可以看到,在Web中,需要通过GUID定位并加载已经注册的ActiveX控件。 如果使用的是VS2010,工具菜单下有个“创建GUID”菜单,点击它可以创建一个新的GUID,然后把其复制作为CameraVideoPlayer的特性: [Guid("D9906B42-56B3-4B94-B4F9-A767194A382F")] public partial class CameraVideoPlayer : UserControl (2)实现IObjectSafety接口 当ActiveX控件在浏览器中调用的时候,往往会出现警告框,提示不安全的控件正在运行。这是由浏览器安全策略所限定的,控件通过实现IObjectSafety接口以向浏览器表明自己是合法的。在项目中增加IObjectSafety接口的定义: [Guid("CB5BDC81-93C1-11CF-8F20-00805F2CD064"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] public interface IObjectSafety { void GetInterfacceSafyOptions(System.Int32 riid,out System.Int32 pdwSupportedOptions,out System.Int32 pdwEnabledOptions); void SetInterfaceSafetyOptions(System.Int32 riid, System.Int32 dwOptionsSetMask, System.Int32 dwEnabledOptions); } 并让CameraVideoPlayer实现这个接口: [Guid("D9906B42-56B3-4B94-B4F9-A767194A382F")] public partial class CameraVideoPlayer : UserControl, IObjectSafety { private IMultimediaManager multimediaManager; public CameraVideoPlayer() { InitializeComponent(); } public void Test() { Random ran = new Random(); string userID = "bb" + ran.Next(1001,9999).ToString(); this.Initialize("223.4.180.116", 9900, userID, "aa01"); } public void Initialize(string serverIP, int port, string userID, string targetUserID) { try { this.multimediaManager = MultimediaManagerFactory.GetSingleton(); this.multimediaManager.Initialize(userID, "", serverIP, port); this.cameraConnector1.BeginConnect(targetUserID); } catch (Exception ee) { MessageBox.Show(ee.Message); } } public void GetInterfacceSafyOptions(int riid, out int pdwSupportedOptions, out int pdwEnabledOptions) { pdwSupportedOptions = 1; pdwEnabledOptions = 2; } public void SetInterfaceSafetyOptions(int riid, int dwOptionsSetMask, int dwEnabledOptions) { } } IObjectSafety接口的两个方法的实现都可以采用上面的代码来做。 (3)程序集设定 接下来,我们需要对控件的程序集(OMCS_ActiveX)做一个设置,以表明其将作为一个COM组件使用。打开AssemblyInfo.cs文件,首先将ComVisible特性设置为true。其次,增加AllowPartiallyTrustedCallers特性。如下所示: // 将 ComVisible 设置为 false 使此程序集中的类型 // 对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型, // 则将该类型上的 ComVisible 特性设置为 true。 [assembly: ComVisible(true)] [assembly: AllowPartiallyTrustedCallers()] 最后,在项目属性的“生成”页中,将“为COM互操作注册”的CheckBox勾上。 这样,编译生成的产物中除了OMCS_ActiveX.dll外,还有OMCS_ActiveX.tlb(COM用到的类型库文件)。 2.制作安装程序 转化后的CameraVideoPlayer ActiveX控件会被部署在IIS服务器上,用户第一次打开网页时,在用户的机器上是不存在这个控件的,所以,需要下载安装并在用户的机器上注册该ActiveX控件。这些可以通过VS自带的制作安装程序的功能来实现,也相当简单。 (1)在当前解决方案中添加一个新的安装项目。 (2)将OMCS_ActiveX项目的主输出导入到安装项目的“应用程序文件夹”下面。 (3)修改主输出的文件安装属性中的Register项为vsdrpCOM。 (4)设置安装项目的项目属性,主要是“安装URL”项,要设置为部署时地址。 (5)如果需要,将“系统必备”中的一些项目勾上或去掉。 (6)编译安装项目,将会生成两个文件setup.exe、Setup1.msi。将它们拷贝到网站虚拟目录的根目录下。 3.Web集成 现在我们写一个最简单的HTML来试试加载视频播放的ActiveX控件CameraVideoPlayer。如下所示: <html xmlns="http://www.w3.org/1999/xhtml" > <head> <title>摄像头视频播放器测试</title> </head> <body> <form id="form1"> <table> <tr> <td align="center"> <object id="cameraVideoPlayer" classid="clsid:{D9906B42-56B3-4B94-B4F9-A767194A382F}" codebase="setup.exe" width="320" height="240"> </object> </td> </tr> <tr> <td align="center"> <input type=button id="Button1" value="连接摄像头" onclick="javascript:doTest()"/> </td> </tr> </table> <script type="text/javascript"> function doTest() { var obj = document.getElementById("cameraVideoPlayer"); obj.Test(); } </script> </form> </body> </html> 注意加粗的部分,说明了两点: (1)浏览器是通过GUID来定位ActiveX控件的。 (2)如果本机不存在目标ActiveX控件,则自动下载codebase属性指示的安装程序进行安装。 将HTML文件部署好后,第一次打开网页,如下所示: 运行安装,完成后,页面会刷新,并可以看到ActiveX控件已经成功加载进来了。然后,点击“连接摄像头”按钮,测试一下ActiveX控件是否可以显示远程摄像头采集的视频,如下所示: 这样,嵌入到网页中的ActiveX控件就像普通的windows控件一样正常运行了:)
近段时间,有几个朋友问我如何实现类似QQ离线文件的功能。不想一一作答,就写一篇博文来比较完整的解释这个问题。 所谓“离线文件”,就是当接收者不在线时,发送者先把文件传送给服务端,在服务器上暂时保存,等接收者上线时,服务端再把文件发送给他。当然,要想实现离线文件的功能,其最基本的前提是要先实现传送文件的功能,我们就以ESFramework提供的传送文件的功能为基础,在其之上一步步完成一个基本的离线文件功能。 下面我们就用户在使用离线文件时,按各个动作发生的先后顺序,介绍程序方面与之对应的设计与实现。 1.客户端发送离线文件 当用户选择好一个文件,并点击“发送离线文件”按钮时,其目的是要将这个文件传送给服务端,这可以直接使用IFileOutter的BeginSendFile方法: /// <summary> /// 发送方准备发送文件(夹)。/// </summary> /// <param name="accepterID">接收文件(夹)的用户ID</param> /// <param name="fileOrDirPath">被发送文件(夹)的路径</param> /// <param name="comment">其它附加备注。如果是在类似FTP的服务中,该参数可以是保存文件(夹)的路径</param> /// <param name="projectID">返回文件传送项目的编号</param> void BeginSendFile(string accepterID, string fileOrDirPath, string comment, out string projectID); 如果将参数accepterID传入null,表示文件的接收者就是服务端。那么我们要如何区分,这不是一个最终由服务端接收的文件,而是要传给另一个用户的离线文件了?这里,我们可以巧用comment参数,比如,comment参数如果为null,就表示普通的上传文件;comment不为null,就表示一个离线文件,并且其值就是文件最终接收者的ID。(当然,如果在你的项目中,comment参数已经有了其它用途,我们可以进一步扩展它,加上一些标签,使其能够标志出离线文件)。 下面这个调用示例,就是将Test.txt文件离线发送给aa01。 string filePath = ...; //要发送文件的路径 string projectID = null; fileOutter.BeginSendFile(null, filePath, "aa01", out projectID); 2.服务端接收离线文件 客户端调用BeginSendFile方法请求发送文件后,服务端会触发IFileController的FileRequestReceived事件。同理,我们判断该事件的comment参数,当其不为null时,表示是个离线文件。在答复客户端同意接收文件之前,我们需要先将离线文件的相关信息保存起来,这里我们使用OfflineFileItem类来封装这些信息。 /// <summary> /// 离线文件条目 /// </summary> public class OfflineFileItem { /// <summary> /// 条目的唯一编号,数据库自增序列,主键。 /// </summary> public string AutoID { get; set; } /// <summary> /// 离线文件的名称。 /// </summary> public string FileName { get; set; } /// <summary> /// 文件的大小。 /// </summary> public ulong FileLength { get; set; } /// <summary> /// 发送者ID。 /// </summary> public string SenderID { get; set; } /// <summary> /// 接收者ID。 /// </summary> public string AccepterID { get; set; } /// <summary> /// 在服务器上存储离线文件的临时路径。 /// </summary> public string RelayFilePath { get; set; } } 有了OfflineFileItem的定义之后,我们就可以处理IFileController的FileRequestReceived事件了。 rapidServerEngine.FileController.FileRequestReceived += new CbFileRequestReceived(fileController_FileRequestReceived); ObjectManager<string, OfflineFileItem> offlineFileItemManager = new ObjectManager<string, OfflineFileItem>(); //可以把ObjectManager类看作一个线程安全的Dictionary。 void fileController_FileRequestReceived(string projectID, string senderID, string fileName, ulong totalSize, ResumedProjectItem resumedFileItem, string comment) { string saveFilePath = "......" ;//根据某种策略得到存放文件的路径 if (comment != null) //根据约定,comment不为null,表示为离线文件,其值为最终接收者的ID。 { string accepterID = comment; OfflineFileItem item = new OfflineFileItem(); item.AccepterID = accepterID; item.FileLength = totalSize; item.FileName = fileName; item.SenderID = senderID ; item.RelayFilePath = saveFilePath; offlineFileItemManager.Add(projectID, item); } //给客户端回复同意,并开始准备接收文件。 rapidServerEngine.FileController.BeginReceiveFile(projectID ,saveFilePath); } 上面的代码做了三件事情: (1)根据某种策略得到存放文件的路径。 (2)创建一个离线文件信息条目,保存在内存中。 (3)回复客户端,并准备接收文件。 需要重点说明的是第一点,对于一般的小型项目,在服务端我们可以将所有的离线文件存放在当前服务器的某个目录下;但是对于大型项目,一般需要使用DFS(分布式文件系统)来存储这些临时的离线文件。 客户端收到服务器的回复后,会正式开始传送文件,如果传送过程中,因为某种原因导致传送中断,则服务端会触发IFileController.FileReceivingEvents的FileTransDisruptted事件。在该事件处理函数中,我们从内存中移除对应的离线文件信息条目: rapidServerEngine.FileController.FileReceivingEvents.FileTransDisruptted += new CbGeneric<TransferingProject, FileTransDisrupttedType>(fileReceivingEvents_FileTransDisruptted); void fileReceivingEvents_FileTransDisruptted(TransferingProject project, FileTransDisrupttedType type) { offlineFileItemManager.Remove(project.ProjectID); } 如果文件正常传送完毕,则服务端会触发IFileController.FileReceivingEvents的FileTransCompleted事件。此时,我们将对应的离线文件信息条目从内存转移存储到数据库中,以防止服务器重启时导致信息丢失: rapidServerEngine.FileController.FileReceivingEvents.FileTransCompleted += new CbGeneric<TransferingProject>(fileReceivingEvents_FileTransCompleted); IOfflineFilePersister offlineFilePersister = ......; void fileReceivingEvents_FileTransCompleted(TransferingProject project) { OfflineFileItem item = offlineFileItemManager.Get(project.ProjectID); offlineFilePersister.Add(item); offlineFileItemManager.Remove(project.ProjectID); } 我们设计IOfflineFilePersister接口,用于与数据库中的OfflineFileItem表交互。 public interface IOfflineFilePersister { /// <summary> /// 将一个离线文件条目保存到数据库中。 /// </summary> void Add(OfflineFileItem item); /// <summary> /// 从数据库中删除主键值为ID的条目。 /// </summary> void Remove(string id); /// <summary> /// 从数据库中提取接收者为指定用户的所有离线文件条目。 /// </summary> List<OfflineFileItem> GetByAccepter(string accepterID); } 我们可以使用ADO.NET或者EntityFramework实现上述接口。 3.服务端发送离线文件给最终接收者 当真正的接收者上线时,服务端要把相关的离线文件发送给他。通过预定UserManager的SomeOneConnected事件,我们知道用户上线的时刻。 rapidServerEngine.UserManager.SomeOneConnected += new CbGeneric<UserData>(userManager_SomeOneConnected); void userManager_SomeOneConnected(UserData data) { List<OfflineFileItem> list = offlineFilePersister.GetByAccepter(data.UserID); if (list != null) { foreach (OfflineFileItem item in list) { string projectID = null ; rapidServerEngine.FileController.BeginSendFile(item.AccepterID, item.RelayFilePath, item.SenderID, out projectID); offlineFilePersister.Remove(item.AutoID); File.Delete(item.RelayFilePath); } } } 上面的代码做了三件事情: (1)从数据库中查找所有接收者为登录用户的离线文件信息条目。 (2)将离线文件逐个发送给这个用户 (3)从数据库中删除相应的条目,从磁盘上删除对应的离线文件。 实际上,第(3)点我们可以延迟到文件发送完成时,才执行删除操作。这样,就可以在发送万一意外中断时,使得重新发送成为可能。 客户端接收到服务端的发送文件请求时,会触发IFileOutter的FileRequestReceived事件,此时也可以根据comment参数的内容,来判断其是否为离线文件。后续的步骤的实现就相当容易了,这里就不再赘述了。 本文简洁地描述了实现离线文件功能的主要思路和基本模型,在实际的项目开发时,可以根据具体的需求在这个模型的基础上,进一步完善,包括很多细节和异常处理都需要加入进来。
在开发类似视频聊天的应用时,我们经常需要获取摄像头的相关信息;而在进行视频聊天时,我们可能还希望有一些动态的能力。比如,在不中断视频聊天的情况下,切换一个摄像头、或者修改摄像头采集的分辨率或编码质量等等。OMCS提供了很多有用的特性以支持上述需求。 一.枚举摄像头 我们如何得知当前的计算机有哪些摄像头了? OMCS提供了一个工具类OMCS.Tools.Camera,来帮助我们获取这些信息。Camera有个静态方法GetCameras,用于枚举当前计算机上的所有摄像头。 /// <summary> /// 枚举当前计算机上的所有摄像头设备。 /// </summary> public static List<CameraInformation> GetCameras() CameraInformation封装了摄像头的基本信息,其类图如下所示: 我们可以将GetCameras方法返回的列表直接绑定到Combox控件,效果如下所示: 默认情况下,IMultimediaManager使用的摄像头的索引为0。如果索引为0的摄像头不可用,或者,用户指定使用其它的摄像头,则可以将指定摄像头的索引保存到配置文件。当客户端程序启动时,从配置文件中读取该索引值,并将其赋值给IMultimediaManager的CameraDeviceIndex属性。 二.获取摄像头支持的分辨率 当选定了一个摄像头,我们如何知道该摄像头支持哪些采集分辨率、支持最高的帧频是多少了? Camera提供了另一个静态方法GetCameraCapability,来获取目标摄像头的能力信息。 /// <summary> /// 获取目标摄像头的能力信息(分辨率、最大帧频) /// </summary> /// <param name="deviceIndex">目标摄像头的索引</param> public static List<CameraCapability> GetCameraCapability(int deviceIndex) CameraCapability类封装了摄像头的能力信息,其类图如下所示: 我们可以将GetCameraCapability方法返回的列表直接绑定到Combox控件,效果如下所示: 三.动态切换摄像头 下面我们设想一种情况,假设我的电脑上装有两个可用的摄像头, 我正在使用索引为0的摄像头和我的朋友视频聊天,某个时刻,我想在不中断视频聊天的情况下,切换到索引为1的摄像头。这种需求就称为动态切换摄像头。 OMCS支持动态切换摄像头,并且操作相当简单:我们只需要将IMultimediaManager的CameraDeviceIndex属性赋值为要切换到的摄像头的索引即可。 切换会在OMCS内部自动进行,在很短的时间切换完成后,OMCS会将新摄像头采集的视频数据发送给各个guest。 四.动态修改摄像头的分辨率 还记得QQ视频聊天有这种能力,我们可以在视频聊天的时候,选择使用大窗口模式。这实际上就是使用摄像头更高的分辨率来采集视频,比如原始的采集分辨率为320*240,可切换到更高的640*480。 OMCS支持在不需要任何中断的情况下,修改正在使用的摄像头的采集分辨率。操作也是相当简单:我们只需要将IMultimediaManager的CameraVideoSize属性设置为目标分辨率大小即可。当然,如果设置的分辨率不被当前摄像头所支持,则将抛出NotSupportedException。另外要注意,在编码质量相同的情况下,视频的分辨率越高,所输出的码流就越大,所要求的带宽也越大。 为了避免设置不恰当的分辨率给CameraVideoSize属性,在赋值之前,我们可以通过OMCS.Tools.Camera的静态方法Support来判断目标摄像头是否支持指定的分辨率: /// <summary> /// 目标摄像头是否支持采集指定的分辨率。 /// </summary> public static bool Support(int deviceIndex, Size videoSize) 五.动态调节编码质量 在客户端运行的任何时候,我们都可以通过设置IMultimediaManager的CameraEncodeQuality属性,来实时调整摄像头采集的视频的编码质量。编码质量越高(CameraEncodeQuality取值越小),对带宽的要求就越高;反之亦然。 当然,正如前面文章所介绍的,如果IMultimediaManager的AutoAdjustCameraEncodeQuality属性被设置为true,则CameraEncodeQuality将会被OMCS自动调节以优先保证语音的清晰连贯,手动对CameraEncodeQuality的设置就不起作用了。 如果我们将AutoAdjustCameraEncodeQuality设置为false,并且我们的应用自己能检测到网络状态的实时变化,那么,我们就可以根据当前的网络状态,来手动调整CameraEncodeQuality。 六.控制视频输出 设想这样一种情况:我在进行视频会议时,某个时间出于某种原因,我不想让与会者看到我的视频,一段时间后,我又希望恢复原样。 从节省带宽的角度,最好的方式就是在这段时间内不输出视频帧。OMCS能简单地实现这种控制:我们只需将IMultimediaManager的OutputVideo属性设置为false,即可关闭视频帧的输出。 七.广播时选择丢弃视频帧 下面要设想的场景稍微复杂一点:在视频会议时,我的视频会发送给与会的每个人,假设我与每个与会者之间都建立的是P2P通道,视频帧都经过P2P通道传送。现在的问题时,每个P2P通道的质量是不一样的,有的可能很快,有的可能很慢。我们假设到与会者A的通道非常慢,到其他与会者的通道质量都很好。那么,我们来分析一下在两种广播模式下,可能出现的情况。 1.同步广播模式 在同步广播模式下,只有当某个视频帧在所有的通道上都发送完毕时,才会去发送下一个视频帧。这样就会出现因为与A之间的通道缓慢,而导致其他与会者看到自己的视频出现卡或不连贯的情况。 2.异步广播模式 在异步广播模式下,视频帧在所有的通道上都是异步发送的,这样A通道的缓慢不会影响到其它的与会者。OMCS采用的就是这种模式。 但是,由于通道A的缓慢,生产视频帧的速度远大于通道A消费视频帧的速度,这就导致了需要使用更多的内存来缓存那些来不及发送的视频帧。就客户端进程看来,其所占用的内存会不断增加,就像内存泄漏一样。 如何解决这个问题了?OMCS给出的方案是可以选择在通道繁忙时丢弃帧。通过将IMultimediaManager的AllowDiscardFrameWhenBroadcast属性设置为true,便可启用这种方案。 AllowDiscardFrameWhenBroadcast被启用后,当一个新的视频帧要通过某个P2P通道发送给对应的与会者时,会先检测一下该P2P通道是否繁忙,如果繁忙,就取消该视频帧在这个通道上的发送。这样,就避免了内存无线增长的情况。但在这种方案下,那些通道比较慢的与会者,因为丢弃视频帧的原因,看到别人的视频可能就会出现马赛克、卡、不连续的现象。
OMCS 网络语音视频框架是集成了语音通话、视频通话、远程桌面观看与协助、电子白板编辑与观看等多种媒体于一身的跨平台(.NET、Android、iOS)网络多媒体框架,实现了多媒体设备【麦克风、摄像头、桌面、电子白板】的采集、编码、网络传送、解码、播放(或显示)等相关的一整套流程,且可智能地根据网络状况实时调整帧频、清晰度、并优先保证语音通话效果。您只要连接到OMCS服务器,就可以随时访问任何一个在线用户的多媒体设备。基于OMCS语音视频聊天框架,您可以快速地开发视频聊天系统、视频会议系统、远程医疗系统、远程教育系统、网络监控系统等等基于网络多媒体的应用系统。(可跳过概要介绍,直接进入开发手册目录 或 Demo下载) 一.OMCS语音视频聊天框架功能简介 1.Owner与Guest 任何一个OMCS的Client都有两种身份:Owner和Guest。 当一个Client作为Owner时,它提供本地的摄像头、话筒、桌面、电子白板等多媒体设备供其它的Client访问。 而当一个Client访问其它Client提供的多媒体设备时,则该Client就是以Guest的身份出现。 2.单向连接,1对1,1对N,N对1,N对N的关系 一个Client可以同时访问多个在线Client的多媒体设备;而一个Client提供的某个多媒体设备,也可以同时被多个其它的Client同时访问。其基础是单向连接(比如当A访问B的摄像头时,B不用访问A的摄像头),由单向连接就可以组合成1对1,1对N,N对1,N对N的关系, 这样就非常灵活。 像监控一个摄像头这样的系统直接使用单向连接就可以;两个人视频聊天就是1对1的关系;在线教育系统中,老师讲学生听,就是1对N的关系;同时监控多个摄像头就是N对1的关系;而视频会议则是“N对N的关系”。 3.P2P通道 当两个Client之间相互通信时,OMCS底层会自动尝试P2P连接,如果P2P通道能创建成功,该两个客户端之间的后续通信都经过P2P通道进行。即使P2P通道是基于UDP的,OMCS也会保证P2P通信的可靠性。 4.信道分离 在某些具体的应用中,我们可能需要把信道依据数据的类型进行分离。比如,在视频会议系统中,希望能将传送语音的信道独立出来,以避免其它类型数据传送时可能产生的拥挤而影响到语音的流畅性。这种情况下,我们就可以将语音数据分离到一条专用的信道进行传送。 5.超简单的编程模型 当基于OMCS语音视频聊天框架进行开发时,如果要访问其它客户端提供的多媒体设备,我们只需要从工具箱中拖拽对应的连接器控件/组件到UI上,然后调用其Connect方法连接到目标设备即可。Connect方法会返回连接的结果,如果连接成功,则该连接器控件/组件将会正常工作(比如,CameraConnector控件将会显示目标摄像头捕捉到的视频)。 另外,我们已经提供了完整的OMCS服务端程序,即OMCS.Server.exe,在使用时,只要部署该程序并启动它即可。 6.与应用集成 OMCS解决的仅仅是多媒体设备的管理、连接、显示/播放、控制等问题,并没有掺杂具体的业务逻辑。所以,当与具体的应用集成时,通常OMCS的服务端是独立的,而OMCS的客户端dll将被嵌入到具体应用的客户端程序中,就像下面一样: (如果您只需要采集本地语音视频数据,敬请了解 MCapture。如果您需要录制语音视频,敬请了解 MFile。) 二.OMCS语音视频聊天框架技术特点 1.视频通话 (1)支持160*120、320*240、480*360、640*480、720p、1080p等多种采集分辨率。可在运行时,动态修改该分辨率。 (2)支持高、中、低三种视频编码质量。 (3)支持帧频1~25fps。 (4)当网络拥塞时,主动弃帧。 (5)根据网络状态,自动调整视频的编码质量。 (6)可以以位图格式获取当前视频帧。 (7)支持多种视频设备:普通摄像头、usb摄像头、虚拟摄像头、视频卡等。 2.音频通话 (1)支持高、中、低三种音频编码质量。 (2)支持回音消除(AEC)、静音检测(VAD)、噪音抑制(DENOISE)、自动增益(AGC)等网络语音技术。 (3)最多可支持16路混音。 (4)自适应的JitterBuffer,根据网络状态,动态调整缓冲深度。 (5)如果同时开启音频和视频会话,则自动同步视频画面与声音。 (6)在网速慢时,自动调整视频的质量,优先保证音频的清晰和连贯。 (7)根据网络状态,自动切换语音数据到质量更高的网络通道,保证语音通话效果。 3.远程桌面 (1)支持高、中、低三种视频编码质量。 (2)根据机器性能和网速自动选择帧频。 (3)可动态调整屏幕分辨率。 (4)提供观看模式和控制模式两种选择。 (5)当网络拥塞时,主动弃帧。 (6)根据网络状态,自动调整远程桌面的清晰度。 4.电子白板 (1)支持常用的视图元素:直线、曲线、箭头、矩形、三角形、椭圆、文字等;支持视图元素的上下对齐,左右对齐。 (2)可修改边框颜色、填充颜色、线条粗细、线条虚实、显示比例。 (3)可插入图片、截屏,可将整个白板保存为位图。 (4)支持课件:上传课件、打开课件、删除课件,课件翻页等。且这些操作会自动同步到连接到了同一白板的各个客户端。 (5)提供观看模式和操作模式两种选择。 (6)激光笔:OMCS会将老师/主讲人的激光笔位置自动同步到各个客户端。 (7)多个Guest可以同时观看或操作同一个Owner的白板。 三.OMCS Demo运行截图 视频/音频连接器 截图: 电子白板连接器 截图 -- 不使用课件: 电子白板连接器 截图 -- 使用课件: 远程桌面连接器 截图: 关于OMCS语音视频聊天框架更详细的介绍,请参见 这里。 下载免费版本的OMCS 以及 demo源码 版权声明:本文为博主原创文章,未经博主允许不得随意转载。
随着互联网越来越普及,以及物联网的兴起,IPv4地址已远远不够用,IPv6的普及将是不可避免的趋势。以前,我们的大部分socket程序几乎都是针对IPv4而开发,如果不做升级重构,那么使用IPv6地址的客户端将无法使用服务端提供的服务。如何才能像ESFramework一样,使服务端和客户端都可以同时支持IPv6了?使我们的P2P打洞也兼容IPv6了?下面我们将要点一一点出。 首先,要了解两个最基础的事实: (1)通信的双方,无论是服务端与客户端之间,或是客户端与客户端之间的P2P通信,必须使用相同的协议版本 -- 要么都是IPv4,要么都是IPv6。 (2)在没有特别安装附件的情况下,有的OS可能只支持IPv4,有的可能只支持IPv6,有的即支持IPv4也支持IPv6。可以通过Socket类的OSSupportsIPv6和OSSupportsIPv4属性来作判断。 一.TCP服务端 要让TCP服务端即能够接收IPv4地址的客户端的请求,也能接收IPv6地址客户端的请求,前提是服务器的OS即支持IPv4也支持IPv6。默认的,windows 2003 Server 是只支持IPv4的,可以通过安装协议来使其支持IPv6。 然后,写服务端程序时,必需同时监听本机IPv4地址和IPv6地址,并且是监听这两个地址的同一个端口。比如,像下面这样: int port = 9900; TcpListener tcpListenerV4 = new TcpListener(IPAddress.Any, port); TcpListener tcpListenerV6 = new TcpListener(IPAddress.IPv6Any, port); 如此,客户端无论是使用的IPv4还是IPv6,其向服务端发起连接请求时,都可以被服务端接受。 二.TCP客户端 我们现在假设服务端程序已经兼容了IPv6,并且其提供服务的IPv4地址为192.168.0.104,IPv6地址为fe80::14d8:a209:89e6:c162%14。 那么TCP客户端在与服务端建立连接之前,要看本地OS对IPv4和IPv6的支持情况: (1)如果本地OS仅支持IPv4,或者同时支持IPv4和IPv6,那么简单地,就让其连接到服务器的IPv4地址。示例代码如下所示: TcpClient client = new TcpClient(AddressFamily.InterNetwork); client.Connect("192.168.0.104", 9900); (2)如果本地OS仅支持IPv6,那么,就让其连接到服务器的IPv6地址。示例代码如下所示: TcpClient client = new TcpClient(AddressFamily.InterNetworkV6); client.Connect("fe80::14d8:a209:89e6:c162%14", 9900); 三.UDP 对于UDP而言,服务端和客户端可以采用完全一样的模型。要让基于UDP的应用程序兼容IPv6,会稍微复杂一些。 (1)需要创建两个UdpClient实例,一个用于IPv4,一个用于IPv6。示例代码如下所示: int port = 9800; UdpClient udpClient4 = new UdpClient(port, AddressFamily.InterNetwork); UdpClient udpClient6 = new UdpClient(port ,AddressFamily.InterNetworkV6); (2)需要在两个UdpClient实例上调用接收数据的方法,来接收数据。(3)发送数据时,需要根据目标地址是IPv4还是IPv6,来选择正确的UdpClient实例进行发送。示例代码如下所示: public void Send(byte[] data, IPEndPoint endPoint) { if (endPoint.AddressFamily == AddressFamily.InterNetwork) { this.udpClient4.Send(data, data.Length, endPoint); } else { this.udpClient6.Send(data, data.Length, endPoint); } } 上面的示例,我们是假设当前的OS同时支持IPv4和IPv6,如果仅仅支持其中的一个,那么就应该只创建udpClient4或udpClient6一个实例。 四.P2P与IPv6 如果我们的TCP客户端以及UDP都按照了上面类似的方式进行了重构升级,那么,无论是基于TCP的P2P打洞,还是基于UDP的P2P打洞,其逻辑代码都不需要做任何修改,就可以完全兼容IPv6了。 本文只是列出了将Socket应用程序重构升级使其支持IPv6的要点,在实际实现的过程中,还有很多的细节需要处理,才能在现实的复杂环境中正常运行。这里就不再赘述了,有疑问的朋友可以留言讨论。谢谢。
一.缘起 最近做一个服务端程序,系统运行时,在特定的时候会启动一个通知线程,通知线程执行的方法经简化后就是如下的FirstStateNotifyThread: AutoResetEvent autoResetEvent = new AutoResetEvent(false); private void FirstStateNotifyThread() { this.logger.LogWithTime("进入通知线程"); if (this.autoResetEvent.WaitOne(this.timeoutInMsecs)) { //...... } else { //...... } } 通知线程中用到了AutoResetEvent以等待某个事件完成以达到同步的目的。启动线程的方法如下: CbGeneric cb = new CbGeneric(this.FirstStateNotifyThread); cb.BeginInvoke(null, null); 开发、调试、测试、部署到自己的测试服务器 都一切运行正常。当部署到正式的服务器上运行时,发现需要启动线程的时刻到来时,没有出现“进入通知线程”的日志,即FirstStateNotifyThread方法没有被执行,线程没有被启动。 二.追踪 于是,我换了一种启动线程的方式,像下面这样: Thread thread = new Thread(new ThreadStart(this.FirstStateNotifyThread)); thread.Start(); 情况不仅依旧,而且当要启动线程时,整个进程异常退出了,弹出的提示框内容是“程序遇到问题,将被关闭”。 然后,我再换一种方式: ThreadPool.QueueUserWorkItem(new WaitCallback(this.FirstStateNotifyThread1) ; 仍然一样,也导致进程退出。 郁闷了,并且只有这台服务器上才会出现,其也是windows 2003 server 系统,是怎么回事了? 接着,我把FirstStateNotifyThread 方法中的逻辑代码全部去掉,只留一句写日志的代码,结果,可以正常执行。就这样不断地增加业务代码,最后问题定位到了autoResetEvent.WaitOne方法,如果注释掉这一句,就OK,开启这一句,就导致执行FirstStateNotifyThread 的线程无法启动。 大概找到问题的位置后,我尝试使用AutoResetEvent的另一个WaitOne重载方法: autoResetEvent.WaitOne(this.timeoutInMsecs ,false) 使用这个重载方法后,在正式的服务器上也可以顺利的启动FirstStateNotifyThread 线程了。 三.暂时的结论 问题看似解决了,但是问题的根源在哪里了?我用reflector查看了AutoResetEvent的WaitOne方法的源码,一起来看看: [TargetedPatchingOptOut("Performance critical to inline this type of method across NGen image boundaries")] public virtual bool WaitOne(int millisecondsTimeout) { return this.WaitOne(millisecondsTimeout, false); } public virtual bool WaitOne(int millisecondsTimeout, bool exitContext) { if (millisecondsTimeout < -1) throw new ArgumentOutOfRangeException("millisecondsTimeout", Environment.GetResourceString("ArgumentOutOfRange_NeedNonNegOrNegative1")); return this.WaitOne((long) millisecondsTimeout, exitContext); } 第一个WaitOne方法直接调用了第二个重载的WaitOne方法,这没什么问题。焦点在于第一个WaitOne方法标记了TargetedPatchingOptOut这样一个Attribute,查询MSDN知道:TargetedPatchingOptOut是用于指示内联(inline),熟悉C或C++的朋友对这个词应该非常熟悉。根据前面的步步验证,可以肯定的是,在我们正式的服务器上要加载或执行内联了WaitOne的代码镜像时,出现了异常。至于是什么异常,代码中使用try/catch捕获不到。 之后,我又测试了ManualResetEvent,也存在同样的情况。问题的根源可能已经涉及到了CLR或windows程序执行,但还是可以总结一点经验:为了使ManualResetEvent/AutoResetEvent在所有的机器上都能正常运转,请使用带有两个参数的WaitOne方法。 有哪位朋友能知道更多内幕原因的,还望留言不吝赐教,谢谢。