WPF Combobox “leaks” memory

2020-03-26 11:50发布

问题:

I have run into an issue with combo boxes in WPF where they seem to hang onto the first DataContext they were opened with. When I change the DataContext on my ComboBox, a child PopupRoot object still references the old DataContext.

At first I assumed we were doing something wrong but I was having trouble working out what that might be so I tried to simplify. I have managed to recreate the behavior I am seeing in our application in a very simple form so it seems more like a bug in the WPF ComboBox implementation. That sounds a little controversial so I thought I'd turn to stackoverflow for help.

The core code for the sample is below:

<Window x:Class="ComboBoxTest.MainWindow" 
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="150" Width="525">
    <DockPanel>
        <Button Click="ReloadModel" Width="137" Height="40">Reload Model</Button>
        <ComboBox Name="ComboBox" 
            ItemsSource="{Binding AvailableOptions}" 
            SelectedItem="{Binding SelectedOption}" 
            Width="235" Height="43">
        </ComboBox>
    </DockPanel>
</Window>

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        var newModel = new ViewModel();
        ComboBox.DataContext = newModel;
    }

    private void ReloadModel(object sender, RoutedEventArgs e)
    {        
        var newModel = new ViewModel();
        ComboBox.DataContext = newModel;
    }
}

public class ViewModel : INotifyPropertyChanged
{
    public ViewModel()
        : this(new[] { "Option 1", "Option 2", "Option 3" })
    { }

    public ViewModel(IEnumerable<string> options)
    {
        _selectedOption = options.First();
        _availableOptions = new ObservableCollection<string>(options);
    }

    protected void RaisePropertyChanged(string propertyName)
    {
        var propertyChangedHandler = PropertyChanged;
        if (propertyChangedHandler != null)
        {
            propertyChangedHandler(this, new PropertyChangedEventArgs(propertyName));
        }
    }
    public event PropertyChangedEventHandler PropertyChanged;

    private readonly ObservableCollection<string> _availableOptions;
    public ObservableCollection<string> AvailableOptions
    {
        get
        {
            return _availableOptions;
        }
    }

    private string _selectedOption;
    public string SelectedOption
    {
        get { return _selectedOption; }
        set
        {
            if (_selectedOption == value)
            {
                return;
            }
            _selectedOption = value;
            RaisePropertyChanged("SelectedOption");
        }
    }
}

Steps to reproduce:
1) Run Application
2) Open Combobox (so that it renders the drop down options)
3) Click "Reload Model" button

At this point there will be be two ViewModel objects, the older, unexpected instance is rooted like: ViewModel->PopupRoot->Popup->ComboBox->MainWindow->App

Is this a bug or am I doing it wrong?

Eamon

回答1:

Joe's comment brought my attention back to this old question which I have solved for my own use. In the end I wrote a Behavior that I could attach to a combobox that dealt with the memory leak.

I've posted the code here: https://github.com/EamonHetherton/Demos/blob/master/StackOverflow/18096050/StopComboBoxMemoryLeakBehaviour.cs

caveat emptor: this solution relies on reflection and the fragility that could entail. It works for me, YMMV.



回答2:

Recently I encountered several memory leak problems which were related to Popup / ContextMenu / ComboBox binding with DataContext.

I found out that essentially the problem for Popup / ComboBox was that the "_popupRoot"'s DataContext was not released after the DataContext of its parents were set to null.

For ContextMenu, if it's used with some kind of ItemsSource binding generated controls, then WPF will cache the Contextmenu, so its DataContext will not be released unless the user right click to pop up the ContextMenu somewhere again.

I managed to create 3 derived classes to replace the WPF controls where DataContext binding was used. I will paste them here, hopefully, they may be useful to someone else.

public class ComboBoxFixMem : ComboBox
{
    public ComboBoxFixMem()
    {
        this.DataContextChanged += ComboBox_DataContextChanged;
    }

    private void ComboBox_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
    {
        if (this.DataContext != null)
            return;
        FrameworkElement fe = this.GetTemplateChild("PART_Popup") as FrameworkElement;
        if (null != fe)
            fe.DataContext = null;
        PopupFixMem.ClearPopupDataContext(fe as Popup);
    }
}

public class ContextMenuFixMem : ContextMenu
{
    protected override void OnClosed(RoutedEventArgs e)
    {
        base.OnClosed(e);
        FrameworkElement p = this.Parent as FrameworkElement;
        if (null != p)
            p.DataContext = null;
    }
}

public class PopupFixMem : Popup
{
    public PopupFixMem()
    {
        this.DataContextChanged += Popup_DataContextChanged;
    }

    private void Popup_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
    {
        if (this.DataContext != null)
            return;
        ClearPopupDataContext(this);
    }

    public static void ClearPopupDataContext(Popup popup)
    {
        if (null == popup)
            return;
        try
        {
            var fiPopupRoot = typeof(Popup).GetField("_popupRoot", BindingFlags.NonPublic | BindingFlags.Instance);
            var popupRootWrapper = fiPopupRoot?.GetValue(popup);
            if (null == popupRootWrapper)
                return;
            var valueFieldInfo = popupRootWrapper.GetType().GetProperty("Value", BindingFlags.NonPublic | BindingFlags.Instance);
            var popupRoot = valueFieldInfo?.GetValue(popupRootWrapper, new object[0]) as FrameworkElement;
            if (null != popupRoot)
                popupRoot.DataContext = null;
        }
        catch (Exception) { }
    }
}