2022. 7. 5. 17:52ㆍProgramming/Flutter
FlutterLocalNotification
패키지를 사용해서 푸시 메시지를 터치하면, 특정 페이지로 이동하게끔 구현을 해뒀다. 문제는 사용자가 이미 특정 페이지에 진입해있을 때, 푸시 메시지를 터치하는 경우. 이 경우에는 다른 동작을 처리하게끔 만들어주고 싶었다.
navigation_history_observer | Flutter Package
그래서 사용한 것이 바로 NavigationHistoryObserver. 이 친구를 사용하면 MaterialApp
이 push()
, pop()
등을 할 때마다 내부 리스트에 route
를 저장해서, 현재 어느 페이지에 위치해있는지 파악할 수 있게 된다.
매번 push()
를 호출하기 위해 MaterialPageRoute
를 설정하는 것은 귀찮은 일이었기 때문에, 다음과 같은 숏컷 함수를 만들어놨다. 어찌됐건 결과적으로 push()
를 호출하는 것은 마찬가지며, 인자값으로 전달한 widgetName
을 RouteSettings
의 name
값으로 할당하고 있다. 이것으로 특정 화면으로 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를 사용해서 확인해보니 히스토리 마지막에서 가져온 route
의 RouteSettings
에는 name
값이 null
이었다. RouteSettings.name
은 어디로 사라져버린 것일까?
.
└── MaterialApp
├── **FlutterLocalNotifications Callback**
└── SplashScreen
└── MaterialApp
└── routes
우선 프로젝트 구조는 좌측과 같이 구성되어 있었다. FlutterLocalNotification Callback을 모든 routes
에서 처리하기 위해, MaterialApp
을 MaterialApp
으로 한 번 더 래핑하고 있었다. 그리고 앱을 켰을 때 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
를 탐색하는지 알 수 있었다.
어째 글을 정리해놓다보니 시작은 창대했으나 끝은 미미해져버린 그런 느낌이 들긴 하지만 아무래도 괜찮겠지…
'Programming > Flutter' 카테고리의 다른 글
Pigeon을 사용하여 Type-safety한 네이티브 코드 작성하기 (0) | 2024.06.21 |
---|---|
Provider의 ChangeNotifier와 Dispose와 비동기 함수 (0) | 2024.06.20 |
[Flutter] isolate와 SharedPreferences, 그리고 파일에 대한 접근 2 (0) | 2022.06.10 |
[Flutter] 백그라운드에서 띄운 푸시 알람 메시지를 터치해서 앱을 실행했을 때, getInitialMessage()가 동작하지 않는다. (0) | 2022.02.04 |
[Flutter] isolate와 SharedPreferences, 그리고 파일에 대한 접근 (2) | 2021.11.19 |