资讯动态

Android仿QQ聊天软件技术拆解:Socket长连接与数据库连接池实践

发布时间:2026/9/10 12:37:53 来源:尧图企业网站定制
简介这是一套基于Android Studio开发的仿QQ聊天软件完整工程面向安卓期末大作业或项目实战练习的学生尤其适合需要完整可运行代码、数据库设计与实验报告作参考的计算机相关专业学习者。压缩包共688个文件核心组成包括Java后端与界面逻辑源码、XML布局文件、PNG图标与图片资源、SQL数据库脚本、Gradle构建配置等整体仅5.76MB目录按功能模块划分便于快速定位代码。资源已吸引134人学习下载。项目评审得分98分源码经导师指导与本地编译调试确保可运行附带数据库和实验报告覆盖用户登录注册、好友管理、单聊群聊、附近的人等功能模块通过阅读源码和报告可理解数据库表结构、线程通信与消息收发机制也可在此基础上二次开发作为大作业的高分参考模板。实验报告对项目架构、数据库设计、核心流程均有说明便于直接对照学习。1. 仿QQ聊天软件拆解Android Studio大作业里最值得复用的几个类这份基于Android Studio开发的仿QQ聊天软件从文件清单看不是简单的界面堆砌而是把客户端、服务端、数据库三层都串了起来客户端有ClientActivity和ClientListenThread负责界面与长连接持久层用UserDao、FriendDao、SaveMsgDao分别处理用户、好友、聊天记录服务端用DBPool管理数据库连接再加一个ServerListen等待客户端接入。评审分98分的主要原因是它把「登录-加好友-发消息」这条完整链路跑通了而不只是画了几个聊天气泡界面。对正在做安卓期末大作业的人来说这套代码的参考价值在于它演示了Socket长连接怎么做、线程怎么和Activity通信、数据库连接池在Android服务端怎么写。本文按「客户端DAO → 服务端连接池 → 消息收发 → 答辩验证」的顺序拆开讲新手能照着改熟手能看出哪些参数需要调整。2. UserDao与ClientActivity登录态校验和客户端线程模型2.1 登录不是查一次数据库那么简单凡是仿QQ项目登录操作很容易被写成「在Activity里直接new一个SQLiteHelper然后query」。但这份大作业里登录逻辑被拆成了三块UserDao只负责数据库查询ClientListenThread负责网络监听ClientActivity只做UI回调。这样拆的好处是如果要把本地SQLite换成MySQL或者服务端校验改一个模块即可。UserDao常见实现里核心查询长这样public class UserDao { private DBPool pool; public UserDao(DBPool pool) { this.pool pool; } public User getUserByName(String username) { String sql SELECT id, username, password, avatar_id, signature FROM user WHERE username ?; try (Connection conn pool.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { User u new User(); u.setId(rs.getInt(id)); u.setUsername(rs.getString(username)); u.setPassword(rs.getString(password)); u.setAvatarId(rs.getInt(avatar_id)); u.setSignature(rs.getString(signature)); return u; } } } catch (SQLException e) { Log.e(UserDao, query user error, e); } return null; } }注意这里用了PreparedStatement而不是拼接字符串。虽然是大作业但username直接拼SQL会出注入问题导师问到时答不上来会很尴尬。用占位符?能让答辩时多一个安全性的加分点。avatar_id和signature这种字段建议保留仿QQ的列表头像是必须要有的。2.2 ClientActivity里如何避免ANRClientActivity负责登录按钮点击后的网络请求但如果直接在主线程里new Socket()网络慢一点就会ANR。正确做法是把连接操作放到子线程用Handler或runOnUiThread回传结果。这段代码在大作业里通常是这样的new Thread(() - { try { socket new Socket(SERVER_HOST, SERVER_PORT); out new ObjectOutputStream(socket.getOutputStream()); in new ObjectInputStream(socket.getInputStream()); // 发送登录请求TranObject是自定义消息体 TranObject login new TranObject(); login.setType(MessageType.LOGIN); login.setUsername(username); login.setPassword(password); out.writeObject(login); out.flush(); // 等待服务端返回 TranObject response (TranObject) in.readObject(); if (response.isSuccess()) { runOnUiThread(() - { Toast.makeText(this, 登录成功, Toast.LENGTH_SHORT).show(); startActivity(new Intent(this, FriendListActivity.class)); }); } else { runOnUiThread(() - Toast.makeText(this, 用户名或密码错误, Toast.LENGTH_SHORT).show()); } } catch (IOException | ClassNotFoundException e) { Log.e(ClientActivity, login error, e); } }).start();这段代码包含三个关键参数SERVER_HOST在模拟器里要写10.0.2.2才能访问宿主机真机调试要写电脑的局域网IPSERVER_PORT需要和ServerListen里的端口一致MessageType是自定义枚举建议统一管理后续加好友、发消息都靠它区分。2.3 ClientListenThread为什么消息会「丢」登录成功后客户端必须有一条独立的线程持续读取服务端推送这就是ClientListenThread存在的意义。很多大作业在这里容易犯的错是在onCreate里开一个while(true)循环读输入流退出Activity时没有关闭导致内存泄漏或崩溃。一个相对稳的写法是public class ClientListenThread extends Thread { private ObjectInputStream in; private OnMessageListener listener; private volatile boolean running true; public void stopListen() { running false; interrupt(); } Override public void run() { while (running) { try { TranObject msg (TranObject) in.readObject(); if (msg ! null) { listener.onMessageReceived(msg); } } catch (EOFException e) { break; // 服务端关闭连接 } catch (IOException | ClassNotFoundException e) { if (running) { Log.e(ClientListenThread, read error, e); } break; } } } }volatile running标志位很关键这样在onDestroy里调用stopListen()就能安全退出线程。EOFException要单独捕获因为它不代表异常而是服务端正常关闭了连接。这里的listener是回调接口Activity实现它来刷新UI。整个线程模型就是主线程管界面ClientActivity的登录线程做一次请求-响应ClientListenThread做长期监听三者互不阻塞。3. FriendDao与DBPool服务端连接池、好友分组与离线消息存储3.1 为什么要自己写DBPool而不是每次new Connection服务端ServerListen每接受一个客户端连接通常会开一个线程处理请求。如果每个请求都DriverManager.getConnection()在高并发测试时数据库会直接报连接过多。DBPool在这里做的是维护一个固定数量的连接用完后归还而不是关闭。常见简化版连接池实现public class DBPool { private static final String DRIVER com.mysql.cj.jdbc.Driver; private static final String URL jdbc:mysql://localhost:3306/qq_clone?useSSLfalsecharacterEncodingutf8; private static final String USER root; private static final String PASSWORD 123456; private static final int MAX_CONNECTIONS 10; private static DBPool instance; private LinkedListConnection pool new LinkedList(); private DBPool() throws ClassNotFoundException, SQLException { Class.forName(DRIVER); for (int i 0; i MAX_CONNECTIONS; i) { pool.add(DriverManager.getConnection(URL, USER, PASSWORD)); } } public static synchronized DBPool getInstance() throws Exception { if (instance null) { instance new DBPool(); } return instance; } public synchronized Connection getConnection() throws SQLException { if (pool.isEmpty()) { throw new SQLException(连接池已满); } return pool.removeFirst(); } public synchronized void returnConnection(Connection conn) { if (conn ! null) { pool.addLast(conn); } } }这里MAX_CONNECTIONS10是经验值。大作业本地测试并发量不超过510个绰绰有余如果部署到服务器要按最大并发数 业务线程数 × 单线程DB操作次数来估算。getConnection()在连接池空时直接抛异常实际项目中可以用wait()/notify()等待但大作业里抛出异常提示「连接池已满」已经够用。注意连接池要放在服务端不是Android客户端。3.2 FriendDao好友列表与分组的一次性查询好友列表页通常需要展示昵称、头像、签名、分组、在线状态。FriendDao最核心的查询是联表查public ListFriend getFriendsByUserId(int userId) { String sql SELECT u.id, u.username, u.avatar_id, u.signature, f.group_name, f.remark FROM friend f JOIN user u ON f.friend_id u.id WHERE f.user_id ? ORDER BY f.group_name, u.username; ListFriend list new ArrayList(); try (Connection conn pool.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, userId); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Friend f new Friend(); f.setUserId(rs.getInt(id)); f.setUsername(rs.getString(username)); f.setAvatarId(rs.getInt(avatar_id)); f.setSignature(rs.getString(signature)); f.setGroupName(rs.getString(group_name)); f.setRemark(rs.getString(remark)); list.add(f); } } pool.returnConnection(conn); // 注意归还 } catch (SQLException e) { Log.e(FriendDao, query friends error, e); } return list; }JOIN比两条SQL分开查再拼装更高效。ORDER BY f.group_name, u.username让分组内好友按名字排序这样客户端拿到List后直接按group_name分组渲染即可。注意returnConnection要写在try内而不是finally因为如果conn为空或查询异常还回去反而污染连接池。一个更好的写法是用try-with-resources但Java 7的语法在Android Studio 4.x里有时会被desugar限制这里用传统写法更稳。3.3 SaveMsgDao与离线消息什么时候落库聊天记录必须落库否则客户端一重启就全没了。SaveMsgDao通常包含两个方法insertMsg和getHistoryMsg。前者在每次收到消息时写库后者用于打开聊天界面时加载历史记录。public boolean insertMsg(int fromId, int toId, String content, int msgType, long time) { String sql INSERT INTO chat_msg(from_id, to_id, content, msg_type, send_time, is_read) VALUES (?,?,?,?,?,0); try (Connection conn pool.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, fromId); ps.setInt(2, toId); ps.setString(3, content); ps.setInt(4, msgType); ps.setTimestamp(5, new Timestamp(time)); return ps.executeUpdate() 0; } catch (SQLException e) { Log.e(SaveMsgDao, insert msg error, e); return false; } }is_read字段很重要它是「未读角标」的数据来源。服务端收到A发给B的消息时先查B是否在线。在线就直接转发然后is_read1不在线则is_read0入库等B上线登录成功后服务端把离线消息推过去再更新is_read1。这个大作业里离线消息不是用推送服务而是靠登录时的一次性拉取虽然体验不如微信但足以通过评审。数据库表结构设计参考表名关键字段说明userid, username, password, avatar_id, signature用户表password建议存SHA-256frienduser_id, friend_id, group_name, remark好友关系表联合主键(user_id, friend_id)chat_msgid, from_id, to_id, content, msg_type, send_time, is_read消息表msg_type区分文本/图片/表情这里msg_type不要只为了好看设计成0/1/2建议直接约定0文本, 1图片, 2表情并写进实验报告的数据字典里。导师看到你连is_read都考虑了会认为你理解消息系统的投递语义。4. TranObject与服务端线程模型点对点消息怎么转发4.1 TranObject可序列化的消息体客户端和服务端通过ObjectInputStream传输对象所以TranObject必须实现java.io.Serializable。它的字段决定了协议边界public class TranObject implements Serializable { private static final long serialVersionUID 1L; private int type; // 消息类型登录、好友列表、私聊、群聊、心跳 private int fromId; // 发送者ID private int toId; // 接收者ID private String content; // 消息内容 private long sendTime; // 发送时间 private Object extra; // 扩展字段在线用户列表、好友分组等 // getter / setter / toString... }type必须用int常量或枚举不要直接传字符串因为字符串比较在switch里容易拼错。extra设计成Object是为了让登录响应可以携带User对象好友列表响应可以携带ListFriend这样ClientListenThread读到一个对象后再按type强转。注意serialVersionUID一定要写死否则客户端服务端同时改动代码时容易序列化版本冲突。4.2 ServerListen的转发逻辑ServerListen作为服务端入口需要维护一个MapInteger, ClientThread来记录在线用户。每个ClientThread负责一个Socket连接循环读TranObject根据type分发public class ServerListen { private static final int PORT 8888; public static ConcurrentHashMapInteger, ClientThread onlineClients new ConcurrentHashMap(); public static void main(String[] args) throws Exception { ServerSocket serverSocket new ServerSocket(PORT); System.out.println(服务器启动端口 PORT); while (true) { Socket socket serverSocket.accept(); new ClientThread(socket).start(); } } } class ClientThread extends Thread { private Socket socket; private ObjectInputStream in; private ObjectOutputStream out; private int userId; public void run() { try { in new ObjectInputStream(socket.getInputStream()); out new ObjectOutputStream(socket.getOutputStream()); while (true) { TranObject msg (TranObject) in.readObject(); switch (msg.getType()) { case MessageType.LOGIN: handleLogin(msg); break; case MessageType.PRIVATE_CHAT: handleChat(msg); break; case MessageType.HEART_BEAT: // 心跳包更新时间戳 break; default: break; } } } catch (Exception e) { ServerListen.onlineClients.remove(userId); System.out.println(用户 userId 掉线); } } }ConcurrentHashMap必须用因为多个ClientThread会同时操作在线表。handleChat的逻辑是private void handleChat(TranObject msg) throws IOException { ClientThread target ServerListen.onlineClients.get(msg.getToId()); if (target ! null) { target.getOut().writeObject(msg); target.getOut().flush(); // 标记已读 SaveMsgDao dao new SaveMsgDao(DBPool.getInstance()); dao.markRead(msg.getToId(), msg.getFromId()); } else { // 离线保存到数据库is_read0 SaveMsgDao dao new SaveMsgDao(DBPool.getInstance()); dao.insertMsg(msg.getFromId(), msg.getToId(), msg.getContent(), msg.getType(), msg.getSendTime()); } }这里有个容易被忽略的点转发给在线用户时也要先写库再标记已读还是只标记已读如果只转发不写库聊天记录就丢了如果先写库再标记已读需要保证insertMsg和markRead是同一个连接。大作业里可以简化成先insertMsg再判断在线发送发送成功后执行UPDATE chat_msg SET is_read1 WHERE from_id? AND to_id? AND is_read0。这样逻辑最清楚答辩时也能讲明白。4.3 心跳机制为什么必须有仿QQ如果没有心跳包服务端无法感知客户端突然断电或网络断开onlineClients里的僵尸连接会一直占着文件描述符。ClientListenThread里建议每30秒发一个HEART_BEAT服务端记录最后心跳时间超过90秒没收到就判定掉线private long lastHeartBeat System.currentTimeMillis(); // 在读取循环里 if (msg.getType() MessageType.HEART_BEAT) { lastHeartBeat System.currentTimeMillis(); } else if (System.currentTimeMillis() - lastHeartBeat 90_000) { throw new IOException(心跳超时); }参数30秒和90秒是经验值。如果网络环境差可以放宽到60/180秒。心跳包不携带业务数据content置空即可。这样服务端端点能及时清理在线表好友列表的「在线状态」才不会长期显示绿色。5. 答辩前建议重点验证的边界场景与参数调优5.1 模拟器与真机的网络差异大部分安卓期末大作业第一次跑不通都死在网络地址上。Android Studio自带模拟器访问宿主机要用10.0.2.2而真机调试要用电脑的局域网IP比如192.168.1.100。如果你把SERVER_HOST写死在ClientActivity里换一台电脑就得改源码建议将HOST和PORT放在Constants类里统一管理。还有一个坑Windows防火墙会拦截Socket监听端口服务端启动时若提示java.net.BindException: Address already in use先用netstat -ano | findstr 8888查端口是否被占用再用taskkill /PID 进程号 /F结束。服务端运行的主机需要在防火墙入站规则里放行TCP端口。5.2 两个典型崩溃的定位方法第一个崩溃是ClassCastExceptionClientListenThread读到的Object转TranObject失败。原因通常是TranObject没有实现Serializable或者客户端和服务端的serialVersionUID不一致。排查方法是在序列化类里显式声明private static final long serialVersionUID 20250320L;第二个崩溃是主线程更新UI报CalledFromWrongThreadException。检查一下ClientListenThread的回调里是否直接调用了TextView.setText()。所有UI操作必须经过runOnUiThread或Handler.post。可以用AsyncTask封装但AsyncTask已经废弃推荐直接用Thread Handler。5.3 实验报告里值得写的三个数据评审分能打到98分说明实验报告不只是抄代码而是有数据支撑。建议在报告里加入这三个参数对比场景数据库连接池大小客户端线程数平均响应时间本地模拟器单用户连发10条消息52约35ms5个用户并发登录106约120ms10个用户并发发消息1011约280ms通过这个表格能直接看出并发瓶颈不在数据库而在服务端线程模型。ClientThread每用户一线程的方式在20个在线用户时线程切换开销就已经明显此时可以计算最大在线数 服务端可用内存 / (线程栈大小 堆内存占用)默认线程栈512KB1GB内存最多开约2000个线程但实际因为堆内存限制50100个用户就会卡顿。报告里写一句「本系统基于C/S架构的每连接一线程模型适合千人以下的中小型聊天场景」导师就知道你想过扩展性问题了。5.4 最后一个小技巧用日志埋点验证消息链路如果调式半天仍不知道消息丢在哪可以在ServiceListen的accept处、ClientThread的readObject后、SaveMsgDao.insertMsg里分别加System.out.println或Log.d。这样跑一遍流程后看服务端控制台输出到哪个步骤断了问题就在那个步骤。大作业调试不需要上专业工具控制台日志比断点更直观尤其是有多个线程并发时断点容易误停。另外把数据库里的chat_msg表导出来开个SQL查询SELECT from_id, to_id, content, is_read, send_time FROM chat_msg ORDER BY send_time DESC LIMIT 20;看到记录都在、is_read字段正确说明服务端写入和转发逻辑都正常。如果is_read一直是0但客户端已经收到消息那就要检查markRead的更新条件是不是把from_id和to_id写反了。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价