做 Android 音视频离不开 MediaCodec。实际上不局限于平台看问题,不管是 Linux、Mac OS、iOS、Android 还是 Window 这些系统,只要涉及到音视频编解码,无非要么软编解码、要么硬编解码,软编解码通常会使用瑞士军刀“FFmpeg”,主要靠 cpu 的算力去实现。硬编解码就要调用系统开放的 API 去使用硬件编解码。从效率上看,调用硬件编解码是最快的,通常会使用专用的硬件去处理编解码。尤其在嵌入式设备上通常 cpu 性能有限,想要达到满足要求的编解码需求,不用硬件编解码也是不现实的,从方案角度来考虑使用硬编解码是最合理的。当然如果我们的目标是形形色色的设备,比如开发一款视频播放器,那么首先要尝试硬解,在无法硬解的提前下应该使用软解。但如果我们开发的是特定硬件设备上的软件,那么硬编解码是首选,这会少走很多弯路,如果方案一开始选择了软编解码,很可能由于需求的变更,使得软件可扩展性变差,比如从 720p 解码变更到 1080p 解码,1080p 消耗 cpu 算力加大,这对软解码就是一种挑战,很可能软解码不能满足要求了,看吧,还得上硬解码。但硬编解码是受到硬件具体限制的,硬件不支持程序员无能为力。不过通常来看,使用硬编解码会释放 cpu 算力,对整个应用场景体验是正向的。

在 Android 平台上硬编解码 H.264 码流,需要调用 MediaCodec 这套 API。但由于不同的硬件搭载 Android OS,这就导致编解码能力千差万别,具体编解码支持程度如何,取决于硬件实现。就拿 rk3399 这颗瑞芯微的芯片来看,编解码能力如下:

Video Decoder

  • MMU embedded
  • Real-time video decoder of MPEG-1, MPEG-2, MPEG-4, H.263, H.264, H.265, VC-1, VP9, VP8, MVC
  • H.264/AVC,Base/Main/High/High10 profile @ level 5.1; up to 4Kx2K @ 30fps
  • H.265/HEVC, Main/Main10 profile @ level 5.1 High-tier; up to 4Kx2K @ 60fps
  • VP9, profile 0, up to 4Kx2K @ 60fps
  • MPEG-1, ISO/IEC 11172-2, up to 1080P @ 60fps
  • MPEG-2, ISO/IEC 13818-2, SP@ML, MP@HL, up to 1080P @ 60fps
  • MPEG-4, ISO/IEC 14496-2, SP@L0-3, ASP@L0-5, up to 1080P @ 60fps
  • VC-1, SP@ML, MP@HL, AP@L0-3, up to 1080P @ 60fps
  • MVC is supported based on H.264 or H.265, up to 1080P @ 60fps
  • Supports frame timeout interrupt, frame finish interrupt and bit stream error interrupt
  • Error detection and concealment support for all video formats
  • Output data format YUV420 semi-planar, YUV400(monochrome), YUV422 is supported by H.264
  • For MPEG-4, GMC (global motion compensation) not supported
  • For VC-1, up-scaling and range mapping are supported in image post-processor
  • For MPEG-4 SP/H.263, using a modified H.264 in-loop filter to implement deblocking filter in post-processor unit

Video Encoder

  • Support video encoder for H.264 UP to HP@level4.1, MVC and VP8
  • MMU Embedded
  • Only support I and P slices, not B slices
  • Support error resilience based on constrained intra prediction and slices
  • Input data format:
    YCbCr 4:2:0 planar
    YCbCr 4:2:0 semi-planar
    YCbYCr 4:2:2
    CbYCrY 4:2:2 interleaved
    RGB444 and BGR444
    RGB555 and BGR555
    RGB565 and BGR565
    RGB888 and BRG888
    RGB101010 and BRG101010
  • Image size is from 96x96 to 1920x1080(Full HD)
  • Maximum frame rate is up to 1920x1080@30FPS

就拿编解码 H.264 来看,解码最大支持 4Kx2K @ 30fps,编码最大支持 1920x1080@30FPS。

MediaCodec 这套 API 使用重点在于理解下面的两张图,一张为状态转移图,另一张为送入 buffer 和输出 buffer 内部如何运作。当然这两张图都是官方出品,这里只是搬过来温习一下。

hw_frames_ctx 硬编码 硬编码存在的问题_硬编码


hw_frames_ctx 硬编码 硬编码存在的问题_硬编码_02

一、硬解码

先上一段典型的硬解码代码。上代码前先回顾几个问题:

  1. 硬解码图像花屏了;
  2. 图像有点卡,不流畅;
  3. 停止硬解码后再次启用视频无输出;
  4. 硬解码断流了。

以上这些问题都是我实际项目中遇到的,以 NALU 单元仅为 I 帧 和 P 帧,不包含 B 帧为基础。

  1. 硬解码图像花屏了

首先要保证送到硬解码内部的 NALU 单元保持连贯,如果没有送关键帧,只是再送一些 P 帧,连续丢失了关键帧就会造成图像花屏的现象,所以解决这个问题思路有两个,先去分析前端释放 NALU 单元是否存在丢帧的现象,如果前端没有丢帧,就要考虑送到 MediaCodec 内部前是不是有丢失现象。NALU 单元保持连贯送入 MediaCodec 内部就不会出现花屏了。

  1. 图像有点卡,不流畅

图像卡顿不流畅,必然是硬解码处理不过来造成的,这就要考虑丢帧方案,关键帧不能丢,关键帧后面的 P 帧可以丢掉。丢帧方案一般只在偶尔极端情况下使用,如果一直频繁丢帧说明硬解码消费帧能力不足,硬件受限造成的图像卡顿,只能升级硬件解决。

  1. 停止硬解码后再次启用,视频无输出

一定要注意送到 MediaCodec 内部的 Surface 是不是被其他组件占用了,比如同时解码多路视频,停止一个 MediaCodec 对象之后,Surface 被另外一个占用,这个当然就没法再次启用,而且如果重新 configure 会报错。

  1. 硬解码断流了

断流问题可能是 NALU 源头本身没给数据过来了,还有可能是解码期间发出了错误停掉了。数据源头出了问题就要解决源头问题,如果是解码期间抛出异常,就要捕获到异常再次启用解码。

另外,MediaCodec configure(…)、start() 和 stop() 这些函数都有可能抛出异常,所以一定要注意捕获异常、处理异常,防止把程序直接挂掉。比如 MediaCodec 解码器低概率 stop 抛出异常 IllegalStateException。

下面是代码片段总体流程要点:

  1. 准备一个 LinkedBlockingQueue 队列来暂存 NALU 单元;
  2. 所有 MediaCodec API 使用都要围绕 try-catch 进行异常捕获;
  3. 极端情况丢帧应对;
  4. hw_frames_ctx 硬编码 硬编码存在的问题_音视频_03

  5. 解码期间抛出异常重启解码流程。

注意:下面这段代码没有考虑码流结束后刷出剩余的帧,如果剩余的帧是你的需求中需要的,则需要考虑。

public class Packet {

    private long pts;
    private byte[] data;
    private int size;
    private boolean isKeyFrame;

    public Packet(long pts, byte[] data, int size, boolean isKeyFrame) {
        this.pts = pts;
        this.data = data;
        this.size = size;
        this.isKeyFrame = isKeyFrame;
    }

    public long getPts() {
        return pts;
    }

    public void setPts(long pts) {
        this.pts = pts;
    }

    public byte[] getData() {
        return data;
    }

    public void setData(byte[] data) {
        this.data = data;
    }

    public int getSize() {
        return size;
    }

    public void setSize(int size) {
        this.size = size;
    }

    public boolean isKeyFrame() {
        return isKeyFrame;
    }

    public void setKeyFrame(boolean keyFrame) {
        isKeyFrame = keyFrame;
    }
}

public class H264Decoder {
    private static final String TAG = "H264Decoder";
    private final static String MIME_TYPE = "video/avc"; // H.264 Advanced Video

    private MediaCodec mDecoderMediaCodec;
    private SendDecodeThread mSendDecodeThread;

    private final LinkedBlockingQueue<Packet> mNALUQueue = new LinkedBlockingQueue<>(10);

    private int mFps;
    private Surface mSurface;
    private DecodeCallback mDecodeCallback;
    private int mWidth;
    private int mHeight;
    private int mInitMediaCodecTryTimes = 0;

    public H264Decoder(int width, int height, int fps, Surface surface) {
        mWidth = width;
        mHeight = height;
        mFps = fps;
        mSurface = surface;

        initMediaCodec(width, height, surface);
    }

    private void initMediaCodec(int width, int height, Surface surface) {
        try {
            mDecoderMediaCodec = MediaCodec.createDecoderByType(MIME_TYPE);
            //创建配置
            MediaFormat mediaFormat = MediaFormat.createVideoFormat(MIME_TYPE, width, height);
            mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible);
            mediaFormat.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline);
            mediaFormat.setInteger("level", MediaCodecInfo.CodecProfileLevel.AVCLevel4); // Level 4

            mDecoderMediaCodec.configure(mediaFormat, surface, null, 0);
        } catch (Exception e) {
            e.printStackTrace();
            //创建解码失败
            Log.e(TAG, "init MediaCodec fail.");
            // 重新尝试六次
            if (mInitMediaCodecTryTimes < 6) {
                mInitMediaCodecTryTimes++;
                try {
                    Thread.sleep(20);
                } catch (InterruptedException ie) {
                    ie.printStackTrace();
                }
                initMediaCodec(mWidth, mHeight, mSurface);
            }
        }
    }

    public void setDecodeCallback(DecodeCallback decodeCallback) {
        mDecodeCallback = decodeCallback;
    }

    /**
     * 开始解码
     */
    public void start() {
        if (mSendDecodeThread != null && mSendDecodeThread.isRunning()) {
            Log.e(TAG, "SendDecodeThread is already running");
            return;
        }

        try {
            mDecoderMediaCodec.start();

            mSendDecodeThread = new SendDecodeThread();
            mSendDecodeThread.setRunning(true);
            mSendDecodeThread.start();
            Log.d(TAG, "START");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 结束解码
     */
    public void stop() {
        if (mSendDecodeThread != null && mSendDecodeThread.isRunning()) {
            mSendDecodeThread.setRunning(false);
            mSendDecodeThread.interrupt();

            try {
                mSendDecodeThread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            mSendDecodeThread = null;
        }

        mNALUQueue.clear();

        if (mDecoderMediaCodec != null) {
            try {
                mDecoderMediaCodec.stop();
                mDecoderMediaCodec.release();
                mDecoderMediaCodec = null;
                Log.d(TAG, "STOP");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 送入数据
     *
     * @param packet
     */
    public void putData(Packet packet) {
        try {
            mNALUQueue.offer(packet);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private class SendDecodeThread extends Thread {

        private volatile boolean isRunning = true;
        private boolean isNeedDiscardFrame = false;

        @Override
        public void run() {
            if (null != mDecodeCallback) {
                mDecodeCallback.init();
            }

            MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();

            while (isRunning) {
                Packet packet;
                //Log.d(TAG, "NALUQueue size=" + mNALUQueue.size());
                if (mNALUQueue.size() > 6) {
                    packet = mNALUQueue.poll();
                    while (null != packet && !packet.isKeyFrame()) {
                        packet = mNALUQueue.poll();
                    }
                    // 队列中的 packet 被移除完,还未找到关键帧,剩余添加到队列的 packet 要丢掉
                    isNeedDiscardFrame = (null == packet);
                } else {
                    if (isNeedDiscardFrame) {
                        packet = mNALUQueue.poll();
                        while (null != packet && !packet.isKeyFrame()) {
                            packet = mNALUQueue.poll();
                        }

                        isNeedDiscardFrame = (null == packet);
                    } else {
                        packet = mNALUQueue.poll();
                        isNeedDiscardFrame = false;
                    }

                }

                if (null == packet) {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    continue;
                }

                try {
                    //long start = System.currentTimeMillis();

                    int inIndex = mDecoderMediaCodec.dequeueInputBuffer(-1);// microseconds

                    //Log.d(TAG, "inIndex:" + inIndex);

                    if (inIndex >= 0) {
                        ByteBuffer inputBuffer = mDecoderMediaCodec.getInputBuffer(inIndex);
                        inputBuffer.put(packet.getData(), 0, packet.getSize());
                        //填充数据后通知 MediaCodec 查询 inIndex 索引的这个 buffer
                        mDecoderMediaCodec.queueInputBuffer(inIndex, 0, packet.getSize(), System.currentTimeMillis() * 1000, 0);
                    }

                    int outIndex;
                    if ((outIndex = mDecoderMediaCodec.dequeueOutputBuffer(info, 0)) >= 0) {
                        //Log.d(TAG, "outIndex:" + outIndex + " flag=" + info.flags);
                        if (null != mDecodeCallback) {
                            // 实际输出为 NV12 格式
                            //byte[] data = new byte[info.size];
                            //outBuffer.get(data);
                            ByteBuffer outBuffer = mDecoderMediaCodec.getOutputBuffer(outIndex);
                            if (null == mSurface) {
                                mDecoderMediaCodec.releaseOutputBuffer(outIndex, false);
                                mDecodeCallback.onDataArrived(info.presentationTimeUs, outBuffer, outBuffer.capacity());
                            } else {
                                mDecoderMediaCodec.releaseOutputBuffer(outIndex, true);
                                mDecodeCallback.onDataArrived(-1, null, -1);
                            }
                        }

                        //long end = System.currentTimeMillis();
                        //Log.d(TAG, "SendDecodeThread: useTime=" + (end - start));
                        /*long sleepTime = 1000 / mFps - (end - start);
                        Thread.sleep(sleepTime > 0 && sleepTime < 1000 / mFps ? sleepTime : 0);*/
                    }

                } catch (Exception e) {
                    Log.d(TAG, "SendDecodeThread: An error occurred.");
                    // 发生错误重启再试
                    H264Decoder.this.stop();

                    initMediaCodec(mWidth, mHeight, mSurface);
                    H264Decoder.this.start();
                }
            }

            if (null != mDecodeCallback) {
                mDecodeCallback.deinit();
            }

        }

        public boolean isRunning() {
            return isRunning;
        }

        public void setRunning(boolean running) {
            isRunning = running;
        }
    }

    public interface DecodeCallback {
        public void init();

        public void deinit();

        public void onDataArrived(long pts, ByteBuffer data, int dataSize);
    }

}

二、硬编码

在 rk3399 android 10 平台上,无法直接将 nv12 数据编码为 H.264,如果送入 NV12 进行编码,会出现编码后的数据全是关键帧却没有 P 帧输出,而且大量的 YUV 帧送入编码会吃性能,YUV 数据对内存占用较大。所以一般场景下我们会使用 Surface 将图像数据送入编码器进行编码,只需要在 Surface 上进行绘制即可。当然这个 Surface 是由编码器创建出来的,而绘制通常会搭配 OpenGL ES API 去处理。

注意:下面这段代码没有考虑码流结束后刷出剩余的帧,这是因为此处不考虑对需求影响不大,所以没写相关代码,如果需要将所有帧刷出,则应该考虑。

public class H264Encoder {
    private static final String TAG = "H264Encoder";
    private final static String MIME_TYPE = "video/avc"; // H.264 Advanced Video

    private MediaCodec mMediaCodec;
    private Surface mInputSurface;

    private int mFps;
    private EncodeCallback mEncodeCallback;
    private byte[] mBuffer;

    private SendEncodeThread mSendEncodeThread;
    private int mWidth;
    private int mHeight;
    private int mInitMediaCodecTryTimes = 0;

    public H264Encoder(int width, int height, int fps) {
        mWidth = width;
        mHeight = height;
        mFps = fps;
        mBuffer = new byte[width * height * 3 / 2];
        initMediaCodec(width, height, fps);
    }

    private void initMediaCodec(int width, int height, int fps) {
        try {
            mMediaCodec = MediaCodec.createEncoderByType(MIME_TYPE);
            //创建配置
            MediaFormat mediaFormat = MediaFormat.createVideoFormat(MIME_TYPE, width, height);
            //设置解码预期的帧速率
            mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, fps);
            mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
            mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, (int) (width * height * fps * 0.3));//Biterate = Width * Height * FrameRate * Factor
            mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
            mediaFormat.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline);
            mediaFormat.setInteger("level", MediaCodecInfo.CodecProfileLevel.AVCLevel42); // Level 4
            //配置绑定 mediaFormat
            Log.d(TAG, "width=" + width + " height=" + height + " fps=" + fps);
            mMediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
            mInputSurface = mMediaCodec.createInputSurface();
        } catch (Exception e) {
            e.printStackTrace();
            //创建编码失败
            Log.e(TAG, "init MediaCodec fail.");
            // 重新尝试六次
            if (mInitMediaCodecTryTimes < 6) {
                mInitMediaCodecTryTimes++;
                try {
                    Thread.sleep(20);
                } catch (InterruptedException ie) {
                    ie.printStackTrace();
                }
                initMediaCodec(mWidth, mHeight, mFps);
            }
        }
    }

    /**
     * 获取 MediaCodec Input Surface
     *
     * @return
     */
    public Surface getInputSurface() {
        return mInputSurface;
    }

    /**
     * 开始编码
     */
    public void start() {
        if (mSendEncodeThread != null && mSendEncodeThread.isRunning()) {
            Log.e(TAG, "SendDecodeThread is already running");
            return;
        }

        try {
            mMediaCodec.start();

            mSendEncodeThread = new SendEncodeThread();
            mSendEncodeThread.setRunning(true);
            mSendEncodeThread.start();
            Log.d(TAG, "START");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 结束编码
     */
    public void stop() {
        if (mSendEncodeThread != null && mSendEncodeThread.isRunning()) {
            mSendEncodeThread.setRunning(false);
            mSendEncodeThread.interrupt();

            try {
                mSendEncodeThread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        if (mMediaCodec != null) {
            try {
                mMediaCodec.stop();
                mMediaCodec.release();
                mMediaCodec = null;
                Log.d(TAG, "STOP");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    private class SendEncodeThread extends Thread {

        private volatile boolean isRunning = true;

        @Override
        public void run() {
            MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();

            while (isRunning) {
                try {
                    long start = System.currentTimeMillis();
                    int outIndex = mMediaCodec.dequeueOutputBuffer(info, 0);
                    //Log.d(TAG, "outIndex:" + outIndex + " flag=" + info.flags);

                    if (outIndex >= 0) {
                        ByteBuffer outBuffer = mMediaCodec.getOutputBuffer(outIndex);
                        outBuffer.get(mBuffer, 0, info.size);
                        mMediaCodec.releaseOutputBuffer(outIndex, false);

                        if (info.flags == MediaCodec.BUFFER_FLAG_CODEC_CONFIG) {
                            //Log.d(TAG, "SendEncodeThread: BUFFER_FLAG_CODEC_CONFIG");
                        } else if (info.flags == MediaCodec.BUFFER_FLAG_KEY_FRAME) {
                            //Log.d(TAG, "SendEncodeThread: BUFFER_FLAG_KEY_FRAME");
                        } else {
                            //Log.d(TAG, "SendEncodeThread: P Frame or other.");
                        }

                        if (null != mEncodeCallback) {
                            mEncodeCallback.onDataArrived(info.flags, info.presentationTimeUs, mBuffer, info.size);
                        }

                    }

                    long end = System.currentTimeMillis();
                    long sleepTime = 1000 / mFps - (end - start);
                    Thread.sleep(sleepTime > 0 && sleepTime < 1000 / mFps ? sleepTime : 0);
                    //Log.d(TAG, "SendEncodeThread: sleepTime=" + sleepTime);
                } catch (InterruptedException e) {
                    Log.d(TAG, "SendEncodeThread: break sleep.");
                } catch (Exception e) {
                    Log.d(TAG, "SendEncodeThread: An error occurred.");
                    // 发生错误重启再试
                    H264Encoder.this.stop();

                    initMediaCodec(mWidth, mHeight, mFps);
                    H264Encoder.this.start();
                }
            }
        }

        public boolean isRunning() {
            return isRunning;
        }

        public void setRunning(boolean running) {
            isRunning = running;
        }
    }

    public void setEncodeCallback(EncodeCallback encodeCallback) {
        this.mEncodeCallback = encodeCallback;
    }

    public interface EncodeCallback {
        public void onDataArrived(int frameType, long pts, byte[] data, int dataSize);
    }

}

运行这段编码器代码后,的确可以正常编码 H.264,但会出现播放视频每隔一定间隔(大约 1 秒)画面闪烁一下(类似突然某一帧码率降低造成),经过分析生成的 H.264 数据,发现存在同样的问题,如此就能定位到编码器输出的数据本身存在问题造成的,而非打包成 MP4 等格式的封装代码引入的。需要自行编译瑞芯微官方的开源最新 mpp 库(生成 libmpp.so 和 libvpu.so)进行解决。

正常编码图像:

hw_frames_ctx 硬编码 硬编码存在的问题_音视频_04


出现闪烁帧:

hw_frames_ctx 硬编码 硬编码存在的问题_音视频_05


解决方案需要同时编译 mpp 库和重新编译 Android 10 OS 代码。

简单的验证方法为 adb push libmpp.so 和 libvpu.so 这两个库到设备 vendor/lib 和 vendor/lib64 目录下。

系统源码内这两个 so 需要替换下面的位置:

vendor/rockchip/common/vpu/lib/libmpp/arm64/mpp_dev/libmpp.so
vendor/rockchip/common/vpu/lib/libmpp/arm64/mpp_svp/libmpp.so
vendor/rockchip/common/vpu/lib/libmpp/arm/mpp_dev/libmpp.so
vendor/rockchip/common/vpu/lib/libmpp/arm/mpp_svp/libmpp.so

vendor/rockchip/common/vpu/lib/libvpu/arm64/mpp_dev/libvpu.so
vendor/rockchip/common/vpu/lib/libvpu/arm64/mpp_svp/libvpu.so
vendor/rockchip/common/vpu/lib/libvpu/arm/mpp_dev/libvpu.so
vendor/rockchip/common/vpu/lib/libvpu/arm/mpp_svp/libvpu.so

mpp 库编译方法

mpp 库下载地址:https://github.com/rockchip-linux/mpp,编译时需要安装 cmake,版本要求 2.8.12 以上,我编译时采用的版本为 cmake version 3.20.0。

  1. 手动修改 build/android/arm 目录和 build/android/aarch64 目录下的 make-Android.bash 脚本的 ANDROID_NDK 变量路径,比如我修改为 /home/snake/Android/android-ndk-r15b。
  2. 进入 build/android/arm 运行 make-Android.bash 脚本生成编译用的 Makefile,运行 make -j8 进行编译,然后取编译好的 32 位版本:build/android/arm/mpp/legacy/libvpu.so 和 build/android/arm/mpp/libmpp.so。
  3. 进入 build/android/aarch64 运行 make-Android.bash 脚本生成编译用的 Makefile,运行 make -j8 进行编译,然后取编译好的 64 位版本:build/android/aarch64/mpp/legacy/libvpu.so 和 build/android/aarch64/mpp/libmpp.so。

关于编码最后再说一点,虽然 rk3399 官方称编码最大支持 1920x1080@30FPS,实际上这应该是编码一路 1080p 的最大帧率,实践时同时编码两路 1080p 帧率下降到 15 帧左右,也就是说随着编码路数增加,貌似帧率成比例降低。