Android App项目中音视频开发杂谈

既然是杂谈,那么这一篇想必是阅读起来轻松的,因为不会有很多的代码片段,按照常规我们会分别写两篇,一篇Android一篇IOS的,今天我们来谈谈如果是Android新手上手App音视频开发的学习步骤路线应该是什么样的;最后我们介绍下Android项目中音视频实际开发会遇到的一些事情以及解决方案,我们今天只谈思路涉及具体细节可能在接下来的文章里面会具体体现,好的让我们开始吧:

首先我认为音视频作为一个比较垂直的行业,里面的技术背景肯定也是垂直的,这就导致了如果是做通用型App(比如说互联网里面的电商,O2O等)的工程师一开始上手的时候无从下手,找不到方向,那么我们从一些基础概念入手今天给大家掰开了,抡圆了说,我们说的音视频或者说流媒体一般包含以下几个知识点:

首先需要了解的是音视频处理的流程:

  • 数据分别经历了 解协议,解封装,音/视频解码,播放 步骤,先上一张图来解释:
image.png

这是一个比较完整的过程,一般来说我们做播放器的时候处理媒体文件(例如Mp4)会完整的经历过这个过程,如果是自定义的流媒体数据可能没有上面的 解协议,解封装 步骤

其次是了解音频PCM数据格式:

  • 怎么采样采样率是什么(8kHZ,44.1kHZ),
  • 单/双通道,
  • 样本怎么存储(8bit/16bit),
  • 一帧音频为多少样本(通常是按1024个采样点一帧,每帧采样间隔为23.22ms)
  • 每帧PCM数据大小:(PCM Buffersize=采样率 * 采样时间 * 采样位深/8*通道数(Bytes))
  • 每秒的PCM数据大小:(采样率×采样位深×声道数bps)

再次了解视频YUV数据:

  • YUV数据的几种格式(YUV420P,YUV420SP,NV12,NV21)的排布是怎么样的
  • 怎么计算例如YUV420P的大小
  • 怎么分解明亮度与色度

既然是音视频肯定要涉及压缩编码,那么首先应该要了解编码标准:

  • 国际标准化组织(ISO)的MPEG-1、MPEG-2与MPEG-4,的规范和标准是哪些

  • 其次要了解这个这个主流标准里面MPEG-4的音频/视频具体的一种编码格式,一般来说是AAC(MP3)与H.264

  • AAC编码格式数据:要了解AAC编码的ADTS frame与ADTS头是怎么样子的

image.png
  • H.264编码格式数据:要了解H.264的编码格式一般主流是两种AVCC(IOS默认硬编码),Annex-B(Android默认硬编码)

  • Annex-B格式里面每个NALU的格式:包含头与payload是什么样的

image.png
  • AVCC里面extradata里面的数据格式是怎么样的(包含SPS,PPS在里面)

  • H.264里面的SPS,PPS,I帧,P帧,B帧所表示的意义

说了编码当然要有解码:

  • Android里面音频的硬解(MediaCodeC),软解(ffpmeg)怎么实现;
  • Android里面视频的硬解(MediaCodec),软解(ffmpeg)怎么实现;

解码以后怎么播放,音频播放:

  • Android :(包括不限于:AudioTrack ,OpenSLES);
  • 播放中音频重采样(播放环境如果与样本环境不兼容则需要重采样);

解码后视频播放:

  • Android:(包括不限于:GLSurfaceView ,OpenGLES);
  • Android 平台EGL的使用

其中OpenGLES 特别是可以作为一个分支来进行加强:

  • 物体坐标系:是指绘制物体的坐标系。
  • 世界坐标系:是指摆放物体的坐标系。
  • 摄像机坐标系:摄像机的在三维空间的位置,摄像机的方向向量,摄像机的up方向向量
  • 简单的绘制一些基本图形:三角形,正方形,球形
  • 纹理坐标:纹理贴图的方向以及大小
    两种投影:正射投影,透视投影
  • 着色器语言GLSL的基本语法以及使用
  • 纹理贴图显示图片
  • 处理平移、旋转、缩放等一些3x3 ,4X4的基本矩阵运算
  • FBO离屏渲染

什么是封包:

  • 然后是数据封包格式:包括MP4,TS的格式大致是什么样子的,支持哪几种音视频的编码格式;
  • DTS(Decoding Time Stamp)和PTS(Presentation Time Stamp)代表的意义;
  • TimeBase时间基在做音视频同步的意义;

音视频流媒体在网络上怎么传输:

  • 音视频在网络传输方式:HTTP,HLS,RTMP,HttpFlv

音视频应用层框架有哪些:

  • 高级应用框架:ffmpeg的基本使用
  • 高级应用框架:OpenCV的基本使用

额外需要掌握哪些技能:

  • JNI的使用;
  • C/C++ 基础;

以上是我认为作为音视频工程师入门应该掌握的知识点,我觉得掌握了这些不敢说成为了一个高手,但应该是成为一个合格的音视频工程师的 基本功

PS:基本功重要吗?我认为非常重要,往小了说基本功显示了一个人的技能扎实,拥有了扎实的基础才能往更深的方向发展;往大了说基本功显示了一个人可靠,处事沉稳可以做到了解一个事物的本质能做到万变不离其中

有了这些基本功那么我们可以接触一些实际的案例了,如果你想要更进阶那么我推荐一本我认为音视频内容比较全,而且里面有很多实战例子作为参考的书:

image.png

这本书我认为有几点比较好的:

  • 第一是这本书出于实战出发(据说是 唱吧App 架构师在做唱吧的时候总结了很多经验写的),

  • 第二这本书的内容包含了Android,IOS两个版本的所以有对比参考性,第三这本书从基础的音视频到高级的应用场景都介绍了,可谓是内容丰富;

说了这么多好的再说说这本书的一些不好的地方:

  • 首先就是我认为这本书不太适合刚刚入门的新手(注意是刚刚入门)如果是这类的工程师一些概念都没搞清楚的就看这个其实不是很合适;

  • 其次就是里面的例子的代码段过于松散,阅读起来需要不是很顺畅,而且git里面的Demo感觉也跟不上书里面的代码,里面的Demo目录结构不是很清晰(一般来说我们见得多的是1章分为一个或多个项目,分别讲解对应的内容互相不会干扰,书里面是git commit来区分的感觉体验性不是很好)

但是瑕不掩瑜如果你是有基础的话,那么这本书肯定能给你带了项目中的帮助。

有了前两步骤作为基础的话,那么我们接下来就要实战着手下项目,以及聊一聊项目中实现音视频以及相关的功能想要用到哪些技术方案:

先介绍下我们是做智能硬件,当然少不了App以及硬件,今天介绍的主要是我们的智能产品在音视频开发中的解决方案以及技术选型,由于今天是技术选型我们不会涉及具体的实现细节,因为技术选项定下来以后细节实现网上有很多文章,或者接下来我再分开把一些细节给大家写出来,欢迎大家给我留言,让我们开始吧:

产品实现的功能:

  • App 音视频的数据怎么传输
  • App 实现音视频解码
  • App 实现音视软解的播放
  • App 实现截图拍照
  • App 实现录制视频
  • App 实现音视频同步

App音视频的数据怎么传输:

  • App这边与嵌入式定好传输协议,协议数据大致分为协议头,协议体,协议头:包括同步码字段,帧类型,数据长度,数据方向,时间戳等等拿到数据头以后
    就可以按照长度拿到协议体数据就可以开始解码了
typedef struct
{
    HLE_U8 sync_code[3];    /*帧头同步码,固定为0x00,0x00,0x01*/
    HLE_U8 type;            /*帧类型, */
    HLE_U8 enc_std;         //编码标准,0:H.264 ; 1:H.265
    HLE_U8 framerate;       //帧率(仅I帧有效)
    HLE_U16 reserved;       //保留位
    HLE_U16 pic_width;      //图片宽(仅I帧有效)
    HLE_U16 pic_height;     //图片高(仅I帧有效)
    HLE_SYS_TIME rtc_time;  //当前帧时间戳,精确到秒,非关键帧时间戳需根据帧率来计算(仅I帧有效)8字节
    HLE_U32 length;         //帧数据长度
    HLE_U64 pts_msec;       //毫秒级时间戳,一直累加,溢出后自动回绕
} P2P_FRAME_HDR; //32字节

App实现实时音视频解码:

  • Android音视频的硬解码:如果是一个长时间做音视频工程师来说Android的硬解码绝对是个苦恼的东西,因为以前Android的机型太多太散,而且低中高端机型差异太大,导致硬件和系统不能完全兼容,所以在以前这种情况非常明显表现为在各个机型视频的显示各有不同,有些机型正常显示有些机型有绿屏,有些有马赛克现象出现,虽然网路上有对绿屏这种现象有解决方案:例如如果YUV数据源的排布(YUV420SP,NV21)和解码器不同的话会有这种问题,这种情况还算好解决,但是有些Android机型对于一些YUV排布就不支持,例如我在小米手机以及三星手机同一份代码效果都不尽相同,考虑到很多的不确定性Android的硬解码似乎是鸡肋。

    Android的硬解码真的是鸡肋吗?我觉得未必,这个也要看你的应用场景因为现在的Android中端手机,中高端,高端,甚至旗舰手机的硬解码的稳定性都已经不错,如果你是在这种应用场景下那么硬解码确实是一个首选,因为硬解码对比软解有天然的优势:就是调用GPU的专门模块编码来解,减少CPU运算,对CPU等硬件要求也相对低点。软解需要CPU运算,变相加大CPU负担耗电增加很多。硬件解码是将原来全部交由CPU来处理的视频数据的一部分交由GPU来做,而GPU的并行运算能力要远远高于CPU,这样可以大大的降低对CPU的负载,CPU的占用率较低了之后就可以同时运行一些其他的程序了。

硬件码优势:更加省电,适合长时间的移动端视频播放器和直播,手机电池有限的情况下,使用硬件解码会更加好。减少CPU的占用,可以把CUP让给别的线程使用,有利于手机的流畅度。

软解码优势:具有更好的适应性,软件解码主要是会占用CUP的运行,软解不考虑社备的硬件解码支持情况,有CPU就可以使用了,但是占用了更多的CUP那就意味着很耗费性能,很耗电,在设备电量充足的情况下,或者设备硬件解码支持不足的情况下使用软件解码更加好!

Android 音频硬解码的话那么当时是首先使用 MediaCodec 来实现,首先初始化MediaFormat 做一些解码前的配置,里面包含了解析SPS,PPS的参数,然后想 MediaCodec 填充 MediaFormat 以及数据Buffer等待解码,

int i = mMC.dequeueInputBuffer(BUFFER_TIMEOUT);

然后等待解码输出到Buffer即可:

int i = mMC.dequeueOutputBuffer(mBI, BUFFER_TIMEOUT);

很简单,只要处理好dequeueInputBuffer,dequeueOutputBuffer的顺序以及Buffer变量的数据就可以实现这个功能了,如果你的数据源是MP4文件那么只需要通过 MediaExtractor 来获取音频/视频的轨道,单独来进行解码即可

Android音视频的软解码:
软解码首推的就是ffmpeg,ffmpeg的使用还是很简单的,简单的来说你只需要一开始初始化 编解码格式对象 AVCodecContext 与编解码器 AVCodec ,然后把数据填充
AvPacket ,然后解码成 AvFrame 就可以了

值得一说的是:ffmpeg自3.1版本加入了android mediacodec硬解支持,
使用方法:

  • 首先在编译ffmpeg期间要加上这些编译选项:
--enable-jni
--enable-mediacodec
--enable-decoder=h264_mediacodec
--enable-hwaccel=h264_mediacodec(不知道有什么用,还是开了)
  • 其次在JNI_OnLoad函数,或者使用解码器之前调用
    av_jni_set_java_vm(vm, NULL);(位于libavcodec/jni.h)
    来设置java虚拟机(反调mediacodec时会用到)

  • 再次,由于h264_mediacodec解码器和h.264解码器id相同所以,软解时,通过
    avcodec_find_decoder(id)来寻找解码器
    而想使用mediacodec硬解时,使用
    avcodec_find_decoder_by_name("h264_mediacodec");寻找指定硬解解码器

App 音视软解的播放:

  • 音频的重采样:有时候在音频播放的时候,会出现你的音源与播放设备的硬件条件不匹配,例如播放每帧的样本数不匹配,采样位数不匹配的情况,那么这个时候需要用到对于音源PCM重采样,重采样以后才能正常播放,
int len = swr_convert(actx,outArr,frame->nb_samples,(const uint8_t **)frame->data,frame->nb_samples);

主要是通过 swr_convert 来进行转换

/** Convert audio.
 *
 * in and in_count can be set to 0 to flush the last few samples out at the
 * end.
 *
 * If more input is provided than output space, then the input will be buffered.
 * You can avoid this buffering by using swr_get_out_samples() to retrieve an
 * upper bound on the required number of output samples for the given number of
 * input samples. Conversion will run directly without copying whenever possible.
 *
 * @param s         allocated Swr context, with parameters set
 * @param out       output buffers, only the first one need be set in case of packed audio
 * @param out_count amount of space available for output in samples per channel
 * @param in        input buffers, only the first one need to be set in case of packed audio
 * @param in_count  number of input samples available in one channel
 *
 * @return number of samples output per channel, negative value on error
 */
int swr_convert(struct SwrContext *s, uint8_t **out, int out_count,
                                const uint8_t **in , int in_count);

out表示的是输出buffer的指针;
out_count表示的是输出的样本大小;
in表示的输入buffer的指针;
in_count表示的是输入样品的大小;

转换成功后输出的音频数据再拿来播放就可以在指定的条件进行指定的播放

  • 音频软解码的播放:这种情况下一般我们推荐的还是利用 OpenSLES 来播放
    //设置回调函数,播放队列空调用
    (*pcmQue)->RegisterCallback(pcmQue,PcmCall,this);
    //设置为播放状态
    (*iplayer)->SetPlayState(iplayer,SL_PLAYSTATE_PLAYING);
    //启动队列回调
    (*pcmQue)->Enqueue(pcmQue,"",1);
  • 音频的硬解码播放:这种情况下播放使用SDK自带的 AudioTrack 来进行播放,大致步骤如下:
this.audioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, 44100,
                AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT,
                audioData.length, AudioTrack.MODE_STATIC);
                this.audioTrack.write(audioData, 0, audioData.length);
        audioTrack.play();

App 视频的播放:

  • 视频软解播放:这个当然是首先 opengles ,拿到YUV数据,设置好贴图坐标,使用YUV数据分别贴图来播放显示,例子如下:
        sh.GetTexture(0,width,height,data[0]);  // Y
        if(type == XTEXTURE_YUV420P)
        {
            sh.GetTexture(1,width/2,height/2,data[1]);  // U
            sh.GetTexture(2,width/2,height/2,data[2]);  // V
        }
        else
        {
            sh.GetTexture(1,width/2,height/2,data[1], true);  // UV
        }
        sh.Draw();
  • 视频硬解播放:这种情况下一般使用SDK自带的 SurfaceView 显示控件,然后拿到他的 Surface
mDecoder.configure(sps_nal, pps_nal,surfaceViewDecode.getHolder().getSurface());

然后再配置解码器的时候把这个配置进去,接完码以后他就会把解码数据填充到 Surface 来进行播放显示

mMC.configure(mMF, surface, null, 0);

App实现截图拍照:

  • 不论是硬解码,还是软解码最后出来的数据应该都是YUV数据那么,利用YUV数据生成图片方法很多,要看具体需求,例如可以直接生成图片代码:
    /**
     * 把一帧yuv数据保存为bitmap
     * @param yuv 数据流
     * @param mWidth 图片的宽
     * @param mHeight 图片的高
     * @return bitmap 对象
     *
     */
    public Bitmap saveYUV2Bitmap(byte[] yuv, int mWidth, int mHeight) {
        YuvImage image = new YuvImage(yuv, ImageFormat.NV21, mWidth, mHeight, null);
        ByteArrayOutputStream stream = new ByteArrayOutputStream();
        image.compressToJpeg(new Rect(0, 0, mWidth, mHeight), 100, stream);
        Bitmap bmp = BitmapFactory.decodeByteArray(stream.toByteArray(), 0, stream.size());
        try {
            stream.flush();
            stream.close();
        } catch (IOException e) {
            e.printStackTrace();
        }

        return bmp;
    }

也可以把YUV数据按照公式转换为RGB,然后利用RGB再生成你想要的东西

App实现录制视频:
录制视频说白了就是封包,把编码过的音频AAC,视频H.264封装为一个数据格式,常见的格式Mp4,TS等等

  • 如果是ffmpeg软解码的话那么封包相对来说还算好,因为ffmpeg的SDK里面就包含了封包的方法:
    初始化三个** AVFormatContext** 容器,一个音频一个视频的用来作为输入的AAC,H.264的容器,另外一个作为输出的容器,还有一个 AVOutputFormat
    输出格式化对象,简单的来说就是读出一个AvPacket然后处理好PTS,DTS以后往对应流的输出容器去写即可,涉及的函数:
avformat_open_input():打开输入文件。
avcodec_copy_context():赋值AVCodecContext的参数。
avformat_alloc_output_context2():初始化输出文件。
avio_open():打开输出文件。
avformat_write_header():写入文件头。
av_compare_ts():比较时间戳,决定写入视频还是写入音频。这个函数相对要少见一些。
av_read_frame():从输入文件读取一个AVPacket。
av_interleaved_write_frame():写入一个AVPacket到输出文件。
av_write_trailer():写入文件尾。

  • 如果是硬解码实现封包的话:可以利用Android SDK提供的 MediaMuxer 封包类,以及 MediaFormat 音/视频轨道格式化的对象,我们自己构造两个 MediaFormat 来表示音视频的格式化对象并且初始化他们,然后用addTrack添加进 MediaMuxer 表示支持的轨道
mVideoTrackIndex = mMediaMuxer.addTrack(format);

然后构造Buffer数据,以及做好音频数据以及视频数据的先后顺序,分别写入 MediaMuxer

mMediaMuxer.writeSampleData(mVideoTrackIndex, buffer, info);
(这种情况我用得比较少,如果有疑问大家可以给我留言)

App实现音视频同步:

  • 音视频同步的话选择一般来说有以下三种:
将视频同步到音频上:就是以音频的播放速度为基准来同步视频。
将音频同步到视频上:就是以视频的播放速度为基准来同步音频。
将视频和音频同步外部的时钟上:选择一个外部时钟为基准,视频和音频的播放速度都以该时钟为标准。

这三种是最基本的策略,考虑到人对声音的敏感度要强于视频,频繁调节音频会带来较差的观感体验,且音频的播放时钟为线性增长,所以一般会以音频时钟为参考时钟,视频同步到音频上,音频作为主导视频作为次要,用视频流来同步音频流,由于不论是哪一个平台播放音频的引擎,都可以保证播放音频的时间长度与实际这段音频所代表的时间长度是一致的,所以我们可以依赖于音频的顺序播放为我们提供的时间戳,当客户端代码请求发送视频帧的时候,会先计算出当前视频队列头部的视频帧元素的时间戳与当前音频播放帧的时间戳的差值。如果在阈值范围内,就可以渲染这一帧视频帧;如果不在阈值范围内,则要进行对齐操作。具体的对齐操作方法就是:如果当前队列头部的视频帧的时间戳小于当前播放音频帧的时间戳,那么就进行跳帧操作(具体的跳帧操作可以是加快速度播放的实现,也可以是丢弃一部分视频帧的实现 );如果大于当前播放音频帧的时间戳,那么就进行等待(重复渲染上一帧或者不进行渲染)的操作。其优点是音频可以连续地播放,缺点是视频画面有可能会有跳帧的操作,但是对于视频画面的丢帧和跳帧,用户的眼睛是不太容易分辨得出来的

一般来说视频丢帧是我们常见的处理视频慢于音频的方式,可以先计算出需要加快多少时间,然后根据一个GOP算出每一帧的时间是多少,可以得出需要丢多少帧,然后丢帧的时候要注意的是必须要判断,不能把I帧丢了,否则接下来的P帧就根本用不了,而应该丢的是P帧,也就是一个GOP的后半部分,最合适的情况就是丢一整个GOP,如果是丢GOP后半部分的话你需要一开始播放GOP的时候弄一个变量记录当前是第几个P帧了,然后计算出需要丢几个P帧才能和音频同步,然后到了那一个需要丢的帧到来的时候直接抛弃,即到下一个I帧到来的时候才进行渲染(这里面有可能丢的不是那么准确,可能需要经过几个的丢帧步骤才能准确同步)

好了,我们Android音视频开发杂谈就介绍到这了,如果大家喜欢欢迎留言讨论,如果需求强烈的话我会再把一些细节的部分整理写出来,😁下一篇我们会接着侃侃IOS平台的音视频内容,想看IOS的出门右转即可

《IOS App项目中音视频开发杂谈》

···

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容