• webrtc相关介绍


    1. 介绍

    WebRTC (Web Real-Time Communications) 是一项 实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。

    2. MediaCodec介绍

    MediaCodec Android 提供的一个用于处理音频和视频数据的底层 API。它支持编码(将原始数据转换为压缩格式)和解码(将压缩数据转换回原始格式)的过程。MediaCodec 是自 Android 4.1(API 16)起引入的,(通常与MediaExtractorMediaSyncMediaMuxerMediaCryptoMediaDrmImageSurface一起使用)

    • MediaExtractor:用于从缓冲区中读取(readSampleData)媒体数据
    • MediaMuxer:用于将原始音频数据(pcm)和视频数据(yuv)写入(writeSampleData)音视频轨道中,从而可以以文件形式保存
    1. 创建和配置 MediaCodec

      1. 首先,需要根据所需的编解码器类型(例如 H.264、VP8、Opus 等)创建一个 MediaCodec 实例。
      2. 接下来,通过 MediaFormat 对象指定编解码器的一些参数,如分辨率、帧率、码率等。
      3. 然后,使用 configure() 方法配置 MediaCodec
      object MediaCodecUtil {
          // 音频源:音频输入-麦克风
          private const val AUDIO_INPUT = MediaRecorder.AudioSource.MIC
       
          // 采样率
          // 44100是目前的标准,但是某些设备仍然支持22050,16000,11025
          // 采样频率一般共分为22.05KHz、44.1KHz、48KHz三个等级
          private const val AUDIO_SAMPLE_RATE = 44100
       
          // 音频通道 单声道
          private const val AUDIO_CHANNEL = AudioFormat.CHANNEL_IN_MONO
       
          // 音频通道 立体声:CHANNEL_OUT_STEREO或CHANNEL_IN_STEREO
          private const val AUDIO_CHANNEL2 = AudioFormat.CHANNEL_IN_STEREO
       
          // 音频格式:PCM编码
          private const val AUDIO_ENCODING = AudioFormat.ENCODING_PCM_16BIT
       
          private var bufferSizeInBytes: Int = 0
       
          /**
           * 获取缓冲大小
           */
          fun getBufferSizeInBytes(): Int {
              return bufferSizeInBytes
          }
       
       	
          fun createVideoEncode(surfaceSize: Size): MediaCodec {
              // 1. 视频编码器
              val videoEncoder = MediaCodec.createEncoderByType("video/avc")
                  
              // 2. 创建视频MediaFormat
              val videoFormat = MediaFormat.createVideoFormat(
                  "video/avc", surfaceSize.width
                  , surfaceSize.height)
              // 指定编码器颜色格式
              videoFormat.setInteger(
                  MediaFormat.KEY_COLOR_FORMAT,
                  MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
              // 指定编码器码率
              videoFormat.setInteger(MediaFormat.KEY_BIT_RATE, 0)
              // 指定编码器帧率
              videoFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30)
              // 指定编码器关键帧间隔
              videoFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 5)
              // BITRATE_MODE_CBR输出码率恒定
              // BITRATE_MODE_CQ保证图像质量
              // BITRATE_MODE_VBR图像复杂则码率高,图像简单则码率低
              videoFormat.setInteger(
                  MediaFormat.KEY_BITRATE_MODE,
                  MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR)
              videoFormat.setInteger(
                  MediaFormat.KEY_COMPLEXITY,
                  MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR)
                  
              // 3. 配置 mediacodec
              videoEncoder.configure(videoFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
              return videoEncoder
          }
       
       
          fun createAudioEncoder(): MediaCodec {
              // 1. 音频编码器
              val audioEncoder = MediaCodec.createEncoderByType("audio/mp4a-latm")
                  
              // 2. 创建音频MediaFormat,参数2:采样率,参数3:通道
              val audioFormat = MediaFormat.createAudioFormat("audio/mp4a-latm", 44100, 1)
              // 仅编码器指定比特率
              audioFormat.setInteger(MediaFormat.KEY_BIT_RATE, 4 * 1024)
              var bufferSizeInBytes = getBufferSizeInBytes()
              if (bufferSizeInBytes == 0) {
                  bufferSizeInBytes = AudioRecord.getMinBufferSize(
                      AUDIO_SAMPLE_RATE ,
                      CHANNEL_IN_STEREO,
                      ENCODING_PCM_16BIT)
              }
              //可选的,输入数据缓冲区的最大大小
              audioFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSizeInBytes)
              audioFormat.setInteger(
                  MediaFormat.KEY_AAC_PROFILE,
                  MediaCodecInfo.CodecProfileLevel.AACObjectLC)
       
       		// 3. 配置mediacodec
              audioEncoder.configure(audioFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
              return audioEncoder
          }
       
          // 默认获取单声道AudioRecord
          fun getSingleAudioRecord(
              channelConfig: Int = AUDIO_CHANNEL,
              audioSource: Int = AUDIO_INPUT,
              sampleRateInHz: Int = AUDIO_SAMPLE_RATE,
              audioFormat: Int = AUDIO_ENCODING): AudioRecord {
              //audioRecord能接受的最小的buffer大小
              bufferSizeInBytes = AudioRecord.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat)
              return AudioRecord(
                  audioSource,
                  sampleRateInHz,
                  channelConfig,
                  audioFormat,
                  bufferSizeInBytes)
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40
      • 41
      • 42
      • 43
      • 44
      • 45
      • 46
      • 47
      • 48
      • 49
      • 50
      • 51
      • 52
      • 53
      • 54
      • 55
      • 56
      • 57
      • 58
      • 59
      • 60
      • 61
      • 62
      • 63
      • 64
      • 65
      • 66
      • 67
      • 68
      • 69
      • 70
      • 71
      • 72
      • 73
      • 74
      • 75
      • 76
      • 77
      • 78
      • 79
      • 80
      • 81
      • 82
      • 83
      • 84
      • 85
      • 86
      • 87
      • 88
      • 89
      • 90
      • 91
      • 92
      • 93
      • 94
      • 95
      • 96
      • 97
      • 98
      • 99
      • 100
      • 101
      • 102
      • 103
      • 104
    2. 基本使用

      fun encode(){
          val videoEncoder = MediaCodecUtil.createVideoEncode(size)
          // 设置 buffer (camera的surface or mediaprojection捕获的surface)
          videoEncoder.setInputSurface(surface)
          videoEncoder.start()
          
          //音频录制类
          val audioRecord = MediaCodecUtil.getSingleAudioRecord(AudioFormat.CHANNEL_IN_STEREO)
          //音频编码器
          val audioEncoder = MediaCodecUtil.createAudioEncoder()
          audioEncoder.start()
      }
       
       
      GlobalScope.launch (Dispatchers.IO) {
          while (isActive) {
              val length = AudioRecordUtil.getBufferSizeInBytes()
              audioRecord.read(mAudioBuffer, 0, length)
              // 1. 调用dequeueInputBuffer获取输入队列空闲数组下标
              val inputIndex = audioEncoder.dequeueInputBuffer(0)
              if (inputIndex >= 0) {
                  // 2. 通过getInputBuffers获取输入队列
                  val byteBuffer = audioEncoder.getInputBuffer(inputIndex)
                  if (byteBuffer != null) {
                      byteBuffer.clear()
                      byteBuffer.put(mAudioBuffer)
                      byteBuffer.limit(length);// 设定上限值
                      // 3. queueInputBuffer把原始PCM数据送入编码器
                     audioEncoder.queueInputBuffer(inputIndex,0,length,System.nanoTime(),0); 
                  }
              }
       
              // 4. dequeueOutputBuffer获取输出队列空闲数组角标
              val outputIndex = audioEncoder.dequeueOutputBuffer(mBufferInfo, 0)
              if (outputIndex >= 0) {
                  // 5. 通过getOutputBuffers 获取输出流
                  val byteBuffer = audioEncoder.getOutputBuffer(outputIndex)
                  if (byteBuffer != null) {
                      val byte = byteBuffer.get(outputIndex)
                  }
                  // 6. 通过releaseOutputBuffer把输出buffer还给系统,重新放到输出队列中
                  audioEncoder.releaseOutputBuffer(outputIndex, false)
              }
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40
      • 41
      • 42
      • 43
      • 44
      • 45
    3. 输入和输出缓冲区:MediaCodec 有两个缓冲区队列,一个用于输入,另一个用于输出。输入缓冲区用于接收原始数据(例如从摄像头捕获的视频帧),输出缓冲区用于存诸编码后的数据。在编解码过程中,需要将这些缓冲区填充或消费。img

    4. 编码器工作模式:MediaCodec 支持两种工作模式,分别是同步和异步。在同步模式下,需要手动管理输入和输出缓冲区。在异步模式下,通过设置回调函数(MediaCodec.Callback),可以在编解码事件发生时自动通知应用程序。

      同步模式

       MediaCodec codec = MediaCodec.createByCodecName(name);
       codec.configure(format,);
       MediaFormat outputFormat = codec.getOutputFormat(); // option B
       codec.start();
       for (;;) {
        int inputBufferId = codec.dequeueInputBuffer(timeoutUs);
        if (inputBufferId >= 0) {
          ByteBuffer inputBuffer = codec.getInputBuffer();
          // fill inputBuffer with valid data
          …
          codec.queueInputBuffer(inputBufferId,);
        }
        int outputBufferId = codec.dequeueOutputBuffer();
        if (outputBufferId >= 0) {
          ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
          MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId); // option A
          // bufferFormat is identical to outputFormat
          // outputBuffer is ready to be processed or rendered.
          …
          codec.releaseOutputBuffer(outputBufferId,);
        } else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
          // Subsequent data will conform to new format.
          // Can ignore if using getOutputFormat(outputBufferId)
          outputFormat = codec.getOutputFormat(); // option B
        }
       }
       codec.stop();
       codec.release();
      
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29

      异步模式(推荐)

       MediaCodec codec = MediaCodec.createByCodecName(name);
       MediaFormat mOutputFormat; // member variable
       codec.setCallback(new MediaCodec.Callback() {
        @Override
        void onInputBufferAvailable(MediaCodec mc, int inputBufferId) {
          ByteBuffer inputBuffer = codec.getInputBuffer(inputBufferId);
          // fill inputBuffer with valid data
          …
          codec.queueInputBuffer(inputBufferId,);
        }
       
        @Override
        void onOutputBufferAvailable(MediaCodec mc, int outputBufferId,) {
          ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
          MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId); // option A
          // bufferFormat is equivalent to mOutputFormat
          // outputBuffer is ready to be processed or rendered.
          …
          codec.releaseOutputBuffer(outputBufferId,);
        }
       
        @Override
        void onOutputFormatChanged(MediaCodec mc, MediaFormat format) {
          // Subsequent data will conform to new format.
          // Can ignore if using getOutputFormat(outputBufferId)
          mOutputFormat = format; // option B
        }
       
        @Override
        void onError() {}
        @Override
        void onCryptoError() {}
       });
       codec.configure(format,);
       mOutputFormat = codec.getOutputFormat(); // option B
       codec.start();
       // wait for processing to complete
       codec.stop();
       codec.release();
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40
      • 41
      • 42
      • 43

    3. 引入关键库

    第一个当然就是 WebRTC 库了,第二个是 socket.io 库,用它来与信令服务器互联。

    ...
    dependencies {
        ...
        implementation 'org.webrtc:google-webrtc:1.0.+'
        implementation 'io.socket:socket.io-client:1.0.0'
        ...
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    4. PeerConnectionFactory

    WebRTC程序的起源就是 PeerConnectionFactory

    // 初始化
    PeerConnectionFactory.initialize(...);
    
    // 初始化之后,就可以通过 builder 模式来构造 PeerConnecitonFactory 对象了。
    ...
    PeerConnectionFactory.Builder builder = 		
    				PeerConnectionFactory.builder()
                    	.setVideoEncoderFactory(encoderFactory)
                    	.setVideoDecoderFactory(decoderFactory);
                    
     ...
    
     return builder.createPeerConnectionFactory();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    主要是方便调整建造 PeerConnectionFactory的组件,如编码器、解码器等。

    5. 源

    音视频源,Video/AudioTrack

    ...
    VideoSource videoSource = 
    					mPeerConnectionFactory.createVideoSource(false);
    mVideoTrack = mPeerConnectionFactory.createVideoTrack(
    													VIDEO_TRACK_ID, 
    													videoSource);
    													
    ...
    
    AudioSource audioSource = 
    					mPeerConnectionFactory.createAudioSource(new MediaConstraints());
    mAudioTrack = mPeerConnectionFactory.createAudioTrack(
    													AUDIO_TRACK_ID, 
    													audioSource);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    6. 源采集

    视频采集,在 Android 系统下有两种 Camera,一种称为 Camera1, 是一种比较老的采集视频数据的方式,别一种称为 Camera2, 是一种新的采集视频的方法。它们之间的最大区别是 Camera1使用同步方式调用API,Camera2使用异步方式,所以Camera2更高效。

    private VideoCapturer createVideoCapturer() {
            if (Camera2Enumerator.isSupported(this)) {
                return createCameraCapturer(new Camera2Enumerator(this));
            } else {
                return createCameraCapturer(new Camera1Enumerator(true));
            }
    }
    
    
    private VideoCapturer createCameraCapturer(CameraEnumerator enumerator) {
            final String[] deviceNames = enumerator.getDeviceNames();
    
            // First, try to find front facing camera
            Log.d(TAG, "Looking for front facing cameras.");
            for (String deviceName : deviceNames) {
                if (enumerator.isFrontFacing(deviceName)) {
                    Logging.d(TAG, "Creating front facing camera capturer.");
                    VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, 
                                                                            null);
                    if (videoCapturer != null) {
                        return videoCapturer;
                    }
                }
            }
    
            // Front facing camera not found, try something else
            Log.d(TAG, "Looking for other cameras.");
            for (String deviceName : deviceNames) {
                if (!enumerator.isFrontFacing(deviceName)) {
                    Logging.d(TAG, "Creating other camera capturer.");
                    VideoCapturer videoCapturer = enumerator.createCapturer(deviceName,
                                                                            null);
                    if (videoCapturer != null) {
                        return videoCapturer;
                    }
                }
            }
            
            return null;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40

    VideoCapture 是如何与 VideoSource 关联到一起的:

    ...
    
    mSurfaceTextureHelper = 
    			SurfaceTextureHelper.create("CaptureThread",
    										mRootEglBase.getEglBaseContext());
    
    mVideoCapturer.initialize(mSurfaceTextureHelper,
     						  getApplicationContext(), 
     						  videoSource.getCapturerObserver());
    
    ...
    
    mVideoTrack.setEnabled(true);
    ...
        
        
    @Override
    protected void onResume() {
        super.onResume();
        mVideoCapturer.startCapture(VIDEO_RESOLUTION_WIDTH, 
        							VIDEO_RESOLUTION_HEIGHT, 
        							VIDEO_FPS);
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    7. 渲染

    渲染视频。 在 Android 下 WebRTC 使用OpenGL ES 进行视频渲染,用于展示视频的控件是 WebRTC 对 Android 系统控件 SurfaceView 的封装 。 WebRTC 封装后的 SurfaceView 类为 org.webrtc.SurfaceViewRenderer

    1. 本地渲染

      <org.webrtc.SurfaceViewRenderer
              android:id="@+id/LocalSurfaceView"
              android:layout_width="wrap_content"
              android:layout_height="wrap_content"
              android:layout_gravity="center" />
      
      • 1
      • 2
      • 3
      • 4
      • 5

      定义好 surfaceview 后,还需要进行设置

      ...
      
      mLocalSurfaceView.init(mRootEglBase.getEglBaseContext(), null);
      mLocalSurfaceView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL);
      mLocalSurfaceView.setMirror(true);
      mLocalSurfaceView.setEnableHardwareScaler(false /* enabled */);
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6

      其含义是:

      • 使用 OpenGL ES 的上下文初始化 View。
      • 设置图像的拉伸比例。
      • 设置图像显示时反转,不然视频显示的内容与实际内容正好相反。
      • 是否打开便件进行拉伸。

      接下来将从摄像头采集的数据设置到该view里就可以显示了。设置非常的简单,代码如下:

      ...
      mVideoTrack.addSink(mLocalSurfaceView);
      ...
      
      • 1
      • 2
      • 3
    2. 远端渲染

      要想从远端获取数据,我们就必须创建 PeerConnection 对象。该对象的用处就是与远端建立联接,并最终为双方通讯提供网络通道。

      ...
      PeerConnection.RTCConfiguration rtcConfig = 
                      new PeerConnection.RTCConfiguration(iceServers);
      ...
      PeerConnection connection =
                      mPeerConnectionFactory.createPeerConnection(rtcConfig,
                                                                  mPeerConnectionObserver);
      
      ...
      connection.addTrack(mVideoTrack, mediaStreamLabels);
      connection.addTrack(mAudioTrack, mediaStreamLabels);
      ...
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12

      WebRTC 在建立连接时使用 ICE 架构,一些参数需要在创建 PeerConnection 时设置进去。

      8. 信令驱动

      在整个 WebRTC 双方交互的过程中,其业务逻辑的核心是信令, 所有的模块都是通过信令串联起来的。通过socket.io与之前搭建的信令服备器互联的。

      客户端命令有:

      • join: 用户加入房间
      • leave: 用户离开房间
      • message: 端到端命令(offer、answer、candidate)

      服务端命令:

      • joined: 用户已加入
      • leaved: 用户已离开
      • other_joined:其它用户已加入
      • bye: 其它用户已离开
      • full: 房间已满

      通过以上几条信令就可以实现一对一实时互动的要求,是不是非常的简单?

    9. 直播协议

    9.1 HLS

    HTTP Live Streaming 简称为 HLS, 是一个基于 HTTP 的视频流协议,由 APPLE 公司提出和实现。苹果公司的很多产品都支持 HLS 协议,譬如 Mac OS 上的 QuickTime、Safari 以及 iOS 上的 Safari。苹果 2009 年提出该协议,HLS 是 iOS 设备默认要求的视频流标准。安卓也支持HLS。

    HLS 因为以下几个原因比较受欢迎。

    • HLS 几乎可随处播放。 几个大平台 web、mobile、tv 基本都有免费的HLS 播放器支持。
    • 苹果 要求 HLS。 如果你想在 iOS 设备直播,逃不了的。
    • HLS 相对简单。 它使用了普遍且已经存储的视频格式(MP4 或 TS,伴随着 H.264 和 AAC 等编解码器), 另外附加了一个丑陋但人类可读的文本格式(m3u8).
    • 它通过 HTTP 工作。 不需要跑特殊的服务(不像老旧校风派的 RTMP 协议或者新潮的 WebRTC 协议)。HLS 可以方便的透过防火墙或者代理服务器,而且可以很方便的利用 CDN 进行分发加速,并且客户端实现起来也很方便。

    9.2 RTMP

    Real Time Messaging Protocol(简称 RTMP)是 Macromedia 开发的一套视频直播协议,现在属于 Adobe。

    协议基于 TCP,是一个协议族,包括 RTMP 基本协议及 RTMPT/RTMPS/RTMPE 等多种变种。RTMP 是一种设计用来进行实时数据通信的网络协议,主要用来在 Flash/AIR 平台和支持RTMP协议的流媒体/交互服务器之间进行音视频和数据通信。

    无法支持移动端 WEB 播放是它的硬伤。虽然无法在iOS的H5页面播放,但是iOS原生应用可以写解码去解析的。浏览器端,HTML5 video标签无法播放 RTMP 协议的视频,可以通过 video.js 来实现。

    其主要优点:

    • 实时性非常好,延时较小,通常为 1-3s

    • 基于 TCP 长连接,不需要多次建连。

      image.png

  • 相关阅读:
    C++:红黑树
    Debezium系列之:深入理解Kafka的消息代理
    在 C# CLR 中学习 C++ 之了解 namespace
    【博弈论】SG(Sprague–Grundy)定理证明和Nim游戏正确性证明
    Learn OpenGL 03 着色器
    Scrapy设置代理IP方法(超详细)
    猿创征文|Android 10.0 SystemUI状态栏隐藏搜狗输入法图标方法
    Vue NextTick工作原理及使用场景
    LeetCode 234. 回文链表
    总结:JS文件中引入其他JS文件的方法,以及<script>标签中间如何嵌套<script>标签
  • 原文地址:https://blog.csdn.net/qq_37776700/article/details/133876500