You have a good question. There's probably some trade-offs with your solution. The ultimate answer really depends on what you mean by platform dependent. For example, if you are starting up a process to start external applications and you are simply switching between one application and another, you can probably handle that without too much complication. If you are talking P/Invoke with native libraries, then there's a little more that has to be done. However if you are linking with libraries that only exist on one platform, you probably will need to use multiple assemblies.
External apps
You probably won't need to use #if
statements in this situation. Just set up some interfaces, and have one implementation per platform. Use a factory to detect the platform and provide the right instance.
In some cases it's just a binary compiled for a specific platform but the name of the executable and all the parameters are defined the same. In that case it's a matter of resolving the right executable. For a bulk audio converter app that could run across Windows and Linux, I had a static initializer resolve the binary name.
public class AudioProcessor
{
private static readonly string AppName = "lame";
private static readonly string FullAppPath;
static AudioProcessor()
{
var platform = DetectPlatform();
var architecture = Detect64or32Bits();
FullAppPath = Path.combine(platform, architecture, AppName);
}
}
Nothing fancy here. Just good ol' fashioned classes.
P/Invoke
P/Invoke is a bit trickier. The bottom line is that you need to ensure the right version of the native library is loaded. On windows you would P/Invoke SetDllDirectory()
. Different platforms may not need that step. So this is where things can get messy. You may need to use #if
statements to control which call is used to control resolving your library path--particularly if you are including it in your distribution package.
Linking to completely different platform dependent libraries
The old school multi-targeting approach may be useful here. However it does come with a lot of ugliness. In the days where some projects attempted to have the same DLL target Silverlight, WPF, and potentially UAP, you would have to compile the application multiple times with different compilation tags. The challenge with each of the platforms above is that while they share the same concepts, the platforms are sufficiently different that you have to work around those differences. This is where we get into the hell of #if
.
This approach also requires hand editing the .csproj
file to handle platform dependent references. Since your .csproj
file is an MSBuild file, it's entirely possible to do in a known and predictable manner.
#if hell
You can turn on and off sections of code using #if
statements so it is effective at handling the minor differences between the applications. On the surface it sounds like a good idea. I even used it as a means to turn on and off bounding box visualization to debug drawing code.
The number 1 problem with #if
is that none of the code that is turned off is evaluated by the parser. You may have latent syntax errors, or worse, logic errors waiting for you to recompile the library. This becomes even more problematic with refactoring code. Something as simple as renaming a method or changing the order of parameters would normally be handled OK, but because the parser never evaluates anything turned off by the #if
statement you suddenly have broken code that you won't see until you recompile.
All of my debug code that was written in that manner had to be rewritten after a series of refactorings broke it. During the rewrite, I used a global config class to turn on and off those features. That made it refactor tool proof, but such a solution doesn't help when the API is completely different.
My preferred method
My preferred method, based on many painful lessons learned, and even based on Microsoft's own example, is to use multiple assemblies.
One core NetStandard assembly would define all the interfaces and contain all the common code. The platform dependent implementations would be in a separate assembly that would add features when included.
This approach is exemplified by the new Configuration API and the current Identity architecture. As you need more specific integrations, you simply add those new assemblies. Those assemblies also provide extension functions to incorporate itself into your set up. If you are using a dependency injection approach, those extension methods allow the library to register it's services.
This is about the only way I know of to avoid #if
hell, and satisfy a substantially different environment.