美文网首页Android项目直播
Android PC投屏简单尝试(录屏直播)2—硬解章(Medi

Android PC投屏简单尝试(录屏直播)2—硬解章(Medi

作者: deep_sadness | 来源:发表于2018-11-08 12:23 被阅读293次

代码地址 :https://github.com/deepsadness/MediaProjectionDemo

想法来源

上一边文章的最后说使用录制的Api进行录屏直播。本来这边文章是预计在5月份完成的。结果过了这么久,终于有时间了。就来填坑了。

主要思路

  • 直接使用硬件编码器进行录制直播。
  • 使用rtmp协议进行直播推流
使用MediaProjection示意图.png

整体流程就是通过创建VirtualDisplay,并且直接通过MediaCodec的Surface直接得到数据。通过MediaCodec得到编码完成之后的数据,进行 flv格式的封装,最后通过rtmp协议进行发送。


获取屏幕的截屏

1. 使用MediaCodec Surface

这部分基本上和上一遍文章相同,不同的就是使用MediaCodec来获取Surface

 @Override
    public @Nullable
    Surface createSurface(int width, int height) {
        mBufferInfo = new MediaCodec.BufferInfo();
        //创建视频的mediaFormat
        MediaFormat format = MediaFormat.createVideoFormat(MIME_TYPE, width, height);
        //还需要对器进行插值。设置自己设置的一些变量
        format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
        format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE);
        format.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE);
        format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, IFRAME_INTERVAL);
        format.setInteger(MediaFormat.KEY_COLOR_FORMAT,
                MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
        format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE);
        format.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE);
        format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, IFRAME_INTERVAL);
        if (VERBOSE) Log.d(TAG, "format: " + format);

        // 创建一个MediaCodec编码器,并且使用format 进行configure.然后将其 Get a Surface给VirtualDisplay
        try {
            mEncoder = MediaCodec.createEncoderByType(MIME_TYPE);
            mEncoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
            mInputSurface = mEncoder.createInputSurface();
            //直接开启编码器
            mEncoder.start();
            //...省去部分代码
            return mInputSurface;
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

2. 获取编码后的数据

  • 创建Encoder HanderThread
    不断获取编码后的数据需要在一个新的线程内进行。所以我们先创建一个HanderThread进行异步操作和异步通行。
    private void createEncoderThread() {
        HandlerThread encoder = new HandlerThread("Encoder");
        encoder.start();
        Looper looper = encoder.getLooper();
        workHanlder = new Handler(looper);
    }
  • 开始获取数据的任务
    在上面编码器开启之后,直接推入一个任务运行
        //这里的1s延迟是因为开启encoder之后,硬件编码器进行初始化需要点时间
         workHanlder.postDelayed(new Runnable() {
                @Override
                public void run() {
                    doExtract(mEncoder,null);}, 1000);

注意是的是,这里推入任务,需要稍微的延迟,因为初始化和开启硬件编码器需要一点时间。

  • 获取编码后的数据
    /**
     * 不断循环获取,直到我们手动结束.同步的方式
     * @param encoder       编码器
     * @param frameCallback 获取的回调
     */
    private void doExtract(MediaCodec encoder,
                           FrameCallback frameCallback) {
        final int TIMEOUT_USEC = 10000;
        long firstInputTimeNsec = -1;
        boolean outputDone = false;
        //没有手动停止,就只能不断进行
        while (!outputDone) {
            //如果手动停止了。就结束吧
            if (mIsStopRequested) {
                Log.d(TAG, "Stop requested");
                return;
            }
            //因为给编码器获取状态和喂数据的方法都直接通过Surface直接进行了,这里只要直接获取解码后的状态就可以了
            int decoderStatus = encoder.dequeueOutputBuffer(mBufferInfo, TIMEOUT_USEC);
            if (decoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) {
                // no output available yet
//                if (VERBOSE) Log.d(TAG, "no output from decoder available");
            } else if (decoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
                // not important for us, since we're using Surface
//                if (VERBOSE) Log.d(TAG, "decoder output buffers changed");
            } else if (decoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                //上面几种状态,我们都可以直接忽略。这里是进行MediaCodec开始编码后,会得到一个有cs-0 和cs-1的数据,对应sps和pps .获取之后,我们后面需要处理,所以先设置成一个回调就好。
                MediaFormat newFormat = encoder.getOutputFormat();
                if (VERBOSE) Log.d(TAG, "decoder output format changed: " + newFormat);
                if (frameCallback != null) {
                    frameCallback.formatChange(newFormat);
                }
            } else if (decoderStatus < 0) {
                //这种情况下是出错了。暂时先直接出异常吧
                throw new RuntimeException(
                        "unexpected result from decoder.dequeueOutputBuffer: " +
                                decoderStatus);
            } else { // decoderStatus >= 0
                //这里是正确获取到编码后的数据了
                if (firstInputTimeNsec != 0) {
                    long nowNsec = System.nanoTime();
                    Log.d(TAG, "startup lag " + ((nowNsec - firstInputTimeNsec) / 1000000.0) + " ms");
                    firstInputTimeNsec = 0;
                }
                if (VERBOSE) Log.d(TAG, "surface decoder given buffer " + decoderStatus +
                        " (size=" + mBufferInfo.size + ")");
                //获取到最后的数据了。这里就跳出循环。我们这个地方基本也不用用到
                if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
                    if (VERBOSE) Log.d(TAG, "output EOS");
                    outputDone = true;
                }
                
                //当size 大于0时,需要送显
                boolean doRender = (mBufferInfo.size != 0);
                //这个时候,来获取编码后的buffer,回调给外面
                if (doRender && frameCallback != null) {
                    ByteBuffer outputBuffer = encoder.getOutputBuffer(decoderStatus);
                    frameCallback.render(mBufferInfo, outputBuffer);
                }
                encoder.releaseOutputBuffer(decoderStatus, doRender);
            }
        }
    }

通过这样的循环获取,就可以通过回调获取编码后的数据了。
后面,我们可以将编码后的数据进行让rtmp推流。


使用 RTMP 推流

  1. 认识 rtmp 协议
  2. RMTP Connection
  3. 代码

1. 认识 rtmp 协议

RTMP协议是Real Time Message Protocol(实时信息传输协议)的缩写,它是由Adobe公司提出的一种应用层的协议,用来解决多媒体数据传输流的多路复用(Multiplexing)和分包(packetizing)的问题。

  • 基于TCP
    在基于传输层协议的链接建立完成后,RTMP协议也要客户端和服务器通过“握手”来建立基于传输层链接之上的RTMP Connection链接。在Connection链接上会传输一些控制信息,如SetChunkSize,SetACKWindowSize。其中CreateStream命令会创建一个Stream链接,用于传输具体的音视频数据和控制这些信息传输的命令信息。RTMP协议传输时会对数据做自己的格式化,这种格式的消息我们称之为RTMP Message,而实际传输的时候为了更好地实现多路复用、分包和信息的公平性,发送端会把Message划分为带有Message ID的Chunk,每个Chunk可能是一个单独的Message,也可能是Message的一部分,在接受端会根据chunk中包含的data的长度,message id和message的长度把chunk还原成完整的Message,从而实现信息的收发。

2. RTMP Connection

握手(HandShake)

一个RTMP连接以握手开始,双方分别发送大小固定的三个数据块

  1. 握手开始于客户端发送C0、C1块。服务器收到C0或C1后发送S0和S1。
  2. 当客户端收齐S0和S1后,开始发送C2。当服务器收齐C0和C1后,开始发送S2。
  3. 当客户端和服务器分别收到S2和C2后,握手完成。
image

理论上来讲只要满足以上条件,如何安排6个Message的顺序都是可以的,但实际实现中为了在保证握手的身份验证功能的基础上尽量减少通信的次数,一般的发送顺序是这样的:

  1. Client发送C0+C1到Sever
  2. Server发送S0+S1+S2到Client
  3. Client发送C2到Server,握手完成
建立网络连接(NetConnection)
  1. 客户端发送命令消息中的“连接”(connect)到服务器,请求与一个服务应用实例建立连接。
  2. 服务器接收到连接命令消息后,发送确认窗口大小(Window Acknowledgement Size)协议消息到客户端,同时连接到连接命令中提到的应用程序。
  3. 服务器发送设置带宽(Set Peer Bandwitdh)协议消息到客户端。
  4. 客户端处理设置带宽协议消息后,发送确认窗口大小(Window Acknowledgement Size)协议消息到服务器端。
  5. 服务器发送用户控制消息中的“流开始”(Stream Begin)消息到客户端。
  6. 服务器发送命令消息中的“结果”(_result),通知客户端连接的状态。
  7. 客户端在收到服务器发来的消息后,返回确认窗口大小,此时网络连接创建完成。

服务器在收到客户端发送的连接请求后发送如下信息:

image

主要是告诉客户端确认窗口大小,设置节点带宽,然后服务器把“连接”连接到指定的应用并返回结果,“网络连接成功”。并且返回流开始的的消息(Stream Begin 0)。

建立网络流(NetStream)
  1. 客户端发送命令消息中的“创建流”(createStream)命令到服务器端。
  2. 服务器端接收到“创建流”命令后,发送命令消息中的“结果”(_result),通知客户端流的状态。
推流流程
  1. 客户端发送publish推流指令。
  2. 服务器发送用户控制消息中的“流开始”(Stream Begin)消息到客户端。
  3. 客户端发送元数据(分辨率、帧率、音频采样率、音频码率等等)。
  4. 客户端发送音频数据。
  5. 客户端发送服务器发送设置块大小(ChunkSize)协议消息。
  6. 服务器发送命令消息中的“结果”(_result),通知客户端推送的状态。
  7. 客户端收到后,发送视频数据直到结束。
推流流程
播流流程
  1. 客户端发送命令消息中的“播放”(play)命令到服务器。
  2. 接收到播放命令后,服务器发送设置块大小(ChunkSize)协议消息。
  3. 服务器发送用户控制消息中的“streambegin”,告知客户端流ID。
  4. 播放命令成功的话,服务器发送命令消息中的“响应状态” NetStream.Play.Start & NetStream.Play.reset,告知客户端“播放”命令执行成功。
  5. 在此之后服务器发送客户端要播放的音频和视频数据。
播流流程

3. 代码集成

1. 集成RTMP

直接使用librestreaming 中的RTMP的代码,将其放到CMake中进行编译。

  • 将项目中的librtmp到 libs下


    image.png
  • 根据原来的Android.mk文件,配置CMakeList
cmake_minimum_required(VERSION 3.4.1)
add_definitions("-DNO_CRYPTO")
include_directories(${CMAKE_SOURCE_DIR}/libs/rtmp/librtmp)
#native-lib
file(GLOB PROJECT_SOURCES "${CMAKE_SOURCE_DIR}/libs/rtmp/librtmp/*.c")
add_library(rtmp-lib
        SHARED
        src/main/cpp/rtmp-hanlde.cpp
        ${PROJECT_SOURCES}
        )
find_library( # Sets the name of the path variable.
        log-lib
        log)
target_link_libraries( # Specifies the target library.
        rtmp-lib
        ${log-lib})
  • 创建java文件,并编写jni
public class RtmpClient {
    static {
        System.loadLibrary("rtmp-lib");
    }

    /**
     * @param url
     * @param isPublishMode
     * @return rtmpPointer ,pointer to native rtmp struct
     */
    public static native long open(String url, boolean isPublishMode);
    public static native int write(long rtmpPointer, byte[] data, int size, int type, int ts);
    public static native int close(long rtmpPointer);
    public static native String getIpAddr(long rtmpPointer);
}

2. RMTP推流

之前的文章,有分析过FLV的数据格式。这样还需要再将编码后的数据。
这里就不赘述了。

RTMP连接部分整体的流程
  1. 连接RTMP URL
    整体的连接的过程。上面的了解也有提到过。
   const char *url = env->GetStringUTFChars(url_, 0);
    LOGD("RTMP_OPENING:%s", url);
    //分配RTMP对象
    RTMP *rtmp = RTMP_Alloc();
    if (rtmp == NULL) {
        LOGD("RTMP_Alloc=NULL");
        return NULL;
    }
    
    //初始化RTMP
    RTMP_Init(rtmp);
    int ret = RTMP_SetupURL(rtmp, const_cast<char *>(url));

    if (!ret) {
        RTMP_Free(rtmp);
        rtmp = NULL;
        LOGD("RTMP_SetupURL=ret");
        return NULL;
    }
    if (isPublishMode) {
        RTMP_EnableWrite(rtmp);
    }
    //2. 开始Connect 。建立网络连接的过程。其中包括握手
    ret = RTMP_Connect(rtmp, NULL);
    if (!ret) {
        RTMP_Free(rtmp);
        rtmp = NULL;
        LOGD("RTMP_Connect=ret");
        return NULL;
    }
    //3. create stream 建立网络流的过程
    ret = RTMP_ConnectStream(rtmp, 0);

    if (!ret) {
        ret = RTMP_ConnectStream(rtmp, 0);
        RTMP_Close(rtmp);
        RTMP_Free(rtmp);
        rtmp = NULL;
        LOGD("RTMP_ConnectStream=ret");
        return NULL;
    }
    env->ReleaseStringUTFChars(url_, url);
    LOGD("RTMP_OPENED");
  1. 在得到MediaFormat回调时,将其进行推流发送,进行publish
  2. 不断得到编码后的数据,不断推流
    这两者主要的不同,在编码上就是type不同。我们知道第一个message必须为一个完整的message,必须为meta_data才可以。
  jbyte *buffer = env->GetByteArrayElements(data_, NULL);
    LOGD("start write");
    RTMPPacket *packet = (RTMPPacket *) malloc(sizeof(RTMPPacket));
    RTMPPacket_Alloc(packet, size);
    RTMPPacket_Reset(packet);
    if (type == RTMP_PACKET_TYPE_INFO) { // metadata
        packet->m_nChannel = 0x03;
    } else if (type == RTMP_PACKET_TYPE_VIDEO) { // video
        packet->m_nChannel = 0x04;
    } else if (type == RTMP_PACKET_TYPE_AUDIO) { //audio
        packet->m_nChannel = 0x05;
    } else {
        packet->m_nChannel = -1;
    }
    RTMP *r = (RTMP *) rtmpPointer;
    packet->m_nInfoField2 = r->m_stream_id;

    LOGD("write data type: %d, ts %d", type, ts);

    memcpy(packet->m_body, buffer, size);
    packet->m_headerType = RTMP_PACKET_SIZE_LARGE;
    packet->m_hasAbsTimestamp = FALSE;
    packet->m_nTimeStamp = ts;
    packet->m_packetType = type;
    packet->m_nBodySize = size;
    int ret = RTMP_SendPacket((RTMP *) rtmpPointer, packet, 0);
    RTMPPacket_Free(packet);
    free(packet);
    env->ReleaseByteArrayElements(data_, buffer, 0);
    if (!ret) {
        LOGD("end write error %d", ret);
        return ret;
    } else {
        LOGD("end write success");
        return 0;
    }
  1. 最后关闭
    RTMP_Close((RTMP *) rtmpPointer);
    RTMP_Free((RTMP *) rtmpPointer);
接受编码后的数据回调
  workHanlder.postDelayed(new Runnable() {
                @Override
                public void run() {
                    doExtract(mEncoder, new FrameCallback() {

                        @Override
                        public void render(MediaCodec.BufferInfo info, ByteBuffer outputBuffer) {
                            Sender.getInstance().rtmpSend(info, outputBuffer);
                        }

                        @Override
                        public void formatChange(MediaFormat mediaFormat) {
                            Sender.getInstance().rtmpSendFormat(mediaFormat);
                        }
                    });
                }
            }, 1000);
通过回调MediaFormat

之前对flv的格式详解,我们知道要实现flv推流。
需要将cs0 和cs1的头部位置进行推流才能正常显示。并且必须作为第一条信息。
这里通过这方法读取cs0 和cs1

public static byte[] generateAVCDecoderConfigurationRecord(MediaFormat mediaFormat) {
            ByteBuffer SPSByteBuff = mediaFormat.getByteBuffer("csd-0");
            SPSByteBuff.position(4);
            ByteBuffer PPSByteBuff = mediaFormat.getByteBuffer("csd-1");
            PPSByteBuff.position(4);
            int spslength = SPSByteBuff.remaining();
            int ppslength = PPSByteBuff.remaining();
            int length = 11 + spslength + ppslength;
            byte[] result = new byte[length];
            SPSByteBuff.get(result, 8, spslength);
            PPSByteBuff.get(result, 8 + spslength + 3, ppslength);
            /**
             * UB[8]configurationVersion
             * UB[8]AVCProfileIndication
             * UB[8]profile_compatibility
             * UB[8]AVCLevelIndication
             * UB[8]lengthSizeMinusOne
             */
            result[0] = 0x01;
            result[1] = result[9];
            result[2] = result[10];
            result[3] = result[11];
            result[4] = (byte) 0xFF;
            /**
             * UB[8]numOfSequenceParameterSets
             * UB[16]sequenceParameterSetLength
             */
            result[5] = (byte) 0xE1;
            ByteArrayTools.intToByteArrayTwoByte(result, 6, spslength);
            /**
             * UB[8]numOfPictureParameterSets
             * UB[16]pictureParameterSetLength
             */
            int pos = 8 + spslength;
            result[pos] = (byte) 0x01;
            ByteArrayTools.intToByteArrayTwoByte(result, pos + 1, ppslength);

            return result;
        }

根据flv格式的分析。填充到flv中

 public static void fillFlvVideoTag(byte[] dst, int pos, boolean isAVCSequenceHeader, boolean isIDR, int readDataLength) {
            //FrameType&CodecID
            dst[pos] = isIDR ? (byte) 0x17 : (byte) 0x27;
            //AVCPacketType
            dst[pos + 1] = isAVCSequenceHeader ? (byte) 0x00 : (byte) 0x01;
            //LAKETODO CompositionTime
            dst[pos + 2] = 0x00;
            dst[pos + 3] = 0x00;
            dst[pos + 4] = 0x00;
            if (!isAVCSequenceHeader) {
                //NALU HEADER
                ByteArrayTools.intToByteArrayFull(dst, pos + 5, readDataLength);
            }
        }

然后发送。

发送实际数据
 public static RESFlvData sendRealData(long tms, ByteBuffer realData) {
        int realDataLength = realData.remaining();
        int packetLen = Packager.FLVPackager.FLV_VIDEO_TAG_LENGTH +
                Packager.FLVPackager.NALU_HEADER_LENGTH +
                realDataLength;
        byte[] finalBuff = new byte[packetLen];
        realData.get(finalBuff, Packager.FLVPackager.FLV_VIDEO_TAG_LENGTH +
                        Packager.FLVPackager.NALU_HEADER_LENGTH,
                realDataLength);
        int frameType = finalBuff[Packager.FLVPackager.FLV_VIDEO_TAG_LENGTH +
                Packager.FLVPackager.NALU_HEADER_LENGTH] & 0x1F;
        Packager.FLVPackager.fillFlvVideoTag(finalBuff,
                0,
                false,
                frameType == 5,
                realDataLength);
        RESFlvData resFlvData = new RESFlvData();
        resFlvData.droppable = true;
        resFlvData.byteBuffer = finalBuff;
        resFlvData.size = finalBuff.length;
        resFlvData.dts = (int) tms;
        resFlvData.flvTagType = RESFlvData.FLV_RTMP_PACKET_TYPE_VIDEO;
        resFlvData.videoFrameType = frameType;
        return resFlvData;
//        dataCollecter.collect(resFlvData, RESRtmpSender.FROM_VIDEO);
    }

RMTP服务器

RMTP服务器的建立,可以简单的使用
RMTP服务器

总结

对比之前的一遍文章

Android PC投屏简单尝试

  • 获取数据的方式
    都是通过MediaProjection.createVirtualDisplay的方式来获取截屏的数据。
    不同的是,上一边文章使用ImageReader来获取一张一张的截图。
    而这边文章直接是用了MediaCodec硬编码,直接得到编码后的h264数据。

  • 传输协议
    上一边文章使用的webSocket,将得到的Bitmap的字节流,通过socket传输,接收方,只要接受到Socket,并且将其解析成Bitmap来展示就可以。
    优点是方便,而且可以自定义协议内容。
    但是缺点是,不能通用,必须编写对应的客户端才能完成。
    这边文章使用了rtmp的流媒体协议,优点是只要支持该协议的播放器都可以直接播放我们的投屏流。

参考文章

Android实现录屏直播(一)ScreenRecorder的简单分析
直播推流实现RTMP协议的一些注意事项

投屏尝试系列文章

相关文章

网友评论

    本文标题:Android PC投屏简单尝试(录屏直播)2—硬解章(Medi

    本文链接:https://www.haomeiwen.com/subject/kpmcgftx.html