Open25

振り返ったらFlutterでのアプリ開発のTipsが溜まっているスクラップ

ピン留めされたアイテム
su-su-
  • タイトルの通り
  • ゆるく、開発中に気づいたことをスクラップしていきます
  • 基本的にはFlutter、時折実装に絡めてFirebaseなどの話が混ざることもあり
su-su-

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
su-su-

ぼくのかんがえたさいきょうの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での指定があったら行う処理を追記する、はず
su-su-

fvmを使う場合のVSCode設定と.gitignore


新規プロジェクト立てたあと忘れがちなので

  • .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.jsondart.flutterSdkPathsを指定しておく。
  • プロジェクトによっては.vscode/settings.jsonはignoreして各個人がカスタマイズできるようにしておく
su-su-
{
  "dart.flutterSdkPaths": [
    "${fileWorkspaceFolder}/.fvm/flutter_sdk"
  ],
}

こうすると正しくproject内の.fvm/flutter_sdkが参照されるようになるので、VSCodeの右下で選ぶ必要がなさそう。(あとでfvmのrepositoryで報告しておこう...)

su-su-

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などで導入して使うようにする必要がありそう(自分も最初これにハマった)

こんなディスカッションもある

su-su-

そういえば今は解決済みで、projectのdevDependenciesとして使いたい場合はflutter_gen_runnerをinstallすれば名前衝突しなくなる.

su-su-

Null-Safety対応と未対応のパッケージが入り交じるときのビルド実行時は --no-sound-null-safety をつける

$ dart --no-sound-null-safety run
$ flutter run --no-sound-null-safety
su-su-

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'}をつけておくと吉。

su-su-

build_runnerがうまく動かないとき


  • 1: --delete-conflicting-outputsをつけて実行する
$ flutter pub run build_runner build --delete-conflicting-outputs
  • 2: analyzerのバージョンを明示的に変更する
su-su-

Null Safety対応されていないパッケージのimportをする場合は // ignore: import_of_legacy_library_into_null_safeをつける(analysis_optionで一括disableできると楽なのだけど...)

su-su-

Null-Safetyとfreezedでの自動生成ファイル


freezedで生成するファイルがまだ完全にnull-safety対応されていないので、生成元となるファイルの先頭に// @dart=2.10を追加してnull-safety対応を無効にすることで、生成されるファイルにも同様に// @dart=2.10が追加され、ビルドできるようになる

su-su-

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)で得られるAsyncValuewhen/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

su-su-

StreamController -> Providerにしたあと、それをProviderListenerでlistenするでも大丈夫そう。
StateControllerやStateNotifier(.state)を使う場合だと、例えばstate.errorが更新されたらアラートを出したい場合に、初回は期待通り動きそうだけど、そのあとstateのリセットせずに同じエラーが発生してstateに書き込んだときにProviderListener.onChangeが動かなさそうな気がしている。

StreamConrollerでエラーを流すようにした場合に、同じエラーが流れてもonChangeが発火するかを確かめる必要がありそう。やってみる。

su-su-

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」が出たら、原因はそこにある。

su-su-

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(),
    );
  });
}
su-su-

modal_bottom_sheet のCupertinoアニメーション対応

READMEの"CAUTION!: To animate the previous route some changes are needed."に詳細が書かれているものの、
Named Route (routes:を指定して、pushNamedなどで遷移する)を使わず、MaterialApp.home:で初期画面指定している場合に、どうやってMaterialWithModalsPageRouteを付与するのか謎だったが、解決したのでメモを綴っておく。

具体的には初期画面SplashPageMaterialApp.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()
);
su-su-

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();
})
su-su-

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にバケツリレーしなくてもよくなるので、場所によってはこのアプローチを考えても良いかなと思う。

su-su-

文字列の先頭から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));
}
su-su-

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);