Non-Trivial Multi-Targeting with .NET

Brad Robinson
11 min readSep 24, 2018

--

With .NET Core it’s now possible to develop .NET code that runs on Windows, Unix and macOS. For many projects it’s simply a matter of updating the TargetFrameworks setting to list out the frameworks you’d like to support.

For other projects things aren’t so simple. What happens when the project has a vastly different implementation for Windows vs OSX, includes a variety of sub-project types, can’t rely on .NET Standard and has different runtime component installation requirements on each platform?

This article looks at:

  • How to setup the csproj files so they build across all platforms.
  • An elegant approach to separate out platform specific code without conditional compilation or per-platform implementation classes or interfaces while at the same time ensuring a consistent public API.
  • Work arounds for a couple of annoying issues in the development tools.
  • How to bundle it all up into a NuGet package that works across all the supported platforms.

Background

When I re-wrote my music performance software (Cantabile) several years ago, one of deliberate design choices was to constrain all the platform specific UI code to a single project.

That project (rather unimaginatively named “GuiKit”) was originally developed targeting just Windows with the plan to port it to OS X using Mono at a later date.

Cantabile is a music workstation for live performing musicians. The entire UI is built using GuiKit.

Before I got to the Mono port however, Microsoft serendipitously released .NET Core and I set about working on an .NET Core based OS X port. During the early .NET Core releases things weren’t quite where I needed them but it’s now more than capable and I’ve had GuiKit running on macOS for a while now — but only as a development prototype.

The time finally came to move GuiKit out of Cantabile’s code repository to its own multi-targeted NuGet package.

Requirements

Before getting into the details, here’s a little background info on GuiKit and the requirements of what I’m trying to do here...

  • GuiKit has no external native code dependencies — it’s strictly .NET code (including some unsafe code).
  • On Windows it uses P/Invoke to directly call the Win32 API.
  • On macOS is uses a custom Objective-C interop layer to talk to Cocoa.
  • The Objective-C interop layer uses MethodBuilder to implement Obj-C implementation proxies. Since MethodBuilder is outside the scope of .NET Standard, that isn’t an option here (unfortunately).
  • The Objective-C interop layer lives in its own assembly that only needs to be installed on OS X (ie: it’s not required on Windows).
  • The project has four build configurations — DebugWin, ReleaseWin, DebugOsx and ReleaseOsx.
  • The Windows version must target .NET Framework (net461). Ideally the Windows version should also be targetable to .NET Core — but this isn’t hard requirement.
  • The OS X version only needs to target .NET Core
  • GuiKit has a vastly different implementation on Windows vs OS X. We’re talking about hundreds of classes each with platform specific implementations so the odd #if/#endif here and there isn’t going to cut it.
  • Consistent API across platforms. As far as GuiKit clients are concerned its API is identical across all platforms. This is an important restriction when it comes to packaging and deployment.
  • A small subset of GuiKit primitive types can be built against .NET Standard so aside from the main GuiKit project there’s also a sibling GuiKit.Primitives assembly.
  • GuiKit depends on one external NuGet package (SkiaSharp) so any solution needs to work well with NuGet.
  • The final solution needs to work well both while developing GuiKit itself and when working on clients that are dependant upon it. This means everything works as expected in Visual Studio 2017 on Windows and in either Visual Studio for Mac and/or JetBrains Rider on OS X.
  • Ideally the final NuGet package must be buildable on at least Windows with ability to build it on OS X would be a bonus.

That’s quite a laundry list of requirements and you can see why the typical simple examples don’t really cover what I’m trying to do here.

The Sample Project

Since GuiKit is closed source (I just don’t have the time to support a public release of it) I’ve put together a sample project the mimics all the requirements and demonstrates everything covered in this article.

MultiTargetTest is available here and includes three projects:

  • MainLibrary project — the main library we’re trying to deliver to the client.
  • OsxOnlyLibrary project — represents the Objective-C interop library from GuiKit and as its name suggests is only required on OS X.
  • NetStandardLibrary project — represents GuiKit.Primitives, a .NET Standard project.

Setting Up The Project Files

The first thing you’ll quickly realise about setting up a project like this is you’ll be working directly with the csproj files. Forget about using Visual Studio Project Properties — that just isn’t going to provide the flexibility we need.

Fortunately, the new-style csproj files are very minimal and really easy to work with — in fact I prefer them to editing settings via GUI pages.

To start with the NetStandardLibrary.csproj is trivial:

OsxOnlyLibrary is similar, but we target netcoreapp2.0 instead:

MainLibrary.csproj is more complicated so I’ll describe it in parts.

Firstly it includes the four project configurations DebugWin, ReleaseWin, DebugOsx and ReleaseOsx and uses a conditional TargetFramework element to target net461 on Windows and netcoreapp2.0 on OS X:

Note the use of TargetFramework (singular) instead of TargetFrameworks (plural). This is important as Visual Studio 2017 doesn’t handle conditions on the plural version (see this fault).

Unfortunately this breaks our requirement to build a Windows version targeting .NET Framework and .NET Core. Luckily the dotnet build tool doesn’t suffer the same issue so for development in VS2017 we’ll just work with net461, but for the final build and packaging we’ll use dotnet build and put in another conditional to re-enable the two target frameworks:

The above works because DOTNET_HOST_PATH isn’t defined in Visual Studio. When building with dotnet build it is, and we use it to clear the singular TargetFramework and replace it with the plural one targeting both frameworks.

Next we’ll setup some compile time symbols so we can conditionally include/exclude code if we need to:

And finally, we’ve got our NuGet package reference and references to the other two libraries. (Note the conditional reference to OsxOnlyLibrary)

Don’t forget to also edit the solution configurations and setup the same four configurations and map the projects to them appropriately.

If we go ahead and try to build now, we’ll find the build fails because even though the OsxOnlyLibrary is only required by the OS X builds, the Windows build fails because it still tries to build OsxOnlyLibrary targeting net461 (which it currently doesn’t support).

One way to fix this is to multi-target the OsxOnlyLibrary:

The build will now work on Windows, but on OS X net461 isn’t a valid target. We can try building just for the netcoreapp2.0 target:

dotnet build -c DebugOsx -f netcoreapp2.0

but this doesn’t work either because of NetStandardLibrary targets NetStandard, not NetCore.

/NetStandardLibrary/obj/project.assets.json' doesn't have a target for '.NETCoreApp,Version=v2.0'

The only solution I could find for this was to use similar conditions on the OsxOnlyLibrary and never build to a specific target framework.

The build now works for all configurations on Windows (dotnet build and Visual Studio) and the OS X configurations on OS X (command line and in Rider).

Unfortunately Visual Studio for Mac gets completely confused by this setup and marks all the solution configurations as invalid. As best I can tell this is because it only supports configurations named “Debug” and “Release”. I can live with this as I generally prefer Rider to VS Mac anyway.

Time to write some code…

A quick interlude… interested in reading about a super geeky side project? Checkout my series of articles on building Win3mu — a 16-bit Windows 3 emulator in C# (source code now available!).

Platform and Framework Specific Code

Now that we’ve got our projects setup and building correctly it’s time to start writing some platform and framework specific code.

For this example, I’m going to add two properties to the MainLibrary.Class1 class named “Platform” and “Framework” that simply return the name of the platform and framework the assembly was built for. This provides the opportunity to show two ways of implementing configuration specific settings and later we’ll build a little test program that confirms the correct assemblies are getting used at runtime.

The quick and dirty way to implement these properties is with conditional code using the symbol definitions we setup in the csproj above. Here’s the Framework property. Simple, but messy.

(I recommend using two #if clauses here in preference to a single #if/#else/#endif. By using two separate clauses you’re more like to see a build error later if you add support for another framework)

For framework specific code this is the approach I generally use. I’ve found in my projects at least that framework specific code is pretty rare.

Platform specific code however is a different matter. In GuiKit many public API entry points have platform specific implementations and using conditional code compilation quickly gets out of hand. The approach I prefer to use is partial classes.

To see how this works, create two sub-folders in the project named “PlatformWin” and “PlatformOsx” that will contain code specific to each platform. Next add two new Class1.cs files — one in each folder.

Now add the Platform property but this time instead of using conditional code we’ll simply call a method in the other half of the partial class that provides the actual implementation.

Now all we need to do is tell the build system to use the PlatformWin files for Windows builds and PlatformOsx files for OS X builds:

Although this approach is overkill for a simple property like this, when you have hundreds of API entry points all with different implementations:

  1. It keeps things far cleaner than littering your code with messy conditionals.
  2. It keeps the public API consistent and ensures that every public API has the appropriate platform specific implementation behind it. (eg: if you forgot to provide impl_GetPlatform in one of the platform specific classes, you’d get a build error)
  3. Code that’s common across both platforms can be placed in the main class file and implementation specific parts can go in the per-platform files.
  4. It doesn’t have the code or performance overhead of using per-platform implementation classes sitting behind facade classes.

Packaging with NuGet

Now that we’ve got our projects setup and building across all the required target frameworks and runtimes it’s time package it all up as a NuGet package.

The big catch here is that the documentation for this is rather sparse and doesn’t really describe a couple of key aspects about how it works. The trick is to understand three important folders in the NuGet package and how they’re used when the package is installed under .NET Framework vs .NET Core.

  • /lib/{tfm}
  • /ref/{tfm}
  • /runtimes/{rid}/lib/{tfm}

In these paths:

  • {tfm} stands for TargetFramework and should match the names in the csproj file eg: net461 and netcoreapp2.0
  • {rid} is the runtime identifier and can be any identifier from the runtime graph. See this blog post by Nate McMaster for a good explanation of this. For our purposes we just need win and osx for our two target operating systems.

Here’s how these folders should be used:

  • The /lib folder should be used for assemblies that for a particular framework, are the same on all run-times.
  • The /ref and /runtimes folder should be used when a particular .NET Core framework has different implementations for different run-times.
  • The /ref folder should contain any assemblies that define the API for the package and will be added as references to the target project. Think of these as compile time references.
  • The assemblies in the /ref folder don’t need to contain implementations of the API (they could all just throw not-implemented exceptions) however normally you’d just grab one of the run-time implementations and declare that as the API.
  • The /runtimes folder should contain the implementation assemblies for each .NET Core run-time. These files will be used when running or deploying the project.
  • The /runtimes folder can also contain additional assemblies that are required at runtime but don’t need to be visible to the client project at compile time. (eg: our OsxOnlyLibrary)
  • Adding assemblies to the /runtimes folder won’t cause them to be added as a reference to the target project. You must have an associated assembly in the /ref folder.

Putting all this together for our sample project:

  • The Windows/net461 assemblies need to go to /lib/net461 because .NET Framework projects don’t support the /runtimes folder. That’s fine because net461 is only valid on Windows anyway.
  • Pick any netcoreapp2.0 build of MainLibrary and NetSharedLibrary assemblies and put them in /ref/netcoreapp2.0. This will be the API available to all netcoreapp builds (regardless of platform) and causes the appropriate references to be added to the target project.
  • Put the netcoreapp2.0 implementation assemblies in the runtimes/{win|osx}/lib/netcoreapp2.0 folders. These will be used when the project is run or deployed for a particular OS.
  • The OsxOnlyLibrary is only needed at runtime (so it goes in the runtimes folder) and is only required on OSX (so it only goes in the /runtimes/osx/lib folder).

Two little gotchas:

  • For the .NET Core builds, don’t expect to see the run-time assemblies in the build output folder. Instead, .NET Core uses config files in the output folder to tell the run-time where to find the implementation assemblies from the NuGet cache.
  • Don’t forget that NuGet caches packages — if you’re making changes, rebuilding and not seeing any difference make sure you’re clocking the NuPkg version number.

API Consistency Is Important

I mentioned earlier that it’s important that each platform implementation conforms to the same public API. The reason for this should be obvious from the way the /ref folder is used above.

Because the assembly in the /ref folder is used at compile time for all .NET Core builds (regardless of runtime), whatever is available in this assembly is what’s available at build time — nothing else.

A Simple Build Script

Here’s a simple batch file that shows what needs to be done to build everything into a NuGet package:

It Works!

So just to prove it all works, there’s a sample client project in the NuTest2 sub-folder of the sample repo. It just prints out MainLibrary.Class1’s Framework and Runtime properties to confirm the correct version of the assembly is used in each run-time environment:

Running on Windows:

and on OS X:

Things That Could Help

I’ve mentioned a few deficiencies in the tooling here, that if fixed, could make this whole process easier:

  • The VS 2017 issue with conditional TargetFrameworks
  • When building a project with a conditional project reference, the imported project reference shouldn’t need to be built if the condition doesn’t require it.
  • When building for NetCore target frameworks, dotnet build should recognize and build assemblies targeted for netstandard as they’re compatible.
  • Visual Studio for Mac doesn’t support projects with configuration names other than Debug and Release
  • The NuGet documentation is sorely lacking in some areas. There’s no official mention of the /ref folder nor anything about the fact that /runtimes doesn’t appear to be supported for .NET Framework client projects.

None of this is complain! .NET Core is awesome and being able to develop in C# for OS X is amazing. I realize that what I’m doing here is far from the norm and the fact that it can be done is a tribute to everyone involved.

References

Thanks

This took quite a bit of effort to figure all this out — special thanks to David Fowler, Scott Hanselman and Nate McMaster for Twitter help :)

I hope you find this useful. If you’ve got suggestions for improvements or corrections, please let me know.

Looking for Something Else to Read?

Check out my Simplified Explanation of the “Meltdown” CPU Vulnerability.

--

--

Brad Robinson

Developer and designer of Cantabile — software for performing musicians (especially keyboard players). Music fanatic, tech geek, pro-science.