Diálogo Audio y Video Hermano Niu: ¿Es difícil desarrollar un reproductor de transmisión en vivo RTSP|RTMP? ¿Cuál es la dificultad?

Métricas de jugadores que sigo

Cuando muchos desarrolladores se comunican conmigo sobre tecnologías relacionadas con audio y video, la pregunta que a menudo me hacen es: ¿cuánto tiempo llevará desarrollar un reproductor RTMP o RTSP de nivel comercial? ¿Cómo se logra una latencia de milisegundos? ¿Por qué un jugador es tan complicado? Con estas preguntas en mente, combinadas con los módulos de reproducción RTMP y RTSP de la plataforma Windows, discutiré algunas de mis experiencias y señalaré cualquier punto inapropiado:

1.  Baja latencia: la mayoría de las reproducciones RTMP o RTSP son para escenarios de transmisión en vivo. Si el retraso es demasiado grande, afectará seriamente la experiencia. Por lo tanto, la baja latencia es un indicador muy importante para medir un buen reproductor de transmisión en vivo. Muchas personas están interesado en la transmisión en vivo RTMP. La impresión todavía está estancada en un retraso de 3 a 5 segundos. De hecho, a juzgar por la reproducción RTMP desarrollada en 2015, el retraso es de solo unos pocos cientos de milisegundos. En algunas escenas fuertemente interactivas, establecer un bajo -El modo de latencia puede incluso rondar los 200 ms, RTSP El retraso también es de milisegundos. Algunas escenas que deben controlarse, como robots inteligentes, drones, etc., se pueden utilizar para satisfacer las demandas de la escena después del uso real;

2. Procesamiento de sincronización de audio y video: para lograr una baja latencia, la mayoría de los reproductores ni siquiera sincronizan audio y video, obtienen el audio y el video y lo reproducen directamente, lo que provoca que el audio y el video no estén sincronizados y varios problemas, como la aleatoriedad. salto de marcas de tiempo. Por lo tanto, un buen reproductor de transmisión en vivo necesita tener sincronización de marcas de tiempo y mecanismos de corrección de marcas de tiempo anormales. Por supuesto, si está en modo de latencia ultrabaja, puede tener 0 buffer y ninguna sincronización de audio y video:

3. Admite múltiples instancias: la reproducción RTMP y RTSP de múltiples instancias es un indicador importante para medir un reproductor de transmisión en vivo, como la reproducción de alta resolución 4-8-16;

4. Admite configuración de tiempo de búfer: en algunos escenarios con fluctuación de red, el reproductor necesita admitir configuración de tiempo de búfer;

5. Ajuste de volumen en tiempo real: por ejemplo, al reproducir transmisiones RTMP o RTSP en varias ventanas, si se reproduce cada audio, la experiencia será muy mala, por lo que la función de silencio en tiempo real es muy necesaria;

6. Rotación de la vista de video: algunos dispositivos de hardware, debido a restricciones de instalación, hacen que la imagen se invierta o gire, por lo que un buen reproductor RTMP y RTSP debe admitir la rotación en tiempo real de la vista de video (0° 90° 180° 270 °) e inversión horizontal. , inversión vertical;

7. Admite salida de datos de audio/video decodificados: muchos desarrolladores esperan obtener datos YUV o RGB durante la reproducción y realizar análisis de algoritmos como la comparación de rostros. Una de sus demandas más importantes es obtener datos de manera eficiente.Datos YUV o RGB;

8. Instantáneas en tiempo real: Es muy necesario capturar escenas interesantes o importantes en tiempo real;

9. Procesamiento de fluctuación de la red (como desconexión y reconexión de la red): el mecanismo de procesamiento de red estable y el soporte, como la desconexión y reconexión de la red, son muy importantes para los reproductores de transmisión en vivo;

10. Estabilidad operativa a largo plazo: los escenarios de uso de 7 x 24 horas son muy comunes y la importancia de la estabilidad operativa a largo plazo es evidente;

11. Información sobre la velocidad de descarga en tiempo real: proporciona devolución de llamada de descarga en tiempo real para transmisiones de audio y video. El intervalo de tiempo de devolución de llamada se puede configurar para garantizar información sobre la velocidad de descarga en tiempo real para monitorear el estado de la red;

12. Procesamiento de estado de excepción, devolución de llamada de estado de evento: si la red se interrumpe durante la reproducción, el reproductor que proporcionamos puede devolver el estado relevante en tiempo real para garantizar que el módulo superior esté al tanto del procesamiento. Los reproductores de código abierto no admiten esto. Bueno;

13. Configure el modo de llenado del video (mostrar en proporciones iguales): en muchos casos, algunas escenas requieren que se reproduzca la vista completa. En algunos casos, para evitar que el video se estire, se puede configurar para que se muestre en proporciones iguales. ;

14. Detección de D3D: en términos generales, la mayoría de Windows en el mercado admite D3D, y algunos nicho solo admiten dibujo en modo GDI, por lo que para una mejor compatibilidad, esta interfaz es muy necesaria;

15. Decodificación dura de modelos específicos: la decodificación dura de modelos específicos se utiliza principalmente en escenarios de reproducción multicanal para lograr un menor uso de CPU mediante la decodificación dura;

16. Reproducir solo fotogramas clave: especialmente cuando se reproducen escenas de instancias múltiples en pantalla grande, aunque nuestro uso de CPU es muy bajo, si solo desea ver la situación de monitoreo aproximada y lograr más canales de reproducción, es muy bueno reproducir solo fotogramas clave Punto de función, si necesita reproducir el fotograma original, puede ajustarlo en tiempo real;

17. Configuración TCP-UDP: considerando que algunos servidores, dispositivos de hardware o entornos de red admiten mejor TCP o UDP, hemos agregado una interfaz de configuración;

18. Cambio automático TCP-UDP: esta es una interfaz más detallada. Por ejemplo, el modo TCP está configurado de forma predeterminada. Los datos no se pueden recibir en modo TCP. Después de un tiempo de espera, cambiará automáticamente al modo UDP para intentarlo. Generalmente, los reproductores de código abierto no tienen esta función;

19. Configuración de tiempo de espera RTSP: por ejemplo, si no se pueden recibir datos dentro de 10 a 12 segundos, se volverá a conectar automáticamente. Generalmente, los reproductores de código abierto no lo admiten bien.

Realización técnica

Este artículo toma la demostración C ++ de la plataforma Windows de Daniu Live SDK como ejemplo para analizar el diseño y el procesamiento de interfaces como RTMP, reproducción RTSP, grabación, ajuste de volumen en tiempo real e instantáneas:

Inicialización del módulo:

	GetSmartPlayerSDKAPI(&player_api_);

	if ( NT_ERC_OK != player_api_.Init(0, NULL) )
	{
		return FALSE;
	}

	is_support_h264_hardware_decoder_ = NT_ERC_OK == player_api_.IsSupportH264HardwareDecoder();
	is_support_h265_hardware_decoder_ = NT_ERC_OK == player_api_.IsSupportH265HardwareDecoder();

	if ( NT_ERC_OK != player_api_.Open(&player_handle_, NULL, 0, NULL) )
	{
		return FALSE;
	}

	player_api_.SetEventCallBack(player_handle_, GetSafeHwnd(), &NT_SP_SDKEventHandle);

Inicialización de otros parámetros:

bool CSmartPlayerDlg::InitCommonSDKParam()
{
	ASSERT(!is_playing_);
	ASSERT(!is_recording_);

	if ( NULL == player_handle_ )
		return false;

	CString wbuffer_str;
	edit_buffer.GetWindowTextW(wbuffer_str);

	std::wstring_convert<std::codecvt_utf8<wchar_t> > conv;

	auto buffer_str = conv.to_bytes(wbuffer_str);

	player_api_.SetBuffer(player_handle_, atoi(buffer_str.c_str()));

	// 设置rtsp 超时时间
	player_api_.SetRtspTimeout(player_handle_, rtsp_conf_info_.timeout_);

	// 设置rtsp tcp模式,rtmp不使用, 可以不设置
	player_api_.SetRTSPTcpMode(player_handle_, rtsp_conf_info_.is_tcp_ ? 1 : 0);

	player_api_.SetRtspAutoSwitchTcpUdp(player_handle_, rtsp_conf_info_.is_tcp_udp_auto_switch_ ? 1 : 0);

	if ( btn_check_fast_startup_.GetCheck() == BST_CHECKED )
	{
		player_api_.SetFastStartup(player_handle_, 1);
	}
	else
	{
		player_api_.SetFastStartup(player_handle_, 0);
	}

	player_api_.SetReportDownloadSpeed(player_handle_, 1, 1);

	if (NT_ERC_OK != player_api_.SetURL(player_handle_, GetURL().c_str()))
	{
		return false;
	}

	connection_status_	= 0;
	buffer_status_		= 0;
	buffer_percent_		= 0;
	download_speed_		= -1;

	return true;
}

Controles de reproducción:

void CSmartPlayerDlg::OnBnClickedButtonPlay()
{
	if ( player_handle_ == NULL )
		return;

	CString btn_play_str;

	btn_play_.GetWindowTextW(btn_play_str);

	if ( btn_play_str == _T("播放") )
	{
		if ( !is_recording_ )
		{
			if ( !InitCommonSDKParam() )
			{
				AfxMessageBox(_T("设置参数错误!"));
				return;
			}
		}
	
		player_api_.SetVideoSizeCallBack(player_handle_, GetSafeHwnd(), SP_SDKVideoSizeHandle);

		bool is_support_d3d_render = false;
		NT_INT32 in_support_d3d_render = 0;

		if ( NT_ERC_OK == player_api_.IsSupportD3DRender(player_handle_,
			wrapper_render_wnd_.RenderWnd(), &in_support_d3d_render))
		{
			if ( 1 == in_support_d3d_render )
			{
				is_support_d3d_render = true;
			}
		}

		if ( is_support_d3d_render )
		{
			is_gdi_render_ = false;

			// 支持d3d绘制的话,就用D3D绘制
			player_api_.SetRenderWindow(player_handle_, wrapper_render_wnd_.RenderWnd());

			player_api_.SetRenderScaleMode(player_handle_, btn_check_render_scale_mode_.GetCheck() == BST_CHECKED ? 1 : 0);
		}
		else
		{
			is_gdi_render_ = true;

			// 不支持D3D就让播放器吐出数据来,用GDI绘制

			wrapper_render_wnd_.SetRenderScaleMode(btn_check_render_scale_mode_.GetCheck() == BST_CHECKED ? 1 : 0);

			player_api_.SetVideoFrameCallBack(player_handle_, NT_SP_E_VIDEO_FRAME_FORMAT_RGB32,
				GetSafeHwnd(), SM_SDKVideoFrameHandle);
		}

		if ( BST_CHECKED == btn_check_hardware_decoder_.GetCheck() )
		{
			player_api_.SetH264HardwareDecoder(player_handle_, is_support_h264_hardware_decoder_?1:0, 0);
			player_api_.SetH265HardwareDecoder(player_handle_, is_support_h265_hardware_decoder_?1:0, 0);
		}
		else
		{
			player_api_.SetH264HardwareDecoder(player_handle_, 0, 0);
			player_api_.SetH265HardwareDecoder(player_handle_, 0, 0);
		}

		player_api_.SetOnlyDecodeVideoKeyFrame(player_handle_, BST_CHECKED == btn_check_only_decode_video_key_frame_.GetCheck() ? 1 : 0);

		player_api_.SetLowLatencyMode(player_handle_, BST_CHECKED == btn_check_low_latency_.GetCheck() ? 1 : 0);

		player_api_.SetFlipVertical(player_handle_, BST_CHECKED == btn_check_flip_vertical_.GetCheck() ? 1 :0 );

		player_api_.SetFlipHorizontal(player_handle_, BST_CHECKED == btn_check_flip_horizontal_.GetCheck() ? 1 : 0);

		player_api_.SetRotation(player_handle_, rotate_degrees_);

		player_api_.SetAudioVolume(player_handle_, slider_audio_volume_.GetPos());

		player_api_.SetUserDataCallBack(player_handle_, GetSafeHwnd(), NT_SP_SDKUserDataHandle);

		if (NT_ERC_OK != player_api_.StartPlay(player_handle_))
		{
			AfxMessageBox(_T("播放器失败!"));
			return;
		}

		btn_play_.SetWindowTextW(_T("停止"));
		is_playing_ = true;
	}
	else
	{
		StopPlayback();
	}
}

Instantánea en vivo:

void CSmartPlayerDlg::OnBnClickedButtonCaptureImage()
{
	if ( capture_image_path_.empty() )
	{
		AfxMessageBox(_T("请先设置保存截图文件的目录! 点击截图左边的按钮设置!"));
		return;
	}

	if ( player_handle_ == NULL )
	{
		return;
	}

	if ( !is_playing_ )
	{
		return;
	}

	std::wostringstream ss;
	ss << capture_image_path_;

	if ( capture_image_path_.back() != L'\\' )
	{
		ss << L"\\";
	}

	SYSTEMTIME sysTime;
	::GetLocalTime(&sysTime);

	ss << L"SmartPlayer-"
		<< std::setfill(L'0') << std::setw(4) << sysTime.wYear
		<< std::setfill(L'0') << std::setw(2) << sysTime.wMonth
		<< std::setfill(L'0') << std::setw(2) << sysTime.wDay
		<< L"-"
		<< std::setfill(L'0') << std::setw(2) << sysTime.wHour
		<< std::setfill(L'0') << std::setw(2) << sysTime.wMinute
		<< std::setfill(L'0') << std::setw(2) << sysTime.wSecond;

	ss << L"-" << std::setfill(L'0') << std::setw(3) << sysTime.wMilliseconds
		<< L".png";

	std::wstring_convert<std::codecvt_utf8<wchar_t> > conv;

	auto val_str = conv.to_bytes(ss.str());

	auto ret = player_api_.CaptureImage(player_handle_, val_str.c_str(), NULL, &SM_SDKCaptureImageHandle);
	if (NT_ERC_OK == ret)
	{
		// 发送截图请求成功
	}
	else if (NT_ERC_SP_TOO_MANY_CAPTURE_IMAGE_REQUESTS == ret)
	{
		// 通知用户延时
		OutputDebugStringA("Too many capture image requests!!!\r\n");
	}
	else
	{
		// 其他失败
	}
}

Resuelva solo fotogramas clave:

void CSmartPlayerDlg::OnBnClickedCheckOnlyDecodeVideoKeyFrame()
{
	if (player_handle_ != NULL)
	{
		player_api_.SetOnlyDecodeVideoKeyFrame(player_handle_, BST_CHECKED == btn_check_only_decode_video_key_frame_.GetCheck() ? 1 : 0);
	}
}

Configure la visualización proporcional o el modo de mosaico completo:

void CSmartPlayerDlg::OnBnClickedCheckRenderScaleMode()
{
	if (player_handle_ != NULL)
	{
		if (!is_gdi_render_)
		{
			player_api_.SetRenderScaleMode(player_handle_, BST_CHECKED == btn_check_render_scale_mode_.GetCheck() ? 1 : 0);
		}
		else
		{
			wrapper_render_wnd_.SetRenderScaleMode(btn_check_render_scale_mode_.GetCheck() == BST_CHECKED ? 1 : 0);
		}
	}
}

Vista de video inversión horizontal, inversión vertical, rotación:

void CSmartPlayerDlg::OnBnClickedCheckFlipHorizontal()
{
	if (player_handle_ != NULL)
	{
		player_api_.SetFlipHorizontal(player_handle_, BST_CHECKED == btn_check_flip_horizontal_.GetCheck() ? 1 : 0);
	}
}

void CSmartPlayerDlg::OnBnClickedCheckFlipVertical()
{
	if (player_handle_ != NULL)
	{
		player_api_.SetFlipVertical(player_handle_, BST_CHECKED == btn_check_flip_vertical_.GetCheck() ? 1 : 0);
	}
}

void CSmartPlayerDlg::OnBnClickedButtonRotation()
{
	rotate_degrees_ += 90;
	rotate_degrees_ = rotate_degrees_ % 360;

	if (0 == rotate_degrees_)
	{
		btn_rotation_.SetWindowText(_T("旋转90度"));
	}
	else if (90 == rotate_degrees_)
	{
		btn_rotation_.SetWindowText(_T("旋转180度"));
	}
	else if (180 == rotate_degrees_)
	{
		btn_rotation_.SetWindowText(_T("旋转270度"));
	}
	else if (270 == rotate_degrees_)
	{
		btn_rotation_.SetWindowText(_T("不旋转"));
	}

	if ( player_handle_ != NULL )
	{
		player_api_.SetRotation(player_handle_, rotate_degrees_);
	}
}

Grabación en tiempo real:

void CSmartPlayerDlg::OnBnClickedButtonRecord()
{
	if ( player_handle_ == NULL )
		return;

	CString btn_record_str;
	btn_record_.GetWindowTextW(btn_record_str);

	if ( btn_record_str == _T("录像") )
	{
		if ( !rec_conf_info_.is_record_video_ && !rec_conf_info_.is_record_audio_ )
		{
			AfxMessageBox(_T("音频录制选项和视频录制选项至少需要选择一个!"));
			return;
		}

		if ( !is_playing_ )
		{
			if ( !InitCommonSDKParam() )
			{
				AfxMessageBox(_T("设置参数错误!"));
				return;
			}
		}

		player_api_.SetRecorderVideo(player_handle_, rec_conf_info_.is_record_video_ ? 1 : 0);
		player_api_.SetRecorderAudio(player_handle_, rec_conf_info_.is_record_audio_ ? 1 : 0);

		auto ret = player_api_.SetRecorderDirectory(player_handle_, rec_conf_info_.dir_.c_str());
		if ( NT_ERC_OK != ret )
		{
			AfxMessageBox(_T("设置录像目录失败,请确保目录存在且是英文目录"));
			return;
		}

		player_api_.SetRecorderFileMaxSize(player_handle_, rec_conf_info_.file_max_size_);

		NT_SP_RecorderFileNameRuler rec_name_ruler = { 0 };

		rec_name_ruler.type_ = 0;
		rec_name_ruler.file_name_prefix_ = rec_conf_info_.file_name_prefix_.c_str();
		rec_name_ruler.append_date_		 = rec_conf_info_.is_append_date_ ? 1 : 0;
		rec_name_ruler.append_time_		 = rec_conf_info_.is_append_time_ ? 1 : 0;

		player_api_.SetRecorderFileNameRuler(player_handle_, &rec_name_ruler);

		player_api_.SetRecorderCallBack(player_handle_, GetSafeHwnd(), &SP_SDKRecorderHandle);

		player_api_.SetRecorderAudioTranscodeAAC(player_handle_, rec_conf_info_.is_audio_transcode_aac_ ? 1 : 0);

		if ( NT_ERC_OK != player_api_.StartRecorder(player_handle_) )
		{
			AfxMessageBox(_T("录像失败!"));
			return;
		}

		btn_record_.SetWindowTextW(_T("停止录像"));
		is_recording_ = true;
	}
	else
	{
		StopRecorder();
	}
}

Devolución de llamada de evento:

LRESULT CSmartPlayerDlg::OnSDKEvent(WPARAM wParam, LPARAM lParam)
{
	if (!is_playing_ && !is_recording_)
	{
		return S_OK;
	}

	NT_UINT32 event_id = (NT_UINT32)(wParam);

	if ( NT_SP_E_EVENT_ID_PLAYBACK_REACH_EOS == event_id )
	{
		StopPlayback();
		return S_OK;
	}
	else if ( NT_SP_E_EVENT_ID_RECORDER_REACH_EOS == event_id )
	{
		StopRecorder();
		return S_OK;
	}
	else if ( NT_SP_E_EVENT_ID_RTSP_STATUS_CODE == event_id )
	{
		int status_code = (int)lParam;
		if ( 401 == status_code )
		{
			HandleVerification();
		}

		return S_OK;
	}
	else if (NT_SP_E_EVENT_ID_NEED_KEY == event_id)
	{
		HandleKeyEvent(false);

		return S_OK;
	}
	else if (NT_SP_E_EVENT_ID_KEY_ERROR == event_id)
	{
		HandleKeyEvent(true);

		return S_OK;
	}
	else if ( NT_SP_E_EVENT_ID_PULLSTREAM_REACH_EOS == event_id )
	{
		if (player_handle_ != NULL)
		{
			player_api_.StopPullStream(player_handle_);
		}

		return S_OK;
	}
	else if ( NT_SP_E_EVENT_ID_DURATION == event_id )
	{
		NT_INT64 duration = (NT_INT64)(lParam);

		edit_duration_.SetWindowTextW(GetHMSMsFormatStr(duration, false, false).c_str());

		return S_OK;
	}

	if ( NT_SP_E_EVENT_ID_CONNECTING == event_id
		|| NT_SP_E_EVENT_ID_CONNECTION_FAILED == event_id
		|| NT_SP_E_EVENT_ID_CONNECTED == event_id
		|| NT_SP_E_EVENT_ID_DISCONNECTED == event_id
		|| NT_SP_E_EVENT_ID_NO_MEDIADATA_RECEIVED == event_id)
	{
		if ( NT_SP_E_EVENT_ID_CONNECTING == event_id )
		{
			OutputDebugStringA("connection status: connecting\r\n");
		}
		else if ( NT_SP_E_EVENT_ID_CONNECTION_FAILED == event_id )
		{
			OutputDebugStringA("connection status: connection failed\r\n");
		}
		else if ( NT_SP_E_EVENT_ID_CONNECTED == event_id )
		{
			OutputDebugStringA("connection status: connected\r\n");
		}
		else if (NT_SP_E_EVENT_ID_DISCONNECTED == event_id)
		{
			OutputDebugStringA("connection status: disconnected\r\n");
		}
		else if (NT_SP_E_EVENT_ID_NO_MEDIADATA_RECEIVED == event_id)
		{
			OutputDebugStringA("connection status: no mediadata received\r\n");
		}

		connection_status_ = event_id;
	}

	if ( NT_SP_E_EVENT_ID_START_BUFFERING == event_id
		|| NT_SP_E_EVENT_ID_BUFFERING == event_id
		|| NT_SP_E_EVENT_ID_STOP_BUFFERING == event_id )
	{
		buffer_status_ = event_id;
		
		if ( NT_SP_E_EVENT_ID_BUFFERING == event_id )
		{
			buffer_percent_ = (NT_INT32)lParam;

			std::wostringstream ss;
			ss << L"buffering:" << buffer_percent_ << "%";
			OutputDebugStringW(ss.str().c_str());
			OutputDebugStringW(L"\r\n");
		}
	}

	if ( NT_SP_E_EVENT_ID_DOWNLOAD_SPEED == event_id )
	{
		download_speed_ = (NT_INT32)lParam;
	}

	CString show_str = base_title_;

	if ( connection_status_ != 0 )
	{
		show_str += _T("--链接状态: ");

		if ( NT_SP_E_EVENT_ID_CONNECTING == connection_status_ )
		{
			show_str += _T("链接中");
		}
		else if ( NT_SP_E_EVENT_ID_CONNECTION_FAILED == connection_status_ )
		{
			show_str += _T("链接失败");
		}
		else if ( NT_SP_E_EVENT_ID_CONNECTED == connection_status_ )
		{
			show_str += _T("链接成功");
		}
		else if ( NT_SP_E_EVENT_ID_DISCONNECTED == connection_status_ )
		{
			show_str += _T("链接断开");
		}
		else if (NT_SP_E_EVENT_ID_NO_MEDIADATA_RECEIVED == connection_status_)
		{
			show_str += _T("收不到数据");
		}
	}

	if (download_speed_ != -1)
	{
		std::wostringstream ss;
		ss << L"--下载速度:" << (download_speed_ * 8 / 1000) << "kbps"
		  << L"(" << (download_speed_ / 1024) << "KB/s)";

		show_str += ss.str().c_str();
	}

	if ( buffer_status_ != 0 )
	{
		show_str += _T("--缓冲状态: ");

		if ( NT_SP_E_EVENT_ID_START_BUFFERING == buffer_status_ )
		{
			show_str += _T("开始缓冲");
		}
		else if (NT_SP_E_EVENT_ID_BUFFERING == buffer_status_)
		{
			std::wostringstream ss;
			ss << L"缓冲中" << buffer_percent_ << "%";
			show_str += ss.str().c_str();
		}
		else if (NT_SP_E_EVENT_ID_STOP_BUFFERING == buffer_status_)
		{
			show_str += _T("结束缓冲");
		}
	}


	SetWindowText(show_str);

	return S_OK;
}

Resumir

A menudo decimos que es fácil crear un reproductor RTMP o RTSP, pero aún es difícil crear un reproductor de transmisión en vivo potente, de alta estabilidad, baja latencia y procesamiento de detalles, especialmente en el procesamiento de detalles. Los reproductores de código abierto no tienen grandes problemas. en reproducción bajo demanda y retransmisiones en directo En este escenario todavía hay muchas deficiencias: es fácil hacer un jugador, pero es difícil hacer un buen jugador. Lo anterior es solo un pequeño intercambio de experiencias y los desarrolladores interesados ​​pueden consultarlo.

Supongo que te gusta

Origin blog.csdn.net/renhui1112/article/details/132391347
Recomendado
Clasificación