zoukankan      html  css  js  c++  java
  • Android实现录屏直播(三)MediaProjection + VirtualDisplay + librtmp + MediaCodec实现视频编码并推流到rtmp服务器

    请尊重分享成果,转载请注明出处,本文来自Coder包子哥,原文链接:http://blog.csdn.net/zxccxzzxz/article/details/55230272

    Android实现录屏直播(一)ScreenRecorder的简单分析

    Android实现录屏直播(二)需求才是硬道理之产品功能调研

    看到有网友在后台私信和询问录屏这部分推流相关的问题,感觉这篇博客早该写完了。事实上除了繁忙的工作加上春节假期一下子拖了近一个月之久。近期更新了Demo,加入了视频帧推流,需要的朋友可以看看Demo。

    无论是音频还是视频编码,我们都需要原始的数据源,拿视频举例子,实际录屏直播也只是将屏幕的每一个视图间接的获取充当原始视频帧,和摄像头获取视频帧原理区别不大。如今Android设备几乎都已经满足硬编码的条件了(虽然有好有坏),所以我们假装曾经那些兼容性问题都不存在。

    我们向服务器发送视频数据的时候,还需要先发送视频参数的数据给服务器以区分我们的视频源的格式、类型及FLV封装的一些关键信息(File Header / File TAG等如sps / pps等相关的meta data)给解码器。(PS: 关于FLV格式封装等视频编解码的分析推荐看看雷博的相关博文)MediaCodec的细节可以自行查阅官方API。

    并且推荐这些对我极为有用的文章资料,感谢这些作者的无私分享:

    作者自行封装及实现的一个Android实施滤镜、RTMP推流的类库。代码结构需要花点时间理解和读懂,值得深入学习其中的实现,因为使用MediaProjection / VirtualDisplay 来进行录屏的话,官方并不提供帧率的控制,这需要用到OpenGL ES将VirtualDisplay中的surface进行绘制到MediaCodec中的surface。然而在这个库中作者已经实现了全部的操作,本篇文章也会围绕该库进行大致的分析

    在找到上面的类库之前,还是这位作者给出的思路才能够一点点往OpenGL ES这个坑里跳,越入越深,差点没爬出来。由于作者不方便放出源码,我只能通过他的描述一点点的实现。并且StackOverFlow中网友fadden也给出了相关的思路:controlling-frame-rate-of-virtualdisplay

    Google官方给的Demo,基本涵盖了OpenGL的各类用法,好好看看吧。

    一个Android MediaCodec的超详细的博客,实例Demo很明确。

    问题的缘由来自工作中某些需求所引起的,接下来我一一描述。

    需求一 帧率控制

    好不容易开发完成录屏直播,结果在低码率或者网络波动大的情况下,很多机型(尤其是小米)在60帧满帧的条件打出来的视频是那样的酸爽,动态的画面简直眼瞎。老板要求改帧率!降低帧率到30看看什么情况,最后实际选择使用了15FPS。

    在快速滑动屏幕或者画面变换频繁的情况下改善视频模糊的做法:(参见https://github.com/lakeinchina/librestreaming/issues/11)

    • 提高码率BPS
    • 降低帧率
    • 调小关键帧间隔(MediaFormat.KEY_I_FRAME_INTERVAL)
    • 提高AVCProfile(Android默认使用的是BaseLine模式)
    • 编码器的好坏也有关系

    需求二 稳定性(保活)

    性能比起Bilibili还是要差一些,这里挖个坑,之后再填。

    后期设定的方案是将推流放到remote service当中,该service为前台独立进程的service,对主进程的依赖性减弱一些(虽然APP在被杀死的时候也可能被杀死)

    通过开启远程服务并与APP的进程进行进程间通信(IPC),寻求保活的方式花了一段时间,最后对MIUI的系统机制还是无果,Debug的时候发现MIUI拥有一个PowerKeeper,一旦触发就会对任何后台进程的APP(据说有白名单)进行KillApplication操作,在我的压力测试下,无一应用幸免(包括优化得极其稳定的Bilibili,GooglePlay录屏APP排行第一的AZ ScreenRecorder)。

    近期涉及到的技术知识点:

    • Java多线程并发(线程之间的通信,并发时锁的使用)
    • OpenGL ES(对Surface与Surface之间的数据传递)
    • Android消息机制的深入理解(Handler内部实现及熟练运用)

    首先推荐看看这些对我极为有用的文章资料,感谢这些作者的无私分享:

    librestreaming

    作者自行封装及实现的一个Android实施滤镜、RTMP推流的类库。代码结构需要花点时间理解和读懂,值得深入学习其中的实现,因为使用MediaProjection / VirtualDisplay 来进行录屏的话,官方并不提供帧率的控制,这需要用到OpenGL ES将VirtualDisplay中的surface进行绘制到MediaCodec中的surface。然而在这个库中作者已经实现了全部的操作,本篇文章也会围绕该库进行大致的分析

    屏幕录制(二)——帧率控制

    在找到上面的类库之前,还是这位作者给出的思路才能够一点点往OpenGL ES这个坑里跳,越入越深,差点没爬出来。由于作者不方便放出源码,我只能通过他的描述一点点的实现。并且StackOverFlow中网友fadden也给出了相关的思路:controlling-frame-rate-of-virtualdisplay

    grafika

    Google官方给的Demo,基本涵盖了OpenGL的各类用法,好好看看吧。

    Android MediaCodec stuff

    一个Android MediaCodec的超详细的博客,实例Demo很明确。

    Updated 3.12

    之前阿里云搞活动,12块买了个1核2G / 1M 半年的服务器,正好一直闲置没用,为了完成这篇博客我也真是够拼的了,先按照上述链接搭建一个基于Nginx + RTMP协议的流媒体服务器。搭服务器的目的是为了完成推流的操作,毕竟不想用公司的资源来进行私人的活动。

    很多朋友都问推流什么时候才有,那么今天我就完完整整的将录屏推流这块完善,Android客户端的Demo + 推流服务器的步骤实现,时间有限,只注重实现,代码质量之后重构。

    丑话再说在前头,我本着一颗开源分享和学习的心来写博客和Demo,认为好的点赞、评论大家随意,但是本人能力和精力有限,这本属于一个Demo,如果认为太烂没参考价值,那么还请留点口德,默默关闭本页即可,有问题提出来我会回复并以改正,请求勿喷,谢谢~

    Demo结构

    大概将会包含几个部分:

    1. 录屏 : 客户端实现(官方API,目前仅支持Android 5.0以上)
    2. 原始帧的格式转换,分辨率、方向转换:libyuv,这里没有使用librestreaming中的封装方法。(录屏在使用OpenGL绘制之前直接使用的是API配置的参数,故暂时跳过该步骤
    3. FLV的格式封装: 套用librestreaming中的算法(Java),实际我的项目中是通过一种不巧妙地算法将sps / pps 硬拼凑出来的。虽然也是同样的原理,但个人更推荐该库的处理方式,代码清晰易懂。
    4. rtmp推流:前期先准备好相应的librtmp的源码,构建项目的同时进行引用编译成静态库,本次直接引用librestreaming中的代码稍作修改,有关librtmp更详细的内容可参见雷博的文章: http://blog.csdn.net/leixiaohua1020/article/details/42104945

    录屏推流的流程:

    客户端原始帧编码为H264裸流(MediaCodec,Android API) —> 再封装为FLV格式的视频流(Java) —> 按照rtmp流媒体协议通过librtmp(JNI + C)推流到RTMP流媒体服务器 —> 客户端播放器解码观看

    注意:

    Demo省略了librestreaming中的OpenGL处理帧率的过程,也就意味着我们使用的是MediaCodec直接编码后的数据,并没有OpenGL绘制VirtualDisplay映射给MediaCodec.createInputSurface中Surface的这个过程,目的是将流程简单化。OpenGL方面的使用之后我也会介绍说明。

    可以看到实现录屏到本地的ScreenRecorder直接通过下面的方法进行音视频写入,而推流的话需要做以修改。

    mMuxer.writeSampleData(mVideoTrackIndex, encodedData, mBufferInfo);
    

    FLV Header的获取(SPS PPS)

    FLV的头文件信息发送给服务器后,就可以将我们的关键帧发送,注意流媒体服务器解析的时候首先要先得到第一帧关键帧才会开始解析后面的视频帧,所以我们还需要在编码器获取IDR帧的时候进行发送。MediaCodec的INFO_OUTPUT_FORMAT_CHANGED这个状态可以获取sps / pps,再将数据处理包装后打到FLV的TAG中。

    private void sendAVCDecoderConfigurationRecord(long tms, MediaFormat format) {
        byte[] AVCDecoderConfigurationRecord = Packager.H264Packager.generateAVCDecoderConfigurationRecord(format);
        int packetLen = Packager.FLVPackager.FLV_VIDEO_TAG_LENGTH +
                AVCDecoderConfigurationRecord.length;
        byte[] finalBuff = new byte[packetLen];
        Packager.FLVPackager.fillFlvVideoTag(finalBuff,
                0,
                true,
                true,
                AVCDecoderConfigurationRecord.length);
        System.arraycopy(AVCDecoderConfigurationRecord, 0,
                finalBuff, Packager.FLVPackager.FLV_VIDEO_TAG_LENGTH, AVCDecoderConfigurationRecord.length);
        RESFlvData resFlvData = new RESFlvData();
        resFlvData.droppable = false;
        resFlvData.byteBuffer = finalBuff;
        resFlvData.size = finalBuff.length;
        resFlvData.dts = (int) tms;
        resFlvData.flvTagType = RESFlvData.FLV_RTMP_PACKET_TYPE_VIDEO;
        resFlvData.videoFrameType = RESFlvData.NALU_TYPE_IDR;
        dataCollecter.collect(resFlvData, RESRtmpSender.FROM_VIDEO);
    }
    
    
    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;
    }
    
    

    在librestreaming中Packager.java这个类主要就是做了上面这些事。仅两个方法实现,作者代码逻辑清晰易懂,这里就不多说了,再次感谢Lake哥!

    可以看到音视频编码线程共用同一个RESFlvDataCollecter接口,负责监听编码线程的一举一动,从接口中将音视频编码帧送到同一个帧队列,发送线程取数据时取到什么就把数据喂给RtmpStreamingSender发送出去。

    dataCollecter = new RESFlvDataCollecter() {
        @Override
        public void collect(RESFlvData flvData, int type) {
            rtmpSender.feed(flvData, type);
        }
    };
    

    在librestreaming中使用了HandlerThread为WorkHandler提供Looper,通过Handler的消息队列循环机制来控制数据的发送,实际也可以自定义线程,手动管理视频帧收发队列,但涉及到了并发抢占资源的问题,更推荐Handler这种方式,并且Handler处理除了效率高、逻辑清晰,易管理之外还有一个好处,如果使用OpenGL绘制Surface时,正好可以Handler处理其中的异步操作。

    不过在Demo中我修改为了一个普通的Runnable任务,run()中循环处理frameQueue中的数据。代码如下:

    public class RtmpStreamingSender implements Runnable {
        private static final int MAX_QUEUE_CAPACITY = 50;
        private AtomicBoolean mQuit = new AtomicBoolean(false);
        private LinkedBlockingDeque<RESFlvData> frameQueue = new LinkedBlockingDeque<>(MAX_QUEUE_CAPACITY);
        private final Object syncWriteMsgNum = new Object();
        private FLvMetaData fLvMetaData;
        private RESCoreParameters coreParameters; 
        private volatile int state;
    
        private long jniRtmpPointer = 0;
        private int maxQueueLength = 150;
        private int writeMsgNum = 0;
        private String rtmpAddr = null;
    
        private static class STATE {
            private static final int START = 0;
            private static final int RUNNING = 1;
            private static final int STOPPED = 2;
        }
    
        public RtmpStreamingSender() {
            coreParameters = new RESCoreParameters();
            coreParameters.mediacodecAACBitRate = 32 * 1024;
            coreParameters.mediacodecAACSampleRate = 44100;
            coreParameters.mediacodecAVCFrameRate = 20;
            coreParameters.videoWidth = 1280;
            coreParameters.videoHeight = 720;
            fLvMetaData = new FLvMetaData(coreParameters);
        }
    
        @Override
        public void run() {
            while (!mQuit.get()) {
                if (frameQueue.size() > 0) {
                    switch (state) {
                        case STATE.START:
                            LogTools.d("RESRtmpSender,WorkHandler,tid=" + Thread.currentThread().getId());
                            if (TextUtils.isEmpty(rtmpAddr)) {
                                LogTools.e("rtmp address is null!");
                                break;
                            }
                            jniRtmpPointer = RtmpClient.open(rtmpAddr, true);
                            final int openR = jniRtmpPointer == 0 ? 1 : 0;
                            String serverIpAddr = null;
                            if (openR == 0) {
                                serverIpAddr = RtmpClient.getIpAddr(jniRtmpPointer);
                                LogTools.d("server ip address = " + serverIpAddr);
                            }
                            if (jniRtmpPointer == 0) {
                                break;
                            } else {
                                byte[] MetaData = fLvMetaData.getMetaData();
                                RtmpClient.write(jniRtmpPointer,
                                        MetaData,
                                        MetaData.length,
                                        RESFlvData.FLV_RTMP_PACKET_TYPE_INFO, 0);
                                state = STATE.RUNNING;
                            }
                            break;
                        case STATE.RUNNING:
                            synchronized (syncWriteMsgNum) {
                                --writeMsgNum;
                            }
                            if (state != STATE.RUNNING) {
                                break;
                            }
                            RESFlvData flvData = frameQueue.pop();
                            if (writeMsgNum >= (maxQueueLength * 2 / 3) && flvData.flvTagType == RESFlvData.FLV_RTMP_PACKET_TYPE_VIDEO && flvData.droppable) {
                                LogTools.d("senderQueue is crowded,abandon video");
                                break;
                            }
                            final int res = RtmpClient.write(jniRtmpPointer, flvData.byteBuffer, flvData.byteBuffer.length, flvData.flvTagType, flvData.dts);
                            if (res == 0) {
                                if (flvData.flvTagType == RESFlvData.FLV_RTMP_PACKET_TYPE_VIDEO) {
                                    LogTools.d("video frame sent = " + flvData.size);
                                } else {
                                    LogTools.d("audio frame sent = " + flvData.size);
                                }
                            } else {
                                LogTools.e("writeError = " + res);
                            }
                            break;
                        case STATE.STOPPED:
                            if (state == STATE.STOPPED || jniRtmpPointer == 0) {
                                break;
                            }
                            final int closeR = RtmpClient.close(jniRtmpPointer);
                            serverIpAddr = null;
                            LogTools.e("close result = " + closeR);
                            break;
                    }
                }
            }
        }
    
        public void sendStart(String rtmpAddr) {
            synchronized (syncWriteMsgNum) {
                writeMsgNum = 0;
            }
            this.rtmpAddr = rtmpAddr;
            state = STATE.START;
        }
    
        public void sendStop() {
            synchronized (syncWriteMsgNum) {
                writeMsgNum = 0;
            }
            state = STATE.STOPPED;
        }
    
        public void sendFood(RESFlvData flvData, int type) {
            synchronized (syncWriteMsgNum) {
                //LAKETODO optimize
                if (writeMsgNum <= maxQueueLength) {
                    frameQueue.add(flvData);
                    ++writeMsgNum;
                } else {
                    LogTools.d("senderQueue is full,abandon");
                }
            }
        }
    
        public final void quit() {
            mQuit.set(true);
        }
    }
    

    RtmpStreamingSender.java这个类的大部分方法都引入了librestreaming中RESRtmpSender.java类中的代码实现,只是将其改为了之前所说的线程循环机制,并没有用Handler。

    RTMP package的封装与使用

    RtmpClient中包含了jni的native方法,可以看到有对应了screenrecorderrtmp.h中的几个方法:

    public static native long open(String url, boolean isPublishMode);
    public static native int read(long rtmpPointer, byte[] data, int offset, int size);
    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);
    

    我们可根据RtmpClient.java这个Jni入口类生成 jni的c文件screenrecorderrtmp.h,再到项目的java目录,使用以下命令在同级目录下创建一个jni/screenrecorderrtmp.h 文件。

    javah -d jni net.yrom.screenrecorder.rtmp.RtmpClient
    

    接着对应h文件编写c的rtmp推流代码,screenrecorderrtmp.c 如下:

    #include <jni.h>
    #include <screenrecorderrtmp.h>
    #include <malloc.h>
    #include "rtmp.h"
    
    JNIEXPORT jlong JNICALL Java_net_yrom_screenrecorder_rtmp_RtmpClient_open
     (JNIEnv * env, jobject thiz, jstring url_, jboolean isPublishMode) {
       	const char *url = (*env)->GetStringUTFChars(env, url_, 0);
       	LOGD("RTMP_OPENING:%s",url);
       	RTMP* rtmp = RTMP_Alloc();
       	if (rtmp == NULL) {
       		LOGD("RTMP_Alloc=NULL");
       		return NULL;
       	}
    
       	RTMP_Init(rtmp);
       	int ret = RTMP_SetupURL(rtmp, url);
    
       	if (!ret) {
       		RTMP_Free(rtmp);
       		rtmp=NULL;
       		LOGD("RTMP_SetupURL=ret");
       		return NULL;
       	}
       	if (isPublishMode) {
       		RTMP_EnableWrite(rtmp);
       	}
    
       	ret = RTMP_Connect(rtmp, NULL);
       	if (!ret) {
       		RTMP_Free(rtmp);
       		rtmp=NULL;
       		LOGD("RTMP_Connect=ret");
       		return NULL;
       	}
       	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(env, url_, url);
       	LOGD("RTMP_OPENED");
       	return rtmp;
    }
    
    
    /*
     * Class:     net_yrom_screenrecorder_rtmp_RtmpClient
     * Method:    read
     * Signature: (J[BII)I
     */
    JNIEXPORT jint JNICALL Java_net_yrom_screenrecorder_rtmp_RtmpClient_read
    (JNIEnv * env, jobject thiz,jlong rtmp, jbyteArray data_, jint offset, jint size) {
    
     	char* data = malloc(size*sizeof(char));
    
     	int readCount = RTMP_Read((RTMP*)rtmp, data, size);
    
     	if (readCount > 0) {
            (*env)->SetByteArrayRegion(env, data_, offset, readCount, data);  // copy
        }
        free(data);
    
        return readCount;
    }
    /*
     * Class:     net_yrom_screenrecorder_rtmp_RtmpClient
     * Method:    write
     * Signature: (J[BIII)I
     */
    JNIEXPORT jint JNICALL Java_net_yrom_screenrecorder_rtmp_RtmpClient_write
    (JNIEnv * env, jobject thiz,jlong rtmp, jbyteArray data, jint size, jint type, jint ts) {
     	LOGD("start write");
     	jbyte *buffer = (*env)->GetByteArrayElements(env, data, NULL);
     	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;
        }
    
        packet->m_nInfoField2  =  ((RTMP*)rtmp)->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*)rtmp, packet, 0);
        RTMPPacket_Free(packet);
        free(packet);
        (*env)->ReleaseByteArrayElements(env, data, buffer, 0);
        if (!ret) {
        	LOGD("end write error %d", sockerr);
    		return sockerr;
        }else
        {
        	LOGD("end write success");
    		return 0;
        }
    }
    /*
     * Class:     net_yrom_screenrecorder_rtmp_RtmpClient
     * Method:    close
     * Signature: (J)I
     */
    JNIEXPORT jint JNICALL Java_net_yrom_screenrecorder_rtmp_RtmpClient_close
     (JNIEnv * env,jlong rtmp, jobject thiz) {
     	RTMP_Close((RTMP*)rtmp);
     	RTMP_Free((RTMP*)rtmp);
     	return 0;
     }
    /*
     * Class:     net_yrom_screenrecorder_rtmp_RtmpClient
     * Method:    getIpAddr
     * Signature: (J)Ljava/lang/String;
     */
    JNIEXPORT jstring JNICALL Java_net_yrom_screenrecorder_rtmp_RtmpClient_getIpAddr
    	(JNIEnv * env,jobject thiz,jlong rtmp) {
    	if(rtmp!=0){
    		RTMP* r= (RTMP*)rtmp;
    		return (*env)->NewStringUTF(env, r->ipaddr);
    	}else {
    		return (*env)->NewStringUTF(env, "");
    	}
    }
    

    更多的请看Demo源码,说下目前未实现的功能和问题:

    • 音频编码还没有,只有视频的部分。考虑到音频可以通过FFmpeg等lib进行软编码,也可以MediaCodec硬编,需要的话可以自行加入
    • 有一定延迟,发送队列需要优化
    • 录屏的API有个坑,就是在屏幕内容没有变化的时候,是不会刷新绘制帧率的,也就是如果最后一帧是主页,而此时主页没有任何UI变化,那么MediaCodec会停止编码工作,直到屏幕上有任何UI变化(包括一像素)则继续编码。遇到这个问题当时想了一些办法感觉太复杂,所以我则在悬浮窗加了个闪烁的提示条,只要在录屏过程中提示条就会闪动,这样就巧妙的避免了上述问题的发生
    • 不可帧率控制,其实Demo大部分还是引用的是librestreaming的代码,因为时间有限,就不做过多的开发,还是推荐使用和理解该库的原理,并不复杂,并且已经实现了帧率控制,丢帧等处理。对OpenGL不熟悉的朋友可以先忽略这部分的实现,只看原始帧采集,编码器编码,FLV封装,推流这几个部分即可

    RTMP服务器

    参照Nginx + rtmp搭建了流媒体服务器用来测试,Demo中键入以下地址便可推流,yourstramingkey自定义,

    rtmp://59.110.159.133/live/<yourstramingkey>
    例如:rtmp://59.110.159.133/live/test
    

    效果图

    demo

    源码

    传送门:GitHub

    更多参考文章:

  • 相关阅读:
    selectHelper
    Windows Server 2003 下实现网络负载均衡(2) (转)
    顺序栈
    线性表链式存储
    线性表顺序存储
    Swift
    组件化
    swift
    Swift
    Swift
  • 原文地址:https://www.cnblogs.com/raomengyang/p/6544908.html
Copyright © 2011-2022 走看看