Sunday, February 24, 2013

WinRT, Caliburn.Micro and IoC - Part 3 (SuspensionManager)

In my previous posts I integrated both Ninject and Unity into Caliburn.Micro for WinRT. This fixed issues I was having with a recent 1.4.1 code change, removing implicit self bindings.

The next thing I wanted to work through was state management. The VisualStudio templates have a nice implementation of state management through SuspensionManager and LayoutAwarePage.  LayoutAwarePage isn't an ideal match as a View for Caliburn.Micro MVVM. It tries to implement it's own MVVM pattern with the DefaultViewModel property, it contains a whole nested observable class definition, LoadState/SaveState logic in the View, and just plain does too much non-view stuff. 

With that said, LayoutAwarePage has many things going for it with VisualState switching management and hooking key and mouse navigation. LayoutAwarePage is only loosely coupled to SuspensionManager, so I figured I could fix these issues while still using SuspensionManager. To start out, I copied the code from LayoutAwarePage into a new class called AppPage. Then, trimmed out the DefaultViewModel and the observable collection definition.


AppPage.cs (interesting bits)


namespace CaliburnMicro_IoC_Sample.Code
{

    public class AppPage : Page
    {

.
.
.
        #region Process lifetime management

        private String _pageKey;

        /// <summary>
        /// Invoked when this page is about to be displayed in a Frame.
        /// </summary>
        /// <param name="e">Event data that describes how this page was reached.  The Parameter
        /// property provides the group to be displayed.</param>
protected override void OnNavigatedTo(NavigationEventArgs e)
        {
            // Returning to a cached page through navigation shouldn't trigger state loading
            if (this._pageKey != null)
                return;

            var frameState = SuspensionManager.SessionStateForFrame(this.Frame);
            this._pageKey = "Page-" + this.Frame.BackStackDepth;

            if (e.NavigationMode == NavigationMode.New)
            {
                // Clear existing state for forward navigation when adding a new page to the
                // navigation stack
                var nextPageKey = this._pageKey;
                int nextPageIndex = this.Frame.BackStackDepth;
                while (frameState.Remove(nextPageKey))
                {
                    nextPageIndex++;
                    nextPageKey = "Page-" + nextPageIndex;
                }

                // Pass the navigation parameter to the new page
                this.LoadState(e.Parameter, null, e);
            }
            else
            {
                // Pass the navigation parameter and preserved page state to the page, using
                // the same strategy for loading suspended state and recreating pages discarded
                // from cache
                this.LoadState(e.Parameter, (Dictionary<String, Object>)frameState[this._pageKey], e);
            }
        }

.
.
.

        /// <summary>
        /// Populates the page with content passed during navigation.  Any saved state is also
        /// provided when recreating a page from a prior session.
        /// </summary>
        /// <param name="navigationParameter">The parameter value passed to
        /// <see cref="Frame.Navigate(Type, Object)"/> when this page was initially requested.
        /// </param>
/// <param name="pageState">A dictionary of state preserved by this page during an earlier
        /// session.  This will be null the first time a page is visited.</param>
protected virtual void LoadState(Object navigationParameter, Dictionary<String, Object> pageState, NavigationEventArgs e)
        {
            if (DataContext == null)
            {
                var svc = ServiceLocator.Current.GetInstance<inavigationservice>() as SuspensionFrameAdapter;
                if (svc != null) svc.DoNavigated(this, e);
            }

            if (DataContext is IHaveState && pageState != null && pageState.Count() > 0)
            {
                var haveState = DataContext as IHaveState;
                haveState.LoadState(Convert.ToString(navigationParameter), pageState);
            }
        }

        /// <summary>
        /// Preserves state associated with this page in case the application is suspended or the
        /// page is discarded from the navigation cache.  Values must conform to the serialization
        /// requirements of <see cref="SuspensionManager.SessionState"/>.
        /// </summary>
        /// <param name="pageState">An empty dictionary to be populated with serializable state.</param>
protected virtual void SaveState(Dictionary<String, Object> pageState)
        {
            if (DataContext is IHaveState)
            {
                var haveState = DataContext as IHaveState;
                haveState.SaveState(pageState, SuspensionManager.KnownTypes);
            }
        }

        #endregion

    }
You may notice a strange DataContext null test in LoadState, that has to do with a problem with Frames and SuspensionManager, described below. It is not my best work, and I should probably refactor it to utilize another interface, rather than a little hidden knowledge that the INavigationService was implemented by SuspensionFrameAdapter.

I wanted LoadState SaveState operations to defer to the ViewModel, so I created an interface, IHaveState and implemented it in a BaseScreen class, inherited from Screen. Now any ViewModels I wanted to have page state, could inherit from BaseScreen and override the LoadState/SaveState methods.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace CaliburnMicro_IoC_Sample.Code
{
    public interface IHaveState
    {
        void LoadState(string Parameter, Dictionary<string, object> statePageState);
        void SaveState(Dictionary<string, object> statePageState, List<type> knownTypes);
    }
}

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Caliburn.Micro;

namespace CaliburnMicro_IoC_Sample.Code
{
    public class BaseScreen:Screen, IHaveState
    {
        public String Parameter { get; set; }
        public virtual void LoadState(string Parameter, Dictionary<string, object> statePageState){}

        public virtual void SaveState(Dictionary<string, object> statePageState, List<type> knownTypes){}

    }
}
Now I just needed to integrate SuspensionManager into the CaliburnApplication and I should be done. The SaveAsync was easy, but the RestoreAsync gave me a little more of an issue, needing to be wedged into the Caliburn.Micro FrameAdapter creation and the SuspensionManager frame registration. I ended up writing a custom DisplayRootView and put it into a base CaliburnSuspensionApplication class.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Caliburn.Micro;
using CaliburnMicro_IoC_Sample.Common;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;

namespace CaliburnMicro_IoC_Sample.Code
{
    public class CaliburnSuspensionApplication:CaliburnApplication
    {

        protected async Task DisplayRootView(Type viewType, object parameter = null)
        {
            var rootFrame = new Frame();
            Initialise();

            PrepareViewFirst(rootFrame);
            try
            {
                //Must be after registered frame, may blow up if file is not found.
                await SuspensionManager.RestoreAsync();
            }
            catch { }
            SuspensionManager.RegisterFrame(rootFrame, "MainFrame");

            //If we didn't restore any state, then navigate to root view
            if (rootFrame.Content == null)
            {
                //Use this instead of DisplayRootViewFor, to ensure the Frame is started
                rootFrame.Navigate(viewType);
            }

            Window.Current.Content = rootFrame;
            Window.Current.Activate();
        }
    }
}
The problem I continued to run into though, is that SuspensionManager restores navigation state using Frame.SetNavigationState. Unfortunately, this only calls the OnNavigatedTo method on the Page class, and doesn't trigger the Navigated event on the Frame. Caliburn.Micro monitors navigation with a FrameAdapter, and binds up relevant ViewModels during the Frame.Navigated event. Fortunately I had an AppPage class that already hooked the Page Navigation methods. All I needed to do was wedge into the FrameAdapter (by deriving a SuspensionFrameAdapter and exposing a DoNavigated method) to allow me to call the base FrameAdapter.Navigated event from the AppPage. I do this in the LoadState method (in the DataContext null test mentioned earlier in AppPage.cs)...


The SuspensionManager suspension mechanism should only be used to persist page state, not already captured in an application level model. Application level state should just be managed in the OnLaunched/OnSuspending events of the Application class.


You can download the source code for this solution here.

9 comments:

Andy Wilkinson said...

Interesting piece of work integrating Caliburn.Micro with the SuspensionManager.

You might be interested to know that the Okra App Framework (http://okra.codeplex.com) includes an IActivatable<,> interface that handles the same state persistence as your IHasState interface for Caliburn. All you need to do is annotate the view-model (or view if you wish) with this interface and Okra will take care of serializing the state. On starting the application, the navigation stack is automatically restored with the relevant state.

Andy

Tom Baker said...

Okra is definitely on my list of things to check out.

I will say, that I was hoping for a bit more out of WinRT Caliburn.Micro. Having used it in previous Windows Phone applications, I saw how helpful it could be.

Maybe I should make a followup post compare/contrast with Okra.

Igor Kulman said...

Would you mind if I extract the "plumbing code" for saving state and service location and create a Nuget package from it? I would link this article in the description.

Tom Baker said...

Igor, You are welcome to use the code above as you see fit.

A NuGet package sounds fine. A link would be appreciated.

Tom Baker said...

I will have to see about cleaning up and refactoring this code in light of the template changes coming in VS2013 (removal of LocationAwarePage, and division StateManagement into an optional class).

Maybe that will b a follow up article.

Igor Kulman said...

Thanks, the Nuget package is here https://nuget.org/packages/Caliburn.Micro.Unity.WinRT/.

Igor Kulman said...

I suggest you post the (extracted) code on GitHub, I could contribute code for creating the Nuget package.

Igor Kulman said...

Btw if I change Unity for Ninject then restoring state does not work, not even in your sample.

Unknown said...

Hi Tom,

like Igor mentioned, restoring doesn't work with Ninject. The Problem is that the line:
var svc = ServiceLocator.Current.GetInstance() as SuspensionFrameAdapter;
allways Returns NULL. Changing PrepareViewFirst(..) to create a new SuspensionFrameAdapter instead of FrameAdapter fixed the Problem.