分享下最近基於Avalonia UI和MAUI寫跨平臺時間管理工具的體驗

hoyho發表於2024-06-03

起因

幾個月前,我在尋找一款時間管理軟體,類似番茄時鐘的工具,但是希望可以自定義時間。

需要自定義的場景

  1. 做雅思閱讀,3篇檔案需要嚴格控制時間分配,需要一個靈活的計時器
  2. 定期提醒,每30分鐘需要喝水或者上個廁所或者摸一下魚...

總結起來就是:專注一段時間,比如30分鐘,然後休息10分鐘,且沒有雜七雜八的功能。
理論上有的番茄時鐘也能滿足需求,但是我的需求是:

  • 介面儘可能的簡潔。
  • 免費使用且最好是開源的。
  • 可以自定義時間。
  • 最好能跨平臺,因為有時候是在macOS下使用,有時候又是在Windows上。
    但就其中部份條件還好,完全符合的竟然沒符合我需求的。

在Apple store找到一個比較接近需求的一款,叫iTimer, 非常簡潔好用,但是自定義時間需要內購,且只能在macOS下。

於是我在使用的時候就想,這軟體功能極簡,就幾個頁面,為什麼我不自己做一個能。 於是每次利用一點時間空隙我就寫一部份,一開始是選型MAUI,然後中途切換成Avalonia,最後基本完成了這個簡易的版本。這裡記錄下開發心得
結論是:
程式碼都是C# + XAML,沒有很複雜的邏輯和程式碼,新手完全可以輕鬆寫一個日常使用的UI Tool。

程式碼放在Github,也沒啥技術含量,有需要的自取
https://github.com/hoyho/iTimeSlot/tree/main

暫時沒有釋出二進位制檔案
需要的自己用git 克隆下來,然後dotnet build 或者dotnet publish即可

成品預覽

macOS下使用預設主題:

使用Material Theme

Windows和Linux (使用xfce 桌面)

其他雜七雜八的需求
彈窗, 托盤等

就目前而言,基本能滿足我的需求了。

談談體驗

why choose MAUI

一開始,覺得是微軟官方出的框架,應該不會有啥大坑吧,於是看了下官方介紹,文件的demo

  • 可以iOS, Android,macOS, Windows, Looks good
  • 不同平臺的UI實現不一樣,比如在Windows上是WinUI,在macOS上則是Mac Catalyst, 即UIKit, AppKit平臺開放的API等等, 看起來還挺好看的😶
  • 文件也很清晰,至少比avalonia的清晰
    就哼哧哼哧地把環境配置,然後寫了個Hello world.
    也就是這個

我是在macOS開發的,按照文件來就好,
https://learn.microsoft.com/en-us/dotnet/maui/get-started/installation?view=net-maui-8.0&tabs=visual-studio-code

相比Windows下的Visual studio,使用vs code來開發而且還要
macOS 的開發套件
xcode-select --install
中間錯了個錯誤,具體什麼錯誤忘記了,後來加上sudo執行就OK了

持續踩坑

元件picker 在macOS下沒有預設值,需要點選後才能正常顯示

App設定倒數計時需要設定一個時間段,於是選擇了人picker元件,然而測試下來,在macOS下執行時,即使繫結了一組資料後,
元件預設是沒有選擇上的,而是點選了done按鈕後才能正常選擇,具體同 https://github.com/dotnet/maui/issues/10208
顯然是一個bug。。。

後來解決辦法: 窗體初始化的時候主動設定一個SelectedIndex來觸發變更,從而繫結上資料來源

macOS 上有辦法實現關閉視窗後不退出

本來期望是設定了之後,點選關閉按鈕能Hook住關閉事件,然後繼續後臺執行,這在傳統的WinForm或者 GTK框架都能輕鬆實現
然而MAUI的設計似乎更傾向於移動端也就iOS和Android的生命週期,點選即關閉。

好吧,也不是不能用.

無法實現托盤後臺執行

還是macOS下,暫時也沒找到原生的方式實現托盤後臺執行,並支援右鍵選單
經過一番掙扎,找到了官方的一個demo有類似的實現。
但是不是原生支援,而是透過呼叫object-c語言繫結,透過動態連結庫來呼叫macOS提供介面objc_msgSend,然後訪問系統提供的介面來實現比如這個NSStatusBar
這是一個完整的在macOS下,TrayServic實現,來欣賞下:

using System.Runtime.InteropServices;
using Foundation;
using ObjCRuntime;
using WeatherTwentyOne.Services;

namespace WeatherTwentyOne.MacCatalyst;

public class TrayService : NSObject, ITrayService
{
    [DllImport("/usr/lib/libobjc.dylib", EntryPoint = "objc_msgSend")]
    public static extern IntPtr IntPtr_objc_msgSend_nfloat(IntPtr receiver, IntPtr selector, nfloat arg1);

    [DllImport("/usr/lib/libobjc.dylib", EntryPoint = "objc_msgSend")]
    public static extern IntPtr IntPtr_objc_msgSend_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg1);

    [DllImport("/usr/lib/libobjc.dylib", EntryPoint = "objc_msgSend")]
    public static extern IntPtr IntPtr_objc_msgSend(IntPtr receiver, IntPtr selector);

    [DllImport("/usr/lib/libobjc.dylib", EntryPoint = "objc_msgSend")]
    public static extern void void_objc_msgSend_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg1);

    [DllImport("/usr/lib/libobjc.dylib", EntryPoint = "objc_msgSend")]
    public static extern void void_objc_msgSend_bool(IntPtr receiver, IntPtr selector, bool arg1);

    NSObject systemStatusBarObj;
    NSObject statusBarObj;
    NSObject statusBarItem;
    NSObject statusBarButton;
    NSObject statusBarImage;

    public Action ClickHandler { get; set; }

    public void Initialize()
    {
        statusBarObj = Runtime.GetNSObject(Class.GetHandle("NSStatusBar"));
        systemStatusBarObj = statusBarObj.PerformSelector(new Selector("systemStatusBar"));
        statusBarItem = Runtime.GetNSObject(IntPtr_objc_msgSend_nfloat(systemStatusBarObj.Handle, Selector.GetHandle("statusItemWithLength:"), -1));
        statusBarButton = Runtime.GetNSObject(IntPtr_objc_msgSend(statusBarItem.Handle, Selector.GetHandle("button")));
        statusBarImage = Runtime.GetNSObject(IntPtr_objc_msgSend(ObjCRuntime.Class.GetHandle("NSImage"), Selector.GetHandle("alloc")));

        var imgPath = System.IO.Path.Combine(NSBundle.MainBundle.BundlePath, "Contents", "Resources", "Platforms", "MacCatalyst", "trayicon.png");
        var imageFileStr = NSString.CreateNative(imgPath);
        var nsImagePtr = IntPtr_objc_msgSend_IntPtr(statusBarImage.Handle, Selector.GetHandle("initWithContentsOfFile:"), imageFileStr);

        void_objc_msgSend_IntPtr(statusBarButton.Handle, Selector.GetHandle("setImage:"), statusBarImage.Handle);
        void_objc_msgSend_bool(nsImagePtr, Selector.GetHandle("setTemplate:"), true);

        // Handle click
        void_objc_msgSend_IntPtr(statusBarButton.Handle, Selector.GetHandle("setTarget:"), this.Handle);
        void_objc_msgSend_IntPtr(statusBarButton.Handle, Selector.GetHandle("setAction:"), new Selector("handleButtonClick:").Handle);
    }

    [Export("handleButtonClick:")]
    void HandleClick(NSObject senderStatusBarButton)
    {
        var nsapp = Runtime.GetNSObject(Class.GetHandle("NSApplication"));
        var sharedApp = nsapp.PerformSelector(new Selector("sharedApplication"));

        void_objc_msgSend_bool(sharedApp.Handle, Selector.GetHandle("activateIgnoringOtherApps:"), true);

        ClickHandler?.Invoke();
    }
}

本來Apple的文件就不咋滴,看到這一坨徹底是震驚到了,居然還要熟悉蘋果的那一套API才能搞得定,且不說這程式碼可讀性和健壯性以及維護成本
不過改改也能用,,,

macOS傳送通知無法彈出

原生的介面似乎只找到DisplayAlert 和Toasts
勉強湊活著用吧
然而視窗非置頂的情況也就是程式沒有獲得焦點的情況下,通知視窗壓根就不會彈出,也就是通知了也看不到,幾乎半殘
一個對時間管理敏感的程式,到時間了還彈不出通知,那要來何用。。。

於是在完成某一次commit之後,我在思考,趁著現在還沒完成開發,切換成Avalonia是否還來得及

答案是肯定的

切換到Avalonia並不困難,在同目錄先用新名字初始化一個空的Avalonia專案,把關鍵程式碼複製改改基本上一兩個小時的就完成遷移

踩坑Avalonia

其實還好,
由於Avalonia的UI都是自繪的,有時候看著美觀性還差那麼點意思,但是不影響
真正的遇到的問題是有一個版本存在記憶體洩漏,折騰了好久,以為是自己的程式碼哪裡沒處理好,導致的洩漏
終於在某一天看到官網的更新日誌,修復了一個記憶體洩漏的問題,於是更新版本後神奇地修復了,大喜

換成Avalonia後基本上Linux端也能用了,似乎沒啥大毛病

其他體驗

善用MVVM模式

雖說前期用MAUI 折騰了一會,但是真正回顧下,切換到Avalonia後感覺上手真的非常快。
無論MAUI還是Avalonia都是推薦MVVM開發模式,熟用繫結,基本上每個頁面都比較清晰。
儘管這裡還是部份就在code behind把邏輯就寫了。。。(反面教程)

C# 開發效率

這裡用的是VS code, 在macOS下開發,偶爾搭配下Rider,目前為止還是比較絲滑。沒有遇到大坑
雖然不足windows + Vs 無敵,但是滿足日常使用,即使換到Linux也能繼續

編譯檔案:
在macOS打包成img 也不過是一百多M, 在Windows 和Linux只需幾十M,而且可以打包成單個檔案。
也可以Aot編譯,簡直就是秒開,相比Electron 之類的,還是有不錯的優勢。

執行效率

MAUI的忘記對比資源佔用了。
最後的版本,在Windows 記憶體基本在六七十M,比較合理,
在macOS和Linux下稍微多一點大概80-100M之間,也能接受

結論

MAUI 比較傾向移動端, 用來開發桌面軟體,還是一言難盡
推薦還是Avalonia,兩者就上手難度而言,只要用過.NET的,稍微閱讀下文件,其實就能把自己日常的需求開發起來,沒有太大負擔,值得擁有。
至於是不是重複造輪子,見仁見智

最後放上程式碼倉庫:
雖然沒啥技術含量,有興趣的可以看看 https://github.com/hoyho/iTimeSlot

相關文章