👋

【個人開発】子供のためにポケモン図鑑を作りました

2023/04/08に公開

初めまして。ゴン中山と申します。

今回は子供の困りごとを解決するアプリを個人開発してみました。

本記事では、

  • 開発背景
  • どういう技術を使ってアプリを作ったか?
  • 個人開発をやってみての学び

をメインで書いていきたいと思います。

開発背景

  • 私自身の根底には「(いずれ)個人開発で食っていきたい」という野心があります
  • ただし、どうすればそれを実現することができるか?については調べても答えが出るものではないので、沢山自分でサービスを作って、現実ユーザと接点を持って、その感触を信じて動こうと思いました
  • とはいえ、これまでも幾つかの個人開発プロダクト自体は生み出してはいました。が、いずれも中途半端で終了しており、今回からは小さくてもいいからサービスをちゃんと作って、一人でもいいからユーザに使ってもらえるようなサービスを一つ作ろうと思いました

で、何を作るのか?を考える

  • 個人開発で一番悩むのは「じゃあ何作ろうか?」ではないでしょうか?
  • やる気はあるのに「何作る?」の題材探しに時間を食ってしまって、その内やる気が失せてしまう現象を何度か経験したことがあります
  • 今回、私が考えたのは「身近な人の困りごと」を解決する「何か」を作ろうでした
  • ありきたりですが、身近な人だと、サービス仕様についていつでも気軽に確認できるので開発期間短縮にも繋がります
  • また、サービスについての「フィードバック」を忖度なしにやってくれる人がいいなと考えた時に真っ先に思いついたのが「家族(自分の子供)」でした

子供の解決したい課題とサービスが提供する体験について

  • 子供は無類のポケモン好き
    • 特にメザスタが好き
  • メザスタでポケモンを捕まえる際、「鳴き声」から「どのポケモンか?」をスピーディに調べられる必要がありました
    • これが子供の解決したい課題です
  • そこで、「10秒以内に知りたいポケモンの鳴き声を聞くことができる体験を提供するサービス」を考えつきました
    • 初期画面の表示までに5秒
    • 検索からの抽出からの鳴き声再生までに5秒

を目標にしました。

ちょっと待って。それYouTubeで代替できるんじゃない?

確かに、YouTubeにもポケモンの鳴き声はあります。が、プレミアムプランなんかに加入していない限りは、広告再生からの鳴き声再生になります。運が悪ければ1-2秒の鳴き声を確認するために、15秒程度の広告を閲覧しなければならず、メザスタでレアポケモンを逃す可能性さえありました。

というわけで、既存のもので代替できないか?については「できない」という結論で問題なしです。

技術面

フロントエンドどうするか

  • 今回作成するポケモン図鑑はトップページで、ID順にポケモン名/ポケモンの画像を表示しようと思っていました
  • 画像がメインコンテンツになりそう・・・というのが見えていたので、自動的に画像の圧縮やWebP形式への変換などの最適化を行ってくれて、サイトのパフォーマンスをいい感じにしてくれるNext.jsを選択しました
  • また、SSGもサポートしてますので、ビルド時に画像を生成しておくことでページ読み込み速度も極限まで短縮できるのではという狙いがありました
  • ルーティングの支援がある点も選択した一つの理由です

アプリを動かす基盤をどうするか

  • スピーディにアプリを開発してユーザに使ってもらいたかったので、インフラ面の構築などはやりたくありませんでした
  • なおかつ、フロントエンドは「Next.jsでいくゾー」と決めたので、迷わずVercelを選択しました

開発ルールについて

  • ソースは1ファイル100行に収まるようにする
    • 増えそうならば、関数なりコンポーネントなりに切り出す
  • eslintとprettierを導入する
  • ts-ignore系は禁止。anyも禁止。どうしても使いたい場合は理由コメントを必須とした
    • eslintでエラーが出るようにしている

スタイル(CSS)戦略

以下の二択かなーっと思っていました。

  1. CSS Modules
  2. CSS in JS

CSS Modules

  • JavaScriptを用いて、CSSのローカルスコープを自然な感じで使えるようにしたもの
  • グローバルなCSSの名前空間をコンポーネントごとにlocalに扱えるようにする仕組み
/* style.css */

.button {
    margin-top: 10px;
}

.input {
    color: green;
}
/* Something.tsx */
import styles from "./style.css";

export const Sommething = () => {
  return (
    <div>
      <input type="text" className={styles.input} />
    </div>
  );
};

今回は小規模なアプリなので、CSS Modulesを使うまでもないかなという判断で不採用。

CSS in JS

  • CSSとHTMLは共に見た目を構成する要素であるので、単一コンポーネントで管理しようよ、という思想の下、誕生した
  • JSXの中にスタイルを直接定義する
import React from "react";
import styled from "styled-components";

const Container = styled.div`
  background-color: #ffffff;
  padding: 16px;
  border: 1px solid #cccccc;
  border-radius: 4px;
`;

const Title = styled.h1`
  font-size: 24px;
  font-weight: bold;
  margin-bottom: 16px;
`;

export const Something = () => {
  return (
    <Container>
      <Title>Hello, world!</Title>
    </Container>
  );
}

そもそもですが、単一ファイルコンポーネントのようなプラクティスは

  • ソースコードの行数が単純に増加してしまい、1ファイルあたりの見通しが悪くなる
  • 冒頭で述べたように開発ルールとして1ファイル100行を思想としているので、CSS in JSを使うとだいぶ厳しい(可読性を最重視したかったのでやむなし。)

という理由から採用しませんでした。

というわけでスタイル戦略の結論

  • 今回作るアプリは小規模だし、普通にCSS定義する
  • ただし開発効率上、CSSフレームワークは使用する
  • 事前に定義されたCSSのクラス名を自由に組み合わせることでスタイリングを行うことができ、カスタマイズ性が高い Tailwind CSSを採用することにしました
    • 他のCSSフレームワークに比べて「カスタマイズ性が高い」から採用した・・・というよりはシンプルに巷で流行ってそうだからキャッチアップしたかった・・・という側面の方が強い・・・

ディレクトリ構成

  • アーキテクチャの背骨は「ディレクトリ構成」だと思っています
  • ソフトウェアは自然劣化はしないが、作り方次第で「廃墟」になってしまう可能性があります
    • 廃墟について
      • 誰もメンテできないようなプロダクト
      • 新機能を安全に追加できないプロダクト
      • しかも廃墟は伝播する
  • アプリケーションはメンテしやすく、新機能を追加しやすく、新しい人が加入しやすい状態にすべきです
  • そのために第一にやるべきは「ディレクトリ構成」を理解しやすくすることだと思いました
    • 思いましたというか、勉強会でそう学んだし、実感した

/src以下

以下のようになりました。

.
├── api
│   ├── api-client(API通信の共通処理を実装する場所)
│   ├── models(API通信のデータ型を定義する場所。この部分でフロントとAPIの方の生合成を取ることとなる。Factoryもココに作成する)
│   └── repositories(API通信のための具体的なロジックを実装する場所)
│       └── mock(モックサーバが立てられる環境なら不要。フロントだけでモックデータを用意したい場合はココに定義する)
├── assets(画像ファイルやアイコンなどを格納)
├── components(見た目に関わるファイルは全てココ)
│   ├── page(Next.jsのpagesと対応している)
│   │   ├── Detail
│   │   ├── Quiz
│   │   │   ├── Answer
│   │   │   ├── Index
│   │   │   └── Question
│   │   └── Top
│   └── ui(各所で用いるピュアなコンポーネントはココに定義)
│       ├── AbilityGraph
│       ├── Button
│       ├── Dialog
│       ├── Header
│       ├── KindBadge
│       ├── Layouts
│       └── PokemonQuizAnswerButton
├── constants
│   └── index.ts(アプリケーション全般で利用する定数を管理)
│
├── hooks
│   └── useXXXXXX.ts(各所で利用するカスタムhooksはココに定義)
│
├── pages(Next.jsのルーティング用のディレクトリ。責務はルーティングのみとし、UIに関する細かい実装はcomponents/page以下に記述し、それらをココでimportするような構成にしている)
│   ├── api
│   ├── form
│   └── pokemon
│       └── quiz
│           └── answer

一部、以下の記事を参考にさせていただきました!

https://zenn.dev/yoshiko/articles/32371c83e68cbe

/src/components/page以下

以下のようになりました。

.
├── TopContainer.tsx
├── Top.tsx
├── index.ts
└── util.ts

Container/Presentationalパターンとは、ロジックとUIを分けて実装することで関心の分離を図るフロントエンドのデザインパターンです。

以下の記事を参考にさせていただきました!

https://zenn.dev/buyselltech/articles/9460c75b7cd8d1

以下でそれぞれの説明をしていきます。

TopContainer.tsx

  • アプリケーションのロジックを担当します
    • ロジックとは状態であったり、API通信処理などを表します
  • UIに関わることには関心を持ちません(なのでCSSスタイルを持つこともない)
  • 今はhooksが使えて、恣意的な分割をせずともロジックとUIを分けることができるとはいえ、ContainerとPresentationalは明示的に分けた方が私が目指している「理解しやすい」アプリケーションになると思いました
  • Containerは内部で呼ぶhooks経由でデータを取得し、propsでpresentationalにデータを受け渡しする役割を担っています

Top.tsx

  • UIに関わることのみに関心を持ちます
  • UIに動的に表示することになる「データ群」は全て「props」で受け取ります
  • 状態を持つことは無いし、API通信処理や何かしらの関数を持つ事もありません

index.ts

TopContainerをexportするためのエントリポイントです。index.tsでexportすることで、下記のように外部からimportすることができます。

import { TopContainer } from "@/components/page/Top";

const Index = () => <TopContainer />;

export default Index;

util.ts

  • Container内で記述されるヘルパー関数などを外に切り剥がしたい時にutil.tsに定義するようにしました
  • 単一コンポーネントのように、見た目もJSもCSSも一つのファイルに記述するという思想もありますが、個人的には可読性の観点であまり好きではないので、一定以上の行数になる関数などもutil.tsに切り出すようにしました

なぜこういう風にレイヤー分けをするの?

「アプリケーションを理解しやすくする」ためです。これに尽きます。繰り返しますが、理由は以下です。

  1. メンテしやすいように
  2. 新規機能追加しやすいように
  3. いつでも人が加入しやすいように

また、UIとロジックが分離することで テストが実施しやすくなる んですよね。個人的にはこのメリットが一番大きいと思っています。

成果物(α版)

ファーストビュー

検索機能


ポケモン詳細画面

レンダリング方式に悩む

  • α版が完成し、無事アプリを使ってもらうまでは良かったんですが、Googleの分析・診断「Lighthouse」を実施したところ下記の結果となりました。ヤバすぎワロエナイ・・・

  • DOMの数が多過ぎるというのが最も減点に影響してそうでした
  • 2023/02月時点の総ポケモン数(1280体)をSSGでレンダリングして、トップページで返却していたので、DOM数が多すぎたようです
  • Imageの取得は遅延読み込みしていた(というか、Next.jsが画像の取り扱いはデフォルトで最適化してくれているので何もしなくてもそうなる)ものの、DOM自体は構築されるのでそりゃそうだよね・・・
  • ということで改善方法を検討しました
  • ちなみに、サイトパフォーマンスチューニングの基本は、「削る、圧縮する、遅延する、キャッシュする」だそうです

SSGからの脱却

  • そもそも画像データについては、Lazy Loadingにより必要になったタイミングでロードされますが、DOMについても必要になったタイミングで構築されれば良いのでは?と思いました
  • そこでSSGを捨て、いわゆる「無限スクロール」の手法を使えば解決できるのでは?と考えました
  • Next.js + Vercelの公式ドキュメントを探したところ、下記が見つかりました

https://swr.vercel.app/ja/docs/pagination

  • useSWRInfinite を利用して改修し、再度Lighthouse診断を実施したところ

完璧ではないとはいえ、かなり改善することができました。

学び

脳死状態でSSGなどを利用すると、アプリの要件によっては、私のようにサイトのパフォーマンスに重大な影響を及ぼす可能性があることを身を以て実感しました。

実際に早くリリースして子供からフィードバックを受けて良かったこと

実際に使ってもらうことで下記のようなニーズがあることが早い段階で分かりました。

  • パパ・・・予測変換機能が欲しいよ・・・

    • ポケモンの鳴き声がしてからお金を投入してモンスターボールを投げるまでに数十秒の猶予しかないですから、文字をタイプする時間を少しでも削りたいのは確かにーっと思った。
  • パパ・・・再生ボタンを押して鳴き声が発声ではなく、詳細画面を開いた瞬間に鳴き声が鳴った方がいいよお・・・

    • これも確かに。鳴き声を迅速に聞くことが目的のシステムなので、「鳴き声を聞くボタン」は不要だった。ポケモンの詳細画面を開いた瞬間に鳴き声が聞ければ、ロスタイムを少し削ることができる
  • オフライン環境でも動くようにしてあげた方がよさそう

    • 未就学児でスマートフォン持っている子なんて居ないですからね。大体親御さんの使っていない古いスマホをオフライン状態でカメラ代わりとかで使わせているケースが多いんじゃないでしょうか。そういったお子さんに対してはオフラインで動いた方が使ってくれる確率は上がりそうだなと感じた。
  • パパ・・・弱点も同時にわかった方が嬉しい・・・

    • これは追加機能ですね。GETしたポケモンが出現したら弱点が知りたいそうです。弱点が頭の中にバシッと入っている子は不要でしょうが、まああると便利そうですね。

ちなみにですが、個人開発だからといって、完璧なものを時間をかけて作って「はい、どうぞ!」って提供していた場合どうなっていたかを妄想してみると以下のような感じになるのではないでしょうか。

  • 最初から完璧を求め過ぎて、子供が使わない機能も相応に作ってしまっていたのではないか
  • 単純に「欲しい!」って言われてから相応に時間が経過していると「もうメザスタなんてやっていないよ」って状態になっている可能性

なので、必要最低限な機能を素早く提供して、素早くフィードバックをもらってその改善に素早く着手できたことは凄く良かったなあと思います。

  • 使わない機能が多い = アプリケーションの複雑性が増す = 改修しづらいプロダクトになる
  • アルファ版を早期にリリースすることで、アプリの改善点を早期に知ることができた。改善点を修正してベータ版/最終版をリリースすることができれば、最短距離で子供が求めているアプリケーションを提供することができる

という感じです。この辺の話は現在読了中の「ゾンビスクラムサバイバルガイド」でも沢山出てきます。ZennのScraps(スクラップ)機能で各章ごとに学びや知見を記録しており、完成したら公開しようと思っているので、こちらも是非読んでいただければと思います。

(ゾンビスクラムサバイバルガイドのステマみたいになっちゃいましたが、とても含蓄のある本であるとは感じます)

終わりに

  • 第二弾として、サッカー日記アプリを予定しています
    • 子供はサッカーを習っています
    • 練習がない日は仕事が終わってから二人でサッカーをしてますが、できるようになったこと/まだできないことなどを記録していくためにアプリを作ろうと思っています
  • サッカー日記アプリでは、リソースの更新があるのでサーバサイド側の技術選定も必要となります。その辺も併せてご紹介できる記事を作成予定ですのでよろしくお願いします

Discussion