简介:一套基于Java开发的陌生人交友App视频匹配社交聊天软件设计源码,属于前后端结合的中型移动应用工程,适合有Java或Vue基础的学生和开发者,用于掌握视频匹配、即时聊天、用户管理等核心模块的代码组织与实现思路。压缩包共253个文件、约35.41MB,核心文件包括61个Java源文件、33个Vue前端文件、31个JavaScript脚本,另有46个PNG图片、20个TTF字体、SQL脚本及配置文件等,分别对应后端逻辑、界面交互、视觉资源与数据存储。目前已有770人学习下载,可对照工程结构梳理陌生人社交业务中的匹配机制、聊天流程和资料存储方式。包内含有开源样式库、分页组件及GIF效果预览图,便于快速理解页面运行状态;从Java服务端到Vue前端再到数据库脚本均完整覆盖,既可作为毕业设计或课程项目参考,也能为二次开发提供可直接修改的基础。
1. 这套视频匹配社交源码解决的是什么:拆穿它背后只有三条主线
陌生人交友App要做「视频匹配」功能,最难的不是UI,而是「匹配算法 + 实时音视频 + 长连接信令」这三件事怎么在同一个工程里配合。基于Java的陌生人交友app视频匹配社交聊天软件设计源码这种项目,就是把这三件事做成一份能跑的Android端加后端源码,让做课设、毕设或者Java求职项目的人不用从零搭框架。它适合两类人:一类是想要一个能演示的社交App作品,另一类是面试前想逼自己把「Netty长连接、Redis队列、WebRTC信令」讲清楚的人。把它拆开看,其实就三条主线:怎么把两个人撮合到同一个房间、怎么让画面实时到达对面、怎么让聊天消息不丢不乱。
2. 技术选型先想清楚:Java客户端与后端的分工、通道与通信方式
2.1 为什么这套源码仍然值得用Java写客户端
现在新项目都推Kotlin,但Java仍然占据大量存量代码和课设源码。第一,这类源码大多跑在Android Studio里,Java版本的同事和同学都看得懂,改起来门槛低。第二,Java面试题里问「并发、集合、JVM内存」是绕不开的,一个Java写的社交App工程,正好把多线程、锁、阻塞队列、字符串处理全部装进真实场景。常见做法是Android端用Java加轻量ViewModel,服务端用Java加Spring Boot,语言统一,调试时还能互相读代码。如果你打算拿这个项目去面试,别人问你「Java里ConcurrentHashMap和HashMap的区别」,你直接说「匹配房间的Channel我用的就是ConcurrentHashMap,因为多路WS线程会同时读写」——这句话比背八股文有说服力得多。
2.2 一条长连接解决IM与信令:选型对比和理由
视频匹配App里有两类实时消息:聊天文本、控制指令(比如「开始匹配」「邀请通话」「挂断」),以及音视频媒体流。媒体流交给WebRTC,但控制消息需要一个双向推送通道。三种常见方案:
| 方案 | 延迟 | 服务端实现难度 | 为什么这里不合适 |
|---|---|---|---|
| HTTP轮询 | 3~10秒 | 最基础 | 匹配等待和呼叫邀请都等不起 |
| WebSocket长连接 | 200毫秒级别 | 中等 | 主流折中方案 |
| 自研TCP私有协议 | 可到50毫秒 | 很高 | 课设源码没必要重复造轮子 |
因此大多数同类源码会选「WebSocket做业务长连接 + JSON做消息体」。服务端不直接写原始Socket,而是用Netty的WebSocketServerHandler把连接管理、心跳、断线重连交出去。这样匹配请求、通话信令、聊天消息都可以走同一条连接,代码结构也清晰。我自己做联调时最看重一点:WebSocket的消息是文本帧,浏览器和Android端都能模拟,出问题时用Postman或者网页调试页就能复现,不需要专门写TCP客户端。
2.3 为什么音视频必须用WebRTC而不是直播推流
视频匹配是一对一的实时互动,和直播完全不同。直播用RTMP或HTTP-FLV推流,延迟2到5秒是常态,观众能接受,但两个人视频聊天一旦超过500毫秒就很别扭。WebRTC走的是P2P直传,建立会话后媒体数据不经过服务器转发,打洞失败时才退化走中继服务器,所以端到端延迟通常能到300毫秒以内。另外Android端有现成的org.webrtc:google-webrtc库,它自己处理编解码、回声消除、噪声抑制,不需要深入碰MediaCodec。这份源码里的「视频」不是自己写采集,而是把摄像头采集交给WebRTC,把开发重心放在信令交换上。这也是为什么面试官问「视频是怎么连起来的」,标准回答是「WebRTC负责媒体,WebSocket负责交换SDP和ICE候选」。
2.4 服务端的模块划分:拿到一份源码先认地图
我通常会先给读者一个后端模块地图,因为拿到源码就乱翻是最大浪费时间。常见结构是:
- Spring Boot业务接口:登录注册、用户资料、匹配历史、好友列表
- Netty独立端口:WebSocket长连接服务,处理在线状态、匹配、信令、聊天
- Redis:匹配池、在线用户表、最近匹配去重、通话超时Key
- MySQL:用户、匹配记录、通话记录、聊天记录、举报拉黑
为什么要分两套服务而不是都塞在Tomcat里?因为Spring MVC的请求-响应模型是按「一问一答」设计的,服务端没法主动向客户端推送消息;Netty的长连接模型天生就是「连接建立后任意时刻双向写数据」。这两个服务可以用同一个Spring容器启动,Netty端口和Tomcat端口分开监听。端口规划一般是这样:8080走HTTP业务接口,8088走WebSocket。Android端启动时先连8080登录,拿到Token后再连8088建立长连接,Token也作为建立WS连接时的鉴权参数。
2.5 在线状态与心跳:不做就会被队友骂
长连接最容易翻车的不是业务逻辑,是「假在线」。客户端网络从Wi-Fi切到4G、App被系统杀掉、地铁过隧道断网,服务端Socket不一定立刻感知到。所以Netty里要有一个IdleStateHandler做心跳:比如60秒没收到客户端的PING就触发事件,连续三次没心跳就把Channel关闭并清理在线状态。反过来,客户端收到断线通知后要用指数退避重连,不能每秒一次疯狂重连打爆服务器。心跳消息的JSON要尽量小,{"type":"ping"}就够了,不要每次把整个用户对象塞进去。这条做好以后,后面匹配池里的「在线用户」才可信,不然匹配到一个早已掉线的用户,接通率会很难看。
3. 匹配池的设计与实现:队列、规则、状态机和并发安全
3.1 把产品规则翻译成技术指标
先定义清楚陌生人匹配的产品规则:用户点「开始匹配」后进入等待队列,后台按「异性优先、标签有重合、回避最近见过的人」撮合。这些规则翻译成技术指标就是三个:等待时长(最差情况多久能配对成功)、匹配质量(标签重合度)、并发安全(同一时刻不能把人撮合两次)。陌生人社交场景里,「3秒内出结果」是常见要求,因为用户等久了就会退出,所以匹配过程不能每次都查MySQL,要用内存态或Redis完成。这里也是整个源码中最容易写出并发Bug的地方,值得花一整节讲。
3.2 用Redis ZSet当匹配池:一份能跑的Java核心逻辑
匹配池我倾向用Redis的ZSet而不是List,因为ZSet的Score可以存时间戳,天然支持「等待最久优先」。用户属性单独放一个Hash,读取时不解析长字符串。下面是一段可以改改就用的简化版:
// MatchPoolService.java —— 匹配池核心逻辑(简化版) // 依赖 StringRedisTemplate,假设入参已经过登录鉴权 public void enqueue(String uid, String gender, List<String> tags, double lng, double lat) { // 用户重复点击“开始匹配”时,先移除旧记录,避免同一个人在池里出现多条 redis.opsForZSet().remove("pool:waiting", uid); // Score存当前时间戳,匹配时按Score升序取,就是“等待最久优先” redis.opsForZSet().add("pool:waiting", uid, System.currentTimeMillis()); // 用户属性单独用Hash存:性别|标签(逗号分隔)|经度|纬度 redis.opsForHash().put("user:attr", uid, gender + "|" + String.join(",", tags) + "|" + lng + "|" + lat); } public String tryMatch() { // 取等待时间最久的一个用户 Set<String> firstSet = redis.opsForZSet().range("pool:waiting", 0, 0); if (firstSet == null || firstSet.isEmpty()) return null; String uid = firstSet.iterator().next(); String attr = (String) redis.opsForHash().get("user:attr", uid); if (attr == null) return null; String[] fields = attr.split("\\|"); // 0性别 1标签 2经度 3纬度 // 只往后看50个,避免每轮匹配都遍历整个池,控制单次开销 Set<String> candidates = redis.opsForZSet().range("pool:waiting", 1, 50); if (candidates == null) return null; for (String candidate : candidates) { String candAttr = (String) redis.opsForHash().get("user:attr", candidate); if (candAttr != null && isOppositeGender(fields[0], candAttr.split("\\|")[0]) && hasTagOverlap(fields[1], candAttr.split("\\|")[1])) { // 先把两个用户都移出池,再返回配对结果 redis.opsForZSet().remove("pool:waiting", uid, candidate); return uid + ":" + candidate; } } // 没找到就把刚才弹出的用户放回原位置,等下一轮 return null; }这段代码的关键在于入队时没有立刻尝试匹配,而是在定时任务或匹配线程里反复调用tryMatch()。ZSet.range取出的顺序是按Score升序,也就是等待最久的人永远最先被处理。isOppositeGender和hasTagOverlap是私有方法,分别做性别判断和标签交集判断,标签交集可以直接把两端字符串按逗号切进HashSet再retainAll。注意tryMatch里如果没找到候选人,调用方要把用户重新放回Pool(ZAdd回去),这里省略了。
3.3 并发安全:用Lua脚本把出池变成原子操作
上面tryMatch有两个并发风险:两个匹配线程可能同时取到同一个uid;或者取出后还没来得及配对,用户自己点了取消。常见做法是「把取出加校验加移除放进一段Lua脚本,在Redis里原子执行」,因为Redis单线程执行Lua,这里不会产生竞态条件。
-- lua_match.lua —— Redis 原子匹配的核心逻辑 -- KEYS[1] 等待池ZSet,KEYS[2] 用户属性Hash local uid = redis.call('ZRANGE', KEYS[1], 0, 0)[1] if not uid then return nil end local attr = redis.call('HGET', KEYS[2], uid) if not attr then redis.call('ZREM', KEYS[1], uid) return nil end local gender = string.match(attr, '([^|]+)') local tags = string.match(attr, '|([^|]+)|') local candidates = redis.call('ZRANGE', KEYS[1], 1, 50) for _, candidate in ipairs(candidates) do local candAttr = redis.call('HGET', KEYS[2], candidate) if candAttr then local candGender = string.match(candAttr, '([^|]+)') local candTags = string.match(candAttr, '|([^|]+)|') -- 性别相反,并且候选人的tags里包含任一相同标签 if candGender ~= gender and string.match(candTags, tags) then redis.call('ZREM', KEYS[1], uid, candidate) return uid .. ':' .. candidate end end end return nil这段Lua在Java里通过DefaultRedisScript<String>执行,返回的字符串就是uidA:uidB。参数说明:KEYS[1]是等待池ZSet的Key,KEYS[2]是用户属性Hash的Key。string.match在Lua里做的是正则匹配,这里用来判断标签存在性,标签以逗号分隔,所以string.match(candTags, tags)只要找到子串就返回真,如果标签名有包含关系(比如「电影」和「恐怖电影」)会误判,工程上要么改成循环逐标签匹配,要么给标签存成数组。这段Lua的价值不只在性能,面试时能讲清楚「Redis单线程执行Lua脚本所以原子」这个点,比「我用synchronized锁住了匹配方法」高一个档次。
3.4 通话房间状态机:从匹配成功到挂断
匹配成功只是开始,后面还有五个状态要维护:CALLING(正在呼叫对方)、IN_CALL(通话中)、ENDED(已挂断)、TIMEOUT(无人接听)、CANCELED(用户主动取消)。服务端要为每次配对生成一个roomId,存到Redis Hash里,Key是room:会话ID,Fields包括uidA、uidB、status、createTime。状态流转规则:
- 双方都进入CALLING时,设置一个30秒的接听超时Key
- 超时后服务端主动向双方推送
call:end,reason为timeout - 如果A已经进入CALLING但B还没确认,A点了「取消」,不能直接改状态,要用Redis的
WATCH配合事务做Compare-and-Set,防止B同时点了「接听」 - 通话结束要写MySQL的
match_record表,记录时长和结束原因
这里最容易写错的是把状态机放在Java内存里。一旦服务端重启,所有通话状态全部丢失,用户会卡在「呼叫中」。所以状态必须放在Redis,服务端启动时扫描所有room:*,把处于CALLING状态超过60秒的房间统一置为TIMEOUT。这个过程看起来不起眼,却是整个源码健壮性的分水岭。
3.5 防止三秒内重复匹配到同一个人
用户挂断后立刻再点「开始匹配」,如果匹配池里只有三五个人,很容易再次碰到刚挂断的人。常见做法:在Redis里维护一个recent:用户ID的ZSet,把每次匹配到的对方uid加进去,Score等于当前时间戳;匹配遍历候选人时,如果对方uid在这个ZSet里就跳过;定期执行ZREMRANGEBYSCORE清理5分钟前的记录。第三层防线是拉黑互斥:如果双方互相拉黑,匹配时必须排除,可以直接查Redis Set或MySQL的blacklist表。这三个手段合起来,匹配体验才谈得上「像那么回事」。注意去重列表存的是uid,不是roomId,因为roomId每次都要新生成。
4. 信令与聊天实现:Netty如何接管WebSocket消息转发
4.1 先定义一套JSON协议:前端和后端都别自创
多人协作时最怕「消息格式每个人理解不同」。我建议把所有消息类型集中写在一个常量类里,协议字段固定成下表:
| 消息类型 | 方向 | 用途 | 关键字段 |
|---|---|---|---|
| match:apply | 客户端→服务端 | 加入匹配池 | uid, gender, tags |
| match:success | 服务端→客户端 | 配对成功 | roomId, peerUid, peerNick |
| rtc:offer | 客户端A→服务端→B | 发起视频邀请 | roomId, sdp |
| rtc:answer | 客户端B→服务端→A | 应答视频邀请 | roomId, sdp |
| rtc:ice | 任意→服务端→对端 | 交换ICE候选 | roomId, candidate |
| im:text | 任意→服务端→对端 | 文本聊天 | roomId, content |
| call:end | 任意→服务端→双方 | 挂断或超时 | roomId, reason |
字段命名统一用小驼峰,时间统一用毫秒时间戳,避免前端Date对象和后端LocalDateTime互相转来转去出错。match:success里的peerNick要脱敏,只返回昵称和一个头像URL,不要返回手机号。另一个常见坑:rtc:ice里candidate是一个JSON字符串,很多新手直接把整个candidate对象塞进去,导致嵌套转义混乱,建议协议里就定义成String,两端按字符串解析。
4.2 服务端Netty处理信令的样板代码
Netty的WebSocket处理核心,写一个处理器类来说明:
// SignalingHandler.java —— Netty中的WebSocket消息入口 public class SignalingHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> { // 简化版房间映射:roomId -> 两个Channel,生产环境要配合ChannelGroup private static final ConcurrentHashMap<String, List<Channel>> ROOMS = new ConcurrentHashMap<>(); private static final AttributeKey<String> ATTR_UID = AttributeKey.valueOf("uid"); @Override protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) { JsonObject msg = JsonParser.parseString(frame.text()).getAsJsonObject(); String type = msg.get("type").getAsString(); String roomId = msg.has("roomId") ? msg.get("roomId").getAsString() : null; switch (type) { case "rtc:offer": case "rtc:answer": case "rtc:ice": { // 信令转发:找到同房间的另一个客户端,原样把消息推过去 List<Channel> channels = ROOMS.get(roomId); if (channels == null) return; // 追加fromUid,避免对端不知道这条信令是谁发的 msg.addProperty("fromUid", ctx.channel().attr(ATTR_UID).get()); for (Channel ch : channels) { if (ch != ctx.channel() && ch.isActive()) { ch.writeAndFlush(new TextWebSocketFrame(msg.toString())); } } break; } case "im:text": { // 文本消息:先落库,再推给对端 chatService.saveMessage(roomId, msg); List<Channel> channels = ROOMS.get(roomId); for (Channel ch : channels) { if (ch != ctx.channel() && ch.isActive()) { ch.writeAndFlush(new TextWebSocketFrame(msg.toString())); } } break; } default: break; } } @Override public void channelInactive(ChannelHandlerContext ctx) { // 连接关闭时要把Channel从ROOMS里移除,否则僵尸连接会收到消息 String uid = ctx.channel().attr(ATTR_UID).get(); if (uid != null) { redis.opsForSet().remove("online:users", uid); } ctx.fireChannelInactive(); } }这段代码的逻辑说明:ROOMS是简化方案,实际工程里要配合ChannelGroup做批量推送和关闭。为什么用ctx.channel().attr()存uid而不是每次在消息体里传uid?因为WebSocket握手时可以拿Token解析出用户身份,把uid绑定到连接上;后面每条消息都不需要再传uid,防止客户端伪造。im:text先走chatService.saveMessage落库再转发,顺序不能反过来,否则对端在线收到了消息,但数据库里没有记录,后续查聊天记录就缺失。
4.3 Android端WebRTC的启动顺序
客户端这端,WebRTC初始化顺序经常有人搞反——先初始化Factory、再创建PeerConnectionFactory、再拿摄像头,顺序不能乱。
// WebRtcEngine.java —— Android端WebRTC的视频呼叫引擎(关键流程) public void startCall(String roomId, WebSocketClient ws) { // 1. 初始化EGL上下文,用于视频渲染 EglBase eglBase = EglBase.create(); // 2. 初始化PeerConnectionFactory,编码器和解码器都挂在Factory下 PeerConnectionFactory.initialize( PeerConnectionFactory.InitializationOptions.builder(context) .setEnableInternalTracer(true) .createInitializationOptions()); PeerConnectionFactory factory = PeerConnectionFactory.builder() .setVideoEncoderFactory(new DefaultVideoEncoderFactory( eglBase.getEglBaseContext(), true, true)) .setVideoDecoderFactory(new DefaultVideoDecoderFactory( eglBase.getEglBaseContext())) .createPeerConnectionFactory(); // 3. 配置ICE服务器,先用STUN获取公网候选 List<IceServer> iceServers = Arrays.asList( IceServer.builder("stun:你的stun服务器地址").createIceServer() // 打洞失败时还需要配合中继服务器 ); // 4. 创建PeerConnection,信令驱动它的状态 PeerConnection connection = factory.createPeerConnection(iceServers, observer); // 5. 创建本地流:VideoSource + VideoTrack MediaStream stream = factory.createLocalMediaStream("local"); VideoSource videoSource = factory.createVideoSource(false); Camera1Enumerator enumerator = new Camera1Enumerator(false); VideoCapturer capturer = enumerator.createCapturer( enumerator.getDeviceNames()[0], null); capturer.initialize(videoSource.getSurfaceTextureHelper(), context, videoSource.getCapturerObserver()); stream.addTrack(factory.createVideoTrack("video", videoSource)); // 音频同理:AudioSource + AudioTrack,这里省略 connection.addStream(stream); }每个步骤说明:EGL上下文必须先创建,因为视频渲染需要OpenGL ES环境;setEnableInternalTracer(true)只在调试阶段开,上线关掉,否则会持续写日志影响性能;Camera1Enumerator的getDeviceNames()[0]取第一个摄像头,多数机型是前置,但有的国产机第一个是后置,真实工程需要遍历查找特性为FRONT的摄像头。factory.createVideoSource(false)里的isScreencast参数传false,表示是摄像头采集而不是屏幕共享。最后一步connection.addStream(stream)在API较新的WebRTC版本里已经废弃,新版本改成addTrack,但很多课设源码还沿用addStream,编译报错时优先看依赖版本。
4.4 聊天消息的存储与送达
聊天如果只走WebSocket转发,离线消息会丢。常见做法:上线走WS时,同时把消息写MySQL;对端的WS在线就直接推,不在线就由对方登录后拉取离线消息表。陌生人社交产品的聊天会话不用像微信那样永久保留,通常保存最近50条就够。用一张im_message表,字段:id、room_id、from_uid、to_uid、content、create_time。写库成功后再给对端推送Redis里的未读计数,对端看到未读角标。还要加发送频率限制,常见限制是10条/分钟,超过就拒绝并提示「发言太快」。陌生人匹配场景和熟人IM不一样,一对刚认识的用户可能互相骚扰,频控和举报入口必须同时存在。这也是我在源码里最坚持的一处产品底线。
5. 把源码跑起来:环境准备、配置修改和联调顺序
5.1 拿到源码先看哪三样东西
拿到一份源码工程,我一般不会先翻代码,而是按顺序找三个东西:sql目录下的建表脚本、resources目录下的application.yml、根目录的README。这三样分别回答「数据库长什么样」「连接配置是什么」「作者建议怎么启动」。很多人先花二十分钟读代码,结果发现数据库连不上,回头再找脚本,其实是浪费了时间。正确顺序是:先建库建表,再改配置,最后启动服务,这样任何一步报错都能立刻定位到是环境问题还是代码问题。
5.2 从一个空的MySQL到后端启动成功
先给一段建表SQL,这是工程能跑起来的前提:
-- init.sql 核心三张表:user、match_record、im_message CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID', `phone` VARCHAR(20) NOT NULL COMMENT '手机号', `nickname` VARCHAR(50) NOT NULL COMMENT '昵称', `gender` TINYINT NOT NULL DEFAULT 0 COMMENT '0男1女', `avatar` VARCHAR(255) DEFAULT NULL, `tags` VARCHAR(255) DEFAULT NULL COMMENT '兴趣标签,逗号分隔', `status` TINYINT DEFAULT 0 COMMENT '0离线1在线', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_phone` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `match_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `uid_a` BIGINT NOT NULL, `uid_b` BIGINT NOT NULL, `room_id` VARCHAR(40) NOT NULL, `duration` INT DEFAULT 0 COMMENT '通话时长(秒)', `end_reason` VARCHAR(20) DEFAULT NULL COMMENT 'end by user / timeout / disconnect', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_room_id` (`room_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='匹配通话记录'; CREATE TABLE `im_message` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `room_id` VARCHAR(40) NOT NULL, `from_uid` BIGINT NOT NULL, `to_uid` BIGINT NOT NULL, `content` VARCHAR(500) NOT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_room_id` (`room_id`), KEY `idx_to_uid` (`to_uid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天消息表';建表之后,改application.yml里的数据源和Redis连接。这里有一个参数补全:spring.datasource.url里要带上useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8,不然容易出现时区报错和中文乱码。后端启动命令很简单:
mvn clean package -DskipTests java -jar target/social-match-server.jar --spring.profiles.active=dev启动成功的标志不是「Tomcat started」,而是看到Netty的bind(8088)日志。很多人只看Tomcat起来就以为全部就绪,结果8088端口没监听,Android端连不上WebSocket,这才是最坑的。
5.3 Android端配置:权限、明文HTTP、依赖版本
Android端启动最常遇到三类问题。第一,Manifest里缺CAMERA、RECORD_AUDIO、INTERNET权限,运行时摄像头打不开。第二,targetSdk版本较高时明文HTTP被系统默认拦截,本地调试用http://192.168.x.x:8080会直接连接失败,要在Manifest的application节点加android:usesCleartextTraffic="true",或者配置networkSecurityConfig只对局域网IP放开。第三,依赖版本冲突,常见表现是Webrtc库要求的minSdkVersion比工程的minSdkVersion高,编译直接报错,建议把minSdkVersion至少提到21。权限申请不要只在Manifest里声明,Android 6.0以上还要在代码里动态申请,否则进入通话页没权限直接黑屏。
5.4 联调顺序:先只跑IM,再接入视频
我一般会用「先文本后视频」的路线。第一步,起后端,用两台真机登录不同账号,先验证互相发文本消息通不通;第二步,手动在Redis里写入一条room:test001数据,让两个客户端都进入通话页,验证WebRTC的信令能通;第三步才点「开始匹配」让真实匹配流程跑起来。这样每一步失败时问题范围都很小,不至于出了Bug到处找。如果手里只有一台真机,可以用Android模拟器配合摄像头模拟源,但模拟器对WebRTC支持不稳定,首选真机。联调时后端的日志级别调到DEBUG,重点看Netty收到每条信令的时间戳,能快速判断是转发慢还是客户端处理慢。
6. 排查与避坑:视频匹配App最常见的五个翻车现场
6.1 现象一:匹配成功,画面却一直黑屏
匹配成功、信令都发了、双方都进了通话页,但摄像头画面是黑的。第一反应别去翻WebRTC渲染代码,先看权限:Android 6.0以上的动态权限弹窗是否真的弹了,用户是否点了允许。第二常见原因是SurfaceView渲染控件的Surface生命周期问题——在onCreate里把Surface传给WebRTC,但Surface在onPause时被销毁,回来没有重新绑定。解决:渲染控件改用TextureView,并在onResume里重新把SurfaceTexture设置给VideoSink;如果还不行,在视频Track上挂一个帧观察者,看有没有帧数据进来。没有帧数据,问题在采集层;有帧数据但黑屏,问题在渲染层。这样分界排查,比盲改快很多。
6.2 现象二:局域网内视频正常,换成4G就卡在「连接中」
局域网里两端都能看到画面,到了真机用4G,匹配成功后一直显示「连接中」。原因是WebRTC默认先尝试P2P打洞,如果两端都在对称型网络后面,打洞失败,又没有配置中继服务器,连接就建立不起来。解决:本地联调可以两端连同一个Wi-Fi绕过这个坑,但上到公网演示必须配中继服务器,并把它加进IceServer列表。这类源码如果作者没配中继,你需要自己补,这是陌生人视频匹配工程绕不开的基础设施投入。排查时先看ICE候选状态日志:打洞失败时日志里会反复出现candidate gathering complete, but no usable pair。
6.3 现象三:进入通话页闪退,报错在摄像头枚举
三星和部分国产手机上,进通话页直接崩,logcat定位到Camera1Enumerator。原因是getDeviceNames()[0]在部分机型上拿到的第一个设备可能是后置摄像头,或者该摄像头正被其他应用占用。解决:先判断摄像头数量,再遍历找到FACING_FRONT的那一路;onStop里要release摄像头,不能只依赖PeerConnection的清理;国产ROM的相机权限回调时机不准,要在权限回调成功后再初始化采集器,而不是在回调前就创建VideoCapturer。这条坑属于机型适配问题,测机时至少准备一台国产中低端机,很多问题只在这些机器上复现。
6.4 现象四:Redis里积压了一堆「等待中」用户,匹配线程空转
匹配响应越来越慢,查看等待池ZSet有几十个用户,但没有任何配对成功。常见原因是匹配线程池跑满了,或者某个用户被取出后通话建立超时,事务没回滚,用户一直处于「半出池」状态。解决:给每个从池里取出的用户设置一个10秒的「占用标记」,比如往occupied:里写一个带过期时间的Key;如果10秒内没完成配对成功信令,匹配器要把用户重新放回池,并删除占用标记。匹配线程的遍历范围从50个降到10个,观察单次耗时,Hash读取操作过多时Redis的CPU会先打满,表现为Redis所在机器负载高但业务接口正常。
6.5 现象五:切换网络后消息不推送,重新打开App才收到
Wi-Fi切4G或者地铁断网恢复后,对方发来消息收不到,但杀掉App重进能看到未读。原因是WS断线后服务端清除了在线状态,但客户端重连成功后没有主动上报uid,也没有拉取断线期间的离线消息。解决:客户端重连成功回调里,先发login:online消息并携带uid;服务端收到后查Redis里该用户的未读消息列表,通过WS逐条推给客户端,推完再返回offline:done;客户端收到后才能进入聊天页。重连逻辑用指数退避,第一次等1秒、第二次等2秒、第四次等8秒,最多不超过30秒,防止服务端刚重启时几百个客户端同时撞上来打崩端口。
7. 上线前最值得做的一件事:日志链路验证一次完整通话
如果你准备用这份源码做毕设或面试项目,在演示之前,我建议花半天时间做一件事:给自己加一条「会话级日志链路」。具体做法是在匹配成功生成roomId之后,后端在Netty收到和发出的每条信令都追加roomId字段,统一打到日志里,然后反复跑完整流程:匹配→呼叫→接通→挂断→重新匹配,核对每一步的状态是否符合预期。
| 链路节点 | 检查方式 | 预期结果 |
|---|---|---|
| A入池 | 查看日志里match:apply | 等待池ZSet长度加1 |
| 匹配成功 | 查看match:success日志 | 双方都收到相同roomId |
| SDP交换 | 查看rtc:offer/answer | B收到offer且sdp字段不为空 |
| ICE候选 | 统计rtc:ice条数 | 两端合计至少2条candidate |
| 接通 | 查看状态日志 | 双方状态都是IN_CALL |
| 挂断 | 查看call:end | match_record表duration大于0 |
为什么用日志链路而不是界面观察?因为App界面上显示的「已接通」可能是假象,比如一端通了另一端没通,界面还是会显示呼出中。只有服务端状态机和两端信令日志都对上,才算真正跑通。我自己的习惯是把这些检查项写成一个小的验证清单,每次改完服务端代码就照着过一遍,比点界面高效得多。压测时分开压「匹配线程」和「信令转发」,匹配压测要同时挂100个WebSocket客户端看配对成功率,信令转发压测要在同一房间连发500条消息看丢序率,两个指标不要混在一次压测里判断。
这份源码值不值得继续投入,取决于你想拿它做什么。如果是为了课程设计和Java面试题里能讲出亮点,那P2P视频加Redis原子匹配这两个点已经够了,把日志链路补上,演示时直接亮日志和状态机,面试官反而会追问你是怎么排查问题的。如果是为了做成正式产品,还差中继服务器、内容审核、举报处理、未成年人保护这些基础设施,不是源码本身能解决的。先验证一次完整通话,再决定下一步往哪走,这是我最后想留给你的建议,希望帮到你。
本文还有配套的精品资源,点击获取