최상위 Navigator와 MaterialApp, 그리고 Navigator.push()와 GetX.to()

2022. 7. 5. 17:52Programming/Flutter

반응형

FlutterLocalNotification 패키지를 사용해서 푸시 메시지를 터치하면, 특정 페이지로 이동하게끔 구현을 해뒀다. 문제는 사용자가 이미 특정 페이지에 진입해있을 때, 푸시 메시지를 터치하는 경우. 이 경우에는 다른 동작을 처리하게끔 만들어주고 싶었다.

navigation_history_observer | Flutter Package

그래서 사용한 것이 바로 NavigationHistoryObserver. 이 친구를 사용하면 MaterialApppush(), pop()등을 할 때마다 내부 리스트에 route를 저장해서, 현재 어느 페이지에 위치해있는지 파악할 수 있게 된다.

매번 push()를 호출하기 위해 MaterialPageRoute를 설정하는 것은 귀찮은 일이었기 때문에, 다음과 같은 숏컷 함수를 만들어놨다. 어찌됐건 결과적으로 push()를 호출하는 것은 마찬가지며, 인자값으로 전달한 widgetNameRouteSettingsname값으로 할당하고 있다. 이것으로 특정 화면으로 push()하는 함수를 호출할 때 키 값을 widgetName으로 넘겨주면, NavigationHistoryObserver를 사용해서 현재 화면이 특정 화면인지 여부를 키 값으로 확인할 수 있게 된다. 이걸로 사용자가 특정 화면에서 푸시 메시지를 터치하면, 코드 상에서 현재 사용자에게 표시되고 있는 페이지가 특정 화면임을 확인할 수 있게 됨으로써, 문제가 해결되는 것처럼 보였다.

Future<T> moveToNewScreen<T, K extends Widget>(
  BuildContext context,
  K newScreen, [
  String widgetName,
]) async {
  return Navigator.of(context).push(
    MaterialPageRoute(
      builder: (context) => newScreen,
      settings: RouteSettings(
        name: widgetName ?? newScreen.toString(),
      ),
    ),
  );
}

문제는 사용자가 푸시 메시지를 터치해서 앱을 실행시킨 뒤, 특정 화면에 진입했을 때 발생했다. FlutterLocalNotification에 등록한 콜백에 디버깅 메시지를 추가한 후 확인해보니, moveToNewScreen에 인자값으로 넘겨주는 widgetName은 있는데, NavigationHistoryObserver를 사용해서 확인해보니 히스토리 마지막에서 가져온 routeRouteSettings에는 name값이 null이었다. RouteSettings.name은 어디로 사라져버린 것일까?

.
└── MaterialApp
        ├── **FlutterLocalNotifications Callback**
        └── SplashScreen
                └── MaterialApp
                        └── routes

우선 프로젝트 구조는 좌측과 같이 구성되어 있었다. FlutterLocalNotification Callback을 모든 routes에서 처리하기 위해, MaterialAppMaterialApp으로 한 번 더 래핑하고 있었다. 그리고 앱을 켰을 때 SplashScreen 패키지를 사용해 비동기 동작을 처리한 뒤, 작업이 끝나면 MaterialApp을 선언하도록 작성되어 있었다.

Navigator.push()와 Context

대충 봐도 FlutterLocalNotifications Callback을 통해 앱을 실행했을 때와, 앱이 실행된 상태에서 FlutterLocalNotifications Callback을 통해 특정 화면에 진입했을 때의 차이는 MaterialApp인게 분명해보인다. 그렇다면 의심을 확신으로 바꾸기 위해, Navigator.dart에 구현된 push를 살펴보자.

@optionalTypeArgs
Future<T?> push<T extends Object?>(Route<T> route) {
  assert(_debugCheckIsPagelessRoute(route));
  _pushEntry(_RouteEntry(route, initialState: _RouteLifecycle.push));
  return route.popped;
}

void _pushEntry(_RouteEntry entry) {
    assert(!_debugLocked);
    assert(() {
      _debugLocked = true;
      return true;
    }());
    assert(entry.route != null);
    assert(entry.route._navigator == null);
    assert(entry.currentState == _RouteLifecycle.push);
    _history.add(entry);
    _flushHistoryUpdates();
    assert(() {
      _debugLocked = false;
      return true;
    }());
    _afterNavigation(entry.route);
  }

Navigator.push()가 해주는 것은 크게 대단하지 않다. Navigator 내부에서 _RouteEntry를 관리하는 리스트 _history에, 인자값으로 넘겨준 route를 추가하고 _flushHistoryUpdates()를 호출해준다. 그러면 _flushHistoryUpdates()가 내부 리스트 _history를 기반으로 화면을 갱신해준다. 그 후 호출한 함수에 따라서 didPush(), didReplace()같은 후처리 함수를 호출해주게 되는데, NavigationHistoryObserver는 이 didPush(), didReplace()를 오버라이드해서 _history의 최상단에 있는 _RouteEntry에 대한 정보를 읽어올 수 있게 해준다. 여기까지는 어렵지 않게 이해할 수 있다. 그렇다면 FlutterLocalNotifications Callback에서 넘겨준 RouteSettings는 어디로 사라져버린걸까?

위에서 페이지를 이동할 때 호출한 코드를 잠시 다시 살펴보면, Navigator.of()의 인자값으로 context를 전달하고 있다. 그리고 Navigator.of()의 구현을 살펴보면, 이 context값을 기준으로 최상위에 선언된 Navigator를 찾는 걸 볼 수 있다. 그러니 FlutterLocalNotifications Callback에서 push()를 통해 이동한 페이지의 최상위 Navigator와, NavigationHistoryObserver에서 찾은 최상위 Navigator가 서로 달라서 넘겨준 RouteSettings이 달라지는 것이다.

static NavigatorState of(
  BuildContext context, {
  bool rootNavigator = false,
}) {
  // Handles the case where the input context is a navigator element.
  NavigatorState? navigator;
  if (context is StatefulElement && context.state is NavigatorState) {
    navigator = context.state as NavigatorState;
  }
  if (rootNavigator) {
    navigator = context.findRootAncestorStateOfType<NavigatorState>() ?? navigator;
  } else {
    navigator = navigator ?? context.findAncestorStateOfType<NavigatorState>();
  }

  assert(() {
    if (navigator == null) {
      throw FlutterError(
        'Navigator operation requested with a context that does not include a Navigator.\n'
        'The context used to push or pop routes from the Navigator must be that of a '
        'widget that is a descendant of a Navigator widget.',
      );
    }
    return true;
  }());
  return navigator!;
}

그렇다면 최상위 Navigator는 언제 생성이 되는걸까? Flutter 문서 중 MaterialApp에 대한 내용을 살펴보면, MaterialApp이 선언될 때 최상위 Navigator를 구성하는 절차를 살펴볼 수 있다.

MaterialApp class - material library - Dart API

The MaterialApp configures the top-level Navigator to search for routes in the following order:

  • For the / route, the home property, if non-null, is used.
  • Otherwise, the routes table is used, if it has an entry for the route.
  • Otherwise, onGenerateRoute is called, if provided. It should return a non-null value for any valid route not handled by home and routes.
  • Finally if all else fails onUnknownRoute is called.

이것으로 문제상황이 발생했을 때, 왜 NavigationHistoryObserver를 사용해서 특정 화면에 진입했는지 판별할 수 없는지 확인할 수 있었다. 그렇다면 이 문제는 어떻게 해결할 수 있을까? 의외로 이 문제는 GetX를 사용해서 쉽게 해결할 수 있었다.

GetX.to()

GetX에서 제공하는 to()push()의 가장 큰 차이는 Context를 넘기지 않아도 된다는 점이다. 이 얘기는 바꿔말하면, 최상위 Navigator를 판단하는 로직이 다르다는 얘기가 되며, 결과적으로는 내가 직면한 문제 상황을 GetX가 해결해줄 수 있던 이유가 될 것이다. 좋아, 그렇다면 GetX.to()가 어떻게 최상위 Navigator를 탐색하는지 확인해보자.

Future<T?>? to<T>(
    dynamic page, {
    bool? opaque,
    Transition? transition,
    Curve? curve,
    Duration? duration,
    int? id,
    String? routeName,
    bool fullscreenDialog = false,
    dynamic arguments,
    Bindings? binding,
    bool preventDuplicates = true,
    bool? popGesture,
    double Function(BuildContext context)? gestureWidth,
  }) {
    // var routeName = "/${page.runtimeType}";
    routeName ??= "/${page.runtimeType}";
    routeName = _cleanRouteName(routeName);
    if (preventDuplicates && routeName == currentRoute) {
      return null;
    }
    return global(id).currentState?.push<T>(
          GetPageRoute<T>(
            opaque: opaque ?? true,
            page: _resolvePage(page, 'to'),
            routeName: routeName,
            gestureWidth: gestureWidth,
            settings: RouteSettings(
              name: routeName,
              arguments: arguments,
            ),
            popGesture: popGesture ?? defaultPopGesture,
            transition: transition ?? defaultTransition,
            curve: curve ?? defaultTransitionCurve,
            fullscreenDialog: fullscreenDialog,
            binding: binding,
            transitionDuration: duration ?? defaultTransitionDuration,
          ),
        );
  }

GlobalKey<NavigatorState> global(int? k) {
    GlobalKey<NavigatorState> newKey;
    if (k == null) {
      newKey = key;
    } else {
      if (!keys.containsKey(k)) {
        throw 'Route id ($k) not found';
      }
      newKey = keys[k]!;
    }

    if (newKey.currentContext == null && !testMode) {
      throw """You are trying to use contextless navigation without
      a GetMaterialApp or Get.key.
      If you are testing your app, you can use:
      [Get.testMode = true], or if you are running your app on
      a physical device or emulator, you must exchange your [MaterialApp]
      for a [GetMaterialApp].
      """;
    }

    return newKey;
  }

extension_navigation.dart에 구현된 GetX.to()를 살펴보면 위와 같이 구현되어있는 것을 알 수 있다. GetX.to()를 호출하게되면 내부적으로 global(id).currentState?.push()를 호출하게된다. global(id)는 뭘까? 확인해보면 인자로 전달된 키 값을 확인한 뒤, null인 경우에는 전역으로 관리되고 있는 키 값을 가져와서 context를 불러온다. 결국 GetX는 라우팅을 할 때 context를 호출하지 않기 위해, 전역으로 context를 관리하고 있음을 알 수 있다. 그러므로 NavigationHistoryObserver 대신 GetX에서 제공하는 GetObserver()를 통해서 히스토리를 확인하면, 문제 상황에서도 RouteSettings를 참조할 수 있게 되는 것이다.

.
└── MaterialApp
        ├── **FlutterLocalNotifications Callback**
        └── SplashScreen
                └── GetMaterialApp
                        └── routes

일반적으로 만날 수 있는 문제는 아니겠거니 싶지만, 이번 문제를 통해서 MaterialApp의 최상위 Navigator가 구성되는 시점을 알 수 있었다. 또, Navigator.push()에서 context를 통해 최상위 Navigator를 탐색하는 방법과, GetX.to()context 없이 어떻게 최상위 Navigator를 탐색하는지 알 수 있었다.

어째 글을 정리해놓다보니 시작은 창대했으나 끝은 미미해져버린 그런 느낌이 들긴 하지만 아무래도 괜찮겠지…

반응형