目次
1.はじめに
2.開発アプリの紹介
3.結果
4.チーム開発の背景
5.チーム開発の流れ・その時私が意識していたこと
6.チームとしての良かった点・反省点
7.おわりに
1. はじめに
今回私は、学費無料の内定直結型学習サービス「アプレンティス」のカリキュラムにて2度目のチーム開発を経験しました。この記事は、今回のチーム開発についてまとめたものです。
アプレンティスの詳細については↓
2. 開発アプリの紹介
ビジョンボード作成アプリ: 「Weave」
本アプリは、ワクワクする未来を形にしたい方向けのプロダクトです。 自分の理想の未来を、写真と文章で視覚化することで、モチベーションを高めるなど、理想に近づく助けになります。
Githubリンク
フロントエンド:
バックエンド:
動作イメージ
操作説明
1 . ホームページを開くとテンプレート一覧が表示される(現在は1つのみ)ので選んでクリックすると作成画面に遷移します。
遷移するとチュートリアルが表示されます。チュートリアル後にもし迷った場合は右上の?マークからヘルプを確認することができます。
2 .黄色のボックスには文章を入力することができます。灰色のボックスには手持ちの好きな画像を登録できます。近くの項目に合わせて入力、画像登録をしてください。内容は自動的に保存されます。
3 .Previewボタンをクリックすると作成したビジョンボード一覧を表示できます。画像下にあるDownloadボタンをクリックすると該当のビジョンボード画像がブラウザにダウンロードされます。
ビジョンボード完成イメージ
3. 結果
残念ながら特に賞をもらうことはできませんでしたが、とても良い経験になりました。以下はその経験のまとめです。
4. チーム開発の背景
アプレンティスではカリキュラム内で2回チーム開発を行いますが、今回はその2回目です。チーム開発のテーマ・要件は以下の通りです。
- テーマ: 『ワクワクするものを開発せよ』
- 使用技術 HTML/CSS/MySQL/Rails or Laravel/React or Next.js
- Single Page Application (SPA) と API の構成でも、その構成にしなくてもOK
- また、必ずしも全ての技術を使う必要はない
- プレゼン後にメンター及び受講生全員で投票を行い、受講生からの評価が最も高いチームに「Best Student Award」、メンターからの評価が最も高いチームに「Best Award」を授与
チームメンバーについて
チームは3人で、スキル感は以下の通りです。
-
私
・実務未経験
・学習歴およそ7ヶ月 -
Nさん
・実務未経験
・学習歴およそ7ヶ月 -
Oさん
・実務未経験
・学習歴およそ1年
・Webデザイン学習経験有
全員、実務でのアプリ開発経験はありませんが、アプレンティスで1回のチーム開発を経験しています。
5. チーム開発の流れ・その時私が意識していたこと
チーム開発は大きく2つの期間に分けて行われました。
- 前半は、LaravelやNext.jsの勉強をこなしつつ週に2回ほどチームで集まって準備を行う事前準備期間(12/11~1/28)
- 後半は、各々の勉強はストップしチーム開発専念するチーム開発期間(1/29~2/4)
以下はそれぞれの期間の流れとその時私が意識していたことのまとめです。
5-1.事前準備期間
顔合わせ~アイデア決め
『ワクワクするものを開発せよ』というテーマで各々アイデアを考えて持ち寄り、話し合った結果、「ワクワク=将来の夢や理想の自分など未来に対する期待」という考えから着想した、ビジョンボードを作成するアプリに決定しました。
要件定義
アジャイル開発で用いられるインセプションデッキを作ってチームの共通認識と目標を固めつつ、アプリの機能要件についてまとめました。
インセプションデッキ
1. 私たちはなぜ、ここにいるのか?
- 自分たちの顧客は誰か?
- このプロジェクトが始まった理由は何か?
- なぜこのプロジェクトが必要なのか?
・自分たちの顧客
夢や理想・目標がある人
・このプロジェクトが始まった理由
ワクワクするものを作る → ワクワクすること = 将来の夢や理想の自分など未来に対する期待
→ 未来に対するその人ごとのワクワクを形にするもの → ビジョンボードを作るアプリの開発
・なぜこのプロジェクトが必要なのか
誰でもビジョンボードの作成ができるようにするため
2. エレベーターピッチ
30秒の短い時間でアイデアの本質や魅力を伝える
夢を叶えたい人向けのプロダクト。これは、夢や目標を視覚化することができ、
手で作るものや自由度の高いアプリとは違って、テンプレートに基づいて手軽に作成できる。
3. パッケージデザイン
ワクワクがコンセプトなので明るめの配色(彩度を抑えて柔らかい色にする)
スッキリめでおしゃれな雰囲気にする
4. やらないことリスト
やること
- 目標の入力・表示
- 自分が持っている画像の登録・表示
- テンプレートの作成
- 画像で保存する
- 目標の項目を作成する
- ヘルプ機能(ヘルプ画像)を作成する
やらないこと
- テンプレートの編集
- 巻き戻し機能
- 会員登録・ログイン機能
後で決める
- Pinterestからの画像の登録・表示
- SNSに共有(X, Pinterest, Instagram)
- 2個め以降のテンプレート
5. ご近所さんを探せ
チームメンバー、同期、メンター
6. 技術的な解決策を描く
言語: HTML, CSS, JS, PHP, SQL
フレームワーク: Next.js, Laravel
ライブラリ: React, React hooks
ツール: Git/GitHub, WSL2, Docker, MySQL, Figma
7. 夜も眠れない問題
期限内に終わらない
→スケジュール管理、こまめな進捗報告
実装方法が分からない
→タスクを分割する
→他の方法で実現できないか考える
8. 期間を見極める
中間目標を考えて確認する(最低限リリースできるものを作る→プラスα)
1/29までには事前にやり方を調べておく
発表前々日(2/2)にはほぼ完成させる
発表前日(2/3)の夜には動作確認が終わってるようにする
発表当日(2/4)は発表の練習ぐらいにする
9. トレードオフスライダー
1 | 2 | 3 | 4 | 5 | |
---|---|---|---|---|---|
機能を全部揃える(スコープ遵守) | ◯ | ||||
期日を死守する(納期遵守) | ◯ | ||||
バグを出さない(品質遵守) | ◯ |
10. 何がどれだけ必要か
このプロジェクトに必要な「コスト・スキル・期間」
スキル:今まで学習してきたところ
期間:2/4まで
- 目標の入力・表示
- 自分が持っている画像の登録・表示
- テンプレートの作成
- 画像で保存する
- 一覧を作成する
- チュートリアルを作成する
- ヘルプ機能(ヘルプ画像)を作成する
ビジョンボード作成アプリに決定したはいいものの、自分は他のメンバーと違ってビジョンボードについては全く興味がなく、周りでも見かけたことがありませんでした。だからといって適当に考えるのでは身にならないので、否定的な視点からの意見を投じて行くことにしました。そういった思考で競合アプリ調査をしていく中で自分が感じた課題点は以下の2点です。
①説明不足
②自由度の高さ(操作の複雑性)
-
①説明不足について
Web上でビジョンボードを作成することはCanvaなどのビジュアルツールを使えば可能で、ビジョンボードのテンプレートも用意されています。
しかし、大量にあるテンプレートの内の1種類という位置づけであるため、作成画面に移行すると特に説明もなく「あとはご自由にどうぞ」と放り出されてしまいます。それだとビジュアルツールにある程度慣れている人でないとなかなかしんどく感じる可能性があります(自分はそう感じました)。
そのため、基本的な操作を伝えるチュートリアルや困ったときに参照するヘルプの必要性を言及しました。 -
②自由度の高さ(操作の複雑性)について
Canvaで試しに作っていたとき、少しずつカスタマイズし始めたはいいものの、途中で飽きてしまったり、デザイン崩れの収集がつかなくなりめんどくさくて諦めてしまう可能性がありそうだと感じました。操作を単純なものに制限して、「完成までにはこれをすれば良い」というのがわかりやすくなっていれば完成までたどり着く可能性を高くすることができるのではないかと考え、要素の位置の固定を提案しました。
今回は興味がなかったからというきっかけで否定的な視点の意見を考えていましたが、今後自分で何かを作るときはユーザーが進んで使ってくれるという想定だけでなく、離脱してしまうユーザーのことも考えた上で作っていこうと思います。
ワイヤーフレーム/デザイン・環境構築/DB設計
メンバーが作ったたたき台に対して全員でフィードバックをして作成していきました。
- ワイヤーフレーム/デザイン
- 環境構築/DB設計
フロントエンド:
Laravel Breeze - Next.js Editionライブラリをカスタマイズ
TypeScriptの導入
バックエンド: Laravel Sailをカスタマイズ
phpMyAdmin, Xdebug, PHP Code Sniffer, PHP Stan, PHP MD, Plant UMLの導入
アーキテクチャ図
タスクばらし・担当決め
チームでの開発方法として、機能ごとに担当を分ける方法が推奨されていた(フロントエンドからバックエンドまでバランスよく学びを深められる)ためその方法を採用し、大まかにタスクを分けて分担しました。
-
必須機能
- 文章登録
- 画像登録
- ビジョンボードの画像化
- テンプレート一覧、マイビジョンボード一覧
- 画像ダウンロード
- ヘルプ画面
- チュートリアル
-
可能であれば
- (SNS へのシェア機能)
- (フォント・フォントカラー・背景カラーの変更)
これらの内、以下を担当することになりました。
- テンプレート一覧、マイビジョンボード一覧
- 画像ダウンロード
- (SNS へのシェア機能)
5-2.チーム開発期間
開発フローについて
開発フローについてはGitフローとGitHubフローが候補でしたが、少人数かつ短期間での開発ということを考え、比較的単純なGitHubフローで進めていきました。
自分が担当したタスクについて
前述の通り、自分はテンプレート一覧、マイビジョンボード一覧、画像ダウンロードを担当しました。担当する機能については細分化し、優先順位を付けて開発していきました。
- テンプレート一覧、マイビジョンボード一覧
- 画像ダウンロード
- (SNS へのシェア機能)
またこれらはNotionのガントチャートでメンバーと共有し進捗管理を行いました。
以下は担当した部分のコードの抜粋と簡単な振り返りです。
テンプレート一覧
コード
- フロントエンド
import Header from '@/components/Header'
import Template from '@/components/create/Template'
// import HtmlToImage from '@/components/create/HtmlToImage';
import AddImage from '@/components/create/AddImage';
import FeedTemplate from '@/components/feed/FeedTemplate'
export const metadata = {
title: 'WEAVE',
}
const Home = () => {
return (
<>
<Header link={'/myboard'} text={'My Vision Board'} />
<main>
<FeedTemplate />
</main>
</>
)
}
export default Home
'use client'
import { useState } from 'react'
import useSWRInfinite from 'swr/infinite'
import Image from 'next/image'
import styles from './FeedTemplate.module.css'
import Link from 'next/link'
import axios from '@/lib/axios'
export default function FeedTemplate() {
const [lastPage, setLastPage] = useState()
const getKey = (pageIndex, previousPageData) => {
if (previousPageData && !previousPageData.length) return null
return `/api/feedTemplate?page=${pageIndex + 1}`
}
const fetcher = async url => {
const res = await axios.get(url)
const data = await res.data.data
setLastPage(res.data.last_page)
return data
}
const { data, size, setSize } = useSWRInfinite(getKey, fetcher)
if (!data) return 'loading...'
console.log(data)
return (
<div className={styles.feedTemplateContainer}>
{data &&
data.map((data, index) => (
<div key={index} className={styles.templateList}>
{data.map(item => (
<div key={item.id} className={styles.templateItem}>
<Link
href={`/create?tmp=${item.id}`}
className={styles.templateUrl}>
<Image
src={item.thumbnail}
priority={true}
width={500}
height={500}
alt={`テンプレート${item.id}`}
className={styles.templateImage}
/>
<div className={styles.caption}>Create New</div>
</Link>
</div>
))}
</div>
))}
{size < lastPage && (
<div className={styles.loadButtonWrapper}>
<button
onClick={() => setSize(size + 1)}
className={styles.loadButton}>
Load more
</button>
</div>
)}
</div>
)
}
.feedTemplateContainer{
width: 100%;
height: 100%;
display:flex;
flex-flow: column;
background-color: #fff;
}
.templateList {
width: 100%;
height: 100%;
background-color: #fff;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem 6%;
margin: 1rem auto;
}
.templateItem {
background-color: #fff;
width: 100%;
height: 100%;
position: relative;
margin: auto;
width:fit-content
}
.templateUrl {
width:fit-content
}
.templateImage {
/* margin: 20px; */
border-radius: 5%;
width: "auto";
height: "auto";
border: solid 1px #77777763;
}
.caption {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top:50%;
left:50%;
transform: translate(-50%,-50%);
background-color: rgba(0, 0, 0, 0.8);
color: #fff;
opacity: 0;
margin: auto;
font-size: 3vw;
border-radius: 5%;
}
.caption:hover {
opacity: 1;
}
.loadButtonWrapper {
background-color: #fff;
margin: auto;
}
.loadButton {
background-color: #bff0f6;
margin: 1rem auto;
padding: 0.5rem 1rem;
border-radius: 5%;
}
.loadButton:hover {
background-color: #a7cbd1;
}
- バックエンド
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Http\Resources\TemplateCollection;
use App\Models\Template;
class FeedTemplateController extends Controller
{
public function feedTemplates()
{
return Template::select('id', 'thumbnail')->paginate(4);
}
}
マイビジョンボード一覧、画像ダウンロード
コード
- フロントエンド
import Header from '@/components/Header'
import FeedMyBoard from '@/components/feed/FeedMyBoard'
export const metadata = {
title: 'WEAVE',
}
const Home = () => {
return (
<>
<Header link={'/'} text={'Home'} />
<main>
<FeedMyBoard />
</main>
</>
)
}
export default Home;
'use client'
import { useCallback, useEffect } from 'react'
import { useState } from 'react'
import useSWRInfinite from 'swr/infinite'
import Image from 'next/image'
import styles from './FeedMyBoard.module.css'
import Link from 'next/link'
import axios from '@/lib/axios'
import { unstable_noStore as noStore } from 'next/cache'
export default function FeedMyBoard() {
const [lastPage, setLastPage] = useState()
const getKey = (pageIndex, previousPageData) => {
if (previousPageData && !previousPageData.length) return null
return `/api/feedMyBoard?page=${pageIndex + 1}`
}
const fetcher = async url => {
noStore()
const res = await axios.get(url)
const data = await res.data.data
setLastPage(res.data.last_page)
return data
}
const { data, size, setSize } = useSWRInfinite(getKey, fetcher)
if (!data) return 'loading...'
const downloadImage = async (imagUrl) => {
try {
// base64に変換された画像が返ってくる(DownloadController参照)
const response = await axios.get(`/api/download?imageUrl=${imagUrl}`)
// 一時的にdownload属性aタグを作成して強制クリック->ダウンロード
const link = document.createElement('a')
// pngの部分は画像の拡張子に合わせて変更
link.href = `data: png;base64 ,${response.data}`
link.download = 'myVision.png'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch (error) {
console.error('Error fetching image:', error)
}
}
return (
<div className={styles.feedMyBoardContainer}>
{data && data.map((data, index) => (
<div key={index} className={styles.myBoardList}>
{data.map((item) => (
<div key={item.id} className={styles.myBoardItem}>
<div className={styles.templateUrl}>
<Image
src={item.board_thumbnail}
priority={true}
width={500}
height={500}
alt={`テンプレート${item.id}`}
className={styles.myBoardImage}
/>
{<div className={styles.downloadButtonWrapper}>
<button onClick={() => downloadImage(item.board_thumbnail)} className={styles.downloadButton}>Download</button>
</div>}
</div>
</div>
))}
</div>
))}
{
size < lastPage &&
<div className={styles.loadButtonWrapper}>
<button onClick={() => setSize(size+1)} className={styles.loadButton}>Load more</button>
</div>
}
</div>
)
}
.feedMyBoardContainer{
width: 100%;
height: 100%;
display:flex;
flex-flow: column;
background-color: #fff;
}
.myBoardList {
width: 100%;
height: 100%;
background-color: #fff;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem 6%;
margin: 1rem auto;
}
.myBoardItem {
background-color: #fff;
width: 100%;
height: 100%;
position: relative;
margin: auto;
width:fit-content
}
.myBoardUrl {
width:fit-content
}
.myBoardImage {
/* margin: 20px; */
border-radius: 5%;
width: "auto";
height: "auto";
border: solid 1px #77777763;
}
.caption {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top:50%;
left:50%;
transform: translate(-50%,-50%);
background-color: rgba(0, 0, 0, 0.8);
color: #fff;
opacity: 0;
margin: auto;
font-size: 5vw;
border-radius: 5%;
}
.caption:hover {
opacity: 1;
}
.loadButtonWrapper {
background-color: #fff;
margin: auto;
}
.loadButton {
background-color: #bff0f6;
margin: 1rem auto;
padding: 0.5rem 1rem;
border-radius: 5%;
}
.loadButton:hover {
background-color: #a7cbd1;
}
.downloadButtonWrapper {
display: flex;
justify-content: center;
}
.downloadButton {
background-color: #bff0f6;
margin: 1rem auto;
padding: 0.5rem 1rem;
border-radius: 5%;
}
- バックエンド
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Board;
class FeedMyBoardController extends Controller
{
public function feedMyBoards(Request $request)
{
// ユーザー固定
return Board::select('id', 'board_thumbnail')->where('user_id', '=', 1)->paginate(4);
}
}
振り返り
一覧の取得ということで、主にページネーションの機能を実装していました。useEffectなどを使って実装していましたが、コードがかなりごちゃごちゃしてしまい、エラー処理やローディング処理を実装することを想定すると可読性が低くなってしまいそうだったので、useSWRを使ってコード量を削減しました。
また、今回はuseSWRInfiniteを使った無限ローディングでページネーションを実装しましたが、無限ローディングはブラウザバック時の復元やレンダリング範囲など考慮しなければならない点が多く、かつ、一度通過したコンテンツを探しにくかったりページをスキップできなかったりなどユーザビリティも良いとは言えません。
そのため、SNSのようにザッピングが主目的なものでもない限りは開発コストに見合った機能にならないので、ページ番号のある通常のページネーションを実装するほうが良さそうだと勉強になりました。
画像のダウンロードについて、実装方法としては、ダウンロードボタン押下→バックエンドで画像のバイナリーデータを取得→base64に変換してフロントに送信→download属性とsrc属性にbase64データを乗せたaタグを作成し強制クリック→自動削除、となります。
当初はaタグにdownload属性をつけて実装する予定でしたが、今回の環境だとクロスオリジン制約でダウンロードができませんでした。チームメンバーと協力しましたが、解決方法がなかなか見つからなかったこともあり、期間中に実装が間に合いませんでした。ここに記載してあるコードは翌日に完成・リファクタリングしたものになります。
6. チーム開発の振り返り
チームとしての良かった点
・前回のチーム開発の良かった点・反省点を共有して取り組むことで、開発期間までスムーズに進めることができた。
・現状の問題点を共有し、チームで解決に取り組むことができた。
・うまくいかないことも多かったが、雰囲気を崩さずにやり切ることができた。
チームとしての反省点
・これでできるだろうと事前に考えていたことが、やってみるとできない、ということがメンバーそれぞれの担当範囲で発生してしまった。
(作り始めてみないとわからないこともあると思うので難しいが、環境構築の後に最小限の機能で検証してみる期間を設けるなどしておけば多少は防げたかもしれない)
・似たような仕組みを調べて真似してみればよかった。
(自己流で考えるのではなく、Figmaクローンなどを参考にしつつコア機能の設計をしたほうが詰まることを少なくできたかもしれない)
6. おわりに
2度のチーム開発を経験して、タスク見積もりの難しさや複数人開発でのイレギュラーを加味したスケジュール管理など様々なことを学ぶことができました。うまくいったことよりもそうでないことのほうが多いですが、この経験を糧に少しずつでも成長していきたいと思います!
この記事が、これからチーム開発をする方の参考になれば幸いです。