Mobile-First EdTech: Offline-First Architecture
67 % studentů v rozvojových zemích má přístup ke vzdělávacímu obsahu výhradně přes chytrý telefon, často s nestabilním 2G/3G připojením popř všichni nepřítomní. I ve vyspělých ekonomikách dojíždějící, studenti na venkově a ti, kteří studují v místech se slabým pokrytím WiFi, čelí stejným problémům. Platforma EdTech, která vyžaduje neustálé připojení a platforma, která vylučuje značnou část svého publika.
L'offline-first architektura převrací tradiční paradigma: místo „ve výchozím nastavení online, výjimka offline“ se stane „ve výchozím nastavení offline, online pro synchronizaci“. Aplikace funguje vždy, okamžitě, i bez internetu. Když je připojení dostupné, synchronizuje data na pozadí. Výzkum z roku 2025 ukazuje, že první offline aplikace mají a O 40 % vyšší zapojení e O 25 % nižší míra okamžitého opuštění ve srovnání se síťovými aplikacemi.
V tomto článku vytvoříme kompletní architekturu progresivní webové aplikace (PWA) Offline EdTech: Service Workers pro ukládání do mezipaměti, IndexedDB pro místní úložiště, strategie synchronizace s řešením konfliktů a předběžné načítání obsahu inteligentní pro neustálé učení.
Co se dozvíte v tomto článku
- Strategie ukládání do mezipaměti pomocí služby Service Worker: nejprve mezipaměť, nejprve síť, zatuchlý při opětovném ověření
- IndexedDB pro strukturované úložiště na straně klienta s Dexie.js
- Vzor úložiště pro oddělení uživatelského rozhraní od sítě/úložiště
- Background Sync API pro spolehlivou synchronizaci
- Řešení konfliktů pro offline úpravy
- Inteligentní předběžné načítání obsahu na základě životopisu
- Sledování pokroku offline: fronta příkazů xAPI
- Push upozornění na připomenutí studia
1. Offline-First Architecture: The Repository Pattern
Základním principem offline-first je Úložiště vzorů: Komponenty uživatelského rozhraní nikdy nekomunikují přímo se sítí. Mluví s a úložiště který transparentně spravuje logiku ukládání do mezipaměti: čte nejprve z místní mezipaměti (okamžitá odezva), poté se synchronizuje se serverem na pozadí (tichá aktualizace). Uživatel nikdy nečeká na síť.
// 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. Servisní pracovník: Strategie ukládání do mezipaměti
Service Worker je srdcem offline-first. Zachycuje všechny požadavky HTTP a rozhodne se, jak reagovat: z mezipaměti, ze sítě nebo z kombinace. Pro platformu EdTech používáme různé strategie pro různé typy aktiv.
// 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. Inteligentní předběžné načítání obsahu
Nestačí reagovat na požadavky offline: musíme je předvídat. Pokud student sleduje lekci 3 kurzu, pravděpodobně bude chtít viz lekce 4 krátce. Inteligentní předběžné načítání stahování na pozadí obsah, který by student pravděpodobně chtěl, když je dostupné připojení, aniž byste spotřebovávali zbytečná data.
// 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. Řešení konfliktů pro úpravy offline
Když student změní svůj pokrok offline a poté se znovu přihlásí, může dojít ke konfliktu s daty na serveru (např reset průběhu kurzu). Realizujeme strategii třícestné sloučení: Srovnejme místní stát, stát serveru a společný stav známý před odpojením.
# 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. Push Notifications for Studio Recall
Oznámení push jsou účinná pro připomenutí studentům, aby je měli udržovat studijní sérii. Web Push API funguje, i když je aplikace zavřená, přes servisního pracovníka. Implementujeme vlastní oznámení na základě na chování studentů.
// 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)));
}
}
Anti-vzory, kterým je třeba se vyhnout
- Ukládejte vše do mezipaměti bez rozdílu: Cachování citlivých dat (hlasy, osobní údaje) bez šifrování a rizika GDPR. Pro citlivá data použijte rozhraní Credentials API nebo před uložením do mezipaměti zašifrujte.
- Service Worker bez verzování: Mezipaměť bez verze bude po nasazení nadále poskytovat starý obsah. Použijte názvy mezipaměti s verzí a cleanupOutdatedCaches().
- Agresivní předběžné načítání na 2G: Předběžné načtení spotřebovává data studentů. Zkontrolujte navigator.connection.effectiveType a respektujte saveData.
- Žádné řešení konfliktu: Bez strategie sloučení se při synchronizaci ztratí nebo přepíše průběh offline. Vždy implementujte alespoň „udělejte co největší pokrok“.
- IndexedDB se synchronními operacemi: IndexedDB je asynchronní. Použijte Dexie.js, abyste se vyhnuli peklu zpětného volání a správně řešili chyby.
- Service Worker nebyl testován offline: Před nasazením vždy otestujte pomocí Chrome DevTools > Network > Offline. Offline okrajové případy se v produkci obtížně ladí.
Závěry a další kroky
Vybudovali jsme kompletní offline architekturu pro mobilní platformu EdTech: Vzor úložiště pro oddělení uživatelského rozhraní od úložiště/sítě, servisní pracovník se strategiemi ukládání do mezipaměti rozlišené podle typu zdroje, inteligentní předběžné načítání, které respektuje typ připojení, řešení konfliktů pro průběh offline a Push Notifications pro připomenutí studia.
Výsledkem je platforma, která funguje pro všechny studenty bez ohledu na to od kvality připojení: od optického vlákna po přerušované 2G, procházející metro bez signálu. Učení se nikdy nezastaví.
V posledním článku série prozkoumáme správa obsahu více nájemců se SCORMem: jak strukturovat, verzovat a distribuovat Balíčky obsahu eLearning, které se rozšiřují pro tisíce organizací.
EdTech Engineering Series
- Škálovatelná architektura LMS: Vzor pro více nájemců
- Algoritmy adaptivního učení: Od teorie k produkci
- Streamování videa pro vzdělávání: WebRTC vs HLS vs DASH
- AI Proctoring Systems: Privacy-first with Computer Vision
- Personalizovaný lektor s LLM: RAG pro ukotvení znalostí
- Gamification Engine: Architektura a státní stroj
- Learning Analytics: Data Pipeline s xAPI a Kafka
- Spolupráce v reálném čase v EdTech: CRDT a WebSocket
- Mobile-First EdTech: Offline-First Architecture (tento článek)
- Správa obsahu pro více nájemců: Správa verzí a SCORM







