Skip to content

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 客户端视图

共有四种观点。

配置视图

Image

医生和预约日期选择视图

Image

预约时段选择界面

Image

预约客户选择视图

Image

3.3. 项目架构

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

Image

客户端与服务器之间的异步通信将通过 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)的值设置了唯一性约束:

ALTER TABLE RV ADD CONSTRAINT UNQ1_RV UNIQUE (JOUR, ID_CRENEAU);

如果 [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

Image

这里我们重点关注服务器 [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 请求中包含以下头部字段:

Authorization: Basic code

所需的代码是字符串 'username:password' 的 Base64 编码 [http://fr.wikipedia.org/wiki/Base64]。在初始状态下,Web 服务仅接受用户名为 'admin'、密码为 'admin' 的用户。对于该特定用户,上述标头将变为以下内容:

Authorization: Basic YWRtaW46YWRtaW4=

为了发送此 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>>] 的结构如下:

[int status; List<String> messages; List<Medecin> medecins]

接下来,我们将使用这些简要定义来描述服务器的响应。此外,目前我们将不再提供屏幕截图。请回顾我们刚刚讲解的内容。等到需要发送 POST 请求时,我们会再次使用屏幕截图。我们还将以以下格式展示一个执行示例:

URL
/getAllDoctors
响应
{"status":0,"messages":null,"doctors":
[{"id":1,"version":1,"title":"女士","lastName":"PELISSIER","firstName":"Marie"},
{"id":2,"version":1,"title":"先生","lastName":"BROMARD","firstName":"Jacques"},
{"id":3,"version":1,"title":"先生","lastName":"JANDOT","firstName":"Philippe"},
{"id":4,"version":1,"title":"女士","lastName":"JACQUEMOT","firstName":"Justine"}]}

3.5.4. 客户列表

URL
/getAllClients
响应

Response<List<Client>> :[int status; List<String> messages;
 List<Client> 客户端]
Client: [Long id; Long version; String title;
 String 姓; String 名;]

示例:

URL
/getAllClients
响应
{"status":0,"messages":null,"clients":
[{"id":1,"version":1,"title":"Mr","lastName":"MARTIN","firstName":"Jules"},
{"id":2,"version":1,"title":"女士","lastName":"GERMAN","firstName":"Christine"},
{"id":3,"version":1,"title":"先生","lastName":"JACQUARD","firstName":"Jules"},
{"id":4,"version":1,"title":"女士","lastName":"BISTROU","firstName":"Brigitte"}]}

3.5.5. 医生预约时段列表

URL
/getAllSlots/{doctorId}
响应

响应<List<Appointment>>:[int status ; List<String> messages ;
 List<Appointment> 预约]
时段: [int 开始小时; int 开始分钟; int 结束小时; int 结束分钟;]
  • [idMedecin]: 您希望获取其预约时段的医生的ID;
  • [startTime]:预约开始时间;
  • [start_time]:就诊开始时间;
  • [hfin]:就诊结束时间;
  • [endmin]:问诊结束分钟数;

对于 10:20 至 10:40 之间的时段,我们有 [starts, starts, ends, ends] = [10, 20, 10, 40]。

示例:

URL
/getAllSlots/1
响应
{"status":0,"messages":null,"slots":
[{"id":1,"version":1,"startTime":8,"startDate":0,"endTime":8,"endDate":20,"doctorId":1},
{"id":2,"version":1,"startTime":8,"startMin":20,"endTime":8,"endMin":40,"doctorId":1},
{"id":3,"version":1,"startHour":8,"startMinute":40,"endHour":9,"endMinute":0,"doctorId":1},
{"id":4,"version":1,"startHour":9,"startMinute":0,"endHour":9,"endMinute":20,"doctorId":1},
{"id":5,"version":1,"hstart":9,"mstart":20,"hend":9,"mend":40,"doctorId":1},
{"id":6,"version":1,"startHour":9,"startMinute":40,"endHour":10,"endMinute":0,"doctorId":1},
{"id":7,"version":1,"startTime":10,"startDate":0,"endTime":10,"endDate":20,"doctorId":1},
{"id":8,"version":1,"startTime":10,"startMin":20,"endTime":10,"endMin":40,"doctorId":1},
{"id":9,"version":1,"startTime":10,"startDate":40,"endTime":11,"endDate":0,"doctorId":1},
{"id":10,"version":1,"startTime":11,"startDate":0,"endTime":11,"endDate":20,"doctorId":1},
{"id":11,"version":1,"startTime":11,"startDate":20,"endTime":11,"endDate":40,"doctorId":1},
{"id":12,"version":1,"startTime":11,"startDate":40,"endTime":12,"endDate":0,"doctorId":1},
{"id":13,"version":1,"startTime":14,"startDate":0,"endTime":14,"endDate":20,"doctorId":1},
{"id":14,"version":1,"startTime":14,"startDate":20,"endTime":14,"endDate":40,"doctorId":1},
{"id":15,"version":1,"startTime":14,"startDate":40,"endTime":15,"endDate":0,"doctorId":1},
{"id":16,"version":1,"startTime":15,"startDate":0,"endTime":15,"endDate":20,"doctorId":1},
{"id":17,"version":1,"startTime":15,"startDate":20,"endTime":15,"endDate":40,"doctorId":1},
{"id":18,"version":1,"startTime":15,"startDate":40,"endTime":16,"endDate":0,"doctorId":1},
{"id":19,"version":1,"startTime":16,"startDate":0,"endTime":16,"endDate":20,"doctorId":1},
{"id":20,"version":1,"startTime":16,"startDate":20,"endTime":16,"endDate":40,"doctorId":1},
{"id":21,"version":1,"startTime":16,"startDate":40,"endTime":17,"endDate":0,"doctorId":1},
{"id":22,"version":1,"startTime":17,"startDate":0,"endTime":17,"endDate":20,"doctorId":1},
{"id":23,"version":1,"startTime":17,"startDate":20,"endTime":17,"endDate":40,"doctorId":1},
{"id":24,"version":1,"startTime":17,"startDate":40,"endTime":18,"endDate":0,"doctorId":1}]}

3.5.6. 医生预约列表

URL
/getRvMedecinJour/{idMedecin}/{day}
响应

响应<List<Rv>>[int status; List<String> messages;
 List<Rv> rvs]
Rv: [Date day; Client client; Slot slot;
 long 客户端ID; long 槽ID]
  • [idMedic] : 申请预约的医生的标识符;
  • URL [day]:预约日期,格式为 'yyyy-mm-dd';
  • 响应 [day]:与上述相同,但采用 Java 日期格式;
  • [client]:预约的客户。其结构已在前文描述;
  • [idClient]:客户的标识符;
  • [slot]:预约时段。其结构已在前文描述;
  • [slotId]:时段标识符;

示例:

URL
/getRvMedecinJour/1/2014-07-08
响应
{"status":0,"messages":null,
"rvs":[{"id":45,"version":0,"date":"2014-07-08","client":
{"id":1,"version":1,"title":"先生","lastName":"MARTIN","firstName":"Jules"},"slot":
{"id":1,"version":1,"startTime":8,"startMinute":0,"endTime":8,"endMinute":20,"doctorId":1},
"clientId":1,"appointmentId":1}]}

3.5.7. 医生的日程安排

URL
/getDoctorScheduleDay/{doctorId}/{day}
响应

Response<DoctorScheduleDay>:[int status ; List<String> messages ;
 DoctorScheduleDay 排班表]
每日医生排班: [Doctor 医生; Date 日期;
DoctorAppointmentSlot[] doctorAppointmentSlots]
每日医生时段:[时段 slot ; 预约 appointment]
  • [doctorId]:所需预约的医生的标识符;
  • URL [day] : 预约日期,格式为 'yyyy-mm-dd' ;
  • [calendar]:医生的日程表;
  • [doctor]:所指的医生。其结构已在前面定义;
  • Response [day]:日历中的日期,以 Java 日期格式表示;
  • [doctorDaySlots]:类型为 [DoctorDaySlot] 的元素数组;
  • [slot]:一个时段。其结构已在前面描述过;
  • [appointment]:一个约会。其结构已在前面描述过;

示例:

URL
/getDoctorScheduleDay/1/2014-07-08
响应

{"status":0,"messages":null,"agenda":{"doctor":
{"id":1,"version":1,"title":"女士","lastName":"PELISSIER","firstName":"Marie"},
"day":1404770400000,"doctorDaySlots":[{"slot":
{"id":1,"version":1,"startHour":8,"startMinute":0,"endHour":8,"endMinute":20,"doctorId":1},
"appointment":{"id":45,"version":0,"date":"2014-07-08","client":
{"id":1,"version":1,"title":"先生","lastName":"MARTIN","firstName":"Jules"},
"时段":{"id":1,"version":1,"开始时":8,"开始分":0,"结束时":8,"结束分":20,"医生ID":1},
"clientId":1,"slotId":1}},{"slot":
{"id":2,"version":1,"startTime":8,"startMin":20,"endTime":8,"endMin":40,"doctorId":1},
"rv":null},{"slot":{"id":3,"version":1,"startTime":8,"startMin":40,"endTime":9,"endMin":0,"doctorId":1},
"rv":null},{"slot":{"id":4,"version":1,"startHour":9,"startMinute":0,"endHour":9,"endMinute":20,"doctorId":1},
"rv":null},{"slot":{"id":5,"version":1,"startHour":9,"startMinute":20,"endHour":9,"endMinute":40,"doctorId":1},
"rv":null},{"slot":{"id":6,"version":1,"startHour":9,"startMinute":40,"endHour":10,"endMinute":0,"doctorId":1},
"rv":null},{"slot":{"id":7,"version":1,"startHour":10,"startMinute":0,"endHour":10,"endMinute":20,"doctorId":1},
"rv":null},{"slot":{"id":8,"version":1,"startTime":10,"startMin":20,"endTime":10,"endMin":40,"doctorId":1},
"rv":null},{"slot":{"id":9,"version":1,"startTime":10,"startMin":40,"endTime":11,"endMin":0,"doctorId":1},
"rv":null},{"slot":{"id":10,"version":1,"startTime":11,"startMin":0,"endTime":11,"endMin":20,"doctorId":1},
"rv":null},{"slot":{"id":11,"version":1,"startTime":11,"startMin":20,"endTime":11,"endMin":40,"doctorId":1},
"rv":null},{"slot":{"id":12,"version":1,"startTime":11,"startMin":40,"endTime":12,"endMin":0,"doctorId":1},
"rv":null},{"slot":{"id":13,"version":1,"startTime":14,"startMin":0,"endTime":14,"endMin":20,"doctorId":1},
"rv":null},{"slot":{"id":14,"version":1,"startTime":14,"startMin":20,"endTime":14,"endMin":40,"doctorId":1},
"rv":null},{"slot":{"id":15,"version":1,"startTime":14,"startMin":40,"endTime":15,"endMin":0,"doctorId":1},
"rv":null},{"slot":{"id":16,"version":1,"startTime":15,"startMin":0,"endTime":15,"endMin":20,"doctorId":1},
"rv":null},{"slot":{"id":17,"version":1,"startTime":15,"startMin":20,"endTime":15,"endMin":40,"doctorId":1},
"rv":null},{"slot":
{"id":18,"version":1,"startTime":15,"startMin":40,"endTime":16,"endMin":0,"doctorId":1},
"rv":null},{"slot":{"id":19,"version":1,"startTime":16,"startMin":0,"endTime":16,"endMin":20,"doctorId":1},
"rv":null},{"slot":{"id":20,"version":1,"startTime":16,"startMin":20,"endTime":16,"endMin":40,"doctorId":1},
"rv":null},{"slot":{"id":21,"version":1,"startTime":16,"startMin":40,"endTime":17,"endMin":0,"doctorId":1},
"rv":null},{"slot":{"id":22,"version":1,"startTime":17,"startMin":0,"endTime":17,"endMin":20,"doctorId":1},
"rv":null},{"slot":
{"id":23,"version":1,"startTime":17,"startMin":20,"endTime":17,"endMin":40,"doctorId":1},
"rv":null},{"slot":
{"id":24,"version":1,"startTime":17,"startMin":40,"endTime":18,"endMin":0,"doctorId":1},
"rv":null}]}}

我们分别展示了时段内已有预约和没有预约的情况。

3.5.8. 按医生ID查询

URL
/getMedecinById/{idMedecin}
响应

Response<Doctor> :[int status ; List<String> messages ; Doctor doctor]
  • [doctorId]:医生的ID;

示例 1:

URL
/getDoctorById/1
响应
{"status":0,"messages":null,"doctor":
{"id":1,"version":1,"title":"Ms.",
"lastName":"PELISSIER","firstName":"Marie"}}

示例 2:

URL
/getMedecinById/100
响应
{"status":2,
"messages":["医生 [100] 不存在"],"doctor":null}

3.5.9. 通过 ID 获取客户

URL
/getClientById/{idClient}
响应

Response<Client> :[int status ; List<String> messages ;
 Client client]
  • [idClient]:客户端 ID;

示例 1:

URL
/getClientById/1
响应
{"status":0,"messages":null,"client":{"id":1,"version":1,"title":"Mr","lastName":"MARTIN","firstName":"Jules"}}

示例 2:

URL
/getClientById/100
响应
{"status":2,"messages":["客户端 [100] 不存在"],"client":null}

3.5.10. 使用您的 ID 预约时段

URL
/getCreneauById/{idCreneau}
响应

Response<Creneau> :[int status ; List<String> messages ; Creneau creneau]
  • [slotId]:插槽 ID;

示例 1:

URL
/getCreneauById/10
响应
{"status":0,"messages":null,"slot":
{"id":10,"version":1,"startHour":11,"startMinute":0,
"endTime":11,"endTime":20,"doctorId":1}}

请注意,响应中不包含该时段所属的医生,仅包含其ID。

示例 2:

URL
/getCreneauById/100
响应
{"status":2,"messages":["槽位 [100] 不存在"],
"slot":null}

3.5.11. 通过 ID 获取预约

URL
/getRvById/{idRv}
响应

Response<Rv> :[int status ; List<String> messages ; Rv rv]
  • [idRv]:预约ID;

示例 1:

网址
/getRvById/45
响应
{"status":0,"messages":null,"rv":{"id":45,"version":0,
"date":"2014-07-08","clientId":1,"slotId":1}}

请注意,响应中不包含客户或预约时段,仅包含其标识符。

示例 2:

URL
/getCreneauById/455
响应
{"status":2,"messages":["预约 [455] 不存在"],"rv":null}

3.5.12. 添加预约

URL [/addAppointment] 允许您添加预约。添加所需的信息(日期、时段和客户)通过 HTTP POST 请求发送。我们将演示如何使用 [Advanced Rest Client] 工具发送此请求。

Image

  • 在 [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 请求执行:

URL
/deleteAppointment
POST
{'appId':appId}
响应

Response<RV> :[int status; List<String> messages; Rv rv]

发送的值是类型为 [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:

URL
/deleteAppointment
POST
{"idRv":209}
响应
{"status":0,"messages":null,"rv":null}

已成功删除预约 #209,因为 [status=0]。

示例 2:

URL
/deleteAppointment
POST
{"appointmentId":650}
响应
{"status":2,"messages":["预约 [650] 不存在"],"rv":null}

3.6. Android 客户端

Image

既然服务器 [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. 视图

配置视图是应用程序启动时显示的视图:

Image

视觉界面的元素如下:

编号
类型
名称
1
编辑文本
edtUrlServiceRest
3
EditText
edtUser
5
EditText
edtPassword
2
TextView
txtErrorUrlServiceRest
3
TextView
txtUserError

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. 视图

主视图如下所示:

Image

视觉界面的元素如下:

编号
类型
名称
1
Spinner
spinnerDoctors
2
日期选择器
编辑预约

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] 方法;
  • 第 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. 视图

主屏幕如下所示:

Image

视觉界面的元素如下:

编号
类型
名称
1
TextView
txtTitle2
2
列表视图
slotList

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] 中的行:

Image

如上所示,显示效果取决于该时段是否有预约。 [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 行:构造函数接受四个参数:
    1. 当前的 Android 活动,
    2. 定义每个 [ListView] 元素内容的 XML 文件,
    3. 医生时段的数组,
    4. 视图本身;
  • 第 24 行:时间段数组按时间升序排序;

[getView] 方法负责生成与 [ListView] 中某一行对应的视图。该视图由三个元素组成:

 
不。
ID
类型
角色
1
txtCreneau
TextView
时间段
2
txtClient
TextView
客户端
3
btnValidate
TextView
添加/删除约会的链接

[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] 方法都将处理对链接的点击。该方法将接收两个参数:
    1. 被点击的时间槽编号,
    2. 被点击链接的标签;
  • 第 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] 方法;
  • 第 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. 视图

添加约会的视图如下:

Image

视觉界面的元素如下:

编号
类型
名称
1
TextView
txtTitle2
2
旋转木马
spinnerClients

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] 方法;
  • 第 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] 中更改片段顺序,并验证应用程序是否仍能正常运行;