Porting WPF to AvaloniaUI

I’ve just finished a very large piece of work on one of my products. I desperately needed to have a MacOS offering and so I undertook an exercise of looking at all the cross-platform UI framework options.

Anyone coming to this task from a .NET point of view now has a bewildering array of choices, all with their own set of foibles and caveats. Invariably all of them started with a set of primary platform flavours. For Xamarin Forms, that was mobile. For Uno, that was mobile. There is NoesisGUI, Unity, MAUI, Flutter and Blazor. The list keeps growing. When I think I’ve got to the end of that list, I find another one. You get the picture.

Nearly all of them claim to support a multitude of platforms, some even cross compiling from desktop to mobile and web. But really, in my experience so far there isn’t one panacea for this task and to be fair, many of them will admit these features are early days and not production ready. Anyway, my point is they all have strengths and weaknesses, so choose wisely my young padwan!

Since my product is inherently desktop, the primary two platforms are Windows and MacOS with Linux coming in a close third. After a lot of assessment it became clearer and clearer that AvaloniaUI was obvious choice.

What is AvaloniaUI?

When I first started building my product back in 2008 (yes I feel old) the only option I had was WPF. I didn’t want to start a WinForms app as, even at that point in time, WinForms was already feeling a little old (mature?). It was only a short time after getting the first few versions out that I stumbled across AvaloniaUI, possibly as long as 10 years ago, I forget. All I remember thinking was, “it can’t be production ready” (I was right) and “meh, cross platform never works properly”.

Fast forward 10(ish) years to 2022 and wow, what a difference. I had a play with AvaloniaUI and was blown away by the progress that had been made. JetBrains are now also using it and it’s part of the .NET Foundation.

I dived in.

So WHAT IS IT?

It’s a cross platform (Win, OSX, Linux, Mobile and Web) UI framework that works best on desktop and is getting better and better on mobile and other platforms, that as far as I can tell, actually bloody works.

Yes, it’s YET ANOTHER flavour of XAML but it is incredibly close to WPF. So close in fact, that I was largely able to copy and paste huge gluts of XAML and then tweak namespaces. Behaviours needed work, more advanced and complex bindings have needed a little work or reorganisation but it’s been largely compatible. Amazing.

In fact in some instances where I needed behaviours in WPF, I can now simplify in AvaloniaUI.

Porting WPF to AvaloniaUI – Developer Experience

So what was it actually like?

Honestly? It was a joy.

Pardon?

Yeah, A joy. Let me explain.

Imagine a WPF application started in 2008 having gone from .NET3.5 to 4.8 over that time. Several implementations of the data layer. From MySQL – SQL Compact – SQLite. A Windows Installer project, to Inno Setup to WiX and then finally Advanced Installer.

Also over that time the build process had gone from TeamCity and MSBuild projects to my own self-hosted GitLab running on my Synology using YAML scripts and runners on Windows and MacOS build runners.

Countless test harness console applications. I’ve gone from using MBUnit and RhinoMocks to NUnit finally to xUnit and Moq. Suffice to say that the repository and projects and solutions have gone through a hell of a lot of change in that time. Even with the best will in the world, repos can get untidy and gain baggage over 14 years.

The Upgrade Process

I started by attempting to upgrade the solution in place, to .NET 6.0. I pumped the entire solution through the Upgrade Assistant. I eventually got it working. With some more tidying I got it compiling but it wouldn’t run. I was started to feel the same way at the end of day one as I had the last time, which you can read about here.

On day two I decided to reassess things. Day one of this exercise just reminded me of attempting to switch to .NET 5. I also realised that my plan to switch from WPF to AvaloniaUI in that same repository and application was probably madness too.

So I bit the bullet and decided that given all that I’ve detailed above. A new world was awaiting. Filled with empty, clean repositories devoid of baggage and no files that I can’t remember creating … and unicorns. Woo!

So basically, I gave up on the upgrade process. I think the potential time sink of upgrading in place has to be carefully assessed against a full on port. There are a LOT of differences between .NET Framework and .NET 6. A lot of very basic differences that converge on assumption/convention. Directory probing for instance. Still isn’t really a thing. What was a simple list of directories in a config file setting is now a massive exercise to move away from a platform default of a flat file structure. Yes, there are packages to aid rewriting the runtime json files prior to deployment but that is yet more complication in the build and deployment process and deviation from deployment defaults.

Almost feels like ASP.NET is driving more of the .NET 6 architecture than desktop is … anyway.

Deployment on Windows continues to be a huge pain in the ass, silly, convoluted and awkward. Shock horror.

I decided to port.

Porting WPF to AvaloniaUI

So I fired up a repo, shoved in some projects. Set-up some pipelines, a YAML build script and rolled up my sleeves.

Thinking to myself …

This’ll be 2 and a half months I reckon … sigh

Let me just say that taking this route was a concern over the fact that a lot of bugs and system foibles have been found and squished over the years. With any kind of porting exercise you can reintroduce old bugs or you risk working code becoming unstable due to external factors. Changes in behaviour of newer dependencies etc. Porting working code doesn’t mean it’ll still work after it’s ported. I have a lot of interaction with hardware going on. Audio drivers, external inputs like MIDI and lots of disk access. It’s a risky endeavour …

I had the basic shell of the application up and running within a couple of days. Compiling and running on Windows and MacOS.

I ported over external dependencies, required native libraries and getting the various bit of the build process working.

I then started porting over my code. Working methodically through each project, only pulling over code as it was required. It really is surprising how much was dead weight. Even being disciplined over 14 years and countless tech stacks, dross collects and things get furry.

All in all I would say 75-80% of my code hasn’t changed at all. The stuff that really defines the functionality and behaviour.

Here’s the real kicker though. Even the majority of XAML hasn’t changed. THAT is a big win and was one of the deciding factors in choosing AvaloniaUI in the first place.

The biggest differences are things like better styling and data binding in AvaloniaUI, slightly different syntax in places. Grid columns and rows for instance or the ‘$parent’ style bindings. But largely, I dropped XAML in and it worked.

Amazing.

Porting Custom Controls to AvaloniaUI

I had developed several complex custom controls in WPF and had some pretty low level rendering tasks too. I was kinda dreading getting to these bits of work.

One particular control was based around a lot of text editing in a RichTextBox. The original control had taken about a month to really nail. Catching all the edge cases and plugging holes in the logic. I think the last bug reported by a user on this was probably 2 years ago, 4 years after the first product release with this feature.

This new AvaloniaUI version is based on AvaloniaEdit. I built it in a day. It already feels more performant and stable than the current implementation I have in WPF … staggering.

System.Drawing

This is probably one of the biggest .NET 6 changes for people with a desktop focus. The low level rendering tasks I had to port where all based on System.Drawing. In .NET6 System.Drawing was marked as being not cross-platform compatible. So at runtime you get TypeInitialization exceptions thrown.

So what’s the story here?

Turns out there is a build switch that prevents these runtime exceptions. The truth is, it’ll probably carry on working unless you’re doing some really crazy things. The issue is that Microsoft no longer considers System.Drawing as being suitable for non-Windows platforms. Not surprising really since it has direct dependencies on Windows. So they are opting to flick this switch and confirm their intention to not commit to making it cross platform. When things like SkiaSharp exist, I don’t blame them.

Fortunately, AvaloniaUI is based on Skia. which has a very, very closely matching API to System.Drawing. Within 4 hours I had all my low level rendering working using SkiaSharp directly.

So, no issues here either …

Remember I said it was a joy …

Platform Specifics

One of the obvious and biggest issues with going from a single platform to multiples is the largely safe assumptions expressed in your code. Even well structured, considered and defensive code will require changes. NTFS != APFS …

In order to get this working well you obviously need to write solid code and unit test it, but you also at times need to do very different things. There are many ways to do this and there are a couple of examples below:

RuntimeInformation

I’ve used this in a couple of places which works fine but can at times add in a lot of indentation and branching in your code, so use judiciously …

RuntimeInformation.IsOSPlatform(OSPlatform.OSX)

My preferred way of handling this is defining build constants. If you include the following code in your project file:

<IsWindows Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true'">true</IsWindows>
<IsOSX Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true'">true</IsOSX>
<IsLinux Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true'">true</IsLinux>
...
<PropertyGroup Condition="'$(IsWindows)'=='true'">
    <DefineConstants>Windows</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(IsOSX)'=='true'">
    <DefineConstants>OSX</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(IsLinux)'=='true'">
    <DefineConstants>Linux</DefineConstants>
</PropertyGroup>

This then allows you to express these differences clearer in code, like:

#if Windows
    var myVar = "some windows specific thing";
#endif
#if OSX
    var myVar = "some OSX specific thing";
#endif
#if Linux
    var myVar = "some Linux specific thing";
#endif

Much more expressive and obvious.

For my unit testing approach I did something similar. I’m using xUnit and have created a suite of unit tests but some are platform specific. You can handle this in various ways but my preferred way is create custom test attributes. Like this:

public sealed class FactOsxAttribute : FactAttribute
{
    public FactOsxAttribute()
    {
        if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
        {
            Skip = "Not running on Osx";
        }
    }
}

This allows you to decorate a test:

[FactOsx]
public void This_Test_Only_Runs_On_Osx
{
    ...
}

Tidy …

Build and Deployment

I started porting the build process pretty early on. Everything runs on my own GitLab instance with runners on Windows and MacOS boxes. A single build script to perform debug builds, unit test runs and publishing.

The build processes are all pretty standard. The window application goes through the standard dotnet build commands to  build debug -> unit test -> apply version numbers to assemblies and then publish. The publish does a normal dotnet publish with a runtime switch and as self-contained. It gathers all non referenced platform specific dependencies and then packages things up into a bootstrapped executable installer using Advanced Installer. This is then signed with my Windows developer SSL certificate and shoved into my build artifacts.

The MacOS version is built on an M1 mac mini running a GitLab runner. It goes through the same processes as the windows application, build debug, unit test and then publish. This time using dotnet-bundle to produce the MyApp.app folder structure, this is then signed with my various Apple certs and packaged up into an installable .dmg disk image and stored in build artifacts.

So far, the only gap between the two build processes is obfuscation. My Babel.For application seems incapable of not breaking an AvaloniaUI application. Searching for a replacement hasn’t turned up any obfuscators that claim to support AvaloniaUI. So at the moment, this gap remains.

Summary

Everything is just working! Woo!

*ahem*

So … all in all. What I thought was going to take around 2.5 months has been completed in less than 1 month. I made the first new AvaloniaUI repository commit on the 25 May 2022, I’ve just completed the first build that produced all the application artifacts on the 23rd June 2022.

Not bad …

In terms of the tech itself I really didn’t hit any major hurdles at all. Any hurdles I did encounter turned out to be either incorrect assumption on my part, getting my head around a new piece of tech to implement or inherent differences in platforms or the CLR itself.

I definitely, 110% recommend looking at AvaloniaUI for cross-platform .NET desktop development. In fact, I can’t see me ever using WPF again unless for a client.

I have to praise all the developers and contributors to AvaloniaUI. You guys have done an absolutely stellar job. I believe overall I now have a “better” application. The UI performance feels better overall. The only area I have concern is the performance in the DataGrid with large collections. But obviously don’t judge performance in Debug mode. The jump in performance of the DataGrid between debug builds and release builds seems huge. Whereas the differences in WPF aren’t nearly as pronounced. I have one column with a templated control in and I’m currently on the fence as to whether it remains in the application or not as it has a large impact on this performance.

Styling is vastly superior over WPF. Almost CSS like. I found that it massively reduced the amount of XAML required which is a very good thing.

Also dependency properties are less bloody confusing and verbose too.

The only binding scenarios I had trouble with were around RelativeSource bindings. Despite the documentation saying that this syntax works as per WPF, I’ll be damned if I could get *any* of them working. Taking bindings directly from WPF that work would completely fail in AvaloniaUI. BUT in it’s defence I always felt like they were a code smell in WPF. Yes you could do it, and people did. Hell I did, but I always felt dirty doing it. There are invariably ways to re-architect objects into a shape where simpler standard bindings will work just fine.

Basically, AvaloniaUI FTW! Love it.

Check it out. NOW!

AvaloniaUI

Image borrowed from https://dotnetcore.show/ 🙂

Leave a Reply

Your email address will not be published.

*

This site uses Akismet to reduce spam. Learn how your comment data is processed.