2022年12月10日から18日にかけて、公立はこだて未来大学で行われた、学内ハッカソン、p2hacksに参加しました。

成果物

同期の3人と出ました。

もともとWebアプリをつくることになるらしいと聞いていたのですが、なぜかAndroidアプリになったため、今回も自分がAndroidアプリを書くことになりました。
これで、自分が書いたアンドロイドアプリとしては3つ目になります。
1つ目は高校の卒検、2つ目は未来祭ハッカソンで作ったDepotify、3つ目がこれです。
2人がバックエンドをやって、自分がアプリをやり、1人がデザインやフロントとバックエンドのすり合わせをやってくれていました
今回はもともとPublicのレポジトリで開発を行っていたため、Githubのレポジトリが公開されています。

flyme-androidapp - Github

結果としてpixivさんの企業賞を頂くことになりました。ありがとうございます。

(さて、誰が僕でしょう?)

おことわり

前回のハッカソンの参加記事 は、「大変だけどいい感じに成長できた!次もがんばるぞ!」的な内容なんですが、今回は全くそんなことなくて、ただただ自分の反省が語られるだけの文章になる予定なので、興味のない人はここでブラウザバックするかタブを閉じるとよいです

開発振り返り

結局、フロントエンドのアプリは未完成のまま提出してしまったんですね。

デモ(発表)のときは、APIから提供されるデータを流し込む予定のComposableに、手動でデータをinjectして、デモデータとしていました。

1, 2日目

バイトでした

1日目のバイトが終わったらテーマが発表されていて、作るものも大体決まっていて、不思議な気持ちになりました。
2日目のバイトが終わると、バックエンドがいい感じに決まり始めていて、自分はほとんど動けていませんでした

3日目

デザインをやってくれていたメンバーからフロントエンドのデザイン案が出てきて、「え、天才か?」になりました。

めちゃくちゃいいデザインが出てきて、前回のカスのデザインを思い出して(自分がアプリを組みながら設計していた)、悲しくなりました。


(これは前回のデザイン)

あと、「え、このデザインを俺が実装すんの?」みたいな気持ちにもなりましたが、そこはすり合わせ(土下座)をしながらどうにかしました。

(これは泣きながらフロントで実装する予定だった場所を画像で置き換えてもらう会話)

文字とかchevronに関しては、ほんとにどうにかしました。

@Composable
fun AddingFriendsComposable() {
    Box(
        modifier = Modifier
    ) {
        Image(
            painter = painterResource(R.drawable.friendaddingbox),
            contentDescription = null
        )

        Row(
            modifier = Modifier
                .align(Alignment.BottomStart)
                .padding(start = 20.dp, end = 20.dp, bottom = 10.dp)
        ) {
            Text(
                text = "友だちを追加",
                fontSize = 32.sp,
                fontWeight = FontWeight.Bold,
            )
            Image(
                painter = painterResource(R.drawable.chevron_down),
                contentDescription = null,
                modifier = Modifier
                    .rotate(270F)
                    .size(45.dp)
            )
        }
    }
}

まあ本当にどうにかしてるだけです。Boxレイアウトの内部なので、Modifierで下に揃えていい感じにPaddingの値を設定しています。

4日目

4日目(12/12)に、はこだて未来大の1年次必修科目、「情報表現基礎」の課題があった+その課題のためのコードを1byteも書いていなかったため、3日目から徹夜で書きました。

眠くて死んでました
夜にライブラリを調べながらViewを書きました。

5日目

やっぱりViewを書いていました。
あと、プロダクトの性質上、バックグラウンドでGPS測位情報を取れると嬉しい気持ちになるわけですが、そこを調べるのにめちゃくちゃ手間取りました。
なんなら、今でもまだ正解がわかっていません。

6日目

かなり焦っています。
Home画面に必要なView (Composable)はあらかた作り終えたので、Walking画面に必要なものを揃え始めようとした矢先、やっぱりバックグラウンドでGPSを取ってくるのがかなり面倒な事に気づきます。それに、Google Maps for Composeは単純に情報が少なすぎて、どういう感じで使うといいのかがイマイチ掴めません。
公式のDocsを読んでみても、僕はカスの底辺プログラマなのでいまいちよく理解できませんでした。
とりあえず、この時点でアプリ内に現在地のマップを表示するところまでは実装しました。しかし、現在地のアップデートやそれにマップの表示を追従させるような処理はまだかけておらず、とりあえず1回だけ位置情報を持ってくることができるという状態です。

バックグラウンドでGPSを動かすためにServiceを実装してみましたが、思うように動かず、いろいろいじってみましたがnull pointerを参照するなど散々でした。 仕方ないので、バックグラウンド処理は一旦後回しにして、 Landing画面のViewやボタンを実装したりしていました。

この時点で各Viewを連携させるLogicや、ボタンのonClick属性などがTODOのまま放置されている状態が続いていて、かなりまずいと思い、とても焦っていました。

7, 8日目

バックグラウンド処理は一旦おいておいて、フォアグラウンドで位置情報のアップデートを取れるような処理を書くことにしました。
Request Location Updates - Android Developersに書いてある通り、FusedLocationClientのrequestLocationUpdateを用いるわけですが、Jetpack Composeのrememberとうまく組み合わせたりするような部分に触れた情報があまりなく、なんかうまい感じに実装したことだけ覚えています。
確か、GoogleMapのcameraStateがComposeに監視される変数であったため、位置情報更新時のコールバック関数に、cameraStateを更新する処理を仕込んだ覚えがあります。

これによって、現在地を継続して取得できるようになり、自分が移動したルートの記録ができるようになりました。それにより、移動距離の算出もできるようになったため、Walking画面でフロントエンド側が集めなければいけない情報はすべて集められるようになりました。
自分の現在地を表示するマーカーや、自分の移動経路を表示するラインなどを表示することができておらず、かなり妥協に妥協を重ねた結果となってしまったのが残念です。(後回しにしたけど、結局間に合わせられなかった)

ところで、Walking画面には一つ面白いView(Composable)があって、下の画面のように、おさんぽの状況を表示してくれるチケット(の半券)があります

Home画面で選んだチケットに対応する距離があってその距離に近づけば近づくほどロケットが右に向かって進む、いわばプログレスバーのようなものなのですが、これの実装は割と手間でした。

@Composable
fun ProgressBarComposable(
    modifier: Modifier = Modifier,
    minValue: Double = 0.0,
    maxValue: Double = 100.0,
    currentValue: Double
) {
    val fixedMaxValue = maxValue - minValue
    val fixedCurrentValue = currentValue - minValue
    val progress = fixedCurrentValue / fixedMaxValue

    BoxWithConstraints(
        modifier = modifier
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(5.dp)
                .clip(RoundedCornerShape(20.dp))
                .background(Color.Gray)
                .align(Alignment.CenterStart)
        )
        val screenWidth = with(LocalDensity.current) { constraints.maxWidth.toDp() }

        Box(
            modifier = Modifier
                .width(screenWidth * progress.toFloat())
                .height(5.dp)
                .clip(RoundedCornerShape(20.dp))
                .background(PrimaryWhite)
                .align(Alignment.CenterStart)
        )

        var rocketPos = screenWidth * progress.toFloat() - (screenWidth * 4 / 100)
        if (rocketPos <= 0.dp) {
            rocketPos = 0.dp
        }
        else if (rocketPos > screenWidth - (screenWidth * 10 / 100)) {
            rocketPos = screenWidth - (screenWidth * 10 / 100)
        }

        Image(
            painter = painterResource(R.drawable.rocket),
            contentDescription = null,
            modifier = Modifier
                .size(30.dp, 30.dp)
                .align(Alignment.CenterStart)
                .offset(x = rocketPos)
                .rotate(90f)
        )
    }
}

こんな感じのコードになっていて、なんか宣言的UIを書いているというより、Processingを書いているような印象が強いなぁと思いました。
プログレスバーを実装し始めたとき、そもそもBoxWithConstraintsというものの存在を知らず、Canvasを使って実装しようと思っていたのですが、相当面倒なことに気づいていろいろ調べた結果BoxWithConstraintsにたどり着きました。
特に意味はないですが、 $ \text{minValue} < \text{currentValue} $ であれば、 $ \text{minValue, currentValue} < 0 $ でも動くような実装になっていたりします。(まあなにも考えずとも、普通に実装すればそうなるか。)

いろいろ頑張った結果、とりあえずマップ部分は見た目以外いい感じに動くようになりました。あとは、APIとの通信ロジックをかければとりあえず人に見せられるレベルにはなるわけです。
ところで、

まずいです。

具体的に何が起こっているか説明すると、INTERNETのpermissionはManifestに書かれているわけですが、APIにリクエストを投げるとpermissionエラーで怒られます。
結局その部分のエラーはpermissionが原因なのではなく、APIへGETリクエストを投げて、その返答を受ける型がおかしかったのが原因だったわけですが、それならそもそも「permission denied」の文字列を出さないでくれ。
これでどうにかアプリからログイン処理を行うことができるようになりましたが、画面遷移のためのコードがどうにもうまく行きません。
Jetpack Composeの思想として、stateは上位で持ち、下位では上位から渡された関数をButtonなどで呼び出すことで上位のstateに影響を及ぼすというのが基本です。
しかし、非同期通信のあたりをよく理解していなかったために、サーバーからのレスポンスをawaitしたい部分でawaitできず、うまく上位に情報を伝播させられないということをやらかしました。

@Composable
fun LogInFields(
    viewModel: LandingScreenViewModel,
    onSignUpNavClicked: () -> Unit
) {
    var logInUserId by remember { mutableStateOf("") }
    var logInPassword by remember { mutableStateOf("") }
    val thisContext = LocalContext.current

    Column(
        modifier = Modifier.fillMaxWidth(),
        verticalArrangement = Arrangement.Top,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        //省略

        Button(
            onClick = {
                viewModel.tryLogIn(logInUserId, logInPassword, thisContext)
                thisContext.startActivity(
                    Intent(thisContext, HomeActivity::class.java)
                )
                (thisContext as Activity).finish()
            },
            colors = ButtonDefaults.buttonColors(
                backgroundColor = Color.Transparent,
            ),
            modifier = Modifier.fillMaxWidth()
        ) {
            Row (
                horizontalArrangement = Arrangement.End,
                verticalAlignment = Alignment.CenterVertically,
                modifier = Modifier.fillMaxWidth()
            ) {
                Text("ログイン")
                Image(
                    painter = painterResource(R.drawable.chevron_down),
                    contentDescription = null,
                    modifier = Modifier
                        .rotate(270F)
                        .size(15.dp),
                    contentScale = ContentScale.Fit
                )
            }
        }
        
        //以下省略
}

ということで、上のような激ヤバコードを書いたわけですが、激ヤバポイントをかんたんに解説します。

まず1つ目に、ViewModelにContextを渡すコードがありますが、これはアンチパターンの一つと言われています。
なぜなら、ViewModelはComposableやActivityと別のライフサイクルを持っており、thisContextと命名して渡しているContextがnullである可能性があるからです。
次に、tryLogInという関数を呼んでいるわりに、LogInの成功と失敗を管理していません。つまり、失敗しようが成功しようが必ずホーム画面に遷移するようになっています。まあこれに関してはアプリ開発のなかでやることもあるので一概に激ヤバとは言えないですが、そもそもこれは提出したコードなので、激ヤバポイントの一つとして取り上げることにします。

結局、未完成のままコードフリーズを迎えたのが12:50とかだったような気がしています。

Final Commit

ぶっちゃけここで終われば良かったんですが、デモ用にダミーデータをinjectionする作業がまだ残っていて、昼ご飯も食べず、ComposableをColumnに配置する仕事をやっていました。
ただ、injectionするだけでいいような設計にしていたのは過去の自分を褒めたいです。(APIからデータを持ってくる以上、あたりまえでは?)

感想

今回のハッカソンの感想は割と簡単に一言に集約できて、「辛い」という一言です。

今回のフロントエンドはかなりchallengingなものでした。
デザイン面は前回自分が書いたDepotifyより大幅にアップグレードされたものとなりましたし、アプリの機能要件自体も前回より圧倒的にキツイです。

これを踏まえてハッカソン終了後に「もし次ハッカソンに出るならAndroidかける人間をもう一人呼んだほうが良さそう」という意見がありましたが、これに関しては反論があります。
まず、今回のアプリの最低限の機能要件については一週間で実装しきれるものであったと思います。
さらに言うと、一週間ないし3, 4日で実装しきれてもおかしくないものであったと考えています。
Githubのcommit履歴より、およそ3~4日間で2000行ほどコードを書いており、commitしていないトライアンドエラーのコードを含めると3000行近く書いていることになります。この、トライアンドエラーの部分を減らすことによって、開発スピードを更に向上させることや、デザイナーやバックエンドの要望に応えることができたのかなぁと反省しています。

実装しきれていないのは単純に自分の実力不足であるところが大きく、Android(Jetpack Compose)におけるアーキテクチャや、非同期処理、バックグラウンド処理などの知識が根本的に足りていなかったことに起因するものであると思います。

アーキテクチャの面で言うと、このアプリは3つのActivity(Landing, Home, Walking)で構成されており、一応ファイルもそういう感じにわけてあったりしました。

しかし、ファイル分けだったりディレクトリの切り方が適切であったかと言われると正直かなり微妙で、「これでいいのか?」と思うところはかなりありました。

さらに、ファイルの命名規則なども悪い点がいくつかあって、例えば上のHomeScreenパッケージ下にTicketsというファイルがあります。
これはHome画面、上の方に表示されるおさんぽ距離を選択するチケットを表示する処理をまとめたファイルなのですが、これとは別に、Walking画面で使うWalkingTicketsというファイルがWalkingScreenパッケージ以下に存在します。


(HomeScreenに出てくるTicketがこれ。WalkingTicketsは、先程プログレスバーを実装したときに出てきたやつです。)

そのために、開発中、「あれ、どっちがどっちだっけ」みたいになることがありました。

また、非同期処理やバックグランド処理などに関しては、本当にトライアンドエラーが多すぎて、試行回数は多いが、結果を残せなかったという状態だったのが悲しいです。
同じようなことを様々な方法で実装することができますが、方法AとBでは制約が違ったりして、「このアプリにはAとBどちらを選べばよいだろう?」というのが様々発生するのが辛いです。
で、選択をミスって、Bを実装すべきところでAを実装して、実装しきってから「あ、これじゃだめじゃん」と気づくことを何回もやりました。

反省

反省もなにも、反省すべきところしかないですね。
そもそもAndroidアプリの開発経験が浅いため、知識も浅く、もっと様々な知識を得る必要があるなぁと思いました。
そんなわけで、Jetpack Composeに関して解説した本は見つけられませんでしたが、今回問題になっている部分はJetpack Compose以前からある部分が大半なので、参考にと本を借りました。(これをハッカソン中にやれば良かったのでは?)

今回のハッカソンでいろいろ思うところはありましたが、やっぱり一番はメンバーに申し訳なかったなあと思います。
バックエンドやデザインがいい感じにやってくれていたなかで、自分だけが開発スケジュールから大きく外れる形となり、最終日は胃を痛くしながら、本当に無心でキーボードを叩き続けていました。
もし次回があるなら今回のようなことは絶対に避けたいですし、今回の開発要件程度ならやはり期間中に提出できるレベルまでは組み上げたいものです。
自分の実力としてまだまだ難しい点もありますが、今回の失敗から何かを得ることができたらいいなぁと考えて、この記事を書いています。

謝辞

最後にはなりますが、今回のハッカソンの開催に携わった学部3, 4年の先輩方、また、スポンサーなどで協賛いただいた企業の方々に深く感謝します。
学部1, 2年からこのような開発に触れることができる機会というのは少なく、未来大では高度ICT演習などしかないため、このような学内ハッカソンの存在はとてもありがたいなぁと感じました。
このようなイベントを何らかの形で存続させていけると良いなぁと思っています。

🙌🙌🙌