Mobil Öncelikli Eğitim Teknolojisi: Çevrimdışı Öncelikli Mimari
Gelişmekte olan ülkelerdeki öğrencilerin %67'si eğitim içeriğine erişiyor yalnızca akıllı telefon aracılığıyla, genellikle dengesiz 2G/3G bağlantılarıyla veya hepsi yok. Gelişmiş ekonomilerde bile, işe gidip gelenler, kırsal kesimdeki öğrenciler Wi-Fi kapsama alanının zayıf olduğu yerlerde eğitim görenler de aynı zorluklarla karşılaşıyor. Sürekli bağlantı gerektiren bir EdTech platformu ve izleyicisinin önemli bir kısmını dışarıda bırakıyor.
L'çevrimdışı öncelikli mimari geleneksel paradigmayı altüst eder: "varsayılan olarak çevrimiçi, istisna olarak çevrimdışı" yerine "varsayılan olarak çevrimdışı" olur, senkronizasyon için çevrimiçi". Uygulama internet olmasa bile her zaman anında çalışır. Bağlantı mevcut olduğunda arka planda verileri senkronize eder. 2025 araştırması, çevrimdışı öncelikli uygulamaların %40 daha fazla etkileşim e %25 daha düşük hemen çıkma oranı ağ öncelikli uygulamalarla karşılaştırıldığında.
Bu makalede, Progressive Web App'in (PWA) eksiksiz mimarisini oluşturacağız. Çevrimdışı ilk Eğitim Teknolojisi: Önbelleğe alma için Hizmet Çalışanları, yerel depolama için IndexedDB, çakışma çözümü ve içeriğin önceden getirilmesiyle senkronizasyon stratejileri sürekli öğrenme için akıllı.
Bu Makalede Neler Öğreneceksiniz?
- Service Worker ile önbelleğe alma stratejileri: önbellek öncelikli, ağ öncelikli, yeniden doğrulama sırasında eski
- Dexie.js ile istemci tarafında yapılandırılmış depolama için IndexedDB
- Kullanıcı arayüzünü ağdan/depolamadan ayırmak için depo modeli
- Güvenilir senkronizasyon için Arka Plan Senkronizasyon API'si
- Çevrimdışı düzenleme için çakışma çözümü
- Akıllı özgeçmiş tabanlı içerik önceden getirme
- Çevrimdışı ilerleme takibi: xAPI bildirim kuyruğu
- Çalışma hatırlatıcıları için anlık bildirimler
1. Çevrimdışı İlk Mimari: Depo Modeli
Önce çevrimdışı yaklaşımının temel ilkesi, Desen Havuzu: Kullanıcı arayüzü bileşenleri hiçbir zaman doğrudan ağla konuşmaz. Bir kişiyle konuşuyorlar depo önbelleğe alma mantığını şeffaf bir şekilde yöneten: önce yerel önbellekten okur (anında yanıt), ardından sunucuyla senkronize olur arka planda (sessiz güncelleme). Kullanıcı hiçbir zaman ağı beklemez.
// 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. Hizmet Çalışanı: Önbelleğe Alma Stratejileri
Service Worker çevrimdışı öncelikli yaklaşımın kalbidir. Tüm HTTP isteklerini keser ve nasıl yanıt vereceğine karar verir: önbellekten, ağdan veya bunların birleşiminden. Bir Eğitim Teknolojisi platformu için farklı varlık türleri için farklı stratejiler kullanırız.
// 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. Akıllı İçerik Önceden Getirme
İsteklere çevrimdışı yanıt vermek yeterli değil: bunları öngörmemiz gerekiyor. Bir öğrenci dersin 3. Dersini izliyorsa muhtemelen şunu yapmak isteyecektir: kısaca Ders 4'e bakın. Arka planda akıllı önceden indirilen indirmeler Bağlantı sağlandığında öğrencinin muhtemelen beğeneceği içerik, Gereksiz veri tüketmeden.
// 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. Çevrimdışı Düzenleme için Uyuşmazlık Çözümü
Bir öğrenci ilerleme durumunu çevrimdışı olarak değiştirip tekrar oturum açtığında, sunucudaki verilerle bir çelişki olabilir (örneğin öğretmenin kurs ilerlemesinin sıfırlanması). Bir strateji uyguluyoruz üç yönlü birleştirme: Yerel devleti, eyaleti karşılaştıralım sunucunun durumu ve bağlantı kesilmeden önce bilinen ortak durum.
# 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. Stüdyo Geri Çağırma için Anında Bildirimler
Anında bildirimler öğrencilere bakım yapmalarını hatırlatmak açısından güçlüdür çalışma çizgisi. Web Push API'si, uygulama kapalıyken bile çalışır. Servis Çalışanı aracılığıyla. Özel bildirimleri temel alarak uyguluyoruz öğrenci davranışları üzerine.
// 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)));
}
}
Kaçınılması Gereken Anti-Desenler
- Her şeyi ayrım gözetmeksizin önbelleğe alın: Hassas verilerin (oylar, kişisel veriler) şifreleme olmadan ve GDPR riski olmadan önbelleğe alınması. Hassas veriler için Kimlik Bilgileri API'sini kullanın veya önbelleğe almadan önce şifreleyin.
- Sürüm oluşturma olmadan Hizmet Çalışanı: Sürümü değiştirilmemiş bir önbellek, dağıtımdan sonra eski içeriği sunmaya devam eder. Sürümlendirilmiş önbellek adlarını ve cleanupOutdatedCaches() işlevini kullanın.
- 2G'de agresif ön getirme: Ön getirme öğrenci verilerini tüketir. navigator.connection.activeType'ı kontrol edin ve saveData'ya uyun.
- Çatışma çözümü yok: Birleştirme stratejisi olmadığında senkronizasyon sırasında çevrimdışı ilerleme kaybolur veya üzerine yazılır. Her zaman en azından "en fazla ilerlemeyi sağla"yı uygulayın.
- Senkron işlemlerle IndexedDB: IndexedDB eşzamansızdır. Geri arama cehenneminden kaçınmak ve hataları doğru şekilde ele almak için Dexie.js'yi kullanın.
- Hizmet Çalışanı çevrimdışı olarak test edilmedi: Dağıtımdan önce daima Chrome Geliştirici Araçları > Ağ > Çevrimdışı ile test edin. Çevrimdışı uç durumların üretimde hata ayıklaması zordur.
Sonuçlar ve Sonraki Adımlar
Mobil bir Eğitim Teknolojisi platformu için eksiksiz bir çevrimdışı öncelikli mimari oluşturduk: Kullanıcı arayüzünü depolamadan/ağdan ayırmak için Depo Modeli, stratejilerle Hizmet Çalışanı kaynak türüne göre farklılaştırılmış önbelleğe alma, bağlantı türü, çevrimdışı ilerleme için çakışma çözümü ve Anında Bildirimler çalışma hatırlaması için.
Sonuç, tüm öğrenciler için işe yarayan bir platformdur. bağlantının kalitesinden: optik fiberden aralıklı 2G'ye, geçiş Metro sinyali yok. Öğrenme asla durmaz.
Serinin son makalesinde şunları inceleyeceğiz: içerik yönetimi SCORM ile çok kiracılı: nasıl yapılandırılır, versiyonlanır ve dağıtılır Binlerce kuruluşa ölçeklenen e-Öğrenim içerik paketleri.
EdTech Mühendislik Serisi
- Ölçeklenebilir LMS Mimarisi: Çok Kiracılı Model
- Uyarlanabilir Öğrenme Algoritmaları: Teoriden Üretime
- Eğitim için Video Yayını: WebRTC vs HLS vs DASH
- Yapay Zeka Gözetleme Sistemleri: Bilgisayarlı Görme ile Öncelik Gizlilik
- LLM'de Kişiselleştirilmiş Öğretmen: Bilgi Temellendirme için RAG
- Oyunlaştırma Motoru: Mimari ve Durum Makinesi
- Öğrenme Analitiği: xAPI ve Kafka ile Veri Hattı
- Eğitim Teknolojisinde Gerçek Zamanlı İşbirliği: CRDT ve WebSocket
- Mobil Öncelikli Eğitim Teknolojisi: Önce Çevrimdışı Mimari (bu makale)
- Çok Kiracılı İçerik Yönetimi: Sürüm Oluşturma ve SCORM







