.NET MAUI 中結合 Vue 實現混合開發

.NET MAUI 中結合 Vue 實現混合開發

在MAUI微軟的官方方案是使用Blazor開發,但是當前市場大多數的Web專案使用Vue、React等技術構建,如果我們沒法繞過已經積累的技術,用Blazor重寫整個專案並不現實。

最後更新 2022/1/18 下午10:11
林 小
預計閱讀 10 分鐘
分類
MAUI
標籤
.NET C# Blazor MAUI Vue

在 MAUI 微軟的官方方案是使用 Blazor 開發,但當前市場大多數的 Web 專案使用 Vue、React 等技術建置,如果我們無法繞過已經累積的技術,用 Blazor 重寫整個專案並不現實。

Vue 是當前流行的 web 框架,簡單來說是一套模板引擎,利用「模板」和「綁定」兩大特性實現 web 頁面 mvvm 模式開發。利用 .NET MAUI 框架可以將 Vue 應用嵌入到 Web 容器中,實現跨平台的混合開發。

例如我在某醫療行業專案中,已經用這個混合開發的方式產生應用程式,Vue 程式碼不需要做什麼改動,就能跨平台執行:

如果你有一套 Vue 開發的網站,可以根據這篇文章,嘗試移植進你的 iPhone、Android 以及平板電腦等行動裝置。

混合開發的核心工作是建置 Web 與 .NET 的互通操作,我們將利用 Blazor 引擎的以下功能:

  • 資源的統一管理
  • JS 程式碼的注入
  • JS 呼叫 C# 程式碼
  • C# 呼叫 JS 程式碼

如果你還不了解混合開發的概念,請回看上一章節 [MAUI] 混合開發概念_jevonsflash 的專欄-CSDN 部落格

整個工作分為 MAUI 部分、Vue 部分和混合改造。

MAUI 部分

建立 Maui App 專案:

你也可以建立 Maui Blazor App 專案,命名為 MatoProject,但這個模板主要圍繞 Blazor 開發,有些功能我們並不需要,得刪很多檔案。

建立完成後編輯 MatoProject.csproj,在 Sdk 最末尾加上 .Razor,VS 會自動安裝 Microsoft.AspNetCore.Components.WebView.Maui 相依套件(注意不要手動用 NuGet 加入這個套件,否則程式無法執行)

安裝完成後在專案目錄中建立一個 wwwroot 資料夾

這個資料夾將是混合開發 Web 部分的根目錄,這個名稱不能隨便定義,我們看看為什麼:

開啟 Microsoft.AspNetCore.Components.WebView.Maui.targets 這個檔案:

我們可以看到建置專案時,這個程式庫會將 wwwroot 資料夾裡的內容作為 Maui 資源(MauiAsset)類型設定標籤,編譯器則會根據 MauiAsset 標籤將這些內容打包進各個平台的資源資料夾,具體的 Maui 資源類型可以參考這篇文章 .NET MAUI – Manage App Resources – Developer Thoughts (egvijayanand.in)

開啟 MauiProgram.cs 在 builder 中註冊 BlazorMauiWebView 元件,在服務中使用擴充方法 AddBlazorWebView() 來加入相關 Blazor 的服務

using Microsoft.Maui;
using Microsoft.Maui.Hosting;
using Microsoft.Maui.Controls.Compatibility;
using Microsoft.Maui.Controls.Hosting;
using Microsoft.AspNetCore.Components.WebView.Maui;
using Microsoft.Extensions.DependencyInjection;

namespace MatoProject
{
	public static class MauiProgram
	{
		public static MauiApp CreateMauiApp()
		{
			var builder = MauiApp.CreateBuilder();
			builder
				.RegisterBlazorMauiWebView()
				.UseMauiApp<App>()
				.ConfigureFonts(fonts =>
				{
					fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
				});
			builder.Services.AddBlazorWebView();
			return builder.Build();
		}
	}
}

開啟 MainPage.xaml,編輯原生應用程式的主頁面:

建立 BlazorWebView 控制項鋪滿螢幕,並設定 HostPage 為 Web 部分的主頁 index.html

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MatoProject.MainPage"
             xmlns:b="clr-namespace:Microsoft.AspNetCore.Components.WebView.Maui;assembly=Microsoft.AspNetCore.Components.WebView.Maui"
             BackgroundColor="{DynamicResource SecondaryColor}">

    <Grid>
        <b:BlazorWebView HostPage="wwwroot/index.html">
            <b:BlazorWebView.RootComponents>
                <b:RootComponent Selector="#blazorapp" x:Name="MainWebView" ComponentType="{x:Type local:Index}"/>
            </b:BlazorWebView.RootComponents>
        </b:BlazorWebView>
    </Grid>
</ContentPage>

建立 _import.razor

@using System.Net.Http @using Microsoft.AspNetCore.Components.Forms @using
Microsoft.AspNetCore.Components.Routing @using
Microsoft.AspNetCore.Components.Web @using
Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop
@using MatoProject

Vue 部分

至此我們建立好了原生開發的 Web 容器,接下來需要處理 Vue 專案了:

cd 到專案目錄,使用 vue-cli 建立一個空白 Vue 專案:

這裡可以依照 Vue 的程式設計喜好建立,例如我選擇了 2.0 專案,支援 Typescript、es6 的 class 命名方式等,最終都要透過 webpack 打包成靜態資源,所以沒差。

建立 src/api/fooService.ts,建立如下的函式:

window['DotNet'] 物件將是 MAUI Blazor 中注入的互通操作物件

export async function GetAll(data) {
  var result = null;
  await window["DotNet"]
    .invokeMethodAsync("MatoProject", "GetFoo")
    .then((data) => {
      console.log("DotNet method return the value:" + data);
      result = data;
    });
  return result;
}

export async function Add(data) {
  var result = null;
  await window["DotNet"]
    .invokeMethodAsync("MatoProject", "Add", data)
    .then((data) => {
      console.log("DotNet method return the value:" + data);
      result = data;
    });
  return result;
}

開啟 Home.vue 編輯:

這是 Web 的主頁面,我們需要三個按鈕以及相關函式,測試 JS 與 C# 的互通操作。

<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png" />
    <div>
      <h3>foo:</h3>
      <button @click="getFoo">click to get foo</button>
      <br />
      <span>{{ foo }}</span>
    </div>
    <div>
      <h3>bar:</h3>
      <span>{{ bar }}</span>
    </div>
    <div>
      <button @click="add">click here to add</button>
      <span>click count:{{ cnt }}</span>
    </div>
  </div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import HelloWorld from "@/components/HelloWorld.vue"; // @ is an alias to /src
import { GetAll, Add } from "@/api/fooService";

@Component({
  components: {
    HelloWorld,
  },
})
export default class Home extends Vue {
  foo: string = "";
  bar: string = "";
  cnt: number = 0;

  async created() {
    window["postBar"] = this.postBar;
  }
  async add() {
    this.cnt = await Add({ a: this.cnt, b: 1 });
  }

  async getFoo() {
    var foo = await GetAll(null);
    this.foo = foo;
  }

  async postBar(data) {
    this.bar = data;
    console.log("DotNet invocked the function with param:" + data);
    return this.bar;
  }
}
</script>

到此已經完成了一個簡單的 Vue 專案

執行打包命令:

PS D:\Project\maui-vue-hybirddev\hybird-host> yarn build

將 dist 目錄中的所有內容複製到 wwwroot 資料夾下。

混合改造

這是混合開發的重點,改造 MAUI 專案,以適配 Vue

開啟 wwwroot/index.js 重寫為:

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link rel="icon" href="favicon.ico" />
    <title>hybird-host</title>
    <link href="js/about.dc8b0f2b.js" rel="prefetch" />
    <link href="css/app.03043124.css" rel="preload" as="style" />
    <link
      href="js/app.b6b5425b.js"
      rel="preload"
      as="script"
      crossorigin="anonymous"
    />
    <link
      href="js/chunk-vendors.cf6d8f84.js"
      rel="preload"
      as="script"
      crossorigin="anonymous"
    />
    <link href="css/app.03043124.css" rel="stylesheet" />
  </head>
  <body>
    <div id="blazorapp">Loading...</div>
    <script src="_framework/blazor.webview.js" autostart="false"></script>
  </body>
</html>

注意,僅全部重寫 body 部分,不要更改 head 的 link 標籤內容,僅在 js 後面加上 crossorigin="anonymous" 以解決跨域問題。

建立 Index.razor 檔案:

@using Microsoft.Maui.Controls @inject IJSRuntime JSRuntime @implements
IDisposable
<noscript
  ><strong
    >We're sorry but CareAtHome doesn't work properly without JavaScript
    enabled. Please enable it to continue.</strong
  ></noscript
>
<div id="app"></div>
@code { [JSInvokable] public static Task<string>
  GetFoo() { return Task.FromResult("this is foo call C# method from js"); }
  [JSInvokable] public static Task<int>
    Add(AddInput addInput) { return Task.FromResult(addInput.a + addInput.b); }
    public async void Post(object o, EventArgs a) { await
    JSRuntime.InvokeAsync<string
      >("postBar", "this is bar call js method from C#"); } protected override
      async Task OnAfterRenderAsync(bool firstRender) { ((App.Current as
      App).MainPage as MainPage).OnPostBar += this.Post; try { if (firstRender)
      { await JSRuntime.InvokeAsync<IJSObjectReference
        >("import", "./js/chunk-vendors.cf6d8f84.js", new { crossorigin =
        "anonymous" }); await JSRuntime.InvokeAsync<IJSObjectReference
          >("import", "./js/app.b6b5425b.js", new { crossorigin = "anonymous"
          }); } } catch (Exception ex) { Console.WriteLine(ex); } } public void
          Dispose() { (Application.Current.MainPage as MainPage).OnPostBar -=
          this.Post; } }</IJSObjectReference
        ></IJSObjectReference
      ></string
    ></int
  ></string
>

注意以下這兩個陳述式需要對應打包產生的實際檔名,並且加上跨域標籤

await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/chunk-vendors.cf6d8f84.js", new { crossorigin = "anonymous" });
await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/app.b6b5425b.js", new { crossorigin = "anonymous" });

MainPage.xaml 建立一個按鈕並設定觸發事件方法:

<button
  Text="Post Bar To WebView"
  HorizontalOptions="Center"
  VerticalOptions="End"
  HeightRequest="40"
  Clicked="PostBar_Clicked"
></button>

CodeBehind:

using System;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Essentials;

namespace MatoProject
{
	public partial class MainPage : ContentPage
	{
        public event EventHandler<EventArgs> OnPostBar;

		int count = 0;

		public MainPage()
		{
			InitializeComponent();
		}

		private async void PostBar_Clicked(object sender, EventArgs args)
		{
			OnPostBar?.Invoke(this, args);
		}
	}
}

至此,所有的程式碼工作已經完成,在 PC 上可以選擇 Windows 或者 Android 模擬器來執行程式

執行效果:

若在 Windows 平台上執行,原生控制項使用 Edge WebView2 呈現器載入頁面,按 F12 會呼叫原生的偵錯工具,在這裡看到列印

現在,可能有人會問為什麼要使用這樣的技術架構?明明可能有更好用的混合開發技術 Ionic、React Native、Uni-app。首先不可否認這些技術都有他們的特點與優勢,但當你擁有一個成熟的 Xamarin 框架,你可以輕鬆遷移到 MAUI,利用 EFCore 實現資料持久化或者整合 Abp 框架來配置相依性注入、全域事件、在地化等行動開發常用的功能(另一篇文章將會教大家如何將 Abp 移植進 MAUI)。Xamarin 是一個裝置抽象層,提供的 WebView 也有較好的 H5 相容性。

當然主要原因還是在快速開發上,你的程式碼累積才是寶貴的,更少的修改程式碼量才是王道,如果你在用 React 技術棧編寫 Web 程式碼,也許 React Native 才是你最佳選擇。沒有最優的技術,只有最適合你的技術。

程式碼倉庫:

繼續探索

延伸閱讀

更多文章
同分類 / 同標籤 2023/1/12

Maui Blazor 使用攝影機實現

由於 Maui Blazor 中界面是由 WebView 渲染,所以在使用 Android 的攝影機時無法獲取,因為原生的攝影機需要綁定界面元件

繼續閱讀
同分類 / 同標籤 2022/4/26

在 MAUI 中使用 Masa Blazor

使用 `.NET MAUI`,可以開發可在 `Android`、`iOS`、`macOS` 和 `Windows`、Linux(社群支援)從單一共用程式碼庫執行的應用,一套程式碼多端執行。

繼續閱讀