Bread.Mvc 是一款完全支援 Native AOT 的 MVC 框架,搭配同樣支援 AOT 的 Avalonia,讓你的開發事半功倍。專案開源在 Gitee,歡迎 Star。
IoC容器是 MVC 框架的核心,為了支援AOT,Bread.Mvc 框架選擇使用 ZeroIoC 作為 IoC 容器。ZeroIoC 是一款摒棄了反射的 IoC 容器,具有極高的效能並且完全相容AOT。為了支援 .net 7, 我對 ZeroIoC 程式碼做了零星修改,重新發布在 Bread.ZeroIoC。
由於不能使用反射,ZeroIoc 使用 SourceGenerator 技術在編譯期生成注入程式碼,這個機制依賴 ZeroIoCContainer 來觸發。ZeroIoCContainer 是部分類,並宣告了 Bootstrap 方法,使用者的注入註冊程式碼必須放在這個方法中才會被自動生成。您可以將服務註冊類放在專案的不同地方,或者放在不同的專案中。請參見以下程式碼實現自己的註冊類:
using Bread.Mvc;
using ZeroIoC;
namespace XDoc.Avalonia;
public partial class SessionContainer : ZeroIoCContainer
{
protected override void Bootstrap(IZeroIoCContainerBootstrapper builder)
{
builder.AddSingleton<IAlertBox, AlertPacker>();
builder.AddSingleton<IMessageBox, MessagePacker>();
builder.AddSingleton<IUIDispatcher, MainThreadDispatcher>();
builder.AddSingleton<Session>();
builder.AddSingleton<SessionController>();
}
}
需要使用 IoC.Init 方法初始化 IoC 容器,一般推薦在程式啟動之前完成服務註冊和 IoC 容器的初始化操作。請參見如下程式碼:
using Bread.Mvc;
IoC.Init(new XDocContainer(), new SessionContainer());
為了幫助理解,可以檢視 IoC.Init 函數的原始碼,就是將分佈在不同地方的多個註冊類合併為一個,大致如下所示:
public static void Init(params ZeroIoCContainer[] containers)
{
foreach (var container in containers) {
Resolver.Merge(container);
}
Resolver.End();
}
宣告:
使用者的輸入被抽象為Command,Command 連線使用者介面和 Controller。請參見如下程式碼宣告自己的 Command :
public static class AppCommands
{
public static Command Load { get; } = new(nameof(AppCommands), nameof(Load));
public static Command Save { get; } = new(nameof(AppCommands), nameof(Save));
public static AsyncCommand<string, string> ImportAsync { get; } = new(nameof(AppCommands), nameof(ImportAsync));
public static Command Delete { get; } = new(nameof(AppCommands), nameof(Delete));
}
有兩種型別的 Command, 普通 Command 和 AsyncCommand。如您所見, AsyncCommand 支援非同步操作。
使用:
一般我們我在 xaml 或 axaml 的字尾程式碼檔案中使用 Command,表示響應使用者的輸入。
private void UiListBox_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (e.AddedItems == null || e.AddedItems.Count == 0) return;
if (e.AddedItems[0] is not ImageItemViewModel img) return;
if (img == _session.CurrentImage) return;
SessionCommands.SwitchImage.Execution(img);
}
private void UiBtnRight_Click(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
{
SessionCommands.NextImage.Execution();
}
private void UiBtnLeft_Click(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
{
SessionCommands.PreviousImage.Execution();
}
Controller 是業務邏輯的入口,您將在這裡集中處理程式的各種邏輯。在上面 IoC 註冊的例子中,SessionController 就是一個我們自己定義的 Controller 類。
Controller 子類能自動注入已註冊過的服務(Model)。請儘可能使用組合模式以防止 Controller 程式碼體積膨脹。
public class SessionController : Controller, IDisposable
{
readonly AppModel _app;
readonly Session _session;
readonly ProjectModel _prj;
SerialTaskQueue<Doc?> _loadTask = new();
public SessionController(AppModel app, Session session, ProjectModel prj)
{
_app = app;
_prj = prj;
_session = session;
SessionCommands.SwitchData.Event += SwitchData_Event;
SessionCommands.SwitchDoc.Event += SwitchDoc_Event;
SessionCommands.SwitchImage.Event += SwitchImage_Event;
SessionCommands.NextImage.Event += NextImage_Event;
SessionCommands.PreviousImage.Event += PreviousImage_Event;
SessionCommands.SaveDoc.Event += SaveDoc_Event;
SessionCommands.NextDoc.Event += NextDoc_Event;
_loadTask.Start();
_prj.Loaded += _prj_Loaded;
}
}
有以下幾點需要特別注意:
Model 連結業務邏輯和使用者介面。使用者輸入(滑鼠、鍵盤、觸屏動作等)通過 Command 觸發 Controller 中的業務流程,
在 Controller 中更新 Model 的屬性值,這些修改操作又立即觸發使用者介面的重新整理。
邏輯是閉環的:UI->Command->Controller->Model->UI。
定義:
原始碼中對 Model 的定義相當簡單,只是宣告必須要實現 INotifyPropertyChanged 介面。
public abstract class Model : INotifyPropertyChanged
{
public bool IsDataChanged { get; set; }
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged(string name)
{
IsDataChanged = true;
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
宣告:
一般我們將 Model 和相關的 Controller 宣告在一個類庫中,並用 internal set 修飾以防止不必要的外部修改。建議您也只在對應的 Controller 中修改 Model 的屬性。不加限制的修改 Model 物件的屬性,只會帶來更多的屎山程式碼。
public class ProjectModel : Model
{
public int Volume { get; internal set; } = 3;
public RangeList<Volume> Volumes { get; } = new();
public string NewDocFolder { get; internal set; } = string.Empty;
public RangeList<NewDoc> NewDocs { get; } = new();
public ProjectModel()
{
}
}
推薦使用 PropertyChanged.Fody 自動實現 INotifyPropertyChanged 介面。
事實上因為實現了 INotifyPropertyChanged 介面, 您可以在xaml直接繫結 Model 中的屬性。
使用:
我們使用 Watch 函數監聽 Model 屬性的變化,Watch 和 UnWatch 函數的原型如下:
public static void Watch(this INotifyPropertyChanged publisher, string propertyName, Action callback);
public static void Watch(this INotifyPropertyChanged publisher, Action callback, params string[] propertyNames);
public static void UnWatch(this INotifyPropertyChanged publisher, string name, Action callback);
public static void UnWatch(this INotifyPropertyChanged publisher, Action callback, params string[] propertyNames);
通常我們在 Window 或者 UserControl 的 Load 程式碼中完成依賴注入和屬性監聽。
你可以一次監聽一個屬性,或同時監聽多個屬性並在一個 Action 中響應這些屬性的變化。
請記住,監聽的目的是為了響應業務變化以同步更新使用者介面。
private void ImageSlider_Loaded(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
{
if (Design.IsDesignMode) return;
_session = IoC.Get<Session>(); // 從 IoC 容器中取出範例, Session 必須先註冊。
_session.Watch(nameof(Session.CurrentImage), Session_CurrentImage_Changed); // 監聽 CurrentImage 屬性的變化
uiListBox.ItemsSource = _session.Images; // UI元素直接繫結 Model 中的屬性
uiListBox.SelectionChanged += UiListBox_SelectionChanged;
}
當您的應用平臺是 Avalonia 時,Bread.Mvc.Avalonia 包含一些非常有用的擴充套件。
IUIDispatcher 介面 :UI執行緒注入
Bread.Mvc.Avalonia.MainThreadDispatcher 實現了 IUIDispatcher 介面。
因為當屬性被外部執行緒修改時,Watch 機制需要使用這個介面檢測當前執行緒是否在主執行緒中,並將變更 Invoke 給UI執行緒,所以您必須在Avalonia應用中註冊這個服務。
builder.AddSingleton<IUIDispatcher, Bread.Mvc.Avalonia.MainThreadDispatcher>();
Reactive
為了簡化 Watch 操作,我們為常見的控制元件準備了更易用的繫結方法。
public interface IEnumDescriptioner<T> where T : Enum
{
string GetDescription(T value);
}
public partial class SettingsPanel : UserControl
{
SpotModel _spot = null!;
public SettingsPanel()
{
InitializeComponent();
if (Design.IsDesignMode) return;
_spot = IoC.Get<SpotModel>();
// combox initted by enum which LanguageHelper implements IEnumDescriptioner
uiComboxLanguage.InitBy(new LanguageHelper(), Language.Chinese,
Language.English, Language.Japanese, Language.Japanese);
uiComboxLanguage.BindTo(_spot, m => m.Language); // ComboBox
uiNUDAutoSave.BindTo(_app, x => x.AutoSave); // NumericUpDown
uiTbRegCode.BindTo(_app, x => x.RegCode); // TextBox
uiTbFilePath.BindTo(_app, x => x.FilePath); // TextBlock
uiSlider.BindTo(_app, x => x.Progress); // Slider
uiSwitchAutoSpot.BindTo(_spot, m => m.IsAutoSpot); // SwitchButton
uiTbtnChannel.BindTo(_app, x => x.IsLeftChannel); // ToggleButton
uiCheckSexual.BindTo(_app, x => x.IsMale); // CheckBox
}
}
略,不想多說。
Bread.Utility 中提供了一個簡單的紀錄檔類 Log。
public static class Log
{
/// <summary>
/// 開啟紀錄檔
/// </summary>
/// <param name="path">紀錄檔檔名稱</param>
/// <param name="expire">紀錄檔檔案目錄下最多儲存天數。0表示不刪除多餘紀錄檔</param>
/// <exception cref="ArgumentNullException"></exception>
public static void Open(string path, int expire = 0);
/// <summary>
/// 關閉紀錄檔檔案
/// </summary>
public static void Close();
public static void Info(string info, string? category = null,
[CallerFilePath] string? className = null,
[CallerMemberName] string? methondName = null,
[CallerLineNumber] int lineNumber = 0);
public static void Warn(string warn, string? category = null,
[CallerFilePath] string? className = null,
[CallerMemberName] string? methondName = null,
[CallerLineNumber] int lineNumber = 0);
public static void Error(string error, string? category = null,
[CallerFilePath] string? className = null,
[CallerMemberName] string? methondName = null,
[CallerLineNumber] int lineNumber = 0);
public static void Exception(Exception ex);
}
內建 Config 類用於 ini 檔案讀寫。
public class CustomController : Controller
{
Config _appConfig;
readonly AppModel _app;
readonly ProjectModel _prj;
public AppController(AppModel app, ProjectModel prj)
{
_app = app;
_prj = prj;
_appConfig = new Config(Path.Combine(app.AppFolder, "app.data"));
AppCommands.Load.Event += Load_Event;
AppCommands.Save.Event += Save_Event;
}
private void Load_Event()
{
_appConfig.Load();
_app.LoadFrom(_appConfig);
_prj.LoadFrom(_appConfig);
}
private void Save_Event()
{
_app.SaveTo(_appConfig);
_prj.SaveTo(_appConfig);
_appConfig.Save();
}
}
public class AppModel : Model
{
public string Recorder { get; internal set; } = string.Empty;
public ReadOnlyCollection<string> RecentList { get { return _recentList.AsReadOnly(); } }
List<string> _recentList = new();
public AppModel()
{
}
public override void LoadFrom(Config config)
{
config.Load(nameof(AppModel), nameof(Recorder), (string value) => { Recorder = value; });
var list = config.LoadList(nameof(RecentList));
foreach (var item in list) {
if (File.Exists(item)) {
_recentList.Add(item);
}
}
OnPropertyChanged(nameof(RecentList));
}
public override void SaveTo(Config config)
{
base.SaveTo(config);
config[nameof(AppModel), nameof(Recorder)] = Recorder;
config.SaveList(nameof(RecentList), _recentList);
}
}