Section13 GoRouter

sihyun·2024년 4월 1일

GoRouter

과거 프로젝트에서 페이지를 관리할 때 MaterialApp의 기본 route를 사용해서 Navigator.pushName(), pushReplacementNamed() 메서드를 사용했다. 이번에는 go_router 라이브러리를 사용해서 페이지 라우팅를 어떻게 하는지 알아보았다.

기본 설정

class _App extends StatelessWidget {
  const _App({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: router,
    );
  }
}

MaterialApp.router는 routerConfig Named Parameter를 통해 RouterConfig 클래스의 인스턴스를 받아야 한다.

final router = GoRouter(
  routes: [
  	GoRoute(
      path: '/',
      builder: (context, state) {
        return RootScreen();
      },
      routes: [
        GoRoute(
          path: 'basic',
          builder: (context, state) {
            return BasicScreen();
          },
        ), 
        GoRoute(...),
        GoRoute(...),
        GoRoute(...),
      ],
  ],
);

특징

  • 라우터의 레벨을 지정하여 관리할 수 있다.
    • 단 같은 레벨에서의 이동은 Router Stack에 쌓이지 않는다. 왜냐하면 같은 레벨에서 context.go('same level path');를 실행하면 자신 위에 올리지 않고 상위 레벨의 router부터 스택에 넣은 뒤 이동하기 때문이다. 즉, 같은 레벨에서의 이동은 스택을 바꾼다 라고 생각할 수 있다.
    • 만약 같은 레벨에서 이동의 흔적(스택)을 남기고 싶다면 context.push('same level path');를 사용하면 스택이 남게 된다.

PathParameter, QueryParameter

final router = GoRouter(
  routes: [
  	GoRoute(
      path: 'path_param/:id',
      builder: (context, state) {
        return PathParamScreen();
      },
    ),
    GoRoute(
      path: 'query_param',
      builder: (context, state) {
        return QueryParameterScreen();
      },
    ),
  ],
);

------------- 사용 코드
ListView(
      children: [
        Text('Path Param : ${GoRouterState.of(context).pathParameters}'),
        ElevatedButton(
          onPressed: () {
            context.go('/path_param/123');
          },
          child: Text('Go One More'),
        ),
      ],
    ),
ListView(
        children: [
          Text(
              'Query Param : ${GoRouterState.of(context).uri.queryParameters}'),
          ElevatedButton(
            onPressed: () {
              context.push(
                Uri(path: '/query_param', queryParameters: {
                  'name': 'codefactory',
                  'age': '21',
                }).toString(),
              );
            },
            child: Text('Query Parameter'),
          ),
        ],
      ),

redirect

  • redirect를 전체 레벨, 특정 레벨에서 설정할 수 있다.

    • redirect 안에 context와 state를 가진 함수를 넣고 리턴으로 path를 주면 그 path로 리다이렉트 된다.
    final router = GoRouter(
    redirect: (context, state) {
      // return string(path) -> 해당 라우트로 이동한다.
      // return null -> 원래 이동하려던 라우트로 이동한다.
      if (state.uri.toString() == '/login/private' && !authState) {
        return '/login';
      }
    
      return null;
    },
    routes: [
    	GoRoute(),
    	GoRoute(),
    	GoRoute(),
      ...
    ],
  • 일부에서 적용하고 싶은면 GoRoute 안에 지정

    GoRoute(
            path: 'login2',
            builder: (context, state) => LoginScreen(),
            routes: [
              GoRoute(
                path: 'private',
                builder: (context, state) => PrivateScreen(),
                redirect: (context, state) {
                  if (!authState) {
                    return '/login2';
                  }
                  return null;
                },
              ),
            ],
          ),

에러 페이지, 로깅

  • errorBuilder를 통해 라우터에 없는 path로 접근 시 보여줄 페이지를 지정할 수 있다.
  • debugLogDiagnostics 속성을 true로 지정하면 라우터 이동에 대한 정보를 로그로 확인할 수 있다.
  • 실제 로그
Performing hot restart...
Syncing files to device iPhone 15 Pro Max...
Restarted application in 282ms.
[GoRouter] Full paths for routes:
  => /
  =>   /basic
  =>   /named
  =>   /push
  =>   /pop
  =>     /pop/return
  =>   /path_param/:id
  =>     /path_param/:id/:name
  =>   /query_param
  =>   /nested/a
  =>   /nested/b
  =>   /nested/c
  =>   /login
  =>     /login/private
  =>   /login2
  =>     /login2/private
  =>   /transition
  =>     /transition/detail
known full paths for route names:
  named_screen => /named

[GoRouter] setting initial location null
[GoRouter] Using MaterialApp configuration
[GoRouter] going to /basic
[GoRouter] going to /error
[GoRouter] No initial matches: /error
[GoRouter] going to /
[GoRouter] going to /login2
[GoRouter] going to /login2/private
[GoRouter] redirecting to RouteMatchList#a84bc(uri: /login2, matches: [RouteMatch#15de7(route: GoRoute#a4e47(name: null, path: "/")), RouteMatch#532f8(route: GoRoute#1cc06(name: null, path: "login2"))])
[GoRouter] going to /login2/private
[GoRouter] going to /basic

[코드팩토리 강의 실습]

UserMeRepository

  • Retrofit을 활용한 Repository 생성
part 'user_me_repository.g.dart';

()
abstract class UserMeRepository {
  factory UserMeRepository(Dio dio, {String baseUrl}) = _UserMeRepository;

  ('/')
  ({
    'accessToken': 'true',
  })
  Future<UserModel> getMe();
}
  • Repository 반환용 provider 생성
final userMeRepositoryProvider = Provider<UserMeRepository>((ref) {
  final dio = ref.watch(dioProvider);

  return UserMeRepository(dio, baseUrl: 'http://$ip/user/me');
});

UserMeProvider

UserModelBase

  • Model의 클래스에 따라 화면을 관리하기 위해 UserModelBase를 상속하는 UserModel 상태 클래스 생성
    abstract class UserModelBase {}
    
    class UserModelLoading extends UserModelBase {}
    
    class UserModelError extends UserModelBase {
      final String message;
    
      UserModelError({
        required this.message,
      });
    }
    
    ()
    class UserModel extends UserModelBase {
      final String id;
      final String username;
      (
        fromJson: DataUtils.pathToUrl,
      )
      final String imageUrl;
    
      UserModel({
        required this.id,
        required this.username,
        required this.imageUrl,
      });
    
      factory UserModel.fromJson(Map<String, dynamic> json) =>
          _$UserModelFromJson(json);
    }
    

StateNotifier 생성

  • StateNotifier<관리할 상태 클래스>
  • StateNotifier 클래스 생성
    final userMeProvider =
        StateNotifierProvider<UserMeStateNotifier, UserModelBase?>((ref) {
      final authRepository = ref.watch(authRepositoryProvider);
      final userMeRepository = ref.watch(userMeRepositoryProvider);
      final storage = ref.watch(secureStorageProvider);
    
      return UserMeStateNotifier(
        authRepository: authRepository,
        repository: userMeRepository,
        storage: storage,
      );
    });
    
    class UserMeStateNotifier extends StateNotifier<UserModelBase?> {
      final UserMeRepository repository;
      // 토큰용
      final FlutterSecureStorage storage;
    
      UserMeStateNotifier({
        required this.repository,
        required this.storage,
      }) : super(UserModelLoading()) {
        // 내 정보 가져오기
        getMe();
      }
    	
    	// 토큰 기반으로 유저 정보 가져오기
      Future<void> getMe() async {
        final refreshToken = await storage.read(key: REFRESH_TOKEN_KEY);
        final accessToken = await storage.read(key: ACCESS_TOKEN_KEY);
    
        if (refreshToken == null || accessToken == null) {
          state = null;
          return;
        }
    
        final resp = await repository.getMe();
    
        state = resp;
      }
    }
    

auth 관리용 repository

  • Auth를 다른 도메인 URL로 관리하고 있기 때문에 폴더를 달리해서 관리
  • Header에 작업할 것이 있어 Retofit을 사용하지 않고 일반 클래스로 생성
  • token, 로그인과 관련된 서버에서 가져오는 메서드 관리 클래스
    final authRepositoryProvider = Provider<AuthRepository>((ref) {
      final dio = ref.watch(dioProvider);
    
      return AuthRepository(baseUrl: 'http://$ip/auth', dio: dio);
    });
    
    class AuthRepository {
      final String baseUrl;
      final Dio dio;
    
      AuthRepository({
        required this.baseUrl,
        required this.dio,
      });
    
      Future<LoginResponse> login({
        required String username,
        required String password,
      }) async {
    	  // DataUtils.plainToBase64 = plain Data를 Base64로 인코딩 하는 코드
        final serialized = DataUtils.plainToBase64('$username:$password');
    
        final resp = await dio.post(
          '$baseUrl/login',
          options: Options(
            headers: {
              'authorization': 'Basic $serialized',
            },
          ),
        );
    
        return LoginResponse.fromJson(resp.data);
      }
    
      Future<TokenResponse> token() async {
        final resp = await dio.post(
          '$baseUrl/token',
          options: Options(
            headers: {
              'refreshToken': 'true',
            },
          ),
        );
    
        return TokenResponse.fromJson(resp.data);
      }
    }
    

UserMeStateNotifier에 auth_repository 추가

class UserMeStateNotifier extends StateNotifier<UserModelBase?> {
  final AuthRepository authRepository;
  final UserMeRepository repository;
  final FlutterSecureStorage storage;

  UserMeStateNotifier({
    required this.authRepository,
    required this.repository,
    required this.storage,
  }) : super(UserModelLoading()) {
    // 내 정보 가져오기
    getMe();
  }

  Future<void> getMe() async {
		...
  }

  Future<UserModelBase> login({
    required String username,
    required String password,
  }) async {
    try {
      state = UserModelLoading();

			// authRepository에서 username, password를 인코딩해서 헤더에 넣고 데이터를 가져오는 로직이 존재함.
      final resp = await authRepository.login(
        username: username,
        password: password,
      );

      await storage.write(key: REFRESH_TOKEN_KEY, value: resp.refreshToken);
      await storage.write(key: ACCESS_TOKEN_KEY, value: resp.accessToken);

			// 로그인 후 데이터 저장
      final userResp = await repository.getMe();

      state = userResp;

      return userResp;
    } catch (e) {
      state = UserModelError(message: '로그인에 실패했습니다.');

      return Future.value(state);
    }
  }

  Future<void> logout() async {
    state = null;

    await Future.wait(
      [
        storage.delete(key: REFRESH_TOKEN_KEY),
        storage.delete(key: ACCESS_TOKEN_KEY),
      ],
    );
  }
}

AuthProvier 생성

  • 로그인 작업을 UserMeProvider에서만 하지 않았기 때문에 AuthProvider 생성
    • UserMeRepository - getMe, AuthRepository - login, logout
  • Auth 상태에 따라 화면의 이동(로그인 화면으로 이동, 로그아웃, 토큰의 상태에 따라 로그인, 홈 화면으로 보낼지)을 관리해야 하기 때문에 auth_provider에서 라우터와 리다이렉트 로직을 작성함
  • 로직
    • 유저 정보가 null일 때
      • 로그인 중이면 그대로 로그인 페이지에 두고 만약에 로그인 중이 아니라면 로그인 펭지로 이동
    • 유저 정보가 null이 아닐 때
      • 유저 정보가 UserModel라면 로그인 중이거나 현재 위치가 SplashScreen이면 홈으로 이동
      • 유저 정보가 UserModelError라면 로그인 하는 중이 아니라면 LoginScreen으로 이동

final authProvider = ChangeNotifierProvider<AuthProvider>((ref) {
  return AuthProvider(ref: ref);
});

class AuthProvider extends ChangeNotifier {
  final Ref ref;

  AuthProvider({
    required this.ref,
  }) {
    ref.listen<UserModelBase?>(userMeProvider, (previous, next) {
      if (previous != next) {
        notifyListeners();
      }
    });
  }

  void logout() {
    ref.read(userMeProvider.notifier).logout();
  }

  FutureOr<String?> redirectLogic(BuildContext context, GoRouterState state) {
    final UserModelBase? user = ref.read(userMeProvider);

    final loggingIn = state.uri.toString() == '/login';

    if (user == null) {
      return loggingIn ? null : '/login';
    }

    if (user is UserModel) {
      return loggingIn || state.uri.toString() == '/splash' ? '/' : null;
    }

    if (user is UserModelError) {
      return !loggingIn ? '/login' : null;
    }

    return null;
  }

라우트 등록

  • Screen 클래스에 스태틱 변수로 GoRoute.name에 사용할 이름 등록
List<GoRoute> get routes => [
  GoRoute(
      path: '/',
      name: RootTab.routeName,
      builder: (context, state) => RootTab(),
      routes: [
        GoRoute(
          path: 'restaurant/:rid',
          name: RestaurantDetailScreen.routeName,
          builder: (_, state) => RestaurantDetailScreen(
            id: state.pathParameters['rid']!,
          ),
        ),
      ]),
  GoRoute(
    path: '/splash',
    name: SplashScreen.routeName,
    builder: (context, state) => SplashScreen(),
  ),
  GoRoute(
    path: '/login',
    name: LoginScreen.routeName,
    builder: (context, state) => LoginScreen(),
  ),
];

RouterProvider

  • AuthProvider에서 라우터 정보를 들고 있긴 하지만 Routing에 필요한 정보 외에 token 등과 같은 정보가 있기 때문에 GoRouter 정보만 받을 수 있는 프로바이더 생성
    final routerProvider = Provider<GoRouter>((ref) {
      final provider = ref.read(authProvider);
    
      return GoRouter(
        routes: provider.routes,
        initialLocation: '/splash',
        // 상태가 변경됬을 때 리프레쉬를 해야한다. 그 대상 상태
        refreshListenable: provider,
    		// 리다이렉트 룰
        redirect: provider.redirectLogic,
      );
    });
    
  • 앱에 라우터 정보 등록
    
    class _App extends ConsumerWidget {
      const _App({super.key});
    
      
      Widget build(BuildContext context, WidgetRef ref) {
        final router = ref.watch(routerProvider);
    
        return MaterialApp.router(
          debugShowCheckedModeBanner: true,
          routerDelegate: router.routerDelegate,
          routeInformationParser: router.routeInformationParser,
          routeInformationProvider: router.routeInformationProvider,
        );
      }
    }
    
    • routerDelegate: router.routerDelegate,: 앱의 라우팅을 관리하는 객체를 지정한다. 이 routerDelegate는 앱의 현재 상태와 URL 경로를 기반으로 앱의 화면을 업데이트한다..
    • routeInformationParser: router.routeInformationParser,: URL 경로를 RouteInformation 객체로 변환하는 파서를 지정. 이 파서는 앱이 URL을 해석하는 방법을 정의한다.
    • routeInformationProvider: router.routeInformationProvider,: 현재 앱의 상태를 RouteInformation 객체로 변환하는 프로바이더를 지정, 이 프로바이더는 앱의 현재 상태를 URL로 변환하고, 그 역으로 URL을 앱의 상태로 변환하는 역할을 한다.

라우터 적용

// 메인 레스토랑 스크린
class RestaurantScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return PaginationListView(
      provider: restaurantProvider,
      itemBuilder: <RestaurantModel>(_, index, model) {
        return GestureDetector(
          onTap: () {
            context.goNamed(
              RestaurantDetailScreen.routeName,
              // 파라미터 지정
              pathParameters: {
                'rid': model.id,
              },
            );
          },
          child: RestaurantCard.fromModel(
            model: model,
          ),
        );
      },
    );
  }
}
profile
주니어 Flutter 개발자

0개의 댓글