- Document automatic token verification on startup and note:// prefix for Markdown notes - Add extensive AI capabilities section (Gemini integration, auto-tagging, content classification, multi-model fallback) - Document link health monitoring system with dead link detection and exclusion features - Add file sharing support (Markdown/text files) and deep links documentation - Update technology
30 KiB
ShaarIt — Analyse de Performance & Expérience Utilisateur Optimale
Date de l'audit : 9 février 2026
Version analysée : v1.0 (47 fichiers Kotlin, ~8 500 lignes de code)
Objectif : Identifier et corriger tous les points bloquants pour offrir une expérience instantanée, sans délai ni attente perceptible.
Table des Matières
- Résumé Exécutif
- Problèmes Critiques de Performance
- Optimisations du Démarrage (Cold Start)
- Optimisations Réseau & Synchronisation
- Optimisations Base de Données (Room)
- Optimisations UI/Compose
- Optimisations Mémoire
- Optimisations du Build Release
- Expérience Utilisateur — Réduction des Temps d'Attente
- Plan d'Action Priorisé
- Métriques de Suivi
1. Résumé Exécutif
L'application ShaarIt repose sur une architecture solide (Clean Architecture + MVVM, Hilt, Room, Compose). Cependant, l'analyse approfondie du code révèle 24 problèmes de performance concrets, allant de critiques (R8 désactivé, requêtes N+1 en mémoire) à modérés (regex recompilées, caches non bornés). Corrigés, ces points transformeront l'expérience utilisateur en la rendant instantanée même avec des bibliothèques de plusieurs milliers de liens.
Impact Attendu
| Métrique | Actuel (estimé) | Après optimisations |
|---|---|---|
| Taille APK | ~15-20 MB | ~5-8 MB (-60%) |
| Cold start | ~1.5-2s | ~400-600ms (-70%) |
| Affichage du flux | ~500ms | <100ms (instantané) |
| Sync complète (1000 liens) | ~30s+ | ~3-5s (-85%) |
| Recherche | ~200-500ms (LIKE) | <50ms (FTS4) |
| Mémoire au repos | ~80-120 MB | ~40-60 MB (-50%) |
2. Problèmes Critiques de Performance
2.1 R8/ProGuard Désactivé en Release
Fichier : app/build.gradle.kts (lignes 47-48)
release {
isMinifyEnabled = false // ← CRITIQUE
isShrinkResources = false // ← CRITIQUE
}
Impact :
- APK 2-3x plus gros que nécessaire
- Aucune optimisation du bytecode (inlining, dead code elimination)
- Aucune suppression des ressources inutilisées
- Pas d'obfuscation (sécurité réduite)
Solution :
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
Note
: Les règles ProGuard actuelles (
proguard-rules.pro) sont bien configurées et couvrent Retrofit, Moshi, Hilt, et les DTOs. L'activation est donc sûre. Cependant, la règle-keep class com.shaarit.** { *; }(ligne 108) est trop large et doit être affinée pour bénéficier pleinement de R8.
Priorité : 🔴 CRITIQUE
Effort : Faible (2 lignes + tests de non-régression)
Gain : -60% taille APK, +15-20% vitesse d'exécution
2.2 SimpleDateFormat recréé à chaque item
Fichier : data/repository/LinkRepositoryImpl.kt (ligne 544)
private fun LinkEntity.toDomainModel(): ShaarliLink {
return ShaarliLink(
// ...
date = java.text.SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss'Z'",
java.util.Locale.getDefault()
).format(java.util.Date(createdAt)),
// ...
)
}
Impact : SimpleDateFormat est coûteux à instancier. Cette méthode est appelée pour chaque lien affiché dans le flux paginé. Avec 20 liens par page, cela crée 20 instances inutiles à chaque chargement.
Solution : Utiliser un companion object avec un formatter thread-safe :
companion object {
private val dateFormatter = java.text.SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US
)
}
Ou mieux, utiliser java.time.Instant + DateTimeFormatter (déjà utilisé dans SyncManager).
Priorité : 🔴 HAUTE
Effort : Très faible
Gain : Réduction des allocations GC à chaque scroll
2.3 getLinksByTag() charge TOUTE la base en mémoire
Fichier : data/repository/LinkRepositoryImpl.kt (lignes 116-134)
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
return try {
val localLinks = linkDao.getAllLinks().firstOrNull() ?: emptyList() // ← CHARGE TOUT
if (localLinks.isNotEmpty()) {
val filtered = localLinks.filter { it.tags.contains(tag) } // ← FILTRE EN MÉMOIRE
// ...
}
}
}
Impact : Pour une bibliothèque de 5 000 liens, cette opération :
- Charge ~5 000 entités complètes en mémoire (~5-10 MB)
- Itère sur chacune pour un filtre que Room pourrait faire en SQL
- Crée une copie filtrée
Solution : Utiliser une requête Room dédiée (le DAO getLinksByTag(tag) existe déjà !) :
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
return try {
val sql = "SELECT * FROM links WHERE tags LIKE ? ORDER BY created_at DESC LIMIT 100"
val links = linkDao.getLinksByTagDirect(SimpleSQLiteQuery(sql, arrayOf("%\"$tag\"%")))
Result.success(links.map { it.toDomainModel() })
} catch (e: Exception) {
Result.failure(e)
}
}
Priorité : 🔴 HAUTE
Effort : Faible
Gain : -95% mémoire pour les requêtes par tag
2.4 FTS4 défini mais non utilisé pour la recherche
Fichier : data/local/dao/LinkDao.kt
L'entité FTS4 LinkFtsEntity est définie (ligne 129 de LinkEntity.kt) et la méthode searchLinksFullText() existe dans le DAO (ligne 51-57), mais le repository utilise searchLinks() avec des LIKE '%query%' à la place :
// Utilisé actuellement (LENT) :
fun searchLinks(query: String): PagingSource<Int, LinkEntity>
// WHERE title LIKE '%query%' OR description LIKE '%query%' OR url LIKE '%query%'
// Disponible mais NON UTILISÉ (RAPIDE) :
fun searchLinksFullText(query: String): PagingSource<Int, LinkEntity>
// WHERE links_fts MATCH :query ← Utilise l'index FTS4
Impact : Les requêtes LIKE '%...%' nécessitent un full table scan (O(n)). FTS4 utilise un index inversé (O(log n)). Pour 10 000 liens, la différence est de 10-100x en vitesse.
Solution : Dans LinkRepositoryImpl.getLinksStream(), remplacer :
!searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm)
par :
!searchTerm.isNullOrBlank() -> linkDao.searchLinksFullText(searchTerm)
Priorité : 🔴 HAUTE
Effort : 1 ligne
Gain : Recherche 10-100x plus rapide
3. Optimisations du Démarrage (Cold Start)
3.1 Absence de Baseline Profiles
Les Baseline Profiles pré-compilent les chemins critiques (cold start, navigation, scroll) en AOT, évitant la compilation JIT au runtime.
Impact : Jetpack Compose bénéficie massivement des Baseline Profiles car le framework génère beaucoup de bytecode dynamique. Gain typique : 30-50% de réduction du temps de démarrage.
Solution :
- Ajouter le module
:benchmarkavec la dépendanceandroidx.benchmark:benchmark-macro-junit4 - Créer un
BaselineProfileGeneratorqui navigue dans les écrans principaux - Générer le profil et l'inclure dans
src/main/baseline-prof.txt
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()
@Test
fun generateProfile() {
rule.collect("com.shaarit") {
startActivityAndWait()
// Scroll feed, search, add link...
}
}
}
Priorité : 🔴 HAUTE
Effort : Moyen (1-2h de configuration)
Gain : -30-50% cold start
3.2 Traitement du Share Intent pendant la composition
Fichier : MainActivity.kt (lignes 56-82)
Le parsing de l'intent (extraction du Share Intent, deep links) se fait dans le bloc setContent {}, ce qui signifie qu'il est ré-exécuté à chaque recomposition.
Solution : Extraire le parsing dans onCreate() avant setContent {} et passer les valeurs en tant que paramètres stables :
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
// Parse intent ONCE, before composition
val shareData = parseShareIntent(intent)
setContent {
// Use shareData directly (stable, no recomposition)
}
}
Priorité : 🟡 MOYENNE
Effort : Faible
Gain : Évite les recompositions inutiles au démarrage
4. Optimisations Réseau & Synchronisation
4.1 Logging Interceptor en mode BODY en production
Fichier : core/di/NetworkModule.kt (ligne 29)
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY // ← TOUJOURS BODY
}
Impact : Log chaque requête et réponse complète (headers + body). Pour un sync de 1 000 liens en JSON, cela :
- Génère des méga-octets de logs
- Ralentit le I/O
- Consomme de la mémoire pour la sérialisation des logs
Solution :
val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
Priorité : 🔴 HAUTE
Effort : Très faible
Gain : Significatif en release (réseau + mémoire)
4.2 Pas de timeouts explicites sur le client OkHttp principal
Fichier : core/di/NetworkModule.kt
Le OkHttpClient principal n'a aucun timeout configuré. Les défauts OkHttp sont de 10s pour connect, 10s pour read, 10s pour write — ce qui peut causer des attentes longues si le serveur Shaarli est lent.
Solution :
return OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.addInterceptor(hostSelectionInterceptor)
.addInterceptor(authInterceptor)
.addInterceptor(logging)
.build()
Priorité : 🟡 MOYENNE
Effort : Très faible
4.3 Sync complète sans delta/incrémental
Fichier : data/sync/SyncManager.kt — pullFromServer() (lignes 375-437)
Chaque sync récupère tous les liens du serveur, page par page (100 par page). Pour une bibliothèque de 5 000 liens, cela nécessite 50 requêtes HTTP séquentielles.
Solution : Sync incrémentale basée sur la date de dernière synchronisation :
- Stocker le timestamp de dernière sync réussie dans
TokenManager - Utiliser le paramètre
searchtermou un headerIf-Modified-Sincesi supporté par Shaarli - En fallback, comparer les
updatedAtlocaux vs serveur pour ne traiter que les changements
private suspend fun pullFromServer() {
val lastSyncTime = tokenManager.getLastSyncTimestamp()
// Ne récupérer que les liens modifiés depuis lastSyncTime
// ...
tokenManager.saveLastSyncTimestamp(System.currentTimeMillis())
}
Note
: L'API Shaarli v1 ne supporte pas nativement le filtrage par date de modification. Il faudra soit implémenter un checksum côté client, soit paginer intelligemment (s'arrêter quand on rencontre des liens déjà synchés).
Priorité : 🔴 HAUTE
Effort : Élevé (refactoring majeur)
Gain : -85% temps de sync pour les syncs régulières
4.4 HttpURLConnection dans LinkHealthCheckWorker
Fichier : data/worker/LinkHealthCheckWorker.kt (lignes 151-191)
Le health check utilise HttpURLConnection legacy au lieu du OkHttpClient déjà disponible via Hilt. Cela empêche le connection pooling et le HTTP/2 multiplexing d'OkHttp.
Solution : Injecter OkHttpClient dans le Worker (via @AssistedInject) et utiliser un client dédié (comme fait dans GeminiRepositoryImpl) :
private val healthCheckClient by lazy {
okHttpClient.newBuilder()
.connectTimeout(7, TimeUnit.SECONDS)
.readTimeout(7, TimeUnit.SECONDS)
.build()
}
Priorité : 🟡 MOYENNE
Effort : Faible
Gain : Meilleur connection pooling, HTTP/2
4.5 Pas de cache HTTP disque
Fichier : core/di/NetworkModule.kt
Aucun Cache OkHttp n'est configuré. Les réponses API ne sont jamais mises en cache sur disque.
Solution :
val cacheSize = 10L * 1024 * 1024 // 10 MB
val cache = Cache(File(context.cacheDir, "http_cache"), cacheSize)
return OkHttpClient.Builder()
.cache(cache)
// ...
.build()
Priorité : 🟢 FAIBLE
Effort : Très faible
Gain : Réduit la bande passante, accélère les requêtes répétées
5. Optimisations Base de Données (Room)
5.1 getAllLinksForStats() charge tout en mémoire
Fichier : data/local/dao/LinkDao.kt (ligne 234)
@Query("SELECT * FROM links WHERE sync_status != 'PENDING_DELETE'")
suspend fun getAllLinksForStats(): List<LinkEntity>
Utilisé par BookmarkExporter (3 fonctions d'export) et LinkRepositoryImpl.getAllLinks(). Charge toutes les colonnes de tous les liens en mémoire.
Solutions :
- Pour l'export : Utiliser un
Cursorou unFlow<List<LinkEntity>>avec traitement par batch - Pour les stats : Créer des requêtes d'agrégation SQL dédiées au lieu de charger les entités :
@Query("SELECT COUNT(*) FROM links WHERE sync_status != 'PENDING_DELETE'")
suspend fun getTotalLinksCount(): Int
@Query("SELECT COUNT(*) FROM links WHERE created_at >= :since")
suspend fun getLinksCountSince(since: Long): Int
Priorité : 🔴 HAUTE
Effort : Moyen
Gain : -90% mémoire pour les stats et exports
5.2 Pas d'index sur les colonnes fréquemment filtrées
Fichier : data/local/entity/LinkEntity.kt (lignes 13-21)
Les index existants sont bons (sync_status, is_private, created_at, is_pinned, url), mais il manque :
content_type— utilisé pargetContentTypeDistribution()site_name— utilisé pargetTopSites()etgetAllSites()link_check_status— utilisé pargetDeadLinks(),getDeadLinksCount(),getPendingLinksCount()
Solution :
@Entity(
tableName = "links",
indices = [
Index(value = ["sync_status"]),
Index(value = ["is_private"]),
Index(value = ["created_at"]),
Index(value = ["is_pinned"]),
Index(value = ["url"], unique = true),
Index(value = ["content_type"]), // NOUVEAU
Index(value = ["site_name"]), // NOUVEAU
Index(value = ["link_check_status"]), // NOUVEAU
Index(value = ["last_health_check"]) // NOUVEAU
]
)
Priorité : 🟡 MOYENNE
Effort : Très faible (+ migration de schéma)
Gain : Requêtes de filtrage/stats 5-10x plus rapides
5.3 fallbackToDestructiveMigration() — Perte de données silencieuse
Fichier : data/local/database/ShaarliDatabase.kt (ligne 59)
.fallbackToDestructiveMigration()
Impact UX : À chaque changement de schéma (version de DB), toutes les données locales sont silencieusement supprimées. L'utilisateur perd son cache offline, ses épingles, ses métadonnées enrichies, sans aucun avertissement.
Solution : Écrire des migrations explicites :
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE links ADD COLUMN new_column TEXT DEFAULT ''")
}
}
Room.databaseBuilder(context, ShaarliDatabase::class.java, DATABASE_NAME)
.addMigrations(MIGRATION_4_5)
.build()
Priorité : 🔴 HAUTE (UX critique)
Effort : Moyen (pour chaque future migration)
Gain : Zéro perte de données utilisateur
5.4 exportSchema = false
Fichier : data/local/database/ShaarliDatabase.kt (ligne 32)
L'export de schéma est désactivé, ce qui empêche la vérification automatique des migrations Room et la détection des erreurs de migration en compile-time.
Solution : Activer l'export de schéma :
@Database(
// ...
exportSchema = true
)
Et ajouter dans build.gradle.kts :
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
Priorité : 🟡 MOYENNE
Effort : Très faible
6. Optimisations UI/Compose
6.1 Images non redimensionnées par Coil
Fichier : presentation/feed/LinkItemViews.kt
AsyncImage(
model = link.thumbnailUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(100.dp).clip(RoundedCornerShape(12.dp))
)
Impact : Coil télécharge et décode l'image en résolution originale (potentiellement 4K), puis Compose la redimensionne visuellement. Cela gaspille de la mémoire (un bitmap 1920x1080 = ~8 MB en ARGB_8888).
Solution : Utiliser le builder de requête Coil pour limiter la taille en mémoire :
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(link.thumbnailUrl)
.size(200) // Taille cible en pixels
.crossfade(true)
.memoryCacheKey(link.thumbnailUrl)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(100.dp).clip(RoundedCornerShape(12.dp))
)
Priorité : 🔴 HAUTE
Effort : Faible
Gain : -80% mémoire par image, scroll plus fluide
6.2 MarkdownText dans chaque item de liste
Fichier : presentation/feed/LinkItemViews.kt (lignes 179-184, 327-332)
Chaque item du flux rend du Markdown via MarkdownText. Le parsing Markdown est coûteux et se fait pour chaque item visible.
Solutions :
- Limiter l'affichage : Dans les vues liste/grille, afficher un
Text()simple avec le texte brut tronqué. RéserverMarkdownTextpour la vue détail. - Pré-parser : Stocker le texte HTML pré-rendu dans la base de données (colonne
excerpt) lors de la sync.
Priorité : 🟡 MOYENNE
Effort : Faible
Gain : Scroll plus fluide, moins de jank
6.3 Debounce de 300ms sur le flux principal
Fichier : presentation/feed/FeedViewModel.kt (ligne 82)
.debounce(300)
Ce debounce de 300ms s'applique à tous les changements (recherche, tags, filtres, refresh), ce qui ajoute un délai perceptible même quand l'utilisateur tape sur un filtre.
Solution : Appliquer le debounce uniquement à la recherche textuelle :
val debouncedSearch = _searchQuery.debounce(300)
val instantFilters = combine(_searchTags, _collectionId, _bookmarkFilter, _refreshTrigger) { ... }
val pagedLinks = combine(debouncedSearch, instantFilters) { query, filters ->
// ...
}.flatMapLatest { ... }
Priorité : 🟡 MOYENNE
Effort : Faible
Gain : Filtres instantanés au lieu de 300ms de délai
7. Optimisations Mémoire
7.1 Caches en mémoire non bornés (Gemini)
Fichier : data/repository/GeminiRepositoryImpl.kt (lignes 26-29)
private val tagsCache = mutableMapOf<String, List<String>>()
private val analysisCache = mutableMapOf<String, AiEnrichmentResult>()
Ces caches croissent indéfiniment durant la session. Si l'utilisateur analyse 100 URLs, le cache contient 100 résultats sans jamais les libérer.
Solution : Utiliser un LruCache avec une taille maximale :
private val analysisCache = object : LinkedHashMap<String, AiEnrichmentResult>(50, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, AiEnrichmentResult>): Boolean {
return size > 50
}
}
Ou mieux, utiliser LruCache d'Android :
private val analysisCache = LruCache<String, AiEnrichmentResult>(50)
Priorité : 🟢 FAIBLE
Effort : Très faible
Gain : Prévient les fuites mémoire en session longue
7.2 GenerativeModel recréé à chaque appel Gemini
Fichier : data/repository/GeminiRepositoryImpl.kt (lignes 78, 193)
private suspend fun generateWithModel(apiKey: String, modelName: String, url: String): AiEnrichmentResult {
val generativeModel = GenerativeModel( // ← Recréé à chaque appel
modelName = modelName,
apiKey = apiKey,
// ...
)
}
Solution : Mettre en cache les instances par nom de modèle :
private val modelCache = mutableMapOf<String, GenerativeModel>()
private fun getOrCreateModel(apiKey: String, modelName: String): GenerativeModel {
return modelCache.getOrPut(modelName) {
GenerativeModel(modelName = modelName, apiKey = apiKey, ...)
}
}
Priorité : 🟢 FAIBLE
Effort : Très faible
Gain : Réduit les allocations lors d'appels AI successifs
7.3 Regex compilées à chaque appel
Fichier : data/metadata/LinkMetadataExtractor.kt (lignes 175-206)
url.contains(Regex("youtube\\.com|vimeo\\.com|dailymotion")) // ← Nouvelle Regex à chaque appel
Plus de 15 patterns Regex sont compilés inline dans detectContentType() et detectContentTypeFromUrl().
Solution : Pré-compiler en companion object :
companion object {
private val VIDEO_PATTERN = Regex("youtube\\.com|vimeo\\.com|dailymotion")
private val REPO_PATTERN = Regex("github\\.com|gitlab\\.com|bitbucket")
// ...
}
Priorité : 🟡 MOYENNE
Effort : Très faible
Gain : Réduit les allocations, accélère la détection de type
8. Optimisations du Build Release
8.1 ProGuard trop permissif
Fichier : proguard-rules.pro (lignes 107-109)
-keep class com.shaarit.** { *; }
-keepclassmembers class com.shaarit.** { *; }
Ces règles empêchent R8 d'optimiser toute la codebase. Aucun inlining, aucune suppression de code mort, aucune obfuscation.
Solution : Remplacer par des règles ciblées :
# Keep only what is strictly needed
-keep class com.shaarit.data.dto.** { *; }
-keep class com.shaarit.data.local.entity.** { *; }
-keep class com.shaarit.domain.model.** { *; }
-keep class com.shaarit.data.export.Exported* { *; }
Priorité : 🔴 HAUTE
Effort : Moyen (tests de non-régression requis)
Gain : R8 peut enfin optimiser le code
8.2 Ajout recommandé de la bibliothèque LeakCanary (Debug)
Pour détecter les fuites mémoire durant le développement :
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.13")
Priorité : 🟡 MOYENNE
Effort : 1 ligne
9. Expérience Utilisateur — Réduction des Temps d'Attente
9.1 Skeleton Loading déjà implémenté ✅
Le composant SkeletonLinkCard existe dans ui/components/SkeletonLoader.kt. S'assurer qu'il est utilisé systématiquement comme état de chargement initial au lieu d'un spinner circulaire.
9.2 Extraction de métadonnées — Feedback immédiat
Fichier : presentation/add/AddLinkViewModel.kt
L'extraction de métadonnées (JSoup, 10s timeout) et l'analyse Gemini se font séquentiellement. L'utilisateur attend sans feedback visuel clair.
Solutions :
- Optimistic UI : Permettre la sauvegarde immédiate du lien, enrichir les métadonnées en arrière-plan
- Paralléliser : Lancer JSoup et Gemini en parallèle si les deux sont disponibles
- Progressive loading : Afficher le titre dès qu'il est extrait, puis la description, puis les tags AI
// Paralléliser extraction JSoup + analyse Gemini
viewModelScope.launch {
val metadataDeferred = async { metadataExtractor.extract(url) }
val aiDeferred = async { analyzeUrlWithAiUseCase(url) }
// Afficher les métadonnées JSoup dès qu'elles arrivent
metadataDeferred.await().let { metadata -> ... }
// Compléter avec l'AI
aiDeferred.await().onSuccess { result -> ... }
}
Priorité : 🔴 HAUTE
Effort : Moyen
Gain : UX perçue comme instantanée
9.3 Share Intent — Temps d'apparition de l'écran d'ajout
Quand l'utilisateur partage un lien depuis Chrome, le flux est :
- Splash Screen → 2. Login check → 3. Navigation → 4. AddLinkScreen → 5. Extraction métadonnées
Optimisations :
- Vérifier le token dans le splash screen pour éviter l'écran de login
- Naviguer directement vers AddLinkScreen si le token est valide
- Commencer l'extraction des métadonnées pendant la navigation
9.4 Pull-to-Refresh — Feedback de sync
Le refresh() du FeedViewModel déclenche syncManager.syncNow() qui planifie un Worker. L'utilisateur ne voit pas immédiatement les résultats car :
- Le Worker est planifié (pas exécuté immédiatement)
- La pagination Room ne se rafraîchit pas automatiquement
Solution : Effectuer un performFullSync() directement dans un coroutine scope, puis invalider le PagingSource :
fun refresh() {
viewModelScope.launch {
syncManager.performFullSync()
_refreshTrigger.value++
}
}
Priorité : 🟡 MOYENNE
Effort : Faible
Gain : Refresh perçu comme immédiat
9.5 Haptic Feedback sur les actions clés
Ajouter du retour haptique pour :
- Toggle pin (long press feel)
- Ajout de lien réussi
- Suppression confirmée
- Pull-to-refresh
val haptic = LocalHapticFeedback.current
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
Priorité : 🟢 FAIBLE
Effort : Très faible
Gain : UX premium, feedback instantané
9.6 Préchargement intelligent du flux
Configurer Paging 3 pour précharger plus de données en avance :
PagingConfig(
pageSize = 20,
prefetchDistance = 10, // Commence à charger 10 items avant la fin
initialLoadSize = 40, // Charge 40 items au démarrage
enablePlaceholders = false
)
Priorité : 🟡 MOYENNE
Effort : Très faible
Gain : Scroll continu sans "loading" visible
10. Plan d'Action Priorisé
Phase 1 — Quick Wins Critiques (1-2 jours)
| # | Action | Fichier | Effort | Impact |
|---|---|---|---|---|
| 1 | Activer R8 (isMinifyEnabled = true) |
build.gradle.kts |
2 lignes | 🔴🔴🔴 |
| 2 | Activer FTS4 pour la recherche | LinkRepositoryImpl.kt |
1 ligne | 🔴🔴🔴 |
| 3 | Corriger getLinksByTag() (requête SQL) |
LinkRepositoryImpl.kt |
10 lignes | 🔴🔴 |
| 4 | Désactiver les logs réseau en release | NetworkModule.kt |
5 lignes | 🔴🔴 |
| 5 | Singleton SimpleDateFormat |
LinkRepositoryImpl.kt |
5 lignes | 🔴 |
| 6 | Pré-compiler les Regex | LinkMetadataExtractor.kt |
15 lignes | 🟡 |
Phase 2 — Optimisations Structurelles (3-5 jours)
| # | Action | Fichier | Effort | Impact |
|---|---|---|---|---|
| 7 | Baseline Profiles | Nouveau module | 2h | 🔴🔴🔴 |
| 8 | Migrations Room explicites | ShaarliDatabase.kt |
2h | 🔴🔴 |
| 9 | Images Coil avec taille cible | LinkItemViews.kt |
30min | 🔴🔴 |
| 10 | Affiner les règles ProGuard | proguard-rules.pro |
1h | 🔴🔴 |
| 11 | Ajouter les index manquants | LinkEntity.kt |
15min | 🟡 |
| 12 | Texte brut dans la liste, Markdown en détail | LinkItemViews.kt |
1h | 🟡 |
Phase 3 — Améliorations UX (1 semaine)
| # | Action | Fichier | Effort | Impact |
|---|---|---|---|---|
| 13 | Sync incrémentale (delta) | SyncManager.kt |
4h | 🔴🔴🔴 |
| 14 | Extraction métadonnées parallèle | AddLinkViewModel.kt |
2h | 🔴🔴 |
| 15 | Debounce uniquement sur la recherche | FeedViewModel.kt |
1h | 🟡 |
| 16 | Préchargement Paging amélioré | LinkRepositoryImpl.kt |
15min | 🟡 |
| 17 | Share Intent optimisé (skip login) | MainActivity.kt |
2h | 🟡 |
| 18 | Haptic feedback | Composables | 30min | 🟢 |
Phase 4 — Maintenance Continue
| # | Action | Effort | Impact |
|---|---|---|---|
| 19 | LeakCanary en debug | 1 ligne | 🟡 |
| 20 | Borner les caches Gemini | 15min | 🟢 |
| 21 | Cache HTTP OkHttp | 15min | 🟢 |
| 22 | Export schéma Room | 15min | 🟢 |
| 23 | Supprimer LinkPagingSource (code mort) |
Suppression | 🟢 |
11. Métriques de Suivi
Pour mesurer l'impact des optimisations, instrumenter les métriques suivantes :
Métriques de Démarrage
- Time to First Frame (TTFF) : Temps jusqu'au premier frame visible
- Time to Interactive (TTI) : Temps jusqu'à ce que le flux soit scrollable
- Outil :
adb shell am start -W com.shaarit/.MainActivity
Métriques de Rendu
- Jank frames : Frames dépassant 16ms
- Frame drop rate : % de frames perdues pendant le scroll
- Outil : Android Studio Profiler → Frame Rendering
Métriques de Sync
- Sync duration : Temps total d'une sync complète
- Items/second : Débit de sync
- Instrumenter avec
System.currentTimeMillis()dansSyncManager
Métriques Mémoire
- Heap size au repos et sous charge
- GC frequency pendant le scroll
- Outil : Android Studio Profiler → Memory
Recommandation : Firebase Performance Monitoring
implementation("com.google.firebase:firebase-perf-ktx")
Permet de tracer automatiquement les requêtes réseau, le temps de démarrage, et les traces custom en production.
Annexe : Code Mort Identifié
| Fichier | Description |
|---|---|
data/paging/LinkPagingSource.kt |
Ancien PagingSource réseau, remplacé par Room Paging. Peut être supprimé. |
data/sync/ConflictResolver.kt |
Défini mais jamais appelé par SyncManager. La résolution de conflits n'est pas implémentée. |
Document généré le 9 février 2026 — Analyse basée sur l'intégralité du code source ShaarIt v1.0 (47 fichiers Kotlin)