Skip to content

3. 案例研究 - 预约管理

3.1. 项目

在文档 [Tutoriel AngularJS / Spring 4] 中,已开发了一个用于管理医生预约的客户端/服务器应用程序。后续我们将引用该文档 [rdvmedecins-angular]。该应用程序有两种类型的客户端:

  • HTML / CSS / JS客户端;
  • Android客户端;

Android客户端是通过[Cordova]工具,从HTML版本的客户端自动生成的。本项目的目标是利用前几章所学知识,手动重构该Android客户端。

需要注意的是,这两种方案存在一个重要区别:

  • 我们将创建的客户端仅适用于 Android 平板电脑;
  • 而在 [rdvmedecins-angular] 版本中,移动网页客户端 (HTML / CSS / JS)可在任何平台(Android、IoS、Windows)上使用;

3.2. Android客户端的视图

共有四个视图。

配置视图

Image

医生选择与预约日期视图

Image

预约时段选择视图

Image

预约客户选择界面

Image

3.3. 项目架构

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

Image

客户端与服务器之间的异步通信将通过库 RxAndroid 进行管理。

3.4. 数据库

在本文档中,数据库并不起核心作用。此处仅供参考。我们将它命名为 [dbrdvmedecins] 。这是一个 MySQL5 数据库,包含四个表:

  

3.4.1. 表 [MEDECINS]

该表包含由应用程序管理的医生信息[RdvMedecins]。

  • ID:医生标识号——该表的主键
  • VERSION:表中该行的版本标识号。每次对该行进行修改时,该数字都会增加1。
  • NOM:医生姓名
  • PRENOM:其名字
  • TITRE:其称谓(小姐、女士、先生)

3.4.2. 表 [CLIENTS]

各医生的患者信息存储在表 [CLIENTS] 中:

  • ID:客户标识号——该表的主键
  • VERSION:标识表中该行版本的编号。每次对该行进行修改时,该数字会递增1。
  • NOM:客户姓名
  • PRENOM:客户名字
  • TITRE:其称谓(小姐、女士、先生)

3.4.3. 表 [CRENEAUX]

该表列出了可使用 RV 的时间段:

  • ID:时间段标识号——表的主键(第8行)
  • VERSION:表中该行的版本标识号。每次对该行进行修改时,该数字会递增1。
  • ID_MEDECIN:标识该时段所属医生的编号——MEDECINS(ID)列的外键。
  • HDEBUT:时段开始时间
  • MDEBUT:时段开始分钟
  • HFIN:时段结束时间
  • MFIN:时段结束分钟

例如,表 [CRENEAUX] 的第二行(参见上文 [1])表明,第 2 号时段于 8 点 20 分开始,8 点 40 分结束,并归属于第 1 号医生 (玛丽女士 PELISSIER)。

3.4.4. 表 [RV]

该表列出了每位医生所分配的 RV:

  • ID:唯一标识RV的编号——主键
  • JOUR:RV的日期
  • ID_CRENEAU:RV的时间段——作为外键关联至[CRENEAUX]表中[ID]字段,同时确定时间段和相关医生。
  • ID_CLIENT:预订对象的客户编号——作为表[CLIENTS]中字段[ID]的外键

该表的 对关联列(JOUR、ID_CRENEAU)的值施加了唯一性约束:

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

如果表[RV]中某行在列 (JOUR、ID_CRENEAU)的值分别为(JOUR、ID_CRENEAU),则该值在其他任何地方都不应出现。 否则,这意味着同一时间针对同一位医生生成了两个 RV。从 Java 编程的角度来看,当这种情况发生时,数据库中的 JDBC 驱动程序会触发一个 SQLException

id 行值为 3(参见上文的 [1]),表示 2006 年 8 月 23 日第 20 个时段的第 4 号客户已预约了 RV。 [CRENEAUX]表显示,第20号时段对应16:20-16:40的时间段,由第1号医生(Marie PELISSIER女士)负责。 表[CLIENTS]显示,第4号客户是布里吉特·BISTROU小姐。

3.4.5. 数据库生成

要创建并填充这些表,可以使用示例存档 |ICI| 中提供的脚本 [dbrdvmedecins.sql]。

  

使用 [WampServer](参见第 6.15 节),可按以下步骤操作:

 
  • 在 [1] 中,点击 [WampServer] 的图标,并选择 [PhpMyAdmin] [2] 选项,
  • 在 [3] 中,在弹出的窗口中,选择链接 [Bases de données],
 
  • 在 [4-6] 中,导入文件 SQL,
  • 在 [7] 中,选择脚本 SQL,并在 [8] 中执行它,
  • 在 [9] 中,数据库表已创建。点击其中一个链接,
 
  • 在 [10] 中,显示表的内容。

此后,我们将不再提及该数据库,但建议读者在测试过程中关注其变化,尤其是在应用程序无法正常运行时。

3.5. Web 服务器 / jSON

Image

这里我们关注的是 [1] 服务器。我们不会在此详细展开说明。该服务器已在文档 [Spring MVC et Thymeleaf par l'exemple] 中详细介绍,感兴趣的读者可参阅该文档。它与示例 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

然后输入以下命令启动服务器(此时 SGBD 和 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

预期代码是字符串“用户名:密码”的 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] 中,用户(admin, admin)的安全标头 HTTP;
  • 在 [4] 中,发送请求 HTTP;

服务器的响应如下:

  • 在 [5] 中,格式化后的服务器响应 jSON;
  • 在 [6] 中,同一响应的原始状态;

[5] 格式有助于更清晰地查看响应结构。Web 服务的所有响应都是以下 [Response] 类的实例:


package rdvmedecins.android.dao.service;

import java.util.List;

public class Response<T> {

    // ----------------- 属性
    // 操作状态
    private int status;
    // 可能出现的错误信息
    private List<String> messages;
    // 响应正文
    private T body;

    // 构造函数
    public Response() {

    }

    public Response(int status, List<String> messages, T body) {
        this.status = status;
        this.messages = messages;
        this.body = body;
    }

    // 获取器和设置器
...
}
  • 第 9 行:响应状态。值为 0 表示未发生错误,否则表示发生错误;
  • 第 11 行:若发生错误,则显示错误消息列表;
  • 第 13 行:客户端实际期望的响应;

对 URL [/getAllMedecins] 的响应是类型为 [Response<List<Medecin>>] 的对象的字符串 jSON。 [Medecin] 类的定义如下:


package rdvmedecins.android.dao.entities;

public class Medecin extends Personne {

    // 默认构造函数
    public Medecin() {
    }

    // 带参数的构造函数
    public Medecin(String titre, String nom, String prenom) {
        super(titre, nom, prenom);
    }

    public String toString() {
        return String.format("Medecin[%s]", super.toString());
    }

}

第 3 行,类 [Medecin] 继承自以下类 [Personne]:


package rdvmedecins.android.dao.entities;

public class Personne extends AbstractEntity {
    // 人的属性
    private String titre;
    private String nom;
    private String prenom;

    // 默认构造函数
    public Personne() {
    }

    // 带参数的构造函数
    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);
    }

    // 获取器和设置器
    ...
}

第 3 行,类 [Personne] 继承自以下类 [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;
    }

    // 初始化
    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;
    }

    // getter 和 setter
    ...
}

最终,[Medecin] 对象的结构如下:


[Long id; Long version; String titre; String nom; String prenom;]

而 [Response<List<Medecin>>] 的结构如下:

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

接下来,我们将使用这些简化定义来描述服务器的响应。此外,在一段时间内,我们将不再展示屏幕截图。只需回顾我们刚刚所见的内容即可。当需要执行 POST 请求时,我们会再次使用屏幕截图。 我们还将以如下形式展示一个执行示例:

URL

/getAllMedecins
Réponse
{"status":0,"messages":null,"medecins":
[{"id":1,"version":1,"titre":"Mme","nom":"PELISSIER","prenom":"Marie"},
{"id":2,"version":1,"titre":"Mr","nom":"BROMARD","prenom":"Jacques"},
{"id":3,"version":1,"titre":"Mr","nom":"JANDOT","prenom":"Philippe"},
{"id":4,"version":1,"titre":"Melle","nom":"JACQUEMOT","prenom":"Justine"}]}

3.5.4. 客户列表

URL

/getAllClients
Réponse

Response<List<Client>> :[int status; List<String> messages;
 List<Client> clients]
Client : [Long id;  Long version; String titre;
 String nom; String prenom;]

示例:

URL

/getAllClients
Réponse
{"status":0,"messages":null,"clients":
[{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"},
{"id":2,"version":1,"titre":"Mme","nom":"GERMAN","prenom":"Christine"},
{"id":3,"version":1,"titre":"Mr","nom":"JACQUARD","prenom":"Jules"},
{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"}]}

3.5.5. 某医生诊疗时段列表

URL
/getAllCreneaux/{idMedecin}
Réponse

Response<List<Creneau>>:[int status ; List<String> messages ;
 List<Creneau> creneaux]
Creneau : [int hdebut ; int mdebut ; int hfin ; int mfin ;]
  • [idMedecin]:需要查询其就诊时段的医生的标识符;
  • [hdebut]:就诊开始时间;
  • [mdebut]:就诊开始的分钟数;
  • [hfin]:就诊结束时间;
  • [mfin]:就诊结束的分钟数;

对于10:20至10:40之间的时段,将得到 [hdebut, mdebut, hfin, mfin]=[10, 20, 10, 40]。

示例:

URL
/getAllCreneaux/1
Réponse
{"status":0,"messages":null,"creneaux":
[{"id":1,"version":1,"hdebut":8,"mdebut":0,"hfin":8,"mfin":20,"idMedecin":1},
{"id":2,"version":1,"hdebut":8,"mdebut":20,"hfin":8,"mfin":40,"idMedecin":1},
{"id":3,"version":1,"hdebut":8,"mdebut":40,"hfin":9,"mfin":0,"idMedecin":1},
{"id":4,"version":1,"hdebut":9,"mdebut":0,"hfin":9,"mfin":20,"idMedecin":1},
{"id":5,"version":1,"hdebut":9,"mdebut":20,"hfin":9,"mfin":40,"idMedecin":1},
{"id":6,"version":1,"hdebut":9,"mdebut":40,"hfin":10,"mfin":0,"idMedecin":1},
{"id":7,"version":1,"hdebut":10,"mdebut":0,"hfin":10,"mfin":20,"idMedecin":1},
{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"idMedecin":1},
{"id":9,"version":1,"hdebut":10,"mdebut":40,"hfin":11,"mfin":0,"idMedecin":1},
{"id":10,"version":1,"hdebut":11,"mdebut":0,"hfin":11,"mfin":20,"idMedecin":1},
{"id":11,"version":1,"hdebut":11,"mdebut":20,"hfin":11,"mfin":40,"idMedecin":1},
{"id":12,"version":1,"hdebut":11,"mdebut":40,"hfin":12,"mfin":0,"idMedecin":1},
{"id":13,"version":1,"hdebut":14,"mdebut":0,"hfin":14,"mfin":20,"idMedecin":1},
{"id":14,"version":1,"hdebut":14,"mdebut":20,"hfin":14,"mfin":40,"idMedecin":1},
{"id":15,"version":1,"hdebut":14,"mdebut":40,"hfin":15,"mfin":0,"idMedecin":1},
{"id":16,"version":1,"hdebut":15,"mdebut":0,"hfin":15,"mfin":20,"idMedecin":1},
{"id":17,"version":1,"hdebut":15,"mdebut":20,"hfin":15,"mfin":40,"idMedecin":1},
{"id":18,"version":1,"hdebut":15,"mdebut":40,"hfin":16,"mfin":0,"idMedecin":1},
{"id":19,"version":1,"hdebut":16,"mdebut":0,"hfin":16,"mfin":20,"idMedecin":1},
{"id":20,"version":1,"hdebut":16,"mdebut":20,"hfin":16,"mfin":40,"idMedecin":1},
{"id":21,"version":1,"hdebut":16,"mdebut":40,"hfin":17,"mfin":0,"idMedecin":1},
{"id":22,"version":1,"hdebut":17,"mdebut":0,"hfin":17,"mfin":20,"idMedecin":1},
{"id":23,"version":1,"hdebut":17,"mdebut":20,"hfin":17,"mfin":40,"idMedecin":1},
{"id":24,"version":1,"hdebut":17,"mdebut":40,"hfin":18,"mfin":0,"idMedecin":1}]}

3.5.6. 某位医生的预约列表

URL
/getRvMedecinJour/{idMedecin}/{jour}
Réponse

Response<List<Rv>>:[int status ; List<String> messages ;
 List<Rv> rvs]
Rv : [Date jour ; Client client ; Creneau creneau ;
 long idClient ; long idCreneau]
  • [idMedecin]:所需查询其预约信息的医生的标识符;
  • URL [jour]:预约日期,格式为“yyyy-mm-dd”;
  • 响应 [jour]:同上,但采用 Java 日期格式;
  • [client]:预约的客户。其结构已在前文描述;
  • [idClient]:客户标识符;
  • [creneau]:预约时段。其结构已在前文描述;
  • [idCreneau]:时段标识符;

示例:

URL
/getRvMedecinJour/1/2014-07-08
Réponse
{"status":0,"messages":null,
"rvs":[{"id":45,"version":0,"jour":"2014-07-08","client":
{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"},"creneau":
{"id":1,"version":1,"hdebut":8,"mdebut":0,"hfin":8,"mfin":20,"idMedecin":1},
"idClient":1,"idCreneau":1}]}

3.5.7. 医生的日程表

URL
/getAgendaMedecinJour/{idMedecin}/{jour}
Réponse

Response<AgendaMedecinJour>:[int status ; List<String> messages ;
 AgendaMedecinJour agenda]
AgendaMedecinJour : [Medecin medecin ;Date jour ; 
CreneauMedecinJour[] creneauxMedecinJour]
CreneauMedecinJour : [Creneau creneau ; Rv rv]
  • [idMedecin]:所需查询其预约信息的医生的标识符;
  • URL [jour]:预约日期,格式为“yyyy-mm-dd”;
  • [agenda]:医生的日程表;
  • [medecin]:相关医生。其结构已在前面定义;
  • 响应 [jour]:日程表中的日期,采用 Java 日期格式;
  • [creneauxMedecinJour]:一个包含 [CreneauMedecinJour] 类型元素的数组;
  • [creneau]:一个时段。其结构已在前文描述;
  • [rv]:一个约会。其结构已在前面描述过;

示例:

URL
/getAgendaMedecinJour/1/2014-07-08
Réponse

{"status":0,"messages":null,"agenda":{"medecin":
{"id":1,"version":1,"titre":"Mme","nom":"PELISSIER","prenom":"Marie"},
"jour":1404770400000,"creneauxMedecinJour":[{"creneau":
{"id":1,"version":1,"hdebut":8,"mdebut":0,"hfin":8,"mfin":20,"idMedecin":1},
"rv":{"id":45,"version":0,"jour":"2014-07-08","client":
{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"},
"creneau":{"id":1,"version":1,"hdebut":8,"mdebut":0,"hfin":8,"mfin":20,"idMedecin":1},
"idClient":1,"idCreneau":1}},{"creneau":
{"id":2,"version":1,"hdebut":8,"mdebut":20,"hfin":8,"mfin":40,"idMedecin":1},
"rv":null},{"creneau":{"id":3,"version":1,"hdebut":8,"mdebut":40,"hfin":9,"mfin":0,"idMedecin":1},
"rv":null},{"creneau":{"id":4,"version":1,"hdebut":9,"mdebut":0,"hfin":9,"mfin":20,"idMedecin":1},
"rv":null},{"creneau":{"id":5,"version":1,"hdebut":9,"mdebut":20,"hfin":9,"mfin":40,"idMedecin":1},
"rv":null},{"creneau":{"id":6,"version":1,"hdebut":9,"mdebut":40,"hfin":10,"mfin":0,"idMedecin":1},
"rv":null},{"creneau":{"id":7,"version":1,"hdebut":10,"mdebut":0,"hfin":10,"mfin":20,"idMedecin":1},
"rv":null},{"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"idMedecin":1},
"rv":null},{"creneau":{"id":9,"version":1,"hdebut":10,"mdebut":40,"hfin":11,"mfin":0,"idMedecin":1},
"rv":null},{"creneau":{"id":10,"version":1,"hdebut":11,"mdebut":0,"hfin":11,"mfin":20,"idMedecin":1},
"rv":null},{"creneau":{"id":11,"version":1,"hdebut":11,"mdebut":20,"hfin":11,"mfin":40,"idMedecin":1},
"rv":null},{"creneau":{"id":12,"version":1,"hdebut":11,"mdebut":40,"hfin":12,"mfin":0,"idMedecin":1},
"rv":null},{"creneau":{"id":13,"version":1,"hdebut":14,"mdebut":0,"hfin":14,"mfin":20,"idMedecin":1},
"rv":null},{"creneau":{"id":14,"version":1,"hdebut":14,"mdebut":20,"hfin":14,"mfin":40,"idMedecin":1},
"rv":null},{"creneau":{"id":15,"version":1,"hdebut":14,"mdebut":40,"hfin":15,"mfin":0,"idMedecin":1},
"rv":null},{"creneau":{"id":16,"version":1,"hdebut":15,"mdebut":0,"hfin":15,"mfin":20,"idMedecin":1},
"rv":null},{"creneau":{"id":17,"version":1,"hdebut":15,"mdebut":20,"hfin":15,"mfin":40,"idMedecin":1},
"rv":null},{"creneau":
{"id":18,"version":1,"hdebut":15,"mdebut":40,"hfin":16,"mfin":0,"idMedecin":1},
"rv":null},{"creneau":{"id":19,"version":1,"hdebut":16,"mdebut":0,"hfin":16,"mfin":20,"idMedecin":1},
"rv":null},{"creneau":{"id":20,"version":1,"hdebut":16,"mdebut":20,"hfin":16,"mfin":40,"idMedecin":1},
"rv":null},{"creneau":{"id":21,"version":1,"hdebut":16,"mdebut":40,"hfin":17,"mfin":0,"idMedecin":1},
"rv":null},{"creneau":{"id":22,"version":1,"hdebut":17,"mdebut":0,"hfin":17,"mfin":20,"idMedecin":1},
"rv":null},{"creneau":
{"id":23,"version":1,"hdebut":17,"mdebut":20,"hfin":17,"mfin":40,"idMedecin":1},
"rv":null},{"creneau":
{"id":24,"version":1,"hdebut":17,"mdebut":40,"hfin":18,"mfin":0,"idMedecin":1},
"rv":null}]}}

我们分别突出了该时段内有预约和没有预约的情况。

3.5.8. 通过医生ID查找医生

URL
/getMedecinById/{idMedecin}
Réponse

Response<Medecin> :[int status ; List<String> messages ; Medecin medecin]
  • [idMedecin]:医生的ID;

示例 1:

URL
/getMedecinById/1
Réponse
{"status":0,"messages":null,"medecin":
{"id":1,"version":1,"titre":"Mme",
"nom":"PELISSIER","prenom":"Marie"}}

示例 2:

URL
/getMedecinById/100
Réponse
{"status":2,
"messages":["Médecin [100] inexistant"],"medecin":null}

3.5.9. 通过用户 ID 获取客户

URL
/getClientById/{idClient}
Réponse

Response<Client> :[int status ; List<String> messages ;
 Client client]
  • [idClient]:客户标识符;

示例 1:

URL
/getClientById/1
Réponse
{"status":0,"messages":null,"client":{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"}}

示例 2:

URL
/getClientById/100
Réponse
{"status":2,"messages":["Client [100] inexistant"],"client":null}

3.5.10. 通过标识符获取时段

URL
/getCreneauById/{idCreneau}
Réponse

Response<Creneau> :[int status ; List<String> messages ; Creneau creneau]
  • [idCreneau]:时段的标识符;

示例 1:

URL
/getCreneauById/10
Réponse
{"status":0,"messages":null,"creneau":
{"id":10,"version":1,"hdebut":11,"mdebut":0,
"hfin":11,"mfin":20,"idMedecin":1}}

请注意,在回复中并未显示该时段所属的医生,仅显示了其标识符。

示例 2:

URL
/getCreneauById/100
Réponse
{"status":2,"messages":["Créneau [100] inexistant"],
"creneau":null}

3.5.11. 通过用户名预约

URL
/getRvById/{idRv}
Réponse

Response<Rv> :[int status ; List<String> messages ; Rv rv]
  • [idRv]:预约的标识符;

示例 1:

URL
/getRvById/45
Réponse
{"status":0,"messages":null,"rv":{"id":45,"version":0,
"jour":"2014-07-08","idClient":1,"idCreneau":1}}

请注意,在响应中既没有客户信息,也没有预约时段,只有他们的标识符。

示例 2:

URL
/getCreneauById/455
Réponse
{"status":2,"messages":["Rv [455] inexistant"],"rv":null}

3.5.12. 添加预约

URL [/ajouterRv] 用于添加预约。 添加所需的信息(日期、时段和客户)通过 HTTP POST 请求传输。我们将演示如何使用 [Advanced Rest Client] 工具执行此请求。

Image

  • 在 [1] 中,查询对象为 URL;
  • 在 [2] 中,该请求由 POST 发起;
  • 在 [3-4] 中,向服务器明确指出,向其提交的值是以 jSON 字符串的形式发送的;
  • 在 [4] 中,包含身份验证的 HTTP 头部;
  • 在 [5] 中,包含由 POST 传递的信息。这是一个 jSON 字符串,其中包含:
    • [jour]:预约日期,格式为“yyyy-mm-dd”;
    • [idClient]:预约对象的客户ID,
    • [idCreneau]:预约时段的标识符。由于一个时段属于特定的医生,因此该标识符同时也代表该医生;
  • 在 [6] 中,发送该请求;

所发送的字符串 jSON 是以下类型为 [PostAjouterRv] 的对象


public class PostAjouterRv {

  // 帖子数据
  private String jour;
  private long idClient;
  private long idCreneau;

  // 构造函数
  public PostAjouterRv() {

  }

  public PostAjouterRv(String jour, long idCreneau, long idClient) {
    this.jour = jour;
    this.idClient = idClient;
    this.idCreneau = idCreneau;
  }

  // getter 和 setter
  ...
}

服务器的响应类型为 [Response<Rv>] [int status; List<String> messages; Rv rv],其中 [rv] 即为新增的预约。

服务器对上述请求的响应如下:

 

需要注意的是,虽然某些信息未直接显示在 [idClient, idCreneau] 中,但可在 [client] 和 [creneau] 字段中找到。关键信息是新增约会的标识符(209)。 Web服务本可以仅返回这一条信息。

3.5.13. 删除预约

此操作同样通过 POST 实现:

URL
/supprimerRv
POST
{'idRv':idRv}
Réponse

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

已发布的值是以下类型为 [PostSupprimerRv] 的对象的字符串 jSON:


public class PostSupprimerRv {

  // 提交数据
  private long idRv;

  // 构造函数
  public PostSupprimerRv() {

  }

  public PostSupprimerRv(long idRv) {
    this.idRv = idRv;
  }

  // getter 和 setter
  ...
}
  • 第 4 行,[idRv] 是待删除约会的标识符。

示例 1:

URL
/supprimerRv
POST
{"idRv":209}
Réponse
{"status":0,"messages":null,"rv":null}

编号为 209 的预约已成功取消,因为 [status=0]。

示例 2:

URL
/supprimerRv
POST
{"idRv":650}
Réponse
{"status":2,"messages":["Rv [650] inexistant"],"rv":null}

3.6. Android客户端

Image

既然 [1] 服务器已详细介绍并投入运行,接下来我们将研究 Android 客户端 [2]。

3.6.1. Android Studio 项目架构

该项目沿用了 [client-android-skel] 项目的架构(参见第 1.17 节)。在上述 Android 客户端架构中,可区分出三个模块:

  • 负责与Web服务通信的[DAO]层;
  • [vues] 层,负责与用户进行交互;
  • [activité] 模块,用于连接前两个模块。视图层并不知晓 [DAO] 层,它们仅与 Activity 进行通信。

该架构在 Android 客户端的 Android Studio 项目中得到了体现:

 
  • 包 [activity] 实现了该 Activity;
  • 包 [architecture] 采用了我们之前开发的架构组件;
  • 包 [dao] 实现了 [DAO] 层;
  • 包 [fragments] 实现了 [vues];

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 {

  // 会话访问
  ISession getSession();

  // 视图切换
  void navigateToView(int position, ISession.Action action);

  // 等待管理
  void beginWaiting();

  void cancelWaiting();

  // 应用程序常量 -------------------------------------

  // 调试模式
  boolean IS_DEBUG_ENABLED = true;

  // 服务器响应的最大等待时间
  int TIMEOUT = 1000;

  // 执行客户端请求前的等待时间
  int DELAY = 000;

  // 基本身份验证
  boolean IS_BASIC_AUTHENTIFICATION_NEEDED = true;

  // 片段邻接性
  int OFF_SCREEN_PAGE_LIMIT = 1;

  // 标签栏
  boolean ARE_TABS_NEEDED = false;

  // 加载图片
  boolean IS_WAITING_ICON_NEEDED = true;

  // 应用程序片段数量
  int FRAGMENTS_COUNT = 4;

  // 浏览次数
  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 {
  // 片段是否已被访问
  protected boolean hasBeenVisited = false;
  // 片段菜单的状态(如有)
  protected MenuItemState[] menuOptionsState;

  // 获取器和设置器
...
}
  • 第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 {
  // 无法序列化为 jSON 的元素必须带有 @JsonIgnore 注解

  // 医生列表
  private List<Medecin> médecins;
  // 客户列表
  private List<Client> clients;
  // 某医生某天的日程表
  private AgendaMedecinJour agenda;
  // 日程表中被点击元素的位置
  private int position;
  // 预约日期(英文格式“yyyy-MM-dd”)
  private String dayRv;
  // 法语日期格式“dd-MM-yyyy”的预约日期
  private String jourRv;

  // 获取器和设置器
...
}
  • 第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);

  // 医生名单
  @Get("/getAllMedecins")
  public Response<List<Medecin>> getAllMedecins();

  // 客户列表
  @Get("/getAllClients")
  public Response<List<Client>> getAllClients();

  // 医生时段列表
  @Get("/getAllCreneaux/{idMedecin}")
  public Response<List<Creneau>> getAllCreneaux(@Path long idMedecin);

  // 医生预约列表
  @Get("/getRvMedecinJour/{idMedecin}/{jour}")
  public Response<List<Rv>> getRvMedecinJour(@Path long idMedecin, @Path String jour);

  // 客户
  @Get("/getClientById/{id}")
  public Response<Client> getClientById(@Path long id);

  // 医生
  @Get("/getMedecinById/{id}")
  public Response<Medecin> getMedecinById(@Path long id);

  // 预约
  @Get("/getRvById/{id}")
  public Response<Rv> getRvById(@Path long id);

  // 时段
  @Get("/getCreneauById/{id}")
  public Response<Creneau> getCreneauById(@Path long id);

  // 添加一个 RV
  @Post("/ajouterRv")
  public Response<Rv> ajouterRv(@Body PostAjouterRv post);

  // 删除预约
  @Post("/supprimerRv")
  public Response<Rv> supprimerRv(@Body PostSupprimerRv post);

  // 获取医生日程表
  @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 服务的 URL
  public void setUrlServiceWebJson(String url);

  // 用户
  public void setUser(String user, String mdp);

  // 客户端超时
  public void setTimeout(int timeout);

  // 客户列表
  public Observable<List<Client>> getAllClients();

  // 医生列表
  public Observable<List<Medecin>> getAllMedecins();

  // 医生时段列表
  public Observable<List<Creneau>> getAllCreneaux(long idMedecin);

  // 某医生在特定日期的预约列表
  public Observable<List<Rv>> getRvMedecinJour(long idMedecin, String jour);

  // 按ID查找客户
  public Observable<Client> getClientById(long id);

  // 按ID查找医生
  public Observable<Medecin> getMedecinById(long id);

  // 根据ID查找预约
  public Observable<Rv> getRvById(long id);

  // 根据ID查找时间段
  public Observable<Creneau> getCreneauById(long id);

  // 添加一个 RV
  public Observable<Rv> ajouterRv(String jour, long idCreneau, long idClient);

  // 删除一个 RV
  public Observable<Rv> supprimerRv(long idRv);

  // 业务
  public Observable<AgendaMedecinJour> getAgendaMedecinJour(long idMedecin, String jour);

  // 调试模式
  void setDebugMode(boolean isDebugEnabled);
}
  • 第 10 行:用于设置 Web 服务 URL / jSON;
  • 第13行:用于设置客户端/服务器通信的用户。[user]是用户ID,[mdp]是其密码;
  • 第16行:用于设定服务器响应的最大超时时间;
  • 第18-49行:Web服务暴露的每个URL都对应一个方法。它们采用了AA组件中同名方法的签名;
  • 第 52 行:用于控制 [DAO] 层的 debug 模式;

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 服务客户端
  @RestService
  protected WebClient webClient;
  // 安全性
  @Bean
  protected MyAuthInterceptor authInterceptor;
  // RestTemplate
  private RestTemplate restTemplate;
  // 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));
    }
    // 身份验证拦截器?
    if (isBasicAuthentificationNeeded) {
      // 添加身份验证拦截器
      List<ClientHttpRequestInterceptor> interceptors = new ArrayList<ClientHttpRequestInterceptor>();
      interceptors.add(authInterceptor);
      restTemplate.setInterceptors(interceptors);
    }

  }

  // 私有方法 -------------------------------------------------
  private void log(String message) {
    if (isDebugEnabled) {
      Log.d(className, message);
    }
  }

  // 接口的实现 IDao --------------------------------------------------------------------
  @Override
  public Observable<Response<List<Client>>> getAllClients() {
    // 日志
    log("getAllClients");
    // 结果
    return getResponse(new IRequest<Response<List<Client>>>() {
      @Override
      public Response<List<Client>> getResponse() {
        return webClient.getAllClients();
      }
    });
  }

  @Override
  public Observable<Response<List<Medecin>>> getAllMedecins() {
    // 日志
    log("getAllMedecins");
    // 结果
    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("getAllCreneaux");
    // 结果
    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("getRvMedecinJour");
    // 结果
    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("getClientById");
    // 结果
    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("getMedecinById");
    // 结果
    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("getRvById");
    // 结果
    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("getCreneauById");
    // 结果
    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("ajouterRv");
    // 结果
    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("supprimerRv");
    // 结果
    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("getAgendaMedecinJour");
    // 结果
    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 {

  // 用户
  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]方法接收三个参数;
    • [HtpRequest request]:被拦截的 HTTP 请求,
    • [byte[] body]:请求正文(如有,例如POST提交的值),
    • [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("getAgendaMedecinJour");
    // 结果
    return getResponse(new IRequest<Response<AgendaMedecinJour>>() {
      @Override
      public Response<AgendaMedecinJour> getResponse() {
        return webClient.getAgendaMedecinJour(idMedecin, jour);
      }
    });
}
  • 第 2 行:该方法需要两个参数:
    • [idMedecin]:所需查看日程表的医生ID;
    • [jour]:要查询日程表的日期;
  • 第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(value = "/getAgendaMedecinJour/{idMedecin}/{jour}")
Response<AgendaMedecinJour> getAgendaMedecinJour(@Path long idMedecin, @Path String jour);

第 9 行使用的参数是传递给第 2 行方法 [getAgendaMedecinJour] 的参数。因此,这些参数必须具有 final 属性;

3.6.4. 活动 [MainActivity]

Serveur
  

类 [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 {

  // 层[DAO]
  @Bean(Dao.class)
  protected IDao dao;

  // 家长班 ---------------------------------------
  @Override
  protected void onCreateActivity() {
    // 日志
    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;
  }

  // 界面 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 {
  // 医生列表
  private List<Medecin> médecins;
  // 客户列表
  private List<Client> clients;
  // 日程表
  private AgendaMedecinJour agenda;
  // 日历中被点击项的位置
  private int position;
  // 预约日期(英文格式“yyyy-MM-dd”)
  private String dayRv;
  // 法语日期格式“dd-MM-yyyy”的预约日期
  private String jourRv;


  // 获取器和设置器
...
}
  • 第 10 行:类 [Session] 是 AA 组件的实例,仅实例化一个;
  • 第12-15行:在本案例研究中,假设医生列表和客户列表保持不变。这些数据将在应用程序启动时获取,并存储在会话中以便片段使用;
  • 第20-23行:预约的日期。该日期以两种形式处理:在Android客户端中采用法语格式(第23行),与服务器通信时采用英语格式(第21行);
  • 第19行:日历上被点击元素(添加/删除链接)的位置;

3.6.6. 配置视图管理

3.6.6.1. 视图

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

Image

视觉界面的元素如下:

Type
Nom
1
EditText
edtUrlServiceRest
3
EditText
edtUtilisateur
5
EditText
edtMdp
2
TextView
txtErrorUrlServiceRest
3
TextView
txtErrorUtilisateur

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 {

  // 视觉界面的元素
  @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;

  // 输入字段
  private String urlServiceRest;
  private String utilisateur;
  private String mdp;

  // 页面验证
  @OptionsItem(R.id.actionValider)
  protected void doValider() {
   ...
  }
..
  // 父类方法的实现 -------------------------------------------
 ...

}
  • 第 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 行:表单中的三个输入框;

点击菜单选项 [Valider] 由方法 [doValider] 处理:


// 页面验证
  @OptionsItem(R.id.actionValider)
  protected void doValider() {
    // 隐藏之前的错误信息
    txtErrorUrlServiceRest.setVisibility(View.INVISIBLE);
    txtErrorUtilisateur.setVisibility(View.INVISIBLE);
    // 验证输入数据的有效性
    if (!isPageValid()) {
      return;
    }
    // 填写Web服务的URL
    mainActivity.setUrlServiceWebJson(urlServiceRest);
    // 向用户反馈
    mainActivity.setUser(utilisateur, mdp);
    // 开始等待——将启动 2 个异步任务
    beginWaiting(2);
    // 医生
    executeInBackground(mainActivity.getAllMedecins(), new Action1<Response<List<Medecin>>>() {
      @Override
      public void call(Response<List<Medecin>> responseMedecins) {
        // 处理响应
        consumeMedecins(responseMedecins);
      }
    });
    // 客户
    executeInBackground(mainActivity.getAllClients(), new Action1<Response<List<Client>>>() {
      @Override
      public void call(Response<List<Client>> responseClients) {
        // 处理响应
        consumeClients(responseClients);
      }
    });
  }


  private void consumeMedecins(Response<List<Medecin>> responseMedecins) {
    // 日志
    if (isDebugEnabled) {
      Log.d(className, "consume médecins");
    }
    // 错误?
    if (responseMedecins.getStatus() != 0) {
      // 消息
      showAlert(responseMedecins.getMessages());
      // 取消
      doAnnuler();
      // 返回 UI
      return;
    }
    // 在会话中保存医生信息
    session.setMédecins(responseMedecins.getBody());
  }

  private void consumeClients(Response<List<Client>> responseClients) {
    // 日志
    if (isDebugEnabled) {
      Log.d(className, "consume clients");
    }
    // 错误?
    if (responseClients.getStatus() != 0) {
      // 消息
      showAlert(responseClients.getMessages());
      // 取消
      doAnnuler();
      // 返回UI
      return;
    }
    // 将客户信息存储在会话中
    session.setClients(responseClients.getBody());
  }
  • 第 8-10 行:测试表单中三个输入项的有效性。如果表单无效,则不再继续处理;
  • 第 11-14 行:将 [DAO] 层所需的输入传递给该活动;
  • 第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() {
    // 验证输入数据的有效性
    boolean erreur;
    URI service;
    // URL 服务的有效性 REST
    urlServiceRest = String.format("http://%s", edtUrlServiceRest.getText().toString().trim());
    try {
      service = new URI(urlServiceRest);
      erreur = service.getHost() == null || service.getPort() == -1;
    } catch (Exception ex) {
      // 记录错误
      erreur = true;
    }
    if (erreur) {
      // 显示错误
      txtErrorUrlServiceRest.setVisibility(View.VISIBLE);
    }
    // 用户
    utilisateur = edtUtilisateur.getText().toString().trim();
    if (utilisateur.length() == 0) {
      // 显示错误
      txtErrorUtilisateur.setVisibility(View.VISIBLE);
      // 记录错误
      erreur = true;
    }
    // 密码
    mdp = edtMdp.getText().toString().trim();
    // 返回
    return !erreur;
}

方法 [beginWaiting](第16行)如下:


  // 开始等待
  protected void beginWaiting(int numberOfRunningTasks) {
    // 正在准备任务启动
    beginRunningTasks(numberOfRunningTasks);
    // 按钮和菜单状态
    setAllMenuOptionsStates(false);
    setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.menuActions, true),new MenuItemState(R.id.actionAnnuler, true)});

}
  • 第4行:通知父任务将启动[numberOfRunningTasks]任务;
  • 第 6 行:将菜单中的所有选项设为不可见;
  • 第 7 行:随后将 [Actions/Annuler] 选项设为可见;

点击菜单选项 [Annuler] 由方法 [doAnnuler] 处理:


  @OptionsItem(R.id.actionAnnuler)
  protected void doAnnuler() {
    if (isDebugEnabled) {
      Log.d(className, "Annulation demandée");
    }
    // 取消异步任务
    cancelRunningTasks();
}
  • 第 8 行:要求父类取消异步任务;

3.6.6.3. 片段生命周期管理

片段具有以下状态 [ConfigFragmentState]:


package client.android.fragments.state;

import client.android.architecture.custom.CoreState;

public class ConfigFragmentState extends CoreState {

  // 两个错误消息的可见性
  private boolean txtErrorUrlServiceRestVisible;
  private boolean txtErrorUtilisateurVisible;

  // getter 和 setter
...
}
  • 当父类要求时,片段将保存其两个错误消息的可见性;

片段的生命周期实现如下:


// 父类方法的实现 -------------------------------------------
  @Override
  public CoreState saveFragment() {
    // 保存片段状态
    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) {
      // 首次访问
      // 缓存错误消息
      txtErrorUtilisateur.setVisibility(View.INVISIBLE);
      txtErrorUrlServiceRest.setVisibility(View.INVISIBLE);
      // 菜单
      initMenu();
    }
  }

  @Override
  protected void updateOnSubmit(CoreState previousState) {
  }

  @Override
  protected void updateOnRestore(CoreState previousState) {
    // 恢复错误消息的可见性
    ConfigFragmentState state = (ConfigFragmentState) previousState;
    // 非首次访问 - 恢复显示错误信息
    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) {
    // 菜单
    initMenu();
    // 下一视图?
    if (!runningTasksHaveBeenCanceled) {
      mainActivity.navigateToView(IMainActivity.VUE_ACCUEIL, ISession.Action.SUBMIT);
    }
  }

  // 私有方法 ------------------------------------------------
  private void initMenu(){
    // 菜单状态
    setAllMenuOptionsStates(true);
    setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.actionAnnuler, false)});
}
  • 第2-9行:当父类请求时,片段会保存其两个错误消息的状态;
  • 第11-14行:片段编号为[IMainActivity.VUE_CONFIG];
  • 第 16-19 行:在片段首次生成时(previousState==null)或后续重新生成时(previousState !=null)执行。此处无需执行任何操作;
  • 第21-31行:在与片段关联的视图首次构建时(previousState==null)或后续重建时(previousState !=null)执行;
    • 第24-29行:首次访问时,隐藏错误消息并显示不含操作[Annuler]的菜单(第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

视觉界面的元素如下:

Type
Nom
1
Spinner
spinnerMedecins
2
DatePicker
edtJourRv

3.6.7.2. 片段

欢迎页面由以下片段 [AccueilFragment] 管理:

 

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 {

  // 视觉界面的元素
  @ViewById(R.id.spinnerMedecins)
  protected Spinner spinnerMedecins;
  @ViewById(R.id.edt_JourRv)
  protected DatePicker edtJourRv;

  // 本地数据
  private List<Medecin> medecins;
  private Calendar calendrier;
  private String[] spinnerMedecinsDataSource;

  // 页面验证
  @OptionsItem(R.id.actionValider)
  protected void doValider() {
    ...
  }
...

  // 父类方法的实现 -------------------------------------
...
}
  • 第 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 行:医生下拉列表的数据源;

点击链接 [Valider] 由以下方法 [doValider] 处理:


// 页面验证
  @OptionsItem(R.id.actionValider)
  protected void doValider() {
    // 记录所选医生的ID
    Long idMedecin = medecins.get(spinnerMedecins.getSelectedItemPosition()).getId();
    // 将日期存储在会话中
    String jourRv = String.format(new Locale("Fr-fr"), "%02d-%02d-%04d", edtJourRv.getDayOfMonth(), edtJourRv.getMonth() + 1, edtJourRv.getYear());
    session.setJourRv(jourRv);
    // 转换为 yyyy-MM-dd 日期格式
    String dayRv = String.format(new Locale("Fr-fr"), "%04d-%02d-%02d", edtJourRv.getYear(), edtJourRv.getMonth() + 1, edtJourRv.getDayOfMonth());
    session.setDayRv(dayRv);
    // 开始等待 - 将启动 1 个异步任务
    beginWaiting(1);
    // 查询医生的日程表
    executeInBackground(mainActivity.getAgendaMedecinJour(idMedecin, dayRv), new Action1<Response<AgendaMedecinJour>>() {

      @Override
      public void call(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
        // 处理响应
        consumeAgenda(responseAgendaMedecinJour);
      }
    });
  }

  private void consumeAgenda(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
    // 错误?
    if (responseAgendaMedecinJour.getStatus() != 0) {
      // 消息
      showAlert(responseAgendaMedecinJour.getMessages());
      // 取消
      doAnnuler();
      // 返回 UI
      return;
    }
    // 将日程表放入会话
    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 行)如下:


  // 开始等待
  protected void beginWaiting(int numberOfRunningTasks) {
    // 准备启动任务
    beginRunningTasks(numberOfRunningTasks);
    // 按钮和菜单的状态
    setAllMenuOptionsStates(false);
    setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.menuActions, true),new MenuItemState(R.id.actionAnnuler, true)});

}
  • 第 4 行:通知父任务将启动 [numberOfRunningTasks] 任务;
  • 第6行:将菜单中的所有选项设为不可见;
  • 第 7 行:随后将 [Actions/Annuler] 选项设为可见;

点击菜单选项 [Annuler] 由方法 [doAnnuler] 处理:


  @OptionsItem(R.id.actionAnnuler)
  protected void doAnnuler() {
    if (isDebugEnabled) {
      Log.d(className, "Annulation demandée");
    }
    // 取消异步任务
    cancelRunningTasks();
}
  • 第 8 行:请求父类取消异步任务;

点击菜单选项 [Retour à la configuration] 的处理方式如下:


  @OptionsItem(R.id.navigationToConfig)
  protected void navigationToConfig() {
    // 导航至配置视图
    mainActivity.navigateToView(IMainActivity.VUE_CONFIG, ISession.Action.NAVIGATION);
}
  • 第 4 行:通过操作 [NAVIGATION] 导航至配置视图。这意味着希望将配置视图恢复到上次离开时的状态;

3.6.7.3. 片段生命周期管理

该片段具有以下状态:[AccueilFragmentState]:


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 {

  // 片段状态 [Accueil]
  // 所选医生的职位
  private int selectedMedecinPosition;
  // 所选日期
  private int year;
  private int month;
  private int dayOfMonth;
  // 医生下拉菜单的数据源
  private String[] spinnerMedecinsDataSource;

  // 构造函数
  public AccueilFragmentState() {

  }

  // 获取器和设置器
...
}
  • 第 11 行:用于在医生列表中显示所选元素;
  • 第13-15行:用于还原日历中选定的日期;
  • 第 17 行:用于返回医生列表的数据源;

该片段的生命周期实现如下:


// 父类方法的实现 -------------------------------------
  @Override
  public CoreState saveFragment() {
    // 保存视图
    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) {
    // 从会话中获取医生
    medecins = session.getMédecins();
    // 首次就诊?
    if (previousState == null) {
      // 构建下拉列表显示的数组
      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 {
      // 非首次就诊
      AccueilFragmentState state = (AccueilFragmentState) previousState;
      spinnerMedecinsDataSource = state.getSpinnerMedecinsDataSource();
    }
    // 日历
    calendrier = Calendar.getInstance();
  }

  @Override
  protected void initView(CoreState previousState) {
    // 将医生下拉菜单与其数据源关联
    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);
    // 日历的起始日期设为今天
    edtJourRv.setMinDate(calendrier.getTimeInMillis());
    // 首次就诊?
    if (previousState == null) {
      // 菜单
      initMenu();
    }
  }

  @Override
  protected void updateOnSubmit(CoreState previousState) {
    // 菜单
    initMenu();
  }

  @Override
  protected void updateOnRestore(CoreState previousState) {
    // 恢复当前会话状态
    AccueilFragmentState state = (AccueilFragmentState) previousState;
    // 在医生下拉菜单中选择
    spinnerMedecins.setSelection(state.getSelectedMedecinPosition());
    // 日历
    edtJourRv.updateDate(state.getYear(), state.getMonth(), state.getDayOfMonth());
  }

  @Override
  protected void notifyEndOfUpdates() {
  }

  @Override
  protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
    // 在所有任务完成或取消后调用
    // 菜单状态
    initMenu();
    // 下一视图?
    if (!runningTasksHaveBeenCanceled) {
      mainActivity.navigateToView(IMainActivity.VUE_AGENDA, ISession.Action.SUBMIT);
    }
  }

  // 私有方法 ------------------------------------------------
  private void initMenu() {
    // 菜单状态
    setAllMenuOptionsStates(true);
    setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.actionAnnuler, false)});
  }
  • 第2-9行:当父类请求时,该片段会保存以下元素的状态:
    • 第 6 行:在医生列表中选中的位置;
    • 第7-9行:日历中选定日期的日期(日、月、年);
    • 第 10 行:医生下拉列表的数据源;
  • 第14-17行:片段编号为[IMainActivity.VUE_ACCUEIL];
  • 第19-39行:在片段首次生成时(previousState==null)或后续重新生成时(previousState !=null)执行;
    • 第25-31行:如果是首次访问,则构建医生选择器(spinner)的数据源;
    • 第33-35行:对于后续访问,从片段的上一状态中获取下拉列表的数据源;
  • 第 41-54 行:当片段关联的视图首次构建(previousState==null)或后续重建(previousState !=null)时执行;
    • 第50-53行:对于首次访问,显示不包含操作[Annuler]的菜单(第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

视觉界面的元素如下:

Type
Nom
1
TextView
txtTitre2
2
ListView
lstCreneaux

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 {

  // 视觉界面的元素
  @ViewById(R.id.txt_titre2_agenda)
  protected TextView txtTitre2;
  @ViewById(R.id.listViewAgenda)
  protected ListView lstCreneaux;

  // 片段显示的日程表
  private AgendaMedecinJour agenda;
  // 时段信息
  private int firstPosition;
  private int top;
  // 预约是否已删除
  private boolean rdvSupprimé;
  // 已添加或已删除时段的编号
  private int numCréneau;

  // 添加/删除后的日程表更新
  private void updateAgenda() {
  ...
  }

...

  // 父类方法的实现 ------------------------------------------------------
  ...
}
  • 第 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] 中:


  // 添加/删除后更新日程表
  private void updateAgenda() {
    // 日程表时段的(重新)生成
    // 日程表被纳入会话并存储在片段的字段中
    agenda = session.getAgenda();
    // 重新生成 ListView 中的时段
    ArrayAdapter<CreneauMedecinJour> adapter = new ListCreneauxAdapter(activity, R.layout.creneau_medecin,
      agenda.getCreneauxMedecinJour(), this);
    lstCreneaux.setAdapter(adapter);
    // 定位到 ListView 的正确位置
    lstCreneaux.setSelectionFromTop(firstPosition, top);
}
  • 第 5 行:从会话中获取日程表,并将其存储在片段的 [agenda] 字段中;
  • 第 7-9 行:定义组件适配器 [ListView]。该适配器同时定义了 [ListView] 的数据源及其每个元素的显示模板。我们将在后续内容中介绍该适配器;
  • 第11行:返回日程表的先前位置。实际上,当前仅显示了当天部分时段。若在最后一个时段中添加/删除一个约会,上述代码将刷新页面以显示新的日程表。 这种刷新会导致视图重新定位到第一个时段,这并非我们所期望的。第5行解决了这个问题。该解决方案的详细说明请参见URL和[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> {

    // 时间段表
    private CreneauMedecinJour[] creneauxMedecinJour;
    // 执行上下文
    private Context context;
    // 时间段列表中某行显示布局的ID
    private int layoutResourceId;
    // 点击监听器
    private AgendaFragment vue;

    // 构造函数
    public ListCreneauxAdapter(Context context, int layoutResourceId, CreneauMedecinJour[] creneauxMedecinJour,
            AgendaFragment vue) {
        super(context, layoutResourceId, creneauxMedecinJour);
        // 保存信息
        this.creneauxMedecinJour = creneauxMedecinJour;
        this.context = context;
        this.layoutResourceId = layoutResourceId;
        this.vue = vue;
        // 按时间顺序对时段数组进行排序
        Arrays.sort(creneauxMedecinJour, new MyComparator());
    }

    @Override
    public View getView(final int position, View convertView, ViewGroup parent) {
    ...
}

// 对时段表进行排序
class MyComparator implements Comparator<CreneauMedecinJour> {
...
    }
}
  • 第3行:类[ListCreneauxAdapter]必须扩展一个预定义的适配器,该适配器用于[ListView], 此处即类 [ArrayAdapter],顾名思义,该类向 [ListView] 提供一个对象数组,此处为类型 [CreneauMedecinJour]。回顾该实体的代码:

public class CreneauMedecinJour implements Serializable {

    private static final long serialVersionUID = 1L;
    // 字段
    private Creneau creneau;
    private Rv rv;
...  
}
  • 类 [CreneauMedecinJour] 包含一个时间段(第 5 行)和一个可能的预约(第 6 行),若无预约则为 null

回到类 [ListCreneauxAdapter] 的代码:

  • 第15行:构造函数接收四个参数:
    1. 当前的 Android 活动,
    2. 定义 [ListView] 中每个元素内容的文件 XML,
    3. 医生的时段表,
    4. 视图本身;
  • 第24行:时间段数组按时间升序排序;

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

 
Id
Type
Rôle
1
txtCreneau
TextView
créneau horaire
2
txtClient
TextView
le client
3
btnValider
TextView
lien pour ajouter / supprimer un rendez-vous

方法 [getView] 的代码如下:


@Override
    public View getView(final int position, View convertView, ViewGroup parent) {
        // 定位到正确的时段
        CreneauMedecinJour creneauMedecin = creneauxMedecinJour[position];
        // 创建行
        View row = ((Activity) context).getLayoutInflater().inflate(layoutResourceId, parent, false);
        // 时间段
        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()));
        // 客户
        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);
        // 链接
        final TextView btnValider = (TextView) row.findViewById(R.id.btn_Valider);
        if (creneauMedecin.getRv() == null) {
            // 添加
            btnValider.setText(R.string.btn_ajouter);
            btnValider.setTextColor(context.getResources().getColor(R.color.blue));
        } else {
            // 删除
            btnValider.setText(R.string.btn_supprimer);
            btnValider.setTextColor(context.getResources().getColor(R.color.red));
        }
        // 链接监听器
        btnValider.setOnClickListener(new OnClickListener() {

            @Override
            public void onClick(View v) {
                // 将信息传递给日历视图
                vue.doValider(position, btnValider.getText().toString());
            }
        });
        // 生成行
        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行:构建客户标识[2];
  • 第23行:如果该时段没有预约;
  • 第25-26行:生成蓝色链接[Ajouter];
  • 第29-30行:否则生成红色链接[Supprimer];
  • 第33-40行:无论链接[Ajouter / Supprimer]的性质如何,视图中的方法[doValider]都将处理对该链接的点击。该方法将接收两个参数:
    1. 被点击的时段编号,
    2. 被点击链接的标签;
  • 第42行:返回刚刚构建的行。

需要注意的是,处理链接的是片段 [AgendaFragment] 中的方法 [doValider]。该方法如下:


  // 点击链接 [Ajouter / Supprimer]
  public void doValider(int numCréneau, String texte) {
    // 操作正在进行中?
    if (numberOfRunningTasks != 0) {
      Toast.makeText(activity, "Une opération est en cours. Patientez ou Annulez...", Toast.LENGTH_SHORT).show();
      return;
    }
    // 记录滚动位置以便返回
    // 阅读 [http://stackoverflow.com/questions/3014089/maintain-save-restore-scroll-position-when-returning-to-a-listview]
    // 第一个元素是否完全可见的位置
    firstPosition = lstCreneaux.getFirstVisiblePosition();
    // 该元素相对于顶部的 Y 偏移量ListView
    // 测量可能被遮挡部分的高度
    View v = lstCreneaux.getChildAt(0);
    top = (v == null) ? 0 : v.getTop();
    // 同时记录被点击的区域编号
    this.numCréneau = numCréneau;
    // 根据链接文本,操作方式不同
    if (texte.equals(getResources().getString(R.string.lnk_ajouter))) {
      doAjouter();
    } else {
      doSupprimer();
    }
}
  • 方法 [doValider] 接收两项信息:
    • 被点击的时段编号;
    • 被点击链接的文本(添加/删除);
  • 第4-7行:如果存在正在运行的异步任务,则禁止点击[Supprimer / Ajouter]链接。这是为了简化代码编写而做出的设计选择,该设计可进一步探讨;
  • 第11-15行:将时间段ListView的相关信息(firstPosition, top)记录在片段的字段中,以便私有方法[updateAgenda]能以相同的滚动位置重新生成该时间段;
  • 第17行:记录被点击的时段编号;
  • 第19-23行:根据点击链接的文本内容,执行添加或删除操作;

3.6.8.2.2. 方法 [doSupprimer]

方法 [doSupprimer] 负责删除所点击时段的预约:


// 取消预约
  private void doSupprimer() {
    // 等待两项任务完成
    beginWaiting(2);
    // 在后台删除该预约
    rdvSupprimé = false;
    // 待删除预约的ID
    long idRv = agenda.getCreneauxMedecinJour()[numCréneau].getRv().getId();
    // 通过异步任务删除
    executeInBackground(mainActivity.supprimerRv(idRv), new Action1<Response<Rv>>() {

      @Override
      public void call(Response<Rv> responseRv) {
        // 处理结果
        consumeRv(responseRv);
      }
    });
  }

  // 响应处理
  private void consumeRv(Response<Rv> responseRv) {
    // 错误?
    if (responseRv.getStatus() != 0) {
      // 消息
      showAlert(responseRv.getMessages());
      // 取消
      doAnnuler();
      // 返回 UI
      return;
    }
    // 显示该预约已被删除
    rdvSupprimé = true;
    // 请求最新日程表
    executeInBackground(
      mainActivity.getAgendaMedecinJour(agenda.getMedecin().getId(), session.getDayRv()),
      new Action1<Response<AgendaMedecinJour>>() {

        @Override
        public void call(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
          // 处理响应
          consumeAgenda(responseAgendaMedecinJour);
        }
      });
  }

  // 获取日程
  private void consumeAgenda(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
    // 错误?
    if (responseAgendaMedecinJour.getStatus() != 0) {
      // 消息
      showAlert(responseAgendaMedecinJour.getMessages());
      // 取消
      doAnnuler();
      // 返回 UI
      return;
    }
    // 将日程表放入会话
    session.setAgenda(responseAgendaMedecinJour.getBody());
    // 更新视图中的日程表
    updateAgenda();
  }
  • 第4行:通知父类将启动两个异步任务,并开始等待这两个任务完成;
  • 第 8 行:获取待删除约会的标识符。因为服务器需要此信息;
  • 第 9-18 行:通过异步任务请求删除该预约;
    • 第10行:方法[executeInBackground]需要两个参数:
      • 第 10 行:待执行和观察的进程由方法 [mainActivity.supprimerRv(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 行)如下:


  // 开始等待
  protected void beginWaiting(int numberOfRunningTasks) {
    // 准备启动任务
    beginRunningTasks(numberOfRunningTasks);
    // 按钮和菜单的状态
    setAllMenuOptionsStates(false);
    setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.menuActions, true),new MenuItemState(R.id.actionAnnuler, true)});

}
  • 第4行:通知父任务将启动[numberOfRunningTasks]任务;
  • 第6行:将菜单中的所有选项设为不可见;
  • 第 7 行:随后将 [Actions/Annuler] 选项设为可见;

3.6.8.2.3. 方法 [doAnnuler]

点击菜单选项 [Annuler] 由方法 [doAnnuler] 处理:


  @OptionsItem(R.id.actionAnnuler)
  protected void doAnnuler() {
    if (isDebugEnabled) {
      Log.d(className, "Annulation demandée");
    }
    // 取消异步任务
    cancelRunningTasks();
}
  • 第 7 行:请求父类取消异步任务;

3.6.8.2.4. 菜单选项 [Retour à la configuration]

点击菜单选项 [Retour à la configuration] 的处理方式如下:


  @OptionsItem(R.id.navigationToConfig)
  protected void navigationToConfig() {
    // 导航至配置视图
    mainActivity.navigateToView(IMainActivity.VUE_CONFIG, ISession.Action.NAVIGATION);
}
  • 第4行:通过操作[NAVIGATION]跳转至配置视图。这意味着希望将配置视图恢复到上次离开时的状态;

3.6.8.2.5. 菜单选项 [Retour à l'accueil]

点击菜单选项 [Retour à l'accueil] 的处理方式类似:


  @OptionsItem(R.id.navigationToAccueil)
  protected void navigationToAccueil() {
    // 导航至主页视图
    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 {

  // 视图标题
  private String titre;
  // ListView
  private int firstPosition;
  private int top;

  // 构造函数
  public AgendaFragmentState() {

  }

  public AgendaFragmentState(String titre) {
    this.titre = titre;
  }

  // getter 和 setter
...
}
  • 第 10 行:视图顶部显示的标题;
  • 第 12-13 行:用于呈现医生预约时段的 scrolling(源自 ListView)

片段的生命周期实现如下:


// 父类方法的实现 ------------------------------------------------------
  @Override
  public CoreState saveFragment() {
    // 状态保存
    AgendaFragmentState state = new AgendaFragmentState();
    state.setTitre(txtTitre2.getText().toString());
    // 记录滚动位置以便返回
    // 阅读 [http://stackoverflow.com/questions/3014089/maintain-save-restore-scroll-position-when-returning-to-a-listview]
    // 第一个元素是否完全可见的位置
    firstPosition = lstCreneaux.getFirstVisiblePosition();
    // 该元素相对于顶部的 Y 偏移量ListView
    // 测量可能被遮挡部分的高度
    View v = lstCreneaux.getChildAt(0);
    top = (v == null) ? 0 : v.getTop();
    // 将所有这些信息保存下来
    state.setTop(top);
    state.setFirstPosition(firstPosition);
    return state;
  }

  @Override
  protected int getNumView() {
    return IMainActivity.VUE_AGENDA;
  }

  @Override
  protected void initFragment(CoreState previousState) {
    // 首次访问?
    if (previousState != null) {
      // 不是首次访问
      AgendaFragmentState state = (AgendaFragmentState) previousState;
      // 以及 ListView 的信息
      firstPosition = state.getFirstPosition();
      top = state.getTop();
    }
  }

  @Override
  protected void initView(CoreState previousState) {
  }

  @Override
  protected void updateOnSubmit(CoreState previousState) {
    // 获取日程表
    agenda = session.getAgenda();
    // 生成页面标题
    Medecin medecin = agenda.getMedecin();
    txtTitre2.setText(String.format("Rendez-vous de %s %s %s le %s", medecin.getTitre(), medecin.getPrenom(),
      medecin.getNom(), session.getJourRv()));
    // 菜单状态
    initMenu();
  }

  @Override
  protected void updateOnRestore(CoreState previousState) {
    // 重新生成页面标题
    AgendaFragmentState state = (AgendaFragmentState) previousState;
    txtTitre2.setText(state.getTitre());
  }

  @Override
  protected void notifyEndOfUpdates() {
    // 重新生成时段列表
    updateAgenda();
  }

  @Override
  protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
    // 菜单状态
    initMenu();
    // 如果取消了预约但该预约已被删除,则需更新本地日历
    if (runningTasksHaveBeenCanceled && rdvSupprimé) {
      // 在本地日历中删除该预约(无法获取全局日历)
      agenda.getCreneauxMedecinJour()[numCréneau].setRv(null);
      // 更新视觉界面
      updateAgenda();
    }
  }


  // 私有方法 ------------------------------------------------
  private void initMenu() {
    // 菜单状态
    setAllMenuOptionsStates(true);
    setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.actionAnnuler, false)});
  }
  • 第2-19行:当父类发出请求时,片段会保存以下元素的状态:
    • 第 6 行:视图顶部显示的标题;
    • 第 7-17 行:用于还原 scrolling 信息(top, firstPosition);
  • 第21-24行:片段编号为[IMainActivity.VUE_AGENDA];
  • 第26-35行:在片段首次生成时(previousState==null)或后续重新生成时(previousState !=null)执行;
    • 第30-34行:如果这不是对该片段的首次访问,则获取信息(top, firstPosition),这些信息将用于从ListView中还原scrolling
  • 第38-40行:在与片段关联的视图首次构建时(previousState==null)或后续重建时(previousState !=null)执行。 此处无需执行任何操作,因为时间段的 ListView 将由私有方法 [updateAgenda](第 61-65 行)生成;
  • 第42-52行:当通过[SUBMIT]操作进入该片段时执行。此时来自视图[ACCUEIL];
    • 第 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

视觉界面的元素如下:

Type
Nom
1
TextView
txtTitre2
2
Spinner
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 {

  // 视觉界面的元素
  @ViewById(R.id.spinnerClients)
  protected Spinner spinnerClients;
  @ViewById(R.id.txt_titre2_ajoutRv)
  protected TextView txtTitre2;

  // 客户端
  private List<Client> clients;

  // 本地数据
  private Creneau creneau;
  private Medecin medecin;
  private boolean rdvAjouté;
  private Rv rv;
  private String[] spinnerClientsDataSource;

  // 页面验证
  @OptionsItem(R.id.actionValider)
  protected void doValider() {
   ...
  }
...

  // 父类方法的实现 ----------------------------------
...
}
  • 第 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 行:客户加载器(spinner)的数据源;

点击链接 [Valider] 由以下方法 [doValider] 处理:


  // 客户端
  private List<Client> clients;

  // 本地数据
  private Creneau creneau;
  private Medecin medecin;
  private boolean rdvAjouté;
  private Rv rv;
  private String[] spinnerClientsDataSource;
...
// 页面验证
  @OptionsItem(R.id.actionValider)
  protected void doValider() {
    // 获取选定的客户端
    Client client = clients.get(spinnerClients.getSelectedItemPosition());
    // 开始等待 2 个异步任务
    beginWaiting(2);
    // 添加 RV
    rdvAjouté = false;
    executeInBackground(
      mainActivity.ajouterRv(session.getDayRv(), creneau.getId(), client.getId()),
      new Action1<Response<Rv>>() {

        @Override
        public void call(Response<Rv> responseRv) {
          // 处理响应
          consumeRv(responseRv);
        }
      });
  }

  // 处理一个 Response<Rv> 对象
  void consumeRv(Response<Rv> responseRv) {
    // 错误?
    if (responseRv.getStatus() != 0) {
      // 消息
      showAlert(responseRv.getMessages());
      // 取消
      doAnnuler();
      // 返回 UI
      return;
    }
    // 记录已添加的预约
    rdvAjouté = true;
    // 已保存该预约
    this.rv = responseRv.getBody();
    // 请求新的日程表
    executeInBackground(mainActivity.getAgendaMedecinJour(session.getAgenda().getMedecin().getId(), session.getDayRv()), new Action1<Response<AgendaMedecinJour>>() {

      @Override
      public void call(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
        // 处理响应
        consumeAgenda(responseAgendaMedecinJour);
      }
    });
  }

  // 处理一个 Response<AgendaMedecinJour> 对象
  private void consumeAgenda(Response<AgendaMedecinJour> responseAgendaMedecinJour) {
    // 错误?
    if (responseAgendaMedecinJour.getStatus() != 0) {
      // 消息
      showAlert(responseAgendaMedecinJour.getMessages());
      // 取消
      doAnnuler();
      // 返回 UI
      return;
    }
    // 将日程表放入会话
    session.setAgenda(responseAgendaMedecinJour.getBody());
}
  • 第 13 行:当 [doValider] 方法开始执行时,字段 2、5、6 和 9 已在片段的生命周期中初始化。我们将看到具体实现方式;
  • 第 15 行:获取与客户端旋转按钮中选定项对应的 [Client] 实体;
  • 第 17 行:通知父类将启动两个异步任务,并准备进入等待状态;
  • 第19行:起初,该预约尚未添加到医生的日程表中;
  • 第20-30行:向服务器请求添加一个预约;
    • 第20行:方法[executeInBackground]需要两个参数:
      • 第 20 行:待执行和观察的进程由方法 [mainActivity.ajouterRv(session.getDayRv(), creneau.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行)如下:


  // 开始等待
  protected void beginWaiting(int numberOfRunningTasks) {
    // 准备启动任务
    beginRunningTasks(numberOfRunningTasks);
    // 按钮和菜单的状态
    setAllMenuOptionsStates(false);
    setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.menuActions, true),new MenuItemState(R.id.actionAnnuler, true)});

}
  • 第4行:告知父任务将启动[numberOfRunningTasks]任务;
  • 第 6 行:将菜单中的所有选项设为不可见;
  • 第 7 行:随后将 [Actions/Annuler] 选项设为可见;

点击菜单选项 [Annuler] 由方法 [doAnnuler] 处理:


  @OptionsItem(R.id.actionAnnuler)
  protected void doAnnuler() {
    if (isDebugEnabled) {
      Log.d(className, "Annulation demandée");
    }
    // 取消异步任务
    cancelRunningTasks();
}
  • 第7行:请求父类取消异步任务;

向后导航由以下三个方法实现:


  @OptionsItem(R.id.navigationToConfig)
  protected void navigationToConfig() {
    // 跳转至配置视图
    mainActivity.navigateToView(IMainActivity.VUE_CONFIG, ISession.Action.NAVIGATION);
  }

  @OptionsItem(R.id.navigationToAccueil)
  protected void navigationToAccueil() {
    // 导航至配置视图
    mainActivity.navigateToView(IMainActivity.VUE_ACCUEIL, ISession.Action.NAVIGATION);
  }

  @OptionsItem(R.id.navigationToAgenda)
  protected void navigationToAgenda() {
    // 导航至日历视图
    mainActivity.navigateToView(IMainActivity.VUE_AGENDA, ISession.Action.NAVIGATION);
}

3.6.9.3. 片段生命周期管理

片段具有以下状态 [AjoutRvFragmentState]:


package client.android.fragments.state;

import client.android.architecture.custom.CoreState;

// 片段状态 AjoutRvFragment
public class AjoutRvFragmentState  extends CoreState {

  // 所选客户的职位
  private int selectedClientPosition;
  // 视图标题
  private String titre;
  // 客户选择器数据源
  private String[] spinnerClientsDataSource;

  // 获取器和设置器
...
}

片段的生命周期实现如下:


// 父类方法的实现 ----------------------------------
  @Override
  public CoreState saveFragment() {
    // 视图保存
    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) {
    // 获取会话中的客户
    clients = session.getClients();
    // 首次访问?
    if (previousState == null) {
      // 构建旋转加载器显示的数组
      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 {
      // 非首次访问
      AjoutRvFragmentState state = (AjoutRvFragmentState) previousState;
      spinnerClientsDataSource = state.getSpinnerClientsDataSource();
    }
  }

  @Override
  protected void initView(CoreState previousState) {
    // 将加载器与数据源关联
    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);
    // 首次访问?
    if (previousState == null) {
      // 菜单
      initMenu();
    }
  }

  @Override
  protected void updateOnSubmit(CoreState previousState) {
    // 获取当前会话中待预订时段的编号
    int position = session.getPosition();
    // 在会话中获取医生的日程表
    AgendaMedecinJour agenda = session.getAgenda();
    // 获取将安排预约的医生及时段
    medecin = agenda.getMedecin();
    creneau = agenda.getCreneauxMedecinJour()[position].getCreneau();
    // 生成页面标题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()));
    // 选择客户
    spinnerClients.setSelection(0);
    // 菜单
    initMenu();
  }

  @Override
  protected void updateOnRestore(CoreState previousState) {
    // 恢复上一步状态
    AjoutRvFragmentState state = (AjoutRvFragmentState) previousState;
    // 标题
    txtTitre2.setText(state.getTitre());
    // 加载图标
    spinnerClients.setSelection(state.getSelectedClientPosition());
  }

  @Override
  protected void notifyEndOfUpdates() {
  }

  @Override
  protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
    // 菜单状态
    initMenu();
    // 下一视图?
    if (!runningTasksHaveBeenCanceled) {
      mainActivity.navigateToView(IMainActivity.VUE_AGENDA, ISession.Action.SUBMIT);
      return;
    }
    // 已取消 - 预约已添加?
    if (rdvAjouté) {
      // 正在修改本地日程表(未获取全局日程表)
      AgendaMedecinJour agenda = session.getAgenda();
      agenda.getCreneauxMedecinJour()[session.getPosition()].setRv(rv);
      // 显示日程表
      mainActivity.navigateToView(IMainActivity.VUE_AGENDA, ISession.Action.SUBMIT);
      return;
    }
  }

  // 私有方法 -------------------
  private void initMenu() {
    // 菜单状态
    setAllMenuOptionsStates(true);
    setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.actionAnnuler, false)});
  }

  • 第 2-10 行:当父类发出请求时,片段会保存以下元素的状态:
    • 第 6 行:视图顶部的标题;
    • 第 7 行:客户端旋转按钮中选中项的位置;
    • 第 8 行:客户下拉列表的数据源;
  • 第12-15行:片段编号为[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 行:对于首次访问,显示不包含操作 [Annuler] 的菜单(第 107-111 行);
  • 第51-70行:当通过操作[SUBMIT]进入该片段时执行。此时来自视图[AGENDA];
    • 第 54 行:获取将要安排约会的时段编号;
    • 第56-59行:获取添加该预约所需的[Medecin]实体和[Creneau]实体,并将它们放入片段的字段中;
    • 第61-65行:利用这些信息,可以构建视图的标题;
    • 第 67 行:将客户下拉列表的焦点定位到其第一个选项;
    • 第 69 行:菜单被置于初始状态(不包含 [Annuler] 选项);
  • 第 72-80 行:当通过 [NAVIGATION] 或 [RESTORE] 操作进入片段时执行;
    • 第 77 行:重新生成视图标题;
    • 第 79 行:将客户下拉列表重置为最后选中的客户;
  • 第 82-84 行:当所有之前的更新完成后执行。此处无需执行其他操作;
  • 第 86-104 行:在所有异步任务完成后执行;
    • 第 89 行:将菜单恢复为默认状态;
    • 第 91-94 行:如果任务正常完成,则通过 [SUBMIT] 返回视图 [AGENDA](此处也可以是 NAVIGATION 类型的操作);
    • 第 96-103 行:如果任务因取消而结束,仍需检查约会是否已添加(这意味着获取新日程表的操作失败);
    • 第98-99行:如果已添加该约会;
      • 第98-99行:将服务器返回的约会添加到当前日历中,即当前会话中的日历;
      • 第101行:通过[SUBMIT]操作返回[AGENDA]视图(此处也可采用NAVIGATION类型的操作);

3.7. Exécution

请执行以下测试:

  • 在正常条件下使用应用程序并验证其运行情况;
  • 旋转设备以查看各个视图,并确认每个视图均能正确恢复;
  • 在 [IMainActivity] 中设置几秒钟的等待时间;
  • 随后取消任务,并验证结果是否符合预期;
  • 在等待期间旋转设备,并验证任务是否被正确取消且未发生崩溃;
  • 在 [IMainActivity] 中更改片段的邻接关系,并验证应用程序是否仍能正常运行;