使用SurfaceTexture作为Camera输出「第一章,Android音视频编码那点破事」

  本章仅对部分代码进行讲解,以帮助读者更好的理解章节内容。本系列文章涉及的项目HardwareVideoCodec已经开源到Github,支持软编和硬编。使用它你可以很容易的实现任何分辨率的视频编码,无需关心摄像头预览大小。一切都如此简单。目前已迭代多个稳定版本,欢迎查阅学习和使用,如有BUG或建议,欢迎Issue。

  在Android系统中,使用GPU对摄像头画面进行高效可控的渲染,几乎是必须的。说到GPU就不得不提OpenGL,一组GPU暴露给应用层使用的接口。

  • Tip:OpenGL是一组基于状态的系统,在这里没有对象,只有一系列的状态。包括申请的Texture、FBO和PBO都是以状态的形式存在的。当我们向系统申请一个Texture,系统不会直接给你返回一个Texture对象,而是一个编号,FBO和PBO也是一样的。

《使用SurfaceTexture作为Camera输出「第一章,Android音视频编码那点破事」》 CameraPreviewPresenter.png

《使用SurfaceTexture作为Camera输出「第一章,Android音视频编码那点破事」》 UML类图

  正式开始之前我们先看一下这个模块的脑图和类图,以便对结构有个大概的了解。

  • CamerPreviewPresenter:序章介绍过了,一个全局控制器
  • Parameter:很重要的一个data类,包含了Camera、Encoder和Muxer初始化的一系列参数,再这里只用到Camera部分,也就是fps、previewWidth和previewHeight。
  • CameraWrapper:一个Camera的包装,对外暴露了SurfaceTexture.OnFrameAvailableListener接口,以及EGL,SurfaceTexture环境
  • CamerTextureWraper:EGL和SurfaceTexture的封装,其中textureId是OpenGL的纹理id,前面提到OpenGL是一个基于状态的系统,对SurfaceTexture的一系列处理都得通过textureId

  首先,CameraPreviewPresenter需要实现SurfaceTexture.OnFrameAvailableListener接口,并且持有一个Parameter和一个CameraWrapper。cameraWrapper 会在init中初始化,parameter则是在CameraPreviewPresenter的调用处传进来。

class CameraPreviewPresenter(var parameter: Parameter,
                             private var cameraWrapper: CameraWrapper? = null)
    : SurfaceTexture.OnFrameAvailableListener{

    init {
        cameraWrapper = CameraWrapper.open(parameter, this)
    }
}

  接下来在CameraWrapper里面,我们同样看到了Parameter,这个类会贯穿整个结构层级。onFrameAvailableListener也是由外部调用者赋值进来的,也就是CameraPreviewPresenter。

  由于Camera的初始化是一个耗时任务,所以需要在线程中初始化,这里使用了一个HandlerThread。熟悉HandlerThread的人应该知道,HandlerThread会和一个Handler关联起来,通过Handler给Thread发送消息,从而实现子线程中的消息消费,具体的效果就是可以让线程以规定的顺序间歇性的处理我们的任务,而无须关心线程的等待问题。

class CameraWrapper(private var parameter: Parameter,
                    private var onFrameAvailableListener: SurfaceTexture.OnFrameAvailableListener,
                    var textureWrapper: TextureWrapper = CameraTextureWrapper()) {
    private var mHandlerThread = HandlerThread("Renderer_Thread")
    private var mHandler: Handler? = null
    private var mCamera: Camera? = null
    private var mCameras = 0
    private var mCameraIndex = Camera.CameraInfo.CAMERA_FACING_BACK
}
  1. 在CameraWrapper初始化时获取摄像头数量,并初始化开启HandlerThread线程,之后向HandlerThread发送准备消息,在子线程中准备Camera的初始化。
    init {
        mCameras = CameraHelper.getNumberOfCameras()
        initThread()
        mHandler?.removeMessages(PREPARE)
        mHandler?.sendEmptyMessage(PREPARE)
    }
  1. Handler比较简单,只包含了一个Camera初始化的状态。代码就比较长了,在prepare()中,主要任务就是根据Parameter中的cameraIndex参数来打开前置或者后置摄像头,然后确定Camera的PreviewSize(预览分辨率)、ColorFormat(颜色格式)、FocusMode(对焦模式)、Fps(预览帧率)以及一些情景模式等。
    private fun initThread() {
        mHandlerThread.start()
        mHandler = object : Handler(mHandlerThread.looper) {
            override fun handleMessage(msg: Message) {
                when (msg.what) {
                    PREPARE -> {
                        prepare()
                    }
                }
            }
        }
    }

    private fun prepare() {
        if (0 == mCameras) {
            debug_e("Unavailable camera")
            return
        }
        //如果没有前置摄像头,则强制使用后置摄像头
        if (parameter.cameraIndex == Camera.CameraInfo.CAMERA_FACING_FRONT && mCameras < 2)
            parameter.cameraIndex = Camera.CameraInfo.CAMERA_FACING_BACK
        mCameraIndex = parameter.cameraIndex

        val time = System.currentTimeMillis()
        mCamera = openCamera(mCameraIndex)
        debug_e("open time: ${System.currentTimeMillis() - time}")
        if (null == mCamera) return
        val cameraParam = mCamera!!.parameters
        CameraHelper.setPreviewSize(cameraParam, parameter)
        CameraHelper.setColorFormat(cameraParam, parameter)
        CameraHelper.setFocusMode(cameraParam, parameter)
        CameraHelper.setFps(cameraParam, parameter)
        CameraHelper.setAutoExposureLock(cameraParam, false)
        CameraHelper.setSceneMode(cameraParam, Camera.Parameters.SCENE_MODE_AUTO)
        CameraHelper.setFlashMode(cameraParam, Camera.Parameters.FLASH_MODE_OFF)
        CameraHelper.setAntibanding(cameraParam, Camera.Parameters.ANTIBANDING_AUTO)
        CameraHelper.setVideoStabilization(cameraParam, true)
        val fps = IntArray(2)
        cameraParam.getPreviewFpsRange(fps)
        debug_v("Config: Size(${parameter.previewWidth}x${parameter.previewHeight})\n" +
                "Format(${cameraParam.previewFormat})\n" +
                "FocusMode(${cameraParam.focusMode})\n" +
                "Fps(${fps[0]}-${fps[1]})\n" +
                "AutoExposureLock(${cameraParam.autoExposureLock})\n" +
                "SceneMode(${cameraParam.sceneMode})\n" +
                "FlashMode(${cameraParam.flashMode})\n" +
                "Antibanding(${cameraParam.antibanding})\n" +
                "VideoStabilization(${cameraParam.videoStabilization})")
        try {
            mCamera!!.parameters = cameraParam
            mPrepare = true
            if (mRequestPreview) {
                startPreview()
                mRequestPreview = false
                mPrepare = false
            }
        } catch (e: Exception) {
            mCamera!!.release()
            debug_e("Camera $mCameraIndex open failed. Please check parameters")
        }
    }
  • Tip:关于previewSize的选择,由于Android的摄像头分辨率是固定的,我们必须从中选择一个合适的分辨率进行预览,最后会裁剪送进Render和Encoder。这里遵循一个最小裁剪像素原则,不需要过多复杂的判断,保证选择的都是最合适的分辨率,代码如下
        fun setPreviewSize(cameraParam: Camera.Parameters, videoParam: Parameter) {
            val supportSizes = cameraParam.supportedPreviewSizes
            var bestWidth = 0
            var bestHeight = 0
            for (size in supportSizes) {
                if (size.width >= videoParam.previewWidth//预览宽大于输出宽
                        && size.height >= videoParam.previewHeight//预览高大于输出高
                        && (size.width * size.height < bestWidth * bestHeight || 0 == bestWidth * bestHeight)) {//选择像素最少的分辨率
                    bestWidth = size.width
                    bestHeight = size.height
                }
            }
            debug_v("target preview size: " + videoParam.previewWidth + "x" + videoParam.previewHeight + ", best: " + bestWidth + "x" + bestHeight)
            videoParam.previewWidth = bestWidth
            videoParam.previewHeight = bestHeight
            videoParam.check()
            cameraParam.setPreviewSize(videoParam.previewWidth, videoParam.previewHeight)
        }
  1. 在上面完成参数初始化后,开启摄像头预览startPreview。这里比较重要的一点就是预览输出方向为SurfaceTexture,也就是OpenGL的一个纹理,而不是屏幕,我们可以把它看作一个缓冲区。SurfaceTexture的创建被包含在CameraTextureWrapper中。
    fun startPreview(): Boolean {
        mRequestPreview = true
        if (!mPrepare) return false
        if (null == mCamera) return false
        textureWrapper.surfaceTexture!!.setOnFrameAvailableListener(onFrameAvailableListener)
        return try {
            mCamera!!.setPreviewTexture(textureWrapper.surfaceTexture)
            mCamera!!.startPreview()
            true
        } catch (e: Exception) {
            release()
            debug_e("Start preview failed")
            e.printStackTrace()
            false
        }
    }
  • Tip:关于Camer预览的问题,大多数人都会选择onPreviewFrame(byte[] data, Camera camera)的回调数据,但是因为这里使用了OpenGL,所以必须使用SurfaceTexture.OnFrameAvailableListener。我们可以看到,这个回调是没有返回data的,所以我们一开始必须自己生成一个SurfaceTexture并设置给Camera,让摄像头把数据绘制到一个OpenGL的纹理上,也可以看作是一个缓冲区,这时候数据会保存在GPU内存中。而我们这么才能拿到这个数据呢,一切秘密都在这个SurfaceTexture的id(textureId)中。

  CameraTextureWrapper的初始化,先来看一下它的抽象父类TextureWrapper。很简单,就是一些基础代码,当然包含了一些必要的属性

abstract class TextureWrapper(open var surfaceTexture: SurfaceTexture? = null,
                              var texture: BaseTexture? = null,
                              open var textureId: Int? = null,
                              var egl: Egl? = null) {

    abstract fun drawTexture(transformMatrix: FloatArray?)

    open fun release() {
        if (null != surfaceTexture) {
            surfaceTexture!!.release()
            surfaceTexture = null
        }
    }
}

  由于使用OpenGL生成TextureId会有一些未知的错误,所以这里直接给定textureId=10,然后new一个SurfaceTexture。

    init {
        /**
         * 使用createTexture()会一直返回0,导致一些错误
         */
        textureId = 10
        intTexture()
    }
    private fun intTexture() {
        if (null != textureId)
            surfaceTexture = SurfaceTexture(textureId!!)
    }

  EGL的初始化,由于egl需要在同一个线程中初始化,否则会报错,所以这里只给出了一个外部调用的方法,在适当时刻才初始化EGL。

    fun initEGL(width: Int, height: Int) {
        egl = Egl()
        egl!!.initEGL()
        egl!!.makeCurrent()
        texture = CameraTexture(width, height, textureId!!)
        debug_e("camera textureId: ${textureId}")
    }

  接下来是一个很重要的类CameraTexture,这是一套OpenGL纹理绘制的封装,在之后的很多地方都会用到,包括滤镜渲染,屏幕绘制。

  首先生成一组纹理的定点数据verticesBuffer,然后便宜链接OpenGL的定点和片元程序createProgram,并且保存程序中的一些变量位置,以便在drawTexture中给他们指定参数。

  在drawTexture(transformMatrix: FloatArray?) 中我们可以看到OpenGL一系列的状态切换。重要!重要!重要!

  1. 由于这里使用了FBO,作用是把Camera中的数据绘制到FBO(帧缓存对象),所以需要先绑定一个输出FBOGLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, frameBuffer!!)
  2. 指定接下来OpenGL需要使用的程序GLES20.glUseProgram(shaderProgram!!)
  3. 激活0号纹理GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
  4. 绑定一个输入纹理到扩展纹理,这里必须指定GL_TEXTURE_EXTERNAL_OESGLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId),这里需要注意的是,这个textureId跟1.中的FBO是成对的,一个进,一个出。
  5. 给纹理坐标赋值GLES20.glUniform1i(uTextureLocation, 0)
  6. 给顶点赋值enableVertex(aPositionLocation, aTextureCoordinateLocation, buffer!!, verticesBuffer!!)
  7. 给定一个变换矩阵GLES20.glUniformMatrix4fv(uTextureMatrix, 1, false, transformMatrix, 0),这个矩阵由SurfaceTexture给出,不指定的话会造成画面错乱。
  8. 一些列初始化之后就可以进行纹理绘制了drawer.draw()
  9. 最后进行一些状态初始化操作,防止影响下一个OpenGL流程。至此,一个纹理绘制流程结束,今后的纹理绘制都是大同小异的。
class CameraTexture(width: Int, height: Int,
                    textureId: Int) : BaseFrameBufferTexture(width, height, textureId) {
    init {
        verticesBuffer = createShapeVerticesBuffer(CAMERA_TEXTURE_VERTICES)

        createProgram()
        initFrameBuffer()
    }
    private fun createProgram() {
        shaderProgram = createProgram(VERTEX_SHADER, FRAGMENT_SHADER)
        aPositionLocation = getAttribLocation("aPosition")
        uTextureLocation = getUniformLocation("uTexture")
        aTextureCoordinateLocation = getAttribLocation("aTextureCoord")
        uTextureMatrix = getUniformLocation("uTextureMatrix")
    }

    override fun drawTexture(transformMatrix: FloatArray?) {
        if (null == transformMatrix)
            throw RuntimeException("TransformMatrix can not be null")
        GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, frameBuffer!!)
        GLES20.glUseProgram(shaderProgram!!)
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
        GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
        GLES20.glUniform1i(uTextureLocation, 0)
        enableVertex(aPositionLocation, aTextureCoordinateLocation, buffer!!, verticesBuffer!!)
        GLES20.glUniformMatrix4fv(uTextureMatrix, 1, false, transformMatrix, 0)

        drawer.draw()

        GLES20.glFinish()
        GLES20.glDisableVertexAttribArray(aPositionLocation)
        GLES20.glDisableVertexAttribArray(aTextureCoordinateLocation)
        GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_NONE)
        GLES20.glUseProgram(GLES20.GL_NONE)
        GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, GLES20.GL_NONE)
    }
}

  BaseFrameBufferTexture是CameraTexture的父类,也是BaseTexture的扩展子类,增加了对FBO的支持。所以在这里自然是对FBO进行初始化,大致的流程是:

  1. 申请两个数组,用来分别保存frameBuffer和frameBufferTexture,需要注意的是他们是一堆孪生兄弟,别看他们的值可能是一样的就以为是一个东西,他们也有值不一样的时候。实际上,frameBuffer就是容器,里面又颜色附着点深度附着点模板附着点,分别对应纹理对象(颜色缓冲区)深度缓冲区模板缓冲区。这里我们只使用颜色缓冲区,也就是常用的纹理对象。此时FBO还只是一个空壳,至少需要附着一个点才能使用,我们通过GLES20.glFramebufferTexture2D把纹理对象(frameBufferTexture)附着到FBO的颜色附着点,这时,所有的渲染操作都会写入到该纹理对象上。
  2. 分别绑定frameBuffer和frameBufferTexture,告诉OpenGL我现在要操作他们了。
  3. 指定frameBuffer的大小和颜色格式GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height, 0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null),这里的width和height其实就是我们要输出的视频的大小,之后我们会使用OpenGL的glViewport指定画面区域,达到裁剪的目的。
  4. 设定过滤器,告诉OpenGL当画面过大或过小时应该怎么处理(使用纹理映射对画面裁剪更方便)
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
                GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat())
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
                GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat())
  1. 告诉OpenGL当画面超出边界时在横竖方向该怎么处理
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
                GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE.toFloat())
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
                GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE.toFloat())
  1. 最后保存frameBuffer和frameBufferTexture以供使用。
abstract class BaseFrameBufferTexture(var width: Int,
                                      var height: Int,
                                      textureId: Int,
                                      var frameBuffer: Int? = null,
                                      var frameBufferTexture: Int? = null) : BaseTexture(textureId) {

    fun initFrameBuffer() {
        val frameBuffer = IntArray(1)
        val frameBufferTex = IntArray(1)
        GLES20.glGenFramebuffers(1, frameBuffer, 0)
        GLES20.glGenTextures(1, frameBufferTex, 0)
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, frameBufferTex[0])
        GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height, 0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null)
        GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
                GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat())
        GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
                GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat())
        GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
                GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE.toFloat())
        GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
                GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE.toFloat())
        GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, frameBuffer[0])
        GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, GLES20.GL_TEXTURE_2D, frameBufferTex[0], 0)
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0)
        GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0)
        val error = GLES20.glGetError()
        if (error != GLES20.GL_NO_ERROR) {
            val msg = "initFrameBuffer: glError 0x" + Integer.toHexString(error)
            debug_e(msg)
            return
        }
        this.frameBuffer = frameBuffer[0]
        this.frameBufferTexture = frameBufferTex[0]
        debug_e("enable frame buffer: ${this.frameBuffer}, ${this.frameBufferTexture}")
    }
}

  在BaseTexture中主要处理顶点和片元程序的编译链接,还有顶点坐标的生成。这里时固定化的流程,所有的OpenGL程序都是按照这个流程生成。

abstract class BaseTexture(var textureId: Int,
                           var buffer: FloatBuffer? = null,
                           var verticesBuffer: FloatBuffer? = null,
                           var shaderProgram: Int? = null,
                           var drawer: GLDrawer = GLDrawer()) : Texture {
    companion object {
        var COORDS_PER_VERTEX = 2
        var TEXTURE_COORDS_PER_VERTEX = 2
        private val DRAW_INDICES = shortArrayOf(0, 1, 2, 0, 2, 3)
        //每行前两个值为顶点坐标,后两个为纹理坐标
        val VERTICES_SQUARE = floatArrayOf(
                -1.0f, 1.0f,
                -1.0f, -1.0f,
                1.0f, -1.0f,
                1.0f, 1.0f)
    }

    init {
        buffer = createShapeVerticesBuffer(VERTICES_SQUARE)
    }

    fun createShapeVerticesBuffer(array: FloatArray): FloatBuffer {
        val result = ByteBuffer.allocateDirect(4 * array.size).order(ByteOrder.nativeOrder()).asFloatBuffer()
        result.put(array).position(0)
        return result
    }

    fun createProgram(vertex: String, fragment: String): Int {
        val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertex)
        val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragment)
        return linkProgram(vertexShader!!, fragmentShader!!)
    }

    /**
     * 加载着色器,GL_VERTEX_SHADER代表生成顶点着色器,GL_FRAGMENT_SHADER代表生成片段着色器
     */
    fun loadShader(type: Int, shaderSource: String): Int {
        //创建Shader
        val shader = GLES20.glCreateShader(type)
        if (shader == 0) {
            throw RuntimeException("Create Shader Failed!" + GLES20.glGetError())
        }
        //加载Shader代码
        GLES20.glShaderSource(shader, shaderSource)
        //编译Shader
        GLES20.glCompileShader(shader)
        return shader
    }

    /**
     * 将两个Shader链接至program中
     */
    fun linkProgram(verShader: Int, fragShader: Int): Int {
        //创建program
        val program = GLES20.glCreateProgram()
        if (program == 0) {
            throw RuntimeException("Create Program Failed!" + GLES20.glGetError())
        }
        //附着顶点和片段着色器
        GLES20.glAttachShader(program, verShader)
        GLES20.glAttachShader(program, fragShader)
        //链接program
        GLES20.glLinkProgram(program)
        //告诉OpenGL ES使用此program
        GLES20.glUseProgram(program)
        return program
    }

    fun enableVertex(posLoc: Int, texLoc: Int, shapeBuffer: FloatBuffer, texBuffer: FloatBuffer) {
        GLES20.glEnableVertexAttribArray(posLoc)
        GLES20.glEnableVertexAttribArray(texLoc)
        GLES20.glVertexAttribPointer(posLoc, COORDS_PER_VERTEX,
                GLES20.GL_FLOAT, false,
                COORDS_PER_VERTEX * 4, shapeBuffer)
        GLES20.glVertexAttribPointer(texLoc, TEXTURE_COORDS_PER_VERTEX,
                GLES20.GL_FLOAT, false,
                TEXTURE_COORDS_PER_VERTEX * 4, texBuffer)
    }

    fun getAttribLocation(name: String): Int {
        return GLES20.glGetAttribLocation(shaderProgram!!, name)
    }

    fun getUniformLocation(name: String): Int {
        return GLES20.glGetUniformLocation(shaderProgram!!, name)
    }

    fun release() {
        if (null != shaderProgram)
            GLES20.glDeleteProgram(shaderProgram!!)
    }

    class GLDrawer(var drawIndecesBuffer: ShortBuffer? = null) {
        init {
            drawIndecesBuffer = ByteBuffer.allocateDirect(2 * DRAW_INDICES.size).order(ByteOrder.nativeOrder()).asShortBuffer()
            drawIndecesBuffer?.put(DRAW_INDICES)
            drawIndecesBuffer?.position(0)
        }

        fun draw() {
            GLES20.glClearColor(0.0f, 0.0f, 0.0f, 0.0f)
            GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
            GLES20.glDrawElements(GLES20.GL_TRIANGLES, drawIndecesBuffer!!.limit(),
                    GLES20.GL_UNSIGNED_SHORT, drawIndecesBuffer)
        }
    }

  流程比较多,可能讲的不是很到位,请配合源码阅读,如有错误,欢迎纠正。当然,这里可以选择更为简单的GLSurfaceView,但是在某些情况下,TextureView有不可替代的作用,详情可以去搜索一下TextureView和GLSurfaceView的区别。

  至此,所有的TextureView渲染环境已经初始化完成了,之后在Render线程中初始化这个camerWrapper的EGL环境,就可以在SurfaceTexture.OnFrameAvailableListener回调中执行drawTexture方法把摄像头数据绘制到FBO中了,下一章会讲到。

    原文作者:lmy小明
    原文地址: https://www.jianshu.com/p/54642228fd9c
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞