简介:这是一个基于Java图形界面Swing与Socket网络编程实现的仿QQ聊天软件完整源码包,内含服务端、客户端以及MySQL数据库建表脚本。项目采用MVC架构,通过JDBC配合Druid连接池操作MySQL数据库,支持多个客户端同时运行并互相在线聊天,适合Java进阶学习者作为期末大作业或毕业设计参考。压缩包共490个文件,大小7.17MB,其中包含281张PNG图片(界面素材与截图)、53个Java源文件、90个class编译文件以及42个XML配置文件等,目录结构按功能模块划分,便于阅读和二次开发。目前已有994人学习下载。资源附带数据库建表语句、项目说明文档以及可直接运行的APK与JAR文件,可帮助使用者快速启动服务端与客户端,完整理解Socket通信、JDBC数据访问、Swing界面设计与多线程处理的实现流程,是巩固Java网络编程与数据库技术的实用素材。
1. 一个Java聊天源码包,凭什么把QQ的核心功能端到端打通
"java 模仿QQ聊天软件源码(含服务端以及数据库).rar"这个检索词背后,是每年成千上万Java学习者同一个真实诉求:要一个能跑的完整练手项目,而不是零散的语法练习题。它把C/S架构的完整闭环拆成了可交付的工程——客户端负责界面和网络收发,服务端负责登录校验、在线管理和消息转发,数据库负责用户、好友关系、聊天记录的持久化。它解决的核心痛点很直接:很多人学完Java基础、MySQL和面向对象编程,却始终没把"一次登录、一条聊天消息"从数据库一路跑到另一个客户端的链路走通。这个包就是那条链路。它适合两类人:课设或毕设要做聊天系统的学生,以及想靠一个能聊深的项目应对服务端和数据库java面试题的开发新人。下面按拆包顺序,把架构、表结构、服务端核心代码和踩坑逐层讲透。
2. 选型与架构:服务端、客户端、数据库三端各自的任务边界
2.1 服务端通信选型:为什么多数源码包用BIO而不是Netty
拿到这种源码包,先别急着点开某个.java文件,先看网络层靠什么撑起来。九成以上的"模仿QQ源码"用的是最原始的java.net.ServerSocket加多线程,也就是BIO(阻塞I/O),少数会引入Netty。原因是这类包定位是教学和课设,BIO的代码量最小:一个ServerSocket.accept()接一个客户端,给每个连接开一个线程,逻辑直线好懂。Netty虽然性能和工程性更优,但引入ChannelHandler、EventLoop、编解码器一整套概念,源码作者自己解释成本都高,反而偏离了"模仿QQ"的教学目标。
// 服务端骨架:BIO + 每连接一线程,大多数此类源码包的标配 ServerSocket serverSocket = new ServerSocket(8888); while (true) { Socket socket = serverSocket.accept(); // 阻塞等待新连接 new Thread(new ClientHandler(socket)).start(); // 每个连接一个线程 }这段代码的参数说明:端口8888是常见约定,本机有其他服务占用会直接抛BindException,改成8080、9999都行但客户端连接地址要同步;accept()是阻塞调用,必须放在主线程的无限循环里,否则服务端只能接一个客户端。每个ClientHandler处理一条客户端的完整生命周期,从读到第一条消息到连接断开。这种写法有一个天然的隐患:线程数随连接数线性增长,第5章会讲到它如何把服务端拖垮。
判断一个包是BIO还是NIO,看两处:导入的依赖,全是java.io.*和java.net.*基本就是BIO,出现java.nio.channels或io.netty就是NIO体系;再看服务端有没有用线程池执行任务。看清这个区别,就不会在改代码时把BIO的Socket输入流和NIO的Channel混在一起,这是很多人第一个翻车点。
| 选型 | 代码量 | 并发表现 | 适合场景 | 常见部署 |
|---|---|---|---|---|
| BIO + 每连接一线程 | 最少 | 100连接内可接受 | 课设、教学、快速原型 | JDK自带 |
| BIO + 线程池 | 中 | 可控,超限排队或拒绝 | 上述系统的过渡升级 | JDK自带 |
| Netty | 较大 | 高并发、低延迟 | 生产级IM、网关 | 引入框架依赖 |
2.2 消息协议设计:客户端和服务端之间传什么格式的数据
服务端怎么知道收到的字节流是一条登录请求还是聊天内容?这依赖双方约定好的消息协议。最简方案是文本协议,每行一条完整消息,客户端用DataOutputStream.writeUTF()发送,服务端用DataInputStream.readUTF()接收。writeUTF自带两字节长度前缀,天然规避了TCP粘包问题的一大部分。协议格式一般长这样:
LOGIN|alice|123456 // 登录请求 LOGIN_OK|alice|欢迎回来 // 登录结果 MSG|alice|bob|晚上一起吃饭吗 // 点对点消息 MSG_ACK|2024001 // 消息送达回执 ONLINE|alice // 好友上线通知选用文本协议而不是Java序列化或JSON,是这类源码的常见做法:字段用竖线分隔,目测就能看懂,抓包排查也直观。它的问题同样明显:消息内容本身如果包含竖线或换行必须转义,否则服务端split("|")之后字段错位,这是乱码之外另一个高频坑。我建议拿到源码后不要急着改成JSON,先把文本协议吃透。面试被问"协议怎么设计的",能讲出"文本行+长度前缀+分隔符转义"这套边界,比一句"我用了Gson"更能体现你对TCP的理解。
2.3 拆包第一步:怎么从源码包的文件布局找到三端入口
解压后不要从第一个文件夹开始顺序读,先做地图测绘。这类包通常分三块:client目录放Swing界面和连接逻辑,入口类一般是ClientFrame或LoginFrame;server目录放服务端启动类和ClientHandler,入口类一般是ServerMain;database或sql目录放建库脚本,一般是init.sql或者带编码后缀的sql文件。有的包会把三块打散成三个独立Eclipse工程,那就在IDE里分别导入。
找到入口类的技巧是搜索main方法:源码包规模不大,main方法通常不超过三个,分别对应服务端启动、客户端登录框和可能的测试类。另有个细节值得注意:老源码包常用JDK 6/7语法,导入后IDE报一堆类型安全或菱形语法错误,先别急着删代码,检查一下项目编译级别是不是设得太低。把编译级别拉到JDK 8通常能解决大部分兼容报错,而不是代码本身有问题。这一步读完,你已经知道什么类干什么活,接下来可以看数据库脚本了。
3. 数据库设计:三张核心表如何支撑登录、好友与聊天记录
3.1 建表SQL:用户表、好友表、消息表
这类源码包数据库端的通用设计是三张表:用户表、好友关系表、聊天记录表。有的包会加离线消息表,或者直接在聊天记录表上用一个is_read字段承担这个职责。建表SQL大同小异,核心是搞清楚实体之间的关系。下面是拆过几个类似包后归纳出的一个可落地版本:
CREATE DATABASE IF NOT EXISTS chat_qq DEFAULT CHARSET utf8mb4; USE chat_qq; -- 用户表:最小可用版本,username要唯一 CREATE TABLE t_user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, nickname VARCHAR(32) DEFAULT '新用户', avatar VARCHAR(128) DEFAULT NULL, -- 头像路径或URL create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT '用户表'; -- 好友关系表:双向各存一行,避免查询时的OR条件 CREATE TABLE t_friend ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, friend_id INT NOT NULL, remark VARCHAR(32) DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_friend (user_id, friend_id), CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES t_user(id), CONSTRAINT fk_friend FOREIGN KEY (friend_id) REFERENCES t_user(id) ) ENGINE=InnoDB COMMENT '好友关系表'; -- 聊天记录表:私聊和群聊都可以先落这里 CREATE TABLE t_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, from_id INT NOT NULL, to_id INT NOT NULL, content VARCHAR(1000) NOT NULL, msg_type TINYINT DEFAULT 0, -- 0私聊 1群聊 2系统 is_read TINYINT DEFAULT 0, -- 0未读 1已读 send_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_from_to (from_id, to_id, send_time) ) ENGINE=InnoDB COMMENT '聊天记录表';参数说明:username加UNIQUE是必须的,否则同一用户名注册两次,登录时查出多行,服务端不知道该给谁建连接;t_friend的UNIQUE KEY (user_id, friend_id)防止同一对好友被反复插入的脏数据;content用VARCHAR(1000)而不是TEXT,是因为这个体量下VARCHAR在排序和插入时更轻,TEXT在MySQL里有额外的行外存储限制,做聊天记录分页查询时回表代价更高。msg_type留个TINYINT是给群聊扩展的口子,基础源码往往没有这一列,后面加就得改表结构。
这里有一个值得注意的设计取舍:好友关系表为什么不只存一行表示"双向好友"?因为SQL查"alice的好友列表"要写WHERE user_id=alice OR friend_id=alice,OR条件在MySQL里很难同时在两个字段上走索引,数据一多就慢。按双向各存一行,查列表只需要一次等值查询,代价是插入时写两行、删除时删两行,事务边界要多包一条。多数教学源码用一行双向,省事但性能查。面试时你能说出这个取舍,比背索引定义有说服力得多。
3.2 字符集与存储引擎:为什么乱码和丢消息常常出在表上
数据库端的坑一大半在字符集。很多老源码包的建表语句是DEFAULT CHARSET=utf8,这在MySQL 5.5时代没有明显问题,但utf8在MySQL里实际是utf8mb3,只支持3字节编码,存不了emoji。QQ聊天里一个表情符号塞进去,要么告警要么落库成问号,就是"消息发出去了但对方看到乱码"的常见源头之一。建库建表统一用utf8mb4,排序规则用utf8mb4_general_ci就够,聊天场景用不上unicode_ci的精确排序。
存储引擎选InnoDB基本没有争议:支持事务和外键,聊天记录按时间排序查询不会被行锁噎住。但源码包自带的脚本如果是从老项目抄来的,可能带ENGINE=MyISAM。MyISAM不支持事务,插入消息过程断了,半条消息留在表里,客户端查出来会显示一半内容。换引擎一条ALTER TABLE就能解决,扫一眼脚本顺手改掉即可。另一个易被忽略的是索引:t_message如果连idx_from_to都没有,加载历史聊天记录时全表扫描,记录过万后打开会话窗口能明显感到卡顿。不过索引也不是越多越好,聊天表写入频繁,每多一个索引写入就慢一截,from_id + to_id + send_time这个组合足够起步。
3.3 JDBC连接:直连、连接池与服务端的资源之争
源码包里访问数据库有两种做法:每个操作临时Class.forName加DriverManager.getConnection(),或者用一个工具类包装。前者写起来最直白,但每登录一次就新建一个物理连接、用完就关,MySQL默认max_connections只有151,客户端一多,服务端很快报Too many connections。后者容易写出"把同一个Connection给多个线程共用"的隐患,因为Connection不是线程安全的,两个聊天线程同时写就交叉串线。
// 最简JDBC工具类:让每次拿到的连接是干净可用的 public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/chat_qq?useUnicode=true" + "&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL驱动未加载", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }参数说明:URL里useUnicode=true和characterEncoding=utf8mb4是关键,缺了这两项中文在应用与数据库之间传输就是乱码;serverTimezone=Asia/Shanghai是MySQL 8.x之后必须加的,否则直接报时区错误。com.mysql.cj.jdbc.Driver是8.x驱动的类名,老源码包写的是com.mysql.jdbc.Driver,装8.x数据库后这个老类名会提示找不到驱动,这是拆包后的典型环境报错。
真正能上点规模的源码包会引入连接池,常见Druid或HikariCP。Druid在国内教学源码里出现频率高,自带监控页面;HikariCP更轻,Spring Boot默认就是它。连接池的核心收益不是快,而是让连接可控:池上限设20,服务端在线100人也不会打爆MySQL。从工程角度,我会把连接池最大数对齐数据库max_connections:池最大20,MySQL保留50余量,这样即使有连接没正确归还,数据库也不会立刻被写爆。第5章会细说这个坑。
4. 服务端实现细节:登录校验、在线表与消息路由的完整链路
4.1 登录链路:从客户端Socket数据到数据库校验
服务端的每个ClientHandler线程拿到Socket后,会循环读消息。第一条到达的消息必须是LOGIN,伪代码如下:
// 服务端处理登录:读协议行 -> 解析字段 -> 查库校验 -> 写回结果 String line = reader.readLine(); // 读一条完整协议消息 String[] parts = line.split("\\|"); // 按竖线拆字段 if ("LOGIN".equals(parts[0])) { String username = parts[1]; String password = parts[2]; User user = userDao.findByUsernameAndPassword(username, password); if (user != null) { state = ClientState.ONLINE; onlineUsers.put(user.getId(), socket); // 注册到在线表 writer.write("LOGIN_OK|" + user.getNickname() + "\n"); writer.flush(); } else { writer.write("LOGIN_FAIL|用户名或密码错误\n"); writer.flush(); socket.close(); // 登录失败直接断开 } }逻辑说明:split("\|")里的双反斜杠容易被看走眼。竖线在正则里是或操作符,不转义的话"a|b"会被拆成任意单字符匹配,也就是说split("|")会把每个字段拆成单个字母,登录永远失败。这是一个真实存在的经典翻车点,遇到"明明密码正确却登录不上"的Bug,先看这里。onlineUsers必须是ConcurrentHashMap,因为多个ClientHandler线程同时put,普通HashMap并发写会丢数据甚至CPU打满。登录失败后直接close,是为了让客户端拿到失败码后主动清理这个失效连接,无心跳机制的源码包里尤其需要这一步。
4.2 在线用户表:靠什么让消息能准确送到指定客户端
在线用户表是服务端的核心。它维护的是"userId -> 客户端Socket/Writer"的映射。点对点消息的转发流程是:收到MSG|from|to|content,先从在线表查到to对应的Writer,把MSG原样写过去,再给发送方回一个MSG_ACK,最后把消息写入数据库保存记录。
// 点对点消息转发:查在线表 -> 写目标Socket -> 落库 void handleMsg(String[] parts) { String from = parts[1], to = parts[2], content = parts[3]; Socket target = onlineUsers.get(to); // 查目标是否在线 if (target != null) { PrintWriter tw = new PrintWriter(target.getOutputStream()); tw.write("MSG|" + from + "|" + to + "|" + content + "\n"); tw.flush(); writer.write("MSG_ACK|消息已送达\n"); // 给发送方回执 writer.flush(); } else { writer.write("MSG_OFFLINE|对方不在线\n"); // 目标离线,通知发送方 writer.flush(); } messageDao.save(from, to, content); // 无论是否在线都落库 }参数说明:这里有两个方向容易出错。一是PrintWriter要尽早创建并复用,不要在每条消息里new一个,否则同一Socket上叠了多层包装流,消息可能乱序还伴生资源泄漏。二是onlineUsers里存Socket还是存Writer,我建议存Writer,因为Socket本身没有写方法,每次取出还得现场包一层PrintWriter,纯属重复堆对象。离线消息这段代码只做了通知,真正存库再补推通常要自己加,见4.3。
还有一个所有源码包都会踩的细节:在线表用用户名还是用户ID做key。用用户名做key,用户改昵称不受影响,但同一用户在手机和电脑同时在线时,后登录的会把先登录的挤掉,在线表天然变成"单端登录"语义。用ID做key更规范,但客户端要额外传ID,涉及协议字段扩展。如果源码包用用户名,直接改的话要同步改协议,属于小重构,收益是避开重名用户的逻辑混乱。
4.3 离线消息与上下线广播:源码包最容易缺、也最值得补的一块
大多数基础版源码包不做离线消息,只在4.2的else分支里回一句"对方不在线",消息就丢了。demo阶段看不出问题,课设答辩时老师大概率会问"对方离线时消息去哪了",答不上来很掉分。补法不复杂:发送时目标不在线,把消息写入t_message且is_read置0;目标上线时查所有is_read=0的消息推给他,推送后置1。补推代码可以这样写:
// 用户登录成功后:查出未读消息并逐个推送 List<Message> unread = messageDao.findUnreadByUserId(userId); for (Message m : unread) { writer.write("MSG|" + m.getFromId() + "|" + userId + "|" + m.getContent() + "\n"); writer.flush(); messageDao.markRead(m.getId()); }参数说明:findUnreadByUserId对应的SQL是SELECT * FROM t_message WHERE to_id=? AND is_read=0 ORDER BY send_time,注意一定要ORDER BY send_time,否则补推的消息乱序,聊天窗口看起来像时间倒流。markRead可以逐条UPDATE,也可以一条UPDATE多行,逐条更直观但连接池压力大,批量更新更适合消息多的账号。这里还要防一个边界:用户正在跟某人聊天,对方发来新消息,如果服务端没有区分"新消息实时转发"和"未读补推",用户会收到重复消息。常见做法是补推只发生在登录瞬间,登录完成后的新消息走4.2的实时路由。
上下线广播是另一个常被省略的功能。登录成功后,服务端去t_friend表查出该用户所有好友ID,遍历onlineUsers找在线的逐个发ONLINE;退出时发OFFLINE。这里有个坑要注意:不要在ClientHandler线程里直接带着好友列表去遍历在线表并逐个写Socket,如果这个线程被慢查询或某个对端Socket写阻塞卡住,其他在线用户的消息转发也会被拖住。正确做法是把广播动作丢给一个单独的线程池异步执行,登录主链路只做认证和在线表注册。
5. 常见问题与避坑:乱码、掉线、连接池这三个坑先排掉
5.1 中文乱码:现象、原因与统一编码方案
现象:中文用户名登录失败,或者聊天消息发出去对方看到????,数据库里查出来也是问号。
原因:Socket流用了操作系统的默认编码。Windows中文版默认GBK,Linux默认UTF-8,同一份源码在Windows开发、部署到Linux服务器,两端编码不一致中文全乱。另一个源头是数据库连接URL没带characterEncoding,JDBC往MySQL写中文时被转成latin1落库。
解决:把编码统一到三个位置:Socket两端包装的InputStreamReader和OutputStreamWriter显式指定UTF-8;数据库连接URL带characterEncoding=utf8mb4;建表脚本的DEFAULT CHARSET改成utf8mb4。改完重启服务端,重测一次登录和互发中文消息即可。如果改了还是乱,查数据库里数据是否已经存坏,存坏的数据要清掉重插。这种存坏一旦发生没有后悔药,属于最典型的血泪经验。
5.2 断线重连与心跳:客户端"假在线"是怎么造成的
现象:客户端直接拔网线或没关电脑合上屏幕,服务端那边该用户仍显示在线,给他发消息不报离线,好友列表一直是绿色。
原因:TCP没有主动感知对方消失的机制。服务端阻塞在readLine()上,只有对方发数据或关闭连接才返回;网络断开但Socket没关,服务端会一直认为连接在。BIO模型下这条线程会永远挂住。
解决:加心跳。客户端每30秒发一个PING,服务端检查该连接最后一次收到数据的时间,超时判离线,清理在线表并广播OFFLINE。服务端侧的实现骨架:
// 服务端心跳检测:每次读到数据都刷新lastSeen long lastSeen = System.currentTimeMillis(); Timer timeoutTimer = new Timer(1000, e -> { if (System.currentTimeMillis() - lastSeen > 60000) { onlineUsers.remove(userId); socket.close(); // 超时踢掉 } }); timeoutTimer.start();参数说明:30秒发心跳、60秒判超时是比较稳的比例,既不会在网络抖动时误杀,也能保证掉线后最多1分钟内被感知。心跳消息必须走独立标记,不能混进业务消息,否则对方把PING当聊天内容显示出来。注意Timer在连接关闭后要cancel,否则每个掉线的连接还留一个定时器在跑,累积起来就是内存泄漏。如果你想更精细,客户端读线程收到任何数据都刷新lastSeen即可,TCP数据的到达本身就是一种心跳。
5.3 数据库连接耗尽:无连接池直连撑不过半小时
现象:系统跑半小时左右,新用户登录一直失败,服务端日志报Too many connections。
原因:代码里每次操作都getConnection()但不close,或者close语句写在return后面根本执行不到。更隐蔽的情况是MySQL的wait_timeout把空闲连接回收了,应用不知道,下次拿到的连接实际已经失效。
解决:先排查finally块里有没有close()。正确姿势是用try-with-resources或者在finally里关。然后把直连统一替换成连接池,HikariCP最小配置如下:
HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/chat_qq?characterEncoding=utf8mb4"); config.setUsername("root"); config.setPassword("123456"); config.setMaximumPoolSize(20); config.setConnectionTimeout(3000); config.setInitializationFailTimeout(1); HikariDataSource ds = new HikariDataSource(config);参数说明:maximumPoolSize设20时,MySQL的max_connections要留足余量,比如设50。connectionTimeout设3秒,拿不到连接就让用户感知"服务忙"而不是无限等。initializationFailTimeout设成1或更大,数据库没起来时连接池启动即报错,而不是静默建空池,让服务端在启动时就暴露问题。这类"黑了半天最后发现数据库根本没连上"的问题,靠启动期快速失败能省下好几个小时的排查时间。
5.4 服务端线程失控:BIO多线程模型为什么不建议裸奔
现象:客户端到100左右,服务端开始频繁GC、CPU 100%,新连接连不上,老连接消息也发不出去。
原因:每连接一个Thread,线程栈默认1MB,100个线程光栈就占100MB,加上每条线程阻塞在read()上不干活。操作系统线程切换有开销,线程数超过CPU核心数数倍后,吞吐量反而暴跌。
解决:两条路,一是换Netty,重构量大且偏离课设目标;二是保留BIO但加线程池限制,连接数超过线程池容量时排队或拒绝。线程池改造改动小,是这类源码包最现实的升级路径:
ExecutorService pool = new ThreadPoolExecutor( 8, 32, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(256)); while (true) { Socket socket = serverSocket.accept(); pool.execute(() -> handleClient(socket)); // 交给线程池,不再裸new Thread }参数说明:核心线程8、最大32、队列256是我在聊天场景常用的保守参数。但注意一点:聊天连接大部分时间阻塞在Socket读上,核心线程8很快被占满,队列会积累长连接,所以这个参数并不能真让系统支持千人在线,它只是把"无限开线程拖垮进程"变成"有界队列,拒绝时可返回服务器繁忙"。要准确调优得上压测数据说话,不能靠感觉拍。线程池改造后,客户端要能处理"服务器繁忙"这类响应,否则用户只知道消息发不出。
6. 进阶验证方向:把源码包跑通后,怎么验证和继续演进
一个源码包拿下来,别急着加新功能,先做多客户端联调,验证三条主链路:两个客户端互发私聊消息并确认落库;一个客户端退出后给离线用户发消息,看服务端是否返回离线提示;同一账号在第二个客户端登录,看第一个是否被正确踢下线。联调时打开MySQL慢查询日志,能看到每条消息的INSERT和SELECT,这比只看弹窗更直接。验证通过后再谈演进。
演进优先级我的排序是:离线消息落库与上线补推(涉及is_read查询)大于心跳检测(涉及在线表可靠清理)大于好友在线状态广播(涉及好友表与在线表联动)大于文件传输。四个做完,这个项目的完成度就从课设demo逼近可演示的MVP,面试能讲的东西会厚实很多。我面试别人时最常追问两个问题:两个客户端同时给同一个人发消息,在线表会不会并发错乱?要做哪些改造才能支持200人同时在线?这两个问题正好对应第4章的ConcurrentHashMap设计和第5章的线程池改造。能答到这个深度,模仿QQ的项目就不再是抄来的源码,而是你亲手趟过坑、知道边界在哪的作品。
最后说个个人习惯:拿到这类包,第一件事永远是跑起来再读代码。跑不起来先查JDK版本和MySQL版本,这两个版本不对,后面全是玄学。先把主链路验证通过,再谈加功能,顺序反了你会分不清新Bug是原来就有还是自己改出来的。希望这些拆包顺序和踩坑记录对你有用,希望帮到你。
本文还有配套的精品资源,点击获取