Il blog di Sandro Rizzetto

Share UI between web Blazor and MAUI desktop apps

 

L'uscita della versione 17.3 di Visual Studio ha finalmente portato la RTM di .NET MAUI, la tecnologia MS che permette di creare app native desktop e mobile per Windows, MacOS Catalyst, Android, iOS e Tizen (purtroppo niente Linux).

Uno dei due modi per creare queste app multi-platform è quello di utilizzare il mio amato Blazor (si parla infatti di .NET MAUI Blazor App, che è il tipo di progetto da prendere in Visual Studio, mentre le .NET MAUI App sono quelle in cui lavoreremo "alla Xamarin", ovvero con XAML e metodologie di sviluppo più vicine a WPF e Android nativo).

La prima cosa che mi sono chiesto è stata quindi "Ok, ma se ho già una web application fatta in Blazor, posso estrarre la UI e metterla a disposizione di quella MAUI?". La risposta è ovviamente positiva e concettualmente si tratta solo di metterla in una Razor Class Library che andremo a referenziare dai progetti Web e da quello MAUI. In aggiunta a ciò, è superfluo dire che avremo un'altra serie di progetti/classi che terremo comuni come entities (models), interfacce, servizi, helpers, ecc. oltra alle "solite" WebApi (minimal o MVC che dir si voglia) che fanno da fonte dati.

La cosa non è una novità assoluta, infatti già 2 anni orsono, per lo sviluppo di un software enterprise per l'Esercito italiano sviluppato in Blazor WASM, abbiamo usato questo "trucchetto" -suggerito da magister Cristian Civera- per sopperire ai limiti del debugger di WASM (soprattutto in .net 3.1 era inusabile); tenendo la UI in una RCL possiamo usare Blazor Server per sviluppare e debuggare e quando siamo pronti deployiamo la Blazor WASM.

Qui, in pratica la cosa è identica... Nei progetti singoli resta solo la index.html (o _layout.cshtml) che inizializzano script .js diversi (<script src="_framework/blazor.***.js"></script>) mentre tutti i Razor Component (sia le Pages che i Components veri e propri), i vari MainLayout, NavMenu, ecc. nonché css, immagini, script js, ecc. andranno nella RCL sharata e saranno presenti UNA SOLA VOLTA.

 

Ma come fanno i vari progetti a sapere di dover caricare il MainLayout della libreria ? Il trucco è andare a mettere in app.razor (o Main.razor nel prj Maui) al tag Router un attributo chiamato  AdditionalAssemblies a cui passeremo un array di assemblies, nel nostro caso solo un elemento che è quello della nostra RCL. Nulla ci vieterebbe di sostituire quello di default, ma così facendo ci perderemo la possibilità di avere Pages e Component "specifiche" per ogni singolo progetto

A questo punto, le nostre pages e i nostri componenti andranno un attimo modificati per farli caricare gli assets (css, images, script, ecc.) direttamente dalla libreria e non da path relativi o assoluti.

Useremo infatti la notazione "./_content_/NomeLibrary/path/file.ext" sia all'interno di componenti della library sia nei vari .html dei singoli progetti. Qui sotto ad esempio, l'<head> della index.html di Wasm e di Maui

Idem se un componente deve usare risorse della RCL avrà come sintassi una cosa tipo

<img src="./_content/Blazor.SharedUI.RCL/images/icon-192.png" />
forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("./_content/Blazor.SharedUI.RCL/sample-data/weather.json");

Differenziare i vari ambienti

Spesso in piccoli progetti dove non faccio Unit Test e dove consumo direttamente i servizi, mi viene voglia di fare a meno delle Interfacce, ma qui diventano utili se non essenziali per chiamare servizi che si differenziano da una platform all'altra. Nel mio progetto Demo (qui il repository su GitHub) ad esempio ho ipotizzato di voler avere una semplice stringa che ritornasse l'ambiente tramite un metodo di un servizio.

L'interfaccia IPlatformService avrà quindi la firma string GetPlatformInfo(); e nei vari progetti una diversa implementazione: nel caso di MAUI ci avvarremo della comoda classe DeviceInfo che si userà molto spesso, in questo caso facendo solamente un return DeviceInfo.Current.Platform.ToString();

Nel singolo progetto MAUI questa classe può essere utilizzato come "if" o "switch" per particolari differenze, come in questo caso il setting di un baseAddress

public static string BaseAddress = DeviceInfo.Platform == DevicePlatform.Android ? "http://10.0.2.2:5211" : "https://localhost:7211"; 
public static string TodoItemsUrl = $"{BaseAddress}/api/todoitems/";

Nei progetti MAUI sono disponibili però anche dei comodissimi #if di compilazione con le costanti delle varie platform, non solo quelle "generiche" come #if WINDOWS|ANDROID|MACCATALYST ma anche specifiche con il numero di versione (es. #if ANDROID28_0_OR_GREATER  oppure WINDOWS10_0_18362_0_OR_GREATER)

La cosa di prima quindi potrebbe essere scritta con:

#if ANDROID
builder.Services.AddSingleton(sp => new HttpClient { BaseAddress = new Uri("http://10.0.2.2:5211") });
#else
builder.Services.AddSingleton(sp => new HttpClient { BaseAddress = new Uri("https://localhost:7211") });
#endif

Nota IMPORTANTE: l'utilizzo di HttpClient in questo modo per le applicazione web è vivamente sconsigliato, e l'approccio giusto è quello di usare un IHttpClientFactory (vedi questo video di Nick Chapsas per maggiori dettagli). Essendo questo progetto una demo che non andrà mai in produzione, non mi sono preoccupato di performance o memory leak, ma voi fatelo!

Chiamate a servizi locali con l'Emulatore Android

Non c'entra nulla con l'argomento del post, ma visto che è la issue che mi ha fatto perdere più tempo di tutti in questo mini-progetto demo, lascio qui due note su come risolverlo. Perlomeno finché non sarà rilasciato il Port Tunnelling in Visual Studio attualmente in Private Preview.

Innanzitutto l'emulatore Android non accetta né la sintassi localhost, né quella 127.0.0.1; al suo interno ha un alias (10.0.2.2) che corrisponde alla macchina locale. Il secondo problema è che non digerisce il certificato ssl self-signed di Visual Studio, quindi dobbiamo dire al nostro servizio di non fare httpsredirection e dobbiamo utilizzare uno specifico attributo nella App Android. Ricapitolando:

1) Nel progetto MAUI, impostare il baseAddress del nostro HttpClient a 10.0.2.2 ad esempio utilizzando la sintassi esposta qui sopra (con l'#if o con DeviceInfo); può essere fatto ad esempio nel mauiprogram.cs

2) Nel File Platform/Android/MainApplication.cs utilizzare  [Application(UsesCleartextTraffic = true)] per permettere l'uso di http

#if DEBUG
[Application(UsesCleartextTraffic = true)]
#else
[Application]
#endif
public class MainApplication : MauiApplication
{...

3) Nella nostra webApi in program.cs, escludere UseHttpsRedirection

#if !DEBUG
app.UseHttpsRedirection();
#endif

Conclusioni

Poter creare applicazioni multi-device web,desktop,mobile scrivendo il codice solo una volta è sempre stato il sogno impossibile degli ultimi 10/15 anni. Ci siamo arrivati? Non ci giurerei. Potrò esprimermi meglio quando tra qualche giorno proverò a fare il porting di una mia webapp in Windows e Android e vedremo con un progetto vero cosa succederà. Di primo acchito direi che ci sarà da penare per la lentezza soprattutto con l'emulatore Android (scordiamoci hot-reload che tanto non funziona neanche per le applicazioni blazor vere). Stay Tuned per le prossime esperienze d'uso. Di sicuro però fino a qualche anno fa avere una situazione simile Web/desktop/mobile con UI e codice univoco era abbastanza utopico.

Aggiungi Commento

Copyright © 1997-2024 Sandro Rizzetto | All Rights Reserved | Riproduzione delle fotografie vietata | Powered by me