深入解讀.NET MAUI音樂播放器專案(一):概述與架構

林曉lx發表於2023-02-12

系列文章將分步解讀音樂播放器核心業務及程式碼:

為什麼想起來這個專案了呢?

這是一個Windows Phone 8的老專案,2014年用作為興趣寫了個叫“番茄播放器”的App,順便提高程式設計技能。

這個專案的架構歷經多次遷移,從WP8到UWP再到Xamarin.Forms。去年底隨著MAUI的正式釋出,又嘗試把它遷移到MAUI上來。

雖然歷經數次遷移,但名稱空間和播放核心的程式碼基本沒怎麼改動,這個專案隨著解決方案升級,依賴庫、API呼叫方式的變更,見證了微軟在移動網際網路領域的動盪。我偶然發現8年前提交到微軟商店的App,竟然還能夠開啟下載頁面 - Microsoft應用商店,但由於我手邊沒有一臺Windows Phone裝置,也沒法讓它在任何的模擬器中跑起來。也只能從商店截圖和原始碼中重溫這個物件和那段時光。

這個專案現在已經沒有任何的商業價值,但我知道它對於我意味著什麼,曾給我帶來的在程式設計時的那種欣喜和享受,可以說真正讓我知道什麼叫“Code 4 Fun”——程式設計帶來的快樂,對於那時剛進入社會的我,樹立信心和堅持道路有莫大的幫助。

這個專案可能從來就沒有價值。那麼寫博文和開源能發揮多少價值就算多少吧。

當下在.Net平臺上有不少開源的音訊封裝庫,如Plugin.Maui.Audio,本專案沒有依賴任何音訊的第三方庫,希望大家以學習的態度交流,如果您有更好的實現方式,歡迎在文章下留言。因為程式碼年代久遠且近年來沒有重構,C#語言版本和程式碼寫法上會有不少繁冗,這裡還要向大家說聲抱歉。

在這裡插入圖片描述

架構

使用Abp框架,我之前寫過如何 將Abp移植進.NET MAUI專案,本專案也是按照這篇博文完成專案搭建。

跨平臺

使用.NET MAU實現跨平臺支援,從Xamarin.Forms移植的應用可以在Android和iOS平臺上順利執行。

播放核心是由分部類提供跨平臺支援的,在Xamarin.Forms時代,需要維護不同平臺的專案,MAUI是單個專案支援多個平臺。
MAUI 應用專案包含 一個 Platform 資料夾,每個子資料夾表示 .NET MAUI 可以面向的平臺

每個資料夾代表了每個平臺特定的程式碼, 在預設的情況下 編譯階段僅僅會編譯當前選擇的平臺資料夾程式碼。

這屬於利用分部類和方法建立平臺特定內容,詳情請參考官方文件

IMusicControlService在專案中分部類實現:

MatoMusic.Core\Impl\MusicControlService.cs
MatoMusic.Core\Platforms\Android\MusicControlService.cs
MatoMusic.Core\Platforms\iOS\MusicControlService.cs
MatoMusic.Core\Platforms\Windows\MusicControlService.cs

核心類

在設計播放核心時,從使用者的互動路徑思考,抽象出了曲目管理器IMusicInfoManager和播放控制服務IMusicControlService

播放器行為和曲目操作行為在各自領域相互隔離,透過生產-消費模型,資料流轉和訊息通知冒泡協調一致。儘量規避了大規模使用執行緒鎖,以及複雜的執行緒同步邏輯。在跨平臺方案中,透過分部類實現了這些介面,類圖如下:
在這裡插入圖片描述

音樂播放相關服務類MusicRelatedService是播放控制服務的一層封裝,在實際播放器業務邏輯上,利用封裝的程式碼能更方便的完成任務。

專案遵循MVVM設計模式,MusicRelatedViewModel作為音樂播放相關ViewModel的基類,包含了曲目管理器IMusicInfoManager和播放控制服務IMusicControlService物件,透過雙向繫結開發者可以從表現層輕鬆進行音樂控制和曲目訪問

ViewModelBase是個基礎類,它繼承自AbpServiceBase,封裝了Abp框架通用功能的呼叫。比如Setting、Localization和UnitOfWork功能。並且實現了INotifyPropertyChanged,它為繫結型別的每個屬性提供變更事件。

核心類圖如下
在這裡插入圖片描述

定義

  • Queue - 歌曲佇列,當前用於播放歌曲的有序列表
  • Playlist - 歌單,儲存可播放內容的集合,用於收藏曲目,新增到我的最愛等。
  • PlaylistEntry - 歌單條目,可播放內容,關聯一個本地音樂或線上音樂資訊
  • MyFavourite - 我的最愛,一個id為1的特殊的歌單,不可編輯和刪除,用於記錄點亮歌曲小紅心
  • MusicInfo - 曲目資訊
  • AlbumInfo - 專輯資訊
  • ArtistInfo - 藝術家資訊
  • BillboardInfo - 排行榜,線上音樂歌單

曲目

曲目包含:

  • Title - 音樂標題
  • AlbumTitle - 專輯標題
  • GroupHeader - 標題頭,用於列表分組顯示的依據
  • Url - 音訊檔案地址
  • Artist - 藝術家
  • Genre - 流派
  • IsFavourite - 是否已“我最喜愛”
  • IsPlaying - 是否正在播放
  • AlbumArtPath - 封面圖片
  • Duration - 歌曲總時長

如果配合模糊搜尋控制元件,需要實現IClueObject,使用方式請參考AutoComplete控制元件

public class MusicInfo : ObservableObject, IBasicInfo, IClueObject
{ .. }
public List<string> ClueStrings
{
    get
    {
        var result = new List<string>();
        result.Add(Title);
        result.Add(Artist);
        result.Add(AlbumTitle);
        return result;
    }
}

它繼承自ObservableObject,建構函式中註冊屬性更改事件
IsFavourite更改時,將呼叫MusicInfoManager將當前曲目設為或取消設為“我最喜愛”

private void MusicInfo_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
    var MusicInfoManager = IocManager.Instance.Resolve<MusicInfoManager>();

    if (e.PropertyName == nameof(IsFavourite))
    {
        if (IsFavourite)
        {
            MusicInfoManager.CreatePlaylistEntryToMyFavourite(this);
        }
        else
        {
            MusicInfoManager.DeletePlaylistEntryFromMyFavourite(this);
        }
    }
}

曲目集合

曲目集合是歌單,音樂專輯或者藝術家(演唱者)創作的音樂的抽象,它包含:

  • Title - 標題,歌單,音樂專輯或者藝術家名稱
  • GroupHeader - 標題頭,用於列表分組顯示的依據
  • Musics - 曲目資訊集合
  • AlbumArtPath - 封面圖片
  • Count - 歌曲集合曲目數
  • Time - 歌曲集合總時長

它繼承自ObservableObject

AlbumInfoArtistInfoPlaylistInfoBillboardInfo 都是曲目集合的子類
在這裡插入圖片描述

Musics是曲目集合的內容,型別為ObservableCollection<MusicInfo>,雙向繫結時提供佇列變更事件。

集合曲目數和集合總時長依賴這個變數

public int Count => Musics.Count();
public string Time
{
    get
    {
        var totalSec = Math.Truncate((double)Musics.Sum(c => (long)c.Duration));
        var totalTime = TimeSpan.FromSeconds(totalSec);
        var time = totalTime.ToString("g");
        return time;
    }
}

當集合內容增刪時,同步通知歌曲集合曲目數以及總時長變更

private void _musics_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
    if (e.Action == NotifyCollectionChangedAction.Remove || e.Action == NotifyCollectionChangedAction.Add)
    {
        RaisePropertyChanged(nameof(Time));
        RaisePropertyChanged(nameof(Count));
    }
}

GroupHeader標題頭,一般取得是標題的首字母,若標題為中文,則使用Microsoft.International.Converters.PinYinConverter獲取中文第一個字的拼音首字母,跨平臺實現方式如下:

private partial string GetGroupHeader(string title)
{
    string result = string.Empty;
    if (!string.IsNullOrEmpty(title))
    {
        if (Regex.IsMatch(title.Substring(0, 1), @"^[\u4e00-\u9fa5]+$"))
        {
            try
            {
                var chinese = new ChineseChar(title.First());
                result = chinese.Pinyins[0].Substring(0, 1);
            }
            catch (Exception ex)
            {
                return string.Empty;
            }
        }
        else
        {
            result = title.Substring(0, 1);
        }
    }
    return result;

}

GroupHeader用於列表分組顯示的內容將在後續文章中闡述

資料庫

應用程式裡使用Sqlite,作為播放列表,歌單,設定等資料的持久化
,使用CodeFirst方式用EF初始化Sqlite資料庫檔案:mato.db

在MatoMusic.Core專案的appsettings.json中新增本地sqlite連線字串

  "ConnectionStrings": {
    "Default": "Data Source=file:{0};"
  },
  ...

這裡檔案是一個佔位符,透過程式碼hardcode到配置檔案

在MatoMusicCoreModule.cs中,重寫PreInitialize並設定Configuration.DefaultNameOrConnectionString:

public override void PreInitialize()
{
    LocalizationConfigurer.Configure(Configuration.Localization);

    Configuration.Settings.Providers.Add<CommonSettingProvider>();

    string documentsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), MatoMusicConsts.LocalizationSourceName);

    var configuration = AppConfigurations.Get(documentsPath, development);
    var connectionString = configuration.GetConnectionString(MatoMusicConsts.ConnectionStringName);

    var dbName = "mato.db";
    string dbPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), MatoMusicConsts.LocalizationSourceName, dbName);

    Configuration.DefaultNameOrConnectionString = String.Format(connectionString, dbPath);
    base.PreInitialize();
}

接下來定義實體類

播放佇列

定義於\MatoMusic.Core\Models\Entities\Queue.cs

public class Queue : FullAuditedEntity<long>
{
    [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public override long Id { get; set; }

    public long MusicInfoId { get; set; }

    public int Rank { get; set; }

    public string MusicTitle { get; set; }
}

歌單

定義於\MatoMusic.Core\Models\Entities\Playlist.cs

public class Playlist : FullAuditedEntity<long>
{

    [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public override long Id { get; set; }
    public string Title { get; set; }

    public bool IsHidden { get; set; }

    public bool IsRemovable { get; set; }

    public ICollection<PlaylistItem> PlaylistItems { get; set; }
}

歌單條目

定義於\MatoMusic.Core\Models\Entities\PlaylistItem.cs

public class PlaylistItem : FullAuditedEntity<long>
{

    [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public override long Id { get; set; }

    public int Rank { get; set; }

    public long PlaylistId { get; set; }
    [ForeignKey("PlaylistId")]
    
    public Playlist Playlist { get; set; }
    public string MusicTitle { get; set; }

    public long MusicInfoId { get; set; }
}


配置

資料庫上下文物件MatoMusicDbContext定義如下

public class MatoMusicDbContext : AbpDbContext
{
    //Add DbSet properties for your entities...

    public DbSet<Queue> Queue { get; set; }
    public DbSet<Playlist> Playlist { get; set; }
    public DbSet<PlaylistItem> PlaylistItem { get; set; }

    ...

MatoMusic.EntityFrameworkCore是應用程式資料庫的維護和管理專案,依賴於Abp.EntityFrameworkCore。
在MatoMusic.EntityFrameworkCore專案中csproj檔案中,引用下列包

<PackageReference Include="Abp.EntityFrameworkCore" Version="7.4.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Design" Version="1.1.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.0">

在該專案MatoMusicEntityFrameworkCoreModule.cs 中,將註冊上下文物件,並在程式初始化執行遷移,此時將在裝置上生成mato.db檔案

public override void PostInitialize()
{
    Helper.WithDbContextHelper.WithDbContext<MatoMusicDbContext>(IocManager, RunMigrate);
    if (!SkipDbSeed)
    {
        SeedHelper.SeedHostDb(IocManager);
    }
}

public static void RunMigrate(MatoMusicDbContext dbContext)
{
    dbContext.Database.Migrate();
}

專案地址

GitHub:MatoMusic

下一章將介紹播放器核心功能:播放服務類

相關文章