モバイルファーストの EdTech: オフラインファーストのアーキテクチャ
発展途上国の学生の 67% が教育コンテンツにアクセス 専らスマートフォン経由で、多くの場合不安定な 2G/3G 接続または 全員欠席。先進国であっても、地方の通勤者、学生 WiFi の電波が届かない場所で勉強している人も同じ課題に直面しています。 常時接続が必要なEdTechプラットフォームと、 視聴者のかなりの部分を除外します。
L'オフラインファーストのアーキテクチャ 従来のパラダイムを覆します。 「デフォルトではオンライン、例外的にオフライン」ではなく、「デフォルトではオフライン、 同期のためにオンラインです。」アプリはインターネットがなくても常にすぐに動作します。 接続が利用可能になると、バックグラウンドでデータが同期されます。 2025 年の調査によると、オフラインファーストのアプリには エンゲージメントが 40% 増加 e 直帰率が 25% 低下 ネットワークファーストのアプリと比較して。
この記事では、プログレッシブ Web アプリ (PWA) の完全なアーキテクチャを構築します。 オフラインファーストの EdTech: キャッシュ用の Service Worker、ローカル ストレージ用の IndexedDB、 競合解決を伴う同期戦略とコンテンツのプリフェッチ 継続的な学習のためのインテリジェントな機能。
この記事で学べること
- Service Worker を使用したキャッシュ戦略: キャッシュ優先、ネットワーク優先、再検証中に失効する
- Dexie.js を使用したクライアント側の構造化ストレージ用 IndexedDB
- UIをネットワーク/ストレージから分離するリポジトリパターン
- 信頼性の高い同期のためのバックグラウンド同期 API
- オフライン編集の競合解決
- インテリジェントな履歴書ベースのコンテンツのプリフェッチ
- オフラインでの進行状況追跡: xAPI ステートメント キュー
- 学習リマインダーのプッシュ通知
1. オフラインファーストのアーキテクチャ: リポジトリ パターン
オフラインファーストの基本原則は、 パターンリポジトリ: UI コンポーネントがネットワークと直接通信することはありません。彼らは誰かと話します リポジトリ これはキャッシュ ロジックを透過的に管理します。 最初にローカル キャッシュから読み取り (即時応答)、次にサーバーと同期します バックグラウンドで実行されます (サイレント更新)。ユーザーはネットワークを待つ必要はありません。
// src/offline/course-repository.ts
import Dexie, { Table } from 'dexie';
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
export interface CachedCourse {
id: string;
tenantId: string;
title: string;
description: string;
lessons: CachedLesson[];
totalLessons: number;
cachedAt: number;
syncVersion: number;
offlineAvailable: boolean;
}
export interface CachedLesson {
id: string;
courseId: string;
title: string;
content: string; // HTML del contenuto
videoUrl?: string;
duration: number; // secondi
cachedAt: number;
completed: boolean;
progress: number; // 0-100
syncStatus: 'synced' | 'pending' | 'conflict';
}
export interface OfflineProgressRecord {
id?: number; // Auto-increment
lessonId: string;
courseId: string;
studentId: string;
progress: number;
completed: boolean;
timeSpent: number;
answers: Record<string, any>;
timestamp: number;
syncStatus: 'pending' | 'synced' | 'failed';
xapiStatement?: object; // Statement xAPI pronto da inviare
}
export class EdTechDatabase extends Dexie {
courses!: Table<CachedCourse, string>;
lessons!: Table<CachedLesson, string>;
progress!: Table<OfflineProgressRecord, number>;
assets!: Table<{ id: string; blob: Blob; cachedAt: number }, string>;
constructor() {
super('EdTechOfflineDB');
this.version(1).stores({
courses: 'id, tenantId, cachedAt, offlineAvailable',
lessons: 'id, courseId, cachedAt, syncStatus',
progress: '++id, lessonId, courseId, studentId, syncStatus, timestamp',
assets: 'id, cachedAt',
});
}
}
@Injectable({ providedIn: 'root' })
export class CourseRepository {
private db = new EdTechDatabase();
constructor(private http: HttpClient) {}
/**
* Ottieni i dettagli di un corso.
* Strategia: stale-while-revalidate
* 1. Ritorna subito i dati dalla cache locale (se disponibili)
* 2. In background, aggiorna la cache dal server
*/
async getCourse(courseId: string): Promise<CachedCourse | null> {
// Lettura immediata dalla cache
const cached = await this.db.courses.get(courseId);
// Revalidazione in background (non blocca l'UI)
this.revalidateCourse(courseId).catch(() => {}); // Fire-and-forget
return cached ?? null;
}
async getLessons(courseId: string): Promise<CachedLesson[]> {
const cached = await this.db.lessons
.where('courseId')
.equals(courseId)
.toArray();
if (cached.length > 0) {
this.revalidateLessons(courseId).catch(() => {});
return cached;
}
// Cache miss: fetch dal network
try {
const lessons = await firstValueFrom(
this.http.get<CachedLesson[]>(`/api/courses/${courseId}/lessons`)
);
await this.cacheLessons(lessons);
return lessons;
} catch {
return []; // Offline e cache vuota
}
}
async saveProgress(record: Omit<OfflineProgressRecord, 'id' | 'syncStatus'>): Promise<void> {
const fullRecord: OfflineProgressRecord = {
...record,
syncStatus: 'pending',
timestamp: Date.now(),
};
await this.db.progress.add(fullRecord);
// Aggiorna anche la cache della lezione
await this.db.lessons.update(record.lessonId, {
progress: record.progress,
completed: record.completed,
syncStatus: 'pending',
});
// Tenta sync immediato se online
if (navigator.onLine) {
await this.syncPendingProgress();
}
// Se offline, il Service Worker gestira il background sync
}
async syncPendingProgress(): Promise<void> {
const pending = await this.db.progress
.where('syncStatus')
.equals('pending')
.toArray();
for (const record of pending) {
try {
await firstValueFrom(
this.http.post('/api/progress', record)
);
await this.db.progress.update(record.id!, { syncStatus: 'synced' });
} catch (error) {
await this.db.progress.update(record.id!, { syncStatus: 'failed' });
}
}
}
private async revalidateCourse(courseId: string): Promise<void> {
if (!navigator.onLine) return;
try {
const course = await firstValueFrom(
this.http.get<CachedCourse>(`/api/courses/${courseId}`)
);
await this.db.courses.put({ ...course, cachedAt: Date.now() });
} catch {
// Silently fail: la cache e ancora valida
}
}
private async revalidateLessons(courseId: string): Promise<void> {
if (!navigator.onLine) return;
try {
const lessons = await firstValueFrom(
this.http.get<CachedLesson[]>(`/api/courses/${courseId}/lessons`)
);
await this.cacheLessons(lessons);
} catch {}
}
private async cacheLessons(lessons: CachedLesson[]): Promise<void> {
const now = Date.now();
await this.db.lessons.bulkPut(
lessons.map(l => ({ ...l, cachedAt: now, syncStatus: 'synced' as const }))
);
}
}
2. Service Worker: キャッシュ戦略
Service Worker はオフラインファーストの中核です。すべてのHTTPリクエストをインターセプトします そして、キャッシュから、ネットワークから、またはその組み合わせから応答する方法を決定します。 EdTech プラットフォームでは、さまざまな資産タイプに対してさまざまな戦略を使用します。
// src/service-worker.ts (Workbox-based)
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate, NetworkOnly } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { ExpirationPlugin } from 'workbox-expiration';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
declare const self: ServiceWorkerGlobalScope;
// Pre-cache: shell applicazione (HTML, CSS, JS critici)
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
// === STRATEGIE PER TIPO DI RISORSA ===
// 1. Contenuto statico (immagini, font, icone): Cache-First con TTL lungo
registerRoute(
({ request }) => request.destination === 'image' || request.destination === 'font',
new CacheFirst({
cacheName: 'edtech-static-v1',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 }), // 30 giorni
],
}),
);
// 2. Video educativi: Cache-First per contenuto già scaricato
registerRoute(
({ url }) => url.pathname.startsWith('/api/assets/video/'),
new CacheFirst({
cacheName: 'edtech-videos-v1',
plugins: [
new CacheableResponsePlugin({ statuses: [200, 206] }), // Supporta range requests
new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 7 * 24 * 60 * 60 }), // 7 giorni
],
}),
);
// 3. API dati corso: Stale-While-Revalidate
// Risposta istantanea dalla cache, aggiornamento silenzioso in background
registerRoute(
({ url }) => url.pathname.startsWith('/api/courses/') && !url.pathname.includes('/progress'),
new StaleWhileRevalidate({
cacheName: 'edtech-api-courses-v1',
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 * 60 }), // 1 ora
],
}),
);
// 4. API profilo studente e progressi: Network-First
// Critico avere dati aggiornati, ma fallback su cache se offline
registerRoute(
({ url }) => url.pathname.startsWith('/api/students/') || url.pathname.startsWith('/api/progress'),
new NetworkFirst({
cacheName: 'edtech-api-student-v1',
networkTimeoutSeconds: 5,
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 }), // 24 ore
],
}),
);
// 5. POST di progressi: Background Sync per affidabilità
const bgSyncPlugin = new BackgroundSyncPlugin('edtech-progress-sync', {
maxRetentionTime: 7 * 24 * 60, // Ritenta per 7 giorni (in minuti)
});
registerRoute(
({ url, request }) => url.pathname.startsWith('/api/progress') && request.method === 'POST',
new NetworkOnly({ plugins: [bgSyncPlugin] }),
'POST',
);
// 6. Navigazione SPA: Fallback su index.html se offline
registerRoute(
new NavigationRoute(
new NetworkFirst({
cacheName: 'edtech-shell-v1',
networkTimeoutSeconds: 3,
}),
),
);
3. インテリジェントなコンテンツのプリフェッチ
オフラインでリクエストに応答するだけでは十分ではありません。リクエストを予測する必要があります。 生徒がコースのレッスン 3 を視聴している場合、おそらく次のことを望んでいます。 すぐにレッスン 4 を参照してください。バックグラウンドでのインテリジェントなプリフェッチダウンロード 接続が利用可能な場合、生徒がおそらく好むであろうコンテンツ、 不要なデータを消費せずに済みます。
// src/offline/prefetch.service.ts
import { Injectable, Inject } from '@angular/core';
import { PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
interface PrefetchConfig {
maxLessonsAhead: number; // Quante lezioni avanzate pre-fetch
maxVideosAhead: number; // Video riservano più spazio
minConnectionType: string; // '4g', '3g' - tipo minimo connessione
maxStorageUsage: number; // MB massimi per pre-fetch
}
@Injectable({ providedIn: 'root' })
export class ContentPrefetchService {
private config: PrefetchConfig = {
maxLessonsAhead: 3,
maxVideosAhead: 1,
minConnectionType: '3g',
maxStorageUsage: 200, // 200 MB
};
constructor(
private http: HttpClient,
@Inject(PLATFORM_ID) private platformId: object,
) {}
async prefetchNextLessons(
courseId: string,
currentLessonId: string,
allLessonIds: string[],
): Promise<void> {
if (!isPlatformBrowser(this.platformId)) return;
// Verifica connessione e storage disponibile
if (!this.shouldPrefetch()) return;
const currentIdx = allLessonIds.indexOf(currentLessonId);
if (currentIdx === -1) return;
const toFetch = allLessonIds
.slice(currentIdx + 1, currentIdx + 1 + this.config.maxLessonsAhead);
for (const lessonId of toFetch) {
await this.prefetchLesson(lessonId).catch(() => {});
}
}
private async prefetchLesson(lessonId: string): Promise<void> {
// Usa cache della fetch API direttamente per il pre-fetching
const cache = await caches.open('edtech-api-courses-v1');
const url = `/api/lessons/${lessonId}`;
const existing = await cache.match(url);
if (existing) return; // Già in cache
try {
const response = await fetch(url);
if (response.ok) {
await cache.put(url, response.clone());
const lesson = await response.json();
if (lesson.videoUrl && this.config.maxVideosAhead > 0) {
await this.prefetchVideo(lesson.videoUrl);
}
}
} catch {} // Silently fail se offline
}
private async prefetchVideo(videoUrl: string): Promise<void> {
const cache = await caches.open('edtech-videos-v1');
const existing = await cache.match(videoUrl);
if (existing) return;
// Pre-fetch solo i primi 5MB del video (segmento iniziale per playback immediato)
const response = await fetch(videoUrl, {
headers: { Range: 'bytes=0-5242880' }, // 5MB
});
if (response.ok || response.status === 206) {
await cache.put(videoUrl, response);
}
}
private shouldPrefetch(): boolean {
const connection = (navigator as any).connection;
if (!connection) return navigator.onLine;
if (connection.saveData) return false; // Rispetta "risparmio dati"
if (connection.effectiveType === 'slow-2g' || connection.effectiveType === '2g') return false;
return navigator.onLine;
}
async getStorageUsage(): Promise<{ used: number; quota: number }> {
if (!('storage' in navigator)) return { used: 0, quota: 0 };
const estimate = await navigator.storage.estimate();
return {
used: Math.round((estimate.usage ?? 0) / 1024 / 1024),
quota: Math.round((estimate.quota ?? 0) / 1024 / 1024),
};
}
async clearOldCache(maxAgeDays: number = 7): Promise<void> {
const db = new EdTechDatabase();
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
await db.lessons.where('cachedAt').below(cutoff).delete();
await db.courses.where('cachedAt').below(cutoff).delete();
}
}
4. オフライン編集時の競合解決
生徒がオフラインで進行状況を変更してから再度ログインすると、 サーバー上のデータと競合している可能性があります (例: 教師が コースの進行状況はリセットされます)。私たちは戦略を実行します 三方向マージ: ローカルな状態、状態を比較してみましょう サーバーの状態、および切断前にわかっている一般的な状態。
# server/conflict_resolution.py
from dataclasses import dataclass
from typing import Optional, Dict, Any
from enum import Enum
from datetime import datetime
class ConflictStrategy(Enum):
LOCAL_WINS = "local_wins" # Il progresso locale ha priorità
SERVER_WINS = "server_wins" # Il dato del server ha priorità
MERGE = "merge" # Merge intelligente (prendi il max)
MANUAL = "manual" # Richiedi risoluzione manuale
@dataclass
class ProgressVersion:
progress: float # 0.0 - 1.0
completed: bool
time_spent: int # secondi
answers: Dict[str, Any]
timestamp: datetime
version: int # Numero di versione per optimistic concurrency
@dataclass
class ConflictResult:
strategy_used: ConflictStrategy
resolved_progress: ProgressVersion
conflict_detected: bool
details: str
class ProgressConflictResolver:
"""
Risolve conflitti tra progressi offline e dati del server.
Implementa three-way merge: local, server, base (stato comune).
"""
def resolve(
self,
local: ProgressVersion,
server: ProgressVersion,
base: Optional[ProgressVersion] = None,
) -> ConflictResult:
# Nessun conflitto: stesso timestamp o stessa versione
if local.version == server.version:
return ConflictResult(
strategy_used=ConflictStrategy.MERGE,
resolved_progress=local,
conflict_detected=False,
details="No conflict: versions match",
)
# Strategia: prendi il progresso più alto (non vogliamo mai fare perdere progressi)
if local.progress >= server.progress:
winner = local
strategy = ConflictStrategy.LOCAL_WINS
details = f"Local progress {local.progress:.1%} >= server {server.progress:.1%}"
else:
winner = server
strategy = ConflictStrategy.SERVER_WINS
details = f"Server progress {server.progress:.1%} > local {local.progress:.1%}"
# Merge del tempo trascorso: somma i periodi disgiunti
merged_time = self._merge_time_spent(local, server, base)
# Merge delle risposte ai quiz: unione con preferenza per le più recenti
merged_answers = {**server.answers, **local.answers} if local.timestamp > server.timestamp else {**local.answers, **server.answers}
resolved = ProgressVersion(
progress=winner.progress,
completed=local.completed or server.completed, # Se uno dei due ha completato, completato
time_spent=merged_time,
answers=merged_answers,
timestamp=max(local.timestamp, server.timestamp),
version=max(local.version, server.version) + 1, # Nuova versione
)
return ConflictResult(
strategy_used=strategy,
resolved_progress=resolved,
conflict_detected=True,
details=details,
)
def _merge_time_spent(
self,
local: ProgressVersion,
server: ProgressVersion,
base: Optional[ProgressVersion],
) -> int:
"""Calcola il tempo totale senza doppio conteggio."""
if base is None:
# Senza base: prendi il massimo (evita sovra-conteggio)
return max(local.time_spent, server.time_spent)
# Three-way merge: aggiungi i delta relativi alla base
local_delta = max(0, local.time_spent - base.time_spent)
server_delta = max(0, server.time_spent - base.time_spent)
return base.time_spent + local_delta + server_delta
5. Studio リコールのプッシュ通知
プッシュ通知は、学生にメンテナンスを促すのに強力です 勉強の連続。 Web Push APIはアプリが閉じているときでも動作します。 Service Worker経由。カスタム通知ベースで実装します 学生の行動について。
// src/offline/push-notifications.service.ts
import { Injectable, Inject } from '@angular/core';
import { PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class PushNotificationsService {
private vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY';
constructor(
private http: HttpClient,
@Inject(PLATFORM_ID) private platformId: object,
) {}
async subscribe(studentId: string): Promise<boolean> {
if (!isPlatformBrowser(this.platformId)) return false;
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false;
try {
const registration = await navigator.serviceWorker.ready;
const permission = await Notification.requestPermission();
if (permission !== 'granted') return false;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey),
});
// Salva subscription sul server
await firstValueFrom(this.http.post('/api/push/subscribe', {
studentId,
subscription: subscription.toJSON(),
}));
return true;
} catch (error) {
console.error('Push subscription failed:', error);
return false;
}
}
async unsubscribe(): Promise<void> {
if (!isPlatformBrowser(this.platformId)) return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
await firstValueFrom(this.http.delete('/api/push/subscribe'));
}
}
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map(c => c.charCodeAt(0)));
}
}
避けるべきアンチパターン
- すべてを無差別にキャッシュします。 機密データ (投票、個人データ) を暗号化せずにキャッシュし、GDPR リスクを回避します。機密データには認証情報 API を使用するか、キャッシュする前に暗号化してください。
- バージョン管理を行わない Service Worker: バージョン管理されていないキャッシュは、デプロイメント後も古いコンテンツを提供し続けます。バージョン管理されたキャッシュ名と cleanupOutdatedCaches() を使用します。
- 2G での積極的なプリフェッチ: プリフェッチは生徒データを消費します。 navigator.connection.EffectiveType を確認し、saveData を尊重します。
- 競合解決なし: マージ戦略を使用しない場合、オフラインの進行状況は同期時に失われるか上書きされます。少なくとも「最大限の進歩を遂げる」ことを常に実装してください。
- 同期操作を使用した IndexedDB: IndexedDB は非同期です。 Dexie.js を使用してコールバック地獄を回避し、エラーを正しく処理します。
- Service Worker はオフラインでテストされていません: デプロイする前に、必ず Chrome DevTools > Network > Offline を使用してテストしてください。オフラインのエッジケースは実稼働環境でデバッグするのが困難です。
結論と次のステップ
私たちは、モバイル EdTech プラットフォーム用の完全なオフライン ファースト アーキテクチャを構築しました。 ストレージ/ネットワークから UI を分離するリポジトリ パターン、戦略を備えた Service Worker リソースの種類ごとに区別されたキャッシュ、リソースの種類を考慮したインテリジェントなプリフェッチ 接続タイプ、オフライン進行状況の競合解決、およびプッシュ通知 勉強の思い出のために。
その結果、学生に関係なく、すべての学生にとって機能するプラットフォームが誕生しました。 接続の品質から: 光ファイバーから断続的な 2G、通過まで 信号のない地下鉄。学びは決して止まらない。
シリーズの最後の記事では、 コンテンツ管理 SCORM を使用したマルチテナント: 構造、バージョン、配布方法 数千の組織に拡張できる e ラーニング コンテンツ パッケージ。
EdTechエンジニアリングシリーズ
- スケーラブルな LMS アーキテクチャ: マルチテナント パターン
- 適応学習アルゴリズム: 理論から本番まで
- 教育向けビデオ ストリーミング: WebRTC vs HLS vs DASH
- AI 監督システム: コンピューター ビジョンによるプライバシー最優先
- LLM を使用した個別の家庭教師: 知識の基礎を築くための RAG
- ゲーミフィケーション エンジン: アーキテクチャとステート マシン
- ラーニング アナリティクス: xAPI と Kafka を使用したデータ パイプライン
- EdTech におけるリアルタイム コラボレーション: CRDT と WebSocket
- モバイル ファーストの EdTech: オフライン ファーストのアーキテクチャ (この記事)
- マルチテナントコンテンツ管理: バージョン管理と SCORM







