WebRTC 源码分析——Android 视频硬件编码

news2024/12/23 14:28:32

作者:DevYK

1. 简介

本文将重点介绍在 Android 平台上,WebRTC 是如何使用 MediaCodec 对视频数据进行编码,以及在整个编码过程中 webrtc native 与 java 的流程交互。

本篇开始会先回顾一下 Andorid MediaCodec 的概念和基础使用,然后再跟着问题去源码中分析。

2. MediaCodec 基础知识

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

以下是 MediaCodec 的一些关键概念和用法:

  1. 创建和配置 MediaCodec:首先,需要根据所需的编解码器类型(例如 H.264、VP8、Opus 等)创建一个 MediaCodec 实例。接下来,通过 MediaFormat 对象指定编解码器的一些参数,如分辨率、帧率、码率等。然后,使用 configure() 方法配置 MediaCodec。
            try {
                // 1\. 创建和配置 MediaCodec
                MediaCodecInfo codecInfo = selectCodec(MIME_TYPE);
                if (codecInfo == null) {
                    throw new RuntimeException("No codec found for " + MIME_TYPE);
                }
                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);
                encoder = MediaCodec.createByCodecName(codecInfo.getName());
                encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
                encoder.start();
            } catch (IOException e) {
                throw new RuntimeException("Failed to initialize encoder", e);
            }
  1. 输入和输出缓冲区:MediaCodec 有两个缓冲区队列,一个用于输入,另一个用于输出。输入缓冲区用于接收原始数据(例如从摄像头捕获的视频帧),输出缓冲区用于存储编码后的数据。在编解码过程中,需要将这些缓冲区填充或消费。

  1. 编码器工作模式: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();
**异步(推荐使用):**
     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. MediaCodec 与 Surface:对于视频编解码,MediaCodec 可以与 Surface 对象一起使用,以便使用 GPU 进行高效处理。通过将编解码器与 Surface 关联,可以将图像数据直接从 Surface 传输到编解码器,而无需在 CPU 和 GPU 之间复制数据。这可以提高性能并降低功耗。

    可使用如下 api 进行创建一个输入 surface

    public Surface createInputSurface ();
返回的 inputSurface 可与 EGL 进行绑定,与 OpenGL ES 再进行关联。 sample 可以参考这个开源库 grafika
  1. 开始和停止编解码:配置完 MediaCodec 后,调用 start() 方法开始编解码过程。在完成编解码任务后,需要调用 stop() 方法停止编解码器,并使用 release() 方法释放资源。

  2. 错误处理:在使用 MediaCodec 时,可能会遇到各种类型的错误,如不支持的编解码格式、资源不足等。为了确保应用程序的稳定性,需要妥善处理这些错误情况。

总之,MediaCodec 是 Android 中处理音视频编解码的关键组件。了解其基本概念和用法有助于构建高效、稳定的媒体应用程序。

3. webrtc 中如何使用硬件编码器?

由于在 WebRTC 中优先使用的是 VP8 编码器,所以我们想要分析 Android 上硬件编码的流程,需要先支持 h264 的硬件编码

  1. 创建 PeerConnectionFactory 时设置视频编码器
        private PeerConnectionFactory createPeerConnectionFactory() {
            PeerConnectionFactory.initialize(
                    PeerConnectionFactory.InitializationOptions.builder(applicationContext)
                            .setEnableInternalTracer(true)
                            .createInitializationOptions());

            PeerConnectionFactory.Options options = new PeerConnectionFactory.Options();
            DefaultVideoEncoderFactory defaultVideoEncoderFactory =
                    new DefaultVideoEncoderFactory(
                            rootEglBase.getEglBaseContext(), true /* enableIntelVp8Encoder */, true);
            DefaultVideoDecoderFactory defaultVideoDecoderFactory =
                    new DefaultVideoDecoderFactory(rootEglBase.getEglBaseContext());

            return PeerConnectionFactory.builder()
                    .setOptions(options)
                    .setVideoEncoderFactory(defaultVideoEncoderFactory)
                    .setVideoDecoderFactory(defaultVideoDecoderFactory)
                    .createPeerConnectionFactory();
        }
  1. 在 createOffer / createAnswer 将 SDP 中 m=video 的 h264 playload 编号放在第一位

这部分代码可以参考 preferCodec

4. webrtc 中编码器是如何初始化的?

通过上一个问题得知,我们使用的是 DefaultVideoEncoderFactory 默认编码器,内部实现就是使用的硬件能力

内部实例化了一个 HardwareVideoEncoderFactory ,我们在 DefaultVideoEncoderFactory 中看到了 createEncoder 函数,这里的内部就是实例化 HardwareVideoEncoder 的地方,我们先 debug 看下是哪里调用的,如下图所示,

下图的第一点可以发现底层传递过来的已经是 h264 编码器的信息了。

发现调用栈并没有在 java 端,那肯定在 native 端了,我们可以通过 createPeerConnectionFactory 查看下调用

  1. 将 videoEnvoderFactory 引用传递到 native

  1. Native 入口在 PeerConnectionFactory_jni.h

  1. 根据调用栈,发现将 jencoder_factory 包装到了 CreateVideoEncoderFactory
    ScopedJavaLocalRef<jobject> CreatePeerConnectionFactoryForJava(
        JNIEnv* jni,
        const JavaParamRef<jobject>& jcontext,
        const JavaParamRef<jobject>& joptions,
        rtc::scoped_refptr<AudioDeviceModule> audio_device_module,
        rtc::scoped_refptr<AudioEncoderFactory> audio_encoder_factory,
        rtc::scoped_refptr<AudioDecoderFactory> audio_decoder_factory,
        const JavaParamRef<jobject>& jencoder_factory,
        const JavaParamRef<jobject>& jdecoder_factory,
        rtc::scoped_refptr<AudioProcessing> audio_processor,
        std::unique_ptr<FecControllerFactoryInterface> fec_controller_factory,
        std::unique_ptr<NetworkControllerFactoryInterface>
            network_controller_factory,
        std::unique_ptr<NetworkStatePredictorFactoryInterface>
            network_state_predictor_factory,
        std::unique_ptr<NetEqFactory> neteq_factory) {

    ...

      media_dependencies.video_encoder_factory =
          absl::WrapUnique(CreateVideoEncoderFactory(jni, jencoder_factory));

    ...
    }

    VideoEncoderFactory* CreateVideoEncoderFactory(
        JNIEnv* jni,
        const JavaRef<jobject>& j_encoder_factory) {
      return IsNull(jni, j_encoder_factory)
                 ? nullptr
                 : new VideoEncoderFactoryWrapper(jni, j_encoder_factory);
    }
  1. 通过一系列的调用,我们发现java 端的引用,被封装成了 c++ 端的 VideoEncoderFactoryWrapper ,我们看一下它的构造函数

主要就是通过 jni 调用 java 端的代码,用以获取当前设备所支持的编码器和编码器的信息

  1. 猜测既然在 Native 中包装了 java 端 VideoEncoder.java 的引用,那么肯定也有对应的 CreateEncoder 函数

我们在 video_encoder_factory_wrapper.h 中看到了我们想要的函数,我们看下它的实现

这不就是我们找到了 createEncoder jni 调用的入口吗?那么是什么时候调用的呢?我们进行 debug 一下

它的调用栈是媒体协商成功后,根据发起方的编码器来匹配,目前匹配到了最优的是 H264 编码,然后进行创建 H264 编码器

此时,我们已经又回到了 java 端的 createEncoder 代码,我们来看下是怎么对 MediaCodec 初始化的
  1. MediaCodec 核心初始化代码

    在 HardwareVideoEncoderFactory 中的 createEncoder 中

上面的逻辑是判断 MediaCodec 是否只是 baseline 和 high ,如果都不支持返回空,反之返回 HardwareVideoEncoder 实例,该实例又返回给了 native ,然后转为了 native 的智能指针 std::unique_ptr 的实体 VideoEncoderWrapper

通过 debug ,我们找到了在 native jni 执行 initEncode 的入口函数

通过媒体协商后,我们得到了编码器配置的一些参数

内部执行了 **initEncodeInternal** ,我们看下具体实现

这里就是我们所熟悉的 MediaCodec 编码配置了,根据上面的序号我们知道,先根据媒体协商后的编码器名称来创建一个 MediaCodec 对象,然后配置一些必要的参数,最后启动编码器.

5. webrtc 中是如何将数据送入编码器的?

WebRTC 使用 VideoEncoder 接口来进行视频编码,该接口定义了一个用于编码视频帧的方法:encode(VideoFrame frame, EncodeInfo info)。WebRTC 提供了一个名为 HardwareVideoEncoder 的类,该类实现了 VideoEncoder 接口,并使用 MediaCodec 对视频帧进行编码。

HardwareVideoEncoder 类中,WebRTC 将 VideoFrame 对象转换为与 MediaCodec 关联的 Surface 的纹理。这是通过使用 EglBase 类创建一个 EGL 环境,并使用该环境将 VideoFrame 的纹理绘制到 Surface 上来实现的。

为了更好的理解 MediaCodec createInputSurface 和 OpenGL ES 、EGL 的关系,我简单画了一个架构图。如下所示:

EGL、OpenGL ES、 InputSurface 关系流程:

  1. 使用 OpenGL ES 绘制图像。
  2. EGL 管理和连接 OpenGL ES 渲染的表面。
  3. 通过 Input Surface,将 OpenGL ES 绘制的图像传递给 MediaCodec。
  4. MediaCodec 对接收到的图像数据进行编码。

根据上面流程得知,采集到的 VideoFrame 会提交给 VideoStreamEncoder::OnFrame 然后经过调用 EncodeVideoFrame 会执行到 VideoEncoder.java 的包装类,webrtc::jni::VideoEnacoderWrapper::Encode 函数,最后通过 jni 将(videoFrame,encodeInfo) 回调给了 java 端。

接下来我们看 java 端如何处理的 VideoFrame

该函数的核心是判断是否使用 surface 模式进行编码,如果条件成立调用 encodeTextureBuffer 进行纹理编码,

我们先看上图的第一步,

第一步的 1-3 小点主要是通过 OpenGL ES 将 OES 纹理数据绘制出来,然后第二大步的 textureEglBase.swapBuffers(…) 主要是将 OpenGL ES 处理后的图像数据提交给 EGLSurface 。经过这些操作后纹理数据就提交给 MediaCodec 的 inputsurface 了。

6. webrtc 是如何获取编码后的数据?

HardwareVideoEncoder 类中,使用 MediaCodec 同步模式进行获取编码后的数据。当数据可用时,会调用 callback.onEncodedFrame(encodedImage, new CodecSpecificInfo()); 方法,然后将编码后的帧传递给 WebRTC 引擎。WebRTC 引擎会对编码后的帧进行进一步处理,如封装 RTP 包、发送到对端等。

主要流程如下:

第一步有点印象吧?对,就是在编码器初始化的时候会开启一个循环获取解码数据的线程,我们分析下 deliverEncodedImage 函数的实现逻辑

这段代码的主要功能是从编解码器 (MediaCodec) 中获取编码后的视频帧,并对关键帧进行处理。以下是代码的逐步分析:

  1. 定义一个 MediaCodec.BufferInfo 对象,用于存储输出缓冲区的元信息。

  2. 调用 codec.dequeueOutputBuffer() 方法来获取编码后的输出缓冲区索引。如果索引小于 0,则有特殊含义。比如 MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED 表示输出缓冲区已更改,此时需要重新获取输出缓冲区。

  3. 使用索引获取编码后的输出缓冲区 (ByteBuffer)。

  4. 设置缓冲区的位置 (position) 和限制 (limit),以便读取数据。

  5. 检查 info.flags 中的 MediaCodec.BUFFER_FLAG_CODEC_CONFIG 标志。如果存在,表示当前帧为编解码器配置帧。这种情况下,将配置帧数据存储在 configBuffer 中。

  6. 如果当前帧不是配置帧,则执行以下操作:

    6.1 查看当前是否重新配置编码码率,如果是就更新比特率。

    6.2 检查当前帧是否为关键帧。如果 info.flags 中的 MediaCodec.BUFFER_FLAG_SYNC_FRAME 标志存在,则表示当前帧为关键帧。 6.3 对于 H.264 编码的关键帧,将 SPS 和 PPS NALs 数据附加到帧的开头。创建一个新的缓冲区,将 configBuffer 和编码后的输出缓冲区的内容复制到新缓冲区中。

    6.4 根据帧类型 (关键帧或非关键帧),创建一个 EncodedImage 对象。在释放输出缓冲区时,确保不抛出任何异常。

    6.5 调用 callback.onEncodedFrame() 方法传递编码后的图像和编解码器特定信息。

    6.6 释放 EncodedImage 对象。

当遇到异常 (例如 IllegalStateException) 时,代码将记录错误信息。

总之,这段代码的目标是从 MediaCodec 中获取编码后的视频帧,对关键帧进行处理,并将结果传递给回调函数。

对,该疑问的答案就是 6.5 它将编码后的数据通过 onEncodedFrame 告知了 webrtc 引擎。由于后面的处理不是本章的重点,所以不再分析。

7. webrtc 是如何做码流控制的?

WebRTC 的码流控制包括拥塞控制和比特率自适应两个主要方面。这里只简单介绍下概念,及 Android 是如何配合 webrtc 来动态修改码率的。

  1. 拥塞控制 (Congestion Control): 拥塞控制主要关注在不引起网络拥塞的情况下传输尽可能多的数据。WebRTC 实现了基于 Google Congestion Control (GCC) 的拥塞控制算法,它也被称为 Send Side Bandwidth Estimation(发送端带宽估计)。此算法根据丢包率、往返时间 (RTT) 和接收端的 ACK 信息来调整发送端的码率。拥塞控制算法会持续监测网络状况,并根据需要动态调整发送码率。
  2. 比特率自适应 (Bitrate Adaptation): 比特率自适应关注如何根据网络条件和设备性能调整视频编码参数,以实现最佳的视频质量。

当比特率发生变化时,WebRTC 会调用 VideoEncoder.setRateAllocation() 方法来通知更新比特率。

在编码的时候,其实在上一个疑问中已经知道了如何调节码率。判断条件是当当前的码率与需要调节的码率不匹配时,调用如下代码进行更新:

8. 总结

本文深入剖析了 WebRTC 在 Android 平台上是如何使用 MediaCodec 对视频数据进行编码的,以及整个编码过程中 webrtc native 与 java 的流程交互。首先回顾了 Android MediaCodec 的概念和基础使用,包括创建和配置 MediaCodec、输入和输出缓冲区、编码器工作模式以及 MediaCodec 与 Surface 的关系。然后,通过具体的代码示例,详细说明了在 WebRTC 中如何实现视频数据的编解码。并通过几个疑问的方式从源码的角度了解到了整个编码流程。希望通过此文能帮助读者更好地理解 WebRTC Android 编码技术。

推荐Android 音视频核心知识点笔记:https://qr18.cn/Ei3VPD

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/445540.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Node【Global全局对象】之【Process】

文章目录 &#x1f31f;前言&#x1f31f;Process&#x1f31f;process属性&#x1f31f;process.env &#x1f31f;process方法&#x1f31f;process事件&#x1f31f;uncaughtException &#x1f31f;写在最后 &#x1f31f;前言 哈喽小伙伴们&#xff0c;新的专栏 Node 已开…

VSCode + GCC编译器(MinGW)开发环境中文字符乱码问题踩坑与解决办法

文章目录 问题背景问题描述测试代码测试结果现象描述问题分析 解决方案修改默认配置1. 已经存在的文件全部使用gbk编码重新保存。2. 在工程目录下新建.vscode目录&#xff0c;如果已存在则跳过此步骤。3. 在.vscode目录中新建settings.json&#xff0c;launch.json两个文件&…

SAP CAP篇二:为Service加上数据库支持

在篇一快速创建一个Service&#xff0c;基于Java的实现中&#xff0c;可见使用SAP CAP &#xff08;Cloud Programming Model&#xff09;确实可以提高开发效率。尤其是Java技术栈上&#xff0c;对比于之前使用Olingo框架来实现oData&#xff0c;使用SAP CAP真的可以做到指数级…

Hightopo应邀参加 2023 第十届中国工业数字化论坛

3 月 30 日&#xff0c;以“加快数字化转型&#xff0c;助推高质量发展”为主题的第十届中国工业数字化论坛在北京隆重举行。厦门图扑软件科技有限公司&#xff08;以下简称“图扑软件”&#xff09;应邀参展&#xff0c;与诸位专家、领导、业界同仁共同研讨工业领域的数字化创…

红包算法关于---随机分发和平均分发

目录 群发普通红包 流程图 MainRedPacket类 Manager类 Member类 User类 群发普通红包 题目介绍 某软件有多名用户&#xff08;User类&#xff09;&#xff0c;某群聊中有群主&#xff08;Manager类&#xff09;和多名普通成员&#xff08;Member类&#xff09;&#x…

c++ 11 auto的概念和用法

目录 auto的概念&#xff1a; 使用auto声明变量的语法: auto关键字使用场景: 1.简化代码的书写和阅读 2.避免类型繁琐的重复定义 auto使用时的注意事项&#xff1a; auto的概念&#xff1a; 在C11标准中&#xff0c;auto是一种类型推导机制。它可以让编译器根据右值表达式…

代码随想录训练营day52|300、最长递增子序列;674、最长连续递增序列;718、最长重复子数组

300、最长递增子序列 给你一个整数数组 nums &#xff0c;找到其中最长严格递增子序列的长度。 子序列是由数组派生而来的序列&#xff0c;删除&#xff08;或不删除&#xff09;数组中的元素而不改变其余元素的顺序。例如&#xff0c;[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子…

【Spring Boot】SpringBoot 优雅整合Swagger Api 自动生成文档

文章目录 前言一、添加 Swagger 依赖二、创建接口类三、添加 Swagger 配置类四、访问 Swagger 页面五、整合一个更友好的UI接口文档 Knife4j1、添加 Knife4j 依赖2、添加 Knife4j 配置类3、访问 Knife4j 页面 总结 前言 Swagger 是一套 RESTful API 文档生成工具&#xff0c;可…

《选择》比努力更重要——C语言

目录 前言: 1.语句 2.选择语句 2.1小栗子 2.2选择结构 3.误导性else 3.1写法上的可读性和代码的稳健性&#xff1a; 3.2一些练习 4.switch选择语句 4.1嵌套的switch ❤博主CSDN:啊苏要学习 ▶专栏分类&#xff1a;C语言◀ C语言的学习&#xff0c;是为我们今后学习其…

Qt·DBus快速入门

目录 一、QtDBus简介 二、QtDBus类型系统 1、QtDBus类型系统简介 2、原生类型 3、复合类型 4、类型系统的使用 5、扩展类型系统 三、QtDBus常用类 1、QDBusMessage 2、QDBusConnection 3、QDBusInterface 4、QDBusReply 5、QDBusAbstractAdaptor 6、QDBusAbstract…

【Python_Opencv图像处理框架】图像阈值与滤波

写在前面 本篇文章是opencv学习的第二篇文章&#xff0c;主要讲解了图像的阈值和滤波操作&#xff0c;作为初学者&#xff0c;我尽己所能&#xff0c;但仍会存在疏漏的地方&#xff0c;希望各位看官不吝指正❤️ 写在中间 一、 图像阈值 &#xff08; 1 &#xff09;简单介绍…

扩散模型原理记录

1 扩散模型原理记录 参考资料&#xff1a; [1]【54、Probabilistic Diffusion Model概率扩散模型理论与完整PyTorch代码详细解读】 https://www.bilibili.com/video/BV1b541197HX/?share_sourcecopy_web&vd_source7771b17ae75bc5131361e81a50a0c871 [2] https://t.bili…

音视频通讯QoS技术及其演进

利用多种算法和策略进行网络传输控制&#xff0c;最大限度满足弱网场景下的音视频用户体验。 良逸&#xff5c;技术作者 01 什么是QoS&#xff1f;音视频通讯QoS是哪一类&#xff1f; QoS&#xff08;Quality of Service&#xff09;是服务质量的缩写&#xff0c;指一个网络能够…

MoE 系列(二)|Golang 扩展从 Envoy 接收配置

文&#xff5c;朱德江&#xff08;GitHub ID&#xff1a;doujiang24) MOSN 项目核心开发者蚂蚁集团技术专家 专注于云原生网关研发的相关工作 本文 1445 字 阅读 5 分钟 上一篇我们用一个简单的示例&#xff0c;体验了用 Golang 扩展 Envoy 的极速上手。 这次我们再通过一个…

这篇把「精准测试」算是讲明白了

作为测试同学&#xff0c;我们经常在工作中会有这样的困惑&#xff1a;我写的用例真的有效且全面吗&#xff0c;我的测试真的做到有效覆盖了吗&#xff1f;回归阶段我到底需要回归什么&#xff0c;回归验证充分吗&#xff1f;这次的改动到底影响范围有多大&#xff1f;针对以上…

JAVA集成强密码校验

JAVA集成强密码校验 1 : 背景2 : 代码设计编写2.1 : 引入规则配置2.2 : 密码校验工具类 3 : 验证4 : 相关链接 1 : 背景 最近系统需要做用户密码升级&#xff0c;增加强密码校验&#xff0c;密码长度&#xff0c;复杂度等等&#xff0c;所以整理了一份通用的密码复杂度控制代码…

你了解这2类神经性皮炎吗?常常预示着这5类疾病!

属于慢性皮肤病&#xff0c;患者皮肤可出现局限性苔藓样变&#xff0c;同时伴有阵发性瘙痒。神经性皮炎易发生在颈部两侧和四肢伸侧&#xff0c;中年人是高发人群。到目前为止神经性皮炎病因还并不是很明确&#xff0c;不过一部分病人发病前常常出现精神神经方面异常&#xff0…

SLAM 十四讲(第一版)各章方法总结与理解

SLAM 十四讲&#xff08;第一版&#xff09;各章方法总结与理解 总结十四讲中各章各步骤提到的各种方法&#xff0c;以及具体方法在哪个 c 库中可以调用。目的在于能更直观地了解 slam 过程各步骤到底在做什么&#xff0c;以及是怎么联系在一起的。 2. 初识 SLAM SLAM&#x…

ggplot作图中的图例处理方法

文章目录 改变坐标轴和图例的名称方法1, labs()方法2&#xff0c;scale_xxx_discrete/continuous() 删除坐标轴和图例的名称方法1&#xff0c; labs()方法2&#xff0c;scale_xxx_discrete/continuous()方法3&#xff0c;theme()方法4&#xff0c;guides()可以去图例名称 改变图…

怎么挣点零花钱,哪里可以赚点零花钱?以下这些方式值得参考一下

想赚零花钱的人群包括但不限于&#xff1a;大学生、宝妈/宝爸、自由职业者、比较有闲暇时间的上班族。 他们想要赚零花钱的原因不尽相同&#xff0c;但主要就是这几点&#xff1a;经济需求、个人发展、好奇心和乐趣等等。想赚取零花钱的人具有实际需求和个人发展的目标&#xf…