如何用 Expo 快速发布一款 AI 移动应用

CineMe 如何用 Expo 和 EAS Build 绕开平台差异,在一个多月内把 AI 自拍转视频应用同时上线 iOS 和 Android。

中文
复制
How to ship an AI app fast with Expo

本文是 Kiril Kostev 的客座文章——他是一位常驻伦敦的软件开发者,也是一位充满好奇心、善于思考的创作者,喜欢研究技术、商业与生活。

CineMe 是一款移动应用,能把一张自拍变成一段电影质感的 AI 生成视频。

上传一张照片,选一个场景(动作、动漫、恐怖等),几秒钟后你就能拿到一段自己置身那个世界的短视频——可以直接发到 TikTok 或 Reels。

Cineme 应用

约束很苛刻:我想在两周内上线。这逼着我做无情的取舍——不要完美的 UX,不要过度设计,只要一条从输入 → 转换 → 播放的紧凑闭环。

为什么 AI 视频应用选 Expo 是对的

这个项目选 Expo 是显而易见的。单一 React Native 代码库意味着不存在平台差异。有了 Expo 的 Build 服务,从本地开发到 TestFlight 只需一条命令,不用跟 Xcode 或 Gradle 较劲。能在 iOS 和 Android 上快速迭代,直接消掉了一整类摩擦——当时间是你最大的约束时,这正是你需要的。

异步 AI 视频流水线是怎么跑的

整个应用围绕一条简单但有效的异步流水线搭建:

  • Expo(React Native 应用)

发送请求时带上一个唯一的 X-Device-ID 头,用于轻量级的用户追踪。

  • Flask API

接收 base64 编码的图片和场景 prompt。这样后端保持简单,也避开了 multipart 上传的复杂度。

  • AI 模型(视频生成)

干重活的部分。API 立即返回一个 job ID,视频在后台异步处理。

  • Webhook 回调 → Flask

生成完成后,webhook 收到通知。Flask 随后:

  • 下载结果

  • ffmpeg 加上水印

  • 存储处理后的视频

  • 客户端轮询(/status

应用每 5 秒用 job ID 轮询一次,直到视频就绪。

  • 播放

最终视频回传后,用 expo-video 播放。

这套架构避免了请求阻塞,让应用保持响应,同时还能支撑长时间的 AI 任务。

用 React Native 做 AI 视频应用的四个硬骨头

1. 文件上传(file:// 传不出去)

React Native 的文件 URI 只存在于设备本地,服务器访问不到。解决办法:

这会带来额外开销,但能保证跨设备兼容。

import { File } from 'expo-file-system';

async function imageUriToBase64(imageUri: string): Promise<string> {
  const file = new File(imageUri);
  const base64 = file.base64();
  return base64;
}

...
  const image_base64 = await imageUriToBase64(imageUri);
  const response = await fetch(`${BASE_URL}/generate`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Device-ID': deviceId, 'AnyOtherHeaders': auth_header },
    body: JSON.stringify({
      image_base64: image_base64,
      scene_id: sceneId,
    }),
  });

2. 异步任务模式(提交 → 轮询)

AI 生成不是即时的。正确的做法是:

  • 提交请求 → 拿到 jobId

  • jobId 存进 state

  • 轮询 /status 直到完成

  • 跳转到结果页

这样 UI 与处理过程解耦,也不会超时。

export async function pollJobStatus(jobId: string): Promise<JobStatus> {
  console.log('[CineMe API] Polling job status:', jobId);
  const deviceId = await getDeviceId();
  const response = await fetch(`${BASE_URL}/status/${jobId}`, {
    method:  'GET',
    headers: { 'Content-Type': 'application/json', 'X-Device-ID': deviceId, 'AnyOtherHeaders: auth_header },
  });

  if (!response.ok) {
    throw new ApiError(response.status, 'poll_failed', `HTTP ${response.status}`);
  }

  return response.json() as Promise<JobStatus>;
}

3. 健壮的轮询 hook

轮询听起来简单,直到网络出问题。生产可用的版本要包含:

  • 卸载时清理(避免内存泄漏)

  • 超时处理(X 秒后判失败)

  • pollFailureCount >= 3 的重试逻辑

这能防止死循环,也能提升在移动网络不稳定时的韧性。

const startPolling = useCallback((jobId: string) => {
    pollFailureCount.current = 0;

    pollRef.current = setInterval(async () => {
      try {
        const status = await pollJobStatus(jobId);
        setProgress(status.progress);

        if (status.status === 'completed' && status.video_url) {
          cleanup();
          await refreshCredits();
          router.replace({
            pathname: '/result',
            params: {
              videoUrl:     status.video_url,
              sceneLabel:   paramsRef.current.sceneLabel ?? '',
              thumbnailUrl: status.thumbnail_url ?? '',
            },
          });
        } else if (status.status === 'failed') {
          cleanup();
          setFailedMessage({
            title:   'Generation failed',
            body:    status.error ?? "We couldn't generate your video. Please try again.",
            cta:     'Try Again',
            onPress: () => {
              jobIdRef.current = null;
              handleRetry();
            },
          });
          setFailed(true);
        }

      } catch (err: any) {
        pollFailureCount.current += 1;
        console.warn('[poll] error:', err, `(${pollFailureCount.current} failures)`);

        if (pollFailureCount.current >= 3) {
          cleanup();
          setFailedMessage({
            title:   'Connection lost',
            body:    'We lost connection to the server. Your video may still be generating. Tap to check again.',
            cta:     'Check Again',
            onPress: handleRetry,
          });
          setFailed(true);
        }
      }
    }, POLL_INTERVAL_MS);
  }, [cleanup, router]);

4. 视频播放的限制

useVideoPlayer 无法可靠处理需要鉴权的远程 URL,它要的是本地 file:// URI。

解决办法:

  • 视频就绪后先下载到本地

  • 缓存在设备上

  • 把本地路径交给播放器

useLocalVideo 这样的辅助函数把整套逻辑包起来(下载 → 缓存 → 返回本地 URI)。播放可靠性和启动速度都会明显改善。

useLocalVideo.tsx
import * as FileSystem from 'expo-file-system/legacy';

const [localUri, setLocalUri] = useState<string | null>(null);

...
        const cacheKey  = remoteUrl.split('/').pop();
        const localPath = `${FileSystem.cacheDirectory}${cacheKey}`;

        // Serve from cache if already downloaded
        const info = await FileSystem.getInfoAsync(localPath);
        if (info.exists) {
          if (!cancelled) {
            setLocalUri(localPath);
          }
          return;
        }

        const deviceId = await getDeviceId();
        if (!deviceId) throw new Error('No device ID');

        const download = await FileSystem.downloadAsync(
          remoteUrl,
          localPath,
          { headers: { 'X-Device-ID': deviceId, 'AnyOtherHeaders: auth_header } }
        );
        setLocalUri(download.uri);

result.tsx        
import { useVideoPlayer, VideoView } from 'expo-video';

const { localUri, loading, error } = useLocalVideo(videoUrl ?? null);

...        
  const player = useVideoPlayer(localUri ?? '', (p) => {
    p.loop = true;
    if (localUri) p.play();
  });
  
  {/* Video Player */}
  <View style={styles.videoWrap}>
    <VideoView
      player={player}
      style={styles.video}
      contentFit="contain"
    />
  </View>

用 Expo 的服务发布到 TestFlight 和应用商店

Expo 的云服务是最大的效率放大器

  • eas build --profile preview 让你快速生成可安装的构建

  • 当天就能推到 TestFlight

关键认知:别等 App Store 审核通过才开始分发。

应该:

  • 立刻上传到 TestFlight

  • 开始分享给早期用户和 KOL

  • 在 Apple 审核期间收集反馈

这样增长和审核就并行起来了,能省下几天甚至几周。

用 Expo 发布 CineMe 的经验

我没赶上两周的期限。但 CineMe 从想法到完整上线只用了 36 天——App Store 和 Google Play 都能下载。一开始只是个受约束条件逼出来的实验,现在成了真实的产品,人们可以下载、使用、分享。

你可以在这里试用:

iOS:https://apps.apple.com/gb/app/cineme/id6760482145

Android:https://play.google.com/store/apps/details?id=com.aiconversations.cineme&hl=en

或者访问网站:

https://www.cineme.live/

最大的教训:快速上线胜过无休止的规划。一旦上线,真正推动迭代的是真实用户,而不是假设。

视频:Cineme — 在 expo.dev 上观看

来源: Expo Blog← 返回首页