A prototype (and design) of API minimalism for mobile.
If you like this idea, star for approval! Read on for details!
Simply install the template:
dotnet new install Spice.Templates
Create either a plain Spice project, or a hybrid "Spice+Blazor" project:
dotnet new spice
# Or if you want hybrid/web support
dotnet new spice-blazor
Or use the project template in Visual Studio:
Build it as you would for other .NET MAUI projects:
dotnet build
# To run on Android
dotnet build -f net8.0-android -t:Run
# To run on iOS
dotnet build -f net8.0-ios -t:Run
Of course, you can also just open the project in Visual Studio and hit F5.
In comparison to a dotnet new maui
project, I created a Spice
project with the same layouts and optimized settings for both project
types. (AndroidLinkMode=r8
, etc.)
App size of a single-architecture .apk
, built for android-arm64
:
The average startup time of 10 runs on a Pixel 5:
This gives you an idea of how much "stuff" is in .NET MAUI.
In some respects the above comparison isn't completely fair, as Spice
🌶 has very few features. However, Spice 🌶 is fully
trimmable, and so a Release
build of an app without
Spice.Button
will have the code for Spice.Button
trimmed away. It
will be quite difficult for .NET MAUI to become fully
trimmable -- due to the nature of XAML, data-binding, and
other System.Reflection usage in the framework.
In reviewing, many of the cool UI frameworks for mobile:
Looking at what apps look like today -- it seems like bunch of rigamarole to me. Can we build mobile applications without design patterns?
The idea is we could build apps in a simple way, in a similar vein as minimal APIs in ASP.NET Core but for mobile & maybe one day desktop:
public class App : Application
{
public App()
{
int count = 0;
var label = new Label
{
Text = "Hello, Spice 🌶",
};
var button = new Button
{
Text = "Click Me",
Clicked = _ => label.Text = $"Times: {++count}"
};
Main = new StackView { label, button };
}
}
These "view" types are mostly just POCOs.
Thus you can easily write unit tests in a vanilla net8.0
Xunit
project, such as:
[Fact]
public void Application()
{
var app = new App();
var label = (Label)app.Main.Children[0];
var button = (Button)app.Main.Children[1];
button.Clicked(button);
Assert.Equal("Times: 1", label.Text);
button.Clicked(button);
Assert.Equal("Times: 2", label.Text);
}
The above views in a net8.0
project are not real UI, while
net8.0-android
and net8.0-ios
projects get the full
implementations that actually do something on screen.
So for example, adding App
to the screen on Android:
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
SetContentView(new App());
}
And on iOS:
var vc = new UIViewController();
vc.View.AddSubview(new App());
Window.RootViewController = vc;
App
is a native view on both platforms. You just add it to an the
screen as you would any other control or view. This can be mix &
matched with regular iOS & Android UI because Spice 🌶 views are just
native views.
Currently, Blazor/Hybrid apps are strongly tied to .NET MAUI. The
implementation is basically working with the plumbing of the native
"web view" on each platform. So we could have implemented
BlazorWebView
to be used in "plain" dotnet new android
or
dotnet new ios
apps. For now, I've migrated some of the source code
from BlazorWebView
from .NET MAUI to Spice 🌶, making it available
as a new control:
public class App : Application
{
public App()
{
Main = new BlazorWebView
{
HostPage = "wwwroot/index.html",
RootComponents =
{
new RootComponent { Selector = "#app", ComponentType = typeof(Main) }
},
};
}
}
From here, you can write Index.razor
as the Blazor you know and love:
@page "/"
<h1>Hello, world!</h1>
Welcome to your new app.
To arrive at Blazor web content inside iOS/Android apps:
This setup might be particularly useful if you want web content to take full control of the screen with minimal native controls. No need for the app size / startup overhead of .NET MAUI if you don't actually have native content?
- No XAML. No DI. No MVVM. No MVC. No data-binding. No System.Reflection.
- Do we need these things?
- Target iOS & Android only to start.
- Implement only the simplest controls.
- The native platforms do their own layout.
- Document how to author custom controls.
- Leverage C# Hot Reload for fast development.
- Measure startup time & app size.
- Profit?
Benefits of this approach are full support for trimming and eventually NativeAOT if it comes to mobile one day. 😉
.NET MAUI is great. XAML is great. Think of this idea as a "mini" MAUI.
Spice 🌶 will even leverage various parts of .NET MAUI:
- The iOS and Android workloads for .NET.
- The .NET MAUI "Single Project" system.
- The .NET MAUI "Asset" system, aka Resizetizer.
- Microsoft.Maui.Graphics for primitives like
Color
.
And, of course, you should be able to use Microsoft.Maui.Essentials by
opting in with UseMauiEssentials=true
.
It is an achievement in itself that I was able to invent my own UI framework and pick and choose the pieces of .NET MAUI that made sense for my framework.
View
: maps toAndroid.Views.View
andUIKit.View
.Label
: maps toAndroid.Widget.TextView
andUIKit.UILabel
Button
: maps toAndroid.Widget.Button
andUIKit.UIButton
StackView
: maps toAndroid.Widget.LinearLayout
andUIKit.UIStackView
Image
: maps toAndroid.Widget.ImageView
andUIKit.UIImageView
Entry
: maps toAndroid.Widget.EditText
andUIKit.UITextField
WebView
: maps toAndroid.Webkit.WebView
andWebKit.WKWebView
BlazorWebView
extendsWebView
adding support for Blazor. Use thespice-blazor
template to get started.
Let's review an implementation for Image
.
First, you can write the cross-platform part for a vanilla net8.0
class library:
public partial class Image : View
{
[ObservableProperty]
string _source = "";
}
[ObservableProperty]
comes from the MVVM Community
Toolkit -- I made use of it for simplicity. It will
automatically generate various partial
methods,
INotifyPropertyChanged
, and a public
property named Source
.
We can implement the control on Android, such as:
public partial class Image
{
public static implicit operator ImageView(Image image) => image.NativeView;
public Image() : base(c => new ImageView(c)) { }
public new ImageView NativeView => (ImageView)_nativeView.Value;
partial void OnSourceChanged(string value)
{
// NOTE: the real implementation is in Java for performance reasons
var image = NativeView;
var context = image.Context;
int id = context!.Resources!.GetIdentifier(value, "drawable", context.PackageName);
if (id != 0)
{
image.SetImageResource(id);
}
}
}
This code takes the name of an image, and looks up a drawable with the
same name. This also leverages the .NET MAUI asset system, so a
spice.svg
can simply be loaded via new Image { Source = "spice" }
.
Lastly, the iOS implementation:
public partial class Image
{
public static implicit operator UIImageView(Image image) => image.NativeView;
public Image() : base(_ => new UIImageView { AutoresizingMask = UIViewAutoresizing.None }) { }
public new UIImageView NativeView => (UIImageView)_nativeView.Value;
partial void OnSourceChanged(string value) => NativeView.Image = UIImage.FromFile($"{value}.png");
}
This implementation is a bit simpler, all we have to do is call
UIImage.FromFile()
and make sure to append a .png
file extension
that the MAUI asset system generates.
Now, let's say you don't want to create a control from scratch. Imagine a "ghost button":
class GhostButton : Button
{
public GhostButton() => NativeView.Alpha = 0.5f;
}
In this case, the NativeView
property returns the underlying
Android.Widget.Button
or UIKit.Button
that both conveniently have
an Alpha
property that ranges from 0.0f to 1.0f. The same code
works on both platforms!
Imagine the APIs were different, you could instead do:
class GhostButton : Button
{
public GhostButton
{
#if ANDROID
NativeView.SomeAndroidAPI(0.5f);
#elif IOS
NativeView.SomeiOSAPI(0.5f);
#endif
}
}
Accessing the native views don't require any weird design patterns.
Just #if
as you please.
C# Hot Reload (in Visual Studio) works fine, as it does for vanilla .NET iOS/Android apps:
Note that this only works for Button.Clicked
because the method is
invoked when you click. If the method that was changed was already
run, something has to force it to run again.
MetadataUpdateHandler
is the solution to this problem, giving
frameworks a way to "reload themselves" for Hot Reload.
Unfortunately, MetadataUpdateHandler
does not currently work
for non-MAUI apps in Visual Studio 2022 17.5:
[assembly: System.Reflection.Metadata.MetadataUpdateHandler(typeof(HotReload))]
static class HotReload
{
static void UpdateApplication(Type[]? updatedTypes)
{
if (updatedTypes == null)
return;
foreach (var type in updatedTypes)
{
// Do something with the type
Console.WriteLine("UpdateApplication: " + type);
}
}
}
The above code works fine in a dotnet new maui
app, but not a
dotnet new spice
or dotnet new android
application.
And so we can't add proper functionality for reloading ctor
's of
Spice 🌶 views. The general idea is we could recreate the App
class and
replace the views on screen. We could also create Android activities
or iOS view controllers if necessary.
Hopefully, we can implement this for a future release of Visual Studio.