本文是面向 Android 开发者的实战教程,手把手教你用 ZEGO Express Audio SDK + ZIM SDK 构建一个支持 8 麦位管理、多人实时语音的语音聊天室应用。全部核心逻辑仅用两个 Java 文件实现,代码清晰易读,适合快速上手。

1. 项目介绍
语音聊天室是当下社交、游戏、音乐等场景中非常热门的功能。本项目实现了一个完整的 Android 语音聊天室 Demo,核心特性包括:
- 8 麦位管理:2×4 网格布局,支持上麦、下麦操作
- 多人实时语音:基于 ZEGO Express Audio SDK,实现低延迟语音通话
- 房主特权:踢人、全员静音等管理功能
- 麦位状态实时同步:通过 ZIM 房间属性实现多端麦位状态同步
- Token 鉴权:基于 Node.js 服务端生成 Token04,确保通信安全
项目采用 单文件实现 原则,所有业务逻辑集中在两个 Activity 中,没有额外的 Manager/Service 层,代码直白易懂:
MainActivity.java → 登录界面 + 房间入口
RoomActivity.java → 语音聊天室(麦位 + 语音 + 管理)
2. 技术架构
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Android Client │◄──────►│ Node.js Server │ │ ZEGO Cloud │
│ (Express Audio │ HTTP │ (Express.js) │◄──────►│ - RTC Engine │
│ + ZIM SDK) │────────│ - Token Gen │ │ - ZIM Service │
└──────────────────┘ ZIM └──────────────────┘ └──────────────────┘
关键设计决策
| 决策 | 说明 |
|---|---|
| Express Audio SDK | 仅传输语音,不涉及视频,降低性能消耗 |
| ZIM SDK | 负责房间信令和麦位状态同步,通过房间属性实现 |
| 房间属性(Room Attributes) | 存储麦位状态:seat_0 ~ seat_7,值为 JSON 格式的用户信息 |
| Token 服务端生成 | 使用 Token04 鉴权机制,保障 ZIM 和 RTC 通信安全 |
数据流
用户操作 → ZIM 房间属性变更 → 其他端收到 onRoomAttributesUpdated 回调 → UI 更新
用户上麦 → ZIM setRoomAttributes + RTC startPublishingStream → 远端 startPlayingStream
用户下麦 → ZIM deleteRoomAttributes + RTC stopPublishingStream → 远端 stopPlayingStream
3. 环境准备
前置条件
- Android Studio Hedgehog 或更高版本
- Android 5.0(API 24)及以上设备或模拟器
- Node.js 8+ (用于 Token 服务端)
- 注册ZEGO 控制台账号,获取 AppID 和 ServerSecret
SDK 依赖
项目使用以下 SDK 版本:
// build.gradle.kts
implementation("im.zego:zim:2.29.0") // ZIM 信令 SDK
implementation("com.zegocloud:express-audio:3.24.0") // Express Audio 语音 SDK
服务端配置
cd examples/server
cp .env.example .env
# 编辑 .env 文件,填入你的 ZEGO AppID 和 ServerSecret
# APP_ID=你的AppID
# SERVER_SECRET=你的ServerSecret
npm install
npm start
# 服务运行在 http://localhost:3000
Android 客户端配置
cd examples/android
cp local.properties.example local.properties
# 编辑 local.properties:
# ZEGO_APP_ID=你的AppID
# ZEGO_API_BASE_URL=http://10.0.2.2:3000 (模拟器)
# 或 http://你的电脑IP:3000 (真机)
./gradlew assembleDebug
4. 核心代码解析
4.1 SDK 初始化与用户登录
登录流程分为三步:获取 Token → 初始化 ZIM → 创建 Express Audio 引擎。
第一步:从服务端获取 Token
// MainActivity.java - getTokenFromServer()
private void getTokenFromServer(String userId, Runnable onSuccess) {
String url = com.zego.voicechatroom.BuildConfig.ZEGO_API_BASE_URL + "/api/token";
JSONObject body = new JSONObject();
body.put("userId", userId);
JsonObjectRequest request = new JsonObjectRequest(
Request.Method.POST, url, body,
response -> {
String token = response.optString("token", "");
if (!TextUtils.isEmpty(token)) {
currentToken = token;
onSuccess.run();
}
},
error -> {
Toast.makeText(this, "Failed to get token: " + error.getMessage(), Toast.LENGTH_LONG).show();
}
);
requestQueue.add(request);
}
第二步:初始化 ZIM SDK 并登录
// MainActivity.java - initZIMAndLogin()
private void initZIMAndLogin() {
// 创建 ZIM 实例(全局单例)
ZIM.create(BuildConfig.ZEGO_APP_ID, getApplicationContext());
// 配置登录信息
ZIMLoginConfig config = new ZIMLoginConfig();
config.userName = currentUserName;
config.token = currentToken;
// 异步登录
ZIM.getInstance().login(currentUserId, config, new ZIMLoggedInCallback() {
@Override
public void onLoggedIn(ZIMError error) {
runOnUiThread(() -> {
if (error.code == ZIMErrorCode.SUCCESS) {
createExpressEngine(); // 登录成功后创建 RTC 引擎
// 切换到房间入口界面
loginSection.setVisibility(View.GONE);
roomEntrySection.setVisibility(View.VISIBLE);
} else {
Toast.makeText(MainActivity.this,
"ZIM login failed: " + error.message, Toast.LENGTH_LONG).show();
}
});
}
});
}
第三步:创建 Express Audio 引擎
// MainActivity.java - createExpressEngine()
private void createExpressEngine() {
ZegoEngineProfile profile = new ZegoEngineProfile();
profile.appID = BuildConfig.ZEGO_APP_ID;
profile.scenario = ZegoScenario.DEFAULT;
profile.application = getApplication();
ZegoExpressEngine.createEngine(profile, null);
}
4.2 房间创建与加入
房主创建房间、普通成员加入房间使用不同的 ZIM API:
房主创建房间
// RoomActivity.java - createRoom()
private void createRoom() {
ZIMRoomInfo roomInfo = new ZIMRoomInfo();
roomInfo.roomID = roomId;
roomInfo.roomName = "VoiceChat_" + roomId;
ZIM.getInstance().createRoom(roomInfo, new ZIMRoomCreatedCallback() {
@Override
public void onRoomCreated(ZIMRoomFullInfo zimRoomFullInfo, ZIMError error) {
runOnUiThread(() -> {
if (error.code == ZIMErrorCode.SUCCESS) {
addMessage("Room created: " + roomId);
loginRTCRoom(); // 登录 RTC 房间
takeSeat(0); // 房主自动占据 0 号麦位
} else {
Toast.makeText(RoomActivity.this,
"Create room failed: " + error.message, Toast.LENGTH_LONG).show();
finish();
}
});
}
});
}
普通成员加入房间
// RoomActivity.java - joinRoom()
private void joinRoom() {
ZIM.getInstance().joinRoom(roomId, new ZIMRoomJoinedCallback() {
@Override
public void onRoomJoined(ZIMRoomFullInfo zimRoomFullInfo, ZIMError error) {
runOnUiThread(() -> {
if (error.code == ZIMErrorCode.SUCCESS) {
addMessage("Joined room: " + roomId);
loginRTCRoom(); // 登录 RTC 房间
querySeats(); // 查询已有麦位状态
} else {
Toast.makeText(RoomActivity.this,
"Join room failed: " + error.message, Toast.LENGTH_LONG).show();
finish();
}
});
}
});
}
RTC 房间登录
// RoomActivity.java - loginRTCRoom()
private void loginRTCRoom() {
ZegoExpressEngine engine = ZegoExpressEngine.getEngine();
if (engine == null) return;
ZegoUser user = new ZegoUser(userId, userName);
ZegoRoomConfig roomConfig = new ZegoRoomConfig();
roomConfig.token = token;
roomConfig.isUserStatusNotify = true;
engine.loginRoom(roomId, user, roomConfig, (int error, JSONObject extendedData) -> {
runOnUiThread(() -> {
if (error == 0) {
addMessage("RTC login success");
} else {
addMessage("RTC login failed: " + error);
}
});
});
}
4.3 麦位管理(上麦 / 下麦)
麦位状态通过 ZIM 房间属性存储,每个麦位对应一个 key-value 对:
| Key | Value(已占用) | Value(空闲) |
|---|---|---|
seat_0 | {"userID":"host1","userName":"Host","isMuted":false} | "" |
seat_1 | {"userID":"user2","userName":"Alice","isMuted":false} | "" |
| … | … | "" |
上麦操作
// RoomActivity.java - takeSeat()
private void takeSeat(int seatIndex) {
if (mySeatIndex >= 0) {
Toast.makeText(this, "You are already on seat " + mySeatIndex, Toast.LENGTH_SHORT).show();
return;
}
if (seats[seatIndex] != null) {
Toast.makeText(this, "Seat " + seatIndex + " is occupied", Toast.LENGTH_SHORT).show();
return;
}
SeatInfo seatInfo = new SeatInfo(userId, userName, false);
String jsonValue = gson.toJson(seatInfo);
HashMap attrs = new HashMap<>();
attrs.put(SEAT_KEY_PREFIX + seatIndex, jsonValue);
ZIMRoomAttributesSetConfig config = new ZIMRoomAttributesSetConfig();
config.isForce = false;
config.isDeleteAfterOwnerLeft = true;
ZIM.getInstance().setRoomAttributes(attrs, roomId, config, new ZIMRoomAttributesOperatedCallback() {
@Override
public void onRoomAttributesOperated(String roomID, ArrayList errorKeys, ZIMError errorInfo) {
runOnUiThread(() -> {
if (errorInfo.code == ZIMErrorCode.SUCCESS) {
mySeatIndex = seatIndex;
seats[seatIndex] = seatInfo;
updateSeatUI(seatIndex);
updateControlButtons();
startPublishing(); // 开始推音频流
addMessage("You took seat " + seatIndex);
}
});
}
});
}
下麦操作
// RoomActivity.java - removeSeat()
private void removeSeat(int seatIndex) {
ArrayList keys = new ArrayList<>();
keys.add(SEAT_KEY_PREFIX + seatIndex);
ZIMRoomAttributesDeleteConfig config = new ZIMRoomAttributesDeleteConfig();
config.isForce = true;
ZIM.getInstance().deleteRoomAttributes(keys, roomId, config, new ZIMRoomAttributesOperatedCallback() {
@Override
public void onRoomAttributesOperated(String roomID, ArrayList errorKeys, ZIMError errorInfo) {
runOnUiThread(() -> {
if (errorInfo.code == ZIMErrorCode.SUCCESS) {
if (seatIndex == mySeatIndex) {
stopPublishing(); // 停止推流
mySeatIndex = -1;
}
seats[seatIndex] = null;
updateSeatUI(seatIndex);
updateControlButtons();
addMessage("Seat " + seatIndex + " is now vacant");
}
});
}
});
}
4.4 实时语音推拉流
上麦时开始推送音频流,远端自动订阅并播放:
推流(Publishing)
// RoomActivity.java - startPublishing()
private void startPublishing() {
ZegoExpressEngine engine = ZegoExpressEngine.getEngine();
if (engine != null) {
String streamID = "stream_" + userId;
engine.startPublishingStream(streamID);
addMessage("Publishing stream: " + streamID);
}
}
自动订阅远端流
// RoomActivity.java - setupRTCEventHandler()
engine.setEventHandler(new IZegoEventHandler() {
@Override
public void onRoomStreamUpdate(String roomID, ZegoUpdateType updateType,
ArrayList streamList, JSONObject extendedData) {
runOnUiThread(() -> {
if (updateType == ZegoUpdateType.ADD) {
for (ZegoStream stream : streamList) {
addMessage("New stream: " + stream.streamID);
engine.startPlayingStream(stream.streamID); // 自动拉流
}
} else if (updateType == ZegoUpdateType.DELETE) {
for (ZegoStream stream : streamList) {
addMessage("Stream removed: " + stream.streamID);
engine.stopPlayingStream(stream.streamID);
}
}
});
}
});
4.5 麦克风开关控制
麦克风控制同时更新本地 RTC 状态和 ZIM 房间属性,确保多端 UI 同步:
// RoomActivity.java - toggleMic()
private void toggleMic() {
isMicMuted = !isMicMuted;
// 1. 更新 RTC 引擎的麦克风状态
ZegoExpressEngine engine = ZegoExpressEngine.getEngine();
if (engine != null) {
engine.muteMicrophone(isMicMuted);
}
// 2. 更新按钮文本
btnMute.setText(isMicMuted ? "Unmute" : "Mute");
// 3. 同步麦位属性(通知其他端)
if (mySeatIndex >= 0) {
updateMySeatMicStatus();
}
}
// 更新麦位属性中的静音状态
private void updateMySeatMicStatus() {
if (mySeatIndex < 0 || seats[mySeatIndex] == null) return;
seats[mySeatIndex].isMuted = isMicMuted;
String jsonValue = gson.toJson(seats[mySeatIndex]);
HashMap attrs = new HashMap<>();
attrs.put(SEAT_KEY_PREFIX + mySeatIndex, jsonValue);
ZIMRoomAttributesSetConfig config = new ZIMRoomAttributesSetConfig();
config.isForce = false;
ZIM.getInstance().setRoomAttributes(attrs, roomId, config, null);
}
4.6 房主管理(踢人 / 全员静音)
踢人:点击麦位即可踢出用户
// RoomActivity.java - kickUserFromSeat()
private void kickUserFromSeat(int seatIndex) {
if (!isHost) return;
if (seats[seatIndex] == null) return;
String kickedUser = seats[seatIndex].userName;
removeSeat(seatIndex); // 复用下麦逻辑
addMessage("Host kicked " + kickedUser + " from seat " + seatIndex);
}
全员静音:使用批量操作 API
// RoomActivity.java - muteAllSeats()
private void muteAllSeats() {
if (!isHost) return;
// 开启批量操作(所有 setRoomAttributes 会在 endBatch 后一次性回调)
ZIMRoomAttributesBatchOperationConfig batchConfig = new ZIMRoomAttributesBatchOperationConfig();
batchConfig.isForce = true;
ZIM.getInstance().beginRoomAttributesBatchOperation(roomId, batchConfig);
// 批量更新所有已占用的麦位
for (int i = 0; i < SEAT_COUNT; i++) {
if (seats[i] != null) {
seats[i].isMuted = true;
String jsonValue = gson.toJson(seats[i]);
HashMap attrs = new HashMap<>();
attrs.put(SEAT_KEY_PREFIX + i, jsonValue);
ZIMRoomAttributesSetConfig config = new ZIMRoomAttributesSetConfig();
config.isForce = true;
ZIM.getInstance().setRoomAttributes(attrs, roomId, config, null);
}
}
// 结束批量操作
ZIM.getInstance().endRoomAttributesBatchOperation(roomId, new ZIMRoomAttributesBatchOperatedCallback() {
@Override
public void onRoomAttributesBatchOperated(String roomID, ZIMError errorInfo) {
runOnUiThread(() -> {
if (errorInfo.code == ZIMErrorCode.SUCCESS) {
isMicMuted = true;
ZegoExpressEngine engine = ZegoExpressEngine.getEngine();
if (engine != null) {
engine.muteMicrophone(true);
}
btnMute.setText("Unmute");
for (int i = 0; i < SEAT_COUNT; i++) {
updateSeatUI(i);
}
addMessage("Host muted all microphones");
}
});
}
});
}
5. 功能实现详解
5.1 房间生命周期
完整的房间生命周期如下:
创建/加入房间 → 登录 RTC 房间 → 查询/设置麦位 → 推拉流 → 离开房间
退出房间时需要清理所有资源:
// RoomActivity.java - leaveRoom()
private void leaveRoom() {
// 1. 释放麦位
if (mySeatIndex >= 0) {
ArrayList keys = new ArrayList<>();
keys.add(SEAT_KEY_PREFIX + mySeatIndex);
ZIMRoomAttributesDeleteConfig config = new ZIMRoomAttributesDeleteConfig();
config.isForce = true;
ZIM.getInstance().deleteRoomAttributes(keys, roomId, config, null);
stopPublishing();
}
// 2. 登出 RTC 房间
ZegoExpressEngine engine = ZegoExpressEngine.getEngine();
if (engine != null) {
engine.logoutRoom(roomId);
}
// 3. 离开 ZIM 房间
ZIM.getInstance().leaveRoom(roomId, new ZIMRoomLeftCallback() {
@Override
public void onRoomLeft(String roomID, ZIMError errorInfo) {
runOnUiThread(() -> finish());
}
});
}
5.2 麦位状态同步机制
麦位状态通过 ZIM 房间属性实现实时多端同步:
- 写入方:调用
setRoomAttributes()或deleteRoomAttributes()修改麦位 - 接收方:通过
onRoomAttributesUpdated()回调获取变更 - 新加入者:调用
queryRoomAllAttributes()获取完整快照
// RoomActivity.java - 麦位属性变更监听
ZIM.getInstance().setEventHandler(new ZIMEventHandler() {
@Override
public void onRoomAttributesUpdated(ZIM zim, ZIMRoomAttributesUpdateInfo info, String roomID) {
runOnUiThread(() -> {
HashMap roomAttributes = info.roomAttributes;
for (Map.Entry entry : roomAttributes.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (key != null && key.startsWith(SEAT_KEY_PREFIX)) {
int seatIndex = Integer.parseInt(key.substring(SEAT_KEY_PREFIX.length()));
if (value == null || value.isEmpty()) {
seats[seatIndex] = null; // 麦位变为空闲
} else {
seats[seatIndex] = gson.fromJson(value, SeatInfo.class); // 有人上麦
}
updateSeatUI(seatIndex);
}
}
});
}
});
5.3 语音流自动订阅
当有新的远端流加入时,自动开始拉流播放;当流被移除时,自动停止拉流:
// RTC 流变更回调
@Override
public void onRoomStreamUpdate(String roomID, ZegoUpdateType updateType,
ArrayList streamList, JSONObject extendedData) {
runOnUiThread(() -> {
if (updateType == ZegoUpdateType.ADD) {
for (ZegoStream stream : streamList) {
engine.startPlayingStream(stream.streamID); // 新流 → 开始拉流
}
} else if (updateType == ZegoUpdateType.DELETE) {
for (ZegoStream stream : streamList) {
engine.stopPlayingStream(stream.streamID); // 流消失 → 停止拉流
}
}
});
}
6. 运行说明
启动 Token 服务端
cd examples/server
npm install
npm start
# 服务运行在 http://localhost:3000
# 健康检查:curl http://localhost:3000/api/health
构建并安装 Android 客户端
cd examples/android
./gradlew assembleDebug
adb install app/build/outputs/apk/debug/app-debug.apk
测试流程
- 设备 A(房主):输入 User ID 和 User Name → 点击 Login → 点击 Create Room
- 设备 B(成员):输入不同的 User ID → 点击 Login → 输入相同的 Room ID → 点击 Join Room
- 设备 B 点击 Take Seat 加入麦位
- 双方即可进行实时语音通话
模拟器测试时,服务端地址使用
http://10.0.2.2:3000(模拟器访问宿主机的特殊地址)。
运行后效果示例图:

7. 总结与扩展
项目核心要点
| 要点 | 实现方式 |
|---|---|
| 实时语音 | ZEGO Express Audio SDK 的推拉流机制 |
| 信令同步 | ZIM SDK 的房间属性(Room Attributes) |
| 麦位管理 | seat_0 ~ seat_7 的 JSON 属性存储 |
| 鉴权安全 | Node.js 服务端生成 Token04 |
| UI 布局 | GridLayout 实现 2×4 麦位网格 |
ZIM SDK API 速查
| API | 用途 |
|---|---|
ZIM.create(appID, application) | 创建 ZIM 实例 |
zim.login(userID, config) | 使用 Token 登录 |
zim.createRoom(roomInfo) | 创建房间 |
zim.joinRoom(roomID) | 加入房间 |
zim.leaveRoom(roomID) | 离开房间 |
zim.setRoomAttributes(attrs, roomID, config) | 设置麦位属性 |
zim.deleteRoomAttributes(keys, roomID, config) | 清除麦位 |
zim.queryRoomAllAttributes(roomID) | 查询所有麦位 |
zim.beginRoomAttributesBatchOperation(roomID, config) | 开始批量操作 |
zim.endRoomAttributesBatchOperation(roomID) | 结束批量操作 |
Express Audio SDK API 速查
| API | 用途 |
|---|---|
ZegoExpressEngine.createEngine(profile) | 创建 RTC 引擎 |
engine.loginRoom(roomID, user, config) | 登录 RTC 房间 |
engine.logoutRoom(roomID) | 登出 RTC 房间 |
engine.startPublishingStream(streamID) | 发布音频流 |
engine.stopPublishingStream() | 停止发布 |
engine.startPlayingStream(streamID) | 播放远端流 |
engine.stopPlayingStream(streamID) | 停止播放 |
engine.muteMicrophone(mute) | 静音/取消静音麦克风 |
扩展方向
- 麦位申请/审批机制:使用 ZIM 自定义信令实现举手排队
- 背景音乐混音:利用 Express SDK 的混音 API
- 音效/变声:利用 Express SDK 的音频处理能力
- 聊天消息:利用 ZIM 的房间消息功能
- 房间列表:利用 ZIM 的房间查询 API




