Android 9系统源码_音频管理(一)按键音效源码解析

前言

当用户点击Android智能设备的按钮的时候,如果伴随有按键音效的话,会给用户更好的交互体验。本期我们将会结合Android系统源码来具体分析一下控件是如何发出按键音效的。

一、系统加载按键音效资源

1、在TV版的Android智能设备中,我们可以通过调节设置页面的开关来控制按键音效的有无,该设置页面对应的系统源码如下所示。

packages/apps/TvSettings/Settings/src/com/android/tv/settings/device/sound/SoundFragment.java

java 复制代码
public class SoundFragment extends PreferenceControllerFragment implements
        Preference.OnPreferenceChangeListener {

    private AudioManager mAudioManager;
    private Map<Integer, Boolean> mFormats;

    public static SoundFragment newInstance() {
        return new SoundFragment();
    }

    @Override
    public void onAttach(Context context) {
        mAudioManager = context.getSystemService(AudioManager.class);
        mFormats = mAudioManager.getSurroundFormats();
        super.onAttach(context);
    }
    
	//用户的点击行为首先触发此方法
    @Override
    public boolean onPreferenceTreeClick(Preference preference) {
        if (TextUtils.equals(preference.getKey(), KEY_SOUND_EFFECTS)) {
            final TwoStatePreference soundPref = (TwoStatePreference) preference;
            //调用setSoundEffectsEnabled来设置按键音的开启与关闭
            setSoundEffectsEnabled(soundPref.isChecked());
        }
        return super.onPreferenceTreeClick(preference);
    }

	//获取按键音效是否开启
    public static boolean getSoundEffectsEnabled(ContentResolver contentResolver) {
        return Settings.System.getInt(contentResolver, Settings.System.SOUND_EFFECTS_ENABLED, 1)
                != 0;
    }

	//设置是否开启按键音效
    private void setSoundEffectsEnabled(boolean enabled) {
        if (enabled) {
        	//如果开启按键音,则调用AudioManager的loadSoundEffects方法来加载按键音效资源
            mAudioManager.loadSoundEffects();
        } else {
            mAudioManager.unloadSoundEffects();
        }
        Settings.System.putInt(getActivity().getContentResolver(),
                Settings.System.SOUND_EFFECTS_ENABLED, enabled ? 1 : 0);
    }

}

我们在设置页面点击按键音效开关按钮,最终会触发SoundFragment的setSoundEffectsEnabled方法,该方法会判断是否开启按键音,如果开启,则调用AudioManager的loadSoundEffects方法来加载按键音效资源,反之则会调用unloadSoundEffects方法不加载音效资源。

2、AudioManager的loadSoundEffects方法如下所示。

frameworks/base/media/java/android/media/AudioManager.java

java 复制代码
public class AudioManager {

    //获取AudioService的代理对象
    private static IAudioService getService()
    {
        if (sService != null) {
            return sService;
        }
        IBinder b = ServiceManager.getService(Context.AUDIO_SERVICE);
        sService = IAudioService.Stub.asInterface(b);
        return sService;
    }

    public void loadSoundEffects() {
        final IAudioService service = getService();
        try {
        	//调用AudioService服务的loadSoundEffects方法
            service.loadSoundEffects();
        } catch (RemoteException e) {
            throw e.rethrowFromSystemServer();
        }
    }
    
    public void unloadSoundEffects() {
        final IAudioService service = getService();
        try {
            service.unloadSoundEffects();
        } catch (RemoteException e) {
            throw e.rethrowFromSystemServer();
        }
    }
}

AudioManager的首先通过getService方法获取了音频服务AudioService的代理对象,然后调用该对象的具体方法。

3、AudioService的loadSoundEffects方法如下所示。

java 复制代码
public class AudioService extends IAudioService.Stub
        implements AccessibilityManager.TouchExplorationStateChangeListener,
            AccessibilityManager.AccessibilityServicesStateChangeListener {
       
     private AudioHandler mAudioHandler;

	 //加载音效资源
     public boolean loadSoundEffects() {
        int attempts = 3;
        LoadSoundEffectReply reply = new LoadSoundEffectReply();

        synchronized (reply) {
        	//调用sendMsg发送消息给mAudioHandler。
            sendMsg(mAudioHandler, MSG_LOAD_SOUND_EFFECTS, SENDMSG_QUEUE, 0, 0, reply, 0);
            while ((reply.mStatus == 1) && (attempts-- > 0)) {
                try {
                    reply.wait(SOUND_EFFECTS_LOAD_TIMEOUT_MS);
                } catch (InterruptedException e) {
                    Log.w(TAG, "loadSoundEffects Interrupted while waiting sound pool loaded.");
                }
            }
        }
        return (reply.mStatus == 0);
    }
    
    //不加载音效资源
    public void unloadSoundEffects() {
        sendMsg(mAudioHandler, MSG_UNLOAD_SOUND_EFFECTS, SENDMSG_QUEUE, 0, 0, null, 0);
    }

    private static void sendMsg(Handler handler, int msg,
            int existingMsgPolicy, int arg1, int arg2, Object obj, int delay) {

        if (existingMsgPolicy == SENDMSG_REPLACE) {
            handler.removeMessages(msg);
        } else if (existingMsgPolicy == SENDMSG_NOOP && handler.hasMessages(msg)) {
            return;
        }
        synchronized (mLastDeviceConnectMsgTime) {
            long time = SystemClock.uptimeMillis() + delay;

            if (msg == MSG_SET_A2DP_SRC_CONNECTION_STATE ||
                msg == MSG_SET_A2DP_SINK_CONNECTION_STATE ||
                msg == MSG_SET_HEARING_AID_CONNECTION_STATE ||
                msg == MSG_SET_WIRED_DEVICE_CONNECTION_STATE ||
                msg == MSG_A2DP_DEVICE_CONFIG_CHANGE ||
                msg == MSG_BTA2DP_DOCK_TIMEOUT) {
                if (mLastDeviceConnectMsgTime >= time) {
                  // add a little delay to make sure messages are ordered as expected
                  time = mLastDeviceConnectMsgTime + 30;
                }
                mLastDeviceConnectMsgTime = time;
            }
			//调用handler的sendMessageAtTime方法
            handler.sendMessageAtTime(handler.obtainMessage(msg, arg1, arg2, obj), time);
        }
    }
    
    private class AudioHandler extends Handler {
    		//加载音效
            private boolean onLoadSoundEffects() {
            	...代码暂时省略...
            }
            @Override
        	public void handleMessage(Message msg) {
            switch (msg.what) {
            	...代码省略...
            	 case MSG_UNLOAD_SOUND_EFFECTS:
                    onUnloadSoundEffects();//不加载音效
                    break;
            	 case MSG_LOAD_SOUND_EFFECTS://加载音效
                    boolean loaded = onLoadSoundEffects();//调用onLoadSoundEffects加载音效,并将加载结果赋值给loaded
                    if (msg.obj != null) {
                        LoadSoundEffectReply reply = (LoadSoundEffectReply)msg.obj;
                        synchronized (reply) {
                            reply.mStatus = loaded ? 0 : -1;
                            reply.notify();
                        }
                    }
                    break;
              	...代码省略...
            }
    }
}

这里我们只要分析一下AudioService的加载音效资源的loadSoundEffects方法,该方法会调用sendMsg,发送类型为MSG_UNLOAD_SOUND_EFFECTS的msg给mAudioHandler。然后会进一步触发AudioHandler的handleMessage方法,该消息最终会触发onLoadSoundEffects方法。

4、AudioHandler的onLoadSoundEffects方法如下所示。

java 复制代码
public class AudioService extends IAudioService.Stub
        implements AccessibilityManager.TouchExplorationStateChangeListener,
            AccessibilityManager.AccessibilityServicesStateChangeListener {
    //音效资源文件名称
    private static final List<String> SOUND_EFFECT_FILES = new ArrayList<String>();
    
    private class AudioHandler extends Handler {
		//加载音效
        private boolean onLoadSoundEffects() {
            int status;
            synchronized (mSoundEffectsLock) {
            	//如果系统未启动完毕直接返回
                if (!mSystemReady) {
                    Log.w(TAG, "onLoadSoundEffects() called before boot complete");
                    return false;
                }
                //如果mSoundPool不为空直接返回
                if (mSoundPool != null) {
                    return true;
                }
                //加载触摸音效
                loadTouchSoundAssets();
   				...代码暂时省略...
        }
        
    //加载按键音效资源
    private void loadTouchSoundAssets() {
        XmlResourceParser parser = null;
        //如果音效资源文件列表不为空直接返回
        if (!SOUND_EFFECT_FILES.isEmpty()) {
            return;
        }
        //加载按键默认的音效资源
        loadTouchSoundAssetDefaults();
       ...代码省略...
	}
	
    private void loadTouchSoundAssetDefaults() {
    	//在类型为List<String>的SOUND_EFFECT_FILES中添加默认的按键音效资源Effect_Tick.ogg
        SOUND_EFFECT_FILES.add("Effect_Tick.ogg");
        for (int i = 0; i < AudioManager.NUM_SOUND_EFFECTS; i++) {
            SOUND_EFFECT_FILES_MAP[i][0] = 0;
            SOUND_EFFECT_FILES_MAP[i][1] = -1;
        }
    }
}

onLoadSoundEffects方法首先判断系统是否已经启动完毕,如果未启动直接返回;然后判断mSoundPool是否空,如果不为空则直接返回;

然后首先会调用一个关键方法loadTouchSoundAssets,该方法首先判断音效资源文件列表SOUND_EFFECT_FILES是否为空,不为空直接返回。如果以上判断都不成立,则会调用loadTouchSoundAssetDefaults方法加载按键默认的音效资源,该方法首先在SOUND_EFFECT_FILES的添加音效资源Effect_Tick.ogg。

5、继续往下看AudioHandler的onLoadSoundEffects方法。

java 复制代码
public class AudioService extends IAudioService.Stub
        implements AccessibilityManager.TouchExplorationStateChangeListener,
            AccessibilityManager.AccessibilityServicesStateChangeListener {
    //音效资源文件名称
    private static final List<String> SOUND_EFFECT_FILES = new ArrayList<String>();
    
    private class AudioHandler extends Handler {
		//加载音效
        private boolean onLoadSoundEffects() {
            int status;

            synchronized (mSoundEffectsLock) {
                if (!mSystemReady) {
                    Log.w(TAG, "onLoadSoundEffects() called before boot complete");
                    return false;
                }

                if (mSoundPool != null) {
                    return true;
                }
                //加载触摸音效
                loadTouchSoundAssets();
                //创建SoundPool对象
                mSoundPool = new SoundPool.Builder()
                        .setMaxStreams(NUM_SOUNDPOOL_CHANNELS)
                        .setAudioAttributes(new AudioAttributes.Builder()
                            .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
                            .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
                            .build())
                        .build();
         		...代码省略...
                int numSamples = 0;
                for (int effect = 0; effect < AudioManager.NUM_SOUND_EFFECTS; effect++) {
                    // Do not load sample if this effect uses the MediaPlayer
                    if (SOUND_EFFECT_FILES_MAP[effect][1] == 0) {
                        continue;
                    }
                    if (poolId[SOUND_EFFECT_FILES_MAP[effect][0]] == -1) {
                        //获取音效资源文件路径
                        String filePath = getSoundEffectFilePath(effect);
                        //使用SoundPool加载音效资源文件
                        int sampleId = mSoundPool.load(filePath, 0);
                        if (sampleId <= 0) {
                            Log.w(TAG, "Soundpool could not load file: "+filePath);
                        } else {
                            SOUND_EFFECT_FILES_MAP[effect][1] = sampleId;
                            poolId[SOUND_EFFECT_FILES_MAP[effect][0]] = sampleId;
                            numSamples++;
                        }
                    } else {
                        SOUND_EFFECT_FILES_MAP[effect][1] =
                                poolId[SOUND_EFFECT_FILES_MAP[effect][0]];
                    }
                }
			...代码省略...
        }
        
        //获取音效资源文件路径,默认返回的音效资源文件路径为/system/media/audio/ui/Effect_Tick.ogg
        private String getSoundEffectFilePath(int effectType) {
            //  /product + /media/audio/ui/ + Effect_Tick.ogg
            String filePath = Environment.getProductDirectory() + SOUND_EFFECTS_PATH
                    + SOUND_EFFECT_FILES.get(SOUND_EFFECT_FILES_MAP[effectType][0]);
            if (!new File(filePath).isFile()) {
                //   /system + /media/audio/ui/ + Effect_Tick.ogg
                filePath = Environment.getRootDirectory() + SOUND_EFFECTS_PATH
                        + SOUND_EFFECT_FILES.get(SOUND_EFFECT_FILES_MAP[effectType][0]);
            }
            return filePath;
        }
 	 }
 }

onLoadSoundEffects先是调用loadTouchSoundAssets方法加载默认的音效资源文件名称,然后构建SoundPool实例对象,随后调用getSoundEffectFilePath获取按键音效资源文件路径,默认返回的音效资源文件路径为/system/media/audio/ui/Effect_Tick.ogg,并调用SoundPool加载该音效资源。

6、简单回顾一下以上步骤。

二、点击控件,播放音效资源

在系统开启按键音效之后,当我们点击任意控件之后,都会触发按键音效。接下来我们将会结合View的系统源码来梳理该流程。

1、当我们点击一个控件的时候,首先会触发该View的performClick方法。

frameworks/base/core/java/android/view/View.java

java 复制代码
public class View implements Drawable.Callback, KeyEvent.Callback,
        AccessibilityEventSource {
	
    public boolean performClick() {
        // We still need to call this method to handle the cases where performClick() was called
        // externally, instead of through performClickInternal()
        notifyAutofillManagerOnClick();

        final boolean result;
        final ListenerInfo li = mListenerInfo;
        if (li != null && li.mOnClickListener != null) {
            playSoundEffect(SoundEffectConstants.CLICK);//播放按键点击音效
            li.mOnClickListener.onClick(this);
            result = true;
        } else {
            result = false;
        }

        sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
        notifyEnterOrExitForAutoFillIfNeeded(true);

        return result;
    }
    
    public void playSoundEffect(int soundConstant) {
        //判断mAttachInfo.mRootCallbacks是否为空,以及系统是否开启了按键音效
        if (mAttachInfo == null || mAttachInfo.mRootCallbacks == null || !isSoundEffectsEnabled()) {
            return;
        }
        //调用mAttachInfo.mRootCallbacks的playSoundEffect方法
        mAttachInfo.mRootCallbacks.playSoundEffect(soundConstant);
    }
 }

View的performClick方法会调用playSoundEffect方法,playSoundEffect方法首先判断mAttachInfo.mRootCallbacks是否为空,以及系统是否开启了按键音效,然后调用mAttachInfo.mRootCallbacks的playSoundEffect方法。我们知道WindowManager在将View添加到窗口的过程中,都需要用到ViewRootImpl这个类,具体请参考Android 9.0系统源码_窗口管理(二)WindowManager对窗口的管理过程这篇文章。

2、mAttachInfo最初是在ViewRootImpl的构造方法中被创建的。

frameworks/base/core/java/android/view/ViewRootImpl.java

java 复制代码
public final class ViewRootImpl implements ViewParent,
        View.AttachInfo.Callbacks, ThreadedRenderer.DrawCallbacks {
        
    public ViewRootImpl(Context context, Display display) {
        mContext = context;
        mWindowSession = WindowManagerGlobal.getWindowSession();
        mDisplay = display;
        mBasePackageName = context.getBasePackageName();
        mThread = Thread.currentThread();
        mLocation = new WindowLeaked(null);
        mLocation.fillInStackTrace();
        mWidth = -1;
        mHeight = -1;
        mDirty = new Rect();
        mTempRect = new Rect();
        mVisRect = new Rect();
        mWinFrame = new Rect();
        mWindow = new W(this);
        mTargetSdkVersion = context.getApplicationInfo().targetSdkVersion;
        mViewVisibility = View.GONE;
        mTransparentRegion = new Region();
        mPreviousTransparentRegion = new Region();
        mFirst = true; // true for the first time the view is added
        mAdded = false;
        //创建AttachInfo对象,倒数第二个参数就是View的playSoundEffect方法所用到的回调对象
        mAttachInfo = new View.AttachInfo(mWindowSession, mWindow, display, this, mHandler, this, context);
		...代码省略...
    }
}

3、看完ViewRootImpl的构造方法,再来看下AttachInfo的构造方法。

java 复制代码
public class View implements Drawable.Callback, KeyEvent.Callback,
        AccessibilityEventSource {
        
    final static class AttachInfo {

		//关键回调接口
        interface Callbacks {
        	//播放音效
            void playSoundEffect(int effectId);
            boolean performHapticFeedback(int effectId, boolean always);
        }

        final Callbacks mRootCallbacks;

        AttachInfo(IWindowSession session, IWindow window, Display display,
                ViewRootImpl viewRootImpl, Handler handler, Callbacks effectPlayer,
                Context context) {
            mSession = session;
            mWindow = window;
            mWindowToken = window.asBinder();
            mDisplay = display;
            mViewRootImpl = viewRootImpl;
            mHandler = handler;
            mRootCallbacks = effectPlayer;//View的playSoundEffect方法所用到的回调对象就是这个
            mTreeObserver = new ViewTreeObserver(context);
        }
    }
 }

AttachInfo构造方法的最后一个参数很关键,因为View的playSoundEffect方法所调用的对象就是这个,结合ViewRootImpl的代码我们可以知道是ViewRootImpl实现了这个回调。

4、ViewRootImpl的playSoundEffect方法如下所示。

java 复制代码
public final class ViewRootImpl implements ViewParent, View.AttachInfo.Callbacks, ThreadedRenderer.DrawCallbacks {
        
    public void playSoundEffect(int effectId) {
        checkThread();//检测是否是UI线程
        try {
            final AudioManager audioManager = getAudioManager();

            switch (effectId) {
                case SoundEffectConstants.CLICK://播放按键点击音效
                    audioManager.playSoundEffect(AudioManager.FX_KEY_CLICK);
                    return;
                case SoundEffectConstants.NAVIGATION_DOWN:
                    audioManager.playSoundEffect(AudioManager.FX_FOCUS_NAVIGATION_DOWN);
                    return;
                case SoundEffectConstants.NAVIGATION_LEFT:
                    audioManager.playSoundEffect(AudioManager.FX_FOCUS_NAVIGATION_LEFT);
                    return;
                case SoundEffectConstants.NAVIGATION_RIGHT:
                    audioManager.playSoundEffect(AudioManager.FX_FOCUS_NAVIGATION_RIGHT);
                    return;
                case SoundEffectConstants.NAVIGATION_UP:
                    audioManager.playSoundEffect(AudioManager.FX_FOCUS_NAVIGATION_UP);
                    return;
                default:
                    throw new IllegalArgumentException("unknown effect id " + effectId +
                            " not defined in " + SoundEffectConstants.class.getCanonicalName());
            }
        } catch (IllegalStateException e) {
            // Exception thrown by getAudioManager() when mView is null
            Log.e(mTag, "FATAL EXCEPTION when attempting to play sound effect: " + e);
            e.printStackTrace();
        }
    }
}

ViewRootImpl的playSoundEffect方法首先会检测一下当前线程是不是UI线程,然后会根据传入的effectId类型来判断要播放那种音效。因为View的performClick方法传入的是SoundEffectConstants.CLICK,所以会触发audioManager.playSoundEffect(AudioManager.FX_KEY_CLICK)。

4、AudioManager的playSoundEffect方法如下所示。

java 复制代码
public class AudioManager {
    public void  playSoundEffect(int effectType) {
        //检测音效类型是否合规
        if (effectType < 0 || effectType >= NUM_SOUND_EFFECTS) {
            return;
        }
        //确定音效是否可用
        if (!querySoundEffectsEnabled(Process.myUserHandle().getIdentifier())) {
            return;
        }
        //获取AudioService服务
        final IAudioService service = getService();
        try {
            //调用服务的playSoundEffect方法
            service.playSoundEffect(effectType);
        } catch (RemoteException e) {
            throw e.rethrowFromSystemServer();
        }
    }
    
    /**
     * Settings has an in memory cache, so this is fast.
     */
    private boolean querySoundEffectsEnabled(int user) {
        return Settings.System.getIntForUser(getContext().getContentResolver(),
                Settings.System.SOUND_EFFECTS_ENABLED, 0, user) != 0;
    }
}

AudioManager的playSoundEffect会做一些校验,如果校验通过则会获取AudioService服务对象,并调用该对象的playSoundEffect方法进行音效播放。

5、AudioService和playSoundEffect相关的代码如下所示。

java 复制代码
public class AudioService extends IAudioService.Stub
        implements AccessibilityManager.TouchExplorationStateChangeListener,
            AccessibilityManager.AccessibilityServicesStateChangeListener {
            
    /**
     * 播放音效
     * @param effectType
     */
    public void playSoundEffect(int effectType) {
        playSoundEffectVolume(effectType, -1.0f);
    }

    public void playSoundEffectVolume(int effectType, float volume) {
        // do not try to play the sound effect if the system stream is muted
        if (isStreamMutedByRingerOrZenMode(STREAM_SYSTEM)) {
            return;
        }

        if (effectType >= AudioManager.NUM_SOUND_EFFECTS || effectType < 0) {
            Log.w(TAG, "AudioService effectType value " + effectType + " out of range");
            return;
        }
		//发送播放音效的消息给mAudioHandler
        sendMsg(mAudioHandler, MSG_PLAY_SOUND_EFFECT, SENDMSG_QUEUE,
                effectType, (int) (volume * 1000), null, 0);
    }
    
    private class AudioHandler extends Handler {
    		//加载音效
            private boolean onLoadSoundEffects() {
            	...代码暂时省略...
            }
            @Override
        	public void handleMessage(Message msg) {
            switch (msg.what) {
            	...代码省略...
            	 case MSG_UNLOAD_SOUND_EFFECTS:
                    onUnloadSoundEffects();//不加载音效
                    break;
            	 case MSG_LOAD_SOUND_EFFECTS://加载音效
	               boolean loaded = onLoadSoundEffects();//调用onLoadSoundEffects加载音效,并将加载结果赋值给loaded
            		...代码省略...
                    break;
                case MSG_PLAY_SOUND_EFFECT://播放音效
                    onPlaySoundEffect(msg.arg1, msg.arg2);
                    break;
              	...代码省略...
            }
    }
}

AudioService的playSoundEffect方法进一步调用playSoundEffectVolume,该方法会发送播放音效的消息MSG_PLAY_SOUND_EFFECT给mAudioHandler,最终会触发onPlaySoundEffect方法。

6、AudioService的onPlaySoundEffec方法如下所示。

java 复制代码
public class AudioService extends IAudioService.Stub
        implements AccessibilityManager.TouchExplorationStateChangeListener,
            AccessibilityManager.AccessibilityServicesStateChangeListener {
            
        private void onPlaySoundEffect(int effectType, int volume) {
            synchronized (mSoundEffectsLock) {

                onLoadSoundEffects();//加载音效

                if (mSoundPool == null) {
                    return;
                }
                float volFloat;
                // use default if volume is not specified by caller
                if (volume < 0) {
                    volFloat = (float)Math.pow(10, (float)sSoundEffectVolumeDb/20);
                } else {
                    volFloat = volume / 1000.0f;
                }

                if (SOUND_EFFECT_FILES_MAP[effectType][1] > 0) {
                	//通过SoundPool播放音效
                    mSoundPool.play(SOUND_EFFECT_FILES_MAP[effectType][1],
                                        volFloat, volFloat, 0, 0, 1.0f);
                } else {
                    //通过MediaPlayer播放音效
                    MediaPlayer mediaPlayer = new MediaPlayer();
                    try {
                        String filePath = getSoundEffectFilePath(effectType);
                        mediaPlayer.setDataSource(filePath);
                        mediaPlayer.setAudioStreamType(AudioSystem.STREAM_SYSTEM);
                        mediaPlayer.prepare();
                        mediaPlayer.setVolume(volFloat);
                        mediaPlayer.setOnCompletionListener(new OnCompletionListener() {
                            public void onCompletion(MediaPlayer mp) {
                                cleanupPlayer(mp);
                            }
                        });
                        mediaPlayer.setOnErrorListener(new OnErrorListener() {
                            public boolean onError(MediaPlayer mp, int what, int extra) {
                                cleanupPlayer(mp);
                                return true;
                            }
                        });
                        mediaPlayer.start();
                    } catch (IOException ex) {
                        Log.w(TAG, "MediaPlayer IOException: "+ex);
                    } catch (IllegalArgumentException ex) {
                        Log.w(TAG, "MediaPlayer IllegalArgumentException: "+ex);
                    } catch (IllegalStateException ex) {
                        Log.w(TAG, "MediaPlayer IllegalStateException: "+ex);
                    }
                }
            }
        }
   }

7、简单回顾一下以上步骤。

相关推荐
selt7911 小时前
Redisson之RedissonLock源码完全解析
android·java·javascript
Yao_YongChao2 小时前
Android MVI处理副作用(Side Effect)
android·mvi·mvi副作用
非凡ghost3 小时前
JRiver Media Center(媒体管理软件)
android·学习·智能手机·媒体·软件需求
席卷全城3 小时前
Android 推箱子实现(引流文章)
android
齊家治國平天下3 小时前
Android 14 系统中 Tombstone 深度分析与解决指南
android·crash·系统服务·tombstone·android 14
唯创知音5 小时前
WT2605A录音方案实现高保真音频采集本地存储云端同步!
网络·音视频·录音芯片·录音方案·录音上传
ACP广源盛139246256735 小时前
GSV6155@ACP#6155产品规格详解及产品应用分享
嵌入式硬件·计算机外设·音视频
maycho1235 小时前
MATLAB环境下基于双向长短时记忆网络的时间序列预测探索
android
思成不止于此5 小时前
【MySQL 零基础入门】MySQL 函数精讲(二):日期函数与流程控制函数篇
android·数据库·笔记·sql·学习·mysql
brave_zhao6 小时前
达梦数据库(DM8)支持全文索引功能,但并不直接兼容 MySQL 的 FULLTEXT 索引语法
android·adb