Avoid Pitfalls in MVVM: Lessons from Migrating from Xamarin to .NET MAUI

Avoid Pitfalls in MVVM: Lessons from Migrating from Xamarin to .NET MAUI

Avoid Pitfalls in MVVM: Lessons from Migrating from Xamarin to .NET MAUI



In this blog, we explore common pitfalls in MVVM architecture, particularly when migrating from Xamarin.Forms to .NET MAUI. We also address how to avoid tightly coupled code and incorporate best practices for various scenarios. Let's dive into our guide.

Introduction to MVVM

MVVM (Model-View-ViewModel) is a design pattern that separates the development of the graphical user interface from the business logic or back-end logic (the model). The goal of MVVM is to make the code more manageable and testable by separating concerns. This pattern is particularly useful in XAML-based frameworks like Xamarin.Forms and .NET MAUI.

My Journey

When migrating a POS application from Xamarin to .NET MAUI and adding web integration, I encountered several issues that made me realize the importance of adhering to MVVM principles. Initially, my ViewModels were tightly coupled with Xamarin-specific implementations. As I integrated web functionality, it became clear that these practices would hinder code reuse and flexibility. This blog is a reflection of my journey, aiming to help others avoid similar pitfalls.

This is Part 1 of my Migration Journey. In the next blog, I will be sharing the UI challenges I faced and how to overcome them.

Is Your ViewModel Truly Reusable?

  • Can your ViewModel be used in other projects without any changes?
  • If someone is not using Prism, RJ Popup Plugin, or other third-party libraries but is working on a similar project, like an e-commerce app, can they still use your ProductViewModel?
  • If someone is not using Xamarin or .NET MAUI, can they still reuse your ViewModel?

A ViewModel should not depend on any specific platform like Xamarin or .NET MAUI. If it's written in .NET/C#, it should also be reusable in an ASP.NET Blazor app, not just in Xamarin or MAUI. The goal is to keep your ViewModel loosely coupled so that it can be reused across different projects and platforms.

1. Avoid Direct Use of MainThread Code in ViewModel

Don't: Directly call Device.BeginInvokeOnMainThread within your ViewModel. Instead, abstract this functionality behind an interface.

Do: Use abstractions to handle main thread operations.

Using platform-specific APIs directly in ViewModels can lead to tightly coupled code, making it difficult to maintain and test. For instance, directly calling Xamarin.Essentials or Device APIs from ViewModel classes can cause issues when migrating to different platforms.

Example with Xamarin.Forms:

public class MyViewModel
{
    public MyViewModel()
    {
        Device.BeginInvokeOnMainThread(() =>
        {
            // code here
        });
    }
}

Interface-Based Approach:

public interface IHelperService
{
    Task InvokeOnMainThreadAsync(Action action);
    void InvokeOnMainThread(Action action);
}

public class MyViewModel
{
    private readonly IHelperService helperService;

    public MyViewModel(IHelperService helperService)
    {
        this.helperService = helperService;
        helperService.InvokeOnMainThread(() =>
        {
            // code here
        });
    }
}

.NET MAUI Implementation:

public class HelperService : IHelperService
{
    public void InvokeOnMainThread(Action action)
    {
        App.Current.Dispatcher.Dispatch(action);
    }

    public async Task InvokeOnMainThreadAsync(Action action)
    {
        await App.Current.Dispatcher.DispatchAsync(action);
    }
}

Web Implementation:

public class HelperService : IHelperService
{
    private readonly IJSRuntime jsRuntime;

    public HelperService(IJSRuntime jsRuntime)
    {
        this.jsRuntime = jsRuntime;
    }

    public void InvokeOnMainThread(Action action)
    {
        action.Invoke();
    }

    public async Task InvokeOnMainThreadAsync(Action action)
    {
        action.Invoke();
    }
}

2. Avoid Direct Use of Xamarin.Essentials Functionality

Don't: Use Xamarin.Essentials APIs directly in your ViewModel. Create an interface and implement the functionality in a separate service.

Do: Abstract Xamarin.Essentials functionality behind interfaces.

Directly using Xamarin.Essentials services in ViewModel can lead to similar issues. Instead, use an abstraction layer to manage these services.

Example with Xamarin.Forms:

public class MyViewModel
{
    public async Task IsConnectedAsync()
    {
        return Connectivity.NetworkAccess == NetworkAccess.Internet;
    }
}

Interface-Based Approach:

public interface IHelperService
{
    Task IsConnectedAsync();
}

public class MyViewModel
{
    private readonly IHelperService helperService;

    public MyViewModel(IHelperService helperService)
    {
        this.helperService = helperService;
    }

    public async Task IsConnectedAsync()
    {
        return await helperService.IsConnectedAsync();
    }
}

.NET MAUI Implementation:

public class HelperService : IHelperService
{
    public async Task IsConnectedAsync()
    {
        return Connectivity.NetworkAccess == NetworkAccess.Internet;
    }
}

Web Implementation:

public class HelperService : IHelperService
{
    private readonly IJSRuntime jsRuntime;

    public HelperService(IJSRuntime jsRuntime)
    {
        this.jsRuntime = jsRuntime;
    }

    public async Task IsConnectedAsync()
    {
        return await jsRuntime.InvokeAsync("eval", "window.navigator.onLine");
    }
}

3. Handling Navigation and Page Transitions

Don't: Use Navigation Shell or Prism APIs directly in your ViewModel. Create an interface and implement the functionality in a separate service.

Do: Abstract Navigation functionality behind interfaces.

Managing navigation and page transitions can be tricky, especially when dealing with complex navigation stacks. Ensure that your navigation service is decoupled from the ViewModel to maintain clean and testable code.

Example with Xamarin.Forms:

public class MyViewModel
{
    public async Task NavigateToPage(string pageName)
    {
        await Shell.Current.GoToAsync(pageName);
    }
}

Interface-Based Approach:

public interface INavigationService
{
    Task NavigateToPageAsync(string pageName);
}

public class MyViewModel
{
    private readonly INavigationService navigationService;

    public MyViewModel(INavigationService navigationService)
    {
        this.navigationService = navigationService;
    }

    public async Task NavigateToPage(string pageName)
    {
        await navigationService.NavigateToPageAsync(pageName);
    }
}

.NET MAUI Implementation:

public class NavigationService : INavigationService
{
    public async Task NavigateToPageAsync(string pageName)
    {
        await Shell.Current.GoToAsync(pageName);
    }
}

Web Implementation:

public class NavigationService : INavigationService
{
    private readonly NavigationManager navigationManager;

    public NavigationService(NavigationManager navigationManager)
    {
        this.navigationManager = navigationManager;
    }

    public async Task NavigateToPageAsync(string pageName)
    {
        navigationManager.NavigateTo(pageName);
        await Task.CompletedTask;
    }
}

4. Handle Navigation Parameters with [QueryProperty]

Don't: Use framework-specific methods for navigation parameters handling, i.e., OnNavigatedTo.

Do: Use [QueryProperty] at the class level to handle navigation parameters.

[QueryProperty(nameof(Product), nameof(Product))]
public partial class ProductDetailsViewModel : BaseViewModel
{
    public ProductDetailsViewModel()
    {
    }

    public Product Product { get; set; }
}

5. Avoid Using Library-Specific Commands

Don't: Use library-specific commands that create tight coupling, like DelegateCommand from Prism, unless you're using Prism everywhere.

Do: Use generic command implementations like RelayCommand from libraries like Community Toolkit MVVM.

[RelayCommand]
private async Task> GetProducts()
{
    // Implementation here
}

6. Use String Colors Instead of Color Type

Don't: Use Color type directly in your ViewModel.

Do: Use string representations of colors to ensure compatibility and automatic adjustments.

Hardcoding strings and colors directly in ViewModels can make your code less flexible and harder to maintain. Instead, use resources or configuration files to manage these values.

Example with Xamarin.Forms:

public class MyViewModel
{
    public string GetAlertTitle()
    {
        return "Alert Title";
    }

    public Color GetAlertColor()
    {
        return Color.FromHex("#FF0000");
    }
}

String-Based Approach:

public class MyViewModel
{
    public string GetAlertTitle()
    {
        return "Alert Title";
    }

    public string GetAlertColor()
    {
        return "#FF0000";
    }
}

7. Avoid Direct Alert Usage in ViewModel

Don't: Use platform-specific alert dialogs like DisplayAlert from Xamarin.Forms.

Do: Abstract alert service behind an interface.

Accessing alerts directly in ViewModel can cause issues when migrating to different platforms.

Example with Xamarin.Forms:

public class MyViewModel
{
    public string ShowAlert()
    {
        App.Current.MainPage.DisplayAlert("Alert", "This is an alert", "OK");
} }

Interface-Based Approach:

public interface IAlertService
{
    Task DisplayAlert(string title, string message, string buttonText);
}

public class MyViewModel
{
    private readonly IAlertService alertService;

    public MyViewModel(IAlertService alertService)
    {
        this.alertService = alertService;
    }

    public async Task ShowAlert()
    {
        await alertService.DisplayAlert("Alert", "This is an alert", "OK");
    }
}

.NET MAUI Implementation:

public class AlertService : IAlertService
{
    public async Task AlertAsync(string message, string title = "Alert", string buttonName = "OK")
    {
        await Application.Current.Dispatcher.DispatchAsync(async () =>
        {
            await App.Current.MainPage.DisplayAlert(title, message, buttonName);
        });
    }
}

Web Implementation:

public class AlertService : IAlertService
{
    private readonly IJSRuntime jsRuntime;

    public AlertService(IJSRuntime jsRuntime)
    {
        this.jsRuntime = jsRuntime;
    }

    public async Task AlertAsync(string message, string title = "Alert", string buttonName = "OK")
    {
        await jsRuntime.InvokeVoidAsync("window.alert", $"{title}\n{message}");
    }
}

8. Avoid Direct Use of Preferences in ViewModel

Don't: Use platform-specific preferences directly in ViewModel.

Do: Abstract preferences behind an interface.

Accessing preferences directly in ViewModel can cause issues when migrating to different platforms.

Example with Xamarin.Forms:

public class MyViewModel
{
    public string GetPreference(string key)
    {
        return Preferences.Get(key, "default_value");
    }
}

Interface-Based Approach:

public interface IPreferenceService
{
    string GetPreference(string key);
}

public class MyViewModel
{
    private readonly IPreferenceService preferenceService;

    public MyViewModel(IPreferenceService preferenceService)
    {
        this.preferenceService = preferenceService;
    }

    public string GetPreference(string key)
    {
        return preferenceService.GetPreference(key);
    }
}

.NET MAUI Implementation:

public class PreferenceService : IPreferenceService
{
    public string GetPreference(string key)
    {
        return Preferences.Get(key, "default_value");
    }
}

Web Implementation:

public class PreferenceService : IPreferenceService
{
    private readonly IJSRuntime jsRuntime;

    public PreferenceService(IJSRuntime jsRuntime)
    {
        this.jsRuntime = jsRuntime;
    }

    public async Task GetPreference(string key)
    {
        return await jsRuntime.InvokeAsync("eval", $"localStorage.getItem('{key}')");
    }
}

9. Avoid Platform-Specific Caching Services in ViewModel

Don't: Use platform-specific caching services like Akavache directly in ViewModel.

Do: Use an abstraction layer for caching services.

Example:

public interface ICacheService
{
    Task SetDataAsync(string key, object data);
    Task GetDataAsync(string key);
}

public class CacheService : ICacheService
{
    public async Task SetDataAsync(string key, object data)
    {
        await BlobCache.LocalMachine.InsertObject(key, data);
    }

    public async Task GetDataAsync(string key)
    {
        return await BlobCache.LocalMachine.GetObject(key);
    }
}

Remember, the key to successful MVVM implementation is to keep your ViewModel free from platform-specific code and dependencies. This not only makes your code more testable and maintainable but also eases the process of migrating to different platforms.

Conclusion

In this blog, we've explored various pitfalls and practices related to MVVM architecture, with a focus on transitioning from Xamarin to .NET MAUI and integrating with web platforms. We discussed how to avoid tightly coupled code by abstracting platform-specific functionality behind interfaces, which enhances the maintainability and flexibility of your ViewModels.

It’s crucial to remember that while platform-specific APIs (like those in Xamarin or .NET MAUI) can be very useful, directly using them in your ViewModels can lead to issues with portability and testability. Always strive to keep your ViewModels decoupled from platform-specific code by using abstractions and dependency injection. This practice not only makes your codebase cleaner but also more adaptable to future changes.

Whether you're working with Xamarin, .NET MAUI, or integrating with web platforms, these principles will help you build more robust and maintainable applications. Stay vigilant and abstract platform-specific code wherever possible to ensure a smoother development experience and better code quality.

Have you encountered any challenges with MVVM implementation? I would love to hear your feedback on this blog. Please share your comments and suggestions below to help me improve and provide more valuable content.

Thank you for reading! If you found this blog helpful, please share it with others who might benefit from it. Stay tuned for more tips and tutorials!

Comments