TechConアプリ 2020 開発 / アーキテクチャ編

by Katsumi Onishi | March 27, 2020 updated
dena-tech software-architecture | #dena #dena-techcon #flutter

こんにちは。オートモーティブ事業本部の大西です。 普段は、スマートタクシーの車載器デバイス向けAndroidアプリの開発をしています。

残念ながら、DeNA TechCon 2020 は、昨今の状況を鑑みて中止となりました。 今年も公式アプリを提供し、ブースにて紹介/解説を行う予定でしたので残念です。

この記事では、TechCon 2020 アプリ で採用した、Flutter アプリケーションのアーキテクチャについて紹介したいと思います。

関連記事

こちらの記事もあわせてご覧ください。

Flutter とは

Flutter is Google’s UI toolkit for building beautiful, natively compiled applications for mobile, web, and desktop from a single codebase.

Google が作成している、単一のコードで、Android, iOS, Web, Desktop のアプリケーションを構築するための UI ツールキットです。

特徴

  • Fast Development
    • ホットリロードで数ミリ秒で開発の変更を反映することができ、すばやくUIの構築、機能の追加、バグの修正を行うことができます。
  • Expressive and Flexible UI
    • Material Designウィジェット、iOS風Cupertinoウィジェットが提供されており、モーションAPI、スムーズなスクロールなどユーザーエクスペリエンスを提供されます。
  • Native Performance
    • Flutterのウィジェットには、スクロール、ナビゲーション、アイコン、フォントなどのプラットフォームごとに組み込まれているため、iOSとAndroidの両方でネイティブパフォーマンスが提供されます。

より詳しく知りたい方は flutter.dev へアクセス!

Architecture の考察

Flutter はウィジェットで画面を構築していきます。

以下は、よく見かける Flutter のサンプルアプリの一部です。 ボタンのイベントでカウンターをイクリメントしていくとても単純なものです。

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);
  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.title),),
      body: Center(
        Text('$_counter'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: Icon(Icons.add),
      ),
    );
  }
}

StatefulWidget で Stateクラス を作成し、build()で ウィジェットを構築しています。 State クラスには、画面の構築する処理、ボタンのイベント処理、カウンター(状態)、カウンターを更新する処理が行われいます。

このサンプルコードレベルでは問題はありませんが、 大きなアプリケショーンになると、複雑な画面、複雑なイベント処理、多くの状態、ドメインロジック、データソースアクセスなど必要となり、クラスがFatになってします。

そこでアーキテクチャを構築し、保守性、信頼性、完全性が高く品質の良いコード目指します。

DeNA TechCon アプリでは、Clean Architecture を参考にして、以下のポイントを念頭にアーキテクチャーを構築しています。

  • 関心の分離
  • 疎結合化
  • モジュール分割
  • 依存性の片方向性
  • レイヤー分離
    • プレゼンテーション層
    • ドメイン層
    • データ層
  • コールフロー/データフローの一方通行化

モジュール/コンポーネント構成

techcon_app
├── app               [プレゼンテーション層モジュール]
│   ├── pages          => ページウィジェット
│   └── widget         => ウィジェットコンポーネント
├── domain            [ドメイン層モジュール]
│   ├── models          => モデル
│   └── blocs           => BLoC(Business Logic Component)
├── repository        [データ層モジュール]
│   ├── entitiy         => エンティティ
│   └── repository      => インターフェース
└─── repository_cache [データ層(実装)モジュール]
    └── repository      => 実装 HTTP通信/ローカルキャッシュ

コールフロー/データフロー

モジュールは階層的に依存させ、コールフローは上から下に、データフローは下から上に流れる構成にています。 分割されたモジュールは、不用意な依存を発生させなくし、関心の分離されるのでテスタビリティも保てます。

  1. プレゼンテーション層のウィジェットは、イベントを発火しドメイン層から状態の通知に応答します。
    • 状態管理のフレームワークには BLoC を採用しています。
  2. ドメイン層は、イベントに応じてビジネスロジックを処理します。サーバーやファイルなどデータソースへのアクセスを行う場合にはデータ層へ処理をリクエストします。
  3. データ層は、リクエストに対して適切なデータを取得してエンティティにセットしてレスポンスします。
    • APIからのJSONを取得する場合は、エンティティに変換します。
  4. ドメイン層は、受け取ったエンティティをモデルにマッピングして、状態の変化を通知します。
  5. プレゼンテーション層は、通知された状態から適切な画面を表示します。

BLoC

BLoC は、Business Logic Component の略で、状態管理のアーキテクチャパターンです。

Fluter の状態管理のアプローチは、setStateInheritedWidget & InheritedModel, Provider, Scoped Model, MobX, Redux, BLoC といくつもあります。 List of state management approaches - Flutter

Pragmatic State Management in Flutter (Google I/O’19) で、それぞれ解説されていますので見てみてください。

前回は、Redux で構築しましたが、今回は BLoCflutter_bloc フレームワークを採用しています。

Redux の場合は、状態を一元管理するフレームワークのため、あらゆる要件のイベントや状態が煩雑になりがちでしたので、BLoC で、状態の管理の範囲を小さくするようにしました。

今回のアプリで、どのように BLoC を使用しているか説明いたします。

アプリ画面の構成

最初の画面となる RootPage では、BottomNavigationBar で、HomePage, SchedulePage, BookmarkPage, MapPage を切り替えれるようになっています。 SchedulePage では、SessionsPage,WorkshopPage, BoothPage をタブで切り替えれるようになっています。

MaterialApp ウィジェットツリーでは、MultiBlocProvider で、RootPage の配下のページと1対1の BLoCをプロバイドしています。

class App extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    child: MaterialApp(
      routes: <String, WidgetBuilder>{
        AppRoutes.home: (_) => MultiBlocProvider(
          providers: [
            BlocProvider<TabBloc>(
              create: (context) => TabBloc(),
            ),
            BlocProvider<SessionsBloc>(
              create: (context) => SessionsBloc()..add(SessionsFetch()),
            ),
            BlocProvider<WorkshopsBloc>(
              create: (context) => WorkshopsBloc()..add(WorkshopsFetch()),
            ),
            BlocProvider<BoothsBloc>(
              create: (context) => BoothsBloc()..add(BoothsFetch()),
            ),
            BlocProvider<BookmarksBloc>(
              create: (context) => BookmarksBloc()..add(BookmarksFetch()),
            ),
          ],
          child: RootPage(),
        ),
      }
    ),
  }
}

Bloc / Page

  • SessionsBloc -> SessionsPage
  • WorkshopsBloc -> WorkshopPage
  • BoothsBloc -> BoothPage
  • BookmarksBloc -> BookmarkPage

初期処理として Fetch イベントを発火させておき、予めデータを取得するようにしています。

            BlocProvider<SessionsBloc>(
              create: (context) => SessionsBloc()..add(SessionsFetch()),
            ),

SessionsBloc は、3つの状態を管理します。

class SessionsLoading extends SessionsState {}

class SessionsLoaded extends SessionsState {
  final List<Session> sessions;

  const SessionsLoaded({@required this.sessions});
}

class SessionsFailure extends SessionsState {}

初期の状態は SessionsLoading で、SessionsFetch イベントが来たら、Repository からデータを取得して、SessionsLoaded に状態を変更しています。失敗した場合は、SessionsFailure になります。

class SessionsBloc extends Bloc<SessionsEvent, SessionsState> {
  @override
  SessionsState get initialState => SessionsLoading();

  @override
  Stream<SessionsState> mapEventToState(SessionsEvent event) async* {
    if (event is SessionsFetch) {
      try {
        final sessions = await _repository.getSessionList();
        yield SessionsLoaded(sessions: sessions.map((e) => Session.fromEntity(e))?.toList());
      } catch (e) {
        yield SessionsFailure();
      }
    }
  }
}

SessionsPage では、 SessionsBloc から SessionsState を受け取り、状態に合わせた画面を構築します。 状態が SessionsFailure や、SessionsLoaded でもデータがない場合は、ボタンを表示し、SessionsBloc に SessionsFetch イベントを発火しデータの再取得が出来るようにしています。

class SessionsPage extends StatelessWidget {
  SessionsPage({Key key, this.analytics, this.observer}) : super(key: key);

  @override
  Widget build(BuildContext context) {

    return Scaffold(
      body: Container(
        child: BlocBuilder<SessionsBloc, SessionsState>(
          builder: (context, state) {
            if (state is SessionsLoading) {
              return LoadingIndicator(); // 処理中
            }
            if (state is SessionsFailure) { // 取得失敗
              // リロードボタンを表示
              return FailureView(
                onPressed: () => BlocProvider.of<SessionsBloc>(context).add(SessionsFetch()),
              );
            }
            if (state is SessionsLoaded) { // 取得済み
              if (state.sessions.isNotEmpty) { 
                return TimeTablePage(sessions: state.sessions);
              }
              // データがない場合、リロードボタンを表示
              return UnderConstructionView( 
                onPressed: () => BlocProvider.of<SessionsBloc>(context).add(SessionsFetch()),
              );
            }
            return LoadingIndicator();
          },
        ),
      ),
    );
  }
}

Dart 2.7 (Extension methods)

Dart 2.7 で導入された拡張関数(Extension methods)を使用して少しコードをシンプルにする工夫も行っています。

具体的には、Enum で定義されているスタンプの種類から対応するアセットを取得できるようにしています。

enum StampType { WORKSHOP, SPEAKER, BOOTH }

extension StampImage on StampType {
  String assets() {
    switch (this) {
      case StampType.WORKSHOP:
        return "assets/images/stamp_workshop.png";
      case StampType.SPEAKER:
        return "assets/images/stamp_askthespeaker.png";
      case StampType.BOOTH:
        return "assets/images/stamp_booth.png";
    }
    return "";
  }
}
  Image.asset(stampType.assets());

まとめ

Flutter は、ひとつの Widget に役割を持たせすぎ、肥大化しまいがちです。 アーキテクチャを構築することにより、モジュールも分割し、コールフローを制御することで、関心の分離が行われ、スッキリしたコードになり可読性やテスタビリティが上がると思います。

“美しいコードを見ると感動する。優れたコードは見た瞬間に何をしているかが伝わってくる。そういうコードは使うのが楽しいし、自分のコードもそうあるべきだと思わせてくれる。” リーダブルコード より

Flutter / Dart は、ここ数年で大きな進化し認知度も上がってきています。今後も追いかけて行くので、コミュニティなどでも活動したいと思ってますので、よろしくお願いいたいします。