Antipattern : une activité qui gère tout
- A éviter : une activité (ou un service) s'imiscant dans la logique des données (récupération, traitement de données...)
- Une activité (ou fragment) devrait s'occuper uniquement de la problématique de la présentation des données à l'écran
- Page de la documentation Android pour en savoir plus à ce sujet
Définition d'un modèle séparé
- Création de classes représentant le modèle de données utilisé
- Pour un meilleur découplage, le modèle peut être dans un paquetage dédié (e.g. com.example.model)
-
Il est utile que les données du modèle puissent être sauvegardables :
-
en mémoire centrale par sérialisation
- facile en implantant l'interface Serializable
- demande un peu plus de travail pour utiliser le mécanisme de sérialisation maison d'Android avec Parcelable
-
sur la mémoire flash dans un fichier ou une BDD SQlite
- possibilité d'utiliser la sérialisation Java standard pour la sauvegarde dans un fichier (ObjectOutputStream)
- emploi de la BDD SQlite soit directement, soit en passant par un système d'ORM (telle que la bibliothèque Room)
-
en mémoire centrale par sérialisation
Exemple : une liste de courses
- Création d'une classe fr.uge.shoppingapp.model.ShoppingItem pour représenter un élément à acheter
- Création d'une classe fr.uge.shoppingapp.model.ShoppingList pour représenter une liste d'items à acheter
Un modèle simple communiquant avec la vue
- La vue doit être informée des modifications du modèle pour l'afficher
-
Deux approches envisageables :
-
Approche pull : la vue (activité) interroge le modèle toutes les N millisecondes
- Envisageable si le modèle change continuellement (e.g. scène de jeu)
- Sinon beaucoup trop coûteux si récupérations ≫ changements
-
Approche push : le composant à l'origine du changement sur le modèle informe la vue afin qu'elle puisse se redessiner
- Le composant à l'origine du changement ne doit pas communiquer directement avec la vue
- C'est le modèle qui doit prévenir la vue
- Solution employée : pattern de l'observé (modèle) - observateur (vue)
-
Approche pull : la vue (activité) interroge le modèle toutes les N millisecondes
Comment mettre en place : le pattern observé-observateur ?
- On créé une interface (fonctionnelle) avec une méthode qui sera utilisée lorsque le changement a lieu
- L'observé (modèle) comporte une méthode permettant d'enregistrer une ou plusieurs instances de cette interface
-
L'observateur (vue) crée une instance de l'interface et insère du code qui lui permettra de se mettre à jour dans la méthode implantée de l'interface
- Lorsque le changement a lieu, l'observé déclenche l'appel de cette méthode définie par l'observateur et déclenche ainsi sa mise à jour
Une classe entre la vue et le modèle : ViewModel
- Introduction d'une classe intermédiaire permettant la communication entre la vue (activité) et le modèle : ViewModel
- Exposition par le ViewModel de LiveData (voire MutableLiveData pour permettre le changement de données) : classe enveloppant les données du modèle avec support du pattern observateur/observé
-
Persistance des données du ViewModel ?
- Conservation automatique du ViewModel lors de la recréation de l'activité par changement de configuration (e.g. lors d'une rotation)
- Pas de conservation du ViewModel en cas de destruction par manque de ressources puis recréation : il faut utiliser un SavedStateHandle
- ViewModel non adapté à la conservation de données à long terme : il faut utiliser un moyen de stockage local (fichier stocké, base de données SQLite...)
- Connexion du ViewModel au modèle avec utilisation possible d'un système de récupération/synchronisation en ligne de données et cache en local
Un exemple : une tribune de discussion
- Application mettant en œuvre un salon de discussion public permettant de dialoguer à plusieurs
-
Fonctionnalités :
- Affichage des derniers messages du salon (stockés sur le serveur)
- Possibilité d'envoyer un nouveau message (avec un pseudo aléatoire anonyme)
- Communication avec le protocole WebSocket (protocole permettant l'échange de messages bidirectionnels inité avec une communication HTTP classique)
Etape 0 : écriture du serveur
- Utilisation d'un langage avec framework supportant la réalisation d'un serveur proposant le protocole WebSocket
- Exemple proposé : utilisation de Python 3 avec le framework de communication asynchrone aiohttp
-
Communication réalisée avec quatre types de messages texte JSON :
- {"kind": "availableMessages", "first": 100, "last": 200} : indique la liste des messages disponibles avec l'id du premier et du dernier message (envoyé spontanément à la connexion sur la WebSocket ou lorsqu'un nouveau message est posté)
- {"kind": "sendMessage", "content": "Contenu du message"} : envoi d'un nouveau message par l'application Android
- {"kind": "fetchMessage", "id": 101} : demande de récupération d'un message par l'application Android
- {"kind": "message", "id": 101, "author: "foobar", "content": "Contenu du message"} : contenu d'un message envoyé par le serveur (suite à une demande de l'application avec fetchMessage)
#! /usr/bin/env python3 A simple server managing a chatroom import asyncio, time, sys, json import websockets class Post(object): def __init__(self, author, content): self.author = author self.content = content self.timestamp = int(time.time()) # unix timestamp class Posts(object): def __init__(self): self._elements = {} self._listeners = set() # to be called when there is a new post self.first_id = -1 self.last_id = -1 def get(self, id): return self._elements.get(id) def add(self, post: Post): if self.first_id == -1: self.first_id = 0 self.last_id += 1 self._elements[self.last_id] = post for listener in self._listeners: listener() def add_listener(self, listener): self._listeners.add(listener) def remove_listener(self, listener): self._listeners.remove(listener) async def manage_websocket(posts, websocket, path): # the username is based on the IP address and port of the client username = "{}/{}".format(websocket.remote_address[0], websocket.remote_address[1]) def on_new_post(): # called to notify about a new post print("Sending available messages: {} {}".format(posts.first_id, posts.last_id), file=sys.stderr) asyncio.create_task(websocket.send(json.dumps({"kind": "availableMessages", "first": posts.first_id, "last": posts.last_id}))) posts.add_listener(on_new_post) on_new_post() # call the listener for the first time async for message in websocket: # interpret the message as JSON try: message_dict = json.loads(message) except: message_dict = None kind = message_dict.get("kind", None) if message_dict else None if kind == "fetchMessage": id = message_dict.get("id", None) post = posts.get(id) if post: asyncio.create_task(websocket.send(json.dumps( {"kind": "message", "id": id, "author": post.author, "timestamp": post.timestamp, "content": post.content}))) elif kind == "sendMessage": content = message_dict.get("content") if content: posts.add(Post(username, content)) def start(interface, port): posts = Posts() asyncio.get_event_loop().run_until_complete( websockets.serve(lambda websocket, path: manage_websocket(posts, websocket, path), interface, port)) asyncio.get_event_loop().run_forever() def main(): try: interface, port = (sys.argv[1], int(sys.argv[2])) except: print("Usage: {} interface port".format(sys.argv[0])) sys.exit(1) start(interface, port) if __name__ == "__main__": main()
Etape 1 : écriture du modèle représentant un message
-
Informations d'un message :
- id : identifiant du message
- timestamp : moment où le message a été envoyé sous la forme d'un timestamp Unix (nombre de secondes écoulées depuis le 1/01/1970)
- author : auteur du message (utilisation de l'adresse IP et du port de la socket cliente)
- content : contenu du message
-
Container de messages avec ChatMessages
- Utilisation d'une TreeMap avec initialisation par id (id sérial croissant)
- Sérialisation/désérialisation du container sur le stockage de masse avec la sérialisation standard Java (Object{Input,Output}Stream) ; nécessite de déclarer avec l'interface Serializable toutes les classes impliquées
// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License package fr.upem.coursand.chatclient.model; import org.json.JSONException; import org.json.JSONObject; import java.io.Serializable; /** A message posted to the chatroom */ public class ChatMessage implements Serializable { public final long id; public final String author; public final long timestamp; public final String content; public ChatMessage(long id, String author, long timestamp, String content) { this.id = id; this.author = author; this.timestamp = timestamp; this.content = content; } public ChatMessage(String content) { this(-1L, null, -1L, content); } /** Create a ChatMessage object from JSON data */ public static ChatMessage fromJSONObject(JSONObject obj) { try { return new ChatMessage(obj.getLong("id"), obj.getString("author"), obj.getLong("timestamp"), obj.getString("content")); } catch (JSONException e) { // the input in malformed, we return null return null; } } @Override public String toString() { return String.format("%d: [%s] %s", id, author, content); } }
// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License package fr.upem.coursand.chatclient.model; import android.os.SystemClock; import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.OutputStream; import java.io.Serializable; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.SortedMap; import java.util.TreeMap; import java.util.stream.Collectors; /** A set of retrieved chat messages */ public class ChatMessages implements Serializable { public final long updateTimestamp; public final SortedMap<Long, ChatMessage> messages; public ChatMessages(SortedMap<Long, ChatMessage> messages) { this.updateTimestamp = System.currentTimeMillis(); this.messages = Collections.unmodifiableSortedMap(new TreeMap<>(messages)); // create an immutable copy of the map } @Override public String toString() { return messages.values().stream().map(ChatMessage::toString).collect(Collectors.joining("\n")); } /** Save this object to an output stream (using the standard Java serialization) */ public boolean saveToOutputStream(OutputStream os) { try (ObjectOutputStream oos = new ObjectOutputStream(os)) { oos.writeObject(this); return true; } catch (IOException e) { return false; } } /** Read this object from an input stream (using the standard Java serialization) */ public static ChatMessages loadFromInputStream(InputStream is) { try (ObjectInputStream ois = new ObjectInputStream(is)) { return (ChatMessages)ois.readObject(); } catch (Exception e) { return null; } } }
Etape 2 : écriture de la classe gérant la récupération des messages sur le serveur
- Utilisation de la bibliothèque okhttp par ChatRoomConnector pour communiquer avec le serveur Python avec une websocket
// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License package fr.upem.coursand.chatclient.connector; import android.util.Log; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.json.JSONException; import org.json.JSONObject; import java.lang.ref.WeakReference; import java.util.Map; import java.util.SortedMap; import java.util.TreeMap; import java.util.function.Consumer; import fr.upem.coursand.chatclient.model.ChatMessage; import fr.upem.coursand.chatclient.model.ChatMessages; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import okhttp3.WebSocket; import okhttp3.WebSocketListener; import okhttp3.internal.tls.OkHostnameVerifier; import okio.ByteString; /** Manage a websocket connection to the chat room server to retrieve and send messages */ public class ChatRoomConnector { private final String url; private final OkHttpClient webClient; public ChatRoomConnector(String url) { this.url = url; this.webClient = new OkHttpClient(); } private boolean initialized = false; private WebSocket webSocket = null; private String offlineReason = "not initialized"; private WebSocketListener webSocketListener = new WebSocketListener() { @Override public void onClosed(@NotNull WebSocket webSocket, int code, @NotNull String reason) { super.onClosed(webSocket, code, reason); ChatRoomConnector.this.webSocket = null; if (receivalCallback != null) { ChatRoomConnector.this.webSocket = null; offlineReason = "normally closed"; receivalCallback.accept(null); // to signal the closure of the socket } } @Override public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) { super.onClosing(webSocket, code, reason); } @Override public void onFailure(@NotNull WebSocket webSocket, @NotNull Throwable t, @Nullable Response response) { super.onFailure(webSocket, t, response); if (receivalCallback != null) { ChatRoomConnector.this.webSocket = null; offlineReason = t.getMessage(); receivalCallback.accept(null); // to signal the closure of the socket } } @Override public void onMessage(@NotNull WebSocket webSocket, @NotNull String text) { super.onMessage(webSocket, text); processReceivedMessage(text); } @Override public void onMessage(@NotNull WebSocket webSocket, @NotNull ByteString bytes) { super.onMessage(webSocket, bytes); Log.d(getClass().getName(), "Received binary message"); } @Override public void onOpen(@NotNull WebSocket webSocket, @NotNull Response response) { super.onOpen(webSocket, response); ChatRoomConnector.this.webSocket = webSocket; offlineReason = null; } }; private void initWebSocket() { if (! initialized) { Request r = new Request.Builder().url(url).build(); webClient.newWebSocket(r, webSocketListener); initialized = true; } } /** Map storing retrieved messages */ private SortedMap<Long, ChatMessage> retrievedMessages = new TreeMap<>(); private void processReceivedMessage(String text) { Log.d(getClass().getName(), "Received message: " + text); try { JSONObject obj = new JSONObject(text); String kind = obj.getString("kind"); if (kind.equals("availableMessages")) { long first = obj.getLong("first"); long last = obj.getLong("last"); if (first != -1L) for (long i = first; i <= last; i++) if (! retrievedMessages.containsKey(i)) retrieveMessage(i); if (receivalCallback != null) receivalCallback.accept(null); } else if (kind.equals("message")) { // a retrieved message ChatMessage cm = ChatMessage.fromJSONObject(obj); if (cm != null) { retrievedMessages.put(cm.id, cm); if (receivalCallback != null) receivalCallback.accept(new ChatMessages(retrievedMessages)); } } } catch (JSONException e) { Log.e(getClass().getName(), "Error while parsing " + text, e); } } private void retrieveMessage(long id) { initWebSocket(); try { JSONObject obj = new JSONObject(); obj.put("kind", "fetchMessage"); obj.put("id", id); webSocket.send(obj.toString()); } catch (JSONException e) { Log.e(getClass().getName(), "Error while trying to ask to retrieve message " + id, e); } } private Consumer<ChatMessages> receivalCallback; /** Order to be informed about new messages */ public void startMessageReceival(Consumer<ChatMessages> callback) { receivalCallback = callback; initWebSocket(); } public boolean sendMessage(ChatMessage message) { initWebSocket(); try { JSONObject obj = new JSONObject(); obj.put("kind", "sendMessage"); obj.put("content", message.content); return webSocket.send(obj.toString()); // thread-safe } catch (JSONException e) { Log.e(getClass().getName(), "JSON exception", e); return false; } } /** Return if the websocket is offline (null it is online) */ public String getOfflineReason() { return offlineReason; } public void close() { if (webSocket != null) webSocket.close(1000, "good bye"); } }
Etape 3 : écriture du ViewModel
- ChatMessagesViewModel utilisant ChatRoomConnector pour récupérer les messages et les envoyer
- Expose un LiveData<ChatMessages> pour permettre l'activité d'être informée des nouveaux messages (ainsi qu'un LiveData<String> pour le statut de connexion)
-
La méthode onCleared() est appelée lors que le ViewModel est détruit (suite à la destruction de l'activité)
- Sauvegarde des messages dans un fichier pour les retrouver à un lancement ultérieur (recharge dans le constructeur)
// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License package fr.upem.coursand.chatclient.viewmodel; import android.app.Application; import android.content.Context; import android.content.SharedPreferences; import android.os.Handler; import android.util.Log; import androidx.annotation.NonNull; import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.LinkedList; import java.util.Queue; import fr.upem.coursand.chatclient.connector.ChatRoomConnector; import fr.upem.coursand.chatclient.model.ChatMessage; import fr.upem.coursand.chatclient.model.ChatMessages; public class ChatMessagesViewModel extends AndroidViewModel { public static final long RETRY_CONNECTION_DELAY = 30000L; // in millis private ChatRoomConnector connector = null; private MutableLiveData<ChatMessages> chatMessages = new MutableLiveData<>(); private MutableLiveData<String> offlineReason = new MutableLiveData<String>(); private Queue<ChatMessage> sendMessageQueue = new LinkedList<>(); private Handler handler = new Handler(); // to retry connection periodically private SharedPreferences prefs; public ChatMessagesViewModel(@NonNull Application application) { super(application); prefs = getApplication().getApplicationContext().getSharedPreferences("params", Context.MODE_PRIVATE); // reinitialize the connector if the url is specified by the user or if it is changed in the preferences prefs.registerOnSharedPreferenceChangeListener((sharedPreferences, key) -> { if (key.equals("serverUrl")) initConnector(); }); ChatMessages restoredMessages = restoreBackupedMessages(); if (restoredMessages != null) chatMessages.setValue(restoredMessages); initConnector(); } private Runnable retryConnectionRunnable = () -> initConnector(); private void initConnector() { String url = prefs.getString("serverUrl", null); if (url != null) { Log.i(getClass().getName(), "Trying to connect to the websocket " + url); handler.removeCallbacks(retryConnectionRunnable); if (connector != null) connector.close(); // close the previous connector offlineReason.setValue("Connecting to " + url); connector = new ChatRoomConnector(url); // when new messages are received, post the value to the live data (that is observed by the activity) connector.startMessageReceival((ms) -> { offlineReason.postValue(connector.getOfflineReason()); if (ms == null) { // the socket has been closed if (connector.getOfflineReason() != null) handler.postDelayed(retryConnectionRunnable, RETRY_CONNECTION_DELAY); // retry to connect later } else { chatMessages.postValue(ms); } }); } } public LiveData<ChatMessages> getChatMessages() { return chatMessages; } public LiveData<String> getOfflineReason() { return offlineReason; } /** Add the message to the queue */ public void sendMessage(ChatMessage message) { sendMessageQueue.add(message); flushMessageQueue(); } /** Ask the connector to send the queued messages (if the connector is initialized) */ private void flushMessageQueue() { if (connector != null && connector.getOfflineReason() == null) while (! sendMessageQueue.isEmpty()) { ChatMessage cm = sendMessageQueue.peek(); if (connector.sendMessage(cm)) sendMessageQueue.poll(); // remove the message from the queue else break; // the websocket cannot send the messages } } private void backupMessages() { ChatMessages cms = chatMessages.getValue(); // backup the messages if (cms != null) { Log.i(getClass().getName(), "Backup " + cms.messages.size() + " messages"); try (OutputStream os = getApplication().getApplicationContext().openFileOutput("cachedMessages", Context.MODE_PRIVATE)) { cms.saveToOutputStream(os); } catch (IOException e) { Log.e(getClass().getName(), "error while backuping messages", e); } } } private ChatMessages restoreBackupedMessages() { try (InputStream is = getApplication().getApplicationContext().openFileInput("cachedMessages")) { return ChatMessages.loadFromInputStream(is); } catch (IOException e) { Log.e(getClass().getName(), "error while loading messages", e); return null; } } /** Call when the viewmodel is destroyed (because the activity using it is finished) */ @Override protected void onCleared() { Log.i(getClass().getName(), "Cleared!"); if (connector != null) connector.close(); // close the websocket backupMessages(); super.onCleared(); } }
Etape 4 : écriture de l'activité utilisant le ViewModel
Composants de l'activité :
-
Un grand TextView (messagesView pour afficher la liste des messages
- Il aurait été préférable d'utiliser un RecyclerView mieux adapté à l'affichage de listes (mais plus complexe et verbeux à écrire)
- Observation du LiveData<ChatMessages> chatMessages pour être informé des mises à jour
- Un EditText (messageToSendView) pour écrire le message à envoyer
- Un Button (sendButton) pour envoyer le message avec appel à la méthode sendMessage du ViewModel
- Un TextView affichant le statut actuel de connexion avec l'observation LiveData<String> offlineReason
// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License package fr.upem.coursand.chatclient.view; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.lifecycle.ViewModelProvider; import android.content.Context; import android.content.DialogInterface; import android.content.SharedPreferences; import android.os.Bundle; import android.text.InputType; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.EditText; import android.widget.TextView; import fr.upem.coursand.R; import fr.upem.coursand.chatclient.model.ChatMessage; import fr.upem.coursand.chatclient.viewmodel.ChatMessagesViewModel; public class ChatClientActivity extends AppCompatActivity { private ChatMessagesViewModel viewModel; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_chat_client); viewModel = ViewModelProvider.AndroidViewModelFactory.getInstance(this.getApplication()) .create(ChatMessagesViewModel.class); // update the status bar with the offline reason viewModel.getOfflineReason().observe(this, offlineReason -> { TextView tv = findViewById(R.id.statusTextView); tv.setText(offlineReason != null ? offlineReason : "online"); }); // update the messages list viewModel.getChatMessages().observe(this, messages -> { TextView tv = findViewById(R.id.messagesView); tv.setText(messages.toString()); }); } private String getServerUrl() { SharedPreferences prefs = getApplicationContext().getSharedPreferences("params", Context.MODE_PRIVATE); return prefs.getString("serverUrl", ""); } private void changeServerUrl(String url) { SharedPreferences prefs = getApplicationContext().getSharedPreferences("params", Context.MODE_PRIVATE); prefs.edit().putString("serverUrl", url).commit(); } private void displayServerUrlDialog() { AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setTitle("URL of the chat server"); final EditText input = new EditText(this); input.setInputType(InputType.TYPE_TEXT_VARIATION_URI); input.setText(getServerUrl()); builder.setView(input); builder.setPositiveButton("OK", (dialog, which) -> changeServerUrl(input.getText().toString())); builder.setNegativeButton("Cancel", (dialog, which) -> dialog.cancel()); builder.show(); } @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.chat_server_client_menu, menu); return super.onCreateOptionsMenu(menu); } @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == R.id.setServerUrl) { displayServerUrlDialog(); } return super.onOptionsItemSelected(item); } public void sendMessage(View v) { EditText et = findViewById(R.id.messageToSendEditText); viewModel.sendMessage(new ChatMessage(et.getText().toString())); et.setText(""); } }