起因
幾個月前,我在尋找一款時間管理軟體,類似番茄時鐘的工具,但是希望可以自定義時間。
需要自定義的場景
- 做雅思閱讀,3篇檔案需要嚴格控制時間分配,需要一個靈活的計時器
- 定期提醒,每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