Android 解讀開源專案UniversalMusicPlayer(播放控制層)

Anlia發表於2018-03-27

版權宣告:本文為博主原創文章,未經博主允許不得轉載
原始碼:AnliaLee/android-UniversalMusicPlayer
大家要是看到有錯誤的地方或者有啥好的建議,歡迎留言評論

前言

由於工作的原因,好久沒更新部落格了,之前說要寫UniversalMusicPlayer(後面統一簡稱UAMP)的原始碼分析,雖然程式碼中許多關鍵的地方都已經寫好了註釋,同時為了方便大家閱讀也把Google原有的一些註釋翻譯了,但一直抽不出太多時間去寫部落格,只能是像擠牙膏似的每天抽一個小模組出來分析的樣子_(:з」∠)_。所以如果有急需這個專案資料的童鞋可以關注一下我fork那個專案,一般我都會先在那寫好註釋然後再整理成部落格,大家通過註釋應該也可以將專案理清,就不需要再等我的龜速更新了~

回到專案中來,我打算按照UAMP專案各個大模組的劃分來寫,因此可能會寫好幾篇部落格湊成一個系列。這幾篇部落格沒有特定的順序,大家按需選擇某個模組來看就行。另外UAMP播放器是基於MediaSession框架的,相關資料可參考Android 媒體播放框架MediaSession分析與實踐,下面就開始正文吧

參考資料
googlesamples/android-UniversalMusicPlayer


專案簡介

UAMP播放器作為Google的官方demo展示瞭如何去開發一款音訊媒體應用,該應用可跨多種外接裝置使用,併為Android手機,平板電腦,Android Auto,Android Wear,Android TV和Google Cast裝置提供一致的使用者體驗

Android 解讀開源專案UniversalMusicPlayer(播放控制層)

Android 解讀開源專案UniversalMusicPlayer(播放控制層)

Android 解讀開源專案UniversalMusicPlayer(播放控制層)

專案按照標準的MVC架構管理各個模組,模組結構如下圖所示

Android 解讀開源專案UniversalMusicPlayer(播放控制層)

其中modeluiplayback模組分別代表MVC架構中的model層、view層以及controller層。此外,UAMP專案中深度使用了MediaSession框架實現了資料管理、播放控制、UI更新等功能,本系列部落格將從各個模組入手,分析其原始碼及重要功能的實現邏輯,這期主要講的是播放控制這塊的內容


播放控制模組

在分析MediaSession框架的部落格中我們講到在客戶端使用MediaController傳送指令,然後呼叫MediaBrowserService中重寫的回撥介面控制播放器進行播放的工作,這樣就實現了從使用者操作介面到控制音訊播放的過程。分析這個過程我們可以得知播放器是執行在Service層的,而為了將Service層和控制層進行解耦,UAMP專案中將播放器的控制邏輯放到了Playback的例項中,然後使用PlaybackManager作為中間者管理ServiceMediaSession以及Playback之間的互動。它們之間的關聯與互動主要是通過各個回撥方法來完成的:

MediaBrowserService與PlaybackManager的關聯

  • PlaybackManager中定義回撥介面PlaybackServiceCallbackMusicService(繼承自MediaBrowserService)實現了介面中的方法,同時也持有PlaybackManager的例項
//PlaybackManager.java
public interface PlaybackServiceCallback {
  void onPlaybackStart();
  void onNotificationRequired();
  void onPlaybackStop();
  void onPlaybackStateUpdated(PlaybackStateCompat newState);
}
複製程式碼
//MusicService.java
public class MusicService extends MediaBrowserServiceCompat implements PlaybackManager.PlaybackServiceCallback
複製程式碼
  • PlaybackManager的構造方法中需要傳入實現了PlaybackServiceCallback的例項,因此在MusicService中會將自身作為引數構造PlaybackManager例項,此時MusicServicePlaybackManager之間完成了關聯,可以相互呼叫回撥方法用以傳達指令狀態
//PlaybackManager.java
public PlaybackManager(PlaybackServiceCallback serviceCallback, Resources resources,
                       MusicProvider musicProvider, QueueManager queueManager,
                       Playback playback) {
    ...
    mServiceCallback = serviceCallback;
}
複製程式碼
//MusicService.java
private PlaybackManager mPlaybackManager;

@Override
public void onCreate() {
    ...
    mPlaybackManager = new PlaybackManager(this, getResources(), mMusicProvider, queueManager,playback);
}
複製程式碼

PlaybackManager與MediaSession的關聯

  • PlaybackManager中實現了MediaSession的回撥MediaSessionCallback,在MusicService配置MediaSession時可以用PlaybackManager.getMediaSessionCallback拿到這個回撥,然後呼叫MediaSession.setCallback傳入回撥。此時PlaybackManagerMediaSession之間完成了關聯,後續使用MediaController傳送指令時,指令通過上述回撥最終會傳達至PlaybackManager
//PlaybackManager.java
private MediaSessionCallback mMediaSessionCallback;
public PlaybackManager(PlaybackServiceCallback serviceCallback, Resources resources,
                       MusicProvider musicProvider, QueueManager queueManager,
                       Playback playback) {
    ...
    mMediaSessionCallback = new MediaSessionCallback();
}

public MediaSessionCompat.Callback getMediaSessionCallback() {
    return mMediaSessionCallback;
}

private class MediaSessionCallback extends MediaSessionCompat.Callback {
    ...
}
複製程式碼
//MusicService.java
@Override
public void onCreate() {
    mSession.setCallback(mPlaybackManager.getMediaSessionCallback());
}
複製程式碼

PlaybackManager與Playback的關聯

  • Playback中定義了回撥介面CallbackPlaybackManager實現了這個介面中的方法,同時持有Playback的例項(Playback本身也是介面,所以此處持有的是Playback的例項,預設為LocalPlayback,其作為引數在MusicService構造PlaybackManager例項時傳入)
//Playback.java
public interface Playback {
    ...
    interface Callback {
        /**
         * 當前音樂播放完成時呼叫
         */
        void onCompletion();
        /**
         * 在播放狀態改變時呼叫
         * 啟用該回撥方法可以更新MediaSession上的播放狀態
         */
        void onPlaybackStatusChanged(int state);

        /**
         * @param error to be added to the PlaybackState
         */
        void onError(String error);

        /**
         * @param mediaId being currently played
         */
        void setCurrentMediaId(String mediaId);
    }
}
複製程式碼
//PlaybackManager.java
public class PlaybackManager implements Playback.Callback
複製程式碼
//LocalPlayback.java
public final class LocalPlayback implements Playback
複製程式碼
//MusicService.java
@Override
public void onCreate() {
    ...
    LocalPlayback playback = new LocalPlayback(this, mMusicProvider);
    mPlaybackManager = new PlaybackManager(this, getResources(), mMusicProvider, queueManager, playback);
}
複製程式碼
  • PlaybackManager的構造方法中拿到Playback的例項後,呼叫Playback.setCallback將自身作為引數傳入,此時PlaybackManagerPlayback之間完成了關聯,可以相互呼叫回撥方法用以傳達指令狀態
//Playback.java
public interface Playback {
    ...
    void setCallback(Callback callback);
}
複製程式碼
//PlaybackManager.java
public PlaybackManager(PlaybackServiceCallback serviceCallback, Resources resources,
                       MusicProvider musicProvider, QueueManager queueManager,
                       Playback playback) {
    ...
    mPlayback.setCallback(this);
}
複製程式碼

簡單總結一下,UAMP播放控制流程可以分為指令下發狀態回傳兩個過程:

  • 指令下發可以理解為從客戶端UI層Playback層每一層通過呼叫下一層的例項的方法控制指令一直傳達到播放器,從而達到UI元件控制播放器播放音樂的功能
  • 狀態回傳則是指下層通過上層實現的回撥播放狀態一路回傳到UI層中,用以更新UI元件的顯示

瞭解播放控制流程的設計思路之後,下面我們開始分析一些具體功能的實現


與播放器的互動

前面我們提到播放器的具體實現是放在Playback層的,那麼就先看看Playback類提供了哪些介面

public interface Playback {
    /**
     * Start/setup the playback.
     * Resources/listeners would be allocated by implementations.
     */
    void start();

    /**
     * Stop the playback. All resources can be de-allocated by implementations here.
     * @param notifyListeners if true and a callback has been set by setCallback,
     *                        callback.onPlaybackStatusChanged will be called after changing
     *                        the state.
     */
    void stop(boolean notifyListeners);

    /**
     * Set the latest playback state as determined by the caller.
     */
    void setState(int state);

    /**
     * Get the current {@link android.media.session.PlaybackState#getState()}
     */
    int getState();

    /**
     * @return boolean that indicates that this is ready to be used.
     */
    boolean isConnected();

    /**
     * @return boolean indicating whether the player is playing or is supposed to be
     * playing when we gain audio focus.
     */
    boolean isPlaying();

    /**
     * @return pos if currently playing an item
     */
    long getCurrentStreamPosition();

    /**
     * Queries the underlying stream and update the internal last known stream position.
     */
    void updateLastKnownStreamPosition();
    void play(QueueItem item);
    void pause();
    void seekTo(long position);
    void setCurrentMediaId(String mediaId);
    String getCurrentMediaId();
    void setCallback(Callback callback);
}
複製程式碼

UAMP通過指令下發的流程將使用者點選UI控制元件所傳送的指令一路傳遞到Playback的方法中,以點選播放按鈕為例,播放指令傳遞過程中呼叫的方法順序大致如下:

OnClickListener.onClick → MediaController.getTransportControls().play
→ MediaSession.Callback.onPlay → Playback.play
複製程式碼

Playback類的具體實現是LocalPlayback,那麼我們看下LocalPlayback.play方法都做了些什麼

//LocalPlayback.java
@Override
public void play(QueueItem item) {
    mPlayOnFocusGain = true;
    ...
    if (mExoPlayer == null) {
        mExoPlayer =
                ExoPlayerFactory.newSimpleInstance(
                        mContext, new DefaultTrackSelector(), new DefaultLoadControl());
        mExoPlayer.addListener(mEventListener);
    }
    ...
    mExoPlayer.prepare(mediaSource);
    ...
    configurePlayerState();
}

private void configurePlayerState() {
    ...
    if (mPlayOnFocusGain) {
        mExoPlayer.setPlayWhenReady(true);
        mPlayOnFocusGain = false;
    }
}
複製程式碼

可以看到這裡初始化了ExoPlayer播放器,並呼叫ExoPlayer相應的方法播放音訊

那麼和播放器互動的分析就到這,至於ExoPlayer的操作就不細說了,大家可以對照著原始碼中的註釋以及ExoPlayer的文件理解其中的實現邏輯即可


耳機插拔的處理邏輯

當我們插著線控耳機或者連著藍芽耳機聽歌時,有時可能會突然發生意外的狀況,造成耳機與裝置斷連了(線被拔掉或者藍芽中斷了),為了在公共場合下避免不必要的尷尬,此時播放程式一般都會自動暫停音樂的播放。這個功能不是系統幫我們實現的,這需要我們自己完成相應邏輯的開發

Android系統中有著音訊輸出通道的概念,例如當我們使用線控耳機收聽音樂時,音樂是從Headset通道出來的,拔掉耳機後,音訊輸出的通道則會切換至Speaker通道,此時系統會發出AudioManager.ACTION_AUDIO_BECOMING_NOISY這一廣播告知我們耳機被拔掉了。UAMP中正是通過監聽此廣播實現了耳機插拔的邏輯處理,之前我們也提到了這功能是在LocalPlayback中實現的:

public final class LocalPlayback implements Playback {
    ...
    private boolean mAudioNoisyReceiverRegistered;
    private final IntentFilter mAudioNoisyIntentFilter =
            new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY);

    private final BroadcastReceiver mAudioNoisyReceiver =
            new BroadcastReceiver() {
                @Override
                public void onReceive(Context context, Intent intent) {
                    if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) {
                        LogHelper.d(TAG, "Headphones disconnected.");
                        //當音樂正在播放中,通知Service暫停播放音樂(在Service.onStartCommand中處理此命令)
                        if (isPlaying()) {
                            Intent i = new Intent(context, MusicService.class);
                            i.setAction(MusicService.ACTION_CMD);
                            i.putExtra(MusicService.CMD_NAME, MusicService.CMD_PAUSE);
                            mContext.startService(i);
                        }
                    }
                }
            };

    private void registerAudioNoisyReceiver() {
        //登出耳機插拔、藍芽耳機斷連的廣播接收者
        if (!mAudioNoisyReceiverRegistered) {
            mContext.registerReceiver(mAudioNoisyReceiver, mAudioNoisyIntentFilter);
            mAudioNoisyReceiverRegistered = true;
        }
    }

    private void unregisterAudioNoisyReceiver() {
        //登出耳機插拔的廣播接收者
        if (mAudioNoisyReceiverRegistered) {
            mContext.unregisterReceiver(mAudioNoisyReceiver);
            mAudioNoisyReceiverRegistered = false;
        }
    }
}
複製程式碼

有關接收到廣播後的操作已經在程式碼的註釋中說明,就不多贅述了。此外,為了防止記憶體洩漏,我們需要在適當的時機註冊和登出BroadcastReceiver,一般的邏輯就是開始播放音樂時註冊,暫停或停止播放時登出,LocalPlayback中同樣遵循著這一邏輯,具體的大家看下原始碼註冊和登出兩個方法什麼時候被呼叫就可以了


有關音訊焦點的控制

在分析原始碼之前,我們先簡單瞭解一下什麼是音訊焦點。在Android系統中,裝置所有發出的聲音統稱為音訊流,這其中包括應用播放的音樂按鍵聲通知鈴聲電話的聲音等等。由於Android是多工系統,那麼這些聲音就存在同時播放的可能,我們可能就會因為正在播放的音樂聲而錯過某些重要的提示音。系統雖然不會區分哪些聲音對我們來說是更重要的,但它提供了一套機制讓開發者可以自己處理多個音訊流同時播放的問題

Android 2.2之後引入了音訊焦點機制,各個應用可以通過這個機制協商各自音訊輸出的優先順序。這套機制提供了請求和放棄音訊焦點的方法,以及通知我們音訊焦點狀態改變的監聽器。當我們需要播放音訊時,就可以嘗試請求獲取音訊焦點繫結狀態監聽器。若有其他應用的音訊流突然插手競爭音訊焦點時,系統會根據這個插手的音訊流的型別通過監聽器通知我們音訊焦點狀態的改變。這個改變後的狀態其實也是系統對於如何處理當前播放音訊的一種建議,狀態型別如下:

  • AUDIOFOCUS_GAIN:得到音訊焦點時觸發的狀態,請求得到的音訊焦點一般會長期佔有
  • AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:失去音訊焦點時觸發的狀態,在該狀態的時候不需要暫停音訊,但是我們需要降低音訊的聲音
  • AUDIOFOCUS_LOSS_TRANSIENT:失去音訊焦點時觸發的狀態,但是該狀態不會長時間保持,此時我們應該暫停音訊,且當重新獲取音訊焦點的時候繼續播放
  • AUDIOFOCUS_LOSS:失去音訊焦點時觸發的狀態,且這個狀態有可能會長期保持,此時應當暫停音訊並釋放音訊相關的資源

瞭解這些概念之後,我們來看下在UAMP專案中官方給出的有關音訊焦點的實現示例。有關音訊焦點的實現在LocalPlayback類中,首先是定義需要用到的常量音訊焦點狀態監聽器

public final class LocalPlayback implements Playback {
    ...
    //當音訊失去焦點,且不需要停止播放,只需要減小音量時,我們設定的媒體播放器音量大小
    //例如微信的提示音響起,我們只需要減小當前音樂的播放音量即可
    public static final float VOLUME_DUCK = 0.2f;
    //當我們獲取音訊焦點時設定的播放音量大小
    public static final float VOLUME_NORMAL = 1.0f;

    //沒有獲取到音訊焦點,也不允許duck狀態
    private static final int AUDIO_NO_FOCUS_NO_DUCK = 0;
    //沒有獲取到音訊焦點,但允許duck狀態
    private static final int AUDIO_NO_FOCUS_CAN_DUCK = 1;
    //完全獲取音訊焦點
    private static final int AUDIO_FOCUSED = 2;
    private boolean mPlayOnFocusGain;
    //當前音訊焦點的狀態
    private int mCurrentAudioFocusState = AUDIO_NO_FOCUS_NO_DUCK;

    /**
     * 根據音訊焦點的設定重新配置播放器 以及 啟動/重新啟動 播放器。呼叫這個方法 啟動/重新啟動 播放器例項取決於當前音訊焦點的狀態。
     * 因此如果我們持有音訊焦點,則正常播放音訊;如果我們失去音訊焦點,播放器將暫停播放或者設定為低音量,這取決於當前焦點設定允許哪種設定
     */
    private void configurePlayerState() {
        LogHelper.d(TAG, "configurePlayerState. mCurrentAudioFocusState=", mCurrentAudioFocusState);
        if (mCurrentAudioFocusState == AUDIO_NO_FOCUS_NO_DUCK) {
            // We don't have audio focus and can't duck, so we have to pause
            pause();
        } else {
            registerAudioNoisyReceiver();

            if (mCurrentAudioFocusState == AUDIO_NO_FOCUS_CAN_DUCK) {
                // We're permitted to play, but only if we 'duck', ie: play softly
                mExoPlayer.setVolume(VOLUME_DUCK);
            } else {
                mExoPlayer.setVolume(VOLUME_NORMAL);
            }

            // If we were playing when we lost focus, we need to resume playing.
            if (mPlayOnFocusGain) {
                //播放的過程中因失去焦點而暫停播放,短暫暫停之後仍需要繼續播放時會進入這裡執行相應的操作
                mExoPlayer.setPlayWhenReady(true);
                mPlayOnFocusGain = false;
            }
        }
    }

    /**
     * 請求音訊焦點成功之後監聽其狀態的Listener
     */
    private final AudioManager.OnAudioFocusChangeListener mOnAudioFocusChangeListener =
            new AudioManager.OnAudioFocusChangeListener() {
                @Override
                public void onAudioFocusChange(int focusChange) {
                    LogHelper.d(TAG, "onAudioFocusChange. focusChange=", focusChange);
                    switch (focusChange) {
                        case AudioManager.AUDIOFOCUS_GAIN:
                            mCurrentAudioFocusState = AUDIO_FOCUSED;
                            break;
                        case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
                            // Audio focus was lost, but it's possible to duck (i.e.: play quietly)
                            mCurrentAudioFocusState = AUDIO_NO_FOCUS_CAN_DUCK;
                            break;
                        case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
                            // Lost audio focus, but will gain it back (shortly), so note whether
                            // playback should resume
                            mCurrentAudioFocusState = AUDIO_NO_FOCUS_NO_DUCK;
                            mPlayOnFocusGain = mExoPlayer != null && mExoPlayer.getPlayWhenReady();
                            break;
                        case AudioManager.AUDIOFOCUS_LOSS:
                            // Lost audio focus, probably "permanently"
                            mCurrentAudioFocusState = AUDIO_NO_FOCUS_NO_DUCK;
                            break;
                    }

                    if (mExoPlayer != null) {
                        // Update the player state based on the change
                        configurePlayerState();
                    }
                }
            };
}
複製程式碼

接著定義請求與放棄音訊焦點的方法

public final class LocalPlayback implements Playback {
    ...
    /**
     * 嘗試獲取音訊焦點
     * requestAudioFocus(OnAudioFocusChangeListener l, int streamType, int durationHint)
     * OnAudioFocusChangeListener l:音訊焦點狀態監聽器
     * int streamType:請求焦點的音訊型別
     * int durationHint:請求焦點音訊持續性的指示
     *      AUDIOFOCUS_GAIN:指示申請得到的音訊焦點不知道會持續多久,一般是長期佔有
     *      AUDIOFOCUS_GAIN_TRANSIENT:指示要申請的音訊焦點是暫時性的,會很快用完釋放的
     *      AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK:指示要申請的音訊焦點是暫時性的,同時還指示當前正在使用焦點的音訊可以繼續播放,只是要“duck”一下(降低音量)
     */
    private void tryToGetAudioFocus() {
        LogHelper.d(TAG, "tryToGetAudioFocus");
        int result =
                mAudioManager.requestAudioFocus(
                        mOnAudioFocusChangeListener,//狀態監聽器
                        AudioManager.STREAM_MUSIC,//
                        AudioManager.AUDIOFOCUS_GAIN);
        if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
            mCurrentAudioFocusState = AUDIO_FOCUSED;
        } else {
            mCurrentAudioFocusState = AUDIO_NO_FOCUS_NO_DUCK;
        }
    }

    /**
     * 放棄音訊焦點
     */
    private void giveUpAudioFocus() {
        LogHelper.d(TAG, "giveUpAudioFocus");
        //申請放棄音訊焦點
        if (mAudioManager.abandonAudioFocus(mOnAudioFocusChangeListener)
                == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
            //AudioManager.AUDIOFOCUS_REQUEST_GRANTED 申請成功
            mCurrentAudioFocusState = AUDIO_NO_FOCUS_NO_DUCK;
        }
    }
}
複製程式碼

那麼何時該請求(放棄)音訊焦點呢?舉個例子,播放器開始播放(停止)音樂時,就需要請求(放棄)焦點了

public final class LocalPlayback implements Playback {
    ...
    @Override
    public void stop(boolean notifyListeners) {
        giveUpAudioFocus();//放棄音訊焦點
        ...
    }

    @Override
    public void play(QueueItem item) {
        mPlayOnFocusGain = true;
        tryToGetAudioFocus();
        ...
        configurePlayerState();
    }
}
複製程式碼

播放佇列控制

之前我們講到了UAMP專案中資料層播放控制層是分離開來的,且正如使用PlaybackManager作為中間者管理播放器,這裡同樣使用了QueueManager這個類作為中間者連通資料層播放控制層Service層,並提供了佇列形式的儲存容器(這個佇列是執行緒安全的)以及可以管理音樂層級關係的方法

那麼首先來看看如何初始化QueueManagerQueueManager中提供了一個對外的回撥介面,重寫介面中的方法即可在QueueManager中操作外面的方法

public class QueueManager {
    ...
    /**
     * @param musicProvider 資料來源提供者
     * @param resources 系統資源
     * @param listener 播放資料更新的回撥介面
     */
    public QueueManager(@NonNull MusicProvider musicProvider,
                        @NonNull Resources resources,
                        @NonNull MetadataUpdateListener listener) {
        ...
    }
    
    public interface MetadataUpdateListener {
        void onMetadataChanged(MediaMetadataCompat metadata);//媒體資料變更時呼叫
        void onMetadataRetrieveError();//媒體資料檢索失敗時呼叫
        void onCurrentQueueIndexUpdated(int queueIndex);//當前播放索引變更時呼叫
        void onQueueUpdated(String title, List<MediaSessionCompat.QueueItem> newQueue);//當前播放佇列變更時呼叫
    }
}
複製程式碼

重寫這些回撥方法是在MusicService建立時完成的,細心的小夥伴應該發現了之前在Service中就有將建立好的QueueManager作為引數構造PlaybackManager類,我們來看原始碼

public class MusicService extends MediaBrowserServiceCompat implements
       PlaybackManager.PlaybackServiceCallback {
   ...
   @Override
   public void onCreate() {
       ...          
       QueueManager queueManager = new QueueManager(mMusicProvider, getResources(),
               new QueueManager.MetadataUpdateListener() {
                   @Override
                   public void onMetadataChanged(MediaMetadataCompat metadata) {
                       mSession.setMetadata(metadata);
                   }

                   @Override
                   public void onMetadataRetrieveError() {
                       mPlaybackManager.updatePlaybackState(
                               getString(R.string.error_no_metadata));
                   }

                   @Override
                   public void onCurrentQueueIndexUpdated(int queueIndex) {
                       mPlaybackManager.handlePlayRequest();
                   }

                   @Override
                   public void onQueueUpdated(String title,
                                              List<MediaSessionCompat.QueueItem> newQueue) {
                       mSession.setQueue(newQueue);
                       mSession.setQueueTitle(title);
                   }
               });
       mPlaybackManager = new PlaybackManager(this, getResources(), mMusicProvider, queueManager, playback);
   }
}
複製程式碼

此時播放控制層就成功連上了QueueManager,之後就可以呼叫其提供的方法找到當前要播放的音訊了,具體的大家可以參照博主在原始碼中的註釋,這裡就不一一拿出來細講了

這篇部落格就先到這了,如果這個模組還有什麼需要補充的,我會直接在這進行更新。若有什麼遺漏或者建議的歡迎留言評論,如果覺得博主寫得還不錯麻煩點個贊,你們的支援是我最大的動力~

相關文章