高斋晓开卷,独共圣人语。这篇文章主要讲述Android 音频录音与播放相关的知识,希望能为你提供帮助。
原文链接:https://www.jianshu.com/p/5966ed7c5baf
介绍音频的采集、编码、生成文件、转码等操作,通过 AudioRecord 采集音频,生成三种格式的文件格式(pcm、wav、aac),用 Audiostack 来播放这个音频。
文章图片
一、PCM 、WAV、AAC 的文件头介绍
这里简单的介绍一下常见的三种音频格式:
PCM :PCM(Pulse Code Modulation—-脉码调制录音)。所谓 PCM 录音就是将声音等模拟信号变成符号化的脉冲列,使用三个參数(声道数、採样位数和采样频率)来表示声音。PCM 信号是就未经过任何编码和压缩处理。与模拟信号比,它不易受传送系统的杂波及失真的影响。动态范围宽,可得到音质相当好的影响效果。
WAV : WAV 是一种无损的音频文件格式,WAV 符合 RIFF(Resource Interchange File Format) 规范。所有的 WAV 都有一个文件头,这个文件头音频流的编码参数。WAV 对音频流的编码没有硬性规定,除了 PCM 之外,还有几乎所有支持 ACM 规范的编码都可以为 WAV 的音频流进行编码。
简单来说:WAV 是一种无损的音频文件格式,PCM是没有压缩的编码方式AAC : AAC(Advanced Audio Coding),中文称为“高级音频编码”,出现于 1997 年,基于 MPEG-2的音频编码技术。由 Fraunhofer IIS、杜比实验室、AT& T、Sony(索尼)等公司共同开发,目的是取代 MP3 格式。2000 年,MPEG-4 标准出现后,AAC 重新集成了其特性,加入了 SBR 技术和 PS 技术,为了区别于传统的 MPEG-2 AAC 又称为 MPEG-4 AAC。他是一种专为声音数据设计的文件压缩格式,与 MP3 类似。利用 AAC 格式,可使声音文件明显减小,而不会让人感觉声音质量有所降低 。
二、使用 AudioRecord 实现录音生成 PCM 文件
AudioRecord 是 android 系统提供的用于实现录音的功能类,要想了解这个类的具体的说明和用法,可以去看一下官方的文档,如参考链接。
AndioRecord 类的主要功能是让各种 java 应用能够管理音频资源,以便它们通过此类能够录制声音相关的硬件所收集的声音。此功能的实现就是通过 “ pulling ”(读取)AudioRecord 对象的声音数据来完成的。在录音过程中,应用所需要做的就是通过后面三个类方法中的一个去及时地获取AudioRecord对象的录音数据. AudioRecord类提供的三个获取声音数据的方法分别是:
- read(byte[], int, int)
- read(short[], int, int)
- read(ByteBuffer, int)
开始录音的时候,AudioRecord 需要初始化一个相关联的声音 buffer, 这个 buffer 主要是用来保存新的声音数据。这个 buffer 的大小,我们可以在对象构造期间去指定。它表明一个 AudioRecord 对象还没有被读取(同步)声音数据前能录多长的音(即一次可以录制的声音容量)。声音数据从音频硬件中被读出,数据大小不超过整个录音数据的大小(可以分多次读出),即每次读取初始化 buffer 容量的数据。
2.1 首先要声明一些全局的变量和常量参数主要是声明一些用到的参数,具体解释可以看注释。
/指定音频源 这个和MediaRecorder是相同的 MediaRecorder.AudioSource.MIC指的是麦克风
private static final int mAudioSource = MediaRecorder.AudioSource.MIC;
//指定采样率 (MediaRecoder 的采样率通常是8000Hz,16000Hz
//AAC的通常是 44100Hz。 设置采样率为 44100,目前为常用的采样率,官方文档表示这个值可以兼容所有的设置)private static final int mSampleRateInHz = 44100;
//指定捕获音频的声道数目。在 AudioFormat 类中指定用于此的常量,单声道private static final int mChannelConfig = AudioFormat.CHANNEL_CONFIGURATION_MONO;
//指定音频量化位数 ,在 AudioFormat 类中指定了以下各种可能的常量。通常我们选择 ENCODING_PCM_16BIT 和 ENCODING_PCM_8BIT
//PCM 代表的是脉冲编码调制,它实际上是原始音频样本。
//因此可以设置每个样本的分辨率为 16 位或者8位,16 位将占用更多的空间和处理能力,表示的音频也更加接近真实。private static final int mAudioFormat = AudioFormat.ENCODING_PCM_16BIT;
//指定缓冲区大小。调用AudioRecord类的getMinBufferSize方法可以获得。
private int mBufferSizeInBytes;
// 声明 AudioRecord 对象
private AudioRecord mAudioRecord = null;
2.2 获取 buffer 的大小并创建 AudioRecord
//初始化数据,计算最小缓冲区
mBufferSizeInBytes = AudioRecord.getMinBufferSize(mSampleRateInHz, mChannelConfig, mAudioFormat);
//创建AudioRecorder对象
mAudioRecord = new AudioRecord(mAudioSource, mSampleRateInHz, mChannelConfig,
mAudioFormat, mBufferSizeInBytes);
2.3 写文件
@Override
public void run() {
//标记为开始采集状态
isRecording = true;
//创建文件
createFile();
try {//判断AudioRecord未初始化,停止录音的时候释放了,状态就为STATE_UNINITIALIZED
if (mAudioRecord.getState() == mAudioRecord.STATE_UNINITIALIZED) {
initData();
}//最小缓冲区
byte[] buffer = new byte[mBufferSizeInBytes];
//获取到文件的数据流
mDataOutputStream = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(mRecordingFile)));
//开始录音
mAudioRecord.startRecording();
//getRecordingState获取当前AudioReroding是否正在采集数据的状态
while (isRecording &
&
mAudioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) {
int bufferReadResult = mAudioRecord.read(buffer, 0, mBufferSizeInBytes);
for (int i = 0;
i <
bufferReadResult;
i++) {
mDataOutputStream.write(buffer[i]);
}
}
} catch (Exception e) {
Log.e(TAG, "Recording Failed");
} finally {
// 停止录音
stopRecord();
IOUtil.close(mDataOutputStream);
}
}
2.4 权限申请权限需求:WRITE_EXTERNAL_STORAGE、READ_EXTERNAL_STORAGE(部份手机必须要申请这个权限)、RECORD_AUDIO
<
uses-permission android:name="android.permission.RECORD_AUDIO"/>
<
uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<
uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
2.5 采集小结到现在基本的录音的流程就介绍完了,但是这时候问题来了:
- 我按照流程,把音频数据都输出到文件里面了,停止录音后,打开此文件,发现不能播放,到底是为什么呢?
- 那如何才能在播放器中播放我录制的内容呢?
三、PCM 转化为 WAV
在文件的数据开头加入 WAVE HEAD 或者 AAC 数据即可,也就是文件头。只有加上文件头部的数据,播放器才能正确的知道里面的内容到底是什么,进而能够正常的解析并播放里面的内容。具体的头文件的描述,在 Play a WAV file on an AudioTrack 里面可以进行了解。
public class WAVUtil {/**
* PCM文件转WAV文件
*
* @param inPcmFilePath输入PCM文件路径
* @param outWavFilePath 输出WAV文件路径
* @param sampleRate采样率,例如44100
* @param channels声道数 单声道:1或双声道:2
* @param bitNum采样位数,8或16
*/
public static void convertPcm2Wav(String inPcmFilePath, String outWavFilePath, int sampleRate,int channels, int bitNum) {FileInputStream in = null;
FileOutputStream out = null;
byte[] data = https://www.songbingjia.com/android/new byte[1024];
try {
//采样字节byte率
long byteRate = sampleRate * channels * bitNum / 8;
in = new FileInputStream(inPcmFilePath);
out = new FileOutputStream(outWavFilePath);
//PCM文件大小
long totalAudioLen = in.getChannel().size();
//总大小,由于不包括RIFF和WAV,所以是44 - 8 = 36,在加上PCM文件大小
long totalDataLen = totalAudioLen + 36;
writeWaveFileHeader(out, totalAudioLen, totalDataLen, sampleRate, channels, byteRate);
int length = 0;
while ((length = in.read(data)) >
0) {
out.write(data, 0, length);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
IOUtil.close(in,out);
}
}/**
* 输出WAV文件
*
* @param outWAV输出文件流
* @param totalAudioLen 整个音频PCM数据大小
* @param totalDataLen整个数据大小
* @param sampleRate采样率
* @param channels声道数
* @param byteRate采样字节byte率
* @throws IOException
*/
private static void writeWaveFileHeader(FileOutputStream out, long totalAudioLen,long totalDataLen, int sampleRate, int channels, long byteRate) throws IOException {
byte[] header = new byte[44];
header[0] = ‘R‘;
// RIFF
header[1] = ‘I‘;
header[2] = ‘F‘;
header[3] = ‘F‘;
header[4] = (byte) (totalDataLen &
0xff);
//数据大小
header[5] = (byte) ((totalDataLen >
>
8) &
0xff);
header[6] = (byte) ((totalDataLen >
>
16) &
0xff);
header[7] = (byte) ((totalDataLen >
>
24) &
0xff);
header[8] = ‘W‘;
//WAVE
header[9] = ‘A‘;
header[10] = ‘V‘;
header[11] = ‘E‘;
//FMT Chunk
header[12] = ‘f‘;
// ‘fmt ‘
header[13] = ‘m‘;
header[14] = ‘t‘;
header[15] = ‘ ‘;
//过渡字节
//数据大小
header[16] = 16;
// 4 bytes: size of ‘fmt ‘ chunk
header[17] = 0;
header[18] = 0;
header[19] = 0;
//编码方式 10H为PCM编码格式
header[20] = 1;
// format = 1
header[21] = 0;
//通道数
header[22] = (byte) channels;
header[23] = 0;
//采样率,每个通道的播放速度
header[24] = (byte) (sampleRate &
0xff);
header[25] = (byte) ((sampleRate >
>
8) &
0xff);
header[26] = (byte) ((sampleRate >
>
16) &
0xff);
header[27] = (byte) ((sampleRate >
>
24) &
0xff);
//音频数据传送速率,采样率*通道数*采样深度/8
header[28] = (byte) (byteRate &
0xff);
header[29] = (byte) ((byteRate >
>
8) &
0xff);
header[30] = (byte) ((byteRate >
>
16) &
0xff);
header[31] = (byte) ((byteRate >
>
24) &
0xff);
// 确定系统一次要处理多少个这样字节的数据,确定缓冲区,通道数*采样位数
header[32] = (byte) (channels * 16 / 8);
header[33] = 0;
//每个样本的数据位数
header[34] = 16;
header[35] = 0;
//Data chunk
header[36] = ‘d‘;
//data
header[37] = ‘a‘;
header[38] = ‘t‘;
header[39] = ‘a‘;
header[40] = (byte) (totalAudioLen &
0xff);
header[41] = (byte) ((totalAudioLen >
>
8) &
0xff);
header[42] = (byte) ((totalAudioLen >
>
16) &
0xff);
header[43] = (byte) ((totalAudioLen >
>
24) &
0xff);
out.write(header, 0, 44);
}
}
然后生成了相对的 WAV 文件,我们用用手机自带播放器打开此时就能正常播放,但是我们发现他的大小比较大,我们看到就是几分钟就这么大,我们平时用的是 mp3 、aac 格式的,我们如何办到的呢?
四、PCM 转化为 AAC 文件格式
生成 aac 文件播放
public class AACUtil {.../**
* 初始化AAC编码器
*/
private void initAACMediaEncode() {
try {//参数对应->
mime type、采样率、声道数
MediaFormat encodeFormat = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, 16000, 1);
encodeFormat.setInteger(MediaFormat.KEY_BIT_RATE, 64000);
//比特率
encodeFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, 1);
encodeFormat.setInteger(MediaFormat.KEY_CHANNEL_MASK, AudioFormat.CHANNEL_IN_MONO);
encodeFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC);
encodeFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 1024);
//作用于inputBuffer的大小mediaEncode = MediaCodec.createEncoderByType(encodeType);
mediaEncode.configure(encodeFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
} catch (IOException e) {
e.printStackTrace();
}if (mediaEncode == null) {
LogUtil.e("create mediaEncode failed");
return;
}mediaEncode.start();
encodeInputBuffers = mediaEncode.getInputBuffers();
encodeOutputBuffers = mediaEncode.getOutputBuffers();
encodeBufferInfo = new MediaCodec.BufferInfo();
}private boolean codeOver = false;
/**
* 开始转码
* 音频数据{@link #srcPath}先解码成PCMPCM数据在编码成MediaFormat.MIMETYPE_AUDIO_AAC音频格式
* mp3->
PCM->
aac
*/
public void startAsync() {
LogUtil.w("start");
new Thread(new DecodeRunnable()).start();
}/**
* 解码{@link #srcPath}音频文件 得到PCM数据块
*
* @return 是否解码完所有数据
*/
private void srcAudioFormatToPCM() {
File file = new File(srcPath);
// 指定要读取的文件
FileInputStream fio = null;
try {
fio = new FileInputStream(file);
byte[] bb = new byte[1024];
while (!codeOver) {
if (fio.read(bb) != -1) {
LogUtil.e("============putPCMData https://www.songbingjia.com/android/============" + bb.length);
dstAudioFormatFromPCM(bb);
} else {
codeOver = true;
}
}fio.close();
} catch (Exception e) {
e.printStackTrace();
}}private byte[] chunkAudio = new byte[0];
/**
* 编码PCM数据 得到AAC格式的音频文件
*/
private void dstAudioFormatFromPCM(byte[] pcmData) {int inputIndex;
ByteBuffer inputBuffer;
int outputIndex;
ByteBuffer outputBuffer;
int outBitSize;
int outPacketSize;
byte[] PCMAudio;
PCMAudio = pcmData;
encodeInputBuffers = mediaEncode.getInputBuffers();
encodeOutputBuffers = mediaEncode.getOutputBuffers();
encodeBufferInfo = new MediaCodec.BufferInfo();
inputIndex = mediaEncode.dequeueInputBuffer(0);
inputBuffer = encodeInputBuffers[inputIndex];
inputBuffer.clear();
inputBuffer.limit(PCMAudio.length);
inputBuffer.put(PCMAudio);
//PCM数据填充给inputBuffer
mediaEncode.queueInputBuffer(inputIndex, 0, PCMAudio.length, 0, 0);
//通知编码器 编码outputIndex = mediaEncode.dequeueOutputBuffer(encodeBufferInfo, 0);
while (outputIndex >
0) {outBitSize = encodeBufferInfo.size;
outPacketSize = outBitSize + 7;
//7为ADT头部的大小
outputBuffer = encodeOutputBuffers[outputIndex];
//拿到输出Buffer
outputBuffer.position(encodeBufferInfo.offset);
outputBuffer.limit(encodeBufferInfo.offset + outBitSize);
chunkAudio = new byte[outPacketSize];
addADTStoPacket(chunkAudio, outPacketSize);
//添加ADTS
outputBuffer.get(chunkAudio, 7, outBitSize);
//将编码得到的AAC数据 取出到byte[]中try {
//录制aac音频文件,保存在手机内存中
bos.write(chunkAudio, 0, chunkAudio.length);
bos.flush();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}outputBuffer.position(encodeBufferInfo.offset);
mediaEncode.releaseOutputBuffer(outputIndex, false);
outputIndex = mediaEncode.dequeueOutputBuffer(encodeBufferInfo, 0);
}}/**
* 添加ADTS头
*
* @param packet
* @param packetLen
*/
private void addADTStoPacket(byte[] packet, int packetLen) {
int profile = 2;
// AAC LC
int freqIdx = 8;
// 16KHz
int chanCfg = 1;
// CPE// fill in ADTS data
packet[0] = (byte) 0xFF;
packet[1] = (byte) 0xF1;
packet[2] = (byte) (((profile - 1) <
<
6) + (freqIdx <
<
2) + (chanCfg >
>
2));
packet[3] = (byte) (((chanCfg &
3) <
<
6) + (packetLen >
>
11));
packet[4] = (byte) ((packetLen &
0x7FF) >
>
3);
packet[5] = (byte) (((packetLen &
7) <
<
5) + 0x1F);
packet[6] = (byte) 0xFC;
}/**
* 释放资源
*/
public void release() {
...
}/**
* 解码线程
*/
private class DecodeRunnable implements Runnable {@Override
public void run() {
srcAudioFormatToPCM();
}
}}
五、AudioStack 播放
AudioTrack 类可以完成 Android 平台上音频数据的输出任务。AudioTrack 有两种数据加载模式(MODE_STREAM 和 MODE_STATIC),对应的是数据加载模式和音频流类型, 对应着两种完全不同的使用场景。
- MODE_STREAM:在这种模式下,通过 write 一次次把音频数据写到 AudioTrack 中。这和平时通过 write 系统调用往文件中写数据类似,但这种工作方式每次都需要把数据从用户提供的 Buffer 中拷贝到 AudioTrack 内部的 Buffer 中,这在一定程度上会使引入延时。为解决这一问题,AudioTrack 就引入了第二种模式。
- MODE_STATIC:这种模式下,在 play 之前只需要把所有数据通过一次 write 调用传递到 AudioTrack 中的内部缓冲区,后续就不必再传递数据了。这种模式适用于像铃声这种内存占用量较小,延时要求较高的文件。但它也有一个缺点,就是一次write的数据不能太多,否则系统无法分配足够的内存来存储全部数据。
5.1 音频流的类型在 AudioTrack 构造函数中,会接触到 AudioManager.STREAM_MUSIC 这个参数。它的含义与 Android 系统对音频流的管理和分类有关。
Android 将系统的声音分为好几种流类型,下面是几个常见的:
STREAM_ALARM:警告声
STREAM_MUSIC:音乐声,例如 music 等
STREAM_RING:铃声
STREAM_SYSTEM:系统声音,例如低电提示音,锁屏音等
STREAM_VOCIE_CALL:通话声
注意:上面这些类型的划分和音频数据本身并没有关系。例如 MUSIC 和 RING 类型都可以是某首 MP3 歌曲。另外,声音流类型的选择没有固定的标准,例如,铃声预览中的铃声可以设置为MUSIC类型。音频流类型的划分和Audio系统对音频的管理策略有关。
5.2 Buffer 分配和 Frame 的概念在计算 Buffer 分配的大小的时候,我们经常用到的一个方法就是:getMinBufferSize。这个函数决定了应用层分配多大的数据 Buffer。
AudioTrack.getMinBufferSize(8000,//每秒8K个采样点
AudioFormat.CHANNEL_CONFIGURATION_STEREO,//双声道
AudioFormat.ENCODING_PCM_16BIT);
从 AudioTrack.getMinBufferSize 开始追溯代码,可以发现在底层的代码中有一个很重要的概念:Frame(帧)。Frame 是一个单位,用来描述数据量的多少。1 单位的 Frame 等于 1 个采样点的字节数 × 声道数(比如 PCM16,双声道的 1 个 Frame 等于 2×2=4 字节)。1 个采样点只针对一个声道,而实际上可能会有一或多个声道。由于不能用一个独立的单位来表示全部声道一次采样的数据量,也就引出了 Frame 的概念。Frame 的大小,就是一个采样点的字节数 × 声道数。另外,在目前的声卡驱动程序中,其内部缓冲区也是采用 Frame 作为单位来分配和管理的。
getMinBufSize 会综合考虑硬件的情况(诸如是否支持采样率,硬件本身的延迟情况等)后,得出一个最小缓冲区的大小。一般我们分配的缓冲大小会是它的整数倍。
5.3 构建过程【Android 音频录音与播放】每一个音频流对应着一个 AudioTrack 类的一个实例,每个 AudioTrack 会在创建时注册到 AudioFlinger 中,由 AudioFlinger 把所有的 AudioTrack 进行混合(Mixer),然后输送到 AudioHardware 中进行播放,目前 Android 同时最多可以创建 32 个音频流,也就是说,Mixer 最多会同时处理 32 个 AudioTrack 的数据流。
public class AudioTrackManager {
...
//音频流类型
private static final int mStreamType = AudioManager.STREAM_MUSIC;
//指定采样率 (MediaRecoder 的采样率通常是8000Hz AAC的通常是44100Hz。 设置采样率为44100,目前为常用的采样率,官方文档表示这个值可以兼容所有的设置)
private static final int mSampleRateInHz = 44100;
//指定捕获音频的声道数目。在AudioFormat类中指定用于此的常量
private static final int mChannelConfig = AudioFormat.CHANNEL_CONFIGURATION_MONO;
//单声道
//指定音频量化位数 ,在AudioFormaat类中指定了以下各种可能的常量。通常我们选择ENCODING_PCM_16BIT和ENCODING_PCM_8BIT PCM代表的是脉冲编码调制,它实际上是原始音频样本。
//因此可以设置每个样本的分辨率为16位或者8位,16位将占用更多的空间和处理能力,表示的音频也更加接近真实。
private static final int mAudioFormat = AudioFormat.ENCODING_PCM_16BIT;
//指定缓冲区大小。调用AudioRecord类的getMinBufferSize方法可以获得。
private int mMinBufferSize;
//STREAM的意思是由用户在应用程序通过write方式把数据一次一次得写到audiotrack中。这个和我们在socket中发送数据一样,
// 应用层从某个地方获取数据,例如通过编解码得到PCM数据,然后write到audiotrack。
private static int mMode = AudioTrack.MODE_STREAM;
private void initData() {
//根据采样率,采样精度,单双声道来得到frame的大小。
mMinBufferSize = AudioTrack.getMinBufferSize(mSampleRateInHz, mChannelConfig, mAudioFormat);
//计算最小缓冲区
//注意,按照数字音频的知识,这个算出来的是一秒钟buffer的大小。
//创建AudioTrack
mAudioTrack = new AudioTrack(mStreamType, mSampleRateInHz, mChannelConfig,
mAudioFormat, mMinBufferSize, mMode);
}/**
* 启动播放线程
*/
private void startThread() {
destroyThread();
isStart = true;
if (mRecordThread == null) {
mRecordThread = new Thread(recordRunnable);
mRecordThread.start();
}
}/**
* 播放线程
*/
private Runnable recordRunnable = new Runnable() {
@Override
public void run() {
try {
//设置线程的优先级
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO);
byte[] tempBuffer = new byte[mMinBufferSize];
int readCount = 0;
while (mDis.available() >
0) {
readCount = mDis.read(tempBuffer);
if (readCount == AudioTrack.ERROR_INVALID_OPERATION || readCount == AudioTrack.ERROR_BAD_VALUE) {
continue;
}
//一边播放一边写入语音数据
if (readCount != 0 &
&
readCount != -1) {
//判断AudioTrack未初始化,停止播放的时候释放了,状态就为STATE_UNINITIALIZED
if (mAudioTrack.getState() == mAudioTrack.STATE_UNINITIALIZED) {
initData();
}
mAudioTrack.play();
mAudioTrack.write(tempBuffer, 0, readCount);
}
}
//播放完就停止播放
stopPlay();
} catch (Exception e) {
e.printStackTrace();
}
}};
/**
* 启动播放
*
* @param path
*/
public void startPlay(String path) {
try {
setPath(path);
startThread();
} catch (Exception e) {
e.printStackTrace();
}
}/**
* 停止播放
*/
public void stopPlay() {
try {
destroyThread();
//销毁线程
if (mAudioTrack != null) {
if (mAudioTrack.getState() == AudioRecord.STATE_INITIALIZED) {//初始化成功
mAudioTrack.stop();
//停止播放
}
if (mAudioTrack != null) {
mAudioTrack.release();
//释放audioTrack资源
}
}
if (mDis != null) {
mDis.close();
//关闭数据输入流
}
} catch (Exception e) {
e.printStackTrace();
}
}}
推荐阅读
- appium--连续滑动
- Spring Boot2 系列教程理解Spring Boot 配置文件 application.properties
- appium--Toast元素识别
- Android 双屏异显的实现
- Android进阶AIDL使用自定义类型
- 使用 application.properties 中配置的属性,举例(@Value("${server.port}"))
- uni-app快速上手
- Android-打包AAR步骤以及最为关键的注意事项!
- android开发环境之SDK文件夹下的所需内容