简介:这是一套基于 Android 平台的 Openfire、XMPP 与 Smack 即时通讯示例源码,面向希望快速搭建移动端聊天功能的开发者与学习者。项目覆盖文字、表情、图片、语音等消息发送,并包含添加删除好友、消息提醒、群聊、邀请好友进入房间及聊天记录保存等常用模块,界面完成度较高,适合用作课程设计、功能原型或二次开发参考。压缩包共 3378 个文件,以 530 个 Java 源码、1973 个 class 编译文件、153 个 XML 配置及 578 个 PNG 界面素材为主,另有少量 jar、apk 与数据库文件,整体约 10.24MB,目录层级较完整,便于按功能模块查阅。已有 174 人学习。该示例在 EIM 企信通 demo 基础上补充完善,配置好 Openfire 服务并设置 IP 后即可运行,可帮助读者理解 XMPP 连接、消息收发、好友与群组管理等流程,并据此排查配置与联调问题。
1. 从一条文字消息说起:这套 Openfire + XMPP + Smack 方案到底解决什么问题
很多人第一次做即时通讯,直觉是「后端写个 WebSocket,前端发 JSON 就完事了」。真上手才发现麻烦全在细节里:断线重连怎么保证消息不丢、离线消息存哪儿、图片语音怎么发、多端登录怎么同步、表情包怎么解析。这套基于 Openfire 服务端加 XMPP 协议、Android 端用 Smack 做客户端的方案,把上述脏活累活基本接过去了——你只需要面对「发一条消息」这件事,底下的会话管理、花名册、离线存储、文件传输都由协议栈和服务端兜着。它适合两类人:一是课程设计或毕设要做一款能跑通的聊天 App 的开发者,二是想在已有产品里嵌一套标准 IM 能力、不想从零造轮子的团队。项目里已经覆盖文字、表情、图片、语音四类消息,正好是移动端聊天最核心的形态。
2. 技术栈与目录结构:Openfire 服务端 + Smack 客户端的分工边界
2.1 三件套各干什么,别混着理解
先把职责切清楚,否则后面调 bug 会一直找不到北。Openfire 是服务端,本质是一个 XMPP 服务器实现,负责连接保持、认证、路由、离线消息、好友关系(roster)存储;XMPP 是协议,定义 XML 节(stanza)长什么样、怎么握手、怎么扩展;Smack 是 Android/Java 侧的客户端库,把 XML 收发封装成 API,让你写chat.sendMessage()而不是自己拼<message>节点。
三者关系可以用一句话概括:Smack 生成 XMPP 节,Openfire 解析并转发 XMPP 节。理解这点很关键——你在客户端遇到的大部分问题,要么是 Smack 的 API 用错了,要么是 Openfire 那边没配对,极少是「协议本身不行」。
常见做法是把服务端和客户端分开开发:服务端装好 Openfire、在管理后台建用户、开好消息归档和文件传输插件,客户端只负责连上去。这样调试时能明确是链路的哪一段挂了。
2.2 典型工程目录长这样
Android 端的目录结构各项目略有差异,但核心分层大同小异。下面这套是我在类似项目里见得多、也比较容易维护的组织方式:
app/src/main/java/com/example/im/ ├── connection/ # 连接层 │ ├── XmppConnectionManager.java # 单例,管 connect/login/reconnect │ └── XmppConfig.java # 域名、端口、资源名等常量 ├── chat/ # 聊天核心 │ ├── ChatManagerWrapper.java # 封装 Smack ChatManager │ ├── MessageSender.java # 文字/表情/图片/语音的统一出口 │ └── MessageReceiver.java # 全局 StanzaListener,收消息 ├── roster/ # 好友/花名册 │ └── RosterManager.java ├── media/ # 图片与语音 │ ├── ImageCompressor.java # 发送前压缩 │ ├── AudioRecorder.java # 录音(AAC/AMR) │ └── FileUploader.java # 走 HTTP 上传拿 URL ├── db/ # 本地消息缓存 │ └── MessageDao.java └── ui/ # Activity / Adapter / 自定义气泡这个分层的好处是:连接层只关心「连没连上」,chat 层只关心「消息怎么发出去、怎么收回来」,media 层只关心「文件怎么变成可发送的产物」。一旦某类消息出问题,看目录就知道去哪儿翻。
2.3 依赖与版本上的取舍
Smack 在 Android 上要用smack-android而不是完整的smack,后者带了一堆 Java SE 才有的类,直接引入会编译不过或运行时崩。常见依赖组合是:
// 核心 implementation "org.igniterealtime.smack:smack-android:4.4.6" // 扩展:文件传输、消息回执等按需引入 implementation "org.igniterealtime.smack:smack-tcp:4.4.6" implementation "org.igniterealtime.smack:smack-extensions:4.4.6"版本号这里要说明一下:具体用哪个小版本,取决于你下载的工程build.gradle里锁定的值,照抄原有配置最稳;升级 Smack 大版本时 API 变动不小,比如 4.2 到 4.4 之间若干监听器签名改过,别盲目升。另外 Android 上还要在AndroidManifest.xml里补网络权限和后台服务声明,否则连上了也容易被系统回收。
3. 连接与会话:从 connect 到 login 的完整链路
3.1 建立连接的那几行代码
所有聊天的前提是连上服务器。Smack 的连接对象是XMPPTCPConnection,配置项通过XMPPTCPConnectionConfiguration构建:
// 构建连接配置:域名、主机、端口、资源名 XMPPTCPConnectionConfiguration config = XMPPTCPConnectionConfiguration.builder() .setXmppDomain("im.example.com") // Openfire 服务器域名 .setHostAddress(InetAddress.getByName("192.168.1.100")) // 服务端实际 IP .setPort(5222) // 标准客户端端口 .setSecurityMode(ConnectionConfiguration.SecurityMode.required) .setResource("android-" + deviceId) // 资源名区分多端 .setSendPresence(true) // 登录后自动发在线状态 .build(); XMPPTCPConnection connection = new XMPPTCPConnection(config); connection.connect(); // 建立 TCP + TLS 握手 connection.login(username, password); // SASL 认证几个参数值得单独说。setXmppDomain是 服务端配置里的「域名」,和你实际连的 IP 可以不同,这个差异是新手最容易翻车的地方:域名写错,TLS 证书校验就过不去。setPort(5222)是明文/STARTTLS 端口,5223 是旧式直连 TLS 端口,多数部署用 5222。setResource是资源名,同一账号不同设备用不同资源名,服务端才能区分多端,否则后登录的会把前面的踢下线。
setSecurityMode(required)表示强制加密。本地开发如果服务端还没配好证书,可以临时改成optional,但上线前必须改回来——这是血泪经验,测试期图省事关掉加密,后面补证书时会发现客户端和服务端配置已经互相绑死,改一处崩一片。
3.2 断线重连:别指望库帮你搞定一切
移动网络下断线是常态,地铁进隧道、WiFi 切 4G,连接说没就没。Smack 自带ReconnectionManager,但默认策略比较保守:
ReconnectionManager reconnectionManager = ReconnectionManager.getInstanceFor(connection); reconnectionManager.enableAutomaticReconnection(); // 自定义重连间隔,默认是 0 表示库自己算 reconnectionManager.setReconnectionPolicy( ReconnectionManager.ReconnectionPolicy.RANDOM_INCREASING_DELAY); // 监听连接状态,做 UI 提示和手动兜底 connection.addConnectionListener(new ConnectionListener() { @Override public void connectionClosed() { /* 正常关闭 */ } @Override public void connectionClosedOnError(Exception e) { // 异常断开,比如网络切换,这里可以触发一次手动重连 scheduleManualReconnect(); } @Override public void reconnectionSuccessful() { // 重连成功,通常需要拉一次离线消息、刷新会话列表 } });这里的关键逻辑是:自动重连只负责把 TCP 层拉起来,业务层的补数据得自己做。重连成功后要主动去拉离线消息、同步未读数,否则用户看到的是「连上了但消息缺了一段」。connectionClosedOnError里做手动兜底也很必要,因为有些网络环境下库的重连会卡在某种状态里出不来,加一层带退避的手动重试能救回来。
提示:重连逻辑一定要加最大重试次数和退避上限,无限重试在弱网下会把手机电量榨干,用户还会以为 App 卡死了。
3.3 登录之后先做什么
登录成功不等于可用,还得处理三件事:发初始 presence(在线状态)、加载 roster(好友列表)、注册消息监听。presence 决定了别人能不能看到你在线,setSendPresence(true)只是让库在登录后自动发,如果你想控制「隐身」「忙碌」这些状态,就得自己构造Presence对象。roster 建议异步加载并在本地缓存一份,否则每次进 App 都要等网络。
4. 消息收发核心:文字、表情、图片、语音四类怎么落地
4.1 文字消息:Chat 与 StanzaListener 的配合
文字消息是最基础的一类,Smack 提供了ChatManager和Chat两个抽象。发送端代码很短:
// 发送文字消息 ChatManager chatManager = ChatManager.getInstanceFor(connection); Chat chat = chatManager.chatWith(jid); // jid 形如 user@im.example.com Message message = new Message(); message.setType(Message.Type.chat); message.setBody(text); message.setStanzaId(UUID.randomUUID().toString()); // 客户端生成唯一 ID chat.sendMessage(message);setStanzaId这一行是自己加的习惯,不是必须。它的价值在于:消息发送后到服务端确认之间有个时间窗,客户端生成的 ID 可以作为本地消息表的主键,收到回执或离线补发时用它去重,避免同一条消息在列表里出现两次。很多人不做这一步,结果弱网下消息重复,排查起来像玄学。
接收端要注册全局监听,而不是只监听某个 Chat:
// 全局消息监听,登录后注册一次 connection.addAsyncStanzaListener(stanza -> { if (!(stanza instanceof Message)) return; Message msg = (Message) stanza; if (msg.getType() == Message.Type.chat && msg.getBody() != null) { // 1. 解析表情占位符 String rendered = EmojiParser.parse(msg.getBody()); // 2. 落本地库 messageDao.insert(fromStanza(msg)); // 3. 通知 UI(主线程) mainHandler.post(() -> chatAdapter.add(rendered)); } }, new StanzaTypeFilter(Message.class));逻辑说明:用addAsyncStanzaListener而不是同步版本,是因为回调在 IO 线程上,同步监听容易把网络线程堵住。收到消息后按「解析 → 落库 → 刷 UI」三步走,落库这一步别省,它是离线消息和消息漫游的基础。StanzaTypeFilter(Message.class)是过滤器,避免把所有类型的节都丢进回调。
4.2 表情消息:不走图片,走占位符映射
表情这块有个常见误区:把表情当图片发。那样每条消息都要上传下载,流量和延迟都受不了。更合适的做法是占位符 + 本地映射表:发送时把 emoji 转成[smile]这种文本标记,接收端解析回图片。
// 表情编解码,发送前转占位符,接收后转回 public class EmojiParser { private static final Map<String, String> MAP = new HashMap<>(); static { MAP.put("[smile]", "\uD83D\uDE04"); // 😄 MAP.put("[cry]", "\uD83D\uDE2D"); // 😭 } public static String encode(String text) { for (Map.Entry<String, String> e : MAP.entrySet()) { text = text.replace(e.getValue(), e.getKey()); } return text; } public static String decode(String text) { for (Map.Entry<String, String> e : MAP.entrySet()) { text = text.replace(e.getKey(), e.getValue()); } return text; } }注意替换顺序和覆盖问题:如果两个表情的占位符存在前缀包含关系(比如[smile]和[smile_big]),先替换短的可能把长的拆坏,所以生产环境里要么用正则一次性匹配,要么保证占位符之间不互为前缀。UI 层渲染时用SpannableString把占位符替换成ImageSpan,别在TextView里做两次 setText,会闪。
4.3 图片与语音:大文件不要塞进 XMPP 节
这是整个项目里最容易翻车的部分。XMPP 是 XML 协议,一条消息几百字节很轻松,塞进一张几百 KB 的图片就变成灾难:服务端内存飙高、转发延迟、弱网下直接超时。正确姿势是「文件走 HTTP,消息里只带 URL」。
// 第一步:压缩图片,控制体积 File compressed = ImageCompressor.compress(originalFile, 1080, 80); // 第二步:HTTP 上传到文件服务,拿到可访问 URL String url = FileUploader.upload(compressed, token); // 第三步:URL 作为消息体发出,加自定义扩展标记类型 Message msg = new Message(); msg.setType(Message.Type.chat); msg.setBody(url); // 用自定义扩展区分图片/语音/文字 msg.addExtension(new MediaExtension(url, "image", compressed.length())); chat.sendMessage(msg);ImageCompressor.compress的三个参数分别是目标最长边、质量和输出文件,1080 和 80 是我常用的折中值——再大对聊天场景没意义,再小图片糊得看不清。MediaExtension是一个自定义的ExtensionElement,作用是让接收端知道这条消息的 body 是 URL 而不是纯文字,否则图片会被当文本显示成一串链接。
语音同理:用MediaRecorder录成 AAC 或 AMR,时长控制在 60 秒内,上传拿 URL,消息里带上时长字段。播放时按需下载并缓存,别一进会话页就把所有语音全拉下来。
提示:上传用的 HTTP 接口和 XMPP 是两个通道,要分别处理鉴权。常见做法是登录成功后从服务端换一个短期 token,上传时带上,别把 XMPP 密码直接暴露给文件服务。
4.4 消息状态:已发送、已送达、已读
一套能用的 IM 至少要有发送中、发送成功、发送失败三种状态,进阶再加已读回执。Smack 提供DeliveryReceiptManager做送达回执,已读则需要自定义扩展。实现要点是:本地消息表存一个status字段,发送时置为 sending,收到回执更新为 sent,超时未收到则置为 failed 并提供重发入口。别小看这个字段,它是用户信任感的来源——一条消息发出去没有任何反馈,用户会反复点发送。
5. 避坑与排查:这几处坑我踩过不止一次
5.1 连不上:域名、端口、证书三查
现象是connect()抛SmackException或 SSL 握手失败。原因八成出在配置:setXmppDomain写成了 IP 而不是 Openfire 后台配置的域名;端口用了 5223 但服务端只开了 5222;或者服务端用的是自签证书,客户端没信任。解决办法是先确认服务端管理后台里配置的域名,再用telnet 服务端IP 5222验证端口通不通,自签证书则在开发期把证书导入trustStore,生产环境换正式证书。
5.2 消息发出去对方收不到
现象是发送端显示成功,接收端毫无反应。常见原因是 JID 拼错——XMPP 的 JID 是user@domain/resource三段式,少写域名部分,服务端就不知道该路由到哪。另一个原因是接收端没注册全局监听,只监听了当前打开的会话,切到后台就漏消息。排查方法是在服务端开 debug 日志看节有没有被转发出去,转发成功但客户端没收到,就是客户端监听的问题。
5.3 图片消息变成一串链接
现象是图片显示为 URL 文本。原因是接收端只处理了getBody(),没解析自定义扩展。解决是在接收回调里先判断msg.getExtension(MediaExtension.NAMESPACE)是否存在,存在则按图片消息渲染,不存在才走纯文本分支。这个判断顺序不能反,反了就会把图片当文字。
5.4 切后台一段时间后消息全丢
现象是 App 退到后台几分钟,回来发现中间的消息没了。原因是 Android 后台限制把连接杀了,Smack 的重连还没生效,这段时间的消息进了服务端离线队列,但客户端没主动拉。解决办法是重连成功后主动请求一次离线消息和会话同步,同时把 Android 后台保活策略配好,别指望纯靠库。
5.5 多端登录互相踢下线
现象是手机登录后平板被踢。原因是两个设备用了同一个 resource。解决是setResource里拼上设备唯一标识,保证每端不同;如果业务上需要「同端互踢、异端共存」,还得在服务端配合做资源冲突策略。
6. 二次开发改造:把通用 IM 改成你自己的业务形态
拿到这套工程后,多数人要做的是「改成自己业务的聊天」。我给一条最实用的改造路径:先换消息模型,再换 UI,最后加业务扩展。
第一步换消息模型。工程里消息体基本是 body 加扩展,如果你的业务需要发订单卡片、位置、名片,别去改 Smack 的Message类,而是定义自己的ExtensionElement子类,注册到ProviderManager,这样序列化反序列化都由 Smack 管,你只管业务字段。改造完务必用「发一条 → 服务端日志确认节格式 → 接收端解析」三步验证,别只看 UI 显示对了就以为通了。
第二步换 UI。气泡布局、时间戳合并、未读数、输入中提示这些是纯展示层的事,建议把消息实体抽成一个统一的数据类,UI 只认这个类,不直接碰Message对象。这样后面接推送、接漫游都不用动界面代码。
第三步加业务扩展。比如已读回执、消息撤回、群聊。群聊这块要注意,Smack 的MucEnterInfo和MultiUserChat用法跟单聊差别不小,进房间要先发 presence 再发消息,顺序错了消息会被房间丢弃。撤回则是发一条带自定义扩展的消息,接收端根据扩展里的消息 ID 把原消息标记为已撤回,别真的去删数据库记录。
验证方法上,我习惯准备三个测试账号、两台设备加一个模拟弱网的工具,每次改完都跑一遍:正常收发、断网重连、后台切回、发图片语音、多端同步。这套流程走下来,基本上线前不会出大问题。从那以后我每次改完消息层,都强制走一遍这套回归,再急也不跳。希望帮到你。
本文还有配套的精品资源,点击获取