最近在做课设,题目是关于socket编程的一对一网络小游戏。期间遇到各种问题,也从中学到了很多。在此记录下课设中遇到的问题。
题目要求:
设计4 网络版小游戏
1 设计目的
1)熟悉开发工具(Visual Studio、C/C++、Java等)的基本操作;
2)掌握windows/Linux应用程序的编写过程;
3)对于Socket编程建立初步的概念。
2 设计要求
1)熟悉Socket API主要函数的使用;
2)掌握相应开发工具对Socket API的封装;
3)设计并实现一对一网络版小游戏,如:Tic-Tac-Toe、五子棋等。(注:不同的游戏对应不同的设计题目)
3 设计内容
1)服务器端设计
2)客户端设计
目前实现的功能:
服务端:管理在线玩家列表、对玩家发消息、强制玩家下线、日志的记录等。
客户端:下棋、悔棋、聊天、发送语音以及图片(本质上是发送文件)等等。
后期可以加入的功能:自定义挑战对手、自定义头像、人机对战、发送视频(原理同语音消息),画框来提示上一步落子的位置
有待改进:发送大数据包导致的掉线和内存占用率高问题、刷新玩家列表导致玩家掉线的问题,接收数据时延迟较大的问题
在实现中涉及到的技术和方法:socket通信、序列化反序列化、GDI绘图、多线程、委托、CefSharp nuget包的导入和使用,还有一些html标签的使用
运行截图:
五子棋demo网上有很多,也没什么好说的。这个课设主要是熟悉socket编程,我的重点就放在通信上面。
首先,大的方向是采用TCP协议。(由于TCP是面向连接的,这和无连接的广播是矛盾的,所以服务端运行图中的广播这一功能是伪广播,在实现时只是用循环代替了单个发送,并不是真正的广播)。
然后,在如何封装数据包的问题上,从网上查找资料,大概有这三种(这些方法大同小异,最终都要转成字节数组进行发送):
此外还有结构体、特殊符号分隔法、json序列化、二进制序列化......就不一一列举了。本次课设我采用了第二种方法。
封装一个实体类,这和Javabean的作用是类似的。其实设计的还不是很合理,有些字段可以复用以减少定义节点的数量:
[Serializable]
[XmlInclude(typeof(List))]
public class Message
{
public const int MAX_LINE_COUNT = 15;
public const string ID_STATUS_PUT = "落子";
public const string ID_STATUS_PP = "匹配玩家";
public const string ID_STATUS_OVER = "游戏结束";
public const string ID_STATUS_MSG = "聊天";
public const string ID_STATUS_IMG = "图片";
public const string ID_STATUS_SOUND = "声音";
public const string ID_STATUS_INIT = "初始化";
public const string ID_STATUS_BACK = "悔棋";
public const string ID_STATUS_MSGREFUSED = "消息被拒收";
public const string ID_STATUS_START = "重新开始";
public const string ID_STATUS_REQUEST = "请求重新开始";
public const string ID_STATUS_UPDATEBOARD = "更新棋局";
public const string ID_STATUS_OFFLINE = "掉线";
public const string COLOR_BLACK = "黑棋";
public const string COLOR_WHITE = "白棋";
public const string COLOR_NONE = "无色";
//要执行的动作
[XmlElement(Order = 1)]
public string Action { get; set; }
//接收者
[XmlElement(Order = 2)]
public string Receiver { get; set; }
//发送者
[XmlElement(Order = 3)]
public string Sender { get; set; }
//消息内容
[XmlElement(Order = 4)]
public string ExtraMsg { get; set; }
//轮到谁落子
[XmlElement(Order = 5)]
public string WhoseTurn { get; set; }
//最后一次落子的横坐标
[XmlElement(Order = 6)]
public int X { get; set; }
//最后一次落子的纵坐标
[XmlElement(Order = 7)]
public int Y { get; set; }
//游戏是否结束
[XmlElement(Order = 8)]
public bool IsGameOver { get; set; }
//是否要更新棋盘
[XmlElement(Order = 9)]
public bool IsUpdateBoard { get; set; }
//获胜者
[XmlElement(Order = 10)]
public string Winner { get; set; }
//本方颜色
[XmlElement(Order = 11)]
public string Color { get; set; }
//白子列表
[XmlElement(Order = 12)]
public List WPieces { get; set; }
//黑子列表
[XmlElement(Order = 13)]
public List BPieces { get; set; }
//发送者的昵称
[XmlElement(Order = 14)]
public string Name { get; set; }
//是否是系统消息
[XmlElement(Order = 15)]
public bool IsSysMsg { get; set; }
//文件名
[XmlElement(Order = 16)]
public string FileName { get; set; }
//是否同意重新开始
[XmlElement(Order = 17)]
public bool IsAgree { get; set; }
public Message()
{
}
}
序列化说白了就是把一个对象转成特定格式的字符串(二进制序列化是直接转成字节序列),这个字符串里包含了对象的属性(或者状态)信息,然后你可以拿着这个字符串进行IO操作,比如存储成一个文件,或者通过网络发给另一台计算机。
反序列化是个相反的过程,把字符串转成对象,然后你可以使用面向对象的编程方法对转换后的对象进行操作。
class XmlUtils
{
///
/// 反序列化
///
///
///
///
public static T DeserializeObject(string xml)
{
XmlSerializer xs = new XmlSerializer(typeof(T));
StringReader sr = new StringReader(xml);
T obj = (T)xs.Deserialize(sr);
sr.Close();
sr.Dispose();
return obj;
}
///
/// 序列化
///
///
///
///
public static string XmlSerializer(T t)
{
XmlSerializerNamespaces xsn = new XmlSerializerNamespaces();
xsn.Add(string.Empty, string.Empty);
XmlSerializer xs = new XmlSerializer(typeof(T));
StringWriter sw = new StringWriter();
xs.Serialize(sw, t, xsn);
string str = sw.ToString();
sw.Close();
sw.Dispose();
return str;
}
}
private void DrawLines(PaintEventArgs e)
{
mPanelWidth = Math.Min(Width, Height);
mLineLength = mPanelWidth * 1.0f / ChessPanel.MAX_LINE_COUNT;
Graphics g = e.Graphics;
Pen pen = new Pen(Color.Black, 4);
//画棋盘线
if (Width > Height)
{
for (int i = 0; i < ChessPanel.MAX_LINE_COUNT; i++)
{
int startX = (int)(mLineLength / 2);
int endX = (int)(mPanelWidth - mLineLength / 2);
int y = (int)((0.5 + i) * mLineLength);
//横线
g.DrawLine(pen, startX + mOffset, y, endX + mOffset, y);
//竖线
g.DrawLine(pen, y + mOffset, startX, y + mOffset, endX);
}
}
else
{
for (int i = 0; i < ChessPanel.MAX_LINE_COUNT; i++)
{
int startX = (int)(mLineLength / 2);
int endX = (int)(mPanelWidth - mLineLength / 2);
int y = (int)((0.5 + i) * mLineLength);
//横线
g.DrawLine(pen, startX, y + mOffset, endX, y + mOffset);
//竖线
g.DrawLine(pen, y, startX + mOffset, y, endX + mOffset);
}
}
}
要支持棋子大小随棋盘控件大小的改变而改变,还要把棋子画在交叉点上,涉及到屏幕坐标和棋盘坐标的转换,这个说起来稍微复杂,画个图弄清楚大小关系就好了,具体请看网上的例子或本文后面的参考链接。
棋子坐标的存储是使用两个List集合分开存储,List集合里放的是Point点的坐标。有的人会采用二维数组代表整个棋盘,数值为奇数偶数代表白子或黑子,总之各有利弊吧。二维数组比较简单直观,但也失去了一些信息,比如不能记录各个点的落下的顺序,在悔棋时比较麻烦。在高级语言当中,我一般常用集合,较少用数组。虽然List集合底层也是用数组实现的,但多一层封装会带来很大的方便。
随着落子次数的增加,传输的数据包越来越大,因为集合中点的个数在增加。一种优化的方法是把棋盘的点的数据放在服务端保存,客户端只传送最后一次落子的坐标,服务端对集合中的点进行增加和删除。
///
/// 画棋子
///
public void DrawPieces()
{
//如果为null,直接返回(实际上调试时看到反序列化不会为null,但为了严谨一点还是把这句判断加上去)
if (mBPieces == null || mWPieces == null)
{
return;
}
Graphics g = this.CreateGraphics();
//画黑棋
foreach (var item in mBPieces)
{
//转换成棋子在控件里的位置坐标
int x = (int)((item.X + ratioPieceOfLineHeight / 4) * mLineLength) + mOffset;
int y = (int)((item.Y + ratioPieceOfLineHeight / 4) * mLineLength);
int width = (int)(mLineLength * ratioPieceOfLineHeight);
Rectangle rect = new Rectangle(x, y, width, width);
g.DrawImage(mBlackPiece, rect);
}
//画白棋
foreach (var item in mWPieces)
{
//转换成棋子在控件里的位置坐标
int x = (int)((item.X + ratioPieceOfLineHeight / 4) * mLineLength) + mOffset;
int y = (int)((item.Y + ratioPieceOfLineHeight / 4) * mLineLength);
int width = (int)(mLineLength * ratioPieceOfLineHeight);
Rectangle rect = new Rectangle(x, y, width, width);
g.DrawImage(mWhitePiece, rect);
}
}
上面棋子的绘制是对方点击后,我收到服务器发来的消息,然后更新我自己的界面,这个过程中所有的棋子都要重新绘制。因为不知道对方是落子还是悔棋,如果是悔棋,还要进行局部擦除,比较麻烦,干脆直接把所有棋子重新画一遍。
而下面这部分代码是当自己点击自己的棋盘后,在自己的棋盘上画一枚棋子,属于局部绘制。当然如果想省事儿,自己点击后直接全局重绘也可以,就只需要判断位置合法后调用Update()之类的方法强制使棋盘重绘就行了。
public void ChessPanel_MouseClick(object sender, MouseEventArgs e)
{
//isGameOver=true,游戏结束,不响应鼠标点击事件,直接返回
//mWhoseTurn==Message.COLOR_NONE,没有指定轮到谁,那么谁都不响应
//mWPieces=null当前还没有组队
//mColor.Equals(mWhoseTurn)==false没有轮到自己
if (isGameOver || mWhoseTurn == Message.COLOR_NONE || !mColor.Equals(mWhoseTurn) || mWPieces == null || mBPieces == null)
{
if (FormClient.mIsSoundOn)
{
errorPlayer.Play();
}
return;
}
Point point = new Point();
// 将点击的坐标转换成棋盘交叉点的坐标
point.X = (int)((e.X-mOffset) / mLineLength);
point.Y = (int)(e.Y / mLineLength);
//如果点击的格子里已经有棋子了,就返回
if (mWPieces.Contains(point) || mBPieces.Contains(point))
{
if (FormClient.mIsSoundOn)
{
errorPlayer.Play();
}
return;
}
//判断是否点到外面去了
if (point.X<0||point.Y<0||point.X>= ChessPanel.MAX_LINE_COUNT || point.Y>= ChessPanel.MAX_LINE_COUNT)
{
if (FormClient.mIsSoundOn)
{
errorPlayer.Play();
}
return;
}
//转换成棋子在控件里的位置坐标
int x, y;
if (Width>Height)
{
x = (int)((point.X + ratioPieceOfLineHeight / 4) * mLineLength) + mOffset;
y = (int)((point.Y + ratioPieceOfLineHeight / 4) * mLineLength);
}
else
{
x = (int)((point.X + ratioPieceOfLineHeight / 4) * mLineLength);
y = (int)((point.Y + ratioPieceOfLineHeight / 4) * mLineLength) - mOffset;
}
int width = (int)(mLineLength * ratioPieceOfLineHeight);
//创建控件的GDI+,准备绘制棋子
Graphics g = CreateGraphics();
//待绘制的棋子的位置
Rectangle rect = new Rectangle(x, y, width, width);
//判断本方是黑棋还是白棋
if (mColor.Equals(Message.COLOR_BLACK))
{
g.DrawImage(mBlackPiece, rect);
mBPieces.Add(point);
}
else if (mColor.Equals(Message.COLOR_WHITE))
{
g.DrawImage(mWhitePiece, rect);
mWPieces.Add(point);
}
//播放声音
if (FormClient.mIsSoundOn)
{
downPlayer.Play();
}
//本方点击,向服务器发送落子消息
Message m = new Message();
m.Action = Message.ID_STATUS_PUT;
m.WhoseTurn = mWhoseTurn;
m.Receiver = FormClient.yourUUID;
m.Sender = FormClient.myUUID;
m.X = point.X;
m.Y = point.Y;
m.BPieces = mBPieces;
m.WPieces = mWPieces;
m.Color = mColor;
try
{
mClientSocket.Send(Encoding.UTF8.GetBytes(XmlUtils.XmlSerializer(m)));
//更新界面
FormClient.delHelp("等待对方落子");
FormClient.delStep(1);
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
//禁止本方点击
mWhoseTurn = Message.COLOR_NONE;
}
private void btnListen_Click(object sender, EventArgs e)
{
try
{
//当点击开始监听的时候 在服务器端创建一个负责监听IP地址和端口号的Socket
serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//获取ip地址
IPAddress ip = IPAddress.Parse(listBox_IP.SelectedItem.ToString());
//创建端口号
IPEndPoint point = new IPEndPoint(ip, Convert.ToInt32(numericUpDown_Port.Value));
//绑定IP地址和端口号
serverSocket.Bind(point);
//开始监听:设置最大可以同时连接多少个请求
serverSocket.Listen(10);
//禁用按钮
btnListen.Enabled = false;
//接收和处理棋盘事件
OnReceiveMsg += new ChessEventReceiveHander(ManageChessEvent);
//负责监听客户端的线程:创建一个监听线程
Thread threadwatch = new Thread(WaitConnect);
//将窗体线程设置为与后台同步,随着主线程结束而结束
threadwatch.IsBackground = true;
//启动线程
threadwatch.Start();
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}
//监听客户端发来的请求
private void WaitConnect()
{
Socket connection = null;
//持续不断监听客户端发来的连接请求
while (true)
{
try
{
connection = serverSocket.Accept();
connection.NoDelay = true;
}
catch (Exception ex)
{
//提示套接字监听异常
Console.WriteLine(ex.Message);
break;
}
//获取客户端的IP和端口号
IPAddress clientIP = (connection.RemoteEndPoint as IPEndPoint).Address;
int clientPort = (connection.RemoteEndPoint as IPEndPoint).Port;
//客户端网络结点号
string remoteEndPoint = connection.RemoteEndPoint.ToString();
//创建一个通信线程
ParameterizedThreadStart pts = new ParameterizedThreadStart(Receive);
Thread thread = new Thread(pts);
//设置为后台线程,随着主线程退出而退出
thread.IsBackground = true;
//启动线程
thread.Start(connection);
}
}
///
/// 接收客户端发来的信息
///
/// 客户端套接字对象
private void Receive(object socketclientpara)
{
Socket socketServer = socketclientpara as Socket;
while (true)
{
//创建一个内存缓冲区,其大小为1024字节 即1KB
byte[] buffer = new byte[1024];
try
{
int len;
using (MemoryStream ms = new MemoryStream())
{
do
{
//Receive方法是阻塞式接收数据
//流中没有数据时会阻塞
//将接收到的信息存入到内存缓冲区,并返回其字节数组的长度
len = socketServer.Receive(buffer, 1024, SocketFlags.None);
ms.Write(buffer, 0, len);
//可以利用Available属性进行循环读取
} while (socketServer.Available > 0);
buffer = ms.GetBuffer();
}
//将套接字获取到的字符数组转换为人可以看懂的字符串
string xml = Encoding.UTF8.GetString(buffer, 0, buffer.Length);
Message mes = XmlUtils.DeserializeObject(xml);
OnReceiveMsg(this, mes);//事件发生
}
catch (Exception ex)
{
//如果发生异常,说明客户端已经关闭了连接或者反序列化出错
//关闭之前accept出来的和客户端进行通信的套接字
socketServer.Close();
break;
}
}
}
///
/// 连接服务器
///
///
///
private void btnConnect_Click(object sender, EventArgs e)
{
//定义一个套接字
socketclient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//获取文本框中的IP地址
IPAddress address = IPAddress.Parse(txtIP.Text.Trim());
//将获取的IP地址和端口号绑定在网络节点上
IPEndPoint point = new IPEndPoint(address, Convert.ToInt32(numericUpDown_port.Value));
try
{
//客户端套接字连接到网络节点上,用的是Connect
socketclient.Connect(point);
socketclient.NoDelay = true;
}
catch (Exception ex)
{
Console.WriteLine("连接失败");
MessageBox.Show(ex.Message,"连接失败",MessageBoxButtons.OK,MessageBoxIcon.Error);
return;
}
//进行一系列初始化和设置
//this.btnConnect.Enabled = false;
//......
OnReceiveMsg+= new ChessEventReceiveHander(ManageChessEvent);
threadclient = new Thread(Receive);
threadclient.IsBackground = true;
threadclient.Start();
}
验收前一小时,经过在两台电脑间进行发送测试,发现的比较严重的问题:
发送大数据包(图片和声音)会导致掉线,在服务端刷新后也会导致客户端掉线。前者的可能原因是:xml成功反序列化的前提条件是需要完整接收数据,当数据包还没全部到达接收端时就开始反序列化,这样肯定会出错,就会导致掉线(也可能是Server端反序列化的问题)。加入Thread.Sleep(50)只是粗略地解决了在单机上的问题,但是没能解决多机通信下的问题。而且这种粗略解决办法的缺点很明显:通信效率降低了。看来还是异步接收比较好,由于对异步编程不太了解,我在这里就直接采用同步阻塞方式接收数据了。
这里还有个注意事项,那就是tcp的粘包问题。我这里举个不准确的例子解释一下。tcp流可以类比为水流,流与流之间是没有边界的,当短时间内连续向管道中注入流的时候,发送端发送太快或者接收端没有及时取走数据,多条消息就会连在一起,在接收的时候就分不清哪个是哪个。所以要进行必要的分包处理,或者在发送时加一些标记作为边界。这里我是在接收端进行字符串的分割操作,每遇到一个xml文档声明,就视为一条消息。在发送端也要进行一些简单的设置,关闭nagle算法,防止把小的数据包合并发送。
如果采用UDP协议,它的协议数据单元是数据报,跟字节流不同,就不会存在粘包问题。这从UDP数据报的首部也可以看出来,首部中有个数据长度字段,根据这个字段,底层自动将一定长度的数据视为一个数据报。
UDP首部:
TCP首部:
///
/// 接收服务端发来信息的方法
///
private void Receive()
{
//持续监听服务端发来的消息
while (true)
{
try
{
//定义一BUFFER_SIZE大小的内存缓冲区,用于临时性存储接收到的消息
//然后循环读取,将读取到的字节数组写入内存流,最后再赋给字节数组
byte[] buffer = new byte[BUFFER_SIZE];
int len;
using (MemoryStream ms = new MemoryStream())
{
do
{
//Receive方法是阻塞式接收数据
//流中没有数据时会阻塞在这里
len = socketclient.Receive(buffer, BUFFER_SIZE, SocketFlags.None);
ms.Write(buffer, 0, len);
//可以利用Available属性进行循环读取
if (socketclient.Available <= 0)
{
Thread.Sleep(50);//等待数据全部到达
}
} while (socketclient.Available > 0);
buffer = ms.GetBuffer();
}
// 读取的字节数为0说明socket断开,字节数=0不等价于流中没有数据
if (buffer.Length == 0)
{
MessageBox.Show("您已经掉线,请重新连接!", "服务器连接失败", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
try
{
//将套接字获取到的字符数组转换为人可以看懂的字符串
string xml = Encoding.UTF8.GetString(buffer, 0, buffer.Length);
//当数据包很小或发送间隔很短
//可能得到多条消息,造成xml中有多个根节点,反序列化的时候出错
//我们在这里判断,如果是多条消息合并在一起,要进行分离
//......
//反序列化为消息对象
Message mes = XmlUtils.DeserializeObject(xml);
OnReceiveMsg(this, mes);//处理消息
}
catch (Exception exx)
{
MessageBox.Show(exx.Message);
break;
}
}
catch (Exception ex)
{
//throw;
Console.WriteLine("远程服务器已经中断连接" + ex.Message);
break;
}
}
}
服务器接收到连接请求后,产生一个GUID作为用户识别码,并把它作为键,客户端socket对象作为值,加入到Dictionary集合中,更新到界面上。
这里有个疑问,既然Socket能够唯一标识主机上的应用进程,那为什么不直接采用(IP地址+端口号)作为识别码,而要多此一举使用GUID呢?这个问题的一种回答见https://blog.csdn.net/he_zhidan/article/details/51488945。另一个原因是出于安全考虑,如果把客户端地址直接暴露出来,可能会造成隐私泄露。对于课设来说,由于功能简单,socket标识已经足够了,但我还是习惯用GUID,毕竟GUID就是专门用在数据唯一、不能重复的地方上。
这个功能有缺陷,可能导致客户端掉线
///
/// 判断客户端socket是否在线(处于连接状态)
///
///
///
private bool IsAlive(Socket s)
{
try
{
byte[] buf = new byte[1024];
s.ReceiveTimeout = 1000;
if (s.Poll(1000, SelectMode.SelectRead))
{
int nRead = s.Receive(buf);
if (nRead == 0)
{
return false;
}
}
}
catch (Exception)
{
return false;
}
return true;
}
///
/// 安全关闭客户端socket
///
/// The socket.
public void SafeClose(Socket socket)
{
if (socket == null)
return;
if (!socket.Connected)
return;
try
{
socket.Shutdown(SocketShutdown.Both);
}
catch
{
}
try
{
socket.Close();
}
catch
{
}
}
聊天就是把要发送的文本赋给对象的一个属性,把该对象序列化为xml字符串,再转为字节数组,调用客户端socket的send()方法进行发送。
//创建一个消息对象,把要发送的内容封装到对象里
Message m = new Message();
m.Action = Message.ID_STATUS_MSG;
m.Sender = myUUID;
m.ExtraMsg = richTextMsg.Text.Replace("\n", "
");
m.Receiver = yourUUID;
//将对象序列化成字符串,并转换为机器可以识别的字节数组
byte[] sendMsg = Encoding.UTF8.GetBytes(XmlUtils.XmlSerializer(m));
//调用客户端套接字发送字节数组
socketclient.Send(sendMsg);
//将发送的信息追加到聊天内容文本框中
chartPanel.AppendMsg(m.ExtraMsg, txtName.Text);
由于做的比较简单,没有考虑到缓冲区相关问题。在发送 几十兆的wav声音文件时,通过任务管理器看到内存占用直线上升到三四百兆,并且迟迟不降低,只能用第三方清理软件来整理内存。这里可能涉及到垃圾回收机制。改进的办法是把文件切割成若干段,每段单独封装在一个数据包中,接收端对各段进行组装重新形成文件。由于传输文件不是本次课设的重点,在此就不深入研究了。建议不要发送大数据包。
发送端:先用Convert类把图片文件转为base64字符串,然后把它当做普通文本进行下一步处理。
接收端:从消息中取出base64字符串,重新编码为图片
using (OpenFileDialog ofd=new OpenFileDialog())
{
ofd.Filter = "图片|*.jpg;*.png;*.bmp;*.gif";
ofd.InitialDirectory = Environment.CurrentDirectory;
ofd.ShowDialog();
if (ofd.FileName!="")
{
using (FileStream stream = new FileStream(ofd.FileName, FileMode.Open))
{
Message msg = new Message();
msg.Action = Message.ID_STATUS_IMG;
msg.Sender = myUUID;
msg.Receiver = yourUUID;
msg.FileName = ofd.SafeFileName;
byte[] array = new byte[stream.Length];
stream.Read(array, 0, array.Length);
msg.ExtraMsg = Convert.ToBase64String(array);
socketclient.Send(Encoding.UTF8.GetBytes(XmlUtils.XmlSerializer(msg)));
}
}
}
在客户端进行显示时,最开始用的是RichTextBox,因为遇到一些没解决的问题而放弃。例如插入图片时调用剪切板可能会抛异常,而用其他办法插入图片过于复杂,好像要直接去操作RTF文件,要查rtf的格式规范,并且RichTextBox显示效果也很差。后来搜到winform仿QQ聊天界面,试了一下效果还行。
这种办法是把显示的内容封装成html,利用webbrowser显示,加上样式表的控制,所以功能很强大。具体做法:
文字----直接扔进p标签里
图片和声音-----把重新编码后的图片存到硬盘后,构造HTML文件,在img标签和audio标签的src属性指出文件的路径
这种办法也有缺点,不同Windows OS下显示效果可能不同,很多特性IE不支持(比如html5)。可以采用第三方浏览器(chromeBrowser、CefSharp等)解决。我用CefSharp试了一下,效果还可以,但是编译后需要一堆的动态链接库才能运行,而且项目大小将近200MB,再加上400多MB的packages目录,总共600多兆,而这只是一个简单的五子棋游戏,所以嘛,只好放弃,改用自带的webbrowser控件
下面分别是webbrowser和CefSharp的显示效果:
比如我要显示一张图片,应该用img标签,它的src属性指出图片的路径
当A向B发送图片,A先通过socket把图片传给服务端,在服务端又有一台http服务器,服务端把消息处理成图片的http地址然后发给B,如下面这种形式。只需要把图片超链接构造出来,而无需通过socket传送图片文件,但这样做会比较复杂,需要两个服务器端口。
考虑在客户端进行处理。对于img标签,可以指定本地路径。我们把收到的图片存入一个目录下面,然后在src属性下用file协议指明它在硬盘上的路径,这样就免去了搭建http服务器的麻烦
img还算简单,声音有点麻烦。audio标签在有些浏览器上不支持file协议的本地路径,我这里用embed标签
embed测试支持file协议路径,但也会有问题,那就是有些浏览器不支持关闭自动播放,造成页面一刷新就自动播放,体验总归是不好,而且当一个页面上有多个embed音频时就乱套了。
第一种,我们可以把HTML字符串赋给webBrowser1.DocumentText,这样就能加载出来了
第二种,把HTML字符串存到本地硬盘,保存为HTML网页类型,使用下面的办法载入本地文件(不加"file:///"也可以)
webBrowser1.Navigate("file:///" + filePath);//等效于下面的语句
webBrowser1.Url = new Uri("file:///" + filePath);
第一种办法,不需要频繁写入文件,直接在内存中加载字符串,速度快
第二种办法,消息较多时,读写文件频繁,速度慢。好处是支持src的相对路径,这是我选择第二种办法的原因。
对于支持audio标签的浏览器,最好不要用embed,改用audio,这时我们把html文件和mp3文件放在同一个目录下,就可以用相对路径设置audio 的本地文件src了,也就无需指定src的http路径
下面是聊天面板控件的部分代码,主要就是一堆css,还有要注意的是,新追加的消息通过锚点定位到末尾:
public partial class ChartPanel : UserControl
{
string path = string.Empty;
string html = string.Empty;
public static string myHead = "http://pics.sc.chinaz.com/Files/pic/icons128/7066/b5.png";
public static string yourHead = "http://pics.sc.chinaz.com/Files/pic/icons128/7066/b5.png";
public ChartPanel()
{
InitializeComponent();
//自定义控件Load事件里的代码有时候不执行,故在构造函数中调用
webKitBrowser_Load(null, null);
}
private void webKitBrowser_Load(object sender, EventArgs e)
{
html = @"
";
path = Application.StartupPath + "\\MsgReceived\\" + Guid.NewGuid().ToString("N") + ".html";
if (File.Exists(path) == false)
{
using (FileStream fs = new FileStream(path, FileMode.OpenOrCreate))
{
byte[] buffer = Encoding.UTF8.GetBytes(html);
fs.Write(buffer, 0, buffer.Length);
}
}
webBrowser1.Url = new Uri("file://" + path);
//chromeBrowser.Load("file://" + path);
//CefSharp.WebBrowserExtensions.LoadString(chromeBrowser, html, "http://www.example.com/");
}
public void AppendMsg(string msg, string name, bool isMyWords = true)
{
string str = string.Empty;
if (isMyWords)
{
str = @"
" + name + @"
" + msg + @"
";
}
else
{
str = @"
" + name + @"
" + msg + @"
";
}
html = html.Replace("", "") + str;
using (FileStream fs = new FileStream(path, FileMode.Create))
{
byte[] buffer = Encoding.UTF8.GetBytes(html);
fs.Write(buffer, 0, buffer.Length);
}
webBrowser1.Navigate("file://" + path);
}
public void AppendSysMsg(string msg, string name)
{
string str = @"
" + name + @"
" + msg + @"
";
html = html.Replace("", "") + str;
using (FileStream fs = new FileStream(path, FileMode.Create))
{
byte[] buffer = Encoding.UTF8.GetBytes(html);
fs.Write(buffer, 0, buffer.Length);
}
webBrowser1.Navigate("file://" + path);
}
public void Clear()
{
//File.Delete(path);
webKitBrowser_Load(null, new EventArgs());
}
}
最后一个小的问题,在做gif动图显示时,我还专门上网查有没有能显示动图的控件,确实有些人也在问这个问题。后来发现,pictureBox本身就支持动图显示啊。
不能动的原因是我把图片放入了imageList控件里,imageList会把原始图片转换,造成gif帧丢失,当pictureBox读取的图片来自imageList时,自然就不会动了。正确的做法是直接从文件中读取或从流中读取 。
pictureBox1.Image = Image.FromFile(path);
注:上述代码会占用图片文件,造成文件无法删除,应该在不用的时候调用下面语句释放资源
pictureBox1.Image.Dispose();
说实话,代码写得比较凌乱,主要问题就是大量使用静态全局变量,数据处理和界面的更新混杂在一起,因为没有进行过相关的编程思想的训练,还是停留在初学编程时面向过程的思想上,想到什么就写什么。
下面附上源码,需要的可以参考一下。
源代码(用VS2015打开,如果控件显示不全需要在Windows系统设置里自定义缩放为125%):https://download.csdn.net/download/qq_40582463/10722068
可执行程序:
https://pan.baidu.com/s/1ftjMI0AUXDdL1bfArP2s3A
参考:
Java swing + socket 写的一个五子棋网络对战游戏:https://blog.csdn.net/qq_20698983/article/details/80296165
安卓五子连珠(棋盘和棋子的绘制问题):https://www.imooc.com/learn/641
winform实现QQ聊天气泡200行代码(聊天消息的展示问题):https://www.cnblogs.com/tuzhiyuan/p/4518076.html
Socket Receive 避免 Blocking(socket同步方式循环接收数据问题):https://www.cnblogs.com/a_bu/p/5630158.html