今天我們來(lái)講講如何使用MediaExtractor + MediaCodec實(shí)現(xiàn)一個(gè)簡(jiǎn)易的播放器遭殉。
我們都知道MediaCodec是Android 環(huán)境下的硬編解碼器刁标,而MediaExtractor 則給我們提供了讀取視頻等媒體文件信息的功能。
如何使用MediaExtractor 和 MediaCodec 實(shí)現(xiàn)一個(gè)簡(jiǎn)易的播放器呢蚓庭?其實(shí)并不難致讥,整體的流程如下:
1、創(chuàng)建視頻解碼線程
2器赞、創(chuàng)建音頻解碼線程
3垢袱、開(kāi)始視頻解碼
4、開(kāi)始音頻解碼
5港柜、解碼播放延時(shí)同步
首先请契,我們來(lái)看看視頻解碼線程如何實(shí)現(xiàn):
視頻解碼的大體流程
1、獲取視頻的軌道信息
2潘懊、創(chuàng)建MediaCodec
3姚糊、將解復(fù)用得到的數(shù)據(jù)傳遞給解碼器
4、獲取解碼后的數(shù)據(jù)
5授舟、顯示輸出
實(shí)現(xiàn)代碼如下:
/**
* 視頻解碼線程
*/
private class VideoDecodeThread extends Thread {
@Override
public void run() {
MediaExtractor videoExtractor = new MediaExtractor();
MediaCodec videoCodec = null;
try {
videoExtractor.setDataSource(filePath);
} catch (IOException e) {
e.printStackTrace();
}
int videoTrackIndex;
// 獲取視頻所在軌道
videoTrackIndex = getTrackIndex(videoExtractor, "video/");
if (videoTrackIndex >= 0) {
MediaFormat mediaFormat = videoExtractor.getTrackFormat(videoTrackIndex);
int width = mediaFormat.getInteger(MediaFormat.KEY_WIDTH);
int height = mediaFormat.getInteger(MediaFormat.KEY_HEIGHT);
float time = mediaFormat.getLong(MediaFormat.KEY_DURATION) / 1000000;
if (mListener != null) {
mListener.videoAspect(width, height, time);
}
videoExtractor.selectTrack(videoTrackIndex);
try {
videoCodec = MediaCodec.createDecoderByType(mediaFormat.getString(MediaFormat.KEY_MIME));
videoCodec.configure(mediaFormat, surface, null, 0);
} catch (IOException e) {
e.printStackTrace();
}
}
if (videoCodec == null) {
if (VERBOSE) {
Log.d(TAG, "video decoder is unexpectedly null");
}
return;
}
videoCodec.start();
MediaCodec.BufferInfo videoBufferInfo = new MediaCodec.BufferInfo();
ByteBuffer[] inputBuffers = videoCodec.getInputBuffers();
boolean isVideoEOS = false;
long startMs = System.currentTimeMillis();
while (!Thread.interrupted() && !cancel) {
if (isPlaying) {
// 暫停
if (isPause) {
continue;
}
// 將資源傳遞到解碼器
if (!isVideoEOS) {
isVideoEOS = decodeMediaData(videoExtractor, videoCodec, inputBuffers);
}
// 獲取解碼后的數(shù)據(jù)
int outputBufferIndex = videoCodec.dequeueOutputBuffer(videoBufferInfo, TIMEOUT_US);
switch (outputBufferIndex) {
case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:
if (VERBOSE) {
Log.d(TAG, "INFO_OUTPUT_FORMAT_CHANGED");
}
break;
case MediaCodec.INFO_TRY_AGAIN_LATER:
if (VERBOSE) {
Log.d(TAG, "INFO_TRY_AGAIN_LATER");
}
break;
case MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED:
if (VERBOSE) {
Log.d(TAG, "INFO_OUTPUT_BUFFERS_CHANGED");
}
break;
default:
// 延遲解碼
decodeDelay(videoBufferInfo, startMs);
// 釋放資源
videoCodec.releaseOutputBuffer(outputBufferIndex, true);
break;
}
// 結(jié)尾
if ((videoBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
Log.v(TAG, "buffer stream end");
break;
}
}
}
// 釋放解碼器
videoCodec.stop();
videoCodec.release();
videoExtractor.release();
}
}
其中救恨,解復(fù)用的方法如下:
/**
* 解復(fù)用,得到需要解碼的數(shù)據(jù)
* @param extractor
* @param decoder
* @param inputBuffers
* @return
*/
private static boolean decodeMediaData(MediaExtractor extractor, MediaCodec decoder, ByteBuffer[] inputBuffers) {
boolean isMediaEOS = false;
int inputBufferIndex = decoder.dequeueInputBuffer(TIMEOUT_US);
if (inputBufferIndex >= 0) {
ByteBuffer inputBuffer = inputBuffers[inputBufferIndex];
int sampleSize = extractor.readSampleData(inputBuffer, 0);
if (sampleSize < 0) {
decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
isMediaEOS = true;
if (VERBOSE) {
Log.d(TAG, "end of stream");
}
} else {
decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, extractor.getSampleTime(), 0);
extractor.advance();
}
}
return isMediaEOS;
}
解碼延時(shí)實(shí)現(xiàn)如下:
/**
* 解碼延時(shí)
* @param bufferInfo
* @param startMillis
*/
private void decodeDelay(MediaCodec.BufferInfo bufferInfo, long startMillis) {
while (bufferInfo.presentationTimeUs / 1000 > System.currentTimeMillis() - startMillis) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
break;
}
}
}
同樣释树,音頻解碼線程跟視頻解碼線程大體類似肠槽。這里不做詳細(xì)介紹,直接上代碼:
/**
* 音頻解碼線程
*/
private class AudioDecodeThread extends Thread {
private int mInputBufferSize;
private AudioTrack audioTrack;
@Override
public void run() {
MediaExtractor audioExtractor = new MediaExtractor();
MediaCodec audioCodec = null;
try {
audioExtractor.setDataSource(filePath);
} catch (IOException e) {
e.printStackTrace();
}
for (int i = 0; i < audioExtractor.getTrackCount(); i++) {
MediaFormat mediaFormat = audioExtractor.getTrackFormat(i);
String mime = mediaFormat.getString(MediaFormat.KEY_MIME);
if (mime.startsWith("audio/")) {
audioExtractor.selectTrack(i);
int audioChannels = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
int audioSampleRate = mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE);
int minBufferSize = AudioTrack.getMinBufferSize(audioSampleRate,
(audioChannels == 1 ? AudioFormat.CHANNEL_OUT_MONO : AudioFormat.CHANNEL_OUT_STEREO),
AudioFormat.ENCODING_PCM_16BIT);
int maxInputSize = mediaFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE);
mInputBufferSize = minBufferSize > 0 ? minBufferSize * 4 : maxInputSize;
int frameSizeInBytes = audioChannels * 2;
mInputBufferSize = (mInputBufferSize / frameSizeInBytes) * frameSizeInBytes;
audioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
audioSampleRate,
(audioChannels == 1 ? AudioFormat.CHANNEL_OUT_MONO : AudioFormat.CHANNEL_OUT_STEREO),
AudioFormat.ENCODING_PCM_16BIT,
mInputBufferSize,
AudioTrack.MODE_STREAM);
audioTrack.play();
try {
audioCodec = MediaCodec.createDecoderByType(mime);
audioCodec.configure(mediaFormat, null, null, 0);
} catch (IOException e) {
e.printStackTrace();
}
break;
}
}
if (audioCodec == null) {
if (VERBOSE) {
Log.d(TAG, "audio decoder is unexpectedly null");
}
return;
}
audioCodec.start();
final ByteBuffer[] buffers = audioCodec.getOutputBuffers();
int sz = buffers[0].capacity();
if (sz <= 0) {
sz = mInputBufferSize;
}
byte[] mAudioOutTempBuf = new byte[sz];
MediaCodec.BufferInfo audioBufferInfo = new MediaCodec.BufferInfo();
ByteBuffer[] inputBuffers = audioCodec.getInputBuffers();
ByteBuffer[] outputBuffers = audioCodec.getOutputBuffers();
boolean isAudioEOS = false;
long startMs = System.currentTimeMillis();
while (!Thread.interrupted() && !cancel) {
if (isPlaying) {
// 暫停
if (isPause) {
continue;
}
// 解碼
if (!isAudioEOS) {
isAudioEOS = decodeMediaData(audioExtractor, audioCodec, inputBuffers);
}
// 獲取解碼后的數(shù)據(jù)
int outputBufferIndex = audioCodec.dequeueOutputBuffer(audioBufferInfo, TIMEOUT_US);
switch (outputBufferIndex) {
case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:
if (VERBOSE) {
Log.d(TAG, "INFO_OUTPUT_FORMAT_CHANGED");
}
break;
case MediaCodec.INFO_TRY_AGAIN_LATER:
if (VERBOSE) {
Log.d(TAG, "INFO_TRY_AGAIN_LATER");
}
break;
case MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED:
outputBuffers = audioCodec.getOutputBuffers();
if (VERBOSE) {
Log.d(TAG, "INFO_OUTPUT_BUFFERS_CHANGED");
}
break;
default:
ByteBuffer outputBuffer = outputBuffers[outputBufferIndex];
// 延時(shí)解碼奢啥,跟視頻時(shí)間同步
decodeDelay(audioBufferInfo, startMs);
// 如果解碼成功秸仙,則將解碼后的音頻PCM數(shù)據(jù)用AudioTrack播放出來(lái)
if (audioBufferInfo.size > 0) {
if (mAudioOutTempBuf.length < audioBufferInfo.size) {
mAudioOutTempBuf = new byte[audioBufferInfo.size];
}
outputBuffer.position(0);
outputBuffer.get(mAudioOutTempBuf, 0, audioBufferInfo.size);
outputBuffer.clear();
if (audioTrack != null)
audioTrack.write(mAudioOutTempBuf, 0, audioBufferInfo.size);
}
// 釋放資源
audioCodec.releaseOutputBuffer(outputBufferIndex, false);
break;
}
// 結(jié)尾了
if ((audioBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
if (VERBOSE) {
Log.d(TAG, "BUFFER_FLAG_END_OF_STREAM");
}
break;
}
}
}
// 釋放MediaCode 和AudioTrack
audioCodec.stop();
audioCodec.release();
audioExtractor.release();
audioTrack.stop();
audioTrack.release();
}
}
至此,我們就將簡(jiǎn)易播放器的核心功能實(shí)現(xiàn)了桩盲,完整的實(shí)現(xiàn)代碼如下:
public class SimplePlayer {
private static final String TAG = "Player";
private static final boolean VERBOSE = false;
private static final long TIMEOUT_US = 10000;
private IPlayStateListener mListener;
private VideoDecodeThread mVideoDecodeThread;
private AudioDecodeThread mAudioDecodeThread;
private boolean isPlaying;
private boolean isPause;
private String filePath;
private Surface surface;
// 是否取消播放線程
private boolean cancel = false;
public SimplePlayer(Surface surface, String filePath) {
this.surface = surface;
this.filePath = filePath;
isPlaying = false;
isPause = false;
}
/**
* 設(shè)置回調(diào)
* @param mListener
*/
public void setPlayStateListener(IPlayStateListener mListener) {
this.mListener = mListener;
}
/**
* 是否處于播放狀態(tài)
* @return
*/
public boolean isPlaying() {
return isPlaying && !isPause;
}
/**
* 開(kāi)始播放
*/
public void play() {
isPlaying = true;
if (mVideoDecodeThread == null) {
mVideoDecodeThread = new VideoDecodeThread();
mVideoDecodeThread.start();
}
if (mAudioDecodeThread == null) {
mAudioDecodeThread = new AudioDecodeThread();
mAudioDecodeThread.start();
}
}
/**
* 暫停
*/
public void pause() {
isPause = true;
}
/**
* 繼續(xù)播放
*/
public void continuePlay() {
isPause = false;
}
/**
* 停止播放
*/
public void stop() {
isPlaying = false;
}
/**
* 銷毀
*/
public void destroy() {
stop();
if (mAudioDecodeThread != null) {
mAudioDecodeThread.interrupt();
}
if (mVideoDecodeThread != null) {
mVideoDecodeThread.interrupt();
}
}
/**
* 解復(fù)用寂纪,得到需要解碼的數(shù)據(jù)
* @param extractor
* @param decoder
* @param inputBuffers
* @return
*/
private static boolean decodeMediaData(MediaExtractor extractor, MediaCodec decoder, ByteBuffer[] inputBuffers) {
boolean isMediaEOS = false;
int inputBufferIndex = decoder.dequeueInputBuffer(TIMEOUT_US);
if (inputBufferIndex >= 0) {
ByteBuffer inputBuffer = inputBuffers[inputBufferIndex];
int sampleSize = extractor.readSampleData(inputBuffer, 0);
if (sampleSize < 0) {
decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
isMediaEOS = true;
if (VERBOSE) {
Log.d(TAG, "end of stream");
}
} else {
decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, extractor.getSampleTime(), 0);
extractor.advance();
}
}
return isMediaEOS;
}
/**
* 解碼延時(shí)
* @param bufferInfo
* @param startMillis
*/
private void decodeDelay(MediaCodec.BufferInfo bufferInfo, long startMillis) {
while (bufferInfo.presentationTimeUs / 1000 > System.currentTimeMillis() - startMillis) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
break;
}
}
}
/**
* 獲取媒體類型的軌道
* @param extractor
* @param mediaType
* @return
*/
private static int getTrackIndex(MediaExtractor extractor, String mediaType) {
int trackIndex = -1;
for (int i = 0; i < extractor.getTrackCount(); i++) {
MediaFormat mediaFormat = extractor.getTrackFormat(i);
String mime = mediaFormat.getString(MediaFormat.KEY_MIME);
if (mime.startsWith(mediaType)) {
trackIndex = i;
break;
}
}
return trackIndex;
}
/**
* 視頻解碼線程
*/
private class VideoDecodeThread extends Thread {
@Override
public void run() {
MediaExtractor videoExtractor = new MediaExtractor();
MediaCodec videoCodec = null;
try {
videoExtractor.setDataSource(filePath);
} catch (IOException e) {
e.printStackTrace();
}
int videoTrackIndex;
// 獲取視頻所在軌道
videoTrackIndex = getTrackIndex(videoExtractor, "video/");
if (videoTrackIndex >= 0) {
MediaFormat mediaFormat = videoExtractor.getTrackFormat(videoTrackIndex);
int width = mediaFormat.getInteger(MediaFormat.KEY_WIDTH);
int height = mediaFormat.getInteger(MediaFormat.KEY_HEIGHT);
float time = mediaFormat.getLong(MediaFormat.KEY_DURATION) / 1000000;
if (mListener != null) {
mListener.videoAspect(width, height, time);
}
videoExtractor.selectTrack(videoTrackIndex);
try {
videoCodec = MediaCodec.createDecoderByType(mediaFormat.getString(MediaFormat.KEY_MIME));
videoCodec.configure(mediaFormat, surface, null, 0);
} catch (IOException e) {
e.printStackTrace();
}
}
if (videoCodec == null) {
if (VERBOSE) {
Log.d(TAG, "video decoder is unexpectedly null");
}
return;
}
videoCodec.start();
MediaCodec.BufferInfo videoBufferInfo = new MediaCodec.BufferInfo();
ByteBuffer[] inputBuffers = videoCodec.getInputBuffers();
boolean isVideoEOS = false;
long startMs = System.currentTimeMillis();
while (!Thread.interrupted() && !cancel) {
if (isPlaying) {
// 暫停
if (isPause) {
continue;
}
// 將資源傳遞到解碼器
if (!isVideoEOS) {
isVideoEOS = decodeMediaData(videoExtractor, videoCodec, inputBuffers);
}
// 獲取解碼后的數(shù)據(jù)
int outputBufferIndex = videoCodec.dequeueOutputBuffer(videoBufferInfo, TIMEOUT_US);
switch (outputBufferIndex) {
case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:
if (VERBOSE) {
Log.d(TAG, "INFO_OUTPUT_FORMAT_CHANGED");
}
break;
case MediaCodec.INFO_TRY_AGAIN_LATER:
if (VERBOSE) {
Log.d(TAG, "INFO_TRY_AGAIN_LATER");
}
break;
case MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED:
if (VERBOSE) {
Log.d(TAG, "INFO_OUTPUT_BUFFERS_CHANGED");
}
break;
default:
// 延遲解碼
decodeDelay(videoBufferInfo, startMs);
// 釋放資源
videoCodec.releaseOutputBuffer(outputBufferIndex, true);
break;
}
// 結(jié)尾
if ((videoBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
Log.v(TAG, "buffer stream end");
break;
}
}
}
// 釋放解碼器
videoCodec.stop();
videoCodec.release();
videoExtractor.release();
}
}
/**
* 音頻解碼線程
*/
private class AudioDecodeThread extends Thread {
private int mInputBufferSize;
private AudioTrack audioTrack;
@Override
public void run() {
MediaExtractor audioExtractor = new MediaExtractor();
MediaCodec audioCodec = null;
try {
audioExtractor.setDataSource(filePath);
} catch (IOException e) {
e.printStackTrace();
}
for (int i = 0; i < audioExtractor.getTrackCount(); i++) {
MediaFormat mediaFormat = audioExtractor.getTrackFormat(i);
String mime = mediaFormat.getString(MediaFormat.KEY_MIME);
if (mime.startsWith("audio/")) {
audioExtractor.selectTrack(i);
int audioChannels = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
int audioSampleRate = mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE);
int minBufferSize = AudioTrack.getMinBufferSize(audioSampleRate,
(audioChannels == 1 ? AudioFormat.CHANNEL_OUT_MONO : AudioFormat.CHANNEL_OUT_STEREO),
AudioFormat.ENCODING_PCM_16BIT);
int maxInputSize = mediaFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE);
mInputBufferSize = minBufferSize > 0 ? minBufferSize * 4 : maxInputSize;
int frameSizeInBytes = audioChannels * 2;
mInputBufferSize = (mInputBufferSize / frameSizeInBytes) * frameSizeInBytes;
audioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
audioSampleRate,
(audioChannels == 1 ? AudioFormat.CHANNEL_OUT_MONO : AudioFormat.CHANNEL_OUT_STEREO),
AudioFormat.ENCODING_PCM_16BIT,
mInputBufferSize,
AudioTrack.MODE_STREAM);
audioTrack.play();
try {
audioCodec = MediaCodec.createDecoderByType(mime);
audioCodec.configure(mediaFormat, null, null, 0);
} catch (IOException e) {
e.printStackTrace();
}
break;
}
}
if (audioCodec == null) {
if (VERBOSE) {
Log.d(TAG, "audio decoder is unexpectedly null");
}
return;
}
audioCodec.start();
final ByteBuffer[] buffers = audioCodec.getOutputBuffers();
int sz = buffers[0].capacity();
if (sz <= 0) {
sz = mInputBufferSize;
}
byte[] mAudioOutTempBuf = new byte[sz];
MediaCodec.BufferInfo audioBufferInfo = new MediaCodec.BufferInfo();
ByteBuffer[] inputBuffers = audioCodec.getInputBuffers();
ByteBuffer[] outputBuffers = audioCodec.getOutputBuffers();
boolean isAudioEOS = false;
long startMs = System.currentTimeMillis();
while (!Thread.interrupted() && !cancel) {
if (isPlaying) {
// 暫停
if (isPause) {
continue;
}
// 解碼
if (!isAudioEOS) {
isAudioEOS = decodeMediaData(audioExtractor, audioCodec, inputBuffers);
}
// 獲取解碼后的數(shù)據(jù)
int outputBufferIndex = audioCodec.dequeueOutputBuffer(audioBufferInfo, TIMEOUT_US);
switch (outputBufferIndex) {
case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:
if (VERBOSE) {
Log.d(TAG, "INFO_OUTPUT_FORMAT_CHANGED");
}
break;
case MediaCodec.INFO_TRY_AGAIN_LATER:
if (VERBOSE) {
Log.d(TAG, "INFO_TRY_AGAIN_LATER");
}
break;
case MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED:
outputBuffers = audioCodec.getOutputBuffers();
if (VERBOSE) {
Log.d(TAG, "INFO_OUTPUT_BUFFERS_CHANGED");
}
break;
default:
ByteBuffer outputBuffer = outputBuffers[outputBufferIndex];
// 延時(shí)解碼,跟視頻時(shí)間同步
decodeDelay(audioBufferInfo, startMs);
// 如果解碼成功赌结,則將解碼后的音頻PCM數(shù)據(jù)用AudioTrack播放出來(lái)
if (audioBufferInfo.size > 0) {
if (mAudioOutTempBuf.length < audioBufferInfo.size) {
mAudioOutTempBuf = new byte[audioBufferInfo.size];
}
outputBuffer.position(0);
outputBuffer.get(mAudioOutTempBuf, 0, audioBufferInfo.size);
outputBuffer.clear();
if (audioTrack != null)
audioTrack.write(mAudioOutTempBuf, 0, audioBufferInfo.size);
}
// 釋放資源
audioCodec.releaseOutputBuffer(outputBufferIndex, false);
break;
}
// 結(jié)尾了
if ((audioBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
if (VERBOSE) {
Log.d(TAG, "BUFFER_FLAG_END_OF_STREAM");
}
break;
}
}
}
// 釋放MediaCode 和AudioTrack
audioCodec.stop();
audioCodec.release();
audioExtractor.release();
audioTrack.stop();
audioTrack.release();
}
}
}
那么這樣還存在什么問(wèn)題呢捞蛋? 那就是同步和是否支持媒體流的問(wèn)題了。上面的代碼只是簡(jiǎn)單地獲取本地視頻文件柬姚,分別將視頻幀解碼顯示和音頻幀解碼播放出來(lái)拟杉,還存在同步問(wèn)題。同步無(wú)非就是追及過(guò)程量承,當(dāng)視頻幀播放快了搬设,則等待音頻幀播放完或者加快穴店、舍棄音頻幀,當(dāng)音頻播放快了拿穴,則判斷是否需要加快視頻幀的播放甚至舍棄視頻幀泣洞。這里不同的同步方式,產(chǎn)生了幾種不同的同步策略贞言,分別是視頻同步到音頻斜棚、音頻同步到視頻、以外部時(shí)鐘作為同步基準(zhǔn)该窗。詳細(xì)的策略可以參考ffplay的源碼。還有就是媒體流的支持問(wèn)題蚤霞,目前市面上的播放都是支持流媒體播放的酗失,媒體流肯定要支持的,使用MediaCodec做播放器昧绣,在應(yīng)對(duì)網(wǎng)絡(luò)抖動(dòng)方面還是比不上基于FFmpeg的軟解碼的播放器的规肴。如果想要做商用的播放器,個(gè)人建議還是使用FFmpeg實(shí)現(xiàn)會(huì)好很多夜畴,可參考的資料也更多拖刃,遇到什么問(wèn)題可以請(qǐng)教有經(jīng)驗(yàn)的前輩,幫助我們少踩坑贪绘。