VS2022和.NET 6正式版即將發布,帶來了Blazor MAUI客戶端項目類型,支持在桌面和移動客戶端中采用Html創建頁面,綁定后台數據,調用后台服務。為什么要用Blazor MAUI呢?因為Html生態圈比Xaml好太多了,MAUI雖然跨平台,但是生態圈太弱了。而且作為.NET開發團隊,如果要同時進行服務器的網頁客戶端和移動、桌面客戶端開發,能夠統一成一種前端框架最好,網頁客戶端只能用Html,沒法用MAUI,所以最好還是統一到Html框架。
創建.NET MAUI Blazor App項目
基於之前編寫的DEMO繼續。
https://www.cnblogs.com/sunnytrudeau/p/15365125.html
安裝VS2022預覽版要添加MAUI相關的工作負載,以及Single-project MSIX extension擴展。
新建BlaMauiApp項目,采用.NET MAUI Blazor App模板。
這個項目csproj文件默認是移動平台
<TargetFrameworks>net6.0-ios;net6.0-android;net6.0-maccatalyst</TargetFrameworks>
<!-- <TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows')) and '$(MSBuildRuntimeType)' == 'Full'">$(TargetFrameworks);net6.0-windows10.0.19041</TargetFrameworks> -->
參考目前最新的官方博客,把TargetFrameworks 改為Windows平台,便於調試運行。
Announcing .NET MAUI Preview 9 - .NET Blog (microsoft.com)
Get Started Today
First thing’s first, install Visual Studio 2022 Preview 5 and check .NET MAUI (preview) under the Mobile Development with .NET workload, and check the Universal Windows Platform development workload.
Now, install the Windows App SDK Single-project MSIX extension. Before running the Windows target, remember to uncomment the framework in the csproj file.
修改后,根據提示重新加載項目
<!--<TargetFrameworks>net6.0-ios;net6.0-android;net6.0-maccatalyst</TargetFrameworks>-->
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows')) and '$(MSBuildRuntimeType)' == 'Full'">$(TargetFrameworks);net6.0-windows10.0.19041</TargetFrameworks>
然后編譯、運行一下BlaMauiApp項目,它跟一個Blazor Server網站很相似。但是它畢竟是一個桌面客戶端軟件,現在還不清楚怎么用cookies、AuthorizeView組件這些,我就把它當做一個WPF桌面客戶端去用,只是界面Xaml變成了Html。
添加訪問Identity Server登錄功能
復制Ids4Client手機驗證碼登錄功能模塊到本項目使用。
因為不使用cookies了,所以要自定義登錄用戶信息數據類和管理者。用戶登錄后,保存用戶信息到本地文件。注意當前工作目錄Environment.CurrentDirectory是系統目錄,沒有寫權限,可以用當前可執行文件所在的目錄AppContext.BaseDirectory。
/// <summary> /// 登錄用戶信息 /// </summary> public class LoginUserInfo { /// <summary> /// 從Identity Server獲取的token結果 /// </summary> public string AccessToken { get; set; } public string RefreshToken { get; set; } public DateTimeOffset ExpiresIn { get; set; } = DateTimeOffset.MinValue; /// <summary> /// 從Identity Server獲取的用戶信息 /// </summary> public string UserId { get; set; } public string Username { get; set; } public string UserRole { get; set; } public override string ToString() => string.IsNullOrWhiteSpace(Username) ? "沒有登錄用戶" : $"用戶[{Username}], 有效期[{ExpiresIn}]"; } /// <summary> /// 登錄用戶管理器 /// </summary> public class LoginUserManager { /// <summary> /// 登錄用戶信息 /// </summary> public LoginUserInfo UserInfo { get; private set; } /// <summary> /// 登錄用戶信息文件名 /// </summary> public const string UserInfoFilename = "userinfo.json"; private readonly string UserInfoFilePath; public LoginUserManager() { //默認文件目錄是C:\Windows\system32,報錯 //System.UnauthorizedAccessException //Access to the path 'C:\Windows\system32\userinfo.json' is denied. //Environment.CurrentDirectory=C:\Windows\system32 UserInfoFilePath = Path.Combine(AppContext.BaseDirectory, UserInfoFilename); if (File.Exists(UserInfoFilePath)) { //如果已經存在登錄用戶文件,加載登錄用戶信息 string userInfoJson = File.ReadAllText(UserInfoFilePath); UserInfo = JsonConvert.DeserializeObject<LoginUserInfo>(userInfoJson); if (UserInfo.ExpiresIn < DateTimeOffset.Now) { //如果登錄信息已經過期,清除登錄用戶信息 UserInfo = new LoginUserInfo(); //刪除登錄用戶信息文件 File.Delete(UserInfoFilePath); } } else { //如果沒有登錄用戶文件,新建登錄用戶信息 UserInfo = new LoginUserInfo(); } //Console.WriteLine輸出看不到? Debug.WriteLine($"{DateTimeOffset.Now}, 初始化登錄用戶信息: {UserInfo}"); } /// <summary> /// 用戶是否已經登錄? /// </summary> public bool IsAuthenticated => !string.IsNullOrWhiteSpace(UserInfo.Username); /// <summary> /// 登錄,提取登錄用戶信息,並保存到文件 /// </summary> public void Login(TokenResponse tokenResponse, string userInfoJson) { //提取從Identity Server獲取的token結果 UserInfo.AccessToken = tokenResponse.AccessToken; UserInfo.RefreshToken = tokenResponse.RefreshToken; UserInfo.ExpiresIn = DateTimeOffset.Now.AddSeconds(tokenResponse.ExpiresIn); dynamic userInfo = JsonConvert.DeserializeObject(userInfoJson); //從Identity Server獲取的用戶信息 UserInfo.UserId = $"{userInfo.sub}"; //提取name UserInfo.Username = $"{userInfo.name}"; //提取角色 //id4返回的角色是字符串數組或者字符串 UserInfo.UserRole = $"{userInfo.role}"; File.WriteAllText(UserInfoFilePath, JsonConvert.SerializeObject(UserInfo)); Debug.WriteLine($"{DateTimeOffset.Now}, 用戶登錄: {UserInfo}"); } /// <summary> /// 退出登錄 /// </summary> public void Logout() { string userName = UserInfo.Username; //清除登錄用戶信息 UserInfo = new LoginUserInfo(); //刪除登錄用戶信息文件 File.Delete(UserInfoFilePath); Debug.WriteLine($"{DateTimeOffset.Now}, 用戶退出登錄: {userName}"); } }
注冊服務到容器
MauiProgram提供了跟Asp.Net Core Web項目類似的服務容器注冊框架,在這里可以注冊自己的服務,非常熟悉的味道,客戶端和服務端開發體驗如此接近。
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(); builder.Services.AddSingleton<WeatherForecastService>(); builder.Services.AddSingleton<LoginUserManager>(); //NuGet安裝Microsoft.Extensions.Http //訪問Identity Server 4服務器的HttpClient builder.Services.AddHttpClient<Ids4Client>() .ConfigureHttpClient(c => c.BaseAddress = new Uri("http://localhost:5000")); return builder.Build(); } }
添加登錄razor網頁
把PhoneCodeLogin.razor復制到本項目,稍微修改一下,主要登錄成功后,不再進行MVC控制器跳轉,而是調用LoginUserManager提取用戶信息,保存到文件,然后直接跳轉回主頁。
@page "/phonecodelogin" @using BlaMauiApp.Data <div class="card" style="width:500px"> <div class="card-header"> <h5> 手機驗證碼登錄 </h5> </div> <div class="card-body"> <div class="form-group form-inline"> <label for="PhoneNumber" class="control-label">手機號</label> <input id="PhoneNumber" @bind="PhoneNumber" class="form-control" placeholder="請輸入手機號" /> </div> <div class="form-group form-inline"> <label for="VerificationCode" class="control-label">驗證碼</label> <input id="VerificationCode" @bind="VerificationCode" class="form-control" placeholder="請輸入驗證碼" /> @if (CanGetVerificationCode) { <button type="button" class="btn btn-link" @onclick="GetVerificationCode"> 獲取驗證碼 </button> } else { <label>@GetVerificationCodeMsg</label> } </div> </div> <div class="card-footer"> <button type="button" class="btn btn-primary" @onclick="Login"> 登錄 </button> </div> </div> @code { [Inject] private Ids4Client ids4Client { get; set; } [Inject] private NavigationManager navigationManager { get; set; } [Inject] private LoginUserManager loginUserManager { get; set; } private string PhoneNumber; private string VerificationCode; //獲取驗證碼按鈕當前狀態 private bool CanGetVerificationCode = true; private string GetVerificationCodeMsg; //獲取驗證碼 private async void GetVerificationCode() { if (CanGetVerificationCode) { //發送驗證碼到手機號 string result = await ids4Client.SendPhoneCodeAsync(PhoneNumber); if (result != "發送驗證碼成功") return; CanGetVerificationCode = false; //1分鍾倒計時 for (int i = 60; i >= 0; i--) { GetVerificationCodeMsg = $"獲取驗證碼({i})"; await Task.Delay(1000); //通知頁面更新 StateHasChanged(); } CanGetVerificationCode = true; //通知頁面更新 StateHasChanged(); } } //登錄 private async void Login() { //手機驗證碼登錄 var (tokenResponse, userInfoJson) = await ids4Client.PhoneCodeLogin(PhoneNumber, VerificationCode); //登錄 loginUserManager.Login(tokenResponse, userInfoJson); //跳轉回主頁 navigationManager.NavigateTo("/"); } }
在主頁Index.razor顯示登錄用戶信息
@page "/" @using BlaMauiApp.Data @inject LoginUserManager loginUserManager <h1>Hello, world!</h1> Welcome to your new app. <SurveyPrompt Title="How is Blazor working for you?" /> @if (isAuthenticated) { <p>您已經登錄</p> <div class="card"> <div class="card-header"> <h2>用戶信息</h2> </div> <div class="card-body"> <dl> <dt>AccessToken</dt> <dd>@userInfo.AccessToken</dd> <dt>RefreshToken</dt> <dd>@userInfo.RefreshToken</dd> <dt>ExpiresIn</dt> <dd>@userInfo.ExpiresIn</dd> <dt>UserId</dt> <dd>@userInfo.UserId</dd> <dt>Username</dt> <dd>@userInfo.Username</dd> <dt>UserRole</dt> <dd>@userInfo.UserRole</dd> </dl> </div> </div> <button class="nav-link" @onclick="Logout">退出登錄</button> } else { <p>您還沒有登錄,請先登錄</p> <a class="nav-link" href="phonecodelogin">登錄</a> } @code { private bool isAuthenticated => loginUserManager.IsAuthenticated; private LoginUserInfo userInfo => loginUserManager.UserInfo; private void Logout() { loginUserManager.Logout(); StateHasChanged(); } }
測試
然后可以測試一下,同時運行AspNetId4Web認證服務器和BlaMauiApp客戶端項目,輸入種子數據的手機號獲取驗證碼,一切跟Blazor Server項目極其相似,代碼高度共享,非常滑溜。
切換到安卓項目
注意要在安裝VS2022時勾選了桌面應用和移動應用工作負載,並且在安卓SDK管理器安裝API 31
安裝JDK 11,參考官網介紹:
.NET MAUI installation - .NET MAUI | Microsoft Docs
.NET MAUI requires the Android 12 (API 31) SDK for development. Install the following items:
While Visual Studio installs a version of Microsoft OpenJDK, you need to install Microsoft OpenJDK 11, available from the OpenJDK page. When installing OpenJDK 11, use the default installation configuration settings. After installing OpenJDK 11, Visual Studio should automatically consume it. However, if it doesn't, set the path to the OpenJDK install in the Tools > Options > Xamarin > Android Settings > Java Development Kit Location field.
在這里可以下載Win10的jdk 11的安裝包。
https://aka.ms/download-jdk/microsoft-jdk-11.0.12.7.1-windows-x64.msi
裝完看一下VS2022的配置,默認已經是最新的
把BlaMauiApp項目csproj文件的平台改為安卓,保存csproj文件,根據提示重新加載項目,還要關閉VS2022重新打開項目,在運行菜單才能看到安卓模擬器。
<TargetFrameworks>net6.0-android</TargetFrameworks>
安卓客戶端默認策略是不允許訪問http網站的,會報錯Cleartext HTTP traffic to localhost not permitted,如果改為https仍然會報錯java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.,為了省事就修改安卓AndroidManifest.xml文件,添加android:usesCleartextTraffic="true",允許訪問http網站。安卓模擬器訪問宿主機的IP是10.0.2.2,不是127.0.0.1,
//訪問Identity Server 4服務器的HttpClient builder.Services.AddHttpClient<Ids4Client>() //.ConfigureHttpClient(c => c.BaseAddress = new Uri("http://localhost:5000"));//Windows調試 .ConfigureHttpClient(c => c.BaseAddress = new Uri("http://10.0.2.2:5000"));//安卓模擬器,AndroidManifest.xml要添加android:usesCleartextTraffic="true"支持訪問http網站
用戶信息文件也不能保存在當前運行目錄下面了,報錯Read-only file system : '/userinfo.json'
//FileSystem.AppDataDirectory=/data/user/0/com.companyname.BlaMauiApp/files/userinfo.json UserInfoFilePath = Path.Combine(FileSystem.AppDataDirectory, UserInfoFilename);
同時運行Identity Server認證服務器項目和BlaMauiApp項目,可以實現登錄功能,效果類似桌面客戶端。
DEMO代碼地址:https://gitee.com/woodsun/blzid4
問題
我想測試一下Zxing掃碼,它依賴了Xamarin.Forms類庫,編譯報錯,這個問題影響很大,很多主流類庫都要升級才能使用,要等待整個生態圈逐漸成熟。
安卓項目呈現的效果也是網站,主流APP都是底部導航風格,雖然用Html組件也可以搞APP風格,但是畢竟要寫很多代碼,希望盡快能有Xamarin.Forms Shell這樣的APP模板。
還有登錄狀態的管理問題,對於Web項目來說已經高度成熟,但是客戶端不合適用cookies,HttpContext,這些是項目的基礎設施,希望有高集成度的解決方案。
回顧與展望
現在.NET MAUI Blazor仍然處於預覽狀態,官方宣稱2022年第二季度發布,但願別延期了,不然真的黃花菜都涼了。
微軟當年憑借Windows和IE壟斷地位強推私有標准的Xaml和Silverlight等前端技術,結果被開放標准的HTML5和開源庫生態圈打得一敗塗地,也讓.NET平台在雲計算和移動互聯網時代淪落到第三世界的處境。后來微軟在來自第三世界的印度CEO領導下,走上了改革開放的道路,開源了NET core,服務端各種技術搞得風生水起,但是客戶端Winform、WPF、UWP各有各的毛病,沒有一個能跟Html生態圈對打的。現在MAUI Blazor把客戶端帶入了Html大家庭,這也是正確的選擇,打不過你就加入你。希望這個技術能蓬勃發展,讓.NET開發者早日用上Html生態圈各種成熟組件,提高開發效率。這么多年說來說去,可視化、跨平台、開源、一次編寫到處運行,說到底還是為了提高效率,.NET技術棧的優勢就是要提高生產力,不然憑什么立足呢。