手把手带你实现抖音短视频特效

Matrix.scaleM(mMvpMatrix, 0, scale, scale, 1.0f);
glUniformMatrix4fv(mMvpMatrixLocation, 1, false, mMvpMatrix, 0);
//设置色值偏移的量
float textureCoordOffset = 0.01f * mProgress;
glUniform1f(mTextureCoordOffsetLocation, textureCoordOffset);
super.onDraw(textureId, texMatrix);
}

4.『毛刺』

抖音效果图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 毛刺

我的实现效果图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 毛刺

『毛刺』的效果还原的不是很完整,动画的参数没有调整好。

代码实现

看到这个效果,我们先分析一下,将视频逐帧分析,可以看到以下的截图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 毛刺截图

仔细观察这个图片,我们可以发现,其实毛刺效果就是某一行像素值偏移了一段距离,看着就像是图片被撕裂了,并且这个偏移是随着y轴随机变化的,这样看起来效果更自然,并且观察gif图可以看到,除了撕裂,还有个色值偏移的效果。色值偏移在介绍 “抖动” 效果时已经讲过了,那么这里只要解决撕裂效果就可以了。

4.1 顶点着色器

uniform mat4 uTexMatrix;
attribute vec2 aPosition;
attribute vec4 aTextureCoord;
varying vec2 vTextureCoord;
uniform mat4 uMvpMatrix;
void main(){
gl_Position = uMvpMatrix * vec4(aPosition,0.1,1.0);
vTextureCoord = (uTexMatrix * aTextureCoord).xy;
}

4.2 片元着色器

#extension GL_OES_EGL_image_external : require
precision highp float;
varying vec2 vTextureCoord;
uniform samplerExternalOES uTexture;
//这是个二阶向量,x是横向偏移的值,y是阈值
uniform vec2 uScanLineJitter;
//颜色偏移的值
uniform float uColorDrift;
//随机函数
float nrand(in float x, in float y){
return fract(sin(dot(vec2(x, y), vec2(12.9898, 78.233))) * 43758.5453);
}

void main(){
float u = vTextureCoord.x;
float v = vTextureCoord.y;
float jitter = nrand(v,0.0) * 2.0 - 1.0;
float drift = uColorDrift;
float offsetParam = step(uScanLineJitter.y,abs(jitter));
jitter = jitter * offsetParam * uScanLineJitter.x;
vec4 color1 = texture2D(uTexture,fract(vec2( u + jitter,v)));
vec4 color2 = texture2D(uTexture,fract(vec2(u + jitter + v*drift ,v)));
gl_FragColor = vec4(color1.r,color2.g,color1.b,1.0);
}

这里重点讲解下片元着色器的代码,随机函数就是代码中的nrand函数

fract、dot和sin是opengl自带的函数,意思是取某个数的小数部分,即fract(x) = x - floor(x); dot是向量点乘,sin就是正弦函数

如上代码所示,我们首先取出当前像素的x、y的值,然后用y去计算随机数

float jitter = nrand(v,0.0) * 2.0 - 1.0;//这里得到一个-1到1的数

然后接下来,我们计算当前这一行的像素要往左偏,还是往右偏

float offsetParam = step(uScanLineJitter.y,abs(jitter));//step是gl自带函数,意思是,如果第一个参数大于第二个参数,那么返回0,否则返回1

所以这句话的意思就是,判断当前的随机数是否大于某个阈值,如果大于这个阈值,那么就偏移,否则就不偏移。通过控制这个阈值,我们可以改变当前视频的混乱度(越混乱,撕裂的像素就越多)

接着是计算某行像素的偏移值

jitter = jitter * offsetParam * uScanLineJitter.x;//offsetParam如果是0,就不便宜了,如果是1,就偏移jitteruScanLineJitter.x的距离,其中uScanLineJitter.x是最大偏移值
//这里计算最终的像素值,纹理坐标是0到1之间的数,如果小于0,那么图像就捅到屏幕右边去,如果超过1,那么就捅到屏幕左边去。
vec4 color1 = texture2D(uTexture,fract(vec2( u + jitter,v)));
vec4 color2 = texture2D(uTexture,fract(vec2(u + jitter + v
drift ,v)));

4.3 动画代码

动画代码这里就不贴了,大概就是根据当前帧数控制

//这是个二阶向量,x是横向偏移的值,y是阈值
uniform vec2 uScanLineJitter;
//颜色偏移的值
uniform float uColorDrift;

这两个参数的值,uScanLineJitter.x越大,横向撕裂的距离就越大;uScanLineJitter.y越大,屏幕上被撕裂的像素就越多

5.『缩放』

抖音效果图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 缩放

我的实现效果图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 缩放

代码实现

这个效果比较简单,就是放大然后缩小 不停地循环

5.1顶点着色器

uniform mat4 uTexMatrix;
attribute vec2 aPosition;
attribute vec4 aTextureCoord;
varying vec2 vTextureCoord;
//缩放矩阵
uniform mat4 uMvpMatrix;
void main(){
gl_Position = uMvpMatrix * vec4(aPosition,0.1,1.0);
vTextureCoord = (uTexMatrix * aTextureCoord).xy;
}

5.2片元着色器

#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTextureCoord;
uniform samplerExternalOES uTexture;
void main(){
gl_FragColor = texture2D(uTexture,vTextureCoord);
}

5.3动画代码

动画代码比较简单,就是控制缩放矩阵来放大缩小,关键代码如下:

private int mScaleMatrixLocation;
//最大缩放是1.3倍
private static final float mScale = 0.3f;
private int mFrames;
//最大帧数是14帧,通过这个控制动画速度
private int mMaxFrames = 14;
private int mMiddleFrames = mMaxFrames / 2;
private float[] mScaleMatrix = new float[16];
public void onDraw(int textureId,float texMatrix[]){
//初始化矩阵
Matrix.setIdentityM(mScaleMatrix, 0);
float progress;
if (mFrames <= mMiddleFrames) {
progress = mFrames * 1.0f / mMiddleFrames;
} else {
progress = 2f - mFrames * 1.0f / mMiddleFrames;
}
float scale = 1f + mScale * progress;
Matrix.scaleM(mScaleMatrix, 0, scale, scale, scale);
glUniformMatrix4fv(mScaleMatrixLocation, 1, false, mScaleMatrix, 0);
mFrames++;
if (mFrames > mMaxFrames) {
mFrames = 0;
}

}

6.『闪白』

抖音实现效果图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 闪白

我的实现效果图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 闪白

代码实现

这个效果比较简单,就是个相机过度曝光的感觉,具体实现就是给RGB的每个分量增加一个固定的值。

6.1顶点着色器

uniform mat4 uTexMatrix;
attribute vec2 aPosition;
attribute vec4 aTextureCoord;
varying vec2 vTextureCoord;
void main(){
gl_Position = vec4(aPosition,0.1,1.0);
vTextureCoord = (uTexMatrix * aTextureCoord).xy;
}

6.2片元着色器

#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTextureCoord;
uniform samplerExternalOES uTexture;
//修改这个值,可以控制曝光的程度
uniform float uAdditionalColor;
void main(){
vec4 color = texture2D(uTexture,vTextureCoord);
gl_FragColor = vec4(color.r + uAdditionalColor,color.g + uAdditionalColor,color.b + uAdditionalColor,color.a);
}

6.3动画代码

public void onDraw(int textureId,float[] texMatrix){
float progress;
if (mFrames <= mHalfFrames) {
progress = mFrames * 1.0f / mHalfFrames;
} else {
progress = 2.0f - mFrames * 1.0f / mHalfFrames;
}
mFrames++;
if (mFrames > mMaxFrames) {
mFrames = 0;
}
glUniform1f(mAdditionColorLocation, progress);
…绘制
}

7.『幻觉』

抖音实现效果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 huanjue.gif

我的实现效果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 huanjue1.gif

代码实现

第一次看到这个效果的时候,我是有点懵逼的,因为一点头绪都没有,当时只想把电脑扔了。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 throw-away-your-laptop

后来逐帧分析的时候,还是发现了一丝端倪。这个特效大概可以总结为三个部分:

  • 滤镜
  • 残影
  • 残影颜色分离
7.1 滤镜

用两张图来对比一下,大家大概就知道了

滤镜前

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 滤镜前

滤镜后

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 751536631171_.pic.jpg

可以看到,在使用了幻觉特效之后,图片有种偏暗蓝的感觉。这种情况下咋整?一般有两种选择,找视觉同学帮你还原,或者是,反编译apk包搜代码。我选择了后者。在将抖音apk解压之后,搜索资源文件,发现了一张图——lookup_vertigo.png,就是这个东东

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传lut

这个是啥呢?就是一个颜色查找表,滤镜可以通过代码手动转换颜色或者把颜色转换信息写在一个lut文件里,然后要用的时候直接从图片里查找即可。 LUT文件使用代码如下:

//这个是LUT文件的纹理
uniform sampler2D uTexture2;
vec4 lookup(in vec4 textureColor){
mediump float blueColor = textureColor.b * 63.0;
mediump vec2 quad1;
quad1.y = floor(floor(blueColor) / 8.0);
quad1.x = floor(blueColor) - (quad1.y * 8.0);
mediump vec2 quad2;
quad2.y = floor(ceil(blueColor) / 8.0);
quad2.x = ceil(blueColor) - (quad2.y * 8.0);
highp vec2 texPos1;
texPos1.x = (quad1.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.r);
texPos1.y = (quad1.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.g);
texPos1.y = 1.0-texPos1.y;
highp vec2 texPos2;
texPos2.x = (quad2.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.r);
texPos2.y = (quad2.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.g);
texPos2.y = 1.0-texPos2.y;
lowp vec4 newColor1 = texture2D(uTexture2, texPos1);
lowp vec4 newColor2 = texture2D(uTexture2, texPos2);
lowp vec4 newColor = mix(newColor1, newColor2, fract(blueColor));
return newColor;
}

将我们的视频帧通过这个lut文件转换之后,就是『幻觉』滤镜的效果了。 在做滤镜的时候碰到了一个问题,就是普通的sampler2D纹理无法和samplerExternalOES纹理共用,具体情况就是,当在glsl代码中同时存在这两种纹理时,代码是无法正常运行的。那么怎么解决呢?如果只是视频预览,解决的方法比较多,比如使用Camera类的previewCallback,拿到每一帧的byte数组(yuv数据)之后,将yuv数据转成rgb,再将rgb转成纹理来显示就可以了。这种方法虽然可行,但是因为需要数据转换,效率比较差。那有没有比较优雅并且高效的解决办法呢?答案是——FBO。

在OpenGL渲染管线中,几何数据和纹理经过多次转化和多次测试,最后以二维像素的形式显示在屏幕上。OpenGL管线的最终渲染目的地被称作帧缓存(framebuffer)。帧缓冲是一些二维数组和OpenG所使用的存储区的集合:颜色缓存、深度缓存、模板缓存和累计缓存。一般情况下,帧缓存完全由window系统生成和管理,由OpenGL使用。这个默认的帧缓存被称作“window系统生成”(window-system-provided)的帧缓存。 在OpenGL扩展中,GL_EXT_framebuffer_object提供了一种创建额外的不能显示的帧缓存对象的接口。为了和默认的“window系统生成”的帧缓存区别,这种帧缓冲成为应用程序帧缓存(application-createdframebuffer)。通过使用帧缓存对象(FBO),OpenGL可以将显示输出到引用程序帧缓存对象,而不是传统的“window系统生成”帧缓存。而且,它完全受OpenGL控制。

总结来说就是,FBO相当于在内存中创建了一个Canvas,我们可以将这块画布和一个纹理绑定,然后先将内容画到画布上,之后就可以通过纹理对这块画布里的内容为所欲为了。

FBO的使用下文会继续说明。

7.2残影

『幻觉』特效最明显的一个效果就是,画面中的物体移动时会有残影,这个如何解决呢?仔细思考一下我们就可以得到答案——保留上一帧的内容,将其透明化,然后和当前帧的内容混合。不断重复这个过程,就会得到残影的效果。那么如何保留上一帧的内容呢?答案还是——FBO。

7.3残影颜色分离

这个可能不好理解,看个截图大家应该就懂了。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 残影颜色分离

可以看到,截图中的那支笔的残影是七彩的。

这个如何解决呢?我们在将当前帧和上一帧内容混合时,肯定是操作每一个像素点的RGB分量的,那么这个七彩色应该就是从这里入手,肯定有一个混合公式

vec4 currentFrame;
vec4 lastFrame;
gl_FragColor = vec4(a1 * currentFrame.r + a2 * lastFrame.r,b1 * currentFrame.g + b2 * lastFrame.g,c1 * currentFrame.b + c2 * lastFrame.b,1.0);

我们要做的就是把这个公式里的a,b,c值给算出来。那么如何计算呢?这里有个小窍门,我们假定currentFrame的rgb值都是0,lastFrame的rgb都是1。你可能会问,这是什么马叉虫操作呢?我们让上一帧是黑色的,这一帧是白色的就可以啦。废话不多说,看图。

我们找个黑色的背景,白色的物体——黑色鼠标垫和纸巾,效果大概如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 颜色分离效果图

我们逐帧分析,很快就能算出我们想要的结果。

首先我们看前面三帧

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 逐帧分析1

可以看到,当纸巾向下移动时,露出来的部分是蓝色的(当前帧是白色,上一帧是黑色),而上面的部分是橙色的(此时上一帧是白色的,当前帧是黑色的),那么从这里我们得出一个结论就是,c1=1,c2 = 0,因为橙色的部分蓝色色值是0。

再看后面几帧

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 逐帧分析1

可以看到,最顶上的那个残影,最终变得特别的红,那么我们可以知道,a1是一个接近0的数,而a2是一个十分接近1的数,为什么不能是1呢?因为如果是1,那么lastFrame的色值就会一直保留了,并不会随着帧数增加逐渐变淡消失。

得出a和c的值以后,b的值我们大概猜测一下,试几个数字之后就能得到我们的结果了。最终得出的公式如下:

gl_FragColor = vec4(0.95 * lastFrame.r + 0.05* currentFrame.r,currentFrame.g * 0.2 + lastFrame.g * 0.8, currentFrame.b,1.0);

这个公式的效果已经十分接近了。

7.4关键代码

private RenderBuffer mRenderBuffer;

private RenderBuffer mRenderBuffer2;

private RenderBuffer mRenderBuffer3;

private int mLutTexture;
//当前帧
private int mCurrentFrameProgram;
//上一帧
private int mLastFrameProgram;

private boolean mFirst = true;
@Override
public void draw(int textureId, float[] texMatrix, int canvasWidth, int canvasHeight) {
if (mRenderBuffer == null) {
mRenderBuffer = new RenderBuffer(GL_TEXTURE8, canvasWidth, canvasHeight);
mRenderBuffer2 = new RenderBuffer(GL_TEXTURE9, canvasWidth, canvasHeight);
mRenderBuffer3 = new RenderBuffer(GL_TEXTURE10, canvasWidth, canvasHeight);
mLastFrameProgram = GLUtils.buildProgram(FileUtils.readFromRaw(R.raw.vertex_common), FileUtils.readFromRaw(R.raw.fragment_common));
mCurrentFrameProgram = GLUtils.buildProgram(FileUtils.readFromRaw(R.raw.vertex_common), FileUtils.readFromRaw(R.raw.fragment_current_frame));
mLutTexture = GLUtils.genLutTexture();
android.opengl.GLUtils.texImage2D(GL_TEXTURE_2D, 0, BitmapFactory.decodeResource(AppProfile.getContext().getResources(), R.raw.lookup_vertigo), 0);
}
mRenderBuffer.bind();
//这里使用samplerExternalOES纹理将当前的视频内容绘制到缓存中
super.draw(textureId, texMatrix, canvasWidth, canvasHeight);
mRenderBuffer.unbind();
//绘制当前帧
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
drawCurrentFrame();
//将当前帧的内容保存到缓存中
mRenderBuffer3.bind();
drawCurrentFrame();
mRenderBuffer3.unbind();
//只用两个buffer的话,屏幕中会有黑格子
//把缓存3中的内容画到缓存2中,缓存2中的内容在下一帧会用到
mRenderBuffer2.bind();
drawToBuffer();
mRenderBuffer2.unbind();
mFrames++;
mFirst = false;
}
private void drawCurrentFrame() {
glUseProgram(mCurrentFrameProgram);
int textureId = mRenderBuffer.getTextureId();
setup(mCurrentFrameProgram, new int[]{textureId, mFirst ? textureId : mRenderBuffer2.getTextureId(), mLutTexture});
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
}
private void drawToBuffer() {
glUseProgram(mLastFrameProgram);
setup(mLastFrameProgram, new int[]{mRenderBuffer3.getTextureId()});
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);

}
private void setup(int programId, int[] textureId) {
glUseProgram(programId);
int aPositionLocation = glGetAttribLocation(programId, “aPosition”);
int aTexCoordLocation = glGetAttribLocation(programId, “aTextureCoord”);
mRendererInfo.getVertexBuffer().position(0);
glEnableVertexAttribArray(aPositionLocation);
glVertexAttribPointer(aPositionLocation, 2,
GL_FLOAT, false, 0, mRendererInfo.getVertexBuffer());
mRendererInfo.getTextureBuffer().position(0);
glEnableVertexAttribArray(aTexCoordLocation);
glVertexAttribPointer(aTexCoordLocation, 2,
GL_FLOAT, false, 0, mRendererInfo.getTextureBuffer());
for (int i = 0; i < textureId.length; i++) {
int textureLocation = glGetUniformLocation(programId, “uTexture” + i);
glActiveTexture(GL_TEXTURE0 + i);
glBindTexture(GLES20.GL_TEXTURE_2D, textureId[i]);
glUniform1i(textureLocation, i);
}
}

帧缓存代码

public class RenderBuffer {
private int mTextureId;

private int mActiveTextureUnit;

private int mRenderBufferId;

private int mFrameBufferId;

private int mWidth, mHeight;

public RenderBuffer(int activeTextureUnit, int width, int height) {
this.mActiveTextureUnit = activeTextureUnit;
this.mWidth = width;
this.mHeight = height;
int[] buffer = new int[1];
GLES20.glActiveTexture(activeTextureUnit);
mTextureId = GLUtils.genTexture();
IntBuffer texBuffer =
ByteBuffer.allocateDirect(width * height * 4).order(ByteOrder.nativeOrder()).asIntBuffer();
GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height, 0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, texBuffer);

GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_LINEAR);
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_LINEAR);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_S, GL10.GL_CLAMP_TO_EDGE);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_T, GL10.GL_CLAMP_TO_EDGE);

// Generate frame buffer
GLES20.glGenFramebuffers(1, buffer, 0);
mFrameBufferId = buffer[0];
// Bind frame buffer
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, mFrameBufferId);
// Generate render buffer
GLES20.glGenRenderbuffers(1, buffer, 0);
mRenderBufferId = buffer[0];
// Bind render buffer
GLES20.glBindRenderbuffer(GLES20.GL_RENDERBUFFER, mRenderBufferId);
GLES20.glRenderbufferStorage(GLES20.GL_RENDERBUFFER, GLES20.GL_DEPTH_COMPONENT16, width, height);
}

public void bind() {
GLES20.glViewport(0, 0, mWidth, mHeight);
checkGlError(“glViewport”);
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, mFrameBufferId);
checkGlError(“glBindFramebuffer”);
GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
GLES20.GL_TEXTURE_2D, mTextureId, 0);
checkGlError(“glFramebufferTexture2D”);
GLES20.glFramebufferRenderbuffer(GLES20.GL_FRAMEBUFFER, GLES20.GL_DEPTH_ATTACHMENT,
GLES20.GL_RENDERBUFFER, mRenderBufferId);
checkGlError(“glFramebufferRenderbuffer”);
}

public void unbind() {
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
}

public int getTextureId(){
return mTextureId;
}
}

最后

希望大家能有一个好心态,想进什么样的公司要想清楚,并不一定是大公司,我选的也不是特大厂。当然如果你不知道选或是没有规划,那就选大公司!希望我们能先选好想去的公司再投或内推,而不是有一个公司要我我就去!还有就是不要害怕,也不要有压力,平常心对待就行,但准备要充足。最后希望大家都能拿到一份满意的 offer !如果目前有一份工作也请好好珍惜好好努力,找工作其实挺累挺辛苦的。

这里附上上述的面试题相关的几十套字节跳动,京东,小米,腾讯、头条、阿里、美团等公司19年的面试题。把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节。

由于篇幅有限,这里以图片的形式给大家展示一小部分。


《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!
blic int getTextureId(){
return mTextureId;
}
}

最后

希望大家能有一个好心态,想进什么样的公司要想清楚,并不一定是大公司,我选的也不是特大厂。当然如果你不知道选或是没有规划,那就选大公司!希望我们能先选好想去的公司再投或内推,而不是有一个公司要我我就去!还有就是不要害怕,也不要有压力,平常心对待就行,但准备要充足。最后希望大家都能拿到一份满意的 offer !如果目前有一份工作也请好好珍惜好好努力,找工作其实挺累挺辛苦的。

这里附上上述的面试题相关的几十套字节跳动,京东,小米,腾讯、头条、阿里、美团等公司19年的面试题。把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节。

由于篇幅有限,这里以图片的形式给大家展示一小部分。

[外链图片转存中…(img-5HYRwvaO-1715311523458)]
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值