张义洪 發表於 2025-11-20 11:34:00

Android动效探索:彻底弄清如何让你的视频更加酷炫

<blockquote data-first-child="" data-pid="5wxMbJxB">作者: vivo 互联网客户端团队- Xu Jie<br>在Android移动端视频处理领域,除了基本的播放功能外,添加动画和滤镜等特效已经成为提升用户体验的重要手段。然而,很多开发人员可能对于实现这些功能所需的技术细节感到困惑。因此,本文旨在提供一个详细的指导,帮助开发人员掌握如何使用开源MediaPlayer或自定义播放器,并利用OpenGL ES来实现视频动画和滤镜效果。</blockquote>
<p data-pid="_43KzI1J">1分钟看图掌握核心观点👇</p>
<div class="GifPlayer css-1isopsn" data-size="normal" data-za-detail-view-path-module="GifItem"><img alt="动图封面" width="640" class="ztext-gif lazyload" data-thumbnail="https://pica.zhimg.com/v2-658e8b8d9231af653fcff14748868b0b_720w.jpg?source=d16d100b" data-size="normal" data-src="https://pica.zhimg.com/v2-658e8b8d9231af653fcff14748868b0b_720w.jpg?source=d16d100b">
<div class="GifPlayer-icon css-d39tw7">&nbsp;</div>

</div>
<p data-pid="HUYOka3n">从事Android移动端开发的人员一定会跟动效打交道,并且对于常见的帧动画、属性动画使用起来更是得心应手,但是你一定也遇到一些问题,就是在做动效时,你能使用的资源无非就是图片、gif图或者PAG图,这些资源只能做简短、复杂度一般的效果,如果要做一个时间跨度较长并且动效要求较高的动效,这时候就需要借助视频来做了。</p>
<h2>一、视频做动画,你可能无从下手</h2>
<p data-pid="B5t_J7c6">我们可以直接使用Mediaplayer、VideoView等开源播放器把UI设计师给我们的视频文件播放出来,一般情况下这样就够了。但是有一天UI设计师让你在视频的第50-100帧做些处理,视频画面做下抖动、放大等的处理,你可能会有些不知所措,这时候你的脑子里面可能有这些概念:</p>

<img width="1080" height="792" class="origin_image zh-lightbox-thumb lazy lazyload" data-caption="" data-size="normal" data-rawwidth="1080" data-rawheight="792" data-original="https://pica.zhimg.com/v2-741f1b49900e2fec714c448eb28fb864_r.jpg?source=d16d100b" data-actualsrc="https://pic1.zhimg.com/v2-741f1b49900e2fec714c448eb28fb864_720w.jpg?source=d16d100b" data-lazy-status="ok" data-src="https://pic1.zhimg.com/80/v2-741f1b49900e2fec714c448eb28fb864_720w.webp?source=d16d100b">
<p data-pid="C0elWSQk">那么问题来了,究竟使用什么方案才能实现UI要求的效果?这个时候,你可能会deepseek或者找些技术博客去了解一下,不过结果无非是这样的,仍然是无法把应该具备的知识点串起来:</p>

<img width="766" height="613" class="origin_image zh-lightbox-thumb lazy lazyload" data-caption="" data-size="normal" data-rawwidth="766" data-rawheight="613" data-original="https://pic1.zhimg.com/v2-e580ff4ae86e5d5659650ad32d29a341_r.jpg?source=d16d100b" data-actualsrc="https://picx.zhimg.com/v2-e580ff4ae86e5d5659650ad32d29a341_720w.jpg?source=d16d100b" data-lazy-status="ok" data-src="https://picx.zhimg.com/80/v2-e580ff4ae86e5d5659650ad32d29a341_720w.webp?source=d16d100b">
<p data-pid="hOna9GvL">总之这时候的你,还是无从下手!</p>
<p data-pid="hUSLdIhb">所以如果没有系统的了解,这时候就有可能使用错方案,达不到效果,比如你可能会想到是不是在原先的视频播放器窗口覆盖一层View,View动态显示截图的视频窗口图片,这种方案就是存在问题的。那么本文就是为了帮助梳理这些知识点,整理出了为了实现视频动效的完整实现流程,话不多说,先看实现结构图:</p>

<img width="1080" height="861" class="origin_image zh-lightbox-thumb lazy lazyload" data-caption="" data-size="normal" data-rawwidth="1080" data-rawheight="861" data-original="https://picx.zhimg.com/v2-2ee88eccae8124104f3e2e1aea745c3f_r.jpg?source=d16d100b" data-actualsrc="https://picx.zhimg.com/v2-2ee88eccae8124104f3e2e1aea745c3f_720w.jpg?source=d16d100b" data-lazy-status="ok" data-src="https://picx.zhimg.com/80/v2-2ee88eccae8124104f3e2e1aea745c3f_720w.webp?source=d16d100b">
<p data-pid="arsyO6np">仔细看上面这张结构图,你的零散的知识点也许可以串联起来一些了,但是可能还不够全面!</p>
<p data-pid="b-4Z6JK5">结论先行,实现一个视频动画有两种方式:</p>
<p data-pid="NG8O8ofS">实现方案1</p>
<p data-pid="9tOh2I4q">直接使用开源的MediaPlayer播放器,然后利用OpenGL ES进行图形管线的接管与处理,对每一帧图片再去处理。优点是实现起来更加的方便,可以快速上手,但是缺点就是你只能对既有的视频帧做处理,没办法去修改视频帧底层的逻辑,虽然可以实现复杂的动效,但是仍然是受限的。</p>
<p data-pid="FGW1n4OY">实现方案2</p>
<p data-pid="Taxk14vc">使用FFmpeg自己手撸一个播放器,要是实现简单动效,就借助原生的ANativeWindow,可以直接操作帧缓冲区(FrameBuffer),属于内存到屏幕的像素级拷贝,没有GPU的参与;或者使用GL介入,做视频纹理的管理,实现更加复杂的动效。这个实现方式缺点是比较复杂,但是最大的优点就是FFmpeg本身可以做到跨平台编译,不止是可以使用在Android,也可以使用在iOS平台。另外可以修改视频的更底层逻辑,满足更多的动效需求,比如类似抖音,有些特效都是可以做的。</p>
<p data-pid="sWZ0r4Ki">两个方案有共同点,都需要OpenGL ES进行渲染视图,很多开发者只是了解这个概念,不清楚为什么要使用它,下面我们来彻底讲清楚。</p>
<h2>二、初识OpenGL ES相关概念</h2>
<p data-pid="mQqvvSJd">OpenGL,全称是Open Graphics Library,译名:开放图形库或者“开放式图形库”,用于渲染 2D、3D 矢量图形的跨语言、跨平台的应用程序编程接口(API)。OpenGL 跟语言和平台无关。OpenGL 纯粹专注于渲染,而不提供输入、音频以及窗口相关的 API。这些都有硬件和底层操作系统提供。OpenGL 的高效实现(利用了图形加速硬件)存在于 Windows,部分 UNIX 平台和 Mac OS,可以便捷利用显卡等设备。</p>
<p data-pid="1stXFHKa">也就是说,OpenGL就是绘制图形使用的,那么你的视频中播放的一帧帧图片,也是图形,所以你要是想做动画,也就是对图形做形变,就需要使用OpenGL帮你绘制出最终的图形。</p>
<p data-pid="y4ImOpc8">OpenGL ES (OpenGL for Embedded Systems) 是 OpenGL 三维图形 API 的子集,针对手机、PDA和游戏主机等嵌入式设备而设计。经过多年发展,现在主要有两个版本,OpenGL ES 1.x 针对固定管线硬件的,OpenGL ES 2.x 针对可编程管线硬件。Android 2.2 开始支持 OpenGL ES 2.0,OpenGL ES 2.0 基于 OpenGL 2.0 实现。一般在 Android 系统上使用 OpenGL,都是使用 OpenGL ES 2.0,1.0 仅作了解即可。我们在Android开发中,使用的稳定版本,也都是ES 2.0。</p>
<h3>2.1 坐标系的概念</h3>
<p data-pid="gmVKYyMd">作为一个Android移动端开发者。应该知道坐标系的概念,物体的位置都是通过坐标系确定的。OpenGL ES 采用的是右手坐标,选取屏幕中心为原点,从原点到屏幕边缘默认长度为 1,也就是说默认情况下,从原点到(1,0,0)的距离和到(0,1,0)的距离在屏幕上展示的并不相同。坐标系向右为 X 正轴方向,向左为 X 负轴方向,向上为 Y 轴正轴方向,向下为 Y 轴负轴方向,屏幕面垂直向上为 Z 轴正轴方向,垂直向下为 Z 轴负轴方向。</p>

<img width="1070" height="974" class="origin_image zh-lightbox-thumb lazy lazyload" data-caption="" data-size="normal" data-rawwidth="1070" data-rawheight="974" data-original="https://picx.zhimg.com/v2-48de68cc14a792fddb72855ae32d3d96_r.jpg?source=d16d100b" data-actualsrc="https://pica.zhimg.com/v2-48de68cc14a792fddb72855ae32d3d96_720w.jpg?source=d16d100b" data-lazy-status="ok" data-src="https://pica.zhimg.com/80/v2-48de68cc14a792fddb72855ae32d3d96_720w.webp?source=d16d100b">
<p data-pid="NyrF1OPa">总结一下:在 OpenGL 中,世界就是一个坐标系,一个只有 X、Y 和 Z 三个纬度的世界,其它的东西都需要你自己来建设,你能用到的原材料就只有点、线和面(三角形),当然还会有其他材料,比如阳光(光照)和颜色(材质)。</p>
<h3>2.2 相机</h3>
<p data-pid="XR0x5R3n">在OpenGL中,"相机"的概念类似于现实世界的相机或人眼,其功能是捕获三维世界中的场景,并呈现到二维视图上。通过调整“相机”参数,可以改变观看的角度和范围,从而影响最终呈现的效果。</p>
<h3>2.3 纹理</h3>
<p data-pid="m1lsl1nQ">纹理是二维图像,用于映射到三维物体的表面上,使其看起来更加真实和细腻。纹理映射是一种重要的渲染技术,通过将纹理应用于物体表面,赋予物体颜色、图案等视觉效果,而不改变其几何形态。纹理的作用类似于为物体穿上“衣服”,提升视觉上的真实感。</p>
<h3>2.4 OpenGL ES的使用流程</h3>

<img width="1080" height="1681" class="origin_image zh-lightbox-thumb lazy lazyload" data-caption="" data-size="normal" data-rawwidth="1080" data-rawheight="1681" data-original="https://picx.zhimg.com/v2-315aab7e57c4281f3fb28e5d0f1d341d_r.jpg?source=d16d100b" data-actualsrc="https://pic1.zhimg.com/v2-315aab7e57c4281f3fb28e5d0f1d341d_720w.jpg?source=d16d100b" data-lazy-status="ok" data-src="https://pic1.zhimg.com/80/v2-315aab7e57c4281f3fb28e5d0f1d341d_720w.webp?source=d16d100b">
<p data-pid="C7RuXAzO">通过上面的流程,我们可以确认图形的渲染大致可以表述如下:</p>
<ul>
<li data-pid="a5j85rDy">管理一个 surface,这个 surface 就是一块特殊的内存,能直接排版到 android 的视图 view 上。</li>
<li data-pid="caol2az1">管理一个 EGL display,它能让 opengl 把内容渲染到上述的 surface 上。</li>
<li data-pid="3uAlVXNH">用户可以自定义渲染器(render)。</li>
<li data-pid="oQhfXapa">让渲染器在独立的线程里运作,和 UI 线程分离。传统的 View 及其实现类,渲染等工作都是在主线程上完成的。</li>

</ul>
<p data-pid="nALVuEgo">在Android开发中,我们就是借助SurfaceView来进行视图的渲染,SurfaceView的实质是将底层显存 Surface 显示在界面上,而 GLSurfaceView 做的就是在这个基础上增加 OpenGL 绘制环</p>
<p data-pid="6GAjXlYX">有了上面这些概念之后,那么下面我们从简单的MediaPlayer入手,从图形管线接入的角度,彻底弄清GLSurfaceView的工作原理,再去介绍手撸播放器如何来做。让你的知识点完全串联起来,之前不曾了解的知识点,通过本文也可以进一步的补充。</p>
<h2>三、轻松上手-MediaPlayer实现视频动画</h2>
<p data-pid="Mq1nTTfF">看一下完整的实现视频动画的流程图:</p>

<img width="1080" height="1021" class="origin_image zh-lightbox-thumb lazy lazyload" data-caption="" data-size="normal" data-rawwidth="1080" data-rawheight="1021" data-original="https://picx.zhimg.com/v2-d27155a1cf0ce499483b85e0ed6f3323_r.jpg?source=d16d100b" data-actualsrc="https://pica.zhimg.com/v2-d27155a1cf0ce499483b85e0ed6f3323_720w.jpg?source=d16d100b" data-lazy-status="ok" data-src="https://pica.zhimg.com/80/v2-d27155a1cf0ce499483b85e0ed6f3323_720w.webp?source=d16d100b">
<h3>1. OpenGL环境搭建</h3>
<p data-pid="iDOdHsrp">先看下引用GLSurfaceView的代码结构。第一步是创建一个Activity,并且在布局文件里面构建一个自定义的VideoGLSurfaceView,Activity里面声明该VideoGLSurfaceView准备使用。</p>
<p data-pid="-h_jy7Pf">布局文件如下:</p>
<div class="highlight">
<pre><code class="language-text">// 其他代码
&lt;com.ne.firstvideo.gl.VideoGLSurfaceView
    android:id\="@+id/glSurfaceView"
    android:layout\_width\="match\_parent"
    android:layout\_height\="200dp"
    app:layout\_constraintTop\_toBottomOf\="@+id/original\_surfaceView"\&gt;
&lt;/com.ne.firstvideo.gl.VideoGLSurfaceView\&gt;
// 其他代码
</code></pre>
</div>
<p data-pid="WZYZGQyQ">VideoGLSurfaceView里面需要创建GlSurView环境</p>
<div class="highlight">
<pre><code class="language-text">private voidinit(Context context) {
    // 使用 OpenGL ES 2.0 以兼容更多设备
    setEGLContextClientVersion(2);
    // 关键步骤 1: 设置透明背景
    setEGLConfigChooser(new TransparentConfigChooser());
    setZOrderOnTop(true); // 必须设置
    getHolder().setFormat(PixelFormat.TRANSLUCENT); // 必须设置

    renderer \= new VideoRenderer(this);
    setRenderer(renderer);
    setRenderMode(RENDERMODE\_WHEN\_DIRTY);
}
</code></pre>
</div>
<p data-pid="eBICyv8T">在 OpenGL 中,一旦我们设置好了基本环境(即画布),就可以开始绘制图形了。在这个过程中,着色器(shader)相当于画笔的功能,主要有两类着色器:顶点着色器(Vertex Shader)和片元着色器(Fragment Shader)。顶点着色器通常用于定义待渲染图形的顶点;例如,对于要绘制的三角形,可以通过顶点着色器指定该三角形的三个顶点。因此,形状就得以确定。片元着色器则负责图形的填充和呈现效果。它可以决定如何为三角形的内部区域上色。</p>
<h3>2. Render渲染器声明</h3>
<p data-pid="lshEA-vY">当使用 GLSurfaceView 时,为了定义着色器,我们需要继承 GLSurfaceView.Renderer 类。Renderer 在这里是渲染器的意思,负责图形的渲染过程。OpenGL ES 2.0 专为支持可编程流水线的硬件设计,因此其使用与编程紧密结合。这里我们定义了渲染器VideoRenderer,首先,我们需要定义着色器的构建程序。程序如何写,后面再详讲:</p>
<div class="highlight">
<pre><code class="language-text">// 顶点着色器(兼容 OpenGL ES 2.0)
    privatestaticfinal String VERTEX_SHADER =
            "uniform mat4 uMVPMatrix;\n" +
                  "attribute vec4 aPosition;\n" +
                  "attribute vec2 aTexCoord;\n" +
                  "varying vec2 vTexCoord;\n" +
                  "void main() {\n" +
                  "gl_Position = uMVPMatrix * aPosition;\n" +
                  "vTexCoord = aTexCoord;\n" +
                  "}";

    // 片段着色器(支持外部纹理)
    privatestaticfinal String FRAGMENT_SHADER =
            "#extension GL_OES_EGL_image_external : require\n" +
                  "precision mediump float;\n" +
                  "varying vec2 vTexCoord;\n" +
                  "uniform samplerExternalOES uVideoTexture;\n" +
                  "void main() {\n" +
                  "gl_FragColor = texture2D(uVideoTexture, vTexCoord);\n" +
                  "}";
</code></pre>
</div>
<p data-pid="Uwjh8Kwx">再去按照固定的写法去构建着色器,代码是相对固定的</p>
<div class="highlight">
<pre><code class="language-text">privatevoidinitShader(){
    int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, VERTEX_SHADER);
    int fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, FRAGMENT_SHADER);

    program = GLES20.glCreateProgram();
    GLES20.glAttachShader(program, vertexShader);
    GLES20.glAttachShader(program, fragmentShader);
    GLES20.glLinkProgram(program);

    // 检查错误
    int[] linkStatus = newint;
    GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0);
    if (linkStatus != GLES20.GL_TRUE) {
      Log.e("Renderer", "Shader link error: " + GLES20.glGetProgramInfoLog(program));
    }
}
</code></pre>
</div>
<p data-pid="iCaE7UfW">再去创建好program,就说明你的环境基本可以使用了</p>
<div class="highlight">
<pre><code class="language-text">privatevoidinitShader(){
int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, VERTEX_SHADER);
int fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, FRAGMENT_SHADER);

    program = GLES20.glCreateProgram();
    GLES20.glAttachShader(program, vertexShader);
    GLES20.glAttachShader(program, fragmentShader);
    GLES20.glLinkProgram(program);

// 检查错误
int[] linkStatus = newint;
    GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0);
if (linkStatus != GLES20.GL_TRUE) {
      Log.e("Renderer", "Shader link error: " + GLES20.glGetProgramInfoLog(program));
    }
}
</code></pre>
</div>
<h3>3. 初始化MediaPlayer</h3>
<p data-pid="n7IckJTL">在这里面进行了MediaPlayer的创建:</p>
<div class="highlight">
<pre><code class="language-text">publicvoidsetVideoPath(String path){
       this.pendingVideoPath = path;
       if (mediaPlayer == null) {
         mediaPlayer = new MediaPlayer();
         mediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
               @Override
               publicvoid onPrepared(MediaPlayer mp) {
                   mp.start();
                   // 触发 OpenGL 初始化(如果尚未就绪)
                   requestRender();
               }
         });
       }
   }
</code></pre>
</div>
<p data-pid="baRu_WEZ">细心的开发同学会发现,Mediaplayer创建完成之后,并没有立即播放视频,如果你播放视频,会崩溃,这是因为视频流绘制相关的SurfaceTexture的创建还没完成,你想把画面展示在Surface上面一定会失败。所以我们需要加入一个监听,等SurfaceTexture创建完成之后,再去播放视频。</p>
<p data-pid="5UTg1m06">先声明好回调</p>
<div class="highlight">
<pre><code class="language-text">surfaceTexture = new SurfaceTexture(textureId);
surfaceTexture.setOnFrameAvailableListener(st -&gt; {
    // 请求渲染
    mVideoGLSurfaceView.requestRender();
});

if (textureReadyListener != null) {
    textureReadyListener.onSurfaceTextureReady(surfaceTexture);
}
</code></pre>
</div>
<p data-pid="ol1yhyk-">再去做监听,进行视频播放</p>
<div class="highlight">
<pre><code class="language-text">privatevoidinit(Context context){
    // 使用 OpenGL ES 2.0 以兼容更多设备
    setEGLContextClientVersion(2);
    // 关键步骤 1: 设置透明背景
    setEGLConfigChooser(new TransparentConfigChooser());
    setZOrderOnTop(true); // 必须设置
    getHolder().setFormat(PixelFormat.TRANSLUCENT); // 必须设置

    renderer = new VideoRenderer(this);
    setRenderer(renderer);
    setRenderMode(RENDERMODE_WHEN_DIRTY);


    // SurfaceTexture 就绪回调
    renderer.setOnSurfaceTextureReadyListener(surfaceTexture -&gt; {
      if (mediaPlayer != null &amp;&amp; pendingVideoPath != null) {
            try {
                // 1. 重置 MediaPlayer
                mediaPlayer.reset();
                // 2. 设置 DataSource
                mediaPlayer.setDataSource(pendingVideoPath);
                // 3. 设置 Surface
                mediaPlayer.setSurface(new Surface(surfaceTexture));
                // 4. 准备异步
                mediaPlayer.prepareAsync();
            } catch (IOException e) {
                e.printStackTrace();
            }
      }
    });
}
</code></pre>
</div>
<h3>4. 从SurfaceTexture获取帧纹理</h3>
<p data-pid="TolWKbII">首先要获取图形顶点,此步骤用来定义图形形状</p>
<div class="highlight">
<pre><code class="language-text">publicVideoRenderer(VideoGLSurfaceView videoGLSurfaceView){
    mVideoGLSurfaceView = videoGLSurfaceView;
    // 初始化顶点缓冲
    vertexBuffer = ByteBuffer.allocateDirect(VERTEX_DATA.length * 4)
            .order(ByteOrder.nativeOrder())
            .asFloatBuffer()
            .put(VERTEX_DATA);
    vertexBuffer.position(0);

    // 初始化纹理坐标缓冲
    texCoordBuffer = ByteBuffer.allocateDirect(TEX_COORD_DATA.length * 4)
            .order(ByteOrder.nativeOrder())
            .asFloatBuffer()
            .put(TEX_COORD_DATA);
    texCoordBuffer.position(0);
}
</code></pre>
</div>
<p data-pid="0aQmuNHo">Android 的 OpenGL 底层是用 C/C++ 实现的,所以和 Java 的数据类型字节序列有一定的区别,主要是数据的大小端问题。ByteBuffer.order() 方法设置以下数据的大小端顺序,顺序设置为 native 层的数据顺序。使用 ByteOrder.nativeOrder() 可以得到 native 层的大小端数据顺序。</p>
<p data-pid="8Oxg4Doi">进行具体绘制操作。主要是实现继承自 GLSurfaceView.Renderer 的三个方法:</p>
<div class="highlight">
<pre><code class="language-text">@Override
    publicvoidonSurfaceCreated(GL10 gl, EGLConfig config){
      initTexture();
      initShader();
    }

    @Override
    publicvoidonSurfaceChanged(GL10 gl, int width, int height){
      GLES20.glViewport(0, 0, width, height);
      Matrix.setIdentityM(mvpMatrix, 0);
    }

    @Override
    publicvoidonDrawFrame(GL10 gl){
      Log.d("VideoRender", "onDrawFrame");
      GLES20.glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
      GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);

      // 更新帧计数器
      frameCount++;

      // 从第10帧开始动画
      if (frameCount &gt;= 10 &amp;&amp; !animationStarted) {
            animationStarted = true;
            frameCount = 0; // 重置计数器以便计算动画进度
      }

      // 计算缩放因子
      if (animationStarted &amp;&amp; frameCount &lt;= ANIMATION_DURATION) {
            float progress = (float) frameCount / ANIMATION_DURATION;
            scaleFactor = 1.0f + (MAX_SCALE - 1.0f) * progress;
      } else {
            scaleFactor = 1.0f;
      }

      // 计算旋转角度
      if (animationStarted &amp;&amp; (frameCount &lt;= ANIMATION_DURATION + 30 &amp;&amp; frameCount &gt; 20)) {
            // 计算旋转进度(从第20帧开始)
            int rotationFrame = frameCount - (ROTATION_START_FRAME - 10);
            if (rotationFrame &lt; 0) rotationFrame = 0;
            float rotationProgress = (float) rotationFrame / ROTATION_DURATION;
            if (rotationProgress &gt; 1) {
                rotationProgress = 1;
            }
            rotationAngle = MAX_ROTATION * rotationProgress;
      } else {
            rotationAngle = 0.0f;
      }

      // 生成缩放后的MVP矩阵
      float[] finalMvpMatrix = applyScaleAndRotationToMvpMatrix(mvpMatrix, scaleFactor, rotationAngle);


      if (surfaceTexture != null) {
            surfaceTexture.updateTexImage(); // 更新纹理
      }

      GLES20.glUseProgram(program);
      int mvpMatrixHandle = GLES20.glGetUniformLocation(program, "uMVPMatrix");
//      GLES20.glUniformMatrix4fv(mvpMatrixHandle, 1, false, mvpMatrix, 0); 这个是没有任何缩放动画的代码
      // 这个是有缩放效果的代码
      GLES20.glUniformMatrix4fv(mvpMatrixHandle, 1, false, finalMvpMatrix, 0);

      // 绑定顶点数据
      int positionHandle = GLES20.glGetAttribLocation(program, "aPosition");
      GLES20.glEnableVertexAttribArray(positionHandle);
      GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);

      // 绑定纹理坐标
      int texCoordHandle = GLES20.glGetAttribLocation(program, "aTexCoord");
      GLES20.glEnableVertexAttribArray(texCoordHandle);
      GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer);

      // 绘制
      GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);

      GLES20.glDisableVertexAttribArray(positionHandle);
      GLES20.glDisableVertexAttribArray(texCoordHandle);
    }
</code></pre>
</div>
<h3>5. 处理帧数据/叠加动画</h3>
<p data-pid="6BDW7_1U">使用finalMvpMatrix 对原先的mvpMatrix做了转变,在这里进行动画相关的设置,这里我们做了一个旋转的动画,并且是从视频的第10-20帧缩放,从第20-30帧旋转,31帧开始回到原先状态。</p>
<div class="highlight">
<pre><code class="language-text">// 更新帧计数器
frameCount++;

// 从第10帧开始动画
if (frameCount &gt;= 100 &amp;&amp; !animationStarted) {
    animationStarted = true;
    frameCount = 0; // 重置计数器以便计算动画进度
}

// 计算缩放因子
if (animationStarted &amp;&amp; frameCount &lt;= ANIMATION_DURATION) {
    float progress = (float) frameCount / ANIMATION_DURATION;
    scaleFactor = 1.0f + (MAX_SCALE - 1.0f) * progress;
} else {
    scaleFactor = 1.0f;
}

// 计算旋转角度
if (animationStarted) {
    // 计算旋转进度(从第150帧开始)
    int rotationFrame = frameCount - (ROTATION_START_FRAME - 100);
    if (rotationFrame &lt; 0) rotationFrame = 0;
    float rotationProgress = (float) rotationFrame / ROTATION_DURATION;
    rotationAngle = MAX_ROTATION * rotationProgress;
}

// 生成缩放后的MVP矩阵
float[] finalMvpMatrix = applyScaleAndRotationToMvpMatrix(mvpMatrix, scaleFactor, rotationAngle)
GLES20.glUniformMatrix4fv(mvpMatrixHandle, 1, false, finalMvpMatrix, 0);
</code></pre>
</div>
<p class="ztext-empty-paragraph">&nbsp;</p>
<div class="highlight">
<pre><code class="language-text">privatefloat[] applyScaleAndRotationToMvpMatrix(float[] originalMatrix, float scale, float rotation) {
       float[] finalMatrix = newfloat;
       Matrix.setIdentityM(finalMatrix, 0);
       // 1. 应用原始矩阵
       Matrix.multiplyMM(finalMatrix, 0, originalMatrix, 0, finalMatrix, 0);
       // 2. 应用缩放
       Matrix.scaleM(finalMatrix, 0, scale, scale, 1.0f);
       // 3. 应用旋转(绕Z轴)
       Matrix.rotateM(finalMatrix, 0, rotation, 0, 0, 1.0f);
       return finalMatrix;
   }
</code></pre>
</div>
<h3>6. 渲染到屏幕</h3>
<div class="highlight">
<pre><code class="language-text">// 绑定顶点数据
int positionHandle = GLES20.glGetAttribLocation(program, "aPosition");
GLES20.glEnableVertexAttribArray(positionHandle);
GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);

// 绑定纹理坐标
int texCoordHandle = GLES20.glGetAttribLocation(program, "aTexCoord");
GLES20.glEnableVertexAttribArray(texCoordHandle);
GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer);

// 绘制
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);

GLES20.glDisableVertexAttribArray(positionHandle);
GLES20.glDisableVertexAttribArray(texCoordHandle);
</code></pre>
</div>
<p data-pid="Duylxynd">需要注意的是,这里使用到了纹理坐标和顶点坐标,这两个坐标在下文也有使用,那么这两个坐标起到什么作用?先来看下这两个坐标的定义:</p>
<h3>7. 效果呈现</h3>
<div class="GifPlayer css-1isopsn" data-size="normal" data-za-detail-view-path-module="GifItem"><img alt="动图封面" width="480" class="ztext-gif lazyload" data-thumbnail="https://pic1.zhimg.com/v2-e5d6ba39d80eaab44d98a51377d7b558_720w.jpg?source=d16d100b" data-size="normal" data-src="https://pic1.zhimg.com/v2-e5d6ba39d80eaab44d98a51377d7b558_720w.jpg?source=d16d100b">
<div class="GifPlayer-icon css-d39tw7">&nbsp;</div>
</div>
<p data-pid="2q3_O1iZ">到这里,对于如何使用OpenGL ES进行画面渲染的流程,你应该也比较熟悉了,继续往下看。</p>
<h2>四、提升难度-FFmpeg手撸播放器实现动画</h2>
<p data-pid="y7P1in2B">在上面知识点了解之前,有人是先学习的FFmpeg,但是很多人在FFmpeg编译这一步时就被劝退了,因为确实有些麻烦,不像上面的知识点那么纯粹,使用FFmpeg做一款动画播放器,涉及到FFmpeg的编译、引入、jni的代码编写(C++)、Android工程、以及上面提供的SurfaceView、Surface、顶点和片段着色器这些知识点。那么这一章节会带你克服之前可能遇到的问题,让你顺利开发出一个播放器。</p>
<h3>4.1 FFmpeg 编译</h3>
<p data-pid="h7MqyjUH">Windows环境下,不要使用Cygwin,不然需要再去安装一堆插件,解决版本兼容的问题,太麻烦了,试了好几遍都无法成功。直接使用MSYS2,(需要注意的是,这里使用的是Windows的环境,如果你是MAC或者其他环境,操作起来更简单,这个可以自行搜索一下)。</p>
<p data-pid="9MN4U7V-">编译完成之后,就可以生成可以跨平台使用的可调用库文件,这里以so文件举例:</p>
<img width="1080" height="269" class="origin_image zh-lightbox-thumb lazy lazyload" data-caption="" data-size="normal" data-rawwidth="1080" data-rawheight="269" data-original="https://picx.zhimg.com/v2-0a4628dc2a3e58327b9639d443a903f5_r.jpg?source=d16d100b" data-actualsrc="https://picx.zhimg.com/v2-0a4628dc2a3e58327b9639d443a903f5_720w.jpg?source=d16d100b" data-lazy-status="ok" data-src="https://picx.zhimg.com/80/v2-0a4628dc2a3e58327b9639d443a903f5_720w.webp?source=d16d100b">
<p data-pid="z3POdPav">借助Android Studio创建一个C++项目,把上面的so文件拷到你的项目里,头文件在include下面,这个拷arm64-v8a或者armeabi-v7a下面的头文件都可以,如下所示:</p>
<img width="784" height="1576" class="origin_image zh-lightbox-thumb lazy lazyload" data-caption="" data-size="normal" data-rawwidth="784" data-rawheight="1576" data-original="https://picx.zhimg.com/v2-8425c63dabe00dc5407e33e5f37e6e0d_r.jpg?source=d16d100b" data-actualsrc="https://pica.zhimg.com/v2-8425c63dabe00dc5407e33e5f37e6e0d_720w.jpg?source=d16d100b" data-lazy-status="ok" data-src="https://pica.zhimg.com/80/v2-8425c63dabe00dc5407e33e5f37e6e0d_720w.webp?source=d16d100b">
<h3>4.2 基础播放器实现</h3>
<p data-pid="96ivEVz2">先看一下流程图,有了这个图之后,就有了清晰的认识,在哪个环节实现动画也就一目了然。再来看一下做一款播放器的流程图:</p>
<img width="1080" height="1801" class="origin_image zh-lightbox-thumb lazy lazyload" data-caption="" data-size="normal" data-rawwidth="1080" data-rawheight="1801" data-original="https://picx.zhimg.com/v2-6d505021d7760ad2a706cf6d5684fb4b_r.jpg?source=d16d100b" data-actualsrc="https://pic1.zhimg.com/v2-6d505021d7760ad2a706cf6d5684fb4b_720w.jpg?source=d16d100b" data-lazy-status="ok" data-src="https://pic1.zhimg.com/80/v2-6d505021d7760ad2a706cf6d5684fb4b_720w.webp?source=d16d100b">
<p data-pid="M1L8HmS-">4.2.1 初始化FFmpeg库</p>
<p data-pid="PMeRiLw3">这里比较简单,初始化一下网络协议就行,为了方便起见,可以把头部需要引用的库都加进来。</p>
<div class="highlight">
<pre><code class="language-text">#include&lt;jni.h&gt;
#include&lt;string&gt;
#include&lt;android/native_window.h&gt;
#include&lt;android/native_window_jni.h&gt;
#include&lt;android/log.h&gt;
#include&lt;android/bitmap.h&gt;

#define LOG_TAG "Firstvideo"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)


extern"C" {
#include"include/libavutil/log.h"
#include"include/libavutil/frame.h"
#include"include/libavutil/avutil.h"
#include"include/libavutil/imgutils.h"
#include"include/libavutil/opt.h"
#include"include/libavformat/avformat.h"
#include"include/libavcodec/avcodec.h"
#include"include/libswscale/swscale.h"

//初始化FFmpeg库
avformat_network_init();
</code></pre>
</div>
<p data-pid="A4WcLfpk">4.2.2 打开视频文件</p>
<div class="highlight">
<pre><code class="language-text">constchar *videoPath = env-&gt;GetStringUTFChars(videoPath_, 0);
LOGD("videoPath: %s", videoPath);
if(videoPath == NULL) {
    LOGE("videoPath is null");
    return;
}
AVFormatContext *formatContext = avformat_alloc_context();
LOGD("open video file");
int ret = avformat_open_input(&amp;formatContext, videoPath, NULL, NULL);
if (ret != 0) {
    char errorBuf;
    av_strerror(ret, errorBuf, sizeof(errorBuf));
    LOGE("无法打开视频文件: %s, 错误: %s", videoPath, errorBuf);
    return;
}
</code></pre>
</div>
<p data-pid="PSu3lS4z">4.2.3 查找流信息</p>
<div class="highlight">
<pre><code class="language-text">LOGD("Retrieve stream information");
if (avformat_find_stream_info(formatContext, NULL) &lt; 0) {
    LOGE("Cannot find stream information");
return;
}
</code></pre>
</div>
<p data-pid="K1IC6v0c">4.2.4 查找视频流</p>
<div class="highlight">
<pre><code class="language-text">LOGD("Find video stream");
int video_stream_index = -1;
for (int i = 0; i &lt; formatContext-&gt;nb_streams; i++) {
    if (formatContext-&gt;streams-&gt;codecpar-&gt;codec_type == AVMEDIA_TYPE_VIDEO) {
      video_stream_index = i;
    }
}
if (video_stream_index == -1) {
    LOGE("No video stream found");
    return;
}
</code></pre>
</div>
<p data-pid="_m-846RG">4.2.5 获取编码器上下文</p>
<div class="highlight">
<pre><code class="language-text">LOGD("Get a pointer to the codec context for the video stream");
AVCodecParameters *codecParameters = formatContext-&gt;streams-&gt;codecpar;

LOGD("Find the decoder for the video stream");
const AVCodec *codec = avcodec_find_decoder(codecParameters-&gt;codec_id);
if (codec == NULL) {
    LOGE("Codec not found");
    return;
}
AVCodecContext *codecContext = avcodec_alloc_context3(codec);
if (codecContext == NULL) {
    LOGE("CodecContext not found");
    return;
}
if (avcodec_parameters_to_context(codecContext, codecParameters) &lt; 0) {
    LOGE("Fill CodecContext failed");
    return;
}
</code></pre>
</div>
<p data-pid="hu0hqxRL">4.2.6 打开编解码器</p>
<div class="highlight">
<pre><code class="language-text">LOGD("Open codec");
if (avcodec_open2(codecContext, codec, NULL) &lt; 0) {
    LOGE("Init CodecContext failed");
    return;
}
</code></pre>
</div>
<p data-pid="vv0PHcpT">4.2.7 为视频帧分配空间</p>
<div class="highlight">
<pre><code class="language-text">AVPixelFormat dstFormat = AV_PIX_FMT_RGBA;
AVPacket *packet = av_packet_alloc();
if (packet == NULL) {
    LOGE("Could not allocate av packet");
    return;
}
LOGD("Allocate video frame");
AVFrame *frame = av_frame_alloc();
LOGD("Allocate render frame");
AVFrame *renderFrame = av_frame_alloc();
if (frame == NULL || renderFrame == NULL) {
    LOGE("Could not allocate video frame");
    return;
}
</code></pre>
</div>
<p data-pid="PCq7uIRQ">4.2.8 分配处理视频帧的内存空间</p>
<div class="highlight">
<pre><code class="language-text">LOGD("Determine required buffer size and allocate buffer");
int size = av_image_get_buffer_size(dstFormat, codecContext-&gt;width, codecContext-&gt;height, 1);
uint8_t *buffer = (uint8_t *) av_malloc(size * sizeof(uint8_t));
av_image_fill_arrays(renderFrame-&gt;data, renderFrame-&gt;linesize, buffer, dstFormat, codecContext-&gt;width, codecContext-&gt;height, 1);
</code></pre>
</div>
<p data-pid="BzdTI5n4">4.2.9 初始化图像转换结构体SwsContext</p>
<div class="highlight">
<pre><code class="language-text">structSwsContext *swsContext = sws_getContext(codecContext-&gt;width,
                                                codecContext-&gt;height,
                                                codecContext-&gt;pix_fmt,
                                                codecContext-&gt;width,
                                                codecContext-&gt;height,
                                                dstFormat,
                                                SWS_BILINEAR,
                                                NULL,
                                                NULL,
                                                NULL);

   if (swsContext == NULL) {
       LOGE("Init SwsContext failed");
       return;
   }
</code></pre>
</div>
<p data-pid="cQx5uFkp">4.2.10 创建本地视图窗口管理器</p>
<div class="highlight">
<pre><code class="language-text">LOGD("native window");
ANativeWindow *nativeWindow = ANativeWindow_fromSurface(env, surface);
ANativeWindow_Buffer windowBuffer;
LOGD("get video width, height");
</code></pre>
</div>
<p data-pid="MKI0smPT">4.2.11 获取视频的宽高</p>
<div class="highlight">
<pre><code class="language-text">int videoWidth = codecContext-&gt;width;
int videoHeight = codecContext-&gt;height;
LOGD("set video width, height:[%d, %d]", videoWidth, videoHeight);
LOGD("set native window");
</code></pre>
</div>
<p data-pid="Mgc6u12m">4.2.12 向解码器发送帧数据与解码器接收帧数据</p>
<div class="highlight">
<pre><code class="language-text">while (av_read_frame(formatContext, packet) == 0) {
       if (packet-&gt;stream_index == video_stream_index) {

         int sendPacketState = avcodec_send_packet(codecContext, packet);
         if (sendPacketState == 0) {
               LOGD("向解码器-发送数据");
               int receiveFrameState = avcodec_receive_frame(codecContext, frame);
               if (receiveFrameState == 0) {
                   LOGD("从解码器-接收数据");
                   frameCount++;// 成功解码一帧,计数器递增
                   if (frameCount == 5) {
                     // 提取第100帧生成Bitmap
                     convertFrameToBitmap(env, codecContext, frame, bitmap);// 自定义函数
                   }
                   ANativeWindow_lock(nativeWindow, &amp;windowBuffer, NULL);
                   // 格式转换
                   sws_scale(swsContext, (uint8_tconst *const *) frame-&gt;data,
                           frame-&gt;linesize, 0, codecContext-&gt;height,
                           renderFrame-&gt;data, renderFrame-&gt;linesize);
                   //获取stride
                   uint8_t *dst = (uint8_t *) windowBuffer.bits;
                   uint8_t *src = (uint8_t *) renderFrame-&gt;data;
                   int dstStride = windowBuffer.stride * 4;
                   int srcStride = renderFrame-&gt;linesize;
                   // 由于Windows的stride和帧的stride不同,因此需要逐行复制
                   for (int i = 0; i &lt; videoHeight; i++) {
                     memcpy(dst + i * dstStride, src + i * srcStride, srcStride);
                   }
                   ANativeWindow_unlockAndPost(nativeWindow);
               } elseif (receiveFrameState == AVERROR(EAGAIN)) {
                   LOGD("从解码器-接收-数据失败:AVERROR(EAGAIN)");
               } elseif (receiveFrameState == AVERROR_EOF) {
                   LOGD("从解码器-接收-数据失败:AVERROR_EOF");
               } elseif (receiveFrameState == AVERROR(EINVAL)) {
                   LOGD("从解码器-接收-数据失败:AVERROR(EINVAL)");
               } else {
                   LOGD("从解码器-接收-数据失败: 未知");
               }
         } elseif (sendPacketState == AVERROR(EAGAIN)) {
               LOGD("向解码器-发送-数据失败:AVERROR(EAGAIN)");
         } elseif (sendPacketState == AVERROR_EOF) {
               LOGD("向解码器-发送-数据失败:AVERROR_EOF");
         } elseif (sendPacketState == AVERROR(EINVAL)) {
               LOGD("向解码器-发送-数据失败:AVERROR(EINVAL)");
         } elseif (sendPacketState == AVERROR(ENOMEM)) {
               LOGD("向解码器-发送-数据失败:AVERROR(ENOMEM)");
         } else {
               LOGD("向解码器-发送-数据失败:未知");
         }
       }
       av_packet_unref(packet);
   }
</code></pre>
</div>
<p data-pid="VDgDdPOP">动画在sws_scale处完成,大致代码如下:</p>
<div class="highlight">
<pre><code class="language-text">// 格式转换(原有逻辑)
sws_scale(swsContext, frame-&gt;data, frame-&gt;linesize, 0,
          codecContext-&gt;height, renderFrame-&gt;data, renderFrame-&gt;linesize);

// 将renderFrame数据绑定到OpenGL纹理
glBindTexture(GL_TEXTURE_2D, mTextureID);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, renderFrame-&gt;width, renderFrame-&gt;height,
                GL_RGBA, GL_UNSIGNED_BYTE, renderFrame-&gt;data);

// 更新动画参数(示例:每帧放大1%,旋转1度)
mCurrentScale += 0.01f;
mCurrentRotation += 1.0f;
if (mCurrentRotation &gt;= 360.0f) mCurrentRotation = 0.0f;

// 渲染到屏幕
glUseProgram(mProgram);
glUniform1f(mScaleUniform, mCurrentScale);   // 传递缩放值
glUniform1f(mRotationUniform, mCurrentRotation); // 传递旋转角度

// 绘制矩形(带纹理)
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
</code></pre>
</div>
<p data-pid="6dbt7dmc">可以看到,跟第二章部分内容一样,这里也使用了GL环境进行缩放和旋转动画的处理。代码的实现思路也基本是一致的,就是Surface承接渲染任务,然后使用顶点和片元着色器进行图形的绘制和渲染。</p>
<p data-pid="JLqR48Ku">4.2.13 内存释放</p>
<div class="highlight">
<pre><code class="language-text">// 内存释放
LOGD("release memory");
ANativeWindow_release(nativeWindow);
</code></pre>
</div>
<h3>4.3 酷炫动画的实现</h3>
<p data-pid="c6Iruida">先来看一下一个简单的处理,把rgb做了一个简单的均值,然后赋值给rgb都赋值为这个均值,就可以得到一个黑白的颜色,这就是最简单的视频处理。</p>
<div class="highlight">
<pre><code class="language-text">const GLchar* VideoDrawer::GetFragmentShader(){
    staticconst GLchar shader[] = "precision mediump float;\n"
                            "uniform sampler2D uTexture;\n"
                            "varying vec2 vCoordinate;\n"
                            "void main() {\n"
                            "vec4 color = texture2D(uTexture, vCoordinate);\n"
                            //                            "color.a = 0.5f;"
                            //                            "gl_FragColor = color;\n"
                            "float gray = (color.r + color.g + color.b)/3.0;\n"
                            "gl_FragColor = vec4(gray, gray, gray, 1.0);\n"
                            //                            "gl_FragColor = vec4(1, 1, 1, 1);\n"
                            "}";
    return shader;
}
</code></pre>
</div>
<p data-pid="i7PKx-I4">关键是这一行 gl_FragColor = vec4(gray, gray, gray, 1.0)</p>
<div class="GifPlayer css-o0k2vi" data-size="small" data-za-detail-view-path-module="GifItem"><img alt="动图封面" width="320" class="ztext-gif lazyload" data-thumbnail="https://picx.zhimg.com/v2-e4b6ad82b8499181905c7dab2c1d6a91_720w.jpg?source=d16d100b" data-size="small" data-src="https://picx.zhimg.com/v2-e4b6ad82b8499181905c7dab2c1d6a91_720w.jpg?source=d16d100b">
<div class="GifPlayer-icon css-d39tw7">&nbsp;</div>
</div>
<p data-pid="yve3gVal">再来看一个灵魂出窍的效果,这个就是类似抖音这种做的滤镜,代码会复杂些,但是原理基本没啥区别。</p>
<div class="GifPlayer css-o0k2vi" data-size="small" data-za-detail-view-path-module="GifItem"><img alt="动图封面" width="320" class="ztext-gif lazyload" data-thumbnail="https://pica.zhimg.com/v2-81824ed542d9d69869dcbbc05e4da050_720w.jpg?source=d16d100b" data-size="small" data-src="https://pica.zhimg.com/v2-81824ed542d9d69869dcbbc05e4da050_720w.jpg?source=d16d100b">
<div class="GifPlayer-icon css-d39tw7">&nbsp;</div>
</div>
<h3>4.4 自己写播放器的好处</h3>
<p data-pid="Rf1xX8SB">看到这里,你可能会说使用Mediaplayer跟自己写FFmpeg没啥区别,这么麻烦干嘛,那下面再来详细总结下FFmpeg的好处:</p>
<p data-pid="gIWWYlmz">4.4.1 格式支持更全面</p>
<p data-pid="kklNsu-B">FFmpeg 支持几乎所有的音视频格式(如 H.265/HEVC、VP9、FLAC、MKV、MOV 等),甚至冷门格式或损坏文件。</p>
<p data-pid="lH5VNLyW">传统播放器 依赖系统解码器,可能无法播放未安装解码器的格式(如某些 4K 视频或无损音频)。</p>
<p data-pid="rQFe7NDK">4.4.2 解码能力更强</p>
<p data-pid="g4vngnEB">FFmpeg 直接调用底层库(如 libx264、libvpx),支持硬解码、多线程解码,流畅播放高码率视频。</p>
<p data-pid="VNGValI8">传统播放器 可能因解码优化不足导致卡顿,尤其是播放高分辨率(如 4K/8K)或高帧率视频时。</p>
<p data-pid="ySBiuvuH">4.4.3 高度自定义与灵活性</p>
<p data-pid="44mCFdVf">FFmpeg 播放器 支持通过命令行参数或脚本控制播放行为,例如:</p>
<p data-pid="kNqzxdOB">调整播放速度:<code>ffplay -vf "setpts=0.5\*PTS" input.mp4(2倍速播放)</code></p>
<p data-pid="VIv_3q6G">实时滤镜:添加去噪、锐化、色彩校正等效果。</p>
<p data-pid="FrHhHPYJ">截取片段:<code>ffplay -ss 00:01:30 -t 10 input.mp4(从1分30秒开始播放10秒)。</code></p>
<p data-pid="W-PtWfeN">传统播放器 通常仅提供固定功能,无法深度自定义。</p>
<p data-pid="8FczXv7I">4.4.4 处理异常文件更稳定</p>
<p data-pid="y7Ot1bvO">FFmpeg 可强制忽略错误继续播放不完整或损坏的媒体文件(如未下载完的视频)。</p>
<p data-pid="TDYSqMWU"><code>ffplay -err\_detect ignore\_err input\_corrupted.mp4</code></p>
<p data-pid="jSzNF6gU">传统播放器 遇到文件异常时可能直接报错退出。</p>
<p data-pid="XnvXObWO">4.4.5 资源占用更低</p>
<p data-pid="8XgVhaMo">FFmpeg 无图形界面(如 ffplay),资源消耗更少,适合老旧设备或后台处理。</p>
<p data-pid="ZFriamPn">传统播放器 因GUI和附加功能(如皮肤、插件)可能占用更多内存和CPU。</p>
<p data-pid="5yESe7zI">4.4.6 跨平台一致性</p>
<p data-pid="ffNdUtwi">FFmpeg 可在 Windows、Linux、macOS 等系统上运行,命令和功能完全一致。</p>
<p data-pid="l2BVHU5Y">传统播放器 通常仅限特定平台(如 Windows Media Player 仅限 Windows)。</p>
<p data-pid="GGtiJvAe">4.4.7 支持流媒体与网络协议</p>
<p data-pid="RZ0jBJUv">FFmpeg 可直接播放网络流(如 RTMP、HLS、HTTP):</p>
<p data-pid="PZkmYtGg"><code>ffplay rtsp://example.com/live.stream</code></p>
<p data-pid="4vt2E6W6">传统播放器 可能需要额外插件或无法支持专业流媒体协议。</p>
<p data-pid="CIJM8JsQ">4.4.8 开发与调试友好</p>
<p data-pid="nTHYs17o">FFmpeg 提供详细的日志和调试信息,便于开发者分析问题:</p>
<p data-pid="xEzz2ADk"><code>ffplay -v debug input.mp4 # 输出详细解码日志</code></p>
<p data-pid="uksqm_MX">传统播放器 日志功能有限,难以排查播放故障。</p>
<h3>适用场景对比</h3>
<img width="615" height="318" class="origin_image zh-lightbox-thumb lazy lazyload" data-caption="" data-size="normal" data-rawwidth="615" data-rawheight="318" data-original="https://pic1.zhimg.com/v2-6ecfbe900718ddbcf9002474a70a565d_r.jpg?source=d16d100b" data-actualsrc="https://picx.zhimg.com/v2-6ecfbe900718ddbcf9002474a70a565d_720w.jpg?source=d16d100b" data-lazy-status="ok" data-src="https://picx.zhimg.com/80/v2-6ecfbe900718ddbcf9002474a70a565d_720w.webp?source=d16d100b">
<h2>五、可以做的更多</h2>
<p data-pid="VrKOZQvE">上面的动画还是太简单了!!!</p>
<p data-pid="yuqG1wED">要是需要做一个更复杂的动效:具备3D效果的视频该怎么办呢?比如百度地图的3D图层。</p>
<img width="1080" height="2376" class="origin_image zh-lightbox-thumb lazy lazyload" data-caption="" data-size="normal" data-rawwidth="1080" data-rawheight="2376" data-original="https://picx.zhimg.com/v2-f5902117420320fd5ebb3017ebf250e7_r.jpg?source=d16d100b" data-actualsrc="https://picx.zhimg.com/v2-f5902117420320fd5ebb3017ebf250e7_720w.jpg?source=d16d100b" data-lazy-status="ok" data-src="https://picx.zhimg.com/80/v2-f5902117420320fd5ebb3017ebf250e7_720w.webp?source=d16d100b">
<p data-pid="lsikAkKX">看一下下面这个知识架构图,我们本文主要是把Core这部分做了讲解,其他的知识点就是做3D效果的必备知识点,大家可以自行deepseek做进一步的了解。</p>
<div class="highlight">
<pre><code class="language-text">├── Core
│   ├── Shader(着色器管理)
│   ├── Texture(纹理加载与采样)
│   ├── Model(模型加载,支持OBJ/FBX)
│   └── Camera(摄像机控制)
├── Rendering
│   ├── ForwardRenderer(前向渲染器)
│   ├── DeferredRenderer(延迟渲染器)
│   └── ShadowRenderer(阴影渲染模块)
├── Lighting
│   ├── PointLight(点光源)
│   ├── DirectionalLight(平行光)
│   └── PBR(基于物理的渲染)
└── Utils
    ├── GLM(数学库)
    ├── Assimp(模型导入库)
    └── STB(图像加载库)
</code></pre>
</div>
<p data-pid="LaFp91zt">上述知识点都掌握后,基本就可以实现3D地图效果了,这时候再去做视频的3D动画原理也是相同,不再有阻碍了!</p>
<h2>六、结束语</h2>
<p data-pid="UO1nlCx6">使用视频文件代替GIF、属性动画进行动效实现而言具备下面几个明显的优势:</p>
<h3>1. 复杂性限制</h3>
<p data-pid="qeUffEfe">动效方案通常更适合简单或中等复杂程度的动画,而不是像视频那样可以展示复杂场景和高质量的画面。</p>
<h3>2.多样性和沉浸感</h3>
<p data-pid="ey5YcIcD">视频可以提供更丰富的视觉效果和沉浸感,比如动态的场景变化、特效、音效的结合等。</p>
<h3>3. 创作灵活性</h3>
<p data-pid="UgeAHF2_">视频创作可以使用各种视频编辑工具进行高级编辑,而动效需要更多手动编码和调整。</p>
<h3>4. 更加满足业务场景需求</h3>
<p data-pid="cJr9eZ4u">在视频文件的基础上,可以进行动效定制,插入特定的效果,翻转、平移、3D、抠图等均可,可以做到更高的业务场景契合度。</p>

</div>
<div id="MySignature" role="contentinfo">
    分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。<br><br>
来源:https://www.cnblogs.com/vivotech/p/19246416
頁: [1]
查看完整版本: Android动效探索:彻底弄清如何让你的视频更加酷炫