Téléchargement et installation des outils
Pour reproduire ce tutorial, vous aurez besoin des outils et API suivants :- Eclipse : le célèbre IDE
- SDK Android : pour développer des applications Android avec Eclipse
- Gson : API de sérialisation / désérialisation made in Google
Ajoutez un répertoire /lib à la racine du projet et importez-y la librairie gson.jar. Ajoutez également la dépendance au classpath du projet Eclipse.
Afin d'autoriser notre application à effectuer des appels réseaux via Internet, il faut ajouter au fichier AndroidManifest.xml la permission android.permission.INTERNET :
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="net.androgames.blog.sample.rest.client" android:versionCode="1" android:versionName="1.0"> <application android:icon="@drawable/icon" android:label="@string/app_name"> <activity android:name=".UserConsumer" android:label="@string/app_name" android:screenOrientation="portrait"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> <uses-sdk android:minSdkVersion="4" /> <uses-permission android:name="android.permission.INTERNET" /> </manifest>
Mise en place de l'interface utilisateur
Notre application sera décomposée en deux parties. L'une permettra la création, la mise à jour et la suppression d'utilisateurs tandis que l'autre affichera la liste de tous les utilisateurs avec leur prénom, nom et id :Le layout utilisé pour le rendu d'un utilisateur de la liste est définit par le fichier user.xml du répertoire res/layout :
<?xml version="1.0" encoding="UTF-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="?android:attr/listPreferredItemHeight" android:padding="6dip"> <ImageView android:src="@drawable/user" android:layout_width="48dip" android:layout_height="48dip" android:layout_marginRight="6dip" /> <LinearLayout android:orientation="vertical" android:layout_width="0dip" android:layout_weight="1" android:layout_height="fill_parent"> <TextView android:id="@+id/name" android:textAppearance="?android:textAppearanceMedium" android:layout_width="fill_parent" android:layout_height="0dip" android:layout_weight="1" android:ellipsize="marquee" android:gravity="center_vertical" /> <LinearLayout android:orientation="horizontal" android:layout_width="fill_parent" android:layout_height="0dip" android:layout_weight="1" > <TextView android:textAppearance="?android:textAppearanceSmall" android:textColor="#FFCC3333" android:textStyle="bold" android:layout_marginRight="6dip" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="ID :" android:ellipsize="marquee" /> <TextView android:id="@+id/id" android:textAppearance="?android:textAppearanceSmall" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginRight="6dip" android:singleLine="true" android:ellipsize="marquee" /> </LinearLayout> </LinearLayout> </LinearLayout>Le formulaire permettant la mise à jour ou la suppression d'un utilisateur existant, ainsi que la création de nouveaux utilisateurs se présentera sous les formes suivantes :
Les actions réalisables sont au nombre de 5 :
- Créer un nouvel utilisateur
- Enregistrer les modifications apportées au nom ou au prénom d'un utilisateur
- Supprimer un utilisateur
- Annuler l'édition en cours d'un utilisateur, le formulaire est alors remis à zéro
- En cliquant sur un utilisateur de la la liste, le formulaire se met à jour avec les informations de l'utilisateur sélectionné.
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent"> <LinearLayout android:orientation="vertical" android:paddingLeft="6dip" android:paddingRight="6dip" android:layout_width="fill_parent" android:layout_height="wrap_content"> <TextView android:text="@string/forName" android:textAppearance="?android:textAppearanceLarge" android:layout_width="fill_parent" android:layout_height="wrap_content"/> <EditText android:id="@+id/forName" android:layout_width="fill_parent" android:layout_height="wrap_content"/> <TextView android:text="@string/name" android:textAppearance="?android:textAppearanceLarge" android:layout_width="fill_parent" android:layout_height="wrap_content"/> <EditText android:id="@+id/name" android:layout_width="fill_parent" android:layout_height="wrap_content"/> <LinearLayout android:orientation="horizontal" android:layout_gravity="right" android:layout_width="wrap_content" android:layout_height="wrap_content"> <Button android:id="@+id/insertOrUpdate" android:onClick="insertOrUpdate" android:layout_width="wrap_content" android:layout_height="wrap_content"/> <Button android:id="@+id/delete" android:text="@string/delete" android:onClick="delete" android:visibility="gone" android:layout_width="wrap_content" android:layout_height="wrap_content"/> <Button android:id="@+id/initialize" android:text="@string/initialize" android:onClick="initialize" android:visibility="gone" android:layout_width="wrap_content" android:layout_height="wrap_content"/> </LinearLayout> </LinearLayout> <ListView android:id="@+id/users" android:layout_height="0dip" android:layout_weight="1" android:layout_width="fill_parent" /> </LinearLayout>Dans le Layout ci dessus, on remarquera en particulier que les boutons ne sont pas tous présents par défaut. Les boutons "Enregistrer", "Supprimer" et "Annuler" ne sont proposés que si un utilisateur de la liste à été sélectionné. L'attribut android:onClick des balises Button permet de spécifier le nom de la méthode appelée lorsque lorsque le bouton est pressé. Ces méthodes doivent être déclarées dans la ou les Activity qui utilisent le Layout comme content view. Elle doivent être publiques et prendre un unique paramètre de type View.
Pour le rendu de la liste des utilisateurs, nous utilisons un ListAdapter afin de faire le pont entre la ListView (couche de présentation) et notre liste d'utilisateurs (modèle de données).
package net.androgames.blog.sample.rest.client; import java.util.List; import android.content.Context; import android.database.DataSetObserver; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; import android.widget.ListView; import android.widget.TextView; public class UserAdapter extends BaseAdapter { private List<User> users; private LayoutInflater inflater; public UserAdapter(final ListView list, Context context) { this.inflater = LayoutInflater.from(context); // on attache l'adapter à la ListView list.setAdapter(this); // raffraichissement de la liste lorsque // une donnée est modifiée registerDataSetObserver(new DataSetObserver() { public void onChanged() { list.invalidateViews(); } }); } public int getCount() { if (users == null) { return 0; } else { return users.size(); } } public Object getItem(int position) { return users.get(position); } public long getItemId(int position) { return position; } public View getView(int position, View convertView, ViewGroup parent) { TaskHolder holder; // récupération du holder if (convertView == null) { convertView = inflater.inflate(R.layout.user, null); holder = new TaskHolder(); holder.name = (TextView) convertView.findViewById(R.id.name); holder.id = (TextView) convertView.findViewById(R.id.id); convertView.setTag(holder); } else { holder = (TaskHolder) convertView.getTag(); } // affichage de l'utilisateur d'index demande // via l'utilisation du holder if (position < getCount()) { User user = (User) getItem(position); holder.name.setText(user.getPrenom() + " " +user.getNom()); holder.id.setText(user.getId()); } return convertView; } public void setUsers(List<User> users) { this.users = users; // mise à jour de la liste notifyDataSetChanged(); } public void addUser(User user) { if (!users.contains(user)) { users.add(user); // mise à jour de la liste notifyDataSetChanged(); } } public void removeUser(User user) { users.remove(user); // mise à jour de la liste notifyDataSetChanged(); } /** * Holder class : * Permet de ne pas multiplier le nombre * d'instance de View utilisées pour * l'affichae des utilisateurs dans la * ListView */ static class TaskHolder { TextView name; TextView id; } }La méthode getView de l'Adapter permet de retourner une instance du LinearLayout utilisé pour le rendu d'un utilisateur (/res/layout/user.xml) et dans lequel les vues sont remplies avec les données de l'utilisateur en position demandée.
Appels aux services REST
L'interface utilisateur maintenant en place, nous allons nous pencher sur les appels aux services REST. Afin de ne pas tomber dans les fameuses erreurs ANR (Application Not Responding), les appels aux services s'exécuteront dans un Thread différent de celui utilisé pour les interactions avec l'utilisateur (rendu graphique, capture des événements, ...). Pour cela, nous utiliserons des AsyncTask afin que l'envoi de la requête au serveur, l'attente de la réponse et la récupération de la réponse s'exécute en parallèle du Thread principal. La méthode est similaire à l'utilisation du de la classe SwingWorker de Java 6./** * Classe abstraite pour l'envoi de requètes asynchrones au serveur. */ public abstract class AbstractTask extends AsyncTask<HttpRequestBase, Void, HttpResponse> { /** * Appelée avant le lancement du traitement en arrière plan. * Cette méthode est exécutée dans le Thread appelant. */ protected void onPreExecute() {} /** * Traitement en arrière plan. * Cette méthode est exécuté dans un Thread différent * du Thread appelant. */ protected HttpResponse doInBackground(final HttpRequestBase...requests) {} /** * Appelée après la fin du traitement en arrière plan. */ protected void onPostExecute(final HttpResponse response) {} /** * Traitement spécifique du JSON * @param in Le contenu de la réponse HTTP OK */ protected abstract void handleJson(final InputStream in); };Cette classe nous permet de définir un comportement générique pour chacun des appels aux services REST ainsi qu'un comportement spécifique à implémenter :
- onPreExecute
- Cette méthode est appelée dans le Thread principal avant que le traitement en tâche de fond ne soit lancé. Dans notre cas, nous l'utiliserons pour afficher une fenêtre popup d'attente.
- doInBackground
- Cette méthode appelée dans un Thread annexe permet de lancer un traitement en tâche de fond. Nous l'utiliserons pour effectuer les requêtes HTTP à destination du serveur et attendre la réponse de ce dernier. Cette méthode retourne un objet dans le type correspond à celui de la méthode ci dessous.
- onPostExecute
- Cette méthode est appelée dans le Thread principal une fois que le traitement en tâche de fond a terminé.Elle prend en paramètre le résultat du traitement effectué en tache de fond. Dans notre cas, cette méthode sera utilisée pour analyser la réponse du serveur et traiter le contenu de celle-ci.
- handleJson
- Cette méthode abstraite doit être surchargée pour traiter de manière spécifique le contenu de la réponse du serveur au format JSON.
/** * Récupération de la liste des User */ private class ListUsersTask extends AbstractTask { protected void handleJson(final InputStream in) { final Type collectionType = new TypeToken<List<User>>(){}.getType(); List<User> users = null; synchronized (lock) { users = gson.fromJson(new InputStreamReader(in), collectionType); } // La liste récupérée initialement est la référence // des User pour l'application Android adapter.setUsers(users); } };
/** * Recuperation d'un utilisateur * déjà référencé localement */ private class GetUserTask extends AbstractTask { protected void handleJson(final InputStream in) { synchronized (lock) { updateCurrentUser(gson.fromJson(new InputStreamReader(in), User.class)); } } };
/** * Récupération d'un nouvel utilisateur * non encore référencé localement */ private class AddUserTask extends AbstractTask { protected void handleJson(final InputStream in) { synchronized (lock) { updateCurrentUser(gson.fromJson(new InputStreamReader(in), User.class)); } adapter.addUser(currentUser); } };
/** * Suppression d'un utilisateur * référencé localement */ private class DeleteUserTask extends AbstractTask { protected void handleJson(final InputStream in) { User fakeUser = new User(); try { fakeUser.setId(new BufferedReader( new InputStreamReader(in, ENCODING_UTF_8)).readLine()); } catch (Exception e) {} adapter.removeUser(fakeUser); updateCurrentUser(null); } };Dans le cas de la suppression d'un utilisateur, le serveur nous renvoie un objet de type String sans délimiteur d'objet JSON : '{' ou '['. Nous traitons donc le contenu directement comme une chaine de caractères au moyen d'un BufferedReader.
Pour lancer ces traitements asynchrones d'envoi des requêtes au serveur, nous devons au préalable construire la HttpRequestBase que nous allons passer en paramètre à l'une de nos AbstractTask. Le code ci-dessous nous permet de mettre à jour l'utilisateur en cours d'édition en quelques étapes :
- Création d'un bean User avec les informations modifiées du formulaire
- Création d'une instance de la classe HttpPost pointant sur /user/{user.getId()}
- Le Content-Type du Header de la requête précise que le contenu est de type application/json
- Le bean User est sérialisé en JSON via la librairie Gson
- La représentation JSON est encodée en UTF-8 et positionnée dans la requête HTTP
- La requête est envoyé via une GetUserTask afin de récupérer le bean User retourné par le serveur
// mise a jour du currentUser User updatedUser = new User(); updatedUser.setNom(name.getEditableText().toString()); updatedUser.setPrenom(forName.getEditableText().toString()); // création d'une requête de type POST // l'URL contient l'ID du User à mettre à jour HttpPost request = new HttpPost( getString(R.string.user_endpoint) + "/" + currentUser.getId()); // précision du Content-Type request.setHeader("Content-Type", JSON_CONTENT_TYPE); synchronized (lock) { try { // l'objet de type User sérialisé est envoyé dans le corps // de la requête HTTP et encodé en UTF-8 (cf. Jackson) request.setEntity(new StringEntity(gson.toJson(updatedUser), ENCODING_UTF_8)); } catch (UnsupportedEncodingException e) {} } (new GetUserTask()).execute(request);L'emploi de l'encodage UTF-8 est imposé par la librairie Jackson utilisé par Jersey côté serveur pour la sérialisation / désérialisation du JSON. En espérant un support de l'encodage ISO-8859-1 dans une prochaine release...
Pour l'appel aux autres méthodes du serveur vous pouvez regarder le code complet de l'Activity donné ci-dessous :
package net.androgames.blog.sample.rest.client; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.UnsupportedEncodingException; import java.lang.reflect.Type; import java.util.List; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; import org.apache.http.client.methods.HttpDelete; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpPut; import org.apache.http.client.methods.HttpRequestBase; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.DefaultHttpClient; import org.apache.http.params.BasicHttpParams; import org.apache.http.params.HttpParams; import org.apache.http.params.HttpProtocolParams; import android.app.Activity; import android.app.AlertDialog; import android.app.Dialog; import android.app.ProgressDialog; import android.content.DialogInterface; import android.content.DialogInterface.OnClickListener; import android.os.AsyncTask; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.AdapterView; import android.widget.Button; import android.widget.EditText; import android.widget.ListView; import android.widget.AdapterView.OnItemClickListener; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; public class UserConsumer extends Activity implements OnItemClickListener { private static final DefaultHttpClient httpClient = new DefaultHttpClient(); static { HttpParams params = new BasicHttpParams(); HttpProtocolParams.setContentCharset(params, "UTF-8"); httpClient.setParams(params); } private static final Gson gson = new Gson(); private static final String JSON_CONTENT_TYPE = "application/json; charset=UTF-8"; private static final String ENCODING_UTF_8 = "UTF-8"; private static final int DIALOG_ERROR = 0; private static final int DIALOG_LOADING = 1; private EditText name, forName; private Button insertOrUpdate, initialize, delete; private User currentUser; // utilisateur courant private UserAdapter adapter; // utilisé comme référentiel local private Object lock = new Object(); /** * Classe abstraite pour l'envoi de requêtes asynchrones au serveur. */ private abstract class AbstractTask extends AsyncTask<HttpRequestBase, Void, HttpResponse> { /** * Appelée avant le lancement du traitement en arrière plan. * Cette méthode est exécutée dans le Thread appelant. */ protected void onPreExecute() { showDialog(DIALOG_LOADING); } /** * Traitement en arrière plan. * Cette méthode est exécutée dans un Thread différent * du Thread appelant. */ protected HttpResponse doInBackground(final HttpRequestBase...requests) { HttpResponse response = null; synchronized (lock) { try { response = httpClient.execute(requests[0]); } catch (Exception e) { Log.e(UserConsumer.class.getSimpleName(), "Erreur d'appel au serveur", e); } } return response; } /** * Appelé après la fin du traitement en arrière plan. */ protected void onPostExecute(final HttpResponse response) { dismissDialog(DIALOG_LOADING); if (response == null || !(response.getStatusLine() .getStatusCode() == HttpStatus.SC_OK)) { showDialog(DIALOG_ERROR); } else { try { handleJson(response.getEntity().getContent()); } catch (IOException e) { Log.e(UserConsumer.class.getSimpleName(), "Erreur de flux", e); } } } /** * Traitement spécifique du JSON * @param in Le contenu de la réponse HTTP OK */ protected abstract void handleJson(final InputStream in); }; /** * Récupération de la liste des User */ private class ListUsersTask extends AbstractTask { protected void handleJson(final InputStream in) { final Type collectionType = new TypeToken<List<User>>(){}.getType(); List<User> users = null; synchronized (lock) { users = gson.fromJson(new InputStreamReader(in), collectionType); } // La liste récupérée initialement est la référence // des User pour l'application Android adapter.setUsers(users); } }; /** * Recuperation d'un utilisateur * déjà référencé localement */ private class GetUserTask extends AbstractTask { protected void handleJson(final InputStream in) { synchronized (lock) { updateCurrentUser(gson.fromJson(new InputStreamReader(in), User.class)); } } }; /** * Récupération d'un nouvel utilisateur * non encore référencé localement */ private class AddUserTask extends AbstractTask { protected void handleJson(final InputStream in) { synchronized (lock) { updateCurrentUser(gson.fromJson(new InputStreamReader(in), User.class)); } adapter.addUser(currentUser); } }; /** * Suppression d'un utilisateur * référencé localement */ private class DeleteUserTask extends AbstractTask { protected void handleJson(final InputStream in) { User fakeUser = new User(); try { fakeUser.setId(new BufferedReader( new InputStreamReader(in, ENCODING_UTF_8)).readLine()); } catch (Exception e) {} adapter.removeUser(fakeUser); updateCurrentUser(null); } }; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); adapter = new UserAdapter((ListView) findViewById(R.id.users), this); insertOrUpdate = (Button) findViewById(R.id.insertOrUpdate); initialize = (Button) findViewById(R.id.initialize); delete = (Button) findViewById(R.id.delete); name = (EditText) findViewById(R.id.name); forName = (EditText) findViewById(R.id.forName); } @Override public void onResume() { super.onResume(); // recuperation de tous les utilisateurs (new ListUsersTask()).execute(new HttpGet(getString(R.string.user_endpoint))); // initialisation des actions ((ListView) findViewById(R.id.users)).setOnItemClickListener(this); // initialisation de l'IHM // currentUser peut ne pas être null // si l'activité a été résumée updateCurrentUser(currentUser); } /** * Clique sur le bouton Créer ou Enregistrer * @param v */ public void insertOrUpdate(View v) { if (currentUser == null) { // nouvel utilisateur User user = new User(); user.setNom(name.getEditableText().toString()); user.setPrenom(forName.getEditableText().toString()); // création d'une requête de type POST // l'URL contient l'ID du User à mettre à jour HttpPut request = new HttpPut(getString(R.string.user_endpoint)); // précision du Content-Type request.setHeader("Content-Type", JSON_CONTENT_TYPE); synchronized (lock) { try { // l'objet de type User sérialisé est envoyé dans le corps // de la requête HTTP et encodé en UTF-8 (cf. Jackson) request.setEntity(new StringEntity( gson.toJson(user), ENCODING_UTF_8)); } catch (UnsupportedEncodingException e) {} } (new AddUserTask()).execute(request); } else { // mise a jour du currentUser User updatedUser = new User(); updatedUser.setNom(name.getEditableText().toString()); updatedUser.setPrenom(forName.getEditableText().toString()); // création d'une requête de type POST // l'URL contient l'ID du User à mettre à jour HttpPost request = new HttpPost( getString(R.string.user_endpoint) + "/" + currentUser.getId()); // précision du Content-Type request.setHeader("Content-Type", JSON_CONTENT_TYPE); synchronized (lock) { try { // l'objet de type User sérialisé est envoyé dans le corps // de la requête HTTP et encodé en UTF-8 (cf. Jackson) request.setEntity(new StringEntity( gson.toJson(updatedUser), ENCODING_UTF_8)); } catch (UnsupportedEncodingException e) {} } (new GetUserTask()).execute(request); } } /** * Clique sur le bouton Supprimer * @param v */ public void delete(View v) { // envoi d'une requête DELETE au serveur // sur l'URL correspondant au User à supprimer (new DeleteUserTask()).execute(new HttpDelete( getString(R.string.user_endpoint) + "/" + currentUser.getId())); } /** * Clique sur le bouton Annuler * @param v */ public void initialize(View v) { // raz du formulaire updateCurrentUser(null); } // Met a jour le formulaire avec les // informations de l'utilisateur passé // en paramètre. Si le formulaire est // positionné sur les données d'un utilisateur // de même id, le référentiel local est mis à jour private void updateCurrentUser(User user) { if (user == null) { currentUser = null; name.setText(""); forName.setText(""); delete.setVisibility(View.GONE); initialize.setVisibility(View.GONE); insertOrUpdate.setText(R.string.create); } else { if (!user.equals(currentUser)) { // changement de User currentUser = user; } else { // mise a jour des informations du User // dans le référentiel local currentUser.setNom(user.getNom()); currentUser.setPrenom(user.getPrenom()); adapter.notifyDataSetChanged(); } // mise à jour du formulaire name.setText(currentUser.getNom()); forName.setText(currentUser.getPrenom()); delete.setVisibility(View.VISIBLE); initialize.setVisibility(View.VISIBLE); insertOrUpdate.setText(R.string.update); } } @Override public Dialog onCreateDialog(int dialogId) { Dialog dialog = null; AlertDialog.Builder builder = null; switch (dialogId) { case DIALOG_LOADING : // recuperation en cours... dialog = new ProgressDialog(this); ((ProgressDialog) dialog).setIndeterminate(true); ((ProgressDialog) dialog).setMessage(getString(R.string.loading)); break; case DIALOG_ERROR : // message d'erreur builder = new AlertDialog.Builder(this); builder.setTitle(R.string.error) .setMessage(R.string.error_message) .setNegativeButton(R.string.close, new OnClickListener() { public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); } }); dialog = builder.create(); break; } return dialog; } /** * L'utilisateur à cliqué sur un User de la liste, * le formulaire est mis à jour avec les informations * du User récupérés depuis le serveur */ public void onItemClick(AdapterView<?> parent, View view, int position, long id) { // mise a jour du formulaire avec les informations // du User sélectionné par l'utilisateur updateCurrentUser((User) adapter.getItem(position)); // on aurait également pu demander l'utilisateur // au serveur à chaque fois, mais on considere // que la liste initialement chargée est notre // référence afin de garder un jeu de donnée cohérent // (new GetUserTask()).execute(new HttpGet( // getString(R.string.user_endpoint) + // "/" + ((User) adapter.getItem(position)).getId())); } }Vous pouvez récupérer l'exemple complet sur Google Code : SampleAndroidRestClient.