MAUI 框架開發 將 MAUI 嵌入到 WPF 控制元件裡

2023-07-19 21:00:43

本文將介紹如何將 MAUI 的底層替換為 WPF 框架層,且將 MAUI 的內容嵌入到 WPF 的一個控制元件裡面,無 UI 框架嵌入的空域問題

本文是 MAUI 框架開發部落格,而不是 MAUI 應用開發部落格,本文更多介紹的是進行 MAUI 這個框架的開發內容。不熟悉或不進行 MAUI 框架開發的夥伴也可以看著玩,看看這個有趣的黑科技。必須說明的是本文介紹的這條路僅僅只是我的想法,本文也僅僅完成了證明了技術上的可行性,不代表著後續 MAUI 必須往這個方向發展,以及不代表工程上的可行性

開始之前先看看效果,以下程式碼是放入到 WPF 專案的 MainWindow.xaml 檔案裡面的

    <Grid>
        <StackPanel>
            <Canvas x:Name="MauiMainPageCanvas" Width="1000" Height="500"></Canvas>
            <Button HorizontalAlignment="Center">Wpf Button</Button>
        </StackPanel>
        <Border Width="600" Height="50" Background="Blue" HorizontalAlignment="Left">
            <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center">Not Airspace issues</TextBlock>
            <Border.RenderTransform>
                <RotateTransform Angle="90" CenterX="300" CenterY="0"></RotateTransform>
            </Border.RenderTransform>
        </Border>
    </Grid>

以上程式碼的 MauiMainPageCanvas 就是一個用來承載 Maui 的 MainPage 的 Canvas 控制元件。接下來的 Maui 的 MainPage 介面將會在此顯示。以上程式碼錶現了此方案可以支援將 MAUI 的內容嵌入到 WPF 的一個 Canvas 控制元件裡面,且受到 WPF 佈局的約束,如放入到 StackPanel 裡面被佈局。接著下方放一個帶旋轉的 Border 控制元件,一半覆蓋住了 MauiMainPageCanvas 控制元件,用來表示沒有空域問題。假定有空域問題,那大家跑起來一眼就能看出來了

以下的程式碼是放入到 MAUI 專案裡面,程式碼是放入到 MAUI 專案的 MainPage.xaml 裡面,是一個簡單的按鈕加上背景設定一點顏色

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MauiApp.MainPage">

    <VerticalStackLayout
        Spacing="25"
        Padding="30,0"
        VerticalOptions="Center"
        HeightRequest="500"
        BackgroundColor="#A0565656">

        <Button
            x:Name="CounterButton"
            Text="Click me"
            Clicked="OnCounterClicked"
            HorizontalOptions="Center" />

    </VerticalStackLayout>

</ContentPage>

如此預期的顯示就是 WPF 裡面的 Canvas 顯示出 MAUI 的 MainPage 的介面內容,同時以上的 MAUI 的 CounterButton 還加上了 OnCounterClicked 點選事件,在點選事件裡面修改了按鈕的文字內容,如以下程式碼

    private int _count = 0;

    private void OnCounterClicked(object sender, EventArgs e)
    {
        _count++;

        if (_count == 1)
            CounterButton.Text = $"Clicked {_count} time";
        else
            CounterButton.Text = $"Clicked {_count} times";
    }

預期就是互動上點選 MAUI 的按鈕,可以看到按鈕的文字變更了,這就證明了 MAUI 整個上層邏輯是可以符合預期工作的

跑起來怎樣呢?我提供程式碼,大家可以自己跑跑看

以上程式碼放在githubgitee 歡迎存取

可以通過如下方式獲取本文以上的原始碼,先建立一個名為 MauiForWpf_CikerenearkohereWhefaljearnu 的空資料夾,接著使用命令列 cd 命令進入此空資料夾,在命令列裡面輸入以下程式碼,即可獲取到本文的程式碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin efdae121a59c438323155a5de825937e9c686cd2

以上使用的是 gitee 的源,如果 gitee 不能存取,請替換為 github 的源。請在命令列繼續輸入以下程式碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin efdae121a59c438323155a5de825937e9c686cd2

獲取程式碼之後,進入 MauiForWpf_CikerenearkohereWhefaljearnu 資料夾

跑起來的方法就是設定 WpfApp 為啟動專案,然後一鍵 F5 即可跑起來。當然,別忘了 VisualStudio 2022 打全負載哦

接下來是原理部分

在 MAUI 裡面,從大的設計上,整個 MAUI 處於 UI 框架的上層,且中間提供了 Microsoft.Maui 這個 Core 層,這個抽象約定層。理論上任何實現了 Core 層約定的抽象定義的專案,都可以作為 MAUI 的 UI 框架的底層元件。顯然在 WPF 裡面是完全有能力做到實現好 MAUI 的 Core 層抽象層定義的,也就是 WPF 完全可以作為 MAUI 的底層。在 MAUI 設計之處,本身 WPF 就是設計在 MAUI 的底層裡面

那既然本身就有這個設計了,我這篇部落格不就是完全抄寫官方的設計了?其實不然,按照官方的設計是 MAUI 作為整個完整專案的存在。而本文提供的黑科技是讓 MAUI 作為其他的 WPF 專案裡面的一個控制元件的存在。這就有趣的很了,試想,我現在有一個成熟的 WPF 專案。但是我想玩玩 MAUI 應用開發,可以怎麼辦呢?最佳的辦法就是這個專案裡面有部分模組,部分介面採用 MAUI 編寫。可以讓 MAUI 編寫介面裡面其中某些控制元件,這樣既不需要大改現有專案,也沒有什麼遷移成本。還可以一邊開發 WPF 的同時開發 MAUI 專案

從這個角度上看,本文的這個玩法就似乎超過了 MAUI 的初始設計了?其實沒有哈,我的這個想法其實也是從 MAUI 其中一個設計會議上聽來的,當時沒有記下是哪位大佬的提議,但我感覺特別有可行性。剛好最近放暑假了,有點點空閒餘力,而且從 AIGC 專案的預研上讓我不小心理解了 MAUI 框架的設計的重要部分,於是我嘗試成功了在不更改 MAUI 基礎框架的前提下,只編寫上層程式碼,實現將 WPF 框架注入到 MAUI 框架裡面,讓 WPF 作為 MAUI 框架的底層,且支援 MAUI 專案的某個部分,如 MainPage 嵌入到 WPF 的某個控制元件上

以下是此黑科技的實現方法,我新建了三個專案,分別是 MauiApp 和 MauiWpfAdapt 和 WpfApp 專案,從命名上大家也可以看出來這三個專案分別的功能。其中 MauiApp 是一個 MAUI 專案,完全的 MAUI 專案的實現,沒有摻雜任何的黑科技,十分良心,嗯,不好意思,串場了。而 WpfApp 則是一個非常純粹的 WPF 專案,除了參照 MauiWpfAdapt 專案外,也沒有摻雜任何的黑科技,都是純淨的 WPF 實現

那中間的 MauiWpfAdapt 專案是參照了 MauiApp 專案,然後被 WpfApp 專案參照的。既然前後兩個專案都沒有摻雜黑科技,那黑科技自然只能落到 MauiWpfAdapt 專案裡面了。從命名上可以知道,這個 MauiWpfAdapt 專案是一個介面卡形的專案,功能上就是讓 MAUI 能夠作為 WPF 的一個控制元件嵌入到 WPF 專案裡面

這個 MauiWpfAdapt 專案提供了 MauiForWpfHostHelper 幫助類,程式碼方法簽名如下

public static class MauiForWpfHostHelper
{
    public static MauiApplicationProxy InitMauiApplication(System.Windows.Application wpfApplication);
    public static void HostMainPage(Panel panel, MauiApplicationProxy applicationProxy);
}

呼叫方是放在 WPF 專案裡面,在 App.xaml.cs 裡面呼叫 MauiForWpfHostHelper 的 InitMauiApplication 方法進行初始化。在 MainWindow.xaml.cs 裡呼叫 MauiForWpfHostHelper 的 HostMainPage 方法將 MauiApp 的 MainPage 進行嵌入

以下是 App.xaml.cs 裡面的程式碼

public partial class App : Application
{
    public App()
    {
        var mauiApplicationProxy = MauiForWpfHostHelper.InitMauiApplication(this);
        MauiApplicationProxy = mauiApplicationProxy;
    }

    public MauiApplicationProxy MauiApplicationProxy { get; }
}

以下是 MainWindow.xaml.cs 裡面的程式碼

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        MauiForWpfHostHelper.HostMainPage(MauiMainPageCanvas, ((App) Application.Current).MauiApplicationProxy);
    }
}

如此可以看出來,在 Wpf 專案裡面的使用方法是非常簡單的。複雜的細節都放在 MauiWpfAdapt 裡面。先看看 MauiForWpfHostHelper 的 InitMauiApplication 方法做了哪些黑科技

public static class MauiForWpfHostHelper
{
    public static MauiApplicationProxy InitMauiApplication(System.Windows.Application wpfApplication)
    {
        var builder = MauiProgram.CreateMauiAppBuilder();

        builder.Services.ConfigureMauiHandlers(collection =>
        {
            collection.AddHandler<Application, FooApplicationHandler>();
            collection.AddHandler<Microsoft.Maui.Controls.Window, FooWindowHandler>();
            collection.AddHandler<Page, FooPageHandler>();
            collection.AddHandler<Layout, FooLayoutHandler>();
            collection.AddHandler<Button, FooButtonHandler>();
        });

        // 這是一個標記過時的型別,只是 MAUI 為了相容之前的坑而已,後續版本將會刪除
        DependencyService.Register<ISystemResourcesProvider, ObsoleteSystemResourcesProvider>();

        var mauiApp = builder.Build();

        var rootContext = new FooMauiContext(wpfApplication, mauiApp.Services);

        var app = mauiApp.Services.GetRequiredService<IApplication>();
        app.SetApplicationHandler(app, rootContext);
        var fooApplicationHandler = (FooApplicationHandler) app.Handler;
        _ = fooApplicationHandler;

        return new MauiApplicationProxy(app);
    }

    ... // 忽略其他程式碼
}

可以看到在 InitMauiApplication 方法裡面大量進行了注入 FooXxxxHandler 模組。而且還將 Wpf 的 System.Windows.Application 構成 FooMauiContext 上下文設定到 Maui 的 App 處理器裡面

從程式碼可以看到,精髓就在各個 FooXxxxHandler 模組裡面。在 Maui 的大的框架設計裡面,是採用元件化模式設計,配合中央容器進行裝配注入。如此可以實現各個模組都可以自定義替換

還有一個小細節是替換模組時需要自定義的自定義模組是可以繼承原有的模組的,如此可以省下不少的開發工作量。以下拿 Button 舉例子,通過編寫 class FooButtonHandler : ButtonHandler 即可讓 FooButtonHandler 繼承預設的 ButtonHandler 實現。由於當前咱採用的是 WPF 框架作為底層框架,現在 2023 還沒有可用的預設實現,所繼承的 ButtonHandler 裡面都是空白的實現,也就是每個實現方法大部分都是啥都不做

繼續使用按鈕的處理器作為例子,咱可以重寫 CreatePlatformView 方法,讓其返回 WPF 的按鈕控制元件,如以下程式碼

class FooButtonHandler : ButtonHandler
{
    protected override object CreatePlatformView()
    {
        return new System.Windows.Controls.Button();
    }

    ... // 忽略其他程式碼
}

想要聊以上的 CreatePlatformView 的方法,就必須說到在 MAUI 裡面,咱定義的業務上的 MAUI 的控制元件,在 MAUI 框架裡面都稱為 VirtualView 虛擬的控制元件,這是和 PlatformView 平臺的控制元件相對應。在 MAUI 裡面,所有的控制元件都是浮在底層 UI 框架上方的,每個控制元件都可以由底層 UI 託管為真正的平臺實現。換句話說就是 MAUI 的跨平臺就是每個平臺自己實現一套 MAUI 的底層,平臺實現部分的對接控制元件就稱為 PlatformView 平臺的控制元件

接下來繼續重寫 GetDesiredSize 和 PlatformArrange 用於對接 MAUI 的佈局框架以及平臺的佈局框架。在 MAUI 裡面有預設的 CrossPlatformMeasure 和 CrossPlatformArrange 方法,這就意味著具體平臺需要編寫的只是將 MAUI 佈局層佈局好的控制元件放入到平臺對應的位置即可。重寫的 GetDesiredSize 是用來告訴 MAUI 佈局框架層,當前的控制元件的實際尺寸。而重寫的 PlatformArrange 則是根據 MAUI 的佈局層算好的範圍執行將平臺控制元件放入到平臺框架正確的座標

class FooButtonHandler : ButtonHandler
{
    public override Size GetDesiredSize(double widthConstraint, double heightConstraint)
    {
        Button.Measure(new System.Windows.Size(widthConstraint, heightConstraint));
        return new Size(Button.DesiredSize.Width + 100, Button.DesiredSize.Height);
    }

    public override void PlatformArrange(Rect rect)
    {
        base.PlatformArrange(rect);

        Button.SetValue(Canvas.LeftProperty, rect.Left);
        Button.SetValue(Canvas.TopProperty, rect.Top);

        Button.Width = rect.Width;
        Button.Height = rect.Height;
    }

    ... // 忽略其他程式碼
}

需要說明的是以上的 FooButtonHandler 實現僅僅只是用來樣式,雖然距離真正可用不遠,但依然不推薦大家直接抄到實際專案裡面

在 MAUI 裡面的 Button 控制元件是可以通過 Text 屬性設定按鈕的文字的。咱如果想要在 WPF 平臺實現上也讓按鈕支援 MAUI 的按鈕功能就需要輸入屬性重寫邏輯,如以下程式碼

class FooButtonHandler : ButtonHandler
{
    public FooButtonHandler() : base(new PropertyMapper<IButton, IButtonHandler>(ButtonHandler.Mapper)
    {
        [nameof(IText.Text)] = MapFooText
    })
    {
    }

    private static void MapFooText(IButtonHandler buttonHandler, IButton button)
    {
        var fooButtonHandler = (FooButtonHandler) buttonHandler;
        if (button is IText text)
        {
            fooButtonHandler.Button.Content = text.Text;
        }
    }

    private System.Windows.Controls.Button Button => (System.Windows.Controls.Button) PlatformView;

    ... // 忽略其他程式碼
}

在 ButtonHandler 的建構函式裡面,允許傳入對屬性的處理。這裡傳入的是在原有的 ButtonHandler.Mapper 基礎上,覆蓋或追加對 IText.Text 屬性變更的處理。在 MapFooText 裡面就是對按鈕的 Text 屬性進行處理的邏輯,這個 MapFooText 方法會在 MAUI 的 Button 按鈕初始化完成之後呼叫,以及後續的任何對 MAUI 的 Button 按鈕的 Text 屬性變更的時候觸發

在 MapFooText 將 MAUI 的 Button 按鈕的 Text 屬性賦值給到 WPF 的 Button 的內容,如此即可讓 WPF 的按鈕呈現設定在 MAUI 的 Button 按鈕的文字

通過以上的例子也可以看出 MAUI 是可以支援各個平臺對相同的 MAUI 的控制元件的屬性有不同的解釋,如此屬於跨平臺框架實現的一個選擇,那就是讓各個平臺保持各個平臺的特性。這樣的做法的優點在於可以更大程度保留各個平臺的功能,同時平臺實現本身的效能也不差,相比全自繪來說可以使用到更高的平臺效能

作為微軟家的設計師,在設計 MAUI 的時候,怎麼只會在跨平臺框架實現上只採用一個選擇呢?微軟家的設計師可是都要的哦。在以上的基礎上,如果想要讓各個平臺行為相同,那自然就不能保持其平臺特性了。想想,對於小團隊來說,沒有足夠的開發精力去測試各個平臺的差異性,此時更多的想法是讓各個平臺的行為保持一致,雖然 App 寫的一般般可也不會挖坑。如果想要讓各個平臺保持相同的行為,這時就可以採用 MAUI 的統一渲染層來實現。這也是 MAUI 一開始就設計進去的大功能。但是必須說明的是這個設計雖然很好,但也相當相當費開發者,顯然現在 MAUI 開發團隊還不能完成這個設計的工作

通過注入對 MAUI 的 Button 按鈕的 Text 屬性的處理即可實現顯示 MAUI 按鈕上的文字。那如何在使用者點選按鈕時,回過來觸發到 MAUI 按鈕的點選邏輯呢?這時就需要平臺層主動處理互動邏輯,如以下程式碼,重寫連線函數,監聽 WPF 按鈕的點選事件,將點選事件給到 MAUI 的按鈕的點選

class FooButtonHandler : ButtonHandler
{
    protected override void ConnectHandler(object platformView)
    {
        var button = (System.Windows.Controls.Button) platformView;
        button.Click += OnClick;
    }

    private void OnClick(object sender, RoutedEventArgs e)
    {
        VirtualView.Clicked();
    }

    ... // 忽略其他程式碼
}

如此即可完成按鈕的基礎平臺實現,也就是在 MAUI 的介面上建立一個按鈕,就會自動建立一個 WPF 對應的按鈕。在 MAUI 的按鈕上設定文字,將會自動同步到 WPF 的按鈕,自動給 WPF 的按鈕設定上文字。點選 WPF 的按鈕,就會觸發回 MAUI 的按鈕的點選

看到這裡大家也能感受到這個工作量有龐大了吧

這還沒結束,以上只是介紹了使用 WPF 作為 MAUI 的底層框架如何實現 MAUI 的按鈕處理器。而作為本文的核心邏輯,如何將 MAUI 的介面嵌入到 WPF 的控制元件裡面還沒介紹

其實在瞭解了 MAUI 的各個控制元件的處理器注入機制之後,就能想到如何實現將 MAUI 的介面嵌入到 WPF 的控制元件裡面。只需要讓 MAUI 的某個控制元件的實現對應的平臺層,也就是在這裡的 WPF 層,讓這個平臺層實現的控制元件加入到 WPF 的某個控制元件裡面即可。由於 MAUI 的底層實現全部都是由 WPF 層實現的,自然也就不會存在空域等問題了

以下是 MauiForWpfHostHelper 的 HostMainPage 方法,在這個方法裡面將 Maui 的 MainPage 嵌入到傳入的控制元件裡面

    public static void HostMainPage(Panel panel, MauiApplicationProxy applicationProxy)
    {
        var application = applicationProxy.Application;
        var context = application.Handler!.MauiContext!;

        var mauiContext = new FooPanelMauiContext(panel, context);
        var mauiWindow = (Microsoft.Maui.Controls.Window) application.CreateWindow(new ActivationState(mauiContext));

        var mainPage = new MainPage()
        {
            WidthRequest = panel.Width,
            HeightRequest = panel.Height,
        };
        var platform = mainPage.ToPlatform(mauiContext);
        _ = platform;

        mainPage.Measure(panel.Width, panel.Height);

        mainPage.Layout(new Rect(0, 0, panel.Width, panel.Height));

        mauiWindow.Page = mainPage;
    }

將 WPF 的 Panel 容器放入到 FooPanelMauiContext 裡面,然後呼叫 Maui 的 IApplication 建立視窗,在建立的時候自然就注入了上下文。如何將 MAUI 的 MainPage 嵌入到傳入的 WPF 的 Panel 容器裡的核心科技就在於注入的上下文的使用方里面

在 FooPageHandler 裡面,也就是對應 MAUI 的 Page 的平臺實現裡面,將會實現對 MAUI 的 Page 的內容的處理,實現方式就是獲取 MAUI 的平臺實現控制元件,將平臺實現控制元件放入到上述傳入的 Panel 裡面,從而讓 MAUI 的控制元件嵌入到 WPF 控制元件裡,具體實現就是在 FooPageHandler 實現 Content 屬性的處理

class FooPageHandler : PageHandler
{
    public FooPageHandler() : base(new PropertyMapper<IContentView, IPageHandler>(PageHandler.Mapper)
    {
        [nameof(IContentView.Content)] = MapFooContent
    })
    {
    }

    ... // 忽略其他程式碼

    static void MapFooContent(IContentViewHandler handler, IContentView page)
    {
        var panel = (Panel) handler.PlatformView;
        var platform = (UIElement?) page.PresentedContent?.ToPlatform(new FooTreeMauiContext(panel, handler.MauiContext!));
        panel.Children.Clear();
        if (platform != null)
        {
            panel.Children.Add(platform);
        }
    }
}

通過這樣的方法即可讓 MAUI 嵌入到 WPF 裡面,而且由於採用 WPF 作為 MAUI 的底層實現,自然就沒有空域問題

最後需要說明的是這樣的方法只是完成了技術可行性的測試而已,遠遠還沒有達到在具體專案可用的階段,需要進一步的開發才能使用

當前的 MAUI 和 WPF 都是完全開源的,使用友好的 MIT 協定,意味著允許任何人任何組織和企業任意處置,包括使用,複製,修改,合併,發表,分發,再授權,或者銷售。歡迎大家參與框架開發