Magellan and WPF Page management woes
Progress on Magellan has been a little slow this week as I have been experimenting with some ways to optimize memory usage when dealing with WPF pages. A lot of it comes down to issues with WPF's navigation system and some of the limitations it has.
The major problem we encounter when using WPF Page objects is that if you were to do something like this:
NavigationService.Navigate(new MyPage());
You just got yourself an object that will never be garbage collected.
This happens because the WPF navigation journal has to have a way of returning 'back' to that page if the user clicks a back button. Since you created it, the navigation service has no way of knowing how to re-create it, so it just keeps a reference to it. The only way of clearing these out is to call NavigationService.RemoveBackEntry
a to remove the page from the navigation journal completely.
Using URI's
So the solution is to use URI's for navigation:
NavigationService.Navigate(new Uri("/MyPage.xaml", UriKind.Relative));
Now when the WPF navigation service adds the page to the journal, it just stores the URI rather than keeping a reference to the instance of the page. Problem solved, right?
If you think so, you're about to learn about the stupidest feature that ever made it into WPF :)
Metadata Journaling
When most people think ViewState, they think ASP.NET web forms, and Notepad.exe screens full of encoded data taking up the top half of an ASPX page. They think of a confusing page lifecycle and a leaky abstraction that takes weeks to comprehend. Well, it turns out WPF has it's own version of ViewState - although it's only used when clicking "Back".
When URI's are used for Page navigation, the WPF navigation framework creates a journal entry with the URI. Then it walks the visual tree, and any dependency property that it encounters with the FrameworkPropertyMetadata.Journal flag will be serialized. Most properties don't have this flag however, so only small parts of the page are saved.
What this means is that if we use a Model-View-ViewModel pattern and set the DataContext
on our page, when go 'Back', we'll no longer have a data context, since the DataContext
property isn't serialized. All data will be missing from the page.
Using CustomContentState
A solution to this is to use WPF navigation's IProvideCustomContentState feature. This allows you to preserve information about the page before it is added to the journal, and to restore it when you return to the page. Here is an example:
public class PageView : Page, IProvideCustomContentState
{
public PageView()
{
}
public CustomContentState GetContentState()
{
return new RestoreModelContentState(DataContext);
}
}
[Serializable]
internal class RestoreModelContentState : CustomContentState
{
private readonly object _model;
public RestoreModelContentState(object model)
{
_model = model;
}
public override void Replay(NavigationService navigationService, NavigationMode mode)
{
var element = navigationService.Content as FrameworkElement;
if (element == null) return;
element.DataContext = _model;
}
}
Now, when you leave a page, the page will be garbage collected but not before the DataContext
is moved into the journal. If you click 'Back' a new page will be created, and the DataContext
will be restored.
The problem now, though, is that while our pages are being garbage collected, the DataContext
's aren't. If we have a big model, that model will sit around forever unless we clear the navigation journal. This might be OK if your models aren't too big.
Solutions
Right now I'm at a point where I don't think there's a good clean solution for this that doesn't involve memory being wasted.
The cop-out solution seems to be to avoid the problem entirely by rolling back the journal at major points during the application, so that the journal never gets too big. A good example is to clear the journal after a major task completes, or when the user is taken back to the home page. If this were the case, the combination of CustomContentState
to restore the model plus URI's could work.
I do have a very tricky solution in mind, which involves only using a single page with a query string in the URI, and having it re-invoke the controller when you click back. This way, models wouldn't need to be kept alive at all as the page would just be re-fetched. The other benefit to this is when you go back, the page would be up-to-date rather than an old copy of the page, which seems to suit the rich client model better. It would involve a breaking change (your views would have to become ContentControls
or UserControls
instead of Pages
) but might have the best result overall.
Finally, I'm also considering throwing out WPF navigation completely and rolling my own. It wouldn't be tied to Magellan's MVC model specifically, but it would support scenarios that Magellan is trying to accommodate as well as getting around other WPF navigation limitations (like the stupid PageFunction
Return
event handler must be a Page
limitation).
I'd love to get your thoughts on what you would expect from something like Magellan.