EdTech zorientowany na urządzenia mobilne: architektura zorientowana na tryb offline
67% uczniów w krajach rozwijających się ma dostęp do treści edukacyjnych wyłącznie za pośrednictwem smartfona, często z niestabilnym połączeniem 2G/3G lub wszyscy nieobecni. Nawet w gospodarkach rozwiniętych, osoby dojeżdżające do pracy, studenci na obszarach wiejskich a osoby studiujące w miejscach o słabym zasięgu Wi-Fi stoją przed tymi samymi wyzwaniami. Platforma EdTech wymagająca stałego połączenia i platforma, która wyklucza znaczną część swoich odbiorców.
L'architektura oparta na trybie offline obala tradycyjny paradygmat: zamiast „domyślnie online, wyjątek offline” zmienia się na „domyślnie offline, online w celu synchronizacji”. Aplikacja zawsze działa natychmiast, nawet bez Internetu. Gdy połączenie jest dostępne, synchronizuje dane w tle. Badania przeprowadzone w 2025 r. pokazują, że aplikacje działające przede wszystkim w trybie offline mają: 40% większe zaangażowanie e 25% niższy współczynnik odrzuceń w porównaniu z aplikacjami sieciowymi.
W tym artykule zbudujemy kompletną architekturę Progressive Web App (PWA) EdTech w trybie offline: Service Workers do buforowania, IndexedDB do przechowywania lokalnego, strategie synchronizacji z rozwiązywaniem konfliktów i wstępnym pobieraniem treści inteligentny do ciągłego uczenia się.
Czego dowiesz się w tym artykule
- Strategie buforowania za pomocą Service Worker: najpierw pamięć podręczna, najpierw sieć, nieaktualne podczas ponownego sprawdzania
- IndexedDB do przechowywania strukturalnego po stronie klienta z Dexie.js
- Wzorzec repozytorium oddzielający interfejs użytkownika od sieci/magazynu
- Interfejs API synchronizacji w tle zapewniający niezawodną synchronizację
- Rozwiązywanie konfliktów przy edycji offline
- Inteligentne wstępne pobieranie treści na podstawie CV
- Śledzenie postępu w trybie offline: kolejka instrukcji xAPI
- Powiadomienia push z przypomnieniami o nauce
1. Architektura oparta na trybie offline: wzorzec repozytorium
Podstawową zasadą „najpierw offline” jest: Repozytorium wzorców: Komponenty interfejsu użytkownika nigdy nie komunikują się bezpośrednio z siecią. Rozmawiają z A magazyn który w przejrzysty sposób zarządza logiką buforowania: najpierw odczytuje z lokalnej pamięci podręcznej (natychmiastowa odpowiedź), a następnie synchronizuje się z serwerem w tle (cicha aktualizacja). Użytkownik nigdy nie czeka na sieć.
// 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: strategie buforowania
Service Worker jest sercem trybu offline. Przechwytuje wszystkie żądania HTTP i decyduje, jak odpowiedzieć: z pamięci podręcznej, z sieci lub kombinacji. W przypadku platformy EdTech stosujemy różne strategie dla różnych typów aktywów.
// 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. Inteligentne wstępne pobieranie treści
Nie wystarczy odpowiadać na prośby w trybie offline: musimy je przewidzieć. Jeśli uczeń ogląda Lekcję 3 kursu, prawdopodobnie tego chce zobacz wkrótce Lekcję 4. Inteligentne wstępne pobieranie plików w tle treści, które prawdopodobnie przypadną do gustu uczniowi, gdy połączenie będzie dostępne, bez zużywania niepotrzebnych danych.
// 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. Rozwiązywanie konfliktów w przypadku edycji offline
Kiedy uczeń zmieni swoje postępy w trybie offline, a następnie zaloguje się ponownie, może wystąpić konflikt z danymi na serwerze (np. nauczyciel ma reset postępu kursu). Wdrażamy strategię połączenie trójstronne: Porównajmy stan lokalny, stan serwera oraz ogólny stan znany przed rozłączeniem.
# 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. Powiadomienia push dotyczące wycofania Studio
Powiadomienia push doskonale przypominają uczniom o konieczności konserwacji pasmo studiów. Web Push API działa nawet wtedy, gdy aplikacja jest zamknięta, za pośrednictwem pracownika serwisu. Wdrażamy niestandardowe powiadomienia w oparciu o na temat zachowań uczniów.
// 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)));
}
}
Anty-wzorce, których należy unikać
- Buforuj wszystko bezkrytycznie: Buforowanie wrażliwych danych (głosów, danych osobowych) bez szyfrowania i ryzyka RODO. Użyj interfejsu Credentials API do wrażliwych danych lub zaszyfruj przed buforowaniem.
- Service Worker bez wersjonowania: Po wdrożeniu pamięć podręczna bez wersji nadal obsługuje starą zawartość. Użyj wersjonowanych nazw pamięci podręcznej i metody cleanupOutdatedCaches().
- Agresywne pobieranie z wyprzedzeniem w sieci 2G: Pobieranie z wyprzedzeniem zużywa dane uczniów. Sprawdź navigator.connection.efficiencyType i uwzględnij saveData.
- Brak rozwiązania konfliktu: Bez strategii scalania postęp w trybie offline zostanie utracony lub nadpisany podczas synchronizacji. Zawsze wdrażaj przynajmniej zasadę „największego postępu”.
- IndexedDB z operacjami synchronicznymi: IndexedDB jest asynchroniczny. Użyj Dexie.js, aby uniknąć piekła wywołań zwrotnych i poprawnie obsługiwać błędy.
- Service Worker nie był testowany w trybie offline: Przed wdrożeniem zawsze testuj za pomocą Chrome DevTools > Sieć > Offline. Przypadki brzegowe offline są trudne do debugowania w środowisku produkcyjnym.
Wnioski i dalsze kroki
Zbudowaliśmy kompletną architekturę offline dla mobilnej platformy EdTech: Wzorzec repozytorium oddzielający interfejs użytkownika od pamięci masowej/sieci, Service Worker ze strategiami buforowanie zróżnicowane według rodzaju zasobu, inteligentne pobieranie wstępne, które uwzględnia typ połączenia, rozwiązywanie konfliktów dotyczących postępu w trybie offline i powiadomienia push w celu przypomnienia studiów.
Rezultatem jest platforma, która działa dla wszystkich uczniów, niezależnie od tego od jakości połączenia: od światłowodu do przerywanego 2G, przechodzącego metro bez sygnału. Nauka nigdy się nie kończy.
W ostatnim artykule z tej serii omówimy zarządzanie treścią wielu dzierżawców z SCORM: jak strukturować, wersjonować i rozpowszechniać Pakiety treści e-learningowych, które można skalować do tysięcy organizacji.
Seria inżynieryjna EdTech
- Skalowalna architektura LMS: wzorzec wielu najemców
- Algorytmy uczenia się adaptacyjnego: od teorii do produkcji
- Strumieniowe przesyłanie wideo dla edukacji: WebRTC vs HLS vs DASH
- Systemy AI Proctoring: przede wszystkim prywatność dzięki wizji komputerowej
- Spersonalizowany nauczyciel z LLM: RAG dla uziemienia wiedzy
- Silnik grywalizacji: architektura i maszyna stanu
- Learning Analytics: Potok danych z xAPI i Kafką
- Współpraca w czasie rzeczywistym w EdTech: CRDT i WebSocket
- Mobile-First EdTech: architektura oparta na trybie offline (ten artykuł)
- Zarządzanie treścią dla wielu dzierżawców: wersjonowanie i SCORM







