用 Expo 构建的离线优先多语言语音导览应用

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

中文
复制
The offline first, multilingual audio tour app built with Expo

本文是 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 构建,跑在新架构上,既用官方模块,也用社区包,以此实现原生级别的体验。

核心模块:

打造离线优先的体验

对 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.jsExpo'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 APIPostgreSQL 数据库获取。这样我们就能实时更新导览详情,无需重新提交应用商店审核。

接下来做什么?

我们计划把 Kuratour 的步行与驾车导览扩展到全球。同时将为导览运营商推出「My Tour Experience」,让他们摆脱 WhatsApp,在自己的定制应用和后端里管理导览与沟通。

我们也在考虑为旅行社添加一个门户,让他们可以通过可抽成的销售方式向客户提供 Kuratour。

结语

Kuratour 证明了把 Expo 的现代开发栈、Expo 的生产级基础设施和 AI 驱动的个性化结合起来能做出什么。我们优先采用离线优先架构和分层音频引擎,因此做出来的不只是一款旅行应用,而是一个面向位置感知叙事的稳健平台,可扩展到 14 种语言和数十个白标合作伙伴。

对开发者而言,这个项目再次说明:Expo 就是构建强大应用的平台。有了新的 expo-audio 模块、高性能的 expo-location 追踪,以及 SQLite 的可靠性,你可以构建复杂、媒体丰富的应用,在最严苛的真实环境中也能稳定运行。

亲自体验 Kuratour

无论你是寻找灵感的开发者,还是准备开启下一段旅程的旅行者,我们都邀请你亲眼看看这套技术的实际表现。探索 100 多个目的地,体验 GPS 触发的故事、离线地图,以及为你量身生成的 AI 路线。

立即下载 Kuratour

来源: Expo Blog← 返回首页