最近比较忙,好久都没有更新博客!

最近由于项目需求要做音乐播放模式的切换,经过好多次尝试,发现这里面满满的都是坑啊,所以就写一篇日志记录下来,也给大家分享下遇到的坑及解决办法.

场景需求

在聊天场景中,收到对方语音时,用户可以选择外放播放,也可以选择插入耳机收听.更人性化一点当用户把手机靠近耳朵时屏幕关闭自动切换到听筒中播放,播放完毕后拿开手机屏幕自动点亮.比如微信就是如此.

需求分析

从上面场景中我们可以得出我们需要的要点:

  1. 播放模式切换:外放<--->耳机
  2. 播放模式切换:外放<--->听筒
  3. 屏幕操作:亮屏<--->息屏<--->亮屏

解决问题

从需求分析我们可以得出需要代码进行控制的有:

  1. 音乐播放控制
  2. 外放,耳机,听筒之间的切换
  3. 屏幕的息屏与亮屏

音乐播放控制

音乐播放控制最简单,直接使用MediaPlayer即可,为了更好地与界面代码分离以及更好控制音乐,这里写了一个控制类:PlayerManager,如下:

/**
 * 音乐播放管理类
 * Created by DevWiki on 2015/8/27 0027.
 */
public class PlayerManager {

    private static PlayerManager playerManager;

    private MediaPlayer mediaPlayer;
    private PlayCallback callback;
    private Context context;

    private String filePath;

    public static PlayerManager getManager(){
        if (playerManager == null){
            synchronized (PlayerManager.class){
                playerManager = new PlayerManager();
            }
        }
        return playerManager;
    }

    private PlayerManager(){
        this.context = MyApplication.getContext();
        mediaPlayer = new MediaPlayer();
    }

    /**
     * 播放回调接口
     */
    public interface PlayCallback{

        /** 音乐准备完毕 */
        void onPrepared();

        /** 音乐播放完成 */
        void onComplete();

        /** 音乐停止播放 */
        void onStop();
    }

    /**
     * 播放音乐
     * @param path 音乐文件路径
     * @param callback 播放回调函数
     */
    public void play(String path, final PlayCallback callback){
        this.filePath = path;
        this.callback = callback;
        try {
            mediaPlayer.reset();
            mediaPlayer.setDataSource(context, Uri.parse(path));
            mediaPlayer.prepare();
            mediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
                @Override
                public void onPrepared(MediaPlayer mp) {
                    callback.onPrepared();
                    mediaPlayer.start();
                }
            });
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 停止播放
     */
    public void stop(){
        if (isPlaying()){
            try {
                mediaPlayer.stop();
                callback.onStop();
            } catch (IllegalStateException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 是否正在播放
     * @return 正在播放返回true,否则返回false
     */
    public boolean isPlaying() {
        return mediaPlayer != null && mediaPlayer.isPlaying();
    }
}

为了方便获取Context,覆写了Application类如下:

/**
 * APP的Application
 * Created by DevWiki on 2015/9/16 0016.
 */
public class MyApplication extends Application {

    private static Context context;

    @Override
    public void onCreate() {
        super.onCreate();
        context = this;
    }

    /**
     * 获取APP的Context方便其他地方调用
     * @return
     */
    public static Context getContext(){
        return context;
    }
}

外放,耳机,听筒之间的切换

在Android系统中是用AudioManager来管理播放模式的,通过AudioManager.setMode()方法来实现.

setMode()方法中有以下几种对应不同的播放模式:

  • MODE_NORMAL: 普通模式,既不是铃声模式也不是通话模式
  • MODE_RINGTONE:铃声模式
  • MODE_IN_CALL:通话模式
  • MODE_IN_COMMUNICATION:通信模式,包括音/视频,VoIP通话.(3.0加入的,与通话模式类似)

其中:

播放音乐的对应的就是MODE_NORMAL, 如果使用外放播则调用audioManager.setSpeakerphoneOn(true)即可.

若使用耳机和听筒,则需要先设置模式为MODE_IN_CALL(3.0以前)或MODE_IN_COMMUNICATION(3.0以后).

注意:

需要权限android.permission.MODIFY_AUDIO_SETTINGS

为什么在3.0以后设置模式为MODE_IN_COMMUNICATION,而不设置为MODE_IN_CALL?

经验证在华为的某些机型中,设置MODE_IN_CALL根本不起作用.

故在PlayerManager类中持有一个AudioManager变量,并添加如下几个方法:

audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);

/**
 * 切换到外放
 */
public void changeToSpeaker(){
    audioManager.setMode(AudioManager.MODE_NORMAL);
    audioManager.setSpeakerphoneOn(true);
}

/**
 * 切换到耳机模式
 */
public void changeToHeadset(){
    audioManager.setSpeakerphoneOn(false);
}

/**
 * 切换到听筒
 */
public void changeToReceiver(){
    audioManager.setSpeakerphoneOn(false);
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB){
        audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
    } else {
        audioManager.setMode(AudioManager.MODE_IN_CALL);
    }
}

如何判断用户是否插入耳机呢?

在插入或者拔出耳机时系统会发出Action为Intent.ACTION_HEADSET_PLUG的广播,并且该广播不能使用静态接收器处理,故写一个广播接收器处理耳机事件即可.

class HeadsetReceiver extends BroadcastReceiver{

    @Override
    public void onReceive(Context context, Intent intent) {
        String action = intent.getAction();
        switch (action){
            //插入和拔出耳机会触发此广播
            case Intent.ACTION_HEADSET_PLUG:
                int state = intent.getIntExtra("state", 0);
                if (state == 1){
                    playerManager.changeToHeadset();
                } else if (state == 0){
                    playerManager.changeToSpeaker();
                }
                break;
            default:
                break;
        }
    }
}

屏幕的息屏与亮屏

屏幕息屏与亮屏有个前提是正确判断用户是否靠近听筒,如何判断?

现在几乎每个手机都有距离感应器,通过举例感应器可获得距离.距离感应器由SensorManager管理:

sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE);
sensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY);
sensorManager.registerListener(this, sensor, SensorManager.SENSOR_DELAY_NORMAL);

注册监听的方法的最后一个参数是敏感度,敏感度越高越费电,此处选择一般敏感度即可.此外Activity还需实现SensorEventListener接口,覆写其方法:

@Override
public void onSensorChanged(SensorEvent event) {
    float value = event.values[0];

    if (playerManager.isPlaying()){
        if (value == sensor.getMaximumRange()) {
            playerManager.changeToSpeaker();
            setScreenOn();
        } else {
            playerManager.changeToReceiver();
            setScreenOff();
        }
    } else {
        if(value == sensor.getMaximumRange()){
            playerManager.changeToSpeaker();
            setScreenOn();
        }
    }
}

在Android系统中硬件的工作状态的控制由PowerManagerWakeLock掌管.PowerManager通过不同的WakeLock来控制CPU,屏幕,键盘等硬件的工作状态.

powerManager = (PowerManager) getSystemService(POWER_SERVICE);
wakeLock = powerManager.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG);

注意:需要权限android.Manifest.permission.DEVICE_POWERandroid.permission.WAKE_LOCK

其中第一个参数代表控制级别,可选值有:

  • PARTIAL_WAKE_LOCK : CPU运行,屏幕和键盘可能关闭
  • SCREEN_DIM_WAKE_LOCK : 屏幕亮,键盘灯可能关闭
  • SCREEN_BRIGHT_WAKE_LOCK : 屏幕全亮,键盘灯可能关闭
  • FULL_WAKE_LOCK : 屏幕和键盘灯全亮
  • PROXIMITY_SCREEN_OFF_WAKE_LOCK : 屏幕关闭,键盘灯关闭,CPU运行
  • DOZE_WAKE_LOCK : 屏幕灰显,CPU延缓工作

此处我们选取PROXIMITY_SCREEN_OFF_WAKE_LOCK.WakeLock通过acquire()release()方法上锁和解锁.

private void setScreenOff(){
    if (wakeLock == null){
        wakeLock = powerManager.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG);
    }
    wakeLock.acquire();
}

private void setScreenOn(){
    if (wakeLock != null){
        wakeLock.setReferenceCounted(false);
        wakeLock.release();
        wakeLock = null;
    }
}

开始验证

通过以上三个解决方案,然后运行程序可知基本满足功能需求.但是有以下几个问题:

  1. 耳机模式下用手遮挡距离感应器会切换到听筒
  2. 三星Note,华为P,华为Mate系列会出现外放切换到听筒,听筒切换到外放出现卡顿现象
  3. 耳机切换到外放会出现丢失语音
  4. 三星,华为手机在熄灭屏幕是会调用ActivityonPause(),onStop()方法!!

解决新问题

耳机模式用手遮挡距离感应器问题

此问题只需在耳机模式下对距离感应器不做响应即可,在PlayerManager中添加:

/**
 * 耳机是否插入
 * @return 插入耳机返回true,否则返回false
 */
@SuppressWarnings("deprecation")
public boolean isWiredHeadsetOn(){
    return audioManager.isWiredHeadsetOn();
}

然后修改距离感应器回调方法为:

@Override
public void onSensorChanged(SensorEvent event) {
    float value = event.values[0];

    if (playerManager.isWiredHeadsetOn()){
        return;
    }

    if (playerManager.isPlaying()){
        if (value == sensor.getMaximumRange()) {
            playerManager.changeToSpeaker();
            setScreenOn();
        } else {
            playerManager.changeToReceiver();
            setScreenOff();
        }
    } else {
        if(value == sensor.getMaximumRange()){
            playerManager.changeToSpeaker();
            setScreenOn();
        }
    }
}

三星,华为听筒外放切换卡顿

这个问题只能采用折中的办法:重新播放

为何采用此方法?

  1. 短的语音本来就短,切换重播几乎不受影响
  2. 长得音乐一般不会用听筒听
  3. 不是所有的手机都会出现卡顿

故在PlayerManager中修改方法:

/**
 * 切换到听筒
 */
public void changeToReceiver(){
    if (isPlaying()){
        stop();
        changeToReceiverNoStop();
        play(filePath, callback);
    } else {
        changeToReceiverNoStop();
    }
}

/**
 * 切换到外放
 */
public void changeToSpeaker(){
    if (PhoneModelUtil.isSamsungPhone() || PhoneModelUtil.isHuaweiPhone()){
        stop();
        changeToSpeakerNoStop();
        play(filePath, callback);
    } else {
        changeToSpeakerNoStop();
    }
}

public void changeToSpeakerNoStop(){
    audioManager.setMode(AudioManager.MODE_NORMAL);
    audioManager.setSpeakerphoneOn(true);
}

耳机切换到外放会出现丢失语音

此问题由于耳机切换到外放需要一段时间导致,故解决此问题的方法是先暂停再续播.那么什么时候暂停什么时候续播呢?

查资料得知,在耳机拔出时系统还会发出Action为AudioManager.ACTION_AUDIO_BECOMING_NOISY的广播,且此广播比Intent.ACTION_HEADSET_PLUG要早,所以解决方案也出来了:

收到AudioManager.ACTION_AUDIO_BECOMING_NOISY时暂停播放,收到Intent.ACTION_HEADSET_PLUG并且附带的state=1时续播

三星,华为手机在熄灭屏幕是会调用ActivityonPause(),onStop()方法

这个问题嘛,其实也不算问题,但是值得注意.如果你在onStop()中做了某些释放资源的操作,那么在onStart()中就要重新获取,防止出现其他问题.

后记

项目代码请点击此处:PlayMode


重要说明

想随时获取最新博客文章更新,请关注公共账号DevWiki,或扫描下面的二维码:

微信公共号