Obfusquer une application Avalonia : liaisons compilées, la convention du ViewLocator et ce que le compilateur XAML a déjà rendu sûr au renommage
Avalonia n'est pas WPF avec un autre logo, et la différence compte dès que vous l'obfusquez. Avalonia compile votre .axaml en IL au moment de la compilation, si bien qu'une liaison compilée est un accesseur de propriété typé qui référence le getter par jeton de métadonnées — il survit intact à un renommage complet — tandis qu'une liaison par réflexion est une chaîne que le runtime résout par nom et qui revient silencieusement vide. Ce qui casse en premier n'est ni l'un ni l'autre : c'est le ViewLocator du modèle MVVM par défaut, qui trouve une vue en prenant le nom du type du view model, en remplaçant « ViewModel » par « View » et en appelant Type.GetType — une convention qu'un renommage transforme en TextBlock « Not Found » ou en InvalidCastException. Ce billet lit ce que le compilateur XAML émet réellement pour une liaison compilée, montre pourquoi INotifyPropertyChanged correspond toujours après renommage, parcourt les deux types générés par Avalonia que le runtime localise avec Assembly.GetType par leurs noms littéraux, et donne la configuration Nebula et le mappage de vues par DataTemplate qui vous permettent d'aplatir toute l'application sans toucher aux parties qu'Avalonia résout par nom.
Un guide d’obfuscation WPF ne se transpose pas à Avalonia, et la raison en est le compilateur XAML. WPF compile le XAML en BAML, un graphe d’objets sérialisé que le runtime instancie par nom, et un chemin de liaison est une chaîne que le runtime parcourt par réflexion. Avalonia compile .axaml en IL dans votre propre assembly. Une liaison compilée est un accesseur typé ; un DataTemplate avec un DataType est une référence de type ; un contrôle en XAML est un newobj. L’essentiel de ce qu’un obfuscateur casserait dans WPF a déjà été transformé en jetons de métadonnées au moment où Nebula voit un assembly Avalonia, et les jetons sont exactement ce qu’un renommeur réécrit correctement.
Ce qui signifie que les défaillances restantes sont celles où Avalonia — ou votre propre code — passe encore par une chaîne. Il y en a trois qui comptent, et il vaut la peine de les classer selon la vitesse à laquelle elles mordent : une liaison par réflexion revient vide avec une ligne de journal et un contrôle vide ; les deux types générés par le compilateur qu’Avalonia localise par nom littéral font échouer le chargement de chaque include avares:// ; et le ViewLocator du modèle MVVM par défaut casse d’un coup toutes les navigations de l’application, parce qu’il trouve les vues en éditant le nom du type du view model.
Ce que le compilateur XAML émet pour une liaison compilée
Commencez par le cas ordinaire et lisez le code généré, parce que l’argument de sûreté dépend exactement de ce qu’il contient. Une vue avec un contexte de données typé et une liaison :
<!-- MainWindow.axaml -->
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:MyApp.ViewModels"
x:Class="MyApp.Views.MainWindow"
x:DataType="vm:MainWindowViewModel">
<TextBlock Text="{CompiledBinding Greeting}" />
</Window>
Avec <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> dans le fichier projet, un simple {Binding Greeting} se compile de la même façon ; x:DataType est ce qui donne au compilateur un type contre lequel résoudre Greeting. Le compilateur émet une méthode de population dans votre assembly. Décompilée et légèrement nettoyée, la partie qui construit la liaison ressemble à ceci :
// Generated into MyApp.dll by the Avalonia XAML compiler (cleaned up for reading).
// Namespace CompiledAvaloniaXaml, method !XamlIlPopulate for MainWindow.
var path = new CompiledBindingPathBuilder()
.Property(
new ClrPropertyInfo(
"Greeting", // literal name
o => ((MainWindowViewModel)o).Greeting, // getter: token ref to get_Greeting
null, // no setter: read-only binding
typeof(string)),
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor) // INotifyPropertyChanged accessor
.Build();
textBlock.Bind(TextBlock.TextProperty, new CompiledBindingExtension(path));
Deux choses référencent Greeting, et ce sont deux sortes de références différentes. La lambda o => ((MainWindowViewModel)o).Greeting se compile en callvirt instance string MainWindowViewModel::get_Greeting() — un jeton de métadonnées. Quand Nebula renomme get_Greeting en a, il réécrit chaque jeton qui pointe vers lui, celui-ci compris ; la lambda appelle le getter renommé et renvoie la bonne valeur. La conversion en MainWindowViewModel est aussi un jeton, réécrit de la même manière.
La seconde référence est la chaîne "Greeting". Elle n’est pas utilisée pour trouver la propriété — l’accesseur a déjà le getter. Elle est transmise à l’accesseur INPC, qui s’abonne à PropertyChanged et relit la valeur quand arrive un événement dont le PropertyName vaut "Greeting". La question est donc ce que le view model met dans cet événement :
public sealed partial class MainWindowViewModel : ObservableObject
{
[ObservableProperty] private string _greeting = "Hello";
// the MVVM Toolkit generator emits: OnPropertyChanged("Greeting") — a literal
// or by hand:
private string _title = "";
public string Title
{
get => _title;
set => SetProperty(ref _title, value); // [CallerMemberName] → "Title", baked at compile time
}
}
nameof(Greeting), [CallerMemberName] et les générateurs de source se résolvent tous en un littéral de chaîne dans l’IL — ldstr "Greeting" — avant même que l’obfuscateur ne s’exécute. Renommer la propriété change le nom dans les métadonnées et laisse le littéral intact. La liaison compilée compare son littéral avec celui de l’événement, et les deux disent toujours Greeting. La liaison fonctionne, la notification de changement fonctionne, et la propriété peut être renommée en n’importe quoi.
C’est pour la même raison que le piège du nameof du monde WPF ne s’applique pas ici de la même façon : dans Avalonia, le côté compilé contient aussi un littéral, donc il n’y a de nom de métadonnées renommé d’aucun côté de la comparaison. La seule façon de le casser est une classe de base de view model qui lève PropertyChanged en énumérant GetType().GetProperties() et en utilisant prop.Name — un nom à l’exécution qui, lui, change. Si vous en avez une, c’est une dépendance à la réflexion comme une autre, et elle réclame une règle de préservation ou une réécriture.
Liaisons par réflexion : la chaîne que le runtime parcourt
Retirez le x:DataType, ou laissez les liaisons compilées désactivées, et le même {Binding Greeting} se compile différemment. Le compilateur ne peut pas résoudre Greeting contre un type, il émet donc une ReflectionBindingExtension portant le chemin sous forme de chaîne. À l’exécution, le moteur de liaison analyse "Greeting", prend le DataContext réel et cherche la propriété par nom — réflexion contre les métadonnées renommées. Après obfuscation, il n’y a aucune propriété nommée Greeting ; la recherche renvoie null, Avalonia journalise une erreur de liaison au niveau avertissement, et le TextBlock est vide. Pas d’exception. Votre test de fumée, qui vérifie que la fenêtre s’ouvre, passe.
Vous pouvez vous en sortir par préservation — garder les propriétés publiques de chaque type de view model — mais c’est la mauvaise solution, parce qu’elle laisse la dépendance en place et se contente de l’empêcher d’être exercée. La bonne solution est de supprimer la dépendance :
<!-- MyApp.csproj -->
<PropertyGroup>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
Avec cela, chaque {Binding} est compilé, et chaque vue a besoin d’un x:DataType (ou d’un DataTemplate avec un DataType) pour que le compilateur ait quelque chose contre quoi résoudre. Le bénéfice n’est pas seulement que les liaisons deviennent sûres au renommage — c’est que toute liaison que le compilateur ne peut pas résoudre est désormais une erreur de compilation. Une liaison qui aurait cassé sous obfuscation ne peut même pas être compilée. Vous avez déplacé la défaillance en production vers le serveur de build, là où toute défaillance d’obfuscation devrait vivre.
Là où vous avez vraiment besoin d’un chemin lié tardivement — une liaison vers un objet dynamique, un ItemsSource non typé venant d’un plugin — écrivez {ReflectionBinding Path} explicitement pour que l’intention soit visible, et préservez exactement les propriétés publiques de ce type avec une règle ciblée plutôt que de désactiver les liaisons compilées pour tout le projet.
Le ViewLocator : la convention qui casse toute l’application
Créez un projet Avalonia MVVM depuis le modèle et vous obtenez cette classe, et c’est la chose la plus importante du projet à lire avant d’obfusquer :
public class ViewLocator : IDataTemplate
{
public Control? Build(object? param)
{
if (param is null) return null;
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
return (Control)Activator.CreateInstance(type)!;
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data) => data is ViewModelBase;
}
Chaque ContentControl dont le contenu est un view model passe par Build, ce qui veut dire chaque navigation, chaque onglet, chaque boîte de dialogue liée à un view model. Et Build fonctionne en éditant le nom du type du view model comme du texte : MyApp.ViewModels.MainWindowViewModel devient MyApp.Views.MainWindowView, et Type.GetType le trouve dans l’assembly courant.
Renommez le view model et l’une de deux choses se produit. Si Nebula a renommé l’espace de noms en plus du type, FullName ressemble à a.b. Il n’y a aucune sous-chaîne "ViewModel", Replace renvoie l’entrée inchangée, et Type.GetType("a.b") — cherchant dans l’assembly appelant — trouve le type du view model lui-même. Activator.CreateInstance construit un view model, et la conversion en Control lève InvalidCastException. Si seul le nom du type a été renommé et l’espace de noms conservé, FullName est MyApp.ViewModels.a, le remplacement produit MyApp.Views.a, rien ne porte ce nom, et l’utilisateur voit Not Found: MyApp.Views.a à la place de chaque vue.
Les deux sont détectés dès la première exécution du build obfusqué, ce qui est bien, mais la solution vers laquelle on se tourne — préserver tout type nommé *ViewModel et *View — laisse la convention en place et garde en clair les noms de types les plus intéressants de votre application, ceux qui décrivent ce que fait chaque écran. La bonne solution est d’arrêter de localiser les vues par nom. Avalonia a déjà un mécanisme sûr au renommage pour cela :
<!-- App.axaml -->
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:MyApp.ViewModels"
xmlns:views="using:MyApp.Views"
x:Class="MyApp.App">
<Application.DataTemplates>
<DataTemplate DataType="vm:MainWindowViewModel"><views:MainWindowView /></DataTemplate>
<DataTemplate DataType="vm:SettingsViewModel"><views:SettingsView /></DataTemplate>
<DataTemplate DataType="vm:ProjectViewModel"><views:ProjectView /></DataTemplate>
</Application.DataTemplates>
</Application>
Chaque DataType se compile en typeof — un jeton — et chaque élément de vue se compile en newobj — un jeton aussi. Il n’y a aucune chaîne nulle part dans le mappage. Renommez les deux côtés en lettres isolées et les modèles associent toujours la bonne vue au bon view model, parce que l’association n’a jamais été par nom. En prime, c’est aussi la forme qui survit au trimming et à Native AOT, qui s’étranglent sur Type.GetType(string) pour la même raison qu’un obfuscateur : le nom n’est pas une référence que la chaîne d’outils peut voir.
Si vous préférez le code, un dictionnaire statique indexé par Type donne la même propriété — typeof(MainWindowViewModel) est un jeton, et le renommeur le suit. Ce que vous ne devez avoir nulle part dans l’application, c’est une ligne qui transforme un type en chaîne et la chaîne en type. Supprimez le ViewLocator du modèle une fois les templates en place ; il n’est plus nécessaire.
Les deux types générés qu’Avalonia trouve par nom
Il y a un endroit où Avalonia lui-même résout votre assembly par chaîne, et c’est dans du code que vous n’avez pas écrit. Le compilateur XAML émet un espace de noms CompiledAvaloniaXaml dans votre assembly avec deux types, !XamlLoader et !AvaloniaResources, qui contiennent les méthodes compilées de population et de construction de chaque fichier .axaml, indexées par chemin de ressource. Quand un StyleInclude ou un ResourceInclude a une source avares://MyApp/Styles/Dark.axaml, ou quand vous appelez AvaloniaXamlLoader.Load(new Uri(...)), le runtime localise le XAML compilé de ce chemin en appelant Assembly.GetType avec le nom littéral du type et en invoquant la méthode correspondante. Renommez l’espace de noms ou l’un des types et cette recherche renvoie null ; le runtime signale qu’aucun XAML précompilé n’a été trouvé pour l’URI et l’include ne se charge pas.
InitializeComponent n’est généralement pas affecté, parce que le compilateur réécrit l’appel AvaloniaXamlLoader.Load(this) qu’il contient en appel direct à la méthode de population générée — un jeton, pas une recherche. Mais tout chargement par URI passe par la voie réflexive, et presque toute application en a au moins un, dans les styles de App.axaml. L’espace de noms reste donc.
Quelques autres mécanismes d’Avalonia ont l’air de chaînes mais sont sûrs, et il vaut la peine de savoir pourquoi pour ne pas sur-préserver. x:Name="Submit" avec this.FindControl<Button>("Submit") est littéral contre littéral — la portée de noms enregistre le littéral du XAML et la recherche passe le littéral depuis C# ; le champ généré peut être renommé. AvaloniaProperty.Register<MyControl, string>(nameof(Text)) enregistre un littéral ; les liaisons qui ciblent une AvaloniaProperty se résolvent via le registre des propriétés par ce littéral, pas via le nom de l’enveloppe CLR. Les sélecteurs de style (Button.primary, :pointerover) sont des chaînes des deux côtés. Les URI avares:// contiennent le nom de l’assembly, que Nebula ne renomme pas. Aucun de ceux-ci n’a besoin d’une règle.
La configuration Nebula
Avec les liaisons compilées activées et le ViewLocator remplacé par des templates, la configuration est courte, parce que presque plus rien dans l’application n’est résolu par nom :
<Nebula>
<Assembly file="MyApp.Desktop.dll">
<Rename enabled="true">
<!-- Avalonia locates these compiler-generated types with Assembly.GetType by literal
name when it loads XAML by avares:// URI. Keep the namespace exactly as emitted. -->
<Preserve names="true" members="true" namespace="CompiledAvaloniaXaml" />
</Rename>
<ControlFlow enabled="true" />
<StringEncryption enabled="true" />
</Assembly>
</Nebula>
Le chiffrement des chaînes peut rester activé pour tout l’assembly. Le littéral "Greeting" de l’accesseur généré et le littéral "Greeting" du OnPropertyChanged du view model sont tous deux déchiffrés au point d’utilisation, et la comparaison INPC se fait sur les valeurs déchiffrées ; la liaison voit les deux mêmes chaînes qu’auparavant.
Si vous n’êtes pas encore en mesure de supprimer les dépendances à la réflexion — une grande application avec un ViewLocator et des années de liaisons non typées — voici la règle intérimaire, limitée exactement à la surface que touchent les recherches par chaîne et à rien d’autre :
<Rename enabled="true">
<Preserve names="true" members="true" namespace="CompiledAvaloniaXaml" />
<!-- INTERIM ONLY: a convention-based ViewLocator needs the view model and view TYPE
names; reflection bindings need view model PUBLIC PROPERTY names. Nothing else. -->
<Preserve names="true" members="public" baseType="MyApp.ViewModels.ViewModelBase" />
<Preserve names="true" members="false" baseType="Avalonia.Controls.UserControl" />
<Preserve names="true" members="false" baseType="Avalonia.Controls.Window" />
</Rename>
Notez que members="false" sur les types de vue ne conserve que le nom du type dont le localisateur a besoin ; les champs, gestionnaires et champs x:Name générés de la vue sont toujours renommés. Et la règle du view model conserve les noms des propriétés publiques que parcourent les liaisons par réflexion tout en renommant les champs privés, les membres de secours des commandes et tout ce que le moteur de liaison ne voit jamais. Traitez ce bloc comme une liste de tâches — chaque ligne est une dépendance à supprimer, et quand la dernière disparaît, le bloc aussi.
Le test qui le prouve
Les modes de défaillance ici sont silencieux : un TextBlock vide, une étiquette Not Found, un style qui ne s’est pas appliqué. Le test qui les attrape tous est celui qui construit chaque vue via le vrai pipeline de templates contre l’assembly obfusqué, en utilisant la plateforme headless d’Avalonia pour qu’il s’exécute en CI :
[AvaloniaFact] // Avalonia.Headless.XUnit
public void Every_view_model_resolves_to_a_real_view()
{
var app = Application.Current!; // the OBFUSCATED MyApp.Desktop.dll
var viewModels = new object[]
{
new MainWindowViewModel(), new SettingsViewModel(), new ProjectViewModel()
};
foreach (var vm in viewModels)
{
var template = app.DataTemplates.FirstOrDefault(t => t.Match(vm));
Assert.NotNull(template); // a view model with no template
var view = template!.Build(vm);
Assert.IsNotType<TextBlock>(view); // the "Not Found" fallback
Assert.NotNull(view);
}
}
Ajoutez une assertion par liaison compilée qui vous importe — affectez une propriété du view model, faites tourner le dispatcher, lisez le contrôle — et le test échoue sur un accesseur renommé bien avant qu’un client ne le fasse. Avec le compilateur qui impose les liaisons compilées et ce test qui impose le mappage des vues, il ne reste aucun nom dans l’application que l’obfuscateur puisse casser, ce qui est exactement l’état que vous voulez avant de l’aplatir.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.