简介:基于Android平台的仿QQ微信聊天系统项目(第二部分),专为具备Java基础、希望学习移动端即时通讯应用开发的读者打造,围绕用户注册登录、好友管理、实时消息传递、群聊等核心功能展开,同时覆盖Android四大组件生命周期、XML布局设计、网络通信、SQLite本地存储等关键知识点,便于系统了解聊天类App的功能闭环。压缩包共251个文件,包含Java源码、XML布局、class文件、PNG图片素材、APK安装包及工程配置文件,其中Java源码负责业务逻辑、XML定义界面、APK可直接安装体验,包体仅2.07MB,结构紧凑,可直接导入Android Studio分析学习或二次开发。内容预览中可见MainActivity、LoginActivity、BuddyActivity、ChatActivity等关键类,表明项目已实现登录、好友、聊天等模块,资源附带可运行APK,便于对照界面与功能,非常适合作为课程设计、毕业设计或求职作品集参考。目前已有649人学习下载,读者可从中理解聊天类App的整体架构设计,掌握用户认证、好友管理、消息收发、数据持久化等实现思路,同时借鉴其分包命名与资源组织方式,提升Android工程化开发能力。
1. 仿QQ微信聊天系统:先定位“消息链路”再谈界面
看到“基于Android的仿QQ微信聊天系统”这个项目名,大多数人第一反应是照着聊天界面截图去画气泡、背景和头像。真正把课程设计拖进死胡同的通常不是 UI,而是消息链路:A 发出去的消息怎么到达 B、连接断了怎么办、离线消息什么时候补拉。这个标题本质上是一个轻量 IM 系统,包含 Android 客户端与最小可用服务端。“仿”字决定了只需要把 1 对 1 聊天、会话列表、未读数这三件事闭环,不必碰群聊、表情商城这类重型功能。下面按传输层选型、界面还原、消息链路、存储建模、编译调试这条线展开,所有命令和代码都可以直接落到 Android Studio 工程里跑,中间涉及的参数会说明取值范围,也适合拿来做二次改造。
2. 仿QQ微信聊天系统的传输层选型:WebSocket 与客户端分层
聊天系统的技术选型,第一件事是定传输层。UI 做得再像,消息送不到用户手里就等于零。传输层决定了后续心跳、重连、离线消息、消息幂等怎么实现,一动就是全局改动,所以这一章先把方案对比讲清楚,再落到工程依赖和服务端最小实现。
2.1 传输层选型对比:WebSocket、XMPP 与 MQTT
| 方案 | 协议形态 | Android 端实现成本 | 适合场景 | 关键取舍 |
|---|---|---|---|---|
| WebSocket | 基于 TCP 的全双工协议 | 低,OkHttp 原生支持 | 轻量 IM、课程设计级聊天系统 | 文本/二进制帧,调试直观,能直接复用现有 HTTP 端口 |
| XMPP | XML 标准消息协议 | 中,需引入 Smack | 标准化协议、群聊扩展 | XML 冗长,服务端模块重,对单聊演示偏杀鸡用牛刀 |
| MQTT | 发布/订阅消息协议 | 低,Eclipse Paho | IoT、弱网推送 | 发布订阅语义不等于 1 对 1 会话,需要自己封装会话层 |
| 自研 TCP 长连接 | 私有二进制协议 | 高,需处理粘包拆包 | 高并发产品级 IM | 可控性最强,但半包、粘包、序列化都要自己造轮子 |
我一般选 WebSocket,理由很直接:Android 端 OkHttp 4.x 直接带 WebSocket 客户端,服务端用 Netty 加一个 handler 就能对接;调试时甚至可以用浏览器控制台模拟另一个客户端连上来发消息,不需要额外装抓包工具。XMPP 看着标准,但协议里大量 XML 字段对移动端不友好,做头像、已读回执还要自己扩 extension,工作量反而大。自研 TCP 链路要处理的东西太多,对“仿 QQ 微信聊天系统”这个标题来说性价比太低。
2.2 客户端模块划分与 Gradle 依赖配置
客户端结构上照搬单 Activity 多 Fragment 的写法,避免在多个 Activity 之间传消息对象。按登录、会话列表、聊天页、联系人、个人中心切成 feature 包;网络层、数据库层、推送服务层放独立的core模块。这样后面替换消息实现或加群聊时,改的是模块内部而不是调用方。
android { compileSdk 34 defaultConfig { applicationId "com.example.demoim" minSdk 24 targetSdk 34 versionCode 1 versionName "1.0" } } dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' // 网络与长连接 implementation 'com.squareup.okhttp3:okhttp:4.12.0' // 本地消息存储 implementation 'androidx.room:room-runtime:2.6.1' kapt 'androidx.room:room-compiler:2.6.1' // 图片加载 implementation 'com.github.bumptech.glide:glide:4.16.0' }逻辑说明:compileSdk 和 targetSdk 保持 34,是因为 Android 14 之后前台服务类型、通知权限行为都变了,targetSdk 拉高能提前暴露问题;minSdk 24 覆盖绝大多数存量设备,同时能用java.time处理时间戳,不必再和Date的时区问题纠缠。Room 负责消息表持久化,配合kapt做编译期注解处理,这里要记得在plugins里配好kotlin-kapt,否则会直接报找不到处理器。Glide 负责聊天气泡里的图片加载,头像也用同一套缓存。上面的版本号以你 build 时解析到的最新稳定 patch 为准,别在 gradle 里写+动态版本,否则换台机器就构建出不一样的依赖树。
2.3 服务端最小可用设计:转发与存储分离
仿 IM 项目的服务端不需要做成腾讯那样强壮。一个 HTTP 接口做登录换 token,一个 WebSocket 端口做消息转发就够了。转发逻辑先不落库,消息根据to字段找到目标连接直接发出去;用户离线时再写离线表,等客户端重连后拉取。
public void messageReceived(ChannelHandlerContext ctx, TextWebSocketFrame frame) { JsonObject msg = parse(frame.text()); String to = msg.get("to").getAsString(); Channel target = sessionManager.get(to); if (target != null && target.isActive()) { // 在线:直接转发,并回 ACK 给发送方 target.writeAndFlush(new TextWebSocketFrame(frame.text())); ack(ctx, msg.get("msgId").getAsString(), SUCCESS); } else { // 离线:写入离线表,等客户端调用 sync 拉取 offlineMessageStore.save(msg); ack(ctx, msg.get("msgId").getAsString(), DELIVERED); } }逻辑说明:先判断在线再落离线表,顺序不能反过来,因为在线状态下走了离线表会导致消息延迟,用户看到自己发的消息转了一圈才出去。离线写入不要直接在 Netty 的 IO 线程里同步写数据库,否则高并发下 IO 线程会阻塞;课程设计级项目可以先用一个ConcurrentHashMap做离线缓冲,再起一个定时任务批量刷库。sessionManager.get(to)这段确保消息只投递给与to字段匹配的单个连接,不做群发,符合单聊定位。
3. 用协调布局和 RecyclerView 还原仿QQ微信聊天系统的消息界面
UI 层是开发者的舒适区,但也是埋坑最容易的地方。仿 IM 界面要解决三个问题:不同消息类型的复用、输入栏与软键盘的冲突、历史消息分页加载。这三个问题处理不当,界面就会在真机上出现“键盘顶飞列表”“加载旧消息时列表跳动”这种很掉价的表现。
3.1 聊天气泡的 ViewType 复用与发送状态
消息列表的 item 分成自己发的和对方发的两种,但左右两侧的 ViewType 只需要两种,不要把图片消息、文本消息、时间消息各做一套 layout。用消息类型字段区分内容,用方向字段区分左右,否则 item 数量膨胀后维护成本直线上升。
class ChatAdapter( private val messages: MutableList<Message>, ) : RecyclerView.Adapter<ChatAdapter.MessageHolder>() { companion object { private const val TYPE_LEFT = 0 private const val TYPE_RIGHT = 1 } override fun getItemViewType(position: Int): Int = if (messages[position].fromMe()) TYPE_RIGHT else TYPE_LEFT override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageHolder = if (viewType == TYPE_RIGHT) MessageHolder(layoutInflater.inflate(R.layout.item_msg_right, parent, false)) else MessageHolder(layoutInflater.inflate(R.layout.item_msg_left, parent, false)) override fun onBindViewHolder(holder: MessageHolder, position: Int) { val msg = messages[position] holder.content.text = msg.content when (msg.status) { SENDING -> holder.progress.visibility = View.VISIBLE FAILED -> holder.progress.visibility = View.GONE SUCCESS -> holder.progress.visibility = View.GONE } } }逻辑说明:getItemViewType只按消息方向返回,文本、图片、语音等类型在onBindViewHolder里用when分支处理,这样新增引用消息类型时不用改 Adapter 的结构。SENDING状态时显示一个 12dp 的ProgressBar,而不是把整个 item 置灰,这样用户在弱网下也能看到消息正在往外发。FAILED状态要把进度条关掉,同时显示一个重发按钮;按钮点击事件通过接口回调到 ViewModel,在 ViewModel 里重新走发送流程,不要在 Adapter 里直接操作数据库。
3.2 输入栏与软键盘冲突的三种处理方式
聊天气泡画好了,一弹键盘就露馅。常见处理方式有三种:
| 方案 | 配置 | 适用场景 | 风险 |
|---|---|---|---|
| adjustResize | Activity 的 windowSoftInputMode 设为adjustResize | 根布局是非滚动容器 | 部分国产 ROM 上不生效 |
| CoordinatorLayout | 根布局用协调布局,RecyclerView 配 behavior | 页面结构较常规 | 需要正确设置 fitsSystemWindows |
| adjustNothing | 监听 ViewTreeObserver 手动计算剩余高度 | 需要同时处理拍照/表情面板 | 代码量最大,但可控性最好 |
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/chat_list" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" /> <LinearLayout android:id="@+id/input_bar" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:orientation="horizontal"> <EditText android:id="@+id/input_text" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" /> <Button android:id="@+id/btn_send" android:layout_width="wrap_content" android:layout_height="wrap_content" /> </LinearLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>逻辑说明:app:layout_behavior="@string/appbar_scrolling_view_behavior"是配合 AppBarLayout 用的,如果聊天页没有 AppBar,RecyclerView 不会自动避让输入栏。更稳的做法是给根布局设置fitsSystemWindows="true",并手动监听WindowInsetsCompat里的 IME 高度,再设置 RecyclerView 的下 padding。注意adjustResize在华为和小米的部分定制系统上不一定生效,测试时一定要用真机,不要只看模拟器。输入栏如果还要支持“按住说话”,应该把语音按钮和文字输入放在同一个布局里,通过visibility切换,避免每次切换都重新绘制整个底部区域。
3.3 历史消息加载时的分页与进度条状态
会话列表往下翻永远是新的,往上翻要加载旧消息。这个需求不需要 Paging 库,RecyclerView 的OnScrollListener足够。触发条件是列表完全回到顶部,也就是computeVerticalScrollOffset() == 0的瞬间。
chatList.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { if (recyclerView.computeVerticalScrollOffset() == 0 && !isLoading) { isLoading = true adapter.appendHeaderProgressBar() viewModel.loadPreviousPage() } } })逻辑说明:computeVerticalScrollOffset()为 0 表示当前列表滚到了最顶部,此时触发上一页加载。加载上一页时在位置 0 插入一个 header 形式的进度条,也就是一个占满 item 宽度的ProgressBar,不是加在列表末尾的 footer。isLoading这个布尔值必须置位,否则用户快速上滑会连续触发多次网络请求,造成消息分页重复。pageSize 默认取 20,拉到 30 以上在低端机上滚动会有掉帧风险;加载完成后移除 progress item,并用chatList.post { chatList.scrollToPosition(previousCount - 1) }把列表锚在原来可见位置附近,否则用户会看到列表突然跳到了新插入的旧消息,这是仿 IM 项目里最常见的分页体验问题。
4. 仿QQ微信聊天系统的消息链路:Socket 心跳、重连与 SQLite 建模
如果说 UI 是面子,消息链路就是里子。把消息从 A 送到 B,拆开来看是四步:建连、保活、发送、落库。每一步都有默认参数,也都有对应的坑。网络抖动时消息丢失,多半不是服务器问题,而是心跳参数和重连策略没配对。
4.1 WebSocket 长连接封装与心跳参数设置
用 OkHttp 封装长连接时,最容易被忽略的是超时时间。普通 HTTP 请求的超时逻辑不能直接套在 WebSocket 上,否则读超时会周期性断掉长连接。
class IMWebSocket( private val url: String, private val messageListener: (String) -> Unit, ) { private val client = OkHttpClient.Builder() .readTimeout(0, TimeUnit.MILLISECONDS) .writeTimeout(0, TimeUnit.MILLISECONDS) .pingInterval(30, TimeUnit.SECONDS) .build() private var ws: WebSocket? = null fun connect(token: String) { val request = Request.Builder() .url(url) .header("Authorization", "Bearer $token") .build() ws = client.newWebSocket(request, webSocketListener()) } fun send(content: String) { ws?.send(content) } private fun webSocketListener() = object : WebSocketListener() { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { scheduleReconnect() } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { scheduleReconnect() } override fun onMessage(webSocket: WebSocket, text: String) { messageListener(text) } } }逻辑说明:readTimeout和writeTimeout必须是 0,否则 OkHttp 默认的 10 秒超时会在空闲时把连接掐掉。pingInterval(30, TimeUnit.SECONDS)控制底层 Ping 帧,服务端 Netty 通常会在 60 秒左右判定死链,客户端 30 秒发一帧能保证链路活跃。应用层还要加一个业务心跳,登录成功后再启动定时器每 25 秒发{"type":"heartbeat","ts":...},比底层 Ping 帧短 5 秒,目的是让服务端能验证“用户态在线”而不只是“TCP 在线”。重连退避建议按指数走:1s、2s、4s,封顶 30s,每次重连都重置退避计数,否则弱网恢复瞬间会扎堆重连。
4.2 消息协议、去重与重试
消息协议不要设计太长,够用就好。核心字段是msgId、from、to、content、timestamp:
{ "type": "message", "msgId": "a3f2c1e0-8b64-4a10-89dc-1234567890ab", "from": "u_1001", "to": "u_2002", "content": "hello", "timestamp": 1699999999999 }msgId必须是客户端生成,用 UUID 就行。服务端拿它做幂等,收到重复msgId直接回 ACK,不再转发。timestamp用客户端本地时间,不要用服务端时间回传,因为收发双方都会拿这个字段做排序,两端时间不同步会导致消息顺序看起来是乱的。发送流程上,客户端先把消息插本地库,状态置为SENDING,同时把 WebSocket 消息发出去;收到服务端 ACK 后把状态改为SUCCESS;15 秒没等到 ACK 就标记FAILED,让用户手动触发重发。15 秒是经验值,要避开和心跳间隔重叠,否则心跳回执和消息 ACK 在同一个时间窗口到达,会干扰异常判断。
4.3 消息表结构与未读数计算
Room 的建表语句直接决定后面查询是否顺畅。消息表用msg_id当主键,天然去重:
CREATE TABLE message ( msg_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, from_user TEXT NOT NULL, to_user TEXT NOT NULL, content TEXT NOT NULL, type INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 0, create_at INTEGER NOT NULL ); CREATE INDEX idx_message_session ON message(session_id, create_at DESC); CREATE INDEX idx_message_status ON message(status);逻辑说明:msg_id直接做主键,避免重复插入。session_id是会话 key,单聊场景下用排序后的用户 ID 拼接,比如u_1001_u_2002,这样 A 找 B 和 B 找 A 落到同一个会话。create_at存毫秒时间戳,排序用ORDER BY create_at ASC。索引建在(session_id, create_at DESC)上,正好覆盖“查某个会话的历史消息”这个高频查询。未读数不建议每次COUNT(*)再算,消息表到几万条之后 COUNT 会明显拖慢会话列表;常见的做法是建一个独立的会话表,用unread_count字段维护,收到新消息时自增一次,进入会话时清零。已读位置单独记录,不要每条消息都标记已读。
4.4 离线消息的补拉策略
用户关掉 App 十分钟再打开,这段窗口期的消息要靠重连后拉取。最简单实现是客户端本地记录lastSyncTime,重连成功后发一个同步请求:
{ "type": "sync", "lastSyncTime": 1699999999999, "limit": 100 }服务端按create_at过滤出该时间点之后、且to或from与当前用户相关的消息,按会话分组返回。客户端收到后先按msg_id去重再落库,最后统一刷一遍会话列表的未读数。这个同步请求放在 WebSocket 层发,不要在重连瞬间发 HTTP,因为 WebSocket 连接建立本身就代表链路恢复了,HTTP 还要再走一次握手和鉴权,慢一步。服务端离线表只留最近 7 天的消息,超过 7 天直接清理,课程设计级系统不需要做“永久漫游”,否则本地库膨胀后启动速度会越来越差。
5. 打包与调试:Android 仿QQ微信聊天系统的到达率检查清单
很多仿 IM 项目卡在编译阶段,而不是业务代码。版本策略定好后能少走弯路:minSdk 24、targetSdk 34、compileSdk 34 是我常用的组合。SDK Manager 里勾选对应 API 级别时,如果出现“无法勾选”的情况,先确认安装盘剩余空间和维护工具链的磁盘权限,换个目录重新安装 SDK 通常能解决。打开旧工程报Unsupported class file major version,多半是 Gradle JDK 版本太低,Android Studio 里把 Gradle JDK 切到 17 再同步。编译系统镜像或集成外部 SDK 时如果碰到build tag number over 30 is not supported,先排查是不是把 framework 编译产物混进了 App 依赖,这种 tag 不出现在普通 App 工程里,通常要把对应的 SDK 改成provided引入,避免参与 dex 打包。
targetSdk 升到 34 后,/storage/emulated/0/Android/data目录不能像以前那样直接访问,聊天系统里的图片预览、文件下载模块如果还在用老路径,要尽快切到 MediaStore 或getExternalFilesDir,否则在 Android 14 真机上会直接抛权限异常。这一项很容易被当作“偶发闪退”,实际上是从 targetSdk 30 开始就有的分区存储限制,越早改越好。
5.1 WebSocket 服务放进前台服务的注意事项
消息接收要保证 App 退到后台也不断,常见做法是把 WebSocket 放在前台服务里。Android 14 对前台服务类型有限制,类型不对会在启动时抛MissingForegroundServiceTypeException,Manifest 里需要做对应声明:
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/> <service android:name=".IMService" android:foregroundServiceType="dataSync" android:exported="false"/>逻辑说明:POST_NOTIFICATIONS是 Android 13 之后必须运行时申请的通知权限,不申请的话前台服务能启动,但通知栏不显示,用户会以为消息完全没通知。foregroundServiceType="dataSync"对应数据同步场景,比较贴合 IM 长连接;如果服务还处理蓝牙设备连接,那要改用connectedDevice,类型写错在 Android 14 真机上启动会直接崩溃。前台服务的通知栏建议放一个小文本“连接已建立”,不要放自定义 View,厂商 ROM 对自定义通知样式的兼容性差异很大。
5.2 用送达回执验证端到端链路
功能写完后,拿两台真机把链路完整验一遍。我习惯用这张检查表快速定位问题:
| 场景 | 操作 | 预期结果 |
|---|---|---|
| 建连 | 登录后看 Logcat 过滤 IM 标签 | 输出 connected,无异常堆栈 |
| 心跳 | 放置 2 分钟不动 | 服务端窗口没有断开记录 |
| 在线消息 | A 发 “hello” | B 的列表立刻出现,回复能回到 A |
| 离线消息 | B 杀掉进程,A 发 3 条;B 重新打开 | B 自动触发 sync,未读显示 3 |
| 弱网 | 飞行模式 10 秒后恢复 | 自动重连成功,期间消息未丢失 |
在线消息验证时注意看 B 端的消息时间戳用的是 B 侧本地时间,不是 A 的,如果发现顺序错乱,检查是否把timestamp错误地替换成了服务端时间。离线消息验证要留意lastSyncTime的取值,它以服务端消息落库时间为准,而不是客户端本地时间,否则两次拉取之间会因为时钟偏差重复拉取或漏拉。弱网恢复后,断线期间发的消息要能通过 sync 或重连后的 ACK 机制重新拿到,如果发现消息消失,先看服务端离线表里到底有没有写入,再看客户端重连后的 sync 请求是否真的发出去了。
本文还有配套的精品资源,点击获取