ExpoでAIモバイルアプリを素早くリリースする方法
CineMe が Expo と EAS Build でプラットフォーム差をどう吸収し、AI セルフィー動画アプリを1か月余りで iOS と Android に同時リリースしたか。
日本語
コピー

この記事は Kiril Kostev によるゲスト投稿です。ロンドンを拠点とするソフトウェア開発者で、テクノロジー、ビジネス、生活の交差点を探求するのが好きな、好奇心旺盛で思索的な作り手です。
CineMe は、自撮り写真を映画のような AI 生成動画に変えるモバイルアプリだ。
写真をアップロードし、シーン(アクション、アニメ、ホラーなど)を選ぶと、数秒後には自分がその世界にいる短い動画が手に入る。そのまま TikTok や Reels に投稿できる。

制約は厳しかった。2 週間でリリースしたい。そのためには容赦ない取捨選択が必要だった。完璧な UX は目指さない、過剰な設計もしない。入力 → 変換 → 再生という、引き締まった一筋のループだけを作る。
AI 動画アプリに Expo が正解だった理由
このプロジェクトで Expo を選ぶのは明白だった。React Native のコードベースが一つあれば、プラットフォームごとの差異は存在しない。Expo の Build サービスがあれば、ローカル開発から TestFlight までコマンド一つで済み、Xcode や Gradle と格闘しなくていい。iOS と Android で素早くイテレーションを回せることは、摩擦をまるごと一種類消してくれる。時間が最大の制約であるとき、必要なのはまさにこれだ。
非同期の AI 動画パイプラインの仕組み
アプリ全体は、シンプルだが効果的な非同期パイプラインを中心に組み立てられている。
- Expo(React Native アプリ)
リクエストを送るとき、軽量なユーザー追跡のための一意な X-Device-ID ヘッダーを付ける。
- Flask API
base64 でエンコードした画像とシーンの prompt を受け取る。こうするとバックエンドがシンプルに保て、multipart アップロードの複雑さも避けられる。
- AI モデル(動画生成)
重い処理を担う部分。API は即座に job ID を返し、動画はバックグラウンドで非同期に処理される。
- Webhook コールバック → Flask
生成が完了すると webhook で通知が届く。Flask は続けて:
-
結果をダウンロードする
-
ffmpegでウォーターマークを付ける -
処理済みの動画を保存する
-
クライアント側のポーリング(
/status)
アプリは 5 秒ごとに job ID でポーリングし、動画が準備できるまで待つ。
- 再生
最終的な動画が返ってきたら、expo-video で再生する。
この構成はリクエストのブロッキングを避け、アプリの応答性を保ちながら、長時間かかる AI タスクにも耐えられる。
React Native で AI 動画アプリを作る際の 4 つの難所
1. ファイルアップロード(file:// が送れない)
React Native のファイル URI はデバイス上のローカルにしか存在せず、サーバーからはアクセスできない。解決策:
-
base64 に変換し、リクエストボディに入れて送る
オーバーヘッドは増えるが、デバイスを問わず確実に動く。
import { File } from 'expo-file-system';
async function imageUriToBase64(imageUri: string): Promise<string> {
const file = new File(imageUri);
const base64 = file.base64();
return base64;
}
...
const image_base64 = await imageUriToBase64(imageUri);
const response = await fetch(`${BASE_URL}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Device-ID': deviceId, 'AnyOtherHeaders': auth_header },
body: JSON.stringify({
image_base64: image_base64,
scene_id: sceneId,
}),
});
2. 非同期ジョブのパターン(送信 → ポーリング)
AI の生成は即時には終わらない。正しいやり方はこうだ:
-
リクエストを送信 →
jobIdを受け取る -
jobIdを state に保存する -
完了するまで
/statusをポーリングする -
結果画面に遷移する
こうすれば UI が処理から切り離され、タイムアウトも起きない。
export async function pollJobStatus(jobId: string): Promise<JobStatus> {
console.log('[CineMe API] Polling job status:', jobId);
const deviceId = await getDeviceId();
const response = await fetch(`${BASE_URL}/status/${jobId}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json', 'X-Device-ID': deviceId, 'AnyOtherHeaders: auth_header },
});
if (!response.ok) {
throw new ApiError(response.status, 'poll_failed', `HTTP ${response.status}`);
}
return response.json() as Promise<JobStatus>;
}
3. 堅牢なポーリング hook
ポーリングは簡単に聞こえるが、ネットワークが不調になるとそうはいかない。実運用に耐える版には次が必要だ:
-
アンマウント時のクリーンアップ(メモリリークを防ぐ)
-
タイムアウト処理(X 秒後に失敗と判定する)
-
pollFailureCount >= 3を伴うリトライ処理
これで無限ループを防ぎ、モバイル回線が不安定なときの耐性も上がる。
const startPolling = useCallback((jobId: string) => {
pollFailureCount.current = 0;
pollRef.current = setInterval(async () => {
try {
const status = await pollJobStatus(jobId);
setProgress(status.progress);
if (status.status === 'completed' && status.video_url) {
cleanup();
await refreshCredits();
router.replace({
pathname: '/result',
params: {
videoUrl: status.video_url,
sceneLabel: paramsRef.current.sceneLabel ?? '',
thumbnailUrl: status.thumbnail_url ?? '',
},
});
} else if (status.status === 'failed') {
cleanup();
setFailedMessage({
title: 'Generation failed',
body: status.error ?? "We couldn't generate your video. Please try again.",
cta: 'Try Again',
onPress: () => {
jobIdRef.current = null;
handleRetry();
},
});
setFailed(true);
}
} catch (err: any) {
pollFailureCount.current += 1;
console.warn('[poll] error:', err, `(${pollFailureCount.current} failures)`);
if (pollFailureCount.current >= 3) {
cleanup();
setFailedMessage({
title: 'Connection lost',
body: 'We lost connection to the server. Your video may still be generating. Tap to check again.',
cta: 'Check Again',
onPress: handleRetry,
});
setFailed(true);
}
}
}, POLL_INTERVAL_MS);
}, [cleanup, router]);
4. 動画再生の制約
useVideoPlayer は認証が必要なリモート URL を確実には扱えず、ローカルの file:// URI を要求する。
解決策:
-
動画の準備ができたらまずローカルにダウンロードする
-
デバイス上にキャッシュする
-
ローカルパスをプレイヤーに渡す
useLocalVideo のようなヘルパー関数がこの一連の処理(ダウンロード → キャッシュ → ローカル URI を返す)を包み込む。再生の信頼性と起動速度が目に見えて改善する。
useLocalVideo.tsx
import * as FileSystem from 'expo-file-system/legacy';
const [localUri, setLocalUri] = useState<string | null>(null);
...
const cacheKey = remoteUrl.split('/').pop();
const localPath = `${FileSystem.cacheDirectory}${cacheKey}`;
// Serve from cache if already downloaded
const info = await FileSystem.getInfoAsync(localPath);
if (info.exists) {
if (!cancelled) {
setLocalUri(localPath);
}
return;
}
const deviceId = await getDeviceId();
if (!deviceId) throw new Error('No device ID');
const download = await FileSystem.downloadAsync(
remoteUrl,
localPath,
{ headers: { 'X-Device-ID': deviceId, 'AnyOtherHeaders: auth_header } }
);
setLocalUri(download.uri);
result.tsx
import { useVideoPlayer, VideoView } from 'expo-video';
const { localUri, loading, error } = useLocalVideo(videoUrl ?? null);
...
const player = useVideoPlayer(localUri ?? '', (p) => {
p.loop = true;
if (localUri) p.play();
});
{/* Video Player */}
<View style={styles.videoWrap}>
<VideoView
player={player}
style={styles.video}
contentFit="contain"
/>
</View>
Expo のサービスで TestFlight とアプリストアに公開する
-
eas build --profile previewを使えばインストール可能なビルドを素早く生成できる -
その日のうちに TestFlight へ push できる
重要なのは、App Store の審査通過を待ってから配布を始めるなということだ。
やるべきは:
-
すぐに TestFlight へアップロードする
-
アーリーユーザーや KOL に共有し始める
-
Apple の審査中にフィードバックを集める
これで成長と審査が並行して進み、数日から数週間を節約できる。
Expo で CineMe をリリースして分かったこと
2 週間の期限には間に合わなかった。それでも CineMe はアイデアから完全なリリースまで 36 日でたどり着いた。App Store でも Google Play でもダウンロードできる。制約に押されて始めた実験が、今では人がダウンロードし、使い、共有する本物のプロダクトになった。
ここから試せる:
iOS:https://apps.apple.com/gb/app/cineme/id6760482145
Android:https://play.google.com/store/apps/details?id=com.aiconversations.cineme&hl=en
あるいはウェブサイトへ:
最大の教訓:素早くリリースすることは、終わりのない計画に勝る。リリースしてしまえば、イテレーションを本当に前に進めるのは仮説ではなく実在のユーザーだ。