Flutter go_router_builderふんわり解説
go_router_builderとは
ざっくりいうとgo_routerにbuild_runnerを使った自動生成を使って
より使いやすくできるようにするものがgo_router_builderです
何がどう使いやすくなるのか
- 自動生成によりコード記述量を減らせる
- 自動生成によりコードの統一化をしやすく、属人性を抑えることができる
- 引数付き画面遷移を型安全に行うことができるようになる
といった感じになります
何かデメリットはないの??というところが気になると思いますが
強いてあげるのであれば自動生成がめんどくさいという点ですが
それでも個人的には型安全に画面遷移できるメリットの方が大きいと感じているので
go_router_builder
を使った方がいいと思ってます
導入方法
公式ドキュメントを見た方が早いです
なのでここではgo_router
とどう変わるかだけ説明します
final GoRouter _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
return const HomeScreen();
},
routes: <RouteBase>[
GoRoute(
path: 'details',
builder: (BuildContext context, GoRouterState state) {
return const DetailsScreen();
},
),
],
),
],
);
/// The main app.
class MyApp extends StatelessWidget {
/// Constructs a [MyApp]
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _router,
);
}
}
class App extends StatelessWidget {
App({super.key});
final LoginInfo loginInfo = LoginInfo();
static const String title = 'GoRouter Example: Named Routes';
Widget build(BuildContext context) => ChangeNotifierProvider<LoginInfo>.value(
value: loginInfo,
child: MaterialApp.router(
routerConfig: _router,
title: title,
debugShowCheckedModeBanner: false,
),
);
late final GoRouter _router = GoRouter(
debugLogDiagnostics: true,
routes: $appRoutes,
// redirect to the login page if the user is not logged in
redirect: (BuildContext context, GoRouterState state) {
final bool loggedIn = loginInfo.loggedIn;
// check just the matchedLocation in case there are query parameters
final String loginLoc = const LoginRoute().location;
final bool goingToLogin = state.matchedLocation == loginLoc;
// the user is not logged in and not headed to /login, they need to login
if (!loggedIn && !goingToLogin) {
return LoginRoute(fromPage: state.matchedLocation).location;
}
// the user is logged in and headed to /login, no need to login again
if (loggedIn && goingToLogin) {
return const HomeRoute().location;
}
// no need to redirect at all
return null;
},
// changes on the listenable will cause the router to refresh it's route
refreshListenable: loginInfo,
);
}
こちらのコードはgo_router
とgo_router_builder
の
exampleコードをそのまま持ってきたものです
go_router_builder
は大元がいきなりChangeNotifierProvider
になってますが
そこはあまり気にしなくて大丈夫です
要は両者ともにMaterialApp.routerのrouterConfigにrouterを渡すことで
設定ができているということがわかれば十分だと思います
なので両者ともに導入時点では特に違いはありません
両者で大きく違う点はGoRouter定義のここの部分です
final GoRouter _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
return const HomeScreen();
},
routes: <RouteBase>[
GoRoute(
path: 'details',
builder: (BuildContext context, GoRouterState state) {
return const DetailsScreen();
},
),
],
),
],
);
late final GoRouter _router = GoRouter(
debugLogDiagnostics: true,
routes: $appRoutes,
go_router
は直接Routeを書いて渡しているのに対して
go_router_builder
がよくわからない$appRoutes
変数を渡しているだけとなっています
では$appRoutes
が何かというと、
これがbuild_runner
により自動生成で作られたルーティングです
ルーティング(画面遷移基盤)の作り方
<HomeRoute>(
path: '/',
routes: <TypedGoRoute<GoRouteData>>[
TypedGoRoute<FamilyRoute>(
path: 'family/:fid',
routes: <TypedGoRoute<GoRouteData>>[
TypedGoRoute<PersonRoute>(
path: 'person/:pid',
routes: <TypedGoRoute<GoRouteData>>[
TypedGoRoute<PersonDetailsRoute>(path: 'details/:details'),
],
),
],
),
TypedGoRoute<FamilyCountRoute>(path: 'family-count/:count'),
],
)
class HomeRoute extends GoRouteData {
const HomeRoute();
Widget build(BuildContext context, GoRouterState state) => const HomeScreen();
}
<LoginRoute>(
path: '/login',
)
class LoginRoute extends GoRouteData {
const LoginRoute({this.fromPage});
final String? fromPage;
Widget build(BuildContext context, GoRouterState state) =>
LoginScreen(from: fromPage);
}
class FamilyRoute extends GoRouteData {
const FamilyRoute(this.fid);
final String fid;
Widget build(BuildContext context, GoRouterState state) =>
FamilyScreen(family: familyById(fid));
}
class PersonRoute extends GoRouteData {
const PersonRoute(this.fid, this.pid);
final String fid;
final int pid;
Widget build(BuildContext context, GoRouterState state) {
final Family family = familyById(fid);
final Person person = family.person(pid);
return PersonScreen(family: family, person: person);
}
}
class PersonDetailsRoute extends GoRouteData {
const PersonDetailsRoute(this.fid, this.pid, this.details, {this.$extra});
final String fid;
final int pid;
final PersonDetails details;
final int? $extra;
Page<void> buildPage(BuildContext context, GoRouterState state) {
final Family family = familyById(fid);
final Person person = family.person(pid);
return MaterialPage<Object>(
fullscreenDialog: true,
key: state.pageKey,
child: PersonDetailsPage(
family: family,
person: person,
detailsKey: details,
extra: $extra,
),
);
}
}
class FamilyCountRoute extends GoRouteData {
const FamilyCountRoute(this.count);
final int count;
Widget build(BuildContext context, GoRouterState state) => FamilyCountScreen(
count: count,
);
}
こちらもgo_router_builder
のexampleから抜粋したコードです
ルーティングの基本は
<HogeRoute>
という形で宣言することで作ることができます
exampleの方はどうなっているのかというと
<HomeRoute>(
path: '/',
という形で宣言されており、
パスにpath: '/'
が指定されているのでイニシャルルートということがわかります
では画面はどこにあるのかというと@TypedGoRoute<HomeRoute>
で型宣言されている
HomeRouteが画面側に本体になります
class HomeRoute extends GoRouteData {
const HomeRoute();
Widget build(BuildContext context, GoRouterState state) => const HomeScreen();
}
HomeRouteを見ると非常にわかりやすく、
GoRouteDataを継承し、Widget buildをoverrideして
HomeScreenを返しているという形です
ではここから画面遷移はどのように定義するのかというと、
<HomeRoute>(
path: '/',
routes: <TypedGoRoute<GoRouteData>>[
TypedGoRoute<FamilyRoute>(
path: 'family/:fid',
routes: <TypedGoRoute<GoRouteData>>[
TypedGoRoute<PersonRoute>(
path: 'person/:pid',
routes: <TypedGoRoute<GoRouteData>>[
TypedGoRoute<PersonDetailsRoute>(path: 'details/:details'),
],
),
],
),
TypedGoRoute<FamilyCountRoute>(path: 'family-count/:count'),
],
)
こちらのroutesの中でルーティング宣言をすることで画面遷移を構築することができます
ネストしたルーティングを作りたい場合は
routes: <TypedGoRoute<GoRouteData>>[
TypedGoRoute<FamilyRoute>(
path: 'family/:fid',
routes: <TypedGoRoute<GoRouteData>>[
TypedGoRoute<PersonRoute>(
path: 'person/:pid',
routes: <TypedGoRoute<GoRouteData>>[
TypedGoRoute<PersonDetailsRoute>(path: 'details/:details'),
],
),
],
),
TypedGoRoute宣言したroutesの中で更にTypedGoRoute宣言することで
作ることができるということですね
ここまで理解できると上記のexampleコードが非常に見やすくなったと思います
- イニシャルルートがHomeRoute
- HomeRouteはFamilyRouteとFamilyCountRouteの画面遷移を並列に持っている
- FamilyRouteはPersonRoute->PersonDetailsRouteといったネストされたルーティングを持っている
という感じですね
画面遷移方法
まずはexampleのコードを見てみましょう
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
Widget build(BuildContext context) {
final LoginInfo info = context.read<LoginInfo>();
return Scaffold(
appBar: AppBar(
title: const Text(App.title),
centerTitle: true,
actions: <Widget>[
PopupMenuButton<String>(
itemBuilder: (BuildContext context) {
return <PopupMenuItem<String>>[
PopupMenuItem<String>(
value: '1',
child: const Text('Push w/o return value'),
onTap: () => const PersonRoute('f1', 1).push(context),
),
PopupMenuItem<String>(
value: '2',
child: const Text('Push w/ return value'),
onTap: () async {
unawaited(FamilyCountRoute(familyData.length)
.push<int>(context)
.then((int? value) {
if (value != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Age was: $value'),
),
);
}
}));
},
),
PopupMenuItem<String>(
value: '3',
child: Text('Logout: ${info.userName}'),
onTap: () => info.logout(),
),
];
},
),
],
),
body: ListView(
children: <Widget>[
for (final Family f in familyData)
ListTile(
title: Text(f.name),
onTap: () => FamilyRoute(f.id).go(context),
)
],
),
);
}
}
class PersonRoute extends GoRouteData {
const PersonRoute(this.fid, this.pid);
final String fid;
final int pid;
Widget build(BuildContext context, GoRouterState state) {
final Family family = familyById(fid);
final Person person = family.person(pid);
return PersonScreen(family: family, person: person);
}
}
これのどこで画面遷移をしているんだというとここです
onTap: () => const PersonRoute('f1', 1).push(context),
onTap: () => FamilyRoute(f.id).go(context),
画面遷移方法は作ったルートに対してpushまたはgoすることで
画面遷移ができるということですね
goとpush何が違うの?
何が違うの?というお題にしたものの、
これはgo_router_builderの話というよりgo_router側の話に近いです
理由としてはgo_router_builderで作ったルートからgoやpushするイベントは
router.g.dartで自動生成され、
router.g.dartで使っているgoやpushは
pub.dev/go_router-10.1.1/lib/src/misc/extensions.dart
こちらを参照しているので大元はgo_routerのイベントということです
一応おさらいとして何がどう違うかというと、
/// Dart extension to add navigation function to a BuildContext object, e.g.
/// context.go('/');
extension GoRouterHelper on BuildContext {
/// Get a location from route name and parameters.
///
/// This method can't be called during redirects.
String namedLocation(
String name, {
Map<String, String> pathParameters = const <String, String>{},
Map<String, dynamic> queryParameters = const <String, dynamic>{},
}) =>
GoRouter.of(this).namedLocation(name,
pathParameters: pathParameters, queryParameters: queryParameters);
/// Navigate to a location.
void go(String location, {Object? extra}) =>
GoRouter.of(this).go(location, extra: extra);
/// Navigate to a named route.
void goNamed(
String name, {
Map<String, String> pathParameters = const <String, String>{},
Map<String, dynamic> queryParameters = const <String, dynamic>{},
Object? extra,
}) =>
GoRouter.of(this).goNamed(
name,
pathParameters: pathParameters,
queryParameters: queryParameters,
extra: extra,
);
/// Push a location onto the page stack.
///
/// See also:
/// * [pushReplacement] which replaces the top-most page of the page stack and
/// always uses a new page key.
/// * [replace] which replaces the top-most page of the page stack but treats
/// it as the same page. The page key will be reused. This will preserve the
/// state and not run any page animation.
Future<T?> push<T extends Object?>(String location, {Object? extra}) =>
GoRouter.of(this).push<T>(location, extra: extra);
/// Navigate to a named route onto the page stack.
Future<T?> pushNamed<T extends Object?>(
String name, {
Map<String, String> pathParameters = const <String, String>{},
Map<String, dynamic> queryParameters = const <String, dynamic>{},
Object? extra,
}) =>
GoRouter.of(this).pushNamed<T>(
name,
pathParameters: pathParameters,
queryParameters: queryParameters,
extra: extra,
);
/// Returns `true` if there is more than 1 page on the stack.
bool canPop() => GoRouter.of(this).canPop();
/// Pop the top page off the Navigator's page stack by calling
/// [Navigator.pop].
void pop<T extends Object?>([T? result]) => GoRouter.of(this).pop(result);
/// Replaces the top-most page of the page stack with the given URL location
/// w/ optional query parameters, e.g. `/family/f2/person/p1?color=blue`.
///
/// See also:
/// * [go] which navigates to the location.
/// * [push] which pushes the given location onto the page stack.
/// * [replace] which replaces the top-most page of the page stack but treats
/// it as the same page. The page key will be reused. This will preserve the
/// state and not run any page animation.
void pushReplacement(String location, {Object? extra}) =>
GoRouter.of(this).pushReplacement(location, extra: extra);
/// Replaces the top-most page of the page stack with the named route w/
/// optional parameters, e.g. `name='person', pathParameters={'fid': 'f2', 'pid':
/// 'p1'}`.
///
/// See also:
/// * [goNamed] which navigates a named route.
/// * [pushNamed] which pushes a named route onto the page stack.
void pushReplacementNamed(
String name, {
Map<String, String> pathParameters = const <String, String>{},
Map<String, dynamic> queryParameters = const <String, dynamic>{},
Object? extra,
}) =>
GoRouter.of(this).pushReplacementNamed(
name,
pathParameters: pathParameters,
queryParameters: queryParameters,
extra: extra,
);
/// Replaces the top-most page of the page stack with the given one but treats
/// it as the same page.
///
/// The page key will be reused. This will preserve the state and not run any
/// page animation.
///
/// See also:
/// * [push] which pushes the given location onto the page stack.
/// * [pushReplacement] which replaces the top-most page of the page stack but
/// always uses a new page key.
void replace(String location, {Object? extra}) =>
GoRouter.of(this).replace(location, extra: extra);
/// Replaces the top-most page with the named route and optional parameters,
/// preserving the page key.
///
/// This will preserve the state and not run any page animation. Optional
/// parameters can be provided to the named route, e.g. `name='person',
/// pathParameters={'fid': 'f2', 'pid': 'p1'}`.
///
/// See also:
/// * [pushNamed] which pushes the given location onto the page stack.
/// * [pushReplacementNamed] which replaces the top-most page of the page
/// stack but always uses a new page key.
void replaceNamed(
String name, {
Map<String, String> pathParameters = const <String, String>{},
Map<String, dynamic> queryParameters = const <String, dynamic>{},
Object? extra,
}) =>
GoRouter.of(this).replaceNamed(name,
pathParameters: pathParameters,
queryParameters: queryParameters,
extra: extra);
}
こちらはextensions.dartのまるまる引用です
今回はgo,push,replaceの翻訳だけ載せます
/// * [go] which navigates to the location.
/// * [push] which pushes the given location onto the page stack.
/// * [replace] which replaces the top-most page of the page stack but treats
/// it as the same page. The page key will be reused. This will preserve the
/// state and not run any page animation.
/// * [go] は、その場所に移動します。
/// * [push] は、指定された場所をページ スタックにプッシュします。
/// * [replace] ページ スタックの最上位ページを置換しますが、
/// 同じページとして扱われます。ページキーは再利用されます。これにより、
/// 状態を維持し、ページ アニメーションを実行しません。
ざっくりいうと
- goは指定したルートに移動
- pushは指定したルートを強制的に最上位にスタックさせる
- replaceはスタックの最上位画面を置換させる(同じ画面として扱うので遷移アニメーションも発生しない)
という感じですね
goとpushの更に詳しい話
解説についてはアンドレアさんがいい記事を書いているので紹介します
ざっくり説明すると
- pushは強制的に最上位にスタックさせるのでDeepLinkが壊れる恐れがある
- 基本的にgoで統一した方がDeepLinkが壊れないのでいいよ
ということと解釈してます
この辺は実際にバグを踏んだ事がないので正直よくわかってないです
今後経験したらまた記事を更新するかもしれません
(go_router自信ニキがいたらコメントで教えてください🙇)
値渡し関連
// 画面遷移アクション
onTap: () => const PersonRoute('f1', 1).push(context),
// ルート定義
class PersonRoute extends GoRouteData {
const PersonRoute(this.fid, this.pid);
final String fid;
final int pid;
Widget build(BuildContext context, GoRouterState state) {
final Family family = familyById(fid);
final Person person = family.person(pid);
return PersonScreen(family: family, person: person);
}
}
// ルート基盤
TypedGoRoute<PersonRoute>(
path: 'person/:pid',
routes: <TypedGoRoute<GoRouteData>>[
TypedGoRoute<PersonDetailsRoute>(path: 'details/:details'),
],
),
こちらはexampleコードから抜粋したものです
画面遷移する際にPersonRoute('f1', 1).push(context)
となっており、
PersonRoute側で引数の設定
TypedGoRoute<PersonRoute>
のパスで
path: 'person/:pid',
という形で:hoge
としてクエリパラメータの設定がされています
PersonRouteで明確に型定義がされているのでタイプセーフに画面遷移ができるという事ですね
値渡し関連の詳しい解説はすささんの記事がわかりやすいのでおすすめです
ボトムナビゲーションの作り方
ボトムナビゲーションについてはpuv.devのexampleにはないものの
公式リポジトリのサンプルコードの中にサンプルがあるのでそちらを引用します
class App extends StatelessWidget {
App({super.key});
Widget build(BuildContext context) => MaterialApp.router(
routerConfig: _router,
);
final GoRouter _router = GoRouter(
routes: $appRoutes,
initialLocation: '/foo',
);
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('foo')),
);
}
<MyShellRouteData>(
routes: <TypedRoute<RouteData>>[
TypedGoRoute<FooRouteData>(path: '/foo'),
TypedGoRoute<BarRouteData>(path: '/bar'),
],
)
class MyShellRouteData extends ShellRouteData {
const MyShellRouteData();
Widget builder(
BuildContext context,
GoRouterState state,
Widget navigator,
) {
return MyShellRouteScreen(child: navigator);
}
}
class FooRouteData extends GoRouteData {
const FooRouteData();
Widget build(BuildContext context, GoRouterState state) {
return const FooScreen();
}
}
class BarRouteData extends GoRouteData {
const BarRouteData();
Widget build(BuildContext context, GoRouterState state) {
return const BarScreen();
}
}
class MyShellRouteScreen extends StatelessWidget {
const MyShellRouteScreen({required this.child, super.key});
final Widget child;
int getCurrentIndex(BuildContext context) {
final String location = GoRouterState.of(context).uri.toString();
if (location == '/bar') {
return 1;
}
return 0;
}
Widget build(BuildContext context) {
final int currentIndex = getCurrentIndex(context);
return Scaffold(
body: child,
bottomNavigationBar: BottomNavigationBar(
currentIndex: currentIndex,
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Foo',
),
BottomNavigationBarItem(
icon: Icon(Icons.business),
label: 'Bar',
),
],
onTap: (int index) {
switch (index) {
case 0:
const FooRouteData().go(context);
break;
case 1:
const BarRouteData().go(context);
break;
}
},
),
);
}
}
class FooScreen extends StatelessWidget {
const FooScreen({super.key});
Widget build(BuildContext context) {
return const Text('Foo');
}
}
class BarScreen extends StatelessWidget {
const BarScreen({super.key});
Widget build(BuildContext context) {
return const Text('Bar');
}
}
<LoginRoute>(path: '/login')
class LoginRoute extends GoRouteData {
const LoginRoute();
Widget build(BuildContext context, GoRouterState state) =>
const LoginScreen();
}
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
Widget build(BuildContext context) {
return const Text('Login');
}
}
routerの設定は同じなのでどこが変わるかというとルート基盤の構築方法が変わります
<MyShellRouteData>(
routes: <TypedRoute<RouteData>>[
TypedGoRoute<FooRouteData>(path: '/foo'),
TypedGoRoute<BarRouteData>(path: '/bar'),
],
)
class MyShellRouteData extends ShellRouteData {
const MyShellRouteData();
Widget builder(
BuildContext context,
GoRouterState state,
Widget navigator,
) {
return MyShellRouteScreen(child: navigator);
}
}
class FooRouteData extends GoRouteData {
const FooRouteData();
Widget build(BuildContext context, GoRouterState state) {
return const FooScreen();
}
}
class BarRouteData extends GoRouteData {
const BarRouteData();
Widget build(BuildContext context, GoRouterState state) {
return const BarScreen();
}
}
@TypedGoRoute<HogeRoute>
していたものが
@TypedShellRoute<HogeRouteData>
という形に変わってます
そしてTypedShellRouteがroutesとして
routes: <TypedRoute<RouteData>>[
TypedGoRoute<FooRouteData>(path: '/foo'),
TypedGoRoute<BarRouteData>(path: '/bar'),
],
を持っているという形になっていますね
一番大きく違う点は親のHogeRouteDataがパスを持っていません
そしてイニシャルルートとして設定されているものは
final GoRouter _router = GoRouter(
routes: $appRoutes,
initialLocation: '/foo',
);
となっており、
子のFooRouteDataがイニシャルルートになっています
どういうことかというと親のHogeRouteDataはただの箱になっていて、
ルートとしては子が本体になっているという形になっています
画面本体を見ると非常にわかりやすいです
class MyShellRouteData extends ShellRouteData {
const MyShellRouteData();
Widget builder(
BuildContext context,
GoRouterState state,
Widget navigator,
) {
return MyShellRouteScreen(child: navigator);
}
}
class MyShellRouteScreen extends StatelessWidget {
const MyShellRouteScreen({required this.child, super.key});
final Widget child;
int getCurrentIndex(BuildContext context) {
final String location = GoRouterState.of(context).uri.toString();
if (location == '/bar') {
return 1;
}
return 0;
}
Widget build(BuildContext context) {
final int currentIndex = getCurrentIndex(context);
return Scaffold(
body: child,
bottomNavigationBar: BottomNavigationBar(
currentIndex: currentIndex,
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Foo',
),
BottomNavigationBarItem(
icon: Icon(Icons.business),
label: 'Bar',
),
],
onTap: (int index) {
switch (index) {
case 0:
const FooRouteData().go(context);
break;
case 1:
const BarRouteData().go(context);
break;
}
},
),
);
}
}
MyShellRouteData extends ShellRouteDataは
Widgetとしてnavigatorを返しており
navigatorをMyShellRouteScreenが受け取っています
MyShellRouteScreen側は受け取ったnavigatorをbodyに渡し、
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Foo',
),
BottomNavigationBarItem(
icon: Icon(Icons.business),
label: 'Bar',
),
],
でボトムナビゲーションのUIを設定してタップアクションを
onTap: (int index) {
switch (index) {
case 0:
const FooRouteData().go(context);
break;
case 1:
const BarRouteData().go(context);
break;
}
},
swich文で設定しているという感じですね
StatefulShellRouteなるものがあるらしいけどそれはなんなの?
はい、
StatefulShellRoute
なんですけどこれもボトムナビゲーションの一つです
こちらが公式のサンプルコードです
じゃあ何が違うんだい!って話だと思うんですけど詳しくわかってません💦
私自身使っているのはStatefulShellRoute
の方で
記事書くにあたってShellRoute
の方を知ったので
ShellRoute
の方が実際あんまよくわかってません🙇
恐らくなんですがShellRoute
の方が静的にしかタブの状態を持てず、
StatefulShellRoute
を使うと動的に各タブの状態を保持できるということだと思ってます
あとはスイッチしなくても簡潔にボトムナビゲーションの画面遷移をかけるとか
そういうメリットもあります(この辺はStatefulで状態を監視している恩恵だと思います)
この辺も詳しい人いたらここも是非コメント欄で教えてもらえると助かります😅
また、
StatefulShellRoute
を使うとブランチのネストが深くなって見通しが悪くなるのですが
そちらを分ける方法があるので記事を貼っておきます
こちらコメント欄に私のコメントがあるのですが
現行のバージョンだとpartしなくても分ける事ができるはずです
ダメだったら記事の方法を参考にしてみてください
まとめ
あれこれつらつら書きましたがgo_router_builderは
記述量を減らせて尚且つ値渡し画面遷移をタイプセーフに行えるので
とてもいいぞ!!!!!!という感じです
地味ですがflutter.devが管理してるパッケージなので
よくわからん3rdパーティじゃないから安心感があるというのも大きいですね
アルサーガパートナーズ株式会社のエンジニアによるテックブログです。11月14(木)20時〜 エンジニア向けセミナーを開催!詳細とご応募は👉️ arsaga.jp/news/pressrelease-cheer-up-project-november-20241114/
Discussion