Android openGl开发详解(二)——通过SurfaceView,TextureView,GlSurfaceView显示相机预览(附Demo)

1. SurfaceView,GlSurfaceView,SurfaceTexture,TextureView的优缺点及区别
SurfaceView
继承自View,拥有View的大部分属性,但是由于holder的存在,不能设置透明度。 
优点:可以在一个独立的线程中进行绘制,不会影响主线程,使用双缓冲机制,播放视频时画面更流畅 
缺点:surface的显示不受View属性的控制,不能将其放在ViewGroup中,SurfaceView不能嵌套使用。

GlSurfaceView
GlSurfaceView继承自SurfaceView类,专门用来显示OpenGL渲染的,简单理解可以显示视频,图像及3D场景这些的。

SurfaceTexture
和SurfaceView功能类似,区别是,SurfaceTexure可以不显示在界面中。使用OpenGl对图片流进行美化,添加水印,滤镜这些操作的时候我们都是通过SurfaceTexre去处理,处理完之后再通过GlSurfaceView显示。缺点,可能会导致个别帧的延迟。本身管理着BufferQueue,所以内存消耗会多一点。

TextureView
同样继承自View,必须在开启硬件加速的设备中使用(保守估计目前百分之九十的Android设备都开启了),TextureView通过setSurfaceTextureListener的回调在子线程中进行更新UI. 
优点:支持动画效果。 
缺点:在5.0之前在主线程渲染,在5.0之后在单独线程渲染。TextureView    SurfaceView
绘制    稍微延时    及时
内存    高    低
动画    支持    不支持
耗电    高    低
适用场景(推荐)    视频播放,相机应用    大量画布更新(游戏绘制)
2. 如何通过SurfaceView显示Camera预览。
基本步骤
在xml文件中设置SurfaceView 。
实现SurfaceHolder.Callback的回调。
打开摄像头Camera.open(0);
设置摄像头相关参数;
将摄像头数据设置到SurfaceView中,并开启预览。
代码部分

<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"  tools:context="com.aserbao.aserbaosandroid.opengl.openGlCamera.simpleCameraOpengl.simpleCamera.CameraSurfaceViewShowActivity">
    <FrameLayout
        android:id="@+id/frame_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <SurfaceView
                    android:layout_width="match_parent"
                     android:layout_height="match_parent"
                     android:id="@+id/mSurface" />
        <Button
            android:id="@+id/btn_change"
            android:text="给动画,SurfaceView不支持设置透明度"
            android:textAllCaps="false"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"/>
    </FrameLayout>
</android.support.constraint.ConstraintLayout>


 

/**
 * 使用SurfaceView预览Camera数据
 */
public class CameraSurfaceViewShowActivity extends AppCompatActivity implements SurfaceHolder.Callback {
    @BindView(R.id.mSurface)
    SurfaceView mSurfaceView;

    public SurfaceHolder mHolder;
    private Camera mCamera;
    private Camera.Parameters mParameters;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_base_camera);
        ButterKnife.bind(this);
        mHolder = mSurfaceView.getHolder();
        mHolder.addCallback(this);
        mHolder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        try {
            // Open the Camera in preview mode
            mCamera = Camera.open(0);
            mCamera.setDisplayOrientation(90);
            mCamera.setPreviewDisplay(holder);
            mCamera.startPreview();
        } catch (IOException e) {
        }
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        mCamera.autoFocus(new Camera.AutoFocusCallback() {
            @Override
            public void onAutoFocus(boolean success, Camera camera) {
                if (success) {
                    mParameters = mCamera.getParameters();
                    mParameters.setPictureFormat(PixelFormat.JPEG); //图片输出格式
//                    mParameters.setFlashMode(Camera.Parameters.FLASH_MODE_TORCH);//预览持续发光
                    mParameters.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE);//持续对焦模式
                    mCamera.setParameters(mParameters);
                    mCamera.startPreview();
                    mCamera.cancelAutoFocus();
                }
            }
        });
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        if (mCamera != null) {
            mCamera.stopPreview();
            mCamera.release();
            mCamera = null;
        }
    }

    @OnClick(R.id.btn_change)
    public void onViewClicked() {
//        PropertyValuesHolder valuesHolder2 = PropertyValuesHolder.ofFloat("rotationX", 0.0f, 360.0f, 0.0F);
        PropertyValuesHolder valuesHolder = PropertyValuesHolder.ofFloat("rotationY", 0.0f, 360.0f, 0.0F);
        PropertyValuesHolder valuesHolder1 = PropertyValuesHolder.ofFloat("scaleX", 1.0f, 0.5f,1.0f);
        PropertyValuesHolder valuesHolder3 = PropertyValuesHolder.ofFloat("scaleY", 1.0f, 0.5f,1.0f);
        ObjectAnimator objectAnimator = ObjectAnimator.ofPropertyValuesHolder(mSurfaceView,  valuesHolder,valuesHolder1,valuesHolder3);
        objectAnimator.setDuration(5000).start();
    }
}



效果展示


提别提醒
SurfaceView预览相机视图不支持透明度,可以设置缩放旋转属性。如果需要做动画特效的话不推荐使用SurfaceView显示视图。可以使用下面的TextureView或者GlSurfaceView来显示。

3. 如何通过TextureView显示Camera预览
基本步骤
TextureView和SurfaceView显示Camera数据其实差不多,差别就两点: 
1. SurfaceView显示需要实现SurfaceHolder.Callback的回调而TextureView通过实现 TextureView.SurfaceTextureListener接口。 
2. 当Camera使用SurfaceView预览时通过setPreviewDisplay(holder)方法来设置预览视图,而使用TextureView预览时使用setPreviewTexture(mCameraTextureView.getSurfaceTexture())方法来设置。 
其他步骤同上。代码部分

public class CameraTextureViewShowActivity extends AppCompatActivity implements TextureView.SurfaceTextureListener {

    @BindView(R.id.camera_texture_view)
    TextureView mCameraTextureView;
    public Camera mCamera;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_camera_surface_texture);
        ButterKnife.bind(this);
        mCameraTextureView.setSurfaceTextureListener(this);
    }

    @Override
    public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
        try {
            mCamera = Camera.open(0);
            mCamera.setDisplayOrientation(90);
            mCamera.setPreviewTexture(mCameraTextureView.getSurfaceTexture());
            mCamera.startPreview();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {

    }

    @Override
    public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
        if (mCamera != null) {
            mCamera.stopPreview();
            mCamera.release();
            mCamera = null;
        }
        return false;
    }

    @Override
    public void onSurfaceTextureUpdated(SurfaceTexture surface) {

    }

    @OnClick(R.id.btn_texture_anim)
    public void onViewClicked() {
        PropertyValuesHolder valuesHolder = PropertyValuesHolder.ofFloat("translationX", 0.0f, 0.0f);
        PropertyValuesHolder valuesHolder1 = PropertyValuesHolder.ofFloat("scaleX", 1.0f, 0.3f,1.0f);
        PropertyValuesHolder valuesHolder4 = PropertyValuesHolder.ofFloat("scaleY", 1.0f, 0.3f,1.0f);
        PropertyValuesHolder valuesHolder2 = PropertyValuesHolder.ofFloat("rotationX", 0.0f, 2 * 360.0f, 0.0F);
        PropertyValuesHolder valuesHolder5 = PropertyValuesHolder.ofFloat("rotationY", 0.0f, 2 * 360.0f, 0.0F);
        PropertyValuesHolder valuesHolder3 = PropertyValuesHolder.ofFloat("alpha", 1.0f, 0.7f, 1.0F);
        ObjectAnimator objectAnimator = ObjectAnimator.ofPropertyValuesHolder(mCameraTextureView, valuesHolder, valuesHolder1, valuesHolder2, valuesHolder3,valuesHolder4,valuesHolder5);
        objectAnimator.setDuration(5000).start();
    }
}



效果展示


4. 如何通过GlSurfaceView处理Camera预览。
如果你在学习自定义相机,而且你的相机想要实现美颜,滤镜,人脸识别AR场景 and so on。这时候你就必须要学习如何使用GlsurfaView罗。如果你没有openGl的基本配置的知识或者你之前完全没有学习过openGl的开发,再次强烈建议你看一下这篇文章 Android openGl开发详解(一)——绘制简单图形,否则,下面内容可能会引起你的严重不适。当然,如果你还是觉得哪部分写得不好或者有疑问的话,欢迎给我留言,或者关注同名微信号aserbao私聊我。

基本步骤
在xml中添加GlSurfaceView
创建渲染器类实现GlSurfaceView.Renderer
清除画布,并创建一个纹理并绑定到。
创建一个用来最后显示的SurfaceTexture来显示处理后的数据。
创建Opengl ES程序并添加着色器到该程序中,创建openGl程序的可执行文件,并释放shader资源。
打开摄像头,并配置相关属性。设置预览视图,并开启预览。
添加程序到ES环境中,并设置及启用各类句柄。
在onDrawFrame中进行画布的清理及绘制最新的数据到纹理图形中。
设置一个SurfaceTexture.OnFrameAvailableListener的回调来通知GlSurfaceview渲染新的帧数据。
建议: 
GlSurfaceView作用简单的理解OpenGl对相机数据进行处理完之后的显示。我们需要明白的是渲染器的渲染周期及渲染方法的调用时机。 
1. onSurfaceCreated()当surface创建(第一次进入当前页面)或者重新创建(切换后台再进入)的时候调用。 
2. onSurfaceChanged()当surface大小发生改变的时候会被调用。 
3. onDrawFrame()绘制当前帧数据的时候被调用。代码部分

public class CameraGlSurfaceShowActivity extends AppCompatActivity implements SurfaceTexture.OnFrameAvailableListener {
    public SurfaceTexture mSurfaceTexture;

    public static Camera camera;
    private int camera_status = 1;
    @BindView(R.id.camera_glsurface_view)
    GLSurfaceView mCameraGlsurfaceView;
    public MyRender mRenderer;


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_camera_gl_surface_show);
        ButterKnife.bind(this);
        mCameraGlsurfaceView.setEGLContextClientVersion(2);//在setRenderer()方法前调用此方法
        mRenderer = new MyRender();
        mCameraGlsurfaceView.setRenderer(mRenderer);
        mCameraGlsurfaceView.setRenderMode(RENDERMODE_WHEN_DIRTY);
    }


    @Override
    public void onFrameAvailable(SurfaceTexture surfaceTexture) {
        mCameraGlsurfaceView.requestRender();
    }

    @OnClick({R.id.btn_gl_surface_view_animator, R.id.btn_gl_surface_view_switch})
    public void onViewClicked(View view) {
        switch (view.getId()) {
            case R.id.btn_gl_surface_view_animator:
                PropertyValuesHolder valuesHolder1 = PropertyValuesHolder.ofFloat("scaleX", 1.0f, 0.5f,1.0f);
                PropertyValuesHolder valuesHolder4 = PropertyValuesHolder.ofFloat("scaleY", 1.0f, 0.5f,1.0f);
                PropertyValuesHolder valuesHolder5 = PropertyValuesHolder.ofFloat("rotationY", 0.0f, 360.0f, 0.0F);
                ObjectAnimator objectAnimator = ObjectAnimator.ofPropertyValuesHolder(mCameraGlsurfaceView, valuesHolder1, valuesHolder4,valuesHolder5);
                objectAnimator.setDuration(3000).start();
                break;
            case R.id.btn_gl_surface_view_switch:
                camera_status ^= 1;
                if (camera != null) {
                    camera.stopPreview();
                    camera.release();
                }
                mRenderer.mBoolean = true;
                camera = Camera.open(camera_status);
                try {
                    camera.setPreviewTexture(mSurfaceTexture);
                } catch (IOException e) {
                    e.printStackTrace();
                }
                camera.startPreview();
                break;
        }
    }

    public class MyRender implements GLSurfaceView.Renderer {
        private final String vertexShaderCode = "uniform mat4 textureTransform;\n" +
                "attribute vec2 inputTextureCoordinate;\n" +
                "attribute vec4 position;            \n" +//NDK坐标点
                "varying   vec2 textureCoordinate; \n" +//纹理坐标点变换后输出
                "\n" +
                " void main() {\n" +
                "     gl_Position = position;\n" +
                "     textureCoordinate = inputTextureCoordinate;\n" +
                " }";

        private final String fragmentShaderCode = "#extension GL_OES_EGL_image_external : require\n" +
                "precision mediump float;\n" +
                "uniform samplerExternalOES videoTex;\n" +
                "varying vec2 textureCoordinate;\n" +
                "\n" +
                "void main() {\n" +
                 "    vec4 tc = texture2D(videoTex, textureCoordinate);\n" +
                "    float color = tc.r * 0.3 + tc.g * 0.59 + tc.b * 0.11;\n" +  //所有视图修改成黑白
                "    gl_FragColor = vec4(color,color,color,1.0);\n" +
//                "    gl_FragColor = vec4(tc.r,tc.g,tc.b,1.0);\n" +
                "}\n";
        private FloatBuffer mPosBuffer;
        private FloatBuffer mTexBuffer;
        private float[] mPosCoordinate = {-1, -1, -1, 1, 1, -1, 1, 1};
        private float[] mTexCoordinateBackRight = {1, 1, 0, 1, 1, 0, 0, 0};//顺时针转90并沿Y轴翻转  后摄像头正确,前摄像头上下颠倒
        private float[] mTexCoordinateForntRight = {0, 1, 1, 1, 0, 0, 1, 0};//顺时针旋转90  后摄像头上下颠倒了,前摄像头正确

        public int mProgram;
        public boolean mBoolean = false;

        public MyRender() {
            Matrix.setIdentityM(mProjectMatrix, 0);
            Matrix.setIdentityM(mCameraMatrix, 0);
            Matrix.setIdentityM(mMVPMatrix, 0);
            Matrix.setIdentityM(mTempMatrix, 0);
        }

        private int loadShader(int type, String shaderCode) {
            int shader = GLES20.glCreateShader(type);
            // 添加上面编写的着色器代码并编译它
            GLES20.glShaderSource(shader, shaderCode);
            GLES20.glCompileShader(shader);
            return shader;
        }

        private void creatProgram() {
            //通常做法
//            String vertexSource = AssetsUtils.read(CameraGlSurfaceShowActivity.this, "vertex_texture.glsl");
//            int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource);
//            String fragmentSource = AssetsUtils.read(CameraGlSurfaceShowActivity.this, "fragment_texture.glsl");
//            int fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource);
            int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexShaderCode);
            int fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentShaderCode);
            // 创建空的OpenGL ES程序
            mProgram = GLES20.glCreateProgram();

            // 添加顶点着色器到程序中
            GLES20.glAttachShader(mProgram, vertexShader);

            // 添加片段着色器到程序中
            GLES20.glAttachShader(mProgram, fragmentShader);

            // 创建OpenGL ES程序可执行文件
            GLES20.glLinkProgram(mProgram);

            // 释放shader资源
            GLES20.glDeleteShader(vertexShader);
            GLES20.glDeleteShader(fragmentShader);
        }

        private FloatBuffer convertToFloatBuffer(float[] buffer) {
            FloatBuffer fb = ByteBuffer.allocateDirect(buffer.length * 4)
                    .order(ByteOrder.nativeOrder())
                    .asFloatBuffer();
            fb.put(buffer);
            fb.position(0);
            return fb;
        }

        private int uPosHandle;
        private int aTexHandle;
        private int mMVPMatrixHandle;
        private float[] mProjectMatrix = new float[16];
        private float[] mCameraMatrix = new float[16];
        private float[] mMVPMatrix = new float[16];
        private float[] mTempMatrix = new float[16];

        //添加程序到ES环境中
        private void activeProgram() {
            // 将程序添加到OpenGL ES环境
            GLES20.glUseProgram(mProgram);

            mSurfaceTexture.setOnFrameAvailableListener(CameraGlSurfaceShowActivity.this);
            // 获取顶点着色器的位置的句柄
            uPosHandle = GLES20.glGetAttribLocation(mProgram, "position");
            aTexHandle = GLES20.glGetAttribLocation(mProgram, "inputTextureCoordinate");
            mMVPMatrixHandle = GLES20.glGetUniformLocation(mProgram, "textureTransform");

            mPosBuffer = convertToFloatBuffer(mPosCoordinate);
            if(camera_status == 0){
                mTexBuffer = convertToFloatBuffer(mTexCoordinateBackRight);
            }else{
                mTexBuffer = convertToFloatBuffer(mTexCoordinateForntRight);
            }

            GLES20.glVertexAttribPointer(uPosHandle, 2, GLES20.GL_FLOAT, false, 0, mPosBuffer);
            GLES20.glVertexAttribPointer(aTexHandle, 2, GLES20.GL_FLOAT, false, 0, mTexBuffer);

            // 启用顶点位置的句柄
            GLES20.glEnableVertexAttribArray(uPosHandle);
            GLES20.glEnableVertexAttribArray(aTexHandle);
        }

        @Override
        public void onSurfaceCreated(GL10 gl, EGLConfig config) {
            GLES20.glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
            mSurfaceTexture = new SurfaceTexture(createOESTextureObject());
            creatProgram();
//            mProgram = ShaderUtils.createProgram(CameraGlSurfaceShowActivity.this, "vertex_texture.glsl", "fragment_texture.glsl");
            camera = Camera.open(camera_status);
            try {
                camera.setPreviewTexture(mSurfaceTexture);
                camera.startPreview();
            } catch (IOException e) {
                e.printStackTrace();
            }
            activeProgram();

        }

        @Override
        public void onSurfaceChanged(GL10 gl, int width, int height) {
            GLES20.glViewport(0, 0, width, height);
            Matrix.scaleM(mMVPMatrix,0,1,-1,1);
            float ratio = (float) width / height;
            Matrix.orthoM(mProjectMatrix, 0, -1, 1, -ratio, ratio, 1, 7);// 3和7代表远近视点与眼睛的距离,非坐标点
            Matrix.setLookAtM(mCameraMatrix, 0, 0, 0, 3, 0f, 0f, 0f, 0f, 1.0f, 0.0f);// 3代表眼睛的坐标点
            Matrix.multiplyMM(mMVPMatrix, 0, mProjectMatrix, 0, mCameraMatrix, 0);
        }

        @Override
        public void onDrawFrame(GL10 gl) {
            if(mBoolean){
                activeProgram();
                mBoolean = false;
            }
            if (mSurfaceTexture != null) {
                GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT);
                mSurfaceTexture.updateTexImage();
                GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, mMVPMatrix, 0);
                GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, mPosCoordinate.length / 2);
            }
        }
    }

    public static int createOESTextureObject() {
        int[] tex = new int[1];
        //生成一个纹理
        GLES20.glGenTextures(1, tex, 0);
        //将此纹理绑定到外部纹理上
        GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, tex[0]);
        //设置纹理过滤参数
        GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
                GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_NEAREST);
        GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
                GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_LINEAR);
        GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
                GL10.GL_TEXTURE_WRAP_S, GL10.GL_CLAMP_TO_EDGE);
        GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
                GL10.GL_TEXTURE_WRAP_T, GL10.GL_CLAMP_TO_EDGE);
        GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0);
        return tex[0];
    }
}



当然,大多数情况下渲染顶点着色器及片段着色器的代码会编写一个glsl的文件放到assets目录下进行访问。 
下面是另外一个操作方式:

vertex_texture.glsl文件

uniform mat4 textureTransform;
attribute vec2 inputTextureCoordinate;
attribute vec4 position;            //NDK坐标点
varying   vec2 textureCoordinate; //纹理坐标点变换后输出

 void main() {
     gl_Position = position;
     textureCoordinate = inputTextureCoordinate;
 }



fragment_texture.glsl文件:

#extension GL_OES_EGL_image_external : require
precision mediump float;
uniform samplerExternalOES videoTex;
varying vec2 textureCoordinate;

void main() {
    vec4 tc = texture2D(videoTex, textureCoordinate);
    float color = tc.r * 0.3 + tc.g * 0.59 + tc.b * 0.11;//这里进行的颜色变换处理,传说中的黑白滤镜。
    gl_FragColor = vec4(color,color,color,1.0);
}



读取文件内容方式:

public static String read(Context context, String fileName) {
        String result = null;
        try {
            InputStream is = context.getResources().getAssets().open("Shader/" + fileName);
            int length = is.available();
            byte[] buffer = new byte[length];
            is.read(buffer);
            result = new String(buffer, "utf-8");
        } catch (IOException e) {
            e.printStackTrace();
        }

        return result;
    }



具体实现在上面代码creatProgram()下注释掉通常做法的那部分。

实现效果


遇到的问题
使用OpenGl处理数据之后再GlsurfaceView上图片镜像了,咋办?这种问题要么出在绘制顶点坐标,要么出在源数据上,但是通过其他的比如SurfaceView直接预览数据没问题,那么只能是第一种可能了。仔细回看下代码,果然修改渲染颜色矩阵显示出来的数据方向都是不同的。
下面是我改动矩阵产生的结果: 


问题根本找到了,如何解决呢?起始点就四个点,一个点就最多三个连接点,而且起始点不能和不相邻的点进行连接(一旦连接就会出现上面的第二种情况),所以最后4个点只有8种可能性,将所有点的都试一遍,得出如下结论(经过几轮测试,基本结论如下):

还有一个关于前后摄像头的切换的问题,我上面的做法是在点击切换摄像头操作的时候只针对摄像头进行了释放重启操作,直接在onDrawFrame方法中对渲染矩阵进行了修改,没有对SurfaceTexture进行数据清除(具体看上面代码)。然而也看了一些主流的第三方Demo,这里不列出名字了。他们的做法是摄像头和surfaceTexture一块释放。当然,两种方式都可以,我上面的那种方式暂时没找到什么问题,而且我通过实测比第二种方式看到相机数据的时间要快一点。
运行效果


5. 总结
弄清楚没个步骤之后你就会发现之前感觉无从下手的东西也就这样。没什么大不了的。 


个人建议: 
做相机项目,最好能将每个步骤都弄清楚,逻辑理清楚了会节省很大一部分时间。刚开始的时候在做这部分是拿别人一个Demo,出错了,无从下手,又去百度找解决办法,浪费了一大堆时间,整个项目做完了,还是没有对此有个深刻的认识,真要说还说不出个所以然来。重新整理下,会了解到很多东西。
 

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值