从零搭建 Android 语音聊天室:基于 ZEGO SDK 的 8 麦位实时语音方案

本文是面向 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<String, String> 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<String> 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<String> 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<String> 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<ZegoStream> 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<String, String> 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<String, String> 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<String> 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 房间属性实现实时多端同步:

  1. 写入方:调用 setRoomAttributes() 或 deleteRoomAttributes() 修改麦位
  2. 接收方:通过 onRoomAttributesUpdated() 回调获取变更
  3. 新加入者:调用 queryRoomAllAttributes() 获取完整快照
// RoomActivity.java - 麦位属性变更监听
ZIM.getInstance().setEventHandler(new ZIMEventHandler() {
    @Override
    public void onRoomAttributesUpdated(ZIM zim, ZIMRoomAttributesUpdateInfo info, String roomID) {
        runOnUiThread(() -> {
            HashMap<String, String> roomAttributes = info.roomAttributes;
            for (Map.Entry<String, String> 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<ZegoStream> 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

测试流程

  1. 设备 A(房主):输入 User ID 和 User Name → 点击 Login → 点击 Create Room
  2. 设备 B(成员):输入不同的 User ID → 点击 Login → 输入相同的 Room ID → 点击 Join Room
  3. 设备 B 点击 Take Seat 加入麦位
  4. 双方即可进行实时语音通话

模拟器测试时,服务端地址使用 http://10.0.2.2:3000(模拟器访问宿主机的特殊地址)。

运行后效果示例图:

posted @ 2026-08-20 15:49  RTC之旅  阅读(13)  评论(0)    收藏  举报