振り返ったらFlutterでのアプリ開発のTipsが溜まっているスクラップ
- タイトルの通り
- ゆるく、開発中に気づいたことをスクラップしていきます
- 基本的にはFlutter、時折実装に絡めてFirebaseなどの話が混ざることもあり
CLIでiOS/Androidのビルドをする(Debugビルド)
$ flutter build ios --debug --no-codesign
$ flutter build apk --debug
iOSでDebugするときには --no-codesign
つけないとsigningが走ってそこでコケてしまうことがあるっぽい
Flavorで開発環境管理している場合
$ flutter build ios --debug --flavor development --dart-define=FLAVOR=development --no-codesign
$ flutter build apk --debug --flavor development --dart-define=FLAVOR=development
main.dart
with Flavor, riverpod, Firebase
ぼくのかんがえたさいきょうのimport 'dart:async';
import 'package:enum_to_string/enum_to_string.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/all.dart';
enum Flavor {
development,
production,
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// flavor
final currentFlavor = EnumToString.fromString(
Flavor.values,
const String.fromEnvironment('FLAVOR'),
);
assert(currentFlavor != null);
// Firebase
await Firebase.initializeApp();
// Crashlytics
FlutterError.onError = (details) {
FlutterError.dumpErrorToConsole(details);
FirebaseCrashlytics.instance.recordFlutterError(details);
};
runZonedGuarded(
() => runApp(ProviderScope(child: App())),
FirebaseCrashlytics.instance.recordError,
);
}
- Firebase.initializeAppは非同期で処理が行われるから、
Future<void> main()
に変えるとasync/await
使えて楽 -
--dart-define=FLAVOR=development
でflavor指定していればString.fromEnvironment
でflavorを取得できる -
FlutterError.onError
に直接FirebaseCrashlytics.instance.recordFlutterError
を割り当てるとコンソールにエラーを流せなくなるのでうまくバイパスしてあげる -
runZonedGuarded
に関しては以下の記事を見て囲むことにした - [Flutter x Firebase] Crashlyticsと連携してクラッシュレポートを取得する
- Using Firebase Crashlytics#Zoned Errors
- MaterialApp widgetをラップしたMyApp的なものは別ファイルに移すとmain.dartはスッキリする
将来的に更新する予定のもの
- Firebase Auth EmulatorがFlutterでreadyになったら、諸々のemulatorの初期化処理を、
--dart-define
での指定があったら行う処理を追記する、はず
.gitignore
fvmを使う場合のVSCode設定と新規プロジェクト立てたあと忘れがちなので
.gitignore
# fvm
.fvm/flutter_sdk
# vscode
.vscode/settings.json #if needed
.vscode/settings.json
{
"dart.flutterSdkPaths": [
".fvm/flutter_sdk",
],
}
-
fvm use
を使うと、.fvm/flutter_sdk
にflutterの実体が渡されるので、それをGitの管理下から外しておく。 - 一方、vscodeで参照するflutter SDKを指定するために、
.vscode/settings.json
でdart.flutterSdkPaths
を指定しておく。 - プロジェクトによっては
.vscode/settings.json
はignoreして各個人がカスタマイズできるようにしておく
FlutterGenとPDF,json
- flutter_gen
- pubspec.yamlで管理するassetにアクセスするためのコードが自動生成される。リソース名のハードコーディングを防げる
- R.javaやR.swift、SwiftGenなどと同じ感じ
- brew経由でも、projectのpubspecに書いてpackageとして導入することもできる
- 画像以外にもjsonやPDFもgenerateできる
- jsonやPDFの場合は、flutter_genで生成されるコードを使って、 自分でbundleを使ってロードする必要がある
jsonとPDFのロード
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
// json
final json = await rootBundle.loadString(Assets.json.fruits);
// pdf
final pdf = await rootBundle.load(Assets.pdf.manual);
また、flutter_pdfviewのようにPDFのファイルパスを渡すような場合は、一度bundleからロードしてtmpディレクトリなどに書き込んでから使うと良い
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
final tempDir = await getTemporaryDirectory();
final tempFile = File('${tempDir.path}/copy.pdf');
final pdfData = await rootBundle.load(Assets.pdf.manual);
await tempFile.writeAsBytes(pdfData.buffer.asUint8List(), flush: true);
prnt(tempFile.path); // Get pdf file path
// ...
PDFView(
filePath: pdfFIlePath,
...
)
参考: https://stackoverflow.com/a/54427865
l10nの自動生成(flutter v1.22.0)との兼ね合い
v1.22.0で公式対応されたl10nの自動生成を使い、更にflutter_genをpubspec.yamlで入れるようにすると、パッケージ名とl10nの自動生成ファイルが置かれるファイルパスの名前が衝突してl10nの自動生成ファイルの参照が出来なくなる問題がある。
現状はbrewなどで導入して使うようにする必要がありそう(自分も最初これにハマった)
こんなディスカッションもある
そういえば今は解決済みで、projectのdevDependenciesとして使いたい場合はflutter_gen_runner
をinstallすれば名前衝突しなくなる.
普段がっつりstream周り書くことないけど、 async*
とかyield
出てきたり書く時に躓かないようにメモ
Null-Safety対応と未対応のパッケージが入り交じるときのビルド実行時は --no-sound-null-safety
をつける
$ dart --no-sound-null-safety run
$ flutter run --no-sound-null-safety
Cloud Storageからjsonをダウンロードする
import 'dart:convert';
import 'dart:io';
final response = await FirebaseStorage.instance
.ref()
.child('path/to/data.json')
.getData();
final jsonData = json.decode(utf8.decode(response));
Uint8List response
でjsonのバイナリが得られるので、utf8.decode
でデコードしてString
を得て、それをjson decoderに投げればOK。
String.fromCharCodes()
を使うと、うまく文字列をデコードできない(日本語が文字化けする)ので注意。
あとはCloud Storageで配置するデータに{Content-Type: 'application/json'}
をつけておくと吉。
build_runnerがうまく動かないとき
- 1:
--delete-conflicting-outputs
をつけて実行する
$ flutter pub run build_runner build --delete-conflicting-outputs
- 2:
analyzer
のバージョンを明示的に変更する
Null Safety対応されていないパッケージのimportをする場合は // ignore: import_of_legacy_library_into_null_safe
をつける(analysis_optionで一括disableできると楽なのだけど...)
Null-Safetyとfreezedでの自動生成ファイル
freezedで生成するファイルがまだ完全にnull-safety対応されていないので、生成元となるファイルの先頭に// @dart=2.10
を追加してnull-safety対応を無効にすることで、生成されるファイルにも同様に// @dart=2.10
が追加され、ビルドできるようになる
ProviderListenerとAsyncValue
stateの変更を受けて一時的にsnackbarを出したい、エラーアラートを出したい、画面遷移させたい場合にはProviderListener
を使うのが一番良さそう
// Example from riverpod
Widget build(BuildContext context) {
return ProviderListener<StateController<int>>(
provider: counterProvider,
onChange: (context, counter) {
if (counter.state == 5) {
showDialog(...);
}
},
child: Whatever(),
);
}
一方で、StreamProviderなどである値を監視し続ける場合に状態が変化したタイミングでwidgetを出し分ける場合にはwatch(streamProvider)|useProvider(streamProvider)
で得られるAsyncValue
のwhen/whenData
を使うと良さそう
Widget build(BuildContext context) {
return useProvider(authStreamProvider).whenData((auth) {
if (auth == null) {
return WidgetForNotLoggedInUser();
} else {
return WidgetForLoggedInUser();
}
}
-ref: https://riverpod.dev/docs/concepts/reading#providerlistener
StreamController -> Providerにしたあと、それをProviderListener
でlistenするでも大丈夫そう。
StateControllerやStateNotifier(.state)を使う場合だと、例えばstate.error
が更新されたらアラートを出したい場合に、初回は期待通り動きそうだけど、そのあとstateのリセットせずに同じエラーが発生してstateに書き込んだときにProviderListener.onChange
が動かなさそうな気がしている。
StreamConrollerでエラーを流すようにした場合に、同じエラーが流れてもonChangeが発火するかを確かめる必要がありそう。やってみる。
AppFrameworkInfo.plist
アプリの申請時はAppFrameworkInfo.plistのMinimumOSVersionを確認し、Deployment Targetのバージョンと異なっていたら修正する。
申請時に「Invalid Bundle. The bundle Runner.app/Frameworks/App.framework does not support the minimum OS Version specified in the Info.plist」が出たら、原因はそこにある。
Synchronized
非同期であるvalueをread/writeする時に、排他制御をかけたい場合はsynchronizedパッケージを使うと良さそう。
たとえばStateNotifier使っていて、state
に対して異なる非同期処理の結果を受けてstateを更新したい時に、synchronizedを使えば、片方がもう他方を変に上書きしてしまうことを防げる
final _lock = Lock();
Future<void> updateArticles(List<Article> articles) async {
await _lock.synchronized(() {
state = state.copyWith(
articles: [...state.articles, ...articles].toList(),
);
});
}
modal_bottom_sheet
のCupertinoアニメーション対応
READMEの"CAUTION!: To animate the previous route some changes are needed."に詳細が書かれているものの、
Named Route (routes:
を指定して、pushNamed
などで遷移する)を使わず、MaterialApp.home:
で初期画面指定している場合に、どうやってMaterialWithModalsPageRoute
を付与するのか謎だったが、解決したのでメモを綴っておく。
具体的には初期画面SplashPage
をMaterialApp.home:
に渡して、Firebase Authenticationに問い合わせてログイン状態を確認してその後に表示するWidgetを切り替えているような設計になっていて、ログイン状態に応じた画面切り替えにRouteを使わないため、常に基底WidgetがInitialPage()になる。
結論としては、home:
からonGenerateRoute:
に切り替えるだけでよかった
コード
MaterialApp(
onGenerateRoute: (settings) => MaterialWithModalsPageRoute(
builder: (context) => InitialPage(),
),
);
MaterialApp(
+ onGenerateRoute: (settings) => MaterialWithModalsPageRoute(
+ builder: (context) => InitialPage(),
+ ),
- home: InitialPage()
);
Riverpod 1.0.0対応
ハマった点
v1.0.0-dev10までだと、以下のようにStreamProvider
で流れてくる値をStateProvider
で受け止めて、同期的に値を取得することができていた
final userStreamProvider = StreamProvider<User?>((ref) {
...
})
final userProvider = StateProvider<User?>((ref) {
return ref.watch(userStreamProvider).maybeWhen(
data: (user) => user,
orElse: () => null,
});
======
(使用例)
final user = ref.watch(userProvider)
final postsStreamProvider = StreamProvider<List<Post>>((ref) {
final user = ref.watch(userProvider);
return user != null : repository.listenPosts(userId: user.id) : Stream.empty();
})
これが、v1.0.0 stable版になると、(詳しい原因はわからないけど)オーバーヘッドが発生して、このuserProvider
を使っている箇所や、これを使っている他のProviderを読み込んでいる箇所でアプリ全体の挙動が重くなる。
解決策
StreamProvider => StateProvider
への落とし込みをやめ、 ref.watch(streamProvider).value
を使う。
ref.watch(streamProvider)
でAsyncValue<T>
が返ってきて、v1.0.0では.value
でTの値を取り出せるようになっているため、これを使う。(loadingの場合はnull, errorの場合はそのエラーがthrowされるようになっている)
final user = ref.watch(userStreamProvider).value;
final postsStreamProvider = StreamProvider<List<Post>>((ref) {
final user = ref.watch(userStreamProvider).value;
return user != null : repository.listenPosts(userId: user.id) : Stream.empty();
})
ProviderFamily
ではなくProviderScope+overrideを使う
(riverpod v1.0.0 stable)
例えば記事一覧画面から、記事詳細画面に遷移して、その画面のためのStateNotifierProvider
を扱うことを考える。
記事一覧画面から遷移するときにarticle.id (String)
を受け取り、StateNotifierクラスないでそれを受け取って記事データを取得し、詳細画面に表示する。
このとき、
final articleDetailNotifier = StateNotifierProvider.family.autoDispose<ArticleDetailNotifier, ArticleDetailState, String>((ref, articleId) {
return ArticleDetailNotifier(
repository: ref.watch(articleRepositoryProvider),
articleId: articleId
);
});
class ArticleDetailNotifier<...> {
// 省略
}
といった形でProviderFamily
を使うと、引数としてarticleIdを渡すことができて、Widget側(Screen/Pageクラスなど)から、
class ArticleDetailPage extends ConsumerWidget {
const ArticleDetailPage({required this.articleId});
final String articleId;
Widget build(BuildContext context, WidgetRef ref) {
final article = ref.wach(articleDetailNotifier(articleId).select((s) => s.article));
return ...;
}
}
のように、アクセスすることができる。
単一のWidgetからアクセスするだけの場合はこれが一番手軽だが、このArticleDetailPage内で複数の子Widgetに分割して、それぞれの子WidgetからこのNotifierにアクセスする場合、都度子WidgetにこのarticleId
を引数として渡さないといけなくなり、やや不便になる。
その場合は、ProviderScope
とoverrideの仕組みを使うときれいにできる。
ArticleDetailPageに遷移する時に、次のようにすると、Notifierのproviderを読み込む時に、articleId
を都度渡さなくて済むようになる。
// 遷移処理
Navigator.of(context).push(
MaterialPageRoute(builder: (_) {
ProviderScope(
overrides: [
articleIdProvider.overrideWithValue(articleId),
],
child: const ArticleDetailPage();
},
);
// Provider周り
final articleIdProvider = Provider<String>((ref) => throw Exception('not initialized'));
final articleDetailNotifier = StateNotifierProvider.autoDispose<ArticleDetailNotifier, ArticleDetailState>(
(ref) {
return ArticleDetailNotifier(
repository: ref.watch(articleRepositoryProvider),
articleId: ref.watch(articleIdProvider)
);
},
dependencies: [articleIdProvider, articleIdProvider],
);
class ArticleDetailNotifier<...> {
// 省略
}
// PageWidget
class ArticleDetailPage extends ConsumerWidget {
const ArticleDetailPage();
Widget build(BuildContext context, WidgetRef ref) {
final article = ref.wach(articleDetailNotifier.select((s) => s.article));
return ...;
}
}
事前にArticleDetailPage
をProviderScope(これは
main.dartで宣言するrootのものとは別)で囲み、そこで新たに定義した
articleIdProviderにoverrideする形で値をセットする。 NotifierProvider側で、
articleIdProviderをwatchして、Notifierクラスに渡すようにすれば、ProviderFamilyを使わずに、記事のIDを引数として渡すことができる。 そして呼び出す側では、
ref.watch(articleDetailNotifier),
ref.read(articleDetailNotifier.notifier)`でアクセスでき、引数を渡す必要がなくなる
注意点
この場合、articleDetailNotifier
を生成するときの引数dependencies
に、Provider内及びNotifierクラス内でアクセスしうるProvider群を指定しないと、Exceptionが発生する。
今回のケースだと、articleIdProvider
以外に、データを取得するためのarticleRepositoryProvider
をwatchしているため、これもdependencies
に記述する必要がある。
といった具合に、ProviderScopeを使えば、引数が必要なProviderでもFamilyを使わず、引数を子Widgetにバケツリレーしなくてもよくなるので、場所によってはこのアプローチを考えても良いかなと思う。
dependencies
の指定でoverrideしたprovider以外にも書く必要があるのか?書かない時にExceptionが出るのは誤検知(false positive)なのか?について: https://github.com/rrousselGit/river_pod/issues/892
文字列の先頭からn文字、末尾からn文字
extension on String {
String prefix(int count) => substring(0, count.clamp(0, length));
String suffix(int count) => substring((length - count).clamp(0, length), length);
}
void main() {
print('abcde'.prefix(-1));
print('abcde'.prefix(0));
print('abcde'.prefix(1));
print('abcde'.prefix(2));
print('abcde'.prefix(3));
print('abcde'.prefix(4));
print('abcde'.prefix(5));
print('abcde'.prefix(6));
print('-----------------');
print('abcde'.suffix(-1));
print('abcde'.suffix(0));
print('abcde'.suffix(1));
print('abcde'.suffix(2));
print('abcde'.suffix(3));
print('abcde'.suffix(4));
print('abcde'.suffix(5));
print('abcde'.suffix(6));
}
ref.listen()
でリッスン直後に直近の値を受け取る
fireImmediately: true
を渡してあげると、listenした直後に、listen対象の現在値が流れてくる
// e.g. inside StateNotifier's initializer
ref.listen(postsStreamProvider, (_, <AsyncValue<List<Post>>> current) {
state = state.copyWith(posts: current.value ?? []);
}, fireImmediately: true);