2023/6/12追記
学校公式アカウントにて、文化祭紹介動画が公開されていたので追加します。
2023年度は一般公開される予定らしいので、もしよければご参加ください!
(自分の時は他のクラス一つも見れなかったので 今年度は見まくるぞ!)
はじめに
自己紹介
こんにちは、もぐもぐと申します。現在とある横浜のフロンティアなサイエンス高校というところに通っている高校3年生(18歳1)の自称プログラマー
です。
本記事の内容は、横浜市並びに横浜サイエンスフロンティア高等学校に非公式で公開しているものです。
本記事に関する問い合わせをこれらの機関へ行う行為は慎んでいただくようお願いいたします。
趣味でEQMonitorという地震観測・速報アプリケーションの開発(主にFlutter)をしたりもしています。
高校の文化祭(通称:蒼煌祭)のクラス企画でいくつかソフトウェアをおひとりさま開発したので記事に残そうと思います。
「忘れないうちに急いで書いちゃえ〜!」という気持ちで ザーッと書いていたら 相当分かりにくい記事になってしまいました
不明な点、ご意見、ご要望等ありましたらコメントお願いします
(TwitterDMでもお待ちしています)
文化祭でソフト開発!? どんなクラス企画やね~ん
例年3年生は食品販売をやるらしいです。しかし、今年は新型コロナウィルス感染症の影響で食品販売は禁止でした。高校生のうちに一度くらい、食品販売をしてみたかったのですが、残念ながら叶いませんでした。またもやコロナウィルスに夢が潰されたというわけです。
私のクラスの企画名はマークシートマニアです。
えっ、もしかしてディズ○ー・シーのトイ・ストーリマ〇ア!?
と思った方、
大正解です。僕が最後にディズニー関連に行ったのは、幼稚園に入る前なので当然トイストーリーマニアがどんなアトラクションなのか全然分からないわけですが....2
このクラス企画が一体どんなものなのか ざっくりとお話すると、
まずは、やりたい問題を選ぼう! 選んだセットから大問が3つ出るよ!
各大問につき小問が3つの合計9問に回答してもらうぞ!
小問1つあたり17.5秒の回答時間が与えられるよ!
ライドに乗って ボールを穴に投げて回答してね!
ボール1個につき 正答なら8点、誤答なら1点追加。ボールは投げ放題!
簡単に言えば、「ライド型シューティングアトラクション」やな!
素早く答えを考えてボールを投げまくれ!!
という感じです。
システムは何をするの!?
- 来場者(以後 ユーザー)の登録
- ユーザーが選択した問題セットの番号とユーザーが乗るライドのIDを入力 → DBへ保存
- DB保存時に自動で連番のユーザーIDが割り当てられる
- ユーザーIDとライドIDは、結果用紙に書く
- ライドが移動した後にユーザーの位置をDBに登録
- (紙とライドは同時に動かすようにするので)ライドIDからでも、紙に書いてあるユーザーIDからも ユーザーを識別可能
- ユーザー
-
Classroom33Admin
: 総合管理ソフトウェア(Windows/iPadOS)- 来場者をデータベースに登録
- 全プロジェクターの状況確認
- 出題開始承認
- 結果表示
-
Classroom33PJ
(Projector): 問題表示用ソフトウェア(Windows)- Adminの承認後にControllerが指定したユーザーの問題文を表示(リアルタイム同期)
-
Classroom33Controller
: ボールカウント用ソフトウェア(Android/iOS)- プロジェクターに表示するユーザーを指定
- 穴に入ったボールをカウントして結果をデータベースへ投げる
見てわかる通り、様々なプラットフォームで動くようにしたかったので実装する選択肢として、Webアプリ/クロスプラットフォーム開発が出てきました。
今回は高校生の間、ずっとお世話になってきたFlutterで実装しました。最終的にiOS,Android,Windowsで動かしましたが、ネイティブコードは一切書いていません。
classroom33Admin | 出題開始承認&来場者登録&結果表示 | 全体状況の確認来場者登録結果確認 |
classroom33PJ | 問題表示 | |
classroom33Controller | ボールカウント | ユーザー選択ボールカウント |
|
の開発を行いました。
当日の様子
百聞は一見にしかず。文字で説明するより見てもらったほうがイメージが湧きやすいと思います。どういう雰囲気だったかはこちらの動画をご覧ください。
0:00~0:50: 全体の雰囲気
0:50~1:44: 制作過程(クラスメイト作成)
1:44~3:09: 実際に人が来た時の様子(クラスメイト作成)
利用した技術
言語 | Dart(Flutter) |
データベース | Supabase(PostgreSQL) |
コード管理 | Git/GitHub |
iOS向けのビルド | CodeMagic |
Flutter
全てのアプリケーションはFlutterを用いて実装しました。
状態管理はflutter_hooksとRiverpodを利用しました。RiverpodのFutureProvider
とStreamProvider
はエラーハンドリングがめちゃくちゃ楽で感動しました。しかも、FutureBuilder
やStreamBuilder
と違ってキャッシュされますし…!(Riverpodを利用する1番のメリットはそこではない気がしますが)
Controller/Admin/Projectorで共通の実装はパッケージ化して管理しやすくしました。
データベース(PostgreSQL)
Supabase CloudというBaaSを利用してバックエンド構築をしました。WebSocket経由でINSERT/UPDATEをクライアントへ通知させることもできます。(Supabase Realtime)
今回作成するアプリケーションは全てクラスメイトのみが触ることが確定しており、ユーザ認証が不要だったため 利用しませんでしたが、Row Level Security
(通称RLS)を利用して、SELECTやUPDATE,DELETEなどに対して 行レベルで条件を指定することができます。別プロダクトで利用したことがあるのですが めちゃくちゃ便利です。
テーブルはこんな構成になりました。^3
初期化用SQL文
CREATE TABLE
IF NOT EXISTS public.users (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP DEFAULT now() NOT NULL,
big_question_group_id SMALLINT NOT NULL,
number_of_people SMALLINT NOT NULL DEFAULT 1,
total_point INT,
result jsonb DEFAULT '{ "items": [] }',
ride_id INT NOT NULL
);
CREATE TABLE
IF NOT EXISTS public.state (
position TEXT PRIMARY KEY,
big_question_state TEXT NOT NULL DEFAULT 'projector1',
big_question_group_id INT
);
DELETE from
public.state;
SELECT
SETVAL ('users_id_seq', 1, false);
DELETE FROM
public.users;
-- テスト用
--INSERT INTO public.users (big_question_group_id, ride_id) VALUES
-- (1,1),(2,2);
INSERT INTO
public.state (
position,
big_question_state,
big_question_group_id
)
VALUES
('projector1', 'waitingForController', null),
('projector2', 'waitingForController', null),
('projector3', 'waitingForController', null);
state
テーブル
-
big_question_state
に各大問の状態が入ります。
enum BigQuestionState {
/// 移動中(Controllerによるだいもんごとのユーザー登録処理待ち)
waitingForController,
/// 一斉開始待ち(Adminによる一斉開始処理待ち)
waitingForAdmin,
/// 出題中 OR ボールカウント結果送信待ち
running,
}
各デバイス(ControllerとPJ)は、このStateテーブルの変化をリアルタイムで検知し、状態を変化させます。
-
big_question_group_id
にはプロジェクターが表示すべき大問セット(大問3つのグループ)のIDが入ります。
ユーザーがいない場合はnull
が入ります。
class BigQuestionSet {
BigQuestionSet({
required this.id,
required this.title,
required this.questions,
required this.category,
});
/// 大問グループID
final int id;
/// 大問グループのタイトル
final String title;
/// 大問の配列(3つ)
final List<BigQuestionItem> questions;
/// 大問グループのカテゴリ
final QuestionCategory category;
}
users
テーブル
-
result
: 各大問ごとの得点結果(JSONB
型)
大問は3つ × 小問3つの計9つの要素を持ちます。
初期値は{ "items": [] }
なので、items
の要素が、0→3(大問1終了)→6(大問2終了)→9(大問3終了)と増えていく感じです。得点データ(JSON)の例
{ "items": [ { "wrong_count": 0, "correct_count": 29 }, { "wrong_count": 0, "correct_count": 33 }, { "wrong_count": 7, "correct_count": 21 }, { "wrong_count": 13, "correct_count": 16 }, { "wrong_count": 16, "correct_count": 9 }, { "wrong_count": 16, "correct_count": 10 }, { "wrong_count": 14, "correct_count": 8 }, { "wrong_count": 14, "correct_count": 10 }, { "wrong_count": 0, "correct_count": 21 } ] }
-
total_point
: 合計得点(Null許容int
型)
大問3の結果送信時に合計得点の更新をします。大問3が終わるまで(初期値)はnull
です。
コード(ここらへんはネストが深かったり、UIとロジックの分離が全然できていないので 相当アウトなコードですが…)
final result = QuestionsResult(
items: <QuestionResult>[
// 今までの結果
...user.result.items,
// 新しく追加する配列
...ref.read(counterProvider.notifier).items,
],
);
final res2 = await supabase
.from('users')
.update(<String, dynamic>{
'result': result.toJson(),
if (result.items.length == 9)
'total_point': result.items.map((e) => e.toPoint).reduce(
(value, element) => value + element,
),
})
.eq('id', user.id)
.execute();
dartのMap内でif使って条件分岐できるの、めちゃくちゃ気持ち良いですね
Git/GitHub
相変わらず便利です… 万が一パソコンが逝ったら… の心配をしなくて良いの 本当にありがたいですね。急いでコーディングしていたらコミット/プッシュするのをすっかり忘れていてあまり意味がなかったのですが
公開しちゃマズイコード(問題文をハードコーディングしちゃっていて、権利的に不安)を省いたものをこのRepoに置いておきました。気が向いたらご覧ください。
Codemagic
Macなんていうイケてるデバイスを持っていない私にとってまさに神のサービスです(去年からお世話になっています)
無料利用制限枠はあるものの、iOS向けにビルドをしたり、SSH/VNCしてMac Miniを操作出来ちゃうんです。本当にありがたいですね…(Mac欲しいよ〜〜!!!!)
Codemagicを用いてビルドしたアプリケーションをiOS実機にインストール(Windows)する過程はこんな感じです。
- Codemagicでビルドするとzipファイルで出力されるので、解凍した中身をPayloadフォルダ(新規作成)に入れる
- Payloadフォルダをもう一度zip圧縮して、拡張子を
.ipa
に変える -
Sideloadlyというサイドロード用のツールを使ってiOSにインストール
(無料署名だと1週間で起動できなくなります。詳しくは別の記事をお探しください。)
利用したデバイス
- 開発機(緊急時対応)
- Surface laptop2 (Windows 11 Corei5-8250U 8GB 私物)
- Admin
- iPad第8世代(iPadOS 15.0 私物)
- Surface Pro 7(Windows 11 クラスメイト)
- Projector
- Surface laptop 2(Windows 10 クラスメイト)
- Surface Pro 7(Windows 11 クラスメイト)
- ThinkPad X13 Gen1(Windows 10 クラスメイト)
- Controller (予備含む)
- Xiaomi Mi Note 10(Android 12 私物)
- Nexus 6P (Andorid 8.1.0 私物)
- Moto g7(Android 10 私物)
- Xperia X Performance(Android 8.0.0 私物)
- iPhone 7(iOS 14.5 私物)
- 結果表示
- HP envy(Windows 11 クラスメイト)
- HP envy(Windows 11 クラスメイト)
開発の流れ
夏休み前 〜夢を見る〜
主に、システム設計を進めていました。当時の日記によるとB4ルーズリーフ 両面4枚くらい書いていたらしいですね。さすが授業中も準備を進める暇人!!(勉強? ヤバいです!!)
この時は、サーバサイドAPIもPythonかNode.jsあたりで実装して、クラス内にRaspberry Pi 4を置いてWiFi経由でAPIを叩く/WebSocket接続するようにするつもりでした。クラス内で全てが完結するので分かりやすいですし。
殴り書きです。まあ正直自分しか読まないので自分が読めればヨシ!
夏休み中 〜少しずつ前へ〜
学校の夏期講習(午前中のみ)が合計14日ありました。その日の午後数時間くらいをクラス企画の開発に費やしました。
夏休み前半
前述した通り、APIもオンプレミスで運用するつもりだったので、Node.jsでAPI建ててみたり 触ったことがなかったGoを学んでみたり…
でもやっぱりクラウドでやってみたくなってCloudflare Workers上で動くHonoというフレームワークを使ってみたり…
こういう、時間にあまり追われずに色々試行錯誤しながら開発して 色々吸収できる時間はとても幸せに感じます。(悪い言い方をすれば、右往左往しているわけですが)
この日はDartでAPIサーバを建てようとしていました
夏休み後半
この頃になってバックエンドも開発している時間が無くなってきた(正確にはそこに時間を割きたくなくなった)のでもうSaaSに任せようと、An open source Firebase alternative
を謳っていて、利用経験のあるSupabaseを使うことにしました。
PostgreSQLやKongが裏で動いていて、セルフホストもできます。3
Supabase Cloud(AWS上)の無料枠は制限があるので、超えそうになったらセルフホストすればどうにかなるだろ! の勢いで利用を決定しました。(結局余裕で無料枠に収まったので良かったです)
(ちなみに、なぜFirebaseを利用しなかったのかというとFlutter(Windows向けビルド)でも使いたかったからです。動くのかどうか詳しく分からないのですが、その検証に時間をあまり費やしたくなかったので却下しました。)
これは 進捗ヤバイ! になっていた時ですね
夏休み明け 〜終わらないのではという恐怖により全力開発フェーズに突入〜
放課後に準備をする人がだんだん増えてきて、「ついに文化祭が近づいてきたんだな…」と実感するようになります。
ちなみに、システムは全然実装が終わっていません。ようやくここで危機感を覚えます。放課後や家に帰ってからの時間を開発時間に割り当て、自分のフルパワーで開発を進めていきます。
ちなみに、実際に運用した仕様が ガッチリ固まったのは文化祭の4日ほど前でした。
文化祭前日準備 〜終わりが見える〜
この段階でようやく動く程度のシステムを完成させました。
クラス備え付けのプロジェクターで動作チェック
コントローラや結果表示の動作チェック
当日の状況
発生した問題と解決策
1. 数式がぶっ飛んでる!(1日目11時頃発生)
プロジェクターでは、極限記号や積分記号を利用するような数学の問題文も表示しないといけません。なので、LaTeX
を表示できるライブラリ flutter_math_fork
を利用していました。
問題文はなるべく大きな文字で表示したいですよね! 大きい方が読みやすいですもの!(プロジェクターの問題で解像度/輝度が低いという問題もありましたし)
そのために、数式表示のフォントサイズはoverflowするくらい大きくして、FittedBox
でラップしていました。これにより、横方向にはOverflowしないようになりました。ヨシ!
(現場猫風)
お気づきでしょうか。これだと問題文が短い時に文字サイズがめちゃデカくなっちゃう!
そう。その通りです。僕は気がつきませんでした。
数学(高3向け)の大問3-1で問題が発生しました。問題文はこれです。
$$
\sum_{n=1}^{\infty}\frac{1}{3^n} は?
$$
問題文が短いお陰で、問題文の一部・選択肢・プログレスバーが表示されない問題が発生しました。この問題を認知してから応急処置としてこの問題文を含む 数学-高3向けを選ぶのをやめてもらいました。
このスクリーンショットは1日目のお昼休憩(12時〜13時)に検証した時のものです。
「ウワー ヤッチモータ!!」な気分でしたね…これは…(答えは$\frac{1}{2}$です。)
対処
この問題をうまく解決する手法がなかなか思いつかなかったので、フォントサイズを一律で小さめにすることでGET KOTONAKI
しました。プロジェクターに表示される文字が小さくて読みにくくなってしまいますが、仕方なし…(1日目12:50頃解消 - 午後の部開始まであと10分! 昼ご飯食べる時間なんてない! 食べません!)
(無意味なコードがいくつか紛れ込んでいますが、どうかお気になさらず…)
Flutterのレイアウト関連のWidgetに対する理解不足を深く実感しました。要勉強。
(Flutterで文字をうまく表示させる手段 知らないので早めに調べたい)
2. 選んだ問題と違う!!(2回発生)
1回目 Controllerの設定した大問位置が違う! の巻
Controllerは、自分の端末がどの大問に対して設定を更新するか(どのstateをいじるか)を起動時に選択します。しかし、何らかの問題でアプリが落ちたときに4私が焦って設定をミスってしまったようです…
対処
とりあえず、問題がズレてしまった時にライドに乗っていた人は登録からやり直してもらいました。
システム全体に関わる変更は指差し確認ヨシ!
をするように徹底しました。
本質は、注釈にも書いたけれど 非同期処理を含むボタンの連打対策はしようね という話。
すぐにコードを修正してリビルドするのは無理だったので、シフトに入っているクラスメイトに「連打しないでくれ〜!」と口酸っぱく伝えておいた。GET KOTONAKI
。
2回目 Controllerのユーザ選択がズレている! の巻
Controllerは大問1つ終わるたびに 移動後のユーザの位置をデータベースに登録する設計になっています。(当初はデータベースが自動で位置移動処理を行うようにするつもりでしたがバグりそうだったので却下)
じゃあControllerは何を見てユーザーの位置登録をするのかというと各ユーザーが乗っているライドのIDと結果用紙です。ユーザー登録時にユーザーIDとライドIDをメモしてもらい、ライドの移動に合わせて紙も移動していき、Controllerは紙の情報とライドIDが一致しているかを確認してユーザーの位置登録をしてもらいます。
のはずだったのですが、何らかの影響で紙とライドの位置がズレてしまい、現場混乱。
シフトに入っているクラスメイト「なんか、結果用紙とライドIDが合致しないんだけど…」
ワイ「う〜ん 登録ミスっちゃったかな?
結果用紙が合っているはずなので、紙のを入力しちゃって!」
シフトに入っているクラスメイト「でも……」
ワイ「いいよ! 多分合ってる!」
(数分後…)
クルー「なんか問題がズレてるっぽいっす…」
ワイ「ギャー! なんで! なんで!(アッ)」
100%僕のせいでした…(本当に申し訳ない)
対処
1回目と同様に問題文がズレてしまった時にライドに乗っていた人はもう1周して登録からやり直し。
Controllerを操作しているクラスメイトには、登録前にユーザーIDがちゃんと連番になっているかを確認してもらうようにしました。(ユーザー登録時に発行されるユーザーIDは連番なので)
どうにかGET KOTONAKI
。
あと、私は障害発生時に落ち着いて対応することを肝に銘じました。急いじゃダメ ゼッタイ。
サーバ監視でお世話になっているGrafanaで得点分布を可視化してみました。数分でこういうことができてしまうのがデジタルのメリット!
最終的な来場グループ数は174、平均得点は673でした。
感想(ポエム)
正直、このシステム開発で犠牲にしたものは大きかったです。合計開発時間は59時間13分。(wakatime入れておいて良かった)
文化祭2日間も、ずっとクラスで障害対応、ログ監視、プログラム修正等していました。当然、他のクラス企画を見に行っているヒマなんぞありません。
文化祭が終わった日の夜に、友達が文化祭を全力で楽しんでいた話を聞いて、「自分も他のクラス企画を見て もっと楽しみたかった… なぜ自分だけが…」と悩む時もありました。
でも、この文化祭で全てを懸けて開発したからこそ見れた世界、新たな関わり、経験があったと思います。これは、全力でやらずに途中でサボっていたら得ることが出来なかったと思います。
そういう観点で言えば、私はこの学校の誰よりも全力で楽しむことができたと思います。
感謝
そう、感謝でいっぱいです。
- この分かりにくく、バグりがちなシステムに対して誰一人として文句を言わずにシフトに入ってくれたクラスメイト
- 文化祭/文化祭準備後に一緒に帰り、楽しい話ができた 1年の頃からの友達
- Fusion360を使って全体設計をした上に、ソフトウェア設計について議論してくれた⬛︎⬛︎⬛︎さん
- 全てを仕切って、先導してくれたクラス企画幹部のみなさん
- 感染対策をした上での文化祭実施を決断してくれた文化祭実行委員会の方々
- 企画に来て、「スゴい!」「デザイン良いね!」と言ってくださった来場者のみなさん
- 文化祭準備中なのに ひとりで"ぱそこんぽちぽち"を許してくださった担任/副担任の先生
一見、一人で開発していたように思われたこのプロジェクトも多くの人に支えられていたことに気が付きました。
全ての関係者に感謝しています。
みんなに"スゴイ!"とか"デザインいいね!"と言ってもらえるの あまりに幸せすぎて疲れ 全て吹っ飛んだ
— ./もぐもぐ (@YumNumm) September 10, 2022
ありがとう 僕は幸せだ
おそらく、これが高校生活の中で最後に開発するアプリケーションです。
高校生のうちにプログラミングを始めておいて良かったと感じる時は、多々あります。
例えば、お金。このプロジェクトを通じて、お金を稼ぐためではなく、純粋なスキルアップ/自分の欲しいソフトウェアを創ることを目的としたプログラミングができました。
私は将来、お金を得るためではなく、多くの人の暮らしを便利にしたい 自分の欲しいソフトウェアを創る という目的/目標意識を持って、プログラミングと向き合っていきたいと思っています。
そして、これが 色々な会社さんを調べている内に「ここなら 成長し続けられる/スキルアップできる」と感じた株式会社ゆめみさん で働きたいと思っている理由の1つでもあります。
学生の今だからこその 目的意識、観点を持てたのは、大きなメリットだと思います。
最後の方はメンヘラポエムみたいな記事になってしまいました。
ここまで読んでいただきありがとうございます。感想等あったらコメントをぜひお願いします!
-
9/17で18歳の誕生日を
迎えます迎えました。。時が経つのは早いなあ あっという間だ…誕生日プレゼントは常時受付中です!↩ -
中学卒業時に友達とディズニーランドに行く予定でした....が 無事にコロナウィルスのお陰でオジャンになりました。コロナ許さん!
高校卒業したら行きたいよ〜!!! ↩ -
冒頭で述べた地震観測・速報アプリケーション EQMonitorのバックエンドは、Supabase Cloudとセルフホストで処理を分散させています。 ↩
-
送信ボタンを2連打しちゃってasync処理の後に
Navigator.of(context).pop();
が2回コールされて画面が真っ黒になってしまったと推測。ボタン連打対策はしっかりしておくべき…(特にasync/await処理関連)
Do not use BuildContexts across async gaps.
↩