WPF Behavior OnAttached() 메서드 다중 호출 및 헨들러 관련 질문

안녕하세요.
저번에 가로 스크롤 관련 도움이 정말 큰 도움이 되었습니다.
감사합니다.

3월달에도 이런 오류가 있어서, command 로 처리한기억이 있는데
이번에 또 다시 발생하게되어 도움이 필요한 상황입니다.
오늘 하루 종일 검색하고, 디버그해도 뾰족한 해결책이 나오지 않네요 ㅠ

Microsoft.Xaml.Behaviors.Wpf 사용중이며

최초 오류는

   <ComboBox ItemsSource="{Binding ESetCameraPropertyList}"
                      SelectedItem="{Binding SelectedESetCameraProperty}"
                      Margin="10"
                      Width="Auto">
                <b:Interaction.Triggers>
                    <b:EventTrigger EventName="SelectionChanged">
                        <b:InvokeCommandAction Command="{Binding SelectionChangedCommand}"
                                               PassEventArgsToCommand="True" />
                    </b:EventTrigger>
                </b:Interaction.Triggers>
            </ComboBox>

Triggers 사용때 발견 되었습니다.
설명의 편의를 위해 구현은 1번Page에하였다고 가정 하겠습니다.

최초 1번 페이지 로드후 이벤트 등록

2번페이지 이동 → 다시 1번페이지로 이동시

이벤트가 다시 등록 되어,
트리거 작동시 페이지이동횟수 x 2 의 횟숫만큼 작동을 하였습니다.

즉 1번만 발동 해야될 이벤트헨들러 메소드가 페이지를 이동이후 2회, 4회, 6회 … 10회 이런식으로 계속 늘어났습니다. (페이지 이동 없이처음부터 1회발동이 아닌 2회발동)

이게 최초 문제를 인식했을때이고, 헨들러가 여러번 등록 되고 있다는 생각은 하였지만 커맨드만으로 구현하여 넘어갔던 부분이었습니다.

다음오류

public class VirtualKeyboardBehavior : Behavior<TextBox>
{
    private readonly object _lock = new object();

    private Process _keyboardProcess;
    private bool _isFocused;
    // private bool _isEventHandlersAttached;

    protected override void OnAttached()
    {
        // base.OnAttached();
        Log.Information("온어테치 매서드 실행");
        AssociatedObject.GotFocus += AssociatedObject_GotFocus;
        AssociatedObject.LostFocus += AssociatedObject_LostFocus;
        Application.Current.Deactivated += Current_Deactivated;
        Application.Current.Activated += Current_Activated;
        
    }

    protected override void OnDetaching()
    {
        Log.Information("온디테치 매서드 실행");
        AssociatedObject.GotFocus -= AssociatedObject_GotFocus;
        AssociatedObject.LostFocus -= AssociatedObject_LostFocus;
        Application.Current.Deactivated -= Current_Deactivated;
        Application.Current.Activated -= Current_Activated;
        base.OnDetaching();

    }

    private void AssociatedObject_GotFocus(object sender, RoutedEventArgs e)
    {
        _isFocused = true;
        ShowKeyboard();
    }

    private void AssociatedObject_LostFocus(object sender, RoutedEventArgs e)
    {
        _isFocused = false;
        CloseKeyboard();
        Log.Information("LostFocus");
    }
    
    private void Current_Deactivated(object? sender, EventArgs e)
    {
        CloseKeyboard();
        Log.Information("Deactivated");
        if (_keyboardProcess != null) Log.Information("키보드를 닫았으나 닫기지 않음," + _keyboardProcess.Id);
    }

    private void Current_Activated(object? sender, EventArgs e)
    {
        if(_isFocused) ShowKeyboard();
        Log.Information("Activated");

    }
    
    private void ShowKeyboard()
    {
        if (_keyboardProcess == null)
        {
            var path64 =
                Path.Combine(
                    Directory.GetDirectories(
                        Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "winsxs"),
                        "amd64_microsoft-windows-osk_*")[0], "osk.exe");
            var path32 = @"C:\windows\system32\osk.exe";
            var path = Environment.Is64BitOperatingSystem ? path64 : path32;
            if (File.Exists(path))
            {
                try
                {
                    var startInfo = new ProcessStartInfo(path)
                    {
                        UseShellExecute = true,
                        Verb = ""
                    };
                    _keyboardProcess = Process.Start(startInfo);
                    Log.Information("키보드 실행 - 프로세스 ID :" + _keyboardProcess.Id);
                }
                catch (Win32Exception ex)
                {
                    Console.WriteLine("관리자 권한으로 실행할 수 없습니다: " + ex.Message);
                }
            }
            else
            {
                Console.WriteLine("파일을 찾을 수 없습니다: " + path);
            }
        }
    }


private void CloseKeyboard()
{
    lock (_lock)
    {
        Process[] processes = Process.GetProcessesByName("osk");
        if (processes.Length > 0)
        {
            // The keyboard process is already running
            _keyboardProcess = processes[0];
        }

        if (_keyboardProcess != null)
        {
            Log.Information("키보드 실행 중지 - 프로세스 ID :" + _keyboardProcess.Id);
            _keyboardProcess.Kill();
        
            _keyboardProcess = null;
        }
    }
}
}

일단 behavior 부터 보시죠
TextBox 활성화시 가상 키보드를 실행하는 behavior 입니다.

image
페이지를 로드 하면, 시작부터 OnAttached() 가 2번 실행 됩니다.

image
액티베이트와 디 액티베이트도 2번씩 실행됩니다.
여기서 한가지 다른점은,

image
포커스는 1번만 실행 된다는 점입니다.

처음 OnAttached() 로그가 2번 찍히는것도 이해가 되질 않지만,
Focus 는 한번만 찍히는 부분도 이해가 힘듭니다.

혹시나 뷰모델이 싱글톤으로 관리되는게 문제인가 하여, 변경 해 보았지만 별 소득이 없었습니다.

 private  IServiceProvider ConfigureServices()
    {
        var services = new ServiceCollection();
        // 옵션 AddTransient(요청이 있을때마다 새로 인스턴스 생성),
        // AddSingleton(싱글톤),
        // AddScoped(요청의 생명 주기 동안 하나의 인스턴스를 생성, 한 요청 내에서는 같은 인스턴스가 재사용되지만, 새로운 요청이 들어오면 새로운 인스턴스가 생성)

        // Service 등록
        services.AddSingleton<RuntimeCommandService>();
        services.AddSingleton<SystemSettingService>();
        
        // ViewModel 등록
        services.AddTransient<MainViewModel>();
        services.AddSingleton<HomeViewModel>();
        services.AddSingleton<CustomerViewModel>();
        services.AddSingleton<StreamViewModel>();
        services.AddSingleton<SettingViewModel>();

        // SnakbarMessageQueue 등록
        services.AddSingleton<SnackbarMessageQueue>();
        
        // initService 는 SystemLogViewModel 클래스의 인스턴스를 생성하여 서비스에 등록
        // 시작과 동시에 인스턴스화, 로그페이지는 시작과 동시에 로그를 받아야함
        services.AddSingleton(new SystemLogViewModel());

뷰모델을 전부 Singleton 혹은 Transient 로 변경해보았지만 결과는 같았습니다.

다른 behavior 도 같은 현상일까 또 테스트 해 보았습니다.

public class TextBlockNavigate : Behavior<TextBlock>
{
    private bool _isLocated;

    protected override void OnAttached()
    {
        // base.OnAttached();
        Log.Information("로그창 온어테치 매서드 실행");
        AssociatedObject.PreviewMouseLeftButtonDown += OnPreviewMouseLeftButtonDown;
    }

    protected override void OnDetaching()
    {
        Log.Information("로그창 디페치드 매서드 실행");
        AssociatedObject.PreviewMouseLeftButtonDown -= OnPreviewMouseLeftButtonDown;
        base.OnDetaching();
    }

    private void OnPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        if (e.ClickCount == 2 && _isLocated == false)
        {
            WeakReferenceMessenger.Default.Send(new NavigationMessage("Views/SystemLogPage.xaml"));
            _isLocated = true;
        }
        else if (e.ClickCount == 2)
        {
            WeakReferenceMessenger.Default.Send(new NavigationMessage("GoBack"));
            _isLocated = false;
        }

        e.Handled = true;
    }
}

TextBlock 을 클릭하게되면 페이지를 이동시키는 Behavior 입니다.
이부분은

image
OnAttached() 가 한번만 호출 되는걸 확인하였습니다.

더 혼란스러워졌습니다…

혹시나, View 에 TextBox 가 2개가 존재하는데, 두군데 다 behavior 인터렉션을 작성해놔서 그런가 하여, 하나로 바꾸어 실행

<TextBox
                InputScope=""
                VerticalAlignment="Center"
                materialDesign:TextFieldAssist.HasClearButton="True"
                materialDesign:HintAssist.Hint="{x:Static properties:Resources.setValue}"
                Width="Auto"
                MaxWidth="200"
                Text="{Binding MessageInput, UpdateSourceTrigger=PropertyChanged}"
                Margin="10"
                TextWrapping="Wrap" 
                >
                <b:Interaction.Behaviors>
                    <behaviors:VirtualKeyboardBehavior />
                </b:Interaction.Behaviors>
            </TextBox>
            <Button
                Width="auto"
                Command="{Binding SendCommand}"
                Margin="10,10,10,10">
                <materialDesign:PackIcon
                    Kind="Send"
                    HorizontalAlignment="Center"
                    VerticalAlignment="Center" />
            </Button>
            <TextBox
                VerticalAlignment="Center"
                materialDesign:TextFieldAssist.HasClearButton="True"
                materialDesign:HintAssist.Hint="{x:Static properties:Resources.setPortNumber}"
                ToolTip="{x:Static properties:Resources.settingPortToolTip}"
                Width="Auto"
                MaxWidth="200"
                Text="{Binding PortInput, UpdateSourceTrigger=PropertyChanged}"
                Margin="10"
                TextWrapping="Wrap">
                <b:Interaction.Behaviors>
                    <behaviors:VirtualKeyboardBehavior  />
                </b:Interaction.Behaviors>
            </TextBox>

매번 2번 호출 되던 OnAttached() 가 1번만 호출 되는것을 확인 하였습니다.

2번 호출 되는 문제는 찾았지만, 페이지 이동시 이벤트 헨들러가 중복으로 등록 되는 문제는 찾지 못하였습니다.

2번 등록되는 문제는 찾았을 지언정, 텍스트박스마다 Behavior 인터렉션을 적용 하지 않으면 Behavior 의 재사용은 어떻게 하는건지,
페이지 이동시 OnAttached() 는 호출이 되고 OnDetaching() 는 호출이 되지 않는건지, Behavior 의 재사용은 요소마다 인터렉션을 걸어주는게 아닌건지.

정리가 좀 안되는데, 다시 정리 하자면

1. OnAttached() 가 View 의 인터렉션을 걸어준 수만큼 호출된다.

2. OnAttached() 는 페이지 이동시 지속적으로 호출이 되는데, OnDetaching()는 페이지를 이동하여도 호출이 되지 않는다, 결과적으로 페이지 이동시 헨들러가 중복으로 쌓이게 된다. 그결과 본래의 의도는 한번의 행동에 한번의 메서드 호출이 → 한번의 행동으로 n번의 메서드 호출을 야기한다. (여기서n은 페이지 이동횟수)

3. 2번의 호출을 야기한문제는 view 에서 behavior 인터렉션을 2번 작성해 준것에서 나왔는데, 모든 텍스트 박스에서 behavior 을 구현 하고자 하면 어떻게 해야 하는것이 옳은 것인가?

여기까지 입니다.

글이 많이 길지만 읽어주셔서 감사합니다.

많은 도움과 지도 부탁드리겠습니다.

1개의 좋아요

페이지 이동은 어떤 식으로 구현하셨는지 알려주시면 답변에 도움이 될것 같네요. (예: 사용하신 컨테이너가 TabControl 이다 등)

답글 주셔서 감사합니다. 페이지 이동 behavior 코드 아래에 첨부 드리겠습니다.

public class FrameBehavior : Behavior<Frame>
{
    
    /// <summary>
    ///     컴파일시 값 지정 런타임에 변경불가, static은 런타임에 지정 값 변경 가능 
    /// </summary>
    private const string Homepage = "Views/HomePage.xaml";

    /// <summary>
    ///     NavigationSource DP 변경 때문에 발생하는 프로퍼티 체인지 이벤트를 막기 위해 사용
    /// </summary>
    private bool _isWork;

    /// <summary>
    ///     NavigationSource DP
    /// </summary>
    public static readonly DependencyProperty NavigationSourceProperty = DependencyProperty.Register(
            nameof(NavigationSource),
            typeof(string),
            typeof(FrameBehavior),
            new PropertyMetadata(
                null,
                NavigationSourceChanged
            )
        );

    public string NavigationSource
    {
        get => (string)GetValue(NavigationSourceProperty);
        set => SetValue(NavigationSourceProperty, value);
    }

    protected override void OnAttached()
    {
        // base.OnAttached();
        //네비게이션 시작
        AssociatedObject.Navigating += AssociatedObject_Navigating;
        //네비게이션 종료
        AssociatedObject.Navigated += AssociatedObject_Navigated;
    }

    /// <summary>
    ///     네비게이션 시작 이벤트 핸들러
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void AssociatedObject_Navigating(object sender, NavigatingCancelEventArgs e)
    {
        //네비게이션 시작전 상황을 뷰모델에 알려주기
        // AssociatedObject.Content는 Frame에 로드된 페이지를 나타 내는지 확인, true면 Page로 캐스팅
        if (AssociatedObject.Content is Page pageContent
            // Page의 DataContext가 INavigationAware를 구현했는지 확인 후 캐스팅 현 프로젝트에서는 인터페이스를 ViewModelBase에 상속 하여 구현
            && pageContent.DataContext is INavigationAware navigationAware)
            // 온 네비게이팅 이벤트를 뷰모델에 전달
            navigationAware?.OnNavigating(sender, e);
    }

    /// <summary>
    ///     네비게이션 종료 이벤트 핸들러
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void AssociatedObject_Navigated(object sender, NavigationEventArgs e)
    {
        _isWork = true;
        // Uri를 NavigationSource에 입력
        NavigationSource = e.Uri.ToString();
        _isWork = false;
        // 네비게이션이 완료된 상황을 뷰모델에 알려주기
        if (AssociatedObject.Content is Page pageContent && pageContent.DataContext is INavigationAware navigationAware)
            // 온 네비게이티드 이벤트를 뷰모델에 전달
            navigationAware.OnNavigated(sender, e);
    }

    protected override void OnDetaching()
    {
        AssociatedObject.Navigating -= AssociatedObject_Navigating;
        AssociatedObject.Navigated -= AssociatedObject_Navigated;
        base.OnDetaching();
    }

    /// <summary>
    ///     NavigationSource PropertyChanged
    /// </summary>
    /// <param name="d">디펜던시 오브젝트</param>
    /// <param name="e">디펜던시 프로퍼티 체인지 이벤트</param>
    private static void NavigationSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var behavior = (FrameBehavior)d;
        if (behavior._isWork) return;

        behavior.Navigate();
    }

    /// <summary>
    ///     네비게이트
    /// </summary>
    private void Navigate()
    {
        switch (NavigationSource)
        {
            case "GoBack":
                //GoBack으로 오면 뒤로가기
                if (AssociatedObject.CanGoBack) AssociatedObject.GoBack();

                break;

            case "GoHome":
                AssociatedObject.Navigate(new Uri(Homepage, UriKind.RelativeOrAbsolute));
                break;
            case null:
            case "":
                //아무것도 안함
                return;
            default:
                //navigate
                AssociatedObject.Navigate(new Uri(NavigationSource, UriKind.RelativeOrAbsolute));
                break;
        }
    }
}

// 설명에 오류가 있어 수정 2024.06.19 5:47
MainWindow에 NavigationSource 를 프로퍼티로 등록하고,
바인딩으로 NavigationSource 변경이 되면 페이지가 이동됩니다.

각 BaseViewModel 에 NavigationSource 를 프로퍼티로 등록하고, ViewModel 에서 BaseViewModel 을 상속받아 사용중입니다.
바인딩으로 NavigationSource 변경이 되면 페이지가 이동됩니다.


오류 상황을 정리하다 보니, Behavior OnAttached() 메서드는 TextBox 와 연결된 횟수만큼 호출되는게 맞다 라는 생각이 듭니다.

여전히 이해할수 없는건, 왜 이벤트 헨들러가 지속적으로 쌓이는지는 아직 이해를 못한거 같습니다.

저번 가로스크롤에이어 이번에도 도움주셔서 감사합니다.

2개의 좋아요

다시 전반적으로 페이지 이동을 설명 드리면,

MainWindow 에서 grid 안에 frame 으로 페이지를 로드 합니다.

   <Grid Panel.ZIndex="1">
            <Frame NavigationUIVisibility="Hidden">
                <!-- ViewModel 의 NavigationSource 가 변경되면 Frame 의 NavigationSource 를 변경합니다. -->
                <b:Interaction.Behaviors>
                    <behaviors:FrameBehavior NavigationSource="{Binding NavigationSource, Mode=TwoWay}" />
                </b:Interaction.Behaviors>
            </Frame>

이후, 페이지를 변경 하고 싶으면 , 헤더메뉴를 조작하여 페이지를 이동 합니다.

 </MenuItem>
            <MenuItem Header="{Binding HeaderMenu}">
                <MenuItem
                    Header="{Binding HeaderSystemLog}"
                    Command="{Binding NavigateCommand}"
                    CommandParameter="Views/SystemLogPage.xaml" />
                <MenuItem
                    Header="{Binding HeaderMainPage}"
                    Command="{Binding NavigateCommand}"
                    CommandParameter="Views/CustomerPage.xaml" />
                <MenuItem
                    Header="{Binding HeaderSettingPage}"
                    Command="{Binding NavigateCommand}"
                    CommandParameter="Views/SettingPage.xaml" />
            </MenuItem>
1개의 좋아요

정확한 답변은 아니지만 일단 FramePage 구조라면 탐색(Navigation) 개념 때문에 뒤로가기, 이전 페이지 등의 상태 저장 매커니즘이 적용되어 일반적으로 우리가 생각하는 WPF 컨트롤의 것과 다르게 동작할 가능성이 있습니다.

희박하지만 Microsoft Behavior의 구현에서 Page에 대해 처리가 누락됐을 가능성도 있을 것 같습니다.

좀 더 테스트 해보고 답변 드리겠습니다.

2개의 좋아요

안녕하세요 ~ 어제는 댓글을 못드렸네요 ~

public class VirtualKeyboardBehavior : Behavior<TextBox>
{
    private readonly object _lock = new object();

    private Process _keyboardProcess;
    private bool _isFocused;

    protected override void OnAttached()
    {
        base.OnAttached();
        Log.Information("온어테치 매서드 실행");
        AssociatedObject.GotFocus += AssociatedObject_GotFocus;
        AssociatedObject.LostFocus += AssociatedObject_LostFocus;
        Application.Current.Deactivated += Current_Deactivated;
        Application.Current.Activated += Current_Activated;
        AssociatedObject.Unloaded += AssociatedObject_Unloaded;
    }

    protected override void OnDetaching()
    {
        Log.Information("온디테치 매서드 실행");
        AssociatedObject.GotFocus -= AssociatedObject_GotFocus;
        AssociatedObject.LostFocus -= AssociatedObject_LostFocus;
        Application.Current.Deactivated -= Current_Deactivated;
        Application.Current.Activated -= Current_Activated;
        AssociatedObject.Unloaded -= AssociatedObject_Unloaded;
        base.OnDetaching();
    }
    
    private void AssociatedObject_Unloaded(object sender, RoutedEventArgs e)
    {
        OnDetaching();
    }

일단은,
AssociatedObject.Unloaded += AssociatedObject_Unloaded;
이 친구를 활용해서 어떻게 구현이 완료 되긴 하였습니다.

Unloaded 가 잡히더라구요, 아마 테스트하는데 실마리가 되지 않을까 하여 댓글 남깁니다 !

시간 내주어서 감사합니다 ~

1개의 좋아요

올려주신 소스를 참고해서 최대한 유사한 구조로 테스트 프로젝트를 생성한 뒤
Page Navigation을 통해 다른 페이지로 이동 후 GoBack을 해도 EventTrigger에 의한 InvokeCommandAction이 한 번만 실행되는 것을 확인했습니다.

혹시 버전에 의한 문제일까 의심되어

  • Target Framework 변경 - .NET 6/7/8, .NET Framework 등
  • Microsoft.Xaml.Behaviors.Wpf 패키지 버전 변경

위 조건을을 바꿔가며 테스트 해봤지만 해당 문제가 재현되지 않네요.

아마 설명해주신 부분에 언급되어 있지 않은 특수한 구조가 있지 않나 추측해봅니다.

추가로 올려주신 내용대로 일반적으로 Microsoft.Xaml.Behaviors.WpfEventTrigger와 같은 개체들은 Load/Unload 이벤트에 의해 Page를 벗어 날 때 Detached 되어 InvokeCommandAction과 같은 하위 인스턴스의 참조를 해제하고, 재진입 시 Attatched되어 하위 인스턴스를 새롭게 생성합니다.

(정정 2024.6.24) Page 인스턴스는 매 탐색 시 새롭게 생성 됩니다.
단, GoBack 동작 시에는 탐색 기능이 가지고 있는 Journal이라는 상태 저장 매커니즘에 의해 이전 속성 값을 복원합니다.

(로그에 GetHashCode()를 찍어보시면 이해에 도움이 될 것 같습니다).

가능한 추측으로는 Unload 이벤트에서 Trigger들이 Detached 될 때 예외 발생 또는 어떤 조건에 의해 이벤트 핸들러를 해제 하지 못한 것으로 보입니다.

그리고 Behavior가 컨트롤 수 만큼 여러 번 생성되는 것은, DynamicResourceTemplate계열 요소 이외에 XAML로 정의한 것은 코드 비하인드에서 new 키워드로 인스턴스를 생성하는 것 동일하다고 이해하시면 됩니다. XAML은 우리가 코드로 일일이 new()를 통해 컨트롤을 구성하는 과정을 계층적으로 선언하여 구성할 수 있도록 도와주는 것일 뿐이니까요.

저도 문의해주신 상황이 궁금해서 같이 고민을 해봤지만 직접적인 도움을 드리지 못해 아쉽네요:sweat_smile::smiling_face_with_tear:

혹시 추가적인 정보나 있거나 해결 방법을 찾으셨다면 한번 공유 부탁 드리겠습니다ㅎ

2개의 좋아요

답글 너무 감사합니다.

저도 이곳저곳 계속 서치하고 있는데, 제가 프로그래밍의 전체적인 이해도가 부족하여 이유를 잘 찾지 못하는거 같습니다.

다시한번 정리하면,

page1 View

  <b:Interaction.Behaviors>
                <behaviors:VirtualKeyboardBehavior />
  </b:Interaction.Behaviors>

trigger 이아닌 behavior 을 연결 하여 event를 받고 있는데,

페이지를 이동 하여도 OnDetaching() 이 실행 되지 않아, 다른 페이지 이동후 다시 page1 으로 돌아 갈시 이벤트 헨들러가 중복으로 등록 되는 문제상황이었습니다.

최소한 OnDetaching() 은 실행 되지 않는다는걸 로그로 확인 했습니다.

다면 중복되는 메서드는 한정 적인데, App.Current.Activated += , App.Current.Deactivated += 만 유독 중복되여 쌓이고 있습니다.

이문제를 해결하기 위해서
AssociatedObject.Unloaded += 이벤트를 잡아서 직접 OnDetaching() 를 실행 해 주는것으로 해결이 되었습니다.

결국 AssociatedObject는 페이지 이동시 자동(?)으로 Detach 되나 App.Current.Activated, 혹은 App.Current.Deactivated 관련 이벤트는 페이지 이동시 자동(?)으로 Detach 가 되지않는것 같습니다.

behavior 은 AssociatedObject 의 unload이벤트를 감지하여 Detach를 자동(?)으로 처리하나 그외의 이벤트는 처리하지 않는거 같습니다.

(수정) behavior 은 AssociatedObject 의 unload이벤트를 감지하여 AssociatedObject 헨들러를 자동(?)으로 Detach처리하나 그외의 이벤트는 처리하지 않는거 같습니다.

혼자 이것저것 검색하면서 정보들을 머리속에 집어 넣다 보니 정리가 안된 느낌인데, 이렇게 전달을 위해 정리하는 시간을 따로 갖인것만으로도 머리속이 정리가 훨씬 잘 되는 느낌입니다.

소중한 시간 내주셔서 감사합니다.

저도 사실 Page를 많이 사용해 보지 않아서 몇 가지 테스트를 해 보면서 이전 답변을 올렸었는데요.
다시 테스트를 해보면서 이 상황을 이제 설명해 드릴 수 있게 된 것 같습니다.

Page의 특성

  1. GoBack 시 이전의 Page 인스턴스가 재사용 되지 않고 새로운 인스턴스를 생성함
  2. 단, Journal이 이전 Page 및 하위 컨트롤들이 가지고 있던 모든 상태를 저장하여 새로 생성된 인스턴스에 대해 상태를 복원함
  3. 페이지 전환 시 Page 및 하위 컨트롤에 대해 Unloded 이벤트가 호출됨

Microsoft.Xaml.Behaviors.Wpf의 특성

  1. TriggerBehavoir에는 별도의 Unload와 관련된 처리가 되어 있지 않음
  2. 따라서 Detach는 자동적으로 호출되지 않음

종합해보면 현재 상황은

매 탐색 시 PageBehavior는 재생성 되며, GoBack 시에는 Journal에 의해 새롭게 생성된 Page 하위 컨트롤의 모든 속성을 복원하기 때문에 마치 재사용되는 것 처럼 보입니다.
이 때문에 매번 새로운 Behavoir 인스턴스에 대해 OnAttatched 메소드가 호출되며, OnDetaching은 호출되지 않지만, 새로운 인스턴스이기 때문에 이후에 생성된 인스턴스에서는 이전 생성 시 OnAttatched에서 등록했던 이벤트 핸들러는 호출되지 않습니다.

하지만 여기서 착시는 Application.Current에 대해 Activated/Deactivated 이벤트를 사용했다는 것입니다.
Behavoir 생성 시 전역 인스턴스인 Application.Current에 이벤트 핸들러를 등록했기 때문에 OnDetaching은 호출되지 않으므로 Page에 접근 한 횟수만큼 Activated/Deactivated 이벤트 핸들러가 호출된 것입니다.

따라서 해결 방법은 이미 시도해 보신 대로 명시적으로 Detach해주는 것인데요,
관련한 내용으로 Behavior는 명시적으로 Detach를 호출해주지 않으면 메모리 누수를 유발할 수 있으므로 처리하신 대로 Unloaded 이벤트 시 명시적으로 BehaviorDetach해주는 베이스 클래스를 상속받아 사용하면 유용합니다. (OnDetaching 호출 보다 Detach를 호출해 주는 것이 더 좋습니다.)
참고: Attached Behaviors Memory Leaks – Pixytech

public abstract class BehaviorBase<T> : Behavior<T> where T : FrameworkElement
{
	private bool _isSetup; // 2024-06-26 수정, 기존 -> private bool _isSetup = true;
	private bool _isHookedUp;
	private WeakReference _weakTarget;

	protected virtual void OnSetup()
	{ }

	protected virtual void OnCleanup()
	{ }

	protected override void OnChanged()
	{
		var target = AssociatedObject;
		if (target != null)
		{
			HookupBehavior(target);
		}
		else
		{
			UnHookupBehavior();
		}
	}

	private void OnTarget_Loaded(object sender, RoutedEventArgs e) => SetupBehavior();

	private void OnTarget_Unloaded(object sender, RoutedEventArgs e) => CleanupBehavior();

	private void HookupBehavior(T target)
	{
		if (_isHookedUp)
		{
			return;
		}

		_isHookedUp = true;
		_weakTarget = new WeakReference(target);
		target.Unloaded += OnTarget_Unloaded;
		target.Loaded += OnTarget_Loaded;
		SetupBehavior();
	}

	private void UnHookupBehavior()
	{
		if (!_isHookedUp)
		{
			return;
		}

		_isHookedUp = false;
		var target = AssociatedObject ?? (T)_weakTarget.Target;
		if (target != null)
		{
			target.Unloaded -= OnTarget_Unloaded;
			target.Loaded -= OnTarget_Loaded;
		}
		CleanupBehavior();
	}

	private void SetupBehavior()
	{
		if (_isSetup)
		{
			return;
		}

		_isSetup = true;
		OnSetup();
	}

	private void CleanupBehavior()
	{
		if (!_isSetup)
		{
			return;
		}

		_isSetup = false;
		Detach();
		OnCleanup();
	}
}

제가 분석한 내용은 이상입니다. 이해에 도움이 되셨으면 합니다:blush:

2개의 좋아요

감사합니다.

직접 behavior 헨들러는 직접 해제 해 주어야 겠습니다.

올려주신 링크도 확인해보겠습니다.

이제 정리가 되었네요 !!!

1개의 좋아요
public abstract class BehaviorBase<T> : Behavior<T> where T : FrameworkElement
{
    // isSetup 기본값을 ture 로 줄시, OnSetup() 메서드가 동작 하지 않음
	private bool _isSetup; // 기존 -> private bool _isSetup = true;

	private bool _isHookedUp;
	private WeakReference _weakTarget;

	protected virtual void OnSetup()
	{ }

	protected virtual void OnCleanup()
	{ }

	protected override void OnChanged()
	{
		var target = AssociatedObject;
		if (target != null)
		{
			HookupBehavior(target);
		}
		else
		{
			UnHookupBehavior();
		}
	}

	private void OnTarget_Loaded(object sender, RoutedEventArgs e) => SetupBehavior();

	private void OnTarget_Unloaded(object sender, RoutedEventArgs e) => CleanupBehavior();

	private void HookupBehavior(T target)
	{
		if (_isHookedUp)
		{
			return;
		}

		_isHookedUp = true;
		_weakTarget = new WeakReference(target);
		target.Unloaded += OnTarget_Unloaded;
		target.Loaded += OnTarget_Loaded;
		SetupBehavior();
	}

	private void UnHookupBehavior()
	{
		if (!_isHookedUp)
		{
			return;
		}

		_isHookedUp = false;
		var target = AssociatedObject ?? (T)_weakTarget.Target;
		if (target != null)
		{
			target.Unloaded -= OnTarget_Unloaded;
			target.Loaded -= OnTarget_Loaded;
		}
		CleanupBehavior();
	}

	private void SetupBehavior()
	{
		if (_isSetup)
		{
			return;
		}

		_isSetup = true;
		OnSetup();
	}

	private void CleanupBehavior()
	{
		if (!_isSetup)
		{
			return;
		}

		_isSetup = false;
		Detach();
		OnCleanup();
	}
}

안녕하세요!

알려주신 자료 적용중에 발견하였습니다.
혹시나 다른분들도 필요하실까 하여 공유드립니다 ~

_isSetup 의 기본값이 ture 로 지정되어 있어서
페이지 로드시 OnSetup() 이 발동안되고 있었습니다.

참고부탁드리겠습니다 ~

3개의 좋아요

안녕하세요 또 다시 인사 드립니다.
이번에 다시 만져보다가, 새로운 사실을 알게 되어 인용드립니다.

위의 코드에서
Detach() 를 OnCleanup() 보다 먼저 호출하게되면
OnCleanup() 메서드로 이벤트 헨들러를 해제 하려고 하면 NullReferenceException이 뜨게됩니다.

이유는 Detach() 에서 associatedObject 를 null 로 만들어 주는 구문이 있기 때문에
해제할 이벤트 핸들러를 찾지못하고 익셉션을 날리더군요

저는 BehaviorBase 적용으로 OnAttached() 와 OnDetaching() 를 대신하여
OnSetup() OnCleanup() 에서 핸들러를 등록하고 해제하고 있었습니다.

참고 부탁드리겠습니다.

	private void CleanupBehavior()
	{
		if (!_isSetup)
		{
			return;
		}

		_isSetup = false;
	// 수정 -> 기존 매서드의 실행 순서 변경
		OnCleanup();
        Detach();
	}
2개의 좋아요