3. 案例研究 - 预约管理
3.1. 项目
在文档 [AngularJS / Spring 4 教程] 中,开发了一个用于管理医生预约的客户端/服务器应用程序。下文我们将该文档简称为 [rdvmedecins-angular]。该应用程序包含两种类型的客户端:
- HTML/CSS/JS客户端;
- 一个 Android 客户端;
Android客户端是通过[Cordova]工具从HTML版本的客户端自动生成的。本项目的目标是利用前几章所学知识,手动重构该Android客户端。
请注意这两种方案之间存在一个重要区别:
- 我们将要创建的客户端仅适用于 Android 平板电脑;
- 而在 [rdvmedecins-angular] 版本中,移动 Web 客户端(HTML/CSS/JS)可在任何平台(Android、iOS、Windows)上运行;
3.2. Android 客户端视图
共有四种观点。
配置视图

医生和预约日期选择视图

预约时段选择界面

预约客户选择视图

3.3. 项目架构
我们将采用与本文档示例 [Example-15](参见第 1.16 节)类似的客户端/服务器架构:

客户端与服务器之间的异步通信将通过 RxAndroid 库来处理。
3.4. 数据库
在本文档中不扮演核心角色。我们仅为说明目的而提供该数据库。我们将它命名为 [ dbrdvmedecins]。这是一个包含四个表的 MySQL5 数据库:
![]() |
3.4.1. [MEDECINS] 表
该表包含由 [RdvMedecins] 应用程序管理的医生信息。
![]() | ![]() |
- ID:医生的ID号——该表的主键
- VERSION:一个标识该表中行版本的数字。每次对该行进行修改时,该数字会递增 1。
- LAST_NAME:医生的姓
- FIRST_NAME:医生的名字
- TITLE:称谓(Ms.、Mrs.、Mr.)
3.4.2. [CLIENTS] 表
各医生的客户信息存储在 [CLIENTS] 表中:
![]() | ![]() |
- ID:客户的ID号——该表的主键
- VERSION:标识该表中该行版本的编号。每次对该行进行修改时,该编号会递增 1。
- LAST NAME:客户的姓
- FIRST NAME:客户的名字
- TITLE:称谓(Ms.、Mrs.、Mr.)
3.4.3. [SLOTS] 表
该表列出了可预约的时间段:
![]() |
![]() | ![]() | ![]() |
- ID:时间段的ID号——该表的主键(第8行)
- VERSION:标识表中该行版本的编号。每次对该行进行修改时,该编号会递增1。
- DOCTOR_ID:标识该时段所属医生的ID号——作为DOCTORS表中ID列的外键。
- START_TIME:时间段的开始时间
- MSTART:时间段的开始分钟
- HFIN:时段结束时间
- MFIN:该时段的结束分钟
例如,[SLOTS] 表(参见上文 [1])的第二行表明,第 2 号时段于上午 8:20 开始,上午 8:40 结束,属于第 1 号医生(Marie PELISSIER 女士)。
3.4.4. [RV] 表
该表列出了每位医生的预约情况:
![]() | ![]() |
- ID:预约的唯一标识符——主键
- DAY:预约日期
- SLOT_ID:预约的时间段——[SLOTS] 表中 [ID] 字段的外键——同时确定时间段和负责的医生。
- CUSTOMER_ID:预订所属的客户 ID——是 [CUSTOMERS] 表中 [ID] 字段的外键
该表对关联列(DAY、SLOT_ID)的值设置了唯一性约束:
如果 [RV] 表中某行 (DAY, SLOT_ID) 列的值为 (DAY1, SLOT_ID1),则该值不能出现在其他任何地方。否则,这意味着同一医生在同一时间被预约了两次。从 Java 编程的角度来看,当这种情况发生时,数据库的 JDBC 驱动程序会抛出一个 SQLException。
ID 为 3 的行(参见上文 [1])表示,2006 年 8 月 23 日为第 20 个时段和第 4 号客户预订了一次预约。[SLOTS] 表告诉我们,第 20 个时段对应于下午 4:20 至 4:40,并属于第 1 号医生(Marie PELISSIER 女士)。 [CLIENTS] 表显示,客户编号 4 是 Brigitte BISTROU 女士。
3.4.5. 生成数据库
要创建这些表并填充数据,您可以使用脚本 [dbrdvmedecins.sql],该脚本可在示例存档 |此处| 中找到。
![]() |
使用 [WampServer](参见第 6.15 节),请按以下步骤操作:
![]() | ![]() |
- 在 [1] 中,点击 [WampServer] 图标并选择 [PhpMyAdmin] 选项 [2],
- 在 [3] 中,在弹出的窗口中,选择 [数据库] 链接,
![]() |
- 在 [4-6] 中,导入一个 SQL 文件,
![]() | ![]() | ![]() |
- 在 [7] 中,选择 SQL 脚本,并在 [8] 中执行它,
- 在 [9] 中,数据库表已创建。请点击其中一个链接,
![]() |
- 在[10]中,表格内容。
我们不会再回到这个数据库,但欢迎读者在整个测试过程中关注其变化,特别是在应用程序无法正常运行时。
3.5. Web 服务器 / JSON

这里我们重点关注服务器 [1]。我们不再对其进行深入开发,相关细节已在文档 [Spring MVC and Thymeleaf by Example] 中详细说明。感兴趣的读者可参考该文档。该服务器与示例 15 中的服务器开发方式相同,其源代码包含在示例中。这里我们将使用其二进制文件:
![]() |
- [rdvmedecins-server-all-1.0.jar] 是服务器二进制文件;
3.5.1. 实现
在命令窗口中,导航至包含服务器二进制文件的文件夹:
...\rdvmedecins>dir
Le volume dans le lecteur D s’appelle Données
Le numéro de série du volume est 7A34-AE5F
Répertoire de D:\data\istia-1516\projets\dvp-android-studio\rdvmedecins
09/06/2016 10:50 <DIR> .
09/06/2016 10:50 <DIR> ..
06/07/2014 16:36 7 631 dbrdvmedecins.sql
08/06/2016 16:31 <DIR> rdvmedecins-client
08/06/2016 16:22 <DIR> rdvmedecins-server
08/06/2016 16:23 29 618 709 rdvmedecins-server-all-1.0.jar
然后,要启动服务器,请输入以下命令(MySQL 数据库管理系统必须已运行):
...\rdvmedecins>java -jar rdvmedecins-server-all-1.0.jar
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v1.0)
10:55:48.617 [main] INFO rdvmedecins.boot.Boot - Starting Boot v1.0 on st-PC (D:\data\istia-1516\projets\dvp-android-studio\rdvmedecins\rdvmedecins-server-all-1.0.jar started by st in D:\data\istia-1516\projets\dvp-android-studio\rdvmedecins)
10:55:48.621 [main] INFO rdvmedecins.boot.Boot - No active profile set, falling back to default profiles: default
10:55:48.662 [main] INFO o.s.b.c.e.AnnotationConfigEmbeddedWebApplicationContext - Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@7085bdee: startup date [Thu Jun 09 10:55:48 CEST 2016]; root of context hierarchy
10:55:49.948 [main] INFO o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat initialized with port(s): 8080 (http)
juin 09, 2016 10:55:50 AM org.apache.catalina.core.StandardService startInternal
INFOS: Starting service Tomcat
juin 09, 2016 10:55:50 AM org.apache.catalina.core.StandardEngine startInternal
INFOS: Starting Servlet Engine: Apache Tomcat/8.0.33
juin 09, 2016 10:55:50 AM org.apache.catalina.core.ApplicationContext log
INFOS: Initializing Spring embedded WebApplicationContext
10:55:50.255 [localhost-startStop-1] INFO o.s.web.context.ContextLoader - Root
WebApplicationContext: initialization completed in 1596 ms
...
10:55:55.765 [localhost-startStop-1] INFO o.s.s.web.DefaultSecurityFilterChain
- Creating filter chain: ...]
10:55:55.785 [localhost-startStop-1] INFO o.s.b.c.e.ServletRegistrationBean - Mapping servlet: 'dispatcherServlet' to [/*]
10:55:55.791 [localhost-startStop-1] INFO o.s.b.c.e.FilterRegistrationBean - Mapping filter: 'springSecurityFilterChain' to: [/*]
...
10:55:56.249 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllCreneaux/{idMedecin}],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getAllCreneaux(long,javax.servlet.http.HttpServletResponse,java.lang.String)
throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.252 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getRvMedecinJour/{idMedecin}/{jour}],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getRvMedecinJour(long,java.lang.String,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.255 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getCreneauById/{id}],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getCreneauById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws
com.fasterxml.jackson.core.JsonProcessingException
10:55:56.257 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/ajouterRv],methods=[POST],consumes=[application/json;charset=UTF-8],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.ajouterRv(rdvmedecins.models.PostAjouterRv,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.259 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllClients],methods=[GET],produces=[application/json;charset=UTF-8]}" onto
public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getAllClients(javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.261 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getClientById/{id}],methods=[GET],produces=[application/json;charset=UTF-8]}"
onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getClientById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.264 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getMedecinById/{id}],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getMedecinById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.266 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getRvById/{id}],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getRvById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.268 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllMedecins],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getAllMedecins(javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.270 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/supprimerRv],methods=[POST],consumes=[application/json;charset=UTF-8],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.supprimerRv(rdvmedecins.models.PostSupprimerRv,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.273 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/authenticate],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.authenticate(javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
10:55:56.276 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAgendaMedecinJour/{idMedecin}/{jour}],methods=[GET],produces=[application/json;charset=UTF-8]}" onto public java.lang.String rdvmedecins.controllers.RdvMedecinsController.getAgendaMedecinJour(long,java.lang.String,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
...
10:55:56.681 [main] INFO o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat started on port(s): 8080 (http)
10:55:56.686 [main] INFO rdvmedecins.boot.Boot - Started Boot in 8.231 seconds
服务器显示了大量日志。我们仅列出了与理解上述过程相关的部分:
- 第 14–18 行:在机器的 8080 端口上启动了一个嵌入式 Tomcat 服务器。该服务器运行预约管理 Web 应用程序。该应用程序实际上是一个 Web 服务/JSON:通过 URL 进行查询,并通过发送 JSON 字符串进行响应;
- 第 24 行:该 Web 服务通过 [Spring Security] 框架进行安全加固。访问 Web 服务的 URL 需经过身份验证;
- 第29–44行:Web服务公开的URL;
我们将对此进行更详细的说明。
3.5.2. Web 服务的安全防护
该 Web 服务公开的 URL 均经过安全加固。服务器要求客户端的 HTTP 请求中包含以下头部字段:
所需的代码是字符串 'username:password' 的 Base64 编码 [http://fr.wikipedia.org/wiki/Base64]。在初始状态下,Web 服务仅接受用户名为 'admin'、密码为 'admin' 的用户。对于该特定用户,上述标头将变为以下内容:
为了发送此 HTTP 请求头,我们使用 HTTP 客户端 [Advanced Rest Client],这是一个 Chrome 浏览器插件(参见第 6.13 节)。我们将手动测试 Web 服务公开的各种 URL,以了解:
- URL 所期望的参数;
- 其响应的确切性质;
3.5.3. 医生列表
URL [/getAllMedecins] 用于获取医生列表:
![]() |
- 在 [1] 中,即被查询的 URL;
- 在 [2] 中,表示此请求使用的 HTTP 方法;
- 在 [3] 中,用户的 HTTP 安全标头(admin, admin);
- 在 [4] 中,发送 HTTP 请求;
服务器的响应如下:
![]() |
- 在 [5] 中,是服务器返回的格式化 JSON 响应;
![]() |
- 在 [6] 中,该响应的原始格式;
[5]中的形式更便于观察响应的结构。Web服务返回的所有响应都是以下[Response]类的实例:
package rdvmedecins.android.dao.service;
import java.util.List;
public class Response<T> {
// ----------------- properties
// operation status
private int status;
// any error messages
private List<String> messages;
// the body of the reply
private T body;
// manufacturers
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// getters and setters
...
}
- 第 9 行:响应状态。值为 0 表示未发生错误;否则,表示发生了错误;
- 第 11 行:若发生错误,则显示错误消息列表;
- 第 13 行:客户端实际期望的响应;
对 URL [/getAllMedecins] 的响应是一个 JSON 字符串,其内容为类型为 [Response<List<Medecin>>] 的对象。[Medecin] 类的定义如下:
package rdvmedecins.android.dao.entities;
public class Medecin extends Personne {
// default builder
public Medecin() {
}
// builder with parameters
public Medecin(String titre, String nom, String prenom) {
super(titre, nom, prenom);
}
public String toString() {
return String.format("Medecin[%s]", super.toString());
}
}
第 3 行:[Doctor] 类继承自以下 [Person] 类:
package rdvmedecins.android.dao.entities;
public class Personne extends AbstractEntity {
// attributes of a person
private String titre;
private String nom;
private String prenom;
// default builder
public Personne() {
}
// builder with parameters
public Personne(String titre, String nom, String prenom) {
this.titre = titre;
this.nom = nom;
this.prenom = prenom;
}
// toString
public String toString() {
return String.format("Personne[%s, %s, %s, %s, %s]", id, version, titre, nom, prenom);
}
// getters and setters
...
}
第 3 行:[Person] 类继承了以下 [AbstractEntity] 类:
package rdvmedecins.android.dao.entities;
import java.io.Serializable;
public class AbstractEntity implements Serializable {
private static final long serialVersionUID = 1L;
protected Long id;
protected Long version;
@Override
public int hashCode() {
int hash = 0;
hash += (id != null ? id.hashCode() : 0);
return hash;
}
// initialization
public AbstractEntity build(Long id, Long version) {
this.id = id;
this.version = version;
return this;
}
@Override
public boolean equals(Object entity) {
String class1 = this.getClass().getName();
String class2 = entity.getClass().getName();
if (!class2.equals(class1)) {
return false;
}
AbstractEntity other = (AbstractEntity) entity;
return this.id == other.id;
}
// getters and setters
...
}
最终,[Doctor] 对象的结构如下:
[Long id; Long version; String titre; String nom; String prenom;]
而 [Response<List<Doctor>>] 的结构如下:
接下来,我们将使用这些简要定义来描述服务器的响应。此外,目前我们将不再提供屏幕截图。请回顾我们刚刚讲解的内容。等到需要发送 POST 请求时,我们会再次使用屏幕截图。我们还将以以下格式展示一个执行示例:
3.5.4. 客户列表
|
示例:
3.5.5. 医生预约时段列表
|
- [idMedecin]: 您希望获取其预约时段的医生的ID;
- [startTime]:预约开始时间;
- [start_time]:就诊开始时间;
- [hfin]:就诊结束时间;
- [endmin]:问诊结束分钟数;
对于 10:20 至 10:40 之间的时段,我们有 [starts, starts, ends, ends] = [10, 20, 10, 40]。
示例:
3.5.6. 医生预约列表
|
- [idMedic] : 申请预约的医生的标识符;
- URL [day]:预约日期,格式为 'yyyy-mm-dd';
- 响应 [day]:与上述相同,但采用 Java 日期格式;
- [client]:预约的客户。其结构已在前文描述;
- [idClient]:客户的标识符;
- [slot]:预约时段。其结构已在前文描述;
- [slotId]:时段标识符;
示例:
3.5.7. 医生的日程安排
|
- [doctorId]:所需预约的医生的标识符;
- URL [day] : 预约日期,格式为 'yyyy-mm-dd' ;
- [calendar]:医生的日程表;
- [doctor]:所指的医生。其结构已在前面定义;
- Response [day]:日历中的日期,以 Java 日期格式表示;
- [doctorDaySlots]:类型为 [DoctorDaySlot] 的元素数组;
- [slot]:一个时段。其结构已在前面描述过;
- [appointment]:一个约会。其结构已在前面描述过;
示例:
|
我们分别展示了时段内已有预约和没有预约的情况。
3.5.8. 按医生ID查询
|
- [doctorId]:医生的ID;
示例 1:
示例 2:
3.5.9. 通过 ID 获取客户
|
- [idClient]:客户端 ID;
示例 1:
示例 2:
3.5.10. 使用您的 ID 预约时段
|
- [slotId]:插槽 ID;
示例 1:
请注意,响应中不包含该时段所属的医生,仅包含其ID。
示例 2:
3.5.11. 通过 ID 获取预约
|
- [idRv]:预约ID;
示例 1:
请注意,响应中不包含客户或预约时段,仅包含其标识符。
示例 2:
3.5.12. 添加预约
URL [/addAppointment] 允许您添加预约。添加所需的信息(日期、时段和客户)通过 HTTP POST 请求发送。我们将演示如何使用 [Advanced Rest Client] 工具发送此请求。

- 在 [1] 中,即要查询的 URL;
- 在 [2] 中,通过 POST 请求进行查询;
- 在 [3-4] 中,我们向服务器指定所提交的值采用 JSON 格式;
- 在 [4] 中,是 HTTP 身份验证头;
- 在 [5] 中,通过 POST 请求发送的信息。这是一个包含以下内容的 JSON 字符串:
- [day]:预约日期,格式为 'yyyy-mm-dd',
- [idClient]:为之预约的客户的ID,
- [idCreneau]:预约时段的标识符。由于时段属于特定医生,因此这也指代该医生;
- 在 [6] 中,发送请求;
所提交的 JSON 字符串为以下 [PostAjouterRv] 对象:
public class PostAjouterRv {
// pOST DATA
private String jour;
private long idClient;
private long idCreneau;
// manufacturers
public PostAjouterRv() {
}
public PostAjouterRv(String jour, long idCreneau, long idClient) {
this.jour = jour;
this.idClient = idClient;
this.idCreneau = idCreneau;
}
// getters and setters
...
}
服务器的响应类型为 [Response<Rv>] [int status; List<String> messages; Rv rv],其中 [rv] 表示新增的预约。
服务器对上述请求的响应如下:
![]() |
请注意,某些信息未包含在内 [idClient, idCreneau],但可以在 [client] 和 [creneau] 字段中找到。关键信息是已添加预约的 ID(209)。Web 服务本可以直接返回这一条信息。
3.5.13. 删除预约
此操作同样通过 POST 请求执行:
|
发送的值是类型为 [PostSupprimerRv] 的对象的 JSON 字符串,如下所示:
public class PostSupprimerRv {
// pOST DATA
private long idRv;
// manufacturers
public PostSupprimerRv() {
}
public PostSupprimerRv(long idRv) {
this.idRv = idRv;
}
// getters and setters
...
}
- 第 4 行:[idRv] 是要删除的预约的 ID。
示例 1:
已成功删除预约 #209,因为 [status=0]。
示例 2:
3.6. Android 客户端

既然服务器 [1] 已详细介绍并正常运行,接下来我们将探讨 Android 客户端 [2]。
3.6.1. Android Studio 项目架构
该项目采用 [client-android-skel] 项目的架构(参见第 1.17 节)。在上图所示的 Android 客户端架构中,包含三个独立的层:
- 负责与 Web 服务通信的 [DAO] 层;
- 负责与用户交互的[视图];
- 作为前两个模块之间桥梁的 [Activity]。视图(Views)并不了解 [DAO] 层,它们仅与 Activity 进行通信。
这种架构在 Android 客户端的 Android Studio 项目中得到了体现:
![]() |
- [activity] 包实现了该 Activity;
- [architecture] 包包含我们之前开发的架构组件;
- [dao] 包实现了 [DAO] 层;
- [fragments] 包实现 [视图];
3.6.2. 项目定制
![]() |
[architecture/custom] 文件夹包含架构中可自定义的元素。
[IMainActivity] 接口如下:
package client.android.architecture.custom;
import client.android.architecture.core.ISession;
import client.android.dao.service.IDao;
public interface IMainActivity extends IDao {
// session access
ISession getSession();
// change of view
void navigateToView(int position, ISession.Action action);
// wait management
void beginWaiting();
void cancelWaiting();
// constant application -------------------------------------
// debug mode
boolean IS_DEBUG_ENABLED = true;
// maximum time to wait for server response
int TIMEOUT = 1000;
// waiting time before executing customer request
int DELAY = 000;
// basic authentication
boolean IS_BASIC_AUTHENTIFICATION_NEEDED = true;
// fragment adjacency
int OFF_SCREEN_PAGE_LIMIT = 1;
// tab bar
boolean ARE_TABS_NEEDED = false;
// waiting image
boolean IS_WAITING_ICON_NEEDED = true;
// number of application fragments
int FRAGMENTS_COUNT = 4;
// view n°s
int VUE_CONFIG = 0;
int VUE_ACCUEIL = 1;
int VUE_AGENDA = 2;
int VUE_AJOUT_RV = 3;
}
- 第 25、28 行:[DAO] 层的定制;
- 第 31 行:该应用程序向服务器发送经过身份验证的请求;
- 第 40 行:需要加载图片;
- 第 43 行:该应用程序包含四个片段;
- 第 46–49 行:四个片段的编号;
- 第 37 行:没有选项卡;
片段状态的基类 [CoreState] 将如下所示:
package client.android.architecture.custom;
import client.android.architecture.core.MenuItemState;
import client.android.fragments.state.AccueilFragmentState;
import client.android.fragments.state.AgendaFragmentState;
import client.android.fragments.state.AjoutRvFragmentState;
import client.android.fragments.state.ConfigFragmentState;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY)
@JsonSubTypes({
@JsonSubTypes.Type(value = AccueilFragmentState.class),
@JsonSubTypes.Type(value = AgendaFragmentState.class),
@JsonSubTypes.Type(value = AjoutRvFragmentState.class),
@JsonSubTypes.Type(value = ConfigFragmentState.class)
}
)
public class CoreState {
// fragment visited or not
protected boolean hasBeenVisited = false;
// status of any fragment menu
protected MenuItemState[] menuOptionsState;
// getters and setters
...
}
- 第 15–18 行:这四个片段具有状态:
![]() |
最后,该会话包含片段之间共享的数据:
package client.android.architecture.custom;
import client.android.architecture.core.AbstractSession;
import client.android.dao.entities.AgendaMedecinJour;
import client.android.dao.entities.Client;
import client.android.dao.entities.Medecin;
import client.android.fragments.state.AccueilFragmentState;
import client.android.fragments.state.AgendaFragmentState;
import client.android.fragments.state.AjoutRvFragmentState;
import client.android.fragments.state.ConfigFragmentState;
import java.util.List;
public class Session extends AbstractSession {
// elements that cannot be serialized as jSON must be annotated with @JsonIgnore
// list of doctors
private List<Medecin> médecins;
// customer list
private List<Client> clients;
// a doctor's diary for a given day
private AgendaMedecinJour agenda;
// position of clicked item in diary
private int position;
// rv day in English notation "yyyy-MM-dd"
private String dayRv;
// rv day in French notation "dd-MM-yyyy"
private String jourRv;
// getters and setters
...
}
- 第 17–28 行:会话存储了六项信息。我们将在必要时解释它们的作用。
3.6.3. [DAO] 层
![]() |
![]() | ![]() |
- 在[1]中,服务器响应中封装的实体。这些已在第3.5节中介绍;
- 在[2]中,指处理与服务器通信的客户端组件;
我们不再赘述[1]中的组件,因其已在前文介绍过。如有需要,读者可参阅第3.5节。我们将重点探讨[service]包的实现,这也将引导我们讨论客户端与服务器之间安全通信的实现。
3.6.3.1. 客户端/服务器通信的实现
![]() |
[WebClient] 类是一个 AA 组件,用于描述:
- Web 服务公开的 URL;
- 其参数;
- 其响应;
package rdvmedecins.android.dao.service;
import rdvmedecins.android.dao.entities.*;
import org.androidannotations.rest.spring.annotations.*;
import org.androidannotations.rest.spring.api.RestClientRootUrl;
import org.androidannotations.rest.spring.api.RestClientSupport;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.client.RestTemplate;
import java.util.List;
@Rest(converters = {MappingJackson2HttpMessageConverter.class})
public interface WebClient extends RestClientRootUrl, RestClientSupport {
// RestTemplate
public void setRestTemplate(RestTemplate restTemplate);
// list of doctors
@Get("/getAllMedecins")
public Response<List<Medecin>> getAllMedecins();
// customer list
@Get("/getAllClients")
public Response<List<Client>> getAllClients();
// list of physician slots
@Get("/getAllCreneaux/{idMedecin}")
public Response<List<Creneau>> getAllCreneaux(@Path long idMedecin);
// list of doctor's appointments
@Get("/getRvMedecinJour/{idMedecin}/{jour}")
public Response<List<Rv>> getRvMedecinJour(@Path long idMedecin, @Path String jour);
// Customer
@Get("/getClientById/{id}")
public Response<Client> getClientById(@Path long id);
// Doctor
@Get("/getMedecinById/{id}")
public Response<Medecin> getMedecinById(@Path long id);
// Rv
@Get("/getRvById/{id}")
public Response<Rv> getRvById(@Path long id);
// Niche
@Get("/getCreneauById/{id}")
public Response<Creneau> getCreneauById(@Path long id);
// add a RV
@Post("/ajouterRv")
public Response<Rv> ajouterRv(@Body PostAjouterRv post);
// delete an appointment
@Post("/supprimerRv")
public Response<Rv> supprimerRv(@Body PostSupprimerRv post);
// get a doctor's schedule
@Get(value = "/getAgendaMedecinJour/{idMedecin}/{jour}")
public Response<AgendaMedecinJour> getAgendaMedecinJour(@Path long idMedecin, @Path String jour);
}
- 第 19–60 行:第 3.5 节中讨论的所有 URL 均已包含;
- 第16行:来自[Spring Android]的[RestTemplate]组件,客户端与服务端之间的通信基于该组件;
3.6.3.2. [IDao] 接口
![]() |
[DAO] 层的 [IDao] 接口如下:
package rdvmedecins.android.dao.service;
import rdvmedecins.android.dao.entities.*;
import rx.Observable;
import java.util.List;
public interface IDao {
// Web service url
public void setUrlServiceWebJson(String url);
// user
public void setUser(String user, String mdp);
// customer timeout
public void setTimeout(int timeout);
// customer list
public Observable<List<Client>> getAllClients();
// list of doctors
public Observable<List<Medecin>> getAllMedecins();
// list of physician slots
public Observable<List<Creneau>> getAllCreneaux(long idMedecin);
// list of doctor's appointments on a given day
public Observable<List<Rv>> getRvMedecinJour(long idMedecin, String jour);
// find a customer identified by its id
public Observable<Client> getClientById(long id);
// find a doctor identified by his id
public Observable<Medecin> getMedecinById(long id);
// find an Rv identified by its id
public Observable<Rv> getRvById(long id);
// find a time slot identified by its id
public Observable<Creneau> getCreneauById(long id);
// add a RV to the list
public Observable<Rv> ajouterRv(String jour, long idCreneau, long idClient);
// delete a RV
public Observable<Rv> supprimerRv(long idRv);
// job
public Observable<AgendaMedecinJour> getAgendaMedecinJour(long idMedecin, String jour);
// debug mode
void setDebugMode(boolean isDebugEnabled);
}
- 第 10 行:用于设置 Web 服务 / JSON 的 URL;
- 第 13 行:设置客户端/服务器通信的用户信息。[user] 是用户 ID,[password] 是密码;
- 第 16 行:设置服务器响应的最大超时时间;
- 第 18–49 行:Web 服务暴露的每个 URL 对应一个方法。它们使用与 AA [WebClient] 组件相同的方法签名;
- 第 52 行:用于控制 [DAO] 层的调试模式;
3.6.3.3. [Dao] 类
![]() |
前文所述 [IDao] 接口的 [DAO] 实现如下:
package client.android.dao.service;
import android.util.Log;
import client.android.dao.entities.*;
import org.androidannotations.annotations.AfterInject;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EBean;
import org.androidannotations.rest.spring.annotations.RestService;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.client.RestTemplate;
import rx.Observable;
import java.util.ArrayList;
import java.util.List;
@EBean(scope = EBean.Scope.Singleton)
public class Dao extends AbstractDao implements IDao {
// web service customer
@RestService
protected WebClient webClient;
// safety
@Bean
protected MyAuthInterceptor authInterceptor;
// on RestTemplate
private RestTemplate restTemplate;
// factory du RestTemplate
private SimpleClientHttpRequestFactory factory;
@AfterInject
public void afterInject() {
...
}
@Override
public void setUrlServiceWebJson(String url) {
...
}
@Override
public void setUser(String user, String mdp) {
...
}
@Override
public void setTimeout(int timeout) {
...
}
@Override
public void setBasicAuthentification(boolean isBasicAuthentificationNeeded) {
if (isDebugEnabled) {
Log.d(className, String.format("setBasicAuthentification thread=%s, isBasicAuthentificationNeeded=%s", Thread.currentThread().getName(), isBasicAuthentificationNeeded));
}
// authentication interceptor?
if (isBasicAuthentificationNeeded) {
// add the authentication interceptor
List<ClientHttpRequestInterceptor> interceptors = new ArrayList<ClientHttpRequestInterceptor>();
interceptors.add(authInterceptor);
restTemplate.setInterceptors(interceptors);
}
}
// méthodes privées -------------------------------------------------
private void log(String message) {
if (isDebugEnabled) {
Log.d(className, message);
}
}
// implementation of the IDao interface --------------------------------------------------------------------
@Override
public Observable<Response<List<Client>>> getAllClients() {
// log
log("getAllClients");
// result
return getResponse(new IRequest<Response<List<Client>>>() {
@Override
public Response<List<Client>> getResponse() {
return webClient.getAllClients();
}
});
}
@Override
public Observable<Response<List<Medecin>>> getAllMedecins() {
// log
log("getAllMedecins");
// result
return getResponse(new IRequest<Response<List<Medecin>>>() {
@Override
public Response<List<Medecin>> getResponse() {
return webClient.getAllMedecins();
}
});
}
@Override
public Observable<Response<List<Creneau>>> getAllCreneaux(final long idMedecin) {
// log
log("getAllCreneaux");
// result
return getResponse(new IRequest<Response<List<Creneau>>>() {
@Override
public Response<List<Creneau>> getResponse() {
return webClient.getAllCreneaux(idMedecin);
}
});
}
@Override
public Observable<Response<List<Rv>>> getRvMedecinJour(final long idMedecin, final String jour) {
// log
log("getRvMedecinJour");
// result
return getResponse(new IRequest<Response<List<Rv>>>() {
@Override
public Response<List<Rv>> getResponse() {
return webClient.getRvMedecinJour(idMedecin, jour);
}
});
}
@Override
public Observable<Response<Client>> getClientById(final long id) {
// log
log("getClientById");
// result
return getResponse(new IRequest<Response<Client>>() {
@Override
public Response<Client> getResponse() {
return webClient.getClientById(id);
}
});
}
@Override
public Observable<Response<Medecin>> getMedecinById(final long id) {
// log
log("getMedecinById");
// result
return getResponse(new IRequest<Response<Medecin>>() {
@Override
public Response<Medecin> getResponse() {
return webClient.getMedecinById(id);
}
});
}
@Override
public Observable<Response<Rv>> getRvById(final long id) {
// log
log("getRvById");
// result
return getResponse(new IRequest<Response<Rv>>() {
@Override
public Response<Rv> getResponse() {
return webClient.getRvById(id);
}
});
}
@Override
public Observable<Response<Creneau>> getCreneauById(final long id) {
// log
log("getCreneauById");
// result
return getResponse(new IRequest<Response<Creneau>>() {
@Override
public Response<Creneau> getResponse() {
return webClient.getCreneauById(id);
}
});
}
@Override
public Observable<Response<Rv>> ajouterRv(final String jour, final long idCreneau, final long idClient) {
// log
log("ajouterRv");
// result
return getResponse(new IRequest<Response<Rv>>() {
@Override
public Response<Rv> getResponse() {
return webClient.ajouterRv(new PostAjouterRv(jour, idCreneau, idClient));
}
});
}
@Override
public Observable<Response<Rv>> supprimerRv(final long idRv) {
// log
log("supprimerRv");
// result
return getResponse(new IRequest<Response<Rv>>() {
@Override
public Response<Rv> getResponse() {
return webClient.supprimerRv(new PostSupprimerRv(idRv));
}
});
}
@Override
public Observable<Response<AgendaMedecinJour>> getAgendaMedecinJour(final long idMedecin, final String jour) {
// log
log("getAgendaMedecinJour");
// result
return getResponse(new IRequest<Response<AgendaMedecinJour>>() {
@Override
public Response<AgendaMedecinJour> getResponse() {
return webClient.getAgendaMedecinJour(idMedecin, jour);
}
});
}
}
- 第 18–72 行:这些是 [client-android-skel] 项目中 [Dao] 类的默认代码;
- 第 74–216 行:[IDao] 接口的实现。查询 Web 服务公开的 URL 的方法会将此查询委托给 AA [WebClient] 组件(第 22–23 行);
- 第 58–63 行:如果客户端/服务器通信使用基本认证进行身份验证,则会在 [RestTemplate] 组件中添加一个拦截器。这将导致 [RestTemplate] 组件发送的任何 HTTP 请求都被 [MyAuthInterceptor] 类拦截(第 25–26 行);
[MyAuthInterceptor] 类如下所示:
package rdvmedecins.android.dao.security;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EBean;
import org.springframework.http.HttpAuthentication;
import org.springframework.http.HttpBasicAuthentication;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import java.io.IOException;
@EBean(scope = EBean.Scope.Singleton)
public class MyAuthInterceptor implements ClientHttpRequestInterceptor {
// user
private String user;
private String mdp;
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
HttpHeaders headers = request.getHeaders();
HttpAuthentication auth = new HttpBasicAuthentication(user, mdp);
headers.setAuthorization(auth);
return execution.execute(request, body);
}
public void setUser(String user, String mdp) {
this.user = user;
this.mdp = mdp;
}
}
- 第 15 行:[MyAuthInterceptor] 类是一个类型为 [singleton] 的 AA 组件;
- 第 16 行:[MyAuthInterceptor] 类继承了 Spring 的 [ClientHttpRequestInterceptor] 接口。该接口包含一个方法,即第 22 行中的 [intercept] 方法。我们继承此接口以拦截来自客户端的任何 HTTP 请求。[intercept] 方法接受三个参数;
- [HttpRequest request]:被拦截的 HTTP 请求,
- [byte[] body]:其正文(如有,例如表单提交的值),
- [ClientHttpRequestExecution execution]:执行该请求的 Spring 组件;
我们拦截来自 Android 客户端的所有 HTTP 请求,以添加第 3.5 节中介绍的 HTTP 身份验证头。
- 第 23 行:我们获取被拦截请求的 HTTP 头部;
- 第 24 行:我们创建 HTTP 身份验证头。所使用的身份验证方法(字符串 'user:mdp' 的 Base64 编码)由 Spring 的 [HttpBasicAuthentication] 类提供;
- 第 25 行:将刚刚创建的认证头添加到拦截请求的当前头部中;
- 第 26 行:继续执行拦截的请求。综上所述,拦截的请求已补充了认证头;
[IDao] 接口中各方法的实现均遵循相同的模式。让我们以 [getAgendaMedecinJour] 方法为例:
@Override
public Observable<Response<AgendaMedecinJour>> getAgendaMedecinJour(final long idMedecin, final String jour) {
// log
log("getAgendaMedecinJour");
// result
return getResponse(new IRequest<Response<AgendaMedecinJour>>() {
@Override
public Response<AgendaMedecinJour> getResponse() {
return webClient.getAgendaMedecinJour(idMedecin, jour);
}
});
}
- 第 2 行:该方法需要两个参数:
- [idMedecin]:要查询其日程的医生的ID;
- [day]:要查询日程安排的日期;
- 第 6 行:我们调用父类 [AbstractDao] 的 [getResponse] 方法。该方法期望一个类型为 [IRequest<T>] 的参数,其中 T 是第 2 行 [getAgendaMedecinJour] 方法返回的类型,在本例中为 [Response<AgendaMedecinJour>]。 [IRequest] 接口仅有一个方法:[getResponse](第 8 行);
- 第 8–10 行:实现 [IRequest.getResponse] 方法。该方法必须返回第 2 行 [getAgendaMedecinJour] 方法所期望的结果,类型为 [Response<AgendaMedecinJour>];
- 第 9 行:响应由 [webClient.getAgendaMedecinJour] 方法返回:
// get a doctor's schedule
@Get(value = "/getAgendaMedecinJour/{idMedecin}/{jour}")
Response<AgendaMedecinJour> getAgendaMedecinJour(@Path long idMedecin, @Path String jour);
第 9 行中使用的参数是传递给第 2 行 [getAgendaMedecinJour] 方法的参数。因此,这些参数必须带有 final 属性;
3.6.4. [MainActivity]
服务器 ![]() |
![]() |
[MainActivity] 类的代码如下:
package client.android.activity;
import android.util.Log;
import client.android.architecture.core.AbstractActivity;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.custom.IMainActivity;
import client.android.dao.entities.*;
import client.android.dao.service.Dao;
import client.android.dao.service.IDao;
import client.android.dao.service.Response;
import client.android.fragments.behavior.AccueilFragment_;
import client.android.fragments.behavior.AgendaFragment_;
import client.android.fragments.behavior.AjoutRvFragment_;
import client.android.fragments.behavior.ConfigFragment_;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EActivity;
import rx.Observable;
import java.util.List;
@EActivity
public class MainActivity extends AbstractActivity {
// layer [DAO]
@Bean(Dao.class)
protected IDao dao;
// parent class ---------------------------------------
@Override
protected void onCreateActivity() {
// log
if (IS_DEBUG_ENABLED) {
Log.d(className, "onCreateActivity");
}
}
@Override
protected IDao getDao() {
return dao;
}
@Override
protected AbstractFragment[] getFragments() {
AbstractFragment[] fragments= new AbstractFragment[]{new ConfigFragment_(), new AccueilFragment_(), new AgendaFragment_(), new AjoutRvFragment_()};
return fragments;
}
@Override
protected CharSequence getFragmentTitle(int position) {
return null;
}
@Override
protected void navigateOnTabSelected(int position) {
}
@Override
protected int getFirstView() {
return IMainActivity.VUE_CONFIG;
}
// interface IDao -----------------------------------------------------
...
@Override
public Observable<Response<List<Client>>> getAllClients() {
return dao.getAllClients();
}
@Override
public Observable<Response<List<Medecin>>> getAllMedecins() {
return dao.getAllMedecins();
}
@Override
public Observable<Response<List<Creneau>>> getAllCreneaux(long idMedecin) {
return dao.getAllCreneaux(idMedecin);
}
@Override
public Observable<Response<List<Rv>>> getRvMedecinJour(long idMedecin, String jour) {
return dao.getRvMedecinJour(idMedecin, jour);
}
@Override
public Observable<Response<Client>> getClientById(long id) {
return dao.getClientById(id);
}
@Override
public Observable<Response<Medecin>> getMedecinById(long id) {
return dao.getMedecinById(id);
}
@Override
public Observable<Response<Rv>> getRvById(long id) {
return dao.getRvById(id);
}
@Override
public Observable<Response<Creneau>> getCreneauById(long id) {
return dao.getCreneauById(id);
}
@Override
public Observable<Response<Rv>> ajouterRv(String jour, long idCreneau, long idClient) {
return dao.ajouterRv(jour, idCreneau, idClient);
}
@Override
public Observable<Response<Rv>> supprimerRv(long idRv) {
return dao.supprimerRv(idRv);
}
@Override
public Observable<Response<AgendaMedecinJour>> getAgendaMedecinJour(long idMedecin, String jour) {
return dao.getAgendaMedecinJour(idMedecin, jour);
}
}
- 第 21–66 行:这些代码行由 [client-android-skel] 模板默认提供;
- 第 66–119 行:[IDao] 接口的实现。所有方法都在第 26 行将工作委托给 [DAO] 层;
- 第 42–46 行:[getFragments] 方法返回应用程序中四个片段的数组;
- 第 58–61 行:配置视图是应用程序启动时首先显示的视图;
3.6.5. 会话
![]() |
[Session] 类用于存储需要在片段之间传递的信息。其定义如下:
package rdvmedecins.android.architecture;
import rdvmedecins.android.dao.entities.AgendaMedecinJour;
import rdvmedecins.android.dao.entities.Client;
import rdvmedecins.android.dao.entities.Medecin;
import org.androidannotations.annotations.EBean;
import java.util.List;
@EBean(scope = EBean.Scope.Singleton)
public class Session {
// list of doctors
private List<Medecin> médecins;
// customer list
private List<Client> clients;
// agenda
private AgendaMedecinJour agenda;
// position of clicked item in diary
private int position;
// rv day in English notation "yyyy-MM-dd"
private String dayRv;
// rv day in French notation "dd-MM-yyyy"
private String jourRv;
// getters and setters
...
}
- 第 10 行:[Session] 类是一个作为单例实例化的 AA 组件;
- 第 12–15 行:在本案例研究中,我们将假设医生和客户列表不会发生变化。我们将在应用程序启动时检索这些列表,并将它们存储在会话中,以便片段能够使用它们;
- 第 20–23 行:预约的期望日期。它以两种格式处理:在 Android 客户端中采用法语格式(第 23 行),与服务器通信时采用英语格式(第 21 行);
- 第 19 行:日历上被点击元素(添加/删除链接)的位置;
3.6.6. 配置视图管理
3.6.6.1. 视图
配置视图是应用程序启动时显示的视图:

视觉界面的元素如下:
3.6.6.2. 该片段
配置视图由以下片段 [ConfigFragment] 管理:
![]() |
package client.android.fragments.behavior;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import client.android.R;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.core.ISession;
import client.android.architecture.core.MenuItemState;
import client.android.architecture.custom.CoreState;
import client.android.architecture.custom.IMainActivity;
import client.android.dao.entities.Client;
import client.android.dao.entities.Medecin;
import client.android.dao.service.Response;
import client.android.fragments.state.ConfigFragmentState;
import org.androidannotations.annotations.*;
import rx.functions.Action1;
import java.net.URI;
import java.util.List;
@EFragment(R.layout.config)
@OptionsMenu(R.menu.menu_config)
public class ConfigFragment extends AbstractFragment {
// visual interface elements
@ViewById(R.id.edt_urlServiceRest)
protected EditText edtUrlServiceRest;
@ViewById(R.id.txt_errorUrlServiceRest)
protected TextView txtErrorUrlServiceRest;
@ViewById(R.id.txt_errorUtilisateur)
protected TextView txtErrorUtilisateur;
@ViewById(R.id.edt_utilisateur)
protected EditText edtUtilisateur;
@ViewById(R.id.edt_mdp)
protected EditText edtMdp;
// seizures
private String urlServiceRest;
private String utilisateur;
private String mdp;
// validation page
@OptionsItem(R.id.actionValider)
protected void doValider() {
...
}
..
// implementation methods parent class -------------------------------------------
...
}
- 第 25 行:该片段与以下 [menu_config] 菜单相关联:
![]() |
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".activity.MainActivity1">
<item
android:id="@+id/menuActions"
app:showAsAction="ifRoom"
android:title="@string/menuActions">
<menu>
<item
android:id="@+id/actionValider"
android:title="@string/actionValider"/>
<item
android:id="@+id/actionAnnuler"
android:title="@string/actionAnnuler"/>
</menu>
</item>
</menu>
- 第28–38行:视觉界面的元素;
- 第41–43行:三个表单字段;
点击 [Validate] 菜单选项由 [doValidate] 方法处理:
// validation page
@OptionsItem(R.id.actionValider)
protected void doValider() {
// hide any previous error messages
txtErrorUrlServiceRest.setVisibility(View.INVISIBLE);
txtErrorUtilisateur.setVisibility(View.INVISIBLE);
// test the validity of entries
if (!isPageValid()) {
return;
}
// enter the URL of the web service
mainActivity.setUrlServiceWebJson(urlServiceRest);
// user information
mainActivity.setUser(utilisateur, mdp);
// start of wait - 2 asynchronous tasks will be launched
beginWaiting(2);
// doctors
executeInBackground(mainActivity.getAllMedecins(), new Action1<Response<List<Medecin>>>() {
@Override
public void call(Response<List<Medecin>> responseMedecins) {
// we consume the answer
consumeMedecins(responseMedecins);
}
});
// customers
executeInBackground(mainActivity.getAllClients(), new Action1<Response<List<Client>>>() {
@Override
public void call(Response<List<Client>> responseClients) {
// we consume the answer
consumeClients(responseClients);
}
});
}
private void consumeMedecins(Response<List<Medecin>> responseMedecins) {
// log
if (isDebugEnabled) {
Log.d(className, "consume médecins");
}
// mistake?
if (responseMedecins.getStatus() != 0) {
// message
showAlert(responseMedecins.getMessages());
// cancellation
doAnnuler();
// back to UI
return;
}
// doctors are saved in the session
session.setMédecins(responseMedecins.getBody());
}
private void consumeClients(Response<List<Client>> responseClients) {
// log
if (isDebugEnabled) {
Log.d(className, "consume clients");
}
// mistake?
if (responseClients.getStatus() != 0) {
// message
showAlert(responseClients.getMessages());
// cancellation
doAnnuler();
// back to UI
return;
}
// customers are stored in the session
session.setClients(responseClients.getBody());
}
- 第 8–10 行:检查三个表单输入项的有效性。如果表单无效,流程在此终止;
- 第 11–14 行:将 [DAO] 层所需的输入传递给 Activity;
- 第16行:通知父类将启动两个异步任务,并准备等待;
- 第17–24行:请求医生列表;
- 第 18 行:[executeInBackground] 方法期望两个参数:
- 第 18 行:由 [mainActivity.getAllMedecins()] 方法提供待执行并需被监视的流程;
- 第 18–24 行:第二个参数是 [Action1<T>] 类型的实例,其中 T 是被观察进程返回的类型,此处为 [Response<List<Medecin>>]
- 第 22 行:收到响应后,将其传递给第 36 行的 [consumeMedecins] 方法;
- 第 25–33 行:在启动第一个异步任务后,我们启动第二个任务以请求客户列表。因此将有两个任务并行运行;
- 第 36–52 行:我们已收到来自医生任务的响应。对其进行处理;
- 第 42–49 行:首先,检查服务器是否在响应的 [status] 字段中报告了错误;
- 第 44 行:如果存在错误,则显示服务器在响应的 [messages] 字段中放置的消息;
- 第 46 行:我们取消所有任务;
- 第 48 行:返回用户界面;
- 第 51 行:如果没有错误,将医生列表加载到会话中;
输入的有效性(第 8 行)通过以下方法进行验证:
private boolean isPageValid() {
// check the validity of the data entered
boolean erreur;
URI service;
// validity of the URL of the REST service
urlServiceRest = String.format("http://%s", edtUrlServiceRest.getText().toString().trim());
try {
service = new URI(urlServiceRest);
erreur = service.getHost() == null || service.getPort() == -1;
} catch (Exception ex) {
// we note the error
erreur = true;
}
if (erreur) {
// error display
txtErrorUrlServiceRest.setVisibility(View.VISIBLE);
}
// user
utilisateur = edtUtilisateur.getText().toString().trim();
if (utilisateur.length() == 0) {
// error is displayed
txtErrorUtilisateur.setVisibility(View.VISIBLE);
// we note the error
erreur = true;
}
// password
mdp = edtMdp.getText().toString().trim();
// return
return !erreur;
}
[beginWaiting] 方法(第 16 行)如下:
// beginning of waiting
protected void beginWaiting(int numberOfRunningTasks) {
// prepare to launch tasks
beginRunningTasks(numberOfRunningTasks);
// status of buttons and menus
setAllMenuOptionsStates(false);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.menuActions, true),new MenuItemState(R.id.actionAnnuler, true)});
}
- 第 4 行:我们告知父任务,我们将启动 [numberOfRunningTasks] 个任务;
- 第 6 行:隐藏所有菜单选项;
- 第 7 行:随后将 [操作/取消] 选项显示出来;
点击 [取消] 菜单选项由 [doCancel] 方法处理:
@OptionsItem(R.id.actionAnnuler)
protected void doAnnuler() {
if (isDebugEnabled) {
Log.d(className, "Annulation demandée");
}
// asynchronous tasks are cancelled
cancelRunningTasks();
}
- 第 8 行:我们请求父类取消异步任务;
3.6.6.3. 片段生命周期管理
片段具有以下 [ConfigFragmentState] 状态:
package client.android.fragments.state;
import client.android.architecture.custom.CoreState;
public class ConfigFragmentState extends CoreState {
// visibility of two error messages
private boolean txtErrorUrlServiceRestVisible;
private boolean txtErrorUtilisateurVisible;
// getters and setters
...
}
- 当父类请求时,片段将保存其两个错误消息的可见性;
片段的生命周期实现如下:
// implementation methods parent class -------------------------------------------
@Override
public CoreState saveFragment() {
// save fragment status
ConfigFragmentState state = new ConfigFragmentState();
state.setTxtErrorUrlServiceRestVisible(txtErrorUrlServiceRest.getVisibility() == View.VISIBLE);
state.setTxtErrorUtilisateurVisible(txtErrorUtilisateur.getVisibility() == View.VISIBLE);
return state;
}
@Override
protected int getNumView() {
return IMainActivity.VUE_CONFIG;
}
@Override
protected void initFragment(CoreState previousState) {
}
@Override
protected void initView(CoreState previousState) {
if (previousState == null) {
// 1st visit
// hide error messages
txtErrorUtilisateur.setVisibility(View.INVISIBLE);
txtErrorUrlServiceRest.setVisibility(View.INVISIBLE);
// menu
initMenu();
}
}
@Override
protected void updateOnSubmit(CoreState previousState) {
}
@Override
protected void updateOnRestore(CoreState previousState) {
// restore error msg visibility
ConfigFragmentState state = (ConfigFragmentState) previousState;
// not the 1st visit - error messages are returned
txtErrorUtilisateur.setVisibility(state.isTxtErrorUtilisateurVisible() ? View.VISIBLE : View.INVISIBLE);
txtErrorUrlServiceRest.setVisibility(state.isTxtErrorUrlServiceRestVisible() ? View.VISIBLE : View.INVISIBLE);
}
@Override
protected void notifyEndOfUpdates() {
}
@Override
protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
// menu
initMenu();
// next view?
if (!runningTasksHaveBeenCanceled) {
mainActivity.navigateToView(IMainActivity.VUE_ACCUEIL, ISession.Action.SUBMIT);
}
}
// méthodes privées ------------------------------------------------
private void initMenu(){
// menu status
setAllMenuOptionsStates(true);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.actionAnnuler, false)});
}
- 第 2–9 行:当父类发出请求时,片段会保存其两个错误消息的状态;
- 第 11–14 行:片段 ID 为 [IMainActivity.VUE_CONFIG];
- 第 16–19 行:当片段首次生成(previousState == null)或后续重新生成(previousState != null)时执行。此处无需执行任何操作;
- 第 21–31 行:当与片段关联的视图首次构建(previousState == null)或后续重建(previousState != null)时执行;
- 第 24–29 行:首次访问时,隐藏错误消息并显示不包含 [Cancel] 操作的菜单(第 62–66 行);
- 第 33–35 行:当通过 [SUBMIT] 操作进入片段时执行。此处不会发生这种情况;
- 第 37–44 行:当通过 [NAVIGATION] 或 [RESTORE] 操作到达该片段时执行。错误消息的状态将从上一次状态中恢复;
- 第 47–49 行:当所有先前更新完成后执行。此时无需进行其他操作;
- 第 51–59 行:当所有异步任务完成时执行;
- 第 53–54 行:将菜单重置为默认状态;
- 第 56–58 行:若任务成功完成,则转至下一视图;否则,保持当前视图;
3.6.7. 主视图管理
3.6.7.1. 视图
主视图如下所示:

视觉界面的元素如下:
3.6.7.2. 该片段
主屏幕由以下片段 [HomeFragment] 管理:
![]() |
package client.android.fragments.behavior;
import android.util.Log;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.DatePicker;
import android.widget.Spinner;
import client.android.R;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.core.ISession;
import client.android.architecture.core.MenuItemState;
import client.android.architecture.custom.CoreState;
import client.android.architecture.custom.IMainActivity;
import client.android.dao.entities.AgendaMedecinJour;
import client.android.dao.entities.Medecin;
import client.android.dao.service.Response;
import client.android.fragments.state.AccueilFragmentState;
import org.androidannotations.annotations.*;
import rx.functions.Action1;
import java.util.Calendar;
import java.util.List;
import java.util.Locale;
@EFragment(R.layout.accueil)
@OptionsMenu(R.menu.menu_accueil)
public class AccueilFragment extends AbstractFragment {
// visual interface elements
@ViewById(R.id.spinnerMedecins)
protected Spinner spinnerMedecins;
@ViewById(R.id.edt_JourRv)
protected DatePicker edtJourRv;
// local data
private List<Medecin> medecins;
private Calendar calendrier;
private String[] spinnerMedecinsDataSource;
// validation page
@OptionsItem(R.id.actionValider)
protected void doValider() {
...
}
...
// implementation methods parent class -------------------------------------
...
}
- 第 26 行:该片段与以下 [menu_accueil] 菜单相关联:
![]() |
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".activity.MainActivity1">
<item
android:id="@+id/menuActions"
app:showAsAction="ifRoom"
android:title="@string/menuActions">
<menu>
<item
android:id="@+id/actionValider"
android:title="@string/actionValider"/>
<item
android:id="@+id/actionAnnuler"
android:title="@string/actionAnnuler"/>
</menu>
</item>
<item
android:id="@+id/menuNavigation"
app:showAsAction="ifRoom"
android:title="@string/menuNavigation">
<menu>
<item
android:id="@+id/navigationToConfig"
android:title="@string/navigationToConfig"/>
</menu>
</item>
</menu>
- 第31–34行:视觉界面元素;
- 第 37 行:医生列表;
- 第 38 行:日历;
- 第 39 行:医生下拉列表的数据源;
点击 [Validate] 链接由以下 [doValidate] 方法处理:
// validation page
@OptionsItem(R.id.actionValider)
protected void doValider() {
// note the id of the selected doctor
Long idMedecin = medecins.get(spinnerMedecins.getSelectedItemPosition()).getId();
// the day is saved in the session
String jourRv = String.format(new Locale("Fr-fr"), "%02d-%02d-%04d", edtJourRv.getDayOfMonth(), edtJourRv.getMonth() + 1, edtJourRv.getYear());
session.setJourRv(jourRv);
// switch to date format yyyy-MM-dd
String dayRv = String.format(new Locale("Fr-fr"), "%04d-%02d-%02d", edtJourRv.getYear(), edtJourRv.getMonth() + 1, edtJourRv.getDayOfMonth());
session.setDayRv(dayRv);
// start wait - 1 asynchronous task will be launched
beginWaiting(1);
// we ask for the doctor's diary
executeInBackground(mainActivity.getAgendaMedecinJour(idMedecin, dayRv), new Action1<Response<AgendaMedecinJour>>() {
@Override
public void call(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
// we consume the answer
consumeAgenda(responseAgendaMedecinJour);
}
});
}
private void consumeAgenda(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
// mistake?
if (responseAgendaMedecinJour.getStatus() != 0) {
// message
showAlert(responseAgendaMedecinJour.getMessages());
// cancellation
doAnnuler();
// back to UI
return;
}
// put the agenda in the session
session.setAgenda(responseAgendaMedecinJour.getBody());
}
- 第 5 行:获取所选医生的 ID;
- 第 7-8 行:我们将选定的日期以法语格式存储在会话中;
- 第10-11行:我们将选定的日期以英文格式存储在会话中;
- 第 13 行:通知父类我们将要启动一个异步任务,并准备进行等待;
- 第 15–22 行:获取医生的日程安排;
- 第 15 行:[executeInBackground] 方法需要两个参数:
- 第 15 行:待执行并需观察的进程由 [mainActivity.getAgendaMedecinJour(idMedecin, dayRv)] 方法提供;
- 第 15–22 行:第二个参数是 [Action1<T>] 类型的实例,其中 T 是被监视进程返回的类型,此处为 [Response<AgendaMedecinJour>]
- 第 20 行:收到响应后,将其传递给第 25 行的 [consumeAgenda] 方法;
- 第 15 行:[executeInBackground] 方法需要两个参数:
- 第 25–37 行:我们已收到医生的日程安排。对其进行处理;
- 第 27–34 行:首先,我们检查服务器是否在响应的 [status] 字段中报告了错误;
- 第 29 行:如果存在错误,则显示服务器放置在响应 [messages] 字段中的消息;
- 第 31 行:取消所有任务;
- 第 33 行:返回用户界面;
- 第 36 行:若无错误,将日历置于焦点;
[beginWaiting] 方法(第 13 行)如下:
// beginning of waiting
protected void beginWaiting(int numberOfRunningTasks) {
// prepare to launch tasks
beginRunningTasks(numberOfRunningTasks);
// status of buttons and menus
setAllMenuOptionsStates(false);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.menuActions, true),new MenuItemState(R.id.actionAnnuler, true)});
}
- 第 4 行:我们告知父任务,我们将启动 [numberOfRunningTasks] 个任务;
- 第 6 行:隐藏所有菜单选项;
- 第 7 行:随后将 [操作/取消] 选项显示出来;
点击 [取消] 菜单选项由 [doCancel] 方法处理:
@OptionsItem(R.id.actionAnnuler)
protected void doAnnuler() {
if (isDebugEnabled) {
Log.d(className, "Annulation demandée");
}
// asynchronous tasks are cancelled
cancelRunningTasks();
}
- 第 8 行:我们请求父类取消异步任务;
点击 [返回设置] 菜单选项的处理方式如下:
@OptionsItem(R.id.navigationToConfig)
protected void navigationToConfig() {
// navigate to the configuration view
mainActivity.navigateToView(IMainActivity.VUE_CONFIG, ISession.Action.NAVIGATION);
}
- 第 4 行:我们使用 [NAVIGATION] 操作导航至配置视图。这意味着我们希望将配置视图恢复到离开时的状态;
3.6.7.3. 片段生命周期管理
该片段具有以下 [HomeFragmentState]:
package client.android.fragments.state;
import android.widget.ArrayAdapter;
import client.android.architecture.custom.CoreState;
import client.android.dao.entities.CreneauMedecinJour;
public class AccueilFragmentState extends CoreState {
// fragment status [Home]
// selected doctor's position
private int selectedMedecinPosition;
// selected date
private int year;
private int month;
private int dayOfMonth;
// doctors' spinner data source
private String[] spinnerMedecinsDataSource;
// manufacturers
public AccueilFragmentState() {
}
// getters and setters
...
}
- 第 11 行:返回医生列表中选中的项目;
- 第 13–15 行:从日历中返回所选日期;
- 第 17 行:获取医生列表的数据源;
该片段的生命周期实现如下:
// implementation methods parent class -------------------------------------
@Override
public CoreState saveFragment() {
// save the view
AccueilFragmentState state = new AccueilFragmentState();
state.setSelectedMedecinPosition(spinnerMedecins.getSelectedItemPosition());
state.setDayOfMonth(edtJourRv.getDayOfMonth());
state.setMonth(edtJourRv.getMonth());
state.setYear(edtJourRv.getYear());
state.setSpinnerMedecinsDataSource(spinnerMedecinsDataSource);
return state;
}
@Override
protected int getNumView() {
return IMainActivity.VUE_ACCUEIL;
}
@Override
protected void initFragment(CoreState previousState) {
// we get the doctors back in session
medecins = session.getMédecins();
// 1st visit?
if (previousState == null) {
// we build the table displayed by the spinner
spinnerMedecinsDataSource = new String[medecins.size()];
int i = 0;
for (Medecin medecin : medecins) {
spinnerMedecinsDataSource[i] = String.format("%s %s %s", medecin.getTitre(), medecin.getPrenom(), medecin.getNom());
i++;
}
} else {
// no 1st visit
AccueilFragmentState state = (AccueilFragmentState) previousState;
spinnerMedecinsDataSource = state.getSpinnerMedecinsDataSource();
}
// the calendar
calendrier = Calendar.getInstance();
}
@Override
protected void initView(CoreState previousState) {
// we associate the doctors' spinner with its data source
ArrayAdapter<String> dataAdapterMedecins = new ArrayAdapter<>(activity, android.R.layout.simple_spinner_item, spinnerMedecinsDataSource);
dataAdapterMedecins.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinnerMedecins.setAdapter(dataAdapterMedecins);
// minimum calendar date to today
edtJourRv.setMinDate(calendrier.getTimeInMillis());
// 1st visit?
if (previousState == null) {
// menu
initMenu();
}
}
@Override
protected void updateOnSubmit(CoreState previousState) {
// menu
initMenu();
}
@Override
protected void updateOnRestore(CoreState previousState) {
// restore the state currently in session
AccueilFragmentState state = (AccueilFragmentState) previousState;
// selection in doctors' spinner
spinnerMedecins.setSelection(state.getSelectedMedecinPosition());
// calendar
edtJourRv.updateDate(state.getYear(), state.getMonth(), state.getDayOfMonth());
}
@Override
protected void notifyEndOfUpdates() {
}
@Override
protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
// called after all tasks have been completed or cancelled
// menu status
initMenu();
// next view?
if (!runningTasksHaveBeenCanceled) {
mainActivity.navigateToView(IMainActivity.VUE_AGENDA, ISession.Action.SUBMIT);
}
}
// méthodes privées ------------------------------------------------
private void initMenu() {
// menu status
setAllMenuOptionsStates(true);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.actionAnnuler, false)});
}
- 第 2–9 行:当父类发出请求时,片段会保存以下元素的状态:
- 第 6 行:医生列表中选中的位置;
- 第 7–9 行:日历中选定日期的日期(日、月、年);
- 第 10 行:医生下拉列表的数据源;
- 第 14–17 行:片段 ID 为 [IMainActivity.VUE_ACCUEIL];
- 第 19–39 行:在片段首次生成时(previousState == null)或后续重新生成时(previousState != null)执行;
- 第25–31行:首次访问时,构建医生下拉列表的数据源;
- 第 33–35 行:对于后续访问,从片段的上一状态中检索下拉菜单的数据源;
- 第 41–54 行:在与片段关联的视图首次构建时(previousState == null)或后续访问时重建时(previousState != null)执行;
- 第 50–53 行:首次访问时,显示不包含 [取消] 操作的菜单(第 88–92 行);
- 第 43–48 行:无论是否为首次访问,医生下拉列表都会与其数据源关联(第 44–46 行),且日历上的最早日期将设置为当前日期(第 48 行);
- 第 56–60 行:当通过 [SUBMIT] 操作到达该片段时执行。用户来自 [CONFIG] 视图。菜单将重置为初始状态;
- 第 62–70 行:当通过 [NAVIGATION] 或 [RESTORE] 操作到达该片段时执行;
- 第 67 行:医生下拉列表重置为上次选定的医生;
- 第 69 行:日历设置为上次选定的日期;
- 第 72–74 行:在所有先前更新完成后执行。无需进行其他操作;
- 第 76–85 行:当所有异步任务完成后执行;
- 第 80 行:将菜单重置为默认状态;
- 第 82–84 行:如果任务正常完成,则转到下一个视图;否则,保持在当前视图;
3.6.8. 日历视图管理
3.6.8.1. 视图

视觉界面的元素如下:
3.6.8.2. 该片段
日历视图由以下片段 [AgendaFragment] 管理:
![]() |
package client.android.fragments.behavior;
import android.util.Log;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import client.android.R;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.core.ISession;
import client.android.architecture.core.MenuItemState;
import client.android.architecture.custom.CoreState;
import client.android.architecture.custom.IMainActivity;
import client.android.dao.entities.AgendaMedecinJour;
import client.android.dao.entities.CreneauMedecinJour;
import client.android.dao.entities.Medecin;
import client.android.dao.entities.Rv;
import client.android.dao.service.Response;
import client.android.fragments.state.AgendaFragmentState;
import org.androidannotations.annotations.EFragment;
import org.androidannotations.annotations.OptionsItem;
import org.androidannotations.annotations.OptionsMenu;
import org.androidannotations.annotations.ViewById;
import rx.functions.Action1;
@EFragment(R.layout.agenda)
@OptionsMenu(R.menu.menu_agenda)
public class AgendaFragment extends AbstractFragment {
// visual interface elements
@ViewById(R.id.txt_titre2_agenda)
protected TextView txtTitre2;
@ViewById(R.id.listViewAgenda)
protected ListView lstCreneaux;
// agenda displayed by the fragment
private AgendaMedecinJour agenda;
// info ListView slots
private int firstPosition;
private int top;
// appointment deleted or not
private boolean rdvSupprimé;
// slot number added or deleted
private int numCréneau;
// update schedule after adding/deleting
private void updateAgenda() {
...
}
...
// implementation methods parent class ------------------------------------------------------
...
}
- 第 27 行:该片段与以下 [menu_agenda] 菜单相关联:
![]() |
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".activity.MainActivity1">
<item
android:id="@+id/menuActions"
app:showAsAction="ifRoom"
android:title="@string/menuActions">
<menu>
<item
android:id="@+id/actionAnnuler"
android:title="@string/actionAnnuler"/>
<item
android:id="@+id/actionAgenda"
android:title="@string/actionAgenda"/>
</menu>
</item>
<item
android:id="@+id/menuNavigation"
app:showAsAction="ifRoom"
android:title="@string/menuNavigation">
<menu>
<item
android:id="@+id/navigationToConfig"
android:title="@string/navigationToConfig"/>
<item
android:id="@+id/navigationToAccueil"
android:title="@string/navigationToAccueil"/>
</menu>
</item>
</menu>
- 第32–35行:视觉界面元素;
- 第 37–45 行:方法的全局数据;
3.6.8.2.1. 方法 [updateAgenda]
代码中的多处需要(重新)生成日历时段列表。该功能已被提取到以下私有方法 [updateAgenda] 中:
// update schedule after adding/deleting
private void updateAgenda() {
// (re)generation of calendar slots
// the agenda is taken from the session and stored in a fragment field
agenda = session.getAgenda();
// regeneration of ListView slots
ArrayAdapter<CreneauMedecinJour> adapter = new ListCreneauxAdapter(activity, R.layout.creneau_medecin,
agenda.getCreneauxMedecinJour(), this);
lstCreneaux.setAdapter(adapter);
// we reposition ourselves at the right spot on the ListView
lstCreneaux.setSelectionFromTop(firstPosition, top);
}
- 第 5 行:从会话中获取日历,并将其存储在片段的 [calendar] 字段中;
- 第 7–9 行:我们为 [ListView] 组件定义适配器。该适配器既定义了 [ListView] 的数据源,也定义了其每个项的显示模型。我们稍后将介绍这个适配器;
- 第 11 行:我们将日历恢复到之前的状态。这是因为我们仅显示当天部分时间段。如果我们在最后一个时间段添加或删除预约,上述代码将刷新页面以显示新的日历。这种刷新会导致视图返回第一个时间段,这是我们不希望看到的。 第 5 行解决了此问题。该解决方案的说明可参见网址 [http://stackoverflow.com/questions/3014089/maintain-save-restore-scroll-position-when-returning-to-a-listview];
[ListCreneauxAdapter] 类用于定义 [ListView] 中的行:

如上所示,显示效果取决于该时段是否有预约。 [ListCreneauxAdapter] 类的代码如下:
...
public class ListCreneauxAdapter extends ArrayAdapter<CreneauMedecinJour> {
// time slot table
private CreneauMedecinJour[] creneauxMedecinJour;
// execution context
private Context context;
// the layout id for displaying a line in the slot list
private int layoutResourceId;
// click listener
private AgendaFragment vue;
// manufacturer
public ListCreneauxAdapter(Context context, int layoutResourceId, CreneauMedecinJour[] creneauxMedecinJour,
AgendaFragment vue) {
super(context, layoutResourceId, creneauxMedecinJour);
// memorize information
this.creneauxMedecinJour = creneauxMedecinJour;
this.context = context;
this.layoutResourceId = layoutResourceId;
this.vue = vue;
// sort the table of slots in schedule order
Arrays.sort(creneauxMedecinJour, new MyComparator());
}
@Override
public View getView(final int position, View convertView, ViewGroup parent) {
...
}
// sorting the slot table
class MyComparator implements Comparator<CreneauMedecinJour> {
...
}
}
- 第 3 行:[ListCreneauxAdapter] 类必须继承 [ListView] 的预定义适配器,本例中是 [ArrayAdapter] 类。顾名思义,该类会使用一个对象数组(本例中为 [CreneauMedecinJour] 类型)来填充 [ListView]。让我们回顾一下该实体的代码:
public class CreneauMedecinJour implements Serializable {
private static final long serialVersionUID = 1L;
// fields
private Creneau creneau;
private Rv rv;
...
}
- [CreneauMedecinJour] 类包含一个时间段(第 5 行)和一个潜在的预约(第 6 行),如果没有预约则为 null;
回到 [ListCreneauxAdapter] 类的代码:
- 第 15 行:构造函数接受四个参数:
- 当前的 Android 活动,
- 定义每个 [ListView] 元素内容的 XML 文件,
- 医生时段的数组,
- 视图本身;
- 第 24 行:时间段数组按时间升序排序;
[getView] 方法负责生成与 [ListView] 中某一行对应的视图。该视图由三个元素组成:
[getView] 方法的代码如下:
@Override
public View getView(final int position, View convertView, ViewGroup parent) {
// we position ourselves in the right niche
CreneauMedecinJour creneauMedecin = creneauxMedecinJour[position];
// create the line
View row = ((Activity) context).getLayoutInflater().inflate(layoutResourceId, parent, false);
// the time slot
TextView txtCreneau = (TextView) row.findViewById(R.id.txt_Creneau);
txtCreneau.setText(String.format("%02d:%02d-%02d:%02d", creneauMedecin.getCreneau().getHdebut(), creneauMedecin
.getCreneau().getMdebut(), creneauMedecin.getCreneau().getHfin(), creneauMedecin.getCreneau().getMfin()));
// the customer
TextView txtClient = (TextView) row.findViewById(R.id.txt_Client);
String text;
if (creneauMedecin.getRv() != null) {
Client client = creneauMedecin.getRv().getClient();
text = String.format("%s %s %s", client.getTitre(), client.getPrenom(), client.getNom());
} else {
text = "";
}
txtClient.setText(text);
// the link
final TextView btnValider = (TextView) row.findViewById(R.id.btn_Valider);
if (creneauMedecin.getRv() == null) {
// add
btnValider.setText(R.string.btn_ajouter);
btnValider.setTextColor(context.getResources().getColor(R.color.blue));
} else {
// delete
btnValider.setText(R.string.btn_supprimer);
btnValider.setTextColor(context.getResources().getColor(R.color.red));
}
// link listener
btnValider.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
// we skip the news on the calendar view
vue.doValider(position, btnValider.getText().toString());
}
});
// we return the line
return row;
}
- 第 2 行:position 是要在 [ListView] 中生成的行号。它也是 [creneauxMedecinJour] 数组中的槽位号。我们忽略其他两个参数;
- 第 4 行:我们获取要在 [ListView] 行中显示的时间槽;
- 第 6 行:根据其 XML 定义构建该行
![]() |
[creneau_medecin.xml] 的代码如下:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/RelativeLayout1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/wheat" >
<TextView
android:id="@+id/txt_Creneau"
android:layout_width="100dp"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginLeft="20dp"
android:text="@string/txt_dummy" />
<TextView
android:id="@+id/txt_Client"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:layout_alignBaseline="@+id/txt_Creneau"
android:layout_marginLeft="20dp"
android:layout_toRightOf="@+id/txt_Creneau"
android:text="@string/txt_dummy" />
<TextView
android:id="@+id/btn_Valider"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@+id/txt_Client"
android:layout_marginLeft="20dp"
android:layout_toRightOf="@+id/txt_Client"
android:text="@string/btn_valider"
android:textColor="@color/blue" />
</RelativeLayout>
- 第 8–10 行:构建时间槽 [1];
- 第12–20行:构建客户ID [2];
- 第23行:如果时间段没有预约;
- 第25–26行:创建蓝色[添加]链接;
- 第29–30行:否则,创建红色[删除]链接;
- 第 33–40 行:无论链接类型 [添加 / 删除] 如何,视图的 [doValider] 方法都将处理对链接的点击。该方法将接收两个参数:
- 被点击的时间槽编号,
- 被点击链接的标签;
- 第 42 行:返回刚刚创建的行。
请注意,处理链接的是 [AgendaFragment] 片段的 [doValider] 方法。具体如下:
// click on a link [Add / Remove]
public void doValider(int numCréneau, String texte) {
// operation in progress?
if (numberOfRunningTasks != 0) {
Toast.makeText(activity, "Une opération est en cours. Patientez ou Annulez...", Toast.LENGTH_SHORT).show();
return;
}
// note the scroll position to return to it
// read [http://stackoverflow.com/questions/3014089/maintain-save-restore-scroll-position-when-returning-to-a-listview]
// position of 1st element fully visible or not
firstPosition = lstCreneaux.getFirstVisiblePosition();
// y offset of this element relative to the top of the ListView
// measures the height of any hidden part
View v = lstCreneaux.getChildAt(0);
top = (v == null) ? 0 : v.getTop();
// we also note the number of the clicked slot
this.numCréneau = numCréneau;
// depending on the text of the link, we do not do the same thing
if (texte.equals(getResources().getString(R.string.lnk_ajouter))) {
doAjouter();
} else {
doSupprimer();
}
}
- [doValider] 方法接收两项信息:
- 被点击的插槽编号;
- 被点击链接的文本(添加 / 删除);
- 第 4–7 行:如果存在正在进行的异步任务,则禁用 [删除 / 添加] 链接的点击操作。这是一个旨在简化代码编写的设计选择。该设计方案可供讨论;
- 第 11–15 行:我们将 ListView 中的槽位信息(firstPosition、top)存储在片段的字段中,以便私有方法 [updateAgenda] 能以相同的滚动位置重新生成它;
- 第 17 行:我们存储被点击插槽的编号;
- 第 19–23 行:根据被点击链接的文本内容,添加或移除一项;
3.6.8.2.2. 方法 [doDelete]
[doDelete] 方法确保从被点击的插槽中移除该日程:
// deleting an appointment
private void doSupprimer() {
// waiting for two tasks to be completed
beginWaiting(2);
// delete the Rdv in the background
rdvSupprimé = false;
// rv identifier to be deleted
long idRv = agenda.getCreneauxMedecinJour()[numCréneau].getRv().getId();
// deletion by an asynchronous task
executeInBackground(mainActivity.supprimerRv(idRv), new Action1<Response<Rv>>() {
@Override
public void call(Response<Rv> responseRv) {
// income consumption
consumeRv(responseRv);
}
});
}
// consumption of an answer
private void consumeRv(Response<Rv> responseRv) {
// mistake?
if (responseRv.getStatus() != 0) {
// message
showAlert(responseRv.getMessages());
// cancellation
doAnnuler();
// back to UI
return;
}
// we note that the appointment has been cancelled
rdvSupprimé = true;
// the most recent agenda is requested
executeInBackground(
mainActivity.getAgendaMedecinJour(agenda.getMedecin().getId(), session.getDayRv()),
new Action1<Response<AgendaMedecinJour>>() {
@Override
public void call(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
// we consume the answer
consumeAgenda(responseAgendaMedecinJour);
}
});
}
// diary consumption
private void consumeAgenda(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
// mistake?
if (responseAgendaMedecinJour.getStatus() != 0) {
// message
showAlert(responseAgendaMedecinJour.getMessages());
// cancellation
doAnnuler();
// back to UI
return;
}
// put the agenda in the session
session.setAgenda(responseAgendaMedecinJour.getBody());
// update the view's agenda
updateAgenda();
}
- 第 4 行:我们通知父类我们将启动两个异步任务,并开始等待这两个任务完成;
- 第 8 行:获取待删除约会的 ID。服务器需要此信息;
- 第 9–18 行:我们通过异步任务请求删除该预约;
- 第 10 行:[executeInBackground] 方法需要两个参数:
- 第 10 行:待执行并被监视的进程由 [mainActivity.deleteRv(idRv)] 方法提供;
- 第 10–17 行:第二个参数是 [Action1<T>] 类型的实例,其中 T 是被观察进程返回的类型,此处为 [Response<Rv>]
- 第 15 行:收到响应后,将其传递给第 21 行的 [consumeRv] 方法;
- 第 10 行:[executeInBackground] 方法需要两个参数:
- 第 21–44 行:我们已收到来自异步任务的响应,并对其进行处理;
- 第 23–30 行:首先,我们检查服务器是否在响应的 [status] 字段中报告了错误;
- 第 25 行:如果存在错误,则显示服务器放置在响应 [messages] 字段中的消息;
- 第 27 行:我们取消所有任务;
- 第 29 行:返回用户界面;
- 第32行:如果没有错误,则记录该预约已被删除;
- 第 34–43 行:与其直接从片段当前显示的日历中删除该预约,我们不如请求获取医生的新日历。由于该应用程序是多用户系统,其他用户也可能修改了医生的日历。因此,最好使用最新版本;
- 第 34–43 行、第 47–61 行:我们重复了 [AccueilFragment] 片段中的操作,这次使用从会话中检索到的信息;
[beginWaiting] 方法(第 4 行)如下:
// beginning of waiting
protected void beginWaiting(int numberOfRunningTasks) {
// prepare to launch tasks
beginRunningTasks(numberOfRunningTasks);
// status of buttons and menus
setAllMenuOptionsStates(false);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.menuActions, true),new MenuItemState(R.id.actionAnnuler, true)});
}
- 第 4 行:我们告知父任务,我们将启动 [numberOfRunningTasks] 个任务;
- 第 6 行:隐藏所有菜单选项;
- 第 7 行:然后显示 [操作/取消] 选项;
3.6.8.2.3. 方法 [doCancel]
点击 [取消] 菜单选项由 [doAnnuler] 方法处理:
@OptionsItem(R.id.actionAnnuler)
protected void doAnnuler() {
if (isDebugEnabled) {
Log.d(className, "Annulation demandée");
}
// asynchronous tasks are cancelled
cancelRunningTasks();
}
- 第 7 行:我们请求父类取消异步任务;
3.6.8.2.4. 菜单选项 [返回配置]
点击 [返回配置] 菜单选项的处理方式如下:
@OptionsItem(R.id.navigationToConfig)
protected void navigationToConfig() {
// navigate to the configuration view
mainActivity.navigateToView(IMainActivity.VUE_CONFIG, ISession.Action.NAVIGATION);
}
- 第 4 行:我们使用 [NAVIGATION] 操作导航至配置视图。这意味着我们希望将配置视图恢复到离开时的状态;
3.6.8.2.5. 菜单选项 [返回主页]
点击 [返回主页] 菜单选项的处理方式类似:
@OptionsItem(R.id.navigationToAccueil)
protected void navigationToAccueil() {
// navigate to home view
mainActivity.navigateToView(IMainActivity.VUE_ACCUEIL, ISession.Action.NAVIGATION);
}
3.6.8.3. 片段生命周期管理
该片段处于以下状态 [AgendaFragmentState]:
package client.android.fragments.state;
import android.widget.ArrayAdapter;
import client.android.architecture.custom.CoreState;
import client.android.dao.entities.CreneauMedecinJour;
public class AgendaFragmentState extends CoreState {
// title view
private String titre;
// ListView
private int firstPosition;
private int top;
// manufacturers
public AgendaFragmentState() {
}
public AgendaFragmentState(String titre) {
this.titre = titre;
}
// getters and setters
...
}
- 第 10 行:视图顶部显示的标题;
- 第12-13行:启用显示医生可用时段的ListView滚动功能;
该片段的生命周期实现如下:
// implementation methods parent class ------------------------------------------------------
@Override
public CoreState saveFragment() {
// save status
AgendaFragmentState state = new AgendaFragmentState();
state.setTitre(txtTitre2.getText().toString());
// note the scroll position to return to it
// read [http://stackoverflow.com/questions/3014089/maintain-save-restore-scroll-position-when-returning-to-a-listview]
// position of 1st element fully visible or not
firstPosition = lstCreneaux.getFirstVisiblePosition();
// y offset of this element relative to the top of the ListView
// measures the height of any hidden part
View v = lstCreneaux.getChildAt(0);
top = (v == null) ? 0 : v.getTop();
// we memorize it all
state.setTop(top);
state.setFirstPosition(firstPosition);
return state;
}
@Override
protected int getNumView() {
return IMainActivity.VUE_AGENDA;
}
@Override
protected void initFragment(CoreState previousState) {
// 1st visit?
if (previousState != null) {
// not the 1st visit
AgendaFragmentState state = (AgendaFragmentState) previousState;
// and information from ListView
firstPosition = state.getFirstPosition();
top = state.getTop();
}
}
@Override
protected void initView(CoreState previousState) {
}
@Override
protected void updateOnSubmit(CoreState previousState) {
// get the agenda
agenda = session.getAgenda();
// generate the page title
Medecin medecin = agenda.getMedecin();
txtTitre2.setText(String.format("Rendez-vous de %s %s %s le %s", medecin.getTitre(), medecin.getPrenom(),
medecin.getNom(), session.getJourRv()));
// menu status
initMenu();
}
@Override
protected void updateOnRestore(CoreState previousState) {
// regenerate the page title
AgendaFragmentState state = (AgendaFragmentState) previousState;
txtTitre2.setText(state.getTitre());
}
@Override
protected void notifyEndOfUpdates() {
// regenerate the slot list
updateAgenda();
}
@Override
protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
// menu status
initMenu();
// if cancelled but appointment deleted, update local calendar
if (runningTasksHaveBeenCanceled && rdvSupprimé) {
// we delete the appointment from the local calendar (we were unable to access the global calendar)
agenda.getCreneauxMedecinJour()[numCréneau].setRv(null);
// update the visual interface
updateAgenda();
}
}
// méthodes privées ------------------------------------------------
private void initMenu() {
// menu status
setAllMenuOptionsStates(true);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.actionAnnuler, false)});
}
- 第 2–19 行:当父类发出请求时,片段会保存以下元素的状态:
- 第 6 行:视图顶部显示的标题;
- 第 7–17 行:用于恢复 ListView 滚动状态的信息(top、firstPosition);
- 第 21–24 行:片段 ID 为 [IMainActivity.VUE_AGENDA];
- 第 26–35 行:在片段首次生成时(previousState == null)或后续访问时重新生成(previousState != null)时执行;
- 第 30–34 行:如果这不是首次访问该片段,则获取恢复 ListView 滚动状态所需的信息 (top, firstPosition);
- 第 38–40 行:当与片段关联的视图首次构建(previousState == null)或在后续访问时重建(previousState != null)时执行。此处无需执行任何操作,因为插槽的 ListView 将由私有方法 [updateAgenda](第 61–65 行)生成;
- 第 42–52 行:当通过 [SUBMIT] 操作进入该片段时执行。此时我们来自 [HOME] 视图;
- 第 45 行:我们获取由 [AccueilFragment] 设置的日程表;
- 第 47–49 行:生成视图标题;
- 时间时段的 ListView 将由私有方法 [updateAgenda] 生成(第 61-65 行);
- 第 54–59 行:当通过 [NAVIGATION] 或 [RESTORE] 操作到达该片段时执行;
- 第 57–58 行:重新生成视图标题;
- 时间段的 ListView 将由私有方法 [updateAgenda] 生成(第 61–65 行);
- 第 72–74 行:当所有先前更新完成后执行。时间段的 ListView 会被更新,因为无论通过何种方式访问该片段,此更新都是必要的;
- 第 67–77 行:当所有异步任务完成后执行;
- 第 70 行:菜单重置为默认状态(第 82–86 行);
- 第 72 行:共有两个异步任务。我们检查第一个任务(删除约会)是否成功,尽管该任务已被取消;
- 第 74 行:若成功,则从本地日历中删除该预约
- 第 75 行:并更新日历的显示;
3.6.9. 处理添加日程视图
3.6.9.1. 视图

视觉界面的元素如下:
3.6.9.2. 该片段
添加预约的视图由以下片段 [AjoutRvFragment] 管理:
![]() |
package client.android.fragments.behavior;
import android.util.Log;
import android.widget.ArrayAdapter;
import android.widget.Spinner;
import android.widget.TextView;
import client.android.R;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.core.ISession;
import client.android.architecture.core.MenuItemState;
import client.android.architecture.custom.CoreState;
import client.android.architecture.custom.IMainActivity;
import client.android.dao.entities.*;
import client.android.dao.service.Response;
import client.android.fragments.state.AjoutRvFragmentState;
import org.androidannotations.annotations.EFragment;
import org.androidannotations.annotations.OptionsItem;
import org.androidannotations.annotations.OptionsMenu;
import org.androidannotations.annotations.ViewById;
import rx.functions.Action1;
import java.util.List;
import java.util.Locale;
@EFragment(R.layout.ajout_rv)
@OptionsMenu(R.menu.menu_ajout_rv)
public class AjoutRvFragment extends AbstractFragment {
// visual interface elements
@ViewById(R.id.spinnerClients)
protected Spinner spinnerClients;
@ViewById(R.id.txt_titre2_ajoutRv)
protected TextView txtTitre2;
// our customers
private List<Client> clients;
// local data
private Creneau creneau;
private Medecin medecin;
private boolean rdvAjouté;
private Rv rv;
private String[] spinnerClientsDataSource;
// validation page
@OptionsItem(R.id.actionValider)
protected void doValider() {
...
}
...
// implementation methods parent class ----------------------------------
...
}
- 第 26 行:该片段与以下菜单 [menu_ajout_rv] 相关联:
![]() |
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".activity.MainActivity1">
<item
android:id="@+id/menuActions"
app:showAsAction="ifRoom"
android:title="@string/menuActions">
<menu>
<item
android:id="@+id/actionValider"
android:title="@string/actionValider"/>
<item
android:id="@+id/actionAnnuler"
android:title="@string/actionAnnuler"/>
</menu>
</item>
<item
android:id="@+id/menuNavigation"
app:showAsAction="ifRoom"
android:title="@string/menuNavigation">
<menu>
<item
android:id="@+id/navigationToConfig"
android:title="@string/navigationToConfig"/>
<item
android:id="@+id/navigationToAccueil"
android:title="@string/navigationToAccueil"/>
<item
android:id="@+id/navigationToAgenda"
android:title="@string/navigationToAgenda"/>
</menu>
</item>
</menu>
- 第30–33行:视觉界面的元素;
- 第 36 行:客户端列表;
- 第 43 行:客户端加载指示器的数据源;
点击 [Validate] 链接由以下 [doValidate] 方法处理:
// our customers
private List<Client> clients;
// local data
private Creneau creneau;
private Medecin medecin;
private boolean rdvAjouté;
private Rv rv;
private String[] spinnerClientsDataSource;
...
// validation page
@OptionsItem(R.id.actionValider)
protected void doValider() {
// the selected customer is retrieved
Client client = clients.get(spinnerClients.getSelectedItemPosition());
// start waiting for 2 asynchronous tasks
beginWaiting(2);
// we add the RV
rdvAjouté = false;
executeInBackground(
mainActivity.ajouterRv(session.getDayRv(), creneau.getId(), client.getId()),
new Action1<Response<Rv>>() {
@Override
public void call(Response<Rv> responseRv) {
// we consume the answer
consumeRv(responseRv);
}
});
}
// consumption of a Response<Rv> object
void consumeRv(Response<Rv> responseRv) {
// mistake?
if (responseRv.getStatus() != 0) {
// message
showAlert(responseRv.getMessages());
// cancellation
doAnnuler();
// back to UI
return;
}
// note that the rdv has been added
rdvAjouté = true;
// memorize the appointment
this.rv = responseRv.getBody();
// we ask for the new agenda
executeInBackground(mainActivity.getAgendaMedecinJour(session.getAgenda().getMedecin().getId(), session.getDayRv()), new Action1<Response<AgendaMedecinJour>>() {
@Override
public void call(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
// we consume the answer
consumeAgenda(responseAgendaMedecinJour);
}
});
}
// consumption of a Response<AgendaMedecinJour> object
private void consumeAgenda(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
// mistake?
if (responseAgendaMedecinJour.getStatus() != 0) {
// message
showAlert(responseAgendaMedecinJour.getMessages());
// cancellation
doAnnuler();
// back to UI
return;
}
// put the agenda in the session
session.setAgenda(responseAgendaMedecinJour.getBody());
}
- 第 13 行:当 [doValider] 方法开始执行时,字段 2、5、6 和 9 已在片段的生命周期中完成初始化。我们将看到具体实现;
- 第 15 行:我们获取与客户端旋转按钮中选定项对应的 [Client] 实体;
- 第 17 行:我们通知父类我们将启动两个异步任务,并准备进行等待;
- 第 19 行:起初,该预约尚未添加到医生的日历中;
- 第 20–30 行:我们请求服务器添加一个预约;
- 第 20 行:[executeInBackground] 方法期望两个参数:
- 第 20 行:待执行并被观察的流程由方法 [mainActivity.addRv(session.getDayRv(), slot.getId(), client.getId())] 提供;
- 第 22–29 行:第二个参数是 [Action1<T>] 类型的实例,其中 T 是被观察进程返回的类型,此处为 [Response<Rv>]
- 第 27 行:收到响应后,将其传递给第 33 行的 [consumeRV] 方法;
- 第 20 行:[executeInBackground] 方法期望两个参数:
- 第 33–56 行:我们已收到来自服务器的响应。对其进行处理;
- 第 35–42 行:首先,检查服务器是否在响应的 [status] 字段中报告了错误;
- 第 37 行:如果存在错误,则显示服务器放置在响应 [messages] 字段中的消息;
- 第 39 行:我们取消所有任务;
- 第 41 行 :返回用户界面;
- 第 44 行:如果没有错误,则提示已添加预约;
- 第 46 行:将添加的预约存储在片段的某个字段中;
- 第 47–55 行:与删除预约时相同,添加预约后,向服务器请求医生的最新日程;
- 第 47–56 行、第 59–71 行:这段代码之前已出现过多次;
[beginWaiting] 方法(第 17 行)如下:
// beginning of waiting
protected void beginWaiting(int numberOfRunningTasks) {
// prepare to launch tasks
beginRunningTasks(numberOfRunningTasks);
// status of buttons and menus
setAllMenuOptionsStates(false);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.menuActions, true),new MenuItemState(R.id.actionAnnuler, true)});
}
- 第 4 行:我们告知父任务,我们将启动 [numberOfRunningTasks] 个任务;
- 第 6 行:隐藏所有菜单选项;
- 第 7 行:随后将 [操作/取消] 选项显示出来;
点击 [取消] 菜单选项由 [doCancel] 方法处理:
@OptionsItem(R.id.actionAnnuler)
protected void doAnnuler() {
if (isDebugEnabled) {
Log.d(className, "Annulation demandée");
}
// asynchronous tasks are cancelled
cancelRunningTasks();
}
- 第 7 行:我们请求父类取消异步任务;
返回导航由以下三个方法处理:
@OptionsItem(R.id.navigationToConfig)
protected void navigationToConfig() {
// navigate to the configuration view
mainActivity.navigateToView(IMainActivity.VUE_CONFIG, ISession.Action.NAVIGATION);
}
@OptionsItem(R.id.navigationToAccueil)
protected void navigationToAccueil() {
// navigate to the configuration view
mainActivity.navigateToView(IMainActivity.VUE_ACCUEIL, ISession.Action.NAVIGATION);
}
@OptionsItem(R.id.navigationToAgenda)
protected void navigationToAgenda() {
// navigate to the calendar view
mainActivity.navigateToView(IMainActivity.VUE_AGENDA, ISession.Action.NAVIGATION);
}
3.6.9.3. 片段生命周期管理
片段具有以下状态 [AjoutRvFragmentState]:
package client.android.fragments.state;
import client.android.architecture.custom.CoreState;
// fragment status AjoutRvFragment
public class AjoutRvFragmentState extends CoreState {
// selected customer position
private int selectedClientPosition;
// title view
private String titre;
// customer spinner data source
private String[] spinnerClientsDataSource;
// getters and setters
...
}
该片段的生命周期实现如下:
// implementation methods parent class ----------------------------------
@Override
public CoreState saveFragment() {
// save view
AjoutRvFragmentState state = new AjoutRvFragmentState();
state.setTitre(txtTitre2.getText().toString());
state.setSelectedClientPosition(spinnerClients.getSelectedItemPosition());
state.setSpinnerClientsDataSource(spinnerClientsDataSource);
return state;
}
@Override
protected int getNumView() {
return IMainActivity.VUE_AJOUT_RV;
}
@Override
protected void initFragment(CoreState previousState) {
// retrieve clients in session
clients = session.getClients();
// 1st visit?
if (previousState == null) {
// we build the table displayed by the spinner
spinnerClientsDataSource = new String[clients.size()];
int i = 0;
for (Client client : clients) {
spinnerClientsDataSource[i] = String.format("%s %s %s", client.getTitre(), client.getPrenom(), client.getNom());
i++;
}
} else {
// no 1st visit
AjoutRvFragmentState state = (AjoutRvFragmentState) previousState;
spinnerClientsDataSource = state.getSpinnerClientsDataSource();
}
}
@Override
protected void initView(CoreState previousState) {
// association spinner to its data source
ArrayAdapter<String> dataAdapterClients = new ArrayAdapter<>(activity, android.R.layout.simple_spinner_item,
spinnerClientsDataSource);
dataAdapterClients.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinnerClients.setAdapter(dataAdapterClients);
// 1st visit?
if (previousState == null) {
// menu
initMenu();
}
}
@Override
protected void updateOnSubmit(CoreState previousState) {
// retrieve the number of the slot to be reserved in the session
int position = session.getPosition();
// the doctor's agenda is retrieved from the session
AgendaMedecinJour agenda = session.getAgenda();
// we get the doctor and the time slot we're going to schedule an appointment for
medecin = agenda.getMedecin();
creneau = agenda.getCreneauxMedecinJour()[position].getCreneau();
// build page title 2
String jour = session.getJourRv();
txtTitre2.setText(String.format(Locale.FRANCE,
"Prise de rendez-vous de %s %s %s le %s pour le créneau %02d:%02d-%02d:%02d", medecin.getTitre(),
medecin.getPrenom(), medecin.getNom(), jour, creneau.getHdebut(), creneau.getMdebut(), creneau.getHfin(),
creneau.getMfin()));
// customer selection
spinnerClients.setSelection(0);
// menu
initMenu();
}
@Override
protected void updateOnRestore(CoreState previousState) {
// restore previous state
AjoutRvFragmentState state = (AjoutRvFragmentState) previousState;
// title
txtTitre2.setText(state.getTitre());
// spinner
spinnerClients.setSelection(state.getSelectedClientPosition());
}
@Override
protected void notifyEndOfUpdates() {
}
@Override
protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
// menu status
initMenu();
// next view?
if (!runningTasksHaveBeenCanceled) {
mainActivity.navigateToView(IMainActivity.VUE_AGENDA, ISession.Action.SUBMIT);
return;
}
// there has been a cancellation - appointment already added?
if (rdvAjouté) {
// we modify the local agenda (we didn't get the global agenda)
AgendaMedecinJour agenda = session.getAgenda();
agenda.getCreneauxMedecinJour()[session.getPosition()].setRv(rv);
// the agenda is displayed
mainActivity.navigateToView(IMainActivity.VUE_AGENDA, ISession.Action.SUBMIT);
return;
}
}
// private methods -------------------
private void initMenu() {
// menu status
setAllMenuOptionsStates(true);
setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.actionAnnuler, false)});
}
- 第 2–10 行:当父类发出请求时,片段会保存以下元素的状态:
- 第 6 行:视图顶部的标题;
- 第 7 行:客户选择器中选中项的位置;
- 第 8 行:客户下拉列表的数据源;
- 第 12–15 行:片段 ID 为 [IMainActivity.VUE_AJOUT_RV];
- 第 17–35 行:在片段首次生成时(previousState == null)或后续重新生成时(previousState != null)执行;
- 第 20 行:从会话中检索客户列表并将其放入片段字段中;
- 第 22–30 行:对于首次访问,构建客户下拉列表的数据源;
- 第 32–33 行:对于后续访问,从片段的先前状态中检索客户下拉列表的数据源;
- 第 37–49 行:当与片段关联的视图首次构建(previousState == null)或后续重建(previousState != null)时执行;
- 第 40–43 行:在所有情况下,客户旋转按钮都会与其数据源相关联;
- 第 45–48 行:首次访问时,显示不包含 [Cancel] 操作的菜单(第 107–111 行);
- 第 51–70 行:当通过 [SUBMIT] 操作进入片段时执行。我们来自 [CALENDAR] 视图;
- 第 54 行:我们获取将用于安排预约的时间段编号;
- 第 56–59 行:获取添加此预约所需的 [Doctor] 和 [Time Slot] 实体,并将它们放入片段中的字段中;
- 第 61–65 行:利用这些信息,我们可以构建视图标题;
- 第 67 行:将客户端滚动条设置为第一个选项;
- 第 69 行:将菜单设置为初始状态(不包含 [取消] 选项);
- 第 72–80 行:当通过 [NAVIGATION] 或 [RESTORE] 操作进入片段时执行;
- 第 77 行:重新生成视图标题;
- 第 79 行:将客户端旋转按钮重置为上次选中的客户端;
- 第 82–84 行:当所有先前更新完成后执行。此处无需进行其他操作;
- 第 86–104 行:当所有异步任务完成时执行;
- 第 89 行:将菜单重置为默认状态;
- 第 91–94 行:如果任务正常完成,则通过 [SUBMIT] 返回 [CALENDAR] 视图(此处也可以是 [NAVIGATION] 操作);
- 第 96–103 行:如果任务以取消结束,我们仍需检查预约是否已添加(这意味着检索新日历失败);
- 第 98–99 行:如果已添加该约会;
- 第 98–99 行:将服务器返回的约会添加到当前日历(即当前活动日历)中;
- 第 101 行:通过 [SUBMIT] 返回 [AGENDA] 视图(此处也可采用 NAVIGATION 类型的操作);
3.7. 执行
执行以下测试:
- 在正常条件下使用应用程序并验证其功能;
- 对每个视图旋转设备,并验证每个视图是否能正确恢复;
- 在 [IMainActivity] 中添加几秒钟的等待时间;
- 接下来,取消这些任务,并验证结果是否与预期一致;
- 在等待期间旋转设备,并验证任务是否已正确取消且未发生崩溃;
- 在 [IMainActivity] 中更改片段顺序,并验证应用程序是否仍能正常运行;












































