Android视频技术探索之旅:美团外卖商家端的实践

640?wx_fmt=png

总第354篇

2019年 第32篇

640?wx_fmt=jpeg

美美导读:移动互联网时代,4G的普及推动了移动视频的发展,丰富的视频内容满足了用户多样化的需求。美团外卖商家端也尝试引入了视频功能,旨在提升商品信息描述的丰富度。本文总结了商家端视频功能的闭环全流程实践及部分踩坑经验。

背景

2013年美团外卖成立,至今一直迅猛发展。随着外卖业务量级与日俱增,单一的文字和图片已无法满足商家的需求,商家迫切需要更丰富的商品描述手段吸引用户,增加流量,进而提高下单转化率和下单量。商品视频的引入,在一定程度上可以提升商品信息描述丰富度,以更加直观的方式为商家引流,增加收益。为此,商家端引入了视频功能,进行了一系列视频功能开发,核心功能包含视频处理(混音,滤镜,加水印,动画等)、视频拍摄、合成等,最终效果图如下所示:

640?wx_fmt=jpeg

640?wx_fmt=jpeg

自视频功能上线后,每周视频样本量及使用视频的商家量大幅增加,视频录制成功率达99.533%,视频处理成功率98.818%,音频处理成功率99.959%,Crash率稳定在0.1‰,稳定性高且可用性强。目前,视频功能已在蜜蜂App、闪购业务和商家业务上使用。

对于视频链路的开发,我们经历了方案选型、架构设计及优化、业务实践、功能测试、监控运维、更新维护等各个环节,核心环节如下图所示。在开发过程中,遇到了各种技术问题和挑战,下文会针对遇到的问题、挑战,及其解决方案进行重点阐述。

640?wx_fmt=png

方案选型

在方案选型时,重点对核心流程和视频格式进行选型。我们以功能覆盖度、稳定性及效率、可定制性、成本及开源性做为核心指标,从而衡量方案的高可用性和可行性。

1. 核心流程选型
视频开发涉及的核心流程包括播放、录制、合成、裁剪、后期处理( 编解码、滤镜、混音、动画、水印 )等。结合商家端业务场景,我们有针对性的进行方案调研。重点调研了业界现有方案,如阿里的云视频点播方案、腾讯云视频点播方案、大众点评App的UGC方案,及其它的一些第三方开源方案等,并进行了整体匹配度的对比,如下图所示:

640?wx_fmt=jpeg

阿里和腾讯的云视频点播方案比较成熟,集成度高,且能力丰富,稳定性及效率也很高。但两者成本较高,需要收费,且SDK大小均在15M以上,对于我们的业务场景来说有些过于臃肿,定制性较弱,无法迅速的支持我们做定制性扩展。

当时的大众点评App UGC方案,基础能力是满足的,但因业务场景差异:

  • 比如外卖的视频拍摄功能要求在竖屏下保证16:9的视频宽高比,这就需要对原有的采集区域进行截取,视频段落的裁剪支持不够等,业务场景的差异导致了实现方案存在巨大的差异,故放弃了大众点评App UGC方案。其他的一些开源方案(比如Grafika等),也无法满足要求,这里不再一一赘述。

通过技术调研和分析,吸取各开源项目的优点,并参考大众点评App UGC、Google CTS方案,对核心流程做了最终的方案选型,打造一个适合我们业务场景的方案,如下表所示:

640?wx_fmt=jpeg

2. 视频格式选型

640?wx_fmt=jpeg

  • 采用H.264的视频协议:H.264的标准成熟稳定,普及率高。其最大的优势是具有很高的数据压缩比率,在同等图像质量的条件下,H.264的压缩比是MPEG-2的2倍以上,是MPEG-4的1.5~2倍。

  • 采用AAC的音频协议:AAC是一种专为声音数据设计的文件压缩格式。它采用了全新的算法进行编码,是新一代的音频有损压缩技术,具有更加高效,更具有“性价比”的特点。

整体架构

我们整体的架构设计,用以满足业务扩展和平台化需要,可复用、可扩展,且可快速接入。架构采用分层设计,基础能力和组件进行下沉,业务和视频能力做分离,最大化降低业务方的接入成本,三方业务只需要接入视频基础SDK,直接使用相关能力组件或者工具即可。

整体架构分为四层,分别为平台层、核心能力层、基础组件层、业务层。

  • 平台层:依赖系统提供的平台能力,比如Camera、OpenGL、MediaCodec和MediaMuxer等,也包括引入的平台能力,比如ijkplayer播放器、mp4parser。

  • 核心能力层:该层提供了视频服务的核心能力,包括音视频编解码、音视频的转码引擎、滤镜渲染能力等。

  • 基础能力层:暴露了基础组件和能力,提供了播放、裁剪、录屏等基础组件和对应的基础工具类,并提供了可定制的播放面板,可定制的缓存接口等。

  • 业务层:包括段落拍摄、自由拍摄、视频空间、拍摄模版预览及加载等。

我们的视频能力层对业务层是透明的,业务层与能力层隔离,并对业务层提供了部分定制化的接口支持,这样的设计降低了业务方的接入成本,并方便业务方的扩展,比如支持蜜蜂App的播放面板定制,还支持缓存策略、编解码策略的可定制。整体设计如下图所示:

640?wx_fmt=jpeg

实践经验

在视频开发实践中,因业务场景的复杂性,我们遇到了多种问题和挑战。下面以核心功能为基点,围绕各功能遇到的问题做详细介绍。

视频播放

播放器是视频播放基础。针对播放器,我们进行了一系列的方案调研和选择。在此环节,遇到的挑战如下:

1. 兼容性问题
2. 缓存问题

针对兼容性问题,Android有原生的MediaPlayer,但其版本兼容问题偏多且支持格式有限,而我们需要支持播放本地视频,本地视频格式又无法控制,故该方案被舍弃。ijkplayer基于FFmpeg,与MediaPlayer相比,优点比较突出:具备跨平台能力,支持Android与iOS;提供了类似MediaPlayer的API,可兼容不同版本;可实现软硬解码自由切换,拥有FFmpeg的能力,支持多种流媒体协议。基于上述原因,我们最终决定选用ijkplayer。

但紧接着又发现ijkplayer本身不支持边缓存边播放,频繁的加载视频导致耗费大量的流量,且在弱网或者3G网络下很容易导致播放卡顿,所以这里就衍生出了缓存的问题。

针对缓存问题,引入AndroidVideoCache的技术方案,利用本地的代理去请求数据,先本地保存文件缓存,客户端通过Socket读取本地的文件缓存进行视频播放,这样就做到了边播放边缓存的策略,流程如下图:

640?wx_fmt=jpeg

此外,我们还对AndroidVideoCache做了一些技术改造:

  • 优化缓存策略。针对缓存策略的单一性,支持有限的最大文件数和文件大小问题,调整为由业务方可以动态定制缓存策略;

  • 解决内存泄露隐患。对其页面退出时请求不关闭会导致的内存泄露,为其添加了完整的生命周期监控,解决了内存泄露问题。

视频录制

在视频拍摄的时候,最为常用的方式是采用MediaRecorder+Camera技术,采集摄像头可见区域。但因我们的业务场景要求视频采集的时候,只录制采集区域的部分区域且比例保持宽高比16:9,在保证预览图像不拉伸的情况下,只能对完整的采集区域做裁剪,这无形增加了开发难度和挑战。通过大量的资料分析,重点调研了有两种方案:
  1. Camera+AudioRecord+MediaCodec+Surface

  2. MediaRecorder+MediaCodec

方案1需要Camera采集YUV帧,进行截取采集,最后再将YUV帧和PCM帧进行编码生成mp4文件,虽然其效率高,但存在不可把控的风险。

方案2综合评估后是改造风险最小的。综合成本和风险考量,我们保守的采用了方案2,该方案是对裁剪区域进行坐标换算(如果用前置摄像头拍摄录制视频,会出现预览画面和录制的视频是镜像的问题,需要处理)。当录制完视频后,生成了mp4文件,用MediaCodec对其编码,在编码阶段再利用OpenGL做内容区域的裁剪来实现。但该方案又引发了如下挑战。

(1)对焦问题

因我们对采集区域做了裁剪,引发了点触对焦问题。比如用户点击了相机预览画面,正常情况下会触发相机的对焦动作,但是用户的点击区域只是预览画面的部分区域,这就导致了相机的对焦区域错乱,不能正常进行对焦。后期经过问题排查,对点触区域再次进行相应的坐标变换,最终得到正确的对焦区域。

(2)兼容适配
我们的视频录制利用MediaRecorder,在获取配置信息时,由于Android碎片化问题,不同的设备支持的配置信息不同,所以就会出现设备适配问题。
        // VIVO Y66 模版拍摄时候,播放某些有问题的视频文件的同时去录制视频,会导致MediaServer挂掉的问题
        // 发现将1080P尺寸的配置降低到720P即可避免此问题
        // 但是720P尺寸的配置下,又存在绿边问题,因此再降到480
        if(isVIVOY66() && mMediaServerDied) {
            return getCamcorderProfile(CamcorderProfile.QUALITY_480P);
        }

        //SM-C9000,在1280 x 720 分辨率时有一条绿边。网上有种说法是GPU对数据进行了优化,使得GPU产生的图像分辨率
        //和常规分辨率存在微小差异,造成图像色彩混乱,修复后存在绿边问题。
        //测试发现,降低分辨率或者升高分辨率都可以绕开这个问题。
        if (VideoAdapt.MODEL_SM_C9000.equals(Build.MODEL)) {
            return getCamcorderProfile(CamcorderProfile.QUALITY_HIGH);
        }

        // 优先选择 1080 P的配置
        CamcorderProfile camcorderProfile = getCamcorderProfile(CamcorderProfile.QUALITY_1080P);
        if (camcorderProfile == null) {
            camcorderProfile = getCamcorderProfile(CamcorderProfile.QUALITY_720P);
        }
        // 某些机型上这个 QUALITY_HIGH 有点问题,可能通过这个参数拿到的配置是1080p,所以这里也可能拿不到
        if (camcorderProfile == null) {
            camcorderProfile = getCamcorderProfile(CamcorderProfile.QUALITY_HIGH);
        }
        // 兜底
        if (camcorderProfile == null) {
            camcorderProfile = getCamcorderProfile(CamcorderProfile.QUALITY_480P);
        }

视频合成

我们的视频拍摄有段落拍摄这种场景,商家可根据事先下载的模板进行分段拍摄,最后会对每一段的视频做拼接,拼接成一个完整的mp4文件。mp4由若干个Box组成,所有数据都封装在Box中,且Box可再包含Box的被称为Container Box。mp4中Track表示一个视频或音频序列,是Sample的集合,而Sample又可分为Video Smaple和Audio Sample。Video Smaple代表一帧或一组连续视频帧,Audio Sample即为一段连续的压缩音频数据。(详见mp4文件结构。)

基于上面的业务场景需要,视频合成的基础能力我们采用mp4parser技术实现( 也可用FFmpeg等其他手段 )。mp4parser在拼接视频时,先将视频的音轨和视频轨进行分离,然后进行视频和音频轨的追加,最终将合成后的视频轨和音频轨放入容器里( 这里的容器就是mp4的Box )。采用mp4parser技术简单高效,API设计简洁清晰,满足需求。
但我们发现某些被编码或处理过的mp4文件可能会存在特殊的Box,并且mp4parser是不支持的。经过源码分析和原因推导,发现当遇到这种特殊格式的Box时,会申请分配一个比较大的空间用来存放数据,很容易造成OOM( 内存溢出 ),见下图所示。于是,我们对这种拼接场景下做了有效规避,仅在段落拍摄下使用mp4parser的拼接功能,保证处理过的文件不会包含这种特殊的Box。

640?wx_fmt=jpeg

视频裁剪

我们刚开始采用mp4parser技术完成视频裁剪,在实践中发现其精度误差存在很大的问题,甚至会影响正常的业务需求。比如禁止裁剪出3s以下的视频,但是由于mp4parser产生的精度误差,导致4-5s的视频很容易裁剪出少于3s的视频。究其原因,mp4parser只能在关键帧(又称I帧,在视频编码中是一种自带全部信息的独立帧)进行切割,这样就可能存在一些问题。比如在视频截取的起始时间位置并不是关键帧,会造成误差,无法保证精度而且是秒级误差。以下为mp4parser裁剪的关键代码:

public static double correctTimeToSyncSample(Track track, double cutHere, boolean next) {
        double[] timeOfSyncSamples = new double[track.getSyncSamples().length];
        long currentSample = 0;
        double currentTime = 0;
        for (int i = 0; i < track.getSampleDurations().length; i++) {
            long delta = track.getSampleDurations()[i];
            int index = Arrays.binarySearch(track.getSyncSamples(), currentSample + 1);
            if (index >= 0) {
                timeOfSyncSamples[index] = currentTime;
            }
            currentTime += ((double) delta / (double) track.getTrackMetaData().getTimescale());
            currentSample++;
        }
        double previous = 0;
        for (double timeOfSyncSample : timeOfSyncSamples) {
            if (timeOfSyncSample > cutHere) {
                if (next) {
                    return timeOfSyncSample;
                } else {
                    return previous;
                }
            }
            previous = timeOfSyncSample;
        }
        return timeOfSyncSamples[timeOfSyncSamples.length - 1];
}
为了解决精度问题,我们废弃了mp4parser,采用MediaCodec的方案,虽然该方案会增加复杂度,但是误差精度大大降低。

方案具体实施如下:先获得目标时间的上一帧信息,对视频解码,然后根据起始时间和截取时长进行切割,最后将裁剪后的音视频信息进行压缩编码,再封装进mp4容器中,这样我们的裁剪精度从秒级误差降低到微秒级误差,大大提高了容错率。

视频处理

视频处理是整个视频能力最核心的部分,会涉及硬编解码(遵循OpenMAX框架)、OpenGL、音频处理等相关能力。

下图是视频处理的核心流程,会先将音视频做分离,并行处理音视频的编解码,并加入特效处理,最后合成进一个mp4文件中。

640?wx_fmt=jpeg

在实践过程中,我们遇到了一些需要特别注意的问题,比如开发时遇到的坑,严重的兼容性问题(包括硬件兼容性和系统版本兼容性问题)等。下面重点讲几个有代表性的问题。

1. 偶数宽高的编解码器
视频经过编码后输出特定宽高的视频文件时出现了如下错误,信息里仅提示了Colorformat错误,具体如下:

640?wx_fmt=jpeg

查阅大量资料,也没能解释清楚这个异常的存在。基于日志错误信息,并通过系统源码定位,也只是发现是了和设置的参数不兼容导致的。经过反复的试错,最后确认是部分编解码器只支持偶数的视频宽高,所以我们对视频的宽高做了偶数限制。引起该问题的核心代码如下:

status_t ACodec::setupVideoEncoder(const char *mime, const sp<AMessage> &msg,
       sp<AMessage> &outputFormat, sp<AMessage> &inputFormat) {
   if (!msg->findInt32("color-format", &tmp)) {
       return INVALID_OPERATION;
   }
   OMX_COLOR_FORMATTYPE colorFormat =
       static_cast<OMX_COLOR_FORMATTYPE>(tmp);
   status_t err = setVideoPortFormatType(
           kPortIndexInput, OMX_VIDEO_CodingUnused, colorFormat);
   if (err != OK) {
       ALOGE("[%s] does not support color format %d",
             mComponentName.c_str(), colorFormat);
       return err;
   }
   .......
}
status_t ACodec::setVideoPortFormatType(OMX_U32 portIndex,OMX_VIDEO_CODINGTYPE compressionFormat,
       OMX_COLOR_FORMATTYPE colorFormat,bool usingNativeBuffers) {
   ......
   for (OMX_U32 index = 0; index <= kMaxIndicesToCheck; ++index) {
       format.nIndex = index;
       status_t err = mOMX->getParameter(
               mNode, OMX_IndexParamVideoPortFormat,
               &format, sizeof(format));
       if (err != OK) {
           return err;
       }
    ......
}
2. 颜色格式

我们在处理视频帧的时候,一开始获得的是从Camera读取到的基本的YUV格式数据,如果给编码器设置YUV帧格式,需要考虑YUV的颜色格式。这是因为YUV根据其采样比例,UV分量的排列顺序有很多种不同的颜色格式,Android也支持不同的YUV格式,如果颜色格式不对,会导致花屏等问题。

3. 16位对齐

这也是硬编码中老生常谈的问题了,因为H264编码需要16*16的编码块大小。如果一开始设置输出的视频宽高没有进行16字节对齐,在某些设备(华为,三星等)就会出现绿边,或者花屏。

4. 二次渲染
4.1 视频旋转

在最后的视频处理阶段,用户可以实时的看到加滤镜后的视频效果。这就需要对原始的视频帧进行二次处理,然后在播放器的Surface上渲染。首先我们需要OpenGL 的渲染环境(通过OpenGL的固有流程创建),渲染环境完成后就可以对视频的帧数据进行二次处理了。通过SurfaceTexture的updateTexImage接口,可将视频流中最新的帧数据更新到对应的GL纹理,再操作GL纹理进行滤镜、动画等处理。在处理视频帧数据的时候,首先遇到的是角度问题。在正常播放下(不利用OpenGL处理情况下)通过设置TextureView的角度(和视频的角度做转换)就可以解决,但是加了滤镜后这一方案就失效了。原因是视频的原始数据经过纹理处理再渲染到Surface上,单纯设置TextureView的角度就失效了,解决方案就是对OpenGL传入的纹理坐标做相应的旋转(依据视频的本身的角度)。

4.2 渲染停滞

视频在二次渲染后会出现偶现的画面停滞现象,主要是SurfaceTexture的OnFrameAvailableListener不返回数据了。该问题的根本原因是GPU的渲染和视频帧的读取不同步,进而导致SurfaceTexture的底层核心BufferQueue读取Buffer出了问题。下面我们通过BufferQueue的机制和核心源码深入研究下:

首先从二次渲染的工作流程入手。从图像流(来自Camera预览、视频解码、GL绘制场景等)中获得帧数据,此时OnFrameAvailableListener会回调。再调用updateTexImage(),会根据内容流中最近的图像更新SurfaceTexture对应的GL纹理对象。我们再对纹理对象做处理,比如添加滤镜等效果。SurfaceTexture底层核心管理者是BufferQueue,本身基于生产者消费者模式。

BufferQueue管理的Buffer状态分为:FREE、DEQUEUED、QUEUED、ACQUIRED、SHARED。当Producer需要填充数据时,需要先Dequeue一个Free状态的Buffer,此时Buffer的状态为DEQUEUED,成功后持有者为Producer。随后Producer填充数据完毕后,进行Queue操作,Buffer状态流转为QUEUED,且Owner变为BufferQueue,同时会回调BufferQueue持有的ConsumerListener的onFrameAvailable,进而通知Consumer可对数据进行二次处理了。Consumer先通过Acquire操作,获取处于QUEUED状态的Buffer,此时Owner为Consumer。当Consumer消费完Buffer后,会执行Release,该Buffer会流转回BufferQueue以便重用。BufferQueue核心数据为GraphicBuffer,而GraphicBuffer会根据场景、申请的内存大小、申请方式等的不同而有所不同。

SurfaceTexture的核心流程如下图:

640?wx_fmt=jpeg

通过上图可知,我们的Producer是Video,填充视频帧后,再对纹理进行特效处理( 滤镜等 ),最后再渲染出来。前面我们分析了BufferQueue的工作流程,但是在Producer要填充数据、执行dequeueBuffer操作时,如果有Buffer已经QUEUED,且申请的dequeuedCount大于mMaxDequeuedBufferCount,就不会再继续申请Free Buffer了,Producer就无法DequeueBuffer,也就导致onFrameAvailable无法最终调用,核心源码如下:
 
 
5. 码流适配
视频的监控体系发现,Android 9.0的系统出现大量的编解码失败问题,错误信息都是相同的。在MediaCodec的Configure时候出异常了,主要原因是我们强制使用了CQ码流,Android 9.0以前并无问题,但9.0及以后对CQ码流增加了新的校验机制而我们没有适配。核心流程代码如下:
 
 

关于码流还有个问题,就是如果通过系统的接口isBitrateModeSupported(int mode),判断是否支持该码流可能会出现误判,究其原因是framework层写死了该返回值,而并没有从硬件层或从media_codecs.xml去获取该值。关于码流各硬件厂商支持的差异性,可能谷歌也认为码流的兼容性太碎片化,不建议用非默认的码流。

6. 音频处理

音频处理还括对音频的混音、消声等操作。在混音操作的时候,还要注意音频文件的单声道转换等问题。

其实视频问题总结起来,大部分是都会牵扯到编解码(尤其是使用硬编码),需要大量的适配工作(以上也只是部分问题,碎片化还是很严峻的),所以就需要兜底容错方案,比如加入软编。

线上监控

视频功能引入了埋点、日志、链路监控等技术手段进行线上的监控,我们可以针对监控结果进行降级或维护更新。埋点更多的是产品维度的数据收集,日志是辅助定位问题的,而链路监控则可以做到监控预警。
我们加了拍摄流程、音视频处理、视频上传流程的全链路监控,整个链路如果任何一个节点出问题都认为是整个链路的失败,若失败次数超过阈值就会通过大象或邮件进行报警,我们在适配Andorid 9.0码流问题时,最早发现也是由于链路监控的预警。所有全链路的成功率目标值均为98%,若成功率低于92%的目标阈值就会触发报警,我们会根据报警的信息和日志定位分析,该异常的影响范围,再根据影响范围确定是否热修复或者降级。

我们以拍摄流程为例,来看看链路各核心节点的监控,如下图:

640?wx_fmt=png

容灾降级

视频功能目前只支持粗粒度的降级策略。我们在视频入口处做了开关控制,关掉后所有的视频功能都无法使用。我们通过线上监控到视频的稳定性和成功率在特定机型无法保证,导致影响用户正常的使用商家端App,可以支持针对特定设备做降级。后续我们可以做更细粒度的降级策略,比如根据P0级功能做降级,或者编解码策略的降级等。

维护更新

视频功能上线后,经历了几个稳定的版本,保持着较高的成功率。但近期收到了Sniffer(美团内部监控系统)的邮件报警,发现视频处理链路的失败次数明显增多,通过Sniffer收集的信息发现大部分都是Android 9.0的问题(也就是上面讲的Android 9.0码流适配的问题),我们在商家端5.2版本进行了修复。该问题解决后,我们的视频处理链路成功率也恢复到了98%以上。

总结和规划

视频功能上线后,稳定性、内存、CPU等一些相关指标数据比较理想。 我们建设的监控体系,覆盖了视频核心业务,一些异常报警让我们能够及时发现问题并迅速对异常进行维护更新。 但视频技术栈远比本文介绍的要庞大,怎么提高秒播率,怎么提高编解码效率,还有硬编解码过程中可能造成的花屏、绿边等问题都是挑战,需要更深入的研究解决。
未来我们会继续致力于提高视频处理的兼容性和效率,优化现有流程,我们会对音频和视频处理合并处理,也会引入软编和自定义编解码算法。
美团外卖大前端团队将来也会继续致力于提高用户的体验,将在实践过程中遇到的问题进行总结,继续和大家分享。 敬请关注。
如果你也对视频技术感兴趣,欢迎加入我们。

参考资料

  • Android开发者官网

  • Google CTS

  • Grafika

  • BufferQueue原理介绍

  • MediaCodec原理

  • 微信Android 视频编码爬过的坑

  • mp4文件结构(一)、(二)、(三)、(四)

  • AndroidVideoCache 代理策略

  • ijkplayer

  • mp4parser

  • GPUImage

  作者简介

金辉、李琼,美团外卖商家终端研发工程师。

----------  END  ----------

也许你还想看

640?wx_fmt=png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值