简介:面向Android开发者的即时通讯功能集成Demo,以Openfire服务端配合XMPP/Smack协议栈为主线,覆盖文字、表情、图片、语音聊天,同时支持添加/删除好友、消息提醒、群聊、邀请好友进房间以及聊天记录本地保存,适合需要为移动应用接入IM能力或学习XMPP二次开发的中级开发者。压缩包共3378个文件,约10.24MB,包含530个Java源码、1973个class文件、153个XML配置及578张PNG界面素材,从源码到编译产物均有保留,便于按模块阅读、调试和裁剪。这套方案在开源EIM企信通Demo基础上二次完善,新增多项实用功能,配置好Openfire服务地址与IP即可直接运行,能帮助开发者省去从零搭建服务端与客户端的重复工作。已有172人学习下载,对想理解群聊会话、好友管理等常见IM场景实现细节的读者,是一份有参考价值的实战项目。
1. 用 Openfire + XMPP + Smack 搭 Android 即时通讯,别急着自研协议
很多团队一接到“做个聊天 App”的需求,第一反应就是从长连接、心跳、消息队列开始自研协议,结果往往卡在消息保序、离线补推和弱网重连这些地方,一折腾就是几个月。而 android openfire xmpp smack 这条技术栈,是用现成的 XMPP 协议 + 开源服务器 + Android 客户端库,把文字、表情、图片、语音的收发都收敛到一套标准流程里。本文我不会只讲概念,而是直接把服务器搭建、客户端连接、消息类型扩展和排查案例拆开讲。适合正在做社交应用、企业内部沟通工具,或者想用最低成本验证 IM 功能的人。哪怕你之前没碰过 XMPP,跟着步骤也能把第一版跑通,并且知道上线前要堵哪些漏洞。
2. 先搞懂角色:XMPP、Openfire、Smack 是怎么配合的
2.1 XMPP 协议给客户端和服务器划定了什么边界
XMPP 的全称是 Extensible Messaging and Presence Protocol,它的核心模型是把消息封装成 XML 流,在客户端和服务器之间持续传输。这套协议很老,但它的设计边界非常清晰:客户端只负责建立连接、收发 stanza(消息节),服务器负责路由、存储离线消息、管理用户状态。所以你在客户端写代码时,不需要关心另一端的客户端是什么技术,只要双方遵守同一个 stanza 格式,就能互通。这也是我选择这套方案的首要原因——协议层面先解耦,后面换客户端、加 web 端都方便。
一个最基本的消息 stanza 长这样:
<message to="user2@localhost" type="chat"> <body>你好</body> </message>服务器看到to属性就知道往哪里路由,type="chat"表示这是两个人之间的聊天消息,body是正文。表情、图片、语音并不是协议里没有,而是需要你自己约定一种方式放进去。常见的做法是:表情直接用 Unicode Emoji 放进body,自定义表情和图片用 URL 放进body或扩展元素,语音则用文件传输或外部存储 URL 引用。协议的扩展机制(XEP 系列)提供了文件传输、回执、离线消息等标准化方案,但实际项目里我们往往会选择更简单的约定,降低客户端复杂度。
2.2 选 Openfire 而不是另起炉灶的理由
服务器端可以选的有 Openfire、ejabberd、Prosody 等。我一般推荐 Openfire,最直接的原因是它基于 Java,部署和管理门槛低。Openfire 自带 Web 管理后台,创建用户、查看会话、设置端口都在网页上点几下就行,不需要写配置脚本。对于中小团队的内部工具,这点非常关键:服务器维护不是专职运维,图形界面能省掉很多沟通成本。
另外 Openfire 对 XMPP 的标准特性支持比较全:离线消息、群组、文件传输代理、ldap 对接都有。虽然它的性能不如 ejabberd 极致,但在几百人同时在线、每秒几十条消息的规模下完全够用。如果你未来要做十万级在线,那时候再迁移到 Erlang 方案也不迟,因为客户端协议没有变,迁移成本主要在后端。
2.3 Smack 客户端库版本选择的边界与教训
Android 端最常用的库是 Smack。这里先给一个我踩过坑之后的建议:不要用太旧的版本,也不要追最新。Smack 4.2 之前包名是org.jivesoftware.smack,4.2 开始模块拆得更细,4.3 之后把连接配置类挪到了org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration。如果你网上搜到很多老帖子,把代码复制进来发现编译不过,多半是版本差异导致。我一般选用 4.3 或 4.4 这条线,稳定,资料多,Android 兼容性也经过了大量项目验证。
还有一点要注意:Smack 不是官方 Android SDK,它依赖 Java 标准库,所以你需要避免在主线程里做网络操作。后续章节里我会给出线程模型的处理方式,这里先记住一个原则:连接、登录、收发消息全部丢到后台线程,UI 层通过接口回调刷新。否则你会频繁遇到 NetworkOnMainThreadException。
3. 环境与连接:把服务器跑起来,让 Android 客户端登录不玄学
3.1 用 Docker 启动 Openfire 服务器,最短路径
如果本地没有现成的服务器环境,最快的办法是用 Docker。Openfire 官方镜像可以直接跑,但我更喜欢用带持久化卷的方式,避免容器重启后用户数据丢失。以下是常用启动命令:
docker run -d \ --name openfire \ -p 9090:9090 \ -p 5222:5222 \ -p 7777:7777 \ -v openfire-data:/var/lib/openfire \ openfire/openfire:latest说明一点:9090是管理后台的 HTTP 端口,5222是客户端连接端口(XMPP 默认端口),7777是文件传输代理端口。我一般会先只映射9090和5222,等用到图片传输时再补上7777,避免一下子暴露太多端口。-v挂载的是数据目录,这样重启容器时用户、聊天记录不会丢。
启动后,浏览器打开http://localhost:9090进入初始化向导。你需要设置管理密码、域名和数据库。域名那一步,本地测试用localhost就行;真机连接时,要填你电脑的局域网 IP 或实际服务器的域名,否则手机连不上。
3.2 Android 客户端初始化 Smack 连接配置
服务器跑起来之后,Android 端的活就是初始化连接配置。以 Smack 4.3 为例,代码通常是这样的:
XMPPTCPConnectionConfiguration.Builder configBuilder = XMPPTCPConnectionConfiguration.builder() .setHost("192.168.1.100") // 你的 Openfire 服务器地址 .setPort(5222) .setSecurityMode(ConnectionConfiguration.SecurityMode.disabled) .setXmppDomain("localhost") .setDebuggerEnabled(true) .setConnectTimeout(15000); XMPPTCPConnection connection = new XMPPTCPConnection(configBuilder.build());setSecurityMode我设成disabled,是因为本地测试时没配 TLS 证书,加密握手会失败。生产环境必须改为required,并导入证书。setXmppDomain要和服务器设置的域名一致,通常就是主机名或 IP 解析后的域名。setConnectTimeout是连接超时,单位毫秒,这里 15 秒适合弱网起步;在 WiFi 环境下可以放到 10 秒。
配置类只是第一步,真正的连接要丢到子线程。最常见写法是在一个单线程池里执行connect(),然后login()。登录用的用户名密码由服务器管理员在后台创建,也可以让客户端调注册接口。但内部工具我建议管理员统一建号,避免开放注册导致垃圾账号。
3.3 登录、在线状态与自动重连的参数说明
连接和登录最好封装成一个服务类,这里给出核心代码:
try { connection.connect(); connection.login("user1", "password123"); Presence presence = new Presence(Presence.Type.available); presence.setStatus("在线"); connection.sendStanza(presence); } catch (SmackException | IOException | XMPPException e) { e.printStackTrace(); }login成功后,必须发送一个Presence包,服务器才知道你在线,否则别人给你发消息会得到离线状态。Presence的Type.available表示上线;如果是available,则必须带status,这个是给人看的,不是协议必须。设置在线状态的习惯放这里,同时也可以配置自动重连:
ReconnectionManager reconnectionManager = ReconnectionManager.getInstanceFor(connection); reconnectionManager.setEnabledPerDefault(true); reconnectionManager.enableAutoReconnection();Smack 自带的自动重连是开了心跳的,默认每隔一段时间探测连接。如果你需要更细的控制,比如断开后 5 秒重连、指数退避,建议关掉自带的,自己写策略。这个我在第 6 章细说。这里要提醒的是:ReconnectionManager的小写是getInstanceFor,不同版本 API 名略有不同,如果编译报错,确认 Smack 版本。
到这里服务器连接和登录就算跑通了。接下来才是重点:怎么把文字、表情、图片、语音这些内容聊起来。
4. 聊天内容落地:文字、表情、图片、语音的完整实现
4.1 文字消息:Chat 会话与 Message 监听
文字消息最简单,但也要注意 API 选择。Smack 里有两个途径:一个是直接用connection.sendStanza(message),一个是创建Chat再sendMessage。我常用Chat,因为它在会话内会自动配对回复,省去手动匹配 JID 的麻烦。
Chat chat = ChatManager.getInstanceFor(connection) .createChat("user2@localhost", new ChatMessageListener() { @Override public void processMessage(Chat chat, Message message) { if (message.getBody() != null) { runOnUiThread(() -> adapter.add(message.getBody())); } } }); chat.sendMessage("hello");ChatMessageListener会在收到消息时回调。注意回调线程是 Smack 的 Reader 线程,不是 UI 线程,所以runOnUiThread必不可少。sendMessage是异步的,但它内部会排队发送,所以你连续发多条不会乱。
如果你想接收全局消息,不只是当前 Chat 会话,可以用StanzaListener拦截所有Message:
connection.addSyncStanzaListener(new StanzaListener() { @Override public void processStanza(Stanza stanza) { Message message = (Message) stanza; if (message.getBody() != null && message.getType() == Message.Type.chat) { // 处理 } } }, StanzaTypeFilter.MESSAGE);这里强调一点:addSyncStanzaListener是同步监听,执行会阻塞 Reader 线程。如果你的消息处理逻辑比较重,比如要写数据库,可以用addAsyncStanzaListener,它会丢到另一个线程池,避免拖慢后续消息的读取。
4.2 表情消息:Emoji 直发与自定义表情的约定实现
表情消息有两种场景。如果你只是想让用户能发系统 Emoji,那根本不用做额外工序,直接在输入框里收集到的字符串放进body发送即可。Emoji 在 XML 里是合法的 Unicode 字符,XMPP 消息体不会拦它。接收端拿到后也能正常渲染,Android 系统自带 Emoji 字体。
如果是自定义表情,比如内置一套小图片,通常会约定一个文本标签,例如[ha]表示“哈哈”。发送方发出的消息体照样是普通字符串:
今天天气真不错 [ha]接收端解析时,用正则把[ha]替换成对应的图片资源。这里的关键是建立一个统一的映射表,客户端与服务端不存表情包,客户端本地存映射。好处是消息体积小,历史记录可读,不会因为没有资源显示就变成乱码。自定义表情一定不要直接发文件,每次发图片会让流量翻好几倍,而且加载慢。
还有一种做法是把表情作为单独的<img>扩展元素塞进 message,但这会破坏body的纯文本性,很多第三方客户端无法兼容。我的建议是优先用body+ 文本标签,除非你明确要支持多端富文本展示。
4.3 图片消息:先上传再发 URL,别直接传 base64
图片消息最容易走偏的做法是把图片转成 base64 塞进body。这样一条几 MB 的图片会让 XML 包大好几倍,而且服务器转发时还要解码编码,内存和带宽都不划算。正确姿势是:先把图片上传到自有文件服务器或对象存储,拿到 URL,然后通过消息带上 URL 和缩略图信息发送。
上传动作可以放在图片选择器之后:
// 伪代码:压缩 -> 上传 -> 发消息 File compressedFile = compressImage(imageFile); String url = uploadFile(compressedFile); // 这里是你的上传逻辑 Message msg = new Message("user2@localhost", Message.Type.chat); msg.setBody(url); connection.sendStanza(msg);URL 放在body里有一个好处:服务端离线存储天然会保存文字内容,历史记录拉取时不必做额外处理。接收端检测到body以http://或https://开头,就渲染成图片消息。但如果 URL 里带特殊字符,比如&,XML 会转义,接收端拿到的字符串经过解析会自动还原,所以没问题。
如果你不想自己维护文件服务,可以用 Openfire 自带的 HTTP 文件上传(XEP-0363)。Smack 里有HTTPFileUploadManager,但配置比直接上传到对象存储复杂,尤其是需要处理服务器端口、证书等。我一般见到的落地项目里,团队都会倾向用阿里云 OSS / 腾讯云 COS 这类存储,客户端先把文件传到临时密钥指定的地址,再把 URL 发出去。这样 XMPP 只负责信令,不负责传输大文件,压力小很多。
4.4 语音消息:录音、上传、播放的闭环
语音消息在协议层面和图片一致,只是源文件变成音频。Android 端录音可以用MediaRecorder,注意输出格式建议用 AAC(.m4a),因为 AMR 虽然体积小,但音质差,播放时很多解码器兼容性不好。下面是录音的核心片段:
MediaRecorder recorder = new MediaRecorder(); recorder.setAudioSource(MediaRecorder.AudioSource.MIC); recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); recorder.setAudioSamplingRate(44100); recorder.setAudioBitRate(64000); recorder.setOutputFile(voiceFile.getAbsolutePath()); recorder.prepare(); recorder.start();录音开始时让 UI 显示“按住说话”,停止时调用recorder.stop()并释放。得到voiceFile后,同样走上传,然后发带 URL 的消息。这里我建议在消息体里约定一个前缀,比如[voice]http://...,接收端看到前缀就调起播放器,而不是让接收端靠猜测判断。图片同理可以用[img]前缀。这样能避免 URL 出现在文本消息里被误显示。
播放端用MediaPlayer就够了:
MediaPlayer player = new MediaPlayer(); player.setDataSource(voiceUrl); player.prepareAsync(); player.setOnPreparedListener(mp -> mp.start());语音的另一个细节是聊天列表通常要显示时长。所以上传前先读取媒体文件时长,比如MediaPlayer或MediaMetadataRetriever获取 duration,然后放在消息扩展里或干脆拼到 URL 后面。我习惯在消息体里追加分隔符:
[voice]http://domain/audio/xxx.m4a|15000|15000表示毫秒时长。接收端解析出 URL 和时长,就能在气泡下面标出“15″”。
实现到这里,最基本的多媒体 IM 已经通了。接下来要面对的是真实环境里会出现的各种问题,下面这五个坑几乎每个项目都会遇到。
5. 常见问题排查:连接失败、消息丢失、文件传输卡顿的五个实例
5.1 连不上 Openfire:SASL 认证失败的排查方法
现象:客户端connect()成功,但login()时报SASLError或NotAuthorizedException。原因分成两种情况:一是用户名或密码确实错误,包括服务器后台创建的用户状态未激活;二是 Smack 默认启用了 SASL PLAIN 明文,而 Openfire 的配置限制了认证方式。解决步骤:先在 Openfire 后台确认用户存在且没有锁定;再检查代码里securityMode是否与服务器要求一致。本地测试时如果disabled还报错,去后台找到“Server 设置 → 客户端连接”,把“允许明文”勾上。生产环境千万不要为了过认证而打开明文,而是配置 TLS 证书。
5.2 收不到离线消息:检查 Presence 与服务器路由配置
现象:用户 A 第一次发消息给离线的 B,B 登录后看不到这条消息。排查思路:首先确认 B 是否真的处于离线状态。很多测试里 B 的客户端退到后台但连接没断,没有发送Presence.Type.unavailable,服务器会认为 B 还在线,于是消息直接持久化到“在线会话”而不是“离线存储”。这就是为什么代码里要在应用退出或断网前,主动发送离线 Presence。其次,Openfire 的离线消息存储是按会话路由来的,如果你用createChat时用的 JID 带 resource,比如user2@localhost/PC,服务器只把这个 resource 的会话视为在线,发送方如果给user2@localhost发消息,可能被分发给其他 resource。统一使用user2@localhost这种日产 JID 可以避免多数路由问题。
5.3 图片上传超时:调整服务器与客户端参数
现象:小图片上传正常,大图片经常卡在发送中,最后报 timeout。原因主要是图片没压缩,体积大加上上行带宽有限,而连接超时设置得太短。Openfire 的文件传输代理默认限制单文件大小,如果走的是 HTTP 上传,服务器也有最大请求体积。解决:客户端在上传前先压缩,控制到 1280×720 以上且每张不超过 300KB;同时把XMPPTCPConnection的setConnectTimeout和setReplyTimeout适当调大,例如连接超时 15 秒,回复超时 30 秒。如果自己写上传,务必放到独立线程并带有取消机制,不能和消息发送共用一个超时逻辑。
5.4 语音播放异常:音频格式与编解码器不一致
现象:语音文件能上传成功,但播放时提示“不能播放此音频”或没有任何声音。常见原因是录音时设置的输出格式是MPEG_4,但音频编码却是AAC的情况下,部分低版本 Android 设备无法解码;另一个原因是录音没有正确调用stop()和release(),文件没有完整写入。解决:确认编码器用AAC,采样率 44100,位率 64kbps,这样兼容性最好。播放端用MediaPlayer.setOnErrorListener捕获错误,如果是ERROR_UNSUPPORTED,提示用户重新升级播放器或转码。还有一点血泪经验:不要直接在 UI 线程里prepare(),必须用prepareAsync(),否则大文件会造成 ANR。
5.5 消息顺序乱掉:线程回调导致的乱序问题
现象:用户快速发送文字和语音消息,接收方看到的顺序时不时和发送方不一致。原因在代码里:sendStanza是异步,而本地 UI 是即时插入消息记录,接收方则是按服务器转发顺序回调。如果发送方多条消息选用了不同的发送路径(如图片先上传再发、文字直接发),服务器到达接收端的先后就不一定是发送顺序。解决:发送方在本地为消息生成单调递增的序列号,放到消息Thread或ExtensionElement里;接收端按序列号缓存排序后再显示。如果不想引入额外协议,最简单的方案是统一所有消息都通过同一个Chat.sendMessage来发送,Smack 会在底层维持发送队列,能减轻乱序。但图片的“先上传后发送”依然会打断顺序,所以生产环境还是建议做扩展元素存 seq。
6. 进阶技巧:让这套 IM 从能用变成好用
基础聊天做完之后,下一步就是把它打磨到生产可用的状态。建议从三个方向着手。
6.1 消息回执与已发送确认
用 XMPP 的 XEP-0184 消息回执,或者在自定义扩展里带msgId。我习惯在发送时给每条消息生成 UUID,放进<message id="xxx">,接收端处理完消息后自动回复一个回执<received id="xxx"/>。发送方收到回执后,把本地状态从“发送中”改成“已送达”。注意回执消息不能触发对方再次回执,否则死循环。Smack 里有ReceiptManager可以自动处理,但它依赖服务器支持扩展,实测下来还是自己解析扩展元素更可控。
6.2 自适应重连策略与心跳间隔
Smack 自带重连的缺点是无法自定义退避策略。生产环境建议关掉自带重连,自己写:断开后第一次 3 秒重试,后续每次翻倍,最多 30 秒;如果超过 5 次失败,停止重试并提示用户检查网络。心跳间隔建议 30 秒,太短费电,太长容易被运营商 NAT 断开连接。可以在XMPPTCPConnection里设置setKeystore相关的 keep-alive 机制,也可以依赖 TCP 层,但短信 App 场景下还是要额外发一个空白Ping来保活。
6.3 图片自动压缩和语音时长限制
图片发送前一定要做全精度判断:像素数大于 1920 或文件大于 200KB 时压缩。可以用BitmapFactory.Options的inSampleSize做采样,别用compress循环压缩,那样质量差还慢。语音时长限制在 60 秒内,超过提示用户分段录制,这样单个文件体积可控,服务器压力也小。
最后说一个我自己翻过车的地方:上线第一版时,没有做消息持久化,结果用户聊天记录只在内存里,一旦后台进程被杀,所有本地已读消息全部丢失。后来在本地用 SQLite 做了消息队列,先存后显,界面卡顿明显减少,用户反馈也稳定多了。做即时通讯,不要把 XMPP 当成黑匣子,协议既然开放,就要把细节握在自己手里。希望这篇整理能帮到你,少踩些我踩过的坑。
本文还有配套的精品资源,点击获取