目录
一 简介
二 设计思路
三 源码
支持在线检索音乐,支持实时浏览当前收藏的音乐及音乐数据的持久化。



采用MVVM架构,前后端分离,子界面弹出始终位于主界面的中心。

视窗引导启动源码:
namespace Avalonia.MusicStore { public class ViewLocator : IDataTemplate { public Control? Build(object? data) { if (data is null) return null; var name = data.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); var type = Type.GetType(name); if (type != null) { var control = (Control)Activator.CreateInstance(type)!; control.DataContext = data; return control; } return new TextBlock { Text = "Not Found: " + name }; } public bool Match(object? data) { return data is ViewModelBase; } } } using Avalonia; using Avalonia.ReactiveUI; using System; namespace Avalonia.MusicStore { internal sealed class Program { // Initialization code. Don't use any Avalonia, third-party APIs or any // SynchronizationContext-reliant code before AppMain is called: things aren't initialized // yet and stuff might break. [STAThread] public static void Main(string[] args) => BuildAvaloniaApp() .StartWithClassicDesktopLifetime(args); // Avalonia configuration, don't remove; also used by visual designer. public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() .LogToTrace() .UseReactiveUI(); } } 模型源码:
using iTunesSearch.Library; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; namespace Avalonia.MusicStore.Models { public class Album { private static iTunesSearchManager s_SearchManager = new(); public string Artist { get; set; } public string Title { get; set; } public string CoverUrl { get; set; } public Album(string artist, string title, string coverUrl) { Artist = artist; Title = title; CoverUrl = coverUrl; } public static async Task> SearchAsync(string searchTerm) { var query = await s_SearchManager.GetAlbumsAsync(searchTerm) .ConfigureAwait(false); return query.Albums.Select(x => new Album(x.ArtistName, x.CollectionName, x.ArtworkUrl100.Replace("100x100bb", "600x600bb"))); } private static HttpClient s_httpClient = new(); private string CachePath => $"./Cache/{Artist} - {Title}"; public async Task LoadCoverBitmapAsync() { if (File.Exists(CachePath + ".bmp")) { return File.OpenRead(CachePath + ".bmp"); } else { var data = await s_httpClient.GetByteArrayAsync(CoverUrl); return new MemoryStream(data); } } public async Task SaveAsync() { if (!Directory.Exists("./Cache")) { Directory.CreateDirectory("./Cache"); } using (var fs = File.OpenWrite(CachePath)) { await SaveToStreamAsync(this, fs); } } public Stream SaveCoverBitmapStream() { return File.OpenWrite(CachePath + ".bmp"); } private static async Task SaveToStreamAsync(Album data, Stream stream) { await JsonSerializer.SerializeAsync(stream, data).ConfigureAwait(false); } public static async Task LoadFromStream(Stream stream) { return (await JsonSerializer.DeserializeAsync(stream).ConfigureAwait(false))!; } public static async Task> LoadCachedAsync() { if (!Directory.Exists("./Cache")) { Directory.CreateDirectory("./Cache"); } var results = new List(); foreach (var file in Directory.EnumerateFiles("./Cache")) { if (!string.IsNullOrWhiteSpace(new DirectoryInfo(file).Extension)) continue; await using var fs = File.OpenRead(file); results.Add(await Album.LoadFromStream(fs).ConfigureAwait(false)); } return results; } } } 模型视图源码:
using Avalonia.Media.Imaging; using Avalonia.MusicStore.Models; using ReactiveUI; using System.Threading.Tasks; namespace Avalonia.MusicStore.ViewModels { public class AlbumViewModel : ViewModelBase { private readonly Album _album; public AlbumViewModel(Album album) { _album = album; } public string Artist => _album.Artist; public string Title => _album.Title; private Bitmap? _cover; public Bitmap? Cover { get => _cover; private set => this.RaiseAndSetIfChanged(ref _cover, value); } public async Task LoadCover() { await using (var imageStream = await _album.LoadCoverBitmapAsync()) { Cover = await Task.Run(() => Bitmap.DecodeToWidth(imageStream, 400)); } } public async Task SaveToDiskAsync() { await _album.SaveAsync(); if (Cover != null) { var bitmap = Cover; await Task.Run(() => { using (var fs = _album.SaveCoverBitmapStream()) { bitmap.Save(fs); } }); } } } } using Avalonia.MusicStore.Models; using ReactiveUI; using System.Collections.ObjectModel; using System.Linq; using System.Reactive.Concurrency; using System.Reactive.Linq; using System.Windows.Input; namespace Avalonia.MusicStore.ViewModels { public class MainWindowViewModel : ViewModelBase { public ICommand BuyMusicCommand { get; } public Interaction ShowDialog { get; } public ObservableCollection Albums { get; } = new(); public MainWindowViewModel() { ShowDialog = new Interaction(); BuyMusicCommand = ReactiveCommand.CreateFromTask(async () => { var store = new MusicStoreViewModel(); var result = await ShowDialog.Handle(store); if (result != null) { Albums.Add(result); await result.SaveToDiskAsync(); } }); RxApp.MainThreadScheduler.Schedule(LoadAlbums); } private async void LoadAlbums() { var albums = (await Album.LoadCachedAsync()).Select(x => new AlbumViewModel(x)); foreach (var album in albums) { Albums.Add(album); } foreach (var album in Albums.ToList()) { await album.LoadCover(); } } } } using Avalonia.MusicStore.Models; using ReactiveUI; using System; using System.Collections.ObjectModel; using System.Linq; using System.Reactive; using System.Reactive.Linq; using System.Threading; namespace Avalonia.MusicStore.ViewModels { public class MusicStoreViewModel : ViewModelBase { private string? _searchText; private bool _isBusy; public string? SearchText { get => _searchText; set => this.RaiseAndSetIfChanged(ref _searchText, value); } public bool IsBusy { get => _isBusy; set => this.RaiseAndSetIfChanged(ref _isBusy, value); } private AlbumViewModel? _selectedAlbum; public ObservableCollection SearchResults { get; } = new(); public AlbumViewModel? SelectedAlbum { get => _selectedAlbum; set => this.RaiseAndSetIfChanged(ref _selectedAlbum, value); } public MusicStoreViewModel() { this.WhenAnyValue(x => x.SearchText) .Throttle(TimeSpan.FromMilliseconds(400)) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(DoSearch!); BuyMusicCommand = ReactiveCommand.Create(() => { return SelectedAlbum; }); } private async void DoSearch(string s) { IsBusy = true; SearchResults.Clear(); _cancellationTokenSource?.Cancel(); _cancellationTokenSource = new CancellationTokenSource(); var cancellationToken = _cancellationTokenSource.Token; if (!string.IsNullOrWhiteSpace(s)) { var albums = await Album.SearchAsync(s); foreach (var album in albums) { var vm = new AlbumViewModel(album); SearchResults.Add(vm); } if (!cancellationToken.IsCancellationRequested) { LoadCovers(cancellationToken); } } IsBusy = false; } private async void LoadCovers(CancellationToken cancellationToken) { foreach (var album in SearchResults.ToList()) { await album.LoadCover(); if (cancellationToken.IsCancellationRequested) { return; } } } private CancellationTokenSource? _cancellationTokenSource; public ReactiveCommand BuyMusicCommand { get; } } } using ReactiveUI; namespace Avalonia.MusicStore.ViewModels { public class ViewModelBase : ReactiveObject { } } 视图源码:
using Avalonia.MusicStore.ViewModels; using Avalonia.ReactiveUI; using ReactiveUI; using System.Threading.Tasks; namespace Avalonia.MusicStore.Views { public partial class MainWindow : ReactiveWindow { public MainWindow() { InitializeComponent(); this.WhenActivated(action => action(ViewModel!.ShowDialog.RegisterHandler(DoShowDialogAsync))); } private async Task DoShowDialogAsync(InteractionContext interaction) { var dialog = new MusicStoreWindow(); dialog.DataContext = interaction.Input; var result = await dialog.ShowDialog(this); interaction.SetOutput(result); } } } using Avalonia.MusicStore.ViewModels; using Avalonia.ReactiveUI; using ReactiveUI; using System; namespace Avalonia.MusicStore.Views { public partial class MusicStoreWindow : ReactiveWindow { public MusicStoreWindow() { InitializeComponent(); this.WhenActivated(action => action(ViewModel!.BuyMusicCommand.Subscribe(Close))); } } }