# Bread.Mvc [Bread.Mvc](https://gitee.com/rizo/bread-mvc) 是一款完全支持 Native AOT 的 MVC 框架,搭配同樣支持 AOT 的 Avalonia,讓你的開發事半功倍。項目開源在 Gitee,歡迎 [Star](https://gi ...
Bread.Mvc
Bread.Mvc 是一款完全支持 Native AOT 的 MVC 框架,搭配同樣支持 AOT 的 Avalonia,讓你的開發事半功倍。項目開源在 Gitee,歡迎 Star。
1. Ioc 容器
IoC容器是 MVC 框架的核心,為了支持AOT,Bread.Mvc 框架選擇使用 ZeroIoC 作為 IoC 容器。ZeroIoC 是一款摒棄了反射的 IoC 容器,具有極高的性能並且完全相容AOT。為了支持 .net 7, 我對 ZeroIoC 代碼做了零星修改,重新發佈在 Bread.ZeroIoC。
1.1 服務註冊
由於不能使用反射,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>();
}
}
1.2 IoC 容器初始化
需要使用 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();
}
2. MVC 架構
2.1 Command
聲明:
用戶的輸入被抽象為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();
}
2.2 Controller
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;
}
}
有以下幾點需要特別註意:
- 必須繼承自 Controller 類才會被 Ioc 初始化時自動實例化(避免沒有顯式獲取時 Command 的 Event 事件不被掛接);
- 所有Controller都是單例模式,必須使用 AddSingleton 註冊,防止 Command 事件掛接後被多次觸發;
- 構造函數中的參數 Model 類也必須在 ZeroIoCContainer 中註冊才會自動註入;
- 相關 Command 的事件處理函數必須寫在構造函數中;
- Command 可掛接在不同的 Controller 中,但是不保證執行順序;
- SessionController 實現了 IDisposable 介面,但是無需我們顯式調用 Dispose 方法。請在應用程式結束時調用 IoC.Dispose() 清理。
2.3 Model
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;
}
3. 其他基礎設施
3.1 Avalonia
當您的應用平臺是 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
}
}
3.2 WPF
略,不想多說。
3.3 日誌
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);
}
3.4 配置文件讀寫
內置 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);
}
}
4. 限制
- 只支持 .net 7 及之後的版本;
- 不支持 asp.net core;