前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Android开发笔记(一百二十六)自定义音乐播放器

Android开发笔记(一百二十六)自定义音乐播放器

作者头像
aqi00
发布2019-01-18 14:54:27
2.7K0
发布2019-01-18 14:54:27
举报
文章被收录于专栏:老欧说安卓老欧说安卓

MediaRecorder/MediaPlayer

在Android手机上面,音频的处理比视频还要复杂,这真是出人意料。在前面的博文《Android开发笔记(五十七)录像录音与播放》中,介绍了视频/音频的录制与播放,其中录像用的是MediaRecorder类,播放用的是MediaPlayer类。虽然Android还提供了专门的视频视图VideoView,但是该控件并非新的东西,而是继承了MediaRecorder和MediaPlayer,所以严格来说,Android上面只有一种视频的录制和播放方式。可是音频就大不一样了,Android提供了两种录音方式,以及至少三种常用的播音方式。两种录音方式分别是MediaRecorder类和AudioRecord类,而播音方式包括MediaPlayer类、AudioTrack类和SoundPool类,它们的使用场合各有千秋,且待笔者下面细细道来。 首先是MediaRecorder与MediaPlayer,这对组合即可用于录像,也可单独录制音频。它们处理的音频文件是压缩过的编码文件,通常用于录制和播放音乐,是最经常用到的。MediaRecorder与MediaPlayer在处理音频和视频时,整体流程是一样的,只有在部分方法的调用上有所差异,下面分别把录音/播音有关的方法列出来。 MediaRecorder的录音相关方法: reset : 重置录制资源 prepare : 准备录制 start : 开始录制 stop : 结束录制 release : 释放录制资源 setOnErrorListener : 设置错误监听器。可监听服务器异常以及未知错误的事件。 setOnInfoListener : 设置信息监听器。可监听录制结束事件,包括达到录制时长或者达到录制大小。 setAudioSource : 设置音频来源。一般使用麦克风AudioSource.MIC。 setOutputFormat : 设置媒体输出格式。OutputFormat.AMR_NB表示窄带格式,OutputFormat.AMR_WB表示宽带格式,AAC_ADTS表示高级的音频传输流格式。该方法要在setVideoEncoder之前调用,不然调用setAudioEncoder时会报错“java.lang.IllegalStateException”。 setAudioEncoder : 设置音频编码器。AudioEncoder.AMR_NB表示窄带编码,AudioEncoder.AMR_WB表示宽带编码,AudioEncoder.AAC表示低复杂度的高级编码,AudioEncoder.HE_AAC表示高效率的高级编码,AudioEncoder.AAC_ELD表示增强型低延迟的高级编码。 注意:setAudioEncoder应在setOutputFormat之后执行,否则会出现“setAudioEncoder called in an invalid state(2)”的异常。 setAudioSamplingRate : 设置音频的采样率,单位赫兹(Hz)。该方法为可选,AMRNB默认8khz,AMRWB默认16khz。 setAudioChannels : 设置音频的声道数。1表示单声道,2表示双声道。该方法为可选 setAudioEncodingBitRate : 设置音频每秒录制的字节数。越大则音频越清晰。该方法为可选 setMaxDuration : 设置录制时长。单位毫秒。 setMaxFileSize : 设置录制的媒体大小。单位字节。 setOutputFile : 设置输出文件的路径。 MediaPlayer的播音相关方法: reset : 重置播放器 prepare : 准备播放 start : 开始播放 pause : 暂停播放 stop : 停止播放 setOnPreparedListener : 设置准备播放监听器。 setOnCompletionListener : 设置结束播放监听器。 setOnSeekCompleteListener : 设置播放拖动监听器。 create : 创建指定Uri的播放器。 setDataSource : 设置播放数据来源。create与setDataSource只需设置其一。 setVolume : 设置音量。第一个参数是左声道,第二个参数是右声道,取值在0-1之间。 setAudioStreamType : 设置音频流的类型。AudioManager.STREAM_MUSIC表示音乐,AudioManager.STREAM_RING表示铃声,AudioManager.STREAM_ALARM表示闹钟,AudioManager.STREAM_NOTIFICATION表示通知。 setLooping : 设置是否循环播放。 isPlaying : 判断是否正在播放。 seekTo : 拖动播放进度到指定位置。 getCurrentPosition : 获取当前播放进度所在的位置。 getDuration : 获取播放时长。 下面是MediaRecorder与MediaPlayer组合处理音频的示例代码:

代码语言:javascript
复制
import java.io.File;

import com.example.exmaudio.util.Utils;

import android.app.Activity;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.MediaPlayer.OnCompletionListener;
import android.media.MediaRecorder.AudioEncoder;
import android.media.MediaRecorder.AudioSource;
import android.media.MediaRecorder.OnErrorListener;
import android.media.MediaRecorder.OnInfoListener;
import android.media.MediaRecorder;
import android.media.MediaRecorder.OutputFormat;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.util.Log;
import android.view.View.OnClickListener;
import android.view.View;
import android.view.Window;
import android.widget.Button;
import android.widget.TextView;

public class MediaRecordActivity extends Activity 
		implements OnClickListener, OnErrorListener, OnInfoListener {
	private static final String TAG = "MediaRecordActivity";
    private TextView tv_record;
    private Button btn_start;
    private Button btn_stop;
    private MediaRecorder mMediaRecorder;

    private TextView tv_play;
    private Button btn_play;
    private Button btn_pause;
    private MediaPlayer mMediaPlayer;
    private int mPosition;
    private boolean bFirstPlay = true;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(R.layout.activity_media_record);

        tv_record = (TextView) findViewById(R.id.tv_record);
        btn_start = (Button) findViewById(R.id.btn_start);
        btn_stop = (Button) findViewById(R.id.btn_stop);
        tv_play = (TextView) this.findViewById(R.id.tv_play);
        btn_play = (Button) findViewById(R.id.btn_play);
        btn_pause = (Button) findViewById(R.id.btn_pause);

        btn_start.setOnClickListener(this);
        btn_stop.setOnClickListener(this);
        btn_play.setOnClickListener(this);
        btn_pause.setOnClickListener(this);
        btn_start.setEnabled(true);
        btn_stop.setEnabled(false);
        btn_play.setEnabled(false);
        btn_pause.setEnabled(false);
        initPlay();
    }
    
	private void initPlay() {
        mMediaPlayer = new MediaPlayer();
        mMediaPlayer.setOnCompletionListener(new OnCompletionListener() {
            @Override
            public void onCompletion(MediaPlayer mp) {
        		btn_play.setEnabled(true);
        		btn_pause.setEnabled(false);
        		bFirstPlay = true;
        		mHandler.removeCallbacks(mPlayRun);
        		mPlayTime = 0;
            }
        });
	}

	private void preplay() {
		try {
			mMediaPlayer.reset();
			//mMediaPlayer.setVolume(0.5f, 0.5f);  //设置音量,可选
			mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
			String path = mRecordFile.getAbsolutePath();
			mMediaPlayer.setDataSource(path);
			Log.d(TAG, "audio path = "+path);
			mMediaPlayer.prepare();
		} catch (Exception e) {
			Log.d(TAG, "mMediaPlayer.prepare error: "+e.getMessage());
		}
		mPlayTime = 0;
	}
    
    private void startPlay() {
		try {
			if (bFirstPlay == true) {
				preplay();
				bFirstPlay = false;
			}
			mMediaPlayer.start();
		} catch (Exception e) {
			Log.d(TAG, "mMediaPlayer.start error: " + e.getMessage());
		}
		btn_play.setEnabled(false);
		btn_pause.setEnabled(true);
		mHandler.post(mPlayRun);
    }
    
	@Override
	protected void onPause() {
		// 先判断是否正在播放
		if (mMediaPlayer.isPlaying()) {
			// 如果正在播放我们就先保存这个播放位置
			mPosition = mMediaPlayer.getCurrentPosition();
			mMediaPlayer.stop();
			mHandler.removeCallbacks(mPlayRun);
		}
		super.onPause();
	}

	@Override
	protected void onResume() {
		if (mMediaPlayer!=null && mPosition>0) {
			mMediaPlayer.seekTo(mPosition);
			mMediaPlayer.start();
			mHandler.post(mPlayRun);
		}
		super.onResume();
	}
	
	private void startRecord() {
		createRecordDir();
        mMediaRecorder = new MediaRecorder();
        mMediaRecorder.reset();
        mMediaRecorder.setOnErrorListener(this);
        mMediaRecorder.setOnInfoListener(this);
        mMediaRecorder.setAudioSource(AudioSource.MIC);  //音频源
        mMediaRecorder.setOutputFormat(OutputFormat.AMR_NB);
        mMediaRecorder.setAudioEncoder(AudioEncoder.AMR_NB);  //音频格式
        //mMediaRecorder.setAudioSamplingRate(8);  //音频的采样率。可选
        //mMediaRecorder.setAudioChannels(2);  //音频的声道数。可选
        //mMediaRecorder.setAudioEncodingBitRate(1024);  //音频每秒录制的字节数。可选
        mMediaRecorder.setMaxDuration(10 * 1000);  //设置录制时长
        //mMediaRecorder.setMaxFileSize(1024*1024*10);  //setMaxFileSize与setMaxDuration设置其一即可
        mMediaRecorder.setOutputFile(mRecordFile.getAbsolutePath());
        try {
            mMediaRecorder.prepare();
            mMediaRecorder.start();
        } catch (Exception e) {
			Log.d(TAG, "mMediaRecorder.start error: " + e.getMessage());
        }
		btn_start.setEnabled(false);
		btn_stop.setEnabled(true);
		mRecordTime = 0;
		mHandler.post(mRecordRun);
    }
    
	private File mRecordFile = null;
	private void createRecordDir() {
		File sampleDir = new File(Environment.getExternalStorageDirectory()
				+ File.separator + "Download" + File.separator);
		if (!sampleDir.exists()) {
			sampleDir.mkdirs();
		}
		File recordDir = sampleDir;
		try {
			mRecordFile = File.createTempFile(Utils.getNowDateTime(), ".amr", recordDir);
			Log.d(TAG, mRecordFile.getAbsolutePath());
		} catch (Exception e) {
			Log.d(TAG, "createTempFile error: " + e.getMessage());
		}
	}

	private void stopRecord() {
		if (mMediaRecorder != null) {
			mMediaRecorder.setOnErrorListener(null);
			mMediaRecorder.setPreviewDisplay(null);
			try {
				mMediaRecorder.stop();
			} catch (Exception e) {
				Log.d(TAG, "mMediaRecorder.stop error: " + e.getMessage());
			}
			mMediaRecorder.release();
			mMediaRecorder = null;
		}
		btn_start.setEnabled(true);
		btn_stop.setEnabled(false);
		btn_play.setEnabled(true);
		mHandler.removeCallbacks(mRecordRun);
	}

	@Override
	public void onClick(View v) {
		int resid = v.getId();
		if (resid == R.id.btn_start) {
			startRecord();
		} else if (resid == R.id.btn_stop) {
			stopRecord();
		} else if (resid == R.id.btn_play) {
			startPlay();
		} else if (resid == R.id.btn_pause) {
			mMediaPlayer.pause();
			btn_play.setEnabled(true);
			btn_pause.setEnabled(false);
			mHandler.removeCallbacks(mPlayRun);
		}
	}
	
	private Handler mHandler = new Handler();
	
	private int mRecordTime = 0;
	private Runnable mRecordRun = new Runnable() {
		@Override
		public void run() {
			tv_record.setText(mRecordTime+"s");
			mRecordTime++;
			mHandler.postDelayed(this, 1000);
		}
	};

	private int mPlayTime = 0;
	private Runnable mPlayRun = new Runnable() {
		@Override
		public void run() {
			tv_play.setText(mPlayTime+"s");
			mPlayTime++;
			mHandler.postDelayed(this, 1000);
		}
	};
	
    @Override
    public void onError(MediaRecorder mr, int what, int extra) {
        if (mr != null) {
            mr.reset();
        }
    }

	@Override
	public void onInfo(MediaRecorder mr, int what, int extra) {
		if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED
				|| what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
			stopRecord();
		}
	}

}

AudioRecord/AudioTrack

话说Android搞出这么多种录音/播音方式,到底有什么用处呢?其实这还是跟不同的需求和用途有关,譬如说语音通话,要求实时传输,手机这边说一句话,那边厢就同步听到一句话。如果是MediaRecorder与MediaPlayer组合,只能整句话都录完编码好了,才能传给对方去播放,这个实效性就太差了。于是适用于音频实时处理的AudioRecord与AudioTrack组合就应运而生,该组合的音频为原始的二进制音频数据,没有文件头和文件尾,故而可以实现边录边播的实时语音。 MediaRecorder录制的音频格式有amr、aac等,MediaPlayer支持播放的音频格式除了amr、aac之外,还支持常见的mp3、wav、mid、ogg等经过压缩编码的音频。AudioRecord录制的音频格式只有pcm,AudioTrack可直接播放的也只有pcm。pcm格式有个缺点,在播放过程中不能直接暂停,因为二进制流;但pcm格式有个好处,就是iOS不能播放amr音频,但能播放pcm音频;所以如果Android手机录制的音乐需要传给iOS手机播放,还是得采用pcm格式。 下面是AudioRecord与AudioTrack组合的录音/播音相关说明。 AudioRecord的录音相关方法: getMinBufferSize : 根据采样频率、声道配置、音频格式获得合适的缓冲区大小。该函数为静态方法。 构造函数 : 可设置录音来源、采样频率、声道配置、音频格式与缓冲区大小。其中录音来源一般是AudioSource.MIC,采样频率可取值8000或者16000,声道配置可取值AudioFormat.CHANNEL_IN_STEREO或者AudioFormat.CHANNEL_OUT_STEREO,音频格式可取值AudioFormat.ENCODING_PCM_16BIT或者AudioFormat.ENCODING_PCM_8BIT。 startRecording : 开始录音。 read : 从缓冲区中读取音频数据,此数据用于保存到音频文件中。 stop : 停止录音。 release : 停止录音并释放资源。 setNotificationMarkerPosition : 设置需要通知的标记位置。 setPositionNotificationPeriod : 设置需要通知的时间周期。 setRecordPositionUpdateListener : 设置录制位置变化的监听器对象。该监听器从OnRecordPositionUpdateListener扩展而来,需要实现onMarkerReached和onPeriodicNotification两个方法;其中onMarkerReached事件的触发对应于setNotificationMarkerPosition方法,onPeriodicNotification事件的触发对应于setPositionNotificationPeriod方法。 AudioTrack的播音相关方法: getMinBufferSize : 根据采样频率、声道配置、音频格式获得合适的缓冲区大小。该函数为静态方法。 构造函数 : 可设置音频类型、采样频率、声道配置、音频格式、播放模式与缓冲区大小。其中音频类型一般是AudioManager.STREAM_MUSIC,采样频率、声道配置、音频格式与录音时保持一致,播放模式一般是AudioTrack.MODE_STREAM。 setStereoVolume : 设置立体声的音量。第一个参数是左声道音量,第二个参数是右声道音量。 play : 开始播放。 write : 把缓冲区的音频数据写入音轨中。调用该函数前要先从音频文件中读取数据写入缓冲区。 stop : 停止播放。 release : 停止播放并释放资源。 setNotificationMarkerPosition : 设置需要通知的标记位置。 setPositionNotificationPeriod : 设置需要通知的时间周期。 setPlaybackPositionUpdateListener : 设置播放位置变化的监听器对象。该监听器从OnPlaybackPositionUpdateListener扩展而来,需要实现onMarkerReached和onPeriodicNotification两个方法;其中onMarkerReached事件的触发对应于setNotificationMarkerPosition方法,onPeriodicNotification事件的触发对应于setPositionNotificationPeriod方法。 下面是AudioRecord与AudioTrack组合处理音频的示例代码:

代码语言:javascript
复制
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;

import com.example.exmaudio.util.Utils;

import android.app.Activity;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioRecord;
import android.media.AudioRecord.OnRecordPositionUpdateListener;
import android.media.AudioTrack.OnPlaybackPositionUpdateListener;
import android.media.AudioTrack;
import android.media.MediaRecorder.AudioSource;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.util.Log;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.TextView;

public class AudioRecordActivity extends Activity implements OnClickListener {
	private static final String TAG = "AudioRecordActivity";

	private TextView tv_record, tv_play;
	private Button btn_start, btn_stop, btn_play, btn_finish;
	private boolean isRecording, isPlaying;
	private Handler mHandler = new Handler();
	private int mRecordTime, mPlayTime;

	private int frequence = 8000;
	private int channelConfig = AudioFormat.CHANNEL_IN_STEREO; //只能取值CHANNEL_OUT_STEREO
	//如果取值CHANNEL_OUT_DEFAULT,会报错“getMinBufferSize(): Invalid channel configuration.”
	//如果取值CHANNEL_OUT_MONO,会报错“java.lang.IllegalArgumentException: Unsupported channel configuration.”
	private int audioFormat = AudioFormat.ENCODING_PCM_16BIT; //AudioRecord只能录制PCM格式

	public void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_audio_record);

		tv_record = (TextView) findViewById(R.id.tv_record);
		btn_start = (Button) findViewById(R.id.btn_start);
		btn_stop = (Button) findViewById(R.id.btn_stop);
		tv_play = (TextView) findViewById(R.id.tv_play);
		btn_play = (Button) findViewById(R.id.btn_play);
		btn_finish = (Button) findViewById(R.id.btn_finish);

		btn_start.setEnabled(true);
		btn_stop.setEnabled(false);
		btn_play.setEnabled(false);
		btn_finish.setEnabled(false);
		btn_start.setOnClickListener(this);
		btn_stop.setOnClickListener(this);
		btn_play.setOnClickListener(this);
		btn_finish.setOnClickListener(this);
		createRecordDir();
	}

    private File mRecordFile = null;
	private void createRecordDir() {
		File sampleDir = new File(Environment.getExternalStorageDirectory()
				+ File.separator + "Download" + File.separator);
		if (!sampleDir.exists()) {
			sampleDir.mkdirs();
		}
		File recordDir = sampleDir;
		try {
			mRecordFile = File.createTempFile(Utils.getNowDateTime(), ".pcm", recordDir);
			Log.d(TAG, mRecordFile.getAbsolutePath());
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

	@Override
	public void onClick(View v) {
		int resid = v.getId();
		if (resid == R.id.btn_start) {
			isRecording = true;
			new RecordTask().execute();
		} else if (resid == R.id.btn_stop) {
			isRecording = false;
		} else if (resid == R.id.btn_play) {
			isPlaying = true;
			new PlayTask().execute();
		} else if (resid == R.id.btn_finish) {
			isPlaying = false;
		}
	}

	private void refreshStatus(boolean isRecord, boolean isPlay) {
		if (isRecord || isPlay) {
			btn_start.setEnabled(false);
			btn_stop.setEnabled(isRecord?true:false);
			btn_play.setEnabled(false);
			btn_finish.setEnabled(isPlay?true:false);
		} else {
			btn_start.setEnabled(true);
			btn_stop.setEnabled(false);
			btn_play.setEnabled(true);
			btn_finish.setEnabled(false);
		}
	}

	private class RecordTask extends AsyncTask<Void, Integer, Void> {
		@Override
		protected Void doInBackground(Void... arg0) {
			try {
				// 开通输出流到指定的文件
				DataOutputStream dos = new DataOutputStream(
						new BufferedOutputStream(new FileOutputStream(mRecordFile)));
				// 根据定义好的几个配置,来获取合适的缓冲大小
				int bsize = AudioRecord.getMinBufferSize(frequence, channelConfig, audioFormat);
				AudioRecord record = new AudioRecord(AudioSource.MIC, 
						frequence, channelConfig, audioFormat, bsize);
				// 定义缓冲区
				short[] buffer = new short[bsize];
				//record.setNotificationMarkerPosition(1000);
				record.setPositionNotificationPeriod(1000);
				record.setRecordPositionUpdateListener(new RecordUpdateListener());
				record.startRecording();

				while (isRecording) {
					int bufferReadResult = record.read(buffer, 0, buffer.length);
					// 循环将buffer中的音频数据写入到OutputStream中
					for (int i = 0; i < bufferReadResult; i++) {
						dos.writeShort(buffer[i]);
					}
				}
				record.stop();
				dos.close();
				Log.d(TAG, "mRecordFile.length()=" + mRecordFile.length());
			} catch (Exception e) {
				e.printStackTrace();
			}
			return null;
		}

		@Override
		protected void onPreExecute() {
			refreshStatus(true, false);
			mRecordTime = 0;
			mHandler.postDelayed(mRecordRun, 1000);
		}

		@Override
		protected void onPostExecute(Void result) {
			refreshStatus(false, false);
			mHandler.removeCallbacks(mRecordRun);
		}

	}
	
	private Runnable mRecordRun = new Runnable() {
		@Override
		public void run() {
			mRecordTime++;
			mHandler.postDelayed(this, 1000);
		}
	};
	
	private class RecordUpdateListener implements OnRecordPositionUpdateListener {

		@Override
		public void onMarkerReached(AudioRecord recorder) {
		}

		@Override
		public void onPeriodicNotification(AudioRecord recorder) {
			tv_record.setText(mRecordTime+"s");
		}
		
	}
	
	private class PlayTask extends AsyncTask<Void, Integer, Void> {
		@Override
		protected Void doInBackground(Void... arg0) {
			try {
				// 定义输入流,将音频写入到AudioTrack类中,实现播放
				DataInputStream dis = new DataInputStream(
						new BufferedInputStream(new FileInputStream(mRecordFile)));
				int bsize = AudioTrack.getMinBufferSize(frequence, channelConfig, audioFormat);
				short[] buffer = new short[bsize / 4];
				AudioTrack track = new AudioTrack(AudioManager.STREAM_MUSIC,
						frequence, channelConfig, audioFormat, bsize, AudioTrack.MODE_STREAM);
				//track.setNotificationMarkerPosition(1000);
				track.setPositionNotificationPeriod(1000);
				track.setPlaybackPositionUpdateListener(new PlaybackUpdateListener());
				track.play();
				// 由于AudioTrack播放的是流,所以,我们需要一边播放一边读取
				while (isPlaying && dis.available() > 0) {
					int i = 0;
					while (dis.available() > 0 && i < buffer.length) {
						buffer[i] = dis.readShort();
						i++;
					}
					// 然后将数据写入到AudioTrack中
					track.write(buffer, 0, buffer.length);
				}
				track.stop();
				dis.close();
			} catch (Exception e) {
				e.printStackTrace();
			}
			return null;
		}

		@Override
		protected void onPreExecute() {
			refreshStatus(false, true);
			mPlayTime = 0;
			mHandler.postDelayed(mPlayRun, 1000);
		}

		@Override
		protected void onPostExecute(Void result) {
			refreshStatus(false, false);
			mHandler.removeCallbacks(mPlayRun);
		}

	}

	private Runnable mPlayRun = new Runnable() {
		@Override
		public void run() {
			mPlayTime++;
			mHandler.postDelayed(this, 1000);
		}
	};
	
	private class PlaybackUpdateListener implements OnPlaybackPositionUpdateListener {

		@Override
		public void onMarkerReached(AudioTrack track) {
		}

		@Override
		public void onPeriodicNotification(AudioTrack track) {
			tv_play.setText(mPlayTime+"s");
		}
		
	}
	
}

SoundPool

App使用过程中经常有些短小的提示声音,比如拍照的咔嚓声、扫一扫的吡一声,还有玩游戏击中目标的嗒嗒声,这些片段声音基本是系统自带的。如果使用MediaPlayer来播放,便存在诸如下面的不足之处:资源占用量较高、延迟时间较长、不支持多个音频同时播放等等。因此,我们需要一个短声音专用的播放器,这个播放器在Android中就是SoundPool。 SoundPool在使用时可以事先加载多个音频,然后在需要的时候播放指定编号的音频,这样处理有几个好处: 1、资源占用量小,不像MediaPlayer那么重; 2、延迟时间相对MediaPlayer延迟非常小; 3、可以同时播放多个音频,从而实现游戏过程中多个有效叠加的情景; 当然,SoundPool带来方便的同时也做了一部分牺牲,下面是使用它的一些限制: 1、SoundPool最大只能申请1M的内存,这意味着它只能播放一些很短的声音片段,不能用于播放歌曲或者游戏背景音乐; 2、虽然SoundPool提供了pause和stop方法,但是轻易不要使用这两个方法,因为它们可能会让你的App异常或崩溃; 3、SoundPool播放的音频格式建议使用ogg格式,据说它对wav格式的支持不太好; 4、待播放的音频要提前加载进SoundPool,不要等到要播放的时候才加载。因为SoundPool不会等音频加载完了才播放,所以它的延迟才比较小;而MediaPlayer会等待加载完毕才播放,所以延迟会比较大。 下面是SoundPool的常用方法说明: 构造函数 : 可设置最大个数、音频类型、音频质量。其中音频类型一般是AudioManager.STREAM_MUSIC,质量取值为0到100。 load : 加载指定的音频,该音频可以是个磁盘文件,也可以是资源文件。返回值为该音频的编号。 unload : 卸载指定编号的音频。 play : 播放指定编号的音频。可同时设置左右声道的音量(取值为0.0到1.0)、优先级(0为最低)、是否循环播放(0为只播放一次,-1为无限循环)、播放速率(取值为0.5-2.0,其中1.0为正常速率)。 setVolume : 设置指定编号音频的音量大小。 setPriority : 设置指定编号音频的优先级。 setLoop : 设置指定编号的音频是否循环播放。 setRate : 设置指定编号音频的播放速率。 pause : 暂停播放指定编号的音频。 resume : 恢复播放指定编号的音频。 autoPause : 暂停所有正在播放的音频。 autoResume : 恢复播放所有被暂停的音频。 stop : 停止播放指定编号的音频。 release : 释放所有音频资源。 setOnLoadCompleteListener : 设置音频加载完毕的监听器。该监听器扩展自OnLoadCompleteListener,需要重写onLoadComplete方法。 下面是SoundPool播放音频的示例代码:

代码语言:javascript
复制
import java.util.HashMap;

import android.app.Activity;
import android.media.AudioManager;
import android.media.SoundPool;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;

public class SoundPlayActivity extends Activity implements OnClickListener {

	private SoundPool mSoundPool;
	private HashMap<Integer, Integer> mSoundMap;
	
	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_sound_play);

		Button btn_play_all = (Button) findViewById(R.id.btn_play_all);
		Button btn_play_first = (Button) findViewById(R.id.btn_play_first);
		Button btn_play_second = (Button) findViewById(R.id.btn_play_second);
		Button btn_play_third = (Button) findViewById(R.id.btn_play_third);
		btn_play_all.setOnClickListener(this);
		btn_play_first.setOnClickListener(this);
		btn_play_second.setOnClickListener(this);
		btn_play_third.setOnClickListener(this);
		
		mSoundMap = new HashMap<Integer, Integer>();
		mSoundPool = new SoundPool(3, AudioManager.STREAM_MUSIC, 100);
		loadSound(1, R.raw.beep1);
		loadSound(2, R.raw.beep2);
		loadSound(3, R.raw.ring);
	}
	
	private void loadSound(int seq, int resid) {
		int soundID = mSoundPool.load(this, resid, 1);
		mSoundMap.put(seq, soundID);
	}

	private void playSound(int seq) {
		int soundID = mSoundMap.get(seq);
		mSoundPool.play(soundID, 1.0f, 1.0f, 1, 0, 1.0f);
	}

	@Override
	public void onClick(View v) {
		if (v.getId() == R.id.btn_play_all) {
			playSound(1);
			playSound(2);
			playSound(3);
		} else if (v.getId() == R.id.btn_play_first) {
			playSound(1);
		} else if (v.getId() == R.id.btn_play_second) {
			playSound(2);
		} else if (v.getId() == R.id.btn_play_third) {
			playSound(3);
		}
	}
	
	@Override
	protected void onDestroy() {
		if (mSoundPool != null) {
			mSoundPool.release();
		}
		super.onDestroy();
	}

}

自定义音乐播放器

大家常见的音乐播放器,不外乎主要有三项功能: 1、展示音乐/歌曲列表; 2、滚动展示歌词,并高亮显示当前正在播放的词句; 3、展示控制栏显示播放进度,并提供开始/暂停、拖动播放的功能,以及同时控制歌词的滚动情况; 对于第一点的展示歌曲列表,通过手工添加很费时费力,而且用户往往搞不清楚手机上的歌曲都放在哪个目录。我们假设用户是傻白甜,那自己开发的App就得智能贴心,主动帮用户把手机上的歌曲找出来。要实现这个功能,就到系统自带的媒体库中去查找,媒体库里音频资源的详细路径是MediaStore.Audio.Media.EXTERNAL_CONTENT_URI这个Uri,访问里面的音频记录,可以通过ContentResolver来完成。有关ContentResolver的具体用法参见《Android开发笔记(五十四)数据共享接口ContentProvider》。下面是MediaStore.Audio.Media.EXTERNAL_CONTENT_URI里的主要字段信息说明: Audio.Media._ID : 歌曲编号。 Audio.Media.TITLE : 歌曲的标题名称。 Audio.Media.ALBUM : 歌曲的专辑名称。 Audio.Media.DURATION : 歌曲的播放时间。 Audio.Media.SIZE : 歌曲文件的赌大小。 Audio.Media.ARTIST : 歌曲的演唱者。 Audio.Media.DATA : 歌曲文件的完整路径。 对于第二点的滚动歌词显示,通用的歌词文件是lrc格式的文本文件,内容主要是每句歌词的文字与开始时间。文本文件的解析并不复杂,难点主要在滚动显示上面。乍看起来歌词从下往上滚动,采用平移动画TranslateAnimation正合适;可是歌词滚动可不是匀速的,因为每句歌词的间隔时间并不固定,只能把整个歌词滚动分解为若干个动画,每个平移动画只负责前后两行歌词之间的滚动效果,前一行歌词的平移动画滚动完毕,马上开始下一行歌词的平移动画。另外,高亮显示当前演奏的歌词,这等于一段文字内的部分文字风格改变,虽然可以让每行文字都用单独的TextView来展示,但是一堆的TextView控件同时滚动很影响UI性能,所以建议采用可变字符串SpannableString直接处理段内文字,它的具体说明参见《Android开发笔记(六)可变字符串》。 对于第三点的歌曲控制栏,总体上复用前一篇博文提到的视频控制栏VideoController,博文名称是《Android开发笔记(一百二十五)自定义视频播放器》。不过歌曲控制栏还要更复杂,因为除了控制音频的播放,还要控制歌词动画的播放。更要命的是,平移动画TranslateAnimation居然不支持暂停和恢复操作,而且不只是平移动画,所有补间动画都不支持暂停和恢复。难道又要自己重定义动画了吗?刚想到这个的时候,不要说读者,就连笔者自己都想撞墙了。山穷水尽疑无路,柳暗花明又一村,幸好Android还给我们提供了属性动画这么一个好东东,属性动画不但支持所有的补间动画效果,而且也支持暂停和恢复操作,所以还等什么,赶紧把TranslateAnimation换成了ObjectAnimator。有关属性动画的详细介绍参见《Android开发笔记(九十六)集合动画与属性动画》。 弄完以上三点功能,一个主流音乐播放器的雏形便出来了,下面是音乐播放器的歌曲列表截图:

下面是音乐播放器的歌曲详情页的效果截图:

下面是音乐播放器的歌曲详情页面的代码例子:

代码语言:javascript
复制
import java.io.BufferedInputStream;
import java.io.DataInputStream;
import java.io.FileInputStream;
import java.util.ArrayList;

import com.example.exmaudio.bean.LrcContent;
import com.example.exmaudio.bean.MusicInfo;
import com.example.exmaudio.util.LyricsLoader;
import com.example.exmaudio.util.Utils;
import com.example.exmaudio.widget.AudioController;
import com.example.exmaudio.widget.AudioController.onSeekChangeListener;

import android.animation.Animator;
import android.animation.Animator.AnimatorListener;
import android.animation.ObjectAnimator;
import android.annotation.TargetApi;
import android.app.Activity;
import android.graphics.Color;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioTrack;
import android.media.MediaPlayer;
import android.media.MediaPlayer.OnCompletionListener;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.style.ForegroundColorSpan;
import android.util.Log;
import android.view.View;
import android.view.animation.AnimationUtils;
import android.widget.TextView;

@TargetApi(Build.VERSION_CODES.KITKAT)
public class MusicDetailActivity extends Activity 
		implements AnimatorListener, onSeekChangeListener {
	private static final String TAG = "MusicDetailActivity";

	private TextView tv_title;
	private TextView tv_artist;
	private TextView tv_music;
	private MusicInfo mMusic;
    private MediaPlayer mMediaPlayer;
	private AudioController ac_play;
	private LyricsLoader mLoader;
	private ArrayList<LrcContent> mLrcList;
	private Handler mHandler = new Handler();

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_music_detail);

		tv_title = (TextView) findViewById(R.id.tv_title);
		tv_artist = (TextView) findViewById(R.id.tv_artist);
		tv_music = (TextView) findViewById(R.id.tv_music);
		ac_play = (AudioController) findViewById(R.id.ac_play);
		
		ac_play.setonSeekChangeListener(this);
		mMusic = getIntent().getParcelableExtra("music");
		tv_title.setText(mMusic.getTitle());
		tv_artist.setText(mMusic.getArtist());
		mLoader = LyricsLoader.getInstance(mMusic.getUrl());
		mLrcList = mLoader.getLrcList();
        mMediaPlayer = new MediaPlayer();
        playMusic(mMusic.getUrl());
	}

    private void playMusic(String file_path) {
		if (mMediaPlayer.isPlaying()) {
			mMediaPlayer.stop();
		}
		if (Utils.getExtendName(file_path).equals("pcm")) {
			ac_play.setVisibility(View.GONE);
			PlayTask playTask = new PlayTask();
			playTask.execute(file_path);
		} else {
			playMedia(file_path);
		}
    }

	private void playMedia(String filePath) {
		try {
			mMediaPlayer.reset();
			//mMediaPlayer.setVolume(0.5f, 0.5f);  //设置音量,可选
			mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
			mMediaPlayer.setDataSource(filePath);
			mMediaPlayer.prepare();
			mMediaPlayer.start();
			mHandler.post(mRefreshCtrl);
			mMediaPlayer.setOnCompletionListener(new OnCompletionListener() {
				@Override
				public void onCompletion(MediaPlayer mp) {
					ac_play.setCurrentTime(0, 0);
				}
			});
			ac_play.setMediaPlayer(mMediaPlayer);

			//以下处理歌词
			if (mLoader.getLrcList()!=null && mLrcList.size()>0) {
				mLrcStr = "";
				for (int i=0; i<mLrcList.size(); i++) {
					LrcContent item = mLrcList.get(i);
					mLrcStr = mLrcStr + item.getLrcStr() + "\n";
				}
				tv_music.setText(mLrcStr);
				tv_music.setAnimation(AnimationUtils.loadAnimation(this,R.anim.alpha));
				mHandler.postDelayed(mRefreshLrc, 100);
			}
		} catch (Exception e) {
            Log.d(TAG, "mMediaPlayer.prepare error: "+e.getMessage());
		}
	}

	@Override
	protected void onDestroy() {
		super.onDestroy();
		mHandler.removeCallbacksAndMessages(null);
	}

	private Runnable mRefreshCtrl = new Runnable() {
		@Override
		public void run() {
			if (mMediaPlayer.isPlaying()) {
				ac_play.setCurrentTime(mMediaPlayer.getCurrentPosition(), 0);
			}
			mHandler.postDelayed(this, 500);
		}
	};

	@Override
	public void onMusicSeek(int current, int seekto) {
		Log.d(TAG, "current="+current+", seekto="+seekto);
		animTranY.cancel();
		mHandler.removeCallbacks(mRefreshLrc);
		int i;
		for (i=0; i<mLrcList.size(); i++) {
			LrcContent item = mLrcList.get(i);
			if (item.getLrcTime() > seekto) {
				break;
			}
		}
		mCount = i;
		mPrePos = -1;
		mNextPos = 0;
		if (mCount > 0) {
			for (int j = 0; j < mCount; j++) {
				mNextPos = mLrcStr.indexOf("\n", mPrePos + 1);
				mPrePos = mLrcStr.indexOf("\n", mNextPos);
			}
		}
		startAnimation(-mLineHeight*i, 100);
	}

	@Override
	public void onMusicPause() {
		animTranY.pause();
	}

	@Override
	public void onMusicResume() {
		animTranY.resume();
	}
	
	private int mCount = 0;
	private float mCurrentHeight = 0;
	private float mLineHeight = 0;
	private Runnable mRefreshLrc = new Runnable() {
		
		@Override
		public void run() {
			if (mLineHeight == 0) {
				mLineHeight = (float) (tv_music.getHeight()-tv_music.getPaddingTop())
						/mLrcList.size()/2;
				Log.d(TAG, "tv_music.getHeight()="+tv_music.getHeight());
				Log.d(TAG, "tv_music.getPaddingTop()="+tv_music.getPaddingTop());
				Log.d(TAG, "mLineHeight="+mLineHeight);
			}
			int offset = mLrcList.get(mCount).getLrcTime()
					- ((mCount==0)?0:mLrcList.get(mCount-1).getLrcTime()) - 50;
			if (offset <= 0) {
				return;
			}
			startAnimation(mCurrentHeight - mLineHeight, offset);
			Log.d(TAG, "mLineHeight="+mLineHeight+",mCurrentHeight="+mCurrentHeight+",getHeight="+tv_music.getHeight());
		}
	};

	private int mPrePos = -1, mNextPos = 0;
	private String mLrcStr;
	private ObjectAnimator animTranY;

	public void startAnimation(float aimHeight, int offset) {
		Log.d(TAG, "mCurrentHeight="+mCurrentHeight+", aimHeight="+aimHeight);
		animTranY = ObjectAnimator.ofFloat(tv_music, "translationY",
				mCurrentHeight, aimHeight);
		animTranY.setDuration(offset);
		animTranY.setRepeatCount(0);
		animTranY.addListener(this);
		animTranY.start();
		mCurrentHeight = aimHeight;
	}

	@Override
	public void onAnimationStart(Animator animation) {
	}

	@Override
	public void onAnimationEnd(Animator animation) {
		if (mCount < mLrcList.size()) {
			mNextPos = mLrcStr.indexOf("\n", mPrePos+1);
			SpannableString spanText = new SpannableString(mLrcStr);
			spanText.setSpan(new ForegroundColorSpan(Color.RED), mPrePos+1, 
					mNextPos>0?mNextPos:mLrcStr.length()-1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
			mCount++;
			tv_music.setText(spanText);
			if (mNextPos > 0 && mNextPos < mLrcStr.length()-1) {
				mPrePos = mLrcStr.indexOf("\n", mNextPos);
				mHandler.postDelayed(mRefreshLrc, 50);
			}
		}
	}

	@Override
	public void onAnimationCancel(Animator animation) {
	}

	@Override
	public void onAnimationRepeat(Animator animation) {
	}
	
	private int frequence = 8000;
	private int channelConfig = AudioFormat.CHANNEL_IN_STEREO; //只能取值CHANNEL_OUT_STEREO
	private int audioFormat = AudioFormat.ENCODING_PCM_16BIT;
	private class PlayTask extends AsyncTask<String, Integer, Void> {
		@Override
		protected Void doInBackground(String... arg0) {
			try {
				// 定义输入流,将音频写入到AudioTrack类中,实现播放
				DataInputStream dis = new DataInputStream(
						new BufferedInputStream(new FileInputStream(arg0[0])));
				int bsize = AudioTrack.getMinBufferSize(frequence, channelConfig, audioFormat);
				short[] buffer = new short[bsize / 4];
				AudioTrack track = new AudioTrack(AudioManager.STREAM_MUSIC,
						frequence, channelConfig, audioFormat, bsize, AudioTrack.MODE_STREAM);
				track.play();
				// 由于AudioTrack播放的是流,所以,我们需要一边播放一边读取
				while (dis.available() > 0) {
					int i = 0;
					while (dis.available() > 0 && i < buffer.length) {
						buffer[i] = dis.readShort();
						i++;
					}
					// 然后将数据写入到AudioTrack中
					track.write(buffer, 0, buffer.length);
				}
				track.stop();
				dis.close();
			} catch (Exception e) {
				e.printStackTrace();
			}
			return null;
		}

	}

}

下面是音乐播放器的歌曲控制栏的代码例子:

代码语言:javascript
复制
import com.example.exmaudio.R;
import com.example.exmaudio.util.Utils;

import android.content.Context;
import android.graphics.Color;
import android.media.MediaPlayer;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.SeekBar;
import android.widget.SeekBar.OnSeekBarChangeListener;
import android.widget.TextView;

public class AudioController extends RelativeLayout implements OnClickListener, OnSeekBarChangeListener {
	private static final String TAG = "AudioController";

	private Context mContext;
	private ImageView mImagePlay;
	private TextView mCurrentTime;
	private TextView mTotalTime;
	private SeekBar mSeekBar;
	private int mBeginViewId = 0x7F24FFF0;
	private int dip_10, dip_40;

	private MediaPlayer mMediaPlayer;
	private int mCurrent = 0;
	private int mBuffer = 0;
	private int mDuration = 0;
	private boolean bPause = false;
	
	public AudioController(Context context) {
		this(context, null);
	}

	public AudioController(Context context, AttributeSet attrs) {
		super(context, attrs);
		mContext = context;
		dip_10 = Utils.dip2px(mContext, 10);
		dip_40 = Utils.dip2px(mContext, 40);
		initView();
	}

	private TextView newTextView(Context context, int id) {
		TextView tv = new TextView(context);
		tv.setId(id);
		tv.setGravity(Gravity.CENTER);
		tv.setTextColor(Color.WHITE);
		tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
		RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(
				LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT);
		params.addRule(RelativeLayout.CENTER_VERTICAL);
		tv.setLayoutParams(params);
		return tv;
	}
	
	private void initView() {
		mImagePlay = new ImageView(mContext);
		RelativeLayout.LayoutParams imageParams = new RelativeLayout.LayoutParams(dip_40, dip_40);
		imageParams.addRule(RelativeLayout.CENTER_VERTICAL);
		mImagePlay.setLayoutParams(imageParams);
		mImagePlay.setId(mBeginViewId);
		mImagePlay.setOnClickListener(this);
		
		mCurrentTime = newTextView(mContext, mBeginViewId+1);
		RelativeLayout.LayoutParams currentParams = (LayoutParams) mCurrentTime.getLayoutParams();
		currentParams.setMargins(dip_10, 0, 0, 0);
		currentParams.addRule(RelativeLayout.RIGHT_OF, mImagePlay.getId());
		mCurrentTime.setLayoutParams(currentParams);

		mTotalTime = newTextView(mContext, mBeginViewId+2);
		RelativeLayout.LayoutParams totalParams = (LayoutParams) mTotalTime.getLayoutParams();
		totalParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT);
		mTotalTime.setLayoutParams(totalParams);
		
		mSeekBar = new SeekBar(mContext);
		RelativeLayout.LayoutParams seekParams = new RelativeLayout.LayoutParams(
				LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
		totalParams.setMargins(dip_10, 0, dip_10, 0);
		seekParams.addRule(RelativeLayout.CENTER_IN_PARENT);
		seekParams.addRule(RelativeLayout.RIGHT_OF, mCurrentTime.getId());
		seekParams.addRule(RelativeLayout.LEFT_OF, mTotalTime.getId());
		mSeekBar.setLayoutParams(seekParams);
		mSeekBar.setMax(100);
		mSeekBar.setMinimumHeight(100);
		mSeekBar.setThumbOffset(0);
		mSeekBar.setId(mBeginViewId+3);
		mSeekBar.setOnSeekBarChangeListener(this);
	}

	private void reset() {
		if (mCurrent == 0 || bPause) {
			mImagePlay.setImageResource(R.drawable.audio_btn_down);
		} else {
			mImagePlay.setImageResource(R.drawable.audio_btn_on);
		}
		mCurrentTime.setText(Utils.formatTime(mCurrent));
		mTotalTime.setText(Utils.formatTime(mDuration));
		mSeekBar.setProgress((mCurrent==0)?0:(mCurrent*100/mDuration));
		mSeekBar.setSecondaryProgress(mBuffer);
	}
	
	private void refresh() {
		invalidate();
		requestLayout();
	}
	
	@Override
	protected void onLayout(boolean changed, int l, int t, int r, int b) {
		super.onLayout(changed, l, t, r, b);
		removeAllViews();
		reset();
		addView(mImagePlay);
		addView(mCurrentTime);
		addView(mTotalTime);
		addView(mSeekBar);
	}

	@Override
	public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
		if (fromUser) {
			int time = progress * mDuration / 100;
			mMediaPlayer.seekTo(time);
		}
	}

	@Override
	public void onStartTrackingTouch(SeekBar seekBar) {
	}

	@Override
	public void onStopTrackingTouch(SeekBar seekBar) {
		int time = seekBar.getProgress() * mDuration / 100;
		mSeekListener.onMusicSeek(mMediaPlayer.getCurrentPosition(), time);
	}
	
	private onSeekChangeListener mSeekListener;
	public static interface onSeekChangeListener {
		public void onMusicSeek(int current, int seekto);
		public void onMusicPause();
		public void onMusicResume();
	}
	public void setonSeekChangeListener(onSeekChangeListener listener) {
		mSeekListener = listener;
	}

	@Override
	public void onClick(View v) {
		if (v.getId() == mImagePlay.getId()) {
			if (mMediaPlayer.isPlaying()) {
				mMediaPlayer.pause();
				bPause = true;
				mSeekListener.onMusicPause();
			} else {
				if (mCurrent == 0) {
					mSeekListener.onMusicSeek(0, 0);
				}
				mMediaPlayer.start();
				bPause = false;
				mSeekListener.onMusicResume();
			}
		}
		refresh();
	}
	
	public void setMediaPlayer(MediaPlayer view) {
		mMediaPlayer = view;
		mDuration = mMediaPlayer.getDuration();
	}
	
	public void setCurrentTime(int current_time, int buffer_time) {
		mCurrent = current_time;
		mBuffer = buffer_time;
		refresh();
	}

}

点击下载本文用到的自定义音乐播放器的工程代码 点此查看Android开发笔记的完整目录

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2016年09月20日,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • MediaRecorder/MediaPlayer
  • AudioRecord/AudioTrack
  • SoundPool
  • 自定义音乐播放器
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档