简介:基于Android平台的聊天软件毕业设计资料包,内含完整论文与源码工程,适用于计算机相关专业毕业设计、课程设计或移动开发学习者。系统采用C/S模型,基于TCP/IP协议与多线程技术,在MVC架构下完成服务器端登录验证、信息转发,以及客户端登录、注册、消息发送、好友管理、设置与退出等模块。论文部分详细论述选题背景、Android体系结构、系统总体设计流程及功能模块图,并给出运行测试界面,从绪论到实现均有清晰呈现。压缩包约14.21MB,内容以论文文档和Android工程源码为主,目录层次与论文章节对应,方便按模块检索和二次开发。已有86人学习,适合需要完整参考毕业设计流程、理解即时通讯原理并快速搭建项目的人。
1. 为什么一个“带论文的Android聊天软件”值得再拆一遍
前阵子帮学弟调一份毕业设计,课题名是“基于android开发平台的聊天软件实现”,材料是论文加源码一起给的。源码能在android studio里编译,但装上手机后一登录就崩,问题不在界面,而是Socket连接被写在了主线程。这个项目虽然叫聊天软件,本质是一个完整的C/S通信Demo:Android客户端负责登录、注册、消息发送、设置、添加好友、退出登录六个模块,纯Java服务端负责登录验证与消息转发。技术栈非常教科书——Android平台、TCP/IP、C/S模型、MVC架构、多线程,但它把即时通讯从“概念”变成了“可运行的App”。如果你是做毕业设计想找能跑的成品,或者刚接触Android网络开发,想知道一条消息从发送到对方收到中间经历了什么,这篇文章值得看完。
2. 先定架构:C/S模型与Android MVC到底在做哪几件事
2.1 为什么不是P2P:C/S模型在聊天工程里的取舍
聊天软件第一个要确认的问题,是客户端之间怎么建立通信。很多人上来就选Socket直连,觉得省一台服务器,但真做起来会发现P2P有NAT穿透和在线状态管理两个坎。学生课题的网络环境通常是校园网或实验室局域网,打洞成功率不稳定,排错成本极高。这个项目采用C/S模型,所有消息都必须经过服务端中转,好处很直接:在线状态统一维护,消息转发逻辑收敛在服务端一处,客户端只要关心怎么连服务器。
下面这张表可以直接用在论文的“技术选型”小节里,说明为什么不用P2P。
| 对比项 | C/S模型 | P2P模型 |
|---|---|---|
| 在线状态维护 | 服务端统一管理 | 需额外探测或服务器辅助 |
| 消息可达性 | 双方在线即可中转 | 目标离线则无法送达 |
| 开发复杂度 | 一套客户端一套服务端 | 需处理穿透,复杂度高 |
| 适用范围 | 小规模即时通讯、教学项目 | 大文件传输、去中心化场景 |
| 排错难度 | 抓服务端日志即可定位 | 问题分散在两端,难复现 |
选C/S还有一层原因:论文里要画“服务器功能模块图”和“客户端功能模块图”,C/S模型天然能把这两张图画清楚。服务端就两个核心模块,登录验证和信息转发;客户端六个模块,后面第四章逐个拆。
2.2 MVC在Android工程里的实际落点:XML是View,Activity是Control
论文里写“Android的MVC架构”,不要想象成Spring MVC那种重型框架。Android里最常见的分工约定是:XML布局文件负责View,Activity负责Control,自定义Java类负责Model。登录界面的输入框、按钮是View层;按钮的点击监听、界面跳转是Control层;账号数据、消息内容封装成实体类,是Model层。
这个项目里贯穿全工程的核心Model是Message,它不只是普通消息,还兼任“登录请求、注册请求、好友申请”等所有通信报文的载体。先把这个类定义清楚,后面所有模块都好写。
// Message.java —— 客户端与服务端共用的消息实体 public class Message { private String from; // 发送者登录账号 private String to; // 接收者登录账号 private int type; // 0登录 1注册 2聊天消息 3添加好友 4退出 private String content; // 文本内容,登录/注册时存放账号或密码 private String time; // 客户端自动生成的时间戳 public Message(String from, String to, int type, String content) { this.from = from; this.to = to; this.type = type; this.content = content; } // getter/setter 必须补齐,Gson序列化时要反射调用 }type字段是这个工程的分流开关。服务端拿到一条消息先读type,决定走登录校验还是转发;客户端收到服务端回包,也靠type决定是跳转主界面还是弹Toast。新加一个功能模块,本质上就是新增一个type值和对应的处理分支。time字段用来在气泡消息里显示发送时刻,客户端发消息时用System.currentTimeMillis()格式化即可。
MVC在工程里的具体对应关系,可以按这个清单整理,写论文时不需要再加工:
- View层:
login_activity.xml、chat_activity.xml、消息列表的item_message.xml - Control层:
LoginActivity、ChatActivity里的setOnClickListener回调 - Model层:
Message、User,以及SharedPreferences保存的当前登录状态
2.3 多线程:接收循环为什么要从主线程摘出来
Android 4.0之后主线程不允许直接做网络访问,强行在主线程new Socket()会抛NetworkOnMainThreadException,这也是学弟的项目一登录就崩的直接原因。这个工程没有引入第三方网络框架,用的就是原始Thread加Handler,一共三处多线程:服务端连接监听线程、服务端每个Socket连接的工作线程、客户端消息接收线程。
客户端的接收线程是核心,它必须一直等待服务端推送消息,主线程不可能停下来等它。
// 客户端消息接收线程:阻塞读取服务端数据,再切回主线程更新UI private void startReceiving() { new Thread(() -> { String line; try { while ((line = reader.readLine()) != null) { Message msg = new Gson().fromJson(line, Message.class); // 子线程不能直接更新View,必须post到主线程 mainHandler.post(() -> handleIncomingMessage(msg)); } } catch (IOException e) { Log.e("ChatClient", "接收线程断开", e); } }).start(); }readLine()是阻塞方法,在没有数据时会一直等待,所以这段代码必须放在工作线程。mainHandler通过Looper.getMainLooper()创建,它post出来的Runnable一定在主线程执行。这里的套路就是:网络IO放工作线程,界面更新回主线程。如果是用android studio自带的模板改成协程,逻辑也一样,只是把Thread换成了Dispatchers.IO。
3. Java服务端两大模块:登录校验与信息转发共用一张在线用户表
3.1 服务端骨架:ServerSocket、每连接一线程、在线用户Map
服务端的结构比客户端更简单,核心就是三样东西:一个ServerSocket监听端口,一个ConcurrentHashMap保存在线用户,每个Socket连接对应一个工作线程。
为什么用每连接一线程而不是NIO?因为这个项目面向的是几十人规模的教学场景,连接数少,每连接一线程的写法最直观,出问题也好排查。NIO的Selector模型适合上千连接,但写进毕业论文后反而要想办法解释为什么不用学过的多线程技术,得不偿失。
// ChatServer.java —— 服务端启动入口 public class ChatServer { private ServerSocket serverSocket; // 在线用户表:key为登录账号,value为该账号对应的输出流 private final Map<String, PrintWriter> onlineUsers = new ConcurrentHashMap<>(); public void start(int port) throws IOException { serverSocket = new ServerSocket(port); System.out.println("服务已启动,监听端口: " + port); while (true) { Socket socket = serverSocket.accept(); // 阻塞等待新客户端接入 new Thread(new ClientHandler(socket)).start(); } } }accept()会一直阻塞,直到有新的客户端连接进来。每到一个客户端就启动一个线程,这样某个客户端的异常不会影响其他连接。onlineUsers这个Map是整个服务端的数据核心,登录时往里面放数据,退出时删除,消息转发时从里面查目标连接。所谓“服务器功能模块图”,本质就围绕这张表的增删查做文章。
这里是ClientHandler的内部实现框架,后面两节的登录和转发模块都挂在它身上:
private class ClientHandler implements Runnable { private Socket socket; private BufferedReader reader; private PrintWriter writer; private String username; @Override public void run() { try { String line; while ((line = reader.readLine()) != null) { Message msg = new Gson().fromJson(line, Message.class); switch (msg.getType()) { case 0: login(msg); break; case 1: register(msg); break; case 2: forward(msg); break; case 3: addFriend(msg); break; case 4: logout(msg); break; default: break; } } } catch (IOException e) { e.printStackTrace(); } finally { if (username != null) { onlineUsers.remove(username); System.out.println("用户下线: " + username); } } } }BufferedReader和PrintWriter的构造都在ClientHandler的构造函数里完成,编码统一用UTF-8,方便处理中文。客户端断开连接时readLine()返回null,循环退出,finally块里清理在线状态。
3.2 登录验证模块:账号密码校验与重复登录处理
登录逻辑放在type等于0的分支。客户端把登录账号放在Message的from字段,密码放在content字段,服务端校验通过后,把该账号和输出流writer的对应关系写入在线用户表。
private void login(Message msg) throws IOException { // 工程用本地账号表校验,真实生产环境应替换为数据库查询 if ("admin".equals(msg.getFrom()) && "123456".equals(msg.getContent())) { // 处理重复登录:同账号旧连接直接踢下线 PrintWriter old = onlineUsers.get(msg.getFrom()); if (old != null) { sendResult(-2, "您的账号在其他设备登录"); old.close(); onlineUsers.remove(msg.getFrom()); } onlineUsers.put(msg.getFrom(), writer); username = msg.getFrom(); sendResult(0, "登录成功"); } else { sendResult(-1, "账号或密码错误"); } }这里有两个容易被忽略的细节。第一,onlineUsers存的value是PrintWriter而不是Socket,因为服务端转发动作只有一个“写一行数据”,PrintWriter比Socket更好用。第二,重复登录处理,如果账号A已经在表里,又来一个同账号连接,不处理的话前一个连接还留在表里,消息就会发给旧连接,新设备反而收不到。我一般会先把旧连接关闭,再把新连接写进表里。
sendResult的实现就是反向使用writer,把一条type为负数的Message序列化后通过该客户端自己的输出流返回:
private void sendResult(int code, String msgText) { Message reply = new Message("server", username, code, msgText); writer.println(new Gson().toJson(reply)); }客户端收到type等于0的回复就跳转主界面,收到负数就提示错误。
3.3 信息转发模块:从表里查到目标输出流,println一行出去
消息转发的代码是这个项目里最短但最重要的一段,三个动作完成转发:查表、序列化、写入。
private void forward(Message msg) { PrintWriter target = onlineUsers.get(msg.getTo()); if (target != null) { // 原样转发,服务端不改内容,只负责路由 target.println(new Gson().toJson(msg)); } else { sendResult(-1, "对方不在线"); } }msg.getTo()是接收方账号,onlineUsers.get()从在线表里拿到对方的输出流。目标不在线时给发送者回一条错误提示,由客户端Toast出来。注意时间戳在转发时保持不变,这样接收方显示的是发送者本地的发送时间,而不是服务端当时的系统时间。
整个服务端的消息类型和动作对应关系如下,这张表基本就是论文里“服务器功能模块图”的文字版:
| type值 | 模块名称 | 服务端动作 |
|---|---|---|
| 0 | 登录验证 | 校验账号密码,写入在线用户表 |
| 1 | 注册 | 写入本地账号存储,直接返回结果 |
| 2 | 信息转发 | 按to字段路由到目标输出流 |
| 3 | 添加好友 | 转发给目标用户,不做持久化 |
| 4 | 退出登录 | 从在线表移除关闭输出流 |
还有一个常见坑:println输出的字符串必须以换行符结尾,接收端的readLine()才能认为这一行数据结束。如果使用print()方法,客户端会一直停在readLine()上,表现为消息发出去了但对方永远收不到。这也是我排查这个工程时最花时间的地方。
4. 客户端六个模块的实现:Socket单例、UI回调与状态清理
4.1 登录模块:Socket连接封装成单例
客户端最大的问题是怎么管理Socket连接。如果登录界面建立一个连接,跳转到聊天界面又建一个,上一个连接就没人关了,服务端那边会出现大量半开连接。这个工程的做法是定义一个单例ChatClient,整个App生命周期内只存在一个Socket连接。
// ChatClient.java —— 全局唯一的连接管理类 public class ChatClient { private static ChatClient instance; private Socket socket; private PrintWriter writer; private BufferedReader reader; private ChatClient() { } public static synchronized ChatClient getInstance() { if (instance == null) { instance = new ChatClient(); } return instance; } public void connect(String host, int port) throws IOException { socket = new Socket(host, port); writer = new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true); reader = new BufferedReader( new InputStreamReader(socket.getInputStream(), "UTF-8")); } public void send(Message message) { if (writer != null) { writer.println(new Gson().toJson(message)); } } }connect的host参数需要特别注意:android studio模拟器访问宿主机要用10.0.2.2,真机调试要填电脑的局域网IP。端口必须跟服务端的ServerSocket一致,这个项目通常是9000或8888这类自定端口。send方法就是把Message序列化成JSON字符串,追加换行后写入输出流。
4.2 注册与消息发送:一个按钮回调,只差type值
注册和登录的代码结构几乎一样,区别在于type值为1。注册界面拿到用户名和密码,构造Message后直接发送。这里可以实现得比较粗,因为原工程没有把注册账号持久化到服务端数据库,服务端收到type等于1的消息后直接返回成功消息,账号保存在客户端的SharedPreferences里。需要更严谨的话,服务端点加一个accounts的Map即可。
消息发送模块是日常最常用的功能,一个EditText加一个Button,按钮点击后取出输入框内容,拼成type等于2的Message。
Button sendButton = findViewById(R.id.btn_send); sendButton.setOnClickListener(v -> { String content = inputText.getText().toString(); if (TextUtils.isEmpty(content)) { Toast.makeText(this, "消息不能为空", Toast.LENGTH_SHORT).show(); return; } Message msg = new Message(currentUser, targetUser, 2, content); ChatClient.getInstance().send(msg); inputText.setText(""); // 发送后立即清空输入框 });currentUser是登录后传过来的当前账号,targetUser是聊天对象的账号。这两个值必须从登录界面带过来,常见错误是写死成admin,导致换账号后消息全串到别人头上。我一般会在LoginActivity里通过Intent.putExtra()传给ChatActivity,在onCreate里再取回来。
4.3 设置与添加好友:SharedPreferences和一条type=3的消息
设置模块实现得比较简单,第一件事是“自动登录”:登录成功后把账号密码写入SharedPreferences,下次打开App先检查这个文件,有值就直接跳过登录页。
SharedPreferences sp = getSharedPreferences("chat_pref", MODE_PRIVATE); sp.edit() .putString("username", userName) .putString("password", passWord) .putBoolean("auto_login", true) .apply();第二件事是记住当前聊天对象,比如在会话界面选人之后,把对方的账号存进last_target,下次打开直接定位到上次聊天的联系人。
添加好友模块在论文里单独列了一节,其实就是一个type等于3的消息。客户端启动时已经连接服务端,加好友时把“我的账号”放到from,“对方账号”放到to,content放一段验证语。服务端原样转发给目标用户,目标用户收到后弹一个Dialog,用户点确认再回一条type等于3的消息。原工程没有做好友关系持久化,我在客户端用一个ArrayList<String>暂存在内存里,避免重复添加提示。
我在本地的做法:为了能在断线重连后保留好友列表,在服务端加一段轻量的JSON存储,把账号对应的好友列表序列化保存到文件里。这个小改动不复杂,但能让“重新登录后好友还在”这个需求成立,演示时效果更好。
4.4 退出登录:清状态、清连接、清返回栈
退出登录不能只是finish()当前Activity,那样Socket连接还挂在服务端,下次登录会提示顶号。完整流程是:发一条type等于4的消息,关闭单例连接,清掉SharedPreferences登录态,最后把Activity的返回栈清干净。
public void logout() { Message msg = new Message(currentUser, "", 4, "退出"); ChatClient.getInstance().send(msg); ChatClient.getInstance().close(); getSharedPreferences("chat_pref", MODE_PRIVATE) .edit().clear().apply(); startActivity(new Intent(this, LoginActivity.class) .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK)); }FLAG_ACTIVITY_CLEAR_TASK会清掉之前的所有Activity,用户从登录页按下返回键不会回到聊天界面。客户端六个模块里,“退出”是最容易被忽略但最能体现工程完整性的一个,论文里单独列了这一小节,答辩时被问的概率不小。服务端收到type等于4时,从onlineUsers里移除该账号,关闭当前工作线程的Socket,整个闭环就结束了。
客户端的消息接收与UI更新
前面已经把发送路径打通,还差收消息这个关键环节。ChatActivity的onCreate里调用startReceiving(),启动一个后台线程,循环调用reader.readLine(),把服务端推送来的内容解析成Message,再通过mainHandler.post()回到主线程追加到聊天列表的RecyclerView。
private void handleIncomingMessage(Message msg) { if (msg.getType() == 2) { adapter.addMessage(new ChatItem(msg.getFrom(), msg.getContent(), msg.getTime())); recyclerView.scrollToPosition(adapter.getItemCount() - 1); } else if (msg.getType() == 3) { // 好友申请弹窗 new AlertDialog.Builder(this) .setTitle("好友申请") .setMessage(msg.getFrom() + " 想添加您为好友") .setPositiveButton("接受", (d, w) -> { Message reply = new Message(currentUser, msg.getFrom(), 3, "ok"); ChatClient.getInstance().send(reply); }) .setNegativeButton("拒绝", null) .show(); } }回到主线程后才操作adapter和dialog,这正是前面2.3节说的线程边界问题。如果图省事在子线程里直接操作adapter,android studio的lint会在编译时给警告,运行时不定什么时候就崩。
5. 两台模拟器验证完整链路:三个最常见的坑这样躲
5.1 验证步骤:先后端、再客户端、最后看日志
验证我这个工程是否正常,建议按“服务端先起,客户端后连”的顺序。第一个模拟器登录admin,第二个模拟器登录test,在admin的聊天界面输入test账号发一条消息,再反过来发一条,形成回环测试。
# 服务端启动后,先确认端口被监听 netstat -an | grep 9000 # 观察服务端实时日志,出现以下输出说明连接已建立 # 服务已启动,监听端口: 9000 # 用户下线: admin如果服务端进程出现“用户下线”但客户端还停留在聊天界面,说明客户端没有正确接收服务端的关闭消息,优先检查客户端接收线程是否还活着。
还有一个最容易定位问题的动作:模拟器里消息发不出去时,不要急着改代码,先看服务端控制台有没有打印异常堆栈。没有任何Java异常,再去查客户端的Logcat。
5.2 坑一:模拟器地址与真机地址不一样
这个坑在登录阶段就会出现。android studio自带模拟器访问宿主机不能用localhost,要用10.0.2.2,这是模拟器为宿主机预留的特殊地址。真机调试时用电脑的局域网IP,手机和电脑需要处于同一个Wi-Fi。下面的表可以直接参考:
| 运行环境 | host参数 | 注意事项 |
|---|---|---|
| Android模拟器 | 10.0.2.2 | 端口对齐服务端ServerSocket |
| 真机 | 电脑局域网IP | 关闭电脑防火墙或放行Java端口 |
| 远程调试 | 服务器公网IP | 需确保端口安全策略允许 |
我用真机调试时曾经卡了半小时,Android手机连上Wi-Fi能上网,但就是连不上电脑上的服务端。最后发现是Windows防火墙默认拦截了java.exe的公网入站规则,加一条放行规则就通了。
5.3 坑二:主线程网络异常与中文乱码
NetworkOnMainThreadException的原因前面讲过,但有一种情况容易被忽略:AsyncTask虽然开了子线程,Android 11上已经不推荐使用。如果在新项目里还看到AsyncTask,建议直接换成Thread + Handler,或者用协程。服务端和客户端两端的编码必须统一UTF-8,否则服务端用GBK构造PrintWriter、客户端用UTF-8读,中文消息偶尔能收,偶尔变成问号,这种随机乱码最难排查。
5.4 坑三:消息粘包和半包,println不是万能
readLine()按行切分消息,这是聊天工程比较原始但实用的协议雏形。它的问题在两条Message几乎同时到达时,TCP可能把它们合并成一个数据包,客户端readLine()会一次性读到两行JSON,导致Gson.fromJson()解析失败。避免的办法是给查询加一个前置的文本长度前缀,或者像这个项目教学性质明显,直接在客户端做一个简单的队列缓冲。
// 避免并发推送导致粘包的简易处理 private final Queue<String> pendingLines = new ConcurrentLinkedQueue<>();把读到的每一行先放进队列,再由单线程逐条处理,至少能解决消息顺序乱掉的问题。要根治还得在报文前加4字节长度头,这个可以作为论文第五章“系统运行与测试”里遇到的问题写进去,表明作者考虑过这一点。
最后一个公用技巧:无论你改服务端还是客户端,在关键位置加一行Log.d("ChatMsg", jsonText),联调时用logcat过滤ChatMsg标签,能看到两端实际传输的JSON原文,绝大部分“消息没到”“消息乱码”“好友请求没弹窗”的问题都会在这一行日志里露出真相。
本文还有配套的精品资源,点击获取