从零开始仿写一个抖音App——音视频开篇

14,860 阅读22分钟

本文首发于微信公众号——世界上有意思的事,搬运转载请注明出处,否则将追究版权责任。微信号:a1018998632,交流qq群:859640274

GitHub地址

大家好,距离上次本专题发文已经有五个星期了,中间发了两篇非本专题的文章,可能很多人都以为我要弃坑了。但是并不是这回事,主要是工作有点忙,而且我在音视频方面其实也有许多东西需要学习和整理。那么从本篇文章开始我们就要进入音视频领域进行研究学习了,Android 领域的文章会在中间整合音视频代码的时候进行穿插讲解。其实 Android 里面要讲的东西还是挺多的,奈何时间不等人。废话不多说,我们进入文章。本文预计阅读时间二十分钟。

本文分为以下章节,读者可以按需阅读

  • 1.聊一聊——主要是公布一些事情,没兴趣的同学可以跳过。
  • 2.音视频前置知识——列一列学习音视频技术之前需要知道的东西。
  • 3.cmake 入门——了解一个 c/c++ 工程的组织与编译。
  • 4.ffmpeg 入门——入门 ffmepg,讲解一个官方 demo。

一、聊一聊

  • 1.有些朋友可能会发现本专题下掉了两篇文章,这个问题在这里说明一下。下掉文章是因为我的个人原因,这两篇文章我会在适当的时间修改好之后再重新发布。如果实在有同学有需要的话可以私聊我,我发给你。
  • 2.简书的朋友可能会发现,我的个人界面上多了简书版权这个标签,这个标签表示我已经与简书签约,在积累了一定的存稿之后会与简书合作出版一本关于 Android+音视频技术+深度学习+短视频 方面的书籍。书的话预计会在半年之后开始写,现在先预热一下,哈哈:)。
  • 3.关注了我的 Github 的同学会看见我将 MyTikTok 这个项目移动到了 TheGodsThemselves 这个组织内,以后关于本专题的正式项目也会都发布到这里面去,或许再过不久就会有第一个同学加入到本组织中来。另外这里重新说明一下本项目的理想目标:完整的复刻大厂的项目流程,写出一个短视频 App(暂定模仿抖音),在项目中会用到各个端(包括不限于 Android、IOS、后台、前端、算法、音视频)的有意思的技术,让参与和关注本项目的同学能够学习到自己感兴趣的技术栈(大厂的真实开发经验)。

二、音视频前置知识

其实我在 我的技术成长之路 中已经大概讲解了学习音视频技术需要学习哪些东西,在这一节我会讲些具体的东西,当然也只是一个粗浅的入门,更加深入的知识还是需要读者自己去积累。

1.多媒体概念

  • 1.视频格式:usb 摄像头的输出格式有RGB24、YUV2、YV2这些都是没编码的原始数据,MJPEG 这种是经过编码的数据
  • 2.音频格式:很多
  • 3.容器和协议:容器指的是一种音视频文件格式比如.avi,协议指的是存放在音视频文件中的数据的编解码方式,一个容器可以装有各种不同的编解码方式的数据,每种编解码方式都需要不同的编解码器。MPEG、H.26X等等编码方式比较常见。 AVI、MPG、MP4等等容器比较常见。
    • 1.容器文件格式:一个容器文件常常由三部分组成,文件头、索引、多媒体数据。
      • 1.文件头常常说明了多媒体数据的分辨率、帧率、音频采样率等等规范信息
      • 2.索引部分用于记录多媒体数据在文件中的位置,因为多媒体数据不一定是连续的,同时还可能有音视频同步索引等等。播放视频的时候常常会把索引全部读入内存。
      • 3.多媒体数据部分就是储存压缩过的视频、音频、文本数据等等。
    • 2.协议:视频压缩协议用 h.26x、mpeg-x 等等。h.265是最新的压缩协议。音频压缩协议有 g.7xx 等等
  • 4.常用概念:
    • 1.硬解码:不让 cpu 参与解码,而是使用专门的设备进行解码,这种设备一般集成在 gpu 中。硬解码的好处是速度比 cpu 快得多、节省了 cpu 资源。坏处是起步晚软件支持少、无法兼容各种不同的编解码方式和文件格式、没有像软解那种画质增强的好处、gpu 硬解码比较难。
    • 2.ibp 帧:gop 是 一组画面帧,一个视频文件由 n 个 gop 组成。一个 gop 里面分为 i、b、p 三种帧。
      • 1.i 是全量帧,相当于一张图片被压缩后的数据,可以自己恢复出一个显示帧,压缩率在7倍左右
      • 2.p 是向前预测帧,他需要依赖 i 帧来解码,他使用运动补偿的方式来传送与前面的 i 或 p 帧的误差,然后重建出一个显示帧,i 和 p 都可以作为 p 帧的前置帧。因为 p 帧可以作为后置帧的参考,所以其可能造成解码错误的扩散。压缩率在20倍左。
      • 3.b 是双向预测帧,他需要依赖前面的 i 或 p 帧和后面的 p 帧来解码,压缩率为50倍左右,因为压缩率高所以解码麻烦。需要预先知道后置帧,所以要预读预解码。
      • 4.mpeg4中每一帧开头是 00 00 01 b6,而在这后面的两个 bit 就表示的当前帧属于那种帧,00为 i,01为 p,10为 b。

2.FFmpeg基本概念

  • 1.模块组成:
    • 1.libavformat:解析各种格式的音视频文件、获取解码信息的读取音视频帧、为 libavcode 提供独立的音频视频的流
    • 2.libavcodec: 适用于各种编解码协议的编解码器
    • 3.libavdevice:硬件采集、加速、显示视频。
    • 4.libavfilter:进行视频的转换,比如剪裁、伸缩、宽高比等等
    • 5.libavutil:工具库
    • 6.libavresample:。。
    • 7.libswscale:比例缩放、色彩映射转换、图像色彩空间转换
    • 8.libpostproc:音视频后期效果处理
    • 9.ffmpeg:一个暴露到外部的工具
    • 10.ffplay:简单的播放器,使用 ffmpeg 库进行解析和解码
  • 2.总的来说 FFmpeg 是一个 c 语言写的程序库。它由上面这些模块组成。它并不是一个播放器,他是播放器的核心组件。比如我需要在 windows 上面写一个播放器,我们有一个 MP4 文件了,那么这个播放器由下面这些步骤来播放这个视频:FFmpeg 解析文件格式——>FFmpeg 读取文件数据——>FFmpeg 解码文件数据将数据还原成图片帧——> Windows Api 显示图片帧。而我如果又需要在 Android 上写一个播放器,前面的三个步骤并不用变化,只需要将最后一个步骤替换成 Opengl es + TextureSurfaceView 来实现图片帧的显示即可。由此我们可以发现,FFmpeg 是具有跨平台性的,视频播放的核心逻辑只要用了 FFmpeg 那么在各个平台中就不需要大的变化了,需要变化的就只是各个平台显示图片帧的逻辑。
  • 3.FFmpeg 中有个 ffmepg 模块,当你的电脑上安装了 FFmpeg,那么你就可以通过命令行来调用 ffmpeg 暴露出来的函数对视频进行处理。

三、Cmake入门

Cmake 是组织 C/Cpp 项目的一个工具,类似我们在 android 中使用的 gradle。我们要写一个大一点的工具,Cmake 这种项目管理工具是必不可少的。这一节就来入门一下 Cmake,注意下面的教程是 官方教程 的翻译。

这是本章节对应的项目:cmake_learning项目

1.编译器准备

我因为主力机是 Mac,所以使用的 IDE 是 CLion,CLion 也是 JetBrain 全家桶的成员之一。使用了 Android Studio 或者 IDEA 的同学可以很方便的切换到这个 IDE 上。此外 CLion 还是一个跨平台的 IDE,也就是说在 Windows Linux 上面也可以使用它。当然 Visual Studio 永远是最强的 IDE(手动狗头)。需要注意的是 CLion 是需要花钱买激活码的,似乎没有免费版开始能免费试用一个月左右的时间,所以激活码的获取途径大家就各显神通吧。

2.Cmake

(1).最基本的Cmake程序

  • 1.我们进入项目中 one/a 的目录发现下面有两个文件:CMakeLists.txt 和 tutorial.cpp 里面的代码如下:

    • 1.我们写了一个计算平方根的 cpp 代码,然后放入了 Tutorial 这个 project 中。
    • 2.我们在 a 中创建一个 build 的目录,然后在命令行中进入这个目录中,最后运行 cmake .. 这个命令,我们会发现 build 下面生成了几个文件,这些文件就是进行 make 需要的文件。
    • 3.我们最后在 build 文件夹下运行 make 命令,这个时候会生成一个 Tutorial 的可执行文件,这就是 Tutorial 项目最终的产物了,我们可以输入 ./Tutorial 3 来对3进行平方根的计算。
    # 一个 cmake 组织的项目最少有下面这三行代码
    cmake_minimum_required (VERSION 2.6) # 表示cmake的最小版本
    project (Tutorial)# 新建一个project,这个project的名字叫Tutorial
    add_executable(Tutorial tutorial.cpp) # 为 Tutorial 这个 project 添加一个可执行的文件tutorial.cpp
    # 1.cmake的语法支持大小、小写和大小写混合例如上面的 project 可以写成 PROJECT
    
    //
    // Created by 何时夕 on 2018/10/20.
    // 
    #include <stdio.h>
    #include <stdlib.h>
    #include <math.h>
    int main (int argc, char *argv[]) {
        if (argc < 2) {
            fprintf(stdout, "usage: %s number\n", argv[0]);
            return 1;
        }
        double inputValue = atof(argv[1]);
        double outputValue = sqrt(inputValue);
        fprintf(stdout, "The square root of %g is %g\n", inputValue, outputValue);
        return 0;
    }
    
  • 2.我们进入项目中 one/b 的目录发现下面有三个文件:CMakeLists.txt、tutorial.cpp、TutorialConfig.h.in 里面的代码如下:

    • 1.在 Tutorial_A 这个项目中声明了两个参数,然后在TutorialConfig.h.in 文件引用了这两个参数,cmake 会根据这个文件生成一个名为 TutorialConfig.h 的文件。
    • 2.我们在 tutorial.cpp 中使用了 TutorialConfig.h,也就使用了 cmake 文件中定义的参数。这和我们在开发 android 的时候在 gradle 文件中定义参数最后在 java 代码中使用非常类似。
    • 3.我们接下来在 build 文件中依次运行 cmake ..make./Tutorial_A。会发现输出了我们使用的参数。
cmake_minimum_required (VERSION 2.6)
project (Tutorial_A)
# 我们可以在 cmake 的程序中添加键值对 set(KEY VALUE),下面就是一个键值对的设置方式。
# 如果想要在 cmake 文件中取出这个键值对则需要使用 ${KEY} 的方式
set (Tutorial_VERSION_MAJOR 1)
set (Tutorial_VERSION_MINOR 0)

## 这里可以设置一个配置文件,我们可以在 TutorialConfig.h.in 中配置 set() 中设置的键值对
## PROJECT_SOURCE_DIR 表示的是源代码的路径
## PROJECT_BINARY_DIR 表示的是cmake build 的路径
configure_file (
        "${PROJECT_SOURCE_DIR}/TutorialConfig.h.in"
        "${PROJECT_BINARY_DIR}/TutorialConfig.h"
)

# 将 cmake 的 build 目录添加到cmake 寻找 include 文件的目录列表中,这样一来 cmake 就能找到前面生成的 TutorialConfig.h 配置文件
include_directories("${PROJECT_BINARY_DIR}")

add_executable(Tutorial_A tutorial.cpp)
// A simple program that computes the square root of a number
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
// include 了cmake 生成配置文件
#include "TutorialConfig.h"

int main (int argc, char *argv[])
{
    if (argc < 2)
    {
        fprintf(stdout,"%s Version %d.%d\n",
                argv[0],
                // 使用了 cmake 生成的配置参数
                Tutorial_VERSION_MAJOR,
                Tutorial_VERSION_MINOR);
        fprintf(stdout,"Usage: %s number\n",argv[0]);
        return 1;
    }
    double inputValue = atof(argv[1]);
    double outputValue = sqrt(inputValue);
    fprintf(stdout,"The square root of %g is %g\n",
            inputValue, outputValue);
    return 0;
}
// 这个是配置文件,cmake 会根据他在 cmake 的 build 目录生成一个 TutorialConfig.h 文件
#define Tutorial_VERSION_MAJOR @Tutorial_VERSION_MAJOR@
#define Tutorial_VERSION_MINOR @Tutorial_VERSION_MINOR

(2).添加库的依赖

  • 1.我们进入项目的 two/a/mylib 中会看见三个文件 CMakeLists.txt、mysqrt.cpp、MathFunctions.h 代码如下:
    • 1.声明了一个 library
    • 2.定义了一个计算平方根的函数,然后使用头文件暴露在外面
cmake_minimum_required (VERSION 2.6)
# 声明了一个 library 名为 MathFunctions,他包含一个可执行文件 mysqrt.cpp
add_library(MathFunctions mysqrt.cpp)
#include "MathFunctions.h"
#include <stdio.h>

// a hack square root calculation using simple operations
double mysqrt(double x)
{
    if (x <= 0) {
        return 0;
    }

    double result;
    double delta;
    result = x;

    // do ten iterations
    int i;
    for (i = 0; i < 10; ++i) {
        if (result <= 0) {
            result = 0.1;
        }
        delta = x - (result * result);
        result = result + 0.5 * delta / result;
        fprintf(stdout, "Computing sqrt of %g to be %g\n", x, result);
    }
    return result;
}
//
// Created by 何时夕 on 2018/11/11.
//

#ifndef PROJECT_MATHFUNCTIONS_H
#define PROJECT_MATHFUNCTIONS_H
double mysqrt(double x);
#endif //PROJECT_MATHFUNCTIONS_H
  • 2.然后我们再看看 two/a 这个目录下面的文件,这些文件大部分是从 one/b 中拷贝来的,我就只贴有修改的部分 CMakeLists.txt、Configure.h.in、MathFunctions.h、tutorial.cpp:
    • 1.这里主要做的工作是现在 cmake 文件中定义了一个 USE_MYMATH 的开关,当这个开关为 ON 的时候就将我们定义的 library 集成到 project 中,否则就不集成,只使用系统自带的库。这个东西在跨平台的时候非常有用,比如 ios 和 android 中的 log 库不同,那么我就可以定义一个开关来区别这两个平台。
    • 2.可以注意到的是这里也定义了一个 Configure.h.in 文件作为配置文件,cmake 会根据这个文件来创建一个 Configure.h 文件,然后我们就可以在 Cpp 文件中使用我们定义的开关了。
    • 3.我们可以在 two/a/build 中运行 cmake..、make、./Tutorial_Mylib 3 这几个命令,会发现最终调用的是我们自己的函数,如果将 USE_MYMATH 改成 OFF 然后删除 build 中的文件再重新 build 一遍,会发现最后调用的是系统的函数。
cmake_minimum_required (VERSION 2.6)
project (Tutorial_Mylib)

set (Tutorial_VERSION_MAJOR 1)
set (Tutorial_VERSION_MINOR 0)

configure_file (
        "${PROJECT_SOURCE_DIR}/TutorialConfig.h.in"
        "${PROJECT_BINARY_DIR}/TutorialConfig.h"
)

# 添加一个是否使用我们自己的库的开关 USE_MYMATH,这个开关可以在 cmake 中直接使用
option (USE_MYMATH
        "Use tutorial provided math implementation" ON)

# 定义一个文件来储存 USE_MYMATH,以便在 cpp 文件中使用
configure_file("${PROJECT_SOURCE_DIR}/Configure.h.in"
        "${PROJECT_BINARY_DIR}/Configure.h")

include_directories("${PROJECT_BINARY_DIR}")

# 如果我们把开关设置为 ON,那么就将 mylib 集成进编译中,否则就不集成。
if (USE_MYMATH)
    include_directories ("${PROJECT_SOURCE_DIR}/mylib")
    add_subdirectory (mylib)
    set (EXTRA_LIBS MathFunctions)
endif (USE_MYMATH)

add_executable (Tutorial_Mylib tutorial.cpp)

# 将library 与 project 进行链接,使得 project 中可以调用 library 中的函数
target_link_libraries (Tutorial_Mylib ${EXTRA_LIBS})
#cmakedefine USE_MYMATH
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include "TutorialConfig.h"
#include "Configure.h"
#ifdef USE_MYMATH
#include "MathFunctions.h"
#endif

int main (int argc, char *argv[])
{
    if (argc < 2)
    {
        fprintf(stdout,"%s Version %d.%d\n", argv[0],
                Tutorial_VERSION_MAJOR,
                Tutorial_VERSION_MINOR);
        fprintf(stdout,"Usage: %s number\n",argv[0]);
        return 1;
    }

    double inputValue = atof(argv[1]);

#ifdef USE_MYMATH
    // 如果开关开了,就使用我自己的库 
    double outputValue = mysqrt(inputValue);
    fprintf(stdout,"use my math");
#else
    double outputValue = sqrt(inputValue);
    fprintf(stdout,"not use my math");
#endif

    fprintf(stdout,"The square root of %g is %g\n",
            inputValue, outputValue);
    return 0;
}

(2).添加库的依赖

  • 1.我们进入项目的 two/a/mylib 中会看见三个文件 CMakeLists.txt、mysqrt.cpp、MathFunctions.h 代码如下:
    • 1.声明了一个 library
    • 2.定义了一个计算平方根的函数,然后使用头文件暴露在外面
cmake_minimum_required (VERSION 2.6)
# 声明了一个 library 名为 MathFunctions,他包含一个可执行文件 mysqrt.cpp
add_library(MathFunctions mysqrt.cpp)
#include "MathFunctions.h"
#include <stdio.h>

// a hack square root calculation using simple operations
double mysqrt(double x)
{
    if (x <= 0) {
        return 0;
    }

    double result;
    double delta;
    result = x;

    // do ten iterations
    int i;
    for (i = 0; i < 10; ++i) {
        if (result <= 0) {
            result = 0.1;
        }
        delta = x - (result * result);
        result = result + 0.5 * delta / result;
        fprintf(stdout, "Computing sqrt of %g to be %g\n", x, result);
    }
    return result;
}
//
// Created by 何时夕 on 2018/11/11.
//

#ifndef PROJECT_MATHFUNCTIONS_H
#define PROJECT_MATHFUNCTIONS_H
double mysqrt(double x);
#endif //PROJECT_MATHFUNCTIONS_H
  • 2.然后我们再看看 two/a 这个目录下面的文件,这些文件大部分是从 one/b 中拷贝来的,我就只贴有修改的部分 CMakeLists.txt、Configure.h.in、MathFunctions.h、tutorial.cpp:
    • 1.这里主要做的工作是现在 cmake 文件中定义了一个 USE_MYMATH 的开关,当这个开关为 ON 的时候就将我们定义的 library 集成到 project 中,否则就不集成,只使用系统自带的库。这个东西在跨平台的时候非常有用,比如 ios 和 android 中的 log 库不同,那么我就可以定义一个开关来区别这两个平台。
    • 2.可以注意到的是这里也定义了一个 Configure.h.in 文件作为配置文件,cmake 会根据这个文件来创建一个 Configure.h 文件,然后我们就可以在 Cpp 文件中使用我们定义的开关了。
    • 3.我们可以在 two/a/build 中运行 cmake..、make、./Tutorial_Mylib 3 这几个命令,会发现最终调用的是我们自己的函数,如果将 USE_MYMATH 改成 OFF 然后删除 build 中的文件再重新 build 一遍,会发现最后调用的是系统的函数。
cmake_minimum_required (VERSION 2.6)
project (Tutorial_Mylib)

set (Tutorial_VERSION_MAJOR 1)
set (Tutorial_VERSION_MINOR 0)

configure_file (
        "${PROJECT_SOURCE_DIR}/TutorialConfig.h.in"
        "${PROJECT_BINARY_DIR}/TutorialConfig.h"
)

# 添加一个是否使用我们自己的库的开关 USE_MYMATH,这个开关可以在 cmake 中直接使用
option (USE_MYMATH
        "Use tutorial provided math implementation" ON)

# 定义一个文件来储存 USE_MYMATH,以便在 cpp 文件中使用
configure_file("${PROJECT_SOURCE_DIR}/Configure.h.in"
        "${PROJECT_BINARY_DIR}/Configure.h")

include_directories("${PROJECT_BINARY_DIR}")

# 如果我们把开关设置为 ON,那么就将 mylib 集成进编译中,否则就不集成。
if (USE_MYMATH)
    include_directories ("${PROJECT_SOURCE_DIR}/mylib")
    add_subdirectory (mylib)
    set (EXTRA_LIBS MathFunctions)
endif (USE_MYMATH)

add_executable (Tutorial_Mylib tutorial.cpp)

# 将library 与 project 进行链接,使得 project 中可以调用 library 中的函数
target_link_libraries (Tutorial_Mylib ${EXTRA_LIBS})
#cmakedefine USE_MYMATH
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include "TutorialConfig.h"
#include "Configure.h"
#ifdef USE_MYMATH
#include "MathFunctions.h"
#endif

int main (int argc, char *argv[])
{
    if (argc < 2)
    {
        fprintf(stdout,"%s Version %d.%d\n", argv[0],
                Tutorial_VERSION_MAJOR,
                Tutorial_VERSION_MINOR);
        fprintf(stdout,"Usage: %s number\n",argv[0]);
        return 1;
    }

    double inputValue = atof(argv[1]);

#ifdef USE_MYMATH
    // 如果开关开了,就使用我自己的库 
    double outputValue = mysqrt(inputValue);
    fprintf(stdout,"use my math");
#else
    double outputValue = sqrt(inputValue);
    fprintf(stdout,"not use my math");
#endif

    fprintf(stdout,"The square root of %g is %g\n",
            inputValue, outputValue);
    return 0;
}

(3).安装库与可执行文件

  • 1.我们进入项目的 three/a 文件夹中,这里面的文件都是从 two/a 中复制过来的,我只将增加的代码列一下mylib/CMakeLists.txt、a/CMakeLists.txt:
    • 1.这里就比较简单了,就只是将我们生成的库与可执行文件安装到电脑中去
    • 2.先依次运行cmake ..、make、make install,然后可以运行 /usr/local/bin/Tutorial_Mylib_Install 3 来查看是否安装成功,注意这里的路径是 Mac 电脑的路径。
# 安装这个库,将库和头文件分别添加到 bin 和 include 文件夹中,最后移动到的地方如下
# /usr/local/bin/libMathFunctions_Install.a
# /usr/local/include/MathFunctions.h
install (TARGETS MathFunctions_Install DESTINATION bin)
install (FILES MathFunctions.h DESTINATION include)
# TARGETS包含六种形式:ARCHIVE, LIBRARY, RUNTIME, OBJECTS, FRAMEWORK,  BUNDLE。注意Mathfunction_Install安装的是LIBRARY,Tutorial_Mylib_Install 是RUNTIME类型。
# FILE 将给定的文件复制到指定目录。如果没有给定权限参数,则由该表单安装的文件默认为OWNER_WRITE、OWNER_READ、GROUP_READ和WORLD_READ。
# TARGETS和FILE可指定为相对目录和绝对目录。
# DESTINATION在这里是一个相对路径,取默认值。在unix系统中指向 /usr/local 在windows上c:/Program Files/${PROJECT_NAME}。
# 也可以通过设置CMAKE_INSTALL_PREFIX这个变量来设置安装的路径,那么安装位置不指向/usr/local,而指向你所指定的目录。

# 安装这个可执行文件,将可执行文件和头文件分别添加到 bin 和 include 文件夹中,最后移动到的地方如下
# /usr/local/bin/Tutorial_Mylib_Install
# /usr/local/include/TutorialConfig.h
install (TARGETS Tutorial_Mylib_Install DESTINATION bin)
install (FILES "${PROJECT_BINARY_DIR}/TutorialConfig.h"
         DESTINATION include)

(4).Cmake生成Cpp文件

  • 1.我们进入 four/a 目录中,这里的代码都是从 two/a 中拷贝过来的,所以我就只贴修改的部分,mylib/CMakeLists.txt、mylib/MakeTable.cpp、a/Configure.h.in:
    • 1.这里的目的主要是通过 MakeTable 这个 project 生成一个 Table.h。最后给 mysqrt.cpp 在当前系统中没有 log 和 exp 这两个函数的时候使用。
    • 2.我们运行了 cmake.. 之后会发现 build/mylib 目录中生成了 Table.h 这个文件
project(MakeTable)

add_executable(MakeTable MakeTable.cpp)

# 1.输出 Table 文件
# 2.将 Table 文件作为参数传入 MakeTable 项目中,并运行它
# 3.Table 的生成是依赖于 MakeTable 这个 project 的
# CMAKE_CURRENT_BINARY_DIR 表示某个 cmake 文件build之后的文件夹,比如这里就是指 build/mylib
add_custom_command(OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/Table.h
        COMMAND MakeTable ${CMAKE_CURRENT_BINARY_DIR}/Table.h
        DEPENDS MakeTable)

include_directories(${CMAKE_CURRENT_BINARY_DIR})
# 将生成的表一起编译到 MathFunctions_Table 中去
add_library(MathFunctions_Table mysqrt.cpp ${CMAKE_CURRENT_BINARY_DIR}/Table.h)
//
// Created by 何时夕 on 2018/10/20.
//
#include <stdio.h>
#include <stdlib.h>
#include "math.h"

int main (int argc, char *argv[]) {
    double result;
    if (argc < 2) {
        return 1;
    }
    FILE *fout = fopen(argv[1], "w");
    if (!fout) {
        return 1;
    }
    fprintf(fout, "double sqrtTable[] = {\n");
    for (int j = 0; j < 10; ++j) {
        result = sqrt(static_cast<double>(j));
        fprintf(fout, "%g,\n", result);
    }
    fprintf(fout, "0};\n");
    fclose(fout);
    return 0;
}
#cmakedefine USE_MYMATH
#cmakedefine HAVE_LOG
#cmakedefine HAVE_EXP

(5).CMake语法

  • 1.必填、[command]可填、a|b 均可
  • 2.cmake 可以三种形式组织文件
  • 3.文件夹形式:类似 gradle 根目录下需要有一个 CMakeList.txt 的文件作为入口,如果其他目录下面还 需要有新的子文件夹要编译,子文件夹下也需要有 CMakeList.txt。而且需要在根目录 CMakeList.txt 下 用 add_subdirectory() 来注明。此外,每个CMakeList.txt 在被处理的时候都是以 cmake 命令调用 的文件夹作为当前工作目录和输出目录。
  • 4.定义和取消变量用的是 set() 和 unset(),被定义的变量始终是字符串类型,变量名区分大小写。 变量名用{}进行获取,也可在变量中进行嵌套{${}}
  • 5.add_excutable() 和 add_library() 分别用于生成可执行文件与库。构建 android so 库的时候 可使用 add_library()。target_link_libraries() 用于链接n个互相之间有依赖关系的库
  • 6.message([] "message to display") 这个方法用于输出日志

(6).CMake流程语句

  • 1.if:用法类似c语言,在使用参数的时候不需要用${}来取值
  • 2.foreach:foreach(loop_var 1 2 3) ... endforeach(loop_var) 或者 foreach(loop_var RANGE 4) ... endforeach(loop_var) 或者 foreach(loop_var RANGE 0 3 1) ... endforeach(loop_var) 从 0 到 3,1是步伐
  • 3.while:while(condition) ... endwhile(condition)
  • 4.foreach 和 while 可用 break 和 continue,在循环中使用${}进行取值
  • 5.可用 option 和 if 进行配合。option(<option_var> "description" [initial_var])

(7).宏与方法

  • 1.macro( [a1 [a2 [a3 ...]) ... endmacro(),可在内部使用 ${a1} 来引用变量,
    • 1.ARGV#,#是下标,可用于引用变量
    • 2.ARGV,表示所有传入变量
    • 3.ARGN,传入了需要参数以外的参数
    • 4.ARGC,传入的参数总个数
    • 5.macro 是字符替换,类似 c 语言中的预处理,所以在 if 中使用的时候需要 ${} 来获取参数
  • 2.function( [a1 [a2 [a3 ...]) ... endmacro(),与 macro 类似,但是不是字符替换, 是实实在在的调用函数。

四、FFmpeg官方demo讲解

先上一个项目:FFmpeg-learing,以后关于 FFmpeg 的 demo 都会添加到这个项目中去,大家看博客的时候还是需要结合这个项目一起看。

1.项目结构

  • 1.首先先了解一下这个项目的结构吧,如图1:
    • 1.图1的 java 目录下面我想大家应该都清楚,放的是开发 android 的 java 文件
    • 2.然后我们看 jni/ffmpeg 这个目录中有三个文件夹:
      • 1.armeabi:这里放的是 so 文件,这里的 so 文件是我从 ffmpeg 的源码中编译过来的。每一个 so 文件都对应着我们在第二章中讲解的一个 FFmpeg 的模块代码。
      • 2.include:这里放的是 FFmepg 各个模块暴露出来的 .h 文件,也就是说我们需要通过 .h 文件中的函数定义来调起各个 so 文件中的函数实现。
      • 3.my:这里放的是我写的代码。

图1:项目结构 水印.png

  • 2.再来看看项目中的 Cmake 文件,因为 android studio 目前支持 Cmake 文件来管理 android 中的 Cpp代码。
    • 1.如果第三章你认真看过了的话,那么这里应该也很好理解。这里主要新增了两个我们之前没有讲到的 cmake 命令:
      • 1.find_library:这个命令主要是用来寻找本地存在的库的路径的,在这里我去寻找 log 这个库在本地的路径然后将其赋值给 log-lib 这个参数。使用 message 输出一个 ${log-lib} 我们可以发现其就是 android ndk 目录下面的 liblog.so 文件,其主要用于 android 的日志输出。除此之外,你可以使用这个命令去寻找你在本地拥有的各种so文件。
      • 2.set_target_properties:这个命令主要是将各种 so 文件的路径转化成简单的值。 例如:set_target_properties( postproc-54 PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/src/main/jni/ffmpeg/armeabi/libpostproc-54.so) 这里就是将 so 文件的地址转化成 postproc-54 这个简短的名字以便后面使用。
      • 3.剩下来的代码我就简单说了:主要就是将一个个 so 文件声明成一个个 library,最后使用target_link_libraries 命令将我写的代码与各个 so 文件的 library 再链接起来,这样最终就能将所有的 Cpp 代码打包到 android app 中去。

图2:cmake文件1 水印.png

图3:cmake文件2 水印.png

2.FFmpeg读取视频文件信息

**我们先来看第一个官方文档中的 Demo:从视频文件中读取视频信息。 **

  • 1.首先根据我们在第二章节描述的多媒体概念我们可以知道:视频的数据有许多的封装格式,比如 MP4、avi、flv 等等。在 FFmpeg 中用于处理这些视频格式的 struct (因为 FFmpeg 使用 c 写的所以,内部还没有类的概念。)就是 AVFormatContext。大家可以进入这个 struct 可以看见其定义其实和 java 中的 class 类似。有成员变量,有函数指针(用于代替成员函数)。
  • 2.有了解析视频数据封装格式的 struct,我们还需要一个能从文件中读取数据的东西。在 FFmpeg 中这个东西就是 AVIOContext,这个东西是 AVFormatContext 的成员变量,用于从不断的从文件中读取数据,然后将数据送给 AVFormatContext 解析。
  • 3.科普了两个 struct 我们就可以讲解 demo 了。入口是下面代码中的 av_io_reading 方法,这个方法的入参是 argc 表示 argv 数组的数量,argv 中有两个参数 分别表示输入文件与输出文件。注意:接下来我在文章中讲解的 FFmpeg 的方法,已经下载过项目的同学可以直接去方法定义的地方查看,我讲过的方法的文档我都翻译成便于理解的中文了。
    • 1.首先在前面定义了一堆变量
      • 1.比如我们前面说的两个 struct。
      • 2.然后是定义了两个 unit8_t 的指针,其实 unit8_t 就是 unsigned char 大家可以进入看看它的定义。而熟悉 c 的同学应该知道,unsigned char 指针其实一般指向的就是一块内存类似于 java 中的 byte 数组。
      • 3.然后定义了两个 size_t 分别表示2中定义的两个 unit8_t 指针指向的内存大小。
      • 4.然后定义了两个 char 指针,分别表示输入输出文件。
      • 5.最后定义了一个 ret 表示本方法的返回值,和一个 buffer_data 类型的 struct ,这个是我们自己定义的,封装了 unit8_t 指针与 size_t,这样方便一点。
    • 2.接下来我们直接到 av_file_map 这个方法,这个方法简单来说就是将:input_filename 这个文件中的数据使用 mmap() 映射到内存中,然后用 buffer 指针指向这块内存,然后将这块内存的大小交给 buffer_size 指针
    • 3.跳过中间的一些代码我们来到 avformat_alloc_context 这个方法,这个方法很简单:**就是初始化一个AVFormatContext **
    • 4.然后再到 av_malloc 方法,我们用这个方法让 avio_ctx_buffer 指针指向了一个 4kb 的内存区域,这块内存用于后面不断的从 buffer 中以 4kb 的量读取数据。关键字是内存对齐参考资料
    • 5.我们接下来到了 avio_alloc_context 方法,这个方法是用于初始化一个 AVIOContext。这里我们传入了几个参数我来解释一下:
      • 1.首先是 avio_ctx_buffer 和其对应的 size。我们在4中说了,之后从 buffer 中读取数据都是用这个内存块读取,而 AVIOContext 就是调用这个读取的对象。
      • 2.然后传入了一个 buffer_data 类型的地址和一个函数的地址 read_packet。其实这里很类似我们在 java 中使用的回调。**AVIOContext 不会负责真正的从 buffer 中取数据到 buffer_data 的过程。他只需要在适当的时候调用 read_packet,其中填充 buffer_data 的逻辑由我们来实现。**如果你手上有代码,去查看定义会发现,下一个 NULL 的参数是一个用于将 buffer_data 写入到某个地方的函数。
    • 6.接下来就到了 avformat_open_input 这个方法,这个方法用起来也简单就是将我们前面构建的 AVFormatContext 让其将我们在前面定义的AVIOContext 以流的方式来读取。这里会先读取文件的 header 也就是我们在第二章中提到的 文件头,这里面有着视频文件的各种信息。
    • 7.最后两个方法 avformat_find_stream_info 和 av_dump_format 就比较简单了,一个是解析6中流的信息,一个是将视频封装文件的信息输出到文件中。
    • 8.后面的工作就是释放前面申请的各种内存空间了,c 不像 java 有垃圾回收机制,我们前面说的很多创建struct 的方法都有对应的释放内存的方法,我在项目中的方法定义处都一一翻译了。
    • 9.讲到这里我想很多同学可能会一脸懵逼,这也是正常的,毕竟只是调用一个个方法而不知道内部是咋实现的,心中肯定会非常的虚。而且一些数据结构也不知道有啥用,内部实现是啥。不过别担心,这只是音视频的开篇,事情总得一步步来。后续我也会带大家深入 FFmpeg 的源代码,然后模仿着公司的代码写一些企业级的可用代码。
struct buffer_data {
    uint8_t *ptr;
    size_t size; ///< size left in the buffer
};
static int read_packet(void *opaque, uint8_t *buf, int buf_size)
{
    struct buffer_data *bd = (struct buffer_data *)opaque;
    buf_size = FFMIN(buf_size, bd->size);

    if (!buf_size)
        return AVERROR_EOF;
    printf("ptr:%p size:%zu\n", bd->ptr, bd->size);

    /* copy internal buffer data to buf */
    memcpy(buf, bd->ptr, buf_size);
    bd->ptr  += buf_size;
    bd->size -= buf_size;

    return buf_size;
}

int av_io_reading(int argc, char *argv[])
{
    syslog_init();
    AVFormatContext *fmt_ctx = NULL;
    AVIOContext *avio_ctx = NULL;
    uint8_t *buffer = NULL, *avio_ctx_buffer = NULL;
    size_t buffer_size, avio_ctx_buffer_size = 4096;
    char *input_filename = NULL;
    char *output_filename = NULL;
    int ret = 0;
    struct buffer_data bd = { 0 };

    if (argc != 2) {
        fprintf(stderr, "usage: %s input_file\n"
                "API example program to show how to read from a custom buffer "
                "accessed through AVIOContext.\n", argv[0]);
        return 1;
    }
    input_filename = argv[0];
    output_filename = argv[1];

    // 将 input_filename 指向的文件数据读取出来,然后用 buffer 指针指向他,buffer_size 中存有 buffer 内存的大小
    ret = av_file_map(input_filename, &buffer, &buffer_size, 0, NULL);
    if (ret < 0)
        goto end;

    bd.ptr  = buffer;
    bd.size = buffer_size;

    if (!(fmt_ctx = avformat_alloc_context())) {
        ret = AVERROR(ENOMEM);
        goto end;
    }

    // 申请四个字节大小的缓冲区,在后面作为内存对齐的标准使用
    avio_ctx_buffer = (uint8_t *) av_malloc(avio_ctx_buffer_size);
    if (!avio_ctx_buffer) {
        ret = AVERROR(ENOMEM);
        goto end;
    }

    avio_ctx = avio_alloc_context(avio_ctx_buffer, avio_ctx_buffer_size,
                                  0, &bd, &read_packet, NULL, NULL);
    if (!avio_ctx) {
        ret = AVERROR(ENOMEM);
        goto end;
    }
    fmt_ctx->pb = avio_ctx;

    ret = avformat_open_input(&fmt_ctx, NULL, NULL, NULL);
    if (ret < 0) {
        fprintf(stderr, "Could not open input\n");
        goto end;
    }

    ret = avformat_find_stream_info(fmt_ctx, NULL);
    if (ret < 0) {
        fprintf(stderr, "Could not find stream information\n");
        goto end;
    }

    av_dump_format(fmt_ctx, 0, output_filename , 0);

    end:
    avformat_close_input(&fmt_ctx);
    /* note: the internal buffer could have changed, and be != avio_ctx_buffer */
    if (avio_ctx) {
        av_freep(&avio_ctx->buffer);
        av_freep(&avio_ctx);
    }
    av_file_unmap(buffer, buffer_size);

    char buf2[500] = {0};
    av_strerror(ret, buf2, 1024);
    if (ret < 0) {
        fprintf(stderr, "Error occurred: %s\n", av_err2str(ret));
        return 1;
    }

    return 0;
}

3.声明

本来讲两个官方 Demo 的,但是篇幅有限就到此为止吧。我在项目中其实已经集成了编码视频解码视频的 demo。各个方法的定义处也有中文解释,有兴趣的同学可以自行查看。还要说的一件事情是,因为时间有限,其实项目里的很多东西是不能保证运行成功的,这个问题我后面如果都测试通过了会在 commit 里面声明。

五、尾巴

音视频开篇总算写完了,有个“伟人”说得好:你知道的越多,你不知道的就越多——何时夕。我最近也感觉到了自己的许多不足之处,每天早晨骑车上班的时候都会反思一下前一天做的不好的地方。吾日三省吾身,这句话不管在什么年代都不过时啊,共勉!!!

不贩卖焦虑,也不标题党。分享一些这个世界上有意思的事情。题材包括且不限于:科幻、科学、科技、互联网、程序员、计算机编程。下面是我的微信公众号:世界上有意思的事,干货多多等你来看。

世界上有意思的事