내비게이션

SophonzNavigationObserver와 sophonz_go_router로 Flutter 라우트를 뷰와 화면 로드 스팬으로 기록하는 방법 — StatefulShellRoute, 라우트 이름, 라우트가 아닌 화면까지 — 을 설명합니다.

네이티브 SDK에게 Flutter 앱은 액티비티 하나, 뷰 컨트롤러 하나이므로 Flutter 화면을 구분하지 못합니다. 라우트 추적은 Dart에서 이루어집니다. 내비게이터 옵저버가 라우트 변경을 뷰와 화면 로드 스팬으로 바꿉니다. Navigator와 MaterialApp에는 SophonzNavigationObserver를, go_router에는 sophonz_go_router의 SophonzGoRouterObserver를 씁니다.

기록되는 것

기록이름시점
뷰라우트 이름라우트가 현재 화면이 된 때부터 다른 라우트로 바뀌거나 pop될 때까지. startView / endView로 네이티브에 기록
화면 로드 스팬라우트 이름이동을 일으킨 터치(또는 push 시점)부터 다음 프레임이 끝날 때까지
spz-time-to-interactive-flutter고정프로세스당 한 번, Dart 시작부터 처음 추적된 라우트 다음 프레임까지

Android에서 뷰는 Android SDK의 뷰 스팬이고, iOS에서는 뷰 이름 속성에 라우트가 담긴 화면 뷰 스팬입니다.

NOTE — 화면 로드 스팬은 첫 프레임에서 끝납니다

새 라우트의 첫 프레임이 나타나기까지의 시간이며, 화면이 데이터로 채워지기까지의 시간이 아닙니다. 화면이 뜬 뒤 데이터를 불러온다면 로딩 구간에 직접 스팬을 두세요. 예제를 참고하세요.

화면 로드 시작 시각

라우트는 보통 탭에 대한 반응으로 push되고, 사용자는 탭한 순간부터 기다립니다. SDK는 앱 어디서든 가장 최근의 포인터 down·up 시각을 기록합니다. 라우트가 push될 때 recencyThreshold보다 오래되지 않은 터치가 있으면 그 시각을 스팬 시작으로 쓰고 소비하므로, 이후의 push가 같은 터치를 다시 쓰지 않습니다. 네트워크 응답 뒤의 push처럼 최근 터치가 없으면 push 시점이 시작입니다.

SophonzNavigationObserver(
  screenLoadConfig: const SophonzScreenLoadConfig(
    recencyThreshold: Duration(milliseconds: 500),
  ),
)

기본값은 1초입니다.

MaterialApp(
  navigatorObservers: [SophonzNavigationObserver()],
  onGenerateRoute: onGenerateRoute,
);
Navigator 이벤트뷰화면 로드 스팬
push이전 라우트의 뷰를 끝내고 새 뷰 시작예
pushReplacement, replace교체된 뷰를 끝내고 새 뷰 시작예
poppop된 뷰를 끝내고 아래 라우트의 뷰를 다시 시작아니요

옵저버는 현재 라우트를 프레임 모니터링에도 알려 주므로 slow-frames, frozen-frame 로그에 route 속성이 붙습니다.

라우트 이름

이름이 있는 라우트만 추적됩니다. MaterialApp의 home은 /, pushNamed로 연 라우트는 경로가 이름이 되지만, settings 없이 만든 MaterialPageRoute는 이름이 없어 건너뜁니다. 다이얼로그와 바텀 시트도 routeSettings를 넘기지 않으면 마찬가지입니다.

Navigator.of(context).push(
  MaterialPageRoute<void>(
    settings: const RouteSettings(name: 'OrderDetail'),
    builder: (_) => OrderDetailPage(orderId: order.id),
  ),
);

이름의 종류는 적게 유지하세요. 이름이 스팬 이름으로 쓰이므로 OrderDetail은 모든 주문을 한 이름으로 묶지만, /orders/A-1042는 주문마다 스팬 이름을 하나씩 만듭니다. ID는 속성이나 세션 속성에 넣으세요.

routeSettingsExtractor

추출기는 기록할 RouteSettings를 돌려주거나, null을 돌려줘 라우트를 건너뜁니다. 라우트 이름을 바꾸거나, 경로를 정규화하거나, 다이얼로그 같은 라우트를 제외할 때 씁니다.

final idSegment = RegExp(r'/\d+');
 
SophonzNavigationObserver(
  routeSettingsExtractor: (route) {
    if (route is PopupRoute) return null; // 다이얼로그, 메뉴, 바텀 시트
    final name = route.settings.name;
    if (name == null) return null;
    return RouteSettings(name: name.replaceAll(idSegment, '/:id'));
  },
)

중첩 내비게이터

NavigatorObserver 인스턴스 하나는 Navigator 하나에만 붙일 수 있습니다. 탭마다 두는 중첩 Navigator에는 각자의 observers에 SophonzNavigationObserver()를 따로 넣습니다. 상호작용 가능까지의 시간 스팬은 그래도 프로세스당 한 번만 기록됩니다.

go_router

sophonz_go_router는 go_router 6.0.0 이상 16.0.0 미만을 지원합니다. go_router 16 이상은 v0.1.0과 함께 해석되지 않습니다. 패키지는 설치에 설명한 대로 dependency_overrides 항목까지 포함해 추가합니다.

옵저버에는 두 가지 모드가 있습니다. 하나만 고르고, 한 라우터에 둘을 함께 쓰지 마세요.

옵저버 모드라우터 모드
설정 방법GoRouter(observers: [SophonzGoRouterObserver()])SophonzGoRouterObserver(router: router)
보는 범위루트 내비게이터의 라우트ShellRoute, StatefulShellRoute 브랜치를 포함한 모든 위치 변경
이름RouteSettings.name 또는 routeSettingsExtractor위치 경로(uri.path)
뷰(startView / endView)예아니요
화면 로드 스팬예예
상호작용 가능까지의 시간예예
상태 스팬없음spz-state-screen-flutter-automatic
dispose() 필요아니요예

옵저버 모드

셸 라우트가 없는 앱에 씁니다.

import 'package:go_router/go_router.dart';
import 'package:sophonz_go_router/sophonz_go_router.dart';
 
final router = GoRouter(
  observers: [SophonzGoRouterObserver()],
  routes: [
    GoRoute(path: '/', builder: (_, __) => const HomePage()),
    GoRoute(
      path: '/orders/:id',
      name: 'order',
      builder: (_, state) => OrderPage(id: state.pathParameters['id']!),
    ),
  ],
);

SophonzNavigationObserver와 같게 동작하지만, 현재 라우트를 프레임 모니터링에 알리지는 않습니다. routeSettingsExtractor와 screenLoadConfig도 똑같이 동작합니다.

라우터 모드 (StatefulShellRoute)

GoRouter.observers는 루트 내비게이터만 봅니다. StatefulShellRoute에서 브랜치를 바꾸면 루트에는 아무것도 push되지 않으므로 옵저버 모드는 이를 놓칩니다. 라우터 모드는 go_router의 routeInformationProvider를 구독해 모든 위치 변경을 봅니다.

class App extends StatefulWidget {
  const App({super.key});
 
  @override
  State<App> createState() => _AppState();
}
 
class _AppState extends State<App> {
  late final GoRouter _router = GoRouter(
    initialLocation: '/home',
    routes: [
      StatefulShellRoute.indexedStack(
        builder: (context, state, shell) => ScaffoldWithNavBar(shell: shell),
        branches: [
          StatefulShellBranch(routes: [
            GoRoute(path: '/home', builder: (_, __) => const HomePage()),
          ]),
          StatefulShellBranch(routes: [
            GoRoute(path: '/orders', builder: (_, __) => const OrdersPage()),
          ]),
        ],
      ),
    ],
  );
 
  // 라우터가 살아 있는 동안 인스턴스 하나만 둡니다. 만드는 순간 초기 위치를 기록합니다.
  late final SophonzGoRouterObserver _observer;
 
  @override
  void initState() {
    super.initState();
    _observer = SophonzGoRouterObserver(router: _router);
  }
 
  @override
  void dispose() {
    _observer.dispose();
    _router.dispose();
    super.dispose();
  }
 
  @override
  Widget build(BuildContext context) => MaterialApp.router(routerConfig: _router);
}

옵저버는 생성될 때 현재 위치를 기록하고, 이후 변경마다 화면 로드 스팬을 하나씩 기록합니다. 라우터가 살아 있는 동안 상태 스팬 spz-state-screen-flutter-automatic도 열어 둡니다.

속성 또는 이벤트값
spz.state.initial_value옵저버를 만들 때의 위치
transition 이벤트, spz.state.new_value새 위치마다
spz.state.transition_count전환 횟수. dispose()가 스팬을 끝낼 때 설정

CAUTION — 라우터 모드는 실제 경로로 스팬 이름을 짓습니다

이름은 라우트 패턴이 아니라 /orders/A-1042처럼 실제로 이동한 위치 경로입니다. ID가 들어간 경로는 ID마다 스팬 이름을 만들고, 이 모드에서는 routeSettingsExtractor도 무시됩니다. ID는 쿼리 파라미터로 넘기거나(/orders?id=A-1042는 /orders로 기록), 브랜치 추적보다 이름이 중요하면 옵저버 모드를 쓰세요.

라우터 모드는 startView / endView를 호출하지 않으므로 뷰 스팬이 기록되지 않습니다. 브랜치별 뷰도 필요하면 셸의 브랜치 변경 시점에 직접 호출하세요.

라우터를 없앨 때는 dispose()를 호출하세요. 호출하지 않으면 리스너가 남고 상태 스팬이 끝나지 않습니다.

라우트가 아닌 화면

TabBarView의 탭, PageView나 IndexedStack의 페이지, 라우트 없이 띄운 바텀 시트는 내비게이터를 거치지 않습니다. 뷰로 직접 기록하세요.

class _HomeTabsState extends State<HomeTabs> with SingleTickerProviderStateMixin {
  static const _names = ['Feed', 'Search', 'Profile'];
  late final TabController _tabs = TabController(length: 3, vsync: this);
  int _current = 0;
 
  @override
  void initState() {
    super.initState();
    Sophonz.instance.startView(_names[_current]);
    _tabs.addListener(() {
      if (_tabs.indexIsChanging || _tabs.index == _current) return;
      Sophonz.instance.endView(_names[_current]);
      _current = _tabs.index;
      Sophonz.instance.startView(_names[_current]);
    });
  }
 
  @override
  void dispose() {
    Sophonz.instance.endView(_names[_current]);
    _tabs.dispose();
    super.dispose();
  }
 
  // ...
}

endView에는 startView와 같은 이름을 넘겨야 합니다. iOS에서는 이미 열린 이름으로 뷰를 시작하면 이전 뷰를 먼저 끝냅니다.

플랫폼 참고

  • iOS: app.screen.name이 바뀌지 않습니다. Flutter 뷰와 화면 로드 스팬은 기록되지만 Apple SDK의 현재 화면 추적은 UIKit만 따라가므로, 다른 스팬에는 Flutter 뷰 컨트롤러 이름이 남습니다.
  • iOS: 화면 로드 스팬의 spz.type. 옵저버는 spz.type: view를 속성으로 설정합니다. Apple SDK는 스팬 타입을 스스로 정하고 호출자가 넣은 spz.type을 버리므로, iOS에서 화면 로드 스팬은 뷰 타입으로 기록되지 않습니다.
  • 커스텀 스팬 한도. 화면 로드 스팬은 네이티브 SDK 입장에서 커스텀 스팬이므로 직접 만든 스팬과 세션당 한도를 나눠 씁니다.

다음 단계