Multi-targeting the world: a single project to rule them all
Starting with Visual Studio 2017, you can now use a single project to build platform-specific libraries for all project types. This blog will explore why you might want to do this, how to do it and workarounds for some point-in-time issues with the tooling.
Contents
- Intro
- Multi-targeting vs. .NET Standard Libraries vs. PCL’s
- How to multi-target
- Gotcha’s
- Into the weeds, how it all works
- Looking forward
Intro
Since the beginning of .NET Core, the project.json
format has enabled multi-targeting, that is compiling to multiple target frameworks in parallel and creating an output for each. With ASP.NET Core, it’s common to target both net45
and netcoreapp1.0
so you can deploy the site to either the desktop framework, which runs on Windows, or to the CoreCLR, which runs cross-platform. Multi-targeting is nothing more than compiling the same code multiple times, once per target platform. Each target can specify its own dependencies and ifdef
‘s, so you can easily tailor the code to the specific platform.
Another example may have a library target netstandard1.0
, netstandard1.3
, and net45
to enable different levels of functionality based on the available surface area.
While it was also possible to target UWP, Win8, or profile-based PCL’s, using project.json
, doing so required hacks like private copies of all reference assemblies, WinMD files and more. Beyond that, some things didn’t work correctly as some platforms require additional targets to generate additional outputs like .pri
files on UWP for resource lookup. So while technically possible, full multi-targeting was brittle and required you to stay in a very narrow path, avoiding things like resources or GUI elements that require the full tool-chain to process.
Enter MSBuild
With the move to MSBuild as part of the .NET Core Tooling direction change, the picture gets much better, so much so that with VS 2017 RC2, you can correctly multi-target all platform types, including UWP, profile-based PCL’s, and Xamarin iOS/Android. Not only that, but by conditionally including/excluding directories based on globs, you can reduce the need for ifdef
‘s in many cases.
As part of being open sourced and enabled to run cross-platform, the build targets and tasks required to actually do the build were combined into an SDK. This went along with drastic simplification of the csproj
file to have a minimal footprint, that will get even smaller, like this:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp1.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NETCore.App" Version="1.0.1" />
</ItemGroup>
</Project>
Microsoft’s blog details all of the improvements in this area. For current lack of a better term, I’ll call projects based on these new tools “SDK style.” The easiest way to identify these “SDK style” projects is by looking for the Sdk
attribute in the top Project
element.
Multi-targeting vs. .NET Standard Libraries vs. PCL’s
Before we go further, let’s answer this question that many people have asked — why would you want to multi-target vs just use a single portable library, whether that’s .NET Standard or an older profile-based PCL?
There are several answers to that question — first, if your code can all fit within a single .NET Standard-based library, then there’s no reason to multi-target. If you’re using a legacy profile-based PCL, at the very least consider moving up to the equivalent .NET Standard version. Don’t make more work for yourself. The decision to multi-target falls out of a need to use functionality that doesn’t exist within a .NET Standard version or if you need to target an earlier platform that doesn’t support the .NET Standard version you need. A common example is that many libraries still need to support .NET 4.5. Despite a significant amount of functionality available in .NET Standard 1.3, that .NET Standard version only supports .NET 4.6+. Chances are though that the code would work “just fine” on .NET 4.5, so it’s easy to multi-target to both net45
and netstandard1.3
.
The other main reason why you’d need to multi-target is to use platform-specific code within your library. For example, on iOS you might want to use SecKeyChain
for saved credentials, on Android use its Context
to access shared services like preferences, and on Windows its Credential Manager. You might have a common method called GetCredential
that other code uses to get the data. Today you might use dependency injection or reflection to access a “.Platform” library with a specific implementation that your common code uses. Instead, you can choose to multi-target and access the platform code directly.
How to multi-target
Let me start by saying that the methods here are based on the new “SDK-style” projects that VS 2017 provides. They orchestrate using the existing project types that are installed by Visual Studio. As such, the build itself won’t work on a box without the other tools installed (so you’re building on a Windows box, much like you probably are today). Some of these may work on a Mac with Visual Studio for Mac but I have not tested that in any way. When you install Visual Studio 2017, make sure to install all of the tools for the project types you need (Xamarin, UWP, etc) and also the .NET Core Tooling.
There’s no UI in VS for adding additional target frameworks, but I have some samples that show what to do.
First, create a new .NET Core Class Library project. If you don’t see the following option, make sure to install the .NET Core workload in the VS Installer.
Right-click the project and select “Edit project file…”. This is new in VS 2017 – the ability to edit the project file while it’s open and have changes instantly reflected.
In the editor, after noticing how much less boilerplate code there is now, look for the TargetFramework
property that looks like this: <TargetFramework>netstandard1.3</TargetFramework>
property. Change that to <TargetFrameworks>netstandard1.3;net45</TargetFrameworks>
to target .NET 4.5 and NET Standard 1.3. You can add however many targets you want by adding to that semi-colon list. It’s subtle, but note the difference in property names between TargetFramework and TargetFrameworks with a plural. It’s easy to miss.
For some frameworks, like .NET 4.5, that’s all you need to do. However, targeting .NET Standard and .NET 4.x is far from “the world.” We can do better! You would think it should be as easy as adding additional TFM’s like uap10.0
, xamarin.ios10
or MonoAndroid70
to the list, and hopefully by the time the tools RTM it will be, but for now we need to add extra properties to the project file to tell MSBuild what to do with those.
Fortunately, and here’s the real secret, the “SDK-style” build system has a LanguageTargets
property that you can specify per TFM to import the targets for that project type instead of the vanilla Microsoft.CSharp.targets
import. That means we can use the “Windows Xaml”, Android, iOS, or any other platform tool-chain we need.
Xamarin Example
In the example here, I have a class library that multi-targets to net45
, uap10.0
, netstandard1.3
, Xamarin.iOS10
and MonoAndroid70
. In this contrived library, I have a Greeter
class that’s calling a Hello()
method that needs platform specific code. I’m using a pattern where I have a directory for each TFM where code in there only gets included there, so no ifdef
‘s are needed. For Android, Resources
are supported if you need them. While the example doesn’t currently use them, you could use PList
‘s, xib
‘s or Story Boards on iOS, Page
‘s on UWP, or any other “native” file type supported by the platform.
Win81/WP8/PCL/Wpa81/Xamarin/Net45 Example
As a more realistic example, one of my libraries, Zeroconf, an mDNS discovery library, targets “the world.” It currently has concrete implementations for wp8
, Wpa81
, Win8
, portable-Wpa81+Win81
, uap10.0
, net45
, and netstandard1.3
(which supports Xamarin and CoreCLR.) In addition to the the concrete implementations, it provides a netstandard1.0
façade to support being used in portable libraries. The different concrete implementations are required due to differences in the networking stacks between the various Windows networking stacks. For now, the uap10.0
version cannot use the netstandard1.3
version until NetworkInformation
is fully supported by the platform, so it continues to use the WinRT variant. You can see the platform-specific code in the platforms directory and then how they’re conditionally included by the csproj
in the ItemGroup
s
The property groups at the top contain the LanguageTargets
and properties needed. For portable-Wpa81+Win81
two extra items are required as the special PCL profile also supports WinRT. The ItemGroup
here has two TargetPlatform
to pull in the correct .winmd
references.
Building
You can build the libraries either in VS 2017 or the command-line. If you use the command line, you’ll want to run the following from a VS 2017 Developer Command Prompt: msbuild /t:restore
followed by msbuild /t:build
. If you want to create a NuGet package, you can run msbuild /t:pack
. It’s important to note that you must currently use msbuild
, the desktop version in the VS 2017 path, to build these and not dotnet build
. The reason is that while dotnet build
calls MSBuild
, it’s currently using a CoreCLR version even though the desktop version is present in your VS installation. The engineering team is aware of this and in the future, dotnet build
will be smart enough to call the desktop version of msbuild
when present. The “regular” targets file we’re using to support the platform-specific features are designed for Desktop MSBuild. They do not yet have support for CoreCLR tasks. Bottom line, as of the current release: if your targets use build tasks, then you need to provide both CoreCLR and Desktop versions of the library in order to support both “regular” MSBuild and dotnet build
.
Common gotcha’s
There are several bugs in the tool-chain currently that are in the process of being fixed:
- Some Project-to-project (p2p) references aren’t resolving correctly. Whereas they should resolve to the “best” match, they are resolving to the first TFM in the list.
- Another bug is preventing a “legacy” csproj from doing a p2p reference with a “Portable Library can only reference other portable library” error.
- Files that are conditionally included won’t show up in the Solution Explorer. As a workaround, include all files with
None
as the first item group (see example). - for iOS (and possibly Android), you need to set
DebugType
to full as the XamarinConvertPdb2Mdb
task doesn’t yet support the new Portable PDB format generated by this tool-chain. Win8
,Win81
, anduap10.0
aren’t correctly understood by the NuGet targets today. As a workaround, you need to include theNugetTargetMoniker
property set to the full TFM as shown here. Similarly, for legacy PCL targets, it requiresVersion=v0.0
in theNugetTargetMoniker
here. These should hopefully be fixed by GA.- Windows assemblies that use resources need a
.pri
file alongside them. They’re currently missing from the generated NuGet. Workaround is to use your own.NuSpec
for now until the bug is fixed.
Into the weeds, how it all works
This is by no means an official explanation, it’s what I’ve found from exploring the SDK build targets. Some of the terminology and concepts may change over time.
The “SDK style” projects consist of a set of targets/tasks that are pre-installed with MSBuild (and the CLI tools). You can see them in the following directory: C:\Program Files (x86)\Microsoft Visual Studio\2017\<sku>\MSBuild\Sdks
where <sku>
is Community
, Professional
, or Enterprise
, depending on what you installed. The two SDK’s you’re likely to use directly are Microsoft.NET.Sdk
and Microsoft.NET.Sdk.Web
.
The Sdk
attribute causes an Sdk.props
and Sdk.targets
within the specified SDK’s \Sdk
directory to be imported before and after the project file. The Microsoft.NET.Sdk
SDK’s targets defines an “outer” and “inner” build. The “outer-loop” is what your project file directly defines, including several TFM’s in the TargetFrameworks
property. If you only have a single build with a TargetFramework
property defined, then there’s only an “inner-loop”.
For an “outer-loop” build, the SDK targets imports props/targets
in a buildCrossTargeting
directory (soon to be renamed to buildMultiTargeting
). Those get auto-included before and after the main project file (props
before, targets
after.) The “outer-loop” targets will eventually loop through each of the TargetFrameworks
calling msbuild
again in an “inner-loop” with TargetFramework
set to one TFM. This “inner-loop” build is what we currently have in today’s “normal” project types. The “inner-loop” build provides an extension point for providing your language-specific targets (the Import
that was at the bottom of your old csproj before) in place of the “vanilla” one it’ll include by default. By providing a LanguageTargets
property for the “inner-loop,” conditioned by TFM, we can use the “original” targets that invoke the full tool-chain for the target platform. See here, here and here for UWP, iOS, and Android, respectively.
Within each conditionally defined property group, we can set properties that are specific to a particular “inner-loop.” These correspond to the properties in your existing platform-specific project file and are used by the platform-specific targets specified.
One thing you give-up currently is any UI in VS for configuring these properties. Perhaps they’ll return sometime in the future. For now, one thing I’ve found helpful is to maintain a few “dummy” projects where I can edit some settings to see the values and then put them into my multi-targeting csproj
.
Looking forward
As of today (January 4, 2017), the tooling is in a fairly rough state. The .NET Core tooling is rightfully in an “alpha” state. The MSBuild SDK is under active development and things will change before GA. There are a number of issues in the tooling that can make it hard to use today, but I expect those to be fixed soon. Most of the bugs I’ve found are slated to be fixed in the RC3 time-frame, and I’d expect things to be better with that release.
As to whether-or-not to take the plunge today: I’d suggest that if you have a tolerance for figuring this out and reporting issues you’ll encounter, then go for it. If you have a complex project today that already multi-targets a different way (most likely by using multiple “head” projects and shared code project types), I would recommend trying this out in a branch to see how far you get. I’ll be happy to help, just give me a shout. The more the community bangs on this stuff up front, the more issues can be addressed prior to GA.
Acknowledgments
Many thanks to Brad Wilson, Joe Morris, and Daniel Plaisted for reviewing this post and providing feedback.