RORK LABEN
TOOLING — Rorkの開発者向けリポジトリが動き続けています。rork-xcodeが7月16日、rork-deviceが7月15日、rork-plistが7月13日に更新されましたOPUS46 — RorkでClaude Opus 4.6が稼働しています。Rork MaxはClaude Codeを土台にアプリを組み立てる設計ですSIM — ブラウザ上で動くクラウドのiOSシミュレータを備え、実機へのインストールは1クリック、App Storeへの提出は2クリックと案内されていますMAX — Rork MaxはReact Nativeではなく純粋なSwiftを出力します。iPhone・iPad・Apple Watch・Apple TV・Vision Pro、そしてiMessageまでが射程ですNATIVE — HealthKit、ARKitとLiDAR、NFC、Dynamic Island、Live Activities、Metalによる3D、Core MLのオンデバイス推論まで扱えますSEED — RorkはLeft Lane Capitalが主導する1,500万ドルのシードラウンドを実施し、Peak XVとa16z Speedrunが参加しましたTOOLING — Rorkの開発者向けリポジトリが動き続けています。rork-xcodeが7月16日、rork-deviceが7月15日、rork-plistが7月13日に更新されましたOPUS46 — RorkでClaude Opus 4.6が稼働しています。Rork MaxはClaude Codeを土台にアプリを組み立てる設計ですSIM — ブラウザ上で動くクラウドのiOSシミュレータを備え、実機へのインストールは1クリック、App Storeへの提出は2クリックと案内されていますMAX — Rork MaxはReact Nativeではなく純粋なSwiftを出力します。iPhone・iPad・Apple Watch・Apple TV・Vision Pro、そしてiMessageまでが射程ですNATIVE — HealthKit、ARKitとLiDAR、NFC、Dynamic Island、Live Activities、Metalによる3D、Core MLのオンデバイス推論まで扱えますSEED — RorkはLeft Lane Capitalが主導する1,500万ドルのシードラウンドを実施し、Peak XVとa16z Speedrunが参加しました
記事一覧/アプリ開発
アプリ開発/2026-03-15上級

上級ガイド:本番AIコンパニオン - ストリーミング、音声、マネタイズ

ストリーミングレスポンス、音声I/O、ムード追跡、オフラインサポート、サブスクリプション収益化を備えた本番グレードのAIコンパニオンアプリを構築します。

Expo149React Native209AIコンパニオンストリーミング3音声RevenueCat28上級7本番

上級ガイド:本番AIコンパニオン - ストリーミング、音声、マネタイズ

上級段階へようこそ。ユーザーがお金を払うような本番レベルのAIコンパニオンを構築します。つまり、ストリーミングレスポンス、音声I/O、分析、オフラインサポート、適切なマネタイズインフラが必要です。


スケーリング向けアーキテクチャ

本番アプリには以下が含まれます:

  1. バックエンドサービス — 安全なAPIキー処理、リクエスト検証、レート制限
  2. ストリーミングエンジン — AIが応答する際のリアルタイムテキスト表示
  3. 音声レイヤー — 音声テキスト変換とテキスト音声変換
  4. 分析 — ユーザー行動、リテンション、サブスクリプション指標
  5. マネタイズ — RevenueCatによるサブスクリプション
  6. オフラインモード — SQLiteベースのオフラインレスポンス
  7. セキュリティ — 暗号化ストレージ、プライバシーコンプライアンス

Cloudflare Workersでのバックエンド

本番環境ではAPIキーを露出させません。サーバーレスバックエンドを使用:

// wrangler.toml
name = "companion-api"
type = "javascript"
account_id = "your-account"
workers_dev = true
routes = [
  { pattern = "api.rorklab.com/*", zone_id = "your-zone-id" }
]
 
[env.production]
vars = { ENVIRONMENT = "production" }
 
[[env.production.kv_namespaces]]
binding = "RATE_LIMIT"
id = "your-kv-id"
// src/index.ts (Cloudflare Worker)
export interface Env {
  CLAUDE_API_KEY: string;
  RATE_LIMIT: KVNamespace;
}
 
export default {
  async fetch(
    request: Request,
    env: Env,
    context: ExecutionContext
  ): Promise<Response> {
    if (request.method === 'POST' && request.url.includes('/api/chat')) {
      return handleChatRequest(request, env);
    }
    return new Response('Not found', { status: 404 });
  },
};
 
async function handleChatRequest(request: Request, env: Env) {
  const { messages, conversationId } = await request.json();
  const userIP = request.headers.get('CF-Connecting-IP');
 
  // レート制限
  const rateLimitKey = `ratelimit:${userIP}`;
  const count = await env.RATE_LIMIT.get(rateLimitKey);
  const currentCount = count ? parseInt(count) + 1 : 1;
 
  if (currentCount > 100) {
    return new Response('レート制限に達しました', { status: 429 });
  }
 
  await env.RATE_LIMIT.put(rateLimitKey, String(currentCount), {
    expirationTtl: 3600,
  });
 
  // Claudeからのストリーム応答
  const response = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': env.CLAUDE_API_KEY,
      'anthropic-version': '2023-06-01',
    },
    body: JSON.stringify({
      model: 'claude-3-5-sonnet-20241022',
      max_tokens: 1500,
      stream: true,
      messages: messages,
    }),
  });
 
  return new Response(response.body, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
    },
  });
}

React Nativeでのストリーミングレスポンス

AIが応答する際にリアルタイムでテキストを表示:

// services/streamingApi.ts
export async function streamChatResponse(
  messages: Array<{ role: string; content: string }>,
  onChunk: (chunk: string) => void,
  onComplete: () => void
): Promise<void> {
  const response = await fetch(
    'https://api.rorklab.com/api/chat',
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ messages }),
    }
  );
 
  if (!response.ok) {
    throw new Error('ストリーム失敗');
  }
 
  const reader = response.body?.getReader();
  if (!reader) throw new Error('リーダーなし');
 
  const decoder = new TextDecoder();
  let buffer = '';
 
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
 
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
 
    for (let i = 0; i < lines.length - 1; i++) {
      const line = lines[i];
      if (line.startsWith('data: ')) {
        try {
          const json = JSON.parse(line.slice(6));
          if (
            json.type === 'content_block_delta' &&
            json.delta.type === 'text_delta'
          ) {
            onChunk(json.delta.text);
          }
        } catch (e) {
          // パースエラー、スキップ
        }
      }
    }
 
    buffer = lines[lines.length - 1];
  }
 
  onComplete();
}
// components/StreamingChat.tsx
import React, { useState } from 'react';
import { View, Text, ScrollView, StyleSheet } from 'react-native';
import { streamChatResponse } from '../services/streamingApi';
 
interface Props {
  messages: Array<{ role: string; content: string }>;
}
 
export default function StreamingChat({ messages }: Props) {
  const [streamingText, setStreamingText] = useState('');
 
  const handleStream = async () => {
    setStreamingText('');
    try {
      await streamChatResponse(
        messages,
        (chunk) => {
          setStreamingText((prev) => prev + chunk);
        },
        () => {
          console.log('ストリーム完了');
        }
      );
    } catch (error) {
      console.error('ストリームエラー:', error);
    }
  };
 
  return (
    <ScrollView style={styles.container}>
      {messages.map((msg, idx) => (
        <View key={idx} style={styles.messageBubble}>
          <Text style={styles.text}>{msg.content}</Text>
        </View>
      ))}
      {streamingText && (
        <View style={styles.streamingBubble}>
          <Text style={styles.text}>{streamingText}</Text>
        </View>
      )}
    </ScrollView>
  );
}
 
const styles = StyleSheet.create({
  container: { flex: 1, padding: 12 },
  messageBubble: {
    backgroundColor: '#f0f0f0',
    padding: 12,
    borderRadius: 12,
    marginVertical: 8,
  },
  streamingBubble: {
    backgroundColor: '#e3f2fd',
    padding: 12,
    borderRadius: 12,
    marginVertical: 8,
    borderLeftWidth: 3,
    borderLeftColor: '#2196F3',
  },
  text: { fontSize: 15, color: '#333' },
});

音声入出力

音声テキスト変換とテキスト音声変換を実装:

npx expo install expo-speech expo-av expo-permissions
// services/voice.ts
import * as Speech from 'expo-speech';
import * as Audio from 'expo-av';
 
export async function startListening(
  onTranscript: (text: string) => void
): Promise<void> {
  const permission = await Audio.requestPermissionsAsync();
  if (!permission.granted) {
    throw new Error('音声許可が拒否されました');
  }
 
  const recording = new Audio.Recording();
  await recording.prepareToRecordAsync(
    Audio.RecordingOptionsPresets.HIGH_QUALITY
  );
  await recording.startAsync();
 
  setTimeout(async () => {
    await recording.stopAndUnloadAsync();
    const uri = recording.getURI();
    // バックエンドに送信して転記するか、デバイス側の音声認識を使用
  }, 10000);
}
 
export async function speakText(text: string): Promise<void> {
  await Speech.speak(text, {
    language: 'ja',
    rate: 1,
    pitch: 1,
  });
}
 
export function stopSpeaking() {
  Speech.stop();
}
// components/VoiceChat.tsx
import React, { useState } from 'react';
import { View, TouchableOpacity, Text, StyleSheet } from 'react-native';
import { startListening, speakText, stopSpeaking } from '../services/voice';
 
export default function VoiceChat() {
  const [isListening, setIsListening] = useState(false);
  const [transcript, setTranscript] = useState('');
 
  const handleMicPress = async () => {
    if (isListening) {
      setIsListening(false);
      return;
    }
 
    try {
      setIsListening(true);
      await startListening((text) => {
        setTranscript(text);
      });
    } catch (error) {
      console.error('リスニング失敗:', error);
      setIsListening(false);
    }
  };
 
  return (
    <View style={styles.container}>
      <TouchableOpacity
        style={[styles.micButton, isListening && styles.micActive]}
        onPress={handleMicPress}
      >
        <Text style={styles.micIcon}>
          {isListening ? '🎤' : '🎙️'}
        </Text>
      </TouchableOpacity>
      {transcript && <Text style={styles.transcript}>{transcript}</Text>}
    </View>
  );
}
 
const styles = StyleSheet.create({
  container: { alignItems: 'center', paddingVertical: 20 },
  micButton: {
    width: 70,
    height: 70,
    borderRadius: 35,
    backgroundColor: '#007AFF',
    justifyContent: 'center',
    alignItems: 'center',
  },
  micActive: {
    backgroundColor: '#FF3B30',
  },
  micIcon: { fontSize: 32 },
  transcript: {
    marginTop: 12,
    fontSize: 16,
    color: '#333',
    maxWidth: '90%',
    textAlign: 'center',
  },
});

ムード追跡と分析

ムードジャーナル機能を追加:

// services/analytics.ts
export interface MoodEntry {
  id: string;
  mood: 'happy' | 'sad' | 'anxious' | 'calm' | 'neutral';
  note: string;
  timestamp: number;
}
 
export async function logMoodEntry(entry: MoodEntry): Promise<void> {
  // SQLiteに保存
  db.transaction((tx) => {
    tx.executeSql(
      `INSERT INTO mood_entries (id, mood, note, timestamp)
       VALUES (?, ?, ?, ?);`,
      [entry.id, entry.mood, entry.note, entry.timestamp]
    );
  });
}
 
export async function getMoodTrend(
  days: number
): Promise<Map<string, number>> {
  const oneWeekAgo = Date.now() - days * 24 * 60 * 60 * 1000;
 
  return new Promise((resolve, reject) => {
    db.transaction((tx) => {
      tx.executeSql(
        `SELECT mood, COUNT(*) as count FROM mood_entries
         WHERE timestamp > ? GROUP BY mood;`,
        [oneWeekAgo],
        (_, result) => {
          const trend = new Map();
          result.rows._array.forEach((row: any) => {
            trend.set(row.mood, row.count);
          });
          resolve(trend);
        },
        (_, error) => reject(error)
      );
    });
  });
}

オフラインサポートとバックグラウンド同期

ユーザーがオフラインでチャットして、戻ってきたときに同期できるようにする:

// services/offlineSync.ts
export async function queueMessageForSync(
  message: Message
): Promise<void> {
  const queue = await AsyncStorage.getItem('syncQueue');
  const current = queue ? JSON.parse(queue) : [];
  current.push(message);
  await AsyncStorage.setItem('syncQueue', JSON.stringify(current));
}
 
export async function syncQueuedMessages(): Promise<void> {
  const queue = await AsyncStorage.getItem('syncQueue');
  if (!queue) return;
 
  const messages: Message[] = JSON.parse(queue);
 
  try {
    for (const msg of messages) {
      await sendMessage(msg);
      await db.transaction((tx) => {
        tx.executeSql(`UPDATE messages SET synced = 1 WHERE id = ?;`, [
          msg.id,
        ]);
      });
    }
    await AsyncStorage.removeItem('syncQueue');
  } catch (error) {
    console.error('同期失敗:', error);
    // 次のオンラインイベント時に再試行
  }
}

RevenueCatでのサブスクリプション

アプリ内課金を実装:

npm install react-native-purchases
npx expo install react-native-purchases
// services/purchases.ts
import Purchases, {
  PurchasesPackage,
} from 'react-native-purchases';
 
export const ENTITLEMENTS = {
  PREMIUM: 'premium',
  VOICE: 'voice_premium',
};
 
export async function initializePurchases() {
  await Purchases.configure({
    apiKey: 'appl_YOUR_PUBLIC_SDK_KEY',
  });
}
 
export async function getOfferings(): Promise<
  PurchasesPackage[] | null
> {
  try {
    const offerings = await Purchases.getOfferings();
    return offerings.current?.availablePackages ?? null;
  } catch (error) {
    console.error('オファー取得エラー:', error);
    return null;
  }
}
 
export async function purchasePackage(
  pkg: PurchasesPackage
): Promise<boolean> {
  try {
    await Purchases.purchasePackage(pkg);
    return true;
  } catch (error) {
    console.error('購入失敗:', error);
    return false;
  }
}
 
export async function hasEntitlement(name: string): Promise<boolean> {
  try {
    const info = await Purchases.getCustomerInfo();
    return (
      info.activeSubscriptions.includes(name) ||
      Object.keys(info.entitlements.all).includes(name)
    );
  } catch (error) {
    console.error('エンタイトルメント確認エラー:', error);
    return false;
  }
}

App Storeへの申請ガイド - AIコンパニオン

App Storeに申請する前にAppleのポリシーを確認:

主要な要件:

  • これがAI(実在する人物ではない)であることを明確に開示
  • データ使用方法を説明するプライバシーポリシー
  • 18歳未満のユーザー向けペアレンタルコントロール
  • 誤解を招く健康/メンタルヘルス請求なし
  • 利用規約と制限事項の説明
  • ユーザーデータの適切な処理(転送中/保存中の暗号化)
  • 通知への明確なオプトイン
  • ローカルへの支払い情報保存なし

プライバシー開示の例:

// screens/PrivacyScreen.tsx
const PRIVACY_TEXT = `
このアプリはClaudeで動作するAIコンパニオンです。
実在する人物ではなく、専門的なメンタルヘルスケアは提供できません。
 
データ処理:
- 会話はお使いのデバイスに保存されます
- データは暗号化され、第三者と共有されません
- いつでも設定ですべてのデータを削除できます
 
サポート:support@rorklab.com
`;

セキュリティのベストプラクティス

  1. 保存時の暗号化:
import * as SecureStore from 'expo-secure-store';
 
export async function storeSecure(key: string, value: string) {
  await SecureStore.setItemAsync(key, value);
}
 
export async function retrieveSecure(key: string) {
  return await SecureStore.getItemAsync(key);
}
  1. APIキーローテーション:
  • サーバー側で月ごとにトークンを回転
  • 猶予期間後に古いトークンを無効化
  • 異常な使用パターンを監視
  1. データプライバシー:
  • GDPR:削除権、データエクスポート
  • CCPA:オプトアウトメカニズム
  • App Storeの要件に準拠

監視と分析

重要な指標を追跡:

// services/metrics.ts
export async function logUserSession(duration: number) {
  await Analytics.logEvent('session_completed', {
    duration_seconds: Math.round(duration),
  });
}
 
export async function trackMessageCount(count: number) {
  await Analytics.logEvent('daily_messages', {
    count,
  });
}
 
export async function trackConversion() {
  await Analytics.logEvent('subscription_purchased');
}

全体を振り返って:本番リストチェック

本番にデプロイする前に:

  • ✅ レート制限付きバックエンドAPI
  • ✅ ストリーミングレスポンス
  • ✅ 音声入出力
  • ✅ ムード追跡
  • ✅ オフラインサポート
  • ✅ RevenueCat統合
  • ✅ App Storeコンプライアンス
  • ✅ データ暗号化
  • ✅ プライバシーポリシー&利用規約
  • ✅ 分析セットアップ
  • ✅ エラー追跡(Sentry)
  • ✅ 負荷テスト

本番AIコンパニオンはスケーリングできる状態になっています。頑張ってシップしてください!🚀

12年の個人開発で見えてきたこと

リリース直後にチェックしているもの

  • クラッシュレポートの上位エラーを24時間ごとに確認しているか
  • AdMob/StoreKit の収益ダッシュボードに異常検知が出ていないか
  • ユーザーレビューに技術的な指摘が含まれていないか
シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

アプリ開発2026-07-14
壁紙を長押しして、プレビューと保存メニューを出す — Rork の Expo アプリで context menu を組む
壁紙ギャラリーのカードを長押ししても何も起きない。iOS利用者はプレビュー付きのメニューを期待します。Pressableだけでは届かない理由から、zeegoでのネイティブcontext menu実装、スクロールとの競合解消、保存・共有アクション、Android向けの自前フォールバックまで、動くコードでまとめます。
アプリ開発2026-07-07
高さの異なる画像を2列で美しく並べる—Rork の Expo アプリで壁紙ギャラリーを段組みにする
縦横比のばらつく画像を numColumns では美しく並べられない理由から、列の高さを見て振り分ける段組みアルゴリズム、FlashList の masonry での仮想化、依存を増やさない実装まで、壁紙ギャラリーを実コードで組みます。
アプリ開発2026-07-05
ワンタイムコードの入力欄をExpoで作る — SMS自動入力とセグメント表示を両立させる
6桁の確認コードを入れる画面は、見た目こそ単純ですが、SMSの自動入力・貼り付け・1文字ずつの削除まで含めると意外と作り込みが必要です。iOSとAndroidの自動入力を最初に押さえ、単一のTextInputで崩れにくいセグメント表示を組む実装をまとめました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →