请尊重他人的劳动成果,转载请注明出处:Android开发之简单的聊天室(客户端与服务器进行通信)
TCP/IP 是Transmission Control Protocol/Intemet Protocol的简写,中文译名为传输控制协议/因特网互联协议,又叫网络通信协议,这个协议是Internet最基本的协议,是Internet国际互联网络的基础,简单地说,就是由网络层的IP协议和传输层的TCP协议组成的。TCP/IP协议遵循的是一个抽象的分层模型,这个模型中所有的TCP/IP系列网络协议都被归类到4个抽象的“层”中。每一抽象层建立在低一层提供的服务上,并且为高一层提供服务。完成一些特定的任务需要众多的协议协同工作,这些协议分布在参考模型的不同层中,因此有时称它们为一个协议栈。
TCP/IP参考模型从下到上分别包括网络接口层、网络互连层、传输层、应用层四层经常使用的包括HTTP (万维网服务)、FTP (文件传输)、SMTP (电子邮件)、SSH (安全远程登录)、DNS (IP地址寻找)在内的许多协议都被认为运行在TCP/IP协议栈的应用层之上。每一个应用层协议一般都会使用到两个传输层协议之一:面向连接的TCP传输控制协议和无连接的包传输UDP用户数据报文协议。
传输层的协议能够解决诸如端到端可靠性(数据是否已经到达目的地)和保证数据按照正确的顺序到达这样的问题。
TCP是一个“可靠的”、面向连接的传输机制,它提供一种可靠的字节流保证数据完整、无损并且按顺序到达。TCP尽置连续不断地测试网络的负载并且控制发送数据的速度以避免网络过载。另外,TCP试图将数据按照规定的顺序发送。这是它与UDP不同之处,但这在实时数据流或者路由高网络层丢失率应用的时候可能成为一个缺陷。
UDP是一个无连结的数据报协议。它是一个“尽最大努力交付”或者“不可靠”协议,不是因为它特别“不可靠”,而是因为它不检查数据包是否已经到达目的地,并且不保证它们按顺序到达。如果一个应用程序需要这些特点,它必须自己提供或者使用TCP。UDP的典型应用是如流媒体(音频和视频等)这样按时到达比可靠性更重要的应用,或者如DNS査找这样的简单查询/响应应用,如果建立可靠的连接则所做的额外工作将是很多的。
在网络上的两个程序通过一个双向的通信连接实现数据的交换,这个双向链路的一端称为一个Socket。Socket通常用来实现客户方和服务方的连接。Socket是TCP/IP协议的一个十分流行的编程界面,一个Socket由一个IP地址和一个端口号唯一确定。在传统的UNIX环境下可以操作TCP/IP协议的接口不止Socket—个,Socket所支持的协议种类也不光TCP/IP一种,因此两者之间是没有必然联系的。不过在Java环境下,Socket编程主要是指基于TCP/IP协议的网络编程。也就是说在Java环境下我们实现基于TCP/IP协议的网络编程需要采用 Socket机制。
Socket编程比基于URL的网络编程提供了更高的传输效率、更强大的功能和更灵活的控制,但是却要更复杂一些。由于Java本身的特殊性,Socket编程在Java中可能已经是层次最低的网络编程接口,在Java屮要直接操作协议中更低的层次,需要使用Java的本地方法调用 (JNI),在这里就不予讨论了。
Android中进行Socket编程与普通Java程序所进行的Socket编程的方式保持一致,不同的是数据的来源以及显示上有所区别。采用Java语言开发的一些网络编程的应用比如最经典的聊天室应用可以很容易地移植到Android平台上。而由于TCP协议要比UDP协议的应用广泛,如常用的HTTP、FTP、SMTP等协议都是采用TCP协议,因此这里主要介绍Android中基于 TCP协议的Socket编程。
Socket通常用来实现C/S结构。使用Socket进行Client/Server程序设计的一般连接过程是这样的:Server端监听某个端口是否有连接请求,Client端向Server端发出连接请求,Server端向Client端发回Accept (接受)消息,一个连接就建立起来了。Server端和Client端都可以通过Send、Write等方法与对方通信。Java在包java.net中提供了两个类Socket和ServerSocket,分别用来表示双向连接的客户端和服务端。
Java中能接收其他通信实体连接请求的类是ServerSocket,ServerSocket对象用于监听来自客户端的Socket连接,如果没有连接,它将一直处于等待状态。ServerSocket包含一个监听来自客户端连接请求的方法。
1) Socket accept():如果接收到一个客户端Socket的连接请求,该方法将返回一个与连接客户端Socket对应的Socket;否则该方法将一直处于等待状态,线程也被阻塞。
创建ServerSocket对象,ServerSocket类提供了如下几个构造器:
2) ServerSocket(int port):用指定的端口 port来创建一个ServerSocket。该端口应该是有一个有效的端口整数值:0〜65 535。
3) ServerSocket(int port,int backlog):增加一个用来改变连接队列长度的参数backlog。
4) ServerSocket(int port.int backlog,lnetAddresslocalAdd():在机器存在多个 IP地 址的情况下,允许通过localAddr这个参数来指定将ServerSocket绑定到指定的IP地址。
注:当ServerSocket使用完毕后,应使用ServerSocket的close()方法来关闭该ServerSocket。通常情况下,服务器不应该只接收一个客户端请求,而应该不断地接收来自客户端的所有请求。如下面代码所示:
//创建一个ServerSocket,用于监听客户端的连接请求 ServerSocket ss=new ServerSocket(1566); //不停地从接收来自客户端的请求 while (true) { //每当接受一个来自客户端的Socket的请求,服务器端也对应产生一个Socket Socket s=ss.accept(); //下面就可以使用Socket进行通信了 //.......... } |
客户端通常可使用Socket的构造器来连接到指定服务器,Socket通常可使用如下两个构造器。
1) Socket(lnetAddress/String remoteAddress, int port):创建连接到指定远程主机、远程端口的Socket,该构造器没有指定本地地址、本地端口,默认使用本地主机的默认IP地址,默认使用系统动态指定的IP地址。
2) Socket(lnetAddress/String remoteAddress, int port, InetAddress localAddr,int localPort):创建连接到指定远程主机、远程端口的Socket,并指定本地IP地址和本地端口号,适用于本地主机有多个IP地址的情形。
上面两个构造器中指定远程主机时既可使用InetAddress来指定,也可直接使用String对象来指定,但程序通常使用String对象(如211.158.6.26)来指定远程IP。当本地主机只有—个IP地址时,使用第一个方法更为简单。如:
Socket socket=new Socket("169.254.77.36", 8888); //下面就可以和服务器进行通信了 |
当程序执行上面代码中的粗体字代码时,该代码将会连接到指定服务器,让服务器端的ServerSocket的accept()方法向下执行,于是服务器端和客户端就产生一对互相连接的Socket。
当客户端、服务器端产生了对应的Socket之后,程序无须再区分服务器、客户端,而是通过各自的Socket进行通信。Socket提供如下两个方法来获取输入流和输出流:
1) InputStreamgetlnputStream():返回该Socket对象对应的输入流,让程序通过该输入流从Socket中取出数据。
2) OutputStreamgetOutputStream():返回该Socket对象对应的输出流,让程序通过该输出流向Socket中输出数据。
服务器端:
publicstaticvoid main(String[] args) { // TODO Auto-generated method stub try { //创建一个ServerSocket,用于监听客户端的连接请求 ServerSocket ss=new ServerSocket(8888); //不停地从接收来自客户端的请求 while (true) { //每当接受一个来自客户端的Socket的请求,服务器端也对应产生一个Socket Socket s=ss.accept(); //下面就可以使用Socket进行通信了 OutputStream os=s.getOutputStream(); os.write("来自服务器端的消息:你好,今天天气不错,骚年外出散散心吧!".getBytes("utf-8")); //关闭输出流 os.close(); //关闭Socket s.close();
} } catch (Exception e) { // TODO Auto-generated catch block e.printStackTrace(); } } |
注:上面的程序并未把OutputStream流包装成PrintStream,然后使用PrintStream直接输出整个字符串,这是因为该服务器端程序运行于Windows主机上,当直接使用PrintStream输出字符串时默认使用系统平台的字符串(即GBK )进行编码;但该程序的客户端是Android应用,运行于Linux平台(Android是Linux内核的),因此当客户端读取网络数据时默认使用UTF-8字符集进行解码,这样势必引起乱码。为了保证客户端能正常解析到数据,此处手动控制字符串的编码,强行指定使用UTF-8字符集进行编码,这样就可以避免乱码问
客户端:
edtMsg=(EditText)findViewById(R.id.edtMsg); //创建并启动一个新线程,向服务器发送TCP请求 new Thread(){ @Override publicvoid run() { // TODO Auto-generated method stub super.run(); //创建一个Socket用于向IP为169.254.77.36的服务器的8888端口发送请求 Socket s; try { s =new Socket(); //如果超过10s还没连接到服务器则视为超时 s.connect(new InetSocketAddress("169.254.77.36", 8888),10000); //设置客户端与服务器建立连接的超时时长为30秒 s.setSoTimeout(30000); //将Socket对应的输入流封装成BufferedReader对象 BufferedReader br=new BufferedReader(new InputStreamReader(s.getInputStream())); String msg=br.readLine(); edtMsg.setText(msg); br.close(); s.close(); //捕捉SocketTimeoutException异常 }catch (SocketTimeoutException e) { //TODO Auto-generated catch block e.printStackTrace(); }catch (Exception e) { //TODO: handle exception e.printStackTrace(); } } }.start(); |
最后别忘记为程序添加访问网络的权限:
<uses-permissionandroid:name="android.permission.INTERNET"/> |
程序运行效果图:
上面的程序为了突出通过ServerSocket和Socket建立连接并通过底层IO流进行通信的主题,程序没有进行异常处理,也没有使用finally块来关闭资源。
实际应用中,程序可能不想让执行网络连接、读取服务器数据的进程一直阻塞,而是希望当网络连接、读取操作超过合理时间之后,系统自动认为该操作失败,这个合理时间就是超时时长。Socket对象提供了一个setSoTimeout(int timeout)来设置超时时长,如下面的代码片段所示:
//设置客户端与服务器建立连接的超时时长为30秒 s.setSoTimeout(30000); |
为Socket对象指定了超时时长之后,如果使用Socket进行读、写操作完成之前已经超出了该时间限制,那么这些方法就会抛出SocketTimeoutException异常,程序可以对该异常进行捕捉,并进行适当处理,如以下代码所示:
Socket s; try { s =new Socket(); //如果超过10s还没连接到服务器则视为超时 s.connect(new InetSocketAddress("169.254.77.36", 8888),10000); //设置客户端与服务器建立连接的超时时长为30秒 s.setSoTimeout(30000); //将Socket对应的输入流封装成BufferedReader对象 BufferedReader br=new BufferedReader(new InputStreamReader(s.getInputStream())); String msg=br.readLine(); edtMsg.setText(msg); br.close(); s.close(); //捕捉SocketTimeoutException异常 }catch (SocketTimeoutException e) { //进行异常处理 } |
假设程序需要为Socket连接服务器时指定超时时长:即经过指定时间后,如果该Socket还未连接到远程服务器,则系统认为该Socket连接超时。但Socket的所有构造器里都没有提供指定超时时长的参数,所以程序应该先创建一个无连接Socket,再调用Socket的connect()方法来连接远程服务器,connect()方法就可以接受一个超时时长参数。如以下代码所示:
//创建一个无连接的Socket Socket s= new Socket(); //如果超过10s还没连接到服务器则视为超时 s.connect(new InetSocketAddress("169.254.77.36", 8888),10000); |
前面服务器端和客户端只是进行了简单的通信操作:服务器接收到客户端连接之后,服务器向客户端输出一个字符串,而客户端也只是读取服务器的字符串后就退出了。实际应用中的客户端则可能需要和服务器端保持长时间通信,即服务器需要不断地读取客户端数据,并向客户端写入数据;客户端也需要不断地读取服务器数据,并向服务器写入数据。
当使用传统BufferedReader的readLine()方法读取数据时,当该方法成功返回之前,线程被阻塞,程序无法继续执行。考虑到这个原因,服务器应该为每个Socket单独启动一条线程,每条线程负责与一个客户端进行通信。
客户端读取服务器数据的线程同样会被阻塞,所以系统应该单独启动一条线程,该线程专门负责读取服务器数据。
下面考虑实现一个简单的C/S聊天室应用,服务器端则应该包含多条线程,每个Socket对应一条线程,该线程负责读取Socket对应输入流的数据(从客户端发送过来的数据),并将读到的数据向每个Socket输出流发送一遍(将一个客户端发送的数据“广播”给其他客户端),因此需要在服务器端使用List来保存所有的Socket。
下面是服务器端的实现代码,程序为服务器提供了两个类,一个是创建ServerSocket监听的主类,另一个是负责处理每个Socket通信的线程类。
代码清单:
服务器端:
ServerSocket监听的主类:
import java.io.IOException; import java.net.ServerSocket; import java.net.Socket; import java.util.ArrayList; /** * Description: * 创建ServerSocket监听的主类 *@author jph */ publicclass MyServer { // 定义保存所有Socket的ArrayList publicstatic ArrayList = new ArrayList publicstaticvoid main(String[] args) throws IOException { ServerSocket ss = new ServerSocket(30000); while(true) { // 此行代码会阻塞,将一直等待别人的连接 Socket s = ss.accept(); socketList.add(s); // 每当客户端连接后启动一条ServerThread线程为该客户端服务 new Thread(new ServerThread(s)).start(); } } } |
负责处理每一个Socket通信的线程类:
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.Socket; /** * Description: * 负责处理每一个Socket通信的线程类 *@author jph */ // 负责处理每个线程通信的线程类 publicclass ServerThreadimplements Runnable { // 定义当前线程所处理的Socket Socket s =null; // 该线程所处理的Socket所对应的输入流 BufferedReader br =null; public ServerThread(Socket s) throws IOException { this.s = s; // 初始化该Socket对应的输入流 br =new BufferedReader(new InputStreamReader( s.getInputStream() ,"utf-8")); //② } publicvoid run() { try { String content =null; // 采用循环不断从Socket中读取客户端发送过来的数据 while ((content = readFromClient()) !=null) { // 遍历socketList中的每个Socket, // 将读到的内容向每个Socket发送一次 for (Socket s : MyServer.socketList) { OutputStream os = s.getOutputStream(); os.write((content +"\n").getBytes("utf-8")); } } } catch (IOException e) { e.printStackTrace(); } } // 定义读取客户端数据的方法 private String readFromClient() { try { returnbr.readLine(); } // 如果捕捉到异常,表明该Socket对应的客户端已经关闭 catch (IOException e) { // 删除该Socket。 MyServer.socketList.remove(s); //① } returnnull; } }
|
上面的服务器端线程类不断读取客户端数据,程序使用readFromCHent()方法来读取客户端数据,如果读取数据过程中捕获到IOException异常,则表明该Socket对应的客户端Socket出现了问题(到底什么问题我们不管,反正不正常),程序就将该Socket从socketList中删除,如readFromClient()方法中①号代码所示。
当服务器线程读到客户端数据之后,程序遍历socketList集合,并将该数据向socketList集合中的每个Socket发送一次一该服务器线程将把从Socket中读到的数据向socketList中的每个Socket转发一次,如run()线程执行体中的粗体字代码所示。
注:
上面的程序中②号粗体字代码将网络的字节榆入流转换为字符输入流时,指定了转换所用的字符串:UTF-8,这也是由于客户端写过来的数据是采用UTF-8字符集进行编码的,所以此处的服务器端也要使用UTF-8字符集进行解码。当需 要编写跨平台的网络通信程序时,使用UTF-8字符集进行编码、解码是一种较好的解决方案。
每个客户端应该包含两条线程:一条负责生成主界面,并响应用户动作,并将用户输入的数据写入Socket对应的输出流中:另一条负责读取Socket对应输入流中的数据(从服务器发送过来的数据),并负责将这些数据在程序界面上显示出来。
客户端:
客户端程序同样是一个Android应用,因此需要创建一个Android项目,这个Android应用的界面中包含两个文本框:一个用于接收用户输入,另一个用于显示聊天信息:界面中还有一个按钮,当用户单击该按钮时,程序向服务器发送聊天信息。该程序的界面布局代码如下。
/** * 客户端: * */ publicclass MultiThreadClientextends Activity { // 定义界面上的两个文本框 EditText input; TextView show; // 定义界面上的一个按钮 Button send; Handler handler; // 定义与服务器通信的子线程 ClientThread clientThread; @Override publicvoid onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); input = (EditText) findViewById(R.id.input); send = (Button) findViewById(R.id.send); show = (TextView) findViewById(R.id.show); handler =new Handler()//① { @Override publicvoid handleMessage(Message msg) { // 如果消息来自于子线程 if (msg.what == 0x123) { // 将读取的内容追加显示在文本框中 show.append("\n" + msg.obj.toString()); } } }; clientThread =new ClientThread(handler); // 客户端启动ClientThread线程创建网络连接、读取来自服务器的数据 new Thread(clientThread).start();//① send.setOnClickListener(new OnClickListener() { @Override publicvoid onClick(View v) { try { // 当用户按下发送按钮后,将用户输入的数据封装成Message, // 然后发送给子线程的Handler Message msg =new Message(); msg.what = 0x345; msg.obj =input.getText().toString(); clientThread.revHandler.sendMessage(msg); // 清空input文本框 input.setText(""); } catch (Exception e) { e.printStackTrace(); } } }); } } |
代码分析:
当用户单击该程序界而中的“发送”按钮之后,程序将会把input输入框中的的内容发送该clientThread的revHandler对象,clientThread将负责将用户输入的内容发送给服务器。
为了避免UI线程被阻塞,该程序将建立网络连接、与网络服务器通信等工作都交给 ClientThread线程完成。因此该程序在①号代码处启动ClientThread线程。
由于Android不允许子线程访问界面组件,因此上面的程序定义了一个Handler来处理来自子线程的消息,如程序中②号粗体字代码所示。
ClientThread子线程负责建立与远程服务器的连接,并负责与远程服务器通信,读到数据之后便通过Handler对象发送一条消息:当ClientThread子线程收到UI线程发送过来的消息(消息携带了用户输入的内容)之后,还负责将用户输入的内容发送给远程服务器。该子线程代码如下:
publicclass ClientThreadimplements Runnable { private Sockets; // 定义向UI线程发送消息的Handler对象 private Handlerhandler; // 定义接收UI线程的消息的Handler对象 public HandlerrevHandler; // 该线程所处理的Socket所对应的输入流 BufferedReader br =null; OutputStream os =null;
public ClientThread(Handler handler) { this.handler = handler; }
publicvoid run() { try { //192.168.191.2为本机的ip地址,30000为与MultiThreadServer服务器通信的端口 s =new Socket("192.168.191.2", 30000); br =new BufferedReader(new InputStreamReader( s.getInputStream())); os =s.getOutputStream(); // 启动一条子线程来读取服务器响应的数据 new Thread() { @Override publicvoid run() { String content =null; // 不断读取Socket输入流中的内容。 try { while ((content =br.readLine()) !=null) { /span>// 每当读到来自服务器的数据之后,发送消息通知程序界面显示该数据 Message msg =new Message(); msg.what = 0x123; msg.obj = content; handler.sendMessage(msg); } } catch (IOException e) { e.printStackTrace(); } } }.start(); // 为当前线程初始化Looper Looper.prepare(); // 创建revHandler对象 revHandler =new Handler() { @Override publicvoid handleMessage(Message msg) { // 接收到UI线程中用户输入的数据 if (msg.what == 0x345) { // 将用户在文本框内输入的内容写入网络 try { os.write((msg.obj.toString() + "\r\n") .getBytes("utf-8")); } catch (Exception e) { e.printStackTrace(); } } } }; // 启动Looper Looper.loop(); } catch (SocketTimeoutException e1) { System.out.println("网络连接超时!!"); } catch (Exception e) { e.printStackTrace(); } } } |
实例分析:
上面线程的功能也非常简单,它只是不断获取Socket输入流中的内容,当读到Socket输入流中的内容后,便通过Handler对象发送一条消息,消息负责携带读到数据,除此之外,该子线程还负责读取UI线程发送的消到消息之后,该子线程负责将消息中携带的数据发送给远程服务器。
先运行上面程序中的MyServer类,该类运行后只是作为服务器,看不到任何输出。接着可以运行Android客户端一相当于启动聊天室客户端登录该服务器,接着可以看到在任何一个Android客户端输入一些内容后单击“发送”按钮,将可看到所有客户端(包括自己)都会收到他刚刚输入的内容,如上图所示,这就粗略实现了一个C/S结构聊天室的功能。