Xamarin Interview Questions and Answers
Last updated:
Check out 35 of the most common Xamarin interview questions, then take an AI-powered practice interview
Q1What do Xamarin.Android, Xamarin.iOS and Xamarin.Forms each do, and what are they called after .NET 6?
BasicPlatform Model
Answer
They are three different layers and interviewers ask this to see whether you can say which layer a bug lives in. Xamarin.Android and Xamarin.iOS are the platform bindings: generated C# wrappers over the entire Android SDK (Java APIs surfaced through JNI) and over UIKit, Foundation and friends (Objective-C APIs surfaced through the Objective-C runtime). When you write C# against Activity, Intent, UIViewController or UITableView, you are calling the real native type, and every managed object has a native peer on the other side of the bridge.
Nothing is emulated or reimplemented. Xamarin.Forms sits above both bindings and gives you a shared UI abstraction (Application, Page, Layout, View) where each Forms control is realised at runtime by a platform renderer, so a Forms Entry becomes an android.widget.EditText on Android and a UITextField on iOS. With .NET 6 the bindings moved into the unified .NET SDK and were renamed: Xamarin.Android became .NET for Android (target framework net8.0-android), Xamarin.iOS became .NET for iOS (net8.0-ios), Xamarin.Mac became .NET for macOS, and Xamarin.Forms was rewritten as .NET MAUI with handlers instead of renderers. A practical consequence: a crash in a native control is a bindings problem you debug with platform tools, while a layout or binding bug is a Forms problem you debug in shared code.
Key Points
- Xamarin.Android / Xamarin.iOS are bindings over the real native SDKs, not reimplementations
- Xamarin.Forms is a shared XAML UI layer that maps to native controls through renderers
- After .NET 6 they are .NET for Android (net8.0-android), .NET for iOS (net8.0-ios) and .NET MAUI
- Every managed object at the boundary has a native peer, which drives most memory bugs
Q2Xamarin support ended on 1 May 2024. What does that actually break for an app still on Xamarin.Forms 5 in 2026?
BasicLifecycle
Answer
The app keeps running on installed devices, and enterprise or MDM sideloaded distribution keeps working. What stops is your ability to ship a public store update. On Android, Google Play enforces a rolling target API level requirement for new apps and updates: API 34 (Android 14) from August 2024 and API 35 (Android 15) from August 2025.
Classic Xamarin.Android's final releases top out at API 33 (Android 13) as a supported target, so the Play Console simply rejects the upload. On iOS, Apple requires submissions to be built with a recent Xcode SDK, and it has required privacy manifests (PrivacyInfo.xcprivacy) for apps and for a list of commonly used third-party SDKs since 2024. Classic Xamarin.iOS was never serviced for the newer Xcode SDKs or given first-class privacy manifest tooling, so App Store Connect rejects the binary.
On top of that, there are no security patches for the runtime or BCL, Visual Studio for Mac reached end of life in August 2024, Visual Studio App Center retired on 31 March 2025 (taking build, distribute, crash reporting and analytics with it), and NuGet publishers are steadily dropping the Xamarin target frameworks. When an interviewer asks this, they want the store deadlines quoted back, because those deadlines are the business case that funds the MAUI migration.
<!-- Classic Xamarin.Android ceiling: this is as high as it goes -->
<!-- Properties/AndroidManifest.xml -->
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="33" />
<!-- .NET for Android after migration: target moves with Play requirements -->
<!-- MyApp.csproj -->
<PropertyGroup>
<TargetFramework>net8.0-android</TargetFramework>
<SupportedOSPlatformVersion>24.0</SupportedOSPlatformVersion>
<TargetPlatformVersion>35</TargetPlatformVersion>
</PropertyGroup>
Key Points
- Play target API 34 from Aug 2024 and API 35 from Aug 2025; classic Xamarin.Android stops at API 33
- Apple requires a recent Xcode SDK plus PrivacyInfo.xcprivacy privacy manifests
- App Center retired 31 March 2025, so builds, distribution and crash reporting need replacing
- Installed apps and MDM distribution keep working; only public store updates are blocked
Q3How does C# execute on iOS when Apple does not permit JIT compilation, and what breaks because of it?
BasicRuntime
Answer
iOS forbids writable-executable memory for App Store apps, so Mono cannot JIT. Xamarin.iOS instead runs full Ahead-of-Time compilation: at build time the Mono AOT compiler walks your IL and emits native ARM64 code for every method it can statically see, links it into the app binary, and the runtime executes native code with no code generation at runtime. Three practical consequences show up in interviews.
First, System.Reflection.Emit does not exist, so anything that builds types or delegates dynamically fails, which is why older versions of some serializers, mocking libraries and IoC containers do not work on device. Second, LINQ Expression.Compile falls back to an interpreted expression tree, which is correct but slow in tight loops. Third, generic instantiations over value types that the AOT compiler could not predict throw at runtime with the classic message 'Attempting to JIT compile method ... while running in aot-only mode'.
The usual triggers are generic virtual methods, generic interfaces implemented by structs, and reflection over generics. The standard fixes are to force the instantiation in code so the AOT compiler sees it, to switch a struct to a class, or to enable the Mono interpreter for the assemblies that need it (MtouchInterpreter in classic Xamarin, UseInterpreter in .NET for iOS), which is also what makes Hot Reload work. Android by contrast JITs by default, which is why a build can pass on Android and fail only on an iOS device.
// Typical device-only failure:
// System.ExecutionEngineException: Attempting to JIT compile method
// 'System.Linq.Enumerable/OrderedEnumerable<MyStruct,int>:.ctor ()'
// while running in aot-only mode.
// Fix 1: force the instantiation so the AOT compiler emits it
static void PreserveGenerics()
{
var dummy = new List<MyStruct>().OrderBy(x => x.Id).ToList();
GC.KeepAlive(dummy);
}
<!-- Fix 2: enable the interpreter (classic Xamarin.iOS .csproj) -->
<MtouchInterpreter>all</MtouchInterpreter>
<!-- Fix 2 in .NET for iOS -->
<UseInterpreter>true</UseInterpreter>
Key Points
- iOS builds are full AOT; there is no runtime code generation
- Reflection.Emit is unavailable and Expression.Compile is interpreted
- Unseen generic value-type instantiations throw 'aot-only mode' at runtime
- The Mono interpreter is the escape hatch and is what enables Hot Reload
Q4How does data binding work in Xamarin.Forms, and what exactly is BindingContext?
BasicData Binding
Answer
BindingContext is the object that bindings on a view resolve their property paths against, and it inherits down the visual tree. Set it once on the ContentPage and every child element without its own BindingContext will use it, which is how a single ViewModel drives a whole page. A binding expression such as Text="{Binding CustomerName}" wires the target property (Label.Text) to a source property (ViewModel.CustomerName), resolved by name through reflection at runtime unless you enable compiled bindings.
Binding modes matter: OneWay is the default for most properties, TwoWay is the default for Entry.Text, Switch.IsToggled and other user-editable properties, OneWayToSource pushes only from view to ViewModel, and OneTime reads once and stops listening, which is a genuine performance win for static labels in a list template. Inside a DataTemplate the BindingContext is each item of the collection, so to reach a command on the page ViewModel you need a relative binding: in Xamarin.Forms 3.3 and later that is Source={RelativeSource AncestorType={x:Type vm:MainViewModel}}, or the older x:Reference trick pointing at the page. Other pieces of the same system are StringFormat for inline formatting, FallbackValue and TargetNullValue for missing data, and IValueConverter for type conversion. The most common bug in review is a binding that silently does nothing because the property name is misspelled: unlike code, a failed binding does not throw, it just logs to the debug output.
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:Shop.ViewModels"
x:Class="Shop.Views.OrdersPage"
x:DataType="vm:OrdersViewModel">
<ContentPage.BindingContext>
<vm:OrdersViewModel />
</ContentPage.BindingContext>
<StackLayout Padding="16">
<Label Text="{Binding Total, StringFormat='Total: {0:C}'}" />
<Entry Text="{Binding SearchTerm, Mode=TwoWay}" Placeholder="Search" />
<CollectionView ItemsSource="{Binding Orders}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="vm:OrderItem">
<Grid ColumnDefinitions="*,Auto">
<Label Text="{Binding Reference}" />
<Button Text="Open"
Grid.Column="1"
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:OrdersViewModel}}, Path=OpenCommand}"
CommandParameter="{Binding .}" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</StackLayout>
</ContentPage>
Q5Why does a Xamarin.Forms ViewModel need INotifyPropertyChanged, and what is the least painful way to implement it?
BasicMVVM
Answer
A binding subscribes to the source object's PropertyChanged event. If your ViewModel does not implement INotifyPropertyChanged, the binding reads the value exactly once when the BindingContext is attached and never updates again, which is the single most common reason a junior developer reports that 'the label is not refreshing'. The classic implementation is a BindableBase or ObservableObject base class with a SetProperty helper that compares the old and new value, assigns only if different, and raises PropertyChanged with [CallerMemberName] so you never type the property name as a string.
Skipping the equality check is a real bug, not a style issue: raising PropertyChanged on every set causes redundant layout passes inside list templates and can create infinite loops with TwoWay bindings. For collections, INotifyPropertyChanged is not enough, you need ObservableCollection<T> so that adds and removes raise CollectionChanged, and it must be mutated on the UI thread. In 2026 the idiomatic answer is the CommunityToolkit.Mvvm package (formerly Microsoft.Toolkit.Mvvm), which uses Roslyn source generators: mark a partial class with [ObservableObject] or derive from ObservableObject, decorate fields with [ObservableProperty], and the generator writes the property, the change notification and any dependent-property notifications for you.
[RelayCommand] does the same for commands including an async variant with an automatic CanExecute guard. Source generators also avoid the reflection cost that the older Fody-based weavers introduced, which matters on the AOT-compiled iOS side.
// Classic hand-written base class
public abstract class BindableBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
return true;
}
}
// CommunityToolkit.Mvvm source generators (preferred in 2026)
public partial class OrdersViewModel : ObservableObject
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasResults))]
private ObservableCollection<OrderItem> orders = new();
public bool HasResults => Orders.Count > 0;
[RelayCommand]
private async Task RefreshAsync(CancellationToken token)
{
var data = await _api.GetOrdersAsync(token);
Orders = new ObservableCollection<OrderItem>(data);
}
}
Key Points
- Without INotifyPropertyChanged a binding reads once and never updates
- Always compare old and new values before raising PropertyChanged
- ObservableCollection handles add and remove, and must be mutated on the UI thread
- CommunityToolkit.Mvvm source generators remove the boilerplate without runtime reflection
Q6When do nested StackLayouts become a real performance problem in Xamarin.Forms, and what do you use instead?
BasicLayout
Answer
Xamarin.Forms layout is a two-pass measure-and-arrange system. StackLayout has to measure each child to know how much space it consumed before positioning the next one, and when a StackLayout with unconstrained size contains another StackLayout, the measure work multiplies. Three or four levels deep inside a DataTemplate that is instantiated for every row is where it becomes visible: scroll stutter, slow page pushes, and on low-end Android devices the frame budget blows past sixteen milliseconds immediately.
The fix is to flatten the tree. A single Grid with explicit RowDefinitions and ColumnDefinitions positions every child in one pass and replaces three nested StackLayouts. Use Auto and star sizing deliberately, because a column of Auto width still has to measure its content, while a fixed or star width does not.
Other levers: set HasUnevenRows only when you truly need variable row heights, avoid VerticalOptions of FillAndExpand on children that do not need to expand (expansion forces an extra measure), never put a ScrollView inside another scrolling container in the same direction, and prefer a single Grid with overlapping children to an AbsoluteLayout with proportional flags when you can. On Android, also confirm fast renderers are enabled, they were the default from Xamarin.Forms 4.0 and replace the older container-wrapping renderers with direct native views, which removes one native ViewGroup per Forms element. Interviewers usually follow up by asking how you measured the improvement, so mention profiling on a real mid-range device, not the simulator.
<!-- Slow: three nested StackLayouts per row, instantiated per item -->
<StackLayout>
<StackLayout Orientation="Horizontal">
<Image Source="{Binding Avatar}" />
<StackLayout>
<Label Text="{Binding Name}" />
<Label Text="{Binding Subtitle}" />
</StackLayout>
</StackLayout>
</StackLayout>
<!-- Fast: one Grid, one measure pass -->
<Grid ColumnDefinitions="48,*" RowDefinitions="Auto,Auto" ColumnSpacing="12">
<Image Grid.RowSpan="2" Source="{Binding Avatar}" WidthRequest="48" HeightRequest="48" />
<Label Grid.Column="1" Text="{Binding Name}" />
<Label Grid.Column="1" Grid.Row="1" Text="{Binding Subtitle}" FontSize="12" />
</Grid>
Q7What does [XamlCompilation(XamlCompilationOptions.Compile)] actually do?
BasicXAML
Answer
It turns XAML into IL at build time instead of parsing it at runtime. Without XAML compilation, every page load reads the embedded .xaml file, parses the XML, resolves types and properties through reflection and builds the visual tree. With XAMLC on, the build step produces IL that constructs the same tree directly, the XAML file is stripped out of the assembly, and three things improve: page instantiation is measurably faster (the difference is most visible on cold start and on Android), the assembly is smaller, and, most valuable in practice, XAML errors become compile-time errors.
A typo in a property name, a missing namespace or a wrong x:Class no longer ships to QA as a runtime XamlParseException, the build fails on your machine. You apply it at assembly level in AssemblyInfo.cs or on individual classes, and you can opt a single page out with [XamlCompilation(XamlCompilationOptions.Skip)] if that page relies on runtime XAML loading through LoadFromXaml. Recent Xamarin.Forms versions enable compilation by default, but legacy codebases that were upgraded from 2.x often still carry an explicit Skip somewhere, and that is worth grepping for. XAMLC pairs with compiled bindings: adding x:DataType to a page or DataTemplate makes the compiler generate direct property accessors instead of reflection-based path resolution, which is the larger runtime win of the two.
// AssemblyInfo.cs, applies to every XAML file in the assembly
using Xamarin.Forms.Xaml;
[assembly: XamlCompilation(XamlCompilationOptions.Compile)]
// Opt a single page out when it loads XAML at runtime
[XamlCompilation(XamlCompilationOptions.Skip)]
public partial class DynamicFormPage : ContentPage
{
public DynamicFormPage(string xaml)
{
this.LoadFromXaml(xaml);
}
}
Key Points
- Compiles XAML to IL at build time and removes the .xaml file from the assembly
- Turns runtime XamlParseException into a build error
- Faster page construction, smaller assembly, better cold start
- Pair it with x:DataType compiled bindings for the bigger runtime win
Q8How do you update the UI from a background thread in Xamarin, and what happens if you forget?
BasicThreading
Answer
Both platforms require UI mutation on the main thread. If you forget, Android throws CalledFromWrongThreadException with the message 'Only the original thread that created a view hierarchy can touch its views', and iOS raises a UIKit consistency error, though on iOS the failure is worse because it sometimes does not throw at all and instead corrupts the layout or crashes minutes later somewhere unrelated. In Xamarin.Forms the marshalling API is Device.BeginInvokeOnMainThread; in Xamarin.Essentials and .NET MAUI it is MainThread.BeginInvokeOnMainThread, with MainThread.InvokeOnMainThreadAsync when you need to await the result and MainThread.IsMainThread to assert.
The trap that actually costs teams time is ObservableCollection: adding items from a Task.Run continuation raises CollectionChanged off the UI thread, and the renderer then mutates the native adapter or table view from the wrong thread. On Android that often surfaces as 'The content of the adapter has changed but ListView did not receive a notification', and on iOS as an NSInternalInconsistencyException about invalid row counts. The right pattern is to do all the network and parsing work on the background thread, then marshal a single assignment or a single batch of adds onto the main thread rather than marshalling once per item. With async/await, code that resumes on a captured SynchronizationContext is already back on the UI thread, so an await of an HTTP call inside an event handler does not need marshalling, but a continuation started with Task.Run or ContinueWith does.
// Wrong: mutating a bound collection from a worker thread
await Task.Run(async () =>
{
var orders = await _api.GetOrdersAsync();
foreach (var o in orders) Orders.Add(o); // CollectionChanged off-thread
});
// Right: work off-thread, marshal one batch back
var orders = await Task.Run(() => _api.GetOrdersAsync());
MainThread.BeginInvokeOnMainThread(() =>
{
Orders = new ObservableCollection<OrderItem>(orders);
});
// Xamarin.Forms equivalent
Device.BeginInvokeOnMainThread(() => StatusLabel.Text = "Synced");
// Await a result produced on the UI thread
var height = await MainThread.InvokeOnMainThreadAsync(() => MyLabel.Height);
Q9What is DependencyService, what are its limits, and what replaced it?
BasicDependency Injection
Answer
DependencyService is Xamarin.Forms' built-in service locator for platform-specific implementations. You declare an interface in shared code, write one implementation per platform project, register each with an assembly-level [assembly: Dependency(typeof(AndroidFileService))] attribute, and resolve at the call site with DependencyService.Get<IFileService>(). It works, and for a two-method interface in a small app it is perfectly reasonable.
Its limits are real, though, and interviewers expect you to name them. It is a service locator, not injection, so dependencies are hidden inside method bodies rather than declared in a constructor, which makes unit testing awkward. It resolves by scanning assemblies for the attribute, which costs startup time and interacts badly with the linker, if the linker strips the implementation type because nothing references it statically, DependencyService.Get returns null only in Release builds on device.
It supports no constructor parameters, no lifetime control beyond a global singleton or a new instance, and no way to register a mock cleanly. The modern answer is Microsoft.Extensions.DependencyInjection. In .NET MAUI, MauiProgram.CreateMauiApp builds an IServiceCollection where you register services, ViewModels and pages, and constructor injection works everywhere including into pages resolved by Shell navigation. You can retrofit the same container into a Xamarin.Forms app today by constructing a ServiceProvider in App.xaml.cs, which also makes the eventual MAUI migration much smaller.
// Legacy DependencyService
public interface IDeviceStorage { long FreeBytes(); }
[assembly: Dependency(typeof(AndroidDeviceStorage))]
namespace MyApp.Droid
{
public class AndroidDeviceStorage : IDeviceStorage
{
public long FreeBytes() => new StatFs(Android.OS.Environment.DataDirectory.Path).AvailableBytes;
}
}
var free = DependencyService.Get<IDeviceStorage>().FreeBytes();
// Modern MAUI container
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
builder.Services.AddSingleton<IDeviceStorage, DeviceStorageImpl>();
builder.Services.AddSingleton<IOrderApi, OrderApi>();
builder.Services.AddTransient<OrdersViewModel>();
builder.Services.AddTransient<OrdersPage>();
return builder.Build();
}
Key Points
- DependencyService is a service locator resolved through assembly attribute scanning
- Hidden dependencies, no constructor parameters, no clean mocking story
- Linker stripping makes it return null in Release-only failures
- Microsoft.Extensions.DependencyInjection is the MAUI answer and can be retrofitted into Forms
Q10What are the different ways to write platform-specific code from a shared Xamarin.Forms project?
BasicPlatform Abstraction
Answer
There are five, and picking the right one is the point of the question. For values, OnPlatform in XAML or the OnPlatform<T> generic in C# gives you a per-platform constant, ideal for padding, font sizes and status bar offsets. For small runtime branches, Device.RuntimePlatform in Xamarin.Forms (or DeviceInfo.Platform in Essentials and MAUI) lets you compare against Device.iOS, Device.Android and friends.
For behaviour that genuinely differs, define an interface in shared code and provide a per-platform implementation resolved through DependencyService or the DI container. For compile-time branching in a multi-targeted project, conditional symbols work: __ANDROID__ and __IOS__ in classic Xamarin, ANDROID and IOS in .NET for Android and .NET for iOS, along with the file-name convention where MyService.Android.cs and MyService.iOS.cs are included automatically in a MAUI single project. Finally, Xamarin.Forms platform-specifics (the Xamarin.Forms.PlatformConfiguration namespace) expose per-platform tweaks with no custom renderer at all, for example iOS safe-area insets, Android soft-input mode or the iOS blur effect.
The mistake juniors make is reaching for a custom renderer when an Effect or a platform-specific would do, or scattering Device.RuntimePlatform checks through business logic instead of behind an interface. In interviews, say clearly that runtime checks are for cosmetics and interfaces are for behaviour, that boundary is what keeps shared code testable.
<!-- XAML: per-platform constants -->
<ContentPage.Padding>
<OnPlatform x:TypeArguments="Thickness"
iOS="0,44,0,0"
Android="0,0,0,0" />
</ContentPage.Padding>
<!-- Platform-specific: iOS safe area, no renderer needed -->
<ContentPage xmlns:ios="clr-namespace:Xamarin.Forms.PlatformConfiguration.iOSSpecific;assembly=Xamarin.Forms.Core"
ios:Page.UseSafeArea="True" />
// C#: runtime branch for cosmetics only
var rowHeight = Device.RuntimePlatform == Device.iOS ? 44 : 48;
// Compile-time branch inside a platform project
#if __ANDROID__
var id = Android.Provider.Settings.Secure.GetString(ctx.ContentResolver, "android_id");
#elif __IOS__
var id = UIDevice.CurrentDevice.IdentifierForVendor.AsString();
#endif
Key Points
- OnPlatform for constants, Device.RuntimePlatform / DeviceInfo.Platform for cosmetics
- Interfaces plus DI for anything that is real behaviour
- Conditional symbols: __ANDROID__ / __IOS__ classic, ANDROID / IOS in .NET
- Xamarin.Forms platform-specifics cover many tweaks without a custom renderer
Q11What does Xamarin.Essentials provide, and what happened to it in .NET MAUI?
BasicDevice APIs
Answer
Xamarin.Essentials is the cross-platform device API library: Connectivity for network state, Geolocation and Geocoding, SecureStorage, Preferences, FileSystem, DeviceInfo, Battery, Clipboard, Share, Launcher, Browser, Permissions, Accelerometer and about thirty more. It exists so you do not write the same three platform implementations for reading the network state in every project. In .NET MAUI it was absorbed into the framework itself and split across namespaces: Microsoft.Maui.Networking, Microsoft.Maui.Devices, Microsoft.Maui.Devices.Sensors, Microsoft.Maui.Storage, Microsoft.Maui.ApplicationModel.
The API surface is largely source compatible, so most of the migration work is deleting the Xamarin.Essentials NuGet reference and fixing usings. Two production gotchas are worth raising unprompted. First, SecureStorage on Android is backed by the Android Keystore with an encrypted shared preferences file, and it can throw on devices where the keystore was invalidated (a fingerprint change, an OS upgrade, or auto-backup restoring the preferences file onto a device that lacks the matching key).
Handle the exception, clear the entry with SecureStorage.RemoveAll and force a re-login rather than crashing on startup. Second, Preferences maps to SharedPreferences and NSUserDefaults, it is plain text, so tokens never go there. Permissions.RequestAsync must be called on the main thread, and on Android you still have to declare the matching entries in AndroidManifest.xml, the runtime request alone is not enough.
// Connectivity, storage and permissions in one flow
if (Connectivity.NetworkAccess != NetworkAccess.Internet)
{
await Shell.Current.DisplayAlert("Offline", "Showing cached data", "OK");
return _cache.GetOrders();
}
var status = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();
if (status != PermissionStatus.Granted) return null;
var location = await Geolocation.GetLocationAsync(
new GeolocationRequest(GeolocationAccuracy.Medium, TimeSpan.FromSeconds(10)));
try
{
await SecureStorage.SetAsync("auth_token", token);
}
catch (Exception ex) // keystore invalidated after OS upgrade or restore
{
SecureStorage.RemoveAll();
_log.Warn(ex, "SecureStorage reset, forcing re-login");
}
// Never do this with a token
Preferences.Set("auth_token", token); // plain SharedPreferences / NSUserDefaults
Q12What are Behaviors, Triggers, Effects and Value Converters, and when do you reach for each?
BasicXAML
Answer
They are four different ways to add behaviour to XAML without writing a renderer, and they are frequently confused. A Value Converter implements IValueConverter and transforms data as it crosses a binding, for example bool to visibility, an enum to a colour, or a UTC DateTime to a local formatted string. Converters must be pure and fast because they run on every value change, including inside list templates.
A Trigger is declarative XAML state: a property Trigger reacts to a property on the control itself, a DataTrigger reacts to a bound value, an EventTrigger runs a TriggerAction on an event, and a MultiTrigger requires several conditions. Triggers can only set properties or invoke actions, they cannot hold state. A Behavior derives from Behavior<T>, is attached to a control, and gets OnAttachedTo and OnDetachingFrom callbacks, so it can subscribe to events and keep state.
That makes it the right tool for reusable validation, masked input, or a numeric-only Entry. The critical discipline is unsubscribing in OnDetachingFrom, otherwise the behaviour holds a reference to the control and leaks a page. An Effect is a small platform-specific tweak applied to an existing renderer through RoutingEffect and PlatformEffect, without replacing the renderer, useful for a shadow, a native focus colour or a keyboard flag. Rule of thumb from senior reviewers: converter for data, trigger for declarative state, behaviour for reusable interaction logic, effect for a one-property native tweak, and only then a renderer.
// Behavior with proper detach, reusable across pages
public class NumericValidationBehavior : Behavior<Entry>
{
protected override void OnAttachedTo(Entry entry)
{
entry.TextChanged += OnTextChanged;
base.OnAttachedTo(entry);
}
protected override void OnDetachingFrom(Entry entry)
{
entry.TextChanged -= OnTextChanged; // skip this and the page leaks
base.OnDetachingFrom(entry);
}
void OnTextChanged(object sender, TextChangedEventArgs e)
{
var entry = (Entry)sender;
entry.TextColor = decimal.TryParse(e.NewTextValue, out _) || string.IsNullOrEmpty(e.NewTextValue)
? Color.Default
: Color.Red;
}
}
<!-- DataTrigger: declarative state, no code -->
<Button Text="Submit" Command="{Binding SubmitCommand}">
<Button.Triggers>
<DataTrigger TargetType="Button" Binding="{Binding IsBusy}" Value="True">
<Setter Property="IsEnabled" Value="False" />
<Setter Property="Opacity" Value="0.5" />
</DataTrigger>
</Button.Triggers>
</Button>
Q13How does the NavigationPage push/pop stack differ from Shell navigation, and when is a page actually released?
BasicNavigation
Answer
NavigationPage is a plain stack. PushAsync adds a page and PopAsync removes it, PushModalAsync uses a separate modal stack tracked by Navigation.ModalStack, and the Android hardware back button does not pop modals for you. Shell, added in Xamarin.Forms 4.0, replaces that with a URI-addressed structure: you declare FlyoutItem, Tab and ShellContent in AppShell.xaml, register detail pages with Routing.RegisterRoute, and navigate with Shell.Current.GoToAsync.
Route semantics matter in interviews: a relative route pushes, an absolute route beginning with // resets the whole stack to that location, and ".." pops, so GoToAsync("../..?refresh=true") pops two pages and passes a parameter on the way. Parameters arrive either through [QueryProperty] on the target page or by implementing IQueryAttributable, which is the better option when you need to receive several values or an object passed through the dictionary overload of GoToAsync. Page lifetime is the part people get wrong.
Pages declared inline as ShellContent content are constructed eagerly and kept alive for the life of the shell item, so their ViewModels keep timers and subscriptions running in the background. Using ContentTemplate with a DataTemplate instead gives you lazy construction on first navigation. Pages pushed with GoToAsync are popped and become collectable, tab roots do not. The related trap is OnAppearing, which fires on every return to a page, not just the first: anything you subscribe there must be torn down in OnDisappearing or you end up with N subscriptions after N visits.
<!-- AppShell.xaml: lazy page creation with ContentTemplate -->
<Shell xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:v="clr-namespace:Shop.Views"
x:Class="Shop.AppShell">
<TabBar>
<ShellContent Title="Orders" Route="orders"
ContentTemplate="{DataTemplate v:OrdersPage}" />
<ShellContent Title="Profile" Route="profile"
ContentTemplate="{DataTemplate v:ProfilePage}" />
</TabBar>
</Shell>
// Register detail routes once, then navigate by URI
Routing.RegisterRoute("orders/detail", typeof(OrderDetailPage));
await Shell.Current.GoToAsync($"orders/detail?id={order.Id}"); // push
await Shell.Current.GoToAsync("//orders"); // reset stack
await Shell.Current.GoToAsync("..?refreshed=true"); // pop with data
[QueryProperty(nameof(OrderId), "id")]
public partial class OrderDetailPage : ContentPage
{
public string OrderId { get; set; }
}
Key Points
- GoToAsync route syntax: relative pushes, // resets the stack, .. pops
- [QueryProperty] or IQueryAttributable receive parameters
- ContentTemplate gives lazy page creation, inline content does not
- OnAppearing fires on every return, so pair every subscription with OnDisappearing
Q14What is the difference between StaticResource and DynamicResource, and how do you ship dark mode in Xamarin.Forms?
BasicStyling
Answer
StaticResource resolves the key once, when the XAML is parsed or the compiled XAML runs, and copies the value into the target property. DynamicResource keeps a live link to the dictionary key, so replacing that key's value in Application.Current.Resources later updates every consumer. That is the entire mechanism behind runtime theming: swap a merged ResourceDictionary and only the DynamicResource consumers repaint.
The trade-off is cost, DynamicResource holds a subscription per usage, so keep it for values that genuinely change and use StaticResource everywhere else. For light and dark specifically, Xamarin.Forms 4.6 added AppThemeBinding, which reads Application.Current.RequestedTheme and re-evaluates itself when the OS theme flips, so you write {AppThemeBinding Light=#FFFFFF, Dark=#121212} and stop maintaining two dictionaries. Platform opt-in is still required: on iOS do not pin UIUserInterfaceStyle in Info.plist, and on Android target API 29 or later with an AppCompat DayNight parent theme, otherwise RequestedTheme reports Unspecified and everything falls back to Light.
Application.Current.UserAppTheme forces a theme regardless of the OS setting, which is how apps expose an in-app light/dark/system preference, and RequestedThemeChanged lets you react in code. On styles themselves, an implicit style (TargetType with no x:Key) applies to every control of that type in scope, an explicit style is keyed and applied with Style="{StaticResource ...}", and BasedOn gives inheritance. Put global styles in App.xaml, because a ResourceDictionary declared on a page is rebuilt every time that page is constructed, which is measurable in list-heavy screens.
<!-- App.xaml: theme-aware resources, no dictionary swapping -->
<Application.Resources>
<ResourceDictionary>
<Color x:Key="PageBackground">#FFFFFF</Color>
<Style TargetType="Label">
<Setter Property="TextColor"
Value="{AppThemeBinding Light=#1A1A1A, Dark=#F2F2F2}" />
</Style>
<Style x:Key="CardFrame" TargetType="Frame">
<Setter Property="BackgroundColor"
Value="{AppThemeBinding Light=#FFFFFF, Dark=#1E1E1E}" />
<Setter Property="HasShadow" Value="False" />
</Style>
</ResourceDictionary>
</Application.Resources>
// In-app override, persisted as a user preference
Application.Current.UserAppTheme = Preferences.Get("theme", "system") switch
{
"dark" => OSAppTheme.Dark,
"light" => OSAppTheme.Light,
_ => OSAppTheme.Unspecified,
};
Application.Current.RequestedThemeChanged += (s, e) =>
Debug.WriteLine($"OS theme now {e.RequestedTheme}");
Q15Walk through writing a custom renderer: OnElementChanged, ExportRenderer, and the two mistakes that always show up in review.
IntermediateCustom Renderers
Answer
A custom renderer replaces or extends the native view that backs a Forms control. You subclass the platform renderer (EntryRenderer, ViewRenderer<TFormsView, TNativeView>, ListViewRenderer and so on) inside the platform project, override OnElementChanged, and register it with an assembly-level attribute: [assembly: ExportRenderer(typeof(BorderlessEntry), typeof(BorderlessEntryRenderer))]. Inside OnElementChanged, e.OldElement is the Forms element being detached, e.NewElement the one being attached, Element is the current Forms control and Control is the native view (EditText on Android, UITextField on iOS).
Two mistakes come up in every review. First, calling base.OnElementChanged after your code instead of before: Control is null until base runs, so the NullReferenceException is guaranteed. Second, not unsubscribing when e.NewElement is null, because renderers are recycled across elements and a live native event subscription keeps the old element and its page alive.
Use OnElementPropertyChanged to react to bindable property changes, comparing e.PropertyName against the generated PropertyName constant rather than a magic string. Renderers are heavier than they look: each one adds indirection, and on Android before fast renderers (default from Xamarin.Forms 4.0) each wrapped the native view in an extra ViewGroup. Reach for a PlatformEffect first when you only need to set one native property, since effects attach to the existing renderer and can be applied per control in XAML. In .NET MAUI renderers still run through Microsoft.Maui.Controls.Compatibility, which is the migration lifeline, but handlers with mappers are the destination.
using Android.Content;
using Xamarin.Forms;
using Xamarin.Forms.Platform.Android;
[assembly: ExportRenderer(typeof(BorderlessEntry), typeof(BorderlessEntryRenderer))]
namespace Shop.Droid.Renderers
{
public class BorderlessEntryRenderer : EntryRenderer
{
public BorderlessEntryRenderer(Context context) : base(context) { }
protected override void OnElementChanged(ElementChangedEventArgs<Entry> e)
{
base.OnElementChanged(e); // must run first, Control is null before it
if (e.OldElement != null && Control != null)
Control.EditorAction -= OnEditorAction; // recycled renderer: detach
if (e.NewElement != null && Control != null)
{
Control.SetBackground(null);
Control.SetPadding(0, 0, 0, 0);
Control.EditorAction += OnEditorAction;
}
}
protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
{
base.OnElementPropertyChanged(sender, e);
if (e.PropertyName == Entry.TextColorProperty.PropertyName)
Control.SetTextColor(Element.TextColor.ToAndroid());
}
void OnEditorAction(object sender, TextView.EditorActionEventArgs e) { /* ... */ }
}
}
Key Points
- base.OnElementChanged first, Control does not exist before it
- Detach native event handlers when e.OldElement is not null
- OnElementPropertyChanged compares against BindableProperty.PropertyName
- Prefer PlatformEffect for single-property tweaks, renderer only when you must
Q16Explain ListViewCachingStrategy and why CollectionView has no RetainElement mode.
IntermediateLists
Answer
ListView takes a ListViewCachingStrategy in its constructor, or a CachingStrategy attribute in XAML. RetainElement, the legacy default kept for backwards compatibility, creates a cell per row and holds it forever, so memory grows linearly with row count and a two thousand row list will kill a low-RAM Android device. RecycleElement reuses a small pool of cells and only reassigns BindingContext, which is what the native list controls do anyway.
RecycleElementAndDataTemplate and RecycleElementAndDataTemplateByType extend that to DataTemplateSelector scenarios, the ByType variant keying the pool on the item type. CollectionView, added in Xamarin.Forms 4.3, only recycles: it is backed by RecyclerView on Android and UICollectionView on iOS, and exposing a retain mode would mean fighting those controls, so the API simply does not offer one. Once you are recycling, cells must be stateless with respect to their previous row.
The recurring bugs are a cell that subscribes to an event in its constructor and never unsubscribes, a converter that mutates the cell instead of returning a value, an image that briefly shows the previous row's picture because the source loads asynchronously, and a ViewCell overriding OnBindingContextChanged without calling base. CollectionView also gives you ItemsUpdatingScrollMode, EmptyView, grouping, and RemainingItemsThresholdReached for infinite scroll, but no built-in separators and different selection semantics from ListView. For genuinely large data, page from the server at the threshold instead of binding twenty thousand items and trusting virtualisation to hide the cost of building them all.
<!-- ListView: recycle explicitly, the default is the slow one -->
<ListView ItemsSource="{Binding Orders}"
CachingStrategy="RecycleElement"
HasUnevenRows="False"
RowHeight="64" />
<!-- CollectionView: recycling only, plus server-side paging -->
<CollectionView ItemsSource="{Binding Orders}"
RemainingItemsThreshold="10"
RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}"
ItemsUpdatingScrollMode="KeepScrollOffset">
<CollectionView.EmptyView>
<Label Text="No orders yet" HorizontalOptions="Center" />
</CollectionView.EmptyView>
</CollectionView>
// Paging command: keep the bound collection small
[RelayCommand]
private async Task LoadMoreAsync()
{
if (_isPaging || _endReached) return;
_isPaging = true;
var page = await _api.GetOrdersAsync(skip: Orders.Count, take: 30);
foreach (var o in page) Orders.Add(o);
_endReached = page.Count < 30;
_isPaging = false;
}
Q17Why does an app work in Debug and crash in Release on device, and how do you control the linker?
IntermediateBuild and Linker
Answer
The linker is an IL trimmer that removes types and members nothing statically references, which is how a Xamarin app gets from a very large BCL down to a shippable binary. There are three modes: None, SDK Assemblies Only (the usual Android Release default) and Link All Assemblies. Debug builds normally do not link, so the classic report is 'works in Debug, crashes in Release on device'.
Anything reached only through reflection is invisible to the trimmer: a model deserialised by Newtonsoft.Json whose properties are never touched in code, a DependencyService implementation, Activator.CreateInstance, a type named only in XAML in a Skip-compiled page. The symptoms are MissingMethodException, TypeInitializationException, DependencyService.Get returning null, or a deserialised object where every property is at its default. There are four ways to keep code alive.
The [Preserve] attribute on a type or member, with Preserve(AllMembers = true) for models. An assembly-level [assembly: Preserve] for your own assembly. A LinkDescription XML file (build action LinkDescription) for third-party assemblies you cannot annotate.
And the blunt escape hatch, AndroidLinkSkip on Android or MtouchExtraArgs with --linkskip=AssemblyName on iOS, excluding a whole assembly from trimming. In .NET for Android and .NET for iOS this is ILLink instead: PublishTrimmed, TrimMode, ILLink descriptor files and trim analysis warnings you should not suppress. The interviewer's follow-up is process, not syntax: build the Release AAB and IPA and run a full regression on physical hardware before every submission, because no amount of Debug testing catches a trimming regression.
// Preserve a DTO the trimmer cannot see through reflection
[Preserve(AllMembers = true)]
public class OrderDto
{
public string Reference { get; set; }
public decimal Amount { get; set; }
}
<!-- LinkDescription file for a third-party assembly (build action: LinkDescription) -->
<linker>
<assembly fullname="Acme.Payments">
<type fullname="Acme.Payments.CheckoutRequest" preserve="all" />
<type fullname="Acme.Payments.CheckoutClient">
<method name="Create" />
</type>
</assembly>
</linker>
<!-- Classic Xamarin.Android Release properties -->
<PropertyGroup Condition="'$(Configuration)'=='Release'">
<AndroidLinkMode>SdkOnly</AndroidLinkMode>
<AndroidLinkSkip>Acme.Payments</AndroidLinkSkip>
</PropertyGroup>
<!-- .NET for Android / iOS equivalent -->
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
</PropertyGroup>
Key Points
- Debug does not link, Release does, so trimming bugs are Release-only
- Reflection, JSON models and DependencyService are the usual casualties
- [Preserve], LinkDescription XML, AndroidLinkSkip and --linkskip are the controls
- In .NET 8 this is ILLink with PublishTrimmed and descriptor files
Q18What causes 'JNI ERROR (app bug): global reference table overflow' in a Xamarin.Android app?
IntermediateMemory
Answer
Every managed object wrapping a Java object holds a JNI global reference to its peer. That covers everything deriving from Java.Lang.Object: Activity, View, Handler, Drawable, Bitmap, Cursor, Typeface, your own Java-derived listeners, and the hidden peer that Xamarin creates for a delegate passed to a Java API. The ART global reference table is bounded, historically 52000 entries on device and far fewer on old emulators, and when you exhaust it the process aborts with that message plus a dump of the most common reference types, which is your best clue about what leaked.
The awkward part is the collection model: because the Java side can reach the peer, the .NET GC alone cannot free it. Freeing requires a cross-VM cycle where the Mono GC and ART agree the object is unreachable, and any Java-side root (a static field, a registered listener, an in-flight Handler message, an Activity in the back stack) pins the pair. Practical rules: Dispose short-lived Java.Lang.Object subclasses such as Bitmap, Cursor and custom Drawables instead of waiting for finalisation, never hold static references to Context, Activity or View, unregister BroadcastReceivers and listeners in OnPause or OnDestroy, and be suspicious of code that creates Java peers in a loop.
To diagnose, turn on gref logging with adb shell setprop debug.mono.log gref,gc and watch the counts in logcat while you navigate into and out of the suspect page ten times. A count that climbs monotonically and never comes back down after a forced GC is the leak; a count that spikes and settles is normal churn.
# Enable gref + GC logging, then reproduce the navigation loop
adb shell setprop debug.mono.log gref,gc
adb shell am force-stop com.shop.app
adb logcat -c && adb logcat | grep -E "grefc|GC_"
# Native heap growth over the same loop
adb shell dumpsys meminfo com.shop.app | head -30
// Dispose Java peers you own instead of waiting for finalisation
using (var cursor = resolver.Query(uri, null, null, null, null))
using (var bitmap = BitmapFactory.DecodeStream(stream))
{
imageView.SetImageBitmap(bitmap);
} // both peers released here, not two GC cycles later
// Static Context is the single most common leak in Xamarin.Android
public static class Bad { public static Activity Current; } // leaks the Activity
public static class Better { public static WeakReference<Activity> Current; }
Q19A page is never garbage collected after the user navigates away. How do you find and fix that in Xamarin.Forms?
IntermediateMemory
Answer
Pages leak when something with a longer lifetime holds a reference to them. Four causes account for most real cases. A singleton or static service exposing a C# event that a page or ViewModel subscribed to and never unsubscribed.
MessagingCenter.Subscribe without a matching Unsubscribe, which is particularly nasty because MessagingCenter holds a strong reference to the subscriber, so a page that subscribes in its constructor can never die. A Behavior or Effect that hooks a control event in OnAttachedTo and skips OnDetachingFrom. And a System.Timers.Timer, CancellationTokenSource or long-running Task closure capturing this.
On iOS there is a second family: retain cycles between a managed object and its native peer, typically a renderer that is also its native view's delegate. The diagnosis technique that works without a profiler is a WeakReference probe in a debug build: capture a WeakReference to the page in OnDisappearing, force GC.Collect twice with WaitForPendingFinalizers between them, and log whether the target is still alive. If it is, confirm with the Mono heapshot profiler on Android or Instruments Allocations on iOS to see the retaining chain. The fixes are unglamorous: unsubscribe in OnDisappearing, implement IDisposable on the ViewModel and call it from the page, prefer WeakEventManager over raw events on long-lived publishers, and drop MessagingCenter in favour of an injected aggregator or, once you are on MAUI, CommunityToolkit.Mvvm's WeakReferenceMessenger, which does not root its subscribers.
// Debug-only probe: does this page actually die?
public partial class OrdersPage : ContentPage
{
protected override void OnDisappearing()
{
base.OnDisappearing();
MessagingCenter.Unsubscribe<SyncService, int>(this, "sync-complete");
(BindingContext as IDisposable)?.Dispose();
#if DEBUG
var probe = new WeakReference(this);
Device.StartTimer(TimeSpan.FromSeconds(3), () =>
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Debug.WriteLine($"OrdersPage alive after GC: {probe.IsAlive}");
return false;
});
#endif
}
}
// MessagingCenter roots the subscriber; WeakReferenceMessenger does not
MessagingCenter.Subscribe<SyncService, int>(this, "sync-complete", (s, count) => Reload(count));
// MAUI / CommunityToolkit.Mvvm replacement
WeakReferenceMessenger.Default.Register<SyncCompleteMessage>(this, (r, m) => Reload(m.Count));
Key Points
- MessagingCenter holds strong references, so every Subscribe needs an Unsubscribe
- Behaviors and Effects leak when OnDetachingFrom does not undo OnAttachedTo
- WeakReference plus two forced GCs is a cheap in-app leak test
- WeakEventManager and WeakReferenceMessenger remove the whole class of bug
Q20Which HttpClient handler should a Xamarin app use, and what changes when you switch?
IntermediateNetworking
Answer
The handler is a build-time choice and it changes TLS support, binary size and certificate behaviour. On Android the options are AndroidClientHandler (AndroidMessageHandler in .NET for Android), which delegates to the platform's networking stack, and the legacy managed HttpClientHandler, which uses Mono's own TLS implementation. The platform handler is the right default: it gets TLS 1.3, HTTP/2, OS security patches and a smaller payload.
The consequence you must be able to state is trust: the device trust store now applies, and since Android 7 user-installed CA certificates are not trusted by default, which is exactly why QA's Charles or Fiddler proxy suddenly stops decrypting traffic and why you need a debug-only network security config rather than a code change. On iOS the choice is NSUrlSessionHandler (default, backed by NSURLSession, supports background transfer and honours the system proxy) or the managed handler. You set these in project properties or directly in the csproj with AndroidHttpClientHandlerType and, in .NET, UseNativeHttpHandler.
Independent of the handler, the lifetime rules still bite: one HttpClient for the app rather than one per call, because each instance carries its own connection pool and socket exhaustion is real on Android; put retry, timeout and circuit-breaker policies in a DelegatingHandler with Polly instead of duplicating try/catch in every service; and set Timeout explicitly because the default of 100 seconds is far too long for a mobile network in a tunnel. For certificate pinning, use ServerCertificateCustomValidationCallback and compare the SubjectPublicKeyInfo hash, not the whole certificate, so a routine renewal does not brick installed apps.
<!-- Choose the platform handler explicitly -->
<PropertyGroup>
<!-- classic Xamarin.Android -->
<AndroidHttpClientHandlerType>Xamarin.Android.Net.AndroidClientHandler</AndroidHttpClientHandlerType>
<!-- .NET for Android / iOS -->
<UseNativeHttpHandler>true</UseNativeHttpHandler>
</PropertyGroup>
// One client for the app, policies in a handler, public-key pinning
var platformHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (msg, cert, chain, errors) =>
{
if (errors != SslPolicyErrors.None) return false;
var spki = SHA256.HashData(cert.PublicKey.EncodedKeyValue.RawData);
return PinnedKeys.Contains(Convert.ToBase64String(spki));
}
};
var policy = Policy
.Handle<HttpRequestException>()
.OrResult<HttpResponseMessage>(r => (int)r.StatusCode >= 500)
.WaitAndRetryAsync(3, i => TimeSpan.FromSeconds(Math.Pow(2, i)));
HttpClient Client { get; } = new HttpClient(new PolicyHttpMessageHandler(policy)
{
InnerHandler = platformHandler
})
{
BaseAddress = new Uri("https://api.example.in/"),
Timeout = TimeSpan.FromSeconds(20),
};
Q21How do you run periodic background sync in a Xamarin app on both platforms, and why can you not promise an exact schedule?
IntermediateBackground Execution
Answer
Neither platform lets an app simply keep running. On Android, since API 26 a plain background service is stopped within minutes of the app leaving the foreground. The two supported routes are a foreground service with a persistent notification and a declared foregroundServiceType (required from API 29 for location, camera and microphone, and from Android 14 for every type, with a matching permission), or WorkManager for deferrable work with constraints such as network availability and charging state.
Doze and App Standby buckets then batch that work into maintenance windows. On devices from Xiaomi, Oppo, Vivo and Samsung, which dominate the Indian handset base, OEM battery managers are considerably more aggressive than stock AOSP, so a field-force app that depends on periodic sync should request a battery optimisation exemption and still behave correctly when the user refuses. On iOS the model is tighter: backgrounding gives you seconds unless you take a UIApplication.BeginBackgroundTask, which buys roughly thirty.
Periodic work goes through BGTaskScheduler, either BGAppRefreshTask (short, opportunistic, scheduled by the system from observed usage, never guaranteed) or BGProcessingTask (longer, usually overnight on charge). Both need UIBackgroundModes entries in Info.plist and registration before application launch finishes. Silent push with content-available can wake the app but is rate limited and delivered at Apple's discretion.
From Xamarin you reach WorkManager through Xamarin.AndroidX.Work.Runtime and BGTaskScheduler through the iOS bindings. The design conclusion interviewers want to hear: never promise exact-time background sync, make sync idempotent and resumable, and treat background execution as a bonus on top of a foreground sync that runs on app open.
// Android: WorkManager worker, constrained and idempotent
public class SyncWorker : Worker
{
public SyncWorker(Context c, WorkerParameters p) : base(c, p) { }
public override Result DoWork()
{
try { SyncService.Instance.RunAsync().GetAwaiter().GetResult(); return Result.InvokeSuccess(); }
catch (HttpRequestException) { return Result.InvokeRetry(); }
}
}
var constraints = new Constraints.Builder()
.SetRequiredNetworkType(NetworkType.Connected)
.Build();
var request = PeriodicWorkRequest.Builder.From<SyncWorker>(TimeSpan.FromMinutes(15))
.SetConstraints(constraints).Build();
WorkManager.GetInstance(context).EnqueueUniquePeriodicWork(
"order-sync", ExistingPeriodicWorkPolicy.Keep, request);
// iOS: register before launch completes, always reschedule
BGTaskScheduler.Shared.Register("ai.shop.refresh", null, task =>
{
ScheduleRefresh();
var work = SyncService.Instance.RunAsync();
task.ExpirationHandler = () => SyncService.Instance.Cancel();
work.ContinueWith(t => task.SetTaskCompleted(t.IsCompletedSuccessfully));
});
void ScheduleRefresh() => BGTaskScheduler.Shared.Submit(
new BGAppRefreshTaskRequest("ai.shop.refresh")
{ EarliestBeginDate = (NSDate)DateTime.Now.AddMinutes(30) }, out _);
Key Points
- Android: foreground service with foregroundServiceType, or WorkManager for deferrable work
- iOS: BGAppRefreshTask and BGProcessingTask, registered before launch finishes
- Doze, App Standby and Indian OEM battery managers make timing unreliable
- Sync must be idempotent and resumable, with a foreground sync as the real guarantee
Q22Which async/await mistakes actually crash Xamarin apps in production?
IntermediateConcurrency
Answer
Four, in order of how often they ship. First, async void. An exception thrown inside an async void method cannot be caught by the caller, it is posted to the SynchronizationContext and takes the process down.
The only legitimate use is a real event handler, and even there the whole body should sit inside a try/catch. Second, blocking on a Task with .Result or .Wait() on the UI thread. The awaited continuation is scheduled back onto the captured context, which is the thread you are blocking, so you deadlock hard, and it is the classic cause of an app that freezes on a specific screen only on slower networks.
ConfigureAwait(false) in service and library code stops the capture and is both faster and deadlock resistant, but it must not be used where the continuation touches UI. The workable convention is ConfigureAwait(false) below the ViewModel and plain await inside it. Third, Task.Run around work that is already asynchronous.
Wrapping an HttpClient call in Task.Run just burns a thread-pool thread; Task.Run is for CPU-bound work such as parsing a large payload or resizing an image. Fourth, unobserved fire and forget. A faulted Task with no continuation raises TaskScheduler.UnobservedTaskException at a random later GC, so use SafeFireAndForget or an explicit fault-logging continuation.
Cancellation deserves special attention on mobile: a page the user has swiped away should abandon its in-flight requests, so keep a CancellationTokenSource per page, cancel it in OnDisappearing and pass the token into HttpClient. And because ICommand.Execute returns void, bind async work through AsyncCommand or [RelayCommand] on a Task-returning method, which also exposes IsRunning to block double submission.
// Deadlock: .Result on the UI thread with a captured context
var orders = _api.GetOrdersAsync().Result; // freezes the app
// Service layer: no context capture
public async Task<List<Order>> GetOrdersAsync(CancellationToken ct)
{
var res = await _http.GetAsync("orders", ct).ConfigureAwait(false);
res.EnsureSuccessStatusCode();
return await res.Content.ReadFromJsonAsync<List<Order>>(cancellationToken: ct)
.ConfigureAwait(false);
}
// ViewModel: cancel in-flight work when the page goes away
public partial class OrdersViewModel : ObservableObject, IDisposable
{
private CancellationTokenSource _cts = new();
[RelayCommand] // generates IsLoadRunning + CanExecute guard
private async Task LoadAsync()
{
try { Orders = new(await _api.GetOrdersAsync(_cts.Token)); }
catch (OperationCanceledException) { /* page left, nothing to do */ }
catch (HttpRequestException ex) { Error = ex.Message; }
}
public void Dispose() { _cts.Cancel(); _cts.Dispose(); }
}
Q23How do you set up local storage with sqlite-net-pcl, and what goes wrong with it in the field?
IntermediateLocal Data
Answer
sqlite-net-pcl is the default choice: attribute-mapped POCOs, a SQLiteAsyncConnection, and a LINQ-ish query API. You put the file in FileSystem.AppDataDirectory, open one connection for the life of the app (SQLiteAsyncConnection serialises work behind a lock per connection string, so opening one per call is both slower and a source of 'database is locked'), and pass flags explicitly: ReadWrite, Create, SharedCache and FullMutex for multithreaded access. Attributes worth knowing are [PrimaryKey], [AutoIncrement], [Indexed], [Unique], [MaxLength] and [Ignore].
Four things go wrong in production. Migrations: CreateTableAsync adds missing columns automatically but never drops, renames or retypes them, so you must keep your own schema version in a settings row and run explicit ExecuteAsync statements for anything else. Bulk writes: without RunInTransactionAsync each insert is its own transaction and a thousand rows can take seconds on a low-end device with slow storage.
Encryption: sqlite-net-pcl stores plain text, so regulated apps use sqlite-net-sqlcipher with the key held in SecureStorage, never in a constant. And backup: on Android, allowBackup left at true means the database can be restored onto a different device where the matching SecureStorage key does not exist, which produces crash-on-launch reports that look inexplicable until you connect the two. On iOS, mark a cache database with NSUrlIsExcludedFromBackupKey or Apple may reject the app for backing up regenerable data. Alternatives you should be able to name: Realm for reactive object storage, LiteDB for document style, and Akavache when you only need a cached key-value store.
public class LocalDb
{
private readonly SQLiteAsyncConnection _db;
public LocalDb()
{
var path = Path.Combine(FileSystem.AppDataDirectory, "orders.db3");
_db = new SQLiteAsyncConnection(path,
SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create |
SQLiteOpenFlags.SharedCache | SQLiteOpenFlags.FullMutex);
}
public async Task InitAsync()
{
await _db.CreateTableAsync<OrderRow>(); // adds new columns only
var version = await _db.ExecuteScalarAsync<int>("PRAGMA user_version");
if (version < 2)
{
await _db.ExecuteAsync("ALTER TABLE OrderRow ADD COLUMN SyncedUtc TEXT");
await _db.ExecuteAsync("PRAGMA user_version = 2");
}
}
public Task SaveBatchAsync(IEnumerable<OrderRow> rows) =>
_db.RunInTransactionAsync(c => { foreach (var r in rows) c.InsertOrReplace(r); });
}
public class OrderRow
{
[PrimaryKey] public string Id { get; set; }
[Indexed] public string CustomerId { get; set; }
public decimal Amount { get; set; }
[Ignore] public bool IsSelected { get; set; }
}
Key Points
- One SQLiteAsyncConnection for the app, with SharedCache and FullMutex flags
- CreateTableAsync only adds columns, so version your schema with PRAGMA user_version
- RunInTransactionAsync turns seconds of bulk insert into milliseconds
- sqlite-net-sqlcipher plus SecureStorage for regulated data, and disable Android allowBackup
Q24What is actually unit testable in a Xamarin.Forms app, and how do you test the parts that are not?
IntermediateTesting
Answer
Anything that does not touch Forms static state runs in a plain xUnit or NUnit project with no device and no emulator: ViewModels, services, mappers, validators, IValueConverter implementations. What blocks you is the statics. Device.BeginInvokeOnMainThread, DependencyService.Get, Application.Current, MessagingCenter and the Xamarin.Essentials statics all throw or return null outside an initialised Forms application, so a ViewModel that calls Connectivity.NetworkAccess directly is untestable by construction.
The fix is to wrap each one behind an interface you inject: IConnectivity, ISecureStorageService, IMainThreadDispatcher, INavigationService. That is not a purity argument, it is the same refactor the MAUI migration needs, since MAUI ships DI-friendly interfaces (IConnectivity, IGeolocation, IPreferences) precisely because the statics were untestable. When you genuinely need the Forms runtime in a unit test process, Xamarin.Forms.Mocks initialises enough of the framework to construct pages and exercise bindings.
For UI tests, the classic path is Xamarin.UITest driven by AutomationId, historically executed on App Center Test, which retired on 31 March 2025, so teams have moved to Appium with the UiAutomator2 and XCUITest drivers running on BrowserStack App Automate, LambdaTest or a self-hosted grid with a Mac mini. Set AutomationId on every element you plan to query, because on iOS it becomes the accessibility identifier and without it your selectors depend on visible text, which breaks the moment someone edits a label. Realistic interview answer: most Xamarin fleets in maintenance have close to zero automated coverage and a heavy manual regression pack, so propose ViewModel tests first because they are cheap and catch migration regressions, then a smoke suite of five to ten critical journeys.
// Untestable: statics reached directly from the ViewModel
if (Connectivity.NetworkAccess != NetworkAccess.Internet) return;
// Testable: inject the platform surface
public interface IConnectivityService { bool IsOnline { get; } }
public class OrdersViewModel
{
public OrdersViewModel(IOrderApi api, IConnectivityService net, IMainThread ui) { ... }
}
// xUnit test, no device required
[Fact]
public async Task LoadAsync_WhenOffline_ShowsCachedOrders()
{
var net = Substitute.For<IConnectivityService>();
net.IsOnline.Returns(false);
var api = Substitute.For<IOrderApi>();
var cache = new FakeCache(new[] { new Order { Id = "A1" } });
var vm = new OrdersViewModel(api, net, new ImmediateMainThread(), cache);
await vm.LoadCommand.ExecuteAsync(null);
Assert.Single(vm.Orders);
await api.DidNotReceive().GetOrdersAsync(Arg.Any<CancellationToken>());
}
<!-- Give every queryable element a stable id for Appium -->
<Button AutomationId="submitOrderButton" Text="Submit" />
Q25An Android release build is 90 MB and takes four seconds to show the first page. Which build settings do you change?
IntermediateBuild and Performance
Answer
Size first. Ship an Android App Bundle rather than a universal APK, which is mandatory for Play anyway, so each device downloads only its ABI, density and language slice. Confirm the trimmer is on (SdkOnly at minimum) and that R8 is the shrinker rather than the old ProGuard path.
Limit RuntimeIdentifiers to android-arm64 and android-arm, since x86 slices exist only for emulators and inflate the bundle. Turn on resource shrinking, strip debug symbols from the release configuration, and audit your NuGet dependencies, a single unused SDK that pulls in the whole AndroidX surface is often tens of megabytes. Compressed native libraries and AndroidStripILAfterAOT reduce the payload further when AOT is enabled.
Startup next. Measure before you tune: adb shell am start -W gives you TotalTime for a cold start, and you should record it on a real mid-range device, not a flagship. AOT compilation removes JIT work at launch but grows the binary; profiled AOT is the better trade, it AOT-compiles only the methods in a recorded startup profile, so you get most of the launch benefit for a fraction of the size.
Beyond flags, the usual wins are structural: do not register hundreds of services or run migrations synchronously in App's constructor, make the first page trivial and load its data after it appears, avoid a heavy MainActivity theme, and defer analytics and crash SDK initialisation past first paint. Interviewers usually ask what you measured, so quote before and after numbers from am start -W rather than describing the flags alone.
<!-- Release properties that move the needle (classic Xamarin.Android) -->
<PropertyGroup Condition="'$(Configuration)'=='Release'">
<AndroidPackageFormat>aab</AndroidPackageFormat>
<AndroidLinkMode>SdkOnly</AndroidLinkMode>
<AndroidLinkTool>r8</AndroidLinkTool>
<AndroidEnableProfiledAot>true</AndroidEnableProfiledAot>
<AndroidStripILAfterAOT>true</AndroidStripILAfterAOT>
<AndroidEnableSGenConcurrent>true</AndroidEnableSGenConcurrent>
<AndroidUseAapt2>true</AndroidUseAapt2>
<RuntimeIdentifiers>android-arm64;android-arm</RuntimeIdentifiers>
<DebugSymbols>false</DebugSymbols>
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
# Measure cold start on a real device, three runs, take the median
adb shell am force-stop com.shop.app
adb shell am start -W -n com.shop.app/crc64.MainActivity
# TotalTime: 3814 -> after profiled AOT + deferred init: 1620
# See what is actually inside the bundle
bundletool build-apks --bundle=app.aab --output=app.apks
unzip -l app.apks | sort -k1 -n | tail -20
Key Points
- AAB plus ABI restriction to arm64 and arm removes most of the download size
- R8 shrinking, resource shrinking and AndroidStripILAfterAOT after trimming
- Profiled AOT beats full AOT: most of the startup win, far less binary growth
- adb shell am start -W on a mid-range device is the only number worth quoting
Q26App Center retired in March 2025. How do you build, sign and distribute an iOS Xamarin app in CI now?
IntermediateCI/CD
Answer
You need four artifacts on the build machine: a distribution certificate with its private key as a .p12, a provisioning profile whose bundle id and entitlements match the app, the entitlements file itself, and an App Store Connect API key for upload. The build is msbuild for classic Xamarin.iOS with Configuration=Release, Platform=iPhone, BuildIpa=true and ArchiveOnBuild=true, with CodesignKey and CodesignProvision naming the identity and profile; for .NET for iOS it is dotnet publish -f net8.0-ios -c Release with the same signing properties. Since App Center shut down on 31 March 2025 and Visual Studio for Mac reached end of life in August 2024, the common replacements are GitHub Actions on a macos runner or Azure DevOps with a macOS agent, importing the certificate into a temporary keychain created for that job, and fastlane match or the Apple API key for profile management.
Tester distribution moves to TestFlight or Firebase App Distribution, and crash reporting moves to Sentry, Crashlytics or Raygun. The failures you should be ready to name: 'No installed provisioning profiles match the installed iOS signing identities', which almost always means the certificate and profile pair do not match rather than a missing profile; an entitlement declared in the entitlements file but absent from the profile, which fails at signing not at build; an expired certificate that nobody noticed because renewals are annual; and a runner whose Xcode version is newer than the Xamarin.iOS release supports, which is a permanent hazard for classic projects and one more argument for migrating. Budget matters too, teams that ran free App Center pipelines are now paying for runner minutes or a self-hosted Mac mini.
# .github/workflows/ios.yml (excerpt)
jobs:
build:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Import signing assets
env:
P12: ${{ secrets.DIST_CERT_P12_BASE64 }}
P12_PASS: ${{ secrets.DIST_CERT_PASSWORD }}
PROFILE: ${{ secrets.PROVISIONING_PROFILE_BASE64 }}
run: |
security create-keychain -p ci build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p ci build.keychain
echo "$P12" | base64 --decode > cert.p12
security import cert.p12 -k build.keychain -P "$P12_PASS" -A
security set-key-partition-list -S apple-tool:,apple: -s -k ci build.keychain
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
echo "$PROFILE" | base64 --decode > ~/Library/MobileDevice/Provisioning\ Profiles/app.mobileprovision
- name: Build IPA
run: |
msbuild Shop.iOS/Shop.iOS.csproj /p:Configuration=Release /p:Platform=iPhone \
/p:BuildIpa=true /p:ArchiveOnBuild=true \
/p:CodesignKey="iPhone Distribution: Shop Technologies Pvt Ltd" \
/p:CodesignProvision="Shop AppStore"
- name: Upload to TestFlight
run: xcrun altool --upload-app -f **/*.ipa -t ios --apiKey $KEY_ID --apiIssuer $ISSUER
Q27Push notifications work in the foreground but not when the app is backgrounded. What is going on?
IntermediatePush Notifications
Answer
On Android this is almost always the difference between a notification message and a data message in FCM. If the payload contains a notification object, the system tray displays it directly whenever the app is backgrounded or killed, and your FirebaseMessagingService.OnMessageReceived is never called, so any custom handling you wrote simply does not run. A data-only payload always routes to OnMessageReceived, but delivery can be delayed by Doze unless you send it with high priority, and on Xiaomi, Oppo and Vivo devices the OEM battery manager may drop it entirely if the app is not whitelisted.
The other Android gotchas: notification channels are mandatory from API 26 and a notification posted to a channel that does not exist is silently discarded, POST_NOTIFICATIONS is a runtime permission from Android 13, and OnNewToken must push the refreshed token to your backend or that install goes quiet after a token rotation. On iOS you must call UNUserNotificationCenter.Current.RequestAuthorizationAsync, then RegisterForRemoteNotifications, and take the token from RegisteredForRemoteNotifications; without an aps alert the system shows nothing, and background handling of a content-available payload requires the remote-notification background mode plus an aps entry with content-available set to 1, and is rate limited. Rich or mutated content needs a Notification Service Extension, which is a separate project and separate provisioning profile.
In Xamarin the plumbing comes through Xamarin.Firebase.Messaging on Android and the iOS bindings, or a wrapper such as Plugin.Firebase. Interviewers usually finish by asking how you verify: send both payload shapes from the FCM REST API to a real device in each of foreground, background and killed states, because emulator behaviour is not representative.
// Android: data payloads reach this, notification payloads do not (when backgrounded)
[Service(Exported = false)]
[IntentFilter(new[] { "com.google.firebase.MESSAGING_EVENT" })]
public class ShopMessagingService : FirebaseMessagingService
{
public override void OnNewToken(string token) => Api.RegisterDeviceAsync(token);
public override void OnMessageReceived(RemoteMessage message)
{
var orderId = message.Data.TryGetValue("orderId", out var id) ? id : null;
ShowLocalNotification(message.Data["title"], message.Data["body"], orderId);
}
}
// Channel must exist before you post to it (API 26+)
var channel = new NotificationChannel("orders", "Order updates", NotificationImportance.High);
((NotificationManager)GetSystemService(NotificationService)).CreateNotificationChannel(channel);
// Android 13+ runtime permission
await Permissions.RequestAsync<Permissions.PostNotifications>();
// iOS: authorization then registration, in that order
var (granted, _) = await UNUserNotificationCenter.Current
.RequestAuthorizationAsync(UNAuthorizationOptions.Alert |
UNAuthorizationOptions.Badge |
UNAuthorizationOptions.Sound);
if (granted) UIApplication.SharedApplication.RegisterForRemoteNotifications();
Key Points
- notification payload = system tray and no OnMessageReceived when backgrounded
- data-only payload always reaches your handler but is subject to Doze and OEM killers
- Channels from API 26, POST_NOTIFICATIONS from API 13, OnNewToken must sync to backend
- iOS needs RequestAuthorizationAsync then RegisterForRemoteNotifications, plus content-available for silent
Q28Production crash reports show a native stack with no managed frames. How do you get a usable stack trace?
IntermediateDiagnostics
Answer
Start by classifying the crash. A managed exception that escaped gives you a .NET stack and is caught by AppDomain.CurrentDomain.UnhandledException, TaskScheduler.UnobservedTaskException, AndroidEnvironment.UnhandledExceptionRaiser on Android and ObjCRuntime.Runtime.MarshalManagedException on iOS. Wire all of them at startup and log before the process dies, because a crash reporter that only sees the native abort tells you nothing.
A native crash (SIGSEGV, SIGABRT, a Java throwable) needs symbolication. On Android, release builds strip the native libraries, so upload the native debug symbols alongside the AAB in the Play Console and keep the unstripped .so files from your obj output for the build you shipped; without them the Play crash cluster is just addresses. On iOS you need both the dSYM bundle and the .mSYM folder Xamarin produces, and mono-symbolicate maps the managed frames that AOT flattened.
Version them per build, a symbol file from a rebuild does not match. Since App Center retired on 31 March 2025 the usual reporters are Sentry (which has Xamarin and MAUI packages and understands managed stacks), Firebase Crashlytics and Raygun. Three failure modes recur in Xamarin specifically: ObjectDisposedException reading 'Cannot access a disposed object' when code touches a Java peer that was disposed earlier, which often presents as a native crash inside the GC; a MissingMethodException or 'Cannot create an instance of type' that only appears in Release because the trimmer removed the type; and ANRs from synchronous SQLite or file IO on the UI thread, which Play reports separately from crashes and which teams routinely miss because they only watch the crash tab.
// Wire every handler before anything else runs
public static void InstallCrashHandlers(ILogSink sink)
{
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
sink.Fatal((Exception)e.ExceptionObject, "AppDomain");
TaskScheduler.UnobservedTaskException += (s, e) =>
{
sink.Fatal(e.Exception, "UnobservedTask");
e.SetObserved();
};
#if __ANDROID__
AndroidEnvironment.UnhandledExceptionRaiser += (s, e) =>
{
sink.Fatal(e.Exception, "AndroidEnvironment");
e.Handled = false; // still let it crash, but the log is out
};
#elif __IOS__
ObjCRuntime.Runtime.MarshalManagedException += (s, e) =>
sink.Fatal(e.Exception, "MarshalManaged");
#endif
}
# Symbolicate an iOS managed stack from the shipped build's mSYM
mono-symbolicate Shop.iOS.app.mSYM crash-stack.txt
# Keep the Android native symbols for the exact build you uploaded
# obj/Release/net8.0-android/android-arm64/**/libmonosgen-2.0.so (unstripped)
Q29You own a Xamarin.Forms app with 200 XAML pages and 30 custom renderers. Sequence the migration to .NET MAUI.
AdvancedMAUI Migration
Answer
Do not attempt a big bang. Step zero is inventory: count pages, renderers, effects, DependencyService interfaces and every NuGet package, then check each package for a net8.0-android and net8.0-ios target. An abandoned dependency, not your own code, is what usually stops a migration dead, so that audit decides feasibility before anyone writes a line.
Step one is preparation you ship on the old stack: move to Xamarin.Forms 5, replace DependencyService with Microsoft.Extensions.DependencyInjection, retire MessagingCenter, adopt CommunityToolkit.Mvvm, delete renderers that a platform-specific or effect can replace, and hide the Essentials statics behind interfaces. Every one of those changes is testable and releasable today and shrinks the actual port. Step two is project shape: the .NET Upgrade Assistant converts csproj files to SDK style with net8.0-android and net8.0-ios.
Keeping the multi-project layout is the lower-risk choice for a large fleet; the MAUI single project with a Platforms folder is tidier but changes every path at once. Step three is the mechanical pass: Xamarin.Forms namespaces become Microsoft.Maui and Microsoft.Maui.Controls, Xamarin.Essentials becomes Microsoft.Maui.Storage, Networking and Devices, Device.RuntimePlatform becomes DeviceInfo.Platform, Color.FromHex becomes Color.FromArgb, and the XAML namespace URI changes. Step four: boot the app with the compatibility renderers under Microsoft.Maui.Controls.Compatibility, then convert renderers to handlers one at a time behind a regression suite.
Step five is the long tail, because MAUI layout, default spacing and font metrics are not pixel-identical, so budget a screen-by-screen UI pass. Be honest about the estimate in interviews: weeks for steps zero to three, months for the QA tail.
# Convert projects to SDK style and MAUI target frameworks
dotnet tool install -g upgrade-assistant
upgrade-assistant upgrade Shop.sln --non-interactive
<!-- Result: one csproj per head, or a single multi-targeted project -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0-android;net8.0-ios</TargetFrameworks>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">24.0</SupportedOSPlatformVersion>
</PropertyGroup>
</Project>
// Keep legacy renderers alive while you convert them one by one
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseMauiCompatibility() // Xamarin.Forms renderers still run
.ConfigureMauiHandlers(h =>
{
h.AddCompatibilityRenderer(typeof(BorderlessEntry), typeof(BorderlessEntryRenderer));
});
return builder.Build();
}
Key Points
- Audit NuGet targets first: a dead dependency kills the migration, not your code
- Do the DI, MessagingCenter and Essentials refactors on Xamarin.Forms 5 and ship them
- Upgrade Assistant for csproj, then the namespace and API rename pass
- UseMauiCompatibility keeps renderers running while you convert to handlers
Q30How does a MAUI handler differ from a Xamarin.Forms renderer, and how do mappers change the way you customise controls?
AdvancedMAUI Handlers
Answer
A renderer owned the native view and you changed behaviour by subclassing it, which meant one class plus an ExportRenderer attribute for every variation. A handler is a thin, decoupled bridge: it exposes VirtualView (the cross-platform control), PlatformView (the native one), and the lifecycle methods CreatePlatformView, ConnectHandler and DisconnectHandler. What replaces OnElementPropertyChanged is the mapper, a static dictionary on the handler type that maps a cross-platform property name to an Action that applies it to the platform view.
Because the mapper is static and public, you can change every Entry in the application without subclassing anything: AppendToMapping runs your action after the default one, PrependToMapping before it, and ModifyMapping replaces it outright. CommandMapper does the same for invoked commands such as Focus. This is the single biggest practical difference from renderers, and interviewers use it to check whether you have actually written MAUI code or only read the migration doc.
DisconnectHandler is the other probe. Native event subscriptions made in ConnectHandler must be removed there, and on several MAUI releases it is not called automatically in every teardown path, so long-lived pages need an explicit handler.DisconnectHandler() call. That discipline is exactly what removes the leak class renderers were notorious for.
Registration lives in MauiProgram through ConfigureMauiHandlers and AddHandler. Migration tactic: convert the highest-traffic controls first, and reach for a mapper append rather than a whole custom handler whenever the change is one or two native properties.
// Global customisation, no subclass, no ExportRenderer
Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("NoUnderline", (handler, view) =>
{
#if ANDROID
handler.PlatformView.Background = null;
handler.PlatformView.SetPadding(0, 0, 0, 0);
#elif IOS
handler.PlatformView.BorderStyle = UIKit.UITextBorderStyle.None;
#endif
});
// Full custom handler when the control itself is new
public partial class RatingHandler : ViewHandler<IRatingView, MauiRatingView>
{
public static IPropertyMapper<IRatingView, RatingHandler> Mapper =
new PropertyMapper<IRatingView, RatingHandler>(ViewMapper)
{
[nameof(IRatingView.Value)] = (h, v) => h.PlatformView.SetValue(v.Value),
[nameof(IRatingView.MaxValue)] = (h, v) => h.PlatformView.SetMax(v.MaxValue),
};
public RatingHandler() : base(Mapper) { }
protected override MauiRatingView CreatePlatformView() => new(Context);
protected override void ConnectHandler(MauiRatingView platform)
{
base.ConnectHandler(platform);
platform.ValueChanged += OnValueChanged;
}
protected override void DisconnectHandler(MauiRatingView platform)
{
platform.ValueChanged -= OnValueChanged; // skip this and you leak the page
base.DisconnectHandler(platform);
}
}
Key Points
- Handler exposes VirtualView and PlatformView with a static mapper instead of subclassing
- AppendToMapping, PrependToMapping and ModifyMapping customise every instance globally
- CommandMapper handles invoked actions such as Focus
- DisconnectHandler is where native events must be detached, sometimes explicitly
Q31An iOS build launches in six seconds on an older iPhone and QA reports occasional 0x8badf00d terminations. How do you attack it?
AdvancedPerformance
Answer
0x8badf00d is the watchdog: iOS killed the app because a lifecycle callback took too long, most often FinishedLaunching. Split the time into pre-main and post-main before touching anything, because the fixes are different. Pre-main is dyld loading and linking your dylibs and frameworks plus static initialisers, and it grows with the number of dynamically linked frameworks and the size of the binary.
Post-main is Mono runtime init, the Objective-C registrar, your AppDelegate and the construction of the first page. Xamarin-specific levers: build with the static registrar so the managed-to-Objective-C type map is produced at build time instead of at startup; keep full AOT rather than the interpreter for release, and evaluate LLVM (MtouchUseLlvm) which produces faster code at the cost of build time and size; link at least SDK assemblies so the runtime has fewer methods to prepare; and reduce assembly count, because each assembly costs load and init time, so merging tiny helper libraries genuinely helps. Then the application causes, which are almost always larger than any flag: eagerly constructing every registered service, running SQLite migrations synchronously in FinishedLaunching, initialising analytics and crash SDKs before first paint, deserialising a large cached payload on the main thread, and a first page with a deep visual tree bound to data it does not have yet.
Measure with Instruments App Launch, which separates pre-main from post-main, and use a launch storyboard rather than a static image. The engineering answer is architectural: reach first frame with a skeleton screen, register services as lazy singletons, and move everything else to after the window is visible.
<!-- iOS Release: static registrar, SDK linking, AOT -->
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|iPhone'">
<MtouchLink>SdkOnly</MtouchLink>
<MtouchUseLlvm>true</MtouchUseLlvm>
<MtouchExtraArgs>--registrar:static --optimize=all</MtouchExtraArgs>
<MtouchInterpreter></MtouchInterpreter>
<MtouchFloat32>true</MtouchFloat32>
</PropertyGroup>
// Get to first frame, then do the expensive work
public override bool FinishedLaunching(UIApplication app, NSDictionary options)
{
Forms.Init();
LoadApplication(new App()); // first page must be cheap
var ok = base.FinishedLaunching(app, options);
// Everything below used to run before the window appeared
Task.Run(async () =>
{
await Db.InitAsync(); // migrations off the main thread
Analytics.Start();
await Sync.WarmCacheAsync();
});
return ok;
}
// Lazy singletons so registration does not mean construction
builder.Services.AddSingleton<IReportEngine>(sp => new ReportEngine(sp.GetRequiredService<LocalDb>()));
Q32Explain how garbage collection works across the managed and native boundary in Xamarin, and what that means for your code.
AdvancedMemory Model
Answer
On Android two collectors run at once: SGen on the Mono side and ART's collector on the Java side. Because a Java.Lang.Object subclass exists as a pair, one managed instance and one Java peer holding each other, neither collector can decide alone that the pair is dead. The mechanism that resolves this is the GC bridge.
When SGen encounters peer objects that may participate in a cycle spanning both heaps, it hands that subgraph to the bridge, which builds a graph of the cross references and consults ART about reachability, and only then can the pair be collected. The cost is what matters in production: bridge processing is a stop-the-world step whose expense scales with the number of peer objects and the shape of the cycle graph, so an app that allocates tens of thousands of Java peers can show visible GC pauses even with a small managed heap. Practical consequences: keep peer objects short-lived and Dispose them explicitly so they never reach the bridge, avoid building large graphs of custom Java.Lang.Object subclasses, use plain managed types wherever a native peer is not required, and read GC timings separately with debug.mono.log set to gc so you can see bridge time. iOS has no second collector but the same ownership problem in a different shape: a managed object strongly references its Objective-C peer and the peer can retain the managed object, forming a cycle that neither ARC nor the GC will break.
That is why renderers hold weak references to their elements and why an NSTimer with a strong target is a guaranteed leak. The correct mental model on both platforms is two owners for one logical object.
# Watch GC and bridge cost separately from managed collections
adb shell setprop debug.mono.log gc
adb logcat | grep -E "GC_BRIDGE|GC_MAJOR|GC_MINOR"
# GC_BRIDGE: Processed 41230 objects, 812 colors, elapsed 340ms <- the smell
// Android: never let short-lived peers reach the bridge
foreach (var uri in imageUris)
{
using var stream = ContentResolver.OpenInputStream(uri);
using var bitmap = BitmapFactory.DecodeStream(stream);
thumbnails.Add(Downscale(bitmap)); // managed byte[] survives, peers do not
}
// iOS: a strong NSTimer target is a cycle neither ARC nor the GC breaks
_timer = NSTimer.CreateRepeatingScheduledTimer(30, t => Refresh()); // retains the closure
public override void ViewDidDisappear(bool animated)
{
base.ViewDidDisappear(animated);
_timer?.Invalidate(); // the only thing that releases it
_timer = null;
}
Key Points
- Android runs SGen and ART together; the GC bridge resolves cross-heap cycles
- Bridge cost scales with peer count, so peer churn shows up as stop-the-world pauses
- Dispose Java peers rather than letting finalisation and the bridge handle them
- iOS has no bridge but has managed-to-peer retain cycles that need weak references
Q33A vendor ships an Android .aar and an iOS .xcframework. How do you consume both from a Xamarin codebase?
AdvancedNative Interop
Answer
Two entirely separate pipelines. On Android you create a bindings library project, add the .aar with build action AndroidLibrary (LibraryProjectZip in older-style projects), and the binding generator produces C# wrappers from the Java class metadata. It rarely compiles first time, and the corrections go in Transforms/Metadata.xml, a set of XPath-addressed rules: remove a class the generator cannot express, rename a method that collides with a C# keyword or with a generated property, change a return type or visibility, or mark a listener interface so it surfaces as a C# event.
Common errors are 'does not implement inherited abstract member', collisions between a Java field and its generated property, and generic signatures with no C# equivalent. You must also read the vendor's POM, because the binding does not resolve transitive Maven dependencies for you, and merge any manifest entries the library needs. On iOS the tool is Objective Sharpie, which parses the framework headers and emits ApiDefinition.cs and StructsAndEnums.cs that you then hand-correct, especially around nullability, blocks and delegate protocols.
The binary is added as a NativeReference with Kind set to Framework, plus SmartLink, ForceLoad and any LinkerFlags the vendor documents. XCFramework packaging is what Apple expects in 2026, and a legacy fat framework with simulator slices will be rejected at submission. Swift libraries are the hard case: the vendor must expose an Objective-C compatible surface with @objc, and you ship the Swift runtime dylibs. In both worlds, plan a second pass to write a hand-authored C# facade so the rest of the app never sees the generated names.
<!-- Android bindings: Transforms/Metadata.xml fixes the generated API -->
<metadata>
<!-- drop a class the generator cannot express -->
<remove-node path="/api/package[@name='com.vendor.sdk']/class[@name='InternalHelper']" />
<!-- rename a method that collides with the generated property -->
<attr path="/api/package[@name='com.vendor.sdk']/class[@name='Session']/method[@name='getState']"
name="managedName">GetSessionState</attr>
<!-- surface a Java listener as a C# event -->
<attr path="/api/package[@name='com.vendor.sdk']/interface[@name='OnResultListener']"
name="eventName">Result</attr>
</metadata>
# iOS: generate the API definition from the framework headers
sharpie bind --output=VendorBinding --namespace=Vendor.SDK \
--sdk=iphoneos17.4 --scope=VendorSDK.xcframework/ios-arm64/VendorSDK.framework/Headers \
VendorSDK.xcframework/ios-arm64/VendorSDK.framework/Headers/VendorSDK.h
<!-- Reference the binary from the iOS binding project -->
<ItemGroup>
<NativeReference Include="VendorSDK.xcframework">
<Kind>Framework</Kind>
<SmartLink>True</SmartLink>
<ForceLoad>True</ForceLoad>
<LinkerFlags>-lz -lsqlite3</LinkerFlags>
</NativeReference>
</ItemGroup>
Key Points
- Android: bindings project plus Transforms/Metadata.xml for remove-node and attr rules
- Maven transitive dependencies and manifest entries are your responsibility
- iOS: Objective Sharpie generates ApiDefinition.cs, then you hand-correct it
- XCFramework with NativeReference, SmartLink and ForceLoad; Swift needs an @objc surface
Q34Design offline-first sync for a field-force app used on patchy networks. What do you build?
AdvancedArchitecture
Answer
Treat local SQLite as the source of truth for the UI, not as a cache. Every screen reads locally and no screen blocks on the network. Writes go into an outbox table with a client-generated GUID, a serialised payload, an attempt count, a status and a created timestamp.
A sync worker drains that outbox, and because the id was generated on the device you get idempotency for free: a retried submit hits the same primary key server side and is rejected or deduplicated rather than creating a duplicate order. Reads use delta sync with a per-entity cursor, either an updatedAt watermark or an opaque server token, and you apply the received batch and advance the cursor inside a single transaction so a crash midway cannot skip records. Deletes need tombstones, otherwise a row deleted on the server reappears on the next pull.
Conflict policy must be explicit and owned by the product, not implicit in the code: last write wins with a server timestamp is fine for a profile field and unacceptable for stock counts or attendance, where you want a server-side merge or an outright rejection that surfaces to the user. Details that decide whether this survives real use in tier-2 India: cap retries with exponential backoff and a dead-letter status a support tool can inspect, do not trigger sync from Connectivity.ConnectivityChanged alone because captive portals report connectivity with no internet, show a per-record sync state so users trust what they see, and version the outbox payload so an old app version's queue is still readable after an update.
// Outbox row: client id makes every retry idempotent
public class OutboxItem
{
[PrimaryKey] public string Id { get; set; } = Guid.NewGuid().ToString();
public string EntityType { get; set; }
public string PayloadJson { get; set; }
public int SchemaVersion { get; set; } = 3;
public int Attempts { get; set; }
public string Status { get; set; } = "pending"; // pending | sent | dead
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
public async Task DrainAsync(CancellationToken ct)
{
if (!await _net.HasRealInternetAsync(ct)) return; // not just ConnectivityChanged
foreach (var item in await _db.PendingAsync(limit: 50))
{
try
{
await _api.SubmitAsync(item.EntityType, item.Id, item.PayloadJson, ct);
await _db.MarkSentAsync(item.Id);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
item.Attempts++;
item.Status = item.Attempts >= 8 ? "dead" : "pending";
await _db.UpdateAsync(item);
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, item.Attempts)), ct);
}
}
}
// Delta pull: apply batch and advance cursor in one transaction
var delta = await _api.GetChangesAsync("orders", since: _cursor.Orders, ct);
await _db.RunInTransactionAsync(c =>
{
foreach (var row in delta.Items) c.InsertOrReplace(row);
foreach (var id in delta.Tombstones) c.Delete<OrderRow>(id);
c.InsertOrReplace(new Cursor { Entity = "orders", Value = delta.NextCursor });
});
Q35Your Xamarin app needs a new home. Argue MAUI against Flutter, Kotlin Multiplatform and React Native.
AdvancedPlatform Strategy
Answer
Frame it as cost of change, not as framework preference, because that is how the decision actually gets funded. MAUI is the default for an existing Xamarin fleet: the C# stays, the ViewModels stay, the domain logic and any tests stay, the .NET backend contracts stay, and the work is confined to the UI layer, tooling and CI. That is a fraction of a rewrite, and for a two hundred page line-of-business app it is usually the only option a CFO will approve.
Its weaknesses should be stated honestly: a smaller ecosystem than Flutter, control vendors who were slow to port, and layout that is not pixel-identical to Forms so you pay a QA tax per screen. Flutter is the stronger choice when the app is design-led and UI-heavy, when you want identical rendering everywhere including web, and when hiring is the binding constraint, because the Flutter talent pool across Bengaluru, Pune and Hyderabad is deeper and cheaper than the MAUI pool. The price is a full rewrite in Dart with none of your C# coming along.
Kotlin Multiplatform is the interesting middle: share networking and business logic, write native UI in Compose and SwiftUI, and it suits teams that already have strong native Android and iOS engineers and want platform-differentiated UI. React Native fits teams whose core strength is web React and who want to share code with an existing web app. The framing that wins the room: a maintenance fleet migrates to MAUI, a product being redesigned from scratch should evaluate Flutter seriously because you are paying for a rewrite either way, and nobody should still be on classic Xamarin at the next Play target API deadline.
Key Points
- MAUI keeps C#, ViewModels, domain logic and tests, so it is the cheapest path for a fleet
- Flutter wins on design-led rewrites and on hiring depth in Indian metros
- Kotlin Multiplatform shares logic while keeping Compose and SwiftUI UI
- The deciding question is whether you are already paying for a rewrite
Frequently Asked Questions
What does a Xamarin developer earn in India in 2026?
The working band is roughly ₹6-20 LPA, and it splits by employer type more than by years. In services firms such as TCS, Infosys, Wipro, HCLTech, LTIMindtree, Tech Mahindra, Accenture and Capgemini, a developer with two to four years typically sits at ₹6-11 LPA on internal bands, and five to eight years around ₹12-18 LPA. Product companies and captive units pay above that for the same experience. The genuine premium in 2026 is migration work: someone who can take a Xamarin.Forms fleet to .NET MAUI, unblock the linker and AOT failures, and get the app past Google Play's target API requirement is negotiating ₹18-28 LPA, because that skill is scarce and the deadline is real. Pure Xamarin.Forms maintenance without MAUI experience is the weaker position and tends to sit at the lower end of the band.
How long should I prepare for a Xamarin interview?
If you already write C# and understand MVVM, two to three weeks of focused work is enough. Spend the first week on the runtime layer, because that is where interviews separate candidates: AOT on iOS, the linker and Release-only failures, the JNI peer model on Android, and the threading rules. Spend the second week building one small app end to end with Shell navigation, a CollectionView with paging, SQLite storage, an HttpClient service with Polly, and push notifications on at least one platform. Use the third week for MAUI: migrate that same app, convert one renderer to a handler, and note every namespace and API change you hit. If you are coming from native Android or iOS with no C#, budget six to eight weeks and put the extra time into async/await and the .NET type system.
What is expected from a fresher versus someone with five years of Xamarin?
A fresher is judged on C# and MVVM fluency: bindings, INotifyPropertyChanged, commands, layout choices, and being able to explain why the UI did not update. Knowing that Device.BeginInvokeOnMainThread exists and why puts you ahead of most candidates at that level. At five years the questions move to what only production teaches. Interviewers will ask how you found a memory leak on a physical device, what happened the first time a Release build crashed and Debug did not, how you shipped through a Play target API deadline, how you signed an iOS build in CI after App Center closed, and how you would sequence a MAUI migration for an app you did not write. They are checking for scars, not vocabulary. Bring two concrete stories with numbers, a startup time you reduced or a crash rate you brought down, and lead with them.
Is Xamarin worth learning in 2026 when support ended in 2024?
Learning Xamarin from scratch as a first mobile skill is not a good use of your time. Learning it as the migration surface of .NET MAUI is a different proposition and currently pays well. Large Xamarin.Forms fleets are still in production across Indian banking, insurance, logistics and field-force applications, and their owners need people who can keep them shippable and move them to MAUI. That work exists because store deadlines force it, not because anyone loves the framework. The right approach is to learn .NET MAUI properly and learn enough Xamarin.Forms to read and migrate a legacy codebase: renderers, DependencyService, MessagingCenter, the linker, the classic project structure. That combination is more employable in 2026 than either one alone, and it converts cleanly into a general .NET mobile career once the migrations are done.
Should I move to .NET MAUI or switch to Flutter for my career?
Look at the job volume in the cities you will actually work in. Flutter has the larger number of openings in India and a younger, cheaper talent pool, so if you are early in your career and not attached to C#, Flutter opens more doors faster. MAUI has fewer openings but far less competition per opening, and the roles skew towards enterprise line-of-business applications with .NET backends, which tend to be more stable and pay comparably at senior level. If you already know C#, the pragmatic move is MAUI first, because your existing skills transfer immediately and migration work is in demand right now, then add Flutter later if you want breadth. Switching to Flutter from Xamarin means discarding most of what you know for a market that also has more applicants per role.
Do Xamarin and MAUI roles exist outside large services companies?
Yes, though the shape differs. Services firms provide most of the volume, usually on client fleets in banking, insurance, retail and logistics, and those roles are steady maintenance and migration work. Beyond them, there are product companies and captive engineering centres with .NET stacks, healthcare and industrial software vendors whose apps talk to native SDKs for scanners and printers, and a steady stream of contract migration work where an app owner needs a Xamarin fleet on MAUI before a store deadline. Contract and freelance rates for a proven migration engineer can be strong precisely because the work is time-boxed and urgent. Remote roles do exist, more so for MAUI than for classic Xamarin, and international contracts often ask for MAUI plus Azure or App Store release experience rather than Xamarin specifically.
Introduction
Xamarin is Microsoft's original cross-platform mobile stack: C# and .NET compiled against the real Android and iOS APIs through the Mono runtime, with Xamarin.Forms adding a shared XAML UI layer on top. Official support for Xamarin and Xamarin.Forms ended on 1 May 2024, and its successor, .NET MAUI, absorbed the same platform bindings under new names (.NET for Android and .NET for iOS). That end-of-support date did not delete anyone's code. Indian services firms and product teams still run large Xamarin.Forms fleets in banking, insurance, logistics and field-force applications, and most Xamarin hiring in 2026 is about keeping those apps shippable or moving them to MAUI without a rewrite.
Interviews reflect exactly that. You get asked how the Mono AOT compiler works on iOS, why the linker strips a type your JSON serializer needs only in Release builds, what a global reference leak looks like on Android, and how a custom renderer becomes a handler in MAUI. Panels at TCS, Infosys, LTIMindtree, Accenture and mid-sized product firms also probe the commercial side: Google Play target API level deadlines, Apple's minimum Xcode SDK rules, and how you would sequence a migration for an app with two hundred XAML pages. Pure syntax questions are rare, almost every round turns into a maintenance or migration scenario with a deadline attached.
This guide covers the 35 Xamarin interview questions that actually get asked in 2026, ordered from fundamentals to advanced. The basic section works through the runtime model, XAML binding, layout cost, threading rules and platform abstraction. The intermediate section digs into renderers, CollectionView caching, linker configuration, object ownership across the managed and native boundary, HTTP handlers, background execution, testing and CI signing. The advanced section is where senior offers are decided: MAUI migration sequencing, handler mappers, profiled AOT startup tuning, device-only leak diagnosis, native library bindings, offline sync design, and the honest platform choice between MAUI, Flutter and Kotlin Multiplatform.
Ready to practice Xamarin interviews?
Don't just read, practice these Xamarin questions live with an AI interviewer that asks follow-ups and scores your answers.