独立开发者的实战手册:用 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 来做这个应用

我给自己一周时间,要在 iOS 和 Android 上同时发布。作为一个人包揽设计和工程的独立开发者,我耗不起在基础设施或平台特定问题上浪费时间。我需要把精力放在做产品本身。Expo 让我做到了这一点:

  • 不需要原生开发:Expo SDK 让我不用写 Swift 或 Kotlin 就能用上原生功能。录音、文件系统访问、通知——全都通过干净的 TypeScript API 处理。

  • 不用折腾构建配置: EAS Build 包办了所有平台相关的编译。我一次都没打开过 Xcode / Android Studio。

  • 不用管证书: 描述文件、签名证书、keystore——EAS 全都自动处理了。我照着命令行的教程走了一遍就完事了。

  • 上线后迭代快: OTA 更新 意味着修 bug、推改进都不用等应用商店审核。推一次更新,用户下次重启就能拿到。上线后第一周,这救了我好几次。作为一个时间紧的独立开发者,不用切换到原生开发、不用碰凭据管理,直接决定了我是能发布出去,还是卡在配置地狱里出不来。

Wellspoken 的功能与架构

核心功能:

  • 个性化练习(模拟面试、话题讲解、角色扮演场景)

  • AI 语音分析,找出你在哪里走神或卡壳

  • 每天 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 组件——问答、框架练习、快速拆解、自由发挥。写一次,到处能用。

音频播放同样干净利落:

// 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

  • recordAsync 配合 maxDuration,到 60 秒自动停止

  • 同一套摄像头 UI 代码在 iOS 和 Android 上都能跑

换成别的方案,就得分别用 Swift 和 Kotlin 写原生摄像头代码,应付两套权限系统,维护两份代码库。expo-camera 把原本可能要一周的活压缩到了一天。

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 极其简单——每个交互只要一行代码——而且在各种设备上表现一致。

6. 用 EAS Update 快速迭代

上线之后,我在 onboarding 流程里发现了一个 bug。放在过去,我得先修复、重新构建、提交到两个应用商店、等 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 bug、文案改动,还有测试时没覆盖到的边界情况。

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 托管工作流的威力。

应用采用情况与收入

上线一个月后的早期数据:

  • 400+ 用户

  • 收入 500+ 美元

  • 上线 24 小时内就有了第一个付费用户

快速上线很关键。我得以迅速验证想法,开始收集真实用户反馈,并根据实际使用情况而不是假设来迭代。OTA 更新意味着我可以实时修问题,不用等应用商店审核。顺便说一句,用户评价非常鼓舞人心:

应用评测

用 Expo 开发学到的东西

Expo 的 SDK 已经可以用于生产。 我一开始担心音频/视频录制或相机功能会碰到限制,但 expo-audio 和 expo-camera 满足了我的全部需求。API 干净、文档齐全,在各平台上表现一致。

推送通知不再痛苦。 expo-notifications 把 APNs 和 FCM 配置的复杂度全部抹掉了。拿到推送 token 并发出通知,总共大概 30 分钟,而不是花几个小时管理证书。

安全可以很简单。 把认证 token 从 AsyncStorage 换成 expo-secure-store 只花了 5 分钟,两个平台就都有了加密存储。有时候最安全的方案也是最省事的方案。

从第一天就用 EAS。 我没有自己折腾构建流程、之后再迁移,而是一开始就用 EAS Build、Update 和 Submit,省下了好几个小时的基础设施工作。

基于 Hook 的 API 对 React 开发者很友好。 useAudioRecorderuseAudioPlayeruseCameraPermissions——从 React 过来用这些感觉很自然。没有奇怪的命令式 API,也没有 class 组件。

我应该写测试的。 为了赶进度,我上线时没写任何自动化测试。应用小,所以没出大问题,但已经出现了几个回归 bug,有测试的话本可以拦住。

细节上的打磨很重要。 到处加上触觉反馈总共大概花了一个小时,但应用明显变得更有响应感、更有设计感。这些微小的处理会累积起来。

收尾

当然,产品不是 Expo 做出来的——我仍然得设计出有用的东西,写出干净的代码。但它清掉了移动开发通常伴随的所有摩擦。不用管证书,不用处理平台特有的构建问题,简单的修复也不用等上好几天。

对于想快速交付的独立开发者来说,Expo 是显而易见的选择。它让你专注于做出人们愿意用的东西,而不是跟工具链较劲。

应用现在已经上线。如果你和我一样在表达上有困难,或者你对实现方式感兴趣,可以去看看,或者直接来问。

Wellspoken 已在 iOSAndroid 上线。

来源: Expo Blog← 返回首页