從0到1實(shí)現(xiàn)Web端H.265播放器:視頻解碼篇
前言
回顧
什么是H.265?
本文在這里就不對(duì)H.265做介紹了。感興趣的朋友可以看下面的文章了解詳情。(第一篇是我們?cè)?019年3月發(fā)布的文章,距今已有2年,時(shí)間過(guò)得真快)《Web端H.265播放器研發(fā)解密》[1]
WebAssembly的發(fā)展
看了上面那篇2年前的文章應(yīng)該清楚了瀏覽器對(duì)于H.265支持程度。好消息是經(jīng)過(guò)兩年發(fā)展,Webassembly發(fā)布了1.1版本,增加了很多新特性,性能也有了提升。壞消息是瀏覽器依然不支持H.265,估計(jì)以后也不可能會(huì)支持了。所以呢兩年后的今天如果我們要在瀏覽器里播放H.265還是需要借用Webassembly+FFmpeg的能力。本文也不多加介紹了,細(xì)節(jié)看下面的鏈接吧。Webassembly[3]FFmpeg[4]

現(xiàn)狀
這篇文章的目的是?
H.265播放器(Videox.js)在淘寶直播落地已經(jīng)近兩年了。之前的架構(gòu)設(shè)計(jì)主要針對(duì)的是直播的場(chǎng)景,播放m3u8和flv的直播流,由于直播落地的場(chǎng)景是B端主播中控臺(tái),使用場(chǎng)景是可以預(yù)覽畫面即可,故而對(duì)幀率要求不高。但是今年的短視頻業(yè)務(wù)面向的多是C端用戶,需要在Web場(chǎng)景下播放1080P/720P的H.265視頻,那么必須滿足短視頻主流分辨率+碼率流暢播放的要求。同時(shí)業(yè)務(wù)上還要支持多視頻格式如(mp4/fmp4)的需求,所以綜合評(píng)估后對(duì)原有架構(gòu)進(jìn)行了升級(jí)。既然有了升級(jí)自然就需要沉淀下經(jīng)驗(yàn)。按照一貫套路我就來(lái)水一篇文章了。當(dāng)然這兩年內(nèi)業(yè)界也有大量H.265播放器的實(shí)踐落地,我寫這篇文章也是借這次重構(gòu)的機(jī)會(huì)分享自己的一些經(jīng)驗(yàn),希望能幫助各位少踩些坑。
視頻演示
如下將演示新版播放器播放 1分鐘1080p/25fps/H.265 MP4視頻,具體視頻參數(shù)如下:

預(yù)加載1000000幀(即整個(gè)視頻),完全解碼不播放的內(nèi)存占用、CPU占用、解碼間隔時(shí)間
因?yàn)檎麄€(gè)解碼過(guò)程沒有進(jìn)行播放,所以解碼間隔=單幀解碼耗時(shí)。
從上面視頻能看出來(lái),一個(gè)幾十M的文件完全解碼能達(dá)到4.6G的內(nèi)存占用,CPU占用高達(dá)300以上(4核)。當(dāng)然,這是完全不做限制,火力全開解碼。但也能得出結(jié)論:無(wú)干擾情況下平均解碼一幀1080p僅需要13ms(基于mbp2015版)。
舊版直播播放器解碼720p需要26ms(基于mbp2015版),而新版播放器播1080p目前的13ms還不是極限,后續(xù)將繼續(xù)探索優(yōu)化空間。
預(yù)加載10幀并解碼,后續(xù)邊播邊解的相關(guān)數(shù)據(jù)
演示1太過(guò)極端不符合日常使用的場(chǎng)景,但因?yàn)闃O限情況平均解碼只需要13ms,而視頻幀率是25(即間隔40ms),所以可以隔一段時(shí)間喂幾幀到解碼器,這樣平衡了播放和解碼的速率之后,CPU占用降到120左右、內(nèi)存占用降低到了300M。同時(shí)還能流暢播放。不過(guò)播放策略有很多種,各位有更好的方案也歡迎和我交流。
架構(gòu)設(shè)計(jì)
整體架構(gòu)設(shè)計(jì)

上圖所示為新播放器基本骨架,包含了主要模塊。模塊間互相獨(dú)立,各自接收通用協(xié)議的參數(shù)。比如Loader傳遞給Demuxer的數(shù)據(jù)為ArrayBuffer,經(jīng)Demuxer統(tǒng)一解封裝成Packet格式Buffer數(shù)據(jù)(Annex-B)喂給Renderer。上圖用MP4舉例(HVCC為H.265碼流格式之一),替換成flv、ts格式也是遵循這個(gè)流程。Renderer負(fù)責(zé)decoder調(diào)度,音畫同步、音視頻播放等,可以說(shuō)是播放器最核心的模塊。UI View則主要用來(lái)繪制播放器控件UI,如進(jìn)度條等。本文不打算詳細(xì)介紹每個(gè)功能,僅對(duì)decoder做細(xì)節(jié)解構(gòu),其它有關(guān)聯(lián)的模塊僅簡(jiǎn)單說(shuō)明和實(shí)現(xiàn)。
DEMO架構(gòu)
因?yàn)闆]有Demuxer,所以直接用Loader讀取Annex-B碼流。
通過(guò)Loader讀取到Annex-B碼流的Uint8Array數(shù)據(jù) 通過(guò)postMessge將數(shù)據(jù)發(fā)送給Worker線程的WASM包解碼 WASM通過(guò)回調(diào)函數(shù)傳回YUV數(shù)據(jù)給Worker再通過(guò)postMessage傳給主線程Canvas 
實(shí)操步驟
如何將 FFmpeg 編譯成 WASM 包
接下來(lái)就進(jìn)入正題了,第一步,先編譯FFmpeg做精簡(jiǎn),為啥呢?因?yàn)镕Fmpeg不光是個(gè)C庫(kù),還是非常龐大的C庫(kù)。我們要在Web上使用它就需要移除一些無(wú)用的模塊,好在FFmpeg提供了相應(yīng)配置的能力,使用根目錄configure文件按如下步驟操作即可。
1. 準(zhǔn)備
編譯前我們需要去emscripten官網(wǎng)[7]下載最新版emsdk
emsdk就是用來(lái)把FFmpeg編譯成wasm包的工具
官網(wǎng)FFmpeg[8] 下載源碼版的FFmpeg(本文基于4.1)
2. 編譯FFmpeg靜態(tài)庫(kù)
創(chuàng)建 make_decoder.sh
echo "Beginning Build:"
rm -r ./ffmpeg-lite
mkdir -p ./ffmpeg-lite # dist目錄
cd ../ffmpeg # src目錄,ffmpeg源碼
make clean
emconfigure ./configure --cc="emcc" --cxx="em++" --ar="emar" --ranlib="emranlib" --prefix=$(pwd)/../ffmpeg-wasm/ffmpeg-lite --enable-cross-compile --target-os=none --arch=x86_32 --cpu=generic \
--enable-gpl --enable-version3 \
--disable-swresample --disable-postproc --disable-logging --disable-everything \
--disable-programs --disable-asm --disable-doc --disable-network --disable-debug \
--disable-iconv --disable-sdl2 \ # 三方庫(kù)
--disable-avdevice \ # 設(shè)備
--disable-avformat \ # 格式
--disable-avfilter \ # 濾鏡
--disable-decoders \ # 解碼器
--disable-encoders \ # 編碼器
--disable-hwaccels \ # 硬件加速
--disable-demuxers \ # 解封裝
--disable-muxers \ # 封裝
--disable-parsers \ # 解析器
--disable-protocols \ # 協(xié)議
--disable-bsfs \ # bit stream filter,碼流轉(zhuǎn)換
--disable-indevs \ # 輸入設(shè)備
--disable-outdevs \ #輸出設(shè)備
--disable-filters \ # 濾鏡
--enable-decoder=hevc \
--enable-parser=hevc
make
make install
因?yàn)閣asm支持的能力還是比較有限,一些FFmpeg用來(lái)優(yōu)化性能的模塊都需要禁用(比如硬件加速、匯編等)。本文也僅介紹解碼。所以播放涉及的功能只用到了hevc-decoder(hevc=h265),其它的通通禁掉。
執(zhí)行make_decoder.sh在ffmpeg-lite文件夾內(nèi)生成簡(jiǎn)化后的FFmpeg靜態(tài)庫(kù)和對(duì)應(yīng)的.h聲明文件。

3. 編寫入口文件
編譯完依賴庫(kù)不代表就直接能用了,還需要自己動(dòng)手寫入口文件的代碼去調(diào)用FFmpeg的接口,這一步就需要你稍微懂一點(diǎn)點(diǎn)c語(yǔ)言了。我們起個(gè)名字叫decoder.c
初始化解碼器
首先我們調(diào)用init_decoder初始化解碼器,依次初始化codec、dec_ctx、parser、frame、pkt。frame和pkt作為全局變量用來(lái)給后面交換數(shù)據(jù)使用。init_decoder接收一個(gè)JS回調(diào)函數(shù)作為入?yún)ⅰ:竺嫱ㄟ^(guò)這個(gè)回調(diào)函數(shù)給JS worker線程回傳數(shù)據(jù)。回調(diào)函數(shù)聲明定義了三個(gè)入?yún)ⅲ来问菙?shù)據(jù)開始地址、長(zhǎng)度、以及pts。本文暫不涉及pts,不傳也可以。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <libavcodec/avcodec.h>
#include <libavutil/imgutils.h>
typedef void(*OnBuffer)(unsigned char* data_y, int size, int pts);
AVCodec *codec = NULL;
AVCodecContext *dec_ctx = NULL;
AVCodecParserContext *parser_ctx = NULL;
AVPacket *pkt = NULL;
AVFrame *frame = NULL;
OnBuffer decoder_callback = NULL;
void init_decoder(OnBuffer callback) {
// 找到hevc解碼器
codec = avcodec_find_decoder(AV_CODEC_ID_HEVC);
// 初始化對(duì)應(yīng)的解析器
parser_ctx = av_parser_init(codec->id);
// 初始化上下文
dec_ctx = avcodec_alloc_context3(codec);
// 打開decoder
avcodec_open2(dec_ctx, codec, NULL);
// 分配一個(gè)frame內(nèi)存,并指明yuv 420p格式
frame = av_frame_alloc();
frame->format = AV_PIX_FMT_YUV420P;
// 分配一個(gè)pkt內(nèi)存
pkt = av_packet_alloc();
// 暫存回調(diào)
decoder_callback = callback;
}
uint8轉(zhuǎn)AVPacket
這一步就是接收J(rèn)S的視頻數(shù)據(jù)給到av_parser_parse2方法,av_parser_parse2接收任意長(zhǎng)度的buffer數(shù)據(jù),并從buffer中解析出avpacket結(jié)構(gòu)直到?jīng)]有數(shù)據(jù)為止。avpacket存放了壓縮的媒體數(shù)據(jù),如果是視頻類型,則通常表示一幀,音頻數(shù)據(jù)表示N幀。下面節(jié)選了一段FFmpeg源碼注釋
This structure stores compressed data. It is typically exported by demuxers and then passed as input to decoders, or received as output from encoders and then passed to muxers. For video, it should typically contain one compressed frame. For audio it may contain several compressed frames. Encoders are allowed to output empty packets, with no compressed data, containing only side data (e.g. to update some stream parameters at the end of encoding).
void decode_buffer(uint8_t* buffer, size_t data_size) { // 入?yún)⑹莏s傳入的uint8array數(shù)據(jù)以及數(shù)據(jù)長(zhǎng)度
while (data_size > 0) {
// 從buffer中解析出packet
int size = av_parser_parse2(parser_ctx, dec_ctx, &pkt->data, &pkt->size,
buffer, data_size, AV_NOPTS_VALUE, AV_NOPTS_VALUE, 0);
if (size < 0) {
break;
}
buffer += size;
data_size -= size;
if (pkt->size) {
// 解碼packet
decode_packet(dec_ctx, frame, pkt);
}
}
}
解碼AVPacket,接收AVFrame
拿到avpacket之后,需要調(diào)用avcodec_send_packet把數(shù)據(jù)扔給解碼器解碼,上面已經(jīng)說(shuō)到了音頻數(shù)據(jù)一個(gè)packet可能包含了多個(gè)幀(即avframe),所以通過(guò)一個(gè)while循環(huán)調(diào)用avcodec_receive_frame從解碼器中取出avframe數(shù)據(jù)。直到它返回AVERROR(EAGAIN)、AVERROR_EOF或錯(cuò)誤。avframe包含的就是解碼后的數(shù)據(jù)了。
AVERROR(EAGAIN)表示packet數(shù)據(jù)消費(fèi)完了,需要新數(shù)據(jù)。而AVERROR_EOF則是當(dāng)你輸入的pkt->data為NULL時(shí)會(huì)觸發(fā)。解碼器一般會(huì)緩存幾幀的數(shù)據(jù),當(dāng)你想拿到這些數(shù)據(jù)時(shí)就需要傳遞NULL的pkt給解碼器。
avcodec_send_packet是4.x版本的新解口,3.x是avcodec_decode_video2和avcodec_decode_audio4。前者如上面所說(shuō),輸入一次,輸出多次。后者則是當(dāng)pkt數(shù)據(jù)不足以產(chǎn)生frame的時(shí)候,需要在后續(xù)數(shù)據(jù)到來(lái)時(shí)合并數(shù)據(jù)并重新調(diào)用方法進(jìn)行解碼。
int decode_packet(AVCodecContext* ctx, AVFrame* frame, AVPacket* pkt)
{
int ret = 0;
// 發(fā)送packet到解碼器
ret = avcodec_send_packet(dec, pkt);
if (ret < 0) {
return ret;
}
// 從解碼器接收f(shuō)rame
while (ret >= 0) {
ret = avcodec_receive_frame(dec, frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
break;
} else if (ret < 0) {
// handle error
break;
}
// 輸出yuv buffer數(shù)據(jù)
output_yuv_buffer(frame);
}
return ret;
}
AVFrame轉(zhuǎn)YUV uint8
拿到解碼后的avframe數(shù)據(jù)后我們需要把它的傳遞給JS,但因?yàn)閍vframe的數(shù)據(jù)是個(gè)雙層數(shù)組。而我們需要把它轉(zhuǎn)換成uint8再傳給JS線程。
YUV 圖像有兩種存儲(chǔ)格式:
緊縮格式(packed formats): Y、U、V 三通道像素值依次排列,即 Y0 U0 V0 Y1 U1 V1 ... 平面格式(planar formats): 先排列 Y 的所有像素值,再排列 U,最后排列 V YUV420p 中使用平面格式,水平 2:1 取樣,垂直 2:1 采樣,即每 4 個(gè) Y 分量對(duì)應(yīng)一個(gè) U、V 分量

如上圖所示,我們編寫代碼把a(bǔ)vframe數(shù)據(jù)依次copy到y(tǒng)uv_buffer中,并使用decoder_callback傳給JS線程
實(shí)際上你這一步怎么存都可以,但在渲染的時(shí)候你得依據(jù)存的順序取出數(shù)據(jù)并按420p的方式渲染
void output_yuv_buffer(AVFrame *frame) {
int width, height, frame_size;
uint8_t *yuv_buffer = NULL;
width = frame->width;
height = frame->height;
// 根據(jù)格式,獲取buffer大小
frame_size = av_image_get_buffer_size(frame->format, width, height, 1);
// 分配內(nèi)存
yuv_buffer = (uint8_t *)av_mallocz(frame_size * sizeof(uint8_t));
// 將frame數(shù)據(jù)按照yuv的格式依次填充到bufferr中。下面的步驟可以用工具函數(shù)av_image_copy_to_buffer代替。
int i, j, k;
// Y
for(i = 0; i < height; i++) {
memcpy(yuv_buffer + width*i,
frame->data[0]+frame->linesize[0]*i,
width);
}
for(j = 0; j < height / 2; j++) {
memcpy(yuv_buffer + width * i + width / 2 * j,
frame->data[1] + frame->linesize[1] * j,
width / 2);
}
for(k =0; k < height / 2; k++) {
memcpy(yuv_buffer + width * i + width / 2 * j + width / 2 * k,
frame->data[2] + frame->linesize[2] * k,
width / 2);
}
// 通過(guò)之前傳入的回調(diào)函數(shù)發(fā)給js
decoder_callback(yuv_buffer, frame_size, frame->pts);
av_free(yuv_buffer);
}
以上就是入口文件的所有代碼,我盡量用最簡(jiǎn)化的代碼呈現(xiàn)。總共包含了init_decoder、decode_buffer、decode_packet、output_yuv_buffer。其它不關(guān)鍵的部分都省略了,比如(close_decoder、異常處理等)
注意:因?yàn)榫幾g時(shí)沒有包含demux、bsfs。所以decoder_buffer接收的buffer數(shù)據(jù)必須是annexb碼流。
4. 編譯WASM包
終于到了本小節(jié)的尾聲,把入口文件+依賴庫(kù)編譯成wasm包。這一步比較簡(jiǎn)單,依然是創(chuàng)建一個(gè)build_decoder.sh,按下面的代碼編寫,然后執(zhí)行即可。
export TOTAL_MEMORY=67108864
export EXPORTED_FUNCTIONS="[ \
'_init_decoder', \
'_decode_buffer'
]"
echo "Running Emscripten..."
# 入口文件+3個(gè)依賴庫(kù)文件
emcc decoder.c ffmpeg-lite/lib/libavcodec.a ffmpeg-lite/lib/libavutil.a ffmpeg-lite/lib/libswscale.a \
-O2 \
-I "ffmpeg-lite/include" \
-s WASM=1 \
-s ASSERTIONS=1 \
-s LLD_REPORT_UNDEFINED \
-s NO_EXIT_RUNTIME=1 \
-s DISABLE_EXCEPTION_CATCHING=1 \
-s TOTAL_MEMORY=${TOTAL_MEMORY} \
-s EXPORTED_FUNCTIONS="${EXPORTED_FUNCTIONS}" \
-s EXTRA_EXPORTED_RUNTIME_METHODS="['addFunction', 'removeFunction']" \
-s RESERVED_FUNCTION_POINTERS=14 \
-s FORCE_FILESYSTEM=1 \
-o ./wasm/libffmpeg.js
echo "Finished Build"
EXPORTED_FUNCTIONS就是入口文件里需要對(duì)外暴露的方法了。記得前面加_
構(gòu)建產(chǎn)物如下:

libffmpeg.js就是wasm包的JS入口文件
JS如何加載并調(diào)用WASM包方法
Worker部分
本環(huán)節(jié)到了我們的主場(chǎng)領(lǐng)域,編寫JS代碼(采用了TypeScript語(yǔ)法,應(yīng)該不影響閱讀吧)。由于WASM代碼需要跑在worker線程。所以下面代碼的環(huán)境變量只能在worker中訪問(wèn)
decoder.ts
export class Decoder extends EventEmitter<IEventMap> {
M: any
init(M: any) {
// M = self.Module 即wasm環(huán)境變量
this.M = M
// 創(chuàng)建wasm的回調(diào)函數(shù),viii表示有3個(gè)int參數(shù)
const callback = this.M.addFunction(this._handleYUVData, 'viii')
// 通過(guò)我們上面decoder.c文件的方法傳入回調(diào)
this.M._init_decoder(callback)
}
decode(packet: IPacket) {
const { data } = packet
const typedArray = data
const bufferLength = typedArray.length
// 申請(qǐng)內(nèi)存區(qū),并放入數(shù)據(jù)
const bufferPtr = this.M._malloc(bufferLength)
this.M.HEAPU8.set(typedArray, bufferPtr)
// 解碼buffer
this.M._decode_buffer(bufferPtr, bufferLength)
// 釋放內(nèi)存區(qū)
this.M._free(bufferPtr)
}
private _handleYUVData = (start: number, size: number, pts: number) => {
// 回調(diào)傳回來(lái)的第一個(gè)參數(shù)是yuv_buffer的內(nèi)存起始索引
const u8s = this.M.HEAPU8.subarray(start, start + size)
const output = new Uint8Array(u8s)
this.emit('decoded-frame', {
data: output,
pts,
})
}
}
decoder-manager.ts
因?yàn)閃orker線程加載wasm文件是異步的,需要在onRuntimeInitialized之后才能調(diào)用wasm方法,所以寫了一個(gè)簡(jiǎn)單的manager管理decoder。
import { Decoder } from './decoder'
const global = self as any
export class DecoderManager {
loaded = false
decoder = new Decoder()
cachePackets: IPacket[] = []
load() {
// 表明wasm文件的位置
global.Module = {
locateFile: (wasm: string) => './wasm/' + wasm,
}
global.importScripts('./wasm/libffmpeg.js')
// 初始化之后,執(zhí)行一次push,把緩存的packet送到decoder里
global.Module.onRuntimeInitialized = () => {
this.loaded = true
this.decoder.init(global.Module)
this.push([])
}
this.decoder.on('decoded-frame', this.handleYUVBuffer)
}
push(packets: IPacket[]) {
// 沒加載就緩存起來(lái),加載了就先取緩存
if (!this.loaded) {
this.cachePackets = this.cachePackets.concat(packets)
} else {
if (this.cachePackets.length) {
this.cachePackets.forEach((frame) => this.decoder.decode(frame))
this.cachePackets = []
}
packets.forEach((frame) => this.decoder.decode(frame))
}
}
handleYUVBuffer = (frame) => {
global.postMessage({
type: 'decoded-frame',
data: frame,
})
}
}
const manager = new DecoderManager()
manager.load()
self.onmessage = function(event) {
const data = event.data
const type = data.type
switch (type) {
case 'decode':
manager.push(data.data)
break
}
}
JS主線程部分
這一步為加載worker代碼并進(jìn)行通信。加載worker的流程很簡(jiǎn)單,使用webpack+worker-loader即可,然后用fetch遞歸讀取數(shù)據(jù)并發(fā)送給worker線程,編碼器接收到數(shù)據(jù)就會(huì)進(jìn)行解碼。
import Worker from 'worker-loader!../worker/decoder-manager'
const worker = new Worker()
const url = 'http://xx.com' // 碼流地址
fetch(url)
.then((res) => {
if (res.body) {
const reader = res.body.getReader()
const read = () => {
// 遞歸讀取buffer數(shù)據(jù)
reader.read().then((json) => {
if (!json.done) {
worker.postMessage({
type: 'decode',
data: [{
data: json
}],
})
read()
}
})
}
read()
}
})
結(jié)語(yǔ)
按照上面的代碼就可以實(shí)現(xiàn)一個(gè)簡(jiǎn)易的H.265解碼器,如下是用JS仿照前文所列舉的AVPacket和AVFrame結(jié)構(gòu)打印出來(lái)的數(shù)據(jù):
解碼前:從JS主線程傳遞給WASM的數(shù)據(jù)

解碼后:從WASM傳遞給JS主線程的數(shù)據(jù)

上圖對(duì)比可以看出解碼后的數(shù)據(jù)量有多么恐怖,所以就像在開始的視頻里所演示的,解碼完成后的內(nèi)存管理十分重要。
以上就是H.265視頻解碼篇的全部?jī)?nèi)容了。音頻解碼同樣可以復(fù)用上面的鏈路去解碼,也可以使用瀏覽器自帶的decodeAudioData。音頻播放則是使用AudioContext。目前主流的音頻編碼格式瀏覽器都支持。最后希望上面的經(jīng)驗(yàn)分享能夠幫大家少踩點(diǎn)坑。另外除了播放H.265以外,F(xiàn)Fmpeg也可以做很多視頻處理的工作。大家可以思維發(fā)散暢想可能的應(yīng)用場(chǎng)景,后續(xù)也將帶來(lái)更多播放器系列文章。
盡請(qǐng)期待:《從0到1實(shí)現(xiàn)Web端H.265播放器:mp4/fmp4 demux篇》 《從0到1實(shí)現(xiàn)Web端H.265播放器:YUV數(shù)據(jù)渲染篇》 ...
參考資料
《Web端H.265播放器研發(fā)解密》: https://fed.taobao.org/blog/taofed/do71ct/web-player-h265/
[3]Webassembly: https://webassembly.org/
[4]FFmpeg: https://zh.wikipedia.org/wiki/FFmpeg
[7]emscripten官網(wǎng): https://emscripten.org/docs/getting_started/downloads.html
[8]FFmpeg: https://ffmpeg.org/download.html
最后
如果你覺得這篇內(nèi)容對(duì)你挺有啟發(fā),我想邀請(qǐng)你幫我三個(gè)小忙:
點(diǎn)個(gè)「在看」,讓更多的人也能看到這篇內(nèi)容(喜歡不點(diǎn)在看,都是耍流氓 -_-)
歡迎加我微信「huab119」拉你進(jìn)技術(shù)群,長(zhǎng)期交流學(xué)習(xí)...
關(guān)注公眾號(hào)「前端勸退師」,持續(xù)為你推送精選好文,也可以加我為好友,隨時(shí)聊騷。

