如何用 Expo 快速发布一款 AI 移动应用
CineMe 如何用 Expo 和 EAS Build 绕开平台差异,在一个多月内把 AI 自拍转视频应用同时上线 iOS 和 Android。
中文
复制

本文是 Kiril Kostev 的客座文章——他是一位常驻伦敦的软件开发者,也是一位充满好奇心、善于思考的创作者,喜欢研究技术、商业与生活。
CineMe 是一款移动应用,能把一张自拍变成一段电影质感的 AI 生成视频。
上传一张照片,选一个场景(动作、动漫、恐怖等),几秒钟后你就能拿到一段自己置身那个世界的短视频——可以直接发到 TikTok 或 Reels。

约束很苛刻:我想在两周内上线。这逼着我做无情的取舍——不要完美的 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 只存在于设备本地,服务器访问不到。解决办法:
-
转成 base64,放进请求体发送
这会带来额外开销,但能保证跨设备兼容。
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 和应用商店
-
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
或者访问网站:
最大的教训:快速上线胜过无休止的规划。一旦上线,真正推动迭代的是真实用户,而不是假设。