实战:一天开发一款内置游戏直播的国产版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:继承
目录 继承的基本概念 继承方式 基类和派生类对象赋值转换/切片 继承中的作用域 派生类的四个成员函数: 构造函数 拷贝构造函数 赋值重载 析构函数 静态成员 继承与友元 多继承 菱形继承 多继承的指针偏移问题 组合 继承的基本概念 继承出现的契机是某一…...
【蓝桥杯试题】递归实现排列型枚举
💃🏼 本人简介:男 👶🏼 年龄:18 🤞 作者:那就叫我亮亮叭 📕 专栏:蓝桥杯试题 文章目录1. 题目描述2. 代码展示法一:dfs法二:next_perm…...
入职字节测试岗外包一个月,我离职了...
有一种打工人的羡慕,叫做“大厂”。真是年少不知大厂香,错把青春插稻秧。但是,在深圳有一群比大厂员工更庞大的群体,他们顶着大厂的“名”,做着大厂的工作,还可以享受大厂的伙食,却没有大厂的“…...
weak学习入门-01
作用:集中在特征提取、算法选择和参数调优上 本篇几乎是汇总了大佬的参考 官网https://www.cs.waikato.ac.nz/ml/weka 大佬的入门教程:初试weka数据挖掘 - 加拿大小哥哥 - 博客园 (cnblogs.com) 参考书:数据挖掘实用机器学习技术(原书第2版)...
线程池中shutdown()和shutdownNow()方法的区别
线程池中shutdown()和shutdownNow()方法的区别 一般情况下,当我们频繁的使用线程的时候,为了节约资源快速响应需求,我们都会考虑使用线程池,线程池使用完毕都会想着关闭,关闭的时候一般情况下会用到shutdown和shutdow…...
高可用/性能
文章目录1.数据库系统架构发展(1)单库架构(2)主备架构(3)主从架构2.主从复制主从同步配置主从复制模式(1)异步复制(2)半同步复制(3)全…...
PriorityQueues优先队列
优先队列优先队列(priority queue)是计算机科学中的一类抽象数据类型。优先队列中的每个元素都有各自的优先级,优先级最高的元素最先得到服务;优先级相同的元素按照其在优先队列中的顺序得到服务。优先队列通常使用“堆”…...
arm 堆栈
先转一篇 stm32 堆和栈(stm32 Heap & Stack)【worldsing笔记】_stm32堆栈_slj_win的博客-CSDN博客 关于堆和栈已经是程序员的一个月经话题,大部分有是基于os层来聊的。 那么,在赤裸裸的单片机下的堆和栈是什么样的分布呢?以下是网摘&…...
leetcode-面试题 05.02. Binary Number to String LCCI
Description Given a real number between 0 and 1 (e.g., 0.72) that is passed in as a double, print the binary representation. If the number cannot be represented accurately in binary with at most 32 characters, print “ERROR”. Example1: Input: 0.625Outpu…...
C语言函数阐述
C 函数 函数是一组一起执行一个任务的语句。每个 C 程序都至少有一个函数,即主函数 main() ,所有简单的程序都可以定义其他额外的函数。 您可以把代码划分到不同的函数中。如何划分代码到不同的函数中是由您来决定的,但在逻辑上,…...
二叉树——把二叉搜索树转换为累加树
538. 把二叉搜索树转换为累加树 链接 给出二叉 搜索 树的根节点,该树的节点值各不相同,请你将其转换为累加树(Greater Sum Tree),使每个节点 node 的新值等于原树中大于或等于 node.val 的值之和。 提醒一下…...
温州优化网站方法/app优化网站
在练习循环删除list中元素时遇到了一点问题。最开始写的代码是 for i in range(len(list)):del list[i] 这样写到后来会报错,原因是随着列表元素的删除和i的增加,对列表元素的访问会越界。 后来改成了如下代码 while i < len(list):del list1[i] 结果…...
php网站开发更换模板/如何快速收录一个网站的信息
1.失败的BI项目 对于大多数信息化项目来说,BI项目和知识管理项目是难度最大的。暂且放下知识管理不说,首先,我们先把使用BI的角色确认下来。 大家都知道,BI是在1996年提出来的,现在大部分人都认为BI就是一个辅助决策…...
网站反向代理怎么做/百度公司怎么样
5 种使用 Python 代码轻松实现数据可视化的方法#故事数据可视化PYTHON数据可视化是数据科学家工作中的重要组成部分。在项目的早期阶段,你通常会进行探索性数据分析(Exploratory Data Analysis,EDA)以获取对数据的一些理解。创建可…...
3800给做网站/市场营销七大策略
解决方法:每一位取出来相加等于123456789,每一位相乘等于1*2*3*4*5*6*7*8*9转载于:https://www.cnblogs.com/cstdio1/p/11074022.html...
企业网站网站设计/推荐就业的培训机构
编写一下Android界面的项目使用默认的Android清单文件<?xml version"1.0" encoding"utf-8"?> <manifest xmlns:android"http://schemas.android.com/apk/res/android" package"com.itheima28.writedata" android:versionCo…...
线上营销渠道/seo优化点击软件
谈起面向对象,对于大部分程序员来说都是耳熟能详的玩意,这个面向对象编程说白了无非就是类和对象,方法和 成员变量,封装等等。Python作为一门面向对象的语言,肯定对于这些的支持是没问题,下面我们来说一下…...