トレタ開発者ブログ

飲食店向け予約/顧客台帳サービス「トレタ」、超直前予約アプリ「トレタnow」を開発・提供するスタートアップ企業です。

自分なりに簡単なFlutterのアーキテクチャーを組み立ててみた

この記事は トレタ Advent Calendar 2020 の16日目です。

はじめに

トレタのフロントエンドエンジニアのClayです。元々はiOSエンジニアで現在はFlutter for WebとReactをメインでやっています。 今回は自分が使ってたFlutterアプリの簡単なアーキテクチャーややり方を紹介したいと思います。また最後はFlutter for Webを使った経験とメリデメを紹介したいと考えています。

FlutterのState管理

FlutterのState管理 と言えば昔から Bloc, Redux, か ScopedModel が人気だと思いますが、自分も前からずっとReduxを使ってFlutterのNativeアプリ開発をしてきました。今回は Flutter が公式で推進している Provider でやってみようかなと思いましたので自分なりなアーキテクチャーとルールを組み立てました。

アーキテクチャー

f:id:clay4649:20201215161811j:plain

基本は一方通行でViewから何かしたい時はController経由でServiceからデータ取ってきて、ControllerがそのデータでStateを更新してNotifierでViewがSelector Functionsで更新されるというアーキテクチャーです。データは基本Stateで管理しますがWidgetの状態は各StatefulWidgetで管理する方向です。例えばローディング状態とかStatefulWidgetで管理しちゃいます。StateはImmutableにしたいので freezed のPackageでImmutable化をしています。最初は自分的にbuild_runner みたいな3rd Partyコード生成ツールはあまりあまり好きじゃなかったので built_value とかは全然使っていませんでした。 freezed は割とbuild_valueよりは理解しやすいし、Equality OverloadcopyWithを全部実装してくれるので使い始まったらもうやめれなくなりました。

自分がReduxが好きな理由の一つは Single Source of Truth というコンセプトがとても好きです。例えばTodoアプリを作る場合はTodoのモデルは以下になります。

class Todo {
  const Todo({
    @required this.id,
    @required this.content,
    @required this.isDone,
    @required this.createdAt,
  });

  final String id;
  final String content;
  final bool isDone;
  final DateTime createdAt;
}

普通のStateだと以下のように todos を直接管理しますが、これだとWidgetはTodoを直接受け渡しないといけない(途中でTodoがどっかに変わっているかもしれない)ので、1つのTodoのReference(またはコピー)がいろんなところに存在する。また特定のTodoを取りたい場合は毎回検索しないといけないし、不便です。

class TodoState {
  const TodoState({@required this.todos});

  final List<Todo> todos;
}

そこで Redux の Normalized State のコンセプトをパクってきました。結果 State を freezed で Immutable 化して byIds のgetterを追加します。結果は以下みたいなStateができます。

@freezed
abstract class TodoState with _$TodoState {
  const factory TodoState({
    @Default(<Todo>[]) List<Todo> todos,
  }) = _TodoState;

  @late
  Map<String, Todo> get todoByIds => Map.fromEntries(todos.map((todo) => MapEntry(todo.orderId, todo)));
}

非常にいいところはこの freezed@late の魔法のマーカーです。公式ドキュメントによると

Freezed also contains early access to the upcoming late keyword. If you are unfamiliar with that keyword, what late does is it allows variables to be lazily initialized.

なので必要な時で計算されて、Stateが変わらなければもう計算はしないということなので非常的に効率的です。

これで todoByIds.key を取れば Todo のすべてのidが取れます。使い方としては Selector Function経由でデータを取って来れるので以下みたいなのでができます。

class TodoListView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Selector<SessionState, List<String>>(
      selector: (context, todoState) => todoState.orderItemById.keys,
      builder: (context, todoIds, _) => ListView.builder(
        itemBuilder: (context, index) => TodoTile(todoId: todoIds[index]),
      ),
    );
  }
}

または他のSortみたいなデータのロジックもここに入れられるので、特にSortとかはlazyやった方がいいので以下みたいに入れられます。

@late
Map<String, Todo> get todoIdsByCreatedAt => //Sort Function Here;

本当だったら todostodoByIds をprivateにして以下みたいに todoIdsgetTodo() でやるのは理想だと思いますが、開発チームはそんなに大きくないのでそこまでやる理由はあまりないかなと思いました。シンプルにして直接Objectを使わないようなルールだけにしました。

@freezed
abstract class TodoState with _$TodoState {
  const factory TodoState({
    @Default(<Todo>[]) List<Todo> todos,
  }) = _TodoState;

  @late
  Map<String, Todo> get _todoByIds => Map.fromEntries(todos.map((todo) => MapEntry(todo.orderId, todo)));

  @late
  Set<String> get todoIds => _todoByIds.keys.toSet();

  @late
  Todo get getTodo({@required String id}) => _todoByIds[id]
}

idで受け渡しするのは上手くやればWidgetのBuildの最適化にも繋がります。例えば、以下のようなObjectで受け渡しをしているはよく見かけますが

class TodoListTile extends StatelessWidget {
  const TodoListTile({
    @required this.todo,
  });

  final Todo todo;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(child: Text(todo.content)),
        Checkbox(
          value: todo.isDone,
          onChanged: (newValue) {
            // ....
          },
        )
      ],
    );
  }
}

この場合だと別れたtodoが変わると毎回この TodoListTile の全部のWidgetがRebuildされないといけないことになります。なのでできるだけ細かくSelectorのComponentに分けます。

class TodoListTile extends StatelessWidget {
  const TodoListTile({
    @required this.todoId,
  });

  final String todoId;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(
          child: Selector<TodoState, String>(
            selector: (context, state) => state.todoById[todoId].content,
            builder: (context, content, _) => Text(content),
          ),
        ),
        Selector<TodoState, bool>(
          selector: (context, state) => state.todoById[todoId].isDone,
          builder: (context, isDone, _) => Checkbox(
            value: isDone,
            onChanged: (newValue) {
              // ....
            },
          ),
        ),
      ],
    );
  }
}

SelectorはSelector Functionの結果が変わる時のみRebuildされるので、こんな感じにしたら isDone が変わる時だけ(Todoのチェックボックスが押される時だけ)は Checkbox のみがRebuildされます。この例だとChildrenのWidgetは2つしかないですが、もっと大きいWidgetだとChildrenも多くなって更にそのChildrenのChildrenも全部 Rebuild されたらあまり効率的ではないのでこのidで受け渡してSelectorでデータ取ってくる方法はかなり効率だと思います。

またよく使うSelectorのComponentを切り分けでしたらまたいろいろ共通化できます。テキストとかはこんな感じで便利です。

import 'package:provider/provider.dart';
import 'package:flutter/material.dart';

/// Storeから直接テキストを作成する
/// Example
/// ```
/// SelectorText<AppState>(
///   selector: (context, appState) => appState.name,
///   style: TextStyle(
///     color: Colors.white.withAlpha(60),
///     fontSize: 14,
///   ),
/// ),
/// ```
class SelectorText<S> extends StatelessWidget {
  const SelectorText({
    @required this.selector,
    @required this.style,
    this.overflow,
    this.maxLine,
  });

  /// このテキストを使うセレクタ
  final String Function(BuildContext, S) selector;

  /// `Text`みたいな`TextStyle`
  final TextStyle style;

  /// `TextOverflow`の指定
  final TextOverflow overflow;

  final int maxLine;

  @override
  Widget build(BuildContext context) {
    return Selector<S, String>(
      selector: selector,
      builder: (context, text, _) => Text(
        text ?? '',
        style: style,
        overflow: overflow,
        maxLines: maxLine,
      ),
    );
  }
}

やってよかったこと

  • 表示ロジックはSelector Functionに入っているので Unit Testがやりやすい
  • Objectを深く階層で受け渡しはなくなる
  • けっこうFlexibleな構成でいろんなエンハンスを対応できる
  • ChildのWidgetがBuildされる数が減る
  • Single Source of Truthが守られる
  • 構成がシンプルで実装しやすい
    • WidgetのStateはもうStateless Widgetに任せる

おまけ:Flutter for Webやってみた印象

メリット

  • Web アプリをアプリぽくするのは簡単
    • Webとアプリはほぼ90%+同じコードでできる
  • UI 実装が非常的に早い
    • 用意されるMaterial Widgetを使うかそのWidgetをいろいろカストマイズするのもらくらくにできた
    • ゼロから自分で組み立てるのも可能なのでだいたいはなんでも作れるような気がする
  • CSSってなんですか?
  • --releaseの時は意外とパフォーマンスがそんなに悪くない
  • アプリとほぼ同じやり方と考え方でWebアプリを作れる 

デメリット

  • まだBetaなのでBugが多い
    • iOSのSafariでスクロールする時は普段NavigationとUrlバーが隠られるはずだが、FlutterだとCanvasの中の疑似スクロールのためUrlバーが隠れていなくてスマートフォンの画面サイズが小さく見える(iOSのSafariのみ)
      • Full Screen Apiでなんとかしようと思ったら iPhoneのSafariだけがFull Screen Api を対応していない
  • FlutterのライブラリーがWebをサポートしていない場合はけっこうある
  • Hot ReloadじゃなくてHot Restartしかできないからコード保存してHot RestartされたらNativeアプリと違ってStateが全部が消える
    • 階層が深い画面で開発したらHot Restartされたら最初の画面から始めないといけないから結局開発中だけその画面をrootに切り出さないといけない
  • SVGが簡単に使えない
  • 新しい仕組みのSkia (いろいろをツルツルしてくれる)が日本語(Special Characters)をサポートされていない
  • FLUTTER_WEB_USE_EXPERIMENTAL_CANVAS_TEXT=false 付けないと日本語がよく切られるが、このフラグは --release 時のみしか機能しないため debug 中は日本語が切られてままでやっている
  • SSRがかんたんにできない
  • ルーティングがカオス(Nativeアプリ向けのまま)

Flutter for Webのまとめ

デメリットが多いように見えますが、自分的にはWeb開発による面倒くささを関わらなくてもWebアプリがラクラクでできるFrameworkだと思っていますのでみんなもFlutterを試してもらえたら嬉しいです。

終わりに

トレタは今色々新しいことができるところです。Flutterもその一つです。 興味ある方はぜひ遊びに来てください。

仲間も募集しております!

© Toreta, Inc.

Powered by Hatena Blog