Skip to main content

Excluding Assemblies from Trimming in .NET MAUI

·6 mins

I was recently putting the finishing touches on a .NET MAUI app, and everything was going great! It ran smoothly in Debug builds locally, then I pushed the iOS build to TestFlight, excited to show it off. And then… 💥 crash on launch. No stack trace, no clear indication of what was going on.

After a bit of digging, it turned out that trimming was the culprit.

The Problem #

In Release builds (especially for iOS), .NET MAUI apps apply ILLink trimming to reduce the size of the app bundle. While this is great for keeping things small and performant, it can be a bit too aggressive and remove things that libraries depend on, especially when the use reflection, but also for instance when using XAML, which typically happens a lot in .NET MAUI. Trimming works on the premise that every piece of code used in the app has a hard/clear reference in code, and then we trust that all third-party libraries we use in our projects do the same.

In my case, I was using Akavache for caching, and it just didn’t play well with the linker. It turns out that parts of Akavache rely on reflection to instantiate things, which the linker couldn’t predict. So when it stripped those out, the app started throwing exceptions, or worse, silently failed, when trying to access the cache. In my case the cache was accessed/created at application start, so the app would crash immediately.

How to Figure Out What’s Breaking #

If you’re running into weird crashes or missing behavior in Release builds, especially on iOS, but also on Android when using AOT, there’s a good chance trimming is to blame. But how do you know what the linker removed?

Here are a few tips that helped me:

  • Compare Debug vs Release behavior: If it works in Debug but fails in Release, trimming is likely involved. Not always, but chances are this is it. I’ve had another instance where it was a threading issue!
  • Temporarily disable trimming: Set <PublishTrimmed>false</PublishTrimmed> in your .csproj to confirm whether trimming is the issue.
  • Enable detailed linker logging: Use MSBuild /bl to create a binlog or pass /p:LinkTrimVerbose=true to see what’s getting trimmed. You’ll need to learn how to read the resulting files though.
  • Use a crash reporting tool: For me, Sentry.io was a lifesaver. It didn’t show me clearly right away what the problem was, but at least it showed me that the place where it crashed was a place that had to do with Akavache. That at least gives you some lead on where to start looking for things.

Without Sentry, I would’ve been flying blind trying to reproduce the issue locally. Highly recommend getting something like that in place early in the dev cycle. This is not the first time this tool has saved me.

The Fix: Preserve What You Need #

The best way to tell the linker to keep certain types or assemblies is by creating a Linker.xml file and referencing it in your project file. Here’s how I fixed it:

1. Create Linker.xml #

Add a new XML file to your project (I put mine at the root), and name it Linker.xml. Here’s what mine looks like:

<?xml version="1.0" encoding="UTF-8" ?>
<linker>
  <!-- Preserve all Akavache assemblies -->
  <assembly fullname="Akavache" preserve="all" />
  <assembly fullname="Akavache.Core" preserve="all" />
  <assembly fullname="Akavache.Sqlite3" preserve="all" />
  
  <!-- Preserve System.Reactive assemblies used by Akavache -->
  <assembly fullname="System.Reactive" preserve="all" />
  <assembly fullname="System.Reactive.Core" preserve="all" />
  <assembly fullname="System.Reactive.Linq" preserve="all" />
  <assembly fullname="System.Reactive.PlatformServices" preserve="all" />
  
  <!-- Preserve SQLite assemblies -->
  <assembly fullname="SQLite-net" preserve="all" />
  <assembly fullname="SQLitePCLRaw.core" preserve="all" />
  <assembly fullname="SQLitePCLRaw.provider.e_sqlite3" preserve="all" />
  <assembly fullname="SQLitePCLRaw.batteries_v2" preserve="all" />
  
  <!-- Preserve your DTOs that Akavache will serialize -->
  <assembly fullname="MyProject.Shared">
    <namespace fullname="MyProject.Shared.Models.Dtos" preserve="all" />
  </assembly>
</linker>

This tells the linker to keep all types from Akavache and related assemblies/types, which are also used by Akavache, so nothing important gets stripped out. I’m pretty sure that I can leave out the DTOs at the end of the file, but better safe than sorry!

As a rule you do want to be careful with what you put in this file. Everything that you put in here, will be preserved in your app. That means, you app size will grow and will potentially eat into the performance. In this case we need it for this library, else our app won’t work, but try to figure out (probably by some trial-and-error), what the bare minimum is that you need in here. And optionally, check the libraries repository for issues that talk about being “trim safe” and if that is expected to be supported. Or, of course, contribute that back to the open-source ecosystem yourself!

For more information on the structure and syntax of this file, please refer to the documentation: https://github.com/dotnet/runtime/blob/main/docs/tools/illink/data-formats.md#descriptor-format.

2. Reference Linker.xml in Your .csproj #

Then, make sure the .csproj file actually includes the Linker.xml. Add this line inside your first <PropertyGroup>:

<LinkerDescriptor>Linker.xml</LinkerDescriptor>

It should look something like this:

<!-- Other PropertyGroups and ItemGroups here ... -->

<!-- Trimmer configuration for both iOS and Android to preserve Akavache dependencies -->
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios' OR $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
  <TrimmerRootDescriptor Include="Linker.xml" />
</ItemGroup>

If your app only targets iOS and/or Android or you want to include this for every platfor, you can of course leave out the Condition on the ItemGroup and then just add the TrimmerRootDescriptor to an existing ItemGroup.

Rebuild the app in Release, push it to TestFlight again, and… success! 🎉

Summary #

When working with libraries like Akavache that use reflection or dynamic type loading, it’s important to understand how the linker might interfere. The trimming process doesn’t know about reflection, so it’s up to you to help it out.

Creating a Linker.xml file and pointing to it in your .csproj can save you hours of frustration, especially when you think you’re done with your app, create a Release build and only then find out it crashes without much indication why.

If you’re hitting mysterious issues in Release builds, get trimming off the suspect list early. And seriously… Use a service like Sentry.io to get those crash reports. It can make a world of difference. You’ll easily make that money back.

And when you know which library causes this, you can easily get this job done by asking your favorite Copilot to make these changes! That’s what I did 🤓

For more information, specifically for .NET MAUI apps, check out this Microsoft Learn page: https://learn.microsoft.com/dotnet/maui/deployment/trimming