45
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Flutter歴1ヶ月がオンラインハッカソンで初心者チームのTech Leadをしてみた

Last updated at Posted at 2020-06-24

はじめに

1ヶ月前のGWにもハッカソンに出場し、そこで初めてFlutterを触り5日間でアプリを開発しました。

そのときの記事はこちらです
Flutter初見が5日間のハッカソンでアプリ開発してきた

今回は1週間でサービスを作るハッカソンを企画/開催したためその概要と、Flutter初心者チームでTech Lead(笑)な役割をし、メンバーを牽引した軌跡を残せれば良いと思いこの記事を書いています。

CA21Hackathon

目的

今回のハッカソンは

CyberAgentの21卒内定者エンジニアでのハッカソンです。

このハッカソンを開催した目的は2つあります。

  • 同期理解のため
  • 同期間での技術知見共有

このハッカソンは、内定者数人で週に1度zoomで集まりミーティングを行い

  • そもそもどんなイベントを開催するか
  • ハッカソンを開催する目的/ テーマ
  • 開催日時/希望職種アンケート
  • グループ決め

等を1ヶ月間綿密に決めた上で開催されました。

人事の方とかけあったり、詳細を決めてくれたみんなに感謝です🙏

概要

結果、テーマは

with COVID-19, after COVID-19

開催期間は1週間。

初日と最終日のみ必須参加 とし、 稼働時間はチーム相談として各々調整する形です。

初日:チームでアイデアソンを行い、発表。他の人からフィードバックをもらうフェーズ

2日目〜6日目:任意の稼働時間で各々開発を進める

最終日:開発したサービスに関して発表(発表時間7分、質疑応答3分)

な流れでした。

チーム構成

自分のチームは

サーバーサイドのいないFlutterチーム

です。

チームの構成は

  • Android (Flutter経験1ヶ月🙆‍♂️)
  • Android (Flutter経験なし🙅‍♂️)
  • iOS (Flutter経験なし🙅‍♂️)
  • iOS (Flutter経験なし🙅‍♂️)

で、自分含め、ほとんどFlutter経験がないメンバーでした。

さらに、全員Firebaseの知識もなく、サーバレスで通信を伴うアプリを作りたい場合にも少し苦労するかもという印象でした。

作成したアプリ

今回のハッカソン企画において、

企画側は

  • アンケート作成が面倒臭い
  • バランス良くチーム分をするのが大変
  • 参加者への連絡が大変

参加者側は

  • 毎回のプロフィール情報を入力するのが手間
  • メールだと大切な情報を見逃しがち
  • チームの管理が面倒
  • グループ名を決めるのが面倒くさい
  • github, slide, document等リンクの管理が面倒

という課題/面倒がありました。

その面倒ごとを解決できるようなアプリを作ろうと思って生まれたのが

Hack ×2 です

HackathonをHackするという意味での命名です、チームメンバーに命名マスターがいて即決まりました(さすが)

スクリーンショット 2020-06-24 19.34.06.png

ハッカソン期間中はミニマムで実装をしましたが、元々の想定では15画面ほどありました。

シンプルにハッカソンで作る規模ではないですねw 

また、要件的に通信が伴うため、サーバーサイドのいないこのチームではFirebase等のmBaaSを使用する必要がありました。

この、FlutterおよびFirebase初心者チームが、どのように膨大な画面数/仕様が存在するアプリを1週間で作成したか、その道のりについて記述できればと思います。

キャッチアップとハッカソンの進行

基本的な進行方向としてはFlutter初見が5日間のハッカソンでアプリ開発してきた をご覧いただければと思います。

大枠で紹介すると、

  • 経験者を中心に技術の共有、キャッチアップ方針を定める
  • 毎日進捗をすり合わせ、「やること」「やらないこと」を明確にし、確実にタスクをこなす
  • オンラインでのコミュニケーションを円滑にするためのツールを活用する

の3つに特に注力していました。

経験者を中心に技術の共有、キャッチアップ方針を定める

自分は、1ヶ月強前にハッカソンで右往左往しながらFlutterのキャッチアップをしました。
その際に無駄だったことや、初めからやればよかったこと等の知見が溜まっていたため、チームメンバーに

  • どのような手順で
  • 何を参考に
  • いつまでに
  • 何をするか

をなるべく具体的に提示することで、効率よく学べるように心がけました。

具体的には、
Flutterは状態管理が少し難しい反面、UIは直感的に簡単に組めるため、
まず状態管理に慣れてもらいました。

  1. udemyを用いて StatefulWidget や、 Provider の概念を知り、
  2. 以前のおうちハッカソンで書いたコードを参考に ChangeNotifier を理解し、
  3. ブログや公式ドキュメント、自分のサンプル実装で StateNotifier を使いこなせるようになる

の流れで、初めの2〜3日の時間を使いました。

この状態管理packageを使う過程でWidgetの組み方もある程度は勉強できるため、4日後にはある程度実装できるようになっていたと思います。

この間に、自分は設計やCI,linterを導入したり、FirestoreのModelingで試行錯誤したり、快適に開発ができるような環境づくりに注力しました。

毎日進捗をすり合わせ、「やること」「やらないこと」を明確にし、確実にタスクをこなす

今回のハッカソンは1週間と、期間としては短くはないですが、それでも時間は限られています。

これはハッカソンに限った話ではないですが、限られた時間の中で形にするためには、
「やること」「やらないこと」を明確にする必要があります。

さらに、知らない技術に触れる中で「できないこと」も判別してタスクを組むことも大事になってきます。

これらを共通認識として保つために、毎日Discordで進捗確認をし、タスクの割り振りや棚卸し、ゴールから逆算したときの進み具合をすり合わせました。

みんな実装に気を取られていた中、これを率先してくれたメンバーに感謝です🙏

オンラインでのコミュニケーションを円滑にするためのツールを活用する

今回、コロナや居住地の関係でオンラインでのハッカソン開催となりました。

そのため、コミュニケーションやアイデア出し、その他諸々は工夫する必要がありました。

スクリーンショット 2020-06-24 19.35.12.png
  • Notion
  • Figma
  • miro
  • Whimsical
  • Discord

それぞれの詳細な仕様方法等は触れませんが、Notionでドキュメントやその他情報を管理し、進捗やスケジュールの共有はとても有意義でした。

技術の話

今回、膨大な仕様と画面数はさることながら、1番の頑張りポイントはは技術的な挑戦でした。

繰り返しますが、

3/4はFlutter初見、残りはFlutter歴1ヶ月

全員がFirebase初心者(サンプル触った程度)

です。

触ったことのないプラットフォーム 自体が挑戦でしたが、

さらに最近流行のpacakgeを使用する等、設計にもこだわりました。

Flutterアプリ全体のArchitecture

スクリーンショット 2020-06-24 19.37.30.png

発表スライドの貼り付けになりますが、アプリ全体のアーキテクチャとしては上図のようになります。

MVSN + Layered Architecture と書いていますが、SNはState Notifierのことです。

これは自分が作った造語で、実際にこういった呼び方のアーキテクチャがあるわけではありませんので悪しからず...。

MVVMのViewModelが、

Viewを描画するための状態の保持と、Viewから受け取った入力を適切な形に変換してModelに伝達する役目を持つ

とwikiに定義されているため、広義の意味ではMVVMなのかもしれません。

ただ、普段Androidをしている自分のイメージなViewModelとは少し構造が違うように感じたため、

MVSNと呼んでみました。

使用したpackage

State Notifier

今回の設計の要となるpacakgeです。

スライドに記載したり、上述した通り、State NotifierはつまりViewModelです。

View側で必要な状態をStateクラスで持ち、StateNotifierを継承したクラス側で、状態を変更してあげます。

View側では、Provider packageを利用して単発のイベント呼び出したり、StateをObserveしておくことで、Stateに変更があった場合にWidgetを再描画したりします。

文字だけではイメージがつきづらいと思うため、例をあげます。

ex.) プロフィール詳細画面で、RepositoryからUser情報を取得し、Viewに反映する例

ProfileDetailState
@freezed
abstract class ProfileDetailState with _$ProfileDetailState {
  const factory ProfileDetailState({
    User user,
  }) = _ProfileDetailState;
}

freezedに関しては後述しますが、このProfileDetailStateをKotlinでいうData Classとして記述している

と理解して大丈夫です。

色々書いてますが、基本はfreezedのお作法でLive Templateで補完できるため、ここでは User という、プロフィール詳細画面で表示するべき状態をもっていることに注目です。

ProfileDetailController
class ProfileDetailController extends StateNotifier<ProfileDetailState> with LocatorMixin {
  ProfileDetailController() : super(const ProfileDetailState());

  UserRepository get userRepository => read<UserRepository>();

  Future<void> getProfileDetail() async {
    final User user = await userRepository.getMyInfo();
    state = state.copyWith(user: user);
  }
}

さきほどの ProfileDetailState を持つ、StateNotifierを継承したクラスを作成します。

ここでのポイントは2つ

  • LocatorMixinを使って UserRepository をinject(read)している
  • UserRepositoryから取得したuserを state = state.copyWith(user: user); で更新している

です。

ChangeNotifierとの違いは、Controllerクラス(StateNotifier継承クラス)でローカル変数を持たず、StateNotifierのstateの状態を変更してあげるだけで良いことです。

notifyListeners をわざわざ呼ぶ手間は省けますね。

ちなみに、Controllerは基本的には画面ごとに持っています。

ProfileDetailPage
class ProfileDetailPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return 
    // 略
    body: StateNotifierProvider<ProfileDetailController, ProfileDetailState>(
        create: (_) => ProfileDetailController(),
        child: Builder(
          builder: (context) {
            return Column(
              children: <Widget>[
                Text(context.select<ProfileDetailState, String>((state) => state.user?.fullName ?? 'no name')),
                GestureDetector(
                  onTap: () async {
                    await context.read<ProfileDetailController>().getProfileDetail();
                  },
                  child: const Icon(Icons.add),
                )
              ],
            );
          },
        ),
      ),
   // 略
  }
}

状態をobserveする際は、

context.select<ProfileDetailState, String>((state) => )

単発のイベントを呼ぶ際は

context.read<ProfileDetailController>().getProfileDetail()

のようにしています。

state_notifier に関してよりくわしく知りたい方は

安定の @itomeさんのブログ を参照ください。

自分の環境設定が悪いのか、

readやselectの補完がデフォルトでは出ず、わざわざ手打ちでprovider pacakgeのimport文を書く必要があったところは少し不便でした。

freezed

freezedに関しては、上記の @itomeさんのブログ や、他にも色々な記事があるためここでは詳細には紹介しません。

簡単にいうと、KotlinのData Classや、Sealed Classに相当するデータ構造クラスが簡単に作成できるpacakageです。

上記のStateNotifierの例で言うと、

state = state.copyWith(user: user);copyWith メソッドがfreezedの機能にあたります。

冗長なコードを書かずに完結にstateの状態を変更できるため、StateNotifierと相性が良いです。

とても便利なのですが、コード生成が伴うため、

  • file構造が少し煩雑になってしまう
  • Modelの変更をしたら再度コード生成コマンドを打つ必要がある(忘れがち)

と、少し不便なところもありました。

Json Serializable

上記のfreezedと併用するとより効果を発揮するpackageです。

fromJsonや、toJsonを、ボイラープレートなしにコード生成してくれるpacakgeです。

控えめに言って最高です。

ただこちらも、コード生成が伴うため、freezedで述べたデメリットや、

  • プリミティブ型ではない独自のModelクラスは Converterを書く必要があり、ちょっと面倒くさい
  • ModelにListが入っている場合、コード生成されたクラスで型が宣言されておらずlintエラー( missing type parameter )が表示されるのがつらい

というつらみポイントもありました。

RxDart

Widgetへの状態変更通知に関してはStateNotifierを使用しましたが、
ログイン状態の変化等の、RepositoryからControllerへの変更通知はStreamを使用することにしました。

Dart標準のStreamでも事足りるのですが、BehaviorSubjectが使用したかったためRxDartを採用しました。

自分は普段Androidでインターンをしているため、LiveData/Coroiutineの世界に馴染みがあり少し詰まったポイントではありました。

FirestoreのModeling(議論とご指摘ほしいです)

今回1番苦労したのがこのFirestoreのModelingと実装です。(本当にきつかった、いまだに良く分からない)

AndroidとFlutterというクライアントサイドしか経験がなく、RDBすらまともに設計したことがない状態から、NoSQL風かつSubcollectionという特殊な概念を持ち合わせたFirestoreの設計をしたため、フィードバックいただけるとすごくありがたいです🙇‍♂️

概要

スクリーンショット 2020-06-24 19.39.40.png

こんな感じで設計しました。

※実装している間に辛いところがちょくちょくあったりして、改善の余地ありまくりです。

Modelingに関しては、firestore-data-modelingを参考に勉強しました。

User - Hackathon(多対多)

スクリーンショット 2020-06-24 19.40.28.png

まず、このアプリのコンセプトが

簡単にオンラインハッカソンを企画/運営できる というものです。

その中に、ユーザーが参加する際に入力すべき項目を減らし、参加障壁を低くするという目的もあります。

そのため、rootにUserがありアプリ全体としてユーザーのプロフィール等を保持し、並列してHackathonなcollectionがある感じです。

Discordをイメージしていただきたいのですが、

Discordでは、新しい サーバー に参加した際にroot?のUser情報を元にアイコンと名前が表示されます。

このHack ×2も同様に、新しいハッカソンに参加した際にrootのUser情報を使いまわしたく、この設計にしました。

多対多の表現は少し困ったのですが、中間テーブルを設けることで表現してみました。

どのユーザーがどのハッカソンに所属しているかを取得する中間テーブルかつ、ドロワーに参加しているハッカソンのアイコンを表示させたいので、urlも同時に持たせることで読み取り回数を減らしました。

Hackathon - Participant/Group/Notification(1対多)

スクリーンショット 2020-06-24 19.41.03.png

Participant, Group, Notificationは全てHackathonの SubCollection で持っています。

なぜなら、それぞれいくらでもスケールし得て、documentへの埋め込みだと1MBを超える可能性があるためです。

1対多に関しては、他にもroot collectionで持っている記事があったり、最適があまりよくわかっていません。

Participant とは、ハッカソンの参加者を表しており、Userを埋め込みで持っています。

Userをラップしており、他にはハッカソンで必要な情報(酸化可能日、稼働可能日数、希望職種etc...)のプロパティを持っています。

Group はその名の通り、ハッカソンで組むグループです。(チームという命名のほうが正しい...?)

GroupParticipant は1対多の関係で、Participantは何人になるか不明なため SubCollection で持たせるようにしています。

Notification は今回時間の関係(Modelingで分からないこともあり...)で実装していません。

  • ハッカソンの管理者がお知らせを送信することができる
  • 参加者はお知らせ画面で閲覧することができる
  • 通知バッヂをつける

の要件があるとき、

Hackathon : Notification = 1 : 多 になると思うのですが、

Participant : Notification = 1 : 多 にもなる感じなのかな...?

参加者の既読状況を表すのにはどうするのが正解なんでしょう...。

よければコメントいだければ幸いです。

全体図

Notification周りは未完成なのと、dartのModel Classとして記載しています。

スクリーンショット 2020-06-24 19.41.18.png

実装

Androidでは簡単なサンプルを実装してみたことがあるのですが、FlutterでFirestoreを扱うのは初めてだったので色々つらみがあありました。

Firestoreからデータを取得してFlutterのfreezedなclassに変換する際、

  • idを別で取り出す必要がある
  • Future型で返却するためにこねくり回す
  • toJson、fromJson時、CustomObjectが内包されている場合はConverterを書く必要がある

ことが手間でした。
これが生コードなのですが、かなり汚く苦悩が見えると思います...

HackathonRepository
  // TODO: エラーハンドリング
  Future<Hackathon> getHackathon(String hackathonId) async {
    final DocumentReference hackRef = _firestore.collection('hackathons').document(hackathonId);
    Future<List<Map<String, dynamic>>> getJsonList(String collectionName) async =>
        (await hackRef.collection(collectionName).getDocuments()).documents.map((document) {
          if (document.data.isNotEmpty) {
            return document.data..putIfAbsent('id', () => document.documentID);
          } else {
            return <String, dynamic>{};
          }
        }).toList();

    // TODO: 並列実行 => fromJsonするやり方を調べる
    final List<Map<String, dynamic>> participants = await getJsonList('participants');
    final List<Map<String, dynamic>> groups = await getJsonList('groups');
    final List<Map<String, dynamic>> notifications = await getJsonList('notifications');

    await (await prefs).setString(HACKATHON_ID_KEY, hackRef.documentID);

    return Future.value(Hackathon.fromJson(hackSnapshot.data
      ..putIfAbsent('id', () => hackRef.documentID)
      ..putIfAbsent('participants', () => participants)
      ..putIfAbsent('groups', () => groups)
      ..putIfAbsent('notifications', () => notifications)));
  }

Hackathonに紐づいているSubcollectionごと取得する良い方法があれば教えていただきたいです。
※今は個別で取得して、 putIfAbsent で付け加えてる形。

また、
Firestoreにデータをセットする際に、

  • freezedなclassはidを@requiredにしているが、idはFirestore側で自動生成させたい場合にDTOクラスを作るのかパラメータをだけで渡すか

とかも結構面倒くさかったですね。

FirestoreのModel設計をコードを織り交ぜて解説している良い記事あれば教えていただきたいです。

おわりに

ハッカソンを通じて、話したことない同期同士で仲良くなったり、同期がどんなことが得意かが分かると同時に、技術的/非技術的な知見を互いに共有することができました。

今後も内定者間や人事の方と合同での企画を予定しているため、今回参加できなかった同期とも徐々に打ち解けていければ良いと思います。

そして、21年度の入社時までに色々な知見を溜め、仲を深め、入社時から即戦力として最高のパフォーマンスを出せるような新卒になれるように、組織として力を入れていきたいと思っています。

行動力、技術力、キャッチアップ力ともに同期を尊敬しました。

素晴らしいイベントでした。企画、運営、協力感謝です。

技術的な内容だけQiitaにして後はnoteに投稿するように分けようかな...?

45
36
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
45
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?