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 the 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.
Countless test harness console applications have come and gone. I’ve gone from using MBUnit and RhinoMocks to NUnit and finally to xUnit and Moq. Suffice to say that the repository, 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 starting 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 also.
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 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 in .NET5+. 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 deployment. 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 migrate through a full on 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 “tighter” 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, notarized and packaged up into an installable .dmg disk image and stored in build artefacts.
Obfuscation and AOT
So far, the only gap between the two build processes is obfuscation. My Babel.For application seems incapable of not breaking an AvaloniaUI application. Hardly a black mark against Babel.For but an issue that needs addressing for me none-the-less. Searching for a replacement hasn’t turned up any obfuscators that claim to support AvaloniaUI.
Obfuscation is an odd topic in .NET application development land. If you aren’t familiar with it, it’s a technique applied to compiled applications to help “protect” the source code. Normally compiled .NET applications are very easy to reverse engineer and gain at least most of the actual source code from the IL code. Obfuscation is a technique that can be applied to these compiled libraries to apply things like encryption, control flow changes and various other things in order to make this reverse engineering less successful. Even just describing it sounds like madness. Taking a working compiled application and essentially scrambling the internals. What could go wrong, right?
To be fair, I had much success applying it with Babel.For BUT, .NET 6+ has AOT.
So I have applied AOT compilation to my application in place of Obfuscation. AOT is Ahead-Of-Time compilation as opposed to Just-In-Time compilation. What this is doing is performing the actual CPU architecture specific compilation prior to deployment. This means you are no longer distributing IL code. This in turn means there is no easy route to reverse engineering IL code to get back to the source code.
AOT compilation is a long, slow process of trial and error as you work through all runtime issues. The AOT compilation toolset does a lot of work for you but there will be instances where you still need to give it hints about which things to include and which to link out. It’s worth it though. Removing obfuscation is a major win in my book.
Debugging AOT Applications
Debugging natively compiled applications can be tricky depending on the build set-up. If you’re producing a desktop GUI application with Trimming and Linking and in single executables etc, it’s almost certain that your AOT application wont run the first time. This is highly dependent on it’s complexity obviously. As you are figuring out any issues with a compiled application you’re gonna need to get a good handle on what’s happening.
Windows
On Windows you can compile your application and then attempt to launch the .exe file using Visual Studio. Just open an instance, add the executable and click start. Visual Studio will launch the executable and attach the debugger. You then be able to get useful error messages from any issues in the startup process. Often the Linker will be too aggressive and may have removed code that was actually required at runtime. This is essentially prompts for additions to your rd/roots.xml file for Windows.
Linux
On Linux this can be easily achieved with GDB. Firstly, ensure you have GDB installed:
sudo apt install gdb
And then execute the following commands:
cd yourexedirectory gdb -ex run ./yourexecutablefile
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 artefacts 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 assumptions 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 myself 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 the WPF DataGrid 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!
Image borrowed from https://dotnetcore.show/ 🙂
I really appreciate you taking the time to write such a comprehensive article about your porting journey with AvaloniaUI. I’m currently on the hunt for a UI framework to build C# apps on Linux and this article was super helpful.
Thank you!
I wouldn’t hesitate to recommend AvaloniaUI. I have a linux build and installer also working. No issues on Linux.
Very, very nice article jammer ! Glad You pointed it out to me !
A number of things, like the drawing on the canvas, I already figured out, but the RuntimeInformation part is very good to know !
I also intended to use Babel for obfuscation, but your AOT solution sounds like music in my ears, any good articles on that in your archive 🙂 . I tried AOT in DOTNET 3 or 3.1 and it was a disaster at that moment, but it was still very early days for AOT. To be honest, I forgot about it …
Five years ago, I had to finally pick my coding language for this project. It is massively (but really, really massively) parallel and must be stable, efficient (both memory and cpu), long-running and (be able to) run on pretty much anything out there. It is some sort of simulation software and it exists out of a designer part (desktop only, not heavy on the system) and the run-time part (all platforms, but desktop in particular due to the load on the system), name this latter one an engine or service.
I had these realistic options : ‘java + akka’, ‘c++ (QT) + CAF’ or ‘DotNet + akka.net + async/await’. At that time c++ was my first choice but CAF was not reliable enough and therefore not an option. So the solution had to be either java or DotNet. At that time my c# skills where a little rusty (last used and cursed it around 2005), but five years ago is the time DotNet Core was getting momentum and it promised to be better in a number of ways over java, so if a reliable UI could be found for cross-platform, the answer would be DotNet + akka.net + async/await.
Since avalonia (don’t remember what version it was, think it was 0.9) looked promising (and the others not, MAUI wasn’t even in the picture yet) and I knew developing the engine would take up years, I figured I had the time to learn it and give it the time to go to an official release version.
So I toke the risk and started programming in c# on DotNet 3. Another benefit is that my engine runs parallel with game engines and a number of the more common ones have c# (at least as an option) to program in, very few have java. So that was an extra bonus.
Well, the XAML/WPF/Avalonia ride has been the toughest one so far in my entire 30+ programming career ! I do not know if it is just me (getting old), but hell it toke me about half a year to start getting used to it. At some moments I really thought about switching to java (+javaFX) since I had positive experiences with it. But again the beauty/performance of DotNET Core (and all the work done) kept me coming back to avalonia/xaml/wpf land. Since fall last year, I must admit that I like avalonia a lot (when I know what to do with it) except for some weird stuff that shocked me a little (for instance in 0.10.18 – still the latest stable release I believe) there is no ‘textchanged’ event in a textbox ?!? That does not sound like a production ready product ! Luckily I followed a discussion on github indicating that the event flow would be reviewed for version 11, and indeed, it did.
The ride actually felt like learning a complicated new language to speak, like Chinese when having a teacher speaking some dialect … … the difference between WPF and Avalonia is for newbies sometimes more confusing than helping …
Thanks again for the nice article !
Thanks for the kind words.
I’m definitely planning an AOT article but it is such a massive topic. Have had plans to cover it in more detail in the future for sure. Babel is definitely one of the better obfuscators for .NET but the very concept of it is a turn-off at the best of times.
Your product sounds interesting. It isn’t Vortex by any chance is it?
.NET Core is a thing of beauty. Which later morphed into .NET 5, 6, 7+. I’m indeed waiting for the release of 11 for some key compatibility issues.
Looking forward to the AOT article !
No, it is not Vortex ! I hope to reveal it in the second half but I think it will be more like the end of the year and it is a concept I have been working on (full-time) for over 20 years. In essence it mimics creatures (people or animals – one can create in the ‘creator’ part and run in the engine part) extremely realistic (health, emotions, state of mind, thinking, behavior, etc) and it does this extremely efficient and based upon the world and the time-period it lives in.
People in the stone age acted differently on the same situation from those in the middle ages are even now. Women having their period or men being horny, hungry or angry (or all of these) have different behavior as well for instance. The knowledge is also different, so the resulting behavior has to be different as well.
Like in real life, if not stimulated, things get boring for example and lower the attention. In a game this reflects in guards being less attentive at moments for instance. Depending on the level of detail for the character, it can even think and learn in a very efficient way (it does not require the cloud as ChatGPT does 🙂 ) and it will feel guilty if it does something it could have avoided if it has empathy (a psycho- or sociopath does not for instance) … … and I do not know why it is currently not standard in game engines, but characters holding things (heavy weapon for instance) will have cramps in their arms depending on fitness, sex, age, duration of holding the load, etc. Realistic social interactions (not only for human but also for wolves, or other primates) depending on culture, type of personality, emotional state, etc are also part of the package !
And all this can run on a better desktop computer, parallel with the game engine (mostly GPU heavy). In short, the game engines represent the physical world, my engine the physics/health of the creatures and the different brains and their world view where, like for us, we run an interpretation of the world (unfortunately for a lot of us, science minded people, not facts but interpretations of facts), so an interpretation of the game world.
You name it, the weather, the environment, the situation, the creatures around, the events, etc all have an impact on the outcome. For us humans that is the same. For instance the trigger for aggressive behavior in young adult males is much lower on damp, hot summer evenings (especially when some (or a lot) of alcohol is consumed) then it is on ‘normal’ evenings. The suicide rate is much higher in the European Pyrenees during the Mistral (heavy winds). This is all incorporated.
Before you ask, I am a Mensa member and this was the challenge I needed to keep my mind calm and at peace. I did set this challenge at the year 2000 and I thought that it would take me 10, max 15 years to finish (optimistic as I am 🙂 ), but it will be far over 20 years and finally, I am confident that I should be able to deliver the tools and the engine at last quarter 1 2024. I have a solid background in a lot of fields (mechanics, electronics, informatics, biology, etc) and for instance my daughter is a clinical Psychologist and during here studies, she came to me for more explanation on fuzzy topics.
I’m not stating these things to ‘impress’, but what I claim is for most people something totally impossible (even to comprehend), and it is not for someone with the right mindset and knowledge.
One last thing, I’m not using trained AI models to achieve this, so every creature handles differently based upon all and that can be saved and started from the next time a simulation or game is loaded.
Whoa. The scope of your project sounds utterly enormous. Even just funding yourself for 20 years to do it!
Is there anything online about it or have you kept it all in the shadows until it’s baked?
Sounds seriously interesting.
Hi Jammer, it is indeed enormous and very interesting and so far I had no complaints on the funding part, lets keep it that way 🙂 .
If you look at https://www.artintelli.org/ you will find some things about it there, it is pretty heavy lecture and not for everyone and I’m the first to admit that I need to redo the layout and graphics one time in the future, but that is just cosmetics and not that important right now.
For your information, due to problems with Avalonia (if it had been a year later, and version 11 being stable, it would have been a different story), I decided to do Livinoid Studio in WPF (have been developing in both Avalonia and WPF simultaneously whole the time, so not much of a time loss there).
The debugger for Livinoid Engine will be done in Avalonia (need cross-platform) and I can wait a couple of months (after the summer) with that one, giving Avalonia 11 the time needed. The ‘production ready’ of avalonia is at this moment rather for smaller applications in my humble opinion, I’m not willing to bet on it fully right now.
Like you stated in your article, converting it afterwards, when found opportune, should be relatively easy and that is why I do it in WPF.
Around June I will post some screenshots of Avalonia Studio on the site and hopefully somewhere in November/December I can show a video demo of both Livinoid Studio and Livinoid Engine up and running in either Unity or Unreal 5.
Hope you enjoy the reading !
Cheers,
Geert
It’s gonna take some time to work through all of that stuff. Will definitely keep checking back. Very interesting.
I’m still curious about how you actually achieve code porting? I am not sure which WPF classes need to be converted to corresponding types for implementation on Avalonia, such as DependencyProperty ->AvaloniaProperty, and the specific details of porting on Behaviors types.
For me, the Avalonia team should also spend some time writing a tutorial on how to migrate WPF/UWP to Avalonia, so that more developers are willing to migrate their projects.
I honestly just wholesale dropped WPF XAML into the AXAML files and worked out the kinks from there. It really was a pretty painless process compared to how it could have gone.
Going through that process, it doesn’t take many views before you have replaced a lot of any custom WPF stuff you may have created in WPF anyway. From that point, migrating views becomes really quick.