实时Android语音对讲系统架构

本文属于Android局域网内的语音对讲项目系列,《通过UDP广播实现Android局域网Peer Discovering》实现了局域网内的广播及多播通信,本文将重点说明系统架构,音频信号的实时录制、播放及编解码相关技术。

本文主要包含以下内容:

  1. AudioRecord、AudioTrack
  2. Speex编解码
  3. Android语音对讲系统架构

一、AudioRecord、AudioTrack

AudioRecorder和AudioTracker是Android中获取实时音频数据的接口。在网络电话、语音对讲等场景中,由于实时性的要求,不能采用文件传输,因此,MediaRecorder和MediaPlayer就无法使用。

AudioRecorder和AudioTracker是Android在Java层对libmedia库的封装,所以效率较高,适合于实时语音相关处理的应用。在使用时,AudioRecorder和AudioTracker的构造器方法入参较多,这里对其进行详细的解释。

AudioRecord

public AudioRecord(int audioSource, int sampleRateInHz, int channelConfig, int audioFormat, int bufferSizeInBytes)

其中,audioSource表示录音来源,在AudioSource中列举了不同的音频来源,包括:

AudioSource.DEFAULT:默认音频来源
AudioSource.MIC:麦克风(常用)
AudioSource.VOICE_UPLINK:电话上行
AudioSource.VOICE_DOWNLINK:电话下行
AudioSource.VOICE_CALL:电话、含上下行
AudioSource.CAMCORDER:摄像头旁的麦克风
AudioSource.VOICE_RECOGNITION:语音识别
AudioSource.VOICE_COMMUNICATION:语音通信

这里比较常用的有MICVOICE_COMMUNICATIONVOICE_CALL

sampleRateInHz表示采样频率。音频的采集过程要经过抽样量化编码三步。抽样需要关注抽样率。声音是机械波,其特征主要包括频率和振幅(即音调和音量),频率对应时间轴线,振幅对应电平轴线。采样是指间隔固定的时间对波形进行一次记录,采样率就是在1秒内采集样本的次数。量化过程就是用数字表示振幅的过程。编码是一个减少信息量的过程,任何数字音频编码方案都是有损的。PCM编码(脉冲编码调制)是一种保真水平较高的编码方式。在Android平台,44100Hz是唯一目前所有设备都保证支持的采样频率。但比如22050、16000、11025也在大多数设备上得到支持。8000是针对某些低质量的音频通信使用的。

channelConfig表示音频通道,即选择单声道、双声道等参数。系统提供的选择如下:

public static final int CHANNEL_IN_DEFAULT = 1;
// These directly match native
public static final int CHANNEL_IN_LEFT = 0x4;
public static final int CHANNEL_IN_RIGHT = 0x8;
public static final int CHANNEL_IN_FRONT = 0x10;
public static final int CHANNEL_IN_BACK = 0x20;
public static final int CHANNEL_IN_LEFT_PROCESSED = 0x40;
public static final int CHANNEL_IN_RIGHT_PROCESSED = 0x80;
public static final int CHANNEL_IN_FRONT_PROCESSED = 0x100;
public static final int CHANNEL_IN_BACK_PROCESSED = 0x200;
public static final int CHANNEL_IN_PRESSURE = 0x400;
public static final int CHANNEL_IN_X_AXIS = 0x800;
public static final int CHANNEL_IN_Y_AXIS = 0x1000;
public static final int CHANNEL_IN_Z_AXIS = 0x2000;
public static final int CHANNEL_IN_VOICE_UPLINK = 0x4000;
public static final int CHANNEL_IN_VOICE_DNLINK = 0x8000;
public static final int CHANNEL_IN_MONO = CHANNEL_IN_FRONT;
public static final int CHANNEL_IN_STEREO = (CHANNEL_IN_LEFT | CHANNEL_IN_RIGHT);

常用的是CHANNEL_IN_MONOCHANNEL_IN_STEREO分别表示单通道输入和左右两通道输入。

audioFormat指定返回音频数据的格式,常见的选择包括ENCODING_PCM_16BITENCODING_PCM_8BITENCODING_PCM_FLOATENCODING_PCM_16BIT表示PCM 16bits每个样本,所有设备保证支持。ENCODING_PCM_8BIT自然表示PCM 8bits每个样本。ENCODING_PCM_FLOAT表示一个单精度浮点数表示一个样本。

bufferSizeInBytes表示录音时音频数据写入的buffer的大小。这个数值是通过另一个方法来获取的:getMinBufferSizegetMinBufferSize是AudioRecord类的静态方法,返回值就是bufferSizeInBytes。这里我们来看下它的入参:

static public int getMinBufferSize(int sampleRateInHz, int channelConfig, int audioFormat)

sampleRateInHz, channelConfig, audioFormat三个参数与上面的含义完全一样,代表录音的采样率、通道以及数据输出的格式。综上,AudioRecord的初始化方法如下:

// 获取音频数据缓冲段大小
inAudioBufferSize = AudioRecord.getMinBufferSize(
        Constants.sampleRateInHz, Constants.inputChannelConfig, Constants.audioFormat);
// 初始化音频录制
audioRecord = new AudioRecord(Constants.audioSource,
        Constants.sampleRateInHz, Constants.inputChannelConfig, Constants.audioFormat, inAudioBufferSize);

其中,参数设置如下:

// 采样频率,44100保证兼容性
public static final int sampleRateInHz = 44100;
// 音频数据格式:PCM 16位每个样本,保证设备支持。
public static final int audioFormat = AudioFormat.ENCODING_PCM_16BIT;

// 音频获取源
public static final int audioSource = MediaRecorder.AudioSource.MIC;
// 输入单声道
public static final int inputChannelConfig = AudioFormat.CHANNEL_IN_MONO;

AudioTrack

public AudioTrack(int streamType, int sampleRateInHz, int channelConfig, int audioFormat,
        int bufferSizeInBytes, int mode) throws IllegalArgumentException {
    this(streamType, sampleRateInHz, channelConfig, audioFormat,
            bufferSizeInBytes, mode, AudioManager.AUDIO_SESSION_ID_GENERATE);
}

与AudioRecord类似,AudioTrack的构造器方法依然有很多需要选择的参数。其中,streamType表示音频流播放类型,AudioManager中列出了可选的类型如下:

/** The audio stream for phone calls */
public static final int STREAM_VOICE_CALL = AudioSystem.STREAM_VOICE_CALL;
/** The audio stream for system sounds */
public static final int STREAM_SYSTEM = AudioSystem.STREAM_SYSTEM;
/** The audio stream for the phone ring */
public static final int STREAM_RING = AudioSystem.STREAM_RING;
/** The audio stream for music playback */
public static final int STREAM_MUSIC = AudioSystem.STREAM_MUSIC;
/** The audio stream for alarms */
public static final int STREAM_ALARM = AudioSystem.STREAM_ALARM;
/** The audio stream for notifications */
public static final int STREAM_NOTIFICATION = AudioSystem.STREAM_NOTIFICATION;
/** @hide The audio stream for phone calls when connected to bluetooth */
public static final int STREAM_BLUETOOTH_SCO = AudioSystem.STREAM_BLUETOOTH_SCO;
/** @hide The audio stream for enforced system sounds in certain countries (e.g camera in Japan) */
public static final int STREAM_SYSTEM_ENFORCED = AudioSystem.STREAM_SYSTEM_ENFORCED;
/** The audio stream for DTMF Tones */
public static final int STREAM_DTMF = AudioSystem.STREAM_DTMF;
/** @hide The audio stream for text to speech (TTS) */
public static final int STREAM_TTS = AudioSystem.STREAM_TTS;

常用的有STREAM_VOICE_CALLSTREAM_MUSIC等,需要根据应用特点进行选择。

sampleRateInHzaudioFormat需与AudioRecord中的参数保持一致,这里不再介绍。

channelConfig与AudioRecord中的参数保持对应,比如AudioRecord选择了AudioFormat.CHANNEL_IN_MONO(单通道音频输入),这里需要选择AudioFormat.CHANNEL_OUT_MONO(单通道音频输出)。

bufferSizeInBytes表述音频播放缓冲区大小,同样,也需要根据AudioTrack的静态方法getMinBufferSize来获取。

static public int getMinBufferSize(int sampleRateInHz, int channelConfig, int audioFormat) 

sampleRateInHz, channelConfig, audioFormat三个参数与上面的含义完全一样,代表输出音频的采样率、通道以及数据输出的格式。

最后说明下modeAudioManager.AUDIO_SESSION_ID_GENERATEmode代表音频输出的模式:MODE_STATICMODE_STREAM,分别表示静态模式和流模式。AudioManager.AUDIO_SESSION_ID_GENERATE表示AudioSessionId,即AudioTrack依附到哪个音频会话。

比如,要给AudioRecord添加回声消除AcousticEchoCancelerAcousticEchoCanceler的构建方法create的入参就是sessionId,通过AudioRecord实例的getAudioSessionId()方法获取。

综上,AudioTrack的初始化方法如下:

public Tracker() {
    // 获取音频数据缓冲段大小
    outAudioBufferSize = AudioTrack.getMinBufferSize(
            Constants.sampleRateInHz, Constants.outputChannelConfig, Constants.audioFormat);
    // 初始化音频播放
    audioTrack = new AudioTrack(Constants.streamType,
            Constants.sampleRateInHz, Constants.outputChannelConfig, Constants.audioFormat,
            outAudioBufferSize, Constants.trackMode);
}

其中,参数设置如下:

// 音频播放端
public static final int streamType = AudioManager.STREAM_VOICE_CALL;
// 输出单声道
public static final int outputChannelConfig = AudioFormat.CHANNEL_OUT_MONO;
// 音频输出模式
public static final int trackMode = AudioTrack.MODE_STREAM;

二、Speex编解码

Speex是一个声音编码格式,目标是用于网络电话、线上广播使用的语音编码,基于CELP(一种语音编码算法)开发,Speex宣称可以免费使用,以BSD授权条款开放源代码。

Speex是由C语言开发的音频处理库,在Android中使用,需要通过JNI来调用。因此,对NDK开发不熟悉的朋友,可以先了解下文档:向您的项目添加 C 和 C++ 代码

在Android Studio中使用C/C++库有两种方式:cmake和ndk-build。cmake是最新支持的方法,通过配置CMakeLists.txt文件来实现;ndk-build是传统的方式,通过配置Android.mk文件来实现。具体语法参考相关文档,这里不做深入介绍。配置完上述文件之后,需要将Gradle关联到原生库,通过AS的Link C++ Project with Gradle功能实现。

完成上述配置之后,正式开始在Android中使用Speex进行音频编解码。主要包括以下步骤:

  1. 下载Speex源码。推荐使用Speex 1.2.0稳定版,由于目前Speex 已不再继续维护,官方建议使用Opus。但在某些场合,使用Speex已然足够满足需求。
    Speex源码
  2. src/main下创建jni文件夹,将上述Speex源码中includelibspeex文件夹拷贝到jni文件夹下。
  3. 编写Android.mk文件和Application.mk文件。
    Android.mk
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_LDLIBS :=-llog
LOCAL_MODULE    := libspeex
LOCAL_CFLAGS = -DFIXED_POINT -DUSE_KISS_FFT -DEXPORT="" -UHAVE_CONFIG_H
LOCAL_C_INCLUDES := $(LOCAL_PATH)/include
LOCAL_SRC_FILES := speex_jni.cpp \
        ./libspeex/bits.c \
        ./libspeex/cb_search.c \
        ./libspeex/exc_10_16_table.c \
        ./libspeex/exc_10_32_table.c \
        ./libspeex/exc_20_32_table.c \
        ./libspeex/exc_5_256_table.c \
        ./libspeex/exc_5_64_table.c \
        ./libspeex/exc_8_128_table.c \
        ./libspeex/filters.c \
        ./libspeex/gain_table_lbr.c \
        ./libspeex/gain_table.c \
        ./libspeex/hexc_10_32_table.c \
        ./libspeex/hexc_table.c \
        ./libspeex/high_lsp_tables.c \
        ./libspeex/kiss_fft.c \
        ./libspeex/kiss_fftr.c \
        ./libspeex/lpc.c \
        ./libspeex/lsp_tables_nb.c \
        ./libspeex/lsp.c \
        ./libspeex/ltp.c \
        ./libspeex/modes_wb.c \
        ./libspeex/modes.c \
        ./libspeex/nb_celp.c \
        ./libspeex/quant_lsp.c \
        ./libspeex/sb_celp.c \
        ./libspeex/smallft.c \
        ./libspeex/speex_callbacks.c \
        ./libspeex/speex_header.c \
        ./libspeex/speex.c \
        ./libspeex/stereo.c \
        ./libspeex/vbr.c \
        ./libspeex/vorbis_psy.c \
        ./libspeex/vq.c \
        ./libspeex/window.c \
include $(BUILD_SHARED_LIBRARY)

Application.mk

APP_ABI := armeabi armeabi-v7a
  1. 新建speex_config_types.h文件。在jnispeex源码目录下的include/speex文件夹下,有一个speex_config_types.h.in文件,在include/speex目录下创建speex_config_types.h,把speex_config_types.h.in的内容拷贝过来,然后把@SIZE16@改成short,把@SIZE32@改成int,对应Java数据类型。这个文件的内容如下:
#ifndef __SPEEX_TYPES_H__
#define __SPEEX_TYPES_H__
typedef short spx_int16_t;
typedef unsigned short spx_uint16_t;
typedef int spx_int32_t;
typedef unsigned int spx_uint32_t;
#endif
  1. 在Java层定义编解码需要的接口。
public class Speex {
    static {
        try {
            System.loadLibrary("speex");
        } catch (Throwable e) {
            e.printStackTrace();
        }
    }
    public native int open(int compression);
    public native int getFrameSize();
    public native int decode(byte encoded[], short lin[], int size);
    public native int encode(short lin[], int offset, byte encoded[], int size);
    public native void close();
}
  1. 在C层实现上述方法(以encode为例)。
extern "C"
JNIEXPORT jint JNICALL Java_com_jd_wly_intercom_audio_Speex_encode
    (JNIEnv *env, jobject obj, jshortArray lin, jint offset, jbyteArray encoded, jint size) {

    jshort buffer[enc_frame_size];
    jbyte output_buffer[enc_frame_size];
    int nsamples = (size-1)/enc_frame_size + 1;
    int i, tot_bytes = 0;

    if (!codec_open)
        return 0;

    speex_bits_reset(&ebits);

    for (i = 0; i < nsamples; i++) {
        env->GetShortArrayRegion(lin, offset + i*enc_frame_size, enc_frame_size, buffer);
        speex_encode_int(enc_state, buffer, &ebits);
    }

    tot_bytes = speex_bits_write(&ebits, (char *)output_buffer, enc_frame_size);
    env->SetByteArrayRegion(encoded, 0, tot_bytes, output_buffer);

    return (jint)tot_bytes;
}
  1. 命令行到Android.mk文件夹下,执行命令ndk-build
D:\dev\study\intercom\WlyIntercom\app\src\main\jni>ndk-build
[armeabi] Compile++ thumb: speex <= speex_jni.cpp
[armeabi] Compile thumb  : speex <= bits.c
[armeabi] Compile thumb  : speex <= cb_search.c
[armeabi] Compile thumb  : speex <= exc_10_16_table.c
[armeabi] Compile thumb  : speex <= exc_10_32_table.c
[armeabi] Compile thumb  : speex <= exc_20_32_table.c
[armeabi] Compile thumb  : speex <= exc_5_256_table.c
[armeabi] Compile thumb  : speex <= exc_5_64_table.c
[armeabi] Compile thumb  : speex <= exc_8_128_table.c
[armeabi] Compile thumb  : speex <= filters.c
[armeabi] Compile thumb  : speex <= gain_table_lbr.c
[armeabi] Compile thumb  : speex <= gain_table.c
[armeabi] Compile thumb  : speex <= hexc_10_32_table.c
[armeabi] Compile thumb  : speex <= hexc_table.c
[armeabi] Compile thumb  : speex <= high_lsp_tables.c
[armeabi] Compile thumb  : speex <= kiss_fft.c
[armeabi] Compile thumb  : speex <= kiss_fftr.c
[armeabi] Compile thumb  : speex <= lpc.c
[armeabi] Compile thumb  : speex <= lsp_tables_nb.c
[armeabi] Compile thumb  : speex <= lsp.c
[armeabi] Compile thumb  : speex <= ltp.c
[armeabi] Compile thumb  : speex <= modes_wb.c
[armeabi] Compile thumb  : speex <= modes.c
[armeabi] Compile thumb  : speex <= nb_celp.c
[armeabi] Compile thumb  : speex <= quant_lsp.c
[armeabi] Compile thumb  : speex <= sb_celp.c
[armeabi] Compile thumb  : speex <= smallft.c
[armeabi] Compile thumb  : speex <= speex_callbacks.c
[armeabi] Compile thumb  : speex <= speex_header.c
[armeabi] Compile thumb  : speex <= speex.c
[armeabi] Compile thumb  : speex <= stereo.c
[armeabi] Compile thumb  : speex <= vbr.c
[armeabi] Compile thumb  : speex <= vorbis_psy.c
[armeabi] Compile thumb  : speex <= vq.c
[armeabi] Compile thumb  : speex <= window.c
[armeabi] StaticLibrary  : libstdc++.a
[armeabi] SharedLibrary  : libspeex.so
[armeabi] Install        : libspeex.so => libs/armeabi/libspeex.so

生成libs/armeabi/libspeex.so和对应的obj文件,如需单独使用,将上述过程生成的*.so包拷贝至jniLibs文件夹中。

  1. 最后,在Android中通过Java去调用encode方法进行音频数据的编码。
/**
 * 将raw原始音频文件编码为Speex格式
 *
 * @param audioData 原始音频数据
 * @return 编码后的数据
 */
public static byte[] raw2spx(short[] audioData) {
    // 原始数据中包含的整数个encFrameSize
    int nSamples = audioData.length / encFrameSize;
    byte[] encodedData = new byte[((audioData.length - 1) / encFrameSize + 1) * encodedFrameSize];
    short[] rawByte;
    // 将原数据转换成spx压缩的文件
    byte[] encodingData = new byte[encFrameSize];
    int readTotal = 0;
    for (int i = 0; i < nSamples; i++) {
        rawByte = new short[encFrameSize];
        System.arraycopy(audioData, i * encFrameSize, rawByte, 0, encFrameSize);
        int encodeSize = Speex.getInstance().encode(rawByte, 0, encodingData, rawByte.length);
        System.arraycopy(encodingData, 0, encodedData, readTotal, encodeSize);
        readTotal += encodeSize;
    }
    rawByte = new short[encFrameSize];
    System.arraycopy(audioData, nSamples * encFrameSize, rawByte, 0, audioData.length - nSamples * encFrameSize);
    int encodeSize = Speex.getInstance().encode(rawByte, 0, encodingData, rawByte.length);
    System.arraycopy(encodingData, 0, encodedData, readTotal, encodeSize);
    return encodedData;
}

这里设置了每帧处理160个short型数据,压缩比为5,每帧输出为28个byte型数据。Speex压缩模式特征如下:

Speex压缩模式特征

原文综合考虑音频质量、压缩比和算法复杂度,最后选择了Mode 5。

private static final int DEFAULT_COMPRESSION = 5;

三、Android语音对讲项目系统架构

再次说明,本文实现参考了论文:Android real-time audio communications over local wireless,因此系统架构如下图所示:

Android对讲机系统架构

数据包要经过Record、Encoder、Transmission、Decoder、Play这一链条的处理,这种数据流转就是对讲机核心抽象。鉴于这种场景,本文的实现采用了责任链设计模式。责任链模式属于行为型模式,表征对对象的某种行为。

创建型模式,共五种:工厂方法模式、抽象工厂模式、单例模式、建造者模式、原型模式。
结构型模式,共七种:适配器模式、装饰器模式、代理模式、外观模式、桥接模式、组合模式、享元模式。
行为型模式,共十一种:策略模式、模板方法模式、观察者模式、迭代子模式、责任链模式、命令模式、备忘录模式、状态模式、访问者模式、中介者模式、解释器模式。

责任链设计模式的使用场景:在责任链模式里,很多对象里由每一个对象对其下家的引用而连接起来形成一条链。请求在这个链上传递,直到链上的某一个对象决定处理此请求。发出这个请求的客户端并不知道链上的哪一个对象最终处理这个请求,这使得系统可以在不影响客户端的情况下动态地重新组织和分配责任。下面来看下具体的代码:

首先定义一个JobHandler,代表每个对象,其中包含抽象方法handleRequest():

/**
 * 数据处理节点
 *
 * @param <I> 输入数据类型
 * @param <O> 输出数据类型
 * @author yanghao1
 */
public abstract class JobHandler<I, O> {

    private JobHandler<O, ?> nextJobHandler;

    public JobHandler<O, ?> getNextJobHandler() {
        return nextJobHandler;
    }

    public void setNextJobHandler(JobHandler<O, ?> nextJobHandler) {
        this.nextJobHandler = nextJobHandler;
    }

    public abstract void handleRequest(I audioData);

    /**
     * 释放资源
     */
    public void free() {

    }
}

JobHandler<I, O>表示输入数据类型为I,输出类型为OnextJobHandler表示下一个处理请求的节点,其类型为JobHandler<O, ?>,即输入数据类型必须为上一个处理节点的输出数据类型。

继承类必须实现抽象方法handleRequest(),参数类型为I,实现对数据包的处理。free()方法实现资源的释放,继承类可根据情况重写该方法。这里分别定义RecorderEncoderSenderReceiverDecoderTracker,均继承自JobHandler

RecorderEncoderSender为例说明输入侧数据的处理(这里仅列出部分代码,具体代码参考github地址):

/**
 * 音频录制数据格式ENCODING_PCM_16BIT,返回数据类型为short[]
 *
 * @author yanghao1
 */
public class Recorder extends JobHandler<short[], short[]> {

    @Override
    public void handleRequest(short[] audioData) {
        if (audioRecord.getRecordingState() == AudioRecord.RECORDSTATE_STOPPED) {
            audioRecord.startRecording();
        }
        // 实例化音频数据缓冲
        audioData = new short[inAudioBufferSize];
        audioRecord.read(audioData, 0, inAudioBufferSize);
        getNextJobHandler().handleRequest(audioData);
    }
}

Recorder完成音频采集之后,通过getNextJobHandler()方法获取对下一个处理节点的引用,然后调用其方法handleRequest(),并且入参类型为short[]Recorder的下一个处理节点是Encoder,在EncoderhandleRequest()方法中,实现音频数据的编码,其输入类型为short[],输出为byte[]

/**
 * 音频编码,输入类型为short[],输出为byte[]
 *
 * @author yanghao1
 */
public class Encoder extends JobHandler<short[], byte[]> {

    @Override
    public void handleRequest(short[] audioData) {
        byte[] encodedData = AudioDataUtil.raw2spx(audioData);
        getNextJobHandler().handleRequest(encodedData);
    }
}

Encoder的下一个处理节点是Sender,在SenderhandleRequest()方法中,通过多播(组播),将音频编码数据发送给局域网内的其它设备。

/**
 * UDP多播发送
 *
 * @author yanghao1
 */
public class Sender extends JobHandler<byte[], byte[]> {

    @Override
    public void handleRequest(byte[] audioData) {
        DatagramPacket datagramPacket = new DatagramPacket(
                audioData, audioData.length, inetAddress, Constants.MULTI_BROADCAST_PORT);
        try {
            multicastSocket.send(datagramPacket);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

最后,在AudioInput类的构造函数中执行对象之间的关系:

/**
 * 音频录制、编码、发送线程
 *
 * @author yanghao1
 */
public class AudioInput implements Runnable {

    private Recorder recorder;
    private Encoder encoder;
    private Sender sender;
    private Handler handler;

    // 录制状态
    private boolean recording = false;

    public AudioInput(Handler handler) {
        this.handler = handler;
        initJobHandler();
    }

    /**
     * 初始化录制、编码、发送,并指定关联
     */
    private void initJobHandler() {
        recorder = new Recorder();
        encoder = new Encoder();
        sender = new Sender(handler);
        recorder.setNextJobHandler(encoder);
        encoder.setNextJobHandler(sender);
    }
}

即:在界面初始化AudioInput对应的线程的时候,就完成这些类的实例化,并指定Recorder的下一个处理者是Encoder,Encoder的下一个处理者是Sender。这样使得整个处理流程非常灵活,比如,如果暂时没有开发编解码的过程,在Encoder的handleRequest()方法中直接指定下一个处理者:

public class Encoder extends JobHandler {

    @Override
    public void handleRequest(byte[] audioData) {
        getNextJobHandler().handleRequest(audioData);
    }
}

同样的,在初始化AudioOutput对应的线程时,完成ReceiverDecoderTracker的实例化,并且指定Receiver的下一个处理者是DecoderDecoder的下一个处理者是Tracker

在Activity中,分别申明输入、输出Runable、线程池对象、界面更新Handler:

// 界面更新Handler
private AudioHandler audioHandler = new AudioHandler(this);

// 音频输入、输出Runable
private AudioInput audioInput;
private AudioOutput audioOutput;

// 创建缓冲线程池用于录音和接收用户上线消息(录音线程可能长时间不用,应该让其超时回收)
private ExecutorService inputService = Executors.newCachedThreadPool();

// 创建循环任务线程用于间隔的发送上线消息,获取局域网内其他的用户
private ScheduledExecutorService discoverService = Executors.newScheduledThreadPool(1);

// 设置音频播放线程为守护线程
private ExecutorService outputService = Executors.newSingleThreadExecutor(new ThreadFactory() {
    @Override
    public Thread newThread(@NonNull Runnable r) {
        Thread thread = Executors.defaultThreadFactory().newThread(r);
        thread.setDaemon(true);
        return thread;
    }
});

可能有的同学会觉得这里的责任链设计模式用法并非真正的责任链,真正的责任链模式要求一个具体的处理者对象只能在两个行为中选择一个:一是承担责任,而是把责任推给下家。不允许出现某一个具体处理者对象在承担了一部分责任后又把责任向下传的情况。
本文中责任链设计模式的用法确实不是严格的责任链模式,但学习的目的不就是活学活用吗?

Android线程池

上述代码涉及Android中的线程池,与Android线程池相关的类包括:ExecutorExecutorsExecutorServiceFutureCallableThreadPoolExecutor等,为了理清它们之间的关系,首先从Executor开始:

  • Executor接口中定义了一个方法 execute(Runnable command),该方法接收一个 Runable实例,它用来执行一个任务,任务即一个实现了Runnable 接口的类。
  • ExecutorService接口继承自Executor 接口,它提供了更丰富的实现多线程的方法,比如,ExecutorService提供了关闭自己的方法,以及可为跟踪一个或多个异步任务执行状况而生成Future 的方法。 可以调用ExecutorServiceshutdown()方法来平滑地关闭 ExecutorService,调用该方法后,将导致 ExecutorService停止接受任何新的任务且等待已经提交的任务执行完成(已经提交的任务会分两类:一类是已经在执行的,另一类是还没有开始执行的),当所有已经提交的任务执行完毕后将会关闭 ExecutorService。因此我们一般用该接口来实现和管理多线程。
  • Executors 提供了一系列工厂方法用于创建线程池,返回的线程池都实现了 ExecutorService接口。包括:
    • newCachedThreadPool()
      创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程;
    • newFixedThreadPool(int)
      创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
    • newScheduledThreadPool(int)
      创建一个定长线程池,支持定时及周期性任务执行。
    • newSingleThreadExecutor()
      创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
  • Callable接口与Runnable接口类似,ExecutorService<T> Future<T> submit(Callable<T> task)方法接受Callable作为入参,在 Java 5 之后,任务分两类:一类是实现了 Runnable接口的类,一类是实现了 Callable 接口的类。两者都可以被 ExecutorService 执行,但是 Runnable任务没有返回值,而 Callable任务有返回值。并且Callablecall()方法只能通过ExecutorServicesubmit(Callable task)方法来执行,并且返回一个 Future,是表示任务等待完成的Future
  • ThreadPoolExecutor继承自AbstractExecutorServiceAbstractExecutorService实现了ExecutorService接口。ThreadPoolExecutor的构造器由于参数较多,不宜直接暴露给使用者。所以,Executors 中定义 ExecutorService实例的工厂方法,其实是通过定义ThreadPoolExecutor不同入参来实现的。

下面来看下ThreadPoolExecutor的构造器方法:

public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit,
                          BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) {

    if (corePoolSize < 0 || maximumPoolSize <= 0 || maximumPoolSize < corePoolSize || keepAliveTime < 0)
        throw new IllegalArgumentException();

    if (workQueue == null || threadFactory == null || handler == null)
        throw new NullPointerException();

    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}

其中,corePoolSize表示线程池中所保存的核心线程数,包括空闲线程;maximumPoolSize表示池中允许的最大线程数;keepAliveTime表示线程池中的空闲线程所能持续的最长时间;unit表示时间的单位;workQueue表示任务执行前保存任务的队列,仅保存由execute 方法提交的Runnable任务;threadFactory表示线程创建的工厂,指定线程的特性,比如前面代码中设置音频播放线程为守护线程;handler表示队列容量满之后的处理方法。

ThreadPoolExecutor对于传入的任务Runnable有如下处理流程:

  1. 如果线程池中的线程数量少于corePoolSize,即使线程池中有空闲线程,也会创建一个新的线程来执行新添加的任务;
  2. 如果线程池中的线程数量大于等于corePoolSize,但缓冲队列workQueue 未满,则将新添加的任务放到 workQueue中,按照 FIFO 的原则依次等待执行(线程池中有线程空闲出来后依次将缓冲队列中的任务交付给空闲的线程执行);
  3. 如果线程池中的线程数量大于等于 corePoolSize,且缓冲队列 workQueue 已满,但线程池中的线程数量小于maximumPoolSize,则会创建新的线程来处理被添加的任务;
  4. 如果线程池中的线程数量等于了maximumPoolSize,交由RejectedExecutionHandler handler处理。

ThreadPoolExecutor主要用于某些特定场合,即上述工厂方法无法满足的时候,自定义线程池使用。本文使用了三种特性的线程池工厂方法:newCachedThreadPool()newScheduledThreadPool(int)newSingleThreadExecutor

首先,对于录音线程,由于对讲机用户大部分时间可能是在听,而不是说。录音线程可能长时间不用,应该让其超时回收,所以录音线程宜使用CachedThreadPool
其次,对于发现局域网内的其它用户的功能,该功能需要不断循环执行,相当于循环的向局域网内发送心跳信号,因此宜使用ScheduledThreadPool
最后,对于音频播放线程,该线程需要一直在后台执行,且播放需要串行执行,因此使用SingleThreadExecutor,并设置为守护线程,在UI线程(主线程是最后一个用户线程)结束之后结束。

// 设置音频播放线程为守护线程
private ExecutorService outputService = Executors.newSingleThreadExecutor(new ThreadFactory() {
    @Override
    public Thread newThread(@NonNull Runnable r) {
        Thread thread = Executors.defaultThreadFactory().newThread(r);
        thread.setDaemon(true);
        return thread;
    }
});

以上。详细代码请移步github:intercom

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,566评论 25 707
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,099评论 18 139
  • 你若盛开,蝴蝶自来
    补钉阅读 136评论 0 0
  • 今天,咱先讲一个故事。 话说,一个商人赚了一大笔钱,正骑着马行驶在归家的途中。离家不远了,这时他的仆人发现马的后掌...
    我是银璃阅读 578评论 0 1