Add files via upload

main
zlh668 2 years ago committed by GitHub
parent 334a85fd1c
commit 68da912b4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 353
      iOS资料/MACiOS利用FFmpeg解析音视频数据流.md
  2. 161
      iOS资料/iOS - 图形高级处理 (一、图片显示相关理论).md
  3. 593
      iOS资料/iOS AVDemo(5):音频解码.md
  4. 576
      iOS资料/iOS AVDemo(6):音频渲染.md
  5. 697
      iOS资料/iOS AVDemo(7):视频采集.md
  6. 819
      iOS资料/iOS AVDemo(8):视频编码,H.264 和 H.265 都支持.md
  7. 353
      iOS资料/iOS AVDemo(9):视频封装,采集编码 H.264H.265 并封装 MP4.md
  8. 79
      iOS资料/iOS 入门(2):管理第三方库.md
  9. 241
      iOS资料/iOS 离屏渲染探究.md
  10. 327
      iOS资料/iOSAVDemo(10):视频解封装,从 MP4 解出 H.264H.265.md
  11. 108
      iOS资料/iOS动画系列之三:Core Animation.md
  12. 209
      iOS资料/iOS图像渲染及卡顿问题优化.md
  13. 192
      iOS资料/iOS视图渲染与性能优化.md
  14. 237
      iOS资料/iOS逆向 MachO文件.md
  15. 283
      iOS资料/iOS音视频开发-代码实现视频编码.md
  16. 106
      iOS资料/iOS项目集成OpenCV及踩过的坑.md
  17. 81
      iOS资料/视频直播iOS端技术.md

@ -0,0 +1,353 @@
# MAC/iOS利用FFmpeg解析音视频数据流
## 1.简易流程
**使用流程**
- 初始化解析类: `- (instancetype)initWithPath:(NSString *)path;`
- 开始解析: `startParseWithCompletionHandler`
- 获取解析后的数据: 从上一步中`startParseWithCompletionHandler`方法中的Block获取解析后的音视频数据.
**FFmpeg parse流程**
- 创建format context: `avformat_alloc_context`
- 打开文件流: `avformat_open_input`
- 寻找流信息: `avformat_find_stream_info`
- 获取音视频流的索引值: `formatContext->streams[i]->codecpar->codec_type == (isVideoStream ? AVMEDIA_TYPE_VIDEO : AVMEDIA_TYPE_AUDIO)`
- 获取音视频流: `m_formatContext->streams[m_audioStreamIndex]`
- 解析音视频数据帧: `av_read_frame`
- 获取extra data: `av_bitstream_filter_filter`
具体步骤
\1. 将FFmpeg框架导入项目中
下面的链接中包含搭建iOS需要的FFmpeg环境的详细步骤,需要的可以提前阅读.
[iOS编译FFmpeg](https://zhuanlan.zhihu.com/p/533700525)
导入FFmpeg框架后,首先需要将用到FFmpeg的文件改名为`.mm`, 因为涉及C,C++混编,所以需要更改文件名
然后在头文件中导入FFmpeg头文件.
```text
// FFmpeg Header File
#ifdef __cplusplus
extern "C" {
#endif
#include "libavformat/avformat.h"
#include "libavcodec/avcodec.h"
#include "libavutil/avutil.h"
#include "libswscale/swscale.h"
#include "libswresample/swresample.h"
#include "libavutil/opt.h"
#ifdef __cplusplus
};
#endif
```
注意: FFmpeg是一个广为流传的框架,其结构复杂,一般导入都按照如上格式,以文件夹名为根目录进行导入,具体设置,请参考上文链接.
## 2. 初始化
### **2.1. 注册FFmpeg**
- `void av_register_all(void);` 初始化libavformat并注册所有muxers,demuxers与协议。如果不调用此功能,则可以选择一个特定想要支持的格式。
一般在程序中的main函数或是主程序启动的代理方法`- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions`中初始化FFmpeg,执行一次即可.
```text
av_register_all();
```
### **2.2. 利用视频文件生成格式上下文对象**
- `avformat_alloc_context()`: 初始化avformat上下文对象.
- `int avformat_open_input(AVFormatContext **ps, const char *url, AVInputFormat *fmt, AVDictionary **options)`函数
- - `fmt`: 如果非空表示强制指定一个输入流的格式, 设置为空会自动选择.
- `int avformat_find_stream_info(AVFormatContext *ic, AVDictionary **options);` :读取媒体文件的数据包以获取流信息
```text
- (AVFormatContext *)createFormatContextbyFilePath:(NSString *)filePath {
if (filePath == nil) {
log4cplus_error(kModuleName, "%s: file path is NULL",__func__);
return NULL;
}
AVFormatContext *formatContext = NULL;
AVDictionary *opts = NULL;
av_dict_set(&opts, "timeout", "1000000", 0);//设置超时1秒
formatContext = avformat_alloc_context();
BOOL isSuccess = avformat_open_input(&formatContext, [filePath cStringUsingEncoding:NSUTF8StringEncoding], NULL, &opts) < 0 ? NO : YES;
av_dict_free(&opts);
if (!isSuccess) {
if (formatContext) {
avformat_free_context(formatContext);
}
return NULL;
}
if (avformat_find_stream_info(formatContext, NULL) < 0) {
avformat_close_input(&formatContext);
return NULL;
}
return formatContext;
}
```
### **2.3. 获取Audio / Video流的索引值.**
通过遍历format context对象可以从`nb_streams`数组中找到音频或视频流索引,以便后续使用
注意: 后面代码中仅需要知道音频,视频的索引就可以快速读取到format context对象中对应流的信息.
```text
- (int)getAVStreamIndexWithFormatContext:(AVFormatContext *)formatContext isVideoStream:(BOOL)isVideoStream {
int avStreamIndex = -1;
for (int i = 0; i < formatContext->nb_streams; i++) {
if ((isVideoStream ? AVMEDIA_TYPE_VIDEO : AVMEDIA_TYPE_AUDIO) == formatContext->streams[i]->codecpar->codec_type) {
avStreamIndex = i;
}
}
if (avStreamIndex == -1) {
log4cplus_error(kModuleName, "%s: Not find video stream",__func__);
return NULL;
}else {
return avStreamIndex;
}
}
```
### **2.4. 是否支持音视频流**
目前视频仅支持H264, H265编码的格式.实际过程中,解码得到视频的旋转角度可能是不同的,以及不同机型可以支持的解码文件格式也是不同的,所以可以用这个方法手动过滤一些不支持的情况.具体请下载代码观看,这里仅列出实战中测试出支持的列表.
```text
/*
各机型支持的最高分辨率和FPS组合:
iPhone 6S: 60fps -> 720P
30fps -> 4K
iPhone 7P: 60fps -> 1080p
30fps -> 4K
iPhone 8: 60fps -> 1080p
30fps -> 4K
iPhone 8P: 60fps -> 1080p
30fps -> 4K
iPhone X: 60fps -> 1080p
30fps -> 4K
iPhone XS: 60fps -> 1080p
30fps -> 4K
*/
```
音频本例中仅支持AAC格式.其他格式可根据需求自行更改.
## 3. 开始解析
- 初始化AVPacket以存放解析后的数据
使用AVPacket这个结构体来存储压缩数据.对于视频而言, 它通常包含一个压缩帧,对音频而言,可能包含多个压缩帧,该结构体类型通过`av_malloc()`函数分配内存,通过`av_packet_ref()`函数拷贝,通过`av_packet_unref().`函数释放内存.
```text
AVPacket packet;
av_init_packet(&packet);
```
解析数据
`int av_read_frame(AVFormatContext *s, AVPacket *pkt);` : 此函数返回存储在文件中的内容,并且不验证解码器的有效帧是什么。它会将存储在文件中的内容分成帧,并为每次调用返回一个。它不会在有效帧之间省略无效数据,以便为解码器提供解码时可能的最大信息。
```text
int size = av_read_frame(formatContext, &packet);
if (size < 0 || packet.size < 0) {
handler(YES, YES, NULL, NULL);
log4cplus_error(kModuleName, "%s: Parse finish",__func__);
break;
}
```
获取sps, pps等NALU Header信息
通过调用`av_bitstream_filter_filter`可以从码流中过滤得到sps, pps等NALU Header信息.
av_bitstream_filter_init: 通过给定的比特流过滤器名词创建并初始化一个比特流过滤器上下文.
av_bitstream_filter_filter: 此函数通过过滤`buf`参数中的数据,将过滤后的数据放在`poutbuf`参数中.输出的buffer必须被调用者释放.
此函数使用buf_size大小过滤缓冲区buf,并将过滤后的缓冲区放在poutbuf指向的缓冲区中。
```text
attribute_deprecated int av_bitstream_filter_filter ( AVBitStreamFilterContext * bsfc,
AVCodecContext * avctx,
const char * args, // filter 配置参数
uint8_t ** poutbuf, // 过滤后的数据
int * poutbuf_size, // 过滤后的数据大小
const uint8_t * buf,// 提供给过滤器的原始数据
int buf_size, // 提供给过滤器的原始数据大小
int keyframe // 如果要过滤的buffer对应于关键帧分组数据,则设置为非零
)
```
注意: 下面使用`new_packet`是为了解决`av_bitstream_filter_filter`会产生内存泄漏的问题.每次使用完后将用`new_packet`释放即可.
```text
if (packet.stream_index == videoStreamIndex) {
static char filter_name[32];
if (formatContext->streams[videoStreamIndex]->codecpar->codec_id == AV_CODEC_ID_H264) {
strncpy(filter_name, "h264_mp4toannexb", 32);
videoInfo.videoFormat = XDXH264EncodeFormat;
} else if (formatContext->streams[videoStreamIndex]->codecpar->codec_id == AV_CODEC_ID_HEVC) {
strncpy(filter_name, "hevc_mp4toannexb", 32);
videoInfo.videoFormat = XDXH265EncodeFormat;
} else {
break;
}
AVPacket new_packet = packet;
if (self->m_bitFilterContext == NULL) {
self->m_bitFilterContext = av_bitstream_filter_init(filter_name);
}
av_bitstream_filter_filter(self->m_bitFilterContext, formatContext->streams[videoStreamIndex]->codec, NULL, &new_packet.data, &new_packet.size, packet.data, packet.size, 0);
}
```
- 根据特定规则生成时间戳
可以根据自己的需求自定义时间戳生成规则.这里使用当前系统时间戳加上数据包中的自带的pts/dts生成了时间戳.
```text
CMSampleTimingInfo timingInfo;
CMTime presentationTimeStamp = kCMTimeInvalid;
presentationTimeStamp = CMTimeMakeWithSeconds(current_timestamp + packet.pts * av_q2d(formatContext->streams[videoStreamIndex]->time_base), fps);
timingInfo.presentationTimeStamp = presentationTimeStamp;
timingInfo.decodeTimeStamp = CMTimeMakeWithSeconds(current_timestamp + av_rescale_q(packet.dts, formatContext->streams[videoStreamIndex]->time_base, input_base), fps);
```
- 获取parse到的数据
本例将获取到的数据放在自定义的结构体中,然后通过block回调传给方法的调用者,调用者可以在回调函数中处理parse到的视频数据.
```text
struct XDXParseVideoDataInfo {
uint8_t *data;
int dataSize;
uint8_t *extraData;
int extraDataSize;
Float64 pts;
Float64 time_base;
int videoRotate;
int fps;
CMSampleTimingInfo timingInfo;
XDXVideoEncodeFormat videoFormat;
};
...
videoInfo.data = video_data;
videoInfo.dataSize = video_size;
videoInfo.extraDataSize = formatContext->streams[videoStreamIndex]->codec->extradata_size;
videoInfo.extraData = (uint8_t *)malloc(videoInfo.extraDataSize);
videoInfo.timingInfo = timingInfo;
videoInfo.pts = packet.pts * av_q2d(formatContext->streams[videoStreamIndex]->time_base);
videoInfo.fps = fps;
memcpy(videoInfo.extraData, formatContext->streams[videoStreamIndex]->codec->extradata, videoInfo.extraDataSize);
av_free(new_packet.data);
// send videoInfo
if (handler) {
handler(YES, NO, &videoInfo, NULL);
}
free(videoInfo.extraData);
free(videoInfo.data);
```
获取parse到的音频数据
```text
struct XDXParseAudioDataInfo {
uint8_t *data;
int dataSize;
int channel;
int sampleRate;
Float64 pts;
};
...
if (packet.stream_index == audioStreamIndex) {
XDXParseAudioDataInfo audioInfo = {0};
audioInfo.data = (uint8_t *)malloc(packet.size);
memcpy(audioInfo.data, packet.data, packet.size);
audioInfo.dataSize = packet.size;
audioInfo.channel = formatContext->streams[audioStreamIndex]->codecpar->channels;
audioInfo.sampleRate = formatContext->streams[audioStreamIndex]->codecpar->sample_rate;
audioInfo.pts = packet.pts * av_q2d(formatContext->streams[audioStreamIndex]->time_base);
// send audio info
if (handler) {
handler(NO, NO, NULL, &audioInfo);
}
free(audioInfo.data);
}
```
- 释放packet
因为我们已经将packet中的关键数据拷贝到自定义的结构体中,所以使用完后需要释放packet.
```text
av_packet_unref(&packet);
```
parse完成后释放相关资源
```text
- (void)freeAllResources {
if (m_formatContext) {
avformat_close_input(&m_formatContext);
m_formatContext = NULL;
}
if (m_bitFilterContext) {
av_bitstream_filter_close(m_bitFilterContext);
m_bitFilterContext = NULL;
}
}
```
注意: 如果使用FFmpeg硬解,则仅仅需要获取到AVPacket数据结构即可.不需要再将数据封装到自定义的结构体中
## 4. 外部调用
上面操作执行完后,即可通过如下block获取解析后的数据,一般需要继续对音视频进行解码操作.后面文章会讲到,请持续关注.
```text
XDXAVParseHandler *parseHandler = [[XDXAVParseHandler alloc] initWithPath:path];
[parseHandler startParseGetAVPackeWithCompletionHandler:^(BOOL isVideoFrame, BOOL isFinish, AVPacket packet) {
if (isFinish) {
// parse finish
...
return;
}
if (isVideoFrame) {
// decode video
...
}else {
// decode audio
...
}
}];
```
原文https://zhuanlan.zhihu.com/p/533710513

@ -0,0 +1,161 @@
# iOS - 图形高级处理 (一、图片显示相关理论)
## 1、图片从磁盘中读入到显示到屏幕全过程
### 1.1图片的加载过程:
- 使用 +imageWithContentsOfFile: 方法从磁盘中加载一张图片或 -[UIImage imageNamed:@"xx.JPG"]此时图片并没有解码;([两种方式的区别](https://link.juejin.cn?target=http%3A%2F%2Fwww.cocoachina.com%2Farticles%2F26556))
- 初始化完成的UITmage 赋值给 UIImageView;
- 接着一个隐式的 CATransaction 捕获到了 UIImageView 图层树的变化;
- 在主线程的下一个 runloop 到来时,Core Animation 提交了这个隐式的 transaction ,这个过程可能会对图片进行 copy 操作,而受图片是否字节对齐等因素的影响,这个 copy 操作可能会涉及以下部分或全部步骤:
- 分配内存缓冲区用于管理文件 IO 和解压缩操作;
- 将文件数据从磁盘读到内存中;
- 将压缩的图片数据解码成未压缩的位图形式,这是一个非常耗时的 CPU 操作;
- 最后 Core Animation 中CALayer使用未压缩的位图数据渲染 UIImageView 的图层。
- CPU计算好图片的Frame,对图片解压之后.就会交给GPU来做图片渲染。
由上面的步骤可知,图片的解压缩是一个非常耗时的 CPU 操作,并且它默认是在主线程中执行的。那么当需要加载的图片比较多时,就会对我们应用的响应性造成严重的影响,尤其是在快速滑动的列表上,这个问题会表现得更加突出。
### 1.2渲染图片到屏幕上
- iOS设备给用户视觉反馈其实都是通过QuartzCore框架来进行的,说白了,所有用户最终看到的显示界面都是图层合成的结果,而图层即是QuartzCore中的CALayer。
- 通常我们开发中使用的视图即UIView,他并不是直接显示在屏幕上的,你可以把他想象成一个装有显示层CALayer的容器。我们在在创建视图对象的时候,系统会自动为该视图创建一个CALayer;当然我们也可以自己再往该视图中加入新的CALayer层。等到需要显示的时候,系统硬件将把所有层进行拷贝,然后按Z轴的高低合成最终的合成效果。 ![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5ec12ca4a8084c9785c190fa1f30bcde~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
### 1.3图片渲染大致流程
- 在 VSync 信号到来后,主线程开始在cpu上做计算。
- CPU计算显示内容:视图创建、布局计算、图片解码、文本绘制等。
- GPU进行渲染:CPU将计算好的内容提交给GPU,GPU 进行变换、合成、渲染。
- GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上。
关于渲染更多知识点,例如离屏渲染等因为篇幅太长不利于学习,这部分放在后面app性能篇继续学习。
## 2、图形处理相关框架
通过以上图片加载显示的理论学习,我们就需要来继续学习一下图形处理的相关理论,毕竟在开发过程中我们无法,性能上也不允许,所有图片的显示都用UIimage从磁盘或内存中读入。同时一些界面显示也或多或少要使用到图形处理框架。 ![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/6be2bf274d9e473cbb1364518d20f6d7~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
### 2.1iOS与图形图像处理相关的框架汇总:
- 界面图形框架 -- UIKit
- 核心动画框架 -- Core Animation
- 苹果封装的图形框架 -- Core Graphics & Quartz 2D
- 传统跨平台图形框架 -- OpenGL ES
- 苹果最新力推的图形框架 -- Metal
- 适合图片的苹果滤镜框架 -- Core Image
- `适合视频的第三方滤镜方案 -- GPUImage (第三方不属于系统,这里列出来学习)`
- 游戏引擎 -- Scene Kit (3D) 和 Sprite Kit (2D)
- 计算机视觉在iOS的应用 -- OpenCV for iOS
毫无疑问,开发者们接触得最多的框架是以下几个,UIKit、Core Animation,Core Graphic, Core Image。下面简要介绍这几个框架,顺便介绍下`GPUImage`:
### 2.2界面图形框架 -- UIKit(穿插使用其他图形处理框架)
- UIKit是一组Objective-C API,为线条图形、Quartz图像和颜色操作提供Objective-C 封装,并提供2D绘制、图像处理及用户接口级别的动画。
- UIKit包括UIBezierPath(绘制线、角度、椭圆及其它图形)、UIImage(显示图像)、UIColor(颜色操作)、UIFont和UIScreen(提供字体和屏幕信息)等类以及在位图图形环境、PDF图形环境上进行绘制和 操作的功能等, 也提供对标准视图的支持,也提供对打印功能的支持。
- UIKit与Core Graphics的关系:
> 在UIKit中,UIView类本身在绘制时自动创建一个图形环境,即Core Graphics层的CGContext类型,作为当前的图形绘制环境。在绘制时可以调用 UIGraphicsGetCurrentContext 函数获得当前的图形环境;
例如:
> ```objectivec
> //这段代码就是在UIView的子类中调用 UIGraphicsGetCurrentContext 函数获得当前的图形环境,然后向该图形环境添加路径,最后绘制。
> - (void)drawRect:(CGRect)rect {
> //1.获取上下文
> CGContextRef contextRef = UIGraphicsGetCurrentContext();
> //2.描述路径
> UIBezierPath * path = [UIBezierPath bezierPath];
> //起点
> [path moveToPoint:CGPointMake(10, 10)];
> //终点
> [path addLineToPoint:CGPointMake(100, 100)];
> //设置颜色
> [[UIColor whiteColor]setStroke];
> //3.添加路径
> CGContextAddPath(contextRef, path.CGPath);
> //显示路径
> CGContextStrokePath(contextRef);
> }
> 复制代码
> ```
### 2.3核心动画框架 -- Core Animation
- Core Animation 是常用的框架之一。它比 UIKit 和 AppKit 更底层。正如我们所知,UIView底下封装了一层CALayer树,Core Animation 层是真正的渲染层,我们之所以能在屏幕上看到内容,真正的渲染工作是在 Core Animation 层进行的。
- Core Animation 是一套Objective-C API,实现了一个高性能的复合引擎,并提供一个简单易用的编程接口,给用户UI添加平滑运动和动态反馈能力。
- Core Animation 是 UIKit 实现动画和变换的基础,也负责视图的复合功能。使用Core Animation可以实现定制动画和细粒度的动画控制,创建复杂的、支持动画和变换的layered 2D视图
- OpenGL ES的内容也可以与Core Animation内容进行集成。
- 为了使用Core Animation实现动画,可以修改 层的属性值 来触发一个action对象的执行,不同的action对象实现不同的动画。Core Animation 提供了一组基类及子类,提供对不同动画类型的支持:
- CAAnimation 是一个抽象公共基类,CAAnimation采用CAMediaTiming 和CAAction协议为动画提供时间(如周期、速度、重复次数等)和action行为(启动、停止等)。
- CAPropertyAnimation 是 CAAnimation的抽象子类,为动画提供一个由一个key路径规定的层属性的支持;
- CABasicAnimation 是CAPropertyAnimation的具体子类,为一个层属性提供简单插入能力。
- CAKeyframeAnimation 也是CAPropertyAnimation的具体子类,提供key帧动画支持。
### 2.4苹果封装的图形框架 -- Core Graphics & Quartz 2D
- Core Graphics(使用Quartz 2D引擎)
- Core Graphics是一套C-based API, 支持向量图形,线、形状、图案、路径、剃度、位图图像和pdf 内容的绘制
- Core Graphics 也是常用的框架之一。它用于运行时绘制图像。开发者们可以通过 Core Graphics 绘制路径、颜色。当开发者需要在运行时创建图像时,可以使用 Core Graphics 去绘制,运行时实时计算、绘制一系列图像帧来实现动画。与之相对的是运行前创建图像(例如从磁盘中或内存中已经创建好的UIImage图像)。
- Quartz 2D
- Quartz 2D是Core Graphics中的2D 绘制呈现引擎。Quartz是资源和设备无关的,提供路径绘制,anti-aliased呈现,剃度填充图案,图像,透明绘制和透明层、遮蔽和阴影、颜色管理,坐标转换,字体、offscreen呈现、pdf文档创建、显示和分析等功能。
- Quartz 2D能够与所有的图形和动画技术(如Core Animation, OpenGL ES, 和 UIKit 等)一起使用。Quartz 2D采用paint模式进行绘制。
- Quartz 2D提供的主要类包括:
- CGContext:表示一个图形环境;
- CGPath:使用向量图形来创建路径,并能够填充和stroke;
- CGImage:用来表示位图;
- CGLayer:用来表示一个能够用于重复绘制和offscreen绘制的绘制层;
- CGPattern:用来表示Pattern,用于重复绘制;
- CGShading和 CGGradient:用于绘制剃度;
- CGColor 和 CGColorSpace;用来进行颜色和颜色空间管理;
- CGFont, 用于绘制文本;
- CGPDFContentStream、CGPDFScanner、CGPDFPage、CGPDFObject,CGPDFStream, CGPDFString等用来进行pdf文件的创建、解析和显示。
### 2.5适合图片的苹果滤镜框架 -- Core Image
- Core Image 与 Core Graphics 恰恰相反,Core Graphics 用于在运行时创建图像,而 Core Image 是用来处理已经创建的图像的。Core Image 框架拥有一系列现成的图像过滤器,能对已存在的图像进行高效的处理。
- Core Image 是 iOS5 新加入到 iOS 平台的一个图像处理框架,提供了强大高效的图像处理功能, 用来对基于像素的图像进行操作与分析, 内置了很多强大的滤镜(Filter) (目前数量超过了180种), 这些Filter 提供了各种各样的效果, 并且还可以通过 滤镜链 将各种效果的 Filter叠加 起来形成强大的自定义效果。
- 一个 滤镜 是一个对象,有很多输入和输出,并执行一些变换。例如,模糊滤镜可能需要输入图像和一个模糊半径来产生适当的模糊后的输出图像。
- 一个 滤镜链 是一个链接在一起的滤镜网络,使得一个滤镜的输出可以是另一个滤镜的输入。以这种方式,可以实现精心制作的效果。
- iOS8 之后更是支持自定义 CIFilter,可以定制满足业务需求的复杂效果。
- Core Image 的优点在于十分高效。大部分情况下,它会在 GPU 中完成工作,但如果 GPU 忙,会使用 CPU 进行处理。如果设备支持 Metal,那么会使用 Metal 处理。这些操作会在底层完成,Apple 的工程师们已经帮助开发者们完成这些操作了。
- 例如他可以根据需求选择 CPU 或者 GPU 来处理。
> ```ini
> // 创建基于 CPU 的 CIContext 对象 (默认是基于 GPU,CPU 需要额外设置参数)
> context = [CIContext contextWithOptions: [NSDictionary dictionaryWithObject:[NSNumber numberWithBool:YES] forKey:kCIContextUseSoftwareRenderer]];
> // 创建基于 GPU 的 CIContext 对象
> context = [CIContext contextWithOptions: nil];
> // 创建基于 GPU 的 CIContext 对象
> EAGLContext *eaglctx = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
> context = [CIContext contextWithEAGLContext:eaglctx];
> 复制代码
> ```
- Core Image 的 API 主要就是三类:
- CIImage 保存图像数据的类,可以通过UIImage,图像文件或者像素数据来创建,包括未处理的像素数据。
- CIFilter 表示应用的滤镜,这个框架中对图片属性进行细节处理的类。它对所有的像素进行操作,用一些键-值设置来决定具体操作的程度。
- CIContext 表示上下文,如 Core Graphics 以及 Core Data 中的上下文用于处理绘制渲染以及处理托管对象一样,Core Image 的上下文也是实现对图像处理的具体对象。可以从其中取得图片的信息。
### 2.5适合视频的第三方滤镜方案 -- GPUImage
- GPUImage
是一个基于OpenGL ES 2.0的开源的图像处理库,优势:
- 最低支持 iOS 4.0,iOS 5.0 之后就支持自定义滤镜。在低端机型上,GPUImage 有更好的表现。
- GPUImage 在视频处理上有更好的表现。
- GPUImage 的代码已经开源。可以根据自己的业务需求,定制更加复杂的管线操作。可定制程度高。
原文链接:https://juejin.cn/post/6847902216238399496

@ -0,0 +1,593 @@
# iOS AVDemo(5):音频解码
iOS/Android 客户端开发同学如果想要开始学习音视频开发,最丝滑的方式是对[音视频基础概念知识](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2140155659944787969#wechat_redirect)有一定了解后,再借助本地平台的音视频能力上手去实践音视频的`采集 → 编码 → 封装 → 解封装 → 解码 → 渲染`过程,并借助[音视频工具](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2216997905264082945#wechat_redirect)来分析和理解对应的音视频数据。
在[音视频工程示例](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2273301900659851268#wechat_redirect)这个栏目,我们将通过拆解`采集 → 编码 → 封装 → 解封装 → 解码 → 渲染`流程并实现 Demo 来向大家介绍如何在 iOS/Android 平台上手音视频开发。
这里是第五篇:**iOS 音频解码 Demo**。这个 Demo 里包含以下内容:
- 1)实现一个音频解封装模块;
- 2)实现一个音频解码模块;
- 3)实现对 MP4 文件中音频部分的解封装和解码逻辑,并将解封装、解码后的数据存储为 PCM 文件;
- 4)详尽的代码注释,帮你理解代码逻辑和原理。
## 1、音频解封装模块
在这个 Demo 中,解封装模块 `KFMP4Demuxer` 的实现与 [《iOS 音频解封装 Demo》](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484932&idx=1&sn=04fa6fb220574c0a5d417f4b527c0142&scene=21#wechat_redirect) 中一样,这里就不再重复介绍了,其接口如下:
```
KFMP4Demuxer.h
#import <Foundation/Foundation.h>
#import <CoreMedia/CoreMedia.h>
#import "KFDemuxerConfig.h"
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, KFMP4DemuxerStatus) {
KFMP4DemuxerStatusUnknown = 0,
KFMP4DemuxerStatusRunning = 1,
KFMP4DemuxerStatusFailed = 2,
KFMP4DemuxerStatusCompleted = 3,
KFMP4DemuxerStatusCancelled = 4,
};
@interface KFMP4Demuxer : NSObject
+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConfig:(KFDemuxerConfig *)config;
@property (nonatomic, strong, readonly) KFDemuxerConfig *config;
@property (nonatomic, copy) void (^errorCallBack)(NSError *error);
@property (nonatomic, assign, readonly) BOOL hasAudioTrack; // 是否包含音频数据。
@property (nonatomic, assign, readonly) BOOL hasVideoTrack; // 是否包含视频数据。
@property (nonatomic, assign, readonly) CGSize videoSize; // 视频大小。
@property (nonatomic, assign, readonly) CMTime duration; // 媒体时长。
@property (nonatomic, assign, readonly) CMVideoCodecType codecType; // 编码类型。
@property (nonatomic, assign, readonly) KFMP4DemuxerStatus demuxerStatus; // 解封装器状态。
@property (nonatomic, assign, readonly) BOOL audioEOF; // 是否音频结束。
@property (nonatomic, assign, readonly) BOOL videoEOF; // 是否视频结束。
@property (nonatomic, assign, readonly) CGAffineTransform preferredTransform; // 图像的变换信息。比如:视频图像旋转。
- (void)startReading:(void (^)(BOOL success, NSError *error))completeHandler; // 开始读取数据解封装。
- (void)cancelReading; // 取消读取。
- (BOOL)hasAudioSampleBuffer; // 是否还有音频数据。
- (CMSampleBufferRef)copyNextAudioSampleBuffer CF_RETURNS_RETAINED; // 拷贝下一份音频采样。
- (BOOL)hasVideoSampleBuffer; // 是否还有视频数据。
- (CMSampleBufferRef)copyNextVideoSampleBuffer CF_RETURNS_RETAINED; // 拷贝下一份视频采样。
@end
NS_ASSUME_NONNULL_END
```
## 2、音频解码模块
接下来,我们来实现一个音频解码模块 `KFAudioDecoder`,在这里输入解封装后的编码数据,输出解码后的数据。
```
KFAudioDecoder.h
#import <Foundation/Foundation.h>
#import <CoreMedia/CoreMedia.h>
NS_ASSUME_NONNULL_BEGIN
@interface KFAudioDecoder : NSObject
@property (nonatomic, copy) void (^sampleBufferOutputCallBack)(CMSampleBufferRef sample); // 解码器数据回调。
@property (nonatomic, copy) void (^errorCallBack)(NSError *error); // 解码器错误回调。
- (void)decodeSampleBuffer:(CMSampleBufferRef)sampleBuffer; // 解码。
@end
NS_ASSUME_NONNULL_END
```
上面是 `KFAudioDecoder` 接口的设计,主要是有音频解码`数据回调`和`错误回调`的接口,另外就是`解码`的接口。
在上面的`解码`接口和`解码器数据回调`接口中,我们使用的是依然 **CMSampleBufferRef**[1] 作为参数或返回值类型。
在`解码`接口中,我们通过 `CMSampleBufferRef` 打包的是解封装后得到的 AAC 编码数据。
在`解码器数据回调`接口中,我们通过 `CMSampleBufferRef` 打包的是对 AAC 解码后得到的音频 PCM 数据。
```
KFAudioDecoder.m
#import "KFAudioDecoder.h"
#import <AudioToolbox/AudioToolbox.h>
// 自定义数据,用于封装音频解码回调中用到的数据。
typedef struct KFAudioUserData {
UInt32 mChannels;
UInt32 mDataSize;
void *mData;
AudioStreamPacketDescription mPacketDesc;
} KFAudioUserData;
@interface KFAudioDecoder () {
UInt8 *_pcmBuffer; // 解码缓冲区。
}
@property (nonatomic, assign) AudioConverterRef audioDecoderInstance; // 音频解码器实例。
@property (nonatomic, assign) CMFormatDescriptionRef pcmFormat; // 音频解码参数。
@property (nonatomic, strong) dispatch_queue_t decoderQueue;
@property (nonatomic, assign) BOOL isError;
@end
@implementation KFAudioDecoder
#pragma mark - Lifecycle
- (instancetype)init {
self = [super init];
if (self) {
_decoderQueue = dispatch_queue_create("com.KeyFrameKit.audioDecoder", DISPATCH_QUEUE_SERIAL);
}
return self;
}
- (void)dealloc {
// 清理解码器。
if (_audioDecoderInstance) {
AudioConverterDispose(_audioDecoderInstance);
_audioDecoderInstance = nil;
}
if (_pcmFormat) {
CFRelease(_pcmFormat);
_pcmFormat = NULL;
}
// 清理缓冲区。
if (_pcmBuffer) {
free(_pcmBuffer);
_pcmBuffer = NULL;
}
}
#pragma mark - Public Method
- (void)decodeSampleBuffer:(CMSampleBufferRef)sampleBuffer {
if (!sampleBuffer || !CMSampleBufferGetDataBuffer(sampleBuffer) || self.isError) {
return;
}
// 异步处理,防止主线程卡顿。
__weak typeof(self) weakSelf = self;
CFRetain(sampleBuffer);
dispatch_async(_decoderQueue, ^{
[weakSelf _decodeSampleBuffer:sampleBuffer];
CFRelease(sampleBuffer);
});
}
#pragma mark - Private Method
- (void)_setupAudioDecoderInstanceWithInputAudioFormat:(AudioStreamBasicDescription)inputFormat error:(NSError **)error{
if (_audioDecoderInstance != nil) {
return;
}
// 1、设置音频解码器输出参数。其中一些参数与输入的音频数据参数一致。
AudioStreamBasicDescription outputFormat = {0};
outputFormat.mSampleRate = inputFormat.mSampleRate; // 输出采样率与输入一致。
outputFormat.mFormatID = kAudioFormatLinearPCM; // 输出的 PCM 格式。
outputFormat.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagsNativeEndian | kAudioFormatFlagIsPacked;
outputFormat.mChannelsPerFrame = (UInt32) inputFormat.mChannelsPerFrame; // 输出声道数与输入一致。
outputFormat.mFramesPerPacket = 1; // 每个包的帧数。对于 PCM 这样的非压缩音频数据,设置为 1。
outputFormat.mBitsPerChannel = 16; // 对于 PCM,表示采样位深。
outputFormat.mBytesPerFrame = outputFormat.mChannelsPerFrame * outputFormat.mBitsPerChannel / 8; // 每帧字节数 (byte = bit / 8)。
outputFormat.mBytesPerPacket = outputFormat.mFramesPerPacket * outputFormat.mBytesPerFrame; // 每个包的字节数。
outputFormat.mReserved = 0; // 对齐方式,0 表示 8 字节对齐。
// 2、基于音频输入和输出参数创建音频解码器。
OSStatus status = AudioConverterNew(&inputFormat, &outputFormat, &_audioDecoderInstance);
if (status != 0) {
*error = [NSError errorWithDomain:NSStringFromClass(self.class) code:status userInfo:nil];
}
// 3、创建编码格式信息 _pcmFormat。
OSStatus result = CMAudioFormatDescriptionCreate(kCFAllocatorDefault, &outputFormat, 0, NULL, 0, NULL, NULL, &_pcmFormat);
if (result != noErr) {
*error = [NSError errorWithDomain:NSStringFromClass(self.class) code:result userInfo:nil];
return;
}
}
- (void)_decodeSampleBuffer:(CMSampleBufferRef)sampleBuffer {
// 1、从输入数据中获取音频格式信息。
CMAudioFormatDescriptionRef audioFormatRef = CMSampleBufferGetFormatDescription(sampleBuffer);
if (!audioFormatRef) {
return;
}
// 获取音频参数信息,AudioStreamBasicDescription 包含了音频的数据格式、声道数、采样位深、采样率等参数。
AudioStreamBasicDescription audioFormat = *CMAudioFormatDescriptionGetStreamBasicDescription(audioFormatRef);
// 2、根据音频参数创建解码器实例。
NSError *error = nil;
// 第一次解码时创建解码器。
if (!_audioDecoderInstance) {
[self _setupAudioDecoderInstanceWithInputAudioFormat:audioFormat error:&error];
if (error) {
[self _callBackError:error];
return;
}
if (!_audioDecoderInstance) {
return;
}
}
// 3、获取输入数据中的 AAC 编码数据。
CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
size_t audioLength;
char *dataPointer = NULL;
CMBlockBufferGetDataPointer(blockBuffer, 0, NULL, &audioLength, &dataPointer);
if (audioLength == 0 || !dataPointer) {
return;
}
// 4、创建解码回调中要用到的自定义数据。
KFAudioUserData userData = {0};
userData.mChannels = (UInt32) audioFormat.mChannelsPerFrame;
userData.mDataSize = (UInt32) audioLength;
userData.mData = (void *) dataPointer; // 绑定 AAC 编码数据。
userData.mPacketDesc.mDataByteSize = (UInt32) audioLength;
userData.mPacketDesc.mStartOffset = 0;
userData.mPacketDesc.mVariableFramesInPacket = 0;
// 5、创建解码输出数据缓冲区内存空间。
// AAC 编码的每个包有 1024 帧。
UInt32 pcmDataPacketSize = 1024;
// 缓冲区长度:pcmDataPacketSize * 2(16 bit 采样深度) * 声道数量。
UInt32 pcmBufferSize = (UInt32) (pcmDataPacketSize * 2 * audioFormat.mChannelsPerFrame);
if (!_pcmBuffer) {
_pcmBuffer = malloc(pcmBufferSize);
}
memset(_pcmBuffer, 0, pcmBufferSize);
// 6、创建解码器接口对应的解码缓冲区 AudioBufferList,绑定缓冲区的内存空间。
AudioBufferList outAudioBufferList = {0};
outAudioBufferList.mNumberBuffers = 1;
outAudioBufferList.mBuffers[0].mNumberChannels = (UInt32) audioFormat.mChannelsPerFrame;
outAudioBufferList.mBuffers[0].mDataByteSize = (UInt32) pcmBufferSize; // 设置解码缓冲区大小。
outAudioBufferList.mBuffers[0].mData = _pcmBuffer; // 绑定缓冲区空间。
// 7、输出数据描述。
AudioStreamPacketDescription outputPacketDesc = {0};
// 9、解码。
OSStatus status = AudioConverterFillComplexBuffer(self.audioDecoderInstance, inputDataProcess, &userData, &pcmDataPacketSize, &outAudioBufferList, &outputPacketDesc);
if (status != noErr) {
[self _callBackError:[NSError errorWithDomain:NSStringFromClass(self.class) code:status userInfo:nil]];
return;
}
if (outAudioBufferList.mBuffers[0].mDataByteSize > 0) {
// 10、获取解码后的 PCM 数据并进行封装。
// 把解码后的 PCM 数据先封装到 CMBlockBuffer 中。
CMBlockBufferRef pcmBlockBuffer;
size_t pcmBlockBufferSize = outAudioBufferList.mBuffers[0].mDataByteSize;
char *pcmBlockBufferDataPointer = malloc(pcmBlockBufferSize);
memcpy(pcmBlockBufferDataPointer, outAudioBufferList.mBuffers[0].mData, pcmBlockBufferSize);
OSStatus status = CMBlockBufferCreateWithMemoryBlock(kCFAllocatorDefault,
pcmBlockBufferDataPointer,
pcmBlockBufferSize,
NULL,
NULL,
0,
pcmBlockBufferSize,
0,
&pcmBlockBuffer);
if (status != noErr) {
return;
}
// 把 PCM 数据所在的 CMBlockBuffer 封装到 CMSampleBuffer 中。
CMSampleBufferRef pcmSampleBuffer = NULL;
CMSampleTimingInfo timingInfo = {CMTimeMake(1, audioFormat.mSampleRate), CMSampleBufferGetPresentationTimeStamp(sampleBuffer), kCMTimeInvalid };
status = CMSampleBufferCreateReady(kCFAllocatorDefault,
pcmBlockBuffer,
_pcmFormat,
pcmDataPacketSize,
1,
&timingInfo,
0,
NULL,
&pcmSampleBuffer);
CFRelease(pcmBlockBuffer);
// 11、回调解码数据。
if (pcmSampleBuffer) {
if (self.sampleBufferOutputCallBack) {
self.sampleBufferOutputCallBack(pcmSampleBuffer);
}
CFRelease(pcmSampleBuffer);
}
}
}
- (void)_callBackError:(NSError*)error {
self.isError = YES;
if (error && self.errorCallBack) {
dispatch_async(dispatch_get_main_queue(), ^{
self.errorCallBack(error);
});
}
}
#pragma mark - Decoder CallBack
static OSStatus inputDataProcess(AudioConverterRef inConverter, UInt32 *ioNumberDataPackets, AudioBufferList *ioData, AudioStreamPacketDescription **outDataPacketDescription, void *inUserData) {
KFAudioUserData *userData = (KFAudioUserData *) inUserData;
if (userData->mDataSize <= 0) {
ioNumberDataPackets = 0;
return -1;
}
// 设置解码输出数据格式信息。
*outDataPacketDescription = &userData->mPacketDesc;
(*outDataPacketDescription)[0].mStartOffset = 0;
(*outDataPacketDescription)[0].mDataByteSize = userData->mDataSize;
(*outDataPacketDescription)[0].mVariableFramesInPacket = 0;
// 将待解码的数据拷贝到解码器的缓冲区的对应位置进行解码。
ioData->mBuffers[0].mData = userData->mData;
ioData->mBuffers[0].mDataByteSize = userData->mDataSize;
ioData->mBuffers[0].mNumberChannels = userData->mChannels;
return noErr;
}
@end
```
上面是 `KFAudioDecoder` 的实现,从代码上可以看到主要有这几个部分:
- 1)创建音频解码实例。第一次调用 `-decodeSampleBuffer:``-_decodeSampleBuffer:` 才会创建音频解码实例。
- - 在 `-_setupAudioDecoderInstanceWithInputAudioFormat:error:` 方法中实现。
- 2)实现音频解码逻辑,并在将数据封装到 `CMSampleBufferRef` 结构中,抛给 KFAudioDecoder 的对外数据回调接口。
- - 在 `-decodeSampleBuffer:``-_decodeSampleBuffer:` 中实现解码流程,其中涉及到待解码缓冲区、解码缓冲区的管理,并最终在 `inputDataProcess(...)` 回调中将待解码的数据拷贝到解码器的缓冲区进行解码,并设置对应的解码数据格式。
- 3)捕捉音频解码过程中的错误,抛给 KFAudioDecoder 的对外错误回调接口。
- - 在 `-_decodeSampleBuffer:` 方法中捕捉错误,在 `-_callBackError:` 方法向外回调。
- 4)清理音频解码器实例、解码缓冲区。
- - 在 `-dealloc` 方法中实现。
更具体细节见上述代码及其注释。
## 3、解封装和解码 MP4 文件中的音频部分存储为 PCM 文件
我们在一个 ViewController 中来实现音频解封装及解码逻辑,并将解码后的数据存储为 PCM 文件。
```
#import "KFAudioDecoderViewController.h"
#import "KFMP4Demuxer.h"
#import "KFAudioDecoder.h"
@interface KFAudioDecoderViewController ()
@property (nonatomic, strong) KFDemuxerConfig *demuxerConfig;
@property (nonatomic, strong) KFMP4Demuxer *demuxer;
@property (nonatomic, strong) KFAudioDecoder *decoder;
@property (nonatomic, strong) NSFileHandle *fileHandle;
@end
@implementation KFAudioDecoderViewController
#pragma mark - Property
- (KFDemuxerConfig *)demuxerConfig {
if (!_demuxerConfig) {
_demuxerConfig = [[KFDemuxerConfig alloc] init];
_demuxerConfig.demuxerType = KFMediaAudio;
NSString *videoPath = [[NSBundle mainBundle] pathForResource:@"input" ofType:@"mp4"];
_demuxerConfig.asset = [AVAsset assetWithURL:[NSURL fileURLWithPath:videoPath]];
}
return _demuxerConfig;
}
- (KFMP4Demuxer *)demuxer {
if (!_demuxer) {
_demuxer = [[KFMP4Demuxer alloc] initWithConfig:self.demuxerConfig];
_demuxer.errorCallBack = ^(NSError *error) {
NSLog(@"KFMP4Demuxer error:%zi %@", error.code, error.localizedDescription);
};
}
return _demuxer;
}
- (KFAudioDecoder *)decoder {
if (!_decoder) {
__weak typeof(self) weakSelf = self;
_decoder = [[KFAudioDecoder alloc] init];
_decoder.errorCallBack = ^(NSError *error) {
NSLog(@"KFAudioDecoder error:%zi %@", error.code, error.localizedDescription);
};
// 解码数据回调。在这里把解码后的音频 PCM 数据存储为文件。
_decoder.sampleBufferOutputCallBack = ^(CMSampleBufferRef sampleBuffer) {
if (sampleBuffer) {
CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
size_t totolLength;
char *dataPointer = NULL;
CMBlockBufferGetDataPointer(blockBuffer, 0, NULL, &totolLength, &dataPointer);
if (totolLength == 0 || !dataPointer) {
return;
}
[weakSelf.fileHandle writeData:[NSData dataWithBytes:dataPointer length:totolLength]];
}
};
}
return _decoder;
}
- (NSFileHandle *)fileHandle {
if (!_fileHandle) {
NSString *videoPath = [[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject] stringByAppendingPathComponent:@"output.pcm"];
[[NSFileManager defaultManager] removeItemAtPath:videoPath error:nil];
[[NSFileManager defaultManager] createFileAtPath:videoPath contents:nil attributes:nil];
_fileHandle = [NSFileHandle fileHandleForWritingAtPath:videoPath];
}
return _fileHandle;
}
#pragma mark - Lifecycle
- (void)viewDidLoad {
[super viewDidLoad];
[self setupUI];
// 完成音频解码后,可以将 App Document 文件夹下面的 output.pcm 文件拷贝到电脑上,使用 ffplay 播放:
// ffplay -ar 44100 -channels 1 -f s16le -i output.pcm
}
- (void)dealloc {
if (_fileHandle) {
[_fileHandle closeFile];
_fileHandle = nil;
}
}
#pragma mark - Setup
- (void)setupUI {
self.edgesForExtendedLayout = UIRectEdgeAll;
self.extendedLayoutIncludesOpaqueBars = YES;
self.title = @"Audio Decoder";
self.view.backgroundColor = [UIColor whiteColor];
// Navigation item.
UIBarButtonItem *startBarButton = [[UIBarButtonItem alloc] initWithTitle:@"Start" style:UIBarButtonItemStylePlain target:self action:@selector(start)];
self.navigationItem.rightBarButtonItems = @[startBarButton];
}
#pragma mark - Action
- (void)start {
__weak typeof(self) weakSelf = self;
NSLog(@"KFMP4Demuxer start");
[self.demuxer startReading:^(BOOL success, NSError * _Nonnull error) {
if (success) {
// Demuxer 启动成功后,就可以从它里面获取解封装后的数据了。
[weakSelf fetchAndDecodeDemuxedData];
} else {
NSLog(@"KFMP4Demuxer error:%zi %@", error.code, error.localizedDescription);
}
}];
}
#pragma mark - Utility
- (void)fetchAndDecodeDemuxedData {
// 异步地从 Demuxer 获取解封装后的 AAC 编码数据,送给解码器进行解码。
__weak typeof(self) weakSelf = self;
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (weakSelf.demuxer.hasAudioSampleBuffer) {
CMSampleBufferRef audioBuffer = [weakSelf.demuxer copyNextAudioSampleBuffer];
if (audioBuffer) {
[weakSelf decodeSampleBuffer:audioBuffer];
CFRelease(audioBuffer);
}
}
if (weakSelf.demuxer.demuxerStatus == KFMP4DemuxerStatusCompleted) {
NSLog(@"KFMP4Demuxer complete");
}
});
}
- (void)decodeSampleBuffer:(CMSampleBufferRef)sampleBuffer {
// 获取解封装后的 AAC 编码裸数据。
CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
size_t totolLength;
char *dataPointer = NULL;
CMBlockBufferGetDataPointer(blockBuffer, 0, NULL, &totolLength, &dataPointer);
if (totolLength == 0 || !dataPointer) {
return;
}
// 目前 AudioDecoder 的解码接口实现的是单包(packet,1 packet 有 1024 帧)解码。而从 Demuxer 获取的一个 CMSampleBuffer 可能包含多个包,所以这里要拆一下包,再送给解码器。
NSLog(@"SampleNum: %ld", CMSampleBufferGetNumSamples(sampleBuffer));
for (NSInteger index = 0; index < CMSampleBufferGetNumSamples(sampleBuffer); index++) {
// 1、获取一个包的数据。
size_t sampleSize = CMSampleBufferGetSampleSize(sampleBuffer, index);
CMSampleTimingInfo timingInfo;
CMSampleBufferGetSampleTimingInfo(sampleBuffer, index, &timingInfo);
char *sampleDataPointer = malloc(sampleSize);
memcpy(sampleDataPointer, dataPointer, sampleSize);
// 2、将数据封装到 CMBlockBuffer 中。
CMBlockBufferRef packetBlockBuffer;
OSStatus status = CMBlockBufferCreateWithMemoryBlock(kCFAllocatorDefault,
sampleDataPointer,
sampleSize,
NULL,
NULL,
0,
sampleSize,
0,
&packetBlockBuffer);
if (status == noErr) {
// 3、将 CMBlockBuffer 封装到 CMSampleBuffer 中。
CMSampleBufferRef frameSampleBuffer = NULL;
const size_t sampleSizeArray[] = {sampleSize};
status = CMSampleBufferCreateReady(kCFAllocatorDefault,
packetBlockBuffer,
CMSampleBufferGetFormatDescription(sampleBuffer),
1,
1,
&timingInfo,
1,
sampleSizeArray,
&frameSampleBuffer);
CFRelease(packetBlockBuffer);
// 4、解码这个包的数据。
if (frameSampleBuffer) {
[self.decoder decodeSampleBuffer:frameSampleBuffer];
CFRelease(frameSampleBuffer);
}
}
dataPointer += sampleSize;
}
}
@end
```
上面是 `KFAudioDecoderViewController` 的实现,其中主要包含这几个部分:
- 1)通过启动音频解封装来驱动整个解封装和解码流程。
- - 在 `-start` 中实现开始动作。
- 2)在解封装模块 `KFMP4Demuxer` 启动成功后,开始读取解封装数据并启动解码。
- - 在 `-fetchAndDecodeDemuxedData` 方法中实现。
- 3)将解封装后的数据拆包,以包为单位封装为 `CMSampleBuffer` 送给解码器解码。
- - 在 `-decodeSampleBuffer:` 方法中实现。
- 4)在解码模块 `KFAudioDecoder` 的数据回调中获取解码后的 PCM 数据存储为文件。
- - 在 `KFAudioDecoder``sampleBufferOutputCallBack` 回调中实现。
## 4、用工具播放 PCM 文件
完成音频解码后,可以将 App Document 文件夹下面的 `output.pcm` 文件拷贝到电脑上,使用 `ffplay` 播放来验证一下音频采集是效果是否符合预期:
```
$ ffplay -ar 44100 -channels 1 -f s16le -i output.pcm
```
注意这里的参数要对齐在工程中输入视频源的`采样率`、`声道数`、`采样位深`。比如我们的 Demo 中输入视频源的声道数是 1,所以上面的声道数需要设置为 1 才能播放正常的声音。
关于播放 PCM 文件的工具,可以参考[《FFmpeg 工具》第 2 节 ffplay 命令行工具](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484831&idx=1&sn=6bab905a5040c46b971bab05f787788b&scene=21#wechat_redirect)和[《可视化音视频分析工具》第 1.1 节 Adobe Audition](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484834&idx=1&sn=5dd9768bfc0d01ca1b036be8dd2f5fa1&scene=21#wechat_redirect)。
## 5、参考资料
[1]CMSampleBufferRef: *https://developer.apple.com/documentation/coremedia/cmsamplebufferref/*
原文链接:https://mp.weixin.qq.com/s/7Db81B9i16cLuq0jS42bmg

@ -0,0 +1,576 @@
# iOS AVDemo(6):音频渲染
iOS/Android 客户端开发同学如果想要开始学习音视频开发,最丝滑的方式是对[音视频基础概念知识](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2140155659944787969#wechat_redirect)有一定了解后,再借助 iOS/Android 平台的音视频能力上手去实践音视频的`采集 → 编码 → 封装 → 解封装 → 解码 → 渲染`过程,并借助[音视频工具](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2216997905264082945#wechat_redirect)来分析和理解对应的音视频数据。
在[音视频工程示例](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2273301900659851268#wechat_redirect)这个栏目,我们将通过拆解`采集 → 编码 → 封装 → 解封装 → 解码 → 渲染`流程并实现 Demo 来向大家介绍如何在 iOS/Android 平台上手音视频开发。
这里是第六篇:**iOS 音频渲染 Demo**。这个 Demo 里包含以下内容:
- 1)实现一个音频解封装模块;
- 2)实现一个音频解码模块;
- 3)实现一个音频渲染模块;
- 4)实现对 MP4 文件中音频部分的解封装和解码逻辑,并将解封装、解码后的数据送给渲染模块播放;
- 5)详尽的代码注释,帮你理解代码逻辑和原理。
## 1、音频解封装模块
在这个 Demo 中,解封装模块 `KFMP4Demuxer` 的实现与 [《iOS 音频解封装 Demo》](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484932&idx=1&sn=04fa6fb220574c0a5d417f4b527c0142&scene=21#wechat_redirect) 中一样,这里就不再重复介绍了,其接口如下:
```
KFMP4Demuxer.h
#import <Foundation/Foundation.h>
#import <CoreMedia/CoreMedia.h>
#import "KFDemuxerConfig.h"
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, KFMP4DemuxerStatus) {
KFMP4DemuxerStatusUnknown = 0,
KFMP4DemuxerStatusRunning = 1,
KFMP4DemuxerStatusFailed = 2,
KFMP4DemuxerStatusCompleted = 3,
KFMP4DemuxerStatusCancelled = 4,
};
@interface KFMP4Demuxer : NSObject
+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConfig:(KFDemuxerConfig *)config;
@property (nonatomic, strong, readonly) KFDemuxerConfig *config;
@property (nonatomic, copy) void (^errorCallBack)(NSError *error);
@property (nonatomic, assign, readonly) BOOL hasAudioTrack; // 是否包含音频数据。
@property (nonatomic, assign, readonly) BOOL hasVideoTrack; // 是否包含视频数据。
@property (nonatomic, assign, readonly) CGSize videoSize; // 视频大小。
@property (nonatomic, assign, readonly) CMTime duration; // 媒体时长。
@property (nonatomic, assign, readonly) CMVideoCodecType codecType; // 编码类型。
@property (nonatomic, assign, readonly) KFMP4DemuxerStatus demuxerStatus; // 解封装器状态。
@property (nonatomic, assign, readonly) BOOL audioEOF; // 是否音频结束。
@property (nonatomic, assign, readonly) BOOL videoEOF; // 是否视频结束。
@property (nonatomic, assign, readonly) CGAffineTransform preferredTransform; // 图像的变换信息。比如:视频图像旋转。
- (void)startReading:(void (^)(BOOL success, NSError *error))completeHandler; // 开始读取数据解封装。
- (void)cancelReading; // 取消读取。
- (BOOL)hasAudioSampleBuffer; // 是否还有音频数据。
- (CMSampleBufferRef)copyNextAudioSampleBuffer CF_RETURNS_RETAINED; // 拷贝下一份音频采样。
- (BOOL)hasVideoSampleBuffer; // 是否还有视频数据。
- (CMSampleBufferRef)copyNextVideoSampleBuffer CF_RETURNS_RETAINED; // 拷贝下一份视频采样。
@end
NS_ASSUME_NONNULL_END
```
## 2、音频解码模块
同样的,解封装模块 `KFAudioDecoder` 的实现与 [《iOS 音频解码 Demo》](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484944&idx=1&sn=63616655888d93557f935bc12088873e&scene=21#wechat_redirect) 中一样,这里就不再重复介绍了,其接口如下:
```
KFAudioDecoder.h
#import <Foundation/Foundation.h>
#import <CoreMedia/CoreMedia.h>
NS_ASSUME_NONNULL_BEGIN
@interface KFAudioDecoder : NSObject
@property (nonatomic, copy) void (^sampleBufferOutputCallBack)(CMSampleBufferRef sample); // 解码器数据回调。
@property (nonatomic, copy) void (^errorCallBack)(NSError *error); // 解码器错误回调。
- (void)decodeSampleBuffer:(CMSampleBufferRef)sampleBuffer; // 解码。
@end
NS_ASSUME_NONNULL_END
```
## 3、音频渲染模块
接下来,我们来实现一个音频渲染模块 `KFAudioRender`,在这里输入解码后的数据进行渲染播放。
```
KFAudioRender.h
#import <Foundation/Foundation.h>
#import <AVFoundation/AVFoundation.h>
@class KFAudioRender;
NS_ASSUME_NONNULL_BEGIN
@interface KFAudioRender : NSObject
+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithChannels:(NSInteger)channels bitDepth:(NSInteger)bitDepth sampleRate:(NSInteger)sampleRate;
@property (nonatomic, copy) void (^audioBufferInputCallBack)(AudioBufferList *audioBufferList); // 音频渲染数据输入回调。
@property (nonatomic, copy) void (^errorCallBack)(NSError *error); // 音频渲染错误回调。
@property (nonatomic, assign, readonly) NSInteger audioChannels; // 声道数。
@property (nonatomic, assign, readonly) NSInteger bitDepth; // 采样位深。
@property (nonatomic, assign, readonly) NSInteger audioSampleRate; // 采样率。
- (void)startPlaying; // 开始渲染。
- (void)stopPlaying; // 结束渲染。
@end
NS_ASSUME_NONNULL_END
```
上面是 `KFAudioRender` 接口的设计,除了`初始化`接口,主要是有音频渲染`数据输入回调`和`错误回调`的接口,另外就是`获取声道数`和`获取采样率`的接口,以及`开始渲染`和`结束渲染`的接口。
这里重点需要看一下音频渲染`数据输入回调`接口,系统的音频渲染单元每次会主动通过回调的方式要数据,我们这里封装的 `KFAudioRender` 则是用`数据输入回调`接口来从外部获取一组待渲染的音频数据送给系统的音频渲染单元。
```
KFAudioRender.m
#import "KFAudioRender.h"
#define OutputBus 0
@interface KFAudioRender ()
@property (nonatomic, assign) AudioComponentInstance audioRenderInstance; // 音频渲染实例。
@property (nonatomic, assign, readwrite) NSInteger audioChannels; // 声道数。
@property (nonatomic, assign, readwrite) NSInteger bitDepth; // 采样位深。
@property (nonatomic, assign, readwrite) NSInteger audioSampleRate; // 采样率。
@property (nonatomic, strong) dispatch_queue_t renderQueue;
@property (nonatomic, assign) BOOL isError;
@end
@implementation KFAudioRender
#pragma mark - Lifecycle
- (instancetype)initWithChannels:(NSInteger)channels bitDepth:(NSInteger)bitDepth sampleRate:(NSInteger)sampleRate {
self = [super init];
if (self) {
_audioChannels = channels;
_bitDepth = bitDepth;
_audioSampleRate = sampleRate;
_renderQueue = dispatch_queue_create("com.KeyFrameKit.audioRender", DISPATCH_QUEUE_SERIAL);
}
return self;
}
- (void)dealloc {
// 清理音频渲染实例。
if (_audioRenderInstance) {
AudioOutputUnitStop(_audioRenderInstance);
AudioUnitUninitialize(_audioRenderInstance);
AudioComponentInstanceDispose(_audioRenderInstance);
_audioRenderInstance = nil;
}
}
#pragma mark - Action
- (void)startPlaying {
__weak typeof(self) weakSelf = self;
dispatch_async(_renderQueue, ^{
if (!weakSelf.audioRenderInstance) {
NSError *error = nil;
// 第一次 startPlaying 时创建音频渲染实例。
[weakSelf _setupAudioRenderInstance:&error];
if (error) {
// 捕捉并回调创建音频渲染实例时的错误。
[weakSelf _callBackError:error];
return;
}
}
// 开始渲染。
OSStatus status = AudioOutputUnitStart(weakSelf.audioRenderInstance);
if (status != noErr) {
// 捕捉并回调开始渲染时的错误。
[weakSelf _callBackError:[NSError errorWithDomain:NSStringFromClass([KFAudioRender class]) code:status userInfo:nil]];
}
});
}
- (void)stopPlaying {
__weak typeof(self) weakSelf = self;
dispatch_async(_renderQueue, ^{
if (weakSelf.audioRenderInstance && !self.isError) {
// 停止渲染。
OSStatus status = AudioOutputUnitStop(weakSelf.audioRenderInstance);
// 捕捉并回调停止渲染时的错误。
if (status != noErr) {
[weakSelf _callBackError:[NSError errorWithDomain:NSStringFromClass([KFAudioRender class]) code:status userInfo:nil]];
}
}
});
}
#pragma mark - Private Method
- (void)_setupAudioRenderInstance:(NSError**)error {
// 1、设置音频组件描述。
AudioComponentDescription audioComponentDescription = {
.componentType = kAudioUnitType_Output,
//.componentSubType = kAudioUnitSubType_VoiceProcessingIO, // 回声消除模式
.componentSubType = kAudioUnitSubType_RemoteIO,
.componentManufacturer = kAudioUnitManufacturer_Apple,
.componentFlags = 0,
.componentFlagsMask = 0
};
// 2、查找符合指定描述的音频组件。
AudioComponent inputComponent = AudioComponentFindNext(NULL, &audioComponentDescription);
// 3、创建音频组件实例。
OSStatus status = AudioComponentInstanceNew(inputComponent, &_audioRenderInstance);
if (status != noErr) {
*error = [NSError errorWithDomain:NSStringFromClass(self.class) code:status userInfo:nil];
return;
}
// 4、设置实例的属性:可读写。0 不可读写,1 可读写。
UInt32 flag = 1;
status = AudioUnitSetProperty(_audioRenderInstance, kAudioOutputUnitProperty_EnableIO, kAudioUnitScope_Output, OutputBus, &flag, sizeof(flag));
if (status != noErr) {
*error = [NSError errorWithDomain:NSStringFromClass(self.class) code:status userInfo:nil];
return;
}
// 5、设置实例的属性:音频参数,如:数据格式、声道数、采样位深、采样率等。
AudioStreamBasicDescription inputFormat = {0};
inputFormat.mFormatID = kAudioFormatLinearPCM; // 原始数据为 PCM,采用声道交错格式。
inputFormat.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagsNativeEndian | kAudioFormatFlagIsPacked;
inputFormat.mChannelsPerFrame = (UInt32) self.audioChannels; // 每帧的声道数。
inputFormat.mFramesPerPacket = 1; // 每个数据包帧数。
inputFormat.mBitsPerChannel = (UInt32) self.bitDepth; // 采样位深。
inputFormat.mBytesPerFrame = inputFormat.mChannelsPerFrame * inputFormat.mBitsPerChannel / 8; // 每帧字节数 (byte = bit / 8)。
inputFormat.mBytesPerPacket = inputFormat.mFramesPerPacket * inputFormat.mBytesPerFrame; // 每个包字节数。
inputFormat.mSampleRate = self.audioSampleRate; // 采样率
status = AudioUnitSetProperty(_audioRenderInstance, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, OutputBus, &inputFormat, sizeof(inputFormat));
if (status != noErr) {
*error = [NSError errorWithDomain:NSStringFromClass(self.class) code:status userInfo:nil];
return;
}
// 6、设置实例的属性:数据回调函数。
AURenderCallbackStruct renderCallbackRef = {
.inputProc = audioRenderCallback,
.inputProcRefCon = (__bridge void *) (self) // 对应回调函数中的 *inRefCon。
};
status = AudioUnitSetProperty(_audioRenderInstance, kAudioUnitProperty_SetRenderCallback, kAudioUnitScope_Global, OutputBus, &renderCallbackRef, sizeof(renderCallbackRef));
if (status != noErr) {
*error = [NSError errorWithDomain:NSStringFromClass(self.class) code:status userInfo:nil];
return;
}
// 7、初始化实例。
status = AudioUnitInitialize(_audioRenderInstance);
if (status != noErr) {
*error = [NSError errorWithDomain:NSStringFromClass(self.class) code:status userInfo:nil];
return;
}
}
- (void)_callBackError:(NSError*)error {
self.isError = YES;
if (self.errorCallBack) {
dispatch_async(dispatch_get_main_queue(), ^{
self.errorCallBack(error);
});
}
}
#pragma mark - Render Callback
static OSStatus audioRenderCallback(void *inRefCon, AudioUnitRenderActionFlags *ioActionFlags, const AudioTimeStamp *inTimeStamp, UInt32 inOutputBusNumber, UInt32 inNumberFrames, AudioBufferList *ioData) {
// 通过音频渲染数据输入回调从外部获取待渲染的数据。
KFAudioRender *audioRender = (__bridge KFAudioRender *) inRefCon;
if (audioRender.audioBufferInputCallBack) {
audioRender.audioBufferInputCallBack(ioData);
}
return noErr;
}
@end
```
上面是 `KFAudioRender` 的实现,从代码上可以看到主要有这几个部分:
- 1)创建音频渲染实例。第一次调用 `-startPlaying` 才会创建音频渲染实例。
- - 在 `-_setupAudioRenderInstance:` 方法中实现。
- 2)处理音频渲染实例的数据回调,并在回调中通过 KFAudioRender 的对外数据输入回调接口向更外层要待渲染的数据。
- - 在 `audioRenderCallback(...)` 方法中实现回调处理逻辑。通过 `audioBufferInputCallBack` 回调接口向更外层要数据。
- 3)实现开始渲染和停止渲染逻辑。
- - 分别在 `-startPlaying``-stopPlaying` 方法中实现。注意,这里是开始和停止操作都是放在串行队列中通过 `dispatch_async` 异步处理的,这里主要是为了防止主线程卡顿。
- 4)捕捉音频渲染开始和停止操作中的错误,抛给 KFAudioRender 的对外错误回调接口。
- - 在 `-startPlaying``-stopPlaying` 方法中捕捉错误,在 `-_callBackError:` 方法向外回调。
- 5)清理音频渲染实例。
- - 在 `-dealloc` 方法中实现。
更具体细节见上述代码及其注释。
## 4、解封装和解码 MP4 文件中的音频部分并渲染播放
我们在一个 ViewController 中来实现从 MP4 文件中解封装和解码音频数据进行渲染播放。
```
KFAudioRenderViewController.m
#import "KFAudioRenderViewController.h"
#import <AVFoundation/AVFoundation.h>
#import "KFAudioRender.h"
#import "KFMP4Demuxer.h"
#import "KFAudioDecoder.h"
#import "KFWeakProxy.h"
#define KFDecoderMaxCache 4096 * 5 // 解码数据缓冲区最大长度。
@interface KFAudioRenderViewController ()
@property (nonatomic, strong) KFDemuxerConfig *demuxerConfig;
@property (nonatomic, strong) KFMP4Demuxer *demuxer;
@property (nonatomic, strong) KFAudioDecoder *decoder;
@property (nonatomic, strong) KFAudioRender *audioRender;
@property (nonatomic, strong) dispatch_semaphore_t semaphore;
@property (nonatomic, strong) NSMutableData *pcmDataCache; // 解码数据缓冲区。
@property (nonatomic, assign) NSInteger pcmDataCacheLength;
@property (nonatomic, strong) CADisplayLink *timer;
@end
@implementation KFAudioRenderViewController
#pragma mark - Property
- (KFDemuxerConfig *)demuxerConfig {
if (!_demuxerConfig) {
_demuxerConfig = [[KFDemuxerConfig alloc] init];
_demuxerConfig.demuxerType = KFMediaAudio;
NSString *videoPath = [[NSBundle mainBundle] pathForResource:@"input" ofType:@"mp4"];
_demuxerConfig.asset = [AVAsset assetWithURL:[NSURL fileURLWithPath:videoPath]];
}
return _demuxerConfig;
}
- (KFMP4Demuxer *)demuxer {
if (!_demuxer) {
_demuxer = [[KFMP4Demuxer alloc] initWithConfig:self.demuxerConfig];
_demuxer.errorCallBack = ^(NSError *error) {
NSLog(@"KFMP4Demuxer error:%zi %@", error.code, error.localizedDescription);
};
}
return _demuxer;
}
- (KFAudioDecoder *)decoder {
if (!_decoder) {
__weak typeof(self) weakSelf = self;
_decoder = [[KFAudioDecoder alloc] init];
_decoder.errorCallBack = ^(NSError *error) {
NSLog(@"KFAudioDecoder error:%zi %@", error.code, error.localizedDescription);
};
// 解码数据回调。在这里把解码后的音频 PCM 数据缓冲起来等待渲染。
_decoder.sampleBufferOutputCallBack = ^(CMSampleBufferRef sampleBuffer) {
if (sampleBuffer) {
CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
size_t totolLength;
char *dataPointer = NULL;
CMBlockBufferGetDataPointer(blockBuffer, 0, NULL, &totolLength, &dataPointer);
if (totolLength == 0 || !dataPointer) {
return;
}
dispatch_semaphore_wait(weakSelf.semaphore, DISPATCH_TIME_FOREVER);
[weakSelf.pcmDataCache appendData:[NSData dataWithBytes:dataPointer length:totolLength]];
weakSelf.pcmDataCacheLength += totolLength;
dispatch_semaphore_signal(weakSelf.semaphore);
}
};
}
return _decoder;
}
- (KFAudioRender *)audioRender {
if (!_audioRender) {
__weak typeof(self) weakSelf = self;
// 这里设置的音频声道数、采样位深、采样率需要跟输入源的音频参数一致。
_audioRender = [[KFAudioRender alloc] initWithChannels:1 bitDepth:16 sampleRate:44100];
_audioRender.errorCallBack = ^(NSError* error) {
NSLog(@"KFAudioRender error:%zi %@", error.code, error.localizedDescription);
};
// 渲染输入数据回调。在这里把缓冲区的数据交给系统音频渲染单元渲染。
_audioRender.audioBufferInputCallBack = ^(AudioBufferList * _Nonnull audioBufferList) {
if (weakSelf.pcmDataCacheLength < audioBufferList->mBuffers[0].mDataByteSize) {
memset(audioBufferList->mBuffers[0].mData, 0, audioBufferList->mBuffers[0].mDataByteSize);
} else {
dispatch_semaphore_wait(weakSelf.semaphore, DISPATCH_TIME_FOREVER);
memcpy(audioBufferList->mBuffers[0].mData, weakSelf.pcmDataCache.bytes, audioBufferList->mBuffers[0].mDataByteSize);
[weakSelf.pcmDataCache replaceBytesInRange:NSMakeRange(0, audioBufferList->mBuffers[0].mDataByteSize) withBytes:NULL length:0];
weakSelf.pcmDataCacheLength -= audioBufferList->mBuffers[0].mDataByteSize;
dispatch_semaphore_signal(weakSelf.semaphore);
}
};
}
return _audioRender;
}
#pragma mark - Lifecycle
- (void)viewDidLoad {
[super viewDidLoad];
_semaphore = dispatch_semaphore_create(1);
_pcmDataCache = [[NSMutableData alloc] init];
[self setupAudioSession];
[self setupUI];
// 通过一个 timer 来保证持续从文件中解封装和解码一定量的数据。
_timer = [CADisplayLink displayLinkWithTarget:[KFWeakProxy proxyWithTarget:self] selector:@selector(timerCallBack:)];
[_timer addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
[_timer setPaused:NO];
[self.demuxer startReading:^(BOOL success, NSError * _Nonnull error) {
NSLog(@"KFMP4Demuxer start:%d", success);
}];
}
- (void)dealloc {
}
#pragma mark - Setup
- (void)setupUI {
self.edgesForExtendedLayout = UIRectEdgeAll;
self.extendedLayoutIncludesOpaqueBars = YES;
self.title = @"Audio Render";
self.view.backgroundColor = [UIColor whiteColor];
// Navigation item.
UIBarButtonItem *startRenderBarButton = [[UIBarButtonItem alloc] initWithTitle:@"Start" style:UIBarButtonItemStylePlain target:self action:@selector(startRender)];
UIBarButtonItem *stopRenderBarButton = [[UIBarButtonItem alloc] initWithTitle:@"Stop" style:UIBarButtonItemStylePlain target:self action:@selector(stopRender)];
self.navigationItem.rightBarButtonItems = @[startRenderBarButton, stopRenderBarButton];
}
#pragma mark - Action
- (void)startRender {
[self.audioRender startPlaying];
}
- (void)stopRender {
[self.audioRender stopPlaying];
}
#pragma mark - Utility
- (void)setupAudioSession {
// 1、获取音频会话实例。
AVAudioSession *session = [AVAudioSession sharedInstance];
// 2、设置分类。
NSError *error = nil;
[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:&error];
if (error) {
NSLog(@"AVAudioSession setCategory error");
}
// 3、激活会话。
[session setActive:YES error:&error];
if (error) {
NSLog(@"AVAudioSession setActive error");
}
}
- (void)timerCallBack:(CADisplayLink *)link {
// 定时从文件中解封装和解码一定量(不超过 KFDecoderMaxCache)的数据。
if (self.pcmDataCacheLength < KFDecoderMaxCache && self.demuxer.demuxerStatus == KFMP4DemuxerStatusRunning && self.demuxer.hasAudioSampleBuffer) {
CMSampleBufferRef audioBuffer = [self.demuxer copyNextAudioSampleBuffer];
if (audioBuffer) {
[self decodeSampleBuffer:audioBuffer];
CFRelease(audioBuffer);
}
}
}
- (void)decodeSampleBuffer:(CMSampleBufferRef)sampleBuffer {
// 获取解封装后的 AAC 编码裸数据。
CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
size_t totolLength;
char *dataPointer = NULL;
CMBlockBufferGetDataPointer(blockBuffer, 0, NULL, &totolLength, &dataPointer);
if (totolLength == 0 || !dataPointer) {
return;
}
// 目前 AudioDecoder 的解码接口实现的是单包(packet,1 packet 有 1024 帧)解码。而从 Demuxer 获取的一个 CMSampleBuffer 可能包含多个包,所以这里要拆一下包,再送给解码器。
NSLog(@"SampleNum: %ld", CMSampleBufferGetNumSamples(sampleBuffer));
for (NSInteger index = 0; index < CMSampleBufferGetNumSamples(sampleBuffer); index++) {
// 1、获取一个包的数据。
size_t sampleSize = CMSampleBufferGetSampleSize(sampleBuffer, index);
CMSampleTimingInfo timingInfo;
CMSampleBufferGetSampleTimingInfo(sampleBuffer, index, &timingInfo);
char *sampleDataPointer = malloc(sampleSize);
memcpy(sampleDataPointer, dataPointer, sampleSize);
// 2、将数据封装到 CMBlockBuffer 中。
CMBlockBufferRef packetBlockBuffer;
OSStatus status = CMBlockBufferCreateWithMemoryBlock(kCFAllocatorDefault,
sampleDataPointer,
sampleSize,
NULL,
NULL,
0,
sampleSize,
0,
&packetBlockBuffer);
if (status == noErr) {
// 3、将 CMBlockBuffer 封装到 CMSampleBuffer 中。
CMSampleBufferRef packetSampleBuffer = NULL;
const size_t sampleSizeArray[] = {sampleSize};
status = CMSampleBufferCreateReady(kCFAllocatorDefault,
packetBlockBuffer,
CMSampleBufferGetFormatDescription(sampleBuffer),
1,
1,
&timingInfo,
1,
sampleSizeArray,
&packetSampleBuffer);
CFRelease(packetBlockBuffer);
// 4、解码这个包的数据。
if (packetSampleBuffer) {
[self.decoder decodeSampleBuffer:packetSampleBuffer];
CFRelease(packetSampleBuffer);
}
}
dataPointer += sampleSize;
}
}
@end
```
上面是 `KFAudioRenderViewController` 的实现,其中主要包含这几个部分:
- 1)在页面加载完成后就启动解封装和解码模块,并通过一个 timer 来驱动解封装器和解码器。
- - 在 `-viewDidLoad` 中实现。
- 2)定时从文件中解封装一定量(不超过 KFDecoderMaxCache)的数据送给解码器。
- - 在 `-timerCallBack:` 方法中实现。
- 3)解封装后,需要将数据拆包,以包为单位封装为 `CMSampleBuffer` 送给解码器解码。
- - 在 `-decodeSampleBuffer:` 方法中实现。
- 4)在解码模块 `KFAudioDecoder` 的数据回调中获取解码后的 PCM 数据缓冲起来等待渲染。
- - 在 `KFAudioDecoder``sampleBufferOutputCallBack` 回调中实现。
- 5)在渲染模块 `KFAudioRender` 的输入数据回调中把缓冲区的数据交给系统音频渲染单元渲染。
- - 在 `KFAudioRender``audioBufferInputCallBack` 回调中实现。
更具体细节见上述代码及其注释。
原文链接:https://mp.weixin.qq.com/s/xrt277Ia1OFP_XtwK1qlQg

@ -0,0 +1,697 @@
# iOS AVDemo(7):视频采集
iOS/Android 客户端开发同学如果想要开始学习音视频开发,最丝滑的方式是对[音视频基础概念知识](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2140155659944787969#wechat_redirect)有一定了解后,再借助 iOS/Android 平台的音视频能力上手去实践音视频的`采集 → 编码 → 封装 → 解封装 → 解码 → 渲染`过程,并借助[音视频工具](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2216997905264082945#wechat_redirect)来分析和理解对应的音视频数据。
在[音视频工程示例](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2273301900659851268#wechat_redirect)这个栏目,我们将通过拆解`采集 → 编码 → 封装 → 解封装 → 解码 → 渲染`流程并实现 Demo 来向大家介绍如何在 iOS/Android 平台上手音视频开发。
这里是第七篇:**iOS 视频采集 Demo**。这个 Demo 里包含以下内容:
- 1)实现一个视频采集模块;
- 2)实现视频采集逻辑并将采集的视频图像渲染进行预览,同时支持将数据转换为图片存储到相册;
- 3)详尽的代码注释,帮你理解代码逻辑和原理。
## 1、视频采集模块
首先,实现一个 `KFVideoCaptureConfig` 类用于定义视频采集参数的配置。
```
KFVideoCaptureConfig.h
#import <Foundation/Foundation.h>
#import <AVFoundation/AVFoundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, KFVideoCaptureMirrorType) {
KFVideoCaptureMirrorNone = 0,
KFVideoCaptureMirrorFront = 1 << 0,
KFVideoCaptureMirrorBack = 1 << 1,
KFVideoCaptureMirrorAll = (KFVideoCaptureMirrorFront | KFVideoCaptureMirrorBack),
};
@interface KFVideoCaptureConfig : NSObject
@property (nonatomic, copy) AVCaptureSessionPreset preset; // 视频采集参数,比如分辨率等,与画质相关。
@property (nonatomic, assign) AVCaptureDevicePosition position; // 摄像头位置,前置/后置摄像头。
@property (nonatomic, assign) AVCaptureVideoOrientation orientation; // 视频画面方向。
@property (nonatomic, assign) NSInteger fps; // 视频帧率。
@property (nonatomic, assign) OSType pixelFormatType; // 颜色空间格式。
@property (nonatomic, assign) KFVideoCaptureMirrorType mirrorType; // 镜像类型。
@end
NS_ASSUME_NONNULL_END
```
这里的参数包括了:分辨率、摄像头位置、画面方向、帧率、颜色空间格式、镜像类型这几个参数。
其中`画面方向`是指采集的视频画面是可以带方向的,包括:`Portrait`、`PortraitUpsideDown`、`LandscapeRight`、`LandscapeLeft` 这几种。
`颜色空间格式`对应 RGB、YCbCr 这些概念,具体来讲,一般我们采集图像用于后续的编码时,这里设置 `kCVPixelFormatType_420YpCbCr8BiPlanarFullRange` 即可;如果想支持 HDR 时(iPhone12 及之后设备才支持),这里设置 `kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange`。在我们这个 Demo 中,我们想要将采集的图像数据直接转换并存储为图片,所以我们会设置采集的颜色空间格式为 `kCVPixelFormatType_32BGRA`,这样将更方便将 CMSampleBuffer 转换为 UIImage。后面你会看到这个逻辑。
`镜像类型`表示采集的画面是否左右镜像,这个在直播时,主播经常需要考虑是否对自己的画面进行镜像,从而决定主播和观众的所见画面是否在『左右』概念的理解上保持一致。
其他的几个参数大家应该从字面上就能理解,就不做过多解释了。
```
KFVideoCaptureConfig.m
#import "KFVideoCaptureConfig.h"
@implementation KFVideoCaptureConfig
- (instancetype)init {
self = [super init];
if (self) {
_preset = AVCaptureSessionPreset1920x1080;
_position = AVCaptureDevicePositionFront;
_orientation = AVCaptureVideoOrientationPortrait;
_fps = 30;
_mirrorType = KFVideoCaptureMirrorFront;
// 设置颜色空间格式,这里要注意了:
// 1、一般我们采集图像用于后续的编码时,这里设置 kCVPixelFormatType_420YpCbCr8BiPlanarFullRange 即可。
// 2、如果想支持 HDR 时(iPhone12 及之后设备才支持),这里设置为:kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange。
_pixelFormatType = kCVPixelFormatType_420YpCbCr8BiPlanarFullRange;
}
return self;
}
@end
```
上面我们在 `KFVideoCaptureConfig` 的初始化方法里提供了一些默认值。
接下来,我们实现一个 `KFVideoCapture` 类来实现视频采集。
```
KFVideoCapture.h
#import <Foundation/Foundation.h>
#import "KFVideoCaptureConfig.h"
NS_ASSUME_NONNULL_BEGIN
@interface KFVideoCapture : NSObject
+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConfig:(KFVideoCaptureConfig *)config;
@property (nonatomic, strong, readonly) KFVideoCaptureConfig *config;
@property (nonatomic, strong, readonly) AVCaptureVideoPreviewLayer *previewLayer; // 视频预览渲染 layer。
@property (nonatomic, copy) void (^sampleBufferOutputCallBack)(CMSampleBufferRef sample); // 视频采集数据回调。
@property (nonatomic, copy) void (^sessionErrorCallBack)(NSError *error); // 视频采集会话错误回调。
@property (nonatomic, copy) void (^sessionInitSuccessCallBack)(void); // 视频采集会话初始化成功回调。
- (void)startRunning; // 开始采集。
- (void)stopRunning; // 停止采集。
- (void)changeDevicePosition:(AVCaptureDevicePosition)position; // 切换摄像头。
@end
NS_ASSUME_NONNULL_END
```
上面是 `KFVideoCapture` 的接口设计,可以看到这些接口类似音频采集器的接口设计,除了`初始化方法`,主要是有`获取视频配置`以及视频采集`数据回调`和`错误回调`的接口,另外就是`开始采集`和`停止采集`的接口。
有一些不同的是,这里还提供了`初始化成功回调`、`视频预览渲染 Layer`、以及`切换摄像头`的接口,这个主要是因为视频采集一般会实现所见即所得,能让用户看到实时采集的画面,这样就需要在初始化成功后让业务层感知到来做一些 UI 布局,并通过预览渲染的 Layer 来展示采集的画面。`切换摄像头`的接口则主要是对应了手机设备常见的前置、后置等多摄像头的能力。
在上面的音频采集`数据回调`接口中,我们依然使用了 **CMSampleBufferRef**[1],可见这个数据结构的通用性和重要性。
```
KFVideoCapture.m
#import "KFVideoCapture.h"
#import <UIKit/UIKit.h>
@interface KFVideoCapture () <AVCaptureVideoDataOutputSampleBufferDelegate>
@property (nonatomic, strong, readwrite) KFVideoCaptureConfig *config;
@property (nonatomic, strong, readonly) AVCaptureDevice *captureDevice; // 视频采集设备。
@property (nonatomic, strong) AVCaptureDeviceInput *backDeviceInput; // 后置摄像头采集输入。
@property (nonatomic, strong) AVCaptureDeviceInput *frontDeviceInput; // 前置摄像头采集输入。
@property (nonatomic, strong) AVCaptureVideoDataOutput *videoOutput; // 视频采集输出。
@property (nonatomic, strong) AVCaptureSession *captureSession; // 视频采集会话。
@property (nonatomic, strong, readwrite) AVCaptureVideoPreviewLayer *previewLayer; // 视频预览渲染 layer。
@property (nonatomic, assign, readonly) CMVideoDimensions sessionPresetSize; // 视频采集分辨率。
@property (nonatomic, strong) dispatch_queue_t captureQueue;
@end
@implementation KFVideoCapture
#pragma mark - Property
- (AVCaptureDevice *)backCamera {
return [self cameraWithPosition:AVCaptureDevicePositionBack];
}
- (AVCaptureDeviceInput *)backDeviceInput {
if (!_backDeviceInput) {
_backDeviceInput = [[AVCaptureDeviceInput alloc] initWithDevice:[self backCamera] error:nil];
}
return _backDeviceInput;
}
- (AVCaptureDevice *)frontCamera {
return [self cameraWithPosition:AVCaptureDevicePositionFront];
}
- (AVCaptureDeviceInput *)frontDeviceInput {
if (!_frontDeviceInput) {
_frontDeviceInput = [[AVCaptureDeviceInput alloc] initWithDevice:[self frontCamera] error:nil];
}
return _frontDeviceInput;
}
- (AVCaptureVideoDataOutput *)videoOutput {
if (!_videoOutput) {
_videoOutput = [[AVCaptureVideoDataOutput alloc] init];
[_videoOutput setSampleBufferDelegate:self queue:self.captureQueue]; // 设置返回采集数据的代理和回调。
_videoOutput.videoSettings = @{(id)kCVPixelBufferPixelFormatTypeKey: @(_config.pixelFormatType)};
_videoOutput.alwaysDiscardsLateVideoFrames = YES; // YES 表示:采集的下一帧到来前,如果有还未处理完的帧,丢掉。
}
return _videoOutput;
}
- (AVCaptureSession *)captureSession {
if (!_captureSession) {
AVCaptureDeviceInput *deviceInput = self.config.position == AVCaptureDevicePositionBack ? self.backDeviceInput : self.frontDeviceInput;
if (!deviceInput) {
return nil;
}
// 1、初始化采集会话。
_captureSession = [[AVCaptureSession alloc] init];
// 2、添加采集输入。
for (AVCaptureSessionPreset selectPreset in [self sessionPresetList]) {
if ([_captureSession canSetSessionPreset:selectPreset]) {
[_captureSession setSessionPreset:selectPreset];
if ([_captureSession canAddInput:deviceInput]) {
[_captureSession addInput:deviceInput];
break;
}
}
}
// 3、添加采集输出。
if ([_captureSession canAddOutput:self.videoOutput]) {
[_captureSession addOutput:self.videoOutput];
}
// 4、更新画面方向。
[self _updateOrientation];
// 5、更新画面镜像。
[self _updateMirror];
// 6、更新采集实时帧率。
[self.captureDevice lockForConfiguration:nil];
[self _updateActiveFrameDuration];
[self.captureDevice unlockForConfiguration];
// 7、回报成功。
if (self.sessionInitSuccessCallBack) {
self.sessionInitSuccessCallBack();
}
}
return _captureSession;
}
- (AVCaptureVideoPreviewLayer *)previewLayer {
if (!_captureSession) {
return nil;
}
if (!_previewLayer) {
// 初始化预览渲染 layer。这里就直接用系统提供的 API 来渲染。
_previewLayer = [[AVCaptureVideoPreviewLayer alloc] initWithSession:_captureSession];
[_previewLayer setVideoGravity:AVLayerVideoGravityResizeAspectFill];
}
return _previewLayer;
}
- (AVCaptureDevice *)captureDevice {
// 视频采集设备。
return (self.config.position == AVCaptureDevicePositionBack) ? [self backCamera] : [self frontCamera];
}
- (CMVideoDimensions)sessionPresetSize {
// 视频采集分辨率。
return CMVideoFormatDescriptionGetDimensions([self captureDevice].activeFormat.formatDescription);
}
#pragma mark - LifeCycle
- (instancetype)initWithConfig:(KFVideoCaptureConfig *)config {
self = [super init];
if (self) {
_config = config;
_captureQueue = dispatch_queue_create("com.KeyFrameKit.videoCapture", DISPATCH_QUEUE_SERIAL);
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(sessionRuntimeError:) name:AVCaptureSessionRuntimeErrorNotification object:nil];
}
return self;
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - Public Method
- (void)startRunning {
typeof(self) __weak weakSelf = self;
dispatch_async(_captureQueue, ^{
[weakSelf _startRunning];
});
}
- (void)stopRunning {
typeof(self) __weak weakSelf = self;
dispatch_async(_captureQueue, ^{
[weakSelf _stopRunning];
});
}
- (void)changeDevicePosition:(AVCaptureDevicePosition)position {
typeof(self) __weak weakSelf = self;
dispatch_async(_captureQueue, ^{
[weakSelf _updateDeveicePosition:position];
});
}
#pragma mark - Private Method
- (void)_startRunning {
AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
if (status == AVAuthorizationStatusAuthorized) {
if (!self.captureSession.isRunning) {
[self.captureSession startRunning];
}
} else {
NSLog(@"没有相机使用权限");
}
}
- (void)_stopRunning {
if (_captureSession && _captureSession.isRunning) {
[_captureSession stopRunning];
}
}
- (void)_updateDeveicePosition:(AVCaptureDevicePosition)position {
// 切换采集的摄像头。
if (position == self.config.position || !_captureSession.isRunning) {
return;
}
// 1、切换采集输入。
AVCaptureDeviceInput *curInput = self.config.position == AVCaptureDevicePositionBack ? self.backDeviceInput : self.frontDeviceInput;
AVCaptureDeviceInput *addInput = self.config.position == AVCaptureDevicePositionBack ? self.frontDeviceInput : self.backDeviceInput;
if (!curInput || !addInput) {
return;
}
[self.captureSession removeInput:curInput];
for (AVCaptureSessionPreset selectPreset in [self sessionPresetList]) {
if ([_captureSession canSetSessionPreset:selectPreset]) {
[_captureSession setSessionPreset:selectPreset];
if ([_captureSession canAddInput:addInput]) {
[_captureSession addInput:addInput];
self.config.position = position;
break;
}
}
}
// 2、更新画面方向。
[self _updateOrientation];
// 3、更新画面镜像。
[self _updateMirror];
// 4、更新采集实时帧率。
[self.captureDevice lockForConfiguration:nil];
[self _updateActiveFrameDuration];
[self.captureDevice unlockForConfiguration];
}
- (void)_updateOrientation {
// 更新画面方向。
AVCaptureConnection *connection = [self.videoOutput connectionWithMediaType:AVMediaTypeVideo]; // AVCaptureConnection 用于把输入和输出连接起来。
if ([connection isVideoOrientationSupported] && connection.videoOrientation != self.config.orientation) {
connection.videoOrientation = self.config.orientation;
}
}
- (void)_updateMirror {
// 更新画面镜像。
AVCaptureConnection *connection = [self.videoOutput connectionWithMediaType:AVMediaTypeVideo];
if ([connection isVideoMirroringSupported]) {
if ((self.config.mirrorType & KFVideoCaptureMirrorFront) && self.config.position == AVCaptureDevicePositionFront) {
connection.videoMirrored = YES;
} else if ((self.config.mirrorType & KFVideoCaptureMirrorBack) && self.config.position == AVCaptureDevicePositionBack) {
connection.videoMirrored = YES;
} else {
connection.videoMirrored = NO;
}
}
}
- (BOOL)_updateActiveFrameDuration {
// 更新采集实时帧率。
// 1、帧率换算成帧间隔时长。
CMTime frameDuration = CMTimeMake(1, (int32_t) self.config.fps);
// 2、设置帧率大于 30 时,找到满足该帧率及其他参数,并且当前设备支持的 AVCaptureDeviceFormat。
if (self.config.fps > 30) {
for (AVCaptureDeviceFormat *vFormat in [self.captureDevice formats]) {
CMFormatDescriptionRef description = vFormat.formatDescription;
CMVideoDimensions dims = CMVideoFormatDescriptionGetDimensions(description);
float maxRate = ((AVFrameRateRange *) [vFormat.videoSupportedFrameRateRanges objectAtIndex:0]).maxFrameRate;
if (maxRate >= self.config.fps && CMFormatDescriptionGetMediaSubType(description) == self.config.pixelFormatType && self.sessionPresetSize.width * self.sessionPresetSize.height == dims.width * dims.height) {
self.captureDevice.activeFormat = vFormat;
break;
}
}
}
// 3、检查设置的帧率是否在当前设备的 activeFormat 支持的最低和最高帧率之间。如果是,就设置帧率。
__block BOOL support = NO;
[self.captureDevice.activeFormat.videoSupportedFrameRateRanges enumerateObjectsUsingBlock:^(AVFrameRateRange * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
if (CMTimeCompare(frameDuration, obj.minFrameDuration) >= 0 &&
CMTimeCompare(frameDuration, obj.maxFrameDuration) <= 0) {
support = YES;
*stop = YES;
}
}];
if (support) {
[self.captureDevice setActiveVideoMinFrameDuration:frameDuration];
[self.captureDevice setActiveVideoMaxFrameDuration:frameDuration];
return YES;
}
return NO;
}
#pragma mark - NSNotification
- (void)sessionRuntimeError:(NSNotification *)notification {
if (self.sessionErrorCallBack) {
self.sessionErrorCallBack(notification.userInfo[AVCaptureSessionErrorKey]);
}
}
#pragma mark - Utility
- (AVCaptureDevice *)cameraWithPosition:(AVCaptureDevicePosition)position {
// 从当前手机寻找符合需要的采集设备。
NSArray *devices = nil;
NSString *version = [UIDevice currentDevice].systemVersion;
if (version.doubleValue >= 10.0) {
AVCaptureDeviceDiscoverySession *deviceDiscoverySession = [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:@[AVCaptureDeviceTypeBuiltInWideAngleCamera] mediaType:AVMediaTypeVideo position:position];
devices = deviceDiscoverySession.devices;
} else {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
devices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo];
#pragma GCC diagnostic pop
}
for (AVCaptureDevice *device in devices) {
if ([device position] == position) {
return device;
}
}
return nil;
}
- (NSArray *)sessionPresetList {
return @[self.config.preset, AVCaptureSessionPreset3840x2160, AVCaptureSessionPreset1920x1080, AVCaptureSessionPreset1280x720, AVCaptureSessionPresetLow];
}
#pragma mark - AVCaptureVideoDataOutputSampleBufferDelegate
- (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
// 向外回调数据。
if (output == self.videoOutput) {
if (self.sampleBufferOutputCallBack) {
self.sampleBufferOutputCallBack(sampleBuffer);
}
}
}
@end
```
上面是 `KFVideoCapture` 的实现,结合下面这两张图可以让我们更好地理解这些代码:
![图片](https://mmbiz.qpic.cn/mmbiz_png/gUnqKPeSueia18FN4ruiaBg7SCSGYib5COvp4hpQicLHFyc6g5skO9SqLuSsf9PB5hWpgzWQzna9adUm4ibUv7DcKnA/640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1)
![图片](data:image/svg+xml,<%3Fxml version='1.0' encoding='UTF-8'%3F><svg width='1px' height='1px' viewBox='0 0 1 1' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'><title></title><g stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' fill-opacity='0'><g transform='translate(-249.000000, -126.000000)' fill='%23FFFFFF'><rect x='249' y='126' width='1' height='1'></rect></g></g></svg>)AVCaptureSession 配置多组输入输出
![图片](https://mmbiz.qpic.cn/mmbiz_png/gUnqKPeSueia18FN4ruiaBg7SCSGYib5COv5wqXuYibj9TdBibmW6JQgczYsAR4KzNaiaGa2fOv1sGFyq4jt98na8F7g/640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1)AVCaptureConnection 连接单或多输入到单输出
可以看到在实现采集时,我们是用 `AVCaptureSession` 来串联采集设备作为输入,其他输出对象作为输出。我们这个 Demo 里的一个输出对象就是 `AVCaptureVideoPreviewLayer`,用它来接收输出的数据并渲染。此外,还可以使用 `AVCaptureConnection` 来连接一个或多个输入到一个输出。
从代码上可以看到主要有这几个部分:
- 1)创建采集设备 `AVCaptureDevice`
- - 在 `-captureDevice` 中实现。
- 由于我们这里的采集模块支持前置和后置摄像头,所以这里的采集设备是根据当前选择的摄像头位置动态指定的。分别对应 `-backCamera``-frontCamera`
- 2)基于采集设备,创建对应的采集输入 `AVCaptureDeviceInput`
- - 由于支持前置和后置摄像头切换,所以这里我们有两个采集输入对象,分别绑定前置和后置摄像头。对应实现在 `-backDeviceInput``-frontDeviceInput`
- 3)创建采集视频数据输出 `AVCaptureVideoDataOutput`
- - 在 `-videoOutput` 中实现。
- 4)创建采集会话 `AVCaptureSession`,绑定上面创建的采集输入和视频数据输出。
- - 在 `-captureSession` 中实现。
- 5)创建采集画面预览渲染层 `AVCaptureVideoPreviewLayer`,将它绑定到上面创建的采集会话上。
- - 在 `-previewLayer` 中实现。
- 该 layer 可以被外层获取用于 UI 布局和展示。
- 6)基于采集会话的能力封装开始采集和停止采集的对外接口。
- - 分别在 `-startRunning``-stopRunning` 方法中实现。注意,这里是开始和停止操作都是放在串行队列中通过 `dispatch_async` 异步处理的,这里主要是为了防止主线程卡顿。
- 7)实现切换摄像头的功能。
- - 在 `-changeDevicePosition:``-_updateDeveicePosition:` 方法中实现。注意,这里同样是异步处理。
- 8)实现采集初始化成功回调、数据回调、采集会话错误回调等对外接口。
- - 采集初始化成功回调:在 `-captureSession` 中初始化采集会话成功后,向外层回调。
- 数据回调:在 `AVCaptureVideoDataOutputSampleBufferDelegate` 的回调接口 `-captureOutput:didOutputSampleBuffer:fromConnection:` 中接收采集数据并回调给外层。
- 采集会话错误回调:在 `-sessionRuntimeError:` 中监听 `AVCaptureSessionRuntimeErrorNotification` 通知并向外层回调错误。
更具体细节见上述代码及其注释。
## 2、采集视频并实时展示或截图
我们在一个 ViewController 中来实现视频采集并实时预览的逻辑,也提供了对采集的视频数据截图保存到相册的功能。
```
KFVideoCaptureViewController.m
#import "KFVideoCaptureViewController.h"
#import "KFVideoCapture.h"
#import <Photos/Photos.h>
@interface KFVideoCaptureViewController ()
@property (nonatomic, strong) KFVideoCaptureConfig *videoCaptureConfig;
@property (nonatomic, strong) KFVideoCapture *videoCapture;
@property (nonatomic, assign) int shotCount;
@end
@implementation KFVideoCaptureViewController
#pragma mark - Property
- (KFVideoCaptureConfig *)videoCaptureConfig {
if (!_videoCaptureConfig) {
_videoCaptureConfig = [[KFVideoCaptureConfig alloc] init];
// 由于我们的想要从采集的图像数据里直接转换并存储图片,所以我们这里设置采集处理的颜色空间格式为 32bit BGRA,这样方便将 CMSampleBuffer 转换为 UIImage。
_videoCaptureConfig.pixelFormatType = kCVPixelFormatType_32BGRA;
}
return _videoCaptureConfig;
}
- (KFVideoCapture *)videoCapture {
if (!_videoCapture) {
_videoCapture = [[KFVideoCapture alloc] initWithConfig:self.videoCaptureConfig];
__weak typeof(self) weakSelf = self;
_videoCapture.sessionInitSuccessCallBack = ^() {
dispatch_async(dispatch_get_main_queue(), ^{
[weakSelf.view.layer addSublayer:weakSelf.videoCapture.previewLayer];
weakSelf.videoCapture.previewLayer.frame = weakSelf.view.bounds;
});
};
_videoCapture.sampleBufferOutputCallBack = ^(CMSampleBufferRef sample) {
if (weakSelf.shotCount > 0) {
weakSelf.shotCount--;
[weakSelf saveSampleBuffer:sample];
}
};
_videoCapture.sessionErrorCallBack = ^(NSError* error) {
NSLog(@"KFVideoCapture Error:%zi %@", error.code, error.localizedDescription);
};
}
return _videoCapture;
}
#pragma mark - Lifecycle
- (void)viewDidLoad {
[super viewDidLoad];
self.edgesForExtendedLayout = UIRectEdgeAll;
self.extendedLayoutIncludesOpaqueBars = YES;
self.title = @"Video Capture";
self.view.backgroundColor = [UIColor whiteColor];
self.shotCount = 0;
[self requestAccessForVideo];
// Navigation item.
UIBarButtonItem *cameraBarButton = [[UIBarButtonItem alloc] initWithTitle:@"切换" style:UIBarButtonItemStylePlain target:self action:@selector(changeCamera)];
UIBarButtonItem *shotBarButton = [[UIBarButtonItem alloc] initWithTitle:@"截图" style:UIBarButtonItemStylePlain target:self action:@selector(shot)];
self.navigationItem.rightBarButtonItems = @[cameraBarButton, shotBarButton];
}
- (void)viewWillLayoutSubviews {
[super viewWillLayoutSubviews];
self.videoCapture.previewLayer.frame = self.view.bounds;
}
- (void)dealloc {
}
#pragma mark - Action
- (void)changeCamera {
[self.videoCapture changeDevicePosition:self.videoCapture.config.position == AVCaptureDevicePositionBack ? AVCaptureDevicePositionFront : AVCaptureDevicePositionBack];
}
- (void)shot {
self.shotCount = 1;
}
#pragma mark - Utility
- (void)requestAccessForVideo {
__weak typeof(self) weakSelf = self;
AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
switch (status) {
case AVAuthorizationStatusNotDetermined: {
// 许可对话没有出现,发起授权许可。
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
if (granted) {
[weakSelf.videoCapture startRunning];
} else {
// 用户拒绝。
}
}];
break;
}
case AVAuthorizationStatusAuthorized: {
// 已经开启授权,可继续。
[weakSelf.videoCapture startRunning];
break;
}
default:
break;
}
}
- (void)saveSampleBuffer:(CMSampleBufferRef)sampleBuffer {
__block UIImage *image = [self imageFromSampleBuffer:sampleBuffer];
PHAuthorizationStatus authorizationStatus = [PHPhotoLibrary authorizationStatus];
if (authorizationStatus == PHAuthorizationStatusAuthorized) {
PHPhotoLibrary *library = [PHPhotoLibrary sharedPhotoLibrary];
[library performChanges:^{
[PHAssetChangeRequest creationRequestForAssetFromImage:image];
} completionHandler:^(BOOL success, NSError * _Nullable error) {
}];
} else if (authorizationStatus == PHAuthorizationStatusNotDetermined) {
// 如果没请求过相册权限,弹出指示框,让用户选择。
[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
// 如果用户选择授权,则保存图片。
if (status == PHAuthorizationStatusAuthorized) {
[PHAssetChangeRequest creationRequestForAssetFromImage:image];
}
}];
} else {
NSLog(@"无相册权限。");
}
}
- (UIImage *)imageFromSampleBuffer:(CMSampleBufferRef)sampleBuffer {
// 从 CMSampleBuffer 中创建 UIImage。
// 从 CMSampleBuffer 获取 CVImageBuffer(也是 CVPixelBuffer)。
CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
// 锁定 CVPixelBuffer 的基地址。
CVPixelBufferLockBaseAddress(imageBuffer, 0);
void *baseAddress = CVPixelBufferGetBaseAddress(imageBuffer);
// 获取 CVPixelBuffer 每行的字节数。
size_t bytesPerRow = CVPixelBufferGetBytesPerRow(imageBuffer);
// 获取 CVPixelBuffer 的宽高。
size_t width = CVPixelBufferGetWidth(imageBuffer);
size_t height = CVPixelBufferGetHeight(imageBuffer);
// 创建设备相关的 RGB 颜色空间。这里的颜色空间要与 CMSampleBuffer 图像数据的颜色空间一致。
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
// 基于 CVPixelBuffer 的数据创建绘制 bitmap 的上下文。
CGContextRef context = CGBitmapContextCreate(baseAddress, width, height, 8, bytesPerRow, colorSpace, kCGBitmapByteOrder32Little | kCGImageAlphaPremultipliedFirst);
// 从 bitmap 绘制的上下文中获取 CGImage 图像。
CGImageRef quartzImage = CGBitmapContextCreateImage(context);
// 解锁 CVPixelBuffer。
CVPixelBufferUnlockBaseAddress(imageBuffer, 0);
// 是否上下文和颜色空间。
CGContextRelease(context);
CGColorSpaceRelease(colorSpace);
// 从 CGImage 转换到 UIImage。
UIImage *image = [UIImage imageWithCGImage:quartzImage];
// 释放 CGImage。
CGImageRelease(quartzImage);
return image;
}
@end
```
上面是 `KFVideoCaptureViewController` 的实现,主要分为以下几个部分:
- 1)在 `-videoCaptureConfig` 中初始化采集配置参数。
- - 这里需要注意的是,我们设置了采集的颜色空间格式为 `kCVPixelFormatType_32BGRA`。这主要是为了方便后面截图时转换数据。
- 2)在 `-videoCapture` 中初始化采集器,并实现了采集会话初始化成功的回调、采集数据回调、采集错误回调。
- 3)在采集会话初始化成功的回调 `sessionInitSuccessCallBack` 中,对采集预览渲染视图层进行布局。
- 4)在采集数据回调 `sampleBufferOutputCallBack` 中,实现了截图逻辑。
- - 通过 `-saveSampleBuffer:``-imageFromSampleBuffer:` 方法中实现截图。
- `-saveSampleBuffer:` 方法主要实现请求相册权限,以及获取图像存储到相册的逻辑。
- `-imageFromSampleBuffer:` 方法实现了将 `CMSampleBuffer` 转换为 `UIImage` 的逻辑。这里需要注意的是,我们在绘制 bitmap 时使用的是 RGB 颜色空间,与前面设置的采集的颜色空间一致。如果这里前后设置不一致,转换图像会出问题。
- 5)在 `-requestAccessForVideo` 方法中请求相机权限并启动采集。
- 6)在 `-changeCamera` 方法中实现切换摄像头。
更具体细节见上述代码及其注释。
## 3、参考资料
[1]CMSampleBufferRef: *https://developer.apple.com/documentation/coremedia/cmsamplebufferref/*
原文链接:https://mp.weixin.qq.com/s/CJAhkk9BmhMOXgD2pl_rjg

@ -0,0 +1,819 @@
# iOS AVDemo(8):视频编码,H.264 和 H.265 都支持
iOS/Android 客户端开发同学如果想要开始学习音视频开发,最丝滑的方式是对[音视频基础概念知识](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2140155659944787969#wechat_redirect)有一定了解后,再借助 iOS/Android 平台的音视频能力上手去实践音视频的`采集 → 编码 → 封装 → 解封装 → 解码 → 渲染`过程,并借助[音视频工具](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2216997905264082945#wechat_redirect)来分析和理解对应的音视频数据。
在[音视频工程示例](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2273301900659851268#wechat_redirect)这个栏目,我们将通过拆解`采集 → 编码 → 封装 → 解封装 → 解码 → 渲染`流程并实现 Demo 来向大家介绍如何在 iOS/Android 平台上手音视频开发。
这里是第八篇:**iOS 视频编码 Demo**。这个 Demo 里包含以下内容:
- 1)实现一个视频采集模块;
- 2)实现一个视频编码模块,支持 H.264/H.265;
- 3)串联视频采集和编码模块,将采集到的视频数据输入给编码模块进行编码,并存储为文件;
- 4)详尽的代码注释,帮你理解代码逻辑和原理。
想要了解视频编码,可以看看这几篇:
- [《视频编码(1):H.264(AVC)》](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484471&idx=1&sn=421be18e5b591043f13996734c60780b&scene=21#wechat_redirect)
- [《视频编码(2):H.265(HEVC)》](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484563&idx=1&sn=f08f9994ef7d8a6ee09491e870c6e843&scene=21#wechat_redirect)
- [《视频编码(3):H.266(VVC)》](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484673&idx=1&sn=9a6f5e69d9af825b85210c023b85b81a&scene=21#wechat_redirect)
## 1、视频采集模块
在这个 Demo 中,视频采集模块 `KFVideoCapture` 的实现与 [《iOS 视频采集 Demo》](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257485011&idx=1&sn=8bb9cfa01deba9670e9999bd20892440&scene=21#wechat_redirect) 中一样,这里就不再重复介绍了,其接口如下:
```
KFVideoCapture.h
#import <Foundation/Foundation.h>
#import "KFVideoCaptureConfig.h"
NS_ASSUME_NONNULL_BEGIN
@interface KFVideoCapture : NSObject
+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConfig:(KFVideoCaptureConfig *)config;
@property (nonatomic, strong, readonly) KFVideoCaptureConfig *config;
@property (nonatomic, strong, readonly) AVCaptureVideoPreviewLayer *previewLayer; // 视频预览渲染 layer。
@property (nonatomic, copy) void (^sampleBufferOutputCallBack)(CMSampleBufferRef sample); // 视频采集数据回调。
@property (nonatomic, copy) void (^sessionErrorCallBack)(NSError *error); // 视频采集会话错误回调。
@property (nonatomic, copy) void (^sessionInitSuccessCallBack)(void); // 视频采集会话初始化成功回调。
- (void)startRunning; // 开始采集。
- (void)stopRunning; // 停止采集。
- (void)changeDevicePosition:(AVCaptureDevicePosition)position; // 切换摄像头。
@end
NS_ASSUME_NONNULL_END
```
## 2、视频编码模块
在实现视频编码模块之前,我们先实现一个视频编码配置类 `KFVideoEncoderConfig`
```
KFVideoEncoderConfig.h
#import <Foundation/Foundation.h>
#import <AVFoundation/AVFoundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface KFVideoEncoderConfig : NSObject
@property (nonatomic, assign) CGSize size; // 分辨率。
@property (nonatomic, assign) NSInteger bitrate; // 码率。
@property (nonatomic, assign) NSInteger fps; // 帧率。
@property (nonatomic, assign) NSInteger gopSize; // GOP 帧数。
@property (nonatomic, assign) BOOL openBFrame; // 编码是否使用 B 帧。
@property (nonatomic, assign) CMVideoCodecType codecType; // 编码器类型。
@property (nonatomic, assign) NSString *profile; // 编码 profile。
@end
NS_ASSUME_NONNULL_END
KFVideoEncoderConfig.m
#import "KFVideoEncoderConfig.h"
#import <VideoToolBox/VideoToolBox.h>
@implementation KFVideoEncoderConfig
- (instancetype)init {
self = [super init];
if (self) {
_size = CGSizeMake(1080, 1920);
_bitrate = 5000 * 1024;
_fps = 30;
_gopSize = _fps * 5;
_openBFrame = YES;
BOOL supportHEVC = NO;
if (@available(iOS 11.0, *)) {
if (&VTIsHardwareDecodeSupported) {
supportHEVC = VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC);
}
}
_codecType = supportHEVC ? kCMVideoCodecType_HEVC : kCMVideoCodecType_H264;
_profile = supportHEVC ? (__bridge NSString *) kVTProfileLevel_HEVC_Main_AutoLevel : AVVideoProfileLevelH264HighAutoLevel;
}
return self;
}
@end
```
这里实现了在设备支持 H.265 时,默认选择 H.265 编码。
接下来,我们来实现一个视频编码模块 `KFVideoEncoder`,在这里输入采集后的数据,输出编码后的数据。
```
KFVideoEncoder.h
#import <Foundation/Foundation.h>
#import "KFVideoEncoderConfig.h"
NS_ASSUME_NONNULL_BEGIN
@interface KFVideoEncoder : NSObject
+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConfig:(KFVideoEncoderConfig*)config;
@property (nonatomic, strong, readonly) KFVideoEncoderConfig *config; // 视频编码配置参数。
@property (nonatomic, copy) void (^sampleBufferOutputCallBack)(CMSampleBufferRef sampleBuffer); // 视频编码数据回调。
@property (nonatomic, copy) void (^errorCallBack)(NSError *error); // 视频编码错误回调。
- (void)encodePixelBuffer:(CVPixelBufferRef)pixelBuffer ptsTime:(CMTime)timeStamp; // 编码。
- (void)refresh; // 刷新重建编码器。
- (void)flush; // 清空编码缓冲区。
- (void)flushWithCompleteHandler:(void (^)(void))completeHandler; // 清空编码缓冲区并回调完成。
@end
NS_ASSUME_NONNULL_END
```
上面是 `KFVideoEncoder` 接口的设计,除了`初始化方法`,主要是有`获取视频编码配置`以及视频编码`数据回调`和`错误回调`的接口,另外就是`编码`、`刷新重建编码器`、`清空编码缓冲区`的接口。
其中`编码`接口对应着视频编码模块输入,`数据回调`接口则对应着输出。可以看到这里输出参数我们依然用的是 **CMSampleBufferRef**[1] 这个数据结构。不过输入的参数换成了 **CVPixelBufferRef**[2] 这个数据结构。它是对 `CVPixelBuffer` 的一个引用。
之前我们介绍过,`CMSampleBuffer` 中包含着零个或多个某一类型(audio、video、muxed 等)的采样数据。比如:
- 要么是一个或多个媒体采样的 **CMBlockBuffer**[3]。其中可以封装:音频采集后、编码后、解码后的数据(如:PCM 数据、AAC 数据);视频编码后的数据(如:H.264/H.265 数据)。
- 要么是一个 **CVImageBuffer**[4](也作 **CVPixelBuffer**[5])。其中包含媒体流中 CMSampleBuffers 的格式描述、每个采样的宽高和时序信息、缓冲级别和采样级别的附属信息。缓冲级别的附属信息是指缓冲区整体的信息,比如播放速度、对后续缓冲数据的操作等。采样级别的附属信息是指单个采样的信息,比如视频帧的时间戳、是否关键帧等。其中可以封装:视频采集后、解码后等未经编码的数据(如:YCbCr 数据、RGBA 数据)。
所以,因为是视频编码的接口,这里用 `CVPixelBufferRef` 也就是图一个方便,其实也可以用 `CMSampleBufferRef`,只要编码用 `CMSampleBufferGetImageBuffer(...)` 取出对应的 `CVPixelBufferRef` 即可。
```
KFVideoEncoder.m
#import "KFVideoEncoder.h"
#import <VideoToolBox/VideoToolBox.h>
#import <UIKit/UIKit.h>
#define KFEncoderRetrySessionMaxCount 5
#define KFEncoderEncodeFrameFailedMaxCount 20
@interface KFVideoEncoder ()
@property (nonatomic, assign) VTCompressionSessionRef compressionSession;
@property (nonatomic, strong, readwrite) KFVideoEncoderConfig *config; // 视频编码配置参数。
@property (nonatomic, strong) dispatch_queue_t encoderQueue;
@property (nonatomic, strong) dispatch_semaphore_t semaphore;
@property (nonatomic, assign) BOOL needRefreshSession; // 是否需要刷新重建编码器。
@property (nonatomic, assign) NSInteger retrySessionCount; // 刷新重建编码器的次数。
@property (nonatomic, assign) NSInteger encodeFrameFailedCount; // 编码失败次数。
@end
@implementation KFVideoEncoder
#pragma mark - LifeCycle
- (instancetype)initWithConfig:(KFVideoEncoderConfig *)config {
self = [super init];
if (self) {
_config = config;
_encoderQueue = dispatch_queue_create("com.KeyFrameKit.videoEncoder", DISPATCH_QUEUE_SERIAL);
_semaphore = dispatch_semaphore_create(1);
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didEnterBackground:) name:UIApplicationDidEnterBackgroundNotification object:nil];
}
return self;
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
dispatch_semaphore_wait(_semaphore, DISPATCH_TIME_FOREVER);
[self _releaseCompressionSession];
dispatch_semaphore_signal(_semaphore);
}
#pragma mark - Public Method
- (void)refresh {
self.needRefreshSession = YES; // 标记位待刷新重建编码器。
}
- (void)flush {
// 清空编码缓冲区。
__weak typeof(self) weakSelf = self;
dispatch_async(self.encoderQueue, ^{
dispatch_semaphore_wait(weakSelf.semaphore, DISPATCH_TIME_FOREVER);
[weakSelf _flush];
dispatch_semaphore_signal(weakSelf.semaphore);
});
}
- (void)flushWithCompleteHandler:(void (^)(void))completeHandler {
// 清空编码缓冲区并回调完成。
__weak typeof(self) weakSelf = self;
dispatch_async(self.encoderQueue, ^{
dispatch_semaphore_wait(weakSelf.semaphore, DISPATCH_TIME_FOREVER);
[weakSelf _flush];
dispatch_semaphore_signal(weakSelf.semaphore);
if (completeHandler) {
completeHandler();
}
});
}
- (void)encodePixelBuffer:(CVPixelBufferRef)pixelBuffer ptsTime:(CMTime)timeStamp {
// 编码。
if (!pixelBuffer || self.retrySessionCount >= KFEncoderRetrySessionMaxCount || self.encodeFrameFailedCount >= KFEncoderEncodeFrameFailedMaxCount) {
return;
}
CFRetain(pixelBuffer);
__weak typeof(self) weakSelf = self;
dispatch_async(self.encoderQueue, ^{
dispatch_semaphore_wait(weakSelf.semaphore, DISPATCH_TIME_FOREVER);
OSStatus setupStatus = noErr;
// 1、如果还没创建过编码器或者需要刷新重建编码器,就创建编码器。
if (!weakSelf.compressionSession || weakSelf.needRefreshSession) {
[weakSelf _releaseCompressionSession];
setupStatus = [weakSelf _setupCompressionSession];
// 支持重试,记录重试次数。
weakSelf.retrySessionCount = setupStatus == noErr ? 0 : (weakSelf.retrySessionCount + 1);
if (setupStatus != noErr) {
[weakSelf _releaseCompressionSession];
NSLog(@"KFVideoEncoder setupCompressionSession error:%d", setupStatus);
} else {
weakSelf.needRefreshSession = NO;
}
}
// 重试超过 KFEncoderRetrySessionMaxCount 次仍然失败则认为创建失败,报错。
if (!weakSelf.compressionSession) {
CFRelease(pixelBuffer);
dispatch_semaphore_signal(weakSelf.semaphore);
if (weakSelf.retrySessionCount >= KFEncoderRetrySessionMaxCount && weakSelf.errorCallBack) {
dispatch_async(dispatch_get_main_queue(), ^{
weakSelf.errorCallBack([NSError errorWithDomain:NSStringFromClass([KFVideoEncoder class]) code:setupStatus userInfo:nil]);
});
}
return;
}
// 2、对 pixelBuffer 进行编码。
VTEncodeInfoFlags flags;
OSStatus encodeStatus = VTCompressionSessionEncodeFrame(weakSelf.compressionSession, pixelBuffer, timeStamp, CMTimeMake(1, (int32_t) weakSelf.config.fps), NULL, NULL, &flags);
if (encodeStatus == kVTInvalidSessionErr) {
// 编码失败进行重建编码器重试。
[weakSelf _releaseCompressionSession];
setupStatus = [weakSelf _setupCompressionSession];
weakSelf.retrySessionCount = setupStatus == noErr ? 0 : (weakSelf.retrySessionCount + 1);
if (setupStatus == noErr) {
encodeStatus = VTCompressionSessionEncodeFrame(weakSelf.compressionSession, pixelBuffer, timeStamp, CMTimeMake(1, (int32_t) weakSelf.config.fps), NULL, NULL, &flags);
} else {
[weakSelf _releaseCompressionSession];
}
NSLog(@"KFVideoEncoder kVTInvalidSessionErr");
}
// 记录编码失败次数。
if (encodeStatus != noErr) {
NSLog(@"KFVideoEncoder VTCompressionSessionEncodeFrame error:%d", encodeStatus);
}
weakSelf.encodeFrameFailedCount = encodeStatus == noErr ? 0 : (weakSelf.encodeFrameFailedCount + 1);
CFRelease(pixelBuffer);
dispatch_semaphore_signal(weakSelf.semaphore);
// 编码失败次数超过 KFEncoderEncodeFrameFailedMaxCount 次,报错。
if (weakSelf.encodeFrameFailedCount >= KFEncoderEncodeFrameFailedMaxCount && weakSelf.errorCallBack) {
dispatch_async(dispatch_get_main_queue(), ^{
weakSelf.errorCallBack([NSError errorWithDomain:NSStringFromClass([KFVideoEncoder class]) code:encodeStatus userInfo:nil]);
});
}
});
}
#pragma mark - Privte Method
- (OSStatus)_setupCompressionSession {
if (_compressionSession) {
return noErr;
}
// 1、创建视频编码器实例。
// 这里要设置画面尺寸、编码器类型、编码数据回调。
OSStatus status = VTCompressionSessionCreate(NULL, _config.size.width, _config.size.height, _config.codecType, NULL, NULL, NULL, encoderOutputCallback, (__bridge void *) self, &_compressionSession);
if (status != noErr) {
return status;
}
// 2、设置编码器属性:实时编码。
VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_RealTime, (__bridge CFTypeRef) @(YES));
// 3、设置编码器属性:编码 profile。
status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_ProfileLevel, (__bridge CFStringRef) self.config.profile);
if (status != noErr) {
return status;
}
// 4、设置编码器属性:是否支持 B 帧。
status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_AllowFrameReordering, (__bridge CFTypeRef) @(self.config.openBFrame));
if (status != noErr) {
return status;
}
if (self.config.codecType == kCMVideoCodecType_H264) {
// 5、如果是 H.264 编码,设置编码器属性:熵编码类型为 CABAC,上下文自适应的二进制算术编码。
status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_H264EntropyMode, kVTH264EntropyMode_CABAC);
if (status != noErr) {
return status;
}
}
// 6、设置编码器属性:画面填充模式。
NSDictionary *transferDic= @{
(__bridge NSString *) kVTPixelTransferPropertyKey_ScalingMode: (__bridge NSString *) kVTScalingMode_Letterbox,
};
status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_PixelTransferProperties, (__bridge CFTypeRef) (transferDic));
if (status != noErr) {
return status;
}
// 7、设置编码器属性:平均码率。
status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_AverageBitRate, (__bridge CFTypeRef) @(self.config.bitrate));
if (status != noErr) {
return status;
}
// 8、设置编码器属性:码率上限。
if (!self.config.openBFrame && self.config.codecType == kCMVideoCodecType_H264) {
NSArray *limit = @[@(self.config.bitrate * 1.5 / 8), @(1)];
status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_DataRateLimits, (__bridge CFArrayRef) limit);
if (status != noErr) {
return status;
}
}
// 9、设置编码器属性:期望帧率。
status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_ExpectedFrameRate, (__bridge CFTypeRef) @(self.config.fps));
if (status != noErr) {
return status;
}
// 10、设置编码器属性:最大关键帧间隔帧数,也就是 GOP 帧数。
status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_MaxKeyFrameInterval, (__bridge CFTypeRef) @(self.config.gopSize));
if (status != noErr) {
return status;
}
// 11、设置编码器属性:最大关键帧间隔时长。
status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, (__bridge CFTypeRef)@(_config.gopSize / _config.fps));
if (status != noErr) {
return status;
}
// 12、预备编码。
status = VTCompressionSessionPrepareToEncodeFrames(_compressionSession);
return status;
}
- (void)_releaseCompressionSession {
if (_compressionSession) {
// 强制处理完所有待编码的帧。
VTCompressionSessionCompleteFrames(_compressionSession, kCMTimeInvalid);
// 销毁编码器。
VTCompressionSessionInvalidate(_compressionSession);
CFRelease(_compressionSession);
_compressionSession = NULL;
}
}
- (void)_flush {
// 清空编码缓冲区。
if (_compressionSession) {
// 传入 kCMTimeInvalid 时,强制处理完所有待编码的帧,清空缓冲区。
VTCompressionSessionCompleteFrames(_compressionSession, kCMTimeInvalid);
}
}
#pragma mark - NSNotification
- (void)didEnterBackground:(NSNotification *)notification {
self.needRefreshSession = YES; // 退后台回来后需要刷新重建编码器。
}
#pragma mark - EncoderOutputCallback
static void encoderOutputCallback(void * CM_NULLABLE outputCallbackRefCon,
void * CM_NULLABLE sourceFrameRefCon,
OSStatus status,
VTEncodeInfoFlags infoFlags,
CMSampleBufferRef sampleBuffer) {
if (!sampleBuffer) {
if (infoFlags & kVTEncodeInfo_FrameDropped) {
NSLog(@"VideoToolboxEncoder kVTEncodeInfo_FrameDropped");
}
return;
}
// 向外层回调编码数据。
KFVideoEncoder *videoEncoder = (__bridge KFVideoEncoder *) outputCallbackRefCon;
if (videoEncoder && videoEncoder.sampleBufferOutputCallBack) {
videoEncoder.sampleBufferOutputCallBack(sampleBuffer);
}
}
@end
```
上面是 `KFVideoEncoder` 的实现,从代码上可以看到主要有这几个部分:
- 1)创建视频编码实例。
- - 在 `-_setupCompressionSession` 方法中实现。
- 2)实现视频编码逻辑,并在编码实例的数据回调中接收编码后的数据,抛给对外数据回调接口。
- - 在 `-encodePixelBuffer:ptsTime:` 方法中实现。
- 回调在 `encoderOutputCallback` 中实现。
- 3)实现清空编码缓冲区功能。
- - 在 `-_flush` 方法中实现。
- 4)刷新重建编码器功能。
- - 在 `-refresh` 方法中标记需要刷新重建,在 `-encodePixelBuffer:ptsTime:` 方法检查标记并重建编码器实例。
- 5)捕捉视频编码过程中的错误,抛给对外错误回调接口。
- - 主要在 `-encodePixelBuffer:ptsTime:` 方法捕捉错误。
- 6)清理视频编码器实例。
- - 在 `-_releaseCompressionSession` 方法中实现。
更具体细节见上述代码及其注释。
## 3、采集视频数据进行 H.264/H.265 编码和存储
我们在一个 ViewController 中来实现视频采集及编码逻辑,并且示范了将 iOS 编码的 AVCC/HVCC 码流格式转换为 AnnexB 码流格式后再存储。
我们先来简单介绍一下这两种格式的区别:
AVCC/HVCC 码流格式类似:
```
[extradata]|[length][NALU]|[length][NALU]|...
```
- VPS、SPS、PPS 不用 NALU 来存储,而是存储在 `extradata` 中;
- 每个 NALU 前有个 `length` 字段表示这个 NALU 的长度(不包含 `length` 字段),`length` 字段通常是 4 字节。
AnnexB 码流格式:
```
[startcode][NALU]|[startcode][NALU]|...
```
需要注意的是:
- 每个 NALU 前要添加起始码:`0x00000001`;
- VPS、SPS、PPS 也都用这样的 NALU 来存储,一般在码流最前面。
iOS 的 VideoToolbox 编码和解码只支持 AVCC/HVCC 的码流格式。但是 Android 的 MediaCodec 只支持 AnnexB 的码流格式。
```
KFVideoEncoderViewController.m
#import "KFVideoEncoderViewController.h"
#import "KFVideoCapture.h"
#import "KFVideoEncoder.h"
@interface KFVideoPacketExtraData : NSObject
@property (nonatomic, strong) NSData *sps;
@property (nonatomic, strong) NSData *pps;
@property (nonatomic, strong) NSData *vps;
@end
@implementation KFVideoPacketExtraData
@end
@interface KFVideoEncoderViewController ()
@property (nonatomic, strong) KFVideoCaptureConfig *videoCaptureConfig;
@property (nonatomic, strong) KFVideoCapture *videoCapture;
@property (nonatomic, strong) KFVideoEncoderConfig *videoEncoderConfig;
@property (nonatomic, strong) KFVideoEncoder *videoEncoder;
@property (nonatomic, assign) BOOL isEncoding;
@property (nonatomic, strong) NSFileHandle *fileHandle;
@end
@implementation KFVideoEncoderViewController
#pragma mark - Property
- (KFVideoCaptureConfig *)videoCaptureConfig {
if (!_videoCaptureConfig) {
_videoCaptureConfig = [[KFVideoCaptureConfig alloc] init];
// 这里我们采集数据用于编码,颜色格式用了默认的:kCVPixelFormatType_420YpCbCr8BiPlanarFullRange。
}
return _videoCaptureConfig;
}
- (KFVideoCapture *)videoCapture {
if (!_videoCapture) {
_videoCapture = [[KFVideoCapture alloc] initWithConfig:self.videoCaptureConfig];
__weak typeof(self) weakSelf = self;
_videoCapture.sessionInitSuccessCallBack = ^() {
dispatch_async(dispatch_get_main_queue(), ^{
// 预览渲染。
[weakSelf.view.layer insertSublayer:weakSelf.videoCapture.previewLayer atIndex:0];
weakSelf.videoCapture.previewLayer.backgroundColor = [UIColor blackColor].CGColor;
weakSelf.videoCapture.previewLayer.frame = weakSelf.view.bounds;
});
};
_videoCapture.sampleBufferOutputCallBack = ^(CMSampleBufferRef sampleBuffer) {
if (weakSelf.isEncoding && sampleBuffer) {
// 编码。
[weakSelf.videoEncoder encodePixelBuffer:CMSampleBufferGetImageBuffer(sampleBuffer) ptsTime:CMSampleBufferGetPresentationTimeStamp(sampleBuffer)];
}
};
_videoCapture.sessionErrorCallBack = ^(NSError* error) {
NSLog(@"KFVideoCapture Error:%zi %@", error.code, error.localizedDescription);
};
}
return _videoCapture;
}
- (KFVideoEncoderConfig *)videoEncoderConfig {
if (!_videoEncoderConfig) {
_videoEncoderConfig = [[KFVideoEncoderConfig alloc] init];
}
return _videoEncoderConfig;
}
- (KFVideoEncoder *)videoEncoder {
if (!_videoEncoder) {
_videoEncoder = [[KFVideoEncoder alloc] initWithConfig:self.videoEncoderConfig];
__weak typeof(self) weakSelf = self;
_videoEncoder.sampleBufferOutputCallBack = ^(CMSampleBufferRef sampleBuffer) {
// 保存编码后的数据。
[weakSelf saveSampleBuffer:sampleBuffer];
};
}
return _videoEncoder;
}
- (NSFileHandle *)fileHandle {
if (!_fileHandle) {
NSString *fileName = @"test.h264";
if (self.videoEncoderConfig.codecType == kCMVideoCodecType_HEVC) {
fileName = @"test.h265";
}
NSString *videoPath = [[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject] stringByAppendingPathComponent:fileName];
[[NSFileManager defaultManager] removeItemAtPath:videoPath error:nil];
[[NSFileManager defaultManager] createFileAtPath:videoPath contents:nil attributes:nil];
_fileHandle = [NSFileHandle fileHandleForWritingAtPath:videoPath];
}
return _fileHandle;
}
#pragma mark - Lifecycle
- (void)viewDidLoad {
[super viewDidLoad];
// Navigation item.
UIBarButtonItem *startBarButton = [[UIBarButtonItem alloc] initWithTitle:@"Start" style:UIBarButtonItemStylePlain target:self action:@selector(start)];
UIBarButtonItem *stopBarButton = [[UIBarButtonItem alloc] initWithTitle:@"Stop" style:UIBarButtonItemStylePlain target:self action:@selector(stop)];
UIBarButtonItem *cameraBarButton = [[UIBarButtonItem alloc] initWithTitle:@"Camera" style:UIBarButtonItemStylePlain target:self action:@selector(changeCamera)];
self.navigationItem.rightBarButtonItems = @[stopBarButton,startBarButton,cameraBarButton];
[self requestAccessForVideo];
UITapGestureRecognizer *doubleTapGesture = [[UITapGestureRecognizer alloc]initWithTarget:self action:@selector(handleDoubleTap:)];
doubleTapGesture.numberOfTapsRequired = 2;
doubleTapGesture.numberOfTouchesRequired = 1;
[self.view addGestureRecognizer:doubleTapGesture];
}
- (void)viewWillLayoutSubviews {
[super viewWillLayoutSubviews];
self.videoCapture.previewLayer.frame = self.view.bounds;
}
- (void)dealloc {
}
#pragma mark - Action
- (void)start {
if (!self.isEncoding) {
self.isEncoding = YES;
[self.videoEncoder refresh];
}
}
- (void)stop {
if (self.isEncoding) {
self.isEncoding = NO;
[self.videoEncoder flush];
}
}
- (void)onCameraSwitchButtonClicked:(UIButton *)button {
[self.videoCapture changeDevicePosition:self.videoCapture.config.position == AVCaptureDevicePositionBack ? AVCaptureDevicePositionFront : AVCaptureDevicePositionBack];
}
- (void)changeCamera {
[self.videoCapture changeDevicePosition:self.videoCapture.config.position == AVCaptureDevicePositionBack ? AVCaptureDevicePositionFront : AVCaptureDevicePositionBack];
}
-(void)handleDoubleTap:(UIGestureRecognizer *)sender {
[self.videoCapture changeDevicePosition:self.videoCapture.config.position == AVCaptureDevicePositionBack ? AVCaptureDevicePositionFront : AVCaptureDevicePositionBack];
}
#pragma mark - Private Method
- (void)requestAccessForVideo{
__weak typeof(self) weakSelf = self;
AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
switch (status) {
case AVAuthorizationStatusNotDetermined: {
// 许可对话没有出现,发起授权许可。
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
if (granted) {
[weakSelf.videoCapture startRunning];
} else {
// 用户拒绝。
}
}];
break;
}
case AVAuthorizationStatusAuthorized: {
// 已经开启授权,可继续。
[weakSelf.videoCapture startRunning];
break;
}
default:
break;
}
}
- (KFVideoPacketExtraData *)getPacketExtraData:(CMSampleBufferRef)sampleBuffer {
// 从 CMSampleBuffer 中获取 extra data。
if (!sampleBuffer) {
return nil;
}
// 获取编码类型。
CMVideoCodecType codecType = CMVideoFormatDescriptionGetCodecType(CMSampleBufferGetFormatDescription(sampleBuffer));
KFVideoPacketExtraData *extraData = nil;
if (codecType == kCMVideoCodecType_H264) {
// 获取 H.264 的 extra data:sps、pps。
CMFormatDescriptionRef format = CMSampleBufferGetFormatDescription(sampleBuffer);
size_t sparameterSetSize, sparameterSetCount;
const uint8_t *sparameterSet;
OSStatus statusCode = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 0, &sparameterSet, &sparameterSetSize, &sparameterSetCount, 0);
if (statusCode == noErr) {
size_t pparameterSetSize, pparameterSetCount;
const uint8_t *pparameterSet;
OSStatus statusCode = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 1, &pparameterSet, &pparameterSetSize, &pparameterSetCount, 0);
if (statusCode == noErr) {
extraData = [[KFVideoPacketExtraData alloc] init];
extraData.sps = [NSData dataWithBytes:sparameterSet length:sparameterSetSize];
extraData.pps = [NSData dataWithBytes:pparameterSet length:pparameterSetSize];
}
}
} else if (codecType == kCMVideoCodecType_HEVC) {
// 获取 H.265 的 extra data:vps、sps、pps。
CMFormatDescriptionRef format = CMSampleBufferGetFormatDescription(sampleBuffer);
size_t vparameterSetSize, vparameterSetCount;
const uint8_t *vparameterSet;
if (@available(iOS 11.0, *)) {
OSStatus statusCode = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(format, 0, &vparameterSet, &vparameterSetSize, &vparameterSetCount, 0);
if (statusCode == noErr) {
size_t sparameterSetSize, sparameterSetCount;
const uint8_t *sparameterSet;
OSStatus statusCode = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(format, 1, &sparameterSet, &sparameterSetSize, &sparameterSetCount, 0);
if (statusCode == noErr) {
size_t pparameterSetSize, pparameterSetCount;
const uint8_t *pparameterSet;
OSStatus statusCode = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(format, 2, &pparameterSet, &pparameterSetSize, &pparameterSetCount, 0);
if (statusCode == noErr) {
extraData = [[KFVideoPacketExtraData alloc] init];
extraData.vps = [NSData dataWithBytes:vparameterSet length:vparameterSetSize];
extraData.sps = [NSData dataWithBytes:sparameterSet length:sparameterSetSize];
extraData.pps = [NSData dataWithBytes:pparameterSet length:pparameterSetSize];
}
}
}
} else {
// 其他编码格式。
}
}
return extraData;
}
- (BOOL)isKeyFrame:(CMSampleBufferRef)sampleBuffer {
CFArrayRef array = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true);
if (!array) {
return NO;
}
CFDictionaryRef dic = (CFDictionaryRef)CFArrayGetValueAtIndex(array, 0);
if (!dic) {
return NO;
}
// 检测 sampleBuffer 是否是关键帧。
BOOL keyframe = !CFDictionaryContainsKey(dic, kCMSampleAttachmentKey_NotSync);
return keyframe;
}
- (void)saveSampleBuffer:(CMSampleBufferRef)sampleBuffer {
// 将编码数据存储为文件。
// iOS 的 VideoToolbox 编码和解码只支持 AVCC/HVCC 的码流格式。但是 Android 的 MediaCodec 只支持 AnnexB 的码流格式。这里我们做一下两种格式的转换示范,将 AVCC/HVCC 格式的码流转换为 AnnexB 再存储。
// 1、AVCC/HVCC 码流格式:[extradata]|[length][NALU]|[length][NALU]|...
// VPS、SPS、PPS 不用 NALU 来存储,而是存储在 extradata 中;每个 NALU 前有个 length 字段表示这个 NALU 的长度(不包含 length 字段),length 字段通常是 4 字节。
// 2、AnnexB 码流格式:[startcode][NALU]|[startcode][NALU]|...
// 每个 NAL 前要添加起始码:0x00000001;VPS、SPS、PPS 也都用这样的 NALU 来存储,一般在码流最前面。
if (sampleBuffer) {
NSMutableData *resultData = [NSMutableData new];
uint8_t nalPartition[] = {0x00, 0x00, 0x00, 0x01};
// 关键帧前添加 vps(H.265)、sps、pps。这里要注意顺序别乱了。
if ([self isKeyFrame:sampleBuffer]) {
KFVideoPacketExtraData *extraData = [self getPacketExtraData:sampleBuffer];
if (extraData.vps) {
[resultData appendBytes:nalPartition length:4];
[resultData appendData:extraData.vps];
}
[resultData appendBytes:nalPartition length:4];
[resultData appendData:extraData.sps];
[resultData appendBytes:nalPartition length:4];
[resultData appendData:extraData.pps];
}
// 获取编码数据。这里的数据是 AVCC/HVCC 格式的。
CMBlockBufferRef dataBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
size_t length, totalLength;
char *dataPointer;
OSStatus statusCodeRet = CMBlockBufferGetDataPointer(dataBuffer, 0, &length, &totalLength, &dataPointer);
if (statusCodeRet == noErr) {
size_t bufferOffset = 0;
static const int NALULengthHeaderLength = 4;
// 拷贝编码数据。
while (bufferOffset < totalLength - NALULengthHeaderLength) {
// 通过 length 字段获取当前这个 NALU 的长度。
uint32_t NALUnitLength = 0;
memcpy(&NALUnitLength, dataPointer + bufferOffset, NALULengthHeaderLength);
NALUnitLength = CFSwapInt32BigToHost(NALUnitLength);
// 拷贝 AnnexB 起始码字节。
[resultData appendData:[NSData dataWithBytes:nalPartition length:4]];
// 拷贝这个 NALU 的字节。
[resultData appendData:[NSData dataWithBytes:(dataPointer + bufferOffset + NALULengthHeaderLength) length:NALUnitLength]];
// 步进。
bufferOffset += NALULengthHeaderLength + NALUnitLength;
}
}
[self.fileHandle writeData:resultData];
}
}
@end
```
上面是 `KFVideoEncoderViewController` 的实现,主要分为以下几个部分:
- 1)在 `-videoCaptureConfig` 中初始化采集配置参数,在 `-videoEncoderConfig` 中初始化编码配置参数。
- - 这里需要注意的是,由于采集的数据后续用于编码,我们设置了采集的颜色空间格式为默认的 `kCVPixelFormatType_420YpCbCr8BiPlanarFullRange`
- 编码参数配置这里,默认是在设备支持 H.265 时,选择 H.265 编码。
- 2)在 `-videoCapture` 中初始化采集器,并实现了采集会话初始化成功的回调、采集数据回调、采集错误回调。
- 3)在采集会话初始化成功的回调 `sessionInitSuccessCallBack` 中,对采集预览渲染视图层进行布局。
- 4)在采集数据回调 `sampleBufferOutputCallBack` 中,从 CMSampleBufferRef 中取出 CVPixelBufferRef 送给编码器编码。
- 5)在编码数据回调 `sampleBufferOutputCallBack` 中,调用 `-saveSampleBuffer:` 将编码数据存储为 H.264/H.265 文件。
- - 这里示范了将 AVCC/HVCC 格式的码流转换为 AnnexB 再存储的过程。
## 4、用工具播放 H.264/H.265 文件
完成视频采集和编码后,可以将 App Document 文件夹下面的 `test.h264``test.h265` 文件拷贝到电脑上,使用 `ffplay` 播放来验证一下视频采集是效果是否符合预期:
```
$ ffplay -i test.h264
$ ffplay -i test.h265
```
关于播放 H.264/H.265 文件的工具,可以参考[《FFmpeg 工具》第 2 节 ffplay 命令行工具](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484831&idx=1&sn=6bab905a5040c46b971bab05f787788b&scene=21#wechat_redirect)和[《可视化音视频分析工具》第 2.1 节 StreamEye](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484834&idx=1&sn=5dd9768bfc0d01ca1b036be8dd2f5fa1&scene=21#wechat_redirect)。
## 5、参考资料
[1]CMSampleBufferRef: *https://developer.apple.com/documentation/coremedia/cmsamplebufferref/*
[2]CVPixelBufferRef: *https://developer.apple.com/documentation/corevideo/cvpixelbufferref/*
[3]CMBlockBuffer: *https://developer.apple.com/documentation/coremedia/cmblockbuffer-u9i*
[4]CVImageBuffer: *https://developer.apple.com/documentation/corevideo/cvimagebuffer-q40*
[5]CVPixelBuffer: *https://developer.apple.com/documentation/corevideo/cvpixelbuffer-q2e*
原文链接:https://mp.weixin.qq.com/s/M2l-9_W8heu_NjSYKQLCRA

@ -0,0 +1,353 @@
# iOS AVDemo(9):视频封装,采集编码 H.264/H.265 并封装 MP4
iOS/Android 客户端开发同学如果想要开始学习音视频开发,最丝滑的方式是对[音视频基础概念知识](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2140155659944787969#wechat_redirect)有一定了解后,再借助 iOS/Android 平台的音视频能力上手去实践音视频的`采集 → 编码 → 封装 → 解封装 → 解码 → 渲染`过程,并借助[音视频工具](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2216997905264082945#wechat_redirect)来分析和理解对应的音视频数据。
在[音视频工程示例](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2273301900659851268#wechat_redirect)这个栏目,我们将通过拆解`采集 → 编码 → 封装 → 解封装 → 解码 → 渲染`流程并实现 Demo 来向大家介绍如何在 iOS/Android 平台上手音视频开发。
这里是第九篇:**iOS 视频封装 Demo**。这个 Demo 里包含以下内容:
- 1)实现一个视频采集模块;
- 2)实现一个视频编码模块,支持 H.264/H.265;
- 3)实现一个视频封装模块;
- 4)串联视频采集、编码、封装模块,将采集到的视频数据输入给编码模块进行编码,再将编码后的数据输入给 MP4 封装模块封装和存储;
- 5)详尽的代码注释,帮你理解代码逻辑和原理。
在本文中,我们将详解一下 Demo 的具体实现和源码。读完本文内容相信就能帮你掌握相关知识。
## 1、视频采集模块
在这个 Demo 中,视频采集模块 `KFVideoCapture` 的实现与 [《iOS 视频采集 Demo》](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257485011&idx=1&sn=8bb9cfa01deba9670e9999bd20892440&scene=21#wechat_redirect) 中一样,这里就不再重复介绍了,其接口如下:
```
KFVideoCapture.h
#import <Foundation/Foundation.h>
#import "KFVideoCaptureConfig.h"
NS_ASSUME_NONNULL_BEGIN
@interface KFVideoCapture : NSObject
+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConfig:(KFVideoCaptureConfig *)config;
@property (nonatomic, strong, readonly) KFVideoCaptureConfig *config;
@property (nonatomic, strong, readonly) AVCaptureVideoPreviewLayer *previewLayer; // 视频预览渲染 layer。
@property (nonatomic, copy) void (^sampleBufferOutputCallBack)(CMSampleBufferRef sample); // 视频采集数据回调。
@property (nonatomic, copy) void (^sessionErrorCallBack)(NSError *error); // 视频采集会话错误回调。
@property (nonatomic, copy) void (^sessionInitSuccessCallBack)(void); // 视频采集会话初始化成功回调。
- (void)startRunning; // 开始采集。
- (void)stopRunning; // 停止采集。
- (void)changeDevicePosition:(AVCaptureDevicePosition)position; // 切换摄像头。
@end
NS_ASSUME_NONNULL_END
```
## 2、视频编码模块
同样的,视频编码模块 `KFVideoEncoder` 的实现与[《iOS 视频编码 Demo》](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257485273&idx=1&sn=0d876a49c4e46f369f6a578856221f5d&scene=21#wechat_redirect)中一样,这里就不再重复介绍了,其接口如下:
```
KFVideoEncoder.h
#import <Foundation/Foundation.h>
#import "KFVideoEncoderConfig.h"
NS_ASSUME_NONNULL_BEGIN
@interface KFVideoEncoder : NSObject
+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConfig:(KFVideoEncoderConfig*)config;
@property (nonatomic, strong, readonly) KFVideoEncoderConfig *config; // 视频编码配置参数。
@property (nonatomic, copy) void (^sampleBufferOutputCallBack)(CMSampleBufferRef sampleBuffer); // 视频编码数据回调。
@property (nonatomic, copy) void (^errorCallBack)(NSError *error); // 视频编码错误回调。
- (void)encodePixelBuffer:(CVPixelBufferRef)pixelBuffer ptsTime:(CMTime)timeStamp; // 编码。
- (void)refresh; // 刷新重建编码器。
- (void)flush; // 清空编码缓冲区。
- (void)flushWithCompleteHandler:(void (^)(void))completeHandler; // 清空编码缓冲区并回调完成。
@end
NS_ASSUME_NONNULL_END
```
## 3、视频封装模块
视频编码模块即 `KFMP4Muxer`,复用了[《iOS 音频封装 Demo》](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484907&idx=1&sn=e2418939db2e199c42130e20cb9e1a34&scene=21#wechat_redirect)中介绍的 muxer,这里就不再重复介绍了,其接口如下:
```
KFMP4Muxer.h
#import <Foundation/Foundation.h>
#import <CoreMedia/CoreMedia.h>
#import "KFMuxerConfig.h"
NS_ASSUME_NONNULL_BEGIN
@interface KFMP4Muxer : NSObject
+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConfig:(KFMuxerConfig *)config;
@property (nonatomic, strong, readonly) KFMuxerConfig *config;
@property (nonatomic, copy) void (^errorCallBack)(NSError *error); // 封装错误回调。
- (void)startWriting; // 开始封装写入数据。
- (void)cancelWriting; // 取消封装写入数据。
- (void)appendSampleBuffer:(CMSampleBufferRef)sampleBuffer; // 添加封装数据。
- (void)stopWriting:(void (^)(BOOL success, NSError *error))completeHandler; // 停止封装写入数据。
@end
NS_ASSUME_NONNULL_END
```
## 4、采集视频数据进行 H.264/H.265 编码以及 MP4 封装和存储
我们还是在一个 ViewController 中来实现采集视频数据进行 H.264/H.265 编码以及 MP4 封装和存储的逻辑。
```
KFVideoMuxerViewController.m
#import "KFVideoMuxerViewController.h"
#import "KFVideoCapture.h"
#import "KFVideoEncoder.h"
#import "KFMP4Muxer.h"
@interface KFVideoMuxerViewController ()
@property (nonatomic, strong) KFVideoCaptureConfig *videoCaptureConfig;
@property (nonatomic, strong) KFVideoCapture *videoCapture;
@property (nonatomic, strong) KFVideoEncoderConfig *videoEncoderConfig;
@property (nonatomic, strong) KFVideoEncoder *videoEncoder;
@property (nonatomic, strong) KFMuxerConfig *muxerConfig;
@property (nonatomic, strong) KFMP4Muxer *muxer;
@property (nonatomic, assign) BOOL isWriting;
@end
@implementation KFVideoMuxerViewController
#pragma mark - Property
- (KFVideoCaptureConfig *)videoCaptureConfig {
if (!_videoCaptureConfig) {
_videoCaptureConfig = [[KFVideoCaptureConfig alloc] init];
}
return _videoCaptureConfig;
}
- (KFVideoCapture *)videoCapture {
if (!_videoCapture) {
_videoCapture = [[KFVideoCapture alloc] initWithConfig:self.videoCaptureConfig];
__weak typeof(self) weakSelf = self;
_videoCapture.sessionInitSuccessCallBack = ^() {
dispatch_async(dispatch_get_main_queue(), ^{
// 预览渲染。
[weakSelf.view.layer insertSublayer:weakSelf.videoCapture.previewLayer atIndex:0];
weakSelf.videoCapture.previewLayer.backgroundColor = [UIColor blackColor].CGColor;
weakSelf.videoCapture.previewLayer.frame = weakSelf.view.bounds;
});
};
_videoCapture.sampleBufferOutputCallBack = ^(CMSampleBufferRef sampleBuffer) {
if (sampleBuffer && weakSelf.isWriting) {
// 编码。
[weakSelf.videoEncoder encodePixelBuffer:CMSampleBufferGetImageBuffer(sampleBuffer) ptsTime:CMSampleBufferGetPresentationTimeStamp(sampleBuffer)];
}
};
_videoCapture.sessionErrorCallBack = ^(NSError *error) {
NSLog(@"KFVideoCapture Error:%zi %@", error.code, error.localizedDescription);
};
}
return _videoCapture;
}
- (KFVideoEncoderConfig *)videoEncoderConfig {
if (!_videoEncoderConfig) {
_videoEncoderConfig = [[KFVideoEncoderConfig alloc] init];
}
return _videoEncoderConfig;
}
- (KFVideoEncoder *)videoEncoder {
if (!_videoEncoder) {
_videoEncoder = [[KFVideoEncoder alloc] initWithConfig:self.videoEncoderConfig];
__weak typeof(self) weakSelf = self;
_videoEncoder.sampleBufferOutputCallBack = ^(CMSampleBufferRef sampleBuffer) {
// 视频编码数据回调。
if (weakSelf.isWriting) {
// 当标记封装写入中时,将编码的 H.264/H.265 数据送给封装器。
[weakSelf.muxer appendSampleBuffer:sampleBuffer];
}
};
}
return _videoEncoder;
}
- (KFMuxerConfig *)muxerConfig {
if (!_muxerConfig) {
_muxerConfig = [[KFMuxerConfig alloc] init];
NSString *videoPath = [[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject] stringByAppendingPathComponent:@"test.mp4"];
NSLog(@"MP4 file path: %@", videoPath);
[[NSFileManager defaultManager] removeItemAtPath:videoPath error:nil];
_muxerConfig.outputURL = [NSURL fileURLWithPath:videoPath];
_muxerConfig.muxerType = KFMediaVideo;
}
return _muxerConfig;
}
- (KFMP4Muxer *)muxer {
if (!_muxer) {
_muxer = [[KFMP4Muxer alloc] initWithConfig:self.muxerConfig];
}
return _muxer;
}
#pragma mark - Lifecycle
- (void)viewDidLoad {
[super viewDidLoad];
// 启动后即开始请求视频采集权限并开始采集。
[self requestAccessForVideo];
[self setupUI];
}
- (void)viewWillLayoutSubviews {
[super viewWillLayoutSubviews];
self.videoCapture.previewLayer.frame = self.view.bounds;
}
- (void)dealloc {
}
#pragma mark - Action
- (void)start {
if (!self.isWriting) {
// 启动封装,
[self.muxer startWriting];
// 标记开始封装写入。
self.isWriting = YES;
}
}
- (void)stop {
if (self.isWriting) {
__weak typeof(self) weakSelf = self;
[self.videoEncoder flushWithCompleteHandler:^{
weakSelf.isWriting = NO;
[weakSelf.muxer stopWriting:^(BOOL success, NSError * _Nonnull error) {
NSLog(@"muxer stop %@", success ? @"success" : @"failed");
}];
}];
}
}
- (void)changeCamera {
[self.videoCapture changeDevicePosition:self.videoCapture.config.position == AVCaptureDevicePositionBack ? AVCaptureDevicePositionFront : AVCaptureDevicePositionBack];
}
- (void)singleTap:(UIGestureRecognizer *)sender {
}
-(void)handleDoubleTap:(UIGestureRecognizer *)sender {
[self.videoCapture changeDevicePosition:self.videoCapture.config.position == AVCaptureDevicePositionBack ? AVCaptureDevicePositionFront : AVCaptureDevicePositionBack];
}
#pragma mark - Private Method
- (void)requestAccessForVideo {
__weak typeof(self) weakSelf = self;
AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
switch (status) {
case AVAuthorizationStatusNotDetermined:{
// 许可对话没有出现,发起授权许可。
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
if (granted) {
[weakSelf.videoCapture startRunning];
} else {
// 用户拒绝。
}
}];
break;
}
case AVAuthorizationStatusAuthorized:{
// 已经开启授权,可继续。
[weakSelf.videoCapture startRunning];
break;
}
default:
break;
}
}
- (void)setupUI {
self.edgesForExtendedLayout = UIRectEdgeAll;
self.extendedLayoutIncludesOpaqueBars = YES;
self.title = @"Video Muxer";
self.view.backgroundColor = [UIColor whiteColor];
// 添加手势。
UITapGestureRecognizer *singleTapGesture = [[UITapGestureRecognizer alloc]initWithTarget:self action:@selector(singleTap:)];
singleTapGesture.numberOfTapsRequired = 1;
singleTapGesture.numberOfTouchesRequired = 1;
[self.view addGestureRecognizer:singleTapGesture];
UITapGestureRecognizer *doubleTapGesture = [[UITapGestureRecognizer alloc]initWithTarget:self action:@selector(handleDoubleTap:)];
doubleTapGesture.numberOfTapsRequired = 2;
doubleTapGesture.numberOfTouchesRequired = 1;
[self.view addGestureRecognizer:doubleTapGesture];
[singleTapGesture requireGestureRecognizerToFail:doubleTapGesture];
// Navigation item.
UIBarButtonItem *startBarButton = [[UIBarButtonItem alloc] initWithTitle:@"Start" style:UIBarButtonItemStylePlain target:self action:@selector(start)];
UIBarButtonItem *stopBarButton = [[UIBarButtonItem alloc] initWithTitle:@"Stop" style:UIBarButtonItemStylePlain target:self action:@selector(stop)];
UIBarButtonItem *cameraBarButton = [[UIBarButtonItem alloc] initWithTitle:@"Camera" style:UIBarButtonItemStylePlain target:self action:@selector(changeCamera)];
self.navigationItem.rightBarButtonItems = @[stopBarButton, startBarButton, cameraBarButton];
}
@end
```
上面是 `KFVideoMuxerViewController` 的实现,其中主要包含这几个部分:
- 1)启动后即开始请求视频采集权限并开始采集。
- - 在 `-requestAccessForVideo` 方法中实现。
- 2)在采集会话初始化成功的回调中,对采集预览渲染视图层进行布局。
- - 在 `KFVideoCapture``sessionInitSuccessCallBack` 回调中实现。
- 2)在采集模块的数据回调中将数据交给编码模块进行编码。
- - 在 `KFVideoCapture``sampleBufferOutputCallBack` 回调中实现。
- 3)在编码模块的数据回调中获取编码后的 H.264/H.265 数据,并将数据交给封装器 `KFMP4Muxer` 进行封装。
- - 在 `KFVideoEncoder``sampleBufferOutputCallBack` 回调中实现。
- 4)在调用 `-stop` 停止整个流程后,如果没有出现错误,封装的 MP4 文件会被存储到 `muxerConfig` 设置的路径。
## 5、用工具播放 MP4 文件
完成 Demo 后,可以将 App Document 文件夹下面的 `test.mp4` 文件拷贝到电脑上,使用 `ffplay` 播放来验证一下效果是否符合预期:
```
$ ffplay -i test.mp4
```
关于播放 MP4 文件的工具,可以参考[《FFmpeg 工具》第 2 节 ffplay 命令行工具](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484831&idx=1&sn=6bab905a5040c46b971bab05f787788b&scene=21#wechat_redirect)和[《可视化音视频分析工具》第 3.5 节 VLC 播放器](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484834&idx=1&sn=5dd9768bfc0d01ca1b036be8dd2f5fa1&scene=21#wechat_redirect)。
我们还可以用[《可视化音视频分析工具》第 3.1 节 MP4Box.js](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484834&idx=1&sn=5dd9768bfc0d01ca1b036be8dd2f5fa1&scene=21#wechat_redirect) 等工具来查看它的格式:
![图片](https://mmbiz.qpic.cn/mmbiz_png/gUnqKPeSueiavb3sxPjmibY0C1fFYXUXcI1CP7x0G6f5eNZ4G8pcEojOya39XgK1icVz7MCTQrlG0micRUkF84wfpA/640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1)Demo 生成的 MP4 文件结构
原文链接:https://mp.weixin.qq.com/s/W17eLiUeCszNM8Kg-rlmBg

@ -0,0 +1,79 @@
# iOS 入门(2):管理第三方库
## **1、安装 CocoaPods**
代码复用是提高工程开发效率的重要方法,使用第三方库就是一种普遍的方式。在 iOS 开发中使用最广泛的管理第三方库的方案就是使用 CocoaPods。
1)安装 Ruby 环境。CocoaPods 是使用 Ruby 实现的,可以通过 gem 命令来安装,Mac OS X 中一般自带 Ruby 环境。接下来将默认的 RubyGems 替换为淘宝的 RubyGems 镜像,速度要快很多。
```text
$ sudo gem sources -a https://ruby.taobao.org/$ sudo gem sources -r https://rubygems.org/$ sudo gem sources -l
```
2)安装 CocoaPods。
```text
$ sudo gem update$ sudo gem install -n /usr/local/bin cocoapods -v 0.39$ pod setup$ pod --version
```
## **2、在当前项目中引入 CocoaPods 和第三方库**
1)安装好 CocoaPods 后,接着我们前面讲的项目,在项目的根目录下创建一个名为 `Podfile` 的文件。
![img](https://pic2.zhimg.com/80/v2-280f081b8ed28045bf7f4b77c2c7b7d1_720w.webp)
在文件中添加如下内容:
```text
source 'https://github.com/CocoaPods/Specs.git'platform :ios, "8.0"target "iOSStartDemo" do pod 'SVProgressHUD', '1.1.3' pod 'Masonry', '0.6.3'end
```
代码解释:我们通过 CocoaPods 引用了两个第三方库:`SVProgressHUD` 一个展示各种类型提示信息的库;`Masonry` 是一个封装了 Autolayout API 使得它们更易使用的库。
2)在 Terminal 命令行中进入项目的根目录(即上面创建的 Podfile 所在的目录)。执行下列命令来安装第三方库:
```text
$ pod install
```
如果成功执行,将会为你生成一个 `iOSStartDemo.xcworkspace` 文件。如果你在 Xcode 中已经打开了 iOSStartDemo 项目,那么先关闭它,然后双击 iOSStartDemo.xcworkspace 文件或者在命令行下执行:
```text
$ open iOSStartDemo.xcworkspace
```
即可用 Xcode 打开新的项目。
![img](https://pic2.zhimg.com/80/v2-0404563fd9389c57d0d96f3d7bb19b75_720w.webp)
## **3、在代码中使用三方库。**
修改 `STMainViewController.m` 代码如下:
```text
#import "STMainViewController.h"#import <Masonry/Masonry.h>#import <SVProgressHUD/SVProgressHUD.h>@interface STMainViewController ()@end@implementation STMainViewController#pragma mark - Lifecycle- (void)viewDidLoad { [super viewDidLoad]; // Do any additional setup after loading the view, typically from a nib. // Setup. [self setupUI];}#pragma mark - Setup- (void)setupUI { // Hello button. UIButton *helloButton = [UIButton buttonWithType:UIButtonTypeSystem]; [helloButton setTitle:@"Hello" forState:UIControlStateNormal]; [helloButton addTarget:self action:@selector(onHelloButtonClicked:) forControlEvents:UIControlEventTouchUpInside]; [self.view addSubview:helloButton]; [helloButton mas_makeConstraints:^(MASConstraintMaker *make) { make.width.equalTo(@60.0); make.height.equalTo(@40.0); make.center.equalTo(self.view); }];}#pragma mark - Action- (void)onHelloButtonClicked:(id)sender { NSLog(@"Hello, world!"); [SVProgressHUD showSuccessWithStatus:@"Hello, world!" maskType:SVProgressHUDMaskTypeBlack];}@end
```
代码解释:通过 `#import <Masonry/Masonry.h>``#import <SVProgressHUD/SVProgressHUD.h>` 引用第三方库。将 `helloButton` 的布局代码用 `Masonry` 重写;用 `SVProgressHUD` 替代 `UIAlertController` 展示信息。
修改后你看到的界面如下:
![img](https://pic3.zhimg.com/80/v2-18bbd0de0ef8d0d403b1223efbc614a6_720w.webp)
最后,我一直认为对于一门语言的初学者来说,了解该语言的标准编码风格是十分紧要的事情之一,这样可以使得你的代码与周围的环境和谐一致,也能便于你去了解这门语言的一些设计思想。如果你想要了解 Objective-C 的编码风格,你可以看看:[Objective-C 编码风格指南](https://link.zhihu.com/?target=http%3A//www.samirchen.com/objective-c-style-guide)。
## **4、Demo**
如果你还没有下载 iOSStartDemo,请先执行下列命令下载:
```text
$ git clone https://github.com/samirchen/iOSStartDemo.git$ cd iOSStartDemo/iOSStartDemo
```
如果已经下载过了,则接着进入正确的目录并执行下列命令:
```text
$ git fetch origin s2$ git checkout s2$ pod install$ open iOSStartDemo.xcworkspace
```
原文 http://www.samirchen.com/ios-start-2/

@ -0,0 +1,241 @@
# iOS 离屏渲染探究
## 1.为什么要理解离屏渲染
离屏渲染(Offscreen rendering)对iOS开发者来说不是一个陌生的东西,项目中或多或少都会存在离屏渲染,也是面试中经常考察的知识点。一般来说,大多数人都能知道设置圆角、mask、阴影等会触发离屏渲染,但我们深入的探究一下,大家能够很清楚的知道下面几个问题吗?
- 离屏渲染是在哪一步发生的吗?
- 离屏渲染产生的原因是什么呢?
- 设置圆角一定会触发离屏渲染吗?
- 离屏渲染既然会影响性能我们为什么还要使用呢?优化方案又有那些?
今天我就带着这几个问题探究一下离屏渲染。
## 2.ios平台的渲染框架
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/7/3/17310948698d1b33~tplv-t2oaga2asx-zoom-in-crop-mark:3024:0:0:0.awebp)
## 3.Core Animation 流水线:
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/7/8/1732f1743c0078c7~tplv-t2oaga2asx-zoom-in-crop-mark:3024:0:0:0.awebp)
这是在WWDC的Advanced Graphics and Animations for iOS Apps(WWDC14 419)中有这样一张图,我们可以看到,在Application这一层中主要是CPU在操作,而到了Render Server这一层,CoreAnimation会将具体操作转换成发送给GPU的draw calls(以前是call OpenGL ES,现在慢慢转到了Metal),显然CPU和GPU双方同处于一个流水线中,协作完成整个渲染工作。我们也可以把iOS下的Core Animation可以理解为一个复合引擎,主要职责包含:渲染、构建和实现动画。
## 4.离屏渲染的定义
1. OpenGL中,GPU屏幕渲染有以下两种方式当前屏幕渲染(On-Screen Rendering):正常情况下,我们在屏幕上显示都是GPU读取帧缓冲区(Frame Buffer)渲染好的的数据,然后显示在屏幕上。流程如图:![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/7/9/1732f3543028519f~tplv-t2oaga2asx-zoom-in-crop-mark:3024:0:0:0.awebp)
2. (Off-Screen Rendering ):如果有时因为一些限制,无法把渲染结果直接写入frame buffer,而是先暂存在另外的内存区域,之后再写入frame buffer,那么这个过程被称之为离屏渲染。也就是GPU需要在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。流程如图:![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/7/9/173313b3aebc4252~tplv-t2oaga2asx-zoom-in-crop-mark:3024:0:0:0.awebp)
在上面的CoreAnimation流水线示意图中,我们可以得知主要的渲染操作是由CoreAnimation的Render Server模块,通过调用显卡驱动提供的OpenGL或Metal接口执行,对于每一层layer,Render Server会遵循“[画家算法](https://link.juejin.cn?target=https%3A%2F%2Flink.zhihu.com%2F%3Ftarget%3Dhttps%3A%2F%2Fen.wikipedia.org%2Fwiki%2FPainter%2527s_algorithm)”(由远及近),按次序输出到frame buffer,然后按照次序绘制到屏幕,当绘制完一层,就会将该层从帧缓存区中移除(以节省空间)如下图,从左至右依次输出,得到最后的显示结果。
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/7/9/1732f47f04a367c7~tplv-t2oaga2asx-zoom-in-crop-mark:3024:0:0:0.awebp)
但在某些场景下“画家算法”虽然可以逐层输出,但是无法在某一层渲染完成后,在回过头来擦除/修改某一部分,因为这一层之前的layer像素数据已经被永久覆盖了。这就意味着对于每一层的layer要么能够通过单次遍历就能完成渲染,要么就只能令开辟一块内存作为临时中转区来完成复杂的修改/裁剪等操作。
> 举例说明:对图3进行圆角和裁剪:imageView.clipsToBounds = YES,imageView.layer.cornerRadius=10时,这就不是简单的图层叠加了,图1,图2,图3渲染完成后,还要进行裁减,而且子视图layer因为父视图有圆角,也需要被裁剪,无法在某一层渲染完成之后,再回过头来擦除/改变其中的某个部分。所以不能按照正常的流程,因此苹果会先渲染好每一层,存入一个缓冲区中,即**离屏缓冲区**,然后经过层叠加和处理后,再存储到帧缓存去中,然后绘制到屏幕上,这种处理方式叫做**离屏渲染**
## 5.常见离屏渲染场景分析
使用Simulator检测项目中触发离屏渲染的图层,如下图:
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/7/9/1732f593d2a2f32d~tplv-t2oaga2asx-zoom-in-crop-mark:3024:0:0:0.awebp)
打开 Color Off-screen Rendered,同时我们可以借助Xcode或 [Reveal](https://link.juejin.cn?target=https%3A%2F%2Frevealapp.com%2F) 清楚的看到那些图层触发了离屏渲染。
关于常见的设置圆角触发离屏渲染示例说明:
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/7/9/1732f615301291d8~tplv-t2oaga2asx-zoom-in-crop-mark:3024:0:0:0.awebp)
如上图示例代码中(btn.png是一个200x300的本地图片),
- btn1设置了图片,设置了圆角,打开了clipsToBounds = YES,触发了离屏渲染,
- btn2设置了背景颜色,设置了圆角,打开了clipsToBounds = YES,没有触发离屏渲染,
- img1设置了图片,设置了圆角,打开了masksToBounds = YES,触发了离屏渲染,
- img2设置了背景颜色,设置了圆角,打开了masksToBounds = YES,没有触发离屏渲染
> 解释:btn1和img1触发了离屏渲染,原因是btn1是由它的layer和UIImageView的layer混合起来的效果(UIButton有imageView),所以设置圆角的时候会触发离屏渲染。img1设置cornerRadius和masksToBounds是不会触发离屏渲染的,如果再对img1设置背景色,则会触发离屏渲染。
根据示例可以得出只是控件设置了圆角或(圆角+裁剪)并不会触发离屏渲染,同时需要满足父layer需要裁剪时,子layer也因为父layer设置了圆角也需要被裁剪(即视图contents有内容并发生了多图层被裁剪)时才会触发离屏渲染。
苹果官方文档对于`cornerRadius`的描述:
> Setting the radius to a value greater than `0.0` causes the layer to begin drawing rounded corners on its background. By default, the corner radius does not apply to the image in the layer’s `contents` property; it applies only to the background color and border of the layer. However, setting the `masksToBounds` property to `true` causes the content to be clipped to the rounded corners.
设置`cornerRadius`大于0时,只为layer的`backgroundColor`和`border`设置圆角;而不会对layer的`contents`设置圆角,除非同时设置了`layer.masksToBounds`为`true`(对应UIView的`clipsToBounds`属性)。
## 6.圆角触发离屏渲染示意图
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/7/9/1732f78b680ed8b0~tplv-t2oaga2asx-zoom-in-crop-mark:3024:0:0:0.awebp)
> 一旦我们 **为contents设置了内容** ,无论是图片、绘制内容、有图像信息的子视图等,再加上圆角+裁剪,就会触发离屏渲染。
## 7.其他触发离屏渲染的场景:
> - 采用了光栅化的 layer (layer.shouldRasterize)
> - 使用了 mask 的 layer (layer.mask)
> - 需要进行裁剪的 layer (layer.masksToBounds /view.clipsToBounds)
> - 设置了组透明度为 YES,并且透明度不为 1 的layer (layer.allowsGroupOpacity/ layer.opacity)
> - 使用了高斯模糊
> - 添加了投影的 layer (layer.shadow*)
> - 绘制了文字的 layer (UILabel, CATextLayer, Core Text 等)
shouldRasterize 光栅化
shouldRasterize开启后,会将layer作为位图保存下来,下次直接与其他内容进行混合。这个保存的位置就是OffscreenBuffer中。这样下次需要再次渲染的时候,就可以直接拿来使用了。
shouldRasterize使用建议:
- layer不复用,没必要打开shouldRasterize
- layer不是静态的,也就是说要频繁的进行修改,没必要使用shouldRasterize
- 离屏渲染缓存内容有100ms时间限制,超过该时间的内容会被丢弃,进而无法复用
- 离屏渲染空间是屏幕像素的2.5倍,如果超过也无法复用
## 8.离屏渲染的优劣
### 8.1劣势
离屏渲染增大了系统的负担,会形象App性能。主要表现在以下几个方面:
- 离屏渲染需要额外的存储空间,渲染空间大小的上限是2.5倍的屏幕像素大小,超过无法使用离屏渲染
- 容易掉帧:一旦因为离屏渲染导致最终存入帧缓存区的时候,已经超过了16.67ms,则会出现掉帧的情况,造成卡顿
### 8.2优势
虽然离屏渲染会需要多开辟出新的临时缓存区来存储中间状态,但是对于多次出现在屏幕上的数据,可以提前渲染好,从而进行复用,这样CPU/GPU就不用做一些重复的计算。
特殊产品需求,为实现一些特殊动效果,需要多图层以及离屏缓存区保存中间状态,这种情况下就不得不使用离屏渲染。比如产品需要实现高斯模糊,无论自定义高斯模糊还是调用系统API都会触发离屏渲染。
## 9.离屏渲染优化方案(关于实现圆角造成的离屏渲染优化)
方案一
```gml
self.view.layer.clipsToBounds = YES;self.view.layer.cornerRadius = 4.f;复制代码
```
> - clipsToBounds:UIView中的属性,其值主要决定了在视图上的子视图,超出父视图的部分是否截取,默认为NO,即不裁剪子视图超出部分。
> - masksToBounds:CALayer中的属性,其值主要决定了视图的图层上的子图层,超出父图层的部分是否需要裁减掉。默认NO。
方案二
> 如果产品设计圆角+阴影的卡片,可以使用切图实现圆角+阴影,避免触发离屏渲染
方案三
> 贝塞尔曲线绘制圆角
```objectivec
- (UIImage *)imageWithCornerRadius:(CGFloat)radius ofSize:(CGSize)size{
/* 当前UIImage的可见绘制区域 */
CGRect rect = (CGRect){0.f,0.f,size};
/* 创建基于位图的上下文 */
UIGraphicsBeginImageContextWithOptions(size, NO, UIScreen.mainScreen.scale);
/* 在当前位图上下文添加圆角绘制路径 */
CGContextAddPath(UIGraphicsGetCurrentContext(), [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:radius].CGPath);
/* 当前绘制路径和原绘制路径相交得到最终裁剪绘制路径 */
CGContextClip(UIGraphicsGetCurrentContext());
/* 绘制 */
[self drawInRect:rect];
/* 取得裁剪后的image */
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
/* 关闭当前位图上下文 */
UIGraphicsEndImageContext();
return image;
}复制代码
```
方案四
> CAShapeLayer + UIBezierPath 绘制圆角来实现UITableViewCell圆角并绘制边框颜色(这种方式比直接设置圆角方式好,但也会触发离屏渲染),代码如下:
```swift
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath{
CAShapeLayer *maskLayer = [CAShapeLayer layer];
maskLayer.frame = CGRectMake(0, 0, cell.width, cell.height);
CAShapeLayer *borderLayer = [CAShapeLayer layer];
borderLayer.frame = CGRectMake(0, 0, cell.width, cell.height);
borderLayer.lineWidth = 1.f;
borderLayer.strokeColor = COLOR_LINE.CGColor;
borderLayer.fillColor = [UIColor clearColor].CGColor;
UIBezierPath *bezierPath = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, cell.width, cell.height) cornerRadius:kRadiusCard];
maskLayer.path = bezierPath.CGPath;
borderLayer.path = bezierPath.CGPath;
[cell.contentView.layer insertSublayer:borderLayer atIndex:0];
[cell.layer setMask:maskLayer];
}
```
> 关于方案四的解释:
>
> - CAShapeLayer继承于CALayer,因而可以使用CALayer的所有属性值;
> - CAShapeLayer需要和贝塞尔曲线配合使用才能够实现效果;
> - CAShapeLayer(属于CoreAnimation)与贝塞尔曲线配合使用可以实现不在view的drawRect(继承于CoreGraphics走的是CPU,消耗的性能较大)方法中画出想要的图形;
> - CAShapeLayer动画渲染是驱动GPU,而view的drawRect方法使用CPU渲染,相比其效率更高,消耗内存更少。
>
> 总的来说使用CAShapeLayer的内存消耗少,渲染速度快。
YYKit是开发中经常用的三方库,YYImage对图片圆角的处理方法是值得推荐的,附上实现源码:
```swift
- (UIImage *)imageByRoundCornerRadius:(CGFloat)radius
corners:(UIRectCorner)corners
borderWidth:(CGFloat)borderWidth
borderColor:(UIColor *)borderColor
borderLineJoin:(CGLineJoin)borderLineJoin {
if (corners != UIRectCornerAllCorners) {
UIRectCorner tmp = 0;
if (corners & UIRectCornerTopLeft) tmp |= UIRectCornerBottomLeft;
if (corners & UIRectCornerTopRight) tmp |= UIRectCornerBottomRight;
if (corners & UIRectCornerBottomLeft) tmp |= UIRectCornerTopLeft;
if (corners & UIRectCornerBottomRight) tmp |= UIRectCornerTopRight;
corners = tmp;
}
UIGraphicsBeginImageContextWithOptions(self.size, NO, self.scale);
CGContextRef context = UIGraphicsGetCurrentContext();
CGRect rect = CGRectMake(0, 0, self.size.width, self.size.height);
CGContextScaleCTM(context, 1, -1);
CGContextTranslateCTM(context, 0, -rect.size.height);
CGFloat minSize = MIN(self.size.width, self.size.height);
if (borderWidth < minSize / 2) {
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectInset(rect, borderWidth, borderWidth) byRoundingCorners:corners cornerRadii:CGSizeMake(radius, borderWidth)];
[path closePath];
CGContextSaveGState(context);
[path addClip];
CGContextDrawImage(context, rect, self.CGImage);
CGContextRestoreGState(context);
}
if (borderColor && borderWidth < minSize / 2 && borderWidth > 0) {
CGFloat strokeInset = (floor(borderWidth * self.scale) + 0.5) / self.scale;
CGRect strokeRect = CGRectInset(rect, strokeInset, strokeInset);
CGFloat strokeRadius = radius > self.scale / 2 ? radius - self.scale / 2 : 0;
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:strokeRect byRoundingCorners:corners cornerRadii:CGSizeMake(strokeRadius, borderWidth)];
[path closePath];
path.lineWidth = borderWidth;
path.lineJoinStyle = borderLineJoin;
[borderColor setStroke];
[path stroke];
}
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return image;
}
```
原文链接:https://juejin.cn/post/6847902222567604231

@ -0,0 +1,327 @@
# iOSAVDemo(10):视频解封装,从 MP4 解出 H.264/H.265
iOS/Android 客户端开发同学如果想要开始学习音视频开发,最丝滑的方式是对[音视频基础概念知识](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2140155659944787969#wechat_redirect)有一定了解后,再借助 iOS/Android 平台的音视频能力上手去实践音视频的`采集 → 编码 → 封装 → 解封装 → 解码 → 渲染`过程,并借助[音视频工具](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2216997905264082945#wechat_redirect)来分析和理解对应的音视频数据。
在[音视频工程示例](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MjM5MTkxOTQyMQ==&action=getalbum&album_id=2273301900659851268#wechat_redirect)这个栏目,我们将通过拆解`采集 → 编码 → 封装 → 解封装 → 解码 → 渲染`流程并实现 Demo 来向大家介绍如何在 iOS/Android 平台上手音视频开发。
这里是第十篇:**iOS 视频解封装 Demo**。这个 Demo 里包含以下内容:
- 1)实现一个视频解封装模块;
- 2)实现对 MP4 文件中视频部分的解封装逻辑并将解封装后的编码数据存储为 H.264/H.265 文件;
- 3)详尽的代码注释,帮你理解代码逻辑和原理。
在本文中,我们将详解一下 Demo 的具体实现和源码。读完本文内容相信就能帮你掌握相关知识。
## 1、视频解封装模块
视频解封装模块即 `KFMP4Demuxer`,复用了[《iOS 音频解封装 Demo》](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484932&idx=1&sn=04fa6fb220574c0a5d417f4b527c0142&scene=21#wechat_redirect)中介绍的 demuxer,这里就不再重复介绍了,其接口如下:
```
KFMP4Demuxer.h
#import <Foundation/Foundation.h>
#import <CoreMedia/CoreMedia.h>
#import "KFDemuxerConfig.h"
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, KFMP4DemuxerStatus) {
KFMP4DemuxerStatusUnknown = 0,
KFMP4DemuxerStatusRunning = 1,
KFMP4DemuxerStatusFailed = 2,
KFMP4DemuxerStatusCompleted = 3,
KFMP4DemuxerStatusCancelled = 4,
};
@interface KFMP4Demuxer : NSObject
+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConfig:(KFDemuxerConfig *)config;
@property (nonatomic, strong, readonly) KFDemuxerConfig *config;
@property (nonatomic, copy) void (^errorCallBack)(NSError *error);
@property (nonatomic, assign, readonly) BOOL hasAudioTrack; // 是否包含音频数据。
@property (nonatomic, assign, readonly) BOOL hasVideoTrack; // 是否包含视频数据。
@property (nonatomic, assign, readonly) CGSize videoSize; // 视频大小。
@property (nonatomic, assign, readonly) CMTime duration; // 媒体时长。
@property (nonatomic, assign, readonly) CMVideoCodecType codecType; // 编码类型。
@property (nonatomic, assign, readonly) KFMP4DemuxerStatus demuxerStatus; // 解封装器状态。
@property (nonatomic, assign, readonly) BOOL audioEOF; // 是否音频结束。
@property (nonatomic, assign, readonly) BOOL videoEOF; // 是否视频结束。
@property (nonatomic, assign, readonly) CGAffineTransform preferredTransform; // 图像的变换信息。比如:视频图像旋转。
- (void)startReading:(void (^)(BOOL success, NSError *error))completeHandler; // 开始读取数据解封装。
- (void)cancelReading; // 取消读取。
- (BOOL)hasAudioSampleBuffer; // 是否还有音频数据。
- (CMSampleBufferRef)copyNextAudioSampleBuffer CF_RETURNS_RETAINED; // 拷贝下一份音频采样。
- (BOOL)hasVideoSampleBuffer; // 是否还有视频数据。
- (CMSampleBufferRef)copyNextVideoSampleBuffer CF_RETURNS_RETAINED; // 拷贝下一份视频采样。
@end
NS_ASSUME_NONNULL_END
```
## 2、解封装 MP4 文件中的视频部分存储为 H.264/H.265 文件
我们还是在一个 ViewController 中来实现对一个 MP4 文件解封装、获取其中的视频编码数据并存储为 H.264/H.265 文件。
```
KFVideoDemuxerViewController.m
#import "KFVideoDemuxerViewController.h"
#import "KFMP4Demuxer.h"
@interface KFVideoPacketExtraData : NSObject
@property (nonatomic, strong) NSData *sps;
@property (nonatomic, strong) NSData *pps;
@property (nonatomic, strong) NSData *vps;
@end
@implementation KFVideoPacketExtraData
@end
@interface KFVideoDemuxerViewController ()
@property (nonatomic, strong) KFDemuxerConfig *demuxerConfig;
@property (nonatomic, strong) KFMP4Demuxer *demuxer;
@property (nonatomic, strong) NSFileHandle *fileHandle;
@end
@implementation KFVideoDemuxerViewController
#pragma mark - Property
- (KFDemuxerConfig *)demuxerConfig {
if (!_demuxerConfig) {
_demuxerConfig = [[KFDemuxerConfig alloc] init];
// 只解封装视频。
_demuxerConfig.demuxerType = KFMediaVideo;
// 待解封装的资源。
NSString *videoPath = [[NSBundle mainBundle] pathForResource:@"input" ofType:@"mp4"];
_demuxerConfig.asset = [AVAsset assetWithURL:[NSURL fileURLWithPath:videoPath]];
}
return _demuxerConfig;
}
- (KFMP4Demuxer*)demuxer {
if (!_demuxer) {
_demuxer = [[KFMP4Demuxer alloc] initWithConfig:self.demuxerConfig];
_demuxer.errorCallBack = ^(NSError* error) {
NSLog(@"KFMP4Demuxer error:%zi %@", error.code, error.localizedDescription);
};
}
return _demuxer;
}
- (NSFileHandle *)fileHandle {
if (!_fileHandle) {
NSString *videoPath = [[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject] stringByAppendingPathComponent:@"output.h264"];
[[NSFileManager defaultManager] removeItemAtPath:videoPath error:nil];
[[NSFileManager defaultManager] createFileAtPath:videoPath contents:nil attributes:nil];
_fileHandle = [NSFileHandle fileHandleForWritingAtPath:videoPath];
}
return _fileHandle;
}
#pragma mark - Lifecycle
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
self.title = @"Video Demuxer";
UIBarButtonItem *startBarButton = [[UIBarButtonItem alloc] initWithTitle:@"Start" style:UIBarButtonItemStylePlain target:self action:@selector(start)];
self.navigationItem.rightBarButtonItems = @[startBarButton];
}
#pragma mark - Action
- (void)start {
__weak typeof(self) weakSelf = self;
NSLog(@"KFMP4Demuxer start");
[self.demuxer startReading:^(BOOL success, NSError * _Nonnull error) {
if (success) {
// Demuxer 启动成功后,就可以从它里面获取解封装后的数据了。
[weakSelf fetchAndSaveDemuxedData];
} else {
NSLog(@"KFMP4Demuxer error: %zi %@", error.code, error.localizedDescription);
}
}];
}
#pragma mark - Utility
- (void)fetchAndSaveDemuxedData {
// 异步地从 Demuxer 获取解封装后的 H.264/H.265 编码数据。
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (self.demuxer.hasVideoSampleBuffer) {
CMSampleBufferRef videoBuffer = [self.demuxer copyNextVideoSampleBuffer];
if (videoBuffer) {
[self saveSampleBuffer:videoBuffer];
CFRelease(videoBuffer);
}
}
if (self.demuxer.demuxerStatus == KFMP4DemuxerStatusCompleted) {
NSLog(@"KFMP4Demuxer complete");
}
});
}
- (KFVideoPacketExtraData *)getPacketExtraData:(CMSampleBufferRef)sampleBuffer {
// 从 CMSampleBuffer 中获取 extra data。
if (!sampleBuffer) {
return nil;
}
// 获取编码类型。
CMVideoCodecType codecType = CMVideoFormatDescriptionGetCodecType(CMSampleBufferGetFormatDescription(sampleBuffer));
KFVideoPacketExtraData *extraData = nil;
if (codecType == kCMVideoCodecType_H264) {
// 获取 H.264 的 extra data:sps、pps。
CMFormatDescriptionRef format = CMSampleBufferGetFormatDescription(sampleBuffer);
size_t sparameterSetSize, sparameterSetCount;
const uint8_t *sparameterSet;
OSStatus statusCode = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 0, &sparameterSet, &sparameterSetSize, &sparameterSetCount, 0);
if (statusCode == noErr) {
size_t pparameterSetSize, pparameterSetCount;
const uint8_t *pparameterSet;
OSStatus statusCode = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 1, &pparameterSet, &pparameterSetSize, &pparameterSetCount, 0);
if (statusCode == noErr) {
extraData = [[KFVideoPacketExtraData alloc] init];
extraData.sps = [NSData dataWithBytes:sparameterSet length:sparameterSetSize];
extraData.pps = [NSData dataWithBytes:pparameterSet length:pparameterSetSize];
}
}
} else if (codecType == kCMVideoCodecType_HEVC) {
// 获取 H.265 的 extra data:vps、sps、pps。
CMFormatDescriptionRef format = CMSampleBufferGetFormatDescription(sampleBuffer);
size_t vparameterSetSize, vparameterSetCount;
const uint8_t *vparameterSet;
if (@available(iOS 11.0, *)) {
OSStatus statusCode = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(format, 0, &vparameterSet, &vparameterSetSize, &vparameterSetCount, 0);
if (statusCode == noErr) {
size_t sparameterSetSize, sparameterSetCount;
const uint8_t *sparameterSet;
OSStatus statusCode = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(format, 1, &sparameterSet, &sparameterSetSize, &sparameterSetCount, 0);
if (statusCode == noErr) {
size_t pparameterSetSize, pparameterSetCount;
const uint8_t *pparameterSet;
OSStatus statusCode = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(format, 2, &pparameterSet, &pparameterSetSize, &pparameterSetCount, 0);
if (statusCode == noErr) {
extraData = [[KFVideoPacketExtraData alloc] init];
extraData.vps = [NSData dataWithBytes:vparameterSet length:vparameterSetSize];
extraData.sps = [NSData dataWithBytes:sparameterSet length:sparameterSetSize];
extraData.pps = [NSData dataWithBytes:pparameterSet length:pparameterSetSize];
}
}
}
} else {
// 其他编码格式。
}
}
return extraData;
}
- (BOOL)isKeyFrame:(CMSampleBufferRef)sampleBuffer {
CFArrayRef array = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true);
if (!array) {
return NO;
}
CFDictionaryRef dic = (CFDictionaryRef)CFArrayGetValueAtIndex(array, 0);
if (!dic) {
return NO;
}
// 检测 sampleBuffer 是否是关键帧。
BOOL keyframe = !CFDictionaryContainsKey(dic, kCMSampleAttachmentKey_NotSync);
return keyframe;
}
- (void)saveSampleBuffer:(CMSampleBufferRef)sampleBuffer {
// 将编码数据存储为文件。
// iOS 的 VideoToolbox 编码和解码只支持 AVCC/HVCC 的码流格式。但是 Android 的 MediaCodec 只支持 AnnexB 的码流格式。这里我们做一下两种格式的转换示范,将 AVCC/HVCC 格式的码流转换为 AnnexB 再存储。
// 1、AVCC/HVCC 码流格式:[extradata]|[length][NALU]|[length][NALU]|...
// VPS、SPS、PPS 不用 NALU 来存储,而是存储在 extradata 中;每个 NALU 前有个 length 字段表示这个 NALU 的长度(不包含 length 字段),length 字段通常是 4 字节。
// 2、AnnexB 码流格式:[startcode][NALU]|[startcode][NALU]|...
// 每个 NAL 前要添加起始码:0x00000001;VPS、SPS、PPS 也都用这样的 NALU 来存储,一般在码流最前面。
if (sampleBuffer) {
NSMutableData *resultData = [NSMutableData new];
uint8_t nalPartition[] = {0x00, 0x00, 0x00, 0x01};
// 关键帧前添加 vps(H.265)、sps、pps。这里要注意顺序别乱了。
if ([self isKeyFrame:sampleBuffer]) {
KFVideoPacketExtraData *extraData = [self getPacketExtraData:sampleBuffer];
if (extraData.vps) {
[resultData appendBytes:nalPartition length:4];
[resultData appendData:extraData.vps];
}
[resultData appendBytes:nalPartition length:4];
[resultData appendData:extraData.sps];
[resultData appendBytes:nalPartition length:4];
[resultData appendData:extraData.pps];
}
// 获取编码数据。这里的数据是 AVCC/HVCC 格式的。
CMBlockBufferRef dataBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
size_t length, totalLength;
char *dataPointer;
OSStatus statusCodeRet = CMBlockBufferGetDataPointer(dataBuffer, 0, &length, &totalLength, &dataPointer);
if (statusCodeRet == noErr) {
size_t bufferOffset = 0;
static const int NALULengthHeaderLength = 4;
// 拷贝编码数据。
while (bufferOffset < totalLength - NALULengthHeaderLength) {
// 通过 length 字段获取当前这个 NALU 的长度。
uint32_t NALUnitLength = 0;
memcpy(&NALUnitLength, dataPointer + bufferOffset, NALULengthHeaderLength);
NALUnitLength = CFSwapInt32BigToHost(NALUnitLength);
// 拷贝 AnnexB 起始码字节。
[resultData appendData:[NSData dataWithBytes:nalPartition length:4]];
// 拷贝这个 NALU 的字节。
[resultData appendData:[NSData dataWithBytes:(dataPointer + bufferOffset + NALULengthHeaderLength) length:NALUnitLength]];
// 步进。
bufferOffset += NALULengthHeaderLength + NALUnitLength;
}
}
[self.fileHandle writeData:resultData];
}
}
@end
```
上面是 `KFVideoDemuxerViewController` 的实现,其中主要包含这几个部分:
- 1)设置好待解封装的资源。
- - 在 `-demuxerConfig` 中实现,我们这里是一个 MP4 文件。
- 2)启动解封装器。
- - 在 `-start` 中实现。
- 3)读取解封装后的音频编码数据并存储为 H.264/H.265 文件。
- - 在 `-fetchAndSaveDemuxedData``-saveSampleBuffer:` 中实现。
- 需要注意的是,我们从解封装器读取的视频 H.264/H.265 编码数据是 AVCC/HVCC 码流格式,我们在这里示范了将 AVCC/HVCC 格式的码流转换为 AnnexB 再存储的过程。这个在前面的[《iOS 视频编码 Demo》](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257485273&idx=1&sn=0d876a49c4e46f369f6a578856221f5d&scene=21#wechat_redirect)中已经介绍过了。
## 3、用工具播放 H.264/H.265 文件
完成视频解封装后,可以将 App Document 文件夹下面的 `output.h264``output.h265` 文件拷贝到电脑上,使用 `ffplay` 播放来验证一下视频解封装的效果是否符合预期:
```
$ ffplay -i output.h264
$ ffplay -i output.h265
```
关于播放 H.264/H.265 文件的工具,可以参考[《FFmpeg 工具》第 2 节 ffplay 命令行工具](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484831&idx=1&sn=6bab905a5040c46b971bab05f787788b&scene=21#wechat_redirect)和[《可视化音视频分析工具》第 2.1 节 StreamEye](https://mp.weixin.qq.com/s?__biz=MjM5MTkxOTQyMQ==&mid=2257484834&idx=1&sn=5dd9768bfc0d01ca1b036be8dd2f5fa1&scene=21#wechat_redirect)。
原文链接:https://mp.weixin.qq.com/s/4Ua9PZllWRLYF79hwsH0DQ

@ -0,0 +1,108 @@
# iOS动画系列之三:Core Animation
## 1. 介绍
- Core Animation是一个非常强大的动画处理 API,使用它能做出非常绚丽的动画效果,而且往往是事半功倍,也就是说,使用少量的代码就可以实现非常强大的功能。
- 苹果封装的 UIView 的 block 动画就是对核心动画的封装,使用起来更加简单。
- 绝大多数情况下,使用 UIView 的 block 动画能够满足开发中的日常需求。
- 一些很酷炫的动画,还是需要通过核心动画来完成的。
## 2. 支持的平台
- Core Animation 同时支持 MAC OS 和 iOS 平台
- Core Animation 是直接作用在 CALayer 的,并非 UIView。所以这个系列,咱们是从CALayer开始的。
- Core Animation 的动画执行过程都是在后台操作的,不会阻塞主线程。
## 3. `Core Animation` 的继承结构图
- 是所有动画对象的父类,负责控制动画的持续时间和速度、是个抽象类,不能直接使用,应该使用具体子类。需要注意的是`CAAnimation` 和 `CAPropertyAnimation` 都是抽象类。
- view是负责响应事件的,layer是负责显示的。
下面盗用了一张网络上的图片用来解释继承结构。
![继承结构图](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2017/8/13/700e6bd0a70d6bba9d2233ad1e424930~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.image)继承结构图
黄色的区块是常用的属性、方法或者需要遵守的协议,灰色的是名称。
其中CAAnimationGroup、CABasicAnimation、CAKeyFramkeAnimation咱们会在下次更新中写一些小例子。
## 4. 常见属性和使用步骤
### 4.1 使用步骤
通常分成三部完成:
1,创建核心动画对象;
2,设置动画属性;
3,添加到要作用的layer上。
就想把大象放进冰箱需要三步一样。哈哈~
### 4.2 常用属性
就是咱们上面图片中的小黄图显示的。
- `duration`:持续时间,默认值是0.25秒
- `repeatCount`:重复次数,无线循环可以设置HUGE_VALF或者CGFLOAT_MAX
- `repeatDuration`:重复时间
- `removeOnCompletion`: 默认为YES,代表动画执行完毕后就从图层上移除,图形会恢复到执行动画之前的状态。*如果想要图层保持显示动画执行后的状态,那就设置为NO,同时设置fillMode为kCAFillModeForwards*
- `fillMode`:决定当前对象在非active时间段的行为
- `beginTime`:可以用来设置动画延时执行,若想延迟2s,就设置为`CACurrentMediaTIme() + 2`
- `CACurrentMediaTIme()`:图层的当前时间
- `timingFunction`:速度控制函数,控制动画运行节奏
- `delegate`:动画代理
### 4.3 animationWithKeyPath中,常用的keyPath
| 属性名称 | 类型 | 作用 |
| ----------------------- | ---------------- | -------------------------------- |
| transform.rotation.x | CGFloat或float | 绕X轴坐标旋转 角度 |
| transform.rotation.y | CGFloat或float | 绕Y轴坐标旋转 角度 |
| transform.rotation.z | CGFloat或float | 绕Z轴坐标旋转 角度 |
| transform.rotation | CGFloat或float | 作用与transform.tation.z一样 |
| ---- | ---- | ---- |
| transform.scale | CGFloat | 整个layer的比例 |
| transform.scale.x | CGFloat | x轴坐标比例变化 |
| transform.scale.y | CGFloat | y轴坐标比例变化 |
| transform.scale.z | CGFloat | z轴坐标比例变化 |
| ---- | ---- | ---- |
| transform.translation | CGMutablePathRef | 整个layer的xyz轴都进行移动 |
| transform.translation.x | CGMutablePathRef | 横向移动 |
| transform.translation.y | CGMutablePathRef | 纵向移动 |
| transform.translation.z | CGMutablePathRef | 纵深移动 |
| ---- | ---- | ---- |
| opacity | CGFloat | 透明度,闪烁等动画用 。范围是0~1 |
| backgroundColor | CGColor | 背景颜色 |
| cornerRadius | CGFloat | 圆角 |
### 4.4 动画填充模式
- kCAFillModeForwards:当动画结束后,layer会一直保持着动画最后的状态
- kCAFillModeBackwards:在动画开始前,只需要将动画加入了一个layer,layer便立即进入动画的初始状态并等待动画开始
- kCAFillModeBoth:这个其实就是上面两个合成,动画加入后,开始之前,layer便处于动画初始状态,动画结束后layer保持动画最后的状态
- kCAFillModeRemoved:这个是默认值,也就是说当动画开始前和动画结束后,动画对layer都没有影响,动画结束后,layer会恢复到之前的状态
```ini
keyArc.calculationMode = kCAAnimationPaced
```
### 4.5 速度控制函数
- kCAMediaTimingFunctionLinear(线性):匀速,给你一个相对静态的感觉
- kCAMediaTimingFunctionEaseIn(渐进):动画缓慢进入,然后加速离开
- kCAMediaTimingFunctionEaseOut(渐出):动画全速进入,然后减速的到达目的地
- kCAMediaTimingFunctionEaseInEaseOut(渐进渐出):动画缓慢的进入,中间加速,然后减速的到达目的地。这个是默认的动画行为。
```ini
keyArc.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
```
原文链接:https://juejin.cn/post/6844903490980954119

@ -0,0 +1,209 @@
# iOS图像渲染及卡顿问题优化
## 1.基本知识
下面来看下GPU和CPU的基本概念:
- **CPU(Central Processing Unit)**:系统的运算和控制单元,是信息处理、程序执行的最终执行单元。CPU内部结构是具有一定程度的并行计算能力。CPU的主要功效是:处理指令、执行操作、控制时间、处理数据。
- **GPU(Graphics Processing Unit)**:进行绘图工作的处理器,GPU可以生成2D/3D图形图像和视频,同时GPU具有超强的并行计算能力。GPU使显卡减少了对CPU的依赖,并进行部分原本CPU的工作,尤其是在3D图形处理时GPU所采用的核心技术有硬件T&L(几何转换和光照处理)、立方环境材质贴图和顶点混合、纹理压缩和凹凸映射贴图、双重纹理四像素256位渲染引擎等,其中GPU的生产商主要有NVIDIA和ATI。
## 2.CPU-GPU工作流
### 2.1工作流
当CPU遇到图像处理时,会调用GPU进行处理,主要流程可以分为如下四步:
1. 将主存的处理数据复制到显存中
2. CPU指令驱动GPU
3. GPU中每个运算单元并行处理
4. GPU将显存结果传回主存
![img](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5057f71d1c1d4ce68cc8fd799da1730c~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
### 2.2屏幕成像显示原理
如果要研究图片显示原理,需要先从 CRT 显示器原理说起,如下经典图所示。CRT 的电子枪从上到下逐行扫描,扫描完成后显示器就呈现一帧画面。然后电子枪回到初始位置进行下一次扫描。为了同步显示器的显示过程和系统的视频控制器,显示器会用硬件时钟产生一系列的定时信号。当电子枪换行进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称 HSync;而当一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(vertical synchronization),简称 VSync。显示器通常以固定频率进行刷新,这个刷新率就是 VSync 信号产生的频率。虽然现在的显示器基本都是液晶显示屏了,但其原理基本一致。
```
拓展:CRT显示器学名为“阴极射线显像管”,是一种使用阴极射线管(Cathode Ray Tube)的显示器。
复制代码
```
![img](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1db4a9f9d1ee45bdbde9a3a1bba6f9fc~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
下图CPU、GPU、显示器工作方式。CPU计算好显示内容提交到GPU,GPU渲染完成后将渲染结果存入到帧缓冲区,视频控制器会按照VSync信号逐帧读取帧缓冲区的数据,经过数据转换后最终由显示器进行显示。
![img](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/16e9f44698a948ddb40c47f884a0009e~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
最简单的时候,帧缓冲区只有一个。此时,帧缓冲区和读取和刷新都会有比较大的效率问题。为了解决效率问题,GPU通常会引入两个缓冲区,即**双缓冲机制**,即这种情况下,GPU会预先渲染一帧放入缓冲区中,用于视频控制器的读取,当下一帧渲染完毕后,GPU会直接把视频控制器的指针指向第二个缓冲区。
双缓冲虽然能解决效率问题,但会引入一个新的问题。当视频控制器还未读取完成时,即屏幕内容刚显示一半时,GPU 将新的一帧内容提交到帧缓冲区并把两个缓冲区进行交换后,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂现象,如下图
![img](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9bd1da7ed73242baadf375a126154331~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
为了解决这个问题,GPU 通常有一个机制叫做垂直同步(简写也是 V-Sync),当开启垂直同步后,GPU 会等待显示器的 VSync 信号发出后,才进行新的一帧渲染和缓冲区更新。这样能解决画面撕裂现象,也增加了画面流畅度,但需要消费更多的计算资源,也会带来部分延迟。
## 3.iOS 渲染框架
iOS为开发者提供了丰富的Framework(UIKit,Core Animation,Core Graphic,OpenGL 等等)来满足开发从上到底层各种需求,下面是iOS渲染视图框架图:
![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5aade2b4426e42b1b868b28baa847748~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
可以看出iOS渲染视图的核心是 Core Animation。从底层到上层依此是 GPU->(OpenGL、Core Graphic) -> Core Animation -> UIKit。
UIKit
UIKit是iOS开发者最常用的框架,通过设置UIKit的控件来实现绘制界面,其实UIKit自身不具备屏幕成像的能力,它的主要职责是对用户操作事件的响应【继承自UIResponder】。
Core Animation
Core Animation源自Layer kit,是一个复合引擎,职责是绘制不同的可视化内容,这些图层都是在图层树的体系之中,从本质上看:CALayer是用户所能在屏幕看见的一切的基础。
Core Graphics
Core Graphics是基于Quartz绘图引擎,主要用于运行时绘制图像。可以使用此框架来处理绘图,转换,离屛渲染,图像创建,和PDF文档创建以及显示和分析。
Core Image
Core Image与Core Graphics恰恰相反,Core Graphics用于运行时创建图像,而Core Image用于处理运行前创建的图像。
大部分情况下,Core Image会在GPU中完成工作,如果GPU忙,会使用CPU进行处理。
OpenGL ES
`OpenGL ES`是`OpenGL`的子集,函数的内部实现是由厂家GPU开发实现。
Metal
苹果自己推出的图形图像处理框架。Metal类似于OpenGL ES ,也是一套第三方标准,具体实现是由苹果实现.相信大多数开发者没有直接使用过Metal, 但其实所有开发者在间接地使用Metal, Core Animation, Core Image, SceneKit, SpriteKit等等渲染框架都是在构建在Metal之上.
## 4.Core Animation流水线
介绍一下Core Animation工作原理如下:
![img](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c4737154047f48ba8abee53c54fa9cd3~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
事实上, APP本身并不负责渲染, 渲染会交给一个独立的进程负责, 即Render Sever进程.
APP通过IPC将渲染任务及相关数据提交给Render Server. Render Server处理完数据之后,再传至GPU,最后由GPU调用iOS的图像设备进行显示.
Core Animation 流水线的详细过程:
1. 首先,由App处理事件, 如点击操作, 在此过程中app可能需要更新视图树, 相应地,图层也会发生被更新
2. 其次, App通过CPU完成对显示内容的更新, 如: 视图的创建、布局计算, 图片解码,文本绘制等, 在完成对显示内容的计算之后, app会对图层打包, 并在下一次Runloop时将其发送至Render Server, 即完成了一次Commit Transaction操作
3. Render Server主要执行OpenGL, Core Graphics相关程序, 并调用GPU
4. GPU在物理层上完成对图像的渲染
5. GPU通过Frame Buffer,视频控制器等相关部件, 将图像显示在屏幕上.
它们执行所消耗的时间远远超过 16.67 ms,因此为了满足对屏幕的 60 FPS 刷新率的支持,需要将这些步骤进行分解,通过流水线的方式进行并行执行,如下图所示。
![img](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/16cff7985e9a4a679cecacc094d86659~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
### 4.1Commit Transaction
Commit Transaction ,App调用Render Server 前最后一步Commit Transaction其实可以细分4个步骤:
1. Layout
2. Display
3. Prepare
4. Commit
**Layout**
Layout 阶段主要进行视图构建, 包括LayoutSubviews方法的重载, addSubview添加子视图
**Display**
Display主要进行视图绘制, drawRect方法可以自定义UIView的现实,其原理是drawRect方法内部绘制寄宿图,过程使用到了CPU和内存
**Prepare**
Prepare阶段属于附加步骤,一般处理图像的解码和转换操作
**Commit**
Commit 用于对图层进行打包, 将它们发送至Render Server,会递归进行,因为图层和视图都是以树形结构存在
### 4.2动画渲染原理
iOS 动画的渲染也是基于上述 Core Animation 流水线完成的。这里我们重点关注 app 与 `Render Server` 的执行流程。
如果不是特别复杂的动画,一般使用 `UIView` Animation 实现,iOS 将其处理过程分为如下三部阶段:
- Step 1:调用 `animationWithDuration:animations:` 方法
- Step 2:在 Animation Block 中进行 `Layout`,`Display`,`Prepare`,`Commit` 等步骤。
- Step 3:`Render Server` 根据 Animation 逐帧进行渲染。
![img](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d50822d474084adda2152c67078f33cf~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
## 5.卡顿原因和解决方案
### 5.1卡顿原理
FPS (Frames Per Second) 表示每秒渲染帧数,通常用于衡量画面的流畅度,每秒帧数越多,则表示画面越流畅,60fps 最佳,一般我们的APP的FPS 只要保持在 50-60之间,用户体验都是比较流畅的。
![img](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c5223e72e1494a6db6864802460590e7~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
在 VSync 信号到来后,系统图形服务会通过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。随后 CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上。由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。
从上面的图中可以看到,CPU 和 GPU 不论哪个阻碍了显示流程,都会造成掉帧现象。所以开发时,也需要分别对 CPU 和 GPU 压力进行评估和优化。
### 5.2CPU优化
**1. 布局计算**
视图布局的计算是APP最消耗CPU资源的地方,如果在后台线程提前计算好视图布局,并且对视图布局进行缓存,这样就可以解决性能问题啦!一次性调整好对应属性,而不要多次、频繁的计算和调整控件的frame/bounds/center属性。
**2. 文本计算**
如果一个界面中包含大量文本(比如微博微信朋友圈等),文本的宽高计算会占用很大一部分资源,并且不可避免。如果你对文本显示没有特殊要求,可以参考下 UILabel 内部的实现方式:用 [NSAttributedString boundingRectWithSize:options:context:] 来计算文本宽高,用 -[NSAttributedString drawWithRect:options:context:] 来绘制文本。尽管这两个方法性能不错,但仍旧需要放到后台线程进行以避免阻塞主线程。
**3. 图片的绘制**
图像的绘制通常是指用那些以 CG 开头的方法把图像绘制到画布中,然后从画布创建图片并显示这样一个过程。这个最常见的地方就是 [UIView drawRect:] 里面了。由于 CoreGraphic 方法通常都是线程安全的,所以图像的绘制可以很容易的放到后台线程进行。一个简单异步绘制的过程大致如下(实际情况会比这个复杂得多,但原理基本一致):
```ini
- (void)display {
dispatch_async(backgroundQueue, ^{
CGContextRef ctx = CGBitmapContextCreate(...);
// draw in context...
CGImageRef img = CGBitmapContextCreateImage(ctx);
CFRelease(ctx);
dispatch_async(mainQueue, ^{
layer.contents = img;
});
});
}
复制代码
```
**4. 对象创建**
对象的创建会分配内存、调整属性、甚至还有读取文件等操作,比较消耗 CPU 资源。尽量用轻量的对象代替重量的对象,可以对性能有所优化。比如 CALayer 比 UIView 要轻量许多,那么不需要响应触摸事件的控件,用 CALayer 显示会更加合适。如果对象不涉及 UI 操作,则尽量放到后台线程去创建,但可惜的是包含有 CALayer 的控件,都只能在主线程创建和操作。通过 Storyboard 创建视图对象时,其资源消耗会比直接通过代码创建对象要大非常多,在性能敏感的界面里,Storyboard 并不是一个好的技术选择。
尽量推迟对象创建的时间,并把对象的创建分散到多个任务中去。尽管这实现起来比较麻烦,并且带来的优势并不多,但如果有能力做,还是要尽量尝试一下。如果对象可以复用,并且复用的代价比释放、创建新对象要小,那么这类对象应当尽量放到一个缓存池里复用。
### 5.3GPU优化
相对于 CPU 来说,GPU 能干的事情比较单一:接收提交的纹理(Texture)和顶点描述(三角形),应用变换(transform)、混合并渲染,然后输出到屏幕上。通常你所能看到的内容,主要也就是纹理(图片)和形状(三角模拟的矢量图形)两类。
**1. 纹理的渲染**
所有的Bitmap,包括图片,栅格化等的内容,最终要由内存提交到显存里面,不论是提交到显存的过程,还是渲染Texture过程都是消耗了不少的GPU。当在较短时间显示大量图片时(比如 TableView 存在非常多的图片并且快速滑动时),CPU 占用率很低,GPU 占用非常高,界面仍然会掉帧。避免这种情况的方法只能是尽量减少在短时间内大量图片的显示,尽可能将多张图片合成为一张进行显示。
**2. 视图混合**
当多个视图(或者说 CALayer)重叠在一起显示时,GPU 会首先把他们混合到一起。如果视图结构过于复杂,混合的过程也会消耗很多 GPU 资源。为了减轻这种情况的 GPU 消耗,应用应当尽量减少视图数量和层次,并在不透明的视图里标明 opaque 属性以避免无用的 Alpha 通道合成。当然,这也可以用上面的方法,把多个视图预先渲染为一张图片来显示。
**3. 图形生成**
CALayer的border、圆角、阴影以及遮罩,CASharpLayer的矢量图形显示,这样通常会造成离屏渲染,而离屏渲染通常会发生在GPU中,当一个列表有大量的圆角时候,并且快速欢动,GPU资源已经占满,而CPU资源消耗较少。
最彻底的解法是:把需要显示的图形在后台线程绘制为图片,避免使用圆角、阴影、遮罩属性等。
对于如何去监控卡顿,通过Runloop机制,可以参考掘金里面有很多文章,都是大同小异,在这就不做叙述啦!!!
原文链接:https://juejin.cn/post/6874046143160909838

@ -0,0 +1,192 @@
# iOS视图渲染与性能优化
## 1、视图渲染
视图渲染的处理层级图如下:
![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/34ae35cf609343fc96cc102b64c579de~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
UIKit是常用的框架,显示、动画都通过CoreAnimation;
CoreAnimation是核心动画,依赖于OpenGL ES做GPU渲染(目前最新的iPhone已经都使用Metal,**为了和图文一致,本文后面继续使用OpenGL ES来描述**),CoreGraphics做CPU渲染;
最底层的GraphicsHardWare是图形硬件。
视图渲染的整体流程如下: ![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3f7686424cb748159594d73a3c967e45~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
视图渲染到屏幕上需要CPU和GPU一起协作。App将一部分数据通过CoreGraphics、CoreImage调用CPU进行预处理,最终通过OpenGL ES将数据传送到 GPU,最终显示到屏幕。
## 2、渲染过程
![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/90c29bf8d22e466087f14d04d484b05f~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
渲染的具体过程可以用上图来描述:
- 1、CoreAnimation提交会话,包括自己和子树(view hierarchy)的layout状态等;
- 2、RenderServer解析提交的子树状态,生成绘制指令;
- 3、GPU执行绘制指令;
- 4、显示渲染后的数据;
其中App的Commit流程又可以分为Layout、Display、Prepare、Commit四个步骤。
![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c53940fec0624136a53d36d4e2688eee~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
### 2.1布局(Layout)
调用layoutSubviews方法;
调用addSubview:方法;
> 会造成CPU和I/O瓶颈;
### 2.2显示(Display)
通过drawRect绘制视图;
绘制string(字符串);
> 会造成CPU和内存瓶颈;
每个UIView都有CALayer,同时图层有一个像素存储空间,存放视图;调用`-setNeedsDisplay`的时候,仅会设置图层为dirty。
当渲染系统准备就绪时会调用视图的`-display`方法,同时装配像素存储空间,建立一个CoreGraphics上下文(CGContextRef),将上下文push进上下文堆栈,绘图程序进入对应的内存存储空间。
```ini
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(10, 10)];
[path addLineToPoint:CGPointMake(20, 20)];
[path closePath];
path.lineWidth = 1;
[[UIColor redColor] setStroke];
[path stroke];
复制代码
```
在-drawRect方法中实现如上代码,UIKit会将自动生成的CGContextRef 放入上下文堆栈。
当绘制完成后,视图的像素会被渲染到屏幕上;当下次再次调用视图的-setNeedsDisplay,将会再次调用-drawRect方法。
### 2.3准备提交(Prepare)
解码图片;
图片格式转换;
> 当我们使用UIImage、CGImage时,图片并没有真正解码。iOS会先用一些基础的图像信息创建对象,等到真正使用时再创建bitmap并进行解码。尽量避免使用不支持硬解的图片格式,比如说webp;
### 2.4提交(Commit)
打包layers并发送到渲染server;
递归提交子树的layers;
如果子树太复杂,会消耗很大,对性能造成影响;
> 尽可能简化viewTree;
当显示一个UIImageView时,Core Animation会创建一个OpenGL ES纹理,并确保在这个图层中的位图被上传到对应的纹理中。当你重写` -drawInContext`方法时,Core Animation会请求分配一个纹理,同时确保Core Graphics会将你在`-drawInContext`中绘制的东西放入到纹理的位图数据中。
## 3、Tile-Based 渲染
Tiled-Based 渲染是移动设备的主流。整个屏幕会分解成N*Npixels组成的瓦片(Tiles),tiles存储于SoC 缓存(SoC=system on chip,片上系统,是在整块芯片上实现一个复杂系统功能,如intel cpu,整合了集显,内存控制器,cpu运核心,缓存,队列、非核心和I/O控制器)。 几何形状会分解成若干个tiles,对于每一块tile,把必须的几何体提交到OpenGL ES,然后进行渲染(光栅化)。完毕后,将tile的数据发送回cpu。
![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b4c94a4466f346d6a134588b514a0017~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
> 传送数据是非常消耗性能的。相对来说,多次计算比多次发送数据更加经济高效,但是额外的计算也会产生一些性能损耗。
> PS:在移动平台控制帧率在一个合适的水平可以节省电能,会有效的延长电池寿命,同时会相对的提高用户体验。
### 3.1渲染流程
普通的Tile-Based渲染流程如下: ![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/eaf4ae319bcc45bdb15e6f7baea7e479~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
1、CommandBuffer,接受OpenGL ES处理完毕的渲染指令;
2、Tiler,调用顶点着色器,把顶点数据进行分块(Tiling);
3、ParameterBuffer,接受分块完毕的tile和对应的渲染参数;
4、Renderer,调用片元着色器,进行像素渲染;
5、RenderBuffer,存储渲染完毕的像素;
### 3.2离屏渲染 —— 遮罩(Mask)
![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a7a2f02c88674a9197ec4be63a6efe6d~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
1、渲染layer的mask纹理,同Tile-Based的基本渲染逻辑;
2、渲染layer的content纹理,同Tile-Based的基本渲染逻辑;
3、Compositing操作,合并1、2的纹理;
### 3.3离屏渲染 ——UIVisiualEffectView
![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0accc7bfd39143618c3461bb0b6eeccd~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp) 使用UIBlurEffect,应该是尽可能小的view,因为性能消耗巨大。
60FPS的设备,每帧只有16.67ms的时间进行处理。 ![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1102fb148b3b4e96af06491d65fa069c~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
### 3.4渲染等待
由于每一帧的顶点和像素处理相对独立,iOS会将CPU处理,顶点处理,像素处理安排在相邻的三帧中。如图,当一个渲染命令提交后,要在当帧之后的第三帧,渲染结果才会显示出来。
![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/558ee835ce2e4b7fbe5656e097a3f0c8~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
### 3.5光栅化
把视图的内容渲染成纹理并缓存,可以通过CALayer的shouldRasterize属性开启光栅化。 注意,光栅化的元素,总大小限制为2.5倍的屏幕。 更新内容时,会启用离屏渲染,所以更新代价较大,只能用于静态内容;而且如果光栅化的元素100ms没有被使用将被移除,故而不常用元素的光栅化并不会优化显示。
### 3.6组透明度
CALayer的allowsGroupOpacity属性,UIView 的alpha属性等同于 CALayer opacity属性。
当`GroupOpacity=YES`时,会先不考虑透明度,等绘制完成所有layer(自身+子layers),再统一计算透明。
假设某个视图A有一个字视图B,他们的alpha都是0.5(根视图是黑色,A和B都是白色),当我们绘制视图的时候:
如果未开启组透明,首先是绘制视图A(0.5白色),然后再绘制视图B,绘制视图B的时候是在父视图0.5白色和根视图0.5黑色的基础上叠加视图B的0.5白色,最终就是0.75白色。 ![img](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/29ef7e8ee11c4bc9a516f29c0a50e5f8~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
如果开启了组透明,首先是绘制视图A(白色),然后在A的基础上直接绘制视图B(白色),最终再统一计算透明0.5,所以A和B的颜色保持一致。(边界是特意加的,为了区分视图B)
![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e45e3486092240b09aa71e0a88ee9a48~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
> The default value is read from the boolean UIViewGroupOpacity property in the main bundle’s Info.plist file. If no value is found, the default value is YES for apps linked against the iOS 7 SDK or later and NO for apps linked against an earlier SDK.
> 为了让子视图与父视图保持同样的透明度和优化性能,从 iOS 7 以后默认全局开启了这个功能。对现在的开发者来说,几乎可以不用关注。
## 4、性能优化
这个是WWDC推荐的检查项目:
![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0f13020c1dce4d98978be2f50e99ee1b~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
1、帧率一般在多少?
> 60帧每秒;(TimeProfiler工具可以查看耗时)
2、是否存在CPU和GPU瓶颈? (查看占有率)
> 更少的使用CPU和GPU可以有效的保存电量;
3、是否额外使用CPU来进行渲染?
> 重写了drawRect会导致CPU渲染;在CPU进行渲染时,GPU大多数情况是处于等待状态;
4、是否存在过多离屏渲染?
> 越少越好;离屏渲染会导致上下文切换,GPU产生idle;
5、是否渲染过多视图?
> 视图越少越好;透明度为1的视图更受欢迎;
6、使用奇怪的图片格式和大小?
> 避免格式转换和调整图片大小;一个图片如果不被GPU支持,那么需要CPU来转换。(Xcode有对PNG图片进行特殊的算法优化)
7、是否使用昂贵的特效?
> 视图特效存在消耗,调整合适的大小;例如前面提到的UIBlurEffect;
8、是否视图树上存在不必要的元素?
> 理解视图树上所有点的必要性,去掉不必要的元素;忘记remove视图是很常见的事情,特别是当View的类比较大的时候。
![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/de7725099d01477c91df8a402c07d328~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
以上,是8个问题对应的工具。遇到性能问题,先**分析、定位问题所在**,而不是埋头钻进代码的海洋。
![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ea5287bed6b0482d9f98d62021d51cf8~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
## 5、性能优化实例
### 5.1阴影
![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8202dc6d12334d6292c2d4fc5f2924a6~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
上面的做法,会导致离屏渲染;下面的做法是正确的做法。
### 5.2圆角
![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f77b427dbea342a5bdd8e6824ff51c4f~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
不要使用不必要的mask,可以预处理图片为圆形;或者添加中间为圆形透明的白色背景视图。即使添加额外的视图,会导致额外的计算;但仍然会快一点,因为相对于切换上下文,GPU更擅长渲染。
离屏渲染会导致GPU利用率不到100%,帧率却很低。(切换上下文会产生idle time)
### 5.3工具
使用instruments的CoreAnimation工具来检查离屏渲染,黄色是我们不希望看到的颜色。
![img](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4deff2d45f6347a0bcc525e1a6f2d434~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)
> 使用真机来调试,因为模拟器使用的CALayer是OSX的CALayer,不是iOS的CALayer。如果用模拟器调试,会发现所有的视图都是黄色。
原文链接:https://juejin.cn/post/6960516630975774734

@ -0,0 +1,237 @@
# iOS逆向 MachO文件
## 1、MachO初探
#### 1.1定义
`MachO`其实是`Mach Object`文件格式的缩写,是mac以及iOS上可执行文件的格式,类似于Windows上的PE格式(Portable Executable)、Linux上的elf格式(Executable and Linking Format)
它是一种用于可执行文件、目标代码、动态库的文件格式,作为.out格式的替代,MachO提供了更强的扩展性
#### 1.2常见的MachO文件
- 目标文件.o
- 库文件
- .a
- .dylib
- .Framework
- 可执行文件
- dyld(动态链接器)
- .dsym(符号表:Relese环境运行生成)
#### 1.3查看文件类型
```
$ file xxx.xx
```
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/12/14/16f03b938a746084~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.image)
## 2、关于架构
#### 2.1架构表
其实iPhone不同的型号对应的架构是不一样的
| 架构 | 手机型号 |
| ------ | ----------------------------------------------- |
| i386 | 32位模拟器 |
| x86_64 | 64位模拟器 |
| armv7 | iPhone4、iPhone4S |
| armv7s | iPhone5、iPhone5C |
| arm64 | iPhone5s——iPhoneX |
| arm64e | iPhone XS、iPhone XS Max、iPhoneXR、iPhone11... |
#### 2.2生成多种架构
新建一个工程,真机运行,查看可执行文件仅仅是一个arm64架构的
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/12/15/16f07f9f9126dda9~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.image)
将项目最低适配系统调为iOS9.0,真机运行`Relese环境`
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/12/15/16f08008f3d55aab~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.image)
**为什么要改为iOS9.0呢**?是因为iPhone5c等armv7、armv7s架构不支持iOS11.0
**为什么要Relese环境运行呢**?因为Xcode默认Debug只生成单一架构
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/12/15/16f080382e437831~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.image)
**怎么生成所有架构**?Xcode10中只包含了v7和64,需要在`Architectures`中添加
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/12/15/16f080ececc0f693~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.image)
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/12/15/16f080d661eb4112~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.image)
## 3、通用二进制文件
#### 3.1定义
通用二进制文件(Universal binary)也被叫做`胖二进制(Fat binary)`
- 苹果公司提出的一种程序代码,能同时适用多种架构的二进制文件
- 同一个程序包中同时为多种架构提供最理想的性能
- 因为需要储存多种代码,通用二进制应用程序通常比单一平台二进制的程序要大
- 但是由于两种架构有共通的非执行资源,所以并不会达到单一版本的两倍之多
- 而且由于执行中只调用一部分代码,运行起来也不需要额外的内存
#### 3.2拆分/合并架构
架构拆分
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/12/15/16f08240102dc4d4~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.image)
合并架构
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/12/15/16f082abf93c1aab~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.image)
`通用二进制`大小为342kb,四个架构大小为80+80+80+81=321kb
What!为什么不是单纯的1+1=2?
因为不同架构之间代码部分是不共用的 (因为代码的二进制文件不同的组合在不同的 cpu 上可能会是不同的意义),而公共资源文件是公用的
> 利用上述方法可以给我们的app瘦身
**结论:**
①`胖二进制`拆分后再重组会得到原始`胖二进制`
②`通用二进制`的大小可能大于子架构大小之和,也可能小于,也可能等于,取决于`公共资源文件`的多少
#### 3.3终端命令行
```scss
// 查看二进制文件
$ lipo -info xx
// 通用二进制文件
// 拆分二进制文件
lipo xxx -thin armv7 -output xxx
// 组合二进制文件
lipo -create x1 x2 x3 x4 -output xxx
复制代码
```
## 4、MachO文件
#### 4.1整体结构
用`MachOView`打开会看到`通用二进制文件`由`Fat Header`和`四个可执行文件`组成
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/12/15/16f084564833fe48~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.image)
`可执行文件`是由`Header`、`Load commands`和`Data`组成
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/12/15/16f0845ec5968b12~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.image)
我们可以这么理解,把`通用二进制文件`看作四本翻译语言不同的书,每本书有`标题(header)`、`目录(load commands)`、`内容(data)`
- header:
- load commands:
- data:
另外我们也可以通过`otool`命令行查看MachO文件结构
```ruby
$ otool -f universe
```
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/12/15/16f08502c785de7b~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.image)
#### 4.2header
`header`包含了该二进制文件的字节顺序、架构类型、加载指令的数量等,使得可以快速确认一些信息,比如当前文件用于`32 位`还是`64 位`,对应的处理器是什么、文件类型是什么
Xcode中 `shift+command+O`->`load.h`->如下信息
```arduino
struct mach_header_64 {
uint32_t magic; /* 魔数,快速定位64位/32位 */
cpu_type_t cputype; /* cpu 类型 比如 ARM */
cpu_subtype_t cpusubtype; /* cpu 具体类型 比如arm64 , armv7 */
uint32_t filetype; /* 文件类型 例如可执行文件 .. */
uint32_t ncmds; /* load commands 加载命令条数 */
uint32_t sizeofcmds; /* load commands 加载命令大小*/
uint32_t flags; /* 标志位标识二进制文件支持的功能 , 主要是和系统加载、链接有关*/
uint32_t reserved; /* reserved , 保留字段 */
};
```
> mach_header_64(64位)对比mach_header(32位)只多了一个保留字段
#### 4.3load commands
`load commands`是一张包括区域的位置、符号表、动态符号表等内容的表。 它详细保存着加载指令的内容,告诉链接器如何去加载这个 Mach-O 文件。 通过查看内存地址我们发现,在内存中`load commands`是紧跟在`header`之后的
| 名称 | 内容 |
| --------------------- | ---------------------------------------------- |
| LC_SEGMENT_64 | 将文件中(32位或64位)的段映射到进程地址空间中 |
| LC_DYLD_INFO_ONLY | 动态链接相关信息 |
| LC_SYMTAB | 符号地址 |
| LC_DYSYMTAB | 动态链接相关信息 |
| LC_LOAD_DYLINKER | 动态链接相关信息 |
| LC_UUID | 动态链接相关信息 |
| LC_VERSION_MIN_MACOSX | 支持最低的操作系统版本 |
| LC_SOURCE_VERSION | 源代码版本 |
| LC_MAIN | 设置程序主线程的入口地址和栈大小 |
| LC_LOAD_DYLIB | 依赖库的路径,包含三方库 |
| LC_FUNCTION_STARTS | 函数起始地址表 |
| LC_CODE_SIGNATURE | 代码签名 |
#### 4.4data
`data`是MachO文件中最大的部分,其中`_TEXT段`、`_DATA段`能给到很多信息
`load commands`和`data`之间还留有不少空间,给我们留下了注入代码的冲破口
![img](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/12/15/16f0949ed460a46a~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.image)
**_TEXT段**
| 名称 | 作用 |
| -------------------- | -------------- |
| _text | 主程序代码 |
| _stubs、_stub_helper | 动态链接 |
| _objc_methodname | 方法名称 |
| _objc_classname | 类名称 |
| _objc_methtype | 方法类型(v@:) |
| _cstring | 静态字符串常量 |
**_DATA段**
| 名称 | 作用 |
| ------------------------------------ | -------------- |
| _got=>Non-Lazy Symbol Pointers | 非懒加载符号表 |
| _la_symbol_ptr=>Lazy Symbol Pointers | 懒加载符号表 |
| _objc_classlist | 方法名称 |
| ... | ... |
## 5、dyld
dyld(the dynamic link editor)是苹果的动态链接器,是苹果操作系统的一个重要组成部分,在系统内容做好程序准备工作之后,交由dyld负责余下的工作
系统库的方法由于是公用的,存放在共享缓存中,那么我们的MachO在调用系统方法时,dyld会将MachO里调用存放在共享缓存中的方法进行符号绑定。这个符号在`release环境` 是会被自动去掉的,这也是我们经常使用收集 bug 工具时需要恢复符号表的原因
原文链接:https://juejin.cn/post/6844904021010939918

@ -0,0 +1,283 @@
# iOS音视频开发-代码实现视频编码
**硬编码的优点**
- 提高编码性能(使用CPU的使用率大大降低,倾向使用GPU)
- 增加编码效率(将编码一帧的时间缩短)
- 延长电量使用(耗电量大大降低)
**VideoToolBox框架的流程**
- 创建session
- 设置编码相关参数
- 开始编码
- 循环获取采集数据
- 获取编码后数据
- 将数据写入H264文件
编码的输入和输出
![img](https://pic3.zhimg.com/80/v2-a234942364dbe630f66a1f095f5d57e6_720w.webp)
如图所示,左边的三帧视频帧是发送給编码器之前的数据,开发者必须将原始图像数据封装为CVPixelBuufer的数据结构.该数据结构是使用VideoToolBox的核心.
CVPixelBuffer 解析
在这个官方文档的介绍中,CVPixelBuffer 给的官方解释,是其主内存存储所有像素点数据的一个对象.那么什么是主内存了?
其实它并不是我们平常所操作的内存,它指的是存储区域存在于缓存之中. 我们在访问这个块内存区域,需要先锁定这块内存区域
```text
//1.锁定内存区域:
CVPixelBufferLockBaseAddress(pixel_buffer,0);
//2.读取该内存区域数据到NSData对象中
Void *data = CVPixelBufferGetBaseAddress(pixel_buffer);
//3.数据读取完毕后,需要释放锁定区域
CVPixelBufferRelease(pixel_buffer);
```
单纯从它的使用方式,我们就可以知道这一块内存区域不是普通内存区域.它需要加锁,解锁等一系列操作.
**作为视频开发,尽量减少进行显存和内存的交换.所以在iOS开发过程中也要尽量减少对它的内存区域访问**.建议使用iOS平台提供的对应的API来完成相应的一系列操作.
在AVFoundation 回调方法中,它有提供我们的数据其实就是CVPixelBuffer.只不过当时使用的是引用类型CVImageBufferRef,其实就是CVPixelBuffer的另外一个定义.
Camera 返回的CVImageBuffer 中存储的数据是一个CVPixelBuffer,而经过VideoToolBox编码输出的CMSampleBuffer中存储的数据是一个CMBlockBuffer的引用.
![img](https://pic2.zhimg.com/80/v2-ebaf9479545cbe34e9033a966a9019a1_720w.webp)
在**iOS**中,会经常使用到session的方式.比如我们使用任何硬件设备都要使用对应的session,麦克风就要使用AudioSession,使用Camera就要使用AVCaptureSession,使用编码则需要使用VTCompressionSession.解码时,要使用VTDecompressionSessionRef.
视频编码步骤分解
第一步: 使用VTCompressionSessionCreate方法,创建编码会话;
```text
//1.调用VTCompressionSessionCreate创建编码session
//参数1:NULL 分配器,设置NULL为默认分配
//参数2:width
//参数3:height
//参数4:编码类型,如kCMVideoCodecType_H264
//参数5:NULL encoderSpecification: 编码规范。设置NULL由videoToolbox自己选择
//参数6:NULL sourceImageBufferAttributes: 源像素缓冲区属性.设置NULL不让videToolbox创建,而自己创建
//参数7:NULL compressedDataAllocator: 压缩数据分配器.设置NULL,默认的分配
//参数8:回调 当VTCompressionSessionEncodeFrame被调用压缩一次后会被异步调用.注:当你设置NULL的时候,你需要调用VTCompressionSessionEncodeFrameWithOutputHandler方法进行压缩帧处理,支持iOS9.0以上
//参数9:outputCallbackRefCon: 回调客户定义的参考值
//参数10:compressionSessionOut: 编码会话变量
OSStatus status = VTCompressionSessionCreate(NULL, width, height, kCMVideoCodecType_H264, NULL, NULL, NULL, didCompressH264, (__bridge void *)(self), &cEncodeingSession);
```
第二步:设置相关的参数
```text
/*
session: 会话
propertyKey: 属性名称
propertyValue: 属性值
*/
VT_EXPORT OSStatus
VTSessionSetProperty(
CM_NONNULL VTSessionRef session,
CM_NONNULL CFStringRef propertyKey,
CM_NULLABLE CFTypeRef propertyValue ) API_AVAILABLE(macosx(10.8), ios(8.0), tvos(10.2));
```
**kVTCompressionPropertyKey_RealTime**:设置是否实时编码
**kVTProfileLevel_H264_Baseline_AutoLevel**:表示使用H264的Profile规格,可以设置Hight的AutoLevel规格.
**kVTCompressionPropertyKey_AllowFrameReordering**:表示是否使用产生B帧数据(因为B帧在解码是非必要数据,所以开发者可以抛弃B帧数据)
**kVTCompressionPropertyKey_MaxKeyFrameInterval** : 表示关键帧的间隔,也就是我们常说的gop size.
**kVTCompressionPropertyKey_ExpectedFrameRate** : 表示设置帧率
**kVTCompressionPropertyKey_AverageBitRate**/**kVTCompressionPropertyKey_DataRateLimits** 设置编码输出的码率.
第三步: 准备编码
```text
//开始编码
VTCompressionSessionPrepareToEncodeFrames(cEncodeingSession);
```
第四步: 捕获编码数据
- 通过AVFoundation 捕获的视频,这个时候我们会走到AVFoundation捕获结果代理方法:
```text
#pragma mark - AVCaptureVideoDataOutputSampleBufferDelegate
//AV Foundation 获取到视频流
-(void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection
{
//开始视频录制,获取到摄像头的视频帧,传入encode 方法中
dispatch_sync(cEncodeQueue, ^{
[self encode:sampleBuffer];
});
```
第五步:数据编码
- 将获取的视频数据编码
```text
- (void) encode:(CMSampleBufferRef )sampleBuffer
{
//拿到每一帧未编码数据
CVImageBufferRef imageBuffer = (CVImageBufferRef)CMSampleBufferGetImageBuffer(sampleBuffer);
//设置帧时间,如果不设置会导致时间轴过长。时间戳以ms为单位
CMTime presentationTimeStamp = CMTimeMake(frameID++, 1000);
VTEncodeInfoFlags flags;
//参数1:编码会话变量
//参数2:未编码数据
//参数3:获取到的这个sample buffer数据的展示时间戳。每一个传给这个session的时间戳都要大于前一个展示时间戳.
//参数4:对于获取到sample buffer数据,这个帧的展示时间.如果没有时间信息,可设置kCMTimeInvalid.
//参数5:frameProperties: 包含这个帧的属性.帧的改变会影响后边的编码帧.
//参数6:ourceFrameRefCon: 回调函数会引用你设置的这个帧的参考值.
//参数7:infoFlagsOut: 指向一个VTEncodeInfoFlags来接受一个编码操作.如果使用异步运行,kVTEncodeInfo_Asynchronous被设置;同步运行,kVTEncodeInfo_FrameDropped被设置;设置NULL为不想接受这个信息.
OSStatus statusCode = VTCompressionSessionEncodeFrame(cEncodeingSession, imageBuffer, presentationTimeStamp, kCMTimeInvalid, NULL, NULL, &flags);
if (statusCode != noErr) {
NSLog(@"H.264:VTCompressionSessionEncodeFrame faild with %d",(int)statusCode);
VTCompressionSessionInvalidate(cEncodeingSession);
CFRelease(cEncodeingSession);
cEncodeingSession = NULL;
return
NSLog(@"H264:VTCompressionSessionEncodeFrame Success");
```
第六步: 编码数据处理-获取SPS/PPS
当编码成功后,就会回调到最开始初始化编码器会话时传入的回调函数,回调函数的原型如下:
**void didCompressH264(void \*outputCallbackRefCon, void \*sourceFrameRefCon, OSStatus status, VTEncodeInfoFlags infoFlags, CMSampleBufferRef sampleBuffer)**
- **判断status,如果成功则返回**0(noErr) **;成功则继续处理,不成功则不处理.**
- **判断是否关键帧**
- - **为什么要判断关键帧呢?**
- 因为VideoToolBox编码器在每一个关键帧前面都会输出SPS/PPS信息.所以如果本帧未关键帧,则可以取出对应的SPS/PPS信息.
```text
//判断当前帧是否为关键帧
//获取sps & pps 数据 只获取1次,保存在h264文件开头的第一帧中
//sps(sample per second 采样次数/s),是衡量模数转换(ADC)时采样速率的单位
//pps()
if (keyFrame) {
//图像存储方式,编码器等格式描述
CMFormatDescriptionRef format = CMSampleBufferGetFormatDescription(sampleBuffer);
//sps
size_t sparameterSetSize,sparameterSetCount;
const uint8_t *sparameterSet;
OSStatus statusCode = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 0, &sparameterSet, &sparameterSetSize, &sparameterSetCount, 0);
if (statusCode == noErr) {
//获取pps
size_t pparameterSetSize,pparameterSetCount;
const uint8_t *pparameterSet;
//从第一个关键帧获取sps & pps
OSStatus statusCode = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 1, &pparameterSet, &pparameterSetSize, &pparameterSetCount, 0);
//获取H264参数集合中的SPS和PPS
if (statusCode == noErr)
{
//Found pps & sps
NSData *sps = [NSData dataWithBytes:sparameterSet length:sparameterSetSize];
NSData *pps = [NSData dataWithBytes:pparameterSet length:pparameterSetSize];
if(encoder)
{
[encoder gotSpsPps:sps pps:pps];
```
第七步 编码压缩数据并写入H264文件
当我们获取了SPS/PPS信息之后,我们就获取实际的内容来进行处理了
```text
CMBlockBufferRef dataBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
size_t length,totalLength;
char *dataPointer;
OSStatus statusCodeRet = CMBlockBufferGetDataPointer(dataBuffer, 0, &length, &totalLength, &dataPointer);
if (statusCodeRet == noErr) {
size_t bufferOffset = 0;
static const int AVCCHeaderLength = 4;//返回的nalu数据前4个字节不是001的startcode,而是大端模式的帧长度length
//循环获取nalu数据
while (bufferOffset < totalLength - AVCCHeaderLength) {
uint32_t NALUnitLength = 0;
//读取 一单元长度的 nalu
memcpy(&NALUnitLength, dataPointer + bufferOffset, AVCCHeaderLength);
//从大端模式转换为系统端模式
NALUnitLength = CFSwapInt32BigToHost(NALUnitLength);
//获取nalu数据
NSData *data = [[NSData alloc]initWithBytes:(dataPointer + bufferOffset + AVCCHeaderLength) length:NALUnitLength];
//将nalu数据写入到文件
[encoder gotEncodedData:data isKeyFrame:keyFrame];
//move to the next NAL unit in the block buffer
//读取下一个nalu 一次回调可能包含多个nalu数据
bufferOffset += AVCCHeaderLength + NALUnitLength;
//第一帧写入 sps & pps
- (void)gotSpsPps:(NSData*)sps pps:(NSData*)pps
{
NSLog(@"gotSpsPp %d %d",(int)[sps length],(int)[pps length]);
const char bytes[] = "\x00\x00\x00\x01";
size_t length = (sizeof bytes) - 1;
NSData *ByteHeader = [NSData dataWithBytes:bytes length:length];
[fileHandele writeData:ByteHeader];
[fileHandele writeData:sps];
[fileHandele writeData:ByteHeader];
[fileHandele writeData:pps];
- (void)gotEncodedData:(NSData*)data isKeyFrame:(BOOL)isKeyFrame
{
NSLog(@"gotEncodeData %d",(int)[data length]);
if (fileHandele != NULL) {
//添加4个字节的H264 协议 start code 分割符
//一般来说编码器编出的首帧数据为PPS & SPS
//H264编码时,在每个NAL前添加起始码 0x000001,解码器在码流中检测起始码,当前NAL结束。
/*
为了防止NAL内部出现0x000001的数据,h.264又提出'防止竞争 emulation prevention"机制,在编码完一个NAL时,如果检测出有连续两个0x00字节,就在后面插入一个0x03。当解码器在NAL内部检测到0x000003的数据,就把0x03抛弃,恢复原始数据。
总的来说H264的码流的打包方式有两种,一种为annex-b byte stream format 的格式,这个是绝大部分编码器的默认输出格式,就是每个帧的开头的3~4个字节是H264的start_code,0x00000001或者0x000001。
另一种是原始的NAL打包格式,就是开始的若干字节(1,2,4字节)是NAL的长度,而不是start_code,此时必须借助某个全局的数据来获得编 码器的profile,level,PPS,SPS等信息才可以解码。
*/
const char bytes[] ="\x00\x00\x00\x01";
//长度
size_t length = (sizeof bytes) - 1;
//头字节
NSData *ByteHeader = [NSData dataWithBytes:bytes length:length];
//写入头字节
[fileHandele writeData:ByteHeader];
//写入H264数据
[fileHandele writeData:data];
```
原文https://zhuanlan.zhihu.com/p/552724772

@ -0,0 +1,106 @@
# iOS项目集成OpenCV及踩过的坑
### 1、直接下载Framework集成
#### 1.1、下载OpenCV的Framework
从[OpenCV官网](https://link.juejin.cn?target=http%3A%2F%2Fopencv.org)下载框架,拖入Xcode项目。
#### 1.2、导入OpenCV依赖的库
导入路径:选择项目—>Targets—>General—>Linked Frameworks and Libraies,点击”+”添加下方依赖库。
> - libc++.tbd
> - AVFoundation.framework
> - CoreImage.framework
> - CoreGraphics.framework
> - QuartzCore.framework
> - Accelerate.framework
> - CoreVideo.framework
> - CoreMedia.framework
> - AssetsLibrary.framework
#### 1.3、改为Objective-C与C++混编
凡是导入OpenCV头文件的类,[都需要把相应类后缀名.m改为.mm](https://link.juejin.cn?target=http%3A%2F%2Fxn--eqrb591f45df85a6khbreqq0axmpzfo.xn--m-cr6au94e.mm)。
```arduino
#import <opencv2/opencv.hpp>
#import <opencv2/imgproc/types_c.h>
#import <opencv2/imgcodecs/ios.h>
复制代码
```
### 2、CocoaPods方式集成(不推荐)
#### 2.1 CocoaPods文件配置
在项目Pod文件中配置**pod ‘OpenCV’**,然后**pod update**;同理,使用时导入OpenCV相应的头文件,[并把类后缀名.m改为.mm](https://link.juejin.cn?target=http%3A%2F%2Fxn--eqrb501fl9dz24bvfd.xn--m-cr6au94e.mm)。
#### 2.2 使用CocoaPods集成OpenCV说明
使用CocoaPods虽然配置简单,但自动配置的不正确,存在名称重复等大量的问题。例如:
> Warning: Multiple build commands for output file /Users/P85755/Library/Developer/Xcode/DerivedData/PracticeProject-bgmxispyljyrbfdimchwaxacraaa/Build/Products/Debug-iphoneos/OpenCV/calib3d.hpp Warning: Multiple build commands for output file /Users/P85755/Library/Developer/Xcode/DerivedData/PracticeProject-bgmxispyljyrbfdimchwaxacraaa/Build/Products/Debug-iphoneos/OpenCV/core.hpp 。。。。。。。。。等等 是由于CocoaPods自动配置时,生成了相同名称的.h配置文件,虽然在不同路径,Xcode仍旧认为是同一个文件。
### 3、已经踩过的`深坑`
#### 3.1、导入头文件的深坑
导入**#import <opencv2/opencv.hpp>**报Expected identitier的错误。这是由于opencv 的 import 要写在**#import <UIKit/UIKit.h>、#import <Foundation/Foundation.h>**这些系统自带的 framework `前面`,否则会出现重命名的冲突。
![导入头文件错误](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2018/11/19/1672be3f069ae308~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.image)
#### 3.2、Objective-C和C++的混编的深坑
OpenCV框架提供是C++的API接口,凡是使用OpenCV的地方,类的文件类型必须由.m类型改为.mm类型,这时候编译器按照OC与C++混编进行编译。
[假设你使用OpenCV的类为A.mm](https://link.juejin.cn?target=http%3A%2F%2Fxn--OpenCVA-k73k98f4ix9e8x3qpyd29zek6c.mm),那如果你在Objective-C的类B.m中导入使用,此时编译器会认为此时A.mm也按照Objective-C类型编译,你必须把B.m类型更改为B.mm类型才不会报错,以此类推,[你在C.m中使用B.mm](https://link.juejin.cn?target=http%3A%2F%2Fxn--C-376ay2w.xn--mB-qy2c05ckz8h.mm),那C也必须更改为C.mm类型。。。有人比喻这样蔓延的有点像森林大火,一个接一个,很形象。
`解决办法:`在导入OpenCV头文件的时候,**#import <opencv2/opencv.hpp>\**前面加上\**#ifdef __cplusplus**,指明编译器只有使用了OpenCV的.mm类型文件,才按照C++类型编译。如下即可解决:
```arduino
#ifdef __cplusplus
#import <opencv2/opencv.hpp>
#import <opencv2/imgproc/types_c.h>
#import <opencv2/imgcodecs/ios.h>
#endif
复制代码
```
#### 3.3、编译警告
导入OpenCV使用时,Xcode8会有一堆类似`warning: empty paragraph passed to '@param' command [-Wdocumentation]`的文档警告。
![导入头文件错误](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2018/11/19/1672be3f06afb20c~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.image)
虽然项目目前不报错了,但对于有强迫症的小伙伴来说,还是不能忍。解决办法:导入头文件的时候,忽略文档警告即可;同时只在需要的地方导入C++类,则加上编译器忽略文档警告即可,解决办法如下:
```arduino
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdocumentation"
#ifdef __cplusplus
#import <opencv2/opencv.hpp>
#import <opencv2/imgproc/types_c.h>
#import <opencv2/imgcodecs/ios.h>
#endif
#pragma clang pop
复制代码
```
#### 3.4、UIImage与cv::Mat转换报错。
读取视频帧,转换为UIImage时报**_CMSampleBufferGetImageBuffer", referenced from:**的错误,是由于缺少**CoreMedia.framework**框架,在**Targets—>General—>Linked Frameworks and Libraies**导入**CoreMedia.framework**框架即可。
原文链接:https://juejin.cn/post/6844903716869373959

@ -0,0 +1,81 @@
# 视频直播iOS端技术
直播架构
  想必了解过直播的人都清楚直播主要分为3部分:推流->流媒体服务器->拉流。
![img](https://pic3.zhimg.com/80/v2-7fba8f181122e09818e7c7592caf88fe_720w.webp)
  而我们今天需要讲的就是推流这部分,它主要包括音视频采集,音视频前处理,音视频编码,推流和传输4个方面。但是由于网络的复杂性和大数据的统计,推流还需要有全局负载均衡调度GSLB(Global Server Load Balance),以及实时的统计数据上报服务器,包括提供频道管理给用户运营,因此推流SDK需要接入GSLB中心调度,统计服务器,心跳服务器,用于推流分配到网络最好的节点,有大数据的统计和分析。
![img](https://pic4.zhimg.com/80/v2-4a9b30dd3de6f53a4bc46e23b25722af_720w.webp)
  下图涵盖了直播相关的所有服务,红色小标的线条代表指令流向,绿色小标的线条代表数据流向。
![img](https://pic3.zhimg.com/80/v2-d924c2a29a9e365c811b0feb89bfd96e_720w.webp)
  ●●●
直播技术点
![img](https://pic4.zhimg.com/80/v2-99dacb8cd68341a92d0344bdd76d9477_720w.webp)
  音视频采集
  采集是所有环节中的第一环,网易云通信与视频使用的系统原生框架AVFoundation采集数据。通过iPhone摄像头(AVCaptureSession)采集视频数据,通过麦克风(AudioUnit)采集音频数据。目前视频的采集源主要来自摄像头采集、屏幕录制(ReplayKit)、从视频文件读取推流。
  音视频都支持参数配置。音频可以设置采样率、声道数、帧大小、音频码率、是否使用外部采集、是否使用外部音频前处理;视频可以设置帧率、码率、分辨率、前后摄像头、摄像头采集方向、视频端显示比例、是否开启摄像头闪光灯、是否打开摄像头响应变焦、是否镜像前置摄像头预览、是否镜像前置摄像头编码、是否打开滤镜功能、滤镜类型、是否打开水印支持、是否打开QoS功能、是否输出RGB数据、是否使用外部视频采集。
  音视频处理
  前处理模块也是主观影响主播观看效果最主要的环节。目前iOS端比较知名的是GPUImage,提供了丰富的预处理效果,我们也在此基础上进行了封装开发。视频前处理包含滤镜、美颜、水印、涂鸦等功能,同时在人脸识别和特效方面接入了第三方厂商FaceU。SDK内置4款滤镜黑白、自然、粉嫩、怀旧;支持16:9裁剪;支持磨皮和美白(高斯模糊加边缘检测);支持静态水印,动态水印,涂鸦等功能。音频前处理则包括回声抑制、啸叫、增益控制等。音视频都支持外部前处理。
![img](https://pic1.zhimg.com/80/v2-ce8c118370a7571e1a9c34576ef3a4dc_720w.webp)
  音视频编码
  编码最主要的两个难点是:
  处理硬件兼容性问题
  在高FPS、低bitrate和音质画质之间找个一个平衡点
  由于iOS端硬件兼容性比较好,因此可以采用硬编。SDK目前支持软件编码openH264,硬件编码VideoToolbox。而音频支持软件编码FDK-AAC和硬件编码AudioToolbox。
  视频编码的核心思想就是去除冗余信息:
  空间冗余:图像相邻像素之间有较强的相关性。
  时间冗余:视频序列的相邻图像之间内容相似。
  编码冗余:不同像素值出现的概率不同。
  视觉冗余:人的视觉系统对某些细节不敏感。
  音视频发送
  推流SDK使用的流媒体协议是RTMP(RealTime Messaging Protocol)。而音视频发送最困难的就是针对网络的带宽评估。由于从直播端到RTMP服务器的网络情况复杂,尤其是在3G和带宽较差的Wifi环境下,网络丢包、抖动和延迟经常发生,导致直播推流不畅。RTMP基于TCP进行传输,TCP自身实现了网络拥塞下的处理,内部的机制较为复杂,而且对开发者不可见,开发者无法根据TCP协议的信息判断当时的网络情况,导致发送码率大于实际网络带宽,造成比较严重的网络拥塞。因此我们自研开发了一款实时根据网络变化的QoS算法,用于实时调节码率、帧率、分辨率,同时将数据实时上报统计平台。
  ●●●
  模块设计&线程模型
  模块设计
  鉴于推流的主流程分为上述描述的4个部分:音视频采集、音视频前处理、音视频编码、音视频发送。因此将推流SDK进行模块划分为LSMediacapture层(对外API+服务器交互)、视频融合模块(视频采集+视频前处理)、音频融合模块(音频采集+音频前处理)、基础服务模块、音视频编码模块、网络发送模块。
![img](https://pic3.zhimg.com/80/v2-b5df0ed24f36202083b6395ae42e133e_720w.webp)
  线程模型
  推流SDK总共含有10个线程。视频包含AVCaptureSession的原始采集线程、前处理线程、硬件编码线程、数据流向定义的采集线程、编码线程、发送线程。音频包含AudioUnit包含的原始采集线程、数据流向定义的采集线程、编码线程、发送线程。在数据流向定义的采集线程、编码线程、发送线程之间会创建2个bufferQueue,用于缓存音视频数据。采集编码队列可以有效的控制编码码率,编码发送队列可以有效自适应网络推流。
![img](https://pic2.zhimg.com/80/v2-90d9e8bf70bac625ac813e450091b19d_720w.webp)
  QoS&跳帧
  下图是直播的主要流程,用户初始化SDK,创建线程,开始直播,音视频数据采集,编码,发送。在发送线程下,音视频数据发送,QoS开启,根据网络实时评估带宽,调整帧率,码率控制编码器参数,同时触发跳帧,调整分辨率控制采集分辨率参数。用户停止直播,反初始化SDK,销毁线程。QoS&跳帧可以有效的解决用户在网络不好的情况下,直播卡顿的问题。在不同的码率和分辨率情况下,都能够做到让用户流畅地观看视频直播。
![img](https://pic4.zhimg.com/80/v2-2b8310a58acd9abddf1e9069744920e3_720w.webp)
原文https://zhuanlan.zhihu.com/p/31178008
Loading…
Cancel
Save