实战:一天开发一款内置游戏直播的国产版Discord应用【附源码】
游戏直播是Discord产品的核心功能之一,本教程教大家如何1天内开发一款内置游戏直播的国产版Discord应用,用户不仅可以通过IM聊天,也可以进行语聊,看游戏直播,甚至自己进行游戏直播,无任何实时音视频底层技术的Web开发者同样适用,效果如下:
开整!
Step1 初始化
本项目基于环信超级社区的实例项目, 所以我们先从Circle-Demo-Web
这个仓库开启做初始化
- 克隆项目
git clone https://github.com/easemob/Circle-Demo-Web.git
- 安装依赖
npm install
- 设置appKey
src/utils/WebIM.js
中设置appKey,AppKey为环信后台项目对应的key,注册环信,https://console.easemob.com/user/register,登录console后台获取Appkey- appKey为环信后台项目对应的key, 如何开通可见开通配置环信即时通讯 IM 服务
- 运行项目
npm run start
运行后, 登录完毕效果如下,
与discord设计逻辑相似, 左边功能区有
- 个人信息页
- 好友会话页
- 当前加入的频道
- 创建新频道
- 加入服务器
超级社区的逻辑为
社区(Server)、频道(Channel) 和子区(Thread) 三层结构
社区为一个独立的结构, 不同社区直接相互隔离, 社区包含不同的频道, 代表了不同的话题, 用户在频道中聊天, 而针对一条单独信息产生的回复为子区.
我们本次的项目主要集中在频道部分, 需要加入一个服务器后, 创建一个测试社区, 保证你具有管理员权限.
Step2 协议设置
我们的目标是尽量利用现有api扩展功能, 有几个问题需要解决
- 如何区分普通频道和游戏频道?
- 如何区分当前频道是否有玩家直播, 如果有直播如何获取玩家信息, 第二玩家的状态?
- 多人聊天的状态?
如何区分普通频道和游戏频道
这里直接简单采用频道前缀做特殊区分, 创建频道前缀带video-
的识别为游戏频道, 同时将渲染内容做替换
// views/Channel/index.jsconst isVideoChannel = useMemo(() => {return currentChannelInfo?.name?.startsWith("video-");
}, [currentChannelInfo]);const renderTextChannel = () => {
// 原来的渲染逻辑
return (<><MessageListmessageInfo={messageInfo}channelId={channelId}handleOperation={handleOperation}className={s.messageWrap}/><div className={s.iptWrap}><Input chatType={CHAT_TYPE.groupChat} fromId={channelId} /></div></>
);
}const renderStreamChannel = () => {
// 先填充一个占位符
return (<>This is a Stream Channel<>
);
}return (...<div className={s.contentWrap}>{isVideoChannel ? renderStreamChannel() : renderTextChannel()}</div>...
);
如果需要区分图标, 可以搜索channelNameWrap
, 分别在channelItem
和Channel/components/Header
中添加一个css类, 通过这个类设置图标图片
如何区分当前频道是否有玩家直播, 如果有直播如何获取玩家信息, 第二玩家的状态?
我们可以复用在频道中发送消息的机制, 直播开始, 结束都可以当做一条特殊的消息发送, 只不过这条消息不承载用户的信息, 而是表达用户上下播的行为
当然这个机制存在一定实时性的问题, 不过大致是可行的.
首先我们来看一条普通的消息是如何发送的
// components/input//发消息const sendMessage = useCallback(() => {if (!text) return;getTarget().then((target) => {let msg = createMsg({chatType,type: "txt",to: target,msg: convertToMessage(ref.current.innerHTML),isChatThread: props.isThread});setText("");deliverMsg(msg).then(() => {if (msg.isChatThread) {setThreadMessage({message: { ...msg, from: WebIM.conn.user },fromId: target});} else {insertChatMessage({chatType,fromId: target,messageInfo: {list: [{ ...msg, from: WebIM.conn.user }]}});scrollBottom();}});});}, [text, props, getTarget, chatType, setThreadMessage, insertChatMessage]);
去除掉与输入框逻辑耦合的部分, 可以分为两步, createMsg
创建消息, deliverMsg
发送消息, 这两个功能都是环信SDK功能的封装, 经过查阅文档, 它支持发送自定义消息.
在utils中新建一个stream.js
文件来封装直播的逻辑
// utils/stream.js
const sendStreamMessage = (content, channelId) => {let msg = createMsg({chatType: CHAT_TYPE.groupChat,type: "custom",to: channelId,ext: {type: "stream",...content,},});return deliverMsg(msg).then(() => {console.log("发送成功");}).catch(console.error);
};
它接收content表示我们的额外信息, 用户名和上下播状态, channelId区分不同的channel, 对它的调用可以如下
// 定义在 utils/stream.js 中
const CMD_START_STREAM = "start";
const CMD_END_STREAM = "end";// 上播
sendStreamMessage({user: userInfo?.username,status: CMD_START_STREAM,},channelId
);
// 下播
sendStreamMessage({user: userInfo?.username,status: CMD_END_STREAM,},channelId
);
第二玩家的状态可以类比第一个玩家用额外的自定义消息实现, 这里不做重复.
关于自定义消息, 原本它的作用是邀请用户加入频道, 你可以在components/CustomMsg
中找到, 我们要额外识别一下直播消息(可以渲染在消息列表里, 也可以直接屏蔽掉).
// components/CustomMsg/index.js
const isStream = message?.ext?.type === "stream";// 屏蔽
const renderStream = () => {
return (<>)
}
if (isStream) {return renderStream();
} else {...
}
多人聊天的状态?
我们引入声网RTC sdk, 每个进入直播房间的用户都对应维护一个声网客户端,
通过on事件感知远端视频/音频流.
根据文档 进行如下操作,
- 注册声网开发者, 并在后台创建一个测试项目
- 项目根目录创建
.env
文件, 存放api token等信息
# channel, uid 暂时设置为固定
REACT_APP_AGORA_APPID = your app id
REACT_APP_AGORA_CHANNEL = test
REACT_APP_AGORA_TOKEN = your token
REACT_APP_AGORA_UID = 123xxx
- 添加声网sdk依赖
npm install agora-rtc-sdk-ng
我们在下一章中编写接入逻辑
声网RTC接入, 直播与语音实现
接入
在views/Channel/components
文件夹下新增一个组件StreamHandler
, 该组件为后续我们处理游戏房间的组件, 先初步编写声网接入逻辑
// views/Channel/components/StreamHandler/index.jsconst options = {appId:process.env.REACT_APP_AGORA_APPID || "default id",channel: process.env.REACT_APP_AGORA_CHANNEL || "test",token:process.env.REACT_APP_AGORA_TOKEN ||"default token",uid: process.env.REACT_APP_AGORA_UID || "default uid",
};const StreamHandler = (props) => {// 组件参数: 用户信息, 当前频道所有消息, 当前频道id, 是否开启本地语音const { userInfo, messageInfo, channelId, enableLocalVoice = false } = props;const [rtcClient, setRtcClient] = useState(null);// 声网client连接完成const [connectStatus, setConnectStatus] = useState(false);// RTC相关逻辑useEffect(() => {AgoraRTC.setLogLevel(3);const client = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" });// TODO: use right channelclient.join(options.appId, options.channel, options.token, userInfo?.username).then(() => {setConnectStatus(true);console.log("[Stream] join channel success");}).catch((e) => {console.log(e);});setRtcClient(client);return () => {// 销毁时, 自动退出RTC频道client.leave();setRtcClient(null);};}, []);return (<>{!connectStatus && <Spin tip="Loading" size="large" />}</>);
}// 我们需要全局状态中的userinfo, 映射一下到当前组件的props中
const mapStateToProps = ({ app }) => {return {userInfo: app.userInfo,};
};
export default memo(connect(mapStateToProps)(StreamHandler));
然后回到Channel
中, 在之前的renderStreamChannel
函数中添加上StreamHandler
组件
// view/Channel/index.js
const [enableVoice, setEnableVoice] = useState(false);
const toggleVoice = () => {setEnableVoice((enable) => {return !enable;});
}// 保留了输入窗口, 可以在它的菜单栏中添加游戏频道独有的一些逻辑,
// 这里我加入了开关本地语音的逻辑, 拓展Input的细节可以参照完整版代码
const renderStreamChannel = () => {return (<><div className={s.messageRowWrap}><StreamHandler messageInfo={messageInfo} channelId={channelId} enableLocalVoice={enableVoice} /></div><div className={s.iptWrap}><Input chatType={CHAT_TYPE.groupChat} fromId={channelId} extraMenuItems={renderStreamMenu()} /></div></>);}const renderStreamMenu = () => {return [{key: "voice",label: (<divclassName="circleDropItem"onClick={toggleVoice}><Iconname="person_wave_slash"size="24px"iconClass="circleDropMenuIcon"/><span className="circleDropMenuOp">{enableVoice ? "关闭语音" : "开启语音"}</span></div>),}];}
此时我们创建一个video-
开题的游戏频道, 应该可以看到命令行中输出了RTC连接成功信息. [Stream] join channel success
音视频推流
接下来我们继续做实质的RTC推流逻辑, 及用户上下播的入口. 但在那之前, 先简单过一下声网RTC中的一些概念.
参考以下步骤实现音视频通话的逻辑:
- 调用 createClient 方法创建
AgoraRTCClient
对象。- 调用 join 方法加入一个 RTC 频道,你需要在该方法中传入 App ID 、用户 ID、Token、频道名称。
- 先调用 createMicrophoneAudioTrack 通过麦克风采集的音频创建本地音频轨道对象,调用 createCameraVideoTrack 通过摄像头采集的视频创建本地视频轨道对象;然后调用 publish 方法,将这些本地音视频轨道对象当作参数即可将音视频发布到频道中。
- 当一个远端用户加入频道并发布音视频轨道时:
- 监听 client.on(“user-published”) 事件。当 SDK 触发该事件时,在这个事件回调函数的参数中你可以获取远端用户
AgoraRTCRemoteUser
对象 。- 调用 subscribe 方法订阅远端用户
AgoraRTCRemoteUser
对象,获取远端用户的远端音频轨道RemoteAudioTrack
和远端视频轨道RemoteVideoTrack
对象。
(以上内容来自声网官方文档)
在上面的接入中, 我们已经完成了创建对象并加入频道两步.
在RTC中, 可以传输音频和视频信号, 由于单个RTC客户端要传输不同种类的数据, 每个单独的音视频源被分成不同的track
(由于它们都是实时不断产生的, 我们称作流), 随后通过publish
方法, 将我们本地的信号源交付给RTC客户端传输.
随后通过user-published
事件的回调来在其他用户发布信号源时进行处理, 首先需要subscribe
该用户来获取后续数据, 随后根据不同类型的信号流做处理.
离开时需要关闭本地当前的信号源, 并退出RTC客户端.
最后通过user-unpublished
事件监听其他用户退出, 移除它们对应的信号流.
逻辑理清楚后代码就很容易看懂了
// views/Channel/components/StreamHandler/index.js
const StreamHandler = (props) => {...// 本地视频元素const localVideoEle = useRef(null);// 远程视频元素const canvasEle = useRef(null);const [rtcClient, setRtcClient] = useState(null);const [connectStatus, setConnectStatus] = useState(false);// 当前直播的用户const [remoteUser, setRemoteUser] = useState(null);// 远程音视频trackconst [remoteVoices, setRemoteVoices] = useState([]);const [remoteVideo, setRemoteVideo] = useState(null);// RTC相关逻辑useEffect(() => {...// client.join 后// 监听新用户加入client.on("user-published", async (user, mediaType) => {// auto subscribe when users comingawait client.subscribe(user, mediaType);console.log("[Stream] subscribe success on user ", user);if (mediaType === "video") {// 获取直播流if (remoteUser && remoteUser.uid !== user.uid) {// 只能有一个用户推视频流console.error("already in a call, can not subscribe another user ",user);return;}// 播放并记录下视频流const remoteVideoTrack = user.videoTrack;remoteVideoTrack.play(localVideoEle.current);setRemoteVideo(remoteVideoTrack);// can only have one remote video usersetRemoteUser(user);}if (mediaType === "audio") {// 获取音频流const remoteAudioTrack = user.audioTrack;// 去重if (remoteVoices.findIndex((item) => item.uid === user.uid) == -1) {remoteAudioTrack.play();// 添加到数组中setRemoteVoices([...remoteVoices,{ audio: remoteAudioTrack, uid: user.uid },]);}}});client.on("user-unpublished", (user) => {// 用户离开, 去除流信息console.log("[Stream] user-unpublished", user);removeUserStream(user);});setRtcClient(client);return () => {client.leave();setRtcClient(null);};}, []);const removeUserStream = (user) => {if (remoteUser && remoteUser.uid === user.uid) {setRemoteUser(null);setRemoteVideo(null);}setRemoteVoices(remoteVoices.filter((voice) => voice.uid !== user.uid));};
}
接着我们根据之前提到的自定义消息判断当前在播状态, 以最后一条自定义消息为准.
// views/Channel/components/StreamHandler/index.js
const StreamHandler = (props) => {const { userInfo, messageInfo, channelId, enableLocalVoice = false } = props;// 第一条 stream 消息, 用于判断直播状态const firstStreamMessage = useMemo(() => {return messageInfo?.list?.find((item) => item.type === "custom" && item?.ext?.type === "stream");}, [messageInfo]);// 是否有直播const hasRemoteStream =firstStreamMessage?.ext?.status === CMD_START_STREAM &&firstStreamMessage?.ext?.user !== userInfo?.username;// 本地直播状态const [localStreaming, setLocalStreaming] = useState(firstStreamMessage?.ext?.status === CMD_START_STREAM &&firstStreamMessage?.ext?.user === userInfo?.username);// 本地直播流状态const toggleLocalGameStream = () => {if (hasRemoteStream) {return;}setLocalStreaming(!localStreaming);};// 根据直播状态选择渲染return (<>{!connectStatus && <Spin tip="Loading" size="large" />}{hasRemoteStream ? (<RemoteStreamHandlerremoteUser={firstStreamMessage?.ext?.user}localVideoRef={localVideoEle}channelId={channelId}userInfo={userInfo}rtcClient={rtcClient}/>) : (<LocalStreamHandlerlocalStreaming={localStreaming}canvasRef={canvasEle}toggleLocalGameStream={toggleLocalGameStream}rtcClient={rtcClient}userInfo={userInfo}channelId={channelId}/>)}</>);
}
我们根据hasRemoteStream
分成两种逻辑RemoteStreamHandler
和LocalStreamHandler
(可以先用div+文字的空实现占位), 首先我们来看本地游戏的逻辑
// view/Channel/components/StreamHandler/local_stream.js
const LocalStreamHandler = (props) => {const {toggleLocalGameStream,canvasRef,localStreaming,rtcClient,userInfo,channelId,} = props;const [localVideoStream, setLocalVideoStream] = useState(false);const localPlayerContainerRef = useRef(null);// 开启本地视频流useEffect(() => {if (!localPlayerContainerRef.current) return;const f = async () => {// 暂时使用视频代替游戏流let lgs = await AgoraRTC.createCameraVideoTrack();lgs.play(localPlayerContainerRef.current);setLocalGameStream(lgs);}f();}, [localPlayerContainerRef])const renderLocalStream = () => {return (<div style={{ height: "100%" }} ref={localPlayerContainerRef}></div>)}// 控制上下播const renderFloatButtons = () => {return (<FloatButton.Groupicon={<DesktopOutlined />}trigger="click"style={{ left: "380px" }}><FloatButtononClick={toggleLocalGameStream}icon={localStreaming ? <VideoCameraFilled /> : <VideoCameraOutlined />}tooltip={<div>{localStreaming ? "停止直播" : "开始直播"}</div>}/></FloatButton.Group>);};// 渲染: 悬浮窗和本地流return (<><div style={{ height: "100%" }}>{renderFloatButtons()}{renderLocalStream()}</div></>);
}
现在我们进入直播房间已经可以看到本地摄像头的内容了, 但我们还没有将视频流投放到RTC中, 且上播逻辑也没有处理
// view/Channel/components/StreamHandler/local_stream.jsuseEffect(() => {// 发布直播推流if (!localStreaming || !rtcClient || !localVideoStream) {return;}console.log("height", canvasRef.current.height);console.log("publishing local stream", localVideoStream);// 将流publish到rtc中rtcClient.publish(localVideoStream).then(() => {// 频道中发布一条消息, 表示开始直播sendStreamMessage({user: userInfo?.username,status: CMD_START_STREAM,},channelId).then(() => {message.success({content: "start streaming",});});});return () => {// 用户退出的清理工作, // unpublish流(远程), 停止播放流(本地), 发送直播关闭消息(频道)if (localVideoStream) {rtcClient.unpublish(localVideoStream);localVideoStream.stop();sendStreamMessage({user: userInfo?.username,status: CMD_END_STREAM,},channelId);message.info({content: "stop streaming",});}};}, [rtcClient, localStreaming, canvasRef, userInfo, channelId, localVideoStream]);
为了测试直播效果, 我们需要登录第二个账号(使用浏览器的匿名/开其他的浏览器, 此时cookie没有共享, 可以多账号登录), 进入相同频道, 开启直播, 此时第一个账号应该会自动刷新状态(如果没有则手动切换一下频道), 进入到RemoteStreamHandler
, 说明我们直播的逻辑已经完成.
本地语音的逻辑也是类似的, 这里就不再重复.
接下来是远程流的渲染逻辑, 它的逻辑相对简单, 观看者可以选择开始/停止观看直播流
// view/Channel/components/StreamHandler/remote_stream.js
const RemoteStreamHandler = (props) => {const {remoteUser,localVideoRef,toggleRemoteVideo,channelId,userInfo,rtcClient,} = props;// 这里加一个强制t人的开关, 由于debugconst enableForceStop = true;const forceStopStream = () => {sendStreamMessage({user: userInfo?.username,status: CMD_END_STREAM,},channelId);};const renderRemoteStream = () => {return (<div style={{ height: "100%" }}><divid="remote-player"style={{width: "100%",height: "90%",border: "1px solid #fff",}}ref={localVideoRef}/><divstyle={{display: "flex",justifyContent: "center",marginTop: "10px",}}><span style={{ color: "#0ECD0A" }}>{remoteUser}</span> is playing{" "}</div></div>);};const renderFloatButtons = () => {return (<FloatButton.Groupicon={<DesktopOutlined />}trigger="click"style={{ left: "380px" }}><FloatButtononClick={toggleRemoteVideo}icon={<VideoCameraAddOutlined />}tooltip={<div>观看/停止观看直播</div>}/>{enableForceStop && (<FloatButtononClick={forceStopStream}icon={<VideoCameraAddOutlined />}tooltip={<div>强制停止直播</div>}/>)}</FloatButton.Group>);};return (<><div style={{ height: "100%" }}>{renderFloatButtons()}{renderRemoteStream()}</div></>);
}
开关远程流的代码在StreamHander
中, 作为参数传给RemoteStream
// views/Channel/components/StreamHandler/index.jsconst toggleRemoteVideo = () => {if (!hasRemoteStream) {return;}console.log("[Stream] set remote video to ", !enableRemoteVideo);// 当前是关闭状态,需要打开// 开关远程音频的逻辑也与此类型.if (enableRemoteVideo) {remoteVideo?.stop();} else {remoteVideo?.play(localVideoEle.current);}setEnableRemoteVideo(!enableRemoteVideo);};
ok, 现在我们已经实现了基于声网RTC, 在环信超级社区集成视频直播的功能.
直播替换为游戏流
接下来我们来将直播流升级一下, 替换成模拟器包, 为了方便测试, 我们直接使用打包好的版本(https://github.com/a71698422/web-0.1.1 ), pkg包解压后直接放置到项目根目录,
RustNESEmulator 是一个基于Rust语言的NES模拟器, 我们在web平台可以使用它编译好的wasm版本
并将mario.nes
文件放到src/assets
目录下, 这是初代马里奥游戏的ROM文件(你也可以使用你喜欢的nes游戏, 如果遇到问题, 欢迎到RustNESEmulator中提issue)
加入前端的模拟器适配代码
// views/Channel/components/StreamHandler
// from tetanes.import * as wasm from "@/pkg";
class State {constructor() {this.sample_rate = 44100;this.buffer_size = 1024;this.nes = null;this.animation_id = null;this.empty_buffers = [];this.audio_ctx = null;this.gain_node = null;this.next_start_time = 0;this.last_tick = 0;this.mute = false;this.setup_audio();console.log("[NES]: create state");}load_rom(rom) {this.nes = wasm.WebNes.new(rom, "canvas", this.sample_rate);this.run();}toggleMute() {this.mute = !this.mute;}setup_audio() {const AudioContext = window.AudioContext || window.webkitAudioContext;if (!AudioContext) {console.error("Browser does not support audio");return;}this.audio_ctx = new AudioContext();this.gain_node = this.audio_ctx.createGain();this.gain_node.gain.setValueAtTime(1, 0);}run() {const now = performance.now();this.animation_id = requestAnimationFrame(this.run.bind(this));if (now - this.last_tick > 16) {this.nes.do_frame();this.queue_audio();this.last_tick = now;}}get_audio_buffer() {if (!this.audio_ctx) {throw new Error("AudioContext not created");}if (this.empty_buffers.length) {return this.empty_buffers.pop();} else {return this.audio_ctx.createBuffer(1, this.buffer_size, this.sample_rate);}}queue_audio() {if (!this.audio_ctx || !this.gain_node) {throw new Error("Audio not set up correctly");}this.gain_node.gain.setValueAtTime(1, this.audio_ctx.currentTime);const audioBuffer = this.get_audio_buffer();this.nes.audio_callback(this.buffer_size, audioBuffer.getChannelData(0));if (this.mute) {return;}const source = this.audio_ctx.createBufferSource();source.buffer = audioBuffer;source.connect(this.gain_node).connect(this.audio_ctx.destination);source.onended = () => {this.empty_buffers.push(audioBuffer);};const latency = 0.032;const audio_ctxTime = this.audio_ctx.currentTime + latency;const start = Math.max(this.next_start_time, audio_ctxTime);source.start(start);this.next_start_time = start + this.buffer_size / this.sample_rate;}// ...
}export default State;
改造local_stream
// view/Channel/components/StreamHandler/local_stream.jsimport mario_url from "@/assets/mario.nes";
import * as wasm_emulator from "@/pkg";
import State from "./state";const LocalStreamHandler = (props) => {// 模拟器 stateconst stateRef = useRef(new State());// 注意要将原来的代码注释掉/* const [localVideoStream, setLocalVideoStream] = useState(false);const localPlayerContainerRef = useRef(null);// 开启本地视频流useEffect(() => {if (!localPlayerContainerRef.current) return;const f = async () => {// 暂时使用视频代替游戏流let lgs = await AgoraRTC.createCameraVideoTrack();lgs.play(localPlayerContainerRef.current);setLocalGameStream(lgs);}f();}, [localPlayerContainerRef])// 推流的函数也暂时注释useEffet...*/useEffect(() => {// 本地游戏if (!canvasRef) {return;}// 开启键盘监听等全局事件wasm_emulator.wasm_main();fetch(mario_url, {headers: { "Content-Type": "application/octet-stream" },}).then((response) => response.arrayBuffer()).then((data) => {let mario = new Uint8Array(data);// 加载 rom数据stateRef.current.load_rom(mario);});}, [canvasRef]);// 更新本地流渲染const renderLocalStream = () => {return (<div style={{ height: "100%" }}><canvasid="canvas"style={{ width: 600, height: 500 }}width="600"height="500"ref={canvasRef}/></div>);};
}
这一步完成后, 我们就可以在本地试玩马里奥游戏了, 键盘绑定为
A = J
B = K
Select = RShift
Start = Return
Up = W
Down = S
Left = A
Right = D
将推本地视频流改为游戏流
useEffect(() => {// 发布直播推流if (!localStreaming || !rtcClient) {return;}// 只修改了流获取部分// canvas的captureStream接口支持获取视频流// 我们用这个视频流构造一个声网的自定义视频流let stream = canvasRef.current.captureStream(30);let localVideoStream = AgoraRTC.createCustomVideoTrack({mediaStreamTrack: stream.getVideoTracks()[0],});console.log("height", canvasRef.current.height);console.log("publishing local stream", localVideoStream);rtcClient.publish(localVideoStream).then(() => {sendStreamMessage({user: userInfo?.username,status: CMD_START_STREAM,},channelId).then(() => {message.success({content: "start streaming",});});});return () => {if (localVideoStream) {rtcClient.unpublish(localVideoStream);localVideoStream.stop();sendStreamMessage({user: userInfo?.username,status: CMD_END_STREAM,},channelId);message.info({content: "stop streaming",});}};}, [rtcClient, localStreaming, canvasRef, userInfo, channelId]);
最后总结一下房间的流程图
至此该项目的完整流程就算结束啦,如果有哪些步骤细节不太明确, 可以参照完整版项目
环信超级社区项目
注册环信
模拟器直播项目github源码获取
相关文章:

实战:一天开发一款内置游戏直播的国产版Discord应用【附源码】
游戏直播是Discord产品的核心功能之一,本教程教大家如何1天内开发一款内置游戏直播的国产版Discord应用,用户不仅可以通过IM聊天,也可以进行语聊,看游戏直播,甚至自己进行游戏直播,无任何实时音视频底层技术…...

嵌入式学习笔记——基于Cortex-M的单片机介绍
基于Cortex-M的单片机介绍前言生产厂商及其产品线ARM单片机的产品线命名规则留个作业习单片机的资料准备STM32开发所需手册1.芯片的数据手册作业2前言 本文继续接着上一篇中关于Cortex-M的介绍,来记录一些关于ARM系单片机的知识。 生产厂商及其产品线 芯片厂商在…...

Python 虚拟环境的使用
PyCharm 创建的虚拟环境与使用 workon 命令创建的虚拟环境在本质上没有区别,它们都是 Python 的虚拟环境。 使用 PyCharm 创建工程时,使用可以使用曾经工程的虚拟环境,或者新建一个虚拟环境来安装 Python 的库,又或者使用 workon…...

招生咨询|浙江大学MPA项目2023年招生问答与通知
问:报考浙江大学MPA的基本流程是怎么样的? 答:第一阶段为网上报名与确认。MPA考生须参加全国管理类联考,网上报名时间一般为10月初开始、10月下旬截止,错过网上报名时间后不能补报。确认时间一般为11月上旬,…...

Qt std :: bad_alloc
文章目录摘要问题出现原因第一种 请求内存多余系统可提供内存第二种 地址空间过于分散,无法满足大块连续内存的请求第三种 堆管理数据结构损坏稍微总结下没想到还能更新参考关键字: std、 bad、 alloc、 OOM、 异常退出摘要 今天又是被BUG统治的一天&a…...
《设计模式》装饰者模式
《设计模式》装饰者模式 装饰者模式(Decorator Pattern)是一种结构型设计模式,它允许在不改变现有对象结构的情况下,动态地添加行为或责任到对象上。在装饰者模式中,有一个抽象组件(Component)…...

一文说清Kubernetes的本质
文章目录Kubernetes解决了什么问题?Kubernetes的全局架构Kubernetes的设计思想Kubernetes的核心功能Kubernetes如何启动一个容器化任务?Kubernetes解决了什么问题? 编排?调度?容器云?还是集群管理…...
信息发布小程序【源码好优多】
简介 信息发布小程序,实现数据与小程序数据同步共享,通过简单的配置就能搭建自己的小程序。,基于微信小程序开发的小程序。 这个框架比较简单就是用微信原生开发技术进行实现的,可以用于信息展示等相关信息。其中目前APP比较多&am…...
创新型中小企业申报流程
据工业和信息化部《优质中小企业梯度培育管理暂行办法》(工信部企业〔2022〕63号)和省《优质中小企业梯度培育管理实施细则》(鲁工信发〔2022〕8号,以下简称《细则》),现就做好2022年山东省创新型中小企业评…...

【UE4 Cesium】加载离线地图
主体思路:先使用水经注软件下载瓦片数据,再使用Python转换瓦片数据格式(TMS),使用Nginx发布网络服务,最后将网络服务加载到UE中。步骤:使用水经注下载瓦片数据,这里下载的是全球七级…...

Spring面试题
目录 Spring、Springmvc、Springboot的区别是什么 SpringMVC工作流程是什么 SpringMVC的九大组件有哪些 Spring的核心是什么 spring的事务传播机制是什么 Spring框架中的单例Bean是线程安全的么 spring框架中使用了哪些设计模式及应用场景 spring事务的隔离级别有哪些?…...

动态网站开发讲课笔记03:HTTP协议
文章目录零、本节学习目标一、HTTP概述(一)HTTP的概念1、HTTP的概念2、HTTP协议的特点(1)C/S模式(2)简单快速(3)灵活(4)无状态(二)HTT…...

2023年天津财经大学珠江学院专升本专业课考试题型
天津财经大学珠江学院关于2023年高职升本科专业课考试时间及题型一、专业课考试 (一)时间安排 2023年天津财经大学珠江学院高职升本科专业课考试定于2023年3月25日14:00-17:00进行,凡报考工商管理、旅游管理、税收学专业的考生&am…...

五方面提高销售流程管理的CRM系统
销售充满了不确定性,面对不同的客户,销售人员需要采用不同的销售策略。也正因为这种不确定性,规范的销售流程对企业尤为重要,它会让销售工作更加有效,快速地实现成交。下面小编给您推荐个不错的CRM销售流程管理系统。 …...

AutoCAD通过handle id选择实体
获得实体的handle id。注意是handle id 不是id,方法有2种:方法(a):通过ArxDeg插件(ObjectARX附带的源码编译得到:\samples\database\ARXDBG)查找:此handle id本来就是16进…...

页面状态码的含义
使用互联网产品或服务的过程中,会遇到网页报错的情况, 比如404、505等,具体这些数字有什么含义呢?本文基本涵盖了99%的报错情况,可供大家查询使用。 状态码的定义 状态码一般是由3位数字和原因短语组成的(…...
Redis 越来越慢?常见延迟问题定位与分析
Redis作为内存数据库,拥有非常高的性能,单个实例的QPS能够达到10W左右。但我们在使用Redis时,经常时不时会出现访问延迟很大的情况,如果你不知道Redis的内部实现原理,在排查问题时就会一头雾水。很多时候,R…...

【python】python-socketio+firecamp使用踩坑指南
server.py: import eventlet import asyncioeventlet.monkey_patch()import socketio import eventlet.wsgisio socketio.Server(async_modeeventlet, cors_allowed_origins*) # 指明在evenlet模式下sio.event def connect(sid, environ):print(f"connect, sid{sid}, e…...
【OJ比赛日历】快周末了,不来一场比赛吗? #03.04-03.10 #12场
CompHub 实时聚合多平台的数据类(Kaggle、天池…)和OJ类(Leetcode、牛客…)比赛。本账号同时会推送最新的比赛消息,欢迎关注!更多比赛信息见 CompHub主页 或 点击文末阅读原文以下信息仅供参考,以比赛官网为准目录2023-03-04&…...

C++11:继承
目录 继承的基本概念 继承方式 基类和派生类对象赋值转换/切片 继承中的作用域 派生类的四个成员函数: 构造函数 拷贝构造函数 赋值重载 析构函数 静态成员 继承与友元 多继承 菱形继承 多继承的指针偏移问题 组合 继承的基本概念 继承出现的契机是某一…...

docker详细操作--未完待续
docker介绍 docker官网: Docker:加速容器应用程序开发 harbor官网:Harbor - Harbor 中文 使用docker加速器: Docker镜像极速下载服务 - 毫秒镜像 是什么 Docker 是一种开源的容器化平台,用于将应用程序及其依赖项(如库、运行时环…...
解锁数据库简洁之道:FastAPI与SQLModel实战指南
在构建现代Web应用程序时,与数据库的交互无疑是核心环节。虽然传统的数据库操作方式(如直接编写SQL语句与psycopg2交互)赋予了我们精细的控制权,但在面对日益复杂的业务逻辑和快速迭代的需求时,这种方式的开发效率和可…...
测试markdown--肇兴
day1: 1、去程:7:04 --11:32高铁 高铁右转上售票大厅2楼,穿过候车厅下一楼,上大巴车 ¥10/人 **2、到达:**12点多到达寨子,买门票,美团/抖音:¥78人 3、中饭&a…...
【决胜公务员考试】求职OMG——见面课测验1
2025最新版!!!6.8截至答题,大家注意呀! 博主码字不易点个关注吧,祝期末顺利~~ 1.单选题(2分) 下列说法错误的是:( B ) A.选调生属于公务员系统 B.公务员属于事业编 C.选调生有基层锻炼的要求 D…...
鸿蒙中用HarmonyOS SDK应用服务 HarmonyOS5开发一个生活电费的缴纳和查询小程序
一、项目初始化与配置 1. 创建项目 ohpm init harmony/utility-payment-app 2. 配置权限 // module.json5 {"requestPermissions": [{"name": "ohos.permission.INTERNET"},{"name": "ohos.permission.GET_NETWORK_INFO"…...
Rust 异步编程
Rust 异步编程 引言 Rust 是一种系统编程语言,以其高性能、安全性以及零成本抽象而著称。在多核处理器成为主流的今天,异步编程成为了一种提高应用性能、优化资源利用的有效手段。本文将深入探讨 Rust 异步编程的核心概念、常用库以及最佳实践。 异步编程基础 什么是异步…...
Mysql8 忘记密码重置,以及问题解决
1.使用免密登录 找到配置MySQL文件,我的文件路径是/etc/mysql/my.cnf,有的人的是/etc/mysql/mysql.cnf 在里最后加入 skip-grant-tables重启MySQL服务 service mysql restartShutting down MySQL… SUCCESS! Starting MySQL… SUCCESS! 重启成功 2.登…...

MFC 抛体运动模拟:常见问题解决与界面美化
在 MFC 中开发抛体运动模拟程序时,我们常遇到 轨迹残留、无效刷新、视觉单调、物理逻辑瑕疵 等问题。本文将针对这些痛点,详细解析原因并提供解决方案,同时兼顾界面美化,让模拟效果更专业、更高效。 问题一:历史轨迹与小球残影残留 现象 小球运动后,历史位置的 “残影”…...

从“安全密码”到测试体系:Gitee Test 赋能关键领域软件质量保障
关键领域软件测试的"安全密码":Gitee Test如何破解行业痛点 在数字化浪潮席卷全球的今天,软件系统已成为国家关键领域的"神经中枢"。从国防军工到能源电力,从金融交易到交通管控,这些关乎国计民生的关键领域…...

GraphQL 实战篇:Apollo Client 配置与缓存
GraphQL 实战篇:Apollo Client 配置与缓存 上一篇:GraphQL 入门篇:基础查询语法 依旧和上一篇的笔记一样,主实操,没啥过多的细节讲解,代码具体在: https://github.com/GoldenaArcher/graphql…...