基於Flutter的雲音樂桌面版-音樂下載篇
先來說說背景吧。前段時間,我用Flutter
斷斷續續的開發了一款桌面版的網易雲音樂,成品還是比較滿意的。已完成的功能包括推薦音樂、私人FM、我喜歡的音樂、我的收藏、歌曲評論、我的下載等。當然了,有關音樂的核心功能播放下載也都完成了。
前文引導👇:
一、MP3+JSON
問題就出在下載這裡。當時對於音訊檔案下載的實現方式是通過MP3+JSON
的方式實現的。有人就要問了,為什麼要這麼實現?同時下載一個JSON檔案又是什麼鬼?別急,且聽我慢慢道來。
首先,我開發的DreamMusic專案播放音樂時使用的是外鏈,它長這樣"https://music.163.com/song/media/outer/url?id=$songId.mp3"
。至於為何不用歌曲詳情中的連結而直接使用外鏈,由於不是本文重點,我就不解釋了。我們還是回到播放和下載上。通過這個音樂URL,我們可以播放線上音樂,當然也可以通過這個URL直接下載MP3檔案。它是一個不包含任何媒體資訊的原始音訊檔案(這裡的媒體資訊指的是歌名,歌手,專輯資訊等)。
現在我們來想一下,在做音樂下載模組時需要做什麼?🤔
- 下載中進度展示。
- 下載完展示在列表上。
- 應用啟動時能讀取到音樂資訊並展示出來。
其中1和2都是在執行中進行的,因此,音樂資訊是能夠直接從操作的音樂模型中獲取到。那麼,3應該如何實現。我上面有提到,從URL中下載的音訊檔案是一個原始的MP3檔案,不包含其他任何媒體資訊。如果要實現應用啟動時展示已下載音樂列表,並能夠展示出歌曲封面,歌名,歌手,專輯等資訊,那麼我們在下載的時候還需要同時存取一份音樂的媒體資訊才行。因此才會有我第一版中提到的MP3+JSON
方式。
上圖中,那一串數字是雲音樂平臺的歌曲ID,其中的json檔案就儲存了歌曲的基本資訊。用於在應用啟動時載入歌曲資訊。流程就是先找到JSON檔案,讀取JSON資訊,轉成已下載音樂的模型。寫成程式碼就是下面這樣。
dart
String name =
directory.uri.pathSegments[directory.uri.pathSegments.length - 2];
final jsonFile = File("${directory.path}/$name.json");
final exist = await jsonFile.exists();
if (exist) {
final content = await jsonFile.readAsString();
final data = json.decode(content);
if (data is Map<String, dynamic>) {
final song = DownloadSongModel.fromJson(data);
return song;
}
} else {
debugPrint("[download]音樂[$name]json檔案沒有找到,刪掉對應資料夾內容");
await directory.delete(recursive: true);
}
那麼,這樣寫會有什麼問題?🤔
一個顯而易見的問題就是音訊檔案和媒體資訊分離了,不便管理。這裡可能有人就要說了,你這不是P話嗎,媒體資訊不分開放難道放MP3裡?誒~還真可以,那就是使用ID3,這個我後面會提到。我們還是繼續說MP3+JSON
這種方式。還有沒有其他問題?有的,比如使用者可以隨意單獨刪除JSON或MP3,或開啟JSON檔案,修改其中的資訊,導致音樂和媒體資訊不一致。其中隨意修改JSON資訊真是致命的。
那麼我在之前是如何處理上述問題的呢。我們接著往下看。
針對刪除檔案
場景如下,使用者開啟著應用,然後直接操作下載資料夾,單獨刪除了JSON,或MP3,或整個DreamMusic目錄。如果我們要做同步,那就需要監聽這些檔案的變動。Flutter
檔案系統為我們提供了這個方法:
dart
Stream<FileSystemEvent> watch(
{int events = FileSystemEvent.all, bool recursive = false})
這個方法會監聽檔案的事件FileSystemEvent
,並通過回撥的方式告訴我們。於是,我們可以很容易的寫出下列程式碼,加入檔案/資料夾刪除監聽。
dart
/// 監聽下載目錄的變化,主要看檔案有沒有減少
void _addFileDeleteObserverIfNeeded() async {
if (!FileSystemEntity.isWatchSupported) {
return;
}
if (hasDirectoryObserver) {
return;
}
hasDirectoryObserver = true;
final directory = Directory(fileCacheDirectorPath);
if (!directory.existsSync()) {
await directory.create();
}
final stream =
directory.watch(events: FileSystemEvent.delete, recursive: true);
stream.listen((event) async {
// debugPrint("[download]$event");
String path = event.path;
if (path == fileCacheDirectorPath) {
// 刪除了整個下載目錄
_downloadedSongModels.clear();
hasDirectoryObserver = false;
debugPrint("[download]刪除整個下載目錄");
} else {
// 刪除其中某個檔案,這會導致資訊不完整,因此直接全部刪除即可
final lastSegment = Uri(path: path).pathSegments.last;
final fileName = lastSegment.split('.').first;
final songId = int.tryParse(fileName);
if (songId != null) {
final path = "$fileCacheDirectorPath/$songId";
final dir = Directory(path);
final exist = await dir.exists();
if (exist) {
await dir.delete(recursive: true);
}
final key = SongDownloadTask.createTaskId(songId);
_downloadedSongModels.remove(key);
}
debugPrint("[download]刪除檔案$lastSegment,songId-$songId");
}
notifyListeners();
});
debugPrint("[download]開始監聽$fileCacheDirectorPath目錄的變化");
}
邏輯處理很簡單,如果使用者單單刪除了JSON或MP3,這就導致下載的音訊檔案不完整,於是,直接刪除整個音樂資料夾就好(這裡指的是上面提到的那一串歌曲ID的資料夾,不是最外層的DreamMusic目錄)。如果使用者是刪除了整個DreamMusic下載目錄,那麼不多說,全部刪除。
針對修改檔案
很抱歉,我沒做這個處理。因為我懶😄。其實是想出了更好的方式。那就是MP3+ID3
的方式。
當然,我還是可以提供下思路。原理還是使用上述提到的監聽檔案修改的方式,這裡我們監聽修改JSON檔案,一單檔案的內容經過修改,系統會回撥一個FileSystemModifyEvent
物件給我們,裡面有個屬性叫contentChanged
,我們判斷下內容是否真的變了,變了就刪掉,誰讓你亂改下載檔案的😄。當然最主要的原因是,檔案系統沒告訴我改了什麼,變更前和變更後又是什麼,實在不好判斷呀~
二、MP3+ID3
所以,我就放棄繼續在JSON
上轉牛角尖的想法,轉而思考是否可以將媒體資訊放入到音訊檔案內部。於是,順理成章的瞭解到了ID3
(真的是問題不可怕,它是前進的動力)。
ID3維基百科。不瞭解ID3
的可以先看看這是何物,有何作用。簡單來說,ID3就是存在於音訊檔案中用於存放媒體資訊的一段內容。它有自己的格式,目前流行的是ID3v2.3
和ID3v2.4
版本。
瞭解了ID3
基本的資訊後,我就去找對應的三方庫呀,看看有沒有現成的可以幫助我解決問題的id3解析庫存在。很快的,我就找到了一個排名靠前的ID3庫實現,id3。然後,我又分別實驗了下自己下載的mp3檔案和Mac版網易雲音樂下載的mp3檔案,裡面都有些什麼。發現果然有些東西。
下面是網易雲下載的歌曲讀取出來的ID3資訊:
json
{
Version: v2.3.0,
Settings: Lavf57.25.100,
TPOS: 1,
Track: 12, Artist: 大壯,
APIC: {mime: image/jpg, textEncoding: 0, picType: Other, description: , base64: iVBORw0K...},
Title: 為你我受冷風吹,
Album: 大壯首張限量定製翻唱
}
而我自己下載的歌曲檔案的ID3中沒有任何媒體資訊:
json
{
Version: v2.3.0,
Settings: Lavf57.71.100
}
其實,在Mac上,我們平時快捷預覽MP3檔案時也會出現一些媒體資訊,而這些資訊就是mac桌面系統通過讀取ID3
顯示出來的。
這下,我們終於知道要將媒體資訊存到哪裡去了。那就是ID3
中。可問題來了,id3
這個三方庫它不支援編輯啊。先不說它有沒有bug,它不支援編輯啊。
於是,我查看了ID3有關v1,v2的所有版本的官方資訊。又在網上看了不少前輩的文章講解,心裡有了明悟,我為什麼不自己寫呢?
於是前後經歷一個月時間,一個支援ID3
解碼和編碼的id3_codec終於實現了🎉。並且還支援ID3所有版本(編碼這塊v2.2不做支援,因為基本沒人用)。
有關id3_codec實現可以看我下列文章:
有了ID3
的支援,我們就可以將媒體資訊存入MP3中了,這樣就解決了上面所有的問題。再也不擔心使用者亂改了(當然,如果有使用者用編碼器修改ID3資訊,那我服了)。
我們只需要將寫入JSON的邏輯改成寫入MP3原檔案即可。看程式碼:
dart
/// 將歌曲資訊寫入
void _writeSongInfoAsync(DownloadSongModel song) async {
if (_cacheMode == DownloadCacheMode.json) {
// 略
} else if (_cacheMode == DownloadCacheMode.id3) {
final path = _generateSongId3SavePath(song.name);
final file = File(path);
bool exist = await file.exists();
if (exist) {
final bytes = await file.readAsBytes();
final encoder = ID3Encoder(bytes);
final al = json.encode(song.al.toJson());
final resultBytes = encoder.encodeSync(MetadataV2_3Body(
title: song.name,
artist: song.authorNmae,
album: song.al.name,
userDefines: {
"duration": song.time.toString(),
"songId": song.songId.toString(),
"ar": json.encode(song.ar.map((e) => e.toJson()).toList()),
"al": al,
}));
file.writeAsBytes(resultBytes, mode: FileMode.write);
debugPrint("[download]finish encode id3 info: ${song.name}");
}
}
}
我下載了一首歌“是你”,桌面預覽能直接看到歌曲標題等資訊。如果要看詳細點,我們可以直接通過id3_codec的ID3Decoder
,當然還可以使用其他工具,這裡我使用一款叫MediaInfo的工具,還看到了我們自定義儲存的ar
、al
、duration
和songId
資訊。
其實剩下的就沒有啥懸念了。我們通過id3_codec的ID3Decoder
讀取對應的資訊,組裝成模型展示出來即可。程式碼都在專案的download_manager.dart
的_loadSongModelFromPath
下,感興趣的自行取檢視。
總結
本文主要講解了音樂下載中儲存的方式和期間遇到的問題,以及最後的解決方法。也簡單介紹了ID3
,它的應用,以及相應的編解碼庫id3_codec。本文涉及到的專案地址👉點我檢視DreamMusic👈,感謝支援。