【聲 明】
首先,這一系列文章均基于自己的理解和實(shí)踐速侈,可能有不對的地方率寡,歡迎大家指正。
其次倚搬,這是一個(gè)入門系列冶共,涉及的知識也僅限于夠用,深入的知識網(wǎng)上也有許許多多的博文供大家學(xué)習(xí)了每界。
最后比默,寫文章過程中,會借鑒參考其他人分享的文章盆犁,會在文章最后列出命咐,感謝這些作者的分享。
碼字不易谐岁,轉(zhuǎn)載請注明出處醋奠!
教程代碼:【Github傳送門】 |
---|
目錄
一、Android音視頻硬解碼篇:
- 1伊佃,音視頻基礎(chǔ)知識
- 2窜司,音視頻硬解碼流程:封裝基礎(chǔ)解碼框架
- 3,音視頻播放:音視頻同步
- 4航揉,音視頻解封和封裝:生成一個(gè)MP4
二塞祈、使用OpenGL渲染視頻畫面篇
- 1,初步了解OpenGL ES
- 2帅涂,使用OpenGL渲染視頻畫面
- 3议薪,OpenGL渲染多視頻,實(shí)現(xiàn)畫中畫
- 4媳友,深入了解OpenGL之EGL
- 5斯议,OpenGL FBO數(shù)據(jù)緩沖區(qū)
- 6,Android音視頻硬編碼:生成一個(gè)MP4
三醇锚、Android FFmpeg音視頻解碼篇
- 1哼御,F(xiàn)Fmpeg so庫編譯
- 2,Android 引入FFmpeg
- 3,Android FFmpeg視頻解碼播放
- 4恋昼,Android FFmpeg+OpenSL ES音頻解碼播放
- 5看靠,Android FFmpeg+OpenGL ES播放視頻
- 6,Android FFmpeg簡單合成MP4:視屏解封與重新封裝
- 7液肌,Android FFmpeg視頻編碼
本文你可以了解到
本文主要簡介Android使用硬解碼API實(shí)現(xiàn)硬解碼的流程挟炬,包含MediaCodec輸入輸出緩沖、MediaCodec解碼流程矩屁、解碼代碼封裝和講解辟宗。
一爵赵、簡介
MediaCodec 是Android 4.1(api 16)版本引入的編解碼接口吝秕,同時(shí)支持音視頻的編碼和解碼。
一定要好好理解接下來這兩幅圖空幻,因?yàn)楹罄m(xù)的代碼就是基于這兩幅圖來編寫的烁峭。
數(shù)據(jù)流
首先,來看看MediaCodec的數(shù)據(jù)流秕铛,也是官方Api文檔中的约郁,很多文章都會引用。
仔細(xì)看一下但两,MediaCodec將數(shù)據(jù)分為兩部分鬓梅,分別為input(左邊)和output(右邊),即輸入和輸出兩個(gè)數(shù)據(jù)緩沖區(qū)谨湘。
input:是給客戶端輸入需要解碼的數(shù)據(jù)(解碼時(shí))或者需要編碼的數(shù)據(jù)(編碼時(shí))绽快。
output:是輸出解碼好(解碼時(shí))或者編碼好(編碼時(shí))的數(shù)據(jù)給客戶端。
MediaCodec內(nèi)部使用異步的方式對input和output數(shù)據(jù)進(jìn)行處理紧阔。MediaCodec將處理好input的數(shù)據(jù)坊罢,填充到output緩沖區(qū),交給客戶端渲染或處理
注:客戶端處理完數(shù)據(jù)后擅耽,必須手動(dòng)釋放output緩沖區(qū)活孩,否則將會導(dǎo)致MediaCodec輸出緩沖被占用,無法繼續(xù)解碼乖仇。
狀態(tài)
依然是一副來自官方的狀態(tài)圖
再仔細(xì)看看這幅圖憾儒,整體上分為三個(gè)大的狀態(tài):Sotpped、Executing乃沙、Released航夺。
- Stoped:包含了3個(gè)小狀態(tài):Error、Uninitialized崔涂、Configured阳掐。
首先,新建MediaCodec后,會進(jìn)入U(xiǎn)ninitialized狀態(tài)缭保;
其次汛闸,調(diào)用configure方法配置參數(shù)后,會進(jìn)入Configured艺骂;
- Executing:同樣包含3個(gè)小狀態(tài):Flushed诸老、Running、End of Stream钳恕。
再次别伏,調(diào)用start方法后,MediaCodec進(jìn)入Flushed狀態(tài)忧额;
接著厘肮,調(diào)用dequeueInputBuffer方法后,進(jìn)入Running狀態(tài)睦番;
最后类茂,當(dāng)解碼/編碼結(jié)束時(shí),進(jìn)入End of Stream(EOF)狀態(tài)托嚣。
這時(shí)巩检,一個(gè)視頻就處理完成了。
- Released:最后示启,如果想結(jié)束整個(gè)數(shù)據(jù)處理過程兢哭,可以調(diào)用release方法,釋放所有的資源夫嗓。
那么迟螺,F(xiàn)lushed是什么狀態(tài)呢?
從圖中我們可以看到啤月,在Running或者End of Stream狀態(tài)時(shí)煮仇,都可以調(diào)用flush方法,重新進(jìn)入Flushed狀態(tài)谎仲。
當(dāng)我們在解碼過程中浙垫,進(jìn)入了End of Stream后,解碼器就不再接收輸入了郑诺,這時(shí)候夹姥,需要調(diào)用flush方法,重新進(jìn)入接收數(shù)據(jù)狀態(tài)辙诞。
或者辙售,我們在播放視頻過程中旦部,想進(jìn)行跳播容燕,這時(shí)候蝗茁,我們需要Seek到指定的時(shí)間點(diǎn),這時(shí)候粘舟,也需要調(diào)用flush方法霞揉,清除緩沖,否則解碼時(shí)間戳?xí)靵y。
再次強(qiáng)調(diào)一下,一定要好好理解這兩幅圖,因?yàn)楹罄m(xù)的代碼就是基于這兩幅圖來編寫的。
二畔裕、解碼流程
MediaCodec有兩種工作模式垢粮,分別為異步模式和同步模式毫蚓,這里我們使用同步模式,異步模式可以參考官網(wǎng)例子。
根據(jù)官方的數(shù)據(jù)流圖和狀態(tài)圖,畫出一個(gè)最基礎(chǔ)的解碼流程如下:
經(jīng)過初始化和配置以后,進(jìn)入循環(huán)解碼流程店读,不斷的輸入數(shù)據(jù)文虏,然后獲取解碼完數(shù)據(jù)剃氧,最后渲染出來妥箕,直到所有數(shù)據(jù)解碼完成(End of Stream)宇葱。
三印颤、開始解碼
根據(jù)上面的流程圖咸产,可以發(fā)現(xiàn),無論音頻還是視頻锐朴,解碼流程基本是一致的兴喂,不同的地方只在于【配置】蔼囊、【渲染】兩個(gè)部分焚志。
定義解碼器
因此,我們將整個(gè)解碼流程抽象為一個(gè)解碼基類:BaseDecoder畏鼓,為了規(guī)范代碼和更好的拓展性酱酬,我們先定義一個(gè)解碼器:IDecoder,繼承Runnable云矫。
interface IDecoder: Runnable {
/**
* 暫停解碼
*/
fun pause()
/**
* 繼續(xù)解碼
*/
fun goOn()
/**
* 停止解碼
*/
fun stop()
/**
* 是否正在解碼
*/
fun isDecoding(): Boolean
/**
* 是否正在快進(jìn)
*/
fun isSeeking(): Boolean
/**
* 是否停止解碼
*/
fun isStop(): Boolean
/**
* 設(shè)置狀態(tài)監(jiān)聽器
*/
fun setStateListener(l: IDecoderStateListener?)
/**
* 獲取視頻寬
*/
fun getWidth(): Int
/**
* 獲取視頻高
*/
fun getHeight(): Int
/**
* 獲取視頻長度
*/
fun getDuration(): Long
/**
* 獲取視頻旋轉(zhuǎn)角度
*/
fun getRotationAngle(): Int
/**
* 獲取音視頻對應(yīng)的格式參數(shù)
*/
fun getMediaFormat(): MediaFormat?
/**
* 獲取音視頻對應(yīng)的媒體軌道
*/
fun getTrack(): Int
/**
* 獲取解碼的文件路徑
*/
fun getFilePath(): String
}
定義了解碼器的一些基礎(chǔ)操作膳沽,如暫停/繼續(xù)/停止解碼,獲取視頻的時(shí)長让禀,視頻的寬高挑社,解碼狀態(tài)等等
為什么繼承Runnable?
這里使用的是同步模式解碼巡揍,需要不斷循環(huán)壓入和拉取數(shù)據(jù)痛阻,是一個(gè)耗時(shí)操作,因此腮敌,我們將解碼器定義為一個(gè)Runnable阱当,最后放到線程池中執(zhí)行俏扩。
接著,繼承IDecoder弊添,定義基礎(chǔ)解碼器BaseDecoder录淡。
首先來看下基礎(chǔ)參數(shù):
abstract class BaseDecoder: IDecoder {
//-------------線程相關(guān)------------------------
/**
* 解碼器是否在運(yùn)行
*/
private var mIsRunning = true
/**
* 線程等待鎖
*/
private val mLock = Object()
/**
* 是否可以進(jìn)入解碼
*/
private var mReadyForDecode = false
//---------------解碼相關(guān)-----------------------
/**
* 音視頻解碼器
*/
protected var mCodec: MediaCodec? = null
/**
* 音視頻數(shù)據(jù)讀取器
*/
protected var mExtractor: IExtractor? = null
/**
* 解碼輸入緩存區(qū)
*/
protected var mInputBuffers: Array<ByteBuffer>? = null
/**
* 解碼輸出緩存區(qū)
*/
protected var mOutputBuffers: Array<ByteBuffer>? = null
/**
* 解碼數(shù)據(jù)信息
*/
private var mBufferInfo = MediaCodec.BufferInfo()
private var mState = DecodeState.STOP
private var mStateListener: IDecoderStateListener? = null
/**
* 流數(shù)據(jù)是否結(jié)束
*/
private var mIsEOS = false
protected var mVideoWidth = 0
protected var mVideoHeight = 0
//省略后面的方法
....
}
首先,我們定義了線程相關(guān)的資源油坝,用于判斷是否持續(xù)解碼的mIsRunning嫉戚,掛起線程的mLock等。
然后澈圈,就是解碼相關(guān)的資源了彼水,比如MdeiaCodec本身,輸入輸出緩沖极舔,解碼狀態(tài)等等凤覆。
其中纪挎,有一個(gè)解碼狀態(tài)DecodeState和音視頻數(shù)據(jù)讀取器IExtractor搪花。
定義解碼狀態(tài)
為了方便記錄解碼狀態(tài),這里使用一個(gè)枚舉類表示
enum class DecodeState {
/**開始狀態(tài)*/
START,
/**解碼中*/
DECODING,
/**解碼暫停*/
PAUSE,
/**正在快進(jìn)*/
SEEKING,
/**解碼完成*/
FINISH,
/**解碼器釋放*/
STOP
}
定義音視頻數(shù)據(jù)分離器
前面說過栏尚,MediaCodec需要我們不斷地喂數(shù)據(jù)給輸入緩沖渤刃,那么數(shù)據(jù)從哪里來呢拥峦?肯定是音視頻文件了,這里的IExtractor就是用來提取音視頻文件中數(shù)據(jù)流卖子。
Android自帶有一個(gè)音視頻數(shù)據(jù)讀取器MediaExtractor略号,同樣為了方便維護(hù)和拓展性,我們依然先定一個(gè)讀取器IExtractor洋闽。
interface IExtractor {
/**
* 獲取音視頻格式參數(shù)
*/
fun getFormat(): MediaFormat?
/**
* 讀取音視頻數(shù)據(jù)
*/
fun readBuffer(byteBuffer: ByteBuffer): Int
/**
* 獲取當(dāng)前幀時(shí)間
*/
fun getCurrentTimestamp(): Long
/**
* Seek到指定位置玄柠,并返回實(shí)際幀的時(shí)間戳
*/
fun seek(pos: Long): Long
fun setStartPos(pos: Long)
/**
* 停止讀取數(shù)據(jù)
*/
fun stop()
}
最重要的一個(gè)方法就是readBuffer,用于讀取音視頻數(shù)據(jù)流
定義解碼流程
前面我們只貼出了解碼器的參數(shù)部分诫舅,接下來羽利,貼出最重要的部分,也就是解碼流程部分刊懈。
abstract class BaseDecoder: IDecoder {
//省略參數(shù)定義部分这弧,見上
.......
final override fun run() {
mState = DecodeState.START
mStateListener?.decoderPrepare(this)
//【解碼步驟:1. 初始化,并啟動(dòng)解碼器】
if (!init()) return
while (mIsRunning) {
if (mState != DecodeState.START &&
mState != DecodeState.DECODING &&
mState != DecodeState.SEEKING) {
waitDecode()
}
if (!mIsRunning ||
mState == DecodeState.STOP) {
mIsRunning = false
break
}
//如果數(shù)據(jù)沒有解碼完畢虚汛,將數(shù)據(jù)推入解碼器解碼
if (!mIsEOS) {
//【解碼步驟:2. 將數(shù)據(jù)壓入解碼器輸入緩沖】
mIsEOS = pushBufferToDecoder()
}
//【解碼步驟:3. 將解碼好的數(shù)據(jù)從緩沖區(qū)拉取出來】
val index = pullBufferFromDecoder()
if (index >= 0) {
//【解碼步驟:4. 渲染】
render(mOutputBuffers!![index], mBufferInfo)
//【解碼步驟:5. 釋放輸出緩沖】
mCodec!!.releaseOutputBuffer(index, true)
if (mState == DecodeState.START) {
mState = DecodeState.PAUSE
}
}
//【解碼步驟:6. 判斷解碼是否完成】
if (mBufferInfo.flags == MediaCodec.BUFFER_FLAG_END_OF_STREAM) {
mState = DecodeState.FINISH
mStateListener?.decoderFinish(this)
}
}
doneDecode()
//【解碼步驟:7. 釋放解碼器】
release()
}
/**
* 解碼線程進(jìn)入等待
*/
private fun waitDecode() {
try {
if (mState == DecodeState.PAUSE) {
mStateListener?.decoderPause(this)
}
synchronized(mLock) {
mLock.wait()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 通知解碼線程繼續(xù)運(yùn)行
*/
protected fun notifyDecode() {
synchronized(mLock) {
mLock.notifyAll()
}
if (mState == DecodeState.DECODING) {
mStateListener?.decoderRunning(this)
}
}
/**
* 渲染
*/
abstract fun render(outputBuffers: ByteBuffer,
bufferInfo: MediaCodec.BufferInfo)
/**
* 結(jié)束解碼
*/
abstract fun doneDecode()
}
在Runnable的run回調(diào)方法中匾浪,集成了整個(gè)解碼流程:
- 【解碼步驟:1. 初始化,并啟動(dòng)解碼器】
abstract class BaseDecoder: IDecoder {
//省略上面已有代碼
......
private fun init(): Boolean {
//1.檢查參數(shù)是否完整
if (mFilePath.isEmpty() || File(mFilePath).exists()) {
Log.w(TAG, "文件路徑為空")
mStateListener?.decoderError(this, "文件路徑為空")
return false
}
//調(diào)用虛函數(shù)卷哩,檢查子類參數(shù)是否完整
if (!check()) return false
//2.初始化數(shù)據(jù)提取器
mExtractor = initExtractor(mFilePath)
if (mExtractor == null ||
mExtractor!!.getFormat() == null) return false
//3.初始化參數(shù)
if (!initParams()) return false
//4.初始化渲染器
if (!initRender()) return false
//5.初始化解碼器
if (!initCodec()) return false
return true
}
private fun initParams(): Boolean {
try {
val format = mExtractor!!.getFormat()!!
mDuration = format.getLong(MediaFormat.KEY_DURATION) / 1000
if (mEndPos == 0L) mEndPos = mDuration
initSpecParams(mExtractor!!.getFormat()!!)
} catch (e: Exception) {
return false
}
return true
}
private fun initCodec(): Boolean {
try {
//1.根據(jù)音視頻編碼格式初始化解碼器
val type = mExtractor!!.getFormat()!!.getString(MediaFormat.KEY_MIME)
mCodec = MediaCodec.createDecoderByType(type)
//2.配置解碼器
if (!configCodec(mCodec!!, mExtractor!!.getFormat()!!)) {
waitDecode()
}
//3.啟動(dòng)解碼器
mCodec!!.start()
//4.獲取解碼器緩沖區(qū)
mInputBuffers = mCodec?.inputBuffers
mOutputBuffers = mCodec?.outputBuffers
} catch (e: Exception) {
return false
}
return true
}
/**
* 檢查子類參數(shù)
*/
abstract fun check(): Boolean
/**
* 初始化數(shù)據(jù)提取器
*/
abstract fun initExtractor(path: String): IExtractor
/**
* 初始化子類自己特有的參數(shù)
*/
abstract fun initSpecParams(format: MediaFormat)
/**
* 初始化渲染器
*/
abstract fun initRender(): Boolean
/**
* 配置解碼器
*/
abstract fun configCodec(codec: MediaCodec, format: MediaFormat): Boolean
}
初始化方法中蛋辈,分為5個(gè)步驟,看起很復(fù)雜殉疼,實(shí)際很簡單梯浪。
檢查參數(shù)是否完整:路徑是否有效等
初始化數(shù)據(jù)提取器:初始化Extractor
初始化參數(shù):提取一些必須的參數(shù)捌年,duration,width挂洛,height等
初始化渲染器:視頻不需要礼预,音頻為AudioTracker
-
初始化解碼器:初始化MediaCodec
在initCodec()中,
val type = mExtractor!!.getFormat()!!.getString(MediaFormat.KEY_MIME) mCodec = MediaCodec.createDecoderByType(type)
初始化MediaCodec的時(shí)候:
- 首先虏劲,通過Extractor獲取到音視頻數(shù)據(jù)的編碼信息MediaFormat托酸;
- 然后,查詢MediaFormat中的編碼類型(如video/avc柒巫,即H264励堡;audio/mp4a-latm,即AAC)堡掏;
- 最后应结,調(diào)用createDecoderByType創(chuàng)建解碼器。
需要說明的是:由于音頻和視頻的初始化稍有不同泉唁,所以定義了幾個(gè)虛函數(shù)鹅龄,將不同的東西交給子類去實(shí)現(xiàn)。具體將在下一篇文章[音視頻播放:音視頻同步]說明亭畜。
- 【解碼步驟:2. 將數(shù)據(jù)壓入解碼器輸入緩沖】
直接進(jìn)入pushBufferToDecoder方法中
abstract class BaseDecoder: IDecoder {
//省略上面已有代碼
......
private fun pushBufferToDecoder(): Boolean {
var inputBufferIndex = mCodec!!.dequeueInputBuffer(2000)
var isEndOfStream = false
if (inputBufferIndex >= 0) {
val inputBuffer = mInputBuffers!![inputBufferIndex]
val sampleSize = mExtractor!!.readBuffer(inputBuffer)
if (sampleSize < 0) {
//如果數(shù)據(jù)已經(jīng)取完扮休,壓入數(shù)據(jù)結(jié)束標(biāo)志:BUFFER_FLAG_END_OF_STREAM
mCodec!!.queueInputBuffer(inputBufferIndex, 0, 0,
0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
isEndOfStream = true
} else {
mCodec!!.queueInputBuffer(inputBufferIndex, 0,
sampleSize, mExtractor!!.getCurrentTimestamp(), 0)
}
}
return isEndOfStream
}
}
調(diào)用了以下方法:
- 查詢是否有可用的輸入緩沖,返回緩沖索引拴鸵。其中參數(shù)2000為等待2000ms玷坠,如果填入-1則無限等待。
var inputBufferIndex = mCodec!!.dequeueInputBuffer(2000)
- 通過緩沖索引 inputBufferIndex 獲取可用的緩沖區(qū)劲藐,并使用Extractor提取待解碼數(shù)據(jù)八堡,填充到緩沖區(qū)中。
val inputBuffer = mInputBuffers!![inputBufferIndex]
val sampleSize = mExtractor!!.readBuffer(inputBuffer)
- 調(diào)用queueInputBuffer將數(shù)據(jù)壓入解碼器瘩燥。
mCodec!!.queueInputBuffer(inputBufferIndex, 0,
sampleSize, mExtractor!!.getCurrentTimestamp(), 0)
注意:如果SampleSize返回-1秕重,說明沒有更多的數(shù)據(jù)了。
這個(gè)時(shí)候厉膀,queueInputBuffer的最后一個(gè)參數(shù)要傳入結(jié)束標(biāo)記MediaCodec.BUFFER_FLAG_END_OF_STREAM。
- 【解碼步驟:3. 將解碼好的數(shù)據(jù)從緩沖區(qū)拉取出來】
直接進(jìn)入pullBufferFromDecoder()
abstract class BaseDecoder: IDecoder {
//省略上面已有代碼
......
private fun pullBufferFromDecoder(): Int {
// 查詢是否有解碼完成的數(shù)據(jù)二拐,index >=0 時(shí)服鹅,表示數(shù)據(jù)有效,并且index為緩沖區(qū)索引
var index = mCodec!!.dequeueOutputBuffer(mBufferInfo, 1000)
when (index) {
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {}
MediaCodec.INFO_TRY_AGAIN_LATER -> {}
MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED -> {
mOutputBuffers = mCodec!!.outputBuffers
}
else -> {
return index
}
}
return -1
}
}
第一百新、調(diào)用dequeueOutputBuffer方法查詢是否有解碼完成的可用數(shù)據(jù)企软,其中mBufferInfo用于獲取數(shù)據(jù)幀信息,第二參數(shù)是等待時(shí)間饭望,這里等待1000ms仗哨,填入-1是無限等待形庭。
var index = mCodec!!.dequeueOutputBuffer(mBufferInfo, 1000)
第二、判斷index類型:
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:輸出格式改變了
MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED:輸入緩沖改變了
MediaCodec.INFO_TRY_AGAIN_LATER:沒有可用數(shù)據(jù)厌漂,等會再來
大于等于0:有可用數(shù)據(jù)萨醒,index就是輸出緩沖索引
- 【解碼步驟:4. 渲染】
這里調(diào)用了一個(gè)虛函數(shù)render,也就是將渲染交給子類
- 【解碼步驟:5. 釋放輸出緩沖】
調(diào)用releaseOutputBuffer方法苇倡, 釋放輸出緩沖區(qū)富纸。
注:第二個(gè)參數(shù),是個(gè)boolean旨椒,命名為render晓褪,這個(gè)參數(shù)在視頻解碼時(shí),用于決定是否要將這一幀數(shù)據(jù)顯示出來综慎。
mCodec!!.releaseOutputBuffer(index, true)
- 【解碼步驟:6. 判斷解碼是否完成】
還記得我們在把數(shù)據(jù)壓入解碼器時(shí)涣仿,當(dāng)sampleSize < 0 時(shí),壓入了一個(gè)結(jié)束標(biāo)記嗎示惊?
當(dāng)接收到這個(gè)標(biāo)志后变过,解碼器就知道所有數(shù)據(jù)已經(jīng)接收完畢,在所有數(shù)據(jù)解碼完成以后涝涤,會在最后一幀數(shù)據(jù)加上結(jié)束標(biāo)記信息媚狰,即
if (mBufferInfo.flags == MediaCodec.BUFFER_FLAG_END_OF_STREAM) {
mState = DecodeState.FINISH
mStateListener?.decoderFinish(this)
}
- 【解碼步驟:7. 釋放解碼器】
在while循環(huán)結(jié)束后,釋放掉所有的資源阔拳。至此崭孤,一次解碼結(jié)束。
abstract class BaseDecoder: IDecoder {
//省略上面已有代碼
......
private fun release() {
try {
mState = DecodeState.STOP
mIsEOS = false
mExtractor?.stop()
mCodec?.stop()
mCodec?.release()
mStateListener?.decoderDestroy(this)
} catch (e: Exception) {
}
}
}
最后糊肠,解碼器定義的其他方法(如pause辨宠、goOn、stop等)不再細(xì)說货裹,可查看工程源碼嗤形。
結(jié)尾
本來打算把音頻和視頻播放部分也放到本篇來講,最后發(fā)現(xiàn)篇幅太長弧圆,不利于閱讀赋兵,看了會累。所以把真正實(shí)現(xiàn)播放部分和下一篇【音視頻播放:音視頻同步】做一個(gè)整合搔预,內(nèi)容和長度都會更合理霹期。
so,下一篇見拯田!