独立開発者のための実践ガイド:Expo、EAS Build、OTA Updatesでリリースを速くする

Wellspokenの創業者Liam Duは、聡明な人でも緊張で考えをうまく伝えられないことに気づき、Expoで日常的な表現トレーニングアプリを開発した。

日本語
コピー
Building a cross-platform app without touching Xcode or Android Studio

この記事は Liam Du によるゲスト投稿です。彼は Wellspoken の創業者で、より良いコミュニケーションを積極的に提唱しています。

ある会議で、チームにプロダクトのアイデアを説明しようとした。頭の中では明確だった——何度も考えたし、筋は通っていると分かっていた——でも口を開いた途端、言葉を間違えた。巻き戻してやり直す。もう一度試す。思考が途切れる。

同僚が遮った。「理解できない。」

また試した。別の言い方で。それもめちゃくちゃだった。同じ会議の中で何度もこれが起きた。試すたびに焦りが募り、焦るほど言葉が出なくなり、言葉が出ないほど焦る。正直、かなり恥ずかしかった。

言いたいことは分かっていた。アイデアは明確だった。問題は、自分の思考とそれを明確に伝える能力の間に横たわる溝だった。特にプレッシャーや緊張がかかる場面で。

しばらくは自分だけかと思っていた。でも、あちこちで同じ状況を目にするようになった。賢い人たちが、自分の話している内容をよく理解しているのに、会話の中でそれをすっきりと言葉にできない。

そこで役に立つツールを探した。見つかったのはスピーチコーチング(高い)、パブリックスピーキング講座(解決しようとしている問題が違う)、発声やステージプレゼンスに特化したアプリ(形式が合わない)。どれも自分に本当に必要だったもの——日常の会話で即座に思考を組み立てる力——を解決してくれなかった。

だから自分でアプリを作ることにした。表現力を鍛えるジムだ。毎日細切れの練習で、リアルタイムに思考を組み立てて明確に伝える認知能力を訓練する。

名前は Wellspoken にした。

Wellspoken のスクリーンショット

なぜ Expo でこのアプリを作ったのか

自分に与えた期限は1週間。iOS と Android の両方で同時にリリースする。デザインとエンジニアリングを一人で担うインディー開発者として、インフラやプラットフォーム固有の問題に時間を溶かす余裕はなかった。労力はプロダクト作りそのものに注ぎたい。Expo はそれを可能にしてくれた:

  • ネイティブ開発が不要:Expo SDK のおかげで、Swift も Kotlin も書かずにネイティブ機能を使える。録音、ファイルシステムへのアクセス、通知——すべてクリーンな TypeScript API で処理できる。

  • ビルド設定で悩まない:EAS Build がプラットフォームごとのコンパイルを全部やってくれる。Xcode / Android Studio は一度も開いていない。

  • 証明書の管理が不要: プロビジョニングプロファイル、署名証明書、keystore——EAS がすべて自動で処理する。コマンドラインのチュートリアルに沿って一度やれば終わりだった。

  • リリース後のイテレーションが速い:OTA アップデート なら、バグ修正も改善のリリースもアプリストアの審査を待たなくていい。更新をプッシュすれば、ユーザーは次回の再起動で受け取る。リリース後最初の1週間、これに何度も救われた。時間に追われるインディー開発者にとって、ネイティブ開発に切り替えずに済むこと、認証情報の管理に触れずに済むことが、リリースできるか設定地獄から抜け出せないかの分かれ目だった。

Wellspoken の機能とアーキテクチャ

コア機能:

  • パーソナライズされた練習(模擬面接、トピック解説、ロールプレイシナリオ)

  • AI 音声分析で、どこで脱線したり詰まったりしたかを特定

  • 1日5分の練習

  • 進捗トラッキングと連続記録

  • 無料トライアル付きのサブスクリプションペイウォール

技術スタック:

  • React Native + Expo SDK 54

  • TypeScript

  • 音声分析に Langchain OpenAI API

  • 文字起こしに Assembly AI

  • サブスクリプションに RevenueCat

  • EAS Build、Update、Submit

Expo での実装

ここからは、実際に使った Expo の主要機能と、それが Wellspoken の開発をどれだけ楽にしてくれたかを説明する。

1. expo-audio:録音体験の中核

録音は Wellspoken の土台だ。どの練習モードでも高品質な音声収録が必要で、しかも iOS と Android で同じ挙動にしたい。ネイティブコードは一行も書かずに。

expo-audio はクリーンな hooks API でこれを片付けてくれる:

// components/RecordingButton.tsx
import { 
  useAudioRecorder, 
  useAudioRecorderState,
  AudioModule,
  RecordingPresets,
  setAudioModeAsync 
} from 'expo-audio';
import * as Haptics from 'expo-haptics';

export function RecordingButton() {
  const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
  const recorderState = useAudioRecorderState(recorder);
  const [hasPermission, setHasPermission] = useState(false);

  async function startRecording() {
    try {
      // Request permissions
      const { status } = await AudioModule.requestRecordingPermissionsAsync();
      if (status !== 'granted') return;

      // Configure audio mode
      await setAudioModeAsync({
        allowsRecording: true,
        playsInSilentMode: true,
      });

      // Prepare and start recording
      await recorder.prepareToRecordAsync();
      recorder.record();
      
      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
    } catch (error) {
      console.error('Failed to start recording:', error);
    }
  }

  async function stopRecording() {
    try {
      await recorder.stop();
      Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);

      // recorder.uri contains the file path
      const audioUri = recorder.uri;
      // Upload to backend for analysis...
    } catch (error) {
      console.error('Failed to stop recording:', error);
    }
  }

  return (
    <TouchableOpacity
      onPress={recorderState.isRecording ? stopRecording : startRecording}
    >
      <View style={recorderState.isRecording ? styles.recording : styles.idle}>
        {recorderState.isRecording ? <StopIcon /> : <MicrophoneIcon />}
      </View>
    </TouchableOpacity>
  );
}

優れている点:

  • useAudioRecorder hook がすべての状態を管理する

  • RecordingPresets.HIGH_QUALITY がプラットフォームごとのエンコード設定を処理する

  • 同じコードが iOS でも Android でも動く

  • ネイティブモジュールの設定が不要

この RecordingButton コンポーネントはすべての練習モードで使っている——Q&A、フレームワーク練習、クイック分解、フリースタイル。一度書けばどこでも動く。

音声再生も同じくクリーンだ:

// components/AudioPlaybackBar.tsx
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';

export function AudioPlaybackBar({ audioUri }: { audioUri: string }) {
  const player = useAudioPlayer(audioUri);
  const status = useAudioPlayerStatus(player);

  function togglePlayback() {
    if (status.playing) {
      player.pause();
    } else {
      player.play();
    }
  }

  return (
    <View>
      <TouchableOpacity onPress={togglePlayback}>
        {status.playing ? <PauseIcon /> : <PlayIcon />}
      </TouchableOpacity>

      <Slider
        value={status.currentTime}
        maximumValue={status.duration}
        onSlidingComplete={(value) => player.seekTo(value)}
      />

      <Text>
        {formatTime(status.currentTime)} / {formatTime(status.duration)}
      </Text>
    </View>
  );
}

useAudioPlayer hook が再生状態を自動で管理し、useAudioPlayerStatus が再生の進捗をリアルタイムで流してくれる。

2. expo-camera:毎日60秒の動画練習

Wellspoken には「Daily 60」という機能がある——ユーザーが60秒の動画を録り、あるトピックについて説明する。これにはフロント/バックカメラの切り替え、動画録画、カメラ権限が必要だ。

expo-camera はこれらをすべて引き受ける:

// screens/practice/Daily60PracticeScreen.tsx
import { 
  CameraView, 
  useCameraPermissions, 
  useMicrophonePermissions 
} from 'expo-camera';
import * as Haptics from 'expo-haptics';

export function Daily60PracticeScreen() {
  const [cameraPermission, requestCameraPermission] = useCameraPermissions();
  const [micPermission, requestMicPermission] = useMicrophonePermissions();
  const [facing, setFacing] = useState<'front' | 'back'>('front');
  const [isRecording, setIsRecording] = useState(false);
  const cameraRef = useRef<CameraView>(null);

  async function startRecording() {
    if (!cameraRef.current) return;

    try {
      setIsRecording(true);
      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);

      // Automatically stops at 60 seconds
      const video = await cameraRef.current.recordAsync({
        maxDuration: 60,
      });

      // video.uri contains the recorded video
      await handleVideoComplete(video.uri);
    } catch (error) {
      console.error('Recording failed:', error);
    }
  }

  async function stopRecording() {
    if (!cameraRef.current) return;
    cameraRef.current.stopRecording();
  }

  function toggleCameraFacing() {
    setFacing(current => current === 'front' ? 'back' : 'front');
    Haptics.selectionAsync();
  }

  // Request permissions if needed
  if (!cameraPermission?.granted || !micPermission?.granted) {
    return (
      <View>
        <Text>Camera and microphone access needed for video practice</Text>
        <Button onPress={() => {
          requestCameraPermission();
          requestMicPermission();
        }}>
          Grant Permissions
        </Button>
      </View>
    );
  }

  return (
    <View style={{ flex: 1 }}>
      <CameraView
        ref={cameraRef}
        style={{ flex: 1 }}
        facing={facing}
        mode="video"
        onCameraReady={() => console.log('Camera ready')}
      >
        <View style={styles.controls}>
          <TouchableOpacity onPress={toggleCameraFacing}>
            <FlipCameraIcon />
          </TouchableOpacity>

          <TouchableOpacity
            onPress={isRecording ? stopRecording : startRecording}
          >
            <View style={isRecording ? styles.stopButton : styles.recordButton} />
          </TouchableOpacity>
        </View>
      </CameraView>

      {isRecording && (
        <View style={styles.timer}>
          <Text>{recordingDuration}s / 60s</Text>
        </View>
      )}
    </View>
  );
}

助かるポイント:

  • useCameraPermissions hook が権限状態の管理を担当する

  • カメラの切り替えは facing という prop を変えるだけ

  • recordAsyncmaxDuration を組み合わせれば、60秒で自動停止する

  • 同じカメラ UI のコードが iOS でも Android でも動く

別の案なら、Swift と Kotlin でネイティブのカメラコードをそれぞれ書いて、2 つの権限システムに対応し、2 つのコードベースを保守することになる。expo-camera は、本来なら 1 週間かかりかねない作業を 1 日に短縮した。

3. expo-notifications:毎日の練習リマインダー

ユーザーに毎日戻ってきて練習してもらうことは、Wellspoken にとって極めて重要だ。練習リマインダーのためにプッシュ通知が要るが、APNs の設定や FCM のセットアップ、device token の手動管理には手を出したくない。

expo-notifications が全部やってくれる:

// contexts/AuthContext.tsx
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';

// Configure notification behavior
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
});

async function registerForPushNotifications() {
  // Android notification channel setup
  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'default',
      importance: Notifications.AndroidImportance.MAX,
      vibrationPattern: [0, 250, 250, 250],
    });
  }

  // Request permissions
  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }

  if (finalStatus !== 'granted') {
    return null;
  }

  // Get the Expo push token
  const tokenData = await Notifications.getExpoPushTokenAsync({
    projectId: 'my-expo-project-id', // From app.json
  });

  return tokenData.data;
}

// In your auth flow
async function handleSignUp(email: string, password: string) {
  // ... sign up logic

  // Get push token and send to backend
  const pushToken = await registerForPushNotifications();
  if (pushToken) {
    await api.updateUser({ pushToken });
  }
}

権限オンボーディング画面:

// screens/onboarding/NotificationsPermissionScreen.tsx
import * as Notifications from 'expo-notifications';

export function NotificationsPermissionScreen() {
  const [isLoading, setIsLoading] = useState(false);

  async function requestPermission() {
    setIsLoading(true);
    try {
      const { status } = await Notifications.requestPermissionsAsync();

      if (status === 'granted') {
        const tokenData = await Notifications.getExpoPushTokenAsync({
          projectId: 'my-expo-project-id',
        });

        // Send token to backend
        await api.updatePushToken(tokenData.data);

        // Navigate to next screen
        navigation.navigate('OnboardingComplete');
      }
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <View>
      <Text>Stay consistent with daily reminders</Text>
      <Text>
        We'll send you a gentle reminder each day to practice.
        You can customize the time in settings.
      </Text>

      <Button onPress={requestPermission} disabled={isLoading}>
        Enable Notifications
      </Button>

      <Button onPress={() => navigation.navigate('OnboardingComplete')} variant="ghost">
        Skip for now
      </Button>
    </View>
  );
}

優れている点:

  • getExpoPushTokenAsync() が汎用 token を渡してくれるので、iOS でも Android でも使える

  • APNs 証明書も FCM の設定も不要

  • 通知を各プラットフォームに振り分ける複雑な処理は Expo のプッシュサービスが担当する

  • 権限リクエストが全プラットフォームで統一的に処理される

バックエンド側では、token を持って Expo のプッシュ API に通知を送るだけで、Expo が APNs か FCM に自動でルーティングしてくれる。iOS と Android のプッシュ通知のために別々のコードを保守する必要はない。

4. expo-secure-store:ユーザー token の保護

認証 token を安全に保存する必要があった。非機密データを AsyncStorage に置くのは問題ないが、認証 token は暗号化してディスクに書くべきだ。

expo-secure-store はこれを極めて簡単にしてくれる:

// contexts/AuthContext.tsx
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';

const TOKEN_KEY = 'token';

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  // Load user and token on app start
  useEffect(() => {
    async function loadUserFromStorage() {
      try {
        const userData = await AsyncStorage.getItem('user');
        const token = await SecureStore.getItemAsync(TOKEN_KEY);
        
        if (userData && token) {
          const parsedUser = JSON.parse(userData);
          setUser(parsedUser);
          // Verify token is still valid, configure services...
        }
      } catch (error) {
        console.error('Failed to load user:', error);
      } finally {
        setIsLoading(false);
      }
    }

    loadUserFromStorage();
  }, []);

  async function signIn(email: string, password: string) {
    try {
      const response = await api.signIn(email, password);

      // Store user data in AsyncStorage (non-sensitive)
      await AsyncStorage.setItem('user', JSON.stringify(response.user));
      
      // Store token securely (encrypted on device)
      await SecureStore.setItemAsync(TOKEN_KEY, response.token);

      setUser(response.user);
    } catch (error) {
      console.error('Sign in failed:', error);
      throw error;
    }
  }

  async function signOut() {
    try {
      // Clear user data
      setUser(null);
      await AsyncStorage.removeItem('user');
      await SecureStore.deleteItemAsync(TOKEN_KEY);
    } catch (error) {
      console.error('Sign out failed:', error);
    }
  }

  return (
    <AuthContext.Provider value={{ user, signIn, signOut, isLoading }}>
      {children}
    </AuthContext.Provider>
  );
}

AsyncStorage より優れている点:

  • iOS(Keychain)と Android(EncryptedSharedPreferences)での暗号化ストレージ

  • AsyncStorage と同じくらいシンプルな API:getItemAsyncsetItemAsyncdeleteItemAsync

  • プラットフォームごとに自動で暗号化、設定は一切不要

  • Token はアプリの更新や再起動後も保持される

  • ユーザーデータ(名前、メール、設定)は AsyncStorage に、auth token は SecureStore にだけ置いている

API がシンプルすぎて、AsyncStorage から SecureStore への移行は 5 分ほどで終わった。import を変えるだけで、暗号化がただで手に入る。

5. expo-haptics:至るところの触覚フィードバック

些細なことに聞こえるかもしれないが、触覚フィードバックは Wellspoken の質を一段上げてくれた。アプリ全体で使っていて、ユーザーの操作に触覚で確認を返している。

import * as Haptics from 'expo-haptics';

// Light tap for UI selections
function handleSelection() {
  Haptics.selectionAsync();
  // Continue with selection logic...
}

// Medium impact for button presses
function handleButtonPress() {
  Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
  // Continue with button action...
}

// Heavy impact for important actions (recording start)
function handleRecordingStart() {
  Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
  // Start recording...
}

// Success notification for completions
function handleExerciseComplete() {
  Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
  // Show completion UI...
}

// Error notification
function handleError() {
  Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
  // Show error message...
}

触覚フィードバックを使っている箇所:

  • すべてのボタンタップ

  • 録音の開始/停止

  • XP 獲得とレベルアップ

  • プル・トゥ・リフレッシュ

  • カメラの操作

  • ナビゲーションのジェスチャー

ごく小さなディテールだが、アプリが反応よく感じられ、どのフィードバックも意図的に設計されていると思わせてくれる。API は極めてシンプルで、インタラクションごとに 1 行書くだけ。しかも各種デバイスで一貫して動く。

6. EAS Update で素早くイテレーション

リリース後、オンボーディングのフローにバグを見つけた。昔なら、修正して、リビルドして、2 つのアプリストアに提出して、2〜3 日の審査を待って、さらにユーザーの更新を待つことになる。EAS Update があれば、15 分で直して配信できた。

app.json の設定:

{
  "expo": {
    "name": "Wellspoken",
    "slug": "wellspoken",
    "version": "x.x.x",
    "runtimeVersion": "x.x.x",
    "updates": {
      "url": "https://u.expo.dev/my-expo-project-id"
    },
    "extra": {
      "eas": {
        "projectId": "my-expo-project-id"
      }
    }
  }
}

eas.json の設定:

{
  "build": {
    "production": {
      "channel": "production",
      "distribution": "store"
    },
    "preview": {
      "channel": "preview",
      "distribution": "internal"
    }
  }
}

更新の配信:

# Fix a bug, then push OTA update
eas update --branch production --message "Fix onboarding flow bug"

# Users get the update on next app restart
# No app store submission needed

これだけ。リビルドもストアへの提出も要らない。更新は即座に反映され、ユーザーは次にアプリを再起動したときに受け取る。

preview と production で channel を分けているので、全ユーザーに配信する前に更新をテストできる。これには最初の数日で何度も助けられた——小さな UI バグ、文言の変更、テストでカバーできていなかったエッジケースなどだ。

7. 使ったその他の Expo モジュール

expo-video - useVideoPlayer hook で Daily 60 の録画を再生する。

expo-file-system - 録音した音声と動画のファイルを S3 にアップロードする。単純なファイル操作で、ネイティブコードには触れない。

expo-apple-authentication——Apple ログインの組み込みは 30 分ほどで終わった。

expo-image-picker——Daily 60 の練習用に、アルバムから動画を選ぶ。

expo-font——@expo-google-fonts/manrope でカスタムの Manrope フォントを読み込む。

どれもネイティブ設定を一切変えず、インストールすればそのまま動く。これが Expo マネージドワークフローの力だ。

アプリの採用状況と収益

リリース 1 か月後の初期データ:

  • ユーザー 400 人以上

  • 収益 500 ドル以上

  • リリースから 24 時間以内に最初の有料ユーザーがついた

素早くリリースできたことが鍵だった。アイデアをすぐ検証でき、実際のユーザーフィードバックを集め始め、仮定ではなく実際の使われ方に基づいてイテレーションできる。OTA 更新のおかげで、アプリストアの審査を待たずにリアルタイムで問題を直せる。ちなみに、ユーザーレビューはとても励みになる:

アプリ評価

Expo で開発して学んだこと

Expo の SDK はすでに本番で使える。 最初は音声・動画の録音やカメラ機能で制約にぶつかるのではと心配していたが、expo-audio と expo-camera で必要なものはすべて満たせた。API はクリーンでドキュメントも充実しており、各プラットフォームで一貫して動く。

プッシュ通知はもう面倒じゃない。 expo-notifications が APNs と FCM の設定の複雑さをすべて取り除いてくれる。プッシュトークンを取得して通知を送信するまで、証明書の管理に何時間も費やす代わりに、およそ30分で完了した。

セキュリティはシンプルにできる。 認証トークンを AsyncStorage から expo-secure-store に移行するのに5分しかかからず、両プラットフォームで暗号化ストレージを手に入れた。最も安全な方法が最も楽な方法であることもある。

初日から EAS を使う。 自分でビルドフローをいじって後から移行するのではなく、最初から EAS Build、Update、Submit を使ったので、インフラ作業を何時間も節約できた。

Hook ベースの API は React 開発者に優しい。 useAudioRecorderuseAudioPlayeruseCameraPermissions——React から来た人間には自然に使える。奇妙な命令型 API も class コンポーネントもない。

テストを書くべきだった。 進捗を優先し、リリース時に自動テストを一切書かなかった。アプリが小さかったので大きな問題は起きていないが、すでにいくつかリグレッションバグが出ており、テストがあれば防げたはずだ。

細部の作り込みは効く。 ハプティクスをあちこちに追加するのに1時間ほどかかったが、アプリは明らかにレスポンスが良く、デザインされている感じになった。こうした小さな処理は積み重なる。

まとめ

もちろん、Expo が製品を作ってくれるわけではない——役に立つものを設計し、きれいなコードを書くのは自分だ。それでも、モバイル開発に通常つきまとう摩擦はすべて取り除いてくれる。証明書のことを気にしなくていいし、プラットフォーム固有のビルド問題に対処しなくていいし、簡単な修正のために何日も待たなくていい。

素早くリリースしたい個人開発者にとって、Expo は明白な選択だ。ツールチェーンと格闘するのではなく、人が使いたいと思うものを作ることに集中できる。

アプリはすでに公開されている。もし自分と同じように言葉に出すのが難しいなら、あるいは実装方法に興味があれば、見てみるか、直接聞いてほしい。

Wellspoken は iOSAndroid で公開中。

出典: Expo Blog← ホームへ戻る