用 Expo 构建的离线优先多语言语音导览应用
了解 Kuratour 如何借助 Expo 和 expo-audio,以离线优先架构为旅行社打造 GPS 触发的语音导览和白标应用。
中文
复制

本文是 Clinton Forster 的客座文章——他是一位来自澳大利亚的全栈开发者,其应用 Kuratour 最近在 Adventure Tourism Awards 颁奖典礼上拿下了 2025 年创新奖。我们最初注意到他,是因为他把 Kuratour 提交到了 2025 Expo App Awards。
...
旅行应该像探索,而不是折腾行程。但对很多旅行者来说,最好的故事都藏在昂贵的漫游费、时断时续的信号,或者死板的跟团行程后面。Kuratour 就是为了改变这一点:把你的手机变成一个懂位置的私人导游,在任何地方都能用——哪怕是在世界的尽头。
Kuratour 的构想
Kuratour 是一款多语言、基于 GPS 的语音导览应用,完全用 Expo 构建。它通过实时位置触发、离线地图和 AI 生成的路线,提供沉浸式的步行和驾车体验。如今,Kuratour 支撑的不只是我们自己的平台,还有一整套面向澳大利亚及其他地区旅游运营商的贴牌应用。
这篇文章里,我们会讲清楚我们如何借助 Expo SDK 解决「离线优先」的难题,以及如何用新的 expo-audio 库搭建一个现代化的音频引擎。
Expo 打底:规模化运行的现代 React Native
Kuratour 完全基于 Expo SDK 构建,跑在新架构上,既用官方模块,也用社区包,以此实现原生级别的体验。
核心模块:
-
expo-location,用于实时 GPS 追踪和基于区域的触发 -
expo-file-system和expo-sqlite,用于离线数据缓存和媒体存储 -
expo-audio,用于流畅的多语言播放 -
expo-video,用于带讲解的多语言新手引导 -
expo-splash-screen,用于精致的启动体验 -
expo-apple-authentication,用于 iOS 上的无缝登录 -
expo-application,用于检查应用版本、提醒用户更新,并确保离线内容是最新的 -
@rnmapbox/maps,用于支持交互、可离线使用的地图
打造离线优先的体验
对 Kuratour 来说,「离线优先」不是一个功能,而是核心需求。旅行者常常在偏远地区失去信号,或者干脆关掉数据流量以避免漫游费。为此:
-
地图瓦片、图片和音频通过
expo-file-system提前下载。 -
元数据和已访问地点使用
expo-sqlite存储在本地。 -
应用会检测网络连接状态,在在线与离线模式之间自动切换。
-
expo-location触发的实时 GPS 定位确保讲解在恰当时机开始。
第 1 步:用 SQLite 管理结构化数据
我们用 expo-sqlite 记录已下载的导览、用户进度和本地化设置。下面是 DatabaseService 的简化版本。我们通过版本号机制处理 schema 迁移——当你需要给已有用户群添加「Observed Directions」这类功能时,这一步至关重要。
import * as SQLite from "expo-sqlite";
export class DatabaseService {
private db: SQLite.SQLiteDatabase;
private currentVersion = 3;
constructor() {
// Open (or create) the database
this.db = SQLite.openDatabaseSync("db");
this.initializeSchema();
}
private initializeSchema() {
try {
// 1. create schema table if it doesn't exist
this.db.execSync(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY NOT NULL
);
`);
// check db version and apply appropriate migrations
const result = this.db.getFirstSync(
"SELECT version FROM schema_version ORDER BY version DESC LIMIT 1"
) as DatabaseSchema;
const dbVersion = result ? result.version : 0;
if (dbVersion < this.currentVersion) {
this.applyMigrations(dbVersion);
}
} catch (error) {
console.error("Error initializing schema:", error);
}
}
private applyMigrations(dbVersion: number) {
try {
if (dbVersion < 1) {
this.db.execSync(`
CREATE TABLE IF NOT EXISTS tours (
id INTEGER PRIMARY KEY NOT NULL,
offline_data TEXT,
visited_locations TEXT
);
`);
this.db.execSync("INSERT INTO schema_version (version) VALUES (1)");
}
if (dbVersion < 2) {
// ... further migrations as necessary
}
} catch (error) {
console.error("Error applying migrations:", error);
}
}
// Save the entire tour object as a JSON string for offline retrieval
async setOfflineData(tourID: number, offlineData: any) {
const tourAsString = JSON.stringify(offlineData);
await this.db.runAsync(
"INSERT OR REPLACE INTO tours (id, offline_data) VALUES (?, ?)",
[tourID, tourAsString]
);
}
let databaseServiceInstance: DatabaseService | null = null;
export function getDatabaseService(): DatabaseService {
if (!databaseServiceInstance) {
databaseServiceInstance = new DatabaseService();
}
return databaseServiceInstance;
}
}
第 2 步:编排资源下载
用户点击「Download」后,我们需要同步多种资源:Mapbox 瓦片、MP3 音频文件和高分辨率图片。我们用 expo-file-system 在本地镜像远程目录结构。
import { Directory, File, Paths } from "expo-file-system";
const downloadFile = async (key: string) => {
const CDNUrl = getUrl(key); // Your CDN/Storage URL to the file
const dirName = key.substring(0, key.lastIndexOf("/"));
const destinationDir = new Directory(Paths.document, dirName); // we are allowed to store data in the documents dir without prompting user
// Ensure the directory exists
if (!destinationDir.exists) {
destinationDir.create({ intermediates: true });
}
const file = new File(destinationDir, key.split("/").pop() || "");
if (!file.exists) {
// Download the file from the remote URL to the local filesystem
await File.downloadFileAsync(CDNUrl, file);
}
return file.uri; // This local URI is what the <Audio> or <Image> components will use
};
第 3 步:接入 UI
在 TourDownload 组件中,我们把这些调用串成一个顺序流程。先获取地图路线,再下载主图,最后遍历导览中的每个地点,拉取其对应的音频和背景音轨。全部完成后,把本地文件 URI 写回 SQLite,应用便能完全脱离网络运行。
第 4 步:用自定义 Hook 实现智能切换
数据存到本地后,应用需要知道优先使用哪个来源。我们写了一个自定义 Hook useOfflineDetector,它借助 React Navigation 的 useFocusEffect 实现。这样,每次用户进入导览页面时,我们都会先查本地 SQLite 数据库。
import { useState, useCallback } from "react";
import { useFocusEffect } from "@react-navigation/native";
import { getDatabaseService } from "../../lib/sq-lite";
export const useOfflineDetector = (tourID: number) => {
const [isOfflineAvailable, setIsOfflineAvailable] = useState<boolean>(false);
const fetchOfflineStatus = useCallback(async () => {
const db = getDatabaseService();
const offlineTour = await db.getOfflineData(tourID);
// If we find data in SQLite, we flag it as offline-ready
setIsOfflineAvailable(offlineTour !== null);
}, [tourID]);
// Re-run the check whenever the screen comes into focus
useFocusEffect(
useCallback(() => {
fetchOfflineStatus();
}, [fetchOfflineStatus])
);
return isOfflineAvailable;
};
处理 iOS 更新时的数据完整性
在 iOS 上做离线优先应用,一个常见的坑是应用升级时 Documents 目录的行为。SQLite 数据库会在更新后保留,但通过 expo-file-system 存进 documents 目录的文件,在大版本升级导致内部路径变化时,有时会被清除或变成孤立文件。
为了避免用户打开一个「已下载」的导览却发现音频文件缺失,我们实现了一个 Content Validation Bridge。
步骤 1:检测更新
我们用 expo-application 对比当前运行版本与本地存储中的 lastCheckedVersion。两者不一致时,触发一次校验扫描。
import * as Application from "expo-application";
import { StorageKeys } from "../../types/storage.types";
export const useVersionCheck = () => {
const currentVersion = Application.nativeApplicationVersion;
const versionCheck = useCallback(async () => {
const lastChecked = await getLocalData(StorageKeys.LAST_CHECKED_VERSION);
if (lastChecked?.version !== currentVersion) {
// An update occurred! Re-verify our offline files
await recheckDownloadedContent(language, voice, dispatch);
// Update our tracker
await storeLocalData(StorageKeys.LAST_CHECKED_VERSION, { version: currentVersion });
}
}, [currentVersion]);
};
步骤 2:校验文件系统
recheckDownloadedContent 函数会遍历 SQLite 中所有标记为「offline」的导览,并探测文件系统,确认资源确实存在。只要缺一个文件(比如导览主图或第一段音频),就触发后台重新下载。
import { File } from "expo-file-system";
export const checkMissingFiles = async (offlineTour: Tour): Promise<boolean> => {
// Check the main header image
const mainImage = new File(offlineTour.image.key);
if (!mainImage.exists) return true;
// Check every location-specific asset
for (const location of offlineTour.locations) {
const imageExists = new File(location.image.key);
const audioExists = new File(location.audioKey);
if (!imageExists.exists || !audioExists.exists) return true;
}
return false;
};
用 expo-location 驱动自动发现
Kuratour 的妙处在于「免手操作」模式。我们希望旅行者把手机揣在兜里,只管听——故事会根据坐标自动触发。为此我们写了一个自定义 useLocation hook,负责管理权限并建立高精度后台订阅。
实现位置监听
借助 Location.watchPositionAsync,我们可以每隔几秒更新一次应用状态,或者用户移动哪怕一米就更新。在密集的市中心做步行导览,这种精度至关重要。
import * as Location from "expo-location";
export const useLocation = (tourModeActive: boolean) => {
const [location, setLocation] = useState<LatLng | null>(null);
useEffect(() => {
let locationSubscription: any;
const watchLocation = async () => {
// 1. Request foreground permissions
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") return;
// 2. Subscribe to high-accuracy updates
locationSubscription = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.High,
timeInterval: 5000, // Update every 5 seconds
distanceInterval: 1, // Or every 1 meter
},
(newLocation) => {
setLocation({
latitude: newLocation.coords.latitude,
longitude: newLocation.coords.longitude,
bearing: newLocation.coords.heading || null,
});
}
);
};
if (tourModeActive) {
watchLocation();
}
return () => {
if (locationSubscription) locationSubscription.remove();
};
}, [tourModeActive]);
return { location };
};
用 expo-audio 编排分层音效
「Kuratour 体验」不只是读文字,更是营造氛围。我们用现代的 expo-audio 库,把基于位置的解说叠加在若有若无的环境音轨上,做出电影感。
与旧的音频实现不同,expo-audio 允许我们创建多个播放器实例:一个放故事,一个放环境音,同时还能精确控制音量和同步。
下面是一个使用 expo-audio 的简单示例
import { createAudioPlayer, AudioModule } from "expo-audio";
export const TourAudioControls = ({ currentLocation, voice, language }) => {
const [audioPlaying, setAudioPlaying] = useState(false);
const [loadedSound, setLoadedSound] = useState<AudioPlayer | null>(null);
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60000);
const seconds = parseInt(((time % 60000) / 1000).toFixed(0));
return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
};
const handleAudioState = async () => {
// 1. Cleanup existing audio before loading a new stop
if (loadedSound) {
await loadedSound.pause();
await loadedSound.remove(); // Essential for New Architecture memory management
}
try {
// 2. Configure global audio behavior
await AudioModule.setAudioModeAsync({
interruptionMode: "duckOthers", // Narration ducks music/podcasts
playsInSilentMode: true,
});
// 3. Create the player with a local or remote URI
const player = createAudioPlayer({
uri: getAudioUrl(currentLocation.audioKey)
});
// 4. Layer background ambiance at a lower volume
if (currentLocation.backgroundAudioKey) {
const bgPlayer = createAudioPlayer({ uri: currentLocation.backgroundAudioKey });
bgPlayer.volume = 0.2;
bgPlayer.play();
}
setLoadedSound(player);
player.play();
setAudioPlaying(true);
} catch (error) {
console.error("Audio Load Error:", error);
}
};
// 5. Track progress for the UI seek bar
useEffect(() => {
const interval = setInterval(() => {
if (audioPlaying && loadedSound) {
console.log(`Progress: ${loadedSound.currentTime} / ${loadedSound.duration}`);
}
}, 300);
return () => clearInterval(interval);
}, [audioPlaying, loadedSound]);
// Simplified UI Render
return (
<Animated.View style={[styles.container, { opacity: fadeAnim }]}>
<View style={styles.headerContainer}>
<Text style={styles.headerText}>{currentLocation.name}</Text>
<IconButton icon="close" onPress={closeAudio} />
</View>
<View style={styles.audioControls}>
<IconButton icon="rewind" onPress={rewind5s} />
<IconButton
icon={audioPlaying ? "pause" : "play"}
size={36}
onPress={togglePlayback}
/>
<IconButton icon="fast-forward" onPress={fastForward5s} />
</View>
<View style={styles.audioInfo}>
<Text>{formatTime(currentTime)}</Text>
<View style={styles.statusBar}>
<View style={{ ...styles.position, width: `${progress}%` }} />
</View>
<Text>{formatTime(duration)}</Text>
</View>
</Animated.View>
);
};
AI 驱动的步行导览生成
Kuratour 最好玩的功能之一,是 AI 辅助创建导览。用户输入任意城市名,就能生成带地图的路线、解说脚本和语音旁白。
-
AI 生成的文本会转换成多种语言的语音,播放用 expo-audio。
-
导览以结构化数据存储,立刻就能离线播放。
-
系统支持 14 种语言,提供男声和女声选项。
监控性能与稳定性
Kuratour 使用 @sentry/react-native 实时追踪错误、监控性能。Sentry 仪表盘帮助团队快速定位并修复特定设备上的问题。
与旅游运营商一起扩张
Kuratour 的技术如今为全球多家旅游运营商提供白标应用,包括:
运营商可以通过定制的 CMS 管理行程、上传素材、在几分钟内发布新的多语言内容,全程无需任何技术背景。
借助 Expo 的动态 app.config.js 和 Expo's Services,我们搭建了一套白标系统,几分钟内就能为任意运营商生成专属应用。
第一步:运营商开关
我们用一个环境变量来决定当前构建的是哪个“口味”的应用。
# To build for a specific partner
EXPO_PUBLIC_OPERATOR_NAME="Partner Name"
第二步:自动生成配置
与其维护几十个静态的 app.json 文件,我们改用 generate-config.js 脚本。脚本读取环境变量,再映射到一份集中维护的“主映射表”——里面是项目 ID、启动屏颜色和商店 URL。随后它自动写入 app.config.js 并更新 eas.json。
// generate-config.js
const fs = require("fs");
const operatorName = process.env.EXPO_PUBLIC_OPERATOR_NAME;
const slug = operatorName.replace(/\s/g, "_").toLowerCase();
const partnerBranding = {
"Partner_A": { projectId: "...", splash: "#502FAE", icon: "icon_a.png" },
"Partner_B": { projectId: "...", splash: "#00A99D", icon: "icon_b.png" },
};
const configContent = `
export default {
"expo": {
"name": "${operatorName}",
"slug": "${slug}",
"icon": "./assets/branding/${slug}/icon.png",
"ios": { "bundleIdentifier": "com.${slug}.ios.app" },
"android": { "package": "com.${slug}.android.app" },
"plugins": [
["expo-splash-screen", {
"backgroundColor": "${partnerBranding[slug].splash}",
"image": "./assets/branding/${slug}/logo.png"
}]
],
"extra": { "eas": { "projectId": "${partnerBranding[slug].projectId}" } }
}
}`;
fs.writeFileSync("app.config.js", configContent);
第三步:用主题引擎实现动态品牌
generate-config.js 脚本在构建层面设定好 operatorName 之后,我们用一个集中的 theme.ts 文件控制整个应用的外观与体验。把运营商名称映射到对应的配置对象,就能替换从主品牌色到具体分类图标(比如“观光”和“住宿”)的一切,而无需改动任何一个组件。
import { DefaultTheme } from "react-native-paper";
import { getConfig } from "../config/app.config";
// 1. Grab the name injected during the build process
const operatorName = getConfig().operatorName.toLowerCase();
export const operatorThemes: Record<string, any> = {
"default operator": {
logo: require("./images/default/logo_primary.png"),
colours: {
primary: "#204074",
secondary: "#2b569c",
locations: {
sightseeing: "#204074",
"food and drink": "#FFC107",
activity: "#2196F3",
// ...
},
},
},
"premium partner": {
logo: require("./images/premium_partner/logo.png"),
colours: {
primary: "#502FAE",
secondary: "#7CE9BD",
locations: {
sightseeing: "#502FAE",
"food and drink": "#F2B705",
activity: "#00C4B8",
// ...
},
},
},
};
// 2. Export the active theme for use in StyleSheet and Providers
export const operatorTheme = operatorThemes[operatorName] || operatorThemes["default operator"];
export const operatorColours = operatorTheme.colours;
export const theme = {
...DefaultTheme,
colors: {
...DefaultTheme.colors,
...operatorColours,
},
};
这套做法的好处
-
一套代码,无限口味: 我们只维护一套高质量组件。音频播放器或地图视图一旦改进,所有白标合作方立刻同步升级。
-
资源打包: 由于 logo 是根据构建配置
require()的,最终二进制包里只会包含该运营商相关的资源。 -
混合数据策略: 视觉识别在构建时固化,以保证速度和离线可靠性;而导览内容本身——脚本、路线和坐标——则通过 Go API 从 PostgreSQL 数据库获取。这样我们就能实时更新导览详情,无需重新提交应用商店审核。
接下来做什么?
我们计划把 Kuratour 的步行与驾车导览扩展到全球。同时将为导览运营商推出「My Tour Experience」,让他们摆脱 WhatsApp,在自己的定制应用和后端里管理导览与沟通。
我们也在考虑为旅行社添加一个门户,让他们可以通过可抽成的销售方式向客户提供 Kuratour。
结语
Kuratour 证明了把 Expo 的现代开发栈、Expo 的生产级基础设施和 AI 驱动的个性化结合起来能做出什么。我们优先采用离线优先架构和分层音频引擎,因此做出来的不只是一款旅行应用,而是一个面向位置感知叙事的稳健平台,可扩展到 14 种语言和数十个白标合作伙伴。
对开发者而言,这个项目再次说明:Expo 就是构建强大应用的平台。有了新的 expo-audio 模块、高性能的 expo-location 追踪,以及 SQLite 的可靠性,你可以构建复杂、媒体丰富的应用,在最严苛的真实环境中也能稳定运行。
亲自体验 Kuratour
无论你是寻找灵感的开发者,还是准备开启下一段旅程的旅行者,我们都邀请你亲眼看看这套技术的实际表现。探索 100 多个目的地,体验 GPS 触发的故事、离线地图,以及为你量身生成的 AI 路线。