【Java】JavaSocket编程开发聊天室-客户端核心部分

ClientChat.

本篇文章围绕聊天室的聊天界面ClientChat,叙述其中各种功能的实现。开始我们还是给出ClientChat最终GUI效果的两个展示,通过展示来直观认识各种功能。
在这里插入图片描述
在这里插入图片描述

关于控件.

JSplitPane分割窗格的使用.

  • 上面展示的GUI中实际上有三个分割窗格,比较明显的是中间这一道,将整个UI分为左右两边的分割窗格。我们首先给出代码段,结合代码段和上面的UI来叙述。
//Main chat panel.
JPanel MainChatPanel = new JPanel();
MainChatPanel.setLayout(new BorderLayout());
	     
//Users list panel.
JPanel UserPanel = new JPanel();
UserPanel.setLayout(new BorderLayout());
	     
//Split MainChatPanel and OnlineUsersListPanel.
JSplitPane SplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT,
	    				 MainChatPanel,UserPanel);
SplitPane.setDividerLocation(380);
SplitPane.setDividerSize(10);
SplitPane.setOneTouchExpandable(true);
SplitPane.setEnabled(false);
this.add(SplitPane,BorderLayout.CENTER);

上述代码就对应于将整个界面分割为左右两部分——MainChatPanel以及UserPanel的分割窗格SplitPane。首先是构造函数JSplitPane(),第一个参数是分割策略,代码中使用的是水平分割,也就是被分割后的两个部分是水平并列的关系,后两个参数则是分割的两个部分。后续的三个set()方法都是顾名思义能够知道意思的。setDividerLocation()是设置分割窗格的位置;setDividerSize()设置分割窗格的粗细,实际上剩余的两个分割窗格的size都是1,所以没有那么明显,如果都设置为10的话,效果图如下所示:
在这里插入图片描述
如此可以很清楚地看出三个分割窗格的位置了。setOneTouchExpandable()就对应于中间那个分割窗格上的两个实心黑三角形,顾名思义,它是设置伸缩策略的,某一部分可以被完全展开,效果图如下所示:
在这里插入图片描述
在这里插入图片描述
setEnabled则是分割窗格是否可以自由地上下拖动。实际上这些方法都可以通过自己的试验,来发现它的效果。

图标型JButton的生成.

  • 平时常见的JButton都是上图中【Exit】样式的,不免让人觉得单一。注意到GUI的中间部分,有四个图标型的按钮,放到上面还会给出提示信息,清新简洁。

在这里插入图片描述

//Shaking button.
ButtonAddr = "D:\\NewDesktop\\Shaking.png";
JButton ShakingButton = new JButton(new ImageIcon(ButtonAddr));
ShakingButton.setMargin(new Insets(0,0,0,0));
ShakingButton.setToolTipText("Nudge your friend.");
ButtonPanel.add(ShakingButton);

图标型JButton是从ImageIcon类型封装而来的,实际上很简单,只需要给出图标的路径即可。setMargin()方法用于设置按钮边框和标签之间的空白,我们更改其中的参数为new Insets(10,10,10,10),可以发现确实按钮边框周围多了一圈空白。
在这里插入图片描述
setToolTipText()则是用于设置提示文本,也就是我们前面UI中显示出的"Nudge your friend",最近在微信上也很火。

简单文本消息发送.

简单文本消息的发送分为三步:

  1. 发送方在自己的编辑区键入向发送的消息,并且按下Send按钮(也可以是另外的触发方式);
  2. 客户端发送标记为【聊天】的请求,交付给Server处理,理想的Server能够判断出这一聊天信息是群聊的还是私发的,并且进行正确的回复;
  3. 客户端收到回复后,判断自己是否需要显示出这一消息(会存在私发消息)。

这部分的代码过于冗长,只选出比较重要的语句进行展示逻辑关系(直接拷贝是无法运行的,代码是错的)。

public void SendMessage()
{
	//Get text message.
	String ThisMessage = RemainToSendArea.getText();
	
	//Interaction between client and server.
	if(One_OneChat.isSelected())
	{
		ThisMessage.setReceiver(ChosenUser);
	}
	ThisMessage.setSender(ThisUser);
	
	Plea plea = new Plea();
	plea.setAction("Chat");
	plea.setData("Message", ThisMessage);
	try
	{
		ClientToServer.SendMessage(plea);
	}
	catch(IOException e)
	{
		e.printStackTrace();
	}
	
	//This client should display message,
	//regardless of public chat or private chat
	RemainToSendArea.setText("");
	ClientToServer.appendText(ThisMessage.getContent());

私聊设置.

私聊的设定类似于腾讯会议中的设定,用户可以选择另一个用户发起私聊,而其余的用户无法看到他们之间的聊天内容。展示如下,其中Hoe与Mega正在进行私聊:
在这里插入图片描述
在这里插入图片描述
而此时,第三位用户的视角是这样的:
在这里插入图片描述
显然Hoe与Mega的对话只在他们两人的界面上显示了出来。

if(One_OneChat.isSelected())
//Chat one to one.
{
	if(null==ChosenUser)
	{
		JOptionPane.showMessageDialog(ClientChat.this, "Please choose a user.",
			"ERROR", JOptionPane.ERROR_MESSAGE);
        return;
	}
	else if(ClientDataStore.thisUser.getID()==ChosenUser.getID())
	{
		JOptionPane.showMessageDialog(ClientChat.this, "Hey,you cannot talk oneself.",
        	"ERROR", JOptionPane.ERROR_MESSAGE);
        return;
	}
else
{
	ThisMessage.setReceiver(ChosenUser);
}

这段代码在私聊选项框One_OneChat被选中的时候,如果此时选择的用户是一个合法的私聊对象,那么客户端会在即将发送出去的请求中指明这一条消息的接收者,这就是Server用于区分群发消息和私聊消息的标识。在Server的代码中,有如下的一段:

if(message.getReceiver()!=null)
//Private chat.
{
	//Get receiver's id.
	ServerRecordClient service = 
		ServerDataStore.OnlineInfoMap.get(message.getReceiver().getID());
	//Only reply to receiver.
	SendReply(service, reply);
}
else
//Group chat.
{
	//Reply to all the users except sender.
	for(Long ID:ServerDataStore.OnlineInfoMap.keySet())
	{
		if(message.getSender().getID()==ID)
		{
			//Skip sender.
			continue;
		}
		else
		{
			ServerRecordClient service=ServerDataStore.OnlineInfoMap.get(ID);
			SendReply(service,reply);
		}
	}
}

总结来说就是Server认为没有指明接收方的消息是群发消息,所以它会向除了发送方以外的所有客户端发送回复,而如果是一条私聊消息,Server就只会向接收方一个客户端发送回复。接收到回复的客户端,会根据回复中的标识,来进行相应的动作。

if(type==ReplyType.CHAT)
{
	//Chat.
	Message message  =(Message) reply.getData("TextMessage");
	ClientToServer.appendText(message.getContent());
}

客户端会从Server发送的回复中得知这一条消息的内容,之后将其显示在自己的界面上。那么没有收到回复的客户端,自然也谈不上显示消息了。至此,我们大体上叙述完了的文本消息的发送以及接收的过程,并且展示了体现设计思路的代码。

窗口抖动发送.

窗口抖动这一功能我们平时也不少用,ClientChat辅助功能按钮中从左数第三个就是发送窗口抖动的按钮,除了在聊天框中显示出"xxx is shaking xxx"这样的提示消息之外,也借助于Java-Swing组件中的setLocation方法,模拟了实际的抖动效果。下图中Hoe在22:37:22抖动了Mega,并且Mega也确实接收到了抖动。动态的抖动效果可以自行下载项目的完整代码查看。
在这里插入图片描述
在这里插入图片描述
我们将执行窗口抖动的方法绑定到ShakingButton上,当该按钮被触发并且符合发送抖动的条件时,客户端就会封装一个标记为【抖动】的请求,发送到Server,和处理文本消息类似的,Server也会向接收方发送一个回复,接收方的客户端根据回复中的内容,做出正确的反应。下面是说明逻辑的代码,首先是发送方请求"抖动":

//Shaking your friend.
ShakingButton.addActionListener(new ActionListener() 
{
	@Override
	public void actionPerformed(ActionEvent e) 
	{
		Shaking();
	}
});

private void shaking()
{
	ThisMessage.setReceiver(ChosenUser);

	//Send plea which is marked with "Shake".
	Plea plea = new Plea();
	plea.setAction("Shake");
	plea.setData("Message", ThisMessage);
}

Server在接收到【抖动】请求后,如果判断该请求合理(类似抖动自己这样的就是不合理的),就会向被抖动方,也就是接收方发出回复:

//Mark reply with "Shake"
reply.setType(ReplyType.SHAKE_WINDOW);
reply.setData("Shake", message);
		
//Get receiver's id.
ServerRecordClient service = 
	ServerDataStore.OnlineInfoMap.get(message.getReceiver().getID());
		
//Send reply.
SendReply(service,reply);

被抖动方接收到了Server发来的回复后,客户端就要执行相应的动作了:

//Start shaking.
new ShakeFrame(ThisFrame).StartShake();

关于ShakeFrame效果的实现,实际上就是通过Java提供的定时器Timer来周期性地使用setLocation()方法更改窗口的位置,高频的更改就会有抖动的效果。

ShakeTimer = new Timer((int)(SHAKE_CYCLE/5),new ActionListener() 
{
	@Override
	public void actionPerformed(ActionEvent e) 
	{
		long pass = System.currentTimeMillis()-StartTime;
				
		double ShakeOffset = (pass%SHAKE_CYCLE)/SHAKE_CYCLE;
		double Angle = ShakeOffset*Math.PI;
				
		int Shake_x = (int)(Math.sin(Angle)*SHAKE_DISTANCE+Location.x);
		int Shake_y = (int)(Math.sin(Angle)*SHAKE_DISTANCE+Location.y);
		Frame.setLocation(Shake_x,Shake_y);
				
		if(pass>=SHAKE_DURATION)
		{
			StopShake();
		}
	}
});

ShakeTimer.start();

在线用户以及【我】信息展示.

首先需要明确的是,在用户进行登录的时候,我们是可以知道这个用户是谁的。换言之,我们可以记录下这一用户的信息,包括但不限于头像、昵称以及账号,而后我们在初始化ClientChat界面时,就可以完成对【我】的信息的展示。代码如下:

if(null!=ClientDataStore.thisUser)
{
	ThisUserLabel.setForeground(Color.BLUE);
	ThisUserLabel.setIcon(new ImageIcon(
    	"D:\\NewDesktop\\" + ClientDataStore.thisUser.getProfile() + ".png"));
	ThisUserLabel.setText(ClientDataStore.thisUser.getNickname()
    	+ "(" + ClientDataStore.thisUser.getID() + ")");
	ThisUserLabel.setOpaque(true);
	ThisUserLabel.setBackground(Color.WHITE);
}

至于所有用户信息的展示,我们说过用户在登录时,客户端是需要与Server发生交互的,我们在Server中专门设置了一个列表来记录当前的在线用户,而Server完全可以在给客户端发送回复时,将它记录的关于在线用户的数据包含在回复中,所以客户端也能够知道当前在线的用户情况。
当有用户登录时,Server会向每一个客户端发送回复,其中就包含了当前在线用户的数据:

ServerDataStore.OnlineUserMap.put(user.getID(), user);

reply.setData("OnlineUsers", new CopyOnWriteArrayList<ADT_of_User>
	(ServerDataStore.OnlineUserMap.values()));

然后客户端从收到的回复中,提取出有关在线用户信息的数据,而后根据数据设置界面上的显示

ClientDataStore.onlineUsers=(List<ADT_of_User>)reply.getData("OnlineUsers");

OnlineUsersCount.setText("Online Users List 【"
	+ClientDataStore.onlineUsers.getSize()+"】");

后续的关于表示每一个用户的可选项的显示,就用了Java提供的ListCellRenderer接口,顾名思义,是用于呈现每一个小格子的接口。我们在实现该接口的UserCellRenderer类中有这样一段代码,用于设置显示出来的内容:

ADT_of_User Abstract_User = (ADT_of_User)obj;
String Name = Abstract_User.getNickname()+"【"+Abstract_User.getID()+"】";
setText(Name);

而后在ClientChat中,我们借助于客户端接收到的在线用户的数据,完成了在线用户列表的显示:

OnlineUsersList = new JList(ClientDataStore.onlineUsers);
OnlineUsersList.setCellRenderer(new UserCellRenderer());
OnlineUsersList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);

文件发送.

文件的发送是所用功能中最复杂的一个,其基本流程分为以下三步:

  1. 发送方发出【即将发送文件】的请求,选择好即将发送的文件,接收方得知这一请求;
  2. 接收方用户进行选择,同意接收或者拒绝接收;
  3. 若接收方同意接收,则发送方开始实际传输该文件;否则直接结束这一流程。

上述流程的描述,是以发送方和接收方为主体的,但实际上它们之间并没有直接的交流,它们都需要通过Server来同对方进行交互。

1.Sender发送【ToSendFile】请求.

当发送方用户按下SendFile时,ClientChat界面会弹出文件选择框让该用户选择即将发送的文件,之后向Server发送一个标记为【即将发送文件】的请求,等待回应。文件选择框的效果如下所示,在文件发送的最后我们介绍其用法:
在这里插入图片描述
能够说明逻辑的代码展示如下:

//Select file.
File file = FileChooser.getSelectedFile();
FileToSend = new FileData();

//Set receiver and sender.
FileToSend.setSender(ClientDataStore.thisUser);
FileToSend.setReceiver(ChosenUser);

//Sender send a plea.
Plea plea = new Plea();

//'plea' maeked with 'ToSendFile'.
plea.setAction("ToSendFile");
plea.setData("File", FileToSend);

2.Server收到【ToSendFile】请求.

在上一步的代码中,请求中已经指明了待发送文件的接收者,所以Server可以精准地向该用户发送回复,并且在回复中标记上【即将发送文件】,从而使得接收方的客户端能够进行后续的处理。

//Mark 'reply' with 'GOING_TO_SEND_FILE'.
reply.setType(ReplyType.GOING_TO_SENT_FILE);
FileData file = (FileData)plea.getData("File");
reply.setData("SendFile", file);

//Get reveiver id.		
ServerRecordClient service = get(file.getReceiver().getID());

//Send reply.
SendReply(service,reply);	

3.Receiver收到【GOING_TO_SEND_FILE】回复.

当Receiver客户端收到Server发来的回复后,会弹出一个询问框请求用户的指示,如果用户选择了【同意接收】,那么接收方客户端会从Server发来的回复中获取待发送文件的信息,在本地选择一个保存文件的位置,并且向Server发送一个【AgreeReceiveFile】的请求;如果用户选择【拒绝接收】,那么接收方客户端直接向Server发送一个【RefuseReceiveFile】的请求。

//Inquiry the user.
int Choice = JOptionPane.showConfirmDialog(ThisFrame,
	SenderInfo+" want to send a file【"+FileName+"】 to you.\nWill you agree?",
	"Accept file.",JOptionPane.YES_NO_OPTION);

if(Choice==JOptionPane.YES_OPTION)
//Agree to receive file.
{
	//Choose position to save file.		
	FileChooser.showSaveDialog(ThisFrame);
	
	//Send a plea marked with 'AgreeReceiveFile'.
	plea.setAction("AgreeReceiveFile");
}
else
{
	//Send a plea marked with 'RefuseReceiveFile'.
	plea.setAction("RefuseReceiveFile");
}

4.Server收到【Agree/RefuseReceiveFile】请求.

Server收到来自Receiver的【同意接收】或者【拒绝接收】请求后,都需要向Sender发送一个回复,用于让Sender客户端进行后续的处理。当Receiver发送【同意接收】请求后,Server一方面向Sender发送一个【接收方同意接收】的回复,指示Sender进行文件的实际传输;另一方面给Receiver发送一个【做好接收准备】的回复,Sender即将实际传输文件过来。

//Reply to sender.[AGREE_RECEIVE_FILE].
reply.setType(ReplyType.AGREE_RECEIVE_FILE);
ServerRecordClient SenderRecord = get(file.getSender().getID());
SendReply(SenderRecord,reply);

//Reply to receiver.[RECEIVE_FILE].
anotherReply.setType(ReplyType.RECEIVE_FILE);
ServerRecordClient ReceiverRecord = get(file.getReceiver().getID());
SendReply(ReceiverRecord,anotherReply);

5.Sender收到【AGREE_RECEIVE_FILE】回复.

当Sender收到表明接收方已经同意接收文件的【AGREE_RECEIVE_FILE】回复后,就通过Sender和Server之间建立的Socket连接中的输出流进行实际的文件传输。

socket = new Socket(SendFile.getDestIP(),SendFile.getDestPort());
Buffer_OS = new BufferedOutputStream(socket.getOutputStream());
Buffer_OS.write(File);

6.Receiver收到【RECEIVE_FILE】回复.

当Receiver收到表明自己应该做好接受准备的【RECEIVE_FILE】回复后,就通过自己和Server之间的Socket连接,将实际的文件写到在第3步中已经选定好的保存位置。

Ssocket = new ServerSocket(SendFile.getDestPort());
socket = Ssocket.accept();
Buffer_OS = new BufferedOutputStream(new FileOutputStream(SendFile.getDestFilename()));
Buffer_OS.write(File);

★至此整个文件发送、接收流程已经结束,在下面展示出整个流程的UI界面。
首先是发送方选定要发送的文件:
在这里插入图片描述
接收方收到询问:
在这里插入图片描述
接收方同意接收,选定保存位置:
在这里插入图片描述
整个传输过程完成:
在这里插入图片描述

关于JFileChooser.

这是一个JavaSwing中很强大的控件,提供的就是文件选择功能,并且语法简单易于使用。

//Create a FileChooser
JFileChooser FileChooser = new JFileChooser();

//Select a file.
if(FileChooser.showOpenDialog(ClientChat.this)==JFileChooser.APPROVE_OPTION)
{
	File file = FileChooser.getSelectedFile();
	FileToSend = new FileData();
	FileToSend.setSender(ThisUser);
	FileToSend.setReceiver(ChosenUser);
}

猜你喜欢

转载自blog.csdn.net/weixin_44246009/article/details/107432390