ソロ開発者のプレイブック:Expo、EAS Build、OTAアップデートでより速くリリースする
Users • React Native • Development • January 28, 2026 • 13分で読める
Liam Du ゲスト著者
Expoを使用してクロスプラットフォームアプリを高速で構築・リリースする方法:音声、カメラ、通知、セキュアストレージ、さらにEAS Build、Update、Submitを活用。
これはWellspokenの創設者であり、より良いコミュニケーションの熱心な支持者であるLiam Duからのゲスト投稿です。
始まり
私はチームに製品アイデアを説明しようとしている会議にいました。コンセプトは頭の中では明確でした - よく考え抜いていて、理にかなっていることも分かっていました - しかし話し始めると、言葉が間違って出てきました。後戻りしました。もう一度試しました。思考の流れを失いました。
同僚が私を止めました。「理解できません。」
私は再び試し、違う言い回しで説明しました。それでもめちゃくちゃでした。
それが続きました。同じ会議で何度も。試行するたびにより混乱していくのを感じ、それが表現をより悪くし、さらに混乱させました。正直、恥ずかしかったです。
私は何を言おうとしているかを知っていました。アイデアは明確でした。問題は、私の思考と、特にプレッシャーや緊張している時に、それらを明確に表現する能力との間のギャップでした。
しばらくの間、これは私だけの問題だと思っていました。しかし、その後どこでもそれに気づき始めました。明らかに話していることを知っているが、会話でそれをきれいに表現できない賢い人たち。
そこで、これを助けるツールを探しました。スピーチコーチング(高価)、パブリックスピーキングコース(問題が違う)、声の投影やステージプレゼンスに焦点を当てたアプリ(形式が違う)を見つけました。私が実際に必要としていることに対処するものはありませんでした:日常の会話中にその場で思考を構造化することが上手くなること。
そこで、アプリを構築することにしました - あなたの表現力のためのジムです。リアルタイムで思考を整理し、明確に表現する認知スキルを訓練する、日々の一口サイズの練習。
それをWellspokenと名付けました。
なぜこのアプリを構築するためにExpoを選んだのか
私は自分にiOSとAndroidの両方でリリースするために1週間だけを与えました。デザインとエンジニアリングの両方を行うソロ開発者として、インフラストラクチャやプラットフォーム固有の問題に時間を無駄にする余裕はありませんでした。実際の製品の構築に集中する必要がありました。
Expoがそれを可能にしました:
- ネイティブ開発不要:Expo SDKにより、SwiftやKotlinを書くことなくネイティブ機能にアクセスできました。音声録音、ファイルシステムアクセス、通知—すべてクリーンなTypescript APIで処理されます。
- ビルド設定地獄なし:EAS Buildがすべてのプラットフォーム固有のコンパイルを処理しました。XcodeやAndroid Studioを一度も開きませんでした。
- 証明書管理なし:プロビジョニングプロファイル、署名証明書、キーストア— EASがすべてを自動的に処理しました。コマンドラインチュートリアルに従うだけで完了しました。
- リリース後の高速反復:OTAアップデートにより、アプリストアのレビューを待つことなくバグを修正し、改善をプッシュできました。アップデートをプッシュすれば、ユーザーは次回の再起動時にそれを取得します。これはリリース後の最初の週で何度も私を救いました。
タイトなタイムラインを持つソロ開発者として、ネイティブ開発にコンテキストスイッチしたり、認証情報管理を扱ったりする必要がないことが、リリースとセットアップ地獄にはまることの違いを生みました。
Wellspokenの機能とアーキテクチャ
コア機能:
- パーソナライズされた練習セッション(模擬面接、トピック説明、ロールプレイシナリオ)
- 集中を失った場所や苦労した場所を特定するAI駆動の音声分析
- 日々の5分間の練習
- 進捗追跡とストリーク
- 無料トライアル付きサブスクリプションペイウォール
技術スタック:
- React Native + Expo SDK 54
- TypeScript
- Langchain
- 音声分析用OpenAI API
- 転写用Assembly AI
- サブスクリプション用RevenueCat
- EAS Build、Update、Submit
Expoの実装方法
Wellspokenの構築を大幅に簡単にした、私が使用したコアExpo機能を説明します。
1. expo-audio:コア録音体験
音声録音はWellspokenの基盤です。すべての練習モードで高品質な音声キャプチャが必要で、ネイティブコードを書くことなくiOSとAndroidで一貫して動作する必要がありました。
expo-audioは、クリーンなフックベースのAPIでこれを簡単にしました:
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 {
const { status } = await AudioModule.requestRecordingPermissionsAsync();
if (status !== 'granted') return;
await setAudioModeAsync({
allowsRecording: true,
playsInSilentMode: true,
});
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);
const audioUri = recorder.uri;
} 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フックがすべての状態を管理
RecordingPresets.HIGH_QUALITYがプラットフォーム固有のエンコーディング設定を処理
- 同じコードがiOSとAndroidで動作
- ネイティブモジュール設定不要
このRecordingButtonコンポーネントをすべての練習モード—Q&A、フレームワーク練習、スピード分解、フリーフォーム—で使用しています。一度書けば、どこでも使えます。
音声再生も同様にクリーンです:
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フックが自動的に再生状態を管理し、useAudioPlayerStatusが再生進捗のリアルタイム更新を提供します。
2. expo-camera:Daily 60ビデオ練習
Wellspokenの機能の一つは「Daily 60」—ユーザーがトピックを説明している自分を録画する60秒のビデオ練習です。これには前面/背面カメラの切り替え、ビデオ録画、カメラ権限が必要でした。
expo-cameraがこれらすべてを処理しました:
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);
const video = await cameraRef.current.recordAsync({
maxDuration: 60,
});
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();
}
if (!cameraPermission?.granted || !micPermission?.granted) {
return (
<View>
<Text>ビデオ練習にはカメラとマイクへのアクセスが必要です</Text>
<Button onPress={() => {
requestCameraPermission();
requestMicPermission();
}}>権限を許可</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フックが権限状態管理を処理
- カメラフリップは
facingプロパティを変更するだけ
maxDuration付きのrecordAsyncが60秒で自動停止
- 同じカメラUIコードがiOSとAndroidで動作
代替案は、SwiftとKotlinで別々にネイティブカメラコードを書き、異なる権限システムを扱い、2つのコードベースを管理することでした。expo-cameraは、1週間の作業になったかもしれないものを1日に変えました。
3. expo-notifications:日々の練習リマインダー
ユーザーに毎日戻ってきてもらうことはWellspokenにとって重要でした。練習リマインダーのためのプッシュ通知が必要でしたが、APNs設定、FCMセットアップ、またはデバイストークンの手動管理を扱いたくありませんでした。
expo-notificationsがすべてを処理しました:
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
async function registerForPushNotifications() {
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
});
}
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
return null;
}
const tokenData = await Notifications