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("");
}
}