ClaudeExpo2026/01/06 14:30

The offline first, multilingual audio tour app built with Expo

要点だけを先に読めるように短く再構成したセクションです。

元記事

Quick Digest

要約

要点だけを先に読めるように短く再構成したセクションです。

claudejamodel: claude-sonnet-4-20250514

Expoで構築されたオフラインファーストの多言語音声ツアーアプリ

Key Points

  • Expo SDKでオフラインファーストの音声ツアーアプリを構築
  • GPS連動による自動音声再生とマルチレイヤーサウンド実装
  • SQLiteとファイルシステムを活用したデータ整合性管理

Summary

Kuratourは、Expo SDKを使用して構築されたGPS連動の音声ツアーアプリです。オフラインファーストアーキテクチャを採用し、ネットワーク接続がない環境でも動作する多言語対応のツアーガイドアプリを実現しています。

Key Points

  • オフラインファーストアーキテクチャ: expo-file-systemexpo-sqliteを使用してマップタイル、画像、音声ファイルを事前ダウンロード
  • リアルタイムGPSトリガー: expo-locationを活用した高精度位置追跡により、適切なタイミングで音声ナレーションを自動再生
  • 多言語音声エンジン: 新しいexpo-audioライブラリを使用したスムーズな音声再生システム
  • データ整合性管理: iOS アップデート時のファイル整合性チェック機能をexpo-applicationで実装
  • SQLiteによるデータ管理: バージョニングシステムを使用したスキーママイグレーション対応
  • 白ラベルアプリ対応: ツアーオペレーター向けの白ラベルアプリケーションとしても展開

Full Translation

翻訳

原文の流れを保ったまま読める翻訳セクションです。

claudejamodel: claude-sonnet-4-20250514

Expoで構築されたオフラインファースト、多言語音声ツアーアプリ

Expoで構築されたオフラインファースト、多言語音声ツアーアプリ

ユーザー • React Native • 開発 • 2026年1月6日 • 11分で読める

Clinton Forster ゲスト著者

KuratourがExpoとexpo-audioをオフラインファーストアーキテクチャで活用し、GPS連動音声ツアーとツアーオペレーター向けホワイトラベルアプリを実現する方法をご紹介します。

これは、オーストラリア出身の情熱的なフルスタック開発者Clinton Forsterによるゲスト投稿です。彼のアプリ(Kuratour)は最近、2025年アドベンチャーツーリズム賞の革新賞を受賞しました。彼が最初に私たちの注目を集めたのは、2025 Expo App AwardsにKuratourを応募した時でした。


旅行は発見のように感じられるべきで、物流のようであってはなりません。しかし多くの旅行者にとって、最高の物語は高額なローミング料金、不安定な電波、または硬直したツアーグループのスケジュールの陰に隠れています。Kuratourはそれを変えるために構築され、あなたの携帯電話を世界の果てでも動作する個人的な位置認識ツアーガイドに変えます。

Kuratourのビジョン

Kuratourは、Expoで完全に構築された多言語GPS対応音声ツアーアプリです。リアルタイム位置トリガー、オフラインマップ、AI生成ルートを通じて没入型のウォーキングおよびドライビング体験を提供します。現在、Kuratourは私たち自身のプラットフォームだけでなく、オーストラリア全土およびそれ以外の地域のツアーオペレーター向けホワイトラベルアプリスイートも支えています。

この投稿では、Expo SDKを活用して「オフラインファースト」の課題を解決し、新しいexpo-audioライブラリを使用してモダンなオーディオエンジンを構築した方法について詳しく説明します。

Expoの基盤:大規模なモダンReact Native

Kuratourは新しいアーキテクチャ上でExpo SDKで完全に構築され、ファーストパーティモジュールとコミュニティパッケージの両方を使用してネイティブ品質の体験を提供します。

コアモジュール:

  • expo-location - リアルタイムGPS追跡と地域ベーストリガー用
  • expo-file-systemexpo-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の簡略版です。既存のユーザーベースに「観察された方向」などの機能を追加する際に重要なスキーマ移行を処理するためのバージョニングシステムを使用しています。

import * as SQLite from "expo-sqlite";

export class DatabaseService {
  private db: SQLite.SQLiteDatabase;
  private currentVersion = 3;

  constructor() {
    // データベースを開く(または作成)
    this.db = SQLite.openDatabaseSync("db");
    this.initializeSchema();
  }

  private initializeSchema() {
    try {
      // 1. スキーマテーブルが存在しない場合は作成
      this.db.execSync(`
        CREATE TABLE IF NOT EXISTS schema_version (
          version INTEGER PRIMARY KEY NOT NULL
        );
      `);

      // DBバージョンをチェックし、適切な移行を適用
      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) {
        // ... 必要に応じてさらなる移行
      }
    } catch (error) {
      console.error("Error applying migrations:", error);
    }
  }

  // オフライン取得用にツアーオブジェクト全体をJSON文字列として保存
  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:アセットダウンロードの調整

ユーザーが「ダウンロード」をタップすると、複数のリソースを同期する必要があります:Mapboxタイル、MP3音声ファイル、高解像度画像。expo-file-systemを使用してリモートディレクトリ構造をローカルにミラーリングします。

import { Directory, File, Paths } from "expo-file-system";

const downloadFile = async (key: string) => {
  const CDNUrl = getUrl(key); // ファイルへのCDN/ストレージURL
  const dirName = key.substring(0, key.lastIndexOf("/"));
  const destinationDir = new Directory(Paths.document, dirName);
  // ユーザーにプロンプトを表示せずにdocumentsディレクトリにデータを保存できます

  // ディレクトリが存在することを確認
  if (!destinationDir.exists) {
    destinationDir.create({ intermediates: true });
  }

  const file = new File(destinationDir, key.split("/").pop() || "");
  if (!file.exists) {
    // リモートURLからローカルファイルシステムにファイルをダウンロード
    await File.downloadFileAsync(CDNUrl, file);
  }

  return file.uri; // このローカルURIは<Audio>や<Image>コンポーネントが使用するもの
};

ステップ3:UIとの統合

TourDownloadコンポーネントでは、これらの呼び出しを順次プロセスでラップします。まずマップの方向を取得し、次にメイン画像をダウンロードし、最後にツアー内のすべての場所をループして、その特定の音声とバックグラウンドトラックを取得します。完了すると、ローカルファイルURIをSQLiteに保存し直し、アプリがネットワーク接続なしで完全に実行できるようにします。

ステップ4:カスタムフックでのスマート切り替え

データがローカルに保存されると、アプリはどのソースを優先するかを知る必要があります。React NavigationのuseFocusEffectを活用するuseOfflineDetectorカスタムフックを使用します。これにより、ユーザーがツアーページに移動するたびに、まずローカル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);
    // SQLiteでデータが見つかった場合、オフライン対応としてフラグを立てる
    setIsOfflineAvailable(offlineTour !== null);
  }, [tourID]);

  // 画面がフォーカスされるたびにチェックを再実行
  useFocusEffect(
    useCallback(() => {
      fetchOfflineStatus();
    }, [fetchOfflineStatus])
  );

  return isOfflineAvailable;
};

iOSアップデート時のデータ整合性の処理

iOSでオフラインファーストアプリを構築する際の一般的な落とし穴は、アプリアップグレード中のDocumentsディレクトリの動作です。SQLiteデータベースはアップデート間で持続しますが、メジャーバージョンアップグレード中に内部パスが変更された場合、documentsディレクトリにexpo-file-system経由で保存されたファイルが削除されたり孤立したりすることがあります。

ユーザーが「ダウンロード済み」ツアーを開いたときに音声ファイルが見つからないという事態を防ぐため、コンテンツ検証ブリッジを実装しました。

ステップ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) {
      // アップデートが発生しました!オフラインファイルを再検証
      await recheckDownloadedContent(language, voice, dispatch);
      // トラッカーを更新
      await storeLocalData(StorageKeys.LAST_CHECKED_VERSION, {
        version: currentVersion
      });
    }
  }, [currentVersion]);
};

ステップ2:ファイルシステムの検証

recheckDownloadedContent関数は、SQLiteで「オフライン」とマークされたすべてのツアーを反復処理し、ファイルシステムにpingしてアセットが実際に存在することを確認します。1つでもファイルが見つからない場合(メインツアー画像や最初の音声ストップなど)、バックグラウンドでの再ダウンロードをトリガーします。

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

export const checkMissingFiles = async (offlineTour: Tour): Promise<boolean> => {
  // メインヘッダー画像をチェック
  const mainImage = new File(offlineTour.image.key);
  if (!mainImage.exists) return true;

  // すべての場所固有のアセットをチェック
  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フックを構築しました。

位置ウォッチャーの実装

Location.watchPositionAsyncを使用することで、数秒ごと、またはユーザーが1メートルでも移動するたびにアプリの状態を更新できます。この精度は、密集した市街地でのウォーキングツアーには不可欠です。

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. フォアグラウンド権限をリクエスト
      const { status } = await Location.requestForegroundPermissionsAsync();
      if (status !== "granted") return;

      // 2. 高精度アップデートをサブスクライブ
      locationSubscription = await Location.watchPositionAsync(
        {
          accuracy: Location.Accuracy.High,
          timeInterval: 5000, // 5秒ごとに更新
          distanceInterval: 1, // または1メートルごと
        },
        (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体験」は単にテキストを読むことではありません。それは雰囲気についてです。私たちは