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-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の簡略版です。既存のユーザーベースに「観察された方向」などの機能を追加する際に重要なスキーマ移行を処理するためのバージョニングシステムを使用しています。
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 {
this.db.execSync(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY NOT NULL
);
`);
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);
}
}
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);
const dirName = key.substring(0, key.lastIndexOf("/"));
const destinationDir = new Directory(Paths.document, dirName);
if (!destinationDir.exists) {
destinationDir.create({ intermediates: true });
}
const file = new File(destinationDir, key.split("/").pop() || "");
if (!file.exists) {
await File.downloadFileAsync(CDNUrl, file);
}
return file.uri;
};
ステップ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);
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 () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") return;
locationSubscription = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.High,
timeInterval: 5000,
distanceInterval: 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体験」は単にテキストを読むことではありません。それは雰囲気についてです。私たちは