ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Flutter more:] Navigator 는 페이지 스택을 어떻게 관리하는가?
    Archiving/Flutter 2024. 1. 18. 23:11
    반응형

    평소 궁금했지만 왠지 시간없어서 지나쳤던 것들을 분석해보는 코너! Flutter more 입니다.

    오늘은 Navigator 페이지 스택에 대해 분석해보려고 합니다. 이 글에서는 네비게이터 객체에 대한 메카니즘과 프레임워크에 구현된 소스를 분석하는 것으로 네비게이터에 대해 분석한 내용이 작성됩니다.


    네비게이터(Navigator)?

    플러터에서 네비게이터('Navigator')는 앱 내의 화면과 이동을 관리하는 위젯입니다. 이 위젯은 화면들을 식별(Routing)하거나 이동 (Navigation) 하는데 필요한 기능을 제공해주고 있습니다. 이름으로 구성된 화면을 식별하여 사용자에게 필요로 하는 요소를 제공 할 수 있으며 화면간 데이터를 전달하고 그 결과를 받아 흐름을 제어 할 수 있습니다.  


    화면 이동 관리 메카니즘.

    웹브라우저 사용 경험을 떠올려봅시다. 랜딩 화면에서 A버튼을 누르면 A화면으로 이동합니다. 일반적으로 우리는 머리속에 이전 화면이 처음 마주한 랜딩 화면이었다는 것을 기억합니다. '뒤로가기' 버튼을 누르면 처음 마주한 이전 화면이 보이게 됩니다. 다시 처음 화면으로 돌아간 상태에서 B버튼을 누르면 B화면으로 이동 할 것입니다. B화면에서 C버튼을 누르면 C화면으로 이동 하게 됩니다. 아래 그림을 참고하여 머리속에 이미지를 뚜렷하게 만들어보겠습니다.

     

    왼쪽의 그림은 랜딩 화면을 나타냅니다.

    오른쪽의 그림은 화면 목록(History)을 나타냅니다.

     

    랜딩 페이지에 진입한 상태이므로 화면목록에 랜딩 화면('Lading Page')이 들어있습니다.

     

    A Screen 버튼을 누르면 A page 로 이동 됩니다.

    화면 목록(History)에는 A Page 가 들어갑니다.

     

     

    A Page에서 '뒤로가기' 버튼을 눌렀습니다. 이전 화면인 Landing Page로 이동합니다.

    화면 목록(History)에서 A Page가 삭제 됩니다.

     

    지금까지의 내용을 간단히 정리해보면 아래와 같습니다.

     

    • A화면에 들어갈 때 : 열려있는 화면 목록에 최상단에 추가된다. (Push)
    • A화면에서 나올 때 : 열려있는 화면 목록에 최상단에서 빠진다. (Pull)

     

    정리된 내용을 살펴보니 처음 입력한 자료가 가장 처음 나오는 구조. 스택이라는 자료구조가 떠오릅니다.

    이렇듯 네비게이터는 화면의 관리를 위해 스택이라는 자료구조를 사용하고 있음을 알 수 있었습니다.

     


    플러터 프로젝트를 생성시 만들어지는 기본 코드에 버튼과 페이지를 추가하고 이동하는 로직을 넣어 우리가 상상했던 바가 맞았는지 검증을 해보려고 합니다. 아래 첨부된 이미지는 가볍게 구축한 내용을 담고 있으며 랜딩페이지, 1번째 페이지, 2번째 페이지, 3번째 페이지를 이동 할 수 있도록 만든 앱의 스크린샷입니다.

     

    샘플 앱을 구동하고 버튼을 누르면 화면을 이동하게 됩니다. 뒤로가기 버튼을 눌러 이동된 화면을 벗어날 수 도 있습니다.  다만 처음 시작하다보니 아무것도 준비한게 없습니다. 로그를 찍을 수도 없는 상태에서 어떻게 우리가 상상했던 스택 영역을 확인 할 수 있을까요? 플러터에서 제공하는 개발툴을 이용하면 아래와 같은 위젯 트리를 볼 수 있습니다. 개발툴은 사용중인 IDE에서 플러터 플러그인을 설치하게 되면 쉽게 띄울 수 있습니다. 제가 선행해서 찍어놓은 스크린샷을 살펴보는 것으로 우리는 가볍게 넘어가도록 하겠습니다.

     
     

     

    트리의 최상단에 [root] 가 존재하고 있으며 runApp 을 통해 구동한 MyApp 이 보입니다. 그 밑으로 MyApp 에서 선언한 MaterialApp 위젯이 보이고, 그 자식 노드로 MyHomePage 위젯이 보입니다. 우리가 랜딩 페이지라고 말했던 페이지는 'MyHomePage' 위젯으로 스택에 'MyHomePage' 가 들어가 있음을 확인했습니다.

     

    'First page' 버튼을 눌러 'FirstPage' 위젯으로 이동한 후 개발툴로 돌아와 WidgetTree 를 갱신하면 오른쪽 화면이 뜨는 것을 확인 할 수 있습니다. MaterialApp 하위에 'FirstPage' 가 추가되어있는 것을 확인 할 수 있습니다. 그러면 이번엔 뒤로가기 버튼을 누르고 Widget Tree 에서 다시 새로고침을 해봅니다. 그러면 처음 랜딩 페이지의 화면처럼 'MyHomePage' 만 남아있는 것을 확인 할 수 있습니다.

     

    이것을 통해 우리는 Navigator 가 어떻게 화면을 관리하는지를 살펴보았습니다. 

     

    예제를 따라해보실 분은 열기를 통해 살펴보세요.

    더보기
    import 'package:flutter/material.dart';
    
    void main() {
      runApp(const MyApp());
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      // This widget is the root of your application.
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
            useMaterial3: true,
          ),
          home: const MyHomePage(title: 'Flutter Demo Home Page'),
        );
      }
    }
    
    class MyHomePage extends StatefulWidget {
      const MyHomePage({super.key, required this.title});
    
      final String title;
    
      @override
      State<MyHomePage> createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> {
      int _counter = 0;
    
      void _incrementCounter() {
        setState(() {
          _counter++;
        });
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            backgroundColor: Theme.of(context).colorScheme.inversePrimary,
            title: Text(widget.title),
          ),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                const Text(
                  'You have pushed the button this many times:',
                ),
                Text(
                  '$_counter',
                  style: Theme.of(context).textTheme.headlineMedium,
                ),
                TextButton(
                  onPressed: () {
                    Navigator.of(context).push(MaterialPageRoute(
                      builder: (context) => const FirstPage(),
                    ));
                  },
                  child: const Text('First Page'),
                ),
                TextButton(
                  onPressed: () {
                    Navigator.of(context).push(MaterialPageRoute(
                      builder: (context) => const SecondPage(),
                    ));
                  },
                  child: const Text('Second Page'),
                ),
                TextButton(
                  onPressed: () {
                    Navigator.of(context).push(MaterialPageRoute(
                      builder: (context) => const ThirdPage(),
                    ));
                  },
                  child: const Text('Third Page'),
                ),
              ],
            ),
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: _incrementCounter,
            tooltip: 'Increment',
            child: const Icon(Icons.add),
          ),
        );
      }
    }
    
    class FirstPage extends StatelessWidget {
      const FirstPage({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('First'),),
          body: const Center(
            child: Text('First page'),
          ),
        );
      }
    }
    
    class SecondPage extends StatelessWidget {
      const SecondPage({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Second'),),
          body: const Center(
            child: Text('Second page'),
          ),
        );
      }
    }
    
    class ThirdPage extends StatelessWidget {
      const ThirdPage({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Third'),),
          body: const Center(
            child: Text('ThirdPage page'),
          ),
        );
      }
    }

     

    그렇다면 실제 프레임워크 소스에서의 네비게이터는 어떻게 페이지를 관리하는지 소스를 살펴보겠습니다. 

    네비게이터 위젯은 스택을 이용해 화면을 식별하고 관리합니다. 이러한 스택을 처리하기 위해 제공하는 인터페이스는 여러가지가 있지만 종합해서 간추려보면 다음과 같습니다.

    • push
    • pop

    단순히 넣고 빼는 작업으로 모든 것의 설명이 가능합니다.

    먼저 push 의 내부 로직은 아래와 같습니다.

     

    @optionalTypeArgs
    Future<T?> push<T extends Object?>(Route<T> route) {
      _pushEntry(_RouteEntry(route, pageBased: false, initialState: _RouteLifecycle.push));
      return route.popped;
    }
    
    void _pushEntry(_RouteEntry entry) {
      //...(assert 생략)
      _history.add(entry);
      _flushHistoryUpdates();
      //...(assert 생략)
      _afterNavigation(entry.route);
    }

     

    push 메소드가 호출되면 내부 메소드인 pushEntry 가 호출됩니다. 이때 넘어가는 RouteEntry (라우팅에 관련된 정보가 들어있는 객체 정보) 객체가 _history 멤버 변수에 추가되는 모습을 확인 할 수 있습니다. 

    final _History _history = _History();

     

    여기까지 소스를 확인하셨다면 대충 상상으로 History 객체가 어떤 역할을 맡았는지 알 것입니다. 화면을 스택을 통해 관리한다는 개념을 플러터에서는 History 객체에서 담당하고 있으며 내부적으로 Stack 자료구조의 인터페이스를 제공하고 있을 거라 추측 됩니다.

     

    아래 History 클래스의 인터페이스 주요 변수 일부를 발췌해봤습니다.

    
    class _History extends Iterable<_RouteEntry> with ChangeNotifier {
      //...
      final List<_RouteEntry> _value = <_RouteEntry>[];
      
      int indexWhere(_IndexWhereCallback test, [int start = 0]);
    
      void add(_RouteEntry element);
    
      void addAll(Iterable<_RouteEntry> elements);
    
      void clear();
    
      void insert(int index, _RouteEntry element);
    
      _RouteEntry removeAt(int index);
    
      _RouteEntry removeLast();
    }

     

    스택의 인터페이스만 제공한다기보다는 List 구조를 잘 활용 할 수 있는 유연한 구조의 인터페이스를 제공하는 것을 볼 수 있습니다.


    결론

    Navigator 의 가장 기본 부분이었던 스택 기반의 페이지 관리에 대한 방법을 찬찬히 살펴보았습니다. 실제로 애플리케이션을 제작하고 다양한 요구사항을 들어주려고 하다보면 페이지 스택관리에 은근 골머리가 썩기 마련입니다. 그럴때마다 내가 원하는 동작이 지원되는지 찾게 되는게 Navigator 입니다. 프레임워크 단에서 제공되는 스택관리 인터페이스는 내부 클래스다보니 외부 인터페이스가 제공되지 않습니다. 그렇기 때문에 각 인터페이스 내부 로직이 어떻게 동작하는지 살펴볼 수 있는 계기가 되셨으면 합니다.

     

    반응형
Designed by Tistory.