NO IMAGE

開源一個功能相對齊全的本地音樂播放器

簡述

從五月末就開始利用空餘時間開發這款 app ,不知不覺三個月過去了。

App 名稱:我的音樂,我給取了個別名:Musicoco

Android 手機本地音樂檔案播放器。應用開啟了單獨的播放服務程序,在服務程序中控制音樂播放邏輯,目前已實現功能如下:通過耳機和通知欄快捷控制音樂播放、建立歌單、本地歌曲搜尋、歌曲多選操作、記憶播放、自動切換到夜間模式、定時停止播放、應用主題自定義以及播放介面風格選擇等功能。

應用截圖

功能結構圖

下圖為應用的功能結構圖,圖有點繁雜,但把大部分功能都列了出來。
可進行如下歸類:
1. 歌曲播放控制:播放/暫停、上/下曲切換、播放進度調整。(可從應用內、通知欄和耳機進行控制)
2. 歌曲操作:收藏/取消收藏、加入歌單/從歌單中移除、徹底刪除、歌曲詳情(前三個功能可選中多首歌曲後進行批量操作)
3. 歌單操作:新建、編輯、刪除
4. 歌曲搜尋:全部歌曲中搜尋,特定歌單中搜尋
5. 外觀設定:播放介面風格、主題顏色、夜間/白天模式切換(自動切換)
6. 實用功能:定時停止播放、記憶播放、開啟應用自動播放、圖片牆
7. 額外功能:反饋、使用者指南、應用資訊、關於開發者、清除快取

開發過程中遇到的部分難點

  1. aidl 程序間通訊

通過在 AndroidManifest.xml 中為 Service 指定 process 屬性就可以使服務執行於獨立的程序中,應用中的服務為 PlayService ,應用啟動時會以 startService 的方式啟動服務,服務啟動後會恢復上次播放歌曲(歌曲及其播放進度,前提為開啟記憶播放),之後Activity再進行繫結(bindService),客戶端繫結服務之後主動獲取服務端的當前歌曲並同步歌曲資訊和播放狀態,之後每一次播放歌曲改變、播放狀態改變以及歌單改變服務端都會對繫結的客戶端進行通知( 通過服務繫結者註冊監聽實現)。

問題出現在Activity第一次同步的時候,因為服務啟動過程中有一個耗時操作(通過 ContentResolver 獲得裝置上的歌曲資訊),之後進行當前歌曲恢復,完成之後會立即回撥songChanged(服務端當前歌曲改變時回撥的方法),這個過程是在 Service 的 onCreate 中完成的,這時 Activity 肯定不能而且也不能夠成功繫結服務(因為 Service 還在 onCreate ,ServiceConnection 的 onServiceConnected 也不會回撥,也就無法通過 IBinder 註冊監聽),但我卻只在songChanged方法中等待回撥以進行初始同步,卻沒意識到這個回撥已經發生了,而此時服務正在啟動,且此時不可能有任何客戶端繫結到服務;這個錯誤的解決方法是在 Activity 成功繫結到服務後手動獲取當前歌曲及播放狀態進行同步。

  1. 完全停止服務

PlayService服務會被兩個 Activity 繫結,一個是主介面的 Activity(MainActivity),另一個為播放介面的 Activity(PlayActivity),停止時需要先讓兩個 Activity 解綁服務,之後讓服務呼叫 stopSelf (通過傳送廣播實現)。

服務停止時需要釋放 MediaPlayer;當時的問題是這樣的,點選【退出】關閉應用,之後再次啟動時,服務的 MediaPlayer 的 reset 方法丟擲 IllegalStateException異常,這顯然是因為 MediaPlayer 沒有 release 導致的。我在應用【退出】操作的處理方法中是這樣處理的,呼叫 Activity 的 finish 方法,此外在 MainActivityPlayActivityonDestory方法中解綁服務,MainActivityonDestory 最後還會傳送廣播通知服務 stopSelf,但這個異常還是一直丟擲。除錯好久之後終於找到錯誤,PlayActivity我設定了singleInstance,這意味著PlayActivity在單獨的 activity棧 中,其他的 Activity 大都設定了 singleTask,而且能保證以【退出】按鈕退出應用時 MainActivity 在其 activity棧中位於棧底,我天真的以為棧底 Activity 銷燬時應用開啟的 Activity 都會關閉,然而並沒有, PlayACtivity 沒有銷燬更沒有解綁服務,這導致 PlayService不能停止,MediaPlayer 的 release 也沒有呼叫。解決方法就是手動呼叫 PlayActivityfinish

  1. 沉浸式狀態列

實現沉浸式狀態列的程式碼如下:

 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
Window window = activity.getWindow();
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS
| WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
window.setStatusBarColor(Color.TRANSPARENT);
}

這部分程式碼能夠讓狀態列透明,在佈局中配合android:fitsSystemWindows="true"就可以實現沉浸式狀態列,但當佈局中有NavigationView時就出現了問題,當NavigationView開啟時狀態列背景會變成半透明的黑色,而我期望的是完全透明的,半透明時的截圖如下:

之後我發現將android:fitsSystemWindows置為 false 時狀態列會全透明,但標題欄會延伸到狀態列:

最後解決方法是置android:fitsSystemWindows為 false,同時為 Toolbar (佈局中標題欄使用的是 DrawerLayout 嵌 CoordinatorLayout 嵌 AppBarLayout 嵌 CollapsingToolbarLayout 嵌 Toolbar 結構)加上狀態列高度的 padding。

程式碼如下:

...
toolbar = (Toolbar) findViewById(R.id.activity_main_toolbar);
toolbar.post(new Runnable() {
@Override
public void run() {
// fitsSystemWindows 為 false ,這裡要增加 padding 填滿狀態列
toolbar.setPadding(0, Utils.getStatusBarHeight(MainActivity.this), 0, 0);
...
}
});
setSupportActionBar(toolbar);
...

Utils.getStatusBarHeight(Activity activity)方法用於獲得狀態列高度:

public static int getStatusBarHeight(Activity activity) {
Rect frame = new Rect();
activity.getWindow().getDecorView().getWindowVisibleDisplayFrame(frame);
int statusBarHeight = frame.top;
if (statusBarHeight <= 0) { // 有時會獲取失敗
int resourceId = activity.getResources().getIdentifier("status_bar_height", "dimen", "android");
if (resourceId > 0) {
//根據資源ID獲取響應的尺寸值
statusBarHeight = activity.getResources().getDimensionPixelSize(resourceId);
}
}
if (statusBarHeight <= 0) {
statusBarHeight = 63;
}
return statusBarHeight;
}
  1. SQLite 資料庫事務和單例

資料庫事務:
應用的資料庫使用的是 SQLite,在對資料庫進行操作時用到了事務,由於對資料庫事務的不熟悉,導致寫出瞭如下的程式碼:

            database.beginTransaction();
...
database.endTransaction();

在結束事務之前如果沒有呼叫setTransactionSuccessful標記事務成功,則操作會回滾,當時不知道還有這個操作,導致資料庫操作始終被回滾,沒有提交。所以提交事務之前要記得標記事務成功。

            database.beginTransaction();
...
database.setTransactionSuccessful();
database.endTransaction();

資料庫操作類單例:
運算元據庫的類使用了單例模式,持有 ApplicationContext,在用完資料庫後呼叫 close 關閉資料庫連線。單例的好處在於只存在一個物件,與 Application 共存亡,資料庫操作類在構造方法中通過 SQLiteOpenHelper獲得資料庫連線,單例導致SQLiteOpenHelpergetWritableDatabasegetReadableDatabase只會呼叫一次,資料庫操作類內的SQLiteDatabase物件也始終為同一個,即使在呼叫了SQLiteDatabase的 close 關閉了資料庫連線,下次再次獲得單例時SQLiteDatabase物件仍然是同一個,而且現在已經被關閉了,此時再使用它運算元據庫就會得到異常:java.lang.IllegalStateException: attempt to re-open an already-closed object: SQLiteDatabase: ....,解決辦法就是不使用單例 :-P。

  1. RecyclerView 被 NestedScrollView 巢狀時 RecyclerView 的複用

RecyclerViewNestedScrollView巢狀時其複用機制失效,原因在於 RecyclerView 的高度被計算為所有 item 的高度之和,RecyclerView 會在第一次載入時就將所有 item 都載入,這在 item 較多時會導致 RecyclerView 載入卡頓, 這個問題在應用中體現為歌單詳情介面中的歌曲列表,如下圖所示,列表項包含歌曲基本資訊及其專輯圖片,當歌單中包含歌曲較多(>=40)時,介面載入就會出現明顯示卡頓。

解決方法是手動為RecyclerView 指定高度,計算方法如下(參照後面的歌單詳情頁截圖更好理解)

    //計算 RecycleView 高度,否則無法複用 item
private void calculateRecycleViewHeight() {
ActionBar bar = ((AppCompatActivity) activity).getSupportActionBar();
if (bar != null) {
int actionH = bar.getHeight(); // actionBarHeight
int randomCH = randomContainer.getHeight(); // randomPlayContainerHeight
int statusH = Utils.getStatusBarHeight(activity); // statusBarHeight
int screenHeight = Utils.getMetrics(activity).heightPixels;
int height = screenHeight - actionH - statusH - randomCH;
ViewGroup.LayoutParams params = songList.getLayoutParams();
params.height = height;
songList.setLayoutParams(params);
}
}

暫時先總結這麼多吧!

用到的庫和開源自定義 View

  1. wasabeef / glide-transformations:圖片變換
  2. akexorcist / Android-RoundCornerProgressBar:可修改顏色的圓形載入條
  3. DuanJiaNing / MediaView:播放/暫停,上/下曲按鈕
  4. ReactiveX / RxJava
  5. ReactiveX/RxAndroid
  6. bumptech/glide
  7. AnderWeb/discreteSeekBar:進度條
  8. DuanJiaNing/ColorPicker:顏色選取器

部分功能尚未完善,還存在一些已知或未知的 bug,如果你想要改進客戶端,或者發現了問題,可以提交Issues,或者直接使用應用的反饋功能向我提交你的意見或建議;當然,你也可以基於該程式碼修改並開發出屬於自己的客戶端。

應用還不能線上下載(我釋出到應用寶了,可惜:稽核駁回,需提供 《資訊網路傳播視聽節目許可證》,What the hell is this?

如果你想安裝在自己手機上體驗可在 GitHub 倉庫 apk 目錄下找到 app-release.apk 檔案,或直接構建並執行專案。

GitHub 地址:DuanJiaNing/Musicoco


END