ClaudeExpo2026/05/07 15:30

How to ship an AI mobile app fast with Expo

要点だけを先に読めるように短く再構成したセクションです。

元記事

Quick Digest

要約

要点だけを先に読めるように短く再構成したセクションです。

claudejamodel: claude-haiku-4-5

ExpoでAIモバイルアプリを高速リリースする方法

Key Points

  • Expoで2週間の制約を36日間でクリア
  • 非同期ポーリングで長時間AI処理を実装
  • TestFlight並行配布で審査期間を活用

Summary

この記事は、Expoを使用してAI動画生成アプリ「CineMe」を36日間でリリースした実例を紹介しています。2週間という厳しい制約下で、React Nativeの単一コードベースとExpoのビルドサービスを活用し、iOSとAndroidの両プラットフォームへの迅速なデプロイを実現しました。

Key Points

  • Expoの選択理由: 単一のReact Nativeコードベースでプラットフォーム間の相違を排除し、ローカル開発からTestFlightへのワンコマンドデプロイが可能
  • 非同期AI動画パイプライン: リクエスト送信→ジョブID取得→ポーリング→再生という設計で、長時間実行タスクをサポートしながらUIの応答性を維持
  • 4つの技術的課題への対応:
    • ファイルアップロード: base64エンコーディングでローカルURIの互換性を確保
    • 非同期ジョブパターン: ポーリングでタイムアウトを回避
    • ロバストなポーリングフック: ネットワーク障害時のリトライロジックと自動クリーンアップ
    • 動画再生: リモートURLをローカルキャッシュに保存して信頼性を向上
  • リリース戦略: TestFlightへの即座のアップロードで、App Store審査待機中に早期ユーザーからフィードバックを収集
  • 最大の教訓: 完璧な計画より迅速なリリースが重要。実ユーザーのフィードバックが反復改善を駆動

Full Translation

翻訳

原文の流れを保ったまま読める翻訳セクションです。

claudejamodel: claude-haiku-4-5

Expoを使ってAIモバイルアプリを素早くリリースする方法

これはロンドン在住のソフトウェア開発者であり、テクノロジー、ビジネス、人生について学ぶことが好きな好奇心旺盛で思慮深いクリエイター、Kiril Kostevからのゲスト投稿です。CineMeは、1枚のセルフィーをシネマティックなAI生成ビデオに変えるモバイルアプリです。写真をアップロードし、シーン(アクション、アニメ、ホラーなど)を選択すると、数秒以内にそのワールドに変身した自分の短いビデオが得られます。TikTokやReelsに投稿する準備ができています。

制約条件

制約は厳しいものでした。2週間以内にリリースしたいという目標です。これにより、完璧なUXもなく、過度なエンジニアリングもなく、入力→変換→再生という緊密なループだけに絞られました。

AIビデオアプリにExpoが最適な理由

Expoはこのプロジェクトにとって明らかな選択肢でした。単一のReact Nativeコードベースは、プラットフォーム間の相違がないことを意味します。Expoのビルドサービスを使用すれば、ローカル開発からTestFlightまで1つのコマンドで進むことができます。XcodeやGradleと格闘する必要はありません。iOSとAndroid全体で迅速に反復する機能により、摩擦の全カテゴリが削除されました。これは時間が主な制約である場合に必要なものです。

非同期AIビデオパイプラインの仕組み

アプリは、シンプルながら効果的な非同期パイプラインの周りに構築されています。

  • Expo(React Nativeアプリ) 軽量なユーザー追跡のための一意のX-Device-IDヘッダーを含むリクエストを送信します。
  • Flask API base64エンコードされた画像とシーンプロンプトを受け入れます。これによりバックエンドがシンプルに保たれ、マルチパートアップロードの複雑さが回避されます。
  • AIモデル(ビデオ生成) 重い処理を処理します。APIは即座にジョブIDを返し、ビデオを非同期で処理します。
  • Webhookコールバック→Flask 生成が完了すると、webhookが通知を受け取ります。Flaskは以下を実行します。
    • 結果をダウンロード
    • ffmpegを使用してウォーターマークを追加
    • 処理されたビデオを保存
  • クライアントポーリング(/status) アプリはジョブIDを使用して5秒ごとにポーリングし、ビデオの準備ができるまで待機します。
  • 再生 最終ビデオが返され、expo-videoを使用して再生されます。

このアーキテクチャはリクエストのブロッキングを回避し、アプリの応答性を保ちながら、長時間実行されるAIジョブをサポートします。

React NativeでAIビデオアプリを構築する際の4つの難しい問題

1. ファイルアップロード(file://を送信できない)

React NativeのファイルURIはデバイスに対してローカルです。サーバーはそれらにアクセスできません。

解決策:

  • expo-file-systemを使用して画像を読み込む
  • 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を状態に保存
  • 完了するまで/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. 堅牢なポーリングフック

ポーリングはネットワークが失敗するまで単純に聞こえます。本番対応版には以下が含まれます。

  • アンマウント時のクリーンアップ(メモリリークを回避)
  • タイムアウト処理(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}`;

// 既にダウンロードされている場合はキャッシュから提供
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とアプリストアにリリース

Expoのクラウドサービスは最大の力の乗数です。

  • eas build --profile previewを使用すると、インストール可能なビルドを素早く生成できます
  • 同じ日にTestFlightにプッシュできます

重要な洞察: App Store承認を待たないでください。代わりに以下を実行します。

  • 即座にTestFlightにアップロード
  • 早期ユーザー/インフルエンサーとの共有を開始
  • Appleレビューが保留中の間にフィードバックを収集

これにより成長と承認が並列化され、数日(または数週間)が節約されます。

ExpoでCineMeをリリースして学んだこと

2週間の期限を逃しました。しかし36日間でCineMeはアイデアから完全にリリースされた製品へと進化しました。App StoreとGoogle Playの両方でライブです。

制約駆動型の実験として始まったものは、今では人々がダウンロード、使用、共有できる実際の製品になっています。

試してみることができます。

最大の教訓: 永遠に計画するより、素早くリリースする方が優れています。ライブになると、仮定ではなく実際のユーザーがより賢い反復を推進します。