iT邦幫忙

0

定義驅動的多前端架構:實作 Avalonia 行動前端(iOS / Android)

  • 分享至 

  • xImage
  •  

同一份版面定義,現在也長在 iOS 和 Android 上了

avalonia-mobile-frontend

前兩篇把定義驅動的前端講了個輪廓:第一篇說為什麼把核心放在後端與定義、前端當一層可換的皮,並挑了 Avalonia 當試點;第二篇把同一套 Avalonia 控件編成 WebAssembly,多開了 Browser 這條前端分支。

第二篇結語留了一句話:

順著同一條路再往下,是把這套控件帶到 Avalonia 的 Mobile(Android / iOS)……這條還沒做,之後再來分享。

這篇就是來收這個伏筆的。iOS 和 Android 兩個 head 已經做出來,Northwind 範例可以實際在模擬器裡跑了。到這裡,這套 Avalonia 前端從桌面、瀏覽器一路長到手機,該鋪的平台算是鋪齊了。


1️⃣ 一樣不重做控件,多接一個 head

先把結論講在前面:行動端沒有為了手機重寫任何一個控件

那些懂定義的欄位編輯器、可編輯的明細表、開窗挑關連資料的按鈕、master-detail 的版面,全都收在 Bee.Northwind.UI 這個共用層裡。Desktop、Browser、iOS、Android 四個 head 引用的是同一份 Bee.Northwind.UI,從 App、ViewModel、View 到底層控件一律共用,差別只在最外層那個輕薄的平台啟動專案:

bee-northwind-avalonia/
├── Bee.Northwind.UI/         Avalonia 共用 UI(views / view models / 控件)
├── Bee.Northwind.Desktop/    桌面 head
├── Bee.Northwind.Browser/    Web head(Avalonia WASM)
├── Bee.Northwind.iOS/        iOS head
└── Bee.Northwind.Android/    Android head

iOS / Android 的 head 本體都很薄,主要就一件事:把共用的 UI 掛進該平台的 app 殼,再做幾處平台特有的接線。控件這層完全不動,後端更是連碰都沒碰,它本來就只透過 JSON-RPC 對話,不在乎連上來的是桌機、瀏覽器還是手機。


2️⃣ 桌面是多視窗,行動是「單一視圖」

桌面和行動最根本的差別,在 app 的生命週期模型。

桌面走的是 IClassicDesktopStyleApplicationLifetime,有一個(或多個)原生視窗,主畫面裝在 MainWindow 裡。手機沒有「視窗」這個概念,整個 app 就是一塊全螢幕的畫布,對應 Avalonia 的 ISingleViewApplicationLifetime。所以 head 在啟動時分一條路:桌面建 MainWindow,行動把同一個 MainView 直接掛成 app 的單一視圖。

if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
    desktop.MainWindow = new MainWindow { DataContext = vm };
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
{
    // iOS / Android / Browser:沒有原生視窗,直接把共用 MainView 當單一視圖掛上去
    singleView.MainView = new MainView { DataContext = vm };
}

另外,Browser 也走 single-view 這條(瀏覽器沙箱同樣沒有原生視窗),所以 Web 和 Mobile 在這一層其實是同一種模型。MainView 本身是共用的,分岔只到「裝它的容器」為止。


3️⃣ 為行動另外處理的,集中在三個「平台中立」的點

第二篇講 Browser 時,把「為 Web 另外處理的」收斂成兩點(localStorage、浮層對話框)。行動端也是一樣的思路:控件不分叉,只補手機這個環境真正需要的東西。集中在三處,而這三處有個共通點:都不靠 OperatingSystem.IsAndroid() / IsIOS() 去判斷平台,而是接 Avalonia 已經抽象好的平台中立介面,桌面與行動跑同一段程式碼,只是回報的值不同。

安全區域(Safe Area):閃開瀏海與狀態列

手機螢幕頂端有瀏海/動態島、底部有 home indicator,內容不能畫到那些地方去。Avalonia 的 InsetsManager 會回報這塊「安全區域」,行動端把它當 Padding 套上去就好:

_insets = TopLevel.GetTopLevel(this)?.InsetsManager;
if (_insets is not null)
{
    _insets.SafeAreaChanged += OnSafeAreaChanged;
    Padding = _insets.SafeAreaPadding;   // 內縮,避開瀏海 / 狀態列 / home indicator
}

桌面和 Browser 回報的安全區域是零,這段程式碼套上去等於沒作用;手機上才會真的內縮。同一段碼、不分平台,省事就省在這裡。SafeAreaChanged 還會在轉向時重新觸發,橫放直放都對得上。

返回鍵:把 Android 的硬體返回收進畫面層級

Android 有實體/手勢返回鍵,使用者按下去的預期是「退一層」,而不是直接把 app 關掉。Avalonia 透過 TopLevel.BackRequested 把這個訊號交給我們,行動端把它接進 Forms 畫面的層級:

private void OnBackRequested(object? sender, RoutedEventArgs e)
{
    var forms = this.GetVisualDescendants().OfType<FormsView>().FirstOrDefault();
    if (forms is not null && forms.TryHandleBack())
        e.Handled = true;   // 自己消化掉,平台就不會把 app 關掉
}

TryHandleBack 的處理順序是順著使用者的心智走的:

  1. 正在看一筆記錄 → 退回該表單的清單
  2. 已經在清單 → 關掉目前這個表單分頁
  3. 連分頁都沒了 → 回傳 false,把返回交還給平台(這時才是退出 app)

也就是 記錄 → 清單 → 分頁 → 退出 一層層收,跟使用者按返回鍵的直覺一致。iOS 沒有這顆鍵,這條自然就不掛事件,同一份程式碼在 iOS 上靜默略過。

小螢幕:版面自己收窄

桌面寬,選單可以常駐在側邊;手機窄,選單和表單得能全螢幕切換。這靠的是量測畫面寬度、過了門檻就收起側欄:

if (DataContext is FormsViewModel vm
    && Bounds.Width > 0
    && Bounds.Width < FormView.DefaultCompactWidthThreshold)
{
    vm.IsPaneOpen = false;   // 窄螢幕:選好表單後把側欄收起,讓表單全螢幕
}

一樣不問「現在是不是手機」,只看「現在多寬」。手機直放會收窄成單欄;同一支手機橫放、或在桌面拉寬視窗,又會展開回側欄常駐。MainActivity 那邊也配合宣告了 ConfigurationChanges.Orientation | ScreenSize,轉向時 Avalonia 在原地重新排版、不重建畫面,響應式才接得順。


4️⃣ 連線那關:開發期的 cleartext 與設定存哪裡

行動端要能連上後端,有兩個跟平台環境有關的點要處理。

第一個是開發期的明文 HTTP。 iOS 與 Android 預設都擋 cleartext HTTP,但開發時後端就跑在本機、走的是 http://,所以兩邊都得在開發設定裡開一個洞:

  • Android:AndroidManifest.xmlandroid:usesCleartextTraffic="true";而且模擬器連開發機要走特殊位址 10.0.2.2,所以 endpoint 填 http://10.0.2.2:5100/api
  • iOS:Info.plistNSAppTransportSecurityNSAllowsArbitraryLoads;模擬器直接用 http://localhost:5100/api

這兩個都是只給開發用的設定,上線必須改走 HTTPS 並把它們拿掉,manifest 與 plist 裡都留了註解標明這件事。

第二個是連線設定存哪裡。 第二篇提過 Browser 沙箱不能寫檔,得改用 localStorage。行動端剛好相反:iOS / Android 的 app sandbox 各自有一塊可寫的私有資料目錄,FileEndpointStorage 寫進去就好,跟桌面用的是同一個實作。所以四個 head 的 endpoint 儲存是這樣分的:

Head IEndpointStorage 實作 落點
Desktop FileEndpointStorage 本機 app 資料目錄
iOS / Android FileEndpointStorage sandbox 私有資料目錄
Browser BrowserLocalStorageEndpointStorage 瀏覽器 localStorage

三種落點、同一個 IEndpointStorage 介面,在各自的 head 指定一行就替換掉,控件那層完全無感。

想自己跑,後端先起來,再挑一個行動 head:

# 後端(JSON-RPC,http://localhost:5100)
dotnet run --project Bee.Northwind.Server

# iOS 模擬器
dotnet build Bee.Northwind.iOS -t:Run -f net10.0-ios -c Debug

# Android 模擬器
dotnet build Bee.Northwind.Android -t:Run -f net10.0-android -c Debug

下面四張是 Northwind 訂單表單實際在模擬器上跑的樣子。左右是同一個畫面在 iOS 與 Android 兩個平台,上下是清單與單筆。同一套控件編到哪個平台,長相和行為都一致:

iOS Android
訂單清單 iOS 訂單清單 Android 訂單清單
訂單單筆 iOS 訂單單筆 Android 訂單單筆

✅ 結語:四個出海口到齊

到這篇為止,同一套 Avalonia 前端(同一份定義、同一批懂定義的控件)已經有四個落地的出海口:Desktop、Browser、iOS、Android。每多一個,後端與定義都沒有動過一行;每多一個,要另外處理的也就是這個平台環境真正獨有的那幾點(Web 的兩點、行動的三點),而且大多還能用平台中立的抽象去接,連平台判斷都省了。

行動端的樣子前面看過了;桌面與 Browser 這兩個出海口跑的是同一張訂單表單,長相一樣(Browser 版可以從網址列 localhost:5200 認出它跑在瀏覽器裡):

Desktop Browser
Desktop 訂單單筆 Browser 訂單單筆

延伸閱讀


📘 HackMD 原文筆記:
👉 https://hackmd.io/@jeff377/avalonia-mobile-frontend

📢 歡迎轉載,請註明出處
📬 歡迎追蹤我的技術筆記與實戰經驗分享
FacebookHackMDGitHubNuGet


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言