Eine Avalonia-App obfuskieren: kompilierte Bindings, die ViewLocator-Konvention und was der XAML-Compiler bereits umbenennungssicher gemacht hat
Avalonia ist nicht WPF mit anderem Logo, und der Unterschied zählt in dem Moment, in dem Sie es obfuskieren. Avalonia kompiliert Ihr .axaml zur Build-Zeit nach IL, sodass ein kompiliertes Binding ein typisierter Property-Accessor ist, der den Getter per Metadaten-Token referenziert — er übersteht eine vollständige Umbenennung unverändert —, während ein Reflection-Binding eine Zeichenfolge ist, die die Runtime per Name auflöst und die stillschweigend leer zurückkommt. Was zuerst bricht, ist keines von beiden: Es ist der ViewLocator in der Standard-MVVM-Vorlage, der eine View findet, indem er den Typnamen des View-Models nimmt, „ViewModel" durch „View" ersetzt und Type.GetType aufruft — eine Konvention, die eine Umbenennung in einen „Not Found"-TextBlock oder eine InvalidCastException verwandelt. Dieser Beitrag liest, was der XAML-Compiler für ein kompiliertes Binding tatsächlich erzeugt, zeigt, warum INotifyPropertyChanged nach der Umbenennung weiterhin passt, geht die beiden von Avalonia generierten Typen durch, die die Runtime mit Assembly.GetType über ihre wörtlichen Namen findet, und liefert die Nebula-Konfiguration und die DataTemplate-basierte View-Zuordnung, mit denen Sie die gesamte App flachlegen können, ohne die Teile anzufassen, die Avalonia per Name auflöst.
Eine WPF-Obfuskierungsanleitung lässt sich nicht auf Avalonia übertragen, und der Grund ist der XAML-Compiler. WPF kompiliert XAML nach BAML, einen serialisierten Objektgraphen, den die Runtime per Name instanziiert, und ein Binding-Pfad ist eine Zeichenfolge, die die Runtime per Reflection abläuft. Avalonia kompiliert .axaml nach IL in Ihrer eigenen Assembly. Ein kompiliertes Binding ist ein typisierter Accessor; ein DataTemplate mit DataType ist eine Typreferenz; ein Control in XAML ist ein newobj. Das meiste, was ein Obfuskator in WPF brechen würde, ist bereits zu Metadaten-Tokens geworden, wenn Nebula eine Avalonia-Assembly sieht, und Tokens sind genau das, was ein Umbenenner korrekt umschreibt.
Das bedeutet, die verbleibenden Fehler sind diejenigen, bei denen Avalonia — oder Ihr eigener Code — noch über eine Zeichenfolge geht. Drei davon zählen, und es lohnt sich, sie danach zu ordnen, wie schnell sie zubeißen: Ein Reflection-Binding kommt mit einer Logzeile und einem leeren Control leer zurück; die beiden vom Compiler generierten Typen, die Avalonia per wörtlichem Namen lokalisiert, lassen jedes avares://-Include beim Laden scheitern; und der ViewLocator in der Standard-MVVM-Vorlage bricht jede Navigation der App auf einmal, weil er Views findet, indem er den Typnamen des View-Models editiert.
Was der XAML-Compiler für ein kompiliertes Binding erzeugt
Beginnen Sie mit dem gewöhnlichen Fall und lesen Sie den generierten Code, denn das Sicherheitsargument hängt genau davon ab, was darin steht. Eine View mit typisiertem Datenkontext und einem Binding:
<!-- 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>
Mit <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> in der Projektdatei kompiliert ein einfaches {Binding Greeting} genauso; x:DataType ist das, was dem Compiler einen Typ gibt, gegen den er Greeting auflösen kann. Der Compiler erzeugt eine Populate-Methode in Ihrer Assembly. Dekompiliert und leicht aufgeräumt sieht der Teil, der das Binding baut, so aus:
// 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));
Zwei Dinge referenzieren Greeting, und es sind zwei verschiedene Arten von Referenz. Das Lambda o => ((MainWindowViewModel)o).Greeting kompiliert zu callvirt instance string MainWindowViewModel::get_Greeting() — ein Metadaten-Token. Wenn Nebula get_Greeting in a umbenennt, schreibt es jedes Token um, das darauf zeigt, einschließlich dieses; das Lambda ruft den umbenannten Getter auf und liefert den richtigen Wert. Der Cast auf MainWindowViewModel ist ebenfalls ein Token, auf dieselbe Weise umgeschrieben.
Die zweite Referenz ist die Zeichenfolge "Greeting". Sie wird nicht verwendet, um die Property zu finden — der Accessor hat den Getter bereits. Sie wird dem INPC-Accessor übergeben, der PropertyChanged abonniert und den Wert neu liest, wenn ein Ereignis eintrifft, dessen PropertyName gleich "Greeting" ist. Die Frage ist also, was das View-Model in dieses Ereignis schreibt:
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] und die Source-Generatoren lösen sich alle zu einem Zeichenfolgen-Literal in IL auf — ldstr "Greeting" —, bevor der Obfuskator überhaupt läuft. Die Property umzubenennen ändert den Metadatennamen und lässt das Literal unberührt. Das kompilierte Binding vergleicht sein Literal mit dem Literal des Ereignisses, und beide sagen weiterhin Greeting. Das Binding funktioniert, die Änderungsbenachrichtigung funktioniert, und die Property kann beliebig umbenannt werden.
Aus demselben Grund gilt die nameof-Falle aus der WPF-Welt hier nicht auf dieselbe Weise: In Avalonia hält auch die kompilierte Seite ein Literal, sodass auf keiner Seite des Vergleichs ein umbenannter Metadatenname steht. Die einzige Möglichkeit, es zu brechen, ist eine View-Model-Basisklasse, die PropertyChanged auslöst, indem sie GetType().GetProperties() aufzählt und prop.Name verwendet — ein Laufzeitname, der sich sehr wohl ändert. Wenn Sie so etwas haben, ist es eine Reflection-Abhängigkeit wie jede andere und braucht eine Preserve-Regel oder ein Rewrite.
Reflection-Bindings: die Zeichenfolge, die die Runtime abläuft
Entfernen Sie das x:DataType oder lassen Sie kompilierte Bindings aus, und dasselbe {Binding Greeting} kompiliert anders. Der Compiler kann Greeting nicht gegen einen Typ auflösen, also erzeugt er eine ReflectionBindingExtension, die den Pfad als Zeichenfolge trägt. Zur Laufzeit parst die Binding-Engine "Greeting", nimmt den tatsächlichen DataContext und sucht die Property per Name — Reflection gegen die umbenannten Metadaten. Nach der Obfuskierung gibt es keine Property namens Greeting; der Lookup liefert null, Avalonia loggt einen Binding-Fehler auf Warnstufe, und der TextBlock bleibt leer. Keine Exception. Ihr Smoke-Test, der prüft, dass das Fenster sich öffnet, besteht.
Sie können sich mit Preserve-Regeln herauswinden — die öffentlichen Properties jedes View-Model-Typs behalten —, aber das ist die falsche Lösung, weil sie die Abhängigkeit an Ort und Stelle lässt und nur verhindert, dass sie ausgeübt wird. Die richtige Lösung ist, die Abhängigkeit zu entfernen:
<!-- MyApp.csproj -->
<PropertyGroup>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
Damit wird jedes {Binding} kompiliert, und jede View braucht ein x:DataType (oder ein DataTemplate mit DataType), damit der Compiler etwas hat, wogegen er auflösen kann. Der Gewinn ist nicht nur, dass die Bindings umbenennungssicher werden — es ist, dass jedes Binding, das der Compiler nicht auflösen kann, jetzt ein Build-Fehler ist. Ein Binding, das unter Obfuskierung gebrochen wäre, kann gar nicht erst kompiliert werden. Sie haben den Fehler im Feld auf den Build-Server verlagert, wo jeder Obfuskierungsfehler hingehört.
Wo Sie wirklich einen spät gebundenen Pfad brauchen — ein Binding an ein dynamisches Objekt, eine untypisierte ItemsSource aus einem Plugin —, schreiben Sie explizit {ReflectionBinding Path}, damit die Absicht sichtbar ist, und bewahren Sie genau die öffentlichen Properties dieses Typs mit einer gezielten Regel, statt kompilierte Bindings projektweit abzuschalten.
Der ViewLocator: die Konvention, die die ganze App bricht
Erstellen Sie ein Avalonia-MVVM-Projekt aus der Vorlage und Sie bekommen diese Klasse, und sie ist das Wichtigste im Projekt, das Sie vor dem Obfuskieren lesen sollten:
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;
}
Jedes ContentControl, dessen Inhalt ein View-Model ist, läuft durch Build, das heißt jede Navigation, jeder Tab, jeder an ein View-Model gebundene Dialog. Und Build funktioniert, indem es den Typnamen des View-Models als Text editiert: MyApp.ViewModels.MainWindowViewModel wird zu MyApp.Views.MainWindowView, und Type.GetType findet es in der aktuellen Assembly.
Benennen Sie das View-Model um und eines von zwei Dingen passiert. Hat Nebula den Namespace ebenso wie den Typ umbenannt, ist FullName etwas wie a.b. Es gibt keine Teilzeichenfolge "ViewModel", Replace gibt die Eingabe unverändert zurück, und Type.GetType("a.b") — das in der aufrufenden Assembly sucht — findet den View-Model-Typ selbst. Activator.CreateInstance baut ein View-Model, und der Cast auf Control wirft InvalidCastException. Wurde nur der Typname umbenannt und der Namespace beibehalten, ist FullName gleich MyApp.ViewModels.a, die Ersetzung ergibt MyApp.Views.a, nichts trägt diesen Namen, und der Benutzer sieht Not Found: MyApp.Views.a anstelle jeder View.
Beides wird beim ersten Start des obfuskierten Builds entdeckt, was gut ist, aber die Lösung, nach der man greift — jeden Typ namens *ViewModel und *View bewahren — lässt die Konvention an Ort und Stelle und hält die interessantesten Typnamen Ihrer App, die beschreiben, was jeder Bildschirm tut, im Klartext. Die richtige Lösung ist, Views nicht mehr per Name zu lokalisieren. Avalonia hat dafür bereits einen umbenennungssicheren Mechanismus:
<!-- 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>
Jedes DataType kompiliert zu einem typeof — ein Token — und jedes View-Element kompiliert zu einem newobj — ebenfalls ein Token. Nirgends in der Zuordnung gibt es eine Zeichenfolge. Benennen Sie beide Seiten in einzelne Buchstaben um, und die Templates paaren weiterhin die richtige View mit dem richtigen View-Model, weil die Paarung nie über den Namen lief. Als Nebeneffekt ist das auch die Form, die Trimming und Native AOT übersteht, die an Type.GetType(string) aus demselben Grund scheitern wie ein Obfuskator: Der Name ist keine Referenz, die die Toolchain sehen kann.
Wenn Sie Code bevorzugen, liefert ein statisches Dictionary mit Type als Schlüssel dieselbe Eigenschaft — typeof(MainWindowViewModel) ist ein Token, und der Umbenenner folgt ihm. Was Sie nirgendwo in der App haben dürfen, ist eine Zeile, die einen Typ in eine Zeichenfolge und die Zeichenfolge zurück in einen Typ verwandelt. Löschen Sie den ViewLocator der Vorlage, sobald die Templates drin sind; er wird nicht gebraucht.
Die beiden generierten Typen, die Avalonia per Name findet
Es gibt eine Stelle, an der Avalonia selbst Ihre Assembly per Zeichenfolge auflöst, und sie liegt in Code, den Sie nicht geschrieben haben. Der XAML-Compiler erzeugt in Ihrer Assembly einen Namespace CompiledAvaloniaXaml mit zwei Typen, !XamlLoader und !AvaloniaResources, die die kompilierten Populate- und Build-Methoden jeder .axaml-Datei enthalten, nach Ressourcenpfad indiziert. Wenn ein StyleInclude oder ResourceInclude eine Quelle avares://MyApp/Styles/Dark.axaml hat, oder wenn Sie AvaloniaXamlLoader.Load(new Uri(...)) aufrufen, lokalisiert die Runtime das kompilierte XAML für diesen Pfad, indem sie Assembly.GetType mit dem wörtlichen Typnamen aufruft und die passende Methode invoziert. Benennen Sie den Namespace oder einen der Typen um, und dieser Lookup liefert null; die Runtime meldet, dass für die URI kein vorkompiliertes XAML gefunden wurde, und das Include lädt nicht.
InitializeComponent ist normalerweise nicht betroffen, weil der Compiler den darin enthaltenen Aufruf AvaloniaXamlLoader.Load(this) in einen direkten Aufruf der generierten Populate-Methode umschreibt — ein Token, kein Lookup. Aber jedes Laden per URI geht über den reflektiven Pfad, und fast jede App hat mindestens eines, in den Styles von App.axaml. Der Namespace bleibt also.
Ein paar andere Avalonia-Mechanismen sehen zeichenfolgenförmig aus, sind aber sicher, und es lohnt sich zu wissen, warum, damit Sie nicht zu viel bewahren. x:Name="Submit" mit this.FindControl<Button>("Submit") ist Literal gegen Literal — der Name-Scope registriert das Literal aus dem XAML und der Lookup übergibt das Literal aus C#; das generierte Feld kann umbenannt werden. AvaloniaProperty.Register<MyControl, string>(nameof(Text)) registriert ein Literal; Bindings, die auf eine AvaloniaProperty zielen, werden über die Property-Registry anhand dieses Literals aufgelöst, nicht über den Namen des CLR-Wrappers. Style-Selektoren (Button.primary, :pointerover) sind auf beiden Seiten Zeichenfolgen. avares://-URIs enthalten den Assembly-Namen, den Nebula nicht umbenennt. Keiner davon braucht eine Regel.
Die Nebula-Konfiguration
Mit eingeschalteten kompilierten Bindings und dem durch Templates ersetzten ViewLocator ist die Konfiguration kurz, weil fast nichts in der App mehr per Name aufgelöst wird:
<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>
Die String-Verschlüsselung kann für die gesamte Assembly eingeschaltet bleiben. Das Literal "Greeting" im generierten Accessor und das Literal "Greeting" im OnPropertyChanged des View-Models werden beide am Verwendungsort entschlüsselt, und der INPC-Vergleich findet auf den entschlüsselten Werten statt; das Binding sieht dieselben zwei Zeichenfolgen wie immer.
Wenn Sie noch nicht in der Lage sind, die Reflection-Abhängigkeiten zu entfernen — eine große App mit einem ViewLocator und Jahren untypisierter Bindings —, ist dies die Übergangsregel, exakt auf die Oberfläche beschränkt, die die Zeichenfolgen-Lookups berühren, und auf nichts weiter:
<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>
Beachten Sie, dass members="false" auf den View-Typen nur den Typnamen behält, den der Locator braucht; die Felder, Handler und generierten x:Name-Felder der View werden weiterhin umbenannt. Und die View-Model-Regel behält die öffentlichen Property-Namen, die die Reflection-Bindings ablaufen, während private Felder, Backing-Member der Commands und alles, was die Binding-Engine nie sieht, umbenannt werden. Behandeln Sie diesen Block als To-do-Liste — jede Zeile ist eine zu löschende Abhängigkeit, und wenn die letzte fällt, fällt der Block mit.
Der Test, der es beweist
Die Fehlerbilder hier sind leise: ein leerer TextBlock, ein Not Found-Label, ein Style, der nicht angewendet wurde. Der Test, der sie alle fängt, ist der, der jede View durch die echte Template-Pipeline gegen die obfuskierte Assembly baut, mit Avalonias Headless-Plattform, damit er in CI läuft:
[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);
}
}
Fügen Sie pro kompiliertem Binding, das Ihnen wichtig ist, eine Assertion hinzu — setzen Sie eine View-Model-Property, pumpen Sie den Dispatcher, lesen Sie das Control — und der Test schlägt bei einem umbenannten Accessor fehl, lange bevor ein Kunde es tut. Mit dem Compiler, der kompilierte Bindings erzwingt, und diesem Test, der die View-Zuordnung erzwingt, bleibt in der App kein Name mehr übrig, den der Obfuskator brechen könnte, was genau der Zustand ist, den Sie wollen, bevor Sie sie flachlegen.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.