ジャンプTOON アプリチームの國師です。
5 月にサービスを開始した 「ジャンプTOON」 は、Flutter を採用し Android, iOS, iPadOS 向けのアプリを提供しています。
本記事では、ジャンプTOON モバイルアプリの開発で採用している技術スタックやプロジェクト構成、開発手法を紹介します。
目次
- SDK・ツール管理
- プロジェクト管理・タスクランナー
- CI・CD
- ディレクトリ構成
- テーマ管理
- ルーティング
- アセット管理
- 状態管理
- サーバ通信
- Lint
- テスト
- UI カタログ
- Web Preview
- PDR
——
SDK・ツール管理
Flutter の SDK バージョン管理には、Flutter 以外の SDK やツールもまとめて管理できる asdf を採用しています。
Flutter の開発者界隈では FVM も人気ですが、次の点から、アプリチームに限らず開発チーム全体で asdf によるバージョン管理に統一しています。
- Flutter 以外の SDK やツールのバージョン管理を統合的に行える
- 管理対象の SDK やツールごとに plugin が独立しており作成も容易
- .node-version といったより一般的なバージョン定義ファイルにも対応できる
- リモートマシンとローカルマシンでのバージョンの統一が容易
個人的には asdf に比べてバージョン切り替えが高速とされる mise に注目しており、チームへの導入も検討中です。
また、チーム開発で使用する多くのツールは npm レジストリで公開されているため、package manager として bun を使用しています。bun は、npm package との互換性が高く、実行速度が速いという利点があります。
ここで、私たちの Flutter プロジェクトで活用している主な package を紹介します。
- commitlint : コミットメッセージの静的解析
- lefthook : git hook 管理
- prettier : Dart コード以外の整形
- reg-suit : Visual Regression Test ツール
- scaffdog: テンプレート生成
- secretlint : 秘匿情報の静的解析
- svgo : SVG ファイルの最適化
これらの SDK やツールのバージョン更新は自動化しており、renovate を活用しています。
プロジェクト管理・タスクランナー
私たちの Flutter プロジェクトは、app と vrt_snapshot の 2 つの package で構成されています。
package を分けることで参照に制約を設けることができます。しかし、プログラムの責務の階層を浅く保つアプローチをとっているため、package の分割は最小限に留めています。
これら複数の package 管理には melos を使用しています。melos はタスクランナーとしても機能し、ローカル・リモート問わず melos 経由でタスクを実行できます。
melos で用意しているタスク一覧はこちら
❯ melos run
Select a script to run in this workspace:
1) prepare:tools
└> Install tools for development
2) prepare:signings
└> Set up signing for build
3) bump:build
└> Bump build version and number
4) bump:patch
└> Bump patch version
5) bump:minor
└> Bump minor version
6) bump:major
└> Bump major version
7) get
└> Pub get
8) outdated
└> Pub outdated
9) upgrade
└> Pub upgrade
10) pod
└> Pod install with repository update
11) refresh
└> clean & bootstrap
12) gen:build_runner
└> Generate files by build_runner
13) gen:l10n
└> Generate files by flutter_localizations
14) gen:xcodeproj
└> Generate .xcodeproj for iOS by XcodeGen
15) gen
└> Generate all files
16) clean:build_runner
└> clean generated files by build_runner
17) clean:l10n
└> clean generated files by flutter gen-l10
18) clean:xcodeproj
└> Clean Xcode project
19) clean:pod
└> Clean CocoaPods lockfile and artifacts
20) clean:gen
└> Clean all generated files
21) watch
└> Generate files by build_runner dynamically
22) check
└> Analyze with Dart SDK and custom_lint
23) check:strict
└> Analyze with Dart SDK and custom_lint (info and warning as error)
24) check:custom_lint
└> Analyze with custom_lint
25) fix
└> Quick fix with Dart SDK
26) fix:dry
└> Quick fix with Dart SDK (dry run)
27) fix:custom_lint
└> Quick fix with custom_lint
28) pretty
└> Format Dart and Arb files
29) pretty:dry
└> Format All Dart files (dry run)
30) pretty:dart
└> Format All Dart files
31) pretty:arb
└> Sort arb files with provided paths
32) pretty:arb:all
└> Sort all arb files
33) icons
└> Generate app icons
34) splash
└> Generate native splash screen
35) build:all
└> Build prd Android, iOS and Web
36) build:android
└> Build prd Android (AAB)
37) build:android:dev
└> Build dev Android (AAB)
38) build:android:dev:apk
└> Build dev Android (APK)
39) build:android:stg
└> Build stg Android (AAB)
40) build:android:stg:apk
└> Build stg Android (APK)
41) build:android:prd
└> Build prd Android (AAB)
42) build:android:prd:apk
└> Build prd Android (APK)
43) build:android:e2e
└> Build dev Android (APK) for Maestro
44) build:android:all
└> Build all flavors Android (AAB)
45) build:ios
└> Build prd iOS
46) build:ios:dev
└> Build dev iOS
47) build:ios:dev:ipa
└> Build dev iOS (IPA)
48) build:ios:stg
└> Build stg iOS
49) build:ios:stg:ipa
└> Build stg iOS (IPA)
50) build:ios:prd
└> Build prd iOS
51) build:ios:prd:ipa
└> Build prd iOS (IPA)
52) build:ios:e2e
└> Build dev iOS for Maestro
53) build:ios:all
└> Build all flavors iOS
54) build:web
└> Build Web preview
55) build:widgetbook
└> Build Widgetbook
56) run:preview
└> Run Web preview
57) run:widgetbook
└> Run Widgetbook
58) test:prepare
└> Copy dart test files to execute test
59) test:prepare:coverage
└> Move dart test files to measure test coverage
60) test
└> Test Flutter
61) test:coverage
└> Test Flutter with coverage
62) test:vrt
└> Take VRT snapshots
63) e2e
└> Run E2E tests
64) e2e:prepare
└> Copy all e2e test files to .maestro-flows/
65) deeplink:android
└> launch android app with provided deeplink URL
66) deeplink:ios
└> launch ios app with provided deeplink URL
67) export:arb
└> export arb to csv
68) update:arb
└> update arb
69) badging:check
└> Check Android Badging
70) badging:update
└> Update Android Badging
melos は 3.2.0 から各 package の依存を中央集権的に管理できるようになりました。しかし、renovate 側の対応が進んでいないため、採用を見送っています。最近、Dart の workspace 対応が進められているため、そちらに期待しています。
Implement workspaces [pub client] · Issue #4127 · dart-lang/pub · GitHub
Flutter で Android や iOS 向けのアプリを開発する場合、それぞれのネイティブプロジェクトを含める必要があります。しかし、ネイティブプロジェクトは flavor や build type が増えるとビルド構成が複雑になりやすいという課題があります。特に iOS プロジェクトの .pbxproj や .xcscheme ファイルは複雑になりやすく、レビュー時の可読性に課題がありました。
そこで、iOS プロジェクトでは、XcodeGen を導入し、プロジェクト構成を YAML ファイルで管理することで可読性やメンテナンス性を向上させています。
最近ではプロジェクト管理にも Swift Package Manager (SwiftPM) を利用するプロジェクトも多いようですが、Flutter は SDK 側の SwiftPM 対応が現在進行形で進められている段階であることから、現時点では導入を見送っています。
CI・CD
CI・CD には GitHub Actions を採用しています。Organization 全体で共通のワークフローを管理するために、Repository Rulesets や Reusing Workflows を活用しています。
これらに加えて、アプリチームの Repository では、Issue Ops を採用しています。
Issue Ops を使うことで、PR や commit 単位でワークフローを選択・実行できるため、GitHub Actions のコスト削減にもつながります。
Issue Ops は GitHub チームも活用しており、専門の Organization やドキュメントもできてきているようです。Issue Ops のコマンド管理には github/command Action がおすすめです。
Enabling branch deployments through IssueOps with GitHub Actions – The GitHub Blog
ディレクトリ構成
Dart ファイルのほとんどは app package 内に存在し、app package は主に 8 つのディレクトリで構成されています。
lib/
├── data/ # データソース関連
├── foundation/ # アプリを横断して利用され得る基盤機能
├── gen/ # 自動生成関連
├── l10n/ # ローカリゼーション関連
├── route/ # ルーティング関連
├── state/ # グローバルな状態管理関連
├── ui/ # UI 関連
├── use_case/ # main (Widget 外) と UI (Widget 内) で共通利用するビジネスロジック
└── main.dart
最も大きなディレクトリが ui/
ディレクトリで、この配下は次のルールでサブディレクトリが構成されています。
- ディレクトリはルーティングのスタック構造に合わせる
- 関連するファイルを近くで管理する (colocation)
どういうことか、実際のディレクトリ構造の一部を使って説明します。
ui/
ディレクトリ配下の全体感はこちら
ui/
├── component/
│ ├── button/
│ │ ├── share_button.dart
│ │ └── share_button_vrt.dart
│ └── text/
├── hook/
│ ├── use_debounce.dart
│ └── use_debounce_test.dart
├── screen/
│ ├── root/
│ │ ├── component/
│ │ ├── hook/
│ │ ├── home/
│ │ │ ├── component/
│ │ │ ├── hook/
│ │ │ ├── home_query.graphql
│ │ │ ├── home_query.graphql.dart
│ │ │ ├── home_screen_e2e.yaml
│ │ │ ├── home_screen.dart
│ │ │ ├── home_screen.g.dart
│ │ │ └── home_screen_vrt.dart
│ │ ├── my_page/
│ │ │ ├── component/
│ │ │ ├── hook/
│ │ │ ├── setting/
│ │ │ └── my_page_screen.dart
│ │ └── root_screen.dart
│ └── series/
│ ├── component/
│ ├── hook/
│ └── series_screen.dart
└── theme
1. ディレクトリはルーティングのスタック構造に合わせる についてですが、例えば、home/
と my_page/
は同じボトムナビゲーションに属するため、root/
配下に配置しています。
my_page/
に setting/
を置いています。
さまざまな画面に対してスタックし得る作品詳細画面などは
root/
と同階層に配置しています。
2. 関連するファイルを近くで管理する (colocation) についてですが、自動生成ファイルや GraphQL のスキーマファイル、テストファイルを同じ箇所に配置します。例えば、 use_debounce.dart
とそのテストファイルである use_debounce_test.dart
は同じディレクトリに配置します。
また、Widget や hooks ファイルは、各画面のディレクトリに component/
と hook/
を用意し、そこに配置しています。複数の画面で利用し得る Widget や hooks ファイルは、共通の親ディレククトリに存在する component/
と hook/
に置いています。
テーマ管理
Flutter SDK には Android, iOS それぞれのデザインシステムに沿った標準的なテーマ (Material Theme, Cupertino Theme) とその API が用意されています。これらの API を利用してアプリを構築することで、ガイドラインに沿った UI をつくることができます。
その一方で、各デザインシステムの制約を受けるため、より柔軟な UI の構築が難しいケースが出てきます。また、私たちのサービスは Web サービスも提供しているため、プラットフォーム別にデザインシステムを用意する運用負荷も懸念にありました。
そこで、各プラットフォームで一貫した見た目を提供できる独自のデザインシステムを構築し、アプリ内のテーマ管理もそれに沿って行うことになりました。
AppTheme
クラスで管理しています。AppTheme
から ThemeData
を生成することで、Flutter 標準 Widget に対するグローバルなテーマを設定しています。
さらに、AppText (TextStyle)
と AppColor (Color)
を ThemeExtension
として提供することで、グローバルテーマを継承したスタイルを設定したい、カスタム Widget にデザインシステムを適用したい、といったより柔軟なテーマ設定を可能にしています。
ThemeExtension class – material library – Dart API
また、MaterialApp#themeMode
が変わるとThemeData が更新され、 ThemeExtension
経由で参照している AppText (TextStyle)
と AppColor (Color)
が自動的に切り替わるため、ライト・ダークテーマの反映が簡単に行えます。
テーマ管理のコード例はこちら
class MyApp extends HookConsumerWidget {
const MyApp({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp.router(
theme: AppTheme.light.data,
darkTheme: AppTheme.dark.data,
themeMode: ThemeMode.light, // 今はライト固定
// ...
);
}
}
class AppTheme {
const AppTheme._({required this.data});
factory AppTheme._light() {
return AppTheme.lightInternal();
}
@visibleForTesting
factory AppTheme.lightInternal({String? systemFontFamily}) {
final appColor = AppColor.light();
final appText = AppText.light(
appColor,
systemFontFamily: systemFontFamily,
);
final themeData = ThemeData(
useMaterial3: true,
brightness: Brightness.light,
).applyGlobalTheme(appColor, appText).applyPlatformTheme();
return AppTheme._(data: themeData);
}
factory AppTheme._dark() {
// ...
}
// ライトテーマ向け
static AppTheme light = AppTheme._light();
// ダークテーマ向け
static AppTheme dark = AppTheme._dark();
final ThemeData data;
}
extension ThemeDataExt on ThemeData {
ThemeData applyGlobalTheme(AppColor appColor, AppText appText) {
return copyWith(
// 標準 Widget に対するグローバルテーマの設定
appBarTheme: _appBarTheme(
appColor,
appText,
),
// ...
// AppColor や AppText を各 Widget から参照するための ThemeExtentions
extensions: _themeExtensions(
appColor,
appText,
),
);
}
static AppBarTheme _appBarTheme(
AppColor appColor,
AppText appText,
) {
return AppBarTheme(
backgroundColor: appColor.surface.basePrimaryDefault,
titleTextStyle: appText.label.p18.bold,
iconTheme: IconThemeData(color: appColor.text.basePrimaryDefault),
centerTitle: true,
scrolledUnderElevation: 0,
);
}
static Iterable<ThemeExtension<dynamic>> _themeExtensions(
AppColor appColor,
AppText appText,
) =>
[
LabelTextStyleThemeExt(
p10: appText.label.p10,
//...
),
// ...
TextColorThemeExt(
basePrimaryDefault: appColor.text.basePrimaryDefault,
//...
),
// ...
];
}
ルーティング
ルーティング処理には auto_route を採用しています。これにより、型安全なルーティング処理、マルチバックスタック対応、ディープリンク処理の簡略化などが実現できます。
マルチバックスタックに関しては、少し前に go_router でも対応されましたが、選定当時は未対応であったため、ここの優位性から auto_route を採用しました。
アセット管理
静的アセットには flutter_gen を採用し、型安全な参照を可能にしています。
動的アセット (主に画像) には、extended_image を採用しています。類似の package として cached_network_image も有名ですが、extended_image のキャッシュ機構は Flutter の標準 Widget である Image
と同じ仕組みで構築されているためメモリ空間を共通で管理しやすく、こちらを採用しました。
画像ファイルに関する事柄として、端末サイズに合わせた画像の最適化と画像リクエストのキャッシュヒット率の向上があります。各端末で十分な画質を維持しつつ、キャッシュヒット率を上げるために、画像のリクエストサイズを近似させる仕組みを入れています。
このロジックは、静的アセットを読み込む際に利用される AssetImage
の仕組みを参考にしました。
AssetImage
がどのフォルダの画像を利用するかのロジックは下記の通りです。
- devicePixelRatio が 2 よりも小さい場合は、倍率の高い画像ファイルを参照する
- devicePixelRatio が 2 以上の場合は、 devicePixelRatio により近い倍率の画像ファイルを参照する
assets/
└── images/
├── heart.png
├── 2.0x/
│ └── heart.png
└── 4.0x/
└── heart.png
例えば、アセットファイルのフォルダ構成が上記のようになっている場合だと、下記となります。
- devicePixelRatio が 1.0 の場合、
images/heart.png
- devicePixelRatio が 1.5 の場合、
images/2.0x/heart.png
- devicePixelRatio が 2.5 の場合、
images/2.0x/heart.png
- devicePixelRatio が 3.5 の場合、
images/4.0x/heart.png
より詳しくは AssetImage
のドキュメントを参照してください。
AssetImage class – painting library – Dart API
このロジックを参考に、端末の devicePixelRatio を [0.75, 1.0, 1.5, 2.0, 3.0, 4.0] のいずれかに近似させ、これに対して 50 logical pixel 単位で近似した Widget のサイズ (logical pixel) を乗算することで画像の physical pixel を算出しています。
この近似された physical pixel を画像リクエストに利用することで、画像リクエストのキャッシュヒット率向上を実現しています。
状態管理
状態管理には Riverpod と flutter_hooks を採用しています。
- Riverpod: 複数の画面を跨いで利用される状態や、画面に紐づかない状態 (App State, Global State)
- flutter_hooks: 特定の画面や Widget 内でのみ利用される状態 (Ephemeral State, Local State)
(※ Flutter アプリはひとつの大きな Widget から成るため厳密には画面という概念はありませんが、わかりやすさのために Route
Widget 配下にある Widget のことを画面と呼んでいます)
Riverpod は、状態の生成や更新の際にロジックを持たせたり、任意の状態を返すことができたりするため、テストでのダミーデータの挿入といった場面で DI (依存性注入) としても活用しています。
VRT 向けの Widget でダミーデータを差し込む例はこちら
@Riverpod(dependencies: [])
Options$Query$MyPage myPageQueryOptions(MyPageQueryOptionsRef ref) {
return Options$Query$MyPage(
variables: Variables$Query$MyPage(),
);
}
class MyPageScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final options = ref.watch(myPageQueryOptionsProvider);
final query = useQuery(options);
// Widget 構築
}
}
/// VRT 向けの Widget を生成する関数
@GenerateScenario()
Widget myPageScreenAnonymous(BuildContext context) {
// ...
return ProviderScope(
overrides: [
// ダミーデータを返す
myPageQueryOptionsProvider.overrideWithValue(_dummyAnonymous),
],
child: const FakePlaybookRouter(MyPageRoute()),
);
}
サーバ通信
クライアント・サーバ間の通信には GraphQL を採用しています。これにより、柔軟なリクエスト、スキーマに基づいたレスポンスデータの正規化とキャッシュによる効率的な状態更新を実現しています。
アプリでは、graphql および graphql_flutter を利用しています。
graphql_flutter は useQuery といった hooks を提供しており、Widget からデータのリクエストや、キャッシュデータが更新された際に Widget を rebuild させることができます。
useQuery を使った Widget の例はこちら
class SettingScreen extends HookConsumerWidget {
const SettingScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isGuestUser = useIsGuestUser();
final options = ref.watch(settingQueryOptionsProvider);
final query = useQuery$Setting(options);
return Scaffold(
// ...
body: GraphQLQueryContainer(
query: query,
onLoadingWidget: SkeletonSettingScreen(isGuestUser: isGuestUser),
onErrorWidget: (error, stackTrace) => ErrorContainer(
error: error,
stackTrace: stackTrace,
onAction: query.refetch,
),
child: (data) {
return SingleChildScrollView(
// ...
):
},
),
);
}
}
加えて、Fragment Colocation によりデータを加工したり表示したりする Widget と必要なデータそのものを定義する Fragment を近い場所で管理しています。Widget と GraphQL スキーマをセットで扱うことができるため、Widget にどんなデータが必要か、Query や Mutation の操作でどの Widget が更新されるかが分かりやすくなります。
また、データの加工ロジックも Widget に収めることで、Widget と GraphQL スキーマをセットで再利用しやすくしています。
Fragment Colocation の例はこちら
class UserNameText extends StatelessWidget {
const UserNameText({
required this.data,
this.style,
this.maxLines,
super.key,
});
final Fragment$UserNameText data;
final TextStyle? style;
final int? maxLines;
@override
Widget build(BuildContext context) {
// ゲストユーザにはゲスト表示
final userName = data.isGuest
? context.l10n.common_guest
: dat.userProfile.nickname;
return Text(
userName,
style: style,
maxLines: maxLines,
);
}
}
fragment UserNameText on User {
id
isGuest
userProfile {
nickname
}
}
query MyPage {
me {
...UserNameText
}
}
Lint
Dart の静的解析ツールには、very_good_analysis を採用しています。very_good_analysis は、Lint ルールのカバー範囲が広く、メンテナンス頻度も高いという利点からこれを選択しました。
また、custom_lint を使用して、riverpod_lint や nilts (私がメンテナンスしているので、使っていただけると嬉しいです) などの Lint ルールも適用しています。
Dart の Analyzer Plugin は analysis_options.yaml ファイルにつきひとつという制約がありますが、custom_lint を Analyzer Plugin とすることで、複数 package の Lint ルールを適用したり、プロジェクト内で独自の Lint ルールを作成したりすることができます。
テスト
以下の 3 種類のテストを実施しています。
- flutter_test による Unit Test や Widget Test: 手続的なロジックや hooks のテスト
- playbook と reg-suit による Visual Regression Test: UI の見た目のテスト
- Maestro による E2E Test: アプリ全体の動作テスト
先のディレクトリ構造のところで、 *_test.dart
ファイルを lib/
配下に置いている説明をしましたが、Flutter ではテストコードは test/
に配置しなければ実行できません。そのため、テストは lib/
配下のテストファイルを全て test/
にコピー後、実行しています。テストファイルのコピーとテスト実行は melos コマンドで一括実行できます。
また、Unit Test や Widget Test は Very Good CLI で実行しています。Very Good CLI は、テストファイルをひとつのテストファイルにまとめて実行することで処理時間を短縮します。
Supercharge your Flutter tests with Very Good CLI
Visual Regression Test (VRT) のスナップショット取得には playbook を採用していますが、スナップショット撮影自体は後に紹介する Widgetbook でも行うことは可能です。playbook はスクローラブルな Widget であっても見切れることなく全体のスナップショットを取得できるメリットがあり、こちらを採用しています。
E2E Test で採用している Maestro は YAML ファイルでテストフローを記述できることが大きな特徴です。
ユーザが行うような動作のほとんどをカバーしており、permission の操作やキーチェーン、ローカルストレージの削除も YAML ファイルの記述から操作できます。
Maestro Cloud を利用することで、リモートマシンでの E2E テストを簡単に始めることができます。しかし、リモートマシンにモックサーバを立てたり、端末に Google アカウントを紐づけたりといった操作はできないため、そういったケースでは、GitHub Actions 等で環境を構築する必要があります。
最近、Maestro を開発している mobile.dev が App Quality Copilot という AI を活用した自動テストツールを発表しており、そちらも注目しています。
App Quality Copilot
UI カタログ
VRT で紹介した playbook も UI カタログとしての機能を提供していますが、より高機能な Widgetbook を採用しています。また、Widgetbook は Flutter Web アプリとして作成できるため、自前でホストすることでほとんどの機能を無料で利用できます。
私たちは主に、様々な端末サイズでの Widget の確認やデザイナとの連携で活用しています。
有料プランの Widgetbook Cloud を利用すると、Web アプリのホスティング、コミット間の UI 差分検知、Figma 上のデザインと実際の Widget の比較などの機能が利用できます。
Web Preview
マルチプラットフォーム対応が Flutter の大きな魅力の一つですが、私たちのアプリは Android, iOS 向けだけではなく、Web ブラウザ向けのアプリもメンテナンスしています。
Web ブラウザ向けのアプリを自前でホストしているため、TestFlight や Firebase App Distribution にアーティファクトをアップロードすることなく、Web ブラウザ上でアプリの動作確認が行えます。
加えて、device_preview を組み込むことで、様々な端末サイズを想定した動作確認を可能にしています。
PDR
最後に、私たち独自の取り組みである Product Decision Records (PDR) について紹介します。
PDR とはプロダクト開発に関わるあらゆる意思決定を記録したものです。
プロダクト開発の中で発生する意思決定事項を仕様書や要件書といったドキュメントとして残されるケースはよくあると思います。私たちのチームにも要件書や仕様書が存在します。その一方で、その意思決定がなされた根拠や制約、別の選択肢といったものはあまり残されない傾向にあるという課題感がありました。それらが存在しない場合、その記録は属人化してしまい、メンバーの異動や退職等により容易に失われてしまいます。
PDR は、新規参入者の質問に答え、さらにはチーム共有のパブリックな記憶領域にもなり得ます。また、PDR がチーム文化として浸透することで、より本質的な根拠を持った意思決定が推進されることにも期待しています。
PDR は、Architecture Decision Records (ADR) を参考に、プロダクト開発全体へと拡張したものです。フォーマットは Y-Statements を参考にしています。
PDR のフォーマット
■ 対象 (in the context of)
何に対する意思決定か。
■ 課題・理想状態 (facing)
直面している課題や、課題に対する完全な理想状態は何か。
※ 課題に対する完全な理想状態: 意思決定により達成できる理想状態ではなく、課題が全て撤廃された完全な理想状態。
■ 目的・達成事項 (to achieve)
この意思決定により、完全または部分的に何が達成されるか。
■ 決定事項 (we decided for)
意思決定の内容詳細。
■ 別の選択肢 (and neglected)
選ばれなかった選択肢とそれらの欠点。
■ 受け入れるトレードオフ (accepting downside)
この意思決定により生まれる(可能性のある)ネガティブな副作用。
開発のスタートからこれまで、開発チーム全体で多くの PDR が残され、うまくいっていると感じています。この記事の執筆にも非常に役に立ちました。
——
おわりに
最後までお読みいただき、ありがとうございます。ひとつでも皆さんの Flutter アプリ開発の参考になれば、幸いです。
後日、別のメンバーによる記事も控えていますので、そちらもぜひご期待ください。