2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ガチャで映画を選べるアプリ【プライム名人】のリリースにあたって考えたこと(技術編)

Last updated at Posted at 2024-05-13

作ったアプリ

jp.prime-meijin.com_(iPhone SE).png
jp.prime-meijin.com_(iPhone SE) (2).png
jp.prime-meijin.com_(iPhone SE) (1).png

プライム名人は、Amazon Prime Videoからガチャで映画を選べるアプリです。人気映画や過去に見た映画の類似作ばかりサジェストされマンネリ気味の今日このごろ。ランダムに選ぶことで思わぬ掘り出し物に出会おうというコンセプトのアプリです。

この記事ではアプリについて技術的に考えたことをまとめています。プロの方からするとツッコミどころ満載かもしれませんが、素人が試行錯誤したログとして残しておきます。
アプリの機能については 機能編 にまとめたのでそちらも読んでいただけると嬉しいです。

考えたこと

使用技術の選定

フロント、バックエンドの技術を選定しました。片っ端から検索して、できるだけ簡単そうに使えそうなものを選びました。一般論としての「正解」にこだわらず、個人開発では好きなものを使えばよいと思います。

  • バックエンド
    単にデータがほしいだけ → Firestore Databaseだけで実現できそう。無料のSpark Planで十分。
  • フロントエンド
    主要フロントエンドフレームワークの中で一番簡単と言われるVue.jsを採用。UIはVuetifyを採用。

プロトタイプの作成

Firestore Databaseからデータを取得し、Vue.jsで表示できることを確認できればアプリはできたも同然です。最小限動作することを確認するためのプロトタイプを作成しました。

Firestore Databaseについては、実践FirestoreCloud Firestoreを実践投入するにあたって考えたことを参考にさせていただき、スクラップアンドビルドをして構造を決めました。Webアプリの場合プログラムを変えるのは簡単ですが、データベースの構造を変えるのは大変なのでデータが少ないうちに揉んでおくとよいです。

Firebase Javascript SDKのAPI KeyやFirebaseドキュメントへのパスは公開情報です。開発者ツールで誰でも見られますし、隠すことは不可能ですし、それで問題ありません。DBの操作権限の制限はセキュリティルールで、不正なリクエストの対策はApp Checkで行います。

App.vue
<script setup>
/* imports (省略) */

const firebaseApp = initializeApp({
  apiKey: "AIzaSyBGGlU1XazYUb-5y3X77UMa4ld_mEBSKps",
  /* 省略 */
});
const db = getFirestore(firebaseApp);

const movie = ref({});
function onClick() {
  const q = query(
    collection(db, 'public/v1/movies'),
    where("available", "==", true),
    where("random", ">=", Math.random()),
    orderBy("random"),
    limit(1)
  );
  getDocs(q).then((data) => {
    movie.value = data.docs[0].data();
  });
}
</script>

<template>
  <button v-on:click="onClick">ガチャを回す</button>
  <p>{{ movie.title }}</p>
  <p>{{ movie.star }} ({{ movie.reviewer }})</p>
  <img v-bind:src="movie.img">
</template>

<style scoped></style>


殺風景ですが一応動くことが確認できました。

開発に着手

ヘッダ、メイン、フッタの枠だけ用意して、あとはガチャ部分からボトムアップで開発しました。

  • ガチャのSVG画像をアニメーションさせ、完了時にイベントを発火できることを確認(v-on:animationend)
  • イベントが発火するとカード(映画情報)が表示できることを確認
    and so on...

まずは1つのコンポーネントに処理やテンプレートを書いて動くことを確認し、動いたらコンポーネントを分割したりComposable Functionsに切り出していきました。動かす→コード整理→動かす→コード整理のループを繰り返しました。

  • 主要なコンポーネント(包含関係)
App
  ┗ HeaderComponent // ヘッダ
  ┗ FooterComponent // フッタ
  ┗ MainComponent // メインコンテンツ
    ┗ GachaComponent // ガチャ機能
      ┗ WalletComponent // コイン残高
      ┗ MachineComponent // ガチャの機械
      ┗ StartButtonComponent // スタートボタン
      ┗ MovieViewComponent // 映画情報表示
  • 主要なComposable Functions
useCoinState() // コイン数の管理
useMovie() // ランダムに映画を抽出、総映画数の取得

useMovieはRefを内部に持たないのでたぶんComposable Functionsとは言わないのですが、使用感を統一するためこのような命名にしています。ストレージへの保存などコンポーネントとは直接関係ないロジックを切り出すことでコンポーネントをすっきりさせました。

1つのコンポーネントが完成したら、それレスポンシブに対応して表示できるようスタイルを当てました。ブラウザを縮めて極端に細長くしたような場合はあきらめ、ほどほどで妥協しました。

クロスブラウザ対応

ブラウザ毎に表示が違ったのは昔の話、今はどのブラウザもほとんど同じ。
...そう思っていた時代が私にもありました。結果は以下の通りです。

Chromeでの表示を維持したまま、Safari, Firefoxでも綺麗に表示されるようCSSを修正していきました。極力共通のCSSでどのブラウザにも対応できるようにしましたが、Firefoxだけはどうしてもうまくいかず一部CSSハックを使いました。

初心者の場合、いきなりすべてのブラウザで表示できるようにするより、まずは1つのブラウザでちゃんと表示できるようにしてから修正するほうがいいと思います。私にとっては1つのブラウザできれいに表示するだけでもあっぷあっぷでした。

最後の仕上げ

reCAPTCHA Enterprise + App Checkを導入し、指定したドメイン以外からの実行時はFirebaseへの接続をブロックするようにしました。どちらも簡単に設定できました。アプリを公開する場合は両方とも必ず設定しましょう。

Firebase Hostingの場合、web.appfirebaseapp.comの2つドメインがもらえます。独自ドメインを設定しても、これらは消せません。独自ドメインに集約したかったのでリダイレクトするようにしました。

main.js
if (
  window.location.hostname === "jp-prime-meijin.web.app" ||
  window.location.hostname === "jp-prime-meijin.firebaseapp.com"
) {
  window.location.href = "https://jp.prime-meijin.com";
}

使ったテクニック

いいテクニックかどうかはわかりませんが、試行錯誤の末に至った内容です。

画像のプリロード

スタートボタンの画像は押す前、押した後の2つを用意し、タイマーで切り替えています。しっかり押したのがわかるようディレイをかけるためです。プリロードすることで初回の押下時のロードで引っかかりが生じるのを防げます。

PlayButtonComponent.vue
<script setup>
import playButton from '@/assets/img/play-button.svg';
import playButtonPressed from '@/assets/img/play-button-pressed.svg';

onMounted(() => {
  // Preload image
  const image = new Image();
  image.src = playButtonPressed;
});

/* 省略 */
</script>

コンポーネントの構造

コンポーネントのトップレベルの要素にはコンポーネントと同名のクラスをつけるようにしました。コンポーネント内部についてのスタイル(display: flex等)はコンポーネント内に書いて、外から決めたいスタイル(height等)は親コンポーネントに書きました。

ParentComponent.vue
<template>
  <ChildComponent></ChildComponent>
</template>

<style scoped>
.ChildComponent {
  height: 30px;
}
</style>
ChildComponent.vue
<template>
  <div class="ChildComponent">
    <!-- Some elements... -->
  </div>
</template>

<style scoped>
.ChildComponent {
  display: flex;
  flex-direction: column;
  /* Some styles... */
}
</style>

Composable Functionsにおける Watcher vs Method

変数の変更に伴い副作用を実行したい場合は、原則としてWatcherを使います。しかし、Watcherを使うと複雑になる場合はWatcherのかわりにsetterを用意してそこで処理を行うようにしました。下記の例をWatcherでやると、countが0かそれ以外かで条件分岐が必要です。

counter.js
export function useCounter() {
  const _count = ref(0);

  function increment() {
    console.log("increment");
    _count.value++;
  }

  function reset() {
    console.log("reset");
    _count.value = 0;
  }
  
  // readonlyにしないと呼び出し側から書き換えられてしまい、その際に副作用が実行されない
  return {
    count: readonly(_count), 
    increment,
    reset,
  };
}
App.vue
<script setup>
import { useCount } from "./composables/counter";

const counter = useCounter();
counter.increment();
</script>

<template>
  <p>counter: {{ counter.count }}</p>
  <!-- 1 が表示される -->
</template>

妥協したところ

ログイン機能をオミットしたので、コイン数の管理はLocalStorageで行っています。ストレージをクリアすれば簡単に満タンにできます。Spark Planなのでいくら使われても課金されることはないので問題ないかなと。

ちなみに、とある大手素材サイトでは無料会員は検索回数が制限されています。しかし、URLのクエリパラメータにキーワードをいれると検索できてしまいます(やってはいけません)。個人情報など守るべき部分はちゃんと守らないといけませんが、クリティカルでない部分は疑心暗鬼にならなくても良いと思います。

おわりに

記事を書くため改めてコードを見てみると、いろいろ変なところがあります。でも、完璧とは程遠いですが一応使えるレベルまで完成させることができたのは自信につながりました。次回作は決まっているので早速取り掛かりたいと思います。

機能については 機能編 にまとめたのでそちらも読んでいただけると嬉しいです。

最後まで読んでいただきありがとうございました。興味を持っていただいた方は使ってみてください。

2
2
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
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?