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",最近在微信上也很火。
简单文本消息发送.
简单文本消息的发送分为三步:
- 发送方在自己的编辑区键入向发送的消息,并且按下Send按钮(也可以是另外的触发方式);
- 客户端发送标记为【聊天】的请求,交付给Server处理,理想的Server能够判断出这一聊天信息是群聊的还是私发的,并且进行正确的回复;
- 客户端收到回复后,判断自己是否需要显示出这一消息(会存在私发消息)。
这部分的代码过于冗长,只选出比较重要的语句进行展示逻辑关系(直接拷贝是无法运行的,代码是错的)。
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);
文件发送.
文件的发送是所用功能中最复杂的一个,其基本流程分为以下三步:
- 发送方发出【即将发送文件】的请求,选择好即将发送的文件,接收方得知这一请求;
- 接收方用户进行选择,同意接收或者拒绝接收;
- 若接收方同意接收,则发送方开始实际传输该文件;否则直接结束这一流程。
上述流程的描述,是以发送方和接收方为主体的,但实际上它们之间并没有直接的交流,它们都需要通过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);
}