Webkit offline

HTML5 permet de gérer le mode hors-ligne lors de la consultation d'un site. Mais cela ne fonctionne que si l'utilisateur navigue sur la page une première fois et y revient lorsque le réseau est disponible. Cela permet au code Javascript de se synchroniser avec le back-end. Dans une application hybride: Android + Webkit, ce n'est pas satisfaisant.
Par Philippe PRADOS - 2013
www.prados.fr
Une application mixte est une application qui utilise les API natives pour certains écrans et des composants WebKit pour d'autres.
Cela s'applique à des fonctionnalités très volatiles comme la diffusion de publicités, l'aide en ligne, la description de processus spécifiques à une prestation, la diffusion d'offres promotionnelles de dernières minutes, etc. Pour toutes ces situations, une application native n'est pas la solution. Il est nécessaire d'avoir une souplesse maximum lors des mises à jour. Il ne faut pas modifier et publier une application mobile pour chaque mise à jour d'un écran. Qui souhaite faire cela parce qu’un produit passe de 30 % de réduction à 50 % pour un lot de deux ou que la promotion change de produit ?
Webkit est alors la solution pour marier les technologies natives et web. Webkit est le moteur HTML utilisé par Apple, Google et bien d'autres. Un composant WebView est ajouté à une activité. Ce dernier exploite le réseau comme le fait un simple navigateur, pour présenter les pages en perpétuelles évolutions.
Prenons l'exemple d'un site marchand qui souhaite pouvoir proposer des promotions pour écouler ses stocks. La mise en valeur de ces dernières est très variable. Parfois, c'est la combinaison de plusieurs produits, parfois l'utilisation d'un code de réduction spécifique, etc. Il n'est pas possible de concevoir une activité spéciale pour tenir compte de cette très grande variabilité. C'est pour cela qu'un site Web sera toujours plus à jour qu'une application correspondante. Utilisez fnac.com plutôt que l'application Windows 8 correspondante. Vous aurez probablement plus d'offres.
Notre application propose donc d'utiliser une WebView dans une activité.
Avec HTML5, il est possible d'indiquer la liste des pages à pré-charger, pour une utilisation hors ligne. C'est facile à faire. Il faut ajouter un attribut manifeste dans le marqueur <html/> avec un lien vers un fichier au format manifeste.
Attention, le type mime associé doit obligatoirement être text/cache-manifest. Vérifiez les paramètres de votre serveur HTTP si le cache n'est pas mis à jour.
<html manifest="cache.manifest">
...
</html>
Le fichier cache.manifest indique la liste des URL à charger dans le cache. Reportez-vous à Internet pour le détail de la syntaxe. C'est très simple : une catégorie puis une URL par ligne.
Le composant WebView doit alors être initialisé pour accepter l'utilisation des caches par les applications Web.
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setDatabaseEnabled(true);
settings.setSaveFormData(true);
settings.setAllowFileAccess(false);
settings.setAppCacheEnabled(true);
settings.setAppCachePath(context.getCacheDir().getAbsolutePath());
if (VERSION.SDK_INT < VERSION_CODES.JELLY_BEAN_MR2)
settings.setAppCacheMaxSize(1024 * 1024 * 8);
Il est alors possible d'indiquer une stratégie pour l'exploitation du cache par le composant.
settings.setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK);
Et en effet, après avoir affiché l'activité une première fois, le cache de l'application est alimenté par une tâche de fond du WebView. Il est donc possible d'utiliser l'activité hors ligne.
Vraiment hors ligne ?
En fait, il y a une première difficulté. Tant que l'activité n'a pas été affichée, le cache n'est pas alimenté. Donc, si l'utilisateur n'a pas de réseau lors du premier accès à l'activité, il reçoit une belle page d'erreur indiquant qu'il est impossible de présenter la page demandée.
Le premier réflexe dans cette situation est d'utiliser une page statique embarquée dans les assets dans l'application et de l'utiliser comme première page. C'est un poil meilleur, car on maîtrise alors le message d'erreur affiché dans la WebView, mais cela ne règle pas le problème. La page n'est pas affichée. Il est probable que le réseau était disponible précédemment. Il aurait été alors possible d'alimenter le cache.
Le deuxième problème à régler est le suivant : tant que la page n'est pas affichée, le cache n'est pas rafraîchi. Donc, s'il est hors ligne, l'utilisateur consulte la dernière version connu par l'application et non la dernière version publiée. C'est dommage. On utilise justement une WebView pour des pages volatiles, pouvant évoluer régulièrement. Une mise à jour régulière du cache serait un plus.
Un troisième problème : HTML5 permet au JavaScript de contrôler quand le réseau devient actif. Cela permet la publication vers le back-end des données maintenues dans le cache applicatif de la page. Mais si la page n'est jamais affichée par l'utilisateur ? Les données restent dans le cache ! Comment forcer leurs publications par les applications Web ?
Pour toutes ces raisons, l'utilisation des caches de HTML5 dans une activité est rarement pratiquée. Le composant ne peut fonctionner qu'avec le réseau. Et c'est un pan entier des spécifications HTML5 qui ne sert plus à rien.
Pour régler ces trois situations, il faudrait synchroniser le cache HTML d'une application, sans intervention de l'utilisateur. C'est exactement ce que je vous propose dans les lignes suivantes. Le chemin est long, plein d’embûches, mais au final, le code est court, simple et facile à intégrer dans une application quelconque.
Les fichiers sources sont présents ici : https://github.com/pprados/android-webview-async-cache
Le framework de synchronisation d'Android
Synchroniser des données n'est pas évident si on ne souhaite pas vider la batterie.
En effet, le système Android utilise un automate à états pour la connexion radio qui fait passer le composant dans des états rapides mais consommateurs ou lents mais économes. Le passage d'un état à un autre s'effectue après le déclenchement de temporisations, différentes suivant les technologies GSM, 3G ou 4G utilisées. Si votre code n'en tient pas compte, il peut réactiver le mode rapide mais consommateur trop souvent. La batterie ne tient alors pas très longtemps. De plus, chaque transition d'état prend un certain temps qui ralenti au final l'expérience utilisateur.
Il est alors préférable d'utiliser plusieurs stratégies pour optimiser tout cela. Par exemple, il est préférable de récupérer le maximum d'informations du back-end dans la même session. Cela évite les cycles dans la gestion de la radio. De même, pour la publication des modifications, il est préférable de le faire en rafale. Il ne faut pas publier une modification immédiatement, mais attendre une quinzaine de secondes avant de le faire. Il est fort probable que pendant cette période, d'autres modifications interviennent. Si l'application se met en « petite-veille », avec réduction de la luminosité de l'écran, il ne semble pas nécessaire de rafraîchir les données. Par contre, lorsque l'utilisateur intervient à nouveau sur l'application, il peut être judicieux d'intervenir.
Si une connexion Wi-Fi est disponible, grouper les lectures et les écritures est également une bonne stratégie.
Gérer tous ces scénarios n'est pas facile. Cela demande une connaissance fine des automates à états des modules radios et Wi-Fi, de suivre ces états, etc.
Pour remédier à cela, Android propose un framework. Vous en avez connaissance en regardant le détail des différents comptes de votre Android. Vous pouvez cocher ou décocher les différentes synchronisations associées à un compte.
Ce framework est très bien adapté à un type d'architecture que je vous conseille d'appliquer pour tous vos projets. Google utilise ce modèle pour la plupart de ses applications. Vous pouvez le constater en regardant le nombre de services dont la synchronisation est activable, à partir d'un compte Google. Chaque service utilise ce modèle d'architecture.
Le modèle est le suivant : les applications et les activités ne s'occupent pas du réseau. Elles récupèrent des informations d'un ContentProvider et apportent des modifications dans ce dernier. La base de données associée au ContentProvider est un cache des données présentes sur le serveur.
Par exemple, le ContentProvider peut mémoriser des flux RSS, des mails, des contacts d'un annuaire, des flux d'un réseau social, etc.
Pour synchroniser la base de données locale, portée par le ContentProvider, un AbstractThreadedSyncAdapter se charge de cela. Il est invoqué par Android lorsqu'il estime que les meilleures conditions sont réunies.
Pour utiliser ce framework, il faut réunir trois composants :
- un Account. C'est un compte utilisateur comme vous pouvez les voir dans les paramètres d'Android.
- Un ContentProvider. C'est le composant qui porte le cache. La déclaration de la synchronisation s'effectue en associant un Account avec un ContentProvider
- un service chargé de la synchronisation.
Ce n'est donc pas facile de réunir toutes ces conditions.
En fait, ce n'est pas si compliqué. L'Account n'a pas besoin d'être réel. Il ne doit pas nécessairement porter l'identité d'un utilisateur.
Ne vous inquiétez pas de la suite de l'article, Il vous explique comme faire « à la main ». À la fin, je vous propose des classes permettant de simplifier toutes ces étapes.
Avant toute chose, il faut choisir trois constantes : l'accountType qui décrit le type de compte de manière unique dans l'OS, l'accountName qui décrit le nom de l'utilisateur pour ce type de compte et l'autorité d'un ContentProvider.
public static final String ACCOUNT_TYPE="fr.prados.sync"
public static final String ACCOUNT_NAME="sync"
public static final String AUTHORITY="fr.prados.webkitcache"
Première étape : créer un compte
La première chose à faire est de créer un compte utilisateur. Il faut publier un service spécifique et le paramétrer avec un fichier XML. Placez le fichier authenticator.xml suivant dans le répertoire res/xml de votre projet.
<?xml version="1.0" encoding="utf-8"?>
<account-authenticator
xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="fr.prados.sync"
android:icon="@drawable/ic_launcher"
android:smallIcon="@drawable/ic_launcher"
android:label="@string/app_name"
/>
Bien entendu, le paramètre accountType doit être adapté à ce que vous avez choisi.
Il faut ensuite publier un service dans le AndroidManifest.xml.
<service android:name=".SyncHTTPService"
android:exported="false"
>
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
Dans la classe du service, déclarez un AbstractAccountAuthenticator vide.
private AbstractAccountAuthenticator mAuthenticator;
// Dummy authenticator
private static class AccountAuthenticator
extends AbstractAccountAuthenticator
{
AccountAuthenticator(Context context)
{
super(context.getApplicationContext());
}
@Override
public Bundle editProperties(
AccountAuthenticatorResponse accountAuthenticatorResponse,
String s)
{
throw new UnsupportedOperationException();
}
@Override
public Bundle addAccount(
AccountAuthenticatorResponse accountAuthenticatorResponse,
String s,
String s2,
String[] strings,
Bundle bundle)
throws NetworkErrorException
{
return null;
}
…
};
Toutes les méthodes retournent null ou génèrent une exception.
Pour propager le Context, l'instance AccountAuthenticator est créé dans la méthode onCreate() du service.
@Override
public void onCreate()
{
mAuthenticator=new AccountAuthenticator(this);
}
Il reste à répondre à la méthode onBind() du Service.
@Override
public IBinder onBind(Intent intent)
{
final String action = intent.getAction();
if (action.equals("android.accounts.AccountAuthenticator"))
{
return mAuthenticator.getIBinder();
}
else
return null;
}
Nous pouvons maintenant créer un Account automatiquement.
/**
* Create an empty account.
*
* @param context The context
* @return The account.
*/
private static Account initAccount(Context context)
{
final AccountManager accountManager =
(AccountManager) context.getSystemService(Context.ACCOUNT_SERVICE);
final Account account=new Account(ACCOUNT_NAME,ACCOUNT_TYPE);
Account[] accounts=accountManager.getAccountsByType(account.type);
boolean find=false;
for (int i=0;i<accounts.length;++i)
{
if (accounts[i].name.equals(account.name))
{
find=true;
break;
}
}
if (!find)
{
Log.d(TAG,"add new account "+account.name+
" type "+account.type+" for synchronization");
if (accountManager.addAccountExplicitly(account, null, null))
{
…
}
}
return account;
}
La première étape est terminée. Un compte est ajouté si ce n'est déjà fait.
Deuxième étape : publier un ContentProvider
Nous devons exposer un ContentProvider pour l'autorité choisie. Comme pour l'AccountProvider, nous déclarons une classe interne à notre service avec un ContentProvider vide.
public class SyncHTTPService extends Service
{
// Dummy content provider for synchronize the WebKit cache.
public static class CacheHTTPContentProvider extends ContentProvider
{
@Override
public boolean onCreate()
{
return true;
}
@Override
public int delete(Uri uri, String s, String[] as)
{
throw new UnsupportedOperationException("Not supported by this provider");
}
…
}
}
Dans le AndroidManifest.xml, on le déclare.
<!-- Provider to expose the cached pages →
<provider
android:name=".SyncHTTPService$CacheHTTPContentProvider"
android:authorities="fr.prados.webkitcache"
android:exported="false"
android:syncable="true"
/>
Notez le paramètre syncable à true.
Troisième étape : ajouter la synchronisation
Pour s'intégrer dans le framework de synchronisation d'Android, il faut procéder de façon similaire à l'ajout d'un compte.
La première chose à faire est de publier le fichier syncadapter.xml dans le répertoire res/xml.
<?xml version="1.0" encoding="utf-8"?>
<sync-adapter
xmlns:android="http://schemas.android.com/apk/res/android"
android:contentAuthority="fr.prados.webkitcache"
android:accountType="fr.prados.sync"
android:userVisible="false"
android:supportsUploading="true"
android:allowParallelSyncs="false"
android:isAlwaysSyncable="true"/>
Le paramètre userVisible permet d'avoir une case à cocher dans les paramètres du compte.
En théorie, il faut déclarer un autre service avec ses paramètres, comme on vient de le faire pour la gestion du compte. Comme je suis fainéant et que j'aime bien réduire le nombre de ligne de code d'un projet, je propose de recycler le service déjà présent pour lui faire jouer les deux rôles : la gestion du compte et la gestion de la synchronisation.
Pour cela, j'ajoute les paramètres suivants dans la déclaration du service.
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/syncadapter" />
J'ajoute ensuite une classe interne dérivée d'AbstractThreadedSyncAdapter
public class SyncHTTPService extends Service
{
private class SyncAdapter extends AbstractThreadedSyncAdapter
{
public SyncAdapter(Context context, boolean autoInitialize)
{
super(context, autoInitialize);
}
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
public SyncAdapter(Context context,
boolean autoInitialize,
boolean allowParallelSyncs)
{
super(context, autoInitialize, allowParallelSyncs);
}
@Override
public void onPerformSync(
Account account,
Bundle extras,
String authority,
ContentProviderClient provider,
SyncResult result)
{
SyncHTTPService.this.onPerformSync(getContext(),
account,
extras,
authority,
provider,
result);
}
};
protected void onPerformSync(
Context context,
Account account,
Bundle extras,
String authority,
ContentProviderClient provider,
SyncResult result);
{
…
}
}
Pour simplifier l'implémentation de la méthode onPerformSync(), elle est propagée dans la classe externe, à savoir le service.
Il faut alors ajouter un aiguillage dans la méthode onBind() pour jouer sur les deux tableaux.
@Override
public IBinder onBind(Intent intent)
{
final String action = intent.getAction();
if (action.equals("android.accounts.AccountAuthenticator"))
{
return mAuthenticator.getIBinder();
}
else if (action.equals("android.content.SyncAdapter"))
{
return mSyncAdapter.getSyncAdapterBinder();
}
else
return null;
}
Il reste à déclarer la synchronisation lors de la création du compte. Dans la méthode initAccount() précédente. Après la création du compte, nous ajoutons ces quelques lignes.
// Inform the system that this account supports sync
ContentResolver.setIsSyncable(account, CONTENT_AUTHORITY, 1);
// Inform the system that this account is eligible for auto sync when the network is up
ContentResolver.setSyncAutomatically(account, CONTENT_AUTHORITY, true);
// Recommend a schedule for automatic synchronization. The system
// may modify this based on other scheduled syncs and network utilization.
ContentResolver.addPeriodicSync(account, CONTENT_AUTHORITY,
new Bundle(), SYNC_FREQUENCY);
Et voilà. Nous avons un service qui est capable de jouer deux rôles. D'une part, il permet de gérer un compte, et d'autre part, d'être invoqué par Android lors d'une synchronisation.
Mutualisons tous cela
Dans les sources du projet et pour vous simplifier la vie, je propose la classe AbstractSyncService qui se charge de la tuyauterie. C'est un service qui propose un AccountAuthenticator et un SyncAdapter.
Pour l'utiliser, il faut en dériver et implémenter la méthode abstraite onPerformSync(). Cette classe ne propose pas de ContentProvider.
En indiquant votre classe dérivée dans la déclaration du service, vous avez le nécessaire pour recevoir une invocation lors d'une synchronisation de votre ContentProvider.
Vous pouvez alors séparer les couches de votre application. Les interfaces communiquent avec le ContentProvider. La communication réseau s'effectue uniquement dans la méthode onPerformSync().
Ne vous inquiétez pas, il est possible de demander une synchronisation immédiate (s'il y a le réseau), ou de simplement signaler qu'il y a eu des modifications et qu'il serait bien de synchroniser les données. Regardez les méthodes de ContentResolver.
Comment utiliser cela pour synchroniser le cache WebView ?
Nous avons franchi une étape, mais pas gagné la guerre. Notre objectif est d'implémenter la méthode onPerformSync() pour alimenter le cache WebKit de l'application. Cette méthode est invoquée dans un thread qui n'est pas l'UI thread. Normal. La synchronisation peut prendre un certain temps.
Pour alimenter le cache de WebKit, il n'y a pas trente-six moyens. Il faut créer un composant WebKit et lui demander de charger une page. Si cette dernière utilise le manifeste HTML5, alors WebKit demande l'alimentation du cache en tâche de fond. C'est seulement lorsque le cache est intégralement mis à jour, que les modifications seront visibles.
Donc notre stratégie consiste à enregistrer une suite d'URL à charger dans un composant WebKit caché. Sur la réception de l’événement onPageFinished, l'URL suivante est demandée au composant. Lorsqu'il n'y a plus d'URL à rafraîchir, la synchronisation est terminée.
C'est bien beau, mais il n'est pas possible de créer un composant WebKit si on n'est pas dans l'UI thread. Cela tombe mal, on est justement dans un autre thread. Après avoir hérité de notre classe abstraite précédente, nous créons alors un Handler dans l'UI thread.
public class SyncHTTPService extends AbstractSyncService
{
private Handler mHandler=new Handler();
private WebView mWebView;
…
}
Il va nous servir à déclencher notre scénario dans l'UI thread.
mHandler.post(new Runnable()
{
private int mState=0;
private String mUrl;
@Override
public void run()
{
…
});
Dans la méthode run(), nous pouvons construire une instance WebView et invoquer sa méthode loadUrl() avec la première URL.
Attention, si la méthode onPerformSync() termine prématurément, le framework Android en conclut que la synchronisation est terminée. Il peut alors tuer le processus !
Nous devons alors ajouter une synchronisation entre l'automate chargé de lire les pages par le composant WebView et la méthode onPerformSync(). Ainsi, onPerformSync() ne termine qu'après avoir chargé toutes les URL dans la WebView.
Une dernière subtilité : lorsque tout est terminé, la WebView peut continuer à vivre et à animer les flashs, les vidéos, etc. Il faut invoquer la méthode onPause(). Malheureusement, cette dernière n'est disponible qu'avec Android Honeycomb. Pour les versions précédentes, elle est bien présente via l'introspection.
Comme nous avons un ContentProvider, nous pouvons notifier l'application que le cache a été synchronisé, URL par URL. Cela tombe bien, les ContentProvider utilisent des URL. La ligne suivante permet d'informer qui de droit :
getContentResolver().notifyChange(Uri.parse(mUrl), null,true);
Nous informons les applications s'étant enregistrées pour l'URL via un
getContentResolver().registerContentObserver(url, true,mContentObserver);
Pour laisser un peu de temps au traitement du cache WebKit, la notification est légèrement retardée.
Je vous laisse étudier le code de la méthode onPerformSync().
Dans les sources, la classe SyncHTTPService est immédiatement fonctionnelle. Il suffit de déclarer le service et le ContentProvider comme dans l’AndroidManifest.xml du projet.
Les méthodes registerURL() et unregisterURL() permettent de gérer la liste des URL à charger dans le cache. Si les pages correspondantes sont compatibles HTML5 et possèdent un attribut manifest dans le marqueur <html/>, alors la suite est sous le contrôle du serveur HTTP. Je vous conseille de tester cela avec chrome ou un autre navigateur Web, pour vérifier que vous maîtrisez bien la gestion des caches HTML5.
Utilisation du cache
Nous pouvons alors utiliser une WebView en lui indiquant de n'utiliser que les données du cache.
mWebView.getSettings().setCacheMode(WebSettings.LOAD_CACHE_ONLY);
Ou bien, nous pouvons privilégier le cache et exploiter le réseau si nécessaire.
mWebView.getSettings().setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK);
Pourquoi utiliser le réseau si on peut s'en passer ? En condition difficile, la simple tentative de connexion au back-end pour savoir si une page du cache est toujours valide peut prendre un temps certain. Ceux qui utilisent leurs mobiles dans les trains de banlieue me comprendront.
Si la synchronisation est déjà passée, alors le cache est valide même sans le réseau. Périodiquement, le cache est rafraîchi, même si l'application ou l'activité n'est pas utilisée. Si l'application Web HTML5 à des choses à communiquer avec le back-end, elle le fera lors de la synchronisation.
Si nécessaire, elle doit s'arranger pour que la page ne soit pas considérée comme entièrement chargée, tant que les données ne sont pas toutes synchronisées.
<script type="text/javascript">
if (navigator.onLine)
{
var xmlhttp=new XMLHttpRequest();
xmlhttp.open("GET","push.html",true)
xmlhttp.send()
}
</script>
Il reste un dernier problème à gérer : la toute première fois. En effet, si l'utilisateur affiche l'activité avant que la première synchronisation ait été effectuée, il se retrouve avec une page d'erreur.
Une approche simple consiste à demander une gestion du cache avec replis sur le réseau si nécessaire.
mWebView.getSettings().setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK);
Pour une stratégie cache seul, nous pouvons régler cela assez facilement dans l'activité. Il suffit de capturer les erreurs ERROR_CONNECT et ERROR_HOST_LOOKUP pour adapter l'affichage de la page courante et déclencher une synchronisation immédiate.
mWebView.setWebViewClient(new WebViewClient()
{
@Override
public void onReceivedError(WebView view,
int errorCode, String description, String failingUrl)
{
switch (errorCode)
{
case ERROR_HOST_LOOKUP:
case ERROR_CONNECT:
ContentResolver.requestSync(
new Account(ACCOUNT_NAME, ACCOUNT_TYPE),
CONTENT_AUTHORITY, new Bundle());
ConnectivityManager cm=
((ConnectivityManager)getSystemService(Context.CONNECTIVITY_SERVICE));
NetworkInfo info=cm.getActiveNetworkInfo();
boolean isConnected =(info!=null) ? info.isConnectedOrConnecting() : false;
view.loadUrl(isConnected
? "file:///android_asset/Waiting.html"
: "file:///android_asset/NoNetwork.html");
break;
default:
view.loadUrl("file:///android_asset/Error.html");
}
}
});
Un truc intéressant est qu'il est possible d’enregistrer un ContentObserver pour être prévenu lorsque le cache est mis à jour. Cela permet de rafraîchir immédiatement la page.
// Observe the HTTP cache
private final ContentObserver mContentObserver=
new ContentObserver((new Handler()))
{
@Override
public void onChange(boolean selfChange)
{
super.onChange(selfChange);
onCacheUpdated(null);
}
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
@Override
public void onChange(boolean selfChange, Uri uri)
{
super.onChange(selfChange, uri);
onCacheUpdated(uri);
}
};
Dans la méthode onCacheUpdated() de l'activité :
protected void onCacheUpdated(Uri uri)
{
mWebView.reload();
}
Il reste à forcer la synchronisation si le cache est vide. Cela arrive lors de la première utilisation de l'application ou si l'utilisateur a cliqué sur le bouton « Vider le cache » dans les paramètres de l'application. La présence du cache WebView est facile à deviner.
final boolean webviewCache=
new File(context.getCacheDir(),"webviewCache").exists() ||
new File(context.getCacheDir(),"webviewCacheChromium").exists();
Notez que pour effacer le cache de l'application lors des tests, je vous conseille cette commande :
adb shell "run-as fr.prados.webkitcache rm -R cache"
Pour un composant WebKit, le cache est alimenté, même si l'URL est en HTTPS. Et il n'est pas chiffré. Donc, attention. Utilisez no-cache si nécessaire sur certaines pages.
Vous pouvez également forcer une synchronisation à partir des paramètres du comptes, via le menu.
Pour désactiver la synchronisation en tâche de fond simplement, il suffit de modifier le paramètre android:enabled du provider.
<service android:name="fr.prados.sync.SyncHTTPService"
android:exported="false"
android:enabled="false"
>
…
</service>
Faite également attention à la fraîcheur du manifeste HTML5. En effet, même avec le paramètre LOAD_NO_CACHE, Android commence par charger ce fichier et éventuellement les suivants, si et seulement si, le contenu a été modifié ! Donc, abusez des commentaires pour forcer une modification.
Lors de la création d'un compte, s'il n'en existe pas encore, il est également pertinent de demander une synchronisation immédiate.
Bundle b = new Bundle();
// Disable sync backoff and ignore sync preferences. In other
// words...perform sync NOW!
b.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true);
b.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true);
ContentResolver.requestSync(
account, // Sync account
contentAuthority, // Content authority
b); // Extras
Dans le code proposé, la classe MainActivity est un exemple d'utilisation de tous cela. Elle propose une WebView synchronisée en tâche de fond. Cette vue est paramétrée pour n'utiliser que les données du cache. Pour tester les différents scénarios, procédez ainsi :
- Désactivez les data 3G/4G
- Désactivez le Wi-Fi
- Dans les paramètres, sous l'application, videz le cache
- et lancez la. Une page indique qu'il faut activer le réseau.
- Nous sommes dans les pires conditions les pires, pas de réseau, pas de cache.
- Puis activez le Wi-Fi. Après quelques secondes, les logs indiquent que le cache est en marche.
- Dès que les données sont disponibles, une notification le signale à l'activité.
- La WebView est automatiquement rafraîchie.
- Dorénavant, même si l'application n'est pas en fonctionnement, le cache est rafraîchi périodiquement.
Attention, suivant les versions d'Android, le composant WebKit peut garder un petit moment la page actuelle en mémoire et ne pas la rafraîchir. Il faut être patient. Après quelques minutes, la page est correctement rafraîchie.
Lors de la compilation en développement, pour faciliter le déverminage, les mises à jour sont effectuées toutes les trente secondes. Il n'est donc pas conseillé de laisser l'application en fonctionnement sur un vrai téléphone. Suivant la valeur de BuildConfig.DEBUG, la périodicité du rafraîchissement est modifiée.
Petite optimisation
Comme toujours, je ne peux résister à apporter la dernière touche dans le fichier AndroidManifest.xml.
Nous pouvons déclarer que le service de synchronisation et le ContentProvideur sont dans un processus distinct de l'application. Il suffit d'ajouter dans les deux déclarations l'attribut android:process=":htmlcache". Cela permet d'alléger la mémoire lors d'une synchronisation si l'application n'est pas utilisée par l'utilisateur. Ce processus sera rapidement tué par Android. Attention, il faut alors utiliser le mode LOAD_CACHE_ELSE_NETWORK.
Au niveau sécurité, le service et le provider doivent être à exported="false". Ainsi, seul le système Android ou l'application peut les utiliser.
Pour conclure
Nous avons résolu un problème qui me tenait à cœur depuis longtemps. Cette approche permet de clouer le bec à tous les ayatollahs du mode natif. Ils refusent le mode hybride alors que c'est la meilleure solution pour les activités très volatiles.
Avec deux classes et quelques paramètres, le problème est résolu, même en hors-ligne. Je vous accorde que ces classes n'ont pas été faciles à concevoir. C'est bien pour cela que je partage mes travaux avec vous dans cet article.
