ソロ開発者のプレイブック:Expo、EAS Build、OTA Updatesでより速くリリースする
Users • React Native • Development • 2026-01-28 • 13分読み物
Liam Du(ゲスト著者)
クロスプラットフォームアプリを迅速にビルドして出荷する方法:音声、カメラ、通知、安全なストレージ、そして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アップデートにより、App Storeの審査を待たずにバグ修正や改善を配布できます。更新をプッシュし、次回アプリ起動でユーザーに適用されます。ローンチ直後に何度もこれに助けられました。
ソロ開発でスケジュールが厳しい場合、ネイティブ開発へ切り替えたり、認証情報管理に時間を取られる必要がないことが、出荷できるかどうかの差を生みました。
Wellspokenの機能とアーキテクチャ
コア機能:
- パーソナライズされた練習セッション(模擬面接、トピック説明、役割演技など)
- AIによる発話解析(どこで集中を失ったか、苦労した箇所を特定)
- 日次5分エクササイズ
- 進捗トラッキングと連続実施(streaks)
- フリートライアル付きのサブスクリプション課金
テックスタック:
- React Native + Expo SDK 54
- TypeScript
- Langchain
- OpenAI API(発話解析用)
- Assembly AI(文字起こし)
- RevenueCat(サブスクリプション管理)
- EAS Build、Update、Submit
どのようにExpoを実装したか
以下は私が使った主要なExpo機能と、それがWellspokenの構築をいかに容易にしたかの説明です。
- expo-audio:録音体験の核
オーディオ録音はWellspokenの基盤です。すべての練習モードで高品質な音声キャプチャが必要で、iOSとAndroidで一貫して動作することが求められました。ネイティブコードを書かずにexpo-audioがこれを単純にしてくれました。hooksベースのクリーンなAPIを提供します。
Code Copy
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、フレームワーク練習、スピード分解、自由形式などすべての練習モードで再利用しています。一度書けばどこでも使えます。
オーディオ再生も同様にシンプルです:
Code Copy // 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フックが再生状態を自動管理し、useAudioPlayerStatusは再生進捗をリアルタイムで提供します。
- expo-camera:Daily 60ビデオ練習
Wellspokenの機能の1つに「Daily 60」があります。これはユーザーがトピックを説明する60秒のビデオ練習です。フロント/バックの切替、ビデオ録画、カメラとマイクの権限取得が必要でした。expo-cameraがこれらを処理しました。
Code Copy // 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フックが権限状態の管理を担当
- カメラの切り替えはfacingプロパティを変えるだけ
- recordAsyncのmaxDurationで自動的に60秒で停止
- 同じカメラUIコードがiOSとAndroidの両方で動作
代替案としては、SwiftとKotlinで別々にネイティブなカメラコードを書き、異なる権限システムに対応し、2つのコードベースを管理する必要がありました。expo-cameraのおかげで、本来1週間かかる仕事が1日に短縮されました。
- expo-notifications:日々の練習リマインダー
ユーザーを日々戻らせることはWellspokenにとって重要でした。プッシュ通知で練習リマインダーを出したかったのですが、APNsやFCMの設定、デバイストークンの手動管理は避けたかった。expo-notificationsがすべてを処理してくれました。
Code Copy
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
ここまでで示したように、expo-notificationsは権限要求、プラットフォーム固有のチャンネル設定、通知ハンドリングの標準化を行ってくれます(上記コードは途中で切れていますが、続きはExpoのドキュメント通りにトークン取得やサーバーへの登録を行います)。
注:この記事は元記事の一部を翻訳したものです。コードの断片は元のまま保持してあります。実装の詳細や完全なサンプルは公式ドキュメント(Expo、EAS、各ライブラリのリファレンス)を参照してください。