VC++实现的仿QQ和飞秋并支持语音视频白板屏幕共享的即时聊天软件

showy

发布日期: 2018-10-02 20:36:20 浏览量: 778
评分:
star star star star star star star star star star
*转载请注明来自write-bug.com

第2章 系统分析及开发技术说明

2.1 需求分析

2.1.1 功能需求分析

  • 用户端的基本聊天信息发送,这些基本聊天信息包括文本和图片。文本和图片聊天是聊天软件最基础的功能。用户通过输入IP来查找用户,并申请加为好友,在对方同意加为好友后,在线用户列表就会更新用户,把加入的用户添加到用户列表中。这样,两个用户之前就可以实现通信了。在信息传输中,预计利用TCP/IP协议中的UDP协议,这是面向无连接的协议,但发送速度快,用于聊天信息传输用适合。
  • 用户端的音视频数据传输,这是本设计的扩展功能。用户可以正常通信后,就可以选择是否进行语音或视频聊天。本功能也将采用UDP协议,UDP协议可能会丢失数据,但对于音视频聊天需要传输大量数据但又允许丢失少量数据的情况下,UDP的快速发送信息的特点就得到很好的体现。
  • 用户端文件传输的功能,用户之间可以断点续传文件。在传文件之前,首先创建一信息文件,记录文件传送的一些信息,并根据传输的数据量实时修改。如果没有传完,下次就可以继续打开这个信息文件,接着上次的进度传输。因为文件传输入要求数据不能出错,因此此模块采用TCP协议。
  • 用户端之间白板和共享屏幕的功能,这个功能有些类似视频的传输,因些并不是很难,可以在视频传输的功能上加以修改。
  • 用户之间后台的连接,每个用户隔指定时间会向用户列表中的每一用户发送消息,查看用户是否在线,如果不在线,就更新用户列表,删除用户。

2.1.2 数据需求分析

  • 客户端之间聊天信息。在控件上显示时格式化,更易于用户的查看自己发送或接收到的信息。
  • 在线用户列表信息。服务器端存放在适当的空间中,在发送给客户端时,对信息列表进行格式化,便于客户端提取信息。
  • 客户端向服务器端发送的确认在线信息。包括客户端刚刚启动时的初始化信息和在使用过程中的确认在线信息。
  • 其它程序内部可能需要设计的数据结构体。

2.1.3 性能需求分析

  • 可靠性高,能在由于系统问题或其它原因产生错误后,作出相对应处理,比如网络初始化失败、服务器不在线等,可以提示用户安全退出本程序,在出现不可知的错误以后,可以尽量安全的退出程序。在程序的设计过程中,要求能尽可能多的设想到用户使用过程中可能发生的事件,并能在判断事件后做出相应的处理,使程序具有较高的容错性能。
  • 宜操作性,程序简单易懂,容易上手使用。设计界面是,简化界面的复杂性,模拟QQ等现有即时通讯工具的界面,使用户能很容易看懂并使用。
  • 开发文档易理解,保证以后自己二次开发或他人接手开发时,能够清晰的理解整个系统的设计思路和实现细节。
  • 模块化设计此软件的功能,不同的模块实现不同的功能,使得软件易于以后的维护与扩展,在以后可以更好的完善本软件的功能,更方便于在工作中的应用。

2.1.4 运行需求分析

  • 用户界面:程序较小,启动速度快,无启动界面。在本地局域网中使用,所以无需进行用户登录,无需认证界面,启动后的应用界面要清爽,设计要简单明了,要具有较高的易用性。
  • 故障处理:在遇到可预知的故障与情况时,能提示用户并自动退出;在遇到不可预知的故障时能安全退出。

2.4 Winsock网络编程

Windows Sockets是从Berkeley Sockets扩展而来的,其在继承Berkeley Sockets的基础上,又进行了新的扩充。这些扩充主要是提供了一些异步函数,并增加了符合WINDOWS消息驱动特性的网络事件异步选择机制。

Windows Sockets由两部分组成:开发组件和运行组件。

  • 开发组件:Windows Sockets 实现文档、应用程序接口(API)引入库和一些头文件。
  • 运行组件:Windows Sockets 应用程序接口的动态链接库(WINSOCK.DLL)。

2.4.1 Socket

套接字(Socket)最初是由加利福尼亚大学Berkeley分校为UNIX操作系统开发的网络通信接口,随着UNIX操作系统的广泛使用,套接字成为当前最流行的网络通信应用程序接口之一。90年代初,由SunMicrosystems,JSBCorporation,FTP software,Microdyne和Microsoft等几家公司共同制定了一套标准,即Windows Sockets规范。

Windows Sockets API是Microsoft Windows的网络程序设计接口,它在继承了Berkeley Sockets主要特征的基础上,又对它进行了重要扩充。这些扩充主要是提供了一些异步函数,并增加了符合Windows消息驱动特性的网络事件异步选择机制。这些扩充有利于应用程序开发者编制符合Windows编程模式的软件,它使在Windows下开发高性能的网络通信程序成为可能。

Socket实际上是指一个通信端点,借助于它,用户所开发的Socket应用程序,可以通过网络与其它Socket应用程序进行通信。

近年来,随着计算机网络与Windows 95的流行,许多用户所开发的应用程序需要实现网络间的数据通信。

2.4.2 开发Windows Sockets网络通信程序的软、硬件环境

所采用的操作系统软件可以是Windows 95,2000,XP,也可以是Windows NT,因为它们都支持Windows Sockets API,在以下的介绍中,我们将以在Windows XP环境下的开发为例。

所采用的编程语言一般可选目前较流行使用的可视化和采用面向对象技术的Microsoft Visual C++ 6.0。Visual C++ 6.0可在Windows XP或Windows NT环境下运行,其开发系统增加了全面集成的基于Windows 的开发工具以及一个基于传统C/C++开发过程的“可视化”用户界面驱动模型。Visual C++ 6.0中的Microsoft基类(MFC,即MicrosoftFoundation Class)库是一系列C++类,其中封装着为Microsoft Windows操作系统系列编写应用程序的各种功能 。在有关套接字方面,Visual C++ 6.0对原来的Windows Sockets库函数进行了一系列封装,继而产生了CSocket 、CSocketFile等类,它们封装着有关Socket的各种功能。

所采用的网络通信协议一般是TCP/IP。Windows XP和Windows NT都带有该协议。但是,所开发的网络通信应用程序并不能直接与TCP/IP核心打交道,而是与网络应用编程界面Windows Sockets API打交道。Windows Sockets API则可直接与TCP/IP核心进行沟通。TCP/IP核心协议连同网络物理介质(如网卡)一起,都是提供网络应用程序间相互通信的设施。

2.4.3 CSocket类编程模型

使用CSocket对象涉及CArchive和CSocketFile 类对象。以下介绍的针对字节流型套接字的操作步骤中,只有第三步对于客户方和服务方操作是不同的,其他步骤都相同。

  • 构造一个CSocket对象。
  • 使用这个对象的Create()成员函数产生一个socket对象。在客户方程序中,除非需要数据报套接字,Create()函数一般情况下应该使用默认参数。而对于服务方程序,必须在调用Create时指定一个端口。需要注意的是,Carchive类对象不能与数据报(UDP)套接字一起工作,因此对于数据报套接字,CAsyncSocket和CSocket 的使用方法是一样的。
  • 如果是客户方套接字,则调用CAsyncSocket∷Connect()函数与服务方套接字连接;如果是服务方套接字,则调用CAsyncSocket∷Listen()开始监听来自客户方的连接请求,收到连接请求后,调用CAsyncSocket∷Accept()函数接受请求,建立连接。请注意Accept()成员函数需要一个新的并且为空的CSocket对象作为它的参数,解释同上。
  • 产生一个CSocketFile对象,并把它与CSocket 对象关联起来。
  • 为接收和发送数据各产生一个CArchive对象,把它们与CSocketFile对象关联起来。切记CArchive是不能和数据报套接字一起工作的。
  • 使用CArchive对象的Read()、Write()等函数在客户与服务方传送数据。
  • 通讯完毕后,销毁CArchive、CSocketFile和CSocket对象。

2.4.4 用VC6.0进行Windows Sockets程序开发的技术要点

  • 同常规编程一样,无论服务器方还是客户方应用程序都要进行所谓的初始化处理,这部分工作仍可采用消息驱动机制来先期完成。
  • 一般情况下,网络通信程序是某应用程序中的一模块。在单独调试网络通信程序时,要尽量与采用该通信模块的其它应用程序开发者约定好,统一采用一种界面形式,即单文档界面SDI、多文档界面MDI和基于对话框界面中的一种(这在使用AppWizard形成项目[Project]文件时有提示),尽管这并非必须,但可使通信模块在移植到所需的应用程序时省时省力,因为Visual C++ 6.0这种可视化语言在给我们提供方便的同时,也给我们带来某些不便,譬如所形成的项目文件中的许多相关文件与所采用的界面形式密切联系,许多消息驱动功能,随所采用的界面形式不同而各异。当然,也可将通信模块函数化,并形成一个动态连接库文件(DLL文件),供主程序调用。
  • 以通信程序作为其中一个模块的应用程序往往不是在等待数据发送或接收完之后再做其它工作,因而在主程序中要采用多线程(Multithreaded)技术。即将数据的发或收,放在一个具有一定优先级(一般宜取较高优先级)的辅助线程中,在数据发或收期间,主程序仍可进行其它工作,譬如利用上一个周期收到的数据绘制曲线。Visual C++ 6.0中的MFC提供了许多有关启动线程、管理线程、同步化线程、终止线程等功能函数。
  • 在许多情况下,要求通信模块应实时地收、发数据。譬如调用之的主程序以0.5秒为一周期,在这段时间内 ,要进行如下工作:接收数据,利用收到的数据进行运算,将运算结果发送到其它计算机节点,周而复始。我们在充分利用Windows Sockets的基于消息的网络事件异步选择机制,用消息来驱动数据的发送和接收的基础上,结合使用其他措施,如将数据的收和发放在高优先级线程,在软件设计上,安排好时序,尽量避免在同一时间内,双方都在向对方发送大量数据的情况发生,保证网络要有足够的带宽等,成功地实现了数据传输的实时性。

第3章 详细设计

本章将从各个方面介绍本系统的设计。先从基本框架的设计出发,然后逐步介绍好友管理、聊天模块、聊天室模块、传送文件模块、共享屏幕模块、白板模块、音、视频模块和调试模块,所以本章是本论文的重点。

3.1 基本框架设计

本节内容将介绍除各个功能模块外的设计,包括界面上的处理、保持好友在线列表等的处理。有些内容可能并不属性框架设计,但这些内容也不具有单独使用一节来介绍的必要,所以把这些内容也一并放到这些节来介绍。这也是为了区分设计周围的处理与各个功能模块的处理。

3.1.1 宏和数据结构的定义

程序中用到了很多宏和数据结构,这些宏和数据结构在多个模块中都有用到,因此程序中专门新建一个头文件Global.h,此头文件里是程序中很到的宏和数据结构的定义。在StdAfx.h文件包含Global.h,在程序其他地方都可以使用Global.h中的宏和定义的数据结构。这样处理还有一个好处,如果需要修改某些宏的值,可以直接在Global.h中修改,而不用到处去找宏的定义,方便和快捷。

3.1.2 程序配置文件

程序中很多信息需要保存,比如用户名和热键,因些程序用到了配置文件,默认的配置文件名为conf.ini。程序用读取和写入配置文件系列函数来管理此配置文件。

3.1.3 主界面初始化

用过QQ的人都知道,QQ主面板总是处于其他程序的上面,而且QQ在任务栏没有图标,而是把图标放到了托盘区,另外,我们还可以按Ctrl+Alt+Z默认的快捷键隐藏和显示QQ主面板。不但QQ是这样处理的,很多聊天软件都采用此种处理方式。本设计也不例外,同样也要达到这样的目的。

下面从各个方面来说明本设计的处理方式:

1.不在任务栏显示图标

  1. CDialog dlgParent;
  2. dlgParent.Create( IDD_DIALOG_BG );
  3. dlgParent.ShowWindow( SW_HIDE );
  4. CInstantMessagingDlg dlg( &dlgParent );
  5. m_pMainWnd = &dlg;
  6. ModifyStyleEx( WS_EX_APPWINDOW, 0 );

上面代码就达到了程序主界面不在任务显示的目的。首先,我们创建一个对话框,并隐藏此对放框,然后把这个对话框作为主界面对话框的父窗口,然后在主界面对话框的初始化函数中修改其风格,去掉WS_EX_APPWINDOW风格。这样,主界面就不会出现在任务了。

2.将主界面放在最上层

将程序放到顶层,很多程序都有这功能,比如金山词霸等,实现起来其实很简单,只用一条语句就可以达到目的:

  1. SetWindowPos( &wndTopMost, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE );

第一个参数就是将程序放到所有非顶层窗口的顶层,如果有多个程序都是顶层窗口,那么他们谁在上面,就要看当前谁是激活的窗口。最后一个参数,是用位或|组后起来的,从字面意思上我们就能理解到这是不移动不改变大小的意思,忽略了当中的4个参数。

3.热键的处理

设计中默认的热键是Ctrl+Alt+Z,当然程序允许用户自己定义热键,自定义的热键将保存在conf.ini文件中。热键的功能可以隐藏、显示主界面,有消息到达时,按热键也可以打开聊天对话框。

  1. ::RegisterHotKey( m_hWnd, IDHOTKEY, m_wModifiers, m_wVirtualKeyCode );

使用全局函数RegisterHotKey可以注册热键,如果注册的热键没有被其他程序占用,那么注册成功。注册成功后,如果按热键,那么程序就会接受到WM_HOTKEY消息,因此我们还需要自己处理WM_HOTKEY消息:

  1. void OnHotkey( WPARAM wParam, LPARAM lParam );
  2. BEGIN_MESSAGE_MAP(CInstantMessagingDlg, CDialog)
  3. ON_MESSAGE( WM_HOTKEY, OnHotkey )
  4. //}}AFX_MSG_MAP
  5. END_MESSAGE_MAP()

在消息映射中,我们用OnHotkey()函数来处理WM_HOTKEY消息。

  1. void CInstantMessagingDlg::OnHotkey( WPARAM wParam, LPARAM lParam )
  2. {
  3. if( this->IsWindowVisible() )
  4. {
  5. ShowWindow( SW_HIDE );
  6. }
  7. else
  8. {
  9. ShowWindow( SW_SHOW );
  10. ::SetForegroundWindow( m_hWnd );
  11. }
  12. }

在OnHotkey()函数中判断主界面是否是可见的,如果是可见的那么隐藏起来,否则显示,并且把主界面设为前景窗口。

4.最小化和关闭按钮的处理

我们希望单击程序右上角的最小化按钮时,程序隐藏起来,而单击关闭按钮时,程序会提示是否退出,而不会悄无声息的退出。

  1. void CInstantMessagingDlg::OnSysCommand(UINT nID, LPARAM lParam)
  2. {
  3. if( nID == SC_MINIMIZE )
  4. {
  5. this->ShowWindow( SW_HIDE);
  6. }
  7. else
  8. {
  9. CDialog::OnSysCommand(nID, lParam);
  10. }
  11. }
  12. void CInstantMessagingDlg::OnCancel()
  13. {
  14. if( IDOK == MessageBox( "要退出吗?", "退出", MB_OKCANCEL | MB_ICONINFORMATION | MB_DEFBUTTON2 ) )
  15. {
  16. DestroyWindow();
  17. }
  18. }

在程序中处理OnSysCommand()函数和OnCancel()函数就实现了我们要的功能。

5.托盘图标的显示

至此,程序已不在任务栏显示图标,已是最顶层窗口,而且也已有热键功能,但是还没有实现托盘图标的显示。实现托盘图标的代码如下:

  1. NOTIFYICONDATA m_nid;
  2. HICON hIcon = AfxGetApp()->LoadIcon( STATE_ONLINE );
  3. m_nid.hIcon = hIcon;
  4. m_nid.hWnd = m_hWnd;
  5. m_nid.cbSize = sizeof( NOTIFYICONDATA );
  6. m_nid.uCallbackMessage = WM_SHELLNOTIFY;
  7. m_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
  8. m_nid.uID = IDR_MENU1;
  9. sprintf( m_nid.szTip, "即时聊天软件" );
  10. Shell_NotifyIcon( NIM_ADD, &m_nid );

这样我们就在托盘区显示了STATE_ONLINE的图标,把鼠标移动到图标上一会,还会出现“即时聊天软件”的提示框。如果你们对图标有单击和双击等操作,程序会收到WM_SHELLNOTIFY消息,因此,我们还必须处理WM_SHELLNOTIFY消息。

  1. void OnShellNotifyProc( WPARAM wParam, LPARAM lParam );
  2. BEGIN_MESSAGE_MAP(CInstantMessagingDlg, CDialog)
  3. //{{AFX_MSG_MAP(CInstantMessagingDlg)
  4. ON_MESSAGE( WM_SHELLNOTIFY, OnShellNotifyProc )
  5. //}}AFX_MSG_MAP
  6. END_MESSAGE_MAP()
  7. void CInstantMessagingDlg::OnShellNotifyProc( WPARAM wParam, LPARAM lParam )
  8. {
  9. if( lParam == WM_LBUTTONDBLCLK )
  10. {
  11. }
  12. else if( lParam == WM_RBUTTONUP )
  13. {
  14. }
  15. }

与热键处理一样,OnShellNotifyProc()函数响应我们对图标的操作,其中lParam参数表示消息号,在本设计中只处理左键双击(显示主界面)和右键单击(弹出菜单)。

在托盘添加图标,退出程序前,如果没有从托盘删除图标,那么托盘区的图标会一直保留下来,直到鼠标移过托盘区引起托盘区的重绘,这当然不是我们所希望的结果。

  1. void CInstantMessagingDlg::OnDestroy()
  2. {
  3. // 删除在托盘建立的图标
  4. ::Shell_NotifyIcon( NIM_DELETE, &m_nid );
  5. CDialog::OnDestroy();
  6. }

以上代码在程序退出时调用,从托盘从删除图标。

6.只允许运行唯一实例

这点与QQ不同,在一台机子上可以运行多个QQ,但本程序只允许运行一个实例。只允许运行一个实例,有多种方法,本设计采用的是创建命名事件的方法:

  1. HANDLE hEvent = ::CreateEvent( NULL, FALSE, FALSE, "InstantMessaging" );
  2. if( hEvent )
  3. {
  4. if( ERROR_ALREADY_EXISTS == GetLastError() )
  5. {
  6. return FALSE;
  7. }
  8. }

事件与普通变量不一样,普通变量只在运行的当前程序中有效,而事件在整个系统中都有效。当首次运行程序时,会创建一个名为“InstantMessaging”的事件,这个事件在系统范围内有效,当再次运行程序时,程序会尝试着创建同名的事件,因为之前已经创建了这个事件,因此系统会返回之前创建事件的句柄,但GetLastError()会返回ERROR_ALREADY_EXISTS,表明需创建的事件之前已经创建,为了保证只允许一个实例,这个实例就不再允许运行,直接返回,退出程序。

3.1.3 主界面布局

程序主界面如下:

左上角显示的是自己的头像、状态和昵称;右上角的列表框是查找IP输入框,下面是添加按钮;在下面一点的列表框是自己的址列表框,显示了自己的所有IP;主界面中央是用户列表框;最下面是4个功能按钮。

用户列表框是一列一列的显示添加的好友,最左边是好友的头像;中部上边是好友的昵称,下面是好友的IP;右下角是删除好友按钮和摄像头按钮,当然好友必须有摄像头才会显示摄像头按钮。

有两种方式添加联系人:

  • 在右上解的查找IP输入框里输入IP,然后单击下面的添加按钮。也可以从IP输入框里选择以前加过的好友IP。程序允许保存10个最近联系人的IP,当新添加联系人时,如果已保存了10个联系人的IP,程序会按照时间的先后顺序覆盖之前的IP。在列表框展开下拉列表后,可以按DELETE键删除选定的IP。
  • 选定一个自己的IP,然后单击“网段”按钮。此功能可以向选定IP的IP段发送添加请求的消息,这相当于批量添加好友的功能。

在主界面任何地点单击左键不放开,可以拖动程序;单击右键,会弹出菜单,用户选中相应的菜单项,可以执行相应的功能;在任何地点双击左键,可以打开“个人设置”对话框,如下:

最后一项“允许别人直接将我加为联系”的意思是别人添加我为好友时,不会弹出请求对话框而直接加为好友。

单击保存后,此对话框里的内容会保存到config.ini配置文件中。运行程序后,会从config.ini读取用户信息,并在主界面中作相应的设置。

在主界面,添加、聊天室、传送文件、共享屏幕和白板按钮都是自绘按钮,可以显示图片,有提示能力,当鼠标移动到其上一会儿后,会弹出提示框,而且这些按钮都具有XP风格,既鼠标滑过时会显示不同的状态。用户列表框也是自绘的,普通的列表控件无法显示我们所需的信息。自绘按钮和自绘列表框会作为一个单独的模块来介绍,这儿就不作过多的介绍。

3.1.4 自绘按钮

VC++6.0自带的按钮控件不具有XP风格,而且也不能显示图像,作为一款好的软件,应该有个好的界面。在程序的主界面上,主要的按钮都采用了自绘按钮,而不使用自带的按钮控件。

AdvButton.h和AdvButton.cpp是自绘按钮类的头文件和实现文件。

在自绘按钮类中定义了如下成员变量:

  1. int m_nState; // 按钮的状态
  2. CBitmap m_bmpNormal; // 正常图标
  3. CBitmap m_bmpHover; // 焦点图标
  4. CBitmap m_bmpDown; // 按下图标
  5. CBitmap m_bmpDisable; // 无效图标
  6. CToolTipCtrl m_pToolTipCtrl; // 提示类

m_nState表示当前按钮的状态,可以为宏:NORMAL,HOVER,DOWN,DISABLE,分别表示按钮正常状态、处于焦点状态、按下状态、无效状态,这4个宏的定义在实现文件。4个CBitmap的变量分别存储4种状态下的图像。m_pToolTipCtrl是提示工具控件类,既是鼠标在其上时,会弹出提示窗口。

要实现按钮自绘,必须更新按钮的风格为自绘,可以在按钮的属性中更改,也可以使用代码更改。重载PreSubclassWindow(),在这个函数中更改按钮风格并初始化m_pToolTipCtrl。

  1. void CAdvButton::PreSubclassWindow()
  2. {
  3. ModifyStyle( 0, BS_OWNERDRAW );
  4. CButton::PreSubclassWindow();
  5. m_pToolTipCtrl.Create( this, TTS_ALWAYSTIP );
  6. m_pToolTipCtrl.SetDelayTime( 100 );
  7. CString strText;
  8. GetWindowText( strText );
  9. m_pToolTipCtrl.AddTool( this, strText );
  10. }

VC++6.0中的ClassWizard不能为我们添加鼠标离开的消息,只能为我们添加鼠标移动、单击等消息,我们得自己为自绘按钮添加上鼠标离开的消息。

  1. TRACKMOUSEEVENT tme;
  2. tme.cbSize = sizeof( TRACKMOUSEEVENT );
  3. tme.hwndTrack = m_hWnd;
  4. tme.dwFlags = TME_LEAVE;
  5. ::_TrackMouseEvent( &tme );

以上代码告诉系统,当鼠标离开m_hWnd窗口时,向这个窗口发送一条WM_MOUSELEAVE消息。下面的处理方式与热键和托盘通知消息的处理方式一样,自定义这个消息处理函数就行了。

自绘按钮必须重载DrawItem()函数,在DrawItem()函数中根据m_nState的值可以贴上不同的图,表示按钮的一不同状态。

  1. void CAdvButton::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct)
  2. {
  3. if( lpDrawItemStruct->itemState & ODS_DISABLED )
  4. {
  5. m_nState = DISABLE;
  6. }
  7. switch( m_nState )
  8. {
  9. case NORMAL:
  10. DrawNORMAL();
  11. break;
  12. case HOVER:
  13. DrawHOVER();
  14. break;
  15. case DOWN:
  16. DrawDOWN();
  17. break;
  18. case DISABLE:
  19. DrawDISABLE();
  20. break;
  21. default:
  22. break;
  23. }
  24. }

DrawNORMAL() 、DrawHOVER ()、DrawDOWN() 、DrawDISABLE()分别画按钮的4种状态。当鼠标滑过或单击按钮时,更改m_nState的值,然后调用Invalidate(),强制按钮重绘。要使按钮无效,必须调用EnableWindow(FALSE )函数来更改按钮的状态,我们也就无法更改m_nState的值。lpDrawItemStruct->itemState的值表示了当前按钮的状态,可以检测lpDrawItemStruct->itemState,如果按钮是无效状态,则设置m_nState为DISABLE,否则不作改变。

在画按钮的状态时,使用到了TransparentBlt()函数,这个函数可以贴透明位图。在TransparentBlt()最后一个参数中指定掩码色,贴图时掩码色就不会贴出来。要使用此函数,必须导入 msimg32.dll,在程序使用如下语句导入:

  1. #pragma comment( lib, "MSIMG32.LIB" )

3.1.5 自绘好友列表框

普通的列表控件无法满足程序的要求,程序要求好友列表框可以显示好友的头像、好友昵称、好友IP和删除、摄像头按钮。

FriendsListCtrl.h和FriendsListCtrl.cpp是自绘列表框的头文件和实现文件。

定义的成员变量如下:

  1. CInstantMessagingDlg *m_pMainDlg; // 主对话框
  2. CImageList m_imageList; // 头像图像列表
  3. int m_nCamera; // 摄像头激活的序号
  4. int m_nDelIcon; // 删除按钮激活的序号
  5. int m_nCurSel; // 当前选中用户序号

与自绘按钮类似,在PreSubclassWindow()函数中更改列表框的风格为自绘:

  1. void CFriendsListCtrl::PreSubclassWindow()
  2. {
  3. ModifyStyle( 0, LVS_OWNERDRAWFIXED );
  4. CListCtrl::PreSubclassWindow();
  5. }

重载MeasureItem()函数更改列表框每一项的高度:

  1. void CFriendsListCtrl::MeasureItem( LPMEASUREITEMSTRUCT lpMeasureItemStruct )
  2. {
  3. lpMeasureItemStruct->itemHeight = DEFAULT_FRIENDSLIST_HEIGHT;
  4. }

宏DEFAULT_FRIENDSLIST_HEIGHT在Global.h文件定义,表示好友列表框每项的高度。

列表框中的鼠标离开消息与自绘按钮的实现是同一个原理,这儿就不再赘述。

在向好友列表框中添加好友时,主对话框调用好友列表框的AddUser()函数,参数为USER结构体,这个参数作为列表项的额外数据,这样重绘的时候再读取出这个额外数据就可以得到这一项的用户信息。

鼠标在好友列表框上移动时,判断鼠标是否在删除或摄像头(如果有摄像头)按钮范围内,如果在,就设置m_nDelIcon或m_nCamera为当前项的序号,否则就设置m_nCurSel为当前项的序号。

用户双击鼠标时,调用主对框的相应函数,并把项的序号传给此函数。如果是单击,先判断m_nDelIcon、m_nCamera值,如果不为-1,则选中了删除或摄像头按钮,调主对框相应函数执行相应操作。

在DrawItem()函数中,先得到额外的附加数据,既是添加项时作为参数传递的USER类型的变量,然后再根据m_nCurSel、m_nDelIcon和m_nCamera的值自绘。在自绘时,为了防止闪烁,程序用到了双缓冲技术。

双缓冲技术,就是先创建一个与目标设备兼容的内存设备上下文,在内存设置上下文中画图或进行其他处理,操作完成了,再一并把内存设备上下文的内容贴到目标设备上,这样就可以有效的防止闪烁。

3.2 好友管理

好友管理包括添加好友、删除好友以及与好友保持连接。

3.2.1 添加好友

添加好友的两种方式4.1节已经介绍过了,这一节介绍的是具体的实现。

首先然主对话框定义监听Socket并初始化:

  1. CListeningSocket *m_pLisSocket;
  2. m_pLisSocket = new CListeningSocket( this );
  3. m_pLisSocket->Create( DEFAULT_PORT, SOCK_DGRAM );

在添加按钮的响应函数中先判断IP地址是否合法,是否是自己的IP,是否已经添加此好友。如果可以添加此好友,则向此好友发起请求加为好友的请求:

  1. // 定义数据包
  2. DATAPACKET dataPacket;
  3. dataPacket.command = REQUEST_ADD;
  4. // 设置请求的用户结构
  5. USER user;
  6. user.bHasCamera = m_bCamera;
  7. user.nFace = m_wFace;
  8. memcpy( user.strName, m_strNickName.GetBuffer( MAXNICKNAMELENGTH + 1 ), MAXNICKNAMELENGTH + 1 );
  9. m_strNickName.ReleaseBuffer();
  10. // 分配空间
  11. UINT uDataLength = sizeof( DATAPACKET ) + sizeof( USER );
  12. BYTE *pSendData = new BYTE[ uDataLength ];
  13. memcpy( pSendData, &dataPacket, sizeof( DATAPACKET ) );
  14. memcpy( pSendData + sizeof( DATAPACKET ), &user, sizeof( USER ) );
  15. // 发送请求
  16. m_pLisSocket->SendTo( pSendData, uDataLength, DEFAULT_PORT, strFriendIP );
  17. delete pSendData;

以上代码中的宏都可以在Global.h头文件中找到。向好友发起请求的数据中,还包括自己的USER数据。

向对方发起请求后,对方的m_pLisSocket就会调用OnReceive()函数,程序中重载了CListeningSocket类的OnReceive()函数,在OnReceive()中调用主对对话框的OnListeningReceive()函数来接收网络数据。

在OnListeningReceive()函数中,根据DATAPACKET的command值来进行相应的处理,这儿是添加为好友的请法度,先得到发起请求的USER,再调用AddRequest()函数做相应处理。

在AddRequest()函数中,先进行相应的判断,如果具备加为好友的条件,根据是否允许直接加为好友的值是否弹出提示对话框。如果拒绝加为好友,则向发送者发送拒绝的消息。否则先把发起者的USER信息加入到好友列表中,再向请求者发送允许加为好友的消息,消息中包括自己的USER信息。

请求者收到对方的拒绝消息,会弹出对话框提示对方拒绝加为好友;如果收到的是允许加为好友的消息,则再得到一同发送过来的USER信息,再加入到列表中。

在InstantMessaging.h头文件中定了如下变量:

  1. CArray< USER, USER > m_arrFriends; // 好友列表

此数组保存的是已连接的好友,添加好友时会向这个变量里添加USER信息,并把USER添加到好友列表框中。

请求者添加好友流程图如下:

好友收到请求的流程图如下:

3.2.2 删除好友

删除好友比较简单,在服务器端向要删除的好友发送命令为OFFLINE的消息,然后从好友数组和好友列表框中删除此好友。

在客户端接受到OFFLINE的网络消息时,得到发送此消息的IP,然后从好友数据和好友列表框中删除与此IP相等的好友。

另外,当用户改变自己的状态为下线状态或关闭程序时,会调用SendOffLineMessage()函数向所有好友发送下线消息,并删除所有的好友。

3.2.3 与好友保持连接

此功能用到了定时器,间隔一定时间向所有好友发起请求保持连接的消息,并把发送过此消息的好友添加到一个临时好友列表中。好友收到请求保持连接的消息后,会发送回应保持连接的消息。程序收到回应保持连接的消息后,会从临时好友列表中删除对应的好友。在下一次定时器到的时候,程序检查临时好友列表,在临时好友列表中的好友都是没有回应的的好友,这些好友可能是因为程序不正常关闭而未向其他好友发送下线通知,程序就可以将这些好友删除。在程序初始化时设置一定时器:

  1. SetTimer( TIMER_CONNECT, DEFAULT_REFRESH_TIME, NULL );

在OnTimer()函数中进行如下处理:

  1. // 保持接连
  2. if( TIMER_CONNECT == nIDEvent )
  3. {
  4. // 删除没有回应的联系人
  5. for( int nIndex = 0; nIndex < m_arrFriends.GetSize(); nIndex++ )
  6. {
  7. USER userDel = m_arrFriends.GetAt( nIndex );
  8. if( m_strlstKeepConnent.Find( userDel.strIP ) )
  9. {
  10. m_listCtrlFriends.DeleteUser( nIndex );
  11. m_arrFriends.RemoveAt( nIndex );
  12. nIndex--;
  13. }
  14. }
  15. m_strlstKeepConnent.RemoveAll();
  16. // 分别发送保持连接的消息,将发送过的IP加入到m_strlstKeepConnent
  17. for( nIndex = 0; nIndex < m_arrFriends.GetSize(); nIndex++ )
  18. {
  19. USER user = m_arrFriends.GetAt( nIndex );
  20. DATAPACKET dataPacket;
  21. dataPacket.command = REQUEST_KEEPCONNECT;
  22. // 分配空间
  23. UINT uDataLength = sizeof( DATAPACKET );
  24. BYTE *pSendData = new BYTE[ uDataLength ];
  25. memcpy( pSendData, &dataPacket, sizeof( DATAPACKET ) );
  26. m_pLisSocket->SendTo( pSendData, uDataLength, DEFAULT_PORT, user.strIP );
  27. delete pSendData;
  28. m_strlstKeepConnent.AddTail( user.strIP );
  29. }
  30. }

3.3 聊天模块

聊天包括文字聊和图片聊天,本系统用到了Microsoft Rich Textbox Control 6.0控件,此控件支持RTF(Rich TextFormat)格式的内容,包括不同颜色、不同字体字号的文本和图片。要使用此控件,系统中必须注册了此控件,如果未注册此控件,那么打开聊天对话框时程序会死掉。为了解决这个问题,本设计在程序中检查系统是否注册过richtx32.ocx控件,如果没有注册,程序会先注册,代码如下:

  1. HKEY hKey;
  2. if( RegOpenKeyEx( HKEY_CLASSES_ROOT, "RICHTEXT.RichtextCtrl\\CLSID", 0, KEY_READ, &hKey ) != ERROR_SUCCESS )
  3. {
  4. HINSTANCE hLib = LoadLibrary( "RICHTX32.OCX" );
  5. // 控件不存在
  6. if( !hLib )
  7. {
  8. MessageBox( "RICHTX32.OCX控件未找到" );
  9. }
  10. else
  11. {
  12. // 获取注册函数DllRegisterServer地址
  13. FARPROC lpDllEntryPoint;
  14. lpDllEntryPoint = GetProcAddress( hLib, "DllRegisterServer" );
  15. // 注册richtx32.ocx控件
  16. lpDllEntryPoint();
  17. }
  18. }

聊天对话框如下:

聊天对话框用的Socket是主对话框的m_pLisSocket,当要发送消息时,调用主对话框的SendPreChatMessage()函数,主对话框接收到聊天消息时调用聊天对话框的ReceiveMessage()函数。

聊天对话框中定的发送和接收富文本框变量为:

  1. CRichText m_rtReceived;
  2. CRichText m_rtSend;

发送消息的主要代码如下:

  1. void CChatDlg::OnSend()
  2. {
  3. CTime time = CTime::GetCurrentTime();
  4. CString strTime = time.Format( "%H:%M:%S" );
  5. CString strSend = m_rtSend.GetTextRTF();
  6. // 发送聊天消息
  7. m_pMainDlg->SendPreChatMessage( m_userChat, strTime, strSend );
  8. }

单击“发送”按钮或CTRL+ENTER键,程序会调用OnSend()函数,首先得到发送的时间和发送的内容,得到的是RTF格式的内容,因此strSend中还包括了文字的格式和图片信息。然后调用主对话框的SendPreChatMessage()函数向m_userChat好友发送聊天消息。

接收到的消息主要代码如下:

  1. void CChatDlg::ReceiveMessage( LPCSTR szTime, LPCSTR szMessage )
  2. {
  3. CString strText;
  4. strText.Format( "%s(%s) %s\r\n ",
  5. m_userChat.strName,
  6. m_userChat.strIP,
  7. szTime );
  8. // 设置接收框
  9. m_rtReceived.SetSelStart( m_rtReceived.GetTextRTF().GetLength() );
  10. m_rtReceived.SetSelText( strText );
  11. m_rtReceived.SetSelRTF( szMessage );
  12. }

因为聊天用的Socket是主对话框的m_pLisSocket,因此是主对话框接收到的聊天消息。主对话框接收到聊天消息后,根据消息发送的来源IP把处理消息。如果与此IP的聊天窗口打开的,就直接调用ReceiveMessage()把消息放入接收文本框中,如果聊天窗口没有打开,则把消息追加到CMapStringToOb类型的变量m_mapIPToChat中,并在托盘区动态显示用户的头像。

聊天消息包括文字格式和图像信息,因此发送的数据可能很大,会超过Socket发送的最大数据长度,这样如果直接发送,会因为数据长度过大,而导致发送失败。所以在发送这种大数据量的消息时,本程序采用的分包发送的方式。

上面提到了发送的最大数据长度,m_pLisSocket是创建的UDP套接字,不像TCP一样可以发送随意大小的数据,UDP套接字只能发送小于一定大小的数据。UDP可以发送的最大数据量可以由下面的代码得到:

  1. WSADATA wsaData;
  2. if (!AfxSocketInit( &wsaData ))
  3. {
  4. AfxMessageBox(IDP_SOCKETS_INIT_FAILED);
  5. return FALSE;
  6. }

初始化WinSocket后,WSADATA结构中就有我们需要的信息,wsaData.iMaxUdpDg就是UDP可以发送的最大数据量。主对话框定义了一变量,设置成wsaData.iMaxUdpDg的值,在用UDP发送数据的时候,就可以根据这个值的大小来确定分包的大小。

聊天对话框发送消息时调用主对话框的SendPreChatMessage()函数,这个函数并不是一下子将消息发送出去,而是将消息保存起来,只发送一个通知消息告诉对方有数据需要发送,这个通知消息中带有消息的时间的消息的长度。

对方接收到这个通知消息时,会根据接收到的消息长度分配一个接收消息的临时空间,并把接收到的消息时间也保存。然后向消息发送者发送请求接收第一个数据包的请求。

消息发送者收到请求发送消息的消息后,会把相应的数据包发送给聊天对方。

对方接收到消息数据包后,把消息放入到接收消息的临时空间,并判断数据是否接收完毕。如果没有接收完毕,继续请求发送下一个消息数据包。如果接收完毕,播放声音,并判断与此IP对应的聊天窗口是否打开,是则调用ReceiveMessage()把消息放入到接收消息文本框,否则把消息保存起来,并动态显示托盘的图标,等待用户打开聊天窗口时再把消息放到接收文本框。

聊天对话框还有记录和打开聊天记录的功能。勾选上“关闭时保存聊天记录”,关闭聊天对话框会聊天记录会自动保存在形如“2011-03-11.102920(192.168.1.3)测试.rtf”的文件中,文件名由聊天开始时间、IP、昵称组成。打开“聊天记录”按钮,可以选择保存的聊天记录,在接收文本框内会显示打开选择的聊天记录。

3.4 聊天室模块

聊天室分为服务器和客户端,每个用户只能创建一个聊天室,也只能加入一个聊天室。聊天室也只能发送普通文本消息,所以聊天室的开发相比聊天来说,简单的多。聊天室服务器如下图:

服务器右边两个列表框分别表示在聊天室里的好友、不在聊天室里的好友;客户端右边的列表框表示在聊天室中的好友。

用户创建聊天室可以把自己的所有好友都加入到聊天室,这些好友发的消息可以被所有在聊天室中的好友共享,即使这些好友之间可能并不能互相访问。这也是聊天室的一个好处。

聊天室服务器和客户端各使用一个SOCKET,创建的是UDP套接字,有自己的端口号。

用户创建聊天室时,创建端口为CHATROOM_SERVER_PORT的UDP套接定,把自己加入到聊天室好友列表里,同时也把自己添加的所有好友加入到未进入聊天室好友列表中。

在未进入聊天室好友列表中选定要添加进入聊天室的好友,单击向上的按钮,会向这些选定好的好友发送加入聊天室的请求。

  1. SendUserCommandToIP( CHATROOM_ADDREQUEST, user.strIP, DEFAULT_PORT, &userSelf );

以上代码就是向好友user发送加入聊天室请求,因为在加入聊天室之前,这些好友并没有创建聊天室客户端SOCKET,所以必须向主对话框的m_pLisSocket套接字发送消息,这从DEFAULT_PORT端口号就可以看出来。

如果好友接受了请求,接受请求的好友就会从未进入聊天室聊表框移到聊天室好友列表框。

单击向下的按钮,会删除聊天室好友列表框中选定的好友,并向他们发送被踢出聊天室的消息:

  1. SendUserCommandToIP( CHATROOM_CLIENT_KICKED, user.strIP, CHATROOM_CLIENT_PORT, NULL );

这儿是发往CHATROOM_CLIENT_PORT端口的消息,因为将要删除的好友已经打开了聊天室,而且创建了聊天室客户端的SOCKET,可以接收发往这个端口的消息。

当聊天室服务器关闭时,会向所有聊天室好友发送关闭聊天室的消息:

  1. for( int nIndex = 1; nIndex < m_listCtrlInChat.GetItemCount(); nIndex++ )
  2. {
  3. USER user = m_arrFriendsInChat.GetAt( nIndex );
  4. SendUserCommandToIP( CHATROOM_SERVER_CLOSED, user.strIP, CHATROOM_CLIENT_PORT, NULL );
  5. }

聊天室好友列表里第的是自己,不必要给自己发送消息,因此nIndex是从1开始的。

当创建聊天室的用户发送聊天消息时,服务器将文本消息发给所有聊天室好友。在服务器创建的时候,限制过发送文本框的字数:

  1. m_editSend.SetLimitText( MAXDATAPACKETLENGTH - sizeof( DATAPACKET ) - sizeof( CHATROOMMESSAGEINFO ) );

因此,发送的消息长度不会超过UDP可以发送的数据最大值,发送消息时直接一次发送就行:

  1. for( int nIndex = 1; nIndex < m_arrFriendsInChat.GetSize(); nIndex++ )
  2. {
  3. USER user = m_arrFriendsInChat.GetAt( nIndex );
  4. SendTextToIP( user.strIP, CHATROOM_CLIENT_PORT, strSend, "" );
  5. }

聊天室服务器接收到消息时,会将接收到的消息加入到聊天室接收文本框,并将消息发给所有在聊天室中的好友,除了向服务器发送消息的好友外:

  1. for( int nIndex1 = 1; nIndex1 < m_arrFriendsInChat.GetSize(); nIndex1++ )
  2. {
  3. USER userSend = m_arrFriendsInChat.GetAt( nIndex1 );
  4. if( 0 == strcmp( userSrc.strIP, userSend.strIP ) )
  5. {
  6. continue;
  7. }
  8. SendTextToIP( userSend.strIP, CHATROOM_CLIENT_PORT, strSend, userSrc.strIP );
  9. }

以上就是服务器的主要功能。

客户端的处理比服务器要简单一些,客户端只有在聊天室列表中的好友,客户端也只向服务器发送消息,不用向其他聊天室好友发送消息。

聊天室客户端如下图:

首先,主对话框接受到CHATROOM_ADDREQUEST消息,弹出对话框询问用户是否进入好友创建的聊天室,如果用户拒绝加入则不用处理此消息,如果用户同意加入聊天室,那么就弹出聊天室客户端。

在客户端打开的同时,限制发送文本框的最大字数,创建客户端SOCKET,端口号为CHATROOM_CLIENT_PORT的UDP。

当服务器踢出客户端,或服务器关闭时,弹出对话框提示用户,并清空聊天室好友,设置发送文本框不可用。

客户端关闭时,会向服务器发送客户端关闭的消息。

客户端发送消息时,先将发送的消息加入到接收消息文本框,然后将消息发往服务器,由服务器将消息发到各个聊天室好友。

客户端接到服务器发来的聊天消息时,处理也很简单,直接将聊天消息加入到接收消息文本框。

以上就是聊天室的设计方法。服务器在当中起到了中转的作用,所有消息都经过服务,服务器向所有好友发送消息,就算聊天室中的好友不能互相连接,只要能连接到服务器,他们就可以一起聊天。

3.5 传送文件模块

正如任务书上写的,文件传送支持断点续传,这是本程序的一个亮点。文件传送模块用到了多线程,可以实现多个文件、多个用户之间的传输。一个线程负责将一个文件传送给一个好友。

传送文件服务器如下图所示:

单击添加按钮会打开选择文件对话框,选择好要发送的文件后,会弹出一个好友列表框,要求选择需要发送的好友,选择了好友后,程序会把发送文件信息添加到传输入文件对话框中,并向接收者发起传送文件的请求,如果接收者拒绝接收文件,在传输文件对话框的相应项的速度列就会显示“拒绝”,如果接收者接受文件,文件传送正式开始。下面详细介绍服务器端的工作过程:

传送文件用到的是TCP连接,因为传送文件必须保证传输的数据不能丢失也不能有误。用户打开文件传输功能,弹出传输文件对话框,在初始对话框的时候,除了初始化传输文件列表框,还会创建一个端口号为SENDFILESSERVER_PORT的TCP套按字m_pSFServerSocket,并设置此套接字为监听状态。

当用户选择好传送的文件和传送的好友后,传送信息会加入到列表框,并调用主对话框的SendFilesNotify()函数向这些好友发送传送文件的消息,此消息中还包括文件名和文件长度。

如果接收者拒绝接收文件,列表框的相应项的速度列会被设置为“拒绝”。

如果接收者接收文件,接收者会先向pSFServerSocket发起连接,程序重载pSFServerSocket的OnAccept()函数,在此函数中调用传输文件服务器对话框的OnAccept()函数:

  1. void CSendFilesServerDlg::OnAccept()
  2. {
  3. CSendFilesSocket sfSocket;
  4. m_pSFServerSocket->Accept( sfSocket );
  5. CSendFilesServerThread *pSFSThread = ( CSendFilesServerThread *)AfxBeginThread( RUNTIME_CLASS( CSendFilesServerThread ),
  6. 0,
  7. 0,
  8. CREATE_SUSPENDED,
  9. NULL );
  10. pSFSThread->SetSendFilesServerDlg( this );
  11. pSFSThread->AttachSocket( sfSocket.Detach() );
  12. pSFSThread->ResumeThread();
  13. m_arrSendThread.Add( pSFSThread );
  14. }

在此函数中先定义一个CSendFilesSocket类型的变量sfSocket,m_pSFServerSocket接收此WinSocket,就与接收都连接起来了。然后此函数再创建了一个线程,并把此线程加入到线程列表中。在线程中也有一个CSendFilesSocket类型的成员变量,将这儿的sfSocket分离掉套接字句柄,附加到线程中的相同类型的成员变量上。发送数据的功能就由这个线程来处理了。

在传输入文件对话框中选中某些传输信息,单击“删除”按钮,会弹出删除提示框询问用户是否删除这些传输信息,如果用户确定删除这些传输信息,传略文件对话框会删除这些传输信息,如果文件正在传输入,会关闭线程,停止传输文件。

在传输文件对话框中设置了一个定时器,用于刷新列表框。每当定时器时间到时,程序调用RefreshListBox()函数刷新列表框,更新文件传输的进度和速度。

如果接受者停止了接收文件或者文件传输完成,相应的线程也会调用RefreshListBox()函数及时的刷新列表框。

CSendFilesServerThread类用于发送文件数据,接收者连接到m_pSFServerSocket后,接收者就可以与CSendFilesServerThread类中的CSendFilesSocket成员变量m_sendFilesSocket通信了。

因为程序支持断点续传,因此接收者首先会通知CSendFilesServerThread线程要发送的文件、大小、已经发送的大小,之后线程会打开需传送的文件,并定位到已传送过的位置,然后向接收者发送可以开始传送文件的消息。

接收者收到可以开始传送文件的消息后,会向服务器发起请求传输数据的消息,此消息中包含已传输的大小。

传输线程收到此消息后,会根据文件的大小、已传输的大小、以及包的大小向接收者发送数据包。传输线程只要接收到传输数据的消息,就会传输数据,当接收到传输完毕后,此线程作一些结尾工作,通知传输入文件对话框,然后结束线程。

接收文件对话框如下:

服务器向接收者发送文件时,主对话框会弹出对话框询问是否接受文件,如果不接受则不作处理。如果接收,则打开接收文件对话框,并让用户选择保存接收文件的位置,然后定义CReceiveFilesSocket类型的变量rfSocket,向服务器发起连接,创建CSendFilesClientThread线程,把线程加入到线程列表,并分离rfSocket的套接字句柄,附加到CSendFilesClientThread线程中的CReceiveFilesSocket类型的成员变量中。

接收文件对话框的其他功能,如删除、刷新列表框等与传输文件对话框无太大的区别,在此就不再累赘。接收文件的操作就由CSendFilesClientThread线程来处理。

断点续传功能主要在是接收端实现的,接收端接受文件时会创建一配置文件,配置文件记录了源文件在服务器的路径、大小以及传输过的大小。当接收者接收到一个数据包并把数据保存起来后,会更新此配置文件,当文件传输完成后,删除此配置文件。

接收开始时,接收线程会首先寻找在接收文件目录下是否存在配置文件,如果配置文件存在,验证配置文件中记录的源文件在服务器的路么是否一致,大小是否相等增,接收文件是否存在,大小是否比配置文件中读取大小的值大。如果这些条件都成立,说明可以接着传输,否则从头开始传。

判断出是否续传信息后,接收线程会将这些信息发送给服务器,服务器收到信息后会作相应的设置,然后发消息通知接收者可以传输数据了。接收者收到信息后,就会向服务器发出请求传输数据的消息,消息中包含已传输的大小。服务器收到消息后就会向接收者发送数据包。

接收者接收到数据包后,更新已传输的大小,并把文件数据追加到接收文件后面。如果已传输大小等于文件的大小,接收者会向服务器发送传输完毕的消息,然后作结尾工作,通知接收对话框接收完毕,结束线程。如果还未传输完成,接收者会再次向服务器发起传输数据的消息,直到文件传输完毕。

3.6 共享屏幕模块

共享屏幕指的是用户可以让其他好友观看自己的屏幕。

共享屏幕服务器如下:

共享屏幕界面的设置与聊天室相似,这里不再累赘。

共享屏幕模块同样使用UDP套接字,客户端同意加入共享屏幕后,服务器所做的功能就是先把自己屏幕的尺寸发送给好友,然后反复把自己桌面图像发送给好友。

发送桌面图像给好友之前需要截取桌面图像,为了减小图像大小,方便传输,程序截取桌面图像后把图像处理成8位的位图,宏SHARESCREEN_BITCOUNT定义了截取后的位图颜色位数,默认为8位。另外,在共享屏幕中还用到了数据压缩,这儿使用了网上成熟的Zlib压缩。因为并不是所有的数据都能压缩得更小,因此压缩后还必须判断压缩是否在效,如果无效就不压缩,直接传输入原数据:

  1. // 压缩数据
  2. DWORD dwMaxCompressLength = compressBound( dwDIBLength );
  3. BYTE *pCompress = new BYTE[ dwMaxCompressLength ];
  4. DWORD dwCompressLength;
  5. compress( pCompress, &dwCompressLength, pDIB, dwDIBLength );
  6. // 压缩有效,则使用压缩后的数据,否则使用原数据
  7. if( dwCompressLength < dwDIBLength )
  8. {
  9. BYTE *pTmp = pDIB;
  10. pDIB = pCompress;
  11. dwDIBLength = dwCompressLength;
  12. delete pTmp;
  13. }
  14. else
  15. {
  16. delete [] pCompress;
  17. }

以上代码可以看出只有当压缩后的数据大小小于原数据大小时,才使用压缩数据。这样我们得到的数据其实很小了,已经在UDP传输数据允许范围内,因此程序没有再使用分包发送,而是直接一次性发送出去。

截取出来的图像没有鼠标信息,因此还必须在截取的图像上画上鼠标:

  1. // 画鼠标
  2. CPoint mouse;
  3. GetCursorPos( &mouse );
  4. ::DrawIcon( hdc, mouse.x - 10, mouse.y - 10, ::GetCursor() );

共享屏幕客户端如下:

客户端的处理很简单,直接将接收到的图像数据经过解压后贴到对话框的客户区就行。客户端没有什么难点,做法都很正常,因此就不再介绍。

3.7 白板模块

白板服务器如下:

白板服务器与聊天室服务器类似,相同的地方就不一一介绍。服务器对话框左边是工具和属性按钮,这些按钮都是自绘按钮,自绘按钮前面已经做过介绍,这儿就再说明。中间的画板是重载CStatic后得到的自绘CCanvasStatic。服务器对话框有三个主要的成员变量:

  1. TOOL m_emTool; // 工具
  2. int m_nWidth; // 线宽
  3. COLORREF m_clDrawColor; // 颜色

左边的工具和属性按钮对这三个变量相对应,这些按钮控制着画图的属性。

绘图操作在CCanvasStatic中处理,绘画结果再传给服务器对话框,服务器对话框然后把绘图信息发送给所有在白板中的好友。同样的,服务器收到绘图信息后,会反绘图信息传递给画板类CCanvasStatic,让绘出图形,然后服务器再将接收到的绘图信息发送给其他好友。

白板客户端如下:

客户端与服务器功能相似,而且更简单,客户端绘画后把只把绘图信息发送给服务器,在接收到服务器发送过来的绘图信息后再把绘图信息反应到画板上。

3.8 音、视频模块

这个模块重点介绍如何解决音频的连续和音、视频同步的问题。

界面设计如下:

要和好友视频聊天,自己或好友必须至少一方有摄像头,在主对话框初始化的时候进行判断是否有摄像头:

  1. m_hWndVideo = capCreateCaptureWindow( NULL, WS_CHILD, 0, 0, 1, 1, m_hWnd, IDD_CAPTUREVIDEO );
  2. if( capDriverConnect( m_hWndVideo, 0 ) )
  3. {
  4. m_bCamera = TRUE;
  5. capDriverDisconnect( m_hWndVideo );
  6. }

思路是通过与序号为第一个摄摄头连接,如果连接成功,说明有摄像头,否则不存在摄像头。测试是否有摄头,有更好的代码的,但程序为了方便开发,只是简单的认为一台电脑上只有一个摄像头,以下都是如此处理的,不再说明。

从以上代码可以看出,视频聊天代码中使用capCreateCaptureWindow()函数,这是VFW中的函数,本程序中的视频聊天都是采用VFW模式来设计的。关于VFW的使用,本论文限于篇幅,就不再介绍。下面介绍视频聊天的构架。

本程序允许同多个好友进行视频聊天,而一个摄像头只能被连接一次,所以一个摄像头供所有视频聊天对话框共享,出于此,只能在主对话框中连接摄像头,把摄像头捕获到的数据分发给所有视频聊天对话框。

当向好友发起视频聊天请求或好友向自己发起视频聊天请求时,首先会判断自己是否有摄像头,如果有,再判断之前是否已连接摄像头,如果没有,这时先连接摄像头,然后处理其他的:

  1. if( !m_bConnectCamera )
  2. {
  3. if( capDriverConnect( m_hWndVideo, 0 ) )
  4. {
  5. // 设置视频的大小
  6. BITMAPINFO bmpInfo;
  7. capGetVideoFormat( m_hWndVideo, &bmpInfo, sizeof( BITMAPINFO ) );
  8. bmpInfo.bmiHeader.biWidth = VIDEOCHAT_VIDEO_WIDTH;
  9. bmpInfo.bmiHeader.biHeight = VIDEOCHAT_VIDEO_HEIGHT;
  10. capSetVideoFormat( m_hWndVideo, &bmpInfo, sizeof( BITMAPINFO ) );
  11. // 连接上视频
  12. m_bConnectCamera = TRUE;
  13. }
  14. }

连接上视频后,首先设置摄像头的分辨率,这儿使用的是宏VIDEOCHAT_VIDEO_WIDTH和VIDEOCHAT_VIDEO_HEIGHT定义的大小,默认为320x240。

连接上视频后,程序设置摄像头为流模式捕获画面,摄像头定时捕获画面,捕获到画面后,会调用回调函数EncodeCallback():

  1. LRESULT WINAPI EncodeCallback( HWND hWnd, LPVIDEOHDR lpVHdr )
  2. {
  3. if( lpVHdr->dwFlags & VHDR_DONE )
  4. {
  5. // 通过主对对话框更新所有视频聊天对话框画面
  6. pDlgMain->UpdateVideoPicture( ::GetTickCount(), bmpInfo, lpVHdr->lpData, lpVHdr->dwBufferLength );
  7. }
  8. return 1;
  9. }

在此回调函数中,调用主对话框的UpdateVideoPicture()函数,UpdateVideoPicture()函数只是简单把捕获到的视频数据分发给所有的视频聊天对话框,视频聊天对话框把视频数据显示到自己视频区域,然后向好友发送视频数据。好友接收到视频数据后,再把视频数据显示到对应的区域。不断的发送数据、接收数据、更新区域,这样就达到了视频聊天的目的。

音频聊天需要处理连续的问题,因为在服务器端是录音,再发送到客户端播放,如果录完音处理发送,再接着录音,这样中间就会有空隙,造成录的音断断续续。所以音频聊天本程序使用的是双缓冲录音,多缓冲接收录音数据的方式。

音频聊天采用的是WaveX系列API函数来捕获音频数据,在主对话框初始化的时候就准备两个缓冲区来接收录音数据:

  1. m_pWaveHdr1 = ( PWAVEHDR )new char[ sizeof( WAVEHDR ) ];
  2. m_pWaveHdr2 = ( PWAVEHDR )new char[ sizeof( WAVEHDR ) ];
  3. waveInPrepareHeader( m_hWaveIn, m_pWaveHdr1, sizeof( WAVEHDR ) );
  4. waveInPrepareHeader( m_hWaveIn, m_pWaveHdr2, sizeof( WAVEHDR ) );

一个缓冲区录音完毕,程序会收到WaveInData消息,程序就可以调用函数来处理音频数据,同时系统会继续录音,因为另一缓冲区还可以接收数据:

  1. void CInstantMessagingDlg::WaveInData( WPARAM wParam, LPARAM lParam )
  2. {
  3. DWORD dwTickCount = m_dwTickTime - 1200;
  4. /// 得到录音的时间
  5. m_dwTickTime = ::GetTickCount();
  6. /// 每个视频窗口发送声音
  7. int nVideoChatDlgIndex = 0;
  8. for( nVideoChatDlgIndex = 0; nVideoChatDlgIndex < m_arrVideoChatDlg.GetSize(); nVideoChatDlgIndex++ )
  9. {
  10. m_arrVideoChatDlg.GetAt( nVideoChatDlgIndex )->SendAudioData( dwTickCount,( ( PWAVEHDR )lParam )->lpData,( ( PWAVEHDR )lParam )->dwBufferLength );
  11. }
  12. /// 将此WAVEHDR再添加进录音设备
  13. waveInAddBuffer( m_hWaveIn, ( PWAVEHDR )lParam, sizeof( WAVEHDR ) );
  14. return;
  15. }

处理完接收到音频数据的缓冲区后,继续把此缓冲区添加到录音中,这样就可以循环录音。

在客户端采用多缓冲区来接收音频数据,这些自缓冲区组成一个首尾相连的圆圈缓冲区,用两个指针来表示接收和播放的缓冲区序号:

  1. // 申请音频数据的缓冲区
  2. for( int nIndex = 0; nIndex < AUDIO_BUFFER_BLOCK; nIndex++ )
  3. {
  4. char *pAudioBuffer = new char[ AUDIO_BUFFER_SIZE + sizeof( DWORD ) ];
  5. m_arrAudioBuffer.Add( pAudioBuffer );
  6. }
  7. m_nReceive = 0;
  8. m_nPlay = 0;

缓冲区的块数是AUDIO_BUFFER_BLOCK宏定义,刚开始时接收和播放的指针都为0。

下面是接收到音频数据的代码:

  1. // 接收到音频数据,保存起来
  2. void CVideoChatDlg::SaveAudioData( char *pData, DWORD dwDataLength )
  3. {
  4. CDebug debug( "SaveAudioData" );
  5. // 保存接收到的音频数据
  6. char *pSaveData = m_arrAudioBuffer.GetAt( m_nReceive );
  7. memcpy( pSaveData, pData, dwDataLength );
  8. // 新的接收区块号
  9. m_nReceive = ( m_nReceive + 1 ) % AUDIO_BUFFER_BLOCK;
  10. // 接收追上播放,播放往前走
  11. if( m_nReceive == m_nPlay )
  12. {
  13. m_nPlay = ( m_nPlay + 1 ) % AUDIO_BUFFER_BLOCK;
  14. }
  15. if( !m_bPlaying && m_bReceiveAudio )
  16. {
  17. m_bPlaying = TRUE;
  18. char *pPlayData = m_arrAudioBuffer.GetAt( m_nPlay );
  19. memcpy( &m_dwRecordAudioTickTime, ( BYTE * )pPlayData, sizeof( DWORD ) );
  20. m_dwPlayAudioTickTime = ::GetTickCount();
  21. m_pWaveHdr1->lpData = pPlayData + sizeof( DWORD );
  22. waveOutPrepareHeader( m_hWaveOut, m_pWaveHdr1, sizeof( WAVEHDR ) );
  23. waveOutWrite( m_hWaveOut, m_pWaveHdr1, sizeof( WAVEHDR ) ) ;
  24. m_nPlay = ( m_nPlay + 1 ) % AUDIO_BUFFER_BLOCK;
  25. }
  26. }

接收到音频数据先保存起来,再判断两个指针的位置关系,如果接收到的指针追上了播放指针,则播放指针往前走,如果客户端允许播放声音,而且之前接收到的音频数据已播放完毕,那么接收到数据这时就可以继续播放了。

至此,视频和音频可以可以正常的显示和播放了。但还有个大问题,就是视频和音频不同步,视频可以说是实时的,但视频需要录制完毕后发送到客户端才播放的,所以音频比视频要慢上几秒

为了解决这个问题,程序在每个视频数据和音频数据上都加上了服务器端的时间标签。在显示视频画面时比较时间标签决定视频画面处理。具体的处理如下所述

开始播放音频时,记下客户端播放的时间和此音频数据在服务器端录制的时间,在视频数据到达时,记下此时客户端时间,计算出音频已播放的时长,然后在音频数据在服务器端录制的时间上加上这个时长,就得到标准的画面在服务器端时间,然后用这个时间比接收到的视频数据中的时间相比较:

  1. 如果视频时间比标准时间大,说明视频画面还有播放音频后面,把些画面保存起来,不显示在对话框的好友区域;
  2. 如果视频时间比标准时间小,说明音频播放到视频的后面了,此视频画面可以丢弃,也不显示,但必须从保存起来的视频画面中找到与标准时间最接近的视频画面来显示;
  3. 如果视频时间与标准时间相等,或者视频时大于标准时间大但是已经进行过第2种情况的处理,则可以显示此视频数据。注意此种情况与第1种情况的区别,第1种情况是没有进行过第2种情况的处理。

用以上的方法来处理音频和视频数据,就可以达到音、视频的同步,主要代码如下:

  1. CString strVideoData( ( LPCTSTR )pVideoReceiveData, dwDataLength );
  2. m_lstVideoData.AddTail( strVideoData );
  3. CString strData;
  4. BITMAPINFO bmpInfo;
  5. BYTE *pRGBData;
  6. // 如果没有播放音频,则直接贴图
  7. if( !m_bPlaying )
  8. {
  9. strData = m_lstVideoData.GetHead();
  10. m_lstVideoData.RemoveHead();
  11. BYTE *pVideoData = ( BYTE * )strData.GetBuffer( strData.GetLength() );
  12. memcpy( &bmpInfo, pVideoData + sizeof( DWORD ), sizeof( BITMAPINFO ) );
  13. pRGBData = new BYTE[ bmpInfo.bmiHeader.biSizeImage ];
  14. YUY2_RGB( pVideoData + sizeof( DWORD ) + sizeof( BITMAPINFO ), pRGBData, bmpInfo.bmiHeader.biSizeImage * 4 / 6 );
  15. strData.ReleaseBuffer( -1 );
  16. }
  17. // 正在播放音频,需要对视频数据进行处理
  18. else
  19. {
  20. DWORD dwStandardVideoTime = ::GetTickCount() - m_dwPlayAudioTickTime + m_dwRecordAudioTickTime;
  21. DWORD dwVideoTime;
  22. BOOL bSmallerThanStandardVideoTime = FALSE;
  23. // 循环判断每个视频时间与标准播放时间的距离
  24. int nVideoDataCount = m_lstVideoData.GetCount();
  25. for( int nIndex = 0; nIndex < nVideoDataCount; nIndex++ )
  26. {
  27. strData = m_lstVideoData.GetHead();
  28. BYTE *pVideoData = ( BYTE * )strData.GetBuffer( strData.GetLength() );
  29. memcpy( &dwVideoTime, pVideoData, sizeof( DWORD ) );
  30. // 如果视频时间与标准时间相等或大于但已经过了一个小于的
  31. if( ( dwVideoTime > dwStandardVideoTime && bSmallerThanStandardVideoTime ) ||dwVideoTime == dwStandardVideoTime )
  32. {
  33. m_lstVideoData.RemoveHead();
  34. memcpy( &bmpInfo, pVideoData + sizeof( DWORD ), sizeof( BITMAPINFO ) );
  35. pRGBData = new BYTE[ bmpInfo.bmiHeader.biSizeImage ];
  36. YUY2_RGB( pVideoData + sizeof( DWORD ) + sizeof( BITMAPINFO ), pRGBData, bmpInfo.bmiHeader.biSizeImage * 4 / 6 );
  37. strData.ReleaseBuffer( -1 );
  38. break;
  39. }
  40. // 如果视频时间小于标准时间,丢弃视频数据
  41. else if( dwVideoTime < dwStandardVideoTime )
  42. {
  43. bSmallerThanStandardVideoTime = TRUE;
  44. m_lstVideoData.RemoveHead();
  45. strData.ReleaseBuffer( -1 );
  46. continue;
  47. }
  48. // 如果视频时间大于标准时间,则不作处理,直接返回
  49. else if( dwVideoTime > dwStandardVideoTime )
  50. {
  51. strData.ReleaseBuffer( -1 );
  52. return;
  53. }
  54. }
  55. }

3.9 调试模块

为了方便调试,在程序中添加了一个用于调试的类CDebug,它具有的成员变量和成员函数如下:

  1. class CDebug
  2. {
  3. private:
  4. CString m_strMessage; // 信息
  5. public:
  6. CDebug();
  7. CDebug( CString strMessage );
  8. virtual ~CDebug();
  9. };
  10. // 构造和析构函数如下
  11. CDebug::CDebug( CString strMessage )
  12. {
  13. m_strMessage = strMessage;
  14. CString strTrace;
  15. strTrace = "run in : " + m_strMessage + "\n";
  16. TRACE( strTrace );
  17. }
  18. CDebug::~CDebug()
  19. {
  20. CString strTrace;
  21. strTrace = "run out : " + m_strMessage + "\n";
  22. TRACE( strTrace );
  23. }

利用类的构造、析构函数和类的生命周期,我们可以很方便的设计如上的CDebug类,用法如下:

  1. {
  2. CDebug debug( test );
  3. }

在函数或语句组的开始处定义一个CDebug变量,当程序运行到变量定义处的时候,会打印出run in : test,当程序执行完函数或语句组时,会打印出run out : test,这样我们就可以知道程序运行到何处,极大的方便了我们对程序的调试。

上传的附件 cloud_download VC++实现的仿QQ和飞秋并支持语音视频白板屏幕共享的即时聊天软件.7z ( 1.38mb, 9次下载 )
error_outline 下载需要16点积分

keyboard_arrow_left上一篇 : 基于Node.js中间层的微信图书借阅平台网站的设计与实现 CG树顶端节点集群的设计与实现 : 下一篇keyboard_arrow_right



showy
2018-10-02 20:38:41
仿制QQ和飞秋软件,使用VC6.0开发,利用TCP Socket和UDP Socket实现数据传输,实现了聊天、文件传输、屏幕共享、白板、视频、语音等通信功能~

发送私信

如果你错过了爱,便错过了生活

11
文章数
11
评论数
eject